You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

generate.go 12KB

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