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:
1 parent
2fb0cc219b
commit
98c66440f6
3 files changed
+62
-4
No files matched your search
@@ -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
@@ -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
@@ -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}
|
||||
Reference in new issue
Block a user