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:
+21
-3
@@ -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
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user