283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
import Database from 'better-sqlite3'
|
|
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
import * as schema from './schema/index.js'
|
|
|
|
export type Sqlite = Database.Database
|
|
export type AppDb = ReturnType<typeof drizzle<typeof schema>>
|
|
|
|
export function resolveDatabasePath(databaseUrl: string): string {
|
|
return databaseUrl.startsWith('sqlite:')
|
|
? databaseUrl.slice('sqlite:'.length)
|
|
: databaseUrl
|
|
}
|
|
|
|
export function createDb(databaseUrl: string): { db: AppDb; sqlite: Sqlite } {
|
|
const path = resolveDatabasePath(databaseUrl)
|
|
const sqlite = new Database(path)
|
|
sqlite.pragma('journal_mode = WAL')
|
|
sqlite.pragma('foreign_keys = ON')
|
|
sqlite.pragma('synchronous = NORMAL')
|
|
// Без busy_timeout любая конкуренция за блокировку сразу даёт SQLITE_BUSY.
|
|
sqlite.pragma('busy_timeout = 5000')
|
|
sqlite.pragma('cache_size = -16000') // ~16 МиБ
|
|
sqlite.pragma('temp_store = MEMORY')
|
|
sqlite.pragma('mmap_size = 67108864') // 64 МиБ
|
|
sqlite.pragma('wal_autocheckpoint = 1000')
|
|
sqlite.pragma('journal_size_limit = 67108864')
|
|
const db = drizzle(sqlite, { schema })
|
|
return { db, sqlite }
|
|
}
|
|
|
|
export function createMemoryDb(): { db: AppDb; sqlite: Sqlite } {
|
|
const sqlite = new Database(':memory:')
|
|
sqlite.pragma('foreign_keys = ON')
|
|
const db = drizzle(sqlite, { schema })
|
|
return { db, sqlite }
|
|
}
|
|
|
|
/** Версия схемы в PRAGMA user_version; при совпадении migrateSchema ничего не делает. */
|
|
// ВАЖНО: любое изменение baseMigrate()/migrateV1() требует увеличить SCHEMA_VERSION
|
|
// (+ новая migrateVN), иначе существующие БД со скипом по user_version его не применят.
|
|
export const SCHEMA_VERSION = 1
|
|
|
|
export function migrateSchema(sqlite: Sqlite): void {
|
|
const current = sqlite.pragma('user_version', { simple: true }) as number
|
|
if (current >= SCHEMA_VERSION) return
|
|
sqlite.transaction(() => {
|
|
baseMigrate(sqlite)
|
|
migrateV1(sqlite)
|
|
sqlite.pragma(`user_version = ${SCHEMA_VERSION}`)
|
|
})()
|
|
}
|
|
|
|
/** v1: дедупликация доступа, уникальные индексы и индексы под очистку/фильтры. */
|
|
function migrateV1(sqlite: Sqlite): void {
|
|
sqlite.exec(`
|
|
DELETE FROM user_apps WHERE rowid NOT IN (
|
|
SELECT MIN(rowid) FROM user_apps GROUP BY user_id, app_id
|
|
);
|
|
DELETE FROM user_permissions WHERE rowid NOT IN (
|
|
SELECT MIN(rowid) FROM user_permissions GROUP BY user_id, permission
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_apps ON user_apps(user_id, app_id);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_permissions ON user_permissions(user_id, permission);
|
|
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires ON refresh_sessions(expires_at);
|
|
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_expires ON oidc_auth_codes(expires_at);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_severity ON audit_log(severity, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_email ON audit_log(actor_email);
|
|
`)
|
|
}
|
|
|
|
function baseMigrate(sqlite: Sqlite): void {
|
|
sqlite.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
email TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
disabled INTEGER NOT NULL DEFAULT 0,
|
|
last_login_at TEXT,
|
|
last_login_ip TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_apps (
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
app_id TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_permissions (
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
permission TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS refresh_sessions (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token_hash TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
revoked_at TEXT,
|
|
ip TEXT,
|
|
user_agent TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS portal_settings (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
app_switcher_json TEXT,
|
|
audit_retention_days INTEGER NOT NULL DEFAULT 90,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
event_id TEXT,
|
|
source_app TEXT NOT NULL DEFAULT 'portal',
|
|
action TEXT NOT NULL,
|
|
severity TEXT NOT NULL,
|
|
actor_user_id TEXT,
|
|
actor_email TEXT,
|
|
actor_name TEXT,
|
|
target_type TEXT,
|
|
target_id TEXT,
|
|
summary TEXT NOT NULL,
|
|
details_json TEXT,
|
|
ip TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_user_apps_user ON user_apps(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_user_permissions_user ON user_permissions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user ON refresh_sessions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token_hash);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_id, created_at);
|
|
`)
|
|
|
|
// Existing DBs: CREATE TABLE IF NOT EXISTS skips schema — ALTER before indexes on new cols.
|
|
const settingsCols = sqlite
|
|
.prepare(`PRAGMA table_info(portal_settings)`)
|
|
.all() as Array<{ name: string }>
|
|
if (!settingsCols.some((c) => c.name === 'audit_retention_days')) {
|
|
sqlite.exec(
|
|
`ALTER TABLE portal_settings ADD COLUMN audit_retention_days INTEGER NOT NULL DEFAULT 90`,
|
|
)
|
|
}
|
|
|
|
const userCols = sqlite
|
|
.prepare(`PRAGMA table_info(users)`)
|
|
.all() as Array<{ name: string }>
|
|
if (!userCols.some((c) => c.name === 'last_login_at')) {
|
|
sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_at TEXT`)
|
|
}
|
|
if (!userCols.some((c) => c.name === 'last_login_ip')) {
|
|
sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_ip TEXT`)
|
|
}
|
|
|
|
const sessionCols = sqlite
|
|
.prepare(`PRAGMA table_info(refresh_sessions)`)
|
|
.all() as Array<{ name: string }>
|
|
if (!sessionCols.some((c) => c.name === 'ip')) {
|
|
sqlite.exec(`ALTER TABLE refresh_sessions ADD COLUMN ip TEXT`)
|
|
}
|
|
if (!sessionCols.some((c) => c.name === 'user_agent')) {
|
|
sqlite.exec(`ALTER TABLE refresh_sessions ADD COLUMN user_agent TEXT`)
|
|
}
|
|
|
|
const auditCols = sqlite
|
|
.prepare(`PRAGMA table_info(audit_log)`)
|
|
.all() as Array<{ name: string }>
|
|
if (!auditCols.some((c) => c.name === 'event_id')) {
|
|
sqlite.exec(`ALTER TABLE audit_log ADD COLUMN event_id TEXT`)
|
|
}
|
|
if (!auditCols.some((c) => c.name === 'source_app')) {
|
|
sqlite.exec(
|
|
`ALTER TABLE audit_log ADD COLUMN source_app TEXT NOT NULL DEFAULT 'portal'`,
|
|
)
|
|
}
|
|
sqlite.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_source ON audit_log(source_app, created_at);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log(event_id) WHERE event_id IS NOT NULL;
|
|
`)
|
|
|
|
sqlite.exec(`
|
|
CREATE TABLE IF NOT EXISTS oidc_clients (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
client_id TEXT NOT NULL UNIQUE,
|
|
client_secret_hash TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
redirect_uris_json TEXT NOT NULL,
|
|
scopes_json TEXT NOT NULL,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS oidc_auth_codes (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
code_hash TEXT NOT NULL UNIQUE,
|
|
client_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
redirect_uri TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
nonce TEXT,
|
|
code_challenge TEXT,
|
|
code_challenge_method TEXT,
|
|
expires_at TEXT NOT NULL,
|
|
used_at TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS oidc_signing_keys (
|
|
kid TEXT PRIMARY KEY NOT NULL,
|
|
private_pem TEXT NOT NULL,
|
|
public_jwk_json TEXT NOT NULL,
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_client ON oidc_auth_codes(client_id);
|
|
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_user ON oidc_auth_codes(user_id);
|
|
`)
|
|
|
|
sqlite.exec(`
|
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
credential_id TEXT NOT NULL UNIQUE,
|
|
public_key TEXT NOT NULL,
|
|
counter INTEGER NOT NULL DEFAULT 0,
|
|
device_type TEXT,
|
|
backed_up INTEGER NOT NULL DEFAULT 0,
|
|
transports_json TEXT,
|
|
name TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
last_used_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
|
purpose TEXT NOT NULL,
|
|
challenge TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
|
|
ON webauthn_credentials(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
|
|
ON webauthn_challenges(expires_at);
|
|
`)
|
|
}
|
|
|
|
/** Идемпотентное закрытие: checkpoint WAL, обновление статистики планировщика, close. */
|
|
export function closeDb(sqlite: Sqlite): void {
|
|
if (!sqlite.open) return
|
|
try {
|
|
if (!sqlite.memory) sqlite.pragma('wal_checkpoint(TRUNCATE)')
|
|
sqlite.pragma('optimize')
|
|
} finally {
|
|
sqlite.close()
|
|
}
|
|
}
|
|
|
|
export function healthCheck(sqlite: Sqlite): void {
|
|
sqlite.prepare('SELECT 1').get()
|
|
}
|
|
|
|
export * from './schema/index.js'
|
|
export * from './tx.js'
|
|
export * from './backup.js'
|
|
export * from './generation.js'
|
|
export * from './housekeeping.js'
|
|
export * from './users.js'
|
|
export * from './settings.js'
|
|
export * from './audit-log.js'
|
|
export * from './oidc.js'
|
|
export * from './webauthn.js'
|