@orthacms/media-provider-local 0.0.0-reserve.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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/lib/local-storage-provider.d.ts +38 -0
- package/dist/lib/local-storage-provider.d.ts.map +1 -0
- package/dist/lib/local-storage-provider.js +223 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ortha CMS contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,0BAA0B,EAC1B,0BAA0B,EAC7B,MAAM,8BAA8B,CAAC;AACtC,YAAY,EAAE,kBAAkB,EAAE,MAAM,8BAA8B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StorageKeyOutsideRootError = exports.createLocalStorageProvider = void 0;
|
|
4
|
+
var local_storage_provider_1 = require("./lib/local-storage-provider");
|
|
5
|
+
Object.defineProperty(exports, "createLocalStorageProvider", { enumerable: true, get: function () { return local_storage_provider_1.createLocalStorageProvider; } });
|
|
6
|
+
Object.defineProperty(exports, "StorageKeyOutsideRootError", { enumerable: true, get: function () { return local_storage_provider_1.StorageKeyOutsideRootError; } });
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { StorageProvider } from '@orthacms/media-server';
|
|
2
|
+
/** Local filesystem provider settings. */
|
|
3
|
+
export interface LocalStorageConfig {
|
|
4
|
+
/** Directory blobs live under (absolute or project-relative). */
|
|
5
|
+
rootDir: string;
|
|
6
|
+
/** Base path the browser hits to stream a blob (reserved for direct URLs). */
|
|
7
|
+
publicBasePath: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Raised when a storage key resolves outside the provider's `rootDir`.
|
|
11
|
+
*
|
|
12
|
+
* Keys are provider-owned and every key this provider *mints* is contained by
|
|
13
|
+
* construction — but `get`/`remove` are handed keys read back from
|
|
14
|
+
* `media_asset.storage_key`, which is only as trustworthy as everything that
|
|
15
|
+
* can write that column (a migration, an import script, a future provider that
|
|
16
|
+
* mints structured keys). Without this check `remove('../../x')` deletes an
|
|
17
|
+
* arbitrary file the server user can reach; with it, the traversal is a
|
|
18
|
+
* rejection instead of an action.
|
|
19
|
+
*/
|
|
20
|
+
export declare class StorageKeyOutsideRootError extends Error {
|
|
21
|
+
readonly storageKey: string;
|
|
22
|
+
constructor(storageKey: string);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The default {@link StorageProvider}: streams blobs to a directory on disk,
|
|
26
|
+
* keyed `<workspaceId>/<assetId>/<filename>` so they stay workspace-partitioned
|
|
27
|
+
* and collision-free. Computes size + sha256 as it writes. Bound at the
|
|
28
|
+
* composition root; the media core depends only on the `StorageProvider` port.
|
|
29
|
+
*
|
|
30
|
+
* `put` is **all-or-nothing**, as the port requires: bytes go to a temporary
|
|
31
|
+
* file and are moved into place with a single `rename` only once the whole body
|
|
32
|
+
* has been written. A body that errors mid-stream, a full disk, or a failed
|
|
33
|
+
* rename therefore leave nothing behind — which matters because a rejected
|
|
34
|
+
* `put` never handed its key back, so anything it left would be garbage no
|
|
35
|
+
* caller could ever name, let alone reclaim.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createLocalStorageProvider(config: LocalStorageConfig): StorageProvider;
|
|
38
|
+
//# sourceMappingURL=local-storage-provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-storage-provider.d.ts","sourceRoot":"","sources":["../../src/lib/local-storage-provider.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAER,eAAe,EAElB,MAAM,wBAAwB,CAAC;AAEhC,0CAA0C;AAC1C,MAAM,WAAW,kBAAkB;IAC/B,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;IAChB,8EAA8E;IAC9E,cAAc,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;GAUG;AACH,qBAAa,0BAA2B,SAAQ,KAAK;IACrC,QAAQ,CAAC,UAAU,EAAE,MAAM;gBAAlB,UAAU,EAAE,MAAM;CAI1C;AAgFD;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,CACtC,MAAM,EAAE,kBAAkB,GAC3B,eAAe,CA2IjB"}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StorageKeyOutsideRootError = void 0;
|
|
4
|
+
exports.createLocalStorageProvider = createLocalStorageProvider;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const promises_1 = require("node:fs/promises");
|
|
8
|
+
const node_path_1 = require("node:path");
|
|
9
|
+
const node_stream_1 = require("node:stream");
|
|
10
|
+
const promises_2 = require("node:stream/promises");
|
|
11
|
+
/**
|
|
12
|
+
* Raised when a storage key resolves outside the provider's `rootDir`.
|
|
13
|
+
*
|
|
14
|
+
* Keys are provider-owned and every key this provider *mints* is contained by
|
|
15
|
+
* construction — but `get`/`remove` are handed keys read back from
|
|
16
|
+
* `media_asset.storage_key`, which is only as trustworthy as everything that
|
|
17
|
+
* can write that column (a migration, an import script, a future provider that
|
|
18
|
+
* mints structured keys). Without this check `remove('../../x')` deletes an
|
|
19
|
+
* arbitrary file the server user can reach; with it, the traversal is a
|
|
20
|
+
* rejection instead of an action.
|
|
21
|
+
*/
|
|
22
|
+
class StorageKeyOutsideRootError extends Error {
|
|
23
|
+
storageKey;
|
|
24
|
+
constructor(storageKey) {
|
|
25
|
+
super(`Storage key resolves outside the storage root: ${storageKey}`);
|
|
26
|
+
this.storageKey = storageKey;
|
|
27
|
+
this.name = 'StorageKeyOutsideRootError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.StorageKeyOutsideRootError = StorageKeyOutsideRootError;
|
|
31
|
+
/**
|
|
32
|
+
* Longest single path component this provider will produce.
|
|
33
|
+
*
|
|
34
|
+
* 255 bytes is the per-component limit on ext4/APFS/NTFS alike. Exceeding it is
|
|
35
|
+
* an `ENAMETOOLONG` from `fs.open` — a raw node error the caller can only turn
|
|
36
|
+
* into a 500 — so the provider bounds the segment itself rather than letting a
|
|
37
|
+
* long name reach the syscall. Truncation (not rejection) is the right shape
|
|
38
|
+
* here because the key is opaque and provider-owned: the truncated key is what
|
|
39
|
+
* `put` returns and what the caller persists, so the round-trip still matches.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_SEGMENT_LENGTH = 255;
|
|
42
|
+
/** Longest extension worth preserving when a name has to be truncated. */
|
|
43
|
+
const MAX_PRESERVED_EXTENSION = 16;
|
|
44
|
+
/**
|
|
45
|
+
* Reduces a file name to a safe key segment.
|
|
46
|
+
*
|
|
47
|
+
* Three rules, in order:
|
|
48
|
+
*
|
|
49
|
+
* 1. Every run outside `[A-Za-z0-9_.-]` collapses to a single `_`, so no
|
|
50
|
+
* separator, control character or NUL can reach the path.
|
|
51
|
+
* 2. `.`, `..` and the empty string (all three reachable once rule 1 has eaten
|
|
52
|
+
* the rest) are **path tokens, not names**: `join` normalises `W/A/..` to
|
|
53
|
+
* `W`, so the write lands on the workspace directory itself rather than
|
|
54
|
+
* inside the asset — creating a *file* named `W` where every other asset in
|
|
55
|
+
* that workspace needs a *directory*. Prefixing with `_` keeps it an
|
|
56
|
+
* ordinary leaf name. Three dots or more is just an odd filename and is
|
|
57
|
+
* left alone.
|
|
58
|
+
* 3. The result is bounded by {@link MAX_SEGMENT_LENGTH}, keeping the extension
|
|
59
|
+
* when there is a plausible one.
|
|
60
|
+
*
|
|
61
|
+
* After rule 1 the segment is ASCII-only (`\w` is ASCII in a non-unicode
|
|
62
|
+
* regex), so character length and byte length are the same and rule 3 can slice
|
|
63
|
+
* without splitting a code point.
|
|
64
|
+
*/
|
|
65
|
+
function sanitize(name) {
|
|
66
|
+
const collapsed = name.replace(/[^\w.-]+/g, '_');
|
|
67
|
+
const leaf = collapsed === '' || collapsed === '.' || collapsed === '..'
|
|
68
|
+
? `_${collapsed}`
|
|
69
|
+
: collapsed;
|
|
70
|
+
if (leaf.length <= MAX_SEGMENT_LENGTH)
|
|
71
|
+
return leaf;
|
|
72
|
+
const dot = leaf.lastIndexOf('.');
|
|
73
|
+
const extension = dot > 0 && leaf.length - dot <= MAX_PRESERVED_EXTENSION
|
|
74
|
+
? leaf.slice(dot)
|
|
75
|
+
: '';
|
|
76
|
+
const stem = dot > 0 ? leaf.slice(0, dot) : leaf;
|
|
77
|
+
return stem.slice(0, MAX_SEGMENT_LENGTH - extension.length) + extension;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Removes `from` and each parent up to (but never including) `root`, stopping
|
|
81
|
+
* at the first directory that is not empty.
|
|
82
|
+
*
|
|
83
|
+
* Nothing else ever reclaims `<workspaceId>/<assetId>/`, so without this a
|
|
84
|
+
* workspace that uploads and deletes a million files keeps a million empty
|
|
85
|
+
* directories and their inodes forever. Best-effort by design: any error
|
|
86
|
+
* (`ENOTEMPTY`, `ENOENT`, `EACCES`, a concurrent writer) just ends the walk,
|
|
87
|
+
* because `remove` must stay idempotent and must not fail over housekeeping.
|
|
88
|
+
*
|
|
89
|
+
* The walk only ever moves *upwards* from the removed file's own directory, so
|
|
90
|
+
* it can never descend into a directory a concurrent `put` has just created.
|
|
91
|
+
*/
|
|
92
|
+
async function pruneEmptyDirectories(from, root) {
|
|
93
|
+
let directory = from;
|
|
94
|
+
while (directory !== root && directory.startsWith(root + node_path_1.sep)) {
|
|
95
|
+
try {
|
|
96
|
+
await (0, promises_1.rmdir)(directory);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
directory = (0, node_path_1.dirname)(directory);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The default {@link StorageProvider}: streams blobs to a directory on disk,
|
|
106
|
+
* keyed `<workspaceId>/<assetId>/<filename>` so they stay workspace-partitioned
|
|
107
|
+
* and collision-free. Computes size + sha256 as it writes. Bound at the
|
|
108
|
+
* composition root; the media core depends only on the `StorageProvider` port.
|
|
109
|
+
*
|
|
110
|
+
* `put` is **all-or-nothing**, as the port requires: bytes go to a temporary
|
|
111
|
+
* file and are moved into place with a single `rename` only once the whole body
|
|
112
|
+
* has been written. A body that errors mid-stream, a full disk, or a failed
|
|
113
|
+
* rename therefore leave nothing behind — which matters because a rejected
|
|
114
|
+
* `put` never handed its key back, so anything it left would be garbage no
|
|
115
|
+
* caller could ever name, let alone reclaim.
|
|
116
|
+
*/
|
|
117
|
+
function createLocalStorageProvider(config) {
|
|
118
|
+
// Pinned once, at construction. `rootDir` defaults to a *relative* path, so
|
|
119
|
+
// resolving it per call would silently re-home the whole store the moment
|
|
120
|
+
// anything called `process.chdir()` — the same key would then name two
|
|
121
|
+
// different files depending on when it was used.
|
|
122
|
+
const root = (0, node_path_1.resolve)(config.rootDir);
|
|
123
|
+
const keyFor = (workspaceId, assetId, fileName, isVariant) => {
|
|
124
|
+
// The ids are minted server-side (`AssetId.generate()` and the current
|
|
125
|
+
// workspace) and are uuids today, which `sanitize` leaves untouched.
|
|
126
|
+
// They are sanitized anyway so that a key this provider mints is
|
|
127
|
+
// contained by construction, whatever the core hands it — the
|
|
128
|
+
// containment check below is the backstop, not the only line.
|
|
129
|
+
const segments = [sanitize(workspaceId), sanitize(assetId)];
|
|
130
|
+
if (isVariant) {
|
|
131
|
+
// Derivatives live in a reserved `variants/` sub-namespace, so
|
|
132
|
+
// they can never collide with the original blob's key.
|
|
133
|
+
segments.push('variants');
|
|
134
|
+
}
|
|
135
|
+
segments.push(sanitize(fileName));
|
|
136
|
+
return segments.join('/');
|
|
137
|
+
};
|
|
138
|
+
const absolute = (storageKey) => {
|
|
139
|
+
// `resolve`, not `join`: `join(root, '/etc/passwd')` quietly rebases an
|
|
140
|
+
// absolute key under the root and hides it from a naive prefix check,
|
|
141
|
+
// whereas `resolve` returns `/etc/passwd` and the check below rejects.
|
|
142
|
+
const target = (0, node_path_1.resolve)(root, storageKey);
|
|
143
|
+
const inside = (0, node_path_1.relative)(root, target);
|
|
144
|
+
if (inside === '' ||
|
|
145
|
+
inside === '..' ||
|
|
146
|
+
inside.startsWith(`..${node_path_1.sep}`) ||
|
|
147
|
+
(0, node_path_1.isAbsolute)(inside)) {
|
|
148
|
+
throw new StorageKeyOutsideRootError(storageKey);
|
|
149
|
+
}
|
|
150
|
+
return target;
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
async put(object) {
|
|
154
|
+
const storageKey = keyFor(object.workspaceId, object.assetId, object.fileName, object.isVariant ?? false);
|
|
155
|
+
const target = absolute(storageKey);
|
|
156
|
+
const directory = (0, node_path_1.dirname)(target);
|
|
157
|
+
await (0, promises_1.mkdir)(directory, { recursive: true });
|
|
158
|
+
// A fixed-length name of its own rather than one derived from the
|
|
159
|
+
// target, so the temporary file can never be the thing that busts
|
|
160
|
+
// the 255-byte component limit. The leading dot and `.part` suffix
|
|
161
|
+
// make a leftover self-evidently machine droppings.
|
|
162
|
+
const temporary = (0, node_path_1.join)(directory, `.${(0, node_crypto_1.randomBytes)(12).toString('hex')}.part`);
|
|
163
|
+
const hash = (0, node_crypto_1.createHash)('sha256');
|
|
164
|
+
let size = 0;
|
|
165
|
+
const meter = new node_stream_1.PassThrough();
|
|
166
|
+
meter.on('data', (chunk) => {
|
|
167
|
+
size += chunk.length;
|
|
168
|
+
hash.update(chunk);
|
|
169
|
+
});
|
|
170
|
+
try {
|
|
171
|
+
await (0, promises_2.pipeline)(object.body, meter, (0, node_fs_1.createWriteStream)(temporary));
|
|
172
|
+
// Atomic within a filesystem: the key either names the complete
|
|
173
|
+
// object or names nothing. It also makes two concurrent `put`s
|
|
174
|
+
// on one key resolve to one *intact* body rather than a mix.
|
|
175
|
+
await (0, promises_1.rename)(temporary, target);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
await (0, promises_1.rm)(temporary, { force: true }).catch(() => undefined);
|
|
179
|
+
// The directory chain was created for a write that never
|
|
180
|
+
// landed; leave no trace of it either.
|
|
181
|
+
await pruneEmptyDirectories(directory, root);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
return { storageKey, size, checksum: hash.digest('hex') };
|
|
185
|
+
},
|
|
186
|
+
async get(storageKey) {
|
|
187
|
+
// The port says `get` rejects when the key is gone.
|
|
188
|
+
// `createReadStream` cannot honour that: it opens lazily, so a
|
|
189
|
+
// missing blob resolved fine and then emitted `ENOENT` when the
|
|
190
|
+
// response was already a streaming 200 that could no longer become
|
|
191
|
+
// a 404. Opening the descriptor here moves the failure back in
|
|
192
|
+
// front of the response.
|
|
193
|
+
const handle = await (0, promises_1.open)(absolute(storageKey), 'r');
|
|
194
|
+
const stats = await handle.stat();
|
|
195
|
+
if (!stats.isFile()) {
|
|
196
|
+
await handle.close();
|
|
197
|
+
throw Object.assign(new Error(`EISDIR: storage key is not a file: ${storageKey}`), { code: 'EISDIR' });
|
|
198
|
+
}
|
|
199
|
+
// `autoClose` defaults to *false* for a FileHandle stream, which
|
|
200
|
+
// would leak the descriptor on every download.
|
|
201
|
+
return handle.createReadStream({ autoClose: true });
|
|
202
|
+
},
|
|
203
|
+
async remove(storageKey) {
|
|
204
|
+
const target = absolute(storageKey);
|
|
205
|
+
await (0, promises_1.rm)(target, { force: true });
|
|
206
|
+
await pruneEmptyDirectories((0, node_path_1.dirname)(target), root);
|
|
207
|
+
},
|
|
208
|
+
async url(storageKey) {
|
|
209
|
+
// NOTE: no route serves this path today — downloads go through
|
|
210
|
+
// `GET /api/media/assets/:id/raw`, which resolves the key from the
|
|
211
|
+
// asset row *after* checking workspace membership. Nothing calls
|
|
212
|
+
// `url()`; it exists because the port promises a direct URL that a
|
|
213
|
+
// signed-URL backend (S3) can give and a filesystem cannot.
|
|
214
|
+
//
|
|
215
|
+
// Whoever implements the static-serving mode: it must not be an
|
|
216
|
+
// `express.static` over `rootDir`. That would serve every blob to
|
|
217
|
+
// anyone who can guess a key and would bypass the membership check
|
|
218
|
+
// wholesale — the keys are uuids, but they are also handed out in
|
|
219
|
+
// API responses.
|
|
220
|
+
return `${config.publicBasePath}/blob/${encodeURIComponent(storageKey)}`;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@orthacms/media-provider-local",
|
|
3
|
+
"version": "0.0.0-reserve.0",
|
|
4
|
+
"description": "@orthacms/media-provider-local — part of Ortha CMS.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/media/provider-local",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ortha-source/ortha-cms.git",
|
|
10
|
+
"directory": "packages/media/provider-local"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ortha-source/ortha-cms/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@orthacms/media-server": "^0.0.1",
|
|
29
|
+
"tslib": "^2.3.0"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
}
|
|
34
|
+
}
|