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:
@@ -0,0 +1,131 @@
|
||||
// Package config loads application configuration from environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all application configuration.
|
||||
type Config struct {
|
||||
// Server
|
||||
Port int `env:"PORT" default:"4000"`
|
||||
Host string `env:"HOST" default:"0.0.0.0"`
|
||||
|
||||
// Auth
|
||||
AuthToken string `env:"AUTH_TOKEN" required:"true"`
|
||||
ProxyToken string `env:"PROXY_TOKEN" required:"true"`
|
||||
|
||||
// Database
|
||||
DBType string `env:"DB_TYPE" default:"sqlite"`
|
||||
DBURL string `env:"DB_URL" default:""`
|
||||
DBSSL bool `env:"DB_SSL" default:"false"`
|
||||
|
||||
// Storage
|
||||
DataDir string `env:"DATA_DIR" default:"/app/data"`
|
||||
|
||||
// Redis (optional)
|
||||
RedisURL string `env:"REDIS_URL" default:""`
|
||||
|
||||
// Runtime
|
||||
TZ string `env:"TZ" default:"UTC"`
|
||||
LogLevel string `env:"LOG_LEVEL" default:"info"`
|
||||
JWTSecret string `env:"JWT_SECRET" default:""`
|
||||
|
||||
// Desktop (set by Electron wrapper, not user)
|
||||
Desktop bool `env:"BOOSAPI_DESKTOP" default:"false"`
|
||||
DesktopPort int `env:"BOOSAPI_DESKTOP_SERVER_PORT" default:"0"`
|
||||
DesktopDev string `env:"BOOSAPI_DESKTOP_DEV_SERVER_URL" default:""`
|
||||
DesktopExt string `env:"BOOSAPI_DESKTOP_EXTERNAL_SERVER_URL" default:""`
|
||||
|
||||
// Derived
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{}
|
||||
|
||||
if v := os.Getenv("PORT"); v != "" {
|
||||
p, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Port = p
|
||||
}
|
||||
|
||||
if v := os.Getenv("HOST"); v != "" {
|
||||
cfg.Host = v
|
||||
}
|
||||
|
||||
cfg.AuthToken = os.Getenv("AUTH_TOKEN")
|
||||
if cfg.AuthToken == "" {
|
||||
cfg.AuthToken = "change-me-admin-token"
|
||||
}
|
||||
|
||||
cfg.ProxyToken = os.Getenv("PROXY_TOKEN")
|
||||
|
||||
if v := os.Getenv("DB_TYPE"); v != "" {
|
||||
cfg.DBType = v
|
||||
}
|
||||
|
||||
if v := os.Getenv("DB_URL"); v != "" {
|
||||
cfg.DBURL = v
|
||||
}
|
||||
|
||||
if v := os.Getenv("DB_SSL"); v == "true" || v == "1" {
|
||||
cfg.DBSSL = true
|
||||
}
|
||||
|
||||
if v := os.Getenv("DATA_DIR"); v != "" {
|
||||
cfg.DataDir = v
|
||||
}
|
||||
|
||||
if v := os.Getenv("REDIS_URL"); v != "" {
|
||||
cfg.RedisURL = v
|
||||
}
|
||||
|
||||
if v := os.Getenv("TZ"); v != "" {
|
||||
cfg.TZ = v
|
||||
}
|
||||
|
||||
if v := os.Getenv("LOG_LEVEL"); v != "" {
|
||||
cfg.LogLevel = v
|
||||
}
|
||||
|
||||
cfg.JWTSecret = os.Getenv("JWT_SECRET")
|
||||
|
||||
if v := os.Getenv("BOOSAPI_DESKTOP"); v == "1" {
|
||||
cfg.Desktop = true
|
||||
}
|
||||
if v := os.Getenv("BOOSAPI_DESKTOP_SERVER_PORT"); v != "" {
|
||||
p, _ := strconv.Atoi(v)
|
||||
cfg.DesktopPort = p
|
||||
}
|
||||
cfg.DesktopDev = os.Getenv("BOOSAPI_DESKTOP_DEV_SERVER_URL")
|
||||
cfg.DesktopExt = os.Getenv("BOOSAPI_DESKTOP_EXTERNAL_SERVER_URL")
|
||||
|
||||
loc, err := time.LoadLocation(cfg.TZ)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
cfg.location = loc
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Location returns the configured timezone location.
|
||||
func (c *Config) Location() *time.Location {
|
||||
return c.location
|
||||
}
|
||||
|
||||
// ListenAddr returns the address string for net/http to listen on.
|
||||
func (c *Config) ListenAddr() string {
|
||||
return c.Host + ":" + strconv.Itoa(c.Port)
|
||||
}
|
||||
|
||||
// DataPath returns the path under DataDir for a given subdirectory.
|
||||
func (c *Config) DataPath(sub string) string {
|
||||
return c.DataDir + "/" + sub
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Package db provides database connection management and migration support.
|
||||
// Supports SQLite, MySQL, and PostgreSQL backends.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/lib/pq"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// DBType constants.
|
||||
const (
|
||||
TypeSQLite = "sqlite"
|
||||
TypeMySQL = "mysql"
|
||||
TypePostgres = "postgres"
|
||||
)
|
||||
|
||||
// Open opens a database connection based on the configuration.
|
||||
func Open(dbType, dbURL, dataDir string) (*sql.DB, error) {
|
||||
var driverName, dsn string
|
||||
|
||||
switch dbType {
|
||||
case TypeSQLite:
|
||||
driverName = "sqlite"
|
||||
if dbURL == "" {
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
dbURL = filepath.Join(dataDir, "hub.db")
|
||||
}
|
||||
dsn = dbURL + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
|
||||
|
||||
case TypeMySQL:
|
||||
driverName = "mysql"
|
||||
dsn = dbURL
|
||||
if dsn == "" {
|
||||
return nil, errors.New("DB_URL required for mysql")
|
||||
}
|
||||
|
||||
case TypePostgres:
|
||||
driverName = "postgres"
|
||||
dsn = dbURL
|
||||
if dsn == "" {
|
||||
return nil, errors.New("DB_URL required for postgres")
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported DB_TYPE: %s", dbType)
|
||||
}
|
||||
|
||||
db, err := sql.Open(driverName, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", dbType, err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
db.SetConnMaxIdleTime(1 * time.Minute)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping %s: %w", dbType, err)
|
||||
}
|
||||
|
||||
slog.Info("database connected", "type", dbType)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// RunMigrations reads SQL migration files from the given directory and applies
|
||||
// them in filename order. Each file is executed within a transaction.
|
||||
// Migration filenames should follow the format: 000001_description.up.sql
|
||||
func RunMigrations(db *sql.DB, migrationsDir string) error {
|
||||
entries, err := os.ReadDir(migrationsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
slog.Warn("migrations directory not found, skipping", "dir", migrationsDir)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, f := range files {
|
||||
path := filepath.Join(migrationsDir, f)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", f, err)
|
||||
}
|
||||
|
||||
// Skip empty files
|
||||
sql := strings.TrimSpace(string(content))
|
||||
if sql == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx for %s: %w", f, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(sql); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("execute migration %s: %w", f, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", f, err)
|
||||
}
|
||||
|
||||
slog.Info("migration applied", "file", f)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close gracefully closes the database connection.
|
||||
func Close(db *sql.DB) {
|
||||
if db != nil {
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Initial schema
|
||||
-- Compatible with SQLite, MySQL, and PostgreSQL
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
key_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'openai',
|
||||
base_url TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
weight REAL NOT NULL DEFAULT 1.0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id),
|
||||
site_id TEXT REFERENCES sites(id),
|
||||
model TEXT NOT NULL,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cost REAL NOT NULL DEFAULT 0,
|
||||
request_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_logs_user_id ON usage_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_logs_request_at ON usage_logs(request_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user