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
2 changes: 1 addition & 1 deletion .github/workflows/i18n.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ jobs:
cache: npm
- run: npm ci
- run: node scripts/prepare-production.mjs
- run: npm run typecheck && npm run typecheck:client && npm run test:i18n && npm run test:permissions && npm run test:git
- run: npm run typecheck && npm run typecheck:client && npm run test:i18n && npm run test:permissions && npm run test:git && npm run test:services
- run: npm run build
- run: I18N_CHECK_BUILD=1 npm run test:i18n
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ VireoWiki 当前版本采用 [Mozilla Public License 2.0](LICENSE)(MPL-2.0)
后台“服务条款与隐私政策”提供两份可编辑的 Markdown 初始模板,发布后作为普通 Wiki 文档保留修订历史。页脚入口为 `/terms`、`/privacy`,使用站点配置中的协议文档地址;请按本站实际服务修改模板。

文档的“更多 → Git 提交”支持使用标准 Git 客户端提交 `page.md`,详见 [Git 编辑说明](GIT_EDITING.md)。网页与 Git 共用 Wiki 权限和编辑校验,服务器拒绝所有非快进覆盖及分支删除。

统计功能在未配置 Analytics Engine 或其查询凭据时使用 D1 后备实现,成功的文档 API 读取(包括单页导航)记录访问量,编辑器读取和私有/删除页面不计入公开热门。后台统计同样可直接使用;小时汇总保留约 90 天,文档累计访问量单独保留。后备性能百分位使用响应时间直方图上界估算。历史上没有记录的访问不能补算。

生产配置默认启用 MCP(`MCP_MODE = "open"`):公共阅读工具可连接 `/api/mcp`,编辑操作仍需有效身份及相应权限。可通过配置设为 `disabled` 关闭。
6 changes: 6 additions & 0 deletions migrations/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -680,3 +680,9 @@ CREATE TABLE IF NOT EXISTS git_commit_links (
page_id INTEGER NOT NULL, oid TEXT NOT NULL, revision_id INTEGER, user_id INTEGER,
PRIMARY KEY(page_id, oid)
);

-- Local analytics fallback: hourly aggregates and lifetime page totals.
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);
CREATE INDEX IF NOT EXISTS wiki_analytics_expiration ON wiki_analytics_hourly(hour);
CREATE TABLE IF NOT EXISTS wiki_page_view_totals (page_id INTEGER PRIMARY KEY, views INTEGER NOT NULL DEFAULT 0);
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"deploy:do": "wrangler deploy -c do-worker/wrangler.toml",
"test:i18n": "node scripts/test-i18n.mjs",
"test:permissions": "node scripts/test-permissions.mjs",
"test:git": "node scripts/test-git.mjs"
"test:git": "node scripts/test-git.mjs",
"test:services": "node scripts/test-services.mjs"
},
"dependencies": {
"@block65/webcrypto-web-push": "^1.0.2",
Expand Down
11 changes: 11 additions & 0 deletions scripts/test-services.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { build } from 'esbuild';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
const dir = await mkdtemp(join(tmpdir(), 'vireo-services-'));
try {
const outfile = join(dir, 'services.test.mjs');
await build({ entryPoints: ['tests/services.test.ts'], outfile, bundle: true, platform: 'node', format: 'esm', banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" } });
process.exitCode = spawnSync(process.execPath, ['--test', outfile], { stdio: 'inherit' }).status ?? 1;
} finally { await rm(dir, { recursive: true, force: true }); }
21 changes: 17 additions & 4 deletions src/client/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1692,7 +1692,13 @@ async function loadRecentChanges() {

// ── 실시간 트렌딩 로드 ──
// ── 실시간 트렌딩 로드 ──
const trendingCleanup = new WeakMap();
let trendingRefresh = null;
let trendingLoading = false;
async function loadTrending() {
if (!trendingRefresh) trendingRefresh = setInterval(() => { if (!document.hidden && document.querySelector('.trending-container')) void loadTrending(); }, 30000);
if (trendingLoading) return;
trendingLoading = true;
try {
const res = await fetch('/api/analytics/trending?limit=10');
if (!res.ok) return;
Expand All @@ -1713,14 +1719,17 @@ async function loadTrending() {
const content = data.trending && data.trending.length > 0 ? html : emptyMsg;

document.querySelectorAll('.trending-container').forEach(el => {
if (el._trendingContent === content) return;
trendingCleanup.get(el)?.();
el._trendingContent = content;
el.innerHTML = content;
if (data.trending && data.trending.length > 0) {
initTrendingTicker(el, Math.min(data.trending.length, 10));
}
});
} catch (e) {
// 무시
}
// Retry during the next refresh.
} finally { trendingLoading = false; }
}

function initTrendingTicker(container, count) {
Expand Down Expand Up @@ -1748,6 +1757,7 @@ function initTrendingTicker(container, count) {
container.style.left = '0';
container.style.width = '100%';
container.style.transition = 'transform 0.4s ease';
container.style.transform = 'translateY(0)';

let currentIndex = 0;
let tickerInterval = setInterval(slideNext, 3000);
Expand All @@ -1768,7 +1778,7 @@ function initTrendingTicker(container, count) {
container.style.transform = `translateY(-${currentIndex * itemHeight}px)`;
}

window.addEventListener('resize', () => {
const resizeHandler = () => {
const nextHeight = getItemHeight();
if (nextHeight === itemHeight) return;
itemHeight = nextHeight;
Expand All @@ -1777,7 +1787,9 @@ function initTrendingTicker(container, count) {
} else {
applyFoldedState();
}
}, { passive: true });
};
window.addEventListener('resize', resizeHandler, { passive: true });
trendingCleanup.set(container, () => { clearInterval(tickerInterval); window.removeEventListener('resize', resizeHandler); });

const section = container.closest('.sidebar-section');
const expandBtn = section ? section.querySelector('.trending-expand-btn') : null;
Expand All @@ -1786,6 +1798,7 @@ function initTrendingTicker(container, count) {
// 클릭 시 이벤트 전파 방지 등을 고려해 다시 세팅
const clone = expandBtn.cloneNode(true);
expandBtn.replaceWith(clone);
clone.innerHTML = ui("m_06864158538ff797");

clone.addEventListener('click', (e) => {
e.preventDefault();
Expand Down
22 changes: 7 additions & 15 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { localTrending, localPageViews } from './utils/localAnalytics';
import gitRoutes from './routes/git';
import { HTTPException } from 'hono/http-exception';
import permissionGroupsRoutes from './routes/permissionGroups';
Expand Down Expand Up @@ -33,7 +34,7 @@ import analyticsRoutes from './routes/analytics';
import blogRoutes from './routes/blog';
import exploreRoutes from './routes/explore';
import qrLoginRoutes from './routes/qr-login';
import { trackPageView, trackError, queryAnalytics } from './utils/analytics';
import { trackError, queryAnalytics } from './utils/analytics';
import { isR2OnlyNamespace, isMapNamespace, normalizeSlug } from './utils/slug';
import { getEnabledExtensions } from './utils/extensions';
import { getRevisionContent } from './utils/r2';
Expand Down Expand Up @@ -194,6 +195,7 @@ app.get('/sw.js', async (c) => {

// ── 공개 Analytics API (인기 문서, 문서별 조회수) ──
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;

Expand All @@ -205,7 +207,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) return c.json({ trending: [] });
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' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cache the polled D1 trending result

In environments without complete Analytics Engine configuration—including the checked production configuration—this branch executes the aggregate D1 query and returns no-store, so it never populates the cache checked immediately above. Since src/client/common.ts now polls this endpoint every 30 seconds in every visible tab, every active user causes two aggregate scans per minute; traffic therefore scales directly into D1 rows-read and latency even though a shared cache with a roughly 30-second TTL would preserve the requested refresh cadence.

Useful? React with 👍 / 👎.


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 Down Expand Up @@ -234,7 +236,7 @@ app.get('/api/analytics/trending', async (c) => {
app.get('/api/analytics/page-views/:slug', requireAdmin, async (c) => {
const accountId = c.env.CF_ACCOUNT_ID;
const apiToken = c.env.CF_API_TOKEN;
if (!accountId || !apiToken) return c.json({ total: 0, recent: 0 });
if (!accountId || !apiToken || !c.env.ANALYTICS) return c.json(await localPageViews(c.env.DB,c.req.param('slug')),200,{'Cache-Control':'no-store'});

const slug = c.req.param('slug');
// Analytics Engine 쿼리는 파라미터 바인딩이 없어 문자열 보간을 쓴다. 닫는 따옴표
Expand Down Expand Up @@ -503,7 +505,7 @@ app.get('/w/*', async (c) => {
if (canUseCache && !isMapNamespace(slug)) {
const cached = await cache.match(ssrCacheKey);
if (cached) {
trackPageView(c, slug, Date.now() - startTime);

return new Response(cached.body, cached);
}
}
Expand All @@ -519,7 +521,7 @@ app.get('/w/*', async (c) => {
if (!user && canUseCache && permsQueryRaw == null) {
const cached = await cache.match(ssrCacheKey);
if (cached) {
trackPageView(c, slug, Date.now() - startTime);

return new Response(cached.body, cached);
}
}
Expand Down Expand Up @@ -787,11 +789,6 @@ ${contentBlock}
// 크롤러: 본문(마크다운)이 보이는 미니멀 HTML로 응답
// renderForAI 결과는 그대로 마크다운이므로 escape 후 <pre>에 넣어 전달한다.
if (isCrawler) {
// 관리자 열람 전용 비공개 문서는 Analytics Engine 통계에서 완전히 제외
// (sourceWasPrivate: 비공개 슬러그가 public 으로 redirect 된 진입 경로도 함께 차단)
if (page.is_private !== 1 && !sourceWasPrivate) {
trackPageView(c, page.slug, Date.now() - startTime);
}
// 표시 이름은 title 우선, 호출/공식 식별자는 slug. 둘 다 크롤러에 노출해 검색 색인성 유지.
const displayName = page.title || page.slug;
const title = `${displayName} - ${wikiName}`;
Expand Down Expand Up @@ -856,11 +853,6 @@ ${contentBlock}
const response = await renderHtml(c, '/', ssrData);

// Analytics: 문서 조회 추적 (존재하는 문서만)
// 관리자 열람 전용 비공개 문서는 Analytics Engine 통계에서 완전히 제외
// (sourceWasPrivate: 비공개 슬러그가 public 으로 redirect 된 진입 경로도 함께 차단)
if (!ssrData._ssrNotFound && ssrData.is_private !== 1 && !sourceWasPrivate) {
trackPageView(c, ssrData.slug || slug, Date.now() - startTime);
}

// 4) 공개 문서이면 Edge 캐시에 24시간 저장
if (shouldCache) {
Expand Down
23 changes: 12 additions & 11 deletions src/routes/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { localDashboard } from '../utils/localAnalytics';
import { ui } from '../i18n/server';
import { Hono } from 'hono';
import { requireAdmin } from '../middleware/session';
Expand All @@ -14,7 +15,7 @@ const DATASET = 'cloudwiki';
function getAnalyticsCredentials(c: any): { accountId: string; apiToken: string } | null {
const accountId = (c.env.CF_ACCOUNT_ID || '').trim();
const apiToken = (c.env.CF_API_TOKEN || '').trim();
if (!accountId || !apiToken) return null;
if (!accountId || !apiToken || !c.env.ANALYTICS) return null;
return { accountId, apiToken };
}

Expand All @@ -34,7 +35,7 @@ function credentialError(c: any) {
*/
analyticsRoutes.get('/overview', async (c) => {
const creds = getAnalyticsCredentials(c);
if (!creds) return credentialError(c);
if (!creds) return c.json(await localDashboard(c));

const period = c.req.query('period') || '7d';
const days = period === '90d' ? 90 : period === '30d' ? 30 : 7;
Expand Down Expand Up @@ -74,7 +75,7 @@ analyticsRoutes.get('/overview', async (c) => {
*/
analyticsRoutes.get('/pages', async (c) => {
const creds = getAnalyticsCredentials(c);
if (!creds) return credentialError(c);
if (!creds) return c.json(await localDashboard(c));

const period = c.req.query('period') || '7d';
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit')) || 20));
Expand Down Expand Up @@ -102,7 +103,7 @@ analyticsRoutes.get('/pages', async (c) => {
*/
analyticsRoutes.get('/trending', async (c) => {
const creds = getAnalyticsCredentials(c);
if (!creds) return credentialError(c);
if (!creds) return c.json(await localDashboard(c));

const hours = Math.min(72, Math.max(1, Number(c.req.query('hours')) || 24));

Expand All @@ -128,7 +129,7 @@ analyticsRoutes.get('/trending', async (c) => {
*/
analyticsRoutes.get('/referrers', async (c) => {
const creds = getAnalyticsCredentials(c);
if (!creds) return credentialError(c);
if (!creds) return c.json(await localDashboard(c));

const period = c.req.query('period') || '7d';
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit')) || 20));
Expand Down Expand Up @@ -156,7 +157,7 @@ analyticsRoutes.get('/referrers', async (c) => {
*/
analyticsRoutes.get('/countries', async (c) => {
const creds = getAnalyticsCredentials(c);
if (!creds) return credentialError(c);
if (!creds) return c.json(await localDashboard(c));

const period = c.req.query('period') || '7d';
const days = period === '90d' ? 90 : period === '30d' ? 30 : 7;
Expand All @@ -183,7 +184,7 @@ analyticsRoutes.get('/countries', async (c) => {
*/
analyticsRoutes.get('/devices', async (c) => {
const creds = getAnalyticsCredentials(c);
if (!creds) return credentialError(c);
if (!creds) return c.json(await localDashboard(c));

const period = c.req.query('period') || '7d';
const days = period === '90d' ? 90 : period === '30d' ? 30 : 7;
Expand All @@ -208,7 +209,7 @@ analyticsRoutes.get('/devices', async (c) => {
*/
analyticsRoutes.get('/searches', async (c) => {
const creds = getAnalyticsCredentials(c);
if (!creds) return credentialError(c);
if (!creds) return c.json(await localDashboard(c));

const period = c.req.query('period') || '7d';
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit')) || 30));
Expand Down Expand Up @@ -236,7 +237,7 @@ analyticsRoutes.get('/searches', async (c) => {
*/
analyticsRoutes.get('/errors', async (c) => {
const creds = getAnalyticsCredentials(c);
if (!creds) return credentialError(c);
if (!creds) return c.json(await localDashboard(c));

const period = c.req.query('period') || '7d';
const limit = Math.min(100, Math.max(1, Number(c.req.query('limit')) || 30));
Expand Down Expand Up @@ -265,7 +266,7 @@ analyticsRoutes.get('/errors', async (c) => {
*/
analyticsRoutes.get('/performance', async (c) => {
const creds = getAnalyticsCredentials(c);
if (!creds) return credentialError(c);
if (!creds) return c.json(await localDashboard(c));

const period = c.req.query('period') || '7d';
const days = period === '90d' ? 90 : period === '30d' ? 30 : 7;
Expand Down Expand Up @@ -305,7 +306,7 @@ analyticsRoutes.get('/performance', async (c) => {
*/
analyticsRoutes.get('/page/:slug', async (c) => {
const creds = getAnalyticsCredentials(c);
if (!creds) return credentialError(c);
if (!creds) return c.json(await localDashboard(c));

const slug = c.req.param('slug');
const period = c.req.query('period') || '7d';
Expand Down
9 changes: 8 additions & 1 deletion src/routes/wiki.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { trackPageView } from '../utils/analytics';
import { ui } from '../i18n/server';
import { Hono, type Context } from 'hono';
import type { Env, Page, Revision, User } from '../types';
Expand Down Expand Up @@ -1537,7 +1538,13 @@ wiki.get('/w/wiki-stats', async (c) => {
* - 리다이렉트 처리: 문서가 없고 리다이렉트가 존재하면 대상 문서 반환 (redirected_from 포함)
* - 비공개 문서: 관리자만 접근 가능
*/
wiki.get('/w/:slug', async (c) => {
wiki.get('/w/:slug', async (c, next) => {
const start = Date.now(); await next();
if (c.res.status === 200 && c.req.query('for_edit') !== 'true') {
const result = await c.res.clone().json() as {slug?:string; id?:number; is_private?:boolean; deleted_at?:number};
if (result.id && result.slug && !result.is_private && !result.deleted_at) trackPageView(c, result.slug, Date.now()-start);
Comment on lines +1541 to +1545

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore view tracking for direct SSR loads

When a visitor opens or refreshes /w/:slug, src/client/pages/index.ts consumes the injected ssrData and deliberately skips the /api/w/:slug request, so this middleware never runs; the corresponding trackPageView calls were also removed from src/index.ts, including the SSR cache-hit paths. Consequently only SPA/API navigations are counted, while direct landings and refreshes—typically a large share of real traffic—are absent from totals and trending.

Useful? React with 👍 / 👎.

Comment on lines +1544 to +1545

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude reads through private redirect sources

When an authorized user reads a private page that redirects to a public page, the response contains the public target's id, slug, and is_private=0, so this condition records the target even though the request originated from a private document. The removed SSR logic explicitly checked sourceWasPrivate; this replacement should likewise inspect redirected_from or propagate the source's privacy state so private-page reads do not inflate public analytics.

Useful? React with 👍 / 👎.

Comment on lines +1543 to +1545

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude all editor metadata reads from page views

Checking only for_edit=true still counts several non-viewing editor requests as visits: the child-document existence check and conflict metadata refresh in src/client/edit/main.ts, plus the permissions modal's fetchCurrentPage, all call /api/w/:slug?redirect=no&nocache=true without for_edit. Each successful lookup now increments totals and trending, so routine editing and administration corrupt the newly introduced statistics; these callers need a non-tracking marker or the route needs a reliable read-purpose distinction.

Useful? React with 👍 / 👎.

}
}, async (c) => {
if (c.env.WIKI_VISIBILITY === 'closed' && !c.get('user')) {
return c.json({ error: ui("m_640f50ae9c6b8e41") }, 401);
}
Expand Down
4 changes: 4 additions & 0 deletions src/utils/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { recordLocalAnalytics } from './localAnalytics';
import { ui } from '../i18n/server';
import type { Context } from 'hono';
import type { Env } from '../types';
Expand Down Expand Up @@ -29,6 +30,7 @@ function detectDevice(ua: string): string {

export function trackPageView(c: Context<Env>, slug: string, responseTimeMs: number = 0) {
if (slug.startsWith('이미지:')) return;
c.executionCtx.waitUntil(recordLocalAnalytics(c, 'pageview', slug, responseTimeMs).catch(error => console.error('Local analytics write failed:', error)));
try {
const analytics = c.env.ANALYTICS;
if (!analytics) return;
Expand Down Expand Up @@ -62,6 +64,7 @@ export function trackPageView(c: Context<Env>, slug: string, responseTimeMs: num
}

export function trackSearch(c: Context<Env>, query: string, resultCount: number, responseTimeMs: number = 0) {
c.executionCtx.waitUntil(recordLocalAnalytics(c, 'search', query, responseTimeMs).catch(error => console.error('Local analytics write failed:', error)));
try {
const analytics = c.env.ANALYTICS;
if (!analytics) return;
Expand Down Expand Up @@ -95,6 +98,7 @@ export function trackSearch(c: Context<Env>, query: string, resultCount: number,
}

export function trackError(c: Context<Env>, path: string, statusCode: number, errorMessage: string, responseTimeMs: number = 0) {
c.executionCtx.waitUntil(recordLocalAnalytics(c, 'error', path, responseTimeMs, statusCode, errorMessage).catch(error => console.error('Local analytics write failed:', error)));
try {
const analytics = c.env.ANALYTICS;
if (!analytics) return;
Expand Down
Loading
Loading