import logger from '@server/logger' ;
import axios from 'axios' ;
import rateLimit , { type rateLimitOptions } from 'axios-rate-limit' ;
import { createHash } from 'crypto' ;
import { promises } from 'fs' ;
import path , { join } from 'path' ;
type ImageResponse = {
meta : {
revalidateAfter : number ;
curRevalidate : number ;
isStale : boolean ;
etag : string ;
extension : string ;
cacheKey : string ;
cacheMiss : boolean ;
} ;
imageBuffer : Buffer ;
} ;
const baseCacheDirectory = process . env . CONFIG_DIRECTORY
? ` ${ process . env . CONFIG_DIRECTORY } /cache/images `
: path . join ( __dirname , '../../config/cache/images' ) ;
class ImageProxy {
public static async clearCache ( key : string ) {
let deletedImages = 0 ;
const cacheDirectory = path . join ( baseCacheDirectory , key ) ;
const files = await promises . readdir ( cacheDirectory ) ;
for ( const file of files ) {
const filePath = path . join ( cacheDirectory , file ) ;
const stat = await promises . lstat ( filePath ) ;
if ( stat . isDirectory ( ) ) {
const imageFiles = await promises . readdir ( filePath ) ;
for ( const imageFile of imageFiles ) {
const [ , expireAtSt ] = imageFile . split ( '.' ) ;
const expireAt = Number ( expireAtSt ) ;
const now = Date . now ( ) ;
if ( now > expireAt ) {
await promises . rm ( path . join ( filePath , imageFile ) ) ;
deletedImages += 1 ;
}
}
}
}
logger . info ( ` Cleared ${ deletedImages } stale image(s) from cache ` , {
label : 'Image Cache' ,
} ) ;
}
public static async getImageStats (
key : string
) : Promise < { size : number ; imageCount : number } > {
const cacheDirectory = path . join ( baseCacheDirectory , key ) ;
const imageTotalSize = await ImageProxy . getDirectorySize ( cacheDirectory ) ;
const imageCount = await ImageProxy . getImageCount ( cacheDirectory ) ;
return {
size : imageTotalSize ,
imageCount ,
} ;
}
private static async getDirectorySize ( dir : string ) : Promise < number > {
const files = await promises . readdir ( dir , {
withFileTypes : true ,
} ) ;
const paths = files . map ( async ( file ) = > {
const path = join ( dir , file . name ) ;
if ( file . isDirectory ( ) ) return await ImageProxy . getDirectorySize ( path ) ;
if ( file . isFile ( ) ) {
const { size } = await promises . stat ( path ) ;
return size ;
}
return 0 ;
} ) ;
return ( await Promise . all ( paths ) )
. flat ( Infinity )
. reduce ( ( i , size ) = > i + size , 0 ) ;
}
private static async getImageCount ( dir : string ) {
const files = await promises . readdir ( dir ) ;
return files . length ;
}
private axios ;
private cacheVersion ;
private key ;
constructor (
key : string ,
baseUrl : string ,
options : {
cacheVersion? : number ;
rateLimitOptions? : rateLimitOptions ;
} = { }
) {
this . cacheVersion = options . cacheVersion ? ? 1 ;
this . key = key ;
this . axios = axios . create ( {
baseURL : baseUrl ,
} ) ;
if ( options . rateLimitOptions ) {
this . axios = rateLimit ( this . axios , options . rateLimitOptions ) ;
}
}
public async getImage ( path : string ) : Promise < ImageResponse > {
const cacheKey = this . getCacheKey ( path ) ;
const imageResponse = await this . get ( cacheKey ) ;
if ( ! imageResponse ) {
const newImage = await this . set ( path , cacheKey ) ;
if ( ! newImage ) {
throw new Error ( 'Failed to load image' ) ;
}
return newImage ;
}
// If the image is stale, we will revalidate it in the background.
if ( imageResponse . meta . isStale ) {
this . set ( path , cacheKey ) ;
}
return imageResponse ;
}
private async get ( cacheKey : string ) : Promise < ImageResponse | null > {
try {
const directory = join ( this . getCacheDirectory ( ) , cacheKey ) ;
const files = await promises . readdir ( directory ) ;
const now = Date . now ( ) ;
for ( const file of files ) {
const [ maxAgeSt , expireAtSt , etag , extension ] = file . split ( '.' ) ;
const buffer = await promises . readFile ( join ( directory , file ) ) ;
const expireAt = Number ( expireAtSt ) ;
const maxAge = Number ( maxAgeSt ) ;
return {
meta : {
curRevalidate : maxAge ,
revalidateAfter : maxAge * 1000 + now ,
isStale : now > expireAt ,
etag ,
extension ,
cacheKey ,
cacheMiss : false ,
} ,
imageBuffer : buffer ,
} ;
}
} catch ( e ) {
// No files. Treat as empty cache.
}
return null ;
}
private async set (
path : string ,
cacheKey : string
) : Promise < ImageResponse | null > {
try {
const directory = join ( this . getCacheDirectory ( ) , cacheKey ) ;
const response = await this . axios . get ( path , {
responseType : 'arraybuffer' ,
} ) ;
const buffer = Buffer . from ( response . data , 'binary' ) ;
const extension = path . split ( '.' ) . pop ( ) ? ? '' ;
const maxAge = Number (
( response . headers [ 'cache-control' ] ? ? '0' ) . split ( '=' ) [ 1 ]
) ;
const expireAt = Date . now ( ) + maxAge * 1000 ;
const etag = ( response . headers . etag ? ? '' ) . replace ( /"/g , '' ) ;
await this . writeToCacheDir (
directory ,
extension ,
maxAge ,
expireAt ,
buffer ,
etag
) ;
return {
meta : {
curRevalidate : maxAge ,
revalidateAfter : expireAt ,
isStale : false ,
etag ,
extension ,
cacheKey ,
cacheMiss : true ,
} ,
imageBuffer : buffer ,
} ;
} catch ( e ) {
logger . debug ( 'Something went wrong caching image.' , {
label : 'Image Cache' ,
errorMessage : e.message ,
} ) ;
return null ;
}
}
private async writeToCacheDir (
dir : string ,
extension : string ,
maxAge : number ,
expireAt : number ,
buffer : Buffer ,
etag : string
) {
const filename = join ( dir , ` ${ maxAge } . ${ expireAt } . ${ etag } . ${ extension } ` ) ;
await promises . rm ( dir , { force : true , recursive : true } ) . catch ( ( ) = > {
// do nothing
} ) ;
await promises . mkdir ( dir , { recursive : true } ) ;
await promises . writeFile ( filename , buffer ) ;
}
private getCacheKey ( path : string ) {
return this . getHash ( [ this . key , this . cacheVersion , path ] ) ;
}
private getHash ( items : ( string | number | Buffer ) [ ] ) {
const hash = createHash ( 'sha256' ) ;
for ( const item of items ) {
if ( typeof item === 'number' ) hash . update ( String ( item ) ) ;
else {
hash . update ( item ) ;
}
}
// See https://en.wikipedia.org/wiki/Base64#Filenames
return hash . digest ( 'base64' ) . replace ( /\//g , '-' ) ;
}
private getCacheDirectory() {
return path . join ( baseCacheDirectory , this . key ) ;
}
}
export default ImageProxy ;