"""FlowDeck — Sites & public Forms (v6.8.0). Notion Sites + Forms parity: multi-page public sites (/s/) with nav, password/expiry gating, SEO + view stats, and anonymous collection forms (/f/) with rate limiting, validation and notifications. Auth: session cookie first, Bearer token fallback (api_tokens, extension_devices, legacy user_tokens) via api_v2_helpers. """ from __future__ import annotations import hashlib import html import json import re import secrets import time import unicodedata from datetime import UTC, datetime from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from app.auth.session import SessionManager from app.config import settings from app.db import get_conn from app.password_utils import hash_password, verify_password from app.services.api_v2_helpers import ( audit_log, get_bearer_user, has_scope, paginate_headers, parse_pagination, resolve_bearer_token, row_to_dict, ) router = APIRouter(tags=["sites"]) _SLUG_RE = re.compile(r"^[a-z0-9-]{3,50}$") _FORM_TOKEN_RE = re.compile(r"^f_[A-Za-z0-9_-]{6,64}$") # In-memory rate limiting for anonymous form posts: ip -> (window_start, count). _form_rate: dict[str, tuple[float, int]] = {} _FORM_RATE_MAX = 20 _FORM_RATE_WINDOW = 3600.0 # ── helpers ──────────────────────────────────────────────────────────────── def _slugify(title: str) -> str: slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii") slug = re.sub(r"[^\w\s-]", "", slug.lower()) slug = re.sub(r"[-\s]+", "-", slug).strip("-") return slug or "untitled" def _check_slug(slug: str) -> None: if not _SLUG_RE.match(slug or ""): raise HTTPException(400, "Invalid slug: 3-50 chars, lowercase letters, digits, dashes.") def _auth_user(request: Request, *, require_write: bool = False) -> dict: """Session-first auth, Bearer fallback. Enforces scope for Bearer tokens.""" sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if sess: return sess auth = request.headers.get("authorization") or request.headers.get("Authorization") or "" if auth.lower().startswith("bearer "): user = resolve_bearer_token(auth[7:].strip()) if not user: raise HTTPException(401, "Invalid or expired API token") if require_write and not has_scope(user.get("_token_scopes") or "read", "write"): raise HTTPException(403, "Insufficient scope. Required: write") return user raise HTTPException(401, "Authentication required") def _site_auth_cookie(site_id: int) -> str: return f"site_auth_{site_id}" def _site_unlocked(request: Request, site: dict) -> bool: if not site.get("password_hash"): return True from itsdangerous import BadSignature, URLSafeTimedSerializer ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth") try: val = ser.loads(request.cookies.get(_site_auth_cookie(site["id"]), ""), max_age=86400) return val == site["id"] except BadSignature: return False except Exception: return False def _site_expired(site: dict) -> bool: exp = site.get("expires_at") if not exp: return False try: dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) return dt.timestamp() < time.time() except Exception: return False def _resolve_site(conn, *, slug: str = "", host: str = "") -> dict | None: row = None if slug: row = conn.execute("SELECT * FROM sites WHERE slug=?", (slug,)).fetchone() elif host: row = conn.execute("SELECT * FROM sites WHERE custom_domain=?", (host.split(":")[0],)).fetchone() return dict(row) if row else None def _site_pages(conn, site_id: int) -> list[dict]: rows = conn.execute( """SELECT p.id, p.title, p.page_icon, p.cover_url, p.updated_at, sp.position FROM site_pages sp JOIN pages p ON p.id = sp.page_id WHERE sp.site_id=? AND (p.deleted_at IS NULL OR p.deleted_at='') ORDER BY sp.position, p.id""", (site_id,), ).fetchall() out = [] for r in rows: d = dict(r) d["slug"] = _slugify(d.get("title") or "untitled") or f"page-{d['id']}" out.append(d) return out def _find_site_page(pages: list[dict], ref: str) -> dict | None: ref = (ref or "").strip() if ref.isdigit(): for p in pages: if p["id"] == int(ref): return p for p in pages: if p["slug"] == ref: return p # slug with - suffix fallback m = re.search(r"-(\d+)$", ref) if m: for p in pages: if p["id"] == int(m.group(1)): return p return None def _render_page_html(page: dict) -> str: """Render a pages row to HTML (blocks → public renderer, else
)."""
    if page.get("content_format") == "blocks" and page.get("content"):
        try:
            from app.routers import dashboard as _dash
            blocks = json.loads(page["content"])
            try:
                from app.services.synced_blocks import resolve_synced_block
                blocks = resolve_synced_block(blocks)
            except Exception:
                pass
            titles: dict = {}
            try:
                from app.db import get_conn as _gc
                from app.services.wiki_links import token_labels
                with _gc() as _c:
                    titles = token_labels(_c, page["content"])
            except Exception:
                titles = {}
            return _dash._render_blocks_public(blocks, titles)
        except Exception:
            return f"

{html.escape(str(page.get('content', '')))}

" if page.get("content"): return ( "
{html.escape(page['content'])}
" ) return "

Empty page.

" def _site_shell(*, site: dict, pages: list[dict], current_id: int, title: str, body_html: str, noindex: bool = False) -> str: nav = "".join( f"" f"{html.escape((p.get('page_icon') or '') + ' ' + (p.get('title') or 'Untitled'))}" for p in pages ) robots = "noindex,nofollow" if (noindex or site.get("noindex")) else "index,follow" desc = html.escape((site.get("title") or title)[:160]) theme_bg = "#191919" if site.get("theme", "dark") == "dark" else "#ffffff" theme_fg = "#e0e0e0" if site.get("theme", "dark") == "dark" else "#222222" return f""" {html.escape(title)} — {html.escape(site.get('title') or 'FlowDeck Site')} """ def _track_view(site_id: int) -> None: day = datetime.now(UTC).strftime("%Y-%m-%d") try: with get_conn() as conn: conn.execute( """INSERT INTO site_views (site_id, day, views) VALUES (?, ?, 1) ON CONFLICT(site_id, day) DO UPDATE SET views=views+1""", (site_id, day), ) conn.commit() except Exception: pass def _form_config(conn, collection_id: int) -> dict: row = conn.execute( "SELECT id, name, form_config_json FROM collections WHERE id=?", (collection_id,) ).fetchone() if not row: raise HTTPException(404, "Collection not found") try: cfg = json.loads(row["form_config_json"] or "{}") except Exception: cfg = {} return {"id": row["id"], "name": row["name"], "config": cfg} def _check_form_rate(ip: str) -> None: now = time.time() start, count = _form_rate.get(ip, (now, 0)) if now - start > _FORM_RATE_WINDOW: _form_rate[ip] = (now, 1) return if count >= _FORM_RATE_MAX: raise HTTPException(429, "Too many submissions. Try again later.") _form_rate[ip] = (start, count + 1) # ── Sites CRUD (session or Bearer) ───────────────────────────────────────── @router.post("/api/v2/sites") async def create_site(request: Request): user = _auth_user(request, require_write=True) try: body = await request.json() except Exception: body = {} root_page_id = body.get("root_page_id") if not root_page_id: raise HTTPException(400, "root_page_id is required") slug = (body.get("slug") or "").strip().lower() or None with get_conn() as conn: page = conn.execute("SELECT id, title FROM pages WHERE id=?", (root_page_id,)).fetchone() if not page: raise HTTPException(404, "Root page not found") if not slug: slug = _slugify(page["title"]) base, i = slug, 1 while conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone(): slug = f"{base}-{i}" i += 1 else: _check_slug(slug) if conn.execute("SELECT id FROM sites WHERE slug=?", (slug,)).fetchone(): raise HTTPException(409, "Slug already taken") theme = body.get("theme", "dark") if theme not in ("light", "dark"): raise HTTPException(400, "theme must be light or dark") custom_domain = (body.get("custom_domain") or "").strip() or None if custom_domain and conn.execute( "SELECT id FROM sites WHERE custom_domain=?", (custom_domain,) ).fetchone(): raise HTTPException(409, "Domain already linked to another site") expires_at = body.get("expires_at") if expires_at: try: datetime.fromisoformat(str(expires_at).replace("Z", "+00:00")) except Exception: raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None cur = conn.execute( """INSERT INTO sites (slug, root_page_id, title, theme, custom_domain, expires_at, noindex, analytics_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", (slug, root_page_id, body.get("title") or page["title"], theme, custom_domain, expires_at, 1 if body.get("noindex") else 0, (body.get("analytics_id") or "")[:120], user["id"]), ) site_id = cur.lastrowid conn.execute( "INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, 0)", (site_id, root_page_id), ) conn.commit() site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() audit_log(user, "site.create", "site", site_id, f"slug={slug}", request) return JSONResponse(status_code=201, content=row_to_dict(site)) @router.get("/api/v2/sites") async def list_sites(request: Request): user = _auth_user(request) limit, offset = parse_pagination(request) with get_conn() as conn: total = conn.execute( "SELECT COUNT(*) FROM sites WHERE created_by=?", (user["id"],) ).fetchone()[0] rows = conn.execute( "SELECT * FROM sites WHERE created_by=? ORDER BY id DESC LIMIT ? OFFSET ?", (user["id"], limit, offset), ).fetchall() resp = JSONResponse([row_to_dict(r) for r in rows]) for k, v in paginate_headers(total).items(): resp.headers[k] = v return resp @router.get("/api/v2/sites/{site_id}") async def get_site(site_id: int, request: Request): user = _auth_user(request) with get_conn() as conn: row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() if not row: raise HTTPException(404, "Site not found") site = dict(row) if site.get("created_by") != user["id"] and not user.get("is_admin"): raise HTTPException(404, "Site not found") pages = _site_pages(conn, site_id) out = row_to_dict(row) out["pages"] = pages return out @router.patch("/api/v2/sites/{site_id}") async def update_site(site_id: int, request: Request): user = _auth_user(request, require_write=True) try: body = await request.json() except Exception: body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() if not row: raise HTTPException(404, "Site not found") site = dict(row) if site.get("created_by") != user["id"] and not user.get("is_admin"): raise HTTPException(404, "Site not found") updates: dict = {} if "title" in body: updates["title"] = str(body["title"] or "")[:200] if "theme" in body: if body["theme"] not in ("light", "dark"): raise HTTPException(400, "theme must be light or dark") updates["theme"] = body["theme"] if "slug" in body and body["slug"] != site["slug"]: _check_slug(str(body["slug"]).lower()) if conn.execute( "SELECT id FROM sites WHERE slug=? AND id!=?", (body["slug"].lower(), site_id) ).fetchone(): raise HTTPException(409, "Slug already taken") updates["slug"] = str(body["slug"]).lower() if "custom_domain" in body: dom = (body["custom_domain"] or "").strip() or None if dom and conn.execute( "SELECT id FROM sites WHERE custom_domain=? AND id!=?", (dom, site_id) ).fetchone(): raise HTTPException(409, "Domain already linked to another site") updates["custom_domain"] = dom if "expires_at" in body: if body["expires_at"]: try: datetime.fromisoformat(str(body["expires_at"]).replace("Z", "+00:00")) except Exception: raise HTTPException(400, "Invalid expires_at (use ISO-8601)") from None updates["expires_at"] = body["expires_at"] if "noindex" in body: updates["noindex"] = 1 if body["noindex"] else 0 if "analytics_id" in body: updates["analytics_id"] = str(body["analytics_id"] or "")[:120] if "password" in body: updates["password_hash"] = hash_password(str(body["password"])) if body["password"] else "" if updates: updates["updated_at"] = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S") sets = ", ".join(f"{k}=?" for k in updates) conn.execute(f"UPDATE sites SET {sets} WHERE id=?", (*updates.values(), site_id)) conn.commit() site = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() audit_log(user, "site.update", "site", site_id, ",".join(updates), request) return row_to_dict(site) @router.delete("/api/v2/sites/{site_id}") async def delete_site(site_id: int, request: Request): user = _auth_user(request, require_write=True) with get_conn() as conn: row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() if not row: raise HTTPException(404, "Site not found") if row["created_by"] != user["id"] and not user.get("is_admin"): raise HTTPException(404, "Site not found") conn.execute("DELETE FROM sites WHERE id=?", (site_id,)) conn.commit() audit_log(user, "site.delete", "site", site_id, "", request) return {"status": "deleted", "id": site_id} @router.get("/api/v2/sites/{site_id}/pages") async def list_site_pages(site_id: int, request: Request): user = _auth_user(request) with get_conn() as conn: row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() if not row or (row["created_by"] != user["id"] and not user.get("is_admin")): raise HTTPException(404, "Site not found") return {"site_id": site_id, "pages": _site_pages(conn, site_id)} @router.post("/api/v2/sites/{site_id}/pages") async def add_site_page(site_id: int, request: Request): user = _auth_user(request, require_write=True) try: body = await request.json() except Exception: body = {} page_id = body.get("page_id") if not page_id: raise HTTPException(400, "page_id is required") with get_conn() as conn: row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() if not row or (row["created_by"] != user["id"] and not user.get("is_admin")): raise HTTPException(404, "Site not found") if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone(): raise HTTPException(404, "Page not found") pos = conn.execute( "SELECT COALESCE(MAX(position), -1)+1 FROM site_pages WHERE site_id=?", (site_id,) ).fetchone()[0] conn.execute( "INSERT OR IGNORE INTO site_pages (site_id, page_id, position) VALUES (?, ?, ?)", (site_id, page_id, pos), ) conn.commit() pages = _site_pages(conn, site_id) audit_log(user, "site.page.add", "site", site_id, f"page={page_id}", request) return {"site_id": site_id, "pages": pages} @router.delete("/api/v2/sites/{site_id}/pages/{page_id}") async def remove_site_page(site_id: int, page_id: int, request: Request): user = _auth_user(request, require_write=True) with get_conn() as conn: row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() if not row or (row["created_by"] != user["id"] and not user.get("is_admin")): raise HTTPException(404, "Site not found") if page_id == row["root_page_id"]: raise HTTPException(400, "Cannot remove the root page") conn.execute( "DELETE FROM site_pages WHERE site_id=? AND page_id=?", (site_id, page_id) ) conn.commit() audit_log(user, "site.page.remove", "site", site_id, f"page={page_id}", request) return {"status": "removed", "site_id": site_id, "page_id": page_id} @router.get("/api/v2/sites/{site_id}/stats") async def site_stats(site_id: int, request: Request, days: int = 30): user = _auth_user(request) days = max(1, min(int(days or 30), 365)) with get_conn() as conn: row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() if not row or (row["created_by"] != user["id"] and not user.get("is_admin")): raise HTTPException(404, "Site not found") rows = conn.execute( "SELECT day, views FROM site_views WHERE site_id=? ORDER BY day DESC LIMIT ?", (site_id, days), ).fetchall() total = conn.execute( "SELECT COALESCE(SUM(views), 0) FROM site_views WHERE site_id=?", (site_id,) ).fetchone()[0] return {"site_id": site_id, "total_views": total, "days": [{"day": r["day"], "views": r["views"]} for r in rows]} # ── Public site rendering ────────────────────────────────────────────────── def _public_guard(site: dict, request: Request): if _site_expired(site): return HTMLResponse("

410 — Site expired.

", status_code=410) if site.get("password_hash") and not _site_unlocked(request, site): return HTMLResponse( f"""

🔒 {html.escape(site.get('title') or 'Protected site')}

""", status_code=401, ) return None @router.get("/s/{slug}", response_class=HTMLResponse) async def public_site_home(request: Request, slug: str): with get_conn() as conn: site = _resolve_site(conn, slug=slug, host=request.headers.get("host", "")) if not site: return HTMLResponse("

404 — Site not found.

", status_code=404) guard = _public_guard(site, request) if guard: return guard pages = _site_pages(conn, site["id"]) page = conn.execute("SELECT * FROM pages WHERE id=?", (site["root_page_id"],)).fetchone() if not page: return HTMLResponse("

404 — Root page removed.

", status_code=404) page = dict(page) _track_view(site["id"]) body = f"

{html.escape(page.get('title') or 'Untitled')}

" + _render_page_html(page) return _site_shell(site=site, pages=pages, current_id=page["id"], title=page.get("title") or "Untitled", body_html=body) @router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse) async def site_sitemap(request: Request, slug: str): with get_conn() as conn: site = _resolve_site(conn, slug=slug) if not site or _site_expired(site) or site.get("password_hash"): return PlainTextResponse("Not found", status_code=404) pages = _site_pages(conn, site["id"]) base = str(request.base_url).rstrip("/") urls = [f"{base}/s/{slug}"] + [ f"{base}/s/{slug}/{p['slug']}" for p in pages ] return PlainTextResponse( "" "" f"{''.join(urls)}", media_type="application/xml", ) @router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse) async def public_site_page(request: Request, slug: str, page_ref: str): with get_conn() as conn: site = _resolve_site(conn, slug=slug, host=request.headers.get("host", "")) if not site: return HTMLResponse("

404 — Site not found.

", status_code=404) guard = _public_guard(site, request) if guard: return guard pages = _site_pages(conn, site["id"]) target = _find_site_page(pages, page_ref) if not target: return HTMLResponse("

404 — Page not in this site.

", status_code=404) page = conn.execute("SELECT * FROM pages WHERE id=?", (target["id"],)).fetchone() if not page: return HTMLResponse("

404 — Page removed.

", status_code=404) page = dict(page) _track_view(site["id"]) body = f"

{html.escape(page.get('title') or 'Untitled')}

" + _render_page_html(page) return _site_shell(site=site, pages=pages, current_id=page["id"], title=page.get("title") or "Untitled", body_html=body) @router.post("/s/{slug}/auth") async def public_site_auth(request: Request, slug: str): with get_conn() as conn: site = _resolve_site(conn, slug=slug) if not site: return JSONResponse({"detail": "Site not found"}, status_code=404) if not site.get("password_hash"): return {"status": "public"} ctype = request.headers.get("content-type", "") password = "" if "application/json" in ctype: try: password = (await request.json()).get("password", "") except Exception: password = "" else: try: form = await request.form() password = form.get("password", "") except Exception: password = "" if not verify_password(password or "", site["password_hash"] or ""): raise HTTPException(401, "Wrong password") from itsdangerous import URLSafeTimedSerializer ser = URLSafeTimedSerializer(settings.app_secret_key, salt="site-auth") resp = JSONResponse({"status": "unlocked"}) resp.set_cookie(_site_auth_cookie(site["id"]), ser.dumps(site["id"]), httponly=True, samesite="lax", max_age=86400, path="/") return resp # ── Public Forms ─────────────────────────────────────────────────────────── @router.get("/api/v2/collections/{collection_id}/form") async def get_form_config(collection_id: int, request: Request): _auth_user(request) with get_conn() as conn: info = _form_config(conn, collection_id) return {"collection_id": collection_id, "name": info["name"], "form": info["config"]} @router.put("/api/v2/collections/{collection_id}/form") async def put_form_config(collection_id: int, request: Request): user = _auth_user(request, require_write=True) try: body = await request.json() except Exception: body = {} with get_conn() as conn: info = _form_config(conn, collection_id) cfg = info["config"] if isinstance(info["config"], dict) else {} if "enabled" in body: cfg["enabled"] = bool(body["enabled"]) for key in ("title", "success_message"): if key in body: cfg[key] = str(body[key] or "")[:300] for key in ("fields", "required", "notify_user_ids"): if key in body and isinstance(body[key], list): cfg[key] = body[key][:50] if "public_token" in body and body["public_token"]: tok = str(body["public_token"]) if not _FORM_TOKEN_RE.match(tok): raise HTTPException(400, "Invalid public_token (f_ + 6-64 chars)") cfg["public_token"] = tok if cfg.get("enabled") and not cfg.get("public_token"): cfg["public_token"] = "f_" + secrets.token_urlsafe(9) conn.execute( "UPDATE collections SET form_config_json=? WHERE id=?", (json.dumps(cfg), collection_id), ) conn.commit() audit_log(user, "form.config", "collection", collection_id, "", request) return {"collection_id": collection_id, "form": cfg} def _collection_props(conn, collection_id: int) -> list[dict]: return [dict(r) for r in conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()] @router.get("/f/{token}", response_class=HTMLResponse) async def public_form(request: Request, token: str): embed = request.query_params.get("embed") == "1" with get_conn() as conn: row = conn.execute("SELECT * FROM collections").fetchone() target = None if _FORM_TOKEN_RE.match(token or ""): for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall(): try: cfg = json.loads(c["form_config_json"] or "{}") except Exception: continue if cfg.get("enabled") and cfg.get("public_token") == token: target = (c, cfg) break _ = row if not target: return HTMLResponse("

404 — Form not found.

", status_code=404) coll, cfg = target props = _collection_props(conn, coll["id"]) fields = cfg.get("fields") or [p["name"] for p in props if p["prop_type"] != "formula"][:10] required = set(cfg.get("required") or []) inputs = "" for name in fields: prop = next((p for p in props if p["name"] == name), None) ptype = (prop or {}).get("prop_type", "text") itype = {"number": "number", "email": "email", "url": "url", "date": "date", "phone": "tel"}.get(ptype, "text") req = "required" if name in required else "" if ptype in ("select", "status") and prop: try: opts = json.loads(prop.get("options_json") or "[]") except Exception: opts = [] opts_html = "".join( f"" for o in opts) inputs += (f"" f"") elif ptype == "checkbox": inputs += (f"") else: inputs += (f"" f"") chrome = "" if embed else f"

{html.escape(cfg.get('title') or coll['name'])}

" return HTMLResponse( f""" {html.escape(cfg.get('title') or coll['name'])} {chrome}
{inputs}
""" ) @router.post("/f/{token}") async def submit_form(request: Request, token: str): ip = request.client.host if request.client else "unknown" _check_form_rate(ip or "unknown") ctype = request.headers.get("content-type", "") data: dict = {} if "application/json" in ctype: try: data = await request.json() except Exception: data = {} else: try: form = await request.form() data = dict(form) except Exception: data = {} if data.get("__hp"): raise HTTPException(400, "Spam detected") with get_conn() as conn: target = None if _FORM_TOKEN_RE.match(token or ""): for c in conn.execute("SELECT id, name, form_config_json FROM collections").fetchall(): try: cfg = json.loads(c["form_config_json"] or "{}") except Exception: continue if cfg.get("enabled") and cfg.get("public_token") == token: target = (c, cfg) break if not target: # NOTE: return (not raise) — the global 404 handler redirects # non-/api paths to /workspaces, which would turn this into a 200. return JSONResponse({"detail": "Form not found"}, status_code=404) coll, cfg = target props = _collection_props(conn, coll["id"]) by_name = {p["name"]: p for p in props} fields = cfg.get("fields") or list(by_name)[:10] required = set(cfg.get("required") or []) values: dict = {} for name in fields: prop = by_name.get(name) if not prop: continue raw = data.get(name, "") if prop["prop_type"] == "checkbox": raw = True if raw in (True, "on", "true", "1", "checked") else False if name in required and (raw is None or raw == "" or raw is False): raise HTTPException(400, f"Field required: {name}") values[str(prop["id"])] = raw # Validate via property_types.validate_property_rule try: from app.services.property_types import validate_property_rule for name in fields: prop = by_name.get(name) if not prop: continue ok, _msg = validate_property_rule( prop.get("prop_type", "text"), values.get(str(prop["id"])), prop.get("validation_json") or prop.get("options_json") or "") if not ok: raise HTTPException(400, f"Invalid value for {name}: {_msg}") except HTTPException: raise except Exception: pass title = str(data.get(by_name[fields[0]]["name"], "Form response") if fields else "Form response")[:200] cur = conn.execute( """INSERT INTO collection_pages (collection_id, title, property_values_json) VALUES (?, ?, ?)""", (coll["id"], title or "Form response", json.dumps(values)), ) row_id = cur.lastrowid ip_hash = hashlib.sha256(f"{ip}|{datetime.now(UTC).strftime('%Y-%m-%d')}".encode()).hexdigest() conn.execute( "INSERT INTO form_responses (collection_id, row_id, ip_hash) VALUES (?, ?, ?)", (coll["id"], row_id, ip_hash), ) conn.commit() notify_ids = cfg.get("notify_user_ids") or [] # Notify (never throws the submission) try: from app.services.notifications import create_notification for uid in notify_ids[:20]: try: create_notification(int(uid), None, "form_response", f"New response: {coll['name']}", f"{title}", "collection", coll["id"], f"/db/{coll['id']}") except Exception: continue except Exception: pass try: from app.services.automations import fire_event as _fire await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id}) except Exception: pass if "application/json" in ctype: return {"status": "ok", "row_id": row_id, "message": cfg.get("success_message") or "Merci !"} return HTMLResponse( f"""

{html.escape(cfg.get('success_message') or 'Merci !')}

""" ) # used by tests to reset the anonymous rate limiter def _reset_form_rate() -> None: _form_rate.clear() # Backwards-compat alias for tests importing ``get_bearer_user`` from here. __all__ = ["router", "get_bearer_user"]