536dbcaebe
Users now have an avatar. The account page takes an image, lets the user
drag and zoom it under a circular mask, and uploads exactly what the circle
shows as a 256x256 PNG. The picture replaces the initial-letter circle in
the sidebar, on the account page, on the on-call hero and week strip, on
the schedule's day chips and in the incident assign menu; someone without
one keeps the initial.
The picture lives in Postgres (user_avatars, bytea) rather than on a
volume: the chart runs two replicas on a read-only root filesystem, and
the k8up pg_dump backup then covers it without any chart or operator
change. The server does not trust the client's crop. It decodes the PNG or
JPEG, checks the declared size before decoding, centre-crops to a square,
scales to 256 and re-encodes, which drops EXIF and anything that is not
pixels. Standard library only; WebP is cropped by the browser and arrives
as PNG, a raw WebP sent to the API is refused.
GET/PUT/DELETE /api/users/{id}/avatar: anyone signed in may read, only the
owner or an administrator may change. /api/me and /api/users gain
avatar_version, absent when there is no picture, which is also the ?v= that
busts the cache after a change. Incident timeline entries and the queue are
text-only and are unchanged.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
487 lines
15 KiB
Go
487 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// bootstrapLockKey is the transaction-scoped advisory lock handleBootstrap
|
|
// holds; distinct from the migration and notifier keys.
|
|
const bootstrapLockKey = 0x7465726475744254
|
|
|
|
func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
// Password is optional; without one the first user can only use the
|
|
// API key until somebody sets it.
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if req.Username == "" || req.Email == "" {
|
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
|
return
|
|
}
|
|
var passwordHash *string
|
|
if req.Password != "" {
|
|
if msg := validatePassword(req.Password); msg != "" {
|
|
respond(w, http.StatusBadRequest, errResp(msg))
|
|
return
|
|
}
|
|
h, err := hashPassword(req.Password)
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
passwordHash = &h
|
|
}
|
|
|
|
// Check-then-insert has to be one atomic step: two concurrent calls on
|
|
// an empty install would otherwise both see zero users and both create
|
|
// an admin. The transaction-scoped lock serialises them, and the loser
|
|
// sees the winner's row.
|
|
tx, err := db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
if _, err := tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock($1)", bootstrapLockKey); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
|
|
var count int
|
|
if err := tx.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
if count > 0 {
|
|
respond(w, http.StatusForbidden, errResp("bootstrap already completed"))
|
|
return
|
|
}
|
|
|
|
var userID int64
|
|
if err := tx.QueryRowContext(r.Context(),
|
|
"INSERT INTO users (username, email, password_hash, is_admin) VALUES ($1, $2, $3, true) RETURNING id",
|
|
req.Username, req.Email, passwordHash).Scan(&userID); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
|
|
raw, hash, err := randomToken()
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
var keyID int64
|
|
if err := tx.QueryRowContext(r.Context(),
|
|
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
|
|
userID, hash, "bootstrap").Scan(&keyID); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
|
|
user, _ := fetchUser(r.Context(), db, userID)
|
|
key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt}
|
|
respond(w, http.StatusCreated, map[string]any{"user": user, "api_key": key})
|
|
}
|
|
}
|
|
|
|
// 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 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, is_admin, admin_source, disabled_at, (SELECT updated_at FROM user_avatars WHERE user_id = users.id) FROM users ORDER BY id")
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
users := []models.User{}
|
|
for rows.Next() {
|
|
var u models.User
|
|
var ts int64
|
|
var disabled *int64
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
|
u.DisabledAt = unixPtr(disabled)
|
|
if !seeAll && u.ID != caller.ID {
|
|
u.Email = ""
|
|
}
|
|
users = append(users, u)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func handleCreateUser(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if req.Username == "" || req.Email == "" {
|
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
|
return
|
|
}
|
|
|
|
var id int64
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"INSERT INTO users (username, email) VALUES ($1, $2) RETURNING id",
|
|
req.Username, req.Email).Scan(&id); err != nil {
|
|
if isUniqueViolation(err) {
|
|
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
|
return
|
|
}
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
user, _ := fetchUser(r.Context(), db, id)
|
|
respond(w, http.StatusCreated, 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)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
|
return
|
|
}
|
|
// Deleting yourself is how an install ends up with no administrator at
|
|
// all, and it is never what somebody meant to do.
|
|
caller, _ := userFromContext(r.Context())
|
|
if caller.ID == id {
|
|
respond(w, http.StatusConflict, errResp("cannot delete your own account"))
|
|
return
|
|
}
|
|
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
} else if last {
|
|
respond(w, http.StatusConflict, errResp("cannot delete the last administrator"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", id)
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// maxAPIKeyExpiryDays bounds expires_in_days: generous enough for any real
|
|
// rotation policy, tight enough to reject a typo (a year in hours, say) that
|
|
// would otherwise mint a key that outlives the server by decades.
|
|
const maxAPIKeyExpiryDays = 3650 // ~10 years
|
|
|
|
func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, 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, userID) {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
// ExpiresInDays is optional and, left zero, means the key never
|
|
// expires — the only behavior any key had before this field
|
|
// existed, so an existing integration that does not send it is
|
|
// unaffected.
|
|
ExpiresInDays int64 `json:"expires_in_days,omitempty"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
|
return
|
|
}
|
|
if req.ExpiresInDays < 0 || req.ExpiresInDays > maxAPIKeyExpiryDays {
|
|
respond(w, http.StatusBadRequest, errResp("expires_in_days must be 0 (never expires) or up to "+strconv.Itoa(maxAPIKeyExpiryDays)))
|
|
return
|
|
}
|
|
|
|
var exists int
|
|
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
|
|
raw, hash, err := randomToken()
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
var expiresAt *int64
|
|
var expiresAtTime *time.Time
|
|
if req.ExpiresInDays > 0 {
|
|
t := time.Now().AddDate(0, 0, int(req.ExpiresInDays)).UTC()
|
|
u := t.Unix()
|
|
expiresAt = &u
|
|
expiresAtTime = &t
|
|
}
|
|
var keyID int64
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"INSERT INTO api_keys (user_id, key_hash, name, expires_at) VALUES ($1, $2, $3, $4) RETURNING id",
|
|
userID, hash, req.Name, expiresAt).Scan(&keyID); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
key := models.APIKey{
|
|
ID: keyID, UserID: userID, Name: req.Name, Key: raw,
|
|
CreatedAt: time.Now().UTC(), ExpiresAt: expiresAtTime,
|
|
}
|
|
respond(w, http.StatusCreated, key)
|
|
}
|
|
}
|
|
|
|
// handleListAPIKeys lists a user's own API keys: never the raw key itself
|
|
// (only ever returned once, at creation), just enough to tell them apart,
|
|
// see which are stale (last_used_at) and which are about to stop working
|
|
// (expires_at) — the data handleCreateAPIKey and apiKeyUser's last-use stamp
|
|
// already produce, with no endpoint to read it back until now.
|
|
func handleListAPIKeys(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, 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, userID) {
|
|
return
|
|
}
|
|
|
|
rows, err := db.QueryContext(r.Context(),
|
|
`SELECT id, name, created_at, last_used_at, expires_at
|
|
FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
keys := []models.APIKey{}
|
|
for rows.Next() {
|
|
var k models.APIKey
|
|
var created int64
|
|
var lastUsed, expires *int64
|
|
if err := rows.Scan(&k.ID, &k.Name, &created, &lastUsed, &expires); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
k.UserID = userID
|
|
k.CreatedAt = time.Unix(created, 0).UTC()
|
|
k.LastUsedAt = unixPtr(lastUsed)
|
|
k.ExpiresAt = unixPtr(expires)
|
|
keys = append(keys, k)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, keys)
|
|
}
|
|
}
|
|
|
|
func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, 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, userID) {
|
|
return
|
|
}
|
|
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid key id"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"DELETE FROM api_keys WHERE id = $1 AND user_id = $2", keyID, userID)
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("api key not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// randomToken generates a random 32-byte secret encoded as hex, plus its SHA-256
|
|
// hash for storage. Used for API keys and for notification acknowledge tokens.
|
|
func randomToken() (raw, hash string, err error) {
|
|
b := make([]byte, 32)
|
|
if _, err = rand.Read(b); err != nil {
|
|
return
|
|
}
|
|
raw = hex.EncodeToString(b)
|
|
h := sha256.Sum256([]byte(raw))
|
|
hash = hex.EncodeToString(h[:])
|
|
return
|
|
}
|
|
|
|
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
|
var u models.User
|
|
var ts int64
|
|
var disabled *int64
|
|
err := db.QueryRowContext(ctx,
|
|
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at, (SELECT updated_at FROM user_avatars WHERE user_id = users.id) FROM users WHERE id = $1", id).
|
|
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion)
|
|
if err != nil {
|
|
return u, err
|
|
}
|
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
|
u.DisabledAt = unixPtr(disabled)
|
|
return u, nil
|
|
}
|
|
|
|
// handleSetAdmin grants or revokes the system administrator flag.
|
|
//
|
|
// Revoking is guarded twice: an install must keep at least one administrator,
|
|
// and you cannot demote yourself. The first stops the flag being lost
|
|
// altogether; the second stops the likelier accident, where the only admin
|
|
// clears their own flag while tidying up and locks the door behind them.
|
|
func handleSetAdmin(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
|
|
}
|
|
var req struct {
|
|
IsAdmin *bool `json:"is_admin"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil || req.IsAdmin == nil {
|
|
respond(w, http.StatusBadRequest, errResp("is_admin is required"))
|
|
return
|
|
}
|
|
|
|
if !*req.IsAdmin {
|
|
var managed bool
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin AND admin_source = 'oidc')",
|
|
id).Scan(&managed); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
if managed {
|
|
respond(w, http.StatusConflict, errResp("administrator access is managed by single sign-on; change the user's groups in the identity provider"))
|
|
return
|
|
}
|
|
|
|
caller, _ := userFromContext(r.Context())
|
|
if caller.ID == id {
|
|
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))
|
|
return
|
|
}
|
|
if last, err := isLastAdmin(r.Context(), db, id); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
} else if last {
|
|
respond(w, http.StatusConflict, errResp("cannot revoke the last administrator"))
|
|
return
|
|
}
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"UPDATE users SET is_admin = $1 WHERE id = $2", *req.IsAdmin, 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)
|
|
}
|
|
}
|
|
|
|
// isLastAdmin reports whether id is an administrator and no other user is one.
|
|
// A non-admin id is never the last one, so removing them is always allowed.
|
|
func isLastAdmin(ctx context.Context, db *sql.DB, id int64) (bool, error) {
|
|
var last bool
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin)
|
|
AND NOT EXISTS (SELECT 1 FROM users WHERE id <> $1 AND is_admin)`, id).Scan(&last)
|
|
return last, err
|
|
}
|