@12-apps/storage 2.4.0 → 2.6.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/package.json +4 -3
- package/src/en-US.ts +30 -0
- package/src/index.ts +2 -0
- package/src/locales.ts +31 -0
- package/src/problems.ts +9 -0
- package/src/react/en-US.ts +49 -0
- package/src/react/index.ts +2 -0
- package/src/react/locales.ts +18 -0
- package/src/server/create-api-storage.ts +6 -3
- package/src/server/routes.ts +21 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/storage",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Image uploads that land at YOUR OWN ORIGIN, never at the bucket (12-20). Isomorphic core (format allowlist, upload ceiling, object-key grammar, rendition set, refusal taxonomy, wire schemas), host-mounted backend surface (./server: createApiStorage, the local-disk driver, the image-pipeline port, the orphan reclaim), Hono adapter (./hono), the S3-compatible driver behind its own subpath (./s3) and the bound picker + upload hook (./react). Standardized adoption contract in ADOPTING.md.",
|
|
6
6
|
"exports": {
|
|
@@ -43,13 +43,14 @@
|
|
|
43
43
|
}
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@12-apps/ui": "^6.
|
|
46
|
+
"@12-apps/ui": "^6.9.0",
|
|
47
47
|
"zod": "^4.3.5"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@12-apps/eslint-config": "^1.21.1",
|
|
51
|
+
"@12-apps/i18n": "^1.1.0",
|
|
51
52
|
"@12-apps/typescript-config": "^1.20.1",
|
|
52
|
-
"@12-apps/wiring": "^1.
|
|
53
|
+
"@12-apps/wiring": "^1.11.0",
|
|
53
54
|
"@aws-sdk/client-s3": "^3.703.0",
|
|
54
55
|
"@testing-library/react": "^16.1.0",
|
|
55
56
|
"@types/react": "19.2.2",
|
package/src/en-US.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { StorageMessageContext, StorageMessages } from './problems';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The en-US pack — a NAMED factory a host passes by hand
|
|
5
|
+
* (`messages: EN_US_STORAGE_MESSAGES`), never a default. The filename is what
|
|
6
|
+
* exempts this file from the copy-portability gate, as `pt-BR.ts` beside it is
|
|
7
|
+
* exempt.
|
|
8
|
+
*
|
|
9
|
+
* `limit` is interpolated rather than hard-coded, for the reason the pt-BR pack
|
|
10
|
+
* gives: a host that raises `maxBytes` must not end up with a message naming
|
|
11
|
+
* the old number, which is the single commonest way this kind of copy goes
|
|
12
|
+
* stale. A translation that inlined the number would reintroduce exactly that.
|
|
13
|
+
*/
|
|
14
|
+
export function EN_US_STORAGE_MESSAGES(context: StorageMessageContext): StorageMessages {
|
|
15
|
+
return {
|
|
16
|
+
invalid_base64: 'That image is not correctly base64-encoded.',
|
|
17
|
+
empty_file: 'That image is empty.',
|
|
18
|
+
file_too_large: `That image is larger than the ${context.limit} limit.`,
|
|
19
|
+
content_mismatch:
|
|
20
|
+
'The image contents do not match the format you declared. Send a PNG, JPEG, WebP or GIF.',
|
|
21
|
+
unsupported_content_type: 'Unsupported format. Send a PNG, JPG, WebP or GIF.',
|
|
22
|
+
image_unreadable: 'That image could not be read — the file appears to be corrupted.',
|
|
23
|
+
image_dimensions_too_large: 'That image is too large in dimensions to process.',
|
|
24
|
+
storage_not_configured: 'Image storage has not been set up for this store yet.',
|
|
25
|
+
storage_unavailable: 'That image could not be saved right now. Try again in a moment.',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The 401 the mounted router answers when the host resolves no actor. */
|
|
30
|
+
export const EN_US_STORAGE_UNAUTHENTICATED = 'Not authenticated.';
|
package/src/index.ts
CHANGED
|
@@ -76,6 +76,8 @@ export {
|
|
|
76
76
|
type StorageProblem,
|
|
77
77
|
} from './problems';
|
|
78
78
|
export { PT_BR_STORAGE_MESSAGES, PT_BR_STORAGE_UNAUTHENTICATED } from './pt-BR';
|
|
79
|
+
export { EN_US_STORAGE_MESSAGES, EN_US_STORAGE_UNAUTHENTICATED } from './en-US';
|
|
80
|
+
export { STORAGE_MESSAGES, STORAGE_UNAUTHENTICATED } from './locales';
|
|
79
81
|
|
|
80
82
|
export {
|
|
81
83
|
imageSources,
|
package/src/locales.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { EN_US_STORAGE_MESSAGES, EN_US_STORAGE_UNAUTHENTICATED } from './en-US';
|
|
2
|
+
import type { StorageMessageContext, StorageMessages } from './problems';
|
|
3
|
+
import { PT_BR_STORAGE_MESSAGES, PT_BR_STORAGE_UNAUTHENTICATED } from './pt-BR';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Both languages, keyed by tag — what a host hands to `@12-apps/i18n` when the
|
|
7
|
+
* reader's language is a property of the request rather than of the deployment.
|
|
8
|
+
*
|
|
9
|
+
* The pack holds FACTORIES rather than tables, because that is what this
|
|
10
|
+
* package's copy already is: `limit` is interpolated so a host that raises
|
|
11
|
+
* `maxBytes` cannot end up with a message naming the old number. `localeCopy`
|
|
12
|
+
* therefore hands back a factory, and the host calls it with its context:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const messages = localeCopy(STORAGE_MESSAGES)({ locale })({ limit });
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* `LocalePack` is mirrored here rather than imported so the package stays
|
|
19
|
+
* liftable into a repo that has never heard of `@12-apps/i18n`.
|
|
20
|
+
*/
|
|
21
|
+
type LocalePack<T> = { readonly 'pt-BR': T; readonly 'en-US': T };
|
|
22
|
+
|
|
23
|
+
export const STORAGE_MESSAGES = {
|
|
24
|
+
'pt-BR': PT_BR_STORAGE_MESSAGES,
|
|
25
|
+
'en-US': EN_US_STORAGE_MESSAGES,
|
|
26
|
+
} as const satisfies LocalePack<(context: StorageMessageContext) => StorageMessages>;
|
|
27
|
+
|
|
28
|
+
export const STORAGE_UNAUTHENTICATED = {
|
|
29
|
+
'pt-BR': PT_BR_STORAGE_UNAUTHENTICATED,
|
|
30
|
+
'en-US': EN_US_STORAGE_UNAUTHENTICATED,
|
|
31
|
+
} as const satisfies LocalePack<string>;
|
package/src/problems.ts
CHANGED
|
@@ -54,6 +54,15 @@ export type StorageMessages = Readonly<Record<StorageProblem, string>>;
|
|
|
54
54
|
export interface StorageMessageContext {
|
|
55
55
|
/** `8 MB`, already formatted. */
|
|
56
56
|
limit: string;
|
|
57
|
+
/**
|
|
58
|
+
* The language to answer this caller in, as a BCP-47 tag.
|
|
59
|
+
*
|
|
60
|
+
* This field is why `messages` was already the right SHAPE: a factory called
|
|
61
|
+
* per refusal can answer per reader, where a value handed over at the mount
|
|
62
|
+
* cannot. Absent means the host's adapter populated nothing — a single
|
|
63
|
+
* -audience host — and the factory should then answer as it always did.
|
|
64
|
+
*/
|
|
65
|
+
locale?: string;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
/**
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { WebStorageMessageContext, WebStorageMessages } from './failures';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The en-US pack — a NAMED factory a host passes by hand, never a default.
|
|
5
|
+
* `limit` is interpolated for the same reason the server pack does it: a host
|
|
6
|
+
* that raises the ceiling must not end up with copy naming the old number.
|
|
7
|
+
*/
|
|
8
|
+
export function EN_US_WEB_STORAGE_MESSAGES(
|
|
9
|
+
context: WebStorageMessageContext,
|
|
10
|
+
): WebStorageMessages {
|
|
11
|
+
return {
|
|
12
|
+
forbidden: 'Your account may not upload images for this store.',
|
|
13
|
+
unsupported_content_type: 'Unsupported format. Send a PNG, JPG, WebP or GIF.',
|
|
14
|
+
file_too_large: `That image is too large. The limit is ${context.limit}.`,
|
|
15
|
+
invalid_key: 'That image could not be found.',
|
|
16
|
+
not_found: 'That image is no longer available.',
|
|
17
|
+
session_expired: 'Your session has expired. Sign in again and repeat the upload.',
|
|
18
|
+
transport: 'Could not reach the server. Check your connection and try again.',
|
|
19
|
+
empty_file: 'Empty file (0 bytes). Pick the image again.',
|
|
20
|
+
file_too_large_upfront: ({ size, limit }) =>
|
|
21
|
+
`That image is too large: ${size}. The limit is ${limit} — shrink it and try again.`,
|
|
22
|
+
unknown_content_type: 'Could not identify the file type. Send a PNG, JPG or WebP.',
|
|
23
|
+
unsupported_content_type_upfront: ({ contentType }) =>
|
|
24
|
+
`Unsupported format (${contentType}). Send a PNG, JPG, WebP or GIF.`,
|
|
25
|
+
missing_key: 'The server accepted the image but returned no key. Try again.',
|
|
26
|
+
upload_failed_http: ({ status, detail }) =>
|
|
27
|
+
`Could not upload that image (HTTP ${status}${detail}). Try again in a moment.`,
|
|
28
|
+
upload_failed: 'Uploading that image failed. Try again in a moment.',
|
|
29
|
+
|
|
30
|
+
pageTitle: 'Store images',
|
|
31
|
+
pageIntro: `Send a PNG, JPG, WebP or GIF of up to ${context.limit}.`,
|
|
32
|
+
fieldLabel: 'Product photo',
|
|
33
|
+
fieldHelper: 'The image is resized in the browser before it is sent.',
|
|
34
|
+
fieldRemove: 'Remove image',
|
|
35
|
+
field: {
|
|
36
|
+
// `buttonLabel` is never read here — `ImageField` overrides it with
|
|
37
|
+
// `fieldLabel`. It is still answered, because the type is the whole of
|
|
38
|
+
// what `UploadButton` may render and a partial answer is not a pack.
|
|
39
|
+
buttonLabel: 'Product photo',
|
|
40
|
+
dropzoneHint: 'Drop the image here, or click to choose one',
|
|
41
|
+
dropzoneRole: (label) => `Image upload area. ${label}`,
|
|
42
|
+
uploading: 'Uploading…',
|
|
43
|
+
dropReady: 'Image ready to drop',
|
|
44
|
+
percentUploaded: (percent) => `${percent}% uploaded`,
|
|
45
|
+
uploadInProgress: (percent) => `Upload in progress: ${percent}%`,
|
|
46
|
+
errorAnnouncement: (message) => `Error: ${message}`,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { EN_US_WEB_STORAGE_MESSAGES } from './en-US';
|
|
2
|
+
import type { WebStorageMessageContext, WebStorageMessages } from './failures';
|
|
3
|
+
import { PT_BR_WEB_STORAGE_MESSAGES } from './pt-BR';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Both languages of the browser half, keyed by tag. Factories, for the reason
|
|
7
|
+
* the server pack's docstring gives: the ceiling is interpolated so copy cannot
|
|
8
|
+
* name a limit the host has since changed.
|
|
9
|
+
*
|
|
10
|
+
* `LocalePack` is mirrored here rather than imported so the package stays
|
|
11
|
+
* liftable into a repo that has never heard of `@12-apps/i18n`.
|
|
12
|
+
*/
|
|
13
|
+
type LocalePack<T> = { readonly 'pt-BR': T; readonly 'en-US': T };
|
|
14
|
+
|
|
15
|
+
export const WEB_STORAGE_MESSAGES = {
|
|
16
|
+
'pt-BR': PT_BR_WEB_STORAGE_MESSAGES,
|
|
17
|
+
'en-US': EN_US_WEB_STORAGE_MESSAGES,
|
|
18
|
+
} as const satisfies LocalePack<(context: WebStorageMessageContext) => WebStorageMessages>;
|
|
@@ -172,11 +172,14 @@ const SILENT: StorageLogger = { error: () => undefined };
|
|
|
172
172
|
export function createApiStorage(config: ApiStorageConfig): ApiStorage {
|
|
173
173
|
const specs = config.renditions ?? CATALOG_RENDITIONS;
|
|
174
174
|
const keyPrefix = config.keyPrefix ?? DEFAULT_KEY_PREFIX;
|
|
175
|
-
const
|
|
175
|
+
const limit = megabytes(config.maxBytes);
|
|
176
|
+
// A host write holds no request and no reader, so it takes the words resolved
|
|
177
|
+
// in the deployment's own language. The ROUTES resolve per caller instead —
|
|
178
|
+
// see `storageRoutes` below, which is handed the factory rather than this.
|
|
176
179
|
const writeDeps: StoreImageDeps = {
|
|
177
180
|
driver: config.driver,
|
|
178
181
|
pipeline: config.imagePipeline,
|
|
179
|
-
messages,
|
|
182
|
+
messages: config.messages({ limit }),
|
|
180
183
|
maxBytes: config.maxBytes,
|
|
181
184
|
specs,
|
|
182
185
|
keyPrefix,
|
|
@@ -192,7 +195,7 @@ export function createApiStorage(config: ApiStorageConfig): ApiStorage {
|
|
|
192
195
|
const resolve = (key: string): string => config.driver.publicUrl(key);
|
|
193
196
|
|
|
194
197
|
return {
|
|
195
|
-
routes: storageRoutes(writeDeps),
|
|
198
|
+
routes: storageRoutes({ ...writeDeps, messages: config.messages, limit }),
|
|
196
199
|
limits: {
|
|
197
200
|
maxBytes: config.maxBytes,
|
|
198
201
|
maxBytesLabel: megabytes(config.maxBytes),
|
package/src/server/routes.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CONTENT_TYPE_BY_EXTENSION, acceptedContentTypeOf } from '../content-types';
|
|
2
2
|
import { parseObjectMemberKey } from '../keys';
|
|
3
3
|
import { STORAGE_PATHS } from '../paths';
|
|
4
|
-
import { isStorageProblem, type StorageMessages } from '../problems';
|
|
4
|
+
import { isStorageProblem, type StorageMessageContext, type StorageMessages } from '../problems';
|
|
5
5
|
import type { StorageDriver } from './driver';
|
|
6
6
|
import { readBodyCapped } from './read-body';
|
|
7
7
|
import { storeImage, type StoreImageDeps } from './store-image';
|
|
@@ -54,6 +54,12 @@ export interface StorageRouteContext {
|
|
|
54
54
|
actor: StorageActor | null;
|
|
55
55
|
params: Record<string, string | undefined>;
|
|
56
56
|
request: Request;
|
|
57
|
+
/**
|
|
58
|
+
* The language to answer in — the same field `@12-apps/wiring`'s
|
|
59
|
+
* `WireRequest` carries, mirrored here because this surface builds its own
|
|
60
|
+
* context. Populated by the host's adapter; absent is not an error.
|
|
61
|
+
*/
|
|
62
|
+
locale?: string;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
/** A JSON answer, an object's bytes, or a redirect to where the object lives. */
|
|
@@ -76,9 +82,15 @@ export interface StorageRoute {
|
|
|
76
82
|
handle(context: StorageRouteContext): Promise<StorageRouteResponse>;
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
interface RoutesDeps extends StoreImageDeps {
|
|
85
|
+
interface RoutesDeps extends Omit<StoreImageDeps, "messages"> {
|
|
80
86
|
driver: StorageDriver;
|
|
81
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The factory, unresolved, plus the limit it needs. Resolving into a value
|
|
89
|
+
* here is what would re-freeze the language into the mount — this object is
|
|
90
|
+
* built once, per process.
|
|
91
|
+
*/
|
|
92
|
+
messages: (context: StorageMessageContext) => StorageMessages;
|
|
93
|
+
limit: string;
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
/** Keys are content-addressed (a uuid), so an object may be cached forever. */
|
|
@@ -96,9 +108,12 @@ async function handleUpload(
|
|
|
96
108
|
// override it (`WebStorageMessages.forbidden`).
|
|
97
109
|
return { status: 403, body: { error: 'forbidden' } };
|
|
98
110
|
}
|
|
111
|
+
// Worded HERE, per request, from the tag the adapter put on the context —
|
|
112
|
+
// never at the mount, which ran once at boot.
|
|
113
|
+
const messages = deps.messages({ limit: deps.limit, locale: context.locale });
|
|
99
114
|
const contentType = acceptedContentTypeOf(context.request.headers.get('content-type'));
|
|
100
115
|
if (!contentType) {
|
|
101
|
-
return { status: 400, body: { error:
|
|
116
|
+
return { status: 400, body: { error: messages.unsupported_content_type } };
|
|
102
117
|
}
|
|
103
118
|
// Capped WHILE STREAMING, so an oversize body is refused without ever being
|
|
104
119
|
// held whole in memory.
|
|
@@ -111,12 +126,12 @@ async function handleUpload(
|
|
|
111
126
|
// "o limite é 8 MB": a ceiling nothing enforced, in the one package whose
|
|
112
127
|
// headline claim is that there is exactly ONE number. The sentence is built
|
|
113
128
|
// from this mount's `maxBytes` and carries this mount's `messages` overrides.
|
|
114
|
-
return { status: 413, body: { error:
|
|
129
|
+
return { status: 413, body: { error: messages.file_too_large } };
|
|
115
130
|
}
|
|
116
131
|
try {
|
|
117
132
|
// No byte check here: `storeImage` owns it, so the endpoint and a host write
|
|
118
133
|
// holding raw bytes cannot disagree about whether it ran (see store-image.ts).
|
|
119
|
-
const imageKey = await storeImage(deps, { bytes, contentType, scope: actor.scope });
|
|
134
|
+
const imageKey = await storeImage({ ...deps, messages }, { bytes, contentType, scope: actor.scope });
|
|
120
135
|
return { status: 200, body: { data: { imageKey } } };
|
|
121
136
|
} catch (error) {
|
|
122
137
|
// The write path speaks in StorageProblemError precisely so both entrances
|