Merge feat/sessions-companion-emoji: session auditing + Companion auth/system/metrics + billets emoji hashtags (#868)

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-17 10:37:13 +02:00
commit 28c4fbde84
21 changed files with 918 additions and 90 deletions

View File

@ -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).

View File

@ -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.

View File

@ -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,

View File

@ -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):

View File

@ -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:

View File

@ -0,0 +1,32 @@
-- SPDX-License-Identifier: LicenseRef-CMSD-1.0
-- Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
-- 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);

View File

@ -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()]

View File

@ -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)

View File

@ -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,
}

View File

@ -0,0 +1,93 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# 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"(?<!\w)#(\w{2,32})\b", re.UNICODE)
MAX_TAGS = 8
def normalise(raw: str) -> 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())

View File

@ -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}

View File

@ -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 %}
<nav class="tagbar" aria-label="Filtrer par tag">
<a class="tag-chip{% if not active_tag %} on{% endif %}" href="/">🌀 tous</a>
{% for t in all_tags %}
<a class="tag-chip{% if active_tag == t.slug %} on{% endif %}" href="/?tag={{ t.slug }}"
rel="{% if active_tag == t.slug %}nofollow{% endif %}">{{ t.emoji }} #{{ t.slug }} <span class="tag-n">{{ t.count }}</span></a>
{% endfor %}
</nav>
{% endif %}
{% if active_tag %}
<p class="tag-active">Vue rapide : <b>#{{ active_tag }}</b><a href="/">tout afficher</a></p>
{% endif %}
<div class="h-feed">
{% for b in billets %}
<article class="h-entry billet">
{# 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 %}
<p class="p-summary resume">{{ b.summary }}</p>
<a class="read-more" href="/b/{{ b.slug }}">Lire la suite →</a>
{% else %}
<div class="e-content">{{ b.body_html|safe }}</div>
{% endif %}
{% if b.tags %}
<div class="tag-row">
{% for t in b.tags %}<a class="tag-chip sm p-category" href="/?tag={{ t.slug }}">{{ t.emoji }} #{{ t.slug }}</a>{% endfor %}
</div>
{% endif %}
{% if b.media %}
<div class="gallery" data-lightbox>
{% for m in b.media %}
@ -32,6 +60,7 @@
{% endfor %}
</div>
{% if next_cursor %}
<nav class="pager"><a rel="next" href="/?cursor={{ next_cursor }}">Billets plus anciens →</a></nav>
{# Carry the active tag across pages, else page 2 silently drops the filter. #}
<nav class="pager"><a rel="next" href="/?cursor={{ next_cursor }}{% if active_tag %}&amp;tag={{ active_tag }}{% endif %}">Billets plus anciens →</a></nav>
{% endif %}
{% endblock %}

View File

@ -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(() => ({}));
};

View File

@ -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);

View File

@ -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}` });
}

View File

@ -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" }]
}

View File

@ -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) })]));
}
}

View File

@ -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'); }

View File

@ -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) })]));
}
}

View File

@ -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; }

View File

@ -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',
];