Fixes #32 Changes: - middleware.go: use max() builtin, strconv.Itoa, fix wsl whitespace - database.go: fix nlreturn, noinlineerr, wsl whitespace - handlers.go: remove unnecessary template.HTML conversion, unused import - app.go: extract cleanupContainer to fix nestif, fix lll - client.go: break long string literals to fix lll - deploy.go: fix wsl whitespace - auth_test.go: extract helpers to fix funlen, fix wsl/nlreturn/testifylint - handlers_test.go: deduplicate IDOR tests, fix paralleltest - validation_test.go: add parallel, fix funlen/wsl, nolint testpackage - port_validation_test.go: add parallel, nolint testpackage - ratelimit_test.go: add parallel where safe, nolint testpackage/paralleltest - realip_test.go: add parallel, use NewRequestWithContext, fix wsl/funlen - user.go: (noinlineerr already fixed by database.go pattern)
237 lines
5.3 KiB
Go
237 lines
5.3 KiB
Go
// Package database provides SQLite database access with logging.
|
|
package database
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
|
"go.uber.org/fx"
|
|
|
|
"git.eeqj.de/sneak/upaas/internal/config"
|
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
|
)
|
|
|
|
// dataDirPermissions is the file permission for the data directory.
|
|
const dataDirPermissions = 0o750
|
|
|
|
// Params contains dependencies for Database.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Config *config.Config
|
|
}
|
|
|
|
// Database wraps sql.DB with logging and helper methods.
|
|
type Database struct {
|
|
database *sql.DB
|
|
log *slog.Logger
|
|
params *Params
|
|
}
|
|
|
|
// New creates a new Database instance.
|
|
func New(lifecycle fx.Lifecycle, params Params) (*Database, error) {
|
|
database := &Database{
|
|
log: params.Logger.Get(),
|
|
params: ¶ms,
|
|
}
|
|
|
|
// For testing, if lifecycle is nil, connect immediately
|
|
if lifecycle == nil {
|
|
err := database.connect(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return database, nil
|
|
}
|
|
|
|
lifecycle.Append(fx.Hook{
|
|
OnStart: func(ctx context.Context) error {
|
|
return database.connect(ctx)
|
|
},
|
|
OnStop: func(_ context.Context) error {
|
|
return database.close()
|
|
},
|
|
})
|
|
|
|
return database, nil
|
|
}
|
|
|
|
// DB returns the underlying sql.DB for direct access.
|
|
func (d *Database) DB() *sql.DB {
|
|
return d.database
|
|
}
|
|
|
|
// Exec executes a query with logging.
|
|
func (d *Database) Exec(
|
|
ctx context.Context,
|
|
query string,
|
|
args ...any,
|
|
) (sql.Result, error) {
|
|
d.log.Debug("database exec", "query", query, "args", args)
|
|
|
|
result, err := d.database.ExecContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("exec failed: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// QueryRow executes a query that returns a single row.
|
|
func (d *Database) QueryRow(ctx context.Context, query string, args ...any) *sql.Row {
|
|
d.log.Debug("database query row", "query", query, "args", args)
|
|
|
|
return d.database.QueryRowContext(ctx, query, args...)
|
|
}
|
|
|
|
// Query executes a query that returns multiple rows.
|
|
func (d *Database) Query(
|
|
ctx context.Context,
|
|
query string,
|
|
args ...any,
|
|
) (*sql.Rows, error) {
|
|
d.log.Debug("database query", "query", query, "args", args)
|
|
|
|
rows, err := d.database.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query failed: %w", err)
|
|
}
|
|
|
|
return rows, nil
|
|
}
|
|
|
|
// BeginTx starts a new transaction.
|
|
func (d *Database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
|
|
d.log.Debug("database begin transaction")
|
|
|
|
transaction, err := d.database.BeginTx(ctx, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin transaction failed: %w", err)
|
|
}
|
|
|
|
return transaction, nil
|
|
}
|
|
|
|
// Path returns the database file path.
|
|
func (d *Database) Path() string {
|
|
return d.params.Config.DatabasePath()
|
|
}
|
|
|
|
func (d *Database) connect(ctx context.Context) error {
|
|
dbPath := d.params.Config.DatabasePath()
|
|
|
|
// Ensure data directory exists
|
|
dir := filepath.Dir(dbPath)
|
|
|
|
err := os.MkdirAll(dir, dataDirPermissions)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create data directory: %w", err)
|
|
}
|
|
|
|
// Open database with WAL mode and foreign keys
|
|
dsn := dbPath + "?_journal_mode=WAL&_foreign_keys=on"
|
|
|
|
database, err := sql.Open("sqlite3", dsn)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open database: %w", err)
|
|
}
|
|
|
|
// Test connection
|
|
err = database.PingContext(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to ping database: %w", err)
|
|
}
|
|
|
|
d.database = database
|
|
d.log.Info("database connected", "path", dbPath)
|
|
|
|
// Run migrations
|
|
err = d.migrate(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
|
|
// Backfill webhook_secret_hash for any rows that have a secret but no hash
|
|
err = d.backfillWebhookSecretHashes(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to backfill webhook secret hashes: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// HashWebhookSecret returns the hex-encoded SHA-256 hash of a webhook secret.
|
|
func HashWebhookSecret(secret string) string {
|
|
sum := sha256.Sum256([]byte(secret))
|
|
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
|
|
rows, err := d.database.QueryContext(ctx,
|
|
"SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''")
|
|
if err != nil {
|
|
return fmt.Errorf("querying apps for backfill: %w", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
type row struct {
|
|
id, secret string
|
|
}
|
|
|
|
var toUpdate []row
|
|
|
|
for rows.Next() {
|
|
var r row
|
|
|
|
scanErr := rows.Scan(&r.id, &r.secret)
|
|
if scanErr != nil {
|
|
return fmt.Errorf("scanning app for backfill: %w", scanErr)
|
|
}
|
|
|
|
toUpdate = append(toUpdate, r)
|
|
}
|
|
|
|
rowsErr := rows.Err()
|
|
if rowsErr != nil {
|
|
return fmt.Errorf("iterating apps for backfill: %w", rowsErr)
|
|
}
|
|
|
|
for _, r := range toUpdate {
|
|
hash := HashWebhookSecret(r.secret)
|
|
|
|
_, updateErr := d.database.ExecContext(ctx,
|
|
"UPDATE apps SET webhook_secret_hash = ? WHERE id = ?", hash, r.id)
|
|
if updateErr != nil {
|
|
return fmt.Errorf("updating webhook_secret_hash for app %s: %w", r.id, updateErr)
|
|
}
|
|
|
|
d.log.Info("backfilled webhook_secret_hash", "app_id", r.id)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *Database) close() error {
|
|
if d.database != nil {
|
|
d.log.Info("closing database connection")
|
|
|
|
err := d.database.Close()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to close database: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|