mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-03 20:12:58 +00:00
overhaul
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"containr/internal/auth"
|
||||
"containr/internal/config"
|
||||
"containr/internal/rbac"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
Cfg config.Config
|
||||
}
|
||||
|
||||
func NowISO() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func generateUUID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
func Open(cfg config.Config) (*Store, error) {
|
||||
// Use PostgreSQL for Containr, fallback to SQLite for APwhy compatibility
|
||||
var db *sql.DB
|
||||
var err error
|
||||
|
||||
if cfg.DatabaseURL != "" {
|
||||
db, err = sql.Open("pgx", cfg.DatabaseURL)
|
||||
} else if cfg.SQLitePath != "" {
|
||||
db, err = sql.Open("sqlite", cfg.SQLitePath)
|
||||
} else {
|
||||
return nil, errors.New("no database configuration provided")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
|
||||
// Test connection
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{DB: db, Cfg: cfg}
|
||||
|
||||
// Only seed APwhy data if using SQLite or if PostgreSQL tables don't exist
|
||||
if cfg.SQLitePath != "" || !s.postgresTablesExist() {
|
||||
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")
|
||||
|
||||
// postgresTablesExist checks if APwhy tables exist in PostgreSQL
|
||||
func (s *Store) postgresTablesExist() bool {
|
||||
var count int
|
||||
err := s.DB.QueryRow(`
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'api_services'
|
||||
`).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
// APwhy Service operations
|
||||
func (s *Store) ListAPServices(ctx context.Context) ([]APService, error) {
|
||||
rows, err := s.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 api_services
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var services []APService
|
||||
for rows.Next() {
|
||||
var service APService
|
||||
err := rows.Scan(
|
||||
&service.ID, &service.Name, &service.Slug, &service.UpstreamURL,
|
||||
&service.RoutePrefix, &service.HealthPath, &service.UpstreamAuthHeader,
|
||||
&service.UpstreamAuthValue, &service.InternalToken, &service.Enabled,
|
||||
&service.RPMLimit, &service.MonthlyQuota, &service.RequestTimeoutMs,
|
||||
&service.LastValidationAt, &service.LastValidationStatus,
|
||||
&service.LastValidationMessage, &service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
services = append(services, service)
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAPService(ctx context.Context, service *APService) error {
|
||||
service.ID = generateUUID()
|
||||
service.CreatedAt = NowISO()
|
||||
service.UpdatedAt = NowISO()
|
||||
|
||||
_, err := s.DB.ExecContext(ctx, `
|
||||
INSERT INTO api_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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
`,
|
||||
service.ID, service.Name, service.Slug, service.UpstreamURL,
|
||||
service.RoutePrefix, service.HealthPath, service.UpstreamAuthHeader,
|
||||
service.UpstreamAuthValue, service.InternalToken, service.Enabled,
|
||||
service.RPMLimit, service.MonthlyQuota, service.RequestTimeoutMs,
|
||||
service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAPService(ctx context.Context, id string, updates map[string]interface{}) error {
|
||||
setParts := make([]string, 0)
|
||||
args := make([]interface{}, 0)
|
||||
argIndex := 1
|
||||
|
||||
for field, value := range updates {
|
||||
setParts = append(setParts, fmt.Sprintf("%s = $%d", field, argIndex))
|
||||
args = append(args, value)
|
||||
argIndex++
|
||||
}
|
||||
|
||||
args = append(args, NowISO(), id)
|
||||
|
||||
_, err := s.DB.ExecContext(ctx,
|
||||
fmt.Sprintf("UPDATE api_services SET %s, updated_at = $%d WHERE id = $%d",
|
||||
strings.Join(setParts, ", "), argIndex, argIndex+1),
|
||||
args...,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// APwhy API Key operations
|
||||
func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKey, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `
|
||||
SELECT id, name, key_hash, 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 {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []APIKey
|
||||
for rows.Next() {
|
||||
var key APIKey
|
||||
err := rows.Scan(
|
||||
&key.ID, &key.Name, &key.KeyHash, &key.KeyPrefix, &key.Plan,
|
||||
&key.AllowedServiceIDs, &key.Enabled, &key.RPMLimit, &key.MonthlyQuota,
|
||||
&key.CreatedAt, &key.UpdatedAt, &key.LastUsedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAPIKey(ctx context.Context, key *APIKey) error {
|
||||
key.ID = generateUUID()
|
||||
key.CreatedAt = NowISO()
|
||||
key.UpdatedAt = NowISO()
|
||||
|
||||
_, err := s.DB.ExecContext(ctx, `
|
||||
INSERT INTO api_keys (
|
||||
id, name, key_hash, key_prefix, plan, allowed_service_ids,
|
||||
enabled, rpm_limit, monthly_quota, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`,
|
||||
key.ID, key.Name, key.KeyHash, key.KeyPrefix, key.Plan,
|
||||
key.AllowedServiceIDs, key.Enabled, key.RPMLimit, key.MonthlyQuota,
|
||||
key.CreatedAt, key.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Types for APwhy
|
||||
type APService struct {
|
||||
ID string `json:"id"`
|
||||
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"`
|
||||
LastValidationAt *string `json:"lastValidationAt"`
|
||||
LastValidationStatus *string `json:"lastValidationStatus"`
|
||||
LastValidationMessage *string `json:"lastValidationMessage"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyHash string `json:"keyHash"`
|
||||
KeyPrefix string `json:"keyPrefix"`
|
||||
Plan string `json:"plan"`
|
||||
AllowedServiceIDs string `json:"allowedServiceIds"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RPMLimit *int `json:"rpmLimit"`
|
||||
MonthlyQuota *int `json:"monthlyQuota"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
LastUsedAt *string `json:"lastUsedAt"`
|
||||
}
|
||||
@@ -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