run-dmcp 0.2.0 → 0.4.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/README.md +51 -13
- package/dist/bin/run-dmcp.js +1 -1
- package/dist/db/schema.js +170 -18
- package/dist/http/server.js +22 -1
- package/dist/index.d.ts +34 -2
- package/dist/index.js +86 -2
- package/dist/mcp-server.d.ts +1 -1
- package/dist/mcp-server.js +1 -1
- package/dist/register/resources.js +2 -2
- package/dist/rpg/index.d.ts +0 -16
- package/dist/rpg/index.js +4 -22
- package/dist/rpg/server.d.ts +16 -0
- package/dist/rpg/server.js +22 -0
- package/dist/schemas/index.d.ts +62 -62
- package/dist/server.d.ts +1 -0
- package/dist/server.js +20 -0
- package/dist/timeline/changes.d.ts +8 -0
- package/dist/timeline/changes.js +8 -0
- package/dist/timeline/export.d.ts +10 -0
- package/dist/timeline/export.js +10 -0
- package/dist/timeline/irreversible.d.ts +2 -0
- package/dist/timeline/replay.d.ts +22 -0
- package/dist/timeline/replay.js +22 -0
- package/dist/timeline/schema.js +2 -0
- package/dist/tools/audio.js +13 -9
- package/dist/tools/game.js +33 -1
- package/dist/tools/images.js +17 -10
- package/dist/tools/resource.d.ts +2 -2
- package/dist/tools/time.js +18 -3
- package/dist/types/index.d.ts +1 -1
- package/dist/utils/media-path.d.ts +52 -0
- package/dist/utils/media-path.js +106 -0
- package/dist/utils/output-schemas.d.ts +63 -63
- package/dist/utils/output-schemas.js +1 -1
- package/package.json +14 -2
- package/dist/register/abilities.d.ts +0 -2
- package/dist/register/abilities.js +0 -165
- package/dist/register/combat.d.ts +0 -2
- package/dist/register/combat.js +0 -207
- package/dist/register/mcp-prompts.d.ts +0 -2
- package/dist/register/mcp-prompts.js +0 -684
- package/dist/register/quests.d.ts +0 -2
- package/dist/register/quests.js +0 -118
- package/dist/register/status.d.ts +0 -2
- package/dist/register/status.js +0 -130
- package/dist/register/tables.d.ts +0 -2
- package/dist/register/tables.js +0 -146
- package/dist/tools/ability.d.ts +0 -48
- package/dist/tools/ability.js +0 -238
- package/dist/tools/combat.d.ts +0 -13
- package/dist/tools/combat.js +0 -195
- package/dist/tools/dice.d.ts +0 -23
- package/dist/tools/dice.js +0 -111
- package/dist/tools/quest.d.ts +0 -34
- package/dist/tools/quest.js +0 -164
- package/dist/tools/status.d.ts +0 -36
- package/dist/tools/status.js +0 -218
- package/dist/tools/tables.d.ts +0 -33
- package/dist/tools/tables.js +0 -209
package/dist/timeline/schema.js
CHANGED
|
@@ -53,6 +53,8 @@ export function initializeTimelineSchema() {
|
|
|
53
53
|
// all (absence is the absence of a fact, never a fact of absence -- hard
|
|
54
54
|
// rule 3). No ON DELETE CASCADE and no FK to `games`: deleting a game
|
|
55
55
|
// deletes its live rows, but the timeline of that game survives.
|
|
56
|
+
// DECISION(#21): a property to be declared irreversible needs its own fact key.
|
|
57
|
+
//
|
|
56
58
|
// `irreversible` is a per-fact flag, not a per-entity or per-value one --
|
|
57
59
|
// so any property a future consumer wants to declare irreversible has to
|
|
58
60
|
// live under its own fact key; you cannot flag half a blob.
|
package/dist/tools/audio.js
CHANGED
|
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from "uuid";
|
|
|
2
2
|
import { getDatabase, getDataDir, withTransaction } from "../db/connection.js";
|
|
3
3
|
import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync, rmSync, } from "fs";
|
|
4
4
|
import { dirname, join, extname } from "path";
|
|
5
|
+
import { mediaDirPath, mediaFilePath, mediaPathWithin } from "../utils/media-path.js";
|
|
5
6
|
import { getCharacter } from "./character.js";
|
|
6
7
|
import { getLocation } from "./world.js";
|
|
7
8
|
import { getFaction } from "./faction.js";
|
|
@@ -128,10 +129,11 @@ export async function storeAudio(params) {
|
|
|
128
129
|
else {
|
|
129
130
|
throw new Error("Either url or filePath must be provided");
|
|
130
131
|
}
|
|
131
|
-
// Build file path
|
|
132
|
+
// Build file path. Every segment below arrives from the caller, so the
|
|
133
|
+
// composition goes through the media-path choke point, which rejects
|
|
134
|
+
// anything that would land outside the audio directory.
|
|
132
135
|
const ext = getExtension(mimeType);
|
|
133
|
-
const relativePath =
|
|
134
|
-
const fullPath = join(getAudioDir(), relativePath);
|
|
136
|
+
const { relativePath, fullPath } = mediaFilePath(getAudioDir(), [params.gameId, `${params.entityType}s`, params.entityId], `${id}.${ext}`);
|
|
135
137
|
// Ensure directory exists and write file
|
|
136
138
|
ensureDir(dirname(fullPath));
|
|
137
139
|
writeFileSync(fullPath, audioBuffer);
|
|
@@ -192,7 +194,7 @@ export function getAudioFilePath(audioId) {
|
|
|
192
194
|
const audio = getAudio(audioId);
|
|
193
195
|
if (!audio)
|
|
194
196
|
return null;
|
|
195
|
-
const fullPath =
|
|
197
|
+
const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
|
|
196
198
|
if (!existsSync(fullPath))
|
|
197
199
|
return null;
|
|
198
200
|
return fullPath;
|
|
@@ -201,7 +203,7 @@ export function getAudioData(audioId) {
|
|
|
201
203
|
const audio = getAudio(audioId);
|
|
202
204
|
if (!audio)
|
|
203
205
|
return null;
|
|
204
|
-
const fullPath =
|
|
206
|
+
const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
|
|
205
207
|
if (!existsSync(fullPath))
|
|
206
208
|
return null;
|
|
207
209
|
const buffer = readFileSync(fullPath);
|
|
@@ -264,7 +266,7 @@ export function getCharacterVoiceReferences(gameId, characterId) {
|
|
|
264
266
|
const audioDir = getAudioDir();
|
|
265
267
|
const filePaths = voiceRefs
|
|
266
268
|
.map((ref) => {
|
|
267
|
-
const fullPath =
|
|
269
|
+
const fullPath = mediaPathWithin(audioDir, ref.filePath);
|
|
268
270
|
return existsSync(fullPath) ? fullPath : null;
|
|
269
271
|
})
|
|
270
272
|
.filter((p) => p !== null);
|
|
@@ -283,7 +285,7 @@ export function deleteAudio(audioId) {
|
|
|
283
285
|
if (!audio)
|
|
284
286
|
return false;
|
|
285
287
|
// Delete file
|
|
286
|
-
const fullPath =
|
|
288
|
+
const fullPath = mediaPathWithin(getAudioDir(), audio.filePath);
|
|
287
289
|
if (existsSync(fullPath)) {
|
|
288
290
|
unlinkSync(fullPath);
|
|
289
291
|
}
|
|
@@ -339,8 +341,10 @@ export function updateAudioMetadata(audioId, updates) {
|
|
|
339
341
|
// Cleanup helper - delete all audio for a game
|
|
340
342
|
export function deleteGameAudio(gameId) {
|
|
341
343
|
const db = getDatabase();
|
|
342
|
-
// Delete files
|
|
343
|
-
|
|
344
|
+
// Delete files. mediaDirPath rejects before the rmSync below, which is
|
|
345
|
+
// recursive and forced: an unchecked `..` here would take the whole data
|
|
346
|
+
// directory with it.
|
|
347
|
+
const gameDir = mediaDirPath(getAudioDir(), [gameId]);
|
|
344
348
|
if (existsSync(gameDir)) {
|
|
345
349
|
rmSync(gameDir, { recursive: true, force: true });
|
|
346
350
|
}
|
package/dist/tools/game.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from "uuid";
|
|
2
2
|
import { getDatabase } from "../db/connection.js";
|
|
3
3
|
import { safeJsonParse } from "../utils/json.js";
|
|
4
|
+
import { createLogger } from "../utils/logger.js";
|
|
5
|
+
import { deleteGameAudio } from "./audio.js";
|
|
6
|
+
import { deleteGameImages } from "./images.js";
|
|
7
|
+
const log = createLogger("game");
|
|
4
8
|
/**
|
|
5
9
|
* Validates that a game exists and returns it, or throws an error if not found.
|
|
6
10
|
* Use this in create operations to prevent orphaned records.
|
|
@@ -119,7 +123,35 @@ export function deleteGame(id) {
|
|
|
119
123
|
const db = getDatabase();
|
|
120
124
|
const stmt = db.prepare(`DELETE FROM games WHERE id = ?`);
|
|
121
125
|
const result = stmt.run(id);
|
|
122
|
-
|
|
126
|
+
if (result.changes === 0)
|
|
127
|
+
return false;
|
|
128
|
+
// The stored_audio / stored_images rows went with the game by foreign key
|
|
129
|
+
// cascade, so nothing is left that knows the files on disk exist. Cleanup
|
|
130
|
+
// therefore runs here, after the delete has committed.
|
|
131
|
+
//
|
|
132
|
+
// A cleanup failure degrades to a log rather than an exception: the row is
|
|
133
|
+
// already gone and throwing cannot un-delete it, so it would trade a stale
|
|
134
|
+
// directory for a failed tool call and lose both. Each cleanup is attempted
|
|
135
|
+
// separately, so one failing does not skip the other.
|
|
136
|
+
try {
|
|
137
|
+
deleteGameAudio(id);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
log.error("Failed to remove stored audio for a deleted game; files remain on disk", {
|
|
141
|
+
gameId: id,
|
|
142
|
+
error: error.message,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
deleteGameImages(id);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
log.error("Failed to remove stored images for a deleted game; files remain on disk", {
|
|
150
|
+
gameId: id,
|
|
151
|
+
error: error.message,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
123
155
|
}
|
|
124
156
|
export function updateGameLocation(gameId, locationId) {
|
|
125
157
|
const db = getDatabase();
|
package/dist/tools/images.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getDatabase, getDataDir, withTransaction } from "../db/connection.js";
|
|
|
3
3
|
import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync, rmSync, } from "fs";
|
|
4
4
|
import { dirname, join } from "path";
|
|
5
5
|
import sharp from "sharp";
|
|
6
|
+
import { mediaDirPath, mediaFilePath, mediaPathWithin } from "../utils/media-path.js";
|
|
6
7
|
import { getCharacter } from "./character.js";
|
|
7
8
|
import { getLocation } from "./world.js";
|
|
8
9
|
import { getItem } from "./inventory.js";
|
|
@@ -122,10 +123,11 @@ export async function storeImage(params) {
|
|
|
122
123
|
catch {
|
|
123
124
|
// If sharp can't parse it, proceed without dimensions
|
|
124
125
|
}
|
|
125
|
-
// Build file path
|
|
126
|
+
// Build file path. Every segment below arrives from the caller, so the
|
|
127
|
+
// composition goes through the media-path choke point, which rejects
|
|
128
|
+
// anything that would land outside the images directory.
|
|
126
129
|
const ext = getExtension(mimeType);
|
|
127
|
-
const relativePath =
|
|
128
|
-
const fullPath = join(getImagesDir(), relativePath);
|
|
130
|
+
const { relativePath, fullPath } = mediaFilePath(getImagesDir(), [params.gameId, `${params.entityType}s`, params.entityId], `${id}.${ext}`);
|
|
129
131
|
// Ensure directory exists and write file
|
|
130
132
|
ensureDir(dirname(fullPath));
|
|
131
133
|
writeFileSync(fullPath, imageBuffer);
|
|
@@ -177,7 +179,7 @@ export async function getImageData(imageId, options) {
|
|
|
177
179
|
const image = getImage(imageId);
|
|
178
180
|
if (!image)
|
|
179
181
|
return null;
|
|
180
|
-
const fullPath =
|
|
182
|
+
const fullPath = mediaPathWithin(getImagesDir(), image.filePath);
|
|
181
183
|
if (!existsSync(fullPath))
|
|
182
184
|
return null;
|
|
183
185
|
const originalBuffer = readFileSync(fullPath);
|
|
@@ -335,7 +337,7 @@ export function deleteImage(imageId) {
|
|
|
335
337
|
if (!image)
|
|
336
338
|
return false;
|
|
337
339
|
// Delete file
|
|
338
|
-
const fullPath =
|
|
340
|
+
const fullPath = mediaPathWithin(getImagesDir(), image.filePath);
|
|
339
341
|
if (existsSync(fullPath)) {
|
|
340
342
|
unlinkSync(fullPath);
|
|
341
343
|
}
|
|
@@ -380,10 +382,13 @@ export function updateImageMetadata(imageId, updates) {
|
|
|
380
382
|
validateEntityExists(newEntityId, newEntityType);
|
|
381
383
|
// If entity changed, move the file
|
|
382
384
|
if (newEntityId !== current.entityId || newEntityType !== current.entityType) {
|
|
383
|
-
const oldFullPath =
|
|
385
|
+
const oldFullPath = mediaPathWithin(getImagesDir(), current.filePath);
|
|
384
386
|
const ext = current.filePath.split(".").pop() || "png";
|
|
385
|
-
|
|
386
|
-
|
|
387
|
+
// The move recomposes the path from a caller-supplied entity id and
|
|
388
|
+
// type, so it goes through the same choke point as the original write.
|
|
389
|
+
const moved = mediaFilePath(getImagesDir(), [current.gameId, `${newEntityType}s`, newEntityId], `${imageId}.${ext}`);
|
|
390
|
+
newFilePath = moved.relativePath;
|
|
391
|
+
const newFullPath = moved.fullPath;
|
|
387
392
|
// Ensure new directory exists
|
|
388
393
|
ensureDir(dirname(newFullPath));
|
|
389
394
|
// Move the file
|
|
@@ -411,8 +416,10 @@ export function updateImageMetadata(imageId, updates) {
|
|
|
411
416
|
// Cleanup helper - delete all images for a game
|
|
412
417
|
export function deleteGameImages(gameId) {
|
|
413
418
|
const db = getDatabase();
|
|
414
|
-
// Delete files
|
|
415
|
-
|
|
419
|
+
// Delete files. mediaDirPath rejects before the rmSync below, which is
|
|
420
|
+
// recursive and forced: an unchecked `..` here would take the whole data
|
|
421
|
+
// directory with it.
|
|
422
|
+
const sessionDir = mediaDirPath(getImagesDir(), [gameId]);
|
|
416
423
|
if (existsSync(sessionDir)) {
|
|
417
424
|
rmSync(sessionDir, { recursive: true, force: true });
|
|
418
425
|
}
|
package/dist/tools/resource.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Resource, ResourceChange } from "../types/index.js";
|
|
2
2
|
export declare function createResource(params: {
|
|
3
3
|
gameId: string;
|
|
4
|
-
ownerType: "game" | "character";
|
|
4
|
+
ownerType: "game" | "character" | "faction" | "location";
|
|
5
5
|
ownerId?: string;
|
|
6
6
|
name: string;
|
|
7
7
|
description?: string;
|
|
@@ -20,7 +20,7 @@ export declare function updateResource(id: string, updates: {
|
|
|
20
20
|
}): Resource | null;
|
|
21
21
|
export declare function deleteResource(id: string): boolean;
|
|
22
22
|
export declare function listResources(gameId: string, filter?: {
|
|
23
|
-
ownerType?: "game" | "character";
|
|
23
|
+
ownerType?: "game" | "character" | "faction" | "location";
|
|
24
24
|
ownerId?: string;
|
|
25
25
|
category?: string;
|
|
26
26
|
}): Resource[];
|
package/dist/tools/time.js
CHANGED
|
@@ -133,9 +133,24 @@ export function advanceTime(gameId, duration) {
|
|
|
133
133
|
const consequenceFailures = [];
|
|
134
134
|
for (const row of events) {
|
|
135
135
|
const triggerTime = safeJsonParse(row.trigger_time, { year: 1, month: 1, day: 1, hour: 0, minute: 0 });
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
// Due if the trigger time has been reached -- deliberately NOT also gated
|
|
137
|
+
// on `triggerTime >= previousTime`.
|
|
138
|
+
//
|
|
139
|
+
// That lower bound looks like the right window ("did we cross it on THIS
|
|
140
|
+
// call?") and quietly broke the retry the catch block below promises. When
|
|
141
|
+
// a consequence throws, the transaction rolls back and the row stays
|
|
142
|
+
// pending, exactly as intended -- but the clock has already moved past
|
|
143
|
+
// triggerTime by then, because it is updated unconditionally above. On
|
|
144
|
+
// every subsequent call the lower bound therefore excluded the row, and
|
|
145
|
+
// the event sat pending forever with its consequence never applied. The
|
|
146
|
+
// rollback was correct and unreachable.
|
|
147
|
+
//
|
|
148
|
+
// Without the lower bound, "pending and past due" is the whole condition,
|
|
149
|
+
// which is the same retry semantics timers already have. The `triggered =
|
|
150
|
+
// 0` filter in the query above is what keeps this exactly-once: a
|
|
151
|
+
// successful event is marked (or, if recurring, rescheduled forward) in
|
|
152
|
+
// the same transaction as its consequence, so it cannot come back.
|
|
153
|
+
if (compareDateTime(triggerTime, newTime, calendarConfig) <= 0) {
|
|
139
154
|
const eventId = row.id;
|
|
140
155
|
const eventName = row.name;
|
|
141
156
|
const recurring = row.recurring;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -465,7 +465,7 @@ export interface Resource {
|
|
|
465
465
|
id: string;
|
|
466
466
|
gameId: string;
|
|
467
467
|
ownerId: string | null;
|
|
468
|
-
ownerType: "game" | "character";
|
|
468
|
+
ownerType: "game" | "character" | "faction" | "location";
|
|
469
469
|
name: string;
|
|
470
470
|
description: string;
|
|
471
471
|
category: string | null;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a path beneath a media root is composed or resolved.
|
|
3
|
+
*
|
|
4
|
+
* Media file paths are built from ids that arrive over the wire -- a game id,
|
|
5
|
+
* an entity id, an entity type -- and are then handed to `writeFileSync` and,
|
|
6
|
+
* worse, to a recursive `rmSync`. `join()` resolves `..` happily, so an id
|
|
7
|
+
* carrying one walks out of the data directory before the write. The rule this
|
|
8
|
+
* breaks is the engine's: it writes nothing to the consumer's machine that the
|
|
9
|
+
* consumer did not name.
|
|
10
|
+
*
|
|
11
|
+
* The guard is here, at the composition point, rather than at each call site,
|
|
12
|
+
* because a per-site check is one forgotten site away from being no check. Two
|
|
13
|
+
* things are asserted, and both are literal checks over characters -- never an
|
|
14
|
+
* attempt to read meaning out of a value (root CLAUDE.md hard rule 4):
|
|
15
|
+
*
|
|
16
|
+
* 1. Every path segment matches an allowlist. Every id the engine mints is a
|
|
17
|
+
* UUID and every entity type is an ASCII word, so the allowlist costs
|
|
18
|
+
* nothing the engine actually uses.
|
|
19
|
+
* 2. The resolved result is strictly beneath the resolved root -- a
|
|
20
|
+
* structural backstop that holds even for a path this module did not
|
|
21
|
+
* compose, such as one read back out of a row written before this guard
|
|
22
|
+
* existed.
|
|
23
|
+
*
|
|
24
|
+
* Rejection, never normalisation: an id containing `a/../b` is refused rather
|
|
25
|
+
* than quietly rewritten to `b`, because the rewrite would silently store one
|
|
26
|
+
* caller's media under a different id than the caller named.
|
|
27
|
+
*/
|
|
28
|
+
export declare class MediaPathError extends Error {
|
|
29
|
+
constructor(message: string);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a relative path against a media root, refusing anything that is not
|
|
33
|
+
* strictly beneath it. Use for paths read back from a row; `mediaFilePath` and
|
|
34
|
+
* `mediaDirPath` funnel through it too, so every media path in the codebase
|
|
35
|
+
* passes this check exactly once.
|
|
36
|
+
*/
|
|
37
|
+
export declare function mediaPathWithin(root: string, relativePath: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Compose the path of a media file from segments and a leaf file name,
|
|
40
|
+
* returning both the relative path to store in the row and the full path to
|
|
41
|
+
* write to.
|
|
42
|
+
*/
|
|
43
|
+
export declare function mediaFilePath(root: string, segments: string[], filename: string): {
|
|
44
|
+
relativePath: string;
|
|
45
|
+
fullPath: string;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Compose the path of a directory beneath a media root. The caller of this one
|
|
49
|
+
* is a recursive delete, so an empty segment list -- which would resolve to the
|
|
50
|
+
* media root itself -- is refused along with everything else.
|
|
51
|
+
*/
|
|
52
|
+
export declare function mediaDirPath(root: string, segments: string[]): string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { isAbsolute, join, resolve, sep } from "path";
|
|
2
|
+
import { createLogger } from "./logger.js";
|
|
3
|
+
const log = createLogger("media-path");
|
|
4
|
+
/**
|
|
5
|
+
* The one place a path beneath a media root is composed or resolved.
|
|
6
|
+
*
|
|
7
|
+
* Media file paths are built from ids that arrive over the wire -- a game id,
|
|
8
|
+
* an entity id, an entity type -- and are then handed to `writeFileSync` and,
|
|
9
|
+
* worse, to a recursive `rmSync`. `join()` resolves `..` happily, so an id
|
|
10
|
+
* carrying one walks out of the data directory before the write. The rule this
|
|
11
|
+
* breaks is the engine's: it writes nothing to the consumer's machine that the
|
|
12
|
+
* consumer did not name.
|
|
13
|
+
*
|
|
14
|
+
* The guard is here, at the composition point, rather than at each call site,
|
|
15
|
+
* because a per-site check is one forgotten site away from being no check. Two
|
|
16
|
+
* things are asserted, and both are literal checks over characters -- never an
|
|
17
|
+
* attempt to read meaning out of a value (root CLAUDE.md hard rule 4):
|
|
18
|
+
*
|
|
19
|
+
* 1. Every path segment matches an allowlist. Every id the engine mints is a
|
|
20
|
+
* UUID and every entity type is an ASCII word, so the allowlist costs
|
|
21
|
+
* nothing the engine actually uses.
|
|
22
|
+
* 2. The resolved result is strictly beneath the resolved root -- a
|
|
23
|
+
* structural backstop that holds even for a path this module did not
|
|
24
|
+
* compose, such as one read back out of a row written before this guard
|
|
25
|
+
* existed.
|
|
26
|
+
*
|
|
27
|
+
* Rejection, never normalisation: an id containing `a/../b` is refused rather
|
|
28
|
+
* than quietly rewritten to `b`, because the rewrite would silently store one
|
|
29
|
+
* caller's media under a different id than the caller named.
|
|
30
|
+
*/
|
|
31
|
+
export class MediaPathError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "MediaPathError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Directory names: ids the engine mints, plus pluralised entity types. */
|
|
38
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
39
|
+
/** Leaf file names: a safe stem, one dot, an alphanumeric extension. */
|
|
40
|
+
const SAFE_FILENAME = /^[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/;
|
|
41
|
+
function reject(what, value) {
|
|
42
|
+
log.warn("Refused a media path built from an unsafe value", { what, value });
|
|
43
|
+
throw new MediaPathError(`Unsafe ${what} for a media path: ${JSON.stringify(value)}. ` +
|
|
44
|
+
`Only letters, digits, underscore and hyphen are allowed; a path separator, ` +
|
|
45
|
+
`"." or ".." would escape the media directory.`);
|
|
46
|
+
}
|
|
47
|
+
function assertSafeSegment(value) {
|
|
48
|
+
if (typeof value !== "string" || !SAFE_SEGMENT.test(value)) {
|
|
49
|
+
reject("path segment", String(value));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a relative path against a media root, refusing anything that is not
|
|
54
|
+
* strictly beneath it. Use for paths read back from a row; `mediaFilePath` and
|
|
55
|
+
* `mediaDirPath` funnel through it too, so every media path in the codebase
|
|
56
|
+
* passes this check exactly once.
|
|
57
|
+
*/
|
|
58
|
+
export function mediaPathWithin(root, relativePath) {
|
|
59
|
+
if (typeof relativePath !== "string" || relativePath.length === 0) {
|
|
60
|
+
reject("stored path", String(relativePath));
|
|
61
|
+
}
|
|
62
|
+
if (isAbsolute(relativePath)) {
|
|
63
|
+
reject("stored path", relativePath);
|
|
64
|
+
}
|
|
65
|
+
// Split on both separators regardless of platform: a stored path composed on
|
|
66
|
+
// one and read on another must not sneak a traversal past the check.
|
|
67
|
+
for (const segment of relativePath.split(/[\\/]/)) {
|
|
68
|
+
if (segment.length === 0 || segment === "." || segment === "..") {
|
|
69
|
+
reject("stored path", relativePath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const rootResolved = resolve(root);
|
|
73
|
+
const fullPath = resolve(rootResolved, relativePath);
|
|
74
|
+
// Compare with the separator appended, so a sibling directory whose name
|
|
75
|
+
// merely starts with the root's (`<root>-elsewhere`) is not mistaken for a
|
|
76
|
+
// child of it.
|
|
77
|
+
if (!fullPath.startsWith(rootResolved + sep)) {
|
|
78
|
+
reject("stored path", relativePath);
|
|
79
|
+
}
|
|
80
|
+
return fullPath;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Compose the path of a media file from segments and a leaf file name,
|
|
84
|
+
* returning both the relative path to store in the row and the full path to
|
|
85
|
+
* write to.
|
|
86
|
+
*/
|
|
87
|
+
export function mediaFilePath(root, segments, filename) {
|
|
88
|
+
segments.forEach(assertSafeSegment);
|
|
89
|
+
if (typeof filename !== "string" || !SAFE_FILENAME.test(filename)) {
|
|
90
|
+
reject("file name", String(filename));
|
|
91
|
+
}
|
|
92
|
+
const relativePath = join(...segments, filename);
|
|
93
|
+
return { relativePath, fullPath: mediaPathWithin(root, relativePath) };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Compose the path of a directory beneath a media root. The caller of this one
|
|
97
|
+
* is a recursive delete, so an empty segment list -- which would resolve to the
|
|
98
|
+
* media root itself -- is refused along with everything else.
|
|
99
|
+
*/
|
|
100
|
+
export function mediaDirPath(root, segments) {
|
|
101
|
+
if (segments.length === 0) {
|
|
102
|
+
reject("path segment", "");
|
|
103
|
+
}
|
|
104
|
+
segments.forEach(assertSafeSegment);
|
|
105
|
+
return mediaPathWithin(root, join(...segments));
|
|
106
|
+
}
|