Lumi/src/services/db.js
2026-06-18 10:54:34 +02:00

393 lines
12 KiB
JavaScript

const path = require("path");
const fs = require("fs");
const Database = require("better-sqlite3");
const dataDir = path.join(__dirname, "..", "..", "data");
const dbPath = path.join(dataDir, "app.db");
fs.mkdirSync(dataDir, { recursive: true });
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS custom_themes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_theme_id TEXT NOT NULL,
values_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
avatar TEXT,
last_login INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_profiles (
id TEXT PRIMARY KEY,
internal_username TEXT NOT NULL UNIQUE COLLATE NOCASE,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
display_name TEXT,
avatar TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(provider, provider_user_id)
);
CREATE TABLE IF NOT EXISTS mod_role_periods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
start_at INTEGER NOT NULL,
end_at INTEGER
);
CREATE TABLE IF NOT EXISTS linked_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
display_name TEXT,
access_token TEXT,
refresh_token TEXT,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(user_id, provider)
);
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
source TEXT,
path TEXT NOT NULL,
installed_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS plugin_settings (
plugin_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (plugin_id, key)
);
CREATE TABLE IF NOT EXISTS stats (
user_id TEXT PRIMARY KEY,
messages INTEGER NOT NULL DEFAULT 0,
commands INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS custom_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
nav_label TEXT,
content TEXT NOT NULL,
content_css TEXT NOT NULL DEFAULT '',
format TEXT NOT NULL DEFAULT 'html',
role TEXT NOT NULL DEFAULT 'public',
show_in_nav INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS custom_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trigger TEXT NOT NULL UNIQUE,
response TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'both',
mode TEXT NOT NULL DEFAULT 'plain',
language TEXT NOT NULL DEFAULT 'js',
code TEXT,
preview_text TEXT,
preview_status TEXT,
preview_error TEXT,
preview_generated_at INTEGER,
preview_dynamic_segments TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS command_usage (
command_id TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level TEXT NOT NULL,
message TEXT NOT NULL,
details TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS logs_created_at_idx ON logs (created_at);
CREATE TABLE IF NOT EXISTS feedback_entries (
id TEXT PRIMARY KEY,
submitter_id TEXT NOT NULL,
summary TEXT NOT NULL,
category TEXT NOT NULL,
severity TEXT NOT NULL,
scope_type TEXT NOT NULL,
scope_label TEXT,
target_metadata_json TEXT NOT NULL DEFAULT '{}',
current_url TEXT,
page_title TEXT,
description TEXT NOT NULL,
steps_to_reproduce TEXT,
expected_behavior TEXT,
actual_behavior TEXT,
diagnostics_json TEXT NOT NULL DEFAULT '{}',
screenshot_path TEXT,
screenshot_mime TEXT,
screenshot_size INTEGER,
status TEXT NOT NULL DEFAULT 'new',
admin_reply TEXT,
assigned_admin_id TEXT,
linked_todo TEXT,
linked_issue TEXT,
linked_correction TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_activity_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE INDEX IF NOT EXISTS feedback_entries_submitter_idx ON feedback_entries (submitter_id);
CREATE INDEX IF NOT EXISTS feedback_entries_status_idx ON feedback_entries (status);
CREATE INDEX IF NOT EXISTS feedback_entries_last_activity_idx ON feedback_entries (last_activity_at);
CREATE TABLE IF NOT EXISTS feedback_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feedback_id TEXT NOT NULL,
actor_id TEXT NOT NULL,
kind TEXT NOT NULL,
body TEXT NOT NULL,
visible_to_submitter INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS feedback_comments_feedback_idx ON feedback_comments (feedback_id, created_at);
CREATE TABLE IF NOT EXISTS feedback_status_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feedback_id TEXT NOT NULL,
status TEXT NOT NULL,
actor_id TEXT,
note TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS feedback_status_history_feedback_idx ON feedback_status_history (feedback_id, created_at);
CREATE TABLE IF NOT EXISTS feedback_view_state (
user_id TEXT PRIMARY KEY,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS feedback_support (
feedback_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (feedback_id, user_id)
);
CREATE INDEX IF NOT EXISTS feedback_support_feedback_idx ON feedback_support (feedback_id, created_at);
CREATE TABLE IF NOT EXISTS feedback_attachments (
id TEXT PRIMARY KEY,
feedback_id TEXT NOT NULL,
storage_path TEXT NOT NULL,
original_name TEXT,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'attachment',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS feedback_attachments_feedback_idx ON feedback_attachments (feedback_id, created_at);
`);
const columns = db
.prepare("PRAGMA table_info(custom_commands)")
.all()
.map((column) => column.name);
if (!columns.includes("mode")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN mode TEXT NOT NULL DEFAULT 'plain'");
}
if (!columns.includes("language")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN language TEXT NOT NULL DEFAULT 'js'");
}
if (!columns.includes("code")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN code TEXT");
}
if (!columns.includes("platform")) {
db.exec(
"ALTER TABLE custom_commands ADD COLUMN platform TEXT NOT NULL DEFAULT 'both'"
);
}
if (!columns.includes("preview_text")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN preview_text TEXT");
}
if (!columns.includes("preview_status")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN preview_status TEXT");
}
if (!columns.includes("preview_error")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN preview_error TEXT");
}
if (!columns.includes("preview_generated_at")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN preview_generated_at INTEGER");
}
if (!columns.includes("preview_dynamic_segments")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN preview_dynamic_segments TEXT NOT NULL DEFAULT '[]'");
}
const pageColumns = db
.prepare("PRAGMA table_info(custom_pages)")
.all()
.map((column) => column.name);
if (!pageColumns.includes("content_css")) {
db.exec(
"ALTER TABLE custom_pages ADD COLUMN content_css TEXT NOT NULL DEFAULT ''"
);
}
if (!pageColumns.includes("format")) {
db.exec(
"ALTER TABLE custom_pages ADD COLUMN format TEXT NOT NULL DEFAULT 'html'"
);
}
const profileColumns = db
.prepare("PRAGMA table_info(user_profiles)")
.all()
.map((column) => column.name);
if (!profileColumns.includes("username_updated_at")) {
db.exec("ALTER TABLE user_profiles ADD COLUMN username_updated_at INTEGER");
}
const feedbackColumns = db
.prepare("PRAGMA table_info(feedback_entries)")
.all()
.map((column) => column.name);
if (!feedbackColumns.includes("screenshot_path")) {
db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_path TEXT");
}
if (!feedbackColumns.includes("screenshot_mime")) {
db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_mime TEXT");
}
if (!feedbackColumns.includes("screenshot_size")) {
db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_size INTEGER");
}
migrateLegacyUsers();
}
function migrateLegacyUsers() {
const legacyUsers = db
.prepare("SELECT id, username, avatar FROM users")
.all();
if (!legacyUsers.length) {
return;
}
const now = Date.now();
const mapping = new Map();
for (const legacy of legacyUsers) {
const existingIdentity = db
.prepare(
"SELECT user_id FROM user_identities WHERE provider = 'discord' AND provider_user_id = ?"
)
.get(legacy.id);
if (existingIdentity?.user_id) {
mapping.set(legacy.id, existingIdentity.user_id);
continue;
}
const username = generateUniqueLegacyUsername(legacy.username);
const userId = cryptoRandomId();
db.prepare(
"INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)"
).run(userId, username, now, now);
db.prepare(
"INSERT INTO user_identities (user_id, provider, provider_user_id, display_name, avatar, created_at, updated_at) VALUES (?, 'discord', ?, ?, ?, ?, ?)"
).run(userId, legacy.id, legacy.username, legacy.avatar, now, now);
mapping.set(legacy.id, userId);
}
for (const [legacyId, userId] of mapping.entries()) {
db.prepare("UPDATE stats SET user_id = ? WHERE user_id = ?").run(
userId,
legacyId
);
db.prepare("UPDATE linked_accounts SET user_id = ? WHERE user_id = ?").run(
userId,
legacyId
);
}
}
function generateUniqueLegacyUsername(name) {
const base = (name || "user").trim() || "user";
if (isLegacyUsernameAvailable(base)) {
return base;
}
let suffix = 2;
let candidate = `${base}-${suffix}`;
while (!isLegacyUsernameAvailable(candidate)) {
suffix += 1;
candidate = `${base}-${suffix}`;
}
return candidate;
}
function isLegacyUsernameAvailable(name) {
const row = db
.prepare(
"SELECT id FROM user_profiles WHERE internal_username = ? LIMIT 1"
)
.get(name);
return !row;
}
function cryptoRandomId() {
return require("crypto").randomUUID();
}
module.exports = {
db,
migrate
};