diff --git a/internal/api/helpers.go b/internal/api/helpers.go new file mode 100644 index 0000000..d2a040d --- /dev/null +++ b/internal/api/helpers.go @@ -0,0 +1,21 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +func respond(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func decodeJSON(r *http.Request, v any) error { + defer r.Body.Close() + return json.NewDecoder(r.Body).Decode(v) +} + +func errResp(msg string) map[string]string { + return map[string]string{"error": msg} +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..ad9817b --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,63 @@ +package api + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "net/http" + "strings" + "time" + + "github.com/yeniklas/terdut-server/internal/models" +) + +type contextKey string + +const ctxUser contextKey = "user" + +func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || token == "" { + respond(w, http.StatusUnauthorized, errResp("unauthorized")) + return + } + + h := sha256.Sum256([]byte(token)) + hash := hex.EncodeToString(h[:]) + + var keyID, userID int64 + err := db.QueryRowContext(r.Context(), + "SELECT id, user_id FROM api_keys WHERE key_hash = ?", hash, + ).Scan(&keyID, &userID) + if err != nil { + respond(w, http.StatusUnauthorized, errResp("unauthorized")) + return + } + + // best-effort; don't fail the request if this update fails + db.ExecContext(r.Context(), + "UPDATE api_keys SET last_used_at = ? WHERE id = ?", + time.Now().Unix(), keyID) + + var u models.User + var createdUnix int64 + if err := db.QueryRowContext(r.Context(), + "SELECT id, username, email, created_at FROM users WHERE id = ?", userID, + ).Scan(&u.ID, &u.Username, &u.Email, &createdUnix); err != nil { + respond(w, http.StatusUnauthorized, errResp("unauthorized")) + return + } + u.CreatedAt = time.Unix(createdUnix, 0).UTC() + + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u))) + }) + } +} + +func userFromContext(ctx context.Context) (models.User, bool) { + u, ok := ctx.Value(ctxUser).(models.User) + return u, ok +} diff --git a/internal/api/router.go b/internal/api/router.go index a7135dc..58040dc 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2,7 +2,6 @@ package api import ( "database/sql" - "encoding/json" "net/http" "github.com/go-chi/chi/v5" @@ -15,8 +14,21 @@ func NewRouter(db *sql.DB) http.Handler { r.Use(middleware.Recoverer) r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + respond(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + // Unauthenticated: bootstrap (only works when user table is empty). + r.Post("/api/bootstrap", handleBootstrap(db)) + + // All other /api routes require a valid API key. + r.Group(func(r chi.Router) { + r.Use(AuthMiddleware(db)) + + r.Get("/api/users", handleListUsers(db)) + r.Post("/api/users", handleCreateUser(db)) + r.Delete("/api/users/{id}", handleDeleteUser(db)) + r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db)) + r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db)) }) return r diff --git a/internal/api/users.go b/internal/api/users.go new file mode 100644 index 0000000..5117ee2 --- /dev/null +++ b/internal/api/users.go @@ -0,0 +1,240 @@ +package api + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/yeniklas/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 := newAPIKey() + 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 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); 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) + } +} + +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 := newAPIKey() + 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) + } +} + +// newAPIKey generates a random 32-byte key encoded as hex, plus its SHA-256 hash for storage. +func newAPIKey() (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 FROM users WHERE id = ?", id). + Scan(&u.ID, &u.Username, &u.Email, &ts) + if err != nil { + return u, err + } + u.CreatedAt = time.Unix(ts, 0).UTC() + return u, nil +} diff --git a/internal/db/migrations/002_users_api_keys.sql b/internal/db/migrations/002_users_api_keys.sql new file mode 100644 index 0000000..5e5a9df --- /dev/null +++ b/internal/db/migrations/002_users_api_keys.sql @@ -0,0 +1,15 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +CREATE TABLE api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + last_used_at INTEGER +); diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..421306a --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,19 @@ +package models + +import "time" + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +type APIKey struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + Key string `json:"key,omitempty"` // populated only on creation, never stored +}