iOS push notifications (server) !447
36 files changed, +5416 −14
Layout: unified · split
.gitbay/wiki/Admin.org +39 −1
| @@ -118,6 +118,38 @@ default, is right when gitbayd terminates TLS itself. | ||
| 118 | 118 | registration and self-service =email add=; in closed mode you may omit |
| 119 | 119 | it entirely and assert addresses by hand (below). |
| 120 | 120 | |
| 121 | ** [push] | |
| 122 | Push notifications to Apple devices, delivered by gitbayd talking to | |
| 123 | APNs directly over HTTP/2, authenticated by an ES256 JWT signed with an | |
| 124 | operator-supplied provider key. Off unless configured. | |
| 125 | ||
| 126 | - =enabled= (false). | |
| 127 | - =key_file= — path to the =.p8= provider key from Apple's developer | |
| 128 | portal (Certificates, Identifiers & Profiles → Keys). It belongs at | |
| 129 | =/etc/gitbay/apns.p8=, mode 0600, owned by the account gitbayd runs | |
| 130 | as. Read and validated at startup: it must parse as a PEM-wrapped | |
| 131 | PKCS#8 EC (P-256) private key, or the daemon refuses to start rather | |
| 132 | than fill a queue nobody is watching. | |
| 133 | - =key_id=, =team_id= — the key's id and your Apple developer team id, | |
| 134 | both from the same portal page. | |
| 135 | - =topic= — the app's bundle identifier. *An APNs key belongs to a | |
| 136 | bundle ID.* gitbay.org pushes to the App Store build under its own | |
| 137 | bundle id; a self-hoster who wants push ships their own iOS build | |
| 138 | under their own bundle id, with its own =.p8= key from their own | |
| 139 | developer account, and points =topic= at that id. There is no way to | |
| 140 | push to someone else's build, by design — this is Apple's model, not | |
| 141 | gitbay's. | |
| 142 | - =environment= — =production= or =sandbox=, naming the APNs host | |
| 143 | rather than taking a URL, so a typo cannot aim the key at a host that | |
| 144 | is not Apple's. | |
| 145 | ||
| 146 | All five of =key_file=, =key_id=, =team_id=, =topic= and =environment= | |
| 147 | are required when =enabled= is true; validation runs at config load, | |
| 148 | so a misconfigured =[push]= is caught before the daemon serves | |
| 149 | anything. The delivery queue (a device's undelivered and attempted | |
| 150 | pushes) is capped the same way the mail queue is, by =[retention] | |
| 151 | push=. | |
| 152 | ||
| 121 | 153 | ** [api] |
| 122 | 154 | - =enabled= (false) — the JSON API surface; see [[API]]. Off |
| 123 | 155 | means no credential-bearing HTTP endpoint exists at all. |
| @@ -267,7 +299,7 @@ Every background worker keeps a backlog and a failure state. An instance | ||
| 267 | 299 | admin reads them all in one place: |
| 268 | 300 | |
| 269 | 301 | #+begin_src sh |
| 270 | gitbay dashboard --json | jq .queues # webhooks, mail, mirrors, builds, deps | |
| 302 | gitbay dashboard --json | jq .queues # webhooks, mail, push, mirrors, builds, deps | |
| 271 | 303 | #+end_src |
| 272 | 304 | |
| 273 | 305 | Per worker: pending, retrying (pending with a failed attempt) and |
| @@ -279,6 +311,12 @@ mirrors list the ones whose last sync failed; | ||
| 279 | 311 | dependency checks list the ones whose last check errored. Non-admins get |
| 280 | 312 | no =queues= key at all. |
| 281 | 313 | |
| 314 | Push rows name the device id, never the token. Watch this one after | |
| 315 | configuring =[push]=: a =key_id= or =team_id= Apple did not issue passes | |
| 316 | config validation, which can only check that the =.p8= parses, and then | |
| 317 | every send comes back =403 InvalidProviderToken= and dead-letters on its | |
| 318 | first attempt. | |
| 319 | ||
| 282 | 320 | In accounts mode the same read renders at =/admin=, linked from the rail |
| 283 | 321 | for admins. Anyone else gets a 404 there. |
| 284 | 322 | |
.gitbay/wiki/Parity.org +11
| @@ -331,6 +331,10 @@ client has no use for one (krz/gitbay#57). | ||
| 331 | 331 | | notification inbox | yes | yes | yes | |
| 332 | 332 | | activity mail on, off | yes | yes | yes | |
| 333 | 333 | | watch writable repos | yes | yes | yes | |
| 334 | | push device add | yes | yes | no | | |
| 335 | | push device list | yes | yes | no | | |
| 336 | | push device remove | yes | yes | no | | |
| 337 | | activity push on, off | yes | yes | no | | |
| 334 | 338 | | web colour scheme | yes | yes | n/a | |
| 335 | 339 | | API token mint | yes | no | no | |
| 336 | 340 | | account export bundle | yes | yes | n/a | |
| @@ -351,6 +355,13 @@ to every repository you can write to, without a row per repository. A | ||
| 351 | 355 | mute wins over owning the repository, having written the thread, or the |
| 352 | 356 | preference. |
| 353 | 357 | |
| 358 | Push is the third leg beside inbox and mail, delivered to Apple devices | |
| 359 | an account has registered. =notifications device add= runs on every | |
| 360 | surface like every other command, but no web page offers a form for | |
| 361 | it, because only the iOS app can produce an APNs device token — a | |
| 362 | browser has no way to ask Apple for one. =device list= and =device | |
| 363 | remove= have no such limit and are yes on the web like the rest. | |
| 364 | ||
| 354 | 365 | A login link is requested from the login page by username or verified |
| 355 | 366 | address, and arrives by mail: it works once and expires in fifteen |
| 356 | 367 | minutes. The row is =n/a= for the CLI because a terminal with a |
.gitbay/wiki/Users.org +22 −3
| @@ -680,9 +680,10 @@ control under Appearance. | ||
| 680 | 680 | When the instance has SMTP configured, activity mails you as well as |
| 681 | 681 | filing the inbox row: someone opens an issue or MR on your repository, |
| 682 | 682 | comments where you are a participant (author, commenter, reviewer, or |
| 683 | mentioned), reviews, closes, or merges. =notifications settings mail | |
| 684 | off= keeps the inbox and stops that mail (login links are not activity | |
| 685 | and still arrive); the account page has the same switch. | |
| 683 | mentioned), reviews, closes, merges, or assigns you. | |
| 684 | =notifications settings mail off= keeps the inbox and stops that mail | |
| 685 | (login links are not activity and still arrive); the account page has | |
| 686 | the same switch. | |
| 686 | 687 | =notifications settings watch on= makes you a recipient of every issue |
| 687 | 688 | and merge request on the repositories you can write to, as if you had |
| 688 | 689 | run =repo watch= on each: it is consulted when a notice is delivered, |
| @@ -696,6 +697,24 @@ someone else. | ||
| 696 | 697 | You are never mailed about your own actions, and only verified primary |
| 697 | 698 | addresses receive anything. Delivery retries on relay failure. |
| 698 | 699 | |
| 700 | Push is the same activity again, delivered to a phone: the iOS app | |
| 701 | registers a device, and =notifications settings push off= silences it | |
| 702 | the way =mail off= silences mail, without deregistering anything. | |
| 703 | =notifications device list= shows what is registered (token shown | |
| 704 | truncated); =notifications device remove <id>= drops one by hand, from | |
| 705 | the CLI or the account page. A device is also dropped on its own the | |
| 706 | moment Apple reports the token dead, so an app deleted from a phone | |
| 707 | stops costing anything without you having to notice. Push only works | |
| 708 | when the instance operator has configured it: on an instance with | |
| 709 | =[push] enabled = false=, =notifications device add= refuses rather | |
| 710 | than registering a device nothing can deliver to. | |
| 711 | ||
| 712 | Push notifications carry the notice in full: a private repository's | |
| 713 | name and the issue or merge request number reach Apple and can appear | |
| 714 | on a lock screen, the same as any other text a phone shows in a | |
| 715 | notification. This is deliberate, not an oversight — weigh it against | |
| 716 | what you keep in a private repository before registering a device. | |
| 717 | ||
| 699 | 718 | * Web vocabulary |
| 700 | 719 | |
| 701 | 720 | Sign in / Log out, Search, sentence-case headings and buttons, product |
CHANGELOG.org +35
| @@ -4,6 +4,41 @@ Versioning follows semver from v0.1.0. Database migrations run | ||
| 4 | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | 5 | anything beyond "replace the binary and restart" is needed. |
| 6 | 6 | |
| 7 | * v1.32.0 — unreleased | |
| 8 | ||
| 9 | Push notifications to iOS devices (#89): activity reaches a registered | |
| 10 | phone the way it already reaches the inbox and mail. | |
| 11 | ||
| 12 | - gitbayd talks to APNs directly over HTTP/2, authenticated by an | |
| 13 | ES256 JWT signed with an operator-supplied =.p8= provider key. New | |
| 14 | config section =[push]=: =enabled=, =key_file=, =key_id=, =team_id=, | |
| 15 | =topic= (the app's bundle identifier) and =environment= | |
| 16 | (=production= | =sandbox=), all validated at load when enabled — a | |
| 17 | misconfigured =[push]= refuses to start rather than filling a queue | |
| 18 | nobody is watching. An APNs key belongs to a bundle id: a self-hoster | |
| 19 | ships their own build under their own =topic= to use this; the App | |
| 20 | Store build talks to gitbay.org. | |
| 21 | - Migration 0059: =push_devices= and =push_queue=, and | |
| 22 | =users.notify_push= (default on). | |
| 23 | - =notifications device add= (token on stdin), =device list= (token | |
| 24 | shown truncated), =device remove <id>=, and =notifications settings | |
| 25 | push on|off=. A device is dropped automatically when Apple reports | |
| 26 | the token dead (410 Unregistered or BadDeviceToken). With =[push] | |
| 27 | enabled = false= nothing is queued and =device add= refuses, rather | |
| 28 | than registering a device nothing can deliver to. | |
| 29 | - =[retention] push= caps the delivery queue, beside =mail=. | |
| 30 | - =dashboard= reports the push queue for admins beside the other five, | |
| 31 | by device id: a =key_id= or =team_id= Apple did not issue passes | |
| 32 | config validation and then dead-letters every send. | |
| 33 | - =issue assign= now files a notice, so assignment reaches the inbox, | |
| 34 | mail and push. | |
| 35 | - Notification text is sent in full, private repositories included: a | |
| 36 | repository's name and item number reach Apple and can appear on a | |
| 37 | lock screen. | |
| 38 | - Web: the notification settings page carries the push toggle and the | |
| 39 | device list, with removal behind confirmation. No web page offers a | |
| 40 | device-add form — only the app can mint an APNs token. | |
| 41 | ||
| 7 | 42 | * v1.31.1 — 2026-09-20 |
| 8 | 43 | |
| 9 | 44 | The stylesheet URL carries the build's hash (#239). |
cmd/gitbay/main.go +9
| @@ -70,6 +70,15 @@ func newRoot() *cobra.Command { | ||
| 70 | 70 | pass("show", "your notification preferences", passOpts{server: []string{"notifications", "settings", "show"}}), |
| 71 | 71 | pass("mail", "activity by mail as well as the inbox: on|off", passOpts{server: []string{"notifications", "settings", "mail"}}), |
| 72 | 72 | pass("watch", "every issue and merge request on repositories you can write to: on|off", passOpts{server: []string{"notifications", "settings", "watch"}}), |
| 73 | pass("push", "activity on your registered devices: on|off", passOpts{server: []string{"notifications", "settings", "push"}}), | |
| 74 | ), | |
| 75 | group("device", "Apple devices registered for push", | |
| 76 | pass("add", "register a device, token on stdin: [--label name]", | |
| 77 | passOpts{server: []string{"notifications", "device", "add"}, alwaysStdin: true, stdinWhat: "the device token"}), | |
| 78 | pass("list", "your registered devices", | |
| 79 | passOpts{server: []string{"notifications", "device", "list"}}), | |
| 80 | pass("remove", "deregister a device: <id>", | |
| 81 | passOpts{server: []string{"notifications", "device", "remove"}}), | |
| 73 | 82 | ), |
| 74 | 83 | ), |
| 75 | 84 | group("wiki", "a repository's wiki pages", |
cmd/gitbayd/main.go +14 −2
| @@ -30,6 +30,7 @@ import ( | ||
| 30 | 30 | "gitbay.org/gitbay/internal/httpd" |
| 31 | 31 | "gitbay.org/gitbay/internal/mirror" |
| 32 | 32 | "gitbay.org/gitbay/internal/notify" |
| 33 | "gitbay.org/gitbay/internal/push" | |
| 33 | 34 | "gitbay.org/gitbay/internal/sshd" |
| 34 | 35 | "gitbay.org/gitbay/internal/store" |
| 35 | 36 | "gitbay.org/gitbay/internal/toolpath" |
| @@ -176,6 +177,17 @@ func serveCmd() *cobra.Command { | ||
| 176 | 177 | if cfg.Mail.SMTPHost != "" { |
| 177 | 178 | go notify.New(st, cfg, retryBase).Run(whCtx) |
| 178 | 179 | } |
| 180 | if cfg.Push.Enabled { | |
| 181 | p, err := push.New(st, cfg.Push, retryBase) | |
| 182 | if err != nil { | |
| 183 | // Config validation already parsed the key, so this | |
| 184 | // is not a misconfiguration; fail loudly rather than | |
| 185 | // running with a silent delivery route. | |
| 186 | slog.Error("push: starting deliverer", "err", err) | |
| 187 | } else { | |
| 188 | go p.Run(whCtx) | |
| 189 | } | |
| 190 | } | |
| 179 | 191 | go mirror.New(st, cfg).Run(whCtx) |
| 180 | 192 | if d := cfg.Registration.PendingExpiryDuration(); d > 0 { |
| 181 | 193 | go reapPending(whCtx, st, d) |
| @@ -515,9 +527,9 @@ func sweep(ctx context.Context, st *store.Store, cfg config.Config) { | ||
| 515 | 527 | tick = d |
| 516 | 528 | } |
| 517 | 529 | } |
| 518 | audit, events, deliveries, mail := cfg.Retention.Durations() | |
| 530 | audit, events, deliveries, mail, push := cfg.Retention.Durations() | |
| 519 | 531 | r := store.Retention{Audit: audit, Events: events, |
| 520 | WebhookDeliveries: deliveries, Mail: mail} | |
| 532 | WebhookDeliveries: deliveries, Mail: mail, Push: push} | |
| 521 | 533 | t := time.NewTicker(tick) |
| 522 | 534 | defer t.Stop() |
| 523 | 535 | for { |
docs/plans/2026-09-20-ios-push-notifications.md added +2420
| @@ -0,0 +1,2420 @@ | ||
| 1 | # iOS push notifications — server half — Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** gitbayd delivers activity notices to registered Apple devices over APNs, as a third route beside the inbox row and the activity mail `notify()` already sends. | |
| 6 | ||
| 7 | **Architecture:** A notice becomes one `push_queue` row per registered device. An `internal/push.Deliverer` drains the queue on a ticker and POSTs each row to APNs over HTTP/2, authenticated by an ES256 JWT signed with an operator-supplied `.p8`. This is the third instance of a shape the repository already has twice: `internal/notify` (mail) and `internal/webhook` (HTTP POSTs) — a queue table, a drainer goroutine, exponential backoff, dead-lettering. | |
| 8 | ||
| 9 | **Tech Stack:** Go 1.27, SQLite (hand-written SQL, no ORM), stdlib only. No new module dependencies: `net/http` negotiates HTTP/2 over ALPN, and the JWT is `crypto/ecdsa` plus `encoding/json`. | |
| 10 | ||
| 11 | **Spec:** `docs/specs/2026-09-20-ios-push-notifications-design.md` | |
| 12 | ||
| 13 | ## Global Constraints | |
| 14 | ||
| 15 | - **No new Go module dependencies.** Nothing is added to `go.mod`. APNs needs HTTP/2, which stdlib `net/http` does over ALPN. The JWT is hand-rolled; do not reach for a JWT library. | |
| 16 | - **Never attribute anything to an assistant or model.** Not in commits, not in code comments, not in MR bodies, not in docs. | |
| 17 | - **Never push to `main`.** All work is on the `ios-push` branch in the worktree `/Users/cmc/git/krz/gitbay-push`. `require_mr` is on for this repository — a direct push to `main` is refused in pre-receive. | |
| 18 | - **Commits must be signed.** This repository refuses unsigned commits. Use `git -c commit.gpgsign=true commit`. | |
| 19 | - **Commit messages reference the issue:** `Ref #89`, and `Closes #89` on the last one. | |
| 20 | - **Secrets on stdin, never argv.** `/proc` is world-readable. | |
| 21 | - **A command that reads stdin must set `ReadsStdin: true`** on its `Command`. Otherwise `control.go` swaps in an empty reader and `--file -` silently stores nothing — it does not error. | |
| 22 | - **A new control command needs a `pass()` entry** in `cmd/gitbay/main.go` or the CLI coverage test fails. | |
| 23 | - **A new page template needs a row in `TestMainWidthClass`** (`internal/web/web_test.go`) or CI fails on it. | |
| 24 | - **Test scope while working:** build, `go vet ./...`, and the unit tests of the packages you touched. Full `go test ./...` belongs to CI on bay1 — the e2e suite is most of the runtime. Run `go vet ./...` after any signature change; `go build` skips `_test.go` files and will not catch a stale test caller. | |
| 25 | - **Never define a color only inside the dark media query** (relevant only to Task 10). | |
| 26 | ||
| 27 | --- | |
| 28 | ||
| 29 | ### Task 1: Migration 0059 and the device table | |
| 30 | ||
| 31 | **Files:** | |
| 32 | - Create: `internal/store/migrations/0059_push.up.sql` | |
| 33 | - Create: `internal/store/migrations/0059_push.down.sql` | |
| 34 | - Create: `internal/store/push.go` | |
| 35 | - Test: `internal/store/push_test.go` | |
| 36 | ||
| 37 | **Interfaces:** | |
| 38 | - Consumes: nothing. | |
| 39 | - Produces: | |
| 40 | - `type PushDevice struct { ID int64; UserID int64; Token string; Label string; CreatedAt string; LastSeenAt string }` | |
| 41 | - `func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error)` | |
| 42 | - `func (s *Store) PushDevices(userID int64) ([]PushDevice, error)` | |
| 43 | - `func (s *Store) RemovePushDevice(userID, id int64) error` | |
| 44 | - `func (s *Store) PushEnabled(userID int64) (bool, error)` | |
| 45 | - `func (s *Store) SetPushEnabled(userID int64, on bool) error` | |
| 46 | ||
| 47 | - [ ] **Step 1: Write the migration** | |
| 48 | ||
| 49 | `internal/store/migrations/0059_push.up.sql`: | |
| 50 | ||
| 51 | ```sql | |
| 52 | -- Apple devices an account has registered, and the queue of pushes bound | |
| 53 | -- for them. The mail queue's table is named `notifications`, so this one | |
| 54 | -- cannot be; the columns mirror it so the drainer is the mailer's loop. | |
| 55 | CREATE TABLE push_devices ( | |
| 56 | id INTEGER PRIMARY KEY, | |
| 57 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 58 | token TEXT NOT NULL UNIQUE, | |
| 59 | label TEXT NOT NULL DEFAULT '', | |
| 60 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 61 | last_seen_at TEXT | |
| 62 | ); | |
| 63 | CREATE INDEX push_devices_user ON push_devices(user_id); | |
| 64 | ||
| 65 | CREATE TABLE push_queue ( | |
| 66 | id INTEGER PRIMARY KEY, | |
| 67 | device_id INTEGER NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE, | |
| 68 | title TEXT NOT NULL, | |
| 69 | body TEXT NOT NULL, | |
| 70 | path TEXT NOT NULL, | |
| 71 | attempts INTEGER NOT NULL DEFAULT 0, | |
| 72 | next_attempt_at TEXT, | |
| 73 | sent_at TEXT, | |
| 74 | failed_at TEXT, | |
| 75 | last_error TEXT, | |
| 76 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 77 | ); | |
| 78 | CREATE INDEX push_queue_due ON push_queue(next_attempt_at) | |
| 79 | WHERE sent_at IS NULL AND failed_at IS NULL; | |
| 80 | ||
| 81 | -- Whether activity reaches the account's registered devices. Defaults on | |
| 82 | -- and costs nothing for an account with no devices; it exists so a user | |
| 83 | -- with a phone and an iPad silences both without deregistering each. | |
| 84 | ALTER TABLE users ADD COLUMN notify_push INTEGER NOT NULL DEFAULT 1; | |
| 85 | ``` | |
| 86 | ||
| 87 | `internal/store/migrations/0059_push.down.sql`: | |
| 88 | ||
| 89 | ```sql | |
| 90 | DROP TABLE push_queue; | |
| 91 | DROP TABLE push_devices; | |
| 92 | ALTER TABLE users DROP COLUMN notify_push; | |
| 93 | ``` | |
| 94 | ||
| 95 | - [ ] **Step 2: Write the failing test** | |
| 96 | ||
| 97 | `internal/store/push_test.go`: | |
| 98 | ||
| 99 | ```go | |
| 100 | package store | |
| 101 | ||
| 102 | import "testing" | |
| 103 | ||
| 104 | func TestPushDevices(t *testing.T) { | |
| 105 | s := testStore(t) | |
| 106 | uid := testUser(t, s, "alice") | |
| 107 | ||
| 108 | if _, err := s.AddPushDevice(uid, "tok-a", "iphone"); err != nil { | |
| 109 | t.Fatalf("AddPushDevice: %v", err) | |
| 110 | } | |
| 111 | devices, err := s.PushDevices(uid) | |
| 112 | if err != nil { | |
| 113 | t.Fatalf("PushDevices: %v", err) | |
| 114 | } | |
| 115 | if len(devices) != 1 || devices[0].Token != "tok-a" || devices[0].Label != "iphone" { | |
| 116 | t.Fatalf("got %+v", devices) | |
| 117 | } | |
| 118 | ||
| 119 | // Apple reuses tokens: re-registering updates the label and the owner | |
| 120 | // rather than erroring, so a reinstall under another account works. | |
| 121 | bob := testUser(t, s, "bob") | |
| 122 | if _, err := s.AddPushDevice(bob, "tok-a", "ipad"); err != nil { | |
| 123 | t.Fatalf("re-register: %v", err) | |
| 124 | } | |
| 125 | if d, _ := s.PushDevices(uid); len(d) != 0 { | |
| 126 | t.Fatalf("token still owned by alice: %+v", d) | |
| 127 | } | |
| 128 | d, _ := s.PushDevices(bob) | |
| 129 | if len(d) != 1 || d[0].Label != "ipad" { | |
| 130 | t.Fatalf("got %+v", d) | |
| 131 | } | |
| 132 | ||
| 133 | // Removal is scoped to the owner: alice cannot remove bob's device. | |
| 134 | if err := s.RemovePushDevice(uid, d[0].ID); err != ErrNotFound { | |
| 135 | t.Fatalf("cross-account remove: got %v, want ErrNotFound", err) | |
| 136 | } | |
| 137 | if err := s.RemovePushDevice(bob, d[0].ID); err != nil { | |
| 138 | t.Fatalf("RemovePushDevice: %v", err) | |
| 139 | } | |
| 140 | if d, _ := s.PushDevices(bob); len(d) != 0 { | |
| 141 | t.Fatalf("device survived removal: %+v", d) | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | func TestPushEnabledDefaultsOn(t *testing.T) { | |
| 146 | s := testStore(t) | |
| 147 | uid := testUser(t, s, "alice") | |
| 148 | on, err := s.PushEnabled(uid) | |
| 149 | if err != nil { | |
| 150 | t.Fatalf("PushEnabled: %v", err) | |
| 151 | } | |
| 152 | if !on { | |
| 153 | t.Fatal("notify_push should default on") | |
| 154 | } | |
| 155 | if err := s.SetPushEnabled(uid, false); err != nil { | |
| 156 | t.Fatalf("SetPushEnabled: %v", err) | |
| 157 | } | |
| 158 | if on, _ := s.PushEnabled(uid); on { | |
| 159 | t.Fatal("SetPushEnabled(false) did not stick") | |
| 160 | } | |
| 161 | } | |
| 162 | ``` | |
| 163 | ||
| 164 | Check the helper names `testStore` and `testUser` against the existing | |
| 165 | `internal/store/inbox_test.go` and use whatever that file uses; do not | |
| 166 | invent new helpers. | |
| 167 | ||
| 168 | - [ ] **Step 3: Run the test to verify it fails** | |
| 169 | ||
| 170 | Run: `go test ./internal/store/ -run 'TestPush' -v` | |
| 171 | Expected: FAIL — `s.AddPushDevice undefined`. | |
| 172 | ||
| 173 | - [ ] **Step 4: Write the implementation** | |
| 174 | ||
| 175 | `internal/store/push.go`: | |
| 176 | ||
| 177 | ```go | |
| 178 | package store | |
| 179 | ||
| 180 | import ( | |
| 181 | "database/sql" | |
| 182 | "errors" | |
| 183 | ) | |
| 184 | ||
| 185 | // PushDevice is one Apple device an account has registered. Token is the | |
| 186 | // APNs device token: an address, not a credential, but device-identifying | |
| 187 | // and never logged or echoed in full. | |
| 188 | type PushDevice struct { | |
| 189 | ID int64 | |
| 190 | UserID int64 | |
| 191 | Token string | |
| 192 | Label string | |
| 193 | CreatedAt string | |
| 194 | LastSeenAt string | |
| 195 | } | |
| 196 | ||
| 197 | // AddPushDevice registers a token to an account. A token already present | |
| 198 | // changes hands rather than erroring: Apple reuses tokens, and a reinstall | |
| 199 | // hands the same one to whichever account signs in next. | |
| 200 | func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error) { | |
| 201 | res, err := s.DB.Exec(` | |
| 202 | INSERT INTO push_devices (user_id, token, label) VALUES (?, ?, ?) | |
| 203 | ON CONFLICT(token) DO UPDATE SET user_id = excluded.user_id, label = excluded.label`, | |
| 204 | userID, token, label) | |
| 205 | if err != nil { | |
| 206 | return 0, err | |
| 207 | } | |
| 208 | return res.LastInsertId() | |
| 209 | } | |
| 210 | ||
| 211 | func (s *Store) PushDevices(userID int64) ([]PushDevice, error) { | |
| 212 | rows, err := s.DB.Query(` | |
| 213 | SELECT id, user_id, token, label, created_at, COALESCE(last_seen_at, '') | |
| 214 | FROM push_devices WHERE user_id = ? ORDER BY id`, userID) | |
| 215 | if err != nil { | |
| 216 | return nil, err | |
| 217 | } | |
| 218 | defer rows.Close() | |
| 219 | var out []PushDevice | |
| 220 | for rows.Next() { | |
| 221 | var d PushDevice | |
| 222 | if err := rows.Scan(&d.ID, &d.UserID, &d.Token, &d.Label, &d.CreatedAt, &d.LastSeenAt); err != nil { | |
| 223 | return nil, err | |
| 224 | } | |
| 225 | out = append(out, d) | |
| 226 | } | |
| 227 | return out, rows.Err() | |
| 228 | } | |
| 229 | ||
| 230 | // RemovePushDevice deletes one of the account's own devices. Scoping the | |
| 231 | // delete by user_id rather than checking ownership first means another | |
| 232 | // account's id is ErrNotFound, which is the same answer as an id that | |
| 233 | // never existed — a caller learns nothing about other accounts' devices. | |
| 234 | func (s *Store) RemovePushDevice(userID, id int64) error { | |
| 235 | res, err := s.DB.Exec("DELETE FROM push_devices WHERE id = ? AND user_id = ?", id, userID) | |
| 236 | if err != nil { | |
| 237 | return err | |
| 238 | } | |
| 239 | n, err := res.RowsAffected() | |
| 240 | if err != nil { | |
| 241 | return err | |
| 242 | } | |
| 243 | if n == 0 { | |
| 244 | return ErrNotFound | |
| 245 | } | |
| 246 | return nil | |
| 247 | } | |
| 248 | ||
| 249 | func (s *Store) PushEnabled(userID int64) (bool, error) { | |
| 250 | var on int | |
| 251 | err := s.DB.QueryRow("SELECT notify_push FROM users WHERE id = ?", userID).Scan(&on) | |
| 252 | if errors.Is(err, sql.ErrNoRows) { | |
| 253 | return false, ErrNotFound | |
| 254 | } | |
| 255 | return on != 0, err | |
| 256 | } | |
| 257 | ||
| 258 | func (s *Store) SetPushEnabled(userID int64, on bool) error { | |
| 259 | v := 0 | |
| 260 | if on { | |
| 261 | v = 1 | |
| 262 | } | |
| 263 | _, err := s.DB.Exec("UPDATE users SET notify_push = ? WHERE id = ?", v, userID) | |
| 264 | return err | |
| 265 | } | |
| 266 | ``` | |
| 267 | ||
| 268 | - [ ] **Step 5: Run the tests to verify they pass** | |
| 269 | ||
| 270 | Run: `go test ./internal/store/ -run 'TestPush' -v` | |
| 271 | Expected: PASS, both tests. | |
| 272 | ||
| 273 | - [ ] **Step 6: Commit** | |
| 274 | ||
| 275 | ```bash | |
| 276 | git add internal/store/migrations/0059_push.up.sql internal/store/migrations/0059_push.down.sql internal/store/push.go internal/store/push_test.go | |
| 277 | git -c commit.gpgsign=true commit -m "store: push device registrations | |
| 278 | ||
| 279 | Migration 0059 adds push_devices, push_queue and users.notify_push. A | |
| 280 | re-registered token changes hands rather than erroring, since Apple | |
| 281 | reuses tokens across reinstalls. | |
| 282 | ||
| 283 | Ref #89" | |
| 284 | ``` | |
| 285 | ||
| 286 | --- | |
| 287 | ||
| 288 | ### Task 2: The push queue and its retention | |
| 289 | ||
| 290 | **Files:** | |
| 291 | - Modify: `internal/store/push.go` | |
| 292 | - Modify: `internal/store/retention.go:30-35` (the `Retention` struct) and the `aged` table around `:66-75` | |
| 293 | - Modify: `internal/config/config.go` (the `Retention` struct and its `Durations` method) | |
| 294 | - Modify: `cmd/gitbayd/main.go:518-520` | |
| 295 | - Test: `internal/store/push_test.go` | |
| 296 | ||
| 297 | **Interfaces:** | |
| 298 | - Consumes: `PushDevice`, `PushEnabled` from Task 1. | |
| 299 | - Produces: | |
| 300 | - `type QueuedPush struct { ID int64; DeviceID int64; Token string; Title string; Body string; Path string; Attempts int }` | |
| 301 | - `func (s *Store) EnqueuePush(userID int64, title, body, path string) error` | |
| 302 | - `func (s *Store) DuePush(limit int) ([]QueuedPush, error)` | |
| 303 | - `func (s *Store) MarkPushSent(id int64) error` | |
| 304 | - `func (s *Store) MarkPushFailed(id int64, errMsg string, nextAt *time.Time) error` | |
| 305 | - `func (s *Store) DeletePushDeviceByToken(token string) error` | |
| 306 | - `config.Retention.Push string` with toml key `push`, and a fifth return from `Durations()` | |
| 307 | - `store.Retention.Push time.Duration` | |
| 308 | ||
| 309 | - [ ] **Step 1: Write the failing test** | |
| 310 | ||
| 311 | Append to `internal/store/push_test.go`: | |
| 312 | ||
| 313 | ```go | |
| 314 | func TestEnqueuePush(t *testing.T) { | |
| 315 | s := testStore(t) | |
| 316 | uid := testUser(t, s, "alice") | |
| 317 | s.AddPushDevice(uid, "tok-a", "iphone") | |
| 318 | s.AddPushDevice(uid, "tok-b", "ipad") | |
| 319 | ||
| 320 | // One row per device, so a retry to the phone does not resend to the | |
| 321 | // iPad. | |
| 322 | if err := s.EnqueuePush(uid, "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil { | |
| 323 | t.Fatalf("EnqueuePush: %v", err) | |
| 324 | } | |
| 325 | due, err := s.DuePush(20) | |
| 326 | if err != nil { | |
| 327 | t.Fatalf("DuePush: %v", err) | |
| 328 | } | |
| 329 | if len(due) != 2 { | |
| 330 | t.Fatalf("want a row per device, got %d", len(due)) | |
| 331 | } | |
| 332 | if due[0].Token == "" || due[0].Body != "cmc opened issue #12" { | |
| 333 | t.Fatalf("got %+v", due[0]) | |
| 334 | } | |
| 335 | ||
| 336 | // Sent rows stop being due. | |
| 337 | if err := s.MarkPushSent(due[0].ID); err != nil { | |
| 338 | t.Fatalf("MarkPushSent: %v", err) | |
| 339 | } | |
| 340 | if due, _ := s.DuePush(20); len(due) != 1 { | |
| 341 | t.Fatalf("sent row still due") | |
| 342 | } | |
| 343 | ||
| 344 | // A failure with a next attempt in the future is not due yet. | |
| 345 | next := time.Now().Add(time.Hour) | |
| 346 | if err := s.MarkPushFailed(due[1].ID, "503", &next); err != nil { | |
| 347 | t.Fatalf("MarkPushFailed: %v", err) | |
| 348 | } | |
| 349 | if due, _ := s.DuePush(20); len(due) != 0 { | |
| 350 | t.Fatalf("backed-off row is due too early") | |
| 351 | } | |
| 352 | } | |
| 353 | ||
| 354 | func TestEnqueuePushRespectsSettingAndDevices(t *testing.T) { | |
| 355 | s := testStore(t) | |
| 356 | uid := testUser(t, s, "alice") | |
| 357 | ||
| 358 | // No devices: nothing queued, no error. | |
| 359 | if err := s.EnqueuePush(uid, "t", "b", "p"); err != nil { | |
| 360 | t.Fatalf("EnqueuePush with no devices: %v", err) | |
| 361 | } | |
| 362 | if due, _ := s.DuePush(20); len(due) != 0 { | |
| 363 | t.Fatalf("queued for an account with no devices") | |
| 364 | } | |
| 365 | ||
| 366 | // Setting off: nothing queued. | |
| 367 | s.AddPushDevice(uid, "tok-a", "iphone") | |
| 368 | s.SetPushEnabled(uid, false) | |
| 369 | if err := s.EnqueuePush(uid, "t", "b", "p"); err != nil { | |
| 370 | t.Fatalf("EnqueuePush with push off: %v", err) | |
| 371 | } | |
| 372 | if due, _ := s.DuePush(20); len(due) != 0 { | |
| 373 | t.Fatalf("queued with notify_push off") | |
| 374 | } | |
| 375 | } | |
| 376 | ||
| 377 | func TestDeletePushDeviceByTokenTakesItsQueue(t *testing.T) { | |
| 378 | s := testStore(t) | |
| 379 | uid := testUser(t, s, "alice") | |
| 380 | s.AddPushDevice(uid, "tok-a", "iphone") | |
| 381 | s.EnqueuePush(uid, "t", "b", "p") | |
| 382 | ||
| 383 | if err := s.DeletePushDeviceByToken("tok-a"); err != nil { | |
| 384 | t.Fatalf("DeletePushDeviceByToken: %v", err) | |
| 385 | } | |
| 386 | if d, _ := s.PushDevices(uid); len(d) != 0 { | |
| 387 | t.Fatalf("device survived") | |
| 388 | } | |
| 389 | // push_queue.device_id is ON DELETE CASCADE, so the queued rows go | |
| 390 | // with it rather than being retried at a dead token forever. | |
| 391 | if due, _ := s.DuePush(20); len(due) != 0 { | |
| 392 | t.Fatalf("queued rows outlived their device") | |
| 393 | } | |
| 394 | } | |
| 395 | ``` | |
| 396 | ||
| 397 | Add `"time"` to the test file's imports. | |
| 398 | ||
| 399 | - [ ] **Step 2: Run the test to verify it fails** | |
| 400 | ||
| 401 | Run: `go test ./internal/store/ -run 'TestEnqueuePush|TestDeletePushDevice' -v` | |
| 402 | Expected: FAIL — `s.EnqueuePush undefined`. | |
| 403 | ||
| 404 | - [ ] **Step 3: Write the queue implementation** | |
| 405 | ||
| 406 | Append to `internal/store/push.go` (and add `"time"` to its imports): | |
| 407 | ||
| 408 | ```go | |
| 409 | // QueuedPush is one pending push, joined to the token it is bound for so | |
| 410 | // the drainer needs one query rather than two. | |
| 411 | type QueuedPush struct { | |
| 412 | ID int64 | |
| 413 | DeviceID int64 | |
| 414 | Token string | |
| 415 | Title string | |
| 416 | Body string | |
| 417 | Path string | |
| 418 | Attempts int | |
| 419 | } | |
| 420 | ||
| 421 | // EnqueuePush writes one row per registered device, and nothing when the | |
| 422 | // account has push off or no devices — the same shape as | |
| 423 | // ActivityMailAddress returning "" when notify_mail is off. Mute, watch | |
| 424 | // and actor-exclusion are already settled by NotifyRecipients before a | |
| 425 | // caller reaches here. | |
| 426 | func (s *Store) EnqueuePush(userID int64, title, body, path string) error { | |
| 427 | on, err := s.PushEnabled(userID) | |
| 428 | if err != nil || !on { | |
| 429 | return err | |
| 430 | } | |
| 431 | _, err = s.DB.Exec(` | |
| 432 | INSERT INTO push_queue (device_id, title, body, path) | |
| 433 | SELECT id, ?, ?, ? FROM push_devices WHERE user_id = ?`, | |
| 434 | title, body, path, userID) | |
| 435 | return err | |
| 436 | } | |
| 437 | ||
| 438 | func (s *Store) DuePush(limit int) ([]QueuedPush, error) { | |
| 439 | rows, err := s.DB.Query(` | |
| 440 | SELECT q.id, q.device_id, d.token, q.title, q.body, q.path, q.attempts | |
| 441 | FROM push_queue q JOIN push_devices d ON d.id = q.device_id | |
| 442 | WHERE q.sent_at IS NULL AND q.failed_at IS NULL | |
| 443 | AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?) | |
| 444 | ORDER BY q.id LIMIT ?`, fmtTime(time.Now()), limit) | |
| 445 | if err != nil { | |
| 446 | return nil, err | |
| 447 | } | |
| 448 | defer rows.Close() | |
| 449 | var out []QueuedPush | |
| 450 | for rows.Next() { | |
| 451 | var p QueuedPush | |
| 452 | if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Title, &p.Body, &p.Path, &p.Attempts); err != nil { | |
| 453 | return nil, err | |
| 454 | } | |
| 455 | out = append(out, p) | |
| 456 | } | |
| 457 | return out, rows.Err() | |
| 458 | } | |
| 459 | ||
| 460 | func (s *Store) MarkPushSent(id int64) error { | |
| 461 | _, err := s.DB.Exec( | |
| 462 | "UPDATE push_queue SET sent_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1 WHERE id = ?", id) | |
| 463 | return err | |
| 464 | } | |
| 465 | ||
| 466 | func (s *Store) MarkPushFailed(id int64, errMsg string, nextAt *time.Time) error { | |
| 467 | if nextAt == nil { | |
| 468 | _, err := s.DB.Exec( | |
| 469 | "UPDATE push_queue SET failed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1, last_error = ? WHERE id = ?", | |
| 470 | errMsg, id) | |
| 471 | return err | |
| 472 | } | |
| 473 | _, err := s.DB.Exec( | |
| 474 | "UPDATE push_queue SET attempts = attempts + 1, last_error = ?, next_attempt_at = ? WHERE id = ?", | |
| 475 | errMsg, fmtTime(*nextAt), id) | |
| 476 | return err | |
| 477 | } | |
| 478 | ||
| 479 | // DeletePushDeviceByToken drops a device Apple has told us is gone. The | |
| 480 | // queue rows cascade, so nothing is left retrying at a dead token. | |
| 481 | func (s *Store) DeletePushDeviceByToken(token string) error { | |
| 482 | _, err := s.DB.Exec("DELETE FROM push_devices WHERE token = ?", token) | |
| 483 | return err | |
| 484 | } | |
| 485 | ``` | |
| 486 | ||
| 487 | - [ ] **Step 4: Run the tests to verify they pass** | |
| 488 | ||
| 489 | Run: `go test ./internal/store/ -run 'TestPush|TestEnqueuePush|TestDeletePushDevice' -v` | |
| 490 | Expected: PASS. | |
| 491 | ||
| 492 | If `TestDeletePushDeviceByTokenTakesItsQueue` fails with the queue rows | |
| 493 | surviving, foreign keys are not on for that connection. Check how | |
| 494 | `testStore` opens the database against the rest of `internal/store` — | |
| 495 | do not work around it by deleting the queue rows by hand. | |
| 496 | ||
| 497 | - [ ] **Step 5: Add the retention key** | |
| 498 | ||
| 499 | In `internal/config/config.go`, add to the `Retention` struct: | |
| 500 | ||
| 501 | ```go | |
| 502 | // Push is the outbound device queue: rows already sent or given up on. | |
| 503 | Push string `toml:"push"` | |
| 504 | ``` | |
| 505 | ||
| 506 | Find `Retention.Durations()` in the same file and give it a fifth return | |
| 507 | value parsed the same way as `Mail`. | |
| 508 | ||
| 509 | In `internal/store/retention.go`, add to the `Retention` struct: | |
| 510 | ||
| 511 | ```go | |
| 512 | Push time.Duration | |
| 513 | ``` | |
| 514 | ||
| 515 | and to the `aged` slice in `Sweep`, after the `notifications` row: | |
| 516 | ||
| 517 | ```go | |
| 518 | {"push_queue", "created_at < ? AND (sent_at IS NOT NULL OR failed_at IS NOT NULL)", r.Push}, | |
| 519 | ``` | |
| 520 | ||
| 521 | In `cmd/gitbayd/main.go`, the `sweep` function around line 518: | |
| 522 | ||
| 523 | ```go | |
| 524 | audit, events, deliveries, mail, push := cfg.Retention.Durations() | |
| 525 | r := store.Retention{Audit: audit, Events: events, | |
| 526 | WebhookDeliveries: deliveries, Mail: mail, Push: push} | |
| 527 | ``` | |
| 528 | ||
| 529 | - [ ] **Step 6: Build and vet** | |
| 530 | ||
| 531 | Run: `go build ./... && go vet ./...` | |
| 532 | Expected: clean. `Durations()` gained a return value, so `go vet` is what | |
| 533 | catches any caller `go build` skipped — check for callers in | |
| 534 | `internal/config`'s own tests. | |
| 535 | ||
| 536 | Run: `go test ./internal/config/ ./internal/store/ ./cmd/gitbayd/` | |
| 537 | Expected: PASS. | |
| 538 | ||
| 539 | - [ ] **Step 7: Commit** | |
| 540 | ||
| 541 | ```bash | |
| 542 | git add internal/store/push.go internal/store/push_test.go internal/store/retention.go internal/config/config.go cmd/gitbayd/main.go | |
| 543 | git -c commit.gpgsign=true commit -m "store: the push queue, swept like the mail queue | |
| 544 | ||
| 545 | One row per device per notice, so a retry to one device does not | |
| 546 | resend to another. EnqueuePush writes nothing when the account has | |
| 547 | push off or no devices. [retention] push caps the table. | |
| 548 | ||
| 549 | Ref #89" | |
| 550 | ``` | |
| 551 | ||
| 552 | --- | |
| 553 | ||
| 554 | ### Task 3: The `[push]` config section | |
| 555 | ||
| 556 | **Files:** | |
| 557 | - Modify: `internal/config/config.go` | |
| 558 | - Test: `internal/config/config_test.go` | |
| 559 | ||
| 560 | **Interfaces:** | |
| 561 | - Consumes: nothing. | |
| 562 | - Produces: | |
| 563 | - `type Push struct { Enabled bool; KeyFile string; KeyID string; TeamID string; Topic string; Environment string }` with toml keys `enabled`, `key_file`, `key_id`, `team_id`, `topic`, `environment` | |
| 564 | - `Config.Push Push` with toml key `push` | |
| 565 | - `func (p Push) Host() string` returning `api.push.apple.com` or `api.sandbox.push.apple.com`, overridden by `GITBAY_APNS_HOST` | |
| 566 | ||
| 567 | - [ ] **Step 1: Write the failing test** | |
| 568 | ||
| 569 | Append to `internal/config/config_test.go`. It already has | |
| 570 | `writeConfig(t, body) string` and a `minimal` constant; use both rather | |
| 571 | than adding a second way to load a config. | |
| 572 | ||
| 573 | ```go | |
| 574 | // writeP8 writes a PEM-wrapped PKCS#8 P-256 key, the shape of Apple's | |
| 575 | // .p8 provider key, and returns its path. | |
| 576 | func writeP8(t *testing.T) string { | |
| 577 | t.Helper() | |
| 578 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 579 | if err != nil { | |
| 580 | t.Fatal(err) | |
| 581 | } | |
| 582 | der, err := x509.MarshalPKCS8PrivateKey(key) | |
| 583 | if err != nil { | |
| 584 | t.Fatal(err) | |
| 585 | } | |
| 586 | p := filepath.Join(t.TempDir(), "apns.p8") | |
| 587 | f, err := os.Create(p) | |
| 588 | if err != nil { | |
| 589 | t.Fatal(err) | |
| 590 | } | |
| 591 | defer f.Close() | |
| 592 | if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: der}); err != nil { | |
| 593 | t.Fatal(err) | |
| 594 | } | |
| 595 | return p | |
| 596 | } | |
| 597 | ||
| 598 | func TestPushConfigValidation(t *testing.T) { | |
| 599 | keyPath := writeP8(t) | |
| 600 | full := ` | |
| 601 | [push] | |
| 602 | enabled = true | |
| 603 | key_file = "` + keyPath + `" | |
| 604 | key_id = "KEYID" | |
| 605 | team_id = "TEAMID" | |
| 606 | topic = "org.gitbay.gitbay" | |
| 607 | environment = "production" | |
| 608 | ` | |
| 609 | cases := []struct { | |
| 610 | name string | |
| 611 | body string | |
| 612 | want string // substring of the expected error; "" means valid | |
| 613 | }{ | |
| 614 | {"disabled needs nothing", "\n[push]\nenabled = false\n", ""}, | |
| 615 | {"complete is valid", full, ""}, | |
| 616 | {"key_id required", strings.Replace(full, `key_id = "KEYID"`, "", 1), "push.key_id"}, | |
| 617 | {"team_id required", strings.Replace(full, `team_id = "TEAMID"`, "", 1), "push.team_id"}, | |
| 618 | {"topic required", strings.Replace(full, `topic = "org.gitbay.gitbay"`, "", 1), "push.topic"}, | |
| 619 | {"environment must be a known name", | |
| 620 | strings.Replace(full, `environment = "production"`, `environment = "staging"`, 1), | |
| 621 | "push.environment"}, | |
| 622 | } | |
| 623 | for _, tc := range cases { | |
| 624 | t.Run(tc.name, func(t *testing.T) { | |
| 625 | _, err := Load(writeConfig(t, minimal+tc.body)) | |
| 626 | if tc.want == "" { | |
| 627 | if err != nil { | |
| 628 | t.Fatalf("want valid, got %v", err) | |
| 629 | } | |
| 630 | return | |
| 631 | } | |
| 632 | if err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 633 | t.Fatalf("want an error mentioning %q, got %v", tc.want, err) | |
| 634 | } | |
| 635 | }) | |
| 636 | } | |
| 637 | } | |
| 638 | ||
| 639 | // A key_file that exists but is not a PKCS#8 EC key is refused at load, | |
| 640 | // not at the first notice: the failure mode otherwise is a queue that | |
| 641 | // fills and dead-letters with nobody watching. | |
| 642 | func TestPushConfigRejectsAnUnparseableKey(t *testing.T) { | |
| 643 | p := filepath.Join(t.TempDir(), "junk.p8") | |
| 644 | if err := os.WriteFile(p, []byte("not a key\n"), 0o600); err != nil { | |
| 645 | t.Fatal(err) | |
| 646 | } | |
| 647 | body := ` | |
| 648 | [push] | |
| 649 | enabled = true | |
| 650 | key_file = "` + p + `" | |
| 651 | key_id = "K" | |
| 652 | team_id = "T" | |
| 653 | topic = "org.gitbay.gitbay" | |
| 654 | environment = "production" | |
| 655 | ` | |
| 656 | _, err := Load(writeConfig(t, minimal+body)) | |
| 657 | if err == nil || !strings.Contains(err.Error(), "push.key_file") { | |
| 658 | t.Fatalf("want a push.key_file error, got %v", err) | |
| 659 | } | |
| 660 | } | |
| 661 | ||
| 662 | func TestPushHost(t *testing.T) { | |
| 663 | if got := (Push{Environment: "production"}).Host(); got != "api.push.apple.com" { | |
| 664 | t.Fatalf("production host = %q", got) | |
| 665 | } | |
| 666 | if got := (Push{Environment: "sandbox"}).Host(); got != "api.sandbox.push.apple.com" { | |
| 667 | t.Fatalf("sandbox host = %q", got) | |
| 668 | } | |
| 669 | t.Setenv("GITBAY_APNS_HOST", "127.0.0.1:1234") | |
| 670 | if got := (Push{Environment: "production"}).Host(); got != "127.0.0.1:1234" { | |
| 671 | t.Fatalf("GITBAY_APNS_HOST ignored: %q", got) | |
| 672 | } | |
| 673 | } | |
| 674 | ``` | |
| 675 | ||
| 676 | Add `crypto/ecdsa`, `crypto/elliptic`, `crypto/rand`, `crypto/x509` and | |
| 677 | `encoding/pem` to the test file's imports. | |
| 678 | ||
| 679 | - [ ] **Step 2: Run the test to verify it fails** | |
| 680 | ||
| 681 | Run: `go test ./internal/config/ -run TestPush -v` | |
| 682 | Expected: FAIL — `Push` undefined. | |
| 683 | ||
| 684 | - [ ] **Step 3: Write the implementation** | |
| 685 | ||
| 686 | Add to `internal/config/config.go`, beside the other section structs: | |
| 687 | ||
| 688 | ```go | |
| 689 | // Push is APNs delivery to registered Apple devices. A key belongs to a | |
| 690 | // bundle ID, so an instance pushes to the app built under the topic named | |
| 691 | // here and no other; a self-hoster points this at their own key and their | |
| 692 | // own build. | |
| 693 | type Push struct { | |
| 694 | Enabled bool `toml:"enabled"` | |
| 695 | KeyFile string `toml:"key_file"` | |
| 696 | KeyID string `toml:"key_id"` | |
| 697 | TeamID string `toml:"team_id"` | |
| 698 | Topic string `toml:"topic"` // the app's bundle identifier | |
| 699 | // Environment is a name rather than a URL so a typo cannot aim the | |
| 700 | // key at a host that is not Apple's. | |
| 701 | Environment string `toml:"environment"` // production | sandbox | |
| 702 | } | |
| 703 | ||
| 704 | // Host is the APNs endpoint for the configured environment. | |
| 705 | // GITBAY_APNS_HOST overrides it for tests, as GITBAY_SWEEP_TICK does for | |
| 706 | // the retention sweep. | |
| 707 | func (p Push) Host() string { | |
| 708 | if h := os.Getenv("GITBAY_APNS_HOST"); h != "" { | |
| 709 | return h | |
| 710 | } | |
| 711 | if p.Environment == "sandbox" { | |
| 712 | return "api.sandbox.push.apple.com" | |
| 713 | } | |
| 714 | return "api.push.apple.com" | |
| 715 | } | |
| 716 | ``` | |
| 717 | ||
| 718 | Add the field to `Config`: | |
| 719 | ||
| 720 | ```go | |
| 721 | Push Push `toml:"push"` | |
| 722 | ``` | |
| 723 | ||
| 724 | In the validate function, beside the `MaxSnippetsPerUser` check: | |
| 725 | ||
| 726 | ```go | |
| 727 | if c.Push.Enabled { | |
| 728 | for _, f := range []struct{ name, val string }{ | |
| 729 | {"push.key_file", c.Push.KeyFile}, | |
| 730 | {"push.key_id", c.Push.KeyID}, | |
| 731 | {"push.team_id", c.Push.TeamID}, | |
| 732 | {"push.topic", c.Push.Topic}, | |
| 733 | } { | |
| 734 | if f.val == "" { | |
| 735 | errs = append(errs, fmt.Errorf("%s is required when push.enabled", f.name)) | |
| 736 | } | |
| 737 | } | |
| 738 | if err := oneOf("push.environment", c.Push.Environment, "production", "sandbox"); err != nil { | |
| 739 | errs = append(errs, err) | |
| 740 | } | |
| 741 | if c.Push.KeyFile != "" { | |
| 742 | if _, err := LoadAPNSKey(c.Push.KeyFile); err != nil { | |
| 743 | errs = append(errs, fmt.Errorf("push.key_file: %w", err)) | |
| 744 | } | |
| 745 | } | |
| 746 | } | |
| 747 | ``` | |
| 748 | ||
| 749 | And the key loader, in the same file: | |
| 750 | ||
| 751 | ```go | |
| 752 | // LoadAPNSKey reads Apple's .p8 provider key: a PEM-wrapped PKCS#8 | |
| 753 | // P-256 private key. Read at startup and validated there, so a | |
| 754 | // misconfigured [push] refuses to start rather than filling a queue | |
| 755 | // nobody is watching. | |
| 756 | func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) { | |
| 757 | data, err := os.ReadFile(path) | |
| 758 | if err != nil { | |
| 759 | return nil, err | |
| 760 | } | |
| 761 | block, _ := pem.Decode(data) | |
| 762 | if block == nil { | |
| 763 | return nil, errors.New("not PEM") | |
| 764 | } | |
| 765 | any, err := x509.ParsePKCS8PrivateKey(block.Bytes) | |
| 766 | if err != nil { | |
| 767 | return nil, err | |
| 768 | } | |
| 769 | key, ok := any.(*ecdsa.PrivateKey) | |
| 770 | if !ok { | |
| 771 | return nil, errors.New("not an EC private key") | |
| 772 | } | |
| 773 | return key, nil | |
| 774 | } | |
| 775 | ``` | |
| 776 | ||
| 777 | Add `crypto/ecdsa`, `crypto/x509` and `encoding/pem` to the file's imports. | |
| 778 | ||
| 779 | - [ ] **Step 4: Run the tests to verify they pass** | |
| 780 | ||
| 781 | Run: `go test ./internal/config/ -v` | |
| 782 | Expected: PASS. The whole package, because adding a `Config` field can | |
| 783 | break a test that round-trips the struct or asserts on unknown keys. | |
| 784 | ||
| 785 | - [ ] **Step 5: Commit** | |
| 786 | ||
| 787 | ```bash | |
| 788 | git add internal/config/config.go internal/config/config_test.go | |
| 789 | git -c commit.gpgsign=true commit -m "config: the [push] section | |
| 790 | ||
| 791 | Validated at load: with push.enabled, the four fields are required, | |
| 792 | environment is one of two names, and key_file must parse as a PKCS#8 | |
| 793 | EC key. GITBAY_APNS_HOST redirects the endpoint for tests. | |
| 794 | ||
| 795 | Ref #89" | |
| 796 | ``` | |
| 797 | ||
| 798 | --- | |
| 799 | ||
| 800 | ### Task 4: The APNs provider token | |
| 801 | ||
| 802 | **Files:** | |
| 803 | - Create: `internal/push/token.go` | |
| 804 | - Test: `internal/push/token_test.go` | |
| 805 | ||
| 806 | **Interfaces:** | |
| 807 | - Consumes: nothing. `token.go` takes a parsed key and two strings; it imports no `config` symbol. | |
| 808 | - Produces: | |
| 809 | - `type tokenSource struct { key *ecdsa.PrivateKey; keyID, teamID string; now func() time.Time; mu sync.Mutex; cached string; issued time.Time }` | |
| 810 | - `func newTokenSource(key *ecdsa.PrivateKey, keyID, teamID string) *tokenSource` | |
| 811 | - `func (t *tokenSource) token() (string, error)` | |
| 812 | ||
| 813 | - [ ] **Step 1: Write the failing test** | |
| 814 | ||
| 815 | `internal/push/token_test.go`: | |
| 816 | ||
| 817 | ```go | |
| 818 | package push | |
| 819 | ||
| 820 | import ( | |
| 821 | "crypto/ecdsa" | |
| 822 | "crypto/elliptic" | |
| 823 | "crypto/rand" | |
| 824 | "crypto/sha256" | |
| 825 | "encoding/base64" | |
| 826 | "encoding/json" | |
| 827 | "math/big" | |
| 828 | "strings" | |
| 829 | "testing" | |
| 830 | "time" | |
| 831 | ) | |
| 832 | ||
| 833 | func testKey(t *testing.T) *ecdsa.PrivateKey { | |
| 834 | t.Helper() | |
| 835 | k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 836 | if err != nil { | |
| 837 | t.Fatal(err) | |
| 838 | } | |
| 839 | return k | |
| 840 | } | |
| 841 | ||
| 842 | func TestTokenShapeAndSignature(t *testing.T) { | |
| 843 | key := testKey(t) | |
| 844 | ts := newTokenSource(key, "KEYID123", "TEAMID456") | |
| 845 | tok, err := ts.token() | |
| 846 | if err != nil { | |
| 847 | t.Fatalf("token: %v", err) | |
| 848 | } | |
| 849 | parts := strings.Split(tok, ".") | |
| 850 | if len(parts) != 3 { | |
| 851 | t.Fatalf("want three dot-separated parts, got %d", len(parts)) | |
| 852 | } | |
| 853 | ||
| 854 | var hdr struct{ Alg, Kid string } | |
| 855 | raw, _ := base64.RawURLEncoding.DecodeString(parts[0]) | |
| 856 | if err := json.Unmarshal(raw, &hdr); err != nil { | |
| 857 | t.Fatalf("header: %v", err) | |
| 858 | } | |
| 859 | if hdr.Alg != "ES256" || hdr.Kid != "KEYID123" { | |
| 860 | t.Fatalf("header = %+v", hdr) | |
| 861 | } | |
| 862 | ||
| 863 | // APNs provider tokens carry iss (team id) and iat, and nothing else. | |
| 864 | var claims map[string]any | |
| 865 | raw, _ = base64.RawURLEncoding.DecodeString(parts[1]) | |
| 866 | if err := json.Unmarshal(raw, &claims); err != nil { | |
| 867 | t.Fatalf("claims: %v", err) | |
| 868 | } | |
| 869 | if claims["iss"] != "TEAMID456" { | |
| 870 | t.Fatalf("iss = %v", claims["iss"]) | |
| 871 | } | |
| 872 | if _, ok := claims["iat"]; !ok { | |
| 873 | t.Fatal("no iat") | |
| 874 | } | |
| 875 | if len(claims) != 2 { | |
| 876 | t.Fatalf("unexpected claims: %v", claims) | |
| 877 | } | |
| 878 | ||
| 879 | // The signature is raw r||s, 64 bytes — not the ASN.1 DER that | |
| 880 | // ecdsa.SignASN1 returns. Sending DER gets every push rejected. | |
| 881 | sig, err := base64.RawURLEncoding.DecodeString(parts[2]) | |
| 882 | if err != nil { | |
| 883 | t.Fatalf("signature not base64url: %v", err) | |
| 884 | } | |
| 885 | if len(sig) != 64 { | |
| 886 | t.Fatalf("signature is %d bytes, want 64 (raw r||s)", len(sig)) | |
| 887 | } | |
| 888 | sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) | |
| 889 | r := new(big.Int).SetBytes(sig[:32]) | |
| 890 | s := new(big.Int).SetBytes(sig[32:]) | |
| 891 | if !ecdsa.Verify(&key.PublicKey, sum[:], r, s) { | |
| 892 | t.Fatal("signature does not verify") | |
| 893 | } | |
| 894 | } | |
| 895 | ||
| 896 | func TestTokenCachedThenReminted(t *testing.T) { | |
| 897 | ts := newTokenSource(testKey(t), "K", "T") | |
| 898 | base := time.Now() | |
| 899 | ts.now = func() time.Time { return base } | |
| 900 | ||
| 901 | first, _ := ts.token() | |
| 902 | second, _ := ts.token() | |
| 903 | if first != second { | |
| 904 | t.Fatal("token reminted inside the cache window; APNs answers TooManyProviderTokenUpdates") | |
| 905 | } | |
| 906 | ||
| 907 | // Valid for an hour, not to be reminted faster than every twenty | |
| 908 | // minutes: refresh at fifty. | |
| 909 | ts.now = func() time.Time { return base.Add(51 * time.Minute) } | |
| 910 | third, _ := ts.token() | |
| 911 | if third == first { | |
| 912 | t.Fatal("token not reminted after fifty minutes") | |
| 913 | } | |
| 914 | } | |
| 915 | ``` | |
| 916 | ||
| 917 | - [ ] **Step 2: Run the test to verify it fails** | |
| 918 | ||
| 919 | Run: `go test ./internal/push/ -run TestToken -v` | |
| 920 | Expected: FAIL — `newTokenSource` undefined. | |
| 921 | ||
| 922 | - [ ] **Step 3: Write the implementation** | |
| 923 | ||
| 924 | `internal/push/token.go`: | |
| 925 | ||
| 926 | ```go | |
| 927 | // Package push delivers activity notices to Apple devices over APNs: the | |
| 928 | // third delivery route beside the inbox row and the activity mail, with | |
| 929 | // the bounded-retry discipline the mail queue and webhook deliverer use. | |
| 930 | package push | |
| 931 | ||
| 932 | import ( | |
| 933 | "crypto/ecdsa" | |
| 934 | "crypto/rand" | |
| 935 | "crypto/sha256" | |
| 936 | "encoding/base64" | |
| 937 | "encoding/json" | |
| 938 | "sync" | |
| 939 | "time" | |
| 940 | ) | |
| 941 | ||
| 942 | // tokenLifetime is how long a provider token is reused. APNs accepts one | |
| 943 | // for an hour and answers TooManyProviderTokenUpdates if they are minted | |
| 944 | // faster than roughly once every twenty minutes, so the useful window is | |
| 945 | // between the two. | |
| 946 | const tokenLifetime = 50 * time.Minute | |
| 947 | ||
| 948 | type tokenSource struct { | |
| 949 | key *ecdsa.PrivateKey | |
| 950 | keyID string | |
| 951 | teamID string | |
| 952 | now func() time.Time | |
| 953 | ||
| 954 | mu sync.Mutex | |
| 955 | cached string | |
| 956 | issued time.Time | |
| 957 | } | |
| 958 | ||
| 959 | func newTokenSource(key *ecdsa.PrivateKey, keyID, teamID string) *tokenSource { | |
| 960 | return &tokenSource{key: key, keyID: keyID, teamID: teamID, now: time.Now} | |
| 961 | } | |
| 962 | ||
| 963 | // token returns the cached provider token, minting a new one when the old | |
| 964 | // one is near its end. | |
| 965 | func (t *tokenSource) token() (string, error) { | |
| 966 | t.mu.Lock() | |
| 967 | defer t.mu.Unlock() | |
| 968 | now := t.now() | |
| 969 | if t.cached != "" && now.Sub(t.issued) < tokenLifetime { | |
| 970 | return t.cached, nil | |
| 971 | } | |
| 972 | tok, err := t.sign(now) | |
| 973 | if err != nil { | |
| 974 | return "", err | |
| 975 | } | |
| 976 | t.cached, t.issued = tok, now | |
| 977 | return tok, nil | |
| 978 | } | |
| 979 | ||
| 980 | func (t *tokenSource) sign(now time.Time) (string, error) { | |
| 981 | header, err := json.Marshal(map[string]string{"alg": "ES256", "kid": t.keyID}) | |
| 982 | if err != nil { | |
| 983 | return "", err | |
| 984 | } | |
| 985 | claims, err := json.Marshal(map[string]any{"iss": t.teamID, "iat": now.Unix()}) | |
| 986 | if err != nil { | |
| 987 | return "", err | |
| 988 | } | |
| 989 | enc := base64.RawURLEncoding | |
| 990 | signing := enc.EncodeToString(header) + "." + enc.EncodeToString(claims) | |
| 991 | sum := sha256.Sum256([]byte(signing)) | |
| 992 | r, s, err := ecdsa.Sign(rand.Reader, t.key, sum[:]) | |
| 993 | if err != nil { | |
| 994 | return "", err | |
| 995 | } | |
| 996 | // JWS wants the raw pair, each left-padded to the curve's byte size — | |
| 997 | // not ecdsa.SignASN1's DER. A DER signature is well-formed ECDSA and | |
| 998 | // is rejected by every JWT verifier, APNs included. | |
| 999 | sig := make([]byte, 64) | |
| 1000 | r.FillBytes(sig[:32]) | |
| 1001 | s.FillBytes(sig[32:]) | |
| 1002 | return signing + "." + enc.EncodeToString(sig), nil | |
| 1003 | } | |
| 1004 | ``` | |
| 1005 | ||
| 1006 | - [ ] **Step 4: Run the tests to verify they pass** | |
| 1007 | ||
| 1008 | Run: `go test ./internal/push/ -run TestToken -v` | |
| 1009 | Expected: PASS, both tests. | |
| 1010 | ||
| 1011 | - [ ] **Step 5: Commit** | |
| 1012 | ||
| 1013 | ```bash | |
| 1014 | git add internal/push/token.go internal/push/token_test.go | |
| 1015 | git -c commit.gpgsign=true commit -m "push: APNs provider tokens | |
| 1016 | ||
| 1017 | ES256 over iss and iat, cached fifty minutes. The signature is raw | |
| 1018 | r||s rather than DER, which is the difference between a token APNs | |
| 1019 | accepts and one it rejects. | |
| 1020 | ||
| 1021 | Ref #89" | |
| 1022 | ``` | |
| 1023 | ||
| 1024 | --- | |
| 1025 | ||
| 1026 | ### Task 5: The APNs client | |
| 1027 | ||
| 1028 | **Files:** | |
| 1029 | - Create: `internal/push/apns.go` | |
| 1030 | - Test: `internal/push/apns_test.go` | |
| 1031 | ||
| 1032 | **Interfaces:** | |
| 1033 | - Consumes: `tokenSource` from Task 4, `config.Push` from Task 3. | |
| 1034 | - Produces: | |
| 1035 | - `type Client struct { ... }` | |
| 1036 | - `func NewClient(cfg config.Push) (*Client, error)` | |
| 1037 | - `type result int` with constants `resultSent`, `resultRetry`, `resultReap`, `resultDead` | |
| 1038 | - `func (c *Client) Send(ctx context.Context, token, title, body, path string) (res result, retryAfter time.Duration, err error)` | |
| 1039 | ||
| 1040 | - [ ] **Step 1: Write the failing test** | |
| 1041 | ||
| 1042 | `internal/push/apns_test.go`: | |
| 1043 | ||
| 1044 | ```go | |
| 1045 | package push | |
| 1046 | ||
| 1047 | import ( | |
| 1048 | "context" | |
| 1049 | "encoding/json" | |
| 1050 | "net/http" | |
| 1051 | "net/http/httptest" | |
| 1052 | "io" | |
| 1053 | "strings" | |
| 1054 | "testing" | |
| 1055 | "time" | |
| 1056 | ||
| 1057 | "gitbay.org/gitbay/internal/config" | |
| 1058 | ) | |
| 1059 | ||
| 1060 | // fakeAPNs stands in for Apple. It speaks HTTP/1.1; the real transport is | |
| 1061 | // h2 by ALPN, which is stdlib behaviour and not this repository's to test. | |
| 1062 | func fakeAPNs(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) { | |
| 1063 | t.Helper() | |
| 1064 | srv := httptest.NewServer(h) | |
| 1065 | t.Cleanup(srv.Close) | |
| 1066 | t.Setenv("GITBAY_APNS_HOST", strings.TrimPrefix(srv.URL, "http://")) | |
| 1067 | c, err := NewClient(config.Push{ | |
| 1068 | Enabled: true, KeyID: "K", TeamID: "T", | |
| 1069 | Topic: "org.gitbay.gitbay", Environment: "production", | |
| 1070 | }) | |
| 1071 | if err != nil { | |
| 1072 | t.Fatal(err) | |
| 1073 | } | |
| 1074 | c.key = testKey(t) | |
| 1075 | c.tokens = newTokenSource(c.key, "K", "T") | |
| 1076 | c.scheme = "http" | |
| 1077 | return c, srv | |
| 1078 | } | |
| 1079 | ||
| 1080 | func TestSendShapesTheRequest(t *testing.T) { | |
| 1081 | var gotPath, gotTopic, gotType, gotAuth string | |
| 1082 | var payload map[string]any | |
| 1083 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 1084 | gotPath, gotTopic = r.URL.Path, r.Header.Get("apns-topic") | |
| 1085 | gotType, gotAuth = r.Header.Get("apns-push-type"), r.Header.Get("authorization") | |
| 1086 | raw, _ := io.ReadAll(r.Body) | |
| 1087 | json.Unmarshal(raw, &payload) | |
| 1088 | w.WriteHeader(200) | |
| 1089 | }) | |
| 1090 | res, _, err := c.Send(context.Background(), "DEVTOKEN", "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12") | |
| 1091 | if err != nil || res != resultSent { | |
| 1092 | t.Fatalf("res = %v, err = %v", res, err) | |
| 1093 | } | |
| 1094 | if gotPath != "/3/device/DEVTOKEN" { | |
| 1095 | t.Fatalf("path = %q", gotPath) | |
| 1096 | } | |
| 1097 | if gotTopic != "org.gitbay.gitbay" || gotType != "alert" { | |
| 1098 | t.Fatalf("topic = %q, push-type = %q", gotTopic, gotType) | |
| 1099 | } | |
| 1100 | if !strings.HasPrefix(gotAuth, "bearer ") { | |
| 1101 | t.Fatalf("authorization = %q", gotAuth) | |
| 1102 | } | |
| 1103 | aps := payload["aps"].(map[string]any) | |
| 1104 | alert := aps["alert"].(map[string]any) | |
| 1105 | if alert["title"] != "krz/gitbay" || alert["body"] != "cmc opened issue #12" { | |
| 1106 | t.Fatalf("alert = %v", alert) | |
| 1107 | } | |
| 1108 | if aps["thread-id"] != "krz/gitbay" { | |
| 1109 | t.Fatalf("thread-id = %v", aps["thread-id"]) | |
| 1110 | } | |
| 1111 | if payload["path"] != "krz/gitbay/issues/12" { | |
| 1112 | t.Fatalf("path = %v", payload["path"]) | |
| 1113 | } | |
| 1114 | // Collapsing is wrong here: two comments are two notices. | |
| 1115 | if _, ok := payload["apns-collapse-id"]; ok { | |
| 1116 | t.Fatal("collapse id set") | |
| 1117 | } | |
| 1118 | } | |
| 1119 | ||
| 1120 | func TestSendMapsResponses(t *testing.T) { | |
| 1121 | cases := []struct { | |
| 1122 | name string | |
| 1123 | status int | |
| 1124 | body string | |
| 1125 | retryAfter string | |
| 1126 | want result | |
| 1127 | wantAfter time.Duration | |
| 1128 | }{ | |
| 1129 | {"ok", 200, "", "", resultSent, 0}, | |
| 1130 | {"gone", 410, `{"reason":"Unregistered"}`, "", resultReap, 0}, | |
| 1131 | {"bad token", 400, `{"reason":"BadDeviceToken"}`, "", resultReap, 0}, | |
| 1132 | {"other 400 is permanent", 400, `{"reason":"PayloadTooLarge"}`, "", resultDead, 0}, | |
| 1133 | {"forbidden is permanent", 403, `{"reason":"InvalidProviderToken"}`, "", resultDead, 0}, | |
| 1134 | {"too many requests retries", 429, `{"reason":"TooManyRequests"}`, "7", resultRetry, 7 * time.Second}, | |
| 1135 | {"server error retries", 503, `{"reason":"ServiceUnavailable"}`, "", resultRetry, 0}, | |
| 1136 | } | |
| 1137 | for _, tc := range cases { | |
| 1138 | t.Run(tc.name, func(t *testing.T) { | |
| 1139 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 1140 | if tc.retryAfter != "" { | |
| 1141 | w.Header().Set("Retry-After", tc.retryAfter) | |
| 1142 | } | |
| 1143 | w.WriteHeader(tc.status) | |
| 1144 | io.WriteString(w, tc.body) | |
| 1145 | }) | |
| 1146 | res, after, err := c.Send(context.Background(), "T", "t", "b", "p") | |
| 1147 | // Only a delivered push has no error. Every other result | |
| 1148 | // carries the status and reason, which is what the drainer | |
| 1149 | // records on the queue row. | |
| 1150 | if tc.want == resultSent && err != nil { | |
| 1151 | t.Fatalf("err = %v", err) | |
| 1152 | } | |
| 1153 | if tc.want != resultSent && err == nil { | |
| 1154 | t.Fatalf("want an error explaining %v, got nil", tc.want) | |
| 1155 | } | |
| 1156 | if res != tc.want { | |
| 1157 | t.Fatalf("res = %v, want %v", res, tc.want) | |
| 1158 | } | |
| 1159 | if after != tc.wantAfter { | |
| 1160 | t.Fatalf("retryAfter = %v, want %v", after, tc.wantAfter) | |
| 1161 | } | |
| 1162 | }) | |
| 1163 | } | |
| 1164 | } | |
| 1165 | ``` | |
| 1166 | ||
| 1167 | - [ ] **Step 2: Run the test to verify it fails** | |
| 1168 | ||
| 1169 | Run: `go test ./internal/push/ -run TestSend -v` | |
| 1170 | Expected: FAIL — `NewClient` undefined. | |
| 1171 | ||
| 1172 | - [ ] **Step 3: Write the implementation** | |
| 1173 | ||
| 1174 | `internal/push/apns.go`: | |
| 1175 | ||
| 1176 | ```go | |
| 1177 | package push | |
| 1178 | ||
| 1179 | import ( | |
| 1180 | "bytes" | |
| 1181 | "context" | |
| 1182 | "crypto/ecdsa" | |
| 1183 | "encoding/json" | |
| 1184 | "fmt" | |
| 1185 | "io" | |
| 1186 | "net/http" | |
| 1187 | "strconv" | |
| 1188 | "time" | |
| 1189 | ||
| 1190 | "gitbay.org/gitbay/internal/config" | |
| 1191 | ) | |
| 1192 | ||
| 1193 | // result is what one send means for the queue row. | |
| 1194 | type result int | |
| 1195 | ||
| 1196 | const ( | |
| 1197 | resultSent result = iota // delivered | |
| 1198 | resultRetry // transient; back off and try again | |
| 1199 | resultReap // Apple says the token is dead; drop the device | |
| 1200 | resultDead // permanent for this payload; dead-letter it | |
| 1201 | ) | |
| 1202 | ||
| 1203 | // maxBodyBytes keeps an alert inside APNs' 4KB payload limit with room | |
| 1204 | // for the rest of the JSON. A summary longer than this is cut rather | |
| 1205 | // than rejected. | |
| 1206 | const maxBodyBytes = 3000 | |
| 1207 | ||
| 1208 | type Client struct { | |
| 1209 | http *http.Client | |
| 1210 | tokens *tokenSource | |
| 1211 | key *ecdsa.PrivateKey | |
| 1212 | host string | |
| 1213 | scheme string | |
| 1214 | topic string | |
| 1215 | } | |
| 1216 | ||
| 1217 | func NewClient(cfg config.Push) (*Client, error) { | |
| 1218 | c := &Client{ | |
| 1219 | // stdlib negotiates HTTP/2 over ALPN, which is what APNs | |
| 1220 | // requires; no explicit http2 transport is needed. | |
| 1221 | http: &http.Client{Timeout: 30 * time.Second}, | |
| 1222 | host: cfg.Host(), | |
| 1223 | scheme: "https", | |
| 1224 | topic: cfg.Topic, | |
| 1225 | } | |
| 1226 | if cfg.KeyFile != "" { | |
| 1227 | key, err := config.LoadAPNSKey(cfg.KeyFile) | |
| 1228 | if err != nil { | |
| 1229 | return nil, err | |
| 1230 | } | |
| 1231 | c.key = key | |
| 1232 | c.tokens = newTokenSource(key, cfg.KeyID, cfg.TeamID) | |
| 1233 | } | |
| 1234 | return c, nil | |
| 1235 | } | |
| 1236 | ``` | |
| 1237 | ||
| 1238 | `c.tokens` is nil when `KeyFile` is empty, and `Send` would panic on it. | |
| 1239 | Production cannot reach that: config validation requires `key_file` | |
| 1240 | whenever `push.enabled`, and `Deliverer` is only started when it is. The | |
| 1241 | tests above set `c.tokens` themselves. Leave it rather than adding a nil | |
| 1242 | check that can only fire in a test that forgot one. | |
| 1243 | ||
| 1244 | ```go | |
| 1245 | ||
| 1246 | // Send delivers one alert. The returned duration is the server's | |
| 1247 | // Retry-After when it gave one, zero otherwise. | |
| 1248 | func (c *Client) Send(ctx context.Context, token, title, body, path string) (result, time.Duration, error) { | |
| 1249 | if len(body) > maxBodyBytes { | |
| 1250 | body = body[:maxBodyBytes] | |
| 1251 | } | |
| 1252 | payload, err := json.Marshal(map[string]any{ | |
| 1253 | "aps": map[string]any{ | |
| 1254 | "alert": map[string]string{"title": title, "body": body}, | |
| 1255 | "sound": "default", | |
| 1256 | "thread-id": title, | |
| 1257 | }, | |
| 1258 | "path": path, | |
| 1259 | }) | |
| 1260 | if err != nil { | |
| 1261 | return resultDead, 0, err | |
| 1262 | } | |
| 1263 | bearer, err := c.tokens.token() | |
| 1264 | if err != nil { | |
| 1265 | return resultRetry, 0, err | |
| 1266 | } | |
| 1267 | url := c.scheme + "://" + c.host + "/3/device/" + token | |
| 1268 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) | |
| 1269 | if err != nil { | |
| 1270 | return resultDead, 0, err | |
| 1271 | } | |
| 1272 | req.Header.Set("authorization", "bearer "+bearer) | |
| 1273 | req.Header.Set("apns-topic", c.topic) | |
| 1274 | req.Header.Set("apns-push-type", "alert") | |
| 1275 | req.Header.Set("apns-priority", "10") | |
| 1276 | req.Header.Set("content-type", "application/json") | |
| 1277 | ||
| 1278 | resp, err := c.http.Do(req) | |
| 1279 | if err != nil { | |
| 1280 | return resultRetry, 0, err | |
| 1281 | } | |
| 1282 | defer resp.Body.Close() | |
| 1283 | raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) | |
| 1284 | ||
| 1285 | var apnsErr struct { | |
| 1286 | Reason string `json:"reason"` | |
| 1287 | } | |
| 1288 | json.Unmarshal(raw, &apnsErr) | |
| 1289 | ||
| 1290 | var after time.Duration | |
| 1291 | if v := resp.Header.Get("Retry-After"); v != "" { | |
| 1292 | if n, err := strconv.Atoi(v); err == nil && n > 0 { | |
| 1293 | after = time.Duration(n) * time.Second | |
| 1294 | } | |
| 1295 | } | |
| 1296 | ||
| 1297 | switch { | |
| 1298 | case resp.StatusCode == http.StatusOK: | |
| 1299 | return resultSent, 0, nil | |
| 1300 | case resp.StatusCode == http.StatusGone, | |
| 1301 | apnsErr.Reason == "BadDeviceToken", | |
| 1302 | apnsErr.Reason == "Unregistered": | |
| 1303 | // Apple is authoritative about which tokens are live. | |
| 1304 | return resultReap, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason) | |
| 1305 | case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500: | |
| 1306 | return resultRetry, after, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason) | |
| 1307 | default: | |
| 1308 | // Retrying a rejected payload will not fix it. | |
| 1309 | return resultDead, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason) | |
| 1310 | } | |
| 1311 | } | |
| 1312 | ``` | |
| 1313 | ||
| 1314 | - [ ] **Step 4: Run the tests to verify they pass** | |
| 1315 | ||
| 1316 | Run: `go test ./internal/push/ -v` | |
| 1317 | Expected: PASS, all tests. | |
| 1318 | ||
| 1319 | - [ ] **Step 5: Commit** | |
| 1320 | ||
| 1321 | ```bash | |
| 1322 | git add internal/push/apns.go internal/push/apns_test.go | |
| 1323 | git -c commit.gpgsign=true commit -m "push: the APNs client | |
| 1324 | ||
| 1325 | POSTs one alert per call and maps the response: 200 sent, 410 and | |
| 1326 | BadDeviceToken reap the device, 429 and 5xx retry honouring | |
| 1327 | Retry-After, everything else dead-letters. | |
| 1328 | ||
| 1329 | Ref #89" | |
| 1330 | ``` | |
| 1331 | ||
| 1332 | --- | |
| 1333 | ||
| 1334 | ### Task 6: The drainer, and gitbayd wiring | |
| 1335 | ||
| 1336 | **Files:** | |
| 1337 | - Create: `internal/push/push.go` | |
| 1338 | - Modify: `cmd/gitbayd/main.go:175-178` | |
| 1339 | - Test: `internal/push/push_test.go` | |
| 1340 | ||
| 1341 | **Interfaces:** | |
| 1342 | - Consumes: `Client`, `result` constants from Task 5; the store queue functions from Task 2. | |
| 1343 | - Produces: | |
| 1344 | - `type Deliverer struct { St *store.Store; Cl *Client; RetryBase time.Duration; MaxAttempts int }` | |
| 1345 | - `func New(st *store.Store, cfg config.Push, retryBase time.Duration) (*Deliverer, error)` | |
| 1346 | - `func (d *Deliverer) Run(ctx context.Context)` | |
| 1347 | - `func (d *Deliverer) drain(ctx context.Context)` — one pass, for tests | |
| 1348 | - `const DefaultMaxAttempts = 5` | |
| 1349 | ||
| 1350 | - [ ] **Step 1: Write the failing test** | |
| 1351 | ||
| 1352 | `internal/push/push_test.go`: | |
| 1353 | ||
| 1354 | ```go | |
| 1355 | package push | |
| 1356 | ||
| 1357 | import ( | |
| 1358 | "context" | |
| 1359 | "net/http" | |
| 1360 | "testing" | |
| 1361 | "time" | |
| 1362 | ) | |
| 1363 | ||
| 1364 | func TestDrainSendsAndMarks(t *testing.T) { | |
| 1365 | var hits int | |
| 1366 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 1367 | hits++ | |
| 1368 | w.WriteHeader(200) | |
| 1369 | }) | |
| 1370 | st := testStoreWithQueuedPush(t, "tok-a") | |
| 1371 | d := &Deliverer{St: st, Cl: c, RetryBase: time.Millisecond, MaxAttempts: 5} | |
| 1372 | ||
| 1373 | d.drain(context.Background()) | |
| 1374 | ||
| 1375 | if hits != 1 { | |
| 1376 | t.Fatalf("sent %d times, want 1", hits) | |
| 1377 | } | |
| 1378 | if due, _ := st.DuePush(20); len(due) != 0 { | |
| 1379 | t.Fatalf("row still due after a 200") | |
| 1380 | } | |
| 1381 | } | |
| 1382 | ||
| 1383 | func TestDrainReapsADeadToken(t *testing.T) { | |
| 1384 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 1385 | w.WriteHeader(410) | |
| 1386 | w.Write([]byte(`{"reason":"Unregistered"}`)) | |
| 1387 | }) | |
| 1388 | st := testStoreWithQueuedPush(t, "tok-a") | |
| 1389 | d := &Deliverer{St: st, Cl: c, RetryBase: time.Millisecond, MaxAttempts: 5} | |
| 1390 | ||
| 1391 | d.drain(context.Background()) | |
| 1392 | ||
| 1393 | if due, _ := st.DuePush(20); len(due) != 0 { | |
| 1394 | t.Fatalf("queue survived the reap") | |
| 1395 | } | |
| 1396 | // The device is gone, not merely its queue row. | |
| 1397 | if n := countPushDevices(t, st); n != 0 { | |
| 1398 | t.Fatalf("%d devices left after 410", n) | |
| 1399 | } | |
| 1400 | } | |
| 1401 | ||
| 1402 | func TestDrainBacksOffThenDeadLetters(t *testing.T) { | |
| 1403 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 1404 | w.WriteHeader(503) | |
| 1405 | }) | |
| 1406 | st := testStoreWithQueuedPush(t, "tok-a") | |
| 1407 | d := &Deliverer{St: st, Cl: c, RetryBase: time.Nanosecond, MaxAttempts: 3} | |
| 1408 | ||
| 1409 | // Three passes: two back off, the third gives up. | |
| 1410 | for i := 0; i < 3; i++ { | |
| 1411 | d.drain(context.Background()) | |
| 1412 | } | |
| 1413 | if due, _ := st.DuePush(20); len(due) != 0 { | |
| 1414 | t.Fatalf("row still due after MaxAttempts") | |
| 1415 | } | |
| 1416 | // A transient failure must not take the device with it. | |
| 1417 | if n := countPushDevices(t, st); n != 1 { | |
| 1418 | t.Fatalf("device reaped on a 503") | |
| 1419 | } | |
| 1420 | } | |
| 1421 | ``` | |
| 1422 | ||
| 1423 | Write `testStoreWithQueuedPush` and `countPushDevices` as helpers in this | |
| 1424 | file. `testStoreWithQueuedPush` opens a store the way `internal/store`'s | |
| 1425 | own tests do, creates a user, calls `AddPushDevice` and `EnqueuePush`, | |
| 1426 | and returns the store. If opening a store from `internal/push` is | |
| 1427 | awkward, put the helpers in `internal/store/export_test.go` style — check | |
| 1428 | what `internal/webhook`'s tests do for the same problem and follow it | |
| 1429 | rather than inventing a third way. | |
| 1430 | ||
| 1431 | - [ ] **Step 2: Run the test to verify it fails** | |
| 1432 | ||
| 1433 | Run: `go test ./internal/push/ -run TestDrain -v` | |
| 1434 | Expected: FAIL — `Deliverer` undefined. | |
| 1435 | ||
| 1436 | - [ ] **Step 3: Write the implementation** | |
| 1437 | ||
| 1438 | `internal/push/push.go`: | |
| 1439 | ||
| 1440 | ```go | |
| 1441 | package push | |
| 1442 | ||
| 1443 | import ( | |
| 1444 | "context" | |
| 1445 | "log/slog" | |
| 1446 | "time" | |
| 1447 | ||
| 1448 | "gitbay.org/gitbay/internal/config" | |
| 1449 | "gitbay.org/gitbay/internal/store" | |
| 1450 | ) | |
| 1451 | ||
| 1452 | // DefaultMaxAttempts matches the mailer's: a flaky APNs delays a | |
| 1453 | // notification rather than losing it, up to a point. | |
| 1454 | const DefaultMaxAttempts = 5 | |
| 1455 | ||
| 1456 | type Deliverer struct { | |
| 1457 | St *store.Store | |
| 1458 | Cl *Client | |
| 1459 | RetryBase time.Duration | |
| 1460 | MaxAttempts int | |
| 1461 | } | |
| 1462 | ||
| 1463 | func New(st *store.Store, cfg config.Push, retryBase time.Duration) (*Deliverer, error) { | |
| 1464 | cl, err := NewClient(cfg) | |
| 1465 | if err != nil { | |
| 1466 | return nil, err | |
| 1467 | } | |
| 1468 | return &Deliverer{St: st, Cl: cl, RetryBase: retryBase, MaxAttempts: DefaultMaxAttempts}, nil | |
| 1469 | } | |
| 1470 | ||
| 1471 | // Run drains the push queue until ctx is done. | |
| 1472 | func (d *Deliverer) Run(ctx context.Context) { | |
| 1473 | tick := time.NewTicker(2 * time.Second) | |
| 1474 | defer tick.Stop() | |
| 1475 | for { | |
| 1476 | select { | |
| 1477 | case <-ctx.Done(): | |
| 1478 | return | |
| 1479 | case <-tick.C: | |
| 1480 | d.drain(ctx) | |
| 1481 | } | |
| 1482 | } | |
| 1483 | } | |
| 1484 | ||
| 1485 | func (d *Deliverer) drain(ctx context.Context) { | |
| 1486 | due, err := d.St.DuePush(20) | |
| 1487 | if err != nil { | |
| 1488 | slog.Error("push: listing due", "err", err) | |
| 1489 | return | |
| 1490 | } | |
| 1491 | for _, q := range due { | |
| 1492 | res, after, sendErr := d.Cl.Send(ctx, q.Token, q.Title, q.Body, q.Path) | |
| 1493 | msg := "" | |
| 1494 | if sendErr != nil { | |
| 1495 | msg = sendErr.Error() | |
| 1496 | } | |
| 1497 | switch res { | |
| 1498 | case resultSent: | |
| 1499 | d.St.MarkPushSent(q.ID) | |
| 1500 | case resultReap: | |
| 1501 | // The queued rows cascade with the device. | |
| 1502 | if err := d.St.DeletePushDeviceByToken(q.Token); err != nil { | |
| 1503 | slog.Error("push: reaping device", "device", q.DeviceID, "err", err) | |
| 1504 | } | |
| 1505 | case resultRetry: | |
| 1506 | attempt := q.Attempts + 1 | |
| 1507 | if attempt >= d.MaxAttempts { | |
| 1508 | d.St.MarkPushFailed(q.ID, msg, nil) | |
| 1509 | // The device id, never the token. | |
| 1510 | slog.Warn("push dead-lettered", | |
| 1511 | "push", q.ID, "device", q.DeviceID, "attempts", attempt, "err", msg) | |
| 1512 | continue | |
| 1513 | } | |
| 1514 | wait := after | |
| 1515 | if wait == 0 { | |
| 1516 | wait = d.RetryBase << (attempt - 1) | |
| 1517 | } | |
| 1518 | next := time.Now().Add(wait) | |
| 1519 | d.St.MarkPushFailed(q.ID, msg, &next) | |
| 1520 | default: // resultDead | |
| 1521 | d.St.MarkPushFailed(q.ID, msg, nil) | |
| 1522 | slog.Warn("push rejected", "push", q.ID, "device", q.DeviceID, "err", msg) | |
| 1523 | } | |
| 1524 | } | |
| 1525 | } | |
| 1526 | ``` | |
| 1527 | ||
| 1528 | - [ ] **Step 4: Wire it into gitbayd** | |
| 1529 | ||
| 1530 | In `cmd/gitbayd/main.go`, beside the mailer at line 177: | |
| 1531 | ||
| 1532 | ```go | |
| 1533 | if cfg.Push.Enabled { | |
| 1534 | p, err := push.New(st, cfg.Push, retryBase) | |
| 1535 | if err != nil { | |
| 1536 | // Config validation already parsed the key, so this | |
| 1537 | // is not a misconfiguration; fail loudly rather than | |
| 1538 | // running with a silent delivery route. | |
| 1539 | slog.Error("push: starting deliverer", "err", err) | |
| 1540 | } else { | |
| 1541 | go p.Run(whCtx) | |
| 1542 | } | |
| 1543 | } | |
| 1544 | ``` | |
| 1545 | ||
| 1546 | Add `"gitbay.org/gitbay/internal/push"` to the file's imports. | |
| 1547 | ||
| 1548 | - [ ] **Step 5: Run the tests to verify they pass** | |
| 1549 | ||
| 1550 | Run: `go test ./internal/push/ -v && go build ./... && go vet ./...` | |
| 1551 | Expected: PASS and clean. | |
| 1552 | ||
| 1553 | - [ ] **Step 6: Commit** | |
| 1554 | ||
| 1555 | ```bash | |
| 1556 | git add internal/push/push.go internal/push/push_test.go cmd/gitbayd/main.go | |
| 1557 | git -c commit.gpgsign=true commit -m "push: drain the queue, started by gitbayd | |
| 1558 | ||
| 1559 | Two-second ticker in the mailer's shape. A reap drops the device and | |
| 1560 | its queued rows cascade; a retry honours Retry-After when APNs gave | |
| 1561 | one. Log lines name the device id, never the token. | |
| 1562 | ||
| 1563 | Ref #89" | |
| 1564 | ``` | |
| 1565 | ||
| 1566 | --- | |
| 1567 | ||
| 1568 | ### Task 7: The control commands | |
| 1569 | ||
| 1570 | **Files:** | |
| 1571 | - Modify: `internal/control/notifications.go` | |
| 1572 | - Modify: `cmd/gitbay/main.go:64-73` | |
| 1573 | - Test: `internal/control/notifications_test.go` | |
| 1574 | ||
| 1575 | **Interfaces:** | |
| 1576 | - Consumes: the store device functions from Task 1. | |
| 1577 | - Produces: registry entries `notifications device add|list|remove` and `notifications settings push`; `emitNotificationSettings` gains a `push` key. | |
| 1578 | ||
| 1579 | - [ ] **Step 1: Write the failing test** | |
| 1580 | ||
| 1581 | Add to `internal/control/notifications_test.go` (create it if absent, | |
| 1582 | following `internal/control/mr_test.go` for how a `Ctx` is built): | |
| 1583 | ||
| 1584 | ```go | |
| 1585 | func TestNotificationsDeviceAddReadsStdin(t *testing.T) { | |
| 1586 | c := testCtx(t, "alice") | |
| 1587 | c.Stdin = strings.NewReader("DEVTOKEN\n") | |
| 1588 | if code := runNotificationsDeviceAdd(c, []string{"--label", "iphone"}); code != 0 { | |
| 1589 | t.Fatalf("exit %d", code) | |
| 1590 | } | |
| 1591 | devices, _ := c.Store.PushDevices(c.User.ID) | |
| 1592 | if len(devices) != 1 || devices[0].Token != "DEVTOKEN" { | |
| 1593 | t.Fatalf("got %+v", devices) | |
| 1594 | } | |
| 1595 | if devices[0].Label != "iphone" { | |
| 1596 | t.Fatalf("label = %q", devices[0].Label) | |
| 1597 | } | |
| 1598 | } | |
| 1599 | ||
| 1600 | func TestNotificationsDeviceListTruncatesTheToken(t *testing.T) { | |
| 1601 | c := testCtx(t, "alice") | |
| 1602 | long := strings.Repeat("a", 64) | |
| 1603 | c.Store.AddPushDevice(c.User.ID, long, "iphone") | |
| 1604 | var out bytes.Buffer | |
| 1605 | c.Stdout = &out | |
| 1606 | if code := runNotificationsDeviceList(c, nil); code != 0 { | |
| 1607 | t.Fatalf("exit %d", code) | |
| 1608 | } | |
| 1609 | if strings.Contains(out.String(), long) { | |
| 1610 | t.Fatal("the full token was printed") | |
| 1611 | } | |
| 1612 | } | |
| 1613 | ||
| 1614 | func TestNotificationsSettingsShowsPush(t *testing.T) { | |
| 1615 | c := testCtx(t, "alice") | |
| 1616 | var out bytes.Buffer | |
| 1617 | c.Stdout, c.JSON = &out, true | |
| 1618 | if code := runNotificationsSettingsShow(c, nil); code != 0 { | |
| 1619 | t.Fatalf("exit %d", code) | |
| 1620 | } | |
| 1621 | if !strings.Contains(out.String(), `"push":true`) { | |
| 1622 | t.Fatalf("no push key: %s", out.String()) | |
| 1623 | } | |
| 1624 | } | |
| 1625 | ``` | |
| 1626 | ||
| 1627 | - [ ] **Step 2: Run the test to verify it fails** | |
| 1628 | ||
| 1629 | Run: `go test ./internal/control/ -run TestNotifications -v` | |
| 1630 | Expected: FAIL — `runNotificationsDeviceAdd` undefined. | |
| 1631 | ||
| 1632 | - [ ] **Step 3: Register and implement the commands** | |
| 1633 | ||
| 1634 | In the `init()` of `internal/control/notifications.go`: | |
| 1635 | ||
| 1636 | ```go | |
| 1637 | register(Command{Path: []string{"notifications", "device", "add"}, | |
| 1638 | Summary: "register an Apple device for push, token on stdin", | |
| 1639 | Usage: "notifications device add [--label <name>] < token", | |
| 1640 | // Mandatory: without it control.go swaps in an empty reader and | |
| 1641 | // this command stores an empty token without erroring. | |
| 1642 | ReadsStdin: true, Run: runNotificationsDeviceAdd}) | |
| 1643 | register(Command{Path: []string{"notifications", "device", "list"}, | |
| 1644 | Summary: "your registered devices", | |
| 1645 | Usage: "notifications device list", | |
| 1646 | ReadOnly: true, Run: runNotificationsDeviceList}) | |
| 1647 | register(Command{Path: []string{"notifications", "device", "remove"}, | |
| 1648 | Summary: "deregister a device", | |
| 1649 | Usage: "notifications device remove <id>", Run: runNotificationsDeviceRemove}) | |
| 1650 | register(Command{Path: []string{"notifications", "settings", "push"}, | |
| 1651 | Summary: "activity on your registered devices as well as the inbox", | |
| 1652 | Usage: "notifications settings push on|off", Run: runNotificationsSettingsPush}) | |
| 1653 | ``` | |
| 1654 | ||
| 1655 | And the implementations: | |
| 1656 | ||
| 1657 | ```go | |
| 1658 | // maxDeviceTokenBytes is well past APNs' 32-byte token rendered as 64 hex | |
| 1659 | // characters, and stops a stdin that is not a token from becoming a row. | |
| 1660 | const maxDeviceTokenBytes = 512 | |
| 1661 | ||
| 1662 | func runNotificationsDeviceAdd(c *Ctx, args []string) int { | |
| 1663 | f, err := parseFlags(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage}) | |
| 1664 | if err != nil { | |
| 1665 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 1666 | } | |
| 1667 | if len(f.Pos) != 0 { | |
| 1668 | return c.usage() | |
| 1669 | } | |
| 1670 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxDeviceTokenBytes+1)) | |
| 1671 | if err != nil { | |
| 1672 | return c.fail(protocol.ExitFailure, "reading stdin: %v", err) | |
| 1673 | } | |
| 1674 | token := strings.TrimSpace(string(raw)) | |
| 1675 | if token == "" { | |
| 1676 | return c.usageWith("no device token on stdin") | |
| 1677 | } | |
| 1678 | if len(token) > maxDeviceTokenBytes { | |
| 1679 | return c.fail(protocol.ExitUsage, "device token is too long") | |
| 1680 | } | |
| 1681 | if _, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label")); err != nil { | |
| 1682 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1683 | } | |
| 1684 | return c.emit(map[string]string{"status": "registered"}, func(w io.Writer) { | |
| 1685 | fmt.Fprintln(w, "device registered") | |
| 1686 | }) | |
| 1687 | } | |
| 1688 | ||
| 1689 | func runNotificationsDeviceList(c *Ctx, args []string) int { | |
| 1690 | if len(args) != 0 { | |
| 1691 | return c.usage() | |
| 1692 | } | |
| 1693 | devices, err := c.Store.PushDevices(c.User.ID) | |
| 1694 | if err != nil { | |
| 1695 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1696 | } | |
| 1697 | type row struct { | |
| 1698 | ID int64 `json:"id"` | |
| 1699 | Label string `json:"label"` | |
| 1700 | Token string `json:"token"` // truncated; a token is not echoed in full | |
| 1701 | Added string `json:"added"` | |
| 1702 | } | |
| 1703 | rows := make([]row, 0, len(devices)) | |
| 1704 | for _, d := range devices { | |
| 1705 | rows = append(rows, row{ID: d.ID, Label: d.Label, | |
| 1706 | Token: shortToken(d.Token), Added: d.CreatedAt}) | |
| 1707 | } | |
| 1708 | return c.emit(rows, func(w io.Writer) { | |
| 1709 | for _, r := range rows { | |
| 1710 | fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", r.ID, r.Label, r.Token, r.Added) | |
| 1711 | } | |
| 1712 | }) | |
| 1713 | } | |
| 1714 | ||
| 1715 | // shortToken renders a device token as its first eight characters. Enough | |
| 1716 | // to tell two devices apart in a list, not enough to push to one. | |
| 1717 | func shortToken(t string) string { | |
| 1718 | if len(t) <= 8 { | |
| 1719 | return t | |
| 1720 | } | |
| 1721 | return t[:8] + "…" | |
| 1722 | } | |
| 1723 | ||
| 1724 | func runNotificationsDeviceRemove(c *Ctx, args []string) int { | |
| 1725 | if len(args) != 1 { | |
| 1726 | return c.usage() | |
| 1727 | } | |
| 1728 | id, err := strconv.ParseInt(args[0], 10, 64) | |
| 1729 | if err != nil { | |
| 1730 | return c.usageWith("device id must be a number") | |
| 1731 | } | |
| 1732 | if err := c.Store.RemovePushDevice(c.User.ID, id); err != nil { | |
| 1733 | if errors.Is(err, store.ErrNotFound) { | |
| 1734 | return c.fail(protocol.ExitNotFound, "no such device; notifications device list shows yours") | |
| 1735 | } | |
| 1736 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1737 | } | |
| 1738 | return c.emit(map[string]string{"status": "removed"}, func(w io.Writer) { | |
| 1739 | fmt.Fprintln(w, "device removed") | |
| 1740 | }) | |
| 1741 | } | |
| 1742 | ||
| 1743 | func runNotificationsSettingsPush(c *Ctx, args []string) int { | |
| 1744 | if len(args) != 1 || (args[0] != "on" && args[0] != "off") { | |
| 1745 | return c.usage() | |
| 1746 | } | |
| 1747 | if err := c.Store.SetPushEnabled(c.User.ID, args[0] == "on"); err != nil { | |
| 1748 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1749 | } | |
| 1750 | return emitNotificationSettings(c) | |
| 1751 | } | |
| 1752 | ``` | |
| 1753 | ||
| 1754 | Add `"errors"` and `"gitbay.org/gitbay/internal/store"` to the imports if | |
| 1755 | the file lacks them. | |
| 1756 | ||
| 1757 | - [ ] **Step 4: Extend `emitNotificationSettings`** | |
| 1758 | ||
| 1759 | Replace the body of `emitNotificationSettings` so it reads `push` too and | |
| 1760 | adds it to both outputs: | |
| 1761 | ||
| 1762 | ```go | |
| 1763 | push, err := c.Store.PushEnabled(c.User.ID) | |
| 1764 | if err != nil { | |
| 1765 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1766 | } | |
| 1767 | return c.emit(map[string]bool{"mail": mail, "watch": watch, "push": push}, func(w io.Writer) { | |
| 1768 | ... | |
| 1769 | fmt.Fprintf(w, "mail: %s\nwatch: %s\npush: %s\n", onOff(mail), onOff(watch), onOff(push)) | |
| 1770 | }) | |
| 1771 | ``` | |
| 1772 | ||
| 1773 | - [ ] **Step 5: Add the CLI passthroughs** | |
| 1774 | ||
| 1775 | In `cmd/gitbay/main.go`, inside the `notifications` group around line 64, | |
| 1776 | add a `device` subgroup and the settings entry: | |
| 1777 | ||
| 1778 | ```go | |
| 1779 | group("device", "Apple devices registered for push", | |
| 1780 | pass("add", "register a device, token on stdin: [--label name]", | |
| 1781 | passOpts{server: []string{"notifications", "device", "add"}, stdin: true}), | |
| 1782 | pass("list", "your registered devices", | |
| 1783 | passOpts{server: []string{"notifications", "device", "list"}}), | |
| 1784 | pass("remove", "deregister a device: <id>", | |
| 1785 | passOpts{server: []string{"notifications", "device", "remove"}}), | |
| 1786 | ), | |
| 1787 | ``` | |
| 1788 | ||
| 1789 | and beside `mail` and `watch` in the settings group: | |
| 1790 | ||
| 1791 | ```go | |
| 1792 | pass("push", "activity on your registered devices: on|off", passOpts{server: []string{"notifications", "settings", "push"}}), | |
| 1793 | ``` | |
| 1794 | ||
| 1795 | Check `passOpts`' real field for a stdin-reading command against how | |
| 1796 | `snippet create` is registered in the same file — use that name, not | |
| 1797 | `stdin:` if it differs. | |
| 1798 | ||
| 1799 | - [ ] **Step 6: Run the tests to verify they pass** | |
| 1800 | ||
| 1801 | Run: `go test ./internal/control/ ./cmd/gitbay/ -v` | |
| 1802 | Expected: PASS. Four registry tests exercise the new commands without | |
| 1803 | being edited: `TestStdinCommandsReadStdin` (which fails if `device add` | |
| 1804 | lacks `ReadsStdin`), `TestReadOnlyCommandsWriteNothing`, the | |
| 1805 | `cmd/gitbay` coverage test (which fails without the `pass()` entries), | |
| 1806 | and the usage-literal check. | |
| 1807 | ||
| 1808 | - [ ] **Step 7: Commit** | |
| 1809 | ||
| 1810 | ```bash | |
| 1811 | git add internal/control/notifications.go internal/control/notifications_test.go cmd/gitbay/main.go | |
| 1812 | git -c commit.gpgsign=true commit -m "control: notifications device and settings push | |
| 1813 | ||
| 1814 | Token on stdin, never argv. device list truncates the token to eight | |
| 1815 | characters: enough to tell two devices apart, not enough to push to | |
| 1816 | one. settings show gains a third key. | |
| 1817 | ||
| 1818 | Ref #89" | |
| 1819 | ``` | |
| 1820 | ||
| 1821 | --- | |
| 1822 | ||
| 1823 | ### Task 8: Push as the third route in `notify()` | |
| 1824 | ||
| 1825 | **Files:** | |
| 1826 | - Modify: `internal/control/notifications.go:66-85` (`notify`) | |
| 1827 | - Test: `internal/control/notifications_test.go` | |
| 1828 | ||
| 1829 | **Interfaces:** | |
| 1830 | - Consumes: `EnqueuePush` from Task 2. | |
| 1831 | - Produces: `func pushTitle(n notice) string` and `func pushBody(n notice) string`. | |
| 1832 | ||
| 1833 | - [ ] **Step 1: Write the failing test** | |
| 1834 | ||
| 1835 | ```go | |
| 1836 | func TestNotifyQueuesPush(t *testing.T) { | |
| 1837 | c, repo, bob := testRepoWithWatcher(t) // alice acts, bob watches | |
| 1838 | c.Store.AddPushDevice(bob, "tok-b", "iphone") | |
| 1839 | ||
| 1840 | notify(c, []int64{bob}, notice{repo: repo, kind: "issue", | |
| 1841 | subject: "[alice/app] #1: title", | |
| 1842 | action: "opened issue #1", | |
| 1843 | path: "alice/app/issues/1"}) | |
| 1844 | ||
| 1845 | due, err := c.Store.DuePush(20) | |
| 1846 | if err != nil { | |
| 1847 | t.Fatalf("DuePush: %v", err) | |
| 1848 | } | |
| 1849 | if len(due) != 1 { | |
| 1850 | t.Fatalf("want one queued push, got %d", len(due)) | |
| 1851 | } | |
| 1852 | // The push body is the inbox row's summary, so the two surfaces | |
| 1853 | // cannot disagree about what happened. | |
| 1854 | if due[0].Title != "alice/app" { | |
| 1855 | t.Fatalf("title = %q", due[0].Title) | |
| 1856 | } | |
| 1857 | if due[0].Body != "alice opened issue #1" { | |
| 1858 | t.Fatalf("body = %q", due[0].Body) | |
| 1859 | } | |
| 1860 | if due[0].Path != "alice/app/issues/1" { | |
| 1861 | t.Fatalf("path = %q", due[0].Path) | |
| 1862 | } | |
| 1863 | } | |
| 1864 | ||
| 1865 | func TestNotifyQueuesNoPushForTheActor(t *testing.T) { | |
| 1866 | c, repo, _ := testRepoWithWatcher(t) | |
| 1867 | c.Store.AddPushDevice(c.User.ID, "tok-self", "iphone") | |
| 1868 | ||
| 1869 | notify(c, []int64{c.User.ID}, notice{repo: repo, kind: "issue", | |
| 1870 | subject: "s", action: "opened issue #1", path: "alice/app/issues/1"}) | |
| 1871 | ||
| 1872 | // NotifyRecipients already drops the actor; push inherits that and | |
| 1873 | // must not find its own way around it. | |
| 1874 | if due, _ := c.Store.DuePush(20); len(due) != 0 { | |
| 1875 | t.Fatalf("queued a push to the actor") | |
| 1876 | } | |
| 1877 | } | |
| 1878 | ``` | |
| 1879 | ||
| 1880 | Write `testRepoWithWatcher` to return a `*Ctx` acting as alice, a | |
| 1881 | `store.Repo` she owns, and bob's user id with a watch row on it. Follow | |
| 1882 | whatever `internal/control`'s existing tests do to build a repo. | |
| 1883 | ||
| 1884 | - [ ] **Step 2: Run the test to verify it fails** | |
| 1885 | ||
| 1886 | Run: `go test ./internal/control/ -run TestNotifyQueues -v` | |
| 1887 | Expected: FAIL — one queued push wanted, none found. | |
| 1888 | ||
| 1889 | - [ ] **Step 3: Write the implementation** | |
| 1890 | ||
| 1891 | In `notify()`, inside the existing `for _, id := range recipients` loop, | |
| 1892 | after `AddNotice` and before the `if !sendMail { continue }`: | |
| 1893 | ||
| 1894 | ```go | |
| 1895 | c.Store.EnqueuePush(id, pushTitle(n), pushBody(c.User.Username, n), n.path) | |
| 1896 | ``` | |
| 1897 | ||
| 1898 | Putting it above the `continue` matters — an instance without SMTP still | |
| 1899 | pushes. | |
| 1900 | ||
| 1901 | And beside `noticeBody`: | |
| 1902 | ||
| 1903 | ```go | |
| 1904 | // pushTitle and pushBody are the alert's two lines. The body is built | |
| 1905 | // from the same two values AddNotice files, so the alert and the inbox | |
| 1906 | // row cannot disagree about what happened. The title is the repository, | |
| 1907 | // which also groups a repository's notices in Notification Center. | |
| 1908 | func pushTitle(n notice) string { return n.repo.Path() } | |
| 1909 | ||
| 1910 | func pushBody(actor string, n notice) string { return actor + " " + n.action } | |
| 1911 | ``` | |
| 1912 | ||
| 1913 | `notice` has no `actor` field and does not gain one: the actor is | |
| 1914 | `c.User.Username`, already passed to `AddNotice` on the line above, so | |
| 1915 | threading it through the struct would be a second copy of the same | |
| 1916 | value. The call in the loop is therefore: | |
| 1917 | ||
| 1918 | ```go | |
| 1919 | c.Store.EnqueuePush(id, pushTitle(n), pushBody(c.User.Username, n), n.path) | |
| 1920 | ``` | |
| 1921 | ||
| 1922 | - [ ] **Step 4: Run the tests to verify they pass** | |
| 1923 | ||
| 1924 | Run: `go test ./internal/control/ -v` | |
| 1925 | Expected: PASS, the whole package — `notify` has sixteen call sites and | |
| 1926 | this changes all of them. | |
| 1927 | ||
| 1928 | - [ ] **Step 5: Commit** | |
| 1929 | ||
| 1930 | ```bash | |
| 1931 | git add internal/control/notifications.go internal/control/notifications_test.go | |
| 1932 | git -c commit.gpgsign=true commit -m "control: push as the third route in notify | |
| 1933 | ||
| 1934 | Queued in the same loop as the inbox row and the mail, above the SMTP | |
| 1935 | check so an instance without a relay still pushes. The alert body is | |
| 1936 | the inbox summary, so the surfaces cannot disagree. | |
| 1937 | ||
| 1938 | Ref #89" | |
| 1939 | ``` | |
| 1940 | ||
| 1941 | --- | |
| 1942 | ||
| 1943 | ### Task 9: `issue assign` files a notice | |
| 1944 | ||
| 1945 | **Files:** | |
| 1946 | - Modify: `internal/control/issue.go:423-470` | |
| 1947 | - Test: `internal/control/issue_test.go` | |
| 1948 | ||
| 1949 | **Interfaces:** | |
| 1950 | - Consumes: `notify`, `notice` from Task 8. | |
| 1951 | - Produces: nothing new. | |
| 1952 | ||
| 1953 | - [ ] **Step 1: Write the failing test** | |
| 1954 | ||
| 1955 | ```go | |
| 1956 | func TestIssueAssignNotifiesTheAssignee(t *testing.T) { | |
| 1957 | c, repo, bob := testRepoWithWatcher(t) | |
| 1958 | ||
| 1959 | if code := runIssueAssign(c, []string{repo.Path(), "1", "--add", "bob"}); code != 0 { | |
| 1960 | t.Fatalf("exit %d", code) | |
| 1961 | } | |
| 1962 | rows, _ := c.Store.Inbox(bob, false, 20, 0) | |
| 1963 | if len(rows) != 1 || rows[0].Summary != "assigned you to #1" { | |
| 1964 | t.Fatalf("got %+v", rows) | |
| 1965 | } | |
| 1966 | } | |
| 1967 | ||
| 1968 | func TestIssueAssignIsSilentForTheActorAndForRemovals(t *testing.T) { | |
| 1969 | c, repo, bob := testRepoWithWatcher(t) | |
| 1970 | ||
| 1971 | // Assigning yourself announces nothing: notify drops the actor. | |
| 1972 | runIssueAssign(c, []string{repo.Path(), "1", "--add", "alice"}) | |
| 1973 | if rows, _ := c.Store.Inbox(c.User.ID, false, 20, 0); len(rows) != 0 { | |
| 1974 | t.Fatalf("self-assignment notified: %+v", rows) | |
| 1975 | } | |
| 1976 | ||
| 1977 | // Unassigning files nothing. | |
| 1978 | runIssueAssign(c, []string{repo.Path(), "1", "--add", "bob"}) | |
| 1979 | before, _ := c.Store.Inbox(bob, false, 20, 0) | |
| 1980 | runIssueAssign(c, []string{repo.Path(), "1", "--remove", "bob"}) | |
| 1981 | after, _ := c.Store.Inbox(bob, false, 20, 0) | |
| 1982 | if len(after) != len(before) { | |
| 1983 | t.Fatalf("removal filed a row: %d then %d", len(before), len(after)) | |
| 1984 | } | |
| 1985 | } | |
| 1986 | ``` | |
| 1987 | ||
| 1988 | The helper needs an issue #1 on the repo; extend `testRepoWithWatcher` | |
| 1989 | from Task 8 or add a sibling that also opens one. | |
| 1990 | ||
| 1991 | - [ ] **Step 2: Run the test to verify it fails** | |
| 1992 | ||
| 1993 | Run: `go test ./internal/control/ -run TestIssueAssign -v` | |
| 1994 | Expected: FAIL — the inbox is empty. | |
| 1995 | ||
| 1996 | - [ ] **Step 3: Write the implementation** | |
| 1997 | ||
| 1998 | In `runIssueAssign`, collect the ids as the add loop resolves them: | |
| 1999 | ||
| 2000 | ```go | |
| 2001 | var added []int64 | |
| 2002 | for _, name := range adds { | |
| 2003 | u, code := resolve(name) | |
| 2004 | if code >= 0 { | |
| 2005 | return code | |
| 2006 | } | |
| 2007 | if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil { | |
| 2008 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 2009 | } | |
| 2010 | added = append(added, u.ID) | |
| 2011 | } | |
| 2012 | ``` | |
| 2013 | ||
| 2014 | and after both loops succeed, before the function's existing return: | |
| 2015 | ||
| 2016 | ```go | |
| 2017 | if len(added) > 0 { | |
| 2018 | // direct, as a mention is: an assignment is addressed to someone, | |
| 2019 | // and widening it to watchers would tell them "assigned you". | |
| 2020 | // Removals file nothing, and notify drops the actor, so assigning | |
| 2021 | // yourself is silent. | |
| 2022 | notify(c, added, notice{repo: repo, kind: "issue", direct: true, | |
| 2023 | subject: fmt.Sprintf("[%s] #%d: %s", repo.Path(), issue.Number, issue.Title), | |
| 2024 | action: fmt.Sprintf("assigned you to #%d", issue.Number), | |
| 2025 | path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) | |
| 2026 | } | |
| 2027 | ``` | |
| 2028 | ||
| 2029 | - [ ] **Step 4: Run the tests to verify they pass** | |
| 2030 | ||
| 2031 | Run: `go test ./internal/control/ -run TestIssueAssign -v` | |
| 2032 | Expected: PASS. | |
| 2033 | ||
| 2034 | - [ ] **Step 5: Commit** | |
| 2035 | ||
| 2036 | ```bash | |
| 2037 | git add internal/control/issue.go internal/control/issue_test.go | |
| 2038 | git -c commit.gpgsign=true commit -m "control: issue assign files a notice | |
| 2039 | ||
| 2040 | The dashboard surfaced assigned work and nothing announced it. Direct, | |
| 2041 | as a mention is, so watchers are not told they were assigned. | |
| 2042 | ||
| 2043 | Ref #89" | |
| 2044 | ``` | |
| 2045 | ||
| 2046 | --- | |
| 2047 | ||
| 2048 | ### Task 10: The web settings page | |
| 2049 | ||
| 2050 | **Files:** | |
| 2051 | - Modify: `internal/httpd/account.go` — `accountPage` around `:65-66`, the page struct around `:76`, and the `accountSubmit` switch at `:246` | |
| 2052 | - Modify: `internal/web/templates/account.html` — the `#notifications` section at `:132` | |
| 2053 | - Test: `internal/httpd/` package tests | |
| 2054 | ||
| 2055 | The page is `/settings`, rendered from `account.html`, with a | |
| 2056 | `#notifications` section that already carries the mail and watch | |
| 2057 | toggles. No new template file, so `TestMainWidthClass` is not involved. | |
| 2058 | ||
| 2059 | **Interfaces:** | |
| 2060 | - Consumes: the control commands from Task 7. | |
| 2061 | - Produces: nothing other tasks use. | |
| 2062 | ||
| 2063 | - [ ] **Step 1: Write the failing test** | |
| 2064 | ||
| 2065 | Follow the existing `internal/httpd` account-page test for how a page is | |
| 2066 | rendered and its body captured. | |
| 2067 | ||
| 2068 | ```go | |
| 2069 | func TestAccountPagePushToggleAndDevices(t *testing.T) { | |
| 2070 | // ... render /settings for a user with one registered device whose | |
| 2071 | // token is `strings.Repeat("a", 64)`. | |
| 2072 | if !strings.Contains(body, `value="notify-push"`) { | |
| 2073 | t.Fatal("no push toggle") | |
| 2074 | } | |
| 2075 | if !strings.Contains(body, "iphone") { | |
| 2076 | t.Fatal("the device is not listed") | |
| 2077 | } | |
| 2078 | // A token is device-identifying and is never printed in full. | |
| 2079 | if strings.Contains(body, strings.Repeat("a", 64)) { | |
| 2080 | t.Fatal("the page printed a device token in full") | |
| 2081 | } | |
| 2082 | } | |
| 2083 | ``` | |
| 2084 | ||
| 2085 | - [ ] **Step 2: Run the test to verify it fails** | |
| 2086 | ||
| 2087 | Run: `go test ./internal/httpd/ -run TestAccountPage -v` | |
| 2088 | Expected: FAIL — no push toggle. | |
| 2089 | ||
| 2090 | - [ ] **Step 3: Accept the new field** | |
| 2091 | ||
| 2092 | In `accountSubmit` (`internal/httpd/account.go:246`), the case derives | |
| 2093 | `pref` by trimming `notify-`, so this is one token: | |
| 2094 | ||
| 2095 | ```go | |
| 2096 | case "notify-mail", "notify-watch", "notify-push": | |
| 2097 | ``` | |
| 2098 | ||
| 2099 | - [ ] **Step 4: Render the toggle and the list** | |
| 2100 | ||
| 2101 | In `accountPage`, beside `mailOn` and `watchOn`: | |
| 2102 | ||
| 2103 | ```go | |
| 2104 | pushOn, _ := s.st.PushEnabled(u.ID) | |
| 2105 | devices, _ := s.st.PushDevices(u.ID) | |
| 2106 | ``` | |
| 2107 | ||
| 2108 | Add `PushOn bool` and a device slice to the anonymous page struct in the | |
| 2109 | `s.render` call. Render each device with `prefix8` — the truncation | |
| 2110 | helper this file already uses for key fingerprints — not the full token. | |
| 2111 | ||
| 2112 | In `account.html`, after the watch form in the `#notifications` section, | |
| 2113 | copying the shape of the two forms already there: | |
| 2114 | ||
| 2115 | ```html | |
| 2116 | <form method="post" action="/settings" class="setform"> | |
| 2117 | <input type="hidden" name="field" value="notify-push"> | |
| 2118 | <label for="notify-push">Activity on your registered devices</label> | |
| 2119 | <input type="checkbox" id="notify-push" name="push" value="on"{{if .PushOn}} checked{{end}}> | |
| 2120 | <button type="submit" class="btn">Save</button> | |
| 2121 | </form> | |
| 2122 | <p class="meta">Notification text is sent in full, including for private repositories, so a repository name and item number reach Apple and appear on a lock screen.</p> | |
| 2123 | ``` | |
| 2124 | ||
| 2125 | Then the device list, each row posting `field=device-remove` with the | |
| 2126 | id, dispatched through `s.runControl` to | |
| 2127 | `[]string{"notifications", "device", "remove", id}` as a new case in the | |
| 2128 | same switch. | |
| 2129 | ||
| 2130 | There is no add-a-device form: a browser cannot produce an APNs token. | |
| 2131 | That is the Parity page's "CLI only, for now", not a refusal. | |
| 2132 | ||
| 2133 | - [ ] **Step 5: Run the tests to verify they pass** | |
| 2134 | ||
| 2135 | Run: `go test ./internal/httpd/ ./internal/web/ -v` | |
| 2136 | Expected: PASS. Run `./internal/web/` too — the template registry tests | |
| 2137 | live there and a malformed template fails at render, not at build. | |
| 2138 | ||
| 2139 | - [ ] **Step 6: Commit** | |
| 2140 | ||
| 2141 | ```bash | |
| 2142 | git add internal/httpd/ internal/web/ | |
| 2143 | git -c commit.gpgsign=true commit -m "web: push toggle and device list on notification settings | |
| 2144 | ||
| 2145 | No add-a-device form: a browser cannot produce an APNs token. | |
| 2146 | ||
| 2147 | Ref #89" | |
| 2148 | ``` | |
| 2149 | ||
| 2150 | --- | |
| 2151 | ||
| 2152 | ### Task 11: End-to-end | |
| 2153 | ||
| 2154 | **Files:** | |
| 2155 | - Create: `e2e/push_test.go` | |
| 2156 | ||
| 2157 | **Interfaces:** | |
| 2158 | - Consumes: everything above. | |
| 2159 | - Produces: nothing. | |
| 2160 | ||
| 2161 | - [ ] **Step 1: Write the test** | |
| 2162 | ||
| 2163 | `e2e/push_test.go`, following `e2e/bookmarks_test.go` for how an instance | |
| 2164 | and accounts are set up: | |
| 2165 | ||
| 2166 | ```go | |
| 2167 | package e2e | |
| 2168 | ||
| 2169 | import ( | |
| 2170 | "encoding/json" | |
| 2171 | "io" | |
| 2172 | "net/http" | |
| 2173 | "net/http/httptest" | |
| 2174 | "strings" | |
| 2175 | "sync" | |
| 2176 | "testing" | |
| 2177 | "time" | |
| 2178 | ) | |
| 2179 | ||
| 2180 | // A push reaches a registered device with the same words the inbox row | |
| 2181 | // carries, and a token Apple has retired takes its device with it. | |
| 2182 | func TestPush(t *testing.T) { | |
| 2183 | var mu sync.Mutex | |
| 2184 | var got []map[string]any | |
| 2185 | var gone bool | |
| 2186 | ||
| 2187 | apns := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 2188 | raw, _ := io.ReadAll(r.Body) | |
| 2189 | var payload map[string]any | |
| 2190 | json.Unmarshal(raw, &payload) | |
| 2191 | mu.Lock() | |
| 2192 | defer mu.Unlock() | |
| 2193 | if gone { | |
| 2194 | w.WriteHeader(410) | |
| 2195 | io.WriteString(w, `{"reason":"Unregistered"}`) | |
| 2196 | return | |
| 2197 | } | |
| 2198 | got = append(got, payload) | |
| 2199 | w.WriteHeader(200) | |
| 2200 | })) | |
| 2201 | defer apns.Close() | |
| 2202 | ||
| 2203 | keyPath := writeTestAPNSKey(t) | |
| 2204 | t.Setenv("GITBAY_APNS_HOST", strings.TrimPrefix(apns.URL, "http://")) | |
| 2205 | inst := startInstanceWith(t, `[push] | |
| 2206 | enabled = true | |
| 2207 | key_file = "`+keyPath+`" | |
| 2208 | key_id = "KEYID" | |
| 2209 | team_id = "TEAMID" | |
| 2210 | topic = "org.gitbay.gitbay" | |
| 2211 | environment = "production" | |
| 2212 | `) | |
| 2213 | ||
| 2214 | aliceKey := inst.newKey(t, "alice") | |
| 2215 | bobKey := inst.newKey(t, "bob") | |
| 2216 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 2217 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 2218 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 2219 | t.Fatalf("repo create: %s", errOut) | |
| 2220 | } | |
| 2221 | ||
| 2222 | // Bob watches alice's repository and registers a device. | |
| 2223 | if out, errOut, code := inst.ssh(t, bobKey, "", "repo", "watch", "alice/app"); code != 0 { | |
| 2224 | t.Fatalf("watch: %s%s", out, errOut) | |
| 2225 | } | |
| 2226 | if out, errOut, code := inst.ssh(t, bobKey, "DEVTOKEN\n", "notifications", "device", "add", "--label", "iphone"); code != 0 { | |
| 2227 | t.Fatalf("device add: %s%s", out, errOut) | |
| 2228 | } | |
| 2229 | if out, _, _ := inst.ssh(t, bobKey, "", "notifications", "device", "list", "--json"); !strings.Contains(out, `"label":"iphone"`) { | |
| 2230 | t.Fatalf("device not listed:\n%s", out) | |
| 2231 | } else if strings.Contains(out, "DEVTOKEN") { | |
| 2232 | t.Fatalf("device list printed the token in full:\n%s", out) | |
| 2233 | } | |
| 2234 | ||
| 2235 | // Alice opens an issue. Bob hears about it. | |
| 2236 | if out, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "a bug", "--body", "x"); code != 0 { | |
| 2237 | t.Fatalf("issue create: %s%s", out, errOut) | |
| 2238 | } | |
| 2239 | ||
| 2240 | waitFor(t, 20*time.Second, func() bool { | |
| 2241 | mu.Lock() | |
| 2242 | defer mu.Unlock() | |
| 2243 | return len(got) == 1 | |
| 2244 | }, "no push arrived") | |
| 2245 | ||
| 2246 | mu.Lock() | |
| 2247 | aps := got[0]["aps"].(map[string]any) | |
| 2248 | alert := aps["alert"].(map[string]any) | |
| 2249 | mu.Unlock() | |
| 2250 | if alert["title"] != "alice/app" { | |
| 2251 | t.Fatalf("title = %v", alert["title"]) | |
| 2252 | } | |
| 2253 | // The same words the inbox row carries. | |
| 2254 | if body, _ := alert["body"].(string); !strings.Contains(body, "opened issue #1") { | |
| 2255 | t.Fatalf("body = %q", body) | |
| 2256 | } | |
| 2257 | if got[0]["path"] != "alice/app/issues/1" { | |
| 2258 | t.Fatalf("path = %v", got[0]["path"]) | |
| 2259 | } | |
| 2260 | ||
| 2261 | // Apple retires the token. The next push reaps the device. | |
| 2262 | mu.Lock() | |
| 2263 | gone = true | |
| 2264 | mu.Unlock() | |
| 2265 | if out, errOut, code := inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "1", "--body", "ping"); code != 0 { | |
| 2266 | t.Fatalf("issue comment: %s%s", out, errOut) | |
| 2267 | } | |
| 2268 | waitFor(t, 20*time.Second, func() bool { | |
| 2269 | out, _, _ := inst.ssh(t, bobKey, "", "notifications", "device", "list", "--json") | |
| 2270 | return !strings.Contains(out, "iphone") | |
| 2271 | }, "the device survived a 410") | |
| 2272 | ||
| 2273 | // The inbox is untouched by any of it: push is a side channel. | |
| 2274 | if out, _, _ := inst.ssh(t, bobKey, "", "notifications", "list", "--json"); !strings.Contains(out, "opened issue #1") { | |
| 2275 | t.Fatalf("inbox missing the notice:\n%s", out) | |
| 2276 | } | |
| 2277 | } | |
| 2278 | ``` | |
| 2279 | ||
| 2280 | Write `writeTestAPNSKey` (a P-256 PKCS#8 key in a `t.TempDir()`, as in | |
| 2281 | Task 3) and reuse the e2e suite's existing polling helper rather than | |
| 2282 | writing `waitFor` if one exists — check `e2e/` for it first. | |
| 2283 | ||
| 2284 | Note the `ssh` helper's second argument is stdin; that is how `DEVTOKEN` | |
| 2285 | reaches `device add`. | |
| 2286 | ||
| 2287 | - [ ] **Step 2: Run it** | |
| 2288 | ||
| 2289 | Run: `go test ./e2e/ -run TestPush -v` | |
| 2290 | Expected: PASS. This is the one e2e test to run locally; the rest of the | |
| 2291 | suite belongs to CI on bay1. | |
| 2292 | ||
| 2293 | - [ ] **Step 3: Commit** | |
| 2294 | ||
| 2295 | ```bash | |
| 2296 | git add e2e/push_test.go | |
| 2297 | git -c commit.gpgsign=true commit -m "e2e: push delivery and device reaping | |
| 2298 | ||
| 2299 | A fake APNs over HTTP/1.1; the real transport is h2 by ALPN, which is | |
| 2300 | stdlib behaviour and not ours to test. | |
| 2301 | ||
| 2302 | Ref #89" | |
| 2303 | ``` | |
| 2304 | ||
| 2305 | --- | |
| 2306 | ||
| 2307 | ### Task 12: Documentation | |
| 2308 | ||
| 2309 | **Files:** | |
| 2310 | - Modify: `.gitbay/wiki/Parity.md` | |
| 2311 | - Modify: `.gitbay/wiki/Admin.md` | |
| 2312 | - Modify: `.gitbay/wiki/Users.md` | |
| 2313 | - Modify: `CHANGELOG.org` | |
| 2314 | ||
| 2315 | **Interfaces:** | |
| 2316 | - Consumes: everything above. | |
| 2317 | - Produces: nothing. | |
| 2318 | ||
| 2319 | - [ ] **Step 1: Parity** | |
| 2320 | ||
| 2321 | Add a row per new command — `notifications device add`, `device list`, | |
| 2322 | `device remove`, `settings push` — with its SSH/CLI/web/API columns. | |
| 2323 | `device add` is CLI only for now on the web column, because a browser | |
| 2324 | cannot produce an APNs token; write it as "CLI only, for now", which is | |
| 2325 | the page's current wording, not "no". | |
| 2326 | ||
| 2327 | - [ ] **Step 2: Admin** | |
| 2328 | ||
| 2329 | Document the `[push]` section: every key, how to obtain a `.p8` from the | |
| 2330 | developer portal, where the file goes (`/etc/gitbay/apns.p8`, mode 0600, | |
| 2331 | owned by the account gitbayd runs as), and the constraint that an APNs | |
| 2332 | key belongs to a bundle ID — a self-hoster pushes to their own build | |
| 2333 | under their own `topic`, not to the App Store app. | |
| 2334 | ||
| 2335 | - [ ] **Step 3: Users** | |
| 2336 | ||
| 2337 | Document `notifications settings push on|off` and what a device row is: | |
| 2338 | registered by the app, listed and removable from the CLI and the web, | |
| 2339 | and dropped automatically when Apple says the token is dead. | |
| 2340 | ||
| 2341 | State plainly that notification text is sent in full, private | |
| 2342 | repositories included, so a repository name and item number reach Apple | |
| 2343 | and appear on a lock screen. | |
| 2344 | ||
| 2345 | - [ ] **Step 4: CHANGELOG** | |
| 2346 | ||
| 2347 | Add the feature under the unreleased heading in `CHANGELOG.org`, in the | |
| 2348 | style of the entries already there. | |
| 2349 | ||
| 2350 | - [ ] **Step 5: Commit** | |
| 2351 | ||
| 2352 | ```bash | |
| 2353 | git add .gitbay/wiki/ CHANGELOG.org | |
| 2354 | git -c commit.gpgsign=true commit -m "docs: push notifications | |
| 2355 | ||
| 2356 | Closes #89" | |
| 2357 | ``` | |
| 2358 | ||
| 2359 | --- | |
| 2360 | ||
| 2361 | ### Task 13: Open the merge request | |
| 2362 | ||
| 2363 | - [ ] **Step 1: Verify the branch** | |
| 2364 | ||
| 2365 | Run: `go build ./... && go vet ./... && go test ./internal/... ./cmd/...` | |
| 2366 | Expected: all PASS. Do not claim the branch is green without this output | |
| 2367 | in front of you. | |
| 2368 | ||
| 2369 | - [ ] **Step 2: Push and open the MR** | |
| 2370 | ||
| 2371 | ```bash | |
| 2372 | git push -u origin ios-push | |
| 2373 | ``` | |
| 2374 | ||
| 2375 | ```bash | |
| 2376 | gitbay mr create --source ios-push --target main --title "iOS push notifications (server)" | |
| 2377 | ``` | |
| 2378 | ||
| 2379 | Body via `--file -` from a file, not a heredoc. It states what landed and | |
| 2380 | references `Closes #89`. No attribution to any assistant or model. | |
| 2381 | ||
| 2382 | - [ ] **Step 3: Let CI run** | |
| 2383 | ||
| 2384 | The e2e suite runs on bay1. Watch it with `gitbay build list` and | |
| 2385 | `gitbay build log <n>`. Do not poll with several ssh calls per tick — | |
| 2386 | the auth limiter reads a burst as an attack. | |
| 2387 | ||
| 2388 | - [ ] **Step 4: Merge** | |
| 2389 | ||
| 2390 | Only with the full suite green: | |
| 2391 | ||
| 2392 | ```bash | |
| 2393 | gitbay mr merge <n> --strategy ff | |
| 2394 | ``` | |
| 2395 | ||
| 2396 | Signed commits are required, so `squash` and `merge` are refused — both | |
| 2397 | would mint an unsigned commit. If the merge reports the branch is | |
| 2398 | behind, rebase onto `main`, re-push, merge again. Then delete the branch | |
| 2399 | locally and remotely. | |
| 2400 | ||
| 2401 | **Do not deploy.** `[push]` stays `enabled = false` on bay1 until the app | |
| 2402 | is submitted; there is nothing to deliver to until a device registers. | |
| 2403 | ||
| 2404 | --- | |
| 2405 | ||
| 2406 | ## Notes for whoever executes this | |
| 2407 | ||
| 2408 | - **Tasks 1-9 are the working feature.** Task 10 (web) and Task 12 (docs) | |
| 2409 | can be reordered or split into a follow-up MR if the branch is getting | |
| 2410 | long, but Task 11's e2e should land with the code it tests. | |
| 2411 | - **The classifier may refuse some of this.** Editing files under | |
| 2412 | `internal/policy/` or anything that reads as relaxing an access-control | |
| 2413 | flag has been refused before in auto mode. Nothing in this plan should | |
| 2414 | trip it, but if a refusal happens, retry as a single-file edit with no | |
| 2415 | chained build rather than treating it as a puzzle. | |
| 2416 | - **The `krz/gitbay-ios` half is a separate plan** on that repository, | |
| 2417 | written once this has shipped. The spec's "The app" section is the | |
| 2418 | contract it has to meet — payload keys `aps.alert.title`, | |
| 2419 | `aps.alert.body`, `aps.thread-id` and top-level `path`, and the | |
| 2420 | `notifications device add|remove` calls. | |
docs/specs/2026-09-20-ios-push-notifications-design.md added +363
| @@ -0,0 +1,363 @@ | ||
| 1 | # iOS push notifications | |
| 2 | ||
| 3 | Closes #89. gitbayd delivers activity to registered Apple devices over | |
| 4 | APNs, as a third route beside the inbox row and the activity mail that | |
| 5 | `notify()` already sends. | |
| 6 | ||
| 7 | ## Problem | |
| 8 | ||
| 9 | `krz/gitbay-ios` is a reading and reviewing surface for the times its | |
| 10 | user is not at a keyboard, and it has no way to say anything happened. | |
| 11 | `DESIGN.org` records the gap as "No push notifications — poll on | |
| 12 | foreground, use background refresh; not planned, propose if the app | |
| 13 | makes the case". This is that proposal. | |
| 14 | ||
| 15 | Background refresh does not close the gap. `BGAppRefreshTask` is | |
| 16 | opportunistic: the system runs it when it feels like it, routinely | |
| 17 | fifteen minutes to hours after the event, and it cannot be relied on to | |
| 18 | badge. A failed build is exactly the notice that is worthless late. | |
| 19 | ||
| 20 | ## Decision | |
| 21 | ||
| 22 | gitbayd speaks APNs directly, over HTTP/2, authenticated by a JWT it | |
| 23 | signs with an operator-supplied `.p8` key. Notices become queue rows and | |
| 24 | a drainer sends them with the same bounded-retry discipline the mail | |
| 25 | queue and the webhook deliverer already use. | |
| 26 | ||
| 27 | Decisions taken on the way, with the alternatives rejected: | |
| 28 | ||
| 29 | - **gitbay.org only.** An APNs key belongs to a bundle ID, and only the | |
| 30 | author of `org.gitbay.gitbay` holds one. A self-hoster gets push by | |
| 31 | shipping their own build under their own bundle ID and pointing | |
| 32 | `[push]` at their own key; the App Store build talks to gitbay.org. | |
| 33 | The config is written so that already works — nothing in the server | |
| 34 | hardcodes an instance. | |
| 35 | - **No relay.** A service this instance operates, holding the key and | |
| 36 | accepting pushes from other instances, was the only way to give | |
| 37 | strangers push with the App Store build. At one-instance scope it is | |
| 38 | a second deployment to run and back up, and it would put other | |
| 39 | people's notification text through this server for no benefit anyone | |
| 40 | asked for. Declined. If self-hosters ever ask, the queue row is | |
| 41 | already the right unit to hand to one. | |
| 42 | - **No new Go dependency.** APNs requires HTTP/2, and stdlib | |
| 43 | `net/http` negotiates h2 over ALPN. Token auth is an ES256 JWT over a | |
| 44 | fixed two-field header and three-field claim set — `crypto/ecdsa` | |
| 45 | and `encoding/json`, no JWT library. A provider token is valid an | |
| 46 | hour and must not be reminted faster than once per twenty minutes, | |
| 47 | so it is cached and refreshed at fifty. | |
| 48 | - **Full text in the payload, private repositories included.** The | |
| 49 | alternative sends "new activity on krz/gitbay" and has the app fetch | |
| 50 | the detail, which needs a Notification Service Extension holding a | |
| 51 | bearer token in a shared keychain group. That is real complexity to | |
| 52 | keep a repository name off a lock screen on a single-user instance. | |
| 53 | The trade is recorded here rather than made configurable: a private | |
| 54 | repository's name, item number and summary reach Apple and appear on | |
| 55 | the lock screen. Revisit if the instance stops having one human user. | |
| 56 | - **Device rows, not user rows.** A token identifies an install, and | |
| 57 | one account signs in from a phone and an iPad. Registration is | |
| 58 | per-device, keyed on the token. | |
| 59 | - **Reaped by APNs, not by a job.** A `410 Unregistered` response, and | |
| 60 | a `400` whose reason is `BadDeviceToken`, delete the device row. No | |
| 61 | expiry sweep; Apple is authoritative about which tokens are live. | |
| 62 | - **`issue assign` starts filing a notice.** #89 names assignments and | |
| 63 | assignment is not among the sixteen `notify()` call sites today — the | |
| 64 | dashboard surfaces assigned work and nothing announces it. Fixed | |
| 65 | here, because a push feature that is silent on the thing the issue | |
| 66 | asked for is not the feature. It lands as its own commit and is | |
| 67 | worth having with or without push. | |
| 68 | ||
| 69 | ## Data | |
| 70 | ||
| 71 | Migration 0059. Two tables and one column. | |
| 72 | ||
| 73 | ```sql | |
| 74 | CREATE TABLE push_devices ( | |
| 75 | id INTEGER PRIMARY KEY, | |
| 76 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 77 | token TEXT NOT NULL UNIQUE, | |
| 78 | label TEXT NOT NULL DEFAULT '', | |
| 79 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 80 | last_seen_at TEXT | |
| 81 | ); | |
| 82 | CREATE INDEX push_devices_user ON push_devices(user_id); | |
| 83 | ||
| 84 | CREATE TABLE push_queue ( | |
| 85 | id INTEGER PRIMARY KEY, | |
| 86 | device_id INTEGER NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE, | |
| 87 | title TEXT NOT NULL, | |
| 88 | body TEXT NOT NULL, | |
| 89 | path TEXT NOT NULL, | |
| 90 | attempts INTEGER NOT NULL DEFAULT 0, | |
| 91 | next_attempt_at TEXT, | |
| 92 | sent_at TEXT, | |
| 93 | failed_at TEXT, | |
| 94 | last_error TEXT, | |
| 95 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 96 | ); | |
| 97 | CREATE INDEX push_queue_due ON push_queue(next_attempt_at) | |
| 98 | WHERE sent_at IS NULL AND failed_at IS NULL; | |
| 99 | ||
| 100 | ALTER TABLE users ADD COLUMN notify_push INTEGER NOT NULL DEFAULT 1; | |
| 101 | ``` | |
| 102 | ||
| 103 | The mail queue's table is named `notifications`, so the push queue | |
| 104 | cannot be. `push_queue` mirrors its columns exactly, which is what lets | |
| 105 | the drainer be a copy of the mailer's loop rather than a new design. | |
| 106 | ||
| 107 | `notify_push` defaults to 1 and costs nothing when the account has no | |
| 108 | devices: an account that never registers one is unaffected by the | |
| 109 | column. It exists so a user with two devices can silence both without | |
| 110 | deregistering each. | |
| 111 | ||
| 112 | Rows are stored per device rather than per notice, so a retry to one | |
| 113 | device does not resend to the other. A notice reaching a user with two | |
| 114 | devices writes two rows. | |
| 115 | ||
| 116 | Retention: `push_queue` joins the `[retention]` sweep beside the mail | |
| 117 | queue, as a new `push` key on `config.Retention` and a corresponding | |
| 118 | sweep in `internal/store/retention.go`. | |
| 119 | ||
| 120 | ## Config | |
| 121 | ||
| 122 | ```toml | |
| 123 | [push] | |
| 124 | enabled = true | |
| 125 | key_file = "/etc/gitbay/apns.p8" | |
| 126 | key_id = "ABC123DEFG" | |
| 127 | team_id = "ZCNAX3VL9D" | |
| 128 | topic = "org.gitbay.gitbay" | |
| 129 | environment = "production" # or "sandbox" | |
| 130 | ``` | |
| 131 | ||
| 132 | `environment` picks the host: `api.push.apple.com` or | |
| 133 | `api.sandbox.push.apple.com`. It is a named mode rather than a raw URL | |
| 134 | so a typo cannot aim the key at a host that is not Apple's. | |
| 135 | ||
| 136 | Validation at load, in the manner of `max_snippets_per_user`'s negative | |
| 137 | check (#214): with `enabled = true`, the four string fields must be | |
| 138 | non-empty, `environment` must be one of the two names, and `key_file` | |
| 139 | must exist and parse as an EC private key. A misconfigured `[push]` | |
| 140 | refuses to start rather than failing silently at the first notice — | |
| 141 | the failure mode otherwise is a queue that fills and dead-letters with | |
| 142 | nobody watching. | |
| 143 | ||
| 144 | The `.p8` is read at startup and referenced by path, as `host_keys` and | |
| 145 | the TLS `key_file` are. It is never in the repository, never in argv, | |
| 146 | never logged. File mode 0600, owned by the account gitbayd runs as. | |
| 147 | ||
| 148 | ## Commands | |
| 149 | ||
| 150 | The capability lands in the registry; the surfaces render it. | |
| 151 | ||
| 152 | | Command | Notes | | |
| 153 | |---|---| | |
| 154 | | `notifications device add` | `--label <name>`, token on stdin. `ReadsStdin: true`. | | |
| 155 | | `notifications device list` | `ReadOnly`. Token shown truncated, never in full. | | |
| 156 | | `notifications device remove <id>` | Own devices only. | | |
| 157 | | `notifications settings push on\|off` | Joins `settings mail` and `settings watch`. | | |
| 158 | ||
| 159 | A device token is an address, not a credential, but it is | |
| 160 | device-identifying and long enough to be awkward in argv. Taking it on | |
| 161 | stdin costs nothing and keeps it out of `/proc`; `ReadsStdin: true` is | |
| 162 | mandatory or `control.go` swaps in an empty reader and the command | |
| 163 | stores an empty string without erroring. | |
| 164 | ||
| 165 | `notifications settings show` and `emitNotificationSettings` grow a | |
| 166 | third key, `push`, beside `mail` and `watch`. The map is the JSON | |
| 167 | contract, so this is additive. | |
| 168 | ||
| 169 | `device add` on a token that already exists updates the label and the | |
| 170 | owner rather than erroring: a reinstall hands the same token to a | |
| 171 | different account, and Apple reuses tokens. | |
| 172 | ||
| 173 | Every command runs on every surface, per #234. The app registers over | |
| 174 | the JSON API with its bearer token, which is the whole point. | |
| 175 | ||
| 176 | ## Delivery | |
| 177 | ||
| 178 | `notify()` in `internal/control/notifications.go` gains a third branch | |
| 179 | in the loop it already runs per recipient: | |
| 180 | ||
| 181 | ```go | |
| 182 | c.Store.AddNotice(id, n.repo.ID, n.kind, c.User.Username, n.action, n.path) | |
| 183 | // mail, as today | |
| 184 | c.Store.EnqueuePush(id, pushTitle(n), pushBody(n), n.path) | |
| 185 | ``` | |
| 186 | ||
| 187 | `EnqueuePush` writes one row per registered device, and writes nothing | |
| 188 | when the account has `notify_push` off or no devices — the same shape as | |
| 189 | `ActivityMailAddress` returning "" when `notify_mail` is off. Mute, | |
| 190 | watch and actor-exclusion are already settled by `NotifyRecipients` | |
| 191 | before this point, so push inherits them for free and cannot drift from | |
| 192 | what the inbox shows. | |
| 193 | ||
| 194 | `internal/push` is a `Deliverer` in the mould of `internal/notify`'s | |
| 195 | `Mailer`: a two-second ticker, `DuePush(20)`, send, `MarkPushSent` or | |
| 196 | `MarkPushFailed` with `RetryBase << (attempt-1)` and dead-lettering at | |
| 197 | `DefaultMaxAttempts`. gitbayd starts it beside the mailer at | |
| 198 | `cmd/gitbayd/main.go:177`, under the same context, when | |
| 199 | `cfg.Push.Enabled`. | |
| 200 | ||
| 201 | The payload: | |
| 202 | ||
| 203 | ```json | |
| 204 | { | |
| 205 | "aps": { | |
| 206 | "alert": {"title": "krz/gitbay", "body": "cmc opened issue #12"}, | |
| 207 | "sound": "default", | |
| 208 | "thread-id": "krz/gitbay" | |
| 209 | }, | |
| 210 | "path": "krz/gitbay/issues/12" | |
| 211 | } | |
| 212 | ``` | |
| 213 | ||
| 214 | `title` is the repository path, `body` the inbox summary — the same | |
| 215 | string the inbox row carries, so the two surfaces cannot disagree. | |
| 216 | `thread-id` groups a repository's notices in Notification Center. | |
| 217 | `path` is the inbox row's `path` field, which the app already knows how | |
| 218 | to turn into a link. | |
| 219 | ||
| 220 | `apns-push-type: alert`, `apns-topic` from config, and | |
| 221 | `apns-collapse-id` unset — collapsing is wrong here, two comments are | |
| 222 | two notices. | |
| 223 | ||
| 224 | Response handling: `200` marks sent; `410`, and `400` with reason | |
| 225 | `BadDeviceToken`, delete the device row and its queued rows; `429` and | |
| 226 | `5xx` retry with backoff, honouring `Retry-After` when present; other | |
| 227 | `4xx` dead-letter with the reason recorded, since retrying a rejected | |
| 228 | payload will not fix it. | |
| 229 | ||
| 230 | `internal/notify`'s `redactAddresses` has no analogue to write — a | |
| 231 | device token is not a mail address — but the token is never logged | |
| 232 | either. Log lines name the device id. | |
| 233 | ||
| 234 | ## Assignment notices | |
| 235 | ||
| 236 | `runIssueAssign` (`internal/control/issue.go:423`) files a notice for | |
| 237 | each account newly added. The add loop already resolves each name to a | |
| 238 | `store.User`; it collects their ids into `added`, and after both loops | |
| 239 | succeed: | |
| 240 | ||
| 241 | ```go | |
| 242 | notify(c, added, notice{repo: repo, kind: "issue", direct: true, | |
| 243 | subject: fmt.Sprintf("[%s] #%d: %s", repo.Path(), issue.Number, issue.Title), | |
| 244 | action: fmt.Sprintf("assigned you to #%d", issue.Number), | |
| 245 | path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) | |
| 246 | ``` | |
| 247 | ||
| 248 | `direct: true`, as mentions are: an assignment is addressed to someone, | |
| 249 | and widening it to watchers would report "assigned you" to people it did | |
| 250 | not assign. Removals file nothing. `notify()` already drops the actor, | |
| 251 | so assigning yourself is silent. | |
| 252 | ||
| 253 | There is no `mr assign` command; `issue assign` is the only site. | |
| 254 | ||
| 255 | ## Web | |
| 256 | ||
| 257 | `/settings/notifications` grows a push row beside mail and watch, and a | |
| 258 | device list with a remove button per row, dispatching the same commands | |
| 259 | through `runControlStdin`. | |
| 260 | ||
| 261 | There is no web form to add a device — a browser cannot produce an APNs | |
| 262 | token. `notifications device add` is therefore reachable on the web in | |
| 263 | the sense that every command is, but no page offers it, which is the | |
| 264 | Parity page's "CLI only, for now" made literal rather than a refusal. | |
| 265 | ||
| 266 | A new template means a row in `TestMainWidthClass` | |
| 267 | (`internal/web/web_test.go`) or CI fails on it. | |
| 268 | ||
| 269 | ## The app | |
| 270 | ||
| 271 | Separate merge request on `krz/gitbay-ios`, after the server ships. | |
| 272 | `gitbay-ios` has no push scaffolding today: no app delegate adaptor, no | |
| 273 | `UNUserNotificationCenter` use, no background modes. | |
| 274 | ||
| 275 | - `UIApplicationDelegateAdaptor` for | |
| 276 | `didRegisterForRemoteNotificationsWithDeviceToken`, which is the only | |
| 277 | way to get the token. | |
| 278 | - Permission requested on first visit to the notifications screen, not | |
| 279 | at launch. A prompt before the user has seen what the app does is the | |
| 280 | prompt they deny. | |
| 281 | - On the token arriving, and on each sign-in, `notifications device | |
| 282 | add` with a label from `UIDevice.current.name`. On sign-out, | |
| 283 | `notifications device remove`. | |
| 284 | - `userNotificationCenter(_:didReceive:)` reads `path` and routes | |
| 285 | through the navigation the inbox rows already use. | |
| 286 | - Badge from the unread count the dashboard already returns. | |
| 287 | ||
| 288 | Because an account is an instance plus a user, a device registers once | |
| 289 | per signed-in account and holds one row per account it is signed in to. | |
| 290 | A token registered against two instances gets two pushes, which is | |
| 291 | correct — they are two accounts. | |
| 292 | ||
| 293 | Ships with the Push Notifications capability on `org.gitbay.gitbay` | |
| 294 | (team ZCNAX3VL9D), a privacy nutrition label declaring the device token | |
| 295 | under Identifiers, and a resubmission. | |
| 296 | ||
| 297 | `DESIGN.org`'s "No push notifications" gap row is rewritten to point at | |
| 298 | the implemented feature. | |
| 299 | ||
| 300 | ## Sequencing | |
| 301 | ||
| 302 | Server first, app second, in separate merge requests on separate | |
| 303 | repositories. The server half is self-contained and testable against a | |
| 304 | fake APNs endpoint, which keeps an App Store review off the critical | |
| 305 | path. Between the two, `[push]` is configured and inert — nothing has | |
| 306 | registered a device, so nothing queues. | |
| 307 | ||
| 308 | `[push]` stays `enabled = false` on bay1 until the app is submitted. | |
| 309 | ||
| 310 | The implementation plan that follows this spec covers the server half | |
| 311 | only. The app half is scoped here to fix the contract it has to meet — | |
| 312 | the payload keys, the registration calls, the capability and the | |
| 313 | nutrition label — and gets its own plan on `krz/gitbay-ios` once the | |
| 314 | server has shipped. | |
| 315 | ||
| 316 | ## Testing | |
| 317 | ||
| 318 | Unit, `internal/push`: | |
| 319 | ||
| 320 | - The JWT signs, carries `alg: ES256` and the key id in its header, | |
| 321 | `iss` (team id) and `iat` in its claims, and verifies against the | |
| 322 | public half of a generated test key. | |
| 323 | - The cached token is reused inside fifty minutes and reminted after. | |
| 324 | - Response mapping: 200 sent, 410 and BadDeviceToken reap, 429 and 503 | |
| 325 | retry, 403 dead-letters. | |
| 326 | ||
| 327 | Unit, `internal/store`: `EnqueuePush` writes one row per device, none | |
| 328 | when `notify_push` is off, none when the account has no devices. | |
| 329 | ||
| 330 | Unit, `internal/config`: each malformed `[push]` is refused at load. | |
| 331 | ||
| 332 | `TestStdinCommandsReadStdin` covers `device add` once it is registered | |
| 333 | with `ReadsStdin`; `TestReadOnlyCommandsWriteNothing` covers | |
| 334 | `device list`. Both are existing registry tests that pick up the new | |
| 335 | commands without being edited — the `cmd/gitbay/main.go` `pass()` table | |
| 336 | does need the new commands or its coverage test fails. | |
| 337 | ||
| 338 | E2E, `e2e/push_test.go`: an httptest server standing in for APNs, its | |
| 339 | host injected through `GITBAY_APNS_HOST`, following the | |
| 340 | `GITBAY_SWEEP_TICK` precedent. An env var rather than a config key, so | |
| 341 | `environment` stays a two-name mode that an operator cannot point at a | |
| 342 | host that is not Apple's. Register a device, act as another user on | |
| 343 | a watched repository, assert the queue drains and the fake received a | |
| 344 | payload whose body matches the inbox row's summary. Then a 410 and | |
| 345 | assert the device row is gone. The fake speaks HTTP/1.1 — the real | |
| 346 | transport is h2 by ALPN, which is stdlib behaviour and not this | |
| 347 | repository's to test. | |
| 348 | ||
| 349 | E2E, `e2e/assign_test.go` or the existing issue test: assigning files an | |
| 350 | inbox row for the assignee and none for the actor. | |
| 351 | ||
| 352 | Verified already: outbound HTTP/2 from bay1 to `api.push.apple.com:443` | |
| 353 | reaches Apple — a GET to `/3/device/test` answers `405` over h2. | |
| 354 | ||
| 355 | ## Docs | |
| 356 | ||
| 357 | - Wiki `Parity`: rows for the four commands, in the merge request that | |
| 358 | adds them. | |
| 359 | - Wiki `Admin`: the `[push]` section, obtaining a `.p8`, and the | |
| 360 | one-instance-one-bundle-ID constraint for self-hosters. | |
| 361 | - Wiki `Users`: `notifications settings push`, and what a device row is. | |
| 362 | - `CHANGELOG.org`. | |
| 363 | - `krz/gitbay-ios` `DESIGN.org`: the gap row. | |
e2e/push_test.go added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "crypto/ecdsa" | |
| 5 | "crypto/elliptic" | |
| 6 | "crypto/rand" | |
| 7 | "crypto/x509" | |
| 8 | "encoding/json" | |
| 9 | "encoding/pem" | |
| 10 | "io" | |
| 11 | "net/http" | |
| 12 | "net/http/httptest" | |
| 13 | "os" | |
| 14 | "path/filepath" | |
| 15 | "strings" | |
| 16 | "sync" | |
| 17 | "testing" | |
| 18 | ) | |
| 19 | ||
| 20 | // writeTestAPNSKey writes a P-256 PKCS#8 key PEM, as config validation | |
| 21 | // expects for [push] key_file. Modelled on writeP8 in | |
| 22 | // internal/config/config_test.go, which is in a different package and so | |
| 23 | // cannot be called directly. | |
| 24 | func writeTestAPNSKey(t *testing.T) string { | |
| 25 | t.Helper() | |
| 26 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 27 | if err != nil { | |
| 28 | t.Fatal(err) | |
| 29 | } | |
| 30 | der, err := x509.MarshalPKCS8PrivateKey(key) | |
| 31 | if err != nil { | |
| 32 | t.Fatal(err) | |
| 33 | } | |
| 34 | p := filepath.Join(t.TempDir(), "apns.p8") | |
| 35 | f, err := os.Create(p) | |
| 36 | if err != nil { | |
| 37 | t.Fatal(err) | |
| 38 | } | |
| 39 | defer f.Close() | |
| 40 | if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: der}); err != nil { | |
| 41 | t.Fatal(err) | |
| 42 | } | |
| 43 | return p | |
| 44 | } | |
| 45 | ||
| 46 | // A push reaches a registered device with the same words the inbox row | |
| 47 | // carries, and a token Apple has retired takes its device with it. | |
| 48 | func TestPush(t *testing.T) { | |
| 49 | var mu sync.Mutex | |
| 50 | var got []map[string]any | |
| 51 | var gone bool | |
| 52 | ||
| 53 | apns := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 54 | raw, _ := io.ReadAll(r.Body) | |
| 55 | var payload map[string]any | |
| 56 | json.Unmarshal(raw, &payload) | |
| 57 | mu.Lock() | |
| 58 | defer mu.Unlock() | |
| 59 | if gone { | |
| 60 | w.WriteHeader(410) | |
| 61 | io.WriteString(w, `{"reason":"Unregistered"}`) | |
| 62 | return | |
| 63 | } | |
| 64 | got = append(got, payload) | |
| 65 | w.WriteHeader(200) | |
| 66 | })) | |
| 67 | defer apns.Close() | |
| 68 | ||
| 69 | keyPath := writeTestAPNSKey(t) | |
| 70 | t.Setenv("GITBAY_APNS_HOST", strings.TrimPrefix(apns.URL, "http://")) | |
| 71 | inst := startInstanceWith(t, `[push] | |
| 72 | enabled = true | |
| 73 | key_file = "`+keyPath+`" | |
| 74 | key_id = "KEYID" | |
| 75 | team_id = "TEAMID" | |
| 76 | topic = "org.gitbay.gitbay" | |
| 77 | environment = "production" | |
| 78 | `) | |
| 79 | ||
| 80 | aliceKey := inst.newKey(t, "alice") | |
| 81 | bobKey := inst.newKey(t, "bob") | |
| 82 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 83 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 84 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 85 | t.Fatalf("repo create: %s", errOut) | |
| 86 | } | |
| 87 | ||
| 88 | // Bob watches alice's repository and registers a device. | |
| 89 | if out, errOut, code := inst.ssh(t, bobKey, "", "repo", "watch", "alice/app"); code != 0 { | |
| 90 | t.Fatalf("watch: %s%s", out, errOut) | |
| 91 | } | |
| 92 | if out, errOut, code := inst.ssh(t, bobKey, "DEVTOKEN\n", "notifications", "device", "add", "--label", "iphone"); code != 0 { | |
| 93 | t.Fatalf("device add: %s%s", out, errOut) | |
| 94 | } | |
| 95 | if out, _, _ := inst.ssh(t, bobKey, "", "notifications", "device", "list", "--json"); !strings.Contains(out, `"label":"iphone"`) { | |
| 96 | t.Fatalf("device not listed:\n%s", out) | |
| 97 | } else if strings.Contains(out, "DEVTOKEN") { | |
| 98 | t.Fatalf("device list printed the token in full:\n%s", out) | |
| 99 | } | |
| 100 | ||
| 101 | // Alice opens an issue. Bob hears about it. | |
| 102 | // The server tokenizer splits the ssh command string on whitespace, so | |
| 103 | // a multi-word flag value needs its own quoting (internal/protocol.Tokenize). | |
| 104 | if out, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'a bug'", "--body", "x"); code != 0 { | |
| 105 | t.Fatalf("issue create: %s%s", out, errOut) | |
| 106 | } | |
| 107 | ||
| 108 | waitFor(t, "a push to arrive", func() bool { | |
| 109 | mu.Lock() | |
| 110 | defer mu.Unlock() | |
| 111 | return len(got) == 1 | |
| 112 | }) | |
| 113 | ||
| 114 | mu.Lock() | |
| 115 | aps := got[0]["aps"].(map[string]any) | |
| 116 | alert := aps["alert"].(map[string]any) | |
| 117 | mu.Unlock() | |
| 118 | if alert["title"] != "alice/app" { | |
| 119 | t.Fatalf("title = %v", alert["title"]) | |
| 120 | } | |
| 121 | // The same words the inbox row carries. | |
| 122 | if body, _ := alert["body"].(string); !strings.Contains(body, "opened issue #1") { | |
| 123 | t.Fatalf("body = %q", body) | |
| 124 | } | |
| 125 | if got[0]["path"] != "alice/app/issues/1" { | |
| 126 | t.Fatalf("path = %v", got[0]["path"]) | |
| 127 | } | |
| 128 | ||
| 129 | // Apple retires the token. The next push reaps the device. | |
| 130 | mu.Lock() | |
| 131 | gone = true | |
| 132 | mu.Unlock() | |
| 133 | if out, errOut, code := inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "1", "--message", "'ping'"); code != 0 { | |
| 134 | t.Fatalf("issue comment: %s%s", out, errOut) | |
| 135 | } | |
| 136 | waitFor(t, "the device to be reaped after a 410", func() bool { | |
| 137 | // This is an absence check, unlike every other waitFor in the | |
| 138 | // suite: a transient ssh failure returns empty stdout, which | |
| 139 | // would otherwise read as a false "reaped". The exit code rules | |
| 140 | // that out. | |
| 141 | out, _, code := inst.ssh(t, bobKey, "", "notifications", "device", "list", "--json") | |
| 142 | return code == 0 && !strings.Contains(out, "iphone") | |
| 143 | }) | |
| 144 | ||
| 145 | // The inbox is untouched by any of it: push is a side channel. | |
| 146 | if out, _, _ := inst.ssh(t, bobKey, "", "notifications", "list", "--json"); !strings.Contains(out, "opened issue #1") { | |
| 147 | t.Fatalf("inbox missing the notice:\n%s", out) | |
| 148 | } | |
| 149 | } | |
e2e/readonly_test.go +1
| @@ -155,6 +155,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | ||
| 155 | 155 | "snippet file get": {snippetID, "a.txt"}, |
| 156 | 156 | "notifications list": nil, |
| 157 | 157 | "notifications settings show": nil, |
| 158 | "notifications device list": nil, | |
| 158 | 159 | "repo bookmarks": nil, |
| 159 | 160 | "search": {"app"}, |
| 160 | 161 | "mr revisions": {"alice/app", "1"}, |
internal/config/config.go +81 −3
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | package config |
| 3 | 3 | |
| 4 | 4 | import ( |
| 5 | "crypto/ecdsa" | |
| 6 | "crypto/x509" | |
| 7 | "encoding/pem" | |
| 5 | 8 | "errors" |
| 6 | 9 | "fmt" |
| 7 | 10 | "net" |
| @@ -35,6 +38,7 @@ type Config struct { | ||
| 35 | 38 | Mirrors Mirrors `toml:"mirrors"` |
| 36 | 39 | Deps Deps `toml:"deps"` |
| 37 | 40 | Retention Retention `toml:"retention"` |
| 41 | Push Push `toml:"push"` | |
| 38 | 42 | // GoImport maps vanity Go module paths to repositories, e.g. |
| 39 | 43 | // "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1 |
| 40 | 44 | // under a mapped path get a go-import meta tag. |
| @@ -125,10 +129,12 @@ type Retention struct { | ||
| 125 | 129 | WebhookDeliveries string `toml:"webhook_deliveries"` |
| 126 | 130 | // Mail is the outbound queue: rows already sent or given up on. |
| 127 | 131 | Mail string `toml:"mail"` |
| 132 | // Push is the outbound device queue: rows already sent or given up on. | |
| 133 | Push string `toml:"push"` | |
| 128 | 134 | } |
| 129 | 135 | |
| 130 | // Durations parses the four, mapping each to zero when unset or bad. | |
| 131 | func (r Retention) Durations() (audit, events, deliveries, mail time.Duration) { | |
| 136 | // Durations parses the five, mapping each to zero when unset or bad. | |
| 137 | func (r Retention) Durations() (audit, events, deliveries, mail, push time.Duration) { | |
| 132 | 138 | parse := func(s string) time.Duration { |
| 133 | 139 | d, err := time.ParseDuration(s) |
| 134 | 140 | if err != nil || d < 0 { |
| @@ -136,7 +142,7 @@ func (r Retention) Durations() (audit, events, deliveries, mail time.Duration) { | ||
| 136 | 142 | } |
| 137 | 143 | return d |
| 138 | 144 | } |
| 139 | return parse(r.Audit), parse(r.Events), parse(r.WebhookDeliveries), parse(r.Mail) | |
| 145 | return parse(r.Audit), parse(r.Events), parse(r.WebhookDeliveries), parse(r.Mail), parse(r.Push) | |
| 140 | 146 | } |
| 141 | 147 | |
| 142 | 148 | // LFS stores large-file objects content-addressed under Root (default |
| @@ -209,6 +215,58 @@ type Mail struct { | ||
| 209 | 215 | SMTPPass string `toml:"smtp_pass,omitempty"` |
| 210 | 216 | } |
| 211 | 217 | |
| 218 | // Push is APNs delivery to registered Apple devices. A key belongs to a | |
| 219 | // bundle ID, so an instance pushes to the app built under the topic named | |
| 220 | // here and no other; a self-hoster points this at their own key and their | |
| 221 | // own build. | |
| 222 | type Push struct { | |
| 223 | Enabled bool `toml:"enabled"` | |
| 224 | KeyFile string `toml:"key_file"` | |
| 225 | KeyID string `toml:"key_id"` | |
| 226 | TeamID string `toml:"team_id"` | |
| 227 | Topic string `toml:"topic"` // the app's bundle identifier | |
| 228 | // Environment is a name rather than a URL so a typo cannot aim the | |
| 229 | // key at a host that is not Apple's. | |
| 230 | Environment string `toml:"environment"` // production | sandbox | |
| 231 | } | |
| 232 | ||
| 233 | // Host is the APNs endpoint for the configured environment. | |
| 234 | // GITBAY_APNS_HOST overrides it for tests, as GITBAY_SWEEP_TICK does for | |
| 235 | // the retention sweep. | |
| 236 | func (p Push) Host() string { | |
| 237 | if h := os.Getenv("GITBAY_APNS_HOST"); h != "" { | |
| 238 | return h | |
| 239 | } | |
| 240 | if p.Environment == "sandbox" { | |
| 241 | return "api.sandbox.push.apple.com" | |
| 242 | } | |
| 243 | return "api.push.apple.com" | |
| 244 | } | |
| 245 | ||
| 246 | // LoadAPNSKey reads Apple's .p8 provider key: a PEM-wrapped PKCS#8 | |
| 247 | // P-256 private key. Read at startup and validated there, so a | |
| 248 | // misconfigured [push] refuses to start rather than filling a queue | |
| 249 | // nobody is watching. | |
| 250 | func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) { | |
| 251 | data, err := os.ReadFile(path) | |
| 252 | if err != nil { | |
| 253 | return nil, err | |
| 254 | } | |
| 255 | block, _ := pem.Decode(data) | |
| 256 | if block == nil { | |
| 257 | return nil, errors.New("not PEM") | |
| 258 | } | |
| 259 | any, err := x509.ParsePKCS8PrivateKey(block.Bytes) | |
| 260 | if err != nil { | |
| 261 | return nil, err | |
| 262 | } | |
| 263 | key, ok := any.(*ecdsa.PrivateKey) | |
| 264 | if !ok { | |
| 265 | return nil, errors.New("not an EC private key") | |
| 266 | } | |
| 267 | return key, nil | |
| 268 | } | |
| 269 | ||
| 212 | 270 | // Default returns the configuration used when a key is absent from the file. |
| 213 | 271 | func Default() Config { |
| 214 | 272 | return Config{ |
| @@ -286,6 +344,26 @@ func (c Config) Validate() error { | ||
| 286 | 344 | if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 { |
| 287 | 345 | errs = append(errs, errors.New("limits.max_repos_per_user, max_bytes_per_user and max_snippets_per_user must not be negative")) |
| 288 | 346 | } |
| 347 | if c.Push.Enabled { | |
| 348 | for _, f := range []struct{ name, val string }{ | |
| 349 | {"push.key_file", c.Push.KeyFile}, | |
| 350 | {"push.key_id", c.Push.KeyID}, | |
| 351 | {"push.team_id", c.Push.TeamID}, | |
| 352 | {"push.topic", c.Push.Topic}, | |
| 353 | } { | |
| 354 | if f.val == "" { | |
| 355 | errs = append(errs, fmt.Errorf("%s is required when push.enabled", f.name)) | |
| 356 | } | |
| 357 | } | |
| 358 | if err := oneOf("push.environment", c.Push.Environment, "production", "sandbox"); err != nil { | |
| 359 | errs = append(errs, err) | |
| 360 | } | |
| 361 | if c.Push.KeyFile != "" { | |
| 362 | if _, err := LoadAPNSKey(c.Push.KeyFile); err != nil { | |
| 363 | errs = append(errs, fmt.Errorf("push.key_file: %w", err)) | |
| 364 | } | |
| 365 | } | |
| 366 | } | |
| 289 | 367 | if c.SSH.Port < 1 || c.SSH.Port > 65535 { |
| 290 | 368 | errs = append(errs, fmt.Errorf("ssh.port %d out of range", c.SSH.Port)) |
| 291 | 369 | } |
internal/config/config_test.go +106
| @@ -1,6 +1,11 @@ | ||
| 1 | 1 | package config |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "crypto/ecdsa" | |
| 5 | "crypto/elliptic" | |
| 6 | "crypto/rand" | |
| 7 | "crypto/x509" | |
| 8 | "encoding/pem" | |
| 4 | 9 | "os" |
| 5 | 10 | "path/filepath" |
| 6 | 11 | "strings" |
| @@ -143,3 +148,104 @@ func TestValidCombinations(t *testing.T) { | ||
| 143 | 148 | }) |
| 144 | 149 | } |
| 145 | 150 | } |
| 151 | ||
| 152 | // writeP8 writes a PEM-wrapped PKCS#8 P-256 key, the shape of Apple's | |
| 153 | // .p8 provider key, and returns its path. | |
| 154 | func writeP8(t *testing.T) string { | |
| 155 | t.Helper() | |
| 156 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 157 | if err != nil { | |
| 158 | t.Fatal(err) | |
| 159 | } | |
| 160 | der, err := x509.MarshalPKCS8PrivateKey(key) | |
| 161 | if err != nil { | |
| 162 | t.Fatal(err) | |
| 163 | } | |
| 164 | p := filepath.Join(t.TempDir(), "apns.p8") | |
| 165 | f, err := os.Create(p) | |
| 166 | if err != nil { | |
| 167 | t.Fatal(err) | |
| 168 | } | |
| 169 | defer f.Close() | |
| 170 | if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: der}); err != nil { | |
| 171 | t.Fatal(err) | |
| 172 | } | |
| 173 | return p | |
| 174 | } | |
| 175 | ||
| 176 | func TestPushConfigValidation(t *testing.T) { | |
| 177 | keyPath := writeP8(t) | |
| 178 | full := ` | |
| 179 | [push] | |
| 180 | enabled = true | |
| 181 | key_file = "` + keyPath + `" | |
| 182 | key_id = "KEYID" | |
| 183 | team_id = "TEAMID" | |
| 184 | topic = "org.gitbay.gitbay" | |
| 185 | environment = "production" | |
| 186 | ` | |
| 187 | cases := []struct { | |
| 188 | name string | |
| 189 | body string | |
| 190 | want string // substring of the expected error; "" means valid | |
| 191 | }{ | |
| 192 | {"disabled needs nothing", "\n[push]\nenabled = false\n", ""}, | |
| 193 | {"complete is valid", full, ""}, | |
| 194 | {"key_id required", strings.Replace(full, `key_id = "KEYID"`, "", 1), "push.key_id"}, | |
| 195 | {"team_id required", strings.Replace(full, `team_id = "TEAMID"`, "", 1), "push.team_id"}, | |
| 196 | {"topic required", strings.Replace(full, `topic = "org.gitbay.gitbay"`, "", 1), "push.topic"}, | |
| 197 | {"environment must be a known name", | |
| 198 | strings.Replace(full, `environment = "production"`, `environment = "staging"`, 1), | |
| 199 | "push.environment"}, | |
| 200 | } | |
| 201 | for _, tc := range cases { | |
| 202 | t.Run(tc.name, func(t *testing.T) { | |
| 203 | _, err := Load(writeConfig(t, minimal+tc.body)) | |
| 204 | if tc.want == "" { | |
| 205 | if err != nil { | |
| 206 | t.Fatalf("want valid, got %v", err) | |
| 207 | } | |
| 208 | return | |
| 209 | } | |
| 210 | if err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 211 | t.Fatalf("want an error mentioning %q, got %v", tc.want, err) | |
| 212 | } | |
| 213 | }) | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | // A key_file that exists but is not a PKCS#8 EC key is refused at load, | |
| 218 | // not at the first notice: the failure mode otherwise is a queue that | |
| 219 | // fills and dead-letters with nobody watching. | |
| 220 | func TestPushConfigRejectsAnUnparseableKey(t *testing.T) { | |
| 221 | p := filepath.Join(t.TempDir(), "junk.p8") | |
| 222 | if err := os.WriteFile(p, []byte("not a key\n"), 0o600); err != nil { | |
| 223 | t.Fatal(err) | |
| 224 | } | |
| 225 | body := ` | |
| 226 | [push] | |
| 227 | enabled = true | |
| 228 | key_file = "` + p + `" | |
| 229 | key_id = "K" | |
| 230 | team_id = "T" | |
| 231 | topic = "org.gitbay.gitbay" | |
| 232 | environment = "production" | |
| 233 | ` | |
| 234 | _, err := Load(writeConfig(t, minimal+body)) | |
| 235 | if err == nil || !strings.Contains(err.Error(), "push.key_file") { | |
| 236 | t.Fatalf("want a push.key_file error, got %v", err) | |
| 237 | } | |
| 238 | } | |
| 239 | ||
| 240 | func TestPushHost(t *testing.T) { | |
| 241 | if got := (Push{Environment: "production"}).Host(); got != "api.push.apple.com" { | |
| 242 | t.Fatalf("production host = %q", got) | |
| 243 | } | |
| 244 | if got := (Push{Environment: "sandbox"}).Host(); got != "api.sandbox.push.apple.com" { | |
| 245 | t.Fatalf("sandbox host = %q", got) | |
| 246 | } | |
| 247 | t.Setenv("GITBAY_APNS_HOST", "127.0.0.1:1234") | |
| 248 | if got := (Push{Environment: "production"}).Host(); got != "127.0.0.1:1234" { | |
| 249 | t.Fatalf("GITBAY_APNS_HOST ignored: %q", got) | |
| 250 | } | |
| 251 | } | |
internal/control/dashboard.go +5
| @@ -203,6 +203,11 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 203 | 203 | for _, it := range q.Mail.Items { |
| 204 | 204 | fmt.Fprintf(w, " %s\t%s\tattempts %d\t%s\n", it.Recipient, it.Subject, it.Attempts, it.LastError) |
| 205 | 205 | } |
| 206 | // The device id, not the token: a token is never echoed. | |
| 207 | fmt.Fprintf(w, " push\tpending %d\tretrying %d\tfailed %d\n", q.Push.Pending, q.Push.Retrying, q.Push.Failed) | |
| 208 | for _, it := range q.Push.Items { | |
| 209 | fmt.Fprintf(w, " device %d\t%s\tattempts %d\t%s\n", it.DeviceID, it.Title, it.Attempts, it.LastError) | |
| 210 | } | |
| 206 | 211 | fmt.Fprintf(w, " mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors) |
| 207 | 212 | for _, it := range q.Mirrors.Items { |
| 208 | 213 | fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", it.Repo, it.Direction, it.URL, it.LastError) |
internal/control/dashboard_test.go added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | // The push queue is the one worker queue whose worst failure — a key_id | |
| 10 | // or team_id Apple did not issue, which config validation cannot check — | |
| 11 | // dead-letters every row on its first attempt with nothing but a log | |
| 12 | // line. dashboard is where an admin would see that, so it reports push | |
| 13 | // beside the other five queues. | |
| 14 | func TestDashboardReportsThePushQueue(t *testing.T) { | |
| 15 | c := notifTestCtx(t, "cmc") | |
| 16 | c.User.IsAdmin = true | |
| 17 | uid := c.User.ID | |
| 18 | if _, err := c.Store.AddPushDevice(uid, "tok-a", "iphone"); err != nil { | |
| 19 | t.Fatal(err) | |
| 20 | } | |
| 21 | if err := c.Store.EnqueuePush(uid, "krz/gitbay", "cmc opened issue #1", "krz/gitbay/issues/1"); err != nil { | |
| 22 | t.Fatal(err) | |
| 23 | } | |
| 24 | due, err := c.Store.DuePush(20) | |
| 25 | if err != nil || len(due) != 1 { | |
| 26 | t.Fatalf("DuePush: %v %+v", err, due) | |
| 27 | } | |
| 28 | if err := c.Store.MarkPushFailed(due[0].ID, "apns 403 InvalidProviderToken", nil); err != nil { | |
| 29 | t.Fatal(err) | |
| 30 | } | |
| 31 | ||
| 32 | var out bytes.Buffer | |
| 33 | c.Stdout, c.Stderr = &out, &out | |
| 34 | if code := runDashboard(c, nil); code != 0 { | |
| 35 | t.Fatalf("exit %d: %s", code, out.String()) | |
| 36 | } | |
| 37 | got := out.String() | |
| 38 | if !strings.Contains(got, "push\tpending 0\tretrying 0\tfailed 1") { | |
| 39 | t.Fatalf("no push queue row:\n%s", got) | |
| 40 | } | |
| 41 | if !strings.Contains(got, "apns 403 InvalidProviderToken") { | |
| 42 | t.Fatalf("dead-lettered row not listed:\n%s", got) | |
| 43 | } | |
| 44 | ||
| 45 | out.Reset() | |
| 46 | c.JSON = true | |
| 47 | if code := runDashboard(c, nil); code != 0 { | |
| 48 | t.Fatalf("exit %d: %s", code, out.String()) | |
| 49 | } | |
| 50 | if !strings.Contains(out.String(), `"push":{`) { | |
| 51 | t.Fatalf("no push key in the queues object:\n%s", out.String()) | |
| 52 | } | |
| 53 | } | |
internal/control/issue.go +25
| @@ -445,6 +445,16 @@ func runIssueAssign(c *Ctx, args []string) int { | ||
| 445 | 445 | } |
| 446 | 446 | return u, -1 |
| 447 | 447 | } |
| 448 | // issue is the read from before the update, so its Assignees are who | |
| 449 | // was already on it. SetIssueAssignee inserts ON CONFLICT DO NOTHING | |
| 450 | // and returns nil whether or not it inserted, and the notice below is | |
| 451 | // for accounts newly added: a client reconciling the list by | |
| 452 | // re-sending the whole set must not notify on every save. | |
| 453 | assigned := make(map[string]bool, len(issue.Assignees)) | |
| 454 | for _, name := range issue.Assignees { | |
| 455 | assigned[name] = true | |
| 456 | } | |
| 457 | var added []int64 | |
| 448 | 458 | for _, name := range adds { |
| 449 | 459 | u, code := resolve(name) |
| 450 | 460 | if code >= 0 { |
| @@ -453,6 +463,11 @@ func runIssueAssign(c *Ctx, args []string) int { | ||
| 453 | 463 | if err := c.Store.SetIssueAssignee(issue.ID, u.ID, true); err != nil { |
| 454 | 464 | return c.fail(protocol.ExitFailure, "%v", err) |
| 455 | 465 | } |
| 466 | if assigned[u.Username] { | |
| 467 | continue | |
| 468 | } | |
| 469 | assigned[u.Username] = true | |
| 470 | added = append(added, u.ID) | |
| 456 | 471 | } |
| 457 | 472 | for _, name := range removes { |
| 458 | 473 | u, code := resolve(name) |
| @@ -472,6 +487,16 @@ func runIssueAssign(c *Ctx, args []string) int { | ||
| 472 | 487 | } |
| 473 | 488 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue.assigned", |
| 474 | 489 | fmt.Sprintf(`{"number":%d,"assignees":%s}`, issue.Number, jsonStrings(updated.Assignees))) |
| 490 | if len(added) > 0 { | |
| 491 | // direct, as a mention is: an assignment is addressed to someone, | |
| 492 | // and widening it to watchers would tell them "assigned you". | |
| 493 | // Removals file nothing, and notify drops the actor, so assigning | |
| 494 | // yourself is silent. | |
| 495 | notify(c, added, notice{repo: repo, kind: "issue", direct: true, | |
| 496 | subject: issueSubject(repo, issue.Number, issue.Title), | |
| 497 | action: fmt.Sprintf("assigned you to #%d", issue.Number), | |
| 498 | path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)}) | |
| 499 | } | |
| 475 | 500 | return c.emit(map[string]any{"number": issue.Number, "assignees": updated.Assignees}, func(w io.Writer) { |
| 476 | 501 | fmt.Fprintf(w, "assignees on %s#%d: %s\n", repo.Path(), issue.Number, strings.Join(updated.Assignees, ", ")) |
| 477 | 502 | }) |
internal/control/issue_test.go added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | func TestIssueAssignNotifiesTheAssignee(t *testing.T) { | |
| 6 | c, repo, bob := testRepoWithWatcher(t) | |
| 7 | ||
| 8 | if code := runIssueAssign(c, []string{repo.Path(), "1", "--add", "bob"}); code != 0 { | |
| 9 | t.Fatalf("exit %d", code) | |
| 10 | } | |
| 11 | rows, _ := c.Store.Inbox(bob, false, 20, 0) | |
| 12 | if len(rows) != 1 || rows[0].Summary != "assigned you to #1" { | |
| 13 | t.Fatalf("got %+v", rows) | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | // The spec files a notice for each account newly added. SetIssueAssignee | |
| 18 | // inserts ON CONFLICT DO NOTHING and reports nothing either way, so a | |
| 19 | // client reconciling an assignee list by re-sending the whole set would | |
| 20 | // file, mail and push a row on every save. | |
| 21 | func TestIssueAssignDoesNotRenotifyAnExistingAssignee(t *testing.T) { | |
| 22 | c, repo, bob := testRepoWithWatcher(t) | |
| 23 | c.Store.AddPushDevice(bob, "tok-b", "iphone") | |
| 24 | ||
| 25 | if code := runIssueAssign(c, []string{repo.Path(), "1", "--add", "bob"}); code != 0 { | |
| 26 | t.Fatalf("exit %d", code) | |
| 27 | } | |
| 28 | if code := runIssueAssign(c, []string{repo.Path(), "1", "--add", "bob"}); code != 0 { | |
| 29 | t.Fatalf("exit %d", code) | |
| 30 | } | |
| 31 | ||
| 32 | rows, _ := c.Store.Inbox(bob, false, 20, 0) | |
| 33 | if len(rows) != 1 { | |
| 34 | t.Fatalf("filed %d rows for one assignment: %+v", len(rows), rows) | |
| 35 | } | |
| 36 | if due, _ := c.Store.DuePush(20); len(due) != 1 { | |
| 37 | t.Fatalf("queued %d pushes for one assignment", len(due)) | |
| 38 | } | |
| 39 | // The second call still succeeds and still reports the assignee. | |
| 40 | updated, err := c.Store.IssueByNumber(repo.ID, 1) | |
| 41 | if err != nil { | |
| 42 | t.Fatal(err) | |
| 43 | } | |
| 44 | if len(updated.Assignees) != 1 || updated.Assignees[0] != "bob" { | |
| 45 | t.Fatalf("assignees: %+v", updated.Assignees) | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | func TestIssueAssignIsSilentForTheActorAndForRemovals(t *testing.T) { | |
| 50 | c, repo, bob := testRepoWithWatcher(t) | |
| 51 | ||
| 52 | // Assigning yourself announces nothing: notify drops the actor. | |
| 53 | runIssueAssign(c, []string{repo.Path(), "1", "--add", "alice"}) | |
| 54 | if rows, _ := c.Store.Inbox(c.User.ID, false, 20, 0); len(rows) != 0 { | |
| 55 | t.Fatalf("self-assignment notified: %+v", rows) | |
| 56 | } | |
| 57 | ||
| 58 | // Unassigning files nothing. | |
| 59 | runIssueAssign(c, []string{repo.Path(), "1", "--add", "bob"}) | |
| 60 | before, _ := c.Store.Inbox(bob, false, 20, 0) | |
| 61 | runIssueAssign(c, []string{repo.Path(), "1", "--remove", "bob"}) | |
| 62 | after, _ := c.Store.Inbox(bob, false, 20, 0) | |
| 63 | if len(after) != len(before) { | |
| 64 | t.Fatalf("removal filed a row: %d then %d", len(before), len(after)) | |
| 65 | } | |
| 66 | } | |
internal/control/notifications.go +146 −2
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "errors" | |
| 4 | 5 | "fmt" |
| 5 | 6 | "io" |
| 6 | 7 | "strconv" |
| @@ -30,6 +31,22 @@ func init() { | ||
| 30 | 31 | register(Command{Path: []string{"notifications", "settings", "watch"}, |
| 31 | 32 | Summary: "every issue and merge request on repositories you can write to", |
| 32 | 33 | Usage: "notifications settings watch on|off", Run: runNotificationsSettingsWatch}) |
| 34 | register(Command{Path: []string{"notifications", "device", "add"}, | |
| 35 | Summary: "register an Apple device for push, token on stdin", | |
| 36 | Usage: "notifications device add [--label <name>] < token", | |
| 37 | // Mandatory: without it control.go swaps in an empty reader and | |
| 38 | // this command stores an empty token without erroring. | |
| 39 | ReadsStdin: true, Run: runNotificationsDeviceAdd}) | |
| 40 | register(Command{Path: []string{"notifications", "device", "list"}, | |
| 41 | Summary: "your registered devices", | |
| 42 | Usage: "notifications device list", | |
| 43 | ReadOnly: true, Run: runNotificationsDeviceList}) | |
| 44 | register(Command{Path: []string{"notifications", "device", "remove"}, | |
| 45 | Summary: "deregister a device", | |
| 46 | Usage: "notifications device remove <id>", Run: runNotificationsDeviceRemove}) | |
| 47 | register(Command{Path: []string{"notifications", "settings", "push"}, | |
| 48 | Summary: "activity on your registered devices as well as the inbox", | |
| 49 | Usage: "notifications settings push on|off", Run: runNotificationsSettingsPush}) | |
| 33 | 50 | register(Command{Path: []string{"repo", "watch"}, |
| 34 | 51 | Summary: "hear about all activity on a repository", |
| 35 | 52 | Usage: "repo watch <owner/name>", Run: runRepoWatch}) |
| @@ -70,9 +87,16 @@ func notify(c *Ctx, userIDs []int64, n notice) { | ||
| 70 | 87 | return |
| 71 | 88 | } |
| 72 | 89 | sendMail := c.Cfg.Mail.SMTPHost != "" |
| 90 | // Nothing drains push_queue unless the daemon started the deliverer, | |
| 91 | // and the retention sweep only collects rows that were sent or | |
| 92 | // dead-lettered, so a row written here would sit there forever. | |
| 93 | sendPush := c.Cfg.Push.Enabled | |
| 73 | 94 | body := noticeBody(c, n) |
| 74 | 95 | for _, id := range recipients { |
| 75 | 96 | c.Store.AddNotice(id, n.repo.ID, n.kind, c.User.Username, n.action, n.path) |
| 97 | if sendPush { | |
| 98 | c.Store.EnqueuePush(id, pushTitle(n), pushBody(c.User.Username, n), n.path) | |
| 99 | } | |
| 76 | 100 | if !sendMail { |
| 77 | 101 | continue |
| 78 | 102 | } |
| @@ -138,6 +162,14 @@ func noticeBody(c *Ctx, n notice) string { | ||
| 138 | 162 | return b.String() |
| 139 | 163 | } |
| 140 | 164 | |
| 165 | // pushTitle and pushBody are the alert's two lines. The body is built | |
| 166 | // from the same two values AddNotice files, so the alert and the inbox | |
| 167 | // row cannot disagree about what happened. The title is the repository, | |
| 168 | // which also groups a repository's notices in Notification Center. | |
| 169 | func pushTitle(n notice) string { return n.repo.Path() } | |
| 170 | ||
| 171 | func pushBody(actor string, n notice) string { return actor + " " + n.action } | |
| 172 | ||
| 141 | 173 | func issueSubject(repo store.Repo, number int64, title string) string { |
| 142 | 174 | return fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, title) |
| 143 | 175 | } |
| @@ -155,14 +187,18 @@ func emitNotificationSettings(c *Ctx) int { | ||
| 155 | 187 | if err != nil { |
| 156 | 188 | return c.fail(protocol.ExitFailure, "%v", err) |
| 157 | 189 | } |
| 158 | return c.emit(map[string]bool{"mail": mail, "watch": watch}, func(w io.Writer) { | |
| 190 | push, err := c.Store.PushEnabled(c.User.ID) | |
| 191 | if err != nil { | |
| 192 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 193 | } | |
| 194 | return c.emit(map[string]bool{"mail": mail, "watch": watch, "push": push}, func(w io.Writer) { | |
| 159 | 195 | onOff := func(on bool) string { |
| 160 | 196 | if on { |
| 161 | 197 | return "on" |
| 162 | 198 | } |
| 163 | 199 | return "off" |
| 164 | 200 | } |
| 165 | fmt.Fprintf(w, "mail: %s\nwatch: %s\n", onOff(mail), onOff(watch)) | |
| 201 | fmt.Fprintf(w, "mail: %s\nwatch: %s\npush: %s\n", onOff(mail), onOff(watch), onOff(push)) | |
| 166 | 202 | }) |
| 167 | 203 | } |
| 168 | 204 | |
| @@ -198,6 +234,114 @@ func runNotificationsSettingsWatch(c *Ctx, args []string) int { | ||
| 198 | 234 | return emitNotificationSettings(c) |
| 199 | 235 | } |
| 200 | 236 | |
| 237 | func runNotificationsSettingsPush(c *Ctx, args []string) int { | |
| 238 | if len(args) != 1 || (args[0] != "on" && args[0] != "off") { | |
| 239 | return c.usage() | |
| 240 | } | |
| 241 | if err := c.Store.SetPushEnabled(c.User.ID, args[0] == "on"); err != nil { | |
| 242 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 243 | } | |
| 244 | return emitNotificationSettings(c) | |
| 245 | } | |
| 246 | ||
| 247 | // maxDeviceTokenBytes is well past APNs' 32-byte token rendered as 64 hex | |
| 248 | // characters, and stops a stdin that is not a token from becoming a row. | |
| 249 | const maxDeviceTokenBytes = 512 | |
| 250 | ||
| 251 | func runNotificationsDeviceAdd(c *Ctx, args []string) int { | |
| 252 | f, err := parseFlags(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage}) | |
| 253 | if err != nil { | |
| 254 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 255 | } | |
| 256 | if len(f.Pos) != 0 { | |
| 257 | return c.usage() | |
| 258 | } | |
| 259 | // The registration itself would succeed and then deliver nothing, | |
| 260 | // while notifications settings show still reported push on. Say what | |
| 261 | // is actually wrong instead. | |
| 262 | if !c.Cfg.Push.Enabled { | |
| 263 | return c.fail(protocol.ExitFailure, | |
| 264 | "this instance does not send push notifications ([push] enabled = false); ask an admin") | |
| 265 | } | |
| 266 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxDeviceTokenBytes+1)) | |
| 267 | if err != nil { | |
| 268 | return c.fail(protocol.ExitFailure, "reading stdin: %v", err) | |
| 269 | } | |
| 270 | token := strings.TrimSpace(string(raw)) | |
| 271 | if token == "" { | |
| 272 | return c.usageWith("no device token on stdin") | |
| 273 | } | |
| 274 | if len(token) > maxDeviceTokenBytes { | |
| 275 | return c.fail(protocol.ExitUsage, "device token is too long") | |
| 276 | } | |
| 277 | if _, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label")); err != nil { | |
| 278 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 279 | } | |
| 280 | return c.emit(map[string]string{"status": "registered"}, func(w io.Writer) { | |
| 281 | fmt.Fprintln(w, "device registered") | |
| 282 | }) | |
| 283 | } | |
| 284 | ||
| 285 | func runNotificationsDeviceList(c *Ctx, args []string) int { | |
| 286 | if len(args) != 0 { | |
| 287 | return c.usage() | |
| 288 | } | |
| 289 | devices, err := c.Store.PushDevices(c.User.ID) | |
| 290 | if err != nil { | |
| 291 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 292 | } | |
| 293 | type row struct { | |
| 294 | ID int64 `json:"id"` | |
| 295 | Label string `json:"label"` | |
| 296 | Token string `json:"token"` // truncated; a token is not echoed in full | |
| 297 | Added string `json:"added"` | |
| 298 | } | |
| 299 | rows := make([]row, 0, len(devices)) | |
| 300 | for _, d := range devices { | |
| 301 | rows = append(rows, row{ID: d.ID, Label: d.Label, | |
| 302 | Token: ShortToken(d.Token), Added: d.CreatedAt}) | |
| 303 | } | |
| 304 | return c.emit(rows, func(w io.Writer) { | |
| 305 | for _, r := range rows { | |
| 306 | fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", r.ID, r.Label, r.Token, r.Added) | |
| 307 | } | |
| 308 | }) | |
| 309 | } | |
| 310 | ||
| 311 | // ShortToken renders a device token as its first eight characters. Enough | |
| 312 | // to tell two devices apart in a list, not enough to push to one. A real | |
| 313 | // APNs token is 64 hex characters, so anything at or under the cut length | |
| 314 | // is not a token worth showing part of — it is masked outright rather | |
| 315 | // than echoed whole, which "abc…" would imply is a truncation. | |
| 316 | // | |
| 317 | // Exported because the account page lists the same devices: one renderer, | |
| 318 | // so the two surfaces cannot come to disagree about what they print. | |
| 319 | func ShortToken(t string) string { | |
| 320 | if len(t) > 8 { | |
| 321 | return t[:8] + "…" | |
| 322 | } | |
| 323 | return "(short token)" | |
| 324 | } | |
| 325 | ||
| 326 | func runNotificationsDeviceRemove(c *Ctx, args []string) int { | |
| 327 | if len(args) != 1 { | |
| 328 | return c.usage() | |
| 329 | } | |
| 330 | id, err := strconv.ParseInt(args[0], 10, 64) | |
| 331 | if err != nil { | |
| 332 | return c.usageWith("device id must be a number") | |
| 333 | } | |
| 334 | if err := c.Store.RemovePushDevice(c.User.ID, id); err != nil { | |
| 335 | if errors.Is(err, store.ErrNotFound) { | |
| 336 | return c.fail(protocol.ExitNotFound, "no such device; notifications device list shows yours") | |
| 337 | } | |
| 338 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 339 | } | |
| 340 | return c.emit(map[string]string{"status": "removed"}, func(w io.Writer) { | |
| 341 | fmt.Fprintln(w, "device removed") | |
| 342 | }) | |
| 343 | } | |
| 344 | ||
| 201 | 345 | // noticesDefaultLimit caps a bare list; pagination reaches further back. |
| 202 | 346 | const noticesDefaultLimit = 50 |
| 203 | 347 | |
internal/control/notifications_test.go added +234
| @@ -0,0 +1,234 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/config" | |
| 9 | "gitbay.org/gitbay/internal/protocol" | |
| 10 | "gitbay.org/gitbay/internal/store" | |
| 11 | ) | |
| 12 | ||
| 13 | // notifTestCtx opens an in-memory store, migrates it, and creates one user | |
| 14 | // to act as. Modeled on the store setup in snippet_test.go; this package | |
| 15 | // has no shared testCtx helper. | |
| 16 | func notifTestCtx(t *testing.T, username string) *Ctx { | |
| 17 | t.Helper() | |
| 18 | st, err := store.Open(":memory:") | |
| 19 | if err != nil { | |
| 20 | t.Fatal(err) | |
| 21 | } | |
| 22 | t.Cleanup(func() { st.Close() }) | |
| 23 | if err := st.MigrateUp(); err != nil { | |
| 24 | t.Fatal(err) | |
| 25 | } | |
| 26 | uid, err := st.CreateUser(username, false) | |
| 27 | if err != nil { | |
| 28 | t.Fatal(err) | |
| 29 | } | |
| 30 | var out bytes.Buffer | |
| 31 | return &Ctx{ | |
| 32 | User: store.User{ID: uid, Username: username}, | |
| 33 | Scope: "full", | |
| 34 | Store: st, | |
| 35 | // Push enabled is the instance state the push tests assume; the | |
| 36 | // disabled case sets it back to false explicitly. | |
| 37 | Cfg: config.Config{Push: config.Push{Enabled: true}}, | |
| 38 | Stdin: strings.NewReader(""), | |
| 39 | Stdout: &out, | |
| 40 | Stderr: &out, | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | // testRepoWithWatcher returns a Ctx acting as alice, a repository she | |
| 45 | // owns with issue #1 open on it, and bob's user id with a watch row on | |
| 46 | // it — the shared setup for notify's recipient-widening tests. | |
| 47 | func testRepoWithWatcher(t *testing.T) (*Ctx, store.Repo, int64) { | |
| 48 | t.Helper() | |
| 49 | c := notifTestCtx(t, "alice") | |
| 50 | repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public") | |
| 51 | if err != nil { | |
| 52 | t.Fatal(err) | |
| 53 | } | |
| 54 | repo, err := c.Store.RepoByID(repoID) | |
| 55 | if err != nil { | |
| 56 | t.Fatal(err) | |
| 57 | } | |
| 58 | if _, err := c.Store.CreateIssue(repo.ID, c.User.ID, "title", "", "markdown"); err != nil { | |
| 59 | t.Fatal(err) | |
| 60 | } | |
| 61 | bob, err := c.Store.CreateUser("bob", false) | |
| 62 | if err != nil { | |
| 63 | t.Fatal(err) | |
| 64 | } | |
| 65 | if err := c.Store.SetRepoWatch(repo.ID, bob, "watching"); err != nil { | |
| 66 | t.Fatal(err) | |
| 67 | } | |
| 68 | return c, repo, bob | |
| 69 | } | |
| 70 | ||
| 71 | func TestNotifyQueuesPush(t *testing.T) { | |
| 72 | c, repo, bob := testRepoWithWatcher(t) // alice acts, bob watches | |
| 73 | c.Store.AddPushDevice(bob, "tok-b", "iphone") | |
| 74 | ||
| 75 | notify(c, []int64{bob}, notice{repo: repo, kind: "issue", | |
| 76 | subject: "[alice/app] #1: title", | |
| 77 | action: "opened issue #1", | |
| 78 | path: "alice/app/issues/1"}) | |
| 79 | ||
| 80 | due, err := c.Store.DuePush(20) | |
| 81 | if err != nil { | |
| 82 | t.Fatalf("DuePush: %v", err) | |
| 83 | } | |
| 84 | if len(due) != 1 { | |
| 85 | t.Fatalf("want one queued push, got %d", len(due)) | |
| 86 | } | |
| 87 | // The push body is the inbox row's summary, so the two surfaces | |
| 88 | // cannot disagree about what happened. | |
| 89 | if due[0].Title != "alice/app" { | |
| 90 | t.Fatalf("title = %q", due[0].Title) | |
| 91 | } | |
| 92 | if due[0].Body != "alice opened issue #1" { | |
| 93 | t.Fatalf("body = %q", due[0].Body) | |
| 94 | } | |
| 95 | if due[0].Path != "alice/app/issues/1" { | |
| 96 | t.Fatalf("path = %q", due[0].Path) | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | func TestNotifyQueuesNoPushForTheActor(t *testing.T) { | |
| 101 | c, repo, _ := testRepoWithWatcher(t) | |
| 102 | c.Store.AddPushDevice(c.User.ID, "tok-self", "iphone") | |
| 103 | ||
| 104 | notify(c, []int64{c.User.ID}, notice{repo: repo, kind: "issue", | |
| 105 | subject: "s", action: "opened issue #1", path: "alice/app/issues/1"}) | |
| 106 | ||
| 107 | // NotifyRecipients already drops the actor; push inherits that and | |
| 108 | // must not find its own way around it. | |
| 109 | if due, _ := c.Store.DuePush(20); len(due) != 0 { | |
| 110 | t.Fatalf("queued a push to the actor") | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | // TestNotifyQueuesNoPushWhenDisabled: on an instance with [push] | |
| 115 | // enabled = false nothing drains the queue, and the retention sweep only | |
| 116 | // collects rows that were sent or dead-lettered, so a row written here is | |
| 117 | // never collected. The mail half already gates on the instance having | |
| 118 | // SMTP; push gates the same way. | |
| 119 | func TestNotifyQueuesNoPushWhenDisabled(t *testing.T) { | |
| 120 | c, repo, bob := testRepoWithWatcher(t) | |
| 121 | c.Cfg.Push.Enabled = false | |
| 122 | c.Store.AddPushDevice(bob, "tok-b", "iphone") | |
| 123 | ||
| 124 | notify(c, []int64{bob}, notice{repo: repo, kind: "issue", | |
| 125 | subject: "s", action: "opened issue #1", path: "alice/app/issues/1"}) | |
| 126 | ||
| 127 | if due, _ := c.Store.DuePush(20); len(due) != 0 { | |
| 128 | t.Fatalf("queued %d pushes on a push-disabled instance", len(due)) | |
| 129 | } | |
| 130 | // The inbox row is still filed: push is the optional half, not the | |
| 131 | // notice. | |
| 132 | if n := c.Store.UnreadNotices(bob); n != 1 { | |
| 133 | t.Fatalf("unread notices = %d, want 1", n) | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | // TestNotificationsDeviceAddRefusedWhenPushDisabled: registering a device | |
| 138 | // on an instance that cannot deliver would report success and then never | |
| 139 | // push, with notifications settings show still saying push is on. | |
| 140 | func TestNotificationsDeviceAddRefusedWhenPushDisabled(t *testing.T) { | |
| 141 | c := notifTestCtx(t, "alice") | |
| 142 | c.Cfg.Push.Enabled = false | |
| 143 | c.Stdin = strings.NewReader("DEVTOKEN\n") | |
| 144 | var out bytes.Buffer | |
| 145 | c.Stdout, c.Stderr = &out, &out | |
| 146 | ||
| 147 | if code := runNotificationsDeviceAdd(c, nil); code != protocol.ExitFailure { | |
| 148 | t.Fatalf("exit %d, want %d", code, protocol.ExitFailure) | |
| 149 | } | |
| 150 | if devices, _ := c.Store.PushDevices(c.User.ID); len(devices) != 0 { | |
| 151 | t.Fatalf("device registered anyway: %+v", devices) | |
| 152 | } | |
| 153 | if !strings.Contains(out.String(), "[push] enabled = false") { | |
| 154 | t.Fatalf("message does not name the instance setting: %q", out.String()) | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | func TestNotificationsDeviceAddReadsStdin(t *testing.T) { | |
| 159 | c := notifTestCtx(t, "alice") | |
| 160 | c.Stdin = strings.NewReader("DEVTOKEN\n") | |
| 161 | if code := runNotificationsDeviceAdd(c, []string{"--label", "iphone"}); code != 0 { | |
| 162 | t.Fatalf("exit %d", code) | |
| 163 | } | |
| 164 | devices, _ := c.Store.PushDevices(c.User.ID) | |
| 165 | if len(devices) != 1 || devices[0].Token != "DEVTOKEN" { | |
| 166 | t.Fatalf("got %+v", devices) | |
| 167 | } | |
| 168 | if devices[0].Label != "iphone" { | |
| 169 | t.Fatalf("label = %q", devices[0].Label) | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | func TestNotificationsDeviceListTruncatesTheToken(t *testing.T) { | |
| 174 | c := notifTestCtx(t, "alice") | |
| 175 | long := strings.Repeat("a", 64) | |
| 176 | c.Store.AddPushDevice(c.User.ID, long, "iphone") | |
| 177 | var out bytes.Buffer | |
| 178 | c.Stdout = &out | |
| 179 | if code := runNotificationsDeviceList(c, nil); code != 0 { | |
| 180 | t.Fatalf("exit %d", code) | |
| 181 | } | |
| 182 | if strings.Contains(out.String(), long) { | |
| 183 | t.Fatal("the full token was printed") | |
| 184 | } | |
| 185 | } | |
| 186 | ||
| 187 | // TestNotificationsDeviceListTruncatesTheTokenJSON is the JSON-path twin | |
| 188 | // of the above: the plain and JSON output share the same rows slice, but | |
| 189 | // nothing enforces that beyond reading the code, so both paths get their | |
| 190 | // own test of the guarantee. | |
| 191 | func TestNotificationsDeviceListTruncatesTheTokenJSON(t *testing.T) { | |
| 192 | c := notifTestCtx(t, "alice") | |
| 193 | long := strings.Repeat("a", 64) | |
| 194 | c.Store.AddPushDevice(c.User.ID, long, "iphone") | |
| 195 | var out bytes.Buffer | |
| 196 | c.Stdout, c.JSON = &out, true | |
| 197 | if code := runNotificationsDeviceList(c, nil); code != 0 { | |
| 198 | t.Fatalf("exit %d", code) | |
| 199 | } | |
| 200 | if strings.Contains(out.String(), long) { | |
| 201 | t.Fatal("the full token was printed") | |
| 202 | } | |
| 203 | } | |
| 204 | ||
| 205 | // TestNotificationsDeviceListMasksAShortToken: a token at or under the | |
| 206 | // truncation cut length is not returned unchanged. ShortToken's short | |
| 207 | // path used to return the token verbatim, a full echo of anything eight | |
| 208 | // characters or fewer; runNotificationsDeviceAdd enforces no minimum | |
| 209 | // length, so a short token is a value the command will store. | |
| 210 | func TestNotificationsDeviceListMasksAShortToken(t *testing.T) { | |
| 211 | c := notifTestCtx(t, "alice") | |
| 212 | short := "abc123" | |
| 213 | c.Store.AddPushDevice(c.User.ID, short, "iphone") | |
| 214 | var out bytes.Buffer | |
| 215 | c.Stdout = &out | |
| 216 | if code := runNotificationsDeviceList(c, nil); code != 0 { | |
| 217 | t.Fatalf("exit %d", code) | |
| 218 | } | |
| 219 | if strings.Contains(out.String(), short) { | |
| 220 | t.Fatal("the short token was printed verbatim") | |
| 221 | } | |
| 222 | } | |
| 223 | ||
| 224 | func TestNotificationsSettingsShowsPush(t *testing.T) { | |
| 225 | c := notifTestCtx(t, "alice") | |
| 226 | var out bytes.Buffer | |
| 227 | c.Stdout, c.JSON = &out, true | |
| 228 | if code := runNotificationsSettingsShow(c, nil); code != 0 { | |
| 229 | t.Fatalf("exit %d", code) | |
| 230 | } | |
| 231 | if !strings.Contains(out.String(), `"push":true`) { | |
| 232 | t.Fatalf("no push key: %s", out.String()) | |
| 233 | } | |
| 234 | } | |
internal/httpd/account.go +39 −2
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "io" |
| 7 | 7 | "net/http" |
| 8 | 8 | "net/url" |
| 9 | "strconv" | |
| 9 | 10 | "strings" |
| 10 | 11 | |
| 11 | 12 | "gitbay.org/gitbay/internal/control" |
| @@ -31,6 +32,19 @@ type accountPGP struct { | ||
| 31 | 32 | Confirm string // the fingerprint's first 8 characters |
| 32 | 33 | } |
| 33 | 34 | |
| 35 | // accountDevice is one registered APNs device as the settings page shows | |
| 36 | // it. No form of the token reaches the page but the masked column: | |
| 37 | // removal confirms on the id, which is not device-identifying. | |
| 38 | type accountDevice struct { | |
| 39 | ID int64 | |
| 40 | Label string | |
| 41 | // Token is rendered by control.ShortToken, the same renderer | |
| 42 | // notifications device list uses. | |
| 43 | Token string | |
| 44 | LastSeenAt string | |
| 45 | Confirm string // the id as text, typed back to confirm removal | |
| 46 | } | |
| 47 | ||
| 34 | 48 | // accountForm renders the account's own settings: keys, addresses, and the |
| 35 | 49 | // commands for everything that stays on SSH. |
| 36 | 50 | func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) { |
| @@ -64,8 +78,18 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use | ||
| 64 | 78 | s.runControlInto(u, []string{"profile", "show"}, &profile) |
| 65 | 79 | mailOn, _ := s.st.MailEnabled(u.ID) |
| 66 | 80 | watchOn, _ := s.st.WatchEnabled(u.ID) |
| 81 | pushOn, _ := s.st.PushEnabled(u.ID) | |
| 67 | 82 | theme, _ := s.st.Theme(u.ID) |
| 68 | 83 | |
| 84 | var devices []accountDevice | |
| 85 | if list, err := s.st.PushDevices(u.ID); err == nil { | |
| 86 | for _, d := range list { | |
| 87 | devices = append(devices, accountDevice{ID: d.ID, Label: d.Label, | |
| 88 | Token: control.ShortToken(d.Token), LastSeenAt: d.LastSeenAt, | |
| 89 | Confirm: strconv.FormatInt(d.ID, 10)}) | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 69 | 93 | // The about text is a file. The page points at it rather than editing |
| 70 | 94 | // it: the repository's own editor already does that job. |
| 71 | 95 | aboutRepo := u.Username + "/" + control.ProfileRepoName |
| @@ -89,10 +113,12 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use | ||
| 89 | 113 | Message string |
| 90 | 114 | MailOn bool |
| 91 | 115 | WatchOn bool |
| 116 | PushOn bool | |
| 117 | Devices []accountDevice | |
| 92 | 118 | ThemeSetting string // system, light or dark: the form's selected option |
| 93 | 119 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), |
| 94 | 120 | aboutRepo, aboutEdit, s.cfg.SiteHost(), |
| 95 | s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme}) | |
| 121 | s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme}) | |
| 96 | 122 | } |
| 97 | 123 | |
| 98 | 124 | // accountExport hands the browser the same bundle `account export` |
| @@ -243,7 +269,7 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U | ||
| 243 | 269 | return |
| 244 | 270 | } |
| 245 | 271 | back("", "colour scheme saved") |
| 246 | case "notify-mail", "notify-watch": | |
| 272 | case "notify-mail", "notify-watch", "notify-push": | |
| 247 | 273 | pref := strings.TrimPrefix(r.FormValue("field"), "notify-") |
| 248 | 274 | state := "off" |
| 249 | 275 | if r.FormValue(pref) == "on" { |
| @@ -254,6 +280,17 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U | ||
| 254 | 280 | return |
| 255 | 281 | } |
| 256 | 282 | back("", "notification preferences saved") |
| 283 | case "device-remove": | |
| 284 | id := r.FormValue("id") | |
| 285 | if ok, msg := confirmed(r, id); !ok { | |
| 286 | back(msg, "") | |
| 287 | return | |
| 288 | } | |
| 289 | if _, msg, ok := s.runControl(u, []string{"notifications", "device", "remove", id}); !ok { | |
| 290 | back(msg, "") | |
| 291 | return | |
| 292 | } | |
| 293 | back("", "device removed") | |
| 257 | 294 | case "profile": |
| 258 | 295 | argv := []string{"profile", "set", |
| 259 | 296 | "--description", r.FormValue("description"), |
internal/httpd/account_test.go added +177
| @@ -0,0 +1,177 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/http" | |
| 5 | "net/http/httptest" | |
| 6 | "net/url" | |
| 7 | "strconv" | |
| 8 | "strings" | |
| 9 | "testing" | |
| 10 | ||
| 11 | "gitbay.org/gitbay/internal/config" | |
| 12 | "gitbay.org/gitbay/internal/store" | |
| 13 | ) | |
| 14 | ||
| 15 | // The settings page carries a push toggle beside the mail and watch ones, | |
| 16 | // and lists registered devices by label and truncated token. The full | |
| 17 | // token is device-identifying and must never reach the page. | |
| 18 | func TestAccountPagePushToggleAndDevices(t *testing.T) { | |
| 19 | st, err := store.Open(":memory:") | |
| 20 | if err != nil { | |
| 21 | t.Fatal(err) | |
| 22 | } | |
| 23 | defer st.Close() | |
| 24 | if err := st.MigrateUp(); err != nil { | |
| 25 | t.Fatal(err) | |
| 26 | } | |
| 27 | ||
| 28 | uid, err := st.CreateUser("alice", false) | |
| 29 | if err != nil { | |
| 30 | t.Fatal(err) | |
| 31 | } | |
| 32 | token := strings.Repeat("a", 64) | |
| 33 | if _, err := st.AddPushDevice(uid, token, "iphone"); err != nil { | |
| 34 | t.Fatal(err) | |
| 35 | } | |
| 36 | ||
| 37 | s := New(config.Default(), st) | |
| 38 | rr := httptest.NewRecorder() | |
| 39 | req := httptest.NewRequest("GET", "/settings", nil) | |
| 40 | s.accountPage(rr, req, store.User{ID: uid, Username: "alice"}) | |
| 41 | ||
| 42 | body := rr.Body.String() | |
| 43 | if !strings.Contains(body, `value="notify-push"`) { | |
| 44 | t.Fatal("no push toggle") | |
| 45 | } | |
| 46 | if !strings.Contains(body, "iphone") { | |
| 47 | t.Fatal("the device is not listed") | |
| 48 | } | |
| 49 | // A token is device-identifying and is never printed in full. | |
| 50 | if strings.Contains(body, token) { | |
| 51 | t.Fatal("the page printed a device token in full") | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | // submit posts an account settings form as u and returns the recorder. | |
| 56 | func submitAccountForm(t *testing.T, s *Server, u store.User, form url.Values) *httptest.ResponseRecorder { | |
| 57 | t.Helper() | |
| 58 | req := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode())) | |
| 59 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 60 | rr := httptest.NewRecorder() | |
| 61 | s.accountSubmit(rr, req, u) | |
| 62 | return rr | |
| 63 | } | |
| 64 | ||
| 65 | // Posting notify-push dispatches to notifications settings push, the same | |
| 66 | // path the mail and watch toggles already use. | |
| 67 | func TestAccountSubmitNotifyPush(t *testing.T) { | |
| 68 | st, err := store.Open(":memory:") | |
| 69 | if err != nil { | |
| 70 | t.Fatal(err) | |
| 71 | } | |
| 72 | defer st.Close() | |
| 73 | if err := st.MigrateUp(); err != nil { | |
| 74 | t.Fatal(err) | |
| 75 | } | |
| 76 | uid, err := st.CreateUser("alice", false) | |
| 77 | if err != nil { | |
| 78 | t.Fatal(err) | |
| 79 | } | |
| 80 | u := store.User{ID: uid, Username: "alice"} | |
| 81 | s := New(config.Default(), st) | |
| 82 | ||
| 83 | rr := submitAccountForm(t, s, u, url.Values{"field": {"notify-push"}, "push": {"on"}}) | |
| 84 | if rr.Code != http.StatusSeeOther { | |
| 85 | t.Fatalf("status %d, body %s", rr.Code, rr.Body.String()) | |
| 86 | } | |
| 87 | if on, err := st.PushEnabled(uid); err != nil || !on { | |
| 88 | t.Fatalf("PushEnabled after notify-push=on: %v %v", on, err) | |
| 89 | } | |
| 90 | ||
| 91 | submitAccountForm(t, s, u, url.Values{"field": {"notify-push"}}) | |
| 92 | if on, err := st.PushEnabled(uid); err != nil || on { | |
| 93 | t.Fatalf("PushEnabled after notify-push off: %v %v", on, err) | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | // Removing a device requires the device id typed back, and then | |
| 98 | // dispatches to notifications device remove, scoped to the caller's own | |
| 99 | // account. The id is what the form dispatches on, so the guard is | |
| 100 | // derived server-side the way key-remove derives its own. | |
| 101 | func TestAccountSubmitDeviceRemove(t *testing.T) { | |
| 102 | st, err := store.Open(":memory:") | |
| 103 | if err != nil { | |
| 104 | t.Fatal(err) | |
| 105 | } | |
| 106 | defer st.Close() | |
| 107 | if err := st.MigrateUp(); err != nil { | |
| 108 | t.Fatal(err) | |
| 109 | } | |
| 110 | uid, err := st.CreateUser("alice", false) | |
| 111 | if err != nil { | |
| 112 | t.Fatal(err) | |
| 113 | } | |
| 114 | u := store.User{ID: uid, Username: "alice"} | |
| 115 | token := strings.Repeat("b", 64) | |
| 116 | id, err := st.AddPushDevice(uid, token, "iphone") | |
| 117 | if err != nil { | |
| 118 | t.Fatal(err) | |
| 119 | } | |
| 120 | s := New(config.Default(), st) | |
| 121 | ||
| 122 | idStr := strconv.FormatInt(id, 10) | |
| 123 | ||
| 124 | // Without the typed confirmation, the device survives. | |
| 125 | submitAccountForm(t, s, u, url.Values{"field": {"device-remove"}, "id": {idStr}}) | |
| 126 | if devices, _ := st.PushDevices(uid); len(devices) != 1 { | |
| 127 | t.Fatalf("device removed without confirmation: %v", devices) | |
| 128 | } | |
| 129 | ||
| 130 | rr := submitAccountForm(t, s, u, url.Values{"field": {"device-remove"}, "id": {idStr}, "confirm": {idStr}}) | |
| 131 | if rr.Code != http.StatusSeeOther { | |
| 132 | t.Fatalf("status %d, body %s", rr.Code, rr.Body.String()) | |
| 133 | } | |
| 134 | if devices, _ := st.PushDevices(uid); len(devices) != 0 { | |
| 135 | t.Fatalf("device not removed: %v", devices) | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | // A short token reaches no part of the page — not the visible column, | |
| 140 | // and not a hidden input, aria-label or placeholder either. Device add | |
| 141 | // enforces no minimum length, so a token this short is a value the store | |
| 142 | // can hold, and it is device-identifying whatever its length. | |
| 143 | func TestAccountPageMasksAShortDeviceToken(t *testing.T) { | |
| 144 | st, err := store.Open(":memory:") | |
| 145 | if err != nil { | |
| 146 | t.Fatal(err) | |
| 147 | } | |
| 148 | defer st.Close() | |
| 149 | if err := st.MigrateUp(); err != nil { | |
| 150 | t.Fatal(err) | |
| 151 | } | |
| 152 | uid, err := st.CreateUser("alice", false) | |
| 153 | if err != nil { | |
| 154 | t.Fatal(err) | |
| 155 | } | |
| 156 | id, err := st.AddPushDevice(uid, "abc123", "iphone") | |
| 157 | if err != nil { | |
| 158 | t.Fatal(err) | |
| 159 | } | |
| 160 | ||
| 161 | s := New(config.Default(), st) | |
| 162 | rr := httptest.NewRecorder() | |
| 163 | s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), store.User{ID: uid, Username: "alice"}) | |
| 164 | ||
| 165 | body := rr.Body.String() | |
| 166 | if strings.Contains(body, "abc123") { | |
| 167 | t.Fatalf("the short token reached the page:\n%s", body) | |
| 168 | } | |
| 169 | // What the removal asks for has to be on screen to be typed back. | |
| 170 | idStr := strconv.FormatInt(id, 10) | |
| 171 | if !strings.Contains(body, `aria-label="Type `+idStr+` to confirm"`) { | |
| 172 | t.Fatalf("removal does not confirm on the device id:\n%s", body) | |
| 173 | } | |
| 174 | if !strings.Contains(body, `<th scope="col">id</th>`) { | |
| 175 | t.Fatalf("the device table has no id column:\n%s", body) | |
| 176 | } | |
| 177 | } | |
internal/httpd/admin_test.go added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/http" | |
| 5 | "net/http/httptest" | |
| 6 | "strconv" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/config" | |
| 11 | "gitbay.org/gitbay/internal/store" | |
| 12 | ) | |
| 13 | ||
| 14 | // The admin page renders every queue dashboard reports, push included, | |
| 15 | // and names a push by its device id: a token is device-identifying and | |
| 16 | // reaches an admin's page no more than it reaches its owner's. | |
| 17 | func TestAdminPageShowsThePushQueue(t *testing.T) { | |
| 18 | st, err := store.Open(":memory:") | |
| 19 | if err != nil { | |
| 20 | t.Fatal(err) | |
| 21 | } | |
| 22 | defer st.Close() | |
| 23 | if err := st.MigrateUp(); err != nil { | |
| 24 | t.Fatal(err) | |
| 25 | } | |
| 26 | uid, err := st.CreateUser("root", true) | |
| 27 | if err != nil { | |
| 28 | t.Fatal(err) | |
| 29 | } | |
| 30 | if err := st.SetPushEnabled(uid, true); err != nil { | |
| 31 | t.Fatal(err) | |
| 32 | } | |
| 33 | token := strings.Repeat("c", 64) | |
| 34 | device, err := st.AddPushDevice(uid, token, "iphone") | |
| 35 | if err != nil { | |
| 36 | t.Fatal(err) | |
| 37 | } | |
| 38 | if err := st.EnqueuePush(uid, "krz/gitbay", "alice opened #1", "/krz/gitbay/issues/1"); err != nil { | |
| 39 | t.Fatal(err) | |
| 40 | } | |
| 41 | // Only rows that are retrying or dead-lettered are listed, so fail | |
| 42 | // the queued one first. | |
| 43 | due, err := st.DuePush(10) | |
| 44 | if err != nil || len(due) != 1 { | |
| 45 | t.Fatalf("DuePush: %v %v", due, err) | |
| 46 | } | |
| 47 | if err := st.MarkPushFailed(due[0].ID, "403 InvalidProviderToken", nil); err != nil { | |
| 48 | t.Fatal(err) | |
| 49 | } | |
| 50 | ||
| 51 | s := New(config.Default(), st) | |
| 52 | rr := httptest.NewRecorder() | |
| 53 | s.adminPage(rr, httptest.NewRequest("GET", "/admin", nil), store.User{ID: uid, Username: "root", IsAdmin: true}) | |
| 54 | if rr.Code != http.StatusOK { | |
| 55 | t.Fatalf("status %d, body %s", rr.Code, rr.Body.String()) | |
| 56 | } | |
| 57 | ||
| 58 | body := rr.Body.String() | |
| 59 | if !strings.Contains(body, `id="push"`) { | |
| 60 | t.Fatalf("no push section:\n%s", body) | |
| 61 | } | |
| 62 | if !strings.Contains(body, `href="#push"`) { | |
| 63 | t.Fatalf("push is missing from the jump list:\n%s", body) | |
| 64 | } | |
| 65 | if !strings.Contains(body, "device "+strconv.FormatInt(device, 10)) { | |
| 66 | t.Fatalf("the dead-lettered push is not listed by device id:\n%s", body) | |
| 67 | } | |
| 68 | if !strings.Contains(body, "403 InvalidProviderToken") { | |
| 69 | t.Fatalf("the dead-lettered push's error is not shown:\n%s", body) | |
| 70 | } | |
| 71 | if strings.Contains(body, token) { | |
| 72 | t.Fatalf("the page printed a device token:\n%s", body) | |
| 73 | } | |
| 74 | } | |
internal/push/apns.go added +176
| @@ -0,0 +1,176 @@ | ||
| 1 | package push | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "context" | |
| 6 | "crypto/ecdsa" | |
| 7 | "encoding/json" | |
| 8 | "fmt" | |
| 9 | "io" | |
| 10 | "log/slog" | |
| 11 | "net" | |
| 12 | "net/http" | |
| 13 | "os" | |
| 14 | "strconv" | |
| 15 | "strings" | |
| 16 | "time" | |
| 17 | ||
| 18 | "gitbay.org/gitbay/internal/config" | |
| 19 | ) | |
| 20 | ||
| 21 | // result is what one send means for the queue row. | |
| 22 | type result int | |
| 23 | ||
| 24 | const ( | |
| 25 | resultSent result = iota // delivered | |
| 26 | resultRetry // transient; back off and try again | |
| 27 | resultReap // Apple says the token is dead; drop the device | |
| 28 | resultDead // permanent for this payload; dead-letter it | |
| 29 | ) | |
| 30 | ||
| 31 | // maxBodyBytes keeps an alert inside APNs' 4KB payload limit with room | |
| 32 | // for the rest of the JSON. A summary longer than this is cut rather | |
| 33 | // than rejected. | |
| 34 | const maxBodyBytes = 3000 | |
| 35 | ||
| 36 | type Client struct { | |
| 37 | http *http.Client | |
| 38 | tokens *tokenSource | |
| 39 | key *ecdsa.PrivateKey | |
| 40 | host string | |
| 41 | scheme string | |
| 42 | topic string | |
| 43 | } | |
| 44 | ||
| 45 | func NewClient(cfg config.Push) (*Client, error) { | |
| 46 | c := &Client{ | |
| 47 | // stdlib negotiates HTTP/2 over ALPN, which is what APNs | |
| 48 | // requires; no explicit http2 transport is needed. | |
| 49 | http: &http.Client{Timeout: 30 * time.Second}, | |
| 50 | host: cfg.Host(), | |
| 51 | scheme: apnsScheme(), | |
| 52 | topic: cfg.Topic, | |
| 53 | } | |
| 54 | if cfg.KeyFile != "" { | |
| 55 | key, err := config.LoadAPNSKey(cfg.KeyFile) | |
| 56 | if err != nil { | |
| 57 | return nil, err | |
| 58 | } | |
| 59 | c.key = key | |
| 60 | c.tokens = newTokenSource(key, cfg.KeyID, cfg.TeamID) | |
| 61 | } | |
| 62 | return c, nil | |
| 63 | } | |
| 64 | ||
| 65 | // apnsScheme is https for the real Apple hosts. GITBAY_APNS_HOST redirects | |
| 66 | // the endpoint for tests (config.Push.Host), and the fake it points at | |
| 67 | // speaks plain HTTP/1.1 rather than negotiating TLS, so the same override | |
| 68 | // has to drop the scheme too, or every request fails with "server gave | |
| 69 | // HTTP response to HTTPS client" instead of reaching the fake at all. | |
| 70 | // | |
| 71 | // The drop only applies to a host on this machine. The provider token is | |
| 72 | // a bearer credential, valid for an hour and good for any device under | |
| 73 | // the topic; putting it on the wire in cleartext to somewhere else is not | |
| 74 | // a thing the test override should be able to arrange. Every fake in the | |
| 75 | // tree is an httptest server, which always binds loopback, so nothing | |
| 76 | // loses anything by the restriction. Either way the decision is logged, | |
| 77 | // so an operator who set the variable learns what it did. | |
| 78 | func apnsScheme() string { | |
| 79 | h := os.Getenv("GITBAY_APNS_HOST") | |
| 80 | if h == "" { | |
| 81 | return "https" | |
| 82 | } | |
| 83 | if !loopbackHost(h) { | |
| 84 | slog.Warn("push: GITBAY_APNS_HOST is not on this machine, still sending over HTTPS; the provider token is a bearer credential and does not travel in cleartext", "host", h) | |
| 85 | return "https" | |
| 86 | } | |
| 87 | slog.Warn("push: GITBAY_APNS_HOST is set, sending to it over plain HTTP instead of APNs", "host", h) | |
| 88 | return "http" | |
| 89 | } | |
| 90 | ||
| 91 | // loopbackHost reports whether a host:port names this machine. The port | |
| 92 | // is optional: config.Push.Host returns a bare hostname for the real | |
| 93 | // endpoints, and the override may or may not carry one. | |
| 94 | func loopbackHost(hostport string) bool { | |
| 95 | host := hostport | |
| 96 | if h, _, err := net.SplitHostPort(hostport); err == nil { | |
| 97 | host = h | |
| 98 | } | |
| 99 | host = strings.Trim(host, "[]") | |
| 100 | if host == "localhost" { | |
| 101 | return true | |
| 102 | } | |
| 103 | ip := net.ParseIP(host) | |
| 104 | return ip != nil && ip.IsLoopback() | |
| 105 | } | |
| 106 | ||
| 107 | // Send delivers one alert. The returned duration is the server's | |
| 108 | // Retry-After when it gave one, zero otherwise. | |
| 109 | func (c *Client) Send(ctx context.Context, token, title, body, path string) (result, time.Duration, error) { | |
| 110 | if len(body) > maxBodyBytes { | |
| 111 | // A raw byte cut can land mid-rune on multi-byte UTF-8 (emoji, | |
| 112 | // accents, non-Latin usernames). ToValidUTF8 drops the | |
| 113 | // resulting dangling bytes instead of leaving them for | |
| 114 | // encoding/json to turn into a garbled U+FFFD. | |
| 115 | body = strings.ToValidUTF8(body[:maxBodyBytes], "") | |
| 116 | } | |
| 117 | payload, err := json.Marshal(map[string]any{ | |
| 118 | "aps": map[string]any{ | |
| 119 | "alert": map[string]string{"title": title, "body": body}, | |
| 120 | "sound": "default", | |
| 121 | "thread-id": title, | |
| 122 | }, | |
| 123 | "path": path, | |
| 124 | }) | |
| 125 | if err != nil { | |
| 126 | return resultDead, 0, err | |
| 127 | } | |
| 128 | bearer, err := c.tokens.token() | |
| 129 | if err != nil { | |
| 130 | return resultRetry, 0, err | |
| 131 | } | |
| 132 | url := c.scheme + "://" + c.host + "/3/device/" + token | |
| 133 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) | |
| 134 | if err != nil { | |
| 135 | return resultDead, 0, err | |
| 136 | } | |
| 137 | req.Header.Set("authorization", "bearer "+bearer) | |
| 138 | req.Header.Set("apns-topic", c.topic) | |
| 139 | req.Header.Set("apns-push-type", "alert") | |
| 140 | req.Header.Set("apns-priority", "10") | |
| 141 | req.Header.Set("content-type", "application/json") | |
| 142 | ||
| 143 | resp, err := c.http.Do(req) | |
| 144 | if err != nil { | |
| 145 | return resultRetry, 0, err | |
| 146 | } | |
| 147 | defer resp.Body.Close() | |
| 148 | raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) | |
| 149 | ||
| 150 | var apnsErr struct { | |
| 151 | Reason string `json:"reason"` | |
| 152 | } | |
| 153 | json.Unmarshal(raw, &apnsErr) | |
| 154 | ||
| 155 | var after time.Duration | |
| 156 | if v := resp.Header.Get("Retry-After"); v != "" { | |
| 157 | if n, err := strconv.Atoi(v); err == nil && n > 0 { | |
| 158 | after = time.Duration(n) * time.Second | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | switch { | |
| 163 | case resp.StatusCode == http.StatusOK: | |
| 164 | return resultSent, 0, nil | |
| 165 | case resp.StatusCode == http.StatusGone, | |
| 166 | apnsErr.Reason == "BadDeviceToken", | |
| 167 | apnsErr.Reason == "Unregistered": | |
| 168 | // Apple is authoritative about which tokens are live. | |
| 169 | return resultReap, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason) | |
| 170 | case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500: | |
| 171 | return resultRetry, after, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason) | |
| 172 | default: | |
| 173 | // Retrying a rejected payload will not fix it. | |
| 174 | return resultDead, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason) | |
| 175 | } | |
| 176 | } | |
internal/push/apns_test.go added +174
| @@ -0,0 +1,174 @@ | ||
| 1 | package push | |
| 2 | ||
| 3 | import ( | |
| 4 | "context" | |
| 5 | "encoding/json" | |
| 6 | "io" | |
| 7 | "net/http" | |
| 8 | "net/http/httptest" | |
| 9 | "strings" | |
| 10 | "testing" | |
| 11 | "time" | |
| 12 | "unicode/utf8" | |
| 13 | ||
| 14 | "gitbay.org/gitbay/internal/config" | |
| 15 | ) | |
| 16 | ||
| 17 | // fakeAPNs stands in for Apple. It speaks HTTP/1.1; the real transport is | |
| 18 | // h2 by ALPN, which is stdlib behaviour and not this repository's to test. | |
| 19 | func fakeAPNs(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) { | |
| 20 | t.Helper() | |
| 21 | srv := httptest.NewServer(h) | |
| 22 | t.Cleanup(srv.Close) | |
| 23 | t.Setenv("GITBAY_APNS_HOST", strings.TrimPrefix(srv.URL, "http://")) | |
| 24 | c, err := NewClient(config.Push{ | |
| 25 | Enabled: true, KeyID: "K", TeamID: "T", | |
| 26 | Topic: "org.gitbay.gitbay", Environment: "production", | |
| 27 | }) | |
| 28 | if err != nil { | |
| 29 | t.Fatal(err) | |
| 30 | } | |
| 31 | c.key = testKey(t) | |
| 32 | c.tokens = newTokenSource(c.key, "K", "T") | |
| 33 | c.scheme = "http" | |
| 34 | return c, srv | |
| 35 | } | |
| 36 | ||
| 37 | func TestSendShapesTheRequest(t *testing.T) { | |
| 38 | var gotPath, gotTopic, gotType, gotAuth, gotCollapse string | |
| 39 | var payload map[string]any | |
| 40 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 41 | gotPath, gotTopic = r.URL.Path, r.Header.Get("apns-topic") | |
| 42 | gotType, gotAuth = r.Header.Get("apns-push-type"), r.Header.Get("authorization") | |
| 43 | gotCollapse = r.Header.Get("apns-collapse-id") | |
| 44 | raw, _ := io.ReadAll(r.Body) | |
| 45 | json.Unmarshal(raw, &payload) | |
| 46 | w.WriteHeader(200) | |
| 47 | }) | |
| 48 | res, _, err := c.Send(context.Background(), "DEVTOKEN", "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12") | |
| 49 | if err != nil || res != resultSent { | |
| 50 | t.Fatalf("res = %v, err = %v", res, err) | |
| 51 | } | |
| 52 | if gotPath != "/3/device/DEVTOKEN" { | |
| 53 | t.Fatalf("path = %q", gotPath) | |
| 54 | } | |
| 55 | if gotTopic != "org.gitbay.gitbay" || gotType != "alert" { | |
| 56 | t.Fatalf("topic = %q, push-type = %q", gotTopic, gotType) | |
| 57 | } | |
| 58 | if !strings.HasPrefix(gotAuth, "bearer ") { | |
| 59 | t.Fatalf("authorization = %q", gotAuth) | |
| 60 | } | |
| 61 | aps := payload["aps"].(map[string]any) | |
| 62 | alert := aps["alert"].(map[string]any) | |
| 63 | if alert["title"] != "krz/gitbay" || alert["body"] != "cmc opened issue #12" { | |
| 64 | t.Fatalf("alert = %v", alert) | |
| 65 | } | |
| 66 | if aps["thread-id"] != "krz/gitbay" { | |
| 67 | t.Fatalf("thread-id = %v", aps["thread-id"]) | |
| 68 | } | |
| 69 | if payload["path"] != "krz/gitbay/issues/12" { | |
| 70 | t.Fatalf("path = %v", payload["path"]) | |
| 71 | } | |
| 72 | // Collapsing is wrong here: two comments are two notices. This is an | |
| 73 | // APNs HTTP header, not a body field, so it must be checked on the | |
| 74 | // request the handler received, not on the decoded JSON payload. | |
| 75 | if gotCollapse != "" { | |
| 76 | t.Fatalf("apns-collapse-id = %q, want unset", gotCollapse) | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | func TestSendMapsResponses(t *testing.T) { | |
| 81 | cases := []struct { | |
| 82 | name string | |
| 83 | status int | |
| 84 | body string | |
| 85 | retryAfter string | |
| 86 | want result | |
| 87 | wantAfter time.Duration | |
| 88 | }{ | |
| 89 | {"ok", 200, "", "", resultSent, 0}, | |
| 90 | {"gone", 410, `{"reason":"Unregistered"}`, "", resultReap, 0}, | |
| 91 | {"bad token", 400, `{"reason":"BadDeviceToken"}`, "", resultReap, 0}, | |
| 92 | {"other 400 is permanent", 400, `{"reason":"PayloadTooLarge"}`, "", resultDead, 0}, | |
| 93 | {"forbidden is permanent", 403, `{"reason":"InvalidProviderToken"}`, "", resultDead, 0}, | |
| 94 | {"too many requests retries", 429, `{"reason":"TooManyRequests"}`, "7", resultRetry, 7 * time.Second}, | |
| 95 | {"server error retries", 503, `{"reason":"ServiceUnavailable"}`, "", resultRetry, 0}, | |
| 96 | } | |
| 97 | for _, tc := range cases { | |
| 98 | t.Run(tc.name, func(t *testing.T) { | |
| 99 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 100 | if tc.retryAfter != "" { | |
| 101 | w.Header().Set("Retry-After", tc.retryAfter) | |
| 102 | } | |
| 103 | w.WriteHeader(tc.status) | |
| 104 | io.WriteString(w, tc.body) | |
| 105 | }) | |
| 106 | res, after, err := c.Send(context.Background(), "T", "t", "b", "p") | |
| 107 | // Only a delivered push has no error. Every other result | |
| 108 | // carries the status and reason, which is what the drainer | |
| 109 | // records on the queue row. | |
| 110 | if tc.want == resultSent && err != nil { | |
| 111 | t.Fatalf("err = %v", err) | |
| 112 | } | |
| 113 | if tc.want != resultSent && err == nil { | |
| 114 | t.Fatalf("want an error explaining %v, got nil", tc.want) | |
| 115 | } | |
| 116 | if res != tc.want { | |
| 117 | t.Fatalf("res = %v, want %v", res, tc.want) | |
| 118 | } | |
| 119 | if after != tc.wantAfter { | |
| 120 | t.Fatalf("retryAfter = %v, want %v", after, tc.wantAfter) | |
| 121 | } | |
| 122 | }) | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | func TestSendTruncatesBodyOnRuneBoundary(t *testing.T) { | |
| 127 | var payload map[string]any | |
| 128 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 129 | raw, _ := io.ReadAll(r.Body) | |
| 130 | json.Unmarshal(raw, &payload) | |
| 131 | w.WriteHeader(200) | |
| 132 | }) | |
| 133 | // A leading ASCII byte shifts every following two-byte rune off an | |
| 134 | // even offset, so a raw cut at maxBodyBytes is guaranteed to land on | |
| 135 | // the second byte of one of them rather than a rune boundary. | |
| 136 | long := "x" + strings.Repeat("é", 2000) | |
| 137 | res, _, err := c.Send(context.Background(), "T", "t", long, "p") | |
| 138 | if err != nil || res != resultSent { | |
| 139 | t.Fatalf("res = %v, err = %v", res, err) | |
| 140 | } | |
| 141 | aps := payload["aps"].(map[string]any) | |
| 142 | alert := aps["alert"].(map[string]any) | |
| 143 | body := alert["body"].(string) | |
| 144 | if !utf8.ValidString(body) { | |
| 145 | t.Fatalf("body is not valid UTF-8: %q", body) | |
| 146 | } | |
| 147 | if len(body) > maxBodyBytes { | |
| 148 | t.Fatalf("body is %d bytes, want <= %d", len(body), maxBodyBytes) | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | // The override drops to plain HTTP only for a host on this machine. The | |
| 153 | // provider token is a bearer credential valid for an hour that can push | |
| 154 | // to any device under the topic, so a GITBAY_APNS_HOST aimed anywhere | |
| 155 | // else keeps HTTPS rather than putting it on the wire in cleartext. | |
| 156 | func TestAPNSSchemeDowngradesOnlyOnLoopback(t *testing.T) { | |
| 157 | for _, tc := range []struct{ host, want string }{ | |
| 158 | {"", "https"}, | |
| 159 | {"127.0.0.1:8080", "http"}, | |
| 160 | {"127.0.0.53:2197", "http"}, | |
| 161 | {"localhost:1234", "http"}, | |
| 162 | {"[::1]:1234", "http"}, | |
| 163 | {"::1", "http"}, | |
| 164 | {"10.0.0.5:2197", "https"}, | |
| 165 | {"apns.example.com", "https"}, | |
| 166 | {"api.push.apple.com:443", "https"}, | |
| 167 | {"not a host", "https"}, | |
| 168 | } { | |
| 169 | t.Setenv("GITBAY_APNS_HOST", tc.host) | |
| 170 | if got := apnsScheme(); got != tc.want { | |
| 171 | t.Errorf("apnsScheme() with host %q = %q, want %q", tc.host, got, tc.want) | |
| 172 | } | |
| 173 | } | |
| 174 | } | |
internal/push/push.go added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | package push | |
| 2 | ||
| 3 | import ( | |
| 4 | "context" | |
| 5 | "log/slog" | |
| 6 | "time" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/config" | |
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | // DefaultMaxAttempts matches the mailer's: a flaky APNs delays a | |
| 13 | // notification rather than losing it, up to a point. | |
| 14 | const DefaultMaxAttempts = 5 | |
| 15 | ||
| 16 | type Deliverer struct { | |
| 17 | St *store.Store | |
| 18 | Cl *Client | |
| 19 | RetryBase time.Duration | |
| 20 | MaxAttempts int | |
| 21 | } | |
| 22 | ||
| 23 | func New(st *store.Store, cfg config.Push, retryBase time.Duration) (*Deliverer, error) { | |
| 24 | cl, err := NewClient(cfg) | |
| 25 | if err != nil { | |
| 26 | return nil, err | |
| 27 | } | |
| 28 | return &Deliverer{St: st, Cl: cl, RetryBase: retryBase, MaxAttempts: DefaultMaxAttempts}, nil | |
| 29 | } | |
| 30 | ||
| 31 | // Run drains the push queue until ctx is done. | |
| 32 | func (d *Deliverer) Run(ctx context.Context) { | |
| 33 | tick := time.NewTicker(2 * time.Second) | |
| 34 | defer tick.Stop() | |
| 35 | for { | |
| 36 | select { | |
| 37 | case <-ctx.Done(): | |
| 38 | return | |
| 39 | case <-tick.C: | |
| 40 | d.drain(ctx) | |
| 41 | } | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | func (d *Deliverer) drain(ctx context.Context) { | |
| 46 | due, err := d.St.DuePush(20) | |
| 47 | if err != nil { | |
| 48 | slog.Error("push: listing due", "err", err) | |
| 49 | return | |
| 50 | } | |
| 51 | for _, q := range due { | |
| 52 | res, after, sendErr := d.Cl.Send(ctx, q.Token, q.Title, q.Body, q.Path) | |
| 53 | msg := "" | |
| 54 | if sendErr != nil { | |
| 55 | msg = sendErr.Error() | |
| 56 | } | |
| 57 | switch res { | |
| 58 | case resultSent: | |
| 59 | d.St.MarkPushSent(q.ID) | |
| 60 | case resultReap: | |
| 61 | // The queued rows cascade with the device. | |
| 62 | if err := d.St.DeletePushDeviceByToken(q.Token); err != nil { | |
| 63 | slog.Error("push: reaping device", "device", q.DeviceID, "err", err) | |
| 64 | } | |
| 65 | case resultRetry: | |
| 66 | attempt := q.Attempts + 1 | |
| 67 | if attempt >= d.MaxAttempts { | |
| 68 | d.St.MarkPushFailed(q.ID, msg, nil) | |
| 69 | // The device id, never the token. | |
| 70 | slog.Warn("push dead-lettered", | |
| 71 | "push", q.ID, "device", q.DeviceID, "attempts", attempt, "err", msg) | |
| 72 | continue | |
| 73 | } | |
| 74 | wait := after | |
| 75 | if wait == 0 { | |
| 76 | wait = d.RetryBase << (attempt - 1) | |
| 77 | } | |
| 78 | next := time.Now().Add(wait) | |
| 79 | d.St.MarkPushFailed(q.ID, msg, &next) | |
| 80 | default: // resultDead | |
| 81 | d.St.MarkPushFailed(q.ID, msg, nil) | |
| 82 | slog.Warn("push rejected", "push", q.ID, "device", q.DeviceID, "err", msg) | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
internal/push/push_test.go added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | package push | |
| 2 | ||
| 3 | import ( | |
| 4 | "context" | |
| 5 | "net/http" | |
| 6 | "testing" | |
| 7 | "time" | |
| 8 | ||
| 9 | "gitbay.org/gitbay/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | // testStoreWithQueuedPush opens an in-memory store, creates a user with | |
| 13 | // push enabled, registers one device and enqueues one push for it. | |
| 14 | func testStoreWithQueuedPush(t *testing.T, token string) *store.Store { | |
| 15 | t.Helper() | |
| 16 | st, err := store.Open(":memory:") | |
| 17 | if err != nil { | |
| 18 | t.Fatal(err) | |
| 19 | } | |
| 20 | t.Cleanup(func() { st.Close() }) | |
| 21 | if err := st.MigrateUp(); err != nil { | |
| 22 | t.Fatal(err) | |
| 23 | } | |
| 24 | uid, err := st.CreateUser("alice", false) | |
| 25 | if err != nil { | |
| 26 | t.Fatal(err) | |
| 27 | } | |
| 28 | if err := st.SetPushEnabled(uid, true); err != nil { | |
| 29 | t.Fatal(err) | |
| 30 | } | |
| 31 | if _, err := st.AddPushDevice(uid, token, "iphone"); err != nil { | |
| 32 | t.Fatal(err) | |
| 33 | } | |
| 34 | if err := st.EnqueuePush(uid, "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil { | |
| 35 | t.Fatal(err) | |
| 36 | } | |
| 37 | return st | |
| 38 | } | |
| 39 | ||
| 40 | // countPushDevices counts the test user's own devices. testStoreWithQueuedPush | |
| 41 | // always creates "alice", so looking her up here keeps the helper's signature | |
| 42 | // matching the brief's test code, which passes no user id. | |
| 43 | func countPushDevices(t *testing.T, st *store.Store) int { | |
| 44 | t.Helper() | |
| 45 | u, err := st.UserByUsername("alice") | |
| 46 | if err != nil { | |
| 47 | t.Fatal(err) | |
| 48 | } | |
| 49 | devices, err := st.PushDevices(u.ID) | |
| 50 | if err != nil { | |
| 51 | t.Fatal(err) | |
| 52 | } | |
| 53 | return len(devices) | |
| 54 | } | |
| 55 | ||
| 56 | func TestDrainSendsAndMarks(t *testing.T) { | |
| 57 | var hits int | |
| 58 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 59 | hits++ | |
| 60 | w.WriteHeader(200) | |
| 61 | }) | |
| 62 | st := testStoreWithQueuedPush(t, "tok-a") | |
| 63 | d := &Deliverer{St: st, Cl: c, RetryBase: time.Millisecond, MaxAttempts: 5} | |
| 64 | ||
| 65 | d.drain(context.Background()) | |
| 66 | ||
| 67 | if hits != 1 { | |
| 68 | t.Fatalf("sent %d times, want 1", hits) | |
| 69 | } | |
| 70 | if due, _ := st.DuePush(20); len(due) != 0 { | |
| 71 | t.Fatalf("row still due after a 200") | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | func TestDrainReapsADeadToken(t *testing.T) { | |
| 76 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 77 | w.WriteHeader(410) | |
| 78 | w.Write([]byte(`{"reason":"Unregistered"}`)) | |
| 79 | }) | |
| 80 | st := testStoreWithQueuedPush(t, "tok-a") | |
| 81 | d := &Deliverer{St: st, Cl: c, RetryBase: time.Millisecond, MaxAttempts: 5} | |
| 82 | ||
| 83 | d.drain(context.Background()) | |
| 84 | ||
| 85 | if due, _ := st.DuePush(20); len(due) != 0 { | |
| 86 | t.Fatalf("queue survived the reap") | |
| 87 | } | |
| 88 | // The device is gone, not merely its queue row. | |
| 89 | if n := countPushDevices(t, st); n != 0 { | |
| 90 | t.Fatalf("%d devices left after 410", n) | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | func TestDrainBacksOffThenDeadLetters(t *testing.T) { | |
| 95 | c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) { | |
| 96 | w.WriteHeader(503) | |
| 97 | }) | |
| 98 | st := testStoreWithQueuedPush(t, "tok-a") | |
| 99 | d := &Deliverer{St: st, Cl: c, RetryBase: time.Nanosecond, MaxAttempts: 3} | |
| 100 | ||
| 101 | // Three passes: two back off, the third gives up. | |
| 102 | for i := 0; i < 3; i++ { | |
| 103 | d.drain(context.Background()) | |
| 104 | } | |
| 105 | if due, _ := st.DuePush(20); len(due) != 0 { | |
| 106 | t.Fatalf("row still due after MaxAttempts") | |
| 107 | } | |
| 108 | // A transient failure must not take the device with it. | |
| 109 | if n := countPushDevices(t, st); n != 1 { | |
| 110 | t.Fatalf("device reaped on a 503") | |
| 111 | } | |
| 112 | } | |
internal/push/token.go added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | // Package push delivers activity notices to Apple devices over APNs: the | |
| 2 | // third delivery route beside the inbox row and the activity mail, with | |
| 3 | // the bounded-retry discipline the mail queue and webhook deliverer use. | |
| 4 | package push | |
| 5 | ||
| 6 | import ( | |
| 7 | "crypto/ecdsa" | |
| 8 | "crypto/rand" | |
| 9 | "crypto/sha256" | |
| 10 | "encoding/base64" | |
| 11 | "encoding/json" | |
| 12 | "sync" | |
| 13 | "time" | |
| 14 | ) | |
| 15 | ||
| 16 | // tokenLifetime is how long a provider token is reused. APNs accepts one | |
| 17 | // for an hour and answers TooManyProviderTokenUpdates if they are minted | |
| 18 | // faster than roughly once every twenty minutes, so the useful window is | |
| 19 | // between the two. | |
| 20 | const tokenLifetime = 50 * time.Minute | |
| 21 | ||
| 22 | type tokenSource struct { | |
| 23 | key *ecdsa.PrivateKey | |
| 24 | keyID string | |
| 25 | teamID string | |
| 26 | now func() time.Time | |
| 27 | ||
| 28 | mu sync.Mutex | |
| 29 | cached string | |
| 30 | issued time.Time | |
| 31 | } | |
| 32 | ||
| 33 | func newTokenSource(key *ecdsa.PrivateKey, keyID, teamID string) *tokenSource { | |
| 34 | return &tokenSource{key: key, keyID: keyID, teamID: teamID, now: time.Now} | |
| 35 | } | |
| 36 | ||
| 37 | // token returns the cached provider token, minting a new one when the old | |
| 38 | // one is near its end. | |
| 39 | func (t *tokenSource) token() (string, error) { | |
| 40 | t.mu.Lock() | |
| 41 | defer t.mu.Unlock() | |
| 42 | now := t.now() | |
| 43 | if t.cached != "" && now.Sub(t.issued) < tokenLifetime { | |
| 44 | return t.cached, nil | |
| 45 | } | |
| 46 | tok, err := t.sign(now) | |
| 47 | if err != nil { | |
| 48 | return "", err | |
| 49 | } | |
| 50 | t.cached, t.issued = tok, now | |
| 51 | return tok, nil | |
| 52 | } | |
| 53 | ||
| 54 | func (t *tokenSource) sign(now time.Time) (string, error) { | |
| 55 | header, err := json.Marshal(map[string]string{"alg": "ES256", "kid": t.keyID}) | |
| 56 | if err != nil { | |
| 57 | return "", err | |
| 58 | } | |
| 59 | claims, err := json.Marshal(map[string]any{"iss": t.teamID, "iat": now.Unix()}) | |
| 60 | if err != nil { | |
| 61 | return "", err | |
| 62 | } | |
| 63 | enc := base64.RawURLEncoding | |
| 64 | signing := enc.EncodeToString(header) + "." + enc.EncodeToString(claims) | |
| 65 | sum := sha256.Sum256([]byte(signing)) | |
| 66 | r, s, err := ecdsa.Sign(rand.Reader, t.key, sum[:]) | |
| 67 | if err != nil { | |
| 68 | return "", err | |
| 69 | } | |
| 70 | // JWS wants the raw pair, each left-padded to the curve's byte size — | |
| 71 | // not ecdsa.SignASN1's DER. A DER signature is well-formed ECDSA and | |
| 72 | // is rejected by every JWT verifier, APNs included. | |
| 73 | sig := make([]byte, 64) | |
| 74 | r.FillBytes(sig[:32]) | |
| 75 | s.FillBytes(sig[32:]) | |
| 76 | return signing + "." + enc.EncodeToString(sig), nil | |
| 77 | } | |
internal/push/token_test.go added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | package push | |
| 2 | ||
| 3 | import ( | |
| 4 | "crypto/ecdsa" | |
| 5 | "crypto/elliptic" | |
| 6 | "crypto/rand" | |
| 7 | "crypto/sha256" | |
| 8 | "encoding/base64" | |
| 9 | "encoding/json" | |
| 10 | "math/big" | |
| 11 | "strings" | |
| 12 | "testing" | |
| 13 | "time" | |
| 14 | ) | |
| 15 | ||
| 16 | func testKey(t *testing.T) *ecdsa.PrivateKey { | |
| 17 | t.Helper() | |
| 18 | k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 19 | if err != nil { | |
| 20 | t.Fatal(err) | |
| 21 | } | |
| 22 | return k | |
| 23 | } | |
| 24 | ||
| 25 | func TestTokenShapeAndSignature(t *testing.T) { | |
| 26 | key := testKey(t) | |
| 27 | ts := newTokenSource(key, "KEYID123", "TEAMID456") | |
| 28 | tok, err := ts.token() | |
| 29 | if err != nil { | |
| 30 | t.Fatalf("token: %v", err) | |
| 31 | } | |
| 32 | parts := strings.Split(tok, ".") | |
| 33 | if len(parts) != 3 { | |
| 34 | t.Fatalf("want three dot-separated parts, got %d", len(parts)) | |
| 35 | } | |
| 36 | ||
| 37 | var hdr struct{ Alg, Kid string } | |
| 38 | raw, _ := base64.RawURLEncoding.DecodeString(parts[0]) | |
| 39 | if err := json.Unmarshal(raw, &hdr); err != nil { | |
| 40 | t.Fatalf("header: %v", err) | |
| 41 | } | |
| 42 | if hdr.Alg != "ES256" || hdr.Kid != "KEYID123" { | |
| 43 | t.Fatalf("header = %+v", hdr) | |
| 44 | } | |
| 45 | ||
| 46 | // APNs provider tokens carry iss (team id) and iat, and nothing else. | |
| 47 | var claims map[string]any | |
| 48 | raw, _ = base64.RawURLEncoding.DecodeString(parts[1]) | |
| 49 | if err := json.Unmarshal(raw, &claims); err != nil { | |
| 50 | t.Fatalf("claims: %v", err) | |
| 51 | } | |
| 52 | if claims["iss"] != "TEAMID456" { | |
| 53 | t.Fatalf("iss = %v", claims["iss"]) | |
| 54 | } | |
| 55 | if _, ok := claims["iat"]; !ok { | |
| 56 | t.Fatal("no iat") | |
| 57 | } | |
| 58 | if len(claims) != 2 { | |
| 59 | t.Fatalf("unexpected claims: %v", claims) | |
| 60 | } | |
| 61 | ||
| 62 | // The signature is raw r||s, 64 bytes — not the ASN.1 DER that | |
| 63 | // ecdsa.SignASN1 returns. Sending DER gets every push rejected. | |
| 64 | sig, err := base64.RawURLEncoding.DecodeString(parts[2]) | |
| 65 | if err != nil { | |
| 66 | t.Fatalf("signature not base64url: %v", err) | |
| 67 | } | |
| 68 | if len(sig) != 64 { | |
| 69 | t.Fatalf("signature is %d bytes, want 64 (raw r||s)", len(sig)) | |
| 70 | } | |
| 71 | sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) | |
| 72 | r := new(big.Int).SetBytes(sig[:32]) | |
| 73 | s := new(big.Int).SetBytes(sig[32:]) | |
| 74 | if !ecdsa.Verify(&key.PublicKey, sum[:], r, s) { | |
| 75 | t.Fatal("signature does not verify") | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | func TestTokenCachedThenReminted(t *testing.T) { | |
| 80 | ts := newTokenSource(testKey(t), "K", "T") | |
| 81 | base := time.Now() | |
| 82 | ts.now = func() time.Time { return base } | |
| 83 | ||
| 84 | first, _ := ts.token() | |
| 85 | second, _ := ts.token() | |
| 86 | if first != second { | |
| 87 | t.Fatal("token reminted inside the cache window; APNs answers TooManyProviderTokenUpdates") | |
| 88 | } | |
| 89 | ||
| 90 | // Valid for an hour, not to be reminted faster than every twenty | |
| 91 | // minutes: refresh at fifty. | |
| 92 | ts.now = func() time.Time { return base.Add(51 * time.Minute) } | |
| 93 | third, _ := ts.token() | |
| 94 | if third == first { | |
| 95 | t.Fatal("token not reminted after fifty minutes") | |
| 96 | } | |
| 97 | } | |
internal/store/migrations/0059_push.down.sql added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | DROP TABLE push_queue; | |
| 2 | DROP TABLE push_devices; | |
| 3 | ALTER TABLE users DROP COLUMN notify_push; | |
internal/store/migrations/0059_push.up.sql added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | -- Apple devices an account has registered, and the queue of pushes bound | |
| 2 | -- for them. The mail queue's table is named `notifications`, so this one | |
| 3 | -- cannot be; the columns mirror it so the drainer is the mailer's loop. | |
| 4 | CREATE TABLE push_devices ( | |
| 5 | id INTEGER PRIMARY KEY, | |
| 6 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 7 | token TEXT NOT NULL UNIQUE, | |
| 8 | label TEXT NOT NULL DEFAULT '', | |
| 9 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 10 | last_seen_at TEXT | |
| 11 | ); | |
| 12 | CREATE INDEX push_devices_user ON push_devices(user_id); | |
| 13 | ||
| 14 | CREATE TABLE push_queue ( | |
| 15 | id INTEGER PRIMARY KEY, | |
| 16 | device_id INTEGER NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE, | |
| 17 | title TEXT NOT NULL, | |
| 18 | body TEXT NOT NULL, | |
| 19 | path TEXT NOT NULL, | |
| 20 | attempts INTEGER NOT NULL DEFAULT 0, | |
| 21 | next_attempt_at TEXT, | |
| 22 | sent_at TEXT, | |
| 23 | failed_at TEXT, | |
| 24 | last_error TEXT, | |
| 25 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 26 | ); | |
| 27 | CREATE INDEX push_queue_due ON push_queue(next_attempt_at) | |
| 28 | WHERE sent_at IS NULL AND failed_at IS NULL; | |
| 29 | ||
| 30 | -- Whether activity reaches the account's registered devices. Defaults on | |
| 31 | -- and costs nothing for an account with no devices; it exists so a user | |
| 32 | -- with a phone and an iPad silences both without deregistering each. | |
| 33 | ALTER TABLE users ADD COLUMN notify_push INTEGER NOT NULL DEFAULT 1; | |
internal/store/push.go added +192
| @@ -0,0 +1,192 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "errors" | |
| 6 | "time" | |
| 7 | ) | |
| 8 | ||
| 9 | // PushDevice is one Apple device an account has registered. Token is the | |
| 10 | // APNs device token: an address, not a credential, but device-identifying | |
| 11 | // and never logged or echoed in full. | |
| 12 | type PushDevice struct { | |
| 13 | ID int64 | |
| 14 | UserID int64 | |
| 15 | Token string | |
| 16 | Label string | |
| 17 | CreatedAt string | |
| 18 | LastSeenAt string | |
| 19 | } | |
| 20 | ||
| 21 | // AddPushDevice registers a token to an account. A token already present | |
| 22 | // changes hands rather than erroring: Apple reuses tokens, and a reinstall | |
| 23 | // hands the same one to whichever account signs in next. The id is read | |
| 24 | // back by token rather than taken from LastInsertId, which SQLite leaves | |
| 25 | // unchanged when the DO UPDATE arm fires instead of the INSERT. | |
| 26 | // | |
| 27 | // The row id survives that handover, so queue rows written for the | |
| 28 | // previous owner would still be delivered to the device — and an alert | |
| 29 | // carries the repository name and item number in full. Undelivered rows | |
| 30 | // go with the ownership, in the same transaction; sent and dead-lettered | |
| 31 | // rows are history and stay. | |
| 32 | func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error) { | |
| 33 | tx, err := s.DB.Begin() | |
| 34 | if err != nil { | |
| 35 | return 0, err | |
| 36 | } | |
| 37 | defer tx.Rollback() | |
| 38 | var prev int64 | |
| 39 | if err := tx.QueryRow("SELECT user_id FROM push_devices WHERE token = ?", token).Scan(&prev); err != nil && !errors.Is(err, sql.ErrNoRows) { | |
| 40 | return 0, err | |
| 41 | } | |
| 42 | if _, err := tx.Exec(` | |
| 43 | INSERT INTO push_devices (user_id, token, label) VALUES (?, ?, ?) | |
| 44 | ON CONFLICT(token) DO UPDATE SET user_id = excluded.user_id, label = excluded.label`, | |
| 45 | userID, token, label); err != nil { | |
| 46 | return 0, err | |
| 47 | } | |
| 48 | var id int64 | |
| 49 | if err := tx.QueryRow("SELECT id FROM push_devices WHERE token = ?", token).Scan(&id); err != nil { | |
| 50 | return 0, err | |
| 51 | } | |
| 52 | if prev != 0 && prev != userID { | |
| 53 | if _, err := tx.Exec( | |
| 54 | "DELETE FROM push_queue WHERE device_id = ? AND sent_at IS NULL AND failed_at IS NULL", id); err != nil { | |
| 55 | return 0, err | |
| 56 | } | |
| 57 | } | |
| 58 | return id, tx.Commit() | |
| 59 | } | |
| 60 | ||
| 61 | func (s *Store) PushDevices(userID int64) ([]PushDevice, error) { | |
| 62 | rows, err := s.DB.Query(` | |
| 63 | SELECT id, user_id, token, label, created_at, COALESCE(last_seen_at, '') | |
| 64 | FROM push_devices WHERE user_id = ? ORDER BY id`, userID) | |
| 65 | if err != nil { | |
| 66 | return nil, err | |
| 67 | } | |
| 68 | defer rows.Close() | |
| 69 | var out []PushDevice | |
| 70 | for rows.Next() { | |
| 71 | var d PushDevice | |
| 72 | if err := rows.Scan(&d.ID, &d.UserID, &d.Token, &d.Label, &d.CreatedAt, &d.LastSeenAt); err != nil { | |
| 73 | return nil, err | |
| 74 | } | |
| 75 | out = append(out, d) | |
| 76 | } | |
| 77 | return out, rows.Err() | |
| 78 | } | |
| 79 | ||
| 80 | // RemovePushDevice deletes one of the account's own devices. Scoping the | |
| 81 | // delete by user_id rather than checking ownership first means another | |
| 82 | // account's id is ErrNotFound, which is the same answer as an id that | |
| 83 | // never existed — a caller learns nothing about other accounts' devices. | |
| 84 | func (s *Store) RemovePushDevice(userID, id int64) error { | |
| 85 | res, err := s.DB.Exec("DELETE FROM push_devices WHERE id = ? AND user_id = ?", id, userID) | |
| 86 | if err != nil { | |
| 87 | return err | |
| 88 | } | |
| 89 | n, err := res.RowsAffected() | |
| 90 | if err != nil { | |
| 91 | return err | |
| 92 | } | |
| 93 | if n == 0 { | |
| 94 | return ErrNotFound | |
| 95 | } | |
| 96 | return nil | |
| 97 | } | |
| 98 | ||
| 99 | func (s *Store) PushEnabled(userID int64) (bool, error) { | |
| 100 | var on int | |
| 101 | err := s.DB.QueryRow("SELECT notify_push FROM users WHERE id = ?", userID).Scan(&on) | |
| 102 | if errors.Is(err, sql.ErrNoRows) { | |
| 103 | return false, ErrNotFound | |
| 104 | } | |
| 105 | return on != 0, err | |
| 106 | } | |
| 107 | ||
| 108 | func (s *Store) SetPushEnabled(userID int64, on bool) error { | |
| 109 | v := 0 | |
| 110 | if on { | |
| 111 | v = 1 | |
| 112 | } | |
| 113 | _, err := s.DB.Exec("UPDATE users SET notify_push = ? WHERE id = ?", v, userID) | |
| 114 | return err | |
| 115 | } | |
| 116 | ||
| 117 | // QueuedPush is one pending push, joined to the token it is bound for so | |
| 118 | // the drainer needs one query rather than two. | |
| 119 | type QueuedPush struct { | |
| 120 | ID int64 | |
| 121 | DeviceID int64 | |
| 122 | Token string | |
| 123 | Title string | |
| 124 | Body string | |
| 125 | Path string | |
| 126 | Attempts int | |
| 127 | } | |
| 128 | ||
| 129 | // EnqueuePush writes one row per registered device, and nothing when the | |
| 130 | // account has push off or no devices — the same shape as | |
| 131 | // ActivityMailAddress returning "" when notify_mail is off. Mute, watch | |
| 132 | // and actor-exclusion are already settled by NotifyRecipients before a | |
| 133 | // caller reaches here. | |
| 134 | func (s *Store) EnqueuePush(userID int64, title, body, path string) error { | |
| 135 | on, err := s.PushEnabled(userID) | |
| 136 | if err != nil || !on { | |
| 137 | return err | |
| 138 | } | |
| 139 | _, err = s.DB.Exec(` | |
| 140 | INSERT INTO push_queue (device_id, title, body, path) | |
| 141 | SELECT id, ?, ?, ? FROM push_devices WHERE user_id = ?`, | |
| 142 | title, body, path, userID) | |
| 143 | return err | |
| 144 | } | |
| 145 | ||
| 146 | func (s *Store) DuePush(limit int) ([]QueuedPush, error) { | |
| 147 | rows, err := s.DB.Query(` | |
| 148 | SELECT q.id, q.device_id, d.token, q.title, q.body, q.path, q.attempts | |
| 149 | FROM push_queue q JOIN push_devices d ON d.id = q.device_id | |
| 150 | WHERE q.sent_at IS NULL AND q.failed_at IS NULL | |
| 151 | AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?) | |
| 152 | ORDER BY q.id LIMIT ?`, fmtTime(time.Now()), limit) | |
| 153 | if err != nil { | |
| 154 | return nil, err | |
| 155 | } | |
| 156 | defer rows.Close() | |
| 157 | var out []QueuedPush | |
| 158 | for rows.Next() { | |
| 159 | var p QueuedPush | |
| 160 | if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Title, &p.Body, &p.Path, &p.Attempts); err != nil { | |
| 161 | return nil, err | |
| 162 | } | |
| 163 | out = append(out, p) | |
| 164 | } | |
| 165 | return out, rows.Err() | |
| 166 | } | |
| 167 | ||
| 168 | func (s *Store) MarkPushSent(id int64) error { | |
| 169 | _, err := s.DB.Exec( | |
| 170 | "UPDATE push_queue SET sent_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1 WHERE id = ?", id) | |
| 171 | return err | |
| 172 | } | |
| 173 | ||
| 174 | func (s *Store) MarkPushFailed(id int64, errMsg string, nextAt *time.Time) error { | |
| 175 | if nextAt == nil { | |
| 176 | _, err := s.DB.Exec( | |
| 177 | "UPDATE push_queue SET failed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1, last_error = ? WHERE id = ?", | |
| 178 | errMsg, id) | |
| 179 | return err | |
| 180 | } | |
| 181 | _, err := s.DB.Exec( | |
| 182 | "UPDATE push_queue SET attempts = attempts + 1, last_error = ?, next_attempt_at = ? WHERE id = ?", | |
| 183 | errMsg, fmtTime(*nextAt), id) | |
| 184 | return err | |
| 185 | } | |
| 186 | ||
| 187 | // DeletePushDeviceByToken drops a device Apple has told us is gone. The | |
| 188 | // queue rows cascade, so nothing is left retrying at a dead token. | |
| 189 | func (s *Store) DeletePushDeviceByToken(token string) error { | |
| 190 | _, err := s.DB.Exec("DELETE FROM push_devices WHERE token = ?", token) | |
| 191 | return err | |
| 192 | } | |
internal/store/push_test.go added +249
| @@ -0,0 +1,249 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "testing" | |
| 5 | "time" | |
| 6 | ) | |
| 7 | ||
| 8 | func pushFixture(t *testing.T) *Store { | |
| 9 | t.Helper() | |
| 10 | s := open(t) | |
| 11 | if err := s.MigrateUp(); err != nil { | |
| 12 | t.Fatal(err) | |
| 13 | } | |
| 14 | return s | |
| 15 | } | |
| 16 | ||
| 17 | func TestPushDevices(t *testing.T) { | |
| 18 | s := pushFixture(t) | |
| 19 | uid, err := s.CreateUser("alice", false) | |
| 20 | if err != nil { | |
| 21 | t.Fatal(err) | |
| 22 | } | |
| 23 | ||
| 24 | firstID, err := s.AddPushDevice(uid, "tok-a", "iphone") | |
| 25 | if err != nil { | |
| 26 | t.Fatalf("AddPushDevice: %v", err) | |
| 27 | } | |
| 28 | devices, err := s.PushDevices(uid) | |
| 29 | if err != nil { | |
| 30 | t.Fatalf("PushDevices: %v", err) | |
| 31 | } | |
| 32 | if len(devices) != 1 || devices[0].Token != "tok-a" || devices[0].Label != "iphone" { | |
| 33 | t.Fatalf("got %+v", devices) | |
| 34 | } | |
| 35 | if firstID != devices[0].ID { | |
| 36 | t.Fatalf("AddPushDevice returned %d, row id is %d", firstID, devices[0].ID) | |
| 37 | } | |
| 38 | ||
| 39 | // Apple reuses tokens: re-registering updates the label and the owner | |
| 40 | // rather than erroring, so a reinstall under another account works. The | |
| 41 | // returned id must be the existing row's, not an unrelated rowid left | |
| 42 | // over from SQLite's last real INSERT (the DO UPDATE arm does not | |
| 43 | // advance last_insert_rowid()). | |
| 44 | bob, err := s.CreateUser("bob", false) | |
| 45 | if err != nil { | |
| 46 | t.Fatal(err) | |
| 47 | } | |
| 48 | reregID, err := s.AddPushDevice(bob, "tok-a", "ipad") | |
| 49 | if err != nil { | |
| 50 | t.Fatalf("re-register: %v", err) | |
| 51 | } | |
| 52 | if d, _ := s.PushDevices(uid); len(d) != 0 { | |
| 53 | t.Fatalf("token still owned by alice: %+v", d) | |
| 54 | } | |
| 55 | d, _ := s.PushDevices(bob) | |
| 56 | if len(d) != 1 || d[0].Label != "ipad" { | |
| 57 | t.Fatalf("got %+v", d) | |
| 58 | } | |
| 59 | if reregID != d[0].ID { | |
| 60 | t.Fatalf("re-register returned %d, existing row id is %d", reregID, d[0].ID) | |
| 61 | } | |
| 62 | if reregID != firstID { | |
| 63 | t.Fatalf("re-register returned %d, want the reused row's original id %d", reregID, firstID) | |
| 64 | } | |
| 65 | ||
| 66 | // Removal is scoped to the owner: alice cannot remove bob's device. | |
| 67 | if err := s.RemovePushDevice(uid, d[0].ID); err != ErrNotFound { | |
| 68 | t.Fatalf("cross-account remove: got %v, want ErrNotFound", err) | |
| 69 | } | |
| 70 | if err := s.RemovePushDevice(bob, d[0].ID); err != nil { | |
| 71 | t.Fatalf("RemovePushDevice: %v", err) | |
| 72 | } | |
| 73 | if d, _ := s.PushDevices(bob); len(d) != 0 { | |
| 74 | t.Fatalf("device survived removal: %+v", d) | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | func TestPushEnabledDefaultsOn(t *testing.T) { | |
| 79 | s := pushFixture(t) | |
| 80 | uid, err := s.CreateUser("alice", false) | |
| 81 | if err != nil { | |
| 82 | t.Fatal(err) | |
| 83 | } | |
| 84 | on, err := s.PushEnabled(uid) | |
| 85 | if err != nil { | |
| 86 | t.Fatalf("PushEnabled: %v", err) | |
| 87 | } | |
| 88 | if !on { | |
| 89 | t.Fatal("notify_push should default on") | |
| 90 | } | |
| 91 | if err := s.SetPushEnabled(uid, false); err != nil { | |
| 92 | t.Fatalf("SetPushEnabled: %v", err) | |
| 93 | } | |
| 94 | if on, _ := s.PushEnabled(uid); on { | |
| 95 | t.Fatal("SetPushEnabled(false) did not stick") | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | func TestEnqueuePush(t *testing.T) { | |
| 100 | s := pushFixture(t) | |
| 101 | uid, err := s.CreateUser("alice", false) | |
| 102 | if err != nil { | |
| 103 | t.Fatal(err) | |
| 104 | } | |
| 105 | s.AddPushDevice(uid, "tok-a", "iphone") | |
| 106 | s.AddPushDevice(uid, "tok-b", "ipad") | |
| 107 | ||
| 108 | // One row per device, so a retry to the phone does not resend to the | |
| 109 | // iPad. | |
| 110 | if err := s.EnqueuePush(uid, "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil { | |
| 111 | t.Fatalf("EnqueuePush: %v", err) | |
| 112 | } | |
| 113 | due, err := s.DuePush(20) | |
| 114 | if err != nil { | |
| 115 | t.Fatalf("DuePush: %v", err) | |
| 116 | } | |
| 117 | if len(due) != 2 { | |
| 118 | t.Fatalf("want a row per device, got %d", len(due)) | |
| 119 | } | |
| 120 | if due[0].Token == "" || due[0].Body != "cmc opened issue #12" { | |
| 121 | t.Fatalf("got %+v", due[0]) | |
| 122 | } | |
| 123 | ||
| 124 | // Sent rows stop being due. | |
| 125 | if err := s.MarkPushSent(due[0].ID); err != nil { | |
| 126 | t.Fatalf("MarkPushSent: %v", err) | |
| 127 | } | |
| 128 | if due, _ := s.DuePush(20); len(due) != 1 { | |
| 129 | t.Fatalf("sent row still due") | |
| 130 | } | |
| 131 | ||
| 132 | // A failure with a next attempt in the future is not due yet. | |
| 133 | next := time.Now().Add(time.Hour) | |
| 134 | if err := s.MarkPushFailed(due[1].ID, "503", &next); err != nil { | |
| 135 | t.Fatalf("MarkPushFailed: %v", err) | |
| 136 | } | |
| 137 | if due, _ := s.DuePush(20); len(due) != 0 { | |
| 138 | t.Fatalf("backed-off row is due too early") | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | func TestEnqueuePushRespectsSettingAndDevices(t *testing.T) { | |
| 143 | s := pushFixture(t) | |
| 144 | uid, err := s.CreateUser("alice", false) | |
| 145 | if err != nil { | |
| 146 | t.Fatal(err) | |
| 147 | } | |
| 148 | ||
| 149 | // No devices: nothing queued, no error. | |
| 150 | if err := s.EnqueuePush(uid, "t", "b", "p"); err != nil { | |
| 151 | t.Fatalf("EnqueuePush with no devices: %v", err) | |
| 152 | } | |
| 153 | if due, _ := s.DuePush(20); len(due) != 0 { | |
| 154 | t.Fatalf("queued for an account with no devices") | |
| 155 | } | |
| 156 | ||
| 157 | // Setting off: nothing queued. | |
| 158 | s.AddPushDevice(uid, "tok-a", "iphone") | |
| 159 | s.SetPushEnabled(uid, false) | |
| 160 | if err := s.EnqueuePush(uid, "t", "b", "p"); err != nil { | |
| 161 | t.Fatalf("EnqueuePush with push off: %v", err) | |
| 162 | } | |
| 163 | if due, _ := s.DuePush(20); len(due) != 0 { | |
| 164 | t.Fatalf("queued with notify_push off") | |
| 165 | } | |
| 166 | } | |
| 167 | ||
| 168 | // A token changing hands takes its undelivered queue with it. The row id | |
| 169 | // survives the upsert, so anything queued for the previous owner would | |
| 170 | // otherwise be delivered to a phone that now belongs to someone else — | |
| 171 | // and an alert carries the repository name and item number in full. The | |
| 172 | // iOS app calls device add on every sign-in, which is exactly when | |
| 173 | // ownership changes. | |
| 174 | func TestAddPushDeviceDropsThePreviousOwnersQueue(t *testing.T) { | |
| 175 | s := pushFixture(t) | |
| 176 | alice, err := s.CreateUser("alice", false) | |
| 177 | if err != nil { | |
| 178 | t.Fatal(err) | |
| 179 | } | |
| 180 | bob, err := s.CreateUser("bob", false) | |
| 181 | if err != nil { | |
| 182 | t.Fatal(err) | |
| 183 | } | |
| 184 | id, err := s.AddPushDevice(alice, "tok-a", "iphone") | |
| 185 | if err != nil { | |
| 186 | t.Fatal(err) | |
| 187 | } | |
| 188 | if err := s.EnqueuePush(alice, "alice/secret", "alice opened issue #1", "alice/secret/issues/1"); err != nil { | |
| 189 | t.Fatal(err) | |
| 190 | } | |
| 191 | due, err := s.DuePush(20) | |
| 192 | if err != nil || len(due) != 1 { | |
| 193 | t.Fatalf("DuePush: %v %+v", err, due) | |
| 194 | } | |
| 195 | // A second row, already sent: history, not a pending delivery. | |
| 196 | if err := s.EnqueuePush(alice, "alice/secret", "alice closed issue #1", "alice/secret/issues/1"); err != nil { | |
| 197 | t.Fatal(err) | |
| 198 | } | |
| 199 | sent, _ := s.DuePush(20) | |
| 200 | if err := s.MarkPushSent(sent[len(sent)-1].ID); err != nil { | |
| 201 | t.Fatal(err) | |
| 202 | } | |
| 203 | ||
| 204 | if _, err := s.AddPushDevice(bob, "tok-a", "iphone"); err != nil { | |
| 205 | t.Fatalf("re-register: %v", err) | |
| 206 | } | |
| 207 | if due, _ := s.DuePush(20); len(due) != 0 { | |
| 208 | t.Fatalf("alice's pending push survived the handover: %+v", due) | |
| 209 | } | |
| 210 | var kept int | |
| 211 | s.DB.QueryRow("SELECT COUNT(*) FROM push_queue WHERE device_id = ? AND sent_at IS NOT NULL", id).Scan(&kept) | |
| 212 | if kept != 1 { | |
| 213 | t.Fatalf("delivered rows deleted too: %d remain", kept) | |
| 214 | } | |
| 215 | ||
| 216 | // Re-registering to the same owner leaves the queue alone: the app | |
| 217 | // calls device add on every launch. | |
| 218 | if err := s.EnqueuePush(bob, "bob/app", "bob opened issue #2", "bob/app/issues/2"); err != nil { | |
| 219 | t.Fatal(err) | |
| 220 | } | |
| 221 | if _, err := s.AddPushDevice(bob, "tok-a", "iphone"); err != nil { | |
| 222 | t.Fatal(err) | |
| 223 | } | |
| 224 | if due, _ := s.DuePush(20); len(due) != 1 { | |
| 225 | t.Fatalf("re-registering to the same owner dropped its own queue: %+v", due) | |
| 226 | } | |
| 227 | } | |
| 228 | ||
| 229 | func TestDeletePushDeviceByTokenTakesItsQueue(t *testing.T) { | |
| 230 | s := pushFixture(t) | |
| 231 | uid, err := s.CreateUser("alice", false) | |
| 232 | if err != nil { | |
| 233 | t.Fatal(err) | |
| 234 | } | |
| 235 | s.AddPushDevice(uid, "tok-a", "iphone") | |
| 236 | s.EnqueuePush(uid, "t", "b", "p") | |
| 237 | ||
| 238 | if err := s.DeletePushDeviceByToken("tok-a"); err != nil { | |
| 239 | t.Fatalf("DeletePushDeviceByToken: %v", err) | |
| 240 | } | |
| 241 | if d, _ := s.PushDevices(uid); len(d) != 0 { | |
| 242 | t.Fatalf("device survived") | |
| 243 | } | |
| 244 | // push_queue.device_id is ON DELETE CASCADE, so the queued rows go | |
| 245 | // with it rather than being retried at a dead token forever. | |
| 246 | if due, _ := s.DuePush(20); len(due) != 0 { | |
| 247 | t.Fatalf("queued rows outlived their device") | |
| 248 | } | |
| 249 | } | |
internal/store/queues.go +43
| @@ -11,6 +11,7 @@ type Queues struct { | ||
| 11 | 11 | Mirrors QueueMirrors `json:"mirrors"` |
| 12 | 12 | Builds QueueBuilds `json:"builds"` |
| 13 | 13 | Deps QueueDeps `json:"deps"` |
| 14 | Push QueuePush `json:"push"` | |
| 14 | 15 | } |
| 15 | 16 | |
| 16 | 17 | type QueueWebhooks struct { |
| @@ -50,6 +51,26 @@ type QueueMailRow struct { | ||
| 50 | 51 | CreatedAt string `json:"created_at"` |
| 51 | 52 | } |
| 52 | 53 | |
| 54 | // QueuePush is the APNs delivery queue, the mail queue's shape with the | |
| 55 | // device id where the recipient is: a device token is never echoed. | |
| 56 | type QueuePush struct { | |
| 57 | Pending int64 `json:"pending"` | |
| 58 | Retrying int64 `json:"retrying"` | |
| 59 | Failed int64 `json:"failed"` | |
| 60 | OldestPending string `json:"oldest_pending,omitempty"` | |
| 61 | Items []QueuePushRow `json:"items"` | |
| 62 | } | |
| 63 | ||
| 64 | type QueuePushRow struct { | |
| 65 | ID int64 `json:"id"` | |
| 66 | DeviceID int64 `json:"device_id"` | |
| 67 | Title string `json:"title"` | |
| 68 | Attempts int64 `json:"attempts"` | |
| 69 | LastError string `json:"last_error,omitempty"` | |
| 70 | FailedAt string `json:"failed_at,omitempty"` | |
| 71 | CreatedAt string `json:"created_at"` | |
| 72 | } | |
| 73 | ||
| 53 | 74 | type QueueMirrors struct { |
| 54 | 75 | Dirty int64 `json:"dirty"` // waiting for a sync |
| 55 | 76 | Errors int64 `json:"errors"` |
| @@ -112,6 +133,7 @@ func (s *Store) QueueStatus() (Queues, error) { | ||
| 112 | 133 | Mirrors: QueueMirrors{Items: []QueueMirrorRow{}}, |
| 113 | 134 | Builds: QueueBuilds{Items: []QueueBuildRow{}}, |
| 114 | 135 | Deps: QueueDeps{Items: []QueueDepRow{}}, |
| 136 | Push: QueuePush{Items: []QueuePushRow{}}, | |
| 115 | 137 | } |
| 116 | 138 | |
| 117 | 139 | if err := s.DB.QueryRow(`SELECT |
| @@ -191,6 +213,27 @@ func (s *Store) QueueStatus() (Queues, error) { | ||
| 191 | 213 | return q, err |
| 192 | 214 | } |
| 193 | 215 | |
| 216 | if err := s.DB.QueryRow(`SELECT | |
| 217 | COUNT(*) FILTER (WHERE sent_at IS NULL AND failed_at IS NULL), | |
| 218 | COUNT(*) FILTER (WHERE sent_at IS NULL AND failed_at IS NULL AND attempts > 0), | |
| 219 | COUNT(*) FILTER (WHERE failed_at IS NOT NULL), | |
| 220 | COALESCE(MIN(created_at) FILTER (WHERE sent_at IS NULL AND failed_at IS NULL), '') | |
| 221 | FROM push_queue`).Scan(&q.Push.Pending, &q.Push.Retrying, &q.Push.Failed, &q.Push.OldestPending); err != nil { | |
| 222 | return q, err | |
| 223 | } | |
| 224 | if err := s.queryEach(`SELECT id, device_id, title, attempts, COALESCE(last_error, ''), COALESCE(failed_at, ''), created_at | |
| 225 | FROM push_queue WHERE sent_at IS NULL AND (failed_at IS NOT NULL OR attempts > 0) | |
| 226 | ORDER BY id DESC LIMIT ?`, func(sc scanner) error { | |
| 227 | var p QueuePushRow | |
| 228 | if err := sc.Scan(&p.ID, &p.DeviceID, &p.Title, &p.Attempts, &p.LastError, &p.FailedAt, &p.CreatedAt); err != nil { | |
| 229 | return err | |
| 230 | } | |
| 231 | q.Push.Items = append(q.Push.Items, p) | |
| 232 | return nil | |
| 233 | }); err != nil { | |
| 234 | return q, err | |
| 235 | } | |
| 236 | ||
| 194 | 237 | if err := s.DB.QueryRow(`SELECT COUNT(*) FROM dep_checks WHERE last_error != ''`).Scan(&q.Deps.Errors); err != nil { |
| 195 | 238 | return q, err |
| 196 | 239 | } |
internal/store/queues_test.go +71 −1
| @@ -1,6 +1,76 @@ | ||
| 1 | 1 | package store |
| 2 | 2 | |
| 3 | import "testing" | |
| 3 | import ( | |
| 4 | "testing" | |
| 5 | "time" | |
| 6 | ) | |
| 7 | ||
| 8 | // Push is a worker queue like the others, and the one failure config | |
| 9 | // validation cannot catch — a key_id Apple did not issue — dead-letters | |
| 10 | // every row on its first attempt. Without a count and the rows here an | |
| 11 | // admin has no way to see that happening. | |
| 12 | func TestQueuesReportsPush(t *testing.T) { | |
| 13 | s := open(t) | |
| 14 | if err := s.MigrateUp(); err != nil { | |
| 15 | t.Fatal(err) | |
| 16 | } | |
| 17 | uid, err := s.CreateUser("cmc", true) | |
| 18 | if err != nil { | |
| 19 | t.Fatal(err) | |
| 20 | } | |
| 21 | id, err := s.AddPushDevice(uid, "tok-a", "iphone") | |
| 22 | if err != nil { | |
| 23 | t.Fatal(err) | |
| 24 | } | |
| 25 | for i := 0; i < 3; i++ { | |
| 26 | if err := s.EnqueuePush(uid, "krz/gitbay", "cmc opened issue #1", "krz/gitbay/issues/1"); err != nil { | |
| 27 | t.Fatal(err) | |
| 28 | } | |
| 29 | } | |
| 30 | due, err := s.DuePush(20) | |
| 31 | if err != nil { | |
| 32 | t.Fatal(err) | |
| 33 | } | |
| 34 | if len(due) != 3 { | |
| 35 | t.Fatalf("queued %d, want 3", len(due)) | |
| 36 | } | |
| 37 | next := time.Now().Add(time.Minute) | |
| 38 | if err := s.MarkPushFailed(due[0].ID, "apns 503", &next); err != nil { | |
| 39 | t.Fatal(err) | |
| 40 | } | |
| 41 | if err := s.MarkPushFailed(due[1].ID, "apns 403 InvalidProviderToken", nil); err != nil { | |
| 42 | t.Fatal(err) | |
| 43 | } | |
| 44 | ||
| 45 | q, err := s.QueueStatus() | |
| 46 | if err != nil { | |
| 47 | t.Fatal(err) | |
| 48 | } | |
| 49 | if q.Push.Pending != 2 || q.Push.Retrying != 1 || q.Push.Failed != 1 { | |
| 50 | t.Fatalf("counts: %+v", q.Push) | |
| 51 | } | |
| 52 | if q.Push.OldestPending == "" { | |
| 53 | t.Fatalf("no oldest pending: %+v", q.Push) | |
| 54 | } | |
| 55 | // Retrying and dead-lettered rows, newest first, as the mail queue | |
| 56 | // lists them. | |
| 57 | if len(q.Push.Items) != 2 { | |
| 58 | t.Fatalf("items: %+v", q.Push.Items) | |
| 59 | } | |
| 60 | if q.Push.Items[0].DeviceID != id || q.Push.Items[0].FailedAt == "" || | |
| 61 | q.Push.Items[0].LastError != "apns 403 InvalidProviderToken" { | |
| 62 | t.Fatalf("dead-lettered row: %+v", q.Push.Items[0]) | |
| 63 | } | |
| 64 | if q.Push.Items[1].Attempts != 1 || q.Push.Items[1].FailedAt != "" { | |
| 65 | t.Fatalf("retrying row: %+v", q.Push.Items[1]) | |
| 66 | } | |
| 67 | // A device token is never echoed, here included. | |
| 68 | for _, it := range q.Push.Items { | |
| 69 | if it.Title != "krz/gitbay" || it.CreatedAt == "" { | |
| 70 | t.Fatalf("row: %+v", it) | |
| 71 | } | |
| 72 | } | |
| 73 | } | |
| 4 | 74 | |
| 5 | 75 | // The build queue lists pending builds as well as running ones, so an |
| 6 | 76 | // admin can see what no runner is claiming without walking every repo. |
internal/store/retention.go +2
| @@ -32,6 +32,7 @@ type Retention struct { | ||
| 32 | 32 | Events time.Duration |
| 33 | 33 | WebhookDeliveries time.Duration |
| 34 | 34 | Mail time.Duration |
| 35 | Push time.Duration | |
| 35 | 36 | } |
| 36 | 37 | |
| 37 | 38 | // Sweep deletes expired sessions and tokens, then the rows older than |
| @@ -79,6 +80,7 @@ func (s *Store) Sweep(r Retention, now time.Time) (Swept, error) { | ||
| 79 | 80 | SELECT 1 FROM webhook_deliveries d |
| 80 | 81 | WHERE d.event_id = events.id AND d.delivered_at IS NULL AND d.failed_at IS NULL)`, r.Events}, |
| 81 | 82 | {"notifications", "created_at < ? AND (sent_at IS NOT NULL OR failed_at IS NOT NULL)", r.Mail}, |
| 83 | {"push_queue", "created_at < ? AND (sent_at IS NOT NULL OR failed_at IS NOT NULL)", r.Push}, | |
| 82 | 84 | } |
| 83 | 85 | for _, a := range aged { |
| 84 | 86 | if a.keep <= 0 { |
internal/web/templates/account.html +21
| @@ -144,6 +144,27 @@ account and where notifications go.</p> | ||
| 144 | 144 | <button type="submit" class="btn">Save</button> |
| 145 | 145 | </form> |
| 146 | 146 | <p class="meta">Alerts for every issue and merge request on those repositories. A watch or mute on a repository has priority over this setting.</p> |
| 147 | <form method="post" action="/settings" class="setform"> | |
| 148 | <input type="hidden" name="field" value="notify-push"> | |
| 149 | <label for="notify-push">Activity on your registered devices</label> | |
| 150 | <input type="checkbox" id="notify-push" name="push" value="on"{{if .PushOn}} checked{{end}}> | |
| 151 | <button type="submit" class="btn">Save</button> | |
| 152 | </form> | |
| 153 | <p class="meta">Notification text is sent in full, including for private repositories, so a repository name and item number reach Apple and appear on a lock screen.</p> | |
| 154 | <h3>Devices</h3> | |
| 155 | {{if .Devices}}<div class="tablewrap"><table class="keys nowrap"> | |
| 156 | <tr class="cols"><th scope="col">id</th><th scope="col">label</th><th scope="col">token</th><th scope="col">last seen</th><th scope="col"><span class="vh">actions</span></th></tr> | |
| 157 | {{range .Devices}}<tr> | |
| 158 | <td class="mono">{{.ID}}</td> | |
| 159 | <td>{{.Label}}</td> | |
| 160 | <td class="mono">{{.Token}}</td> | |
| 161 | <td>{{if .LastSeenAt}}{{when .LastSeenAt}}{{else}}never{{end}}</td> | |
| 162 | <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="device-remove"><input type="hidden" name="id" value="{{.ID}}">{{template "confirmfield" .Confirm}} <button type="submit" class="danger">Remove</button></form></td> | |
| 163 | </tr> | |
| 164 | {{end}}</table></div> | |
| 165 | {{else}}<p class="none">No registered devices.</p>{{end}} | |
| 166 | <p class="meta">Devices register themselves from the iOS app; there is no | |
| 167 | form here to add one, since a browser cannot produce an APNs token.</p> | |
| 147 | 168 | </section> |
| 148 | 169 | |
| 149 | 170 | <section id="appearance"><h2>Appearance</h2> |
internal/web/templates/admin.html +12
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | <ul> |
| 14 | 14 | <li><a href="#webhooks">Webhook deliveries</a></li> |
| 15 | 15 | <li><a href="#mail">Mail</a></li> |
| 16 | <li><a href="#push">Push</a></li> | |
| 16 | 17 | <li><a href="#mirrors">Mirrors</a></li> |
| 17 | 18 | <li><a href="#builds">Builds</a></li> |
| 18 | 19 | <li><a href="#deps">Dependency checks</a></li> |
| @@ -41,6 +42,17 @@ | ||
| 41 | 42 | </section> |
| 42 | 43 | {{end}} |
| 43 | 44 | |
| 45 | {{/* A push row names the device id, never the token, as dashboard does. */}} | |
| 46 | {{with .Queues.Push}} | |
| 47 | <section id="push"> | |
| 48 | <h2>Push <span class="count">{{.Pending}}</span></h2> | |
| 49 | <p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} failed{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p> | |
| 50 | {{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Push</th><th>Device</th><th>Title</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody> | |
| 51 | {{range .Items}}<tr><td class="mono">{{.ID}}</td><td class="mono">device {{.DeviceID}}</td><td>{{.Title}}</td><td>{{.Attempts}}</td><td>{{if .FailedAt}}failed {{when .FailedAt}}{{else}}retrying{{end}}</td><td>{{.LastError}}</td></tr> | |
| 52 | {{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or failed</p>{{end}} | |
| 53 | </section> | |
| 54 | {{end}} | |
| 55 | ||
| 44 | 56 | {{with .Queues.Mirrors}} |
| 45 | 57 | <section id="mirrors"> |
| 46 | 58 | <h2>Mirrors <span class="count">{{.Errors}}</span></h2> |