Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

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}