Statistics for admins (#86): page views, daily visitors and referring sites without cookies or stored addresses (one-day salted hash); moderation activity, accounts and library counts; /admin/stats; privacy page updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-10 00:45:08 +02:00
parent 709b5d4594
commit 86b26ba737
10 changed files with 229 additions and 3 deletions

View File

@ -89,7 +89,7 @@ export async function sessionUser(req: FastifyRequest) {
}
const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
const fromSite = (req: FastifyRequest) => {
export const fromSite = (req: FastifyRequest) => {
const key = process.env.DIVAN_SITE_KEY;
if (!key) return LOOPBACK.includes(req.ip);
const given = Buffer.from(String(req.headers['x-site-key'] ?? '')), want = Buffer.from(key);

View File

@ -16,6 +16,7 @@ import { nameMatches } from './search.ts';
import { lookup, PUNCT } from './dictionary.ts';
import { authRoutes } from './auth.ts';
import { adminRoutes } from './admin.ts';
import { statsRoutes } from './stats.ts';
import { permissionRoutes } from './permissions.ts';
import { libraryRoutes } from './library.ts';
import { moderationRoutes } from './moderation.ts';
@ -220,6 +221,7 @@ siteRoutes(app);
tagRoutes(app);
ebookRoutes(app);
feedRoutes(app);
statsRoutes(app);
const port = Number(process.env.PORT ?? 4100);
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });

42
api/src/stats.test.ts Normal file
View File

@ -0,0 +1,42 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import { authRoutes } from './auth.ts';
import { statsRoutes } from './stats.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('statistics: hits only from the site, counts without addresses, admin-only dashboard', async () => {
const app = Fastify();
authRoutes(app); statsRoutes(app);
const run = Date.now(), path = `/stats-test-${run}`;
const hit = (body: object, headers: object = {}) => app.inject({ method: 'POST', url: '/api/stats/hit', payload: body, headers: headers as any });
try {
process.env.DIVAN_SITE_KEY = 'site-key';
assert.equal((await hit({ path, ip: '1.2.3.4', ua: 'x' })).statusCode, 403, 'not from the site');
const site = { 'x-site-key': 'site-key' };
for (const ip of ['1.2.3.4', '1.2.3.4', '5.6.7.8']) assert.equal((await hit({ path, ip, ua: 'Mozilla', ref: `ref-${run}.pk` }, site)).statusCode, 200);
const views = (await pool.query('SELECT views FROM stat_views WHERE path = $1', [path])).rows[0].views;
assert.equal(views, 3);
const hashes = (await pool.query('SELECT h FROM stat_visitors WHERE day = (now() AT TIME ZONE \'Asia/Karachi\')::date')).rows.map((r) => r.h);
assert.ok(!hashes.some((h) => h.includes('1.2.3.4')), 'no addresses stored');
assert.equal((await pool.query('SELECT n FROM stat_referrers WHERE host = $1', [`ref-${run}.pk`])).rows[0].n, 3);
const s = (await app.inject({ method: 'POST', url: '/api/auth/signup', payload: { email: `stats-${run}@divan.test`, password: 'pass-word-1' }, headers: { 'x-client-ip': `stats-${run}` } })).json();
const get = () => app.inject({ method: 'GET', url: '/api/admin/stats?days=7', headers: { authorization: `Bearer ${s.token}` } });
assert.equal((await get()).statusCode, 403, 'readers cannot see statistics');
await pool.query(`UPDATE users SET role = 'admin' WHERE id = $1`, [s.user.id]);
const st = (await get()).json();
assert.equal(st.daily.length, 7);
assert.ok(st.daily.at(-1).views >= 3 && st.daily.at(-1).visitors >= 2);
assert.ok(st.pages.some((p: any) => p.path === path && p.views === 3));
assert.ok(st.accounts.roles.admin >= 1 && st.accounts.new >= 1);
} finally {
delete process.env.DIVAN_SITE_KEY;
await pool.query('DELETE FROM stat_views WHERE path = $1', [path]);
await pool.query('DELETE FROM stat_referrers WHERE host = $1', [`ref-${run}.pk`]);
await pool.query('DELETE FROM users WHERE email = $1', [`stats-${run}@divan.test`]);
await app.close();
}
});

63
api/src/stats.ts Normal file
View File

@ -0,0 +1,63 @@
// Privacy-friendly statistics for admins (#86): page views, visitors and where they came from, plus moderation and
// account activity. No cookies and nothing personal is stored: the site reports a page view with the reader's address
// and browser, and only a one-day salted hash of them is kept (to count visitors per day); the salt is never stored.
// POST /api/stats/hit {path, ref, ip, ua} from the site only (x-site-key)
// GET /api/admin/stats?days=30 the admin dashboard's numbers
import { createHash, randomBytes } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { pool } from './db.ts';
import { fromSite } from './auth.ts';
import { requireAdmin } from './admin.ts';
const TODAY = `(now() AT TIME ZONE 'Asia/Karachi')::date`;
// ponytail: the salt is per process; a restart mid-day counts a returning visitor once more that day
let salt = { day: '', key: randomBytes(32) };
const visitor = (ip: string, ua: string) => {
const day = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Karachi' });
if (salt.day !== day) salt = { day, key: randomBytes(32) };
return createHash('sha256').update(salt.key).update(ip).update('\n').update(ua).digest('base64url').slice(0, 22);
};
export function statsRoutes(app: FastifyInstance) {
app.post<{ Body: { path?: string; ref?: string; ip?: string; ua?: string } }>('/api/stats/hit', async (req, reply) => {
if (!fromSite(req)) return reply.code(403).send({ error: 'forbidden' });
const { path = '', ref = '', ip = '', ua = '' } = req.body ?? {};
if (!path.startsWith('/') || path.length > 300) return reply.code(400).send({ error: 'path' });
await pool.query(`INSERT INTO stat_views (day, path, views) VALUES (${TODAY}, $1, 1)
ON CONFLICT (day, path) DO UPDATE SET views = stat_views.views + 1`, [path]);
await pool.query(`INSERT INTO stat_visitors (day, h) VALUES (${TODAY}, $1) ON CONFLICT DO NOTHING`, [visitor(ip, ua)]);
if (ref) await pool.query(`INSERT INTO stat_referrers (day, host, n) VALUES (${TODAY}, $1, 1)
ON CONFLICT (day, host) DO UPDATE SET n = stat_referrers.n + 1`, [ref.slice(0, 200)]);
return { ok: true };
});
app.get<{ Querystring: { days?: string } }>('/api/admin/stats', async (req, reply) => {
if (!(await requireAdmin(req, reply))) return;
const days = Math.min(365, Math.max(1, Number(req.query.days) || 30));
const since = `${TODAY} - ${days - 1}`;
const q = async (sql: string) => (await pool.query(sql)).rows;
const [daily, pages, referrers, actions, people, waiting, roles, accounts, library, content] = await Promise.all([
q(`SELECT d::date::text AS day, coalesce(v.views, 0)::int AS views, coalesce(u.visitors, 0)::int AS visitors
FROM generate_series(${since}, ${TODAY}, interval '1 day') d
LEFT JOIN (SELECT day, sum(views) AS views FROM stat_views GROUP BY day) v ON v.day = d::date
LEFT JOIN (SELECT day, count(*) AS visitors FROM stat_visitors GROUP BY day) u ON u.day = d::date ORDER BY d`),
q(`SELECT s.path, sum(s.views)::int AS views, coalesce(p.title, c.title, t.nickname) AS title
FROM stat_views s LEFT JOIN poems p ON p.url = s.path LEFT JOIN categories c ON c.url = s.path LEFT JOIN poets t ON t.url = s.path
WHERE s.day >= ${since} GROUP BY s.path, p.title, c.title, t.nickname ORDER BY views DESC LIMIT 20`),
q(`SELECT host, sum(n)::int AS n FROM stat_referrers WHERE day >= ${since} GROUP BY host ORDER BY n DESC LIMIT 10`),
q(`SELECT action, count(*)::int AS n FROM revision_events WHERE at >= ${since} GROUP BY action ORDER BY n DESC`),
q(`SELECT actor_email AS email, count(*)::int AS n, count(*) FILTER (WHERE action = 'published')::int AS published
FROM revision_events WHERE at >= ${since} AND actor_email <> 'server' GROUP BY actor_email ORDER BY n DESC LIMIT 10`),
q(`SELECT status, count(*)::int AS n FROM revisions WHERE status IN ('submitted', 'approved') GROUP BY status`),
q(`SELECT role, count(*)::int AS n FROM users GROUP BY role`),
q(`SELECT count(*) FILTER (WHERE created_at >= ${since})::int AS new,
(SELECT count(DISTINCT user_id) FROM sessions WHERE created_at >= ${since})::int AS signed_in FROM users`),
q(`SELECT kind, count(*)::int AS n FROM library GROUP BY kind`),
q(`SELECT (SELECT count(*) FROM ebooks WHERE published)::int AS ebooks, (SELECT count(*) FROM tags)::int AS tags,
(SELECT count(*) FROM revisions WHERE status = 'published')::int AS versions`),
]);
return { days, daily, pages, referrers, moderation: { actions, people, waiting: Object.fromEntries(waiting.map((r) => [r.status, r.n])) },
accounts: { ...accounts[0], roles: Object.fromEntries(roles.map((r) => [r.role, r.n])) },
library: Object.fromEntries(library.map((r) => [r.kind, r.n])), content: content[0] };
});
}

View File

@ -253,3 +253,9 @@ ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS coauthor_ids integer[] NOT NULL DEFA
CREATE INDEX IF NOT EXISTS ebooks_coauthors ON ebooks USING gin (coauthor_ids);
ALTER TABLE entity_tags DROP CONSTRAINT IF EXISTS entity_tags_entity_check;
ALTER TABLE entity_tags ADD CONSTRAINT entity_tags_entity_check CHECK (entity IN ('category', 'work', 'ebook'));
-- visitor statistics (api/src/stats.ts, #86): counts only; no cookies, no addresses. A visitor is a hash of their
-- address and browser with a salt that lives one day in memory, so it cannot be traced back or followed across days.
CREATE TABLE IF NOT EXISTS stat_views (day date NOT NULL, path text NOT NULL, views integer NOT NULL DEFAULT 0, PRIMARY KEY (day, path));
CREATE TABLE IF NOT EXISTS stat_visitors (day date NOT NULL, h text NOT NULL, PRIMARY KEY (day, h));
CREATE TABLE IF NOT EXISTS stat_referrers (day date NOT NULL, host text NOT NULL, n integer NOT NULL DEFAULT 0, PRIMARY KEY (day, host));

View File

@ -1,7 +1,8 @@
---
const { current } = Astro.props as { current: 'users' | 'audit' };
const { current } = Astro.props as { current: 'users' | 'audit' | 'stats' };
---
<nav class="admin-nav">
<a href="/admin" aria-current={current === 'users' ? 'page' : undefined}>صارفین</a>
<a href="/admin/audit" aria-current={current === 'audit' ? 'page' : undefined}>ریکارڈ</a>
<a href="/admin/stats" aria-current={current === 'stats' ? 'page' : undefined}>اعداد و شمار</a>
</nav>

View File

@ -4,6 +4,21 @@
import { defineMiddleware } from 'astro:middleware';
import { auth, COOKIE } from './lib/auth';
// visitor statistics (#86): each reading page shown is reported to the API without waiting; the API keeps only counts
// and a one-day hash. Bots, link previews, private pages (accounts, moderation) and errors are not counted.
const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
const PRIVATE = /^\/(api|mod|admin|account|library|notes|words|writers|signin|signup|_astro|ebooks\/file|health)(\/|$)/;
const BOT = /bot|crawl|spider|slurp|preview|fetch|curl|wget|python|java\/|go-http|headless|lighthouse|monitor/i;
function count(ctx: any, res: Response) {
const ua = ctx.request.headers.get('user-agent') ?? '', path = ctx.url.pathname;
if (ctx.request.method !== 'GET' || res.status !== 200 || !res.headers.get('content-type')?.includes('text/html') || !ua || BOT.test(ua) || PRIVATE.test(path)) return;
let ip = '', ref = '';
try { ip = ctx.clientAddress; } catch {}
try { const h = new URL(ctx.request.headers.get('referer') ?? '').hostname; if (h !== ctx.url.hostname) ref = h.replace(/^www\./, ''); } catch {}
fetch(`${API}/api/stats/hit`, { method: 'POST', headers: { 'content-type': 'application/json', ...(process.env.DIVAN_SITE_KEY && { 'x-site-key': process.env.DIVAN_SITE_KEY }) },
body: JSON.stringify({ path, ref, ip, ua }) }).catch(() => {});
}
export const onRequest = defineMiddleware(async (ctx, next) => {
const token = ctx.cookies.get(COOKIE)?.value;
ctx.locals.user = null;
@ -21,6 +36,7 @@ export const onRequest = defineMiddleware(async (ctx, next) => {
msg: String(err?.message ?? err), stack: String(err?.stack ?? '').split('\n').slice(0, 8).join('\n') }));
throw err;
}
count(ctx, res);
if (ctx.cookies.get('divan-digits')?.value !== 'latn' || !res.headers.get('content-type')?.includes('text/html')) return res;
// text only: scripts and styles are left alone (the page's own scripts look for Eastern digits)
const html = (await res.text()).split(/(<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>)/)

View File

@ -0,0 +1,85 @@
---
// Admin: statistics (#86): readers (page views and visitors per day, top pages, where they came from), moderation
// (steps taken, by whom, waiting) and accounts. Counts only: nothing here identifies a reader (see /privacy).
import Base from '../../layouts/Base.astro';
import AdminNav from '../../components/AdminNav.astro';
import { asUser, COOKIE } from '../../lib/auth';
import { ud } from '../../lib/urdu';
import { ROLE } from '../../lib/roles';
import { EVENT } from '../../lib/mod';
const me = Astro.locals.user;
if (!me) return Astro.redirect('/signin?next=/admin/stats');
if (me.role !== 'admin') return new Response('صرف ایڈمن کے لیے', { status: 403 });
const days = [7, 30, 90, 365].includes(Number(Astro.url.searchParams.get('days'))) ? Number(Astro.url.searchParams.get('days')) : 30;
const s = (await asUser(Astro.cookies.get(COOKIE)!.value, `/api/admin/stats?days=${days}`)).data;
const sum = (k: 'views' | 'visitors') => s.daily.reduce((n: number, d: any) => n + d[k], 0);
const max = Math.max(1, ...s.daily.map((d: any) => d.views));
const LIB: Record<string, string> = { poet: 'شاعر', category: 'کتابیں', poem: 'کلام', couplet: 'اشعار', phrase: 'ٹکڑے', word: 'الفاظ', page: 'صفحات' };
const label = (d: string) => ud(d.slice(5).replace('-', '/'));
---
<Base title="ایڈمن: اعداد و شمار">
<h1>ایڈمن</h1>
<AdminNav current="stats" />
<nav class="stats-range" aria-label="مدت">
{[7, 30, 90, 365].map((d) => <a href={`?days=${d}`} aria-current={d === days ? 'page' : undefined} class="chip">{d === 365 ? 'ایک سال' : `${ud(d)} دن`}</a>)}
</nav>
<nav class="stat-tiles" aria-label="خلاصہ">
<div class="stat"><strong>{ud(sum('views'))}</strong><span>صفحات دیکھے گئے</span></div>
<div class="stat"><strong>{ud(sum('visitors'))}</strong><span>قارئین (روزانہ کا مجموعہ)</span></div>
<div class="stat"><strong>{ud(s.accounts.new)}</strong><span>نئے اکاؤنٹ</span></div>
<div class="stat"><strong>{ud(s.moderation.actions.find((a: any) => a.action === 'published')?.n ?? 0)}</strong><span>اشاعتیں</span></div>
</nav>
<section class="lib-section">
<h2>روزانہ</h2>
<div class="bars" role="img" aria-label={`روزانہ صفحات: ${s.daily.map((d: any) => `${label(d.day)} ${ud(d.views)}`).join('، ')}`}>
{s.daily.map((d: any) => (
<div class="bar" title={`${label(d.day)}: ${ud(d.views)} صفحات، ${ud(d.visitors)} قارئین`}>
<span style={`height:${(d.views / max) * 100}%`}></span><i style={`height:${(d.visitors / max) * 100}%`}></i>
</div>
))}
</div>
<p class="muted center"><span class="key-views"></span> صفحات · <span class="key-visitors"></span> قارئین · {label(s.daily[0].day)} تا {label(s.daily.at(-1).day)}</p>
</section>
<div class="stats-grid">
<section class="lib-section">
<h2>سب سے زیادہ پڑھے گئے</h2>
{s.pages.length ? <table class="admin-table"><tbody>
{s.pages.map((p: any) => <tr><td><a href={p.path}>{p.title ?? p.path}</a></td><td>{ud(p.views)}</td></tr>)}
</tbody></table> : <p class="muted">ابھی کچھ نہیں۔</p>}
</section>
<section class="lib-section">
<h2>کہاں سے آئے</h2>
{s.referrers.length ? <table class="admin-table"><tbody>
{s.referrers.map((r: any) => <tr><td><bdi dir="ltr">{r.host}</bdi></td><td>{ud(r.n)}</td></tr>)}
</tbody></table> : <p class="muted">ابھی کچھ نہیں (سیدھے آنے والے یہاں نہیں گنے جاتے)۔</p>}
</section>
<section class="lib-section">
<h2>موڈریشن</h2>
<p>جائزے کے منتظر: <strong>{ud(s.moderation.waiting.submitted ?? 0)}</strong> · اشاعت کے منتظر: <strong>{ud(s.moderation.waiting.approved ?? 0)}</strong>
· کل شائع شدہ ورژن: {ud(s.content.versions)}</p>
<table class="admin-table"><tbody>
{s.moderation.actions.map((a: any) => <tr><td>{EVENT[a.action] ?? a.action}</td><td>{ud(a.n)}</td></tr>)}
</tbody></table>
{s.moderation.people.length > 0 && <>
<h3>سب سے زیادہ کام</h3>
<table class="admin-table"><thead><tr><th>موڈریٹر</th><th>قدم</th><th>اشاعتیں</th></tr></thead><tbody>
{s.moderation.people.map((p: any) => <tr><td><bdi dir="ltr">{p.email}</bdi></td><td>{ud(p.n)}</td><td>{ud(p.published)}</td></tr>)}
</tbody></table>
</>}
</section>
<section class="lib-section">
<h2>اکاؤنٹ اور محفوظ شدہ</h2>
<p>اس مدت میں لاگ ان کرنے والے: <strong>{ud(s.accounts.signed_in)}</strong></p>
<table class="admin-table"><tbody>
{Object.entries(s.accounts.roles).map(([r, n]: any) => <tr><td>{ROLE[r] ?? r}</td><td>{ud(n)}</td></tr>)}
{Object.entries(s.library).map(([k, n]: any) => <tr><td>محفوظ: {LIB[k] ?? k}</td><td>{ud(n)}</td></tr>)}
<tr><td>ای بکس</td><td>{ud(s.content.ebooks)}</td></tr>
<tr><td>ٹیگ</td><td>{ud(s.content.tags)}</td></tr>
</tbody></table>
</section>
</div>
<p class="muted center">ان اعداد میں کسی قاری کی شناخت نہیں: نہ کوکی، نہ پتا محفوظ ہوتا ہے (<a href="/privacy">رازداری</a>)۔</p>
</Base>

View File

@ -6,7 +6,7 @@ import { brand } from '../lib/brand';
<Base title="رازداری" description={`${brand.name} آپ کے بارے میں کیا محفوظ رکھتا ہے، کیوں، اور اسے کیسے مٹایا جائے`}>
<article class="guide about">
<h1>رازداری</h1>
<p class="muted">آخری تبدیلی: ۹ اکتوبر ۲۰۲۶ء</p>
<p class="muted">آخری تبدیلی: ۱۰ اکتوبر ۲۰۲۶ء</p>
<p>
{brand.name} پڑھنے کے لیے کسی اکاؤنٹ کی ضرورت نہیں۔ ہم صرف وہی محفوظ رکھتے ہیں جو سائٹ کے چلنے کے لیے ضروری ہے، اور اسے
کسی کو بیچتے یا دیتے نہیں۔ نہ اشتہار ہیں، نہ اشتہاری یا جاسوس کوکیز، نہ کسی اور کمپنی کے ٹریکر۔
@ -16,6 +16,7 @@ import { brand } from '../lib/brand';
<ul>
<li>آپ کی پسند (خط، رنگ، روشنی، پڑھنے کا انداز) آپ کے اپنے براؤزر میں رہتی ہے، ہمارے سرور پر نہیں۔ ہندسوں کی پسند ایک چھوٹی کوکی میں رہتی ہے تاکہ صفحہ انھی ہندسوں کے ساتھ آئے۔</li>
<li>لاگ ان اور اکاؤنٹ بنانے کی کوششوں کی حد کے لیے سرور آپ کا انٹرنیٹ پتہ (IP) تھوڑی دیر کے لیے یاد رکھتا ہے، اور غلطیوں کے ریکارڈ میں صفحے کا پتہ اور وقت درج ہوتا ہے۔ یہ صرف سائٹ کی حفاظت اور خرابیاں ٹھیک کرنے کے لیے ہیں اور کچھ دن بعد مٹ جاتے ہیں۔</li>
<li><strong>گنتی:</strong> کون سا صفحہ کتنی بار پڑھا گیا، روزانہ کتنے قارئین آئے، اور کس ویب سائٹ سے (صرف اس کا نام) — صرف گنتی، بغیر کوکی کے۔ قارئین گننے کے لیے انٹرنیٹ پتے اور براؤزر کی ایک نہ پلٹنے والی شکل بنتی ہے جس کی کنجی ہر روز بدلتی ہے اور کہیں محفوظ نہیں ہوتی، اس لیے کسی کو پہچانا یا ایک دن سے دوسرے دن تک پیچھا نہیں کیا جا سکتا۔</li>
</ul>
<h2>اکاؤنٹ کے ساتھ</h2>

View File

@ -930,3 +930,13 @@ p, li { text-wrap: pretty; }
.help h2::after { display: none; }
.help-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); flex: none; }
.help p { margin: 0 0 8px; line-height: 2; }
/* admin statistics (#86): daily bars, views behind and visitors in front */
.bars { display: flex; align-items: flex-end; gap: 2px; height: 160px; padding: 8px; border: 1px solid var(--border); border-radius: 10px; direction: ltr; }
.bar { flex: 1; height: 100%; position: relative; display: flex; align-items: flex-end; }
.bar span, .bar i { position: absolute; inset-inline: 0; bottom: 0; border-radius: 3px 3px 0 0; }
.bar span, .key-views { background: color-mix(in srgb, var(--accent) 35%, transparent); }
.bar i, .key-visitors { background: var(--accent); inset-inline: 25%; }
.key-views, .key-visitors { display: inline-block; width: 12px; height: 12px; border-radius: 3px; vertical-align: middle; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 0 24px; }
.stats-range { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin: 10px 0; }