@gmickel/gno 1.29.5 → 1.30.1
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/README.md +15 -5
- package/browser-extension/artifacts/{gno-browser-clipper-v1.29.5.zip → gno-browser-clipper-v1.30.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.30.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +4 -1
- package/src/core/network-boundary-inventory.ts +51 -0
- package/src/ingestion/index.ts +1 -1
- package/src/ingestion/walker.ts +45 -23
- package/src/serve/AGENTS.md +5 -1
- package/src/serve/CLAUDE.md +5 -1
- package/src/serve/fn112-routes.ts +232 -0
- package/src/serve/pdfjs-assets.ts +391 -0
- package/src/serve/public/components/pdf/PdfPageView.tsx +427 -0
- package/src/serve/public/components/pdf/PdfToolbar.tsx +384 -0
- package/src/serve/public/components/pdf/PdfViewer.tsx +539 -0
- package/src/serve/public/components/pdf/pdf-viewer-deps.tsx +94 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/globals.css +113 -0
- package/src/serve/public/hooks/use-pdf-document.ts +227 -0
- package/src/serve/public/hooks/use-pdf-pages.ts +1197 -0
- package/src/serve/public/lib/doc-asset-url.ts +57 -0
- package/src/serve/public/lib/math-sum-precise.ts +34 -0
- package/src/serve/public/lib/pdf.ts +772 -0
- package/src/serve/public/pages/DocView.tsx +295 -39
- package/src/serve/public/pages/doc-pdf-viewer.tsx +7 -0
- package/src/serve/routes/api.ts +154 -14
- package/src/serve/server.ts +190 -37
- package/src/serve/spa-bundle-source.ts +99 -0
- package/src/serve/watch-service.ts +219 -23
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.5.zip.sha256 +0 -1
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-origin serving of pinned pdfjs-dist assets (worker, cMaps, standard fonts).
|
|
3
|
+
*
|
|
4
|
+
* Security model (I1-01 / Sol):
|
|
5
|
+
* - Independently resolve and canonicalize the installed pdfjs-dist package root.
|
|
6
|
+
* - Every candidate (worker / cMap / font) must realpath inside that package root
|
|
7
|
+
* AND inside the expected subdirectory (build/, cmaps/, standard_fonts/).
|
|
8
|
+
* - Never trust an injectable path resolver as both candidate and root authority.
|
|
9
|
+
* - Fail closed on package-root resolution failure and non-ENOENT realpath errors.
|
|
10
|
+
* - Lazy per-request resolve so a broken install degrades to 404, never startup crash.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// node:fs/promises — no Bun equivalent for realpath
|
|
14
|
+
import { realpath } from "node:fs/promises";
|
|
15
|
+
// node:path — no Bun equivalent for path resolution / containment
|
|
16
|
+
import nodePath from "node:path";
|
|
17
|
+
|
|
18
|
+
/** cMaps allowlist: packed binary cMaps only (Sol N18). */
|
|
19
|
+
const CMAP_EXTENSIONS = new Set([".bcmap"]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Standard-font extensions actually shipped by pdfjs-dist@5.7.284:
|
|
23
|
+
* Foxit*.pfb and LiberationSans*.ttf (LICENSE_* files are not served).
|
|
24
|
+
*/
|
|
25
|
+
const STANDARD_FONT_EXTENSIONS = new Set([".pfb", ".ttf"]);
|
|
26
|
+
|
|
27
|
+
/** Immutable long-cache for version-pinned package assets (I1-01). */
|
|
28
|
+
export const PDFJS_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
29
|
+
|
|
30
|
+
export type PdfjsAssetKind = "worker" | "cmaps" | "standard_fonts";
|
|
31
|
+
|
|
32
|
+
export type RealpathFn = (path: string) => Promise<string>;
|
|
33
|
+
|
|
34
|
+
function error404(message: string): Response {
|
|
35
|
+
return Response.json(
|
|
36
|
+
{
|
|
37
|
+
error: {
|
|
38
|
+
code: "NOT_FOUND",
|
|
39
|
+
message,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{ status: 404 }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function pdfjsFileUrlToPath(url: string): string {
|
|
47
|
+
return url.startsWith("file:") ? Bun.fileURLToPath(url) : url;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Independently resolve the installed pdfjs-dist package root via package.json,
|
|
52
|
+
* then canonicalize with realpath. Fail closed (null) on any error.
|
|
53
|
+
*/
|
|
54
|
+
export async function resolvePdfjsPackageRoot(
|
|
55
|
+
realpathFn: RealpathFn = realpath
|
|
56
|
+
): Promise<string | null> {
|
|
57
|
+
try {
|
|
58
|
+
const pkgUrl = import.meta.resolve("pdfjs-dist/package.json");
|
|
59
|
+
const pkgPath = pdfjsFileUrlToPath(pkgUrl);
|
|
60
|
+
const root = nodePath.dirname(pkgPath);
|
|
61
|
+
return await realpathFn(root);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Expected absolute subdirectory under a package root for each asset kind.
|
|
69
|
+
*/
|
|
70
|
+
export function expectedSubdirForKind(
|
|
71
|
+
packageRoot: string,
|
|
72
|
+
kind: PdfjsAssetKind
|
|
73
|
+
): string {
|
|
74
|
+
if (kind === "worker") {
|
|
75
|
+
return nodePath.join(packageRoot, "build");
|
|
76
|
+
}
|
|
77
|
+
if (kind === "cmaps") {
|
|
78
|
+
return nodePath.join(packageRoot, "cmaps");
|
|
79
|
+
}
|
|
80
|
+
return nodePath.join(packageRoot, "standard_fonts");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate a single path segment for cmap/font filenames.
|
|
85
|
+
* Rejects empty, multi-segment, `..`, and disallowed extensions.
|
|
86
|
+
*/
|
|
87
|
+
export function isSafePdfjsAssetFilename(
|
|
88
|
+
file: string,
|
|
89
|
+
kind: "cmaps" | "standard_fonts"
|
|
90
|
+
): boolean {
|
|
91
|
+
if (
|
|
92
|
+
!file ||
|
|
93
|
+
file.includes("/") ||
|
|
94
|
+
file.includes("\\") ||
|
|
95
|
+
file.includes("\0") ||
|
|
96
|
+
file.includes("..")
|
|
97
|
+
) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
if (file === "." || file === "..") {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
// Reject percent-encoded traversal attempts that survive route decoding
|
|
104
|
+
let decoded = file;
|
|
105
|
+
try {
|
|
106
|
+
decoded = decodeURIComponent(file);
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
decoded.includes("/") ||
|
|
112
|
+
decoded.includes("\\") ||
|
|
113
|
+
decoded.includes("..")
|
|
114
|
+
) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const ext = nodePath.extname(decoded).toLowerCase();
|
|
118
|
+
if (kind === "cmaps") {
|
|
119
|
+
return CMAP_EXTENSIONS.has(ext);
|
|
120
|
+
}
|
|
121
|
+
return STANDARD_FONT_EXTENSIONS.has(ext);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function contentTypeForPdfjsAsset(
|
|
125
|
+
kind: PdfjsAssetKind,
|
|
126
|
+
file?: string
|
|
127
|
+
): string {
|
|
128
|
+
if (kind === "worker") {
|
|
129
|
+
return "text/javascript";
|
|
130
|
+
}
|
|
131
|
+
if (kind === "standard_fonts" && file) {
|
|
132
|
+
const ext = nodePath.extname(file).toLowerCase();
|
|
133
|
+
if (ext === ".ttf") {
|
|
134
|
+
return "font/ttf";
|
|
135
|
+
}
|
|
136
|
+
if (ext === ".pfb") {
|
|
137
|
+
return "application/octet-stream";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return "application/octet-stream";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolve a candidate on-disk path for an asset via import.meta.resolve.
|
|
145
|
+
* This is ONLY a candidate locator — containment is decided solely against the
|
|
146
|
+
* independently resolved package root (never against this resolver's "dir").
|
|
147
|
+
*/
|
|
148
|
+
export type PdfjsCandidateResolver = (
|
|
149
|
+
kind: PdfjsAssetKind,
|
|
150
|
+
file?: string
|
|
151
|
+
) => Promise<string | null>;
|
|
152
|
+
|
|
153
|
+
export const defaultPdfjsCandidateResolver: PdfjsCandidateResolver = async (
|
|
154
|
+
kind,
|
|
155
|
+
file
|
|
156
|
+
) => {
|
|
157
|
+
try {
|
|
158
|
+
if (kind === "worker") {
|
|
159
|
+
const url = import.meta.resolve("pdfjs-dist/build/pdf.worker.min.mjs");
|
|
160
|
+
return pdfjsFileUrlToPath(url);
|
|
161
|
+
}
|
|
162
|
+
if (kind === "cmaps") {
|
|
163
|
+
if (!file) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
// Resolve sample to find package layout, then join requested file name only
|
|
167
|
+
const sample = import.meta
|
|
168
|
+
.resolve("pdfjs-dist/cmaps/UniJIS-UCS2-H.bcmap");
|
|
169
|
+
const samplePath = pdfjsFileUrlToPath(sample);
|
|
170
|
+
return nodePath.join(nodePath.dirname(samplePath), file);
|
|
171
|
+
}
|
|
172
|
+
if (!file) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const sample = import.meta
|
|
176
|
+
.resolve("pdfjs-dist/standard_fonts/LiberationSans-Regular.ttf");
|
|
177
|
+
const samplePath = pdfjsFileUrlToPath(sample);
|
|
178
|
+
return nodePath.join(nodePath.dirname(samplePath), file);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* True iff candidate is strictly contained in root after realpath of both.
|
|
186
|
+
* non-ENOENT realpath errors fail closed. ENOENT on candidate → lexical check
|
|
187
|
+
* only (for missing-file 404 paths that still must not escape).
|
|
188
|
+
*/
|
|
189
|
+
export async function isContainedInRoot(
|
|
190
|
+
root: string,
|
|
191
|
+
candidate: string,
|
|
192
|
+
realpathFn: RealpathFn = realpath
|
|
193
|
+
): Promise<boolean> {
|
|
194
|
+
let resolvedRoot: string;
|
|
195
|
+
try {
|
|
196
|
+
resolvedRoot = await realpathFn(root);
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let resolvedCandidate: string;
|
|
202
|
+
try {
|
|
203
|
+
resolvedCandidate = await realpathFn(candidate);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
const code =
|
|
206
|
+
error && typeof error === "object" && "code" in error
|
|
207
|
+
? (error as { code?: string }).code
|
|
208
|
+
: undefined;
|
|
209
|
+
if (code === "ENOENT") {
|
|
210
|
+
const rel = nodePath.relative(resolvedRoot, candidate);
|
|
211
|
+
return rel === "" || (!rel.startsWith("..") && !nodePath.isAbsolute(rel));
|
|
212
|
+
}
|
|
213
|
+
// EACCES, ELOOP, etc. fail closed
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const rel = nodePath.relative(resolvedRoot, resolvedCandidate);
|
|
218
|
+
return rel === "" || (!rel.startsWith("..") && !nodePath.isAbsolute(rel));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export type HandlePdfjsAssetOptions = {
|
|
222
|
+
kind: PdfjsAssetKind;
|
|
223
|
+
file?: string;
|
|
224
|
+
method?: string;
|
|
225
|
+
/** Candidate path locator only — NOT used as containment authority. */
|
|
226
|
+
resolveCandidate?: PdfjsCandidateResolver;
|
|
227
|
+
/** Independently resolve package root (defaults to resolvePdfjsPackageRoot). */
|
|
228
|
+
resolvePackageRoot?: () => Promise<string | null>;
|
|
229
|
+
realpathFn?: RealpathFn;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
function assetHeaders(
|
|
233
|
+
kind: PdfjsAssetKind,
|
|
234
|
+
fileName: string | undefined,
|
|
235
|
+
size: number
|
|
236
|
+
): Headers {
|
|
237
|
+
return new Headers({
|
|
238
|
+
"Content-Type": contentTypeForPdfjsAsset(kind, fileName),
|
|
239
|
+
"Cache-Control": PDFJS_ASSET_CACHE_CONTROL,
|
|
240
|
+
"Content-Length": String(size),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Serve a pdfjs-dist asset. GET returns body; HEAD returns headers with empty body.
|
|
246
|
+
*/
|
|
247
|
+
export async function handlePdfjsAsset(
|
|
248
|
+
options: HandlePdfjsAssetOptions
|
|
249
|
+
): Promise<Response> {
|
|
250
|
+
const method = (options.method ?? "GET").toUpperCase();
|
|
251
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
252
|
+
return Response.json(
|
|
253
|
+
{
|
|
254
|
+
error: {
|
|
255
|
+
code: "METHOD_NOT_ALLOWED",
|
|
256
|
+
message: "Only GET and HEAD are supported",
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
{ status: 405 }
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const realpathFn = options.realpathFn ?? realpath;
|
|
264
|
+
const resolveRoot =
|
|
265
|
+
options.resolvePackageRoot ?? (() => resolvePdfjsPackageRoot(realpathFn));
|
|
266
|
+
const resolveCandidate =
|
|
267
|
+
options.resolveCandidate ?? defaultPdfjsCandidateResolver;
|
|
268
|
+
|
|
269
|
+
// Independent package-root authority — fail closed if unavailable
|
|
270
|
+
const packageRoot = await resolveRoot();
|
|
271
|
+
if (!packageRoot) {
|
|
272
|
+
return error404("pdfjs package not found");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const expectedDir = expectedSubdirForKind(packageRoot, options.kind);
|
|
276
|
+
|
|
277
|
+
if (options.kind === "worker") {
|
|
278
|
+
const candidate = await resolveCandidate("worker");
|
|
279
|
+
if (!candidate) {
|
|
280
|
+
return error404("pdfjs worker not found");
|
|
281
|
+
}
|
|
282
|
+
// Must sit inside package root AND build/
|
|
283
|
+
if (!(await isContainedInRoot(packageRoot, candidate, realpathFn))) {
|
|
284
|
+
return error404("pdfjs worker not found");
|
|
285
|
+
}
|
|
286
|
+
if (!(await isContainedInRoot(expectedDir, candidate, realpathFn))) {
|
|
287
|
+
return error404("pdfjs worker not found");
|
|
288
|
+
}
|
|
289
|
+
// Prefer realpath for open
|
|
290
|
+
let openPath = candidate;
|
|
291
|
+
try {
|
|
292
|
+
openPath = await realpathFn(candidate);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
const code =
|
|
295
|
+
error && typeof error === "object" && "code" in error
|
|
296
|
+
? (error as { code?: string }).code
|
|
297
|
+
: undefined;
|
|
298
|
+
if (code === "ENOENT") {
|
|
299
|
+
return error404("pdfjs worker not found");
|
|
300
|
+
}
|
|
301
|
+
return error404("pdfjs worker not found");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const file = Bun.file(openPath);
|
|
305
|
+
if (!(await file.exists())) {
|
|
306
|
+
return error404("pdfjs worker not found");
|
|
307
|
+
}
|
|
308
|
+
const headers = assetHeaders("worker", undefined, file.size);
|
|
309
|
+
if (method === "HEAD") {
|
|
310
|
+
return new Response(null, { status: 200, headers });
|
|
311
|
+
}
|
|
312
|
+
return new Response(file, { headers });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const fileName = options.file ?? "";
|
|
316
|
+
if (
|
|
317
|
+
!isSafePdfjsAssetFilename(
|
|
318
|
+
fileName,
|
|
319
|
+
options.kind === "cmaps" ? "cmaps" : "standard_fonts"
|
|
320
|
+
)
|
|
321
|
+
) {
|
|
322
|
+
return error404("Asset not found");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Build candidate only from package-root + expected subdir + single segment
|
|
326
|
+
// (do not trust resolver output alone)
|
|
327
|
+
const safeName = (() => {
|
|
328
|
+
try {
|
|
329
|
+
return decodeURIComponent(fileName);
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
})();
|
|
334
|
+
if (!safeName) {
|
|
335
|
+
return error404("Asset not found");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const rootedCandidate = nodePath.join(expectedDir, safeName);
|
|
339
|
+
// Candidate locator may produce an alternate path; still require containment
|
|
340
|
+
const resolvedCandidate =
|
|
341
|
+
(await resolveCandidate(options.kind, safeName)) ?? rootedCandidate;
|
|
342
|
+
|
|
343
|
+
// Both rooted and resolved candidates must stay in package root + subdir
|
|
344
|
+
for (const cand of [rootedCandidate, resolvedCandidate]) {
|
|
345
|
+
if (!(await isContainedInRoot(packageRoot, cand, realpathFn))) {
|
|
346
|
+
return error404("Asset not found");
|
|
347
|
+
}
|
|
348
|
+
if (!(await isContainedInRoot(expectedDir, cand, realpathFn))) {
|
|
349
|
+
return error404("Asset not found");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Open the rooted candidate (authority path under package root)
|
|
354
|
+
let openPath = rootedCandidate;
|
|
355
|
+
try {
|
|
356
|
+
openPath = await realpathFn(rootedCandidate);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
const code =
|
|
359
|
+
error && typeof error === "object" && "code" in error
|
|
360
|
+
? (error as { code?: string }).code
|
|
361
|
+
: undefined;
|
|
362
|
+
if (code === "ENOENT") {
|
|
363
|
+
return error404("Asset not found");
|
|
364
|
+
}
|
|
365
|
+
// EACCES / ELOOP / etc.
|
|
366
|
+
return error404("Asset not found");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Re-check after realpath (symlink final target)
|
|
370
|
+
if (!(await isContainedInRoot(packageRoot, openPath, realpathFn))) {
|
|
371
|
+
return error404("Asset not found");
|
|
372
|
+
}
|
|
373
|
+
if (!(await isContainedInRoot(expectedDir, openPath, realpathFn))) {
|
|
374
|
+
return error404("Asset not found");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const file = Bun.file(openPath);
|
|
378
|
+
if (!(await file.exists())) {
|
|
379
|
+
return error404("Asset not found");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const headers = assetHeaders(options.kind, safeName, file.size);
|
|
383
|
+
if (method === "HEAD") {
|
|
384
|
+
return new Response(null, { status: 200, headers });
|
|
385
|
+
}
|
|
386
|
+
return new Response(file, { headers });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Extensions allowlist exported for tests (Sol N18). */
|
|
390
|
+
export const PDFJS_CMAP_EXTENSIONS = [...CMAP_EXTENSIONS];
|
|
391
|
+
export const PDFJS_STANDARD_FONT_EXTENSIONS = [...STANDARD_FONT_EXTENSIONS];
|