@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 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
@@ -0,0 +1,7 @@
1
+ # @orthacms/media-provider-local
2
+
3
+ Part of [Ortha CMS](https://github.com/ortha-source/ortha-cms).
4
+
5
+ ```sh
6
+ npm install @orthacms/media-provider-local
7
+ ```
@@ -0,0 +1,3 @@
1
+ export { createLocalStorageProvider, StorageKeyOutsideRootError } from './lib/local-storage-provider';
2
+ export type { LocalStorageConfig } from './lib/local-storage-provider';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -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
+ }