diff --git a/.gitignore b/.gitignore index 158185a..d3153d1 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ lerna-debug.log* # ── Docker / runtime data ───────────────────────────────────────────────────── .docker-data/ +/data/ *.pid # ── Misc ────────────────────────────────────────────────────────────────────── diff --git a/INSTALL-KO.md b/INSTALL-KO.md index 57eafd2..7e6c507 100644 --- a/INSTALL-KO.md +++ b/INSTALL-KO.md @@ -13,6 +13,7 @@ | 기본 주소 | `https://learn.itosm.co.kr` | | 별칭 주소 | `https://learn.itosm.com` | | MusicXML 보관 폴더 | `/home/syskiller/piano/music/musicxml` | +| 회원 DB와 MIDI 보관 | `/home/syskiller/piano/bach-to-basics/data` | 백엔드는 호스트 포트를 열지 않는다. 프론트엔드 nginx만 Docker 내부 네트워크에서 백엔드에 접근한다. 외부 요청은 aaPanel nginx와 프론트엔드 nginx를 차례로 거친다. @@ -42,6 +43,8 @@ docker compose down 프론트엔드 컨테이너가 비밀 파일을 읽어 백엔드로 보내는 내부 요청에만 인증 헤더를 추가한다. +회원용 비밀번호 원문은 저장하지 않는다. 초기 관리자 비밀번호만 `secrets/library_admin_password`에 두고 파일 권한을 600으로 유지한다. 회원 DB와 업로드 MIDI는 `data`에 보관하며 Git에서 제외한다. 상세 권한과 백업 방법은 `docs/LIBRARY-AUTH-KO.md`에 기록했다. + ## 업데이트 1. `git status --short --branch`로 로컬 변경을 확인한다. @@ -83,4 +86,5 @@ MIDI와 MusicXML은 브라우저 파일 선택이나 끌어 놓기로 불러온 - MusicXML과 MIDI 및 기본 악보 변환 기능은 설치되어 있다. - 실제 USB MIDI 입력과 따라치기 동작은 안드로이드 Chrome과 실제 전자피아노에서 최종 확인해야 한다. - iPhone과 iPad의 Safari는 Web MIDI를 지원하지 않는다. -- 관리자 곡 업로드와 사용자 곡 목록 선택은 기본 운영 안정화 뒤 진행할 후속 기능이다. +- 회원 가입과 로그인 및 등급별 곡 보관함을 제공한다. 회원은 MIDI를 올리고 전체 공개와 회원 공개 및 지정 공유와 나만 보기 중 하나를 선택할 수 있다. +- 회원은 현재 비밀번호를 확인한 뒤 비밀번호를 변경하거나 탈퇴할 수 있다. diff --git a/backend/library_db.py b/backend/library_db.py new file mode 100644 index 0000000..d8fe92f --- /dev/null +++ b/backend/library_db.py @@ -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() diff --git a/backend/main.py b/backend/main.py index a92e0ea..cbfff4e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -33,7 +33,8 @@ except ImportError: from fastapi import Depends, FastAPI, HTTPException, Request, Response, Security from fastapi.middleware.cors import CORSMiddleware from fastapi.security import APIKeyHeader -from routers import export_routes, fingering, omr, transcribe, youtube +from library_db import initialize_database +from routers import export_routes, fingering, library, omr, transcribe, youtube logging.basicConfig(level=logging.WARNING) @@ -106,6 +107,7 @@ def _startup_checks() -> None: @asynccontextmanager async def _lifespan(_app: FastAPI): _startup_checks() + initialize_database() yield @@ -131,9 +133,9 @@ _allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origins=_allowed_origins, - allow_credentials=False, - allow_methods=["GET", "POST"], - allow_headers=["Content-Type", "X-API-Key"], + allow_credentials=True, + allow_methods=["GET", "POST", "PATCH", "DELETE"], + allow_headers=["Content-Type", "X-API-Key", "X-CSRF-Token"], ) # ── Security-headers middleware ─────────────────────────────────────────────── @@ -266,6 +268,7 @@ app.include_router(omr.router, prefix="/omr", tags=["omr"]) app.include_router(youtube.router, prefix="/youtube", tags=["youtube"]) app.include_router(fingering.router, prefix="/fingering", tags=["fingering"]) app.include_router(export_routes.router, prefix="/export", tags=["export"]) +app.include_router(library.router, prefix="/library", tags=["library"]) @app.get("/health") diff --git a/backend/routers/library.py b/backend/routers/library.py new file mode 100644 index 0000000..4b53ef1 --- /dev/null +++ b/backend/routers/library.py @@ -0,0 +1,593 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import re +import secrets +from pathlib import Path +from urllib.parse import quote + +from fastapi import APIRouter, Cookie, File, Form, HTTPException, Query, Request, Response, UploadFile +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from library_db import ( + MIDI_ROOT, + connect, + create_session, + delete_session, + get_user_for_session, + hash_password, + utc_now, + verify_password, +) + + +router = APIRouter() +COOKIE_NAME = "b2b_session" +MAX_MIDI_BYTES = 10 * 1024 * 1024 +MAX_USER_FILES = 100 +MAX_USER_BYTES = 500 * 1024 * 1024 +USERNAME_RE = re.compile(r"^[A-Za-z0-9_-]{3,30}$") +DIFFICULTIES = {"beginner", "easy", "intermediate", "advanced", "expert"} +ARRANGEMENTS = {"melody", "accompaniment", "piano_solo", "duet", "exercise"} +VISIBILITIES = {"public", "members", "shared", "private"} + + +class AuthPayload(BaseModel): + username: str = Field(min_length=3, max_length=30) + password: str = Field(min_length=10, max_length=128) + + +class RegisterPayload(AuthPayload): + display_name: str = Field(min_length=2, max_length=30) + email: str | None = Field(default=None, max_length=120) + + +class PasswordChangePayload(BaseModel): + current_password: str = Field(min_length=1, max_length=128) + new_password: str = Field(min_length=10, max_length=128) + + +class WithdrawPayload(BaseModel): + password: str = Field(min_length=1, max_length=128) + confirmation: str + + +class MidiUpdatePayload(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=120) + artist: str | None = Field(default=None, max_length=80) + description: str | None = Field(default=None, max_length=500) + visibility: str | None = None + min_tier: int | None = Field(default=None, ge=0, le=4) + difficulty: str | None = None + genre: str | None = Field(default=None, max_length=40) + arrangement: str | None = None + tags: list[str] | None = None + shared_usernames: list[str] | None = None + + +class UserUpdatePayload(BaseModel): + tier: int | None = Field(default=None, ge=1, le=4) + role: str | None = None + status: str | None = None + + +def _public_user(user: dict | None) -> dict | None: + if user is None: + return None + return { + "id": user["id"], + "username": user["username"], + "display_name": user["display_name"], + "email": user.get("email"), + "tier": user["tier"], + "role": user["role"], + } + + +def _session_user(session_token: str | None) -> dict | None: + return get_user_for_session(session_token) + + +def _require_user(session_token: str | None) -> dict: + user = _session_user(session_token) + if user is None: + raise HTTPException(status_code=401, detail="로그인이 필요합니다") + return user + + +def _require_csrf(request: Request, user: dict) -> None: + supplied = request.headers.get("x-csrf-token", "") + if not supplied or not hmac.compare_digest(supplied, user["csrf_token"]): + raise HTTPException(status_code=403, detail="요청 확인 값이 올바르지 않습니다") + + +def _require_admin(session_token: str | None) -> dict: + user = _require_user(session_token) + if user["role"] != "admin": + raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다") + return user + + +def _normalize_tags(tags: str | list[str]) -> list[str]: + values = tags.split(",") if isinstance(tags, str) else tags + cleaned: list[str] = [] + for value in values: + item = value.strip() + if not item or item in cleaned: + continue + if len(item) > 30: + raise HTTPException(status_code=400, detail="태그는 30자 이하로 입력하세요") + cleaned.append(item) + if len(cleaned) > 12: + raise HTTPException(status_code=400, detail="태그는 최대 12개까지 사용할 수 있습니다") + return cleaned + + +def _validate_metadata(visibility: str, min_tier: int, difficulty: str, arrangement: str) -> None: + if visibility not in VISIBILITIES: + raise HTTPException(status_code=400, detail="공개 범위가 올바르지 않습니다") + if difficulty not in DIFFICULTIES: + raise HTTPException(status_code=400, detail="난이도가 올바르지 않습니다") + if arrangement not in ARRANGEMENTS: + raise HTTPException(status_code=400, detail="편곡 형태가 올바르지 않습니다") + if not 0 <= min_tier <= 4: + raise HTTPException(status_code=400, detail="회원 등급 범위가 올바르지 않습니다") + if visibility == "public" and min_tier != 0: + raise HTTPException(status_code=400, detail="전체 공개 곡은 등급 제한을 사용할 수 없습니다") + if visibility != "public" and min_tier < 1: + raise HTTPException(status_code=400, detail="회원 전용 곡은 최소 등급이 1 이상이어야 합니다") + + +def _resolve_share_ids(connection, usernames: list[str]) -> list[int]: + normalized = [name.strip() for name in usernames if name.strip()] + if not normalized: + return [] + placeholders = ",".join("?" for _ in normalized) + rows = connection.execute( + f"SELECT id, username FROM users WHERE username COLLATE NOCASE IN ({placeholders}) AND status = 'active'", + normalized, + ).fetchall() + found = {row["username"].lower(): row["id"] for row in rows} + missing = [name for name in normalized if name.lower() not in found] + if missing: + raise HTTPException(status_code=400, detail="존재하지 않는 회원: " + ", ".join(missing)) + return list(dict.fromkeys(found.values())) + + +def _can_access(row: dict, user: dict | None, shared_ids: set[int]) -> bool: + if user and user["role"] == "admin": + return True + if user and row["owner_id"] == user["id"]: + return True + if row["visibility"] == "public": + return True + if user is None or user["tier"] < row["min_tier"]: + return False + if row["visibility"] == "members": + return True + if row["visibility"] == "shared": + return user["id"] in shared_ids + return False + + +def _serialize_item(row: dict, user: dict | None, shared_usernames: list[str]) -> dict: + tags = json.loads(row["tags_json"] or "[]") + can_edit = bool(user and (user["role"] == "admin" or row["owner_id"] == user["id"])) + return { + "id": row["id"], + "title": row["title"], + "artist": row["artist"], + "description": row["description"], + "original_name": row["original_name"], + "size_bytes": row["size_bytes"], + "visibility": row["visibility"], + "min_tier": row["min_tier"], + "difficulty": row["difficulty"], + "genre": row["genre"], + "arrangement": row["arrangement"], + "tags": tags, + "owner": row["owner_name"] or "ITOSM 피아노 학습실", + "owner_username": row["owner_username"], + "shared_usernames": shared_usernames if can_edit else [], + "can_edit": can_edit, + "created_at": row["created_at"], + "group_key": f"{row['artist'].strip().lower()}::{row['title'].strip().lower()}", + } + + +def _load_item(item_id: str) -> tuple[dict, set[int], list[str]]: + with connect() as connection: + row = connection.execute( + """ + SELECT m.*, u.display_name AS owner_name, u.username AS owner_username + FROM midi_items m LEFT JOIN users u ON u.id = m.owner_id + WHERE m.id = ? + """, + (item_id,), + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="곡을 찾을 수 없습니다") + shares = connection.execute( + "SELECT s.user_id, u.username FROM midi_shares s JOIN users u ON u.id = s.user_id WHERE s.midi_id = ?", + (item_id,), + ).fetchall() + return dict(row), {share["user_id"] for share in shares}, [share["username"] for share in shares] + + +@router.post("/auth/register") +def register(payload: RegisterPayload, response: Response): + username = payload.username.strip() + display_name = payload.display_name.strip() + email = payload.email.strip().lower() if payload.email else None + if not USERNAME_RE.fullmatch(username): + raise HTTPException(status_code=400, detail="아이디는 영문과 숫자 및 밑줄과 하이픈으로 3~30자까지 입력하세요") + if email and ("@" not in email or email.startswith("@") or email.endswith("@")): + raise HTTPException(status_code=400, detail="이메일 형식이 올바르지 않습니다") + with connect() as connection: + exists = connection.execute( + "SELECT 1 FROM users WHERE username = ? COLLATE NOCASE OR (? IS NOT NULL AND email = ? COLLATE NOCASE)", + (username, email, email), + ).fetchone() + if exists: + raise HTTPException(status_code=409, detail="이미 사용 중인 아이디 또는 이메일입니다") + cursor = connection.execute( + "INSERT INTO users (username, display_name, email, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", + (username, display_name, email, hash_password(payload.password), utc_now()), + ) + user_id = cursor.lastrowid + token, csrf_token = create_session(user_id) + _set_cookie(response, token) + user = get_user_for_session(token) + return {"user": _public_user(user), "csrf_token": csrf_token} + + +@router.post("/auth/login") +def login(payload: AuthPayload, response: Response): + with connect() as connection: + row = connection.execute( + "SELECT * FROM users WHERE username = ? COLLATE NOCASE", (payload.username.strip(),) + ).fetchone() + if row is None or not verify_password(payload.password, row["password_hash"]): + raise HTTPException(status_code=401, detail="아이디 또는 비밀번호가 올바르지 않습니다") + if row["status"] != "active": + raise HTTPException(status_code=403, detail="사용이 중지된 계정입니다") + token, csrf_token = create_session(row["id"]) + _set_cookie(response, token) + user = get_user_for_session(token) + return {"user": _public_user(user), "csrf_token": csrf_token} + + +def _set_cookie(response: Response, token: str) -> None: + response.set_cookie( + COOKIE_NAME, + token, + max_age=30 * 24 * 60 * 60, + httponly=True, + secure=True, + samesite="lax", + path="/", + ) + + +@router.get("/auth/me") +def me(session_token: str | None = Cookie(default=None, alias=COOKIE_NAME)): + user = _session_user(session_token) + return {"user": _public_user(user), "csrf_token": user["csrf_token"] if user else None} + + +@router.post("/auth/logout") +def logout(request: Request, response: Response, session_token: str | None = Cookie(default=None, alias=COOKIE_NAME)): + user = _require_user(session_token) + _require_csrf(request, user) + delete_session(session_token) + response.delete_cookie(COOKIE_NAME, path="/") + return {"ok": True} + + +@router.post("/auth/change-password") +def change_password( + payload: PasswordChangePayload, + request: Request, + response: Response, + session_token: str | None = Cookie(default=None, alias=COOKIE_NAME), +): + user = _require_user(session_token) + _require_csrf(request, user) + with connect() as connection: + row = connection.execute("SELECT password_hash FROM users WHERE id = ?", (user["id"],)).fetchone() + if row is None or not verify_password(payload.current_password, row["password_hash"]): + raise HTTPException(status_code=400, detail="현재 비밀번호가 올바르지 않습니다") + if verify_password(payload.new_password, row["password_hash"]): + raise HTTPException(status_code=400, detail="새 비밀번호는 현재 비밀번호와 달라야 합니다") + connection.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + (hash_password(payload.new_password), user["id"]), + ) + connection.execute("DELETE FROM sessions WHERE user_id = ?", (user["id"],)) + response.delete_cookie(COOKIE_NAME, path="/") + return {"ok": True, "message": "비밀번호가 변경되었습니다. 다시 로그인해 주세요."} + + +@router.post("/auth/withdraw") +def withdraw( + payload: WithdrawPayload, + request: Request, + response: Response, + session_token: str | None = Cookie(default=None, alias=COOKIE_NAME), +): + user = _require_user(session_token) + _require_csrf(request, user) + if payload.confirmation != "회원 탈퇴": + raise HTTPException(status_code=400, detail="확인란에 회원 탈퇴를 정확히 입력하세요") + with connect() as connection: + row = connection.execute("SELECT password_hash FROM users WHERE id = ?", (user["id"],)).fetchone() + if row is None or not verify_password(payload.password, row["password_hash"]): + raise HTTPException(status_code=400, detail="비밀번호가 올바르지 않습니다") + files = connection.execute( + "SELECT storage_name FROM midi_items WHERE owner_id = ?", (user["id"],) + ).fetchall() + connection.execute("DELETE FROM midi_items WHERE owner_id = ?", (user["id"],)) + connection.execute("DELETE FROM users WHERE id = ?", (user["id"],)) + for item in files: + (MIDI_ROOT / item["storage_name"]).unlink(missing_ok=True) + response.delete_cookie(COOKIE_NAME, path="/") + return {"ok": True, "message": "회원 탈퇴가 완료되었습니다"} + + +@router.get("/midi") +def list_midi( + search: str = Query(default="", max_length=100), + genre: str = Query(default="", max_length=40), + difficulty: str = Query(default="", max_length=20), + arrangement: str = Query(default="", max_length=30), + tag: str = Query(default="", max_length=30), + session_token: str | None = Cookie(default=None, alias=COOKIE_NAME), +): + user = _session_user(session_token) + clauses = ["1=1"] + params: list[object] = [] + if search: + clauses.append("(m.title LIKE ? OR m.artist LIKE ? OR m.description LIKE ? OR m.tags_json LIKE ?)") + needle = f"%{search.strip()}%" + params.extend([needle, needle, needle, needle]) + if genre: + clauses.append("m.genre = ?") + params.append(genre) + if difficulty: + clauses.append("m.difficulty = ?") + params.append(difficulty) + if arrangement: + clauses.append("m.arrangement = ?") + params.append(arrangement) + if tag: + clauses.append("m.tags_json LIKE ?") + params.append(f'%"{tag}"%') + with connect() as connection: + rows = connection.execute( + f""" + SELECT m.*, u.display_name AS owner_name, u.username AS owner_username + FROM midi_items m LEFT JOIN users u ON u.id = m.owner_id + WHERE {' AND '.join(clauses)} + ORDER BY m.artist COLLATE NOCASE, m.title COLLATE NOCASE, m.difficulty, m.created_at + """, + params, + ).fetchall() + share_rows = connection.execute("SELECT midi_id, user_id FROM midi_shares").fetchall() + username_rows = connection.execute( + "SELECT s.midi_id, u.username FROM midi_shares s JOIN users u ON u.id = s.user_id" + ).fetchall() + shared_ids: dict[str, set[int]] = {} + shared_names: dict[str, list[str]] = {} + for share in share_rows: + shared_ids.setdefault(share["midi_id"], set()).add(share["user_id"]) + for share in username_rows: + shared_names.setdefault(share["midi_id"], []).append(share["username"]) + items = [] + for source in rows: + row = dict(source) + if _can_access(row, user, shared_ids.get(row["id"], set())): + items.append(_serialize_item(row, user, shared_names.get(row["id"], []))) + facets = { + "genres": sorted({item["genre"] for item in items if item["genre"]}), + "tags": sorted({tag_value for item in items for tag_value in item["tags"]}), + } + return {"items": items, "facets": facets} + + +@router.get("/midi/{item_id}/file") +def download_midi(item_id: str, session_token: str | None = Cookie(default=None, alias=COOKIE_NAME)): + user = _session_user(session_token) + row, shared_ids, _ = _load_item(item_id) + if not _can_access(row, user, shared_ids): + raise HTTPException(status_code=403, detail="이 곡을 사용할 권한이 없습니다") + path = MIDI_ROOT / row["storage_name"] + if not path.is_file() or path.parent.resolve() != MIDI_ROOT.resolve(): + raise HTTPException(status_code=404, detail="MIDI 파일을 찾을 수 없습니다") + encoded = quote(row["original_name"].replace("\r", "").replace("\n", "")) + return FileResponse( + path, + media_type="audio/midi", + headers={"Content-Disposition": f"inline; filename*=UTF-8''{encoded}"}, + ) + + +@router.post("/midi") +async def upload_midi( + request: Request, + file: UploadFile = File(...), + title: str = Form(...), + artist: str = Form(""), + description: str = Form(""), + visibility: str = Form("private"), + min_tier: int = Form(1), + difficulty: str = Form("beginner"), + genre: str = Form(""), + arrangement: str = Form("melody"), + tags: str = Form(""), + shared_usernames: str = Form(""), + session_token: str | None = Cookie(default=None, alias=COOKIE_NAME), +): + user = _require_user(session_token) + _require_csrf(request, user) + clean_title = title.strip() + if not clean_title or len(clean_title) > 120: + raise HTTPException(status_code=400, detail="곡 제목은 1~120자로 입력하세요") + _validate_metadata(visibility, min_tier, difficulty, arrangement) + original_name = Path(file.filename or "upload.mid").name + if Path(original_name).suffix.lower() not in {".mid", ".midi"}: + raise HTTPException(status_code=400, detail="MIDI 파일만 업로드할 수 있습니다") + content = await file.read(MAX_MIDI_BYTES + 1) + if len(content) > MAX_MIDI_BYTES: + raise HTTPException(status_code=413, detail="MIDI 파일은 10MB 이하만 업로드할 수 있습니다") + if len(content) < 14 or not content.startswith(b"MThd"): + raise HTTPException(status_code=400, detail="올바른 MIDI 파일이 아닙니다") + item_id = secrets.token_hex(16) + storage_name = f"{item_id}.mid" + target = MIDI_ROOT / storage_name + tag_values = _normalize_tags(tags) + share_names = [name.strip() for name in shared_usernames.split(",") if name.strip()] + with connect() as connection: + usage = connection.execute( + "SELECT COUNT(*) AS file_count, COALESCE(SUM(size_bytes), 0) AS total_bytes FROM midi_items WHERE owner_id = ?", + (user["id"],), + ).fetchone() + if usage["file_count"] >= MAX_USER_FILES or usage["total_bytes"] + len(content) > MAX_USER_BYTES: + raise HTTPException(status_code=413, detail="회원별 보관 한도를 초과했습니다") + share_ids = _resolve_share_ids(connection, share_names) if visibility == "shared" else [] + target.write_bytes(content) + target.chmod(0o640) + try: + connection.execute( + """ + INSERT INTO midi_items ( + id, owner_id, title, artist, description, original_name, storage_name, + size_bytes, sha256, visibility, min_tier, difficulty, genre, arrangement, + tags_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + item_id, user["id"], clean_title, artist.strip()[:80], description.strip()[:500], + original_name, storage_name, len(content), hashlib.sha256(content).hexdigest(), + visibility, min_tier, difficulty, genre.strip()[:40], arrangement, + json.dumps(tag_values, ensure_ascii=False), utc_now(), utc_now(), + ), + ) + connection.executemany( + "INSERT INTO midi_shares (midi_id, user_id) VALUES (?, ?)", + [(item_id, share_id) for share_id in share_ids], + ) + except Exception: + target.unlink(missing_ok=True) + raise + row, _, names = _load_item(item_id) + return _serialize_item(row, user, names) + + +@router.patch("/midi/{item_id}") +def update_midi( + item_id: str, + payload: MidiUpdatePayload, + request: Request, + session_token: str | None = Cookie(default=None, alias=COOKIE_NAME), +): + user = _require_user(session_token) + _require_csrf(request, user) + row, _, _ = _load_item(item_id) + if user["role"] != "admin" and row["owner_id"] != user["id"]: + raise HTTPException(status_code=403, detail="본인이 올린 곡만 수정할 수 있습니다") + values = payload.model_dump(exclude_unset=True) + visibility = values.get("visibility", row["visibility"]) + min_tier = values.get("min_tier", row["min_tier"]) + difficulty = values.get("difficulty", row["difficulty"]) + arrangement = values.get("arrangement", row["arrangement"]) + _validate_metadata(visibility, min_tier, difficulty, arrangement) + if "tags" in values: + values["tags_json"] = json.dumps(_normalize_tags(values.pop("tags")), ensure_ascii=False) + share_names = values.pop("shared_usernames", None) + allowed_columns = { + "title", "artist", "description", "visibility", "min_tier", "difficulty", + "genre", "arrangement", "tags_json", + } + updates = {key: value.strip() if isinstance(value, str) else value for key, value in values.items() if key in allowed_columns} + if "title" in updates and not updates["title"]: + raise HTTPException(status_code=400, detail="곡 제목은 비워둘 수 없습니다") + with connect() as connection: + if updates: + assignments = ", ".join(f"{column} = ?" for column in updates) + connection.execute( + f"UPDATE midi_items SET {assignments}, updated_at = ? WHERE id = ?", + [*updates.values(), utc_now(), item_id], + ) + if share_names is not None or visibility != "shared": + connection.execute("DELETE FROM midi_shares WHERE midi_id = ?", (item_id,)) + if visibility == "shared": + ids = _resolve_share_ids(connection, share_names or []) + connection.executemany( + "INSERT INTO midi_shares (midi_id, user_id) VALUES (?, ?)", + [(item_id, user_id) for user_id in ids], + ) + updated, _, names = _load_item(item_id) + return _serialize_item(updated, user, names) + + +@router.delete("/midi/{item_id}") +def delete_midi( + item_id: str, + request: Request, + session_token: str | None = Cookie(default=None, alias=COOKIE_NAME), +): + user = _require_user(session_token) + _require_csrf(request, user) + row, _, _ = _load_item(item_id) + if row["seed_key"]: + raise HTTPException(status_code=400, detail="기본 공개 샘플은 삭제할 수 없습니다") + if user["role"] != "admin" and row["owner_id"] != user["id"]: + raise HTTPException(status_code=403, detail="본인이 올린 곡만 삭제할 수 있습니다") + with connect() as connection: + connection.execute("DELETE FROM midi_items WHERE id = ?", (item_id,)) + (MIDI_ROOT / row["storage_name"]).unlink(missing_ok=True) + return {"ok": True} + + +@router.get("/admin/users") +def list_users(session_token: str | None = Cookie(default=None, alias=COOKIE_NAME)): + _require_admin(session_token) + with connect() as connection: + rows = connection.execute( + "SELECT id, username, display_name, email, tier, role, status, created_at FROM users ORDER BY created_at" + ).fetchall() + return {"users": [dict(row) for row in rows]} + + +@router.patch("/admin/users/{user_id}") +def update_user( + user_id: int, + payload: UserUpdatePayload, + request: Request, + session_token: str | None = Cookie(default=None, alias=COOKIE_NAME), +): + admin = _require_admin(session_token) + _require_csrf(request, admin) + values = payload.model_dump(exclude_unset=True) + if values.get("role") not in {None, "member", "moderator", "admin"}: + raise HTTPException(status_code=400, detail="회원 역할이 올바르지 않습니다") + if values.get("status") not in {None, "active", "suspended"}: + raise HTTPException(status_code=400, detail="회원 상태가 올바르지 않습니다") + if user_id == admin["id"] and ("role" in values or values.get("status") == "suspended"): + raise HTTPException(status_code=400, detail="현재 관리자 계정의 역할이나 상태는 직접 변경할 수 없습니다") + if not values: + raise HTTPException(status_code=400, detail="변경할 값이 없습니다") + with connect() as connection: + exists = connection.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone() + if not exists: + raise HTTPException(status_code=404, detail="회원을 찾을 수 없습니다") + assignments = ", ".join(f"{column} = ?" for column in values) + connection.execute(f"UPDATE users SET {assignments} WHERE id = ?", [*values.values(), user_id]) + row = connection.execute( + "SELECT id, username, display_name, email, tier, role, status, created_at FROM users WHERE id = ?", + (user_id,), + ).fetchone() + return dict(row) diff --git a/backend/sample_midi/blue-breeze-walk.mid b/backend/sample_midi/blue-breeze-walk.mid new file mode 100644 index 0000000..22a716a Binary files /dev/null and b/backend/sample_midi/blue-breeze-walk.mid differ diff --git a/backend/sample_midi/first-snow-accompaniment.mid b/backend/sample_midi/first-snow-accompaniment.mid new file mode 100644 index 0000000..cf61a79 Binary files /dev/null and b/backend/sample_midi/first-snow-accompaniment.mid differ diff --git a/backend/sample_midi/first-snow-melody.mid b/backend/sample_midi/first-snow-melody.mid new file mode 100644 index 0000000..6d54da5 Binary files /dev/null and b/backend/sample_midi/first-snow-melody.mid differ diff --git a/backend/sample_midi/little-morning-waltz.mid b/backend/sample_midi/little-morning-waltz.mid new file mode 100644 index 0000000..0c0a23a Binary files /dev/null and b/backend/sample_midi/little-morning-waltz.mid differ diff --git a/backend/tests/test_library_db.py b/backend/tests/test_library_db.py new file mode 100644 index 0000000..bff7b17 --- /dev/null +++ b/backend/tests/test_library_db.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import importlib +import os +import tempfile +import unittest +from pathlib import Path + + +class LibraryDatabaseTest(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + os.environ["LIBRARY_DATA_DIR"] = self.temp.name + os.environ.pop("BOOTSTRAP_ADMIN_USERNAME", None) + os.environ.pop("BOOTSTRAP_ADMIN_PASSWORD_FILE", None) + import library_db + + self.db = importlib.reload(library_db) + self.db.initialize_database() + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_password_hash_is_salted_and_verifiable(self) -> None: + first = self.db.hash_password("safe-password-123") + second = self.db.hash_password("safe-password-123") + self.assertNotEqual(first, second) + self.assertTrue(self.db.verify_password("safe-password-123", first)) + self.assertFalse(self.db.verify_password("wrong-password", first)) + + def test_session_round_trip(self) -> None: + with self.db.connect() as connection: + cursor = connection.execute( + """ + INSERT INTO users (username, display_name, password_hash, created_at) + VALUES ('tester', '테스터', ?, ?) + """, + (self.db.hash_password("safe-password-123"), self.db.utc_now()), + ) + user_id = cursor.lastrowid + token, csrf = self.db.create_session(user_id) + user = self.db.get_user_for_session(token) + self.assertIsNotNone(user) + self.assertEqual(user["username"], "tester") + self.assertEqual(user["csrf_token"], csrf) + self.db.delete_session(token) + self.assertIsNone(self.db.get_user_for_session(token)) + + def test_database_directory_is_private_project_data(self) -> None: + self.assertTrue(Path(self.temp.name, "library.sqlite3").is_file()) + self.assertTrue(Path(self.temp.name, "midi").is_dir()) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tools/generate_sample_midis.py b/backend/tools/generate_sample_midis.py new file mode 100644 index 0000000..6f70c58 --- /dev/null +++ b/backend/tools/generate_sample_midis.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path + + +OUTPUT = Path(__file__).resolve().parents[1] / "sample_midi" + + +def variable_length(value: int) -> bytes: + data = [value & 0x7F] + value >>= 7 + while value: + data.insert(0, (value & 0x7F) | 0x80) + value >>= 7 + return bytes(data) + + +def track(events: list[tuple[int, bytes]], tempo: int = 500_000, program: int = 0) -> bytes: + body = bytearray() + body.extend(b"\x00\xff\x51\x03" + tempo.to_bytes(3, "big")) + body.extend(b"\x00\xff\x58\x04\x04\x02\x18\x08") + body.extend(bytes([0, 0xC0, program])) + for delta, message in events: + body.extend(variable_length(delta)) + body.extend(message) + body.extend(b"\x00\xff\x2f\x00") + return b"MTrk" + len(body).to_bytes(4, "big") + body + + +def midi_file(events: list[tuple[int, bytes]], tempo: int = 500_000) -> bytes: + header = b"MThd" + (6).to_bytes(4, "big") + (0).to_bytes(2, "big") + (1).to_bytes(2, "big") + (480).to_bytes(2, "big") + return header + track(events, tempo) + + +def melody(notes: list[int], beats: int = 1, velocity: int = 82) -> list[tuple[int, bytes]]: + result: list[tuple[int, bytes]] = [] + for note in notes: + result.append((0, bytes([0x90, note, velocity]))) + result.append((480 * beats, bytes([0x80, note, 0]))) + return result + + +def chord_progression(chords: list[list[int]], melody_notes: list[int]) -> list[tuple[int, bytes]]: + result: list[tuple[int, bytes]] = [] + for index, note in enumerate(melody_notes): + chord = chords[(index // 4) % len(chords)] + for chord_note in chord: + result.append((0, bytes([0x90, chord_note, 52]))) + result.append((0, bytes([0x90, note, 86]))) + result.append((480, bytes([0x80, note, 0]))) + for chord_note in chord: + result.append((0, bytes([0x80, chord_note, 0]))) + return result + + +def main() -> None: + OUTPUT.mkdir(parents=True, exist_ok=True) + first_snow = [64, 67, 69, 67, 64, 62, 60, 62, 64, 67, 72, 71, 69, 67, 64, 60] + blue_breeze = [60, 62, 64, 67, 69, 67, 64, 62, 64, 65, 67, 72, 69, 67, 64, 60] + morning_waltz = [64, 67, 72, 71, 67, 64, 62, 65, 69, 67, 64, 62, 60, 64, 67, 72] + (OUTPUT / "first-snow-melody.mid").write_bytes(midi_file(melody(first_snow), 560_000)) + (OUTPUT / "first-snow-accompaniment.mid").write_bytes( + midi_file(chord_progression([[48, 52, 55], [45, 48, 52], [41, 45, 48], [43, 47, 50]], first_snow), 560_000) + ) + (OUTPUT / "blue-breeze-walk.mid").write_bytes(midi_file(melody(blue_breeze), 470_000)) + (OUTPUT / "little-morning-waltz.mid").write_bytes( + midi_file(chord_progression([[48, 52, 55], [43, 47, 50], [45, 48, 52]], morning_waltz), 620_000) + ) + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml index f5248b2..b74ec24 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,11 +27,17 @@ services: - RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-60} - MUSIC21_TIMEOUT_S=${MUSIC21_TIMEOUT_S:-60} - FINGERING_TIMEOUT_S=${FINGERING_TIMEOUT_S:-120} + - LIBRARY_DATA_DIR=/data + - BOOTSTRAP_ADMIN_USERNAME=syskiller + - BOOTSTRAP_ADMIN_DISPLAY_NAME=관리자 + - BOOTSTRAP_ADMIN_PASSWORD_FILE=/run/secrets/library_admin_password volumes: # Mount audiveris.jar from host (optional - PDF import is disabled if absent) - ./backend/bin:/app/bin:ro + - ./data:/data secrets: - backend_api_key + - library_admin_password frontend: build: @@ -52,3 +58,5 @@ services: secrets: backend_api_key: file: ./secrets/backend_api_key + library_admin_password: + file: ./secrets/library_admin_password diff --git a/docs/LIBRARY-AUTH-KO.md b/docs/LIBRARY-AUTH-KO.md new file mode 100644 index 0000000..72441a6 --- /dev/null +++ b/docs/LIBRARY-AUTH-KO.md @@ -0,0 +1,87 @@ +# Bach to Basics 회원과 곡 보관함 운영 + +## 기능 범위 + +Bach to Basics의 기존 파일 가져오기 기능을 유지하면서 서버 곡 보관함을 추가했다. 비회원은 전체 공개 MIDI를 바로 연주할 수 있다. 회원은 MIDI를 올리고 공개 범위와 회원 등급을 지정할 수 있다. + +공개 범위는 다음 네 단계다. + +| 공개 범위 | 접근 대상 | +|---|---| +| 전체 공개 | 비회원 포함 모든 사용자 | +| 회원 공개 | 지정한 최소 등급 이상의 로그인 회원 | +| 지정 회원 공유 | 최소 등급을 만족하며 업로더가 지정한 회원 | +| 나만 보기 | 업로더 본인과 관리자 | + +회원 등급은 일반 회원 1단계와 우수 회원 2단계 및 지도 회원 3단계와 관리자 4단계로 구성한다. 역할은 회원과 운영자 및 관리자로 구분한다. 등급은 곡 접근 범위에 사용하며 역할은 운영 권한에 사용한다. + +## 회원 기능 + +- 회원 가입과 로그인 및 로그아웃 +- 현재 비밀번호를 확인한 뒤 비밀번호 변경 +- 모든 세션을 종료한 뒤 새 비밀번호로 다시 로그인 +- 비밀번호와 확인 문구를 입력한 회원 탈퇴 +- 탈퇴 시 본인이 올린 MIDI와 공유 정보 제거 +- 관리자 화면에서 회원 등급과 역할 및 이용 상태 변경 + +비밀번호는 `scrypt`로 해시한다. 원문은 DB와 로그에 저장하지 않는다. 로그인 세션 토큰도 SHA-256 해시만 DB에 보관한다. 상태 변경 요청에는 로그인 세션과 CSRF 확인 값을 함께 검사한다. + +## MIDI 업로드 + +- 허용 확장자: `.mid`, `.midi` +- 최대 파일 크기: 10MB +- 회원별 최대 파일 수: 100개 +- 회원별 최대 보관 용량: 500MB +- 파일 이름에서 디렉터리 요소 제거 +- `MThd` 헤더 확인 +- 임의 저장 이름과 SHA-256 해시 기록 +- 곡 제목과 아티스트 및 설명 +- 난이도와 장르 및 편곡 형태 +- 최대 12개 검색 태그 +- 전체 공개 곡은 등급 제한 사용 불가 + +저장 위치는 `/home/syskiller/piano/bach-to-basics/data`다. SQLite DB와 MIDI 파일은 Git에 넣지 않는다. DB 파일 권한은 600이며 데이터 디렉터리는 750으로 유지한다. + +## 검색과 버전 묶음 + +곡명과 아티스트 및 설명과 태그를 함께 검색한다. 장르와 난이도 및 편곡 형태와 개별 태그로 결과를 좁힐 수 있다. 같은 아티스트와 같은 제목은 한 묶음으로 표시한다. 묶음 안에서 멜로디와 반주 및 피아노 독주와 듀엣과 연습 패턴을 선택한다. + +## 공개 샘플 + +상업 가요를 복제하지 않고 직접 만든 다음 연습곡을 전체 공개 샘플로 제공한다. + +- 첫눈이 내리는 밤: 입문 멜로디 +- 첫눈이 내리는 밤: 중급 양손 반주 +- 파란 바람 산책: 쉬운 가요풍 멜로디 +- 아침의 작은 왈츠: 중급 피아노 독주 + +같은 제목의 난이도와 편곡 형태를 한 묶음에서 고르는 동작은 `첫눈이 내리는 밤`의 두 버전으로 확인한다. + +## 초기 관리자 + +초기 관리자 아이디는 Compose 환경 변수로 지정한다. 비밀번호는 `secrets/library_admin_password`에서만 읽는다. DB에 같은 아이디가 없을 때만 최초 관리자를 생성한다. 컨테이너 재시작 때 기존 비밀번호를 덮어쓰지 않는다. + +## 백업과 복구 + +다음 항목을 같은 시점에 백업한다. + +- `data/library.sqlite3` +- `data/midi` +- `secrets/library_admin_password` +- 소스 저장소 Git 번들 + +비밀 파일은 일반 소스 백업과 분리하고 권한 600을 유지한다. SQLite를 복사하기 전에는 백엔드의 쓰기 요청을 잠시 막거나 SQLite 온라인 백업 명령을 사용한다. 단순 파일 복사 중 DB가 변경되면 일관된 백업이 아닐 수 있다. + +## 운영 확인 + +배포 후 다음을 검사한다. + +1. 비회원에게 공개 샘플만 표시되는지 확인한다. +2. 회원 가입과 로그인 및 로그아웃을 확인한다. +3. 비공개 곡이 다른 회원과 비회원에게 보이지 않는지 확인한다. +4. 회원 공개와 지정 공유 및 최소 등급 제한을 확인한다. +5. MIDI 업로드와 한글 제목 및 태그 검색을 확인한다. +6. 같은 제목의 멜로디와 반주 버전을 각각 불러온다. +7. 비밀번호 변경 후 기존 세션이 끝나는지 확인한다. +8. 시험 회원 탈퇴 후 계정과 업로드가 제거되는지 확인한다. +9. 모바일 화면에서 검색과 업로드 및 계정 화면이 가로로 넘치지 않는지 확인한다. diff --git a/frontend/src/components/Library/LibraryPanel.tsx b/frontend/src/components/Library/LibraryPanel.tsx new file mode 100644 index 0000000..04bace3 --- /dev/null +++ b/frontend/src/components/Library/LibraryPanel.tsx @@ -0,0 +1,506 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; + + +type User = { + id: number; + username: string; + display_name: string; + email?: string | null; + tier: number; + role: "member" | "moderator" | "admin"; + status?: "active" | "suspended"; + created_at?: string; +}; + +type MidiItem = { + id: string; + title: string; + artist: string; + description: string; + original_name: string; + size_bytes: number; + visibility: "public" | "members" | "shared" | "private"; + min_tier: number; + difficulty: "beginner" | "easy" | "intermediate" | "advanced" | "expert"; + genre: string; + arrangement: "melody" | "accompaniment" | "piano_solo" | "duet" | "exercise"; + tags: string[]; + owner: string; + owner_username: string | null; + shared_usernames: string[]; + can_edit: boolean; + created_at: string; + group_key: string; +}; + +type AuthResult = { user: User | null; csrf_token: string | null }; +type Tab = "browse" | "upload" | "account" | "admin"; + +const TIER_LABELS: Record = { + 0: "비회원", + 1: "일반 회원", + 2: "우수 회원", + 3: "지도 회원", + 4: "관리자", +}; + +const DIFFICULTY_LABELS: Record = { + beginner: "입문", + easy: "쉬움", + intermediate: "중급", + advanced: "고급", + expert: "전문가", +}; + +const ARRANGEMENT_LABELS: Record = { + melody: "멜로디", + accompaniment: "반주", + piano_solo: "피아노 독주", + duet: "연탄·듀엣", + exercise: "연습 패턴", +}; + +const VISIBILITY_LABELS: Record = { + public: "전체 공개", + members: "회원 공개", + shared: "지정 회원 공유", + private: "나만 보기", +}; + +async function apiJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.detail || "요청을 처리하지 못했습니다"); + return data as T; +} + +function authHeaders(csrf: string | null, json = true): HeadersInit { + const headers: Record = {}; + if (json) headers["Content-Type"] = "application/json"; + if (csrf) headers["X-CSRF-Token"] = csrf; + return headers; +} + +export function LibraryPanel({ + open, + onClose, + onLoad, +}: { + open: boolean; + onClose: () => void; + onLoad: (file: File) => void; +}) { + const [tab, setTab] = useState("browse"); + const [user, setUser] = useState(null); + const [csrf, setCsrf] = useState(null); + const [items, setItems] = useState([]); + const [genres, setGenres] = useState([]); + const [tags, setTags] = useState([]); + const [search, setSearch] = useState(""); + const [genre, setGenre] = useState(""); + const [difficulty, setDifficulty] = useState(""); + const [arrangement, setArrangement] = useState(""); + const [activeTag, setActiveTag] = useState(""); + const [message, setMessage] = useState(""); + const [busy, setBusy] = useState(false); + + const refreshAuth = useCallback(async () => { + const result = await apiJson("/api/library/auth/me"); + setUser(result.user); + setCsrf(result.csrf_token); + }, []); + + const refreshItems = useCallback(async () => { + const params = new URLSearchParams(); + if (search.trim()) params.set("search", search.trim()); + if (genre) params.set("genre", genre); + if (difficulty) params.set("difficulty", difficulty); + if (arrangement) params.set("arrangement", arrangement); + if (activeTag) params.set("tag", activeTag); + const result = await apiJson<{ items: MidiItem[]; facets: { genres: string[]; tags: string[] } }>( + `/api/library/midi?${params}` + ); + setItems(result.items); + setGenres(result.facets.genres); + setTags(result.facets.tags); + }, [search, genre, difficulty, arrangement, activeTag]); + + useEffect(() => { + if (!open) return; + refreshAuth().catch((error) => setMessage(error.message)); + }, [open, refreshAuth]); + + useEffect(() => { + if (!open) return; + const timer = window.setTimeout(() => refreshItems().catch((error) => setMessage(error.message)), 180); + return () => window.clearTimeout(timer); + }, [open, refreshItems, user]); + + const groups = useMemo(() => { + const result = new Map(); + for (const item of items) { + const list = result.get(item.group_key) ?? []; + list.push(item); + result.set(item.group_key, list); + } + return [...result.values()]; + }, [items]); + + const run = async (action: () => Promise) => { + setBusy(true); + setMessage(""); + try { + await action(); + } catch (error) { + setMessage(error instanceof Error ? error.message : "요청을 처리하지 못했습니다"); + } finally { + setBusy(false); + } + }; + + const loadItem = (item: MidiItem) => + run(async () => { + const response = await fetch(`/api/library/midi/${item.id}/file`); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.detail || "MIDI 파일을 불러오지 못했습니다"); + } + const blob = await response.blob(); + onLoad(new File([blob], item.original_name, { type: "audio/midi" })); + onClose(); + }); + + const updateVisibility = (item: MidiItem, visibility: MidiItem["visibility"]) => + run(async () => { + let shared_usernames: string[] | undefined; + if (visibility === "shared") { + const text = window.prompt("공유할 회원 아이디를 쉼표로 구분해 입력하세요", item.shared_usernames.join(", ")); + if (text === null) return; + shared_usernames = text.split(",").map((value) => value.trim()).filter(Boolean); + } + await apiJson(`/api/library/midi/${item.id}`, { + method: "PATCH", + headers: authHeaders(csrf), + body: JSON.stringify({ + visibility, + min_tier: visibility === "public" ? 0 : Math.max(1, item.min_tier), + shared_usernames, + }), + }); + await refreshItems(); + setMessage("공개 범위를 변경했습니다"); + }); + + if (!open) return null; + + return ( +
+
+
+
+ 곡 보관함 + {user ? `${user.display_name} · ${TIER_LABELS[user.tier]}` : "비회원은 공개 곡을 바로 연주할 수 있습니다"} +
+ +
+ + + + {message &&
{message}
} + +
+ {tab === "browse" && ( + + )} + {tab === "account" && ( + { + setUser(result.user); + setCsrf(result.csrf_token); + setTab("browse"); + await refreshItems(); + }} + onSignedOut={() => { + setUser(null); + setCsrf(null); + setTab("browse"); + }} + /> + )} + {tab === "upload" && user && ( + { + await refreshItems(); + setTab("browse"); + setMessage("MIDI를 업로드했습니다"); + }} /> + )} + {tab === "admin" && user?.role === "admin" && ( + + )} +
+
+
+ ); +} + +function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { + return ; +} + +function BrowseTab(props: { + groups: MidiItem[][]; + genres: string[]; + tags: string[]; + search: string; + genre: string; + difficulty: string; + arrangement: string; + activeTag: string; + setSearch: (value: string) => void; + setGenre: (value: string) => void; + setDifficulty: (value: string) => void; + setArrangement: (value: string) => void; + setActiveTag: (value: string) => void; + loadItem: (item: MidiItem) => void; + updateVisibility: (item: MidiItem, visibility: MidiItem["visibility"]) => void; + busy: boolean; +}) { + return ( +
+
+ props.setSearch(event.target.value)} placeholder="곡명·가수·태그 검색" /> + + + +
+
+ {props.activeTag && } + {props.tags.slice(0, 20).map((tag) => ( + + ))} +
+ {props.groups.length === 0 &&
조건에 맞는 곡이 없습니다.
} +
+ {props.groups.map((group) => ( +
+
+
{group[0].title}{group[0].artist || "아티스트 미지정"}
+ {group.length}개 버전 +
+
+ {group.map((item) => ( +
+
+ {ARRANGEMENT_LABELS[item.arrangement]} + {DIFFICULTY_LABELS[item.difficulty]} · {item.genre || "장르 미지정"} +

{item.description || "설명이 없습니다"}

+
{item.tags.map((tag) => )}
+ {VISIBILITY_LABELS[item.visibility]} · {item.owner}{item.min_tier > 1 ? ` · ${TIER_LABELS[item.min_tier]} 이상` : ""} +
+
+ + {item.can_edit && ( + + )} +
+
+ ))} +
+
+ ))} +
+
+ ); +} + +function AccountTab(props: { + user: User | null; + csrf: string | null; + busy: boolean; + run: (action: () => Promise) => Promise; + onAuthenticated: (result: AuthResult) => Promise; + onSignedOut: () => void; +}) { + const [mode, setMode] = useState<"login" | "register">("login"); + const [username, setUsername] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [withdrawPassword, setWithdrawPassword] = useState(""); + const [withdrawConfirmation, setWithdrawConfirmation] = useState(""); + + if (!props.user) { + return ( +
+
+ + +
+
{ + event.preventDefault(); + props.run(async () => { + const body = mode === "login" ? { username, password } : { username, password, display_name: displayName, email: email || null }; + const result = await apiJson(`/api/library/auth/${mode === "login" ? "login" : "register"}`, { + method: "POST", headers: authHeaders(null), body: JSON.stringify(body), + }); + setPassword(""); + await props.onAuthenticated(result); + }); + }}> + + {mode === "register" && <> + + + } + + + +
+ ); + } + + return ( +
+
+

{props.user.display_name}

+

@{props.user.username} · {TIER_LABELS[props.user.tier]} · {props.user.role === "admin" ? "관리자" : props.user.role === "moderator" ? "운영자" : "회원"}

+ +
+
+

비밀번호 변경

+
{ + event.preventDefault(); + props.run(async () => { + await apiJson("/api/library/auth/change-password", { + method: "POST", headers: authHeaders(props.csrf), body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), + }); + setCurrentPassword(""); setNewPassword(""); props.onSignedOut(); + }); + }}> + + + +
+
+
+

회원 탈퇴

+

본인이 올린 MIDI와 공유 설정이 모두 삭제되며 되돌릴 수 없습니다.

+
{ + event.preventDefault(); + if (!window.confirm("회원 계정과 업로드한 MIDI를 모두 삭제할까요?")) return; + props.run(async () => { + await apiJson("/api/library/auth/withdraw", { + method: "POST", headers: authHeaders(props.csrf), body: JSON.stringify({ password: withdrawPassword, confirmation: withdrawConfirmation }), + }); + props.onSignedOut(); + }); + }}> + + + +
+
+
+ ); +} + +function UploadTab({ csrf, busy, run, onUploaded }: { csrf: string | null; busy: boolean; run: (action: () => Promise) => Promise; onUploaded: () => Promise }) { + const [visibility, setVisibility] = useState("private"); + return ( +
{ + event.preventDefault(); + const form = new FormData(event.currentTarget); + if (visibility === "public") form.set("min_tier", "0"); + run(async () => { + const response = await fetch("/api/library/midi", { method: "POST", headers: authHeaders(csrf, false), body: form }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.detail || "업로드하지 못했습니다"); + event.currentTarget.reset(); + setVisibility("private"); + await onUploaded(); + }); + }}> +

MIDI 업로드

+

파일은 MIDI 헤더와 크기를 검사합니다. 저작권을 보유하거나 공유 허가를 받은 곡만 올려 주세요.

+ + + + + + + + + + {visibility === "shared" && } +