"""Admin-only, one-channel history transfer. ZIP members are read, never extracted. The database transaction owns the import decision and publication. Immutable blob files are installed before their rows commit; a failed transaction may leave an unreferenced object, but never publishes a row whose bytes are incomplete. """ from __future__ import annotations import hashlib import json import math import re import shutil import sqlite3 import tempfile import zipfile from itertools import pairwise from pathlib import Path from urllib.parse import quote, unquote, urlsplit, urlunsplit from . import store from .config import LIMITS from .errors import ApiError, bad_request, conflict, forbidden MAX_BUNDLE_BYTES = 512 * 1024 * 1024 MAX_MANIFEST_BYTES = 32 * 1024 * 1024 MAX_MESSAGES = 10_000 MAX_ATTACHMENTS = 1_000 HASH = re.compile(r"^[0-9a-f]{64}$") ATT_LINK = re.compile(r"(?:/v1/attachments/|#file/)(att_[A-Za-z0-9_-]+)") STORAGE_LINK = re.compile(r"(?:https?://[^\s<>\"'\[\]]+)?/v1/storage/[^/\s<>\"'()\[\]]+/[^\s<>\"'\[\]]+") CODE_SPAN = re.compile(r"(`+)([\s\S]*?)\1|(?m:^ {0,3}(~{3,})[^\n]*\n[\s\S]*?^ {0,3}\3[ \t]*$)") def storage_links(body): # Preserve offsets so the same matches can drive capture and rewriting. # Paths shown as code are examples, not rendered file links. masked = CODE_SPAN.sub(lambda match: " " * len(match.group()), body) return STORAGE_LINK.finditer(masked) def storage_reference(text, source_url): """Resolve only this board's file links; never fetch arbitrary URLs.""" suffix = "" # Keep balanced filename parentheses, but leave Markdown's closing delimiter # outside the URL when a rewritten link is put back into the body. depth = 0 for i, char in enumerate(text): if char == "(": depth += 1 elif char == ")": if not depth: text, suffix = text[:i], text[i:] break depth -= 1 url, source = urlsplit(text), urlsplit(source_url) prefix = "/v1/storage/" path = url.path if url.netloc: if (url.scheme, url.netloc) != (source.scheme, source.netloc): return None prefix = source.path.rstrip("/") + prefix if not path.startswith(prefix): return None handle, _, rest = path[len(prefix):].partition("/") if not handle or not rest or rest.endswith("/"): return None return unquote(handle), store.storage_path(unquote(rest)), url, suffix def require_admin(p): if not p.is_admin: raise forbidden("channel export/import requires an admin") def public_channel(conn, p, channel_id): require_admin(p) ch = store.channel_for(conn, p, channel_id) if ch["kind"] != "public": raise bad_request("only public channels can be exported or imported") return ch def canonical(value): return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() def export_manifest(conn, p, channel_id: int, board_url: str): # Reader connections otherwise run in autocommit mode: use one snapshot for # authors, messages, reactions, doc and attachment metadata. conn.execute("BEGIN") try: ch = public_channel(conn, p, channel_id) rows = conn.execute("SELECT * FROM messages WHERE channel_id = ? ORDER BY created_at, seq, id", (channel_id,)).fetchall() if len(rows) > MAX_MESSAGES: raise ApiError(413, "too_large", f"a channel bundle supports at most {MAX_MESSAGES} messages") messages = [{k: r[k] for k in ("id", "seq", "sender_id", "body", "reply_to", "thread_id", "created_at", "edited_at")} | {"attachments": json.loads(r["attachments"] or "[]")} for r in rows] reactions = [dict(r) for r in conn.execute("""SELECT r.* FROM message_reactions r JOIN messages m ON m.id = r.message_id WHERE m.channel_id = ? ORDER BY r.created_at, r.rowid""", (channel_id,))] doc_row = conn.execute("SELECT body, author_id, created_at FROM channel_doc_versions WHERE channel_id = ? AND version = ?", (channel_id, ch["doc_version"])).fetchone() doc = dict(doc_row) if doc_row else None attachment_ids = {a for m in messages for a in m["attachments"]} for body in [m["body"] for m in messages] + ([doc["body"]] if doc else []): # Only this channel's attachments are copied. Links to other channels # retain their original meaning/access rules, like storage links. for aid in ATT_LINK.findall(body): if conn.execute("SELECT 1 FROM attachments WHERE id = ? AND channel_id = ? AND state = 'ready'", (aid, channel_id)).fetchone(): attachment_ids.add(aid) if len(attachment_ids) > MAX_ATTACHMENTS: raise ApiError(413, "too_large", "too many attachments for one bundle") attachments = [] for aid in sorted(attachment_ids): row = conn.execute("SELECT * FROM attachments WHERE id = ? AND channel_id = ? AND state = 'ready'", (aid, channel_id)).fetchone() if not row: raise conflict("export_changed", "an attachment is missing; export again after checking the channel") attachments.append(dict(row)) storage_files = {} for body in [m["body"] for m in messages] + ([doc["body"]] if doc else []): for match in storage_links(body): ref = storage_reference(match.group(), board_url) if ref is None: continue handle, path, _, _ = ref owner = conn.execute("SELECT id FROM principals WHERE external_id = ? OR hf_username = ?", (handle, handle)).fetchone() row = conn.execute("SELECT * FROM storage_files WHERE owner_id = ? AND path = ?", (owner["id"], path)).fetchone() if owner else None if row is None: raise conflict("export_missing_storage", f"linked storage file is missing: {handle}/{path}") record = storage_files.setdefault((row["owner_id"], path), {k: row[k] for k in ("owner_id", "path", "size", "sha256", "content_type", "updated_at")} | {"handles": []}) if handle not in record["handles"]: record["handles"].append(handle) if len(storage_files) + len(attachments) > MAX_ATTACHMENTS: raise ApiError(413, "too_large", "too many attachment/storage files for one bundle") people = {m["sender_id"] for m in messages} | {r["principal_id"] for r in reactions} people |= {a["uploader_id"] for a in attachments} people |= {f["owner_id"] for f in storage_files.values()} if doc: people.add(doc["author_id"]) principals = [dict(conn.execute("SELECT id, external_id, display_name, identity_type FROM principals WHERE id = ?", (pid,)).fetchone()) for pid in sorted(people)] manifest = {"format": "msgboard-channel", "version": 1, "source": {"board_id": conn.execute("SELECT value FROM settings WHERE key = 'transfer_board_id'").fetchone()[0], "board_url": board_url.rstrip("/"), "channel_id": channel_id, "channel_name": ch["name"]}, "principals": principals, "messages": messages, "reactions": reactions, "doc": doc, "storage_files": list(storage_files.values()), "attachments": [{k: a[k] for k in ("id", "uploader_id", "filename", "content_type", "size", "sha256", "created_at")} for a in attachments]} keys = {a["sha256"]: ("attachment", a["storage_key"]) for a in attachments} for f in storage_files.values(): keys.setdefault(f["sha256"], ("storage", store.storage_key(f["owner_id"], f["path"]))) return manifest, keys finally: conn.execute("ROLLBACK") def build_bundle(manifest, keys, blobs, path: Path, storage=None): validate_manifest(manifest) data = canonical(manifest) if len(data) > MAX_MANIFEST_BYTES: raise ApiError(413, "too_large", "channel manifest is too large") total = len(data) with tempfile.TemporaryDirectory(prefix="msgboard-export-files-") as scratch, zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as z: z.writestr("manifest.json", data) for sha, (kind, key) in keys.items(): digest = hashlib.sha256() try: src_path = blobs.path_for(key) if kind == "attachment" else Path(scratch) / sha if kind == "storage" and not storage.get(key, src_path): raise conflict("export_changed", "linked storage bytes disappeared; retry export") with src_path.open("rb") as src, z.open("blobs/" + sha, "w") as dst: while chunk := src.read(1024 * 1024): total += len(chunk) if total > MAX_BUNDLE_BYTES: raise ApiError(413, "too_large", "channel bundle exceeds 512 MiB") digest.update(chunk) dst.write(chunk) except FileNotFoundError: raise conflict("export_changed", "attachment bytes disappeared; retry the export") if digest.hexdigest() != sha: raise conflict("export_changed", "attachment bytes do not match their recorded checksum") if path.stat().st_size > MAX_BUNDLE_BYTES: raise ApiError(413, "too_large", "channel ZIP exceeds 512 MiB") def _int(value, what): if type(value) is not int or value < 1: raise bad_request(f"{what} must be a positive integer") return value def _time(value, what): if type(value) not in (float, int) or not math.isfinite(value) or value < 0: raise bad_request(f"{what} must be a finite UTC timestamp") def _text(value, what, max_bytes=LIMITS.max_body_bytes): if not isinstance(value, str) or len(value.encode()) > max_bytes: raise bad_request(f"invalid or oversized {what}") def validate_manifest(m): if not isinstance(m, dict) or m.get("format") != "msgboard-channel" or m.get("version") != 1: raise bad_request("expected a version 1 msgboard-channel bundle") source = m.get("source") if not isinstance(source, dict) or not re.fullmatch(r"[0-9a-f]{32}", str(source.get("board_id", ""))): raise bad_request("invalid source board ID") _int(source.get("channel_id"), "source channel ID") _text(source.get("board_url"), "source URL", 2048) url = urlsplit(source["board_url"]) if url.scheme not in ("http", "https") or not url.netloc or url.username or url.password or url.query or url.fragment: raise bad_request("source URL must be an HTTP(S) board address") for field, limit in [("principals", MAX_MESSAGES + MAX_ATTACHMENTS), ("messages", MAX_MESSAGES), ("reactions", MAX_MESSAGES * 100), ("attachments", MAX_ATTACHMENTS), ("storage_files", MAX_ATTACHMENTS)]: if field == "storage_files": m.setdefault(field, []) if not isinstance(m.get(field), list) or len(m[field]) > limit or any(not isinstance(x, dict) for x in m[field]): raise bad_request(f"invalid or oversized {field} list") people = {} handles = set() for p in m["principals"]: p.setdefault("display_name", None) pid = _int(p.get("id"), "author ID") handle = p.get("external_id") if not isinstance(handle, str) or not store.EXTERNAL_ID.fullmatch(handle) or handle.lower() in handles or pid in people: raise bad_request("duplicate or invalid author handle/ID") if p.get("identity_type") not in ("user", "agent", "system"): raise bad_request("invalid author type") if p.get("display_name") is not None: _text(p["display_name"], "display name", 1024) people[pid] = p handles.add(handle.lower()) attachments = {} for a in m["attachments"]: a.setdefault("filename", None) a.setdefault("content_type", None) aid = a.get("id") if not isinstance(aid, str) or not re.fullmatch(r"att_[A-Za-z0-9_-]{1,120}", aid) or aid in attachments: raise bad_request("duplicate or invalid attachment ID") if a.get("uploader_id") not in people or not HASH.fullmatch(str(a.get("sha256", ""))): raise bad_request("invalid attachment author or checksum") if type(a.get("size")) is not int or not 0 <= a["size"] <= LIMITS.max_upload_bytes: raise bad_request("invalid attachment size") for field, limit in [("filename", 1024), ("content_type", 128)]: if a.get(field) is not None: _text(a[field], field, limit) _time(a.get("created_at"), "attachment timestamp") attachments[aid] = a paths = set() aliases = set() for f in m["storage_files"]: if f.get("owner_id") not in people or not HASH.fullmatch(str(f.get("sha256", ""))): raise bad_request("invalid storage owner or checksum") f["path"] = store.storage_path(f.get("path")) if type(f.get("size")) is not int or not 0 <= f["size"] <= MAX_BUNDLE_BYTES: raise bad_request("invalid storage file size") _time(f.get("updated_at"), "storage timestamp") f.setdefault("content_type", None) if f["content_type"] is not None: _text(f["content_type"], "storage content type", 128) if not isinstance(f.get("handles"), list) or not f["handles"]: raise bad_request("storage handle aliases are required") for handle in f["handles"]: if not isinstance(handle, str) or not store.EXTERNAL_ID.fullmatch(handle) or (handle, f["path"]) in aliases: raise bad_request("invalid or duplicate storage alias") aliases.add((handle, f["path"])) if (f["owner_id"], f["path"]) in paths: raise bad_request("duplicate storage path") paths.add((f["owner_id"], f["path"])) ordered_paths = sorted(paths) if any(owner == next_owner and child.startswith(path + "/") for (owner, path), (next_owner, child) in pairwise(ordered_paths)): raise bad_request("storage files have a file/folder collision") if len(paths) + len(attachments) > MAX_ATTACHMENTS: raise bad_request("too many attachment/storage files") messages = {} seqs = set() for msg in m["messages"]: msg.setdefault("reply_to", None) msg.setdefault("edited_at", None) mid = _int(msg.get("id"), "message ID") seq = _int(msg.get("seq"), "message sequence") if mid in messages or seq in seqs or msg.get("sender_id") not in people: raise bad_request("duplicate message ID/sequence or missing author") _text(msg.get("body"), "message body") _time(msg.get("created_at"), "message timestamp") if msg.get("edited_at") is not None: _time(msg["edited_at"], "edit timestamp") if not isinstance(msg.get("attachments"), list) or len(msg["attachments"]) > LIMITS.max_attachments_per_message: raise bad_request("invalid message attachments") if any(not isinstance(aid, str) or aid not in attachments for aid in msg["attachments"]): raise bad_request("message refers to a missing attachment") messages[mid] = msg seqs.add(seq) # Parent links, not untrusted thread_id, define the thread. Validate the whole # graph before any database writes, including parents with later timestamps. for mid, msg in messages.items(): seen = {mid} parent = msg.get("reply_to") while parent is not None: if type(parent) is not int or parent not in messages or parent in seen: raise bad_request("missing parent or cyclic thread") seen.add(parent) parent = messages[parent].get("reply_to") reactions = set() for r in m["reactions"]: if r.get("message_id") not in messages or r.get("principal_id") not in people: raise bad_request("reaction refers to a missing message or author") store._valid_emoji(r.get("emoji")) _time(r.get("created_at"), "reaction timestamp") key = (r["message_id"], r["principal_id"], r["emoji"]) if key in reactions: raise bad_request("duplicate reaction") reactions.add(key) doc = m.setdefault("doc", None) if doc is not None: if not isinstance(doc, dict) or doc.get("author_id") not in people: raise bad_request("invalid channel doc author") _text(doc.get("body"), "channel doc", store.MAX_DOC_BYTES) _time(doc.get("created_at"), "doc timestamp") for body in [msg["body"] for msg in m["messages"]] + ([doc["body"]] if doc else []): for match in storage_links(body): ref = storage_reference(match.group(), source["board_url"]) if ref is not None and (ref[0], ref[1]) not in aliases: raise bad_request("bundle omits a linked same-board storage file") return m def read_bundle(path: Path, stage: Path): try: with zipfile.ZipFile(path) as z: entries = z.infolist() names = [e.filename for e in entries] if len(names) != len(set(names)) or len(names) > MAX_ATTACHMENTS + 1: raise bad_request("duplicate ZIP members or too many files") if sum(e.file_size for e in entries) > MAX_BUNDLE_BYTES: raise ApiError(413, "too_large", "expanded bundle exceeds 512 MiB") if z.getinfo("manifest.json").file_size > MAX_MANIFEST_BYTES: raise ApiError(413, "too_large", "manifest exceeds 32 MiB") m = validate_manifest(json.loads(z.read("manifest.json"))) all_files = m["attachments"] + m["storage_files"] hashes = {a["sha256"] for a in all_files} if set(names) != {"manifest.json"} | {"blobs/" + sha for sha in hashes}: raise bad_request("ZIP members must exactly match the manifest's blobs") files = {} for sha in hashes: expected = {a["size"] for a in all_files if a["sha256"] == sha} info = z.getinfo("blobs/" + sha) if expected != {info.file_size}: raise bad_request("attachment length does not match manifest") dest = stage / sha h = hashlib.sha256() with z.open(info) as src, dest.open("xb") as dst: while chunk := src.read(1024 * 1024): h.update(chunk) dst.write(chunk) if h.hexdigest() != sha: raise bad_request("attachment checksum does not match manifest") files[sha] = dest return m, hashlib.sha256(canonical(m)).hexdigest(), files except (zipfile.BadZipFile, KeyError, ValueError, TypeError, UnicodeError, RuntimeError) as exc: raise bad_request("invalid channel bundle") from exc def plan_import(conn, p, channel_id, m, digest, keep_target_doc=False): ch = public_channel(conn, p, channel_id) existing = conn.execute("SELECT * FROM channel_imports WHERE channel_id = ?", (channel_id,)).fetchone() blockers, mappings = [], [] replay = bool(existing and existing["digest"] == digest) if existing and not replay: blockers.append("target already holds a different import; incremental imports are not supported") if not existing: if conn.execute("SELECT 1 FROM messages WHERE channel_id = ? LIMIT 1", (channel_id,)).fetchone(): blockers.append("initial import requires an empty target channel") if ch["doc_version"] and not keep_target_doc: blockers.append("initial import requires a target with no channel doc") if ch["archived"]: blockers.append("target channel is archived") for author in m["principals"]: rows = conn.execute("SELECT * FROM principals WHERE lower(external_id) = lower(?)", (author["external_id"],)).fetchall() if len(rows) > 1 or (rows and rows[0]["identity_type"] != author["identity_type"]): blockers.append(f"ambiguous or different-type handle: {author['external_id']}") mappings.append({"source_id": author["id"], "handle": author["external_id"], "display_name": author["display_name"], "target_id": rows[0]["id"] if len(rows) == 1 else None, "action": "existing" if rows else "ghost"}) by_author = {a["source_id"]: a for a in mappings} planned_files = [] additions = {} max_file, quota = store.storage_limits(conn) for f in m["storage_files"]: mapping = by_author[f["owner_id"]] owner_id = mapping["target_id"] if owner_id: owner = conn.execute("SELECT disabled, joined_via FROM principals WHERE id = ?", (owner_id,)).fetchone() if owner["disabled"] and owner["joined_via"] != "import": blockers.append(f"mapped storage owner is disabled: {mapping['handle']}") old = conn.execute("SELECT size, sha256 FROM storage_files WHERE owner_id = ? AND path = ?", (owner_id, f["path"])).fetchone() if owner_id else None action = "reuse" if old and old["sha256"] == f["sha256"] and old["size"] == f["size"] else "create" if old and action != "reuse": action = "conflict" blockers.append(f"different target storage file: {mapping['handle']}/{f['path']}") if f["size"] > max_file: blockers.append(f"storage file exceeds target per-file limit: {mapping['handle']}/{f['path']}") if owner_id: occupied = [r[0] for r in conn.execute("SELECT path FROM storage_files WHERE owner_id = ?", (owner_id,))] if any(path.startswith(f["path"] + "/") or f["path"].startswith(path + "/") for path in occupied): blockers.append(f"file/folder collision: {mapping['handle']}/{f['path']}") if not old: additions[f["owner_id"]] = additions.get(f["owner_id"], 0) + f["size"] planned_files.append({"handle": mapping["handle"], "target_id": owner_id, "path": f["path"], "size": f["size"], "sha256": f["sha256"], "action": action}) for source_id, size in additions.items(): mapping = by_author[source_id] used = store.storage_usage(conn, mapping["target_id"]) if mapping["target_id"] else 0 if used + size > quota: blockers.append(f"storage folder exceeds target quota: {mapping['handle']}") counts = {"messages": len(m["messages"]), "attachments": len(m["attachments"]), "storage_files": len(planned_files), "bytes": sum(a["size"] for a in m["attachments"] + m["storage_files"]), "reactions": len(m["reactions"]), "ghosts": sum(a["action"] == "ghost" for a in mappings), "doc": int(m["doc"] is not None)} report = {"source": m["source"], "target_channel_id": channel_id, "digest": digest, "already_imported": replay, "can_import": not blockers, "blockers": blockers, "counts": counts, "would_create": {k: 0 if replay else v for k, v in counts.items()}, "authors": mappings, "storage_files": planned_files} report["would_create"]["storage_files"] = 0 if replay else sum(f["action"] == "create" for f in planned_files) report["document"] = {"action": "keep_target" if keep_target_doc else "import_source" if m["doc"] else "none", "target_version": ch["doc_version"]} if keep_target_doc: report["would_create"]["doc"] = 0 if replay: previous = json.loads(existing["report"]) report["document"] = previous.get("document", report["document"]) return report def verify_reused_storage(report, storage): """Metadata equality alone cannot prove a mutable bucket key still has its bytes.""" with tempfile.TemporaryDirectory(prefix="msgboard-reuse-") as scratch: for f in report["storage_files"]: if f["action"] != "reuse": continue path = Path(scratch) / "file" ok = storage.get(store.storage_key(f["target_id"], f["path"]), path) digest = hashlib.sha256() if ok and path.stat().st_size == f["size"]: with path.open("rb") as src: while chunk := src.read(1024 * 1024): digest.update(chunk) if not ok or path.stat().st_size != f["size"] or digest.hexdigest() != f["sha256"]: report["blockers"].append(f"target storage bytes unavailable or mismatched: {f['handle']}/{f['path']}") report["can_import"] = False return report def _rewrite_storage(body, source_url, files, handles): aliases = {(alias, f["path"]): handles[f["owner_id"]] for f in files for alias in f["handles"]} def replace(match): ref = storage_reference(match.group(), source_url) if ref is None or (ref[0], ref[1]) not in aliases: return match.group() handle, path, url, suffix = ref target = aliases[(handle, path)] if not url.netloc and target == handle: return match.group() return urlunsplit(("", "", "/v1/storage/" + quote(target, safe="") + "/" + quote(path, safe="/"), url.query, url.fragment)) + suffix parts, start = [], 0 for match in storage_links(body): parts.extend((body[start:match.start()], replace(match))) start = match.end() parts.append(body[start:]) return "".join(parts) def _rewrite_attachments(body, source_url, ids): # Absolute links to this source become target-local links. Foreign/storage # links and old textual "msg N" references are deliberately untouched. for old, new in ids.items(): body = body.replace(source_url.rstrip("/") + "/v1/attachments/" + old, "/v1/attachments/" + new) body = re.sub(r"(?