Files
flowdeck/app/routers/sites.py
T
bruno 224bda74d5
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
fix: A21 phase 1 — 352 routes async sans await → threadpool (v7.8.0)
- Conversion `async def` → `def` de TOUTES les routes dont le corps ne contient
  ni `await`, ni `async with`, ni `async for`, ni `asyncio` (scan automatique
  corps par corps sur app/ : 352 converties, 0 dangereuses, vérifié
  `asyncio`/`run_coroutine`/`.result()` absents). FastAPI exécute ces handlers
  dans son threadpool → tout leur SQLite (`get_conn()` + `conn.execute`) quitte
  l'event loop, sans changer une ligne de logique.
- Répartition : api_v2 60, dashboard 40, collections 25, board 23,
  workspace 19, wiki 17, permissions 14, api 14, main.py 6, + 35 fichiers.
- Les 4 routers prioritaires de l'audit sont couverts par ce lot :
  api_v2 60 + dashboard 40 + collections 25 + board 23 = 148 conversions
  (le reste de leurs routes attend la phase 2 : elles ont de vrais `await`).
- Reste (phase 2) : les 311 routes avec de vrais `await` → enrouler les blocs
  DB dans `await anyio.to_thread.run_sync(...)` ; pas de wrapper partagé livré
  (rien ne l'appellerait — YAGNI jusqu'au premier usage).

suite **1037/1037** (233 s) · `ruff check app tests` OK · docs à jour
2026-10-01 10:53:26 -04:00

839 lines
36 KiB
Python

"""FlowDeck — Sites & public Forms (v6.8.0).
Notion Sites + Forms parity: multi-page public sites (/s/<slug>) with nav,
password/expiry gating, SEO + view stats, and anonymous collection forms
(/f/<token>) 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 logging
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,
)
logger = logging.getLogger(__name__)
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 -<id> 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 <pre>)."""
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:
logger.exception("_render_page_html")
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"<p>{html.escape(str(page.get('content', '')))}</p>"
if page.get("content"):
return (
"<pre style='white-space:pre-wrap;font-family:system-ui;"
f"font-size:16px;line-height:1.6;'>{html.escape(page['content'])}</pre>"
)
return "<p style='color:#999'>Empty page.</p>"
def _site_shell(*, site: dict, pages: list[dict], current_id: int, title: str,
body_html: str, noindex: bool = False) -> str:
nav = "".join(
f"<a href='/s/{site['slug']}/{p['slug']}'"
f" style='display:block;padding:6px 10px;border-radius:6px;text-decoration:none;"
f"color:{'#fff' if p['id'] == current_id else '#bbb'};"
f"background:{'#333' if p['id'] == current_id else 'transparent'}'>"
f"{html.escape((p.get('page_icon') or '') + ' ' + (p.get('title') or 'Untitled'))}</a>"
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"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="robots" content="{robots}">
<meta name="description" content="{desc}">
<meta property="og:title" content="{html.escape(title)}">
<meta property="og:description" content="{desc}">
<meta name="twitter:card" content="summary">
<title>{html.escape(title)} — {html.escape(site.get('title') or 'FlowDeck Site')}</title>
<style>body{{font-family:system-ui,sans-serif;background:{theme_bg};color:{theme_fg};margin:0}}
.layout{{display:flex;min-height:100vh}}.nav{{width:240px;padding:16px;border-right:1px solid #333}}
.main{{flex:1;padding:32px;max-width:860px}}a{{color:#4c9aff}}
@media(max-width:700px){{.nav{{display:none}}.main{{padding:16px}}}}</style></head>
<body><div class="layout"><nav class="nav">
<a href="/s/{site['slug']}" style="font-weight:700;color:{theme_fg};text-decoration:none">
{html.escape(site.get('title') or 'Site')}</a><div style="height:12px"></div>{nav}</nav>
<main class="main">{body_html}</main></div></body></html>"""
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:
logger.exception("_track_view")
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")
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}")
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}")
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")
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}")
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")
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("<h1>410 — Site expired.</h1>", status_code=410)
if site.get("password_hash") and not _site_unlocked(request, site):
return HTMLResponse(
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
display:flex;align-items:center;justify-content:center;height:100vh">
<form method="post" action="/s/{site['slug']}/auth">
<h2>🔒 {html.escape(site.get('title') or 'Protected site')}</h2>
<input type="password" name="password" placeholder="Password"
style="padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff">
<button style="padding:8px 14px;border-radius:6px">Unlock</button></form></body></html>""",
status_code=401,
)
return None
@router.get("/s/{slug}", response_class=HTMLResponse)
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("<h1>404 — Site not found.</h1>", 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("<h1>404 — Root page removed.</h1>", status_code=404)
page = dict(page)
_track_view(site["id"])
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _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)
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"<url><loc>{base}/s/{slug}</loc></url>"] + [
f"<url><loc>{base}/s/{slug}/{p['slug']}</loc></url>" for p in pages
]
return PlainTextResponse(
"<?xml version='1.0' encoding='UTF-8'?>"
"<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>"
f"{''.join(urls)}</urlset>",
media_type="application/xml",
)
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
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("<h1>404 — Site not found.</h1>", 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("<h1>404 — Page not in this site.</h1>", status_code=404)
page = conn.execute("SELECT * FROM pages WHERE id=?", (target["id"],)).fetchone()
if not page:
return HTMLResponse("<h1>404 — Page removed.</h1>", status_code=404)
page = dict(page)
_track_view(site["id"])
body = f"<h1>{html.escape(page.get('title') or 'Untitled')}</h1>" + _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:
logger.exception("public_site_auth")
password = ""
else:
try:
form = await request.form()
password = form.get("password", "")
except Exception:
logger.exception("public_site_auth")
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")
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)
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("<h1>404 — Form not found.</h1>", 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"<option>{html.escape(o.get('name', ''))}</option>" for o in opts)
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
f"<select name='{html.escape(name)}' {req}>{opts_html}</select>")
elif ptype == "checkbox":
inputs += (f"<label><input type='checkbox' name='{html.escape(name)}'> "
f"{html.escape(name)}</label>")
else:
inputs += (f"<label>{html.escape(name)}{'*' if req else ''}</label>"
f"<input type='{itype}' name='{html.escape(name)}' {req}>")
chrome = "" if embed else f"<h1>{html.escape(cfg.get('title') or coll['name'])}</h1>"
return HTMLResponse(
f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{html.escape(cfg.get('title') or coll['name'])}</title>
<style>body{{font-family:system-ui;background:#191919;color:#eee;margin:0;padding:24px}}
form{{max-width:520px;margin:auto}}label{{display:block;margin:12px 0 4px}}
input,select,textarea{{width:100%;padding:8px;border-radius:6px;border:1px solid #444;background:#222;color:#fff}}
button{{margin-top:16px;padding:10px 18px;border-radius:6px;border:0;background:#2383E2;color:#fff}}</style>
</head><body>{chrome}
<form method="post" action="/f/{token}">
<input type="text" name="__hp" style="display:none" tabindex="-1" autocomplete="off">
{inputs}<button>Submit</button></form></body></html>"""
)
@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:
logger.exception("submit_form")
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:
logger.exception("submit_form")
try:
from app.services.automations import fire_event as _fire
await _fire("form.submitted", {"collection_id": coll["id"], "row_id": row_id})
except Exception:
logger.exception("submit_form")
if "application/json" in ctype:
return {"status": "ok", "row_id": row_id,
"message": cfg.get("success_message") or "Merci !"}
return HTMLResponse(
f"""<!DOCTYPE html><html><body style="font-family:system-ui;background:#191919;color:#eee;
display:flex;align-items:center;justify-content:center;height:100vh">
<p>{html.escape(cfg.get('success_message') or 'Merci !')}</p></body></html>"""
)
# 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"]