205 lines
8.0 KiB
Python
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()
|