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
This commit is contained in:
Niklas Ye
2026-10-12 21:00:40 +02:00
parent 6292ad6708
commit b114251f45
42 changed files with 2200 additions and 611 deletions
+22 -56
View File
@@ -8,7 +8,6 @@ import (
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
@@ -109,15 +108,16 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
// handleListUsers is readable by anyone signed in, because the assignment
// control and the schedule need to name people. What it returns about other
// people is therefore only what naming them takes: email and ntfy_topic are
// blanked unless the caller is an admin or the row is their own. The topic in
// particular is a publish secret.
// people is therefore only what naming them takes: email is blanked unless the
// caller is an admin or the row is their own, and channels are listed only for
// the caller themselves (with addresses) and for an admin (without, since an
// address can be a secret).
func handleListUsers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller, _ := userFromContext(r.Context())
seeAll := caller.IsAdmin
rows, err := db.QueryContext(r.Context(),
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users ORDER BY id")
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users ORDER BY id")
if err != nil {
serverError(w, r, err)
return
@@ -129,7 +129,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
var u models.User
var ts int64
var disabled *int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
serverError(w, r, err)
return
}
@@ -137,7 +137,6 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
u.DisabledAt = unixPtr(disabled)
if !seeAll && u.ID != caller.ID {
u.Email = ""
u.NtfyTopic = nil
}
users = append(users, u)
}
@@ -145,6 +144,20 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
serverError(w, r, err)
return
}
rows.Close()
// After the user cursor is closed: the pool may be a single connection.
for i := range users {
if !seeAll && users[i].ID != caller.ID {
continue
}
channels, err := loadChannels(r.Context(), db, users[i].ID, users[i].ID == caller.ID)
if err != nil {
serverError(w, r, err)
return
}
users[i].Channels = channels
}
respond(w, http.StatusOK, users)
}
}
@@ -180,53 +193,6 @@ func handleCreateUser(db *sql.DB) http.HandlerFunc {
}
}
// handleSetNotifyTarget points a user's push notifications at an ntfy topic, or
// clears it with an empty string. The topic is a shared secret with the ntfy
// server — anyone who knows it can publish to it — so pick an unguessable one
// unless your ntfy enforces access control.
func handleSetNotifyTarget(db *sql.DB) 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 !requireSelfOrAdmin(w, r, id) {
return
}
var req struct {
NtfyTopic string `json:"ntfy_topic"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
var topic *string
if t := strings.TrimSpace(req.NtfyTopic); t != "" {
topic = &t
}
res, err := db.ExecContext(r.Context(),
"UPDATE users SET ntfy_topic = $1 WHERE id = $2", topic, id)
if err != nil {
serverError(w, r, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
user, err := fetchUser(r.Context(), db, id)
if err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, user)
}
}
func handleDeleteUser(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
@@ -431,8 +397,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var ts int64
var disabled *int64
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled)
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled)
if err != nil {
return u, err
}