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