@stacksjs/image 0.75.409 → 0.75.417

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -76,3 +76,13 @@ if (!verifyImageTransform(requestPath, expires, signature, secret))
76
76
  Use `decorative` instead of empty alt text for decorative images. The component renders responsive, layout-stable HTML without requiring client JavaScript.
77
77
 
78
78
  The library accepts `srcset` / `srcSet`, `fit` / `objectFit`, `position` / `objectPosition`, and `class` / `className`. `loading` sets the native loading hint; `priority` takes precedence and loads eagerly. `placeholder` supports `blur`, `thumbhash`, `color`, `empty`, `none`, or a CSS background; `placeholderColor` supplies the fallback color.
79
+
80
+ ### Private image uploads
81
+
82
+ `sanitizeImageUpload(bytes, { maxBytes?, maxPixels? })` accepts JPEG, PNG and WebP,
83
+ checks encoded bytes and decoded dimensions before decoding, applies EXIF camera
84
+ orientation and encodes only pixels. The returned `{ bytes, format, width, height }`
85
+ contains no source EXIF, XMP or comments. Defaults are 12 MiB and 40 million pixels;
86
+ the encoded result must fit the byte budget too. Store the returned bytes and use
87
+ its dimensions, since orientation can swap width and height. Authorization and
88
+ retention remain the application's responsibility.
package/dist/index.d.ts CHANGED
@@ -37,3 +37,4 @@ export * from './fonts';
37
37
  export * from './generate';
38
38
  export * from './social';
39
39
  export * from './theme';
40
+ export * from './upload';
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import{createHash,createHmac,randomUUID,timingSafeEqual}from"node:crypto";import{mkdir,readFile,rename,stat,writeFile}from"node:fs/promises";import{readFileSync}from"node:fs";import{basename,dirname,extname,isAbsolute,relative,resolve}from"node:path";import{decode,encode,imageToSplatHash,resize}from"ts-images";export*from"./app-icons";export*from"./app-store";export*from"./fonts";export*from"./generate";export*from"./social";export*from"./theme";const mime={avif:"image/avif",webp:"image/webp",jpeg:"image/jpeg",png:"image/png"};function integer(name,value,min,max){if(!Number.isInteger(value)||value<min||value>max)throw TypeError(`${name} must be between ${min} and ${max}`)}let codecs;function codecFingerprint(){if(codecs!==void 0)return codecs;const version=(name,from)=>{try{return JSON.parse(readFileSync(Bun.resolveSync(`${name}/package.json`,from),"utf8")).version??"?"}catch{return"?"}};let imagesDir=import.meta.dir;try{imagesDir=dirname(Bun.resolveSync("ts-images/package.json",import.meta.dir))}catch{}codecs=[`ts-images@${version("ts-images",import.meta.dir)}`,`ts-webp@${version("@stacksjs/ts-webp",imagesDir)}`,`ts-avif@${version("@stacksjs/ts-avif",imagesDir)}`].join(",");return codecs}export function resolveImageSource(source,root=process.cwd()){const allowed=resolve(root),candidate=isAbsolute(source)?resolve(source):resolve(allowed,source),relation=relative(allowed,candidate);if(source.includes("\x00")||relation===".."||relation.startsWith("../")||isAbsolute(relation))throw Error("Image source must stay inside the configured root");return candidate}export class ImageBuilder{source;targetWidths=[480,768,1280,1920];targetFormats=["avif","webp","jpeg"];targetFit="inside";targetHeight;targetAspectRatio;targetPosition="center";includeOriginal=!0;targetStorage;targetQuality=82;options;constructor(source,options={}){this.source=source;const root=options.root??process.cwd();this.options={...options,root,outputDir:options.outputDir??resolve(root,"public/media/images"),publicPath:options.publicPath??"/media/images",concurrency:options.concurrency??4,upscale:options.upscale??!1}}widths(widths){if(!widths.length)throw TypeError("Image widths are required");widths.forEach((value)=>integer("Image width",value,1,16384));this.targetWidths=[...new Set(widths)].sort((a,b)=>a-b);return this}formats(formats){if(!formats.length)throw TypeError("Image formats are required");this.targetFormats=[...new Set(formats)];return this}fit(fit){this.targetFit=fit;return this}height(value){integer("Image height",value,1,16384);this.targetHeight=value;this.targetAspectRatio=void 0;return this}aspectRatio(value){if(!Number.isFinite(value)||value<=0||value>100)throw TypeError("Image aspect ratio must be between 0 and 100");this.targetAspectRatio=value;this.targetHeight=void 0;return this}position(value){this.targetPosition=value;return this}preset(value){const preset={avatar:{widths:[64,128,256,512],ratio:1,fit:"cover",original:!1},content:{widths:[320,640,960,1280,1920],fit:"inside",original:!0},hero:{widths:[640,1280,1920,2560],ratio:1.7777777777777777,fit:"cover",original:!0},thumbnail:{widths:[160,320,640],ratio:1.7777777777777777,fit:"cover",original:!1}}[value];this.targetWidths=[...preset.widths];this.targetFit=preset.fit;this.targetAspectRatio="ratio"in preset?preset.ratio:void 0;this.targetHeight=void 0;this.includeOriginal=preset.original;return this}quality(value){integer("Image quality",value,1,100);this.targetQuality=value;return this}original(include){this.includeOriginal=include;return this}output(dir,publicPath="/media/images"){this.options.outputDir=resolve(dir);this.options.publicPath=`/${publicPath.replace(/^\/+|\/+$/g,"")}`;return this}storage(adapter,prefix="media/images"){this.targetStorage={adapter,prefix:prefix.replace(/^\/+|\/+$/g,"")};return this}async generate(){integer("Image concurrency",this.options.concurrency,1,32);const sourcePath=resolveImageSource(this.source,this.options.root);if(this.options.authorize&&!await this.options.authorize(sourcePath,this.options.authorizationContext))throw Error("Image delivery is not authorized");const bytes=new Uint8Array(await readFile(sourcePath)),hash=createHash("sha256").update(bytes).digest("hex"),decoded=await decode(bytes),widths=this.targetWidths.filter((width)=>this.options.upscale||width<=decoded.width);if(this.includeOriginal&&!widths.includes(decoded.width))widths.push(decoded.width);const tasks=[...new Set(widths)].sort((a,b)=>a-b).flatMap((width)=>this.targetFormats.map((format)=>({width,format}))),variants=[];let cursor=0;await Promise.all(Array.from({length:Math.min(tasks.length,this.options.concurrency)},async()=>{while(cursor<tasks.length){const task=tasks[cursor++];variants.push(await this.variant(decoded,hash,sourcePath,task.width,task.format))}}));variants.sort((a,b)=>a.width-b.width||this.targetFormats.indexOf(a.format)-this.targetFormats.indexOf(b.format));return{source:{width:decoded.width,height:decoded.height,hash},variants,placeholder:Buffer.from(imageToSplatHash(decoded)).toString("base64url")}}async variant(source,hash,sourcePath,width,format){const height=this.targetHeight??(this.targetAspectRatio?Math.max(1,Math.round(width/this.targetAspectRatio)):void 0),output=width===source.width&&height===void 0?source:resize(source,{width,height,fit:this.targetFit,position:this.targetPosition});if(!this.options.upscale&&(output.width>source.width||output.height>source.height))throw TypeError(`Image variant ${output.width}x${output.height} would upscale the source`);const cacheKey=createHash("sha256").update(`${hash}:${width}:${height??"auto"}:${format}:${this.targetFit}:${this.targetPosition}:${this.targetQuality}:${codecFingerprint()}`).digest("hex"),filename=`${basename(sourcePath,extname(sourcePath)).replace(/[^a-zA-Z0-9_-]/g,"-")||"image"}-${output.width}x${output.height}-${cacheKey.slice(0,16)}.${format==="jpeg"?"jpg":format}`,path=resolve(this.options.outputDir,filename);if(this.targetStorage){const key=this.targetStorage.prefix?`${this.targetStorage.prefix}/${filename}`:filename;if(await this.targetStorage.adapter.fileExists(key)){const existing=await this.targetStorage.adapter.stat(key);return{width:output.width,height:output.height,bytes:existing.size,format,mimeType:mime[format],path:key,url:await this.targetStorage.adapter.publicUrl(key),cacheKey}}const encoded=await encode(output,format,{quality:this.targetQuality,progressive:!0}),written=await this.targetStorage.adapter.write(key,encoded);return{width:output.width,height:output.height,bytes:written.size,format,mimeType:mime[format],path:key,url:await this.targetStorage.adapter.publicUrl(key),cacheKey}}const existing=await stat(path).catch(()=>null);if(existing)return{width:output.width,height:output.height,bytes:existing.size,format,mimeType:mime[format],path,url:`${this.options.publicPath}/${filename}`,cacheKey};const encoded=await encode(output,format,{quality:this.targetQuality,progressive:!0});await mkdir(this.options.outputDir,{recursive:!0});const temporary=`${path}.${process.pid}.${randomUUID()}.tmp`;await writeFile(temporary,encoded);await rename(temporary,path);return{width:output.width,height:output.height,bytes:encoded.byteLength,format,mimeType:mime[format],path,url:`${this.options.publicPath}/${filename}`,cacheKey}}}export function image(source,options={}){return new ImageBuilder(source,options)}function accepted(accept,mimeType){const[type,subtype]=mimeType.split("/");let best=0;for(const item of accept.split(",")){const[range="*/*",...params]=item.trim().toLowerCase().split(";").map((value)=>value.trim()),[acceptedType,acceptedSubtype]=range.split("/");if(acceptedType!=="*"&&acceptedType!==type||acceptedSubtype!=="*"&&acceptedSubtype!==subtype)continue;const raw=params.find((param)=>param.startsWith("q="));best=Math.max(best,raw?Number.parseFloat(raw.slice(2))||0:1)}return best}export function negotiateImageVariant(variants,accept="*/*",width){const widths=[...new Set(variants.map((item)=>item.width))].sort((a,b)=>a-b),target=width===void 0?widths.at(-1):widths.find((value)=>value>=width)??widths.at(-1);return variants.filter((item)=>item.width===target).map((variant,index)=>({variant,index,q:accepted(accept||"*/*",variant.mimeType)})).filter((item)=>item.q>0).sort((a,b)=>b.q-a.q||a.index-b.index)[0]?.variant}export function imageResponseHeaders(variant){return{"Content-Type":variant.mimeType,"Content-Length":String(variant.bytes),"Cache-Control":"public, max-age=31536000, immutable",ETag:`"${variant.cacheKey}"`,Vary:"Accept","X-Image-Width":String(variant.width),"X-Image-Height":String(variant.height)}}export function signImageTransform(path,expires,secret){return createHmac("sha256",secret).update(`${path}
1
+ import{createHash,createHmac,randomUUID,timingSafeEqual}from"node:crypto";import{mkdir,readFile,rename,stat,writeFile}from"node:fs/promises";import{readFileSync}from"node:fs";import{basename,dirname,extname,isAbsolute,relative,resolve}from"node:path";import{decode,encode,imageToSplatHash,resize}from"ts-images";export*from"./app-icons";export*from"./app-store";export*from"./fonts";export*from"./generate";export*from"./social";export*from"./theme";export*from"./upload";const mime={avif:"image/avif",webp:"image/webp",jpeg:"image/jpeg",png:"image/png"};function integer(name,value,min,max){if(!Number.isInteger(value)||value<min||value>max)throw TypeError(`${name} must be between ${min} and ${max}`)}let codecs;function codecFingerprint(){if(codecs!==void 0)return codecs;const version=(name,from)=>{try{return JSON.parse(readFileSync(Bun.resolveSync(`${name}/package.json`,from),"utf8")).version??"?"}catch{return"?"}};let imagesDir=import.meta.dir;try{imagesDir=dirname(Bun.resolveSync("ts-images/package.json",import.meta.dir))}catch{}codecs=[`ts-images@${version("ts-images",import.meta.dir)}`,`ts-webp@${version("@stacksjs/ts-webp",imagesDir)}`,`ts-avif@${version("@stacksjs/ts-avif",imagesDir)}`].join(",");return codecs}export function resolveImageSource(source,root=process.cwd()){const allowed=resolve(root),candidate=isAbsolute(source)?resolve(source):resolve(allowed,source),relation=relative(allowed,candidate);if(source.includes("\x00")||relation===".."||relation.startsWith("../")||isAbsolute(relation))throw Error("Image source must stay inside the configured root");return candidate}export class ImageBuilder{source;targetWidths=[480,768,1280,1920];targetFormats=["avif","webp","jpeg"];targetFit="inside";targetHeight;targetAspectRatio;targetPosition="center";includeOriginal=!0;targetStorage;targetQuality=82;options;constructor(source,options={}){this.source=source;const root=options.root??process.cwd();this.options={...options,root,outputDir:options.outputDir??resolve(root,"public/media/images"),publicPath:options.publicPath??"/media/images",concurrency:options.concurrency??4,upscale:options.upscale??!1}}widths(widths){if(!widths.length)throw TypeError("Image widths are required");widths.forEach((value)=>integer("Image width",value,1,16384));this.targetWidths=[...new Set(widths)].sort((a,b)=>a-b);return this}formats(formats){if(!formats.length)throw TypeError("Image formats are required");this.targetFormats=[...new Set(formats)];return this}fit(fit){this.targetFit=fit;return this}height(value){integer("Image height",value,1,16384);this.targetHeight=value;this.targetAspectRatio=void 0;return this}aspectRatio(value){if(!Number.isFinite(value)||value<=0||value>100)throw TypeError("Image aspect ratio must be between 0 and 100");this.targetAspectRatio=value;this.targetHeight=void 0;return this}position(value){this.targetPosition=value;return this}preset(value){const preset={avatar:{widths:[64,128,256,512],ratio:1,fit:"cover",original:!1},content:{widths:[320,640,960,1280,1920],fit:"inside",original:!0},hero:{widths:[640,1280,1920,2560],ratio:1.7777777777777777,fit:"cover",original:!0},thumbnail:{widths:[160,320,640],ratio:1.7777777777777777,fit:"cover",original:!1}}[value];this.targetWidths=[...preset.widths];this.targetFit=preset.fit;this.targetAspectRatio="ratio"in preset?preset.ratio:void 0;this.targetHeight=void 0;this.includeOriginal=preset.original;return this}quality(value){integer("Image quality",value,1,100);this.targetQuality=value;return this}original(include){this.includeOriginal=include;return this}output(dir,publicPath="/media/images"){this.options.outputDir=resolve(dir);this.options.publicPath=`/${publicPath.replace(/^\/+|\/+$/g,"")}`;return this}storage(adapter,prefix="media/images"){this.targetStorage={adapter,prefix:prefix.replace(/^\/+|\/+$/g,"")};return this}async generate(){integer("Image concurrency",this.options.concurrency,1,32);const sourcePath=resolveImageSource(this.source,this.options.root);if(this.options.authorize&&!await this.options.authorize(sourcePath,this.options.authorizationContext))throw Error("Image delivery is not authorized");const bytes=new Uint8Array(await readFile(sourcePath)),hash=createHash("sha256").update(bytes).digest("hex"),decoded=await decode(bytes),widths=this.targetWidths.filter((width)=>this.options.upscale||width<=decoded.width);if(this.includeOriginal&&!widths.includes(decoded.width))widths.push(decoded.width);const tasks=[...new Set(widths)].sort((a,b)=>a-b).flatMap((width)=>this.targetFormats.map((format)=>({width,format}))),variants=[];let cursor=0;await Promise.all(Array.from({length:Math.min(tasks.length,this.options.concurrency)},async()=>{while(cursor<tasks.length){const task=tasks[cursor++];variants.push(await this.variant(decoded,hash,sourcePath,task.width,task.format))}}));variants.sort((a,b)=>a.width-b.width||this.targetFormats.indexOf(a.format)-this.targetFormats.indexOf(b.format));return{source:{width:decoded.width,height:decoded.height,hash},variants,placeholder:Buffer.from(imageToSplatHash(decoded)).toString("base64url")}}async variant(source,hash,sourcePath,width,format){const height=this.targetHeight??(this.targetAspectRatio?Math.max(1,Math.round(width/this.targetAspectRatio)):void 0),output=width===source.width&&height===void 0?source:resize(source,{width,height,fit:this.targetFit,position:this.targetPosition});if(!this.options.upscale&&(output.width>source.width||output.height>source.height))throw TypeError(`Image variant ${output.width}x${output.height} would upscale the source`);const cacheKey=createHash("sha256").update(`${hash}:${width}:${height??"auto"}:${format}:${this.targetFit}:${this.targetPosition}:${this.targetQuality}:${codecFingerprint()}`).digest("hex"),filename=`${basename(sourcePath,extname(sourcePath)).replace(/[^a-zA-Z0-9_-]/g,"-")||"image"}-${output.width}x${output.height}-${cacheKey.slice(0,16)}.${format==="jpeg"?"jpg":format}`,path=resolve(this.options.outputDir,filename);if(this.targetStorage){const key=this.targetStorage.prefix?`${this.targetStorage.prefix}/${filename}`:filename;if(await this.targetStorage.adapter.fileExists(key)){const existing=await this.targetStorage.adapter.stat(key);return{width:output.width,height:output.height,bytes:existing.size,format,mimeType:mime[format],path:key,url:await this.targetStorage.adapter.publicUrl(key),cacheKey}}const encoded=await encode(output,format,{quality:this.targetQuality,progressive:!0}),written=await this.targetStorage.adapter.write(key,encoded);return{width:output.width,height:output.height,bytes:written.size,format,mimeType:mime[format],path:key,url:await this.targetStorage.adapter.publicUrl(key),cacheKey}}const existing=await stat(path).catch(()=>null);if(existing)return{width:output.width,height:output.height,bytes:existing.size,format,mimeType:mime[format],path,url:`${this.options.publicPath}/${filename}`,cacheKey};const encoded=await encode(output,format,{quality:this.targetQuality,progressive:!0});await mkdir(this.options.outputDir,{recursive:!0});const temporary=`${path}.${process.pid}.${randomUUID()}.tmp`;await writeFile(temporary,encoded);await rename(temporary,path);return{width:output.width,height:output.height,bytes:encoded.byteLength,format,mimeType:mime[format],path,url:`${this.options.publicPath}/${filename}`,cacheKey}}}export function image(source,options={}){return new ImageBuilder(source,options)}function accepted(accept,mimeType){const[type,subtype]=mimeType.split("/");let best=0;for(const item of accept.split(",")){const[range="*/*",...params]=item.trim().toLowerCase().split(";").map((value)=>value.trim()),[acceptedType,acceptedSubtype]=range.split("/");if(acceptedType!=="*"&&acceptedType!==type||acceptedSubtype!=="*"&&acceptedSubtype!==subtype)continue;const raw=params.find((param)=>param.startsWith("q="));best=Math.max(best,raw?Number.parseFloat(raw.slice(2))||0:1)}return best}export function negotiateImageVariant(variants,accept="*/*",width){const widths=[...new Set(variants.map((item)=>item.width))].sort((a,b)=>a-b),target=width===void 0?widths.at(-1):widths.find((value)=>value>=width)??widths.at(-1);return variants.filter((item)=>item.width===target).map((variant,index)=>({variant,index,q:accepted(accept||"*/*",variant.mimeType)})).filter((item)=>item.q>0).sort((a,b)=>b.q-a.q||a.index-b.index)[0]?.variant}export function imageResponseHeaders(variant){return{"Content-Type":variant.mimeType,"Content-Length":String(variant.bytes),"Cache-Control":"public, max-age=31536000, immutable",ETag:`"${variant.cacheKey}"`,Vary:"Accept","X-Image-Width":String(variant.width),"X-Image-Height":String(variant.height)}}export function signImageTransform(path,expires,secret){return createHmac("sha256",secret).update(`${path}
2
2
  ${expires}`).digest("base64url")}export function verifyImageTransform(path,expires,signature,secret,now=Date.now()){if(!Number.isInteger(expires)||expires*1000<=now)return!1;const expected=Buffer.from(signImageTransform(path,expires,secret)),actual=Buffer.from(signature);return expected.length===actual.length&&timingSafeEqual(expected,actual)}
@@ -0,0 +1,8 @@
1
+ /** Bound decoding, apply camera orientation, and publish pixels without private metadata. */
2
+ export declare function sanitizeImageUpload(bytes: Uint8Array, options?: { maxBytes?: number, maxPixels?: number }): Promise<SanitizedImage>;
3
+ export declare interface SanitizedImage {
4
+ bytes: Uint8Array
5
+ width: number
6
+ height: number
7
+ format: 'jpeg' | 'png' | 'webp'
8
+ }
package/dist/upload.js ADDED
@@ -0,0 +1 @@
1
+ import{decode,encode,getMetadata}from"ts-images";export async function sanitizeImageUpload(bytes,options={}){const maxBytes=options.maxBytes??12582912,maxPixels=options.maxPixels??40000000;if(!Number.isSafeInteger(maxBytes)||maxBytes<=0||!Number.isSafeInteger(maxPixels)||maxPixels<=0)throw RangeError("Image limits must be positive whole numbers");if(!bytes.byteLength||bytes.byteLength>maxBytes)throw RangeError("Image exceeds the upload byte limit");const metadata=await getMetadata(bytes),sourceFormat=String(metadata.format).toLowerCase(),format=sourceFormat==="jpg"?"jpeg":sourceFormat;if(format!=="jpeg"&&format!=="png"&&format!=="webp")throw TypeError("Send a JPEG, PNG or WebP photo");if(!Number.isSafeInteger(metadata.width)||!Number.isSafeInteger(metadata.height)||metadata.width<=0||metadata.height<=0||metadata.width*metadata.height>maxPixels)throw RangeError("Image exceeds the decoded pixel limit");const source=await decode(bytes,{applyOrientation:!0}),pixels={width:source.width,height:source.height,data:source.data,colorSpace:source.colorSpace,hasAlpha:source.hasAlpha,bitDepth:source.bitDepth},output=await encode(pixels,format,{quality:90});if(output.byteLength>maxBytes)throw RangeError("Normalized image exceeds the upload byte limit");return{bytes:output,width:source.width,height:source.height,format}}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/image",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.409",
5
+ "version": "0.75.417",
6
6
  "description": "Native responsive image delivery for Stacks.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@stacksjs/ts-svg": "^0.1.5",
35
- "@stacksjs/types": "0.75.409",
35
+ "@stacksjs/types": "0.75.417",
36
36
  "ts-images": "^0.2.23"
37
37
  },
38
38
  "devDependencies": {