mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 16:54:15 +00:00
more appview cleanup and test coverage
This commit is contained in:
@@ -1,102 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
)
|
||||
|
||||
func TestAuthorizerBlocksSensitiveTables(t *testing.T) {
|
||||
// Create temporary database
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
// Set environment for database path
|
||||
os.Setenv("ATCR_UI_DATABASE_PATH", dbPath)
|
||||
defer os.Unsetenv("ATCR_UI_DATABASE_PATH")
|
||||
|
||||
// Initialize database (creates schema)
|
||||
database, err := db.InitDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// Create some test data in sensitive tables
|
||||
_, err = database.Exec(`
|
||||
INSERT INTO oauth_sessions (session_key, account_did, session_id, session_data, created_at, updated_at)
|
||||
VALUES ('test-key', 'did:plc:test', 'test-session', 'secret-token-data', datetime('now'), datetime('now'))
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert test data: %v", err)
|
||||
}
|
||||
|
||||
_, err = database.Exec(`
|
||||
INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
|
||||
VALUES ('did:plc:test', 'test.user', 'https://pds.example.com', '', datetime('now'))
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert test user: %v", err)
|
||||
}
|
||||
|
||||
// Open read-only connection with authorizer (using our custom driver)
|
||||
readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open read-only database: %v", err)
|
||||
}
|
||||
defer readOnlyDB.Close()
|
||||
|
||||
// Test 1: Should be able to read from public tables (users)
|
||||
t.Run("AllowPublicTableRead", func(t *testing.T) {
|
||||
var handle string
|
||||
err := readOnlyDB.QueryRow("SELECT handle FROM users WHERE did = ?", "did:plc:test").Scan(&handle)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to read from public table 'users': %v", err)
|
||||
}
|
||||
if handle != "test.user" {
|
||||
t.Errorf("Expected handle 'test.user', got '%s'", handle)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: Should NOT be able to read from sensitive tables (oauth_sessions)
|
||||
t.Run("BlockSensitiveTableRead", func(t *testing.T) {
|
||||
var sessionData string
|
||||
err := readOnlyDB.QueryRow("SELECT session_data FROM oauth_sessions WHERE session_key = ?", "test-key").Scan(&sessionData)
|
||||
if err == nil {
|
||||
t.Errorf("Should NOT be able to read from sensitive table 'oauth_sessions', but got data: %s", sessionData)
|
||||
}
|
||||
// SQLite returns "not authorized" error when authorizer denies access
|
||||
if err != nil && err.Error() != "not authorized" {
|
||||
t.Logf("Got expected error (but different message): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Should NOT be able to read from ui_sessions
|
||||
t.Run("BlockUISessionsTableRead", func(t *testing.T) {
|
||||
rows, err := readOnlyDB.Query("SELECT * FROM ui_sessions LIMIT 1")
|
||||
if err == nil {
|
||||
rows.Close()
|
||||
t.Error("Should NOT be able to read from sensitive table 'ui_sessions'")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 4: Should NOT be able to read from devices
|
||||
t.Run("BlockDevicesTableRead", func(t *testing.T) {
|
||||
rows, err := readOnlyDB.Query("SELECT * FROM devices LIMIT 1")
|
||||
if err == nil {
|
||||
rows.Close()
|
||||
t.Error("Should NOT be able to read from sensitive table 'devices'")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 5: Should NOT be able to write to any table (read-only mode + authorizer)
|
||||
t.Run("BlockAllWrites", func(t *testing.T) {
|
||||
_, err := readOnlyDB.Exec("INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen) VALUES ('did:plc:test2', 'test2', 'https://pds.example.com', '', datetime('now'))")
|
||||
if err == nil {
|
||||
t.Error("Should NOT be able to write to any table in read-only mode")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/distribution/distribution/v3/configuration"
|
||||
)
|
||||
|
||||
// loadConfigFromEnv builds a complete configuration from environment variables
|
||||
// This follows the same pattern as the hold service (no config files, only env vars)
|
||||
func loadConfigFromEnv() (*configuration.Configuration, error) {
|
||||
config := &configuration.Configuration{}
|
||||
|
||||
// Version
|
||||
config.Version = configuration.MajorMinorVersion(0, 1)
|
||||
|
||||
// Logging
|
||||
config.Log = buildLogConfig()
|
||||
|
||||
// HTTP server
|
||||
httpConfig, err := buildHTTPConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build HTTP config: %w", err)
|
||||
}
|
||||
config.HTTP = httpConfig
|
||||
|
||||
// Storage (fake in-memory placeholder - all real storage is proxied)
|
||||
config.Storage = buildStorageConfig()
|
||||
|
||||
// Middleware (ATProto resolver)
|
||||
defaultHoldDID := os.Getenv("ATCR_DEFAULT_HOLD_DID")
|
||||
if defaultHoldDID == "" {
|
||||
return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required")
|
||||
}
|
||||
config.Middleware = buildMiddlewareConfig(defaultHoldDID)
|
||||
|
||||
// Auth
|
||||
baseURL := getBaseURL(httpConfig.Addr)
|
||||
authConfig, err := buildAuthConfig(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build auth config: %w", err)
|
||||
}
|
||||
config.Auth = authConfig
|
||||
|
||||
// Health checks
|
||||
config.Health = buildHealthConfig()
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// buildLogConfig creates logging configuration from environment variables
|
||||
func buildLogConfig() configuration.Log {
|
||||
level := getEnvOrDefault("ATCR_LOG_LEVEL", "info")
|
||||
formatter := getEnvOrDefault("ATCR_LOG_FORMATTER", "text")
|
||||
|
||||
return configuration.Log{
|
||||
Level: configuration.Loglevel(level),
|
||||
Formatter: formatter,
|
||||
Fields: map[string]any{
|
||||
"service": "atcr-appview",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildHTTPConfig creates HTTP server configuration from environment variables
|
||||
func buildHTTPConfig() (configuration.HTTP, error) {
|
||||
addr := getEnvOrDefault("ATCR_HTTP_ADDR", ":5000")
|
||||
debugAddr := getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001")
|
||||
|
||||
// HTTP secret - only needed for multipart uploads in distribution's storage driver
|
||||
// Since AppView is stateless and routes all storage through middleware, this isn't
|
||||
// actually used, but we generate a random secret for defense in depth
|
||||
httpSecret := os.Getenv("REGISTRY_HTTP_SECRET")
|
||||
if httpSecret == "" {
|
||||
// Generate a random 32-byte secret
|
||||
randomBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return configuration.HTTP{}, fmt.Errorf("failed to generate random secret: %w", err)
|
||||
}
|
||||
httpSecret = hex.EncodeToString(randomBytes)
|
||||
}
|
||||
|
||||
return configuration.HTTP{
|
||||
Addr: addr,
|
||||
Secret: httpSecret,
|
||||
Headers: map[string][]string{
|
||||
"X-Content-Type-Options": {"nosniff"},
|
||||
},
|
||||
Debug: configuration.Debug{
|
||||
Addr: debugAddr,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildStorageConfig creates a fake in-memory storage config
|
||||
// This is required for distribution validation but is never actually used
|
||||
// All storage is routed through middleware to ATProto (manifests) and hold services (blobs)
|
||||
func buildStorageConfig() configuration.Storage {
|
||||
storage := configuration.Storage{}
|
||||
|
||||
// Use in-memory storage as a placeholder
|
||||
storage["inmemory"] = configuration.Parameters{}
|
||||
|
||||
// Disable upload purging
|
||||
// NOTE: Must use map[any]any for uploadpurging (not configuration.Parameters)
|
||||
// because distribution's validation code does a type assertion to map[any]any
|
||||
storage["maintenance"] = configuration.Parameters{
|
||||
"uploadpurging": map[any]any{
|
||||
"enabled": false,
|
||||
"age": 7 * 24 * time.Hour, // 168h
|
||||
"interval": 24 * time.Hour, // 24h
|
||||
"dryrun": false,
|
||||
},
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
// buildMiddlewareConfig creates middleware configuration
|
||||
func buildMiddlewareConfig(defaultHoldDID string) map[string][]configuration.Middleware {
|
||||
// Check test mode
|
||||
testMode := os.Getenv("TEST_MODE") == "true"
|
||||
|
||||
return map[string][]configuration.Middleware{
|
||||
"registry": {
|
||||
{
|
||||
Name: "atproto-resolver",
|
||||
Options: configuration.Parameters{
|
||||
"default_hold_did": defaultHoldDID,
|
||||
"test_mode": testMode,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildAuthConfig creates authentication configuration from environment variables
|
||||
func buildAuthConfig(baseURL string) (configuration.Auth, error) {
|
||||
// Token configuration
|
||||
privateKeyPath := getEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem")
|
||||
certPath := getEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt")
|
||||
|
||||
// Token expiration in seconds (default: 5 minutes)
|
||||
expirationStr := getEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300")
|
||||
expiration, err := strconv.Atoi(expirationStr)
|
||||
if err != nil {
|
||||
return configuration.Auth{}, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err)
|
||||
}
|
||||
|
||||
// Auto-derive service name from base URL or use env var
|
||||
serviceName := getServiceName(baseURL)
|
||||
|
||||
// Auto-derive realm from base URL
|
||||
realm := baseURL + "/auth/token"
|
||||
|
||||
return configuration.Auth{
|
||||
"token": configuration.Parameters{
|
||||
"realm": realm,
|
||||
"service": serviceName,
|
||||
"issuer": serviceName,
|
||||
"rootcertbundle": certPath,
|
||||
"privatekey": privateKeyPath,
|
||||
"expiration": expiration,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildHealthConfig creates health check configuration
|
||||
func buildHealthConfig() configuration.Health {
|
||||
return configuration.Health{
|
||||
StorageDriver: configuration.StorageDriver{
|
||||
Enabled: true,
|
||||
Interval: 10 * time.Second,
|
||||
Threshold: 3,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// getBaseURL determines the base URL for the service
|
||||
// Priority: ATCR_BASE_URL env var, then derived from HTTP addr
|
||||
func getBaseURL(httpAddr string) string {
|
||||
baseURL := os.Getenv("ATCR_BASE_URL")
|
||||
if baseURL != "" {
|
||||
return baseURL
|
||||
}
|
||||
|
||||
// Auto-detect from HTTP addr
|
||||
if httpAddr[0] == ':' {
|
||||
// Just a port, assume localhost
|
||||
return fmt.Sprintf("http://127.0.0.1%s", httpAddr)
|
||||
}
|
||||
|
||||
// Full address provided
|
||||
return fmt.Sprintf("http://%s", httpAddr)
|
||||
}
|
||||
|
||||
// getServiceName extracts service name from base URL or uses env var
|
||||
func getServiceName(baseURL string) string {
|
||||
// Check env var first
|
||||
if serviceName := os.Getenv("ATCR_SERVICE_NAME"); serviceName != "" {
|
||||
return serviceName
|
||||
}
|
||||
|
||||
// Try to extract from base URL
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err == nil && parsed.Hostname() != "" {
|
||||
hostname := parsed.Hostname()
|
||||
|
||||
// Strip localhost/127.0.0.1 and use default
|
||||
if hostname == "localhost" || hostname == "127.0.0.1" {
|
||||
return "atcr.io"
|
||||
}
|
||||
|
||||
return hostname
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return "atcr.io"
|
||||
}
|
||||
|
||||
// getEnvOrDefault gets an environment variable or returns a default value
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
+16
-164
@@ -9,14 +9,12 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/distribution/distribution/v3/configuration"
|
||||
"github.com/distribution/distribution/v3/registry"
|
||||
"github.com/distribution/distribution/v3/registry/handlers"
|
||||
sqlite3 "github.com/mattn/go-sqlite3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
@@ -32,34 +30,6 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Define sensitive tables that should never be accessible from public queries
|
||||
var sensitiveTables = map[string]bool{
|
||||
"oauth_sessions": true, // OAuth tokens
|
||||
"ui_sessions": true, // Session IDs
|
||||
"oauth_auth_requests": true, // OAuth state
|
||||
"devices": true, // Device secret hashes
|
||||
"pending_device_auth": true, // Pending device secrets
|
||||
}
|
||||
|
||||
// readOnlyAuthorizerCallback blocks access to sensitive tables
|
||||
func readOnlyAuthorizerCallback(action int, arg1, arg2, dbName string) int {
|
||||
// arg1 contains the table name for most operations
|
||||
tableName := arg1
|
||||
|
||||
// Block any access to sensitive tables
|
||||
if action == sqlite3.SQLITE_READ || action == sqlite3.SQLITE_UPDATE ||
|
||||
action == sqlite3.SQLITE_INSERT || action == sqlite3.SQLITE_DELETE ||
|
||||
action == sqlite3.SQLITE_SELECT {
|
||||
if sensitiveTables[tableName] {
|
||||
fmt.Printf("SECURITY: Blocked access to sensitive table '%s' (action=%d)\n", tableName, action)
|
||||
return sqlite3.SQLITE_DENY
|
||||
}
|
||||
}
|
||||
|
||||
// Allow everything else
|
||||
return sqlite3.SQLITE_OK
|
||||
}
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the ATCR registry server",
|
||||
@@ -72,15 +42,6 @@ See .env.appview.example for available environment variables.`,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register a custom SQLite driver with authorizer for read-only public queries
|
||||
sql.Register("sqlite3_readonly_public",
|
||||
&sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
conn.RegisterAuthorizer(readOnlyAuthorizerCallback)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
// Replace the default serve command with our custom one
|
||||
for i, cmd := range registry.RootCmd.Commands() {
|
||||
if cmd.Name() == "serve" {
|
||||
@@ -93,7 +54,7 @@ func init() {
|
||||
func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
// Load configuration from environment variables
|
||||
fmt.Println("Loading configuration from environment variables...")
|
||||
config, err := loadConfigFromEnv()
|
||||
config, err := appview.LoadConfigFromEnv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config from environment: %w", err)
|
||||
}
|
||||
@@ -101,7 +62,12 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Initialize UI database first (required for all stores)
|
||||
fmt.Println("Initializing UI database...")
|
||||
uiDatabase, uiReadOnlyDB, uiSessionStore := initializeDatabase()
|
||||
uiEnabled := os.Getenv("ATCR_UI_ENABLED") != "false"
|
||||
dbPath := os.Getenv("ATCR_UI_DATABASE_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "/var/lib/atcr/ui.db"
|
||||
}
|
||||
uiDatabase, uiReadOnlyDB, uiSessionStore := db.InitializeDatabase(uiEnabled, dbPath)
|
||||
if uiDatabase == nil {
|
||||
return fmt.Errorf("failed to initialize UI database - required for session storage")
|
||||
}
|
||||
@@ -158,7 +124,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
// Expected format: "did:web:hold01.atcr.io"
|
||||
// To find a hold's DID, visit: https://hold01.atcr.io/.well-known/did.json
|
||||
// The extraction function normalizes URLs to DIDs for consistency
|
||||
defaultHoldDID := extractDefaultHoldDID(config)
|
||||
defaultHoldDID := appview.ExtractDefaultHoldDID(config)
|
||||
|
||||
// Initialize UI routes with OAuth app, refresher, and device store
|
||||
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID)
|
||||
@@ -304,10 +270,10 @@ func initializeAuthKeys(config *configuration.Configuration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
|
||||
issuerName := getStringParam(tokenParams, "issuer", "atcr.io")
|
||||
service := getStringParam(tokenParams, "service", "atcr.io")
|
||||
expirationSecs := getIntParam(tokenParams, "expiration", 300)
|
||||
privateKeyPath := appview.GetStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
|
||||
issuerName := appview.GetStringParam(tokenParams, "issuer", "atcr.io")
|
||||
service := appview.GetStringParam(tokenParams, "service", "atcr.io")
|
||||
expirationSecs := appview.GetIntParam(tokenParams, "expiration", 300)
|
||||
|
||||
// Create issuer (this will generate the key if it doesn't exist)
|
||||
_, err := token.NewIssuer(
|
||||
@@ -331,10 +297,10 @@ func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, erro
|
||||
return nil, fmt.Errorf("token auth not configured")
|
||||
}
|
||||
|
||||
privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
|
||||
issuerName := getStringParam(tokenParams, "issuer", "atcr.io")
|
||||
service := getStringParam(tokenParams, "service", "atcr.io")
|
||||
expirationSecs := getIntParam(tokenParams, "expiration", 300)
|
||||
privateKeyPath := appview.GetStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem")
|
||||
issuerName := appview.GetStringParam(tokenParams, "issuer", "atcr.io")
|
||||
service := appview.GetStringParam(tokenParams, "service", "atcr.io")
|
||||
expirationSecs := appview.GetIntParam(tokenParams, "expiration", 300)
|
||||
|
||||
return token.NewIssuer(
|
||||
privateKeyPath,
|
||||
@@ -344,120 +310,6 @@ func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, erro
|
||||
)
|
||||
}
|
||||
|
||||
// Helper functions to extract values from config parameters
|
||||
func getStringParam(params configuration.Parameters, key, defaultValue string) string {
|
||||
if v, ok := params[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getIntParam(params configuration.Parameters, key string, defaultValue int) int {
|
||||
if v, ok := params[key]; ok {
|
||||
if i, ok := v.(int); ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// extractDefaultHoldDID extracts the default hold DID from middleware config
|
||||
// Returns a DID (e.g., "did:web:hold01.atcr.io")
|
||||
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
|
||||
func extractDefaultHoldDID(config *configuration.Configuration) string {
|
||||
// Navigate through: middleware.registry[].options.default_hold_did
|
||||
registryMiddleware, ok := config.Middleware["registry"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Find atproto-resolver middleware
|
||||
for _, mw := range registryMiddleware {
|
||||
// Check if this is the atproto-resolver
|
||||
if mw.Name != "atproto-resolver" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract options - options is configuration.Parameters which is map[string]any
|
||||
if mw.Options != nil {
|
||||
if holdDID, ok := mw.Options["default_hold_did"].(string); ok {
|
||||
return holdDID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// initializeDatabase initializes the SQLite database and session store
|
||||
// Returns: (read-write DB, read-only DB, session store)
|
||||
func initializeDatabase() (*sql.DB, *sql.DB, *db.SessionStore) {
|
||||
// Check if UI is enabled (optional configuration)
|
||||
uiEnabled := os.Getenv("ATCR_UI_ENABLED")
|
||||
if uiEnabled == "false" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Get database path
|
||||
dbPath := os.Getenv("ATCR_UI_DATABASE_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "/var/lib/atcr/ui.db"
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dbDir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dbDir, 0700); err != nil {
|
||||
fmt.Printf("Warning: Failed to create UI database directory: %v\n", err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Initialize read-write database (for writes and auth operations)
|
||||
database, err := db.InitDB(dbPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to initialize UI database: %v\n", err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Open read-only connection for public queries (search, user pages, etc.)
|
||||
// Uses custom driver with SQLite authorizer that blocks sensitive tables
|
||||
// This prevents accidental writes and blocks access to sensitive tables even if SQL injection occurs
|
||||
readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro")
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to open read-only database connection: %v\n", err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
fmt.Printf("UI database (readonly) initialized at %s\n", dbPath)
|
||||
|
||||
// Create SQLite-backed session store
|
||||
sessionStore := db.NewSessionStore(database)
|
||||
|
||||
// Start cleanup goroutines for all SQLite stores
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx := context.Background()
|
||||
|
||||
// Cleanup UI sessions
|
||||
sessionStore.Cleanup()
|
||||
|
||||
// Cleanup OAuth sessions (older than 30 days)
|
||||
oauthStore := db.NewOAuthStore(database)
|
||||
oauthStore.CleanupOldSessions(ctx, 30*24*time.Hour)
|
||||
oauthStore.CleanupExpiredAuthRequests(ctx)
|
||||
|
||||
// Cleanup device pending auths
|
||||
deviceStore := db.NewDeviceStore(database)
|
||||
deviceStore.CleanupExpired()
|
||||
}
|
||||
}()
|
||||
|
||||
return database, readOnlyDB, sessionStore
|
||||
}
|
||||
|
||||
// initializeUIRoutes initializes the web UI routes
|
||||
// database: read-write connection for auth and writes
|
||||
// readOnlyDB: read-only connection for public queries (search, user pages, etc.)
|
||||
|
||||
Reference in New Issue
Block a user