// repo.go stores which repositories the forge serves. 0001 §8 lists the
// repo registry as forge OLTP state alongside accounts and tokens; until
// now it lived only in argv, so adding a repository meant editing the
// server's command line and restarting every other repository with it.
package oltp
import (
"database/sql"
"errors"
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
)
const repoSchema = `
CREATE TABLE IF NOT EXISTS repo (
name TEXT PRIMARY KEY, -- the forge's name, as it appears in URLs
path TEXT NOT NULL, -- directory holding .zit, on this node
description TEXT NOT NULL DEFAULT '',
-- 'public' or 'private'. Public by DEFAULT and on migration: an
-- upgrade must never silently hide what was already being served,
-- and taking a repository private is the deliberate act.
visibility TEXT NOT NULL DEFAULT 'public',
homepage TEXT NOT NULL DEFAULT '',
topics TEXT NOT NULL DEFAULT '', -- space-separated
created TEXT NOT NULL
);`
// repoMigrations are additive columns for registries written by an
// earlier schema. A duplicate-column error means it is already there.
var repoMigrations = []string{
`ALTER TABLE repo ADD COLUMN description TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public'`,
`ALTER TABLE repo ADD COLUMN homepage TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN topics TEXT NOT NULL DEFAULT ''`,
}
// Visibility values. Private means only an identity with a role on the
// repository may see it at all — including that it exists.
const (
Public = "public"
Private = "private"
)
// RepoName is the accepted repository name. Same rule the --repo flag
// enforces, kept here so a name that arrives through the API cannot be
// one the flag would have refused.
//
// It is deliberately narrow: the name appears in a URL path segment, so
// anything needing escaping to be safe there is better rejected than
// encoded.
var RepoName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
// Repo is one registered repository.
type Repo struct {
Name string
Path string
// Visibility is Public or Private.
Visibility string
// Description is the one line that says what this repository is.
// Every forge has it and quarry had nowhere to put it, so the
// repository list could only ever show names and counts.
Description string
// Homepage and Topics are the About-panel settings a maintainer
// edits in place, GitHub-style; .quarry/repo.json remains the
// in-tree alternative and these override it when set.
Homepage string
Topics string // space-separated
Created string
}
// AddRepo registers a repository. The path is stored absolute: a
// relative one would resolve against whatever directory the server
// happened to start in, which is not a property anybody intends to
// depend on and changes silently between a shell and a service manager.
func (d *DB) AddRepo(name, path, description, visibility string) error {
if !RepoName.MatchString(name) {
return fmt.Errorf("oltp: repository name %q: use letters, digits, dot, dash, underscore", name)
}
if path == "" {
return errors.New("oltp: repository needs a path")
}
abs, err := filepath.Abs(path)
if err != nil {
return err
}
if visibility != Private {
visibility = Public
}
_, err = d.db.Exec(`INSERT INTO repo (name, path, description, visibility, created)
VALUES (?, ?, ?, ?, ?)`,
name, abs, description, visibility, time.Now().UTC().Format(time.RFC3339))
if err != nil && strings.Contains(err.Error(), "UNIQUE") {
return fmt.Errorf("oltp: repository %q already registered", name)
}
return err
}
// DeleteRepo unregisters a repository. It removes the registration only
// — the store on disk and its derived index are left alone, because
// "stop serving this" and "destroy this" are different intentions and
// only one of them is reversible.
func (d *DB) DeleteRepo(name string) error {
_, err := d.db.Exec(`DELETE FROM repo WHERE name = ?`, name)
return err
}
// SetRepoAbout updates the About-panel settings: the project homepage
// and its space-separated topic list.
func (d *DB) SetRepoAbout(name, homepage, topics string) error {
_, err := d.db.Exec(`UPDATE repo SET homepage = ?, topics = ? WHERE name = ?`,
homepage, topics, name)
return err
}
// SetRepoDescription updates the one-line summary.
func (d *DB) SetRepoDescription(name, description string) error {
_, err := d.db.Exec(`UPDATE repo SET description = ? WHERE name = ?`, description, name)
return err
}
// SetRepoVisibility takes a repository public or private.
//
// It REFUSES a repository with no registry row rather than updating
// nothing and reporting success. That is not hypothetical: repositories
// named by --repo had no row, so taking one private silently did
// nothing and told the operator it had worked — a security control that
// reports success while doing nothing is worse than one that is absent.
func (d *DB) SetRepoVisibility(name, visibility string) error {
if visibility != Private {
visibility = Public
}
res, err := d.db.Exec(`UPDATE repo SET visibility = ? WHERE name = ?`, visibility, name)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("oltp: repository %q is not in the registry", name)
}
return nil
}
// EnsureRepo records a repository that is being served but was never
// registered — the --repo bootstrap path. Existing rows are left alone,
// so a description or visibility set through the API survives a restart
// that also names the repository on the command line.
func (d *DB) EnsureRepo(name, path string) error {
abs, err := filepath.Abs(path)
if err != nil {
return err
}
_, err = d.db.Exec(`INSERT INTO repo (name, path, description, visibility, created)
VALUES (?, ?, '', 'public', ?)
ON CONFLICT (name) DO NOTHING`,
name, abs, time.Now().UTC().Format(time.RFC3339))
return err
}
// PrivateRepos returns the set of private repository names. A SET rather
// than a per-name query because the read gate runs on every request and
// the answer changes only when an admin changes it.
func (d *DB) PrivateRepos() (map[string]bool, error) {
rows, err := d.db.Query(`SELECT name FROM repo WHERE visibility = 'private'`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out[n] = true
}
return out, rows.Err()
}
// PurgeRepo removes a repository's registration and everything keyed to
// it: grants, sync users, its mirror, its webhooks and its CI records.
//
// One transaction and one place, because the failure mode of scattering
// this is silent: a deletion that misses a table leaves credentials or
// an outbound webhook pointing at a repository that no longer exists,
// and if a repository of the same name is later created it inherits
// them. When a new repo-keyed table is added, it is added here.
func (d *DB) PurgeRepo(name string) error {
tx, err := d.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, q := range []string{
`DELETE FROM sync_user WHERE repo = ?`,
`DELETE FROM mirror WHERE repo = ?`,
`DELETE FROM webhook WHERE repo = ?`,
`DELETE FROM ci_run WHERE repo = ?`,
`DELETE FROM ci_watermark WHERE repo = ?`,
`DELETE FROM forum_vote WHERE repo = ?`,
`DELETE FROM forum_thread_category WHERE repo = ?`,
`DELETE FROM poll_vote WHERE repo = ?`,
`DELETE FROM forum_moderation WHERE repo = ?`,
`DELETE FROM forum_staged WHERE repo = ?`,
`DELETE FROM forum_modlog WHERE repo = ?`,
`DELETE FROM forum_hidden WHERE repo = ?`,
`DELETE FROM forum_sub WHERE repo = ?`,
`DELETE FROM repo_caps WHERE repo = ?`,
`DELETE FROM branch_protection WHERE repo = ?`,
`DELETE FROM repo_category_caps WHERE repo = ?`,
`DELETE FROM repo WHERE name = ?`,
} {
if _, err := tx.Exec(q, name); err != nil {
return err
}
}
return tx.Commit()
}
// Repos lists registered repositories, name-ordered.
func (d *DB) Repos() ([]Repo, error) {
rows, err := d.db.Query(`SELECT name, path, description, visibility, homepage, topics, created FROM repo ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Repo
for rows.Next() {
var r Repo
if err := rows.Scan(&r.Name, &r.Path, &r.Description, &r.Visibility, &r.Homepage, &r.Topics, &r.Created); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// RepoByName returns one registration.
func (d *DB) RepoByName(name string) (Repo, bool, error) {
var r Repo
err := d.db.QueryRow(`SELECT name, path, description, visibility, homepage, topics, created FROM repo WHERE name = ?`, name).
Scan(&r.Name, &r.Path, &r.Description, &r.Visibility, &r.Homepage, &r.Topics, &r.Created)
if errors.Is(err, sql.ErrNoRows) {
return Repo{}, false, nil
}
return r, err == nil, err
}