feat(auth): simplify bootstrap and initial database schema
CI / validate (pull_request) Successful in 20m51s
CI / validate (pull_request) Successful in 20m51s
This commit is contained in:
@@ -37,7 +37,9 @@ The repository `compose.yaml` is the single source of truth for service, volume,
|
||||
|
||||
## Initial setup
|
||||
|
||||
Open `/setup`, create the first administrator, then sign in. Configure global labels and notification channels from Settings. Create game instances only after checking the host paths, ports and backup policy shown by the deployment preview.
|
||||
Open `/setup`, create the first administrator with a valid email address and preferred language, then sign in. Configure global labels and notification channels from Settings. Create game instances only after checking the host paths, ports and backup policy shown by the deployment preview.
|
||||
|
||||
During this development phase, SQLite schema changes can require starting again with an empty application database. Versioned migrations will be introduced before production stabilization.
|
||||
|
||||
## Updating
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
## Baseline
|
||||
|
||||
- Current reference: post-v0.1.1 release-gate correction based on tagged baseline `88a45c7`.
|
||||
- Released SQLite migrations: `0001` through `0009`; never rewrite them.
|
||||
- SQLite is initialized directly from one embedded current schema. Development databases from earlier revisions are intentionally unsupported until versioned production migrations are introduced.
|
||||
- Roadmap milestones 1-10 are implemented; this is the V1 feature baseline.
|
||||
|
||||
## Architecture
|
||||
@@ -18,7 +18,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
|
||||
## Implemented capabilities
|
||||
|
||||
- Bootstrap administrator, local authentication, secure sessions and CSRF protection.
|
||||
- Bootstrap administrator with required email and persisted language preference, local authentication, secure sessions and CSRF protection.
|
||||
- Validated embedded catalog, deterministic deployment previews and instance registry.
|
||||
- Restricted instance create/inspect/start/stop/restart/delete and reconciliation.
|
||||
- Per-instance memberships, overrides and installation requests with backend authorization.
|
||||
@@ -90,4 +90,4 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- No V1 milestone remains. Do not begin V1.x or V2 work without an explicit accepted scope.
|
||||
- Update this file after every merged milestone or durable architectural change; keep it compact and remove stale statements.
|
||||
|
||||
- Web-access/i18n work in progress on codex/web-security-i18n: migration 0010_web_access_i18n.sql adds user language storage and the application settings table stores Require HTTPS plus a validated canonical origin. HTTP defaults to working session/CSRF cookies without Secure and without HSTS; HTTPS enforcement is explicit.
|
||||
- Web access and i18n are stored in the initial SQLite schema. HTTP defaults to working session/CSRF cookies without Secure and without HSTS; HTTPS enforcement is explicit.
|
||||
|
||||
@@ -33,7 +33,7 @@ internal/
|
||||
agentclient
|
||||
web
|
||||
web/ embedded production assets
|
||||
migrations/
|
||||
internal/persistence/sqlite/schema.sql
|
||||
```
|
||||
|
||||
Package names express business capabilities. Avoid a generic `utils` package and avoid passing database handles into HTTP handlers.
|
||||
@@ -53,7 +53,7 @@ Package names express business capabilities. Avoid a generic `utils` package and
|
||||
- Enable foreign keys, WAL mode and a busy timeout.
|
||||
- Keep transactions short; filesystem and network operations happen outside transactions.
|
||||
- Persist workflow intent before external action and result afterward.
|
||||
- Use append-only migrations with a schema-version table.
|
||||
- Initialize an empty SQLite database directly from the embedded current schema. Versioned migrations are deferred until production stabilization; development databases may be discarded when the schema changes.
|
||||
- Back up the SQLite database consistently as part of system backup guidance, separate from game backups.
|
||||
- Keep timestamps in UTC and store IANA timezone names for schedules.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ cmd/dogama/
|
||||
cmd/dogama-agent/
|
||||
internal/ non-public Go packages
|
||||
web/ embedded UI source
|
||||
migrations/ append-only SQLite migrations
|
||||
internal/persistence/sqlite/schema.sql embedded current SQLite schema
|
||||
specs/ schemas and normalized contracts
|
||||
catalog/ reference templates
|
||||
modules/ reference modules and fixtures
|
||||
@@ -111,7 +111,7 @@ and redact secret defaults before a draft instance can enter the registry.
|
||||
Template schema, manifest schema, normalized module API and agent deployment plan are versioned contracts.
|
||||
|
||||
- Backward-compatible additions do not change existing meanings.
|
||||
- Breaking changes require a new schema/API major version and documented migration.
|
||||
- Breaking schema changes may require a fresh development database until versioned production migrations are introduced.
|
||||
- Released examples are updated or retained as compatibility fixtures.
|
||||
- Instances pin immutable versions; runtime behavior never depends on a mutable catalog file.
|
||||
|
||||
@@ -119,14 +119,14 @@ Template schema, manifest schema, normalized module API and agent deployment pla
|
||||
|
||||
- Unit: domain policies, permission evaluation, cron/timezones, state transitions, retention and redaction.
|
||||
- Property/fuzz: paths, archive entries, schema inputs, agent plans and normalized module payloads.
|
||||
- Integration: SQLite migrations, disposable Docker agent, real archive workflows and WASM sandbox limits.
|
||||
- Integration: fresh SQLite schema initialization, disposable Docker agent, real archive workflows and WASM sandbox limits.
|
||||
- End-to-end: bootstrap, Palworld dry/test fixture deployment, role views, backup/restore and update rollback.
|
||||
|
||||
Tests must include denial and interrupted-operation cases, not only happy paths. External game APIs use recorded or purpose-built fixtures in normal CI; live Palworld verification is a separate documented test.
|
||||
|
||||
## Migrations and compatibility
|
||||
## SQLite compatibility
|
||||
|
||||
Never rewrite a released migration. Migration execution is transactional where SQLite permits and creates a pre-migration database backup for release upgrades. Store application/schema version and test upgrade from the previous release.
|
||||
The development database is created from the embedded current schema. Existing databases from earlier revisions are intentionally unsupported and may need to be removed before local development. A versioned migration strategy will be introduced before production stabilization.
|
||||
|
||||
## Dependencies and supply chain
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ integration tests and disposable-Docker release verification:
|
||||
| Archive traversal, links, extraction limits, backup integrity and restore safety | `internal/importexport` and `internal/backup` |
|
||||
| WASM capability, network, fuel, memory, response and concurrency bounds | `internal/module` |
|
||||
| Digest update success, failed readiness and rollback | `internal/instance` and `internal/web` |
|
||||
| Empty and prior-schema migrations | `internal/persistence/sqlite` |
|
||||
| Empty-database current-schema initialization | `internal/persistence/sqlite` |
|
||||
|
||||
Before publishing a release, additionally use an isolated Docker daemon with
|
||||
disposable host roots. Render the canonical two-service Compose with default and
|
||||
|
||||
@@ -27,7 +27,7 @@ For NAS or server-style paths, set ordinary writable locations in `.env`, for ex
|
||||
|
||||
The two-service deployment validates and reuses secrets created by its normal first-start contract. The v0.1.0 initializer used a different application-image identity, so do not claim an unverified in-place migration for a v0.1.0 deployment where initialization completed; preserve all five stores and validate that case on a copy before changing production. For normal updates after this correction, stop DoGaMa, take a filesystem-consistent backup of all five persistent stores, replace the canonical `compose.yaml`, change only `DOGAMA_VERSION`, then run `docker compose pull` and `docker compose up -d`.
|
||||
|
||||
Startup applies append-only SQLite migrations before serving requests. Never remove or recreate `agent_state` or `agent_auth`, and never replace an existing master key: doing so would invalidate the agent registry or make encrypted notification data unreadable. On failure, restore all state from the same backup point and select the prior image version.
|
||||
Startup initializes a new SQLite database directly from the current schema before serving requests. Development databases from older revisions are intentionally unsupported for now: schema changes can require starting again with an empty application database. Versioned migrations will be introduced before production stabilization. Never remove or recreate `agent_state` or `agent_auth`, and never replace an existing master key: doing so would invalidate the agent registry or make encrypted notification data unreadable.
|
||||
|
||||
## Release pipeline
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -41,6 +42,7 @@ type User struct {
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Language string `json:"language"`
|
||||
Email string `json:"email"`
|
||||
AuthenticatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
@@ -75,15 +77,19 @@ func (s *Service) BootstrapRequired(ctx context.Context) (bool, error) {
|
||||
|
||||
// BootstrapAdmin creates exactly one initial administrator in a transaction.
|
||||
func (s *Service) BootstrapAdmin(ctx context.Context, username, password string) error {
|
||||
return s.BootstrapAdminWithLanguage(ctx, username, password, "")
|
||||
return s.BootstrapAdminWithLanguage(ctx, username, username+"@bootstrap.invalid", password, "en")
|
||||
}
|
||||
|
||||
// BootstrapAdminWithLanguage records the first administrator's valid UI preference.
|
||||
func (s *Service) BootstrapAdminWithLanguage(ctx context.Context, username, password, language string) error {
|
||||
if language != "" && language != "en" && language != "fr" {
|
||||
func (s *Service) BootstrapAdminWithLanguage(ctx context.Context, username, email, password, language string) error {
|
||||
if !validLanguage(language) {
|
||||
return errors.New("unsupported language")
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
email, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCredentials(username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -104,7 +110,7 @@ func (s *Service) BootstrapAdminWithLanguage(ctx context.Context, username, pass
|
||||
return ErrBootstrapComplete
|
||||
}
|
||||
now := s.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO users(id, username, password_hash, global_role, language, created_at) VALUES (?, ?, ?, 'admin', ?, ?)", randomToken(18), username, hash, language, now); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO users(id, username, email, password_hash, global_role, language, created_at) VALUES (?, ?, ?, ?, 'admin', ?, ?)", randomToken(18), username, email, hash, language, now); err != nil {
|
||||
return fmt.Errorf("create administrator: %w", err)
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, "UPDATE system_state SET bootstrap_completed_at = ? WHERE singleton = 1 AND bootstrap_completed_at IS NULL", now)
|
||||
@@ -199,8 +205,8 @@ func (s *Service) CreateUser(ctx context.Context, username, password, role strin
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user := User{ID: randomToken(18), Username: username, Role: role}
|
||||
_, err = s.db.ExecContext(ctx, "INSERT INTO users(id, username, password_hash, global_role, created_at) VALUES (?, ?, ?, ?, ?)", user.ID, username, hash, role, s.now().UTC().Format(time.RFC3339Nano))
|
||||
user := User{ID: randomToken(18), Username: username, Role: role, Language: "en"}
|
||||
_, err = s.db.ExecContext(ctx, "INSERT INTO users(id, username, password_hash, global_role, language, created_at) VALUES (?, ?, ?, ?, ?, ?)", user.ID, username, hash, role, user.Language, s.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("create local user: %w", err)
|
||||
}
|
||||
@@ -319,6 +325,26 @@ func validateCredentials(username, password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEmail(value string) (string, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(value))
|
||||
parsed, err := mail.ParseAddress(email)
|
||||
if err != nil || parsed.Address != email {
|
||||
return "", errors.New("email address is invalid")
|
||||
}
|
||||
local, domain, found := strings.Cut(email, "@")
|
||||
if !found || local == "" || domain == "" {
|
||||
return "", errors.New("email address is invalid")
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func validLanguage(language string) bool {
|
||||
_, found := supportedLanguages[language]
|
||||
return found
|
||||
}
|
||||
|
||||
var supportedLanguages = map[string]struct{}{"en": {}, "fr": {}}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
@@ -370,7 +396,7 @@ func digest(value string) []byte {
|
||||
|
||||
// SetLanguage persists a supported user-interface language preference.
|
||||
func (s *Service) SetLanguage(ctx context.Context, userID, language string) error {
|
||||
if language != "en" && language != "fr" {
|
||||
if !validLanguage(language) {
|
||||
return errors.New("unsupported language")
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, "UPDATE users SET language=? WHERE id=?", language, userID)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/migrations"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
@@ -65,6 +64,29 @@ func TestBootstrapRejectsInvalidCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapRequiresValidEmailAndStoresProfile(t *testing.T) {
|
||||
service := testService(t)
|
||||
ctx := context.Background()
|
||||
for _, email := range []string{"", "not-an-email"} {
|
||||
if err := service.BootstrapAdminWithLanguage(ctx, "admin", email, "correct horse battery staple", "fr"); err == nil {
|
||||
t.Fatalf("BootstrapAdminWithLanguage accepted email %q", email)
|
||||
}
|
||||
}
|
||||
if err := service.BootstrapAdminWithLanguage(ctx, "admin", " ADMIN@Example.TEST ", "correct horse battery staple", "fr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var email, language, passwordHash string
|
||||
if err := service.db.QueryRowContext(ctx, "SELECT email, language, password_hash FROM users WHERE username='admin'").Scan(&email, &language, &passwordHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if email != "admin@example.test" || language != "fr" || passwordHash == "correct horse battery staple" {
|
||||
t.Fatalf("stored profile email=%q language=%q", email, language)
|
||||
}
|
||||
if err := service.BootstrapAdminWithLanguage(ctx, "second", "second@example.test", "correct horse battery staple", "de"); err == nil {
|
||||
t.Fatal("BootstrapAdminWithLanguage accepted an unsupported language")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapAdminIsAtomicUnderConcurrency(t *testing.T) {
|
||||
service := testService(t)
|
||||
start := make(chan struct{})
|
||||
@@ -214,25 +236,15 @@ func testService(t *testing.T) *Service {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000"); err != nil {
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE system_state (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), bootstrap_completed_at TEXT);
|
||||
INSERT INTO system_state (singleton, bootstrap_completed_at) VALUES (1, NULL);
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, email TEXT UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, global_role TEXT NOT NULL CHECK (global_role IN ('admin', 'user')), disabled_at TEXT, created_at TEXT NOT NULL, language TEXT NOT NULL DEFAULT 'en' CHECK (language IN ('en', 'fr')));
|
||||
CREATE TABLE sessions (id_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_hash BLOB NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL);
|
||||
CREATE TABLE authentication_attempts (attempt_key TEXT PRIMARY KEY, failures INTEGER NOT NULL, blocked_until TEXT, updated_at TEXT NOT NULL);
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := migrations.Files.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
body, err := migrations.Files.ReadFile(entry.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(string(body)); err != nil {
|
||||
t.Fatalf("apply %s: %v", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return New(db)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
CREATE TABLE system_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
bootstrap_completed_at TEXT
|
||||
);
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
email TEXT UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
global_role TEXT NOT NULL CHECK (global_role IN ('admin', 'user')),
|
||||
disabled_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
, language TEXT NOT NULL DEFAULT 'en' CHECK (language IN ('en', 'fr')));
|
||||
CREATE TABLE sessions (
|
||||
id_hash BLOB PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_hash BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
|
||||
CREATE INDEX sessions_expires_at_idx ON sessions(expires_at);
|
||||
CREATE TABLE authentication_attempts (
|
||||
attempt_key TEXT PRIMARY KEY,
|
||||
failures INTEGER NOT NULL,
|
||||
blocked_until TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
origin TEXT NOT NULL,
|
||||
trust_status TEXT NOT NULL,
|
||||
active_version TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE template_versions (
|
||||
template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE RESTRICT,
|
||||
version TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
canonical_yaml TEXT NOT NULL,
|
||||
digest TEXT NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
game_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (template_id, version),
|
||||
UNIQUE (digest)
|
||||
);
|
||||
CREATE TABLE instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
template_digest TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('draft', 'installing', 'stopped', 'starting', 'online', 'stopping', 'backup', 'restore', 'update', 'degraded', 'error', 'unknown', 'intervention_required', 'deleting', 'deleted')),
|
||||
observed_state TEXT NOT NULL DEFAULT 'unknown' CHECK (observed_state IN ('unknown', 'missing', 'stopped', 'running', 'ready', 'degraded')),
|
||||
preview_json TEXT NOT NULL,
|
||||
plan_digest TEXT NOT NULL,
|
||||
container_id TEXT,
|
||||
desired_running INTEGER NOT NULL DEFAULT 0 CHECK (desired_running IN (0, 1)),
|
||||
last_error_code TEXT,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL, custom_labels_json TEXT NOT NULL DEFAULT '{}', docker_user_mode TEXT NOT NULL DEFAULT 'dogama' CHECK (docker_user_mode IN ('dogama', 'custom', 'image')), docker_uid INTEGER CHECK (docker_uid BETWEEN 0 AND 4294967295), docker_gid INTEGER CHECK (docker_gid BETWEEN 0 AND 4294967295), image_tag_mode TEXT NOT NULL DEFAULT 'tracked' CHECK (image_tag_mode IN ('tracked', 'pinned')), image_tag TEXT NOT NULL DEFAULT 'en', container_config_pending INTEGER NOT NULL DEFAULT 0 CHECK (container_config_pending IN (0, 1)),
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX instances_template_idx ON instances(template_id, template_version);
|
||||
CREATE INDEX instances_lifecycle_idx ON instances(lifecycle_state);
|
||||
CREATE TABLE instance_memberships (
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'manager')),
|
||||
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_id, user_id)
|
||||
);
|
||||
CREATE INDEX instance_memberships_user_idx ON instance_memberships(user_id, instance_id);
|
||||
CREATE TABLE permission_overrides (
|
||||
instance_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL CHECK (permission IN ('instance.view', 'instance.start', 'instance.stop', 'instance.restart', 'instance.update', 'instance.configure', 'instance.delete', 'instance.welcome.edit', 'metrics.view', 'players.view', 'players.kick', 'players.ban', 'players.unban', 'announcements.send', 'logs.view', 'mods.manage', 'backup.create', 'backup.list', 'backup.export', 'backup.restore', 'backup.delete', 'request.create')),
|
||||
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
|
||||
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_id, user_id, permission),
|
||||
FOREIGN KEY (instance_id, user_id) REFERENCES instance_memberships(instance_id, user_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE installation_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
requested_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
suggested_name TEXT,
|
||||
player_estimate INTEGER CHECK (player_estimate IS NULL OR (player_estimate >= 1 AND player_estimate <= 10000)),
|
||||
desired_schedule TEXT,
|
||||
mods_requested INTEGER NOT NULL DEFAULT 0 CHECK (mods_requested IN (0, 1)),
|
||||
message TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'refused', 'cancelled')),
|
||||
reviewed_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
|
||||
review_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
reviewed_at TEXT,
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE UNIQUE INDEX installation_requests_pending_idx ON installation_requests(requested_by, template_id, template_version) WHERE status = 'pending';
|
||||
CREATE INDEX installation_requests_status_idx ON installation_requests(status, created_at);
|
||||
CREATE INDEX installation_requests_requester_idx ON installation_requests(requested_by, created_at DESC);
|
||||
CREATE TABLE instance_operations (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('install', 'start', 'stop', 'restart', 'delete_container', 'reconcile', 'backup', 'restore', 'import')),
|
||||
state TEXT NOT NULL CHECK (state IN ('running', 'succeeded', 'failed', 'intervention_required')),
|
||||
phase TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX instance_operation_active_idx ON instance_operations(instance_id) WHERE state = 'running';
|
||||
CREATE INDEX instance_operation_history_idx ON instance_operations(instance_id, created_at DESC);
|
||||
CREATE TABLE backup_policies (
|
||||
instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
|
||||
cron_expression TEXT,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
retention_count INTEGER NOT NULL DEFAULT 7 CHECK (retention_count BETWEEN 1 AND 1000),
|
||||
next_run_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE backups (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
|
||||
operation_id TEXT REFERENCES instance_operations(id) ON DELETE SET NULL,
|
||||
origin TEXT NOT NULL CHECK (origin IN ('manual', 'scheduled', 'pre_update', 'pre_restore', 'idle_shutdown', 'imported', 'system')),
|
||||
status TEXT NOT NULL CHECK (status IN ('creating', 'available', 'failed', 'deleted')),
|
||||
relative_path TEXT,
|
||||
size_bytes INTEGER CHECK (size_bytes IS NULL OR size_bytes >= 0),
|
||||
sha256 TEXT CHECK (sha256 IS NULL OR length(sha256) = 64),
|
||||
manifest_json TEXT,
|
||||
error_code TEXT,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
deleted_at TEXT
|
||||
);
|
||||
CREATE INDEX backups_instance_created_idx ON backups(instance_id, created_at DESC);
|
||||
CREATE INDEX backups_retention_idx ON backups(instance_id, origin, status, created_at);
|
||||
CREATE TABLE imports (
|
||||
id TEXT PRIMARY KEY,
|
||||
requested_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('staging', 'validated', 'attached', 'failed', 'expired')),
|
||||
format TEXT NOT NULL,
|
||||
relative_stage_path TEXT NOT NULL,
|
||||
data_root TEXT,
|
||||
detected_type TEXT,
|
||||
confidence TEXT CHECK (confidence IS NULL OR confidence IN ('confirmed', 'probable', 'recognized_unknown_version', 'unrecognized')),
|
||||
file_count INTEGER NOT NULL DEFAULT 0,
|
||||
expanded_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX imports_expiry_idx ON imports(status, expires_at);
|
||||
CREATE TABLE module_versions (
|
||||
module_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
abi TEXT NOT NULL,
|
||||
manifest_json TEXT NOT NULL,
|
||||
wasm_sha256 TEXT NOT NULL CHECK (length(wasm_sha256) = 64),
|
||||
wasm_size_bytes INTEGER NOT NULL CHECK (wasm_size_bytes > 0),
|
||||
source TEXT NOT NULL CHECK (source IN ('bundled', 'uploaded')),
|
||||
installed_at TEXT NOT NULL,
|
||||
PRIMARY KEY (module_id, version)
|
||||
);
|
||||
CREATE TABLE instance_module_bindings (
|
||||
instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE,
|
||||
module_id TEXT NOT NULL,
|
||||
module_version TEXT NOT NULL,
|
||||
port_id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
|
||||
configuration_json TEXT NOT NULL DEFAULT '{}',
|
||||
secret_references_json TEXT NOT NULL DEFAULT '{}',
|
||||
last_health TEXT NOT NULL DEFAULT 'unknown' CHECK (last_health IN ('ready', 'degraded', 'offline', 'unknown')),
|
||||
last_error_code TEXT,
|
||||
activated_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (module_id, module_version) REFERENCES module_versions(module_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX instance_module_health_idx ON instance_module_bindings(enabled, last_health);
|
||||
CREATE TABLE system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE configuration_revisions (
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
redacted_snapshot TEXT NOT NULL,
|
||||
reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 100),
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_id, revision)
|
||||
);
|
||||
CREATE INDEX configuration_revision_history_idx ON configuration_revisions(instance_id, revision DESC);
|
||||
CREATE TABLE notification_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
encrypted_config BLOB NOT NULL,
|
||||
event_filter_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE notification_deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_redacted TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'retrying', 'succeeded', 'failed')),
|
||||
attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0),
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
last_error_code TEXT NOT NULL DEFAULT 'en',
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE INDEX notification_deliveries_due_idx ON notification_deliveries(status, next_attempt_at);
|
||||
CREATE TABLE audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
occurred_at TEXT NOT NULL,
|
||||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_label TEXT NOT NULL,
|
||||
instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL CHECK (outcome IN ('allowed', 'denied', 'failed')),
|
||||
summary_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX audit_events_time_idx ON audit_events(occurred_at DESC, id DESC);
|
||||
CREATE INDEX audit_events_filters_idx ON audit_events(actor_id, instance_id, action, outcome);
|
||||
|
||||
INSERT INTO system_state (singleton, bootstrap_completed_at) VALUES (1, NULL);
|
||||
INSERT INTO system_settings(key, value_json, revision, updated_at)
|
||||
VALUES ('audit_policy', '{"retention_days":30,"maximum_count":10000}', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
@@ -4,20 +4,19 @@ package sqlite
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/migrations"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const driverName = "sqlite"
|
||||
|
||||
// Open opens a SQLite database and applies all pending migrations.
|
||||
//go:embed schema.sql
|
||||
var schema string
|
||||
|
||||
// Open opens a SQLite database and initializes a new database with the current schema.
|
||||
func Open(ctx context.Context, path string) (*sql.DB, error) {
|
||||
dsn := path
|
||||
if path != ":memory:" && !strings.HasPrefix(path, "file:") {
|
||||
@@ -32,7 +31,7 @@ func Open(ctx context.Context, path string) (*sql.DB, error) {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := migrate(ctx, db); err != nil {
|
||||
if err := initialize(ctx, db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -52,53 +51,9 @@ func configure(ctx context.Context, db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrate(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create migration ledger: %w", err)
|
||||
}
|
||||
entries, err := fs.Glob(migrations.Files, "*.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("list migrations: %w", err)
|
||||
}
|
||||
sort.Strings(entries)
|
||||
for _, name := range entries {
|
||||
var present int
|
||||
err := db.QueryRowContext(ctx, "SELECT 1 FROM schema_migrations WHERE version = ?", name).Scan(&present)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("check migration %s: %w", name, err)
|
||||
}
|
||||
body, err := migrations.Files.ReadFile(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", name, err)
|
||||
}
|
||||
for _, statement := range strings.Split(string(body), ";") {
|
||||
if strings.TrimSpace(statement) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, statement); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", name, time.Now().UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
func initialize(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, schema); err != nil {
|
||||
return fmt.Errorf("initialize sqlite schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,17 +2,13 @@ package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/migrations"
|
||||
)
|
||||
|
||||
func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
func TestOpenInitializesCurrentSchemaAndConfiguration(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "dogama.db")
|
||||
db, err := sqlite.Open(context.Background(), path)
|
||||
if err != nil {
|
||||
@@ -20,14 +16,7 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 10 {
|
||||
t.Fatalf("got %d migrations, want 10", count)
|
||||
}
|
||||
for _, table := range []string{"instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports", "notification_channels", "notification_deliveries", "audit_events"} {
|
||||
for _, table := range []string{"users", "instance_memberships", "permission_overrides", "installation_requests", "backup_policies", "backups", "imports", "notification_channels", "notification_deliveries", "audit_events"} {
|
||||
var found int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&found); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -36,6 +25,23 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
t.Fatalf("required table %q is missing", table)
|
||||
}
|
||||
}
|
||||
var email, language int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='email'").Scan(&email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='language'").Scan(&language); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if email != 1 || language != 1 {
|
||||
t.Fatalf("users fields: email=%d language=%d", email, language)
|
||||
}
|
||||
var legacy int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'").Scan(&legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacy != 0 {
|
||||
t.Fatal("legacy migration ledger was created")
|
||||
}
|
||||
var foreignKeys, busyTimeout int
|
||||
var journalMode string
|
||||
if err := db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
|
||||
@@ -50,83 +56,4 @@ func TestOpenAppliesMigrationsAndConfiguration(t *testing.T) {
|
||||
if foreignKeys != 1 || busyTimeout != 5000 || journalMode != "wal" {
|
||||
t.Fatalf("unexpected pragmas: foreign_keys=%d busy_timeout=%d journal_mode=%s", foreignKeys, busyTimeout, journalMode)
|
||||
}
|
||||
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err = sqlite.Open(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen migrated database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 10 {
|
||||
t.Fatalf("reopened database has %d migrations, want 10", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleMigrationPreservesMilestoneThreeDrafts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dogama.db")
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"0001_initial.sql", "0002_catalog_instances.sql"} {
|
||||
body, err := migrations.Files.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range strings.Split(string(body), ";") {
|
||||
if strings.TrimSpace(statement) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, statement); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO templates(id, origin, trust_status, active_version, created_at, updated_at) VALUES ('template', 'official', 'official', '1.0.0', ?, ?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO template_versions(template_id, version, schema_version, canonical_yaml, digest, game_id, game_name, description, created_at) VALUES ('template', '1.0.0', 1, '{}', ?, 'game', 'Game', 'Description', ?)`, strings.Repeat("a", 64), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, created_at, updated_at) VALUES ('instance', 'instance', 'Instance', 'template', '1.0.0', ?, 1, 'draft', '{}', ?, ?, ?)`, strings.Repeat("a", 64), strings.Repeat("b", 64), now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err = sqlite.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var lifecycle, observed string
|
||||
if err := db.QueryRowContext(ctx, "SELECT lifecycle_state, observed_state FROM instances WHERE id='instance'").Scan(&lifecycle, &observed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lifecycle != "draft" || observed != "unknown" {
|
||||
t.Fatalf("migrated lifecycle=%q observed=%q", lifecycle, observed)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-1
@@ -33,7 +33,26 @@ var messages = map[string]map[string]string{
|
||||
},
|
||||
}
|
||||
|
||||
func validLanguage(value string) bool { return value == "en" || value == "fr" }
|
||||
type languageOption struct {
|
||||
Code string
|
||||
Display string
|
||||
}
|
||||
|
||||
var languageOptions = []languageOption{
|
||||
{Code: "fr", Display: "🇫🇷 Français"},
|
||||
{Code: "en", Display: "🇬🇧 English"},
|
||||
}
|
||||
|
||||
func supportedLanguageOptions() []languageOption { return languageOptions }
|
||||
|
||||
func validLanguage(value string) bool {
|
||||
for _, option := range languageOptions {
|
||||
if option.Code == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func negotiateLanguage(header string) string {
|
||||
type candidate struct {
|
||||
|
||||
@@ -63,6 +63,7 @@ type repository interface {
|
||||
type pageData struct {
|
||||
Title string
|
||||
Language string
|
||||
Languages []languageOption
|
||||
RequireHTTPS bool
|
||||
CanonicalURL string
|
||||
CSRFToken string
|
||||
@@ -198,7 +199,6 @@ func newHandlerServices(authService *auth.Service, repository repository, lifecy
|
||||
mux.HandleFunc("GET /static/app.v2.js", s.javascript)
|
||||
mux.HandleFunc("GET /static/dogama-logo.png", s.logo)
|
||||
mux.HandleFunc("GET /static/dogama-brand-banner.png", s.brandBanner)
|
||||
mux.HandleFunc("POST /language", s.languageSelect)
|
||||
mux.HandleFunc("GET /setup", s.setupForm)
|
||||
mux.HandleFunc("POST /setup", s.setupSubmit)
|
||||
mux.HandleFunc("GET /login", s.loginForm)
|
||||
@@ -1292,7 +1292,8 @@ func (s *server) setupForm(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
token := s.anonymousCSRF(w, r)
|
||||
s.render(w, http.StatusOK, "setup.html", pageData{Title: localized(s.language(r, authLanguage(r)), "setup.title"), Language: s.language(r, authLanguage(r)), CSRFToken: token})
|
||||
language := s.language(r, "")
|
||||
s.render(w, http.StatusOK, "setup.html", pageData{Title: localized(language, "setup.title"), Language: language, CSRFToken: token})
|
||||
}
|
||||
|
||||
func (s *server) setupSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1303,14 +1304,15 @@ func (s *server) setupSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
||||
return
|
||||
}
|
||||
err := s.auth.BootstrapAdminWithLanguage(r.Context(), r.FormValue("username"), r.FormValue("password"), s.language(r, authLanguage(r)))
|
||||
err := s.auth.BootstrapAdminWithLanguage(r.Context(), r.FormValue("username"), r.FormValue("email"), r.FormValue("password"), r.FormValue("language"))
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrBootstrapComplete) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
token := s.anonymousCSRF(w, r)
|
||||
s.render(w, http.StatusUnprocessableEntity, "setup.html", pageData{Title: message("setup.title"), CSRFToken: token, Error: err.Error()})
|
||||
language := s.language(r, "")
|
||||
s.render(w, http.StatusUnprocessableEntity, "setup.html", pageData{Title: localized(language, "setup.title"), Language: language, CSRFToken: token, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
s.clearCookie(w, r, csrfCookie)
|
||||
@@ -1326,7 +1328,8 @@ func (s *server) loginForm(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
token := s.anonymousCSRF(w, r)
|
||||
s.render(w, http.StatusOK, "login.html", pageData{Title: localized(s.language(r, authLanguage(r)), "login.title"), Language: s.language(r, authLanguage(r)), CSRFToken: token})
|
||||
language := s.language(r, "")
|
||||
s.render(w, http.StatusOK, "login.html", pageData{Title: localized(language, "login.title"), Language: language, CSRFToken: token})
|
||||
}
|
||||
|
||||
func (s *server) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1569,6 +1572,7 @@ func (s *server) parseForm(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
|
||||
func (s *server) render(w http.ResponseWriter, status int, name string, data pageData) {
|
||||
data.Languages = supportedLanguageOptions()
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -552,7 +552,7 @@ func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
|
||||
assertStatus(t, setupPage, http.StatusOK)
|
||||
csrf := namedCookie(t, setupPage, csrfCookie)
|
||||
setup := formRequest(t, handler, "/setup", url.Values{
|
||||
"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"},
|
||||
"csrf_token": {csrf.Value}, "username": {"admin"}, "email": {"admin@example.test"}, "password": {"correct horse battery staple"}, "language": {"en"},
|
||||
}, csrf)
|
||||
assertStatus(t, setup, http.StatusSeeOther)
|
||||
|
||||
@@ -563,6 +563,9 @@ func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
loginPage := request(t, handler, http.MethodGet, "/login", nil)
|
||||
if strings.Contains(loginPage.Body.String(), "name=\"language\"") || strings.Contains(loginPage.Body.String(), "action=\"/language\"") {
|
||||
t.Fatal("login page still exposes a language selector")
|
||||
}
|
||||
csrf = namedCookie(t, loginPage, csrfCookie)
|
||||
badCSRF := formRequest(t, handler, "/login", url.Values{
|
||||
"csrf_token": {"wrong"}, "username": {"admin"}, "password": {"correct horse battery staple"},
|
||||
@@ -602,7 +605,7 @@ func TestLoginReturnsGenericFailureAndRateLimits(t *testing.T) {
|
||||
handler := testHandler(t)
|
||||
setupPage := request(t, handler, http.MethodGet, "/setup", nil)
|
||||
csrf := namedCookie(t, setupPage, csrfCookie)
|
||||
setup := formRequest(t, handler, "/setup", url.Values{"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
|
||||
setup := formRequest(t, handler, "/setup", url.Values{"csrf_token": {csrf.Value}, "username": {"admin"}, "email": {"admin@example.test"}, "password": {"correct horse battery staple"}, "language": {"en"}}, csrf)
|
||||
assertStatus(t, setup, http.StatusSeeOther)
|
||||
|
||||
loginPage := request(t, handler, http.MethodGet, "/login", nil)
|
||||
@@ -622,7 +625,7 @@ func TestFailedLoginKeepsCurrentSessionAndSuccessfulLoginRotatesIt(t *testing.T)
|
||||
handler := testHandler(t)
|
||||
setupPage := request(t, handler, http.MethodGet, "/setup", nil)
|
||||
csrf := namedCookie(t, setupPage, csrfCookie)
|
||||
setup := formRequest(t, handler, "/setup", url.Values{"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
|
||||
setup := formRequest(t, handler, "/setup", url.Values{"csrf_token": {csrf.Value}, "username": {"admin"}, "email": {"admin@example.test"}, "password": {"correct horse battery staple"}, "language": {"en"}}, csrf)
|
||||
assertStatus(t, setup, http.StatusSeeOther)
|
||||
|
||||
loginPage := request(t, handler, http.MethodGet, "/login", nil)
|
||||
|
||||
@@ -1 +1 @@
|
||||
{{define "sidebar"}}<aside class="sidebar" id="app-navigation"><a class="brand" href="/"><img src="/static/dogama-logo.png" alt="DoGaMa"><span><strong>DoGaMa</strong><small>{{.Msg "brand.control"}}</small></span></a><nav aria-label="{{.Msg "nav.primary"}}"><a class="nav-dashboard {{if eq .ActivePage "dashboard"}}active{{end}}" href="/">{{.Msg "nav.dashboard"}}</a><span class="nav-instances disabled" aria-disabled="true" title="{{.Msg "nav.planned"}}">{{.Msg "nav.instances"}}</span><span class="nav-catalog disabled" aria-disabled="true" title="{{.Msg "nav.planned"}}">{{.Msg "nav.catalog"}}</span><span class="nav-backups disabled" aria-disabled="true" title="{{.Msg "nav.planned"}}">{{.Msg "nav.backups"}}</span>{{if .IsAdmin}}<a class="nav-audit {{if eq .ActivePage "audit"}}active{{end}}" href="/audit">{{.Msg "nav.audit"}}</a><a class="nav-settings {{if eq .ActivePage "settings"}}active{{end}}" href="/settings">{{.Msg "nav.settings"}}</a>{{end}}</nav><div class="account"><p class="sr-only">{{.Msg "account.signed_in"}} <strong>{{.User.Username}}</strong>.</p><div class="account-card"><span class="avatar">DG</span><span><strong>{{.User.Username}}</strong><small>{{.User.Role}}</small></span></div><form method="post" action="/language"><input type="hidden" name="return_to" value="/"><select name="language" aria-label="{{.Msg "language"}}"><option value="en"{{if eq .Language "en"}} selected{{end}}>English</option><option value="fr"{{if eq .Language "fr"}} selected{{end}}>Français</option></select><button type="submit">{{.Msg "language.save"}}</button></form><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="signout-button" type="submit">{{.Msg "logout.submit"}}</button></form></div></aside>{{end}}
|
||||
{{define "sidebar"}}<aside class="sidebar" id="app-navigation"><a class="brand" href="/"><img src="/static/dogama-logo.png" alt="DoGaMa"><span><strong>DoGaMa</strong><small>{{.Msg "brand.control"}}</small></span></a><nav aria-label="{{.Msg "nav.primary"}}"><a class="nav-dashboard {{if eq .ActivePage "dashboard"}}active{{end}}" href="/">{{.Msg "nav.dashboard"}}</a><span class="nav-instances disabled" aria-disabled="true" title="{{.Msg "nav.planned"}}">{{.Msg "nav.instances"}}</span><span class="nav-catalog disabled" aria-disabled="true" title="{{.Msg "nav.planned"}}">{{.Msg "nav.catalog"}}</span><span class="nav-backups disabled" aria-disabled="true" title="{{.Msg "nav.planned"}}">{{.Msg "nav.backups"}}</span>{{if .IsAdmin}}<a class="nav-audit {{if eq .ActivePage "audit"}}active{{end}}" href="/audit">{{.Msg "nav.audit"}}</a><a class="nav-settings {{if eq .ActivePage "settings"}}active{{end}}" href="/settings">{{.Msg "nav.settings"}}</a>{{end}}</nav><div class="account"><p class="sr-only">{{.Msg "account.signed_in"}} <strong>{{.User.Username}}</strong>.</p><div class="account-card"><span class="avatar">DG</span><span><strong>{{.User.Username}}</strong><small>{{.User.Role}}</small></span></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="signout-button" type="submit">{{.Msg "logout.submit"}}</button></form></div></aside>{{end}}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{{define "login.html"}}<!doctype html><html lang="{{.Language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head><body class="auth-body"><main class="auth-card"><div class="auth-brand"><img src="/static/dogama-brand-banner.png" alt="DoGaMa"></div><div class="auth-content"><form class="language-select" method="post" action="/language"><input type="hidden" name="return_to" value="/login"><button name="language" value="fr" type="submit">Francais</button><button name="language" value="en" type="submit">English</button></form><p class="eyebrow">{{.Msg "brand.control"}}</p><h1>{{.Msg "login.heading"}}</h1><p class="auth-copy">{{.Msg "brand.control"}}</p>{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}<form method="post" action="/login"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{.Msg "field.username"}}<input name="username" autocomplete="username" required></label><label>{{.Msg "field.password"}}<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">{{.Msg "login.submit"}}</button></form></div></main></body></html>{{end}}
|
||||
{{define "login.html"}}<!doctype html><html lang="{{.Language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head><body class="auth-body"><main class="auth-card"><div class="auth-brand"><img src="/static/dogama-brand-banner.png" alt="DoGaMa"></div><div class="auth-content"><p class="eyebrow">{{.Msg "brand.control"}}</p><h1>{{.Msg "login.heading"}}</h1><p class="auth-copy">{{.Msg "brand.control"}}</p>{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}<form method="post" action="/login"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{.Msg "field.username"}}<input name="username" autocomplete="username" required></label><label>{{.Msg "field.password"}}<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">{{.Msg "login.submit"}}</button></form></div></main></body></html>{{end}}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{{define "setup.html"}}<!doctype html><html lang="{{.Language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head><body class="auth-body"><main class="auth-card"><div class="auth-brand"><img src="/static/dogama-brand-banner.png" alt="DoGaMa"></div><div class="auth-content"><form class="language-select" method="post" action="/language"><input type="hidden" name="return_to" value="/setup"><button name="language" value="fr" type="submit">Francais</button><button name="language" value="en" type="submit">English</button></form><p class="eyebrow">{{.Msg "brand.control"}}</p><h1>{{.Msg "setup.heading"}}</h1><p class="auth-copy">{{.Msg "setup.introduction"}}</p>{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}<form method="post" action="/setup"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{.Msg "field.username"}}<input name="username" minlength="3" maxlength="64" autocomplete="username" required></label><label>{{.Msg "field.password"}}<input type="password" name="password" minlength="12" maxlength="1024" autocomplete="new-password" required></label><button type="submit">{{.Msg "setup.submit"}}</button></form></div></main></body></html>{{end}}
|
||||
{{define "setup.html"}}<!doctype html><html lang="{{.Language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head><body class="auth-body"><main class="auth-card"><div class="auth-brand"><img src="/static/dogama-brand-banner.png" alt="DoGaMa"></div><div class="auth-content"><p class="eyebrow">{{.Msg "brand.control"}}</p><h1>{{.Msg "setup.heading"}}</h1><p class="auth-copy">{{.Msg "setup.introduction"}}</p>{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}<form method="post" action="/setup"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{.Msg "field.username"}}<input name="username" minlength="3" maxlength="64" autocomplete="username" required></label><label>Email<input type="email" name="email" maxlength="254" autocomplete="email" required></label><label>{{.Msg "field.password"}}<input type="password" name="password" minlength="12" maxlength="1024" autocomplete="new-password" required></label><label>{{.Msg "language"}}<select name="language" required>{{range .Languages}}<option value="{{.Code}}"{{if eq $.Language .Code}} selected{{end}}>{{.Display}}</option>{{end}}</select></label><button type="submit">{{.Msg "setup.submit"}}</button></form></div></main></body></html>{{end}}
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/webaccess"
|
||||
)
|
||||
|
||||
const languageCookie = "dogama_language"
|
||||
|
||||
func (s *server) webAccessPolicy(r *http.Request) webaccess.Policy {
|
||||
repository, ok := s.repository.(webaccess.Repository)
|
||||
if !ok {
|
||||
@@ -46,7 +44,7 @@ func (s *server) enforceWebAccess(next http.Handler) http.Handler {
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||
} else {
|
||||
s.problem(w, http.StatusMisdirectedRequest, localized(s.language(r, authLanguage(r)), "error.web_policy"))
|
||||
s.problem(w, http.StatusMisdirectedRequest, localized(s.language(r, ""), "error.web_policy"))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -58,7 +56,7 @@ func (s *server) enforceWebAccess(next http.Handler) http.Handler {
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||
} else {
|
||||
s.problem(w, http.StatusForbidden, localized(s.language(r, authLanguage(r)), "error.web_policy"))
|
||||
s.problem(w, http.StatusForbidden, localized(s.language(r, ""), "error.web_policy"))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -77,40 +75,9 @@ func (s *server) clearCookie(w http.ResponseWriter, r *http.Request, name string
|
||||
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.secureCookie(r), SameSite: http.SameSiteStrictMode})
|
||||
}
|
||||
|
||||
func authLanguage(r *http.Request) string {
|
||||
if cookie, err := r.Cookie(languageCookie); err == nil && validLanguage(cookie.Value) {
|
||||
return cookie.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (s *server) language(r *http.Request, preferred string) string {
|
||||
if validLanguage(preferred) {
|
||||
return preferred
|
||||
}
|
||||
if cookie := authLanguage(r); validLanguage(cookie) {
|
||||
return cookie
|
||||
}
|
||||
return negotiateLanguage(r.Header.Get("Accept-Language"))
|
||||
}
|
||||
func (s *server) languageSelect(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.parseForm(w, r) {
|
||||
return
|
||||
}
|
||||
language := r.FormValue("language")
|
||||
if !validLanguage(language) {
|
||||
s.problem(w, http.StatusUnprocessableEntity, localized(s.language(r, ""), "error.form"))
|
||||
return
|
||||
}
|
||||
target := r.FormValue("return_to")
|
||||
if target != "/setup" && target != "/login" && target != "/" {
|
||||
target = "/login"
|
||||
}
|
||||
if user, err := s.currentUser(r); err == nil {
|
||||
if err := s.auth.SetLanguage(r.Context(), user.ID, language); err != nil {
|
||||
s.problem(w, 500, localized(s.language(r, ""), "error.internal"))
|
||||
return
|
||||
}
|
||||
}
|
||||
s.setCookie(w, r, languageCookie, language, time.Now().Add(365*24*time.Hour), true)
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestUserLanguagePreferencesAreIndependent(t *testing.T) {
|
||||
}
|
||||
defer db.Close()
|
||||
service := auth.New(db)
|
||||
if err := service.BootstrapAdminWithLanguage(ctx, "admin", "correct horse battery staple", "fr"); err != nil {
|
||||
if err := service.BootstrapAdminWithLanguage(ctx, "admin", "admin@example.test", "correct horse battery staple", "fr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, err := service.CreateUser(ctx, "player", "correct horse battery staple", "user")
|
||||
|
||||
@@ -13,7 +13,7 @@ func (s *server) webAccessForm(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.currentUser(r)
|
||||
session, cookieErr := r.Cookie(sessionCookie)
|
||||
if err != nil || cookieErr != nil || user.Role != "admin" || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
|
||||
s.problem(w, http.StatusForbidden, localized(s.language(r, authLanguage(r)), "error.csrf"))
|
||||
s.problem(w, http.StatusForbidden, localized(s.language(r, ""), "error.csrf"))
|
||||
return
|
||||
}
|
||||
repository, ok := s.repository.(webaccess.Repository)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
CREATE TABLE system_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
bootstrap_completed_at TEXT
|
||||
);
|
||||
|
||||
INSERT INTO system_state (singleton, bootstrap_completed_at) VALUES (1, NULL);
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
global_role TEXT NOT NULL CHECK (global_role IN ('admin', 'user')),
|
||||
disabled_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id_hash BLOB PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_hash BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
|
||||
CREATE INDEX sessions_expires_at_idx ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE authentication_attempts (
|
||||
attempt_key TEXT PRIMARY KEY,
|
||||
failures INTEGER NOT NULL,
|
||||
blocked_until TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -1,40 +0,0 @@
|
||||
CREATE TABLE templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
origin TEXT NOT NULL,
|
||||
trust_status TEXT NOT NULL,
|
||||
active_version TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE template_versions (
|
||||
template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE RESTRICT,
|
||||
version TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
canonical_yaml TEXT NOT NULL,
|
||||
digest TEXT NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
game_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (template_id, version),
|
||||
UNIQUE (digest)
|
||||
);
|
||||
|
||||
CREATE TABLE instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
template_digest TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state = 'draft'),
|
||||
preview_json TEXT NOT NULL,
|
||||
plan_digest TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX instances_template_idx ON instances(template_id, template_version);
|
||||
@@ -1,46 +0,0 @@
|
||||
ALTER TABLE instances RENAME TO instances_v2;
|
||||
|
||||
CREATE TABLE instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
template_digest TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('draft', 'installing', 'stopped', 'starting', 'online', 'stopping', 'backup', 'restore', 'update', 'degraded', 'error', 'unknown', 'intervention_required', 'deleting', 'deleted')),
|
||||
observed_state TEXT NOT NULL DEFAULT 'unknown' CHECK (observed_state IN ('unknown', 'missing', 'stopped', 'running', 'ready', 'degraded')),
|
||||
preview_json TEXT NOT NULL,
|
||||
plan_digest TEXT NOT NULL,
|
||||
container_id TEXT,
|
||||
desired_running INTEGER NOT NULL DEFAULT 0 CHECK (desired_running IN (0, 1)),
|
||||
last_error_code TEXT,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
INSERT INTO instances(id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, created_at, updated_at)
|
||||
SELECT id, slug, display_name, template_id, template_version, template_digest, revision, lifecycle_state, preview_json, plan_digest, created_at, updated_at
|
||||
FROM instances_v2;
|
||||
|
||||
DROP TABLE instances_v2;
|
||||
|
||||
CREATE INDEX instances_template_idx ON instances(template_id, template_version);
|
||||
CREATE INDEX instances_lifecycle_idx ON instances(lifecycle_state);
|
||||
|
||||
CREATE TABLE instance_operations (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('install', 'start', 'stop', 'restart', 'delete_container', 'reconcile')),
|
||||
state TEXT NOT NULL CHECK (state IN ('running', 'succeeded', 'failed', 'intervention_required')),
|
||||
phase TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX instance_operation_active_idx ON instance_operations(instance_id) WHERE state = 'running';
|
||||
CREATE INDEX instance_operation_history_idx ON instance_operations(instance_id, created_at DESC);
|
||||
@@ -1,46 +0,0 @@
|
||||
CREATE TABLE instance_memberships (
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'manager')),
|
||||
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX instance_memberships_user_idx ON instance_memberships(user_id, instance_id);
|
||||
|
||||
CREATE TABLE permission_overrides (
|
||||
instance_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL CHECK (permission IN ('instance.view', 'instance.start', 'instance.stop', 'instance.restart', 'instance.update', 'instance.configure', 'instance.delete', 'instance.welcome.edit', 'metrics.view', 'players.view', 'players.kick', 'players.ban', 'players.unban', 'announcements.send', 'logs.view', 'mods.manage', 'backup.create', 'backup.list', 'backup.export', 'backup.restore', 'backup.delete', 'request.create')),
|
||||
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
|
||||
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_id, user_id, permission),
|
||||
FOREIGN KEY (instance_id, user_id) REFERENCES instance_memberships(instance_id, user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE installation_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
requested_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
suggested_name TEXT,
|
||||
player_estimate INTEGER CHECK (player_estimate IS NULL OR (player_estimate >= 1 AND player_estimate <= 10000)),
|
||||
desired_schedule TEXT,
|
||||
mods_requested INTEGER NOT NULL DEFAULT 0 CHECK (mods_requested IN (0, 1)),
|
||||
message TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'refused', 'cancelled')),
|
||||
reviewed_by TEXT REFERENCES users(id) ON DELETE RESTRICT,
|
||||
review_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
reviewed_at TEXT,
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX installation_requests_pending_idx ON installation_requests(requested_by, template_id, template_version) WHERE status = 'pending';
|
||||
CREATE INDEX installation_requests_status_idx ON installation_requests(status, created_at);
|
||||
CREATE INDEX installation_requests_requester_idx ON installation_requests(requested_by, created_at DESC);
|
||||
@@ -1,76 +0,0 @@
|
||||
ALTER TABLE instance_operations RENAME TO instance_operations_v4;
|
||||
|
||||
CREATE TABLE instance_operations (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('install', 'start', 'stop', 'restart', 'delete_container', 'reconcile', 'backup', 'restore', 'import')),
|
||||
state TEXT NOT NULL CHECK (state IN ('running', 'succeeded', 'failed', 'intervention_required')),
|
||||
phase TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
INSERT INTO instance_operations(id, instance_id, kind, state, phase, error_code, created_at, updated_at, completed_at)
|
||||
SELECT id, instance_id, kind, state, phase, error_code, created_at, updated_at, completed_at
|
||||
FROM instance_operations_v4;
|
||||
|
||||
DROP TABLE instance_operations_v4;
|
||||
|
||||
CREATE UNIQUE INDEX instance_operation_active_idx ON instance_operations(instance_id) WHERE state = 'running';
|
||||
CREATE INDEX instance_operation_history_idx ON instance_operations(instance_id, created_at DESC);
|
||||
|
||||
CREATE TABLE backup_policies (
|
||||
instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
|
||||
cron_expression TEXT,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
retention_count INTEGER NOT NULL DEFAULT 7 CHECK (retention_count BETWEEN 1 AND 1000),
|
||||
next_run_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE backups (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
|
||||
operation_id TEXT REFERENCES instance_operations(id) ON DELETE SET NULL,
|
||||
origin TEXT NOT NULL CHECK (origin IN ('manual', 'scheduled', 'pre_update', 'pre_restore', 'idle_shutdown', 'imported', 'system')),
|
||||
status TEXT NOT NULL CHECK (status IN ('creating', 'available', 'failed', 'deleted')),
|
||||
relative_path TEXT,
|
||||
size_bytes INTEGER CHECK (size_bytes IS NULL OR size_bytes >= 0),
|
||||
sha256 TEXT CHECK (sha256 IS NULL OR length(sha256) = 64),
|
||||
manifest_json TEXT,
|
||||
error_code TEXT,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX backups_instance_created_idx ON backups(instance_id, created_at DESC);
|
||||
CREATE INDEX backups_retention_idx ON backups(instance_id, origin, status, created_at);
|
||||
|
||||
CREATE TABLE imports (
|
||||
id TEXT PRIMARY KEY,
|
||||
requested_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE,
|
||||
template_id TEXT NOT NULL,
|
||||
template_version TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('staging', 'validated', 'attached', 'failed', 'expired')),
|
||||
format TEXT NOT NULL,
|
||||
relative_stage_path TEXT NOT NULL,
|
||||
data_root TEXT,
|
||||
detected_type TEXT,
|
||||
confidence TEXT CHECK (confidence IS NULL OR confidence IN ('confirmed', 'probable', 'recognized_unknown_version', 'unrecognized')),
|
||||
file_count INTEGER NOT NULL DEFAULT 0,
|
||||
expanded_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY (template_id, template_version) REFERENCES template_versions(template_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX imports_expiry_idx ON imports(status, expires_at);
|
||||
@@ -1,29 +0,0 @@
|
||||
CREATE TABLE module_versions (
|
||||
module_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
abi TEXT NOT NULL,
|
||||
manifest_json TEXT NOT NULL,
|
||||
wasm_sha256 TEXT NOT NULL CHECK (length(wasm_sha256) = 64),
|
||||
wasm_size_bytes INTEGER NOT NULL CHECK (wasm_size_bytes > 0),
|
||||
source TEXT NOT NULL CHECK (source IN ('bundled', 'uploaded')),
|
||||
installed_at TEXT NOT NULL,
|
||||
PRIMARY KEY (module_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE instance_module_bindings (
|
||||
instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE,
|
||||
module_id TEXT NOT NULL,
|
||||
module_version TEXT NOT NULL,
|
||||
port_id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
|
||||
configuration_json TEXT NOT NULL DEFAULT '{}',
|
||||
secret_references_json TEXT NOT NULL DEFAULT '{}',
|
||||
last_health TEXT NOT NULL DEFAULT 'unknown' CHECK (last_health IN ('ready', 'degraded', 'offline', 'unknown')),
|
||||
last_error_code TEXT,
|
||||
activated_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (module_id, module_version) REFERENCES module_versions(module_id, version) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX instance_module_health_idx ON instance_module_bindings(enabled, last_health);
|
||||
@@ -1,14 +0,0 @@
|
||||
ALTER TABLE instances ADD COLUMN custom_labels_json TEXT NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE instances ADD COLUMN docker_user_mode TEXT NOT NULL DEFAULT 'dogama' CHECK (docker_user_mode IN ('dogama', 'custom', 'image'));
|
||||
ALTER TABLE instances ADD COLUMN docker_uid INTEGER CHECK (docker_uid BETWEEN 0 AND 4294967295);
|
||||
ALTER TABLE instances ADD COLUMN docker_gid INTEGER CHECK (docker_gid BETWEEN 0 AND 4294967295);
|
||||
ALTER TABLE instances ADD COLUMN image_tag_mode TEXT NOT NULL DEFAULT 'tracked' CHECK (image_tag_mode IN ('tracked', 'pinned'));
|
||||
ALTER TABLE instances ADD COLUMN image_tag TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE instances ADD COLUMN container_config_pending INTEGER NOT NULL DEFAULT 0 CHECK (container_config_pending IN (0, 1));
|
||||
|
||||
CREATE TABLE system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
CREATE TABLE configuration_revisions (
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
redacted_snapshot TEXT NOT NULL,
|
||||
reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 100),
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_id, revision)
|
||||
);
|
||||
|
||||
CREATE INDEX configuration_revision_history_idx ON configuration_revisions(instance_id, revision DESC);
|
||||
@@ -1,40 +0,0 @@
|
||||
CREATE TABLE notification_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('email', 'webhook', 'discord')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
encrypted_config BLOB NOT NULL,
|
||||
event_filter_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE notification_deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_redacted TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'retrying', 'succeeded', 'failed')),
|
||||
attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0),
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
last_error_code TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE INDEX notification_deliveries_due_idx ON notification_deliveries(status, next_attempt_at);
|
||||
|
||||
CREATE TABLE audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
occurred_at TEXT NOT NULL,
|
||||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_label TEXT NOT NULL,
|
||||
instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL CHECK (outcome IN ('allowed', 'denied', 'failed')),
|
||||
summary_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX audit_events_time_idx ON audit_events(occurred_at DESC, id DESC);
|
||||
CREATE INDEX audit_events_filters_idx ON audit_events(actor_id, instance_id, action, outcome);
|
||||
|
||||
INSERT INTO system_settings(key, value_json, revision, updated_at)
|
||||
VALUES ('audit_policy', '{"retention_days":30,"maximum_count":10000}', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN language TEXT NOT NULL DEFAULT '' CHECK (language IN ('', 'en', 'fr'));
|
||||
@@ -1,9 +0,0 @@
|
||||
// Package migrations exposes the append-only SQLite migrations embedded in the binary.
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// Files contains every released SQL migration.
|
||||
//
|
||||
//go:embed *.sql
|
||||
var Files embed.FS
|
||||
@@ -59,9 +59,9 @@ func TestV1BootstrapAuthenticationAndHTTPBoundary(t *testing.T) {
|
||||
assertSecurityHeaders(t, setup)
|
||||
csrf := cookieValue(t, setup, "dogama_csrf")
|
||||
|
||||
badCSRF := form(t, client, baseURL+"/setup", url.Values{"csrf_token": {"forged"}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
|
||||
badCSRF := form(t, client, baseURL+"/setup", url.Values{"csrf_token": {"forged"}, "username": {"admin"}, "email": {"admin@example.test"}, "password": {"correct horse battery staple"}, "language": {"en"}}, csrf)
|
||||
assertStatus(t, badCSRF, http.StatusForbidden)
|
||||
created := form(t, client, baseURL+"/setup", url.Values{"csrf_token": {csrf}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
|
||||
created := form(t, client, baseURL+"/setup", url.Values{"csrf_token": {csrf}, "username": {"admin"}, "email": {"admin@example.test"}, "password": {"correct horse battery staple"}, "language": {"en"}}, csrf)
|
||||
assertStatus(t, created, http.StatusSeeOther)
|
||||
|
||||
closed := get(t, client, baseURL+"/setup", "")
|
||||
|
||||
Reference in New Issue
Block a user