mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-03 20:12:58 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
WebsiteID string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, apiKey, websiteID string) *Client {
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
WebsiteID: websiteID,
|
||||
HTTP: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Enabled() bool {
|
||||
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
|
||||
}
|
||||
|
||||
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
|
||||
if !c.Enabled() {
|
||||
return map[string]any{
|
||||
"enabled": false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
|
||||
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
|
||||
u.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
res, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload["enabled"] = true
|
||||
return payload, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,321 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"containr/internal/apwhy/config"
|
||||
"containr/internal/apwhy/storage"
|
||||
)
|
||||
|
||||
type apiErrorPayload struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type apiResponsePayload struct {
|
||||
OK bool `json:"ok"`
|
||||
Data any `json:"data"`
|
||||
Error apiErrorPayload `json:"error"`
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *http.Client) {
|
||||
t.Helper()
|
||||
|
||||
cfg := config.Load()
|
||||
cfg.SQLitePath = ":memory:"
|
||||
cfg.CookieSecure = false
|
||||
|
||||
store, err := storage.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test storage: %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(store, cfg)
|
||||
ts := httptest.NewServer(server.Handler())
|
||||
|
||||
t.Cleanup(func() {
|
||||
ts.Close()
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cookie jar: %v", err)
|
||||
}
|
||||
|
||||
return ts, &http.Client{Jar: jar}
|
||||
}
|
||||
|
||||
func requestJSON(t *testing.T, client *http.Client, method string, url string, body string) (int, apiResponsePayload) {
|
||||
t.Helper()
|
||||
|
||||
var bodyReader *bytes.Reader
|
||||
if body == "" {
|
||||
bodyReader = bytes.NewReader(nil)
|
||||
} else {
|
||||
bodyReader = bytes.NewReader([]byte(body))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var payload apiResponsePayload
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
|
||||
return resp.StatusCode, payload
|
||||
}
|
||||
|
||||
func bootstrapAndLoginOwner(t *testing.T, client *http.Client, baseURL string) {
|
||||
t.Helper()
|
||||
|
||||
status, payload := requestJSON(
|
||||
t,
|
||||
client,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/bootstrap/register-owner",
|
||||
`{"email":"owner@example.com","password":"owner-pass-123"}`,
|
||||
)
|
||||
if status != http.StatusCreated || !payload.OK {
|
||||
t.Fatalf("owner bootstrap failed: status=%d payload=%+v", status, payload)
|
||||
}
|
||||
|
||||
status, payload = requestJSON(
|
||||
t,
|
||||
client,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/auth/login",
|
||||
`{"email":"owner@example.com","password":"owner-pass-123"}`,
|
||||
)
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("owner login failed: status=%d payload=%+v", status, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerBootstrapLoginAndProtectedRoutes(t *testing.T) {
|
||||
ts, authClient := newTestServer(t)
|
||||
baseURL := ts.URL
|
||||
|
||||
// Bootstrap should be open before any user exists.
|
||||
status, payload := requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("unexpected bootstrap status response: status=%d payload=%+v", status, payload)
|
||||
}
|
||||
bootstrapData, ok := payload.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
|
||||
}
|
||||
if hasUsers, _ := bootstrapData["hasUsers"].(bool); hasUsers {
|
||||
t.Fatalf("expected hasUsers=false before owner bootstrap")
|
||||
}
|
||||
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); !registrationOpen {
|
||||
t.Fatalf("expected registrationOpen=true before owner bootstrap")
|
||||
}
|
||||
|
||||
// Protected route without auth should fail.
|
||||
anonClient := &http.Client{}
|
||||
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
||||
if status != http.StatusUnauthorized || payload.OK {
|
||||
t.Fatalf("expected unauthorized /auth/me response, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
|
||||
// Register owner and login.
|
||||
bootstrapAndLoginOwner(t, authClient, baseURL)
|
||||
|
||||
// Bootstrap should close once owner exists.
|
||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("unexpected bootstrap status after owner creation: status=%d payload=%+v", status, payload)
|
||||
}
|
||||
bootstrapData, ok = payload.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
|
||||
}
|
||||
if hasUsers, _ := bootstrapData["hasUsers"].(bool); !hasUsers {
|
||||
t.Fatalf("expected hasUsers=true after owner bootstrap")
|
||||
}
|
||||
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); registrationOpen {
|
||||
t.Fatalf("expected registrationOpen=false after owner bootstrap")
|
||||
}
|
||||
|
||||
// Owner session cookie should authorize /auth/me.
|
||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("expected successful /auth/me response, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
meData, ok := payload.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload for auth/me, got %#v", payload.Data)
|
||||
}
|
||||
if email, _ := meData["email"].(string); email != "owner@example.com" {
|
||||
t.Fatalf("unexpected /auth/me email: %q", email)
|
||||
}
|
||||
|
||||
// Logout should revoke auth session.
|
||||
status, payload = requestJSON(t, authClient, http.MethodPost, baseURL+"/api/v1/auth/logout", "")
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("expected successful logout response, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
||||
if status != http.StatusUnauthorized || payload.OK {
|
||||
t.Fatalf("expected unauthorized /auth/me after logout, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerServicesCreateValidationRules(t *testing.T) {
|
||||
ts, client := newTestServer(t)
|
||||
baseURL := ts.URL
|
||||
|
||||
bootstrapAndLoginOwner(t, client, baseURL)
|
||||
|
||||
// Route prefix under /api must be rejected.
|
||||
status, payload := requestJSON(
|
||||
t,
|
||||
client,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/services",
|
||||
`{"name":"Bad Service","upstreamUrl":"http://example.com","routePrefix":"/api/conflict"}`,
|
||||
)
|
||||
if status != http.StatusBadRequest || payload.OK {
|
||||
t.Fatalf("expected route conflict validation failure, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
if payload.Error.Code != "ROUTE_CONFLICT" {
|
||||
t.Fatalf("expected ROUTE_CONFLICT error code, got %q", payload.Error.Code)
|
||||
}
|
||||
|
||||
// Valid service should be created successfully.
|
||||
status, payload = requestJSON(
|
||||
t,
|
||||
client,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/services",
|
||||
`{"name":"Web Frontend","upstreamUrl":"http://example.com","routePrefix":"/web"}`,
|
||||
)
|
||||
if status != http.StatusCreated || !payload.OK {
|
||||
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
createData, ok := payload.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload for service creation, got %#v", payload.Data)
|
||||
}
|
||||
if _, ok := createData["id"].(string); !ok {
|
||||
t.Fatalf("expected created service id in response")
|
||||
}
|
||||
if slug, _ := createData["slug"].(string); !strings.Contains(slug, "web-frontend") {
|
||||
t.Fatalf("expected normalized service slug, got %q", slug)
|
||||
}
|
||||
|
||||
// List should contain the newly created service.
|
||||
status, payload = requestJSON(t, client, http.MethodGet, baseURL+"/api/v1/services", "")
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("expected successful services list response, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
rows, ok := payload.Data.([]any)
|
||||
if !ok || len(rows) == 0 {
|
||||
t.Fatalf("expected non-empty services list, got %#v", payload.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerProxyWithAPIKey(t *testing.T) {
|
||||
ts, authClient := newTestServer(t)
|
||||
baseURL := ts.URL
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-api-key") != "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "unexpected-api-key-header")
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "ok:"+r.URL.Path+"?"+r.URL.RawQuery)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
bootstrapAndLoginOwner(t, authClient, baseURL)
|
||||
|
||||
// Create service exposed at /web/* and proxied to the upstream test server.
|
||||
status, payload := requestJSON(
|
||||
t,
|
||||
authClient,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/services",
|
||||
`{"name":"Web Proxy","upstreamUrl":"`+upstream.URL+`","routePrefix":"/web"}`,
|
||||
)
|
||||
if status != http.StatusCreated || !payload.OK {
|
||||
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
|
||||
// Mint API key for gateway access.
|
||||
status, payload = requestJSON(
|
||||
t,
|
||||
authClient,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/keys",
|
||||
`{"name":"integration-key","plan":"free"}`,
|
||||
)
|
||||
if status != http.StatusCreated || !payload.OK {
|
||||
t.Fatalf("expected successful key creation, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
keyData, ok := payload.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload for key creation, got %#v", payload.Data)
|
||||
}
|
||||
rawKey, _ := keyData["key"].(string)
|
||||
if strings.TrimSpace(rawKey) == "" {
|
||||
t.Fatalf("expected non-empty raw API key")
|
||||
}
|
||||
|
||||
// No API key should be rejected.
|
||||
anonClient := &http.Client{}
|
||||
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/web/ping?x=1", "")
|
||||
if status != http.StatusUnauthorized || payload.OK {
|
||||
t.Fatalf("expected unauthorized proxy request without key, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
if payload.Error.Code != "API_KEY_MISSING" {
|
||||
t.Fatalf("expected API_KEY_MISSING error code, got %q", payload.Error.Code)
|
||||
}
|
||||
|
||||
// Valid API key should proxy request and strip the key before forwarding.
|
||||
req, err := http.NewRequest(http.MethodGet, baseURL+"/web/ping?x=1", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create proxy request: %v", err)
|
||||
}
|
||||
req.Header.Set("x-api-key", rawKey)
|
||||
|
||||
resp, err := anonClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("proxy request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read proxy response body: %v", err)
|
||||
}
|
||||
body := string(bodyBytes)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected proxy status 200, got %d body=%q", resp.StatusCode, body)
|
||||
}
|
||||
if body != "ok:/ping?x=1" {
|
||||
t.Fatalf("unexpected proxy body: %q", body)
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>APwhy</title>
|
||||
<script type="module" crossorigin src="/assets/index-DwfYiTMH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DRUelTBf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,119 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
func RandomToken(bytesLen int) (string, error) {
|
||||
buf := make([]byte, bytesLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func RandomPassword(length int) (string, error) {
|
||||
token, err := RandomToken(length)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(token) > length {
|
||||
return token[:length], nil
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func RandomID(prefix string) (string, error) {
|
||||
buf := make([]byte, 10)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(buf)), nil
|
||||
}
|
||||
|
||||
func HashToken(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < 8 {
|
||||
return "", errors.New("password must be at least 8 characters")
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
iterations := uint32(3)
|
||||
memory := uint32(64 * 1024)
|
||||
parallelism := uint8(2)
|
||||
keyLen := uint32(32)
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, keyLen)
|
||||
encoded := fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
memory,
|
||||
iterations,
|
||||
parallelism,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
)
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func VerifyPassword(encodedHash, password string) (bool, error) {
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 {
|
||||
return false, errors.New("invalid hash format")
|
||||
}
|
||||
if parts[1] != "argon2id" {
|
||||
return false, errors.New("unsupported hash type")
|
||||
}
|
||||
|
||||
var memory uint32
|
||||
var iterations uint32
|
||||
var parallelism uint8
|
||||
|
||||
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
decodedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
calculated := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(decodedHash)))
|
||||
if len(calculated) != len(decodedHash) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var diff byte
|
||||
for i := range calculated {
|
||||
diff |= calculated[i] ^ decodedHash[i]
|
||||
}
|
||||
return diff == 0, nil
|
||||
}
|
||||
|
||||
func ParseIntOrDefault(value string, fallback int) int {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
SQLitePath string
|
||||
DashboardUIBasePath string
|
||||
APIKeyHeader string
|
||||
ServiceTokenHeader string
|
||||
AllowRootRoutePrefix bool
|
||||
DefaultServiceTimeout time.Duration
|
||||
CookieSecure bool
|
||||
CookieDomain string
|
||||
AccessTokenTTL time.Duration
|
||||
RefreshTokenTTL time.Duration
|
||||
SessionAccessCookie string
|
||||
SessionRefreshCookie string
|
||||
FreeRPM int
|
||||
ProRPM int
|
||||
BusinessRPM int
|
||||
FreeMonthlyQuota int
|
||||
ProMonthlyQuota int
|
||||
BusinessMonthlyQuota int
|
||||
UmamiBaseURL string
|
||||
UmamiAPIKey string
|
||||
UmamiWebsiteID string
|
||||
TrustedProxyCIDR string
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getenvInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func getenvBool(key string, fallback bool) bool {
|
||||
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value == "1" || value == "true" || value == "yes"
|
||||
}
|
||||
|
||||
func normalizePathPrefix(value, fallback string) string {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
if !strings.HasPrefix(v, "/") {
|
||||
v = "/" + v
|
||||
}
|
||||
if len(v) > 1 && strings.HasSuffix(v, "/") {
|
||||
v = strings.TrimSuffix(v, "/")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func normalizeSQLitePath(value string) string {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
v = "./data/apwhy.sqlite"
|
||||
}
|
||||
if v == ":memory:" {
|
||||
return v
|
||||
}
|
||||
if strings.HasPrefix(v, "file:") {
|
||||
return v
|
||||
}
|
||||
if filepath.IsAbs(v) {
|
||||
return "file:" + v
|
||||
}
|
||||
abs, err := filepath.Abs(v)
|
||||
if err != nil {
|
||||
return "file:" + v
|
||||
}
|
||||
return "file:" + abs
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
Port: getenvInt("APWHY_PORT", getenvInt("PORT", 3001)),
|
||||
SQLitePath: normalizeSQLitePath(getenv("SQLITE_DB_PATH", getenv("DATABASE_URL", "./data/apwhy.sqlite"))),
|
||||
DashboardUIBasePath: normalizePathPrefix(getenv("DASHBOARD_UI_BASE_PATH", "/"), "/"),
|
||||
APIKeyHeader: strings.ToLower(getenv("API_KEY_HEADER", "x-api-key")),
|
||||
ServiceTokenHeader: strings.ToLower(getenv("SERVICE_TOKEN_HEADER", "x-apwhy-service-token")),
|
||||
AllowRootRoutePrefix: getenvBool("ALLOW_ROOT_ROUTE_PREFIX", false),
|
||||
DefaultServiceTimeout: time.Duration(
|
||||
getenvInt("DEFAULT_SERVICE_TIMEOUT_MS", 8000),
|
||||
) * time.Millisecond,
|
||||
CookieSecure: getenvBool("COOKIE_SECURE", false),
|
||||
CookieDomain: getenv("COOKIE_DOMAIN", ""),
|
||||
AccessTokenTTL: time.Duration(getenvInt("ACCESS_TOKEN_TTL_MINUTES", 15)) * time.Minute,
|
||||
RefreshTokenTTL: time.Duration(getenvInt("REFRESH_TOKEN_TTL_HOURS", 168)) * time.Hour,
|
||||
SessionAccessCookie: getenv("SESSION_ACCESS_COOKIE", "apwhy_access"),
|
||||
SessionRefreshCookie: getenv("SESSION_REFRESH_COOKIE", "apwhy_refresh"),
|
||||
FreeRPM: getenvInt("FREE_RPM", 60),
|
||||
ProRPM: getenvInt("PRO_RPM", 600),
|
||||
BusinessRPM: getenvInt("BUSINESS_RPM", 3000),
|
||||
FreeMonthlyQuota: getenvInt("FREE_MONTHLY_QUOTA", 1000),
|
||||
ProMonthlyQuota: getenvInt("PRO_MONTHLY_QUOTA", 50000),
|
||||
BusinessMonthlyQuota: getenvInt("BUSINESS_MONTHLY_QUOTA", 300000),
|
||||
UmamiBaseURL: strings.TrimRight(getenv("UMAMI_BASE_URL", ""), "/"),
|
||||
UmamiAPIKey: getenv("UMAMI_API_KEY", ""),
|
||||
UmamiWebsiteID: getenv("UMAMI_WEBSITE_ID", ""),
|
||||
TrustedProxyCIDR: getenv("TRUSTED_PROXY_CIDR", ""),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func BuildTargetURL(upstreamURL, routePrefix, requestPath, rawQuery string) (string, error) {
|
||||
base, err := url.Parse(upstreamURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
trimmedPrefix := routePrefix
|
||||
if trimmedPrefix == "/" {
|
||||
trimmedPrefix = ""
|
||||
}
|
||||
withoutPrefix := requestPath
|
||||
if trimmedPrefix != "" && strings.HasPrefix(requestPath, trimmedPrefix) {
|
||||
withoutPrefix = strings.TrimPrefix(requestPath, trimmedPrefix)
|
||||
}
|
||||
if !strings.HasPrefix(withoutPrefix, "/") {
|
||||
withoutPrefix = "/" + withoutPrefix
|
||||
}
|
||||
base.Path = strings.TrimRight(base.Path, "/") + withoutPrefix
|
||||
base.RawQuery = rawQuery
|
||||
return base.String(), nil
|
||||
}
|
||||
|
||||
func ProxyRequest(client *http.Client, w http.ResponseWriter, r *http.Request, targetURL string, headers map[string]string) (int, []byte, error) {
|
||||
var body []byte
|
||||
if r.Body != nil {
|
||||
defer r.Body.Close()
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
body = payload
|
||||
}
|
||||
|
||||
upstreamReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
for key, values := range r.Header {
|
||||
lower := strings.ToLower(key)
|
||||
if lower == "host" || lower == "content-length" || lower == "connection" {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
upstreamReq.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
upstreamReq.Header.Set(key, value)
|
||||
}
|
||||
|
||||
res, err := client.Do(upstreamReq)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
for key, values := range res.Header {
|
||||
if strings.EqualFold(key, "transfer-encoding") || strings.EqualFold(key, "connection") {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(res.StatusCode)
|
||||
|
||||
respBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return res.StatusCode, nil, err
|
||||
}
|
||||
_, _ = w.Write(respBody)
|
||||
|
||||
return res.StatusCode, respBody, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package rbac
|
||||
|
||||
type PermissionSeed struct {
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
var PermissionSeeds = []PermissionSeed{
|
||||
{Code: "services.read", Name: "Read Services", Description: "View service routing and status."},
|
||||
{Code: "services.write", Name: "Manage Services", Description: "Create and modify protected services."},
|
||||
{Code: "databases.read", Name: "Read Databases", Description: "View database connectors."},
|
||||
{Code: "databases.write", Name: "Manage Databases", Description: "Create and modify database connectors."},
|
||||
{Code: "keys.read", Name: "Read API Keys", Description: "View API keys and plans."},
|
||||
{Code: "keys.write", Name: "Manage API Keys", Description: "Create and modify API keys."},
|
||||
{Code: "users.read", Name: "Read Users", Description: "View users and login status."},
|
||||
{Code: "users.write", Name: "Manage Users", Description: "Create and modify user accounts."},
|
||||
{Code: "roles.read", Name: "Read Roles", Description: "View roles and permissions."},
|
||||
{Code: "roles.write", Name: "Manage Roles", Description: "Create and modify roles and permissions."},
|
||||
{Code: "analytics.read", Name: "Read Analytics", Description: "View ops and traffic analytics."},
|
||||
{Code: "settings.write", Name: "Manage Settings", Description: "Modify system settings and integrations."},
|
||||
}
|
||||
|
||||
var OwnerPermissionCodes = []string{
|
||||
"services.read", "services.write",
|
||||
"databases.read", "databases.write",
|
||||
"keys.read", "keys.write",
|
||||
"users.read", "users.write",
|
||||
"roles.read", "roles.write",
|
||||
"analytics.read", "settings.write",
|
||||
}
|
||||
|
||||
var AdminPermissionCodes = []string{
|
||||
"services.read", "services.write",
|
||||
"databases.read", "databases.write",
|
||||
"keys.read", "keys.write",
|
||||
"users.read", "users.write",
|
||||
"roles.read",
|
||||
"analytics.read",
|
||||
}
|
||||
|
||||
var ViewerPermissionCodes = []string{
|
||||
"services.read",
|
||||
"databases.read",
|
||||
"keys.read",
|
||||
"users.read",
|
||||
"roles.read",
|
||||
"analytics.read",
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"containr/internal/apwhy/auth"
|
||||
"containr/internal/apwhy/config"
|
||||
"containr/internal/apwhy/rbac"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
Cfg config.Config
|
||||
}
|
||||
|
||||
func NowISO() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func Open(cfg config.Config) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", cfg.SQLitePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if _, err := db.Exec(schemaSQL); err != nil {
|
||||
return nil, fmt.Errorf("failed to apply schema: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{DB: db, Cfg: cfg}
|
||||
if err := s.seedAccessControl(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.DB.Close()
|
||||
}
|
||||
|
||||
func (s *Store) seedAccessControl(ctx context.Context) error {
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := NowISO()
|
||||
|
||||
for _, permission := range rbac.PermissionSeeds {
|
||||
id, _ := auth.RandomID("perm")
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO permissions (id, code, name, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(code) DO UPDATE SET name=excluded.name, description=excluded.description
|
||||
`, id, permission.Code, permission.Name, permission.Description, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
type roleSeed struct {
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
System bool
|
||||
PermCodes []string
|
||||
}
|
||||
|
||||
roles := []roleSeed{
|
||||
{Name: "Owner", Slug: "owner", Description: "Primary administrator with all permissions.", System: true, PermCodes: rbac.OwnerPermissionCodes},
|
||||
{Name: "Admin", Slug: "admin", Description: "Operational admin with management permissions.", System: true, PermCodes: rbac.AdminPermissionCodes},
|
||||
{Name: "Viewer", Slug: "viewer", Description: "Read-only dashboard access.", System: true, PermCodes: rbac.ViewerPermissionCodes},
|
||||
}
|
||||
|
||||
for _, role := range roles {
|
||||
roleID := ""
|
||||
_ = tx.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = ?`, role.Slug).Scan(&roleID)
|
||||
if roleID == "" {
|
||||
roleID, _ = auth.RandomID("role")
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO roles (id, name, slug, description, is_system, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
|
||||
`, roleID, role.Name, role.Slug, role.Description, boolToInt(role.System), now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE roles SET name = ?, description = ?, is_system = ?, updated_at = ? WHERE id = ?
|
||||
`, role.Name, role.Description, boolToInt(role.System), now, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if role.System {
|
||||
_, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = ?`, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, code := range role.PermCodes {
|
||||
permID := ""
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM permissions WHERE code = ?`, code).Scan(&permID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO role_permissions (role_id, permission_id, created_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(role_id, permission_id) DO NOTHING
|
||||
`, roleID, permID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func monthPeriod(t time.Time) string {
|
||||
return t.UTC().Format("2006-01")
|
||||
}
|
||||
|
||||
func slugify(value string, 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 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 normalizeHealthPath(value string) string {
|
||||
return normalizePathPrefix(value, "/health")
|
||||
}
|
||||
|
||||
func parseAllowedServiceIDs(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return []string{}
|
||||
}
|
||||
var result []string
|
||||
_ = json.Unmarshal([]byte(value), &result)
|
||||
if result == nil {
|
||||
return []string{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
bytes, _ := json.Marshal(v)
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
func minLimit(a, b sql.NullInt64) sql.NullInt64 {
|
||||
if !a.Valid && !b.Valid {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
if !a.Valid {
|
||||
return b
|
||||
}
|
||||
if !b.Valid {
|
||||
return a
|
||||
}
|
||||
if a.Int64 < b.Int64 {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func toNullInt(value *int) sql.NullInt64 {
|
||||
if value == nil || *value <= 0 {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Valid: true, Int64: int64(*value)}
|
||||
}
|
||||
|
||||
func scanJSONText(value sql.NullString) string {
|
||||
if !value.Valid {
|
||||
return "[]"
|
||||
}
|
||||
if strings.TrimSpace(value.String) == "" {
|
||||
return "[]"
|
||||
}
|
||||
return value.String
|
||||
}
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
@@ -0,0 +1,194 @@
|
||||
package storage
|
||||
|
||||
const schemaSQL = `
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
force_password_reset INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
access_token_hash TEXT NOT NULL,
|
||||
refresh_token_hash TEXT NOT NULL,
|
||||
access_expires_at TEXT NOT NULL,
|
||||
refresh_expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_access ON sessions(access_token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_refresh ON sessions(refresh_token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id TEXT NOT NULL,
|
||||
permission_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(role_id, permission_id),
|
||||
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id TEXT NOT NULL,
|
||||
role_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, role_id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
upstream_url TEXT NOT NULL,
|
||||
route_prefix TEXT NOT NULL UNIQUE,
|
||||
health_path TEXT NOT NULL DEFAULT '/health',
|
||||
upstream_auth_header TEXT,
|
||||
upstream_auth_value TEXT,
|
||||
internal_token TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
rpm_limit INTEGER,
|
||||
monthly_quota INTEGER,
|
||||
request_timeout_ms INTEGER,
|
||||
last_validation_at TEXT,
|
||||
last_validation_status TEXT,
|
||||
last_validation_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS database_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
provider TEXT NOT NULL,
|
||||
connection_url TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_validation_at TEXT,
|
||||
last_validation_status TEXT,
|
||||
last_validation_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
key_prefix TEXT NOT NULL,
|
||||
plan TEXT NOT NULL,
|
||||
allowed_service_ids TEXT NOT NULL DEFAULT '[]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
rpm_limit INTEGER,
|
||||
monthly_quota INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_counters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
api_key_id TEXT NOT NULL,
|
||||
service_id TEXT NOT NULL,
|
||||
period_month TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(api_key_id, service_id, period_month),
|
||||
FOREIGN KEY(api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS incident_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
service_id TEXT,
|
||||
api_key_id TEXT,
|
||||
code TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'medium',
|
||||
http_status INTEGER,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
occurred_at TEXT NOT NULL,
|
||||
FOREIGN KEY(api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metrics_timeseries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
metric TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
labels_json TEXT,
|
||||
occurred_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_metric_time ON metrics_timeseries(metric, occurred_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS umami_sync_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
payload_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_user_id TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
payload_json TEXT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
FOREIGN KEY(actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
`
|
||||
Reference in New Issue
Block a user