All checks were successful
check / check (push) Successful in 2m54s
GORM's default logger printed the fully interpolated SQL to standard
output on every statement that returned an error, including a plain
record-not-found. On /webhook/{uuid} and on the login form the
interpolated parameter is client-chosen and unbounded, so an
unauthenticated client sized the operator's log, one line per request,
at no level the operator could turn down.
Every gorm.Open in the service now installs internal/gormlog, a
gormlogger.Interface over the service's *slog.Logger. Its lines take
the level the operator set and the handler internal/logger selected; a
record-not-found is not logged at all, since it is the expected
outcome on both of those paths and each handler already records its
own miss at DEBUG without the SQL; slow statements are kept at WARN
above the same 200ms threshold GORM used; and every value it emits is
spent through an encoded-byte budget.
That budget is internal/middleware's truncateLogField, moved to a new
internal/logfield package now that a second writer needs it. The move
is unchanged logic. MaxAccessLogLineBytes bounds a GORM line too, and
internal/gormlog asserts each line against the constant directly.
The third gorm.Open, in the archive writer, was not named in the issue
and had the same default.
README: the ceiling now covers GORM; the writers it does not cover are
named, including net/http's nil ErrorLog, fx's console logger and the
Go runtime, none of which carry a client-chosen value.
278 lines
5.0 KiB
Go
278 lines
5.0 KiB
Go
// Package database provides SQLite persistence for webhooks, events, and users.
|
|
package database
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"go.uber.org/fx"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
)
|
|
|
|
const (
|
|
dataDirPerm = 0750
|
|
randomPasswordLen = 16
|
|
sessionKeyLen = 32
|
|
)
|
|
|
|
//nolint:revive // DatabaseParams is a standard fx naming convention.
|
|
type DatabaseParams struct {
|
|
fx.In
|
|
|
|
Config *config.Config
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
// Database manages the main SQLite connection and schema migrations.
|
|
type Database struct {
|
|
db *gorm.DB
|
|
log *slog.Logger
|
|
params *DatabaseParams
|
|
}
|
|
|
|
// New creates a Database that connects on fx start and disconnects on stop.
|
|
func New(
|
|
lc fx.Lifecycle,
|
|
params DatabaseParams,
|
|
) (*Database, error) {
|
|
d := &Database{
|
|
params: ¶ms,
|
|
log: params.Logger.Get(),
|
|
}
|
|
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(_ context.Context) error {
|
|
return d.connect()
|
|
},
|
|
OnStop: func(_ context.Context) error {
|
|
return d.close()
|
|
},
|
|
})
|
|
|
|
return d, nil
|
|
}
|
|
|
|
// DB returns the underlying GORM database handle.
|
|
func (d *Database) DB() *gorm.DB {
|
|
return d.db
|
|
}
|
|
|
|
// GetOrCreateSessionKey retrieves the session encryption key from the
|
|
// settings table. If no key exists, a cryptographically secure random
|
|
// 32-byte key is generated, base64-encoded, and stored for future use.
|
|
func (d *Database) GetOrCreateSessionKey() (string, error) {
|
|
var setting Setting
|
|
|
|
result := d.db.Where(
|
|
&Setting{Key: "session_key"},
|
|
).First(&setting)
|
|
if result.Error == nil {
|
|
return setting.Value, nil
|
|
}
|
|
|
|
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
|
return "", fmt.Errorf(
|
|
"failed to query session key: %w",
|
|
result.Error,
|
|
)
|
|
}
|
|
|
|
// Generate a new cryptographically secure 32-byte key
|
|
keyBytes := make([]byte, sessionKeyLen)
|
|
|
|
_, err := rand.Read(keyBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to generate session key: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
encoded := base64.StdEncoding.EncodeToString(keyBytes)
|
|
|
|
setting = Setting{
|
|
Key: "session_key",
|
|
Value: encoded,
|
|
}
|
|
|
|
err = d.db.Create(&setting).Error
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to store session key: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
d.log.Info(
|
|
"generated new session key and stored in database",
|
|
)
|
|
|
|
return encoded, nil
|
|
}
|
|
|
|
func (d *Database) connect() error {
|
|
// Ensure the data directory exists before opening the database.
|
|
dataDir := d.params.Config.DataDir
|
|
|
|
err := os.MkdirAll(dataDir, dataDirPerm)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"creating data directory %s: %w",
|
|
dataDir,
|
|
err,
|
|
)
|
|
}
|
|
|
|
// Construct the main application database path inside DATA_DIR.
|
|
dbPath := filepath.Join(dataDir, "webhooker.db")
|
|
dbURL := fmt.Sprintf(
|
|
"file:%s?cache=shared&mode=rwc",
|
|
dbPath,
|
|
)
|
|
|
|
// Open the database with the pure Go SQLite driver
|
|
sqlDB, err := sql.Open("sqlite", dbURL)
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to open database",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// Then use it with GORM
|
|
db, err := gorm.Open(sqlite.Dialector{
|
|
Conn: sqlDB,
|
|
}, &gorm.Config{
|
|
// Never leave this at GORM's default. See internal/gormlog.
|
|
Logger: gormlog.New(d.log),
|
|
})
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to connect to database",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
d.db = db
|
|
d.log.Info("connected to database", "path", dbPath)
|
|
|
|
// Run migrations
|
|
return d.migrate()
|
|
}
|
|
|
|
func (d *Database) migrate() error {
|
|
// Run GORM auto-migrations
|
|
err := d.Migrate()
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to run database migrations",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
d.log.Info("database migrations completed")
|
|
|
|
// Check if admin user exists
|
|
var userCount int64
|
|
|
|
err = d.db.Model(&User{}).Count(&userCount).Error
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to count users",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
if userCount == 0 {
|
|
return d.createAdminUser()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *Database) createAdminUser() error {
|
|
d.log.Info("no users found, creating admin user")
|
|
|
|
// Generate random password
|
|
password, err := GenerateRandomPassword(
|
|
randomPasswordLen,
|
|
)
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to generate random password",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// Hash the password
|
|
hashedPassword, err := HashPassword(password)
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to hash password",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// Create admin user
|
|
adminUser := &User{
|
|
Username: "admin",
|
|
Password: hashedPassword,
|
|
}
|
|
|
|
err = d.db.Create(adminUser).Error
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to create admin user",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
d.log.Info("admin user created",
|
|
"username", "admin",
|
|
"password", password,
|
|
"message",
|
|
"SAVE THIS PASSWORD - it will not be shown again!",
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *Database) close() error {
|
|
if d.db != nil {
|
|
sqlDB, err := d.db.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return sqlDB.Close()
|
|
}
|
|
|
|
return nil
|
|
}
|