@gmickel/gno 1.39.2 → 1.40.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/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.39.2.zip → gno-browser-clipper-v1.40.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.40.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/src/core/network-boundary-inventory.ts +10 -0
- package/src/serve/CLAUDE.md +22 -2
- package/src/serve/clipper-security.ts +3 -17
- package/src/serve/public/components/pdf/PdfViewer.tsx +97 -19
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/globals.css +4 -0
- package/src/serve/public/hooks/use-pdf-document.ts +101 -37
- package/src/serve/public/hooks/use-pdf-pages.ts +324 -92
- package/src/serve/public/lib/pdf-transport.ts +13 -0
- package/src/serve/public/lib/pdf.ts +77 -10
- package/src/serve/public/lib/server-capabilities.ts +19 -0
- package/src/serve/public/pages/Ask.tsx +8 -9
- package/src/serve/public/pages/DocView.tsx +80 -18
- package/src/serve/public/pages/Search.tsx +8 -9
- package/src/serve/request-locality.ts +84 -0
- package/src/serve/routes/api.ts +88 -3
- package/src/serve/server.ts +163 -29
- package/browser-extension/artifacts/gno-browser-clipper-v1.39.2.zip.sha256 +0 -1
|
@@ -54,6 +54,10 @@ import {
|
|
|
54
54
|
type QueryModeType,
|
|
55
55
|
type TagMode,
|
|
56
56
|
} from "../lib/retrieval-filters";
|
|
57
|
+
import {
|
|
58
|
+
fetchServerCapabilities,
|
|
59
|
+
type ServerCapabilities,
|
|
60
|
+
} from "../lib/server-capabilities";
|
|
57
61
|
import { cn } from "../lib/utils";
|
|
58
62
|
|
|
59
63
|
interface PageProps {
|
|
@@ -104,13 +108,6 @@ interface AskResponse {
|
|
|
104
108
|
verification?: AskVerification;
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
interface Capabilities {
|
|
108
|
-
bm25: boolean;
|
|
109
|
-
vector: boolean;
|
|
110
|
-
hybrid: boolean;
|
|
111
|
-
answer: boolean;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
111
|
interface ConversationEntry {
|
|
115
112
|
id: string;
|
|
116
113
|
query: string;
|
|
@@ -195,7 +192,9 @@ function renderAnswer(
|
|
|
195
192
|
export default function Ask({ navigate }: PageProps) {
|
|
196
193
|
const [query, setQuery] = useState("");
|
|
197
194
|
const [conversation, setConversation] = useState<ConversationEntry[]>([]);
|
|
198
|
-
const [capabilities, setCapabilities] = useState<
|
|
195
|
+
const [capabilities, setCapabilities] = useState<ServerCapabilities | null>(
|
|
196
|
+
null
|
|
197
|
+
);
|
|
199
198
|
const [collections, setCollections] = useState<Collection[]>([]);
|
|
200
199
|
const [thoroughness, setThoroughness] = useState<Thoroughness>("balanced");
|
|
201
200
|
const [activePreset, setActivePreset] = useState("slim-tuned");
|
|
@@ -233,7 +232,7 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
233
232
|
useEffect(() => {
|
|
234
233
|
async function bootstrap(): Promise<void> {
|
|
235
234
|
const [capsResult, collectionsResult, presetsResult] = await Promise.all([
|
|
236
|
-
|
|
235
|
+
fetchServerCapabilities(),
|
|
237
236
|
apiFetch<Collection[]>("/api/collections"),
|
|
238
237
|
apiFetch<PresetsResponse>("/api/presets"),
|
|
239
238
|
]);
|
|
@@ -94,6 +94,10 @@ import {
|
|
|
94
94
|
stripSectionTargetLinkParam,
|
|
95
95
|
type SectionLinkNoticeKind,
|
|
96
96
|
} from "../lib/section-links";
|
|
97
|
+
import {
|
|
98
|
+
fetchServerCapabilities,
|
|
99
|
+
type ServerCapabilities,
|
|
100
|
+
} from "../lib/server-capabilities";
|
|
97
101
|
import { subscribeWorkspaceActionRequest } from "../lib/workspace-events";
|
|
98
102
|
import {
|
|
99
103
|
FrontmatterDisplay,
|
|
@@ -361,6 +365,11 @@ function getParentPath(relPath: string): string {
|
|
|
361
365
|
|
|
362
366
|
export default function DocView({ navigate }: PageProps) {
|
|
363
367
|
const [doc, setDoc] = useState<DocData | null>(null);
|
|
368
|
+
// undefined = not answered yet (offer neither locality-dependent action),
|
|
369
|
+
// null = fetch failed (fail closed: treat the client as remote).
|
|
370
|
+
const [serverCapabilities, setServerCapabilities] = useState<
|
|
371
|
+
ServerCapabilities | null | undefined
|
|
372
|
+
>(undefined);
|
|
364
373
|
const [error, setError] = useState<string | null>(null);
|
|
365
374
|
const [loading, setLoading] = useState(true);
|
|
366
375
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
@@ -483,6 +492,20 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
483
492
|
});
|
|
484
493
|
}, [currentUri]);
|
|
485
494
|
|
|
495
|
+
// Server capabilities decide which host-local actions may render. A failed
|
|
496
|
+
// fetch leaves them null, which the view reads as a remote client.
|
|
497
|
+
useEffect(() => {
|
|
498
|
+
let cancelled = false;
|
|
499
|
+
void fetchServerCapabilities().then(({ data }) => {
|
|
500
|
+
if (!cancelled) {
|
|
501
|
+
setServerCapabilities(data);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
return () => {
|
|
505
|
+
cancelled = true;
|
|
506
|
+
};
|
|
507
|
+
}, []);
|
|
508
|
+
|
|
486
509
|
// Fetch document when URI changes
|
|
487
510
|
useEffect(() => {
|
|
488
511
|
loadDocument();
|
|
@@ -542,6 +565,11 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
542
565
|
|
|
543
566
|
const isPdf = Boolean(doc && isPdfDocument(doc.source));
|
|
544
567
|
|
|
568
|
+
const localClient = serverCapabilities?.localClient ?? false;
|
|
569
|
+
// Until /api/capabilities answers, a same-host user would otherwise see the
|
|
570
|
+
// remote "Open original" flash in and then swap to Reveal.
|
|
571
|
+
const localityKnown = serverCapabilities !== undefined;
|
|
572
|
+
|
|
545
573
|
// Spec predicate — evaluated per render, never from mime/ext.
|
|
546
574
|
const extractedTextAvailable = Boolean(doc && isExtractedTextAvailable(doc));
|
|
547
575
|
|
|
@@ -552,6 +580,16 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
552
580
|
return buildDocAssetUrl(doc.uri, doc.relPath);
|
|
553
581
|
}, [doc, isPdf]);
|
|
554
582
|
|
|
583
|
+
// Remote "Open original" target for any document that has a source file:
|
|
584
|
+
// /api/doc-asset serves any collection file inline, so this keeps the
|
|
585
|
+
// previous file:// scope (every read-only source) for remote clients.
|
|
586
|
+
const sourceAssetUrl = useMemo(() => {
|
|
587
|
+
if (!doc?.source.absPath) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
return buildDocAssetUrl(doc.uri, doc.relPath);
|
|
591
|
+
}, [doc]);
|
|
592
|
+
|
|
555
593
|
// Parse frontmatter for markdown files
|
|
556
594
|
const parsedContent = useMemo(() => {
|
|
557
595
|
if (!doc?.content || !isMarkdown) {
|
|
@@ -1690,10 +1728,10 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1690
1728
|
);
|
|
1691
1729
|
|
|
1692
1730
|
return (
|
|
1693
|
-
<div className="min-h-screen">
|
|
1731
|
+
<div className="min-h-screen min-w-0 overflow-x-clip">
|
|
1694
1732
|
{/* Header */}
|
|
1695
1733
|
<header className="glass sticky top-0 z-10 border-border/50 border-b">
|
|
1696
|
-
<div className="flex items-center gap-4 px-
|
|
1734
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 px-4 py-4 sm:px-8">
|
|
1697
1735
|
{/* Home button - Scholarly Dusk brass accent */}
|
|
1698
1736
|
<Button
|
|
1699
1737
|
aria-label="Go to dashboard"
|
|
@@ -1730,8 +1768,14 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1730
1768
|
)}
|
|
1731
1769
|
{doc && (
|
|
1732
1770
|
<>
|
|
1733
|
-
<Separator
|
|
1734
|
-
|
|
1771
|
+
<Separator
|
|
1772
|
+
className="hidden h-6 sm:block"
|
|
1773
|
+
orientation="vertical"
|
|
1774
|
+
/>
|
|
1775
|
+
<div
|
|
1776
|
+
className="flex min-w-0 flex-wrap items-center gap-2"
|
|
1777
|
+
data-testid="doc-header-actions"
|
|
1778
|
+
>
|
|
1735
1779
|
{doc.capabilities.editable ? (
|
|
1736
1780
|
<>
|
|
1737
1781
|
<Button className="gap-1.5" onClick={handleEdit} size="sm">
|
|
@@ -1817,10 +1861,11 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1817
1861
|
)}
|
|
1818
1862
|
Export for gno.sh
|
|
1819
1863
|
</Button>
|
|
1820
|
-
{doc.source.absPath && (
|
|
1864
|
+
{localClient && doc.source.absPath && (
|
|
1821
1865
|
<>
|
|
1822
1866
|
<Button
|
|
1823
1867
|
className="gap-1.5"
|
|
1868
|
+
data-testid="doc-reveal"
|
|
1824
1869
|
onClick={() => {
|
|
1825
1870
|
void handleReveal();
|
|
1826
1871
|
}}
|
|
@@ -1832,6 +1877,7 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1832
1877
|
</Button>
|
|
1833
1878
|
<Button asChild size="sm" variant="outline">
|
|
1834
1879
|
<a
|
|
1880
|
+
data-testid="doc-open-original"
|
|
1835
1881
|
href={`file://${doc.source.absPath}`}
|
|
1836
1882
|
rel="noopener noreferrer"
|
|
1837
1883
|
target="_blank"
|
|
@@ -1842,21 +1888,37 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1842
1888
|
</Button>
|
|
1843
1889
|
</>
|
|
1844
1890
|
)}
|
|
1891
|
+
{localityKnown && !localClient && sourceAssetUrl && (
|
|
1892
|
+
<Button asChild size="sm" variant="outline">
|
|
1893
|
+
<a
|
|
1894
|
+
data-testid="doc-open-original"
|
|
1895
|
+
href={sourceAssetUrl}
|
|
1896
|
+
rel="noopener"
|
|
1897
|
+
target="_blank"
|
|
1898
|
+
>
|
|
1899
|
+
<SquareArrowOutUpRightIcon className="mr-1.5 size-4" />
|
|
1900
|
+
Open original
|
|
1901
|
+
</a>
|
|
1902
|
+
</Button>
|
|
1903
|
+
)}
|
|
1845
1904
|
</>
|
|
1846
1905
|
)}
|
|
1847
|
-
{
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1906
|
+
{localClient &&
|
|
1907
|
+
doc.capabilities.editable &&
|
|
1908
|
+
doc.source.absPath && (
|
|
1909
|
+
<Button
|
|
1910
|
+
className="gap-1.5"
|
|
1911
|
+
data-testid="doc-reveal"
|
|
1912
|
+
onClick={() => {
|
|
1913
|
+
void handleReveal();
|
|
1914
|
+
}}
|
|
1915
|
+
size="sm"
|
|
1916
|
+
variant="outline"
|
|
1917
|
+
>
|
|
1918
|
+
<FolderOpen className="size-4" />
|
|
1919
|
+
Reveal
|
|
1920
|
+
</Button>
|
|
1921
|
+
)}
|
|
1860
1922
|
{isPdf && pdfAssetUrl ? (
|
|
1861
1923
|
<Button
|
|
1862
1924
|
asChild
|
|
@@ -44,6 +44,10 @@ import {
|
|
|
44
44
|
type QueryModeType,
|
|
45
45
|
type TagMode,
|
|
46
46
|
} from "../lib/retrieval-filters";
|
|
47
|
+
import {
|
|
48
|
+
fetchServerCapabilities,
|
|
49
|
+
type ServerCapabilities,
|
|
50
|
+
} from "../lib/server-capabilities";
|
|
47
51
|
import { cn } from "../lib/utils";
|
|
48
52
|
import { AIModelSelector, TagFacets } from "./search-page-widgets";
|
|
49
53
|
|
|
@@ -121,13 +125,6 @@ interface SearchResponse {
|
|
|
121
125
|
};
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
interface Capabilities {
|
|
125
|
-
bm25: boolean;
|
|
126
|
-
vector: boolean;
|
|
127
|
-
hybrid: boolean;
|
|
128
|
-
answer: boolean;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
128
|
interface Collection {
|
|
132
129
|
name: string;
|
|
133
130
|
}
|
|
@@ -157,7 +154,9 @@ export default function Search({ navigate }: PageProps) {
|
|
|
157
154
|
const [loading, setLoading] = useState(false);
|
|
158
155
|
const [error, setError] = useState<string | null>(null);
|
|
159
156
|
const [searched, setSearched] = useState(false);
|
|
160
|
-
const [capabilities, setCapabilities] = useState<
|
|
157
|
+
const [capabilities, setCapabilities] = useState<ServerCapabilities | null>(
|
|
158
|
+
null
|
|
159
|
+
);
|
|
161
160
|
const [collections, setCollections] = useState<Collection[]>([]);
|
|
162
161
|
const [activePreset, setActivePreset] = useState("slim-tuned");
|
|
163
162
|
|
|
@@ -257,7 +256,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
257
256
|
async function bootstrap(): Promise<void> {
|
|
258
257
|
const [capabilitiesResult, collectionsResult, presetsResult] =
|
|
259
258
|
await Promise.all([
|
|
260
|
-
|
|
259
|
+
fetchServerCapabilities(),
|
|
261
260
|
apiFetch<Collection[]>("/api/collections"),
|
|
262
261
|
apiFetch<PresetsResponse>("/api/presets"),
|
|
263
262
|
]);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request locality: is the caller a same-host browser?
|
|
3
|
+
*
|
|
4
|
+
* `gno serve` binds to loopback only, so every remote client reaches it through
|
|
5
|
+
* a same-host proxy or forwarder whose socket peer is loopback. A peer-only
|
|
6
|
+
* check would call those clients local; this rule also requires a loopback
|
|
7
|
+
* Host header and the absence of forwarding headers, and it fails closed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { HttpMcpPeerServer } from "../mcp/http-security";
|
|
11
|
+
|
|
12
|
+
export type RequestPeerServer = Pick<HttpMcpPeerServer, "requestIP">;
|
|
13
|
+
|
|
14
|
+
const FORWARDING_HEADERS = [
|
|
15
|
+
"forwarded",
|
|
16
|
+
"via",
|
|
17
|
+
"x-forwarded-for",
|
|
18
|
+
"x-forwarded-host",
|
|
19
|
+
"x-forwarded-proto",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
const IPV4_MAPPED_PREFIX = "::ffff:";
|
|
23
|
+
const IPV4_OCTET = /^\d{1,3}$/;
|
|
24
|
+
const HOST_PORT = /^:\d{1,5}$/;
|
|
25
|
+
const LOOPBACK_IPV4_FIRST_OCTET = 127;
|
|
26
|
+
const MAX_OCTET = 255;
|
|
27
|
+
const IPV4_OCTET_COUNT = 4;
|
|
28
|
+
|
|
29
|
+
/** Loopback socket address: 127.0.0.0/8, ::1, or an IPv4-mapped form. */
|
|
30
|
+
export function isLoopbackAddress(address: string): boolean {
|
|
31
|
+
const normalized = address.toLowerCase();
|
|
32
|
+
if (normalized === "::1") return true;
|
|
33
|
+
const mapped = normalized.startsWith(IPV4_MAPPED_PREFIX)
|
|
34
|
+
? normalized.slice(IPV4_MAPPED_PREFIX.length)
|
|
35
|
+
: normalized;
|
|
36
|
+
const octets = mapped.split(".");
|
|
37
|
+
return (
|
|
38
|
+
octets.length === IPV4_OCTET_COUNT &&
|
|
39
|
+
octets.every((octet) => IPV4_OCTET.test(octet)) &&
|
|
40
|
+
Number(octets[0]) === LOOPBACK_IPV4_FIRST_OCTET &&
|
|
41
|
+
octets.every((octet) => Number(octet) <= MAX_OCTET)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Host part of a Host header (`name`, `name:port`, `[v6]`, `[v6]:port`). */
|
|
46
|
+
function hostHeaderName(host: string): string | null {
|
|
47
|
+
const trimmed = host.trim().toLowerCase();
|
|
48
|
+
if (trimmed.startsWith("[")) {
|
|
49
|
+
const end = trimmed.indexOf("]");
|
|
50
|
+
if (end === -1) return null;
|
|
51
|
+
const rest = trimmed.slice(end + 1);
|
|
52
|
+
if (rest !== "" && !HOST_PORT.test(rest)) return null;
|
|
53
|
+
return trimmed.slice(1, end) || null;
|
|
54
|
+
}
|
|
55
|
+
const [name, ...portParts] = trimmed.split(":");
|
|
56
|
+
if (portParts.length > 1) return null;
|
|
57
|
+
if (portParts.length === 1 && !HOST_PORT.test(`:${portParts[0]}`)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return name || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Host header naming a loopback host: `localhost`, `127.x.x.x`, or `[::1]`. */
|
|
64
|
+
export function isLoopbackHostHeader(host: string | null): boolean {
|
|
65
|
+
if (!host) return false;
|
|
66
|
+
const name = hostHeaderName(host);
|
|
67
|
+
if (!name) return false;
|
|
68
|
+
return name === "localhost" || isLoopbackAddress(name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* True only when the socket peer is loopback, the Host header names a loopback
|
|
73
|
+
* host, and no forwarding header is present. Any other combination, a missing
|
|
74
|
+
* server, or an unknown peer yields false.
|
|
75
|
+
*/
|
|
76
|
+
export function isLocalClientRequest(
|
|
77
|
+
request: Request,
|
|
78
|
+
server: RequestPeerServer | undefined
|
|
79
|
+
): boolean {
|
|
80
|
+
const peer = server?.requestIP(request) ?? null;
|
|
81
|
+
if (!peer || !isLoopbackAddress(peer.address)) return false;
|
|
82
|
+
if (!isLoopbackHostHeader(request.headers.get("host"))) return false;
|
|
83
|
+
return !FORWARDING_HEADERS.some((header) => request.headers.has(header));
|
|
84
|
+
}
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -177,6 +177,10 @@ import {
|
|
|
177
177
|
} from "../file-refactor-http";
|
|
178
178
|
import { analyzeImportPath } from "../import-preview";
|
|
179
179
|
import { getActiveJob, getJobStatus, startJob } from "../jobs";
|
|
180
|
+
import {
|
|
181
|
+
isLocalClientRequest,
|
|
182
|
+
type RequestPeerServer,
|
|
183
|
+
} from "../request-locality";
|
|
180
184
|
import {
|
|
181
185
|
requestRetrievalTraceId,
|
|
182
186
|
withRetrievalTraceHeader,
|
|
@@ -2179,6 +2183,53 @@ function parseSingleByteRange(
|
|
|
2179
2183
|
return { ok: true, start, end };
|
|
2180
2184
|
}
|
|
2181
2185
|
|
|
2186
|
+
/**
|
|
2187
|
+
* Revalidate-on-every-open: the browser asks once per open and reuses the
|
|
2188
|
+
* cached bytes on 304. Deliberately replaces fn-112's `no-store` (fn-136 R3).
|
|
2189
|
+
*/
|
|
2190
|
+
export const DOC_ASSET_CACHE_CONTROL = "private, max-age=0, must-revalidate";
|
|
2191
|
+
|
|
2192
|
+
const WEAK_ETAG_PREFIX = "W/";
|
|
2193
|
+
|
|
2194
|
+
/** Strong, quoted validator from file size and mtime (fn-136 R3). */
|
|
2195
|
+
export function docAssetEtag(file: {
|
|
2196
|
+
size: number;
|
|
2197
|
+
lastModified: number;
|
|
2198
|
+
}): string {
|
|
2199
|
+
return `"${file.size.toString(16)}-${file.lastModified.toString(16)}"`;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* RFC 9110 §13.1.2 If-None-Match: `*` or any listed validator matching under
|
|
2204
|
+
* weak comparison (a `W/` prefix is ignored on the client's side).
|
|
2205
|
+
*
|
|
2206
|
+
* The list is split on a bare comma. Our validators (`"<size-hex>-<mtime-hex>"`)
|
|
2207
|
+
* never contain one; a foreign tag that does would only produce a false
|
|
2208
|
+
* negative (full body instead of 304), never a false match.
|
|
2209
|
+
*/
|
|
2210
|
+
export function etagMatches(
|
|
2211
|
+
ifNoneMatch: string | null | undefined,
|
|
2212
|
+
etag: string
|
|
2213
|
+
): boolean {
|
|
2214
|
+
if (!ifNoneMatch) {
|
|
2215
|
+
return false;
|
|
2216
|
+
}
|
|
2217
|
+
const trimmed = ifNoneMatch.trim();
|
|
2218
|
+
if (trimmed === "*") {
|
|
2219
|
+
return true;
|
|
2220
|
+
}
|
|
2221
|
+
for (const candidate of trimmed.split(",")) {
|
|
2222
|
+
const value = candidate.trim();
|
|
2223
|
+
const strong = value.startsWith(WEAK_ETAG_PREFIX)
|
|
2224
|
+
? value.slice(WEAK_ETAG_PREFIX.length)
|
|
2225
|
+
: value;
|
|
2226
|
+
if (strong === etag) {
|
|
2227
|
+
return true;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
return false;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2182
2233
|
/**
|
|
2183
2234
|
* GET|HEAD /api/doc-asset
|
|
2184
2235
|
* Query params:
|
|
@@ -2187,6 +2238,8 @@ function parseSingleByteRange(
|
|
|
2187
2238
|
*
|
|
2188
2239
|
* Supports single-range Range requests (206/416). Multi-range → 416 (I1-03).
|
|
2189
2240
|
* HEAD mirrors GET status/headers with empty body (I1-02).
|
|
2241
|
+
* Strong ETag from size + mtime; a matching If-None-Match answers 304 before
|
|
2242
|
+
* any Range handling, so full and ranged GETs revalidate alike (fn-136 R3).
|
|
2190
2243
|
*/
|
|
2191
2244
|
export async function handleDocAsset(
|
|
2192
2245
|
store: SqliteAdapter,
|
|
@@ -2269,13 +2322,24 @@ export async function handleDocAsset(
|
|
|
2269
2322
|
|
|
2270
2323
|
const isHead = (request?.method ?? "GET").toUpperCase() === "HEAD";
|
|
2271
2324
|
const filename = resolvedPath.split(/[\\/]/u).at(-1) ?? "document";
|
|
2325
|
+
const etag = docAssetEtag(file);
|
|
2272
2326
|
const headers = new Headers({
|
|
2273
2327
|
"Accept-Ranges": "bytes",
|
|
2274
|
-
"Cache-Control":
|
|
2328
|
+
"Cache-Control": DOC_ASSET_CACHE_CONTROL,
|
|
2275
2329
|
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
2276
2330
|
"Content-Type": file.type || "application/octet-stream",
|
|
2331
|
+
ETag: etag,
|
|
2277
2332
|
});
|
|
2278
2333
|
|
|
2334
|
+
if (etagMatches(request?.headers.get("If-None-Match"), etag)) {
|
|
2335
|
+
// 304 carries the revalidation headers of the 200 set (RFC 9110 §15.4.5)
|
|
2336
|
+
// and nothing that describes a body.
|
|
2337
|
+
const notModified = new Headers(headers);
|
|
2338
|
+
notModified.delete("Content-Disposition");
|
|
2339
|
+
notModified.delete("Content-Type");
|
|
2340
|
+
return new Response(null, { status: 304, headers: notModified });
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2279
2343
|
const rangeHeader = request?.headers.get("Range");
|
|
2280
2344
|
if (!rangeHeader) {
|
|
2281
2345
|
headers.set("Content-Length", String(file.size));
|
|
@@ -3114,6 +3178,12 @@ export async function handleCreateFolder(
|
|
|
3114
3178
|
}
|
|
3115
3179
|
}
|
|
3116
3180
|
|
|
3181
|
+
/**
|
|
3182
|
+
* POST /api/docs/:id/reveal
|
|
3183
|
+
* Opens the source file in the host's file manager. Only a local client may
|
|
3184
|
+
* open windows on the server host: a request judged remote by the locality
|
|
3185
|
+
* rule is refused before the document is resolved.
|
|
3186
|
+
*/
|
|
3117
3187
|
export async function handleRevealDoc(
|
|
3118
3188
|
ctxHolder: ContextHolder,
|
|
3119
3189
|
store: SqliteAdapter,
|
|
@@ -3121,8 +3191,17 @@ export async function handleRevealDoc(
|
|
|
3121
3191
|
req?: Request,
|
|
3122
3192
|
deps?: {
|
|
3123
3193
|
revealFilePath?: typeof revealFilePath;
|
|
3194
|
+
server?: RequestPeerServer;
|
|
3124
3195
|
}
|
|
3125
3196
|
): Promise<Response> {
|
|
3197
|
+
// Fail closed: no request (no peer to judge) is treated as remote.
|
|
3198
|
+
if (!req || !isLocalClientRequest(req, deps?.server)) {
|
|
3199
|
+
return errorResponse(
|
|
3200
|
+
"FORBIDDEN",
|
|
3201
|
+
"Reveal is only available to a local client",
|
|
3202
|
+
403
|
|
3203
|
+
);
|
|
3204
|
+
}
|
|
3126
3205
|
const docResult = await resolveDocumentReference(
|
|
3127
3206
|
store,
|
|
3128
3207
|
docId,
|
|
@@ -4878,14 +4957,20 @@ export async function handleAsk(
|
|
|
4878
4957
|
|
|
4879
4958
|
/**
|
|
4880
4959
|
* GET /api/capabilities
|
|
4881
|
-
* Returns server capabilities (what features are available)
|
|
4960
|
+
* Returns server capabilities (what features are available) plus whether the
|
|
4961
|
+
* caller is a same-host client (see request-locality).
|
|
4882
4962
|
*/
|
|
4883
|
-
export function handleCapabilities(
|
|
4963
|
+
export function handleCapabilities(
|
|
4964
|
+
ctx: ServerContext,
|
|
4965
|
+
req: Request,
|
|
4966
|
+
server: RequestPeerServer | undefined
|
|
4967
|
+
): Response {
|
|
4884
4968
|
return jsonResponse({
|
|
4885
4969
|
bm25: ctx.capabilities.bm25,
|
|
4886
4970
|
vector: ctx.capabilities.vector,
|
|
4887
4971
|
hybrid: ctx.capabilities.hybrid,
|
|
4888
4972
|
answer: ctx.capabilities.answer,
|
|
4973
|
+
localClient: isLocalClientRequest(req, server),
|
|
4889
4974
|
});
|
|
4890
4975
|
}
|
|
4891
4976
|
|