4 passes (283 → 93 routes async sur 667 = 86 % hors loop, avant 61 %) :
A. RACINE AUTH — `get_current_user` (auth/session.py) était `async def`
SANS aucun await (cookie decode = synchrone) ; idem ses clones :
`agent._current_user_id/_workspace_id/_current_admin` (34 sites) et
`sso._require_admin` (corps 0 await, 6 sites) → `def` +
47 `await` supprimés. Piège : 3 call sites passaient par l'alias `gcu`
(grep littéral aveugle) — 8 tests en échec → corrigés.
B. Re-scan : 19 routes devenues SANS await → `def` (agent 8, sso 5,
web_clipper 3, projects 2, auth 1…).
C/D. 155 routes dont les seuls awaits = `request.json()` / événements :
- try/except `body = {}` → `Body(default={})` (même tolérance)
- try/except `raise HTTPException(400)` → `Body(...)` REQUIS
(422 FastAPI — aucun test ne couvrait le 400)
- forme conditionnelle `request.json() if content-type else {}`
(54 sites) → défaut `{}` (sans corps = `{}` dans les 2 cas)
- `await fire_*` → `run_event_sync(...)` ; imports `Body` /
`run_event_sync` ajoutés aux routers convertis
Reste async (93, justifié) : form/upload/file (22), réseau gitea/llm/oidc,
`_json_body` (9), 2 JSON inline en argument, 1 fallback logique
(capture_frontend_error), 1 lecture conditionnelle (web_clipper), mixtes.
suite **1089/1089** · ruff OK · docs à jour
823 lines
35 KiB
Python
823 lines
35 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, Body, 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")
|
|
def create_site(request: Request, body: dict = Body(default={})):
|
|
user = _auth_user(request, require_write=True)
|
|
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}")
|
|
def update_site(site_id: int, request: Request, body: dict = Body(default={})):
|
|
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")
|
|
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")
|
|
def add_site_page(site_id: int, request: Request, body: dict = Body(default={})):
|
|
user = _auth_user(request, require_write=True)
|
|
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")
|
|
def put_form_config(collection_id: int, request: Request, body: dict = Body(default={})):
|
|
user = _auth_user(request, require_write=True)
|
|
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"]
|