@ultimat3/storage 1.0.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/src/storage.ts ADDED
@@ -0,0 +1,76 @@
1
+ // Single responsibility: named disks (Laravel's model) and the one module-level accessor.
2
+ // Call sites name a disk, never a driver — swapping `local` for `s3` in app.config.ts must
3
+ // not touch a single `storage.disk('uploads').put(...)` call.
4
+
5
+ import { ConfigInvalidError } from '@ultimat3/core';
6
+ import type { StorageDriver } from './driver';
7
+ import { diskUnknown } from './errors';
8
+
9
+ export interface StorageConfig {
10
+ readonly disks: Readonly<Record<string, StorageDriver>>;
11
+ /** Disk used when `disk()` is called with no name. Defaults to the first declared disk. */
12
+ readonly default?: string | undefined;
13
+ }
14
+
15
+ export interface Storage {
16
+ readonly defaultDisk: string;
17
+ readonly diskNames: readonly string[];
18
+ disk(name?: string): StorageDriver;
19
+ }
20
+
21
+ let current: Storage | undefined;
22
+
23
+ /**
24
+ * Build the disk map and install it as the process-wide storage. There is exactly one, the
25
+ * same way there is exactly one `app.config.ts` — a second registry is a second source of truth.
26
+ */
27
+ export function defineStorage(config: StorageConfig): Storage {
28
+ const names = Object.keys(config.disks);
29
+ if (names.length === 0) {
30
+ throw new ConfigInvalidError({
31
+ cause: 'defineStorage() was called with no disks',
32
+ fix: "add a disk: defineStorage({ disks: { local: localDriver({ root: '.storage' }) } })",
33
+ });
34
+ }
35
+ const first = names[0] ?? '';
36
+ const defaultDisk = config.default ?? first;
37
+ if (!names.includes(defaultDisk)) {
38
+ throw new ConfigInvalidError({
39
+ cause: `storage.default is "${defaultDisk}" but the configured disks are: ${names.join(', ')}`,
40
+ fix: `set storage.default to one of: ${names.join(', ')} in app.config.ts`,
41
+ });
42
+ }
43
+ const storageInstance: Storage = {
44
+ defaultDisk,
45
+ diskNames: Object.freeze([...names]),
46
+ disk(name?: string): StorageDriver {
47
+ const wanted = name ?? defaultDisk;
48
+ const driver = config.disks[wanted];
49
+ if (driver === undefined) throw diskUnknown(wanted, names);
50
+ return driver;
51
+ },
52
+ };
53
+ current = storageInstance;
54
+ return storageInstance;
55
+ }
56
+
57
+ /** The configured storage. Throws rather than lazily inventing a disk behind your back. */
58
+ export function storage(): Storage {
59
+ if (current === undefined) {
60
+ throw new ConfigInvalidError({
61
+ cause: 'storage() was called before defineStorage()',
62
+ fix: "call defineStorage({ disks: { local: localDriver({ root: '.storage' }) } }) in app.config.ts",
63
+ });
64
+ }
65
+ return current;
66
+ }
67
+
68
+ /** Shorthand for the common call. `disk()` alone resolves the default disk. */
69
+ export function disk(name?: string): StorageDriver {
70
+ return storage().disk(name);
71
+ }
72
+
73
+ /** Test seam: drop the module-level storage so the next test defines its own. */
74
+ export function resetStorage(): void {
75
+ current = undefined;
76
+ }
package/src/upload.ts ADDED
@@ -0,0 +1,193 @@
1
+ // Single responsibility: the constraint policy for direct-to-storage uploads — size,
2
+ // allowlist, checksum — and the content-type sniff that enforces it.
3
+ // WHY sniff: `Content-Type` is attacker-controlled. A `.png` that is really an HTML document
4
+ // is a stored-XSS delivery vehicle the moment any surface serves it back with the declared
5
+ // type, so the magic bytes decide and a contradiction is rejected outright.
6
+
7
+ import { sha256Base64 } from './driver';
8
+ import { checksumMismatch, contentTypeMismatch, contentTypeNotAllowed, tooLarge } from './errors';
9
+ import { assertSafeKey } from './path';
10
+
11
+ export const DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
12
+
13
+ export const IMAGE_CONTENT_TYPES = [
14
+ 'image/png',
15
+ 'image/jpeg',
16
+ 'image/gif',
17
+ 'image/webp',
18
+ 'image/svg+xml',
19
+ ] as const;
20
+
21
+ export const DOCUMENT_CONTENT_TYPES = [
22
+ 'application/pdf',
23
+ 'application/zip',
24
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
25
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
26
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
27
+ ] as const;
28
+
29
+ export interface UploadPolicy {
30
+ readonly maxBytes: number;
31
+ readonly allowedContentTypes: readonly string[];
32
+ /** When true a candidate without a `checksum` is rejected, not silently trusted. */
33
+ readonly requireChecksum: boolean;
34
+ }
35
+
36
+ export interface UploadPolicyInit {
37
+ readonly maxBytes?: number | undefined;
38
+ readonly allowedContentTypes?: readonly string[] | undefined;
39
+ readonly requireChecksum?: boolean | undefined;
40
+ }
41
+
42
+ export function uploadPolicy(init: UploadPolicyInit = {}): UploadPolicy {
43
+ return {
44
+ maxBytes: init.maxBytes ?? DEFAULT_MAX_UPLOAD_BYTES,
45
+ allowedContentTypes: init.allowedContentTypes ?? IMAGE_CONTENT_TYPES,
46
+ requireChecksum: init.requireChecksum ?? false,
47
+ };
48
+ }
49
+
50
+ export interface UploadCandidate {
51
+ readonly key: string;
52
+ /** Whatever the client claimed. Trusted for nothing except the error message. */
53
+ readonly declaredContentType: string;
54
+ readonly bytes: Uint8Array;
55
+ /** base64 SHA-256. */
56
+ readonly checksum?: string | undefined;
57
+ }
58
+
59
+ export interface ValidatedUpload {
60
+ readonly key: string;
61
+ /** Safe to serve: the declared type, but only after the magic bytes agreed with it. */
62
+ readonly contentType: string;
63
+ readonly bytes: Uint8Array;
64
+ readonly size: number;
65
+ readonly checksum: string;
66
+ }
67
+
68
+ interface MagicRule {
69
+ readonly type: string;
70
+ readonly parts: readonly { readonly offset: number; readonly pattern: readonly number[] }[];
71
+ }
72
+
73
+ const ascii = (text: string): readonly number[] => [...text].map((char) => char.charCodeAt(0));
74
+
75
+ const MAGIC_RULES: readonly MagicRule[] = [
76
+ {
77
+ type: 'image/png',
78
+ parts: [{ offset: 0, pattern: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }],
79
+ },
80
+ { type: 'image/jpeg', parts: [{ offset: 0, pattern: [0xff, 0xd8, 0xff] }] },
81
+ { type: 'image/gif', parts: [{ offset: 0, pattern: ascii('GIF87a') }] },
82
+ { type: 'image/gif', parts: [{ offset: 0, pattern: ascii('GIF89a') }] },
83
+ {
84
+ type: 'image/webp',
85
+ parts: [
86
+ { offset: 0, pattern: ascii('RIFF') },
87
+ { offset: 8, pattern: ascii('WEBP') },
88
+ ],
89
+ },
90
+ { type: 'application/pdf', parts: [{ offset: 0, pattern: ascii('%PDF-') }] },
91
+ { type: 'video/mp4', parts: [{ offset: 4, pattern: ascii('ftyp') }] },
92
+ // Every OOXML document and epub is a zip; the container is as far as magic bytes go.
93
+ { type: 'application/zip', parts: [{ offset: 0, pattern: [0x50, 0x4b, 0x03, 0x04] }] },
94
+ ];
95
+
96
+ const matches = (bytes: Uint8Array, rule: MagicRule): boolean =>
97
+ rule.parts.every((part) =>
98
+ part.pattern.every((byte, index) => bytes[part.offset + index] === byte),
99
+ );
100
+
101
+ function sniffText(bytes: Uint8Array): string | undefined {
102
+ let text: string;
103
+ try {
104
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ for (let index = 0; index < text.length; index += 1) {
109
+ const code = text.charCodeAt(index);
110
+ const printable = code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d;
111
+ if (!printable) return undefined;
112
+ }
113
+ const head = text.slice(0, 512).trimStart().toLowerCase();
114
+ if (
115
+ head.startsWith('<!doctype html') ||
116
+ head.startsWith('<html') ||
117
+ head.startsWith('<head') ||
118
+ head.startsWith('<script') ||
119
+ head.startsWith('<body')
120
+ ) {
121
+ return 'text/html';
122
+ }
123
+ if (head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'))) {
124
+ return 'image/svg+xml';
125
+ }
126
+ return 'text/plain';
127
+ }
128
+
129
+ /** `undefined` means "no rule recognised it", never "it is fine". */
130
+ export function sniffContentType(bytes: Uint8Array): string | undefined {
131
+ for (const rule of MAGIC_RULES) {
132
+ if (matches(bytes, rule)) return rule.type;
133
+ }
134
+ return sniffText(bytes);
135
+ }
136
+
137
+ const ALIASES: Readonly<Record<string, string>> = {
138
+ 'image/jpg': 'image/jpeg',
139
+ 'image/x-png': 'image/png',
140
+ 'application/x-pdf': 'application/pdf',
141
+ };
142
+
143
+ /** Strip parameters and case: `IMAGE/PNG; charset=binary` and `image/png` are one type. */
144
+ export function normalizeContentType(value: string): string {
145
+ const base = (value.split(';')[0] ?? '').trim().toLowerCase();
146
+ return ALIASES[base] ?? base;
147
+ }
148
+
149
+ const ZIP_CONTAINERS = new Set<string>([...DOCUMENT_CONTENT_TYPES, 'application/epub+zip']);
150
+ const TEXT_FAMILY = new Set([
151
+ 'text/plain',
152
+ 'text/csv',
153
+ 'text/markdown',
154
+ 'application/json',
155
+ 'application/xml',
156
+ 'text/xml',
157
+ ]);
158
+
159
+ /** A generic sniff (zip container, plain text) may stand in for a specific declared type. */
160
+ export function contentTypeMatches(declared: string, sniffed: string): boolean {
161
+ const type = normalizeContentType(declared);
162
+ if (type === sniffed) return true;
163
+ if (sniffed === 'application/zip') return ZIP_CONTAINERS.has(type);
164
+ if (sniffed === 'text/plain') return TEXT_FAMILY.has(type);
165
+ return false;
166
+ }
167
+
168
+ /** Throws the first violated constraint. Order is cheapest-first: size, key, type, checksum. */
169
+ export function validateUpload(
170
+ candidate: UploadCandidate,
171
+ policy: UploadPolicy = uploadPolicy(),
172
+ ): ValidatedUpload {
173
+ const key = assertSafeKey(candidate.key);
174
+ const size = candidate.bytes.byteLength;
175
+ if (size > policy.maxBytes) throw tooLarge(key, size, policy.maxBytes);
176
+
177
+ const declared = normalizeContentType(candidate.declaredContentType);
178
+ if (!policy.allowedContentTypes.includes(declared)) {
179
+ throw contentTypeNotAllowed(key, declared, policy.allowedContentTypes);
180
+ }
181
+ const sniffed = sniffContentType(candidate.bytes);
182
+ if (sniffed !== undefined && !contentTypeMatches(declared, sniffed)) {
183
+ throw contentTypeMismatch(key, declared, sniffed);
184
+ }
185
+
186
+ const checksum = sha256Base64(candidate.bytes);
187
+ const claimed = candidate.checksum;
188
+ if (claimed !== undefined && claimed !== checksum) throw checksumMismatch(key, claimed, checksum);
189
+ if (claimed === undefined && policy.requireChecksum) {
190
+ throw checksumMismatch(key, 'none', checksum);
191
+ }
192
+ return { key, contentType: declared, bytes: candidate.bytes, size, checksum };
193
+ }