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