Commit 74936a4b62

74936a4b62df976125c73f56719d4216ee6aeab7

parent: 6a187ab6f5

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 09:55 UTC

control: notifications device and settings push

Token on stdin, never argv. device list truncates the token to eight
characters: enough to tell two devices apart, not enough to push to
one. settings show gains a third key.

Ref #89
cmd/gitbay/main.go +9
@@ -70,6 +70,15 @@ func newRoot() *cobra.Command {
7070 pass("show", "your notification preferences", passOpts{server: []string{"notifications", "settings", "show"}}),
7171 pass("mail", "activity by mail as well as the inbox: on|off", passOpts{server: []string{"notifications", "settings", "mail"}}),
7272 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"}}),
7382 ),
7483 ),
7584 group("wiki", "a repository's wiki pages",
internal/control/notifications.go +118 −2
@@ -1,6 +1,7 @@
11package control
22
33import (
4 "errors"
45 "fmt"
56 "io"
67 "strconv"
@@ -30,6 +31,22 @@ func init() {
3031 register(Command{Path: []string{"notifications", "settings", "watch"},
3132 Summary: "every issue and merge request on repositories you can write to",
3233 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})
3350 register(Command{Path: []string{"repo", "watch"},
3451 Summary: "hear about all activity on a repository",
3552 Usage: "repo watch <owner/name>", Run: runRepoWatch})
@@ -155,14 +172,18 @@ func emitNotificationSettings(c *Ctx) int {
155172 if err != nil {
156173 return c.fail(protocol.ExitFailure, "%v", err)
157174 }
158 return c.emit(map[string]bool{"mail": mail, "watch": watch}, func(w io.Writer) {
175 push, err := c.Store.PushEnabled(c.User.ID)
176 if err != nil {
177 return c.fail(protocol.ExitFailure, "%v", err)
178 }
179 return c.emit(map[string]bool{"mail": mail, "watch": watch, "push": push}, func(w io.Writer) {
159180 onOff := func(on bool) string {
160181 if on {
161182 return "on"
162183 }
163184 return "off"
164185 }
165 fmt.Fprintf(w, "mail: %s\nwatch: %s\n", onOff(mail), onOff(watch))
186 fmt.Fprintf(w, "mail: %s\nwatch: %s\npush: %s\n", onOff(mail), onOff(watch), onOff(push))
166187 })
167188}
168189
@@ -198,6 +219,101 @@ func runNotificationsSettingsWatch(c *Ctx, args []string) int {
198219 return emitNotificationSettings(c)
199220}
200221
222func runNotificationsSettingsPush(c *Ctx, args []string) int {
223 if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
224 return c.usage()
225 }
226 if err := c.Store.SetPushEnabled(c.User.ID, args[0] == "on"); err != nil {
227 return c.fail(protocol.ExitFailure, "%v", err)
228 }
229 return emitNotificationSettings(c)
230}
231
232// maxDeviceTokenBytes is well past APNs' 32-byte token rendered as 64 hex
233// characters, and stops a stdin that is not a token from becoming a row.
234const maxDeviceTokenBytes = 512
235
236func runNotificationsDeviceAdd(c *Ctx, args []string) int {
237 f, err := parseFlags(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage})
238 if err != nil {
239 return c.fail(protocol.ExitUsage, "%v", err)
240 }
241 if len(f.Pos) != 0 {
242 return c.usage()
243 }
244 raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxDeviceTokenBytes+1))
245 if err != nil {
246 return c.fail(protocol.ExitFailure, "reading stdin: %v", err)
247 }
248 token := strings.TrimSpace(string(raw))
249 if token == "" {
250 return c.usageWith("no device token on stdin")
251 }
252 if len(token) > maxDeviceTokenBytes {
253 return c.fail(protocol.ExitUsage, "device token is too long")
254 }
255 if _, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label")); err != nil {
256 return c.fail(protocol.ExitFailure, "%v", err)
257 }
258 return c.emit(map[string]string{"status": "registered"}, func(w io.Writer) {
259 fmt.Fprintln(w, "device registered")
260 })
261}
262
263func runNotificationsDeviceList(c *Ctx, args []string) int {
264 if len(args) != 0 {
265 return c.usage()
266 }
267 devices, err := c.Store.PushDevices(c.User.ID)
268 if err != nil {
269 return c.fail(protocol.ExitFailure, "%v", err)
270 }
271 type row struct {
272 ID int64 `json:"id"`
273 Label string `json:"label"`
274 Token string `json:"token"` // truncated; a token is not echoed in full
275 Added string `json:"added"`
276 }
277 rows := make([]row, 0, len(devices))
278 for _, d := range devices {
279 rows = append(rows, row{ID: d.ID, Label: d.Label,
280 Token: shortToken(d.Token), Added: d.CreatedAt})
281 }
282 return c.emit(rows, func(w io.Writer) {
283 for _, r := range rows {
284 fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", r.ID, r.Label, r.Token, r.Added)
285 }
286 })
287}
288
289// shortToken renders a device token as its first eight characters. Enough
290// to tell two devices apart in a list, not enough to push to one.
291func shortToken(t string) string {
292 if len(t) <= 8 {
293 return t
294 }
295 return t[:8] + "…"
296}
297
298func runNotificationsDeviceRemove(c *Ctx, args []string) int {
299 if len(args) != 1 {
300 return c.usage()
301 }
302 id, err := strconv.ParseInt(args[0], 10, 64)
303 if err != nil {
304 return c.usageWith("device id must be a number")
305 }
306 if err := c.Store.RemovePushDevice(c.User.ID, id); err != nil {
307 if errors.Is(err, store.ErrNotFound) {
308 return c.fail(protocol.ExitNotFound, "no such device; notifications device list shows yours")
309 }
310 return c.fail(protocol.ExitFailure, "%v", err)
311 }
312 return c.emit(map[string]string{"status": "removed"}, func(w io.Writer) {
313 fmt.Fprintln(w, "device removed")
314 })
315}
316
201317// noticesDefaultLimit caps a bare list; pagination reaches further back.
202318const noticesDefaultLimit = 50
203319
internal/control/notifications_test.go added +78
@@ -0,0 +1,78 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/store"
9)
10
11// notifTestCtx opens an in-memory store, migrates it, and creates one user
12// to act as. Modeled on the store setup in snippet_test.go; this package
13// has no shared testCtx helper.
14func notifTestCtx(t *testing.T, username string) *Ctx {
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(username, false)
25 if err != nil {
26 t.Fatal(err)
27 }
28 var out bytes.Buffer
29 return &Ctx{
30 User: store.User{ID: uid, Username: username},
31 Scope: "full",
32 Store: st,
33 Stdin: strings.NewReader(""),
34 Stdout: &out,
35 Stderr: &out,
36 }
37}
38
39func TestNotificationsDeviceAddReadsStdin(t *testing.T) {
40 c := notifTestCtx(t, "alice")
41 c.Stdin = strings.NewReader("DEVTOKEN\n")
42 if code := runNotificationsDeviceAdd(c, []string{"--label", "iphone"}); code != 0 {
43 t.Fatalf("exit %d", code)
44 }
45 devices, _ := c.Store.PushDevices(c.User.ID)
46 if len(devices) != 1 || devices[0].Token != "DEVTOKEN" {
47 t.Fatalf("got %+v", devices)
48 }
49 if devices[0].Label != "iphone" {
50 t.Fatalf("label = %q", devices[0].Label)
51 }
52}
53
54func TestNotificationsDeviceListTruncatesTheToken(t *testing.T) {
55 c := notifTestCtx(t, "alice")
56 long := strings.Repeat("a", 64)
57 c.Store.AddPushDevice(c.User.ID, long, "iphone")
58 var out bytes.Buffer
59 c.Stdout = &out
60 if code := runNotificationsDeviceList(c, nil); code != 0 {
61 t.Fatalf("exit %d", code)
62 }
63 if strings.Contains(out.String(), long) {
64 t.Fatal("the full token was printed")
65 }
66}
67
68func TestNotificationsSettingsShowsPush(t *testing.T) {
69 c := notifTestCtx(t, "alice")
70 var out bytes.Buffer
71 c.Stdout, c.JSON = &out, true
72 if code := runNotificationsSettingsShow(c, nil); code != 0 {
73 t.Fatalf("exit %d", code)
74 }
75 if !strings.Contains(out.String(), `"push":true`) {
76 t.Fatalf("no push key: %s", out.String())
77 }
78}