npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

minio-imgproxy

v1.4.0

Published

Lightweight TypeScript library to upload files to MinIO (or any S3-compatible storage) and generate secure imgproxy URLs.

Readme

minio-imgproxy

npm version license node

Upload files to MinIO (or any S3-compatible storage) and get back a signed imgproxy URL — in one call.

  • HMAC-SHA256 signed URLs — prevents URL tampering and resize abuse
  • Magic-byte validation — catches renamed files (.exe → .jpg)
  • MinIO client cached per config — no reconnect on every request
  • Secrets never leave the server — browser only receives the final URL
  • minio-imgproxy/server subpath for Next.js — build-time error if imported client-side

How it works

Browser → POST file → Your server (NestJS / Next.js Route Handler)
                              │
                     validate + upload to MinIO
                     sign imgproxy URL with HMAC
                              │
                      return { url } only ←────── secrets stay here
                              │
Browser → GET https://imgproxy.yourdomain.com/<sig>/rs:fit:800:0:0/plain/s3://bucket/key.jpg

The browser never sees your MinIO credentials or imgproxy signing keys — only the final signed URL.


Prerequisites

Three services need to be running:

| Service | Purpose | |---|---| | MinIO (or S3 / R2) | Stores original files | | imgproxy | Resizes and delivers images on-demand | | Node.js 18+ | Runs your app |

Generate signing keys (run once, save both values):

echo "IMGPROXY_KEY=$(openssl rand -hex 32)"
echo "IMGPROXY_SALT=$(openssl rand -hex 32)"

Start imgproxy with those keys:

docker run -p 8080:8080 \
  -e IMGPROXY_KEY="your64hexkey" \
  -e IMGPROXY_SALT="your64hexsalt" \
  -e IMGPROXY_USE_S3=true \
  -e AWS_ACCESS_KEY_ID="minio-access-key" \
  -e AWS_SECRET_ACCESS_KEY="minio-secret-key" \
  -e AWS_S3_ENDPOINT_URL="http://minio:9000" \
  darthsim/imgproxy

Installation

npm install minio-imgproxy minio

Environment variables

These go in your server only (NestJS .env, Next.js server env — never NEXT_PUBLIC_*):

MINIO_ENDPOINT=https://minio.yourdomain.com
MINIO_BUCKET=media
MINIO_ACCESS_KEY=your-access-key
MINIO_SECRET_KEY=your-secret-key
MINIO_REGION=us-east-1

MEDIA_BASE_URL=https://imgproxy.yourdomain.com

IMGPROXY_KEY=your64hexkey
IMGPROXY_SALT=your64hexsalt

Recommended: NestJS + Next.js monorepo

The best setup is to put this library only in NestJS. Next.js proxies the file to NestJS and returns only the URL to the browser.

Browser → Next.js Route Handler → NestJS upload endpoint → MinIO
                                          ↓
                                   returns { url }
                                          ↓
                              Next.js → Browser (URL only)

NestJS — env vars and secrets live here only

// media/media.service.ts
import { Injectable, BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { MediaUploadClient, validateImageBuffer, type ImageSize } from "minio-imgproxy";

@Injectable()
export class MediaService {
  private readonly media: MediaUploadClient;

  constructor(config: ConfigService) {
    this.media = new MediaUploadClient({
      endpoint:     config.getOrThrow("MINIO_ENDPOINT"),
      bucket:       config.getOrThrow("MINIO_BUCKET"),
      accessKey:    config.getOrThrow("MINIO_ACCESS_KEY"),
      secretKey:    config.getOrThrow("MINIO_SECRET_KEY"),
      mediaBaseUrl: config.getOrThrow("MEDIA_BASE_URL"),
      imgproxyKey:  config.get("IMGPROXY_KEY"),
      imgproxySalt: config.get("IMGPROXY_SALT"),
    });
  }

  async upload(buffer: Buffer, mimeType: string, folder = "uploads") {
    const check = validateImageBuffer(buffer, mimeType);
    if (!check.ok) throw new BadRequestException(check.error);
    return this.media.upload(buffer, mimeType, folder);
  }

  getUrl(objectKey: string, size: ImageSize = "medium") {
    return this.media.getUrl(objectKey, size);
  }

  async delete(objectKey: string) {
    return this.media.delete(objectKey);
  }
}
// media/media.controller.ts
import { Controller, Post, Delete, Param, UploadedFile, UseInterceptors, BadRequestException, UseGuards } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { MediaService } from "./media.service";

@Controller("media")
@UseGuards(JwtAuthGuard)
export class MediaController {
  constructor(private readonly mediaService: MediaService) {}

  @Post("upload")
  @UseInterceptors(FileInterceptor("file", { limits: { fileSize: 8 * 1024 * 1024 } }))
  async upload(@UploadedFile() file: Express.Multer.File) {
    if (!file) throw new BadRequestException("No file provided");
    return this.mediaService.upload(file.buffer, file.mimetype, "uploads");
    // returns { objectKey, url } — url is the only thing the browser needs
  }

  @Delete(":key(*)")
  async delete(@Param("key") key: string) {
    await this.mediaService.delete(key);
    return { deleted: true };
  }
}

Next.js — thin proxy, zero secrets

# Next.js .env — only needs to know where NestJS is
NEST_API_URL=http://localhost:3001
// app/api/upload/route.ts
// No minio-imgproxy import here — this file has no secrets
export async function POST(req: Request) {
  const formData = await req.formData();
  const file = formData.get("file") as File;

  const upstream = new FormData();
  upstream.append("file", file);

  const res = await fetch(`${process.env.NEST_API_URL}/media/upload`, {
    method: "POST",
    headers: { Authorization: req.headers.get("Authorization") ?? "" },
    body: upstream,
  });

  return Response.json(await res.json());
}
// components/Uploader.tsx  ("use client")
"use client";

export function Uploader() {
  async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;

    const form = new FormData();
    form.append("file", file);

    const res = await fetch("/api/upload", { method: "POST", body: form });
    const { url } = await res.json();
    console.log(url); // safe imgproxy URL — no secrets
  }

  return <input type="file" accept="image/*" onChange={handleUpload} />;
}

Next.js only (without NestJS)

If you're not using NestJS, import from minio-imgproxy/server to get a build-time error if the module is ever accidentally bundled into a client component.

npm install server-only  # one-time
// app/api/upload/route.ts
import { validateImageBuffer, uploadFile } from "minio-imgproxy/server";

export async function POST(req: Request) {
  const form = await req.formData();
  const file = form.get("file") as File;
  const buffer = Buffer.from(await file.arrayBuffer());

  const check = validateImageBuffer(buffer, file.type);
  if (!check.ok) return Response.json({ error: check.error }, { status: check.status });

  const { url } = await uploadFile(buffer, file.type, "uploads");
  return Response.json({ url });
}
// lib/media.ts  (server-side singleton)
import { MediaUploadClient, loadConfigFromEnv } from "minio-imgproxy/server";

export const media = new MediaUploadClient(loadConfigFromEnv());

NestJS only

import { MediaUploadClient, validateImageBuffer, loadConfigFromEnv } from "minio-imgproxy";

const media = new MediaUploadClient(loadConfigFromEnv());

const check = validateImageBuffer(buffer, "image/jpeg");
if (!check.ok) throw new Error(check.error);

const { objectKey, url } = await media.upload(buffer, "image/jpeg", "avatars");

API reference

How sizes work

There is one file stored in MinIO. imgproxy generates every size on-demand from that one original — nothing is pre-resized or duplicated in storage. Each size is just a different signed URL pointing to the same object key.

MinIO: avatars/uuid.jpg  (one file, the original)
           │
           ├─ thumbnail URL → imgproxy resizes to 150×150 on first request
           ├─ medium URL    → imgproxy resizes to 600px wide on first request
           ├─ large URL     → imgproxy resizes to 1200px wide on first request
           └─ original URL  → imgproxy serves as-is, no resize

Upload — returns all sizes immediately

const { objectKey, urls } = await uploadFile(buffer, "image/jpeg", "avatars");

urls.thumbnail  // → signed URL, 150×150 square crop
urls.medium     // → signed URL, 600px wide (height auto)
urls.large      // → signed URL, 1200px wide (height auto)
urls.original   // → signed URL, no resize

// urls.medium is also available as the top-level url field (backward compat)
interface UploadUrls {
  thumbnail: string;
  medium: string;
  large: string;
  original: string;
}

interface UploadResult {
  objectKey: string;  // "avatars/1716382103-uuid.jpg"  — save this to your DB
  url: string;        // same as urls.medium
  urls: UploadUrls;   // all preset sizes, ready to use
}

Get URLs later from a stored objectKey

// Single size
getUrl(objectKey, "thumbnail")  // → 150×150
getUrl(objectKey, "medium")     // → 600px wide
getUrl(objectKey, "large")      // → 1200px wide
getUrl(objectKey, "original")   // → no resize

// All sizes at once
const urls = getAllUrls(objectKey);
// { thumbnail, medium, large, original }

// Custom transform
getCustomUrl(objectKey, { width: 400, height: 300, fit: "fill", format: "webp", quality: 85 })

The preset sizes:

| Size | Width | Height | Fit | Use case | |---|---|---|---|---| | thumbnail | 150 | 150 | fill | Grid thumbnails, avatars | | medium | 600 | auto | fit | Article images, cards | | large | 1200 | auto | fit | Hero images, lightbox | | original | — | — | — | Download, full-res view |

Upload functions

uploadFile(file: Buffer | File, contentType?: string, folder?: string): Promise<UploadResult>
uploadFileWithConfig(config, file, contentType?, folder?): Promise<UploadResult>
uploadBufferToObjectKey(objectKey, buffer, contentType, config?): Promise<UploadResult>

URL functions (no network call)

getUrl(objectKey, size?: ImageSize): string
getAllUrls(objectKey): UploadUrls
getAllUrlsWithConfig(config, objectKey): UploadUrls
getCustomUrl(objectKey, transform: ImageTransform): string
getCustomUrlWithConfig(config, objectKey, transform): string

Delete

deleteFile(objectKey: string): Promise<void>
deleteFileWithConfig(config, objectKey): Promise<void>

Validation

// For Buffer (server-side, recommended)
validateImageBuffer(buffer: Buffer, mimeType: string): ValidationResult

// For Web API File (async — reads magic bytes from the file header)
validateImageFile(file: File): Promise<ValidationResult>

type ValidationResult =
  | { ok: true;  extension: string }
  | { ok: false; error: string; status: number }

Allowed types: image/jpeg, image/png, image/webp — max 8 MB.

MediaUploadClient

const client = new MediaUploadClient(config: MediaUploadConfig);

client.upload(file, contentType?, folder?)    → Promise<UploadResult>  // includes .urls
client.uploadBuffer(objectKey, buffer, type)  → Promise<UploadResult>
client.delete(objectKey)                      → Promise<void>
client.getUrl(objectKey, size?)               → string
client.getAllUrls(objectKey)                  → UploadUrls
client.getCustomUrl(objectKey, transform)     → string

ImageTransform

interface ImageTransform {
  width?:   number;
  height?:  number;
  fit?:     "fit" | "fill" | "crop" | "pad" | "auto";
  quality?: number;   // 1–100
  blur?:    number;
  format?:  "webp" | "avif" | "png" | "jpg";
}
// Examples
{ width: 1200, height: 630, fit: "fill", format: "webp", quality: 80 }  // OG image
{ width: 200,  height: 200, fit: "fill" }                               // avatar
{ width: 20,   height: 20,  fit: "fill", blur: 5 }                      // LQIP placeholder

Security notes

  • Secrets never reach the browseraccessKey, secretKey, imgproxyKey, imgproxySalt are used only server-side. JSON.stringify(config) returns [REDACTED] for all secret fields.
  • HMAC-SHA256 URL signing — any attempt to modify a signed URL (change dimensions, object key, format) is rejected by imgproxy with 403.
  • Magic byte validation — file content is checked against known byte signatures, not just the Content-Type header. A renamed executable is rejected.
  • SVG not supported — SVGs can contain inline scripts and are an XSS vector when served inline.
  • Insecure mode — if IMGPROXY_KEY/IMGPROXY_SALT are not set, the library logs a warning and generates /insecure/ URLs. Anyone can then request arbitrary transformations from your imgproxy instance. Always set signing keys in production.

License

MIT