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
@@ -0,0 +1,115 @@
1
+ // Serve an already-opened file. `express.static` re-opens by path after the caller's check, so a
2
+ // path swapped in between is followed; serving from the verified handle closes that window.
3
+
4
+ import path from "node:path";
5
+ import type { Stats } from "node:fs";
6
+ import type { FileHandle } from "node:fs/promises";
7
+ import { pipeline } from "node:stream/promises";
8
+ import type { Request, Response } from "express";
9
+
10
+ const HEX = 16;
11
+ const RANGE_NOT_SATISFIABLE = -1;
12
+
13
+ export interface ByteRange {
14
+ start: number;
15
+ end: number;
16
+ }
17
+
18
+ /** What `req.range(size, { combine: true })` returns: ranges, -1 (unsatisfiable), -2 (malformed) or nothing. */
19
+ export type ParsedRange = number | readonly ByteRange[] | undefined;
20
+
21
+ export type RangeChoice = { kind: "whole" } | { kind: "unsatisfiable" } | { kind: "part"; range: ByteRange };
22
+
23
+ const WHOLE: RangeChoice = { kind: "whole" };
24
+
25
+ /** Several ranges, a malformed header or none at all get the whole file, as `send` does. */
26
+ export function chooseByteRange(parsed: ParsedRange): RangeChoice {
27
+ if (parsed === RANGE_NOT_SATISFIABLE) return { kind: "unsatisfiable" };
28
+ const only = typeof parsed === "object" && parsed.length === 1 ? parsed[0] : undefined;
29
+ return only === undefined ? WHOLE : { kind: "part", range: only };
30
+ }
31
+
32
+ const weakEtag = (stats: Stats): string => `W/"${stats.size.toString(HEX)}-${Math.floor(stats.mtimeMs).toString(HEX)}"`;
33
+
34
+ /** The validators a conditional request is judged against, as headers carry them. */
35
+ export interface Validators {
36
+ etag: string;
37
+ lastModified: string;
38
+ }
39
+
40
+ const parseHttpDate = (value: string | undefined): number => (value === undefined ? Number.NaN : Date.parse(value));
41
+
42
+ const tokenList = (header: string): string[] =>
43
+ header
44
+ .split(",")
45
+ .map((token) => token.trim())
46
+ .filter((token) => token !== "");
47
+
48
+ const etagMatches = (token: string, etag: string): boolean => token === etag || `W/${token}` === etag || token === `W/${etag}`;
49
+
50
+ /** A Range is honoured without If-Range, or when If-Range names the current entity tag or a date no older than it — as `send` does. */
51
+ export function rangeApplies(ifRange: string | undefined, validators: Validators): boolean {
52
+ if (ifRange === undefined || ifRange === "") return true;
53
+ if (ifRange.includes('"')) return ifRange.includes(validators.etag);
54
+ return parseHttpDate(validators.lastModified) <= parseHttpDate(ifRange);
55
+ }
56
+
57
+ /** If-Match / If-Unmodified-Since that the current entity fails, answered 412 as `send` does. */
58
+ export function preconditionFailed(ifMatch: string | undefined, ifUnmodifiedSince: string | undefined, validators: Validators): boolean {
59
+ if (ifMatch !== undefined && ifMatch !== "") return ifMatch !== "*" && !tokenList(ifMatch).some((token) => etagMatches(token, validators.etag));
60
+ const unmodifiedSince = parseHttpDate(ifUnmodifiedSince);
61
+ const lastModified = parseHttpDate(validators.lastModified);
62
+ return !Number.isNaN(unmodifiedSince) && (Number.isNaN(lastModified) || lastModified > unmodifiedSince);
63
+ }
64
+
65
+ function setEntityHeaders(res: Response, stats: Stats, relPath: string): Validators {
66
+ const validators = { etag: weakEtag(stats), lastModified: stats.mtime.toUTCString() };
67
+ if (res.getHeader("Content-Type") === undefined) res.type(path.extname(relPath) || "application/octet-stream");
68
+ res.setHeader("Accept-Ranges", "bytes");
69
+ res.setHeader("Cache-Control", "public, max-age=0");
70
+ res.setHeader("Last-Modified", validators.lastModified);
71
+ res.setHeader("ETag", validators.etag);
72
+ return validators;
73
+ }
74
+
75
+ // Only a byte range is served; any other unit gets the whole file, as `send` does.
76
+ const BYTES_RANGE_RE = /^ *bytes=/;
77
+
78
+ function requestedRange(req: Request, size: number, validators: Validators): RangeChoice {
79
+ if (!BYTES_RANGE_RE.test(req.get("Range") ?? "") || !rangeApplies(req.get("If-Range"), validators)) return WHOLE;
80
+ return chooseByteRange(req.range(size, { combine: true }));
81
+ }
82
+
83
+ /** 412 / 304 / 416 carry no body, like the 404s these mounts answer. True when one was sent. */
84
+ function answeredWithoutBody(req: Request, res: Response, size: number, validators: Validators, choice: RangeChoice): boolean {
85
+ if (preconditionFailed(req.get("If-Match"), req.get("If-Unmodified-Since"), validators)) res.status(412);
86
+ else if (req.fresh) res.status(304);
87
+ else if (choice.kind === "unsatisfiable") res.status(416).setHeader("Content-Range", `bytes */${size}`);
88
+ else return false;
89
+ res.end();
90
+ return true;
91
+ }
92
+
93
+ async function streamBytes(req: Request, res: Response, handle: FileHandle, size: number, choice: RangeChoice): Promise<void> {
94
+ const { start, end } = choice.kind === "part" ? choice.range : { start: 0, end: size - 1 };
95
+ if (choice.kind === "part") res.status(206).setHeader("Content-Range", `bytes ${start}-${end}/${size}`);
96
+ res.setHeader("Content-Length", String(end - start + 1));
97
+ if (req.method === "HEAD" || size === 0) {
98
+ res.end();
99
+ return;
100
+ }
101
+ // A client that goes away mid-body rejects the pipeline; the response is already torn down then.
102
+ await pipeline(handle.createReadStream({ start, end, autoClose: false }), res).catch(() => res.destroy());
103
+ }
104
+
105
+ /** Serves `handle` as `relPath` (its extension picks the Content-Type unless one is set) and closes it. */
106
+ export async function sendFileHandle(req: Request, res: Response, handle: FileHandle, relPath: string): Promise<void> {
107
+ try {
108
+ const stats = await handle.stat();
109
+ const validators = setEntityHeaders(res, stats, relPath);
110
+ const choice = requestedRange(req, stats.size, validators);
111
+ if (!answeredWithoutBody(req, res, stats.size, validators, choice)) await streamBytes(req, res, handle, stats.size, choice);
112
+ } finally {
113
+ await handle.close();
114
+ }
115
+ }
@@ -0,0 +1,86 @@
1
+ // The image and attachment stores read and write only inside the workspace: the store folder is
2
+ // re-resolved under it on every call, so a store folder replaced by a link out is refused rather
3
+ // than becoming the root, and contents go through the verified open / write
4
+ // (plans/fix-3573-store-files.md).
5
+
6
+ import { readdir, type FileHandle } from "node:fs/promises";
7
+ import type { Dirent } from "node:fs";
8
+ import { resolveExistingWithinRoot, scopeRealpath } from "@mulmoclaude/core/files";
9
+ import { makeConfinedFileOps } from "../../plugins/confined-file-ops.js";
10
+ import { artifactScope, openArtifactFile } from "./artifactMount.js";
11
+ import { storeRelativePath, storeTrustedRoot } from "./storePaths.js";
12
+
13
+ export interface StoreFiles {
14
+ /** Bytes of a regular file in the store; throws when it is missing, a directory, or outside. */
15
+ read: (relativePath: string) => Promise<Buffer>;
16
+ /** Atomic write. Creates the store folder and partitions; `mode` is applied through the verified handle. */
17
+ write: (relativePath: string, content: Uint8Array, options?: { mode?: number }) => Promise<void>;
18
+ /** Never throws: anything but a regular file inside the store is false. */
19
+ isFile: (relativePath: string) => Promise<boolean>;
20
+ /** Entries of a folder in the store ("" for the store itself); empty when it is missing or outside. Names only:
21
+ * a folder swapped for a link mid-call can name outside entries, so read contents through `read`, never by path. */
22
+ listDir: (relativeDir: string) => Promise<Dirent[]>;
23
+ }
24
+
25
+ const rejected = (relativePath: string): Error => new Error(`path traversal rejected: ${relativePath}`);
26
+
27
+ // `getRoot()` is re-read per call because tests repoint `WORKSPACE_PATHS.<dir>` mid-run.
28
+ function storeLocation(getRoot: () => string, dirPrefix: string, relativePath: string): { root: string; trustedRoot: string; inStore: string } {
29
+ const root = getRoot();
30
+ const trustedRoot = storeTrustedRoot(root, dirPrefix);
31
+ const inStore = storeRelativePath(relativePath, dirPrefix);
32
+ if (trustedRoot === null || inStore === null) throw rejected(relativePath);
33
+ return { root, trustedRoot, inStore };
34
+ }
35
+
36
+ async function openStoreFile(getRoot: () => string, dirPrefix: string, relativePath: string): Promise<FileHandle | null> {
37
+ const { trustedRoot, inStore } = storeLocation(getRoot, dirPrefix, relativePath);
38
+ const scope = await artifactScope(trustedRoot, dirPrefix);
39
+ return scope === null ? null : openArtifactFile(scope, inStore);
40
+ }
41
+
42
+ // Only the names are check-then-use: every content read goes back through `openStoreFile`.
43
+ async function listStoreDir(getRoot: () => string, dirPrefix: string, relativeDir: string): Promise<Dirent[]> {
44
+ const trustedRoot = storeTrustedRoot(getRoot(), dirPrefix);
45
+ const inStore = relativeDir === "" ? "" : storeRelativePath(relativeDir, dirPrefix);
46
+ const scope = trustedRoot === null || inStore === null ? null : await artifactScope(trustedRoot, dirPrefix);
47
+ const scopeReal = scope === null ? null : await scopeRealpath(scope);
48
+ const dirReal = scopeReal === null || inStore === null ? null : await resolveExistingWithinRoot(scopeReal, inStore);
49
+ return dirReal === null ? [] : readdir(dirReal, { withFileTypes: true });
50
+ }
51
+
52
+ async function chmodStoreFile(getRoot: () => string, dirPrefix: string, relativePath: string, mode: number): Promise<void> {
53
+ const handle = await openStoreFile(getRoot, dirPrefix, relativePath);
54
+ if (handle === null) throw rejected(relativePath);
55
+ try {
56
+ await handle.chmod(mode);
57
+ } finally {
58
+ await handle.close();
59
+ }
60
+ }
61
+
62
+ export function makeStoreFiles(getRoot: () => string, dirPrefix: string): StoreFiles {
63
+ return {
64
+ read: async (relativePath) => {
65
+ const handle = await openStoreFile(getRoot, dirPrefix, relativePath);
66
+ if (handle === null) throw rejected(relativePath);
67
+ try {
68
+ return await handle.readFile();
69
+ } finally {
70
+ await handle.close();
71
+ }
72
+ },
73
+ write: async (relativePath, content, options = {}) => {
74
+ const { root, trustedRoot, inStore } = storeLocation(getRoot, dirPrefix, relativePath);
75
+ await makeConfinedFileOps({ root, trustedRoot }).write(inStore, content);
76
+ if (options.mode !== undefined) await chmodStoreFile(getRoot, dirPrefix, relativePath, options.mode);
77
+ },
78
+ isFile: async (relativePath) => {
79
+ const handle = await openStoreFile(getRoot, dirPrefix, relativePath).catch(() => null);
80
+ if (handle === null) return false;
81
+ await handle.close();
82
+ return true;
83
+ },
84
+ listDir: (relativeDir) => listStoreDir(getRoot, dirPrefix, relativeDir).catch(() => []),
85
+ };
86
+ }
@@ -0,0 +1,23 @@
1
+ // Path rules for the image / attachment stores, kept free of fs so both directions can be tested.
2
+
3
+ import path from "node:path";
4
+
5
+ // `C:foo` is relative to drive C:'s cwd, so `path.isAbsolute` misses it.
6
+ const WINDOWS_DRIVE_RE = /^[a-zA-Z]:/;
7
+
8
+ /** The workspace a store root `<workspace>/<dirPrefix>` lives in; null when the root does not end in `dirPrefix`. */
9
+ export function storeTrustedRoot(absRoot: string, dirPrefix: string): string | null {
10
+ const segments = dirPrefix.split("/");
11
+ const anchor = path.resolve(absRoot, ...segments.map(() => ".."));
12
+ return path.join(anchor, ...segments) === path.resolve(absRoot) ? anchor : null;
13
+ }
14
+
15
+ /** `relativePath` (workspace-relative, optionally under `dirPrefix/`) as a path inside the store; null when it
16
+ * is empty, absolute, drive-qualified, carries a NUL, or leads out of the store. */
17
+ export function storeRelativePath(relativePath: string, dirPrefix: string): string | null {
18
+ const slashed = relativePath.replace(/\\/g, "/");
19
+ const inStore = slashed.startsWith(`${dirPrefix}/`) ? slashed.slice(dirPrefix.length + 1) : slashed;
20
+ if (inStore === "" || inStore.includes("\0") || path.posix.isAbsolute(inStore) || path.isAbsolute(inStore) || WINDOWS_DRIVE_RE.test(inStore)) return null;
21
+ const normalised = path.posix.normalize(inStore);
22
+ return normalised === "." || normalised === ".." || normalised.startsWith("../") ? null : normalised;
23
+ }
@@ -5,12 +5,12 @@
5
5
  // host produces here. Kept a leaf util (no collection/remote-view imports) so
6
6
  // the builder in remoteView.ts is the only wiring point.
7
7
  //
8
- // Reads are workspace-containment-guarded (resolveWithinRoot, same discipline as
9
- // image-store.ts). Results are cached by (path, mtime, maxEdge) so repeated
8
+ // Reads go through the verified handle (#3573), so a path swapped mid-call cannot
9
+ // reach outside the workspace. Results are cached by (path, mtime, maxEdge) so repeated
10
10
  // pages / "load more" scrolls never re-decode the same source.
11
- import { readFile, realpath, stat } from "fs/promises";
11
+ import type { FileHandle } from "fs/promises";
12
12
  import { workspacePath } from "../../workspace/paths.js";
13
- import { resolveWithinRoot } from "./safe.js";
13
+ import { artifactScope, openArtifactFile } from "./artifactMount.js";
14
14
  import { log } from "../../system/logger/index.js";
15
15
 
16
16
  /** Decode → downscale to fit `maxEdge` (never enlarge) → re-encode JPEG.
@@ -62,6 +62,21 @@ export function clearThumbnailCache(): void {
62
62
  cache.clear();
63
63
  }
64
64
 
65
+ async function thumbnailFromHandle(handle: FileHandle, relPath: string, maxEdge: number, resize: ResizeToJpeg): Promise<string | null> {
66
+ try {
67
+ const info = await handle.stat();
68
+ const cached = cacheGet(relPath, info.mtimeMs, maxEdge);
69
+ if (cached) return cached;
70
+ const out = await resize(await handle.readFile(), maxEdge);
71
+ const dataUrl = `data:image/jpeg;base64,${out.toString("base64")}`;
72
+ cacheSet(relPath, { mtimeMs: info.mtimeMs, maxEdge, dataUrl });
73
+ return dataUrl;
74
+ } catch (err) {
75
+ log.warn("thumbnail", "resolve failed", { relPath, error: String(err) });
76
+ return null;
77
+ }
78
+ }
79
+
65
80
  /** Resolve a workspace-relative image path to a downscaled JPEG `data:` URL, or
66
81
  * `null` when the path escapes the workspace, is missing, or can't be decoded —
67
82
  * the caller then leaves the field as its original path (rendered as a
@@ -70,26 +85,13 @@ export function clearThumbnailCache(): void {
70
85
  export function createThumbnailResolver(resize: ResizeToJpeg = sharpResize) {
71
86
  return async function resolveThumbnail(relPath: string, maxEdge: number): Promise<string | null> {
72
87
  if (typeof relPath !== "string" || relPath.length === 0) return null;
73
- let root: string;
74
- try {
75
- root = await realpath(workspacePath);
76
- } catch {
77
- return null;
78
- }
79
- const abs = resolveWithinRoot(root, relPath);
80
- if (!abs) return null;
81
- const info = await stat(abs).catch(() => null);
82
- if (!info?.isFile()) return null;
83
- const cached = cacheGet(relPath, info.mtimeMs, maxEdge);
84
- if (cached) return cached;
88
+ const scope = await artifactScope(workspacePath, "").catch(() => null);
89
+ const handle = scope === null ? null : await openArtifactFile(scope, relPath).catch(() => null);
90
+ if (handle === null) return null;
85
91
  try {
86
- const out = await resize(await readFile(abs), maxEdge);
87
- const dataUrl = `data:image/jpeg;base64,${out.toString("base64")}`;
88
- cacheSet(relPath, { mtimeMs: info.mtimeMs, maxEdge, dataUrl });
89
- return dataUrl;
90
- } catch (err) {
91
- log.warn("thumbnail", "resolve failed", { relPath, error: String(err) });
92
- return null;
92
+ return await thumbnailFromHandle(handle, relPath, maxEdge, resize);
93
+ } finally {
94
+ await handle.close();
93
95
  }
94
96
  };
95
97
  }
@@ -1,18 +1,19 @@
1
- // Exclusive-create write for dropped-file uploads (#2270).
1
+ // Exclusive-create write for new files: dropped-file uploads (#2270) and the File Explorer's "New file".
2
2
  //
3
3
  // Route handlers don't call raw `fs` — this is the domain module for that
4
4
  // write. It deliberately does NOT go through `writeFileAtomic`: that writes a
5
5
  // temp file and renames over the destination, which would silently clobber an
6
6
  // existing file. The upload route's non-clobbering rename loop is built on the
7
- // `wx` flag failing with EEXIST instead.
7
+ // create failing with EEXIST instead.
8
8
 
9
- import { mkdir, writeFile } from "fs/promises";
9
+ import { mkdir } from "fs/promises";
10
10
  import path from "path";
11
+ import { createFileVerifiedInScope } from "@mulmoclaude/core/files";
11
12
 
12
- /** Create `absPath` holding `bytes`, creating parents as needed. Throws with
13
- * `code: "EEXIST"` when the file is already there — the caller's cue to try
14
- * the next `name (n).ext` candidate. */
15
- export async function writeNewFileExclusive(absPath: string, bytes: Buffer): Promise<void> {
13
+ /** Create `absPath` holding `content`, creating parents as needed. `workspaceRoot` is the async realpath of the
14
+ * workspace: the new file is proven to sit inside it before any byte is written (#3573). Throws with
15
+ * `code: "EEXIST"` when the file is already there — the caller's cue to try the next `name (n).ext` candidate. */
16
+ export async function writeNewFileExclusive(workspaceRoot: string, absPath: string, content: string | Uint8Array): Promise<void> {
16
17
  await mkdir(path.dirname(absPath), { recursive: true });
17
- await writeFile(absPath, bytes, { flag: "wx" });
18
+ await createFileVerifiedInScope({ trustedReal: workspaceRoot, scopeWithinTrusted: "" }, path.dirname(absPath), path.basename(absPath), content);
18
19
  }
@@ -16,22 +16,27 @@
16
16
  // startup as an import side effect.
17
17
 
18
18
  import { readFile as fsReadFile } from "fs/promises";
19
- import { resolveWithinRoot } from "../files/safe.js";
19
+ import type { VerifiedScope } from "@mulmoclaude/core/files";
20
+ import { openArtifactFile } from "../files/artifactMount.js";
20
21
  import { injectImageRepairScript } from "../../../src/utils/image/imageRepairInlineScript.js";
21
22
  import { injectHeightReporterScript } from "../../../src/utils/html/iframeHeightReporterScript.js";
22
23
 
23
- /** Read an HTML artifact file (under `htmlsRoot`) and splice the
24
- * image-self-repair script before its closing `</body>`. Returns
25
- * the spliced HTML on success, `null` when the file can't be
26
- * resolved (escapes the root) or read (missing / unreadable).
27
- *
28
- * `htmlsRoot` MUST already be a realpath — `resolveWithinRoot`
29
- * compares against it strictly. The middleware in `server/index.ts`
30
- * passes the cached `getHtmlsDirReal()` result, which is a realpath. */
31
- export async function readAndInjectHtmlArtifact(htmlsRoot: string, relPath: string): Promise<string | null> {
32
- const abs = resolveWithinRoot(htmlsRoot, relPath);
33
- if (!abs) return null;
34
- return readAndInjectHtmlFile(abs);
24
+ const spliceHelperScripts = (raw: string): string => injectHeightReporterScript(injectImageRepairScript(raw));
25
+
26
+ /** Read an HTML artifact under `scope` through a verified handle and splice the helper scripts.
27
+ * Null when the file is missing, outside the scope, not a regular file, or unreadable. */
28
+ export async function readAndInjectHtmlArtifact(scope: VerifiedScope, relPath: string): Promise<string | null> {
29
+ try {
30
+ const handle = await openArtifactFile(scope, relPath);
31
+ if (handle === null) return null;
32
+ try {
33
+ return spliceHelperScripts(await handle.readFile("utf8"));
34
+ } finally {
35
+ await handle.close();
36
+ }
37
+ } catch {
38
+ return null;
39
+ }
35
40
  }
36
41
 
37
42
  /** Same splice, for a page the caller has ALREADY resolved to an absolute path.
@@ -45,5 +50,5 @@ export async function readAndInjectHtmlFile(absPath: string): Promise<string | n
45
50
  } catch {
46
51
  return null;
47
52
  }
48
- return injectHeightReporterScript(injectImageRepairScript(raw));
53
+ return spliceHelperScripts(raw);
49
54
  }
@@ -1,9 +1,8 @@
1
- import { readFile } from "fs/promises";
2
- import { realpathSync } from "fs";
3
1
  import path from "path";
4
2
  import JSZip from "jszip";
3
+ import type { VerifiedScope } from "@mulmoclaude/core/files";
5
4
  import { resolveWorkspacePath } from "../files/workspace-io.js";
6
- import { resolveWithinRoot } from "../files/safe.js";
5
+ import { artifactScope, lexicallyInside, openArtifactFile, readArtifactFile } from "../files/artifactMount.js";
7
6
  import { rewriteHtmlAssets } from "./rewriteAssets.js";
8
7
  import { log } from "../../system/logger/index.js";
9
8
 
@@ -22,32 +21,29 @@ export interface PackedBundle {
22
21
  files: PackedFile[];
23
22
  }
24
23
 
25
- // `resolveWithinRoot` requires an already-realpath'd root. Resolved
26
- // lazily (not at import) so loading this module never depends on the
27
- // workspace dir existing yet; the location doesn't change during a run.
28
- let workspaceRealCache: string | null = null;
29
- function workspaceReal(): string {
30
- if (workspaceRealCache === null) workspaceRealCache = realpathSync(resolveWorkspacePath("."));
31
- return workspaceRealCache;
32
- }
33
-
34
24
  function stripQueryHash(ref: string): string {
35
25
  const cut = ref.search(/[?#]/);
36
26
  return cut === -1 ? ref : ref.slice(0, cut);
37
27
  }
38
28
 
39
- // Resolve a workspace-relative path to a contained absolute path, or
40
- // null if it escapes the workspace / doesn't exist. `resolveWithinRoot`
41
- // already realpath-checks containment; the extra `path.relative` guard
42
- // is the form CodeQL's js/path-injection analysis recognizes as a
43
- // sanitizer (its `startsWith` form isn't), and is redundant at runtime.
44
- function safeWorkspaceAbs(relFromRoot: string): string | null {
45
- const root = workspaceReal();
46
- const abs = resolveWithinRoot(root, relFromRoot);
47
- if (!abs) return null;
48
- const rel = path.relative(root, abs);
49
- if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
50
- return abs;
29
+ // Reads go through the verified handle (#3573). The lexical check is the form CodeQL's js/path-injection recognises.
30
+ async function workspaceTarget(relFromRoot: string): Promise<{ scope: VerifiedScope; inside: string } | null> {
31
+ const root = resolveWorkspacePath(".");
32
+ const inside = lexicallyInside(root, relFromRoot);
33
+ const scope = inside === null ? null : await artifactScope(root, "");
34
+ return scope === null || inside === null ? null : { scope, inside };
35
+ }
36
+
37
+ // The page's own read errors (EACCES …) propagate; only missing, outside or not a regular file is "not found".
38
+ async function readPage(htmlRelPath: string): Promise<string> {
39
+ const target = await workspaceTarget(htmlRelPath);
40
+ const handle = target === null ? null : await openArtifactFile(target.scope, target.inside);
41
+ if (handle === null) throw new Error(`HTML not found or outside workspace: ${htmlRelPath}`);
42
+ try {
43
+ return await handle.readFile("utf-8");
44
+ } finally {
45
+ await handle.close();
46
+ }
51
47
  }
52
48
 
53
49
  // Resolve a ref (relative to the HTML file's dir) to workspace bytes.
@@ -55,19 +51,12 @@ function safeWorkspaceAbs(relFromRoot: string): string | null {
55
51
  // missing — the caller warns and leaves the (now dangling) link.
56
52
  async function readAsset(htmlDir: string, originalRef: string): Promise<Buffer | null> {
57
53
  const relFromRoot = path.posix.normalize(path.posix.join(htmlDir, stripQueryHash(originalRef)));
58
- const abs = safeWorkspaceAbs(relFromRoot);
59
- if (!abs) return null;
60
- try {
61
- return await readFile(abs);
62
- } catch {
63
- return null;
64
- }
54
+ const target = await workspaceTarget(relFromRoot).catch(() => null);
55
+ return target === null ? null : readArtifactFile(target.scope, target.inside);
65
56
  }
66
57
 
67
58
  export async function packHtmlBundle(htmlRelPath: string): Promise<PackedBundle> {
68
- const abs = safeWorkspaceAbs(htmlRelPath);
69
- if (!abs) throw new Error(`HTML not found or outside workspace: ${htmlRelPath}`);
70
- const html = await readFile(abs, "utf-8");
59
+ const html = await readPage(htmlRelPath);
71
60
 
72
61
  const { html: rewritten, assets } = rewriteHtmlAssets(html);
73
62
  const htmlDir = path.posix.dirname(htmlRelPath);
@@ -14,11 +14,11 @@
14
14
  // auto-capture is enabled in `AppSettings.photoExif`.
15
15
 
16
16
  import path from "node:path";
17
- import { mkdir } from "node:fs/promises";
18
- import { writeFileAtomic } from "../../utils/files/atomic.js";
19
17
  import { WORKSPACE_PATHS, WORKSPACE_DIRS } from "../paths.js";
20
18
  import { isPhotoExifAutoCaptureEnabled, loadSettings } from "../../system/config.js";
21
- import { isExifSupportedMime, readPhotoExif, type ExifParser, type PhotoExif } from "../../utils/exif.js";
19
+ import { isExifSupportedMime, parsePhotoExif, type ExifParser, type PhotoExif } from "../../utils/exif.js";
20
+ import { loadAttachmentBytes } from "../../utils/files/attachment-store.js";
21
+ import { locationsStore } from "./store.js";
22
22
  import { log } from "../../system/logger/index.js";
23
23
  import { publishPhotoLocationsChanged } from "../../events/photo-locations-change.js";
24
24
 
@@ -65,12 +65,16 @@ function partitionForAttachment(attachmentRelativePath: string): string {
65
65
  return path.posix.join(year, month);
66
66
  }
67
67
 
68
+ /** The sidecar's path inside the locations store (`YYYY/MM/<id>.json`). */
69
+ function sidecarStorePath(attachmentRelativePath: string): string | null {
70
+ const partition = partitionForAttachment(attachmentRelativePath);
71
+ return partition === "" ? null : path.posix.join(partition, `${sidecarIdForAttachment(attachmentRelativePath)}.json`);
72
+ }
73
+
68
74
  /** Compute the absolute sidecar path for a saved attachment. */
69
75
  export function sidecarPathForAttachment(attachmentRelativePath: string): string | null {
70
- const partition = partitionForAttachment(attachmentRelativePath);
71
- if (partition === "") return null;
72
- const sidecarId = sidecarIdForAttachment(attachmentRelativePath);
73
- return path.join(WORKSPACE_PATHS.locations, partition, `${sidecarId}.json`);
76
+ const storePath = sidecarStorePath(attachmentRelativePath);
77
+ return storePath === null ? null : path.join(WORKSPACE_PATHS.locations, storePath);
74
78
  }
75
79
 
76
80
  /** Hook the attachment-store calls after every saved attachment.
@@ -81,8 +85,8 @@ export function sidecarPathForAttachment(attachmentRelativePath: string): string
81
85
  *
82
86
  * Errors are swallowed and logged at warn — we never want a failed
83
87
  * EXIF probe to fail the upload. */
84
- export function capturePhotoLocation(absPhotoPath: string, attachmentRelativePath: string, mimeType: string): Promise<void> {
85
- return capturePhotoLocationWithParser(absPhotoPath, attachmentRelativePath, mimeType);
88
+ export function capturePhotoLocation(attachmentRelativePath: string, mimeType: string): Promise<void> {
89
+ return capturePhotoLocationWithParser(attachmentRelativePath, mimeType);
86
90
  }
87
91
 
88
92
  /** Gate check: should we even try to read EXIF for this MIME, given
@@ -92,13 +96,14 @@ function shouldCapture(mimeType: string): boolean {
92
96
  return isPhotoExifAutoCaptureEnabled(loadSettings());
93
97
  }
94
98
 
95
- /** Read EXIF, swallowing parser errors as `null`. The hook never
96
- * fails the upload — a thrown parser is just "skip the sidecar". */
97
- async function readExifSafe(absPhotoPath: string, attachmentRelativePath: string, parser: ExifParser | undefined): Promise<PhotoExif | null> {
99
+ /** Read EXIF from the stored photo, swallowing errors as `null`. The
100
+ * hook never fails the upload — an unreadable photo or a thrown
101
+ * parser is just "skip the sidecar". */
102
+ async function readExifSafe(attachmentRelativePath: string, parser: ExifParser | undefined): Promise<PhotoExif | null> {
98
103
  try {
99
- return await readPhotoExif(absPhotoPath, parser);
104
+ return await parsePhotoExif(await loadAttachmentBytes(attachmentRelativePath), parser);
100
105
  } catch (err) {
101
- log.warn("photo-locations", "exif parse threw — skipping sidecar", { path: attachmentRelativePath, error: String(err) });
106
+ log.warn("photo-locations", "exif read failed — skipping sidecar", { path: attachmentRelativePath, error: String(err) });
102
107
  return null;
103
108
  }
104
109
  }
@@ -106,10 +111,9 @@ async function readExifSafe(absPhotoPath: string, attachmentRelativePath: string
106
111
  /** Write the sidecar JSON, creating parent dirs as needed. Failures
107
112
  * are logged but never thrown — the photo upload already succeeded
108
113
  * by the time this runs. */
109
- async function writeSidecarSafe(sidecarPath: string, payload: PhotoLocationSidecar): Promise<void> {
114
+ async function writeSidecarSafe(storePath: string, payload: PhotoLocationSidecar): Promise<void> {
110
115
  try {
111
- await mkdir(path.dirname(sidecarPath), { recursive: true });
112
- await writeFileAtomic(sidecarPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: SIDECAR_FILE_MODE });
116
+ await locationsStore.write(storePath, Buffer.from(`${JSON.stringify(payload, null, 2)}\n`), { mode: SIDECAR_FILE_MODE });
113
117
  log.info("photo-locations", "sidecar written", {
114
118
  attachment: payload.photo.relativePath,
115
119
  hasGps: payload.exif.lat !== undefined && payload.exif.lng !== undefined,
@@ -124,17 +128,12 @@ async function writeSidecarSafe(sidecarPath: string, payload: PhotoLocationSidec
124
128
  /** Parser-injectable variant. Production code uses
125
129
  * `capturePhotoLocation`; tests reach for this overload to swap
126
130
  * exifr for a stub so they don't need a JPEG fixture. */
127
- export async function capturePhotoLocationWithParser(
128
- absPhotoPath: string,
129
- attachmentRelativePath: string,
130
- mimeType: string,
131
- parser?: ExifParser,
132
- ): Promise<void> {
131
+ export async function capturePhotoLocationWithParser(attachmentRelativePath: string, mimeType: string, parser?: ExifParser): Promise<void> {
133
132
  if (!shouldCapture(mimeType)) return;
134
- const exif = await readExifSafe(absPhotoPath, attachmentRelativePath, parser);
133
+ const exif = await readExifSafe(attachmentRelativePath, parser);
135
134
  if (!exif) return;
136
- const sidecarPath = sidecarPathForAttachment(attachmentRelativePath);
137
- if (!sidecarPath) {
135
+ const storePath = sidecarStorePath(attachmentRelativePath);
136
+ if (!storePath) {
138
137
  log.warn("photo-locations", "could not derive sidecar path — skipping", { attachmentRelativePath });
139
138
  return;
140
139
  }
@@ -144,7 +143,7 @@ export async function capturePhotoLocationWithParser(
144
143
  exif,
145
144
  capturedAt: new Date().toISOString(),
146
145
  };
147
- await writeSidecarSafe(sidecarPath, payload);
146
+ await writeSidecarSafe(storePath, payload);
148
147
  }
149
148
 
150
149
  // `WORKSPACE_DIRS.locations` is referenced for typing-side