Files
flowdeck/app/routers/web_clipper.py
T
bruno 45e59009c3
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 15m23s
FlowDeck CI / docker (push) Canceled after 0s
fix: A21 phase 2c — 190 routes hors loop, 86 % total (v7.26.0)
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
2026-10-01 22:17:48 -04:00

308 lines
14 KiB
Python

"""FlowDeck — Web Clipper router (v6.0.0).
Endpoints:
GET /api/v2/web-clipper/status
POST /api/v2/web-clipper/auth/verify
POST /api/v2/web-clipper/clip
GET /api/v2/web-clipper/devices
DELETE /api/v2/web-clipper/devices/{id}
GET /extensions (HTML download page)
Auth: session cookie OR Bearer api_token OR Bearer extension device token.
"""
from __future__ import annotations
import hashlib
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.web_clipper import (
MAX_CLIP_BYTES,
_check_rate_limit,
create_page_from_clip,
list_devices,
log_clip,
register_device,
revoke_device,
sanitize_html,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["web-clipper"])
api_router = APIRouter(prefix="/api/v2/web-clipper", tags=["web-clipper"])
def _hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def _user_from_request(request: Request) -> dict | None:
# 1) session cookie
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if user:
return user
# 2) Authorization Bearer
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
token = auth[7:].strip()
if not token:
return None
th = _hash(token)
with get_conn() as conn:
# api_tokens (Settings → API tokens)
row = conn.execute(
"SELECT user_id FROM api_tokens WHERE token_hash=? AND revoked=0", (th,)
).fetchone()
if row:
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
if u:
return dict(u)
# extension_devices
row = conn.execute(
"SELECT user_id FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)
).fetchone()
if row:
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
if u:
return dict(u)
# legacy user_tokens
row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
if row:
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone()
if u:
return dict(u)
return None
def _require_user(request: Request) -> dict:
user = _user_from_request(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
# ── API: status ──
@api_router.get("/status")
def clipper_status(request: Request):
user = _user_from_request(request)
if not user:
return {"authenticated": False}
with get_conn() as conn:
dev_cnt = conn.execute("SELECT COUNT(*) FROM extension_devices WHERE user_id=? AND revoked=0", (user["id"],)).fetchone()[0]
clip_cnt = conn.execute("SELECT COUNT(*) FROM extension_clips WHERE user_id=?", (user["id"],)).fetchone()[0]
return {"authenticated": True, "user": {"id": user["id"], "login": user.get("login")}, "devices": dev_cnt, "clips": clip_cnt}
# ── API: auth verify / device registration ──
@api_router.post("/auth/verify")
def auth_verify(request: Request, body: dict = Body(default={})):
user = _require_user(request)
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "").strip()
device_name = (body.get("device_name") or body.get("deviceName") or "").strip()[:200]
extension_name = (body.get("extension_name") or body.get("extensionName") or "chrome").strip()[:20].lower()
if not device_id:
raise HTTPException(status_code=400, detail="device_id required")
if len(device_id) > 128:
raise HTTPException(status_code=400, detail="device_id too long")
try:
res = register_device(user["id"], device_id, device_name, extension_name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
if res["existing"]:
return {"status": "ok", "device_id": device_id, "existing": True, "message": "Device already registered"}
return {"status": "ok", "device_id": device_id, "token": res["token"], "note": "Copy token now — shown once. Use as Authorization: Bearer <token>"}
@api_router.post("/clip")
def clip_page(request: Request, body: dict = Body(...)):
user = _require_user(request)
# Enforce max body size early (10 MB)
clen = request.headers.get("content-length")
if clen:
try:
if int(clen) > MAX_CLIP_BYTES + 1024:
raise HTTPException(status_code=413, detail="Clip too large (max 10 MB)")
except ValueError:
pass
# Device identification for rate limiting and logging
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "web").strip()[:128] or "web"
# Rate limit 50/hour per device
if not _check_rate_limit(f"{user['id']}:{device_id}"):
raise HTTPException(status_code=429, detail="Rate limit: max 50 clips/hour per device")
url = (body.get("url") or body.get("source_url") or "").strip()
title = (body.get("title") or "").strip()
content = body.get("content") or body.get("html") or ""
clip_type = (body.get("content_type") or body.get("clip_type") or "article").strip().lower()
if clip_type not in ("article", "selection", "bookmark", "screenshot"):
clip_type = "article"
if not url and not title and not content:
raise HTTPException(status_code=400, detail="url, title or content required")
# Validate url if present
if url:
if not (url.startswith("http://") or url.startswith("https://")):
# allow bare domain? reject javascript:
if url.lower().startswith("javascript:") or url.lower().startswith("data:"):
raise HTTPException(status_code=400, detail="Invalid URL")
# Cap content bytes
if content and len(content.encode("utf-8")) > MAX_CLIP_BYTES:
raise HTTPException(status_code=413, detail="Content too large (max 10 MB)")
# Sanitize HTML content if present
if content and "<" in content:
# sanitize but keep structure for blocks converter
content = sanitize_html(content)[: MAX_CLIP_BYTES]
# Prepare payload for service
_img_b64 = body.get("image_base64") or body.get("screenshot") or ""
if not _img_b64 and body.get("images"):
try:
_imgs = body.get("images")
if isinstance(_imgs, list) and _imgs:
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
except Exception:
logger.exception("clip_page")
clip_data = {
"url": url,
"title": title[:200],
"content": content,
"content_type": clip_type,
"selection_html": body.get("selection_html") or body.get("selection") or "",
"image_base64": _img_b64,
"tags": body.get("tags") or [],
"target_workspace_id": body.get("target_workspace_id") or body.get("workspace_id"),
"target_page_id": body.get("target_page_id") or body.get("parent_page_id"),
"metadata": body.get("metadata") or {},
}
try:
result = create_page_from_clip(clip_data, user["id"])
except Exception as e:
logger.exception("clip creation failed: %s", e)
raise HTTPException(status_code=500, detail="Failed to create page") from None
# Log clip
try:
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
except Exception:
logger.exception("clip_page")
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
@api_router.get("/devices")
def list_extension_devices(request: Request):
user = _require_user(request)
devices = list_devices(user["id"])
return {"devices": devices}
@api_router.delete("/devices/{device_id}")
def revoke_extension_device(device_id: int, request: Request):
user = _require_user(request)
ok = revoke_device(user["id"], device_id)
if not ok:
raise HTTPException(status_code=404, detail="Device not found")
return {"status": "revoked"}
# ── HTML: /extensions download page ──
@router.get("/extensions", response_class=HTMLResponse)
def extensions_page(request: Request):
from app.routers.dashboard import _sidebar_data
from app.templating import ENV
env = ENV
try:
sidebar = _sidebar_data(request, [])
except Exception:
sidebar = {}
# Simple standalone page reusing base.html
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
)
user = _user_from_request(request)
# Count for auth user
devices = []
clips = 0
if user:
try:
devices = list_devices(user["id"])
clips = sum(d.get("clips_count", 0) for d in devices)
except Exception:
logger.exception("extensions_page")
content_html = f"""
<style>
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
.ext-hero{{text-align:center;padding:28px 0 8px;}}
.ext-hero h1{{font-size:30px;font-weight:800;margin:0 0 6px;}}
.ext-hero p{{color:var(--text-dim);font-size:14px;max-width:560px;margin:0 auto;line-height:1.6;}}
.ext-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:28px 0;}}
.ext-card{{border:1px solid var(--border);border-radius:12px;padding:18px;background:var(--bg-card);}}
.ext-card h3{{font-size:15px;margin:0 0 6px;display:flex;align-items:center;gap:8px;}}
.ext-card p{{font-size:12.5px;color:var(--text-dim);line-height:1.5;margin:0 0 10px;}}
.ext-card a{{font-size:13px;color:var(--accent);text-decoration:none;}}
.ext-card a:hover{{text-decoration:underline;}}
.ext-section{{margin:28px 0;}}
.ext-section h2{{font-size:18px;font-weight:700;margin:0 0 10px;}}
.ext-steps{{counter-reset:step;list-style:none;padding:0;margin:0;}}
.ext-steps li{{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}}
.ext-steps li::before{{counter-increment:step;content:counter(step);flex:0 0 26px;height:26px;display:flex;align-items:center;justify-content:center;background:var(--accent);color:#fff;border-radius:50%;font-size:12px;font-weight:600;}}
.ext-dev-list{{margin-top:12px;}}
.ext-dev-item{{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;background:var(--bg-tertiary);}}
.ext-badge{{font-size:10px;padding:2px 8px;border-radius:99px;background:rgba(46,160,67,.14);color:#2ea043;font-weight:600;}}
</style>
<div class="ext-page">
<div class="ext-hero">
<h1>🧩 FlowDeck Web Clipper</h1>
<p>Capture any web page — article, selection, bookmark or screenshot — directly into FlowDeck. Install the browser extension, connect it once, then clip in one click.</p>
</div>
<div class="ext-grid">
<div class="ext-card">
<h3>🟢 Chrome / Edge</h3>
<p>Manifest V3 — Chrome 88+, Edge 88+.</p>
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load unpacked in chrome://extensions</span>
</div>
<div class="ext-card">
<h3>🟠 Firefox</h3>
<p>Firefox 109+ (Manifest V2 compat).</p>
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load temporary add-on</span>
</div>
<div class="ext-card">
<h3>⌨️ Sans extension</h3>
<p>API directe — <code>POST /api/v2/web-clipper/clip</code> avec Bearer token.</p>
<a href="/help">Docs /help</a>
</div>
</div>
<div class="ext-section">
<h2>How it works</h2>
<ol class="ext-steps">
<li>Install the extension (.zip) → enable in your browser.</li>
<li>Open FlowDeck, go to <b>Settings → Extensions</b> and copy a Bearer token (or the clipper verifies via your session cookie).</li>
<li>On any web page, click <b>📌 Clip to FlowDeck</b> (floating button, right-click selection, or extension popup).</li>
<li>Choose type: Article (full), Selection, Bookmark or Screenshot — the page is created instantly in your workspace.</li>
</ol>
</div>
<div class="ext-section">
<h2>Captures on this account</h2>
<p style="font-size:12px;color:var(--text-dim);">{len(devices)} device(s) · {clips} clip(s) total</p>
<div class="ext-dev-list">
{"".join(f'<div class="ext-dev-item"><span><b>{d.get("device_name") or d.get("extension_name")}</b> <code style="font-size:11px;color:var(--text-dim);">{d.get("device_id")[:24]}</code></span><span><span class="ext-badge">{d.get("clips_count",0)} clips</span> <span style="font-size:11px;color:var(--text-dim);">{d.get("last_clip_at") or ""}</span></span></div>' for d in devices[:10]) or '<p style="font-size:13px;color:var(--text-dim);">No devices yet — clip your first page from the extension to appear here.</p>'}
</div>
<p style="margin-top:10px;"><a href="/accounts/settings" style="font-size:13px;color:var(--accent);">Manage in Settings → Extensions</a></p>
</div>
</div>
"""
return HTMLResponse(block_tpl.render(**sidebar, request=request, page_title="Extensions", title_prefix="Extensions", page_icon="🧩", content_html=content_html))