@12-apps/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/payload.ts ADDED
@@ -0,0 +1,127 @@
1
+ import { isAcceptedContentType } from './content-types';
2
+ import type { StorageProblem } from './problems';
3
+
4
+ /**
5
+ * Proving that some bytes really are an image of the type they claim, and
6
+ * decoding the base64 form of them.
7
+ *
8
+ * A browser POSTs its bytes as a request stream; an agent cannot make an
9
+ * arbitrary HTTP request at all — it calls tools, with JSON arguments — so its
10
+ * bytes travel base64 inside the write that uses them. That second form needs
11
+ * three checks the stream gets from its transport for free:
12
+ *
13
+ * - the SIZE cap has to hold against the ENCODED length, before decoding, or a
14
+ * 30 MB string is allocated twice over just to be rejected;
15
+ * - the encoding may be malformed, and `Buffer.from(…, 'base64')` silently
16
+ * discards what it does not recognise — so a typo'd payload would otherwise
17
+ * be stored as a truncated, unopenable image;
18
+ * - the declared `contentType` is the only thing naming the format, and it
19
+ * decides both the stored extension AND the type the object is served back
20
+ * with.
21
+ *
22
+ * That last check is what keeps an upload surface from being a way to park
23
+ * arbitrary content at a world-readable URL on the store's own domain: bytes
24
+ * that are not one of the accepted formats are refused whatever they claim to
25
+ * be. It runs on BOTH entrances — the decode does it as part of decoding, and
26
+ * the streaming path does it on the bytes it read — because neither may be the
27
+ * one that forgets.
28
+ */
29
+
30
+ /** `data:image/png;base64,` — an agent is as likely to send this as bare base64. */
31
+ const DATA_URL_PREFIX = /^data:[\w.+-]+\/[\w.+-]+(;[\w.+-]+=[\w.+-]+)*;base64,/i;
32
+
33
+ /** Standard base64 alphabet with at most the two padding characters. */
34
+ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
35
+
36
+ function startsWith(bytes: Uint8Array, signature: readonly number[], offset = 0): boolean {
37
+ return signature.every((byte, index) => bytes[offset + index] === byte);
38
+ }
39
+
40
+ /**
41
+ * The MIME type the BYTES themselves declare, by magic number — `undefined` for
42
+ * anything that is not one of the accepted formats.
43
+ */
44
+ export function sniffImageContentType(bytes: Uint8Array): string | undefined {
45
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png';
46
+ if (startsWith(bytes, [0xff, 0xd8, 0xff])) return 'image/jpeg';
47
+ // "GIF8", covering both 87a and 89a.
48
+ if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return 'image/gif';
49
+ // "RIFF" …4-byte length… "WEBP".
50
+ if (
51
+ startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) &&
52
+ startsWith(bytes, [0x57, 0x45, 0x42, 0x50], 8)
53
+ ) {
54
+ return 'image/webp';
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ /**
60
+ * Do `bytes` really carry an image of `contentType`? `null` when they agree.
61
+ *
62
+ * In one place because both entrances need it, and one of them has nothing else
63
+ * corroborating the claim: there is no file dialog upstream of a tool call.
64
+ */
65
+ export function verifyImageBytes(
66
+ bytes: Uint8Array,
67
+ contentType: string,
68
+ ): StorageProblem | null {
69
+ if (bytes.byteLength === 0) return 'empty_file';
70
+ if (!isAcceptedContentType(contentType)) return 'unsupported_content_type';
71
+ return sniffImageContentType(bytes) === contentType ? null : 'content_mismatch';
72
+ }
73
+
74
+ export type DecodedPayload =
75
+ | { ok: true; bytes: Uint8Array }
76
+ | { ok: false; problem: StorageProblem };
77
+
78
+ /**
79
+ * Bytes a padded base64 string decodes to, EXACTLY — every 4 characters carry 3
80
+ * bytes, less the 1 or 2 the padding stands in for.
81
+ *
82
+ * The approximation without that correction over-counts by up to two bytes,
83
+ * which is invisible everywhere except at the cap, where it refuses a file of
84
+ * exactly the documented maximum.
85
+ */
86
+ function decodedLength(encoded: string): number {
87
+ let padding = 0;
88
+ if (encoded.endsWith('=')) padding = encoded.endsWith('==') ? 2 : 1;
89
+ return (encoded.length / 4) * 3 - padding;
90
+ }
91
+
92
+ /**
93
+ * Strip the data-URL wrapper and any whitespace, then restore padding a caller
94
+ * dropped (unpadded base64 is common and unambiguous).
95
+ *
96
+ * A length one more than a multiple of 4 pads to three `=`, which the alphabet
97
+ * check then rejects — correctly, since no base64 string has that length.
98
+ */
99
+ function normalize(input: string): string {
100
+ const encoded = input.replace(DATA_URL_PREFIX, '').replace(/\s+/g, '');
101
+ const remainder = encoded.length % 4;
102
+ return remainder === 0 ? encoded : encoded.padEnd(encoded.length + (4 - remainder), '=');
103
+ }
104
+
105
+ /**
106
+ * Decode `input` and prove it really is an image of `contentType`.
107
+ *
108
+ * `maxBytes` is judged on the ENCODED length first, so an oversize payload is
109
+ * never decoded at all.
110
+ */
111
+ export function decodeImagePayload(
112
+ input: string,
113
+ contentType: string,
114
+ maxBytes: number,
115
+ ): DecodedPayload {
116
+ const encoded = normalize(input);
117
+ if (encoded.length === 0) return { ok: false, problem: 'empty_file' };
118
+ if (!BASE64.test(encoded)) return { ok: false, problem: 'invalid_base64' };
119
+ if (decodedLength(encoded) > maxBytes) return { ok: false, problem: 'file_too_large' };
120
+
121
+ // Copied out of Node's Buffer pool so the stored bytes are a standalone
122
+ // Uint8Array rather than a view into a shared allocation.
123
+ const bytes = new Uint8Array(Buffer.from(encoded, 'base64'));
124
+ if (bytes.byteLength === 0) return { ok: false, problem: 'empty_file' };
125
+ const mismatch = verifyImageBytes(bytes, contentType);
126
+ return mismatch ? { ok: false, problem: mismatch } : { ok: true, bytes };
127
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Why an upload was refused, the status that describes it, and the sentence the
3
+ * person uploading reads.
4
+ *
5
+ * ONE table for all of it, because the same refusal has to serve two entrances:
6
+ * a browser POSTing bytes and a host write carrying them base64 in its body (an
7
+ * agent's only option — a tool call is JSON). Two tables would let the two
8
+ * entrances tell one store owner two different things about one file.
9
+ *
10
+ * The messages are pt-BR because they reach a Brazilian store owner, directly
11
+ * or relayed by an agent. They are product copy, not developer strings: a host
12
+ * with another audience passes `messages` to the mount rather than translating
13
+ * the package.
14
+ */
15
+
16
+ /** Everything an upload can be refused for, across both entrances. */
17
+ export type StorageProblem =
18
+ /** The base64 field is not base64. */
19
+ | 'invalid_base64'
20
+ /** Zero bytes arrived. */
21
+ | 'empty_file'
22
+ /** Over the configured `maxBytes`. */
23
+ | 'file_too_large'
24
+ /** The bytes' magic number contradicts the declared `Content-Type`. */
25
+ | 'content_mismatch'
26
+ /** A `Content-Type` outside the accepted formats. */
27
+ | 'unsupported_content_type'
28
+ /** The pipeline could not decode the bytes. */
29
+ | 'image_unreadable'
30
+ /** Decoding would allocate more than the pipeline's pixel ceiling. */
31
+ | 'image_dimensions_too_large'
32
+ /** No driver could store the bytes — an operator problem, not the caller's. */
33
+ | 'storage_not_configured'
34
+ /** The driver refused for any other reason. */
35
+ | 'storage_unavailable';
36
+
37
+ /** The HTTP status each refusal answers with. */
38
+ export const STORAGE_PROBLEM_STATUS: Readonly<Record<StorageProblem, number>> = {
39
+ invalid_base64: 400,
40
+ empty_file: 400,
41
+ content_mismatch: 400,
42
+ unsupported_content_type: 400,
43
+ image_unreadable: 400,
44
+ file_too_large: 413,
45
+ image_dimensions_too_large: 413,
46
+ storage_not_configured: 503,
47
+ storage_unavailable: 502,
48
+ };
49
+
50
+ /** The sentence each refusal is stated in. */
51
+ export type StorageMessages = Readonly<Record<StorageProblem, string>>;
52
+
53
+ /** Copy that only makes sense with the configured ceiling in it. */
54
+ export interface StorageMessageContext {
55
+ /** `8 MB`, already formatted. */
56
+ limit: string;
57
+ }
58
+
59
+ /**
60
+ * The default pt-BR copy. `limit` is interpolated rather than hard-coded so a
61
+ * host that raises `maxBytes` cannot end up with a message naming the old
62
+ * number — the single commonest way this kind of copy goes stale.
63
+ */
64
+ export function defaultStorageMessages(context: StorageMessageContext): StorageMessages {
65
+ return {
66
+ invalid_base64: 'A imagem enviada não está codificada corretamente em base64.',
67
+ empty_file: 'A imagem enviada está vazia.',
68
+ file_too_large: `A imagem enviada é maior que o limite de ${context.limit}.`,
69
+ content_mismatch:
70
+ 'O conteúdo da imagem não corresponde ao formato informado. Envie um PNG, JPEG, WebP ou GIF.',
71
+ unsupported_content_type: 'Formato não suportado. Envie PNG, JPG, WebP ou GIF.',
72
+ image_unreadable:
73
+ 'Não foi possível ler a imagem enviada — o arquivo parece estar corrompido.',
74
+ image_dimensions_too_large:
75
+ 'A imagem enviada tem dimensões grandes demais para ser processada.',
76
+ storage_not_configured: 'O armazenamento de imagens ainda não foi configurado nesta loja.',
77
+ storage_unavailable:
78
+ 'Não foi possível salvar a imagem agora. Tente novamente em instantes.',
79
+ };
80
+ }
81
+
82
+ /**
83
+ * A refusal, carrying the status and the finished sentence.
84
+ *
85
+ * Thrown by the write path so a host write (`createProduct` with bytes in the
86
+ * body) fails with a usable status instead of creating a row whose photo is
87
+ * silently missing — the same status and the same sentence the endpoint answers.
88
+ */
89
+ export class StorageProblemError extends Error {
90
+ readonly problem: StorageProblem;
91
+ readonly status: number;
92
+
93
+ constructor(problem: StorageProblem, message: string) {
94
+ super(message);
95
+ this.name = 'StorageProblemError';
96
+ this.problem = problem;
97
+ this.status = STORAGE_PROBLEM_STATUS[problem];
98
+ }
99
+ }
100
+
101
+ /** Is `error` one of this package's refusals? */
102
+ export function isStorageProblem(error: unknown): error is StorageProblemError {
103
+ return error instanceof StorageProblemError;
104
+ }
105
+
106
+ /**
107
+ * Missing configuration is an OPERATOR problem, not a runtime bug — a driver
108
+ * raises this and the write path maps it to the 503 an admin UI can explain,
109
+ * rather than to the generic "try again" a bucket timeout gets.
110
+ */
111
+ export class StorageNotConfiguredError extends Error {
112
+ constructor(message: string) {
113
+ super(message);
114
+ this.name = 'StorageNotConfiguredError';
115
+ }
116
+ }
@@ -0,0 +1,100 @@
1
+ import { useState, type ComponentType, type JSX } from 'react';
2
+
3
+ import { Stack } from '@12-apps/ui/mui/Stack';
4
+ import { Text } from '@12-apps/ui/typography/Text';
5
+
6
+ import { DEFAULT_MAX_UPLOAD_BYTES, megabytes } from '../limits';
7
+ import type { WebStorageMessages } from './failures';
8
+ import { ImageField, type ImageFieldProps } from './image-field';
9
+ import type { ImageProfile } from './optimize-image';
10
+ import { useUpload, type UploadState, type UseUploadConfig } from './use-upload';
11
+
12
+ /**
13
+ * The one thing this package exposes to a FRONTEND host.
14
+ *
15
+ * Everything the upload IS lives inside: the browser-side re-encode, the upfront
16
+ * refusals, the single same-origin POST, the failure vocabulary, the picker and its
17
+ * preview. The host names where the API is mounted, and that is the whole wiring.
18
+ *
19
+ * `maxBytes` defaults to {@link DEFAULT_MAX_UPLOAD_BYTES} — the same constant the
20
+ * backend mount is meant to be given — so the two agree by construction unless a
21
+ * host deliberately diverges. It is only an EARLY refusal in any case: the server
22
+ * is the authority, and the copy the browser shows is generated from whatever
23
+ * number it was handed, so it cannot claim a limit it is not applying.
24
+ */
25
+
26
+ export interface WebStorageConfig {
27
+ /** Where the router is mounted, e.g. `/api`. */
28
+ apiBase: string;
29
+ /** The ceiling the server enforces. Default {@link DEFAULT_MAX_UPLOAD_BYTES}. */
30
+ maxBytes?: number;
31
+ /** The browser-side re-encode profile. */
32
+ profile?: ImageProfile;
33
+ /** How requests are made. Default: same-origin `fetch`. */
34
+ fetchImpl?: typeof fetch;
35
+ /**
36
+ * pt-BR refusal copy overrides, mirroring the server mount's `messages`.
37
+ *
38
+ * Override `forbidden` to name YOUR reason: the package states only that the
39
+ * account may not upload, because `mayUpload` is one host-computed boolean and a
40
+ * package that named a role model would be wrong for every host but the first.
41
+ */
42
+ messages?: Partial<WebStorageMessages>;
43
+ }
44
+
45
+ export interface WebStorage {
46
+ /** A standalone upload surface — the whole thing, ready to render. */
47
+ page: ComponentType;
48
+ /** The field a form drops in: picker, preview, refusal, and a key out. */
49
+ ImageField: ComponentType<ImageFieldProps>;
50
+ /** The primitive, for a host composing its own affordance. */
51
+ useUpload: () => UploadState;
52
+ /** What this surface enforces before a request leaves the browser. */
53
+ limits: { maxBytes: number; maxBytesLabel: string };
54
+ }
55
+
56
+ export function createWebStorage(config: WebStorageConfig): WebStorage {
57
+ const maxBytes = config.maxBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
58
+ const uploadConfig: UseUploadConfig = {
59
+ apiBase: config.apiBase,
60
+ maxBytes,
61
+ ...(config.profile ? { profile: config.profile } : {}),
62
+ ...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
63
+ ...(config.messages ? { messages: config.messages } : {}),
64
+ };
65
+ const useBoundUpload = (): UploadState => useUpload(uploadConfig);
66
+
67
+ function BoundImageField(props: ImageFieldProps): JSX.Element {
68
+ return <ImageField {...props} uploader={useBoundUpload()} />;
69
+ }
70
+
71
+ /**
72
+ * The standalone surface: pick a file, see the key it was stored under. It is
73
+ * what a host mounts to prove the wiring end to end, and what the consumer
74
+ * harness drives — the key on screen is the same string a form would save.
75
+ */
76
+ function UploadPage(): JSX.Element {
77
+ const [imageKey, setImageKey] = useState<string | null>(null);
78
+ return (
79
+ <Stack spacing={2} data-testid="storage-upload-page">
80
+ <Text as="h2">Imagens da loja</Text>
81
+ <Text as="p">
82
+ {`Envie um PNG, JPG, WebP ou GIF de até ${megabytes(maxBytes)}.`}
83
+ </Text>
84
+ <BoundImageField
85
+ value={imageKey}
86
+ onChange={setImageKey}
87
+ label="Foto do produto"
88
+ helperText="A imagem é reduzida no navegador antes do envio."
89
+ />
90
+ </Stack>
91
+ );
92
+ }
93
+
94
+ return {
95
+ page: UploadPage,
96
+ ImageField: BoundImageField,
97
+ useUpload: useBoundUpload,
98
+ limits: { maxBytes, maxBytesLabel: megabytes(maxBytes) },
99
+ };
100
+ }
@@ -0,0 +1,201 @@
1
+ import { ACCEPTED_CONTENT_TYPES } from '../content-types';
2
+ import { megabytes } from '../limits';
3
+
4
+ /**
5
+ * Why an upload failed, in words the store owner can act on.
6
+ *
7
+ * Collapsing every cause into one "Falha ao enviar a imagem." is what made a
8
+ * failed upload unreportable: the owner saw a message that named no cause, could
9
+ * not tell a file they should shrink from a permission the operator had not
10
+ * granted, and had nothing to paste into a support request.
11
+ *
12
+ * The upload being a SINGLE same-origin POST is what let this shrink to one rule.
13
+ * A two-hop version had to explain a presign step, a signed grant that could age
14
+ * out, a bucket's own `<Code>` coming back to the browser, and — the honest but
15
+ * useless one — "rede ou CORS", because a blocked cross-origin PUT is deliberately
16
+ * indistinguishable from an offline network at the `fetch` layer. None of those
17
+ * can happen: the browser talks to our own origin, and the server is the only
18
+ * thing that talks to storage.
19
+ *
20
+ * What remains is one rule: **whatever the response DOES say is said out loud.**
21
+ *
22
+ * ## Why this table is overridable
23
+ *
24
+ * Every sentence here reaches a store owner, so it is product copy and it is
25
+ * pt-BR — but two of these refusals are decided by rules the PACKAGE does not
26
+ * know. `forbidden` is the sharp one: `mayUpload` is deliberately one host-computed
27
+ * boolean, and this file once answered it with *"é preciso ser OWNER ou ADMIN"* —
28
+ * naming a role model that belongs to one host. An adopter whose roles are
29
+ * `manager`/`staff`, or who gates on an `@12-apps/rbac` permission, would show an
30
+ * owner two role names that do not exist in their product, with no knob to change
31
+ * it. So the copy states the OUTCOME ("your account may not") and the host
32
+ * supplies the CAUSE if it wants one, through `messages` — the same escape hatch
33
+ * the server's refusals already have.
34
+ */
35
+
36
+ /**
37
+ * The browser-side refusal copy, as one table a host may override.
38
+ *
39
+ * Mirrors the server's `StorageMessages` deliberately: same shape, same reason,
40
+ * so a host that overrides a sentence on one side can find the other.
41
+ *
42
+ * These are the refusals the browser has to phrase ITSELF — a bare code, or a
43
+ * status with no body at all. Anything the server states as a finished sentence is
44
+ * surfaced verbatim instead and never reaches this table (see {@link uploadFailure}).
45
+ */
46
+ export interface WebStorageMessages {
47
+ /**
48
+ * 403. States the outcome only: the reason is the host's rule, and naming a
49
+ * role model here would be wrong for every host but the first.
50
+ */
51
+ forbidden: string;
52
+ unsupported_content_type: string;
53
+ /** Carries the ceiling, so a host overriding it states its own number. */
54
+ file_too_large: string;
55
+ invalid_key: string;
56
+ not_found: string;
57
+ /** 401 — the session went away between picking the file and sending it. */
58
+ session_expired: string;
59
+ /** `fetch` rejected outright; no response ever arrived. */
60
+ transport: string;
61
+ }
62
+
63
+ /** Copy that only makes sense with the configured ceiling in it. */
64
+ export interface WebStorageMessageContext {
65
+ /** `8 MB`, already formatted. */
66
+ limit: string;
67
+ }
68
+
69
+ /**
70
+ * The default pt-BR copy. `limit` is interpolated rather than hard-coded for the
71
+ * same reason the server does it: a host that raises the ceiling must not end up
72
+ * with a message naming the old number.
73
+ */
74
+ export function defaultWebStorageMessages(
75
+ context: WebStorageMessageContext,
76
+ ): WebStorageMessages {
77
+ return {
78
+ forbidden: 'Sua conta não tem permissão para enviar imagens nesta loja.',
79
+ unsupported_content_type: 'Formato não suportado. Envie PNG, JPG, WebP ou GIF.',
80
+ file_too_large: `Imagem muito grande. O limite é ${context.limit}.`,
81
+ invalid_key: 'Não foi possível localizar essa imagem.',
82
+ not_found: 'Essa imagem não está mais disponível.',
83
+ session_expired: 'Sua sessão expirou. Entre novamente e repita o envio.',
84
+ transport: 'Não foi possível falar com o servidor. Verifique sua conexão e tente de novo.',
85
+ };
86
+ }
87
+
88
+ function resolve(
89
+ maxBytes: number,
90
+ overrides: Partial<WebStorageMessages> | undefined,
91
+ ): WebStorageMessages {
92
+ return { ...defaultWebStorageMessages({ limit: megabytes(maxBytes) }), ...overrides };
93
+ }
94
+
95
+ /**
96
+ * The sentence for a refusal CODE, or `undefined` for a code with no entry.
97
+ *
98
+ * An own-property check rather than a bare `in`: the code arrives from a response
99
+ * body, so `constructor` or `toString` would otherwise resolve through the
100
+ * prototype and be shown to a store owner as an explanation.
101
+ */
102
+ function sentenceFor(messages: WebStorageMessages, code: string): string | undefined {
103
+ if (!Object.prototype.hasOwnProperty.call(messages, code)) return undefined;
104
+ return messages[code as keyof WebStorageMessages];
105
+ }
106
+
107
+ /**
108
+ * Refuse a file before any request leaves the browser, or `null` to proceed.
109
+ *
110
+ * Measured against the bytes that would ACTUALLY be sent, which is why it runs
111
+ * after `optimizeImage` and not before: a 12 MB phone photo is a perfectly good
112
+ * product image once it is 1280px of WebP, and refusing it against the cap first
113
+ * was sending owners off to find an image editor for a file we can fix here.
114
+ *
115
+ * An empty `file.type` means the OS could not identify the file at all (an
116
+ * extensionless drop, most often) — worth its own sentence, because "formato não
117
+ * suportado" reads as an accusation about a file the owner knows is a PNG.
118
+ */
119
+ export function rejectFileUpfront(file: File, maxBytes: number): string | null {
120
+ if (file.size === 0) {
121
+ return 'Arquivo vazio (0 bytes). Escolha a imagem novamente.';
122
+ }
123
+ if (file.size > maxBytes) {
124
+ return `Imagem muito grande: ${megabytes(file.size)}. O limite é ${megabytes(
125
+ maxBytes,
126
+ )} — reduza a imagem e tente de novo.`;
127
+ }
128
+ if (file.type === '') {
129
+ return 'Não foi possível identificar o tipo do arquivo. Envie um PNG, JPG ou WebP.';
130
+ }
131
+ if (!ACCEPTED_CONTENT_TYPES.includes(file.type)) {
132
+ return `Formato não suportado (${file.type}). Envie PNG, JPG, WebP ou GIF.`;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /** The `error` field of a JSON refusal, or `null` when there isn't one. */
138
+ async function statedError(response: Response): Promise<string | null> {
139
+ try {
140
+ const parsed: unknown = JSON.parse(await response.text());
141
+ if (parsed && typeof parsed === 'object' && 'error' in parsed) {
142
+ const error = (parsed as { error: unknown }).error;
143
+ return typeof error === 'string' ? error : null;
144
+ }
145
+ return null;
146
+ } catch {
147
+ // An unreadable or non-JSON body tells us nothing extra; the status still does.
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Explain a refusal from the upload endpoint.
154
+ *
155
+ * **The server's own sentence wins.** Every refusal the mount can phrase — an
156
+ * unreadable file, a format that contradicts the bytes, a ceiling, an unsupported
157
+ * type, storage not configured — arrives as a finished pt-BR sentence built from
158
+ * the mount's OWN `maxBytes` and through the mount's OWN `messages` overrides.
159
+ * Those are surfaced verbatim rather than re-worded here, so the two entrances
160
+ * cannot drift into telling one store owner two different things about one file,
161
+ * and so a limit named on screen is the limit something enforces.
162
+ *
163
+ * That last point is why the table below is a FALLBACK and not the primary path.
164
+ * The endpoint used to answer `file_too_large` as a bare code, and the browser
165
+ * re-derived the number from its own default: a mount capped at 4 MB refusing a
166
+ * 6 MB file produced *"o limite é 8 MB"* — a ceiling nothing applied. The codes
167
+ * that remain are the ones no `StorageMessages` entry exists for (`forbidden`,
168
+ * `invalid_key`, `not_found`) plus any older mount still answering a code.
169
+ */
170
+ export async function uploadFailure(
171
+ response: Response,
172
+ maxBytes: number,
173
+ overrides?: Partial<WebStorageMessages>,
174
+ ): Promise<string> {
175
+ const messages = resolve(maxBytes, overrides);
176
+ if (response.status === 401) return messages.session_expired;
177
+ const stated = await statedError(response);
178
+ // A sentence rather than a code: the server already phrased it for the owner,
179
+ // with its own number and its own overrides in it. Checked BEFORE the table so a
180
+ // mount that states a limit is never second-guessed by a local default.
181
+ if (stated && /\s/.test(stated)) return stated;
182
+ const known = stated ? sentenceFor(messages, stated) : undefined;
183
+ if (known) return known;
184
+ // An unrecognised CODE is still the most useful string in the response — it is
185
+ // what turns a support request from "it failed" into something greppable — so it
186
+ // rides along with the status rather than being mapped away.
187
+ const detail = stated ? ` (${stated})` : '';
188
+ return `Não foi possível enviar a imagem (HTTP ${response.status}${detail}). Tente de novo em instantes.`;
189
+ }
190
+
191
+ /**
192
+ * `fetch` rejected outright — no response ever arrived.
193
+ *
194
+ * One sentence, and it can afford to be definite: the upload goes to our own
195
+ * origin, so there is no cross-origin case to hedge about.
196
+ */
197
+ export function transportFailure(overrides?: Partial<WebStorageMessages>): string {
198
+ // No ceiling to interpolate on this one, so it is read without a limit rather
199
+ // than through a table built from a number that would be a lie.
200
+ return overrides?.transport ?? defaultWebStorageMessages({ limit: '' }).transport;
201
+ }
@@ -0,0 +1,119 @@
1
+ import { useState, type JSX } from 'react';
2
+
3
+ import { Alert } from '@12-apps/ui/data-display/Alert';
4
+ import { Box } from '@12-apps/ui/mui/Box';
5
+ import { Stack } from '@12-apps/ui/mui/Stack';
6
+ import { Text } from '@12-apps/ui/typography/Text';
7
+ import { Button } from '@12-apps/ui/form/Button';
8
+ import { UploadButton } from '@12-apps/ui/form/UploadButton';
9
+
10
+ import { ACCEPTED_CONTENT_TYPES } from '../content-types';
11
+ import type { UploadOptions, UploadState } from './use-upload';
12
+
13
+ /**
14
+ * The picker, the preview and the refusal, as one field.
15
+ *
16
+ * `@12-apps/ui/form/UploadButton` is the presentation and this is the BINDING: it
17
+ * owns the three things a form gets wrong when it wires an upload by hand.
18
+ *
19
+ * 1. **The preview appears the moment the file is PICKED**, not when the upload
20
+ * lands. An empty slot during the round-trip reads as "the click did nothing",
21
+ * which is exactly how a swallowed storage 503 looked. A failure withdraws it
22
+ * and `error` says why.
23
+ * 2. **A failed upload restores the previous preview.** Leaving the new one up
24
+ * would show a photo that is not saved and will not be.
25
+ * 3. **The saved key is the only output.** A form folds `imageKey` into its save
26
+ * payload; it never learns a URL, because only the server knows which driver
27
+ * is running and where a CDN points.
28
+ */
29
+
30
+ export interface ImageFieldProps {
31
+ /** The key already saved on the row, if any. */
32
+ value?: string | null;
33
+ /** A URL for the saved key — the server resolved it through the driver. */
34
+ previewUrl?: string | null;
35
+ /** Called with the new key, or `null` when the image is removed. */
36
+ onChange: (imageKey: string | null) => void;
37
+ label?: string;
38
+ helperText?: string;
39
+ /** Passed through per upload — `{ optimize: false }` for a rendered canvas. */
40
+ uploadOptions?: UploadOptions;
41
+ disabled?: boolean;
42
+ 'data-testid'?: string;
43
+ }
44
+
45
+ /** {@link ImageFieldProps} plus the bound upload state the factory injects. */
46
+ export interface BoundImageFieldProps extends ImageFieldProps {
47
+ uploader: UploadState;
48
+ }
49
+
50
+ const ACCEPT = ACCEPTED_CONTENT_TYPES.join(',');
51
+
52
+ export function ImageField(props: BoundImageFieldProps): JSX.Element {
53
+ const { uploader } = props;
54
+ const [preview, setPreview] = useState<string | null>(props.previewUrl ?? null);
55
+ const testId = props['data-testid'] ?? 'storage-image-field';
56
+
57
+ async function handleSelect(file: File): Promise<void> {
58
+ const objectUrl = URL.createObjectURL(file);
59
+ const previous = preview;
60
+ setPreview(objectUrl);
61
+ const key = await uploader.upload(file, props.uploadOptions);
62
+ if (key) {
63
+ props.onChange(key);
64
+ return;
65
+ }
66
+ URL.revokeObjectURL(objectUrl);
67
+ setPreview(previous);
68
+ }
69
+
70
+ function handleRemove(): void {
71
+ setPreview(null);
72
+ uploader.clearError();
73
+ props.onChange(null);
74
+ }
75
+
76
+ return (
77
+ <Stack spacing={1} data-testid={testId}>
78
+ {preview ? (
79
+ <Box>
80
+ {/* A plain <img>: a package cannot assume a framework's image
81
+ component, and this preview is a local blob half the time. */}
82
+ <img
83
+ src={preview}
84
+ alt={props.label ?? 'Imagem'}
85
+ data-testid={`${testId}-preview`}
86
+ style={{ maxWidth: 160, maxHeight: 160, objectFit: 'contain' }}
87
+ />
88
+ </Box>
89
+ ) : null}
90
+ <UploadButton
91
+ variant="dropzone"
92
+ label={props.label ?? 'Enviar imagem'}
93
+ accept={ACCEPT}
94
+ disabled={props.disabled || uploader.uploading}
95
+ uploading={uploader.uploading}
96
+ helperText={props.helperText}
97
+ onSelect={(file) => void handleSelect(file)}
98
+ data-testid={`${testId}-picker`}
99
+ />
100
+ {preview ? (
101
+ <Box>
102
+ <Button variant="text" onClick={handleRemove} dataTestId={`${testId}-remove`}>
103
+ Remover imagem
104
+ </Button>
105
+ </Box>
106
+ ) : null}
107
+ {uploader.error ? (
108
+ <Alert variant="danger" data-testid={`${testId}-error`}>
109
+ {uploader.error}
110
+ </Alert>
111
+ ) : null}
112
+ {props.value ? (
113
+ <Text as="p" data-testid={`${testId}-key`}>
114
+ {props.value}
115
+ </Text>
116
+ ) : null}
117
+ </Stack>
118
+ );
119
+ }