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 }