Skip to content

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

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft

feat: add imgproxy #1337

wants to merge 3 commits into from

Conversation

casualmatt
Copy link

@casualmatt casualmatt commented Apr 22, 2024

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 and imgProxyKey.

--> Add support provider "imgproxy"

@@ -33,6 +33,7 @@
"consola": "^3.2.3",
"defu": "^6.1.4",
"h3": "^1.11.1",
"hash.js": "^1.1.7",
Copy link
Member

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 🙏

Copy link
Author

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.

Copy link

@everyx everyx Apr 23, 2024

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

Copy link
Author

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👍🏻

Copy link
Author

@casualmatt casualmatt Apr 24, 2024

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.

Copy link
Member

@pi0 pi0 Apr 3, 2025

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.

@everyx
Copy link

everyx commented Apr 23, 2024

Some related resource about "secure the imgProxySalt and imgProxyKey"

#276
#963


export const getImage: ProviderGetImage = (src, options) => {
const { modifiers, url, salt, key } = options;
const mergeModifiers = { ...defaultModifiers, ...modifiers };
Copy link

@tombonez tombonez Jun 1, 2024

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' }

@everyx
Copy link

everyx commented Jun 1, 2024

@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

@casualmatt
Copy link
Author

@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.

@productdevbook
Copy link
Member

productdevbook commented Dec 23, 2024

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.

@adrianrudnik
Copy link

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 Buffer.from as it is Node.js-specific and forces a full app reload on every route navigation. Same happend for other approaches I tried, like TextEncoder and import {createHmac} from 'crypto' that were dead ends for a "simple" HMAC signature without heavy dependencies. This led to

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.

@adrianrudnik
Copy link

Finished a prototype with server components.

export default defineNuxtConfig({
  experimental: {
    componentIslands: true,
  },
})

Then create a ImageProxy.server.vue with

<template>
  <NuxtImg />
</template>

and, for whatever reason, it still sometimes calls the code on dynamic routes like [slug].vue, where the secrets are not available, so we have to have a failover in the imgproxy.ts,

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.

@adrianrudnik
Copy link

Also looked through the compiled bundle and .output stuff, the whole jssha lib never gets served to the client, it resides only in the server bundle, which is nice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants