@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/errors.ts ADDED
@@ -0,0 +1,153 @@
1
+ // The X_* codes owned by @ultimat3/storage. Every `fix` is an exact edit or command: a
2
+ // rejected upload must tell the caller which constraint fired and where that constraint is
3
+ // configured, or the caller retries the same bytes forever.
4
+
5
+ import { errorDocsUrl, registerErrorCodes, UltimateError } from '@ultimat3/core';
6
+
7
+ /** Codes this package declares and owns. */
8
+ export const STORAGE_OWNED_ERROR_CODES = [
9
+ 'X_STORAGE_DISK_UNKNOWN',
10
+ 'X_STORAGE_NOT_FOUND',
11
+ 'X_STORAGE_PATH_UNSAFE',
12
+ 'X_STORAGE_TOO_LARGE',
13
+ 'X_STORAGE_TYPE_REJECTED',
14
+ 'X_STORAGE_CHECKSUM_MISMATCH',
15
+ ] as const;
16
+
17
+ /**
18
+ * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `storageNotImplemented()` throws it and this package
19
+ * keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
20
+ * `X_IMAGE_UNSUPPORTED` / `X_IMAGE_DECODE_FAILED` are core's too and surface unwrapped (`image.ts`).
21
+ */
22
+ export const STORAGE_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
23
+
24
+ /** Every code storage can throw through `StorageError`: the owned ones plus the borrowed one. */
25
+ export const STORAGE_ERROR_CODES = [
26
+ ...STORAGE_OWNED_ERROR_CODES,
27
+ ...STORAGE_BORROWED_ERROR_CODES,
28
+ ] as const;
29
+
30
+ export type StorageOwnedErrorCode = (typeof STORAGE_OWNED_ERROR_CODES)[number];
31
+ export type StorageErrorCode = (typeof STORAGE_ERROR_CODES)[number];
32
+
33
+ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string>> = {
34
+ X_STORAGE_DISK_UNKNOWN: 'no disk with that name is configured',
35
+ X_STORAGE_NOT_FOUND: 'no object at that key',
36
+ X_STORAGE_PATH_UNSAFE: 'object key escapes its prefix',
37
+ X_STORAGE_TOO_LARGE: 'payload exceeds the upload size limit',
38
+ X_STORAGE_TYPE_REJECTED: 'content type is not allowed for this upload',
39
+ X_STORAGE_CHECKSUM_MISMATCH: 'bytes do not match the declared checksum',
40
+ };
41
+
42
+ // One unconditional call, so a second package claiming one of storage's codes throws
43
+ // X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
44
+ registerErrorCodes(
45
+ Object.fromEntries(
46
+ Object.entries(STORAGE_ERROR_TITLES).map(([code, title]) => [code, { title }]),
47
+ ),
48
+ );
49
+
50
+ export interface StorageErrorInit {
51
+ readonly code: StorageErrorCode;
52
+ readonly cause: string;
53
+ readonly fix: string;
54
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
55
+ }
56
+
57
+ export class StorageError extends UltimateError {
58
+ override readonly name = 'StorageError';
59
+
60
+ constructor(init: StorageErrorInit) {
61
+ super({
62
+ code: init.code,
63
+ cause: init.cause,
64
+ fix: init.fix,
65
+ docs: errorDocsUrl(init.code),
66
+ meta: init.meta,
67
+ });
68
+ }
69
+ }
70
+
71
+ export function isStorageError(value: unknown): value is StorageError {
72
+ return value instanceof StorageError;
73
+ }
74
+
75
+ export const diskUnknown = (name: string, configured: readonly string[]): StorageError =>
76
+ new StorageError({
77
+ code: 'X_STORAGE_DISK_UNKNOWN',
78
+ cause: `no disk named "${name}" (configured: ${
79
+ configured.length > 0 ? configured.join(', ') : 'none'
80
+ })`,
81
+ fix: `add "${name}" to storage.disks in app.config.ts, or call disk('${
82
+ configured[0] ?? 'local'
83
+ }')`,
84
+ meta: { name, configured },
85
+ });
86
+
87
+ /**
88
+ * The `fix` is the shipped API call, not a CLI invocation: `x storage ls` is not in the command
89
+ * registry, so following it lands on `X_CLI_UNKNOWN_COMMAND` — an instruction that costs the
90
+ * reader a round-trip and teaches them a command that does not exist.
91
+ */
92
+ export const objectNotFound = (disk: string, key: string): StorageError =>
93
+ new StorageError({
94
+ code: 'X_STORAGE_NOT_FOUND',
95
+ cause: `disk "${disk}" has no object at "${key}"`,
96
+ fix: `disk('${disk}').list({ prefix: '${key.split('/').slice(0, -1).join('/')}' })`,
97
+ meta: { disk, key },
98
+ });
99
+
100
+ export const pathUnsafe = (key: string, reason: string): StorageError =>
101
+ new StorageError({
102
+ code: 'X_STORAGE_PATH_UNSAFE',
103
+ cause: `key ${JSON.stringify(key)} ${reason}`,
104
+ fix: 'build the key with scopedKey(orgId, ...parts) — relative, forward slashes, no ".."',
105
+ meta: { key, reason },
106
+ });
107
+
108
+ export const tooLarge = (key: string, bytes: number, maxBytes: number): StorageError =>
109
+ new StorageError({
110
+ code: 'X_STORAGE_TOO_LARGE',
111
+ cause: `"${key}" is ${bytes}B, over the policy limit of ${maxBytes}B`,
112
+ fix: `raise maxBytes in the upload policy (uploadPolicy({ maxBytes: ${bytes} })), or compress the file first`,
113
+ meta: { key, bytes, maxBytes },
114
+ });
115
+
116
+ /** The declared type is not on the allowlist at all. */
117
+ export const contentTypeNotAllowed = (
118
+ key: string,
119
+ declared: string,
120
+ allowed: readonly string[],
121
+ ): StorageError =>
122
+ new StorageError({
123
+ code: 'X_STORAGE_TYPE_REJECTED',
124
+ cause: `"${key}" declares ${declared}, which is not in the policy allowlist (${allowed.join(', ')})`,
125
+ fix: `add '${declared}' to allowedContentTypes in the upload policy, or upload one of: ${allowed.join(', ')}`,
126
+ meta: { key, declared, allowed },
127
+ });
128
+
129
+ /** The bytes say one thing and the client said another — the bytes win. */
130
+ export const contentTypeMismatch = (key: string, declared: string, sniffed: string): StorageError =>
131
+ new StorageError({
132
+ code: 'X_STORAGE_TYPE_REJECTED',
133
+ cause: `"${key}" declares ${declared} but its magic bytes are ${sniffed}`,
134
+ fix: `re-upload with Content-Type: ${sniffed}, or upload a genuine ${declared} file`,
135
+ meta: { key, declared, sniffed },
136
+ });
137
+
138
+ export const checksumMismatch = (key: string, declared: string, actual: string): StorageError =>
139
+ new StorageError({
140
+ code: 'X_STORAGE_CHECKSUM_MISMATCH',
141
+ cause: `"${key}" declared sha256 ${declared} but the bytes hash to ${actual}`,
142
+ fix: 'recompute the checksum over the exact bytes you send, or omit it and let the driver hash',
143
+ meta: { key, declared, actual },
144
+ });
145
+
146
+ /** An interface-complete driver whose remote half is not bound yet. Always carries a fix. */
147
+ export const storageNotImplemented = (feature: string, fix: string): StorageError =>
148
+ new StorageError({
149
+ code: 'X_NOT_IMPLEMENTED',
150
+ cause: `${feature} is declared but not implemented in @ultimat3/storage`,
151
+ fix,
152
+ meta: { feature },
153
+ });
package/src/image.ts ADDED
@@ -0,0 +1,148 @@
1
+ // Single responsibility: the image transform contract — deterministic variant keys and srcset
2
+ // math, plus the byte path bound to `@ultimat3/core`'s pipeline. `@ultimat3/seo` builds
3
+ // `<img srcset>` from `srcsetDescriptors()` without decoding a byte, and when it does need the
4
+ // bytes, `transformImage()` returns exactly the size `fitDimensions()` already promised.
5
+
6
+ import {
7
+ blurDataUrl,
8
+ BLUR_PLACEHOLDER_WIDTH as CORE_BLUR_PLACEHOLDER_WIDTH,
9
+ probeImage,
10
+ transformImageBytes,
11
+ } from '@ultimat3/core';
12
+ import { assertSafeKey, keyExtname } from './path';
13
+
14
+ export const IMAGE_FORMATS = ['avif', 'webp', 'jpeg', 'png'] as const;
15
+ export type ImageFormat = (typeof IMAGE_FORMATS)[number];
16
+
17
+ /** `cover` fills the box and crops the overflow; `contain` fits inside it, no crop. */
18
+ export type ImageFit = 'cover' | 'contain';
19
+
20
+ export interface ImageTransform {
21
+ readonly width?: number | undefined;
22
+ readonly height?: number | undefined;
23
+ readonly format?: ImageFormat | undefined;
24
+ /** 1-100. Omitted means the format default (`DEFAULT_QUALITY`). */
25
+ readonly quality?: number | undefined;
26
+ readonly fit?: ImageFit | undefined;
27
+ }
28
+
29
+ export interface ImageSize {
30
+ readonly width: number;
31
+ readonly height: number;
32
+ }
33
+
34
+ export const DEFAULT_QUALITY = 80;
35
+ export const DEFAULT_SRCSET_WIDTHS = [320, 640, 960, 1280, 1920] as const;
36
+ /** Small enough to inline in HTML; big enough to blur convincingly. Core owns the number. */
37
+ export const BLUR_PLACEHOLDER_WIDTH = CORE_BLUR_PLACEHOLDER_WIDTH;
38
+
39
+ const FORMAT_EXTENSIONS: Readonly<Record<ImageFormat, string>> = {
40
+ avif: 'avif',
41
+ webp: 'webp',
42
+ jpeg: 'jpg',
43
+ png: 'png',
44
+ };
45
+
46
+ /**
47
+ * Derived, not stored: the same source + transform always yields the same key, so a variant
48
+ * is a cache lookup rather than a database row.
49
+ */
50
+ export function variantKey(sourceKey: string, transform: ImageTransform): string {
51
+ const safe = assertSafeKey(sourceKey);
52
+ const stem = safe.slice(0, safe.length - keyExtname(safe).length);
53
+ const format = transform.format ?? 'webp';
54
+ const parts: string[] = [];
55
+ if (transform.width !== undefined) parts.push(`w${transform.width}`);
56
+ if (transform.height !== undefined) parts.push(`h${transform.height}`);
57
+ if (transform.fit !== undefined) parts.push(transform.fit);
58
+ const quality = transform.quality ?? DEFAULT_QUALITY;
59
+ if (quality !== DEFAULT_QUALITY) parts.push(`q${quality}`);
60
+ if (parts.length === 0) parts.push('full');
61
+ return assertSafeKey(`${stem}@${parts.join('-')}.${FORMAT_EXTENSIONS[format]}`);
62
+ }
63
+
64
+ export interface SrcsetDescriptor {
65
+ readonly width: number;
66
+ readonly key: string;
67
+ /** The `srcset` entry suffix, e.g. `640w`. */
68
+ readonly descriptor: string;
69
+ }
70
+
71
+ export interface SrcsetOptions {
72
+ readonly widths?: readonly number[] | undefined;
73
+ readonly format?: ImageFormat | undefined;
74
+ readonly quality?: number | undefined;
75
+ /** Intrinsic size of the source. Widths above it are dropped — upscaling is never useful. */
76
+ readonly intrinsic?: ImageSize | undefined;
77
+ }
78
+
79
+ /** The widths + variant keys `@ultimat3/seo` turns into a `srcset` attribute. */
80
+ export function srcsetDescriptors(
81
+ sourceKey: string,
82
+ options: SrcsetOptions = {},
83
+ ): readonly SrcsetDescriptor[] {
84
+ const widths = options.widths ?? DEFAULT_SRCSET_WIDTHS;
85
+ const max = options.intrinsic?.width;
86
+ return widths
87
+ .filter((width) => width > 0 && (max === undefined || width <= max))
88
+ .map((width) => ({
89
+ width,
90
+ key: variantKey(sourceKey, {
91
+ width,
92
+ format: options.format ?? 'webp',
93
+ quality: options.quality,
94
+ }),
95
+ descriptor: `${width}w`,
96
+ }));
97
+ }
98
+
99
+ /** Aspect-ratio fitting. `cover` rounds up so the box is always fully covered. */
100
+ export function fitDimensions(source: ImageSize, transform: ImageTransform): ImageSize {
101
+ const ratio = source.height / source.width;
102
+ const { width, height } = transform;
103
+ if (width === undefined && height === undefined) return source;
104
+ if (height === undefined) {
105
+ const target = Math.min(width ?? source.width, source.width);
106
+ return { width: target, height: Math.round(target * ratio) };
107
+ }
108
+ if (width === undefined) {
109
+ const target = Math.min(height, source.height);
110
+ return { width: Math.round(target / ratio), height: target };
111
+ }
112
+ // `cover` crops to the exact box; `contain` scales down until both sides fit.
113
+ if (transform.fit === 'cover') return { width, height };
114
+ const scale = Math.min(width / source.width, height / source.height);
115
+ return { width: Math.round(source.width * scale), height: Math.round(source.height * scale) };
116
+ }
117
+
118
+ /**
119
+ * Decode, resize, encode — core's pipeline, which encodes **png and jpeg only**. `avif` and
120
+ * `webp` stay key/`srcset` math: asking for their bytes rejects with core's
121
+ * `X_IMAGE_UNSUPPORTED` (not re-wrapped — one failure, one code), and producing them means a
122
+ * CDN or a custom `ImageTransformDriver`. PNG is also the only output that keeps alpha.
123
+ *
124
+ * The output box is `fitDimensions()`, always: that is the size `@ultimat3/seo` has already
125
+ * written into the `<img>` tag, and bytes that disagreed with it would be the layout shift
126
+ * this whole path exists to prevent.
127
+ */
128
+ export async function transformImage(
129
+ bytes: Uint8Array,
130
+ transform: ImageTransform,
131
+ ): Promise<Uint8Array> {
132
+ // Header read, not a decode — the source size is needed before the box can be chosen.
133
+ const size = fitDimensions(probeImage(bytes), transform);
134
+ return transformImageBytes(bytes, {
135
+ width: size.width,
136
+ height: size.height,
137
+ // The box already carries the fitted aspect ratio, so `cover` crops nothing real; it is
138
+ // what stops a rounded edge leaving a transparent (in JPEG: black) sliver inside it.
139
+ fit: 'cover',
140
+ format: transform.format ?? 'webp',
141
+ quality: transform.quality ?? DEFAULT_QUALITY,
142
+ });
143
+ }
144
+
145
+ /** A `data:` URI small enough to inline as the LQIP behind a real image. Always PNG. */
146
+ export async function blurPlaceholder(bytes: Uint8Array): Promise<string> {
147
+ return blurDataUrl(bytes, BLUR_PLACEHOLDER_WIDTH);
148
+ }
package/src/index.ts ADDED
@@ -0,0 +1,111 @@
1
+ // Single responsibility: the public API of @ultimat3/storage. Explicit named exports only —
2
+ // every consumer imports from here, so this list is the package's contract.
3
+
4
+ export type {
5
+ ListOptions,
6
+ ListPage,
7
+ PutOptions,
8
+ SignedUrlMethod,
9
+ SignedUrlOptions,
10
+ StorageBody,
11
+ StorageDriver,
12
+ StorageObject,
13
+ StorageRead,
14
+ } from './driver';
15
+ export {
16
+ DEFAULT_CONTENT_TYPE,
17
+ DEFAULT_LIST_LIMIT,
18
+ etagOf,
19
+ sha256Base64,
20
+ toBytes,
21
+ } from './driver';
22
+ export type { LocalDriverOptions } from './driver-local';
23
+ export { localDriver } from './driver-local';
24
+ export type {
25
+ S3ClientLike,
26
+ S3DriverOptions,
27
+ S3FileLike,
28
+ S3ListEntryLike,
29
+ S3ListResultLike,
30
+ S3StatLike,
31
+ } from './driver-s3';
32
+ export { s3Driver } from './driver-s3';
33
+ export type { StorageErrorCode, StorageErrorInit } from './errors';
34
+ export {
35
+ checksumMismatch,
36
+ contentTypeMismatch,
37
+ contentTypeNotAllowed,
38
+ diskUnknown,
39
+ isStorageError,
40
+ objectNotFound,
41
+ pathUnsafe,
42
+ STORAGE_ERROR_CODES,
43
+ STORAGE_ERROR_TITLES,
44
+ StorageError,
45
+ storageNotImplemented,
46
+ tooLarge,
47
+ } from './errors';
48
+ export type {
49
+ ImageFit,
50
+ ImageFormat,
51
+ ImageSize,
52
+ ImageTransform,
53
+ SrcsetDescriptor,
54
+ SrcsetOptions,
55
+ } from './image';
56
+ export {
57
+ BLUR_PLACEHOLDER_WIDTH,
58
+ blurPlaceholder,
59
+ DEFAULT_QUALITY,
60
+ DEFAULT_SRCSET_WIDTHS,
61
+ fitDimensions,
62
+ IMAGE_FORMATS,
63
+ srcsetDescriptors,
64
+ transformImage,
65
+ variantKey,
66
+ } from './image';
67
+
68
+ export {
69
+ assertSafeKey,
70
+ isSafeKey,
71
+ isWithinOrg,
72
+ joinKey,
73
+ keyDirname,
74
+ keyExtname,
75
+ MAX_KEY_LENGTH,
76
+ ORG_PREFIX,
77
+ orgPrefix,
78
+ scopedKey,
79
+ } from './path';
80
+ export type {
81
+ SignedUrlConstraints,
82
+ SignedUrlFailure,
83
+ SignedUrlInput,
84
+ SignedUrlVerification,
85
+ VerifySignedUrlInput,
86
+ } from './signed-url';
87
+ export {
88
+ buildSignedUrl,
89
+ canonicalRequest,
90
+ DEFAULT_SIGNED_URL_BASE,
91
+ DEFAULT_SIGNED_URL_TTL_MS,
92
+ SIGNED_URL_FAILURES,
93
+ SIGNED_URL_PARAMS,
94
+ SIGNED_URL_VERSION,
95
+ signConstraints,
96
+ timingSafeEqual,
97
+ verifySignedUrl,
98
+ } from './signed-url';
99
+ export type { Storage, StorageConfig } from './storage';
100
+ export { defineStorage, disk, resetStorage, storage } from './storage';
101
+ export type { UploadCandidate, UploadPolicy, UploadPolicyInit, ValidatedUpload } from './upload';
102
+ export {
103
+ contentTypeMatches,
104
+ DEFAULT_MAX_UPLOAD_BYTES,
105
+ DOCUMENT_CONTENT_TYPES,
106
+ IMAGE_CONTENT_TYPES,
107
+ normalizeContentType,
108
+ sniffContentType,
109
+ uploadPolicy,
110
+ validateUpload,
111
+ } from './upload';
package/src/path.ts ADDED
@@ -0,0 +1,99 @@
1
+ // Single responsibility: object keys. Every key that reaches a driver passes through here,
2
+ // because one `..` in a user-supplied filename turns a local disk into arbitrary file write
3
+ // and an S3 disk into a cross-tenant read. Rejection is total: no sanitising, no rewriting —
4
+ // a key that needed fixing was built wrong, and silently fixing it hides the bug.
5
+
6
+ import { pathUnsafe } from './errors';
7
+
8
+ /** S3's own limit; keeping local and remote disks interchangeable requires the same ceiling. */
9
+ export const MAX_KEY_LENGTH = 1024;
10
+ export const ORG_PREFIX = 'org';
11
+
12
+ // `%2e%2e%2f` decodes to `../` in any layer that decodes twice (proxy, then framework).
13
+ const ENCODED_SEPARATOR = /%(?:2e|2f|5c|00)/i;
14
+
15
+ /** NUL and friends: a C-string API downstream truncates at the NUL and opens a different file. */
16
+ function hasControlByte(key: string): boolean {
17
+ for (let index = 0; index < key.length; index += 1) {
18
+ const code = key.charCodeAt(index);
19
+ if (code < 0x20 || code === 0x7f) return true;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ function unsafeReason(key: string): string | undefined {
25
+ if (key.length === 0) return 'is empty';
26
+ if (key.length > MAX_KEY_LENGTH) {
27
+ return `is ${key.length} chars, over the ${MAX_KEY_LENGTH} limit`;
28
+ }
29
+ if (hasControlByte(key)) return 'contains a NUL or control byte';
30
+ if (key.includes('\\')) return 'contains a backslash';
31
+ if (key.startsWith('/')) return 'is absolute (leading "/")';
32
+ if (ENCODED_SEPARATOR.test(key)) return 'contains a percent-encoded separator (%2e/%2f/%5c)';
33
+ for (const segment of key.split('/')) {
34
+ if (segment.length === 0) return 'contains an empty segment ("//" or a trailing "/")';
35
+ if (segment === '.' || segment === '..') return `contains a "${segment}" segment`;
36
+ if (segment !== segment.trim()) return `has a padded segment ${JSON.stringify(segment)}`;
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ export function isSafeKey(key: string): boolean {
42
+ return unsafeReason(key) === undefined;
43
+ }
44
+
45
+ /** Returns the key unchanged, so it composes: `Bun.file(join(root, assertSafeKey(key)))`. */
46
+ export function assertSafeKey(key: string): string {
47
+ const reason = unsafeReason(key);
48
+ if (reason !== undefined) throw pathUnsafe(key, reason);
49
+ return key;
50
+ }
51
+
52
+ /**
53
+ * Join parts into one validated key. Parts may themselves contain `/`; every resulting
54
+ * segment is checked, so a part of `../other` fails instead of escaping.
55
+ */
56
+ export function joinKey(...parts: readonly string[]): string {
57
+ const segments: string[] = [];
58
+ for (const part of parts) {
59
+ for (const segment of part.split('/')) segments.push(segment);
60
+ }
61
+ return assertSafeKey(segments.join('/'));
62
+ }
63
+
64
+ /** An org id is exactly one segment. `a/b` would silently widen the tenant namespace. */
65
+ function assertOrgId(orgId: string): string {
66
+ if (orgId.length === 0) throw pathUnsafe(orgId, 'is an empty org id');
67
+ if (orgId.includes('/') || orgId.includes('\\')) {
68
+ throw pathUnsafe(orgId, 'is an org id containing a path separator');
69
+ }
70
+ return orgId;
71
+ }
72
+
73
+ /** `org/<orgId>/` — the tenant boundary, present in every multi-tenant key. */
74
+ export function orgPrefix(orgId: string): string {
75
+ return `${joinKey(ORG_PREFIX, assertOrgId(orgId))}/`;
76
+ }
77
+
78
+ /** The only blessed way to build a tenant-scoped key. */
79
+ export function scopedKey(orgId: string, ...parts: readonly string[]): string {
80
+ return joinKey(ORG_PREFIX, assertOrgId(orgId), ...parts);
81
+ }
82
+
83
+ /** Guard for read paths: a key handed in by a client must still belong to the actor's org. */
84
+ export function isWithinOrg(key: string, orgId: string): boolean {
85
+ return isSafeKey(key) && key.startsWith(orgPrefix(orgId));
86
+ }
87
+
88
+ /** `org/o1/a/b.png` -> `org/o1/a`. Empty for a top-level key. */
89
+ export function keyDirname(key: string): string {
90
+ const cut = key.lastIndexOf('/');
91
+ return cut === -1 ? '' : key.slice(0, cut);
92
+ }
93
+
94
+ /** `a/b.tar.gz` -> `.gz`. Empty when the basename has no dot. */
95
+ export function keyExtname(key: string): string {
96
+ const base = key.slice(key.lastIndexOf('/') + 1);
97
+ const dot = base.lastIndexOf('.');
98
+ return dot <= 0 ? '' : base.slice(dot);
99
+ }
@@ -0,0 +1,185 @@
1
+ // Single responsibility: time-limited signed URLs for direct upload and download.
2
+ // The HMAC covers the *constraints* (key, method, expiry, max size, content type), not just
3
+ // the key — otherwise a client that receives a URL for a 2MB PNG edits `?x-max=` and uploads
4
+ // a 2GB executable. Verification never throws and never short-circuits on expiry before the
5
+ // signature, so a forged URL can never learn "the signature was fine, just late".
6
+
7
+ import { type Clock, systemClock } from '@ultimat3/core';
8
+ import type { SignedUrlMethod } from './driver';
9
+ import { assertSafeKey, isSafeKey } from './path';
10
+
11
+ export const SIGNED_URL_VERSION = 'v1';
12
+ export const DEFAULT_SIGNED_URL_TTL_MS = 900_000;
13
+ /** The dev server mounts the download/upload route here; S3 disks never use it. */
14
+ export const DEFAULT_SIGNED_URL_BASE = '/_storage';
15
+
16
+ export const SIGNED_URL_PARAMS = {
17
+ method: 'x-method',
18
+ expires: 'x-exp',
19
+ maxBytes: 'x-max',
20
+ contentType: 'x-ct',
21
+ signature: 'x-sig',
22
+ } as const;
23
+
24
+ export interface SignedUrlConstraints {
25
+ readonly key: string;
26
+ readonly method: SignedUrlMethod;
27
+ /** Epoch ms. */
28
+ readonly expiresAt: number;
29
+ readonly maxBytes: number | undefined;
30
+ readonly contentType: string | undefined;
31
+ }
32
+
33
+ export interface SignedUrlInput {
34
+ /** Never a literal in app.config.ts — read it from an env var. */
35
+ readonly secret: string;
36
+ readonly key: string;
37
+ readonly method?: SignedUrlMethod | undefined;
38
+ readonly expiresInMs?: number | undefined;
39
+ readonly maxBytes?: number | undefined;
40
+ readonly contentType?: string | undefined;
41
+ readonly baseUrl?: string | undefined;
42
+ readonly clock?: Clock | undefined;
43
+ }
44
+
45
+ export const SIGNED_URL_FAILURES = [
46
+ 'malformed',
47
+ 'unsafe-key',
48
+ 'signature-mismatch',
49
+ 'expired',
50
+ ] as const;
51
+
52
+ export type SignedUrlFailure = (typeof SIGNED_URL_FAILURES)[number];
53
+
54
+ export type SignedUrlVerification =
55
+ | { readonly ok: true; readonly constraints: SignedUrlConstraints }
56
+ | { readonly ok: false; readonly reason: SignedUrlFailure; readonly detail: string };
57
+
58
+ /** Newline-separated and order-fixed: an ambiguous canonical form is a forgeable one. */
59
+ export function canonicalRequest(constraints: SignedUrlConstraints): string {
60
+ return [
61
+ SIGNED_URL_VERSION,
62
+ constraints.method,
63
+ constraints.key,
64
+ String(constraints.expiresAt),
65
+ constraints.maxBytes === undefined ? '' : String(constraints.maxBytes),
66
+ constraints.contentType ?? '',
67
+ ].join('\n');
68
+ }
69
+
70
+ const encoder = new TextEncoder();
71
+
72
+ export async function signConstraints(
73
+ secret: string,
74
+ constraints: SignedUrlConstraints,
75
+ ): Promise<string> {
76
+ const key = await crypto.subtle.importKey(
77
+ 'raw',
78
+ encoder.encode(secret),
79
+ { name: 'HMAC', hash: 'SHA-256' },
80
+ false,
81
+ ['sign'],
82
+ );
83
+ const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(canonicalRequest(constraints)));
84
+ return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
85
+ }
86
+
87
+ /** Length is public (fixed-width hex); the byte comparison must not early-exit. */
88
+ export function timingSafeEqual(a: string, b: string): boolean {
89
+ if (a.length !== b.length) return false;
90
+ let diff = 0;
91
+ for (let index = 0; index < a.length; index += 1) {
92
+ diff |= a.charCodeAt(index) ^ b.charCodeAt(index);
93
+ }
94
+ return diff === 0;
95
+ }
96
+
97
+ const trimBase = (base: string): string => base.replace(/\/+$/, '');
98
+ const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
99
+
100
+ export async function buildSignedUrl(input: SignedUrlInput): Promise<string> {
101
+ const key = assertSafeKey(input.key);
102
+ const clock = input.clock ?? systemClock;
103
+ const constraints: SignedUrlConstraints = {
104
+ key,
105
+ method: input.method ?? 'GET',
106
+ expiresAt: clock.now().getTime() + (input.expiresInMs ?? DEFAULT_SIGNED_URL_TTL_MS),
107
+ maxBytes: input.maxBytes,
108
+ contentType: input.contentType,
109
+ };
110
+ const params = new URLSearchParams();
111
+ params.set(SIGNED_URL_PARAMS.method, constraints.method);
112
+ params.set(SIGNED_URL_PARAMS.expires, String(constraints.expiresAt));
113
+ if (constraints.maxBytes !== undefined) {
114
+ params.set(SIGNED_URL_PARAMS.maxBytes, String(constraints.maxBytes));
115
+ }
116
+ if (constraints.contentType !== undefined) {
117
+ params.set(SIGNED_URL_PARAMS.contentType, constraints.contentType);
118
+ }
119
+ params.set(SIGNED_URL_PARAMS.signature, await signConstraints(input.secret, constraints));
120
+ const base = trimBase(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE);
121
+ return `${base}/${encodeKey(key)}?${params.toString()}`;
122
+ }
123
+
124
+ export interface VerifySignedUrlInput {
125
+ /** Absolute or route-relative — both parse. */
126
+ readonly url: string;
127
+ readonly secret: string;
128
+ readonly baseUrl?: string | undefined;
129
+ readonly clock?: Clock | undefined;
130
+ }
131
+
132
+ const fail = (reason: SignedUrlFailure, detail: string): SignedUrlVerification => ({
133
+ ok: false,
134
+ reason,
135
+ detail,
136
+ });
137
+
138
+ function parseConstraints(url: URL, base: string): SignedUrlConstraints | SignedUrlFailure {
139
+ if (!url.pathname.startsWith(`${base}/`)) return 'malformed';
140
+ const key = url.pathname
141
+ .slice(base.length + 1)
142
+ .split('/')
143
+ .map(decodeURIComponent)
144
+ .join('/');
145
+ if (!isSafeKey(key)) return 'unsafe-key';
146
+ const method = url.searchParams.get(SIGNED_URL_PARAMS.method) ?? 'GET';
147
+ if (method !== 'GET' && method !== 'PUT') return 'malformed';
148
+ const expiresAt = Number(url.searchParams.get(SIGNED_URL_PARAMS.expires));
149
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return 'malformed';
150
+ const rawMax = url.searchParams.get(SIGNED_URL_PARAMS.maxBytes);
151
+ const maxBytes = rawMax === null ? undefined : Number(rawMax);
152
+ if (maxBytes !== undefined && (!Number.isSafeInteger(maxBytes) || maxBytes < 0)) {
153
+ return 'malformed';
154
+ }
155
+ return {
156
+ key,
157
+ method,
158
+ expiresAt,
159
+ maxBytes,
160
+ contentType: url.searchParams.get(SIGNED_URL_PARAMS.contentType) ?? undefined,
161
+ };
162
+ }
163
+
164
+ export async function verifySignedUrl(input: VerifySignedUrlInput): Promise<SignedUrlVerification> {
165
+ let url: URL;
166
+ try {
167
+ url = new URL(input.url, 'http://storage.invalid');
168
+ } catch {
169
+ return fail('malformed', `${input.url} is not a URL`);
170
+ }
171
+ const signature = url.searchParams.get(SIGNED_URL_PARAMS.signature);
172
+ if (signature === null) return fail('malformed', `no ${SIGNED_URL_PARAMS.signature} parameter`);
173
+ const parsed = parseConstraints(url, trimBase(input.baseUrl ?? DEFAULT_SIGNED_URL_BASE));
174
+ if (typeof parsed === 'string') return fail(parsed, `${url.pathname} is not a signable request`);
175
+
176
+ const expected = await signConstraints(input.secret, parsed);
177
+ if (!timingSafeEqual(expected, signature)) {
178
+ return fail('signature-mismatch', 'the constraints do not match the signature');
179
+ }
180
+ const now = (input.clock ?? systemClock).now().getTime();
181
+ if (now > parsed.expiresAt) {
182
+ return fail('expired', `expired ${now - parsed.expiresAt}ms ago`);
183
+ }
184
+ return { ok: true, constraints: parsed };
185
+ }