Files
2026-10-15 14:15:10 +02:00

205 lines
8.0 KiB
Python

"""Internet Archive user session and restricted download relay.
The relay keeps IA cookies inside this process. aria2 only receives a signed,
local endpoint, and never sees an IA session cookie or the account password.
"""
import hashlib
import hmac
import json
import os
import secrets
import tempfile
from pathlib import Path
from urllib.parse import urljoin, urlsplit
import httpx
from fastapi.responses import PlainTextResponse, Response, StreamingResponse
from . import store
class ArchiveAuthError(ValueError):
pass
def session_path():
return store.DATA / 'private' / 'archive-session.json'
def _read_session():
try:
value = json.loads(session_path().read_text(encoding='utf-8'))
cookies = value['cookies']
if not all(isinstance(cookies.get(k), str) and cookies[k] for k in ('logged-in-user', 'logged-in-sig')):
return None
return value
except (FileNotFoundError, KeyError, TypeError, ValueError, OSError):
return None
def session_status():
value = _read_session()
return {'authenticated': bool(value), 'email': value.get('email', '') if value else ''}
def session_cookies():
cookies = httpx.Cookies()
value = _read_session()
if value:
for key in ('logged-in-user', 'logged-in-sig'):
cookies.set(key, value['cookies'][key], domain='.archive.org', path='/')
return cookies
def _cookie_value(value):
# The official xauthn API includes cookie attributes after the value.
if not isinstance(value, str):
raise ArchiveAuthError('Brak danych sesji w odpowiedzi Archive.org')
value = value.split(';', 1)[0].strip()
if not value or len(value) > 4096 or any(x in value for x in ('\n', '\r', '\0')):
raise ArchiveAuthError('Niepoprawne dane sesji Archive.org')
return value
def _save_session(email, cookies):
path = session_path()
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
os.chmod(path.parent, 0o700)
fd, name = tempfile.mkstemp(dir=path.parent, prefix='.archive-session-', suffix='.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump({'email': email, 'cookies': cookies}, f)
f.flush()
os.fsync(f.fileno())
os.chmod(name, 0o600)
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
async def login(email, password):
"""Authenticate using IA's documented xauthn endpoint (no password stored)."""
try:
async with httpx.AsyncClient(timeout=25, follow_redirects=False) as client:
result = await client.post('https://archive.org/services/xauthn/', params={'op': 'login'},
data={'email': email, 'password': password})
result.raise_for_status()
data = result.json()
except (httpx.HTTPError, ValueError) as exc:
raise ArchiveAuthError('Błąd połączenia z logowaniem Archive.org') from exc
if not data.get('success'):
reason = str((data.get('values') or {}).get('reason') or data.get('error') or '')
if reason in ('account_bad_password', 'account_not_found'):
raise ArchiveAuthError('Nieprawidłowy adres e-mail lub hasło')
raise ArchiveAuthError('Archive.org odrzuciło logowanie: ' + (reason[:120] or 'brak szczegółów'))
try:
raw = data['values']['cookies']
cookies = {key: _cookie_value(raw[key]) for key in ('logged-in-user', 'logged-in-sig')}
except (KeyError, TypeError) as exc:
raise ArchiveAuthError('Logowanie udane, ale brak ciasteczek sesji w odpowiedzi') from exc
_save_session(email, cookies)
return session_status()
def logout():
session_path().unlink(missing_ok=True)
def is_archive_download(url):
try:
parsed = urlsplit(url)
return parsed.scheme == 'https' and parsed.hostname in ('archive.org', 'www.archive.org') and parsed.path.startswith('/download/')
except ValueError:
return False
def internal_url(job_id, index):
return f'http://127.0.0.1:8000/internal/archive/{job_id}/{index}'
def signed_header(job_id, index):
secret = os.environ['ARIA2_SECRET'].encode('utf-8')
message = f'archive:{job_id}:{index}'.encode('ascii')
digest = hmac.new(secret, message, hashlib.sha256).hexdigest()
return 'X-X360-Proxy-Token: ' + digest
def verify_token(job_id, index, token):
try:
expected = signed_header(job_id, index).split(': ', 1)[1]
except UnicodeEncodeError:
return False
return secrets.compare_digest(expected, token or '')
def allowed_redirect(url):
try:
parts = urlsplit(url)
name = (parts.hostname or '').lower()
return parts.scheme == 'https' and parts.port in (None, 443) and (name == 'archive.org' or name.endswith('.archive.org'))
except ValueError:
return False
async def stream_archive(url, method, headers):
"""Stream authorized bytes and preserve HTTP Range for aria2 resumes.
Only HTTPS Archive.org redirects are followed. Cookie domain scoping is
handled by httpx, unlike forwarding a raw Cookie header through redirects.
"""
outbound_headers = {'Accept-Encoding': 'identity', 'User-Agent': 'X360-Queue/1.1'}
for key in ('range', 'if-range', 'if-modified-since', 'if-none-match'):
if key in headers:
outbound_headers[key] = headers[key]
client = httpx.AsyncClient(timeout=httpx.Timeout(120, connect=25), cookies=session_cookies(),
follow_redirects=False, trust_env=False)
current = url
try:
for _ in range(7):
if not allowed_redirect(current):
return PlainTextResponse('Nieprawidłowe przekierowanie Archive.org', status_code=502)
request = client.build_request(method, current, headers=outbound_headers)
remote = await client.send(request, stream=True)
if remote.status_code in (301, 302, 303, 307, 308) and remote.headers.get('location'):
new_url = urljoin(current, remote.headers['location'])
await remote.aclose()
current = new_url
continue
break
else:
return PlainTextResponse('Zbyt wiele przekierowań Archive.org', status_code=502)
if remote.status_code not in (200, 206, 304, 416):
status = remote.status_code
await remote.aclose()
if status in (401, 403):
return PlainTextResponse('Archive.org odmówiło dostępu. Zaloguj się i sprawdź uprawnienia do pliku.', status_code=403)
return PlainTextResponse(f'Archive.org HTTP {status}', status_code=status if 400 <= status < 600 else 502)
if remote.status_code == 200 and 'text/html' in remote.headers.get('content-type', '').lower():
await remote.aclose()
return PlainTextResponse('Archive.org zwróciło stronę HTML zamiast pliku. Sprawdź logowanie.', status_code=403)
safe_headers = {k: v for k, v in remote.headers.items() if k.lower() in (
'content-length', 'content-range', 'accept-ranges', 'content-disposition',
'content-type', 'etag', 'last-modified')}
if method == 'HEAD' or remote.status_code in (304, 416):
await remote.aclose()
return Response(status_code=remote.status_code, headers=safe_headers)
async def chunks():
try:
async for chunk in remote.aiter_raw(chunk_size=131072):
yield chunk
finally:
await remote.aclose()
await client.aclose()
return StreamingResponse(chunks(), status_code=remote.status_code, headers=safe_headers,
media_type=safe_headers.get('content-type'))
except httpx.HTTPError:
return PlainTextResponse('Nie można połączyć się z Archive.org', status_code=502)
finally:
# A streamed response owns client until the last chunk is sent.
if 'remote' not in locals() or remote.is_closed:
await client.aclose()