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,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
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.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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"
|
||||
|
||||
"apwhy/internal/auth"
|
||||
"apwhy/internal/config"
|
||||
"apwhy/internal/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