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
34 changes: 34 additions & 0 deletions public/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Page not found | Worknot</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<style>
@font-face {
font-family: "IBM Plex Sans";
font-weight: 400 700;
font-display: swap;
src: url("/fonts/ibm-plex-sans-latin.woff2") format("woff2");
}
body {
margin: 0;
padding: 96px 24px;
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
color: #242422;
background: #f5f5f5;
text-align: center;
}
a {
color: #1b6ec2;
}
</style>
</head>
<body>
<h1>Page not found</h1>
<p>The page you are looking for does not exist.</p>
<p><a href="/">Go to Worknot</a> · <a href="/ja/">日本語</a></p>
</body>
</html>
5 changes: 5 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
82 changes: 38 additions & 44 deletions src/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,20 +483,20 @@ ${
// Notion's JS chunks are content-hashed and the same for every visitor, so keep
// the rewritten copy in the edge cache instead of rewriting it on each request.
// Only a handful of chunks contain Notion domains; the rest pass through as is.
// The query string is dropped so that a random one can't bypass the cache.
async function fetchJs(request, url, ctx) {
const cache = caches.default;
const cacheKey = new URL(request.url);
cacheKey.searchParams.set('worknot', CACHE_VERSION);
const cached = await cache.match(cacheKey.toString());
const cacheKey = new URL(url.pathname + '?worknot=' + CACHE_VERSION, request.url).toString();
const cached = await cache.match(cacheKey);
if (cached) return cached;
const upstream = await fetch(url.toString());
const upstream = await fetch(url.origin + url.pathname);
let body = await upstream.text();
if (body.includes('notion.site') || body.includes('www.notion.so')) body = rewriteJsBody(body);
const response = new Response(body, upstream);
response.headers.set('Content-Type', 'text/javascript');
response.headers.delete('Set-Cookie');
if (upstream.ok && isAssetCacheable(upstream)) {
ctx.waitUntil(cache.put(cacheKey.toString(), response.clone()));
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
return response;
}
Expand Down Expand Up @@ -724,30 +724,6 @@ ${
});
}

const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};

function handleOptions(request) {
if (request.headers.get('Origin') !== null &&
request.headers.get('Access-Control-Request-Method') !== null &&
request.headers.get('Access-Control-Request-Headers') !== null) {
// Handle CORS pre-flight request.
return new Response(null, {
headers: corsHeaders
});
} else {
// Handle standard OPTIONS request.
return new Response(null, {
headers: {
'Allow': 'GET, HEAD, POST, PUT, OPTIONS',
}
});
}
}

const NOTION_SITE_DOMAIN = (() => {
try {
return new URL(${str(notionUrl)}).hostname;
Expand Down Expand Up @@ -849,8 +825,10 @@ ${
}

async function fetchAndApply(request, env, ctx) {
// The Notion client only calls this domain from the same origin, so like
// notion.site, answer without CORS headers
if (request.method === 'OPTIONS') {
return handleOptions(request);
return new Response(null, { headers: { 'Allow': 'GET, HEAD, POST, PUT, OPTIONS' } });
}
try {
return rescopeConsentCookie(await handleRequest(request, env, ctx));
Expand Down Expand Up @@ -934,7 +912,7 @@ ${
return notFoundResponse(url);
}
// The Notion client would load the page ID from the URL, so skip the custom 404 page
if (await isForeignPageNavigation(request, url)) {
if (await isForeignPageNavigation(request, url, ctx)) {
return notFoundResponse(url, true);
}
let response;
Expand Down Expand Up @@ -1000,7 +978,6 @@ ${
// If JSON parsing fails, continue with string replacement
}
response = new Response(body, response);
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.delete('Content-Security-Policy');
return response;
} else if (url.pathname.startsWith('/api/')) {
Expand All @@ -1019,7 +996,6 @@ ${
// Rewrite Notion space domain back to custom domain in response
body = swapSpaceDomainInJson(body, NOTION_SPACE_DOMAIN, CUSTOM_SPACE_DOMAIN);
response = new Response(body, response);
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.delete('Content-Security-Policy');
return response;
} else if (IMAGE_OPTIMIZATION === 'resize' && /^\\/images?\\//.test(url.pathname)) {
Expand Down Expand Up @@ -1507,10 +1483,11 @@ ${
}

// Any public Notion page renders when its ID is requested on this domain, so
// check that unmapped page IDs belong to this site's workspace. Cached per isolate.
// check that unmapped page IDs belong to this site's workspace. Results are kept
// per isolate and in the edge cache, so new isolates don't ask Notion again.
const pageIsForeign = new Map();

async function isForeignPageNavigation(request, url) {
async function isForeignPageNavigation(request, url, ctx) {
if (!NOTION_SITE_DOMAIN.endsWith('.notion.site')) return false;
if (!isPageNavigation(request) || NON_PAGE_PATH.test(url.pathname)) return false;
// The mobile client opens a database peek (?p=) as the page itself.
Expand All @@ -1521,13 +1498,34 @@ ${
const match = url.pathname.match(/[0-9a-f]{32}/);
if (match) pageIds.push(match[0]);
for (const pageId of pageIds) {
if (/^[0-9a-f]{32}$/.test(pageId) && PAGE_TO_SLUG[pageId] === undefined && await isForeignPage(pageId)) return true;
if (/^[0-9a-f]{32}$/.test(pageId) && PAGE_TO_SLUG[pageId] === undefined && await isForeignPage(pageId, request, ctx)) return true;
}
return false;
}

async function isForeignPage(pageId) {
async function isForeignPage(pageId, request, ctx) {
if (pageIsForeign.has(pageId)) return pageIsForeign.get(pageId);
const cache = caches.default;
const cacheKey = new URL('/_worknot/foreign-page/' + pageId + '?worknot=' + CACHE_VERSION, request.url).toString();
const cached = await cache.match(cacheKey);
let foreign;
if (cached) {
foreign = (await cached.text()) === 'true';
} else {
foreign = await fetchPageIsForeign(pageId);
// Serve the page this time, but don't cache a transient failure
if (foreign === null) return false;
ctx.waitUntil(cache.put(cacheKey, new Response(String(foreign), {
headers: { 'Cache-Control': 'public, max-age=86400' },
})));
}
if (pageIsForeign.size > 1000) pageIsForeign.clear();
pageIsForeign.set(pageId, foreign);
return foreign;
}

// Ask Notion which workspace the page belongs to; null if that fails
async function fetchPageIsForeign(pageId) {
try {
const response = await fetch('https://' + NOTION_SITE_DOMAIN + '/api/v3/getPublicPageData', {
method: 'POST',
Expand All @@ -1542,16 +1540,12 @@ ${
});
if (!response.ok) {
await response.body?.cancel();
return false;
return null;
}
const data = await response.json();
const foreign = !!data.spaceDomain && data.spaceDomain !== NOTION_SPACE_DOMAIN && data.publicDomainName !== NOTION_SPACE_DOMAIN;
if (pageIsForeign.size > 1000) pageIsForeign.clear();
pageIsForeign.set(pageId, foreign);
return foreign;
return !!data.spaceDomain && data.spaceDomain !== NOTION_SPACE_DOMAIN && data.publicDomainName !== NOTION_SPACE_DOMAIN;
} catch (e) {
// Serve the page this time, but don't cache a transient failure
return false;
return null;
}
}

Expand Down
88 changes: 86 additions & 2 deletions test/generated-worker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@ const ROOT = '11111111111111111111111111111111';
const CHAPTER = '22222222222222222222222222222222';
const OTHER = '33333333333333333333333333333333';
const dashed = (id) => id.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5');
let script;
let worker;
let isolates = 0;
// Each import is a separate module instance, like a new isolate
async function loadWorker() {
const source = `${script}\n// isolate ${isolates++}`;
return (await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`)).default;
}

before(async () => {
const vite = await createServer({ configFile: false, server: { middlewareMode: true, ws: false }, appType: 'custom', logLevel: 'error' });
try {
const { default: generate } = await vite.ssrLoadModule('/src/code.ts');
const script = generate({
script = generate({
myDomain: 'notes.example.com',
notionUrl: `https://source-workspace.notion.site/${ROOT}`,
slugs: [['chapter', `https://source-workspace.notion.site/${OTHER}`]],
Expand All @@ -23,12 +31,32 @@ before(async () => {
rss: { enabled: false }, i18n: { enabled: false, defaultLocale: 'en' },
ogImageGeneration: { enabled: false },
});
({ default: worker } = await import(`data:text/javascript;base64,${Buffer.from(script).toString('base64')}`));
worker = await loadWorker();
} finally {
await vite.close();
}
});

// In-memory stand-in for caches.default, shared by every worker instance
function stubEdgeCache(t) {
const store = new Map();
globalThis.caches = {
default: {
match: async (key) => store.get(key)?.clone(),
put: async (key, response) => { store.set(key, response); },
},
};
t.after(() => { delete globalThis.caches; });
}

// Runs a request and waits for its ctx.waitUntil work, such as cache writes
async function run(instance, request) {
const pending = [];
const response = await instance.fetch(request, {}, { waitUntil: (promise) => pending.push(promise) });
await Promise.all(pending);
return response;
}

async function forward(t, body, referer, extraHeaders = {}, path = '/api/v3/getPublicPageData') {
const upstream = t.mock.method(globalThis, 'fetch', async () => Response.json({ spaceDomain: 'source-workspace' }));
const request = new Request('https://notes.example.com' + path, {
Expand All @@ -38,6 +66,7 @@ async function forward(t, body, referer, extraHeaders = {}, path = '/api/v3/getP
});
const response = await worker.fetch(request, {}, { waitUntil() {} });
assert.equal(response.status, 200);
assert.equal(response.headers.get('access-control-allow-origin'), null);
assert.equal(upstream.mock.calls.length, 1);
const [url, init] = upstream.mock.calls[0].arguments;
assert.equal(new URL(url).hostname, 'source-workspace.notion.site');
Expand Down Expand Up @@ -106,3 +135,58 @@ test('malformed JSON passes through without fabricated site context', async (t)
const forwarded = await forward(t, '{invalid', undefined);
assert.equal(forwarded.body, '{invalid');
});

test('cross-origin preflight requests get no CORS headers', async (t) => {
const upstream = t.mock.method(globalThis, 'fetch', async () => new Response());
const response = await run(worker, new Request('https://notes.example.com/api/v3/getPublicPageData', {
method: 'OPTIONS',
headers: {
Origin: 'https://other.example',
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'content-type',
},
}));
assert.equal(response.headers.get('access-control-allow-origin'), null);
assert.equal(upstream.mock.calls.length, 0);
});

test('a query string does not bypass the JS chunk cache', async (t) => {
stubEdgeCache(t);
const upstream = t.mock.method(globalThis, 'fetch', async () => new Response('chunk', {
headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
}));
for (const query of ['?a=1', '?b=2']) {
const response = await run(worker, new Request(`https://notes.example.com/_assets/chunk-abc.js${query}`));
assert.equal(await response.text(), 'chunk');
}
assert.equal(upstream.mock.calls.length, 1);
assert.equal(upstream.mock.calls[0].arguments[0], 'https://source-workspace.notion.site/_assets/chunk-abc.js');
});

test('new isolates reuse the cached foreign page check', async (t) => {
stubEdgeCache(t);
const upstream = t.mock.method(globalThis, 'fetch', async () => Response.json({ spaceDomain: 'other-workspace' }));
const foreignPage = '44444444444444444444444444444444';
for (const instance of [worker, await loadWorker()]) {
const response = await run(instance, new Request(`https://notes.example.com/${foreignPage}`, {
headers: { 'Sec-Fetch-Dest': 'document' },
}));
assert.equal(response.status, 404);
}
assert.equal(upstream.mock.calls.length, 1);
});

test('a failed foreign page check is not cached', async (t) => {
stubEdgeCache(t);
const upstream = t.mock.method(globalThis, 'fetch', async () => new Response(null, { status: 503 }));
const page = '55555555555555555555555555555555';
for (const instance of [await loadWorker(), await loadWorker()]) {
const response = await run(instance, new Request(`https://notes.example.com/${page}`, {
headers: { 'Sec-Fetch-Dest': 'document' },
}));
// The page is served, so the upstream 503 passes through
assert.equal(response.status, 503);
}
const checks = upstream.mock.calls.filter((call) => call.arguments[0].endsWith('/api/v3/getPublicPageData'));
assert.equal(checks.length, 2);
});
4 changes: 2 additions & 2 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "worknot",
"account_id": "172411ba14a3cd8e6dce641de0152c07",
"compatibility_date": "2026-03-11",
"compatibility_date": "2026-10-28",
"observability": {
"enabled": true
},
"assets": {
"directory": "./build",
"not_found_handling": "single-page-application"
"not_found_handling": "404-page"
}
}
Loading