diff --git a/freevideo_engine/access_proxy.py b/freevideo_engine/access_proxy.py index 1040bf6f..1e645478 100644 --- a/freevideo_engine/access_proxy.py +++ b/freevideo_engine/access_proxy.py @@ -23,6 +23,7 @@ from aiohttp import ClientSession, ClientTimeout, DummyCookieJar, TCPConnector, WSMsgType, client_exceptions, web from yarl import URL from . import proxy_identity, lan_password +from .origin_scheme import effective_scheme from .lan_auth import PasswordAuth, DEVICE_COOKIE, REMEMBER_SECONDS, LOGIN_DELAY PHONE_WEB = Path(__file__).resolve().parents[1] / 'web' @@ -81,17 +82,18 @@ def same_origin(request): try: if len(request.headers.getall('Host', [])) != 1 or len(request.headers.getall('Origin', [])) != 1: return False - host = urlsplit(request.scheme + '://' + request.host) origin = urlsplit(request.headers['Origin']) + scheme = effective_scheme(request, origin.scheme) + host = urlsplit(scheme + '://' + request.host) socket = request.transport.get_extra_info('sockname') allowed = {socket[0].split('%')[0]} if ipaddress.ip_address(socket[0].split('%')[0]).is_loopback: allowed.update(('localhost', '127.0.0.1', '::1')) - own_port = host.port or (443 if request.scheme == 'https' else 80) + own_port = host.port or (443 if scheme == 'https' else 80) return (host.hostname in allowed and own_port == socket[1] and origin.hostname == host.hostname and (origin.port or (443 if origin.scheme == 'https' else 80)) == own_port - and origin.scheme == request.scheme and not any((origin.path, origin.query, origin.fragment, - origin.username, origin.password))) + and origin.scheme == scheme and not any((origin.path, origin.query, origin.fragment, + origin.username, origin.password))) except (ValueError, TypeError, IndexError, KeyError): return False diff --git a/freevideo_engine/agent/access.py b/freevideo_engine/agent/access.py index 2c5f9e02..a46a3d07 100644 --- a/freevideo_engine/agent/access.py +++ b/freevideo_engine/agent/access.py @@ -4,6 +4,7 @@ from .jobs import JobError from .. import proxy_identity +from ..origin_scheme import effective_scheme def peer_address(request): @@ -63,8 +64,9 @@ def check(request, *, local=False, lan=False): if origin is not None: parsed = urlsplit(origin) port = parsed.port or (443 if parsed.scheme == 'https' else 80) - own_port = host.port or (443 if scheme == 'https' else 80) - if (parsed.scheme != scheme or parsed.hostname != host.hostname or port != own_port + wanted = scheme if proxied else effective_scheme(request, parsed.scheme) + own_port = host.port or (443 if wanted == 'https' else 80) + if (parsed.scheme != wanted or parsed.hostname != host.hostname or port != own_port or parsed.path or parsed.query or parsed.fragment or parsed.username or parsed.password): raise ValueError except (ValueError, TypeError, IndexError): diff --git a/freevideo_engine/agent/attachments.py b/freevideo_engine/agent/attachments.py new file mode 100644 index 00000000..b6669fc3 --- /dev/null +++ b/freevideo_engine/agent/attachments.py @@ -0,0 +1,71 @@ +"""Chat images in the retained input assets directory, with server-owned filenames.""" +import filecmp +import json +import os +from pathlib import Path +import re +import threading +import uuid + +from . import media, schema + +FOLDER = 'freevideo/agent/assets' +_LOCK = threading.RLock() + + +def path(input_root, name): + if not isinstance(name, str) or not re.fullmatch(schema.CHAT_ATTACHMENT_NAME['pattern'], name): + raise ValueError('Invalid attachment name') + root = Path(input_root).resolve() + target = root / FOLDER / name + # Reject links, including links to another location inside the input directory. + for part in (target, *target.parents): + if part == root: + break + if part.is_symlink(): + raise ValueError('Attachment paths cannot contain links') + if target.resolve() != target: + raise ValueError('Attachment leaves assets') + return target + + +def _metadata(target): + result = target.with_name('.' + target.name + '.json') + if result.is_symlink(): + raise ValueError('Invalid attachment metadata') + return result + + +def publish(input_root, temporary, name, original_name): + """Publish decoded bytes atomically; the first filename for identical content wins.""" + with _LOCK: + target = path(input_root, name) + target.parent.mkdir(parents=True, exist_ok=True) + metadata = _metadata(target) + if not metadata.exists(): + pending = metadata.with_name('.attachment-' + uuid.uuid4().hex + '.tmp') + try: + pending.write_text(json.dumps(dict(original_name=original_name), ensure_ascii=False), encoding='utf-8') + os.replace(pending, metadata) + finally: + pending.unlink(missing_ok=True) + # Identical bytes are already in place; replacing a file the page is reading fails on Windows. + if target.is_file() and filecmp.cmp(temporary, target, shallow=False): + Path(temporary).unlink(missing_ok=True) + else: + os.replace(temporary, target) + return json.loads(metadata.read_text(encoding='utf-8'))['original_name'] + + +def read(input_root, name): + """Reinspect stored bytes; never use dimensions or filenames sent with a message.""" + with _LOCK: + target = path(input_root, name) + if not target.is_file() or target.stat().st_size > schema.MAX_CHAT_ATTACHMENT_BYTES: + raise ValueError('No saved attachment within the upload limit') + info, suffix = media.validate_upload(target) + if suffix not in ('.png', '.jpg', '.webp') or info['kind'] != 'image': + raise ValueError('Not a chat image') + metadata = _metadata(target) + original_name = json.loads(metadata.read_text(encoding='utf-8'))['original_name'] if metadata.exists() else name + return dict(name=name, original_name=original_name, width=info['width'], height=info['height']) diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index 09ed9a1d..2a4268da 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -69,6 +69,7 @@ 'itself; read the project afterwards and check that one clip covers the whole film.', ], 'references': [ + "User message attachments are reference images by default. Use each name directly in media.references, media.first or media.last according to the user's words; if the intended role is unclear, ask one question. Text can be empty when a message has attachments.", 'Tags are numbered per kind in reference order: , ,