1package backend
2
3import (
4 "bufio"
5 "context"
6 "errors"
7 "fmt"
8 "io/fs"
9 "os"
10 "path/filepath"
11 "time"
12
13 "github.com/charmbracelet/soft-serve/git"
14 "github.com/charmbracelet/soft-serve/server/db"
15 "github.com/charmbracelet/soft-serve/server/db/models"
16 "github.com/charmbracelet/soft-serve/server/hooks"
17 "github.com/charmbracelet/soft-serve/server/proto"
18 "github.com/charmbracelet/soft-serve/server/utils"
19)
20
21func (d *Backend) reposPath() string {
22 return filepath.Join(d.cfg.DataPath, "repos")
23}
24
25// CreateRepository creates a new repository.
26//
27// It implements backend.Backend.
28func (d *Backend) CreateRepository(ctx context.Context, name string, opts proto.RepositoryOptions) (proto.Repository, error) {
29 name = utils.SanitizeRepo(name)
30 if err := utils.ValidateRepo(name); err != nil {
31 return nil, err
32 }
33
34 repo := name + ".git"
35 rp := filepath.Join(d.reposPath(), repo)
36
37 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
38 if err := d.store.CreateRepo(
39 ctx,
40 tx,
41 name,
42 opts.ProjectName,
43 opts.Description,
44 opts.Private,
45 opts.Hidden,
46 opts.Mirror,
47 ); err != nil {
48 return err
49 }
50
51 _, err := git.Init(rp, true)
52 if err != nil {
53 d.logger.Debug("failed to create repository", "err", err)
54 return err
55 }
56
57 if err := os.WriteFile(filepath.Join(rp, "description"), []byte(opts.Description), fs.ModePerm); err != nil {
58 d.logger.Error("failed to write description", "repo", name, "err", err)
59 return err
60 }
61
62 if !opts.Private {
63 if err := os.WriteFile(filepath.Join(rp, "git-daemon-export-ok"), []byte{}, fs.ModePerm); err != nil {
64 d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
65 return err
66 }
67 }
68
69 return hooks.GenerateHooks(ctx, d.cfg, repo)
70 }); err != nil {
71 d.logger.Debug("failed to create repository in database", "err", err)
72 return nil, db.WrapError(err)
73 }
74
75 return d.Repository(ctx, name)
76}
77
78// ImportRepository imports a repository from remote.
79func (d *Backend) ImportRepository(ctx context.Context, name string, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {
80 name = utils.SanitizeRepo(name)
81 if err := utils.ValidateRepo(name); err != nil {
82 return nil, err
83 }
84
85 repo := name + ".git"
86 rp := filepath.Join(d.reposPath(), repo)
87
88 if _, err := os.Stat(rp); err == nil || os.IsExist(err) {
89 return nil, proto.ErrRepoExist
90 }
91
92 copts := git.CloneOptions{
93 Bare: true,
94 Mirror: opts.Mirror,
95 Quiet: true,
96 CommandOptions: git.CommandOptions{
97 Timeout: -1,
98 Context: ctx,
99 Envs: []string{
100 fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
101 filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
102 d.cfg.SSH.ClientKeyPath,
103 ),
104 },
105 },
106 // Timeout: time.Hour,
107 }
108
109 if err := git.Clone(remote, rp, copts); err != nil {
110 d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
111 // Cleanup the mess!
112 if rerr := os.RemoveAll(rp); rerr != nil {
113 err = errors.Join(err, rerr)
114 }
115 return nil, err
116 }
117
118 return d.CreateRepository(ctx, name, opts)
119}
120
121// DeleteRepository deletes a repository.
122//
123// It implements backend.Backend.
124func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
125 name = utils.SanitizeRepo(name)
126 repo := name + ".git"
127 rp := filepath.Join(d.reposPath(), repo)
128
129 return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
130 // Delete repo from cache
131 defer d.cache.Delete(name)
132
133 if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil {
134 return err
135 }
136
137 return os.RemoveAll(rp)
138 })
139}
140
141// RenameRepository renames a repository.
142//
143// It implements backend.Backend.
144func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error {
145 oldName = utils.SanitizeRepo(oldName)
146 if err := utils.ValidateRepo(oldName); err != nil {
147 return err
148 }
149
150 newName = utils.SanitizeRepo(newName)
151 if err := utils.ValidateRepo(newName); err != nil {
152 return err
153 }
154 oldRepo := oldName + ".git"
155 newRepo := newName + ".git"
156 op := filepath.Join(d.reposPath(), oldRepo)
157 np := filepath.Join(d.reposPath(), newRepo)
158 if _, err := os.Stat(op); err != nil {
159 return proto.ErrRepoNotExist
160 }
161
162 if _, err := os.Stat(np); err == nil {
163 return proto.ErrRepoExist
164 }
165
166 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
167 // Delete cache
168 defer d.cache.Delete(oldName)
169
170 if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil {
171 return err
172 }
173
174 // Make sure the new repository parent directory exists.
175 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
176 return err
177 }
178
179 return os.Rename(op, np)
180 }); err != nil {
181 return db.WrapError(err)
182 }
183
184 return nil
185}
186
187// Repositories returns a list of repositories per page.
188//
189// It implements backend.Backend.
190func (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) {
191 repos := make([]proto.Repository, 0)
192
193 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
194 ms, err := d.store.GetAllRepos(ctx, tx)
195 if err != nil {
196 return err
197 }
198
199 for _, m := range ms {
200 r := &repo{
201 name: m.Name,
202 path: filepath.Join(d.reposPath(), m.Name+".git"),
203 repo: m,
204 }
205
206 // Cache repositories
207 d.cache.Set(m.Name, r)
208
209 repos = append(repos, r)
210 }
211
212 return nil
213 }); err != nil {
214 return nil, db.WrapError(err)
215 }
216
217 return repos, nil
218}
219
220// Repository returns a repository by name.
221//
222// It implements backend.Backend.
223func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) {
224 var m models.Repo
225 name = utils.SanitizeRepo(name)
226
227 if r, ok := d.cache.Get(name); ok && r != nil {
228 return r, nil
229 }
230
231 rp := filepath.Join(d.reposPath(), name+".git")
232 if _, err := os.Stat(rp); err != nil {
233 return nil, os.ErrNotExist
234 }
235
236 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
237 var err error
238 m, err = d.store.GetRepoByName(ctx, tx, name)
239 return err
240 }); err != nil {
241 return nil, db.WrapError(err)
242 }
243
244 r := &repo{
245 name: name,
246 path: rp,
247 repo: m,
248 }
249
250 // Add to cache
251 d.cache.Set(name, r)
252
253 return r, nil
254}
255
256// Description returns the description of a repository.
257//
258// It implements backend.Backend.
259func (d *Backend) Description(ctx context.Context, name string) (string, error) {
260 name = utils.SanitizeRepo(name)
261 var desc string
262 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
263 var err error
264 desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name)
265 return err
266 }); err != nil {
267 return "", db.WrapError(err)
268 }
269
270 return desc, nil
271}
272
273// IsMirror returns true if the repository is a mirror.
274//
275// It implements backend.Backend.
276func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) {
277 name = utils.SanitizeRepo(name)
278 var mirror bool
279 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
280 var err error
281 mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name)
282 return err
283 }); err != nil {
284 return false, db.WrapError(err)
285 }
286 return mirror, nil
287}
288
289// IsPrivate returns true if the repository is private.
290//
291// It implements backend.Backend.
292func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) {
293 name = utils.SanitizeRepo(name)
294 var private bool
295 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
296 var err error
297 private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name)
298 return err
299 }); err != nil {
300 return false, db.WrapError(err)
301 }
302
303 return private, nil
304}
305
306// IsHidden returns true if the repository is hidden.
307//
308// It implements backend.Backend.
309func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) {
310 name = utils.SanitizeRepo(name)
311 var hidden bool
312 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
313 var err error
314 hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name)
315 return err
316 }); err != nil {
317 return false, db.WrapError(err)
318 }
319
320 return hidden, nil
321}
322
323// ProjectName returns the project name of a repository.
324//
325// It implements backend.Backend.
326func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) {
327 name = utils.SanitizeRepo(name)
328 var pname string
329 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
330 var err error
331 pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name)
332 return err
333 }); err != nil {
334 return "", db.WrapError(err)
335 }
336
337 return pname, nil
338}
339
340// SetHidden sets the hidden flag of a repository.
341//
342// It implements backend.Backend.
343func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error {
344 name = utils.SanitizeRepo(name)
345
346 // Delete cache
347 d.cache.Delete(name)
348
349 return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
350 return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden)
351 }))
352}
353
354// SetDescription sets the description of a repository.
355//
356// It implements backend.Backend.
357func (d *Backend) SetDescription(ctx context.Context, name string, desc string) error {
358 name = utils.SanitizeRepo(name)
359 rp := filepath.Join(d.reposPath(), name+".git")
360
361 // Delete cache
362 d.cache.Delete(name)
363
364 return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
365 if err := os.WriteFile(filepath.Join(rp, "description"), []byte(desc), fs.ModePerm); err != nil {
366 d.logger.Error("failed to write description", "repo", name, "err", err)
367 return err
368 }
369
370 return d.store.SetRepoDescriptionByName(ctx, tx, name, desc)
371 })
372}
373
374// SetPrivate sets the private flag of a repository.
375//
376// It implements backend.Backend.
377func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {
378 name = utils.SanitizeRepo(name)
379 rp := filepath.Join(d.reposPath(), name+".git")
380
381 // Delete cache
382 d.cache.Delete(name)
383
384 return db.WrapError(
385 d.db.TransactionContext(ctx, func(tx *db.Tx) error {
386 fp := filepath.Join(rp, "git-daemon-export-ok")
387 if !private {
388 if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {
389 d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
390 return err
391 }
392 } else {
393 if _, err := os.Stat(fp); err == nil {
394 if err := os.Remove(fp); err != nil {
395 d.logger.Error("failed to remove git-daemon-export-ok", "repo", name, "err", err)
396 return err
397 }
398 }
399 }
400
401 return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
402 }),
403 )
404}
405
406// SetProjectName sets the project name of a repository.
407//
408// It implements backend.Backend.
409func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error {
410 repo = utils.SanitizeRepo(repo)
411
412 // Delete cache
413 d.cache.Delete(repo)
414
415 return db.WrapError(
416 d.db.TransactionContext(ctx, func(tx *db.Tx) error {
417 return d.store.SetRepoProjectNameByName(ctx, tx, repo, name)
418 }),
419 )
420}
421
422var _ proto.Repository = (*repo)(nil)
423
424// repo is a Git repository with metadata stored in a SQLite database.
425type repo struct {
426 name string
427 path string
428 repo models.Repo
429}
430
431// Description returns the repository's description.
432//
433// It implements backend.Repository.
434func (r *repo) Description() string {
435 return r.repo.Description
436}
437
438// IsMirror returns whether the repository is a mirror.
439//
440// It implements backend.Repository.
441func (r *repo) IsMirror() bool {
442 return r.repo.Mirror
443}
444
445// IsPrivate returns whether the repository is private.
446//
447// It implements backend.Repository.
448func (r *repo) IsPrivate() bool {
449 return r.repo.Private
450}
451
452// Name returns the repository's name.
453//
454// It implements backend.Repository.
455func (r *repo) Name() string {
456 return r.name
457}
458
459// Open opens the repository.
460//
461// It implements backend.Repository.
462func (r *repo) Open() (*git.Repository, error) {
463 return git.Open(r.path)
464}
465
466// ProjectName returns the repository's project name.
467//
468// It implements backend.Repository.
469func (r *repo) ProjectName() string {
470 return r.repo.ProjectName
471}
472
473// IsHidden returns whether the repository is hidden.
474//
475// It implements backend.Repository.
476func (r *repo) IsHidden() bool {
477 return r.repo.Hidden
478}
479
480// UpdatedAt returns the repository's last update time.
481func (r *repo) UpdatedAt() time.Time {
482 // Try to read the last modified time from the info directory.
483 if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil {
484 if t, err := time.Parse(time.RFC3339, t); err == nil {
485 return t
486 }
487 }
488
489 rr, err := git.Open(r.path)
490 if err == nil {
491 t, err := rr.LatestCommitTime()
492 if err == nil {
493 return t
494 }
495 }
496
497 return r.repo.UpdatedAt
498}
499
500func (r *repo) writeLastModified(t time.Time) error {
501 fp := filepath.Join(r.path, "info", "last-modified")
502 if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
503 return err
504 }
505
506 return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm)
507}
508
509func readOneline(path string) (string, error) {
510 f, err := os.Open(path)
511 if err != nil {
512 return "", err
513 }
514
515 defer f.Close() // nolint: errcheck
516 s := bufio.NewScanner(f)
517 s.Scan()
518 return s.Text(), s.Err()
519}