📮 Billets
+Micro-blog gateway inter-médias sociaux — publie court, embarque, republie 🌍
+ +🕒 Derniers billets
+Chargement… ⏳
diff --git a/packages/secubox-billets/api/db.py b/packages/secubox-billets/api/db.py index aa6aac69..72a419bc 100644 --- a/packages/secubox-billets/api/db.py +++ b/packages/secubox-billets/api/db.py @@ -19,7 +19,11 @@ from pathlib import Path import aiosqlite MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations" -DEFAULT_DB_PATH = os.environ.get("BILLETS_DB", "/var/lib/secubox/billets/billets.db") + + +def default_db_path() -> str: + # Resolved at call time (not import) so BILLETS_DB is honoured after import. + return os.environ.get("BILLETS_DB", "/var/lib/secubox/billets/billets.db") _MIGRATION_RE = re.compile(r"^(\d{4})_.+\.sql$") @@ -64,7 +68,7 @@ async def run_migrations(conn: aiosqlite.Connection, *, now: str) -> int: async def connect(db_path: str | None = None, *, now: str) -> aiosqlite.Connection: """Open (creating the parent dir), set WAL + FK enforcement, migrate, return a connection with `row_factory = aiosqlite.Row`.""" - path = db_path or DEFAULT_DB_PATH + path = db_path or default_db_path() if path != ":memory:": Path(path).parent.mkdir(parents=True, exist_ok=True) conn = await aiosqlite.connect(path) diff --git a/packages/secubox-billets/api/main.py b/packages/secubox-billets/api/main.py index 5ad89dda..91759f12 100644 --- a/packages/secubox-billets/api/main.py +++ b/packages/secubox-billets/api/main.py @@ -214,6 +214,20 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None = return feeds.build_jsonfeed(site_title=SITE_TITLE, base_url=base, feed_url=f"{base}/feed.json", items=items) + @app.get("/stats.json") + async def stats_json(request: Request): + # Public aggregate counts for the SecuBox admin panel (cross-origin). + from fastapi.responses import JSONResponse + base = _base(request) + s = await repo.stats(app.state.conn) + rows, _ = await repo.list_published(app.state.conn, limit=6) + s["latest"] = [{"title": feeds.billet_title(r["body"]), + "url": f"{base}/b/{r['slug']}", + "date": (r["published_at"] or r["updated_at"])[:10]} for r in rows] + s["site_url"] = f"{base}/" + return JSONResponse(s, headers={"Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=30"}) + @app.get("/oembed") async def oembed_out(request: Request, url: str, format: str = "json", maxwidth: int | None = None, maxheight: int | None = None): diff --git a/packages/secubox-billets/api/repo.py b/packages/secubox-billets/api/repo.py index b13ff78c..5b0524ab 100644 --- a/packages/secubox-billets/api/repo.py +++ b/packages/secubox-billets/api/repo.py @@ -259,3 +259,23 @@ async def visitor_reactions(conn: aiosqlite.Connection, billet_id: str, "SELECT emoji FROM reaction WHERE billet_id=? AND visitor_token_hash=?", (billet_id, visitor_hash)) as cur: return {r[0] for r in await cur.fetchall()} + + +async def stats(conn: aiosqlite.Connection) -> dict: + """Aggregate counts for the SecuBox webui panel (all public/derived data).""" + async def _one(sql: str) -> int: + async with conn.execute(sql) as cur: + row = await cur.fetchone() + return int(row[0]) if row and row[0] is not None else 0 + reactions_by = {} + async with conn.execute("SELECT emoji, COUNT(*) FROM reaction GROUP BY emoji") as cur: + for emoji, n in await cur.fetchall(): + reactions_by[emoji] = int(n) + return { + "billets_published": await _one("SELECT COUNT(*) FROM billet WHERE status='published'"), + "billets_drafts": await _one("SELECT COUNT(*) FROM billet WHERE status='draft'"), + "comments_approved": await _one("SELECT COUNT(*) FROM comment WHERE status='approved'"), + "comments_pending": await _one("SELECT COUNT(*) FROM comment WHERE status='pending'"), + "reactions_total": await _one("SELECT COUNT(*) FROM reaction"), + "reactions_by": reactions_by, + } diff --git a/packages/secubox-billets/debian/rules b/packages/secubox-billets/debian/rules index 7aab01f1..8fc5c0ff 100755 --- a/packages/secubox-billets/debian/rules +++ b/packages/secubox-billets/debian/rules @@ -11,3 +11,7 @@ override_dh_auto_install: install -m 0644 requirements.txt debian/secubox-billets/usr/lib/secubox/billets/requirements.txt install -d debian/secubox-billets/etc/nginx/sites-available install -m 0644 deploy/nginx.conf debian/secubox-billets/etc/nginx/sites-available/billets.conf + install -d debian/secubox-billets/usr/share/secubox/menu.d + cp menu.d/*.json debian/secubox-billets/usr/share/secubox/menu.d/ + install -d debian/secubox-billets/usr/share/secubox/www/billets + cp -r www/billets/. debian/secubox-billets/usr/share/secubox/www/billets/ diff --git a/packages/secubox-billets/menu.d/415-billets.json b/packages/secubox-billets/menu.d/415-billets.json new file mode 100644 index 00000000..265dbebf --- /dev/null +++ b/packages/secubox-billets/menu.d/415-billets.json @@ -0,0 +1,9 @@ +{ + "id": "billets", + "name": "Billets", + "category": "mind", + "icon": "📮", + "path": "/billets/", + "order": 415, + "description": "Micro-blog gateway inter-médias sociaux" +} diff --git a/packages/secubox-billets/tests/test_feeds.py b/packages/secubox-billets/tests/test_feeds.py index 06e0a286..ce03a5b5 100644 --- a/packages/secubox-billets/tests/test_feeds.py +++ b/packages/secubox-billets/tests/test_feeds.py @@ -94,3 +94,14 @@ async def test_og_and_oembed_discovery_on_page(client): assert 'name="twitter:card"' in r.text assert 'type="application/json+oembed"' in r.text assert "↗ Republier" in r.text and "bsky.app/intent" in r.text + + +async def test_stats_json_cors(client): + c, slug = client + r = await c.get("/stats.json") + assert r.status_code == 200 + assert r.headers.get("access-control-allow-origin") == "*" + d = r.json() + assert d["billets_published"] >= 1 + assert "reactions_by" in d and isinstance(d["latest"], list) + assert d["latest"][0]["url"].endswith(f"/b/{slug}") diff --git a/packages/secubox-billets/www/billets/index.html b/packages/secubox-billets/www/billets/index.html new file mode 100644 index 00000000..f828e55a --- /dev/null +++ b/packages/secubox-billets/www/billets/index.html @@ -0,0 +1,138 @@ + + +
+ + +Micro-blog gateway inter-médias sociaux — publie court, embarque, republie 🌍
+ +Chargement… ⏳