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

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