package store
import (
"crypto/rand"
"encoding/base64"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
type NotificationStatus string
const (
NotificationPending NotificationStatus = "pending"
NotificationAccepted NotificationStatus = "accepted"
NotificationDismissed NotificationStatus = "dismissed"
)
// NotificationKind distinguishes what a Notification is about — the
// federation trust-grant notifications this type originally existed for
// (NotificationGrant, the zero value so old records without a Kind still
// read as grants) versus purely-local activity on a repo the recipient
// owns or a merge request they opened.
type NotificationKind string
const (
NotificationGrant NotificationKind = "grant"
NotificationMROpened NotificationKind = "mr_opened"
NotificationMRComment NotificationKind = "mr_comment"
)
// Notification is a purely advisory record — for NotificationGrant, "some
// other instance says you were granted access to one of its repos"; for
// NotificationMROpened/NotificationMRComment, "a merge request you care
// about changed" on this instance. It is never itself a credential —
// accepting a grant notification only adds a PinnedRepo bookmark, and the
// access it describes is (or isn't) real entirely independently of this
// record, enforced by the granting instance's own ACL when the repo is
// actually used. See internal/federation's notify sender/receiver for how
// grant notifications get here and what's verified before one is ever
// created; MR notifications are created locally by internal/admin instead.
type Notification struct {
ID string `json:"id"`
Principal string `json:"principal"` // local recipient
FromDomain string `json:"from_domain"`
Repo string `json:"repo"`
Role string `json:"role"`
Actor string `json:"actor"` // who granted it, e.g. "alice@chez-moi.fr"
Kind NotificationKind `json:"kind,omitempty"`
MRNumber int `json:"mr_number,omitempty"`
Title string `json:"title,omitempty"` // MR title, for mr_opened/mr_comment
Status NotificationStatus `json:"status"`
ReceivedAt time.Time `json:"received_at"`
}
// EffectiveKind is Kind, defaulting to NotificationGrant for records
// created before Kind existed.
func (n Notification) EffectiveKind() NotificationKind {
if n.Kind == "" {
return NotificationGrant
}
return n.Kind
}
func notifKey(principal, id string) string { return principal + "\x00" + id }
// MaxNotificationsPerPrincipal bounds how many notifications one recipient
// accumulates — nothing about receiving one requires any relationship to
// exist first (see internal/federation/notify.go), so without a cap, any
// instance could bloat a real user's slice of the store indefinitely by
// sending many distinct fake claims. Oldest is evicted to make room rather
// than rejecting the newest, so spam can't hide a legitimate notification
// behind it.
const MaxNotificationsPerPrincipal = 200
// CreateNotification records n, generating an ID if it doesn't have one.
// If a pending notification already exists for the same
// (Principal, FromDomain, Repo, Actor, Kind, MRNumber) — the common case
// being the same grant notified twice, a role change re-notified, or
// several comments on the same MR by the same author — its timestamp
// (and role, for grants) is refreshed instead of creating a duplicate
// entry. MRNumber is part of the match so two different MRs from the same
// actor on the same repo stay as distinct notifications.
func (s *Store) CreateNotification(n Notification) error {
return s.db.Update(func(tx *bolt.Tx) error {
existing, err := listJSONPrefix[Notification](tx, bucketNotifs, n.Principal+"\x00")
if err != nil {
return err
}
for _, e := range existing {
if e.Status == NotificationPending && e.FromDomain == n.FromDomain && e.Repo == n.Repo &&
e.Actor == n.Actor && e.EffectiveKind() == n.EffectiveKind() && e.MRNumber == n.MRNumber {
e.Role = n.Role
e.Title = n.Title
e.ReceivedAt = time.Now().UTC()
return putJSON(tx, bucketNotifs, notifKey(e.Principal, e.ID), e)
}
}
if len(existing) >= MaxNotificationsPerPrincipal {
oldest := existing[0]
for _, e := range existing[1:] {
if e.ReceivedAt.Before(oldest.ReceivedAt) {
oldest = e
}
}
if err := deleteKey(tx, bucketNotifs, notifKey(oldest.Principal, oldest.ID)); err != nil {
return err
}
}
if n.ID == "" {
id, err := randomToken()
if err != nil {
return err
}
n.ID = id
}
n.Status = NotificationPending
n.ReceivedAt = time.Now().UTC()
return putJSON(tx, bucketNotifs, notifKey(n.Principal, n.ID), n)
})
}
// ListNotifications returns all of principal's notifications, newest first.
func (s *Store) ListNotifications(principal string) ([]Notification, error) {
var out []Notification
err := s.db.View(func(tx *bolt.Tx) error {
var err error
out, err = listJSONPrefix[Notification](tx, bucketNotifs, principal+"\x00")
return err
})
sort.Slice(out, func(i, j int) bool { return out[i].ReceivedAt.After(out[j].ReceivedAt) })
return out, err
}
// CountPendingNotifications is a small helper for the nav badge, so it
// doesn't need to unmarshal and sort the full list just for a count.
func (s *Store) CountPendingNotifications(principal string) (int, error) {
all, err := s.ListNotifications(principal)
if err != nil {
return 0, err
}
n := 0
for _, e := range all {
if e.Status == NotificationPending {
n++
}
}
return n, nil
}
// SetNotificationStatus updates one of principal's own notifications. It's a
// no-op (not an error) if the ID doesn't belong to principal or doesn't
// exist, so a stale/tampered ID in a form submission can't probe for other
// users' notification IDs.
func (s *Store) SetNotificationStatus(principal, id string, status NotificationStatus) error {
return s.db.Update(func(tx *bolt.Tx) error {
var n Notification
key := notifKey(principal, id)
if err := getJSON(tx, bucketNotifs, key, &n); err != nil {
if err == ErrNotFound {
return nil
}
return err
}
n.Status = status
return putJSON(tx, bucketNotifs, key, n)
})
}
func randomToken() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}