Fix streaming downloads for large files #51

Merged
jimmy merged 1 commits from fix/streaming-downloads into main 2026-04-18 12:03:28 +08:00
2 changed files with 75 additions and 15 deletions
+63 -5
View File
@@ -1,11 +1,13 @@
import logging import logging
import os import os
import re import re
import secrets
import shutil import shutil
import time
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Query
from fastapi.responses import FileResponse from fastapi.responses import StreamingResponse
from config import VOLUME_ROOT from config import VOLUME_ROOT
from rbac import require_admin from rbac import require_admin
@@ -17,6 +19,10 @@ BASE = Path(VOLUME_ROOT)
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker", "homes", "backups"} BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker", "homes", "backups"}
# Download token storage: {token: (path, expiry_timestamp)}
_download_tokens = {}
TOKEN_EXPIRY = 300 # 5 minutes
_BASE_RESOLVED = str(BASE.resolve()) _BASE_RESOLVED = str(BASE.resolve())
@@ -88,13 +94,65 @@ def browse(path: str = ""):
return {"path": str(target.relative_to(BASE)), "entries": entries} return {"path": str(target.relative_to(BASE)), "entries": entries}
@router.get("/download") @router.post("/download-token")
def download(path: str): def create_download_token(path: str):
"""Generate a temporary download token for large file downloads."""
try: try:
target = _safe_path(path) target = _safe_path(path)
if not target.exists(): if not target.exists():
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
return FileResponse(target, filename=target.name)
# Clean up expired tokens
now = time.time()
expired = [t for t, (_, exp) in _download_tokens.items() if exp < now]
for t in expired:
del _download_tokens[t]
# Generate new token
token = secrets.token_urlsafe(32)
_download_tokens[token] = (str(target), now + TOKEN_EXPIRY)
return {"token": token, "expires_in": TOKEN_EXPIRY}
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
@router.get("/download")
def download(path: str = None, token: str = Query(None)):
"""Download a file. Supports both authenticated access (path param) and token-based access (token param)."""
try:
if token:
# Token-based download (no auth required)
if token not in _download_tokens:
raise HTTPException(status_code=403, detail="Invalid or expired token")
file_path, expiry = _download_tokens[token]
if time.time() > expiry:
del _download_tokens[token]
raise HTTPException(status_code=403, detail="Token expired")
# Consume token after use
del _download_tokens[token]
target = Path(file_path)
else:
# Authenticated download
if not path:
raise HTTPException(status_code=400, detail="Path or token required")
target = _safe_path(path)
if not target.exists():
raise HTTPException(status_code=404, detail="File not found")
def file_iterator():
with open(target, "rb") as f:
while chunk := f.read(8192 * 1024): # 8MB chunks
yield chunk
return StreamingResponse(
file_iterator(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{target.name}"'}
)
except ValueError: except ValueError:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
+12 -10
View File
@@ -203,25 +203,27 @@ export async function download(path, filename) {
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
try { try {
const r = await fetch(`${BASE}/files/download?path=${encodeURIComponent(path)}`, { // Get a temporary download token
method: "GET", const tokenResponse = await fetch(`${BASE}/files/download-token?path=${encodeURIComponent(path)}`, {
method: "POST",
headers, headers,
}); });
if (!r.ok) { if (!tokenResponse.ok) {
const errorText = await r.text(); const errorText = await tokenResponse.text();
console.error(`Download failed: ${r.status} - ${errorText}`); console.error(`Download token failed: ${tokenResponse.status} - ${errorText}`);
throw new Error(`Download failed: ${r.status}`); throw new Error(`Download failed: ${tokenResponse.status}`);
} }
const blob = await r.blob(); const { token: downloadToken } = await tokenResponse.json();
const url = window.URL.createObjectURL(blob);
// Use the token to trigger browser download (no memory loading)
const downloadUrl = `${BASE}/files/download?token=${downloadToken}`;
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = downloadUrl;
a.download = filename; a.download = filename;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
} catch (e) { } catch (e) {
console.error("Download error:", e); console.error("Download error:", e);