회원과 곡 보관함 기능을 추가한다
회원 가입과 로그인 및 등급별 접근과 공개 범위를 구현했다.`nMIDI 업로드와 검색 및 같은 곡의 버전 묶음을 추가했다.`n비밀번호 변경과 회원 탈퇴 및 관리자 회원 관리를 포함했다.`n직접 만든 공개 샘플 MIDI 네 곡과 운영 문서를 추가했다.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user