From da650c2b82a4661eef73525567c9b260c07ea8dd Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Thu, 19 Mar 2026 09:00:07 +0100 Subject: [PATCH] enhance bug report handling with pagination, filtering, and improved response structure --- CLAUDE.md | 79 ++++++++ go.mod | 3 + go.sum | 4 + internal/database/schema/migrator.go | 2 - internal/handlers/admin_auth.route.go | 251 +++++++++++++++++++++++ internal/handlers/admin_users.route.go | 263 +++++++++++++++++++++++++ internal/handlers/bug_report.route.go | 83 +++++++- internal/middleware/adminKey.go | 1 + internal/middleware/apikey.go | 1 + internal/models/bug_report.go | 5 + internal/models/session.go | 4 +- internal/models/user.go | 5 +- main.go | 23 +++ 13 files changed, 708 insertions(+), 16 deletions(-) create mode 100644 CLAUDE.md create mode 100644 internal/handlers/admin_auth.route.go create mode 100644 internal/handlers/admin_users.route.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5095ce2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Development (hot-reload via air) +air + +# Build production binary +go build -o ./build/emly-api.exe . + +# Run directly +go run . + +# Run tests +go test ./... + +# Run a single test +go test ./internal/... -run TestName -v +``` + +## Architecture + +This is a Go REST API for a bug reporting system ("EMLy"). It uses: +- **Router**: `go-chi/chi/v5` +- **Database**: MySQL via `jmoiron/sqlx` +- **Auth**: Header-based API key (`X-API-Key`) and admin key (`X-Admin-Key`) +- **Rate limiting**: `go-chi/httprate` (global: 100/min, route groups: 30/min by IP) + +### Request flow + +``` +main.go → chi router → global middleware → route group middleware → handler +``` + +Global middleware order: RequestID → RealIP → Logger → Recoverer → Timeout(30s) → RateLimitByIP + +### Route groups + +- **Public**: `GET /` (ping), `GET /v1/health` +- **API key only**: `POST /v1/api/bug-reports/`, `GET /v1/api/bug-reports/count` +- **API key + admin key**: All other `/v1/api/bug-reports/*` endpoints + +### Package layout + +- `internal/config/` — Loads config from env vars (via godotenv). Key vars: `PORT`, `DB_DSN`, `DATABASE_NAME`, `API_KEY`, `ADMIN_KEY`. +- `internal/database/` — MySQL connection pool setup with configurable limits. +- `internal/database/schema/` — Conditional migration system: `init.sql` bootstraps tables, `migrations/tasks.json` defines conditional tasks (e.g. `column_not_exists`), `migrations/*.sql` are the individual migration files. +- `internal/handlers/` — Factory functions returning `http.HandlerFunc`. The `*sqlx.DB` is passed in at construction. Response helpers (`jsonOK`, `jsonCreated`, `jsonError`) live in `response.go`. +- `internal/middleware/` — API key and admin key auth middleware; each loads allowed keys into a map at startup for O(1) lookup. +- `internal/models/` — Structs with `db:` and `json:` tags. Sensitive fields use `json:"-"`. + +### Handler conventions + +- Each handler file is named `.route.go`. +- Handlers are factory functions: `func CreateBugReport(db *sqlx.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ... } }`. +- All responses are JSON. Use `jsonOK`, `jsonCreated`, or `jsonError` from `response.go`. +- File uploads use `r.ParseMultipartForm(32 << 20)`. File streams must be explicitly closed. +- ZIP downloads: in-memory `archive/zip` with template-rendered report text via `internal/handlers/templates/report.txt.tmpl`. + +### Database migrations + +The migrator in `internal/database/schema/migrator.go` runs on startup: +1. Executes `init.sql` to ensure base tables exist. +2. Reads `migrations/tasks.json` for conditional tasks. +3. Checks each task's condition (e.g. `column_not_exists`) against the DB before running its SQL file. + +Supported condition types: `column_not_exists`, `column_exists`, `index_not_exists`, `index_exists`, `table_not_exists`, `table_exists`. + +## Environment + +Copy `.env.example` to `.env`. Required vars: `DB_DSN`, `DATABASE_NAME`, `API_KEY`, `ADMIN_KEY`. + +The `DB_DSN` must include `parseTime=true&loc=UTC`, e.g.: +``` +DB_DSN=root:secret@tcp(127.0.0.1:3306)/emly?parseTime=true&loc=UTC +``` \ No newline at end of file diff --git a/go.mod b/go.mod index ee318de..909c0d0 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,11 @@ require ( github.com/go-chi/httprate v0.14.1 github.com/go-sql-driver/mysql v1.8.1 github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.49.0 ) +require golang.org/x/sys v0.42.0 // indirect + require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index bd8534b..0822857 100644 --- a/go.sum +++ b/go.sum @@ -17,5 +17,9 @@ github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/database/schema/migrator.go b/internal/database/schema/migrator.go index 52023b0..e53b6bf 100644 --- a/internal/database/schema/migrator.go +++ b/internal/database/schema/migrator.go @@ -165,8 +165,6 @@ func evaluate(db *sqlx.DB, dbName string, c condition) (bool, error) { } } -// ---------- MySQL introspection helpers ---------- - func columnExists(db *sqlx.DB, dbName, table, column string) (bool, error) { var count int err := db.Get(&count, diff --git a/internal/handlers/admin_auth.route.go b/internal/handlers/admin_auth.route.go new file mode 100644 index 0000000..f05d147 --- /dev/null +++ b/internal/handlers/admin_auth.route.go @@ -0,0 +1,251 @@ +package handlers + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/jmoiron/sqlx" + "golang.org/x/crypto/argon2" + + "emly-api-go/internal/models" +) + +// argon2id params — mirror @node-rs/argon2 defaults used in the TypeScript service. +const ( + argonMemory = 19456 + argonTime = 2 + argonKeyLen = 32 + argonParallelism = 1 + argonSaltLen = 16 + + sessionExpiryDays = 30 +) + +// hashPassword produces a PHC-formatted argon2id string compatible with +// the @node-rs/argon2 library used in the TypeScript service. +func hashPassword(password string) (string, error) { + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonParallelism, argonKeyLen) + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + argonMemory, argonTime, argonParallelism, b64Salt, b64Hash), nil +} + +// verifyPassword checks a password against a PHC-formatted argon2id hash. +func verifyPassword(phc, password string) (bool, error) { + parts := strings.Split(phc, "$") + // Expected: ["", "argon2id", "v=19", "m=...,t=...,p=...", "", ""] + if len(parts) != 6 || parts[1] != "argon2id" { + return false, fmt.Errorf("invalid hash format") + } + var memory, timeCost uint32 + var parallelism uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, ¶llelism); err != nil { + return false, fmt.Errorf("invalid params: %w", err) + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, fmt.Errorf("invalid salt: %w", err) + } + hashBytes, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, fmt.Errorf("invalid hash: %w", err) + } + computed := argon2.IDKey([]byte(password), salt, timeCost, memory, parallelism, uint32(len(hashBytes))) + return subtle.ConstantTimeCompare(computed, hashBytes) == 1, nil +} + +// generateUUID generates a random UUID v4. +func generateUUID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil +} + +// generateSessionID returns a 64-char hex string (32 random bytes), +// matching the TypeScript generateSessionId() implementation. +func generateSessionID() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// authUser is the public representation returned to callers after login/validate. +type authUser struct { + ID string `json:"id"` + Username string `json:"username"` + Displayname string `json:"displayname"` + Role models.UserRole `json:"role"` + Enabled bool `json:"enabled"` +} + +// sessionHeader is the header name used to pass the session ID. +const sessionHeader = "X-Session-Token" + +// LoginUser handles POST /v1/api/admin/auth/login +func LoginUser(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if body.Username == "" || body.Password == "" { + jsonError(w, http.StatusBadRequest, "username and password are required") + return + } + + var row struct { + models.User + PasswordHash string `db:"password_hash"` + } + err := db.GetContext(r.Context(), &row, + "SELECT id, username, displayname, password_hash, role, enabled FROM `user` WHERE username = ? LIMIT 1", + body.Username, + ) + if err != nil { + // Return 401 whether the user doesn't exist or query failed to avoid enumeration + jsonError(w, http.StatusUnauthorized, "invalid credentials") + return + } + + valid, err := verifyPassword(row.PasswordHash, body.Password) + if err != nil || !valid { + jsonError(w, http.StatusUnauthorized, "invalid credentials") + return + } + + if !row.Enabled { + jsonError(w, http.StatusForbidden, "account disabled") + return + } + + sessionID, err := generateSessionID() + if err != nil { + jsonError(w, http.StatusInternalServerError, "failed to generate session: "+err.Error()) + return + } + expiresAt := time.Now().UTC().Add(sessionExpiryDays * 24 * time.Hour) + + if _, err := db.ExecContext(r.Context(), + "INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)", + sessionID, row.ID, expiresAt, + ); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + + log.Printf("[AUTH] User logged in: username=%s session=%s...", body.Username, sessionID[:8]) + + jsonOK(w, map[string]any{ + "session_id": sessionID, + "user": authUser{ + ID: row.ID, + Username: row.Username, + Displayname: row.Displayname, + Role: row.Role, + Enabled: row.Enabled, + }, + }) + } +} + +// ValidateSession handles GET /v1/api/admin/auth/validate +// Reads the session ID from the X-Session-ID header. +func ValidateSession(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sessionID := r.Header.Get(sessionHeader) + if sessionID == "" { + jsonError(w, http.StatusUnauthorized, "missing "+sessionHeader+" header") + return + } + + var row struct { + ID string `db:"id"` + Username string `db:"username"` + Displayname string `db:"displayname"` + Role models.UserRole `db:"role"` + Enabled bool `db:"enabled"` + ExpiresAt time.Time `db:"expires_at"` + } + err := db.GetContext(r.Context(), &row, + `SELECT u.id, u.username, u.displayname, u.role, u.enabled, s.expires_at + FROM session s + JOIN user u ON u.id = s.user_id + WHERE s.id = ? LIMIT 1`, + sessionID, + ) + if err != nil { + jsonError(w, http.StatusUnauthorized, "invalid session") + log.Fatalf("Database error during session validation: %v", err) + return + } + + if time.Now().UTC().After(row.ExpiresAt) { + _, _ = db.ExecContext(r.Context(), "DELETE FROM session WHERE id = ?", sessionID) + jsonError(w, http.StatusUnauthorized, "session expired") + return + } + + if !row.Enabled { + jsonError(w, http.StatusForbidden, "account disabled") + return + } + + jsonOK(w, map[string]any{ + "success": true, + "user": authUser{ + ID: row.ID, + Username: row.Username, + Displayname: row.Displayname, + Role: row.Role, + Enabled: row.Enabled, + }, + }) + } +} + +// LogoutSession handles POST /v1/api/admin/auth/logout +// Reads the session ID from the X-Session-ID header. +func LogoutSession(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sessionID := r.Header.Get(sessionHeader) + if sessionID == "" { + jsonError(w, http.StatusBadRequest, "missing "+sessionHeader+" header") + return + } + + if _, err := db.ExecContext(r.Context(), + "DELETE FROM session WHERE id = ?", sessionID, + ); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + + log.Printf("[AUTH] Session logged out: %s...", sessionID[:8]) + + jsonOK(w, map[string]bool{"logged_out": true}) + } +} diff --git a/internal/handlers/admin_users.route.go b/internal/handlers/admin_users.route.go new file mode 100644 index 0000000..dae2368 --- /dev/null +++ b/internal/handlers/admin_users.route.go @@ -0,0 +1,263 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jmoiron/sqlx" + + "emly-api-go/internal/models" +) + +// ListUsers handles GET /v1/api/admin/users +func ListUsers(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var users []models.User + if err := db.SelectContext(r.Context(), &users, + "SELECT id, username, displayname, role, enabled, created_at FROM `user` ORDER BY created_at ASC", + ); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonOK(w, users) + } +} + +// CreateUser handles POST /v1/api/admin/users +func CreateUser(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Displayname string `json:"displayname"` + Password string `json:"password"` + Role models.UserRole `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if body.Username == "" || body.Password == "" { + jsonError(w, http.StatusBadRequest, "username and password are required") + return + } + if body.Role != models.UserRoleAdmin && body.Role != models.UserRoleUser { + jsonError(w, http.StatusBadRequest, "role must be 'admin' or 'user'") + return + } + + var count int + if err := db.GetContext(r.Context(), &count, + "SELECT COUNT(*) FROM `user` WHERE username = ?", body.Username, + ); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + if count > 0 { + jsonError(w, http.StatusConflict, "username already exists") + return + } + + passwordHash, err := hashPassword(body.Password) + if err != nil { + jsonError(w, http.StatusInternalServerError, "failed to hash password: "+err.Error()) + return + } + id, err := generateUUID() + if err != nil { + jsonError(w, http.StatusInternalServerError, "failed to generate id: "+err.Error()) + return + } + + if _, err := db.ExecContext(r.Context(), + "INSERT INTO `user` (id, username, displayname, password_hash, role) VALUES (?, ?, ?, ?, ?)", + id, body.Username, body.Displayname, passwordHash, body.Role, + ); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + + var user models.User + if err := db.GetContext(r.Context(), &user, + "SELECT id, username, displayname, role, enabled, created_at FROM `user` WHERE id = ?", id, + ); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonCreated(w, user) + } +} + +// GetUserByID handles GET /v1/api/admin/users/{id} +func GetUserByID(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + jsonError(w, http.StatusBadRequest, "missing id parameter") + return + } + + var user models.User + err := db.GetContext(r.Context(), &user, + "SELECT id, username, displayname, role, enabled, created_at FROM `user` WHERE id = ? LIMIT 1", id, + ) + if errors.Is(err, sql.ErrNoRows) { + jsonError(w, http.StatusNotFound, "user not found") + return + } + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonOK(w, user) + } +} + +// UpdateUser handles PATCH /v1/api/admin/users/{id} +// Accepted fields: displayname, enabled +func UpdateUser(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + jsonError(w, http.StatusBadRequest, "missing id parameter") + return + } + + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + fields := []string{} + params := []any{} + + if raw, ok := body["displayname"]; ok { + var v string + if err := json.Unmarshal(raw, &v); err != nil { + jsonError(w, http.StatusBadRequest, "invalid displayname value") + return + } + fields = append(fields, "displayname = ?") + params = append(params, v) + } + if raw, ok := body["enabled"]; ok { + var v bool + if err := json.Unmarshal(raw, &v); err != nil { + jsonError(w, http.StatusBadRequest, "invalid enabled value") + return + } + fields = append(fields, "enabled = ?") + params = append(params, v) + } + + if len(fields) == 0 { + jsonError(w, http.StatusBadRequest, "no updatable fields provided") + return + } + + query := "UPDATE `user` SET " + for i, f := range fields { + if i > 0 { + query += ", " + } + query += f + } + query += " WHERE id = ?" + params = append(params, id) + + result, err := db.ExecContext(r.Context(), query, params...) + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + rows, err := result.RowsAffected() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + if rows == 0 { + jsonError(w, http.StatusNotFound, "user not found") + return + } + jsonOK(w, map[string]bool{"updated": true}) + } +} + +// ResetPassword handles POST /v1/api/admin/users/{id}/reset-password +func ResetPassword(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + jsonError(w, http.StatusBadRequest, "missing id parameter") + return + } + + var body struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if body.Password == "" { + jsonError(w, http.StatusBadRequest, "password is required") + return + } + + passwordHash, err := hashPassword(body.Password) + if err != nil { + jsonError(w, http.StatusInternalServerError, "failed to hash password: "+err.Error()) + return + } + + result, err := db.ExecContext(r.Context(), + "UPDATE `user` SET password_hash = ? WHERE id = ?", passwordHash, id, + ) + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + rows, err := result.RowsAffected() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + if rows == 0 { + jsonError(w, http.StatusNotFound, "user not found") + return + } + jsonOK(w, map[string]bool{"updated": true}) + } +} + +// DeleteUser handles DELETE /v1/api/admin/users/{id} +func DeleteUser(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + jsonError(w, http.StatusBadRequest, "missing id parameter") + return + } + + result, err := db.ExecContext(r.Context(), + "DELETE FROM `user` WHERE id = ?", id, + ) + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + rows, err := result.RowsAffected() + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + if rows == 0 { + jsonError(w, http.StatusNotFound, "user not found") + return + } + jsonOK(w, map[string]bool{"deleted": true}) + } +} diff --git a/internal/handlers/bug_report.route.go b/internal/handlers/bug_report.route.go index 3c23acb..4668f83 100644 --- a/internal/handlers/bug_report.route.go +++ b/internal/handlers/bug_report.route.go @@ -10,8 +10,10 @@ import ( "fmt" "io" "log" + "math" "mime/multipart" "net/http" + "strconv" "strings" "text/template" @@ -139,13 +141,70 @@ func CreateBugReport(db *sqlx.DB) http.HandlerFunc { func GetAllBugReports(db *sqlx.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var reports []models.BugReport - if err := db.SelectContext(r.Context(), &reports, "SELECT * FROM emly_bugreports_dev.bug_reports"); err != nil { + page, pageSize := 1, 20 + if p := r.URL.Query().Get("page"); p != "" { + if v, err := strconv.Atoi(p); err == nil && v > 0 { + page = v + } + } + if ps := r.URL.Query().Get("page_size"); ps != "" { + if v, err := strconv.Atoi(ps); err == nil && v > 0 && v <= 100 { + pageSize = v + } + } + + status := r.URL.Query().Get("status") + search := r.URL.Query().Get("search") + offset := (page - 1) * pageSize + + var conditions []string + var params []interface{} + + if status != "" { + conditions = append(conditions, "br.status = ?") + params = append(params, status) + } + if search != "" { + like := "%" + search + "%" + conditions = append(conditions, "(br.hostname LIKE ? OR br.os_user LIKE ? OR br.name LIKE ? OR br.email LIKE ?)") + params = append(params, like, like, like, like) + } + + whereClause := "" + if len(conditions) > 0 { + whereClause = "WHERE " + strings.Join(conditions, " AND ") + } + + var total int + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM emly_bugreports_dev.bug_reports br " + whereClause) + if err := db.GetContext(r.Context(), &total, countQuery, params...); err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) return } - jsonOK(w, reports) + mainQuery := fmt.Sprintf(` + SELECT br.*, COUNT(bf.id) as file_count + FROM emly_bugreports_dev.bug_reports br + LEFT JOIN emly_bugreports_dev.bug_report_files bf ON bf.report_id = br.id + ` + whereClause + ` + GROUP BY br.id + ORDER BY br.created_at DESC + LIMIT ? OFFSET ?`) + + listParams := append(params, pageSize, offset) + var reports []models.BugReportListItem + if err := db.SelectContext(r.Context(), &reports, mainQuery, listParams...); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + + jsonOK(w, map[string]interface{}{ + "data": reports, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": int(math.Ceil(float64(total) / float64(pageSize))), + }) } } @@ -158,17 +217,25 @@ func GetBugReportByID(db *sqlx.DB) http.HandlerFunc { } var report models.BugReport - err := db.GetContext(r.Context(), &report, "SELECT * FROM emly_bugreports_dev.bug_reports WHERE id = ?", id) - if errors.Is(err, sql.ErrNoRows) { + reportErr := db.GetContext(r.Context(), &report, "SELECT * FROM emly_bugreports_dev.bug_reports WHERE id = ?", id) + if errors.Is(reportErr, sql.ErrNoRows) { jsonError(w, http.StatusNotFound, "bug report not found") return } - if err != nil { - jsonError(w, http.StatusInternalServerError, err.Error()) + if reportErr != nil { + jsonError(w, http.StatusInternalServerError, reportErr.Error()) return } - jsonOK(w, report) + type response struct { + Report models.BugReport `json:"report"` + } + + responseData := response{ + Report: report, + } + + jsonOK(w, responseData) } } diff --git a/internal/middleware/adminKey.go b/internal/middleware/adminKey.go index 71e45b5..ae4b267 100644 --- a/internal/middleware/adminKey.go +++ b/internal/middleware/adminKey.go @@ -28,6 +28,7 @@ func AdminKeyAuth(_ *sqlx.DB) func(http.Handler) http.Handler { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized admin key"}) + log.Println("[ADMIN-KEY] Failed to authorize admin key for URL: " + r.URL.String()) return } next.ServeHTTP(w, r) diff --git a/internal/middleware/apikey.go b/internal/middleware/apikey.go index 7dc8766..c01a717 100644 --- a/internal/middleware/apikey.go +++ b/internal/middleware/apikey.go @@ -28,6 +28,7 @@ func APIKeyAuth(_ *sqlx.DB) func(http.Handler) http.Handler { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + log.Println("[API-KEY] Failed to authorize admin key for URL: " + r.URL.String()) return } next.ServeHTTP(w, r) diff --git a/internal/models/bug_report.go b/internal/models/bug_report.go index 02e5bd0..3163707 100644 --- a/internal/models/bug_report.go +++ b/internal/models/bug_report.go @@ -14,6 +14,11 @@ const ( BugReportStatusClosed BugReportStatus = "closed" ) +type BugReportListItem struct { + BugReport + FileCount int `db:"file_count" json:"file_count"` +} + type BugReport struct { ID uint64 `db:"id" json:"id"` Name string `db:"name" json:"name"` diff --git a/internal/models/session.go b/internal/models/session.go index c1e0eb8..cf63567 100644 --- a/internal/models/session.go +++ b/internal/models/session.go @@ -4,8 +4,6 @@ import "time" type Session struct { ID string `db:"id" json:"id"` - UserID int64 `db:"user_id" json:"user_id"` - Token string `db:"token" json:"token"` + UserID string `db:"user_id" json:"user_id"` ExpiresAt time.Time `db:"expires_at" json:"expires_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` } diff --git a/internal/models/user.go b/internal/models/user.go index 1a4b82b..65c3185 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -10,12 +10,11 @@ const ( ) type User struct { - ID int64 `db:"id" json:"id"` + ID string `db:"id" json:"id"` Username string `db:"username" json:"username"` - Email string `db:"email" json:"email"` + Displayname string `db:"displayname" json:"displayname"` PasswordHash string `db:"password_hash" json:"-"` Role UserRole `db:"role" json:"role"` Enabled bool `db:"enabled" json:"enabled"` CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } diff --git a/main.go b/main.go index c0ab812..0d6cd78 100644 --- a/main.go +++ b/main.go @@ -71,6 +71,29 @@ func main() { r.Get("/health", handlers.Health(db)) r.Route("/api", func(r chi.Router) { + r.Route("/admin", func(r chi.Router) { + r.Use(httprate.LimitByIP(30, time.Minute)) + + // ROUTE: Auth — public, handles its own credential checks + r.Route("/auth", func(r chi.Router) { + r.Post("/login", handlers.LoginUser(db)) + r.Get("/validate", handlers.ValidateSession(db)) + r.Post("/logout", handlers.LogoutSession(db)) + }) + + // ROUTE: User management — protected via Admin Key + r.Route("/users", func(r chi.Router) { + r.Use(apimw.AdminKeyAuth(db)) + + r.Get("/", handlers.ListUsers(db)) + r.Post("/", handlers.CreateUser(db)) + r.Get("/{id}", handlers.GetUserByID(db)) + r.Patch("/{id}", handlers.UpdateUser(db)) + r.Post("/{id}/reset-password", handlers.ResetPassword(db)) + r.Delete("/{id}", handlers.DeleteUser(db)) + }) + }) + // ROUTE: Bug Reports - Protected via API Key r.Route("/bug-reports", func(r chi.Router) { r.Group(func(r chi.Router) {