repo.go

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