Files
terdut-server/internal/api/users.go
T
Niklas Ye 03504b61be
CI / test (push) Successful in 5s
gofmt: restore import grouping after the module rename
The rename to git.ryuvia.com/niklas/... was a plain string substitution, so it
left the import blocks in their old order. The new path sorts before
github.com/go-chi/..., where the old one sorted after, which gofmt considers
unformatted.

go vet does not look at import order, so CI had nothing to say about it.
2026-08-19 21:21:33 +02:00

287 lines
8.3 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"`
}
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 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
}
res, err := db.ExecContext(r.Context(),
"INSERT INTO users (username, email) VALUES (?, ?)", req.Username, req.Email)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
userID, _ := res.LastInsertId()
raw, hash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
keyRes, err := db.ExecContext(r.Context(),
"INSERT INTO api_keys (user_id, key_hash, name) VALUES (?, ?, ?)", userID, hash, "bootstrap")
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
keyID, _ := keyRes.LastInsertId()
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
}
res, err := db.ExecContext(r.Context(),
"INSERT INTO users (username, email) VALUES (?, ?)", req.Username, req.Email)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
respond(w, http.StatusConflict, errResp("username or email already exists"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
id, _ := res.LastInsertId()
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 = ? WHERE id = ?", 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 = ?", 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 = ?", 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
}
res, err := db.ExecContext(r.Context(),
"INSERT INTO api_keys (user_id, key_hash, name) VALUES (?, ?, ?)", userID, hash, req.Name)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
keyID, _ := res.LastInsertId()
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 = ? AND user_id = ?", 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 = ?", 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
}