fix: A30 + A37 + A39 + A40 + A41 — fin du P2/P3 XS/S (v7.4.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s

- A30 — `require_scope()` câblé : 69 sites stricts de api_v2.py passent par la
  factory (Bearer + scope en 1 appel, contrôle manuel supprimé) ; sémantique
  alignée sur celle des handlers (pas de default "read" → 0 changement de
  comportement) ; 12 top-level morts supprimés (0 ref app ET tests) :
  unsync_block, find_referring, _b64url, strip_markdown, format_number,
  get_auto_property_value, get_next_unique_id, local_date_in_tz,
  verify_device_token, _get_dynamic_groups, _require_user_gitea,
  validate_upload_request
- A37 — CORS sans `*` : origines = app_base_url + allow_origin_regex
  (localhost/dev, origines d'extension pour le Web Clipper), méthodes et
  entêtes minutées, allow_credentials explicite + test test_cors_no_star
- A39 — htmx : décision « rien » documentée (32 attributs hx-* réels sur 6
  templates, conversion = refonte du view-switching sans test E2E)
- A40 — version d'assets à source unique : ENV.globals["asset_version"] lu au
  boot depuis le fichier VERSION ; littéraux `?v=` de base.html éliminés ;
  test test_asset_version_single_source
- A41 — app.css : 91 règles mortes purgées (-10 274 octets, 121 618 → 111 344),
  scan templates/JS/CSS/Python à 0 référence

suite **1031/1031** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.4.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
This commit is contained in:
2026-10-01 09:30:37 -04:00
parent 998b5c630c
commit 937ecfc2e0
23 changed files with 179 additions and 819 deletions
-2
View File
@@ -44,8 +44,6 @@ def pkce_pair() -> tuple[str, str]:
return verifier, challenge
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(data: str) -> bytes:
+17 -2
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.3.9",
version="7.4.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -195,7 +195,22 @@ app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_ag
app.add_middleware(CSRFMiddleware)
app.add_middleware(ContentSecurityPolicyMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# A37 : origines explicites (l'auth est un cookie de session ; le front est
# servi par le même hôte). `*` + credentials est la combinaison interdite par la
# spec CORS — ici ni les deux ni l'un : liste fermée, méthodes/entêtes minutées.
_CORS_ORIGINS = sorted(
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
)
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
app.add_middleware(
CORSMiddleware,
allow_origins=_CORS_ORIGINS,
allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?|\w+-extension://.*",
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "Idempotency-Key"],
allow_credentials=True,
)
app.include_router(auth.router)
app.include_router(sso_router)
+1 -18
View File
@@ -1,10 +1,9 @@
"""FlowDeck — Pydantic request models for API validation."""
from __future__ import annotations
from fastapi import UploadFile
from pydantic import BaseModel, Field, model_validator
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
from app.middleware.security import ALLOWED_EXTENSIONS, _ext
# ── File Save ────────────────────────────────────────────────
@@ -34,23 +33,7 @@ class UploadValidationResult(BaseModel):
error: str | None = None
def validate_upload_request(file: UploadFile) -> str | None:
"""Validate an uploaded file (size + extension). Returns error message or None."""
# Size check — we can't read the full file without a size attribute,
# but Starlette's UploadFile has a size property from Content-Length
if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
return f"File '{file.filename}' exceeds maximum size of 10 MB"
# Extension check
if file.filename:
ext = _ext(file.filename)
if ext and ext not in ALLOWED_EXTENSIONS:
return f"File extension '{ext}' is not allowed"
return None
# ── Issue Create / Update ────────────────────────────────────
class IssueCreateRequest(BaseModel):
"""Request model for creating a Gitea issue."""
+71 -208
View File
@@ -16,7 +16,7 @@ from fastapi import APIRouter, Header, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from app.db import get_conn
from app.services.api_v2_helpers import (
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
audit_log,
check_idempotency,
check_v2_rate_limit,
@@ -24,6 +24,7 @@ from app.services.api_v2_helpers import (
has_scope,
paginate_headers,
parse_pagination,
require_scope,
row_to_dict,
store_idempotency,
to_iso8601,
@@ -47,10 +48,8 @@ def _v2_rate_check(request: Request, user: dict) -> None:
@router.post("/tokens")
async def create_token(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -110,10 +109,8 @@ async def list_tokens(request: Request, authorization: str | None = Header(defau
@router.delete("/tokens/{token_id}")
async def revoke_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT id, user_id FROM api_tokens WHERE id=?", (token_id,)).fetchone()
if not row:
@@ -127,10 +124,8 @@ async def revoke_token(token_id: int, request: Request, authorization: str | Non
@router.post("/tokens/{token_id}/rotate")
async def rotate_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT id, user_id, name, scopes FROM api_tokens WHERE id=?", (token_id,)).fetchone()
if not row:
@@ -171,10 +166,8 @@ async def get_me(request: Request, authorization: str | None = Header(default=No
@router.patch("/users/me")
async def patch_me(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -239,10 +232,8 @@ async def list_workspaces(request: Request, authorization: str | None = Header(d
@router.post("/workspaces")
async def create_workspace(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
@@ -291,10 +282,8 @@ async def get_workspace(workspace_id: int, request: Request, authorization: str
@router.patch("/workspaces/{workspace_id}")
async def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -321,10 +310,8 @@ async def patch_workspace(workspace_id: int, request: Request, authorization: st
@router.delete("/workspaces/{workspace_id}")
async def delete_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not row:
@@ -349,10 +336,8 @@ async def list_workspace_members(workspace_id: int, request: Request, authorizat
@router.post("/workspaces/{workspace_id}/members")
async def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -389,10 +374,8 @@ async def invite_member(workspace_id: int, request: Request, authorization: str
@router.patch("/workspaces/{workspace_id}/members/{uid}")
async def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -417,10 +400,8 @@ async def update_member_role(workspace_id: int, uid: int, request: Request, auth
@router.delete("/workspaces/{workspace_id}/members/{uid}")
async def remove_member(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not ws:
@@ -470,10 +451,8 @@ async def list_collections_v2(request: Request, authorization: str | None = Head
@router.post("/collections")
async def create_collection_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
@@ -528,10 +507,8 @@ async def get_collection_v2(collection_id: int, request: Request, authorization:
@router.patch("/collections/{collection_id}")
async def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -555,10 +532,8 @@ async def patch_collection_v2(collection_id: int, request: Request, authorizatio
@router.delete("/collections/{collection_id}")
async def delete_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
@@ -570,10 +545,8 @@ async def delete_collection_v2(collection_id: int, request: Request, authorizati
@router.post("/collections/{collection_id}/linked")
async def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -609,10 +582,8 @@ async def create_linked_db(collection_id: int, request: Request, authorization:
@router.post("/collections/{collection_id}/task")
async def toggle_task(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
@@ -636,10 +607,8 @@ async def list_sources(collection_id: int, request: Request, authorization: str
@router.post("/collections/{collection_id}/sources")
async def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -662,10 +631,8 @@ async def add_source(collection_id: int, request: Request, authorization: str |
@router.delete("/collections/{collection_id}/sources/{source_id}")
async def remove_source(collection_id: int, source_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM collection_data_sources WHERE collection_id=? AND (id=? OR source_collection_id=?)", (collection_id, source_id, source_id))
conn.commit()
@@ -734,10 +701,8 @@ async def list_collection_pages_v2(collection_id: int, request: Request, authori
@router.post("/collections/{collection_id}/pages")
async def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
@@ -816,10 +781,8 @@ async def get_page_v2(page_id: int, request: Request, authorization: str | None
@router.patch("/pages/{page_id}")
async def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -865,10 +828,8 @@ async def patch_page_v2(page_id: int, request: Request, authorization: str | Non
@router.delete("/pages/{page_id}")
async def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row:
@@ -884,10 +845,8 @@ async def delete_page_v2(page_id: int, request: Request, authorization: str | No
@router.post("/pages/{page_id}/restore")
async def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
# For soft-deleted pages (deleted_at) - but collection_pages has no deleted_at; handle pages table
with get_conn() as conn:
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (page_id,)).fetchone()
@@ -903,10 +862,8 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N
@router.post("/pages/{page_id}/move")
async def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -934,10 +891,8 @@ async def list_sub_items_v2(page_id: int, request: Request, authorization: str |
@router.post("/pages/{page_id}/sub-items")
async def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -965,10 +920,8 @@ async def list_dependencies_v2(page_id: int, request: Request, authorization: st
@router.post("/pages/{page_id}/dependencies")
async def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -992,10 +945,8 @@ async def add_dependency_v2(page_id: int, request: Request, authorization: str |
@router.delete("/pages/{page_id}/dependencies/{dep_id}")
async def remove_dependency_v2(page_id: int, dep_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM page_dependencies WHERE page_id=? AND dependency_id=?", (page_id, dep_id))
conn.commit()
@@ -1016,10 +967,8 @@ async def list_properties_v2(collection_id: int, request: Request, authorization
@router.post("/collections/{collection_id}/properties")
async def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1043,10 +992,8 @@ async def create_property_v2(collection_id: int, request: Request, authorization
@router.patch("/properties/{prop_id}")
async def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1069,10 +1016,8 @@ async def patch_property_v2(prop_id: int, request: Request, authorization: str |
@router.delete("/properties/{prop_id}")
async def delete_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
if not conn.execute("SELECT id FROM collection_properties WHERE id=?", (prop_id,)).fetchone():
raise HTTPException(404, "Property not found")
@@ -1083,10 +1028,8 @@ async def delete_property_v2(prop_id: int, request: Request, authorization: str
@router.post("/properties/{prop_id}/relation")
async def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1158,10 +1101,8 @@ async def list_views_v2(collection_id: int, request: Request, authorization: str
@router.post("/collections/{collection_id}/views")
async def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1185,10 +1126,8 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st
@router.patch("/views/{view_id}")
async def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1218,10 +1157,8 @@ async def patch_view_v2(view_id: int, request: Request, authorization: str | Non
@router.delete("/views/{view_id}")
async def delete_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
if not conn.execute("SELECT id FROM collection_views WHERE id=?", (view_id,)).fetchone():
raise HTTPException(404, "View not found")
@@ -1232,10 +1169,8 @@ async def delete_view_v2(view_id: int, request: Request, authorization: str | No
@router.post("/views/{view_id}/save-as")
async def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1261,10 +1196,8 @@ async def list_dashboards_v2(collection_id: int, request: Request, authorization
@router.post("/collections/{collection_id}/dashboards")
async def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1279,10 +1212,8 @@ async def create_dashboard_v2(collection_id: int, request: Request, authorizatio
@router.patch("/dashboards/{dashboard_id}")
async def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1303,10 +1234,8 @@ async def patch_dashboard_v2(dashboard_id: int, request: Request, authorization:
@router.delete("/dashboards/{dashboard_id}")
async def delete_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (dashboard_id,))
conn.commit()
@@ -1326,10 +1255,8 @@ async def list_comments_v2(page_id: int, request: Request, authorization: str |
@router.post("/pages/{page_id}/comments")
async def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1351,10 +1278,8 @@ async def create_comment_v2(page_id: int, request: Request, authorization: str |
@router.patch("/comments/{comment_id}")
async def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1379,10 +1304,8 @@ async def patch_comment_v2(comment_id: int, request: Request, authorization: str
@router.delete("/comments/{comment_id}")
async def delete_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
if not row:
@@ -1395,10 +1318,8 @@ async def delete_comment_v2(comment_id: int, request: Request, authorization: st
@router.post("/pages/{page_id}/mentions")
async def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1498,10 +1419,8 @@ async def list_favorites_v2(request: Request, authorization: str | None = Header
@router.post("/favorites")
async def add_favorite_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1523,10 +1442,8 @@ async def add_favorite_v2(request: Request, authorization: str | None = Header(d
@router.delete("/favorites/{page_id}")
async def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id))
conn.commit()
@@ -1550,10 +1467,8 @@ async def list_tags_v2(request: Request, authorization: str | None = Header(defa
@router.post("/tags")
async def create_tag_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1573,10 +1488,8 @@ async def create_tag_v2(request: Request, authorization: str | None = Header(def
@router.patch("/tags/{tag_id}")
async def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1593,10 +1506,8 @@ async def patch_tag_v2(tag_id: int, request: Request, authorization: str | None
@router.delete("/tags/{tag_id}")
async def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"]))
conn.commit()
@@ -1604,10 +1515,8 @@ async def delete_tag_v2(tag_id: int, request: Request, authorization: str | None
@router.post("/pages/{page_id}/tags")
async def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1625,10 +1534,8 @@ async def attach_tag_v2(page_id: int, request: Request, authorization: str | Non
@router.delete("/pages/{page_id}/tags/{tag_id}")
async def detach_tag_v2(page_id: int, tag_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM page_tags WHERE page_id=? AND tag_id=?", (page_id, tag_id))
conn.commit()
@@ -1659,10 +1566,8 @@ async def list_shares_v2(page_id: int, request: Request, authorization: str | No
@router.post("/pages/{page_id}/shares")
async def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1688,10 +1593,8 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N
@router.patch("/shares/{share_id}")
async def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1708,10 +1611,8 @@ async def patch_share_v2(share_id: int, request: Request, authorization: str | N
@router.delete("/shares/{share_id}")
async def delete_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT page_id FROM page_shares WHERE id=?", (share_id,)).fetchone()
if not row:
@@ -1726,10 +1627,8 @@ async def delete_share_v2(share_id: int, request: Request, authorization: str |
@router.post("/pages/{page_id}/publish")
async def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1749,10 +1648,8 @@ async def publish_page_v2(page_id: int, request: Request, authorization: str | N
@router.delete("/pages/{page_id}/publish")
async def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("UPDATE pages SET is_published=0 WHERE id=?", (page_id,))
conn.commit()
@@ -1776,10 +1673,8 @@ async def list_history_v2(page_id: int, request: Request, authorization: str | N
@router.post("/pages/{page_id}/history/restore")
async def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1820,10 +1715,8 @@ async def list_sprints_v2(collection_id: int, request: Request, authorization: s
@router.post("/collections/{collection_id}/sprints")
async def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1845,10 +1738,8 @@ async def create_sprint_v2(collection_id: int, request: Request, authorization:
@router.patch("/sprints/{sprint_id}")
async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1872,10 +1763,8 @@ async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str |
@router.delete("/sprints/{sprint_id}")
async def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM sprints WHERE id=?", (sprint_id,))
conn.commit()
@@ -1883,10 +1772,8 @@ async def delete_sprint_v2(sprint_id: int, request: Request, authorization: str
@router.post("/sprints/{sprint_id}/assign")
async def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1904,10 +1791,8 @@ async def assign_sprint_v2(sprint_id: int, request: Request, authorization: str
@router.delete("/sprints/{sprint_id}/assign/{page_id}")
async def unassign_sprint_v2(sprint_id: int, page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sprint_id, page_id))
conn.commit()
@@ -1951,10 +1836,8 @@ async def list_templates_v2(collection_id: int, request: Request, authorization:
@router.post("/collections/{collection_id}/templates")
async def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1970,10 +1853,8 @@ async def create_template_v2(collection_id: int, request: Request, authorization
@router.patch("/templates/{template_id}")
async def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -1991,10 +1872,8 @@ async def patch_template_v2(template_id: int, request: Request, authorization: s
@router.delete("/templates/{template_id}")
async def delete_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM page_templates WHERE id=?", (template_id,))
conn.commit()
@@ -2002,10 +1881,8 @@ async def delete_template_v2(template_id: int, request: Request, authorization:
@router.post("/templates/{template_id}/apply")
async def apply_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
tpl = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
if not tpl:
@@ -2027,10 +1904,8 @@ async def list_db_templates_v2(request: Request, authorization: str | None = Hea
@router.post("/templates/database/{template_id}/apply")
async def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -2104,10 +1979,8 @@ async def export_csv_v2(collection_id: int, request: Request, authorization: str
@router.post("/collections/{collection_id}/import/csv")
async def import_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
form = await request.form()
file = form.get("file")
@@ -2283,10 +2156,8 @@ async def list_webhooks_v2(request: Request, authorization: str | None = Header(
@router.post("/webhooks")
async def create_webhook_v2(request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -2313,10 +2184,8 @@ async def create_webhook_v2(request: Request, authorization: str | None = Header
@router.patch("/webhooks/{webhook_id}")
async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
try:
body = await request.json()
except Exception:
@@ -2336,10 +2205,8 @@ async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str
@router.delete("/webhooks/{webhook_id}")
async def delete_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (webhook_id,))
conn.commit()
@@ -2348,10 +2215,8 @@ async def delete_webhook_v2(webhook_id: int, request: Request, authorization: st
@router.post("/webhooks/{webhook_id}/test")
async def test_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
with get_conn() as conn:
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
if not row:
@@ -2399,10 +2264,8 @@ async def list_deliveries_v2(webhook_id: int, request: Request,
async def retry_webhook_deliveries(webhook_id: int, request: Request,
authorization: str | None = Header(default=None)):
"""Manually retry failed deliveries for a webhook."""
user = get_bearer_user(request, authorization)
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
if not has_scope(user.get("_token_scopes"), "write"):
raise HTTPException(403, "Insufficient scope. Required: write")
from app.services.webhook_outbound import retry_due_deliveries
-9
View File
@@ -850,15 +850,6 @@ def _get_project_properties(owner: str, repo: str) -> list[dict]:
return [dict(r) for r in rows]
def _get_dynamic_groups(owner: str, repo: str) -> list[str]:
"""Return groups from Gitea labels/milestones or fallback to defaults."""
try:
labels = json.loads(
json.dumps([lbl["name"] for lbl in asyncio_get_labels(owner, repo)[:5]])
) if False else []
except Exception:
labels = []
return labels if labels else ["Design", "Engineering", "No Team"]
async def asyncio_get_labels(owner: str, repo: str):
-8
View File
@@ -19,16 +19,8 @@ def _require_gitea(request: Request):
return client
def _require_user_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401 (required for write ops)."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
if not client:
raise HTTPException(status_code=401, detail="Gitea not connected. Go to Settings → Integrations or register with Gitea.")
return client
# ── Orgs ──
@router.get("/orgs")
async def list_orgs(request: Request):
"""List organizations the user belongs to."""
+5 -1
View File
@@ -214,9 +214,13 @@ def get_bearer_user(request: Request, authorization: str | None = Header(default
return user
def require_scope(required: str):
"""A30 : la factory de scopes, AVOIR utilisée — les handlers faisaient
`has_scope(...)` à la main (69 sites dans api_v2.py)."""
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
user = get_bearer_user(request, authorization)
scopes = user.get("_token_scopes") or "read"
# Pas de default "read" : identique au contrôle manuel des handlers
# (un jeton sans scope est refusé, quel que soit le scope demandé).
scopes = user.get("_token_scopes")
if not has_scope(scopes, required):
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
return user
-6
View File
@@ -98,9 +98,3 @@ def coerce_tags(value: Any) -> list[str]:
return [str(value)]
def strip_markdown(text: str) -> str:
text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text)
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"[*_~#>]+", "", text)
return text.strip()
-30
View File
@@ -247,13 +247,6 @@ def user_ref(user: dict | None) -> dict | None:
}
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(UTC).isoformat()
if prop_type == "created_by" or prop_type == "last_edited_by":
return user_ref(user)
return None
def apply_auto_properties(
@@ -291,28 +284,5 @@ def apply_auto_properties(
return values
def get_next_unique_id(collection_id: int, conn) -> int:
"""Get the next unique_id for a collection (max + 1)."""
row = conn.execute(
"""SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1
FROM collection_pages WHERE collection_id=?""",
(collection_id,),
).fetchone()
return row[0] if row else 1
def format_number(value: float, fmt: str = "number") -> str:
"""Format a number value for display."""
if value is None:
return ""
if fmt == "percent":
return f"{value}%"
elif fmt == "dollar":
return f"${value:,.2f}"
elif fmt == "euro":
return f"€{value:,.2f}"
elif fmt == "pound":
return f"£{value:,.2f}"
elif fmt == "yen":
return f"¥{value:,.0f}"
return str(value)
-3
View File
@@ -196,9 +196,6 @@ def now_in_tz(tz_name: str | None = None) -> dt.datetime:
return dt.datetime.now(dt.UTC)
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
return now_in_tz(tz_name).date()
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
-11
View File
@@ -306,14 +306,3 @@ def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None:
# ── Unsync: convert synced block to independent copy ──────────────
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
"""Remove a page's sync reference and return the current content
so the caller can turn it into an independent block."""
sb = get_synced_block(synced_block_id)
if not sb:
return None
remove_page_synced(page_id, synced_block_id)
try:
return json.loads(sb["content"])
except (json.JSONDecodeError, TypeError):
return None
-13
View File
@@ -384,19 +384,6 @@ def register_device(user_id: int, device_id: str, device_name: str = "", extensi
return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False}
def verify_device_token(device_id: str, token: str) -> dict | None:
"""Verify a device token, returns device row or None."""
thash = _hash_token(token)
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM extension_devices WHERE device_id=? AND token_hash=? AND revoked=0",
(device_id, thash),
).fetchone()
if row:
conn.execute("UPDATE extension_devices SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
conn.commit()
return dict(row)
return None
def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str):
-5
View File
@@ -83,8 +83,3 @@ def resolve_tokens_html(content: str, titles: dict[str, str]) -> str:
return s
def find_referring(content: str, page_id: int) -> bool:
"""True when the content references ``page_id`` (anchor or wiki token)."""
if not content:
return False
return (f"/pages/{page_id}" in content) or (f"[[fdpage:{page_id}]]" in content)
+5 -5
View File
@@ -10,9 +10,9 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v=5.1.1">
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@@ -2129,7 +2129,7 @@
}
});
</script>
<script src="/static/js/app.js?v=2.4.8" defer data-cfasync="false"></script>
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
@@ -2377,7 +2377,7 @@
</style>
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v=6.0.0" defer data-cfasync="false"></script>
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script data-cfasync="false">
(function() {
if (!('serviceWorker' in navigator)) return;
+14
View File
@@ -6,9 +6,23 @@ les `|safe` du codebase étaient des no-op.
"""
from __future__ import annotations
import pathlib
from jinja2 import Environment, FileSystemLoader, select_autoescape
ENV = Environment(
loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]),
)
# A40 : version des assets statiques première main, lue une seule fois (le
# fichier VERSION fait foi ; repli "dev" si l'image ne le contient pas).
try:
ASSET_VERSION = (pathlib.Path(__file__).resolve().parent.parent / "VERSION").read_text(
encoding="utf-8"
).strip()
except OSError: # pragma: no cover
ASSET_VERSION = "dev"
ENV.globals["asset_version"] = ASSET_VERSION