repo.go

  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, err := d.store.GetRepoByName(ctx, tx, name)
203		if err != nil {
204			return db.WrapError(err)
205		}
206
207		repoID := strconv.FormatInt(repom.ID, 10)
208		strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs", repoID))
209		objs, err := d.store.GetLFSObjectsByName(ctx, tx, name)
210		if err != nil {
211			return db.WrapError(err)
212		}
213
214		for _, obj := range objs {
215			p := lfs.Pointer{
216				Oid:  obj.Oid,
217				Size: obj.Size,
218			}
219
220			d.logger.Debug("deleting lfs object", "repo", name, "oid", obj.Oid)
221			if err := strg.Delete(path.Join("objects", p.RelativePath())); err != nil {
222				d.logger.Error("failed to delete lfs object", "repo", name, "err", err, "oid", obj.Oid)
223			}
224		}
225
226		if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil {
227			return db.WrapError(err)
228		}
229
230		return os.RemoveAll(rp)
231	})
232	if errors.Is(err, db.ErrRecordNotFound) {
233		return proto.ErrRepoNotFound
234	}
235
236	return err
237}
238
239// DeleteUserRepositories deletes all user repositories.
240func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error {
241	return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
242		user, err := d.store.FindUserByUsername(ctx, tx, username)
243		if err != nil {
244			return err
245		}
246
247		repos, err := d.store.GetUserRepos(ctx, tx, user.ID)
248		if err != nil {
249			return err
250		}
251
252		for _, repo := range repos {
253			if err := d.DeleteRepository(ctx, repo.Name); err != nil {
254				return err
255			}
256		}
257
258		return nil
259	})
260}
261
262// RenameRepository renames a repository.
263//
264// It implements backend.Backend.
265func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error {
266	oldName = utils.SanitizeRepo(oldName)
267	if err := utils.ValidateRepo(oldName); err != nil {
268		return err
269	}
270
271	newName = utils.SanitizeRepo(newName)
272	if err := utils.ValidateRepo(newName); err != nil {
273		return err
274	}
275	oldRepo := oldName + ".git"
276	newRepo := newName + ".git"
277	op := filepath.Join(d.reposPath(), oldRepo)
278	np := filepath.Join(d.reposPath(), newRepo)
279	if _, err := os.Stat(op); err != nil {
280		return proto.ErrRepoNotFound
281	}
282
283	if _, err := os.Stat(np); err == nil {
284		return proto.ErrRepoExist
285	}
286
287	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
288		// Delete cache
289		defer d.cache.Delete(oldName)
290
291		if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil {
292			return err
293		}
294
295		// Make sure the new repository parent directory exists.
296		if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
297			return err
298		}
299
300		return os.Rename(op, np)
301	}); err != nil {
302		return db.WrapError(err)
303	}
304
305	return nil
306}
307
308// Repositories returns a list of repositories per page.
309//
310// It implements backend.Backend.
311func (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) {
312	repos := make([]proto.Repository, 0)
313
314	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
315		ms, err := d.store.GetAllRepos(ctx, tx)
316		if err != nil {
317			return err
318		}
319
320		for _, m := range ms {
321			r := &repo{
322				name: m.Name,
323				path: filepath.Join(d.reposPath(), m.Name+".git"),
324				repo: m,
325			}
326
327			// Cache repositories
328			d.cache.Set(m.Name, r)
329
330			repos = append(repos, r)
331		}
332
333		return nil
334	}); err != nil {
335		return nil, db.WrapError(err)
336	}
337
338	return repos, nil
339}
340
341// Repository returns a repository by name.
342//
343// It implements backend.Backend.
344func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) {
345	var m models.Repo
346	name = utils.SanitizeRepo(name)
347
348	if r, ok := d.cache.Get(name); ok && r != nil {
349		return r, nil
350	}
351
352	rp := filepath.Join(d.reposPath(), name+".git")
353	if _, err := os.Stat(rp); err != nil {
354		if !errors.Is(err, fs.ErrNotExist) {
355			d.logger.Errorf("failed to stat repository path: %v", err)
356		}
357		return nil, proto.ErrRepoNotFound
358	}
359
360	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
361		var err error
362		m, err = d.store.GetRepoByName(ctx, tx, name)
363		return db.WrapError(err)
364	}); err != nil {
365		if errors.Is(err, db.ErrRecordNotFound) {
366			return nil, proto.ErrRepoNotFound
367		}
368		return nil, db.WrapError(err)
369	}
370
371	r := &repo{
372		name: name,
373		path: rp,
374		repo: m,
375	}
376
377	// Add to cache
378	d.cache.Set(name, r)
379
380	return r, nil
381}
382
383// Description returns the description of a repository.
384//
385// It implements backend.Backend.
386func (d *Backend) Description(ctx context.Context, name string) (string, error) {
387	name = utils.SanitizeRepo(name)
388	var desc string
389	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
390		var err error
391		desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name)
392		return err
393	}); err != nil {
394		return "", db.WrapError(err)
395	}
396
397	return desc, nil
398}
399
400// IsMirror returns true if the repository is a mirror.
401//
402// It implements backend.Backend.
403func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) {
404	name = utils.SanitizeRepo(name)
405	var mirror bool
406	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
407		var err error
408		mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name)
409		return err
410	}); err != nil {
411		return false, db.WrapError(err)
412	}
413	return mirror, nil
414}
415
416// IsPrivate returns true if the repository is private.
417//
418// It implements backend.Backend.
419func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) {
420	name = utils.SanitizeRepo(name)
421	var private bool
422	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
423		var err error
424		private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name)
425		return err
426	}); err != nil {
427		return false, db.WrapError(err)
428	}
429
430	return private, nil
431}
432
433// IsHidden returns true if the repository is hidden.
434//
435// It implements backend.Backend.
436func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) {
437	name = utils.SanitizeRepo(name)
438	var hidden bool
439	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
440		var err error
441		hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name)
442		return err
443	}); err != nil {
444		return false, db.WrapError(err)
445	}
446
447	return hidden, nil
448}
449
450// ProjectName returns the project name of a repository.
451//
452// It implements backend.Backend.
453func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) {
454	name = utils.SanitizeRepo(name)
455	var pname string
456	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
457		var err error
458		pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name)
459		return err
460	}); err != nil {
461		return "", db.WrapError(err)
462	}
463
464	return pname, nil
465}
466
467// SetHidden sets the hidden flag of a repository.
468//
469// It implements backend.Backend.
470func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error {
471	name = utils.SanitizeRepo(name)
472
473	// Delete cache
474	d.cache.Delete(name)
475
476	return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
477		return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden)
478	}))
479}
480
481// SetDescription sets the description of a repository.
482//
483// It implements backend.Backend.
484func (d *Backend) SetDescription(ctx context.Context, name string, desc string) error {
485	name = utils.SanitizeRepo(name)
486	rp := filepath.Join(d.reposPath(), name+".git")
487
488	// Delete cache
489	d.cache.Delete(name)
490
491	return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
492		if err := os.WriteFile(filepath.Join(rp, "description"), []byte(desc), fs.ModePerm); err != nil {
493			d.logger.Error("failed to write description", "repo", name, "err", err)
494			return err
495		}
496
497		return d.store.SetRepoDescriptionByName(ctx, tx, name, desc)
498	})
499}
500
501// SetPrivate sets the private flag of a repository.
502//
503// It implements backend.Backend.
504func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {
505	name = utils.SanitizeRepo(name)
506	rp := filepath.Join(d.reposPath(), name+".git")
507
508	// Delete cache
509	d.cache.Delete(name)
510
511	return db.WrapError(
512		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
513			fp := filepath.Join(rp, "git-daemon-export-ok")
514			if !private {
515				if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {
516					d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
517					return err
518				}
519			} else {
520				if _, err := os.Stat(fp); err == nil {
521					if err := os.Remove(fp); err != nil {
522						d.logger.Error("failed to remove git-daemon-export-ok", "repo", name, "err", err)
523						return err
524					}
525				}
526			}
527
528			return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
529		}),
530	)
531}
532
533// SetProjectName sets the project name of a repository.
534//
535// It implements backend.Backend.
536func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error {
537	repo = utils.SanitizeRepo(repo)
538
539	// Delete cache
540	d.cache.Delete(repo)
541
542	return db.WrapError(
543		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
544			return d.store.SetRepoProjectNameByName(ctx, tx, repo, name)
545		}),
546	)
547}
548
549var _ proto.Repository = (*repo)(nil)
550
551// repo is a Git repository with metadata stored in a SQLite database.
552type repo struct {
553	name string
554	path string
555	repo models.Repo
556}
557
558// ID returns the repository's ID.
559//
560// It implements proto.Repository.
561func (r *repo) ID() int64 {
562	return r.repo.ID
563}
564
565// UserID returns the repository's owner's user ID.
566// If the repository is not owned by anyone, it returns 0.
567//
568// It implements proto.Repository.
569func (r *repo) UserID() int64 {
570	if r.repo.UserID.Valid {
571		return r.repo.UserID.Int64
572	}
573	return 0
574}
575
576// Description returns the repository's description.
577//
578// It implements backend.Repository.
579func (r *repo) Description() string {
580	return r.repo.Description
581}
582
583// IsMirror returns whether the repository is a mirror.
584//
585// It implements backend.Repository.
586func (r *repo) IsMirror() bool {
587	return r.repo.Mirror
588}
589
590// IsPrivate returns whether the repository is private.
591//
592// It implements backend.Repository.
593func (r *repo) IsPrivate() bool {
594	return r.repo.Private
595}
596
597// Name returns the repository's name.
598//
599// It implements backend.Repository.
600func (r *repo) Name() string {
601	return r.name
602}
603
604// Open opens the repository.
605//
606// It implements backend.Repository.
607func (r *repo) Open() (*git.Repository, error) {
608	return git.Open(r.path)
609}
610
611// ProjectName returns the repository's project name.
612//
613// It implements backend.Repository.
614func (r *repo) ProjectName() string {
615	return r.repo.ProjectName
616}
617
618// IsHidden returns whether the repository is hidden.
619//
620// It implements backend.Repository.
621func (r *repo) IsHidden() bool {
622	return r.repo.Hidden
623}
624
625// UpdatedAt returns the repository's last update time.
626func (r *repo) UpdatedAt() time.Time {
627	// Try to read the last modified time from the info directory.
628	if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil {
629		if t, err := time.Parse(time.RFC3339, t); err == nil {
630			return t
631		}
632	}
633
634	rr, err := git.Open(r.path)
635	if err == nil {
636		t, err := rr.LatestCommitTime()
637		if err == nil {
638			return t
639		}
640	}
641
642	return r.repo.UpdatedAt
643}
644
645func (r *repo) writeLastModified(t time.Time) error {
646	fp := filepath.Join(r.path, "info", "last-modified")
647	if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
648		return err
649	}
650
651	return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm)
652}
653
654func readOneline(path string) (string, error) {
655	f, err := os.Open(path)
656	if err != nil {
657		return "", err
658	}
659
660	defer f.Close() // nolint: errcheck
661	s := bufio.NewScanner(f)
662	s.Scan()
663	return s.Text(), s.Err()
664}