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