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
package/server/index.ts
CHANGED
|
@@ -104,7 +104,7 @@ import { initCollectionChangePublisher } from "./events/collection-change.js";
|
|
|
104
104
|
import { initPhotoLocationsChangePublisher } from "./events/photo-locations-change.js";
|
|
105
105
|
import { getRole, loadAllRoles } from "./workspace/roles.js";
|
|
106
106
|
import { discoverSkills } from "./workspace/skills/index.js";
|
|
107
|
-
import { WORKSPACE_PATHS } from "./workspace/paths.js";
|
|
107
|
+
import { WORKSPACE_DIRS, WORKSPACE_PATHS } from "./workspace/paths.js";
|
|
108
108
|
import { resolveClientDir } from "./utils/clientDir.js";
|
|
109
109
|
import { serverError } from "./utils/httpError.js";
|
|
110
110
|
import { browserVisibleOrigin } from "./utils/forwardedOrigin.js";
|
|
@@ -121,7 +121,8 @@ import { buildSandboxStatus } from "./api/sandboxStatus.js";
|
|
|
121
121
|
import { buildDiagnosticsMarkdown } from "./utils/diagnostics/collect.js";
|
|
122
122
|
import { existsSync, readFileSync } from "fs";
|
|
123
123
|
import { realpath as fsRealpath, stat as fsStat } from "fs/promises";
|
|
124
|
-
import { makeCachedRealpath
|
|
124
|
+
import { makeCachedRealpath } from "./utils/files/safe.js";
|
|
125
|
+
import { artifactMount, sendArtifactFile } from "./utils/files/artifactMount.js";
|
|
125
126
|
import { cpus, loadavg } from "os";
|
|
126
127
|
import { isDockerAvailable, ensureSandboxImage } from "./system/docker.js";
|
|
127
128
|
import { maybeRunJournal } from "./workspace/journal/index.js";
|
|
@@ -387,7 +388,7 @@ app.use("/api", (req, res, next) => {
|
|
|
387
388
|
// requireSameOrigin guard above still applies; the listener also
|
|
388
389
|
// stays loopback-only.
|
|
389
390
|
//
|
|
390
|
-
//
|
|
391
|
+
// Guard:
|
|
391
392
|
// 1. Extension allowlist — reject anything that isn't an image,
|
|
392
393
|
// video, or audio extension. `saveImage` currently writes `.png`
|
|
393
394
|
// only, but Stage B (#1011) extends the markdown / wiki rewriter
|
|
@@ -396,36 +397,14 @@ app.use("/api", (req, res, next) => {
|
|
|
396
397
|
// `artifacts/images/<id>.mp4` (or any user-dropped media file
|
|
397
398
|
// under that dir) needs to round-trip through this mount the
|
|
398
399
|
// same way image refs do — otherwise the rewritten URL hits this
|
|
399
|
-
// mount and 404s
|
|
400
|
-
// 2.
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
400
|
+
// mount and 404s.
|
|
401
|
+
// 2. `artifactMount` (server/utils/files/artifactMount.ts): the mount
|
|
402
|
+
// folder must resolve inside the workspace on every request,
|
|
403
|
+
// dotfile / traversal segments are refused, and the file is served
|
|
404
|
+
// from a handle verified to be the in-folder file — never re-opened
|
|
405
|
+
// by path, so a symlink swapped in mid-request is not followed.
|
|
405
406
|
const IMAGE_EXT_RE = /\.(png|jpe?g|webp|gif|svg|mp4|webm|mov|m4v|ogv|mp3|ogg|oga|wav|m4a|aac)$/i;
|
|
406
|
-
|
|
407
|
-
app.use(
|
|
408
|
-
"/artifacts/images",
|
|
409
|
-
async (req, res, next) => {
|
|
410
|
-
if (!IMAGE_EXT_RE.test(req.path)) {
|
|
411
|
-
res.status(404).end();
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
const root = await getImagesDirReal();
|
|
415
|
-
if (!root) {
|
|
416
|
-
res.status(404).end();
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
// No explicit dotfile check here: this mount never short-circuits,
|
|
420
|
-
// so `express.static`'s `dotfiles: "deny"` below is the authority.
|
|
421
|
-
if (resolveArtifactRequestPath(root, req.path, false) === null) {
|
|
422
|
-
res.status(404).end();
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
next();
|
|
426
|
-
},
|
|
427
|
-
express.static(WORKSPACE_PATHS.images, { dotfiles: "deny", fallthrough: false }),
|
|
428
|
-
);
|
|
407
|
+
app.use("/artifacts/images", artifactMount(workspacePath, WORKSPACE_DIRS.images, IMAGE_EXT_RE));
|
|
429
408
|
|
|
430
409
|
// Static mount for HTML artifacts. The Files-view preview iframe
|
|
431
410
|
// switched from `srcdoc` to `src=/artifacts/html/<name>.html` so the
|
|
@@ -444,10 +423,7 @@ app.use(
|
|
|
444
423
|
// (`previewCsp.ts`) so allowing those extensions would only delivery-
|
|
445
424
|
// vector for blocked resources.
|
|
446
425
|
//
|
|
447
|
-
// Same
|
|
448
|
-
// 1. extension allowlist (`.html` / `.htm` plus image types).
|
|
449
|
-
// 2. `resolveWithinRoot` symlink-aware traversal check.
|
|
450
|
-
// 3. `dotfiles: deny` + `fallthrough: false` on `express.static`.
|
|
426
|
+
// Same guard as `/artifacts/images` (extension allowlist + `artifactMount`).
|
|
451
427
|
//
|
|
452
428
|
// Bearer auth skipped for the same reason as /artifacts/images and
|
|
453
429
|
// /api/files/*: an iframe `src` request can't carry an Authorization
|
|
@@ -474,47 +450,23 @@ app.use(
|
|
|
474
450
|
// eslint-disable-next-line sonarjs/regex-complexity -- flat extension allowlist with no nested quantifiers, ReDoS-safe; complexity is just the disjunction count
|
|
475
451
|
const HTML_PREVIEW_EXT_RE = /\.(html?|png|jpe?g|webp|gif|svg|ico|mp4|webm|mov|m4v|ogv|mp3|ogg|oga|wav|m4a|aac)$/i;
|
|
476
452
|
const HTML_DOCUMENT_EXT_RE = /\.html?$/i;
|
|
477
|
-
const getHtmlsDirReal = makeCachedRealpath(WORKSPACE_PATHS.htmls);
|
|
478
|
-
|
|
479
453
|
app.use(
|
|
480
454
|
"/artifacts/html",
|
|
481
|
-
async (req, res,
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
const root = await getHtmlsDirReal();
|
|
487
|
-
if (!root) {
|
|
488
|
-
res.status(404).end();
|
|
455
|
+
artifactMount(workspacePath, WORKSPACE_DIRS.htmls, HTML_PREVIEW_EXT_RE, async (req, res, scope, relPath) => {
|
|
456
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
457
|
+
if (!HTML_DOCUMENT_EXT_RE.test(req.path)) {
|
|
458
|
+
await sendArtifactFile(req, res, scope, relPath);
|
|
489
459
|
return;
|
|
490
460
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
// `/artifacts/html/.hidden.html` would be served (Codex review on
|
|
495
|
-
// #1056).
|
|
496
|
-
const relPath = resolveArtifactRequestPath(root, req.path, true);
|
|
497
|
-
if (relPath === null) {
|
|
461
|
+
res.setHeader("Content-Security-Policy", buildHtmlPreviewCsp(browserVisibleOrigin(req), undefined, readCspExtraSync()));
|
|
462
|
+
const spliced = await readAndInjectHtmlArtifact(scope, relPath);
|
|
463
|
+
if (spliced === null) {
|
|
498
464
|
res.status(404).end();
|
|
499
465
|
return;
|
|
500
466
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
505
|
-
const spliced = await readAndInjectHtmlArtifact(root, relPath);
|
|
506
|
-
if (spliced === null) {
|
|
507
|
-
res.status(404).end();
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
511
|
-
res.send(spliced);
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
515
|
-
next();
|
|
516
|
-
},
|
|
517
|
-
express.static(WORKSPACE_PATHS.htmls, { dotfiles: "deny", fallthrough: false }),
|
|
467
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
468
|
+
res.send(spliced);
|
|
469
|
+
}),
|
|
518
470
|
);
|
|
519
471
|
|
|
520
472
|
// Mount for HTML pages OUTSIDE `artifacts/html/` — presentHtml's `path` form
|
|
@@ -601,39 +553,20 @@ app.use(HTML_FILE_MOUNT, htmlFileRateLimit, async (req, res, next) => {
|
|
|
601
553
|
// header doesn't interfere with the normal View / Preview path.
|
|
602
554
|
const SVG_RESPONSE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; sandbox";
|
|
603
555
|
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
// fallthrough false on `express.static`. Bearer auth bypassed for the
|
|
556
|
+
// Same guard as `/artifacts/images` (extension allowlist + `artifactMount`).
|
|
557
|
+
// Bearer auth bypassed for the
|
|
607
558
|
// same reason as `/artifacts/images` / `/artifacts/html`: an
|
|
608
559
|
// `<img src>` request can't carry an Authorization header. Loopback-
|
|
609
560
|
// only listener + `requireSameOrigin` remain the trust boundary.
|
|
610
561
|
const SVG_EXT_RE = /\.svg$/i;
|
|
611
|
-
const getSvgsDirReal = makeCachedRealpath(WORKSPACE_PATHS.svgs);
|
|
612
562
|
app.use(
|
|
613
563
|
"/artifacts/svg",
|
|
614
|
-
async (req, res,
|
|
615
|
-
if (!SVG_EXT_RE.test(req.path)) {
|
|
616
|
-
res.status(404).end();
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
const root = await getSvgsDirReal();
|
|
620
|
-
if (!root) {
|
|
621
|
-
res.status(404).end();
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
// denyDotfiles for the same reason as the html mount: this handler
|
|
625
|
-
// sets headers and serves via a bearer-bypassed <img> path, so the
|
|
626
|
-
// dotfile guard can't be left solely to `express.static`.
|
|
627
|
-
if (resolveArtifactRequestPath(root, req.path, true) === null) {
|
|
628
|
-
res.status(404).end();
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
564
|
+
artifactMount(workspacePath, WORKSPACE_DIRS.svgs, SVG_EXT_RE, async (req, res, scope, relPath) => {
|
|
631
565
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
632
566
|
res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
|
|
633
567
|
res.setHeader("Content-Security-Policy", SVG_RESPONSE_CSP);
|
|
634
|
-
|
|
635
|
-
},
|
|
636
|
-
express.static(WORKSPACE_PATHS.svgs, { dotfiles: "deny", fallthrough: false }),
|
|
568
|
+
await sendArtifactFile(req, res, scope, relPath);
|
|
569
|
+
}),
|
|
637
570
|
);
|
|
638
571
|
|
|
639
572
|
app.get(API_ROUTES.health, (_req: Request, res: Response) => {
|
|
@@ -22,8 +22,8 @@ import { ensureInsideBase } from "./runtime-loader.js";
|
|
|
22
22
|
//
|
|
23
23
|
// 1. Replace `\` with `/` (Windows path.join leak repair).
|
|
24
24
|
// 2. `path.posix.normalize` (folds `..`, `.`, `//`).
|
|
25
|
-
// 3. Reject if the normalised form starts with
|
|
26
|
-
// (would escape the scope root or be absolute).
|
|
25
|
+
// 3. Reject if the normalised form starts with `..`, `/` or a
|
|
26
|
+
// drive letter (would escape the scope root or be absolute).
|
|
27
27
|
// 4. `path.join(scopeRoot, ...segments)` — platform-aware join so
|
|
28
28
|
// the returned absolute path uses the host OS separator
|
|
29
29
|
// (backslashes on Windows, forward slashes on POSIX). Critical
|
|
@@ -36,11 +36,13 @@ import { ensureInsideBase } from "./runtime-loader.js";
|
|
|
36
36
|
/** Resolve a plugin-supplied path to an absolute path inside the
|
|
37
37
|
* plugin's scope root. Throws on traversal. Lexical only — symlinks
|
|
38
38
|
* are checked by the FileOps below. Exported for tests. */
|
|
39
|
+
const WINDOWS_DRIVE_RE = /^[a-zA-Z]:/;
|
|
40
|
+
|
|
39
41
|
export function normalizePluginPath(scopeRoot: string, rel: string): string {
|
|
40
42
|
const slashed = rel.replace(/\\/g, "/");
|
|
41
43
|
const normalised = path.posix.normalize(slashed);
|
|
42
44
|
// `path.posix.normalize` produces `..` alone if the input is `..` itself, hence the equality branch.
|
|
43
|
-
if (normalised === ".." || normalised.startsWith("../") || normalised.startsWith("/")) {
|
|
45
|
+
if (normalised === ".." || normalised.startsWith("../") || normalised.startsWith("/") || WINDOWS_DRIVE_RE.test(normalised)) {
|
|
44
46
|
throw new Error(`path escapes plugin scope: ${rel}`);
|
|
45
47
|
}
|
|
46
48
|
const segments = normalised === "." ? [] : normalised.split("/");
|
package/server/system/config.ts
CHANGED
|
@@ -55,7 +55,7 @@ export interface AppSettings {
|
|
|
55
55
|
|
|
56
56
|
// Photo-EXIF auto-capture (#1222 PR-A). When `autoCapture` is true
|
|
57
57
|
// (the default), every saved attachment with an image MIME runs
|
|
58
|
-
// through `
|
|
58
|
+
// through `parsePhotoExif` and a sidecar JSON lands at
|
|
59
59
|
// `data/locations/<YYYY>/<MM>/<id>.json`. Users opt out via
|
|
60
60
|
// Settings → Privacy when they don't want GPS metadata captured
|
|
61
61
|
// automatically (the future `manageMap` `extractExif` tool can
|
package/server/utils/exif.ts
CHANGED
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
// extracted coords straight to the Google Map plugin without a
|
|
16
16
|
// reshape (#1227).
|
|
17
17
|
|
|
18
|
-
import { readFile } from "fs/promises";
|
|
19
18
|
import exifr from "exifr";
|
|
20
19
|
import { isRecord } from "./types.js";
|
|
21
20
|
|
|
@@ -159,17 +158,15 @@ export type ExifParser = (buf: Buffer) => Promise<unknown>;
|
|
|
159
158
|
|
|
160
159
|
const defaultParser: ExifParser = (buf) => exifr.parse(buf, PARSE_OPTIONS);
|
|
161
160
|
|
|
162
|
-
/** Parse a photo
|
|
163
|
-
* `null` when
|
|
161
|
+
/** Parse a photo's bytes and project the fields we care about. Returns
|
|
162
|
+
* `null` when they hold no parseable EXIF (screenshots, scrubbed
|
|
164
163
|
* uploads, non-image MIME types, malformed JPEG). Never throws. */
|
|
165
|
-
export async function
|
|
164
|
+
export async function parsePhotoExif(buf: Buffer, parser: ExifParser = defaultParser): Promise<PhotoExif | null> {
|
|
166
165
|
let raw: unknown;
|
|
167
166
|
try {
|
|
168
|
-
const buf = await readFile(absPath);
|
|
169
167
|
raw = await parser(buf);
|
|
170
168
|
} catch {
|
|
171
|
-
//
|
|
172
|
-
// exists) and exifr "couldn't find any EXIF data" rejections.
|
|
169
|
+
// exifr rejects with "couldn't find any EXIF data" on photos without it.
|
|
173
170
|
return null;
|
|
174
171
|
}
|
|
175
172
|
if (!isRecord(raw)) return null;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The `/artifacts/*` mounts serve a file only while it sits inside the workspace: the mount folder is
|
|
2
|
+
// re-resolved under the workspace on every request, so a folder replaced by a link out is a 404
|
|
3
|
+
// rather than the new root, and the file is served from a handle proven to be the in-scope file
|
|
4
|
+
// (plans/fix-3573-artifact-mounts.md).
|
|
5
|
+
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import type { FileHandle } from "node:fs/promises";
|
|
8
|
+
import type { Request, RequestHandler, Response } from "express";
|
|
9
|
+
import { openVerifiedWithinScope, realpathIfPresent, scopeRealpath, type VerifiedScope } from "@mulmoclaude/core/files";
|
|
10
|
+
import { log } from "../../system/logger/index.js";
|
|
11
|
+
import { resolveArtifactRequestPath } from "./safe.js";
|
|
12
|
+
import { sendFileHandle } from "./sendFileHandle.js";
|
|
13
|
+
|
|
14
|
+
/** `mountDir` is the mount folder relative to the workspace, e.g. `artifacts/images`; `""` is the workspace itself. */
|
|
15
|
+
export async function artifactScope(workspaceRoot: string, mountDir: string): Promise<VerifiedScope | null> {
|
|
16
|
+
const trustedReal = await realpathIfPresent(workspaceRoot);
|
|
17
|
+
return trustedReal === null ? null : { trustedReal, scopeWithinTrusted: mountDir };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The decoded in-scope path of a request, or null when the mount folder is absent or leads out of the
|
|
21
|
+
* workspace, or the URL is malformed, escapes, or touches a dotfile. */
|
|
22
|
+
export async function artifactRequestPath(scope: VerifiedScope, reqPath: string): Promise<string | null> {
|
|
23
|
+
const scopeReal = await scopeRealpath(scope);
|
|
24
|
+
const decoded = scopeReal === null ? null : resolveArtifactRequestPath(scopeReal, reqPath, true);
|
|
25
|
+
return scopeReal === null || decoded === null ? null : lexicallyInside(scopeReal, decoded);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The `path.relative` form is the containment check CodeQL recognises before the path reaches realpath.
|
|
29
|
+
export function lexicallyInside(scopeReal: string, relPath: string): string | null {
|
|
30
|
+
const inside = path.relative(scopeReal, path.resolve(scopeReal, relPath));
|
|
31
|
+
return inside === "" || inside.startsWith("..") || path.isAbsolute(inside) ? null : inside;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A verified handle on a regular file of the scope; null for anything else. The caller closes it. */
|
|
35
|
+
export async function openArtifactFile(scope: VerifiedScope, relPath: string): Promise<FileHandle | null> {
|
|
36
|
+
const handle = await openVerifiedWithinScope(scope, relPath);
|
|
37
|
+
if (handle === null) return null;
|
|
38
|
+
const isFile = await handle.stat().then(
|
|
39
|
+
(stats) => stats.isFile(),
|
|
40
|
+
() => false,
|
|
41
|
+
);
|
|
42
|
+
if (isFile) return handle;
|
|
43
|
+
await handle.close();
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Bytes of a regular file of the scope, read through the verified handle; null for anything else or on any error. */
|
|
48
|
+
export async function readArtifactFile(scope: VerifiedScope, relPath: string): Promise<Buffer | null> {
|
|
49
|
+
try {
|
|
50
|
+
const handle = await openArtifactFile(scope, relPath);
|
|
51
|
+
if (handle === null) return null;
|
|
52
|
+
try {
|
|
53
|
+
return await handle.readFile();
|
|
54
|
+
} finally {
|
|
55
|
+
await handle.close();
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ArtifactResponder = (req: Request, res: Response, scope: VerifiedScope, relPath: string) => Promise<void>;
|
|
63
|
+
|
|
64
|
+
export const sendArtifactFile: ArtifactResponder = async (req, res, scope, relPath) => {
|
|
65
|
+
const handle = await openArtifactFile(scope, relPath);
|
|
66
|
+
if (handle === null) {
|
|
67
|
+
res.status(404).end();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await sendFileHandle(req, res, handle, relPath);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** A mount over `workspaceRoot/mountDir` that answers requests matching `extension`; everything else is a 404. */
|
|
74
|
+
export function artifactMount(workspaceRoot: string, mountDir: string, extension: RegExp, respond: ArtifactResponder = sendArtifactFile): RequestHandler {
|
|
75
|
+
return async (req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
const scope = extension.test(req.path) ? await artifactScope(workspaceRoot, mountDir) : null;
|
|
78
|
+
const relPath = scope === null ? null : await artifactRequestPath(scope, req.path);
|
|
79
|
+
if (scope === null || relPath === null) {
|
|
80
|
+
res.status(404).end();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await respond(req, res, scope, relPath);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
log.warn("artifacts", "could not serve an artifact", { mountDir, path: req.path, error: String(err) });
|
|
86
|
+
if (!res.headersSent) res.status(404).end();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -10,17 +10,15 @@
|
|
|
10
10
|
// data/attachments/YYYY/MM/<id>.<ext> (original, always)
|
|
11
11
|
// data/attachments/YYYY/MM/<id>.pdf (companion, PPTX only — same <id>)
|
|
12
12
|
|
|
13
|
-
import { readFile, stat } from "fs/promises";
|
|
14
13
|
import path from "path";
|
|
15
14
|
import { WORKSPACE_DIRS, WORKSPACE_PATHS } from "../../workspace/paths.js";
|
|
16
15
|
import { shortId } from "../id.js";
|
|
17
|
-
import { writeFileAtomic } from "./atomic.js";
|
|
18
16
|
import { yearMonthUtc } from "@mulmoclaude/core/artifacts";
|
|
19
17
|
import { makePathValidator } from "./path-validator.js";
|
|
20
|
-
import {
|
|
18
|
+
import { makeStoreFiles } from "./store-files.js";
|
|
21
19
|
import { storedExtensionFor } from "./attachment-mime.js";
|
|
22
20
|
|
|
23
|
-
const
|
|
21
|
+
const store = makeStoreFiles(() => WORKSPACE_PATHS.attachments, WORKSPACE_DIRS.attachments);
|
|
24
22
|
|
|
25
23
|
export interface SavedAttachment {
|
|
26
24
|
/** Workspace-relative path of the file written to disk. */
|
|
@@ -31,14 +29,15 @@ export interface SavedAttachment {
|
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
/** Post-save hook fired after every successful `saveAttachment`.
|
|
34
|
-
* Receives the
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* already on disk by the time the hook
|
|
32
|
+
* Receives the workspace-relative path (the same one returned to the
|
|
33
|
+
* caller) and the MIME type the bytes were saved with — no absolute
|
|
34
|
+
* path, so a hook reads the bytes back through the store. Hooks must
|
|
35
|
+
* NEVER throw — the upload is already on disk by the time the hook
|
|
36
|
+
* runs, so a failure should
|
|
38
37
|
* log and return, not propagate. Multiple hooks fan out via
|
|
39
38
|
* `Promise.allSettled` so one slow / failing hook can't block or
|
|
40
39
|
* break the others. (#1222 PR-A.) */
|
|
41
|
-
export type SaveAttachmentHook = (
|
|
40
|
+
export type SaveAttachmentHook = (relativePath: string, mimeType: string) => Promise<void>;
|
|
42
41
|
|
|
43
42
|
const saveAttachmentHooks: SaveAttachmentHook[] = [];
|
|
44
43
|
|
|
@@ -54,14 +53,14 @@ export function registerSaveAttachmentHook(hook: SaveAttachmentHook): () => void
|
|
|
54
53
|
};
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
async function runSaveAttachmentHooks(
|
|
56
|
+
async function runSaveAttachmentHooks(relativePath: string, mimeType: string): Promise<void> {
|
|
58
57
|
if (saveAttachmentHooks.length === 0) return;
|
|
59
58
|
// Snapshot the array so a hook that calls `registerSaveAttachmentHook`
|
|
60
59
|
// (or its unregister fn) during this loop doesn't mutate the
|
|
61
60
|
// iteration order. `allSettled` so one failing hook doesn't stop
|
|
62
61
|
// the others — failures are the hook's responsibility to log.
|
|
63
62
|
const snapshot = [...saveAttachmentHooks];
|
|
64
|
-
await Promise.allSettled(snapshot.map((hook) => hook(
|
|
63
|
+
await Promise.allSettled(snapshot.map((hook) => hook(relativePath, mimeType)));
|
|
65
64
|
}
|
|
66
65
|
|
|
67
66
|
/** Save a single attachment under data/attachments/YYYY/MM/. The
|
|
@@ -70,15 +69,13 @@ async function runSaveAttachmentHooks(absPath: string, relativePath: string, mim
|
|
|
70
69
|
export async function saveAttachment(base64Data: string, mimeType: string, originalFilename?: string): Promise<SavedAttachment> {
|
|
71
70
|
const partition = yearMonthUtc();
|
|
72
71
|
const ext = storedExtensionFor(mimeType, originalFilename);
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
await writeFileAtomic(absPath, Buffer.from(base64Data, "base64"));
|
|
76
|
-
const relativePath = path.posix.join(WORKSPACE_DIRS.attachments, partition, filename);
|
|
72
|
+
const relativePath = path.posix.join(WORKSPACE_DIRS.attachments, partition, `${shortId()}${ext}`);
|
|
73
|
+
await store.write(relativePath, Buffer.from(base64Data, "base64"));
|
|
77
74
|
// Hooks (e.g. photo-EXIF capture) are awaited so callers can rely
|
|
78
75
|
// on the sidecar existing by the time `saveAttachment` resolves —
|
|
79
76
|
// simplifies test assertions and removes a class of race conditions
|
|
80
77
|
// for downstream tools that read sidecars right after upload.
|
|
81
|
-
await runSaveAttachmentHooks(
|
|
78
|
+
await runSaveAttachmentHooks(relativePath, mimeType);
|
|
82
79
|
return { relativePath, mimeType };
|
|
83
80
|
}
|
|
84
81
|
|
|
@@ -92,30 +89,21 @@ export async function saveCompanion(originalRelativePath: string, buf: Buffer, e
|
|
|
92
89
|
const dir = path.posix.dirname(originalRelativePath);
|
|
93
90
|
const base = path.posix.basename(originalRelativePath, path.posix.extname(originalRelativePath));
|
|
94
91
|
const relativePath = path.posix.join(dir, `${base}${ext}`);
|
|
95
|
-
|
|
96
|
-
await writeFileAtomic(absPath, buf);
|
|
92
|
+
await store.write(relativePath, buf);
|
|
97
93
|
return relativePath;
|
|
98
94
|
}
|
|
99
95
|
|
|
100
96
|
export async function loadAttachmentBase64(relativePath: string): Promise<string> {
|
|
101
|
-
|
|
102
|
-
const buf = await readFile(absPath);
|
|
103
|
-
return buf.toString("base64");
|
|
97
|
+
return (await store.read(relativePath)).toString("base64");
|
|
104
98
|
}
|
|
105
99
|
|
|
106
100
|
export async function loadAttachmentBytes(relativePath: string): Promise<Buffer> {
|
|
107
|
-
|
|
108
|
-
return readFile(absPath);
|
|
101
|
+
return store.read(relativePath);
|
|
109
102
|
}
|
|
110
103
|
|
|
111
104
|
/** True when `relativePath` names a regular file inside the attachment store. */
|
|
112
105
|
export async function attachmentExists(relativePath: string): Promise<boolean> {
|
|
113
|
-
|
|
114
|
-
const absPath = await resolvers.forRead(relativePath);
|
|
115
|
-
return (await stat(absPath)).isFile();
|
|
116
|
-
} catch {
|
|
117
|
-
return false;
|
|
118
|
-
}
|
|
106
|
+
return store.isFile(relativePath);
|
|
119
107
|
}
|
|
120
108
|
|
|
121
109
|
export const isAttachmentPath = makePathValidator({ prefix: WORKSPACE_DIRS.attachments });
|
|
@@ -1,32 +1,26 @@
|
|
|
1
|
-
import { readFile } from "fs/promises";
|
|
2
1
|
import path from "path";
|
|
3
2
|
import { WORKSPACE_DIRS, WORKSPACE_PATHS } from "../../workspace/paths.js";
|
|
4
3
|
import { shortId } from "../id.js";
|
|
5
|
-
import { writeFileAtomic } from "./atomic.js";
|
|
6
4
|
import { yearMonthUtc } from "@mulmoclaude/core/artifacts";
|
|
7
5
|
import { makePathValidator } from "./path-validator.js";
|
|
8
|
-
import {
|
|
6
|
+
import { makeStoreFiles } from "./store-files.js";
|
|
9
7
|
|
|
10
|
-
const
|
|
8
|
+
const store = makeStoreFiles(() => WORKSPACE_PATHS.images, WORKSPACE_DIRS.images);
|
|
11
9
|
|
|
12
10
|
// #764 sharded under images/YYYY/MM/ (UTC). Buffer pass-through avoids re-encoding the PNG bytes.
|
|
13
11
|
export async function saveImage(base64Data: string): Promise<string> {
|
|
14
12
|
const partition = yearMonthUtc();
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return path.posix.join(WORKSPACE_DIRS.images, partition, filename);
|
|
13
|
+
const relativePath = path.posix.join(WORKSPACE_DIRS.images, partition, `${shortId()}.png`);
|
|
14
|
+
await store.write(relativePath, Buffer.from(base64Data, "base64"));
|
|
15
|
+
return relativePath;
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
export async function overwriteImage(relativePath: string, base64Data: string): Promise<void> {
|
|
22
|
-
|
|
23
|
-
await writeFileAtomic(absPath, Buffer.from(base64Data, "base64"));
|
|
19
|
+
await store.write(relativePath, Buffer.from(base64Data, "base64"));
|
|
24
20
|
}
|
|
25
21
|
|
|
26
22
|
export async function loadImageBase64(relativePath: string): Promise<string> {
|
|
27
|
-
|
|
28
|
-
const buf = await readFile(absPath);
|
|
29
|
-
return buf.toString("base64");
|
|
23
|
+
return (await store.read(relativePath)).toString("base64");
|
|
30
24
|
}
|
|
31
25
|
|
|
32
26
|
export function stripDataUri(dataUri: string): string {
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
// written ledger never bricks server boot. Writes go through the
|
|
16
16
|
// atomic helper, so a crashed install can't leave a corrupt file.
|
|
17
17
|
|
|
18
|
-
import { readFile } from "node:fs/promises";
|
|
19
18
|
import path from "node:path";
|
|
19
|
+
import type { VerifiedScope } from "@mulmoclaude/core/files";
|
|
20
|
+
import { openArtifactFile } from "./artifactMount.js";
|
|
20
21
|
import { loadJsonFile } from "./json.js";
|
|
21
22
|
import { hasStringProp } from "../types.js";
|
|
22
23
|
import { WORKSPACE_PATHS } from "../../workspace/paths.js";
|
|
@@ -57,10 +58,17 @@ export interface PluginAsset {
|
|
|
57
58
|
contentType: string;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
/** Null for a path that is missing, not a regular file, or outside the scope (also when swapped mid-read); other
|
|
62
|
+
* read errors propagate. The bytes come from the verified handle (#3573). */
|
|
63
|
+
export async function readPluginAsset(scope: VerifiedScope, relPath: string): Promise<PluginAsset | null> {
|
|
64
|
+
const handle = await openArtifactFile(scope, relPath);
|
|
65
|
+
if (handle === null) return null;
|
|
66
|
+
try {
|
|
67
|
+
const data = await handle.readFile();
|
|
68
|
+
return { data, contentType: pluginAssetContentType(path.extname(relPath).toLowerCase()) };
|
|
69
|
+
} finally {
|
|
70
|
+
await handle.close();
|
|
71
|
+
}
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
// Lookup table over a switch — flat data structure stays under
|
|
@@ -14,14 +14,6 @@ export function isEnoent(err: unknown): boolean {
|
|
|
14
14
|
return isErrorWithCode(err) && err.code === "ENOENT";
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function readBinarySafeSync(absPath: string): Buffer | null {
|
|
18
|
-
try {
|
|
19
|
-
return readFileSync(absPath);
|
|
20
|
-
} catch {
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
17
|
export async function readTextSafe(absPath: string): Promise<string | null> {
|
|
26
18
|
try {
|
|
27
19
|
return await promises.readFile(absPath, "utf-8");
|