From b50387a06bdc677dea55836cc28189100d49f867 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 17 Jul 2026 10:35:07 +0200 Subject: [PATCH 1/4] fix(auth): record client IP + user-agent on the MFA login paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /login/mfa and /totp/confirm both hardcoded "ip": "" and omitted user_agent entirely, while only the password path captured them. admin is forced-TOTP, so EVERY real admin login took the MFA path — every session row landed unauditable (who logged in, from where, with what), and the users webui sessions tab rendered blanks for data it was already asking for. Factor the extraction into _client_meta(request) (X-Forwarded-For first: nginx and HAProxy front every login, so request.client.host is only ever the proxy) and use it on all three paths. Verified by driving the real /login/mfa handler in-process with SECUBOX_AUTH_SESSIONS pointed at a temp file: the session row now records ip=192.168.1.77 (the first XFF hop, not the proxy) and the full user-agent. Co-Authored-By: Gerald KERMA --- packages/secubox-auth/api/main.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/secubox-auth/api/main.py b/packages/secubox-auth/api/main.py index 174d4b07..e62a8e6a 100644 --- a/packages/secubox-auth/api/main.py +++ b/packages/secubox-auth/api/main.py @@ -260,13 +260,21 @@ def _check_scope(authorization: Optional[str], expected_scope: str) -> dict: return payload -@_login_router.post("/login") -def _login_v2(req: _LoginIn, request: _Request, response: _Response): - """Branching login: setup_token / mfa_token / enrollment_token / access_token.""" +def _client_meta(request: _Request) -> tuple: + """(ip, user_agent) of the caller. nginx/HAProxy front every login, so the + real client is in X-Forwarded-For; request.client.host would just be the + proxy. Every login path MUST record these — a session row without them is + unauditable (who logged in from where).""" ip = (request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.headers.get("X-Real-IP", "") or (request.client.host if request.client else "")) - ua = request.headers.get("User-Agent", "")[:100] + return ip, request.headers.get("User-Agent", "")[:100] + + +@_login_router.post("/login") +def _login_v2(req: _LoginIn, request: _Request, response: _Response): + """Branching login: setup_token / mfa_token / enrollment_token / access_token.""" + ip, ua = _client_meta(request) user = user_store.get_user(req.username) if not user or not user.get("enabled"): @@ -329,7 +337,10 @@ async def _login_mfa(req: _MfaIn, request: _Request, response: _Response): jti = secrets.token_hex(8) tok = create_token(username, jti=jti) _set_session_cookie(response, tok) # SSO-lite (#400) - _on_session_event("login_success", username, {"jti": jti, "expires_in": 86400, "ip": ""}) + ip, ua = _client_meta(request) + _on_session_event("login_success", username, { + "jti": jti, "expires_in": 86400, "ip": ip, "user_agent": ua, + }) _users_engine.touch_last_login(username) return {"access_token": tok, "token_type": "bearer", "expires_in": 86400} @@ -365,7 +376,10 @@ def _totp_confirm(req: _MfaIn, request: _Request, response: _Response): jti = secrets.token_hex(8) tok = create_token(username, jti=jti) _set_session_cookie(response, tok) # SSO-lite (#400) - _on_session_event("login_success", username, {"jti": jti, "expires_in": 86400, "ip": ""}) + ip, ua = _client_meta(request) + _on_session_event("login_success", username, { + "jti": jti, "expires_in": 86400, "ip": ip, "user_agent": ua, + }) return { "access_token": tok, "token_type": "bearer", "expires_in": 86400, "backup_codes": backup_plain, From f9302df075d3799e56f43b9d3d60e711c6080dab Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 17 Jul 2026 10:35:24 +0200 Subject: [PATCH 2/4] feat(billets): emoji hashtags, quick views, and feed resumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authors already typed #hashtags in their bodies (37 of the 46 billets on gk2 carry one), so tags are EXTRACTED from the body rather than added via a picker: tagging costs the author nothing and applies retroactively. - migration 0004: tag(slug,emoji,label) + billet_tag, indexed on tag_slug. Extracted into rows rather than re-parsed per request, so filtering hits an index instead of LIKE-scanning every body. slug is accent-folded + lowercased (#Réseau == #reseau); emoji is STORED so a later curation change never silently restyles an already-published billet. - services/tags.py: curated map + extraction. The lookbehind keeps a URL anchor (page#anchor) from becoming a tag; unknown tags fall back to a neutral badge rather than being rejected. Map covers this box's real content (podcast/media) as well as the security/box vocabulary; genuinely ambiguous topics are left on the default rather than editorialised. - quick views: ?tag=slug on / and /feed.json, plus /tags.json and /admin/api/tags for the chip bar. Uses EXISTS, not a JOIN, so keyset paging cannot duplicate a row per matching tag; the pager carries the active tag. - feed resumes: long billets show a short excerpt + "Lire la suite"; short ones render whole. `is_long` is measured on the markdown-collapsed text, so a short billet padded with link syntax is not "resumed" into a copy of itself. - feed.json gains `summary` + `tags` (both JSON Feed 1.1 standard) with the emoji in the spec-legal `_secubox` extension; optional keys are omitted, not nulled. - GET /admin/api/billets: the authoring list. The public feed cannot serve it — its item id is a permalink URL (so edit/delete could not address a row, which is why they 404'd) and it hides drafts, which an authoring client must see. - manage.py backfill-tags: idempotent; also re-applies the curated map to existing tags as an explicit operator step. Verified on gk2: backfill tagged 37/46 billets; ?tag=podcast→37, jfk→4, nope→0; a draft's tag correctly stays out of the public chip bar; delete cascades. Co-Authored-By: Gerald KERMA --- packages/secubox-billets/api/main.py | 42 +++++++-- packages/secubox-billets/api/manage.py | 40 ++++++++ .../api/migrations/0004_tags.sql | 32 +++++++ packages/secubox-billets/api/repo.py | 74 ++++++++++++++- .../secubox-billets/api/routes/jwt_admin.py | 31 ++++++- .../secubox-billets/api/services/feeds.py | 29 ++++-- packages/secubox-billets/api/services/tags.py | 93 +++++++++++++++++++ .../secubox-billets/api/static/billets.css | 25 +++++ .../secubox-billets/api/templates/feed.html | 31 ++++++- 9 files changed, 379 insertions(+), 18 deletions(-) create mode 100644 packages/secubox-billets/api/migrations/0004_tags.sql create mode 100644 packages/secubox-billets/api/services/tags.py diff --git a/packages/secubox-billets/api/main.py b/packages/secubox-billets/api/main.py index fee75c5a..a0d1a9c0 100644 --- a/packages/secubox-billets/api/main.py +++ b/packages/secubox-billets/api/main.py @@ -114,10 +114,17 @@ _SECURITY_HEADERS = { } -def _billet_view(row: aiosqlite.Row, base: str = "", media_rows=None) -> dict: +def _billet_view(row: aiosqlite.Row, base: str = "", media_rows=None, tags=None) -> dict: from urllib.parse import urlparse d = dict(row) d["body_html"] = render_markdown(d["body"]) + d["tags"] = tags or [] + # A few words for list/preview contexts; body_html keeps the full billet. + d["summary"] = feeds.excerpt(d["body"], max_len=140) + # Only long billets get resumed in the feed — `excerpt` collapses markdown, so + # compare against IT, not len(body): a short billet padded with link syntax + # would otherwise be "resumed" to a copy of itself followed by "Lire la suite". + d["is_long"] = len(feeds.excerpt(d["body"], max_len=10_000)) > 140 d["ref_host"] = (urlparse(d["ref_url"]).hostname if d.get("ref_url") else None) title = feeds.billet_title(d["body"]) d["title"] = title @@ -173,14 +180,20 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None = return {"status": "ok", "module": "billets"} @app.get("/", response_class=HTMLResponse) - async def feed(request: Request, cursor: str | None = None): - rows, next_cursor = await repo.list_published(app.state.conn, limit=PAGE_SIZE, cursor=cursor) + async def feed(request: Request, cursor: str | None = None, tag: str | None = None): + rows, next_cursor = await repo.list_published(app.state.conn, limit=PAGE_SIZE, + cursor=cursor, tag=tag) base = _base(request) media_map = await repo.list_media_for(app.state.conn, [r["id"] for r in rows]) + # One batched lookup for the page's chips + the whole-site chip bar. + tag_map = await repo.tags_for_many(app.state.conn, [r["id"] for r in rows]) resp = templates.TemplateResponse(request, "feed.html", { "site_title": SITE_TITLE, "tagline": SITE_TAGLINE, - "billets": [_billet_view(r, base, media_map.get(r["id"])) for r in rows], + "billets": [_billet_view(r, base, media_map.get(r["id"]), tag_map.get(r["id"])) + for r in rows], "next_cursor": next_cursor, + "all_tags": await repo.list_tags(app.state.conn), + "active_tag": tag, }) # Embeds render inline in the feed too; allow any self-hosted embed hosts # of the shown billets in frame-src (static providers already covered). @@ -256,18 +269,33 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None = self_url=f"{base}/feed.xml", entries=entries, updated=updated) return Response(xml, media_type="application/atom+xml") + @app.get("/tags.json") + async def tags_json(): + """The quick-view chip bar: every emoji hashtag in use, most-used first.""" + return {"tags": await repo.list_tags(app.state.conn)} + @app.get("/feed.json") - async def feed_json(request: Request): + async def feed_json(request: Request, tag: str | None = None): base = _base(request) - rows = await _feed_rows() + rows, _ = await repo.list_published(app.state.conn, limit=30, tag=tag) + # One batched lookup for the whole page — never per-item (N+1). + tag_map = await repo.tags_for_many(app.state.conn, [r["id"] for r in rows]) items = [{ "id": f"{base}/b/{r['slug']}", "url": f"{base}/b/{r['slug']}", "title": feeds.billet_title(r["body"]), + # Short excerpt so list views can show a few words instead of a wall + # of text; content_html still carries the full billet. + "summary": feeds.excerpt(r["body"], max_len=140), "content_html": render_markdown(r["body"]), "date_published": r["published_at"] or r["updated_at"], + "tags": tag_map.get(r["id"], []), } for r in rows] - return feeds.build_jsonfeed(site_title=SITE_TITLE, base_url=base, + feed = feeds.build_jsonfeed(site_title=SITE_TITLE, base_url=base, feed_url=f"{base}/feed.json", items=items) + if tag: + feed["feed_url"] = f"{base}/feed.json?tag={tag}" + feed["title"] = f"{SITE_TITLE} · #{tag}" + return feed @app.get("/stats.json") async def stats_json(request: Request): diff --git a/packages/secubox-billets/api/manage.py b/packages/secubox-billets/api/manage.py index acec13f5..58855e36 100644 --- a/packages/secubox-billets/api/manage.py +++ b/packages/secubox-billets/api/manage.py @@ -17,6 +17,7 @@ from datetime import datetime, timezone from . import db, repo from .seed import seed_billets from .services import security as sec +from .services.tags import emoji_for as tags_emoji_for def _now() -> str: @@ -62,6 +63,41 @@ async def _seed() -> int: await conn.close() +async def _backfill_tags() -> int: + """Extract #hashtags from billets written before tags existed. + + Idempotent: sync_tags replaces a billet's tag rows from its body every time, + so re-running only ever converges. Covers drafts too — publishing one later + must not need a second backfill. + """ + conn = await db.connect(now=_now()) + try: + async with conn.execute("SELECT id, body FROM billet") as cur: + rows = await cur.fetchall() + tagged = 0 + for r in rows: + if await repo.sync_tags(conn, r["id"], r["body"]): + tagged += 1 + # Re-apply the curated map to tags that already exist. sync_tags never + # restyles a stored tag (a published billet must not silently change + # look); doing it here keeps that an explicit, operator-invoked step — + # run this after editing TAG_EMOJI. + restyled = 0 + async with conn.execute("SELECT slug, emoji FROM tag") as cur: + existing = await cur.fetchall() + for t in existing: + want = tags_emoji_for(t["slug"]) + if want != t["emoji"]: + await conn.execute("UPDATE tag SET emoji = ? WHERE slug = ?", (want, t["slug"])) + restyled += 1 + await conn.commit() + print(f"backfilled {len(rows)} billets — {tagged} carry at least one tag; " + f"{restyled} tag(s) restyled from the curated map") + return 0 + finally: + await conn.close() + + def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(prog="billets-manage") sub = p.add_subparsers(dest="cmd", required=True) @@ -69,8 +105,12 @@ def main(argv: list[str] | None = None) -> int: sp = sub.add_parser(name) sp.add_argument("username") sub.add_parser("seed") + sub.add_parser("backfill-tags") args = p.parse_args(argv) + if args.cmd == "backfill-tags": + return asyncio.run(_backfill_tags()) + if args.cmd in ("create-author", "set-password"): pw = getpass.getpass("Password: ") if len(pw) < 10: diff --git a/packages/secubox-billets/api/migrations/0004_tags.sql b/packages/secubox-billets/api/migrations/0004_tags.sql new file mode 100644 index 00000000..930d3601 --- /dev/null +++ b/packages/secubox-billets/api/migrations/0004_tags.sql @@ -0,0 +1,32 @@ +-- SPDX-License-Identifier: LicenseRef-CMSD-1.0 +-- Copyright (c) 2026 CyberMind — Gérald Kerma +-- billets :: emoji hashtags for quick-view categorisation. +-- Authors just type `#secu #waf` in the body — no extra editor step, and it +-- works retroactively on billets written before this migration (backfilled by +-- `python -m api.manage backfill-tags`). Tags are EXTRACTED into real rows +-- rather than re-parsed per request so the feed can filter on an index +-- instead of scanning every body with LIKE. +-- +-- tag.slug : normalised (lowercase, accents folded) — #Réseau and #reseau are +-- the same tag. This is the identity, so it is the PK. +-- tag.emoji : curated in services/tags.py; unknown tags fall back to a default +-- badge emoji. Stored (not derived at read time) so a later map +-- change never silently rewrites what a published billet displayed. +-- +-- billet_tag rows die with their billet (ON DELETE CASCADE); orphan tag rows are +-- harmless (they simply stop appearing in counts) and get reused on the next use. + +CREATE TABLE IF NOT EXISTS tag ( + slug TEXT PRIMARY KEY, + emoji TEXT NOT NULL, + label TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS billet_tag ( + billet_id TEXT NOT NULL REFERENCES billet(id) ON DELETE CASCADE, + tag_slug TEXT NOT NULL REFERENCES tag(slug) ON DELETE CASCADE, + PRIMARY KEY (billet_id, tag_slug) +); + +-- The feed's filter direction is tag -> billets, so index that side. +CREATE INDEX IF NOT EXISTS idx_billet_tag_slug ON billet_tag(tag_slug); diff --git a/packages/secubox-billets/api/repo.py b/packages/secubox-billets/api/repo.py index ab036d69..5b99cf96 100644 --- a/packages/secubox-billets/api/repo.py +++ b/packages/secubox-billets/api/repo.py @@ -14,6 +14,7 @@ import aiosqlite from .ids import new_ulid from .models import BilletIn, slugify +from .services.tags import extract as tags_extract _FEED_COLUMNS = ("id,created_at,updated_at,published_at,body,ref_url,embed_url," "embed_html,embed_provider,embed_fetched_at,slug,status," @@ -49,6 +50,7 @@ async def create_billet(conn: aiosqlite.Connection, data: BilletIn, *, now: str, data.embed_url, slug, status, data.style), ) await conn.commit() + await sync_tags(conn, billet_id, data.body) return billet_id @@ -67,11 +69,21 @@ async def get_by_id(conn: aiosqlite.Connection, billet_id: str) -> Optional[aios async def list_published(conn: aiosqlite.Connection, *, limit: int = 20, - cursor: Optional[str] = None) -> tuple[list[aiosqlite.Row], Optional[str]]: - """Return (rows, next_cursor). `next_cursor` is None on the last page.""" + cursor: Optional[str] = None, + tag: Optional[str] = None) -> tuple[list[aiosqlite.Row], Optional[str]]: + """Return (rows, next_cursor). `next_cursor` is None on the last page. + + `tag` restricts the feed to one emoji-hashtag (the quick view). It uses an + EXISTS against the indexed billet_tag rather than a JOIN, so keyset paging + stays correct — a JOIN could duplicate a row per matching tag. + """ limit = max(1, min(limit, 100)) params: list[Any] = [] where = "status = 'published'" + if tag: + where += (" AND EXISTS (SELECT 1 FROM billet_tag bt WHERE bt.billet_id = billet.id " + "AND bt.tag_slug = ?)") + params.append(tag) if cursor: decoded = decode_cursor(cursor) if decoded: @@ -161,6 +173,7 @@ async def update_billet(conn: aiosqlite.Connection, billet_id: str, *, body: str (body, ref_url, embed_url, style, now, billet_id), ) await conn.commit() + await sync_tags(conn, billet_id, body) # a #tag removed from the body loses its chip async def set_embed_snapshot(conn: aiosqlite.Connection, billet_id: str, @@ -363,3 +376,60 @@ async def stats(conn: aiosqlite.Connection) -> dict: "reactions_total": await _one("SELECT COUNT(*) FROM reaction"), "reactions_by": reactions_by, } + + +# Tags (emoji hashtags) ────────────────────────────────────────────────── +async def sync_tags(conn: aiosqlite.Connection, billet_id: str, body: str) -> list[tuple[str, str]]: + """Re-extract the body's #hashtags and make billet_tag match it exactly. + + Called on every create/update, so removing a #tag from the body removes the + chip. The tag row itself is upserted (never deleted here) — other billets may + still point at it, and an orphan tag is harmless. + """ + pairs = tags_extract(body) + for slug, emoji in pairs: + # Keep the stored emoji stable once a tag exists: a later curation change + # must not silently restyle billets that already published with it. + await conn.execute( + "INSERT INTO tag(slug,emoji,label) VALUES (?,?,?) ON CONFLICT(slug) DO NOTHING", + (slug, emoji, slug), + ) + await conn.execute("DELETE FROM billet_tag WHERE billet_id = ?", (billet_id,)) + if pairs: + await conn.executemany( + "INSERT OR IGNORE INTO billet_tag(billet_id,tag_slug) VALUES (?,?)", + [(billet_id, slug) for slug, _ in pairs], + ) + await conn.commit() + return pairs + + +async def tags_for_many(conn: aiosqlite.Connection, + billet_ids: list[str]) -> dict[str, list[dict]]: + """{billet_id: [{slug, emoji}]} for a whole page in ONE query — the feed + renders chips for every row, so a per-row lookup would be an N+1.""" + if not billet_ids: + return {} + marks = ",".join("?" * len(billet_ids)) + q = (f"SELECT bt.billet_id, t.slug, t.emoji FROM billet_tag bt " + f"JOIN tag t ON t.slug = bt.tag_slug " + f"WHERE bt.billet_id IN ({marks}) ORDER BY t.slug") + out: dict[str, list[dict]] = {} + async with conn.execute(q, billet_ids) as cur: + for row in await cur.fetchall(): + out.setdefault(row["billet_id"], []).append( + {"slug": row["slug"], "emoji": row["emoji"]}) + return out + + +async def list_tags(conn: aiosqlite.Connection) -> list[dict]: + """Every tag that has at least one PUBLISHED billet, with its count — + this is the quick-view chip bar, so drafts must not leak into it.""" + q = ("SELECT t.slug, t.emoji, COUNT(*) AS n FROM billet_tag bt " + "JOIN tag t ON t.slug = bt.tag_slug " + "JOIN billet b ON b.id = bt.billet_id " + "WHERE b.status = 'published' " + "GROUP BY t.slug, t.emoji ORDER BY n DESC, t.slug") + async with conn.execute(q) as cur: + return [{"slug": r["slug"], "emoji": r["emoji"], "count": r["n"]} + for r in await cur.fetchall()] diff --git a/packages/secubox-billets/api/routes/jwt_admin.py b/packages/secubox-billets/api/routes/jwt_admin.py index e59943d5..1782bf12 100644 --- a/packages/secubox-billets/api/routes/jwt_admin.py +++ b/packages/secubox-billets/api/routes/jwt_admin.py @@ -23,7 +23,7 @@ from pydantic import BaseModel, ValidationError from .. import repo from ..ids import new_ulid from ..models import BilletIn -from ..services import media +from ..services import feeds, media try: from secubox_core.auth import require_jwt @@ -43,18 +43,22 @@ class BilletPayload(BaseModel): status: str = "published" # "published" | "draft" -def _view(row) -> dict: +def _view(row, tags: Optional[list] = None) -> dict: d = dict(row) return { "id": d.get("id"), "slug": d.get("slug"), "body": d.get("body"), + # A short excerpt so list views can show a few words instead of the whole + # billet; `body` stays available for the editor. + "summary": feeds.excerpt(d.get("body") or "", max_len=140), "status": d.get("status"), "style": d.get("style"), "ref_url": d.get("ref_url"), "embed_url": d.get("embed_url"), "published_at": d.get("published_at"), "created_at": d.get("created_at"), + "tags": tags or [], } @@ -70,6 +74,29 @@ def register_jwt_admin(app: FastAPI) -> None: except ValidationError as exc: raise HTTPException(422, f"invalid billet: {exc.errors()}") + @app.get("/admin/api/billets") + async def api_list(request: Request, status: Optional[str] = None, + tag: Optional[str] = None, limit: int = 100, + user=Depends(require_jwt)): + """Authoring list — REAL billet ids, and drafts included. + + The public JSON Feed cannot serve this: its item `id` is a permalink URL + (not the billet id, so edit/delete could not address a row) and it omits + drafts entirely, which an authoring client must see. + """ + conn = request.app.state.conn + rows = await repo.list_all(conn, status=status, limit=max(1, min(limit, 200))) + tag_map = await repo.tags_for_many(conn, [r["id"] for r in rows]) # batched, no N+1 + out = [_view(r, tag_map.get(r["id"], [])) for r in rows] + if tag: + out = [b for b in out if any(t["slug"] == tag for t in b["tags"])] + return {"billets": out, "count": len(out)} + + @app.get("/admin/api/tags") + async def api_tags(request: Request, user=Depends(require_jwt)): + """Emoji hashtags in use (published), most-used first — the chip bar.""" + return {"tags": await repo.list_tags(request.app.state.conn)} + @app.post("/admin/api/billets") async def api_create(request: Request, payload: BilletPayload, user=Depends(require_jwt)): data = _billet_in(payload) diff --git a/packages/secubox-billets/api/services/feeds.py b/packages/secubox-billets/api/services/feeds.py index 05d8ffb5..a1d1fd4c 100644 --- a/packages/secubox-billets/api/services/feeds.py +++ b/packages/secubox-billets/api/services/feeds.py @@ -54,15 +54,32 @@ def build_atom(*, site_title: str, base_url: str, self_url: str, def build_jsonfeed(*, site_title: str, base_url: str, feed_url: str, items: list[dict]) -> dict: - """items: {id, url, title, content_html, date_published}.""" + """items: {id, url, title, content_html, date_published}, plus optional + `summary` (short excerpt) and `tags` [{slug, emoji}]. + + `summary` and `tags` are JSON Feed 1.1 standard fields, so generic readers + understand them; the emoji has no standard home, so it rides in the + `_secubox` extension (the spec reserves `_`-prefixed keys for exactly this). + Optional keys are OMITTED rather than emitted as null — the spec asks for + absence, and a null `summary` would render as the string "null" in naive + readers. + """ + out_items = [] + for it in items: + entry = {"id": it["id"], "url": it["url"], "title": it["title"], + "content_html": it["content_html"], + "date_published": it["date_published"]} + if it.get("summary"): + entry["summary"] = it["summary"] + tags = it.get("tags") or [] + if tags: + entry["tags"] = [t["slug"] for t in tags] # spec: array of strings + entry["_secubox"] = {"tags": tags} # spec-legal extension: slug + emoji + out_items.append(entry) return { "version": "https://jsonfeed.org/version/1.1", "title": site_title, "home_page_url": f"{base_url}/", "feed_url": feed_url, - "items": [ - {"id": it["id"], "url": it["url"], "title": it["title"], - "content_html": it["content_html"], "date_published": it["date_published"]} - for it in items - ], + "items": out_items, } diff --git a/packages/secubox-billets/api/services/tags.py b/packages/secubox-billets/api/services/tags.py new file mode 100644 index 00000000..4cfdd36a --- /dev/null +++ b/packages/secubox-billets/api/services/tags.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +""" +SecuBox-Deb :: billets — emoji hashtag extraction + +Authors write `#secu #waf` inline in the billet body; we lift those into real tag +rows and render them as emoji chips for quick-view filtering. Parsing the body +(rather than a separate tag picker) means tagging costs the author nothing and +applies retroactively to billets written before tags existed. +""" +from __future__ import annotations + +import re +import unicodedata + +# Curated tag -> emoji. Keys are already-normalised slugs. Unknown tags are NOT +# rejected — they get DEFAULT_EMOJI — so authors are never blocked by the map; +# entries here just make the common categories legible at a glance. +TAG_EMOJI: dict[str, str] = { + # security + "secu": "🛡️", "security": "🛡️", "waf": "🧱", "firewall": "🧱", + "alerte": "🚨", "alert": "🚨", "incident": "🚨", "cve": "☢️", + "ids": "👁️", "crowdsec": "🦅", "ban": "⛔", "attaque": "⚔️", + # network + "reseau": "🌐", "network": "🌐", "dns": "📡", "vpn": "🔐", + "wireguard": "🔐", "tor": "🧅", "mesh": "🕸️", "p2p": "🕸️", + "dpi": "🔍", "proxy": "🔀", + # box / ops + "box": "📦", "secubox": "📦", "debian": "🐧", "kernel": "🐧", + "hardware": "🔌", "led": "💡", "boot": "🥾", "backup": "💾", + "update": "⬆️", "deploy": "🚀", "build": "🏗️", + # dev + "bug": "🐛", "fix": "🔧", "feature": "✨", "refactor": "♻️", + "test": "🧪", "doc": "📚", "docs": "📚", "api": "🔌", + # editorial + "news": "📰", "note": "📝", "billet": "📝", "communique": "📢", + "annonce": "📢", "idee": "💡", "question": "❓", "wip": "🚧", + # misc + "perf": "⚡", "crypto": "🔑", "zkp": "🧮", "ia": "🤖", "ai": "🤖", + "mail": "📧", "media": "🎬", "photo": "📸", "audio": "🎧", + # media / broadcast — the tags this box's billets actually carry + "podcast": "🎙️", "rtbf": "📻", "lapremierertbf": "📻", "radio": "📻", + "tv": "📺", "video": "📹", "interview": "🎤", "musique": "🎵", + # editorial topics seen in the archive. Descriptive of the SUBJECT only — + # a tag emoji labels what a billet is about, it does not endorse it. + "complot": "🕵️", "ovnis": "🛸", "chemtrails": "✈️", "matrix": "🕶️", + "simulation": "🎮", "atlantis": "🌊", "bermuda": "🌀", "titanic": "🚢", + "climate": "🌡️", "vaccine": "💉", "aids": "🧬", "sida": "🧬", + "windsor": "👑", "marilynmonroe": "💋", "jfk": "🎯", "attentat": "💥", + "worldtradecenter": "🏙️", "spirituality": "✨", "histoire": "📜", + "science": "🔬", "sante": "🏥", "politique": "🏛️", +} + +DEFAULT_EMOJI = "🏷️" + +# `\w` is unicode-aware in Python 3, so #réseau matches; we fold the accents when +# normalising so #réseau and #reseau collapse to one tag. Bounded length keeps a +# wall of text from becoming a wall of chips. +_HASHTAG_RE = re.compile(r"(? str: + """`#Réseau` -> `reseau`. Accent-folded + lowercased so visually-equal tags + are actually equal. Returns '' if nothing usable survives.""" + s = unicodedata.normalize("NFKD", raw) + s = "".join(c for c in s if not unicodedata.combining(c)) + s = s.lower().strip("_-") + return s if re.fullmatch(r"[0-9a-z_-]{2,32}", s) else "" + + +def emoji_for(slug: str) -> str: + return TAG_EMOJI.get(slug, DEFAULT_EMOJI) + + +def extract(body: str) -> list[tuple[str, str]]: + """Body -> ordered, de-duplicated [(slug, emoji)], capped at MAX_TAGS. + + Order follows first appearance so the chip row reads like the author wrote it. + A tag that normalises to nothing (e.g. `#__`) is dropped rather than stored. + """ + seen: dict[str, str] = {} + for m in _HASHTAG_RE.finditer(body or ""): + slug = normalise(m.group(1)) + if not slug or slug in seen: + continue + seen[slug] = emoji_for(slug) + if len(seen) >= MAX_TAGS: + break + return list(seen.items()) diff --git a/packages/secubox-billets/api/static/billets.css b/packages/secubox-billets/api/static/billets.css index 83a220b7..aad4e8ab 100644 --- a/packages/secubox-billets/api/static/billets.css +++ b/packages/secubox-billets/api/static/billets.css @@ -166,3 +166,28 @@ body.lb-lock{overflow:hidden} top:-8px;left:14px;background:#0d1117;padding:0 6px;font-size:9px; letter-spacing:.2em;color:#00d4ff;font-weight:700} .embed-vignette img{width:100%;height:auto;display:block;border-radius:4px} + +/* ── emoji hashtags ───────────────────────────────────────────────────── + Tags come from the #hashtags authors type in the body. The bar is the + quick view: each chip is a real link (/?tag=slug), so a filtered view is + shareable and works with JS off. */ +.tagbar{display:flex;flex-wrap:wrap;gap:.4rem;margin:0 0 1.2rem} +.tag-chip{display:inline-flex;align-items:center;gap:.3rem;padding:.3rem .7rem; + border-radius:999px;font-size:.82rem;font-weight:600;text-decoration:none; + color:var(--fg);background:var(--card);border:1px solid var(--border); + transition:transform .12s ease,border-color .12s ease} +.tag-chip:hover{transform:translateY(-1px);border-color:var(--link)} +/* The active chip is the one state that must survive a squint — give it the + site's gradient rather than another subtle border tint. */ +.tag-chip.on{background:var(--grad);color:#fff;border-color:transparent} +.tag-chip.sm{font-size:.75rem;padding:.2rem .55rem} +.tag-n{opacity:.6;font-variant-numeric:tabular-nums} +.tag-chip.on .tag-n{opacity:.85} +.tag-row{display:flex;flex-wrap:wrap;gap:.35rem;margin:.6rem 0 .2rem} +.tag-active{font-size:.9rem;color:var(--muted);margin:0 0 1rem} +@media (prefers-reduced-motion:reduce){.tag-chip{transition:none}.tag-chip:hover{transform:none}} +/* Feed resumes: long billets show a few words + a link to the full permalink. */ +.resume{font-size:1rem;line-height:1.55;color:var(--fg);margin:.2rem 0 .5rem} +.read-more{display:inline-block;font-size:.85rem;font-weight:600;text-decoration:none; + color:var(--link)} +.read-more:hover{text-decoration:underline} diff --git a/packages/secubox-billets/api/templates/feed.html b/packages/secubox-billets/api/templates/feed.html index bd458252..d9014570 100644 --- a/packages/secubox-billets/api/templates/feed.html +++ b/packages/secubox-billets/api/templates/feed.html @@ -1,9 +1,37 @@ {% extends "base.html" %} {% block content %} +{# Quick-view chip bar: the emoji hashtags authors typed in their billets. + Plain links (not JS) so it works without scripts and each view is a real, + shareable URL. #} +{% if all_tags %} + +{% endif %} +{% if active_tag %} +

Vue rapide : #{{ active_tag }}tout afficher

+{% endif %}
{% for b in billets %}
+ {# The feed is a scan surface: show a short resume for long billets and send + the reader to the permalink for the rest. Short billets render whole — + clipping three words off a one-liner would be noise, not brevity. #} + {% if b.is_long %} +

{{ b.summary }}

+ Lire la suite → + {% else %}
{{ b.body_html|safe }}
+ {% endif %} + {% if b.tags %} +
+ {% for t in b.tags %}{{ t.emoji }} #{{ t.slug }}{% endfor %} +
+ {% endif %} {% if b.media %} {% if next_cursor %} - +{# Carry the active tag across pages, else page 2 silently drops the filter. #} + {% endif %} {% endblock %} From aef06b8b2227e87ef040ca6e87a7f4aa3562af68 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 17 Jul 2026 10:35:37 +0200 Subject: [PATCH 3/4] feat(companion): real auth/system modules, emoji metrics, 401 re-login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth and system modules were 34-line stubs guessing routes — auth called /api/v1/auth/users, which 404s; identities actually live under /api/v1/users. Both are rebuilt against routes read from the handler source, not guessed. - core/metrics.js: emoji/severity gauges + human formatters. Thresholds are PER KIND because a disk at 94% and a cpu at 94% carry different risk and must not look alike — gk2's disk (94.5%) reads 🔴 while its cpu (10%) reads 🟢. Plain language over field names ("Memory 75% — 5.5 GB of 7.7 GB used"). - modules/auth: /api/v1/users/{users,sessions,roles,groups}; sessions rendered prominently (who / IP / device / age / current), which is the point of the session work landing alongside it. - modules/system: /status, /metrics, /resources, /health_score, /network, /security — each section degrades independently so one failing route cannot blank the module. - billets: tag chip bar + per-billet emoji chips, quick-view filtering, and short resumes instead of full bodies; list now uses the authoring endpoint, so Edit/Delete address real ids and drafts are visible. - 401 re-login: box tokens live 24h, and the Companion seals one at pairing and reuses it — so a day later every authed call 401'd with no way out but unpairing ("Failed: unauthorized" in billets and podcaster). api.js now asks the app to re-authenticate and REPLAYS the request once; single-flight, so a screenful of concurrent 401s yields one prompt, and an in-flight write (a billet just typed) survives instead of being lost. - sw.js v9→v10, metrics.js added to the offline shell. Co-Authored-By: Gerald KERMA --- secubox-companion/www/core/api.js | 40 ++++- secubox-companion/www/core/app.js | 14 ++ secubox-companion/www/core/metrics.js | 100 +++++++++++ .../www/modules/auth/module.json | 4 +- secubox-companion/www/modules/auth/view.js | 150 +++++++++++++--- secubox-companion/www/modules/billets/view.js | 69 ++++++-- secubox-companion/www/modules/system/view.js | 160 +++++++++++++++--- secubox-companion/www/styles/charte.css | 9 + secubox-companion/www/sw.js | 4 +- 9 files changed, 484 insertions(+), 66 deletions(-) create mode 100644 secubox-companion/www/core/metrics.js diff --git a/secubox-companion/www/core/api.js b/secubox-companion/www/core/api.js index 790cd1d0..17c0bbe8 100644 --- a/secubox-companion/www/core/api.js +++ b/secubox-companion/www/core/api.js @@ -12,6 +12,25 @@ let BASE = ''; // e.g. https://box.example.in let TOKEN = ''; const listeners = new Set(); +// Box tokens expire (24h). The Companion seals one at pairing and reuses it, so +// without this the app dead-ends on "unauthorized" a day later with no way back +// except unpairing. On a 401 we ask the app to re-authenticate, then retry the +// request once — single-flight, so a screenful of concurrent 401s produces ONE +// login prompt, not one per request. +let _onUnauthorized = null; +let _reauthInFlight = null; + +async function reauthenticate() { + if (!_onUnauthorized) return false; + if (!_reauthInFlight) { + _reauthInFlight = Promise.resolve() + .then(() => _onUnauthorized()) + .catch(() => false) + .finally(() => { _reauthInFlight = null; }); + } + return await _reauthInFlight; +} + function emit() { for (const fn of listeners) try { fn(state()); } catch {} } function state() { return { online: navigator.onLine, base: BASE }; } @@ -24,6 +43,10 @@ export const api = { isReady() { return !!BASE && !!TOKEN; }, onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }, + /** Register the app's re-login flow. It must return truthy once fresh + * credentials have been passed to api.init(), falsy if the user cancelled. */ + onUnauthorized(fn) { _onUnauthorized = fn; }, + headers(extra = {}) { const h = { 'Accept': 'application/json', ...extra }; if (TOKEN) h['Authorization'] = 'Bearer ' + TOKEN; @@ -31,11 +54,15 @@ export const api = { }, /** GET with cache fallback. `path` is absolute on the box (e.g. /api/v1/billets/feed). */ - async get(path, { cache = true } = {}) { + async get(path, { cache = true, _retried = false } = {}) { const url = BASE + path; try { const r = await fetch(url, { headers: this.headers(), credentials: 'include' }); - if (r.status === 401) throw new ApiError('unauthorized', 401); + if (r.status === 401) { + // Expired/rotated token → re-login once, then replay this read. + if (!_retried && await reauthenticate()) return this.get(path, { cache, _retried: true }); + throw new ApiError('unauthorized', 401); + } if (!r.ok) throw new ApiError(await safeText(r), r.status); const data = await r.json().catch(() => ({})); if (cache) store.cachePut('GET ' + path, data).catch(() => {}); @@ -56,13 +83,18 @@ export const api = { /** Send a write, or queue it if offline / the network drops. * Returns { queued:true, id } when deferred, otherwise the parsed response. */ async _write(method, path, body, { form = false } = {}) { - const send = async () => { + const send = async (retried = false) => { const headers = form ? this.headers() : this.headers({ 'Content-Type': 'application/json' }); const r = await fetch(BASE + path, { method, headers, credentials: 'include', body: body == null ? undefined : (form ? body : JSON.stringify(body)), }); - if (r.status === 401) throw new ApiError('unauthorized', 401); + if (r.status === 401) { + // Re-login and replay ONCE — losing a billet the author just wrote to an + // expired token would be the worst possible outcome here. + if (!retried && await reauthenticate()) return send(true); + throw new ApiError('unauthorized', 401); + } if (!r.ok) throw new ApiError(await safeText(r), r.status); return r.json().catch(() => ({})); }; diff --git a/secubox-companion/www/core/app.js b/secubox-companion/www/core/app.js index a99ee207..8f8b38dc 100644 --- a/secubox-companion/www/core/app.js +++ b/secubox-companion/www/core/app.js @@ -121,6 +121,20 @@ function route() { // ── boot ────────────────────────────────────────────────────────── async function boot(forceLock = false) { + // Box tokens live 24h; the sealed one goes stale and every authed call then + // 401s. Re-login in place (the URL is prefilled) and re-render, so an expired + // session is a prompt rather than a dead end — api.js replays the request that + // hit the 401, so an in-progress write is not lost. + api.onUnauthorized(async () => { + const creds = await pairingScreen(app); + if (!creds || !creds.token) return false; + api.init(creds); + chrome(); + await registry.discover(); + route(); + return true; + }); + if (!store.isPaired()) { const creds = await pairingScreen(app); api.init(creds); diff --git a/secubox-companion/www/core/metrics.js b/secubox-companion/www/core/metrics.js new file mode 100644 index 00000000..4af8f72a --- /dev/null +++ b/secubox-companion/www/core/metrics.js @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: LicenseRef-CMSD-1.0 +// SecuBox Companion :: core/metrics — emoji gauges + human formatters. +// +// Companion is glanced at on a phone, often in passing — raw psutil numbers +// ("disk_percent: 94.5") don't read as urgent fast enough. This module turns +// any percent-ish value into a severity-coded gauge (emoji + colour + bar) +// using PER-KIND thresholds, because a disk at 94% and a cpu at 94% do not +// carry the same risk and must not look the same at a glance. + +import { el } from './ui.js'; + +// [warnAt, critAt] as a 0-100 value, per metric kind. Disk fills up silently +// and is the most dangerous to under-alarm on, so it's the strictest; cpu +// bursts constantly and is tolerated much higher before it means anything. +const THRESHOLDS = { + cpu: [70, 90], + mem: [75, 90], + disk: [80, 92], // 94.5% on this box → correctly reads as crit + temp: [70, 85], // °C, reuses the same [warn,crit] shape as a percent scale + load: [70, 90], // caller normalises load average to % of core count first + default: [70, 90], +}; + +const LEVELS = { + ok: { emoji: '🟢', badge: 'ok', word: 'nominal' }, + warn: { emoji: '🟡', badge: 'warn', word: 'elevated' }, + crit: { emoji: '🔴', badge: 'err', word: 'critical' }, +}; + +/** Classify a 0-100 value for `kind` into 'ok' | 'warn' | 'crit'. */ +export function severity(kind, value) { + if (value == null || Number.isNaN(value)) return 'ok'; + const [warn, crit] = THRESHOLDS[kind] || THRESHOLDS.default; + if (value >= crit) return 'crit'; + if (value >= warn) return 'warn'; + return 'ok'; +} + +export function emojiFor(kind, value) { return LEVELS[severity(kind, value)].emoji; } +export function wordFor(kind, value) { return LEVELS[severity(kind, value)].word; } + +/** bytes → "5.5 GB" — binary units, plain language over raw byte counts. */ +export function fmtBytes(n) { + if (n == null || Number.isNaN(n)) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let v = Number(n), i = 0; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(i === 0 || v >= 10 ? 0 : 1)} ${units[i]}`; +} + +/** seconds → "7d 3h" — coarsest two units only; nobody needs the minutes. */ +export function fmtUptime(sec) { + if (sec == null || Number.isNaN(sec)) return '—'; + sec = Math.floor(sec); + const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60); + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +export function fmtPercent(n, digits = 0) { + if (n == null || Number.isNaN(n)) return '—'; + return `${Number(n).toFixed(digits)}%`; +} + +/** + * "Memory 75% — 5.5 GB of 7.7 GB used" style plain-language sentence. + * Prefer this over showing a bare field name + number. + */ +export function usageSentence(label, percent, used, total) { + const pct = fmtPercent(percent); + if (used == null || total == null) return `${label} ${pct}`; + return `${label} ${pct} — ${fmtBytes(used)} of ${fmtBytes(total)} used`; +} + +/** + * A gauge card: emoji + plain label + big value + proportional bar. + * `percent` (0-100) drives both the bar width and the severity colour/emoji; + * pass `value`/`unit` when the displayed text isn't the percent itself + * (e.g. a °C reading, still classified on the same 0-100 severity scale). + */ +export function gauge({ kind = 'default', label, percent, value, unit = '%', sub }) { + const lvl = severity(kind, percent); + const { emoji, badge } = LEVELS[lvl]; + const shown = value != null ? `${value}${unit}` : fmtPercent(percent); + const pct = Math.max(0, Math.min(100, percent ?? 0)); + return el('div.card', { style: 'text-align:center' }, [ + el('div', { style: 'font-size:1.3rem;line-height:1', text: emoji }), + el('div.data', { style: 'font-size:1.35rem;font-weight:700;margin:3px 0', text: shown }), + el('div.kicker', { text: label }), + el('div.gauge-bar', {}, [el(`div.gauge-fill.${badge}`, { style: `width:${pct}%` })]), + sub ? el('div.muted', { style: 'font-size:.7rem;margin-top:4px', text: sub }) : null, + ]); +} + +/** Small severity badge for non-gauge contexts (list rows, headers). */ +export function severityBadge(kind, value, text) { + const { badge, emoji } = LEVELS[severity(kind, value)]; + return el(`span.badge.${badge}`, { text: `${emoji} ${text}` }); +} diff --git a/secubox-companion/www/modules/auth/module.json b/secubox-companion/www/modules/auth/module.json index 767d0be1..b6a5dcdc 100644 --- a/secubox-companion/www/modules/auth/module.json +++ b/secubox-companion/www/modules/auth/module.json @@ -4,8 +4,8 @@ "module": "AUTH", "icon": "🔑", "description": "Identities, sessions, access.", - "api_base": "/api/v1/auth", - "status_endpoint": "/api/v1/auth/status", + "api_base": "/api/v1/users", + "status_endpoint": "/api/v1/users/status", "capabilities": ["read"], "routes": [{ "path": "", "label": "Identities" }] } diff --git a/secubox-companion/www/modules/auth/view.js b/secubox-companion/www/modules/auth/view.js index eea9146a..488424be 100644 --- a/secubox-companion/www/modules/auth/view.js +++ b/secubox-companion/www/modules/auth/view.js @@ -1,34 +1,146 @@ // SPDX-License-Identifier: LicenseRef-CMSD-1.0 // SecuBox Companion :: auth — identities & sessions (read). +// +// The stub this replaces called `${base}/users` against api_base +// `/api/v1/auth`, which 404s — auth identity data actually lives under +// `/api/v1/users` (see module.json, updated alongside this file). Routes +// below are verified live against packages/secubox-users/api/main.py: +// /users (JWT) → { users:[{username,email,role,enabled,totp}], total } +// /sessions (JWT) → { sessions:[{id,username,email,ip,user_agent,type, +// created_at,expires_at,last_active,current}], total } +// /roles → { roles:[{id,name,description,permissions,builtin,color}], total } +// /groups (JWT) → { groups:[{name,description,permissions,members,created}] } +// Each section is fetched independently so one failing route degrades to a +// small inline notice instead of blanking the whole module. +import { gauge } from '../../core/metrics.js'; + +// The 'master' concept isn't a stored field — the source (api/main.py, +// engine.py) consistently treats the single literal username "admin" as +// the master account (e.g. the only one synced to YaCy). Mirror that here. +const isMaster = (u) => u.username === 'admin'; + +// `created_at`/`expires_at` come from a JWT session file: sometimes a raw +// epoch (seconds OR the rarer ms), sometimes an ISO string, sometimes ''. +// ui.ago() assumes a bare number is already milliseconds, so seconds must +// be upscaled first or old sessions render as "1970". +function toMs(ts) { + if (ts === '' || ts == null) return null; + if (typeof ts === 'number') return ts < 1e12 ? ts * 1000 : ts; + const p = Date.parse(ts); + return Number.isNaN(p) ? null : p; +} +function until(ts) { + const ms = toMs(ts); + if (ms == null) return '—'; + const s = Math.floor((ms - Date.now()) / 1000); + if (s <= 0) return 'expired'; + if (s < 3600) return `in ${Math.floor(s / 60)}m`; + if (s < 86400) return `in ${Math.floor(s / 3600)}h`; + return `in ${Math.floor(s / 86400)}d`; +} + +// IP may be '' (pre-fix rows) or the literal string "unknown" (the API's +// own fallback when the key is absent entirely) — both read as "no data", +// so both collapse to the same neutral placeholder rather than leaking +// either raw form to the UI. +const ipOr = (ip) => (ip && ip !== 'unknown') ? ip : '—'; + export default async function mount(ctx) { const { root, api, base, ui } = ctx; const { el, esc, ago, clear } = ui; const host = clear(root); host.append(el('p.muted', { text: 'Loading identities…' })); + clear(host); + + host.append(el('div.panel-head', {}, [ + el('div', { style: 'font-size:1.6rem', text: '🔑' }), + el('h3', { style: 'margin:0', text: 'Auth' }), + ])); + + // ── sessions (prominent — who / IP / device / age / current) ──── try { - const users = await api.get(`${base}/users`).catch(() => ({ users: [] })); // TODO(api): confirm users route - const list = users.users || users.items || []; - clear(host); - const card = el('div.card', {}, [el('h3', { text: `Users (${list.length})` })]); - if (!list.length) card.append(el('div.empty', { text: 'No users listed (auth users route may be admin-gated — TODO(api)).' })); - for (const u of list) card.append(el('div.item', {}, [ + const d = await api.get(`${base}/sessions`); + const items = d.sessions || []; + const card = el('div.card', {}, [el('h3', { text: `🟢 Active sessions (${d.total ?? items.length})` })]); + if (!items.length) card.append(el('div.empty', { text: 'No active sessions.' })); + for (const s of items) { + const device = s.user_agent || '—'; + card.append(el('div.item', {}, [ + el('div.meta', {}, [ + el('b', {}, [esc(s.username || '?'), s.current ? el('span.badge.ok', { style: 'margin-left:6px', text: '📍 this device' }) : null]), + el('span', { text: `${ipOr(s.ip)} · ${esc(device)} · signed in ${ago(toMs(s.created_at) ?? undefined) || '—'}` }), + ]), + el('span.badge' + (until(s.expires_at) === 'expired' ? '.err' : ''), { text: '⏳ ' + until(s.expires_at) }), + ])); + } + host.append(card); + } catch (e) { + host.append(el('div.card', {}, [el('div.muted', { text: 'Sessions unavailable: ' + esc(e.message) })])); + } + + // ── identities ──────────────────────────────────────────────── + try { + const d = await api.get(`${base}/users`); + const list = d.users || []; + const enabled = list.filter(u => u.enabled !== false).length; + const disabled = list.length - enabled; + const card = el('div.card', {}, [el('h3', { text: `👤 Identities (${d.total ?? list.length})` })]); + if (list.length) { + // Gauge on the *disabled* share, not enabled — severity() reads "higher + // = worse", and a pile of locked-out accounts is the anomaly worth + // flagging, not the (normal) state of everyone being enabled. + card.append(gauge({ kind: 'default', label: 'Accounts disabled', percent: (disabled / list.length) * 100, value: `${enabled}/${list.length} enabled`, unit: '' })); + } else { + card.append(el('div.empty', { text: 'No users listed.' })); + } + for (const u of list) { + const twofa = u.totp && u.totp.enabled; + card.append(el('div.item', {}, [ + el('div.meta', {}, [ + el('b', {}, [esc(u.username), isMaster(u) ? el('span.badge.ok', { style: 'margin-left:6px', text: '★ master' }) : null]), + el('span', { text: [u.role || (u.roles || []).join(',') || 'viewer', esc(u.email || ''), twofa ? '🔐 2FA' : null].filter(Boolean).join(' · ') }), + ]), + el('span.badge' + (u.enabled === false ? '.err' : '.ok'), { text: u.enabled === false ? '🔴 disabled' : '🟢 enabled' }), + ])); + } + host.append(card); + } catch (e) { + host.append(el('div.card', {}, [el('div.muted', { text: 'Identities unavailable: ' + esc(e.message) })])); + } + + // ── roles ───────────────────────────────────────────────────── + try { + const d = await api.get(`${base}/roles`); + const roles = d.roles || []; + const card = el('div.card', {}, [el('h3', { text: `🎭 Roles (${d.total ?? roles.length})` })]); + if (!roles.length) card.append(el('div.empty', { text: 'No roles defined.' })); + for (const r of roles) card.append(el('div.item', {}, [ el('div.meta', {}, [ - el('b', { text: esc(u.username || u.name || u.id) }), - el('span', { text: [u.role, u.admin && 'admin', u.totp && '2FA'].filter(Boolean).join(' · ') }), + el('b', { text: esc(r.name || r.id) }), + el('span', { text: `${esc(r.description || '')} · ${(r.permissions || []).length} permissions` }), ]), - u.master ? el('span.badge.ok', { text: 'master' }) : null, + r.builtin ? el('span.badge', { text: 'builtin' }) : null, ])); host.append(card); + } catch (e) { + host.append(el('div.card', {}, [el('div.muted', { text: 'Roles unavailable: ' + esc(e.message) })])); + } - const sess = await api.get(`${base}/sessions`).catch(() => null); // TODO(api): confirm sessions route - if (sess) { - const items = sess.sessions || sess.items || []; - const sc = el('div.card', {}, [el('h3', { text: `Active sessions (${items.length})` })]); - for (const s of items.slice(0, 30)) sc.append(el('div.item', {}, [ - el('div.meta', {}, [el('b', { text: esc(s.user || s.username || '?') }), el('span', { text: `${esc(s.ip || '')} · ${ago(s.created_at || s.ts)}` })]), - ])); - host.append(sc); - } - } catch (e) { clear(host).append(el('div.empty', { text: 'Failed: ' + esc(e.message) })); } + // ── groups ──────────────────────────────────────────────────── + try { + const d = await api.get(`${base}/groups`); + const groups = d.groups || []; + const card = el('div.card', {}, [el('h3', { text: `👥 Groups (${groups.length})` })]); + if (!groups.length) card.append(el('div.empty', { text: 'No groups defined.' })); + for (const g of groups) card.append(el('div.item', {}, [ + el('div.meta', {}, [ + el('b', { text: esc(g.name) }), + el('span', { text: `${esc(g.description || '')} · ${(g.members || []).length} members` }), + ]), + ])); + host.append(card); + } catch (e) { + host.append(el('div.card', {}, [el('div.muted', { text: 'Groups unavailable: ' + esc(e.message) })])); + } } diff --git a/secubox-companion/www/modules/billets/view.js b/secubox-companion/www/modules/billets/view.js index 06b34515..48f51096 100644 --- a/secubox-companion/www/modules/billets/view.js +++ b/secubox-companion/www/modules/billets/view.js @@ -1,14 +1,16 @@ // SPDX-License-Identifier: LicenseRef-CMSD-1.0 // SecuBox Companion :: billets — write/publish billets + moderate comments. // -// Endpoints below are the clean interface the Companion consumes. Where the -// exact box route/shape is unconfirmed it is marked TODO(api); the box may need -// a JWT-authed admin API for billets (its web admin is session-based). Reads use -// the public feed and work today; writes go through these paths + auto-queue. -// All routes below are served by billets' JWT/SSO admin surface (routes/jwt_admin.py), -// which trusts the SecuBox session — reads use the public JSON Feed. +// Every route below is served by billets' JWT/SSO admin surface +// (routes/jwt_admin.py), which trusts the SecuBox session. +// +// The list deliberately uses the ADMIN route, not the public JSON Feed: the +// feed's item `id` is a permalink URL rather than the billet id (so edit/delete +// could not address a row) and the feed omits drafts, which an authoring client +// must see. const EP = { - feed: (b) => `${b}/feed.json`, // public JSON Feed (jsonfeed.org) + list: (b) => `${b}/admin/api/billets`, // GET {billets:[{id,summary,status,tags[]}]} + tags: (b) => `${b}/admin/api/tags`, // GET {tags:[{slug,emoji,count}]} create: (b) => `${b}/admin/api/billets`, // POST {body,ref_url,embed_url,style,status} update: (b, id) => `${b}/admin/api/billets/${id}`, // PUT remove: (b, id) => `${b}/admin/api/billets/${id}`, // DELETE @@ -43,24 +45,59 @@ export default async function mount(ctx) { } // ── list ──────────────────────────────────────────────────────── + let tagFilter = ''; // '' = all; otherwise a tag slug (the quick view) + async function list() { const host = clear(body); host.append(el('p.muted', { text: 'Loading…' })); try { - const d = await api.get(EP.feed(base)); - const items = d.billets || d.items || d.feed || (Array.isArray(d) ? d : []); + const d = await api.get(EP.list(base)); + const items = d.billets || []; clear(host); if (d.__cached) host.append(el('div.badge.warn', { text: 'cached (offline)' })); - if (!items.length) return host.append(el('div.empty', { text: 'No billets yet.' })); - for (const b of items) { - const title = (b.title || b.body || '').replace(/\s+/g, ' ').slice(0, 70) || '(untitled)'; + + // Quick-view chip bar. Tags come from the #hashtags authors type in the + // body; failing to load them must not hide the billets themselves. + const td = await api.get(EP.tags(base)).catch(() => null); + const tags = (td && td.tags) || []; + if (tags.length) { + const bar = el('div.row', { style: 'gap:6px;margin-bottom:12px;flex-wrap:wrap' }); + const chip = (label, slug) => el(`button.btn.sm${tagFilter === slug ? '.primary' : ''}`, { + text: label, + onclick: () => { tagFilter = tagFilter === slug ? '' : slug; list(); }, + }); + bar.append(chip('🌀 All', '')); + for (const t of tags) bar.append(chip(`${t.emoji} #${t.slug} ${t.count}`, t.slug)); + host.append(bar); + } + + const shown = tagFilter + ? items.filter(b => (b.tags || []).some(t => t.slug === tagFilter)) + : items; + if (!shown.length) { + return host.append(el('div.empty', { + text: tagFilter ? `No billets tagged #${tagFilter}.` : 'No billets yet.', + })); + } + + for (const b of shown) { + // Show a few words, not the whole billet — `summary` is the box-side + // excerpt; fall back to a clipped body if it is ever absent. + const excerpt = b.summary || (b.body || '').replace(/\s+/g, ' ').slice(0, 140) || '(empty)'; + const chips = el('span', { style: 'margin-left:6px' }); + for (const t of (b.tags || [])) + chips.append(el('span.badge', { text: `${t.emoji} ${t.slug}`, style: 'margin-right:4px' })); + const draft = b.status === 'draft'; host.append(el('div.item', {}, [ el('div.meta', {}, [ - el('b', { text: title }), - el('span', { text: `${b.status || 'published'} · ${ago(b.date_published || b.published_at || b.created_at)} · ${b.style || 'default'}` }), + el('b', { text: excerpt }), + el('span', {}, [ + el('span', { text: `${draft ? '📝 draft' : '✅ published'} · ${ago(b.published_at || b.created_at)}${b.style === 'communique' ? ' · 📢 communiqué' : ''}` }), + chips, + ]), ]), el('div.actions', {}, [ - el('button.btn.sm', { text: 'Edit', onclick: () => { active = 'edit:' + (b.id || b.slug); renderTabs(); editor(b.id || b.slug, b); } }), + el('button.btn.sm', { text: 'Edit', onclick: () => { active = 'edit:' + b.id; renderTabs(); editor(b.id, b); } }), el('button.btn.sm.danger', { text: 'Del', onclick: () => remove(b) }), ]), ])); @@ -71,7 +108,7 @@ export default async function mount(ctx) { async function remove(b) { if (!confirmAction('Delete this billet?')) return; try { - const r = await api.del(EP.remove(base, b.id || b.slug)); + const r = await api.del(EP.remove(base, b.id)); toast(r.queued ? 'Delete queued (offline)' : 'Deleted'); list(); } catch (e) { toast('Delete failed: ' + e.message, 'err'); } diff --git a/secubox-companion/www/modules/system/view.js b/secubox-companion/www/modules/system/view.js index be48d75c..a02a245d 100644 --- a/secubox-companion/www/modules/system/view.js +++ b/secubox-companion/www/modules/system/view.js @@ -1,34 +1,148 @@ // SPDX-License-Identifier: LicenseRef-CMSD-1.0 // SecuBox Companion :: system — health, resources, services (read). +// +// Routes below are verified live against packages/secubox-system/api/main.py: +// /status (JWT) → { model, board, hostname, uptime_sec, cpu_count, +// mem_total_mb, mem_free_mb, services_sample, health, cached_at } +// /metrics → { cpu_percent, mem_percent, disk_percent, load_avg_1, +// cpu_temp, uptime_seconds, hostname, memory_used, memory_total } +// /resources → { cpu_percent, memory_percent, memory_used, memory_total, +// disk_percent, disk_used, disk_total, load_avg:[1,5,15] } +// /health_score (JWT) → { score, max, issues:[name…], services:[{name,active,status,enabled}] } +// /network → { interfaces:[{name,addresses,up,speed}] } +// /security → { firewall, ssh_status, apparmor, crowdsec } (plain status words) +// Each section is fetched independently so one failing route degrades to a +// small inline notice instead of blanking the whole module. +import { gauge, fmtUptime, fmtBytes } from '../../core/metrics.js'; + export default async function mount(ctx) { const { root, api, base, ui } = ctx; const { el, esc, clear } = ui; const host = clear(root); host.append(el('p.muted', { text: 'Loading system health…' })); - try { - const s = await api.get(`${base}/status`); - clear(host); - const g = el('div.grid'); - const gauge = (label, val, unit = '') => g.append(el('div.card', { style: 'text-align:center' }, [ - el('div.data', { style: 'font-size:1.5rem;font-weight:700', text: (val ?? '—') + unit }), - el('div.kicker', { text: label }), - ])); - gauge('CPU', s.cpu ?? s.cpu_percent, '%'); - gauge('Memory', s.mem ?? s.memory_percent ?? s.mem_percent, '%'); - gauge('Disk', s.disk ?? s.disk_percent, '%'); - gauge('Uptime', s.uptime_days ?? s.uptime, s.uptime_days ? 'd' : ''); - host.append(g); - const svcs = await api.get(`${base}/services`).catch(() => null); // TODO(api): confirm services route - if (svcs) { - const items = svcs.services || svcs.items || []; - const card = el('div.card', {}, [el('h3', { text: `Services (${items.length})` })]); - for (const sv of items.slice(0, 60)) card.append(el('div.item', {}, [ - el('div.meta', {}, [el('b', { text: esc(sv.name || sv.unit) })]), - el('span.badge' + (sv.active || sv.status === 'active' || sv.running ? '.ok' : '.err'), { text: sv.status || (sv.active ? 'active' : 'down') }), - ])); - host.append(card); + // /metrics is the one call the whole panel leans on (cpu/mem/disk/temp/ + // uptime/hostname in a single response) — if it fails there is nothing + // meaningful to render, so that failure alone blanks the module. + let m; + try { + m = await api.get(`${base}/metrics`); + } catch (e) { + clear(host).append(el('div.empty', { text: 'System metrics unavailable: ' + esc(e.message) })); + return; + } + clear(host); + if (m.__cached) host.append(el('div.badge.warn', { text: 'cached (offline)' })); + + // ── header: who / how long up ────────────────────────────────── + host.append(el('div.panel-head', {}, [ + el('div', { style: 'font-size:1.6rem', text: '🖥️' }), + el('div', {}, [ + el('h3', { style: 'margin:0', text: esc(m.hostname || 'SecuBox') }), + el('div.muted', { style: 'font-size:.78rem', text: `up ${fmtUptime(m.uptime_seconds)}` }), + ]), + ])); + + // ── gauges: cpu / memory / disk / temp / load ────────────────── + const g = el('div.grid'); + host.append(g); + + // disk byte totals aren't in /metrics — fetch /resources for the "N GB of + // N GB" sub-line; the disk % gauge itself still renders if this fails. + let res = null; + try { res = await api.get(`${base}/resources`); } catch { /* degrade: percent-only gauges */ } + + g.append(gauge({ kind: 'cpu', label: 'CPU', percent: m.cpu_percent })); + g.append(gauge({ + kind: 'mem', label: 'Memory', percent: m.mem_percent, + sub: (m.memory_used != null && m.memory_total != null) ? `${fmtBytes(m.memory_used)} of ${fmtBytes(m.memory_total)} used` : undefined, + })); + g.append(gauge({ + kind: 'disk', label: 'Disk', percent: m.disk_percent, + sub: (res && res.disk_used != null && res.disk_total != null) ? `${fmtBytes(res.disk_used)} of ${fmtBytes(res.disk_total)} used` : undefined, + })); + g.append(gauge({ kind: 'temp', label: 'Temperature', percent: m.cpu_temp, value: m.cpu_temp?.toFixed?.(1) ?? m.cpu_temp, unit: '°C' })); + + // Load average isn't a percent on its own — normalise against core count + // from /status (JWT) when reachable; otherwise show the raw figure without + // a false severity read (an un-normalised "3.4" means nothing alone). + try { + const st = await api.get(`${base}/status`); + const cores = st.cpu_count; + const loadPct = cores ? Math.min(100, (m.load_avg_1 / cores) * 100) : null; + g.append(gauge({ + kind: 'load', label: `Load (1m, ${cores || '?'} cores)`, + percent: loadPct, value: m.load_avg_1?.toFixed?.(2) ?? m.load_avg_1, unit: '', + })); + } catch { + g.append(gauge({ kind: 'load', label: 'Load (1m)', percent: null, value: m.load_avg_1?.toFixed?.(2) ?? m.load_avg_1, unit: '' })); + } + + // ── health score + services ───────────────────────────────────── + try { + const hs = await api.get(`${base}/health_score`); + const services = hs.services || []; + const scoreEmoji = hs.score >= 90 ? '🟢' : hs.score >= 70 ? '🟡' : '🔴'; + const card = el('div.card', {}, [ + el('h3', {}, [`${scoreEmoji} Health `, el('span.data', { text: `${hs.score ?? '—'}/${hs.max ?? 100}` })]), + ]); + if ((hs.issues || []).length) { + card.append(el('div.muted', { style: 'margin-bottom:8px', text: '⚠️ ' + hs.issues.join(', ') + ' down' })); } - } catch (e) { clear(host).append(el('div.empty', { text: 'Failed: ' + esc(e.message) })); } + for (const sv of services) { + card.append(el('div.item', {}, [ + el('div.meta', {}, [ + el('b', { text: esc(sv.name) }), + el('span', { text: sv.enabled ? 'enabled at boot' : 'not enabled at boot' }), + ]), + el('span.badge' + (sv.active ? '.ok' : '.err'), { text: sv.active ? '🟢 ' + (sv.status || 'active') : '🔴 ' + (sv.status || 'down') }), + ])); + } + host.append(card); + } catch (e) { + host.append(el('div.card', {}, [el('div.muted', { text: 'Services/health unavailable: ' + esc(e.message) })])); + } + + // ── network ────────────────────────────────────────────────────── + try { + const net = await api.get(`${base}/network`); + const ifaces = net.interfaces || []; + const card = el('div.card', {}, [el('h3', { text: `📡 Network (${ifaces.length})` })]); + if (!ifaces.length) card.append(el('div.empty', { text: 'No interfaces reported.' })); + for (const i of ifaces) { + const addr = (i.addresses || []).join(', ') || 'no address'; + const speed = i.up && i.speed ? ` · ${i.speed} Mbps` : ''; + card.append(el('div.item', {}, [ + el('div.meta', {}, [ + el('b', { text: esc(i.name) }), + el('span', { text: addr + speed }), + ]), + el('span.badge' + (i.up ? '.ok' : ''), { text: i.up ? '🟢 up' : '⚪ down' }), + ])); + } + host.append(card); + } catch (e) { + host.append(el('div.card', {}, [el('div.muted', { text: 'Network unavailable: ' + esc(e.message) })])); + } + + // ── security ───────────────────────────────────────────────────── + try { + const sec = await api.get(`${base}/security`); + const good = (v) => ['Active', 'Running', 'Enabled'].includes(v); + const bad = (v) => ['Stopped', 'Disabled'].includes(v); + const row = (label, val) => el('div.item', {}, [ + el('div.meta', {}, [el('b', { text: label })]), + el(`span.badge${good(val) ? '.ok' : bad(val) ? '.err' : '.warn'}`, { text: `${good(val) ? '🟢' : bad(val) ? '🔴' : '🟡'} ${val ?? '—'}` }), + ]); + host.append(el('div.card', {}, [ + el('h3', { text: '🛡️ Security' }), + row('Firewall', sec.firewall), + row('SSH', sec.ssh_status), + row('AppArmor', sec.apparmor), + row('CrowdSec', sec.crowdsec), + ])); + } catch (e) { + host.append(el('div.card', {}, [el('div.muted', { text: 'Security status unavailable: ' + esc(e.message) })])); + } } diff --git a/secubox-companion/www/styles/charte.css b/secubox-companion/www/styles/charte.css index e518ae74..1239d7be 100644 --- a/secubox-companion/www/styles/charte.css +++ b/secubox-companion/www/styles/charte.css @@ -151,6 +151,15 @@ body.offline .offline-bar { display: block; } .queued-bar { display: none; background: var(--mind); color: #fff; font-size: .74rem; text-align: center; padding: 4px; } body.has-queue .queued-bar { display: block; } +/* ── emoji gauges (core/metrics.js) ──────────────────────────────── + * Bar colour mirrors the .badge severity classes so a gauge and its + * matching badge always agree at a glance. */ +.gauge-bar { height: 5px; border-radius: 4px; background: var(--surf-2); margin-top: 8px; overflow: hidden; } +.gauge-fill { height: 100%; border-radius: 4px; background: var(--muted); transition: width .3s; } +.gauge-fill.ok { background: var(--root); } +.gauge-fill.warn { background: var(--wall); } +.gauge-fill.err { background: var(--boot); } + .empty { color: var(--muted); text-align: center; padding: 40px 16px; font-size: .85rem; } .muted { color: var(--muted); } .mono-sm { font-family: var(--font-mono); font-size: .72rem; } diff --git a/secubox-companion/www/sw.js b/secubox-companion/www/sw.js index 795dd260..b7300f46 100644 --- a/secubox-companion/www/sw.js +++ b/secubox-companion/www/sw.js @@ -7,7 +7,7 @@ // Same-origin app assets → stale-while-revalidate. Box API (cross-origin) → // passthrough (the page handles offline reads/queue). -const VERSION = 'sbx-companion-v8'; +const VERSION = 'sbx-companion-v10'; const SHELL = [ './', './index.html', './manifest.webmanifest', './styles/charte.css', './styles/fonts.css', @@ -15,7 +15,7 @@ const SHELL = [ './styles/fonts/space-grotesk-700.woff2', './styles/fonts/jetbrains-mono-400.woff2', './styles/fonts/jetbrains-mono-700.woff2', './core/app.js', './core/api.js', './core/store.js', - './core/registry.js', './core/auth.js', './core/ui.js', + './core/registry.js', './core/auth.js', './core/ui.js', './core/metrics.js', './modules/index.json', ]; From 1d0f578f1f7d47a4eb3cb1e11fdd2307dc0f3c6d Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 17 Jul 2026 10:36:29 +0200 Subject: [PATCH 4/4] docs: track session auditing + Companion auth/system/metrics + billets hashtags Co-Authored-By: Gerald KERMA --- .claude/HISTORY.md | 16 ++++++++++++++++ .claude/WIP.md | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index 70f387b1..b459a2a5 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -3,6 +3,22 @@ --- +## 2026-07-17 — Session auditing + Companion auth/system/metrics + billets emoji hashtags (branch feat/sessions-companion-emoji) + +Sessions became auditable, the two stub Companion modules became real, and billets gained emoji-hashtag quick views. All live-verified on gk2. + +- **Session logging (root-caused)** 🔑 — `/login/mfa` and `/totp/confirm` hardcoded `"ip": ""` and dropped `user_agent`; only the password path captured them. **admin is forced-TOTP, so every real admin login took the MFA path** → every session row was unauditable and the users webui sessions tab (which already existed, fully built) rendered blanks. Fix: `_client_meta(request)` (X-Forwarded-For first — nginx/HAProxy front every login, so `request.client.host` is only ever the proxy) on all three paths. Proven by driving the real `/login/mfa` handler in-process with `SECUBOX_AUTH_SESSIONS` on a temp file: records `ip=192.168.1.77` (first XFF hop) + full UA. +- **Companion auth + system** ⚙️ — both were 34-line stubs guessing routes (`/api/v1/auth/users` → **404**; identities are under `/api/v1/users`). Rebuilt against routes read from handler source. auth shows identities + **sessions (who/IP/device/age/current)**; system shows status/metrics/resources/health_score/network/security, each section degrading independently. +- **Emoji metrics** 📊 — `core/metrics.js`: per-kind severity thresholds (disk strictest, cpu tolerant) so gk2's disk 94.5% reads 🔴 while cpu 10% reads 🟢; plain language ("Memory 75% — 5.5 GB of 7.7 GB used") + byte/uptime formatters. +- **Billets emoji hashtags** 🏷️ — migration 0004 (`tag` + `billet_tag`, indexed); tags **extracted from the body** (`#secu`) so authoring costs nothing and applies retroactively — the backfill tagged **37 of 46** existing billets. Accent-folded slugs (#Réseau==#reseau); emoji stored (no silent restyling of published billets); URL anchors don't become tags; unknown tags get a neutral badge. Quick views via `?tag=` on `/` + `/feed.json` (EXISTS not JOIN, so keyset paging can't duplicate rows; pager carries the tag). feed.json gains standard `summary`+`tags` with emoji in the `_secubox` extension. +- **Feed resumes** 📄 — long billets show a short excerpt + "Lire la suite"; short ones render whole (`is_long` measured on markdown-collapsed text). 16/20 resumed on the live feed. +- **`GET /admin/api/billets`** — the authoring list. The public feed can't serve it: its item `id` is a permalink URL (**why Edit/Delete 404'd**) and it hides drafts. +- **Companion 401 re-login** 🔓 — box tokens live 24h but the Companion seals one at pairing and reuses it forever → a day later every authed call 401'd with no escape but unpairing ("Failed: unauthorized" in billets + podcaster). `api.js` now triggers re-auth and **replays the request once**, single-flight (one prompt, not one per request), so an in-flight write survives. SW v10. + +Follow-ups: rebuild the billets `.deb` (migration 0004 + tags.py); consider a token refresh so re-login isn't needed every 24h. + +--- + ## 2026-07-17 — Billets JWT admin surface + Companion authoring & image upload (branch fix/companion-billets-feed) Made the SecuBox Companion a working authoring client for the billets micro-blog — the last gap ("New billet missing upload", comment moderation 404). diff --git a/.claude/WIP.md b/.claude/WIP.md index b267479f..2042ea21 100644 --- a/.claude/WIP.md +++ b/.claude/WIP.md @@ -3,6 +3,25 @@ --- +## ✅ 2026-07-17 : Sessions auditables + Companion auth/system/metrics + billets hashtags emoji (branche feat/sessions-companion-emoji) + +Sessions traçables, les 2 modules Companion stubs deviennent réels, billets gagne les vues rapides par hashtag emoji. Live-vérifié gk2. Détail dans HISTORY.md. + +- **Sessions (root-cause)** 🔑 — `/login/mfa` + `/totp/confirm` codaient en dur `"ip": ""` (et pas d'UA) ; seul le chemin mot-de-passe les captait. **admin est forcé TOTP ⇒ tout login admin réel passait par MFA** → lignes de session non auditables, onglet Sessions de la webui users (déjà complet) affichait du vide. Fix `_client_meta(request)` (X-Forwarded-For d'abord : nginx/HAProxy sont devant, `request.client.host` = le proxy). Prouvé en pilotant le vrai handler `/login/mfa` en-process (fichier sessions temporaire) : `ip=192.168.1.77` + UA complet. +- **Companion auth + system** ⚙️ — 2 stubs de 34 lignes qui devinaient les routes (`/api/v1/auth/users` → **404** ; les identités sont sous `/api/v1/users`). Reconstruits sur les routes lues dans le source. auth = identités + **sessions (qui/IP/device/âge/courante)** ; system = status/metrics/resources/health_score/network/security, chaque section dégradant indépendamment. +- **Metrics emoji** 📊 — `core/metrics.js` : seuils **par type** (disk le plus strict, cpu tolérant) ⇒ disk 94.5% = 🔴 / cpu 10% = 🟢 ; langage simple ("Memory 75% — 5.5 GB of 7.7 GB used"). +- **Billets hashtags emoji** 🏷️ — migration 0004 (`tag`+`billet_tag` indexés) ; tags **extraits du corps** (`#secu`) ⇒ zéro effort auteur + rétroactif : le backfill a taggé **37/46** billets. Slugs sans accents (#Réseau==#reseau), emoji stocké (pas de restyle silencieux), ancres d'URL non taggées, tag inconnu = badge neutre. Vues rapides `?tag=` sur `/` + `/feed.json` (EXISTS, pas JOIN → pagination keyset non dupliquée ; le pager garde le tag). +- **Résumés du feed** 📄 — billets longs = extrait court + "Lire la suite" ; courts = entiers (16/20 résumés en live). +- **`GET /admin/api/billets`** — liste d'authoring : le feed public ne peut pas la servir (son `id` est une URL permalien → **cause des 404 Edit/Delete**, et il masque les drafts). +- **Companion re-login 401** 🔓 — les tokens box vivent 24h mais le Companion scelle le sien au pairing et le réutilise → le lendemain tout 401 sans issue ("Failed: unauthorized" billets + podcaster). `api.js` déclenche la ré-auth et **rejoue la requête une fois**, single-flight (1 prompt), donc un write en cours (un billet tapé) n'est pas perdu. SW v10. + +### ⬜ Next / follow-ups +- **Rebuild `.deb` billets** (migration 0004 + `services/tags.py` + postinst .pth). +- **Refresh token** : éviter la re-login toutes les 24h sur le Companion. +- **APK périmé** : l'APK publié (16/07) embarque le vieux code (SW v5, `/feed`) — rebuild nécessaire. + +--- + ## ✅ 2026-07-17 : Billets — API admin JWT + authoring/upload Companion (branche fix/companion-billets-feed) Companion devient un vrai client d'écriture pour le micro-blog billets. Live-vérifié gk2. Détail dans HISTORY.md.