@nage-api/storage 1.0.0-beta.2

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.
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ /**
3
+ * The storage service application code uses (PLAN.md §8, §12).
4
+ *
5
+ * Every write goes through the same three steps in the same order: validate the
6
+ * bytes, build a safe key, then store. There is no method that skips a step,
7
+ * because the one call site that skipped validation is where the stored XSS
8
+ * came from.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.StorageService = void 0;
12
+ const node_crypto_1 = require("node:crypto");
13
+ const core_1 = require("@nage-api/core");
14
+ const keys_js_1 = require("./keys.js");
15
+ const validation_js_1 = require("./validation.js");
16
+ class StorageService {
17
+ #driver;
18
+ #policy;
19
+ #publicBaseUrl;
20
+ #fetcher;
21
+ #clock;
22
+ constructor(options) {
23
+ this.#driver = options.driver;
24
+ this.#policy = options.policy ?? (0, validation_js_1.defaultUploadPolicy)();
25
+ this.#publicBaseUrl = options.publicBaseUrl;
26
+ this.#fetcher = options.fetcher;
27
+ this.#clock = options.clock ?? { now: () => Date.now() };
28
+ }
29
+ /**
30
+ * Validate and store an upload.
31
+ *
32
+ * The stored extension comes from the **verified** content type, not from the
33
+ * client's filename — so a `.php` named `image/png` is stored as `.png` even
34
+ * if every other check somehow passed.
35
+ */
36
+ async upload(input) {
37
+ const contentType = (0, validation_js_1.validateUpload)(input, this.#policy);
38
+ const key = (0, keys_js_1.buildStorageKey)({
39
+ filename: input.filename,
40
+ ...(input.directory === undefined ? {} : { directory: input.directory }),
41
+ extension: (0, keys_js_1.extensionForMimeType)(contentType),
42
+ });
43
+ await this.#driver.put(key, input.content, contentType);
44
+ const url = this.url(key);
45
+ return {
46
+ key,
47
+ size: input.content.length,
48
+ contentType,
49
+ checksum: (0, node_crypto_1.createHash)('sha256').update(input.content).digest('hex'),
50
+ uploadedAt: this.#clock.now(),
51
+ ...(url === undefined ? {} : { url }),
52
+ };
53
+ }
54
+ /**
55
+ * Fetch a URL and store what comes back.
56
+ *
57
+ * The fetch is SSRF-checked and the bytes are validated exactly as an upload
58
+ * is: a file arriving over HTTP is no more trustworthy than one arriving in a
59
+ * form.
60
+ */
61
+ async uploadFromUrl(url, options = {}) {
62
+ if (this.#fetcher === undefined) {
63
+ throw new core_1.NotFoundError({
64
+ detail: 'Fetching from a URL needs a RemoteFetcher; pass one to StorageService',
65
+ });
66
+ }
67
+ const fetched = await this.#fetcher.fetch(url);
68
+ return this.upload({
69
+ filename: options.filename ?? filenameFromUrl(fetched.finalUrl),
70
+ content: fetched.content,
71
+ ...(fetched.contentType === undefined ? {} : { contentType: fetched.contentType }),
72
+ ...(options.directory === undefined ? {} : { directory: options.directory }),
73
+ });
74
+ }
75
+ async download(key) {
76
+ (0, keys_js_1.assertSafeKey)(key);
77
+ const content = await this.#driver.get(key);
78
+ if (content === undefined) {
79
+ throw new core_1.NotFoundError({ detail: `No stored file with key ${key}`, meta: { key } });
80
+ }
81
+ return content;
82
+ }
83
+ async exists(key) {
84
+ (0, keys_js_1.assertSafeKey)(key);
85
+ return this.#driver.exists(key);
86
+ }
87
+ async delete(key) {
88
+ (0, keys_js_1.assertSafeKey)(key);
89
+ await this.#driver.delete(key);
90
+ }
91
+ /** A time-limited URL, when the driver can mint one. */
92
+ async signedUrl(key, expiresInSeconds = 300) {
93
+ (0, keys_js_1.assertSafeKey)(key);
94
+ if (this.#driver.signedUrl === undefined)
95
+ return undefined;
96
+ return this.#driver.signedUrl(key, expiresInSeconds);
97
+ }
98
+ /** The public URL for a key, when one is configured. */
99
+ url(key) {
100
+ if (this.#publicBaseUrl === undefined)
101
+ return undefined;
102
+ (0, keys_js_1.assertSafeKey)(key);
103
+ return `${this.#publicBaseUrl.replace(/\/$/, '')}/${key}`;
104
+ }
105
+ get policy() {
106
+ return this.#policy;
107
+ }
108
+ }
109
+ exports.StorageService = StorageService;
110
+ /** A best-effort name from a URL; sanitised later regardless. */
111
+ function filenameFromUrl(url) {
112
+ try {
113
+ const last = new URL(url).pathname.split('/').pop();
114
+ return last === undefined || last === '' ? 'download' : last;
115
+ }
116
+ catch {
117
+ return 'download';
118
+ }
119
+ }
120
+ //# sourceMappingURL=storage.service.js.map
@@ -0,0 +1,7 @@
1
+ /** DI tokens for storage (PLAN.md §7.3). */
2
+ import { type Token } from '@nage-api/core';
3
+ import type { StorageDriver } from './ports.js';
4
+ import type { StorageService } from './storage.service.js';
5
+ export declare const NAGE_STORAGE: Token<StorageService>;
6
+ export declare const NAGE_STORAGE_DRIVER: Token<StorageDriver>;
7
+ //# sourceMappingURL=tokens.d.ts.map
package/dist/tokens.js ADDED
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ /** DI tokens for storage (PLAN.md §7.3). */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.NAGE_STORAGE_DRIVER = exports.NAGE_STORAGE = void 0;
5
+ const core_1 = require("@nage-api/core");
6
+ exports.NAGE_STORAGE = (0, core_1.createToken)('NAGE_STORAGE');
7
+ exports.NAGE_STORAGE_DRIVER = (0, core_1.createToken)('NAGE_STORAGE_DRIVER');
8
+ //# sourceMappingURL=tokens.js.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Upload validation (PLAN.md §12).
3
+ *
4
+ * Four checks, in the order that costs least first: size, then the shape of the
5
+ * name, then the extensions, then the bytes themselves.
6
+ *
7
+ * The one that matters is the last. A client's `Content-Type` header is a
8
+ * claim, and the file extension is a claim, and both are attacker-controlled.
9
+ * The magic bytes are not: a file that says `image/png` and starts with `<?php`
10
+ * is refused here rather than served back from a CDN later.
11
+ *
12
+ * The signature table is deliberately short. It covers the types an application
13
+ * actually allows people to upload; anything outside it fails closed, which is
14
+ * the right default for a check whose job is to refuse surprises.
15
+ */
16
+ import type { UploadInput, UploadPolicy } from './ports.js';
17
+ /** Leading bytes that identify a format, with the offset they appear at. */
18
+ export interface MagicSignature {
19
+ readonly mime: string;
20
+ readonly offset: number;
21
+ readonly bytes: readonly number[];
22
+ /** Extra bytes that must also match, e.g. `WEBP` inside a RIFF container. */
23
+ readonly trailer?: {
24
+ readonly offset: number;
25
+ readonly bytes: readonly number[];
26
+ };
27
+ }
28
+ export declare const MAGIC_SIGNATURES: readonly MagicSignature[];
29
+ /** Extensions that are executable somewhere, whatever they claim to be. */
30
+ export declare const DEFAULT_DENIED_EXTENSIONS: readonly string[];
31
+ export declare const DEFAULT_MAX_SIZE_BYTES: number;
32
+ /** A conservative default: images and PDFs, nothing that executes. */
33
+ export declare const DEFAULT_ALLOWED_MIME_TYPES: readonly string[];
34
+ export declare function defaultUploadPolicy(): UploadPolicy;
35
+ /**
36
+ * Validate an upload, returning the **verified** content type.
37
+ *
38
+ * The returned type is what the bytes say, not what the client said, and it is
39
+ * what gets stored and served back — so a mislabelled file cannot later be
40
+ * served with a type that makes a browser execute it.
41
+ *
42
+ * @throws ValidationError with field-level detail
43
+ */
44
+ export declare function validateUpload(input: UploadInput, policy: UploadPolicy): string;
45
+ /** The MIME type the bytes say this is, or `undefined` if unrecognised. */
46
+ export declare function detectMimeType(content: Buffer): string | undefined;
47
+ /** Lowercased final extension, including the dot. */
48
+ export declare function extensionOf(filename: string): string;
49
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+ /**
3
+ * Upload validation (PLAN.md §12).
4
+ *
5
+ * Four checks, in the order that costs least first: size, then the shape of the
6
+ * name, then the extensions, then the bytes themselves.
7
+ *
8
+ * The one that matters is the last. A client's `Content-Type` header is a
9
+ * claim, and the file extension is a claim, and both are attacker-controlled.
10
+ * The magic bytes are not: a file that says `image/png` and starts with `<?php`
11
+ * is refused here rather than served back from a CDN later.
12
+ *
13
+ * The signature table is deliberately short. It covers the types an application
14
+ * actually allows people to upload; anything outside it fails closed, which is
15
+ * the right default for a check whose job is to refuse surprises.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.DEFAULT_ALLOWED_MIME_TYPES = exports.DEFAULT_MAX_SIZE_BYTES = exports.DEFAULT_DENIED_EXTENSIONS = exports.MAGIC_SIGNATURES = void 0;
19
+ exports.defaultUploadPolicy = defaultUploadPolicy;
20
+ exports.validateUpload = validateUpload;
21
+ exports.detectMimeType = detectMimeType;
22
+ exports.extensionOf = extensionOf;
23
+ const core_1 = require("@nage-api/core");
24
+ /** ASCII bytes of a signature literal. All of them are ASCII by construction. */
25
+ const ASCII = (text) => Array.from(text, (char) => char.charCodeAt(0));
26
+ exports.MAGIC_SIGNATURES = [
27
+ { mime: 'image/jpeg', offset: 0, bytes: [0xff, 0xd8, 0xff] },
28
+ { mime: 'image/png', offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
29
+ { mime: 'image/gif', offset: 0, bytes: ASCII('GIF8') },
30
+ {
31
+ mime: 'image/webp',
32
+ offset: 0,
33
+ bytes: ASCII('RIFF'),
34
+ trailer: { offset: 8, bytes: ASCII('WEBP') },
35
+ },
36
+ { mime: 'application/pdf', offset: 0, bytes: ASCII('%PDF-') },
37
+ // Every OOXML document and every .zip share this header; the distinction is
38
+ // inside the archive, which is more unpacking than a signature check should do.
39
+ { mime: 'application/zip', offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] },
40
+ ];
41
+ /** Extensions that are executable somewhere, whatever they claim to be. */
42
+ exports.DEFAULT_DENIED_EXTENSIONS = [
43
+ '.php',
44
+ '.phtml',
45
+ '.phar',
46
+ '.jsp',
47
+ '.asp',
48
+ '.aspx',
49
+ '.cgi',
50
+ '.pl',
51
+ '.py',
52
+ '.rb',
53
+ '.sh',
54
+ '.bash',
55
+ '.exe',
56
+ '.dll',
57
+ '.so',
58
+ '.bat',
59
+ '.cmd',
60
+ '.com',
61
+ '.scr',
62
+ '.js',
63
+ '.mjs',
64
+ '.cjs',
65
+ '.jar',
66
+ '.war',
67
+ '.htaccess',
68
+ '.htpasswd',
69
+ '.svg',
70
+ ];
71
+ exports.DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024;
72
+ /** A conservative default: images and PDFs, nothing that executes. */
73
+ exports.DEFAULT_ALLOWED_MIME_TYPES = [
74
+ 'image/jpeg',
75
+ 'image/png',
76
+ 'image/gif',
77
+ 'image/webp',
78
+ 'application/pdf',
79
+ ];
80
+ function defaultUploadPolicy() {
81
+ return {
82
+ maxSizeBytes: exports.DEFAULT_MAX_SIZE_BYTES,
83
+ allowedMimeTypes: exports.DEFAULT_ALLOWED_MIME_TYPES,
84
+ verifyMagicBytes: true,
85
+ deniedExtensions: exports.DEFAULT_DENIED_EXTENSIONS,
86
+ };
87
+ }
88
+ /**
89
+ * Validate an upload, returning the **verified** content type.
90
+ *
91
+ * The returned type is what the bytes say, not what the client said, and it is
92
+ * what gets stored and served back — so a mislabelled file cannot later be
93
+ * served with a type that makes a browser execute it.
94
+ *
95
+ * @throws ValidationError with field-level detail
96
+ */
97
+ function validateUpload(input, policy) {
98
+ const failures = {};
99
+ if (input.content.length === 0) {
100
+ failures['empty'] = 'The file is empty.';
101
+ }
102
+ if (input.content.length > policy.maxSizeBytes) {
103
+ failures['maxSize'] = `Must be ${formatBytes(policy.maxSizeBytes)} or smaller.`;
104
+ }
105
+ // RFC 7578 §4.2 makes the multipart `filename` a basename, so a separator or a
106
+ // null byte in one is refused rather than rewritten. Refusing matters because
107
+ // the two extension checks below split the name on dots alone: given
108
+ // `photo.php/avatar.png` they read `.php/avatar`, which no deny-list contains,
109
+ // and the file is accepted as an image. `buildStorageKey` would neutralise the
110
+ // same name into a safe key, but that is the second line of defence, and a
111
+ // caller that validates here and then keys on the client's name — a pre-signed
112
+ // upload, say — does not get the second line at all.
113
+ if (input.filename.includes('/') ||
114
+ input.filename.includes('\\') ||
115
+ input.filename.includes('\0')) {
116
+ failures['filename'] = 'The file name must be a name, not a path.';
117
+ }
118
+ const extension = extensionOf(input.filename);
119
+ if (extension !== '' && policy.deniedExtensions.includes(extension)) {
120
+ failures['extension'] = `Files ending in ${extension} are not accepted.`;
121
+ }
122
+ // A name with two extensions — `photo.png.php` — is how an upload gets
123
+ // executed by a misconfigured server that dispatches on the last one it
124
+ // recognises rather than the last one present.
125
+ for (const part of secondaryExtensions(input.filename)) {
126
+ if (policy.deniedExtensions.includes(part)) {
127
+ failures['extension'] = `The name contains a ${part} extension.`;
128
+ }
129
+ }
130
+ const detected = detectMimeType(input.content);
131
+ const declared = normaliseMime(input.contentType);
132
+ const effective = detected ?? declared;
133
+ if (effective === undefined) {
134
+ failures['type'] = 'The file type could not be determined.';
135
+ }
136
+ else if (!policy.allowedMimeTypes.includes(effective)) {
137
+ failures['type'] = `${effective} files are not accepted.`;
138
+ }
139
+ if (policy.verifyMagicBytes &&
140
+ detected !== undefined &&
141
+ declared !== undefined &&
142
+ detected !== declared) {
143
+ // The claim and the bytes disagree. Refused rather than silently corrected,
144
+ // because a caller that lies about one thing is not trustworthy about the
145
+ // rest of the request either.
146
+ failures['type'] = 'The file contents do not match the declared type.';
147
+ }
148
+ if (policy.verifyMagicBytes && detected === undefined && declared !== undefined) {
149
+ failures['type'] = `${declared} was declared but the contents are not recognisable as one.`;
150
+ }
151
+ if (Object.keys(failures).length > 0 || effective === undefined) {
152
+ const details = [{ field: 'file', constraints: failures }];
153
+ throw new core_1.ValidationError({ message: 'The uploaded file was rejected.', details });
154
+ }
155
+ return effective;
156
+ }
157
+ /** The MIME type the bytes say this is, or `undefined` if unrecognised. */
158
+ function detectMimeType(content) {
159
+ for (const signature of exports.MAGIC_SIGNATURES) {
160
+ if (!matches(content, signature.offset, signature.bytes))
161
+ continue;
162
+ if (signature.trailer !== undefined &&
163
+ !matches(content, signature.trailer.offset, signature.trailer.bytes)) {
164
+ continue;
165
+ }
166
+ return signature.mime;
167
+ }
168
+ return undefined;
169
+ }
170
+ function matches(content, offset, bytes) {
171
+ if (content.length < offset + bytes.length)
172
+ return false;
173
+ return bytes.every((byte, index) => content[offset + index] === byte);
174
+ }
175
+ /** Lowercased final extension, including the dot. */
176
+ function extensionOf(filename) {
177
+ const index = filename.lastIndexOf('.');
178
+ return index === -1 ? '' : filename.slice(index).toLowerCase();
179
+ }
180
+ /** Every extension except the last, for `photo.png.php`. */
181
+ function secondaryExtensions(filename) {
182
+ const parts = filename.toLowerCase().split('.');
183
+ if (parts.length <= 2)
184
+ return [];
185
+ return parts.slice(1, -1).map((part) => `.${part}`);
186
+ }
187
+ function normaliseMime(contentType) {
188
+ if (contentType === undefined || contentType.trim() === '')
189
+ return undefined;
190
+ // `image/png; charset=binary` — the parameters are not part of the type.
191
+ return contentType.split(';')[0]?.trim().toLowerCase();
192
+ }
193
+ function formatBytes(bytes) {
194
+ if (bytes >= 1024 * 1024)
195
+ return `${String(Math.round(bytes / (1024 * 1024)))}MB`;
196
+ if (bytes >= 1024)
197
+ return `${String(Math.round(bytes / 1024))}KB`;
198
+ return `${String(bytes)} bytes`;
199
+ }
200
+ //# sourceMappingURL=validation.js.map
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@nage-api/storage",
3
+ "version": "1.0.0-beta.2",
4
+ "description": "File storage for @nage-api — pluggable CDN, content validation, SSRF-safe fetch",
5
+ "license": "Apache-2.0",
6
+ "type": "commonjs",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "!dist/.tsbuildinfo",
20
+ "!dist/**/*.map",
21
+ "README.md"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "@nage-api/contracts": "1.0.0-beta.2",
28
+ "@nage-api/core": "1.0.0-beta.2"
29
+ },
30
+ "peerDependencies": {
31
+ "@nestjs/common": "^11.0.0",
32
+ "@nestjs/core": "^11.0.0",
33
+ "reflect-metadata": "^0.2.0"
34
+ },
35
+ "devDependencies": {
36
+ "@nestjs/common": "11.1.29",
37
+ "@nestjs/core": "11.1.29",
38
+ "@nestjs/testing": "11.1.29",
39
+ "@swc/core": "1.15.47",
40
+ "@types/node": "22.20.1",
41
+ "@vitest/coverage-v8": "4.1.10",
42
+ "reflect-metadata": "0.2.2",
43
+ "rimraf": "6.1.3",
44
+ "rxjs": "7.8.2",
45
+ "typescript": "5.9.3",
46
+ "unplugin-swc": "1.5.11",
47
+ "vitest": "4.1.10",
48
+ "@nage-api/testing": "1.0.0-beta.2"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.0.0"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -b tsconfig.build.json",
55
+ "clean": "rimraf dist .turbo",
56
+ "typecheck": "tsc -p tsconfig.json --noEmit",
57
+ "test": "vitest run"
58
+ }
59
+ }