feat: v6.2.0 Web Clipper — extension navigateur (capture article/selection/bookmark/screenshot)
- Service app/services/web_clipper.py: sanitize HTML, html->blocks, extraction article, creation page workspace-aware, rate limit 50/h, device registration - Router app/routers/web_clipper.py: POST /api/v2/web-clipper/clip, GET /status, POST /auth/verify, GET/DELETE /devices, GET /extensions (download page), auth via session ou Bearer (api_tokens / extension_devices) - Migration 19: extension_devices + extension_clips (+ indexes) - Extension Manifest V3: content.js (floating button, selection), background.js (clip + contextMenus), popup.html/js, clipper.css, icons - Settings UI: onglet Extensions (liste devices, revoke, test clip, liens download), page /extensions - Tests: 16 tests web_clipper (sanitize, blocks, article/bookmark/selection/screenshot, bearer, rate-limit, devices, extensions page) - Bump version 6.1.0 -> 6.2.0
This commit is contained in:
@@ -12,6 +12,7 @@ from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
AUTO_TYPES,
|
||||
apply_auto_properties,
|
||||
@@ -25,7 +26,6 @@ from app.services.recurrence import (
|
||||
validate_rule,
|
||||
)
|
||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""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, 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")
|
||||
async 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")
|
||||
async def auth_verify(request: Request):
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
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")
|
||||
async def clip_page(request: Request):
|
||||
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
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON") from None
|
||||
|
||||
# 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:
|
||||
pass
|
||||
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:
|
||||
pass
|
||||
|
||||
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")
|
||||
async def list_extension_devices(request: Request):
|
||||
user = _require_user(request)
|
||||
devices = list_devices(user["id"])
|
||||
return {"devices": devices}
|
||||
|
||||
|
||||
@api_router.delete("/devices/{device_id}")
|
||||
async 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)
|
||||
async def extensions_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
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:
|
||||
pass
|
||||
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))
|
||||
Reference in New Issue
Block a user