internal/control/notifications.go

v1.35.1
gitbay/internal/control/notifications.go history · blame · raw

449 lines · 16049 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	id, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label"))
278	if err != nil {
279		return c.fail(protocol.ExitFailure, "%v", err)
280	}
281	return c.emit(map[string]any{"id": id, "status": "registered"}, func(w io.Writer) {
282		fmt.Fprintf(w, "device %d registered\n", id)
283	})
284}
285
286func runNotificationsDeviceList(c *Ctx, args []string) int {
287	if len(args) != 0 {
288		return c.usage()
289	}
290	devices, err := c.Store.PushDevices(c.User.ID)
291	if err != nil {
292		return c.fail(protocol.ExitFailure, "%v", err)
293	}
294	type row struct {
295		ID    int64  `json:"id"`
296		Label string `json:"label"`
297		Token string `json:"token"` // truncated; a token is not echoed in full
298		Added string `json:"added"`
299	}
300	rows := make([]row, 0, len(devices))
301	for _, d := range devices {
302		rows = append(rows, row{ID: d.ID, Label: d.Label,
303			Token: ShortToken(d.Token), Added: d.CreatedAt})
304	}
305	return c.emit(rows, func(w io.Writer) {
306		for _, r := range rows {
307			fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", r.ID, r.Label, r.Token, r.Added)
308		}
309	})
310}
311
312// ShortToken renders a device token as its first eight characters. Enough
313// to tell two devices apart in a list, not enough to push to one. A real
314// APNs token is 64 hex characters, so anything at or under the cut length
315// is not a token worth showing part of — it is masked outright rather
316// than echoed whole, which "abc…" would imply is a truncation.
317//
318// Exported because the account page lists the same devices: one renderer,
319// so the two surfaces cannot come to disagree about what they print.
320func ShortToken(t string) string {
321	if len(t) > 8 {
322		return t[:8] + "…"
323	}
324	return "(short token)"
325}
326
327func runNotificationsDeviceRemove(c *Ctx, args []string) int {
328	if len(args) != 1 {
329		return c.usage()
330	}
331	id, err := strconv.ParseInt(args[0], 10, 64)
332	if err != nil {
333		return c.usageWith("device id must be a number")
334	}
335	if err := c.Store.RemovePushDevice(c.User.ID, id); err != nil {
336		if errors.Is(err, store.ErrNotFound) {
337			return c.fail(protocol.ExitNotFound, "no such device; notifications device list shows yours")
338		}
339		return c.fail(protocol.ExitFailure, "%v", err)
340	}
341	return c.emit(map[string]string{"status": "removed"}, func(w io.Writer) {
342		fmt.Fprintln(w, "device removed")
343	})
344}
345
346// noticesDefaultLimit caps a bare list; pagination reaches further back.
347const noticesDefaultLimit = 50
348
349func runNotificationsList(c *Ctx, args []string) int {
350	rest, p, code := parsePageFlags(c, args, "notifications", true)
351	if code >= 0 {
352		return code
353	}
354	fl, err := parseFlags(rest, flagSpec{Bools: []string{"--all"}, Usage: c.Cmd.Usage})
355	if err != nil {
356		return c.fail(protocol.ExitUsage, "%v", err)
357	}
358	all := fl.Has("--all")
359	if p.limit == 0 {
360		p.limit = noticesDefaultLimit
361	}
362	notices, err := c.Store.Inbox(c.User.ID, !all, p.queryLimit(), p.keyInt())
363	if err != nil {
364		return c.fail(protocol.ExitFailure, "%v", err)
365	}
366	type out struct {
367		ID        int64  `json:"id"`
368		Repo      string `json:"repo"`
369		Kind      string `json:"kind"`
370		Actor     string `json:"actor"`
371		Summary   string `json:"summary"`
372		Path      string `json:"path"`
373		CreatedAt string `json:"created_at"`
374		ReadAt    string `json:"read_at,omitempty"`
375	}
376	notices, next := trimPage(p, notices, "notifications", func(n store.Notice) string {
377		return strconv.FormatInt(n.ID, 10)
378	})
379	ds := make([]out, 0, len(notices))
380	for _, n := range notices {
381		ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt})
382	}
383	return c.emitPage(p, ds, next, func(w io.Writer) {
384		for _, d := range ds {
385			mark := "*"
386			if d.ReadAt != "" {
387				mark = " "
388			}
389			fmt.Fprintf(w, "%s %d\t%s\t%s\t%s %s\t%s\n",
390				mark, d.ID, d.CreatedAt, d.Repo, d.Actor, d.Summary, d.Path)
391		}
392	})
393}
394
395func runNotificationsRead(c *Ctx, args []string) int {
396	fl, err := parseFlags(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: c.Cmd.Usage})
397	if err != nil {
398		return c.fail(protocol.ExitUsage, "%v", err)
399	}
400	// --all and a list of ids are two ways of saying which rows: taking
401	// both would leave which one won unstated.
402	if fl.Has("--all") == (len(fl.Pos) > 0) {
403		return c.usage()
404	}
405	var ids []int64
406	for _, a := range fl.Pos {
407		n, err := strconv.ParseInt(a, 10, 64)
408		if err != nil {
409			return c.fail(protocol.ExitUsage, "bad notification id %q", a)
410		}
411		ids = append(ids, n)
412	}
413	n, err := c.Store.MarkNoticesRead(c.User.ID, ids)
414	if err != nil {
415		return c.fail(protocol.ExitFailure, "%v", err)
416	}
417	return c.emit(map[string]int64{"read": n}, func(w io.Writer) {
418		fmt.Fprintf(w, "marked %d read\n", n)
419	})
420}
421
422func runRepoWatch(c *Ctx, args []string) int   { return setWatch(c, args, "watch", "watching") }
423func runRepoMute(c *Ctx, args []string) int    { return setWatch(c, args, "mute", "muted") }
424func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "unwatch", "default") }
425
426// setWatch records the caller's state on a repository. "default" deletes
427// the row: watch then unwatch leaves no trace, and a mute is undone the
428// same way.
429func setWatch(c *Ctx, args []string, verb, state string) int {
430	if len(args) != 1 {
431		return c.usage()
432	}
433	repo, code := resolveRepo(c, args[0], policy.CanRead)
434	if code >= 0 {
435		return code
436	}
437	var err error
438	if state == "default" {
439		err = c.Store.ClearRepoWatch(repo.ID, c.User.ID)
440	} else {
441		err = c.Store.SetRepoWatch(repo.ID, c.User.ID, state)
442	}
443	if err != nil {
444		return c.fail(protocol.ExitFailure, "%v", err)
445	}
446	return c.emit(map[string]string{"repo": repo.Path(), "state": state}, func(w io.Writer) {
447		fmt.Fprintf(w, "%s %s\n", state, repo.Path())
448	})
449}