Commit c40a116572
c40a1165727c27b0de99f6143dd1399e4c1895a4
parent: 298ac0b456
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-28 08:57 UTC
store: push tokens for receive-pack
Ref #282
Layout: unified · split
internal/store/migrations/0063_push_tokens.down.sql
added
+1
| @@ -0,0 +1 @@ |
| 1 | DROP TABLE push_tokens; |
internal/store/migrations/0063_push_tokens.up.sql
added
+10
| @@ -0,0 +1,10 @@ |
| 1 | -- One row per receive-pack in flight. The hook names its push by the |
| 2 | -- token; hookd answers only a live one. Only the SHA-256 is stored. |
| 3 | CREATE TABLE push_tokens ( |
| 4 | token_hash TEXT PRIMARY KEY, |
| 5 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, |
| 6 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| 7 | scope TEXT NOT NULL, |
| 8 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), |
| 9 | expires_at TEXT NOT NULL |
| 10 | ); |
internal/store/pushtokens.go
added
+52
| @@ -0,0 +1,52 @@ |
| 1 | package store |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "errors" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | // PushToken is the receive-pack a hook request speaks for. |
| 10 | type PushToken struct { |
| 11 | RepoID int64 |
| 12 | UserID int64 |
| 13 | Scope string |
| 14 | } |
| 15 | |
| 16 | // pushTokenTTL bounds a row whose receive-pack died before deleting it. |
| 17 | const pushTokenTTL = 24 * time.Hour |
| 18 | |
| 19 | // CreatePushToken records a token for one receive-pack and returns it. |
| 20 | func (s *Store) CreatePushToken(repoID, userID int64, scope string) (string, error) { |
| 21 | token, hash, err := NewToken() |
| 22 | if err != nil { |
| 23 | return "", err |
| 24 | } |
| 25 | _, err = s.DB.Exec( |
| 26 | "INSERT INTO push_tokens (token_hash, repo_id, user_id, scope, expires_at) VALUES (?, ?, ?, ?, ?)", |
| 27 | hash, repoID, userID, scope, fmtTime(time.Now().Add(pushTokenTTL))) |
| 28 | if err != nil { |
| 29 | return "", err |
| 30 | } |
| 31 | return token, nil |
| 32 | } |
| 33 | |
| 34 | // PushTokenByHash looks up a live token by its stored hash. ErrNotFound |
| 35 | // covers both an absent row and one that has expired. |
| 36 | func (s *Store) PushTokenByHash(hash string) (PushToken, error) { |
| 37 | var t PushToken |
| 38 | err := s.DB.QueryRow( |
| 39 | "SELECT repo_id, user_id, scope FROM push_tokens WHERE token_hash = ? AND expires_at > ?", |
| 40 | hash, fmtTime(time.Now())).Scan(&t.RepoID, &t.UserID, &t.Scope) |
| 41 | if errors.Is(err, sql.ErrNoRows) { |
| 42 | return PushToken{}, ErrNotFound |
| 43 | } |
| 44 | return t, err |
| 45 | } |
| 46 | |
| 47 | // DeletePushToken removes a token by its raw value, once its receive-pack |
| 48 | // is done with it. |
| 49 | func (s *Store) DeletePushToken(token string) error { |
| 50 | _, err := s.DB.Exec("DELETE FROM push_tokens WHERE token_hash = ?", HashToken(token)) |
| 51 | return err |
| 52 | } |
internal/store/pushtokens_test.go
added
+49
| @@ -0,0 +1,49 @@ |
| 1 | package store |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "testing" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | func TestPushTokens(t *testing.T) { |
| 10 | s := open(t) |
| 11 | if err := s.MigrateUp(); err != nil { |
| 12 | t.Fatal(err) |
| 13 | } |
| 14 | uid, err := s.CreateUser("alice", false) |
| 15 | if err != nil { |
| 16 | t.Fatal(err) |
| 17 | } |
| 18 | repoID, err := s.CreateRepo("user", uid, "app", "public") |
| 19 | if err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | token, err := s.CreatePushToken(repoID, uid, "full") |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | got, err := s.PushTokenByHash(HashToken(token)) |
| 27 | if err != nil || got != (PushToken{RepoID: repoID, UserID: uid, Scope: "full"}) { |
| 28 | t.Fatalf("lookup = %+v, %v", got, err) |
| 29 | } |
| 30 | if err := s.DeletePushToken(token); err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | if _, err := s.PushTokenByHash(HashToken(token)); !errors.Is(err, ErrNotFound) { |
| 34 | t.Fatalf("after delete: %v", err) |
| 35 | } |
| 36 | |
| 37 | // A token whose receive-pack never cleaned up is swept after a day. |
| 38 | stale, err := s.CreatePushToken(repoID, uid, "full") |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | swept, err := s.Sweep(Retention{}, time.Now().Add(25*time.Hour)) |
| 43 | if err != nil || swept["push_tokens"] != 1 { |
| 44 | t.Fatalf("sweep = %v, %v", swept, err) |
| 45 | } |
| 46 | if _, err := s.PushTokenByHash(HashToken(stale)); !errors.Is(err, ErrNotFound) { |
| 47 | t.Fatalf("after sweep: %v", err) |
| 48 | } |
| 49 | } |
internal/store/retention.go
+1
| @@ -51,6 +51,7 @@ func (s *Store) Sweep(r Retention, now time.Time) (Swept, error) { |
| 51 | 51 | {"web_sessions", "expires_at <= ?"}, |
| 52 | 52 | {"login_tokens", "expires_at <= ?"}, |
| 53 | 53 | {"email_tokens", "expires_at <= ?"}, |
| 54 | {"push_tokens", "expires_at <= ?"}, |
| 54 | 55 | } |
| 55 | 56 | for _, e := range expired { |
| 56 | 57 | n, err := s.deleteBy("DELETE FROM "+e.table+" WHERE "+e.where, fmtTime(now)) |