wiki-formant 0.49.0 → 0.50.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/README.md +1 -1
- package/dist/upload.d.ts +29 -15
- package/dist/upload.js +37 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -649,7 +649,7 @@ export const POST = uploadHandler({
|
|
|
649
649
|
});
|
|
650
650
|
```
|
|
651
651
|
|
|
652
|
-
The bytes say what the file is, never the name or type, and SVG is refused. Every image is re-encoded, upright, so nothing from the source's metadata is kept, a phone's GPS position included; the bytes as sent are never stored. `encode` is the re-encode, the sniffed format by default; a wiki with an image standard resizes and converts there. Each uploader has a budget, keyed on `authorize`'s id and address. `maxBytes`, `maxPixels` and `rateLimit` take other limits, and `store` replaces Vercel Blob. `sharp` and `@vercel/blob` are optional peers.
|
|
652
|
+
The bytes say what the file is, never the name or type, and SVG is refused. Every image is re-encoded, upright, so nothing from the source's metadata is kept, a phone's GPS position included; the bytes as sent are never stored. `encode` is the re-encode, the sniffed format by default; a wiki with an image standard resizes and converts there. Each uploader has a budget, keyed on `authorize`'s id and address. `maxBytes`, `maxPixels` and `rateLimit` take other limits, and `store` replaces Vercel Blob. `sharp` and `@vercel/blob` are optional peers. `normalizeImage(bytes, { maxPixels, encode })` is the same sniff, cap and re-encode without the route, for images that arrive another way – a server action, or a picture copied in from another site; it resolves to `{ buffer, format, contentType }` and throws a message fit to show the uploader.
|
|
653
653
|
|
|
654
654
|
## Editor nodes
|
|
655
655
|
|
package/dist/upload.d.ts
CHANGED
|
@@ -1,27 +1,40 @@
|
|
|
1
1
|
import { type Sharp } from 'sharp';
|
|
2
2
|
import { type RateLimitOptions } from './rate-limit.js';
|
|
3
3
|
export type ImageFormat = 'jpg' | 'png' | 'gif' | 'webp' | 'avif';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
type Encode = (image: Sharp, source: {
|
|
5
|
+
format: ImageFormat;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}) => {
|
|
9
|
+
image: Sharp;
|
|
10
|
+
format: ImageFormat;
|
|
11
|
+
};
|
|
12
|
+
export interface NormalizeOptions {
|
|
9
13
|
/** Decoded pixels, default 40 million: a phone camera's full resolution. */
|
|
10
14
|
maxPixels?: number;
|
|
11
|
-
/** Default a burst of 20, then one every five seconds. */
|
|
12
|
-
rateLimit?: RateLimitOptions;
|
|
13
15
|
/**
|
|
14
16
|
* How the upright image is written. Default the format it arrived in. A wiki
|
|
15
17
|
* with an image standard resizes and converts here.
|
|
16
18
|
*/
|
|
17
|
-
encode?:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
encode?: Encode;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The image re-encoded upright with no metadata, in the format `encode` chose.
|
|
23
|
+
* Throws, with a message fit to show the uploader, for bytes that are not an
|
|
24
|
+
* accepted image or decode to more than `maxPixels`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function normalizeImage(bytes: Buffer, { maxPixels, encode, }?: NormalizeOptions): Promise<{
|
|
27
|
+
buffer: Buffer;
|
|
28
|
+
format: ImageFormat;
|
|
29
|
+
contentType: string;
|
|
30
|
+
}>;
|
|
31
|
+
export interface UploadOptions extends NormalizeOptions {
|
|
32
|
+
/** The uploader's id, which keys the rate limit, or the response that turns them away. */
|
|
33
|
+
authorize: (request: Request) => Promise<string | Response>;
|
|
34
|
+
/** Default 4 MB. */
|
|
35
|
+
maxBytes?: number;
|
|
36
|
+
/** Default a burst of 20, then one every five seconds. */
|
|
37
|
+
rateLimit?: RateLimitOptions;
|
|
25
38
|
/** Prepended to the stored name, e.g. `wiki/`. */
|
|
26
39
|
prefix?: string;
|
|
27
40
|
/** Stores the bytes and resolves to their public URL. Default Vercel Blob, public. */
|
|
@@ -29,3 +42,4 @@ export interface UploadOptions {
|
|
|
29
42
|
}
|
|
30
43
|
/** The POST handler: a multipart `file` in, `{ url }` out. */
|
|
31
44
|
export declare function uploadHandler({ authorize, maxBytes, maxPixels, rateLimit, encode, prefix, store, }: UploadOptions): (request: Request) => Promise<Response>;
|
|
45
|
+
export {};
|
package/dist/upload.js
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
// EXIF; the orientation that metadata carried is applied first.
|
|
16
16
|
// - **Each uploader has a budget**, keyed on who they are and where from.
|
|
17
17
|
//
|
|
18
|
+
// `normalizeImage` is the sniff, the pixel cap and the re-encode without the
|
|
19
|
+
// route, for an app whose images arrive another way: a server action, or a
|
|
20
|
+
// picture copied in from another site.
|
|
21
|
+
//
|
|
18
22
|
// `sharp` and `@vercel/blob` are optional peers. Run the route on Node, never
|
|
19
23
|
// the edge: sharp is native.
|
|
20
24
|
import sharp, {} from 'sharp';
|
|
@@ -43,11 +47,39 @@ function sniffImage(buf) {
|
|
|
43
47
|
return 'avif';
|
|
44
48
|
return null;
|
|
45
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The image re-encoded upright with no metadata, in the format `encode` chose.
|
|
52
|
+
* Throws, with a message fit to show the uploader, for bytes that are not an
|
|
53
|
+
* accepted image or decode to more than `maxPixels`.
|
|
54
|
+
*/
|
|
55
|
+
export async function normalizeImage(bytes, { maxPixels = 40_000_000, encode = (image, { format }) => ({ image: image.toFormat(format === 'jpg' ? 'jpeg' : format), format }), } = {}) {
|
|
56
|
+
const megapixels = Math.round(maxPixels / 1_000_000);
|
|
57
|
+
const format = sniffImage(bytes);
|
|
58
|
+
if (!format)
|
|
59
|
+
throw new Error('Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF');
|
|
60
|
+
try {
|
|
61
|
+
const meta = await sharp(bytes, { limitInputPixels: maxPixels }).metadata();
|
|
62
|
+
const width = meta.width ?? 0;
|
|
63
|
+
const height = meta.pageHeight ?? meta.height ?? 0;
|
|
64
|
+
if (!width || !height)
|
|
65
|
+
throw new Error('no dimensions');
|
|
66
|
+
if (width * height > maxPixels)
|
|
67
|
+
throw new Error('pixel limit');
|
|
68
|
+
const upright = sharp(bytes, { animated: (meta.pages ?? 1) > 1, limitInputPixels: maxPixels }).rotate();
|
|
69
|
+
const encoded = encode(upright, { format, width, height });
|
|
70
|
+
return { buffer: await encoded.image.toBuffer(), format: encoded.format, contentType: CONTENT_TYPE[encoded.format] };
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
// Never fall back to the bytes as sent: storing them is what leaks the location.
|
|
74
|
+
throw new Error(e instanceof Error && e.message.includes('pixel limit')
|
|
75
|
+
? `Image too large (max ${megapixels} megapixels)`
|
|
76
|
+
: 'Could not read this image. Save it as PNG or JPEG and try again.');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
46
79
|
const vercelBlob = async (path, body, contentType) => (await put(path, body, { access: 'public', addRandomSuffix: false, contentType })).url;
|
|
47
80
|
/** The POST handler: a multipart `file` in, `{ url }` out. */
|
|
48
|
-
export function uploadHandler({ authorize, maxBytes = 4 * 1024 * 1024, maxPixels
|
|
81
|
+
export function uploadHandler({ authorize, maxBytes = 4 * 1024 * 1024, maxPixels, rateLimit = { capacity: 20, refillPerSec: 0.2 }, encode, prefix = '', store = vercelBlob, }) {
|
|
49
82
|
const megabytes = Math.round((maxBytes / 1024 / 1024) * 10) / 10;
|
|
50
|
-
const megapixels = Math.round(maxPixels / 1_000_000);
|
|
51
83
|
return request => handleRoute(async () => {
|
|
52
84
|
const who = await authorize(request);
|
|
53
85
|
if (who instanceof Response)
|
|
@@ -61,31 +93,14 @@ export function uploadHandler({ authorize, maxBytes = 4 * 1024 * 1024, maxPixels
|
|
|
61
93
|
return errors.badRequest('No file provided');
|
|
62
94
|
if (file.size > maxBytes)
|
|
63
95
|
return errors.badRequest(`File too large (max ${megabytes}MB)`);
|
|
64
|
-
const bytes = Buffer.from(await file.arrayBuffer());
|
|
65
|
-
const format = sniffImage(bytes);
|
|
66
|
-
if (!format)
|
|
67
|
-
return errors.badRequest('Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF');
|
|
68
96
|
let out;
|
|
69
97
|
try {
|
|
70
|
-
|
|
71
|
-
const width = meta.width ?? 0;
|
|
72
|
-
const height = meta.pageHeight ?? meta.height ?? 0;
|
|
73
|
-
if (!width || !height)
|
|
74
|
-
throw new Error('no dimensions');
|
|
75
|
-
if (width * height > maxPixels)
|
|
76
|
-
return errors.badRequest(`Image too large (max ${megapixels} megapixels)`);
|
|
77
|
-
const upright = sharp(bytes, { animated: (meta.pages ?? 1) > 1, limitInputPixels: maxPixels }).rotate();
|
|
78
|
-
const encoded = encode(upright, { format, width, height });
|
|
79
|
-
out = { buffer: await encoded.image.toBuffer(), format: encoded.format };
|
|
98
|
+
out = await normalizeImage(Buffer.from(await file.arrayBuffer()), { maxPixels, encode });
|
|
80
99
|
}
|
|
81
100
|
catch (e) {
|
|
82
|
-
|
|
83
|
-
const tooBig = e instanceof Error && e.message.includes('pixel limit');
|
|
84
|
-
return errors.badRequest(tooBig
|
|
85
|
-
? `Image too large (max ${megapixels} megapixels)`
|
|
86
|
-
: 'Could not read this image. Save it as PNG or JPEG and try again.');
|
|
101
|
+
return errors.badRequest(e.message);
|
|
87
102
|
}
|
|
88
|
-
const url = await store(`${prefix}${crypto.randomUUID()}.${out.format}`, out.buffer,
|
|
103
|
+
const url = await store(`${prefix}${crypto.randomUUID()}.${out.format}`, out.buffer, out.contentType);
|
|
89
104
|
return json({ url });
|
|
90
105
|
}, 'Upload failed');
|
|
91
106
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
4
4
|
"description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|