@gmickel/gno 1.29.6 → 1.30.4

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.
Files changed (36) hide show
  1. package/README.md +15 -5
  2. package/browser-extension/artifacts/gno-browser-clipper-v1.30.4.zip +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.30.4.zip.sha256 +1 -0
  4. package/browser-extension/dist/chunk-627emwpj.js +75 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/browser-extension/dist/preview.html +1 -1
  7. package/browser-extension/dist/service-worker.js +47 -22
  8. package/package.json +42 -39
  9. package/src/converters/adapters/officeparser/adapter.ts +7 -3
  10. package/src/core/network-boundary-inventory.ts +51 -0
  11. package/src/core/project-profile.ts +2 -2
  12. package/src/publish/encrypted-export.ts +1 -1
  13. package/src/serve/AGENTS.md +5 -1
  14. package/src/serve/CLAUDE.md +5 -1
  15. package/src/serve/fn112-routes.ts +232 -0
  16. package/src/serve/pdfjs-assets.ts +391 -0
  17. package/src/serve/public/components/ai-elements/code-block.tsx +28 -10
  18. package/src/serve/public/components/pdf/PdfPageView.tsx +428 -0
  19. package/src/serve/public/components/pdf/PdfToolbar.tsx +384 -0
  20. package/src/serve/public/components/pdf/PdfViewer.tsx +539 -0
  21. package/src/serve/public/components/pdf/pdf-viewer-deps.tsx +94 -0
  22. package/src/serve/public/globals.built.css +2 -2
  23. package/src/serve/public/globals.css +113 -0
  24. package/src/serve/public/hooks/use-pdf-document.ts +199 -0
  25. package/src/serve/public/hooks/use-pdf-pages.ts +1197 -0
  26. package/src/serve/public/lib/doc-asset-url.ts +57 -0
  27. package/src/serve/public/lib/math-sum-precise.ts +34 -0
  28. package/src/serve/public/lib/pdf.ts +772 -0
  29. package/src/serve/public/pages/DocView.tsx +295 -39
  30. package/src/serve/public/pages/doc-pdf-viewer.tsx +7 -0
  31. package/src/serve/routes/api.ts +154 -14
  32. package/src/serve/server.ts +190 -37
  33. package/src/serve/spa-bundle-source.ts +99 -0
  34. package/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip +0 -0
  35. package/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip.sha256 +0 -1
  36. package/browser-extension/dist/chunk-b2zm0jjd.js +0 -50
@@ -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];
@@ -1,3 +1,5 @@
1
+ import type { ShikiTransformer } from "shiki/types";
2
+
1
3
  import { CheckIcon, CopyIcon } from "lucide-react";
2
4
  import {
3
5
  type ComponentProps,
@@ -8,7 +10,8 @@ import {
8
10
  useRef,
9
11
  useState,
10
12
  } from "react";
11
- import { codeToHtml, type ShikiTransformer } from "shiki";
13
+ import { type BundledLanguage, createHighlighter } from "shiki";
14
+ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
12
15
 
13
16
  import { resolveCodeLanguage } from "../../lib/code-language";
14
17
  import { cn } from "../../lib/utils";
@@ -30,6 +33,20 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
30
33
  code: "",
31
34
  });
32
35
 
36
+ const highlighterPromise = createHighlighter({
37
+ engine: createJavaScriptRegexEngine(),
38
+ langs: ["text"],
39
+ themes: ["one-light", "one-dark-pro"],
40
+ });
41
+
42
+ async function getHighlighter(language: BundledLanguage | "text") {
43
+ const highlighter = await highlighterPromise;
44
+ if (!highlighter.getLoadedLanguages().includes(language)) {
45
+ await highlighter.loadLanguage(language);
46
+ }
47
+ return highlighter;
48
+ }
49
+
33
50
  function createLineTransformer(
34
51
  showLineNumbers: boolean,
35
52
  highlightedLines: number[]
@@ -82,39 +99,40 @@ export async function highlightCode(
82
99
  language: string,
83
100
  showLineNumbers = false,
84
101
  highlightedLines: number[] = []
85
- ) {
102
+ ): Promise<[string, string]> {
86
103
  const resolvedLanguage = resolveCodeLanguage(language);
87
104
  const transformers: ShikiTransformer[] =
88
105
  showLineNumbers || highlightedLines.length > 0
89
106
  ? [createLineTransformer(showLineNumbers, highlightedLines)]
90
107
  : [];
108
+ const highlighter = await getHighlighter(resolvedLanguage);
91
109
 
92
110
  try {
93
- return await Promise.all([
94
- codeToHtml(code, {
111
+ return [
112
+ highlighter.codeToHtml(code, {
95
113
  lang: resolvedLanguage,
96
114
  theme: "one-light",
97
115
  transformers,
98
116
  }),
99
- codeToHtml(code, {
117
+ highlighter.codeToHtml(code, {
100
118
  lang: resolvedLanguage,
101
119
  theme: "one-dark-pro",
102
120
  transformers,
103
121
  }),
104
- ]);
122
+ ];
105
123
  } catch {
106
- return await Promise.all([
107
- codeToHtml(code, {
124
+ return [
125
+ highlighter.codeToHtml(code, {
108
126
  lang: "text",
109
127
  theme: "one-light",
110
128
  transformers,
111
129
  }),
112
- codeToHtml(code, {
130
+ highlighter.codeToHtml(code, {
113
131
  lang: "text",
114
132
  theme: "one-dark-pro",
115
133
  transformers,
116
134
  }),
117
- ]);
135
+ ];
118
136
  }
119
137
  }
120
138