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,132 @@
|
|||||||
|
// Package main is the entry point for the BoosAPI server.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"github.com/cita-777/boosapi/internal/config"
|
||||||
|
"github.com/cita-777/boosapi/internal/db"
|
||||||
|
"github.com/cita-777/boosapi/internal/server/handler"
|
||||||
|
"github.com/cita-777/boosapi/internal/server/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load config
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load config", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set timezone
|
||||||
|
if cfg.Location() != time.UTC {
|
||||||
|
time.Local = cfg.Location()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure slog
|
||||||
|
logLevel := new(slog.LevelVar)
|
||||||
|
switch cfg.LogLevel {
|
||||||
|
case "debug":
|
||||||
|
logLevel.Set(slog.LevelDebug)
|
||||||
|
case "info":
|
||||||
|
logLevel.Set(slog.LevelInfo)
|
||||||
|
case "warn":
|
||||||
|
logLevel.Set(slog.LevelWarn)
|
||||||
|
case "error":
|
||||||
|
logLevel.Set(slog.LevelError)
|
||||||
|
default:
|
||||||
|
logLevel.Set(slog.LevelInfo)
|
||||||
|
}
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: logLevel,
|
||||||
|
})))
|
||||||
|
|
||||||
|
// Connect database
|
||||||
|
database, err := db.Open(cfg.DBType, cfg.DBURL, cfg.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to connect database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer db.Close(database)
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
if err := db.RunMigrations(database, "internal/db/migrate"); err != nil {
|
||||||
|
slog.Error("failed to run migrations", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build router
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Global middleware
|
||||||
|
r.Use(chimw.RequestID)
|
||||||
|
r.Use(chimw.RealIP)
|
||||||
|
r.Use(middleware.Recover)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.CORS)
|
||||||
|
|
||||||
|
// Health check (no auth required)
|
||||||
|
r.Get("/healthz", handler.NewHealthHandler(database, version).ServeHTTP)
|
||||||
|
|
||||||
|
// Admin routes (require auth)
|
||||||
|
r.Route("/admin", func(r chi.Router) {
|
||||||
|
r.Use(middleware.AuthToken(cfg.AuthToken))
|
||||||
|
// TODO: admin handlers
|
||||||
|
})
|
||||||
|
|
||||||
|
// Proxy routes (require proxy token)
|
||||||
|
r.Route("/v1", func(r chi.Router) {
|
||||||
|
r.Use(middleware.ProxyToken(cfg.ProxyToken))
|
||||||
|
// TODO: proxy handlers
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
// TODO: API handlers
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
addr := cfg.ListenAddr()
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: r,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 300 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("server starting", "addr", addr, "version", version)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("server error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-quit
|
||||||
|
slog.Info("shutting down...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
slog.Error("force shutdown", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("server stopped")
|
||||||
|
}
|
||||||
+26
-88
@@ -1,95 +1,33 @@
|
|||||||
FROM node:22-bookworm-slim AS boosapi-builder
|
# Stage 1: Build Go binary
|
||||||
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache gcc musl-dev
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=$(git describe --tags --always 2>/dev/null || echo dev)" \
|
||||||
|
-o /app/boosapi ./cmd/boosapi/
|
||||||
|
|
||||||
|
# Stage 2: Minimal runtime image
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN echo 'Acquire::http::Proxy "false";' > /etc/apt/apt.conf.d/99no-proxy && apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
|
|
||||||
ENV PYTHON=/usr/bin/python3
|
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
# Copy binary + migrations
|
||||||
RUN npm ci --ignore-scripts --no-audit --no-fund
|
COPY --from=builder /app/boosapi .
|
||||||
RUN npm rebuild esbuild sharp better-sqlite3 --no-audit --no-fund
|
COPY --from=builder /src/internal/db/migrate ./internal/db/migrate
|
||||||
COPY . .
|
|
||||||
RUN npm run build:web
|
|
||||||
RUN npx tsc -p tsconfig.json --outDir dist --rootDir src --skipLibCheck --noEmit false 2>/dev/null; true
|
|
||||||
RUN ls dist/server/index.js 2>/dev/null || (echo "=== Server build still missing, trying project reference build ===" && npx tsc -p tsconfig.server.json --skipLibCheck --noEmit false 2>/dev/null; true)
|
|
||||||
RUN npm prune --omit=dev --no-audit --no-fund
|
|
||||||
|
|
||||||
# ---- Final image ----
|
# Create data directory
|
||||||
FROM ubuntu:24.04
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
ARG HTTP_PROXY
|
EXPOSE 4000
|
||||||
ARG HTTPS_PROXY
|
|
||||||
ARG ALL_PROXY
|
|
||||||
ARG NO_PROXY
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||||
ENV NODE_ENV=production
|
CMD wget --no-verbose --tries=1 --spider http://localhost:4000/healthz || exit 1
|
||||||
ENV COMFYUI_BASE=http://127.0.0.1:8188
|
|
||||||
ENV HTTP_PROXY=${HTTP_PROXY}
|
|
||||||
ENV HTTPS_PROXY=${HTTPS_PROXY}
|
|
||||||
ENV ALL_PROXY=${ALL_PROXY}
|
|
||||||
ENV NO_PROXY=${NO_PROXY}
|
|
||||||
|
|
||||||
# Install system deps: Node.js 22, Python 3, supervisor
|
CMD ["/app/boosapi"]
|
||||||
RUN echo 'Acquire::http::Proxy "false";' > /etc/apt/apt.conf.d/99no-proxy && apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
curl ca-certificates gnupg \
|
|
||||||
python3 python3-pip python3-venv \
|
|
||||||
supervisor git \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install RTK from pre-built binary
|
|
||||||
COPY rtk /usr/local/bin/rtk
|
|
||||||
RUN chmod +x /usr/local/bin/rtk
|
|
||||||
|
|
||||||
# Set up ComfyUI (workflow editor frontend, calls BoosAPI API via custom nodes)
|
|
||||||
# CPU PyTorch is fine — all generation goes through BoosAPI's API
|
|
||||||
WORKDIR /app/comfy
|
|
||||||
RUN git clone https://github.com/comfyanonymous/ComfyUI.git . \
|
|
||||||
&& python3 -m venv venv \
|
|
||||||
&& . venv/bin/activate \
|
|
||||||
&& pip install --upgrade pip \
|
|
||||||
&& pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu \
|
|
||||||
&& grep -v kornia_rs requirements.txt | pip install -r /dev/stdin \
|
|
||||||
&& rm -rf ~/.cache/pip
|
|
||||||
ENV PATH="/app/comfy/venv/bin:${PATH}"
|
|
||||||
|
|
||||||
# Copy boosapi (built)
|
|
||||||
WORKDIR /app/boosapi
|
|
||||||
COPY --from=boosapi-builder /app/dist ./dist
|
|
||||||
COPY --from=boosapi-builder /app/node_modules ./node_modules
|
|
||||||
COPY --from=boosapi-builder /app/package.json ./
|
|
||||||
COPY --from=boosapi-builder /app/drizzle ./drizzle
|
|
||||||
RUN mkdir -p data
|
|
||||||
|
|
||||||
# Copy BoosAPI custom nodes for ComfyUI (call local BoosAPI API)
|
|
||||||
COPY --from=boosapi-builder /app/boosapi_comfy_nodes.py /app/comfy/custom_nodes/boosapi_comfy_nodes.py
|
|
||||||
|
|
||||||
# Supervisor config
|
|
||||||
RUN mkdir -p /var/log/supervisor
|
|
||||||
COPY <<'SUPERVISOR' /etc/supervisor/conf.d/all.conf
|
|
||||||
[supervisord]
|
|
||||||
nodaemon=true
|
|
||||||
user=root
|
|
||||||
logfile=/var/log/supervisor/supervisord.log
|
|
||||||
pidfile=/tmp/supervisord.pid
|
|
||||||
|
|
||||||
[program:boosapi]
|
|
||||||
command=sh -c "node dist/server/db/migrate.js && node dist/server/index.js"
|
|
||||||
directory=/app/boosapi
|
|
||||||
autorestart=true
|
|
||||||
stdout_logfile=/var/log/supervisor/boosapi.log
|
|
||||||
stderr_logfile=/var/log/supervisor/boosapi.err
|
|
||||||
|
|
||||||
[program:comfyui]
|
|
||||||
command=python3 main.py --listen 0.0.0.0 --port 8188 --cpu
|
|
||||||
directory=/app/comfy
|
|
||||||
autorestart=true
|
|
||||||
startretries=3
|
|
||||||
stdout_logfile=/var/log/supervisor/comfyui.log
|
|
||||||
stderr_logfile=/var/log/supervisor/comfyui.err
|
|
||||||
SUPERVISOR
|
|
||||||
|
|
||||||
EXPOSE 4000 8188
|
|
||||||
|
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/all.conf"]
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
module github.com/cita-777/boosapi
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0 // indirect
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/lib/pq v1.12.3 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
modernc.org/libc v1.72.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.50.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
|
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
|
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
@@ -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