Image Proxy Worker 服务

Image Proxy Worker 服务

这是一个运行在 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.