internal/control/notifications.go
448 lines · 16033 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 ReadOnly: true, Run: runNotificationsList})
21 register(Command{Path: []string{"notifications", "read"},
22 Summary: "mark notifications read",
23 Usage: "notifications read <id>... | --all", Run: runNotificationsRead})
24 register(Command{Path: []string{"notifications", "settings", "show"},
25 Summary: "your notification preferences",
26 Usage: "notifications settings show",
27 ReadOnly: true, Run: runNotificationsSettingsShow})
28 register(Command{Path: []string{"notifications", "settings", "mail"},
29 Summary: "activity by mail as well as the inbox (login links are unaffected)",
30 Usage: "notifications settings mail on|off", Run: runNotificationsSettingsMail})
31 register(Command{Path: []string{"notifications", "settings", "watch"},
32 Summary: "every issue and merge request on repositories you can write to",
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})
50 register(Command{Path: []string{"repo", "watch"},
51 Summary: "hear about all activity on a repository",
52 Usage: "repo watch <owner/name>", Run: runRepoWatch})
53 register(Command{Path: []string{"repo", "unwatch"},
54 Summary: "back to the default: only work you are part of",
55 Usage: "repo unwatch <owner/name>", Run: runRepoUnwatch})
56 register(Command{Path: []string{"repo", "mute"},
57 Summary: "mute a repository, including work you are part of",
58 Usage: "repo mute <owner/name>", Run: runRepoMute})
59}
60
61// notice is one thing that happened, in the shape both delivery routes
62// need: a mail subject and body, and an inbox row. The inbox is filed
63// whether or not the instance has SMTP; mail is the optional half.
64type notice struct {
65 repo store.Repo
66 kind string // issue, mr, or build
67 subject string // mail subject
68 action string // "opened issue #12" — also the inbox summary
69 excerpt string // quoted into the mail, not the inbox
70 path string // web path, no leading slash
71 // body replaces the composed mail body outright, for a notice whose
72 // mail is not prose — a failed build's log tail is not an excerpt of
73 // something someone wrote, and is not cut to an excerpt's length.
74 body string
75 // direct keeps the notice to the given accounts: watchers of the
76 // repository are not added. A mention is addressed to someone.
77 direct bool
78}
79
80// notify delivers a notice to the given user ids widened by the
81// repository's watchers (unless direct), minus anyone who muted it and
82// minus the acting user. A best-effort side channel: failures are
83// ignored, the action itself already succeeded.
84func notify(c *Ctx, userIDs []int64, n notice) {
85 recipients, err := c.Store.NotifyRecipients(n.repo.ID, c.User.ID, userIDs, !n.direct)
86 if err != nil {
87 return
88 }
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
94 body := noticeBody(c, n)
95 for _, id := range recipients {
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 }
100 if !sendMail {
101 continue
102 }
103 email, err := c.Store.ActivityMailAddress(id)
104 if err != nil || email == "" {
105 continue
106 }
107 c.Store.EnqueueMail(email, n.subject, body)
108 }
109}
110
111// notifyMentions files an inbox row for every account text mentions by
112// @name that can read the repository, and records them as participants
113// of the thread so they hear what follows (#202). Mute is honoured by
114// notify; the actor mentioning themselves is dropped there too.
115func notifyMentions(c *Ctx, repo store.Repo, t thread, itemID, number int64, title, text string) {
116 var ids []int64
117 for _, name := range autolink.Mentions(text) {
118 u, err := c.Store.UserByUsername(name)
119 if err != nil {
120 trimmed := strings.TrimRight(name, "._-")
121 if trimmed == "" || trimmed == name {
122 continue
123 }
124 if u, err = c.Store.UserByUsername(trimmed); err != nil {
125 continue
126 }
127 }
128 if u.ID == c.User.ID {
129 continue
130 }
131 grant, err := c.Store.AccessRole(repo.ID, u.ID)
132 if err != nil || !policy.CanRead(u, repo, grant) {
133 continue
134 }
135 ids = append(ids, u.ID)
136 }
137 if len(ids) == 0 {
138 return
139 }
140 c.Store.AddMentions(repo.ID, t.kind, itemID, ids)
141 notify(c, ids, notice{repo: repo, kind: t.kind, direct: true,
142 subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title),
143 action: fmt.Sprintf("mentioned you in %s%d", t.symbol, number),
144 excerpt: text, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)})
145}
146
147// noticeBody builds the standard mail body: who did what, an excerpt, and
148// the web link.
149func noticeBody(c *Ctx, n notice) string {
150 if n.body != "" {
151 return n.body
152 }
153 var b strings.Builder
154 fmt.Fprintf(&b, "%s %s\n", c.User.Username, n.action)
155 if e := strings.TrimSpace(n.excerpt); e != "" {
156 if len(e) > 500 {
157 e = e[:500] + "…"
158 }
159 fmt.Fprintf(&b, "\n%s\n", e)
160 }
161 fmt.Fprintf(&b, "\n%s/%s\n", strings.TrimSuffix(c.Cfg.Server.SiteURL, "/"), n.path)
162 return b.String()
163}
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.
169func pushTitle(n notice) string { return n.repo.Path() }
170
171func pushBody(actor string, n notice) string { return actor + " " + n.action }
172
173func issueSubject(repo store.Repo, number int64, title string) string {
174 return fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, title)
175}
176
177func mrSubject(repo store.Repo, number int64, title string) string {
178 return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title)
179}
180
181func emitNotificationSettings(c *Ctx) int {
182 mail, err := c.Store.MailEnabled(c.User.ID)
183 if err != nil {
184 return c.fail(protocol.ExitFailure, "%v", err)
185 }
186 watch, err := c.Store.WatchEnabled(c.User.ID)
187 if err != nil {
188 return c.fail(protocol.ExitFailure, "%v", err)
189 }
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) {
195 onOff := func(on bool) string {
196 if on {
197 return "on"
198 }
199 return "off"
200 }
201 fmt.Fprintf(w, "mail: %s\nwatch: %s\npush: %s\n", onOff(mail), onOff(watch), onOff(push))
202 })
203}
204
205func runNotificationsSettingsShow(c *Ctx, args []string) int {
206 if len(args) != 0 {
207 return c.usage()
208 }
209 return emitNotificationSettings(c)
210}
211
212// runNotificationsSettingsMail is the "inbox but no mail" switch: the
213// inbox is filed either way, the mail half consults it (#194).
214func runNotificationsSettingsMail(c *Ctx, args []string) int {
215 if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
216 return c.usage()
217 }
218 if err := c.Store.SetMailEnabled(c.User.ID, args[0] == "on"); err != nil {
219 return c.fail(protocol.ExitFailure, "%v", err)
220 }
221 return emitNotificationSettings(c)
222}
223
224// runNotificationsSettingsWatch is the default watch state for
225// repositories the account can write to: consulted when a notice is
226// delivered, so a grant or a revoke needs no watch row of its own (#194).
227func runNotificationsSettingsWatch(c *Ctx, args []string) int {
228 if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
229 return c.usage()
230 }
231 if err := c.Store.SetWatchEnabled(c.User.ID, args[0] == "on"); err != nil {
232 return c.fail(protocol.ExitFailure, "%v", err)
233 }
234 return emitNotificationSettings(c)
235}
236
237func 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.
249const maxDeviceTokenBytes = 512
250
251func 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
285func 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.
319func ShortToken(t string) string {
320 if len(t) > 8 {
321 return t[:8] + "…"
322 }
323 return "(short token)"
324}
325
326func 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
345// noticesDefaultLimit caps a bare list; pagination reaches further back.
346const noticesDefaultLimit = 50
347
348func runNotificationsList(c *Ctx, args []string) int {
349 rest, p, code := parsePageFlags(c, args, "notifications", true)
350 if code >= 0 {
351 return code
352 }
353 fl, err := parseFlags(rest, flagSpec{Bools: []string{"--all"}, Usage: c.Cmd.Usage})
354 if err != nil {
355 return c.fail(protocol.ExitUsage, "%v", err)
356 }
357 all := fl.Has("--all")
358 if p.limit == 0 {
359 p.limit = noticesDefaultLimit
360 }
361 notices, err := c.Store.Inbox(c.User.ID, !all, p.queryLimit(), p.keyInt())
362 if err != nil {
363 return c.fail(protocol.ExitFailure, "%v", err)
364 }
365 type out struct {
366 ID int64 `json:"id"`
367 Repo string `json:"repo"`
368 Kind string `json:"kind"`
369 Actor string `json:"actor"`
370 Summary string `json:"summary"`
371 Path string `json:"path"`
372 CreatedAt string `json:"created_at"`
373 ReadAt string `json:"read_at,omitempty"`
374 }
375 notices, next := trimPage(p, notices, "notifications", func(n store.Notice) string {
376 return strconv.FormatInt(n.ID, 10)
377 })
378 ds := make([]out, 0, len(notices))
379 for _, n := range notices {
380 ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt})
381 }
382 return c.emitPage(p, ds, next, func(w io.Writer) {
383 for _, d := range ds {
384 mark := "*"
385 if d.ReadAt != "" {
386 mark = " "
387 }
388 fmt.Fprintf(w, "%s %d\t%s\t%s\t%s %s\t%s\n",
389 mark, d.ID, d.CreatedAt, d.Repo, d.Actor, d.Summary, d.Path)
390 }
391 })
392}
393
394func runNotificationsRead(c *Ctx, args []string) int {
395 fl, err := parseFlags(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: c.Cmd.Usage})
396 if err != nil {
397 return c.fail(protocol.ExitUsage, "%v", err)
398 }
399 // --all and a list of ids are two ways of saying which rows: taking
400 // both would leave which one won unstated.
401 if fl.Has("--all") == (len(fl.Pos) > 0) {
402 return c.usage()
403 }
404 var ids []int64
405 for _, a := range fl.Pos {
406 n, err := strconv.ParseInt(a, 10, 64)
407 if err != nil {
408 return c.fail(protocol.ExitUsage, "bad notification id %q", a)
409 }
410 ids = append(ids, n)
411 }
412 n, err := c.Store.MarkNoticesRead(c.User.ID, ids)
413 if err != nil {
414 return c.fail(protocol.ExitFailure, "%v", err)
415 }
416 return c.emit(map[string]int64{"read": n}, func(w io.Writer) {
417 fmt.Fprintf(w, "marked %d read\n", n)
418 })
419}
420
421func runRepoWatch(c *Ctx, args []string) int { return setWatch(c, args, "watch", "watching") }
422func runRepoMute(c *Ctx, args []string) int { return setWatch(c, args, "mute", "muted") }
423func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "unwatch", "default") }
424
425// setWatch records the caller's state on a repository. "default" deletes
426// the row: watch then unwatch leaves no trace, and a mute is undone the
427// same way.
428func setWatch(c *Ctx, args []string, verb, state string) int {
429 if len(args) != 1 {
430 return c.usage()
431 }
432 repo, code := resolveRepo(c, args[0], policy.CanRead)
433 if code >= 0 {
434 return code
435 }
436 var err error
437 if state == "default" {
438 err = c.Store.ClearRepoWatch(repo.ID, c.User.ID)
439 } else {
440 err = c.Store.SetRepoWatch(repo.ID, c.User.ID, state)
441 }
442 if err != nil {
443 return c.fail(protocol.ExitFailure, "%v", err)
444 }
445 return c.emit(map[string]string{"repo": repo.Path(), "state": state}, func(w io.Writer) {
446 fmt.Fprintf(w, "%s %s\n", state, repo.Path())
447 })
448}