internal/control/notifications.go
500 lines · 17721 bytes
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "strconv"
8 "strings"
9
10 "gitbay.org/gitbay/internal/autolink"
11 "gitbay.org/gitbay/internal/policy"
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14)
15
16func init() {
17 register(Command{Path: []string{"notifications", "list"},
18 Summary: "your notification inbox, newest first",
19 Usage: "notifications list [--all] [--limit <n>] [--cursor <c>]",
20 Flags: []Flag{
21 {"--all", "", "include already-read notifications", ""},
22 {"--limit", "<n>", "rows per page", ""},
23 {"--cursor", "<c>", "continue from the previous page", ""},
24 },
25 Examples: []string{"notifications list", "notifications list --all --limit 50"},
26 ReadOnly: true, Run: runNotificationsList})
27 register(Command{Path: []string{"notifications", "read"},
28 Summary: "mark notifications read",
29 Usage: "notifications read <id>... | --all",
30 Flags: []Flag{
31 {"--all", "", "mark every unread notification read", ""},
32 },
33 Examples: []string{"notifications read 12 13", "notifications read --all"},
34 Run: runNotificationsRead})
35 register(Command{Path: []string{"notifications", "settings", "show"},
36 Summary: "your notification preferences",
37 Usage: "notifications settings show",
38 Examples: []string{"notifications settings show"},
39 ReadOnly: true, Run: runNotificationsSettingsShow})
40 register(Command{Path: []string{"notifications", "settings", "mail"},
41 Summary: "activity by mail as well as the inbox (login links are unaffected)",
42 Usage: "notifications settings mail on|off",
43 Examples: []string{"notifications settings mail on"},
44 Run: runNotificationsSettingsMail})
45 register(Command{Path: []string{"notifications", "settings", "watch"},
46 Summary: "every issue and merge request on repositories you can write to",
47 Usage: "notifications settings watch on|off",
48 Examples: []string{"notifications settings watch on"},
49 Run: runNotificationsSettingsWatch})
50 register(Command{Path: []string{"notifications", "device", "add"},
51 NeedsRecentSignIn: true,
52 Summary: "register an Apple device for push, token on stdin",
53 Usage: "notifications device add [--label <name>] < token",
54 Flags: []Flag{
55 {"--label", "<name>", "a name for the device", ""},
56 },
57 Examples: []string{"notifications device add --label iphone < token"},
58 // Mandatory: without it control.go swaps in an empty reader and
59 // this command stores an empty token without erroring.
60 ReadsStdin: true, Run: runNotificationsDeviceAdd})
61 register(Command{Path: []string{"notifications", "device", "list"},
62 Summary: "your registered devices",
63 Usage: "notifications device list",
64 Examples: []string{"notifications device list"},
65 ReadOnly: true, Run: runNotificationsDeviceList})
66 register(Command{Path: []string{"notifications", "device", "remove"},
67 Summary: "deregister a device",
68 Usage: "notifications device remove <id>",
69 Examples: []string{"notifications device remove 4"},
70 Run: runNotificationsDeviceRemove})
71 register(Command{Path: []string{"notifications", "settings", "push"},
72 Summary: "activity on your registered devices as well as the inbox",
73 Usage: "notifications settings push on|off",
74 Examples: []string{"notifications settings push on"},
75 Run: runNotificationsSettingsPush})
76 register(Command{Path: []string{"repo", "watch"},
77 Summary: "hear about all activity on a repository",
78 Usage: "repo watch <owner/name>",
79 Examples: []string{"repo watch krz/gitbay"},
80 Run: runRepoWatch})
81 register(Command{Path: []string{"repo", "unwatch"},
82 Summary: "back to the default: only work you are part of",
83 Usage: "repo unwatch <owner/name>",
84 Examples: []string{"repo unwatch krz/gitbay"},
85 Run: runRepoUnwatch})
86 register(Command{Path: []string{"repo", "mute"},
87 Summary: "mute a repository, including work you are part of",
88 Usage: "repo mute <owner/name>",
89 Examples: []string{"repo mute krz/gitbay"},
90 Run: runRepoMute})
91}
92
93// notice is one thing that happened, in the shape both delivery routes
94// need: a mail subject and body, and an inbox row. The inbox is filed
95// whether or not the instance has SMTP; mail is the optional half.
96type notice struct {
97 repo store.Repo
98 kind string // issue, mr, or build
99 subject string // mail subject
100 action string // "opened issue #12" — also the inbox summary
101 excerpt string // quoted into the mail, not the inbox
102 path string // web path, no leading slash
103 // body replaces the composed mail body outright, for a notice whose
104 // mail is not prose — a failed build's log tail is not an excerpt of
105 // something someone wrote, and is not cut to an excerpt's length.
106 body string
107 // direct keeps the notice to the given accounts: watchers of the
108 // repository are not added. A mention is addressed to someone.
109 direct bool
110}
111
112// notify delivers a notice to the given user ids widened by the
113// repository's watchers (unless direct), minus anyone who muted it and
114// minus the acting user. A best-effort side channel: failures are
115// ignored, the action itself already succeeded.
116func notify(c *Ctx, userIDs []int64, n notice) {
117 recipients, err := c.Store.NotifyRecipients(n.repo.ID, c.User.ID, userIDs, !n.direct)
118 if err != nil {
119 return
120 }
121 sendMail := c.Cfg.Mail.SMTPHost != ""
122 // Nothing drains push_queue unless the daemon started the deliverer,
123 // and the retention sweep only collects rows that were sent or
124 // dead-lettered, so a row written here would sit there forever.
125 sendPush := c.Cfg.Push.Enabled
126 body := noticeBody(c, n)
127 for _, id := range recipients {
128 c.Store.AddNotice(id, n.repo.ID, n.kind, c.User.Username, n.action, n.path)
129 if sendPush {
130 c.Store.EnqueuePush(id, pushTitle(n), pushBody(c.User.Username, n), n.path)
131 }
132 if !sendMail {
133 continue
134 }
135 email, err := c.Store.ActivityMailAddress(id)
136 if err != nil || email == "" {
137 continue
138 }
139 c.Store.EnqueueMail(email, n.subject, body)
140 }
141}
142
143// notifyMentions files an inbox row for every account text mentions by
144// @name that can read the repository, and records them as participants
145// of the thread so they hear what follows (#202). Mute is honoured by
146// notify; the actor mentioning themselves is dropped there too.
147func notifyMentions(c *Ctx, repo store.Repo, t thread, itemID, number int64, title, text string) {
148 var ids []int64
149 for _, name := range autolink.Mentions(text) {
150 u, err := c.Store.UserByUsername(name)
151 if err != nil {
152 trimmed := strings.TrimRight(name, "._-")
153 if trimmed == "" || trimmed == name {
154 continue
155 }
156 if u, err = c.Store.UserByUsername(trimmed); err != nil {
157 continue
158 }
159 }
160 if u.ID == c.User.ID {
161 continue
162 }
163 grant, err := c.Store.AccessRole(repo.ID, u.ID)
164 if err != nil || !policy.CanRead(u, repo, grant) {
165 continue
166 }
167 ids = append(ids, u.ID)
168 }
169 if len(ids) == 0 {
170 return
171 }
172 c.Store.AddMentions(repo.ID, t.kind, itemID, ids)
173 notify(c, ids, notice{repo: repo, kind: t.kind, direct: true,
174 subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title),
175 action: fmt.Sprintf("mentioned you in %s%d", t.symbol, number),
176 excerpt: text, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)})
177}
178
179// noticeBody builds the standard mail body: who did what, an excerpt, and
180// the web link.
181func noticeBody(c *Ctx, n notice) string {
182 if n.body != "" {
183 return n.body
184 }
185 var b strings.Builder
186 fmt.Fprintf(&b, "%s %s\n", c.User.Username, n.action)
187 if e := strings.TrimSpace(n.excerpt); e != "" {
188 if len(e) > 500 {
189 e = e[:500] + "…"
190 }
191 fmt.Fprintf(&b, "\n%s\n", e)
192 }
193 fmt.Fprintf(&b, "\n%s/%s\n", strings.TrimSuffix(c.Cfg.Server.SiteURL, "/"), n.path)
194 return b.String()
195}
196
197// pushTitle and pushBody are the alert's two lines. The body is built
198// from the same two values AddNotice files, so the alert and the inbox
199// row cannot disagree about what happened. The title is the repository,
200// which also groups a repository's notices in Notification Center.
201func pushTitle(n notice) string { return n.repo.Path() }
202
203func pushBody(actor string, n notice) string { return actor + " " + n.action }
204
205func issueSubject(repo store.Repo, number int64, title string) string {
206 return fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, title)
207}
208
209func mrSubject(repo store.Repo, number int64, title string) string {
210 return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title)
211}
212
213func emitNotificationSettings(c *Ctx) int {
214 mail, err := c.Store.MailEnabled(c.User.ID)
215 if err != nil {
216 return c.fail(protocol.ExitFailure, "%v", err)
217 }
218 watch, err := c.Store.WatchEnabled(c.User.ID)
219 if err != nil {
220 return c.fail(protocol.ExitFailure, "%v", err)
221 }
222 push, err := c.Store.PushEnabled(c.User.ID)
223 if err != nil {
224 return c.fail(protocol.ExitFailure, "%v", err)
225 }
226 return c.emit(map[string]bool{"mail": mail, "watch": watch, "push": push}, func(w io.Writer) {
227 onOff := func(on bool) string {
228 if on {
229 return "on"
230 }
231 return "off"
232 }
233 v := c.view(w)
234 v.fields(
235 "mail", onOff(mail),
236 "watch", onOff(watch),
237 "push", onOff(push),
238 )
239 })
240}
241
242func runNotificationsSettingsShow(c *Ctx, args []string) int {
243 if len(args) != 0 {
244 return c.usage()
245 }
246 return emitNotificationSettings(c)
247}
248
249// runNotificationsSettingsMail is the "inbox but no mail" switch: the
250// inbox is filed either way, the mail half consults it (#194).
251func runNotificationsSettingsMail(c *Ctx, args []string) int {
252 if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
253 return c.usage()
254 }
255 if err := c.Store.SetMailEnabled(c.User.ID, args[0] == "on"); err != nil {
256 return c.fail(protocol.ExitFailure, "%v", err)
257 }
258 return emitNotificationSettings(c)
259}
260
261// runNotificationsSettingsWatch is the default watch state for
262// repositories the account can write to: consulted when a notice is
263// delivered, so a grant or a revoke needs no watch row of its own (#194).
264func runNotificationsSettingsWatch(c *Ctx, args []string) int {
265 if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
266 return c.usage()
267 }
268 if err := c.Store.SetWatchEnabled(c.User.ID, args[0] == "on"); err != nil {
269 return c.fail(protocol.ExitFailure, "%v", err)
270 }
271 return emitNotificationSettings(c)
272}
273
274func runNotificationsSettingsPush(c *Ctx, args []string) int {
275 if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
276 return c.usage()
277 }
278 if err := c.Store.SetPushEnabled(c.User.ID, args[0] == "on"); err != nil {
279 return c.fail(protocol.ExitFailure, "%v", err)
280 }
281 return emitNotificationSettings(c)
282}
283
284// maxDeviceTokenBytes is well past APNs' 32-byte token rendered as 64 hex
285// characters, and stops a stdin that is not a token from becoming a row.
286const maxDeviceTokenBytes = 512
287
288func runNotificationsDeviceAdd(c *Ctx, args []string) int {
289 f, err := c.parseArgs(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage})
290 if err != nil {
291 return c.fail(protocol.ExitUsage, "%v", err)
292 }
293 if len(f.Pos) != 0 {
294 return c.usage()
295 }
296 // The registration itself would succeed and then deliver nothing,
297 // while notifications settings show still reported push on. Say what
298 // is actually wrong instead.
299 if !c.Cfg.Push.Enabled {
300 return c.fail(protocol.ExitFailure,
301 "this instance does not send push notifications ([push] enabled = false); ask an admin")
302 }
303 raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxDeviceTokenBytes+1))
304 if err != nil {
305 return c.fail(protocol.ExitFailure, "reading stdin: %v", err)
306 }
307 token := strings.TrimSpace(string(raw))
308 if token == "" {
309 return c.usageWith("no device token on stdin")
310 }
311 if len(token) > maxDeviceTokenBytes {
312 return c.fail(protocol.ExitUsage, "device token is too long")
313 }
314 id, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label"))
315 if err != nil {
316 return c.fail(protocol.ExitFailure, "%v", err)
317 }
318 return c.emit(map[string]any{"id": id, "status": "registered"}, func(w io.Writer) {
319 fmt.Fprintf(w, "registered device %d\n", id)
320 })
321}
322
323func runNotificationsDeviceList(c *Ctx, args []string) int {
324 if len(args) != 0 {
325 return c.usage()
326 }
327 devices, err := c.Store.PushDevices(c.User.ID)
328 if err != nil {
329 return c.fail(protocol.ExitFailure, "%v", err)
330 }
331 type row struct {
332 ID int64 `json:"id"`
333 Label string `json:"label"`
334 Token string `json:"token"` // truncated; a token is not echoed in full
335 Added string `json:"added"`
336 }
337 rows := make([]row, 0, len(devices))
338 for _, d := range devices {
339 rows = append(rows, row{ID: d.ID, Label: d.Label,
340 Token: ShortToken(d.Token), Added: d.CreatedAt})
341 }
342 return c.emit(rows, func(w io.Writer) {
343 tb := c.table(w, "ID", "LABEL", "TOKEN", "ADDED")
344 for _, r := range rows {
345 tb.row(cRef(fmt.Sprintf("%d", r.ID)), cText(r.Label), cText(r.Token), cAge(r.Added))
346 }
347 tb.flush()
348 })
349}
350
351// ShortToken renders a device token as its first eight characters. Enough
352// to tell two devices apart in a list, not enough to push to one. A real
353// APNs token is 64 hex characters, so anything at or under the cut length
354// is not a token worth showing part of — it is masked outright rather
355// than echoed whole, which "abc…" would imply is a truncation.
356//
357// Exported because the account page lists the same devices: one renderer,
358// so the two surfaces cannot come to disagree about what they print.
359func ShortToken(t string) string {
360 if len(t) > 8 {
361 return t[:8] + "…"
362 }
363 return "(short token)"
364}
365
366func runNotificationsDeviceRemove(c *Ctx, args []string) int {
367 if len(args) != 1 {
368 return c.usage()
369 }
370 id, err := strconv.ParseInt(args[0], 10, 64)
371 if err != nil {
372 return c.usageWith("device id must be a number")
373 }
374 if err := c.Store.RemovePushDevice(c.User.ID, id); err != nil {
375 if errors.Is(err, store.ErrNotFound) {
376 return c.fail(protocol.ExitNotFound, "no such device; notifications device list shows yours")
377 }
378 return c.fail(protocol.ExitFailure, "%v", err)
379 }
380 return c.emit(map[string]string{"status": "removed"}, func(w io.Writer) {
381 fmt.Fprintln(w, "device removed")
382 })
383}
384
385// noticesDefaultLimit caps a bare list; pagination reaches further back.
386const noticesDefaultLimit = 50
387
388func runNotificationsList(c *Ctx, args []string) int {
389 rest, p, code := parsePageFlags(c, args, "notifications", true)
390 if code >= 0 {
391 return code
392 }
393 fl, err := c.parseArgs(rest, flagSpec{Bools: []string{"--all"}, Usage: c.Cmd.Usage})
394 if err != nil {
395 return c.fail(protocol.ExitUsage, "%v", err)
396 }
397 all := fl.Has("--all")
398 if p.limit == 0 {
399 p.limit = noticesDefaultLimit
400 }
401 notices, err := c.Store.Inbox(c.User.ID, !all, p.queryLimit(), p.keyInt())
402 if err != nil {
403 return c.fail(protocol.ExitFailure, "%v", err)
404 }
405 type out struct {
406 ID int64 `json:"id"`
407 Repo string `json:"repo"`
408 Kind string `json:"kind"`
409 Actor string `json:"actor"`
410 Summary string `json:"summary"`
411 Path string `json:"path"`
412 CreatedAt string `json:"created_at"`
413 ReadAt string `json:"read_at,omitempty"`
414 }
415 notices, next := trimPage(p, notices, "notifications", func(n store.Notice) string {
416 return strconv.FormatInt(n.ID, 10)
417 })
418 ds := make([]out, 0, len(notices))
419 for _, n := range notices {
420 ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt})
421 }
422 if !c.JSON && !p.active && len(ds) == 0 {
423 msg := "nothing to list"
424 if !all {
425 if read, err := c.Store.Inbox(c.User.ID, false, 1, 0); err == nil && len(read) > 0 {
426 msg = "no unread notifications (--all for read ones)"
427 }
428 }
429 fmt.Fprintln(c.Stderr, msg)
430 return protocol.ExitOK
431 }
432 return c.emitPage(p, ds, next, func(w io.Writer) {
433 tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH")
434 for _, d := range ds {
435 mark := "*"
436 if d.ReadAt != "" {
437 mark = " "
438 }
439 tb.row(cRef(fmt.Sprintf("%s %d", mark, d.ID)), cAge(d.CreatedAt), cRef(d.Repo),
440 cFlex(fmt.Sprintf("%s %s", d.Actor, d.Summary)), cText(d.Path))
441 }
442 tb.flush()
443 })
444}
445
446func runNotificationsRead(c *Ctx, args []string) int {
447 fl, err := c.parseArgs(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: c.Cmd.Usage})
448 if err != nil {
449 return c.fail(protocol.ExitUsage, "%v", err)
450 }
451 // --all and a list of ids are two ways of saying which rows: taking
452 // both would leave which one won unstated.
453 if fl.Has("--all") == (len(fl.Pos) > 0) {
454 return c.usage()
455 }
456 var ids []int64
457 for _, a := range fl.Pos {
458 n, err := strconv.ParseInt(a, 10, 64)
459 if err != nil {
460 return c.fail(protocol.ExitUsage, "bad notification id %q", a)
461 }
462 ids = append(ids, n)
463 }
464 n, err := c.Store.MarkNoticesRead(c.User.ID, ids)
465 if err != nil {
466 return c.fail(protocol.ExitFailure, "%v", err)
467 }
468 return c.emit(map[string]int64{"read": n}, func(w io.Writer) {
469 fmt.Fprintf(w, "marked %d read\n", n)
470 })
471}
472
473func runRepoWatch(c *Ctx, args []string) int { return setWatch(c, args, "watch", "watching") }
474func runRepoMute(c *Ctx, args []string) int { return setWatch(c, args, "mute", "muted") }
475func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "unwatch", "default") }
476
477// setWatch records the caller's state on a repository. "default" deletes
478// the row: watch then unwatch leaves no trace, and a mute is undone the
479// same way.
480func setWatch(c *Ctx, args []string, verb, state string) int {
481 if len(args) != 1 {
482 return c.usage()
483 }
484 repo, code := resolveRepo(c, args[0], policy.CanRead)
485 if code >= 0 {
486 return code
487 }
488 var err error
489 if state == "default" {
490 err = c.Store.ClearRepoWatch(repo.ID, c.User.ID)
491 } else {
492 err = c.Store.SetRepoWatch(repo.ID, c.User.ID, state)
493 }
494 if err != nil {
495 return c.fail(protocol.ExitFailure, "%v", err)
496 }
497 return c.emit(map[string]string{"repo": repo.Path(), "state": state}, func(w io.Writer) {
498 fmt.Fprintf(w, "%s %s\n", state, repo.Path())
499 })
500}