# V6.8.0 — Sites & Forms publics > **Statut** : ✅ Livré en v6.8.0 (2026-09-28) — `app/routers/sites.py`, migration 24, 20 tests verts · **Roadmap** : `ROADMAP.md § v6.8.0` > **Référence Notion** : Sites (multi-pages, domaine custom, SEO, analytics, password/expiry) + Forms (soumission anonyme → DB, embed, notifs). > **Existant réutilisé** : page publique `/p/` (`dashboard.py`), `_render_blocks_public`, `export.py` (récursif + images), `validate_property_rule` (`property_types.py`), `notifications` + `mailer.py`, vue Form (`_database_table_scripts.html`). --- ## 1. Vision Passer de « une page publiée isolée » à « publier un mini-site » et « collecter des réponses » : `page privée → site (arbre + thème + gating) → URL publique /s/` et `collection → form public /f/ → ligne collection_pages`. Hors scope : builder drag&drop marketing, paiement, commentaires publics. ## 2. Sites multi-pages ### 2.1 Schéma (migration 24) ```sql CREATE TABLE sites ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, -- /s/ root_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, title TEXT NOT NULL DEFAULT '', theme TEXT NOT NULL DEFAULT 'dark', -- light|dark custom_domain TEXT UNIQUE, -- Host header match password_hash TEXT, -- NULL = public expires_at TIMESTAMP, -- NULL = jamais noindex BOOLEAN NOT NULL DEFAULT 0, analytics_id TEXT DEFAULT '', -- Plausible/GA, pas d'IP brute created_by INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE site_pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE, page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, position INTEGER NOT NULL DEFAULT 0, UNIQUE(site_id, page_id) ); CREATE TABLE site_views ( site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE, day TEXT NOT NULL, -- YYYY-MM-DD UTC views INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (site_id, day) ); ``` Règles : arbre construit depuis `site_pages` ordonné ; page hors site → 404 ; page supprimée → exclue + état « Deleted » ; `resolve_content_json()` (synced) appliqué comme `/p/`. ### 2.2 Gating & SEO - Mot de passe : bcrypt, cookie signé `site_auth_` 24h, `GET/POST /s//auth`. - Expiry : `expires_at < now` → 410 « Site expiré ». - SEO : ``, meta description (1er paragraphe), OG/Twitter (cover+icône), `sitemap.xml`, `robots.txt` (`noindex` → `noindex,nofollow`). - Stats : `+1/jour/site` sur chaque vue (upsert), `GET /api/v2/sites/{id}/stats?days=30`. ### 2.3 Routes ``` GET /s/<slug> → home (root_page + nav) GET /s/<slug>/<page-slug> → page du site (nav active) GET /s/<slug>/sitemap.xml GET|POST /s/<slug>/auth → gate password GET|POST|PATCH|DELETE /api/v2/sites GET|POST|DELETE /api/v2/sites/{id}/pages GET /api/v2/sites/{id}/stats ``` Domaine custom : si `Host == custom_domain` → monte le site sans `/s/<slug>`. ## 3. Forms publics ### 3.1 Config (colonne `collections.form_config_json`) ```json { "enabled": true, "public_token": "f_abc123", "title": "Contact", "success_message": "Merci !", "fields": ["Name", "Email", "Message"], "required": ["Name", "Email"], "notify_user_ids": [1, 2] } ``` Table `form_responses (id, collection_id, row_id, ip_hash, created_at)` — `ip_hash` = sha256(IP+jour), jamais d'IP brute. ### 3.2 Soumission anonyme ``` GET /f/<token> → formulaire thèmable (no-auth, `?embed=1` sans chrome) POST /f/<token> → valide via validate_property_rule → 400 + messages rate-limit 20/h/IP → 429, honeypot __hp + Turnstile optionnel → collection_pages + form_responses + notif in-app/email ``` Trigger automation `form.submitted` (v7.0.0 s'y branchera). Embed : `<iframe src="https://host/f/<token>?embed=1" width="100%" height="600">`. ## 4. UI - Éditeur « … » → `Share → Publish → Site` : créer site, choisir pages, thème, password/expiry/noindex, copier URL + snippet. - Collection → `Views → Form → Share form` : toggle public, champs affichés, message succès, liste réponses (compteur + lien lignes filtrées `form:true`). - Settings → Sites (liste, stats sparkline, domaine custom, revoke). ## 5. Sécurité / perfs - Password bcrypt cost 12, cookie `itsdangerous` signé, CSRF exempt uniquement `POST /f/<token>` (rate-limit + honeypot compensent). - `custom_domain` validé (hostname, pas d'IP privée) ; `slug` `^[a-z0-9-]{3,50}$`. - Cache rendu public 60s (`Cache-Control: public, max-age=60`), stats en upsert (pas de ligne/vue). ## 6. Tests (`tests/test_v68_sites_forms.py`, ~25) CRUD site, nav ordonnée, 404 hors-site, password OK/KO + cookie, expiry 410, noindex meta, sitemap, stats +1/jour, domaine custom (Host override), form GET anonyme, POST valide → ligne, POST invalide → 400, rate-limit 429, honeypot 400, embed `?embed=1`, notif créée, ACL (non-owner 403/404). ## 7. Rollout 1. Migration 24 + CRUD API + tests. 2. Rendu `/s/` + gating + SEO. 3. Forms + embed + notifs. 4. UI Share/Site + docs `/help` + OpenAPI.