-
Notifications
You must be signed in to change notification settings - Fork 294
feat: add imgproxy #1337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: add imgproxy #1337
Conversation
@@ -33,6 +33,7 @@ | |||
"consola": "^3.2.3", | |||
"defu": "^6.1.4", | |||
"h3": "^1.11.1", | |||
"hash.js": "^1.1.7", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as you've suggested, let's switch to ohash
🙏
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not so experienced in hash or ohash, but I think that ohash misses the hmac
function to generate the signature.
const hmac = hash.hmac(hash.sha256, hexDecode(secret));
More in general, I'm open to suggestions on this topic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@casualmatt I have a implement at #963, using uncrypto
, but need async getImage
support
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ty for the hint, I will work on it tomorrow or later today👍🏻
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@everyx
I see, .. so to properly support imgproxy, and to do it safely or imgproxy will allow us to optimize any URL provided; we are waiting for:
#276 --> To securely sign the URLs with uncrypto.
#963 --> To support getImage and not just the NuxtImg component.
I hope to get it right,
For now, as an alternative, @danielroe, we could remove the signing of the URL and add big, pretty big, I would say, disclaimer to use the EnvVar IMGPROXY_ALLOWED_SOURCES
to secure the install of imgproxy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest nobel-hashes if sync HMAC is needed for time being. We can have a smart node/js util from uncrypto later that is sync for better perf.
|
||
export const getImage: ProviderGetImage = (src, options) => { | ||
const { modifiers, url, salt, key } = options; | ||
const mergeModifiers = { ...defaultModifiers, ...modifiers }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As per the Cloudinary provider, defu
should be used here to ensure the defaults are correctly merged.
<NuxtImg src="..." width="400" height="400" />
results in the following mergeModifiers
values with and without defu
, if modifiers
isn't set in nuxt.config.ts.
Without (this results in srcset
not having the defaults set on the <img />
):
{ fit: undefined, width: 400, height: 400, gravity: 'no', enlarge: 1, format: undefined, quality: undefined, background: undefined }
{ fit: undefined, width: 800, height: 800, gravity: 'no', enlarge: 1, format: undefined, quality: undefined, background: undefined }
{ fit: 'fill', width: 400, height: 400, gravity: 'no', enlarge: 1, format: 'webp' }
With:
{ fit: 'fill', width: 400, height: 400, gravity: 'no', enlarge: 1, format: 'webp' }
{ fit: 'fill', width: 800, height: 800, gravity: 'no', enlarge: 1, format: 'webp' }
{ fit: 'fill', width: 400, height: 400, gravity: 'no', enlarge: 1, format: 'webp' }
@casualmatt I have implemented a version myself, you can use it as a reference import type { ImageModifiers } from '@nuxt/image'
import { joinURL } from 'ufo'
import { defu } from 'defu'
import { urlSafeBase64 } from '../utils'
import { createOperationsGenerator } from '#image'
export interface ImgproxyModifiers extends ImageModifiers {
quality: string
background: string
rotate: 'auto_right' | 'auto_left' | 'ignore' | 'vflip' | 'hflip' | number
roundCorner: string
gravity: 'sm' | string
effect: string
color: string
flags: string
dpr: string
opacity: number
overlay: string
underlay: string
transformation: string
zoom: number
colorSpace: string
customFunc: string
density: number
aspectRatio: string
}
export interface ImgproxyOptions {
baseURL?: string
modifiers?: Partial<ImgproxyOptions>
key?: string
salt?: string
signatureSize?: number
srcPrefix?: string
[key: string]: any
}
const operationsGenerator = createOperationsGenerator({
keyMap: {
// standard
width: 'w',
height: 'h',
// format will act as a extension
// format: 'f',
quality: 'q',
fit: 'rs',
// imgporxy
formatQuality: 'fq', // fq:%format1:%quality1:%format2:%quality2:...:%formatN:%qualityN
resize: 'rs', // rs:%resizing_type:%width:%height:%enlarge:%extend
size: 's', // s:%width:%height:%enlarge:%extend
resizingType: 'rt', // rt:%resizing_type
enlarge: 'el', // el:%enlarge
extend: 'ex', // ex:%extend:%gravity
minWidth: 'mw', // mw:%width
minHeight: 'mh', // min-height
zoom: 'z', // z:%zoom_x_y | z:%zoom_x:%zoom_y
dpr: 'dpr', // dpr:%dpr
extendAspectRatio: 'exar', // exar:%extend:%gravity
gravity: 'g', // g:%type:%x_offset:%y_offset
crop: 'c', // c:%width:%height:%gravity
trim: 't', // t:%threshold:%color:%equal_hor:%equal_ver
padding: 'pd', // pd:%top:%right:%bottom:%left
autoRotate: 'ar', // ar:%auto_rotate
rotate: 'rot', // rot:%angle
background: 'bg', // bg:%R:%G:%B | bg:%hex_color
blur: 'bl', // bl:%sigma
sharpen: 'sh', // sh:%sigma
pixelate: 'pix', // pix:%size
watermark: 'wm', // wm:%opacity:%position:%x_offset:%y_offset:%scale
stripMetadata: 'sm', // sm:%strip_metadata
keepCopyright: 'kcr', // kcr:%keep_copyright
stripColorProfile: 'scp', // scp:%strip_color_profile
enforceThumbnail: 'eth', // eth:%enforce_thumbnail
max_bytes: 'mb', // mb:%bytes
skipProcessing: 'skp', // skp:%extension1:%extension2:...:%extensionN
raw: 'raw', // raw:%raw
cachebuster: 'cb', // cb:%string
expires: 'exp', // exp:%timestamp
filename: 'fn', // fn:%string
// pro features
resizingAlgorithm: 'ra', // * ra:%algorithm
unsharpening: 'ush', // * ush:%mode:%weight:%dividor
blurDetections: 'bd', // * bd:%sigma:%class_name1:%class_name2:...:%class_nameN
drawDetections: 'dd', // * dd:%draw:%class_name1:%class_name2:...:%class_nameN
gradient: 'gr', // * gr:%opacity:%color:%direction:%start%stop
watermarkURL: 'wmu', // * wmu:%url
watermarkText: 'wmt', // * wmt:%text
watermarkSize: 'wms', // * wms:%width:%height
watermarkShadow: 'wmsh', // * wmsh:%sigma
style: 'st', // * st:%style
backgroundAlpha: 'bga', // * bga:%alpha
adjust: 'a', // * a:%brightness:%contrast:%saturation
brightness: 'br', // * br:%brightness
contrast: 'co', // * co:%contrast
saturation: 'sa', // * sa:%saturation
autoquality: 'aq', // * aq:%method:%target:%min_quality:%max_quality:%allowed_error
jpegOptions: 'jpgo', // * jpgo:%progressive:%no_subsample:%trellis_quant:%overshoot_deringing:%optimize_scans:%quant_table
pngOptions: 'pngo', // * pngo:%interlaced:%quantize:%quantization_colors
webpOptions: 'pngo', // * webpo:%compression
page: 'pg', // * pg:%page
disableAnimation: 'da', // * da:%disable
videoThumbnailSecond: 'vts', // * vts:%second
fallbackImageUrl: 'fiu', // * fiu:%url
},
valueMap: {
fit: {
cover: 'fill:::1:0',
contain: 'fit:::0:1',
fill: 'force:::1:0',
inside: 'fit:::0:0', // inside use min dimensions
outside: 'fit:::0:0', // outside use max dimensions
},
},
joinWith: '/',
formatter: (key: string, val: string) => `${key}:${val}`,
})
/**
* 让修饰符兼容 nuxt image 默认选项值
*/
function makeModifiersCompatible(modifiers: Partial<ImgproxyModifiers> = {}): Partial<ImgproxyModifiers> {
const _modifiers: Partial<ImgproxyModifiers> = { ...modifiers }
if (_modifiers.fit === 'outside' && _modifiers.width && _modifiers.height) {
if (_modifiers.width > _modifiers.height)
delete _modifiers.height
else
delete _modifiers.width
}
// 这里采用 URL 后缀方式来设置 format,不使用 format 参数
if (_modifiers.format)
delete _modifiers.format
return _modifiers
}
const defaultModifiers = {
fit: 'cover',
}
export function getImage(
src: string,
{ modifiers = {}, baseURL = '/', srcPrefix = '' }: ImgproxyOptions = {}, // signatureSize = 32, key = '', salt = '',
) {
const mergedModifiers = defu(modifiers, defaultModifiers)
const compModifiers = makeModifiersCompatible(mergedModifiers)
const processingOptions = operationsGenerator(compModifiers)
const finalSrc = srcPrefix.length > 0 ? src.replace(new RegExp(srcPrefix), '') : src
const encodedURL = urlSafeBase64(finalSrc)
// const signature = await sign(salt, `/${processingOptions}/${encodedURL}`, key, signatureSize);
const signature = '_'
const extension = (typeof modifiers.format === 'string' && modifiers.format.length > 0)
? modifiers.format
: undefined
// https://docs.imgproxy.net/generating_the_url?id=example
return {
url: joinURL(
baseURL,
signature,
processingOptions,
extension ? `${encodedURL}.${extension}` : encodedURL,
),
}
}
export default getImage |
@everyx Without support for server-side signatures, I wouldn't recommend publishing this. The risk is that users might expose their Imgproxy instance to the public, which could lead to security vulnerabilities or abuses. |
you don't have to use salt and salt, by the way. can remove these 2 situations and assign them to normal requests. Salt protection is an additional feature. |
Thanks for the draft! Here are some remarks I found along the way to find a working solution based on this. Basically it should be possible to configured the secrets through the protected runtime configration to prevent spillover to client readable assets: export default defineNuxtConfig({
runtimeConfig: {
imgProxyBaseUrl: 'https://...',
imgProxyKey: '...',
imgProxySalt: '...',
},
}) This works for the initial SSR where the hashes are applied correctly, and the images load, but fails after a few seconds after the SSR hydration step, where the client enables the interactivity and binds the HTML as the secrets are no longer available. I also tried to go with the maintained jssha lib for HMAC signing as it still offers a synchronous API. Also had to drop import jsSHA from 'jssha/sha256'
function urlSafeBase64(str: string) {
return btoa(encodeURI(str))
}
function hex2string(str: string) {
return str.match(/.{1,2}/g)?.map(function (v) {
return String.fromCharCode(parseInt(v, 16))
}).join('') ?? ''
}
function sign(salt: string, target: string, secret: string) {
const obj = new jsSHA('SHA-256', 'BYTES', {
hmacKey: {value: hex2string(secret), format: 'BYTES'},
})
obj.update(hex2string(salt))
obj.update(target)
const hash = obj.getHash('B64')
return hash
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
}
export const getImage: ProviderGetImage = (src, options) => {
const {modifiers} = options
const runtimeConfig = useRuntimeConfig()
const imgResolved = 'local://' + src.toString()
const mergeModifiers = {...defaultModifiers, ...modifiers}
const encodedUrl = urlSafeBase64(imgResolved)
const path = joinURL('/', operationsGenerator(mergeModifiers), encodedUrl)
const signature = sign(runtimeConfig.imgProxySalt, path, runtimeConfig.imgProxyKey)
return {
url: joinURL(runtimeConfig.imgProxyBaseUrl, signature, path),
}
} So for me the last step remains, rendering the whole thing server-only, to apply hashes with the secret, and serve them. I think NuxtIsland or Server Components will be my next steps. Maybe these notes offer some additional insights. |
Finished a prototype with server components. export default defineNuxtConfig({
experimental: {
componentIslands: true,
},
}) Then create a <template>
<NuxtImg />
</template> and, for whatever reason, it still sometimes calls the code on dynamic routes like export const getImage: ProviderGetImage = (src, options) => {
// ...
const runtimeConfig = useRuntimeConfig()
if (import.meta.client) {
return { url: '#' }
}
// ...
}) and now all images are rendered and signed on the server side, no hydration errors either, which surprised me. Solves it, but gives more requests, but I'm out of ideas how this could be accomplised in any other way. |
Also looked through the compiled bundle and |
WIP.
To start somewhere, I just imported my custom provider for imgproxy.
I used
hash.js
but that could probably be switch for ohash.And I'm open to suggestions on how to secure the
imgProxySalt
andimgProxyKey
.--> Add support provider "imgproxy"