@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.
- package/LICENSE +202 -0
- package/README.md +116 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +43 -0
- package/dist/keys.d.ts +50 -0
- package/dist/keys.js +109 -0
- package/dist/local.driver.d.ts +32 -0
- package/dist/local.driver.js +84 -0
- package/dist/ports.d.ts +67 -0
- package/dist/ports.js +16 -0
- package/dist/remote-fetch.d.ts +75 -0
- package/dist/remote-fetch.js +244 -0
- package/dist/storage.module.d.ts +30 -0
- package/dist/storage.module.js +102 -0
- package/dist/storage.service.d.ts +51 -0
- package/dist/storage.service.js +120 -0
- package/dist/tokens.d.ts +7 -0
- package/dist/tokens.js +8 -0
- package/dist/validation.d.ts +49 -0
- package/dist/validation.js +200 -0
- package/package.json +59 -0
package/dist/ports.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage contracts (PLAN.md §8, §12, §25 P1).
|
|
3
|
+
*
|
|
4
|
+
* One port, several backends. The application asks for "put this somewhere and
|
|
5
|
+
* give me a URL"; whether that is a local directory, S3 or Azure is a
|
|
6
|
+
* configuration choice made once at boot.
|
|
7
|
+
*
|
|
8
|
+
* The port is deliberately narrow. Buckets, ACLs, storage classes and
|
|
9
|
+
* multipart uploads are backend concepts; exposing them here would make every
|
|
10
|
+
* caller depend on the one backend that has them.
|
|
11
|
+
*/
|
|
12
|
+
/** A file on its way in. `content` is a buffer; streaming is a later concern. */
|
|
13
|
+
export interface UploadInput {
|
|
14
|
+
/** Name as the client supplied it — untrusted, and sanitised before use. */
|
|
15
|
+
readonly filename: string;
|
|
16
|
+
readonly content: Buffer;
|
|
17
|
+
/** Declared by the client. Verified against the bytes, never believed. */
|
|
18
|
+
readonly contentType?: string;
|
|
19
|
+
/** Logical folder, e.g. `avatars`. Never a filesystem path. */
|
|
20
|
+
readonly directory?: string;
|
|
21
|
+
/** Copied to the backend where it supports metadata. */
|
|
22
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
23
|
+
}
|
|
24
|
+
/** A file that has been stored. */
|
|
25
|
+
export interface StoredFile {
|
|
26
|
+
/** Backend-relative key: `avatars/2026/01/a1b2c3-photo.jpg`. */
|
|
27
|
+
readonly key: string;
|
|
28
|
+
readonly size: number;
|
|
29
|
+
/** The verified type, which may differ from what the client declared. */
|
|
30
|
+
readonly contentType: string;
|
|
31
|
+
/** SHA-256 of the bytes, for de-duplication and integrity checks. */
|
|
32
|
+
readonly checksum: string;
|
|
33
|
+
readonly uploadedAt: number;
|
|
34
|
+
/** Public URL, when the backend and configuration provide one. */
|
|
35
|
+
readonly url?: string;
|
|
36
|
+
}
|
|
37
|
+
/** A storage backend. */
|
|
38
|
+
export interface StorageDriver {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
put(key: string, content: Buffer, contentType: string): Promise<void>;
|
|
41
|
+
get(key: string): Promise<Buffer | undefined>;
|
|
42
|
+
delete(key: string): Promise<void>;
|
|
43
|
+
exists(key: string): Promise<boolean>;
|
|
44
|
+
/** A time-limited URL, where the backend can mint one. */
|
|
45
|
+
signedUrl?(key: string, expiresInSeconds: number): Promise<string>;
|
|
46
|
+
}
|
|
47
|
+
/** What a file must satisfy to be accepted (PLAN.md §12). */
|
|
48
|
+
export interface UploadPolicy {
|
|
49
|
+
readonly maxSizeBytes: number;
|
|
50
|
+
/** Allow-list of MIME types. Empty means nothing is accepted. */
|
|
51
|
+
readonly allowedMimeTypes: readonly string[];
|
|
52
|
+
/**
|
|
53
|
+
* Verify the declared type against the file's magic bytes.
|
|
54
|
+
*
|
|
55
|
+
* On by default. A client that says `image/png` and sends a PHP script is
|
|
56
|
+
* the oldest upload attack there is, and the extension proves nothing.
|
|
57
|
+
*/
|
|
58
|
+
readonly verifyMagicBytes: boolean;
|
|
59
|
+
/** Extensions refused regardless of the MIME type. */
|
|
60
|
+
readonly deniedExtensions: readonly string[];
|
|
61
|
+
}
|
|
62
|
+
/** Injected so keys and expiry are deterministic in tests. */
|
|
63
|
+
export interface Clock {
|
|
64
|
+
now(): number;
|
|
65
|
+
}
|
|
66
|
+
export declare const systemClock: Clock;
|
|
67
|
+
//# sourceMappingURL=ports.d.ts.map
|
package/dist/ports.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Storage contracts (PLAN.md §8, §12, §25 P1).
|
|
4
|
+
*
|
|
5
|
+
* One port, several backends. The application asks for "put this somewhere and
|
|
6
|
+
* give me a URL"; whether that is a local directory, S3 or Azure is a
|
|
7
|
+
* configuration choice made once at boot.
|
|
8
|
+
*
|
|
9
|
+
* The port is deliberately narrow. Buckets, ACLs, storage classes and
|
|
10
|
+
* multipart uploads are backend concepts; exposing them here would make every
|
|
11
|
+
* caller depend on the one backend that has them.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.systemClock = void 0;
|
|
15
|
+
exports.systemClock = { now: () => Date.now() };
|
|
16
|
+
//# sourceMappingURL=ports.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetching a file from a URL the application was given (PLAN.md §12:
|
|
3
|
+
* "upload+CDN+validation, SSRF-safe").
|
|
4
|
+
*
|
|
5
|
+
* "Import this image from a URL" is a server-side request forgery primitive.
|
|
6
|
+
* The URL comes from a client, the request comes from inside the network, and
|
|
7
|
+
* cloud metadata endpoints — `169.254.169.254` — hand out credentials to
|
|
8
|
+
* anything that asks from the right place.
|
|
9
|
+
*
|
|
10
|
+
* The defence is layered, because each layer alone is bypassable:
|
|
11
|
+
*
|
|
12
|
+
* 1. **scheme allow-list** — `file:`, `gopher:`, `ftp:` and friends are not
|
|
13
|
+
* fetches an application means to make;
|
|
14
|
+
* 2. **DNS resolution before connecting** — a hostname that resolves to a
|
|
15
|
+
* private address is refused. Checking the hostname's *text* is useless:
|
|
16
|
+
* an attacker controls a domain that resolves to 127.0.0.1;
|
|
17
|
+
* 3. **redirects re-checked** — a public URL that redirects to
|
|
18
|
+
* `http://169.254.169.254/` defeats a check that only ran on the first
|
|
19
|
+
* URL, so redirects are followed manually and each hop is re-validated;
|
|
20
|
+
* 4. **size and time limits** — a URL that streams forever is a denial of
|
|
21
|
+
* service, not a download.
|
|
22
|
+
*
|
|
23
|
+
* A residual DNS-rebinding window remains: resolution and connection are two
|
|
24
|
+
* separate operations. Closing it needs a custom agent that pins the resolved
|
|
25
|
+
* address, which is noted in the README rather than pretended away.
|
|
26
|
+
*/
|
|
27
|
+
export interface RemoteFetchOptions {
|
|
28
|
+
/** Schemes that may be fetched. */
|
|
29
|
+
readonly allowedProtocols?: readonly string[];
|
|
30
|
+
/** Refuse a response larger than this. */
|
|
31
|
+
readonly maxBytes?: number;
|
|
32
|
+
readonly timeoutMs?: number;
|
|
33
|
+
/** Redirect hops followed before giving up. */
|
|
34
|
+
readonly maxRedirects?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Hosts allowed even though they resolve privately.
|
|
37
|
+
*
|
|
38
|
+
* For an internal service you deliberately fetch from. Empty by default.
|
|
39
|
+
*/
|
|
40
|
+
readonly allowedHosts?: readonly string[];
|
|
41
|
+
/** Injected in tests; defaults to the global `fetch`. */
|
|
42
|
+
readonly fetchImpl?: typeof fetch;
|
|
43
|
+
/** Injected in tests; defaults to a real DNS lookup. */
|
|
44
|
+
readonly resolve?: (hostname: string) => Promise<readonly string[]>;
|
|
45
|
+
}
|
|
46
|
+
export interface FetchedFile {
|
|
47
|
+
readonly content: Buffer;
|
|
48
|
+
readonly contentType: string | undefined;
|
|
49
|
+
/** The URL actually fetched, after redirects. */
|
|
50
|
+
readonly finalUrl: string;
|
|
51
|
+
}
|
|
52
|
+
export declare class RemoteFetcher {
|
|
53
|
+
#private;
|
|
54
|
+
constructor(options?: RemoteFetchOptions);
|
|
55
|
+
/**
|
|
56
|
+
* Fetch a URL, re-validating every redirect hop.
|
|
57
|
+
*
|
|
58
|
+
* @throws ValidationError when the URL is not one this application may fetch
|
|
59
|
+
* @throws ExternalServiceError when the fetch itself fails
|
|
60
|
+
*/
|
|
61
|
+
fetch(url: string): Promise<FetchedFile>;
|
|
62
|
+
/**
|
|
63
|
+
* Check a URL without fetching it.
|
|
64
|
+
*
|
|
65
|
+
* @throws ValidationError with a client-safe message
|
|
66
|
+
*/
|
|
67
|
+
assertFetchable(url: string): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether an address is one an application should never be tricked into
|
|
71
|
+
* reaching: loopback, link-local (which includes the cloud metadata endpoint),
|
|
72
|
+
* the RFC 1918 ranges, carrier-grade NAT, and their IPv6 equivalents.
|
|
73
|
+
*/
|
|
74
|
+
export declare function isPrivateAddress(address: string): boolean;
|
|
75
|
+
//# sourceMappingURL=remote-fetch.d.ts.map
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Fetching a file from a URL the application was given (PLAN.md §12:
|
|
4
|
+
* "upload+CDN+validation, SSRF-safe").
|
|
5
|
+
*
|
|
6
|
+
* "Import this image from a URL" is a server-side request forgery primitive.
|
|
7
|
+
* The URL comes from a client, the request comes from inside the network, and
|
|
8
|
+
* cloud metadata endpoints — `169.254.169.254` — hand out credentials to
|
|
9
|
+
* anything that asks from the right place.
|
|
10
|
+
*
|
|
11
|
+
* The defence is layered, because each layer alone is bypassable:
|
|
12
|
+
*
|
|
13
|
+
* 1. **scheme allow-list** — `file:`, `gopher:`, `ftp:` and friends are not
|
|
14
|
+
* fetches an application means to make;
|
|
15
|
+
* 2. **DNS resolution before connecting** — a hostname that resolves to a
|
|
16
|
+
* private address is refused. Checking the hostname's *text* is useless:
|
|
17
|
+
* an attacker controls a domain that resolves to 127.0.0.1;
|
|
18
|
+
* 3. **redirects re-checked** — a public URL that redirects to
|
|
19
|
+
* `http://169.254.169.254/` defeats a check that only ran on the first
|
|
20
|
+
* URL, so redirects are followed manually and each hop is re-validated;
|
|
21
|
+
* 4. **size and time limits** — a URL that streams forever is a denial of
|
|
22
|
+
* service, not a download.
|
|
23
|
+
*
|
|
24
|
+
* A residual DNS-rebinding window remains: resolution and connection are two
|
|
25
|
+
* separate operations. Closing it needs a custom agent that pins the resolved
|
|
26
|
+
* address, which is noted in the README rather than pretended away.
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.RemoteFetcher = void 0;
|
|
30
|
+
exports.isPrivateAddress = isPrivateAddress;
|
|
31
|
+
const promises_1 = require("node:dns/promises");
|
|
32
|
+
const node_net_1 = require("node:net");
|
|
33
|
+
const core_1 = require("@nage-api/core");
|
|
34
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
36
|
+
const DEFAULT_MAX_REDIRECTS = 3;
|
|
37
|
+
class RemoteFetcher {
|
|
38
|
+
#allowedProtocols;
|
|
39
|
+
#maxBytes;
|
|
40
|
+
#timeoutMs;
|
|
41
|
+
#maxRedirects;
|
|
42
|
+
#allowedHosts;
|
|
43
|
+
#fetch;
|
|
44
|
+
#resolve;
|
|
45
|
+
constructor(options = {}) {
|
|
46
|
+
this.#allowedProtocols = options.allowedProtocols ?? ['https:'];
|
|
47
|
+
this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
48
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
49
|
+
this.#maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
50
|
+
this.#allowedHosts = new Set(options.allowedHosts ?? []);
|
|
51
|
+
this.#fetch = options.fetchImpl ?? globalThis.fetch;
|
|
52
|
+
this.#resolve = options.resolve ?? defaultResolve;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Fetch a URL, re-validating every redirect hop.
|
|
56
|
+
*
|
|
57
|
+
* @throws ValidationError when the URL is not one this application may fetch
|
|
58
|
+
* @throws ExternalServiceError when the fetch itself fails
|
|
59
|
+
*/
|
|
60
|
+
async fetch(url) {
|
|
61
|
+
let current = url;
|
|
62
|
+
for (let hop = 0; hop <= this.#maxRedirects; hop += 1) {
|
|
63
|
+
await this.assertFetchable(current);
|
|
64
|
+
const response = await this.#request(current);
|
|
65
|
+
const location = response.headers.get('location');
|
|
66
|
+
if (isRedirect(response.status) && location !== null) {
|
|
67
|
+
// Followed by hand so the next hop goes back through validation. A
|
|
68
|
+
// public URL that redirects to the metadata endpoint is the standard
|
|
69
|
+
// bypass for a check that only ran once.
|
|
70
|
+
current = new URL(location, current).toString();
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new core_1.ExternalServiceError('remote-fetch', {
|
|
75
|
+
detail: `Fetching ${current} returned ${String(response.status)}`,
|
|
76
|
+
meta: { status: response.status },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
content: await this.#readBounded(response),
|
|
81
|
+
contentType: response.headers.get('content-type') ?? undefined,
|
|
82
|
+
finalUrl: current,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
throw new core_1.ValidationError({
|
|
86
|
+
message: 'The URL redirected too many times.',
|
|
87
|
+
detail: `More than ${String(this.#maxRedirects)} redirects starting from ${url}`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Check a URL without fetching it.
|
|
92
|
+
*
|
|
93
|
+
* @throws ValidationError with a client-safe message
|
|
94
|
+
*/
|
|
95
|
+
async assertFetchable(url) {
|
|
96
|
+
let parsed;
|
|
97
|
+
try {
|
|
98
|
+
parsed = new URL(url);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new core_1.ValidationError({ message: 'That is not a valid URL.' });
|
|
102
|
+
}
|
|
103
|
+
if (!this.#allowedProtocols.includes(parsed.protocol)) {
|
|
104
|
+
throw new core_1.ValidationError({
|
|
105
|
+
message: 'That URL scheme is not accepted.',
|
|
106
|
+
detail: `Protocol ${parsed.protocol} is not in the allow-list`,
|
|
107
|
+
meta: { protocol: parsed.protocol },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (this.#allowedHosts.has(parsed.hostname))
|
|
111
|
+
return;
|
|
112
|
+
// Resolved, not pattern-matched: an attacker controls a public domain that
|
|
113
|
+
// resolves to 127.0.0.1, and no amount of string inspection catches it.
|
|
114
|
+
const addresses = (0, node_net_1.isIP)(parsed.hostname) !== 0
|
|
115
|
+
? [parsed.hostname]
|
|
116
|
+
: await this.#resolveOrRefuse(parsed.hostname);
|
|
117
|
+
for (const address of addresses) {
|
|
118
|
+
if (isPrivateAddress(address)) {
|
|
119
|
+
throw new core_1.ValidationError({
|
|
120
|
+
message: 'That URL is not reachable from here.',
|
|
121
|
+
detail: `${parsed.hostname} resolves to the private address ${address}`,
|
|
122
|
+
meta: { hostname: parsed.hostname },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async #resolveOrRefuse(hostname) {
|
|
128
|
+
try {
|
|
129
|
+
const addresses = await this.#resolve(hostname);
|
|
130
|
+
if (addresses.length === 0)
|
|
131
|
+
throw new Error('no addresses');
|
|
132
|
+
return addresses;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// A name that does not resolve is refused rather than attempted: letting
|
|
136
|
+
// the fetch proceed would move the decision to the resolver the runtime
|
|
137
|
+
// happens to use.
|
|
138
|
+
throw new core_1.ValidationError({
|
|
139
|
+
message: 'That URL is not reachable from here.',
|
|
140
|
+
detail: `${hostname} could not be resolved`,
|
|
141
|
+
meta: { hostname },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async #request(url) {
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
controller.abort();
|
|
149
|
+
}, this.#timeoutMs);
|
|
150
|
+
timer.unref();
|
|
151
|
+
try {
|
|
152
|
+
return await this.#fetch(url, {
|
|
153
|
+
redirect: 'manual',
|
|
154
|
+
signal: controller.signal,
|
|
155
|
+
headers: { accept: '*/*' },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
throw new core_1.ExternalServiceError('remote-fetch', {
|
|
160
|
+
detail: `Fetching ${url} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
161
|
+
cause: error,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Read a body, refusing to buffer more than the limit.
|
|
170
|
+
*
|
|
171
|
+
* `Content-Length` is checked first because it is cheap, and then the actual
|
|
172
|
+
* bytes are counted, because a server can lie about the header or omit it.
|
|
173
|
+
*/
|
|
174
|
+
async #readBounded(response) {
|
|
175
|
+
const declared = Number(response.headers.get('content-length') ?? '0');
|
|
176
|
+
if (declared > this.#maxBytes) {
|
|
177
|
+
throw new core_1.ValidationError({
|
|
178
|
+
message: 'That file is too large.',
|
|
179
|
+
meta: { maxBytes: this.#maxBytes },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
183
|
+
if (buffer.length > this.#maxBytes) {
|
|
184
|
+
throw new core_1.ValidationError({
|
|
185
|
+
message: 'That file is too large.',
|
|
186
|
+
detail: `Body was ${String(buffer.length)} bytes against a limit of ${String(this.#maxBytes)}`,
|
|
187
|
+
meta: { maxBytes: this.#maxBytes },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return buffer;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
exports.RemoteFetcher = RemoteFetcher;
|
|
194
|
+
async function defaultResolve(hostname) {
|
|
195
|
+
const results = await (0, promises_1.lookup)(hostname, { all: true });
|
|
196
|
+
return results.map((entry) => entry.address);
|
|
197
|
+
}
|
|
198
|
+
function isRedirect(status) {
|
|
199
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Whether an address is one an application should never be tricked into
|
|
203
|
+
* reaching: loopback, link-local (which includes the cloud metadata endpoint),
|
|
204
|
+
* the RFC 1918 ranges, carrier-grade NAT, and their IPv6 equivalents.
|
|
205
|
+
*/
|
|
206
|
+
function isPrivateAddress(address) {
|
|
207
|
+
const version = (0, node_net_1.isIP)(address);
|
|
208
|
+
if (version === 4) {
|
|
209
|
+
const octets = address.split('.').map(Number);
|
|
210
|
+
const [a = 0, b = 0] = octets;
|
|
211
|
+
if (a === 0 || a === 10 || a === 127)
|
|
212
|
+
return true;
|
|
213
|
+
if (a === 169 && b === 254)
|
|
214
|
+
return true; // link-local, incl. 169.254.169.254
|
|
215
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
216
|
+
return true;
|
|
217
|
+
if (a === 192 && b === 168)
|
|
218
|
+
return true;
|
|
219
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
220
|
+
return true; // carrier-grade NAT
|
|
221
|
+
if (a >= 224)
|
|
222
|
+
return true; // multicast and reserved
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
if (version === 6) {
|
|
226
|
+
const normalised = address.toLowerCase();
|
|
227
|
+
if (normalised === '::' || normalised === '::1')
|
|
228
|
+
return true;
|
|
229
|
+
if (normalised.startsWith('fe80'))
|
|
230
|
+
return true; // link-local
|
|
231
|
+
if (normalised.startsWith('fc') || normalised.startsWith('fd'))
|
|
232
|
+
return true; // unique local
|
|
233
|
+
if (normalised.startsWith('ff'))
|
|
234
|
+
return true; // multicast
|
|
235
|
+
// An IPv4-mapped address is still that IPv4 address.
|
|
236
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(normalised);
|
|
237
|
+
if (mapped?.[1] !== undefined)
|
|
238
|
+
return isPrivateAddress(mapped[1]);
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
// Not an address at all: refuse rather than guess.
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
//# sourceMappingURL=remote-fetch.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NageStorageModule.forRoot(config)` (PLAN.md §11.1 item 6).
|
|
3
|
+
*
|
|
4
|
+
* The driver is a scaffold-time choice, like the database driver: `local` is
|
|
5
|
+
* built in, and `s3`/`azure` are bound by the application because their SDKs
|
|
6
|
+
* are large and neither belongs in an install that does not use them.
|
|
7
|
+
*/
|
|
8
|
+
import { type DynamicModule } from '@nestjs/common';
|
|
9
|
+
import type { StorageConfig } from '@nage-api/contracts';
|
|
10
|
+
import { RemoteFetcher } from './remote-fetch.js';
|
|
11
|
+
import type { Clock, StorageDriver, UploadPolicy } from './ports.js';
|
|
12
|
+
export interface NageStorageModuleOptions {
|
|
13
|
+
readonly storage?: StorageConfig;
|
|
14
|
+
/** Required for `s3` and `azure`, which this package does not implement. */
|
|
15
|
+
readonly driver?: StorageDriver;
|
|
16
|
+
/** Root directory for the local driver. */
|
|
17
|
+
readonly root?: string;
|
|
18
|
+
/** Override the upload policy; the default allows images and PDFs. */
|
|
19
|
+
readonly policy?: Partial<UploadPolicy>;
|
|
20
|
+
/** Supply one to enable `uploadFromUrl`. */
|
|
21
|
+
readonly fetcher?: RemoteFetcher;
|
|
22
|
+
readonly clock?: Clock;
|
|
23
|
+
}
|
|
24
|
+
export declare class NageStorageModule {
|
|
25
|
+
static forRoot(options?: NageStorageModuleOptions): DynamicModule;
|
|
26
|
+
private static buildDriver;
|
|
27
|
+
}
|
|
28
|
+
/** `10MB`, `512KB`, or a plain byte count. */
|
|
29
|
+
export declare function parseSize(size: string): number;
|
|
30
|
+
//# sourceMappingURL=storage.module.d.ts.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `NageStorageModule.forRoot(config)` (PLAN.md §11.1 item 6).
|
|
4
|
+
*
|
|
5
|
+
* The driver is a scaffold-time choice, like the database driver: `local` is
|
|
6
|
+
* built in, and `s3`/`azure` are bound by the application because their SDKs
|
|
7
|
+
* are large and neither belongs in an install that does not use them.
|
|
8
|
+
*/
|
|
9
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
10
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
11
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
12
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
13
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
14
|
+
};
|
|
15
|
+
var NageStorageModule_1;
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.NageStorageModule = void 0;
|
|
18
|
+
exports.parseSize = parseSize;
|
|
19
|
+
const common_1 = require("@nestjs/common");
|
|
20
|
+
const core_1 = require("@nage-api/core");
|
|
21
|
+
const local_driver_js_1 = require("./local.driver.js");
|
|
22
|
+
const storage_service_js_1 = require("./storage.service.js");
|
|
23
|
+
const validation_js_1 = require("./validation.js");
|
|
24
|
+
const tokens_js_1 = require("./tokens.js");
|
|
25
|
+
let NageStorageModule = NageStorageModule_1 = class NageStorageModule {
|
|
26
|
+
static forRoot(options = {}) {
|
|
27
|
+
const config = options.storage ?? {};
|
|
28
|
+
if (config.enabled === false) {
|
|
29
|
+
return { module: NageStorageModule_1, providers: [], exports: [] };
|
|
30
|
+
}
|
|
31
|
+
const driver = options.driver ?? NageStorageModule_1.buildDriver(config, options);
|
|
32
|
+
const service = new storage_service_js_1.StorageService({
|
|
33
|
+
driver,
|
|
34
|
+
policy: { ...(0, validation_js_1.defaultUploadPolicy)(), ...resolvePolicy(config), ...options.policy },
|
|
35
|
+
...(config.publicBaseUrl === undefined ? {} : { publicBaseUrl: config.publicBaseUrl }),
|
|
36
|
+
...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }),
|
|
37
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
38
|
+
});
|
|
39
|
+
const providers = [
|
|
40
|
+
{ provide: tokens_js_1.NAGE_STORAGE_DRIVER, useValue: driver },
|
|
41
|
+
{ provide: tokens_js_1.NAGE_STORAGE, useValue: service },
|
|
42
|
+
{ provide: storage_service_js_1.StorageService, useValue: service },
|
|
43
|
+
];
|
|
44
|
+
return {
|
|
45
|
+
module: NageStorageModule_1,
|
|
46
|
+
providers,
|
|
47
|
+
exports: [tokens_js_1.NAGE_STORAGE, tokens_js_1.NAGE_STORAGE_DRIVER, storage_service_js_1.StorageService],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
static buildDriver(config, options) {
|
|
51
|
+
const cdn = config.cdn ?? 'local';
|
|
52
|
+
if (cdn !== 'local') {
|
|
53
|
+
// Refused at composition time rather than at the first upload: a
|
|
54
|
+
// deployment should fail to boot instead.
|
|
55
|
+
throw new core_1.ConfigurationError({
|
|
56
|
+
detail: `storage.cdn "${cdn}" needs a driver; pass one to NageStorageModule.forRoot({ driver })`,
|
|
57
|
+
meta: { setting: 'storage.cdn', cdn },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return new local_driver_js_1.LocalStorageDriver({
|
|
61
|
+
root: options.root ?? './storage',
|
|
62
|
+
...(config.publicBaseUrl === undefined ? {} : { publicBaseUrl: config.publicBaseUrl }),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
exports.NageStorageModule = NageStorageModule;
|
|
67
|
+
exports.NageStorageModule = NageStorageModule = NageStorageModule_1 = __decorate([
|
|
68
|
+
(0, common_1.Module)({})
|
|
69
|
+
], NageStorageModule);
|
|
70
|
+
/** Read the validation block into an upload policy. */
|
|
71
|
+
function resolvePolicy(config) {
|
|
72
|
+
const validation = config.validation;
|
|
73
|
+
if (validation === undefined)
|
|
74
|
+
return {};
|
|
75
|
+
return {
|
|
76
|
+
...(validation.maxSize === undefined ? {} : { maxSizeBytes: parseSize(validation.maxSize) }),
|
|
77
|
+
...(validation.mime === undefined ? {} : { allowedMimeTypes: validation.mime }),
|
|
78
|
+
...(validation.magicBytes === undefined ? {} : { verifyMagicBytes: validation.magicBytes }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** `10MB`, `512KB`, or a plain byte count. */
|
|
82
|
+
function parseSize(size) {
|
|
83
|
+
const match = /^(\d+)\s*(b|kb|mb|gb)?$/i.exec(size.trim());
|
|
84
|
+
if (match === null) {
|
|
85
|
+
throw new core_1.ConfigurationError({
|
|
86
|
+
detail: `storage.validation.maxSize is not a size ("${size}"); use forms like 10MB`,
|
|
87
|
+
meta: { setting: 'storage.validation.maxSize', value: size },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const amount = Number(match[1]);
|
|
91
|
+
switch (match[2]?.toLowerCase()) {
|
|
92
|
+
case 'kb':
|
|
93
|
+
return amount * 1024;
|
|
94
|
+
case 'mb':
|
|
95
|
+
return amount * 1024 * 1024;
|
|
96
|
+
case 'gb':
|
|
97
|
+
return amount * 1024 * 1024 * 1024;
|
|
98
|
+
default:
|
|
99
|
+
return amount;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=storage.module.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage service application code uses (PLAN.md §8, §12).
|
|
3
|
+
*
|
|
4
|
+
* Every write goes through the same three steps in the same order: validate the
|
|
5
|
+
* bytes, build a safe key, then store. There is no method that skips a step,
|
|
6
|
+
* because the one call site that skipped validation is where the stored XSS
|
|
7
|
+
* came from.
|
|
8
|
+
*/
|
|
9
|
+
import type { RemoteFetcher } from './remote-fetch.js';
|
|
10
|
+
import type { Clock, StorageDriver, StoredFile, UploadInput, UploadPolicy } from './ports.js';
|
|
11
|
+
export interface StorageServiceOptions {
|
|
12
|
+
readonly driver: StorageDriver;
|
|
13
|
+
readonly policy?: UploadPolicy;
|
|
14
|
+
/** Base URL stored files are served from. */
|
|
15
|
+
readonly publicBaseUrl?: string;
|
|
16
|
+
/** Supplied when the application imports files from URLs. */
|
|
17
|
+
readonly fetcher?: RemoteFetcher;
|
|
18
|
+
readonly clock?: Clock;
|
|
19
|
+
}
|
|
20
|
+
export declare class StorageService {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(options: StorageServiceOptions);
|
|
23
|
+
/**
|
|
24
|
+
* Validate and store an upload.
|
|
25
|
+
*
|
|
26
|
+
* The stored extension comes from the **verified** content type, not from the
|
|
27
|
+
* client's filename — so a `.php` named `image/png` is stored as `.png` even
|
|
28
|
+
* if every other check somehow passed.
|
|
29
|
+
*/
|
|
30
|
+
upload(input: UploadInput): Promise<StoredFile>;
|
|
31
|
+
/**
|
|
32
|
+
* Fetch a URL and store what comes back.
|
|
33
|
+
*
|
|
34
|
+
* The fetch is SSRF-checked and the bytes are validated exactly as an upload
|
|
35
|
+
* is: a file arriving over HTTP is no more trustworthy than one arriving in a
|
|
36
|
+
* form.
|
|
37
|
+
*/
|
|
38
|
+
uploadFromUrl(url: string, options?: {
|
|
39
|
+
directory?: string;
|
|
40
|
+
filename?: string;
|
|
41
|
+
}): Promise<StoredFile>;
|
|
42
|
+
download(key: string): Promise<Buffer>;
|
|
43
|
+
exists(key: string): Promise<boolean>;
|
|
44
|
+
delete(key: string): Promise<void>;
|
|
45
|
+
/** A time-limited URL, when the driver can mint one. */
|
|
46
|
+
signedUrl(key: string, expiresInSeconds?: number): Promise<string | undefined>;
|
|
47
|
+
/** The public URL for a key, when one is configured. */
|
|
48
|
+
url(key: string): string | undefined;
|
|
49
|
+
get policy(): UploadPolicy;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=storage.service.d.ts.map
|