곡 보관함과 악보 학습 기능을 보완
업로드 형식을 MIDI와 MusicXML과 MXL 및 PDF로 확장하고 파일별 검증을 추가했다. AlphaTab 악보 자산과 Nginx MIME 설정을 보완해 운영에서 오선과 음표가 표시되도록 수정했다. 공개 도메인 클래식과 난이도별 연습 편곡 7개를 같은 제목별로 묶고 출처와 운영 검증 결과를 문서화했다. 검사: TypeScript 검사와 Vitest 67개 및 Vite 운영 빌드 통과. 운영 브라우저 악보 표시와 네 가지 업로드 검증 형식 확인. 백업: /home/syskiller/piano/backups/bach-to-basics/20261003-1545-sheet-upload 운영 반영: 완료
This commit is contained in:
+87
-11
@@ -2,13 +2,16 @@ 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
|
||||
@@ -27,13 +30,23 @@ from library_db import (
|
||||
|
||||
router = APIRouter()
|
||||
COOKIE_NAME = "b2b_session"
|
||||
MAX_MIDI_BYTES = 10 * 1024 * 1024
|
||||
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):
|
||||
@@ -142,6 +155,65 @@ def _validate_metadata(visibility: str, min_tier: int, difficulty: str, arrangem
|
||||
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:
|
||||
@@ -183,6 +255,7 @@ def _serialize_item(row: dict, user: dict | None, shared_usernames: list[str]) -
|
||||
"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"],
|
||||
@@ -407,11 +480,12 @@ def download_midi(item_id: str, session_token: str | None = Cookie(default=None,
|
||||
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 파일을 찾을 수 없습니다")
|
||||
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="audio/midi",
|
||||
media_type=SUPPORTED_UPLOADS.get(extension, "application/octet-stream"),
|
||||
headers={"Content-Disposition": f"inline; filename*=UTF-8''{encoded}"},
|
||||
)
|
||||
|
||||
@@ -439,15 +513,17 @@ async def upload_midi(
|
||||
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 파일이 아닙니다")
|
||||
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}.mid"
|
||||
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()]
|
||||
|
||||
Reference in New Issue
Block a user