@cosmicdrift/kumiko-framework 0.163.1 → 0.163.3
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/package.json +3 -3
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +41 -0
- package/src/bun-db/query.ts +3 -0
- package/src/db/__tests__/dialect-instant.test.ts +1 -4
- package/src/db/__tests__/instant-to-driver-temporal.test.ts +21 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +48 -0
- package/src/db/dialect.ts +4 -0
- package/src/db/replay-migration-sql.ts +30 -27
- package/src/engine/extensions/user-data.ts +9 -1
- package/src/files/__tests__/files.integration.test.ts +24 -0
- package/src/files/__tests__/write-stream.test.ts +13 -0
- package/src/files/file-routes.ts +13 -0
- package/src/files/local-provider.ts +21 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.163.
|
|
3
|
+
"version": "0.163.3",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.163.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.163.3",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.18",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.163.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.163.3",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// kumiko-framework#1480: instantFromDriver referenced the global `Temporal`
|
|
2
|
+
// without importing it. Bun doesn't expose Temporal as a globalThis property
|
|
3
|
+
// reliably — any storeTable read of a timestamptz column crashed with
|
|
4
|
+
// "Temporal is not defined" unless some other boot path happened to install
|
|
5
|
+
// the polyfill globally first (order-dependent, easy to miss in a fresh
|
|
6
|
+
// process). The fix is a static `import { Temporal } from "temporal-polyfill"`
|
|
7
|
+
// in query.ts, so this test deletes globalThis.Temporal before calling
|
|
8
|
+
// coerceRow — proving the coercion no longer depends on the global at all.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { Temporal } from "temporal-polyfill";
|
|
12
|
+
import { coerceRow, type TableInfo } from "../query";
|
|
13
|
+
|
|
14
|
+
function timestamptzTableInfo(): TableInfo {
|
|
15
|
+
return {
|
|
16
|
+
name: "probe",
|
|
17
|
+
columnOf: (f) => f,
|
|
18
|
+
pgTypeOf: (c) => (c === "updated_at" ? "timestamptz" : undefined),
|
|
19
|
+
bigintJsModeOf: () => undefined,
|
|
20
|
+
fieldOf: (c) => c,
|
|
21
|
+
hasColumn: () => true,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("coerceRow — timestamptz → Temporal.Instant", () => {
|
|
26
|
+
test("coerces without relying on a global Temporal", () => {
|
|
27
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
28
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
29
|
+
try {
|
|
30
|
+
const row = { updated_at: new Date("2026-04-18T10:00:00Z") };
|
|
31
|
+
const result = coerceRow(row, timestamptzTableInfo());
|
|
32
|
+
expect(result.updated_at).toBeInstanceOf(Temporal.Instant);
|
|
33
|
+
expect((result.updated_at as unknown as Temporal.Instant).toString()).toBe(
|
|
34
|
+
"2026-04-18T10:00:00Z",
|
|
35
|
+
);
|
|
36
|
+
} finally {
|
|
37
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
38
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -24,6 +24,9 @@ import type {
|
|
|
24
24
|
WhereObject,
|
|
25
25
|
WhereOperator,
|
|
26
26
|
} from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
27
|
+
// Static import, not the ambient global: Bun doesn't expose Temporal on
|
|
28
|
+
// globalThis, so instantFromDriver crashed on timestamptz reads (#1480).
|
|
29
|
+
import { Temporal } from "temporal-polyfill";
|
|
27
30
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
|
|
28
31
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
29
32
|
import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
|
|
@@ -11,11 +11,8 @@
|
|
|
11
11
|
// die invalid-Probe (echte Garbage muss weiterhin throwen — kein silent
|
|
12
12
|
// swallowing).
|
|
13
13
|
|
|
14
|
-
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
15
|
-
|
|
16
|
-
await ensureTemporalPolyfill();
|
|
17
|
-
|
|
18
14
|
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { Temporal } from "temporal-polyfill";
|
|
19
16
|
import { instantToDriver as toDriver } from "../dialect";
|
|
20
17
|
|
|
21
18
|
describe("instant() customType — toDriver", () => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// kumiko-framework#1480 twin bug: instantToDriver referenced the global
|
|
2
|
+
// `Temporal` the same way instantFromDriver in bun-db/query.ts did. Fixed
|
|
3
|
+
// the same way — static `import { Temporal } from "temporal-polyfill"` in
|
|
4
|
+
// dialect.ts. This test deletes globalThis.Temporal before calling it,
|
|
5
|
+
// proving the write path no longer depends on the global either.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { instantToDriver } from "../dialect";
|
|
9
|
+
|
|
10
|
+
describe("instantToDriver — without a global Temporal", () => {
|
|
11
|
+
test("coerces an ISO string without relying on a global Temporal", () => {
|
|
12
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
13
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
14
|
+
try {
|
|
15
|
+
expect(instantToDriver("2026-04-18T10:00:00Z")).toBe("2026-04-18T10:00:00Z");
|
|
16
|
+
} finally {
|
|
17
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
18
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -72,6 +72,54 @@ CREATE INDEX IF NOT EXISTS "read_accounts_tenant_id_idx" ON "read_accounts" ("te
|
|
|
72
72
|
}
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
// Regression: publicstatus#0007_fix-secrets-table-columns adds three
|
|
76
|
+
// columns in ONE statement (comma-separated ADD COLUMN clauses) — the
|
|
77
|
+
// replay used to only pick up the first, reporting "metadata" and
|
|
78
|
+
// "last_rotated_at" as missing even though the migration creates them.
|
|
79
|
+
test("multiple ADD COLUMN clauses in a single ALTER TABLE statement all extend the table", () => {
|
|
80
|
+
const dir = tmpMigrationsDir();
|
|
81
|
+
try {
|
|
82
|
+
write(dir, "0001_init.sql", `CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY);`);
|
|
83
|
+
write(
|
|
84
|
+
dir,
|
|
85
|
+
"0002_add-cols.sql",
|
|
86
|
+
`ALTER TABLE "read_a"
|
|
87
|
+
ADD COLUMN IF NOT EXISTS "envelope" jsonb NOT NULL,
|
|
88
|
+
ADD COLUMN IF NOT EXISTS "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
89
|
+
ADD COLUMN IF NOT EXISTS "last_rotated_at" timestamp with time zone DEFAULT now() NOT NULL;`,
|
|
90
|
+
);
|
|
91
|
+
const replayed = replayMigrationsDir(dir);
|
|
92
|
+
expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual([
|
|
93
|
+
"envelope",
|
|
94
|
+
"id",
|
|
95
|
+
"last_rotated_at",
|
|
96
|
+
"metadata",
|
|
97
|
+
]);
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("mixed ADD + DROP COLUMN clauses in one statement apply in order", () => {
|
|
104
|
+
const dir = tmpMigrationsDir();
|
|
105
|
+
try {
|
|
106
|
+
write(
|
|
107
|
+
dir,
|
|
108
|
+
"0001_init.sql",
|
|
109
|
+
`CREATE TABLE IF NOT EXISTS "read_a" ("id" uuid PRIMARY KEY, "legacy" text);`,
|
|
110
|
+
);
|
|
111
|
+
write(
|
|
112
|
+
dir,
|
|
113
|
+
"0002_migrate-cols.sql",
|
|
114
|
+
`ALTER TABLE "read_a" DROP COLUMN IF EXISTS "legacy", ADD COLUMN IF NOT EXISTS "title" text;`,
|
|
115
|
+
);
|
|
116
|
+
const replayed = replayMigrationsDir(dir);
|
|
117
|
+
expect([...(replayed.get("read_a")?.columns ?? [])].sort()).toEqual(["id", "title"]);
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
75
123
|
test("DROP COLUMN IF EXISTS (hand-edited) still removes the column", () => {
|
|
76
124
|
const dir = tmpMigrationsDir();
|
|
77
125
|
try {
|
package/src/db/dialect.ts
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
//
|
|
13
13
|
// The framework no longer imports drizzle-orm at runtime — schema-files
|
|
14
14
|
// use only this module.
|
|
15
|
+
//
|
|
16
|
+
// Static import, not the ambient global: Bun doesn't expose Temporal on
|
|
17
|
+
// globalThis, so instantToDriver crashed on timestamptz writes (#1480).
|
|
15
18
|
|
|
16
19
|
import {
|
|
17
20
|
type ColumnHandle,
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
KUMIKO_NAME_SYMBOL,
|
|
20
23
|
type SchemaTable,
|
|
21
24
|
} from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
25
|
+
import { Temporal } from "temporal-polyfill";
|
|
22
26
|
import type {
|
|
23
27
|
ColumnMeta,
|
|
24
28
|
CompositePrimaryKeyMeta,
|
|
@@ -54,37 +54,40 @@ function applyStatement(schema: Map<string, { columns: Set<string> }>, statement
|
|
|
54
54
|
const create = statement.match(
|
|
55
55
|
/^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?"([^"]+)"\s*\(([\s\S]*)\);?\s*$/i,
|
|
56
56
|
);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
63
|
-
const addColumnTable = addColumn?.[1];
|
|
64
|
-
const addColumnName = addColumn?.[2];
|
|
57
|
+
if (create?.[1] !== undefined && create[2] !== undefined) {
|
|
58
|
+
schema.set(create[1], { columns: parseColumnNames(create[2]) });
|
|
59
|
+
// skip: CREATE TABLE fully handled above, no other clause can also match
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
65
62
|
|
|
66
63
|
const dropTable = statement.match(/^DROP TABLE\s+(?:IF EXISTS\s+)?"([^"]+)"/i);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const dropColumnTable = dropColumn?.[1];
|
|
73
|
-
const dropColumnName = dropColumn?.[2];
|
|
64
|
+
if (dropTable?.[1] !== undefined) {
|
|
65
|
+
schema.delete(dropTable[1]);
|
|
66
|
+
// skip: DROP TABLE fully handled above, no other clause can also match
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
74
69
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
70
|
+
// A single ALTER TABLE statement can carry multiple comma-separated
|
|
71
|
+
// ADD/DROP COLUMN clauses (e.g. migration 0007_fix-secrets-table-columns'
|
|
72
|
+
// three-column fix in one statement) — matchAll over the whole body
|
|
73
|
+
// instead of matching only the first clause, in statement order so an
|
|
74
|
+
// add-then-drop of the same column (unusual, but not impossible) resolves
|
|
75
|
+
// correctly.
|
|
76
|
+
const alterTable = statement.match(/^ALTER TABLE\s+"([^"]+)"\s+([\s\S]*?);?\s*$/i);
|
|
77
|
+
const alterTableName = alterTable?.[1];
|
|
78
|
+
const alterBody = alterTable?.[2];
|
|
79
|
+
if (alterTableName !== undefined && alterBody !== undefined) {
|
|
80
|
+
const table = schema.get(alterTableName) ?? { columns: new Set<string>() };
|
|
81
|
+
schema.set(alterTableName, table);
|
|
82
|
+
const clauseRe = /(ADD|DROP)\s+COLUMN\s+(?:IF (?:NOT )?EXISTS\s+)?"([^"]+)"/gi;
|
|
83
|
+
for (const [, verb, name] of alterBody.matchAll(clauseRe)) {
|
|
84
|
+
if (verb === undefined || name === undefined) continue;
|
|
85
|
+
if (verb.toUpperCase() === "ADD") table.columns.add(name);
|
|
86
|
+
else table.columns.delete(name);
|
|
87
|
+
}
|
|
85
88
|
}
|
|
86
|
-
// else: CREATE INDEX
|
|
87
|
-
//
|
|
89
|
+
// else: CREATE INDEX and everything else don't change the table/column
|
|
90
|
+
// shape this replay tracks.
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
// Reads `<migrationsDir>/*.sql` in sequence order and replays every
|
|
@@ -145,11 +145,19 @@ export type UserDataExportHook = (ctx: UserDataHookCtx) => Promise<UserDataExpor
|
|
|
145
145
|
*
|
|
146
146
|
* Idempotent — wenn der Job zweimal läuft (Crash-Recovery), darf der
|
|
147
147
|
* Hook nicht crashen.
|
|
148
|
+
*
|
|
149
|
+
* Return additive to `void`: a hook can return `{status:"ok"}` or
|
|
150
|
+
* `{status:"incomplete", reason}` to signal a partial success to the
|
|
151
|
+
* cleanup runner (e.g. an external provider call failed) without
|
|
152
|
+
* throwing/rolling back the sub-transaction. Existing `void` returners
|
|
153
|
+
* stay valid unchanged.
|
|
148
154
|
*/
|
|
149
155
|
export type UserDataDeleteHook = (
|
|
150
156
|
ctx: UserDataHookCtx,
|
|
151
157
|
strategy: UserDataDeleteStrategy,
|
|
152
|
-
) => Promise<
|
|
158
|
+
) => Promise<
|
|
159
|
+
undefined | { readonly status: "ok" } | { readonly status: "incomplete"; readonly reason: string }
|
|
160
|
+
>;
|
|
153
161
|
|
|
154
162
|
/**
|
|
155
163
|
* Komplette Hook-Tafel für EXT_USER_DATA. Sprint 2 user-data-rights
|
|
@@ -593,6 +593,30 @@ describe("error handling", () => {
|
|
|
593
593
|
expect(body.error).toContain("invalid_file_type");
|
|
594
594
|
});
|
|
595
595
|
|
|
596
|
+
test("upload with path-traversal entityType is rejected before it reaches storage", async () => {
|
|
597
|
+
const pngContent = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
598
|
+
const res = await uploadFile(adminUser, "logo.png", pngContent, "image/png", {
|
|
599
|
+
entityType: "../../tenantB",
|
|
600
|
+
entityId: "1",
|
|
601
|
+
fieldName: "logo",
|
|
602
|
+
});
|
|
603
|
+
expect(res.status).toBe(400);
|
|
604
|
+
const body = await res.json();
|
|
605
|
+
expect(body.error).toContain("invalid_entityType");
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("upload with path-traversal fieldName is rejected before it reaches storage", async () => {
|
|
609
|
+
const pngContent = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
610
|
+
const res = await uploadFile(adminUser, "logo.png", pngContent, "image/png", {
|
|
611
|
+
entityType: "tenant",
|
|
612
|
+
entityId: "1",
|
|
613
|
+
fieldName: "../../escape",
|
|
614
|
+
});
|
|
615
|
+
expect(res.status).toBe(400);
|
|
616
|
+
const body = await res.json();
|
|
617
|
+
expect(body.error).toContain("invalid_fieldName");
|
|
618
|
+
});
|
|
619
|
+
|
|
596
620
|
test("upload without auth returns 401", async () => {
|
|
597
621
|
const formData = new FormData();
|
|
598
622
|
formData.append("file", new File([new Uint8Array(10)], "test.png", { type: "image/png" }));
|
|
@@ -147,6 +147,19 @@ describe("FileStorageProvider.writeStream — local-filesystem", () => {
|
|
|
147
147
|
const data = await provider.read("k");
|
|
148
148
|
expect(Array.from(data)).toEqual([2, 2, 2]);
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
test("write/read/delete reject a key that escapes basePath; exists reports false", async () => {
|
|
152
|
+
const provider = createLocalProvider(basePath);
|
|
153
|
+
const escapingKey = "../../etc/passwd";
|
|
154
|
+
|
|
155
|
+
await expect(provider.write(escapingKey, new Uint8Array([1]))).rejects.toThrow(
|
|
156
|
+
/escapes basePath/,
|
|
157
|
+
);
|
|
158
|
+
await expect(provider.read(escapingKey)).rejects.toThrow(/escapes basePath/);
|
|
159
|
+
await expect(provider.delete(escapingKey)).rejects.toThrow(/escapes basePath/);
|
|
160
|
+
// exists() treats any stat failure (missing file, escaping key) as "not there".
|
|
161
|
+
expect(await provider.exists(escapingKey)).toBe(false);
|
|
162
|
+
});
|
|
150
163
|
});
|
|
151
164
|
|
|
152
165
|
describe("FileStorageProvider.writeStream — Streaming-Property", () => {
|
package/src/files/file-routes.ts
CHANGED
|
@@ -112,6 +112,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
112
112
|
const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
|
|
113
113
|
const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
|
|
114
114
|
|
|
115
|
+
// These interpolate directly into the storage key (buildStorageKey below) —
|
|
116
|
+
// reject anything but a safe identifier so a client can't path-traverse
|
|
117
|
+
// out of its tenant's storage prefix (../.. segments, absolute paths).
|
|
118
|
+
for (const [field, value] of [
|
|
119
|
+
["entityType", entityType],
|
|
120
|
+
["entityId", entityId],
|
|
121
|
+
["fieldName", fieldName],
|
|
122
|
+
] as const) {
|
|
123
|
+
if (value !== undefined && !/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
124
|
+
return c.json({ error: `invalid_${field}: must match /^[A-Za-z0-9_-]+$/` }, 400);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
115
128
|
// Validate against entity field definition if available.
|
|
116
129
|
let maxSize = options.maxUploadSize ?? "10mb";
|
|
117
130
|
let accept: readonly string[] | undefined;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
2
2
|
import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
5
|
import type { FileStorageProvider } from "./types";
|
|
6
6
|
|
|
@@ -8,9 +8,23 @@ import type { FileStorageProvider } from "./types";
|
|
|
8
8
|
// pick an object-store provider (S3/R2/…). mimeType is ignored here; the
|
|
9
9
|
// filesystem tracks no metadata beyond what the caller stores on FileRef.
|
|
10
10
|
export function createLocalProvider(basePath: string): FileStorageProvider {
|
|
11
|
+
const resolvedBase = resolve(basePath);
|
|
12
|
+
|
|
13
|
+
// Callers are expected to only ever pass keys built by buildStorageKey(),
|
|
14
|
+
// but the storage layer shouldn't rely solely on that — this resolves the
|
|
15
|
+
// real path and rejects anything that escapes basePath (e.g. a `..`
|
|
16
|
+
// segment that slipped past an upstream check), regardless of the key's
|
|
17
|
+
// source.
|
|
18
|
+
function resolveContainedPath(key: string): string {
|
|
19
|
+
const filePath = resolve(join(basePath, key));
|
|
20
|
+
if (filePath !== resolvedBase && !filePath.startsWith(resolvedBase + sep)) {
|
|
21
|
+
throw new Error(`storage key escapes basePath: "${key}"`);
|
|
22
|
+
}
|
|
23
|
+
return filePath;
|
|
24
|
+
}
|
|
11
25
|
return {
|
|
12
26
|
async write(key: string, data: Uint8Array, _mimeType?: string): Promise<void> {
|
|
13
|
-
const filePath =
|
|
27
|
+
const filePath = resolveContainedPath(key);
|
|
14
28
|
await mkdir(dirname(filePath), { recursive: true });
|
|
15
29
|
await writeFile(filePath, data);
|
|
16
30
|
},
|
|
@@ -26,7 +40,7 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
|
|
|
26
40
|
// Hygiene; ein periodischer cron-cleanup auf alten `.tmp`-Files
|
|
27
41
|
// ist die saubere Loesung wenn das in Production realistisch
|
|
28
42
|
// greift.
|
|
29
|
-
const filePath =
|
|
43
|
+
const filePath = resolveContainedPath(key);
|
|
30
44
|
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
31
45
|
await mkdir(dirname(filePath), { recursive: true });
|
|
32
46
|
try {
|
|
@@ -41,7 +55,7 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
|
|
|
41
55
|
},
|
|
42
56
|
|
|
43
57
|
async read(key: string): Promise<Uint8Array> {
|
|
44
|
-
const filePath =
|
|
58
|
+
const filePath = resolveContainedPath(key);
|
|
45
59
|
return readFile(filePath);
|
|
46
60
|
},
|
|
47
61
|
|
|
@@ -55,7 +69,7 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
|
|
|
55
69
|
// (z.B. ENOENT bei Missing-File faellt erst beim ersten chunk-
|
|
56
70
|
// pull, nicht beim readStream-Aufruf — gleiches Lazy-Verhalten
|
|
57
71
|
// wie inmemory + S3).
|
|
58
|
-
const filePath =
|
|
72
|
+
const filePath = resolveContainedPath(key);
|
|
59
73
|
const stream = createReadStream(filePath);
|
|
60
74
|
return {
|
|
61
75
|
async *[Symbol.asyncIterator]() {
|
|
@@ -76,13 +90,13 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
|
|
|
76
90
|
},
|
|
77
91
|
|
|
78
92
|
async delete(key: string): Promise<void> {
|
|
79
|
-
const filePath =
|
|
93
|
+
const filePath = resolveContainedPath(key);
|
|
80
94
|
await rm(filePath, { force: true });
|
|
81
95
|
},
|
|
82
96
|
|
|
83
97
|
async exists(key: string): Promise<boolean> {
|
|
84
98
|
try {
|
|
85
|
-
await stat(
|
|
99
|
+
await stat(resolveContainedPath(key));
|
|
86
100
|
return true;
|
|
87
101
|
} catch {
|
|
88
102
|
return false;
|