-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathlinkproxy.py
More file actions
98 lines (79 loc) · 3.79 KB
/
Copy pathlinkproxy.py
File metadata and controls
98 lines (79 loc) · 3.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
"""Relay de descargas: sirve los enlaces debrid desde la IP del propio bot.
Los debrid ligan cada enlace generado a la IP que lo pidió; si el bot corre en
una VPS y el usuario abre el enlace desde su casa/móvil, el servicio ve IPs
distintas y puede banear la cuenta. Con LINK_PROXY el bot entrega URLs propias
(http://IP_DEL_BOT:PUERTO/dl/token) y descarga él mismo del debrid en streaming,
así el servicio solo ve una IP: la del bot (o la de DEBRID_PROXY si está activo).
"""
from __future__ import annotations
import asyncio
import logging
import secrets
import time
from urllib.parse import quote
import aiohttp
from aiohttp import web
from debrid import UnrestrictedLink
log = logging.getLogger("linkproxy")
TOKEN_TTL = 24 * 3600 # los enlaces del relay caducan a las 24 h
CHUNK = 256 * 1024
# cabeceras del debrid que se reenvían tal cual al cliente
_PASSTHROUGH = ("Content-Length", "Content-Range", "Accept-Ranges", "Content-Type")
class LinkProxy:
def __init__(self, session: aiohttp.ClientSession, base_url: str):
self.session = session
self.base_url = base_url.rstrip("/")
self._links: dict[str, tuple[UnrestrictedLink, float]] = {}
self._runner: web.AppRunner | None = None
def register(self, link: UnrestrictedLink) -> str:
self._purge()
token = secrets.token_urlsafe(16)
self._links[token] = (link, time.time())
return f"{self.base_url}/dl/{token}"
def _purge(self) -> None:
cutoff = time.time() - TOKEN_TTL
for token in [t for t, (_, ts) in self._links.items() if ts < cutoff]:
del self._links[token]
async def start(self, host: str, port: int) -> None:
app = web.Application()
app.router.add_get("/dl/{token}", self._handle)
self._runner = web.AppRunner(app, access_log=None)
await self._runner.setup()
await web.TCPSite(self._runner, host, port).start()
async def stop(self) -> None:
if self._runner:
await self._runner.cleanup()
async def _handle(self, request: web.Request) -> web.StreamResponse:
entry = self._links.get(request.match_info["token"])
if not entry or time.time() - entry[1] > TOKEN_TTL:
raise web.HTTPNotFound(text="Enlace caducado, pídelo de nuevo al bot.")
link = entry[0]
headers = {}
if "Range" in request.headers: # reanudar / descarga por tramos
headers["Range"] = request.headers["Range"]
async with self.session.get(link.url, headers=headers) as upstream:
if upstream.status >= 400:
log.warning("El debrid respondió %s para %s", upstream.status, link.filename)
return web.Response(
status=upstream.status, text=f"El servicio debrid respondió {upstream.status}"
)
resp = web.StreamResponse(status=upstream.status)
for header in _PASSTHROUGH:
if header in upstream.headers:
resp.headers[header] = upstream.headers[header]
ascii_name = link.filename.encode("ascii", "replace").decode()
resp.headers["Content-Disposition"] = (
f'attachment; filename="{ascii_name}"; '
f"filename*=UTF-8''{quote(link.filename)}"
)
await resp.prepare(request)
try:
async for chunk in upstream.content.iter_chunked(CHUNK):
await resp.write(chunk)
await resp.write_eof()
except (ConnectionResetError, asyncio.CancelledError):
pass # el cliente cortó la descarga; nada que hacer
return resp
async def detect_public_ip(session: aiohttp.ClientSession) -> str:
async with session.get("https://api.ipify.org") as resp:
return (await resp.text()).strip()