182 lines
6.9 KiB
Python
182 lines
6.9 KiB
Python
"""Safe, small bencode parser and ephemeral magnet metadata inspection state."""
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import time
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from . import store
|
|
|
|
ID = re.compile(r'^[a-f0-9]{32}$')
|
|
MAX_METADATA = 32 * 1024 * 1024
|
|
|
|
|
|
class MetadataError(ValueError):
|
|
pass
|
|
|
|
|
|
def decode_bencode(raw):
|
|
if len(raw) > MAX_METADATA:
|
|
raise MetadataError('Metadane torrenta są zbyt duże')
|
|
position = 0
|
|
count = 0
|
|
|
|
def decode(depth=0):
|
|
nonlocal position, count
|
|
if depth > 24:
|
|
raise MetadataError('Metadane mają zbyt duże zagnieżdżenie')
|
|
count += 1
|
|
if count > 600000 or position >= len(raw):
|
|
raise MetadataError('Uszkodzone metadane torrenta')
|
|
start = raw[position]
|
|
if start == 105: # i<number>e
|
|
end = raw.find(b'e', position + 1)
|
|
if end < 0 or end - position > 25:
|
|
raise MetadataError('Błędna liczba bencode')
|
|
try:
|
|
number = int(raw[position + 1:end])
|
|
except ValueError as exc:
|
|
raise MetadataError('Błędna liczba bencode') from exc
|
|
position = end + 1
|
|
return number
|
|
if start in (108, 100): # l, d
|
|
position += 1
|
|
output = [] if start == 108 else {}
|
|
while position < len(raw) and raw[position] != 101:
|
|
if start == 108:
|
|
output.append(decode(depth + 1))
|
|
else:
|
|
key = decode(depth + 1)
|
|
if not isinstance(key, bytes):
|
|
raise MetadataError('Niepoprawny klucz bencode')
|
|
output[key] = decode(depth + 1)
|
|
if position >= len(raw):
|
|
raise MetadataError('Nieukończone metadane')
|
|
position += 1
|
|
return output
|
|
if 48 <= start <= 57: # <len>:<bytes>
|
|
colon = raw.find(b':', position, min(len(raw), position + 22))
|
|
if colon < 0:
|
|
raise MetadataError('Błędny ciąg bencode')
|
|
try:
|
|
length = int(raw[position:colon])
|
|
except ValueError as exc:
|
|
raise MetadataError('Błędna długość bencode') from exc
|
|
position = colon + 1
|
|
if length < 0 or length > len(raw) - position:
|
|
raise MetadataError('Niepoprawna długość pola')
|
|
result = raw[position:position + length]
|
|
position += length
|
|
return result
|
|
raise MetadataError('Uszkodzone metadane torrenta')
|
|
|
|
result = decode()
|
|
if position != len(raw) or not isinstance(result, dict):
|
|
raise MetadataError('Niepoprawny format torrenta')
|
|
return result
|
|
|
|
|
|
def _segment(raw):
|
|
if not isinstance(raw, bytes) or not raw or len(raw) > 255 or raw in (b'.', b'..'):
|
|
raise MetadataError('Niedozwolona nazwa w torrencie')
|
|
if any(char in raw for char in (b'/', b'\\', b'\0')):
|
|
raise MetadataError('Niebezpieczna ścieżka w torrencie')
|
|
return raw.decode('utf-8', errors='replace')
|
|
|
|
|
|
def read_torrent(path):
|
|
try:
|
|
data = decode_bencode(Path(path).read_bytes())
|
|
info = data[b'info']
|
|
name = _segment(info.get(b'name.utf-8') or info[b'name'])
|
|
result = []
|
|
if b'files' in info:
|
|
files = info[b'files']
|
|
if not isinstance(files, list) or len(files) > 50000:
|
|
raise MetadataError('Nieprawidłowa lista plików torrenta')
|
|
for index, file in enumerate(files, start=1):
|
|
parts = file.get(b'path.utf-8') or file[b'path']
|
|
if not isinstance(parts, list) or not parts:
|
|
raise MetadataError('Błędna ścieżka pliku')
|
|
length = file[b'length']
|
|
if not isinstance(length, int) or length < 0:
|
|
raise MetadataError('Nieprawidłowy rozmiar pliku')
|
|
result.append({'index': index, 'name': '/'.join([name] + [_segment(x) for x in parts]),
|
|
'size': length, 'padding': b'p' in file.get(b'attr', b'')})
|
|
else:
|
|
length = info[b'length']
|
|
if not isinstance(length, int) or length < 0:
|
|
raise MetadataError('Nieprawidłowy rozmiar pliku')
|
|
result.append({'index': 1, 'name': name, 'size': length, 'padding': False})
|
|
if not result:
|
|
raise MetadataError('Torrent nie zawiera plików')
|
|
return {'name': name, 'files': result}
|
|
except (KeyError, TypeError, OSError, ValueError) as exc:
|
|
if isinstance(exc, MetadataError):
|
|
raise
|
|
raise MetadataError('Nie można odczytać plików z torrenta') from exc
|
|
|
|
|
|
def suggested_indices(magnet, max_index):
|
|
"""magnet so indexes are zero-based; aria2 select-file indexes are one-based."""
|
|
so = parse_qs(urlsplit(magnet).query).get('so', [''])[0]
|
|
suggested = set()
|
|
for chunk in so.split(',')[:1000]:
|
|
try:
|
|
if '-' in chunk:
|
|
a, b = (int(x) for x in chunk.split('-', 1))
|
|
if b < a or b - a > 1000:
|
|
continue
|
|
values = range(a, b + 1)
|
|
else:
|
|
values = [int(chunk)]
|
|
suggested.update(n + 1 for n in values if 0 <= n < max_index)
|
|
except ValueError:
|
|
continue
|
|
return sorted(suggested)
|
|
|
|
|
|
def inspect_dir(preview_id):
|
|
if not ID.fullmatch(preview_id):
|
|
raise MetadataError('Nieprawidłowy identyfikator metadanych')
|
|
return store.DATA / 'metadata' / preview_id
|
|
|
|
|
|
def write_request(preview_id, data):
|
|
path = inspect_dir(preview_id)
|
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
filename = path / 'request.json'
|
|
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
with os.fdopen(os.open(filename, flags, 0o600), 'w', encoding='utf-8') as f:
|
|
json.dump({'created': time.time(), **data}, f)
|
|
|
|
|
|
def load_request(preview_id):
|
|
try:
|
|
info = json.loads((inspect_dir(preview_id) / 'request.json').read_text(encoding='utf-8'))
|
|
if not isinstance(info['gid'], str) or not isinstance(info['magnet'], str):
|
|
raise MetadataError('Błędne dane podglądu')
|
|
return info
|
|
except FileNotFoundError as exc:
|
|
raise MetadataError('Nie znaleziono podglądu magneta') from exc
|
|
except (ValueError, KeyError, TypeError) as exc:
|
|
raise MetadataError('Uszkodzone dane podglądu') from exc
|
|
|
|
|
|
def torrent_file(preview_id):
|
|
matches = list(inspect_dir(preview_id).glob('*.torrent'))
|
|
return matches[0] if len(matches) == 1 and matches[0].stat().st_size <= MAX_METADATA else None
|
|
|
|
|
|
def cleanup_old(max_age=86400):
|
|
root = store.DATA / 'metadata'
|
|
if not root.exists():
|
|
return
|
|
now = time.time()
|
|
for child in root.iterdir():
|
|
if (ID.fullmatch(child.name) and child.is_dir() and not child.is_symlink()
|
|
and now - child.stat().st_mtime > max_age):
|
|
shutil.rmtree(child)
|