// Copyright 2014 The Gogs Authors. All rights reserved. // Copyright 2017 The Gitea Authors. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package models import ( "bytes" "errors" "fmt" "html/template" "io/ioutil" "os" "os/exec" "path" "path/filepath" "regexp" "sort" "strings" "time" "code.gitea.io/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/options" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/sync" api "code.gitea.io/sdk/gitea" "github.com/Unknwon/cae/zip" "github.com/Unknwon/com" "github.com/go-xorm/xorm" "github.com/mcuadros/go-version" "gopkg.in/ini.v1" ) const ( tplUpdateHook = "#!/usr/bin/env %s\n%s update $1 $2 $3 --config='%s'\n" ) var repoWorkingPool = sync.NewExclusivePool() var ( // ErrRepoFileNotExist repository file does not exist error ErrRepoFileNotExist = errors.New("Repository file does not exist") // ErrRepoFileNotLoaded repository file not loaded error ErrRepoFileNotLoaded = errors.New("Repository file not loaded") // ErrMirrorNotExist mirror does not exist error ErrMirrorNotExist = errors.New("Mirror does not exist") // ErrInvalidReference invalid reference specified error ErrInvalidReference = errors.New("Invalid reference specified") // ErrNameEmpty name is empty error ErrNameEmpty = errors.New("Name is empty") ) var ( // Gitignores contains the gitiginore files Gitignores []string // Licenses contains the license files Licenses []string // Readmes contains the readme files Readmes []string // LabelTemplates contains the label template files LabelTemplates []string // ItemsPerPage maximum items per page in forks, watchers and stars of a repo ItemsPerPage = 40 ) // LoadRepoConfig loads the repository config func LoadRepoConfig() { // Load .gitignore and license files and readme templates. types := []string{"gitignore", "license", "readme", "label"} typeFiles := make([][]string, 4) for i, t := range types { files, err := options.Dir(t) if err != nil { log.Fatal(4, "Failed to get %s files: %v", t, err) } customPath := path.Join(setting.CustomPath, "options", t) if com.IsDir(customPath) { customFiles, err := com.StatDir(customPath) if err != nil { log.Fatal(4, "Failed to get custom %s files: %v", t, err) } for _, f := range customFiles { if !com.IsSliceContainsStr(files, f) { files = append(files, f) } } } typeFiles[i] = files } Gitignores = typeFiles[0] Licenses = typeFiles[1] Readmes = typeFiles[2] LabelTemplates = typeFiles[3] sort.Strings(Gitignores) sort.Strings(Licenses) sort.Strings(Readmes) sort.Strings(LabelTemplates) // Filter out invalid names and promote preferred licenses. sortedLicenses := make([]string, 0, len(Licenses)) for _, name := range setting.Repository.PreferredLicenses { if com.IsSliceContainsStr(Licenses, name) { sortedLicenses = append(sortedLicenses, name) } } for _, name := range Licenses { if !com.IsSliceContainsStr(setting.Repository.PreferredLicenses, name) { sortedLicenses = append(sortedLicenses, name) } } Licenses = sortedLicenses } // NewRepoContext creates a new repository context func NewRepoContext() { zip.Verbose = false // Check Git installation. if _, err := exec.LookPath("git"); err != nil { log.Fatal(4, "Failed to test 'git' command: %v (forgotten install?)", err) } // Check Git version. var err error setting.Git.Version, err = git.BinVersion() if err != nil { log.Fatal(4, "Failed to get Git version: %v", err) } log.Info("Git Version: %s", setting.Git.Version) if version.Compare("1.7.1", setting.Git.Version, ">") { log.Fatal(4, "Gitea requires Git version greater or equal to 1.7.1") } // Git requires setting user.name and user.email in order to commit changes. for configKey, defaultValue := range map[string]string{"user.name": "Gitea", "user.email": "gitea@fake.local"} { if stdout, stderr, err := process.GetManager().Exec("NewRepoContext(get setting)", "git", "config", "--get", configKey); err != nil || strings.TrimSpace(stdout) == "" { // ExitError indicates this config is not set if _, ok := err.(*exec.ExitError); ok || strings.TrimSpace(stdout) == "" { if _, stderr, gerr := process.GetManager().Exec("NewRepoContext(set "+configKey+")", "git", "config", "--global", configKey, defaultValue); gerr != nil { log.Fatal(4, "Failed to set git %s(%s): %s", configKey, gerr, stderr) } log.Info("Git config %s set to %s", configKey, defaultValue) } else { log.Fatal(4, "Failed to get git %s(%s): %s", configKey, err, stderr) } } } // Set git some configurations. if _, stderr, err := process.GetManager().Exec("NewRepoContext(git config --global core.quotepath false)", "git", "config", "--global", "core.quotepath", "false"); err != nil { log.Fatal(4, "Failed to execute 'git config --global core.quotepath false': %s", stderr) } RemoveAllWithNotice("Clean up repository temporary data", filepath.Join(setting.AppDataPath, "tmp")) } // Repository represents a git repository. type Repository struct { ID int64 `xorm:"pk autoincr"` OwnerID int64 `xorm:"UNIQUE(s)"` Owner *User `xorm:"-"` LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"` Name string `xorm:"INDEX NOT NULL"` Description string Website string DefaultBranch string NumWatches int NumStars int NumForks int NumIssues int NumClosedIssues int NumOpenIssues int `xorm:"-"` NumPulls int NumClosedPulls int NumOpenPulls int `xorm:"-"` NumMilestones int `xorm:"NOT NULL DEFAULT 0"` NumClosedMilestones int `xorm:"NOT NULL DEFAULT 0"` NumOpenMilestones int `xorm:"-"` NumReleases int `xorm:"-"` IsPrivate bool `xorm:"INDEX"` IsBare bool `xorm:"INDEX"` IsMirror bool `xorm:"INDEX"` *Mirror `xorm:"-"` ExternalMetas map[string]string `xorm:"-"` Units []*RepoUnit `xorm:"-"` IsFork bool `xorm:"INDEX NOT NULL DEFAULT false"` ForkID int64 `xorm:"INDEX"` BaseRepo *Repository `xorm:"-"` Size int64 `xorm:"NOT NULL DEFAULT 0"` IndexerStatus *RepoIndexerStatus `xorm:"-"` Created time.Time `xorm:"-"` CreatedUnix int64 `xorm:"INDEX created"` Updated time.Time `xorm:"-"` UpdatedUnix int64 `xorm:"INDEX updated"` } // AfterLoad is invoked from XORM after setting the values of all fields of this object. func (repo *Repository) AfterLoad() { // FIXME: use models migration to solve all at once. if len(repo.DefaultBranch) == 0 { repo.DefaultBranch = "master" } repo.NumOpenIssues = repo.NumIssues - repo.NumClosedIssues repo.NumOpenPulls = repo.NumPulls - repo.NumClosedPulls repo.NumOpenMilestones = repo.NumMilestones - repo.NumClosedMilestones repo.Created = time.Unix(repo.CreatedUnix, 0).Local() repo.Updated = time.Unix(repo.UpdatedUnix, 0) } // MustOwner always returns a valid *User object to avoid // conceptually impossible error handling. // It creates a fake object that contains error details // when error occurs. func (repo *Repository) MustOwner() *User { return repo.mustOwner(x) } // FullName returns the repository full name func (repo *Repository) FullName() string { return repo.MustOwner().Name + "/" + repo.Name } // HTMLURL returns the repository HTML URL func (repo *Repository) HTMLURL() string { return setting.AppURL + repo.FullName() } // APIURL returns the repository API URL func (repo *Repository) APIURL() string { return setting.AppURL + path.Join("api/v1/repos", repo.FullName()) } // APIFormat converts a Repository to api.Repository func (repo *Repository) APIFormat(mode AccessMode) *api.Repository { return repo.innerAPIFormat(mode, false) } // GetCommitsCountCacheKey returns cache key used for commits count caching. func (repo *Repository) GetCommitsCountCacheKey(contextName string, isRef bool) string { var prefix string if isRef { prefix = "ref" } else { prefix = "commit" } return fmt.Sprintf("commits-count-%d-%s-%s", repo.ID, prefix, contextName) } func (repo *Repository) innerAPIFormat(mode AccessMode, isParent bool) *api.Repository { var parent *api.Repository cloneLink := repo.CloneLink() permission := &api.Permission{ Admin: mode >= AccessModeAdmin, Push: mode >= AccessModeWrite, Pull: mode >= AccessModeRead, } if !isParent { err := repo.GetBaseRepo() if err != nil { log.Error(4, "APIFormat: %v", err) } if repo.BaseRepo != nil { parent = repo.BaseRepo.innerAPIFormat(mode, true) } } return &api.Repository{ ID: repo.ID, Owner: repo.Owner.APIFormat(), Name: repo.Name, FullName: repo.FullName(), Description: repo.Description, Private: repo.IsPrivate, Empty: repo.IsBare, Size: int(repo.Size / 1024), Fork: repo.IsFork, Parent: parent, Mirror: repo.IsMirror, HTMLURL: repo.HTMLURL(), SSHURL: cloneLink.SSH, CloneURL: cloneLink.HTTPS, Website: repo.Website, Stars: repo.NumStars, Forks: repo.NumForks, Watchers: repo.NumWatches, OpenIssues: repo.NumOpenIssues, DefaultBranch: repo.DefaultBranch, Created: repo.Created, Updated: repo.Updated, Permissions: permission, } } func (repo *Repository) getUnits(e Engine) (err error) { if repo.Units != nil { return nil } repo.Units, err = getUnitsByRepoID(e, repo.ID) return err } // CheckUnitUser check whether user could visit the unit of this repository func (repo *Repository) CheckUnitUser(userID int64, isAdmin bool, unitType UnitType) bool { if err := repo.getUnitsByUserID(x, userID, isAdmin); err != nil { return false } for _, unit := range repo.Units { if unit.Type == unitType { return true } } return false } // LoadUnitsByUserID loads units according userID's permissions func (repo *Repository) LoadUnitsByUserID(userID int64, isAdmin bool) error { return repo.getUnitsByUserID(x, userID, isAdmin) } func (repo *Repository) getUnitsByUserID(e Engine, userID int64, isAdmin bool) (err error) { if repo.Units != nil { return nil } if err = repo.getUnits(e); err != nil { return err } else if err = repo.getOwner(e); err != nil { return err } if !repo.Owner.IsOrganization() || userID == 0 || isAdmin || !repo.IsPrivate { return nil } // Collaborators will not be limited if isCollaborator, err := repo.isCollaborator(e, userID); err != nil { return err } else if isCollaborator { return nil } teams, err := getUserTeams(e, repo.OwnerID, userID) if err != nil { return err } var allTypes = make(map[UnitType]struct{}, len(allRepUnitTypes)) for _, team := range teams { // Administrators can not be limited if team.Authorize >= AccessModeAdmin { return nil } for _, unitType := range team.UnitTypes { allTypes[unitType] = struct{}{} } } // unique var newRepoUnits = make([]*RepoUnit, 0, len(repo.Units)) for _, u := range repo.Units { if _, ok := allTypes[u.Type]; ok { newRepoUnits = append(newRepoUnits, u) } } repo.Units = newRepoUnits return nil } // UnitEnabled if this repository has the given unit enabled func (repo *Repository) UnitEnabled(tp UnitType) bool { if err := repo.getUnits(x); err != nil { log.Warn("Error loading repository (ID: %d) units: %s", repo.ID, err.Error()) } for _, unit := range repo.Units { if unit.Type == tp { return true } } return false } // AnyUnitEnabled if this repository has the any of the given units enabled func (repo *Repository) AnyUnitEnabled(tps ...UnitType) bool { if err := repo.getUnits(x); err != nil { log.Warn("Error loading repository (ID: %d) units: %s", repo.ID, err.Error()) } for _, unit := range repo.Units { for _, tp := range tps { if unit.Type == tp { return true } } } return false } var ( // ErrUnitNotExist organization does not exist ErrUnitNotExist = errors.New("Unit does not exist") ) // MustGetUnit always returns a RepoUnit object func (repo *Repository) MustGetUnit(tp UnitType) *RepoUnit { ru, err := repo.GetUnit(tp) if err == nil { return ru } if tp == UnitTypeExternalWiki { return &RepoUnit{ Type: tp, Config: new(ExternalWikiConfig), } } else if tp == UnitTypeExternalTracker { return &RepoUnit{ Type: tp, Config: new(ExternalTrackerConfig), } } return &RepoUnit{ Type: tp, Config: new(UnitConfig), } } // GetUnit returns a RepoUnit object func (repo *Repository) GetUnit(tp UnitType) (*RepoUnit, error) { if err := repo.getUnits(x); err != nil { return nil, err } for _, unit := range repo.Units { if unit.Type == tp { return unit, nil } } return nil, ErrUnitNotExist } func (repo *Repository) getOwner(e Engine) (err error) { if repo.Owner != nil { return nil } repo.Owner, err = getUserByID(e, repo.OwnerID) return err } // GetOwner returns the repository owner func (repo *Repository) GetOwner() error { return repo.getOwner(x) } func (repo *Repository) mustOwner(e Engine) *User { if err := repo.getOwner(e); err != nil { return &User{ Name: "error", FullName: err.Error(), } } return repo.Owner } // ComposeMetas composes a map of metas for rendering external issue tracker URL. func (repo *Repository) ComposeMetas() map[string]string { unit, err := repo.GetUnit(UnitTypeExternalTracker) if err != nil { return nil } if repo.ExternalMetas == nil { repo.ExternalMetas = map[string]string{ "format": unit.ExternalTrackerConfig().ExternalTrackerFormat, "user": repo.MustOwner().Name, "repo": repo.Name, } switch unit.ExternalTrackerConfig().ExternalTrackerStyle { case markup.IssueNameStyleAlphanumeric: repo.ExternalMetas["style"] = markup.IssueNameStyleAlphanumeric default: repo.ExternalMetas["style"] = markup.IssueNameStyleNumeric } } return repo.ExternalMetas } // DeleteWiki removes the actual and local copy of repository wiki. func (repo *Repository) DeleteWiki() error { return repo.deleteWiki(x) } func (repo *Repository) deleteWiki(e Engine) error { wikiPaths := []string{repo.WikiPath(), repo.LocalWikiPath()} for _, wikiPath := range wikiPaths { removeAllWithNotice(e, "Delete repository wiki", wikiPath) } _, err := e.Where("repo_id = ?", repo.ID).And("type = ?", UnitTypeWiki).Delete(new(RepoUnit)) return err } func (repo *Repository) getAssignees(e Engine) (_ []*User, err error) { if err = repo.getOwner(e); err != nil { return nil, err } accesses := make([]*Access, 0, 10) if err = e. Where("repo_id = ? AND mode >= ?", repo.ID, AccessModeWrite). Find(&accesses); err != nil { return nil, err } // Leave a seat for owner itself to append later, but if owner is an organization // and just waste 1 unit is cheaper than re-allocate memory once. users := make([]*User, 0, len(accesses)+1) if len(accesses) > 0 { userIDs := make([]int64, len(accesses)) for i := 0; i < len(accesses); i++ { userIDs[i] = accesses[i].UserID } if err = e.In("id", userIDs).Find(&users); err != nil { return nil, err } } if !repo.Owner.IsOrganization() { users = append(users, repo.Owner) } return users, nil } // GetAssignees returns all users that have write access and can be assigned to issues // of the repository, func (repo *Repository) GetAssignees() (_ []*User, err error) { return repo.getAssignees(x) } // GetAssigneeByID returns the user that has write access of repository by given ID. func (repo *Repository) GetAssigneeByID(userID int64) (*User, error) { return GetAssigneeByID(repo, userID) } // GetMilestoneByID returns the milestone belongs to repository by given ID. func (repo *Repository) GetMilestoneByID(milestoneID int64) (*Milestone, error) { return GetMilestoneByRepoID(repo.ID, milestoneID) } // IssueStats returns number of open and closed repository issues by given filter mode. func (repo *Repository) IssueStats(uid int64, filterMode int, isPull bool) (int64, int64) { return GetRepoIssueStats(repo.ID, uid, filterMode, isPull) } // GetMirror sets the repository mirror, returns an error upon failure func (repo *Repository) GetMirror() (err error) { repo.Mirror, err = GetMirrorByRepoID(repo.ID) return err } // GetBaseRepo returns the base repository func (repo *Repository) GetBaseRepo() (err error) { if !repo.IsFork { return nil } repo.BaseRepo, err = GetRepositoryByID(repo.ForkID) return err } func (repo *Repository) repoPath(e Engine) string { return RepoPath(repo.mustOwner(e).Name, repo.Name) } // RepoPath returns the repository path func (repo *Repository) RepoPath() string { return repo.repoPath(x) } // GitConfigPath returns the path to a repository's git config/ directory func GitConfigPath(repoPath string) string { return filepath.Join(repoPath, "config") } // GitConfigPath returns the repository git config path func (repo *Repository) GitConfigPath() string { return GitConfigPath(repo.RepoPath()) } // RelLink returns the repository relative link func (repo *Repository) RelLink() string { return "/" + repo.FullName() } // Link returns the repository link func (repo *Repository) Link() string { return setting.AppSubURL + "/" + repo.FullName() } // ComposeCompareURL returns the repository comparison URL func (repo *Repository) ComposeCompareURL(oldCommitID, newCommitID string) string { return fmt.Sprintf("%s/%s/compare/%s...%s", repo.MustOwner().Name, repo.Name, oldCommitID, newCommitID) } // HasAccess returns true when user has access to this repository func (repo *Repository) HasAccess(u *User) bool { has, _ := HasAccess(u.ID, repo, AccessModeRead) return has } // UpdateDefaultBranch updates the default branch func (repo *Repository) UpdateDefaultBranch() error { _, err := x.ID(repo.ID).Cols("default_branch").Update(repo) return err } // IsOwnedBy returns true when user owns this repository func (repo *Repository) IsOwnedBy(userID int64) bool { return repo.OwnerID == userID } func (repo *Repository) updateSize(e Engine) error { repoInfoSize, err := git.GetRepoSize(repo.RepoPath()) if err != nil { return fmt.Errorf("UpdateSize: %v", err) } repo.Size = repoInfoSize.Size + repoInfoSize.SizePack _, err = e.ID(repo.ID).Cols("size").Update(repo) return err } // UpdateSize updates the repository size, calculating it using git.GetRepoSize func (repo *Repository) UpdateSize() error { return repo.updateSize(x) } // CanBeForked returns true if repository meets the requirements of being forked. func (repo *Repository) CanBeForked() bool { return !repo.IsBare && repo.UnitEnabled(UnitTypeCode) } // CanUserFork returns true if specified user can fork repository. func (repo *Repository) CanUserFork(user *User) (bool, error) { if user == nil { return false, nil } if repo.OwnerID != user.ID && !user.HasForkedRepo(repo.ID) { return true, nil } if err := user.GetOwnedOrganizations(); err != nil { return false, err } for _, org := range user.OwnedOrgs { if repo.OwnerID != org.ID && !org.HasForkedRepo(repo.ID) { return true, nil } } return false, nil } // CanEnablePulls returns true if repository meets the requirements of accepting pulls. func (repo *Repository) CanEnablePulls() bool { return !repo.IsMirror && !repo.IsBare } // AllowsPulls returns true if repository meets the requirements of accepting pulls and has them enabled. func (repo *Repository) AllowsPulls() bool { return repo.CanEnablePulls() && repo.UnitEnabled(UnitTypePullRequests) } // CanEnableEditor returns true if repository meets the requirements of web editor. func (repo *Repository) CanEnableEditor() bool { return !repo.IsMirror } // GetWriters returns all users that have write access to the repository. func (repo *Repository) GetWriters() (_ []*User, err error) { return repo.getUsersWithAccessMode(x, AccessModeWrite) } // getUsersWithAccessMode returns users that have at least given access mode to the repository. func (repo *Repository) getUsersWithAccessMode(e Engine, mode AccessMode) (_ []*User, err error) { if err = repo.getOwner(e); err != nil { return nil, err } accesses := make([]*Access, 0, 10) if err = e.Where("repo_id = ? AND mode >= ?", repo.ID, mode).Find(&accesses); err != nil { return nil, err } // Leave a seat for owner itself to append later, but if owner is an organization // and just waste 1 unit is cheaper than re-allocate memory once. users := make([]*User, 0, len(accesses)+1) if len(accesses) > 0 { userIDs := make([]int64, len(accesses)) for i := 0; i < len(accesses); i++ { userIDs[i] = accesses[i].UserID } if err = e.In("id", userIDs).Find(&users); err != nil { return nil, err } } if !repo.Owner.IsOrganization() { users = append(users, repo.Owner) } return users, nil } // NextIssueIndex returns the next issue index // FIXME: should have a mutex to prevent producing same index for two issues that are created // closely enough. func (repo *Repository) NextIssueIndex() int64 { return int64(repo.NumIssues+repo.NumPulls) + 1 } var ( descPattern = regexp.MustCompile(`https?://\S+`) ) // DescriptionHTML does special handles to description and return HTML string. func (repo *Repository) DescriptionHTML() template.HTML { sanitize := func(s string) string { return fmt.Sprintf(`<a href="%[1]s" target="_blank" rel="noopener">%[1]s</a>`, s) } return template.HTML(descPattern.ReplaceAllStringFunc(markup.Sanitize(repo.Description), sanitize)) } // LocalCopyPath returns the local repository copy path func (repo *Repository) LocalCopyPath() string { if filepath.IsAbs(setting.Repository.Local.LocalCopyPath) { return path.Join(setting.Repository.Local.LocalCopyPath, com.ToStr(repo.ID)) } return path.Join(setting.AppDataPath, setting.Repository.Local.LocalCopyPath, com.ToStr(repo.ID)) } // UpdateLocalCopyBranch pulls latest changes of given branch from repoPath to localPath. // It creates a new clone if local copy does not exist. // This function checks out target branch by default, it is safe to assume subsequent // operations are operating against target branch when caller has confidence for no race condition. func UpdateLocalCopyBranch(repoPath, localPath, branch string) error { if !com.IsExist(localPath) { if err := git.Clone(repoPath, localPath, git.CloneRepoOptions{ Timeout: time.Duration(setting.Git.Timeout.Clone) * time.Second, Branch: branch, }); err != nil { return fmt.Errorf("git clone %s: %v", branch, err) } } else { if err := git.Checkout(localPath, git.CheckoutOptions{ Branch: branch, }); err != nil { return fmt.Errorf("git checkout %s: %v", branch, err) } _, err := git.NewCommand("fetch", "origin").RunInDir(localPath) if err != nil { return fmt.Errorf("git fetch origin: %v", err) } if len(branch) > 0 { if err := git.ResetHEAD(localPath, true, "origin/"+branch); err != nil { return fmt.Errorf("git reset --hard origin/%s: %v", branch, err) } } } return nil } // UpdateLocalCopyBranch makes sure local copy of repository in given branch is up-to-date. func (repo *Repository) UpdateLocalCopyBranch(branch string) error { return UpdateLocalCopyBranch(repo.RepoPath(), repo.LocalCopyPath(), branch) } // PatchPath returns corresponding patch file path of repository by given issue ID. func (repo *Repository) PatchPath(index int64) (string, error) { if err := repo.GetOwner(); err != nil { return "", err } return filepath.Join(RepoPath(repo.Owner.Name, repo.Name), "pulls", com.ToStr(index)+".patch"), nil } // SavePatch saves patch data to corresponding location by given issue ID. func (repo *Repository) SavePatch(index int64, patch []byte) error { patchPath, err := repo.PatchPath(index) if err != nil { return fmt.Errorf("PatchPath: %v", err) } dir := filepath.Dir(patchPath) if err := os.MkdirAll(dir, os.ModePerm); err != nil { return fmt.Errorf("Failed to create dir %s: %v", dir, err) } if err = ioutil.WriteFile(patchPath, patch, 0644); err != nil { return fmt.Errorf("WriteFile: %v", err) } return nil } func isRepositoryExist(e Engine, u *User, repoName string) (bool, error) { has, err := e.Get(&Repository{ OwnerID: u.ID, LowerName: strings.ToLower(repoName), }) return has && com.IsDir(RepoPath(u.Name, repoName)), err } // IsRepositoryExist returns true if the repository with given name under user has already existed. func IsRepositoryExist(u *User, repoName string) (bool, error) { return isRepositoryExist(x, u, repoName) } // CloneLink represents different types of clone URLs of repository. type CloneLink struct { SSH string HTTPS string Git string } // ComposeHTTPSCloneURL returns HTTPS clone URL based on given owner and repository name. func ComposeHTTPSCloneURL(owner, repo string) string { return fmt.Sprintf("%s%s/%s.git", setting.AppURL, owner, repo) } func (repo *Repository) cloneLink(isWiki bool) *CloneLink { repoName := repo.Name if isWiki { repoName += ".wiki" } sshUser := setting.RunUser if setting.SSH.StartBuiltinServer { sshUser = setting.SSH.BuiltinServerUser } repo.Owner = repo.MustOwner() cl := new(CloneLink) if setting.SSH.Port != 22 { cl.SSH = fmt.Sprintf("ssh://%s@%s:%d/%s/%s.git", sshUser, setting.SSH.Domain, setting.SSH.Port, repo.Owner.Name, repoName) } else if setting.Repository.UseCompatSSHURI { cl.SSH = fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, setting.SSH.Domain, repo.Owner.Name, repoName) } else { cl.SSH = fmt.Sprintf("%s@%s:%s/%s.git", sshUser, setting.SSH.Domain, repo.Owner.Name, repoName) } cl.HTTPS = ComposeHTTPSCloneURL(repo.Owner.Name, repoName) return cl } // CloneLink returns clone URLs of repository. func (repo *Repository) CloneLink() (cl *CloneLink) { return repo.cloneLink(false) } // MigrateRepoOptions contains the repository migrate options type MigrateRepoOptions struct { Name string Description string IsPrivate bool IsMirror bool RemoteAddr string } /* GitHub, GitLab, Gogs: *.wiki.git BitBucket: *.git/wiki */ var commonWikiURLSuffixes = []string{".wiki.git", ".git/wiki"} // wikiRemoteURL returns accessible repository URL for wiki if exists. // Otherwise, it returns an empty string. func wikiRemoteURL(remote string) string { remote = strings.TrimSuffix(remote, ".git") for _, suffix := range commonWikiURLSuffixes { wikiURL := remote + suffix if git.IsRepoURLAccessible(wikiURL) { return wikiURL } } return "" } // MigrateRepository migrates a existing repository from other project hosting. func MigrateRepository(doer, u *User, opts MigrateRepoOptions) (*Repository, error) { repo, err := CreateRepository(doer, u, CreateRepoOptions{ Name: opts.Name, Description: opts.Description, IsPrivate: opts.IsPrivate, IsMirror: opts.IsMirror, }) if err != nil { return nil, err } repoPath := RepoPath(u.Name, opts.Name) wikiPath := WikiPath(u.Name, opts.Name) if u.IsOrganization() { t, err := u.GetOwnerTeam() if err != nil { return nil, err } repo.NumWatches = t.NumMembers } else { repo.NumWatches = 1 } migrateTimeout := time.Duration(setting.Git.Timeout.Migrate) * time.Second if err := os.RemoveAll(repoPath); err != nil { return repo, fmt.Errorf("Failed to remove %s: %v", repoPath, err) } if err = git.Clone(opts.RemoteAddr, repoPath, git.CloneRepoOptions{ Mirror: true, Quiet: true, Timeout: migrateTimeout, }); err != nil { return repo, fmt.Errorf("Clone: %v", err) } wikiRemotePath := wikiRemoteURL(opts.RemoteAddr) if len(wikiRemotePath) > 0 { if err := os.RemoveAll(wikiPath); err != nil { return repo, fmt.Errorf("Failed to remove %s: %v", wikiPath, err) } if err = git.Clone(wikiRemotePath, wikiPath, git.CloneRepoOptions{ Mirror: true, Quiet: true, Timeout: migrateTimeout, Branch: "master", }); err != nil { log.Warn("Clone wiki: %v", err) if err := os.RemoveAll(wikiPath); err != nil { return repo, fmt.Errorf("Failed to remove %s: %v", wikiPath, err) } } } // Check if repository is empty. _, stderr, err := com.ExecCmdDir(repoPath, "git", "log", "-1") if err != nil { if strings.Contains(stderr, "fatal: bad default revision 'HEAD'") { repo.IsBare = true } else { return repo, fmt.Errorf("check bare: %v - %s", err, stderr) } } if !repo.IsBare { // Try to get HEAD branch and set it as default branch. gitRepo, err := git.OpenRepository(repoPath) if err != nil { return repo, fmt.Errorf("OpenRepository: %v", err) } headBranch, err := gitRepo.GetHEADBranch() if err != nil { return repo, fmt.Errorf("GetHEADBranch: %v", err) } if headBranch != nil { repo.DefaultBranch = headBranch.Name } if err = SyncReleasesWithTags(repo, gitRepo); err != nil { log.Error(4, "Failed to synchronize tags to releases for repository: %v", err) } UpdateRepoIndexer(repo) } if err = repo.UpdateSize(); err != nil { log.Error(4, "Failed to update size for repository: %v", err) } if opts.IsMirror { if _, err = x.InsertOne(&Mirror{ RepoID: repo.ID, Interval: setting.Mirror.DefaultInterval, EnablePrune: true, NextUpdate: time.Now().Add(setting.Mirror.DefaultInterval), }); err != nil { return repo, fmt.Errorf("InsertOne: %v", err) } repo.IsMirror = true return repo, UpdateRepository(repo, false) } return CleanUpMigrateInfo(repo) } // cleanUpMigrateGitConfig removes mirror info which prevents "push --all". // This also removes possible user credentials. func cleanUpMigrateGitConfig(configPath string) error { cfg, err := ini.Load(configPath) if err != nil { return fmt.Errorf("open config file: %v", err) } cfg.DeleteSection("remote \"origin\"") if err = cfg.SaveToIndent(configPath, "\t"); err != nil { return fmt.Errorf("save config file: %v", err) } return nil } // createDelegateHooks creates all the hooks scripts for the repo func createDelegateHooks(repoPath string) (err error) { var ( hookNames = []string{"pre-receive", "update", "post-receive"} hookTpls = []string{ fmt.Sprintf("#!/usr/bin/env %s\ndata=$(cat)\nexitcodes=\"\"\nhookname=$(basename $0)\nGIT_DIR=${GIT_DIR:-$(dirname $0)}\n\nfor hook in ${GIT_DIR}/hooks/${hookname}.d/*; do\ntest -x \"${hook}\" || continue\necho \"${data}\" | \"${hook}\"\nexitcodes=\"${exitcodes} $?\"\ndone\n\nfor i in ${exitcodes}; do\n[ ${i} -eq 0 ] || exit ${i}\ndone\n", setting.ScriptType), fmt.Sprintf("#!/usr/bin/env %s\nexitcodes=\"\"\nhookname=$(basename $0)\nGIT_DIR=${GIT_DIR:-$(dirname $0)}\n\nfor hook in ${GIT_DIR}/hooks/${hookname}.d/*; do\ntest -x \"${hook}\" || continue\n\"${hook}\" $1 $2 $3\nexitcodes=\"${exitcodes} $?\"\ndone\n\nfor i in ${exitcodes}; do\n[ ${i} -eq 0 ] || exit ${i}\ndone\n", setting.ScriptType), fmt.Sprintf("#!/usr/bin/env %s\ndata=$(cat)\nexitcodes=\"\"\nhookname=$(basename $0)\nGIT_DIR=${GIT_DIR:-$(dirname $0)}\n\nfor hook in ${GIT_DIR}/hooks/${hookname}.d/*; do\ntest -x \"${hook}\" || continue\necho \"${data}\" | \"${hook}\"\nexitcodes=\"${exitcodes} $?\"\ndone\n\nfor i in ${exitcodes}; do\n[ ${i} -eq 0 ] || exit ${i}\ndone\n", setting.ScriptType), } giteaHookTpls = []string{ fmt.Sprintf("#!/usr/bin/env %s\n\"%s\" hook --config='%s' pre-receive\n", setting.ScriptType, setting.AppPath, setting.CustomConf), fmt.Sprintf("#!/usr/bin/env %s\n\"%s\" hook --config='%s' update $1 $2 $3\n", setting.ScriptType, setting.AppPath, setting.CustomConf), fmt.Sprintf("#!/usr/bin/env %s\n\"%s\" hook --config='%s' post-receive\n", setting.ScriptType, setting.AppPath, setting.CustomConf), } ) hookDir := filepath.Join(repoPath, "hooks") for i, hookName := range hookNames { oldHookPath := filepath.Join(hookDir, hookName) newHookPath := filepath.Join(hookDir, hookName+".d", "gitea") if err := os.MkdirAll(filepath.Join(hookDir, hookName+".d"), os.ModePerm); err != nil { return fmt.Errorf("create hooks dir '%s': %v", filepath.Join(hookDir, hookName+".d"), err) } // WARNING: This will override all old server-side hooks if err = ioutil.WriteFile(oldHookPath, []byte(hookTpls[i]), 0777); err != nil { return fmt.Errorf("write old hook file '%s': %v", oldHookPath, err) } if err = ioutil.WriteFile(newHookPath, []byte(giteaHookTpls[i]), 0777); err != nil { return fmt.Errorf("write new hook file '%s': %v", newHookPath, err) } } return nil } // CleanUpMigrateInfo finishes migrating repository and/or wiki with things that don't need to be done for mirrors. func CleanUpMigrateInfo(repo *Repository) (*Repository, error) { repoPath := repo.RepoPath() if err := createDelegateHooks(repoPath); err != nil { return repo, fmt.Errorf("createDelegateHooks: %v", err) } if repo.HasWiki() { if err := createDelegateHooks(repo.WikiPath()); err != nil { return repo, fmt.Errorf("createDelegateHooks.(wiki): %v", err) } } if err := cleanUpMigrateGitConfig(repo.GitConfigPath()); err != nil { return repo, fmt.Errorf("cleanUpMigrateGitConfig: %v", err) } if repo.HasWiki() { if err := cleanUpMigrateGitConfig(path.Join(repo.WikiPath(), "config")); err != nil { return repo, fmt.Errorf("cleanUpMigrateGitConfig (wiki): %v", err) } } return repo, UpdateRepository(repo, false) } // initRepoCommit temporarily changes with work directory. func initRepoCommit(tmpPath string, sig *git.Signature) (err error) { var stderr string if _, stderr, err = process.GetManager().ExecDir(-1, tmpPath, fmt.Sprintf("initRepoCommit (git add): %s", tmpPath), "git", "add", "--all"); err != nil { return fmt.Errorf("git add: %s", stderr) } if _, stderr, err = process.GetManager().ExecDir(-1, tmpPath, fmt.Sprintf("initRepoCommit (git commit): %s", tmpPath), "git", "commit", fmt.Sprintf("--author='%s <%s>'", sig.Name, sig.Email), "-m", "Initial commit"); err != nil { return fmt.Errorf("git commit: %s", stderr) } if _, stderr, err = process.GetManager().ExecDir(-1, tmpPath, fmt.Sprintf("initRepoCommit (git push): %s", tmpPath), "git", "push", "origin", "master"); err != nil { return fmt.Errorf("git push: %s", stderr) } return nil } // CreateRepoOptions contains the create repository options type CreateRepoOptions struct { Name string Description string Gitignores string License string Readme string IsPrivate bool IsMirror bool AutoInit bool } func getRepoInitFile(tp, name string) ([]byte, error) { cleanedName := strings.TrimLeft(name, "./") relPath := path.Join("options", tp, cleanedName) // Use custom file when available. customPath := path.Join(setting.CustomPath, relPath) if com.IsFile(customPath) { return ioutil.ReadFile(customPath) } switch tp { case "readme": return options.Readme(cleanedName) case "gitignore": return options.Gitignore(cleanedName) case "license": return options.License(cleanedName) case "label": return options.Labels(cleanedName) default: return []byte{}, fmt.Errorf("Invalid init file type") } } func prepareRepoCommit(repo *Repository, tmpDir, repoPath string, opts CreateRepoOptions) error { // Clone to temporary path and do the init commit. _, stderr, err := process.GetManager().Exec( fmt.Sprintf("initRepository(git clone): %s", repoPath), "git", "clone", repoPath, tmpDir, ) if err != nil { return fmt.Errorf("git clone: %v - %s", err, stderr) } // README data, err := getRepoInitFile("readme", opts.Readme) if err != nil { return fmt.Errorf("getRepoInitFile[%s]: %v", opts.Readme, err) } cloneLink := repo.CloneLink() match := map[string]string{ "Name": repo.Name, "Description": repo.Description, "CloneURL.SSH": cloneLink.SSH, "CloneURL.HTTPS": cloneLink.HTTPS, } if err = ioutil.WriteFile(filepath.Join(tmpDir, "README.md"), []byte(com.Expand(string(data), match)), 0644); err != nil { return fmt.Errorf("write README.md: %v", err) } // .gitignore if len(opts.Gitignores) > 0 { var buf bytes.Buffer names := strings.Split(opts.Gitignores, ",") for _, name := range names { data, err = getRepoInitFile("gitignore", name) if err != nil { return fmt.Errorf("getRepoInitFile[%s]: %v", name, err) } buf.WriteString("# ---> " + name + "\n") buf.Write(data) buf.WriteString("\n") } if buf.Len() > 0 { if err = ioutil.WriteFile(filepath.Join(tmpDir, ".gitignore"), buf.Bytes(), 0644); err != nil { return fmt.Errorf("write .gitignore: %v", err) } } } // LICENSE if len(opts.License) > 0 { data, err = getRepoInitFile("license", opts.License) if err != nil { return fmt.Errorf("getRepoInitFile[%s]: %v", opts.License, err) } if err = ioutil.WriteFile(filepath.Join(tmpDir, "LICENSE"), data, 0644); err != nil { return fmt.Errorf("write LICENSE: %v", err) } } return nil } // InitRepository initializes README and .gitignore if needed. func initRepository(e Engine, repoPath string, u *User, repo *Repository, opts CreateRepoOptions) (err error) { // Somehow the directory could exist. if com.IsExist(repoPath) { return fmt.Errorf("initRepository: path already exists: %s", repoPath) } // Init bare new repository. if err = git.InitRepository(repoPath, true); err != nil { return fmt.Errorf("InitRepository: %v", err) } else if err = createDelegateHooks(repoPath); err != nil { return fmt.Errorf("createDelegateHooks: %v", err) } tmpDir := filepath.Join(os.TempDir(), "gitea-"+repo.Name+"-"+com.ToStr(time.Now().Nanosecond())) // Initialize repository according to user's choice. if opts.AutoInit { if err := os.MkdirAll(tmpDir, os.ModePerm); err != nil { return fmt.Errorf("Failed to create dir %s: %v", tmpDir, err) } defer os.RemoveAll(tmpDir) if err = prepareRepoCommit(repo, tmpDir, repoPath, opts); err != nil { return fmt.Errorf("prepareRepoCommit: %v", err) } // Apply changes and commit. if err = initRepoCommit(tmpDir, u.NewGitSig()); err != nil { return fmt.Errorf("initRepoCommit: %v", err) } } // Re-fetch the repository from database before updating it (else it would // override changes that were done earlier with sql) if repo, err = getRepositoryByID(e, repo.ID); err != nil { return fmt.Errorf("getRepositoryByID: %v", err) } if !opts.AutoInit { repo.IsBare = true } repo.DefaultBranch = "master" if err = updateRepository(e, repo, false); err != nil { return fmt.Errorf("updateRepository: %v", err) } return nil } var ( reservedRepoNames = []string{".", ".."} reservedRepoPatterns = []string{"*.git", "*.wiki"} ) // IsUsableRepoName returns true when repository is usable func IsUsableRepoName(name string) error { return isUsableName(reservedRepoNames, reservedRepoPatterns, name) } func createRepository(e *xorm.Session, doer, u *User, repo *Repository) (err error) { if err = IsUsableRepoName(repo.Name); err != nil { return err } has, err := isRepositoryExist(e, u, repo.Name) if err != nil { return fmt.Errorf("IsRepositoryExist: %v", err) } else if has { return ErrRepoAlreadyExist{u.Name, repo.Name} } if _, err = e.Insert(repo); err != nil { return err } if err = deleteRepoRedirect(e, u.ID, repo.Name); err != nil { return err } // insert units for repo var units = make([]RepoUnit, 0, len(defaultRepoUnits)) for _, tp := range defaultRepoUnits { if tp == UnitTypeIssues { units = append(units, RepoUnit{ RepoID: repo.ID, Type: tp, Config: &IssuesConfig{EnableTimetracker: setting.Service.DefaultEnableTimetracking, AllowOnlyContributorsToTrackTime: setting.Service.DefaultAllowOnlyContributorsToTrackTime}, }) } else { units = append(units, RepoUnit{ RepoID: repo.ID, Type: tp, }) } } if _, err = e.Insert(&units); err != nil { return err } u.NumRepos++ // Remember visibility preference. u.LastRepoVisibility = repo.IsPrivate if err = updateUser(e, u); err != nil { return fmt.Errorf("updateUser: %v", err) } // Give access to all members in owner team. if u.IsOrganization() { t, err := u.getOwnerTeam(e) if err != nil { return fmt.Errorf("getOwnerTeam: %v", err) } else if err = t.addRepository(e, repo); err != nil { return fmt.Errorf("addRepository: %v", err) } else if err = prepareWebhooks(e, repo, HookEventRepository, &api.RepositoryPayload{ Action: api.HookRepoCreated, Repository: repo.APIFormat(AccessModeOwner), Organization: u.APIFormat(), Sender: doer.APIFormat(), }); err != nil { return fmt.Errorf("prepareWebhooks: %v", err) } go HookQueue.Add(repo.ID) } else { // Organization automatically called this in addRepository method. if err = repo.recalculateAccesses(e); err != nil { return fmt.Errorf("recalculateAccesses: %v", err) } } if err = watchRepo(e, doer.ID, repo.ID, true); err != nil { return fmt.Errorf("watchRepo: %v", err) } else if err = newRepoAction(e, u, repo); err != nil { return fmt.Errorf("newRepoAction: %v", err) } return nil } // CreateRepository creates a repository for the user/organization u. func CreateRepository(doer, u *User, opts CreateRepoOptions) (_ *Repository, err error) { if !u.CanCreateRepo() { return nil, ErrReachLimitOfRepo{u.MaxRepoCreation} } repo := &Repository{ OwnerID: u.ID, Owner: u, Name: opts.Name, LowerName: strings.ToLower(opts.Name), Description: opts.Description, IsPrivate: opts.IsPrivate, } sess := x.NewSession() defer sess.Close() if err = sess.Begin(); err != nil { return nil, err } if err = createRepository(sess, doer, u, repo); err != nil { return nil, err } // No need for init mirror. if !opts.IsMirror { repoPath := RepoPath(u.Name, repo.Name) if err = initRepository(sess, repoPath, u, repo, opts); err != nil { if err2 := os.RemoveAll(repoPath); err2 != nil { log.Error(4, "initRepository: %v", err) return nil, fmt.Errorf( "delete repo directory %s/%s failed(2): %v", u.Name, repo.Name, err2) } return nil, fmt.Errorf("initRepository: %v", err) } _, stderr, err := process.GetManager().ExecDir(-1, repoPath, fmt.Sprintf("CreateRepository(git update-server-info): %s", repoPath), "git", "update-server-info") if err != nil { return nil, errors.New("CreateRepository(git update-server-info): " + stderr) } } return repo, sess.Commit() } func countRepositories(userID int64, private bool) int64 { sess := x.Where("id > 0") if userID > 0 { sess.And("owner_id = ?", userID) } if !private { sess.And("is_private=?", false) } count, err := sess.Count(new(Repository)) if err != nil { log.Error(4, "countRepositories: %v", err) } return count } // CountRepositories returns number of repositories. // Argument private only takes effect when it is false, // set it true to count all repositories. func CountRepositories(private bool) int64 { return countRepositories(-1, private) } // CountUserRepositories returns number of repositories user owns. // Argument private only takes effect when it is false, // set it true to count all repositories. func CountUserRepositories(userID int64, private bool) int64 { return countRepositories(userID, private) } // RepoPath returns repository path by given user and repository name. func RepoPath(userName, repoName string) string { return filepath.Join(UserPath(userName), strings.ToLower(repoName)+".git") } // TransferOwnership transfers all corresponding setting from old user to new one. func TransferOwnership(doer *User, newOwnerName string, repo *Repository) error { newOwner, err := GetUserByName(newOwnerName) if err != nil { return fmt.Errorf("get new owner '%s': %v", newOwnerName, err) } // Check if new owner has repository with same name. has,pre { line-height: 125%; } td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } .highlight .hll { background-color: #ffffcc } .highlight .c { color: #888888 } /* Comment */ .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ .highlight .k { color: #008800; font-weight: bold } /* Keyword */ .highlight .ch { color: #888888 } /* Comment.Hashbang */ .highlight .cm { color: #888888 } /* Comment.Multiline */ .highlight .cp { color: #cc0000; font-weight: bold } /* Comment.Preproc */ .highlight .cpf { color: #888888 } /* Comment.PreprocFile */ .highlight .c1 { color: #888888 } /* Comment.Single */ .highlight .cs { color: #cc0000; font-weight: bold; background-color: #fff0f0 } /* Comment.Special */ .highlight .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ .highlight .ge { font-style: italic } /* Generic.Emph */ .highlight .gr { color: #aa0000 } /* Generic.Error */ .highlight .gh { color: #333333 } /* Generic.Heading */ .highlight .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ .highlight .go { color: #888888 } /* Generic.Output */ .highlight .gp { color: #555555 } /* Generic.Prompt */ .highlight .gs { font-weight: bold } /* Generic.Strong */ .highlight .gu { color: #666666 } /* Generic.Subheading */ .highlight .gt { color: #aa0000 } /* Generic.Traceback */ .highlight .kc { color: #008800; font-weight: bold } /* Keyword.Constant */ .highlight .kd { color: #008800; font-weight: bold } /* Keyword.Declaration */ .highlight .kn { color: #008800; font-weight: bold } /* Keyword.Namespace */ .highlight .kp { color: #008800 } /* Keyword.Pseudo */ .highlight .kr { color: #008800; font-weight: bold } /* Keyword.Reserved */ .highlight .kt { color: #888888; font-weight: bold } /* Keyword.Type */ .highlight .m { color: #0000DD; font-weight: bold } /* Literal.Number */ .highlight .s { color: #dd2200; background-color: #fff0f0 } /* Literal.String */ .highlight .na { color: #336699 } /* Name.Attribute */ .highlight .nb { color: #003388 } /* Name.Builtin */ .highlight .nc { color: #bb0066; font-weight: bold } /* Name.Class */ .highlight .no { color: #003366; font-weight: bold } /* Name.Constant */ .highlight .nd { color: #555555 } /* Name.Decorator */ .highlight .ne { color: #bb0066; font-weight: bold } /* Name.Exception */ .highlight .nf { color: #0066bb; font-weight: bold } /* Name.Function */ .highlight .nl { color: #336699; font-style: italic } /* Name.Label */ .highlight .nn { color: #bb0066; font-weight: bold } /* Name.Namespace */ .highlight .py { color: #336699; font-weight: bold } /* Name.Property */ .highlight .nt { color: #bb0066; font-weight: bold } /* Name.Tag */ .highlight .nv { color: #336699 } /* Name.Variable */ .highlight .ow { color: #008800 } /* Operator.Word */ .highlight .w { color: #bbbbbb } /* Text.Whitespace */ .highlight .mb { color: #0000DD; font-weight: bold } /* Literal.Number.Bin */ .highlight .mf { color: #0000DD; font-weight: bold } /* Literal.Number.Float */ .highlight .mh { color: #0000DD; font-weight: bold } /* Literal.Number.Hex */ .highlight .mi { color: #0000DD; font-weight: bold } /* Literal.Number.Integer */ .highlight .mo { color: #0000DD; font-weight: bold } /* Literal.Number.Oct */ .highlight .sa { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Affix */ .highlight .sb { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Backtick */ .highlight .sc { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Char */ .highlight .dl { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Delimiter */ .highlight .sd { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Doc */ .highlight .s2 { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Double */ .highlight .se { color: #0044dd; background-color: #fff0f0 } /* Literal.String.Escape */ .highlight .sh { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Heredoc */ .highlight .si { color: #3333bb; background-color: #fff0f0 } /* Literal.String.Interpol */ .highlight .sx { color: #22bb22; background-color: #f0fff0 } /* Literal.String.Other */ .highlight .sr { color: #008800; background-color: #fff0ff } /* Literal.String.Regex */ .highlight .s1 { color: #dd2200; background-color: #fff0f0 } /* Literal.String.Single */ .highlight .ss { color: #aa6600; background-color: #fff0f0 } /* Literal.String.Symbol */ .highlight .bp { color: #003388 } /* Name.Builtin.Pseudo */ .highlight .fm { color: #0066bb; font-weight: bold } /* Name.Function.Magic */ .highlight .vc { color: #336699 } /* Name.Variable.Class */ .highlight .vg { color: #dd7700 } /* Name.Variable.Global */ .highlight .vi { color: #3333bb } /* Name.Variable.Instance */ .highlight .vm { color: #336699 } /* Name.Variable.Magic */ .highlight .il { color: #0000DD; font-weight: bold } /* Literal.Number.Integer.Long */<?php declare(strict_types=1); /** * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OC\Core\ResponseDefinitions; use OC\Files\SimpleFS\SimpleFile; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\ExAppRequired; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\Files\File; use OCP\Files\GenericFileException; use OCP\Files\IAppData; use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; use OCP\IL10N; use OCP\IRequest; use OCP\Lock\LockedException; use OCP\TaskProcessing\EShapeType; use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\NotFoundException; use OCP\TaskProcessing\Exception\PreConditionNotMetException; use OCP\TaskProcessing\Exception\UnauthorizedException; use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\IManager; use OCP\TaskProcessing\ShapeEnumValue; use OCP\TaskProcessing\Task; use RuntimeException; use stdClass; /** * @psalm-import-type CoreTaskProcessingTask from ResponseDefinitions * @psalm-import-type CoreTaskProcessingTaskType from ResponseDefinitions */ class TaskProcessingApiController extends OCSController { public function __construct( string $appName, IRequest $request, private IManager $taskProcessingManager, private IL10N $l, private ?string $userId, private IRootFolder $rootFolder, private IAppData $appData, ) { parent::__construct($appName, $request); } /** * Returns all available TaskProcessing task types * * @return DataResponse<Http::STATUS_OK, array{types: array<string, CoreTaskProcessingTaskType>}, array{}> * * 200: Task types returned */ #[PublicPage] #[ApiRoute(verb: 'GET', url: '/tasktypes', root: '/taskprocessing')] public function taskTypes(): DataResponse { /** @var array<string, CoreTaskProcessingTaskType> $taskTypes */ $taskTypes = array_map(function (array $tt) { $tt['inputShape'] = array_map(function ($descriptor) { return $descriptor->jsonSerialize(); }, $tt['inputShape']); if (empty($tt['inputShape'])) { $tt['inputShape'] = new stdClass; } $tt['outputShape'] = array_map(function ($descriptor) { return $descriptor->jsonSerialize(); }, $tt['outputShape']); if (empty($tt['outputShape'])) { $tt['outputShape'] = new stdClass; } $tt['optionalInputShape'] = array_map(function ($descriptor) { return $descriptor->jsonSerialize(); }, $tt['optionalInputShape']); if (empty($tt['optionalInputShape'])) { $tt['optionalInputShape'] = new stdClass; } $tt['optionalOutputShape'] = array_map(function ($descriptor) { return $descriptor->jsonSerialize(); }, $tt['optionalOutputShape']); if (empty($tt['optionalOutputShape'])) { $tt['optionalOutputShape'] = new stdClass; } $tt['inputShapeEnumValues'] = array_map(function (array $enumValues) { return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); }, $tt['inputShapeEnumValues']); if (empty($tt['inputShapeEnumValues'])) { $tt['inputShapeEnumValues'] = new stdClass; } $tt['optionalInputShapeEnumValues'] = array_map(function (array $enumValues) { return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); }, $tt['optionalInputShapeEnumValues']); if (empty($tt['optionalInputShapeEnumValues'])) { $tt['optionalInputShapeEnumValues'] = new stdClass; } $tt['outputShapeEnumValues'] = array_map(function (array $enumValues) { return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); }, $tt['outputShapeEnumValues']); if (empty($tt['outputShapeEnumValues'])) { $tt['outputShapeEnumValues'] = new stdClass; } $tt['optionalOutputShapeEnumValues'] = array_map(function (array $enumValues) { return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); }, $tt['optionalOutputShapeEnumValues']); if (empty($tt['optionalOutputShapeEnumValues'])) { $tt['optionalOutputShapeEnumValues'] = new stdClass; } if (empty($tt['inputShapeDefaults'])) { $tt['inputShapeDefaults'] = new stdClass; } if (empty($tt['optionalInputShapeDefaults'])) { $tt['optionalInputShapeDefaults'] = new stdClass; } return $tt; }, $this->taskProcessingManager->getAvailableTaskTypes()); return new DataResponse([ 'types' => $taskTypes, ]); } /** * Schedules a task * * @param array<string, mixed> $input Task's input parameters * @param string $type Type of the task * @param string $appId ID of the app that will execute the task * @param string $customId An arbitrary identifier for the task * @param string|null $webhookUri URI to be requested when the task finishes * @param string|null $webhookMethod Method used for the webhook request (HTTP:GET, HTTP:POST, HTTP:PUT, HTTP:DELETE or AppAPI:APP_ID:GET, AppAPI:APP_ID:POST...) * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_BAD_REQUEST|Http::STATUS_PRECONDITION_FAILED|Http::STATUS_UNAUTHORIZED, array{message: string}, array{}> * * 200: Task scheduled successfully * 400: Scheduling task is not possible * 412: Scheduling task is not possible * 401: Cannot schedule task because it references files in its input that the user doesn't have access to */ #[PublicPage] #[UserRateLimit(limit: 20, period: 120)] #[AnonRateLimit(limit: 5, period: 120)] #[ApiRoute(verb: 'POST', url: '/schedule', root: '/taskprocessing')] public function schedule( array $input, string $type, string $appId, string $customId = '', ?string $webhookUri = null, ?string $webhookMethod = null, ): DataResponse { $task = new Task($type, $input, $appId, $this->userId, $customId); $task->setWebhookUri($webhookUri); $task->setWebhookMethod($webhookMethod); try { $this->taskProcessingManager->scheduleTask($task); /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, ]); } catch (PreConditionNotMetException) { return new DataResponse(['message' => $this->l->t('The given provider is not available')], Http::STATUS_PRECONDITION_FAILED); } catch (ValidationException $e) { return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } catch (UnauthorizedException) { return new DataResponse(['message' => 'User does not have access to the files mentioned in the task input'], Http::STATUS_UNAUTHORIZED); } catch (Exception) { return new DataResponse(['message' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Gets a task including status and result * * Tasks are removed 1 week after receiving their last update * * @param int $id The id of the task * * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> * * 200: Task returned * 404: Task not found */ #[PublicPage] #[ApiRoute(verb: 'GET', url: '/task/{id}', root: '/taskprocessing')] public function getTask(int $id): DataResponse { try { $task = $this->taskProcessingManager->getUserTask($id, $this->userId); /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, ]); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); } catch (RuntimeException) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Deletes a task * * @param int $id The id of the task * * @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> * * 200: Task deleted */ #[NoAdminRequired] #[ApiRoute(verb: 'DELETE', url: '/task/{id}', root: '/taskprocessing')] public function deleteTask(int $id): DataResponse { try { $task = $this->taskProcessingManager->getUserTask($id, $this->userId); $this->taskProcessingManager->deleteTask($task); return new DataResponse(null); } catch (NotFoundException) { return new DataResponse(null); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Returns tasks for the current user filtered by the appId and optional customId * * @param string $appId ID of the app * @param string|null $customId An arbitrary identifier for the task * @return DataResponse<Http::STATUS_OK, array{tasks: list<CoreTaskProcessingTask>}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> * * 200: Tasks returned */ #[NoAdminRequired] #[ApiRoute(verb: 'GET', url: '/tasks/app/{appId}', root: '/taskprocessing')] public function listTasksByApp(string $appId, ?string $customId = null): DataResponse { try { $tasks = $this->taskProcessingManager->getUserTasksByApp($this->userId, $appId, $customId); $json = array_map(static function (Task $task) { return $task->jsonSerialize(); }, $tasks); return new DataResponse([ 'tasks' => $json, ]); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Returns tasks for the current user filtered by the optional taskType and optional customId * * @param string|null $taskType The task type to filter by * @param string|null $customId An arbitrary identifier for the task * @return DataResponse<Http::STATUS_OK, array{tasks: list<CoreTaskProcessingTask>}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> * * 200: Tasks returned */ #[NoAdminRequired] #[ApiRoute(verb: 'GET', url: '/tasks', root: '/taskprocessing')] public function listTasks(?string $taskType, ?string $customId = null): DataResponse { try { $tasks = $this->taskProcessingManager->getUserTasks($this->userId, $taskType, $customId); $json = array_map(static function (Task $task) { return $task->jsonSerialize(); }, $tasks); return new DataResponse([ 'tasks' => $json, ]); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Returns the contents of a file referenced in a task * * @param int $taskId The id of the task * @param int $fileId The file id of the file to retrieve * @return DataDownloadResponse<Http::STATUS_OK, string, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 200: File content returned * 404: Task or file not found */ #[NoAdminRequired] #[NoCSRFRequired] #[ApiRoute(verb: 'GET', url: '/tasks/{taskId}/file/{fileId}', root: '/taskprocessing')] public function getFileContents(int $taskId, int $fileId): DataDownloadResponse|DataResponse { try { $task = $this->taskProcessingManager->getUserTask($taskId, $this->userId); return $this->getFileContentsInternal($task, $fileId); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Returns the contents of a file referenced in a task(ExApp route version) * * @param int $taskId The id of the task * @param int $fileId The file id of the file to retrieve * @return DataDownloadResponse<Http::STATUS_OK, string, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 200: File content returned * 404: Task or file not found */ #[ExAppRequired] #[ApiRoute(verb: 'GET', url: '/tasks_provider/{taskId}/file/{fileId}', root: '/taskprocessing')] public function getFileContentsExApp(int $taskId, int $fileId): DataDownloadResponse|DataResponse { try { $task = $this->taskProcessingManager->getTask($taskId); return $this->getFileContentsInternal($task, $fileId); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Upload a file so it can be referenced in a task result (ExApp route version) * * Use field 'file' for the file upload * * @param int $taskId The id of the task * @return DataResponse<Http::STATUS_CREATED, array{fileId: int}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 201: File created * 400: File upload failed or no file was uploaded * 404: Task not found */ #[ExAppRequired] #[ApiRoute(verb: 'POST', url: '/tasks_provider/{taskId}/file', root: '/taskprocessing')] public function setFileContentsExApp(int $taskId): DataResponse { try { $task = $this->taskProcessingManager->getTask($taskId); $file = $this->request->getUploadedFile('file'); if (!isset($file['tmp_name'])) { return new DataResponse(['message' => $this->l->t('Bad request')], Http::STATUS_BAD_REQUEST); } $handle = fopen($file['tmp_name'], 'r'); if (!$handle) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } $fileId = $this->setFileContentsInternal($handle); return new DataResponse(['fileId' => $fileId], Http::STATUS_CREATED); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * @throws NotPermittedException * @throws NotFoundException * @throws GenericFileException * @throws LockedException * * @return DataDownloadResponse<Http::STATUS_OK, string, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> */ private function getFileContentsInternal(Task $task, int $fileId): DataDownloadResponse|DataResponse { $ids = $this->extractFileIdsFromTask($task); if (!in_array($fileId, $ids)) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } if ($task->getUserId() !== null) { \OC_Util::setupFS($task->getUserId()); } $node = $this->rootFolder->getFirstNodeById($fileId); if ($node === null) { $node = $this->rootFolder->getFirstNodeByIdInPath($fileId, '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); if (!$node instanceof File) { throw new NotFoundException('Node is not a file'); } } elseif (!$node instanceof File) { throw new NotFoundException('Node is not a file'); } return new DataDownloadResponse($node->getContent(), $node->getName(), $node->getMimeType()); } /** * @param Task $task * @return list<int> * @throws NotFoundException */ private function extractFileIdsFromTask(Task $task): array { $ids = []; $taskTypes = $this->taskProcessingManager->getAvailableTaskTypes(); if (!isset($taskTypes[$task->getTaskTypeId()])) { throw new NotFoundException('Could not find task type'); } $taskType = $taskTypes[$task->getTaskTypeId()]; foreach ($taskType['inputShape'] + $taskType['optionalInputShape'] as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { /** @var int|list<int> $inputSlot */ $inputSlot = $task->getInput()[$key]; if (is_array($inputSlot)) { $ids = array_merge($inputSlot, $ids); } else { $ids[] = $inputSlot; } } } if ($task->getOutput() !== null) { foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { /** @var int|list<int> $outputSlot */ $outputSlot = $task->getOutput()[$key]; if (is_array($outputSlot)) { $ids = array_merge($outputSlot, $ids); } else { $ids[] = $outputSlot; } } } } return $ids; } /** * Sets the task progress * * @param int $taskId The id of the task * @param float $progress The progress * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 200: Progress updated successfully * 404: Task not found */ #[ExAppRequired] #[ApiRoute(verb: 'POST', url: '/tasks_provider/{taskId}/progress', root: '/taskprocessing')] public function setProgress(int $taskId, float $progress): DataResponse { try { $this->taskProcessingManager->setTaskProgress($taskId, $progress); $task = $this->taskProcessingManager->getTask($taskId); /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, ]); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Sets the task result * * @param int $taskId The id of the task * @param array<string,mixed>|null $output The resulting task output, files are represented by their IDs * @param string|null $errorMessage An error message if the task failed * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 200: Result updated successfully * 404: Task not found */ #[ExAppRequired] #[ApiRoute(verb: 'POST', url: '/tasks_provider/{taskId}/result', root: '/taskprocessing')] public function setResult(int $taskId, ?array $output = null, ?string $errorMessage = null): DataResponse { try { // set result $this->taskProcessingManager->setTaskResult($taskId, $errorMessage, $output, true); $task = $this->taskProcessingManager->getTask($taskId); /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, ]); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Cancels a task * * @param int $taskId The id of the task * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 200: Task canceled successfully * 404: Task not found */ #[NoAdminRequired] #[ApiRoute(verb: 'POST', url: '/tasks/{taskId}/cancel', root: '/taskprocessing')] public function cancelTask(int $taskId): DataResponse { try { // Check if the current user can access the task $this->taskProcessingManager->getUserTask($taskId, $this->userId); // set result $this->taskProcessingManager->cancelTask($taskId); $task = $this->taskProcessingManager->getUserTask($taskId, $this->userId); /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, ]); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Returns the next scheduled task for the taskTypeId * * @param list<string> $providerIds The ids of the providers * @param list<string> $taskTypeIds The ids of the task types * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask, provider: array{name: string}}, array{}>|DataResponse<Http::STATUS_NO_CONTENT, null, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> * * 200: Task returned * 204: No task found */ #[ExAppRequired] #[ApiRoute(verb: 'GET', url: '/tasks_provider/next', root: '/taskprocessing')] public function getNextScheduledTask(array $providerIds, array $taskTypeIds): DataResponse { try { // restrict $providerIds to providers that are configured as preferred for the passed task types $providerIds = array_values(array_intersect(array_unique(array_map(fn ($taskTypeId) => $this->taskProcessingManager->getPreferredProvider($taskTypeId)->getId(), $taskTypeIds)), $providerIds)); // restrict $taskTypeIds to task types that can actually be run by one of the now restricted providers $taskTypeIds = array_values(array_filter($taskTypeIds, fn ($taskTypeId) => in_array($this->taskProcessingManager->getPreferredProvider($taskTypeId)->getId(), $providerIds, true))); if (count($providerIds) === 0 || count($taskTypeIds) === 0) { throw new NotFoundException(); } $taskIdsToIgnore = []; while (true) { $task = $this->taskProcessingManager->getNextScheduledTask($taskTypeIds, $taskIdsToIgnore); $provider = $this->taskProcessingManager->getPreferredProvider($task->getTaskTypeId()); if (in_array($provider->getId(), $providerIds, true)) { if ($this->taskProcessingManager->lockTask($task)) { break; } } $taskIdsToIgnore[] = (int)$task->getId(); } /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, 'provider' => [ 'name' => $provider->getId(), ], ]); } catch (NotFoundException) { return new DataResponse(null, Http::STATUS_NO_CONTENT); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * @param resource $data * @return int * @throws NotPermittedException */ private function setFileContentsInternal($data): int { try { $folder = $this->appData->getFolder('TaskProcessing'); } catch (\OCP\Files\NotFoundException) { $folder = $this->appData->newFolder('TaskProcessing'); } /** @var SimpleFile $file */ $file = $folder->newFile(time() . '-' . rand(1, 100000), $data); return $file->getId(); } }
<?php declare(strict_types=1); /** * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Core\Controller; use OC\Core\ResponseDefinitions; use OC\Files\SimpleFS\SimpleFile; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\ExAppRequired; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\Files\File; use OCP\Files\GenericFileException; use OCP\Files\IAppData; use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; use OCP\IL10N; use OCP\IRequest; use OCP\Lock\LockedException; use OCP\TaskProcessing\EShapeType; use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\NotFoundException; use OCP\TaskProcessing\Exception\PreConditionNotMetException; use OCP\TaskProcessing\Exception\UnauthorizedException; use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\IManager; use OCP\TaskProcessing\ShapeEnumValue; use OCP\TaskProcessing\Task; use RuntimeException; use stdClass; /** * @psalm-import-type CoreTaskProcessingTask from ResponseDefinitions * @psalm-import-type CoreTaskProcessingTaskType from ResponseDefinitions */ class TaskProcessingApiController extends OCSController { public function __construct( string $appName, IRequest $request, private IManager $taskProcessingManager, private IL10N $l, private ?string $userId, private IRootFolder $rootFolder, private IAppData $appData, ) { parent::__construct($appName, $request); } /** * Returns all available TaskProcessing task types * * @return DataResponse<Http::STATUS_OK, array{types: array<string, CoreTaskProcessingTaskType>}, array{}> * * 200: Task types returned */ #[PublicPage] #[ApiRoute(verb: 'GET', url: '/tasktypes', root: '/taskprocessing')] public function taskTypes(): DataResponse { /** @var array<string, CoreTaskProcessingTaskType> $taskTypes */ $taskTypes = array_map(function (array $tt) { $tt['inputShape'] = array_map(function ($descriptor) { return $descriptor->jsonSerialize(); }, $tt['inputShape']); if (empty($tt['inputShape'])) { $tt['inputShape'] = new stdClass; } $tt['outputShape'] = array_map(function ($descriptor) { return $descriptor->jsonSerialize(); }, $tt['outputShape']); if (empty($tt['outputShape'])) { $tt['outputShape'] = new stdClass; } $tt['optionalInputShape'] = array_map(function ($descriptor) { return $descriptor->jsonSerialize(); }, $tt['optionalInputShape']); if (empty($tt['optionalInputShape'])) { $tt['optionalInputShape'] = new stdClass; } $tt['optionalOutputShape'] = array_map(function ($descriptor) { return $descriptor->jsonSerialize(); }, $tt['optionalOutputShape']); if (empty($tt['optionalOutputShape'])) { $tt['optionalOutputShape'] = new stdClass; } $tt['inputShapeEnumValues'] = array_map(function (array $enumValues) { return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); }, $tt['inputShapeEnumValues']); if (empty($tt['inputShapeEnumValues'])) { $tt['inputShapeEnumValues'] = new stdClass; } $tt['optionalInputShapeEnumValues'] = array_map(function (array $enumValues) { return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); }, $tt['optionalInputShapeEnumValues']); if (empty($tt['optionalInputShapeEnumValues'])) { $tt['optionalInputShapeEnumValues'] = new stdClass; } $tt['outputShapeEnumValues'] = array_map(function (array $enumValues) { return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); }, $tt['outputShapeEnumValues']); if (empty($tt['outputShapeEnumValues'])) { $tt['outputShapeEnumValues'] = new stdClass; } $tt['optionalOutputShapeEnumValues'] = array_map(function (array $enumValues) { return array_map(fn (ShapeEnumValue $enumValue) => $enumValue->jsonSerialize(), $enumValues); }, $tt['optionalOutputShapeEnumValues']); if (empty($tt['optionalOutputShapeEnumValues'])) { $tt['optionalOutputShapeEnumValues'] = new stdClass; } if (empty($tt['inputShapeDefaults'])) { $tt['inputShapeDefaults'] = new stdClass; } if (empty($tt['optionalInputShapeDefaults'])) { $tt['optionalInputShapeDefaults'] = new stdClass; } return $tt; }, $this->taskProcessingManager->getAvailableTaskTypes()); return new DataResponse([ 'types' => $taskTypes, ]); } /** * Schedules a task * * @param array<string, mixed> $input Task's input parameters * @param string $type Type of the task * @param string $appId ID of the app that will execute the task * @param string $customId An arbitrary identifier for the task * @param string|null $webhookUri URI to be requested when the task finishes * @param string|null $webhookMethod Method used for the webhook request (HTTP:GET, HTTP:POST, HTTP:PUT, HTTP:DELETE or AppAPI:APP_ID:GET, AppAPI:APP_ID:POST...) * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_BAD_REQUEST|Http::STATUS_PRECONDITION_FAILED|Http::STATUS_UNAUTHORIZED, array{message: string}, array{}> * * 200: Task scheduled successfully * 400: Scheduling task is not possible * 412: Scheduling task is not possible * 401: Cannot schedule task because it references files in its input that the user doesn't have access to */ #[PublicPage] #[UserRateLimit(limit: 20, period: 120)] #[AnonRateLimit(limit: 5, period: 120)] #[ApiRoute(verb: 'POST', url: '/schedule', root: '/taskprocessing')] public function schedule( array $input, string $type, string $appId, string $customId = '', ?string $webhookUri = null, ?string $webhookMethod = null, ): DataResponse { $task = new Task($type, $input, $appId, $this->userId, $customId); $task->setWebhookUri($webhookUri); $task->setWebhookMethod($webhookMethod); try { $this->taskProcessingManager->scheduleTask($task); /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, ]); } catch (PreConditionNotMetException) { return new DataResponse(['message' => $this->l->t('The given provider is not available')], Http::STATUS_PRECONDITION_FAILED); } catch (ValidationException $e) { return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } catch (UnauthorizedException) { return new DataResponse(['message' => 'User does not have access to the files mentioned in the task input'], Http::STATUS_UNAUTHORIZED); } catch (Exception) { return new DataResponse(['message' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Gets a task including status and result * * Tasks are removed 1 week after receiving their last update * * @param int $id The id of the task * * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> * * 200: Task returned * 404: Task not found */ #[PublicPage] #[ApiRoute(verb: 'GET', url: '/task/{id}', root: '/taskprocessing')] public function getTask(int $id): DataResponse { try { $task = $this->taskProcessingManager->getUserTask($id, $this->userId); /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, ]); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Task not found')], Http::STATUS_NOT_FOUND); } catch (RuntimeException) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Deletes a task * * @param int $id The id of the task * * @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> * * 200: Task deleted */ #[NoAdminRequired] #[ApiRoute(verb: 'DELETE', url: '/task/{id}', root: '/taskprocessing')] public function deleteTask(int $id): DataResponse { try { $task = $this->taskProcessingManager->getUserTask($id, $this->userId); $this->taskProcessingManager->deleteTask($task); return new DataResponse(null); } catch (NotFoundException) { return new DataResponse(null); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Returns tasks for the current user filtered by the appId and optional customId * * @param string $appId ID of the app * @param string|null $customId An arbitrary identifier for the task * @return DataResponse<Http::STATUS_OK, array{tasks: list<CoreTaskProcessingTask>}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> * * 200: Tasks returned */ #[NoAdminRequired] #[ApiRoute(verb: 'GET', url: '/tasks/app/{appId}', root: '/taskprocessing')] public function listTasksByApp(string $appId, ?string $customId = null): DataResponse { try { $tasks = $this->taskProcessingManager->getUserTasksByApp($this->userId, $appId, $customId); $json = array_map(static function (Task $task) { return $task->jsonSerialize(); }, $tasks); return new DataResponse([ 'tasks' => $json, ]); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Returns tasks for the current user filtered by the optional taskType and optional customId * * @param string|null $taskType The task type to filter by * @param string|null $customId An arbitrary identifier for the task * @return DataResponse<Http::STATUS_OK, array{tasks: list<CoreTaskProcessingTask>}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> * * 200: Tasks returned */ #[NoAdminRequired] #[ApiRoute(verb: 'GET', url: '/tasks', root: '/taskprocessing')] public function listTasks(?string $taskType, ?string $customId = null): DataResponse { try { $tasks = $this->taskProcessingManager->getUserTasks($this->userId, $taskType, $customId); $json = array_map(static function (Task $task) { return $task->jsonSerialize(); }, $tasks); return new DataResponse([ 'tasks' => $json, ]); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Returns the contents of a file referenced in a task * * @param int $taskId The id of the task * @param int $fileId The file id of the file to retrieve * @return DataDownloadResponse<Http::STATUS_OK, string, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 200: File content returned * 404: Task or file not found */ #[NoAdminRequired] #[NoCSRFRequired] #[ApiRoute(verb: 'GET', url: '/tasks/{taskId}/file/{fileId}', root: '/taskprocessing')] public function getFileContents(int $taskId, int $fileId): DataDownloadResponse|DataResponse { try { $task = $this->taskProcessingManager->getUserTask($taskId, $this->userId); return $this->getFileContentsInternal($task, $fileId); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Returns the contents of a file referenced in a task(ExApp route version) * * @param int $taskId The id of the task * @param int $fileId The file id of the file to retrieve * @return DataDownloadResponse<Http::STATUS_OK, string, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 200: File content returned * 404: Task or file not found */ #[ExAppRequired] #[ApiRoute(verb: 'GET', url: '/tasks_provider/{taskId}/file/{fileId}', root: '/taskprocessing')] public function getFileContentsExApp(int $taskId, int $fileId): DataDownloadResponse|DataResponse { try { $task = $this->taskProcessingManager->getTask($taskId); return $this->getFileContentsInternal($task, $fileId); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Upload a file so it can be referenced in a task result (ExApp route version) * * Use field 'file' for the file upload * * @param int $taskId The id of the task * @return DataResponse<Http::STATUS_CREATED, array{fileId: int}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 201: File created * 400: File upload failed or no file was uploaded * 404: Task not found */ #[ExAppRequired] #[ApiRoute(verb: 'POST', url: '/tasks_provider/{taskId}/file', root: '/taskprocessing')] public function setFileContentsExApp(int $taskId): DataResponse { try { $task = $this->taskProcessingManager->getTask($taskId); $file = $this->request->getUploadedFile('file'); if (!isset($file['tmp_name'])) { return new DataResponse(['message' => $this->l->t('Bad request')], Http::STATUS_BAD_REQUEST); } $handle = fopen($file['tmp_name'], 'r'); if (!$handle) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } $fileId = $this->setFileContentsInternal($handle); return new DataResponse(['fileId' => $fileId], Http::STATUS_CREATED); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * @throws NotPermittedException * @throws NotFoundException * @throws GenericFileException * @throws LockedException * * @return DataDownloadResponse<Http::STATUS_OK, string, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> */ private function getFileContentsInternal(Task $task, int $fileId): DataDownloadResponse|DataResponse { $ids = $this->extractFileIdsFromTask($task); if (!in_array($fileId, $ids)) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } if ($task->getUserId() !== null) { \OC_Util::setupFS($task->getUserId()); } $node = $this->rootFolder->getFirstNodeById($fileId); if ($node === null) { $node = $this->rootFolder->getFirstNodeByIdInPath($fileId, '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); if (!$node instanceof File) { throw new NotFoundException('Node is not a file'); } } elseif (!$node instanceof File) { throw new NotFoundException('Node is not a file'); } return new DataDownloadResponse($node->getContent(), $node->getName(), $node->getMimeType()); } /** * @param Task $task * @return list<int> * @throws NotFoundException */ private function extractFileIdsFromTask(Task $task): array { $ids = []; $taskTypes = $this->taskProcessingManager->getAvailableTaskTypes(); if (!isset($taskTypes[$task->getTaskTypeId()])) { throw new NotFoundException('Could not find task type'); } $taskType = $taskTypes[$task->getTaskTypeId()]; foreach ($taskType['inputShape'] + $taskType['optionalInputShape'] as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { /** @var int|list<int> $inputSlot */ $inputSlot = $task->getInput()[$key]; if (is_array($inputSlot)) { $ids = array_merge($inputSlot, $ids); } else { $ids[] = $inputSlot; } } } if ($task->getOutput() !== null) { foreach ($taskType['outputShape'] + $taskType['optionalOutputShape'] as $key => $descriptor) { if (in_array(EShapeType::getScalarType($descriptor->getShapeType()), [EShapeType::File, EShapeType::Image, EShapeType::Audio, EShapeType::Video], true)) { /** @var int|list<int> $outputSlot */ $outputSlot = $task->getOutput()[$key]; if (is_array($outputSlot)) { $ids = array_merge($outputSlot, $ids); } else { $ids[] = $outputSlot; } } } } return $ids; } /** * Sets the task progress * * @param int $taskId The id of the task * @param float $progress The progress * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 200: Progress updated successfully * 404: Task not found */ #[ExAppRequired] #[ApiRoute(verb: 'POST', url: '/tasks_provider/{taskId}/progress', root: '/taskprocessing')] public function setProgress(int $taskId, float $progress): DataResponse { try { $this->taskProcessingManager->setTaskProgress($taskId, $progress); $task = $this->taskProcessingManager->getTask($taskId); /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, ]); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Sets the task result * * @param int $taskId The id of the task * @param array<string,mixed>|null $output The resulting task output, files are represented by their IDs * @param string|null $errorMessage An error message if the task failed * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 200: Result updated successfully * 404: Task not found */ #[ExAppRequired] #[ApiRoute(verb: 'POST', url: '/tasks_provider/{taskId}/result', root: '/taskprocessing')] public function setResult(int $taskId, ?array $output = null, ?string $errorMessage = null): DataResponse { try { // set result $this->taskProcessingManager->setTaskResult($taskId, $errorMessage, $output, true); $task = $this->taskProcessingManager->getTask($taskId); /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, ]); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Cancels a task * * @param int $taskId The id of the task * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * * 200: Task canceled successfully * 404: Task not found */ #[NoAdminRequired] #[ApiRoute(verb: 'POST', url: '/tasks/{taskId}/cancel', root: '/taskprocessing')] public function cancelTask(int $taskId): DataResponse { try { // Check if the current user can access the task $this->taskProcessingManager->getUserTask($taskId, $this->userId); // set result $this->taskProcessingManager->cancelTask($taskId); $task = $this->taskProcessingManager->getUserTask($taskId, $this->userId); /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, ]); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * Returns the next scheduled task for the taskTypeId * * @param list<string> $providerIds The ids of the providers * @param list<string> $taskTypeIds The ids of the task types * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask, provider: array{name: string}}, array{}>|DataResponse<Http::STATUS_NO_CONTENT, null, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}> * * 200: Task returned * 204: No task found */ #[ExAppRequired] #[ApiRoute(verb: 'GET', url: '/tasks_provider/next', root: '/taskprocessing')] public function getNextScheduledTask(array $providerIds, array $taskTypeIds): DataResponse { try { // restrict $providerIds to providers that are configured as preferred for the passed task types $providerIds = array_values(array_intersect(array_unique(array_map(fn ($taskTypeId) => $this->taskProcessingManager->getPreferredProvider($taskTypeId)->getId(), $taskTypeIds)), $providerIds)); // restrict $taskTypeIds to task types that can actually be run by one of the now restricted providers $taskTypeIds = array_values(array_filter($taskTypeIds, fn ($taskTypeId) => in_array($this->taskProcessingManager->getPreferredProvider($taskTypeId)->getId(), $providerIds, true))); if (count($providerIds) === 0 || count($taskTypeIds) === 0) { throw new NotFoundException(); } $taskIdsToIgnore = []; while (true) { $task = $this->taskProcessingManager->getNextScheduledTask($taskTypeIds, $taskIdsToIgnore); $provider = $this->taskProcessingManager->getPreferredProvider($task->getTaskTypeId()); if (in_array($provider->getId(), $providerIds, true)) { if ($this->taskProcessingManager->lockTask($task)) { break; } } $taskIdsToIgnore[] = (int)$task->getId(); } /** @var CoreTaskProcessingTask $json */ $json = $task->jsonSerialize(); return new DataResponse([ 'task' => $json, 'provider' => [ 'name' => $provider->getId(), ], ]); } catch (NotFoundException) { return new DataResponse(null, Http::STATUS_NO_CONTENT); } catch (Exception) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } /** * @param resource $data * @return int * @throws NotPermittedException */ private function setFileContentsInternal($data): int { try { $folder = $this->appData->getFolder('TaskProcessing'); } catch (\OCP\Files\NotFoundException) { $folder = $this->appData->newFolder('TaskProcessing'); } /** @var SimpleFile $file */ $file = $folder->newFile(time() . '-' . rand(1, 100000), $data); return $file->getId(); } }