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.
- package/client/assets/{feat-3497-qPLQlhNs.js → feat-3497-Cwr8t4l4.js} +469 -469
- package/client/assets/{feat-3497-CVVJjM2K.css → feat-3497-DEfIEPf6.css} +1 -1
- package/client/assets/{marp-C3oyTBz_.js → marp-CmMNIT1E.js} +1 -1
- package/client/index.html +2 -2
- package/package.json +3 -3
- package/server/api/routes/files.ts +230 -89
- package/server/api/routes/pdf.ts +68 -62
- package/server/api/routes/runtime-plugin.ts +33 -32
- package/server/build/mcp-server.mjs +590 -590
- package/server/index.ts +27 -94
- package/server/plugins/confined-file-ops.ts +5 -3
- package/server/system/config.ts +1 -1
- package/server/utils/exif.ts +4 -7
- package/server/utils/files/artifactMount.ts +89 -0
- package/server/utils/files/attachment-store.ts +17 -29
- package/server/utils/files/image-store.ts +7 -13
- package/server/utils/files/plugins-io.ts +13 -5
- package/server/utils/files/safe.ts +0 -8
- package/server/utils/files/sendFileHandle.ts +115 -0
- package/server/utils/files/store-files.ts +86 -0
- package/server/utils/files/storePaths.ts +23 -0
- package/server/utils/files/thumbnail-store.ts +25 -23
- package/server/utils/files/upload-io.ts +9 -8
- package/server/utils/html/htmlArtifactSplicer.ts +19 -14
- package/server/utils/share/packHtml.ts +23 -34
- package/server/workspace/photo-locations/index.ts +26 -27
- package/server/workspace/photo-locations/list.ts +17 -29
- package/server/workspace/photo-locations/store.ts +4 -0
- package/server/workspace/wiki-pages/io.ts +9 -8
- 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,
|
|
3
|
-
import {
|
|
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
|
|
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>):
|
|
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
|
|
790
|
-
if (!
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
//
|
|
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
|
-
|
|
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
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
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
|
-
|
|
1305
|
-
|
|
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 ───────────────────────────────────
|