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

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