"""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()