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