
这是一个运行在 Cloudflare Workers 上的图片代理服务,对外继续保持 ?url= 的调用方式。
本项目当前支持两种工作模式:
- 原样代理:仅做远程抓取、响应头整理和边缘缓存
- Images 处理:在请求参数中带上尺寸、格式或水印参数时,交给 Cloudflare Images 完成转换、覆盖和编码
使用方式
https://your-worker.workers.dev/?url=https://example.com/image.jpg
指定宽度并转 WebP
https://your-worker.workers.dev/?url=https://example.com/image.jpg&width=640&format=webp
指定宽高并转 AVIF
https://your-worker.workers.dev/?url=https://example.com/image.jpg&width=1280&height=720&format=avif&quality=80
添加水印
https://your-worker.workers.dev/?url=https://example.com/image.jpg&width=1200&watermark=1&wm_key=logo&wm_width=96&wm_height=96&wm_bottom=24&wm_right=24&wm_opacity=0.7&format=webp
当前限制
- 不限制输出尺寸
- 默认拒绝超过
20 MB的原图 - 仅允许输出格式:
avif、webp、jpeg/jpg、png
index.js 代码:
/**
* Cloudflare Workers 图片代理服务
* 保持与 PHP 版本相同的 `?url=` API,并按需接入 Cloudflare Images。
*/
const DEFAULT_CONFIG = {
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
allowedDomains: [],
cacheMaxAge: 86400,
routing: {
rootRedirectUrl: 'https://www.google.com/'
},
security: {
requireSignature: false,
signatureMaxAgeSeconds: 300,
maxSourceImageBytes: 20 * 1024 * 1024,
allowedOutputFormats: ['image/avif', 'image/webp', 'image/jpeg', 'image/png'],
blockedSourceDomains: ['baidu.com', 'baidu.cn', 'baidu.net', 'baidu.tv'],
requestSource: {
allowedOriginHosts: [],
allowedRefererHosts: [],
allowRequestsWithoutSource: true
},
rateLimit: {
enabled: true,
maxRequests: 60,
windowSeconds: 60,
blockSeconds: 300
}
},
images: {
defaultQuality: 85,
defaultWatermarkEnabled: false,
defaultWatermarkKey: 'logo',
defaultWatermarkWidth: 64,
defaultWatermarkHeight: 64,
defaultWatermarkBottom: 24,
defaultWatermarkRight: 24,
defaultWatermarkOpacity: 0.75
}
};
const TRANSFORM_QUERY_KEYS = [
'width',
'height',
'rotate',
'blur',
'fit',
'gravity',
'format',
'quality',
'watermark',
'wm_key',
'wm_width',
'wm_height',
'wm_bottom',
'wm_right',
'wm_opacity'
];
export default {
/**
* 处理图片代理请求,并在带有变换参数时接入 Cloudflare Images。
* @param {Request} request - 请求对象
* @param {Object} env - 运行时绑定
* @param {ExecutionContext} ctx - Worker 执行上下文
* @returns {Promise<Response>} 图片响应
*/
async fetch(request, env, ctx) {
if (request.method === 'OPTIONS') {
return this.createOptionsResponse();
}
if (!['GET', 'HEAD'].includes(request.method)) {
return this.createErrorResponse('Method not allowed', 405);
}
try {
const requestUrl = new URL(request.url);
const imageUrl = requestUrl.searchParams.get('url');
const routeResponse = this.handleRouteAccess(requestUrl, imageUrl);
if (routeResponse) {
return routeResponse;
}
const normalizedImageUrl = this.normalizeImageUrl(imageUrl);
if (!this.validateUrl(normalizedImageUrl)) {
return this.createErrorResponse('Invalid URL format or domain not allowed', 403);
}
const signatureValidation = await this.validateRequestSignature(
requestUrl,
normalizedImageUrl,
env
);
if (!signatureValidation.ok) {
return this.createErrorResponse(signatureValidation.message, signatureValidation.status);
}
const requestSourceValidation = this.validateRequestSource(request);
if (!requestSourceValidation.ok) {
return this.createErrorResponse(requestSourceValidation.message, requestSourceValidation.status);
}
const rateLimitValidation = await this.enforceRateLimit(request, env);
if (!rateLimitValidation.ok) {
return this.createErrorResponse(rateLimitValidation.message, rateLimitValidation.status);
}
const useImagesBinding = this.shouldUseImagesBinding(requestUrl.searchParams);
const cache = caches.default;
const cacheKey = useImagesBinding
? this.generateTransformCacheKey(request, normalizedImageUrl)
: this.generatePassthroughCacheKey(request, normalizedImageUrl);
try {
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
return this.withCacheStatus(cachedResponse, 'HIT');
}
} catch (error) {
console.error('Cache lookup failed:', error);
}
const sourceResponse = await this.fetchRemoteImageResponse(normalizedImageUrl);
if (!sourceResponse || !sourceResponse.body) {
return this.createErrorResponse('Failed to fetch image', 502);
}
const sourceSizeCheck = await this.enforceSourceSizeLimit(sourceResponse);
if (!sourceSizeCheck.ok) {
return this.createErrorResponse(sourceSizeCheck.message, sourceSizeCheck.status);
}
const sourceMimeType = await this.resolveSourceMimeType(sourceSizeCheck.response);
if (!sourceMimeType) {
return this.createErrorResponse('Invalid image data', 400);
}
let response;
if (useImagesBinding) {
response = await this.transformImageWithBinding(
request,
requestUrl.searchParams,
sourceSizeCheck.response,
sourceMimeType,
env
);
} else {
response = this.createPassthroughResponse(sourceSizeCheck.response, sourceMimeType);
}
ctx.waitUntil(this.storeInCache(cache, cacheKey, response.clone()));
return this.withCacheStatus(response, 'MISS');
} catch (error) {
console.error('Error processing request:', error);
return this.createErrorResponse('Internal server error', 500);
}
},
/**
* 处理根路径跳转和无效路径拦截。
* @param {URL} requestUrl - 当前请求 URL
* @param {string | null} imageUrl - 查询参数中的原图地址
* @returns {Response | null} 命中的路由响应,未命中则返回 null
*/
handleRouteAccess(requestUrl, imageUrl) {
if (requestUrl.pathname !== '/') {
return this.createErrorResponse('Not found', 404);
}
if (!imageUrl) {
return Response.redirect(DEFAULT_CONFIG.routing.rootRedirectUrl, 302);
}
return null;
},
/**
* 使用 Images binding 执行图片变换、水印和转码。
* @param {Request} request - 当前请求
* @param {URLSearchParams} searchParams - 查询参数
* @param {Response} sourceResponse - 远程原图响应
* @param {string} sourceMimeType - 原图 MIME 类型
* @param {Object} env - 运行时绑定
* @returns {Promise<Response>} 处理后的响应
*/
async transformImageWithBinding(request, searchParams, sourceResponse, sourceMimeType, env) {
if (!env.IMAGES) {
return this.createErrorResponse('Missing IMAGES binding', 500);
}
let pipeline = env.IMAGES.input(sourceResponse.body);
const baseTransformOptions = this.buildBaseTransformOptions(searchParams);
if (Object.keys(baseTransformOptions).length > 0) {
pipeline = pipeline.transform(baseTransformOptions);
}
const watermarkStream = await this.getWatermarkStream(env, searchParams);
if (watermarkStream) {
const watermarkTransform = this.buildWatermarkTransformOptions(searchParams);
const watermarkDrawOptions = this.buildWatermarkDrawOptions(searchParams);
pipeline = pipeline.draw(
env.IMAGES.input(watermarkStream).transform(watermarkTransform),
watermarkDrawOptions
);
}
const outputOptions = this.buildOutputOptions(
request.headers.get('Accept') || '',
searchParams,
sourceMimeType
);
if (!this.isAllowedOutputFormat(outputOptions.format)) {
return this.createErrorResponse('Unsupported output format', 400);
}
const transformedResponse = (await pipeline.output(outputOptions)).response();
return this.decorateImageResponse(transformedResponse, {
sourceMimeType,
transformed: true
});
},
/**
* 校验请求签名,确保公开接口只能被持有密钥的调用方使用。
* @param {URL} requestUrl - 当前请求 URL
* @param {string} normalizedImageUrl - 规范化后的原图地址
* @param {Object} env - 运行时绑定
* @returns {Promise<{ok: boolean, status?: number, message?: string}>} 校验结果
*/
async validateRequestSignature(requestUrl, normalizedImageUrl, env) {
if (!DEFAULT_CONFIG.security.requireSignature) {
return { ok: true };
}
if (!env.SIGNING_SECRET) {
return {
ok: false,
status: 500,
message: 'Missing SIGNING_SECRET'
};
}
const timestamp = requestUrl.searchParams.get('ts');
const signature = requestUrl.searchParams.get('sign');
if (!timestamp || !signature) {
return {
ok: false,
status: 403,
message: 'Missing request signature'
};
}
const timestampSeconds = this.toInt(timestamp);
const nowSeconds = Math.floor(Date.now() / 1000);
if (timestampSeconds === null || timestampSeconds > nowSeconds + 60) {
return {
ok: false,
status: 403,
message: 'Invalid request timestamp'
};
}
if (nowSeconds - timestampSeconds > DEFAULT_CONFIG.security.signatureMaxAgeSeconds) {
return {
ok: false,
status: 403,
message: 'Expired request signature'
};
}
const payload = this.buildSignaturePayload(requestUrl.searchParams, normalizedImageUrl);
const expectedSignature = await this.signPayload(payload, env.SIGNING_SECRET);
if (!this.constantTimeEquals(signature, expectedSignature)) {
return {
ok: false,
status: 403,
message: 'Invalid request signature'
};
}
return { ok: true };
},
/**
* 构造签名原文,保持原参数体系不变,仅额外加入 ts 和 sign。
* @param {URLSearchParams} searchParams - 当前查询参数
* @param {string} normalizedImageUrl - 规范化后的原图地址
* @returns {string} 规范化后的签名原文
*/
buildSignaturePayload(searchParams, normalizedImageUrl) {
const signatureKeys = ['ts', ...TRANSFORM_QUERY_KEYS].sort();
const parts = [`url=${normalizedImageUrl}`];
for (const key of signatureKeys) {
const value = searchParams.get(key);
if (value !== null && value !== '') {
parts.push(`${key}=${value}`);
}
}
return parts.join('\n');
},
/**
* 使用 HMAC-SHA256 生成 URL 安全签名。
* @param {string} payload - 待签名的原文
* @param {string} secret - 共享密钥
* @returns {Promise<string>} Base64URL 编码签名
*/
async signPayload(payload, secret) {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(payload);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
return this.arrayBufferToBase64Url(signature);
},
/**
* 将 ArrayBuffer 转为 Base64URL 字符串,便于放入查询参数。
* @param {ArrayBuffer} buffer - 待编码的二进制数据
* @returns {string} Base64URL 字符串
*/
arrayBufferToBase64Url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
},
/**
* 进行常量时间字符串比较,减少简单的计时侧信道差异。
* @param {string} left - 左侧字符串
* @param {string} right - 右侧字符串
* @returns {boolean} 是否一致
*/
constantTimeEquals(left, right) {
if (left.length !== right.length) {
return false;
}
let diff = 0;
for (let index = 0; index < left.length; index += 1) {
diff |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return diff === 0;
},
/**
* 校验请求来源,支持基于 Origin / Referer 的白名单控制。
* @param {Request} request - 当前请求
* @param {Object} sourceConfig - 来源校验配置
* @returns {{ok: boolean, status?: number, message?: string}} 校验结果
*/
validateRequestSource(request, sourceConfig = DEFAULT_CONFIG.security.requestSource) {
const hasOriginRules =
sourceConfig.allowedOriginHosts && sourceConfig.allowedOriginHosts.length > 0;
const hasRefererRules =
sourceConfig.allowedRefererHosts && sourceConfig.allowedRefererHosts.length > 0;
const originHeader = request.headers.get('Origin');
const refererHeader = request.headers.get('Referer');
if (originHeader) {
try {
const originHost = new URL(originHeader).hostname.toLowerCase();
if (this.matchesDomainList(originHost, DEFAULT_CONFIG.security.blockedSourceDomains)) {
return {
ok: false,
status: 403,
message: 'Request source blocked'
};
}
} catch {
return {
ok: false,
status: 403,
message: 'Invalid request origin'
};
}
}
if (refererHeader) {
try {
const refererHost = new URL(refererHeader).hostname.toLowerCase();
if (this.matchesDomainList(refererHost, DEFAULT_CONFIG.security.blockedSourceDomains)) {
return {
ok: false,
status: 403,
message: 'Request source blocked'
};
}
} catch {
return {
ok: false,
status: 403,
message: 'Invalid request referer'
};
}
}
if (!hasOriginRules && !hasRefererRules) {
return { ok: true };
}
if (!originHeader && !refererHeader) {
if (sourceConfig.allowRequestsWithoutSource) {
return { ok: true };
}
return {
ok: false,
status: 403,
message: 'Missing request source'
};
}
if (originHeader) {
try {
const originHost = new URL(originHeader).hostname.toLowerCase();
if (
hasOriginRules &&
this.matchesDomainList(originHost, sourceConfig.allowedOriginHosts)
) {
return { ok: true };
}
} catch {
return {
ok: false,
status: 403,
message: 'Invalid request origin'
};
}
}
if (refererHeader) {
try {
const refererHost = new URL(refererHeader).hostname.toLowerCase();
if (
hasRefererRules &&
this.matchesDomainList(refererHost, sourceConfig.allowedRefererHosts)
) {
return { ok: true };
}
} catch {
return {
ok: false,
status: 403,
message: 'Invalid request referer'
};
}
}
return {
ok: false,
status: 403,
message: 'Request source not allowed'
};
},
/**
* 使用 KV 对客户端 IP 做基础限流,减少刷接口行为。
* @param {Request} request - 当前请求
* @param {Object} env - 运行时绑定
* @param {Object} rateLimitConfig - 限流配置
* @returns {Promise<{ok: boolean, status?: number, message?: string}>} 校验结果
*/
async enforceRateLimit(request, env, rateLimitConfig = DEFAULT_CONFIG.security.rateLimit) {
if (!rateLimitConfig.enabled || !env.RATE_LIMITS) {
return { ok: true };
}
const clientIp = this.getClientIp(request);
if (!clientIp) {
return { ok: true };
}
const nowSeconds = Math.floor(Date.now() / 1000);
const blockKey = `rl:block:${clientIp}`;
const blockedUntilValue = await env.RATE_LIMITS.get(blockKey);
const blockedUntil = this.toInt(blockedUntilValue);
if (blockedUntil !== null && blockedUntil > nowSeconds) {
return {
ok: false,
status: 429,
message: 'Too many requests'
};
}
const bucket = Math.floor(nowSeconds / rateLimitConfig.windowSeconds);
const counterKey = `rl:count:${clientIp}:${bucket}`;
const counterValue = await env.RATE_LIMITS.get(counterKey);
const currentCount = this.toInt(counterValue) || 0;
const nextCount = currentCount + 1;
await env.RATE_LIMITS.put(counterKey, String(nextCount), {
expirationTtl: rateLimitConfig.windowSeconds + 10
});
if (nextCount > rateLimitConfig.maxRequests) {
await env.RATE_LIMITS.put(
blockKey,
String(nowSeconds + rateLimitConfig.blockSeconds),
{
expirationTtl: rateLimitConfig.blockSeconds
}
);
return {
ok: false,
status: 429,
message: 'Too many requests'
};
}
return { ok: true };
},
/**
* 获取客户端 IP,优先使用 Cloudflare 注入的请求头。
* @param {Request} request - 当前请求
* @returns {string|null} 客户端 IP
*/
getClientIp(request) {
const cfConnectingIp = request.headers.get('CF-Connecting-IP');
if (cfConnectingIp) {
return cfConnectingIp.trim();
}
const forwardedFor = request.headers.get('X-Forwarded-For');
if (forwardedFor) {
return forwardedFor.split(',')[0].trim();
}
return null;
},
/**
* 判断主机名是否命中域名列表,命中根域名时其子域名也会被视为匹配。
* @param {string} hostname - 主机名
* @param {string[]} domains - 域名列表
* @returns {boolean} 是否匹配
*/
matchesDomainList(hostname, domains) {
const normalizedHost = hostname.toLowerCase();
return domains.some(domain => {
const normalizedDomain = domain.toLowerCase();
return (
normalizedHost === normalizedDomain ||
normalizedHost.endsWith(`.${normalizedDomain}`)
);
});
},
/**
* 判断当前请求是否需要进入 Images 变换管线。
* @param {URLSearchParams} searchParams - 查询参数
* @returns {boolean} 是否需要使用 Images binding
*/
shouldUseImagesBinding(searchParams) {
return TRANSFORM_QUERY_KEYS.some(key => {
const value = searchParams.get(key);
return value !== null && value !== '';
});
},
/**
* 拉取远程图片响应并保留可继续传递的响应流。
* @param {string} url - 原图地址
* @returns {Promise<Response|null>} 原图响应
*/
async fetchRemoteImageResponse(url) {
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': DEFAULT_CONFIG.userAgent,
'Accept': 'image/*,application/octet-stream',
'Referer': new URL(url).origin
},
redirect: 'follow'
});
if (!response.ok) {
console.error(`Failed to fetch image: ${response.status} ${response.statusText}`);
return null;
}
return response;
} catch (error) {
console.error('Error fetching remote image:', error);
return null;
}
},
/**
* 解析远程响应的 MIME 类型,优先信任响应头,不足时再回退到内容探测。
* @param {Response} response - 原图响应
* @returns {Promise<string|null>} 识别出的 MIME 类型
*/
async resolveSourceMimeType(response) {
const contentType = response.headers.get('Content-Type');
const normalizedType = contentType ? contentType.split(';')[0].trim().toLowerCase() : '';
if (normalizedType.startsWith('image/')) {
return normalizedType;
}
try {
const data = await response.clone().arrayBuffer();
return this.detectImageType(data);
} catch (error) {
console.error('Failed to inspect source image type:', error);
return null;
}
},
/**
* 构造主图变换参数。
* @param {URLSearchParams} searchParams - 查询参数
* @returns {Object} 主图变换参数
*/
buildBaseTransformOptions(searchParams) {
const options = {};
const width = this.toInt(searchParams.get('width'));
const height = this.toInt(searchParams.get('height'));
const rotate = this.toInt(searchParams.get('rotate'));
const blur = this.toInt(searchParams.get('blur'));
const fit = searchParams.get('fit');
const gravity = searchParams.get('gravity');
if (width) {
options.width = width;
}
if (height) {
options.height = height;
}
if (rotate !== null) {
options.rotate = rotate;
}
if (blur !== null) {
options.blur = blur;
}
if (fit) {
options.fit = fit;
}
if (gravity) {
options.gravity = gravity;
}
return options;
},
/**
* 读取水印图流,支持 KV、R2 或固定的外部水印地址。
* @param {Object} env - 运行时绑定
* @param {URLSearchParams} searchParams - 查询参数
* @returns {Promise<ReadableStream|null>} 水印图流
*/
async getWatermarkStream(env, searchParams) {
const watermarkValue = searchParams.get('watermark');
const watermarkEnabled =
watermarkValue === '1'
? true
: watermarkValue === '0'
? false
: DEFAULT_CONFIG.images.defaultWatermarkEnabled;
if (!watermarkEnabled) {
return null;
}
const watermarkKey =
searchParams.get('wm_key') || DEFAULT_CONFIG.images.defaultWatermarkKey;
if (env.WATERMARKS && typeof env.WATERMARKS.get === 'function') {
const kvStream = await env.WATERMARKS.get(watermarkKey, 'stream');
if (kvStream) {
return kvStream;
}
}
if (env.WATERMARK_BUCKET && typeof env.WATERMARK_BUCKET.get === 'function') {
const object = await env.WATERMARK_BUCKET.get(watermarkKey);
if (object && object.body) {
return object.body;
}
}
if (env.WATERMARK_URL) {
try {
const response = await fetch(env.WATERMARK_URL);
if (response.ok && response.body) {
return response.body;
}
} catch (error) {
console.error('Failed to fetch watermark image:', error);
}
}
return null;
},
/**
* 构造水印缩放参数。
* @param {URLSearchParams} searchParams - 查询参数
* @returns {Object} 水印变换参数
*/
buildWatermarkTransformOptions(searchParams) {
return {
width:
this.toInt(searchParams.get('wm_width')) ||
DEFAULT_CONFIG.images.defaultWatermarkWidth,
height:
this.toInt(searchParams.get('wm_height')) ||
DEFAULT_CONFIG.images.defaultWatermarkHeight
};
},
/**
* 构造水印叠加位置和透明度参数。
* @param {URLSearchParams} searchParams - 查询参数
* @returns {Object} 水印绘制参数
*/
buildWatermarkDrawOptions(searchParams) {
return {
bottom:
this.toInt(searchParams.get('wm_bottom')) ||
DEFAULT_CONFIG.images.defaultWatermarkBottom,
right:
this.toInt(searchParams.get('wm_right')) ||
DEFAULT_CONFIG.images.defaultWatermarkRight,
opacity:
this.toFloat(searchParams.get('wm_opacity')) ||
DEFAULT_CONFIG.images.defaultWatermarkOpacity
};
},
/**
* 构造输出编码参数,未指定时按原图类型和浏览器能力选择。
* @param {string} acceptHeader - 客户端 Accept 头
* @param {URLSearchParams} searchParams - 查询参数
* @param {string} sourceMimeType - 原图 MIME 类型
* @returns {Object} 输出参数
*/
buildOutputOptions(acceptHeader, searchParams, sourceMimeType) {
const format = this.resolveOutputFormat(searchParams.get('format'), acceptHeader, sourceMimeType);
return {
format,
quality:
this.toInt(searchParams.get('quality')) || DEFAULT_CONFIG.images.defaultQuality
};
},
/**
* 解析输出格式,避免未指定格式时默认把所有图片都强制改为 JPEG。
* @param {string|null} requestedFormat - 显式请求的格式
* @param {string} acceptHeader - 客户端 Accept 头
* @param {string} sourceMimeType - 原图 MIME 类型
* @returns {string} 输出 MIME 类型
*/
resolveOutputFormat(requestedFormat, acceptHeader, sourceMimeType) {
const formatMap = {
avif: 'image/avif',
webp: 'image/webp',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
png: 'image/png'
};
if (requestedFormat) {
const normalizedFormat = requestedFormat.toLowerCase();
if (formatMap[normalizedFormat]) {
return formatMap[normalizedFormat];
}
return null;
}
if (acceptHeader.includes('image/avif')) {
return 'image/avif';
}
if (acceptHeader.includes('image/webp')) {
return 'image/webp';
}
if (sourceMimeType === 'image/png') {
return 'image/png';
}
if (['image/webp', 'image/jpeg'].includes(sourceMimeType)) {
return sourceMimeType;
}
return 'image/jpeg';
},
/**
* 校验输出格式是否在允许列表中。
* @param {string|null} format - 输出格式
* @returns {boolean} 是否允许
*/
isAllowedOutputFormat(format) {
if (!format) {
return false;
}
return DEFAULT_CONFIG.security.allowedOutputFormats.includes(format);
},
/**
* 创建不经过 Images 处理的原样代理响应。
* @param {Response} sourceResponse - 原图响应
* @param {string} sourceMimeType - 原图 MIME 类型
* @returns {Response} 原样代理响应
*/
createPassthroughResponse(sourceResponse, sourceMimeType) {
return this.decorateImageResponse(sourceResponse, {
sourceMimeType,
transformed: false
});
},
/**
* 为图片响应统一补齐缓存、跨域和调试响应头。
* @param {Response} response - 原始响应
* @param {Object} options - 额外控制参数
* @param {string} options.sourceMimeType - 原图 MIME 类型
* @param {boolean} options.transformed - 是否经过 Images 处理
* @returns {Response} 带统一头部的响应
*/
decorateImageResponse(response, { sourceMimeType, transformed }) {
const headers = new Headers(response.headers);
headers.set('Content-Type', headers.get('Content-Type') || sourceMimeType);
headers.set('Cache-Control', `public, max-age=${DEFAULT_CONFIG.cacheMaxAge}`);
headers.set('CDN-Cache-Control', `public, max-age=${DEFAULT_CONFIG.cacheMaxAge}`);
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type');
headers.set('X-Image-Proxy-Mode', transformed ? 'images' : 'passthrough');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
},
/**
* 为响应添加缓存命中状态,便于排查线上行为。
* @param {Response} response - 原始响应
* @param {string} cacheStatus - 缓存状态
* @returns {Response} 带调试头的响应
*/
withCacheStatus(response, cacheStatus) {
const headers = new Headers(response.headers);
headers.set('X-Image-Proxy-Cache', cacheStatus);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
},
/**
* 将响应写入 Cloudflare 默认缓存,缓存失败时不中断主流程。
* @param {Cache} cache - Cloudflare 默认缓存
* @param {Request} cacheKey - 合法的缓存键请求对象
* @param {Response} response - 要缓存的响应
* @returns {Promise<void>}
*/
async storeInCache(cache, cacheKey, response) {
try {
await cache.put(cacheKey, response);
} catch (error) {
console.error('Cache write failed:', error);
}
},
/**
* 规范化图片 URL,兼容部分站点的页面型图片链接。
* @param {string} url - 原始图片 URL
* @returns {string} 规范化后的图片 URL
*/
normalizeImageUrl(url) {
try {
const parsedUrl = new URL(url);
const host = parsedUrl.hostname.toLowerCase();
if (host === 'github.com') {
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
const blobIndex = pathSegments.indexOf('blob');
if (pathSegments.length >= 5 && blobIndex === 2) {
const owner = pathSegments[0];
const repo = pathSegments[1];
const branch = pathSegments[3];
const filePath = pathSegments.slice(4).join('/');
if (owner && repo && branch && filePath) {
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`;
}
}
}
return parsedUrl.toString();
} catch {
return url;
}
},
/**
* 验证 URL 格式和域名白名单。
* @param {string} url - 要验证的 URL
* @returns {boolean} 是否有效
*/
validateUrl(url) {
try {
const parsedUrl = new URL(url);
const host = parsedUrl.hostname.toLowerCase();
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return false;
}
if (this.matchesDomainList(host, DEFAULT_CONFIG.security.blockedSourceDomains)) {
return false;
}
if (DEFAULT_CONFIG.allowedDomains.length > 0) {
const isAllowed = DEFAULT_CONFIG.allowedDomains.some(domain => {
return host === domain.toLowerCase() || host.endsWith(`.${domain.toLowerCase()}`);
});
if (!isAllowed) {
return false;
}
}
return true;
} catch {
return false;
}
},
/**
* 检测二进制内容对应的图片类型,用于兜底识别非标准响应头。
* @param {ArrayBuffer} data - 图片数据
* @returns {string|null} 识别出的 MIME 类型
*/
detectImageType(data) {
const uint8Array = new Uint8Array(data);
if (
uint8Array.length >= 12 &&
uint8Array[0] === 0x52 &&
uint8Array[1] === 0x49 &&
uint8Array[2] === 0x46 &&
uint8Array[3] === 0x46 &&
uint8Array[8] === 0x57 &&
uint8Array[9] === 0x45 &&
uint8Array[10] === 0x42 &&
uint8Array[11] === 0x50
) {
return 'image/webp';
}
if (
uint8Array.length >= 3 &&
uint8Array[0] === 0xff &&
uint8Array[1] === 0xd8 &&
uint8Array[2] === 0xff
) {
return 'image/jpeg';
}
if (
uint8Array.length >= 8 &&
uint8Array[0] === 0x89 &&
uint8Array[1] === 0x50 &&
uint8Array[2] === 0x4e &&
uint8Array[3] === 0x47 &&
uint8Array[4] === 0x0d &&
uint8Array[5] === 0x0a &&
uint8Array[6] === 0x1a &&
uint8Array[7] === 0x0a
) {
return 'image/png';
}
if (uint8Array.length >= 6) {
const header = String.fromCharCode(...uint8Array.slice(0, 6));
if (header === 'GIF87a' || header === 'GIF89a') {
return 'image/gif';
}
}
if (uint8Array.length >= 2 && uint8Array[0] === 0x42 && uint8Array[1] === 0x4d) {
return 'image/bmp';
}
if (uint8Array.length >= 5) {
const text = new TextDecoder('utf-8').decode(
uint8Array.slice(0, Math.min(256, uint8Array.length))
);
if (text.includes('<svg') || text.includes('<?xml')) {
if (text.includes('<svg')) {
return 'image/svg+xml';
}
}
}
return null;
},
/**
* 生成原样代理的缓存键。
* @param {Request} request - 当前请求对象
* @param {string} url - 规范化后的图片地址
* @returns {Request} 原样代理缓存键
*/
generatePassthroughCacheKey(request, url) {
const cacheUrl = new URL(request.url);
cacheUrl.pathname = '/__image_proxy_cache__';
cacheUrl.search = '';
cacheUrl.hash = '';
cacheUrl.searchParams.set('url', url);
return new Request(cacheUrl.toString(), { method: 'GET' });
},
/**
* 生成带变换参数的缓存键,避免不同格式和尺寸互相串缓存。
* @param {Request} request - 当前请求对象
* @param {string} url - 规范化后的图片地址
* @returns {Request} 变换缓存键
*/
generateTransformCacheKey(request, url) {
const sourceUrl = new URL(request.url);
const cacheUrl = new URL('/__image_proxy_cache__', sourceUrl.origin);
cacheUrl.searchParams.set('url', url);
for (const key of TRANSFORM_QUERY_KEYS) {
const value = sourceUrl.searchParams.get(key);
if (value !== null && value !== '') {
cacheUrl.searchParams.set(key, value);
}
}
return new Request(cacheUrl.toString(), { method: 'GET' });
},
/**
* 创建 OPTIONS 预检响应。
* @returns {Response} 预检响应
*/
createOptionsResponse() {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
}
});
},
/**
* 创建错误响应,并返回透明 GIF 保持图片标签可加载。
* @param {string} message - 错误消息
* @param {number} status - HTTP 状态码
* @returns {Response} 错误响应
*/
createErrorResponse(message, status = 500) {
const transparentGif = new Uint8Array([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00,
0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x21, 0xf9, 0x04, 0x01, 0x00,
0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b
]);
return new Response(transparentGif, {
status,
headers: {
'Content-Type': 'image/gif',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'X-Image-Proxy-Error': message
}
});
},
/**
* 将字符串安全解析为整数。
* @param {string|null} value - 原始参数
* @returns {number|null} 解析结果
*/
toInt(value) {
if (value === null || value === '') {
return null;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
},
/**
* 将字符串安全解析为浮点数。
* @param {string|null} value - 原始参数
* @returns {number|null} 解析结果
*/
toFloat(value) {
if (value === null || value === '') {
return null;
}
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
},
/**
* 拦截超大原图,优先使用响应头判断,必要时边读边统计流大小。
* @param {Response} response - 原图响应
* @returns {Promise<{ok: boolean, response?: Response, status?: number, message?: string}>} 校验结果
*/
async enforceSourceSizeLimit(response) {
const limit = DEFAULT_CONFIG.security.maxSourceImageBytes;
const contentLength = this.toInt(response.headers.get('Content-Length'));
if (contentLength !== null && contentLength > limit) {
return {
ok: false,
status: 413,
message: 'Source image too large'
};
}
if (!response.body) {
return { ok: true, response };
}
const [bodyStream, inspectStream] = response.body.tee();
const reader = inspectStream.getReader();
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
totalBytes += value.byteLength;
if (totalBytes > limit) {
await reader.cancel('Source image too large');
return {
ok: false,
status: 413,
message: 'Source image too large'
};
}
}
} catch (error) {
console.error('Failed to inspect source image size:', error);
return {
ok: false,
status: 502,
message: 'Failed to inspect source image size'
};
}
return {
ok: true,
response: new Response(bodyStream, {
status: response.status,
statusText: response.statusText,
headers: response.headers
})
};
}
};
这个服务原本只是用来写文章的时候使用的,最近被人滥用,考虑放出代码部分,各位老板可以自行部署。赛博大善人每天十万次,自己用绰绰有余。
- All rights reserved.
- No part of this website, including text and images, may be reproduced, modified, distributed, or transmitted in any form or by any means, without the prior written permission of the author.
- Unauthorized commercial use is strictly prohibited.
- Unauthorized personal use is strictly prohibited.
