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.
This commit is contained in:
LeoVasanko committed 2026-09-26 01:02:28 +00:00
1 parent 2fb0cc219b
commit 98c66440f6
3 files changed
+62 -4

No files matched your search

+1 -1
View File
@@ -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(),
],
+6 -2
View File
@@ -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
+55 -1
View File
@@ -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}