mdmaid-desk 0.1.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 +215 -0
- package/dist/api-client.d.ts +20 -0
- package/dist/api-client.js +264 -0
- package/dist/api-client.js.map +1 -0
- package/dist/api-types.d.ts +51 -0
- package/dist/api-types.js +2 -0
- package/dist/api-types.js.map +1 -0
- package/dist/auth-state.d.ts +2 -0
- package/dist/auth-state.js +113 -0
- package/dist/auth-state.js.map +1 -0
- package/dist/catalog.d.ts +46 -0
- package/dist/catalog.js +659 -0
- package/dist/catalog.js.map +1 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +370 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon-state.d.ts +17 -0
- package/dist/daemon-state.js +204 -0
- package/dist/daemon-state.js.map +1 -0
- package/dist/domain.d.ts +49 -0
- package/dist/domain.js +44 -0
- package/dist/domain.js.map +1 -0
- package/dist/fs-durability.d.ts +2 -0
- package/dist/fs-durability.js +35 -0
- package/dist/fs-durability.js.map +1 -0
- package/dist/package-info.d.ts +1 -0
- package/dist/package-info.js +17 -0
- package/dist/package-info.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +702 -0
- package/dist/server.js.map +1 -0
- package/dist/sqlite-storage.d.ts +17 -0
- package/dist/sqlite-storage.js +342 -0
- package/dist/sqlite-storage.js.map +1 -0
- package/dist/storage.d.ts +12 -0
- package/dist/storage.js +2 -0
- package/dist/storage.js.map +1 -0
- package/dist/terminal-text.d.ts +9 -0
- package/dist/terminal-text.js +156 -0
- package/dist/terminal-text.js.map +1 -0
- package/dist/tui.d.ts +63 -0
- package/dist/tui.js +912 -0
- package/dist/tui.js.map +1 -0
- package/dist/web-client.d.ts +21 -0
- package/dist/web-client.js +337 -0
- package/dist/web-client.js.map +1 -0
- package/dist/web-styles.d.ts +1 -0
- package/dist/web-styles.js +249 -0
- package/dist/web-styles.js.map +1 -0
- package/package.json +60 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createServer, } from "node:http";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { renderMarkdown } from "mdmaid";
|
|
8
|
+
import { renderMarkdownToTui } from "mdmaid/tui";
|
|
9
|
+
import sanitizeHtml from "sanitize-html";
|
|
10
|
+
import { WEB_STYLES } from "./web-styles.js";
|
|
11
|
+
import { sanitizeTerminalText } from "./terminal-text.js";
|
|
12
|
+
const API_VERSION = 1;
|
|
13
|
+
const MAX_JSON_BYTES = 64 * 1024;
|
|
14
|
+
const SESSION_COOKIE = "mdmaid_desk_session";
|
|
15
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
16
|
+
const WEB_CLIENT_PATH = fileURLToPath(new URL("./web-client.js", import.meta.url));
|
|
17
|
+
const MDMAID_ENTRY = fileURLToPath(import.meta.resolve("mdmaid"));
|
|
18
|
+
const MDMAID_ROOT = resolve(dirname(MDMAID_ENTRY), "../..");
|
|
19
|
+
const MDMAID_REQUIRE = createRequire(MDMAID_ENTRY);
|
|
20
|
+
const MERMAID_PATH = MDMAID_REQUIRE.resolve("mermaid/dist/mermaid.min.js");
|
|
21
|
+
const FONT_PATH = resolve(MDMAID_ROOT, "assets/fonts/DepartureMono-Regular.woff2");
|
|
22
|
+
const FAVICON_PATH = resolve(MDMAID_ROOT, "assets/icons/favicon.svg");
|
|
23
|
+
class HttpError extends Error {
|
|
24
|
+
status;
|
|
25
|
+
code;
|
|
26
|
+
constructor(status, code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
class EventHub {
|
|
33
|
+
#clients = new Set();
|
|
34
|
+
subscribe(request, response) {
|
|
35
|
+
response.statusCode = 200;
|
|
36
|
+
response.setHeader("content-type", "text/event-stream; charset=utf-8");
|
|
37
|
+
response.setHeader("connection", "keep-alive");
|
|
38
|
+
response.write("event: ready\ndata: {}\n\n");
|
|
39
|
+
this.#clients.add(response);
|
|
40
|
+
request.on("close", () => {
|
|
41
|
+
this.#clients.delete(response);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
publish(type, data) {
|
|
45
|
+
const payload = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
46
|
+
for (const client of this.#clients) {
|
|
47
|
+
client.write(payload);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
close() {
|
|
51
|
+
for (const client of this.#clients) {
|
|
52
|
+
client.end();
|
|
53
|
+
}
|
|
54
|
+
this.#clients.clear();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function startDeskServer(options) {
|
|
58
|
+
const host = options.host ?? "127.0.0.1";
|
|
59
|
+
const port = options.port ?? 0;
|
|
60
|
+
const publicOrigin = options.publicUrl === undefined
|
|
61
|
+
? undefined
|
|
62
|
+
: normalizePublicUrl(options.publicUrl);
|
|
63
|
+
const securePublicOrigin = publicOrigin?.startsWith("https://") ?? false;
|
|
64
|
+
if (!LOOPBACK_HOSTS.has(host)) {
|
|
65
|
+
throw new Error("mdmaid.desk server must bind to a loopback host");
|
|
66
|
+
}
|
|
67
|
+
if (!Number.isSafeInteger(port) || port < 0 || port > 65_535) {
|
|
68
|
+
throw new Error("server port must be an integer between 0 and 65535");
|
|
69
|
+
}
|
|
70
|
+
const token = options.token ?? randomBytes(32).toString("base64url");
|
|
71
|
+
if (typeof token !== "string" || token.length < 8) {
|
|
72
|
+
throw new Error("server token must contain at least 8 characters");
|
|
73
|
+
}
|
|
74
|
+
const events = new EventHub();
|
|
75
|
+
const server = createServer((request, response) => {
|
|
76
|
+
void handleRequest(request, response, options.catalog, token, events, publicOrigin, securePublicOrigin).catch((error) => {
|
|
77
|
+
if (response.headersSent) {
|
|
78
|
+
response.destroy();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const normalized = error instanceof HttpError
|
|
82
|
+
? error
|
|
83
|
+
: new HttpError(500, "internal_error", "Internal server error");
|
|
84
|
+
sendJson(response, normalized.status, {
|
|
85
|
+
error: { code: normalized.code, message: normalized.message },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
await listen(server, host, port);
|
|
90
|
+
const address = server.address();
|
|
91
|
+
const urlHost = address.address.includes(":")
|
|
92
|
+
? `[${address.address}]`
|
|
93
|
+
: address.address;
|
|
94
|
+
const url = `http://${urlHost}:${address.port}`;
|
|
95
|
+
return {
|
|
96
|
+
host: address.address,
|
|
97
|
+
port: address.port,
|
|
98
|
+
token,
|
|
99
|
+
url,
|
|
100
|
+
webUrl: `${publicOrigin ?? url}/?token=${encodeURIComponent(token)}`,
|
|
101
|
+
close: async () => {
|
|
102
|
+
events.close();
|
|
103
|
+
await closeServer(server);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
async function handleRequest(request, response, catalog, token, events, publicOrigin, securePublicOrigin) {
|
|
108
|
+
applySecurityHeaders(response, securePublicOrigin);
|
|
109
|
+
const baseUrl = publicOrigin ?? `http://${request.headers.host ?? "127.0.0.1"}`;
|
|
110
|
+
const url = new URL(request.url ?? "/", baseUrl);
|
|
111
|
+
if (request.method === "GET" &&
|
|
112
|
+
url.pathname === "/api/v1/health") {
|
|
113
|
+
sendJson(response, 200, {
|
|
114
|
+
data: { service: "mdmaid.desk", status: "ok", version: API_VERSION },
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (request.method === "GET" &&
|
|
119
|
+
(url.pathname === "/" ||
|
|
120
|
+
/^\/d\/doc-[a-f0-9]{20}$/.test(url.pathname) ||
|
|
121
|
+
/^\/w\/[a-z0-9][a-z0-9-]{0,63}$/.test(url.pathname)) &&
|
|
122
|
+
url.searchParams.has("token")) {
|
|
123
|
+
const candidate = url.searchParams.get("token") ?? "";
|
|
124
|
+
if (!safeEqual(candidate, token)) {
|
|
125
|
+
throw new HttpError(401, "unauthorized", "Authentication required");
|
|
126
|
+
}
|
|
127
|
+
response.statusCode = 303;
|
|
128
|
+
response.setHeader("location", url.pathname);
|
|
129
|
+
response.setHeader("set-cookie", `${SESSION_COOKIE}=${encodeURIComponent(token)}; HttpOnly;${securePublicOrigin ? " Secure;" : ""} SameSite=Strict; Path=/; Max-Age=86400`);
|
|
130
|
+
response.end();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const auth = authenticate(request, token);
|
|
134
|
+
if (!auth) {
|
|
135
|
+
throw new HttpError(401, "unauthorized", "Authentication required");
|
|
136
|
+
}
|
|
137
|
+
authorizeOrigin(request, auth, baseUrl);
|
|
138
|
+
if (request.method === "GET" &&
|
|
139
|
+
(url.pathname === "/" ||
|
|
140
|
+
/^\/d\/doc-[a-f0-9]{20}$/.test(url.pathname) ||
|
|
141
|
+
/^\/w\/[a-z0-9][a-z0-9-]{0,63}$/.test(url.pathname))) {
|
|
142
|
+
sendHtml(response, 200, workspaceHtml(url.pathname));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (request.method === "GET" && url.pathname === "/api/v1/events") {
|
|
146
|
+
events.subscribe(request, response);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (request.method === "GET" && url.pathname.startsWith("/assets/")) {
|
|
150
|
+
await serveAsset(response, url.pathname);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (request.method === "GET" && url.pathname === "/api/v1/workspaces") {
|
|
154
|
+
const documents = catalog.listDocuments();
|
|
155
|
+
sendJson(response, 200, {
|
|
156
|
+
data: catalog.listWorkspaces().map((workspace) => ({
|
|
157
|
+
id: workspace.id,
|
|
158
|
+
name: workspace.name,
|
|
159
|
+
documentCount: documents.filter(({ workspaceId }) => workspaceId === workspace.id).length,
|
|
160
|
+
route: `/w/${workspace.id}`,
|
|
161
|
+
})),
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (request.method === "GET" && url.pathname === "/api/v1/documents") {
|
|
166
|
+
const filters = parseDocumentFilters(url.searchParams);
|
|
167
|
+
sendJson(response, 200, {
|
|
168
|
+
data: catalog.listDocuments(filters).map(publicDocument),
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (request.method === "POST" && url.pathname === "/api/v1/documents") {
|
|
173
|
+
const body = await readJson(request);
|
|
174
|
+
if (!isDocumentRegistration(body)) {
|
|
175
|
+
throw new HttpError(422, "validation_error", "Invalid document registration");
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const document = await catalog.registerDocument(body);
|
|
179
|
+
response.setHeader("location", `/api/v1/documents/${document.id}`);
|
|
180
|
+
sendJson(response, 201, { data: publicDocument(document) });
|
|
181
|
+
events.publish("catalog", { action: "registered", documentId: document.id });
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error instanceof Error) {
|
|
185
|
+
throw new HttpError(422, "validation_error", "Invalid document registration");
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const documentMatch = url.pathname.match(/^\/api\/v1\/documents\/(doc-[a-f0-9]{20})$/);
|
|
192
|
+
if (request.method === "GET" && documentMatch) {
|
|
193
|
+
const document = catalog.getDocument(documentMatch[1] ?? "");
|
|
194
|
+
if (!document) {
|
|
195
|
+
throw new HttpError(404, "not_found", "Document not found");
|
|
196
|
+
}
|
|
197
|
+
sendJson(response, 200, { data: publicDocument(document) });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const renderMatch = url.pathname.match(/^\/api\/v1\/documents\/(doc-[a-f0-9]{20})\/render$/);
|
|
201
|
+
if (request.method === "GET" && renderMatch) {
|
|
202
|
+
const id = renderMatch[1] ?? "";
|
|
203
|
+
const target = url.searchParams.get("target");
|
|
204
|
+
if (target !== "web" && target !== "terminal") {
|
|
205
|
+
throw new HttpError(400, "invalid_target", "Unknown render target");
|
|
206
|
+
}
|
|
207
|
+
const { content, document } = await readDocument(catalog, id);
|
|
208
|
+
if (target === "web") {
|
|
209
|
+
const rendered = sanitizeRenderedHtml(await renderMarkdown(content, { sanitize: false }));
|
|
210
|
+
sendJson(response, 200, {
|
|
211
|
+
data: {
|
|
212
|
+
document: publicDocument(document),
|
|
213
|
+
target,
|
|
214
|
+
content: rendered,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const width = parseWidth(url.searchParams.get("width"));
|
|
220
|
+
const color = parseRenderBoolean(url.searchParams.get("color"), "color", false);
|
|
221
|
+
const unicode = parseRenderBoolean(url.searchParams.get("unicode"), "unicode", true);
|
|
222
|
+
const rendered = await renderMarkdownToTui(content, {
|
|
223
|
+
backend: "beautiful-mermaid",
|
|
224
|
+
color,
|
|
225
|
+
unicode,
|
|
226
|
+
width,
|
|
227
|
+
});
|
|
228
|
+
sendJson(response, 200, {
|
|
229
|
+
data: {
|
|
230
|
+
document: publicDocument(document),
|
|
231
|
+
target,
|
|
232
|
+
content: sanitizeTerminalText(rendered.output, { preserveSgr: true }),
|
|
233
|
+
backend: rendered.backend,
|
|
234
|
+
warnings: rendered.warnings.map((warning) => sanitizeTerminalText(warning)),
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const actionMatch = url.pathname.match(/^\/api\/v1\/documents\/(doc-[a-f0-9]{20})\/(opened|read|unread|archive|restore|missing|present)$/);
|
|
240
|
+
if (request.method === "POST" && actionMatch) {
|
|
241
|
+
const id = actionMatch[1] ?? "";
|
|
242
|
+
const action = actionMatch[2] ?? "";
|
|
243
|
+
const actions = {
|
|
244
|
+
opened: () => catalog.markDocumentOpened(id),
|
|
245
|
+
read: () => catalog.markDocumentRead(id),
|
|
246
|
+
unread: () => catalog.markDocumentUnread(id),
|
|
247
|
+
archive: () => catalog.archiveDocument(id),
|
|
248
|
+
restore: () => catalog.restoreDocument(id),
|
|
249
|
+
missing: () => catalog.markDocumentMissing(id),
|
|
250
|
+
present: () => catalog.markDocumentPresent(id),
|
|
251
|
+
};
|
|
252
|
+
const operation = actions[action];
|
|
253
|
+
if (!operation) {
|
|
254
|
+
throw new HttpError(404, "not_found", "Action not found");
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const document = await operation();
|
|
258
|
+
sendJson(response, 200, { data: publicDocument(document) });
|
|
259
|
+
events.publish("catalog", {
|
|
260
|
+
action,
|
|
261
|
+
documentId: document.id,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
throw mapCatalogError(error);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const tagsMatch = url.pathname.match(/^\/api\/v1\/documents\/(doc-[a-f0-9]{20})\/tags$/);
|
|
270
|
+
if (request.method === "PUT" && tagsMatch) {
|
|
271
|
+
const body = await readJson(request);
|
|
272
|
+
if (!isRecord(body) ||
|
|
273
|
+
!hasOnlyKeys(body, ["tags"]) ||
|
|
274
|
+
!Array.isArray(body.tags) ||
|
|
275
|
+
!body.tags.every((tag) => typeof tag === "string")) {
|
|
276
|
+
throw new HttpError(422, "validation_error", "Invalid document tags");
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const document = await catalog.setDocumentTags(tagsMatch[1] ?? "", body.tags);
|
|
280
|
+
sendJson(response, 200, { data: publicDocument(document) });
|
|
281
|
+
events.publish("catalog", {
|
|
282
|
+
action: "tags",
|
|
283
|
+
documentId: document.id,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
throw mapCatalogError(error);
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
throw new HttpError(404, "not_found", "Route not found");
|
|
292
|
+
}
|
|
293
|
+
export function normalizePublicUrl(value) {
|
|
294
|
+
let url;
|
|
295
|
+
try {
|
|
296
|
+
url = new URL(value);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
throw new Error("public URL must be an HTTP or HTTPS .localhost origin");
|
|
300
|
+
}
|
|
301
|
+
if (!["http:", "https:"].includes(url.protocol) ||
|
|
302
|
+
(url.hostname !== "localhost" && !url.hostname.endsWith(".localhost")) ||
|
|
303
|
+
url.username !== "" ||
|
|
304
|
+
url.password !== "" ||
|
|
305
|
+
url.pathname !== "/" ||
|
|
306
|
+
url.search !== "" ||
|
|
307
|
+
url.hash !== "") {
|
|
308
|
+
throw new Error("public URL must be an HTTP or HTTPS .localhost origin");
|
|
309
|
+
}
|
|
310
|
+
return url.origin;
|
|
311
|
+
}
|
|
312
|
+
function authenticate(request, expectedToken) {
|
|
313
|
+
const authorization = request.headers.authorization;
|
|
314
|
+
if (authorization?.startsWith("Bearer ") &&
|
|
315
|
+
safeEqual(authorization.slice("Bearer ".length), expectedToken)) {
|
|
316
|
+
return { method: "bearer" };
|
|
317
|
+
}
|
|
318
|
+
const cookies = parseCookies(request.headers.cookie ?? "");
|
|
319
|
+
if (safeEqual(cookies.get(SESSION_COOKIE) ?? "", expectedToken)) {
|
|
320
|
+
return { method: "cookie" };
|
|
321
|
+
}
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
function authorizeOrigin(request, auth, baseUrl) {
|
|
325
|
+
if (auth.method === "cookie" &&
|
|
326
|
+
!["GET", "HEAD", "OPTIONS"].includes(request.method ?? "") &&
|
|
327
|
+
request.headers.origin !== baseUrl) {
|
|
328
|
+
throw new HttpError(403, "forbidden_origin", "Request origin is not allowed");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function parseCookies(header) {
|
|
332
|
+
const cookies = new Map();
|
|
333
|
+
for (const entry of header.split(";")) {
|
|
334
|
+
const separator = entry.indexOf("=");
|
|
335
|
+
if (separator <= 0) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const name = entry.slice(0, separator).trim();
|
|
339
|
+
const value = entry.slice(separator + 1).trim();
|
|
340
|
+
try {
|
|
341
|
+
cookies.set(name, decodeURIComponent(value));
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return cookies;
|
|
348
|
+
}
|
|
349
|
+
function safeEqual(candidate, expected) {
|
|
350
|
+
const left = Buffer.from(candidate);
|
|
351
|
+
const right = Buffer.from(expected);
|
|
352
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
353
|
+
}
|
|
354
|
+
function parseDocumentFilters(search) {
|
|
355
|
+
const allowed = new Set([
|
|
356
|
+
"workspace",
|
|
357
|
+
"task",
|
|
358
|
+
"tag",
|
|
359
|
+
"status",
|
|
360
|
+
"kind",
|
|
361
|
+
"attention",
|
|
362
|
+
"archived",
|
|
363
|
+
"missing",
|
|
364
|
+
]);
|
|
365
|
+
for (const key of search.keys()) {
|
|
366
|
+
if (!allowed.has(key)) {
|
|
367
|
+
throw new HttpError(400, "invalid_filter", `Unknown filter ${key}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const boolean = (key) => {
|
|
371
|
+
const value = search.get(key);
|
|
372
|
+
if (value === null) {
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
if (value === "true") {
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
if (value === "false") {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
throw new HttpError(400, "invalid_filter", `Invalid ${key} filter`);
|
|
382
|
+
};
|
|
383
|
+
const value = (key) => search.get(key) ?? undefined;
|
|
384
|
+
return {
|
|
385
|
+
...(value("workspace") === undefined
|
|
386
|
+
? {}
|
|
387
|
+
: { workspaceId: value("workspace") }),
|
|
388
|
+
...(value("task") === undefined ? {} : { taskId: value("task") }),
|
|
389
|
+
...(value("tag") === undefined ? {} : { tag: value("tag") }),
|
|
390
|
+
...(value("status") === undefined
|
|
391
|
+
? {}
|
|
392
|
+
: { status: value("status") }),
|
|
393
|
+
...(value("kind") === undefined
|
|
394
|
+
? {}
|
|
395
|
+
: { kind: value("kind") }),
|
|
396
|
+
...(value("attention") === undefined
|
|
397
|
+
? {}
|
|
398
|
+
: { attention: value("attention") }),
|
|
399
|
+
...(boolean("archived") === undefined
|
|
400
|
+
? {}
|
|
401
|
+
: { archived: boolean("archived") }),
|
|
402
|
+
...(boolean("missing") === undefined ? {} : { missing: boolean("missing") }),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
function parseWidth(value) {
|
|
406
|
+
if (value === null) {
|
|
407
|
+
return 100;
|
|
408
|
+
}
|
|
409
|
+
const width = Number(value);
|
|
410
|
+
if (!Number.isSafeInteger(width) || width < 20 || width > 1_000) {
|
|
411
|
+
throw new HttpError(400, "invalid_width", "Width must be between 20 and 1000");
|
|
412
|
+
}
|
|
413
|
+
return width;
|
|
414
|
+
}
|
|
415
|
+
function parseRenderBoolean(value, name, fallback) {
|
|
416
|
+
if (value === null) {
|
|
417
|
+
return fallback;
|
|
418
|
+
}
|
|
419
|
+
if (value === "true") {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
if (value === "false") {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
throw new HttpError(400, "invalid_render_option", `${name} must be true or false`);
|
|
426
|
+
}
|
|
427
|
+
async function readDocument(catalog, id) {
|
|
428
|
+
try {
|
|
429
|
+
return await catalog.readDocument(id);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
throw mapCatalogError(error);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function mapCatalogError(error) {
|
|
436
|
+
if (error instanceof Error && error.message.startsWith("unknown document")) {
|
|
437
|
+
return new HttpError(404, "not_found", "Document not found");
|
|
438
|
+
}
|
|
439
|
+
if (error instanceof Error) {
|
|
440
|
+
return new HttpError(422, "validation_error", error.message);
|
|
441
|
+
}
|
|
442
|
+
return new HttpError(500, "internal_error", "Internal server error");
|
|
443
|
+
}
|
|
444
|
+
function publicDocument(document) {
|
|
445
|
+
return {
|
|
446
|
+
id: document.id,
|
|
447
|
+
workspaceId: document.workspaceId,
|
|
448
|
+
...(document.taskId === undefined ? {} : { taskId: document.taskId }),
|
|
449
|
+
...(document.producer === undefined ? {} : { producer: document.producer }),
|
|
450
|
+
kind: document.kind,
|
|
451
|
+
title: document.title,
|
|
452
|
+
attention: document.attention,
|
|
453
|
+
tags: document.tags,
|
|
454
|
+
revision: document.revision,
|
|
455
|
+
openedRevision: document.openedRevision,
|
|
456
|
+
completedRevision: document.completedRevision,
|
|
457
|
+
status: document.status,
|
|
458
|
+
archivedAt: document.archivedAt,
|
|
459
|
+
missingAt: document.missingAt,
|
|
460
|
+
createdAt: document.createdAt,
|
|
461
|
+
updatedAt: document.updatedAt,
|
|
462
|
+
route: `/d/${document.id}`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function sanitizeRenderedHtml(content) {
|
|
466
|
+
return sanitizeHtml(content, {
|
|
467
|
+
allowedTags: [
|
|
468
|
+
...sanitizeHtml.defaults.allowedTags,
|
|
469
|
+
"div",
|
|
470
|
+
"img",
|
|
471
|
+
],
|
|
472
|
+
allowedAttributes: {
|
|
473
|
+
a: ["href", "id", "tabindex", "aria-hidden", "title"],
|
|
474
|
+
code: ["class"],
|
|
475
|
+
div: ["class"],
|
|
476
|
+
h1: ["id"],
|
|
477
|
+
h2: ["id"],
|
|
478
|
+
h3: ["id"],
|
|
479
|
+
h4: ["id"],
|
|
480
|
+
h5: ["id"],
|
|
481
|
+
h6: ["id"],
|
|
482
|
+
img: ["src", "alt", "title", "width", "height"],
|
|
483
|
+
span: ["class", "aria-hidden"],
|
|
484
|
+
},
|
|
485
|
+
allowedClasses: {
|
|
486
|
+
code: [/^language-[a-z0-9_-]+$/],
|
|
487
|
+
div: ["mermaid"],
|
|
488
|
+
span: ["icon", "icon-link"],
|
|
489
|
+
},
|
|
490
|
+
allowedSchemes: ["http", "https", "mailto"],
|
|
491
|
+
allowedSchemesByTag: {
|
|
492
|
+
img: ["http", "https", "data"],
|
|
493
|
+
},
|
|
494
|
+
allowProtocolRelative: false,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
async function readJson(request) {
|
|
498
|
+
const chunks = [];
|
|
499
|
+
let total = 0;
|
|
500
|
+
for await (const chunk of request) {
|
|
501
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
502
|
+
total += buffer.length;
|
|
503
|
+
if (total > MAX_JSON_BYTES) {
|
|
504
|
+
throw new HttpError(413, "payload_too_large", "Request body is too large");
|
|
505
|
+
}
|
|
506
|
+
chunks.push(buffer);
|
|
507
|
+
}
|
|
508
|
+
if (chunks.length === 0) {
|
|
509
|
+
return {};
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
throw new HttpError(400, "invalid_json", "Request body must be valid JSON");
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function isDocumentRegistration(value) {
|
|
519
|
+
if (!isRecord(value)) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
const required = [
|
|
523
|
+
"workspaceId",
|
|
524
|
+
"kind",
|
|
525
|
+
"title",
|
|
526
|
+
"path",
|
|
527
|
+
"attention",
|
|
528
|
+
];
|
|
529
|
+
return (hasOnlyKeys(value, [...required, "taskId", "producer", "tags"]) &&
|
|
530
|
+
required.every((key) => typeof value[key] === "string") &&
|
|
531
|
+
(value.taskId === undefined || typeof value.taskId === "string") &&
|
|
532
|
+
(value.producer === undefined || typeof value.producer === "string") &&
|
|
533
|
+
(value.tags === undefined ||
|
|
534
|
+
(Array.isArray(value.tags) &&
|
|
535
|
+
value.tags.every((tag) => typeof tag === "string"))));
|
|
536
|
+
}
|
|
537
|
+
function isRecord(value) {
|
|
538
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
539
|
+
}
|
|
540
|
+
function hasOnlyKeys(value, allowed) {
|
|
541
|
+
const allowedSet = new Set(allowed);
|
|
542
|
+
return Object.keys(value).every((key) => allowedSet.has(key));
|
|
543
|
+
}
|
|
544
|
+
function applySecurityHeaders(response, securePublicOrigin) {
|
|
545
|
+
response.setHeader("content-security-policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
|
|
546
|
+
response.setHeader("x-content-type-options", "nosniff");
|
|
547
|
+
response.setHeader("x-frame-options", "DENY");
|
|
548
|
+
response.setHeader("referrer-policy", "no-referrer");
|
|
549
|
+
response.setHeader("cache-control", "no-store");
|
|
550
|
+
if (securePublicOrigin) {
|
|
551
|
+
response.setHeader("strict-transport-security", "max-age=31536000");
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function sendJson(response, status, body) {
|
|
555
|
+
response.statusCode = status;
|
|
556
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
557
|
+
response.end(JSON.stringify(body));
|
|
558
|
+
}
|
|
559
|
+
function sendHtml(response, status, body) {
|
|
560
|
+
response.statusCode = status;
|
|
561
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
562
|
+
response.end(body);
|
|
563
|
+
}
|
|
564
|
+
async function serveAsset(response, pathname) {
|
|
565
|
+
if (pathname === "/assets/app.css") {
|
|
566
|
+
sendAsset(response, "text/css; charset=utf-8", WEB_STYLES);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (pathname === "/assets/app.js") {
|
|
570
|
+
sendAsset(response, "text/javascript; charset=utf-8", await readFile(WEB_CLIENT_PATH));
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (pathname === "/assets/mermaid.min.js") {
|
|
574
|
+
sendAsset(response, "text/javascript; charset=utf-8", await readFile(MERMAID_PATH));
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (pathname === "/assets/departure-mono.woff2") {
|
|
578
|
+
sendAsset(response, "font/woff2", await readFile(FONT_PATH));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (pathname === "/assets/favicon.svg") {
|
|
582
|
+
sendAsset(response, "image/svg+xml", await readFile(FAVICON_PATH));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
throw new HttpError(404, "not_found", "Asset not found");
|
|
586
|
+
}
|
|
587
|
+
function sendAsset(response, contentType, body) {
|
|
588
|
+
response.statusCode = 200;
|
|
589
|
+
response.setHeader("content-type", contentType);
|
|
590
|
+
response.setHeader("cache-control", "private, max-age=300");
|
|
591
|
+
response.end(body);
|
|
592
|
+
}
|
|
593
|
+
function workspaceHtml(pathname) {
|
|
594
|
+
const documentId = pathname.startsWith("/d/") ? pathname.slice(3) : "";
|
|
595
|
+
const workspaceId = pathname.startsWith("/w/") ? pathname.slice(3) : "";
|
|
596
|
+
return `<!doctype html>
|
|
597
|
+
<html lang="en">
|
|
598
|
+
<head>
|
|
599
|
+
<meta charset="utf-8">
|
|
600
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
601
|
+
<title>mdmaid.desk</title>
|
|
602
|
+
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
|
603
|
+
<link rel="preload" href="/assets/departure-mono.woff2" as="font" type="font/woff2" crossorigin>
|
|
604
|
+
<link rel="stylesheet" href="/assets/app.css">
|
|
605
|
+
<script defer src="/assets/mermaid.min.js"></script>
|
|
606
|
+
<script type="module" src="/assets/app.js"></script>
|
|
607
|
+
</head>
|
|
608
|
+
<body data-document-id="${documentId}" data-workspace-id="${workspaceId}">
|
|
609
|
+
<header class="topbar">
|
|
610
|
+
<div class="brand">
|
|
611
|
+
<strong>mdmaid.desk</strong>
|
|
612
|
+
<span>document workspace</span>
|
|
613
|
+
</div>
|
|
614
|
+
<div class="top-actions">
|
|
615
|
+
<span id="live-status" class="live offline">○ connecting</span>
|
|
616
|
+
<button id="theme-toggle" class="icon-button" type="button" aria-label="Toggle theme">◐</button>
|
|
617
|
+
</div>
|
|
618
|
+
</header>
|
|
619
|
+
<div class="workspace">
|
|
620
|
+
<aside class="sidebar">
|
|
621
|
+
<h2>projects</h2>
|
|
622
|
+
<nav id="project-nav" class="project-nav" data-testid="project-nav"></nav>
|
|
623
|
+
<div class="shortcut-card">
|
|
624
|
+
<div><kbd>/</kbd> search</div>
|
|
625
|
+
<div><kbd>j</kbd> <kbd>k</kbd> scroll</div>
|
|
626
|
+
<div><kbd>m</kbd> mark read</div>
|
|
627
|
+
<div><kbd>u</kbd> unread</div>
|
|
628
|
+
<div><kbd>b</kbd> back</div>
|
|
629
|
+
</div>
|
|
630
|
+
</aside>
|
|
631
|
+
<main class="main">
|
|
632
|
+
<section id="queue-panel">
|
|
633
|
+
<div class="queue-header">
|
|
634
|
+
<div class="queue-title-row">
|
|
635
|
+
<div>
|
|
636
|
+
<span class="eyebrow">persistent reading queue</span>
|
|
637
|
+
<h1>What needs your eyes?</h1>
|
|
638
|
+
</div>
|
|
639
|
+
<p>opening means reading · only you mark done</p>
|
|
640
|
+
</div>
|
|
641
|
+
<div class="controls">
|
|
642
|
+
<input id="search" class="search" type="search" placeholder="search title, task, tag, producer…" autocomplete="off">
|
|
643
|
+
<div class="status-filters" aria-label="Reading status">
|
|
644
|
+
<button class="status-filter active" type="button" data-status-filter="all">all <span class="count">0</span></button>
|
|
645
|
+
<button class="status-filter" type="button" data-status-filter="unread">unread <span class="count">0</span></button>
|
|
646
|
+
<button class="status-filter" type="button" data-status-filter="reading">reading <span class="count">0</span></button>
|
|
647
|
+
<button class="status-filter" type="button" data-status-filter="done">done <span class="count">0</span></button>
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
<div id="document-queue" class="document-queue" data-testid="document-queue"></div>
|
|
652
|
+
<div id="queue-empty" class="empty" hidden>No documents match this view.</div>
|
|
653
|
+
</section>
|
|
654
|
+
<article id="document-reader" class="reader" data-testid="document-reader" hidden>
|
|
655
|
+
<div class="reader-toolbar">
|
|
656
|
+
<button id="reader-back" class="action" type="button">← queue</button>
|
|
657
|
+
<div class="reader-actions">
|
|
658
|
+
<button id="mark-read" class="action" type="button">✓ mark read</button>
|
|
659
|
+
<button id="mark-unread" class="action" type="button">○ unread</button>
|
|
660
|
+
<button id="archive" class="action" type="button">archive</button>
|
|
661
|
+
</div>
|
|
662
|
+
</div>
|
|
663
|
+
<header class="reader-heading">
|
|
664
|
+
<span class="eyebrow">document</span>
|
|
665
|
+
<h1 id="reader-title">Document</h1>
|
|
666
|
+
<p id="reader-meta"></p>
|
|
667
|
+
</header>
|
|
668
|
+
<div id="reader-content" class="reader-content"></div>
|
|
669
|
+
</article>
|
|
670
|
+
</main>
|
|
671
|
+
</div>
|
|
672
|
+
</body>
|
|
673
|
+
</html>`;
|
|
674
|
+
}
|
|
675
|
+
function listen(server, host, port) {
|
|
676
|
+
return new Promise((resolvePromise, reject) => {
|
|
677
|
+
const onError = (error) => {
|
|
678
|
+
server.off("listening", onListening);
|
|
679
|
+
reject(error);
|
|
680
|
+
};
|
|
681
|
+
const onListening = () => {
|
|
682
|
+
server.off("error", onError);
|
|
683
|
+
resolvePromise();
|
|
684
|
+
};
|
|
685
|
+
server.once("error", onError);
|
|
686
|
+
server.once("listening", onListening);
|
|
687
|
+
server.listen(port, host);
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
function closeServer(server) {
|
|
691
|
+
return new Promise((resolvePromise, reject) => {
|
|
692
|
+
server.close((error) => {
|
|
693
|
+
if (error) {
|
|
694
|
+
reject(error);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
resolvePromise();
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
//# sourceMappingURL=server.js.map
|