@nixxie-cms/fields-computed-image 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/LICENSE +23 -0
- package/README.md +102 -0
- package/dist/declarations/src/index.d.ts +53 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/nixxie-cms-fields-computed-image.cjs.d.ts +2 -0
- package/dist/nixxie-cms-fields-computed-image.cjs.js +298 -0
- package/dist/nixxie-cms-fields-computed-image.esm.js +294 -0
- package/package.json +35 -0
- package/src/index.ts +304 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nixxie International DMCC
|
|
4
|
+
Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
|
|
5
|
+
(this software is derived from the KeystoneJS project, https://keystonejs.com)
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @nixxie-cms/fields-computed-image
|
|
2
|
+
|
|
3
|
+
An image upload field for Nixxie CMS that computes width/height, generates a blur placeholder
|
|
4
|
+
and a thumbnail, and stores files through the pluggable storage service
|
|
5
|
+
(`context.services.storage`).
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- A configured **storage service** (`context.services.storage`, e.g. from
|
|
10
|
+
`@nixxie-cms/storage` — local, S3, GCS or Azure). Mutations fail with a clear error when it is
|
|
11
|
+
missing.
|
|
12
|
+
- The **`sharp`** peer dependency (lazy-loaded; only needed at runtime when an image is
|
|
13
|
+
uploaded):
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pnpm add sharp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Schema
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { computedImage } from '@nixxie-cms/fields-computed-image'
|
|
23
|
+
|
|
24
|
+
fields: {
|
|
25
|
+
cover: computedImage({
|
|
26
|
+
storage: { prefix: 'covers/' }, // key prefix, default 'images/'
|
|
27
|
+
thumbnail: { width: 480 }, // or false to disable, default { width: 320 }
|
|
28
|
+
blur: true, // inline blur placeholder, default true
|
|
29
|
+
}),
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Uploading from a client
|
|
34
|
+
|
|
35
|
+
The field's GraphQL input is plain JSON — `{ base64, filename? }` (data URLs are accepted), or
|
|
36
|
+
`null` to clear the field. Decoded images are capped at ~50MB.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// browser
|
|
40
|
+
const file = fileInput.files[0]
|
|
41
|
+
const base64 = await new Promise<string>(resolve => {
|
|
42
|
+
const reader = new FileReader()
|
|
43
|
+
reader.onload = () => resolve(reader.result as string) // data:image/…;base64,…
|
|
44
|
+
reader.readAsDataURL(file)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
await fetch('/api/graphql', {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
query: `mutation ($id: ID!, $cover: JSON) {
|
|
52
|
+
updatePost(where: { id: $id }, data: { cover: $cover }) {
|
|
53
|
+
cover { url thumbnailUrl width height blurDataUrl }
|
|
54
|
+
}
|
|
55
|
+
}`,
|
|
56
|
+
variables: { id, cover: { base64, filename: file.name } },
|
|
57
|
+
}),
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> Plain JSON was chosen deliberately over a multipart `Upload` scalar: the raw GraphQL input
|
|
62
|
+
> resolver does not receive the request context, so all processing (sharp + storage) happens in
|
|
63
|
+
> a field-level `resolveInput` hook, which does. Base64 adds ~33% transfer overhead — fine for
|
|
64
|
+
> CMS-sized images under the 50MB cap.
|
|
65
|
+
|
|
66
|
+
## What gets stored
|
|
67
|
+
|
|
68
|
+
Two objects go to storage (both `public: true`):
|
|
69
|
+
|
|
70
|
+
- the original: `<prefix><randomId>.<ext>` (e.g. `images/aB3xY9….jpg`)
|
|
71
|
+
- the thumbnail: `<prefix><randomId>.thumb.webp` (unless `thumbnail: false`)
|
|
72
|
+
|
|
73
|
+
The database column (JSON) holds:
|
|
74
|
+
|
|
75
|
+
```jsonc
|
|
76
|
+
{
|
|
77
|
+
"key": "images/aB3xY9….jpg",
|
|
78
|
+
"url": "https://…/images/aB3xY9….jpg",
|
|
79
|
+
"thumbnailKey": "images/aB3xY9….thumb.webp", // null when disabled
|
|
80
|
+
"thumbnailUrl": "https://…", // null when disabled
|
|
81
|
+
"width": 2048,
|
|
82
|
+
"height": 1365,
|
|
83
|
+
"filesize": 482133, // bytes (original)
|
|
84
|
+
"format": "jpeg", // as detected by sharp
|
|
85
|
+
"blurDataUrl": "data:image/webp;base64,…", // 16px webp, null when blur: false
|
|
86
|
+
"filename": "holiday.jpg" // client-supplied, null when omitted
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The GraphQL output is a typed `ComputedImage` object exposing those same fields.
|
|
91
|
+
|
|
92
|
+
## Cleanup
|
|
93
|
+
|
|
94
|
+
An `afterOperation` hook removes both objects from storage when the item is deleted, and removes
|
|
95
|
+
the previous objects when the image is replaced or cleared on update. Cleanup failures are
|
|
96
|
+
logged with `console.error` and never thrown (the database write has already succeeded).
|
|
97
|
+
|
|
98
|
+
## Notes
|
|
99
|
+
|
|
100
|
+
- The Admin UI renders this field with the core JSON views (no bespoke upload UI is shipped).
|
|
101
|
+
- Type exports: `ComputedImageData` (stored shape), `ComputedImageInput` (mutation input shape),
|
|
102
|
+
`ComputedImageFieldConfig`.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { BaseFieldTypeInfo, BaseCollectionTypeInfo, CommonFieldConfig, FieldTypeFunc } from '@nixxie-cms/core/types';
|
|
2
|
+
/** The GraphQL input shape — a base64-encoded image (data URLs accepted), or null to clear. */
|
|
3
|
+
export type ComputedImageInput = {
|
|
4
|
+
base64: string;
|
|
5
|
+
filename?: string;
|
|
6
|
+
} | null;
|
|
7
|
+
/** The JSON value stored in the database (and returned from the output type). */
|
|
8
|
+
export type ComputedImageData = {
|
|
9
|
+
/** Storage key of the original image. */
|
|
10
|
+
key: string;
|
|
11
|
+
/** URL of the original image. */
|
|
12
|
+
url: string;
|
|
13
|
+
/** Storage key of the generated thumbnail (null when thumbnails are disabled). */
|
|
14
|
+
thumbnailKey: string | null;
|
|
15
|
+
/** URL of the generated thumbnail. */
|
|
16
|
+
thumbnailUrl: string | null;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
/** Original file size in bytes. */
|
|
20
|
+
filesize: number;
|
|
21
|
+
/** Image format as detected by sharp, e.g. 'jpeg', 'png', 'webp'. */
|
|
22
|
+
format: string;
|
|
23
|
+
/** Tiny inline blur placeholder (`data:image/webp;base64,…`), null when disabled. */
|
|
24
|
+
blurDataUrl: string | null;
|
|
25
|
+
/** Client-supplied filename, if any. */
|
|
26
|
+
filename: string | null;
|
|
27
|
+
};
|
|
28
|
+
export type ComputedImageFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = CommonFieldConfig<CollectionTypeInfo, BaseFieldTypeInfo> & {
|
|
29
|
+
storage?: {
|
|
30
|
+
/** Key prefix for stored objects. Default: 'images/' */
|
|
31
|
+
prefix?: string;
|
|
32
|
+
};
|
|
33
|
+
/** Thumbnail generation options, or `false` to disable. Default: { width: 320 } */
|
|
34
|
+
thumbnail?: {
|
|
35
|
+
width?: number;
|
|
36
|
+
} | false;
|
|
37
|
+
/** Generate a tiny inline blur placeholder. Default: true */
|
|
38
|
+
blur?: boolean;
|
|
39
|
+
db?: {
|
|
40
|
+
map?: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* An image upload field that computes width/height, generates a blur placeholder and a
|
|
45
|
+
* thumbnail, and stores files through `context.services.storage`.
|
|
46
|
+
*
|
|
47
|
+
* The GraphQL input is plain JSON — `{ base64, filename? }` or `null` to clear — and all
|
|
48
|
+
* processing happens in a field-level `resolveInput` hook (which receives the request context,
|
|
49
|
+
* unlike the field type's raw GraphQL input resolver). Requires the `sharp` peer dependency and
|
|
50
|
+
* a configured storage service.
|
|
51
|
+
*/
|
|
52
|
+
export declare function computedImage<CollectionTypeInfo extends BaseCollectionTypeInfo>(config?: ComputedImageFieldConfig<CollectionTypeInfo>): FieldTypeFunc<CollectionTypeInfo>;
|
|
53
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,iBAAiB,EACjB,sBAAsB,EACtB,iBAAiB,EACjB,aAAa,EACd,MAAM,wBAAwB,CAAA;AAO/B,+FAA+F;AAC/F,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,GAAG,IAAI,CAAA;AAER,iFAAiF;AACjF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,yCAAyC;IACzC,GAAG,EAAE,MAAM,CAAA;IACX,iCAAiC;IACjC,GAAG,EAAE,MAAM,CAAA;IACX,kFAAkF;IAClF,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,sCAAsC;IACtC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,mCAAmC;IACnC,QAAQ,EAAE,MAAM,CAAA;IAChB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAA;IACd,qFAAqF;IACrF,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,wCAAwC;IACxC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,wBAAwB,CAAC,kBAAkB,SAAS,sBAAsB,IACpF,iBAAiB,CAAC,kBAAkB,EAAE,iBAAiB,CAAC,GAAG;IACzD,OAAO,CAAC,EAAE;QACR,wDAAwD;QACxD,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB,CAAA;IACD,mFAAmF;IACnF,SAAS,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAA;IACtC,6DAA6D;IAC7D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,EAAE,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACtB,CAAA;AA0DH;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,kBAAkB,SAAS,sBAAsB,EAC7E,MAAM,GAAE,wBAAwB,CAAC,kBAAkB,CAAM,GACxD,aAAa,CAAC,kBAAkB,CAAC,CAsLnC"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export * from "./declarations/src/index.js";
|
|
2
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1maWVsZHMtY29tcHV0ZWQtaW1hZ2UuY2pzLmQudHMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuL2RlY2xhcmF0aW9ucy9zcmMvaW5kZXguZC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSJ9
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var core = require('@nixxie-cms/core');
|
|
6
|
+
var types = require('@nixxie-cms/core/types');
|
|
7
|
+
var node_crypto = require('node:crypto');
|
|
8
|
+
|
|
9
|
+
const PKG = '[@nixxie-cms/fields-computed-image]';
|
|
10
|
+
const MAX_BYTES = 50 * 1024 * 1024; // ~50MB decoded
|
|
11
|
+
|
|
12
|
+
/** The GraphQL input shape — a base64-encoded image (data URLs accepted), or null to clear. */
|
|
13
|
+
|
|
14
|
+
/** The JSON value stored in the database (and returned from the output type). */
|
|
15
|
+
|
|
16
|
+
const CONTENT_TYPES = {
|
|
17
|
+
jpeg: 'image/jpeg',
|
|
18
|
+
png: 'image/png',
|
|
19
|
+
webp: 'image/webp',
|
|
20
|
+
gif: 'image/gif',
|
|
21
|
+
avif: 'image/avif',
|
|
22
|
+
tiff: 'image/tiff',
|
|
23
|
+
svg: 'image/svg+xml',
|
|
24
|
+
heif: 'image/heif'
|
|
25
|
+
};
|
|
26
|
+
const outputType = core.g.object()({
|
|
27
|
+
name: 'ComputedImage',
|
|
28
|
+
fields: {
|
|
29
|
+
key: core.g.field({
|
|
30
|
+
type: core.g.nonNull(core.g.String)
|
|
31
|
+
}),
|
|
32
|
+
url: core.g.field({
|
|
33
|
+
type: core.g.nonNull(core.g.String)
|
|
34
|
+
}),
|
|
35
|
+
thumbnailKey: core.g.field({
|
|
36
|
+
type: core.g.String
|
|
37
|
+
}),
|
|
38
|
+
thumbnailUrl: core.g.field({
|
|
39
|
+
type: core.g.String
|
|
40
|
+
}),
|
|
41
|
+
width: core.g.field({
|
|
42
|
+
type: core.g.nonNull(core.g.Int)
|
|
43
|
+
}),
|
|
44
|
+
height: core.g.field({
|
|
45
|
+
type: core.g.nonNull(core.g.Int)
|
|
46
|
+
}),
|
|
47
|
+
filesize: core.g.field({
|
|
48
|
+
type: core.g.nonNull(core.g.Int)
|
|
49
|
+
}),
|
|
50
|
+
format: core.g.field({
|
|
51
|
+
type: core.g.nonNull(core.g.String)
|
|
52
|
+
}),
|
|
53
|
+
blurDataUrl: core.g.field({
|
|
54
|
+
type: core.g.String
|
|
55
|
+
}),
|
|
56
|
+
filename: core.g.field({
|
|
57
|
+
type: core.g.String
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
async function loadSharp() {
|
|
62
|
+
try {
|
|
63
|
+
var _mod$default;
|
|
64
|
+
const mod = await import('sharp');
|
|
65
|
+
return (_mod$default = mod.default) !== null && _mod$default !== void 0 ? _mod$default : mod;
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(`${PKG} sharp is required — install it with: pnpm add sharp`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Decode a base64 string (optionally a data URL), enforcing the decoded size cap. */
|
|
72
|
+
function decodeBase64(input, fieldKey) {
|
|
73
|
+
let base64 = input.trim();
|
|
74
|
+
const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(base64);
|
|
75
|
+
if (dataUrlMatch) base64 = dataUrlMatch[1];
|
|
76
|
+
base64 = base64.replace(/\s+/g, '');
|
|
77
|
+
// Pre-check before allocating: decoded size is ~3/4 of the base64 length.
|
|
78
|
+
if (base64.length * 0.75 > MAX_BYTES) {
|
|
79
|
+
throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`);
|
|
80
|
+
}
|
|
81
|
+
const buffer = Buffer.from(base64, 'base64');
|
|
82
|
+
if (buffer.length === 0) {
|
|
83
|
+
throw new Error(`${PKG} ${fieldKey}: base64 could not be decoded to any data`);
|
|
84
|
+
}
|
|
85
|
+
if (buffer.length > MAX_BYTES) {
|
|
86
|
+
throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`);
|
|
87
|
+
}
|
|
88
|
+
return buffer;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* An image upload field that computes width/height, generates a blur placeholder and a
|
|
93
|
+
* thumbnail, and stores files through `context.services.storage`.
|
|
94
|
+
*
|
|
95
|
+
* The GraphQL input is plain JSON — `{ base64, filename? }` or `null` to clear — and all
|
|
96
|
+
* processing happens in a field-level `resolveInput` hook (which receives the request context,
|
|
97
|
+
* unlike the field type's raw GraphQL input resolver). Requires the `sharp` peer dependency and
|
|
98
|
+
* a configured storage service.
|
|
99
|
+
*/
|
|
100
|
+
function computedImage(config = {}) {
|
|
101
|
+
var _config$storage$prefi, _config$storage, _config$thumbnail$wid, _config$thumbnail, _config$blur;
|
|
102
|
+
const prefix = (_config$storage$prefi = (_config$storage = config.storage) === null || _config$storage === void 0 ? void 0 : _config$storage.prefix) !== null && _config$storage$prefi !== void 0 ? _config$storage$prefi : 'images/';
|
|
103
|
+
const thumbnailWidth = config.thumbnail === false ? null : (_config$thumbnail$wid = (_config$thumbnail = config.thumbnail) === null || _config$thumbnail === void 0 ? void 0 : _config$thumbnail.width) !== null && _config$thumbnail$wid !== void 0 ? _config$thumbnail$wid : 320;
|
|
104
|
+
const blurEnabled = (_config$blur = config.blur) !== null && _config$blur !== void 0 ? _config$blur : true;
|
|
105
|
+
return meta => {
|
|
106
|
+
var _config$hooks, _config$hooks2, _config$db;
|
|
107
|
+
if (config.isIndexed === 'unique') {
|
|
108
|
+
throw Error("isIndexed: 'unique' is not a supported option for field type computedImage");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Remove a stored image (+thumbnail); logged (not thrown) since the DB write already succeeded. */
|
|
112
|
+
async function removeStored(context, stored) {
|
|
113
|
+
var _context$services;
|
|
114
|
+
if (!stored || typeof stored !== 'object') return;
|
|
115
|
+
const storage = context === null || context === void 0 || (_context$services = context.services) === null || _context$services === void 0 ? void 0 : _context$services.storage;
|
|
116
|
+
if (!storage) {
|
|
117
|
+
if (stored.key) {
|
|
118
|
+
console.error(`${PKG} Cannot clean up "${stored.key}": context.services.storage is not configured`);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const key of [stored.key, stored.thumbnailKey]) {
|
|
123
|
+
if (typeof key !== 'string' || key.length === 0) continue;
|
|
124
|
+
try {
|
|
125
|
+
await storage.delete(key);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error(`${PKG} Failed to delete "${key}" from storage:`, err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Decode, measure, derive (thumbnail + blur), store, and return the value to persist. */
|
|
133
|
+
async function processInput(args) {
|
|
134
|
+
var _context$services2, _CONTENT_TYPES$format;
|
|
135
|
+
const {
|
|
136
|
+
resolvedFieldData: value,
|
|
137
|
+
fieldKey,
|
|
138
|
+
context
|
|
139
|
+
} = args;
|
|
140
|
+
if (value === undefined) return undefined;
|
|
141
|
+
if (value === null) return null;
|
|
142
|
+
if (typeof value !== 'object' || Array.isArray(value) || typeof value.base64 !== 'string') {
|
|
143
|
+
throw new Error(`${PKG} ${fieldKey}: expected { base64: string, filename?: string } or null`);
|
|
144
|
+
}
|
|
145
|
+
const input = value;
|
|
146
|
+
const filename = typeof input.filename === 'string' && input.filename.length > 0 ? input.filename : null;
|
|
147
|
+
const storage = context === null || context === void 0 || (_context$services2 = context.services) === null || _context$services2 === void 0 ? void 0 : _context$services2.storage;
|
|
148
|
+
if (!storage) {
|
|
149
|
+
throw new Error(`${PKG} ${fieldKey}: context.services.storage is not configured — a storage service is required`);
|
|
150
|
+
}
|
|
151
|
+
const buffer = decodeBase64(input.base64, fieldKey);
|
|
152
|
+
const sharp = await loadSharp();
|
|
153
|
+
let metadata;
|
|
154
|
+
try {
|
|
155
|
+
metadata = await sharp(buffer).metadata();
|
|
156
|
+
} catch (err) {
|
|
157
|
+
var _message;
|
|
158
|
+
throw new Error(`${PKG} ${fieldKey}: could not read image (unsupported or corrupt): ${(_message = err === null || err === void 0 ? void 0 : err.message) !== null && _message !== void 0 ? _message : err}`);
|
|
159
|
+
}
|
|
160
|
+
const {
|
|
161
|
+
width,
|
|
162
|
+
height,
|
|
163
|
+
format
|
|
164
|
+
} = metadata !== null && metadata !== void 0 ? metadata : {};
|
|
165
|
+
if (typeof width !== 'number' || typeof height !== 'number' || typeof format !== 'string') {
|
|
166
|
+
throw new Error(`${PKG} ${fieldKey}: could not determine image dimensions/format`);
|
|
167
|
+
}
|
|
168
|
+
const id = node_crypto.randomBytes(12).toString('base64url');
|
|
169
|
+
const ext = format === 'jpeg' ? 'jpg' : format;
|
|
170
|
+
const key = `${prefix}${id}.${ext}`;
|
|
171
|
+
const contentType = (_CONTENT_TYPES$format = CONTENT_TYPES[format]) !== null && _CONTENT_TYPES$format !== void 0 ? _CONTENT_TYPES$format : `image/${format}`;
|
|
172
|
+
const stored = await storage.put(key, buffer, {
|
|
173
|
+
contentType,
|
|
174
|
+
public: true
|
|
175
|
+
});
|
|
176
|
+
let thumbnailKey = null;
|
|
177
|
+
let thumbnailUrl = null;
|
|
178
|
+
let blurDataUrl = null;
|
|
179
|
+
try {
|
|
180
|
+
if (thumbnailWidth !== null) {
|
|
181
|
+
const thumbBuffer = await sharp(buffer).resize({
|
|
182
|
+
width: thumbnailWidth,
|
|
183
|
+
withoutEnlargement: true
|
|
184
|
+
}).webp().toBuffer();
|
|
185
|
+
thumbnailKey = `${prefix}${id}.thumb.webp`;
|
|
186
|
+
const storedThumb = await storage.put(thumbnailKey, thumbBuffer, {
|
|
187
|
+
contentType: 'image/webp',
|
|
188
|
+
public: true
|
|
189
|
+
});
|
|
190
|
+
thumbnailUrl = storedThumb.url;
|
|
191
|
+
}
|
|
192
|
+
if (blurEnabled) {
|
|
193
|
+
const blurBuffer = await sharp(buffer).resize(16).webp({
|
|
194
|
+
quality: 40
|
|
195
|
+
}).toBuffer();
|
|
196
|
+
blurDataUrl = `data:image/webp;base64,${blurBuffer.toString('base64')}`;
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
// Don't leave orphaned objects behind when derivative generation fails mid-way.
|
|
200
|
+
await removeStored(context, {
|
|
201
|
+
key,
|
|
202
|
+
thumbnailKey
|
|
203
|
+
});
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
key,
|
|
208
|
+
url: stored.url,
|
|
209
|
+
thumbnailKey,
|
|
210
|
+
thumbnailUrl,
|
|
211
|
+
width,
|
|
212
|
+
height,
|
|
213
|
+
filesize: buffer.length,
|
|
214
|
+
format,
|
|
215
|
+
blurDataUrl,
|
|
216
|
+
filename
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Compose our processing/cleanup with any hooks the user configured: our resolveInput runs
|
|
221
|
+
// first (turning the raw { base64 } input into the stored JSON), then the user's; the
|
|
222
|
+
// user's afterOperation runs first, then our storage cleanup (mirroring @nixxie-cms/cloudinary).
|
|
223
|
+
const userResolveInput = (_config$hooks = config.hooks) === null || _config$hooks === void 0 ? void 0 : _config$hooks.resolveInput;
|
|
224
|
+
const userAfterOperation = (_config$hooks2 = config.hooks) === null || _config$hooks2 === void 0 ? void 0 : _config$hooks2.afterOperation;
|
|
225
|
+
const hooks = {
|
|
226
|
+
...config.hooks,
|
|
227
|
+
resolveInput: async args => {
|
|
228
|
+
const processed = await processInput(args);
|
|
229
|
+
const userHook = typeof userResolveInput === 'function' ? userResolveInput : userResolveInput === null || userResolveInput === void 0 ? void 0 : userResolveInput[args.operation];
|
|
230
|
+
if (!userHook) return processed;
|
|
231
|
+
return userHook({
|
|
232
|
+
...args,
|
|
233
|
+
resolvedFieldData: processed,
|
|
234
|
+
resolvedData: {
|
|
235
|
+
...args.resolvedData,
|
|
236
|
+
[args.fieldKey]: processed
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
afterOperation: async args => {
|
|
241
|
+
const userHook = typeof userAfterOperation === 'function' ? userAfterOperation : userAfterOperation === null || userAfterOperation === void 0 ? void 0 : userAfterOperation[args.operation];
|
|
242
|
+
await (userHook === null || userHook === void 0 ? void 0 : userHook(args));
|
|
243
|
+
if (args.operation === 'delete') {
|
|
244
|
+
await removeStored(args.context, args.originalItemField);
|
|
245
|
+
} else if (args.operation === 'update') {
|
|
246
|
+
const previous = args.originalItemField;
|
|
247
|
+
const next = args.itemField;
|
|
248
|
+
if (previous !== null && previous !== void 0 && previous.key && previous.key !== (next === null || next === void 0 ? void 0 : next.key)) {
|
|
249
|
+
await removeStored(args.context, previous);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const inputArg = core.g.arg({
|
|
255
|
+
type: core.g.JSON
|
|
256
|
+
});
|
|
257
|
+
return types.fieldType({
|
|
258
|
+
kind: 'scalar',
|
|
259
|
+
mode: 'optional',
|
|
260
|
+
scalar: 'Json',
|
|
261
|
+
map: (_config$db = config.db) === null || _config$db === void 0 ? void 0 : _config$db.map
|
|
262
|
+
})({
|
|
263
|
+
...config,
|
|
264
|
+
hooks,
|
|
265
|
+
__nxTelemetryFieldTypeName: '@nixxie-cms/fields-computed-image',
|
|
266
|
+
input: {
|
|
267
|
+
// The raw GraphQL input resolver does not receive context, so the value passes through
|
|
268
|
+
// here untouched; all storage work happens in the field-level resolveInput hook above.
|
|
269
|
+
create: {
|
|
270
|
+
arg: inputArg
|
|
271
|
+
},
|
|
272
|
+
update: {
|
|
273
|
+
arg: inputArg
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
output: core.g.field({
|
|
277
|
+
type: outputType,
|
|
278
|
+
resolve({
|
|
279
|
+
value
|
|
280
|
+
}) {
|
|
281
|
+
if (value === null || typeof value !== 'object') return null;
|
|
282
|
+
const val = value;
|
|
283
|
+
if (typeof val.key !== 'string') return null;
|
|
284
|
+
return {
|
|
285
|
+
thumbnailKey: null,
|
|
286
|
+
thumbnailUrl: null,
|
|
287
|
+
blurDataUrl: null,
|
|
288
|
+
filename: null,
|
|
289
|
+
...val
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}),
|
|
293
|
+
views: '@nixxie-cms/core/fields/types/json/views'
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
exports.computedImage = computedImage;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { g } from '@nixxie-cms/core';
|
|
2
|
+
import { fieldType } from '@nixxie-cms/core/types';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const PKG = '[@nixxie-cms/fields-computed-image]';
|
|
6
|
+
const MAX_BYTES = 50 * 1024 * 1024; // ~50MB decoded
|
|
7
|
+
|
|
8
|
+
/** The GraphQL input shape — a base64-encoded image (data URLs accepted), or null to clear. */
|
|
9
|
+
|
|
10
|
+
/** The JSON value stored in the database (and returned from the output type). */
|
|
11
|
+
|
|
12
|
+
const CONTENT_TYPES = {
|
|
13
|
+
jpeg: 'image/jpeg',
|
|
14
|
+
png: 'image/png',
|
|
15
|
+
webp: 'image/webp',
|
|
16
|
+
gif: 'image/gif',
|
|
17
|
+
avif: 'image/avif',
|
|
18
|
+
tiff: 'image/tiff',
|
|
19
|
+
svg: 'image/svg+xml',
|
|
20
|
+
heif: 'image/heif'
|
|
21
|
+
};
|
|
22
|
+
const outputType = g.object()({
|
|
23
|
+
name: 'ComputedImage',
|
|
24
|
+
fields: {
|
|
25
|
+
key: g.field({
|
|
26
|
+
type: g.nonNull(g.String)
|
|
27
|
+
}),
|
|
28
|
+
url: g.field({
|
|
29
|
+
type: g.nonNull(g.String)
|
|
30
|
+
}),
|
|
31
|
+
thumbnailKey: g.field({
|
|
32
|
+
type: g.String
|
|
33
|
+
}),
|
|
34
|
+
thumbnailUrl: g.field({
|
|
35
|
+
type: g.String
|
|
36
|
+
}),
|
|
37
|
+
width: g.field({
|
|
38
|
+
type: g.nonNull(g.Int)
|
|
39
|
+
}),
|
|
40
|
+
height: g.field({
|
|
41
|
+
type: g.nonNull(g.Int)
|
|
42
|
+
}),
|
|
43
|
+
filesize: g.field({
|
|
44
|
+
type: g.nonNull(g.Int)
|
|
45
|
+
}),
|
|
46
|
+
format: g.field({
|
|
47
|
+
type: g.nonNull(g.String)
|
|
48
|
+
}),
|
|
49
|
+
blurDataUrl: g.field({
|
|
50
|
+
type: g.String
|
|
51
|
+
}),
|
|
52
|
+
filename: g.field({
|
|
53
|
+
type: g.String
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
async function loadSharp() {
|
|
58
|
+
try {
|
|
59
|
+
var _mod$default;
|
|
60
|
+
const mod = await import('sharp');
|
|
61
|
+
return (_mod$default = mod.default) !== null && _mod$default !== void 0 ? _mod$default : mod;
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error(`${PKG} sharp is required — install it with: pnpm add sharp`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Decode a base64 string (optionally a data URL), enforcing the decoded size cap. */
|
|
68
|
+
function decodeBase64(input, fieldKey) {
|
|
69
|
+
let base64 = input.trim();
|
|
70
|
+
const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(base64);
|
|
71
|
+
if (dataUrlMatch) base64 = dataUrlMatch[1];
|
|
72
|
+
base64 = base64.replace(/\s+/g, '');
|
|
73
|
+
// Pre-check before allocating: decoded size is ~3/4 of the base64 length.
|
|
74
|
+
if (base64.length * 0.75 > MAX_BYTES) {
|
|
75
|
+
throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`);
|
|
76
|
+
}
|
|
77
|
+
const buffer = Buffer.from(base64, 'base64');
|
|
78
|
+
if (buffer.length === 0) {
|
|
79
|
+
throw new Error(`${PKG} ${fieldKey}: base64 could not be decoded to any data`);
|
|
80
|
+
}
|
|
81
|
+
if (buffer.length > MAX_BYTES) {
|
|
82
|
+
throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`);
|
|
83
|
+
}
|
|
84
|
+
return buffer;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An image upload field that computes width/height, generates a blur placeholder and a
|
|
89
|
+
* thumbnail, and stores files through `context.services.storage`.
|
|
90
|
+
*
|
|
91
|
+
* The GraphQL input is plain JSON — `{ base64, filename? }` or `null` to clear — and all
|
|
92
|
+
* processing happens in a field-level `resolveInput` hook (which receives the request context,
|
|
93
|
+
* unlike the field type's raw GraphQL input resolver). Requires the `sharp` peer dependency and
|
|
94
|
+
* a configured storage service.
|
|
95
|
+
*/
|
|
96
|
+
function computedImage(config = {}) {
|
|
97
|
+
var _config$storage$prefi, _config$storage, _config$thumbnail$wid, _config$thumbnail, _config$blur;
|
|
98
|
+
const prefix = (_config$storage$prefi = (_config$storage = config.storage) === null || _config$storage === void 0 ? void 0 : _config$storage.prefix) !== null && _config$storage$prefi !== void 0 ? _config$storage$prefi : 'images/';
|
|
99
|
+
const thumbnailWidth = config.thumbnail === false ? null : (_config$thumbnail$wid = (_config$thumbnail = config.thumbnail) === null || _config$thumbnail === void 0 ? void 0 : _config$thumbnail.width) !== null && _config$thumbnail$wid !== void 0 ? _config$thumbnail$wid : 320;
|
|
100
|
+
const blurEnabled = (_config$blur = config.blur) !== null && _config$blur !== void 0 ? _config$blur : true;
|
|
101
|
+
return meta => {
|
|
102
|
+
var _config$hooks, _config$hooks2, _config$db;
|
|
103
|
+
if (config.isIndexed === 'unique') {
|
|
104
|
+
throw Error("isIndexed: 'unique' is not a supported option for field type computedImage");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Remove a stored image (+thumbnail); logged (not thrown) since the DB write already succeeded. */
|
|
108
|
+
async function removeStored(context, stored) {
|
|
109
|
+
var _context$services;
|
|
110
|
+
if (!stored || typeof stored !== 'object') return;
|
|
111
|
+
const storage = context === null || context === void 0 || (_context$services = context.services) === null || _context$services === void 0 ? void 0 : _context$services.storage;
|
|
112
|
+
if (!storage) {
|
|
113
|
+
if (stored.key) {
|
|
114
|
+
console.error(`${PKG} Cannot clean up "${stored.key}": context.services.storage is not configured`);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
for (const key of [stored.key, stored.thumbnailKey]) {
|
|
119
|
+
if (typeof key !== 'string' || key.length === 0) continue;
|
|
120
|
+
try {
|
|
121
|
+
await storage.delete(key);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`${PKG} Failed to delete "${key}" from storage:`, err);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Decode, measure, derive (thumbnail + blur), store, and return the value to persist. */
|
|
129
|
+
async function processInput(args) {
|
|
130
|
+
var _context$services2, _CONTENT_TYPES$format;
|
|
131
|
+
const {
|
|
132
|
+
resolvedFieldData: value,
|
|
133
|
+
fieldKey,
|
|
134
|
+
context
|
|
135
|
+
} = args;
|
|
136
|
+
if (value === undefined) return undefined;
|
|
137
|
+
if (value === null) return null;
|
|
138
|
+
if (typeof value !== 'object' || Array.isArray(value) || typeof value.base64 !== 'string') {
|
|
139
|
+
throw new Error(`${PKG} ${fieldKey}: expected { base64: string, filename?: string } or null`);
|
|
140
|
+
}
|
|
141
|
+
const input = value;
|
|
142
|
+
const filename = typeof input.filename === 'string' && input.filename.length > 0 ? input.filename : null;
|
|
143
|
+
const storage = context === null || context === void 0 || (_context$services2 = context.services) === null || _context$services2 === void 0 ? void 0 : _context$services2.storage;
|
|
144
|
+
if (!storage) {
|
|
145
|
+
throw new Error(`${PKG} ${fieldKey}: context.services.storage is not configured — a storage service is required`);
|
|
146
|
+
}
|
|
147
|
+
const buffer = decodeBase64(input.base64, fieldKey);
|
|
148
|
+
const sharp = await loadSharp();
|
|
149
|
+
let metadata;
|
|
150
|
+
try {
|
|
151
|
+
metadata = await sharp(buffer).metadata();
|
|
152
|
+
} catch (err) {
|
|
153
|
+
var _message;
|
|
154
|
+
throw new Error(`${PKG} ${fieldKey}: could not read image (unsupported or corrupt): ${(_message = err === null || err === void 0 ? void 0 : err.message) !== null && _message !== void 0 ? _message : err}`);
|
|
155
|
+
}
|
|
156
|
+
const {
|
|
157
|
+
width,
|
|
158
|
+
height,
|
|
159
|
+
format
|
|
160
|
+
} = metadata !== null && metadata !== void 0 ? metadata : {};
|
|
161
|
+
if (typeof width !== 'number' || typeof height !== 'number' || typeof format !== 'string') {
|
|
162
|
+
throw new Error(`${PKG} ${fieldKey}: could not determine image dimensions/format`);
|
|
163
|
+
}
|
|
164
|
+
const id = randomBytes(12).toString('base64url');
|
|
165
|
+
const ext = format === 'jpeg' ? 'jpg' : format;
|
|
166
|
+
const key = `${prefix}${id}.${ext}`;
|
|
167
|
+
const contentType = (_CONTENT_TYPES$format = CONTENT_TYPES[format]) !== null && _CONTENT_TYPES$format !== void 0 ? _CONTENT_TYPES$format : `image/${format}`;
|
|
168
|
+
const stored = await storage.put(key, buffer, {
|
|
169
|
+
contentType,
|
|
170
|
+
public: true
|
|
171
|
+
});
|
|
172
|
+
let thumbnailKey = null;
|
|
173
|
+
let thumbnailUrl = null;
|
|
174
|
+
let blurDataUrl = null;
|
|
175
|
+
try {
|
|
176
|
+
if (thumbnailWidth !== null) {
|
|
177
|
+
const thumbBuffer = await sharp(buffer).resize({
|
|
178
|
+
width: thumbnailWidth,
|
|
179
|
+
withoutEnlargement: true
|
|
180
|
+
}).webp().toBuffer();
|
|
181
|
+
thumbnailKey = `${prefix}${id}.thumb.webp`;
|
|
182
|
+
const storedThumb = await storage.put(thumbnailKey, thumbBuffer, {
|
|
183
|
+
contentType: 'image/webp',
|
|
184
|
+
public: true
|
|
185
|
+
});
|
|
186
|
+
thumbnailUrl = storedThumb.url;
|
|
187
|
+
}
|
|
188
|
+
if (blurEnabled) {
|
|
189
|
+
const blurBuffer = await sharp(buffer).resize(16).webp({
|
|
190
|
+
quality: 40
|
|
191
|
+
}).toBuffer();
|
|
192
|
+
blurDataUrl = `data:image/webp;base64,${blurBuffer.toString('base64')}`;
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// Don't leave orphaned objects behind when derivative generation fails mid-way.
|
|
196
|
+
await removeStored(context, {
|
|
197
|
+
key,
|
|
198
|
+
thumbnailKey
|
|
199
|
+
});
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
key,
|
|
204
|
+
url: stored.url,
|
|
205
|
+
thumbnailKey,
|
|
206
|
+
thumbnailUrl,
|
|
207
|
+
width,
|
|
208
|
+
height,
|
|
209
|
+
filesize: buffer.length,
|
|
210
|
+
format,
|
|
211
|
+
blurDataUrl,
|
|
212
|
+
filename
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Compose our processing/cleanup with any hooks the user configured: our resolveInput runs
|
|
217
|
+
// first (turning the raw { base64 } input into the stored JSON), then the user's; the
|
|
218
|
+
// user's afterOperation runs first, then our storage cleanup (mirroring @nixxie-cms/cloudinary).
|
|
219
|
+
const userResolveInput = (_config$hooks = config.hooks) === null || _config$hooks === void 0 ? void 0 : _config$hooks.resolveInput;
|
|
220
|
+
const userAfterOperation = (_config$hooks2 = config.hooks) === null || _config$hooks2 === void 0 ? void 0 : _config$hooks2.afterOperation;
|
|
221
|
+
const hooks = {
|
|
222
|
+
...config.hooks,
|
|
223
|
+
resolveInput: async args => {
|
|
224
|
+
const processed = await processInput(args);
|
|
225
|
+
const userHook = typeof userResolveInput === 'function' ? userResolveInput : userResolveInput === null || userResolveInput === void 0 ? void 0 : userResolveInput[args.operation];
|
|
226
|
+
if (!userHook) return processed;
|
|
227
|
+
return userHook({
|
|
228
|
+
...args,
|
|
229
|
+
resolvedFieldData: processed,
|
|
230
|
+
resolvedData: {
|
|
231
|
+
...args.resolvedData,
|
|
232
|
+
[args.fieldKey]: processed
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
afterOperation: async args => {
|
|
237
|
+
const userHook = typeof userAfterOperation === 'function' ? userAfterOperation : userAfterOperation === null || userAfterOperation === void 0 ? void 0 : userAfterOperation[args.operation];
|
|
238
|
+
await (userHook === null || userHook === void 0 ? void 0 : userHook(args));
|
|
239
|
+
if (args.operation === 'delete') {
|
|
240
|
+
await removeStored(args.context, args.originalItemField);
|
|
241
|
+
} else if (args.operation === 'update') {
|
|
242
|
+
const previous = args.originalItemField;
|
|
243
|
+
const next = args.itemField;
|
|
244
|
+
if (previous !== null && previous !== void 0 && previous.key && previous.key !== (next === null || next === void 0 ? void 0 : next.key)) {
|
|
245
|
+
await removeStored(args.context, previous);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const inputArg = g.arg({
|
|
251
|
+
type: g.JSON
|
|
252
|
+
});
|
|
253
|
+
return fieldType({
|
|
254
|
+
kind: 'scalar',
|
|
255
|
+
mode: 'optional',
|
|
256
|
+
scalar: 'Json',
|
|
257
|
+
map: (_config$db = config.db) === null || _config$db === void 0 ? void 0 : _config$db.map
|
|
258
|
+
})({
|
|
259
|
+
...config,
|
|
260
|
+
hooks,
|
|
261
|
+
__nxTelemetryFieldTypeName: '@nixxie-cms/fields-computed-image',
|
|
262
|
+
input: {
|
|
263
|
+
// The raw GraphQL input resolver does not receive context, so the value passes through
|
|
264
|
+
// here untouched; all storage work happens in the field-level resolveInput hook above.
|
|
265
|
+
create: {
|
|
266
|
+
arg: inputArg
|
|
267
|
+
},
|
|
268
|
+
update: {
|
|
269
|
+
arg: inputArg
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
output: g.field({
|
|
273
|
+
type: outputType,
|
|
274
|
+
resolve({
|
|
275
|
+
value
|
|
276
|
+
}) {
|
|
277
|
+
if (value === null || typeof value !== 'object') return null;
|
|
278
|
+
const val = value;
|
|
279
|
+
if (typeof val.key !== 'string') return null;
|
|
280
|
+
return {
|
|
281
|
+
thumbnailKey: null,
|
|
282
|
+
thumbnailUrl: null,
|
|
283
|
+
blurDataUrl: null,
|
|
284
|
+
filename: null,
|
|
285
|
+
...val
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}),
|
|
289
|
+
views: '@nixxie-cms/core/fields/types/json/views'
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export { computedImage };
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nixxie-cms/fields-computed-image",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "dist/nixxie-cms-fields-computed-image.cjs.js",
|
|
6
|
+
"module": "dist/nixxie-cms-fields-computed-image.esm.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/nixxie-cms-fields-computed-image.cjs.js",
|
|
10
|
+
"module": "./dist/nixxie-cms-fields-computed-image.esm.js",
|
|
11
|
+
"default": "./dist/nixxie-cms-fields-computed-image.cjs.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@babel/runtime": "^7.24.7"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"sharp": "^0.33.0",
|
|
20
|
+
"@nixxie-cms/core": "^1.1.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@nixxie-cms/core": "^1.0.3",
|
|
24
|
+
"sharp": "^0.33.0"
|
|
25
|
+
},
|
|
26
|
+
"preconstruct": {
|
|
27
|
+
"entrypoints": [
|
|
28
|
+
"index.ts"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/nixxiecms/nixxie/tree/main/packages/fields-computed-image"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { g } from '@nixxie-cms/core'
|
|
2
|
+
import type {
|
|
3
|
+
BaseFieldTypeInfo,
|
|
4
|
+
BaseCollectionTypeInfo,
|
|
5
|
+
CommonFieldConfig,
|
|
6
|
+
FieldTypeFunc,
|
|
7
|
+
} from '@nixxie-cms/core/types'
|
|
8
|
+
import { fieldType } from '@nixxie-cms/core/types'
|
|
9
|
+
import { randomBytes } from 'node:crypto'
|
|
10
|
+
|
|
11
|
+
const PKG = '[@nixxie-cms/fields-computed-image]'
|
|
12
|
+
const MAX_BYTES = 50 * 1024 * 1024 // ~50MB decoded
|
|
13
|
+
|
|
14
|
+
/** The GraphQL input shape — a base64-encoded image (data URLs accepted), or null to clear. */
|
|
15
|
+
export type ComputedImageInput = {
|
|
16
|
+
base64: string
|
|
17
|
+
filename?: string
|
|
18
|
+
} | null
|
|
19
|
+
|
|
20
|
+
/** The JSON value stored in the database (and returned from the output type). */
|
|
21
|
+
export type ComputedImageData = {
|
|
22
|
+
/** Storage key of the original image. */
|
|
23
|
+
key: string
|
|
24
|
+
/** URL of the original image. */
|
|
25
|
+
url: string
|
|
26
|
+
/** Storage key of the generated thumbnail (null when thumbnails are disabled). */
|
|
27
|
+
thumbnailKey: string | null
|
|
28
|
+
/** URL of the generated thumbnail. */
|
|
29
|
+
thumbnailUrl: string | null
|
|
30
|
+
width: number
|
|
31
|
+
height: number
|
|
32
|
+
/** Original file size in bytes. */
|
|
33
|
+
filesize: number
|
|
34
|
+
/** Image format as detected by sharp, e.g. 'jpeg', 'png', 'webp'. */
|
|
35
|
+
format: string
|
|
36
|
+
/** Tiny inline blur placeholder (`data:image/webp;base64,…`), null when disabled. */
|
|
37
|
+
blurDataUrl: string | null
|
|
38
|
+
/** Client-supplied filename, if any. */
|
|
39
|
+
filename: string | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ComputedImageFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> =
|
|
43
|
+
CommonFieldConfig<CollectionTypeInfo, BaseFieldTypeInfo> & {
|
|
44
|
+
storage?: {
|
|
45
|
+
/** Key prefix for stored objects. Default: 'images/' */
|
|
46
|
+
prefix?: string
|
|
47
|
+
}
|
|
48
|
+
/** Thumbnail generation options, or `false` to disable. Default: { width: 320 } */
|
|
49
|
+
thumbnail?: { width?: number } | false
|
|
50
|
+
/** Generate a tiny inline blur placeholder. Default: true */
|
|
51
|
+
blur?: boolean
|
|
52
|
+
db?: { map?: string }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
56
|
+
jpeg: 'image/jpeg',
|
|
57
|
+
png: 'image/png',
|
|
58
|
+
webp: 'image/webp',
|
|
59
|
+
gif: 'image/gif',
|
|
60
|
+
avif: 'image/avif',
|
|
61
|
+
tiff: 'image/tiff',
|
|
62
|
+
svg: 'image/svg+xml',
|
|
63
|
+
heif: 'image/heif',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const outputType = g.object<ComputedImageData>()({
|
|
67
|
+
name: 'ComputedImage',
|
|
68
|
+
fields: {
|
|
69
|
+
key: g.field({ type: g.nonNull(g.String) }),
|
|
70
|
+
url: g.field({ type: g.nonNull(g.String) }),
|
|
71
|
+
thumbnailKey: g.field({ type: g.String }),
|
|
72
|
+
thumbnailUrl: g.field({ type: g.String }),
|
|
73
|
+
width: g.field({ type: g.nonNull(g.Int) }),
|
|
74
|
+
height: g.field({ type: g.nonNull(g.Int) }),
|
|
75
|
+
filesize: g.field({ type: g.nonNull(g.Int) }),
|
|
76
|
+
format: g.field({ type: g.nonNull(g.String) }),
|
|
77
|
+
blurDataUrl: g.field({ type: g.String }),
|
|
78
|
+
filename: g.field({ type: g.String }),
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
async function loadSharp(): Promise<any> {
|
|
83
|
+
try {
|
|
84
|
+
const mod: any = await import('sharp')
|
|
85
|
+
return mod.default ?? mod
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error(`${PKG} sharp is required — install it with: pnpm add sharp`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Decode a base64 string (optionally a data URL), enforcing the decoded size cap. */
|
|
92
|
+
function decodeBase64(input: string, fieldKey: string): Buffer {
|
|
93
|
+
let base64 = input.trim()
|
|
94
|
+
const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(base64)
|
|
95
|
+
if (dataUrlMatch) base64 = dataUrlMatch[1]
|
|
96
|
+
base64 = base64.replace(/\s+/g, '')
|
|
97
|
+
// Pre-check before allocating: decoded size is ~3/4 of the base64 length.
|
|
98
|
+
if (base64.length * 0.75 > MAX_BYTES) {
|
|
99
|
+
throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`)
|
|
100
|
+
}
|
|
101
|
+
const buffer = Buffer.from(base64, 'base64')
|
|
102
|
+
if (buffer.length === 0) {
|
|
103
|
+
throw new Error(`${PKG} ${fieldKey}: base64 could not be decoded to any data`)
|
|
104
|
+
}
|
|
105
|
+
if (buffer.length > MAX_BYTES) {
|
|
106
|
+
throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`)
|
|
107
|
+
}
|
|
108
|
+
return buffer
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* An image upload field that computes width/height, generates a blur placeholder and a
|
|
113
|
+
* thumbnail, and stores files through `context.services.storage`.
|
|
114
|
+
*
|
|
115
|
+
* The GraphQL input is plain JSON — `{ base64, filename? }` or `null` to clear — and all
|
|
116
|
+
* processing happens in a field-level `resolveInput` hook (which receives the request context,
|
|
117
|
+
* unlike the field type's raw GraphQL input resolver). Requires the `sharp` peer dependency and
|
|
118
|
+
* a configured storage service.
|
|
119
|
+
*/
|
|
120
|
+
export function computedImage<CollectionTypeInfo extends BaseCollectionTypeInfo>(
|
|
121
|
+
config: ComputedImageFieldConfig<CollectionTypeInfo> = {}
|
|
122
|
+
): FieldTypeFunc<CollectionTypeInfo> {
|
|
123
|
+
const prefix = config.storage?.prefix ?? 'images/'
|
|
124
|
+
const thumbnailWidth = config.thumbnail === false ? null : (config.thumbnail?.width ?? 320)
|
|
125
|
+
const blurEnabled = config.blur ?? true
|
|
126
|
+
|
|
127
|
+
return meta => {
|
|
128
|
+
if ((config as any).isIndexed === 'unique') {
|
|
129
|
+
throw Error("isIndexed: 'unique' is not a supported option for field type computedImage")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Remove a stored image (+thumbnail); logged (not thrown) since the DB write already succeeded. */
|
|
133
|
+
async function removeStored(context: any, stored: Partial<ComputedImageData> | null | undefined) {
|
|
134
|
+
if (!stored || typeof stored !== 'object') return
|
|
135
|
+
const storage = context?.services?.storage
|
|
136
|
+
if (!storage) {
|
|
137
|
+
if (stored.key) {
|
|
138
|
+
console.error(`${PKG} Cannot clean up "${stored.key}": context.services.storage is not configured`)
|
|
139
|
+
}
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
for (const key of [stored.key, stored.thumbnailKey]) {
|
|
143
|
+
if (typeof key !== 'string' || key.length === 0) continue
|
|
144
|
+
try {
|
|
145
|
+
await storage.delete(key)
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error(`${PKG} Failed to delete "${key}" from storage:`, err)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Decode, measure, derive (thumbnail + blur), store, and return the value to persist. */
|
|
153
|
+
async function processInput(args: any): Promise<ComputedImageData | null | undefined> {
|
|
154
|
+
const { resolvedFieldData: value, fieldKey, context } = args
|
|
155
|
+
if (value === undefined) return undefined
|
|
156
|
+
if (value === null) return null
|
|
157
|
+
|
|
158
|
+
if (typeof value !== 'object' || Array.isArray(value) || typeof (value as any).base64 !== 'string') {
|
|
159
|
+
throw new Error(`${PKG} ${fieldKey}: expected { base64: string, filename?: string } or null`)
|
|
160
|
+
}
|
|
161
|
+
const input = value as { base64: string; filename?: unknown }
|
|
162
|
+
const filename = typeof input.filename === 'string' && input.filename.length > 0 ? input.filename : null
|
|
163
|
+
|
|
164
|
+
const storage = context?.services?.storage
|
|
165
|
+
if (!storage) {
|
|
166
|
+
throw new Error(`${PKG} ${fieldKey}: context.services.storage is not configured — a storage service is required`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const buffer = decodeBase64(input.base64, fieldKey)
|
|
170
|
+
const sharp = await loadSharp()
|
|
171
|
+
|
|
172
|
+
let metadata: any
|
|
173
|
+
try {
|
|
174
|
+
metadata = await sharp(buffer).metadata()
|
|
175
|
+
} catch (err) {
|
|
176
|
+
throw new Error(`${PKG} ${fieldKey}: could not read image (unsupported or corrupt): ${(err as Error)?.message ?? err}`)
|
|
177
|
+
}
|
|
178
|
+
const { width, height, format } = metadata ?? {}
|
|
179
|
+
if (typeof width !== 'number' || typeof height !== 'number' || typeof format !== 'string') {
|
|
180
|
+
throw new Error(`${PKG} ${fieldKey}: could not determine image dimensions/format`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const id = randomBytes(12).toString('base64url')
|
|
184
|
+
const ext = format === 'jpeg' ? 'jpg' : format
|
|
185
|
+
const key = `${prefix}${id}.${ext}`
|
|
186
|
+
const contentType = CONTENT_TYPES[format] ?? `image/${format}`
|
|
187
|
+
|
|
188
|
+
const stored = await storage.put(key, buffer, { contentType, public: true })
|
|
189
|
+
|
|
190
|
+
let thumbnailKey: string | null = null
|
|
191
|
+
let thumbnailUrl: string | null = null
|
|
192
|
+
let blurDataUrl: string | null = null
|
|
193
|
+
try {
|
|
194
|
+
if (thumbnailWidth !== null) {
|
|
195
|
+
const thumbBuffer = await sharp(buffer)
|
|
196
|
+
.resize({ width: thumbnailWidth, withoutEnlargement: true })
|
|
197
|
+
.webp()
|
|
198
|
+
.toBuffer()
|
|
199
|
+
thumbnailKey = `${prefix}${id}.thumb.webp`
|
|
200
|
+
const storedThumb = await storage.put(thumbnailKey, thumbBuffer, {
|
|
201
|
+
contentType: 'image/webp',
|
|
202
|
+
public: true,
|
|
203
|
+
})
|
|
204
|
+
thumbnailUrl = storedThumb.url
|
|
205
|
+
}
|
|
206
|
+
if (blurEnabled) {
|
|
207
|
+
const blurBuffer = await sharp(buffer).resize(16).webp({ quality: 40 }).toBuffer()
|
|
208
|
+
blurDataUrl = `data:image/webp;base64,${blurBuffer.toString('base64')}`
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
// Don't leave orphaned objects behind when derivative generation fails mid-way.
|
|
212
|
+
await removeStored(context, { key, thumbnailKey })
|
|
213
|
+
throw err
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
key,
|
|
218
|
+
url: stored.url,
|
|
219
|
+
thumbnailKey,
|
|
220
|
+
thumbnailUrl,
|
|
221
|
+
width,
|
|
222
|
+
height,
|
|
223
|
+
filesize: buffer.length,
|
|
224
|
+
format,
|
|
225
|
+
blurDataUrl,
|
|
226
|
+
filename,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Compose our processing/cleanup with any hooks the user configured: our resolveInput runs
|
|
231
|
+
// first (turning the raw { base64 } input into the stored JSON), then the user's; the
|
|
232
|
+
// user's afterOperation runs first, then our storage cleanup (mirroring @nixxie-cms/cloudinary).
|
|
233
|
+
const userResolveInput = config.hooks?.resolveInput
|
|
234
|
+
const userAfterOperation = config.hooks?.afterOperation
|
|
235
|
+
const hooks: typeof config.hooks = {
|
|
236
|
+
...config.hooks,
|
|
237
|
+
resolveInput: async (args: any) => {
|
|
238
|
+
const processed = await processInput(args)
|
|
239
|
+
const userHook =
|
|
240
|
+
typeof userResolveInput === 'function'
|
|
241
|
+
? userResolveInput
|
|
242
|
+
: (userResolveInput as any)?.[args.operation]
|
|
243
|
+
if (!userHook) return processed
|
|
244
|
+
return userHook({
|
|
245
|
+
...args,
|
|
246
|
+
resolvedFieldData: processed,
|
|
247
|
+
resolvedData: { ...args.resolvedData, [args.fieldKey]: processed },
|
|
248
|
+
})
|
|
249
|
+
},
|
|
250
|
+
afterOperation: async (args: any) => {
|
|
251
|
+
const userHook =
|
|
252
|
+
typeof userAfterOperation === 'function'
|
|
253
|
+
? userAfterOperation
|
|
254
|
+
: (userAfterOperation as any)?.[args.operation]
|
|
255
|
+
await (userHook as any)?.(args)
|
|
256
|
+
|
|
257
|
+
if (args.operation === 'delete') {
|
|
258
|
+
await removeStored(args.context, args.originalItemField as ComputedImageData | null)
|
|
259
|
+
} else if (args.operation === 'update') {
|
|
260
|
+
const previous = args.originalItemField as ComputedImageData | null
|
|
261
|
+
const next = args.itemField as ComputedImageData | null
|
|
262
|
+
if (previous?.key && previous.key !== next?.key) {
|
|
263
|
+
await removeStored(args.context, previous)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const inputArg = g.arg({ type: g.JSON })
|
|
270
|
+
|
|
271
|
+
return fieldType({
|
|
272
|
+
kind: 'scalar',
|
|
273
|
+
mode: 'optional',
|
|
274
|
+
scalar: 'Json',
|
|
275
|
+
map: config.db?.map,
|
|
276
|
+
})({
|
|
277
|
+
...config,
|
|
278
|
+
hooks,
|
|
279
|
+
__nxTelemetryFieldTypeName: '@nixxie-cms/fields-computed-image',
|
|
280
|
+
input: {
|
|
281
|
+
// The raw GraphQL input resolver does not receive context, so the value passes through
|
|
282
|
+
// here untouched; all storage work happens in the field-level resolveInput hook above.
|
|
283
|
+
create: { arg: inputArg },
|
|
284
|
+
update: { arg: inputArg },
|
|
285
|
+
},
|
|
286
|
+
output: g.field({
|
|
287
|
+
type: outputType,
|
|
288
|
+
resolve({ value }) {
|
|
289
|
+
if (value === null || typeof value !== 'object') return null
|
|
290
|
+
const val = value as any
|
|
291
|
+
if (typeof val.key !== 'string') return null
|
|
292
|
+
return {
|
|
293
|
+
thumbnailKey: null,
|
|
294
|
+
thumbnailUrl: null,
|
|
295
|
+
blurDataUrl: null,
|
|
296
|
+
filename: null,
|
|
297
|
+
...val,
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
views: '@nixxie-cms/core/fields/types/json/views',
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
}
|