clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / tinyagent / hermes / media.py
media.py 2.9K
1"""Validated Tinyrelay room media handling for the Hermes adapter."""23from __future__ import annotations45import hashlib6import re7from typing import Any8from urllib.parse import urlsplit910MAX_MEDIA_BYTES = 32 << 2011_MEDIA_PATH = re.compile(r"^/media/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$", re.I)121314def imeta_fields(tag: list[str]) -> dict[str, str]:15 """Parse NIP-92 ``key value`` fields from an imeta tag."""16 fields: dict[str, str] = {}17 for item in tag[1:]:18 key, separator, value = str(item).partition(" ")19 if separator and key and value:20 fields.setdefault(key.lower(), value)21 return fields222324def _media_path(raw_url: str, relay_url: str) -> tuple[str, str] | None:25 target = urlsplit(raw_url)26 origin = urlsplit(relay_url)27 if target.query or target.fragment or origin.query or origin.fragment:28 return None29 if target.scheme or target.netloc:30 if (target.scheme, target.netloc) != (origin.scheme, origin.netloc):31 return None32 path = target.path33 elif raw_url.startswith("/"):34 path = target.path35 else:36 return None37 prefix = origin.path.rstrip("/")38 if prefix:39 if not path.startswith(prefix + "/"):40 return None41 path = path[len(prefix):]42 match = _MEDIA_PATH.fullmatch(path)43 return (path, match.group(1).lower()) if match else None444546async def incoming_media(rpc: Any, event: dict, relay_url: str) -> tuple[list[str], list[str]]:47 """Download and cache validated same-relay imeta attachments.4849 Tiny authorization is enforced by the signed helper download. The URL and50 digest checks ensure a message cannot make Hermes fetch arbitrary internet51 content or cache bytes under a false identity.52 """53 from gateway.platforms.base import cache_media_bytes_async5455 urls: list[str] = []56 types: list[str] = []57 for tag in event.get("tags", []):58 if not isinstance(tag, list) or len(tag) < 2 or tag[0] != "imeta":59 continue60 fields = imeta_fields(tag)61 location = _media_path(fields.get("url", ""), relay_url)62 if location is None:63 continue64 path, digest = location65 declared = fields.get("x", "").lower()66 if declared and declared != digest:67 continue68 try:69 data, mime = await rpc.download(path)70 except Exception:71 continue72 if len(data) > MAX_MEDIA_BYTES or hashlib.sha256(data).hexdigest() != digest:73 continue74 try:75 if fields.get("size") and int(fields["size"]) != len(data):76 continue77 except ValueError:78 continue79 cached = await cache_media_bytes_async(80 data,81 filename=fields.get("filename", "attachment"),82 mime_type=fields.get("m", mime),83 )84 if cached:85 urls.append(cached.path)86 types.append(cached.media_type)87 return urls, types