163 lines
5.8 KiB
JavaScript
163 lines
5.8 KiB
JavaScript
import Database from 'better-sqlite3';
|
|
import bcrypt from 'bcryptjs';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
/**
|
|
* SQLite DB 연결 및 스키마 초기화
|
|
* @param {string} dbPath
|
|
* @returns {import('better-sqlite3').Database}
|
|
*/
|
|
export function initDb(dbPath) {
|
|
const dir = path.dirname(dbPath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
const db = new Database(dbPath);
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT NOT NULL UNIQUE,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
display_name TEXT,
|
|
avatar_url TEXT,
|
|
role TEXT NOT NULL DEFAULT 'user',
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
last_login_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS decks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
share_code TEXT NOT NULL UNIQUE,
|
|
share_type TEXT NOT NULL DEFAULT 'user',
|
|
content_hash TEXT,
|
|
user_id INTEGER,
|
|
series TEXT NOT NULL,
|
|
series_name TEXT,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
cards_json TEXT NOT NULL,
|
|
is_public INTEGER NOT NULL DEFAULT 1,
|
|
view_count INTEGER NOT NULL DEFAULT 0,
|
|
expires_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_decks_user_id ON decks(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_decks_content_hash ON decks(content_hash);
|
|
|
|
CREATE TABLE IF NOT EXISTS deck_favorites (
|
|
user_id INTEGER NOT NULL,
|
|
share_code TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
PRIMARY KEY (user_id, share_code),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (share_code) REFERENCES decks(share_code) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_deck_favorites_share_code ON deck_favorites(share_code);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
type TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
expires_at TEXT NOT NULL,
|
|
used_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_type
|
|
ON auth_tokens(user_id, type);
|
|
`);
|
|
|
|
const deckCols = db.prepare('PRAGMA table_info(decks)').all();
|
|
if (!deckCols.some((c) => c.name === 'preview_image')) {
|
|
db.exec('ALTER TABLE decks ADD COLUMN preview_image TEXT');
|
|
}
|
|
|
|
const userCols = db.prepare('PRAGMA table_info(users)').all();
|
|
if (!userCols.some((c) => c.name === 'default_deck_public')) {
|
|
db.exec('ALTER TABLE users ADD COLUMN default_deck_public INTEGER NOT NULL DEFAULT 1');
|
|
}
|
|
if (!userCols.some((c) => c.name === 'skip_deck_public_prompt')) {
|
|
db.exec('ALTER TABLE users ADD COLUMN skip_deck_public_prompt INTEGER NOT NULL DEFAULT 0');
|
|
}
|
|
if (!userCols.some((c) => c.name === 'last_login_at')) {
|
|
db.exec('ALTER TABLE users ADD COLUMN last_login_at TEXT');
|
|
}
|
|
if (!userCols.some((c) => c.name === 'email_verified_at')) {
|
|
db.exec('ALTER TABLE users ADD COLUMN email_verified_at TEXT');
|
|
db.exec(`
|
|
UPDATE users
|
|
SET email_verified_at = COALESCE(updated_at, created_at, datetime('now'))
|
|
WHERE email_verified_at IS NULL
|
|
`);
|
|
}
|
|
|
|
const usernameCaseConflicts = db.prepare(`
|
|
SELECT lower(username) AS normalized_username, COUNT(*) AS count
|
|
FROM users
|
|
GROUP BY lower(username)
|
|
HAVING COUNT(*) > 1
|
|
LIMIT 1
|
|
`).get();
|
|
if (!usernameCaseConflicts) {
|
|
db.exec(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_nocase
|
|
ON users(username COLLATE NOCASE)
|
|
`);
|
|
} else {
|
|
console.warn(
|
|
'대소문자만 다른 기존 사용자명이 있어 NOCASE 고유 인덱스를 만들지 못했습니다. 관리자 확인이 필요합니다.',
|
|
);
|
|
}
|
|
|
|
return db;
|
|
}
|
|
|
|
/**
|
|
* 환경변수 기준 관리자 계정 시드
|
|
* ADMIN_EMAIL로 이미 가입한 경우 역할만 admin으로 승격한다.
|
|
* @param {import('better-sqlite3').Database} db
|
|
*/
|
|
export function seedAdmin(db) {
|
|
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
|
const password = process.env.ADMIN_PASSWORD;
|
|
const username = process.env.ADMIN_USERNAME || 'admin';
|
|
|
|
if (!email || !password) return;
|
|
|
|
const existingAdmin = db.prepare('SELECT id FROM users WHERE role = ?').get('admin');
|
|
if (existingAdmin) return;
|
|
|
|
const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
|
if (existingUser) {
|
|
db.prepare(`
|
|
UPDATE users
|
|
SET role = 'admin',
|
|
email_verified_at = COALESCE(email_verified_at, datetime('now')),
|
|
updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`).run(existingUser.id);
|
|
return;
|
|
}
|
|
|
|
const hash = bcrypt.hashSync(password, 12);
|
|
db.prepare(`
|
|
INSERT INTO users (
|
|
email, username, password_hash, display_name, role, email_verified_at
|
|
)
|
|
VALUES (?, ?, ?, ?, 'admin', datetime('now'))
|
|
`).run(email, username, hash, '관리자');
|
|
}
|