Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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));
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/routes/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { analyticsTrendingFilter } from '../utils/trendingPolicy';
import { localDashboard } from '../utils/localAnalytics';
import { ui } from '../i18n/server';
import { Hono } from 'hono';
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions src/utils/localAnalytics.ts
Original file line number Diff line number Diff line change
@@ -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)`,
Expand Down Expand Up @@ -41,9 +42,10 @@ export async function recordLocalAnalytics(c: Context<Env>, 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);
Expand All @@ -57,9 +59,9 @@ export async function localDashboard(c: Context<Env>) {
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])};
Expand Down
13 changes: 13 additions & 0 deletions src/utils/trendingPolicy.ts
Original file line number Diff line number Diff line change
@@ -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<Env['Bindings'], 'WIKI_NAME' | 'WIKI_HOME_PAGE'>): 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<Env['Bindings'], 'WIKI_NAME' | 'WIKI_HOME_PAGE'>): 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('');
}
23 changes: 23 additions & 0 deletions tests/services.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
Loading