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 | {"web_sessions", "expires_at <= ?"}, |
51 | {"web_sessions", "expires_at <= ?"}, |
| 52 | {"login_tokens", "expires_at <= ?"}, |
52 | {"login_tokens", "expires_at <= ?"}, |
| 53 | {"email_tokens", "expires_at <= ?"}, |
53 | {"email_tokens", "expires_at <= ?"}, |
| |
54 | {"push_tokens", "expires_at <= ?"}, |
| 54 | } |
55 | } |
| 55 | for _, e := range expired { |
56 | for _, e := range expired { |
| 56 | n, err := s.deleteBy("DELETE FROM "+e.table+" WHERE "+e.where, fmtTime(now)) |
57 | n, err := s.deleteBy("DELETE FROM "+e.table+" WHERE "+e.where, fmtTime(now)) |