FIX: 11 bugs bảo mật/ổn định (static mount chặn dotfile, delete traversal, cleanup giữ clips serverFileId, upload whitelist, password strength, auth audio endpoints, pedalboard==0.9.19, vendor CDN local) + FEATURE: Carla bridge preview/export MIDI notes âm VSTi (POST /midi-render, /carla-play-notes, nút Preview VSTi/Export MIDI->Audio; pedalboard 0.9.19 raw MIDI bytes; SONICFORGE_STORAGE_DIR cô lập test storage)

This commit is contained in:
2026-08-10 18:46:54 +07:00
parent 67ad2b8e4b
commit 61cb4b846f
33 changed files with 1097 additions and 103 deletions
+21 -3
View File
@@ -74,6 +74,8 @@ async def _resolve_host_ips(hostname: str):
async def _validate_target_url(url: str, user_id: str):
"""Validate target. Trả IP đã validate (str) để proxy connect thẳng vào đó
chống DNS rebinding TOCTOU; None khi giữ hostname (loopback / https)."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="URL chỉ hỗ trợ giao thức http/https")
@@ -88,7 +90,7 @@ async def _validate_target_url(url: str, user_id: str):
# Hostname-level fast path for loopback hosts
if hostname in _LOOPBACK_HOSTS:
if hostname in allowed_hosts:
return
return None
raise HTTPException(status_code=403, detail="Target nội bộ không nằm trong danh sách AI provider đã cấu hình")
# Try direct IP parse (hostname may itself be an IP)
@@ -109,18 +111,34 @@ async def _validate_target_url(url: str, user_id: str):
continue
raise HTTPException(status_code=403, detail="Target IP nội bộ không nằm trong danh sách AI provider đã cấu hình")
# Trả IP đầu tiên đã validate — http sẽ connect thẳng vào IP này (bind),
# không cho httpx re-resolve hostname (fix DNS rebinding TOCTOU).
return str(ips[0])
@router.post("/proxy")
async def proxy_llm(req: ProxyRequest, current_user: dict = Depends(get_current_user)):
await _validate_target_url(req.url, current_user["user_id"])
target_ip = await _validate_target_url(req.url, current_user["user_id"])
# Never forward the app's own auth token upstream.
headers = {
k: v for k, v in req.headers.items()
if k.lower() not in ("host", "origin", "referer", "x-auth-token")
}
url = req.url
# Bug #6: http + hostname (không phải IP literal) → connect thẳng IP đã
# validate, giữ Host gốc. https giữ hostname (SNI + cert validation chống
# rebinding sẵn). IPv6 skip (netloc bracket phức tạp, hiếm gặp).
if target_ip and ":" not in target_ip:
parsed = urlparse(req.url)
if parsed.scheme == "http":
host_header = parsed.netloc
new_netloc = parsed.netloc.replace(parsed.hostname, target_ip)
from urllib.parse import urlunsplit
url = urlunsplit((parsed.scheme, new_netloc, parsed.path, parsed.query, parsed.fragment))
headers["Host"] = host_header
try:
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
resp = await client.post(req.url, headers=headers, json=req.body)
resp = await client.post(url, headers=headers, json=req.body)
raw = resp.text
try:
return resp.json()
+21 -4
View File
@@ -16,6 +16,12 @@ router = APIRouter()
MAX_AUDIO_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
# Bug #4: whitelist extension upload — trước đây nhận mọi đuôi (`.exe`) rồi
# serve qua static. Chỉ chấp nhận định dạng audio phổ biến.
ALLOWED_AUDIO_EXTENSIONS = {
"wav", "mp3", "ogg", "flac", "aiff", "aif", "m4a", "aac", "opus", "webm",
}
def _safe_file_id(file_id: str) -> str:
"""Strip any path components from a client-supplied file id."""
if not file_id:
@@ -81,7 +87,10 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
if current_user:
enforce_password_changed(current_user)
user_id = current_user["user_id"] if current_user else "anonymous"
ext = os.path.splitext(file.filename or "")[1]
ext = os.path.splitext(file.filename or "")[1].lower()
# Bug #4: reject non-audio extensions before saving
if ext and ext.lstrip(".") not in ALLOWED_AUDIO_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"Định dạng file không được hỗ trợ: {ext}")
if not ext:
ext = ".wav"
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
@@ -125,7 +134,9 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
}
@router.post("/edit")
async def edit_audio(req: EditRequest):
async def edit_audio(req: EditRequest, current_user: Optional[dict] = Depends(get_optional_user)):
if current_user:
enforce_password_changed(current_user)
# Use uploaded file if it exists, or look in processed if it was already edited
if not _resolve_storage_path(req.file_id):
raise HTTPException(status_code=404, detail="File not found")
@@ -192,10 +203,12 @@ async def analyze_audio_with_ai(req: AIAnalysisRequest):
}
@router.post("/export")
async def export_audio(req: ExportRequest):
async def export_audio(req: ExportRequest, current_user: Optional[dict] = Depends(get_optional_user)):
"""
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
"""
if current_user:
enforce_password_changed(current_user)
source_path = _resolve_storage_path(req.file_id)
if not source_path:
raise HTTPException(status_code=404, detail="File not found")
@@ -392,7 +405,11 @@ async def list_user_files(req: MyFilesRequest, current_user: dict = Depends(get_
async def delete_user_file(file_id: str, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
prefix = f"user_{user_id}_"
# Bug #2: sanitize TRƯỚC khi check prefix — trước đây chỉ check startswith
# nên `user_<id>_../../tmp/x` pass guard → os.remove xóa file ngoài storage.
file_id = _safe_file_id(file_id)
if not file_id:
raise HTTPException(status_code=404, detail="Không tìm thấy tệp trên server")
# Guard: only own files can be deleted
if not file_id.startswith(prefix):
raise HTTPException(status_code=403, detail="Bạn không có quyền xóa tệp này")
+3
View File
@@ -194,6 +194,9 @@ async def change_password(req: ChangePasswordRequest, current_user: dict = Depen
user_id = current_user["user_id"]
old_pwd = req.old_password.strip()
new_pwd = req.new_password.strip()
# Bug #7: register đã validate độ mạnh, change-password thì KHÔNG — admin bị
# ép đổi mật khẩu có thể đặt `a` (1 ký tự). Áp cùng policy.
_validate_password_strength(new_pwd)
conn = get_db_connection()
cursor = conn.cursor()
+17 -4
View File
@@ -1,8 +1,11 @@
import os
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from app.config import settings
from app.api.v1.auth import enforce_password_changed
from app.api.v1.projects import get_optional_user
from app.api.v1.audio import _safe_file_id
router = APIRouter()
@@ -33,13 +36,19 @@ class MultitrackSessionRequest(BaseModel):
tracks: List[TrackConfig]
@router.post("/mix")
async def mix_multitrack_session(req: MultitrackSessionRequest):
async def mix_multitrack_session(req: MultitrackSessionRequest, current_user: Optional[dict] = Depends(get_optional_user)):
"""
API endpoint để xử lý hòa âm đa kênh (Multitrack Mixdown).
Nhận cấu hình JSON từ Client và gửi task xuống Celery Worker.
"""
# Kiểm tra xem các file nguồn có tồn tại không
if current_user:
enforce_password_changed(current_user)
# Bug #9: guard auth-optional + sanitize file_id (chống traversal vào worker)
for track in req.tracks:
track.file_id = _safe_file_id(track.file_id)
if not track.file_id:
raise HTTPException(status_code=400, detail=f"file_id không hợp lệ cho track {track.track_id}")
# Kiểm tra xem các file nguồn có tồn tại không
if track.muted:
continue
@@ -63,11 +72,15 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
}
@router.post("/process-session")
async def process_session(req: MultitrackSessionRequest):
async def process_session(req: MultitrackSessionRequest, current_user: Optional[dict] = Depends(get_optional_user)):
"""
API endpoint để xử lý toàn bộ session với nhiều tracks và clips.
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
"""
if current_user:
enforce_password_changed(current_user)
for track in req.tracks:
track.file_id = _safe_file_id(track.file_id)
from app.tasks.worker import process_multitrack_session_task
task = process_multitrack_session_task.delay(req.model_dump())
+199 -34
View File
@@ -1,4 +1,4 @@
import os, sys, uuid, json, tempfile, subprocess, time as _time
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading
import numpy as np
import soundfile as sf
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header
@@ -843,47 +843,23 @@ async def preview_instrument(req: PreviewRequest):
if not req.notes:
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để preview")
try:
pm = PluginManager()
vst = pm.load_vst(req.instrument_id)
if vst is None:
raise HTTPException(status_code=404, detail=f"Không tìm thấy VSTi: {req.instrument_id}")
apply_preset_to_plugin(
vst,
out_path, duration_sec = _render_midi_notes_pedalboard(
instrument_id=req.instrument_id,
notes=req.notes,
bpm=req.bpm,
sample_rate=req.sample_rate,
preset_id=req.preset_id,
preset_path=req.preset_path,
preset_data_b64=req.preset_data,
soundfont_bank=req.soundfont_bank,
soundfont_program=req.soundfont_program,
)
from pedalboard import Pedalboard
midi_events = []
for n in req.notes:
midi_events.append({
"note": int(n.get("pitch", 60)),
"start_beat": float(n.get("start_beat", 0)),
"duration_beats": float(n.get("duration_beats", 1)),
"velocity": int(float(n.get("velocity", 0.8)) * 127),
})
midi_messages = PluginManager.midi_events_to_messages(
midi_events, req.bpm, req.sample_rate,
bank=req.soundfont_bank, program=req.soundfont_program,
)
total_needed = 0
beat_sec = 60.0 / max(30.0, req.bpm)
for ev in midi_events:
end_sec = (ev["start_beat"] + ev["duration_beats"]) * beat_sec
if int(end_sec * req.sample_rate) > total_needed:
total_needed = int(end_sec * req.sample_rate)
total_needed = max(total_needed, 1024)
silent = np.zeros((2, total_needed), dtype=np.float32)
board = Pedalboard([vst])
buf = board(silent, sample_rate=req.sample_rate, midi_messages=midi_messages)
fname = f"preview_{uuid.uuid4().hex[:10]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, fname)
sf.write(out_path, buf.T, req.sample_rate)
fname = os.path.basename(out_path)
return {
"success": True,
"url": f"/static/audio/processed/{fname}",
"path": out_path,
"duration_sec": round(buf.shape[1] / float(req.sample_rate), 3),
"duration_sec": round(duration_sec, 3),
}
except HTTPException:
raise
@@ -891,6 +867,195 @@ async def preview_instrument(req: PreviewRequest):
raise HTTPException(status_code=500, detail=f"Preview thất bại: {e}")
class MidiRenderRequest(BaseModel):
"""Render MIDI notes → audio qua VSTi (âm thật, cùng code path với export).
instrument_id = plugin_id của track synth_engine (khớp key scan của
PluginManager). Đây là cầu nối Carla → pedalboard: preset chỉnh trong
Carla (.vstpreset) được áp vào pedalboard trước khi render."""
instrument_id: str
notes: list = []
bpm: float = 120.0
sample_rate: int = 44100
preset_id: Optional[str] = None
preset_path: Optional[str] = None
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
@router.post("/midi-render")
async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_current_user)):
"""Export MIDI notes → WAV với âm của VSTi instrument (lưu vào processed).
Preview/export MIDI notes với âm VSTi trước đây KHÔNG thực hiện được:
- preview realtime chỉ phát qua loa Carla (không vào audio graph DAW)
- clientSideExport chỉ render soundfont (midiCache = FluidSynth WASM)
- /plugins/preview có sẵn nhưng frontend không gọi
Endpoint này render offline bằng pedalboard (đúng plugin + preset như
export) → trả file_id để UI preview / gán clip vào project / download."""
enforce_password_changed(current_user)
if not req.notes:
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để render")
user_id = current_user["user_id"]
out_name = f"user_{user_id}_midi_{uuid.uuid4().hex[:10]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, out_name)
try:
out_path, duration_sec = _render_midi_notes_pedalboard(
instrument_id=req.instrument_id,
notes=req.notes,
bpm=req.bpm,
sample_rate=req.sample_rate,
preset_id=req.preset_id,
preset_path=req.preset_path,
preset_data_b64=req.preset_data,
)
return {
"success": True,
"file_id": os.path.basename(out_path),
"url": f"/static/audio/processed/{os.path.basename(out_path)}",
"path": out_path,
"duration_sec": round(duration_sec, 3),
"render_mode": "pedalboard",
}
except HTTPException:
# Không để lại file rác nếu thất bại giữa chừng
try:
if os.path.exists(out_path):
os.remove(out_path)
except Exception:
pass
raise
except Exception as e:
try:
if os.path.exists(out_path):
os.remove(out_path)
except Exception:
pass
raise HTTPException(
status_code=500,
detail=f"Render MIDI thất bại: {e}. Nếu plugin là VST2 hoặc pedalboard "
"không load được, hãy mở Carla Bridge (chọn VSTi, chỉnh âm, "
"Save preset .vstpreset) rồi Upload preset vào track — render "
"sẽ dùng đúng âm đã chỉnh.",
)
def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
sample_rate: int, preset_id=None, preset_path=None,
preset_data_b64=None, soundfont_bank=None,
soundfont_program=None) -> tuple:
"""Render MIDI notes qua pedalboard (VSTi + preset) → WAV trong PROCESSED_DIR.
Trả (out_path, duration_sec). Ném HTTPException khi plugin không load được."""
if not HAS_PEDALBOARD:
raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này")
pm = PluginManager()
vst = pm.load_vst(instrument_id)
if vst is None:
raise HTTPException(
status_code=404,
detail=f"Không tìm thấy VSTi: {instrument_id} — chưa scan thấy plugin này. "
"Kiểm tra Plugins Manager → Scan, hoặc mở Carla để load VST2 "
"(pedalboard chỉ render được VST3).",
)
apply_preset_to_plugin(
vst,
preset_id=preset_id,
preset_path=preset_path,
preset_data_b64=preset_data_b64,
)
from pedalboard import Pedalboard
midi_events = []
for n in notes:
midi_events.append({
"note": int(n.get("pitch", 60)),
"start_beat": float(n.get("start_beat", 0)),
"duration_beats": float(n.get("duration_beats", 1)),
"velocity": int(float(n.get("velocity", 0.8)) * 127),
})
midi_messages = PluginManager.midi_events_to_messages(
midi_events, bpm, sample_rate,
bank=soundfont_bank, program=soundfont_program,
)
total_needed = 0
beat_sec = 60.0 / max(30.0, bpm)
for ev in midi_events:
end_sec = (ev["start_beat"] + ev["duration_beats"]) * beat_sec
if int(end_sec * sample_rate) > total_needed:
total_needed = int(end_sec * sample_rate)
total_needed = max(total_needed, 1024)
# pedalboard >= 0.9: Pedalboard container KHÔNG chứa instrument — gọi
# thẳng overload MIDI của plugin (overload 2: midi_messages + duration).
buf = vst(midi_messages, sample_rate=sample_rate,
duration=total_needed / float(sample_rate), num_channels=2)
out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav")
sf.write(out_path, buf.T, sample_rate)
return out_path, buf.shape[1] / float(sample_rate)
class CarlaPlayNotesRequest(BaseModel):
"""Phát dãy MIDI notes qua Carla bridge (OSC, realtime) — preview khi
pedalboard không render được plugin (VD VST2). Cần Carla đang chạy với
plugin đã load (open-in-carla)."""
notes: list = []
bpm: float = 120.0
channel: Optional[int] = 0
@router.post("/carla-play-notes")
async def carla_play_notes(req: CarlaPlayNotesRequest, current_user: dict = Depends(get_current_user)):
"""Phát toàn bộ dãy MIDI notes vào Carla (note_on/note_off đúng thời điểm).
Preview realtime qua đúng VSTi đang mở trong Carla — dùng khi pedalboard
không load được plugin (VST2, plugin cần state GUI). Âm phát ra loa hệ
thống (Carla), KHÔNG thu vào file — muốn file audio dùng /midi-render."""
enforce_password_changed(current_user)
if not req.notes:
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để phát")
if not find_carla_local():
raise HTTPException(status_code=409, detail="Carla chưa được định vị (Plugin Manager → Carla Bridge → Định vị Carla...)")
if not _carla_bridge_running():
raise HTTPException(status_code=409, detail="Carla chưa mở. Hãy mở Carla Bridge (nút Synth → VSTi) trước khi preview qua Carla.")
beat_sec = 60.0 / max(30.0, req.bpm)
channel = int(req.channel or 0)
events = []
for n in req.notes:
pitch = int(n.get("pitch", 60))
vel = int(float(n.get("velocity", 0.8)) * 127)
start = float(n.get("start_beat", 0)) * beat_sec
dur = float(n.get("duration_beats", 1)) * beat_sec
events.append((start, "note_on", pitch, max(1, min(127, vel))))
events.append((start + dur, "note_off", pitch, 0))
events.sort(key=lambda e: (e[0], 0 if e[1] == "note_off" else 1))
duration_sec = max((e[0] for e in events), default=0) + 0.3
# Phát trong thread nền — endpoint trả ngay, không block tới hết bản nhạc
def _player():
import time as _t
t0 = _t.monotonic()
for ev_time, ev_type, pitch, vel in events:
wait = (t0 + ev_time) - _t.monotonic()
if wait > 0:
_t.sleep(wait)
_send_carla_osc(ev_type, pitch, vel, channel)
threading.Thread(target=_player, daemon=True).start()
return {"success": True, "mode": "carla_realtime", "duration_sec": round(duration_sec, 3), "events": len(events)}
def find_carla_local() -> str:
from app.core.runtime import find_carla as _fc
try:
return _fc() or ""
except Exception:
return ""
def _carla_bridge_running() -> bool:
try:
_prune_carla_processes()
return len(_CARLA_PROCESSES) > 0
except Exception:
return False
@router.post("/render")
async def render_project(
req: RenderRequest,