- Rotate the session token at the OIDC callback and restore the full lifetime; cap pre-auth sessions at 15 minutes and write no session for bare anonymous requests - Treat db-dsn as a secret: accept db-dsn-file, log only host, port, database and user, and never echo a malformed DSN in an error - Guard the logout callback with a state cookie so a forged visit cannot end a live session - Collapse FedWiki site actions on a foreign tenant's domain to the not-found answer, as for a domain that does not exist
173 lines
5.8 KiB
Go
173 lines
5.8 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
// DBConfig holds database configuration.
|
|
type DBConfig struct {
|
|
DSN string // Data Source Name (PostgreSQL connection string)
|
|
MaxOpenConns int // Maximum number of open connections
|
|
MaxIdleConns int // Maximum number of idle connections
|
|
ConnMaxLifetime time.Duration // Maximum lifetime of connections
|
|
ConnMaxIdleTime time.Duration // Maximum idle time for connections
|
|
}
|
|
|
|
// DefaultDBConfig returns a DBConfig with sensible defaults for PostgreSQL.
|
|
func DefaultDBConfig(dsn string) *DBConfig {
|
|
return &DBConfig{
|
|
DSN: dsn,
|
|
MaxOpenConns: 25,
|
|
MaxIdleConns: 10,
|
|
ConnMaxLifetime: 30 * time.Minute,
|
|
ConnMaxIdleTime: 5 * time.Minute,
|
|
}
|
|
}
|
|
|
|
// ensureSearchPath appends search_path with the core schema to the DSN if
|
|
// not already present, so every connection in the pool resolves core-schema
|
|
// tables without qualification (Decision 113). Provider schemas (stripe,
|
|
// fedwiki) stay out of search_path and their queries stay schema-qualified.
|
|
func ensureSearchPath(dsn string) string {
|
|
const moduleSearchPath = "core,public"
|
|
u, err := url.Parse(dsn)
|
|
if err != nil || u.Scheme == "" {
|
|
// Key=value DSN format — append if not already set.
|
|
if !strings.Contains(dsn, "search_path") {
|
|
return dsn + " search_path=" + moduleSearchPath
|
|
}
|
|
return dsn
|
|
}
|
|
// URI format — append as query parameter.
|
|
q := u.Query()
|
|
if q.Get("search_path") == "" {
|
|
q.Set("search_path", moduleSearchPath)
|
|
u.RawQuery = q.Encode()
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
// dsnLogAttrs parses dsn as a PostgreSQL connection URL and returns only the
|
|
// parts an operator needs to recognise the connection target: host, port,
|
|
// database and user (design D1, security-audit-remediation-2 — the DSN is a
|
|
// secret, not a string to redact at each call site). It never returns the
|
|
// password or the query string. A DSN that fails to parse yields a single
|
|
// attribute naming the failure, never the input, since url.Error.Error()
|
|
// echoes the string it failed to parse.
|
|
func dsnLogAttrs(dsn string) []slog.Attr {
|
|
u, err := url.Parse(dsn)
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
return []slog.Attr{slog.String("dsn_parse_error", "unparseable")}
|
|
}
|
|
database := strings.TrimPrefix(u.Path, "/")
|
|
var user string
|
|
if u.User != nil {
|
|
user = u.User.Username()
|
|
}
|
|
return []slog.Attr{
|
|
slog.String("db_host", u.Hostname()),
|
|
slog.String("db_port", u.Port()),
|
|
slog.String("db_name", database),
|
|
slog.String("db_user", user),
|
|
}
|
|
}
|
|
|
|
// openAndConfigureDB opens the database connection and configures the connection pool.
|
|
func openAndConfigureDB(config *DBConfig) (*sql.DB, error) {
|
|
db, err := sql.Open("pgx", ensureSearchPath(config.DSN))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
|
}
|
|
|
|
// Configure connection pool
|
|
db.SetMaxOpenConns(config.MaxOpenConns)
|
|
db.SetMaxIdleConns(config.MaxIdleConns)
|
|
db.SetConnMaxLifetime(config.ConnMaxLifetime)
|
|
db.SetConnMaxIdleTime(config.ConnMaxIdleTime)
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// ConnectPlain opens a database connection without the custom search_path.
|
|
// Use this for migrations — the custom search_path causes a pgx/PG18
|
|
// interaction that silently drops tables from multi-statement DDL.
|
|
func ConnectPlain(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, error) {
|
|
db, err := sql.Open("pgx", config.DSN)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
|
}
|
|
db.SetMaxOpenConns(config.MaxOpenConns)
|
|
db.SetMaxIdleConns(config.MaxIdleConns)
|
|
db.SetConnMaxLifetime(config.ConnMaxLifetime)
|
|
db.SetConnMaxIdleTime(config.ConnMaxIdleTime)
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
|
}
|
|
logger.LogAttrs(ctx, slog.LevelInfo, "database connection established (plain)",
|
|
dsnLogAttrs(config.DSN)...)
|
|
return db, nil
|
|
}
|
|
|
|
// Connect initializes and returns a new database connection pool.
|
|
// This is the basic connection function without any automatic operations.
|
|
func Connect(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, error) {
|
|
db, err := openAndConfigureDB(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
|
}
|
|
|
|
attrs := append(dsnLogAttrs(config.DSN),
|
|
slog.Int("max_open_conns", config.MaxOpenConns),
|
|
slog.Int("max_idle_conns", config.MaxIdleConns))
|
|
logger.LogAttrs(ctx, slog.LevelInfo, "database connection established", attrs...)
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// ConnectAndMigrate initializes a database connection and automatically runs migrations.
|
|
// The sources parameter specifies migration sources in dependency order.
|
|
//
|
|
// Migrations run on a separate connection WITHOUT the custom search_path to
|
|
// avoid a pgx/PG18 interaction where setting the core schema in the search_path
|
|
// at connection time causes multi-statement DDL to silently lose tables. After
|
|
// migrations complete, the returned connection uses the full search_path for
|
|
// application queries.
|
|
func ConnectAndMigrate(ctx context.Context, logger *slog.Logger, config *DBConfig, sources []MigrationSource) (*sql.DB, error) {
|
|
// Run migrations on a plain connection (no custom search_path).
|
|
migDB, err := ConnectPlain(ctx, logger, config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := RunMigrations(migDB, sources); err != nil {
|
|
migDB.Close()
|
|
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
migDB.Close()
|
|
|
|
// Now open the application connection with full search_path.
|
|
db, err := Connect(ctx, logger, config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
logger.Info("database migrations applied")
|
|
return db, nil
|
|
}
|