rspace-online/modules/data/mod.ts

143 lines
3.9 KiB
TypeScript

/**
* Data module — privacy-first analytics dashboard.
*
* Lightweight module that shows analytics stats from the
* self-hosted Umami instance. No database — proxies to Umami API.
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
const routes = new Hono();
const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online";
const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6a-7a51e3b87b9f";
const TRACKED_APPS = [
"rSpace", "rNotes", "rVote", "rFunds", "rCart", "rWallet",
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
"rTrips", "rTube", "rWork", "rNetwork", "rData",
];
// ── API routes ──
// GET /api/info — module info
routes.get("/api/info", (c) => {
return c.json({
module: "data",
name: "rData",
umamiUrl: UMAMI_URL,
umamiConfigured: !!UMAMI_URL,
features: ["privacy-first", "cookieless", "self-hosted"],
trackedApps: TRACKED_APPS.length,
});
});
// GET /api/health
routes.get("/api/health", (c) => c.json({ ok: true }));
// GET /api/stats — proxy to Umami stats API
routes.get("/api/stats", async (c) => {
const startAt = c.req.query("startAt") || String(Date.now() - 24 * 3600_000);
const endAt = c.req.query("endAt") || String(Date.now());
try {
const res = await fetch(
`${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/stats?startAt=${startAt}&endAt=${endAt}`,
{ signal: AbortSignal.timeout(5000) }
);
if (res.ok) {
const data = await res.json();
return c.json({
...data as Record<string, unknown>,
trackedApps: TRACKED_APPS.length,
apps: TRACKED_APPS,
selfHosted: true,
dashboardUrl: UMAMI_URL,
});
}
} catch {}
// Fallback when Umami unreachable
return c.json({
trackedApps: TRACKED_APPS.length,
cookiesSet: 0,
scriptSize: "~2KB",
selfHosted: true,
dashboardUrl: UMAMI_URL,
apps: TRACKED_APPS,
});
});
// GET /api/active — proxy to Umami active visitors
routes.get("/api/active", async (c) => {
try {
const res = await fetch(
`${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/active`,
{ signal: AbortSignal.timeout(5000) }
);
if (res.ok) return c.json(await res.json());
} catch {}
return c.json({ x: 0 });
});
// GET /collect.js — proxy Umami tracker script
routes.get("/collect.js", async (c) => {
try {
const res = await fetch(`${UMAMI_URL}/script.js`, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
const script = await res.text();
return new Response(script, {
headers: {
"Content-Type": "application/javascript",
"Cache-Control": "public, max-age=3600",
},
});
}
} catch {}
return new Response("/* umami unavailable */", {
headers: { "Content-Type": "application/javascript" },
});
});
// POST /api/collect — proxy Umami event collection
routes.post("/api/collect", async (c) => {
try {
const body = await c.req.text();
const res = await fetch(`${UMAMI_URL}/api/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: AbortSignal.timeout(5000),
});
if (res.ok) return c.json(await res.json());
} catch {}
return c.json({ ok: true });
});
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Data | rSpace`,
moduleId: "data",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "light",
styles: `<link rel="stylesheet" href="/modules/data/data.css">`,
body: `<folk-analytics-view space="${space}"></folk-analytics-view>`,
scripts: `<script type="module" src="/modules/data/folk-analytics-view.js"></script>`,
}));
});
export const dataModule: RSpaceModule = {
id: "data",
name: "rData",
icon: "\u{1F4CA}",
description: "Privacy-first analytics for the r* ecosystem",
routes,
standaloneDomain: "rdata.online",
};