Files
terdut-server/internal/api/users.go
T
Niklas Ye 289eca8076
CI / test (push) Successful in 2m15s
Move to Gitea: git.ryuvia.com/niklas/terdut-server
The module path, the container image, the Helm chart and the CI pipeline all
named GitHub. They now name the Gitea instance everything else already runs on.

The workflows are rewritten rather than translated. Gitea's runner image is
ubuntu:22.04, whose nodejs is Node 12, so no JS action runs there at all --
actions/checkout@v4 dies with a SyntaxError before it does anything. Every step
is shell, checkout is a plain clone (this repo is public, so it needs no
credential), and the jobs that need docker or helm run in host mode because the
dind bridge a `container:` job gets cannot reach github.com or get.helm.sh.

Two consequences worth naming:

- upload-artifact/download-artifact are also JS actions, and there is no
  artifact store here, so the job that builds the binaries is the job that
  publishes them. Nothing is passed between jobs.
- setup-qemu-action is gone with the rest, and the runner has no binfmt
  registration. The Dockerfile's builder stage now runs on $BUILDPLATFORM and
  cross-compiles from TARGETARCH instead, which is what keeps the arm64 image
  buildable -- and makes it native rather than emulated.

The chart moves from a GitHub Pages index to an OCI artifact in Gitea's
registry. Publishing stays tag-only for the reason recorded in release.yaml: a
workflow triggered by the branch push cannot know the version it is about to be
tagged with.

The GitHub repository is left in place and untouched. Nothing pushes to it any
more, but its existing release downloads and chart index keep resolving.
2026-08-19 20:39:10 +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"
"github.com/go-chi/chi/v5"
"git.ryuvia.com/niklas/terdut-server/internal/models"
)
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
}