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
@@ -47,6 +47,8 @@ from app.routers.imports import router as imports_router
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.permissions import router as permissions_router
|
||||
from app.routers.realtime import router as realtime_router
|
||||
from app.routers.web_clipper import api_router as web_clipper_api_router
|
||||
from app.routers.web_clipper import router as web_clipper_router
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -109,7 +111,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="6.1.0",
|
||||
version="6.2.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -153,6 +155,8 @@ app.include_router(sync.router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(import_page_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@@ -796,6 +796,53 @@ def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
|
||||
|
||||
|
||||
@register(19, "v6.0.0: web clipper — extension devices & clips")
|
||||
def _migration_web_clipper(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — Web Clipper: extension browser + capture."""
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS extension_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
extension_name TEXT NOT NULL DEFAULT 'clipper',
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT DEFAULT '',
|
||||
token_hash TEXT NOT NULL DEFAULT '',
|
||||
scopes TEXT NOT NULL DEFAULT 'read,write',
|
||||
last_used_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, extension_name, device_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_devices_user ON extension_devices(user_id, revoked)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_devices_device ON extension_devices(device_id)"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS extension_clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
clip_type TEXT NOT NULL DEFAULT 'article',
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
target_page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL,
|
||||
target_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
title TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_clips_user ON extension_clips(user_id, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_clips_device ON extension_clips(device_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(17, "v6.0.0: sync_version columns")
|
||||
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1,435 @@
|
||||
"""FlowDeck — Web Clipper service (v6.0.0).
|
||||
|
||||
Handles web content capture → FlowDeck page creation.
|
||||
|
||||
- Sanitizes incoming HTML (removes scripts, styles, event handlers).
|
||||
- Extracts readable content via BeautifulSoup heuristics (article/main/body).
|
||||
- Converts HTML → FlowDeck block list (headings, paragraphs, lists, quotes,
|
||||
code, images, bookmarks).
|
||||
- Creates a pages row (workspace-aware) and logs the clip.
|
||||
|
||||
No external network calls: images stay as remote URLs (no download in MVP);
|
||||
a future iteration can download + re-host inline images.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CLIP_BYTES = 10 * 1024 * 1024 # 10 MB per clip
|
||||
MAX_CLIPS_PER_HOUR = 50
|
||||
|
||||
# In-memory rate limiter per device: {device_id: [timestamps]}
|
||||
_rate_store: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def _check_rate_limit(device_id: str) -> bool:
|
||||
"""Return True if allowed, False if rate-limited (50/hour)."""
|
||||
now = time.time()
|
||||
window = 3600
|
||||
bucket = _rate_store.get(device_id, [])
|
||||
bucket = [t for t in bucket if now - t < window]
|
||||
if len(bucket) >= MAX_CLIPS_PER_HOUR:
|
||||
_rate_store[device_id] = bucket
|
||||
return False
|
||||
bucket.append(now)
|
||||
_rate_store[device_id] = bucket
|
||||
return True
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def sanitize_html(html_str: str) -> str:
|
||||
"""Strip dangerous tags/attributes, return sanitized HTML string."""
|
||||
if not html_str:
|
||||
return ""
|
||||
# Cap size
|
||||
if len(html_str.encode("utf-8")) > MAX_CLIP_BYTES:
|
||||
html_str = html_str[: MAX_CLIP_BYTES // 2]
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
for tag in soup(["script", "style", "noscript", "iframe"]):
|
||||
tag.decompose()
|
||||
# Strip event handlers and javascript: URLs
|
||||
for el in soup.find_all(True):
|
||||
for attr in list(el.attrs):
|
||||
if attr.lower().startswith("on"):
|
||||
del el.attrs[attr]
|
||||
elif attr in ("href", "src", "action"):
|
||||
val = str(el.attrs[attr]).strip()
|
||||
if val.lower().startswith("javascript:") or val.lower().startswith("data:text/html"):
|
||||
del el.attrs[attr]
|
||||
return str(soup)
|
||||
|
||||
|
||||
def _extract_main(soup: BeautifulSoup) -> BeautifulSoup:
|
||||
"""Pick the most content-rich container: article > main > body."""
|
||||
for sel in ["article", "main", "[role=article]", "#content", ".post-content", ".article-content"]:
|
||||
el = soup.select_one(sel)
|
||||
if el and len(el.get_text(strip=True)) > 120:
|
||||
return el
|
||||
return soup.body or soup
|
||||
|
||||
|
||||
def _text_node(el) -> str:
|
||||
return el.get_text(separator=" ", strip=True) if el else ""
|
||||
|
||||
|
||||
def html_to_blocks(html_str: str, source_url: str = "") -> list[dict]:
|
||||
"""Convert HTML → FlowDeck block list.
|
||||
|
||||
Covers: headings (h1-h4), paragraphs, blockquotes, code, lists,
|
||||
images, links as bookmark when standalone.
|
||||
"""
|
||||
if not html_str or not html_str.strip():
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
main = _extract_main(soup)
|
||||
blocks: list[dict] = []
|
||||
|
||||
def _add(b):
|
||||
if b.get("content") or b.get("src") or b.get("url"):
|
||||
blocks.append(b)
|
||||
|
||||
# Walk direct children and deeper elements
|
||||
for el in main.find_all(["h1", "h2", "h3", "h4", "p", "blockquote", "pre", "ul", "ol", "img", "figure", "a"], recursive=True):
|
||||
tag = el.name.lower()
|
||||
if tag in ("h1", "h2", "h3", "h4"):
|
||||
level = int(tag[1])
|
||||
level = min(level, 4)
|
||||
txt = _text_node(el)
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": f"heading_{level}", "content": txt})
|
||||
elif tag == "p":
|
||||
txt = _text_node(el)
|
||||
# Skip if parent is blockquote or li already handled
|
||||
if el.find_parent(["blockquote", "li"]):
|
||||
continue
|
||||
# If p contains an image, emit image even when text empty
|
||||
img = el.find("img")
|
||||
if img and img.get("src"):
|
||||
src = img.get("src", "").strip()
|
||||
alt = img.get("alt", "") or ""
|
||||
if src and not src.startswith("data:"):
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
# If there is also text alongside image, emit paragraph too
|
||||
if txt:
|
||||
# Remove image alt from paragraph? Keep text
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": txt})
|
||||
continue
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": txt})
|
||||
elif tag == "blockquote":
|
||||
txt = _text_node(el)
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "quote", "content": txt})
|
||||
elif tag == "pre":
|
||||
code_el = el.find("code")
|
||||
txt = (code_el.get_text() if code_el else el.get_text())
|
||||
if txt.strip():
|
||||
lang = ""
|
||||
if code_el and code_el.get("class"):
|
||||
for c in code_el.get("class"):
|
||||
if c.startswith("language-"):
|
||||
lang = c.replace("language-", "")
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "code", "content": txt.strip("\n"), "language": lang})
|
||||
elif tag in ("ul", "ol"):
|
||||
# Only top-level lists - skip nested
|
||||
if el.find_parent(["ul", "ol"]):
|
||||
continue
|
||||
is_ordered = tag == "ol"
|
||||
for li in el.find_all("li", recursive=False):
|
||||
txt = _text_node(li)
|
||||
if not txt:
|
||||
continue
|
||||
# Detect todo
|
||||
if re.match(r"^\[ ?[xX] ?\]\s*", txt):
|
||||
checked = bool(re.match(r"^\[ ?[xX] ?\]", txt))
|
||||
txt = re.sub(r"^\[ ?[xX] ?\]\s*", "", txt)
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "to_do", "content": txt, "checked": checked})
|
||||
else:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "bulleted_list" if not is_ordered else "numbered_list", "content": txt})
|
||||
elif tag == "img":
|
||||
# Avoid double-count when inside p/figure already emitted
|
||||
if el.find_parent("p") or el.find_parent("figure"):
|
||||
# Still emit if parent p wasn't counted
|
||||
parent_p = el.find_parent("p")
|
||||
if parent_p and parent_p.find("img") == el:
|
||||
continue
|
||||
src = el.get("src", "").strip()
|
||||
if not src:
|
||||
continue
|
||||
# Skip data URIs for size
|
||||
if src.startswith("data:"):
|
||||
continue
|
||||
alt = el.get("alt", "") or ""
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
elif tag == "a":
|
||||
# Standalone links as bookmark when they are the only content in p
|
||||
parent = el.find_parent("p")
|
||||
txt = el.get_text(strip=True)
|
||||
href = el.get("href", "").strip()
|
||||
if href and parent and _text_node(parent) == txt and href.startswith("http"):
|
||||
# Will be handled as paragraph already; add bookmark variant if distinct
|
||||
pass
|
||||
elif tag == "figure":
|
||||
img = el.find("img")
|
||||
if img and img.get("src"):
|
||||
src = img.get("src", "").strip()
|
||||
alt = img.get("alt", "") or ""
|
||||
if src and not src.startswith("data:"):
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
|
||||
if not blocks:
|
||||
# Fallback: whole text as paragraphs
|
||||
texts = [t.strip() for t in main.get_text(separator="\n").split("\n") if t.strip()]
|
||||
for t in texts[:30]:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": t})
|
||||
|
||||
# Always ensure at least one block when source_url present
|
||||
if not blocks and source_url:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": source_url, "title": source_url})
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def extract_article(html_str: str, url: str = "") -> dict:
|
||||
"""High-level extraction returning title, text, images, metadata."""
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
title = ""
|
||||
if soup.title and soup.title.string:
|
||||
title = soup.title.string.strip()
|
||||
og_title = soup.find("meta", property="og:title")
|
||||
if og_title and og_title.get("content"):
|
||||
title = og_title["content"].strip() or title
|
||||
# Sanitize and convert
|
||||
clean = sanitize_html(html_str)
|
||||
blocks = html_to_blocks(clean, source_url=url)
|
||||
# Images
|
||||
images = []
|
||||
for b in blocks:
|
||||
if b.get("type") == "image" and b.get("src"):
|
||||
images.append(b["src"])
|
||||
# Text
|
||||
text_parts = []
|
||||
for b in blocks:
|
||||
if b.get("content"):
|
||||
text_parts.append(b["content"])
|
||||
return {"title": title or "Clipped page", "blocks": blocks, "images": images, "text": "\n\n".join(text_parts)}
|
||||
|
||||
|
||||
def _ensure_workspace(conn, user_id: int, workspace_id: int | None) -> tuple[int, str]:
|
||||
"""Return (workspace_id, workspace_key) for clip insertion."""
|
||||
if workspace_id:
|
||||
row = conn.execute("SELECT id, name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if row:
|
||||
# Check membership or owner
|
||||
mem = conn.execute(
|
||||
"SELECT 1 FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user_id)
|
||||
).fetchone()
|
||||
if mem or conn.execute("SELECT 1 FROM workspaces WHERE id=? AND owner_id=?", (workspace_id, user_id)).fetchone():
|
||||
return workspace_id, row["name"]
|
||||
# Fallback: first workspace owned or member, else create one
|
||||
row = conn.execute(
|
||||
"SELECT w.id, w.name FROM workspaces w LEFT JOIN workspace_members wm ON w.id=wm.workspace_id "
|
||||
"WHERE w.owner_id=? OR wm.user_id=? ORDER BY w.id LIMIT 1",
|
||||
(user_id, user_id),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["id"], row["name"]
|
||||
# Create default workspace
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", ("My Workspace", user_id))
|
||||
ws_id = cur.lastrowid
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'admin')", (ws_id, user_id))
|
||||
return ws_id, "My Workspace"
|
||||
|
||||
|
||||
def create_page_from_clip(clip_data: dict, user_id: int) -> dict:
|
||||
"""Create a FlowDeck page from a clip payload. Returns {page_id, title}."""
|
||||
url = (clip_data.get("url") or clip_data.get("source_url") or "").strip()
|
||||
title = (clip_data.get("title") or "").strip()
|
||||
content = clip_data.get("content") or clip_data.get("html") or ""
|
||||
content_type = clip_data.get("content_type") or clip_data.get("clip_type") or "article"
|
||||
selection_html = clip_data.get("selection_html") or ""
|
||||
tags = clip_data.get("tags") or []
|
||||
target_ws = clip_data.get("target_workspace_id") or clip_data.get("workspace_id")
|
||||
target_page_id = clip_data.get("target_page_id") or clip_data.get("parent_page_id")
|
||||
# Normalize workspace id
|
||||
try:
|
||||
target_ws = int(target_ws) if target_ws is not None else None
|
||||
except (ValueError, TypeError):
|
||||
target_ws = None
|
||||
|
||||
# Determine title and blocks
|
||||
if content_type == "screenshot":
|
||||
# Screenshot: base64 image block + source bookmark (even with empty html)
|
||||
img_b64 = clip_data.get("image_base64") or clip_data.get("screenshot") or ""
|
||||
blocks = []
|
||||
if img_b64:
|
||||
src = img_b64 if img_b64.startswith("data:") else f"data:image/png;base64,{img_b64}"
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": title or "Screenshot"})
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": title or url})
|
||||
if not blocks:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": title or "Screenshot"})
|
||||
title = title or "Screenshot"
|
||||
elif content_type == "selection" and selection_html and selection_html.strip():
|
||||
clean = sanitize_html(selection_html)
|
||||
blocks = html_to_blocks(clean, source_url=url)
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": title or url})
|
||||
title = title or "Clipped selection"
|
||||
elif content_type == "bookmark" or not content.strip():
|
||||
# Bookmark mode: no HTML body, just link card
|
||||
bookmark_title = title or (url or "Bookmark")
|
||||
blocks = [
|
||||
{"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": bookmark_title, "description": clip_data.get("metadata", {}).get("og_description", "") or ""}
|
||||
]
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": f"Source: {url}"})
|
||||
title = bookmark_title
|
||||
else:
|
||||
# Article full
|
||||
result = extract_article(content, url=url)
|
||||
blocks = result["blocks"]
|
||||
if not title:
|
||||
title = result["title"]
|
||||
# Append source bookmark if not already dominant
|
||||
if url:
|
||||
# avoid duplicate bookmark if last block already is bookmark to same url
|
||||
if not blocks or blocks[-1].get("url") != url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": "Source"})
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": url})
|
||||
|
||||
# Cap blocks
|
||||
if len(blocks) > 200:
|
||||
blocks = blocks[:200]
|
||||
title = (title or "Clipped page").strip()[:200] or "Clipped page"
|
||||
|
||||
# Persist
|
||||
with get_conn() as conn:
|
||||
ws_id, ws_key = _ensure_workspace(conn, user_id, target_ws)
|
||||
# Validate target_page_id belongs to same workspace
|
||||
parent_id = None
|
||||
if target_page_id:
|
||||
try:
|
||||
pid = int(target_page_id)
|
||||
pr = conn.execute("SELECT id, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL", (pid,)).fetchone()
|
||||
if pr and (pr["workspace_id"] == ws_id or pr["workspace_id"] is None):
|
||||
parent_id = pid
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Determine sort order
|
||||
if parent_id is not None:
|
||||
next_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1)+1 FROM pages WHERE parent_id=?", (parent_id,)).fetchone()[0]
|
||||
else:
|
||||
next_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1)+1 FROM pages WHERE workspace_id=? AND parent_id IS NULL", (ws_id,)).fetchone()[0]
|
||||
blocks_json = json.dumps(blocks, ensure_ascii=False)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
|
||||
VALUES (?, ?, ?, ?, 'blocks', 'Private', ?, ?)""",
|
||||
(ws_key or "", ws_id, title, blocks_json, parent_id, next_order),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
# Tags: create per-user tags if needed and attach via page_tags
|
||||
for tname in tags[:10]:
|
||||
tn = tname.strip()[:50]
|
||||
if not tn:
|
||||
continue
|
||||
conn.execute("INSERT OR IGNORE INTO tags (name, color, user_id) VALUES (?, '#787774', ?)", (tn, user_id))
|
||||
tr = conn.execute("SELECT id FROM tags WHERE name=? AND user_id=?", (tn, user_id)).fetchone()
|
||||
if tr:
|
||||
conn.execute("INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tr["id"]))
|
||||
conn.commit()
|
||||
|
||||
return {"page_id": page_id, "title": title, "blocks": blocks, "workspace_id": ws_id}
|
||||
|
||||
|
||||
def register_device(user_id: int, device_id: str, device_name: str = "", extension_name: str = "chrome") -> dict:
|
||||
"""Register or update an extension device, returns {device, token} (token shown once if new)."""
|
||||
if not device_id or len(device_id) > 128:
|
||||
raise ValueError("Invalid device_id")
|
||||
token = f"fd_clip_{uuid.uuid4().hex}{uuid.uuid4().hex[:8]}"
|
||||
thash = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id, token_hash FROM extension_devices WHERE user_id=? AND device_id=? AND extension_name=?",
|
||||
(user_id, device_id, extension_name),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE extension_devices SET device_name=?, last_used_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(device_name[:200], existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": existing["id"], "device_id": device_id, "token": None, "existing": True}
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO extension_devices (user_id, extension_name, device_id, device_name, token_hash)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(user_id, extension_name, device_id, device_name[:200], thash),
|
||||
)
|
||||
conn.commit()
|
||||
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):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO extension_clips (user_id, device_id, clip_type, source_url, target_page_id, target_workspace_id, title)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, device_id, clip_type[:20], source_url[:2000], target_page_id, workspace_id, title[:200]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def list_devices(user_id: int) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, extension_name, device_id, device_name, scopes, last_used_at, created_at, revoked FROM extension_devices WHERE user_id=? ORDER BY last_used_at DESC, created_at DESC",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
# Enrich with clip counts
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
cnt = conn.execute("SELECT COUNT(*) AS n FROM extension_clips WHERE user_id=? AND device_id=?", (user_id, r["device_id"])).fetchone()["n"]
|
||||
d["clips_count"] = cnt
|
||||
# Last clip
|
||||
last = conn.execute("SELECT created_at FROM extension_clips WHERE user_id=? AND device_id=? ORDER BY created_at DESC LIMIT 1", (user_id, r["device_id"])).fetchone()
|
||||
d["last_clip_at"] = last["created_at"] if last else None
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def revoke_device(user_id: int, device_row_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("UPDATE extension_devices SET revoked=1 WHERE id=? AND user_id=?", (device_row_id, user_id))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
@@ -138,6 +138,7 @@
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
||||
<div class="settings-nav-header">Workspace</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||
@@ -420,6 +421,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extensions (Web Clipper) -->
|
||||
<div x-show="activeSection==='extensions'">
|
||||
<h2>Extensions</h2>
|
||||
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
|
||||
<div class="setting-group">
|
||||
<h3>Web Clipper</h3>
|
||||
<p style="font-size:12px;color:var(--text-dim);line-height:1.5;margin-bottom:12px;">
|
||||
Install the <a href="/extensions" style="color:var(--accent);">browser extension</a> (Chrome/Firefox/Edge). After install, use <b>Verify & Connect</b> once or paste an <b>API token</b> in the extension popup.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
|
||||
<a href="/extensions" class="btn btn-secondary" style="text-decoration:none;font-size:13px;">Get extension</a>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download class="btn btn-secondary" style="text-decoration:none;font-size:13px;">Download .zip</a>
|
||||
</div>
|
||||
<div x-show="!clipperDevices.length" class="setting-desc" style="padding:8px 0;">No extension connected yet — clip your first page to appear here.</div>
|
||||
<template x-for="d in clipperDevices" :key="d.id">
|
||||
<div class="setting-row">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="setting-label">
|
||||
<span x-text="(d.device_name || d.extension_name || 'Clipper') + ' — ' + d.device_id.slice(0,24)"></span>
|
||||
<span class="llm-badge" :class="d.revoked ? 'off' : 'ok'" style="margin-left:8px;" x-text="d.revoked ? 'Révoqué' : 'Actif'"></span>
|
||||
<span class="llm-badge warn" style="margin-left:6px;" x-text="d.extension_name"></span>
|
||||
</div>
|
||||
<div class="setting-desc" style="font-size:11px;">
|
||||
<span x-text="'Connecté: ' + (d.created_at||'').slice(0,16)"></span>
|
||||
<span x-show="d.last_used_at" x-text="' · Dernier: ' + d.last_used_at.slice(0,16)"></span>
|
||||
<span x-text="' · ' + d.clips_count + ' clipp(s)'"></span>
|
||||
<span x-show="d.last_clip_at" x-text="' · Dernier clip: ' + d.last_clip_at.slice(0,16)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button class="btn-sm" :style="{color:'var(--danger)'}" @click="revokeClipperDevice(d)" x-show="!d.revoked">Révoquer</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<h3>Tester le clipper (sans extension)</h3>
|
||||
<p style="font-size:12px;color:var(--text-dim);">Envoyez un clip тест direct via l'API :</p>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<input class="settings-input" x-model="clipTestUrl" placeholder="https://example.com/article" style="flex:1;">
|
||||
<button class="btn btn-primary" @click="testClip()" :disabled="clipTesting" x-text="clipTesting ? 'Envoi…' : 'Clip URL'"></button>
|
||||
</div>
|
||||
<div x-show="clipTestMsg" style="margin-top:8px;font-size:12px;" :style="{color: clipTestOk ? 'var(--toast-success-bg)' : 'var(--danger)'}" x-text="clipTestMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace -->
|
||||
<div x-show="activeSection==='workspace'">
|
||||
<h2>Workspace</h2>
|
||||
@@ -1056,6 +1103,12 @@ function settingsInit() {
|
||||
newToken: null,
|
||||
tokenName: '',
|
||||
sessions: [],
|
||||
// v6.2.0 Web Clipper
|
||||
clipperDevices: [],
|
||||
clipTestUrl: 'https://example.com',
|
||||
clipTesting: false,
|
||||
clipTestMsg: '',
|
||||
clipTestOk: false,
|
||||
// v5.2.0 Backups (admin)
|
||||
backups: [],
|
||||
backupRunning: false,
|
||||
@@ -1853,6 +1906,35 @@ function settingsInit() {
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
// ── v6.2.0 Web Clipper ──
|
||||
async loadClipperDevices() {
|
||||
try {
|
||||
var r = await fetch('/api/v2/web-clipper/devices', {credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.clipperDevices = d.devices || [];
|
||||
} catch(e) { this.clipperDevices = []; }
|
||||
},
|
||||
async revokeClipperDevice(d) {
|
||||
if (!confirm('Révoquer l\'extension « ' + (d.device_name || d.device_id) + ' » ?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/v2/web-clipper/devices/' + d.id, {method:'DELETE', credentials:'same-origin', headers: {'X-CSRF-Token': this.getCsrfToken()}});
|
||||
if (r.ok) await this.loadClipperDevices();
|
||||
} catch(e) {}
|
||||
},
|
||||
async testClip() {
|
||||
this.clipTesting = true; this.clipTestMsg = ''; this.clipTestOk = false;
|
||||
var url = (this.clipTestUrl || '').trim();
|
||||
if (!url) { this.clipTestMsg = 'URL requise'; this.clipTesting = false; return; }
|
||||
try {
|
||||
var r = await fetch('/api/v2/web-clipper/clip', {method:'POST', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({url: url, title: 'Test clipper — ' + url, content: '<html><body><h1>Test clip</h1><p>Contenu de test depuis Settings.</p></body></html>', content_type: 'article', device_id: 'settings-test'})});
|
||||
var d = await r.json();
|
||||
if (r.ok) { this.clipTestOk = true; this.clipTestMsg = '✓ Page créée : #' + d.page_id + ' — ' + d.title; await this.loadClipperDevices(); }
|
||||
else this.clipTestMsg = d.detail || 'Erreur';
|
||||
} catch(e) { this.clipTestMsg = 'Erreur réseau'; }
|
||||
finally { this.clipTesting = false; }
|
||||
},
|
||||
// ── v5.2.0 Backups (admin) ──
|
||||
async loadBackups() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
# V6.0.0 — SSO / SAML : Enterprise Authentication
|
||||
|
||||
> **Statut** : Conception détaillée — v6.0.0
|
||||
> **Date** : 2026-09-15
|
||||
> **Route** : `feat/v6-sso-saml` → `develop` → `main`
|
||||
> **Dépendances** : v4.0.0 Accounts & Integrations (OAuth2 Gitea/GitHub existant)
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision & objectifs
|
||||
|
||||
Ajouter le support **SSO/SAML 2.0** pour permettre aux entreprises d'intégrer FlowDeck dans leur infrastructure d'authentification existante. Les utilisateurs d'une organisation peuvent se connecter via leur fournisseur d'identité (IdP) d'entreprise sans avoir de compte local séparé.
|
||||
|
||||
### Objectifs
|
||||
|
||||
| Critère | Cible |
|
||||
|---------|-------|
|
||||
| Protocoles | SAML 2.0 (principal), OIDC (complémentaire) |
|
||||
| Fournisseurs supportés | Azure AD, Okta, Google Workspace, OneLogin, Keycloak, Auth0 |
|
||||
| Temps de connexion SSO | < 3 s (redirect + callback) |
|
||||
| Gestion des utilisateurs | Auto-provisioning à la première connexion |
|
||||
| Déconnexion | SLO (Single Logout) supporté |
|
||||
| Fallback | Login local toujours disponible (admin activé/désactivable) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture SSO/SAML
|
||||
|
||||
### 2.1 Flux SAML 2.0
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Utilisateur │ │ FlowDeck │ │ IdP │
|
||||
│ (navigateur) │ │ (SP) │ │ (Azure AD / │
|
||||
│ │ │ │ │ Okta / etc.)│
|
||||
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
│ 1. Accède à FlowDeck │ │
|
||||
│───────────────────────►│ │
|
||||
│ │ │
|
||||
│ 2. Redirige vers IdP │ │
|
||||
│◄───────────────────────│ │
|
||||
│ │ │
|
||||
│ 3. SSO redirect │ │
|
||||
│───────────────────────►│────────────────────────►│
|
||||
│ │ │
|
||||
│ 5. Callback avec │ │
|
||||
│ SAMLResponse │◄────────────────────────│
|
||||
│ │ │
|
||||
│ 6. POST /auth/saml/callback │
|
||||
│───────────────────────►│ │
|
||||
│ │ │
|
||||
│ 7. Validate SAML │ │
|
||||
│ → Create/Update │ │
|
||||
│ user → Session │ │
|
||||
│◄───────────────────────│ │
|
||||
│ │ │
|
||||
│ 8. Authentifié │ │
|
||||
│◄───────────────────────│ │
|
||||
```
|
||||
|
||||
### 2.2 Composants côté serveur
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ FASTAPI (Python) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ auth/routers/sso.py │ │
|
||||
│ │ ├─ GET /auth/saml/login — initie la requête │ │
|
||||
│ │ ├─ POST /auth/saml/callback — traite la réponse │ │
|
||||
│ │ ├─ GET /auth/saml/metadata — SP metadata endpoint │ │
|
||||
│ │ ├─ GET /auth/oidc/login — flux OIDC │ │
|
||||
│ │ ├─ POST /auth/oidc/callback │ │
|
||||
│ │ └─ POST /auth/sso/logout — SLO │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ auth/providers/saml_provider.py │ │
|
||||
│ │ ├─ SAMLProvider — wrapper python3-saml │ │
|
||||
│ │ └─ validate_saml_response() │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ auth/providers/oidc_provider.py │ │
|
||||
│ │ ├─ OIDCProvider — wrapper httpx + OIDC lib │ │
|
||||
│ │ └─ validate_oidc_token() │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ auth/session.py — SessionManager étendu │ │
|
||||
│ │ ├─ create_sso_session() │ │
|
||||
│ │ ├─ handle_sso_user() │ │
|
||||
│ │ └─ enforce_sso_restriction() │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ services/sso_provisioning.py │ │
|
||||
│ │ ├─ auto_provision_user() │ │
|
||||
│ │ ├─ sync_user_attributes() │ │
|
||||
│ │ └─ map_sso_groups_to_workspaces() │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration SAML
|
||||
|
||||
### 3.1 Nouveau table `sso_config`
|
||||
|
||||
```sql
|
||||
CREATE TABLE sso_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
-- NULL = workspace-level; specific workspace; global if NULL+global flag
|
||||
provider_type TEXT NOT NULL,
|
||||
-- 'saml', 'oidc'
|
||||
entity_id TEXT NOT NULL,
|
||||
-- SAML: Entity ID of the IdP (e.g., "http://www.microsoft.com/...")
|
||||
sso_url TEXT NOT NULL,
|
||||
-- SAML: IdP SSO URL (HTTP-POST binding)
|
||||
slo_url TEXT,
|
||||
-- SAML: IdP Single Logout URL
|
||||
x509_certificate TEXT NOT NULL,
|
||||
-- SAML: IdP signing certificate (PEM format)
|
||||
-- OIDC: issuer URL
|
||||
issuer_url TEXT,
|
||||
-- OIDC: OIDC issuer identifier
|
||||
client_id TEXT,
|
||||
-- OIDC: client_id
|
||||
client_secret TEXT,
|
||||
-- OIDC: client_secret (encrypted at rest)
|
||||
scope TEXT DEFAULT 'openid profile email',
|
||||
-- OIDC: scopes requested
|
||||
attribute_mapping TEXT NOT NULL DEFAULT '{}',
|
||||
-- JSON: {"email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", ...}
|
||||
-- Maps SAML/OIDC attributes to FlowDeck user fields
|
||||
auto_provision BOOLEAN NOT NULL DEFAULT 1,
|
||||
-- Auto-create user on first SSO login
|
||||
default_workspace_id INTEGER,
|
||||
-- Workspace to assign new SSO users
|
||||
groups_mapping TEXT DEFAULT '[]',
|
||||
-- JSON: [{"sso_group": "Admins", "workspace_role": "admin"}, ...]
|
||||
active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
### 3.2 Mapping des attributs
|
||||
|
||||
Par défaut pour SAML (mappings standard OASIS) :
|
||||
|
||||
```json
|
||||
{
|
||||
"login": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
||||
"email": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
||||
"full_name": "urn:oasis:names:tc:SAML:attribute:displayName",
|
||||
"avatar_url": "urn:oasis:names:tc:SAML:attribute:thumbnail"
|
||||
}
|
||||
```
|
||||
|
||||
Par défaut pour OIDC :
|
||||
|
||||
```json
|
||||
{
|
||||
"login": "sub",
|
||||
"email": "email",
|
||||
"full_name": "name",
|
||||
"avatar_url": "picture"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Configuration `.env` (exemples)
|
||||
|
||||
```env
|
||||
# SAML — Azure AD
|
||||
SSO_PROVIDER=saml
|
||||
SSO_ENTITY_ID=https://sts.windows.net/{tenant-id}/
|
||||
SSO_SSO_URL=https://login.microsoftonline.com/{tenant-id}/saml2
|
||||
SSO_SLO_URL=https://login.microsoftonline.com/{tenant-id}/saml2/logout
|
||||
SSO_X509_CERT="-----BEGIN CERTIFICATE-----\n..."
|
||||
SSO_ATTRIBUTE_MAPPING={"login":"nameid","email":"email","full_name":"name"}
|
||||
SSO_AUTO_PROVISION=true
|
||||
SSO_DEFAULT_WORKSPACE_ID=1
|
||||
SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin"}]
|
||||
|
||||
# OIDC — Google Workspace
|
||||
SSO_PROVIDER=oidc
|
||||
SSO_ISSUER_URL=https://accounts.google.com
|
||||
SSO_CLIENT_ID=xxx.apps.googleusercontent.com
|
||||
SSO_CLIENT_SECRET=yyy
|
||||
SSO_SCOPE=openid profile email
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints API
|
||||
|
||||
### 4.1 Routeur `app/routers/sso.py`
|
||||
|
||||
```
|
||||
GET /auth/saml/login — Redirige vers l'IdP SAML
|
||||
POST /auth/saml/callback — Traite le SAMLResponse, crée la session
|
||||
GET /auth/saml/metadata — Retourne le SP metadata XML (pour configurer l'IdP)
|
||||
POST /auth/saml/logout — Initie le SLO (Single Logout)
|
||||
GET /auth/oidc/login — Redirige vers l'IdP OIDC
|
||||
POST /auth/oidc/callback — Traite le token OIDC, crée la session
|
||||
POST /auth/oidc/logout — SLO OIDC
|
||||
POST /api/v2/sso/config — Créer/configurer SSO (admin, scope admin)
|
||||
GET /api/v2/sso/config — Lire la config SSO courante
|
||||
PUT /api/v2/sso/config — Mettre à jour la config
|
||||
DELETE /api/v2/sso/config — Supprimer la config SSO (désactiver SSO)
|
||||
GET /api/v2/sso/workspaces — Lister les workspaces avec SSO actif
|
||||
POST /api/v2/sso/sync — Forcer la synchro des groupes/attributs
|
||||
```
|
||||
|
||||
### 4.2 Service `app/services/sso_provisioning.py`
|
||||
|
||||
```python
|
||||
class SSOService:
|
||||
"""Provisioning et gestion des utilisateurs SSO."""
|
||||
|
||||
def handle_sso_login(self, sso_data: dict, provider_type: str) -> dict:
|
||||
"""Traite un login SSO : trouve/crée l'utilisateur, crée la session."""
|
||||
# 1. Extraire les attributs selon le provider
|
||||
# 2. Chercher l'utilisateur par email/login SSO
|
||||
# 3. Si trouvé → mise à jour des attributs
|
||||
# 4. Si non trouvé et auto_provision → création
|
||||
# 5. Assigner le workspace par défaut ou le premier disponible
|
||||
# 6. Vérifier les groupes SSO → rôle workspace
|
||||
# 7. Créer la session
|
||||
|
||||
def sync_sso_groups(self, user_id: int, sso_groups: list[str]) -> None:
|
||||
"""Sync les groupes SSO vers les rôles workspace."""
|
||||
# Comparer sso_groups avec groups_mapping
|
||||
# Mettre à jour workspace_members.role
|
||||
|
||||
def enforce_sso_restriction(self, workspace_id: int) -> bool:
|
||||
"""Vérifie si le workspace est en mode SSO-only."""
|
||||
# Si config.sso_only = True → interdire le login local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Sécurité
|
||||
|
||||
### 5.1 Validation des assertions SAML
|
||||
|
||||
| Validation | Détail |
|
||||
|------------|--------|
|
||||
| Signature | Vérifier la signature avec le certificat IdP |
|
||||
| `NotBefore` / `NotOnOrAfter` | Rejeter les assertions expirées |
|
||||
| `Audience` | Vérifier que l'audience match l'entity_id du SP |
|
||||
| `Destination` | Vérifier que le destination match notre callback URL |
|
||||
| `Issuer` | Vérifier que l'émetteur est le IdP attendu |
|
||||
| `InResponseTo` | Prévenir le replay attack |
|
||||
|
||||
### 5.2 Protection supplémentaire
|
||||
|
||||
- **CSRF sur le callback** : le `RelayState` contient un token CSRF validé
|
||||
- **Rate limiting** sur les endpoints SSO : max 5 tentatives/minute
|
||||
- **Logging audit** : chaque login SSO est enregistré (user, provider, IP, timestamp)
|
||||
- **Session fixation** : nouvelle session créée après chaque SSO login
|
||||
- **Encryption** : `client_secret` chiffré avec `app_secret_key` avant stockage
|
||||
|
||||
### 5.3 Table `sso_login_history`
|
||||
|
||||
```sql
|
||||
CREATE TABLE sso_login_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider_type TEXT NOT NULL,
|
||||
provider_name TEXT NOT NULL,
|
||||
sso_identifier TEXT, -- email ou subject du SSO
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
success BOOLEAN NOT NULL,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sso_history_user ON sso_login_history(user_id, created_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Interface utilisateur
|
||||
|
||||
### 6.1 Login page — ajout SSO buttons
|
||||
|
||||
```html
|
||||
<!-- Extension de login.html — boutons SSO -->
|
||||
<div class="sso-providers" x-show="ssoProviders.length > 0">
|
||||
<div class="sso-divider">ou continuez avec</div>
|
||||
<template x-for="provider in ssoProviders">
|
||||
<button @click="ssoLogin(provider.type)" class="sso-btn">
|
||||
<span x-text="provider.icon"></span>
|
||||
<span x-text="provider.name"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 6.2 Settings — Configuration SSO (admin)
|
||||
|
||||
Nouvelle section dans `settings.html` → onglet **"SSO / Enterprise"** :
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ SSO / Enterprise Authentication │
|
||||
│───────────────────────────────────────────────────────│
|
||||
│ Provider: [▼ SAML / OIDC] │
|
||||
│ │
|
||||
│ SAML Configuration: │
|
||||
│ ├── Entity ID: [___________________________] │
|
||||
│ ├── SSO URL: [___________________________] │
|
||||
│ ├── SLO URL: [___________________________] │
|
||||
│ ├── Certificate: [text area - PEM] │
|
||||
│ └── Attribute Mapping: [JSON editor] │
|
||||
│ │
|
||||
│ OIDC Configuration: │
|
||||
│ ├── Issuer URL: [_________________________] │
|
||||
│ ├── Client ID: [_________________________] │
|
||||
│ ├── Client Secret: [_______________________] │
|
||||
│ └── Scope: [openid profile email] │
|
||||
│ │
|
||||
│ User Provisioning: │
|
||||
│ ├── Auto-provision new users [✓] │
|
||||
│ ├── Default workspace: [dropdown] │
|
||||
│ └── Group mapping: [table editor] │
|
||||
│ │
|
||||
│ ┌─ Group Mappings ───────────────────────────────────┐ │
|
||||
│ │ SSO Group │ FlowDeck Role │ Workspace │ │
|
||||
│ │ FlowDeck Admins │ admin │ Main │ │
|
||||
│ │ FlowDeck Members │ editor │ Main │ │
|
||||
│ └────────────────────┴────────────────┴──────────────┘ │
|
||||
│ │
|
||||
│ [Save SSO Configuration] [Disable SSO] │
|
||||
│ Status: ✅ Active — 12 users provisioned via SSO │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 Métadonnées SP pour l'IdP
|
||||
|
||||
Endpoint `GET /auth/saml/metadata` retourne le XML SAML metadata :
|
||||
|
||||
```xml
|
||||
<EntityDescriptor entityID="https://flowdeck.local/saml/metadata">
|
||||
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<KeyDescriptor use="signing">
|
||||
<KeyInfo>...</KeyInfo>
|
||||
</KeyDescriptor>
|
||||
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
Location="https://flowdeck.local/auth/saml/callback" />
|
||||
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
Location="https://flowdeck.local/auth/saml/logout" />
|
||||
</SPSSODescriptor>
|
||||
</EntityDescriptor>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Intégration avec le système existant
|
||||
|
||||
### 7.1 Comportement selon le type de compte
|
||||
|
||||
```
|
||||
SSO ACTIVÉ + local auth ENABLED:
|
||||
→ Boutons SSO visibles sur la login page
|
||||
→ Login local disponible (optionnel, admin-toggle)
|
||||
→ Les utilisateurs SSO et locaux coexistent
|
||||
|
||||
SSO ACTIVÉ + local auth DISABLED:
|
||||
→ Seuls les boutons SSO sont affichés
|
||||
→ Login local caché / 403 pour /auth/local-login
|
||||
→ Admin du workspace peut garder le sien
|
||||
|
||||
SSO ACTIVÉ + compte local existant:
|
||||
→ Email match → fusion automatique des sessions
|
||||
→ Email ne match pas → login local séparé (deux comptes)
|
||||
```
|
||||
|
||||
### 7.2 Interaction avec le PermissionManager
|
||||
|
||||
Le `PermissionManager` existant (`app/services/permission_manager.py`) est étendu :
|
||||
|
||||
```python
|
||||
class PermissionManager:
|
||||
# Méthodes existantes conservées
|
||||
# + Nouvelles méthodes SSO :
|
||||
|
||||
def is_sso_only_workspace(self, workspace_id: int) -> bool:
|
||||
"""Vérifie si le workspace exige SSO."""
|
||||
|
||||
def get_sso_roles(self, user_id: int, workspace_id: int) -> list[str]:
|
||||
"""Retourne les rôles issus des groupes SSO."""
|
||||
|
||||
def sync_sso_permissions(self, user_id: int, sso_groups: list[str], workspace_id: int):
|
||||
"""Met à jour les rôles basés sur les groupes SSO."""
|
||||
```
|
||||
|
||||
### 7.3 Cookie de session SSO
|
||||
|
||||
Le cookie `flowdeck_session` existant est utilisé tel quel. Une colonne supplémentaire dans la table `users` distingue les utilisateurs SSO :
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local';
|
||||
-- Valeurs: 'local', 'gitea', 'github', 'saml', 'oidc'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Tests
|
||||
|
||||
| Test | Description | Outil |
|
||||
|------|-------------|-------|
|
||||
| SAML login flow | Redirection complète IdP → callback → session | Playwright + mock IdP |
|
||||
| SAML metadata | Endpoint retourne XML valide | Unit test |
|
||||
| SAML assertion validation | Signature invalide → rejet | Unit test |
|
||||
| SAML replay attack | Même assertion utilisée 2× → rejet | Unit test |
|
||||
| OIDC login flow | Authorization code → token → session | Playwright + mock OIDC |
|
||||
| Auto-provision | Nouveau user SSO → création auto | Unit test |
|
||||
| Group mapping | Groupes SSO → rôles workspace | Unit test |
|
||||
| SSO disable | Désactiver SSO → tous les users restants gardent accès | Unit test |
|
||||
| SLO | Logout → redirection IdP SLO | Playwright |
|
||||
| Mixed auth | Login SSO + login local dans le même workspace | Integration test |
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist d'implémentation
|
||||
|
||||
1. **`auth/providers/saml_provider.py`** — wrapper python3-saml ou pysaml2
|
||||
2. **`auth/providers/oidc_provider.py`** — wrapper OIDC (authlib ou httpx)
|
||||
3. **`auth/routers/sso.py`** — endpoints SSO/OIDC
|
||||
4. **Migration `sso_config`** + `sso_login_history` + colonne `auth_method` sur `users`
|
||||
5. **`services/sso_provisioning.py`** — auto-provision + group mapping
|
||||
6. **Extension `settings.html`** — UI admin SSO
|
||||
7. **Extension `login.html`** — boutons SSO
|
||||
8. **SP metadata endpoint** (`/auth/saml/metadata`)
|
||||
9. **Security** — validation assertions, rate limiting, audit log
|
||||
10. **Tests** — tous les scénarios SSO
|
||||
11. **Documentation utilisateur** — `/help` section SSO setup
|
||||
12. **Dépendance** — `python3-saml` ou `pysaml2`, `authlib` dans requirements.txt
|
||||
|
||||
---
|
||||
|
||||
## 10. Dépendances Python
|
||||
|
||||
```
|
||||
# requirements.txt additions pour v6.0.0 SSO
|
||||
python3-saml>=1.16.0 # SAML 2.0 SP
|
||||
authlib>=1.3.0 # OIDC client
|
||||
cryptography>=42.0 # Signature/encryption
|
||||
xmlsec>=1.3.0 # XML signature validation (optionnel)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Références
|
||||
|
||||
- [OASIS SAML 2.0 Core Specification](https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf)
|
||||
- [SAML 2.0 for Dummies (Simplified)](https://www.onelogin.com/sites/default/files/resources/SAML_2.0_for_Dummies.pdf)
|
||||
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
|
||||
- [Azure AD SAML integration](https://learn.microsoft.com/en-us/azure/active-directory/hybrid/how-to-connect-saml-idp)
|
||||
- [Authlib — OIDC client](https://docs.authlib.org/en/latest/client/oidc.html)
|
||||
- `app/auth/providers.py` — Architecture provider existante
|
||||
- `app/services/permission_manager.py` — Extension des rôles
|
||||
- `docs/API_GUIDE_V6.md` — Référence API v2
|
||||
- `ROADMAP.md` — v6.0.0 SSO/SAML item
|
||||
@@ -0,0 +1,589 @@
|
||||
# V6.0.0 — Web Clipper : Extension Navigateur
|
||||
|
||||
> **Statut** : Conception détaillée — v6.0.0
|
||||
> **Date** : 2026-09-15
|
||||
> **Route** : `feat/v6-web-clipper` → `develop` → `main`
|
||||
> **Dépendances** : v4.0.0 Share & Publish, v5.5.0 Embeds & Rich Media, v5.2.0 OAuth (GitHub)
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision & objectifs
|
||||
|
||||
Créer une **extension de navigateur** qui permet aux utilisateurs de capturer du contenu web (articles, pages, images, bookmarks) directement dans FlowDeck. Le Web Clipper agit comme un pont entre le web et FlowDeck, transformant n'importe quelle page web en une page FlowDeck.
|
||||
|
||||
### Objectifs
|
||||
|
||||
| Critère | Cible |
|
||||
|---------|-------|
|
||||
| Navigateurs supportés | Chrome, Firefox, Edge, Safari |
|
||||
| Types de capture | Article complet, sélection, bookmark, screenshot |
|
||||
| Temps de capture | < 2 s (page simple), < 5 s (page complexe) |
|
||||
| Authentification | OAuth via popup (sans mot de passe) |
|
||||
| Format d'import | Markdown + liens + images inline |
|
||||
| Compatibilité | Fonctionne même si FlowDeck est fermé |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
### 2.1 Composants
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ NAVIGATEUR │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ EXTENSION (Manifest V3) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │ │
|
||||
│ │ │ Content │ │ Popup UI │ │ Background │ │ │
|
||||
│ │ │ Script │ │ (sidebar │ │ Script │ │ │
|
||||
│ │ │ (clipping) │ │ panel) │ │ (OAuth + sync) │ │ │
|
||||
│ │ └──────┬──────┘ └──────┬──────┘ └────────┬─────────┘ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ └────────┬───────┘ │ │ │
|
||||
│ │ ▼ │ │ │
|
||||
│ │ ┌────────────────┐ │ │ │
|
||||
│ │ │ Content │ │ │ │
|
||||
│ │ │ Extractor │ │ │ │
|
||||
│ │ │ (Readability) │ │ │ │
|
||||
│ │ └────────┬───────┘ │ │ │
|
||||
│ └─────────────────┼──────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ POST /api/v2/web-clipper/clip │ │
|
||||
└────────────────────┼─────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────▼─────────────────────────────────────────┐
|
||||
│ FASTAPI (Server) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ routers/web_clipper.py │ │
|
||||
│ │ ├─ POST /api/v2/web-clipper/clip — recevoir la clip │ │
|
||||
│ │ ├─ GET /api/v2/web-clipper/status — stat OAuth │ │
|
||||
│ │ └─ POST /api/v2/web-clipper/auth/verify — vérifier │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ services/web_clipper.py │ │
|
||||
│ │ ├─ extract_article() — Readability.js / Mercury Parser │ │
|
||||
│ │ ├─ extract_selection() — sélection HTML → Markdown │ │
|
||||
│ │ ├─ create_page_from_clip() — créer la page FlowDeck │ │
|
||||
│ │ ├─ download_images() — télécharger les images inline │ │
|
||||
│ │ └─ generate_thumbnail() — preview image │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Manifest V3 de l'extension
|
||||
|
||||
### 3.1 `manifest.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FlowDeck Web Clipper",
|
||||
"description": "Capturez du contenu web directement dans FlowDeck",
|
||||
"version": "1.0.0",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"storage",
|
||||
"scripting",
|
||||
"contextMenus"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>",
|
||||
"https://flowdeck.local/*",
|
||||
"https://flowdeck.dracodev.net/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"css": ["clipper.css"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["reader-mode.js", "readability.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints serveur
|
||||
|
||||
### 4.1 Routeur `app/routers/web_clipper.py`
|
||||
|
||||
```
|
||||
POST /api/v2/web-clipper/clip — Recevoir une capture et créer une page
|
||||
GET /api/v2/web-clipper/status — Vérifier l'authentification de l'extension
|
||||
POST /api/v2/web-clipper/auth/verify — Vérifier le token OAuth de l'extension
|
||||
POST /api/v2/web-clipper/auth/callback — OAuth callback pour l'extension
|
||||
```
|
||||
|
||||
### 4.2 Service `app/services/web_clipper.py`
|
||||
|
||||
```python
|
||||
class WebClipperService:
|
||||
"""Traitement des captures web."""
|
||||
|
||||
async def process_clip(self, clip_data: ClipPayload, user_id: int) -> dict:
|
||||
"""Traite une capture complète et crée la page FlowDeck.
|
||||
|
||||
ClipPayload:
|
||||
- url: str (URL source)
|
||||
- title: str (titre extrait)
|
||||
- content: str (HTML ou Markdown)
|
||||
- content_type: str ('article' | 'selection' | 'bookmark' | 'screenshot')
|
||||
- images: list[dict] (URLs + base64 data)
|
||||
- metadata: dict (og:title, og:description, author, date_published)
|
||||
- workspace_id: int | None
|
||||
- collection_id: int | None
|
||||
- parent_page_id: int | None
|
||||
"""
|
||||
|
||||
async def extract_article(self, html: str, url: str) -> dict:
|
||||
"""Extrait le contenu principal d'un article via Readability.js."""
|
||||
# Retourne: {title, content (HTML), text_content (Markdown), images[]}
|
||||
|
||||
async def extract_selection(self, html: str, selection_html: str) -> dict:
|
||||
"""Extrait la sélection de texte de la page."""
|
||||
# Retourne: {content: HTML, text_content: Markdown}
|
||||
|
||||
async def create_page_from_clip(self, clip_data: dict, user_id: int) -> dict:
|
||||
"""Crée une page FlowDeck à partir d'une capture.
|
||||
Inclut la création d'images inline (upload vers /api/upload)."""
|
||||
|
||||
async def download_images(self, image_urls: list[str]) -> list[str]:
|
||||
"""Télécharge les images et retourne les URLs locales."""
|
||||
```
|
||||
|
||||
### 4.3 Payload de la capture
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/article",
|
||||
"title": "Titre de l'article",
|
||||
"content": "<article>...contenu HTML...</article>",
|
||||
"content_type": "article",
|
||||
"content_format": "html",
|
||||
"images": [
|
||||
{"src": "https://example.com/image1.jpg", "alt": "Description", "base64": null},
|
||||
{"src": "https://example.com/image2.png", "alt": null, "base64": "data:image/png;base64,..."}
|
||||
],
|
||||
"metadata": {
|
||||
"og_title": "Titre Open Graph",
|
||||
"og_description": "Description de l'article",
|
||||
"author": "Auteur",
|
||||
"date_published": "2026-09-01",
|
||||
"site_name": "Example.com",
|
||||
"favicon_url": "https://example.com/favicon.ico"
|
||||
},
|
||||
"target_workspace_id": 42,
|
||||
"target_collection_id": null,
|
||||
"target_page_id": null,
|
||||
"tags": ["lecture", "important"],
|
||||
"create_as_draft": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Contenu de l'extension côté client
|
||||
|
||||
### 5.1 Content Script — Extraction du contenu
|
||||
|
||||
```javascript
|
||||
// content.js — injecté dans toutes les pages
|
||||
|
||||
class FlowDeckClipper {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// 1. Bouton flottant "Clip to FlowDeck"
|
||||
this.createFloatButton();
|
||||
|
||||
// 2. Menu contextuel (clic droit)
|
||||
this.createContextMenu();
|
||||
|
||||
// 3. Keyboard shortcut (Ctrl+Shift+C)
|
||||
this.registerShortcut();
|
||||
}
|
||||
|
||||
createFloatButton() {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'fd-clipper-btn';
|
||||
btn.innerHTML = '📌 Clip to FlowDeck';
|
||||
btn.addEventListener('click', () => this.openClipper());
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
async openClipper() {
|
||||
const url = window.location.href;
|
||||
const html = document.documentElement.outerHTML;
|
||||
const selection = window.getSelection().toString();
|
||||
|
||||
// Envoyer au service worker
|
||||
const clipData = { url, html, selection, title: document.title };
|
||||
await chrome.runtime.sendMessage({ action: 'clip', data: clipData });
|
||||
}
|
||||
|
||||
createContextMenu() {
|
||||
// Cliquez sur une sélection → menu contextuel "Send to FlowDeck"
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
const selection = window.getSelection().toString();
|
||||
if (selection.length > 0) {
|
||||
const menuItem = document.createElement('div');
|
||||
menuItem.className = 'fd-context-menu';
|
||||
menuItem.innerHTML = '<span>📌 Clip selection to FlowDeck</span>';
|
||||
menuItem.addEventListener('click', () => this.clipSelection(selection));
|
||||
document.body.appendChild(menuItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Content Extractor — Readability.js
|
||||
|
||||
L'extension embarque une version simplifiée de **Readability.js** (Mozilla) pour extraire le contenu principal des articles :
|
||||
|
||||
```javascript
|
||||
// reader-mode.js — extraction du contenu principal
|
||||
// Version embarquée simplifiée basée sur l'algorithme Readability
|
||||
|
||||
function extractArticle(html) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
// Algorithme : trouver le meilleur candidat basé sur la longueur de texte,
|
||||
// les balises <article>, <main>, <div role="article">, etc.
|
||||
// Retourne: { title, content, textContent }
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Background Script — OAuth et Sync
|
||||
|
||||
```javascript
|
||||
// background.js — Service worker de l'extension
|
||||
|
||||
// Stockage local des tokens et config
|
||||
const STORAGE_KEY = 'flowdeck_clipper';
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.storage.local.set({ [STORAGE_KEY]: { authenticated: false } });
|
||||
});
|
||||
|
||||
// ── Gestion des messages ──
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === 'clip') {
|
||||
handleClip(message.data).then(result => sendResponse(result));
|
||||
return true; // async
|
||||
}
|
||||
if (message.action === 'auth') {
|
||||
handleAuth().then(result => sendResponse(result));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Clip → Serveur ──
|
||||
async function handleClip(data) {
|
||||
const token = await getToken();
|
||||
if (!token || !isAuthenticated()) {
|
||||
return { error: 'not_authenticated', requiresAuth: true };
|
||||
}
|
||||
|
||||
const response = await fetch('https://flowdeck.local/api/v2/web-clipper/clip', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ── OAuth Flow ──
|
||||
async function handleAuth() {
|
||||
// Popup OAuth → redirect to /auth/sso/callback → receive token
|
||||
// Stocker le token dans chrome.storage.local
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Authentification de l'extension
|
||||
|
||||
### 6.1 OAuth Device Flow ou Popup
|
||||
|
||||
L'extension utilise le même OAuth2 que le web :
|
||||
|
||||
```
|
||||
Extension → Background Script → Popup Auth
|
||||
1. User clique "Connect"
|
||||
2. Popup opens → GET /auth/login (avec redirect vers extension)
|
||||
3. OAuth callback → token reçu
|
||||
4. Token stocké dans chrome.storage.local (encrypted)
|
||||
5. Tous les clips sont authentifiés automatiquement
|
||||
```
|
||||
|
||||
### 6.2 Token storage
|
||||
|
||||
```python
|
||||
# Côté serveur : table pour les tokens d'extension
|
||||
ALTER TABLE api_tokens ADD COLUMN source TEXT DEFAULT 'web';
|
||||
-- Valeurs: 'web', 'clipper', 'mobile', 'api'
|
||||
-- Permet de révoquer uniquement les tokens d'extension
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE extension_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL, -- ID unique de l'extension installée
|
||||
token_hash TEXT NOT NULL,
|
||||
scopes TEXT DEFAULT 'read,write',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, device_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Types de capture supportés
|
||||
|
||||
### 7.1 Article complet
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ URL: https://example.com/article │
|
||||
│ │
|
||||
│ ┌─── Article Content ────────────┐ │
|
||||
│ │ Title extracted │ │
|
||||
│ │ Author: John Doe │ │
|
||||
│ │ Date: 2026-09-15 │ │
|
||||
│ │ │ │
|
||||
│ │ Main article text... │ │
|
||||
│ │ │ │
|
||||
│ │ ![#image1.jpg] │ │
|
||||
│ │ Caption from article │ │
|
||||
│ │ │ │
|
||||
│ │ ![#image2.png] │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Tags: [lecture, tech] │
|
||||
│ Target: Workspace X → Collection Y │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 Sélection de texte
|
||||
|
||||
```
|
||||
L'utilisateur sélectionne du texte sur une page → clic droit → "Clip to FlowDeck"
|
||||
→ Crée une page FlowDeck avec le texte sélectionné formaté en Markdown
|
||||
→ Ajoute un lien vers la page source en bas
|
||||
```
|
||||
|
||||
### 7.3 Bookmark
|
||||
|
||||
```
|
||||
L'utilisateur clique sur le bouton clipper sur une page sans contenu riche
|
||||
→ Crée un "bookmark card" (comme v5.5.0 bookmark cards)
|
||||
→ URL, titre, favicon, description OG → page FlowDeck
|
||||
```
|
||||
|
||||
### 7.4 Screenshot
|
||||
|
||||
```
|
||||
L'utilisateur sélectionne une zone → capture en screenshot →
|
||||
→ Image uploadée dans FlowDeck → page avec l'image + annotation possible
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Interface utilisateur du serveur
|
||||
|
||||
### 8.1 Extension management dans Settings
|
||||
|
||||
Nouvelle section dans `settings.html` → onglet **"Extensions"** :
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Connected Extensions │
|
||||
│──────────────────────────────────────────────────────│
|
||||
│ FlowDeck Web Clipper │
|
||||
│ ├── Device: Chrome — Windows │
|
||||
│ ├── Connected: 2026-09-10 14:30 │
|
||||
│ ├── Clips this month: 12 │
|
||||
│ └── [Revoke Access] │
|
||||
│ │
|
||||
│ FlowDeck Web Clipper │
|
||||
│ ├── Device: Firefox — macOS │
|
||||
│ ├── Connected: 2026-09-08 09:15 │
|
||||
│ ├── Clips this month: 5 │
|
||||
│ └── [Revoke Access] │
|
||||
│ │
|
||||
│ [Download Chrome Extension] [Download Firefox Add-on] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 Page de téléchargement : `/extensions`
|
||||
|
||||
Nouvelle page publique offrant les liens de téléchargement de l'extension :
|
||||
|
||||
```
|
||||
GET /extensions — Page de téléchargement
|
||||
GET /extensions/chrome — Chrome Web Store link / CRX download
|
||||
GET /extensions/firefox — Firefox Add-on link / XPI download
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Tables de base de données
|
||||
|
||||
### 9.1 Modifications
|
||||
|
||||
```sql
|
||||
-- Extension tracking
|
||||
CREATE TABLE extension_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
extension_name TEXT NOT NULL, -- 'chrome', 'firefox', 'edge', 'safari'
|
||||
device_id TEXT NOT NULL, -- UUID unique par installation
|
||||
device_name TEXT, -- "Chrome — Windows 11"
|
||||
token_hash TEXT NOT NULL,
|
||||
scopes TEXT DEFAULT 'read,write',
|
||||
last_used_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, extension_name, device_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ext_devices_user ON extension_devices(user_id);
|
||||
CREATE INDEX idx_ext_devices_device ON extension_devices(device_id);
|
||||
|
||||
-- Extension activity log
|
||||
CREATE TABLE extension_clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL,
|
||||
clip_type TEXT NOT NULL, -- 'article', 'selection', 'bookmark', 'screenshot'
|
||||
source_url TEXT NOT NULL,
|
||||
target_page_id INTEGER REFERENCES collection_pages(id),
|
||||
target_workspace_id INTEGER,
|
||||
title TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_clips_user ON extension_clips(user_id, created_at);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Sécurité
|
||||
|
||||
| Menace | Contre-mesure |
|
||||
|--------|---------------|
|
||||
| Extension malveillante | Device ID unique + token par device, pas de partage de tokens |
|
||||
| CSRF via extension | Origin checking dans le server-side validation |
|
||||
| Content injection | Sanitisation HTML côté serveur avant création de page |
|
||||
| Données sensibles | Pas de cookies envoyés à l'extension, uniquement tokens OAuth |
|
||||
| Rate limiting | Max 50 clips/heure par device |
|
||||
| Large payload | Max 10 MB par clip (images incluses) |
|
||||
|
||||
### 10.1 Content Security Policy
|
||||
|
||||
```python
|
||||
# Extension CSP étendu
|
||||
# Ajouter dans le CSP de l'app :
|
||||
# connect-src: ws: https://flowdeck.local https://api.flowdeck.local
|
||||
# Content-Security-Policy header pour les pages d'extension
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Tests
|
||||
|
||||
| Test | Description | Outil |
|
||||
|------|-------------|-------|
|
||||
| Clip article | Capturer un article → créer une page FlowDeck | Playwright + HTTP test |
|
||||
| Clip sélection | Sélectionner du texte → créer une page avec le texte | Playwright |
|
||||
| Clip bookmark | Cliquer sur un bookmark → créer une carte bookmark | Unit test |
|
||||
| Auth flow | Extension OAuth → token → clip | Integration test |
|
||||
| Image download | Article avec images → images téléchargées et inline | Unit test |
|
||||
| Multiple devices | 2 extensions connectées → clips séparés | Integration test |
|
||||
| Revoke | Révoquer un device → clips rejetés | Unit test |
|
||||
| Rate limit | 51 clips → 429 Too Many Requests | Unit test |
|
||||
| Content sanitization | HTML malveillant → page propre créée | Security test |
|
||||
| Readability extraction | Page complexe → contenu principal extrait | Unit test |
|
||||
|
||||
---
|
||||
|
||||
## 12. Checklist d'implémentation
|
||||
|
||||
1. **`app/routers/web_clipper.py`** — endpoints clip et auth
|
||||
2. **`app/services/web_clipper.py`** — service de traitement (extraction + création de page)
|
||||
3. **Migrations DB** — `extension_devices`, `extension_clips`, colonne `source` sur `api_tokens`
|
||||
4. **Readability.js intégré** dans le content script
|
||||
5. **Extension côté client** — manifest.json, content.js, background.js, popup.html
|
||||
6. **Settings UI** — section Extensions dans settings.html
|
||||
7. **Page `/extensions`** — téléchargement
|
||||
8. **OAuth pour extension** — device flow ou popup
|
||||
9. **Content sanitization** — nettoyage HTML côté serveur
|
||||
10. **Tests** — tous les scénarios de clipping
|
||||
11. **Packaging** — Chrome Web Store / Firefox Add-on soumission
|
||||
12. **Documentation utilisateur** — guide d'installation et usage
|
||||
|
||||
---
|
||||
|
||||
## 13. Dépendances Python
|
||||
|
||||
```
|
||||
# requirements.txt additions pour v6.0.0 Web Clipper
|
||||
beautifulsoup4>=4.12 # HTML parsing / extraction
|
||||
lxml>=4.9 # XML/HTML parser rapide
|
||||
html2text>=2024.2.26 # HTML → Markdown conversion
|
||||
requests>=2.32 # Download images dans le service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Références
|
||||
|
||||
- [Chrome Extension Manifest V3](https://developer.chrome.com/docs/extensions/mv3/intro/)
|
||||
- [Firefox Add-on Development](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons)
|
||||
- [Readability.js (Mozilla)](https://github.com/mozilla/readability)
|
||||
- [Mercury Parser](https://github.com/postlight/mercury-parser)
|
||||
- [OAuth 2.0 Device Flow](https://datatracker.ietf.org/doc/html/rfc8628)
|
||||
- `app/services/web_clipper.py` — (à créer)
|
||||
- `app/routers/web_clipper.py` — (à créer)
|
||||
- `app/templates/settings.html` — section Extensions à ajouter
|
||||
- `docs/API_GUIDE_V6.md` — référence API v2
|
||||
- `ROADMAP.md` — v6.0.0 Web Clipper item
|
||||
@@ -0,0 +1,120 @@
|
||||
// FlowDeck Web Clipper — background service worker (Manifest V3)
|
||||
const STORAGE_KEY = "flowdeck_clipper";
|
||||
const DEFAULT_SERVER = "http://localhost:8080";
|
||||
|
||||
async function getConfig() {
|
||||
const data = await chrome.storage.local.get(STORAGE_KEY);
|
||||
return data[STORAGE_KEY] || { serverUrl: DEFAULT_SERVER, token: "", deviceId: "" };
|
||||
}
|
||||
|
||||
async function setConfig(cfg) {
|
||||
await chrome.storage.local.set({ [STORAGE_KEY]: cfg });
|
||||
}
|
||||
|
||||
function genDeviceId() {
|
||||
return "dev_" + Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
async function ensureDeviceId() {
|
||||
const cfg = await getConfig();
|
||||
if (!cfg.deviceId) {
|
||||
cfg.deviceId = genDeviceId();
|
||||
await setConfig(cfg);
|
||||
}
|
||||
return cfg.deviceId;
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async () => {
|
||||
await ensureDeviceId();
|
||||
chrome.contextMenus.create({
|
||||
id: "fd-clip-selection",
|
||||
title: "📌 Clip selection to FlowDeck",
|
||||
contexts: ["selection"]
|
||||
});
|
||||
chrome.contextMenus.create({
|
||||
id: "fd-clip-page",
|
||||
title: "📌 Clip page to FlowDeck",
|
||||
contexts: ["page"]
|
||||
});
|
||||
});
|
||||
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
if (!tab || !tab.id) return;
|
||||
if (info.menuItemId === "fd-clip-selection") {
|
||||
chrome.tabs.sendMessage(tab.id, { action: "clipSelection" });
|
||||
} else if (info.menuItemId === "fd-clip-page") {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => window.__fdOpenClipper && window.__fdOpenClipper("article")
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Unified clip handler
|
||||
async function handleClip(data) {
|
||||
const cfg = await getConfig();
|
||||
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
|
||||
const deviceId = cfg.deviceId || (await ensureDeviceId());
|
||||
const token = cfg.token || "";
|
||||
|
||||
// Require token or let server handle session cookie fallback
|
||||
if (!token) {
|
||||
// Try clipboard without token; server may accept session if user logged in flowdeck tab
|
||||
// but background fetch cannot send cookie cross-origin without credentials; warn
|
||||
}
|
||||
|
||||
const payload = {
|
||||
url: data.url,
|
||||
title: data.title,
|
||||
content: data.html || data.content,
|
||||
content_type: data.content_type || "article",
|
||||
selection_html: data.selection_html || "",
|
||||
device_id: deviceId,
|
||||
metadata: data.metadata || {}
|
||||
};
|
||||
|
||||
const headers = { "Content-Type": "application/json", "X-Device-Id": deviceId };
|
||||
if (token) headers["Authorization"] = "Bearer " + token;
|
||||
|
||||
const resp = await fetch(serverUrl + "/api/v2/web-clipper/clip", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
return { error: body.detail || body.error || "clip_failed", status: resp.status, ...body };
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function handleAuth() {
|
||||
// Token is set via popup; verify it
|
||||
const cfg = await getConfig();
|
||||
if (!cfg.token) return { error: "no_token" };
|
||||
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
|
||||
const resp = await fetch(serverUrl + "/api/v2/web-clipper/status", {
|
||||
headers: { Authorization: "Bearer " + cfg.token }
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
return body;
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === "clip") {
|
||||
handleClip(message.data).then((r) => sendResponse(r));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "auth") {
|
||||
handleAuth().then((r) => sendResponse(r));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "getConfig") {
|
||||
getConfig().then((c) => sendResponse(c));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "setConfig") {
|
||||
setConfig(message.config).then(() => sendResponse({ ok: true }));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
.fd-clipper-btn{
|
||||
position:fixed;bottom:20px;right:20px;z-index:2147483647;
|
||||
background:#2383E2;color:#fff;border:none;border-radius:999px;
|
||||
padding:10px 16px;font-size:13px;font-weight:600;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.25);font-family:system-ui,sans-serif;
|
||||
}
|
||||
.fd-clipper-btn:hover{background:#1a6fc2}
|
||||
.fd-clipper-toast{
|
||||
position:fixed;bottom:60px;right:20px;z-index:2147483647;
|
||||
color:#fff;padding:10px 14px;border-radius:8px;font-size:13px;font-family:system-ui,sans-serif;
|
||||
transform:translateY(10px);opacity:0;transition:all .2s;pointer-events:none;
|
||||
}
|
||||
.fd-clipper-toast.show{transform:translateY(0);opacity:1}
|
||||
@@ -0,0 +1,98 @@
|
||||
// FlowDeck Web Clipper — content script
|
||||
// Injected in every page: floating button + context menu + selection handling
|
||||
(function () {
|
||||
if (window.__fdClipperInjected) return;
|
||||
window.__fdClipperInjected = true;
|
||||
|
||||
const BTN_ID = "fd-clipper-btn";
|
||||
|
||||
function createFloatButton() {
|
||||
if (document.getElementById(BTN_ID)) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.id = BTN_ID;
|
||||
btn.className = "fd-clipper-btn";
|
||||
btn.textContent = "📌 Clip to FlowDeck";
|
||||
btn.title = "Clip this page to FlowDeck";
|
||||
btn.addEventListener("click", () => openClipper("article"));
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
function getSelectionHtml() {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || sel.toString().trim().length < 3) return "";
|
||||
const range = sel.getRangeAt(0);
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(range.cloneContents());
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function openClipper(type) {
|
||||
const html = document.documentElement.outerHTML;
|
||||
const selectionHtml = getSelectionHtml();
|
||||
const selectionText = window.getSelection() ? window.getSelection().toString().trim() : "";
|
||||
let clipType = type;
|
||||
if (selectionHtml && selectionText.length > 20) clipType = "selection";
|
||||
const payload = {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
content: html,
|
||||
content_type: clipType,
|
||||
selection_html: selectionHtml,
|
||||
metadata: {
|
||||
og_title: document.querySelector('meta[property="og:title"]')?.content || "",
|
||||
og_description: document.querySelector('meta[property="og:description"]')?.content || document.querySelector('meta[name="description"]')?.content || "",
|
||||
author: document.querySelector('meta[name="author"]')?.content || ""
|
||||
}
|
||||
};
|
||||
// Send to background
|
||||
try {
|
||||
const resp = await chrome.runtime.sendMessage({ action: "clip", data: payload });
|
||||
if (resp && resp.error === "not_authenticated") {
|
||||
alert("FlowDeck: not authenticated. Open FlowDeck settings to connect the clipper.");
|
||||
} else if (resp && resp.status === "ok") {
|
||||
showToast("Clipped to FlowDeck ✓ — page " + resp.page_id);
|
||||
} else if (resp && resp.error) {
|
||||
showToast("Clip failed: " + resp.error, true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("FlowDeck clipper", e);
|
||||
showToast("Clip failed: " + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg, isError) {
|
||||
let t = document.getElementById("fd-clipper-toast");
|
||||
if (!t) {
|
||||
t = document.createElement("div");
|
||||
t.id = "fd-clipper-toast";
|
||||
t.className = "fd-clipper-toast";
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.style.background = isError ? "#e03e3e" : "#1a7f37";
|
||||
t.classList.add("show");
|
||||
setTimeout(() => t.classList.remove("show"), 3000);
|
||||
}
|
||||
|
||||
// Context menu via right-click: if selection exists, inject inline menu
|
||||
document.addEventListener("mouseup", () => {
|
||||
// No heavy UI here; popup/background handles contextMenus API
|
||||
});
|
||||
|
||||
// Keyboard shortcut Ctrl+Shift+C
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "c") {
|
||||
e.preventDefault();
|
||||
openClipper("article");
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for popup
|
||||
window.__fdOpenClipper = openClipper;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", createFloatButton);
|
||||
} else {
|
||||
createFloatButton();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FlowDeck Web Clipper",
|
||||
"description": "Capturez du contenu web directement dans FlowDeck",
|
||||
"version": "1.0.0",
|
||||
"permissions": ["activeTab", "storage", "scripting", "contextMenus"],
|
||||
"host_permissions": ["<all_urls>", "https://flowdeck.local/*", "https://flowdeck.dracodev.net/*", "http://localhost:8080/*", "http://localhost:3000/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"css": ["clipper.css"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["readability.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui,sans-serif;width:340px;padding:16px;background:#191919;color:#e0e0e0}
|
||||
h2{font-size:15px;margin-bottom:6px}
|
||||
.desc{font-size:12px;color:#999;margin-bottom:12px;line-height:1.4}
|
||||
label{font-size:11px;color:#999;text-transform:uppercase;letter-spacing:.5px;display:block;margin-top:10px;margin-bottom:4px}
|
||||
input{width:100%;background:#2a2a2a;border:1px solid #3a3a3a;border-radius:6px;color:#fff;padding:8px 10px;font-size:13px;outline:none}
|
||||
input:focus{border-color:#2383E2}
|
||||
.btn{width:100%;margin-top:12px;padding:9px;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer}
|
||||
.btn-primary{background:#2383E2;color:#fff}
|
||||
.btn-secondary{background:#2a2a2a;color:#ccc;border:1px solid #3a3a3a}
|
||||
.btn:active{opacity:.9}
|
||||
.row{display:flex;gap:8px;margin-top:10px}
|
||||
.row .btn{flex:1}
|
||||
.status{margin-top:10px;padding:8px 10px;border-radius:6px;font-size:12px;display:none}
|
||||
.status.ok{background:rgba(46,160,67,.15);color:#2ea043;display:block}
|
||||
.status.err{background:rgba(224,62,62,.15);color:#e03e3e;display:block}
|
||||
.status.warn{background:rgba(217,115,13,.15);color:#d9730d;display:block}
|
||||
.small{font-size:11px;color:#777;margin-top:6px}
|
||||
a{color:#2383E2;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>📌 FlowDeck Clipper</h2>
|
||||
<p class="desc">Capture any page into FlowDeck in one click.</p>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn btn-primary" id="clipArticle">Clip article</button>
|
||||
<button class="btn btn-secondary" id="clipBookmark">Bookmark</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="clipSelection" style="margin-top:8px;">Clip selection</button>
|
||||
|
||||
<label>FlowDeck server URL</label>
|
||||
<input id="serverUrl" placeholder="http://localhost:8080">
|
||||
|
||||
<label>Bearer token <span style="text-transform:none;color:#666">(Settings → API tokens)</span></label>
|
||||
<input id="token" placeholder="fd_... or fd_clip_..." type="password">
|
||||
<div style="display:flex;gap:6px;margin-top:6px">
|
||||
<button class="btn btn-secondary" id="save" style="flex:1;padding:7px;font-size:12px;">Save</button>
|
||||
<button class="btn btn-secondary" id="verify" style="flex:1;padding:7px;font-size:12px;">Verify</button>
|
||||
</div>
|
||||
<div id="status" class="status"></div>
|
||||
<p class="small">Need a token? Open <a href="#" id="openFlowDeck">FlowDeck → Settings → API tokens</a> or <a href="#" id="openExtensions">Extensions</a>.</p>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
// Popup logic
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
async function load() {
|
||||
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
|
||||
$("serverUrl").value = cfg.serverUrl || "http://localhost:8080";
|
||||
$("token").value = cfg.token || "";
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const serverUrl = $("serverUrl").value.trim() || "http://localhost:8080";
|
||||
const token = $("token").value.trim();
|
||||
// deviceId preserved
|
||||
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
|
||||
cfg.serverUrl = serverUrl.replace(/\/$/, "");
|
||||
cfg.token = token;
|
||||
await chrome.runtime.sendMessage({ action: "setConfig", config: cfg });
|
||||
show("Saved ✓", "ok");
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
await save();
|
||||
const res = await chrome.runtime.sendMessage({ action: "auth" });
|
||||
if (res && res.authenticated) show(`Connected as ${res.user?.login || "user"} — ${res.devices} device(s)`, "ok");
|
||||
else if (res && res.authenticated === false) show("Not authenticated — check token / server URL", "err");
|
||||
else show(JSON.stringify(res).slice(0, 200), "warn");
|
||||
}
|
||||
|
||||
function show(msg, kind) {
|
||||
const el = $("status");
|
||||
el.textContent = msg;
|
||||
el.className = "status " + kind;
|
||||
}
|
||||
|
||||
async function doClip(type) {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab) return;
|
||||
// Get page data via scripting
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (clipType) => {
|
||||
const sel = window.getSelection();
|
||||
const html = document.documentElement.outerHTML;
|
||||
const selectionHtml = (() => {
|
||||
if (!sel || sel.rangeCount === 0) return "";
|
||||
const d = document.createElement("div");
|
||||
d.appendChild(sel.getRangeAt(0).cloneContents());
|
||||
return d.innerHTML;
|
||||
})();
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
html: html,
|
||||
content_type: sel && sel.toString().trim().length > 20 ? "selection" : clipType,
|
||||
selection_html: selectionHtml,
|
||||
metadata: {
|
||||
og_description: document.querySelector('meta[property="og:description"]')?.content || document.querySelector('meta[name="description"]')?.content || ""
|
||||
}
|
||||
};
|
||||
},
|
||||
args: [type]
|
||||
});
|
||||
const resp = await chrome.runtime.sendMessage({ action: "clip", data: result });
|
||||
if (resp && resp.status === "ok") show(`Clipped → page ${resp.page_id} ✓`, "ok");
|
||||
else show(resp.detail || resp.error || "Clip failed", "err");
|
||||
}
|
||||
|
||||
$("save").addEventListener("click", save);
|
||||
$("verify").addEventListener("click", verify);
|
||||
$("clipArticle").addEventListener("click", () => doClip("article"));
|
||||
$("clipBookmark").addEventListener("click", () => doClip("bookmark"));
|
||||
$("clipSelection").addEventListener("click", () => doClip("selection"));
|
||||
$("openFlowDeck").addEventListener("click", (e) => { e.preventDefault(); chrome.tabs.create({ url: $("serverUrl").value || "http://localhost:8080" }); });
|
||||
$("openExtensions").addEventListener("click", (e) => { e.preventDefault(); chrome.tabs.create({ url: ($("serverUrl").value || "http://localhost:8080") + "/extensions" }); });
|
||||
|
||||
load();
|
||||
@@ -0,0 +1,120 @@
|
||||
// FlowDeck Web Clipper — background service worker (Manifest V3)
|
||||
const STORAGE_KEY = "flowdeck_clipper";
|
||||
const DEFAULT_SERVER = "http://localhost:8080";
|
||||
|
||||
async function getConfig() {
|
||||
const data = await chrome.storage.local.get(STORAGE_KEY);
|
||||
return data[STORAGE_KEY] || { serverUrl: DEFAULT_SERVER, token: "", deviceId: "" };
|
||||
}
|
||||
|
||||
async function setConfig(cfg) {
|
||||
await chrome.storage.local.set({ [STORAGE_KEY]: cfg });
|
||||
}
|
||||
|
||||
function genDeviceId() {
|
||||
return "dev_" + Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
async function ensureDeviceId() {
|
||||
const cfg = await getConfig();
|
||||
if (!cfg.deviceId) {
|
||||
cfg.deviceId = genDeviceId();
|
||||
await setConfig(cfg);
|
||||
}
|
||||
return cfg.deviceId;
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async () => {
|
||||
await ensureDeviceId();
|
||||
chrome.contextMenus.create({
|
||||
id: "fd-clip-selection",
|
||||
title: "📌 Clip selection to FlowDeck",
|
||||
contexts: ["selection"]
|
||||
});
|
||||
chrome.contextMenus.create({
|
||||
id: "fd-clip-page",
|
||||
title: "📌 Clip page to FlowDeck",
|
||||
contexts: ["page"]
|
||||
});
|
||||
});
|
||||
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
if (!tab || !tab.id) return;
|
||||
if (info.menuItemId === "fd-clip-selection") {
|
||||
chrome.tabs.sendMessage(tab.id, { action: "clipSelection" });
|
||||
} else if (info.menuItemId === "fd-clip-page") {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => window.__fdOpenClipper && window.__fdOpenClipper("article")
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Unified clip handler
|
||||
async function handleClip(data) {
|
||||
const cfg = await getConfig();
|
||||
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
|
||||
const deviceId = cfg.deviceId || (await ensureDeviceId());
|
||||
const token = cfg.token || "";
|
||||
|
||||
// Require token or let server handle session cookie fallback
|
||||
if (!token) {
|
||||
// Try clipboard without token; server may accept session if user logged in flowdeck tab
|
||||
// but background fetch cannot send cookie cross-origin without credentials; warn
|
||||
}
|
||||
|
||||
const payload = {
|
||||
url: data.url,
|
||||
title: data.title,
|
||||
content: data.html || data.content,
|
||||
content_type: data.content_type || "article",
|
||||
selection_html: data.selection_html || "",
|
||||
device_id: deviceId,
|
||||
metadata: data.metadata || {}
|
||||
};
|
||||
|
||||
const headers = { "Content-Type": "application/json", "X-Device-Id": deviceId };
|
||||
if (token) headers["Authorization"] = "Bearer " + token;
|
||||
|
||||
const resp = await fetch(serverUrl + "/api/v2/web-clipper/clip", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
return { error: body.detail || body.error || "clip_failed", status: resp.status, ...body };
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function handleAuth() {
|
||||
// Token is set via popup; verify it
|
||||
const cfg = await getConfig();
|
||||
if (!cfg.token) return { error: "no_token" };
|
||||
const serverUrl = (cfg.serverUrl || DEFAULT_SERVER).replace(/\/$/, "");
|
||||
const resp = await fetch(serverUrl + "/api/v2/web-clipper/status", {
|
||||
headers: { Authorization: "Bearer " + cfg.token }
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
return body;
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === "clip") {
|
||||
handleClip(message.data).then((r) => sendResponse(r));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "auth") {
|
||||
handleAuth().then((r) => sendResponse(r));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "getConfig") {
|
||||
getConfig().then((c) => sendResponse(c));
|
||||
return true;
|
||||
}
|
||||
if (message.action === "setConfig") {
|
||||
setConfig(message.config).then(() => sendResponse({ ok: true }));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
.fd-clipper-btn{
|
||||
position:fixed;bottom:20px;right:20px;z-index:2147483647;
|
||||
background:#2383E2;color:#fff;border:none;border-radius:999px;
|
||||
padding:10px 16px;font-size:13px;font-weight:600;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.25);font-family:system-ui,sans-serif;
|
||||
}
|
||||
.fd-clipper-btn:hover{background:#1a6fc2}
|
||||
.fd-clipper-toast{
|
||||
position:fixed;bottom:60px;right:20px;z-index:2147483647;
|
||||
color:#fff;padding:10px 14px;border-radius:8px;font-size:13px;font-family:system-ui,sans-serif;
|
||||
transform:translateY(10px);opacity:0;transition:all .2s;pointer-events:none;
|
||||
}
|
||||
.fd-clipper-toast.show{transform:translateY(0);opacity:1}
|
||||
@@ -0,0 +1,98 @@
|
||||
// FlowDeck Web Clipper — content script
|
||||
// Injected in every page: floating button + context menu + selection handling
|
||||
(function () {
|
||||
if (window.__fdClipperInjected) return;
|
||||
window.__fdClipperInjected = true;
|
||||
|
||||
const BTN_ID = "fd-clipper-btn";
|
||||
|
||||
function createFloatButton() {
|
||||
if (document.getElementById(BTN_ID)) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.id = BTN_ID;
|
||||
btn.className = "fd-clipper-btn";
|
||||
btn.textContent = "📌 Clip to FlowDeck";
|
||||
btn.title = "Clip this page to FlowDeck";
|
||||
btn.addEventListener("click", () => openClipper("article"));
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
function getSelectionHtml() {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || sel.toString().trim().length < 3) return "";
|
||||
const range = sel.getRangeAt(0);
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(range.cloneContents());
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function openClipper(type) {
|
||||
const html = document.documentElement.outerHTML;
|
||||
const selectionHtml = getSelectionHtml();
|
||||
const selectionText = window.getSelection() ? window.getSelection().toString().trim() : "";
|
||||
let clipType = type;
|
||||
if (selectionHtml && selectionText.length > 20) clipType = "selection";
|
||||
const payload = {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
content: html,
|
||||
content_type: clipType,
|
||||
selection_html: selectionHtml,
|
||||
metadata: {
|
||||
og_title: document.querySelector('meta[property="og:title"]')?.content || "",
|
||||
og_description: document.querySelector('meta[property="og:description"]')?.content || document.querySelector('meta[name="description"]')?.content || "",
|
||||
author: document.querySelector('meta[name="author"]')?.content || ""
|
||||
}
|
||||
};
|
||||
// Send to background
|
||||
try {
|
||||
const resp = await chrome.runtime.sendMessage({ action: "clip", data: payload });
|
||||
if (resp && resp.error === "not_authenticated") {
|
||||
alert("FlowDeck: not authenticated. Open FlowDeck settings to connect the clipper.");
|
||||
} else if (resp && resp.status === "ok") {
|
||||
showToast("Clipped to FlowDeck ✓ — page " + resp.page_id);
|
||||
} else if (resp && resp.error) {
|
||||
showToast("Clip failed: " + resp.error, true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("FlowDeck clipper", e);
|
||||
showToast("Clip failed: " + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg, isError) {
|
||||
let t = document.getElementById("fd-clipper-toast");
|
||||
if (!t) {
|
||||
t = document.createElement("div");
|
||||
t.id = "fd-clipper-toast";
|
||||
t.className = "fd-clipper-toast";
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.style.background = isError ? "#e03e3e" : "#1a7f37";
|
||||
t.classList.add("show");
|
||||
setTimeout(() => t.classList.remove("show"), 3000);
|
||||
}
|
||||
|
||||
// Context menu via right-click: if selection exists, inject inline menu
|
||||
document.addEventListener("mouseup", () => {
|
||||
// No heavy UI here; popup/background handles contextMenus API
|
||||
});
|
||||
|
||||
// Keyboard shortcut Ctrl+Shift+C
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "c") {
|
||||
e.preventDefault();
|
||||
openClipper("article");
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for popup
|
||||
window.__fdOpenClipper = openClipper;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", createFloatButton);
|
||||
} else {
|
||||
createFloatButton();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#2383E2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#fff">FD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FlowDeck Web Clipper",
|
||||
"description": "Capturez du contenu web directement dans FlowDeck",
|
||||
"version": "1.0.0",
|
||||
"permissions": ["activeTab", "storage", "scripting", "contextMenus"],
|
||||
"host_permissions": ["<all_urls>", "https://flowdeck.local/*", "https://flowdeck.dracodev.net/*", "http://localhost:8080/*", "http://localhost:3000/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"css": ["clipper.css"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["readability.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui,sans-serif;width:340px;padding:16px;background:#191919;color:#e0e0e0}
|
||||
h2{font-size:15px;margin-bottom:6px}
|
||||
.desc{font-size:12px;color:#999;margin-bottom:12px;line-height:1.4}
|
||||
label{font-size:11px;color:#999;text-transform:uppercase;letter-spacing:.5px;display:block;margin-top:10px;margin-bottom:4px}
|
||||
input{width:100%;background:#2a2a2a;border:1px solid #3a3a3a;border-radius:6px;color:#fff;padding:8px 10px;font-size:13px;outline:none}
|
||||
input:focus{border-color:#2383E2}
|
||||
.btn{width:100%;margin-top:12px;padding:9px;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer}
|
||||
.btn-primary{background:#2383E2;color:#fff}
|
||||
.btn-secondary{background:#2a2a2a;color:#ccc;border:1px solid #3a3a3a}
|
||||
.btn:active{opacity:.9}
|
||||
.row{display:flex;gap:8px;margin-top:10px}
|
||||
.row .btn{flex:1}
|
||||
.status{margin-top:10px;padding:8px 10px;border-radius:6px;font-size:12px;display:none}
|
||||
.status.ok{background:rgba(46,160,67,.15);color:#2ea043;display:block}
|
||||
.status.err{background:rgba(224,62,62,.15);color:#e03e3e;display:block}
|
||||
.status.warn{background:rgba(217,115,13,.15);color:#d9730d;display:block}
|
||||
.small{font-size:11px;color:#777;margin-top:6px}
|
||||
a{color:#2383E2;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>📌 FlowDeck Clipper</h2>
|
||||
<p class="desc">Capture any page into FlowDeck in one click.</p>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn btn-primary" id="clipArticle">Clip article</button>
|
||||
<button class="btn btn-secondary" id="clipBookmark">Bookmark</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="clipSelection" style="margin-top:8px;">Clip selection</button>
|
||||
|
||||
<label>FlowDeck server URL</label>
|
||||
<input id="serverUrl" placeholder="http://localhost:8080">
|
||||
|
||||
<label>Bearer token <span style="text-transform:none;color:#666">(Settings → API tokens)</span></label>
|
||||
<input id="token" placeholder="fd_... or fd_clip_..." type="password">
|
||||
<div style="display:flex;gap:6px;margin-top:6px">
|
||||
<button class="btn btn-secondary" id="save" style="flex:1;padding:7px;font-size:12px;">Save</button>
|
||||
<button class="btn btn-secondary" id="verify" style="flex:1;padding:7px;font-size:12px;">Verify</button>
|
||||
</div>
|
||||
<div id="status" class="status"></div>
|
||||
<p class="small">Need a token? Open <a href="#" id="openFlowDeck">FlowDeck → Settings → API tokens</a> or <a href="#" id="openExtensions">Extensions</a>.</p>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
// Popup logic
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
async function load() {
|
||||
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
|
||||
$("serverUrl").value = cfg.serverUrl || "http://localhost:8080";
|
||||
$("token").value = cfg.token || "";
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const serverUrl = $("serverUrl").value.trim() || "http://localhost:8080";
|
||||
const token = $("token").value.trim();
|
||||
// deviceId preserved
|
||||
const cfg = await chrome.runtime.sendMessage({ action: "getConfig" });
|
||||
cfg.serverUrl = serverUrl.replace(/\/$/, "");
|
||||
cfg.token = token;
|
||||
await chrome.runtime.sendMessage({ action: "setConfig", config: cfg });
|
||||
show("Saved ✓", "ok");
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
await save();
|
||||
const res = await chrome.runtime.sendMessage({ action: "auth" });
|
||||
if (res && res.authenticated) show(`Connected as ${res.user?.login || "user"} — ${res.devices} device(s)`, "ok");
|
||||
else if (res && res.authenticated === false) show("Not authenticated — check token / server URL", "err");
|
||||
else show(JSON.stringify(res).slice(0, 200), "warn");
|
||||
}
|
||||
|
||||
function show(msg, kind) {
|
||||
const el = $("status");
|
||||
el.textContent = msg;
|
||||
el.className = "status " + kind;
|
||||
}
|
||||
|
||||
async function doClip(type) {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab) return;
|
||||
// Get page data via scripting
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (clipType) => {
|
||||
const sel = window.getSelection();
|
||||
const html = document.documentElement.outerHTML;
|
||||
const selectionHtml = (() => {
|
||||
if (!sel || sel.rangeCount === 0) return "";
|
||||
const d = document.createElement("div");
|
||||
d.appendChild(sel.getRangeAt(0).cloneContents());
|
||||
return d.innerHTML;
|
||||
})();
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
html: html,
|
||||
content_type: sel && sel.toString().trim().length > 20 ? "selection" : clipType,
|
||||
selection_html: selectionHtml,
|
||||
metadata: {
|
||||
og_description: document.querySelector('meta[property="og:description"]')?.content || document.querySelector('meta[name="description"]')?.content || ""
|
||||
}
|
||||
};
|
||||
},
|
||||
args: [type]
|
||||
});
|
||||
const resp = await chrome.runtime.sendMessage({ action: "clip", data: result });
|
||||
if (resp && resp.status === "ok") show(`Clipped → page ${resp.page_id} ✓`, "ok");
|
||||
else show(resp.detail || resp.error || "Clip failed", "err");
|
||||
}
|
||||
|
||||
$("save").addEventListener("click", save);
|
||||
$("verify").addEventListener("click", verify);
|
||||
$("clipArticle").addEventListener("click", () => doClip("article"));
|
||||
$("clipBookmark").addEventListener("click", () => doClip("bookmark"));
|
||||
$("clipSelection").addEventListener("click", () => doClip("selection"));
|
||||
$("openFlowDeck").addEventListener("click", (e) => { e.preventDefault(); chrome.tabs.create({ url: $("serverUrl").value || "http://localhost:8080" }); });
|
||||
$("openExtensions").addEventListener("click", (e) => { e.preventDefault(); chrome.tabs.create({ url: ($("serverUrl").value || "http://localhost:8080") + "/extensions" }); });
|
||||
|
||||
load();
|
||||
@@ -0,0 +1,296 @@
|
||||
"""FlowDeck — v6.2.0 Web Clipper tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
|
||||
def _token(user_id, login="user", is_admin=0):
|
||||
return SessionManager.create_session({"id": user_id, "login": login, "full_name": "User", "is_admin": is_admin})
|
||||
|
||||
|
||||
def _as(client, user_id, login="user", is_admin=0):
|
||||
client.cookies.set("flowdeck_session", _token(user_id, login, is_admin))
|
||||
|
||||
|
||||
def _insert_user(login="clipper_user", email="[email protected]"):
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (login, email, full_name, is_active) VALUES (?,?,?,1)",
|
||||
(login, email, "Clipper User"),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _insert_workspace(owner_id, name="ClipWS"):
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, owner_id))
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?, 'admin')", (cur.lastrowid, owner_id))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
# ── Service unit tests ──
|
||||
|
||||
def test_sanitize_removes_script(client):
|
||||
from app.services.web_clipper import sanitize_html
|
||||
|
||||
html = '<p>Hello</p><script>alert(1)</script><div onclick="evil()">x</div><a href="javascript:alert(1)">click</a>'
|
||||
clean = sanitize_html(html)
|
||||
assert "<script" not in clean
|
||||
assert "onclick" not in clean
|
||||
assert "javascript:" not in clean
|
||||
assert "Hello" in clean
|
||||
|
||||
|
||||
def test_html_to_blocks_basic(client):
|
||||
from app.services.web_clipper import html_to_blocks
|
||||
|
||||
html = "<h1>Title</h1><p>Paragraph text</p><ul><li>Item A</li><li>Item B</li></ul><blockquote>Quote</blockquote>"
|
||||
blocks = html_to_blocks(html)
|
||||
types = [b["type"] for b in blocks]
|
||||
assert "heading_1" in types
|
||||
assert "paragraph" in types
|
||||
assert "bulleted_list" in types
|
||||
assert "quote" in types
|
||||
|
||||
|
||||
def test_html_to_blocks_image(client):
|
||||
from app.services.web_clipper import html_to_blocks
|
||||
|
||||
html = '<p><img src="https://example.com/img.png" alt="alt"></p><p>Text</p>'
|
||||
blocks = html_to_blocks(html)
|
||||
assert any(b["type"] == "image" and b["src"] == "https://example.com/img.png" for b in blocks)
|
||||
|
||||
|
||||
def test_extract_article(client):
|
||||
from app.services.web_clipper import extract_article
|
||||
|
||||
html = "<html><head><title> My Article </title></head><body><article><h1>Heading</h1><p>Body text</p></article></body></html>"
|
||||
res = extract_article(html, url="https://example.com/a")
|
||||
assert res["title"] == "My Article"
|
||||
assert any(b["type"] == "heading_1" for b in res["blocks"])
|
||||
|
||||
|
||||
# ── API tests ──
|
||||
|
||||
def test_clip_requires_auth(client):
|
||||
r = client.post("/api/v2/web-clipper/clip", json={"url": "https://example.com", "title": "T"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_clip_article_creates_page(client):
|
||||
uid = _insert_user("clip_a")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_a")
|
||||
html = "<html><head><title>Example Article</title></head><body><h1>Hello</h1><p>World paragraph</p><p><img src='https://example.com/x.png' alt='x'></p></body></html>"
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com/article", "title": "Example Article", "content": html, "content_type": "article", "device_id": "dev1"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["page_id"]
|
||||
# Verify page in DB
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT title, content, workspace_id FROM pages WHERE id=?", (data["page_id"],)).fetchone()
|
||||
assert row is not None
|
||||
assert row["title"] == "Example Article"
|
||||
blocks = json.loads(row["content"])
|
||||
assert any(b["type"] == "paragraph" for b in blocks)
|
||||
# Clip logged
|
||||
cnt = conn.execute("SELECT COUNT(*) FROM extension_clips WHERE user_id=?", (uid,)).fetchone()[0]
|
||||
assert cnt == 1
|
||||
|
||||
|
||||
def test_clip_bookmark(client):
|
||||
uid = _insert_user("clip_b")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_b")
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com", "title": "My Bookmark", "content_type": "bookmark", "device_id": "dev-b"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (data["page_id"],)).fetchone()
|
||||
blocks = json.loads(row["content"])
|
||||
assert any(b["type"] == "bookmark" and b["url"] == "https://example.com" for b in blocks)
|
||||
|
||||
|
||||
def test_clip_selection(client):
|
||||
uid = _insert_user("clip_sel")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_sel")
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={
|
||||
"url": "https://example.com/p",
|
||||
"title": "Sel Test",
|
||||
"content_type": "selection",
|
||||
"selection_html": "<p>Selected <b>text</b> here</p>",
|
||||
"device_id": "dev-sel",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (r.json()["page_id"],)).fetchone()
|
||||
blocks = json.loads(row["content"])
|
||||
assert any("Selected" in b.get("content", "") for b in blocks)
|
||||
|
||||
|
||||
def test_clip_screenshot(client):
|
||||
uid = _insert_user("clip_shot")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_shot")
|
||||
b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com", "title": "Shot", "content_type": "screenshot", "image_base64": b64, "device_id": "dev-shot"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (r.json()["page_id"],)).fetchone()
|
||||
blocks = json.loads(row["content"])
|
||||
assert any(b["type"] == "image" for b in blocks)
|
||||
|
||||
|
||||
def test_clip_sanitization_script_stripped(client):
|
||||
uid = _insert_user("clip_sani")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_sani")
|
||||
html = '<p>ok</p><script>alert(1)</script>'
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com", "content": html, "content_type": "article", "device_id": "dev-sani"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (r.json()["page_id"],)).fetchone()
|
||||
assert "alert" not in row["content"]
|
||||
assert "ok" in row["content"]
|
||||
|
||||
|
||||
def test_clip_rate_limit(client):
|
||||
uid = _insert_user("clip_rate")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_rate")
|
||||
# Reset rate store bucket for isolation (uses in-memory dict keyed by user:device)
|
||||
from app.services.web_clipper import _rate_store
|
||||
|
||||
_rate_store.clear()
|
||||
for i in range(50):
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": f"https://example.com/{i}", "title": f"T{i}", "content": "<p>hi</p>", "device_id": "rate-dev"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com/overflow", "title": "overflow", "content": "<p>hi</p>", "device_id": "rate-dev"},
|
||||
)
|
||||
assert r.status_code == 429
|
||||
_rate_store.clear()
|
||||
|
||||
|
||||
def test_clip_bearer_token_auth(client):
|
||||
import hashlib
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
uid = _insert_user("clip_bearer")
|
||||
_insert_workspace(uid)
|
||||
# Create api_token
|
||||
token = "fd_test_clipper_bearer_123"
|
||||
th = hashlib.sha256(token.encode()).hexdigest()
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?,?,?,?)", (uid, "test", th, token[:12]))
|
||||
conn.commit()
|
||||
# No session cookie
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/api/v2/web-clipper/clip",
|
||||
json={"url": "https://example.com/bearer", "title": "BearerClip", "content": "<p>via bearer</p>", "device_id": "bearer-dev"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT title FROM pages WHERE id=?", (r.json()["page_id"],)).fetchone()
|
||||
assert row["title"] == "BearerClip"
|
||||
|
||||
|
||||
def test_status_and_devices_flow(client):
|
||||
uid = _insert_user("clip_status")
|
||||
_insert_workspace(uid)
|
||||
# unauth status
|
||||
client.cookies.clear()
|
||||
r = client.get("/api/v2/web-clipper/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["authenticated"] is False
|
||||
# auth status
|
||||
_as(client, uid, "clip_status")
|
||||
r = client.get("/api/v2/web-clipper/status")
|
||||
assert r.json()["authenticated"] is True
|
||||
# register device via verify
|
||||
r = client.post("/api/v2/web-clipper/auth/verify", json={"device_id": "dev-xyz-123", "device_name": "Chrome — Test", "extension_name": "chrome"})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["device_id"] == "dev-xyz-123"
|
||||
# second verify same device reports existing
|
||||
r2 = client.post("/api/v2/web-clipper/auth/verify", json={"device_id": "dev-xyz-123", "device_name": "Chrome — Test"})
|
||||
assert r2.json().get("existing") is True
|
||||
# list devices
|
||||
r = client.get("/api/v2/web-clipper/devices")
|
||||
assert r.status_code == 200
|
||||
devs = r.json()["devices"]
|
||||
assert any(d["device_id"] == "dev-xyz-123" for d in devs)
|
||||
did = [d for d in devs if d["device_id"] == "dev-xyz-123"][0]["id"]
|
||||
# revoke
|
||||
r = client.delete(f"/api/v2/web-clipper/devices/{did}")
|
||||
assert r.status_code == 200
|
||||
r = client.get("/api/v2/web-clipper/devices")
|
||||
remaining = [d for d in r.json()["devices"] if d["id"] == did]
|
||||
assert remaining[0]["revoked"] == 1
|
||||
|
||||
|
||||
def test_extensions_page(client):
|
||||
r = client.get("/extensions")
|
||||
assert r.status_code == 200
|
||||
assert "Web Clipper" in r.text
|
||||
assert "Chrome" in r.text
|
||||
|
||||
|
||||
def test_clip_invalid_url_rejected(client):
|
||||
uid = _insert_user("clip_badurl")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_badurl")
|
||||
r = client.post("/api/v2/web-clipper/clip", json={"url": "javascript:alert(1)", "title": "x", "device_id": "d"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_clip_payload_too_large(client):
|
||||
uid = _insert_user("clip_big")
|
||||
_insert_workspace(uid)
|
||||
_as(client, uid, "clip_big")
|
||||
big = "x" * (11 * 1024 * 1024) # 11 MB string
|
||||
r = client.post("/api/v2/web-clipper/clip", json={"url": "https://example.com", "title": "big", "content": big, "device_id": "big-dev"})
|
||||
# Expect 413 or 400 due to body size / content-length
|
||||
assert r.status_code in (413, 400, 422)
|
||||