dc39e3a5d3
First step of #1, and it goes first for one reason: #4 adds a team_id to nearly every table, and doing that twice -- once for SQLite, once for Postgres -- is work nobody gets paid for. The teams migrations now only have to be written against one database. The ten SQLite migrations are replaced by a single Postgres baseline rather than ported one by one. They were incremental in a way that has no value on a fresh install: 004 adds columns 008 drops again, and 008's backfill rewrites data a Postgres database never had. The history stays in git; the schema they add up to is now 001_baseline.sql. Timestamps stay BIGINT unix seconds and are NOT converted to timestamptz. Everything in Go already speaks epochs, so converting would have been a second, larger change riding along inside this one. It is worth doing on its own. The JSON columns did move to jsonb, because #4 will want to filter and index on labels. Most of the port is mechanical -- 170 placeholders from ? to $1 -- but four things needed more than a search and replace: * Dynamically built WHERE clauses cannot keep their numbering straight by hand, so they hand out placeholders through sqlArgs instead. A filter can now be added or reordered without renumbering anything. * SUM(resolved_at IS NULL) was SQLite counting a boolean as 0 or 1. Postgres has no sum(boolean), and this was breaking every dead man's switch -- silently, since the sweeper only logs. Now COUNT(*) FILTER. * unixepoch() became FLOOR(EXTRACT(EPOCH FROM now()))::bigint. The FLOOR is load-bearing: a bare cast rounds half up, so a row written at .6 of a second claimed a timestamp a second in the future and disagreed with the time.Now().Unix() the Go side stamps. * The unique-violation check matched SQLite's error text. It matches SQLSTATE 23505 now, so a renamed constraint cannot turn a 409 back into a 500. Tests need a real Postgres, because there is no in-memory Postgres the way there was an in-memory SQLite. Each test gets its own schema on a shared server -- cheaper than a database each, and still isolated. TERDUT_TEST_DSN says where it is; `make test-db` starts one locally and ci.yaml runs one as a service container. An unset DSN fails the suite rather than skipping it: a run that quietly tests nothing is worse than one that does not run. TestMigration_BackfillCarriesAckAndComments is deleted along with the migrations it replayed. What it protected -- an upgrade not losing acknowledgements and comments -- now belongs to scripts/sqlite-to-postgres.go, which is build-tagged so the SQLite driver stays out of the server binary. Both are meant to be deleted once this install has migrated. The chart loses the PVC, the data volume and the python backup sidecar, and requires database.dsnSecret.name: it provisions no database and cannot guess where the credentials live, so a render without it is meant to fail. Backups move to where Postgres actually runs. The other half of that -- the postgresql CR, the k8up pg_dump annotation and the network policy -- is a change to the wrapper chart in Ryuvia/charts and is not in here. Verified rather than assumed: the gate is green with -race against Postgres 17, govulncheck and gitleaks are clean, and the migration script was run end to end against a SQLite database built at the old schema and seeded in every table. Ids survive, so incidents keep their numbers and every foreign key still points where it did; the identity sequences are moved past the copied ids, and a webhook after the migration opened incident 12 rather than colliding at 1.
303 lines
8.9 KiB
Go
303 lines
8.9 KiB
Go
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) VALUES ($1, $2, $3) 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
|
|
}
|
|
|
|
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 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); 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
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
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 FROM users WHERE id = $1", id).
|
|
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic)
|
|
if err != nil {
|
|
return u, err
|
|
}
|
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
|
return u, nil
|
|
}
|