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
|
@@ -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
|
|
9
|
-
//
|
|
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 {
|
|
11
|
+
import type { FileHandle } from "fs/promises";
|
|
12
12
|
import { workspacePath } from "../../workspace/paths.js";
|
|
13
|
-
import {
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
//
|
|
7
|
+
// create failing with EEXIST instead.
|
|
8
8
|
|
|
9
|
-
import { mkdir
|
|
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 `
|
|
13
|
-
*
|
|
14
|
-
* the next `name (n).ext` candidate. */
|
|
15
|
-
export async function writeNewFileExclusive(absPath: string,
|
|
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
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
59
|
-
|
|
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
|
|
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,
|
|
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
|
|
71
|
-
|
|
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(
|
|
85
|
-
return capturePhotoLocationWithParser(
|
|
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
|
|
96
|
-
* fails the upload —
|
|
97
|
-
|
|
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
|
|
104
|
+
return await parsePhotoExif(await loadAttachmentBytes(attachmentRelativePath), parser);
|
|
100
105
|
} catch (err) {
|
|
101
|
-
log.warn("photo-locations", "exif
|
|
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(
|
|
114
|
+
async function writeSidecarSafe(storePath: string, payload: PhotoLocationSidecar): Promise<void> {
|
|
110
115
|
try {
|
|
111
|
-
await
|
|
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(
|
|
133
|
+
const exif = await readExifSafe(attachmentRelativePath, parser);
|
|
135
134
|
if (!exif) return;
|
|
136
|
-
const
|
|
137
|
-
if (!
|
|
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(
|
|
146
|
+
await writeSidecarSafe(storePath, payload);
|
|
148
147
|
}
|
|
149
148
|
|
|
150
149
|
// `WORKSPACE_DIRS.locations` is referenced for typing-side
|