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:
+22
-56
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user