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