diff --git a/src/index.ts b/src/index.ts index a118bcb..1edc502 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { analyticsTrendingFilter, trendingHomeSlugs } from './utils/trendingPolicy'; import { localTrending, localPageViews } from './utils/localAnalytics'; import gitRoutes from './routes/git'; import { HTTPException } from 'hono/http-exception'; @@ -197,7 +198,9 @@ app.get('/sw.js', async (c) => { app.get('/api/analytics/trending', async (c) => { if (c.env.WIKI_VISIBILITY === 'closed' && !c.get('user')) return c.json({ error: 'Authentication required' }, 401); const cache = caches.default; - const cacheKey = c.req.url; + const cacheUrl = new URL(c.req.url); + cacheUrl.searchParams.set('_ranking_policy', 'system-pages-v1'); + const cacheKey = cacheUrl.toString(); // 캐시 확인 const cached = await cache.match(cacheKey); @@ -207,7 +210,7 @@ app.get('/api/analytics/trending', async (c) => { const accountId = c.env.CF_ACCOUNT_ID; const apiToken = c.env.CF_API_TOKEN; - if (!accountId || !apiToken || !c.env.ANALYTICS) return c.json({ trending: await localTrending(c.env.DB, Math.min(72, Math.max(1, Number(c.req.query('hours')) || 24)), Math.min(20, Math.max(1, Number(c.req.query('limit')) || 10))) }, 200, { 'Cache-Control': 'no-store' }); + if (!accountId || !apiToken || !c.env.ANALYTICS) return c.json({ trending: await localTrending(c.env.DB, Math.min(72, Math.max(1, Number(c.req.query('hours')) || 24)), Math.min(20, Math.max(1, Number(c.req.query('limit')) || 10)), trendingHomeSlugs(c.env)) }, 200, { 'Cache-Control': 'no-store' }); const hours = Math.min(72, Math.max(1, Number(c.req.query('hours')) || 24)); const limit = Math.min(20, Math.max(1, Number(c.req.query('limit')) || 10)); @@ -217,6 +220,7 @@ app.get('/api/analytics/trending', async (c) => { SELECT blob2 as slug, sum(_sample_interval) as views FROM cloudwiki WHERE blob1 = 'pageview' AND blob2 != '' + AND ${analyticsTrendingFilter(c.env)} AND timestamp >= now() - toIntervalHour(${hours}) GROUP BY slug ORDER BY views DESC LIMIT ${limit} FORMAT JSON diff --git a/src/routes/analytics.ts b/src/routes/analytics.ts index bedddaf..2f86c8d 100644 --- a/src/routes/analytics.ts +++ b/src/routes/analytics.ts @@ -1,3 +1,4 @@ +import { analyticsTrendingFilter } from '../utils/trendingPolicy'; import { localDashboard } from '../utils/localAnalytics'; import { ui } from '../i18n/server'; import { Hono } from 'hono'; @@ -114,6 +115,7 @@ analyticsRoutes.get('/trending', async (c) => { FROM ${DATASET} WHERE blob1 = 'pageview' AND blob2 != '' + AND ${analyticsTrendingFilter(c.env)} AND timestamp >= now() - toIntervalHour(${hours}) GROUP BY slug ORDER BY views DESC diff --git a/src/utils/localAnalytics.ts b/src/utils/localAnalytics.ts index 9c03c4b..d3cd667 100644 --- a/src/utils/localAnalytics.ts +++ b/src/utils/localAnalytics.ts @@ -1,5 +1,6 @@ import type { Context } from 'hono'; import type { Env } from '../types'; +import { trendingHomeSlugs } from './trendingPolicy'; const schema = [ `CREATE TABLE IF NOT EXISTS wiki_analytics_hourly (hour INTEGER NOT NULL, key TEXT NOT NULL, type TEXT NOT NULL, page_id INTEGER NOT NULL DEFAULT 0, referrer TEXT NOT NULL, country TEXT NOT NULL, device TEXT NOT NULL, query TEXT NOT NULL, error_message TEXT NOT NULL, path TEXT NOT NULL, status_code INTEGER NOT NULL, duration_bucket INTEGER NOT NULL, events INTEGER NOT NULL DEFAULT 1, duration_sum REAL NOT NULL DEFAULT 0, PRIMARY KEY(hour,key))`, `CREATE INDEX IF NOT EXISTS wiki_analytics_time ON wiki_analytics_hourly(type,hour)`, @@ -41,9 +42,10 @@ export async function recordLocalAnalytics(c: Context, type: 'pageview'|'se await db.batch(statements); } const PUBLIC = `FROM wiki_analytics_hourly a JOIN pages p ON p.id=a.page_id WHERE a.type='pageview' AND p.is_private=0 AND p.deleted_at IS NULL`; -export async function localTrending(db: D1Database, hours=24, limit=10) { +export async function localTrending(db: D1Database, hours=24, limit=10, excludedHomes: string[] = [], excludeSystemPages = true) { await ensureLocalAnalytics(db); - return (await db.prepare(`SELECT p.slug,SUM(a.events) AS views ${PUBLIC} AND a.hour>=? GROUP BY p.id ORDER BY views DESC,p.slug LIMIT ?`).bind(Math.floor(Date.now()/3600000)*3600-hours*3600,limit).all()).results; + const excluded = excludeSystemPages ? ` AND substr(p.slug,1,5)!='Wiki/'${excludedHomes.map(() => ' AND p.slug!=?').join('')}` : ''; + return (await db.prepare(`SELECT p.slug,SUM(a.events) AS views ${PUBLIC} AND a.hour>=?${excluded} GROUP BY p.id ORDER BY views DESC,p.slug LIMIT ?`).bind(Math.floor(Date.now()/3600000)*3600-hours*3600,...(excludeSystemPages ? excludedHomes : []),limit).all()).results; } export async function localPageViews(db:D1Database,slug:string) { await ensureLocalAnalytics(db); @@ -57,9 +59,9 @@ export async function localDashboard(c: Context) { const since=Math.floor(Date.now()/1000)-days*86400,limit=Math.min(100,Math.max(1,Number(c.req.query('limit'))||30)); const all=async(sql:string,params:any[]=[]) => (await db.prepare(sql).bind(...params).all()).results; const daily=()=>all(`SELECT date(a.hour,'unixepoch') AS date,SUM(a.events) AS views ${PUBLIC} AND a.hour>=? GROUP BY date ORDER BY date`,[since]); - if(endpoint==='trending')return {trending:await localTrending(db,Math.min(72,Math.max(1,Number(c.req.query('hours'))||24)),20)}; + if(endpoint==='trending')return {trending:await localTrending(db,Math.min(72,Math.max(1,Number(c.req.query('hours'))||24)),20,trendingHomeSlugs(c.env))}; if(endpoint==='overview')return {summary:await db.prepare(`SELECT COALESCE(SUM(a.events),0) AS total_views,COALESCE(SUM(a.events),0) AS sampled_views,COUNT(DISTINCT NULLIF(a.country,'')) AS unique_countries ${PUBLIC} AND a.hour>=?`).bind(since).first(),daily:await daily()}; - if(endpoint==='pages')return {pages:await localTrending(db,days*24,limit)}; + if(endpoint==='pages')return {pages:await localTrending(db,days*24,limit,[],false)}; if(['referrers','countries','devices'].includes(endpoint)) { const column=endpoint==='referrers'?'referrer':endpoint==='countries'?'country':'device'; return {[endpoint]:await all(`SELECT a.${column} AS ${column==='referrer'?'referer':column},SUM(a.events) AS views ${PUBLIC} AND a.hour>=? AND a.${column}!='' GROUP BY a.${column} ORDER BY views DESC LIMIT ?`,[since,limit])}; diff --git a/src/utils/trendingPolicy.ts b/src/utils/trendingPolicy.ts new file mode 100644 index 0000000..23be38d --- /dev/null +++ b/src/utils/trendingPolicy.ts @@ -0,0 +1,13 @@ +import type { Env } from '../types'; +import { normalizeSlug } from './slug'; + +/** The regular home document and optional configured landing document are not ranked. */ +export function trendingHomeSlugs(env: Pick): string[] { + return [...new Set([env.WIKI_NAME || 'CloudWiki', env.WIKI_HOME_PAGE || ''].map(normalizeSlug).filter(Boolean))]; +} + +/** Analytics Engine uses SQL literals; escape configured names before interpolation. */ +export function analyticsTrendingFilter(env: Pick): string { + const literal = (s: string) => "'" + s.replace(/\\/g, '\\\\').replace(/'/g, "''") + "'"; + return `NOT (blob2 >= 'Wiki/' AND blob2 < 'Wiki0')` + trendingHomeSlugs(env).map(slug => ` AND blob2 != ${literal(slug)}`).join(''); +} diff --git a/tests/services.test.ts b/tests/services.test.ts index b414086..d26e961 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -1,3 +1,4 @@ +import {findPrefixRuleEditAcl,evaluateEditAcl} from '../src/utils/editAcl'; import {test} from 'node:test'; import assert from 'node:assert/strict'; import {DatabaseSync} from 'node:sqlite'; @@ -38,3 +39,25 @@ test('MCP open mode initializes and lists public tools; disabled mode stays deni const tools=await request('tools/list');assert.equal(tools.status,200);assert.ok((await tools.json() as any).result.tools.length>0); assert.equal((await request('initialize','disabled')).status,403); }); + +test('system pages stay counted but are excluded from trending before the limit', async () => { + const {sql,db,c}=fixture(); + sql.exec("INSERT INTO pages(id,slug) VALUES(3,'Wiki/服务条款'),(4,'Nodeloc Wiki'),(5,'Wiki/指南/子页'),(6,'WikiX/普通页面')"); + for(const slug of ['Wiki/服务条款','Nodeloc Wiki','Wiki/指南/子页']) for(let i=0;i<3;i++) await recordLocalAnalytics(c,'pageview',slug); + await recordLocalAnalytics(c,'pageview','Public');await recordLocalAnalytics(c,'pageview','WikiX/普通页面'); + const rows=await localTrending(db,24,1,['Nodeloc Wiki']);assert.equal(rows.length,1);assert.equal(rows[0].slug,'Public'); + assert.equal((await localPageViews(db,'Wiki/服务条款')).total,3); + assert.equal((await localPageViews(db,'Nodeloc Wiki')).total,3); + const all=await localTrending(db,24,20,[],false);assert.equal(all.length,5); + c.env.WIKI_NAME='Nodeloc Wiki';c.req.path='/api/admin/analytics/trending';c.req.query=()=>undefined; + const dashboard:any=await localDashboard(c);assert.deepEqual(dashboard.trending.map((r:any)=>r.slug),['Public','WikiX/普通页面']); +}); + +test('Wiki prefix defaults deny ordinary editors and allow administrators', async () => { + const {sql,db}=fixture(); + sql.exec(`CREATE TABLE doc_setting_prefix_rules(prefix TEXT,is_private INTEGER,edit_acl TEXT);INSERT INTO doc_setting_prefix_rules VALUES('Wiki',NULL,'{"flags":["admin_only"]}')`); + const acl=await findPrefixRuleEditAcl(db,'Wiki/新页面/子页面');assert.ok(acl); + assert.equal((await evaluateEditAcl(db,acl!,{id:1,role:'user'} as any,null,0,false)).allowed,false); + assert.equal((await evaluateEditAcl(db,acl!,{id:1,role:'admin'} as any,null,0,true)).allowed,true); + assert.equal(await findPrefixRuleEditAcl(db,'WikiX/普通页面'),null); +});