mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-03 20:12:58 +00:00
2695 lines
84 KiB
Go
2695 lines
84 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"containr/internal/analytics"
|
|
"containr/internal/auth"
|
|
"containr/internal/config"
|
|
"containr/internal/gateway"
|
|
"containr/internal/storage"
|
|
)
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
type Server struct {
|
|
Store *storage.Store
|
|
Cfg config.Config
|
|
Umami *analytics.Client
|
|
proxyClient *http.Client
|
|
|
|
rpmMu sync.Mutex
|
|
rpmUsage map[string]rpmWindow
|
|
|
|
loginMu sync.Mutex
|
|
loginAttempts map[string]loginWindow
|
|
}
|
|
|
|
type rpmWindow struct {
|
|
WindowStart time.Time
|
|
Count int
|
|
}
|
|
|
|
type loginWindow struct {
|
|
WindowStart time.Time
|
|
Count int
|
|
}
|
|
|
|
type authContext struct {
|
|
UserID string
|
|
Email string
|
|
Enabled bool
|
|
ForcePasswordReset bool
|
|
Roles []string
|
|
Permissions map[string]bool
|
|
SessionID string
|
|
}
|
|
|
|
func NewServer(store *storage.Store, cfg config.Config) *Server {
|
|
return &Server{
|
|
Store: store,
|
|
Cfg: cfg,
|
|
Umami: analytics.NewClient(cfg.UmamiBaseURL, cfg.UmamiAPIKey, cfg.UmamiWebsiteID),
|
|
proxyClient: &http.Client{
|
|
Timeout: cfg.DefaultServiceTimeout,
|
|
},
|
|
rpmUsage: map[string]rpmWindow{},
|
|
loginAttempts: map[string]loginWindow{},
|
|
}
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /health", s.handleHealth)
|
|
|
|
mux.HandleFunc("GET /api/v1/bootstrap/status", s.handleBootstrapStatus)
|
|
mux.HandleFunc("POST /api/v1/bootstrap/register-owner", s.handleBootstrapRegisterOwner)
|
|
|
|
mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin)
|
|
mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout)
|
|
mux.HandleFunc("POST /api/v1/auth/refresh", s.handleAuthRefresh)
|
|
mux.HandleFunc("GET /api/v1/auth/me", s.withAuth("", true, s.handleAuthMe))
|
|
mux.HandleFunc("POST /api/v1/auth/reset-password", s.withAuth("", true, s.handleAuthResetPassword))
|
|
|
|
mux.HandleFunc("GET /api/v1/users", s.withAuth("users.read", false, s.handleUsersList))
|
|
mux.HandleFunc("POST /api/v1/users", s.withAuth("users.write", false, s.handleUsersCreate))
|
|
mux.HandleFunc("PATCH /api/v1/users/{id}", s.withAuth("users.write", false, s.handleUsersPatch))
|
|
|
|
mux.HandleFunc("GET /api/v1/roles", s.withAuth("roles.read", false, s.handleRolesList))
|
|
mux.HandleFunc("POST /api/v1/roles", s.withAuth("roles.write", false, s.handleRolesCreate))
|
|
mux.HandleFunc("PATCH /api/v1/roles/{id}", s.withAuth("roles.write", false, s.handleRolesPatch))
|
|
mux.HandleFunc("GET /api/v1/permissions", s.withAuth("roles.read", false, s.handlePermissionsList))
|
|
|
|
mux.HandleFunc("GET /api/v1/services", s.withAuth("services.read", false, s.handleServicesList))
|
|
mux.HandleFunc("POST /api/v1/services", s.withAuth("services.write", false, s.handleServicesCreate))
|
|
mux.HandleFunc("PATCH /api/v1/services/{id}", s.withAuth("services.write", false, s.handleServicesPatch))
|
|
mux.HandleFunc("POST /api/v1/services/{id}/validate", s.withAuth("services.write", false, s.handleServicesValidate))
|
|
|
|
mux.HandleFunc("GET /api/v1/databases", s.withAuth("databases.read", false, s.handleDatabasesList))
|
|
mux.HandleFunc("POST /api/v1/databases", s.withAuth("databases.write", false, s.handleDatabasesCreate))
|
|
mux.HandleFunc("PATCH /api/v1/databases/{id}", s.withAuth("databases.write", false, s.handleDatabasesPatch))
|
|
mux.HandleFunc("POST /api/v1/databases/{id}/validate", s.withAuth("databases.write", false, s.handleDatabasesValidate))
|
|
|
|
mux.HandleFunc("GET /api/v1/keys", s.withAuth("keys.read", false, s.handleKeysList))
|
|
mux.HandleFunc("POST /api/v1/keys", s.withAuth("keys.write", false, s.handleKeysCreate))
|
|
mux.HandleFunc("PATCH /api/v1/keys/{id}", s.withAuth("keys.write", false, s.handleKeysPatch))
|
|
|
|
mux.HandleFunc("GET /api/v1/analytics/ops", s.withAuth("analytics.read", false, s.handleAnalyticsOps))
|
|
mux.HandleFunc("GET /api/v1/analytics/traffic", s.withAuth("analytics.read", false, s.handleAnalyticsTraffic))
|
|
mux.HandleFunc("POST /api/v1/analytics/events", s.withAuth("", true, s.handleAnalyticsEvents))
|
|
|
|
mux.HandleFunc("/", s.handleGatewayOrUI)
|
|
|
|
return s.withSecurityHeaders(s.withLogging(mux))
|
|
}
|
|
|
|
func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) withLogging(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
next.ServeHTTP(w, r)
|
|
_ = s.logMetric("http_request", 1, map[string]any{
|
|
"method": r.Method,
|
|
"path": r.URL.Path,
|
|
"ms": time.Since(start).Milliseconds(),
|
|
})
|
|
})
|
|
}
|
|
|
|
func (s *Server) withAuth(requiredPermission string, allowForceReset bool, handler func(http.ResponseWriter, *http.Request, *authContext)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx, err := s.resolveAuth(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required")
|
|
return
|
|
}
|
|
if !ctx.Enabled {
|
|
writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled")
|
|
return
|
|
}
|
|
if ctx.ForcePasswordReset && !allowForceReset {
|
|
writeError(w, http.StatusPreconditionRequired, "PASSWORD_RESET_REQUIRED", "Password reset required")
|
|
return
|
|
}
|
|
if requiredPermission != "" && !ctx.Permissions[requiredPermission] {
|
|
writeError(w, http.StatusForbidden, "FORBIDDEN", "Insufficient permissions")
|
|
return
|
|
}
|
|
handler(w, r, ctx)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"data": map[string]any{
|
|
"status": "ok",
|
|
"name": "APwhy",
|
|
"database": "sqlite",
|
|
"generatedAt": time.Now().UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleBootstrapStatus(w http.ResponseWriter, r *http.Request) {
|
|
hasUsers := s.hasUsers(r.Context())
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"data": map[string]any{
|
|
"hasUsers": hasUsers,
|
|
"registrationOpen": !hasUsers,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleBootstrapRegisterOwner(w http.ResponseWriter, r *http.Request) {
|
|
if s.hasUsers(r.Context()) {
|
|
writeError(w, http.StatusForbidden, "BOOTSTRAP_CLOSED", "Owner registration is closed")
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
|
|
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
|
if input.Email == "" || !strings.Contains(input.Email, "@") {
|
|
writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required")
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(input.Password)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error())
|
|
return
|
|
}
|
|
|
|
now := storage.NowISO()
|
|
userID, _ := auth.RandomID("usr")
|
|
|
|
tx, err := s.Store.DB.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
_, err = tx.ExecContext(r.Context(), `
|
|
INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at)
|
|
VALUES (?, ?, ?, 1, 0, ?, ?)
|
|
`, userID, input.Email, hash, now, now)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists")
|
|
return
|
|
}
|
|
|
|
ownerRoleID := ""
|
|
if err := tx.QueryRowContext(r.Context(), `SELECT id FROM roles WHERE slug = 'owner'`).Scan(&ownerRoleID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "ROLE_MISSING", "Owner role not found")
|
|
return
|
|
}
|
|
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, ownerRoleID, now)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign owner role")
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit owner registration")
|
|
return
|
|
}
|
|
|
|
s.audit(r.Context(), userID, "owner.registered", "user", userID, map[string]any{"email": input.Email})
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"ok": true,
|
|
"data": map[string]any{
|
|
"id": userID,
|
|
"email": input.Email,
|
|
"roles": []string{"owner"},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
|
|
email := strings.ToLower(strings.TrimSpace(input.Email))
|
|
if email == "" || input.Password == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_CREDENTIALS", "Email and password are required")
|
|
return
|
|
}
|
|
|
|
if s.isLoginRateLimited(r, email) {
|
|
writeError(w, http.StatusTooManyRequests, "LOGIN_RATE_LIMITED", "Too many login attempts")
|
|
return
|
|
}
|
|
|
|
var user struct {
|
|
ID string
|
|
Email string
|
|
PasswordHash string
|
|
Enabled int
|
|
ForcePasswordReset int
|
|
}
|
|
err := s.Store.DB.QueryRowContext(r.Context(), `
|
|
SELECT id, email, password_hash, enabled, force_password_reset
|
|
FROM users
|
|
WHERE email = ?
|
|
`, email).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.Enabled, &user.ForcePasswordReset)
|
|
if err != nil {
|
|
s.markLoginAttempt(r, email)
|
|
writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials")
|
|
return
|
|
}
|
|
if user.Enabled != 1 {
|
|
writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled")
|
|
return
|
|
}
|
|
|
|
ok, err := auth.VerifyPassword(user.PasswordHash, input.Password)
|
|
if err != nil || !ok {
|
|
s.markLoginAttempt(r, email)
|
|
writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials")
|
|
return
|
|
}
|
|
|
|
s.clearLoginAttempts(r, email)
|
|
|
|
sessionID, accessToken, refreshToken, err := s.createSession(r.Context(), user.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "Failed to create login session")
|
|
return
|
|
}
|
|
|
|
now := storage.NowISO()
|
|
_, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?`, now, now, user.ID)
|
|
_ = sessionID
|
|
|
|
s.setAuthCookies(w, accessToken, refreshToken)
|
|
|
|
ctx, _ := s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessToken))
|
|
if ctx == nil {
|
|
writeError(w, http.StatusInternalServerError, "SESSION_RESOLVE_FAILED", "Failed to load user profile")
|
|
return
|
|
}
|
|
|
|
s.audit(r.Context(), user.ID, "auth.login", "session", sessionID, map[string]any{"email": user.Email})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"data": map[string]any{
|
|
"user": map[string]any{
|
|
"id": ctx.UserID,
|
|
"email": ctx.Email,
|
|
"roles": ctx.Roles,
|
|
"permissions": keysOfMap(ctx.Permissions),
|
|
"forcePasswordReset": ctx.ForcePasswordReset,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
|
|
accessCookie, _ := r.Cookie(s.Cfg.SessionAccessCookie)
|
|
refreshCookie, _ := r.Cookie(s.Cfg.SessionRefreshCookie)
|
|
|
|
now := storage.NowISO()
|
|
if accessCookie != nil {
|
|
_, _ = s.Store.DB.ExecContext(r.Context(), `
|
|
UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE access_token_hash = ?
|
|
`, now, now, auth.HashToken(accessCookie.Value))
|
|
}
|
|
if refreshCookie != nil {
|
|
_, _ = s.Store.DB.ExecContext(r.Context(), `
|
|
UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE refresh_token_hash = ?
|
|
`, now, now, auth.HashToken(refreshCookie.Value))
|
|
}
|
|
|
|
s.clearAuthCookies(w)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
|
|
refreshCookie, err := r.Cookie(s.Cfg.SessionRefreshCookie)
|
|
if err != nil || strings.TrimSpace(refreshCookie.Value) == "" {
|
|
writeError(w, http.StatusUnauthorized, "REFRESH_MISSING", "Refresh token is missing")
|
|
return
|
|
}
|
|
|
|
refreshHash := auth.HashToken(refreshCookie.Value)
|
|
|
|
var session struct {
|
|
ID string
|
|
UserID string
|
|
ExpiresAt string
|
|
RevokedAt sql.NullString
|
|
}
|
|
err = s.Store.DB.QueryRowContext(r.Context(), `
|
|
SELECT id, user_id, refresh_expires_at, revoked_at
|
|
FROM sessions
|
|
WHERE refresh_token_hash = ?
|
|
`, refreshHash).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is invalid")
|
|
return
|
|
}
|
|
if session.RevokedAt.Valid {
|
|
writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is revoked")
|
|
return
|
|
}
|
|
expiresAt, _ := time.Parse(time.RFC3339, session.ExpiresAt)
|
|
if time.Now().After(expiresAt) {
|
|
writeError(w, http.StatusUnauthorized, "REFRESH_EXPIRED", "Refresh token has expired")
|
|
return
|
|
}
|
|
|
|
accessToken, _ := auth.RandomToken(32)
|
|
newRefreshToken, _ := auth.RandomToken(48)
|
|
accessHash := auth.HashToken(accessToken)
|
|
refreshHashNew := auth.HashToken(newRefreshToken)
|
|
|
|
accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339)
|
|
refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339)
|
|
|
|
_, err = s.Store.DB.ExecContext(r.Context(), `
|
|
UPDATE sessions
|
|
SET access_token_hash = ?, refresh_token_hash = ?, access_expires_at = ?, refresh_expires_at = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`, accessHash, refreshHashNew, accessExp, refreshExp, storage.NowISO(), session.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "REFRESH_FAILED", "Failed to refresh session")
|
|
return
|
|
}
|
|
|
|
s.setAuthCookies(w, accessToken, newRefreshToken)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"data": map[string]any{
|
|
"id": ctx.UserID,
|
|
"email": ctx.Email,
|
|
"roles": ctx.Roles,
|
|
"permissions": keysOfMap(ctx.Permissions),
|
|
"forcePasswordReset": ctx.ForcePasswordReset,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAuthResetPassword(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
var input struct {
|
|
NewPassword string `json:"newPassword"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
hash, err := auth.HashPassword(input.NewPassword)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error())
|
|
return
|
|
}
|
|
|
|
now := storage.NowISO()
|
|
_, err = s.Store.DB.ExecContext(r.Context(), `
|
|
UPDATE users SET password_hash = ?, force_password_reset = 0, updated_at = ? WHERE id = ?
|
|
`, hash, now, ctx.UserID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "PASSWORD_UPDATE_FAILED", "Failed to update password")
|
|
return
|
|
}
|
|
|
|
s.audit(r.Context(), ctx.UserID, "auth.reset_password", "user", ctx.UserID, nil)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleUsersList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
rows, err := s.Store.DB.QueryContext(r.Context(), `
|
|
SELECT u.id, u.email, u.enabled, u.force_password_reset, u.created_at, u.updated_at,
|
|
COALESCE(GROUP_CONCAT(r.slug), '') AS role_slugs
|
|
FROM users u
|
|
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
|
LEFT JOIN roles r ON r.id = ur.role_id
|
|
GROUP BY u.id
|
|
ORDER BY u.created_at DESC
|
|
`)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list users")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]map[string]any, 0)
|
|
for rows.Next() {
|
|
var id, email, createdAt, updatedAt, roleSlugs string
|
|
var enabled, forceReset int
|
|
if err := rows.Scan(&id, &email, &enabled, &forceReset, &createdAt, &updatedAt, &roleSlugs); err != nil {
|
|
continue
|
|
}
|
|
roles := []string{}
|
|
if strings.TrimSpace(roleSlugs) != "" {
|
|
roles = strings.Split(roleSlugs, ",")
|
|
}
|
|
items = append(items, map[string]any{
|
|
"id": id,
|
|
"email": email,
|
|
"enabled": enabled == 1,
|
|
"forcePasswordReset": forceReset == 1,
|
|
"roles": roles,
|
|
"createdAt": createdAt,
|
|
"updatedAt": updatedAt,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleUsersCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
var input struct {
|
|
Email string `json:"email"`
|
|
RoleIDs []string `json:"roleIds"`
|
|
RoleSlugs []string `json:"roleSlugs"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
email := strings.ToLower(strings.TrimSpace(input.Email))
|
|
if email == "" || !strings.Contains(email, "@") {
|
|
writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required")
|
|
return
|
|
}
|
|
|
|
roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error())
|
|
return
|
|
}
|
|
if len(roleIDs) == 0 {
|
|
viewerID, err := s.roleIDBySlug(r.Context(), "viewer")
|
|
if err == nil {
|
|
roleIDs = []string{viewerID}
|
|
}
|
|
}
|
|
|
|
tempPassword, _ := auth.RandomPassword(16)
|
|
passwordHash, err := auth.HashPassword(tempPassword)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate temporary password")
|
|
return
|
|
}
|
|
|
|
inviteToken, _ := auth.RandomToken(32)
|
|
inviteHash := auth.HashToken(inviteToken)
|
|
now := storage.NowISO()
|
|
expiresAt := time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339)
|
|
|
|
userID, _ := auth.RandomID("usr")
|
|
inviteID, _ := auth.RandomID("inv")
|
|
|
|
tx, err := s.Store.DB.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
_, err = tx.ExecContext(r.Context(), `
|
|
INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at)
|
|
VALUES (?, ?, ?, 1, 1, ?, ?)
|
|
`, userID, email, passwordHash, now, now)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists")
|
|
return
|
|
}
|
|
|
|
for _, roleID := range roleIDs {
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, roleID, now)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign roles")
|
|
return
|
|
}
|
|
}
|
|
|
|
_, err = tx.ExecContext(r.Context(), `
|
|
INSERT INTO invites (id, user_id, email, token_hash, expires_at, created_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, inviteID, userID, email, inviteHash, expiresAt, ctx.UserID, now)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "INVITE_FAILED", "Failed to create invite")
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user creation")
|
|
return
|
|
}
|
|
|
|
s.audit(r.Context(), ctx.UserID, "users.create", "user", userID, map[string]any{"email": email, "roleCount": len(roleIDs)})
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"ok": true,
|
|
"data": map[string]any{
|
|
"id": userID,
|
|
"email": email,
|
|
"inviteToken": inviteToken,
|
|
"temporaryPass": tempPassword,
|
|
"expiresAt": expiresAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleUsersPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_ID", "User ID is required")
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
Enabled *bool `json:"enabled"`
|
|
RoleIDs []string `json:"roleIds"`
|
|
RoleSlugs []string `json:"roleSlugs"`
|
|
ResetPassword bool `json:"resetPassword"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
|
|
now := storage.NowISO()
|
|
tx, err := s.Store.DB.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if input.Enabled != nil {
|
|
_, err = tx.ExecContext(r.Context(), `UPDATE users SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "Failed to update user")
|
|
return
|
|
}
|
|
}
|
|
|
|
if len(input.RoleIDs) > 0 || len(input.RoleSlugs) > 0 {
|
|
roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error())
|
|
return
|
|
}
|
|
_, err = tx.ExecContext(r.Context(), `DELETE FROM user_roles WHERE user_id = ?`, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update user roles")
|
|
return
|
|
}
|
|
for _, roleID := range roleIDs {
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, id, roleID, now)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to assign role")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
response := map[string]any{}
|
|
if input.ResetPassword {
|
|
tempPassword, _ := auth.RandomPassword(16)
|
|
hash, err := auth.HashPassword(tempPassword)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate password")
|
|
return
|
|
}
|
|
_, err = tx.ExecContext(r.Context(), `
|
|
UPDATE users SET password_hash = ?, force_password_reset = 1, updated_at = ? WHERE id = ?
|
|
`, hash, now, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "PASSWORD_RESET_FAILED", "Failed to reset password")
|
|
return
|
|
}
|
|
response["temporaryPass"] = tempPassword
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user patch")
|
|
return
|
|
}
|
|
|
|
s.audit(r.Context(), ctx.UserID, "users.patch", "user", id, input)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": response})
|
|
}
|
|
|
|
func (s *Server) handleRolesList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
rows, err := s.Store.DB.QueryContext(r.Context(), `
|
|
SELECT r.id, r.name, r.slug, r.description, r.is_system, r.enabled,
|
|
COALESCE(GROUP_CONCAT(p.code), '') AS permission_codes,
|
|
r.created_at, r.updated_at
|
|
FROM roles r
|
|
LEFT JOIN role_permissions rp ON rp.role_id = r.id
|
|
LEFT JOIN permissions p ON p.id = rp.permission_id
|
|
GROUP BY r.id
|
|
ORDER BY r.created_at ASC
|
|
`)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list roles")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
roles := []map[string]any{}
|
|
for rows.Next() {
|
|
var id, name, slug, description, permissionCodes, createdAt, updatedAt string
|
|
var isSystem, enabled int
|
|
if err := rows.Scan(&id, &name, &slug, &description, &isSystem, &enabled, &permissionCodes, &createdAt, &updatedAt); err != nil {
|
|
continue
|
|
}
|
|
codes := []string{}
|
|
if strings.TrimSpace(permissionCodes) != "" {
|
|
codes = strings.Split(permissionCodes, ",")
|
|
sort.Strings(codes)
|
|
}
|
|
roles = append(roles, map[string]any{
|
|
"id": id,
|
|
"name": name,
|
|
"slug": slug,
|
|
"description": description,
|
|
"isSystem": isSystem == 1,
|
|
"enabled": enabled == 1,
|
|
"permissionCodes": codes,
|
|
"createdAt": createdAt,
|
|
"updatedAt": updatedAt,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": roles})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleRolesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
var input struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Description string `json:"description"`
|
|
PermissionCodes []string `json:"permissionCodes"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(input.Name)
|
|
if name == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_NAME", "Role name is required")
|
|
return
|
|
}
|
|
slug := slugify(firstNonEmpty(input.Slug, input.Name), "role")
|
|
id, _ := auth.RandomID("role")
|
|
now := storage.NowISO()
|
|
|
|
tx, err := s.Store.DB.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
_, err = tx.ExecContext(r.Context(), `
|
|
INSERT INTO roles (id, name, slug, description, is_system, enabled, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, 0, 1, ?, ?)
|
|
`, id, name, slug, strings.TrimSpace(input.Description), now, now)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "ROLE_CREATE_FAILED", "Role slug already exists")
|
|
return
|
|
}
|
|
|
|
if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil {
|
|
writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error())
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role creation")
|
|
return
|
|
}
|
|
|
|
s.audit(r.Context(), ctx.UserID, "roles.create", "role", id, input)
|
|
writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}})
|
|
}
|
|
|
|
func (s *Server) handleRolesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_ID", "Role ID is required")
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Enabled *bool `json:"enabled"`
|
|
PermissionCodes []string `json:"permissionCodes"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
|
|
isSystem := 0
|
|
if err := s.Store.DB.QueryRowContext(r.Context(), `SELECT is_system FROM roles WHERE id = ?`, id).Scan(&isSystem); err != nil {
|
|
writeError(w, http.StatusNotFound, "ROLE_NOT_FOUND", "Role not found")
|
|
return
|
|
}
|
|
|
|
now := storage.NowISO()
|
|
tx, err := s.Store.DB.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if input.Name != nil {
|
|
_, err = tx.ExecContext(r.Context(), `UPDATE roles SET name = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Name), now, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role name")
|
|
return
|
|
}
|
|
}
|
|
if input.Description != nil {
|
|
_, err = tx.ExecContext(r.Context(), `UPDATE roles SET description = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Description), now, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role description")
|
|
return
|
|
}
|
|
}
|
|
if input.Enabled != nil {
|
|
_, err = tx.ExecContext(r.Context(), `UPDATE roles SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role status")
|
|
return
|
|
}
|
|
}
|
|
|
|
if input.PermissionCodes != nil {
|
|
if isSystem == 1 {
|
|
writeError(w, http.StatusBadRequest, "SYSTEM_ROLE_LOCKED", "System role permissions are locked")
|
|
return
|
|
}
|
|
if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil {
|
|
writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role patch")
|
|
return
|
|
}
|
|
|
|
s.audit(r.Context(), ctx.UserID, "roles.patch", "role", id, input)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handlePermissionsList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, code, name, description FROM permissions ORDER BY code ASC`)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list permissions")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var id, code, name, description string
|
|
if err := rows.Scan(&id, &code, &name, &description); err != nil {
|
|
continue
|
|
}
|
|
items = append(items, map[string]any{"id": id, "code": code, "name": name, "description": description})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleServicesList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at FROM services ORDER BY created_at DESC`)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list services")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
item, err := scanService(rows)
|
|
if err == nil {
|
|
items = append(items, item)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleServicesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
var input serviceInput
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(input.Name) == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_NAME", "Service name is required")
|
|
return
|
|
}
|
|
if strings.TrimSpace(input.UpstreamURL) == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_UPSTREAM", "Service upstream URL is required")
|
|
return
|
|
}
|
|
routePrefix := normalizePathPrefix(input.RoutePrefix, "/"+slugify(input.Name, "service"))
|
|
if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix {
|
|
writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled")
|
|
return
|
|
}
|
|
if strings.HasPrefix(routePrefix, "/api/") {
|
|
writeError(w, http.StatusBadRequest, "ROUTE_CONFLICT", "Route prefix conflicts with internal API")
|
|
return
|
|
}
|
|
|
|
now := storage.NowISO()
|
|
id, _ := auth.RandomID("svc")
|
|
slug := slugify(firstNonEmpty(input.Slug, input.Name), "service")
|
|
|
|
_, err := s.Store.DB.ExecContext(r.Context(), `
|
|
INSERT INTO services (
|
|
id, name, slug, upstream_url, route_prefix, health_path,
|
|
upstream_auth_header, upstream_auth_value, internal_token,
|
|
enabled, rpm_limit, monthly_quota, request_timeout_ms,
|
|
created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`,
|
|
id,
|
|
strings.TrimSpace(input.Name),
|
|
slug,
|
|
strings.TrimSpace(input.UpstreamURL),
|
|
routePrefix,
|
|
normalizePathPrefix(input.HealthPath, "/health"),
|
|
nullIfEmpty(input.UpstreamAuthHeader),
|
|
nullIfEmpty(input.UpstreamAuthValue),
|
|
nullIfEmpty(input.InternalToken),
|
|
boolToInt(defaultBool(input.Enabled, true)),
|
|
nullInt(input.RPMLimit),
|
|
nullInt(input.MonthlyQuota),
|
|
nullInt(input.RequestTimeoutMS),
|
|
now,
|
|
now,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "SERVICE_CREATE_FAILED", "Service slug or route already exists")
|
|
return
|
|
}
|
|
|
|
s.audit(r.Context(), ctx.UserID, "services.create", "service", id, input)
|
|
writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}})
|
|
}
|
|
|
|
func (s *Server) handleServicesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_ID", "Service ID is required")
|
|
return
|
|
}
|
|
|
|
current, err := s.getServiceByID(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found")
|
|
return
|
|
}
|
|
|
|
var patch serviceInput
|
|
if err := decodeJSON(r.Body, &patch); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
|
|
name := firstNonEmpty(patch.Name, asString(current["name"]))
|
|
routePrefix := normalizePathPrefix(firstNonEmpty(patch.RoutePrefix, asString(current["routePrefix"])), "/")
|
|
if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix {
|
|
writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled")
|
|
return
|
|
}
|
|
|
|
_, err = s.Store.DB.ExecContext(r.Context(), `
|
|
UPDATE services SET
|
|
name = ?, slug = ?, upstream_url = ?, route_prefix = ?, health_path = ?,
|
|
upstream_auth_header = ?, upstream_auth_value = ?, internal_token = ?, enabled = ?,
|
|
rpm_limit = ?, monthly_quota = ?, request_timeout_ms = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`,
|
|
name,
|
|
slugify(firstNonEmpty(patch.Slug, asString(current["slug"])), "service"),
|
|
firstNonEmpty(patch.UpstreamURL, asString(current["upstreamUrl"])),
|
|
routePrefix,
|
|
normalizePathPrefix(firstNonEmpty(patch.HealthPath, asString(current["healthPath"])), "/health"),
|
|
nullIfEmpty(firstNonEmpty(patch.UpstreamAuthHeader, asString(current["upstreamAuthHeader"]))),
|
|
nullIfEmpty(firstNonEmpty(patch.UpstreamAuthValue, asString(current["upstreamAuthValue"]))),
|
|
nullIfEmpty(firstNonEmpty(patch.InternalToken, asString(current["internalToken"]))),
|
|
boolToInt(defaultBool(patch.Enabled, asBool(current["enabled"]))),
|
|
nullInt(patch.RPMLimitOr(current["rpmLimit"])),
|
|
nullInt(patch.MonthlyQuotaOr(current["monthlyQuota"])),
|
|
nullInt(patch.TimeoutOr(current["requestTimeoutMs"])),
|
|
storage.NowISO(),
|
|
id,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "SERVICE_UPDATE_FAILED", "Failed to update service")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleServicesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
id := r.PathValue("id")
|
|
service, err := s.getServiceByID(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found")
|
|
return
|
|
}
|
|
|
|
timeoutMS := intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds()))
|
|
client := &http.Client{Timeout: time.Duration(timeoutMS) * time.Millisecond}
|
|
target := strings.TrimRight(asString(service["upstreamUrl"]), "/") + normalizePathPrefix(asString(service["healthPath"]), "/health")
|
|
|
|
req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
|
|
if header := asString(service["upstreamAuthHeader"]); header != "" {
|
|
req.Header.Set(header, asString(service["upstreamAuthValue"]))
|
|
}
|
|
if token := asString(service["internalToken"]); token != "" {
|
|
req.Header.Set(s.Cfg.ServiceTokenHeader, token)
|
|
}
|
|
res, err := client.Do(req)
|
|
status := 0
|
|
message := ""
|
|
ok := false
|
|
if err != nil {
|
|
status = http.StatusBadGateway
|
|
message = err.Error()
|
|
} else {
|
|
defer res.Body.Close()
|
|
status = res.StatusCode
|
|
message = fmt.Sprintf("HTTP %d", res.StatusCode)
|
|
ok = res.StatusCode >= 200 && res.StatusCode < 300
|
|
}
|
|
|
|
validationStatus := "failed"
|
|
if ok {
|
|
validationStatus = "healthy"
|
|
}
|
|
|
|
_, _ = s.Store.DB.ExecContext(r.Context(), `
|
|
UPDATE services
|
|
SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`, storage.NowISO(), validationStatus, message, storage.NowISO(), id)
|
|
|
|
if !ok {
|
|
s.logIncident(r.Context(), sql.NullString{String: id, Valid: true}, sql.NullString{}, "SERVICE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleDatabasesList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
rows, err := s.Store.DB.QueryContext(r.Context(), `
|
|
SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
|
|
FROM database_connections
|
|
ORDER BY created_at DESC
|
|
`)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list database connections")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var id, name, slug, provider, connURL, createdAt, updatedAt string
|
|
var enabled int
|
|
var lastAt, lastStatus, lastMessage sql.NullString
|
|
if err := rows.Scan(&id, &name, &slug, &provider, &connURL, &enabled, &lastAt, &lastStatus, &lastMessage, &createdAt, &updatedAt); err != nil {
|
|
continue
|
|
}
|
|
items = append(items, map[string]any{
|
|
"id": id,
|
|
"name": name,
|
|
"slug": slug,
|
|
"provider": provider,
|
|
"target": extractDatabaseTarget(provider, connURL),
|
|
"maskedConnectionUrl": maskDatabaseURL(provider, connURL),
|
|
"enabled": enabled == 1,
|
|
"lastValidationAt": nullString(lastAt),
|
|
"lastValidationStatus": nullString(lastStatus),
|
|
"lastValidationMessage": nullString(lastMessage),
|
|
"createdAt": createdAt,
|
|
"updatedAt": updatedAt,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleDatabasesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
var input struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Provider string `json:"provider"`
|
|
ConnectionURL string `json:"connectionUrl"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
|
|
provider := normalizeProvider(input.Provider)
|
|
if provider == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql")
|
|
return
|
|
}
|
|
if strings.TrimSpace(input.ConnectionURL) == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_CONNECTION", "Connection URL is required")
|
|
return
|
|
}
|
|
|
|
now := storage.NowISO()
|
|
id, _ := auth.RandomID("dbc")
|
|
slug := slugify(firstNonEmpty(input.Slug, input.Name+"-"+provider), "database")
|
|
|
|
_, err := s.Store.DB.ExecContext(r.Context(), `
|
|
INSERT INTO database_connections (id, name, slug, provider, connection_url, enabled, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, id, strings.TrimSpace(input.Name), slug, provider, strings.TrimSpace(input.ConnectionURL), boolToInt(defaultBool(input.Enabled, true)), now, now)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "DATABASE_CREATE_FAILED", "Database connection slug already exists")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleDatabasesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_ID", "Database connection ID is required")
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
Name *string `json:"name"`
|
|
Slug *string `json:"slug"`
|
|
Provider *string `json:"provider"`
|
|
ConnectionURL *string `json:"connectionUrl"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
|
|
current, err := s.getDatabaseByID(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found")
|
|
return
|
|
}
|
|
|
|
provider := asString(current["provider"])
|
|
if input.Provider != nil {
|
|
norm := normalizeProvider(*input.Provider)
|
|
if norm == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql")
|
|
return
|
|
}
|
|
provider = norm
|
|
}
|
|
|
|
_, err = s.Store.DB.ExecContext(r.Context(), `
|
|
UPDATE database_connections
|
|
SET name = ?, slug = ?, provider = ?, connection_url = ?, enabled = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`,
|
|
firstNonEmptyPtr(input.Name, asString(current["name"])),
|
|
slugify(firstNonEmptyPtr(input.Slug, asString(current["slug"])), "database"),
|
|
provider,
|
|
firstNonEmptyPtr(input.ConnectionURL, asString(current["connectionUrl"])),
|
|
boolToInt(defaultBool(input.Enabled, asBool(current["enabled"]))),
|
|
storage.NowISO(),
|
|
id,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "DATABASE_UPDATE_FAILED", "Failed to update database connection")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleDatabasesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
id := r.PathValue("id")
|
|
database, err := s.getDatabaseByID(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found")
|
|
return
|
|
}
|
|
|
|
provider := asString(database["provider"])
|
|
connectionURL := asString(database["connectionUrl"])
|
|
ok, status, message := s.validateDatabaseConnection(r.Context(), provider, connectionURL)
|
|
state := "failed"
|
|
if ok {
|
|
state = "healthy"
|
|
}
|
|
_, _ = s.Store.DB.ExecContext(r.Context(), `
|
|
UPDATE database_connections
|
|
SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`, storage.NowISO(), state, message, storage.NowISO(), id)
|
|
|
|
if !ok {
|
|
s.logIncident(r.Context(), sql.NullString{}, sql.NullString{}, "DATABASE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleKeysList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
rows, err := s.Store.DB.QueryContext(r.Context(), `
|
|
SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at, last_used_at
|
|
FROM api_keys
|
|
ORDER BY created_at DESC
|
|
`)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list API keys")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var id, name, keyPrefix, plan, allowed, createdAt, updatedAt string
|
|
var enabled int
|
|
var rpm, monthly sql.NullInt64
|
|
var lastUsed sql.NullString
|
|
if err := rows.Scan(&id, &name, &keyPrefix, &plan, &allowed, &enabled, &rpm, &monthly, &createdAt, &updatedAt, &lastUsed); err != nil {
|
|
continue
|
|
}
|
|
allowedServices := []string{}
|
|
_ = json.Unmarshal([]byte(allowed), &allowedServices)
|
|
items = append(items, map[string]any{
|
|
"id": id,
|
|
"name": name,
|
|
"keyPrefix": keyPrefix,
|
|
"plan": plan,
|
|
"allowedServiceIds": allowedServices,
|
|
"enabled": enabled == 1,
|
|
"rpmLimit": nullIntValue(rpm),
|
|
"monthlyQuota": nullIntValue(monthly),
|
|
"createdAt": createdAt,
|
|
"updatedAt": updatedAt,
|
|
"lastUsedAt": nullString(lastUsed),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleKeysCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
var input struct {
|
|
Name string `json:"name"`
|
|
Plan string `json:"plan"`
|
|
AllowedServiceIDs []string `json:"allowedServiceIds"`
|
|
RPMLimit *int `json:"rpmLimit"`
|
|
MonthlyQuota *int `json:"monthlyQuota"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
name := strings.TrimSpace(input.Name)
|
|
if name == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_NAME", "API key name is required")
|
|
return
|
|
}
|
|
plan := strings.ToLower(strings.TrimSpace(firstNonEmpty(input.Plan, "pro")))
|
|
rpmLimit, monthlyQuota := s.planDefaults(plan)
|
|
if input.RPMLimit != nil {
|
|
rpmLimit = input.RPMLimit
|
|
}
|
|
if input.MonthlyQuota != nil {
|
|
monthlyQuota = input.MonthlyQuota
|
|
}
|
|
|
|
rawKey, _ := auth.RandomToken(28)
|
|
rawKey = "apy_" + rawKey
|
|
keyHash := auth.HashToken(rawKey)
|
|
id, _ := auth.RandomID("key")
|
|
now := storage.NowISO()
|
|
|
|
_, err := s.Store.DB.ExecContext(r.Context(), `
|
|
INSERT INTO api_keys (id, name, key_hash, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`,
|
|
id,
|
|
name,
|
|
keyHash,
|
|
rawKey[:12],
|
|
plan,
|
|
mustJSON(input.AllowedServiceIDs),
|
|
boolToInt(defaultBool(input.Enabled, true)),
|
|
nullInt(rpmLimit),
|
|
nullInt(monthlyQuota),
|
|
now,
|
|
now,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "KEY_CREATE_FAILED", "Failed to create API key")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"ok": true,
|
|
"data": map[string]any{
|
|
"key": rawKey,
|
|
"item": map[string]any{"id": id, "name": name, "plan": plan},
|
|
},
|
|
})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleKeysPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "INVALID_ID", "API key ID is required")
|
|
return
|
|
}
|
|
var input struct {
|
|
Name *string `json:"name"`
|
|
Plan *string `json:"plan"`
|
|
AllowedServiceIDs []string `json:"allowedServiceIds"`
|
|
RPMLimit *int `json:"rpmLimit"`
|
|
MonthlyQuota *int `json:"monthlyQuota"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
|
|
current := struct {
|
|
Name string
|
|
Plan string
|
|
Allowed string
|
|
Enabled int
|
|
RPM sql.NullInt64
|
|
Monthly sql.NullInt64
|
|
}{}
|
|
err := s.Store.DB.QueryRowContext(r.Context(), `
|
|
SELECT name, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota
|
|
FROM api_keys WHERE id = ?
|
|
`, id).Scan(¤t.Name, ¤t.Plan, ¤t.Allowed, ¤t.Enabled, ¤t.RPM, ¤t.Monthly)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "KEY_NOT_FOUND", "API key not found")
|
|
return
|
|
}
|
|
|
|
plan := current.Plan
|
|
if input.Plan != nil {
|
|
plan = strings.ToLower(strings.TrimSpace(*input.Plan))
|
|
}
|
|
|
|
allowed := current.Allowed
|
|
if input.AllowedServiceIDs != nil {
|
|
allowed = mustJSON(input.AllowedServiceIDs)
|
|
}
|
|
|
|
_, err = s.Store.DB.ExecContext(r.Context(), `
|
|
UPDATE api_keys
|
|
SET name = ?, plan = ?, allowed_service_ids = ?, enabled = ?, rpm_limit = ?, monthly_quota = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`,
|
|
firstNonEmptyPtr(input.Name, current.Name),
|
|
plan,
|
|
allowed,
|
|
boolToInt(defaultBool(input.Enabled, current.Enabled == 1)),
|
|
nullInt(coalesceIntPtr(input.RPMLimit, nullIntValue(current.RPM))),
|
|
nullInt(coalesceIntPtr(input.MonthlyQuota, nullIntValue(current.Monthly))),
|
|
storage.NowISO(),
|
|
id,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "KEY_UPDATE_FAILED", "Failed to update API key")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleAnalyticsOps(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
counts := map[string]int{}
|
|
for _, table := range []string{"users", "services", "database_connections", "api_keys"} {
|
|
var count int
|
|
_ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM `+table).Scan(&count)
|
|
counts[table] = count
|
|
}
|
|
|
|
period := time.Now().UTC().Format("2006-01")
|
|
var monthlyRequests int
|
|
_ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(request_count), 0) FROM usage_counters WHERE period_month = ?`, period).Scan(&monthlyRequests)
|
|
|
|
severityRows, _ := s.Store.DB.QueryContext(r.Context(), `
|
|
SELECT severity, COUNT(*) FROM incident_events
|
|
WHERE occurred_at >= datetime('now', '-24 hours')
|
|
GROUP BY severity
|
|
`)
|
|
incidentsBySeverity := map[string]int{}
|
|
if severityRows != nil {
|
|
defer severityRows.Close()
|
|
for severityRows.Next() {
|
|
var severity string
|
|
var count int
|
|
if err := severityRows.Scan(&severity, &count); err == nil {
|
|
incidentsBySeverity[severity] = count
|
|
}
|
|
}
|
|
}
|
|
|
|
healthRows, _ := s.Store.DB.QueryContext(r.Context(), `
|
|
SELECT COALESCE(last_validation_status, 'unknown') AS status, COUNT(*)
|
|
FROM services
|
|
GROUP BY status
|
|
`)
|
|
healthCounts := map[string]int{}
|
|
if healthRows != nil {
|
|
defer healthRows.Close()
|
|
for healthRows.Next() {
|
|
var status string
|
|
var count int
|
|
if err := healthRows.Scan(&status, &count); err == nil {
|
|
healthCounts[status] = count
|
|
}
|
|
}
|
|
}
|
|
|
|
tsRows, _ := s.Store.DB.QueryContext(r.Context(), `
|
|
SELECT strftime('%Y-%m-%dT%H:00:00Z', occurred_at) AS bucket, COUNT(*)
|
|
FROM metrics_timeseries
|
|
WHERE metric = 'request_total' AND occurred_at >= datetime('now', '-24 hours')
|
|
GROUP BY bucket
|
|
ORDER BY bucket ASC
|
|
`)
|
|
hourly := []map[string]any{}
|
|
if tsRows != nil {
|
|
defer tsRows.Close()
|
|
for tsRows.Next() {
|
|
var bucket string
|
|
var count int
|
|
if err := tsRows.Scan(&bucket, &count); err == nil {
|
|
hourly = append(hourly, map[string]any{"bucket": bucket, "value": count})
|
|
}
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"data": map[string]any{
|
|
"counts": map[string]any{
|
|
"users": counts["users"],
|
|
"services": counts["services"],
|
|
"databases": counts["database_connections"],
|
|
"keys": counts["api_keys"],
|
|
},
|
|
"requests": map[string]any{
|
|
"period": period,
|
|
"total": monthlyRequests,
|
|
"hourly": hourly,
|
|
},
|
|
"incidentsBySeverity": incidentsBySeverity,
|
|
"serviceHealth": healthCounts,
|
|
},
|
|
})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleAnalyticsTraffic(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
from := time.Now().Add(-7 * 24 * time.Hour)
|
|
to := time.Now()
|
|
if value := strings.TrimSpace(r.URL.Query().Get("from")); value != "" {
|
|
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
|
from = parsed
|
|
}
|
|
}
|
|
if value := strings.TrimSpace(r.URL.Query().Get("to")); value != "" {
|
|
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
|
to = parsed
|
|
}
|
|
}
|
|
|
|
traffic, err := s.Umami.FetchTraffic(r.Context(), from, to)
|
|
if err == nil {
|
|
_, _ = s.Store.DB.ExecContext(r.Context(), `
|
|
INSERT INTO umami_sync_cache (cache_key, payload_json, updated_at)
|
|
VALUES ('traffic', ?, ?)
|
|
ON CONFLICT(cache_key) DO UPDATE SET payload_json = excluded.payload_json, updated_at = excluded.updated_at
|
|
`, mustJSON(traffic), storage.NowISO())
|
|
} else {
|
|
var payload string
|
|
_ = s.Store.DB.QueryRowContext(r.Context(), `SELECT payload_json FROM umami_sync_cache WHERE cache_key = 'traffic'`).Scan(&payload)
|
|
if payload != "" {
|
|
_ = json.Unmarshal([]byte(payload), &traffic)
|
|
}
|
|
if traffic == nil {
|
|
traffic = map[string]any{"enabled": false, "error": err.Error()}
|
|
}
|
|
}
|
|
|
|
eventRows, _ := s.Store.DB.QueryContext(r.Context(), `
|
|
SELECT COALESCE(json_extract(labels_json, '$.path'), 'unknown') AS path, COUNT(*)
|
|
FROM metrics_timeseries
|
|
WHERE metric = 'client_event' AND occurred_at >= datetime('now', '-7 days')
|
|
GROUP BY path
|
|
ORDER BY COUNT(*) DESC
|
|
LIMIT 20
|
|
`)
|
|
events := []map[string]any{}
|
|
if eventRows != nil {
|
|
defer eventRows.Close()
|
|
for eventRows.Next() {
|
|
var page string
|
|
var count int
|
|
if err := eventRows.Scan(&page, &count); err == nil {
|
|
events = append(events, map[string]any{"path": page, "count": count})
|
|
}
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"data": map[string]any{
|
|
"umami": traffic,
|
|
"clientEvents": events,
|
|
},
|
|
})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleAnalyticsEvents(w http.ResponseWriter, r *http.Request, ctx *authContext) {
|
|
var input struct {
|
|
Event string `json:"event"`
|
|
Path string `json:"path"`
|
|
Meta map[string]any `json:"meta"`
|
|
}
|
|
if err := decodeJSON(r.Body, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
|
|
return
|
|
}
|
|
labels := map[string]any{
|
|
"event": firstNonEmpty(input.Event, "event"),
|
|
"path": firstNonEmpty(input.Path, "unknown"),
|
|
"meta": input.Meta,
|
|
}
|
|
_ = s.logMetric("client_event", 1, labels)
|
|
writeJSON(w, http.StatusCreated, map[string]any{"ok": true})
|
|
_ = ctx
|
|
}
|
|
|
|
func (s *Server) handleGatewayOrUI(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found")
|
|
return
|
|
}
|
|
|
|
uiBasePath := normalizePathPrefix(s.Cfg.DashboardUIBasePath, "/")
|
|
uiRequestPath, uiPathMatched := webPathForRequest(r.URL.Path, uiBasePath)
|
|
if uiBasePath != "/" && uiPathMatched {
|
|
s.serveWeb(w, uiRequestPath)
|
|
return
|
|
}
|
|
|
|
service, ok := s.matchService(r.Context(), r.URL.Path)
|
|
if ok {
|
|
s.handleProxy(w, r, service)
|
|
return
|
|
}
|
|
|
|
if uiBasePath == "/" {
|
|
s.serveWeb(w, uiRequestPath)
|
|
return
|
|
}
|
|
|
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found")
|
|
}
|
|
|
|
func (s *Server) serveWeb(w http.ResponseWriter, requestPath string) {
|
|
sub, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available")
|
|
return
|
|
}
|
|
|
|
reqPath := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(requestPath)), "/")
|
|
if reqPath == "." {
|
|
reqPath = ""
|
|
}
|
|
if reqPath == "" {
|
|
reqPath = "index.html"
|
|
}
|
|
if _, err := fs.Stat(sub, reqPath); err != nil {
|
|
reqPath = "index.html"
|
|
}
|
|
|
|
data, err := fs.ReadFile(sub, reqPath)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available")
|
|
return
|
|
}
|
|
|
|
if contentType := mime.TypeByExtension(path.Ext(reqPath)); contentType != "" {
|
|
w.Header().Set("Content-Type", contentType)
|
|
}
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request, service map[string]any) {
|
|
secret := strings.TrimSpace(r.Header.Get(s.Cfg.APIKeyHeader))
|
|
if secret == "" {
|
|
writeError(w, http.StatusUnauthorized, "API_KEY_MISSING", fmt.Sprintf("Missing API key header '%s'", s.Cfg.APIKeyHeader))
|
|
return
|
|
}
|
|
|
|
apiKey, err := s.getAPIKeyBySecret(r.Context(), secret)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "API_KEY_INVALID", "Invalid API key")
|
|
_ = s.logIncident(r.Context(), nullStringValue(service["id"]), sql.NullString{}, "API_KEY_INVALID", "Invalid API key", "medium", sql.NullInt64{Int64: 401, Valid: true})
|
|
return
|
|
}
|
|
if !asBool(apiKey["enabled"]) {
|
|
writeError(w, http.StatusForbidden, "API_KEY_DISABLED", "API key is disabled")
|
|
return
|
|
}
|
|
if !asBool(service["enabled"]) {
|
|
writeError(w, http.StatusServiceUnavailable, "SERVICE_DISABLED", "Service is disabled")
|
|
return
|
|
}
|
|
|
|
if !serviceAllowed(apiKey, asString(service["id"]), asString(service["slug"]), asString(service["routePrefix"])) {
|
|
writeError(w, http.StatusForbidden, "SERVICE_SCOPE_DENIED", "API key is not allowed for this service")
|
|
_ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "SERVICE_SCOPE_DENIED", "API key attempted unauthorized service access", "medium", sql.NullInt64{Int64: 403, Valid: true})
|
|
return
|
|
}
|
|
|
|
period := time.Now().UTC().Format("2006-01")
|
|
serviceLimitRPM := nullIntValueFromAny(service["rpmLimit"])
|
|
keyLimitRPM := nullIntValueFromAny(apiKey["rpmLimit"])
|
|
serviceLimitMonthly := nullIntValueFromAny(service["monthlyQuota"])
|
|
keyLimitMonthly := nullIntValueFromAny(apiKey["monthlyQuota"])
|
|
|
|
effectiveRPM := minPositive(serviceLimitRPM, keyLimitRPM)
|
|
effectiveMonthly := minPositive(serviceLimitMonthly, keyLimitMonthly)
|
|
|
|
if effectiveMonthly != nil {
|
|
used := s.usageCount(r.Context(), asString(apiKey["id"]), asString(service["id"]), period)
|
|
if used >= *effectiveMonthly {
|
|
writeError(w, http.StatusTooManyRequests, "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded")
|
|
_ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded", "high", sql.NullInt64{Int64: 429, Valid: true})
|
|
return
|
|
}
|
|
}
|
|
|
|
if effectiveRPM != nil {
|
|
if ok, retryAfter := s.allowRPM(asString(apiKey["id"])+":"+asString(service["id"]), *effectiveRPM); !ok {
|
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
|
writeError(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "Rate limit exceeded")
|
|
_ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "RATE_LIMIT_EXCEEDED", "Rate limit exceeded", "medium", sql.NullInt64{Int64: 429, Valid: true})
|
|
return
|
|
}
|
|
}
|
|
|
|
targetURL, err := gateway.BuildTargetURL(asString(service["upstreamUrl"]), asString(service["routePrefix"]), r.URL.Path, r.URL.RawQuery)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "UPSTREAM_INVALID", "Invalid upstream URL")
|
|
return
|
|
}
|
|
|
|
headers := map[string]string{}
|
|
if upstreamHeader := asString(service["upstreamAuthHeader"]); upstreamHeader != "" {
|
|
headers[upstreamHeader] = asString(service["upstreamAuthValue"])
|
|
}
|
|
if token := asString(service["internalToken"]); token != "" {
|
|
headers[s.Cfg.ServiceTokenHeader] = token
|
|
}
|
|
|
|
r.Header.Del(s.Cfg.APIKeyHeader)
|
|
|
|
timeout := time.Duration(intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds()))) * time.Millisecond
|
|
client := &http.Client{Timeout: timeout}
|
|
|
|
statusCode, _, proxyErr := gateway.ProxyRequest(client, w, r, targetURL, headers)
|
|
if proxyErr != nil {
|
|
status := http.StatusBadGateway
|
|
if errors.Is(proxyErr, context.DeadlineExceeded) || strings.Contains(strings.ToLower(proxyErr.Error()), "timeout") {
|
|
status = http.StatusGatewayTimeout
|
|
}
|
|
writeError(w, status, "UPSTREAM_ERROR", proxyErr.Error())
|
|
_ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_ERROR", proxyErr.Error(), "high", sql.NullInt64{Int64: int64(status), Valid: true})
|
|
return
|
|
}
|
|
|
|
s.incrementUsage(r.Context(), asString(apiKey["id"]), asString(service["id"]), period)
|
|
_, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?`, storage.NowISO(), storage.NowISO(), asString(apiKey["id"]))
|
|
_ = s.logMetric("request_total", 1, map[string]any{"service": asString(service["slug"]), "status": statusCode})
|
|
|
|
if statusCode >= 500 {
|
|
_ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_SERVER_ERROR", fmt.Sprintf("Upstream returned %d", statusCode), "high", sql.NullInt64{Int64: int64(statusCode), Valid: true})
|
|
}
|
|
}
|
|
|
|
func (s *Server) resolveAuth(r *http.Request) (*authContext, error) {
|
|
accessCookie, err := r.Cookie(s.Cfg.SessionAccessCookie)
|
|
if err != nil || strings.TrimSpace(accessCookie.Value) == "" {
|
|
return nil, errors.New("access cookie missing")
|
|
}
|
|
return s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessCookie.Value))
|
|
}
|
|
|
|
func (s *Server) resolveAuthFromTokenHash(ctx context.Context, accessTokenHash string) (*authContext, error) {
|
|
var user struct {
|
|
ID string
|
|
Email string
|
|
Enabled int
|
|
ForcePasswordReset int
|
|
SessionID string
|
|
}
|
|
err := s.Store.DB.QueryRowContext(ctx, `
|
|
SELECT u.id, u.email, u.enabled, u.force_password_reset, sess.id
|
|
FROM sessions sess
|
|
JOIN users u ON u.id = sess.user_id
|
|
WHERE sess.access_token_hash = ?
|
|
AND sess.revoked_at IS NULL
|
|
AND sess.access_expires_at > ?
|
|
`, accessTokenHash, storage.NowISO()).Scan(&user.ID, &user.Email, &user.Enabled, &user.ForcePasswordReset, &user.SessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
roles, permissions, err := s.userAccess(ctx, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &authContext{
|
|
UserID: user.ID,
|
|
Email: user.Email,
|
|
Enabled: user.Enabled == 1,
|
|
ForcePasswordReset: user.ForcePasswordReset == 1,
|
|
Roles: roles,
|
|
Permissions: permissions,
|
|
SessionID: user.SessionID,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) userAccess(ctx context.Context, userID string) ([]string, map[string]bool, error) {
|
|
roles := []string{}
|
|
roleRows, err := s.Store.DB.QueryContext(ctx, `
|
|
SELECT r.slug
|
|
FROM user_roles ur
|
|
JOIN roles r ON r.id = ur.role_id
|
|
WHERE ur.user_id = ? AND r.enabled = 1
|
|
`, userID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for roleRows.Next() {
|
|
var slug string
|
|
if err := roleRows.Scan(&slug); err == nil {
|
|
roles = append(roles, slug)
|
|
}
|
|
}
|
|
roleRows.Close()
|
|
|
|
permissions := map[string]bool{}
|
|
permRows, err := s.Store.DB.QueryContext(ctx, `
|
|
SELECT DISTINCT p.code
|
|
FROM user_roles ur
|
|
JOIN role_permissions rp ON rp.role_id = ur.role_id
|
|
JOIN permissions p ON p.id = rp.permission_id
|
|
JOIN roles r ON r.id = ur.role_id
|
|
WHERE ur.user_id = ? AND r.enabled = 1
|
|
`, userID)
|
|
if err != nil {
|
|
return roles, permissions, err
|
|
}
|
|
for permRows.Next() {
|
|
var code string
|
|
if err := permRows.Scan(&code); err == nil {
|
|
permissions[code] = true
|
|
}
|
|
}
|
|
permRows.Close()
|
|
|
|
return roles, permissions, nil
|
|
}
|
|
|
|
func (s *Server) createSession(ctx context.Context, userID string) (string, string, string, error) {
|
|
sessionID, _ := auth.RandomID("ses")
|
|
accessToken, _ := auth.RandomToken(32)
|
|
refreshToken, _ := auth.RandomToken(48)
|
|
accessHash := auth.HashToken(accessToken)
|
|
refreshHash := auth.HashToken(refreshToken)
|
|
|
|
now := storage.NowISO()
|
|
accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339)
|
|
refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339)
|
|
|
|
_, err := s.Store.DB.ExecContext(ctx, `
|
|
INSERT INTO sessions (id, user_id, access_token_hash, refresh_token_hash, access_expires_at, refresh_expires_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, sessionID, userID, accessHash, refreshHash, accessExp, refreshExp, now, now)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
return sessionID, accessToken, refreshToken, nil
|
|
}
|
|
|
|
func (s *Server) setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) {
|
|
accessCookie := &http.Cookie{
|
|
Name: s.Cfg.SessionAccessCookie,
|
|
Value: accessToken,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: s.Cfg.CookieSecure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Expires: time.Now().Add(s.Cfg.AccessTokenTTL),
|
|
}
|
|
if s.Cfg.CookieDomain != "" {
|
|
accessCookie.Domain = s.Cfg.CookieDomain
|
|
}
|
|
|
|
refreshCookie := &http.Cookie{
|
|
Name: s.Cfg.SessionRefreshCookie,
|
|
Value: refreshToken,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: s.Cfg.CookieSecure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Expires: time.Now().Add(s.Cfg.RefreshTokenTTL),
|
|
}
|
|
if s.Cfg.CookieDomain != "" {
|
|
refreshCookie.Domain = s.Cfg.CookieDomain
|
|
}
|
|
|
|
http.SetCookie(w, accessCookie)
|
|
http.SetCookie(w, refreshCookie)
|
|
}
|
|
|
|
func (s *Server) clearAuthCookies(w http.ResponseWriter) {
|
|
expired := time.Unix(0, 0)
|
|
for _, cookieName := range []string{s.Cfg.SessionAccessCookie, s.Cfg.SessionRefreshCookie} {
|
|
cookie := &http.Cookie{
|
|
Name: cookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: s.Cfg.CookieSecure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Expires: expired,
|
|
MaxAge: -1,
|
|
}
|
|
if s.Cfg.CookieDomain != "" {
|
|
cookie.Domain = s.Cfg.CookieDomain
|
|
}
|
|
http.SetCookie(w, cookie)
|
|
}
|
|
}
|
|
|
|
func (s *Server) hasUsers(ctx context.Context) bool {
|
|
var count int
|
|
_ = s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count)
|
|
return count > 0
|
|
}
|
|
|
|
func (s *Server) isLoginRateLimited(r *http.Request, email string) bool {
|
|
key := r.RemoteAddr + ":" + email
|
|
s.loginMu.Lock()
|
|
defer s.loginMu.Unlock()
|
|
entry := s.loginAttempts[key]
|
|
now := time.Now()
|
|
if now.Sub(entry.WindowStart) > 15*time.Minute {
|
|
entry = loginWindow{WindowStart: now, Count: 0}
|
|
}
|
|
return entry.Count >= 10
|
|
}
|
|
|
|
func (s *Server) markLoginAttempt(r *http.Request, email string) {
|
|
key := r.RemoteAddr + ":" + email
|
|
s.loginMu.Lock()
|
|
defer s.loginMu.Unlock()
|
|
entry := s.loginAttempts[key]
|
|
now := time.Now()
|
|
if now.Sub(entry.WindowStart) > 15*time.Minute {
|
|
entry = loginWindow{WindowStart: now, Count: 0}
|
|
}
|
|
entry.Count++
|
|
s.loginAttempts[key] = entry
|
|
}
|
|
|
|
func (s *Server) clearLoginAttempts(r *http.Request, email string) {
|
|
key := r.RemoteAddr + ":" + email
|
|
s.loginMu.Lock()
|
|
defer s.loginMu.Unlock()
|
|
delete(s.loginAttempts, key)
|
|
}
|
|
|
|
func (s *Server) resolveRoleIDs(ctx context.Context, roleIDs []string, roleSlugs []string) ([]string, error) {
|
|
resolved := []string{}
|
|
if len(roleIDs) > 0 {
|
|
for _, roleID := range roleIDs {
|
|
id := strings.TrimSpace(roleID)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
var exists int
|
|
if err := s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM roles WHERE id = ?`, id).Scan(&exists); err != nil || exists == 0 {
|
|
return nil, fmt.Errorf("role %s not found", id)
|
|
}
|
|
resolved = append(resolved, id)
|
|
}
|
|
return uniqueStrings(resolved), nil
|
|
}
|
|
for _, slug := range roleSlugs {
|
|
id, err := s.roleIDBySlug(ctx, slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resolved = append(resolved, id)
|
|
}
|
|
return uniqueStrings(resolved), nil
|
|
}
|
|
|
|
func (s *Server) roleIDBySlug(ctx context.Context, slug string) (string, error) {
|
|
id := ""
|
|
err := s.Store.DB.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = ?`, strings.ToLower(strings.TrimSpace(slug))).Scan(&id)
|
|
if err != nil {
|
|
return "", fmt.Errorf("role %s not found", slug)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func (s *Server) replaceRolePermissionsTx(ctx context.Context, tx *sql.Tx, roleID string, permissionCodes []string) error {
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = ?`, roleID); err != nil {
|
|
return err
|
|
}
|
|
now := storage.NowISO()
|
|
for _, code := range permissionCodes {
|
|
permCode := strings.TrimSpace(code)
|
|
if permCode == "" {
|
|
continue
|
|
}
|
|
permID := ""
|
|
if err := tx.QueryRowContext(ctx, `SELECT id FROM permissions WHERE code = ?`, permCode).Scan(&permID); err != nil {
|
|
return fmt.Errorf("permission not found: %s", permCode)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO role_permissions (role_id, permission_id, created_at) VALUES (?, ?, ?)`, roleID, permID, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) getServiceByID(ctx context.Context, id string) (map[string]any, error) {
|
|
row := s.Store.DB.QueryRowContext(ctx, `
|
|
SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
|
|
FROM services WHERE id = ?
|
|
`, id)
|
|
return scanServiceRow(row)
|
|
}
|
|
|
|
func (s *Server) getDatabaseByID(ctx context.Context, id string) (map[string]any, error) {
|
|
var item struct {
|
|
ID, Name, Slug, Provider, ConnectionURL, CreatedAt, UpdatedAt string
|
|
Enabled int
|
|
LastAt, LastStatus, LastMessage sql.NullString
|
|
}
|
|
err := s.Store.DB.QueryRowContext(ctx, `
|
|
SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
|
|
FROM database_connections
|
|
WHERE id = ?
|
|
`, id).Scan(&item.ID, &item.Name, &item.Slug, &item.Provider, &item.ConnectionURL, &item.Enabled, &item.LastAt, &item.LastStatus, &item.LastMessage, &item.CreatedAt, &item.UpdatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"id": item.ID,
|
|
"name": item.Name,
|
|
"slug": item.Slug,
|
|
"provider": item.Provider,
|
|
"connectionUrl": item.ConnectionURL,
|
|
"target": extractDatabaseTarget(item.Provider, item.ConnectionURL),
|
|
"maskedConnectionUrl": maskDatabaseURL(item.Provider, item.ConnectionURL),
|
|
"enabled": item.Enabled == 1,
|
|
"lastValidationAt": nullString(item.LastAt),
|
|
"lastValidationStatus": nullString(item.LastStatus),
|
|
"lastValidationMessage": nullString(item.LastMessage),
|
|
"createdAt": item.CreatedAt,
|
|
"updatedAt": item.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) validateDatabaseConnection(ctx context.Context, provider, connectionURL string) (bool, int, string) {
|
|
provider = normalizeProvider(provider)
|
|
if provider == "" {
|
|
return false, http.StatusBadRequest, "Unsupported database provider"
|
|
}
|
|
|
|
timeoutCtx, cancel := context.WithTimeout(ctx, s.Cfg.DefaultServiceTimeout)
|
|
defer cancel()
|
|
|
|
sqlDriver := ""
|
|
driverConn := connectionURL
|
|
switch provider {
|
|
case "sqlite":
|
|
sqlDriver = "sqlite"
|
|
if !strings.HasPrefix(driverConn, "file:") && driverConn != ":memory:" {
|
|
if abs, err := pathAbs(driverConn); err == nil {
|
|
driverConn = "file:" + abs
|
|
}
|
|
}
|
|
case "postgres":
|
|
sqlDriver = "pgx"
|
|
case "mysql":
|
|
sqlDriver = "mysql"
|
|
}
|
|
|
|
db, err := sql.Open(sqlDriver, driverConn)
|
|
if err != nil {
|
|
return false, http.StatusBadGateway, err.Error()
|
|
}
|
|
defer db.Close()
|
|
|
|
db.SetConnMaxLifetime(2 * time.Minute)
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
|
|
if err := db.PingContext(timeoutCtx); err != nil {
|
|
return false, http.StatusBadGateway, err.Error()
|
|
}
|
|
return true, http.StatusOK, fmt.Sprintf("%s connection validated", strings.Title(provider))
|
|
}
|
|
|
|
func (s *Server) getAPIKeyBySecret(ctx context.Context, secret string) (map[string]any, error) {
|
|
hash := auth.HashToken(secret)
|
|
var item struct {
|
|
ID, Name, KeyPrefix, Plan, AllowedServiceIDs, CreatedAt, UpdatedAt string
|
|
Enabled int
|
|
RPMLimit, MonthlyQuota sql.NullInt64
|
|
}
|
|
err := s.Store.DB.QueryRowContext(ctx, `
|
|
SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at
|
|
FROM api_keys
|
|
WHERE key_hash = ?
|
|
`, hash).Scan(&item.ID, &item.Name, &item.KeyPrefix, &item.Plan, &item.AllowedServiceIDs, &item.Enabled, &item.RPMLimit, &item.MonthlyQuota, &item.CreatedAt, &item.UpdatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
allowed := []string{}
|
|
_ = json.Unmarshal([]byte(item.AllowedServiceIDs), &allowed)
|
|
return map[string]any{
|
|
"id": item.ID,
|
|
"name": item.Name,
|
|
"keyPrefix": item.KeyPrefix,
|
|
"plan": item.Plan,
|
|
"allowedServiceIds": allowed,
|
|
"enabled": item.Enabled == 1,
|
|
"rpmLimit": nullIntValue(item.RPMLimit),
|
|
"monthlyQuota": nullIntValue(item.MonthlyQuota),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) planDefaults(plan string) (*int, *int) {
|
|
p := strings.ToLower(strings.TrimSpace(plan))
|
|
switch p {
|
|
case "free":
|
|
return intPtr(s.Cfg.FreeRPM), intPtr(s.Cfg.FreeMonthlyQuota)
|
|
case "business":
|
|
return intPtr(s.Cfg.BusinessRPM), intPtr(s.Cfg.BusinessMonthlyQuota)
|
|
case "enterprise":
|
|
return nil, nil
|
|
default:
|
|
return intPtr(s.Cfg.ProRPM), intPtr(s.Cfg.ProMonthlyQuota)
|
|
}
|
|
}
|
|
|
|
func (s *Server) matchService(ctx context.Context, requestPath string) (map[string]any, bool) {
|
|
rows, err := s.Store.DB.QueryContext(ctx, `
|
|
SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
|
|
FROM services
|
|
WHERE enabled = 1
|
|
ORDER BY LENGTH(route_prefix) DESC, route_prefix DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
item, err := scanService(rows)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
prefix := asString(item["routePrefix"])
|
|
if prefix == "/" || requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/") {
|
|
return item, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (s *Server) usageCount(ctx context.Context, apiKeyID, serviceID, period string) int {
|
|
var count int
|
|
_ = s.Store.DB.QueryRowContext(ctx, `
|
|
SELECT COALESCE(request_count, 0)
|
|
FROM usage_counters
|
|
WHERE api_key_id = ? AND service_id = ? AND period_month = ?
|
|
`, apiKeyID, serviceID, period).Scan(&count)
|
|
return count
|
|
}
|
|
|
|
func (s *Server) incrementUsage(ctx context.Context, apiKeyID, serviceID, period string) {
|
|
_, _ = s.Store.DB.ExecContext(ctx, `
|
|
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
|
|
VALUES (?, ?, ?, 1, ?)
|
|
ON CONFLICT(api_key_id, service_id, period_month)
|
|
DO UPDATE SET request_count = request_count + 1, updated_at = excluded.updated_at
|
|
`, apiKeyID, serviceID, period, storage.NowISO())
|
|
}
|
|
|
|
func (s *Server) allowRPM(bucket string, limit int) (bool, int) {
|
|
if limit <= 0 {
|
|
return true, 0
|
|
}
|
|
now := time.Now().UTC()
|
|
windowStart := now.Truncate(time.Minute)
|
|
key := bucket + ":" + windowStart.Format(time.RFC3339)
|
|
|
|
s.rpmMu.Lock()
|
|
defer s.rpmMu.Unlock()
|
|
|
|
for k, v := range s.rpmUsage {
|
|
if now.Sub(v.WindowStart) > 2*time.Minute {
|
|
delete(s.rpmUsage, k)
|
|
}
|
|
}
|
|
|
|
entry := s.rpmUsage[key]
|
|
if entry.WindowStart.IsZero() {
|
|
entry.WindowStart = windowStart
|
|
}
|
|
if entry.Count >= limit {
|
|
retry := int(windowStart.Add(time.Minute).Sub(now).Seconds())
|
|
if retry < 1 {
|
|
retry = 1
|
|
}
|
|
return false, retry
|
|
}
|
|
entry.Count++
|
|
s.rpmUsage[key] = entry
|
|
return true, 0
|
|
}
|
|
|
|
func (s *Server) logIncident(ctx context.Context, serviceID, apiKeyID sql.NullString, code, message, severity string, httpStatus sql.NullInt64) error {
|
|
_, err := s.Store.DB.ExecContext(ctx, `
|
|
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, count, occurred_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
|
|
`, serviceID, apiKeyID, code, message, severity, httpStatus, storage.NowISO())
|
|
return err
|
|
}
|
|
|
|
func (s *Server) logMetric(metric string, value float64, labels map[string]any) error {
|
|
_, err := s.Store.DB.Exec(`
|
|
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
|
|
VALUES (?, ?, ?, ?)
|
|
`, metric, value, mustJSON(labels), storage.NowISO())
|
|
return err
|
|
}
|
|
|
|
func (s *Server) audit(ctx context.Context, actorUserID, action, targetType, targetID string, payload any) {
|
|
_, _ = s.Store.DB.ExecContext(ctx, `
|
|
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, payload_json, occurred_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`, nullIfEmpty(actorUserID), action, nullIfEmpty(targetType), nullIfEmpty(targetID), mustJSON(payload), storage.NowISO())
|
|
}
|
|
|
|
func scanService(rows *sql.Rows) (map[string]any, error) {
|
|
var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string
|
|
var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString
|
|
var enabled int
|
|
var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64
|
|
if err := rows.Scan(
|
|
&id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken,
|
|
&enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS,
|
|
&lastValidationAt, &lastValidationStatus, &lastValidationMessage,
|
|
&createdAt, &updatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"id": id,
|
|
"name": name,
|
|
"slug": slug,
|
|
"upstreamUrl": upstreamURL,
|
|
"routePrefix": routePrefix,
|
|
"healthPath": healthPath,
|
|
"upstreamAuthHeader": nullString(upstreamAuthHeader),
|
|
"upstreamAuthValue": nullString(upstreamAuthValue),
|
|
"internalToken": nullString(internalToken),
|
|
"enabled": enabled == 1,
|
|
"rpmLimit": nullIntValue(rpmLimit),
|
|
"monthlyQuota": nullIntValue(monthlyQuota),
|
|
"requestTimeoutMs": nullIntValue(requestTimeoutMS),
|
|
"lastValidationAt": nullString(upstreamOr(lastValidationAt, sql.NullString{})),
|
|
"lastValidationStatus": nullString(lastValidationStatus),
|
|
"lastValidationMessage": nullString(lastValidationMessage),
|
|
"createdAt": createdAt,
|
|
"updatedAt": updatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func scanServiceRow(row *sql.Row) (map[string]any, error) {
|
|
var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string
|
|
var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString
|
|
var enabled int
|
|
var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64
|
|
if err := row.Scan(
|
|
&id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken,
|
|
&enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS,
|
|
&lastValidationAt, &lastValidationStatus, &lastValidationMessage,
|
|
&createdAt, &updatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"id": id,
|
|
"name": name,
|
|
"slug": slug,
|
|
"upstreamUrl": upstreamURL,
|
|
"routePrefix": routePrefix,
|
|
"healthPath": healthPath,
|
|
"upstreamAuthHeader": nullString(upstreamAuthHeader),
|
|
"upstreamAuthValue": nullString(upstreamAuthValue),
|
|
"internalToken": nullString(internalToken),
|
|
"enabled": enabled == 1,
|
|
"rpmLimit": nullIntValue(rpmLimit),
|
|
"monthlyQuota": nullIntValue(monthlyQuota),
|
|
"requestTimeoutMs": nullIntValue(requestTimeoutMS),
|
|
"lastValidationAt": nullString(lastValidationAt),
|
|
"lastValidationStatus": nullString(lastValidationStatus),
|
|
"lastValidationMessage": nullString(lastValidationMessage),
|
|
"createdAt": createdAt,
|
|
"updatedAt": updatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func serviceAllowed(apiKey map[string]any, serviceID, slug, prefix string) bool {
|
|
allowed, ok := apiKey["allowedServiceIds"].([]string)
|
|
if !ok || len(allowed) == 0 {
|
|
return true
|
|
}
|
|
for _, candidate := range allowed {
|
|
if candidate == serviceID || candidate == slug || candidate == prefix {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload map[string]any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
|
writeJSON(w, status, map[string]any{
|
|
"ok": false,
|
|
"error": map[string]any{
|
|
"code": code,
|
|
"message": message,
|
|
},
|
|
})
|
|
}
|
|
|
|
func decodeJSON(body io.Reader, target any) error {
|
|
decoder := json.NewDecoder(io.LimitReader(body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mustJSON(value any) string {
|
|
bytes, _ := json.Marshal(value)
|
|
return string(bytes)
|
|
}
|
|
|
|
func boolToInt(value bool) int {
|
|
if value {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func intPtr(value int) *int {
|
|
v := value
|
|
return &v
|
|
}
|
|
|
|
func nullInt(value *int) any {
|
|
if value == nil || *value <= 0 {
|
|
return nil
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func nullIntValue(value sql.NullInt64) *int {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
v := int(value.Int64)
|
|
return &v
|
|
}
|
|
|
|
func nullIntValueFromAny(value any) *int {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
switch typed := value.(type) {
|
|
case *int:
|
|
return typed
|
|
case int:
|
|
v := typed
|
|
return &v
|
|
case int64:
|
|
v := int(typed)
|
|
return &v
|
|
case float64:
|
|
v := int(typed)
|
|
return &v
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func nullString(value sql.NullString) any {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
return value.String
|
|
}
|
|
|
|
func nullStringValue(value any) sql.NullString {
|
|
if value == nil {
|
|
return sql.NullString{}
|
|
}
|
|
text := fmt.Sprintf("%v", value)
|
|
if strings.TrimSpace(text) == "" {
|
|
return sql.NullString{}
|
|
}
|
|
return sql.NullString{String: text, Valid: true}
|
|
}
|
|
|
|
func nullIfEmpty(value string) any {
|
|
v := strings.TrimSpace(value)
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
return v
|
|
}
|
|
|
|
func coalesceIntPtr(value *int, fallback *int) *int {
|
|
if value != nil {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func normalizePathPrefix(value, fallback string) string {
|
|
v := strings.TrimSpace(value)
|
|
if v == "" {
|
|
v = fallback
|
|
}
|
|
if !strings.HasPrefix(v, "/") {
|
|
v = "/" + v
|
|
}
|
|
if len(v) > 1 && strings.HasSuffix(v, "/") {
|
|
v = strings.TrimSuffix(v, "/")
|
|
}
|
|
return v
|
|
}
|
|
|
|
func slugify(value, fallback string) string {
|
|
v := strings.ToLower(strings.TrimSpace(value))
|
|
if v == "" {
|
|
v = fallback
|
|
}
|
|
out := strings.Builder{}
|
|
lastDash := false
|
|
for _, r := range v {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
out.WriteRune(r)
|
|
lastDash = false
|
|
continue
|
|
}
|
|
if !lastDash {
|
|
out.WriteRune('-')
|
|
lastDash = true
|
|
}
|
|
}
|
|
result := strings.Trim(out.String(), "-")
|
|
if result == "" {
|
|
return fallback
|
|
}
|
|
return result
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstNonEmptyPtr(value *string, fallback string) string {
|
|
if value != nil {
|
|
if strings.TrimSpace(*value) == "" {
|
|
return fallback
|
|
}
|
|
return strings.TrimSpace(*value)
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func defaultBool(value *bool, fallback bool) bool {
|
|
if value == nil {
|
|
return fallback
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func asString(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
if v, ok := value.(string); ok {
|
|
return v
|
|
}
|
|
return fmt.Sprintf("%v", value)
|
|
}
|
|
|
|
func asBool(value any) bool {
|
|
if value == nil {
|
|
return false
|
|
}
|
|
switch v := value.(type) {
|
|
case bool:
|
|
return v
|
|
case int:
|
|
return v == 1
|
|
case int64:
|
|
return v == 1
|
|
case float64:
|
|
return int(v) == 1
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func intFromAny(value any, fallback int) int {
|
|
if value == nil {
|
|
return fallback
|
|
}
|
|
switch v := value.(type) {
|
|
case int:
|
|
return v
|
|
case int64:
|
|
return int(v)
|
|
case float64:
|
|
return int(v)
|
|
case *int:
|
|
if v == nil {
|
|
return fallback
|
|
}
|
|
return *v
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
func minPositive(a, b *int) *int {
|
|
if a == nil && b == nil {
|
|
return nil
|
|
}
|
|
if a == nil {
|
|
return b
|
|
}
|
|
if b == nil {
|
|
return a
|
|
}
|
|
if *a < *b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func keysOfMap(values map[string]bool) []string {
|
|
keys := make([]string, 0, len(values))
|
|
for key, enabled := range values {
|
|
if enabled {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func uniqueStrings(values []string) []string {
|
|
seen := map[string]bool{}
|
|
output := []string{}
|
|
for _, value := range values {
|
|
v := strings.TrimSpace(value)
|
|
if v == "" || seen[v] {
|
|
continue
|
|
}
|
|
seen[v] = true
|
|
output = append(output, v)
|
|
}
|
|
return output
|
|
}
|
|
|
|
func normalizeProvider(value string) string {
|
|
v := strings.ToLower(strings.TrimSpace(value))
|
|
switch v {
|
|
case "sqlite", "sqlite3", "file":
|
|
return "sqlite"
|
|
case "postgres", "postgresql", "pg":
|
|
return "postgres"
|
|
case "mysql", "mariadb":
|
|
return "mysql"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func maskDatabaseURL(provider, raw string) string {
|
|
if normalizeProvider(provider) == "sqlite" {
|
|
return raw
|
|
}
|
|
u, err := netUrlParse(raw)
|
|
if err != nil {
|
|
if len(raw) <= 8 {
|
|
return "***"
|
|
}
|
|
return raw[:4] + "***" + raw[len(raw)-2:]
|
|
}
|
|
if u.User != nil {
|
|
username := u.User.Username()
|
|
password, hasPassword := u.User.Password()
|
|
if username != "" {
|
|
if len(username) > 2 {
|
|
username = username[:2] + "***"
|
|
} else {
|
|
username = "***"
|
|
}
|
|
}
|
|
if hasPassword && password != "" {
|
|
u.User = url.UserPassword(username, "***")
|
|
} else {
|
|
u.User = url.User(username)
|
|
}
|
|
}
|
|
q := u.Query()
|
|
for _, key := range []string{"password", "pass", "pwd", "token", "secret"} {
|
|
if q.Has(key) {
|
|
q.Set(key, "***")
|
|
}
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
func extractDatabaseTarget(provider, raw string) string {
|
|
if normalizeProvider(provider) == "sqlite" {
|
|
return raw
|
|
}
|
|
u, err := netUrlParse(raw)
|
|
if err != nil {
|
|
return "-"
|
|
}
|
|
name := strings.TrimPrefix(u.Path, "/")
|
|
if name == "" {
|
|
name = "-"
|
|
}
|
|
return name
|
|
}
|
|
|
|
func pathAbs(value string) (string, error) {
|
|
if strings.HasPrefix(value, "file:") {
|
|
return strings.TrimPrefix(value, "file:"), nil
|
|
}
|
|
if filepath.IsAbs(value) {
|
|
return value, nil
|
|
}
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(wd, value), nil
|
|
}
|
|
|
|
func netUrlParse(value string) (*url.URL, error) {
|
|
return url.Parse(value)
|
|
}
|
|
|
|
func webPathForRequest(requestPath string, uiBasePath string) (string, bool) {
|
|
reqPath := path.Clean("/" + strings.TrimSpace(requestPath))
|
|
if reqPath == "." {
|
|
reqPath = "/"
|
|
}
|
|
|
|
base := normalizePathPrefix(uiBasePath, "/")
|
|
if base == "/" {
|
|
return strings.TrimPrefix(reqPath, "/"), true
|
|
}
|
|
|
|
if reqPath == base {
|
|
return "", true
|
|
}
|
|
|
|
prefix := base + "/"
|
|
if strings.HasPrefix(reqPath, prefix) {
|
|
return strings.TrimPrefix(reqPath, prefix), true
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func upstreamOr(a, b sql.NullString) sql.NullString {
|
|
if a.Valid {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
type serviceInput struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
UpstreamURL string `json:"upstreamUrl"`
|
|
RoutePrefix string `json:"routePrefix"`
|
|
HealthPath string `json:"healthPath"`
|
|
UpstreamAuthHeader string `json:"upstreamAuthHeader"`
|
|
UpstreamAuthValue string `json:"upstreamAuthValue"`
|
|
InternalToken string `json:"internalToken"`
|
|
Enabled *bool `json:"enabled"`
|
|
RPMLimit *int `json:"rpmLimit"`
|
|
MonthlyQuota *int `json:"monthlyQuota"`
|
|
RequestTimeoutMS *int `json:"requestTimeoutMs"`
|
|
}
|
|
|
|
func (s serviceInput) RPMLimitOr(value any) *int {
|
|
if s.RPMLimit != nil {
|
|
return s.RPMLimit
|
|
}
|
|
return nullIntValueFromAny(value)
|
|
}
|
|
|
|
func (s serviceInput) MonthlyQuotaOr(value any) *int {
|
|
if s.MonthlyQuota != nil {
|
|
return s.MonthlyQuota
|
|
}
|
|
return nullIntValueFromAny(value)
|
|
}
|
|
|
|
func (s serviceInput) TimeoutOr(value any) *int {
|
|
if s.RequestTimeoutMS != nil {
|
|
return s.RequestTimeoutMS
|
|
}
|
|
return nullIntValueFromAny(value)
|
|
}
|