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
5 changes: 5 additions & 0 deletions src/handlers/api/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export async function handleApiError(error) {
return jsonResponse({ ...payload, ...details }, 409);
}

// 请求体 JSON 解析失败(SyntaxError)属客户端错误,统一归 400,避免畸形/空请求体被当成 500。
if (error instanceof SyntaxError) {
return errorResponse('Invalid JSON in request body', 400);
}

const message = error?.message || 'Internal Server Error';
const explicitStatus = Number(error?.statusCode || error?.status);
const status = Number.isInteger(explicitStatus) && explicitStatus >= 400 && explicitStatus < 600
Expand Down
10 changes: 3 additions & 7 deletions src/handlers/go.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,9 @@ export async function handleGoRequest(request, env, ctx) {
const adminAuthed = await isAdminAuthenticated(request, env);
const privateAccess = adminAuthed || await hasPrivateBookmarkAccess(request, env);
if (!canAccessSite(site, { adminAuthed, privateUnlocked: privateAccess })) {
return new Response(null, {
status: 302,
headers: {
Location: `/?catalog=${encodeURIComponent(site.catelog)}`,
'Cache-Control': 'no-store',
},
});
// 对无权访问的书签返回 404(与"不存在"一致),避免通过 302 跳转 + 分类名泄露受限书签的存在性。
// 受限书签本就不会出现在无权用户的页面链接里,因此这对正常访问无影响,仅堵住按 ID 枚举的探测。
return errorResponse('Site not found', 404);
}

const targetUrl = sanitizeUrl(site.url);
Expand Down
2 changes: 1 addition & 1 deletion src/pages/home/generated-css.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/services/spaceService.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,9 @@ export async function deleteSpace(env, id) {
export async function findSpace(env, idOrSlug) {
try {
if (/^\d+$/.test(String(idOrSlug))) {
return env.NAV_DB.prepare('SELECT * FROM spaces WHERE id = ?').bind(Number(idOrSlug)).first();
return await env.NAV_DB.prepare('SELECT * FROM spaces WHERE id = ?').bind(Number(idOrSlug)).first();
}
return env.NAV_DB.prepare('SELECT * FROM spaces WHERE slug = ?').bind(String(idOrSlug)).first();
return await env.NAV_DB.prepare('SELECT * FROM spaces WHERE slug = ?').bind(String(idOrSlug)).first();
} catch (error) {
console.warn(`[spaces] find fallback: ${error?.message || error}`);
return null;
Expand Down
1 change: 1 addition & 0 deletions src/styles/home-custom.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions tests/apiErrors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import { createAdminSession, createApiToken, revokeApiToken, validateApiToken } from '../src/lib/auth.js';
import { errorResponse } from '../src/lib/utils.js';
import { handleApiRequest } from '../src/handlers/api.js';
import { handleApiError } from '../src/handlers/api/errors.js';
import { createWebhook, dispatchWebhooks, listWebhooks } from '../src/services/webhookService.js';

function createMemoryKv() {
Expand Down Expand Up @@ -92,6 +93,16 @@ function installFetchMock(handler) {
};
}

test('handleApiError maps JSON syntax errors to standardized 400', async () => {
const response = await handleApiError(new SyntaxError('Unexpected end of JSON input'));
const body = await readJson(response);

assert.equal(response.status, 400);
assert.equal(body.code, 400);
assert.equal(body.error.code, 'BAD_REQUEST');
assert.equal(body.message, 'Invalid JSON in request body');
});

test('errorResponse keeps legacy fields and adds normalized error object', async () => {
const response = errorResponse('Missing URL', 400, { field: 'url' });
const body = await readJson(response);
Expand Down
84 changes: 84 additions & 0 deletions tests/go.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import { handleGoRequest } from '../src/handlers/go.js';

function createMemoryKv() {
const store = new Map();

return {
async get(key) {
return store.has(key) ? store.get(key) : null;
},
async put(key, value) {
store.set(key, String(value));
},
async delete(key) {
store.delete(key);
},
};
}

function createMockEnv(site) {
return {
NAV_AUTH: createMemoryKv(),
NAV_DB: {
prepare(sql) {
return {
bind() {
return {
async first() {
if (/FROM sites s/i.test(sql)) return site;
return null;
},
async all() {
return { results: [] };
},
async run() {
return { success: true };
},
};
},
};
},
},
};
}

test('GET /go/:id returns 404 for inaccessible private bookmarks without leaking category', async () => {
const response = await handleGoRequest(new Request('https://example.com/go/42'), createMockEnv({
id: 42,
name: 'Secret',
url: 'https://secret.example.com',
catelog: '私人书签',
visibility: 'private',
}), {});
const body = await response.json();

assert.equal(response.status, 404);
assert.equal(body.message, 'Site not found');
assert.equal(response.headers.get('Location'), null);
assert.ok(!JSON.stringify(body).includes('私人书签'));
});

test('GET /go/:id still redirects accessible public bookmarks via jump page', async () => {
const waitUntilTasks = [];
const response = await handleGoRequest(new Request('https://example.com/go/7?from_catalog=工具'), createMockEnv({
id: 7,
name: 'Example',
url: 'https://example.com/path',
catelog: '工具',
visibility: 'public',
}), {
waitUntil(task) {
waitUntilTasks.push(task);
},
});
const html = await response.text();
await Promise.all(waitUntilTasks);

assert.equal(response.status, 200);
assert.match(response.headers.get('Content-Type') || '', /text\/html/);
assert.match(html, /https:\/\/example\.com\/path/);
assert.match(html, /catalog=%E5%B7%A5%E5%85%B7/);
});
24 changes: 24 additions & 0 deletions tests/spaceService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import { findSpace } from '../src/services/spaceService.js';

test('findSpace awaits D1 first() so async failures use compatibility fallback', async () => {
const env = {
NAV_DB: {
prepare() {
return {
bind() {
return {
async first() {
throw new Error('spaces table missing');
},
};
},
};
},
},
};

assert.equal(await findSpace(env, 'default'), null);
});
Loading