internal/control/notifications.go

ff759b53942049b2043e132f5482b442a7265b98
gitbay/internal/control/notifications.go history · blame · raw

499 lines · 17674 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 := c.parseArgs(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 := c.parseArgs(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	if !c.JSON && !p.active && len(ds) == 0 {
422		msg := "nothing to list"
423		if !all {
424			if read, err := c.Store.Inbox(c.User.ID, false, 1, 0); err == nil && len(read) > 0 {
425				msg = "no unread notifications (--all for read ones)"
426			}
427		}
428		fmt.Fprintln(c.Stderr, msg)
429		return protocol.ExitOK
430	}
431	return c.emitPage(p, ds, next, func(w io.Writer) {
432		tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH")
433		for _, d := range ds {
434			mark := "*"
435			if d.ReadAt != "" {
436				mark = " "
437			}
438			tb.row(cRef(fmt.Sprintf("%s %d", mark, d.ID)), cAge(d.CreatedAt), cRef(d.Repo),
439				cFlex(fmt.Sprintf("%s %s", d.Actor, d.Summary)), cText(d.Path))
440		}
441		tb.flush()
442	})
443}
444
445func runNotificationsRead(c *Ctx, args []string) int {
446	fl, err := c.parseArgs(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: c.Cmd.Usage})
447	if err != nil {
448		return c.fail(protocol.ExitUsage, "%v", err)
449	}
450	// --all and a list of ids are two ways of saying which rows: taking
451	// both would leave which one won unstated.
452	if fl.Has("--all") == (len(fl.Pos) > 0) {
453		return c.usage()
454	}
455	var ids []int64
456	for _, a := range fl.Pos {
457		n, err := strconv.ParseInt(a, 10, 64)
458		if err != nil {
459			return c.fail(protocol.ExitUsage, "bad notification id %q", a)
460		}
461		ids = append(ids, n)
462	}
463	n, err := c.Store.MarkNoticesRead(c.User.ID, ids)
464	if err != nil {
465		return c.fail(protocol.ExitFailure, "%v", err)
466	}
467	return c.emit(map[string]int64{"read": n}, func(w io.Writer) {
468		fmt.Fprintf(w, "marked %d read\n", n)
469	})
470}
471
472func runRepoWatch(c *Ctx, args []string) int   { return setWatch(c, args, "watch", "watching") }
473func runRepoMute(c *Ctx, args []string) int    { return setWatch(c, args, "mute", "muted") }
474func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "unwatch", "default") }
475
476// setWatch records the caller's state on a repository. "default" deletes
477// the row: watch then unwatch leaves no trace, and a mute is undone the
478// same way.
479func setWatch(c *Ctx, args []string, verb, state string) int {
480	if len(args) != 1 {
481		return c.usage()
482	}
483	repo, code := resolveRepo(c, args[0], policy.CanRead)
484	if code >= 0 {
485		return code
486	}
487	var err error
488	if state == "default" {
489		err = c.Store.ClearRepoWatch(repo.ID, c.User.ID)
490	} else {
491		err = c.Store.SetRepoWatch(repo.ID, c.User.ID, state)
492	}
493	if err != nil {
494		return c.fail(protocol.ExitFailure, "%v", err)
495	}
496	return c.emit(map[string]string{"repo": repo.Path(), "state": state}, func(w io.Writer) {
497		fmt.Fprintf(w, "%s %s\n", state, repo.Path())
498	})
499}