mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
250 lines
12 KiB
Python
250 lines
12 KiB
Python
"""Authenticated, bounded adapter to the shared parked replay owner."""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import stat
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
|
|
ID = re.compile(r'[a-f0-9]{32}\Z')
|
|
MAX_MEDIA_BYTES = 1024 * 1024
|
|
DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
|
|
|
|
|
|
class ReplayHTTPError(Exception):
|
|
def __init__(self, code, message, status=409):
|
|
self.code, self.message, self.status = code, message, status
|
|
|
|
|
|
def default_request(payload):
|
|
from openpilot.starpilot.replay.client import ReplayUnavailable, request_operation
|
|
try:
|
|
return request_operation(payload)
|
|
except ReplayUnavailable as error:
|
|
raise ReplayHTTPError('owner_unavailable', 'Replay is unavailable. Refresh before trying again.', 503) from error
|
|
|
|
|
|
class ReplayHTTP:
|
|
def __init__(self, request=None, root=None):
|
|
self.request = request or default_request
|
|
self.root = Path(root) if root is not None else None
|
|
self.secret = secrets.token_bytes(32)
|
|
|
|
def actor(self, identity, client_id):
|
|
if type(client_id) is not str or ID.fullmatch(client_id) is None:
|
|
raise ValueError('Invalid replay client')
|
|
token, generation = identity
|
|
return hmac.new(self.secret, token.encode() + generation + client_id.encode(), hashlib.sha256).hexdigest()[:32]
|
|
|
|
def call(self, handler, identity, payload):
|
|
if handler.settings_session() != identity:
|
|
raise ReplayHTTPError('session_expired', 'Sign in to Galaxy', 401)
|
|
result = self.request(payload)
|
|
if handler.settings_session() != identity:
|
|
raise ReplayHTTPError('session_expired', 'Sign in to Galaxy', 401)
|
|
if type(result) is not dict or type(result.get('ok')) is not bool:
|
|
raise ReplayHTTPError('owner_unavailable', 'Replay returned an unsupported response', 503)
|
|
if not result['ok']:
|
|
error = result.get('error', {})
|
|
code, message = error.get('code', 'unavailable'), error.get('message', 'Replay is unavailable')
|
|
status = 400 if code in {'invalid_request', 'invalid_action', 'invalid_route'} else 503 if code in {'unavailable', 'owner_unavailable'} else 409
|
|
raise ReplayHTTPError(code, message, status)
|
|
value = result.get('result')
|
|
if type(value) is not dict:
|
|
raise ReplayHTTPError('owner_unavailable', 'Replay returned an unsupported response', 503)
|
|
# Private source paths stay inside this adapter, including status receipts.
|
|
return {key: item for key, item in value.items() if key not in {'framePath', 'mediaPath'}} if payload['op'] != 'media' else value
|
|
|
|
def get(self, handler, path):
|
|
if not handler.require_session():
|
|
return
|
|
identity = handler.settings_session()
|
|
if identity is None:
|
|
handler.json(401, {'error': 'Sign in to Galaxy'})
|
|
return
|
|
try:
|
|
query = parse_qs(urlsplit(handler.path).query, keep_blank_values=True, max_num_fields=5)
|
|
if any(len(value) != 1 for value in query.values()):
|
|
raise ValueError('Duplicate replay query')
|
|
args = {key: value[0] for key, value in query.items()}
|
|
operation = path.removeprefix('/api/replay/')
|
|
expected = {'catalog': set(), 'status': {'clientId'}, 'detail': {'routeId'}, 'export-download': {'exportId', 'clientId'},
|
|
'media': {'sessionId', 'clientId', 'epoch', 'sequence'},
|
|
'timeline': {'sessionId', 'clientId', 'cursor'}}.get(operation)
|
|
if expected is None:
|
|
handler.json(404, {'error': 'Replay endpoint unavailable'})
|
|
return
|
|
if set(args) != expected:
|
|
raise ValueError('Invalid replay query')
|
|
if 'clientId' in args:
|
|
args['clientId'] = self.actor(identity, args['clientId'])
|
|
if operation == 'timeline':
|
|
if ID.fullmatch(args['sessionId']) is None or not re.fullmatch(r'0|[1-9][0-9]{0,7}', args['cursor']):
|
|
raise ValueError('Invalid replay timeline identity')
|
|
args['cursor'] = int(args['cursor'])
|
|
if operation == 'export-download':
|
|
self.download(handler, identity, args)
|
|
elif operation == 'media':
|
|
for key in ('epoch', 'sequence'):
|
|
if not re.fullmatch(r'0|[1-9][0-9]{0,9}', args[key]):
|
|
raise ValueError('Invalid replay media identity')
|
|
args[key] = int(args[key])
|
|
if ID.fullmatch(args['sessionId']) is None:
|
|
raise ValueError('Invalid replay session')
|
|
self.media(handler, identity, args)
|
|
else:
|
|
result = self.call(handler, identity, {'op': operation, **args})
|
|
handler.json(200, result)
|
|
except ValueError as error:
|
|
handler.json(400, {'error': str(error), 'code': 'invalid_request'})
|
|
except ReplayHTTPError as error:
|
|
handler.json(error.status, {'error': error.message, 'code': error.code})
|
|
except (OSError, RuntimeError, ImportError):
|
|
handler.json(503, {'error': 'Replay is unavailable. Refresh before trying again.', 'code': 'owner_unavailable'})
|
|
|
|
def post(self, handler, path, payload):
|
|
identity = handler.settings_session()
|
|
if identity is None:
|
|
handler.json(401, {'error': 'Sign in to Galaxy'})
|
|
return
|
|
try:
|
|
if urlsplit(handler.path).query or type(payload) is not dict or 'clientId' not in payload or 'op' in payload:
|
|
raise ValueError('Invalid replay action')
|
|
operation = path.removeprefix('/api/replay/')
|
|
if operation not in {'start', 'control', 'stop', 'export-start'}:
|
|
raise ValueError('Invalid replay action')
|
|
if operation == 'start' and payload.get('target') != 'web':
|
|
raise ValueError('Galaxy replay requires its web viewer')
|
|
args = {**payload, 'clientId': self.actor(identity, payload['clientId']), 'op': operation}
|
|
# Session validation surrounds the slow owner operation; retries stay user-driven.
|
|
handler.json(200, self.call(handler, identity, args))
|
|
except ValueError as error:
|
|
handler.json(400, {'error': str(error), 'code': 'invalid_request'})
|
|
except ReplayHTTPError as error:
|
|
handler.json(error.status, {'error': error.message, 'code': error.code})
|
|
except (OSError, RuntimeError, ImportError):
|
|
handler.json(503, {'error': 'Replay is unavailable. Refresh before trying again.', 'code': 'owner_unavailable'})
|
|
|
|
def clear_route(self, handler, identity, route_id):
|
|
try:
|
|
self.call(handler, identity, {'op': 'export-clear', 'routeId': route_id})
|
|
except ReplayHTTPError as error:
|
|
if error.code not in {'owner_unavailable', 'unavailable'}:
|
|
raise
|
|
|
|
def download(self, handler, identity, args):
|
|
from openpilot.starpilot.galaxy.recording_http import send_recording
|
|
from openpilot.starpilot.galaxy.recording_media import MediaLease
|
|
from openpilot.starpilot.replay.client import replay_root
|
|
from openpilot.starpilot.replay.export import ExportSource, MAX_EXPORT_BYTES
|
|
if ID.fullmatch(args['exportId']) is None:
|
|
raise ValueError('Invalid replay export')
|
|
receipt = self.call(handler, identity, {'op': 'export-file', **args})
|
|
if (receipt.get('id') != args['exportId'] or type(receipt.get('bytes')) is not int or
|
|
not 0 < receipt['bytes'] <= MAX_EXPORT_BYTES or type(receipt.get('filename')) is not str or
|
|
re.fullmatch(r'[a-zA-Z0-9_-]+\.mp4', receipt['filename']) is None):
|
|
raise ValueError('Invalid replay export receipt')
|
|
source = ExportSource(self.root if self.root is not None else replay_root(), args['exportId'], receipt['bytes'])
|
|
try:
|
|
lease = MediaLease(source, os.dup(source.fd))
|
|
except BaseException:
|
|
source.close()
|
|
raise
|
|
send_recording(handler, lease, filename=receipt['filename'])
|
|
|
|
def read_media(self, value, args):
|
|
from openpilot.starpilot.replay.media import web_media_dir
|
|
if (value.get('epoch') != args['epoch'] or value.get('sequence') != args['sequence'] or
|
|
type(value.get('length')) is not int or not 0 < value['length'] <= MAX_MEDIA_BYTES or
|
|
type(value.get('mediaPath')) is not str):
|
|
raise ReplayHTTPError('invalid_media', 'Replay media identity is invalid', 503)
|
|
session = web_media_dir(args['sessionId'])
|
|
root = session.parent.parent
|
|
directory = session / 'media' / str(args['epoch'])
|
|
filename = 'init.mp4' if args['sequence'] == 0 else f"{args['sequence']}.m4s"
|
|
if Path(value['mediaPath']) != directory / filename:
|
|
raise ReplayHTTPError('invalid_media', 'Replay media source is invalid', 503)
|
|
fds = []
|
|
try:
|
|
parent = os.open(root, DIR_FLAGS)
|
|
fds.append(parent)
|
|
for part in (session.parent.name, args['sessionId'], 'media', str(args['epoch'])):
|
|
parent = os.open(part, DIR_FLAGS, dir_fd=parent)
|
|
fds.append(parent)
|
|
fd = os.open(filename, FILE_FLAGS, dir_fd=parent)
|
|
fds.append(fd)
|
|
before = os.fstat(fd)
|
|
if not stat.S_ISREG(before.st_mode) or before.st_size != value['length']:
|
|
raise ReplayHTTPError('media_evicted', 'Replay media changed; reconnect the video')
|
|
data = os.pread(fd, value['length'], 0)
|
|
current, after = os.stat(filename, dir_fd=parent, follow_symlinks=False), os.fstat(fd)
|
|
def revision(item):
|
|
return item.st_dev, item.st_ino, item.st_size, item.st_mtime_ns, item.st_ctime_ns
|
|
if len(data) != value['length'] or revision(before) != revision(current) or revision(before) != revision(after):
|
|
raise ReplayHTTPError('media_evicted', 'Replay media changed; reconnect the video')
|
|
return data
|
|
except OSError as error:
|
|
raise ReplayHTTPError('media_evicted', 'Replay media changed; reconnect the video') from error
|
|
finally:
|
|
for fd in reversed(fds):
|
|
os.close(fd)
|
|
|
|
def media(self, handler, identity, args):
|
|
chunks, total, selected = [], 0, args['sequence']
|
|
count = 1 if selected == 0 else 8
|
|
result = self.call(handler, identity, {'op': 'media', **args, 'count': count})
|
|
if result.get('available') is False:
|
|
if result.get('code') in {'media_evicted', 'epoch_changed', 'media_epoch_changed'}:
|
|
raise ReplayHTTPError(result['code'], 'Replay video changed; reconnect the video')
|
|
handler.json(202, {'available': False})
|
|
return
|
|
rows = [result] if selected == 0 else result.get('fragments')
|
|
if type(rows) is not list or not 1 <= len(rows) <= count or any(type(row) is not dict for row in rows):
|
|
raise ReplayHTTPError('invalid_media', 'Replay media batch is invalid', 503)
|
|
for row in rows:
|
|
length = row.get('length')
|
|
if type(length) is not int or not 0 < length <= MAX_MEDIA_BYTES:
|
|
raise ReplayHTTPError('invalid_media', 'Replay media exceeded its budget', 503)
|
|
if total + length > MAX_MEDIA_BYTES:
|
|
break
|
|
chunks.append(self.read_media(row, {**args, 'sequence': selected}))
|
|
total += length
|
|
selected += 1
|
|
if not chunks:
|
|
handler.json(202, {'available': False})
|
|
return
|
|
# Recheck physical authority/session after file reads, before any video escapes.
|
|
confirmed = self.call(handler, identity, {'op': 'media', **args})
|
|
if confirmed.get('available') is False:
|
|
raise ReplayHTTPError(confirmed.get('code', 'media_evicted'), 'Replay video changed; reconnect the video')
|
|
handler.send_response(200)
|
|
for key, value in {'Content-Type': 'video/mp4', 'Content-Length': str(total), 'Cache-Control': 'no-store',
|
|
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
|
|
'X-Replay-Epoch': str(args['epoch']), 'X-Replay-Next-Sequence': str(selected)}.items():
|
|
handler.send_header(key, value)
|
|
handler.end_headers()
|
|
if handler.command == 'HEAD':
|
|
return
|
|
data = b''.join(chunks)
|
|
for offset in range(0, total, 64 * 1024):
|
|
try:
|
|
status = self.call(handler, identity, {'op': 'status', 'clientId': args['clientId']})
|
|
except ReplayHTTPError:
|
|
handler.close_connection = True
|
|
return # Headers were sent; retire the response instead of exposing stale bytes.
|
|
if (not status.get('owned') or not status.get('offroad') or status.get('mediaReady') is not True or
|
|
status.get('state') not in {'playing', 'paused', 'ended', 'buffering'} or
|
|
status.get('sessionId') != args['sessionId'] or status.get('epoch') != args['epoch']):
|
|
handler.close_connection = True
|
|
return
|
|
try:
|
|
handler.wfile.write(data[offset:offset + 64 * 1024])
|
|
except OSError:
|
|
handler.close_connection = True
|
|
return
|