orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3// The database holds only what git cannot: push tokens, description and topics,
4// and mirror state. Refs, commits, trees, sizes and dates are never copied here,
5// since a cache of something git can answer is a second source of truth.
6
7import (
8 "crypto/rand"
9 "crypto/subtle"
10 "database/sql"
11 "encoding/base64"
12 "encoding/json"
13 "errors"
14 "fmt"
15 "os"
16 "path/filepath"
17 "regexp"
18 "strings"
19 "time"
20
21 "golang.org/x/crypto/argon2"
22
23 _ "modernc.org/sqlite"
24)
25
26const schema = `
27-- One row per repository this site knows about, whether it arrived by push or
28-- by mirror. The row is metadata only: delete it and the repository still
29-- serves, it just loses its description.
30CREATE TABLE IF NOT EXISTS repos (
31 name TEXT PRIMARY KEY,
32 description TEXT NOT NULL DEFAULT '',
33 topics TEXT NOT NULL DEFAULT '[]',
34 homepage TEXT NOT NULL DEFAULT '',
35 -- A mirror is pulled from upstream and refuses pushes. A pushed repo is
36 -- the opposite. The column is what the wire checks before receive-pack.
37 mirror INTEGER NOT NULL DEFAULT 0,
38 upstream TEXT NOT NULL DEFAULT '',
39 -- Set when upstream reports the repository archived, and shown as a
40 -- badge. Twenty of the twenty-one repositories on the account are
41 -- archived, so this is the common case rather than an exception.
42 archived INTEGER NOT NULL DEFAULT 0,
43 -- The point of the mirror half: a repository that vanished upstream but
44 -- still lives here. Loud on the index, because it is the only copy.
45 upstream_gone INTEGER NOT NULL DEFAULT 0,
46 last_sync INTEGER NOT NULL DEFAULT 0,
47 last_sync_err TEXT NOT NULL DEFAULT '',
48 created INTEGER NOT NULL DEFAULT 0,
49 hidden INTEGER NOT NULL DEFAULT 0
50);
51
52-- Push credentials. The token itself is never stored, only an Argon2id hash of
53-- it, so a copy of this database does not let anybody push.
54--
55-- salt is per token rather than global. A single site-wide salt would mean two
56-- tokens with the same value hash identically, which leaks nothing useful here
57-- with one operator but costs nothing to do properly.
58CREATE TABLE IF NOT EXISTS tokens (
59 id INTEGER PRIMARY KEY AUTOINCREMENT,
60 label TEXT NOT NULL,
61 -- The first eight characters of the token, stored in clear. Not a
62 -- secret and not enough to authenticate with; it is what lets the UI
63 -- show "which of these three is the one on my laptop" without keeping
64 -- the token itself.
65 prefix TEXT NOT NULL,
66 hash BLOB NOT NULL,
67 salt BLOB NOT NULL,
68 created INTEGER NOT NULL,
69 last_used INTEGER NOT NULL DEFAULT 0,
70 revoked INTEGER NOT NULL DEFAULT 0
71);
72
73CREATE INDEX IF NOT EXISTS tokens_prefix ON tokens(prefix) WHERE revoked = 0;
74
75-- What the mirror lane pulls from. Rows rather than a constant, because which
76-- account or repository to back up is an operational decision that changes, and
77-- changing it should not need a rebuild.
78--
79-- kind is 'account' (everything owned by that login) or 'repo' (one named
80-- repository). name is empty for an account. The unique constraint is what
81-- makes adding the same source twice a no-op rather than a duplicate fetch.
82CREATE TABLE IF NOT EXISTS mirror_sources (
83 id INTEGER PRIMARY KEY AUTOINCREMENT,
84 kind TEXT NOT NULL,
85 owner TEXT NOT NULL,
86 name TEXT NOT NULL DEFAULT '',
87 created INTEGER NOT NULL,
88 UNIQUE(kind, owner, name)
89);
90
91-- Small key/value store for facts about this installation rather than about a
92-- repository. It exists for the seed marker below and is the right home for the
93-- next flag of that shape.
94CREATE TABLE IF NOT EXISTS settings (
95 key TEXT PRIMARY KEY,
96 value TEXT NOT NULL
97);
98`
99
100// DB wraps the connection. One writer, so no pool tuning beyond the pragmas.
101type DB struct {
102 sql *sql.DB
103}
104
105// OpenDB opens or creates the database beside the repositories.
106func OpenDB(dir string) (*DB, error) {
107 if err := os.MkdirAll(dir, 0o755); err != nil {
108 return nil, fmt.Errorf("create data dir: %w", err)
109 }
110 path := filepath.Join(dir, "repos.db")
111
112 // WAL so a mirror sync writing state does not block a page read, and a
113 // busy_timeout because a push-to-create and a sync tick can land together.
114 dsn := path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"
115
116 conn, err := sql.Open("sqlite", dsn)
117 if err != nil {
118 return nil, fmt.Errorf("open db: %w", err)
119 }
120 if err := conn.Ping(); err != nil {
121 return nil, fmt.Errorf("open db: %w", err)
122 }
123 if _, err := conn.Exec(schema); err != nil {
124 return nil, fmt.Errorf("apply schema: %w", err)
125 }
126 return &DB{sql: conn}, nil
127}
128
129func (d *DB) Close() error { return d.sql.Close() }
130
131// RepoMeta is the row, with topics already decoded.
132type RepoMeta struct {
133 Name string
134 Description string
135 Topics []string
136 Homepage string
137 Mirror bool
138 Upstream string
139 Archived bool
140 UpstreamGone bool
141 LastSync time.Time
142 LastSyncErr string
143 Created time.Time
144 Hidden bool
145}
146
147// Repo reads one row. No row is not an error; it is a repository pushed and
148// never described.
149func (d *DB) Repo(name string) (RepoMeta, error) {
150 row := d.sql.QueryRow(`
151 SELECT name, description, topics, homepage, mirror, upstream,
152 archived, upstream_gone, last_sync, last_sync_err, created, hidden
153 FROM repos WHERE name = ?`, name)
154 return scanRepo(row)
155}
156
157type scanner interface{ Scan(...any) error }
158
159func scanRepo(row scanner) (RepoMeta, error) {
160 var (
161 m RepoMeta
162 topics string
163 mirror, archived, gone, hidden int
164 lastSync, created int64
165 )
166 err := row.Scan(&m.Name, &m.Description, &topics, &m.Homepage, &mirror,
167 &m.Upstream, &archived, &gone, &lastSync, &m.LastSyncErr, &created, &hidden)
168 if err != nil {
169 return RepoMeta{}, err
170 }
171 _ = json.Unmarshal([]byte(topics), &m.Topics)
172 m.Mirror = mirror == 1
173 m.Archived = archived == 1
174 m.UpstreamGone = gone == 1
175 m.Hidden = hidden == 1
176 if lastSync > 0 {
177 m.LastSync = time.Unix(lastSync, 0).UTC()
178 }
179 if created > 0 {
180 m.Created = time.Unix(created, 0).UTC()
181 }
182 return m, nil
183}
184
185// AllRepos reads every row keyed by name, so the index joins against the on-disk
186// list in one pass.
187func (d *DB) AllRepos() (map[string]RepoMeta, error) {
188 rows, err := d.sql.Query(`
189 SELECT name, description, topics, homepage, mirror, upstream,
190 archived, upstream_gone, last_sync, last_sync_err, created, hidden
191 FROM repos`)
192 if err != nil {
193 return nil, err
194 }
195 defer rows.Close()
196
197 out := make(map[string]RepoMeta)
198 for rows.Next() {
199 m, err := scanRepo(rows)
200 if err != nil {
201 return nil, err
202 }
203 out[m.Name] = m
204 }
205 return out, rows.Err()
206}
207
208// EnsureRepo creates the metadata row if it is missing.
209func (d *DB) EnsureRepo(name string) error {
210 _, err := d.sql.Exec(`
211 INSERT INTO repos (name, created) VALUES (?, ?)
212 ON CONFLICT(name) DO NOTHING`, name, time.Now().Unix())
213 return err
214}
215
216// SetDescription updates what the UI can edit. Topics are a JSON array, not a
217// join table; nothing queries across them that a LIKE cannot answer.
218func (d *DB) SetDescription(name, description string, topics []string, homepage string) error {
219 if err := d.EnsureRepo(name); err != nil {
220 return err
221 }
222 blob, err := json.Marshal(topics)
223 if err != nil {
224 return err
225 }
226 _, err = d.sql.Exec(`
227 UPDATE repos SET description = ?, topics = ?, homepage = ? WHERE name = ?`,
228 description, string(blob), homepage, name)
229 return err
230}
231
232// SetHidden keeps a repository on disk but off the index. There is no delete here.
233func (d *DB) SetHidden(name string, hidden bool) error {
234 if err := d.EnsureRepo(name); err != nil {
235 return err
236 }
237 v := 0
238 if hidden {
239 v = 1
240 }
241 _, err := d.sql.Exec(`UPDATE repos SET hidden = ? WHERE name = ?`, v, name)
242 return err
243}
244
245// MarkMirror records that a repository is a mirror of an upstream URL.
246func (d *DB) MarkMirror(name, upstream string, archived bool) error {
247 if err := d.EnsureRepo(name); err != nil {
248 return err
249 }
250 a := 0
251 if archived {
252 a = 1
253 }
254 _, err := d.sql.Exec(`
255 UPDATE repos SET mirror = 1, upstream = ?, archived = ? WHERE name = ?`,
256 upstream, a, name)
257 return err
258}
259
260// RecordSync writes the outcome of one mirror fetch.
261func (d *DB) RecordSync(name string, err error) error {
262 msg := ""
263 if err != nil {
264 msg = err.Error()
265 }
266 _, dberr := d.sql.Exec(`
267 UPDATE repos SET last_sync = ?, last_sync_err = ? WHERE name = ?`,
268 time.Now().Unix(), msg, name)
269 return dberr
270}
271
272// MarkUpstreamGone marks a repository whose upstream is gone, so the index can
273// say this is the only copy left.
274func (d *DB) MarkUpstreamGone(name string, gone bool) error {
275 v := 0
276 if gone {
277 v = 1
278 }
279 _, err := d.sql.Exec(`UPDATE repos SET upstream_gone = ? WHERE name = ?`, v, name)
280 return err
281}
282
283// Token is one credential, without the secret.
284type Token struct {
285 ID int64
286 Label string
287 Prefix string
288 Created time.Time
289 LastUsed time.Time
290}
291
292// Argon2id parameters, modest because what is hashed is a 256 bit random value
293// rather than a human password. The hash is here so a leaked database is not a
294// credential, not to withstand a dictionary attack.
295const (
296 argonTime = 1
297 argonMemory = 64 * 1024
298 argonThreads = 4
299 argonKeyLen = 32
300 saltLen = 16
301 // 32 bytes is 43 base64url characters, so the 8 stored in clear give nothing away.
302 tokenBytes = 32
303)
304
305// CreateToken mints a push credential and returns it once; the database keeps
306// only a hash, so there is no way to read it back.
307func (d *DB) CreateToken(label string) (string, error) {
308 label = strings.TrimSpace(label)
309 if label == "" {
310 label = "unnamed"
311 }
312
313 raw := make([]byte, tokenBytes)
314 if _, err := rand.Read(raw); err != nil {
315 return "", fmt.Errorf("generate token: %w", err)
316 }
317 token := base64.RawURLEncoding.EncodeToString(raw)
318
319 salt := make([]byte, saltLen)
320 if _, err := rand.Read(salt); err != nil {
321 return "", fmt.Errorf("generate salt: %w", err)
322 }
323 hash := argon2.IDKey([]byte(token), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
324
325 _, err := d.sql.Exec(`
326 INSERT INTO tokens (label, prefix, hash, salt, created)
327 VALUES (?, ?, ?, ?, ?)`,
328 label, token[:8], hash, salt, time.Now().Unix())
329 if err != nil {
330 return "", err
331 }
332 return token, nil
333}
334
335var errNoToken = errors.New("no such token")
336
337// VerifyToken checks a credential and returns its label. Every prefix candidate
338// is compared in constant time, so a colliding prefix gives nothing away by timing.
339func (d *DB) VerifyToken(token string) (string, error) {
340 if len(token) < 8 {
341 return "", errNoToken
342 }
343
344 rows, err := d.sql.Query(`
345 SELECT id, label, hash, salt FROM tokens
346 WHERE prefix = ? AND revoked = 0`, token[:8])
347 if err != nil {
348 return "", err
349 }
350 defer rows.Close()
351
352 for rows.Next() {
353 var (
354 id int64
355 label string
356 hash, salt []byte
357 )
358 if err := rows.Scan(&id, &label, &hash, &salt); err != nil {
359 return "", err
360 }
361 candidate := argon2.IDKey([]byte(token), salt,
362 argonTime, argonMemory, argonThreads, argonKeyLen)
363 if subtle.ConstantTimeCompare(candidate, hash) == 1 {
364 // Best effort: a push must not fail because this write did.
365 _, _ = d.sql.Exec(`UPDATE tokens SET last_used = ? WHERE id = ?`,
366 time.Now().Unix(), id)
367 return label, nil
368 }
369 }
370 return "", errNoToken
371}
372
373// Tokens lists the live credentials for the settings page.
374func (d *DB) Tokens() ([]Token, error) {
375 rows, err := d.sql.Query(`
376 SELECT id, label, prefix, created, last_used FROM tokens
377 WHERE revoked = 0 ORDER BY created DESC`)
378 if err != nil {
379 return nil, err
380 }
381 defer rows.Close()
382
383 var out []Token
384 for rows.Next() {
385 var (
386 t Token
387 created, lastUsed int64
388 )
389 if err := rows.Scan(&t.ID, &t.Label, &t.Prefix, &created, &lastUsed); err != nil {
390 return nil, err
391 }
392 t.Created = time.Unix(created, 0).UTC()
393 if lastUsed > 0 {
394 t.LastUsed = time.Unix(lastUsed, 0).UTC()
395 }
396 out = append(out, t)
397 }
398 return out, rows.Err()
399}
400
401// RevokeToken is one UPDATE, effective on the next request.
402func (d *DB) RevokeToken(id int64) error {
403 _, err := d.sql.Exec(`UPDATE tokens SET revoked = 1 WHERE id = ?`, id)
404 return err
405}
406
407// HasTokens reports whether any credential exists, so a fresh install can be told
408// to mint one.
409func (d *DB) HasTokens() bool {
410 var n int
411 if err := d.sql.QueryRow(`SELECT COUNT(*) FROM tokens WHERE revoked = 0`).Scan(&n); err != nil {
412 return false
413 }
414 return n > 0
415}
416
417// MirrorSource is one thing the mirror lane pulls from: a whole GitHub account,
418// or a single repository on one.
419type MirrorSource struct {
420 ID int64
421 Kind string // "account" or "repo"
422 Owner string
423 Name string // empty for an account
424 Created time.Time
425}
426
427// Label is "owner" for an account and "owner/name" for a single repository.
428func (m MirrorSource) Label() string {
429 if m.Kind == sourceRepo {
430 return m.Owner + "/" + m.Name
431 }
432 return m.Owner
433}
434
435func (m MirrorSource) URL() string { return "https://github.com/" + m.Label() }
436
437const (
438 sourceAccount = "account"
439 sourceRepo = "repo"
440)
441
442// ghNamePart is GitHub's charset for a login or repository name. These values are
443// interpolated into an API URL, so they are checked at the form.
444var ghNamePart = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
445
446// ParseMirrorSource accepts "owner" or "owner/repo"; the slash is the whole grammar.
447func ParseMirrorSource(input string) (MirrorSource, error) {
448 in := strings.TrimSpace(input)
449 // A pasted GitHub URL is accepted rather than rejected.
450 in = strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(in,
451 "https://github.com/"), "github.com/"), ".git")
452 // Only a trailing slash is forgiven; a leading one means the owner is missing.
453 in = strings.TrimSuffix(in, "/")
454
455 if in == "" {
456 return MirrorSource{}, errors.New("enter an account or an owner/repository")
457 }
458 owner, name, hasSlash := strings.Cut(in, "/")
459 if !ghNamePart.MatchString(owner) || len(owner) > 100 {
460 return MirrorSource{}, fmt.Errorf("%q is not a GitHub account name", owner)
461 }
462 if !hasSlash {
463 return MirrorSource{Kind: sourceAccount, Owner: owner}, nil
464 }
465 if !ghNamePart.MatchString(name) || len(name) > 100 || strings.Contains(name, "/") {
466 return MirrorSource{}, fmt.Errorf("%q is not a repository name", name)
467 }
468 return MirrorSource{Kind: sourceRepo, Owner: owner, Name: name}, nil
469}
470
471// MirrorSources lists every configured source, accounts first.
472func (d *DB) MirrorSources() ([]MirrorSource, error) {
473 rows, err := d.sql.Query(`
474 SELECT id, kind, owner, name, created FROM mirror_sources
475 ORDER BY kind DESC, owner, name`)
476 if err != nil {
477 return nil, err
478 }
479 defer rows.Close()
480
481 var out []MirrorSource
482 for rows.Next() {
483 var m MirrorSource
484 var created int64
485 if err := rows.Scan(&m.ID, &m.Kind, &m.Owner, &m.Name, &created); err != nil {
486 return nil, err
487 }
488 m.Created = time.Unix(created, 0).UTC()
489 out = append(out, m)
490 }
491 return out, rows.Err()
492}
493
494// AddMirrorSource stores a source. Adding one twice succeeds and changes nothing.
495func (d *DB) AddMirrorSource(m MirrorSource) error {
496 _, err := d.sql.Exec(`
497 INSERT INTO mirror_sources (kind, owner, name, created) VALUES (?, ?, ?, ?)
498 ON CONFLICT(kind, owner, name) DO NOTHING`,
499 m.Kind, m.Owner, m.Name, time.Now().Unix())
500 return err
501}
502
503// DeleteMirrorSource stops syncing a source. The repositories it brought in stay
504// on disk; this site is a backup.
505func (d *DB) DeleteMirrorSource(id int64) error {
506 _, err := d.sql.Exec(`DELETE FROM mirror_sources WHERE id = ?`, id)
507 return err
508}
509
510// SeedMirrorSources adds owner as a source on first run. Guarded by a marker
511// rather than by an empty table, so deleting the last source keeps it deleted.
512func (d *DB) SeedMirrorSources(owner string) error {
513 var seeded string
514 err := d.sql.QueryRow(`SELECT value FROM settings WHERE key = 'mirror_sources_seeded'`).Scan(&seeded)
515 if err == nil {
516 return nil
517 }
518 if !errors.Is(err, sql.ErrNoRows) {
519 return err
520 }
521 if err := d.AddMirrorSource(MirrorSource{Kind: sourceAccount, Owner: owner}); err != nil {
522 return err
523 }
524 _, err = d.sql.Exec(
525 `INSERT INTO settings (key, value) VALUES ('mirror_sources_seeded', ?)`,
526 time.Now().UTC().Format(time.RFC3339))
527 return err
528}