enhance bug report handling with pagination, filtering, and improved response structure
This commit is contained in:
79
CLAUDE.md
Normal file
79
CLAUDE.md
Normal file
@@ -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 `<resource>.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
|
||||||
|
```
|
||||||
3
go.mod
3
go.mod
@@ -7,8 +7,11 @@ require (
|
|||||||
github.com/go-chi/httprate v0.14.1
|
github.com/go-chi/httprate v0.14.1
|
||||||
github.com/go-sql-driver/mysql v1.8.1
|
github.com/go-sql-driver/mysql v1.8.1
|
||||||
github.com/joho/godotenv v1.5.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 (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.1 // indirect
|
filippo.io/edwards25519 v1.1.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
|||||||
4
go.sum
4
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/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 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
||||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
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 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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=
|
||||||
|
|||||||
@@ -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) {
|
func columnExists(db *sqlx.DB, dbName, table, column string) (bool, error) {
|
||||||
var count int
|
var count int
|
||||||
err := db.Get(&count,
|
err := db.Get(&count,
|
||||||
|
|||||||
251
internal/handlers/admin_auth.route.go
Normal file
251
internal/handlers/admin_auth.route.go
Normal file
@@ -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=...", "<salt>", "<hash>"]
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
263
internal/handlers/admin_users.route.go
Normal file
263
internal/handlers/admin_users.route.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
@@ -139,13 +141,70 @@ func CreateBugReport(db *sqlx.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func GetAllBugReports(db *sqlx.DB) http.HandlerFunc {
|
func GetAllBugReports(db *sqlx.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var reports []models.BugReport
|
page, pageSize := 1, 20
|
||||||
if err := db.SelectContext(r.Context(), &reports, "SELECT * FROM emly_bugreports_dev.bug_reports"); err != nil {
|
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())
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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
|
var report models.BugReport
|
||||||
err := db.GetContext(r.Context(), &report, "SELECT * FROM emly_bugreports_dev.bug_reports WHERE id = ?", id)
|
reportErr := db.GetContext(r.Context(), &report, "SELECT * FROM emly_bugreports_dev.bug_reports WHERE id = ?", id)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(reportErr, sql.ErrNoRows) {
|
||||||
jsonError(w, http.StatusNotFound, "bug report not found")
|
jsonError(w, http.StatusNotFound, "bug report not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if reportErr != nil {
|
||||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
jsonError(w, http.StatusInternalServerError, reportErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonOK(w, report)
|
type response struct {
|
||||||
|
Report models.BugReport `json:"report"`
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData := response{
|
||||||
|
Report: report,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOK(w, responseData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func AdminKeyAuth(_ *sqlx.DB) func(http.Handler) http.Handler {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized admin key"})
|
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
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func APIKeyAuth(_ *sqlx.DB) func(http.Handler) http.Handler {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||||
|
log.Println("[API-KEY] Failed to authorize admin key for URL: " + r.URL.String())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ const (
|
|||||||
BugReportStatusClosed BugReportStatus = "closed"
|
BugReportStatusClosed BugReportStatus = "closed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type BugReportListItem struct {
|
||||||
|
BugReport
|
||||||
|
FileCount int `db:"file_count" json:"file_count"`
|
||||||
|
}
|
||||||
|
|
||||||
type BugReport struct {
|
type BugReport struct {
|
||||||
ID uint64 `db:"id" json:"id"`
|
ID uint64 `db:"id" json:"id"`
|
||||||
Name string `db:"name" json:"name"`
|
Name string `db:"name" json:"name"`
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import "time"
|
|||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID string `db:"id" json:"id"`
|
ID string `db:"id" json:"id"`
|
||||||
UserID int64 `db:"user_id" json:"user_id"`
|
UserID string `db:"user_id" json:"user_id"`
|
||||||
Token string `db:"token" json:"token"`
|
|
||||||
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
ID string `db:"id" json:"id"`
|
||||||
Username string `db:"username" json:"username"`
|
Username string `db:"username" json:"username"`
|
||||||
Email string `db:"email" json:"email"`
|
Displayname string `db:"displayname" json:"displayname"`
|
||||||
PasswordHash string `db:"password_hash" json:"-"`
|
PasswordHash string `db:"password_hash" json:"-"`
|
||||||
Role UserRole `db:"role" json:"role"`
|
Role UserRole `db:"role" json:"role"`
|
||||||
Enabled bool `db:"enabled" json:"enabled"`
|
Enabled bool `db:"enabled" json:"enabled"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
23
main.go
23
main.go
@@ -71,6 +71,29 @@ func main() {
|
|||||||
r.Get("/health", handlers.Health(db))
|
r.Get("/health", handlers.Health(db))
|
||||||
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
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
|
// ROUTE: Bug Reports - Protected via API Key
|
||||||
r.Route("/bug-reports", func(r chi.Router) {
|
r.Route("/bug-reports", func(r chi.Router) {
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
|
|||||||
Reference in New Issue
Block a user