feat: v6.2.0 Web Clipper — extension navigateur (capture article/selection/bookmark/screenshot)
FlowDeck CI / lint (push) Successful in 1m11s
FlowDeck CI / test (push) Failing after 8m32s
FlowDeck CI / docker (push) Skipped

- Service app/services/web_clipper.py: sanitize HTML, html->blocks, extraction article, creation page workspace-aware, rate limit 50/h, device registration
- Router app/routers/web_clipper.py: POST /api/v2/web-clipper/clip, GET /status, POST /auth/verify, GET/DELETE /devices, GET /extensions (download page), auth via session ou Bearer (api_tokens / extension_devices)
- Migration 19: extension_devices + extension_clips (+ indexes)
- Extension Manifest V3: content.js (floating button, selection), background.js (clip + contextMenus), popup.html/js, clipper.css, icons
- Settings UI: onglet Extensions (liste devices, revoke, test clip, liens download), page /extensions
- Tests: 16 tests web_clipper (sanitize, blocks, article/bookmark/selection/screenshot, bearer, rate-limit, devices, extensions page)
- Bump version 6.1.0 -> 6.2.0
This commit is contained in:
2026-09-19 23:26:03 -04:00
parent 13d5f8625a
commit 0b251649e5
31 changed files with 3072 additions and 3 deletions
+5 -1
View File
@@ -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")
+47
View File
@@ -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.
+1 -1
View File
@@ -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:
+316
View File
@@ -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))
+435
View File
@@ -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
+82
View File
@@ -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 &amp; 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 {