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

     1  // Copyright 2019 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package repository
     5  
     6  import (
     7  	"bufio"
     8  	"bytes"
     9  	"context"
    10  	"fmt"
    11  	"os"
    12  	"path"
    13  	"path/filepath"
    14  	"regexp"
    15  	"strings"
    16  	"time"
    17  
    18  	git_model "code.gitea.io/gitea/models/git"
    19  	repo_model "code.gitea.io/gitea/models/repo"
    20  	user_model "code.gitea.io/gitea/models/user"
    21  	"code.gitea.io/gitea/modules/git"
    22  	"code.gitea.io/gitea/modules/gitrepo"
    23  	"code.gitea.io/gitea/modules/log"
    24  	repo_module "code.gitea.io/gitea/modules/repository"
    25  	"code.gitea.io/gitea/modules/util"
    26  
    27  	"github.com/gobwas/glob"
    28  	"github.com/huandu/xstrings"
    29  )
    30  
    31  type transformer struct {
    32  	Name      string
    33  	Transform func(string) string
    34  }
    35  
    36  type expansion struct {
    37  	Name         string
    38  	Value        string
    39  	Transformers []transformer
    40  }
    41  
    42  var defaultTransformers = []transformer{
    43  	{Name: "SNAKE", Transform: xstrings.ToSnakeCase},
    44  	{Name: "KEBAB", Transform: xstrings.ToKebabCase},
    45  	{Name: "CAMEL", Transform: func(str string) string {
    46  		return xstrings.FirstRuneToLower(xstrings.ToCamelCase(str))
    47  	}},
    48  	{Name: "PASCAL", Transform: xstrings.ToCamelCase},
    49  	{Name: "LOWER", Transform: strings.ToLower},
    50  	{Name: "UPPER", Transform: strings.ToUpper},
    51  	{Name: "TITLE", Transform: util.ToTitleCase},
    52  }
    53  
    54  func generateExpansion(src string, templateRepo, generateRepo *repo_model.Repository, sanitizeFileName bool) string {
    55  	expansions := []expansion{
    56  		{Name: "REPO_NAME", Value: generateRepo.Name, Transformers: defaultTransformers},
    57  		{Name: "TEMPLATE_NAME", Value: templateRepo.Name, Transformers: defaultTransformers},
    58  		{Name: "REPO_DESCRIPTION", Value: generateRepo.Description, Transformers: nil},
    59  		{Name: "TEMPLATE_DESCRIPTION", Value: templateRepo.Description, Transformers: nil},
    60  		{Name: "REPO_OWNER", Value: generateRepo.OwnerName, Transformers: defaultTransformers},
    61  		{Name: "TEMPLATE_OWNER", Value: templateRepo.OwnerName, Transformers: defaultTransformers},
    62  		{Name: "REPO_LINK", Value: generateRepo.Link(), Transformers: nil},
    63  		{Name: "TEMPLATE_LINK", Value: templateRepo.Link(), Transformers: nil},
    64  		{Name: "REPO_HTTPS_URL", Value: generateRepo.CloneLink().HTTPS, Transformers: nil},
    65  		{Name: "TEMPLATE_HTTPS_URL", Value: templateRepo.CloneLink().HTTPS, Transformers: nil},
    66  		{Name: "REPO_SSH_URL", Value: generateRepo.CloneLink().SSH, Transformers: nil},
    67  		{Name: "TEMPLATE_SSH_URL", Value: templateRepo.CloneLink().SSH, Transformers: nil},
    68  	}
    69  
    70  	expansionMap := make(map[string]string)
    71  	for _, e := range expansions {
    72  		expansionMap[e.Name] = e.Value
    73  		for _, tr := range e.Transformers {
    74  			expansionMap[fmt.Sprintf("%s_%s", e.Name, tr.Name)] = tr.Transform(e.Value)
    75  		}
    76  	}
    77  
    78  	return os.Expand(src, func(key string) string {
    79  		if expansion, ok := expansionMap[key]; ok {
    80  			if sanitizeFileName {
    81  				return fileNameSanitize(expansion)
    82  			}
    83  			return expansion
    84  		}
    85  		return key
    86  	})
    87  }
    88  
    89  // GiteaTemplate holds information about a .gitea/template file
    90  type GiteaTemplate struct {
    91  	Path    string
    92  	Content []byte
    93  
    94  	globs []glob.Glob
    95  }
    96  
    97  // Globs parses the .gitea/template globs or returns them if they were already parsed
    98  func (gt *GiteaTemplate) Globs() []glob.Glob {
    99  	if gt.globs != nil {
   100  		return gt.globs
   101  	}
   102  
   103  	gt.globs = make([]glob.Glob, 0)
   104  	scanner := bufio.NewScanner(bytes.NewReader(gt.Content))
   105  	for scanner.Scan() {
   106  		line := strings.TrimSpace(scanner.Text())
   107  		if line == "" || strings.HasPrefix(line, "#") {
   108  			continue
   109  		}
   110  		g, err := glob.Compile(line, '/')
   111  		if err != nil {
   112  			log.Info("Invalid glob expression '%s' (skipped): %v", line, err)
   113  			continue
   114  		}
   115  		gt.globs = append(gt.globs, g)
   116  	}
   117  	return gt.globs
   118  }
   119  
   120  func checkGiteaTemplate(tmpDir string) (*GiteaTemplate, error) {
   121  	gtPath := filepath.Join(tmpDir, ".gitea", "template")
   122  	if _, err := os.Stat(gtPath); os.IsNotExist(err) {
   123  		return nil, nil
   124  	} else if err != nil {
   125  		return nil, err
   126  	}
   127  
   128  	content, err := os.ReadFile(gtPath)
   129  	if err != nil {
   130  		return nil, err
   131  	}
   132  
   133  	gt := &GiteaTemplate{
   134  		Path:    gtPath,
   135  		Content: content,
   136  	}
   137  
   138  	return gt, nil
   139  }
   140  
   141  func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *repo_model.Repository, tmpDir string) error {
   142  	commitTimeStr := time.Now().Format(time.RFC3339)
   143  	authorSig := repo.Owner.NewGitSig()
   144  
   145  	// Because this may call hooks we should pass in the environment
   146  	env := append(os.Environ(),
   147  		"GIT_AUTHOR_NAME="+authorSig.Name,
   148  		"GIT_AUTHOR_EMAIL="+authorSig.Email,
   149  		"GIT_AUTHOR_DATE="+commitTimeStr,
   150  		"GIT_COMMITTER_NAME="+authorSig.Name,
   151  		"GIT_COMMITTER_EMAIL="+authorSig.Email,
   152  		"GIT_COMMITTER_DATE="+commitTimeStr,
   153  	)
   154  
   155  	// Clone to temporary path and do the init commit.
   156  	templateRepoPath := templateRepo.RepoPath()
   157  	if err := git.Clone(ctx, templateRepoPath, tmpDir, git.CloneRepoOptions{
   158  		Depth:  1,
   159  		Branch: templateRepo.DefaultBranch,
   160  	}); err != nil {
   161  		return fmt.Errorf("git clone: %w", err)
   162  	}
   163  
   164  	if err := util.RemoveAll(path.Join(tmpDir, ".git")); err != nil {
   165  		return fmt.Errorf("remove git dir: %w", err)
   166  	}
   167  
   168  	// Variable expansion
   169  	gt, err := checkGiteaTemplate(tmpDir)
   170  	if err != nil {
   171  		return fmt.Errorf("checkGiteaTemplate: %w", err)
   172  	}
   173  
   174  	if gt != nil {
   175  		if err := util.Remove(gt.Path); err != nil {
   176  			return fmt.Errorf("remove .giteatemplate: %w", err)
   177  		}
   178  
   179  		// Avoid walking tree if there are no globs
   180  		if len(gt.Globs()) > 0 {
   181  			tmpDirSlash := strings.TrimSuffix(filepath.ToSlash(tmpDir), "/") + "/"
   182  			if err := filepath.WalkDir(tmpDirSlash, func(path string, d os.DirEntry, walkErr error) error {
   183  				if walkErr != nil {
   184  					return walkErr
   185  				}
   186  
   187  				if d.IsDir() {
   188  					return nil
   189  				}
   190  
   191  				base := strings.TrimPrefix(filepath.ToSlash(path), tmpDirSlash)
   192  				for _, g := range gt.Globs() {
   193  					if g.Match(base) {
   194  						content, err := os.ReadFile(path)
   195  						if err != nil {
   196  							return err
   197  						}
   198  
   199  						if err := os.WriteFile(path,
   200  							[]byte(generateExpansion(string(content), templateRepo, generateRepo, false)),
   201  							0o644); err != nil {
   202  							return err
   203  						}
   204  
   205  						substPath := filepath.FromSlash(filepath.Join(tmpDirSlash,
   206  							generateExpansion(base, templateRepo, generateRepo, true)))
   207  
   208  						// Create parent subdirectories if needed or continue silently if it exists
   209  						if err := os.MkdirAll(filepath.Dir(substPath), 0o755); err != nil {
   210  							return err
   211  						}
   212  
   213  						// Substitute filename variables
   214  						if err := os.Rename(path, substPath); err != nil {
   215  							return err
   216  						}
   217  
   218  						break
   219  					}
   220  				}
   221  				return nil
   222  			}); err != nil {
   223  				return err
   224  			}
   225  		}
   226  	}
   227  
   228  	if err := git.InitRepository(ctx, tmpDir, false, templateRepo.ObjectFormatName); err != nil {
   229  		return err
   230  	}
   231  
   232  	repoPath := repo.RepoPath()
   233  	if stdout, _, err := git.NewCommand(ctx, "remote", "add", "origin").AddDynamicArguments(repoPath).
   234  		SetDescription(fmt.Sprintf("generateRepoCommit (git remote add): %s to %s", templateRepoPath, tmpDir)).
   235  		RunStdString(&git.RunOpts{Dir: tmpDir, Env: env}); err != nil {
   236  		log.Error("Unable to add %v as remote origin to temporary repo to %s: stdout %s\nError: %v", repo, tmpDir, stdout, err)
   237  		return fmt.Errorf("git remote add: %w", err)
   238  	}
   239  
   240  	// set default branch based on whether it's specified in the newly generated repo or not
   241  	defaultBranch := repo.DefaultBranch
   242  	if strings.TrimSpace(defaultBranch) == "" {
   243  		defaultBranch = templateRepo.DefaultBranch
   244  	}
   245  
   246  	return initRepoCommit(ctx, tmpDir, repo, repo.Owner, defaultBranch)
   247  }
   248  
   249  func generateGitContent(ctx context.Context, repo, templateRepo, generateRepo *repo_model.Repository) (err error) {
   250  	tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-"+repo.Name)
   251  	if err != nil {
   252  		return fmt.Errorf("Failed to create temp dir for repository %s: %w", repo.RepoPath(), err)
   253  	}
   254  
   255  	defer func() {
   256  		if err := util.RemoveAll(tmpDir); err != nil {
   257  			log.Error("RemoveAll: %v", err)
   258  		}
   259  	}()
   260  
   261  	if err = generateRepoCommit(ctx, repo, templateRepo, generateRepo, tmpDir); err != nil {
   262  		return fmt.Errorf("generateRepoCommit: %w", err)
   263  	}
   264  
   265  	// re-fetch repo
   266  	if repo, err = repo_model.GetRepositoryByID(ctx, repo.ID); err != nil {
   267  		return fmt.Errorf("getRepositoryByID: %w", err)
   268  	}
   269  
   270  	// if there was no default branch supplied when generating the repo, use the default one from the template
   271  	if strings.TrimSpace(repo.DefaultBranch) == "" {
   272  		repo.DefaultBranch = templateRepo.DefaultBranch
   273  	}
   274  
   275  	if err = gitrepo.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil {
   276  		return fmt.Errorf("setDefaultBranch: %w", err)
   277  	}
   278  	if err = UpdateRepository(ctx, repo, false); err != nil {
   279  		return fmt.Errorf("updateRepository: %w", err)
   280  	}
   281  
   282  	return nil
   283  }
   284  
   285  // GenerateGitContent generates git content from a template repository
   286  func GenerateGitContent(ctx context.Context, templateRepo, generateRepo *repo_model.Repository) error {
   287  	if err := generateGitContent(ctx, generateRepo, templateRepo, generateRepo); err != nil {
   288  		return err
   289  	}
   290  
   291  	if err := repo_module.UpdateRepoSize(ctx, generateRepo); err != nil {
   292  		return fmt.Errorf("failed to update size for repository: %w", err)
   293  	}
   294  
   295  	if err := git_model.CopyLFS(ctx, generateRepo, templateRepo); err != nil {
   296  		return fmt.Errorf("failed to copy LFS: %w", err)
   297  	}
   298  	return nil
   299  }
   300  
   301  // GenerateRepoOptions contains the template units to generate
   302  type GenerateRepoOptions struct {
   303  	Name            string
   304  	DefaultBranch   string
   305  	Description     string
   306  	Private         bool
   307  	GitContent      bool
   308  	Topics          bool
   309  	GitHooks        bool
   310  	Webhooks        bool
   311  	Avatar          bool
   312  	IssueLabels     bool
   313  	ProtectedBranch bool
   314  }
   315  
   316  // IsValid checks whether at least one option is chosen for generation
   317  func (gro GenerateRepoOptions) IsValid() bool {
   318  	return gro.GitContent || gro.Topics || gro.GitHooks || gro.Webhooks || gro.Avatar ||
   319  		gro.IssueLabels || gro.ProtectedBranch // or other items as they are added
   320  }
   321  
   322  // generateRepository generates a repository from a template
   323  func generateRepository(ctx context.Context, doer, owner *user_model.User, templateRepo *repo_model.Repository, opts GenerateRepoOptions) (_ *repo_model.Repository, err error) {
   324  	generateRepo := &repo_model.Repository{
   325  		OwnerID:          owner.ID,
   326  		Owner:            owner,
   327  		OwnerName:        owner.Name,
   328  		Name:             opts.Name,
   329  		LowerName:        strings.ToLower(opts.Name),
   330  		Description:      opts.Description,
   331  		DefaultBranch:    opts.DefaultBranch,
   332  		IsPrivate:        opts.Private,
   333  		IsEmpty:          !opts.GitContent || templateRepo.IsEmpty,
   334  		IsFsckEnabled:    templateRepo.IsFsckEnabled,
   335  		TemplateID:       templateRepo.ID,
   336  		TrustModel:       templateRepo.TrustModel,
   337  		ObjectFormatName: templateRepo.ObjectFormatName,
   338  	}
   339  
   340  	if err = repo_module.CreateRepositoryByExample(ctx, doer, owner, generateRepo, false, false); err != nil {
   341  		return nil, err
   342  	}
   343  
   344  	repoPath := generateRepo.RepoPath()
   345  	isExist, err := util.IsExist(repoPath)
   346  	if err != nil {
   347  		log.Error("Unable to check if %s exists. Error: %v", repoPath, err)
   348  		return nil, err
   349  	}
   350  	if isExist {
   351  		return nil, repo_model.ErrRepoFilesAlreadyExist{
   352  			Uname: generateRepo.OwnerName,
   353  			Name:  generateRepo.Name,
   354  		}
   355  	}
   356  
   357  	if err = repo_module.CheckInitRepository(ctx, owner.Name, generateRepo.Name, generateRepo.ObjectFormatName); err != nil {
   358  		return generateRepo, err
   359  	}
   360  
   361  	if err = repo_module.CheckDaemonExportOK(ctx, generateRepo); err != nil {
   362  		return generateRepo, fmt.Errorf("checkDaemonExportOK: %w", err)
   363  	}
   364  
   365  	if stdout, _, err := git.NewCommand(ctx, "update-server-info").
   366  		SetDescription(fmt.Sprintf("GenerateRepository(git update-server-info): %s", repoPath)).
   367  		RunStdString(&git.RunOpts{Dir: repoPath}); err != nil {
   368  		log.Error("GenerateRepository(git update-server-info) in %v: Stdout: %s\nError: %v", generateRepo, stdout, err)
   369  		return generateRepo, fmt.Errorf("error in GenerateRepository(git update-server-info): %w", err)
   370  	}
   371  
   372  	return generateRepo, nil
   373  }
   374  
   375  var fileNameSanitizeRegexp = regexp.MustCompile(`(?i)\.\.|[<>:\"/\\|?*\x{0000}-\x{001F}]|^(con|prn|aux|nul|com\d|lpt\d)$`)
   376  
   377  // Sanitize user input to valid OS filenames
   378  //
   379  //		Based on https://github.com/sindresorhus/filename-reserved-regex
   380  //	 Adds ".." to prevent directory traversal
   381  func fileNameSanitize(s string) string {
   382  	return strings.TrimSpace(fileNameSanitizeRegexp.ReplaceAllString(s, "_"))
   383  }