temporary_link feature

This commit is contained in:
Mateusz Gruszczyński
2026-05-21 22:05:08 +02:00
parent cb48735178
commit b772c97d50
10 changed files with 844 additions and 41 deletions

View File

@@ -2111,6 +2111,28 @@
"type": "object"
}
]
},
"TemporaryLinkResponse": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": true
},
"url": {
"type": "string",
"example": "/download/r4nd0mTemporaryToken"
},
"expires_in": {
"type": "integer",
"example": 600
}
},
"required": [
"ok",
"url",
"expires_in"
]
}
},
"securitySchemes": {
@@ -7101,6 +7123,362 @@
},
"summary": "Traffic history"
}
},
"/preview/pdf/{token}": {
"get": {
"summary": "Open temporary PDF preview",
"description": "Streams a PDF through an in-app temporary preview URL created by the API. The browser-visible URL does not expose the stable /api download route.",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "PDF stream",
"content": {
"application/pdf": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"403": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/download/{token}": {
"get": {
"summary": "Open temporary download link",
"description": "Resolves a short-lived in-app download token created by an API endpoint and streams the requested file or ZIP.",
"parameters": [
{
"in": "path",
"name": "token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "File stream",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
},
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
},
"application/x-bittorrent": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"403": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/files/{file_index}/download-link": {
"post": {
"summary": "Create temporary torrent file download link",
"description": "Validates the selected torrent file through the API and returns a short-lived /download URL for the UI.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "file_index",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/files/download-link": {
"post": {
"summary": "Create temporary torrent file download link from body",
"description": "Body-based alias that validates a selected torrent file and returns a short-lived /download URL.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"file_index": {
"type": "integer"
}
},
"required": [
"file_index"
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/files/download.zip/link": {
"post": {
"summary": "Create temporary torrent files ZIP download link",
"description": "Validates selected torrent files and returns a short-lived /download URL for a ZIP archive. If indexes is omitted or null, all files are included.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"indexes": {
"type": "array",
"items": {
"type": "integer"
},
"nullable": true
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/{torrent_hash}/torrent-file/link": {
"get": {
"summary": "Create temporary .torrent export download link",
"description": "Validates .torrent export availability and returns a short-lived /download URL for the UI.",
"parameters": [
{
"in": "path",
"name": "torrent_hash",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/torrents/torrent-files.zip/link": {
"post": {
"summary": "Create temporary .torrent files ZIP download link",
"description": "Validates selected torrents and returns a short-lived /download URL for a ZIP of exported .torrent files.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"hashes": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"hashes"
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemporaryLinkResponse"
}
}
}
},
"400": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
}
}
}

View File

@@ -1,10 +1,16 @@
from __future__ import annotations
from pathlib import Path
from urllib.parse import quote
import queue
import tempfile
import threading
import zipfile
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file
from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
from ..services import auth
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file, stream_with_context
from ..services.preferences import get_preferences, list_profiles, active_profile, get_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
from ..services import auth, pdf_preview_links, rtorrent
from ..config import PYTORRENT_TMP_DIR
from ..services.frontend_assets import asset_path
# for favicon
@@ -18,6 +24,141 @@ def _asset_url(key: str) -> str:
return path if path.startswith("http") else url_for("static", filename=path)
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict:
safe = Path(download_name or "download.bin").name or "download.bin"
safe_disposition = "inline" if disposition == "inline" else "attachment"
return {
"Content-Type": content_type,
"Content-Disposition": f"{safe_disposition}; filename*=UTF-8''{quote(safe)}",
"X-Content-Type-Options": "nosniff",
}
def _cleanup_staged_file(profile: dict, path: str, local: bool = False) -> None:
if local:
try:
Path(path).unlink()
except Exception:
pass
return
rtorrent._remote_remove_staged(profile, path)
try:
tmp_prefix = str(PYTORRENT_TMP_DIR).rstrip("/") + "/pytorrent-download-"
if str(path).startswith(tmp_prefix) and Path(path).exists():
Path(path).unlink()
except Exception:
pass
def _read_staged_file(profile: dict, path: str, local: bool = False) -> bytes:
if local:
return Path(path).read_bytes()
return b"".join(bytes(chunk) for chunk in rtorrent.iter_remote_file_chunks(profile, path) if chunk)
def _safe_zip_name(name: str, fallback: str) -> str:
value = str(name or fallback).replace("\\", "/").lstrip("/")
parts = [part for part in value.split("/") if part not in ("", ".", "..")]
return "/".join(parts) or fallback
class _ZipStream:
def __init__(self):
self.queue: queue.Queue[bytes | None] = queue.Queue(maxsize=16)
self.closed = False
def write(self, data):
if not data:
return 0
payload = bytes(data)
self.queue.put(payload)
return len(payload)
def flush(self):
return None
def close(self):
if not self.closed:
self.closed = True
self.queue.put(None)
def writable(self):
return True
def _stream_torrent_files_zip(profile: dict, items: list[dict]):
writer = _ZipStream()
errors: list[BaseException] = []
def produce():
try:
with zipfile.ZipFile(writer, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
used = set()
for item in items:
arcname = _safe_zip_name(str(item.get("path") or ""), f"file-{item.get('index', 0)}")
base = arcname
counter = 2
while arcname in used:
stem = Path(base).stem or "file"
suffix = Path(base).suffix
parent = str(Path(base).parent).replace(".", "", 1).strip("/")
candidate = f"{stem}-{counter}{suffix}"
arcname = f"{parent}/{candidate}" if parent else candidate
counter += 1
used.add(arcname)
info = zipfile.ZipInfo(arcname)
info.compress_type = zipfile.ZIP_STORED
info.file_size = int(item.get("size") or 0)
with archive.open(info, "w", force_zip64=True) as dest:
for chunk in rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=int(item.get("size") or 0) or None):
dest.write(chunk)
except BaseException as exc:
errors.append(exc)
finally:
writer.close()
threading.Thread(target=produce, name="pytorrent-token-zip-stream", daemon=True).start()
while True:
chunk = writer.queue.get()
if chunk is None:
break
yield chunk
if errors:
raise errors[0]
def _send_staged_torrent_file(profile: dict, path: str, download_name: str, local: bool = False):
headers = _attachment_headers(download_name, "application/x-bittorrent")
if local:
data = Path(path).read_bytes()
_cleanup_staged_file(profile, path, local=True)
headers["Content-Length"] = str(len(data))
return Response(data, headers=headers)
def generate():
try:
yield from rtorrent.iter_remote_file_chunks(profile, path)
finally:
_cleanup_staged_file(profile, path, local=False)
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
def _profile_for_temporary_target(target: dict):
profile_id = int(target.get("profile_id") or 0)
owner_user_id = int(target.get("user_id") or 0)
if auth.enabled() and owner_user_id != auth.current_user_id():
abort(403)
if not auth.can_access_profile(profile_id):
abort(403)
profile = active_profile() if not profile_id else get_profile(profile_id)
if not profile:
abort(404)
return profile
@bp.get("/favicon.ico")
def favicon_ico():
response = send_from_directory(
@@ -65,6 +206,120 @@ def index():
)
@bp.get("/preview/pdf/<token>")
def pdf_preview(token: str):
# Note: This route keeps browser-visible PDF links inside the app and delegates streaming to the existing rTorrent file reader.
target = pdf_preview_links.get_pdf_preview_link(token)
if not target:
abort(404)
profile_id = int(target.get("profile_id") or 0)
owner_user_id = int(target.get("user_id") or 0)
if auth.enabled() and owner_user_id != auth.current_user_id():
abort(403)
if not auth.can_access_profile(profile_id):
abort(403)
profile = active_profile() if not profile_id else get_profile(profile_id)
if not profile:
abort(404)
item = rtorrent.torrent_download_file_info(profile, target["torrent_hash"], int(target["file_index"]))
filename = Path(item.get("download_name") or "preview.pdf").name or "preview.pdf"
if Path(filename).suffix.lower() != ".pdf":
abort(404)
size = int(item.get("size") or 0)
headers = {
"Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}",
"Content-Type": "application/pdf",
"X-Content-Type-Options": "nosniff",
}
if size > 0:
headers["Content-Length"] = str(size)
def generate():
yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None)
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
@bp.get("/download/<token>")
def temporary_download(token: str):
# Note: UI download actions resolve API-created temporary tokens here, keeping browser-visible URLs outside /api/.
target = pdf_preview_links.get_temporary_link(token)
if not target:
abort(404)
profile = _profile_for_temporary_target(target)
kind = str(target.get("kind") or "")
if kind == "file_download":
item = rtorrent.torrent_download_file_info(profile, target["torrent_hash"], int(target["file_index"]))
size = int(item.get("size") or 0)
headers = _attachment_headers(item.get("download_name") or "file.bin")
if size > 0:
headers["Content-Length"] = str(size)
def generate_file():
yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None)
return Response(stream_with_context(generate_file()), headers=headers, direct_passthrough=True)
if kind == "file_zip_download":
items = rtorrent.torrent_download_zip_items(profile, target["torrent_hash"], target.get("indexes"))
headers = _attachment_headers(f"{str(target['torrent_hash'])[:12]}-files.zip", "application/zip")
headers["X-PyTorrent-Download-Mode"] = "temporary-token"
return Response(stream_with_context(_stream_torrent_files_zip(profile, items)), headers=headers, direct_passthrough=True)
if kind == "torrent_file_download":
item = rtorrent.export_torrent_file(profile, target["torrent_hash"])
return _send_staged_torrent_file(profile, item["path"], item["download_name"], bool(item.get("local")))
if kind == "torrent_files_zip_download":
hashes = [str(item) for item in (target.get("hashes") or []) if str(item).strip()]
if not hashes:
abort(404)
staged_paths = []
PYTORRENT_TMP_DIR.mkdir(parents=True, exist_ok=True)
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-torrents-", suffix=".zip", delete=False, dir=str(PYTORRENT_TMP_DIR))
tmp.close()
try:
with zipfile.ZipFile(tmp.name, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
used_names = set()
for torrent_hash in hashes:
item = rtorrent.export_torrent_file(profile, torrent_hash)
staged_paths.append((item["path"], bool(item.get("local"))))
name = Path(item["download_name"]).name or f"{torrent_hash}.torrent"
base_name = name
counter = 2
while name in used_names:
stem = Path(base_name).stem
name = f"{stem}-{counter}.torrent"
counter += 1
used_names.add(name)
archive.writestr(name, _read_staged_file(profile, item["path"], bool(item.get("local"))))
response = send_file(tmp.name, as_attachment=True, download_name="pytorrent-torrents.zip")
def cleanup():
for path, is_local in staged_paths:
_cleanup_staged_file(profile, path, is_local)
try:
Path(tmp.name).unlink()
except Exception:
pass
response.call_on_close(cleanup)
return response
except Exception:
for path, is_local in staged_paths:
_cleanup_staged_file(profile, path, is_local)
try:
Path(tmp.name).unlink()
except Exception:
pass
raise
abort(404)
@bp.get("/docs")
def docs():
html = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>pyTorrent API Docs</title><link rel="stylesheet" href="{_asset_url('swagger_css')}"></head><body><div id="swagger-ui"></div><script src="{_asset_url('swagger_js')}"></script><script>window.onload=()=>SwaggerUIBundle({{url:'/api/openapi.json',dom_id:'#swagger-ui',deepLinking:true,persistAuthorization:true}});</script></body></html>"""

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from ._shared import *
from ..services import torrent_creator
from ..services import pdf_preview_links, torrent_creator
from ..services.reverse_dns import attach_reverse_dns
@bp.get("/torrents")
@@ -105,7 +105,18 @@ def torrent_file_media_info(torrent_hash: str, file_index: int):
return jsonify({"ok": False, "error": "No profile"}), 400
try:
# Note: The route is additive and keeps all existing file endpoints unchanged.
return ok({"media_info": rtorrent.torrent_file_media_info(profile, torrent_hash, file_index)})
media_info = rtorrent.torrent_file_media_info(profile, torrent_hash, file_index)
if media_info.get("kind") == "pdf":
link = pdf_preview_links.create_pdf_preview_link(
torrent_hash,
file_index,
int(profile.get("id") or 0),
int(default_user_id() or 0),
)
# Note: The frontend receives an in-app temporary URL instead of exposing the API download endpoint in the new-tab action.
media_info["preview_url"] = url_for("main.pdf_preview", token=link["token"])
media_info["preview_expires_in"] = link["expires_in"]
return ok({"media_info": media_info})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@@ -199,6 +210,87 @@ def _send_staged_file(profile: dict, path: str, download_name: str, local: bool
@bp.post("/torrents/<torrent_hash>/files/<int:file_index>/download-link")
def torrent_file_download_link(torrent_hash: str, file_index: int):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
# Note: The API validates the file selection before returning a short-lived in-app /download URL to the UI.
rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
link = pdf_preview_links.create_file_download_link(torrent_hash, file_index, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/<torrent_hash>/files/download-link")
def torrent_file_download_link_from_body(torrent_hash: str):
data = request.get_json(silent=True) or {}
try:
file_index = int(data.get("file_index"))
except Exception:
return jsonify({"ok": False, "error": "file_index is required"}), 400
return torrent_file_download_link(torrent_hash, file_index)
@bp.post("/torrents/<torrent_hash>/files/download.zip/link")
def torrent_files_download_zip_link(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
try:
indexes = data.get("indexes") or None
# Note: ZIP link creation validates the requested files through the same service used by the direct download endpoint.
rtorrent.torrent_download_zip_items(profile, torrent_hash, indexes)
link = pdf_preview_links.create_file_zip_download_link(torrent_hash, indexes, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/torrents/<torrent_hash>/torrent-file/link")
def torrent_file_export_link(torrent_hash: str):
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
try:
# Note: Export availability is checked before the UI receives a temporary /download URL.
item = rtorrent.export_torrent_file(profile, torrent_hash)
_cleanup_staged_file(profile, item["path"], bool(item.get("local")))
link = pdf_preview_links.create_torrent_file_download_link(torrent_hash, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.post("/torrents/torrent-files.zip/link")
def torrent_files_export_zip_link():
profile = preferences.active_profile()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {}
hashes = [str(h) for h in (data.get("hashes") or []) if str(h).strip()]
if not hashes:
return jsonify({"ok": False, "error": "No torrents selected"}), 400
try:
# Note: Each hash is checked before the temporary ZIP export link is returned to the UI.
staged_paths = []
try:
for h in hashes:
item = rtorrent.export_torrent_file(profile, h)
staged_paths.append((item["path"], bool(item.get("local"))))
finally:
for path, is_local in staged_paths:
_cleanup_staged_file(profile, path, is_local)
link = pdf_preview_links.create_torrent_files_zip_download_link(hashes, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
def torrent_file_download(torrent_hash: str, file_index: int):
profile = preferences.active_profile()

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import secrets
import threading
import time
_LINK_TTL_SECONDS = 10 * 60
_TEMPORARY_LINKS: dict[str, dict] = {}
_TEMPORARY_LINK_LOCK = threading.Lock()
def _cleanup_expired(now: float | None = None) -> None:
now = time.time() if now is None else float(now)
expired = [token for token, item in _TEMPORARY_LINKS.items() if float(item.get("expires_at") or 0) <= now]
for token in expired:
_TEMPORARY_LINKS.pop(token, None)
def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: dict) -> dict:
"""Create a short-lived in-app link target used by preview and download routes."""
# Note: API routes validate the request first, then return an app URL token instead of exposing stable download URLs in the UI.
now = time.time()
token = secrets.token_urlsafe(24)
with _TEMPORARY_LINK_LOCK:
_cleanup_expired(now)
_TEMPORARY_LINKS[token] = {
"kind": str(kind),
"profile_id": int(profile_id),
"user_id": int(user_id),
"expires_at": now + _LINK_TTL_SECONDS,
**payload,
}
return {"token": token, "expires_in": _LINK_TTL_SECONDS}
def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
"""Create a short-lived in-app PDF preview link without exposing the API download URL."""
# Note: The public link is temporary and points to an app route, while streaming still reuses the existing file reader.
return _create_temporary_link(
"pdf_preview",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash), "file_index": int(file_index)},
)
def create_file_download_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for one torrent file."""
# Note: File downloads use /download/<token> in the UI, but the backend keeps the same rTorrent streaming logic.
return _create_temporary_link(
"file_download",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash), "file_index": int(file_index)},
)
def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for a ZIP of torrent files."""
# Note: Selected indexes are stored with the token so the final /download route does not need an API body.
clean_indexes = None if indexes is None else [int(index) for index in indexes]
return _create_temporary_link(
"file_zip_download",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash), "indexes": clean_indexes},
)
def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for an exported .torrent file."""
# Note: The token hides the stable export API URL from browser-visible download actions.
return _create_temporary_link(
"torrent_file_download",
profile_id,
user_id,
{"torrent_hash": str(torrent_hash)},
)
def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, user_id: int) -> dict:
"""Create a temporary in-app download link for a ZIP of exported .torrent files."""
# Note: Hashes are copied into the token target after the API validates that the request is non-empty.
return _create_temporary_link(
"torrent_files_zip_download",
profile_id,
user_id,
{"hashes": [str(item) for item in hashes]},
)
def get_temporary_link(token: str) -> dict | None:
"""Return a temporary target if the link is still valid."""
# Note: Expired links are removed on read so stale browser tabs stop resolving automatically.
clean = str(token or "").strip()
if not clean:
return None
with _TEMPORARY_LINK_LOCK:
_cleanup_expired()
item = _TEMPORARY_LINKS.get(clean)
return dict(item) if item else None
def get_pdf_preview_link(token: str) -> dict | None:
"""Return a temporary PDF preview target if the link is still valid."""
item = get_temporary_link(token)
if not item or item.get("kind") != "pdf_preview":
return None
return item

View File

@@ -141,8 +141,6 @@ _MEDIA_INFO_SAMPLE_BYTES = 32 * 1024 * 1024
_MEDIA_INFO_CHUNK_BYTES = 1024 * 1024
_TEXT_PREVIEW_BYTES = 512 * 1024
_IMAGE_PREVIEW_BYTES = 8 * 1024 * 1024
_PDF_TEXT_BYTES = 16 * 1024 * 1024
_PDF_TEXT_PAGES = 10
_MEDIA_INFO_TMP_DIR = BASE_DIR / "data" / "media-info-samples"
@@ -296,41 +294,12 @@ def _image_file_preview(profile: dict, selected: dict, remote_path: str, max_byt
return result
def _pdf_imports():
# Note: pypdf is imported lazily so non-PDF previews do not depend on it at request time.
import sys
try:
from pypdf import PdfReader
return PdfReader
except ModuleNotFoundError as exc:
missing = str(getattr(exc, "name", "") or "pypdf")
if missing.split(".", 1)[0] == "pypdf":
raise RuntimeError(
"Python package 'pypdf' is not importable in the application runtime. "
"Install it inside the pyTorrent virtualenv and restart the service: "
"/opt/pyTorrent/venv/bin/pip install -r /opt/pyTorrent/requirements.txt && systemctl restart pytorrent. "
f"Runtime: {sys.executable}."
) from exc
raise RuntimeError(
f"pypdf is installed, but one of its Python dependencies is missing: {missing}. "
f"Runtime: {sys.executable}."
) from exc
except Exception as exc:
raise RuntimeError(
"pypdf was found, but failed during import. "
f"Runtime: {sys.executable}. Details: {exc}"
) from exc
def _pdf_file_preview(
profile: dict,
selected: dict,
remote_path: str,
max_bytes: int = _PDF_TEXT_BYTES,
max_pages: int = _PDF_TEXT_PAGES,
) -> dict:
# Note: The modal keeps a metadata payload here, while the frontend streams the real PDF through the existing file download route in inline mode.
# Note: pypdf is no longer required because PDFs are not parsed; the browser renders the original file stream.
size = int(selected.get("size") or 0)
return {
**selected,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4601,6 +4601,7 @@ body,
border: 1px solid var(--bs-border-color);
border-radius: 0.9rem;
height: min(72vh, 860px);
margin-bottom: 1rem;
min-height: 520px;
overflow: hidden;
}

View File

@@ -6,4 +6,3 @@ psutil>=5.9
simple-websocket>=1.0
gunicorn>=22.0
hachoir>=3.3
pypdf>=4.3