threadroom-pi 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/extensions/README.md +15 -0
- package/extensions/index.ts +280 -0
- package/extensions/native/index.ts +614 -0
- package/extensions/native/presentation.ts +30 -0
- package/extensions/native/receipt.ts +25 -0
- package/extensions/native/ui.ts +167 -0
- package/extensions/presentation/renderers.ts +185 -0
- package/extensions/questions/README.md +41 -0
- package/extensions/questions/compose.ts +82 -0
- package/extensions/questions/external-editor.ts +24 -0
- package/extensions/questions/host.ts +415 -0
- package/extensions/questions/index.ts +6 -0
- package/extensions/questions/model.ts +175 -0
- package/extensions/questions/stream.ts +299 -0
- package/extensions/questions/text.ts +10 -0
- package/extensions/questions/tool.ts +309 -0
- package/extensions/questions/types.ts +40 -0
- package/extensions/questions/view.ts +221 -0
- package/node_modules/threadroom-service/README.md +73 -0
- package/node_modules/threadroom-service/bin/threadroom-service.js +9 -0
- package/node_modules/threadroom-service/dist/public/app.js +349 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-bloom.svg +17 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-drift.svg +16 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-prowler.svg +15 -0
- package/node_modules/threadroom-service/dist/public/client.js +30 -0
- package/node_modules/threadroom-service/dist/public/index.html +54 -0
- package/node_modules/threadroom-service/dist/public/presentations.js +194 -0
- package/node_modules/threadroom-service/dist/public/routes.js +15 -0
- package/node_modules/threadroom-service/dist/public/styles.css +263 -0
- package/node_modules/threadroom-service/dist/src/live.js +170 -0
- package/node_modules/threadroom-service/dist/src/main.js +33 -0
- package/node_modules/threadroom-service/dist/src/presentations.js +116 -0
- package/node_modules/threadroom-service/dist/src/server.js +143 -0
- package/node_modules/threadroom-service/dist/src/site.js +53 -0
- package/node_modules/threadroom-service/dist/src/store.js +459 -0
- package/node_modules/threadroom-service/dist/src/ui-main.js +14 -0
- package/node_modules/threadroom-service/lib/cli.js +188 -0
- package/node_modules/threadroom-service/lib/ensure.js +157 -0
- package/node_modules/threadroom-service/lib/paths.js +19 -0
- package/node_modules/threadroom-service/package.json +19 -0
- package/package.json +50 -0
- package/scripts/stage-service.js +32 -0
- package/scripts/verify-packed.js +85 -0
- package/scripts/verify-release.js +79 -0
- package/src/client.js +135 -0
- package/src/config.js +57 -0
- package/src/http-transport.js +44 -0
- package/src/participation.js +211 -0
- package/src/service-runtime.js +43 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { ThreadStore } from './store.js';
|
|
4
|
+
import { createThreadroomServer } from './server.js';
|
|
5
|
+
import { createWebsiteHandler } from './site.js';
|
|
6
|
+
|
|
7
|
+
const port = Number(process.env.PORT || 4310);
|
|
8
|
+
const host = process.env.HOST || '127.0.0.1';
|
|
9
|
+
const database = resolve(process.env.THREADROOM_DB || 'data/threadroom.sqlite');
|
|
10
|
+
const store = new ThreadStore(database);
|
|
11
|
+
if (process.env.THREADROOM_SEED_DEMO !== '0') store.seedDemo();
|
|
12
|
+
|
|
13
|
+
const allowedOrigins = (process.env.THREADROOM_UI_ORIGINS || 'http://127.0.0.1:4311,http://localhost:4311').split(',').map((origin) => origin.trim()).filter(Boolean);
|
|
14
|
+
const websiteHandler = process.env.THREADROOM_SERVE_UI === '0' ? null : createWebsiteHandler();
|
|
15
|
+
const server = createThreadroomServer(store, {
|
|
16
|
+
allowedOrigins, websiteHandler,
|
|
17
|
+
runtimeIdentity: { storageId: createHash('sha256').update(database).digest('hex').slice(0, 24) }
|
|
18
|
+
});
|
|
19
|
+
server.listen(port, host, () => {
|
|
20
|
+
console.log(`Threadroom ${process.env.THREADROOM_SERVE_UI === '0' ? 'API' : 'API + optional website'} is ready at http://${host}:${server.address().port}`);
|
|
21
|
+
console.log(`Durable records: ${database}`);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function shutdown() {
|
|
25
|
+
server.close(() => {
|
|
26
|
+
store.close();
|
|
27
|
+
process.exit(0);
|
|
28
|
+
});
|
|
29
|
+
// Live subscribers/waiters do not own service lifetime or the durable question.
|
|
30
|
+
server.closeAllConnections();
|
|
31
|
+
}
|
|
32
|
+
process.on('SIGINT', shutdown);
|
|
33
|
+
process.on('SIGTERM', shutdown);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Authored documents are deliberately separate from the host UI and response controls.
|
|
2
|
+
// Serve this policy as an HTTP header: sandbox/frame-ancestors do not work in a meta tag.
|
|
3
|
+
// CSP is not CPU isolation. Own-frame navigation is constrained by the embedding UI's frame-src.
|
|
4
|
+
export const PRESENTATION_CSP = [
|
|
5
|
+
"default-src 'none'",
|
|
6
|
+
"script-src 'unsafe-inline'",
|
|
7
|
+
"style-src 'unsafe-inline'",
|
|
8
|
+
'img-src data:',
|
|
9
|
+
'font-src data:',
|
|
10
|
+
'media-src data:',
|
|
11
|
+
"connect-src 'none'",
|
|
12
|
+
"frame-src 'none'",
|
|
13
|
+
"worker-src 'none'",
|
|
14
|
+
"object-src 'none'",
|
|
15
|
+
"base-uri 'none'",
|
|
16
|
+
"form-action 'none'",
|
|
17
|
+
"frame-ancestors 'self' http://127.0.0.1:* http://localhost:*",
|
|
18
|
+
'sandbox allow-scripts'
|
|
19
|
+
].join('; ');
|
|
20
|
+
|
|
21
|
+
// No host identity or response API is available to this bridge. A proposal is a
|
|
22
|
+
// draft; only the host's controls can decide whether to persist an answer.
|
|
23
|
+
const bridge = `<script>
|
|
24
|
+
(() => {
|
|
25
|
+
const send = window.parent.postMessage.bind(window.parent);
|
|
26
|
+
const stringify = JSON.stringify;
|
|
27
|
+
const parse = JSON.parse;
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
const encode = Function.call.bind(TextEncoder.prototype.encode);
|
|
30
|
+
const arrayIsArray = Array.isArray;
|
|
31
|
+
const Seen = WeakSet;
|
|
32
|
+
const weakSetHas = Function.call.bind(Seen.prototype.has);
|
|
33
|
+
const weakSetAdd = Function.call.bind(Seen.prototype.add);
|
|
34
|
+
const weakSetDelete = Function.call.bind(Seen.prototype.delete);
|
|
35
|
+
const objectKeys = Object.keys;
|
|
36
|
+
const objectPrototype = Object.prototype;
|
|
37
|
+
const getPrototypeOf = Object.getPrototypeOf;
|
|
38
|
+
const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors;
|
|
39
|
+
const ownKeys = Reflect.ownKeys;
|
|
40
|
+
const createObject = Object.create;
|
|
41
|
+
const defineProperty = Object.defineProperty;
|
|
42
|
+
const isFiniteNumber = Number.isFinite;
|
|
43
|
+
const hasOwn = Function.call.bind(objectPrototype.hasOwnProperty);
|
|
44
|
+
const validateJson = (value, seen = new Seen()) => {
|
|
45
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
46
|
+
if (typeof value === 'number') {
|
|
47
|
+
if (!isFiniteNumber(value)) throw new TypeError('Proposal values must contain only finite JSON numbers');
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value !== 'object') throw new TypeError('Proposal values must be JSON-compatible');
|
|
51
|
+
if (weakSetHas(seen, value)) throw new TypeError('Proposal values must not contain cycles');
|
|
52
|
+
const array = arrayIsArray(value);
|
|
53
|
+
const prototype = getPrototypeOf(value);
|
|
54
|
+
if (!array && prototype !== objectPrototype && prototype !== null) {
|
|
55
|
+
throw new TypeError('Proposal values must contain only JSON objects and arrays');
|
|
56
|
+
}
|
|
57
|
+
const descriptors = getOwnPropertyDescriptors(value);
|
|
58
|
+
for (const key of ownKeys(descriptors)) {
|
|
59
|
+
const descriptor = descriptors[key];
|
|
60
|
+
if (descriptor.get || descriptor.set) throw new TypeError('Proposal values must not define accessors');
|
|
61
|
+
}
|
|
62
|
+
if (typeof descriptors.toJSON?.value === 'function') {
|
|
63
|
+
throw new TypeError('Proposal values must not define custom toJSON behavior');
|
|
64
|
+
}
|
|
65
|
+
weakSetAdd(seen, value);
|
|
66
|
+
let copy;
|
|
67
|
+
if (array) {
|
|
68
|
+
copy = [];
|
|
69
|
+
// Never allow an authored Array.prototype.toJSON to transform the copy.
|
|
70
|
+
defineProperty(copy, 'toJSON', { value: undefined });
|
|
71
|
+
for (let index = 0; index < descriptors.length.value; index += 1) {
|
|
72
|
+
if (!hasOwn(descriptors, index)) throw new TypeError('Proposal arrays must not be sparse');
|
|
73
|
+
defineProperty(copy, index, { value: validateJson(descriptors[index].value, seen), enumerable: true, writable: true, configurable: true });
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
copy = createObject(null);
|
|
77
|
+
for (const key of objectKeys(descriptors)) {
|
|
78
|
+
if (!descriptors[key].enumerable) continue;
|
|
79
|
+
defineProperty(copy, key, { value: validateJson(descriptors[key].value, seen), enumerable: true, writable: true, configurable: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
weakSetDelete(seen, value);
|
|
83
|
+
return copy;
|
|
84
|
+
};
|
|
85
|
+
const propose = (values, summary) => {
|
|
86
|
+
if (summary !== undefined && (typeof summary !== 'string' || summary.length > 4096)) {
|
|
87
|
+
throw new TypeError('Proposal summary must be a string of at most 4096 characters');
|
|
88
|
+
}
|
|
89
|
+
const valuesSnapshot = validateJson(values);
|
|
90
|
+
const message = createObject(null);
|
|
91
|
+
message.type = 'threadroom:proposal';
|
|
92
|
+
message.values = valuesSnapshot;
|
|
93
|
+
if (summary !== undefined) message.summary = summary;
|
|
94
|
+
const json = stringify(message);
|
|
95
|
+
if (encode(encoder, json).length > 65536) throw new RangeError('Proposal exceeds 64 KiB');
|
|
96
|
+
const copy = parse(json);
|
|
97
|
+
if (!hasOwn(copy, 'values')) throw new TypeError('Proposal needs JSON values');
|
|
98
|
+
send(copy, '*');
|
|
99
|
+
};
|
|
100
|
+
Object.defineProperty(window, 'Threadroom', {
|
|
101
|
+
value: Object.freeze({propose}), writable: false, configurable: false
|
|
102
|
+
});
|
|
103
|
+
})();
|
|
104
|
+
</script>`;
|
|
105
|
+
|
|
106
|
+
export function renderPresentationDocument(presentation) {
|
|
107
|
+
if (presentation?.kind !== 'html-v1' || typeof presentation.html !== 'string') {
|
|
108
|
+
throw new TypeError('Expected an html-v1 presentation with an HTML document');
|
|
109
|
+
}
|
|
110
|
+
const html = presentation.html;
|
|
111
|
+
// Start the bridge before any authored scripts, preserving the document's
|
|
112
|
+
// doctype (and therefore standards mode). The parser merges the authored head.
|
|
113
|
+
const doctype = html.match(/^\s*<!doctype\s+html\b[^>]*>/i);
|
|
114
|
+
if (doctype) return `${doctype[0]}\n${bridge}\n${html.slice(doctype[0].length)}`;
|
|
115
|
+
return `<!doctype html>\n${bridge}\n${html}`;
|
|
116
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { streamEvents, waitForResponse } from './live.js';
|
|
3
|
+
import { renderPresentationDocument, PRESENTATION_CSP } from './presentations.js';
|
|
4
|
+
|
|
5
|
+
// The service has no website dependency. A static website handler can be injected for convenience.
|
|
6
|
+
export function createThreadroomServer(store, { websiteHandler = null, allowedOrigins = [], runtimeIdentity = {} } = {}) {
|
|
7
|
+
const origins = new Set(allowedOrigins);
|
|
8
|
+
return createServer(async (request, response) => {
|
|
9
|
+
try {
|
|
10
|
+
const url = new URL(request.url, 'http://threadroom.local');
|
|
11
|
+
const hostname = new URL(`http://${request.headers.host || 'unknown'}`).hostname;
|
|
12
|
+
if (!['localhost', '127.0.0.1', '[::1]'].includes(hostname)) return json(response, 403, { error: 'This local spike accepts loopback hostnames only' });
|
|
13
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
14
|
+
response.setHeader('Referrer-Policy', 'no-referrer');
|
|
15
|
+
const origin = request.headers.origin;
|
|
16
|
+
const sameOrigin = origin === `http://${request.headers.host}`;
|
|
17
|
+
const allowed = !origin || sameOrigin || origins.has(origin);
|
|
18
|
+
if (origin && origins.has(origin)) {
|
|
19
|
+
response.setHeader('Access-Control-Allow-Origin', origin);
|
|
20
|
+
response.setHeader('Vary', 'Origin');
|
|
21
|
+
}
|
|
22
|
+
if (request.method === 'OPTIONS') {
|
|
23
|
+
if (!allowed) return json(response, 403, { error: 'Browser origin is not allowed' });
|
|
24
|
+
response.writeHead(204, {
|
|
25
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
26
|
+
'Access-Control-Allow-Headers': 'Content-Type, Idempotency-Key',
|
|
27
|
+
'Access-Control-Max-Age': '60'
|
|
28
|
+
});
|
|
29
|
+
return response.end();
|
|
30
|
+
}
|
|
31
|
+
if (request.method === 'POST' && (!allowed || (request.headers['sec-fetch-site'] === 'cross-site' && !origins.has(origin)))) {
|
|
32
|
+
return json(response, 403, { error: 'Cross-origin browser writes are not permitted' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (url.pathname === '/api/health' && request.method === 'GET') return json(response, 200, {
|
|
36
|
+
ok: true, service: 'threadroom', apiVersion: 2, website: !!websiteHandler, ...runtimeIdentity
|
|
37
|
+
});
|
|
38
|
+
if (url.pathname === '/api/tree' && request.method === 'GET') {
|
|
39
|
+
const nodes = store.listNodes().map(({ presentation, response: savedResponse, body: savedBody, ...summary }) => ({
|
|
40
|
+
...summary, responseKind: savedResponse?.kind || null, hasPresentation: !!presentation
|
|
41
|
+
}));
|
|
42
|
+
return json(response, 200, { nodes });
|
|
43
|
+
}
|
|
44
|
+
if ((url.pathname === '/api/nodes' || url.pathname === '/api/ask') && request.method === 'POST') {
|
|
45
|
+
const input = await body(request);
|
|
46
|
+
// A blocking ask is still publication first, but malformed transport
|
|
47
|
+
// options must not turn a rejected request into a durable question.
|
|
48
|
+
const wait = url.pathname === '/api/ask' ? waitOptions(input) : null;
|
|
49
|
+
const published = store.createNode(input, request.headers['idempotency-key']);
|
|
50
|
+
if (wait) {
|
|
51
|
+
waitForResponse(request, response, store, published.node.id, { published, timeoutMs: wait.timeoutMs });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
return json(response, published.deduplicated ? 200 : 201, published);
|
|
55
|
+
}
|
|
56
|
+
const nodeMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)$/);
|
|
57
|
+
if (nodeMatch && request.method === 'GET') return json(response, 200, store.getNode(decodeURIComponent(nodeMatch[1])));
|
|
58
|
+
const respondMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/respond$/);
|
|
59
|
+
if (respondMatch && request.method === 'POST') {
|
|
60
|
+
const result = store.respond(decodeURIComponent(respondMatch[1]), await body(request), request.headers['idempotency-key']);
|
|
61
|
+
return json(response, result.deduplicated ? 200 : 201, result);
|
|
62
|
+
}
|
|
63
|
+
const presentationMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/presentation$/);
|
|
64
|
+
if (presentationMatch && request.method === 'GET') {
|
|
65
|
+
const { node } = store.getNode(decodeURIComponent(presentationMatch[1]));
|
|
66
|
+
if (node.presentation?.kind !== 'html-v1') return json(response, 404, { error: 'No authored HTML presentation on this thread' });
|
|
67
|
+
response.writeHead(200, {
|
|
68
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
69
|
+
'Content-Security-Policy': PRESENTATION_CSP,
|
|
70
|
+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
|
71
|
+
'Cache-Control': 'no-store'
|
|
72
|
+
});
|
|
73
|
+
response.end(renderPresentationDocument(node.presentation));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (url.pathname === '/api/stream' && request.method === 'GET') {
|
|
77
|
+
streamEvents(request, response, store, { after: Number(url.searchParams.get('after') || 0) });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (url.pathname === '/api/events' && request.method === 'GET') return json(response, 200, {
|
|
81
|
+
events: store.listEvents(Number(url.searchParams.get('after') || 0), url.searchParams.get('threadId'))
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// First-spike compatibility endpoints. All adapters use the same recursive records.
|
|
85
|
+
if (url.pathname === '/api/threads' && request.method === 'GET') return json(response, 200, { threads: store.listThreads(url.searchParams.get('view') || 'history') });
|
|
86
|
+
if (url.pathname === '/api/threads' && request.method === 'POST') return json(response, 201, { thread: store.createThread(await body(request)) });
|
|
87
|
+
const threadMatch = url.pathname.match(/^\/api\/threads\/([^/]+)$/);
|
|
88
|
+
if (threadMatch && request.method === 'GET') return json(response, 200, { thread: store.getThread(decodeURIComponent(threadMatch[1])) });
|
|
89
|
+
const questionMatch = url.pathname.match(/^\/api\/threads\/([^/]+)\/questions$/);
|
|
90
|
+
if (questionMatch && request.method === 'POST') return json(response, 201, { thread: store.addQuestion(decodeURIComponent(questionMatch[1]), await body(request)) });
|
|
91
|
+
const responseMatch = url.pathname.match(/^\/api\/questions\/([^/]+)\/responses$/);
|
|
92
|
+
if (responseMatch && request.method === 'POST') {
|
|
93
|
+
const result = store.addResponse(decodeURIComponent(responseMatch[1]), await body(request), request.headers['idempotency-key']);
|
|
94
|
+
return json(response, result.deduplicated ? 200 : 201, result);
|
|
95
|
+
}
|
|
96
|
+
if (url.pathname.startsWith('/api/')) return json(response, 404, { error: 'API route not found' });
|
|
97
|
+
if (websiteHandler) return await websiteHandler(request, response);
|
|
98
|
+
return json(response, 404, { error: 'API-only service; this host does not serve a website' });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const status = error.statusCode || (error.name === 'SyntaxError' ? 400 : 500);
|
|
101
|
+
if (status === 500) console.error(error);
|
|
102
|
+
return json(response, status, { error: status === 500 ? 'Internal server error' : error.message });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function body(request) {
|
|
108
|
+
const chunks = [];
|
|
109
|
+
let size = 0;
|
|
110
|
+
for await (const chunk of request) {
|
|
111
|
+
size += chunk.length;
|
|
112
|
+
if (size > 5_000_000) {
|
|
113
|
+
const error = new Error('Request body is too large'); error.statusCode = 413; throw error;
|
|
114
|
+
}
|
|
115
|
+
chunks.push(chunk);
|
|
116
|
+
}
|
|
117
|
+
const value = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {};
|
|
118
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw clientError('Request body must be a JSON object');
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function waitOptions(input) {
|
|
123
|
+
if (!Object.hasOwn(input, 'wait') || input.wait === undefined) return null;
|
|
124
|
+
const wait = input.wait;
|
|
125
|
+
if (!wait || typeof wait !== 'object' || Array.isArray(wait)) throw clientError('wait must be an object');
|
|
126
|
+
const timeoutMs = wait.timeoutMs ?? 60_000;
|
|
127
|
+
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
128
|
+
throw clientError('timeoutMs must be a non-negative number');
|
|
129
|
+
}
|
|
130
|
+
return { timeoutMs: Math.min(timeoutMs, 120_000) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function clientError(message) {
|
|
134
|
+
const error = new Error(message);
|
|
135
|
+
error.statusCode = 400;
|
|
136
|
+
return error;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function json(response, status, value) {
|
|
140
|
+
if (response.headersSent || response.destroyed) return;
|
|
141
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
142
|
+
response.end(JSON.stringify(value));
|
|
143
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const publicDir = fileURLToPath(new URL('../public/', import.meta.url));
|
|
6
|
+
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml' };
|
|
7
|
+
|
|
8
|
+
// Website hosting is optional plumbing. This module knows nothing about persistence or thread semantics.
|
|
9
|
+
export function createWebsiteHandler({ apiBaseUrl = '' } = {}) {
|
|
10
|
+
return async function website(request, response) {
|
|
11
|
+
const url = new URL(request.url, 'http://threadroom.local');
|
|
12
|
+
if (url.pathname === '/threadroom-config.json') {
|
|
13
|
+
response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
14
|
+
response.end(JSON.stringify({ apiBaseUrl }));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
18
|
+
response.writeHead(405); response.end('Method not allowed'); return;
|
|
19
|
+
}
|
|
20
|
+
// Thread IDs are one encoded URL segment, never filenames. Route them
|
|
21
|
+
// before decoding so traversal-looking or extension-like IDs cannot expose
|
|
22
|
+
// static assets or escape the public directory.
|
|
23
|
+
const threadRoute = /^\/threads\/[^/]+$/.test(url.pathname);
|
|
24
|
+
let filename;
|
|
25
|
+
if (threadRoute) {
|
|
26
|
+
filename = resolve(publicDir, 'index.html');
|
|
27
|
+
} else {
|
|
28
|
+
const requestedPath = url.pathname === '/' ? '/index.html' : url.pathname;
|
|
29
|
+
let decodedPath;
|
|
30
|
+
try { decodedPath = decodeURIComponent(requestedPath); }
|
|
31
|
+
catch { decodedPath = requestedPath; }
|
|
32
|
+
filename = resolve(publicDir, `.${decodedPath}`);
|
|
33
|
+
const fromPublic = relative(publicDir, filename);
|
|
34
|
+
if (fromPublic === '..' || fromPublic.startsWith(`..${sep}`) || isAbsolute(fromPublic)) {
|
|
35
|
+
response.writeHead(403); response.end('Forbidden'); return;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
if (!(await stat(filename)).isFile()) throw new Error('not a file');
|
|
39
|
+
} catch {
|
|
40
|
+
if (extname(url.pathname)) { response.writeHead(404); response.end('Not found'); return; }
|
|
41
|
+
filename = resolve(publicDir, 'index.html');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const bytes = await readFile(filename);
|
|
45
|
+
const apiOrigin = apiBaseUrl ? new URL(apiBaseUrl).origin : `http://${request.headers.host}`;
|
|
46
|
+
response.writeHead(200, {
|
|
47
|
+
'Content-Type': types[extname(filename)] || 'application/octet-stream',
|
|
48
|
+
'Cache-Control': 'no-cache',
|
|
49
|
+
'Content-Security-Policy': `default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' ${apiOrigin}; frame-src ${apiOrigin}/api/nodes/; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self'`
|
|
50
|
+
});
|
|
51
|
+
response.end(request.method === 'HEAD' ? undefined : bytes);
|
|
52
|
+
};
|
|
53
|
+
}
|