fix: A29 + A42(partiel) — publish partagé, fuite password_hash, data_dir (v7.5.0)
- A29 — `app/services/publish.py` : slugify titré unique (fallback aléatoire),
404 si la page n'existe pas, événements centralisés. Les 3 paires
publish/unpublish déléguent (sharing = front, board, v2) :
· board : mise à jour aveugle → 404 + contrôle de session ajouté
· board : perd `share_mode='anyone'` en bonus, v2 : perd `is_shared=1` —
le share dialog reste l'unique propriétaire de ces drapeaux
· v2 : slug fourni conservé, slug vidé aussi à la dépublication (avant : laissé)
· `/users/me` ×2 et listings collections ×3 = contrats versionnés distincts,
décision documentée (on garde)
- Byproduct sécurité — `GET /api/users/me` (v1) et le contexte de `/accounts`
faisaient `SELECT *` sur users → password_hash / login_attempts / locked_until
exposés → colonnes whitelistées (liste v2)
- A42 (partiel) — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))`
→ `settings.data_dir` (property : lecture à chaque accès, les tests
monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque
écriture. Reste : client httpx partagé (52 créations, cache par event loop)
tests : test_publish_service_shared_and_safe, test_users_me_no_secret_columns,
test_gitea_cache_evicts_expired
suite **1034/1034** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.5.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
This commit is contained in:
@@ -1,12 +1,22 @@
|
||||
"""FlowDeck — Configuration via pydantic-settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@property
|
||||
def data_dir(self) -> str:
|
||||
"""Racine des fichiers (avatars, uploads…).
|
||||
|
||||
Pas un champ : la lecture est faite à chaque accès parce que les tests
|
||||
monkeypatchent `FLOWDECK_DATA_DIR` en cours de vie (A42 — les 9 copies
|
||||
de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` vont ici).
|
||||
"""
|
||||
return os.environ.get("FLOWDECK_DATA_DIR", "/data")
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
||||
)
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.4.0",
|
||||
version="7.5.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
+6
-1
@@ -551,8 +551,13 @@ async def get_my_profile(request: Request):
|
||||
if not user:
|
||||
return {"login": "guest", "full_name": "Guest", "email": ""}
|
||||
with get_conn() as conn:
|
||||
# A29-byproduct : jamais `SELECT *` ici — la ligne contenait
|
||||
# password_hash, login_attempts et locked_until.
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE login=?", (user.get("login", ""),)
|
||||
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
||||
"is_admin, is_active, last_login, created_at "
|
||||
"FROM users WHERE login=?",
|
||||
(user.get("login", ""),),
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
|
||||
+6
-17
@@ -31,6 +31,7 @@ from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est ut
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v2", tags=["api-v2"])
|
||||
@@ -1633,30 +1634,18 @@ async def publish_page_v2(page_id: int, request: Request, authorization: str | N
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
slug = (body.get("slug") or body.get("publish_slug") or f"p-{page_id}-{secrets.token_urlsafe(6)}").strip()
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute("UPDATE pages SET is_published=1, publish_slug=?, is_shared=1 WHERE id=?", (slug, page_id))
|
||||
conn.commit()
|
||||
slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None
|
||||
slug, _title = publish(page_id, explicit_slug=slug_in)
|
||||
audit_log(user, "page.publish", "page", page_id, slug, request)
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
logger.exception("publish_page_v2")
|
||||
await fire_published(page_id, slug)
|
||||
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
async def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET is_published=0 WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
logger.exception("unpublish_page_v2")
|
||||
unpublish(page_id)
|
||||
await fire_unpublished(page_id)
|
||||
return {"page_id": page_id, "status": "unpublished"}
|
||||
|
||||
# ── History ───────────────────────────────────────────────────────────────
|
||||
|
||||
+12
-26
@@ -10,12 +10,14 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard import _get_app_version
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.services.automations import fire_event
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
@@ -1011,35 +1013,20 @@ async def update_share(request: Request, page_id: int):
|
||||
@router.post("/api/pages/{page_id:int}/publish")
|
||||
async def publish_page(request: Request, page_id: int):
|
||||
"""Publish a page to the web (generates publish_slug)."""
|
||||
import secrets
|
||||
slug = "p-" + secrets.token_urlsafe(8)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?",
|
||||
(slug, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
try:
|
||||
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
logger.exception("publish_page")
|
||||
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
slug, title = publish(page_id)
|
||||
await fire_published(page_id, slug)
|
||||
return {"is_published": True, "publish_slug": slug, "title": title}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id:int}/publish")
|
||||
async def unpublish_page(request: Request, page_id: int):
|
||||
"""Unpublish a page from the web."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
logger.exception("unpublish_page")
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
unpublish(page_id)
|
||||
await fire_unpublished(page_id)
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
@@ -1686,8 +1673,7 @@ async def duplicate_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
import os
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _ws_id_for(request: Request, page_id: int) -> int:
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import get_user_gitea_client, gitea
|
||||
|
||||
@@ -440,7 +441,10 @@ async def accounts_page(request: Request):
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
|
||||
users = conn.execute(
|
||||
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
|
||||
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
|
||||
template = env.get_template("accounts.html")
|
||||
return template.render(**ctx)
|
||||
@@ -1143,9 +1147,8 @@ def _file_page_disk_path(page: dict):
|
||||
parts = rel.split("/")
|
||||
if ".." in parts or "." in parts:
|
||||
return None
|
||||
import os as _os
|
||||
from pathlib import Path
|
||||
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
|
||||
root = Path(settings.data_dir).resolve()
|
||||
full = (root / rel).resolve()
|
||||
try:
|
||||
full.relative_to(root)
|
||||
@@ -1508,9 +1511,8 @@ async def restore_local_workspace_item(request: Request, item_id: int):
|
||||
async def serve_uploaded_file(ws_id: int, filename: str):
|
||||
"""Serve an uploaded file from disk."""
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
root = Path(settings.data_dir)
|
||||
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
|
||||
fp = (base_dir / filename).resolve()
|
||||
try:
|
||||
@@ -1568,11 +1570,10 @@ async def upload_local_workspace_file(request: Request):
|
||||
if not files:
|
||||
return JSONResponse({"error": "No files provided"}, status_code=400)
|
||||
|
||||
import os
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1656,11 +1657,10 @@ async def upload_local_workspace_folder(request: Request):
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
||||
|
||||
import os
|
||||
|
||||
from app.middleware.security import validate_upload
|
||||
|
||||
data_root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
data_root = Path(settings.data_dir)
|
||||
upload_dir = data_root / f"uploads/workspace_{ws_id}"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
router = APIRouter(tags=["emojis"])
|
||||
@@ -20,9 +21,8 @@ _IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
import os
|
||||
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _active_ws(request: Request) -> int:
|
||||
|
||||
+5
-58
@@ -2,8 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
@@ -11,6 +9,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||
@@ -24,14 +23,6 @@ def _require_auth(request: Request) -> dict:
|
||||
return user
|
||||
|
||||
|
||||
def _slugify(title: str) -> str:
|
||||
"""Generate a URL-safe slug from a page title."""
|
||||
slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
||||
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
||||
return slug or "untitled"
|
||||
|
||||
|
||||
# ── Page Sharing ──
|
||||
|
||||
|
||||
@@ -305,35 +296,8 @@ async def list_shares(page_id: int, request: Request):
|
||||
async def publish_page(page_id: int, request: Request):
|
||||
"""Publish a page (is_published=1) with a URL slug."""
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, title, is_published FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
slug = _slugify(page["title"])
|
||||
# Ensure uniqueness by appending suffix if needed
|
||||
base_slug = slug
|
||||
counter = 1
|
||||
while conn.execute(
|
||||
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
|
||||
).fetchone():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?",
|
||||
(slug, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
logger.exception("publish_page")
|
||||
|
||||
slug, _title = publish(page_id)
|
||||
await fire_published(page_id, slug)
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": True,
|
||||
@@ -346,25 +310,8 @@ async def publish_page(page_id: int, request: Request):
|
||||
async def unpublish_page(page_id: int, request: Request):
|
||||
"""Unpublish a page."""
|
||||
_require_auth(request)
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, is_published FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
logger.exception("unpublish_page")
|
||||
|
||||
unpublish(page_id)
|
||||
await fire_unpublished(page_id)
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": False,
|
||||
|
||||
@@ -17,12 +17,12 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
# ═══════════════ Helpers ═══════════════
|
||||
@@ -93,7 +93,7 @@ _MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream
|
||||
|
||||
def _data_root() -> Path:
|
||||
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _file_meta(page: dict) -> dict:
|
||||
|
||||
@@ -35,7 +35,10 @@ class GiteaClient:
|
||||
return None
|
||||
|
||||
def _set_cache(self, key: str, value: Any) -> None:
|
||||
self._cache[key] = (datetime.now() + self._ttl, value)
|
||||
now = datetime.now()
|
||||
# A42 : évacue les entrées expirées (le dict ne pouvait que grandir)
|
||||
self._cache = {k: v for k, v in self._cache.items() if v[0] > now}
|
||||
self._cache[key] = (now + self._ttl, value)
|
||||
|
||||
# ── repos ──
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.export import markdown_to_blocks
|
||||
@@ -27,7 +27,7 @@ _IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
return Path(settings.data_dir)
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
|
||||
@@ -16,6 +16,7 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,7 +30,7 @@ class TranscriptionUnavailable(RuntimeError):
|
||||
|
||||
|
||||
def meetings_dir() -> Path:
|
||||
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
root = Path(settings.data_dir)
|
||||
d = root / "uploads" / "meetings"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Publication de pages — A29 : une seule implémentation, les routers déléguent.
|
||||
|
||||
Les trois surfaces divergeaient avant cette passe :
|
||||
|
||||
- `/api/pages/{id}/publish` (sharing, consommateur principal — le front) :
|
||||
slug `slugify(titre)` unique, 404 si absente, `_require_auth`, aucun drapeau
|
||||
- `/board/api/pages/{id}/publish` : slug aléatoire `p-<8>`, mise à jour AVEUGLE
|
||||
(pas de 404), `share_mode='anyone'` en bonus, pas de contrôle d'session
|
||||
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
|
||||
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
|
||||
|
||||
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
|
||||
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
|
||||
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
|
||||
donc pas un partage manuel.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import unicodedata
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
"""URL-safe slug à partir d'un titre (même traitement qu'avant : NFKD)."""
|
||||
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
|
||||
|
||||
|
||||
def _unique_slug(conn, page_id: int, title: str) -> str:
|
||||
"""Slug depuis le titre, suffixé -1, -2… si pris ; fallback aléatoire."""
|
||||
base = slugify(title) or f"p-{secrets.token_urlsafe(8)}"
|
||||
slug, counter = base, 1
|
||||
while conn.execute(
|
||||
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
|
||||
).fetchone():
|
||||
slug = f"{base}-{counter}"
|
||||
counter += 1
|
||||
return slug
|
||||
|
||||
|
||||
def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
|
||||
"""Publie une page. Renvoie ``(slug, title)`` ; 404 si la page n'existe pas."""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
|
||||
)
|
||||
conn.commit()
|
||||
return slug, page["title"] or ""
|
||||
|
||||
|
||||
def unpublish(page_id: int) -> None:
|
||||
"""Dépublie : 404 si absente, sinon `is_published=0` + slug vidé."""
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
async def fire_published(page_id: int, slug: str) -> None:
|
||||
"""Événement `page.published` — l'échec d'eventing n'échoue jamais la route."""
|
||||
try:
|
||||
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
logger.exception("publish page.published")
|
||||
|
||||
|
||||
async def fire_unpublished(page_id: int) -> None:
|
||||
try:
|
||||
await fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
logger.exception("publish page.unpublished")
|
||||
Reference in New Issue
Block a user