Gitfed
bastien-mrq/gitfed / internal / store / notifications.go
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
}