mulmoclaude 2.9.0 → 2.10.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.
Files changed (30) hide show
  1. package/client/assets/{feat-3497-qPLQlhNs.js → feat-3497-Cwr8t4l4.js} +469 -469
  2. package/client/assets/{feat-3497-CVVJjM2K.css → feat-3497-DEfIEPf6.css} +1 -1
  3. package/client/assets/{marp-C3oyTBz_.js → marp-CmMNIT1E.js} +1 -1
  4. package/client/index.html +2 -2
  5. package/package.json +3 -3
  6. package/server/api/routes/files.ts +230 -89
  7. package/server/api/routes/pdf.ts +68 -62
  8. package/server/api/routes/runtime-plugin.ts +33 -32
  9. package/server/build/mcp-server.mjs +590 -590
  10. package/server/index.ts +27 -94
  11. package/server/plugins/confined-file-ops.ts +5 -3
  12. package/server/system/config.ts +1 -1
  13. package/server/utils/exif.ts +4 -7
  14. package/server/utils/files/artifactMount.ts +89 -0
  15. package/server/utils/files/attachment-store.ts +17 -29
  16. package/server/utils/files/image-store.ts +7 -13
  17. package/server/utils/files/plugins-io.ts +13 -5
  18. package/server/utils/files/safe.ts +0 -8
  19. package/server/utils/files/sendFileHandle.ts +115 -0
  20. package/server/utils/files/store-files.ts +86 -0
  21. package/server/utils/files/storePaths.ts +23 -0
  22. package/server/utils/files/thumbnail-store.ts +25 -23
  23. package/server/utils/files/upload-io.ts +9 -8
  24. package/server/utils/html/htmlArtifactSplicer.ts +19 -14
  25. package/server/utils/share/packHtml.ts +23 -34
  26. package/server/workspace/photo-locations/index.ts +26 -27
  27. package/server/workspace/photo-locations/list.ts +17 -29
  28. package/server/workspace/photo-locations/store.ts +4 -0
  29. package/server/workspace/wiki-pages/io.ts +9 -8
  30. package/server/utils/files/store-resolvers.ts +0 -58
@@ -1,13 +1,14 @@
1
1
  import { Router, Request, Response } from "express";
2
- import { Dirent, ReadStream, Stats, createReadStream, readFileSync, realpathSync } from "fs";
3
- import { mkdir, realpath, writeFile } from "fs/promises";
2
+ import { Dirent, ReadStream, Stats, realpathSync } from "fs";
3
+ import { realpath, type FileHandle } from "fs/promises";
4
4
  import path from "path";
5
5
  import { workspacePath } from "../../workspace/workspace.js";
6
- import { statSafe, statSafeAsync, readDirSafeAsync, resolveWithinRoot, writeFileAtomic } from "../../utils/files/index.js";
6
+ import { statSafe, statSafeAsync, readDirSafeAsync, resolveWithinRoot } from "../../utils/files/index.js";
7
7
  import { stripDataUri } from "../../utils/files/attachment-store.js";
8
8
  import { writeNewFileExclusive } from "../../utils/files/upload-io.js";
9
9
  import { MAX_RENAME_ATTEMPTS, renamedCandidate, sanitizeUploadFilename, uploadRelPath } from "../../utils/files/upload-name.js";
10
- import { joinPosixRelPath, toPosixRelPath } from "@mulmoclaude/core/files";
10
+ import { joinPosixRelPath, realpathIfPresent, toPosixRelPath, writeFileVerifiedInScope } from "@mulmoclaude/core/files";
11
+ import { openArtifactFile } from "../../utils/files/artifactMount.js";
11
12
  import { errorMessage } from "../../utils/errors.js";
12
13
  import { badRequest, notFound, sendError, serverError } from "../../utils/httpError.js";
13
14
  import { jsonSyntaxError, MAX_PREVIEW_BYTES } from "../../utils/files/content-write-validate.js";
@@ -362,7 +363,7 @@ function isRefPath(relPath: string): boolean {
362
363
  * the label is unknown, the path escapes the ref root, or the
363
364
  * resolved file is sensitive / hidden.
364
365
  */
365
- function resolveRefPath(prefixedPath: string): string | null {
366
+ function resolveRefPath(prefixedPath: string): { rootReal: string; absPath: string } | null {
366
367
  const afterPrefix = prefixedPath.slice(REF_PREFIX.length);
367
368
  const slashIdx = afterPrefix.indexOf("/");
368
369
  const label = slashIdx >= 0 ? afterPrefix.slice(0, slashIdx) : afterPrefix;
@@ -381,7 +382,7 @@ function resolveRefPath(prefixedPath: string): string | null {
381
382
  const rootReal = target.realPath;
382
383
 
383
384
  // For root of the reference dir (no remainder), return the dir itself
384
- if (!remainder) return rootReal;
385
+ if (!remainder) return { rootReal, absPath: rootReal };
385
386
 
386
387
  const resolved = resolveWithinRoot(rootReal, remainder);
387
388
  if (!resolved) return null;
@@ -395,7 +396,7 @@ function resolveRefPath(prefixedPath: string): string | null {
395
396
  }
396
397
  if (isSensitivePath(resolved)) return null;
397
398
 
398
- return resolved;
399
+ return { rootReal, absPath: resolved };
399
400
  }
400
401
 
401
402
  export interface ByteRange {
@@ -704,7 +705,7 @@ router.get(API_ROUTES.files.dir, async (req: Request<object, unknown, unknown, P
704
705
 
705
706
  // Reference directory branch — resolve against the registered ref dir
706
707
  if (isRefPath(relPath)) {
707
- const absPath = resolveRefPath(relPath);
708
+ const absPath = resolveRefPath(relPath)?.absPath;
708
709
  if (!absPath) {
709
710
  log.warn("files", "GET dir: ref dir not found", { pathPreview: previewSnippet(relPath) });
710
711
  notFound(res, "Not found");
@@ -756,6 +757,14 @@ interface PathQuery {
756
757
  path?: string;
757
758
  }
758
759
 
760
+ interface ResolvedFile {
761
+ relPath: string;
762
+ absPath: string;
763
+ /** The root `absPath` is confined to: the workspace or a reference directory. */
764
+ rootReal: string;
765
+ stat: Stats;
766
+ }
767
+
759
768
  // Shared validation preamble for /files/content and /files/raw. Both
760
769
  // endpoints need to: read `path` from the query, validate it's
761
770
  // inside the workspace (with symlink hardening), stat it, and
@@ -771,14 +780,11 @@ interface PathQuery {
771
780
  // for symlink escapes (which would return 400). Doing them in this
772
781
  // order keeps 404 reachable for the common "file not found" case
773
782
  // instead of conflating it with traversal attempts.
774
- function resolveAndStatFile<T>(
775
- req: Request<object, unknown, unknown, PathQuery>,
776
- res: Response<T | ErrorResponse>,
777
- ): { relPath: string; absPath: string; stat: Stats } | null {
783
+ function resolveAndStatFile<T>(req: Request<object, unknown, unknown, PathQuery>, res: Response<T | ErrorResponse>): ResolvedFile | null {
778
784
  return resolveAndStatFileByPath(getOptionalStringQuery(req, "path") ?? "", res);
779
785
  }
780
786
 
781
- function resolveAndStatFileByPath<T>(relPath: string, res: Response<T | ErrorResponse>): { relPath: string; absPath: string; stat: Stats } | null {
787
+ function resolveAndStatFileByPath<T>(relPath: string, res: Response<T | ErrorResponse>): ResolvedFile | null {
782
788
  if (!relPath) {
783
789
  badRequest(res, "path required");
784
790
  return null;
@@ -786,17 +792,17 @@ function resolveAndStatFileByPath<T>(relPath: string, res: Response<T | ErrorRes
786
792
 
787
793
  // Reference directory branch
788
794
  if (isRefPath(relPath)) {
789
- const absPath = resolveRefPath(relPath);
790
- if (!absPath) {
795
+ const target = resolveRefPath(relPath);
796
+ if (!target) {
791
797
  notFound(res, "Not found");
792
798
  return null;
793
799
  }
794
- const stat = statSafe(absPath);
800
+ const stat = statSafe(target.absPath);
795
801
  if (!stat || !stat.isFile()) {
796
802
  notFound(res, "File not found");
797
803
  return null;
798
804
  }
799
- return { relPath, absPath, stat };
805
+ return { relPath, ...target, stat };
800
806
  }
801
807
 
802
808
  // Workspace path — existing logic
@@ -829,7 +835,55 @@ function resolveAndStatFileByPath<T>(relPath: string, res: Response<T | ErrorRes
829
835
  badRequest(res, "Path outside workspace");
830
836
  return null;
831
837
  }
832
- return { relPath, absPath, stat };
838
+ return { relPath, absPath, rootReal: workspaceReal, stat };
839
+ }
840
+
841
+ interface OpenedFile {
842
+ handle: FileHandle;
843
+ stat: Stats;
844
+ }
845
+
846
+ // A path component that became a link (ELOOP) or a file (ENOTDIR) while it was being opened.
847
+ const SWAPPED_DURING_OPEN = new Set(["ELOOP", "ENOTDIR"]);
848
+
849
+ // The async realpath is the form the verified open compares against (the forms differ on Windows). The workspace's is
850
+ // cached, so a workspace folder replaced after start-up is not taken as the new anchor.
851
+ const anchorFor = (file: ResolvedFile): Promise<string | null> =>
852
+ file.rootReal === workspaceReal ? getWorkspaceRealAsync() : realpathIfPresent(file.rootReal);
853
+
854
+ const sameInode = (opened: Stats, checked: Stats): boolean => opened.dev === checked.dev && opened.ino === checked.ino;
855
+
856
+ // The checks above ran on a path that can be swapped before it is read, so the bytes come from a handle proven to be the
857
+ // in-root file the check stat'ed.
858
+ async function openResolvedFile(file: ResolvedFile): Promise<OpenedFile | null> {
859
+ const trustedReal = await anchorFor(file);
860
+ const handle = trustedReal === null ? null : await openArtifactFile({ trustedReal, scopeWithinTrusted: "" }, path.relative(file.rootReal, file.absPath));
861
+ if (handle === null) return null;
862
+ const stat = await handle.stat().catch(async (err: unknown) => {
863
+ await handle.close();
864
+ throw err;
865
+ });
866
+ if (sameInode(stat, file.stat)) return { handle, stat };
867
+ await handle.close();
868
+ return null;
869
+ }
870
+
871
+ // A file gone or swapped since the check is a 404; an open that fails outright is the read failure it always was.
872
+ async function openForRead<T>(file: ResolvedFile, res: Response<T | ErrorResponse>, label: string): Promise<OpenedFile | null> {
873
+ try {
874
+ const opened = await openResolvedFile(file);
875
+ if (opened === null) {
876
+ log.warn("files", `${label}: changed before it was read`, { pathPreview: previewSnippet(file.relPath) });
877
+ notFound(res, "File not found");
878
+ }
879
+ return opened;
880
+ } catch (err) {
881
+ const swapped = isErrorWithCode(err) && SWAPPED_DURING_OPEN.has(String(err.code));
882
+ log.warn("files", `${label}: open threw`, { pathPreview: previewSnippet(file.relPath), error: errorMessage(err) });
883
+ if (swapped) notFound(res, "File not found");
884
+ else serverError(res, "Failed to read file");
885
+ return null;
886
+ }
833
887
  }
834
888
 
835
889
  // Decide the metadata-only response for a resolved file based on its
@@ -871,7 +925,49 @@ export function decideContentResponse(kind: ContentKind, meta: { path: string; s
871
925
  return null;
872
926
  }
873
927
 
874
- router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, PathQuery>, res: Response<FileContentResponse | ErrorResponse>) => {
928
+ const fileMeta = (relPath: string, stat: Stats) => ({ path: relPath, size: stat.size, modifiedMs: stat.mtimeMs });
929
+
930
+ // One read may return fewer bytes than asked before EOF (network and FUSE mounts), so it repeats until full or EOF.
931
+ async function readInto(handle: FileHandle, buffer: Buffer, offset: number): Promise<number> {
932
+ if (offset >= buffer.length) return offset;
933
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
934
+ return bytesRead === 0 ? offset : readInto(handle, buffer, offset + bytesRead);
935
+ }
936
+
937
+ // At most `size` bytes, so a file appended to while it is read stays within the cap it was judged by.
938
+ async function readTextBounded(handle: FileHandle, size: number): Promise<string> {
939
+ const buffer = Buffer.alloc(size);
940
+ const filled = await readInto(handle, buffer, 0);
941
+ return buffer.toString("utf-8", 0, filled);
942
+ }
943
+
944
+ async function answerText(res: Response<FileContentResponse | ErrorResponse>, relPath: string, kind: ContentKind, opened: OpenedFile): Promise<void> {
945
+ const meta = fileMeta(relPath, opened.stat);
946
+ // Judged again on the opened file: it may have been rewritten in place since the check.
947
+ const preview = decideContentResponse(kind, meta);
948
+ if (preview) {
949
+ res.json(preview);
950
+ return;
951
+ }
952
+ const content = await readTextBounded(opened.handle, meta.size);
953
+ log.info("files", "GET content: ok", { pathPreview: previewSnippet(relPath), bytes: meta.size });
954
+ res.json({ kind: "text", ...meta, content });
955
+ }
956
+
957
+ async function sendTextContent(res: Response<FileContentResponse | ErrorResponse>, file: ResolvedFile, kind: ContentKind): Promise<void> {
958
+ const opened = await openForRead(file, res, "GET content");
959
+ if (!opened) return;
960
+ try {
961
+ await answerText(res, file.relPath, kind, opened);
962
+ } catch (err) {
963
+ log.error("files", "GET content: read threw", { pathPreview: previewSnippet(file.relPath), error: errorMessage(err) });
964
+ serverError(res, "Failed to read file");
965
+ } finally {
966
+ await opened.handle.close();
967
+ }
968
+ }
969
+
970
+ router.get(API_ROUTES.files.content, async (req: Request<object, unknown, unknown, PathQuery>, res: Response<FileContentResponse | ErrorResponse>) => {
875
971
  const requestedPath = getOptionalStringQuery(req, "path") ?? "";
876
972
  log.info("files", "GET content: start", { pathPreview: previewSnippet(requestedPath) });
877
973
  const ctx = resolveAndStatFile(req, res);
@@ -882,30 +978,13 @@ router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, Pat
882
978
  log.warn("files", "GET content: gated by resolve/stat", { pathPreview: previewSnippet(requestedPath) });
883
979
  return;
884
980
  }
885
- const { relPath, absPath, stat } = ctx;
886
-
887
- const meta = {
888
- path: relPath,
889
- size: stat.size,
890
- modifiedMs: stat.mtimeMs,
891
- };
892
-
893
- const kind = classify(absPath);
894
- const preview = decideContentResponse(kind, meta);
981
+ const kind = classify(ctx.absPath);
982
+ const preview = decideContentResponse(kind, fileMeta(ctx.relPath, ctx.stat));
895
983
  if (preview) {
896
984
  res.json(preview);
897
985
  return;
898
986
  }
899
- let content: string;
900
- try {
901
- content = readFileSync(absPath, "utf-8");
902
- } catch (err) {
903
- log.error("files", "GET content: read threw", { pathPreview: previewSnippet(relPath), error: errorMessage(err) });
904
- serverError(res, "Failed to read file");
905
- return;
906
- }
907
- log.info("files", "GET content: ok", { pathPreview: previewSnippet(relPath), bytes: stat.size });
908
- res.json({ kind: "text", ...meta, content });
987
+ await sendTextContent(res, ctx, kind);
909
988
  });
910
989
 
911
990
  type ResolvedTextFile = { ok: true; absPath: string } | { ok: false; status: 400 | 404; message: string };
@@ -937,22 +1016,24 @@ async function resolveExistingTextFile(relPathRaw: string): Promise<ResolvedText
937
1016
 
938
1017
  // Wiki pages route through `writeWikiPage` so the (old, new) pair
939
1018
  // reaches the edit-history pipeline (#763). Everything else takes
940
- // the generic atomic write. `uniqueTmp: true` appends a randomUUID
941
- // to the tmp filename so two simultaneous PUTs to the same path
942
- // can't clobber each other's staging file and race through rename
943
- // (writeWikiPage applies it internally).
1019
+ // the verified atomic write: its staging file has a random name, so
1020
+ // two simultaneous PUTs to the same path can't clobber each other's
1021
+ // staging file, and it is proven to sit inside the workspace before a
1022
+ // byte is written, so a folder swapped for a link out since the check
1023
+ // is refused rather than written through.
944
1024
  //
945
1025
  // `workspaceReal` is the already-realpath'd workspace root —
946
1026
  // resolveSafe returns a realpath'd absPath, so the classifier MUST
947
1027
  // compare against the same realpath'd root. A symlinked workspace
948
1028
  // (e.g. `~/mulmoclaude` → some real path elsewhere) would otherwise
949
1029
  // silently bypass the wiki chokepoint.
950
- async function writeFileContent(absPath: string, content: string): Promise<void> {
1030
+ export async function writeFileContent(absPath: string, content: string): Promise<void> {
951
1031
  const wikiClass = classifyAsWikiPage(absPath, { workspaceRoot: workspaceReal });
952
1032
  if (wikiClass.wiki) {
953
1033
  await writeWikiPage(wikiClass.slug, content, { editor: "user" }, { workspaceRoot: workspaceReal });
954
1034
  } else {
955
- await writeFileAtomic(absPath, content, { uniqueTmp: true });
1035
+ const scope = { trustedReal: await getWorkspaceRealAsync(), scopeWithinTrusted: "" };
1036
+ await writeFileVerifiedInScope(scope, path.dirname(absPath), path.basename(absPath), content);
956
1037
  }
957
1038
  }
958
1039
 
@@ -1033,9 +1114,10 @@ async function resolveNewFilePath(
1033
1114
  }
1034
1115
 
1035
1116
  // Perform the exclusive create write for POST /files/create. Atomic
1036
- // create via `wx` (POSIX O_EXCL) — fails with EEXIST if the target
1037
- // already exists, closing the check-then-write TOCTOU that Codex
1038
- // flagged on earlier iterations. Wiki pages route through
1117
+ // create via O_EXCL — fails with EEXIST if the target already exists,
1118
+ // closing the check-then-write TOCTOU that Codex flagged on earlier
1119
+ // iterations — and verified to sit inside the workspace before any
1120
+ // byte is written, so a folder swapped for a link out is refused. Wiki pages route through
1039
1121
  // `writeWikiPage` with `exclusive: true` so the same exclusive
1040
1122
  // primitive applies after the frontmatter stamp. On failure this
1041
1123
  // writes the appropriate 4xx/5xx response and returns false; the
@@ -1056,8 +1138,7 @@ async function performCreateWrite(
1056
1138
  if (wikiClass.wiki) {
1057
1139
  await writeWikiPage(wikiClass.slug, content, { editor: "user" }, { workspaceRoot: resolved.workspaceRoot, exclusive: true });
1058
1140
  } else {
1059
- await mkdir(path.dirname(resolved.absPath), { recursive: true });
1060
- await writeFile(resolved.absPath, content, { flag: "wx" });
1141
+ await writeNewFileExclusive(resolved.workspaceRoot, resolved.absPath, content);
1061
1142
  }
1062
1143
  } catch (err) {
1063
1144
  const code = isErrorWithCode(err) ? err.code : undefined;
@@ -1139,7 +1220,7 @@ export interface UploadWriteDeps {
1139
1220
  const defaultUploadWriteDeps: UploadWriteDeps = {
1140
1221
  resolve: resolveNewFilePath,
1141
1222
  // Exclusive create lives in its own I/O module; EEXIST drives the rename retry.
1142
- write: writeNewFileExclusive,
1223
+ write: async (absPath, bytes) => writeNewFileExclusive(await getWorkspaceRealAsync(), absPath, bytes),
1143
1224
  };
1144
1225
 
1145
1226
  // Write the bytes under `dirRel`, never clobbering: on EEXIST the name gets a
@@ -1248,23 +1329,19 @@ router.put(API_ROUTES.files.content, async (req: Request<object, unknown, WriteC
1248
1329
  await respondWithWrittenFile(res, { absPath: resolved.absPath, relPath, fallbackBytes: contentBytes, logLabel: "PUT content" });
1249
1330
  });
1250
1331
 
1251
- router.get(API_ROUTES.files.raw, (req: Request<object, unknown, unknown, PathQuery>, res: Response<ErrorResponse>) => {
1252
- const requestedPath = getOptionalStringQuery(req, "path") ?? "";
1253
- log.info("files", "GET raw: start", { pathPreview: previewSnippet(requestedPath) });
1254
- const ctx = resolveAndStatFile(req, res);
1255
- if (!ctx) {
1256
- log.warn("files", "GET raw: gated by resolve/stat", { pathPreview: previewSnippet(requestedPath) });
1257
- return;
1258
- }
1259
- const { absPath, stat } = ctx;
1260
-
1332
+ const rawSizeCapFor = (absPath: string): number => {
1261
1333
  const rawKind = classify(absPath);
1262
- const rawSizeCap = rawKind === "audio" || rawKind === "video" ? MAX_MEDIA_BYTES : MAX_RAW_BYTES;
1263
- if (stat.size > rawSizeCap) {
1264
- log.warn("files", "GET raw: too large", { pathPreview: previewSnippet(requestedPath), bytes: stat.size, cap: rawSizeCap });
1265
- sendError(res, 413, `File too large to stream (${stat.size} bytes, limit ${rawSizeCap})`);
1266
- return;
1267
- }
1334
+ return rawKind === "audio" || rawKind === "video" ? MAX_MEDIA_BYTES : MAX_RAW_BYTES;
1335
+ };
1336
+
1337
+ function rejectedAsTooLarge(res: Response<ErrorResponse>, size: number, cap: number, requestedPath: string): boolean {
1338
+ if (size <= cap) return false;
1339
+ log.warn("files", "GET raw: too large", { pathPreview: previewSnippet(requestedPath), bytes: size, cap });
1340
+ sendError(res, 413, `File too large to stream (${size} bytes, limit ${cap})`);
1341
+ return true;
1342
+ }
1343
+
1344
+ function setRawHeaders(res: Response<ErrorResponse>, absPath: string): void {
1268
1345
  const ext = path.extname(absPath).toLowerCase();
1269
1346
  const mime = MIME_BY_EXT[ext] ?? "application/octet-stream";
1270
1347
  res.setHeader("Accept-Ranges", "bytes");
@@ -1276,33 +1353,97 @@ router.get(API_ROUTES.files.raw, (req: Request<object, unknown, unknown, PathQue
1276
1353
  // browser cannot show them under it. See plans/done/
1277
1354
  // fix-files-raw-csp-sandbox.md for the full threat model.
1278
1355
  applyRawSecurityHeaders(res, mime);
1356
+ }
1279
1357
 
1280
- // Range support is required for `<video>` playback (Safari refuses
1281
- // to play media without 206 responses) and for seek-past-buffered
1282
- // in `<audio>`. When no Range header is sent we fall through to
1283
- // the existing full-file pipe.
1358
+ // A client that left before the stream exists fired `close` already, so it is recorded from the start.
1359
+ function watchClientGone(res: Response<ErrorResponse>): () => boolean {
1360
+ const state = { gone: false };
1361
+ res.once("close", () => {
1362
+ state.gone = true;
1363
+ });
1364
+ return () => state.gone;
1365
+ }
1366
+
1367
+ const wholeFile = (size: number): ByteRange | null => (size > 0 ? { start: 0, end: size - 1 } : null);
1368
+
1369
+ // The stream closes the handle when it ends or is destroyed; a client that leaves mid-body destroys it. `null` is an
1370
+ // empty body. The end is bounded, so a file appended to meanwhile still matches its Content-Length.
1371
+ function pipeHandle(handle: FileHandle, res: Response<ErrorResponse>, range: ByteRange | null, clientGone: () => boolean): void {
1372
+ if (range === null || clientGone()) {
1373
+ handle.close().catch(() => {});
1374
+ if (range === null) res.end();
1375
+ return;
1376
+ }
1377
+ const stream = handle.createReadStream(range);
1378
+ res.on("close", () => stream.destroy());
1379
+ pipeWithErrorHandling(stream, res);
1380
+ }
1381
+
1382
+ // The media MIME was set by setRawHeaders so the 206 success path doesn't have to repeat it, but on a 416 we want JSON
1383
+ // so `res.json` doesn't lie about the body's content-type. Content-Range per RFC 7233 §4.4.
1384
+ function refuseRange(res: Response<ErrorResponse>, size: number): void {
1385
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
1386
+ res.setHeader("Content-Range", `bytes */${size}`);
1387
+ sendError(res, 416, "Range not satisfiable");
1388
+ }
1389
+
1390
+ // Range support is required for `<video>` playback (Safari refuses
1391
+ // to play media without 206 responses) and for seek-past-buffered
1392
+ // in `<audio>`. When no Range header is sent we fall through to
1393
+ // the full-file pipe.
1394
+ function streamRaw(
1395
+ req: Request<object, unknown, unknown, PathQuery>,
1396
+ res: Response<ErrorResponse>,
1397
+ handle: FileHandle,
1398
+ size: number,
1399
+ clientGone: () => boolean,
1400
+ ): void {
1284
1401
  const rangeHeader = req.headers.range;
1285
- if (rangeHeader) {
1286
- const range = parseRange(rangeHeader, stat.size);
1287
- if (!range) {
1288
- // The media MIME was set above so the 206 success path
1289
- // doesn't have to repeat it, but on a 416 we want JSON so
1290
- // `res.json` doesn't lie about the body's content-type. Set
1291
- // the Content-Range per RFC 7233 §4.4 before sending.
1292
- res.setHeader("Content-Type", "application/json; charset=utf-8");
1293
- res.setHeader("Content-Range", `bytes */${stat.size}`);
1294
- sendError(res, 416, "Range not satisfiable");
1295
- return;
1296
- }
1297
- res.status(206);
1298
- res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
1299
- res.setHeader("Content-Length", String(range.end - range.start + 1));
1300
- pipeWithErrorHandling(createReadStream(absPath, { start: range.start, end: range.end }), res);
1402
+ if (!rangeHeader) {
1403
+ res.setHeader("Content-Length", String(size));
1404
+ pipeHandle(handle, res, wholeFile(size), clientGone);
1405
+ return;
1406
+ }
1407
+ const range = parseRange(rangeHeader, size);
1408
+ if (!range) {
1409
+ refuseRange(res, size);
1410
+ handle.close().catch(() => {});
1301
1411
  return;
1302
1412
  }
1413
+ res.status(206);
1414
+ res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${size}`);
1415
+ res.setHeader("Content-Length", String(range.end - range.start + 1));
1416
+ pipeHandle(handle, res, range, clientGone);
1417
+ }
1303
1418
 
1304
- res.setHeader("Content-Length", String(stat.size));
1305
- pipeWithErrorHandling(createReadStream(absPath), res);
1419
+ // Size, range and length are taken from the opened file, so they describe the bytes that are sent.
1420
+ async function sendRaw(
1421
+ req: Request<object, unknown, unknown, PathQuery>,
1422
+ res: Response<ErrorResponse>,
1423
+ file: ResolvedFile,
1424
+ requestedPath: string,
1425
+ ): Promise<void> {
1426
+ const clientGone = watchClientGone(res);
1427
+ const opened = await openForRead(file, res, "GET raw");
1428
+ if (!opened) return;
1429
+ if (rejectedAsTooLarge(res, opened.stat.size, rawSizeCapFor(file.absPath), requestedPath)) {
1430
+ await opened.handle.close();
1431
+ return;
1432
+ }
1433
+ setRawHeaders(res, file.absPath);
1434
+ streamRaw(req, res, opened.handle, opened.stat.size, clientGone);
1435
+ }
1436
+
1437
+ router.get(API_ROUTES.files.raw, async (req: Request<object, unknown, unknown, PathQuery>, res: Response<ErrorResponse>) => {
1438
+ const requestedPath = getOptionalStringQuery(req, "path") ?? "";
1439
+ log.info("files", "GET raw: start", { pathPreview: previewSnippet(requestedPath) });
1440
+ const ctx = resolveAndStatFile(req, res);
1441
+ if (!ctx) {
1442
+ log.warn("files", "GET raw: gated by resolve/stat", { pathPreview: previewSnippet(requestedPath) });
1443
+ return;
1444
+ }
1445
+ if (rejectedAsTooLarge(res, ctx.stat.size, rawSizeCapFor(ctx.absPath), requestedPath)) return;
1446
+ await sendRaw(req, res, ctx, requestedPath);
1306
1447
  });
1307
1448
 
1308
1449
  // ── Reference directory roots ───────────────────────────────────