store.go

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