code.gitea.io/gitea@v1.22.3/services/repository/files/update.go (about)

     1  // Copyright 2019 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package files
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"io"
    10  	"path"
    11  	"strings"
    12  	"time"
    13  
    14  	"code.gitea.io/gitea/models"
    15  	git_model "code.gitea.io/gitea/models/git"
    16  	repo_model "code.gitea.io/gitea/models/repo"
    17  	user_model "code.gitea.io/gitea/models/user"
    18  	"code.gitea.io/gitea/modules/git"
    19  	"code.gitea.io/gitea/modules/gitrepo"
    20  	"code.gitea.io/gitea/modules/lfs"
    21  	"code.gitea.io/gitea/modules/log"
    22  	"code.gitea.io/gitea/modules/setting"
    23  	"code.gitea.io/gitea/modules/structs"
    24  	asymkey_service "code.gitea.io/gitea/services/asymkey"
    25  )
    26  
    27  // IdentityOptions for a person's identity like an author or committer
    28  type IdentityOptions struct {
    29  	Name  string
    30  	Email string
    31  }
    32  
    33  // CommitDateOptions store dates for GIT_AUTHOR_DATE and GIT_COMMITTER_DATE
    34  type CommitDateOptions struct {
    35  	Author    time.Time
    36  	Committer time.Time
    37  }
    38  
    39  type ChangeRepoFile struct {
    40  	Operation     string
    41  	TreePath      string
    42  	FromTreePath  string
    43  	ContentReader io.ReadSeeker
    44  	SHA           string
    45  	Options       *RepoFileOptions
    46  }
    47  
    48  // ChangeRepoFilesOptions holds the repository files update options
    49  type ChangeRepoFilesOptions struct {
    50  	LastCommitID string
    51  	OldBranch    string
    52  	NewBranch    string
    53  	Message      string
    54  	Files        []*ChangeRepoFile
    55  	Author       *IdentityOptions
    56  	Committer    *IdentityOptions
    57  	Dates        *CommitDateOptions
    58  	Signoff      bool
    59  }
    60  
    61  type RepoFileOptions struct {
    62  	treePath     string
    63  	fromTreePath string
    64  	executable   bool
    65  }
    66  
    67  // ChangeRepoFiles adds, updates or removes multiple files in the given repository
    68  func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ChangeRepoFilesOptions) (*structs.FilesResponse, error) {
    69  	err := repo.MustNotBeArchived()
    70  	if err != nil {
    71  		return nil, err
    72  	}
    73  
    74  	// If no branch name is set, assume default branch
    75  	if opts.OldBranch == "" {
    76  		opts.OldBranch = repo.DefaultBranch
    77  	}
    78  	if opts.NewBranch == "" {
    79  		opts.NewBranch = opts.OldBranch
    80  	}
    81  
    82  	gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
    83  	if err != nil {
    84  		return nil, err
    85  	}
    86  	defer closer.Close()
    87  
    88  	// oldBranch must exist for this operation
    89  	if _, err := gitRepo.GetBranch(opts.OldBranch); err != nil && !repo.IsEmpty {
    90  		return nil, err
    91  	}
    92  
    93  	var treePaths []string
    94  	for _, file := range opts.Files {
    95  		// If FromTreePath is not set, set it to the opts.TreePath
    96  		if file.TreePath != "" && file.FromTreePath == "" {
    97  			file.FromTreePath = file.TreePath
    98  		}
    99  
   100  		// Check that the path given in opts.treePath is valid (not a git path)
   101  		treePath := CleanUploadFileName(file.TreePath)
   102  		if treePath == "" {
   103  			return nil, models.ErrFilenameInvalid{
   104  				Path: file.TreePath,
   105  			}
   106  		}
   107  		// If there is a fromTreePath (we are copying it), also clean it up
   108  		fromTreePath := CleanUploadFileName(file.FromTreePath)
   109  		if fromTreePath == "" && file.FromTreePath != "" {
   110  			return nil, models.ErrFilenameInvalid{
   111  				Path: file.FromTreePath,
   112  			}
   113  		}
   114  
   115  		file.Options = &RepoFileOptions{
   116  			treePath:     treePath,
   117  			fromTreePath: fromTreePath,
   118  			executable:   false,
   119  		}
   120  		treePaths = append(treePaths, treePath)
   121  	}
   122  
   123  	// A NewBranch can be specified for the file to be created/updated in a new branch.
   124  	// Check to make sure the branch does not already exist, otherwise we can't proceed.
   125  	// If we aren't branching to a new branch, make sure user can commit to the given branch
   126  	if opts.NewBranch != opts.OldBranch {
   127  		existingBranch, err := gitRepo.GetBranch(opts.NewBranch)
   128  		if existingBranch != nil {
   129  			return nil, git_model.ErrBranchAlreadyExists{
   130  				BranchName: opts.NewBranch,
   131  			}
   132  		}
   133  		if err != nil && !git.IsErrBranchNotExist(err) {
   134  			return nil, err
   135  		}
   136  	} else if err := VerifyBranchProtection(ctx, repo, doer, opts.OldBranch, treePaths); err != nil {
   137  		return nil, err
   138  	}
   139  
   140  	message := strings.TrimSpace(opts.Message)
   141  
   142  	author, committer := GetAuthorAndCommitterUsers(opts.Author, opts.Committer, doer)
   143  
   144  	t, err := NewTemporaryUploadRepository(ctx, repo)
   145  	if err != nil {
   146  		log.Error("NewTemporaryUploadRepository failed: %v", err)
   147  	}
   148  	defer t.Close()
   149  	hasOldBranch := true
   150  	if err := t.Clone(opts.OldBranch, true); err != nil {
   151  		for _, file := range opts.Files {
   152  			if file.Operation == "delete" {
   153  				return nil, err
   154  			}
   155  		}
   156  		if !git.IsErrBranchNotExist(err) || !repo.IsEmpty {
   157  			return nil, err
   158  		}
   159  		if err := t.Init(repo.ObjectFormatName); err != nil {
   160  			return nil, err
   161  		}
   162  		hasOldBranch = false
   163  		opts.LastCommitID = ""
   164  	}
   165  	if hasOldBranch {
   166  		if err := t.SetDefaultIndex(); err != nil {
   167  			return nil, err
   168  		}
   169  	}
   170  
   171  	for _, file := range opts.Files {
   172  		if file.Operation == "delete" {
   173  			// Get the files in the index
   174  			filesInIndex, err := t.LsFiles(file.TreePath)
   175  			if err != nil {
   176  				return nil, fmt.Errorf("DeleteRepoFile: %w", err)
   177  			}
   178  
   179  			// Find the file we want to delete in the index
   180  			inFilelist := false
   181  			for _, indexFile := range filesInIndex {
   182  				if indexFile == file.TreePath {
   183  					inFilelist = true
   184  					break
   185  				}
   186  			}
   187  			if !inFilelist {
   188  				return nil, models.ErrRepoFileDoesNotExist{
   189  					Path: file.TreePath,
   190  				}
   191  			}
   192  		}
   193  	}
   194  
   195  	if hasOldBranch {
   196  		// Get the commit of the original branch
   197  		commit, err := t.GetBranchCommit(opts.OldBranch)
   198  		if err != nil {
   199  			return nil, err // Couldn't get a commit for the branch
   200  		}
   201  
   202  		// Assigned LastCommitID in opts if it hasn't been set
   203  		if opts.LastCommitID == "" {
   204  			opts.LastCommitID = commit.ID.String()
   205  		} else {
   206  			lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID)
   207  			if err != nil {
   208  				return nil, fmt.Errorf("ConvertToSHA1: Invalid last commit ID: %w", err)
   209  			}
   210  			opts.LastCommitID = lastCommitID.String()
   211  		}
   212  
   213  		for _, file := range opts.Files {
   214  			if err := handleCheckErrors(file, commit, opts, repo); err != nil {
   215  				return nil, err
   216  			}
   217  		}
   218  	}
   219  
   220  	contentStore := lfs.NewContentStore()
   221  	for _, file := range opts.Files {
   222  		switch file.Operation {
   223  		case "create", "update":
   224  			if err := CreateOrUpdateFile(ctx, t, file, contentStore, repo.ID, hasOldBranch); err != nil {
   225  				return nil, err
   226  			}
   227  		case "delete":
   228  			// Remove the file from the index
   229  			if err := t.RemoveFilesFromIndex(file.TreePath); err != nil {
   230  				return nil, err
   231  			}
   232  		default:
   233  			return nil, fmt.Errorf("invalid file operation: %s %s, supported operations are create, update, delete", file.Operation, file.Options.treePath)
   234  		}
   235  	}
   236  
   237  	// Now write the tree
   238  	treeHash, err := t.WriteTree()
   239  	if err != nil {
   240  		return nil, err
   241  	}
   242  
   243  	// Now commit the tree
   244  	var commitHash string
   245  	if opts.Dates != nil {
   246  		commitHash, err = t.CommitTreeWithDate(opts.LastCommitID, author, committer, treeHash, message, opts.Signoff, opts.Dates.Author, opts.Dates.Committer)
   247  	} else {
   248  		commitHash, err = t.CommitTree(opts.LastCommitID, author, committer, treeHash, message, opts.Signoff)
   249  	}
   250  	if err != nil {
   251  		return nil, err
   252  	}
   253  
   254  	// Then push this tree to NewBranch
   255  	if err := t.Push(doer, commitHash, opts.NewBranch); err != nil {
   256  		log.Error("%T %v", err, err)
   257  		return nil, err
   258  	}
   259  
   260  	commit, err := t.GetCommit(commitHash)
   261  	if err != nil {
   262  		return nil, err
   263  	}
   264  
   265  	filesResponse, err := GetFilesResponseFromCommit(ctx, repo, commit, opts.NewBranch, treePaths)
   266  	if err != nil {
   267  		return nil, err
   268  	}
   269  
   270  	if repo.IsEmpty {
   271  		if isEmpty, err := gitRepo.IsEmpty(); err == nil && !isEmpty {
   272  			_ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: repo.ID, IsEmpty: false, DefaultBranch: opts.NewBranch}, "is_empty", "default_branch")
   273  		}
   274  	}
   275  
   276  	return filesResponse, nil
   277  }
   278  
   279  // handles the check for various issues for ChangeRepoFiles
   280  func handleCheckErrors(file *ChangeRepoFile, commit *git.Commit, opts *ChangeRepoFilesOptions, repo *repo_model.Repository) error {
   281  	if file.Operation == "update" || file.Operation == "delete" {
   282  		fromEntry, err := commit.GetTreeEntryByPath(file.Options.fromTreePath)
   283  		if err != nil {
   284  			return err
   285  		}
   286  		if file.SHA != "" {
   287  			// If a SHA was given and the SHA given doesn't match the SHA of the fromTreePath, throw error
   288  			if file.SHA != fromEntry.ID.String() {
   289  				return models.ErrSHADoesNotMatch{
   290  					Path:       file.Options.treePath,
   291  					GivenSHA:   file.SHA,
   292  					CurrentSHA: fromEntry.ID.String(),
   293  				}
   294  			}
   295  		} else if opts.LastCommitID != "" {
   296  			// If a lastCommitID was given and it doesn't match the commitID of the head of the branch throw
   297  			// an error, but only if we aren't creating a new branch.
   298  			if commit.ID.String() != opts.LastCommitID && opts.OldBranch == opts.NewBranch {
   299  				if changed, err := commit.FileChangedSinceCommit(file.Options.treePath, opts.LastCommitID); err != nil {
   300  					return err
   301  				} else if changed {
   302  					return models.ErrCommitIDDoesNotMatch{
   303  						GivenCommitID:   opts.LastCommitID,
   304  						CurrentCommitID: opts.LastCommitID,
   305  					}
   306  				}
   307  				// The file wasn't modified, so we are good to delete it
   308  			}
   309  		} else {
   310  			// When updating a file, a lastCommitID or SHA needs to be given to make sure other commits
   311  			// haven't been made. We throw an error if one wasn't provided.
   312  			return models.ErrSHAOrCommitIDNotProvided{}
   313  		}
   314  		file.Options.executable = fromEntry.IsExecutable()
   315  	}
   316  	if file.Operation == "create" || file.Operation == "update" {
   317  		// For the path where this file will be created/updated, we need to make
   318  		// sure no parts of the path are existing files or links except for the last
   319  		// item in the path which is the file name, and that shouldn't exist IF it is
   320  		// a new file OR is being moved to a new path.
   321  		treePathParts := strings.Split(file.Options.treePath, "/")
   322  		subTreePath := ""
   323  		for index, part := range treePathParts {
   324  			subTreePath = path.Join(subTreePath, part)
   325  			entry, err := commit.GetTreeEntryByPath(subTreePath)
   326  			if err != nil {
   327  				if git.IsErrNotExist(err) {
   328  					// Means there is no item with that name, so we're good
   329  					break
   330  				}
   331  				return err
   332  			}
   333  			if index < len(treePathParts)-1 {
   334  				if !entry.IsDir() {
   335  					return models.ErrFilePathInvalid{
   336  						Message: fmt.Sprintf("a file exists where you’re trying to create a subdirectory [path: %s]", subTreePath),
   337  						Path:    subTreePath,
   338  						Name:    part,
   339  						Type:    git.EntryModeBlob,
   340  					}
   341  				}
   342  			} else if entry.IsLink() {
   343  				return models.ErrFilePathInvalid{
   344  					Message: fmt.Sprintf("a symbolic link exists where you’re trying to create a subdirectory [path: %s]", subTreePath),
   345  					Path:    subTreePath,
   346  					Name:    part,
   347  					Type:    git.EntryModeSymlink,
   348  				}
   349  			} else if entry.IsDir() {
   350  				return models.ErrFilePathInvalid{
   351  					Message: fmt.Sprintf("a directory exists where you’re trying to create a file [path: %s]", subTreePath),
   352  					Path:    subTreePath,
   353  					Name:    part,
   354  					Type:    git.EntryModeTree,
   355  				}
   356  			} else if file.Options.fromTreePath != file.Options.treePath || file.Operation == "create" {
   357  				// The entry shouldn't exist if we are creating new file or moving to a new path
   358  				return models.ErrRepoFileAlreadyExists{
   359  					Path: file.Options.treePath,
   360  				}
   361  			}
   362  		}
   363  	}
   364  
   365  	return nil
   366  }
   367  
   368  // CreateOrUpdateFile handles creating or updating a file for ChangeRepoFiles
   369  func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile, contentStore *lfs.ContentStore, repoID int64, hasOldBranch bool) error {
   370  	// Get the two paths (might be the same if not moving) from the index if they exist
   371  	filesInIndex, err := t.LsFiles(file.TreePath, file.FromTreePath)
   372  	if err != nil {
   373  		return fmt.Errorf("UpdateRepoFile: %w", err)
   374  	}
   375  	// If is a new file (not updating) then the given path shouldn't exist
   376  	if file.Operation == "create" {
   377  		for _, indexFile := range filesInIndex {
   378  			if indexFile == file.TreePath {
   379  				return models.ErrRepoFileAlreadyExists{
   380  					Path: file.TreePath,
   381  				}
   382  			}
   383  		}
   384  	}
   385  
   386  	// Remove the old path from the tree
   387  	if file.Options.fromTreePath != file.Options.treePath && len(filesInIndex) > 0 {
   388  		for _, indexFile := range filesInIndex {
   389  			if indexFile == file.Options.fromTreePath {
   390  				if err := t.RemoveFilesFromIndex(file.FromTreePath); err != nil {
   391  					return err
   392  				}
   393  			}
   394  		}
   395  	}
   396  
   397  	treeObjectContentReader := file.ContentReader
   398  	var lfsMetaObject *git_model.LFSMetaObject
   399  	if setting.LFS.StartServer && hasOldBranch {
   400  		// Check there is no way this can return multiple infos
   401  		filename2attribute2info, err := t.gitRepo.CheckAttribute(git.CheckAttributeOpts{
   402  			Attributes: []string{"filter"},
   403  			Filenames:  []string{file.Options.treePath},
   404  			CachedOnly: true,
   405  		})
   406  		if err != nil {
   407  			return err
   408  		}
   409  
   410  		if filename2attribute2info[file.Options.treePath] != nil && filename2attribute2info[file.Options.treePath]["filter"] == "lfs" {
   411  			// OK so we are supposed to LFS this data!
   412  			pointer, err := lfs.GeneratePointer(treeObjectContentReader)
   413  			if err != nil {
   414  				return err
   415  			}
   416  			lfsMetaObject = &git_model.LFSMetaObject{Pointer: pointer, RepositoryID: repoID}
   417  			treeObjectContentReader = strings.NewReader(pointer.StringContent())
   418  		}
   419  	}
   420  
   421  	// Add the object to the database
   422  	objectHash, err := t.HashObject(treeObjectContentReader)
   423  	if err != nil {
   424  		return err
   425  	}
   426  
   427  	// Add the object to the index
   428  	if file.Options.executable {
   429  		if err := t.AddObjectToIndex("100755", objectHash, file.Options.treePath); err != nil {
   430  			return err
   431  		}
   432  	} else {
   433  		if err := t.AddObjectToIndex("100644", objectHash, file.Options.treePath); err != nil {
   434  			return err
   435  		}
   436  	}
   437  
   438  	if lfsMetaObject != nil {
   439  		// We have an LFS object - create it
   440  		lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, lfsMetaObject.RepositoryID, lfsMetaObject.Pointer)
   441  		if err != nil {
   442  			return err
   443  		}
   444  		exist, err := contentStore.Exists(lfsMetaObject.Pointer)
   445  		if err != nil {
   446  			return err
   447  		}
   448  		if !exist {
   449  			_, err := file.ContentReader.Seek(0, io.SeekStart)
   450  			if err != nil {
   451  				return err
   452  			}
   453  			if err := contentStore.Put(lfsMetaObject.Pointer, file.ContentReader); err != nil {
   454  				if _, err2 := git_model.RemoveLFSMetaObjectByOid(ctx, repoID, lfsMetaObject.Oid); err2 != nil {
   455  					return fmt.Errorf("unable to remove failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, err2, err)
   456  				}
   457  				return err
   458  			}
   459  		}
   460  	}
   461  
   462  	return nil
   463  }
   464  
   465  // VerifyBranchProtection verify the branch protection for modifying the given treePath on the given branch
   466  func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName string, treePaths []string) error {
   467  	protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)
   468  	if err != nil {
   469  		return err
   470  	}
   471  	if protectedBranch != nil {
   472  		protectedBranch.Repo = repo
   473  		globUnprotected := protectedBranch.GetUnprotectedFilePatterns()
   474  		globProtected := protectedBranch.GetProtectedFilePatterns()
   475  		canUserPush := protectedBranch.CanUserPush(ctx, doer)
   476  		for _, treePath := range treePaths {
   477  			isUnprotectedFile := false
   478  			if len(globUnprotected) != 0 {
   479  				isUnprotectedFile = protectedBranch.IsUnprotectedFile(globUnprotected, treePath)
   480  			}
   481  			if !canUserPush && !isUnprotectedFile {
   482  				return models.ErrUserCannotCommit{
   483  					UserName: doer.LowerName,
   484  				}
   485  			}
   486  			if protectedBranch.IsProtectedFile(globProtected, treePath) {
   487  				return models.ErrFilePathProtected{
   488  					Path: treePath,
   489  				}
   490  			}
   491  		}
   492  		if protectedBranch.RequireSignedCommits {
   493  			_, _, _, err := asymkey_service.SignCRUDAction(ctx, repo.RepoPath(), doer, repo.RepoPath(), branchName)
   494  			if err != nil {
   495  				if !asymkey_service.IsErrWontSign(err) {
   496  					return err
   497  				}
   498  				return models.ErrUserCannotCommit{
   499  					UserName: doer.LowerName,
   500  				}
   501  			}
   502  		}
   503  	}
   504  	return nil
   505  }