Files
ITOSMPiano-BachToBasics/backend/library_db.py
T
이창현 a41da4029f 회원과 곡 보관함 기능을 추가한다
회원 가입과 로그인 및 등급별 접근과 공개 범위를 구현했다.`nMIDI 업로드와 검색 및 같은 곡의 버전 묶음을 추가했다.`n비밀번호 변경과 회원 탈퇴 및 관리자 회원 관리를 포함했다.`n직접 만든 공개 샘플 MIDI 네 곡과 운영 문서를 추가했다.
2026-10-03 10:12:17 +09:00

268 lines
11 KiB
Python

from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import secrets
import shutil
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Iterator
DATA_ROOT = Path(os.environ.get("LIBRARY_DATA_DIR", "/data"))
DATABASE_PATH = DATA_ROOT / "library.sqlite3"
MIDI_ROOT = DATA_ROOT / "midi"
SESSION_DAYS = int(os.environ.get("SESSION_DAYS", "30"))
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@contextmanager
def connect() -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(DATABASE_PATH, timeout=20)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA journal_mode = WAL")
try:
yield connection
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32)
return "scrypt$16384$8$1$%s$%s" % (
base64.urlsafe_b64encode(salt).decode("ascii"),
base64.urlsafe_b64encode(digest).decode("ascii"),
)
def verify_password(password: str, stored: str) -> bool:
try:
algorithm, n, r, p, salt_text, digest_text = stored.split("$", 5)
if algorithm != "scrypt":
return False
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
expected = base64.urlsafe_b64decode(digest_text.encode("ascii"))
actual = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=int(n), r=int(r), p=int(p), dklen=len(expected)
)
return hmac.compare_digest(actual, expected)
except (ValueError, TypeError):
return False
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_session(user_id: int) -> tuple[str, str]:
token = secrets.token_urlsafe(36)
csrf_token = secrets.token_urlsafe(24)
expires_at = (datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)).isoformat()
with connect() as connection:
connection.execute("DELETE FROM sessions WHERE expires_at <= ?", (utc_now(),))
connection.execute(
"INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
(token_digest(token), user_id, csrf_token, expires_at, utc_now()),
)
return token, csrf_token
def get_user_for_session(token: str | None) -> dict | None:
if not token:
return None
with connect() as connection:
row = connection.execute(
"""
SELECT u.id, u.username, u.display_name, u.email, u.tier, u.role, u.status,
s.csrf_token, s.expires_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ?
""",
(token_digest(token), utc_now()),
).fetchone()
if row is None or row["status"] != "active":
return None
return dict(row)
def delete_session(token: str | None) -> None:
if not token:
return
with connect() as connection:
connection.execute("DELETE FROM sessions WHERE token_hash = ?", (token_digest(token),))
SAMPLES = (
{
"storage_name": "first-snow-melody.mid",
"title": "첫눈이 내리는 밤",
"artist": "ITOSM 연습곡",
"description": "오른손으로 천천히 익히는 다장조 멜로디 연습곡",
"difficulty": "beginner",
"genre": "연습곡",
"arrangement": "melody",
"tags": ["다장조", "오른손", "겨울", "입문"],
},
{
"storage_name": "first-snow-accompaniment.mid",
"title": "첫눈이 내리는 밤",
"artist": "ITOSM 연습곡",
"description": "같은 곡을 양손 반주형으로 편곡한 중급 연습 버전",
"difficulty": "intermediate",
"genre": "연습곡",
"arrangement": "accompaniment",
"tags": ["다장조", "양손", "반주", "겨울"],
},
{
"storage_name": "blue-breeze-walk.mid",
"title": "파란 바람 산책",
"artist": "ITOSM 연습곡",
"description": "가벼운 리듬으로 연주하는 쉬운 멜로디",
"difficulty": "easy",
"genre": "가요풍",
"arrangement": "melody",
"tags": ["멜로디", "리듬", "쉬움", "가요풍"],
},
{
"storage_name": "little-morning-waltz.mid",
"title": "아침의 작은 왈츠",
"artist": "ITOSM 연습곡",
"description": "3박자와 왼손 화음을 함께 익히는 피아노 독주곡",
"difficulty": "intermediate",
"genre": "왈츠",
"arrangement": "piano_solo",
"tags": ["3박자", "왈츠", "양손", "화음"],
},
)
def _seed_samples() -> None:
source_root = Path(__file__).with_name("sample_midi")
with connect() as connection:
for sample in SAMPLES:
source = source_root / sample["storage_name"]
if not source.exists():
continue
target = MIDI_ROOT / sample["storage_name"]
if not target.exists():
shutil.copyfile(source, target)
existing = connection.execute(
"SELECT id FROM midi_items WHERE seed_key = ?", (sample["storage_name"],)
).fetchone()
if existing:
continue
content = target.read_bytes()
connection.execute(
"""
INSERT INTO midi_items (
id, owner_id, seed_key, title, artist, description, original_name,
storage_name, size_bytes, sha256, visibility, min_tier, difficulty,
genre, arrangement, tags_json, created_at, updated_at
) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'public', 0, ?, ?, ?, ?, ?, ?)
""",
(
secrets.token_hex(16), sample["storage_name"], sample["title"], sample["artist"],
sample["description"], sample["storage_name"], sample["storage_name"], len(content),
hashlib.sha256(content).hexdigest(), sample["difficulty"], sample["genre"],
sample["arrangement"], json.dumps(sample["tags"], ensure_ascii=False), utc_now(), utc_now(),
),
)
def initialize_database() -> None:
DATA_ROOT.mkdir(parents=True, exist_ok=True)
MIDI_ROOT.mkdir(parents=True, exist_ok=True)
DATA_ROOT.chmod(0o750)
MIDI_ROOT.chmod(0o750)
with connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL,
email TEXT UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
tier INTEGER NOT NULL DEFAULT 1 CHECK (tier BETWEEN 1 AND 4),
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'moderator', 'admin')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS midi_items (
id TEXT PRIMARY KEY,
owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
seed_key TEXT UNIQUE,
title TEXT NOT NULL,
artist TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
original_name TEXT NOT NULL,
storage_name TEXT NOT NULL UNIQUE,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
visibility TEXT NOT NULL CHECK (visibility IN ('public', 'members', 'shared', 'private')),
min_tier INTEGER NOT NULL DEFAULT 0 CHECK (min_tier BETWEEN 0 AND 4),
difficulty TEXT NOT NULL CHECK (difficulty IN ('beginner', 'easy', 'intermediate', 'advanced', 'expert')),
genre TEXT NOT NULL DEFAULT '',
arrangement TEXT NOT NULL CHECK (arrangement IN ('melody', 'accompaniment', 'piano_solo', 'duet', 'exercise')),
tags_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_midi_title ON midi_items(title COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_midi_owner ON midi_items(owner_id);
CREATE TABLE IF NOT EXISTS midi_shares (
midi_id TEXT NOT NULL REFERENCES midi_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (midi_id, user_id)
);
"""
)
DATABASE_PATH.chmod(0o600)
admin_username = os.environ.get("BOOTSTRAP_ADMIN_USERNAME", "").strip()
password_file = os.environ.get("BOOTSTRAP_ADMIN_PASSWORD_FILE", "").strip()
if admin_username and password_file:
existing = connection.execute(
"SELECT id FROM users WHERE username = ? COLLATE NOCASE", (admin_username,)
).fetchone()
if existing is None:
password = Path(password_file).read_text(encoding="utf-8").strip()
if len(password) < 10:
raise RuntimeError("초기 관리자 비밀번호는 10자 이상이어야 합니다")
connection.execute(
"""
INSERT INTO users (
username, display_name, password_hash, tier, role, status, created_at
) VALUES (?, ?, ?, 4, 'admin', 'active', ?)
""",
(
admin_username,
os.environ.get("BOOTSTRAP_ADMIN_DISPLAY_NAME", "관리자").strip() or "관리자",
hash_password(password),
utc_now(),
),
)
_seed_samples()