package control import ( "errors" "fmt" "io" "strconv" "strings" "time" "gitbay.org/gitbay/internal/autolink" "gitbay.org/gitbay/internal/mailreply" "gitbay.org/gitbay/internal/policy" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/store" ) func init() { register(Command{Path: []string{"notifications", "list"}, Summary: "your notification inbox, newest first", Usage: "notifications list [--all] [--limit ] [--cursor ]", Flags: []Flag{ {"--all", "", "include already-read notifications", ""}, {"--limit", "", "rows per page", ""}, {"--cursor", "", "continue from the previous page", ""}, }, Examples: []string{"notifications list", "notifications list --all --limit 50"}, ReadOnly: true, Run: runNotificationsList}) register(Command{Path: []string{"notifications", "read"}, Summary: "mark notifications read", Usage: "notifications read ... | --all", Flags: []Flag{ {"--all", "", "mark every unread notification read", ""}, }, Examples: []string{"notifications read 12 13", "notifications read --all"}, Run: runNotificationsRead}) register(Command{Path: []string{"notifications", "settings", "show"}, Summary: "your notification preferences", Usage: "notifications settings show", Examples: []string{"notifications settings show"}, ReadOnly: true, Run: runNotificationsSettingsShow}) register(Command{Path: []string{"notifications", "settings", "mail"}, Summary: "activity by mail as well as the inbox (login links are unaffected)", Usage: "notifications settings mail on|off", Examples: []string{"notifications settings mail on"}, Run: runNotificationsSettingsMail}) register(Command{Path: []string{"notifications", "settings", "reply"}, Summary: "reply to issue and merge request mail to comment", Usage: "notifications settings reply on|off", Examples: []string{"notifications settings reply on"}, Run: runNotificationsSettingsReply}) register(Command{Path: []string{"notifications", "settings", "watch"}, Summary: "every issue and merge request on repositories you can write to", Usage: "notifications settings watch on|off", Examples: []string{"notifications settings watch on"}, Run: runNotificationsSettingsWatch}) register(Command{Path: []string{"notifications", "device", "add"}, NeedsRecentSignIn: true, Summary: "register an Apple device for push, token on stdin", Usage: "notifications device add [--label ] < token", Flags: []Flag{ {"--label", "", "a name for the device", ""}, }, Examples: []string{"notifications device add --label iphone < token"}, // Mandatory: without it control.go swaps in an empty reader and // this command stores an empty token without erroring. ReadsStdin: true, Run: runNotificationsDeviceAdd}) register(Command{Path: []string{"notifications", "device", "list"}, Summary: "your registered devices", Usage: "notifications device list", Examples: []string{"notifications device list"}, ReadOnly: true, Run: runNotificationsDeviceList}) register(Command{Path: []string{"notifications", "device", "remove"}, Summary: "deregister a device", Usage: "notifications device remove ", Examples: []string{"notifications device remove 4"}, Run: runNotificationsDeviceRemove}) register(Command{Path: []string{"notifications", "settings", "push"}, Summary: "activity on your registered devices as well as the inbox", Usage: "notifications settings push on|off", Examples: []string{"notifications settings push on"}, Run: runNotificationsSettingsPush}) register(Command{Path: []string{"repo", "watch"}, Summary: "hear about all activity on a repository", Usage: "repo watch ", Examples: []string{"repo watch krz/gitbay"}, Run: runRepoWatch}) register(Command{Path: []string{"repo", "unwatch"}, Summary: "back to the default: only work you are part of", Usage: "repo unwatch ", Examples: []string{"repo unwatch krz/gitbay"}, Run: runRepoUnwatch}) register(Command{Path: []string{"repo", "mute"}, Summary: "mute a repository, including work you are part of", Usage: "repo mute ", Examples: []string{"repo mute krz/gitbay"}, Run: runRepoMute}) } // notice is one thing that happened, in the shape both delivery routes // need: a mail subject and body, and an inbox row. The inbox is filed // whether or not the instance has SMTP; mail is the optional half. type notice struct { repo store.Repo kind string // issue, mr, or build number int64 // the issue or merge request; 0 for a build subject string // mail subject action string // "opened issue #12" — also the inbox summary excerpt string // quoted into the mail, not the inbox path string // web path, no leading slash // body replaces the composed mail body outright, for a notice whose // mail is not prose — a failed build's log tail is not an excerpt of // something someone wrote, and is not cut to an excerpt's length. body string // direct keeps the notice to the given accounts: watchers of the // repository are not added. A mention is addressed to someone. direct bool } // notify delivers a notice to the given user ids widened by the // repository's watchers (unless direct), minus anyone who muted it and // minus the acting user. A best-effort side channel: failures are // ignored, the action itself already succeeded. func notify(c *Ctx, userIDs []int64, n notice) { recipients, err := c.Store.NotifyRecipients(n.repo.ID, c.User.ID, userIDs, !n.direct) if err != nil { return } sendMail := c.Cfg.Mail.SMTPHost != "" // Nothing drains push_queue unless the daemon started the deliverer, // and the retention sweep only collects rows that were sent or // dead-lettered, so a row written here would sit there forever. sendPush := c.Cfg.Push.Enabled body := noticeBody(c, n) for _, id := range recipients { c.Store.AddNotice(id, n.repo.ID, n.kind, c.User.Username, n.action, n.path) if sendPush { c.Store.EnqueuePush(id, pushTitle(n), pushBody(c.User.Username, n), n.path) } if !sendMail { continue } email, err := c.Store.ActivityMailAddress(id) if err != nil || email == "" { continue } if replyTo := replyAddress(c, id, n); replyTo != "" { c.Store.EnqueueMailReplyTo(email, replyTo, n.subject, body+replyFooter) continue } c.Store.EnqueueMail(email, n.subject, body) } } // replyFooter ends mail that carries a reply address. const replyFooter = "\nReply to this mail to comment. Replies are accepted from your verified addresses.\n" // replyAddress is the Reply-To for recipient's mail about n (#295): an // address carrying a token for the recipient and the thread, when the // instance polls for replies and the recipient turned replies on. "" // otherwise, or when no token can be minted. func replyAddress(c *Ctx, recipient int64, n notice) string { in := c.Cfg.Mail.Inbound keys := c.Store.Keyring() if !in.Enabled || keys == nil || n.number == 0 || (n.kind != "issue" && n.kind != "mr") { return "" } if on, err := c.Store.ReplyEnabled(recipient); err != nil || !on { return "" } secrets, err := keys.Derive(mailreply.Purpose) if err != nil { return "" } tok, err := mailreply.Mint(secrets, mailreply.Target{ UserID: recipient, RepoID: n.repo.ID, Kind: n.kind, Number: n.number, }, time.Now().Add(mailreply.Lifetime)) if err != nil { return "" } return mailreply.Address(in.ReplyAddress, tok) } // notifyMentions files an inbox row for every account text mentions by // @name that can read the repository, and records them as participants // of the thread so they hear what follows (#202). Mute is honoured by // notify; the actor mentioning themselves is dropped there too. func notifyMentions(c *Ctx, repo store.Repo, t thread, itemID, number int64, title, text string) { var ids []int64 for _, name := range autolink.Mentions(text) { u, err := c.Store.UserByUsername(name) if err != nil { trimmed := strings.TrimRight(name, "._-") if trimmed == "" || trimmed == name { continue } if u, err = c.Store.UserByUsername(trimmed); err != nil { continue } } if u.ID == c.User.ID { continue } grant, err := c.Store.AccessRole(repo.ID, u.ID) if err != nil || !policy.CanRead(u, repo, grant) { continue } ids = append(ids, u.ID) } if len(ids) == 0 { return } c.Store.AddMentions(repo.ID, t.kind, itemID, ids) notify(c, ids, notice{repo: repo, kind: t.kind, number: number, direct: true, subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title), action: fmt.Sprintf("mentioned you in %s%d", t.symbol, number), excerpt: text, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)}) } // noticeBody builds the standard mail body: who did what, an excerpt, and // the web link. func noticeBody(c *Ctx, n notice) string { if n.body != "" { return n.body } var b strings.Builder fmt.Fprintf(&b, "%s %s\n", c.User.Username, n.action) if e := strings.TrimSpace(n.excerpt); e != "" { if len(e) > 500 { e = e[:500] + "…" } fmt.Fprintf(&b, "\n%s\n", e) } fmt.Fprintf(&b, "\n%s/%s\n", strings.TrimSuffix(c.Cfg.Server.SiteURL, "/"), n.path) return b.String() } // pushTitle and pushBody are the alert's two lines. The body is built // from the same two values AddNotice files, so the alert and the inbox // row cannot disagree about what happened. The title is the repository, // which also groups a repository's notices in Notification Center. func pushTitle(n notice) string { return n.repo.Path() } func pushBody(actor string, n notice) string { return actor + " " + n.action } func issueSubject(repo store.Repo, number int64, title string) string { return fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, title) } func mrSubject(repo store.Repo, number int64, title string) string { return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title) } func emitNotificationSettings(c *Ctx) int { mail, err := c.Store.MailEnabled(c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } watch, err := c.Store.WatchEnabled(c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } push, err := c.Store.PushEnabled(c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } reply, err := c.Store.ReplyEnabled(c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emitView(map[string]bool{"mail": mail, "watch": watch, "push": push, "reply": reply}, func(w io.Writer) { onOff := func(on bool) string { if on { return "on" } return "off" } v := c.view(w) v.fields( "mail", onOff(mail), "reply", onOff(reply), "watch", onOff(watch), "push", onOff(push), ) }, func() screen { var s screen for _, x := range []struct { label, name string on bool }{{"Mail", "mail", mail}, {"Reply", "reply", reply}, {"Watch", "watch", watch}, {"Push", "push", push}} { state, flip := cText("on"), "off" if !x.on { state, flip = cMeta("off"), "on" } s.fields = append(s.fields, field{x.label, []cell{state}}) s.actions = append(s.actions, action{"Settings", []string{"notifications", "settings", x.name, flip}}) } s.actions = append(s.actions, action{"Devices", []string{"notifications", "device", "list"}}) return s }) } func runNotificationsSettingsShow(c *Ctx, args []string) int { if len(args) != 0 { return c.usage() } return emitNotificationSettings(c) } // runNotificationsSettingsMail is the "inbox but no mail" switch: the // inbox is filed either way, the mail half consults it (#194). func runNotificationsSettingsMail(c *Ctx, args []string) int { if len(args) != 1 || (args[0] != "on" && args[0] != "off") { return c.usage() } if err := c.Store.SetMailEnabled(c.User.ID, args[0] == "on"); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return emitNotificationSettings(c) } // runNotificationsSettingsReply turns on a Reply-To on the account's // issue and merge request mail (#295). Turning it on is refused where // nothing reads the replies. func runNotificationsSettingsReply(c *Ctx, args []string) int { if len(args) != 1 || (args[0] != "on" && args[0] != "off") { return c.usage() } on := args[0] == "on" if on && !c.Cfg.Mail.Inbound.Enabled { return c.fail(protocol.ExitFailure, "this instance does not read replies to its mail ([mail.inbound] enabled = false); ask an admin") } if err := c.Store.SetReplyEnabled(c.User.ID, on); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return emitNotificationSettings(c) } // runNotificationsSettingsWatch is the default watch state for // repositories the account can write to: consulted when a notice is // delivered, so a grant or a revoke needs no watch row of its own (#194). func runNotificationsSettingsWatch(c *Ctx, args []string) int { if len(args) != 1 || (args[0] != "on" && args[0] != "off") { return c.usage() } if err := c.Store.SetWatchEnabled(c.User.ID, args[0] == "on"); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return emitNotificationSettings(c) } func runNotificationsSettingsPush(c *Ctx, args []string) int { if len(args) != 1 || (args[0] != "on" && args[0] != "off") { return c.usage() } if err := c.Store.SetPushEnabled(c.User.ID, args[0] == "on"); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return emitNotificationSettings(c) } // maxDeviceTokenBytes is well past APNs' 32-byte token rendered as 64 hex // characters, and stops a stdin that is not a token from becoming a row. const maxDeviceTokenBytes = 512 func runNotificationsDeviceAdd(c *Ctx, args []string) int { f, err := c.parseArgs(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } if len(f.Pos) != 0 { return c.usage() } // The registration itself would succeed and then deliver nothing, // while notifications settings show still reported push on. Say what // is actually wrong instead. if !c.Cfg.Push.Enabled { return c.fail(protocol.ExitFailure, "this instance does not send push notifications ([push] enabled = false); ask an admin") } raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxDeviceTokenBytes+1)) if err != nil { return c.fail(protocol.ExitFailure, "reading stdin: %v", err) } token := strings.TrimSpace(string(raw)) if token == "" { return c.usageWith("no device token on stdin") } if len(token) > maxDeviceTokenBytes { return c.fail(protocol.ExitUsage, "device token is too long") } id, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label")) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]any{"id": id, "status": "registered"}, func(w io.Writer) { fmt.Fprintf(w, "registered device %d\n", id) }) } func runNotificationsDeviceList(c *Ctx, args []string) int { if len(args) != 0 { return c.usage() } devices, err := c.Store.PushDevices(c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } type deviceRow struct { ID int64 `json:"id"` Label string `json:"label"` Token string `json:"token"` // truncated; a token is not echoed in full Added string `json:"added"` } rows := make([]deviceRow, 0, len(devices)) for _, d := range devices { rows = append(rows, deviceRow{ID: d.ID, Label: d.Label, Token: ShortToken(d.Token), Added: d.CreatedAt}) } return c.emitView(rows, func(w io.Writer) { tb := c.table(w, "ID", "LABEL", "TOKEN", "ADDED") for _, r := range rows { tb.row(cRef(fmt.Sprintf("%d", r.ID)), cText(r.Label), cText(r.Token), cAge(r.Added)) } tb.flush() }, func() screen { rs := make([]row, len(rows)) for i, r := range rows { rs[i] = rowOf(cRef(strconv.FormatInt(r.ID, 10)), cFlex(r.Label), cMeta(r.Token, "added "+relAge(r.Added, termNow()))) } return listScreen("Push devices", rs, action{"Devices", []string{"notifications", "device", "remove", ""}}, ) }) } // ShortToken renders a device token as its first eight characters. Enough // to tell two devices apart in a list, not enough to push to one. A real // APNs token is 64 hex characters, so anything at or under the cut length // is not a token worth showing part of — it is masked outright rather // than echoed whole, which "abc…" would imply is a truncation. // // Exported because the account page lists the same devices: one renderer, // so the two surfaces cannot come to disagree about what they print. func ShortToken(t string) string { if len(t) > 8 { return t[:8] + "…" } return "(short token)" } func runNotificationsDeviceRemove(c *Ctx, args []string) int { if len(args) != 1 { return c.usage() } id, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return c.usageWith("device id must be a number") } if err := c.Store.RemovePushDevice(c.User.ID, id); err != nil { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "no such device; notifications device list shows yours") } return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]string{"status": "removed"}, func(w io.Writer) { fmt.Fprintln(w, "device removed") }) } // noticesDefaultLimit caps a bare list; pagination reaches further back. const noticesDefaultLimit = 50 func runNotificationsList(c *Ctx, args []string) int { rest, p, code := parsePageFlags(c, args, "notifications", true) if code >= 0 { return code } fl, err := c.parseArgs(rest, flagSpec{Bools: []string{"--all"}, Usage: c.Cmd.Usage}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } all := fl.Has("--all") if p.limit == 0 { p.limit = noticesDefaultLimit } notices, err := c.Store.Inbox(c.User.ID, !all, p.queryLimit(), p.keyInt()) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } type out struct { ID int64 `json:"id"` Repo string `json:"repo"` Kind string `json:"kind"` Actor string `json:"actor"` Summary string `json:"summary"` Path string `json:"path"` CreatedAt string `json:"created_at"` ReadAt string `json:"read_at,omitempty"` } notices, next := trimPage(p, notices, "notifications", func(n store.Notice) string { return strconv.FormatInt(n.ID, 10) }) ds := make([]out, 0, len(notices)) for _, n := range notices { ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt}) } if !c.JSON && !p.active && len(ds) == 0 { msg := "nothing to list" if !all { if read, err := c.Store.Inbox(c.User.ID, false, 1, 0); err == nil && len(read) > 0 { msg = "no unread notifications (--all for read ones)" } } fmt.Fprintln(c.Stderr, msg) return protocol.ExitOK } return c.emitPageView(p, ds, next, func(w io.Writer) { tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH") for _, d := range ds { mark := "*" if d.ReadAt != "" { mark = " " } tb.row(cRef(fmt.Sprintf("%s %d", mark, d.ID)), cAge(d.CreatedAt), cRef(d.Repo), cFlex(fmt.Sprintf("%s %s", d.Actor, d.Summary)), cText(d.Path)) } tb.flush() }, func() screen { rows := make([]row, len(ds)) var read []string for i, d := range ds { lead := cGlyph("") if d.ReadAt == "" { lead = cYou() } rows[i] = rowOf(cRef(d.Repo), lead, cFlex(d.Actor+" "+d.Summary), cMeta(d.Kind, relAge(d.CreatedAt, termNow()))) if read == nil && (d.Kind == "issue" || d.Kind == "mr") { if j := strings.LastIndex(d.Path, "/"); j >= 0 { read = []string{d.Kind, "show", d.Repo, d.Path[j+1:]} } } } s := listScreen("Notifications", rows, action{"Inbox", []string{"notifications", "read", "--all"}}) if read != nil { s.actions = append(s.actions, action{"Read", read}) } return s }) } func runNotificationsRead(c *Ctx, args []string) int { fl, err := c.parseArgs(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: c.Cmd.Usage}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } // --all and a list of ids are two ways of saying which rows: taking // both would leave which one won unstated. if fl.Has("--all") == (len(fl.Pos) > 0) { return c.usage() } var ids []int64 for _, a := range fl.Pos { n, err := strconv.ParseInt(a, 10, 64) if err != nil { return c.fail(protocol.ExitUsage, "bad notification id %q", a) } ids = append(ids, n) } n, err := c.Store.MarkNoticesRead(c.User.ID, ids) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]int64{"read": n}, func(w io.Writer) { fmt.Fprintf(w, "marked %d read\n", n) }) } func runRepoWatch(c *Ctx, args []string) int { return setWatch(c, args, "watch", "watching") } func runRepoMute(c *Ctx, args []string) int { return setWatch(c, args, "mute", "muted") } func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "unwatch", "default") } // setWatch records the caller's state on a repository. "default" deletes // the row: watch then unwatch leaves no trace, and a mute is undone the // same way. func setWatch(c *Ctx, args []string, verb, state string) int { if len(args) != 1 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanRead) if code >= 0 { return code } var err error if state == "default" { err = c.Store.ClearRepoWatch(repo.ID, c.User.ID) } else { err = c.Store.SetRepoWatch(repo.ID, c.User.ID, state) } if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]string{"repo": repo.Path(), "state": state}, func(w io.Writer) { fmt.Fprintf(w, "%s %s\n", state, repo.Path()) }) }