From 98c66440f647edfa63ebfdc6e62d9df6389f65a6 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 26 Sep 2026 01:02:28 +0000 Subject: [PATCH] contact messages: public upload endpoint PUT /_contact/files/{name} stores panel uploads in the content-addressed file store: raster images through the usual derivative pipeline, a small allowlist of document types as-is. SVG/HTML are excluded (script carriers). Gates for now: a session-shaped token plus size/type caps; the proof-of-work stamp plugs in here later. The dev proxy learns the /_contact path. --- frontend/vite.config.js | 2 +- pagerite/app.py | 8 ++++-- pagerite/messages.py | 56 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 2ecf691..de5135a 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -16,7 +16,7 @@ const CONTENT_PROXY = '^(?!/_|/@|/src|/node_modules|/__).*$' // https://vite.dev/config/ export default defineConfig({ plugins: [ - fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_ws", "/_translate"] }), + fastapiVue({ paths: ["/_api", "/_f", "/_themes", "/_fonts", "/_a", "/_ws", "/_translate", "/_contact"] }), vue(), vueDevTools(), ], diff --git a/pagerite/app.py b/pagerite/app.py index 5858268..8a2cf29 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -11,6 +11,9 @@ The routes live in specialized modules, included below as APIRouters: (``/_api/*``, ``/_translate/{clientkey}``). - ``pagerite.tracking`` — visit analytics (``/_ws``, ``/_api/ws/analytics``, the ``/_a`` viewer page). +- ``pagerite.messages`` — visitor contact threads (docs/messages.md): the + ``/_contact/files`` upload route; the messaging protocol itself rides + the tracking sockets. - ``pagerite.feeds`` — machine-readable site exports: ``/llms.txt``, ``/feed.json`` (JSON Feed) and ``/feed.xml`` (RSS). - ``pagerite.pages`` — the public content pages: ``/``, ``/sitemap.xml``, @@ -41,7 +44,7 @@ from fastapi.responses import Response from fastapi_vue import Frontend, env from starlette.types import ASGIApp, Receive, Scope, Send -from pagerite import api, feeds, files, pages, tracking +from pagerite import api, feeds, files, messages, pages, tracking from pagerite.files import file_store from pagerite.state import analytics_store, config, kanta @@ -119,9 +122,10 @@ async def _headers(request: Request, call_next) -> Response: # Our own routes first: the editor API and translator socket, the analytics -# machinery, and the file store/user assets. +# machinery, the contact-message upload route, and the file store/user assets. app.include_router(api.router) app.include_router(tracking.router) +app.include_router(messages.router) app.include_router(files.router) # Vue build asset routes are inserted at this position during load(): the diff --git a/pagerite/messages.py b/pagerite/messages.py index 2860243..f291280 100644 --- a/pagerite/messages.py +++ b/pagerite/messages.py @@ -20,21 +20,26 @@ restriction is not). Links get rel="nofollow ugc noopener"; images are limited to /_f/ store names (the upload endpoint is the only way in). """ +import asyncio import logging import re import secrets from datetime import UTC, datetime +from pathlib import Path import blake3 -from fastapi import WebSocket +from fastapi import APIRouter, HTTPException, Request, WebSocket from markdown_it import MarkdownIt from markdown_it.common.utils import escapeHtml from pagerite.data import ContactMessage, ContactThread +from pagerite.files import _ext, _hash_name, file_store, store_image from pagerite.state import data, kanta logger = logging.getLogger(__name__) +router = APIRouter() + #: Session-token shape: crypto-random from the browser (uuid or hex). TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$") @@ -292,3 +297,52 @@ async def admin_frame(ws: WebSocket, raw: dict) -> None: ): thread.admin_read = datetime.now(UTC) await _push(_admin_ws, _payload(key, thread)) + + +# --- Uploads ----------------------------------------------------------------- + +#: Contact-panel uploads: raster images get the usual derivative pipeline, +#: a few document types are stored as-is. SVG and HTML are excluded on +#: purpose — script carriers have no place in a thread whose HTML must +#: stay markdown-it output only. +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".heic", ".heif"} +FILE_EXTS = {".pdf", ".txt", ".md", ".csv", ".zip"} +UPLOAD_MAX_BYTES = 20 * 1024 * 1024 + + +@router.put("/_contact/files/{name}") +async def contact_upload(name: str, request: Request) -> dict: + """Store a contact-panel upload in the content-addressed store. + + Public like the messaging socket (only /_api is gated): the caller + must present a session-shaped token, size and type are capped, and + abuse limiting is meant to ride on the proof-of-work stamp once + plugged in. Images get the usual AVIF/WebP/JPEG derivatives and are + referenced extension-less; other allowed types are stored as-is. + Returns the /_f/ path and whether the file is an inline-able image. + """ + if not TOKEN_RE.fullmatch(request.headers.get("x-contact-token", "")): + raise HTTPException(403, "no session") + try: + length = int(request.headers.get("content-length") or 0) + except ValueError: + length = 0 + if length > UPLOAD_MAX_BYTES: + raise HTTPException(413, "file too large") + body = await request.body() + if not body: + raise HTTPException(400, "empty file") + if len(body) > UPLOAD_MAX_BYTES: + raise HTTPException(413, "file too large") + orig = Path(name).name[:100] or "file" + ext = _ext(orig) + if ext in IMAGE_EXTS: + stored = await asyncio.to_thread(store_image, body, ext, derive=ext != ".gif") + image = True + elif ext in FILE_EXTS: + stored = _hash_name(body, orig) + await asyncio.to_thread(file_store.put, stored, body) + image = False + else: + raise HTTPException(415, "unsupported file type") + return {"path": f"/_f/{stored}", "image": image, "name": orig}