-- UOMysticmoon database schema (MariaDB) -- Run automatically by the MariaDB container (docker-entrypoint-initdb.d) on a -- fresh volume, and idempotently by ensureSchema() on every server boot. CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(32) NOT NULL UNIQUE, password_hash VARCHAR(72) NOT NULL, role ENUM('admin','editor') NOT NULL DEFAULT 'admin', totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA) totp_enabled TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_login_at DATETIME NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS posts ( id INT AUTO_INCREMENT PRIMARY KEY, category ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL, title VARCHAR(200) NOT NULL, slug VARCHAR(220) NULL, excerpt VARCHAR(400) NULL, body MEDIUMTEXT NULL, image_url VARCHAR(500) NULL, published TINYINT(1) NOT NULL DEFAULT 0, author_id INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, published_at DATETIME NULL, CONSTRAINT fk_posts_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_posts_feed (category, published, published_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Wiki categories / sections. Defined before wiki_pages so the FK resolves on a -- fresh install. Pages reference a category (nullable = "Uncategorized"). CREATE TABLE IF NOT EXISTS wiki_categories ( id INT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(120) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, description VARCHAR(400) NULL, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS wiki_pages ( id INT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(120) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, body MEDIUMTEXT NULL, excerpt VARCHAR(400) NULL, category_id INT NULL, published TINYINT(1) NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 0, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, published_at DATETIME NULL, CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_wiki_category FOREIGN KEY (category_id) REFERENCES wiki_categories(id) ON DELETE SET NULL, FULLTEXT INDEX idx_wiki_search (title, body) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Wiki tags (many-to-many with pages). CREATE TABLE IF NOT EXISTS wiki_tags ( id INT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(120) NOT NULL UNIQUE, label VARCHAR(120) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS wiki_page_tags ( page_id INT NOT NULL, tag_id INT NOT NULL, PRIMARY KEY (page_id, tag_id), CONSTRAINT fk_wpt_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE, CONSTRAINT fk_wpt_tag FOREIGN KEY (tag_id) REFERENCES wiki_tags(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Internal-link index, rebuilt on each save. target_slug may point at a page -- that does not exist yet (a "red link"). CREATE TABLE IF NOT EXISTS wiki_links ( source_page_id INT NOT NULL, target_slug VARCHAR(120) NOT NULL, CONSTRAINT fk_wiki_links_src FOREIGN KEY (source_page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE, INDEX idx_wiki_links_target (target_slug) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Per-save content snapshots for history / diff / restore. CREATE TABLE IF NOT EXISTS wiki_revisions ( id INT AUTO_INCREMENT PRIMARY KEY, page_id INT NOT NULL, title VARCHAR(200) NOT NULL, body MEDIUMTEXT NULL, excerpt VARCHAR(400) NULL, category_id INT NULL, editor_id INT NULL, change_note VARCHAR(280) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_wiki_rev_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE, CONSTRAINT fk_wiki_rev_editor FOREIGN KEY (editor_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_wiki_rev_page (page_id, id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS settings ( `key` VARCHAR(64) PRIMARY KEY, value TEXT NULL, updated_by INT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_settings_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS activity_log ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NULL, action VARCHAR(64) NOT NULL, detail TEXT NULL, ip VARCHAR(45) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_activity_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Pluggable SSO / OAuth2 provider configuration. Rows exist for the built-in -- providers ('google', 'discord') once an admin configures them, plus any custom -- OIDC/OAuth2 providers (id = a slug). Client secrets are stored ENCRYPTED -- (client_secret_enc) and are never returned to a client. Built-in providers -- hardcode their endpoint URLs in code; the *_url columns are used only by -- custom (oidc/oauth2) providers. CREATE TABLE IF NOT EXISTS auth_providers ( id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug kind ENUM('google','discord','oidc','oauth2') NOT NULL, name VARCHAR(80) NOT NULL, enabled TINYINT(1) NOT NULL DEFAULT 0, client_id VARCHAR(255) NULL, client_secret_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed authorize_url VARCHAR(500) NULL, -- custom providers only token_url VARCHAR(500) NULL, userinfo_url VARCHAR(500) NULL, scopes VARCHAR(500) NULL, priority INT NOT NULL DEFAULT 100, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Account linking: maps an external SSO identity to an internal user. A login via -- SSO succeeds only if a matching (provider, subject) row exists (link-only — -- external identities are never auto-provisioned into accounts). UNIQUE(provider, -- subject) guarantees one external identity maps to exactly one internal user. CREATE TABLE IF NOT EXISTS user_identities ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, provider VARCHAR(64) NOT NULL, -- matches auth_providers.id subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id) email VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_identity_provider_subject (provider, subject), INDEX idx_identity_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Long-lived, revocable refresh tokens for mobile (Android) bearer-token auth. -- The opaque refresh token is never stored in the clear — only its sha256 hash — -- so a DB read does not leak usable tokens. Rows are rotated on every refresh -- (old row revoked, new row inserted) and revoked on logout. Web cookie sessions -- do NOT use this table; it is purely for the mobile bearer flow. CREATE TABLE IF NOT EXISTS mobile_refresh_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort) user_agent VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL, revoked_at DATETIME NULL, CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_mrt_user (user_id), INDEX idx_mrt_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Migrations for databases created before the wiki upgrade. Each statement uses -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- these columns from the CREATE TABLE above; existing installs get them here. -- (The category foreign key is only added on fresh installs; on upgraded databases -- referential integrity for category_id is enforced in application code.) -- Opt-in TOTP two-factor columns for databases created before login hardening. ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL; ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body);