package api import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "net/http" "strconv" "strings" "time" "git.ryuvia.com/niklas/terdut-server/internal/models" "github.com/go-chi/chi/v5" ) 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 { respond(w, http.StatusInternalServerError, errResp("internal error")) return } passwordHash = &h } var count int if err := db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM users").Scan(&count); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } if count > 0 { respond(w, http.StatusForbidden, errResp("bootstrap already completed")) return } var userID int64 if err := db.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 { respond(w, http.StatusInternalServerError, errResp("internal error")) return } raw, hash, err := randomToken() if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } var keyID int64 if err := db.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 { respond(w, http.StatusInternalServerError, errResp("internal error")) return } // The default team exists from migration 003, on a fresh install too. // Without a membership the first user signs in to a working server with // no queue, no schedule and nowhere for an integration to hang off. if teamID, err := defaultTeamID(r.Context(), db); err == nil { db.ExecContext(r.Context(), //nolint:errcheck "INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3) "+ "ON CONFLICT (team_id, user_id) DO NOTHING", teamID, userID, models.RoleOwner) } 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}) } } func handleListUsers(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rows, err := db.QueryContext(r.Context(), "SELECT id, username, email, created_at, ntfy_topic, is_admin FROM users ORDER BY id") if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } defer rows.Close() users := []models.User{} for rows.Next() { var u models.User var ts int64 if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } u.CreatedAt = time.Unix(ts, 0).UTC() users = append(users, u) } 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 } respond(w, http.StatusInternalServerError, errResp("internal error")) return } user, _ := fetchUser(r.Context(), db, id) respond(w, http.StatusCreated, user) } } // 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 { respond(w, http.StatusInternalServerError, errResp("internal error")) 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 { respond(w, http.StatusInternalServerError, errResp("internal error")) 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) 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 { respond(w, http.StatusInternalServerError, errResp("internal error")) 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 { respond(w, http.StatusInternalServerError, errResp("internal error")) return } n, _ := res.RowsAffected() if n == 0 { respond(w, http.StatusNotFound, errResp("user not found")) return } w.WriteHeader(http.StatusNoContent) } } 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"` } 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 } 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 { respond(w, http.StatusInternalServerError, errResp("internal error")) return } var keyID int64 if err := db.QueryRowContext(r.Context(), "INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id", userID, hash, req.Name).Scan(&keyID); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()} respond(w, http.StatusCreated, key) } } 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 { respond(w, http.StatusInternalServerError, errResp("internal error")) 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 err := db.QueryRowContext(ctx, "SELECT id, username, email, created_at, ntfy_topic, is_admin FROM users WHERE id = $1", id). Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin) if err != nil { return u, err } u.CreatedAt = time.Unix(ts, 0).UTC() 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 { 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 { respond(w, http.StatusInternalServerError, errResp("internal error")) 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 { respond(w, http.StatusInternalServerError, errResp("internal error")) 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 { respond(w, http.StatusInternalServerError, errResp("internal error")) 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 }