@gmickel/gno 1.29.6 → 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.6.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/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/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip.sha256 +0 -1
|
@@ -18,7 +18,17 @@ import {
|
|
|
18
18
|
TextIcon,
|
|
19
19
|
TrashIcon,
|
|
20
20
|
} from "lucide-react";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
lazy,
|
|
23
|
+
Suspense,
|
|
24
|
+
useCallback,
|
|
25
|
+
useEffect,
|
|
26
|
+
useMemo,
|
|
27
|
+
useRef,
|
|
28
|
+
useState,
|
|
29
|
+
} from "react";
|
|
30
|
+
|
|
31
|
+
import type { PdfFallbackReason } from "../lib/pdf";
|
|
22
32
|
|
|
23
33
|
import { extractSections } from "../../../core/sections";
|
|
24
34
|
import {
|
|
@@ -63,6 +73,11 @@ import {
|
|
|
63
73
|
buildEditDeepLink,
|
|
64
74
|
parseDocumentDeepLink,
|
|
65
75
|
} from "../lib/deep-links";
|
|
76
|
+
import {
|
|
77
|
+
buildDocAssetUrl,
|
|
78
|
+
isExtractedTextAvailable,
|
|
79
|
+
isPdfDocument,
|
|
80
|
+
} from "../lib/doc-asset-url";
|
|
66
81
|
import { waitForDocumentAvailability } from "../lib/document-availability";
|
|
67
82
|
import {
|
|
68
83
|
downloadPublishArtifactFile,
|
|
@@ -70,6 +85,67 @@ import {
|
|
|
70
85
|
} from "../lib/publish-export";
|
|
71
86
|
import { subscribeWorkspaceActionRequest } from "../lib/workspace-events";
|
|
72
87
|
|
|
88
|
+
/** Lazy so pdfjs is never pulled for non-PDF documents. */
|
|
89
|
+
const PdfViewer = lazy(() => import("./doc-pdf-viewer"));
|
|
90
|
+
|
|
91
|
+
/** Spec "Canonical fallback-notice copy" — DocView Text branch only. */
|
|
92
|
+
const PDF_FALLBACK_NOTICE: Record<
|
|
93
|
+
PdfFallbackReason,
|
|
94
|
+
{ eyebrow: string; body: string }
|
|
95
|
+
> = {
|
|
96
|
+
corrupt: {
|
|
97
|
+
eyebrow: "CANNOT RENDER",
|
|
98
|
+
body: "This PDF could not be rendered. View the extracted text or download the original.",
|
|
99
|
+
},
|
|
100
|
+
password: {
|
|
101
|
+
eyebrow: "PASSWORD PROTECTED",
|
|
102
|
+
body: "This PDF is password protected. Showing the extracted text instead. Download the original to open it in a PDF reader.",
|
|
103
|
+
},
|
|
104
|
+
network: {
|
|
105
|
+
eyebrow: "COULD NOT LOAD",
|
|
106
|
+
body: "The document could not be loaded from this session. Showing the extracted text instead. Switch to Pages to try again, or download the original.",
|
|
107
|
+
},
|
|
108
|
+
bootstrap: {
|
|
109
|
+
eyebrow: "VIEWER UNAVAILABLE",
|
|
110
|
+
body: "The PDF viewer could not start in this window. Showing the extracted text instead. Download the original to read it.",
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function PdfFallbackNotice({
|
|
115
|
+
reason,
|
|
116
|
+
downloadUrl,
|
|
117
|
+
}: {
|
|
118
|
+
reason: PdfFallbackReason;
|
|
119
|
+
downloadUrl: string;
|
|
120
|
+
}) {
|
|
121
|
+
const copy = PDF_FALLBACK_NOTICE[reason];
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
className="mb-4 flex max-w-2xl flex-col items-start gap-2 py-2 text-left"
|
|
125
|
+
data-testid={`pdf-fallback-${reason}`}
|
|
126
|
+
role="status"
|
|
127
|
+
>
|
|
128
|
+
<p className="font-mono text-[10px] text-muted-foreground/60 uppercase tracking-[0.15em]">
|
|
129
|
+
{copy.eyebrow}
|
|
130
|
+
</p>
|
|
131
|
+
<p className="text-[13px] text-foreground/90 leading-relaxed">
|
|
132
|
+
{copy.body}
|
|
133
|
+
</p>
|
|
134
|
+
<Button
|
|
135
|
+
asChild
|
|
136
|
+
className="cursor-pointer focus-visible:ring-primary/50"
|
|
137
|
+
data-testid="pdf-notice-download"
|
|
138
|
+
size="sm"
|
|
139
|
+
variant="secondary"
|
|
140
|
+
>
|
|
141
|
+
<a download href={downloadUrl || undefined}>
|
|
142
|
+
Download original
|
|
143
|
+
</a>
|
|
144
|
+
</Button>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
73
149
|
interface PageProps {
|
|
74
150
|
navigate: (to: string | number) => void;
|
|
75
151
|
}
|
|
@@ -287,6 +363,9 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
287
363
|
const [duplicateName, setDuplicateName] = useState("");
|
|
288
364
|
const [duplicateWarnings, setDuplicateWarnings] = useState<string[]>([]);
|
|
289
365
|
const [showRawView, setShowRawView] = useState(false);
|
|
366
|
+
/** DocView-owned PDF fallback reason (null when Pages or no fallback). */
|
|
367
|
+
const [pdfFallbackReason, setPdfFallbackReason] =
|
|
368
|
+
useState<PdfFallbackReason | null>(null);
|
|
290
369
|
const [creatingCopy, setCreatingCopy] = useState(false);
|
|
291
370
|
const [copyError, setCopyError] = useState<string | null>(null);
|
|
292
371
|
const [externalChangeNotice, setExternalChangeNotice] = useState<
|
|
@@ -422,6 +501,18 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
422
501
|
".bash",
|
|
423
502
|
].includes(doc.source.ext.toLowerCase());
|
|
424
503
|
|
|
504
|
+
const isPdf = Boolean(doc && isPdfDocument(doc.source));
|
|
505
|
+
|
|
506
|
+
// Spec predicate — evaluated per render, never from mime/ext.
|
|
507
|
+
const extractedTextAvailable = Boolean(doc && isExtractedTextAvailable(doc));
|
|
508
|
+
|
|
509
|
+
const pdfAssetUrl = useMemo(() => {
|
|
510
|
+
if (!doc || !isPdf) {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
return buildDocAssetUrl(doc.uri, doc.relPath);
|
|
514
|
+
}, [doc, isPdf]);
|
|
515
|
+
|
|
425
516
|
// Parse frontmatter for markdown files
|
|
426
517
|
const parsedContent = useMemo(() => {
|
|
427
518
|
if (!doc?.content || !isMarkdown) {
|
|
@@ -439,6 +530,39 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
439
530
|
}
|
|
440
531
|
}, [currentTarget.lineStart, currentTarget.view]);
|
|
441
532
|
|
|
533
|
+
// Clear fallback when the loaded document identity changes.
|
|
534
|
+
useEffect(() => {
|
|
535
|
+
setPdfFallbackReason(null);
|
|
536
|
+
}, [doc?.uri]);
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* DocView defends the extractedTextAvailable boundary itself: a spurious
|
|
540
|
+
* onFallback while the predicate is false must not switch view or store a
|
|
541
|
+
* reason (viewer/error surface stays mounted).
|
|
542
|
+
*/
|
|
543
|
+
const handlePdfFallback = useCallback(
|
|
544
|
+
(reason: PdfFallbackReason) => {
|
|
545
|
+
// Re-evaluate from current doc identity — never trust a stale closure alone.
|
|
546
|
+
if (!doc || !isExtractedTextAvailable(doc)) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
setPdfFallbackReason(reason);
|
|
550
|
+
setShowRawView(true);
|
|
551
|
+
},
|
|
552
|
+
[doc]
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
/** Pages/Text for PDFs — clearing notice when returning to Pages. */
|
|
556
|
+
const togglePdfPagesText = useCallback(() => {
|
|
557
|
+
setShowRawView((prev) => {
|
|
558
|
+
if (prev) {
|
|
559
|
+
setPdfFallbackReason(null);
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
return true;
|
|
563
|
+
});
|
|
564
|
+
}, []);
|
|
565
|
+
|
|
442
566
|
useEffect(() => {
|
|
443
567
|
if (!currentHash || showRawView || loading) {
|
|
444
568
|
return;
|
|
@@ -1464,6 +1588,20 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1464
1588
|
Reveal
|
|
1465
1589
|
</Button>
|
|
1466
1590
|
)}
|
|
1591
|
+
{isPdf && pdfAssetUrl ? (
|
|
1592
|
+
<Button
|
|
1593
|
+
asChild
|
|
1594
|
+
className="gap-1.5 cursor-pointer"
|
|
1595
|
+
data-testid="pdf-header-download"
|
|
1596
|
+
size="sm"
|
|
1597
|
+
variant="outline"
|
|
1598
|
+
>
|
|
1599
|
+
<a download href={pdfAssetUrl}>
|
|
1600
|
+
<SquareArrowOutUpRightIcon className="size-4" />
|
|
1601
|
+
Download original
|
|
1602
|
+
</a>
|
|
1603
|
+
</Button>
|
|
1604
|
+
) : null}
|
|
1467
1605
|
<Button
|
|
1468
1606
|
className="gap-1.5 text-muted-foreground hover:text-destructive"
|
|
1469
1607
|
onClick={() => setDeleteDialogOpen(true)}
|
|
@@ -1579,8 +1717,8 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1579
1717
|
|
|
1580
1718
|
{/* Content */}
|
|
1581
1719
|
<div className="relative">
|
|
1582
|
-
{/* Source/Rendered
|
|
1583
|
-
{isMarkdown && doc.contentAvailable && (
|
|
1720
|
+
{/* Markdown Source/Rendered pill — unchanged for non-PDF */}
|
|
1721
|
+
{isMarkdown && doc.contentAvailable && !isPdf && (
|
|
1584
1722
|
<button
|
|
1585
1723
|
className="z-10 flex cursor-pointer items-center gap-1.5 rounded-full border border-border/30 bg-background/80 px-3 py-1 font-mono text-[11px] text-muted-foreground backdrop-blur-sm transition-colors hover:border-primary/30 hover:text-primary"
|
|
1586
1724
|
onClick={() => setShowRawView(!showRawView)}
|
|
@@ -1606,48 +1744,166 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1606
1744
|
</button>
|
|
1607
1745
|
)}
|
|
1608
1746
|
|
|
1609
|
-
{
|
|
1747
|
+
{/* PDF Pages/Text pill — DocView sole owner (no PdfToolbar toggle) */}
|
|
1748
|
+
{isPdf && (
|
|
1749
|
+
<button
|
|
1750
|
+
className="z-10 flex cursor-pointer items-center gap-1.5 rounded-full border border-border/30 bg-background/80 px-3 py-1 font-mono text-[11px] text-muted-foreground backdrop-blur-sm transition-colors hover:border-primary/30 hover:text-primary"
|
|
1751
|
+
data-testid="pdf-pages-text-toggle"
|
|
1752
|
+
onClick={togglePdfPagesText}
|
|
1753
|
+
style={{
|
|
1754
|
+
position: "absolute",
|
|
1755
|
+
top: "0.75rem",
|
|
1756
|
+
right: "0.75rem",
|
|
1757
|
+
left: "auto",
|
|
1758
|
+
}}
|
|
1759
|
+
type="button"
|
|
1760
|
+
>
|
|
1761
|
+
{showRawView ? (
|
|
1762
|
+
<>
|
|
1763
|
+
<FileText className="size-3" />
|
|
1764
|
+
Pages
|
|
1765
|
+
</>
|
|
1766
|
+
) : (
|
|
1767
|
+
<>
|
|
1768
|
+
<TextIcon className="size-3" />
|
|
1769
|
+
Text
|
|
1770
|
+
</>
|
|
1771
|
+
)}
|
|
1772
|
+
</button>
|
|
1773
|
+
)}
|
|
1774
|
+
|
|
1775
|
+
{/* PDF branch: Pages = lazy PdfViewer; Text = extracted + optional notice */}
|
|
1776
|
+
{isPdf && !showRawView && pdfAssetUrl ? (
|
|
1777
|
+
// Reserve the band the absolutely-positioned Pages/Text pill
|
|
1778
|
+
// occupies (top 0.75rem + ~1.75rem tall). Without it the
|
|
1779
|
+
// sticky PdfToolbar (z-10) renders after the pill (also
|
|
1780
|
+
// z-10) and covers it, leaving the toggle invisible and
|
|
1781
|
+
// un-clickable on every PDF that renders. Inline (like the
|
|
1782
|
+
// pill's own positioning above) so the exact clearance is
|
|
1783
|
+
// explicit: the pill occupies 0.75rem + ~1.75rem, and a
|
|
1784
|
+
// `pt-10` utility (2.5rem) would leave it 0.14px short.
|
|
1785
|
+
<div style={{ paddingTop: "2.75rem" }}>
|
|
1786
|
+
<Suspense
|
|
1787
|
+
fallback={
|
|
1788
|
+
<div className="flex items-center gap-2 py-10 text-muted-foreground">
|
|
1789
|
+
<Loader className="size-4" />
|
|
1790
|
+
<span className="font-mono text-xs">
|
|
1791
|
+
Loading viewer…
|
|
1792
|
+
</span>
|
|
1793
|
+
</div>
|
|
1794
|
+
}
|
|
1795
|
+
>
|
|
1796
|
+
<PdfViewer
|
|
1797
|
+
key={doc.uri}
|
|
1798
|
+
assetUrl={pdfAssetUrl}
|
|
1799
|
+
downloadUrl={pdfAssetUrl}
|
|
1800
|
+
extractedTextAvailable={extractedTextAvailable}
|
|
1801
|
+
onFallback={handlePdfFallback}
|
|
1802
|
+
/>
|
|
1803
|
+
</Suspense>
|
|
1804
|
+
</div>
|
|
1805
|
+
) : null}
|
|
1806
|
+
|
|
1807
|
+
{isPdf && showRawView ? (
|
|
1808
|
+
<div className="pt-10">
|
|
1809
|
+
{pdfFallbackReason && extractedTextAvailable ? (
|
|
1810
|
+
<PdfFallbackNotice
|
|
1811
|
+
downloadUrl={pdfAssetUrl ?? ""}
|
|
1812
|
+
reason={pdfFallbackReason}
|
|
1813
|
+
/>
|
|
1814
|
+
) : null}
|
|
1815
|
+
{!doc.contentAvailable ? (
|
|
1816
|
+
<div className="rounded-lg border border-border/50 bg-muted/30 p-6 text-center">
|
|
1817
|
+
<p className="text-muted-foreground">
|
|
1818
|
+
Content not available (document may need re-indexing)
|
|
1819
|
+
</p>
|
|
1820
|
+
</div>
|
|
1821
|
+
) : null}
|
|
1822
|
+
{doc.contentAvailable && !extractedTextAvailable ? (
|
|
1823
|
+
<div
|
|
1824
|
+
className="rounded-lg border border-border/50 bg-muted/30 p-6 text-center"
|
|
1825
|
+
data-testid="pdf-no-extracted-text"
|
|
1826
|
+
>
|
|
1827
|
+
<p className="text-muted-foreground">
|
|
1828
|
+
No extracted text for this document.
|
|
1829
|
+
</p>
|
|
1830
|
+
{pdfAssetUrl ? (
|
|
1831
|
+
<div className="mt-3">
|
|
1832
|
+
<Button
|
|
1833
|
+
asChild
|
|
1834
|
+
className="cursor-pointer"
|
|
1835
|
+
size="sm"
|
|
1836
|
+
>
|
|
1837
|
+
<a download href={pdfAssetUrl}>
|
|
1838
|
+
Download original
|
|
1839
|
+
</a>
|
|
1840
|
+
</Button>
|
|
1841
|
+
</div>
|
|
1842
|
+
) : null}
|
|
1843
|
+
</div>
|
|
1844
|
+
) : null}
|
|
1845
|
+
{extractedTextAvailable ? (
|
|
1846
|
+
<div className="rounded-lg border border-border/50 bg-muted/30 p-6">
|
|
1847
|
+
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed">
|
|
1848
|
+
{doc.content}
|
|
1849
|
+
</pre>
|
|
1850
|
+
</div>
|
|
1851
|
+
) : null}
|
|
1852
|
+
</div>
|
|
1853
|
+
) : null}
|
|
1854
|
+
|
|
1855
|
+
{/* Non-PDF branches (byte-identical behavior) */}
|
|
1856
|
+
{!isPdf && !doc.contentAvailable && (
|
|
1610
1857
|
<div className="rounded-lg border border-border/50 bg-muted/30 p-6 text-center">
|
|
1611
1858
|
<p className="text-muted-foreground">
|
|
1612
1859
|
Content not available (document may need re-indexing)
|
|
1613
1860
|
</p>
|
|
1614
1861
|
</div>
|
|
1615
1862
|
)}
|
|
1616
|
-
{
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
<
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1863
|
+
{!isPdf &&
|
|
1864
|
+
doc.contentAvailable &&
|
|
1865
|
+
isMarkdown &&
|
|
1866
|
+
!showRawView && (
|
|
1867
|
+
<div className="rounded-lg border border-border/40 bg-gradient-to-br from-background to-muted/10 p-4 shadow-inner">
|
|
1868
|
+
<MarkdownPreview
|
|
1869
|
+
collection={doc.collection}
|
|
1870
|
+
content={parsedContent.body}
|
|
1871
|
+
docUri={doc.uri}
|
|
1872
|
+
wikiLinks={resolvedWikiLinks}
|
|
1873
|
+
/>
|
|
1874
|
+
</div>
|
|
1875
|
+
)}
|
|
1876
|
+
{!isPdf &&
|
|
1877
|
+
doc.contentAvailable &&
|
|
1878
|
+
isMarkdown &&
|
|
1879
|
+
showRawView && (
|
|
1880
|
+
<CodeBlock
|
|
1881
|
+
code={doc.content ?? ""}
|
|
1882
|
+
highlightedLines={highlightedLines}
|
|
1883
|
+
language={"markdown" as BundledLanguage}
|
|
1884
|
+
scrollToLine={currentTarget.lineStart}
|
|
1885
|
+
showLineNumbers
|
|
1886
|
+
>
|
|
1887
|
+
<CodeBlockCopyButton />
|
|
1888
|
+
</CodeBlock>
|
|
1889
|
+
)}
|
|
1890
|
+
{!isPdf &&
|
|
1891
|
+
doc.contentAvailable &&
|
|
1892
|
+
isCodeFile &&
|
|
1893
|
+
!isMarkdown && (
|
|
1894
|
+
<CodeBlock
|
|
1895
|
+
code={doc.content ?? ""}
|
|
1896
|
+
highlightedLines={highlightedLines}
|
|
1897
|
+
language={
|
|
1898
|
+
getLanguageFromExt(doc.source.ext) as BundledLanguage
|
|
1899
|
+
}
|
|
1900
|
+
scrollToLine={currentTarget.lineStart}
|
|
1901
|
+
showLineNumbers
|
|
1902
|
+
>
|
|
1903
|
+
<CodeBlockCopyButton />
|
|
1904
|
+
</CodeBlock>
|
|
1905
|
+
)}
|
|
1906
|
+
{!isPdf && doc.contentAvailable && !isCodeFile && (
|
|
1651
1907
|
<div className="rounded-lg border border-border/50 bg-muted/30 p-6">
|
|
1652
1908
|
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed">
|
|
1653
1909
|
{doc.content}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocView-only re-export of PdfViewer.
|
|
3
|
+
*
|
|
4
|
+
* Kept as a separate module so DOM tests can mock.module this path without
|
|
5
|
+
* sticky-mock pollution of the real components/pdf/PdfViewer suite (fn-112.5).
|
|
6
|
+
*/
|
|
7
|
+
export { PdfViewer as default } from "../components/pdf/PdfViewer";
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* @module src/serve/routes/api
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
// node:fs/promises structure ops have no Bun equivalent
|
|
9
|
-
import { readdir } from "node:fs/promises";
|
|
8
|
+
// node:fs/promises structure ops + realpath have no Bun equivalent
|
|
9
|
+
import { readdir, realpath } from "node:fs/promises";
|
|
10
10
|
// node:path has no Bun equivalent
|
|
11
11
|
import { posix as pathPosix } from "node:path";
|
|
12
12
|
|
|
@@ -688,15 +688,57 @@ function isAbsoluteFilesystemPath(pathValue: string): boolean {
|
|
|
688
688
|
);
|
|
689
689
|
}
|
|
690
690
|
|
|
691
|
-
|
|
691
|
+
export type RealpathFn = (path: string) => Promise<string>;
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Lexical containment, then realpath containment (symlink-escape defense).
|
|
695
|
+
* Only candidate ENOENT falls back to the lexical verdict; other realpath
|
|
696
|
+
* errors fail closed. Both root and candidate are canonicalized when present.
|
|
697
|
+
* Exported for adversarial unit tests (I1-04 non-ENOENT fail-closed).
|
|
698
|
+
*/
|
|
699
|
+
export async function isPathWithinRoot(
|
|
692
700
|
root: string,
|
|
693
|
-
candidate: string
|
|
701
|
+
candidate: string,
|
|
702
|
+
realpathFn: RealpathFn = realpath
|
|
694
703
|
): Promise<boolean> {
|
|
695
704
|
const nodePath = await import("node:path"); // no bun equivalent
|
|
696
705
|
const relative = nodePath.relative(root, candidate);
|
|
697
|
-
|
|
706
|
+
const lexicallyWithin =
|
|
698
707
|
relative === "" ||
|
|
699
|
-
(!relative.startsWith("..") && !nodePath.isAbsolute(relative))
|
|
708
|
+
(!relative.startsWith("..") && !nodePath.isAbsolute(relative));
|
|
709
|
+
if (!lexicallyWithin) {
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
let resolvedRoot: string;
|
|
714
|
+
try {
|
|
715
|
+
resolvedRoot = await realpathFn(root);
|
|
716
|
+
} catch {
|
|
717
|
+
// Root must resolve; fail closed
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
let resolvedCandidate: string;
|
|
722
|
+
try {
|
|
723
|
+
resolvedCandidate = await realpathFn(candidate);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
const code =
|
|
726
|
+
error && typeof error === "object" && "code" in error
|
|
727
|
+
? (error as { code?: string }).code
|
|
728
|
+
: undefined;
|
|
729
|
+
// Genuinely missing file: accept lexical verdict so callers can 404 later
|
|
730
|
+
if (code === "ENOENT") {
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
// EACCES, ELOOP, etc. fail closed
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const resolvedRelative = nodePath.relative(resolvedRoot, resolvedCandidate);
|
|
738
|
+
return (
|
|
739
|
+
resolvedRelative === "" ||
|
|
740
|
+
(!resolvedRelative.startsWith("..") &&
|
|
741
|
+
!nodePath.isAbsolute(resolvedRelative))
|
|
700
742
|
);
|
|
701
743
|
}
|
|
702
744
|
|
|
@@ -2056,15 +2098,77 @@ export async function handleDoc(
|
|
|
2056
2098
|
}
|
|
2057
2099
|
|
|
2058
2100
|
/**
|
|
2059
|
-
*
|
|
2101
|
+
* Parse a single-range `Range: bytes=…` header per RFC 9110.
|
|
2102
|
+
* Multi-range requests are rejected here; the caller returns 416 with
|
|
2103
|
+
* Content-Range `bytes * / <size>` (RFC unsatisfiable form; see serve path).
|
|
2104
|
+
*/
|
|
2105
|
+
function parseSingleByteRange(
|
|
2106
|
+
rangeHeader: string,
|
|
2107
|
+
size: number
|
|
2108
|
+
):
|
|
2109
|
+
| { ok: true; start: number; end: number }
|
|
2110
|
+
| { ok: false; reason: "malformed" | "unsatisfiable" } {
|
|
2111
|
+
const trimmed = rangeHeader.trim();
|
|
2112
|
+
// Multi-range: not supported — signal so caller can return 416
|
|
2113
|
+
if (trimmed.includes(",")) {
|
|
2114
|
+
return { ok: false, reason: "malformed" };
|
|
2115
|
+
}
|
|
2116
|
+
const match = /^bytes=(\d*)-(\d*)$/u.exec(trimmed);
|
|
2117
|
+
if (!match || (match[1] === "" && match[2] === "")) {
|
|
2118
|
+
return { ok: false, reason: "malformed" };
|
|
2119
|
+
}
|
|
2120
|
+
const startTok = match[1] ?? "";
|
|
2121
|
+
const endTok = match[2] ?? "";
|
|
2122
|
+
|
|
2123
|
+
let start: number;
|
|
2124
|
+
let end: number;
|
|
2125
|
+
if (startTok === "") {
|
|
2126
|
+
// suffix: bytes=-N
|
|
2127
|
+
const suffix = Number.parseInt(endTok, 10);
|
|
2128
|
+
if (!Number.isFinite(suffix) || suffix <= 0) {
|
|
2129
|
+
return { ok: false, reason: "malformed" };
|
|
2130
|
+
}
|
|
2131
|
+
if (size === 0) {
|
|
2132
|
+
return { ok: false, reason: "unsatisfiable" };
|
|
2133
|
+
}
|
|
2134
|
+
start = Math.max(0, size - suffix);
|
|
2135
|
+
end = size - 1;
|
|
2136
|
+
} else {
|
|
2137
|
+
start = Number.parseInt(startTok, 10);
|
|
2138
|
+
if (!Number.isFinite(start) || start < 0) {
|
|
2139
|
+
return { ok: false, reason: "malformed" };
|
|
2140
|
+
}
|
|
2141
|
+
if (endTok === "") {
|
|
2142
|
+
end = size - 1;
|
|
2143
|
+
} else {
|
|
2144
|
+
end = Number.parseInt(endTok, 10);
|
|
2145
|
+
if (!Number.isFinite(end) || end < 0) {
|
|
2146
|
+
return { ok: false, reason: "malformed" };
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
if (size === 0 || start >= size || end < start) {
|
|
2152
|
+
return { ok: false, reason: "unsatisfiable" };
|
|
2153
|
+
}
|
|
2154
|
+
end = Math.min(end, size - 1);
|
|
2155
|
+
return { ok: true, start, end };
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
/**
|
|
2159
|
+
* GET|HEAD /api/doc-asset
|
|
2060
2160
|
* Query params:
|
|
2061
2161
|
* - path (required): relative to current doc, or absolute filesystem path
|
|
2062
2162
|
* - uri (required for relative paths): current document uri
|
|
2163
|
+
*
|
|
2164
|
+
* Supports single-range Range requests (206/416). Multi-range → 416 (I1-03).
|
|
2165
|
+
* HEAD mirrors GET status/headers with empty body (I1-02).
|
|
2063
2166
|
*/
|
|
2064
2167
|
export async function handleDocAsset(
|
|
2065
2168
|
store: SqliteAdapter,
|
|
2066
2169
|
config: Config,
|
|
2067
|
-
url: URL
|
|
2170
|
+
url: URL,
|
|
2171
|
+
request?: Request
|
|
2068
2172
|
): Promise<Response> {
|
|
2069
2173
|
const assetPath = url.searchParams.get("path")?.trim();
|
|
2070
2174
|
if (!assetPath) {
|
|
@@ -2139,12 +2243,48 @@ export async function handleDocAsset(
|
|
|
2139
2243
|
return errorResponse("NOT_FOUND", "Asset not found", 404);
|
|
2140
2244
|
}
|
|
2141
2245
|
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2246
|
+
const isHead = (request?.method ?? "GET").toUpperCase() === "HEAD";
|
|
2247
|
+
const filename = resolvedPath.split(/[\\/]/u).at(-1) ?? "document";
|
|
2248
|
+
const headers = new Headers({
|
|
2249
|
+
"Accept-Ranges": "bytes",
|
|
2250
|
+
"Cache-Control": "no-store",
|
|
2251
|
+
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
2252
|
+
"Content-Type": file.type || "application/octet-stream",
|
|
2147
2253
|
});
|
|
2254
|
+
|
|
2255
|
+
const rangeHeader = request?.headers.get("Range");
|
|
2256
|
+
if (!rangeHeader) {
|
|
2257
|
+
headers.set("Content-Length", String(file.size));
|
|
2258
|
+
if (isHead) {
|
|
2259
|
+
return new Response(null, { status: 200, headers });
|
|
2260
|
+
}
|
|
2261
|
+
return new Response(file, { headers });
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// Multi-range: unsupported → 416 with bytes */size (I1-03)
|
|
2265
|
+
if (rangeHeader.includes(",")) {
|
|
2266
|
+
headers.set("Content-Range", `bytes */${file.size}`);
|
|
2267
|
+
// No Content-Length for empty 416 body
|
|
2268
|
+
headers.delete("Content-Length");
|
|
2269
|
+
return new Response(null, { status: 416, headers });
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
const parsed = parseSingleByteRange(rangeHeader, file.size);
|
|
2273
|
+
if (!parsed.ok) {
|
|
2274
|
+
headers.set("Content-Range", `bytes */${file.size}`);
|
|
2275
|
+
headers.delete("Content-Length");
|
|
2276
|
+
return new Response(null, { status: 416, headers });
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
const { start, end } = parsed;
|
|
2280
|
+
const length = end - start + 1;
|
|
2281
|
+
headers.set("Content-Length", String(length));
|
|
2282
|
+
headers.set("Content-Range", `bytes ${start}-${end}/${file.size}`);
|
|
2283
|
+
if (isHead) {
|
|
2284
|
+
// Empty body; do not slice/stream the file for HEAD
|
|
2285
|
+
return new Response(null, { status: 206, headers });
|
|
2286
|
+
}
|
|
2287
|
+
return new Response(file.slice(start, end + 1), { status: 206, headers });
|
|
2148
2288
|
}
|
|
2149
2289
|
|
|
2150
2290
|
/**
|
|
@@ -5258,7 +5398,7 @@ export async function routeApi(
|
|
|
5258
5398
|
}
|
|
5259
5399
|
|
|
5260
5400
|
if (path === "/api/doc-asset") {
|
|
5261
|
-
return handleDocAsset(store, config, url);
|
|
5401
|
+
return handleDocAsset(store, config, url, req);
|
|
5262
5402
|
}
|
|
5263
5403
|
|
|
5264
5404
|
if (path === "/api/search" && req.method === "POST") {
|