temporary_link feature
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
109
pytorrent/services/pdf_preview_links.py
Normal file
109
pytorrent/services/pdf_preview_links.py
Normal 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
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user