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