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
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
// "list all my photo locations" or "render every captured pin on
|
|
9
9
|
// a map view".
|
|
10
10
|
|
|
11
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
12
11
|
import path from "node:path";
|
|
13
|
-
import { WORKSPACE_DIRS
|
|
12
|
+
import { WORKSPACE_DIRS } from "../paths.js";
|
|
14
13
|
import { log } from "../../system/logger/index.js";
|
|
15
14
|
import { isRecord } from "../../utils/types.js";
|
|
16
15
|
import type { PhotoExif } from "../../utils/exif.js";
|
|
17
16
|
import type { PhotoLocationSidecar } from "./index.js";
|
|
17
|
+
import { locationsStore } from "./store.js";
|
|
18
18
|
|
|
19
19
|
/** A sidecar paired with the id used in the filename. The id is the
|
|
20
20
|
* attachment's stable identifier; consumers (UI, LLM) use it to
|
|
@@ -33,15 +33,12 @@ const SIDECAR_EXT = ".json";
|
|
|
33
33
|
* the path so an operator can investigate without the listing
|
|
34
34
|
* crashing. */
|
|
35
35
|
export async function listAllSidecars(): Promise<ListedSidecar[]> {
|
|
36
|
-
const
|
|
37
|
-
const yearDirs = await readSubdirsSafe(root);
|
|
36
|
+
const yearDirs = await readSubdirs("");
|
|
38
37
|
const all: ListedSidecar[] = [];
|
|
39
38
|
for (const year of yearDirs) {
|
|
40
|
-
const
|
|
41
|
-
const monthDirs = await readSubdirsSafe(yearAbs);
|
|
39
|
+
const monthDirs = await readSubdirs(year);
|
|
42
40
|
for (const month of monthDirs) {
|
|
43
|
-
const
|
|
44
|
-
const sidecars = await readSidecarsInDir(monthAbs, year, month);
|
|
41
|
+
const sidecars = await readSidecarsInDir(year, month);
|
|
45
42
|
all.push(...sidecars);
|
|
46
43
|
}
|
|
47
44
|
}
|
|
@@ -146,39 +143,30 @@ function validateSidecarShape(value: unknown): PhotoLocationSidecar | null {
|
|
|
146
143
|
return { version: 1, capturedAt, photo: { relativePath, mimeType }, exif: readSidecarExif(exif) };
|
|
147
144
|
}
|
|
148
145
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
} catch {
|
|
154
|
-
// ENOENT on a fresh workspace — locations dir doesn't exist
|
|
155
|
-
// yet, treat as empty.
|
|
156
|
-
return [];
|
|
157
|
-
}
|
|
146
|
+
// A fresh workspace has no locations folder yet; the store lists that as empty.
|
|
147
|
+
async function readSubdirs(storeDir: string): Promise<string[]> {
|
|
148
|
+
const entries = await locationsStore.listDir(storeDir);
|
|
149
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
158
150
|
}
|
|
159
151
|
|
|
160
|
-
async function readSidecarsInDir(
|
|
161
|
-
const entries = await
|
|
152
|
+
async function readSidecarsInDir(year: string, month: string): Promise<ListedSidecar[]> {
|
|
153
|
+
const entries = await locationsStore.listDir(path.posix.join(year, month));
|
|
162
154
|
const out: ListedSidecar[] = [];
|
|
163
|
-
for (const entry of entries) {
|
|
155
|
+
for (const { name: entry } of entries) {
|
|
164
156
|
if (!entry.endsWith(SIDECAR_EXT)) continue;
|
|
165
157
|
const sidecarId = entry.slice(0, -SIDECAR_EXT.length);
|
|
166
|
-
const
|
|
158
|
+
const relativePath = path.posix.join(WORKSPACE_DIRS.locations, year, month, entry);
|
|
167
159
|
try {
|
|
168
|
-
const raw = await
|
|
160
|
+
const raw = (await locationsStore.read(relativePath)).toString("utf8");
|
|
169
161
|
const parsed: unknown = JSON.parse(raw);
|
|
170
162
|
const sidecar = validateSidecarShape(parsed);
|
|
171
163
|
if (!sidecar) {
|
|
172
|
-
log.warn("photo-locations", "skipping sidecar with invalid shape", { path:
|
|
164
|
+
log.warn("photo-locations", "skipping sidecar with invalid shape", { path: relativePath });
|
|
173
165
|
continue;
|
|
174
166
|
}
|
|
175
|
-
out.push({
|
|
176
|
-
id: sidecarId,
|
|
177
|
-
relativePath: path.posix.join(WORKSPACE_DIRS.locations, year, month, entry),
|
|
178
|
-
sidecar,
|
|
179
|
-
});
|
|
167
|
+
out.push({ id: sidecarId, relativePath, sidecar });
|
|
180
168
|
} catch (err) {
|
|
181
|
-
log.warn("photo-locations", "skipping malformed sidecar", { path:
|
|
169
|
+
log.warn("photo-locations", "skipping malformed sidecar", { path: relativePath, error: String(err) });
|
|
182
170
|
}
|
|
183
171
|
}
|
|
184
172
|
return out;
|
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
// site wired up means PR 2 is purely an internal change.
|
|
20
20
|
|
|
21
21
|
import path from "node:path";
|
|
22
|
-
import {
|
|
22
|
+
import { realpath } from "node:fs/promises";
|
|
23
23
|
import { readTextSafe } from "../../utils/files/safe.js";
|
|
24
24
|
import { writeFileAtomic } from "../../utils/files/atomic.js";
|
|
25
|
+
import { writeNewFileExclusive } from "../../utils/files/upload-io.js";
|
|
25
26
|
import { mergeFrontmatter, parseFrontmatter, serializeWithFrontmatter } from "@mulmoclaude/markdown-utils/markdown/frontmatter";
|
|
26
27
|
import { isSafeSlug } from "@mulmoclaude/core/wiki";
|
|
27
28
|
import { wikiSlugFromAbsPath } from "@mulmoclaude/core/wiki/paths";
|
|
@@ -56,7 +57,7 @@ export interface WikiPageWriteOptions {
|
|
|
56
57
|
* injection. Tests pass a fixed `Date` so the round-trip is
|
|
57
58
|
* deterministic; production uses the wall clock. */
|
|
58
59
|
now?: (() => Date) | undefined;
|
|
59
|
-
/** Create-only mode: write via `O_EXCL`
|
|
60
|
+
/** Create-only mode: write via `O_EXCL` so the
|
|
60
61
|
* call atomically refuses to overwrite an existing page. The
|
|
61
62
|
* thrown error has `code: "EEXIST"` — used by the
|
|
62
63
|
* `POST /api/files/create` route (#1598) to map to a 409 and
|
|
@@ -107,7 +108,7 @@ export async function writeWikiPage(slug: string, content: string, meta: WikiWri
|
|
|
107
108
|
const finalContent = stampFrontmatter(oldContent, content, meta, opts);
|
|
108
109
|
if (opts.exclusive) {
|
|
109
110
|
// Re-anchor the absolute path against the workspace root before
|
|
110
|
-
// handing it to
|
|
111
|
+
// handing it to the create. `wikiPagePath` already
|
|
111
112
|
// enforces containment via `isSafeSlug` + `path.join`, but
|
|
112
113
|
// CodeQL's `js/path-injection` analysis doesn't trace through
|
|
113
114
|
// those — the explicit `relative` + `startsWith` containment
|
|
@@ -120,11 +121,11 @@ export async function writeWikiPage(slug: string, content: string, meta: WikiWri
|
|
|
120
121
|
throw new Error(`wiki-pages: refusing to write outside workspace root: ${JSON.stringify(absPath)}`);
|
|
121
122
|
}
|
|
122
123
|
const safeAbsPath = path.join(root, relFromRoot);
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
await
|
|
124
|
+
// `O_EXCL`: atomic create-or-fail. On a concurrent create race
|
|
125
|
+
// only one call wins; the other throws `EEXIST`, which the route
|
|
126
|
+
// handler maps to HTTP 409. The new file is also proven to sit
|
|
127
|
+
// inside the workspace before any byte is written (#3573).
|
|
128
|
+
await writeNewFileExclusive(await realpath(root), safeAbsPath, finalContent);
|
|
128
129
|
} else {
|
|
129
130
|
await writeFileAtomic(absPath, finalContent, { uniqueTmp: true });
|
|
130
131
|
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
// Shared resolver factory for file-store modules (attachment-store,
|
|
2
|
-
// image-store). Each store used to define its own
|
|
3
|
-
// `safeResolve(relativePath)` wrapper of identical shape: ensure the
|
|
4
|
-
// root dir exists, cache its realpath, strip the leading store
|
|
5
|
-
// prefix, hand off to `resolveWithinRoot`. That left three name-cloned
|
|
6
|
-
// `safeResolve` functions that all looked read-only but two of them
|
|
7
|
-
// were also being used for writes — see #1754 / #1744.
|
|
8
|
-
//
|
|
9
|
-
// `makeStoreResolvers` collapses the three copies into one and
|
|
10
|
-
// exposes the read/write split through method names. A caller that
|
|
11
|
-
// wants to write reaches for `.forWrite`, which routes through the
|
|
12
|
-
// write-time confinement primitive instead of the read-time one.
|
|
13
|
-
|
|
14
|
-
import { mkdir, realpath } from "fs/promises";
|
|
15
|
-
import { resolveWithinRoot, resolveWriteWithinRoot } from "./safe.js";
|
|
16
|
-
|
|
17
|
-
export interface StoreResolvers {
|
|
18
|
-
forRead: (relativePath: string) => Promise<string>;
|
|
19
|
-
forWrite: (relativePath: string) => Promise<string>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Cache realpath per absolute root, not per factory instance — test
|
|
23
|
-
// setups override `WORKSPACE_PATHS.<dir>` mid-run, so capturing the
|
|
24
|
-
// path at factory-call time would pin every store to the original
|
|
25
|
-
// workspace. Re-reading via `getRoot()` and keying the cache on the
|
|
26
|
-
// returned absolute path means each tmp workspace gets its own entry.
|
|
27
|
-
const realPathCacheByRoot = new Map<string, string>();
|
|
28
|
-
|
|
29
|
-
async function ensureRoot(getRoot: () => string): Promise<string> {
|
|
30
|
-
const root = getRoot();
|
|
31
|
-
const cached = realPathCacheByRoot.get(root);
|
|
32
|
-
if (cached) return cached;
|
|
33
|
-
await mkdir(root, { recursive: true });
|
|
34
|
-
const real = await realpath(root);
|
|
35
|
-
realPathCacheByRoot.set(root, real);
|
|
36
|
-
return real;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function stripDirPrefix(relativePath: string, dirPrefix: string): string {
|
|
40
|
-
return relativePath.replace(new RegExp(`^${dirPrefix}/`), "");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function makeStoreResolvers(getRoot: () => string, dirPrefix: string): StoreResolvers {
|
|
44
|
-
return {
|
|
45
|
-
forRead: async (relativePath) => {
|
|
46
|
-
const root = await ensureRoot(getRoot);
|
|
47
|
-
const result = resolveWithinRoot(root, stripDirPrefix(relativePath, dirPrefix));
|
|
48
|
-
if (!result) throw new Error(`path traversal rejected: ${relativePath}`);
|
|
49
|
-
return result;
|
|
50
|
-
},
|
|
51
|
-
forWrite: async (relativePath) => {
|
|
52
|
-
const root = await ensureRoot(getRoot);
|
|
53
|
-
const result = await resolveWriteWithinRoot(root, stripDirPrefix(relativePath, dirPrefix));
|
|
54
|
-
if (!result) throw new Error(`path traversal rejected: ${relativePath}`);
|
|
55
|
-
return result;
|
|
56
|
-
},
|
|
57
|
-
};
|
|
58
|
-
}
|