@_linked/server 2.24.5 → 2.24.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @\_linked/server
2
2
 
3
+ ## 2.24.7
4
+
5
+ ### Patch Changes
6
+
7
+ - [#96](https://github.com/linked-fw/server/pull/96) [`e226be6`](https://github.com/linked-fw/server/commit/e226be6625f8814cc39074a8e35a1702537c117e) Thanks [@abdipramana](https://github.com/abdipramana)! - Honor `SaveFileOptions.preservePath` in `LocalFileStore` so locally published release artifacts retain their exact manifest object keys, including case, `@`, and repeated dashes. Unsafe absolute, Windows-style, empty-segment, and traversal paths are rejected, while ordinary uploads keep their existing filename sanitisation behavior.
8
+
9
+ ## 2.24.6
10
+
11
+ ### Patch Changes
12
+
13
+ - [#161](https://github.com/linked-fw/server/pull/161) [`e3414ac`](https://github.com/linked-fw/server/commit/e3414ac3b25f4a700f9fe4369f8b226443beab85) Thanks [@flyon](https://github.com/flyon)! - Require sharp `^0.35.5`. The previous `^0.35.4` range still admitted 0.35.4, which bundles a librsvg affected by CVE-2026-96889 (GHSA-wq5f-xc86-pv6w). 0.35.5 ships libvips 8.18.7 / librsvg 2.63.2 and is the first release outside every open sharp advisory, including the libvips one (GHSA-f88m-g3jw-g9cj) and the libheif one (GHSA-rgj7-g3m4-5g8c).
14
+
3
15
  ## 2.24.5
4
16
 
5
17
  ### Patch Changes
@@ -185,7 +185,24 @@ export class LocalFileStore {
185
185
  * Characters that are not safe in a file name are still replaced, exactly as
186
186
  * `getUploadTarget` does it — only the case is left alone.
187
187
  */
188
- async resolveTarget(filePath, mimeType, suffixDuplicates) {
188
+ async resolveTarget(filePath, mimeType, suffixDuplicates, preservePath) {
189
+ if (preservePath) {
190
+ // Release object keys must match the manifest exactly, but still remain
191
+ // safe relative paths contained within this store's configured root.
192
+ if (!filePath ||
193
+ filePath.includes('\\') ||
194
+ path.posix.isAbsolute(filePath) ||
195
+ path.win32.isAbsolute(filePath) ||
196
+ path.posix.normalize(filePath) !== filePath ||
197
+ filePath.split('/').some((part) => part === '' || part === '.' || part === '..')) {
198
+ throw new Error(`Cannot preserve unsafe file-store path '${filePath}'`);
199
+ }
200
+ return {
201
+ storedPath: filePath,
202
+ // Same public URL shape as ordinary saves (accessURL is still SITE_ROOT).
203
+ publicURL: this.accessURL + publicUploadPath + '/' + filePath,
204
+ };
205
+ }
189
206
  // alphanumerics, dot, underscore, dash and the path separator survive; a
190
207
  // run of anything else becomes one dash. Case is deliberately preserved.
191
208
  //
@@ -274,7 +291,7 @@ export class LocalFileStore {
274
291
  // default to each store. This one protects an existing file, so only an
275
292
  // explicit `false` allows an overwrite.
276
293
  const suffixDuplicates = (_a = normalized.preventDuplicates) !== null && _a !== void 0 ? _a : true;
277
- const target = await this.resolveTarget(filePath, normalized.mimeType, suffixDuplicates);
294
+ const target = await this.resolveTarget(filePath, normalized.mimeType, suffixDuplicates, normalized.preservePath === true);
278
295
  // resolved against basePath, exactly like every read method does, so what
279
296
  // is written here can be read back by the same key
280
297
  const targetFilePath = await this.resolveInside(target.storedPath);
@@ -1 +1 @@
1
- {"version":3,"file":"LocalFileStore.js","sourceRoot":"","sources":["../../../../src/shapes/filestores/LocalFileStore.ts"],"names":[],"mappings":"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","sourcesContent":["import { relativeFileSystemUploadPath } from '@_linked/server-utils/utils/Upload';\nimport { publicUploadPath } from '@_linked/server-utils/utils/ServerPaths';\nimport {\n FileStat,\n IFileStore,\n SaveFileOptions,\n normalizeSaveFileOptions,\n} from '@_linked/core/interfaces/IFileStore';\nimport { createHash } from 'node:crypto';\nimport * as fsSync from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\n\nfunction isInside(base: string, target: string): boolean {\n return target === base || target.startsWith(base + path.sep);\n}\n\n/**\n * `fs.realpath` of `p`, or, when `p` does not exist (yet), the real path of its\n * nearest existing ancestor with the missing segments appended. Every symlink\n * on the existing part of the path is resolved, including a dangling one (a\n * write through it would create its target), so the result is where a read or\n * write of `p` would actually land.\n */\nasync function realpathOfNearestExisting(p: string, depth = 0): Promise<string> {\n if (depth > 40) {\n throw new Error(`Too many symbolic links resolving ${p}`);\n }\n const missing: string[] = [];\n let current = p;\n for (;;) {\n try {\n const real = await fs.realpath(current);\n return missing.length ? path.join(real, ...missing.reverse()) : real;\n } catch (err: any) {\n if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') throw err;\n }\n const link = await fs.readlink(current).catch(() => undefined);\n if (link !== undefined) {\n // a dangling symlink: follow it to where it points\n const pointsTo = path.resolve(path.dirname(current), link);\n const real = await realpathOfNearestExisting(pointsTo, depth + 1);\n return missing.length ? path.join(real, ...missing.reverse()) : real;\n }\n const parent = path.dirname(current);\n if (parent === current) return p;\n missing.push(path.basename(current));\n current = parent;\n }\n}\n\n/**\n * What `saveFile` stored, and where.\n *\n * `storedPath` is the key as it ended up in the store: it is exactly what\n * `getFile`, `fileExists`, `deleteFile` and `statFile` expect, so a caller that\n * wants to verify what it just uploaded never has to guess a name back out of a\n * URL.\n */\nexport interface SavedFileLocation {\n /** The key the file is stored under, relative to the store's base folder. */\n storedPath: string;\n /** The public URL, the same string `saveFile` returns. */\n publicURL: string;\n}\n\n/**\n * A file store is not a Shape.\n *\n * This class used to `extends Shape`, but used nothing from it: no `this.id`, no\n * `this.uri`, no `nodeShape`, no Shape statics, no property decorators, and no\n * caller that treated it as a Shape. The only inherited behaviour was `super({id})`\n * writing an id that nothing ever read.\n *\n * Datasets and stores stopped being Shapes deliberately (core `0e8c86e`,\n * \"datasets are not shapes\"), and `Shape`'s instantiation guard is currently\n * DEFERRED naming this very class as the reason. Dropping the inheritance removes\n * one of the last obstacles to re-enabling it.\n */\nexport class LocalFileStore implements IFileStore {\n private readonly basePath: string;\n\n public readonly accessURL: string;\n\n /**\n * `n` names the store. It was previously also accepted as `{id}` because that is\n * `Shape`'s constructor signature; both forms are still taken so existing callers\n * keep working, but neither is stored as a node id any more.\n */\n constructor(\n n: string | { id: string },\n basePath: string = relativeFileSystemUploadPath\n ) {\n this.accessURL = process.env.SITE_ROOT;\n this.basePath = basePath;\n }\n\n /**\n * Resolve a key against the base folder, refusing anything that ends up\n * outside it: dot segments (`../x`, `a/../../x`), and symlinks inside the\n * folder that point out of it (`link/x` where `link -> /etc`, or a file that\n * is itself such a link).\n *\n * The check runs twice. First lexically, before the file system is touched;\n * then on real paths, with `fs.realpath` applied to the base folder and to\n * the target (or, for a target that does not exist yet, its nearest existing\n * ancestor), so a symlink anywhere along the way is followed before the\n * comparison.\n *\n * Keys are joined, not resolved, so a leading `/` still means \"relative to\n * the base folder\", as it always did.\n */\n private async resolveInside(filePath: string): Promise<string> {\n const base = path.resolve(this.basePath);\n const target = path.resolve(path.join(this.basePath, String(filePath ?? '')));\n if (!isInside(base, target)) {\n throw new Error(`File path escapes the file store: ${filePath}`);\n }\n const [realBase, realTarget] = await Promise.all([\n realpathOfNearestExisting(base),\n realpathOfNearestExisting(target),\n ]);\n if (!isInside(realBase, realTarget)) {\n throw new Error(`File path escapes the file store: ${filePath}`);\n }\n return target;\n }\n\n /**\n * Delete a file from the local filesystem\n * @param filePath The path to the file to delete, relative to the base upload folder\n * @returns A promise that resolves when the file is deleted\n */\n async deleteFile(filePath: string): Promise<void> {\n const fileToDelete = await this.resolveInside(filePath);\n\n return fs.rm(fileToDelete);\n }\n\n /**\n * Check if a file exists on the local filesystem\n * @param filePath The path to the file to check, relative to the base upload folder\n * @returns A promise that resolves to true if the file exists, false otherwise\n */\n async fileExists(filePath: string): Promise<boolean> {\n const fileToCheck = await this.resolveInside(filePath);\n\n return fs\n .access(fileToCheck)\n .then(() => true)\n .catch(() => false);\n }\n\n /**\n * Get a file from the local filesystem\n * @param filePath The path to the file to get, relative to the base upload folder\n * @returns A promise that resolves to the file contents as a buffer, or null if the file does not exist\n */\n async getFile(filePath: string): Promise<Buffer | null> {\n const fileToGet = await this.resolveInside(filePath);\n\n return fs.readFile(fileToGet).catch(() => null);\n }\n\n /**\n * List every file in the store, recursively.\n *\n * @param prefix Only return keys starting with this string. Matched against\n * the returned (relative) key, so `listFiles('images/')` selects the\n * `images` folder and `listFiles('main-')` selects by file name.\n * @returns Keys relative to the base folder, exactly as `getFile`,\n * `fileExists`, `deleteFile` and `statFile` expect them.\n *\n * The keys are relative on purpose: every other method on this store resolves\n * its argument *against* `basePath`, so returning `data/uploads/x.txt` here\n * meant a caller who fed the result straight back into `getFile` looked for\n * `data/uploads/data/uploads/x.txt`. It also matches `S3FileStore`, which\n * strips the bucket prefix before returning.\n */\n async listFiles(prefix?: string): Promise<string[]> {\n const walk = async (relativeDir: string): Promise<string[]> => {\n const absoluteDir = path.join(this.basePath, relativeDir);\n\n let entries: fsSync.Dirent[];\n try {\n entries = await fs.readdir(absoluteDir, { withFileTypes: true });\n } catch (err) {\n console.warn('Error during listFiles', err);\n return [];\n }\n\n const nested = await Promise.all(\n entries.map((entry) => {\n // path.join('', 'x') === 'x', so the top level stays unprefixed\n const key = path.join(relativeDir, entry.name);\n // recurse into the subdirectory itself. This used to call\n // listFiles(prefix), which re-read basePath and so found the same\n // directory again -- unbounded recursion as soon as the store held\n // one folder.\n return entry.isDirectory() ? walk(key) : Promise.resolve([key]);\n })\n );\n\n return nested.flat();\n };\n\n const keys = await walk('');\n\n return prefix ? keys.filter((key) => key.startsWith(prefix)) : keys;\n }\n\n /**\n * Work out the key a save will use, and the URL that key is served under.\n *\n * This used to be `getUploadTarget` from `@_linked/server-utils`, which could\n * not be used here for two reasons:\n *\n * - it lowercases the name. That is HTTP-upload hygiene (browsers hand over\n * whatever the user's filesystem had, and `getUploadTarget` is still the\n * right place for it — `uploadSingleFileFromFormData` calls it before it\n * ever reaches a store), but it corrupts a key a program chose on purpose,\n * such as a Vite content hash: `main-hwqwrAvA.css`.\n * - it always resolves against `relativeFileSystemUploadPath`, ignoring this\n * store's `basePath`, so a store with a custom base folder wrote where it\n * could not read back.\n *\n * Characters that are not safe in a file name are still replaced, exactly as\n * `getUploadTarget` does it — only the case is left alone.\n */\n private async resolveTarget(\n filePath: string,\n mimeType: string | undefined,\n suffixDuplicates: boolean\n ): Promise<SavedFileLocation> {\n // alphanumerics, dot, underscore, dash and the path separator survive; a\n // run of anything else becomes one dash. Case is deliberately preserved.\n //\n // The dash has to be in that set. Without it a run of dashes collapsed to\n // one, so a Vite base64 content hash that happens to start with a dash —\n // `shapeCodeGenerator--2JmNvrO.js` — came back as\n // `shapeCodeGenerator-2JmNvrO.js`, a different key from the one the caller\n // asked for and from the one baked into the bundle.\n let sanitisedName = filePath.replace(/[^A-Za-z0-9\\._\\/-]+/g, '-');\n\n if (sanitisedName.indexOf('.') === -1) {\n //auto add an extension if none is present, based on the mimetype\n if (!mimeType) {\n throw new Error(\n 'This file does not have an extension and no mimetype was provided'\n );\n }\n sanitisedName = sanitisedName + '.' + mimeType.split('/')[1];\n }\n\n const ext = sanitisedName.split('.').pop();\n const withoutExt = sanitisedName.substring(\n 0,\n sanitisedName.length - (ext.length + 1)\n );\n\n // A 6 character random string avoids clobbering an existing file,\n // e.g. report.pdf -> report_312acb.pdf.\n //\n // It is only applied when the name is actually taken. This used to be\n // unconditional, which did prevent duplicates but also meant a caller never\n // got back the key it asked for: a content-hashed bundle handed over as\n // `main-hwqwrAvA.css` landed as `main-hwqwrAvA_x9k2ml.css`, so the name\n // baked into the HTML no longer resolved. Checking first keeps the\n // never-clobber guarantee -- a taken name still gets a suffix, and the\n // suffixed name is itself re-checked -- while leaving free names alone.\n // `S3FileStore` has always worked this way, so the two implementations of\n // IFileStore now read the flag the same.\n let storedPath = withoutExt + '.' + ext;\n\n if (suffixDuplicates) {\n // 36^6 is ~2 billion, so a second collision is already vanishingly\n // unlikely; the cap turns a pathological case into a clear error rather\n // than a spin.\n const maxAttempts = 10;\n let attempt = 0;\n\n while (await this.fileExists(storedPath)) {\n if (attempt++ >= maxAttempts) {\n throw new Error(\n `Could not find a free name for '${filePath}' after ${maxAttempts} attempts`\n );\n }\n\n storedPath =\n withoutExt + '_' + Math.random().toString(36).substring(2, 8) + '.' + ext;\n }\n }\n\n return {\n storedPath,\n publicURL: this.accessURL + publicUploadPath + '/' + storedPath,\n };\n }\n\n /**\n * Save a file to the local filesystem\n * @param filePath The path to save the file to, relative to the base folder\n * @param fileContent The contents of the file as a buffer\n * @param options Save options, or a string read as the old positional mimeType.\n * A local file has no headers to attach, so only `mimeType` (used to add a\n * missing extension) and `preventDuplicates` mean anything here;\n * `cacheControl` and `metadata` are accepted and ignored.\n * @param preventDuplicates Only honoured when `options` is a string\n * @returns A promise that resolves to the public URL of the file. Use\n * {@link saveFileWithPath} when you also need the key it was stored under.\n */\n async saveFile(\n filePath: string,\n fileContent: Buffer,\n options?: SaveFileOptions | string,\n preventDuplicates?: boolean\n ): Promise<string> {\n const { publicURL } = await this.saveFileWithPath(\n filePath,\n fileContent,\n options,\n preventDuplicates\n );\n\n return publicURL;\n }\n\n /**\n * Save a file and report both the key it was stored under and its URL.\n *\n * `saveFile` can only return one string, and `IFileStore` says that string is\n * a URL — but a caller that wants to verify what it just wrote needs the\n * *key*, which is what `statFile`, `getFile`, `fileExists` and `deleteFile`\n * take. That is what `storedPath` is: hand it straight back to any of them.\n *\n * The key equals the `filePath` given verbatim — case included — as long as\n * the name needs no sanitising and already has an extension. With\n * `preventDuplicates` left unspecified (this store's default, kept for upload\n * callers) that still holds for a name nothing else occupies; only a name\n * already taken gains a random suffix, and `storedPath` is the only place\n * that name is reported.\n */\n async saveFileWithPath(\n filePath: string,\n fileContent: Buffer,\n options?: SaveFileOptions | string,\n preventDuplicates?: boolean\n ): Promise<SavedFileLocation> {\n const normalized = normalizeSaveFileOptions(options, preventDuplicates);\n // Core reports an unspecified preventDuplicates as undefined, leaving the\n // default to each store. This one protects an existing file, so only an\n // explicit `false` allows an overwrite.\n const suffixDuplicates = normalized.preventDuplicates ?? true;\n\n const target = await this.resolveTarget(\n filePath,\n normalized.mimeType,\n suffixDuplicates\n );\n\n // resolved against basePath, exactly like every read method does, so what\n // is written here can be read back by the same key\n const targetFilePath = await this.resolveInside(target.storedPath);\n\n //make sure the target folder exists\n if (!fsSync.existsSync(path.dirname(targetFilePath))) {\n fsSync.mkdirSync(path.dirname(targetFilePath), { recursive: true });\n }\n await fs.writeFile(targetFilePath, fileContent);\n\n return target;\n }\n\n /**\n * Read back the metadata of a stored file, for verify-after-upload.\n *\n * The path is resolved exactly like saveFile/getFile/fileExists/deleteFile\n * do, against this store's base folder, so the `storedPath` that\n * saveFileWithPath reports can be handed straight back here.\n *\n * The sha256 is computed on demand from the bytes: statFile is only called\n * during publish verification, not on every upload.\n *\n * @param filePath The path to the file, relative to the base upload folder\n * @returns The size and sha256 of the file, or null if it does not exist\n */\n async statFile(filePath: string): Promise<FileStat | null> {\n const fileToStat = await this.resolveInside(filePath);\n\n let stat: fsSync.Stats;\n try {\n stat = await fs.stat(fileToStat);\n } catch (err) {\n return null;\n }\n if (!stat.isFile()) {\n return null;\n }\n\n const contents = await fs.readFile(fileToStat);\n const sha256 = createHash('sha256').update(contents).digest('hex');\n\n return { size: stat.size, sha256 };\n }\n}\n"]}
1
+ {"version":3,"file":"LocalFileStore.js","sourceRoot":"","sources":["../../../../src/shapes/filestores/LocalFileStore.ts"],"names":[],"mappings":"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","sourcesContent":["import { relativeFileSystemUploadPath } from '@_linked/server-utils/utils/Upload';\nimport { publicUploadPath } from '@_linked/server-utils/utils/ServerPaths';\nimport {\n FileStat,\n IFileStore,\n SaveFileOptions,\n normalizeSaveFileOptions,\n} from '@_linked/core/interfaces/IFileStore';\nimport { createHash } from 'node:crypto';\nimport * as fsSync from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\n\nfunction isInside(base: string, target: string): boolean {\n return target === base || target.startsWith(base + path.sep);\n}\n\n/**\n * `fs.realpath` of `p`, or, when `p` does not exist (yet), the real path of its\n * nearest existing ancestor with the missing segments appended. Every symlink\n * on the existing part of the path is resolved, including a dangling one (a\n * write through it would create its target), so the result is where a read or\n * write of `p` would actually land.\n */\nasync function realpathOfNearestExisting(p: string, depth = 0): Promise<string> {\n if (depth > 40) {\n throw new Error(`Too many symbolic links resolving ${p}`);\n }\n const missing: string[] = [];\n let current = p;\n for (;;) {\n try {\n const real = await fs.realpath(current);\n return missing.length ? path.join(real, ...missing.reverse()) : real;\n } catch (err: any) {\n if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') throw err;\n }\n const link = await fs.readlink(current).catch(() => undefined);\n if (link !== undefined) {\n // a dangling symlink: follow it to where it points\n const pointsTo = path.resolve(path.dirname(current), link);\n const real = await realpathOfNearestExisting(pointsTo, depth + 1);\n return missing.length ? path.join(real, ...missing.reverse()) : real;\n }\n const parent = path.dirname(current);\n if (parent === current) return p;\n missing.push(path.basename(current));\n current = parent;\n }\n}\n\n/**\n * What `saveFile` stored, and where.\n *\n * `storedPath` is the key as it ended up in the store: it is exactly what\n * `getFile`, `fileExists`, `deleteFile` and `statFile` expect, so a caller that\n * wants to verify what it just uploaded never has to guess a name back out of a\n * URL.\n */\nexport interface SavedFileLocation {\n /** The key the file is stored under, relative to the store's base folder. */\n storedPath: string;\n /** The public URL, the same string `saveFile` returns. */\n publicURL: string;\n}\n\n/**\n * A file store is not a Shape.\n *\n * This class used to `extends Shape`, but used nothing from it: no `this.id`, no\n * `this.uri`, no `nodeShape`, no Shape statics, no property decorators, and no\n * caller that treated it as a Shape. The only inherited behaviour was `super({id})`\n * writing an id that nothing ever read.\n *\n * Datasets and stores stopped being Shapes deliberately (core `0e8c86e`,\n * \"datasets are not shapes\"), and `Shape`'s instantiation guard is currently\n * DEFERRED naming this very class as the reason. Dropping the inheritance removes\n * one of the last obstacles to re-enabling it.\n */\nexport class LocalFileStore implements IFileStore {\n private readonly basePath: string;\n\n public readonly accessURL: string;\n\n /**\n * `n` names the store. It was previously also accepted as `{id}` because that is\n * `Shape`'s constructor signature; both forms are still taken so existing callers\n * keep working, but neither is stored as a node id any more.\n */\n constructor(\n n: string | { id: string },\n basePath: string = relativeFileSystemUploadPath\n ) {\n this.accessURL = process.env.SITE_ROOT;\n this.basePath = basePath;\n }\n\n /**\n * Resolve a key against the base folder, refusing anything that ends up\n * outside it: dot segments (`../x`, `a/../../x`), and symlinks inside the\n * folder that point out of it (`link/x` where `link -> /etc`, or a file that\n * is itself such a link).\n *\n * The check runs twice. First lexically, before the file system is touched;\n * then on real paths, with `fs.realpath` applied to the base folder and to\n * the target (or, for a target that does not exist yet, its nearest existing\n * ancestor), so a symlink anywhere along the way is followed before the\n * comparison.\n *\n * Keys are joined, not resolved, so a leading `/` still means \"relative to\n * the base folder\", as it always did.\n */\n private async resolveInside(filePath: string): Promise<string> {\n const base = path.resolve(this.basePath);\n const target = path.resolve(path.join(this.basePath, String(filePath ?? '')));\n if (!isInside(base, target)) {\n throw new Error(`File path escapes the file store: ${filePath}`);\n }\n const [realBase, realTarget] = await Promise.all([\n realpathOfNearestExisting(base),\n realpathOfNearestExisting(target),\n ]);\n if (!isInside(realBase, realTarget)) {\n throw new Error(`File path escapes the file store: ${filePath}`);\n }\n return target;\n }\n\n /**\n * Delete a file from the local filesystem\n * @param filePath The path to the file to delete, relative to the base upload folder\n * @returns A promise that resolves when the file is deleted\n */\n async deleteFile(filePath: string): Promise<void> {\n const fileToDelete = await this.resolveInside(filePath);\n\n return fs.rm(fileToDelete);\n }\n\n /**\n * Check if a file exists on the local filesystem\n * @param filePath The path to the file to check, relative to the base upload folder\n * @returns A promise that resolves to true if the file exists, false otherwise\n */\n async fileExists(filePath: string): Promise<boolean> {\n const fileToCheck = await this.resolveInside(filePath);\n\n return fs\n .access(fileToCheck)\n .then(() => true)\n .catch(() => false);\n }\n\n /**\n * Get a file from the local filesystem\n * @param filePath The path to the file to get, relative to the base upload folder\n * @returns A promise that resolves to the file contents as a buffer, or null if the file does not exist\n */\n async getFile(filePath: string): Promise<Buffer | null> {\n const fileToGet = await this.resolveInside(filePath);\n\n return fs.readFile(fileToGet).catch(() => null);\n }\n\n /**\n * List every file in the store, recursively.\n *\n * @param prefix Only return keys starting with this string. Matched against\n * the returned (relative) key, so `listFiles('images/')` selects the\n * `images` folder and `listFiles('main-')` selects by file name.\n * @returns Keys relative to the base folder, exactly as `getFile`,\n * `fileExists`, `deleteFile` and `statFile` expect them.\n *\n * The keys are relative on purpose: every other method on this store resolves\n * its argument *against* `basePath`, so returning `data/uploads/x.txt` here\n * meant a caller who fed the result straight back into `getFile` looked for\n * `data/uploads/data/uploads/x.txt`. It also matches `S3FileStore`, which\n * strips the bucket prefix before returning.\n */\n async listFiles(prefix?: string): Promise<string[]> {\n const walk = async (relativeDir: string): Promise<string[]> => {\n const absoluteDir = path.join(this.basePath, relativeDir);\n\n let entries: fsSync.Dirent[];\n try {\n entries = await fs.readdir(absoluteDir, { withFileTypes: true });\n } catch (err) {\n console.warn('Error during listFiles', err);\n return [];\n }\n\n const nested = await Promise.all(\n entries.map((entry) => {\n // path.join('', 'x') === 'x', so the top level stays unprefixed\n const key = path.join(relativeDir, entry.name);\n // recurse into the subdirectory itself. This used to call\n // listFiles(prefix), which re-read basePath and so found the same\n // directory again -- unbounded recursion as soon as the store held\n // one folder.\n return entry.isDirectory() ? walk(key) : Promise.resolve([key]);\n })\n );\n\n return nested.flat();\n };\n\n const keys = await walk('');\n\n return prefix ? keys.filter((key) => key.startsWith(prefix)) : keys;\n }\n\n /**\n * Work out the key a save will use, and the URL that key is served under.\n *\n * This used to be `getUploadTarget` from `@_linked/server-utils`, which could\n * not be used here for two reasons:\n *\n * - it lowercases the name. That is HTTP-upload hygiene (browsers hand over\n * whatever the user's filesystem had, and `getUploadTarget` is still the\n * right place for it — `uploadSingleFileFromFormData` calls it before it\n * ever reaches a store), but it corrupts a key a program chose on purpose,\n * such as a Vite content hash: `main-hwqwrAvA.css`.\n * - it always resolves against `relativeFileSystemUploadPath`, ignoring this\n * store's `basePath`, so a store with a custom base folder wrote where it\n * could not read back.\n *\n * Characters that are not safe in a file name are still replaced, exactly as\n * `getUploadTarget` does it — only the case is left alone.\n */\n private async resolveTarget(\n filePath: string,\n mimeType: string | undefined,\n suffixDuplicates: boolean,\n preservePath: boolean\n ): Promise<SavedFileLocation> {\n if (preservePath) {\n // Release object keys must match the manifest exactly, but still remain\n // safe relative paths contained within this store's configured root.\n if (\n !filePath ||\n filePath.includes('\\\\') ||\n path.posix.isAbsolute(filePath) ||\n path.win32.isAbsolute(filePath) ||\n path.posix.normalize(filePath) !== filePath ||\n filePath.split('/').some((part) => part === '' || part === '.' || part === '..')\n ) {\n throw new Error(`Cannot preserve unsafe file-store path '${filePath}'`);\n }\n\n return {\n storedPath: filePath,\n // Same public URL shape as ordinary saves (accessURL is still SITE_ROOT).\n publicURL: this.accessURL + publicUploadPath + '/' + filePath,\n };\n }\n\n // alphanumerics, dot, underscore, dash and the path separator survive; a\n // run of anything else becomes one dash. Case is deliberately preserved.\n //\n // The dash has to be in that set. Without it a run of dashes collapsed to\n // one, so a Vite base64 content hash that happens to start with a dash —\n // `shapeCodeGenerator--2JmNvrO.js` — came back as\n // `shapeCodeGenerator-2JmNvrO.js`, a different key from the one the caller\n // asked for and from the one baked into the bundle.\n let sanitisedName = filePath.replace(/[^A-Za-z0-9\\._\\/-]+/g, '-');\n\n if (sanitisedName.indexOf('.') === -1) {\n //auto add an extension if none is present, based on the mimetype\n if (!mimeType) {\n throw new Error(\n 'This file does not have an extension and no mimetype was provided'\n );\n }\n sanitisedName = sanitisedName + '.' + mimeType.split('/')[1];\n }\n\n const ext = sanitisedName.split('.').pop();\n const withoutExt = sanitisedName.substring(\n 0,\n sanitisedName.length - (ext.length + 1)\n );\n\n // A 6 character random string avoids clobbering an existing file,\n // e.g. report.pdf -> report_312acb.pdf.\n //\n // It is only applied when the name is actually taken. This used to be\n // unconditional, which did prevent duplicates but also meant a caller never\n // got back the key it asked for: a content-hashed bundle handed over as\n // `main-hwqwrAvA.css` landed as `main-hwqwrAvA_x9k2ml.css`, so the name\n // baked into the HTML no longer resolved. Checking first keeps the\n // never-clobber guarantee -- a taken name still gets a suffix, and the\n // suffixed name is itself re-checked -- while leaving free names alone.\n // `S3FileStore` has always worked this way, so the two implementations of\n // IFileStore now read the flag the same.\n let storedPath = withoutExt + '.' + ext;\n\n if (suffixDuplicates) {\n // 36^6 is ~2 billion, so a second collision is already vanishingly\n // unlikely; the cap turns a pathological case into a clear error rather\n // than a spin.\n const maxAttempts = 10;\n let attempt = 0;\n\n while (await this.fileExists(storedPath)) {\n if (attempt++ >= maxAttempts) {\n throw new Error(\n `Could not find a free name for '${filePath}' after ${maxAttempts} attempts`\n );\n }\n\n storedPath =\n withoutExt + '_' + Math.random().toString(36).substring(2, 8) + '.' + ext;\n }\n }\n\n return {\n storedPath,\n publicURL: this.accessURL + publicUploadPath + '/' + storedPath,\n };\n }\n\n /**\n * Save a file to the local filesystem\n * @param filePath The path to save the file to, relative to the base folder\n * @param fileContent The contents of the file as a buffer\n * @param options Save options, or a string read as the old positional mimeType.\n * A local file has no headers to attach, so only `mimeType` (used to add a\n * missing extension) and `preventDuplicates` mean anything here;\n * `cacheControl` and `metadata` are accepted and ignored.\n * @param preventDuplicates Only honoured when `options` is a string\n * @returns A promise that resolves to the public URL of the file. Use\n * {@link saveFileWithPath} when you also need the key it was stored under.\n */\n async saveFile(\n filePath: string,\n fileContent: Buffer,\n options?: SaveFileOptions | string,\n preventDuplicates?: boolean\n ): Promise<string> {\n const { publicURL } = await this.saveFileWithPath(\n filePath,\n fileContent,\n options,\n preventDuplicates\n );\n\n return publicURL;\n }\n\n /**\n * Save a file and report both the key it was stored under and its URL.\n *\n * `saveFile` can only return one string, and `IFileStore` says that string is\n * a URL — but a caller that wants to verify what it just wrote needs the\n * *key*, which is what `statFile`, `getFile`, `fileExists` and `deleteFile`\n * take. That is what `storedPath` is: hand it straight back to any of them.\n *\n * The key equals the `filePath` given verbatim — case included — as long as\n * the name needs no sanitising and already has an extension. With\n * `preventDuplicates` left unspecified (this store's default, kept for upload\n * callers) that still holds for a name nothing else occupies; only a name\n * already taken gains a random suffix, and `storedPath` is the only place\n * that name is reported.\n */\n async saveFileWithPath(\n filePath: string,\n fileContent: Buffer,\n options?: SaveFileOptions | string,\n preventDuplicates?: boolean\n ): Promise<SavedFileLocation> {\n const normalized = normalizeSaveFileOptions(options, preventDuplicates);\n // Core reports an unspecified preventDuplicates as undefined, leaving the\n // default to each store. This one protects an existing file, so only an\n // explicit `false` allows an overwrite.\n const suffixDuplicates = normalized.preventDuplicates ?? true;\n\n const target = await this.resolveTarget(\n filePath,\n normalized.mimeType,\n suffixDuplicates,\n normalized.preservePath === true\n );\n\n // resolved against basePath, exactly like every read method does, so what\n // is written here can be read back by the same key\n const targetFilePath = await this.resolveInside(target.storedPath);\n\n //make sure the target folder exists\n if (!fsSync.existsSync(path.dirname(targetFilePath))) {\n fsSync.mkdirSync(path.dirname(targetFilePath), { recursive: true });\n }\n await fs.writeFile(targetFilePath, fileContent);\n\n return target;\n }\n\n /**\n * Read back the metadata of a stored file, for verify-after-upload.\n *\n * The path is resolved exactly like saveFile/getFile/fileExists/deleteFile\n * do, against this store's base folder, so the `storedPath` that\n * saveFileWithPath reports can be handed straight back here.\n *\n * The sha256 is computed on demand from the bytes: statFile is only called\n * during publish verification, not on every upload.\n *\n * @param filePath The path to the file, relative to the base upload folder\n * @returns The size and sha256 of the file, or null if it does not exist\n */\n async statFile(filePath: string): Promise<FileStat | null> {\n const fileToStat = await this.resolveInside(filePath);\n\n let stat: fsSync.Stats;\n try {\n stat = await fs.stat(fileToStat);\n } catch (err) {\n return null;\n }\n if (!stat.isFile()) {\n return null;\n }\n\n const contents = await fs.readFile(fileToStat);\n const sha256 = createHash('sha256').update(contents).digest('hex');\n\n return { size: stat.size, sha256 };\n }\n}\n"]}
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/linked-fw/server.git"
12
12
  },
13
- "version": "2.24.5",
13
+ "version": "2.24.7",
14
14
  "packageManager": "npm@11.21.0",
15
15
  "linkedPackage": true,
16
16
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "@_linked/cli": "^1.2",
44
- "@_linked/core": "^2.22.8",
44
+ "@_linked/core": "^2.28.0",
45
45
  "@_linked/server-utils": "^1.12.0",
46
46
  "autoprefixer": "^10.4.18",
47
47
  "compression": "^1.7.4",
@@ -53,7 +53,7 @@
53
53
  "proper-lockfile": "^4.1.2",
54
54
  "react-router-dom": "^6.22.3",
55
55
  "rimraf": "^5.0.5",
56
- "sharp": "^0.35.4",
56
+ "sharp": "^0.35.5",
57
57
  "tough-cookie": "^6.0.0",
58
58
  "tsconfig-to-dual-package": "^1.2.0"
59
59
  },
package/readme.md CHANGED
@@ -456,10 +456,11 @@ await store.saveFile('report.pdf', fileBuffer, {
456
456
  });
457
457
  ```
458
458
 
459
- A local file has no headers to attach, so only two of the options do anything here:
459
+ A local file has no headers to attach, so three of the options affect local storage:
460
460
 
461
461
  - `mimeType` — used to add a missing file extension.
462
462
  - `preventDuplicates` — see below.
463
+ - `preservePath` — stores an already-generated relative object key exactly as supplied.
463
464
  - `cacheControl` and `metadata` are accepted (so the same call works against an S3-backed store) and ignored.
464
465
 
465
466
  The old positional form still works: a plain string third argument is read as the mime type, with `preventDuplicates`
@@ -469,7 +470,27 @@ following it.
469
470
  await store.saveFile('report.pdf', fileBuffer, 'application/pdf', true);
470
471
  ```
471
472
 
472
- ##### The key you pass is the key that is stored
473
+ ##### Preserve generated object keys exactly
474
+
475
+ Release manifests and generated bundles refer to exact object keys. Set `preservePath: true` when those keys must not
476
+ be sanitised, lowercased, or renamed:
477
+
478
+ ```ts
479
+ await store.saveFile(
480
+ 'releases/1.2.3/public/images/logo@2x.png',
481
+ fileBuffer,
482
+ { preservePath: true, preventDuplicates: false }
483
+ );
484
+ ```
485
+
486
+ Preserved paths must be safe relative POSIX paths. Absolute paths, Windows paths, backslashes, empty segments, `.` and
487
+ `..` segments, and paths that change under normalisation are rejected. This prevents an exact-key save from escaping
488
+ the store's configured root.
489
+
490
+ Without `preservePath`, ordinary upload behaviour remains unchanged: unsafe filename characters are replaced with a
491
+ dash and a missing extension is added from `mimeType`.
492
+
493
+ ##### Ordinary keys retain safe characters and case
473
494
 
474
495
  Characters that are unsafe in a file name are still replaced with a dash, and a missing extension is still added from
475
496
  `mimeType`, but the **case of the name is preserved**. A build asset named `main-hwqwrAvA.css` is stored under exactly
@@ -492,9 +513,10 @@ const { storedPath, publicURL } = await store.saveFileWithPath(
492
513
  const stat = await store.statFile(storedPath); // always resolves
493
514
  ```
494
515
 
495
- With `preventDuplicates: false`, `storedPath` equals the path you passed, verbatim, as long as it needs no sanitising
496
- and already has an extension. With `preventDuplicates` left unspecified the random suffix is added and `storedPath` is
497
- the only place the resulting name is reported.
516
+ With `preservePath: true`, `storedPath` equals the validated relative path exactly. Without it,
517
+ `preventDuplicates: false` keeps the path verbatim only when it needs no sanitising and already has an extension. With
518
+ `preventDuplicates` left unspecified the random suffix is added and `storedPath` is the only place the resulting name
519
+ is reported.
498
520
 
499
521
  ##### LocalFileStore suffixes by default
500
522