Files
terdut-server/internal/api/channels.go
T
Niklas Ye b114251f45 Add notification channels and a provider abstraction
Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-12 21:00:40 +02:00

391 lines
12 KiB
Go

package api
import (
"context"
"database/sql"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
// maxChannelsPerUser bounds how many channels one account can hold. Nobody needs
// more than a handful, and every enabled one is a delivery per page.
const maxChannelsPerUser = 10
// loadChannels reads a user's channels. withAddress is false for anyone but the
// owner: an address can be a secret (an ntfy topic lets anyone holding it
// publish to it), and an administrator has no need of it to run the install.
func loadChannels(ctx context.Context, q querier, userID int64, withAddress bool) ([]models.NotificationChannel, error) {
rows, err := q.QueryContext(ctx, `
SELECT id, type, address, enabled, created_at
FROM notification_channels WHERE user_id = $1 ORDER BY id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.NotificationChannel{}
for rows.Next() {
var c models.NotificationChannel
var ts int64
if err := rows.Scan(&c.ID, &c.Type, &c.Address, &c.Enabled, &ts); err != nil {
return nil, err
}
c.CreatedAt = time.Unix(ts, 0).UTC()
if !withAddress {
c.Address = ""
}
out = append(out, c)
}
return out, rows.Err()
}
// hasReachableChannel reports whether a page to this user would go anywhere: an
// enabled channel of a type this server can deliver. It is the one definition of
// "pageable" the team and escalation pages share with the notifier, so they
// cannot promise a page that the notifier would skip.
func hasReachableChannel(ctx context.Context, q querier, providers *notify.Registry, userID int64) (bool, error) {
rows, err := q.QueryContext(ctx,
"SELECT type FROM notification_channels WHERE user_id = $1 AND enabled", userID)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var typ string
if err := rows.Scan(&typ); err != nil {
return false, err
}
if providers.Has(typ) {
return true, nil
}
}
return false, rows.Err()
}
// validateTarget checks a fallback target. Both halves empty is "none", which is
// fine; one half alone is a mistake; and the type must be a provider this server
// has, since a fallback nobody can deliver would look configured and page
// nobody.
func validateTarget(providers *notify.Registry, t notify.Target) (notify.Target, error) {
if t.IsZero() {
return notify.Target{}, nil
}
if t.Type == "" || t.Address == "" {
return notify.Target{}, errors.New("type and address go together: set both, or neither")
}
p, ok := providers.Get(t.Type)
if !ok {
return notify.Target{}, errors.New("this server has no " + strconv.Quote(t.Type) + " provider")
}
address, err := p.Validate(t.Address)
if err != nil {
return notify.Target{}, err
}
return notify.Target{Type: t.Type, Address: address}, nil
}
// providerTypes lists the registered provider types, as a query parameter for
// "channels this server can deliver". Never nil: a nil slice is NULL to Postgres,
// and `= ANY(NULL)` is not false.
func providerTypes(providers *notify.Registry) []string {
types := []string{}
for _, p := range providers.List() {
types = append(types, p.Type())
}
return types
}
// providerView is what the UI needs to offer a channel type.
type providerView struct {
Type string `json:"type"`
Label string `json:"label"`
AddressLabel string `json:"address_label"`
AddressHint string `json:"address_hint"`
}
// handleListProviders says which kinds of channel this server can deliver, so
// the UI offers only those. Readable by anyone signed in: it names no secret.
func handleListProviders(providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := []providerView{}
for _, p := range providers.List() {
out = append(out, providerView{
Type: p.Type(), Label: p.Label(),
AddressLabel: p.AddressLabel(), AddressHint: p.AddressHint(),
})
}
respond(w, http.StatusOK, out)
}
}
// channelOwner parses {id} and checks the caller may act on that user's
// channels: themselves, or an administrator.
func channelOwner(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return 0, false
}
if !requireSelfOrAdmin(w, r, id) {
return 0, false
}
return id, true
}
func isOwner(r *http.Request, userID int64) bool {
caller, ok := userFromContext(r.Context())
return ok && caller.ID == userID
}
// handleListChannels returns a user's channels. The owner sees the addresses; an
// administrator sees the types and whether each is on.
func handleListChannels(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := channelOwner(w, r)
if !ok {
return
}
channels, err := loadChannels(r.Context(), db, id, isOwner(r, id))
if err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, channels)
}
}
// handleCreateChannel adds a channel to a user. The same address twice on one
// account is a 409 rather than two pages for one phone.
func handleCreateChannel(db *sql.DB, providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := channelOwner(w, r)
if !ok {
return
}
var req struct {
Type string `json:"type"`
Address string `json:"address"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
p, ok := providers.Get(req.Type)
if !ok {
respond(w, http.StatusBadRequest,
errResp("this server has no "+strconv.Quote(req.Type)+" provider"))
return
}
address, err := p.Validate(req.Address)
if err != nil {
respond(w, http.StatusBadRequest, errResp(err.Error()))
return
}
var count int
if err := db.QueryRowContext(r.Context(),
"SELECT COUNT(*) FROM notification_channels WHERE user_id = $1", id).Scan(&count); err != nil {
serverError(w, r, err)
return
}
if count >= maxChannelsPerUser {
respond(w, http.StatusConflict,
errResp("an account can have at most "+strconv.Itoa(maxChannelsPerUser)+" notification channels"))
return
}
var channelID int64
err = db.QueryRowContext(r.Context(), `
INSERT INTO notification_channels (user_id, type, address)
VALUES ($1, $2, $3) RETURNING id`, id, p.Type(), address).Scan(&channelID)
if err != nil {
switch {
case isUniqueViolation(err):
respond(w, http.StatusConflict, errResp("that channel already exists"))
case isForeignKeyViolation(err):
respond(w, http.StatusNotFound, errResp("user not found"))
default:
serverError(w, r, err)
}
return
}
respondChannel(w, r, db, id, channelID, http.StatusCreated)
}
}
// handleUpdateChannel turns a channel on or off, or changes its address. Off is
// the mute button for one channel: it stays in the list, and pages skip it.
func handleUpdateChannel(db *sql.DB, providers *notify.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := channelOwner(w, r)
if !ok {
return
}
channelID, err := strconv.ParseInt(chi.URLParam(r, "channelID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid channel id"))
return
}
var req struct {
Enabled *bool `json:"enabled"`
Address *string `json:"address"`
}
if err := decodeJSON(r, &req); err != nil || (req.Enabled == nil && req.Address == nil) {
respond(w, http.StatusBadRequest, errResp("enabled or address is required"))
return
}
var typ string
if err := db.QueryRowContext(r.Context(),
"SELECT type FROM notification_channels WHERE id = $1 AND user_id = $2",
channelID, id).Scan(&typ); errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("channel not found"))
return
} else if err != nil {
serverError(w, r, err)
return
}
if req.Address != nil {
p, ok := providers.Get(typ)
if !ok {
respond(w, http.StatusConflict,
errResp("this server no longer has a "+strconv.Quote(typ)+" provider, so its address cannot be checked"))
return
}
address, err := p.Validate(*req.Address)
if err != nil {
respond(w, http.StatusBadRequest, errResp(err.Error()))
return
}
if _, err := db.ExecContext(r.Context(),
"UPDATE notification_channels SET address = $1 WHERE id = $2", address, channelID); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("that channel already exists"))
return
}
serverError(w, r, err)
return
}
}
if req.Enabled != nil {
if _, err := db.ExecContext(r.Context(),
"UPDATE notification_channels SET enabled = $1 WHERE id = $2", *req.Enabled, channelID); err != nil {
serverError(w, r, err)
return
}
}
respondChannel(w, r, db, id, channelID, http.StatusOK)
}
}
// handleDeleteChannel removes a channel. Notifications already queued for it
// keep their address snapshot and are still delivered; later reminders skip it.
func handleDeleteChannel(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := channelOwner(w, r)
if !ok {
return
}
channelID, err := strconv.ParseInt(chi.URLParam(r, "channelID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid channel id"))
return
}
res, err := db.ExecContext(r.Context(),
"DELETE FROM notification_channels WHERE id = $1 AND user_id = $2", channelID, id)
if err != nil {
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("channel not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// respondChannel writes one channel back, with the address only to its owner.
func respondChannel(w http.ResponseWriter, r *http.Request, db *sql.DB, userID, channelID int64, status int) {
channels, err := loadChannels(r.Context(), db, userID, isOwner(r, userID))
if err != nil {
serverError(w, r, err)
return
}
for _, c := range channels {
if c.ID == channelID {
respond(w, status, c)
return
}
}
respond(w, http.StatusNotFound, errResp("channel not found"))
}
// handleTestChannel sends one test message through a channel.
//
// The point of the first-run checklist's notification step is not that an
// address has been typed but that a phone buzzes, and only the person holding it
// can tell whether it did. Sent directly rather than through the outbox: the
// outbox row requires an incident, and this deliberately belongs to no incident.
//
// Owner only, not an administrator: a test is a page, and nobody should be woken
// by somebody else's click.
func handleTestChannel(db *sql.DB, cfg NotifyConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !isOwner(r, id) {
respond(w, http.StatusForbidden, errResp("a test can only be sent to your own channel"))
return
}
channelID, err := strconv.ParseInt(chi.URLParam(r, "channelID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid channel id"))
return
}
var typ, address string
if err := db.QueryRowContext(r.Context(),
"SELECT type, address FROM notification_channels WHERE id = $1 AND user_id = $2",
channelID, id).Scan(&typ, &address); errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("channel not found"))
return
} else if err != nil {
serverError(w, r, err)
return
}
p, ok := cfg.Providers.Get(typ)
if !ok {
respond(w, http.StatusServiceUnavailable,
errResp("this server has no "+strconv.Quote(typ)+" provider configured, so it can send nothing there"))
return
}
if err := p.Send(r.Context(), notify.Message{
Address: address,
Kind: notify.KindTest,
Title: "terdut test",
Body: "If this arrived, your notifications work.",
}); err != nil {
// The failure is the useful part here: a wrong address, a credential
// the provider rejects, or a provider that is down all look the same
// from the phone, which is silence.
respond(w, http.StatusBadGateway, errResp(p.Label()+" rejected the test: "+err.Error()))
return
}
w.WriteHeader(http.StatusNoContent)
}
}