Phase 1: Go backend scaffold

- Project structure: cmd/boosapi, internal/, pkg/
- Config loader (all env vars from Node.js original)
- Database layer with SQLite/MySQL/Postgres support
- Migration runner (sequential SQL files)
- Health check endpoint (GET /healthz)
- HTTP middleware: logger, auth, CORS, panic recovery
- chi router with /admin, /v1, /api route groups
- Graceful shutdown (SIGINT/SIGTERM)
- Multi-stage Dockerfile (alpine, ~20MB image)
- Initial schema migration (users, api_keys, sites, usage_logs)

Signed-off-by: Boos4721 <boos4721@icloud.com>
This commit is contained in:
boos
2026-05-16 01:07:02 +08:00
parent f0188d3f68
commit b6ecd613d4
9 changed files with 686 additions and 88 deletions
+55
View File
@@ -0,0 +1,55 @@
package handler
import (
"database/sql"
"encoding/json"
"net/http"
"time"
)
// HealthHandler returns the service health status.
type HealthHandler struct {
db *sql.DB
startedAt time.Time
version string
}
// NewHealthHandler creates a new health handler.
func NewHealthHandler(db *sql.DB, version string) *HealthHandler {
return &HealthHandler{
db: db,
startedAt: time.Now(),
version: version,
}
}
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version,omitempty"`
Uptime string `json:"uptime,omitempty"`
Database string `json:"database"`
Timestamp string `json:"timestamp"`
}
// ServeHTTP handles GET /healthz
func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
dbStatus := "ok"
if err := h.db.Ping(); err != nil {
dbStatus = "error: " + err.Error()
}
resp := healthResponse{
Status: "ok",
Version: h.version,
Uptime: time.Since(h.startedAt).Round(time.Second).String(),
Database: dbStatus,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if dbStatus != "ok" {
resp.Status = "degraded"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
+102
View File
@@ -0,0 +1,102 @@
// Package middleware provides HTTP middleware for the BoosAPI server.
package middleware
import (
"log/slog"
"net/http"
"strings"
"time"
)
// Logger logs each HTTP request with method, path, status, and duration.
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(sw, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", sw.status,
"duration", time.Since(start).Round(time.Millisecond).String(),
"remote", r.RemoteAddr,
)
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
// AuthToken validates the AUTH_TOKEN header for admin endpoints.
func AuthToken(token string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token == "" {
next.ServeHTTP(w, r)
return
}
auth := r.Header.Get("Authorization")
if auth != "Bearer "+token && auth != "Token "+token {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
// ProxyToken validates the PROXY_TOKEN for /v1/* proxy endpoints.
func ProxyToken(token string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token == "" {
next.ServeHTTP(w, r)
return
}
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, `{"error":"missing bearer token"}`, http.StatusUnauthorized)
return
}
if strings.TrimPrefix(auth, "Bearer ") != token {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
// CORS adds CORS headers for the React frontend.
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// Recover catches panics and returns a 500 error.
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.Error("panic recovered", "error", err)
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}