업로드 형식을 MIDI와 MusicXML과 MXL 및 PDF로 확장하고 파일별 검증을 추가했다. AlphaTab 악보 자산과 Nginx MIME 설정을 보완해 운영에서 오선과 음표가 표시되도록 수정했다. 공개 도메인 클래식과 난이도별 연습 편곡 7개를 같은 제목별로 묶고 출처와 운영 검증 결과를 문서화했다. 검사: TypeScript 검사와 Vitest 67개 및 Vite 운영 빌드 통과. 운영 브라우저 악보 표시와 네 가지 업로드 검증 형식 확인. 백업: /home/syskiller/piano/backups/bach-to-basics/20261003-1545-sheet-upload 운영 반영: 완료
672 lines
28 KiB
Python
672 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
from zipfile import BadZipFile, ZipFile
|
|
|
|
from defusedxml import ElementTree as SafeElementTree
|
|
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_SCORE_BYTES = 25 * 1024 * 1024
|
|
MAX_MXL_EXPANDED_BYTES = 50 * 1024 * 1024
|
|
MAX_MXL_ENTRIES = 200
|
|
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"}
|
|
SUPPORTED_UPLOADS = {
|
|
".mid": "audio/midi",
|
|
".midi": "audio/midi",
|
|
".xml": "application/vnd.recordare.musicxml+xml",
|
|
".musicxml": "application/vnd.recordare.musicxml+xml",
|
|
".mxl": "application/vnd.recordare.musicxml",
|
|
".pdf": "application/pdf",
|
|
}
|
|
|
|
|
|
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 _xml_local_name(tag: str) -> str:
|
|
return tag.rsplit("}", 1)[-1]
|
|
|
|
|
|
def _parse_musicxml(content: bytes) -> None:
|
|
try:
|
|
root = SafeElementTree.fromstring(content)
|
|
except Exception as error:
|
|
raise HTTPException(status_code=400, detail="올바른 MusicXML 악보가 아닙니다") from error
|
|
if _xml_local_name(root.tag) not in {"score-partwise", "score-timewise"}:
|
|
raise HTTPException(status_code=400, detail="MusicXML 악보의 최상위 요소가 올바르지 않습니다")
|
|
|
|
|
|
def _validate_mxl(content: bytes) -> None:
|
|
try:
|
|
with ZipFile(io.BytesIO(content)) as archive:
|
|
entries = archive.infolist()
|
|
if not entries or len(entries) > MAX_MXL_ENTRIES:
|
|
raise HTTPException(status_code=400, detail="MXL 악보의 내부 파일 구성이 올바르지 않습니다")
|
|
if sum(entry.file_size for entry in entries) > MAX_MXL_EXPANDED_BYTES:
|
|
raise HTTPException(status_code=413, detail="압축을 푼 MXL 악보는 50MB 이하여야 합니다")
|
|
names = {entry.filename for entry in entries}
|
|
container_name = "META-INF/container.xml"
|
|
if container_name not in names:
|
|
raise HTTPException(status_code=400, detail="MXL 악보에 container.xml이 없습니다")
|
|
container = SafeElementTree.fromstring(archive.read(container_name))
|
|
rootfile = next(
|
|
(
|
|
element.attrib.get("full-path", "")
|
|
for element in container.iter()
|
|
if _xml_local_name(element.tag) == "rootfile"
|
|
),
|
|
"",
|
|
)
|
|
root_path = Path(rootfile)
|
|
if not rootfile or root_path.is_absolute() or ".." in root_path.parts or rootfile not in names:
|
|
raise HTTPException(status_code=400, detail="MXL 악보의 주 악보 경로가 올바르지 않습니다")
|
|
_parse_musicxml(archive.read(rootfile))
|
|
except HTTPException:
|
|
raise
|
|
except (BadZipFile, KeyError, ValueError) as error:
|
|
raise HTTPException(status_code=400, detail="올바른 MXL 악보가 아닙니다") from error
|
|
|
|
|
|
def _validate_uploaded_score(extension: str, content: bytes) -> None:
|
|
if extension in {".mid", ".midi"}:
|
|
if len(content) < 14 or not content.startswith(b"MThd"):
|
|
raise HTTPException(status_code=400, detail="올바른 MIDI 파일이 아닙니다")
|
|
return
|
|
if extension in {".xml", ".musicxml"}:
|
|
_parse_musicxml(content)
|
|
return
|
|
if extension == ".mxl":
|
|
_validate_mxl(content)
|
|
return
|
|
if extension == ".pdf" and not content.startswith(b"%PDF-"):
|
|
raise HTTPException(status_code=400, detail="올바른 PDF 악보가 아닙니다")
|
|
|
|
|
|
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"],
|
|
"file_type": Path(row["original_name"]).suffix.lower().lstrip("."),
|
|
"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:
|
|
secure_cookie = os.environ.get("SESSION_COOKIE_SECURE", "1").strip().lower() not in {"0", "false", "no"}
|
|
response.set_cookie(
|
|
COOKIE_NAME,
|
|
token,
|
|
max_age=30 * 24 * 60 * 60,
|
|
httponly=True,
|
|
secure=secure_cookie,
|
|
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="곡 또는 악보 파일을 찾을 수 없습니다")
|
|
encoded = quote(row["original_name"].replace("\r", "").replace("\n", ""))
|
|
extension = Path(row["original_name"]).suffix.lower()
|
|
return FileResponse(
|
|
path,
|
|
media_type=SUPPORTED_UPLOADS.get(extension, "application/octet-stream"),
|
|
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
|
|
extension = Path(original_name).suffix.lower()
|
|
if extension not in SUPPORTED_UPLOADS:
|
|
raise HTTPException(status_code=400, detail="MIDI와 MusicXML과 MXL 및 PDF 악보만 업로드할 수 있습니다")
|
|
content = await file.read(MAX_SCORE_BYTES + 1)
|
|
if len(content) > MAX_SCORE_BYTES:
|
|
raise HTTPException(status_code=413, detail="곡 또는 악보 파일은 25MB 이하여야 합니다")
|
|
if not content:
|
|
raise HTTPException(status_code=400, detail="빈 파일은 업로드할 수 없습니다")
|
|
_validate_uploaded_score(extension, content)
|
|
item_id = secrets.token_hex(16)
|
|
storage_name = f"{item_id}{extension}"
|
|
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)
|