repo.go

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