Federated via graft
24 kB
679 lines
1// Package state persists sync bookkeeping so the daemon can tell, across
2// restarts, what has already been mirrored and avoid duplicating issues,
3// patches or commits on every poll.
4package state
5
6import (
7 "database/sql"
8 "fmt"
9 "os"
10 "strings"
11 "time"
12
13 _ "modernc.org/sqlite"
14)
15
16// Store wraps the local SQLite database used to track sync state.
17type Store struct {
18 db *sql.DB
19}
20
21// Open creates (if needed) and opens the state database at path.
22func Open(path string) (*Store, error) {
23 db, err := sql.Open("sqlite", path)
24 if err != nil {
25 return nil, fmt.Errorf("open state db: %w", err)
26 }
27 if err := migrate(db); err != nil {
28 db.Close()
29 return nil, fmt.Errorf("migrate state db: %w", err)
30 }
31 // This database holds every series' ActivityPub private key
32 // (ap_actor.private_key) in plaintext — it must never be
33 // group/world-readable, regardless of the umask the daemon happened to
34 // start with. Best-effort: sqlite may not have created the file yet on
35 // the very first Open before migrate runs, but by this point it has.
36 if err := os.Chmod(path, 0o600); err != nil {
37 return nil, fmt.Errorf("restrict state db permissions: %w", err)
38 }
39 return &Store{db: db}, nil
40}
41
42func (s *Store) Close() error {
43 return s.db.Close()
44}
45
46func migrate(db *sql.DB) error {
47 _, err := db.Exec(`
48CREATE TABLE IF NOT EXISTS git_cursor (
49 repo_pair TEXT PRIMARY KEY,
50 forgejo_sha TEXT NOT NULL DEFAULT '',
51 radicle_sha TEXT NOT NULL DEFAULT ''
52);
53
54CREATE TABLE IF NOT EXISTS item_mapping (
55 repo_pair TEXT NOT NULL,
56 kind TEXT NOT NULL, -- 'issue' or 'patch'
57 forgejo_id INTEGER NOT NULL DEFAULT 0,
58 radicle_id TEXT NOT NULL DEFAULT '',
59 content_hash TEXT NOT NULL DEFAULT '',
60 synced_at TEXT NOT NULL,
61 PRIMARY KEY (repo_pair, kind, forgejo_id, radicle_id)
62);
63
64CREATE INDEX IF NOT EXISTS idx_item_mapping_forgejo
65 ON item_mapping (repo_pair, kind, forgejo_id);
66CREATE INDEX IF NOT EXISTS idx_item_mapping_radicle
67 ON item_mapping (repo_pair, kind, radicle_id);
68
69-- One row per thing actually mirrored: a commit, an issue, a patch. This is
70-- what the status page's activity calendar and commit list read from — it's
71-- an append-only log, never updated, so it can't itself be a source of the
72-- kind of state-loss bug that hit item_mapping.
73CREATE TABLE IF NOT EXISTS activity_log (
74 id INTEGER PRIMARY KEY AUTOINCREMENT,
75 repo_pair TEXT NOT NULL,
76 kind TEXT NOT NULL, -- 'git', 'issue', 'patch'
77 direction TEXT NOT NULL, -- 'forgejo_to_radicle' or 'radicle_to_forgejo'
78 summary TEXT NOT NULL,
79 url TEXT NOT NULL DEFAULT '',
80 series TEXT NOT NULL DEFAULT '',
81 series_url TEXT NOT NULL DEFAULT '',
82 forgejo_id INTEGER NOT NULL DEFAULT 0,
83 radicle_id TEXT NOT NULL DEFAULT '',
84 occurred_at TEXT NOT NULL
85);
86
87CREATE INDEX IF NOT EXISTS idx_activity_log_occurred_at
88 ON activity_log (occurred_at);
89
90-- One row per series' ActivityPub actor: its RSA keypair, generated once
91-- lazily the first time that series is served over ActivityPub.
92CREATE TABLE IF NOT EXISTS ap_actor (
93 series TEXT PRIMARY KEY,
94 private_key TEXT NOT NULL,
95 public_key TEXT NOT NULL,
96 created_at TEXT NOT NULL
97);
98
99-- One row per remote actor following a series' ActivityPub actor.
100CREATE TABLE IF NOT EXISTS ap_follower (
101 series TEXT NOT NULL,
102 actor_uri TEXT NOT NULL,
103 inbox_uri TEXT NOT NULL,
104 PRIMARY KEY (series, actor_uri)
105);
106
107-- How far delivery has gotten through activity_log for each series, so a
108-- restart doesn't redeliver everything to every follower.
109CREATE TABLE IF NOT EXISTS ap_delivery_cursor (
110 series TEXT PRIMARY KEY,
111 last_delivered_id INTEGER NOT NULL DEFAULT 0
112);
113
114-- One-time secret shares: an admin (graft's own, or a peer's) encrypts a
115-- secret (typically a Forgejo token) under a key derived from a 6-digit
116-- passcode plus the high-entropy id itself. Only id_hash (never the id in
117-- reversible form) is stored, so a database dump alone can never be used
118-- to derive the decryption key. Deleted the moment it's successfully
119-- claimed, or after too many wrong passcode attempts, or past expires_at —
120-- whichever comes first.
121CREATE TABLE IF NOT EXISTS token_share (
122 id_hash TEXT PRIMARY KEY,
123 nonce TEXT NOT NULL,
124 ciphertext TEXT NOT NULL,
125 attempts INTEGER NOT NULL DEFAULT 0,
126 created_at TEXT NOT NULL,
127 expires_at TEXT NOT NULL
128);
129
130-- A repo pair added through the dashboard's self-service "add a peer" /
131-- "start a new repo" forms, gated by the shared admin password rather
132-- than a config.yaml edit + restart. forgejo_token_file (not the token
133-- itself) follows the same convention as every statically-configured
134-- pair: graft writes the submitted token out to its own chmod-600 file
135-- under the same directory config.yaml's token_file paths live in, and
136-- only the path is kept here. materialized flips to 1 once the running
137-- daemon has turned this row into a live RepoSyncer — it happens on the
138-- next sync pass, never mid-request, so a submission is never trusted
139-- before graft itself has validated it end to end.
140CREATE TABLE IF NOT EXISTS dynamic_repo (
141 id INTEGER PRIMARY KEY AUTOINCREMENT,
142 name TEXT NOT NULL UNIQUE,
143 series TEXT NOT NULL,
144 forgejo_base_url TEXT NOT NULL,
145 forgejo_owner TEXT NOT NULL,
146 forgejo_repo TEXT NOT NULL,
147 forgejo_token_file TEXT NOT NULL,
148 radicle_rid TEXT NOT NULL,
149 radicle_http_base_url TEXT NOT NULL,
150 radicle_explorer_url TEXT NOT NULL DEFAULT '',
151 radicle_rad_home TEXT NOT NULL,
152 sync_git INTEGER NOT NULL DEFAULT 1,
153 sync_issues INTEGER NOT NULL DEFAULT 1,
154 sync_patches INTEGER NOT NULL DEFAULT 1,
155 created_at TEXT NOT NULL,
156 materialized INTEGER NOT NULL DEFAULT 0
157);
158`)
159 if err != nil {
160 return err
161 }
162 // These columns were added after the table already shipped once; each
163 // ALTER is a no-op (ignored) on a fresh db where the CREATE above
164 // already included it.
165 for _, stmt := range []string{
166 `ALTER TABLE activity_log ADD COLUMN url TEXT NOT NULL DEFAULT ''`,
167 `ALTER TABLE activity_log ADD COLUMN series TEXT NOT NULL DEFAULT ''`,
168 `ALTER TABLE activity_log ADD COLUMN series_url TEXT NOT NULL DEFAULT ''`,
169 `ALTER TABLE activity_log ADD COLUMN forgejo_id INTEGER NOT NULL DEFAULT 0`,
170 `ALTER TABLE activity_log ADD COLUMN radicle_id TEXT NOT NULL DEFAULT ''`,
171 } {
172 if _, err := db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
173 return err
174 }
175 }
176 return nil
177}
178
179// GitCursor returns the last-synced commit SHAs on each side for a repo pair.
180func (s *Store) GitCursor(repoPair string) (forgejoSHA, radicleSHA string, err error) {
181 row := s.db.QueryRow(`SELECT forgejo_sha, radicle_sha FROM git_cursor WHERE repo_pair = ?`, repoPair)
182 err = row.Scan(&forgejoSHA, &radicleSHA)
183 if err == sql.ErrNoRows {
184 return "", "", nil
185 }
186 return forgejoSHA, radicleSHA, err
187}
188
189// SetGitCursor records the commit SHAs last observed on each side.
190func (s *Store) SetGitCursor(repoPair, forgejoSHA, radicleSHA string) error {
191 _, err := s.db.Exec(`
192INSERT INTO git_cursor (repo_pair, forgejo_sha, radicle_sha) VALUES (?, ?, ?)
193ON CONFLICT (repo_pair) DO UPDATE SET forgejo_sha = excluded.forgejo_sha, radicle_sha = excluded.radicle_sha
194`, repoPair, forgejoSHA, radicleSHA)
195 return err
196}
197
198// ItemMapping records the correspondence between a Forgejo issue/PR and its
199// mirrored Radicle issue/patch, plus a content hash used to detect edits
200// that need re-syncing.
201type ItemMapping struct {
202 Kind string // "issue" or "patch"
203 ForgejoID int64
204 RadicleID string
205 ContentHash string
206}
207
208// FindByForgejoID looks up an existing mapping created for a Forgejo item.
209func (s *Store) FindByForgejoID(repoPair, kind string, forgejoID int64) (*ItemMapping, error) {
210 row := s.db.QueryRow(`
211SELECT radicle_id, content_hash FROM item_mapping
212WHERE repo_pair = ? AND kind = ? AND forgejo_id = ?
213`, repoPair, kind, forgejoID)
214 m := &ItemMapping{Kind: kind, ForgejoID: forgejoID}
215 err := row.Scan(&m.RadicleID, &m.ContentHash)
216 if err == sql.ErrNoRows {
217 return nil, nil
218 }
219 return m, err
220}
221
222// FindByRadicleID looks up an existing mapping created for a Radicle item.
223func (s *Store) FindByRadicleID(repoPair, kind, radicleID string) (*ItemMapping, error) {
224 row := s.db.QueryRow(`
225SELECT forgejo_id, content_hash FROM item_mapping
226WHERE repo_pair = ? AND kind = ? AND radicle_id = ?
227`, repoPair, kind, radicleID)
228 m := &ItemMapping{Kind: kind, RadicleID: radicleID}
229 err := row.Scan(&m.ForgejoID, &m.ContentHash)
230 if err == sql.ErrNoRows {
231 return nil, nil
232 }
233 return m, err
234}
235
236// Upsert records or updates a mapping between a Forgejo item and its
237// Radicle counterpart.
238func (s *Store) Upsert(repoPair string, m ItemMapping) error {
239 _, err := s.db.Exec(`
240INSERT INTO item_mapping (repo_pair, kind, forgejo_id, radicle_id, content_hash, synced_at)
241VALUES (?, ?, ?, ?, ?, datetime('now'))
242ON CONFLICT (repo_pair, kind, forgejo_id, radicle_id)
243DO UPDATE SET content_hash = excluded.content_hash, synced_at = excluded.synced_at
244`, repoPair, m.Kind, m.ForgejoID, m.RadicleID, m.ContentHash)
245 return err
246}
247
248// Activity is one thing that was actually mirrored, for the status page.
249type Activity struct {
250 RepoPair string // the sync pair that did it, e.g. "graft-test-v2-f1"
251 Series string // the human-facing repo group this pair belongs to,
252 // e.g. "graft-test-v2" — several pairs (one per forge/Radicle side)
253 // can share a series so the dashboard shows one row per repo, not
254 // one per pair.
255 SeriesURL string // where clicking the series' own name should go
256 Kind string // "git", "issue", "patch"
257 Direction string // ForgejoToRadicle or RadicleToForgejo
258 Summary string
259 URL string // where to send someone who clicks this entry — the
260 // forge or Radicle page for the thing that now exists because of
261 // this sync; "" if none is known.
262 ForgejoID int64 // issue/PR number on Forgejo, for kind "issue"/"patch"; 0 for "git"
263 RadicleID string // issue/patch id on Radicle, for kind "issue"/"patch"; "" for "git"
264}
265
266// Direction values for Activity.Direction, kept as constants so callers
267// can't typo a value the status page's rendering silently fails to
268// recognize.
269const (
270 ForgejoToRadicle = "forgejo_to_radicle"
271 RadicleToForgejo = "radicle_to_forgejo"
272)
273
274// LogActivity records one Activity. Call it once per commit/issue/patch,
275// not once per sync pass.
276func (s *Store) LogActivity(a Activity) error {
277 _, err := s.db.Exec(`
278INSERT INTO activity_log (repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at)
279VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
280`, a.RepoPair, a.Kind, a.Direction, a.Summary, a.URL, a.Series, a.SeriesURL, a.ForgejoID, a.RadicleID, time.Now().UTC().Format(time.RFC3339))
281 return err
282}
283
284// ActivityEntry is one row of activity_log.
285type ActivityEntry struct {
286 ID int64 // activity_log.id, used as the stable suffix of ActivityPub Note URIs
287 Activity
288 OccurredAt time.Time
289}
290
291// ActivitySince returns every activity entry at or after since, newest first.
292func (s *Store) ActivitySince(since time.Time) ([]ActivityEntry, error) {
293 rows, err := s.db.Query(`
294SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log
295WHERE occurred_at >= ?
296ORDER BY occurred_at DESC
297`, since.UTC().Format(time.RFC3339))
298 if err != nil {
299 return nil, err
300 }
301 return scanActivityRows(rows)
302}
303
304// ActivityForSeries returns the most recent activity entries for one
305// series, newest first, capped at limit — used to build that series'
306// ActivityPub outbox.
307func (s *Store) ActivityForSeries(series string, limit int) ([]ActivityEntry, error) {
308 rows, err := s.db.Query(`
309SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log
310WHERE series = ?
311ORDER BY occurred_at DESC
312LIMIT ?
313`, series, limit)
314 if err != nil {
315 return nil, err
316 }
317 return scanActivityRows(rows)
318}
319
320// ActivityByID looks up one activity_log row by id, e.g. to resolve an
321// ActivityPub Note URI (/actors/{series}/notes/{id}) back to the mirrored
322// item it describes.
323func (s *Store) ActivityByID(id int64) (*ActivityEntry, error) {
324 row := s.db.QueryRow(`
325SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log
326WHERE id = ?
327`, id)
328 var e ActivityEntry
329 var occurredAt string
330 err := row.Scan(&e.ID, &e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &e.ForgejoID, &e.RadicleID, &occurredAt)
331 if err == sql.ErrNoRows {
332 return nil, nil
333 }
334 if err != nil {
335 return nil, err
336 }
337 e.OccurredAt, err = time.Parse(time.RFC3339, occurredAt)
338 if err != nil {
339 return nil, fmt.Errorf("parse occurred_at %q: %w", occurredAt, err)
340 }
341 return &e, nil
342}
343
344func scanActivityRows(rows *sql.Rows) ([]ActivityEntry, error) {
345 defer rows.Close()
346 var out []ActivityEntry
347 for rows.Next() {
348 var e ActivityEntry
349 var occurredAt string
350 if err := rows.Scan(&e.ID, &e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &e.ForgejoID, &e.RadicleID, &occurredAt); err != nil {
351 return nil, err
352 }
353 var err error
354 e.OccurredAt, err = time.Parse(time.RFC3339, occurredAt)
355 if err != nil {
356 return nil, fmt.Errorf("parse occurred_at %q: %w", occurredAt, err)
357 }
358 out = append(out, e)
359 }
360 return out, rows.Err()
361}
362
363// ActorKeys is one series' ActivityPub actor keypair, PEM-encoded.
364type ActorKeys struct {
365 PrivateKeyPEM string
366 PublicKeyPEM string
367}
368
369// ActorKeys returns the stored keypair for a series, or nil if none has
370// been generated yet.
371func (s *Store) ActorKeys(series string) (*ActorKeys, error) {
372 row := s.db.QueryRow(`SELECT private_key, public_key FROM ap_actor WHERE series = ?`, series)
373 var k ActorKeys
374 err := row.Scan(&k.PrivateKeyPEM, &k.PublicKeyPEM)
375 if err == sql.ErrNoRows {
376 return nil, nil
377 }
378 if err != nil {
379 return nil, err
380 }
381 return &k, nil
382}
383
384// SaveActorKeys stores a newly generated keypair for a series. Safe to call
385// concurrently: if another goroutine already inserted one, this becomes a
386// no-op via INSERT OR IGNORE, and the caller should re-read with
387// ActorKeys to get whichever copy won.
388func (s *Store) SaveActorKeys(series string, k ActorKeys) error {
389 _, err := s.db.Exec(`
390INSERT OR IGNORE INTO ap_actor (series, private_key, public_key, created_at)
391VALUES (?, ?, ?, ?)
392`, series, k.PrivateKeyPEM, k.PublicKeyPEM, time.Now().UTC().Format(time.RFC3339))
393 return err
394}
395
396// Follower is one remote actor following a series' ActivityPub actor.
397type Follower struct {
398 ActorURI string
399 InboxURI string
400}
401
402// SaveFollower records (or updates the inbox URL of) a follower.
403func (s *Store) SaveFollower(series, actorURI, inboxURI string) error {
404 _, err := s.db.Exec(`
405INSERT INTO ap_follower (series, actor_uri, inbox_uri) VALUES (?, ?, ?)
406ON CONFLICT (series, actor_uri) DO UPDATE SET inbox_uri = excluded.inbox_uri
407`, series, actorURI, inboxURI)
408 return err
409}
410
411// RemoveFollower drops a follower, e.g. on Undo{Follow}.
412func (s *Store) RemoveFollower(series, actorURI string) error {
413 _, err := s.db.Exec(`DELETE FROM ap_follower WHERE series = ? AND actor_uri = ?`, series, actorURI)
414 return err
415}
416
417// Followers lists everyone following a series.
418func (s *Store) Followers(series string) ([]Follower, error) {
419 rows, err := s.db.Query(`SELECT actor_uri, inbox_uri FROM ap_follower WHERE series = ?`, series)
420 if err != nil {
421 return nil, err
422 }
423 defer rows.Close()
424 var out []Follower
425 for rows.Next() {
426 var f Follower
427 if err := rows.Scan(&f.ActorURI, &f.InboxURI); err != nil {
428 return nil, err
429 }
430 out = append(out, f)
431 }
432 return out, rows.Err()
433}
434
435// FollowerCount is len(Followers(series)) without building the slice.
436func (s *Store) FollowerCount(series string) (int, error) {
437 row := s.db.QueryRow(`SELECT COUNT(*) FROM ap_follower WHERE series = ?`, series)
438 var n int
439 err := row.Scan(&n)
440 return n, err
441}
442
443// SeriesWithFollowers lists every series that has at least one follower —
444// the set worth checking for new activity to deliver.
445func (s *Store) SeriesWithFollowers() ([]string, error) {
446 rows, err := s.db.Query(`SELECT DISTINCT series FROM ap_follower`)
447 if err != nil {
448 return nil, err
449 }
450 defer rows.Close()
451 var out []string
452 for rows.Next() {
453 var series string
454 if err := rows.Scan(&series); err != nil {
455 return nil, err
456 }
457 out = append(out, series)
458 }
459 return out, rows.Err()
460}
461
462// DeliveryCursor returns how far ActivityPub delivery has gotten through
463// activity_log for a series (0 if nothing has been delivered yet).
464func (s *Store) DeliveryCursor(series string) (int64, error) {
465 row := s.db.QueryRow(`SELECT last_delivered_id FROM ap_delivery_cursor WHERE series = ?`, series)
466 var id int64
467 err := row.Scan(&id)
468 if err == sql.ErrNoRows {
469 return 0, nil
470 }
471 return id, err
472}
473
474// SetDeliveryCursor records how far delivery has gotten for a series.
475func (s *Store) SetDeliveryCursor(series string, id int64) error {
476 _, err := s.db.Exec(`
477INSERT INTO ap_delivery_cursor (series, last_delivered_id) VALUES (?, ?)
478ON CONFLICT (series) DO UPDATE SET last_delivered_id = excluded.last_delivered_id
479`, series, id)
480 return err
481}
482
483// NewActivityForSeries returns activity_log entries for a series with
484// id > afterID, oldest first — the delivery order for ActivityPub.
485func (s *Store) NewActivityForSeries(series string, afterID int64) ([]ActivityEntry, error) {
486 rows, err := s.db.Query(`
487SELECT id, repo_pair, kind, direction, summary, url, series, series_url, forgejo_id, radicle_id, occurred_at FROM activity_log
488WHERE series = ? AND id > ?
489ORDER BY id ASC
490`, series, afterID)
491 if err != nil {
492 return nil, err
493 }
494 return scanActivityRows(rows)
495}
496
497// TokenShare is one encrypted one-time secret. Nonce and Ciphertext are
498// base64-encoded AES-256-GCM output; the decryption key is derived from
499// the passcode the claimant supplies plus the share's own id (which is
500// never stored — only IDHash is), so a database dump alone never yields
501// enough to decrypt.
502type TokenShare struct {
503 IDHash string
504 Nonce string
505 Ciphertext string
506 Attempts int
507 ExpiresAt time.Time
508}
509
510// CreateTokenShare stores a new encrypted share, indexed by the hash of
511// its id (the plaintext id is only ever known to whoever holds the URL).
512func (s *Store) CreateTokenShare(idHash, nonce, ciphertext string, expiresAt time.Time) error {
513 _, err := s.db.Exec(`
514INSERT INTO token_share (id_hash, nonce, ciphertext, attempts, created_at, expires_at)
515VALUES (?, ?, ?, 0, ?, ?)
516`, idHash, nonce, ciphertext, time.Now().UTC().Format(time.RFC3339), expiresAt.UTC().Format(time.RFC3339))
517 return err
518}
519
520// GetTokenShare looks up a share by id hash. Returns nil, nil if it
521// doesn't exist or has already expired (expiry is checked here rather
522// than left to a background sweep, so a stale row can never be claimed
523// even if cleanup hasn't run yet).
524func (s *Store) GetTokenShare(idHash string) (*TokenShare, error) {
525 row := s.db.QueryRow(`SELECT id_hash, nonce, ciphertext, attempts, expires_at FROM token_share WHERE id_hash = ?`, idHash)
526 var t TokenShare
527 var expiresAt string
528 if err := row.Scan(&t.IDHash, &t.Nonce, &t.Ciphertext, &t.Attempts, &expiresAt); err != nil {
529 if err == sql.ErrNoRows {
530 return nil, nil
531 }
532 return nil, err
533 }
534 exp, err := time.Parse(time.RFC3339, expiresAt)
535 if err != nil {
536 return nil, err
537 }
538 t.ExpiresAt = exp
539 if time.Now().UTC().After(exp) {
540 _ = s.DeleteTokenShare(idHash)
541 return nil, nil
542 }
543 return &t, nil
544}
545
546// IncrementTokenShareAttempts records one failed passcode attempt and
547// returns the new total, so the caller can destroy the share once too
548// many wrong guesses have been made — the actual brute-force defense,
549// since a 6-digit passcode alone is far too small a space to rely on
550// key-derivation cost against a determined online attacker.
551func (s *Store) IncrementTokenShareAttempts(idHash string) (int, error) {
552 _, err := s.db.Exec(`UPDATE token_share SET attempts = attempts + 1 WHERE id_hash = ?`, idHash)
553 if err != nil {
554 return 0, err
555 }
556 row := s.db.QueryRow(`SELECT attempts FROM token_share WHERE id_hash = ?`, idHash)
557 var n int
558 if err := row.Scan(&n); err != nil {
559 return 0, err
560 }
561 return n, nil
562}
563
564// DeleteTokenShare destroys a share — called on a successful claim (it's
565// one-time by design), on exceeding the attempt limit (fail closed), or
566// on expiry.
567func (s *Store) DeleteTokenShare(idHash string) error {
568 _, err := s.db.Exec(`DELETE FROM token_share WHERE id_hash = ?`, idHash)
569 return err
570}
571
572// DynamicRepo is one repo pair added through the dashboard's self-service
573// onboarding forms rather than a config.yaml edit.
574type DynamicRepo struct {
575 ID int64
576 Name string
577 Series string
578 ForgejoBaseURL string
579 ForgejoOwner string
580 ForgejoRepo string
581 ForgejoTokenFile string
582 RadicleRID string
583 RadicleHTTPBaseURL string
584 RadicleExplorerURL string
585 RadicleRadHome string
586 SyncGit bool
587 SyncIssues bool
588 SyncPatches bool
589 Materialized bool
590}
591
592// CreateDynamicRepo inserts a newly-submitted repo pair, active as soon
593// as the next sync pass picks it up. Fails if the name is already taken —
594// the same uniqueness graft would need across config.yaml pairs anyway.
595func (s *Store) CreateDynamicRepo(r DynamicRepo) (int64, error) {
596 res, err := s.db.Exec(`
597INSERT INTO dynamic_repo (
598 name, series, forgejo_base_url, forgejo_owner, forgejo_repo, forgejo_token_file,
599 radicle_rid, radicle_http_base_url, radicle_explorer_url, radicle_rad_home,
600 sync_git, sync_issues, sync_patches, created_at, materialized
601) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
602`, r.Name, r.Series, r.ForgejoBaseURL, r.ForgejoOwner, r.ForgejoRepo, r.ForgejoTokenFile,
603 r.RadicleRID, r.RadicleHTTPBaseURL, r.RadicleExplorerURL, r.RadicleRadHome,
604 boolToInt(r.SyncGit), boolToInt(r.SyncIssues), boolToInt(r.SyncPatches), time.Now().UTC().Format(time.RFC3339))
605 if err != nil {
606 return 0, err
607 }
608 return res.LastInsertId()
609}
610
611// UnmaterializedDynamicRepos returns every dynamic_repo row the running
612// daemon hasn't yet turned into a live RepoSyncer.
613func (s *Store) UnmaterializedDynamicRepos() ([]DynamicRepo, error) {
614 rows, err := s.db.Query(`
615SELECT id, name, series, forgejo_base_url, forgejo_owner, forgejo_repo, forgejo_token_file,
616 radicle_rid, radicle_http_base_url, radicle_explorer_url, radicle_rad_home,
617 sync_git, sync_issues, sync_patches
618FROM dynamic_repo WHERE materialized = 0
619`)
620 if err != nil {
621 return nil, err
622 }
623 defer rows.Close()
624 var out []DynamicRepo
625 for rows.Next() {
626 var r DynamicRepo
627 var g, i, p int
628 if err := rows.Scan(&r.ID, &r.Name, &r.Series, &r.ForgejoBaseURL, &r.ForgejoOwner, &r.ForgejoRepo, &r.ForgejoTokenFile,
629 &r.RadicleRID, &r.RadicleHTTPBaseURL, &r.RadicleExplorerURL, &r.RadicleRadHome, &g, &i, &p); err != nil {
630 return nil, err
631 }
632 r.SyncGit, r.SyncIssues, r.SyncPatches = g != 0, i != 0, p != 0
633 out = append(out, r)
634 }
635 return out, rows.Err()
636}
637
638// MarkDynamicRepoMaterialized flips a row so it's never re-registered on
639// a later pass.
640func (s *Store) MarkDynamicRepoMaterialized(id int64) error {
641 _, err := s.db.Exec(`UPDATE dynamic_repo SET materialized = 1 WHERE id = ?`, id)
642 return err
643}
644
645// AllDynamicRepos returns every dynamic_repo row regardless of
646// materialization state — used at startup to rebuild the full set of
647// live pairs after a restart, since these rows persist across it but
648// config.yaml doesn't know about them.
649func (s *Store) AllDynamicRepos() ([]DynamicRepo, error) {
650 rows, err := s.db.Query(`
651SELECT id, name, series, forgejo_base_url, forgejo_owner, forgejo_repo, forgejo_token_file,
652 radicle_rid, radicle_http_base_url, radicle_explorer_url, radicle_rad_home,
653 sync_git, sync_issues, sync_patches
654FROM dynamic_repo
655`)
656 if err != nil {
657 return nil, err
658 }
659 defer rows.Close()
660 var out []DynamicRepo
661 for rows.Next() {
662 var r DynamicRepo
663 var g, i, p int
664 if err := rows.Scan(&r.ID, &r.Name, &r.Series, &r.ForgejoBaseURL, &r.ForgejoOwner, &r.ForgejoRepo, &r.ForgejoTokenFile,
665 &r.RadicleRID, &r.RadicleHTTPBaseURL, &r.RadicleExplorerURL, &r.RadicleRadHome, &g, &i, &p); err != nil {
666 return nil, err
667 }
668 r.SyncGit, r.SyncIssues, r.SyncPatches = g != 0, i != 0, p != 0
669 out = append(out, r)
670 }
671 return out, rows.Err()
672}
673
674func boolToInt(b bool) int {
675 if b {
676 return 1
677 }
678 return 0
679}