Cloudflare 使用 Workers 建立 Images 代理

最早这个博客是托管在 GitHub pages 上的,绑定的域名是在阿里云注册的,几个月前偶然一次机会,在 Cloudflare 上注册了一个自己喜欢的域名。于是就也通过 Cloudflare pages 托管了相同的 GitHub pages 仓库,因此目前使用 Cloudflare pages 或者 GitHub pages 均可访问,内容都来自同一个 GitHub repo

而就在我无聊把玩赛博菩萨的各种功能的时候,发现 Images 的定价还算很实惠,于是乎开通了 Images Stream Bundle Basic 套餐,包含预付费 $5.00/月 100,000 张图片和 1,000 分钟视频存储以及后付费 $1.00/100,000 张图片的交付。
既然都订阅了,然后就想着把博客现在的图片都迁移到 Images 上来。但是吧,问题就在于 Images 的图片是可以公共读的。直接放到博客上万一哪天被刷流量了,得不偿失。研究了一下发现 Images 是可以生成带有过期时间的签名 URL 的,可是问题是我的博客是静态博客,图片链接都需要写死在 <img> 标签里面。左思右想之下想到了一个折中的办法。
总体思路就是上传的图片打开 需要已签名的 URL 然后该图片就必须通过签名 URL 访问,然后实现一个 Worker 判断 URL 是否包含签名信息,如果不包含,则通过 URL 接收 图像 ID(imageID)变体(variant) 然后对 URL 实现签名,生成一个带签名的 URL 并 302 重定向回 Worker。如果 URL 包含签名信息,则请求图片资源并返回。

思路有了那就开始,首先新建一个 Worker 代码如下

const ACCOUNT_HASH = "此处填写 Images 帐户哈希";
const KEY = "此处填写 Image 账户 API 令牌";
const IMAGE_CUSTOM_DOMAIN = "此处填写 Worker 绑定的自定义域名";
const EXPIRATION = 60; // 签名图片过期时间 1 min
const ALLOWED_REFERERS = []; // 此处填写请求 Referer 白名单作为防盗链

const IMAGE_DELIVERY_DOMAIN = "imagedelivery.net";

const bufferToHex = (buffer) =>
[...new Uint8Array(buffer)]
.map((x) => x.toString(16).padStart(2, "0"))
.join("");

async function generateSignedUrl(url) {
const encoder = new TextEncoder();
const secretKey = encoder.encode(KEY);
const key = await crypto.subtle.importKey(
"raw",
secretKey,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);

const exp = Math.floor(Date.now() / 1000) + EXPIRATION;
url.searchParams.set("exp", exp);

const stringToSign = url.pathname + "?" + url.searchParams.toString();
const mac = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(stringToSign)
);

const signature = bufferToHex(new Uint8Array(mac).buffer);
url.searchParams.set("sig", signature);

return url;
}

export default {
async fetch(request) {
// 检查 Referer (只有 ALLOWED_REFERERS 不为空时才检查)
const referer = request.headers.get("Referer") || "";
if (ALLOWED_REFERERS.length &&!ALLOWED_REFERERS.some((allow) => referer.startsWith(allow))) {
return new Response("Forbidden", { status: 403 });
}

// 解析路径
const url = new URL(request.url);
const match = url.pathname.match(/^\/([^/]+)\/([^/]+)$/);
if (!match) {
return new Response("Bad Request", { status: 400 });
}

const [, imageID, variant] = match;

if (!url.searchParams.has("exp") || !url.searchParams.has("sig")) {
// 无签名 - 生成签名并重定向
const deliveryUrl = new URL(
`https://${IMAGE_DELIVERY_DOMAIN}/${ACCOUNT_HASH}/${imageID}/${variant}`
);
const signedUrl = await generateSignedUrl(deliveryUrl);

const redirectUrl = new URL(
`https://${IMAGE_CUSTOM_DOMAIN}/${imageID}/${variant}`
);
redirectUrl.search = signedUrl.search;

return Response.redirect(redirectUrl.toString(), 302);
} else {
// 有签名 - 直接请求图片
const imageUrl = new URL(
`https://${IMAGE_DELIVERY_DOMAIN}/${ACCOUNT_HASH}/${imageID}/${variant}`
);
imageUrl.search = url.search;

const resp = await fetch(imageUrl.toString(), request);
return new Response(resp.body, {
status: resp.status,
headers: resp.headers,
});
}
},
};

然后将 Worker.jsIMAGE_CUSTOM_DOMAIN 的域名绑定到这个 Worker 上。
在 Images 上传一张图片,打开 需要已签名的 URL 然后获取到他的图像 ID,可以在 Images 里面自定义一个变体,然后就可以通过 https://IMAGE_CUSTOM_DOMAIN/{imageID}/{variant} 访问该图片,此时会自动跳转到 https://IMAGE_CUSTOM_DOMAIN/{imageID}/{variant}?exp={exp}&sig={sig} 其中 sig 就是签名信息,exp 是该链接的过期时间。如果填写了 ALLOWED_REFERERS 则请求必须携带对应的 Referer 否则会 403

现在 Cloudflare 的流程就没有问题了,接下来解决静态博客的问题。我是用的 Hexo 的静态博客,文章中插入图片需要使用 <img> 标签。每次都需要将本地图片传到 Cloudflare Images 并且编辑图像手动打开 需要已签名的 URL 然后再获取到对应的图像 ID 再替换为 URL 略显麻烦。因此手动创建了一个脚本自动化实现以上功能,我只需要在 <img> 标签中添加图片的本地文件路径,然后写好博客之后调用脚本就会自动将文章中引用的 img 图片上传到 Cloudflare Images 并且打开 需要已签名的 URL 然后根据返回的图像 ID 自动拼接 http 链接并替换原文 <img> 标签的本地文件地址。

在本地 Hexo 项目下创建一个 tools/upload-images.js 文件

#!/usr/bin/env node

const fs = require("fs");
const path = require("path");
const axios = require("axios");
const FormData = require("form-data");

// Cloudflare Images API 配置
const CF_ACCOUNT_ID = "此处填写 Image 帐户 ID";
const CF_API_TOKEN = "此处填写 Cloudflare API Token";
const CF_IMAGES_API_URL = `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/images/v1`;
const IMAGE_BASE_URL = "此处填写 Worker 带 https 的 URL 地址";

// 获取命令行参数
const abbrlink = process.argv[2];

if (!abbrlink) {
console.error("请提供 abbrlink\n 例如: npm run upload e6a2e300");
process.exit(1);
}

/**
* 根据 abbrlink 查找对应的文章文件
*/
function findPostByAbbrlink(abbrlink) {
const postsDir = path.join(__dirname, "../source/_posts");
const files = fs.readdirSync(postsDir);

for (const file of files) {
if (!file.endsWith(".md")) continue;

const filePath = path.join(postsDir, file);
const content = fs.readFileSync(filePath, "utf-8");

// 解析 front matter
const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontMatterMatch) {
const frontMatter = frontMatterMatch[1];
const abbrlinkMatch = frontMatter.match(/abbrlink:\s*([a-fA-F0-9]+)/);
if (abbrlinkMatch && abbrlinkMatch[1] === abbrlink) {
return filePath;
}
}
}

return null;
}

/**
* 从文件内容中提取所有图片标签
*/
function extractImageTags(content) {
// 匹配所有的图片标签
const imgTagRegex = /{% img ([^\s]+) (\d+) (\d+)(?: '([^']*)')? %}/g;
const matches = [];
let match;

while ((match = imgTagRegex.exec(content)) !== null) {
matches.push({
fullTag: match[0],
localPath: match[1],
width: match[2],
height: match[3],
alt: match[4] || "",
});
}

return matches;
}

/**
* 检查路径是否为 URL
*/
function isUrl(path) {
return path.startsWith("http://") || path.startsWith("https://");
}

/**
* 解析本地图片路径
*/
function resolveLocalImagePath(localPath) {
// 如果是绝对路径,直接使用
if (path.isAbsolute(localPath)) {
return localPath;
}

// 如果是相对路径,相对于项目根目录解析
return path.resolve(__dirname, "..", localPath);
}

/**
* 上传图片到 Cloudflare Images
*/
async function uploadToCloudflare(filePath) {
try {
const form = new FormData();
form.append("file", fs.createReadStream(filePath));
form.append("requireSignedURLs", "true");

const response = await axios.post(CF_IMAGES_API_URL, form, {
headers: {
Authorization: `Bearer ${CF_API_TOKEN}`,
...form.getHeaders(),
},
});

if (response.data.success) {
const imageId = response.data.result.id;
return imageId;
} else {
throw new Error("上传失败: " + JSON.stringify(response.data));
}
} catch (error) {
console.error(
`上传失败: ${filePath}`,
error.response?.data || error.message
);
throw error;
}
}

/**
* 主处理函数
*/
async function main() {
try {
console.log(`${abbrlink}: 正在处理...`);

// 查找文章文件
const postPath = findPostByAbbrlink(abbrlink);
if (!postPath) {
console.error(`${abbrlink}: 未找到`);
process.exit(1);
}

console.log(`${abbrlink}: ${postPath}`);

// 读取文章内容
let content = fs.readFileSync(postPath, "utf-8");

// 提取图片标签
const imageTags = extractImageTags(content);
if (imageTags.length === 0) {
return;
}

console.log(`${abbrlink}: ${imageTags.length} 个图片`);

// 处理每个图片
for (const tag of imageTags) {
try {
// 如果是URL,跳过处理
if (isUrl(tag.localPath)) {
console.log(`${abbrlink}: 无需处理 ${tag.localPath}`);
continue;
}

console.log(`${abbrlink}: 处理图片 ${tag.localPath}`);

// 解析本地图片路径
const imagePath = resolveLocalImagePath(tag.localPath);

// 检查文件是否存在
if (!fs.existsSync(imagePath)) {
console.error(`${abbrlink}: 图片不存在 ${imagePath}`);
continue;
}

// 上传到 Cloudflare
const imageId = await uploadToCloudflare(imagePath);

// 生成新的图片标签
const newUrl = `${IMAGE_BASE_URL}/${imageId}/origin`;
const newTag = tag.alt
? `{% img ${newUrl} ${tag.width} ${tag.height} '${tag.alt}' %}`
: `{% img ${newUrl} ${tag.width} ${tag.height} %}`;

// 替换文章中的标签
content = content.replace(tag.fullTag, newTag);
console.log(`${abbrlink}: 图片替换 ${tag.localPath} -> ${newUrl}`);
} catch (error) {
console.error(`${abbrlink}: 未知错误 ${tag.localPath}`, error.message);
// 继续处理下一个图片
}
}

// 替换链接
fs.writeFileSync(postPath, content, "utf-8");
console.log(`\n${abbrlink}: 处理完成`);
} catch (error) {
console.error(`${abbrlink}: 处理失败`, error.message);
process.exit(1);
}
}

main();

并将该脚本添加到 package.json

{
// ...省略
"scripts": {
// ...省略
"upload": "node tools/upload-images.js"
// ...省略
},
// ...省略
}

然后就可以通过 npm run upload xxxxxxxx 自动上传该文章中出现的图片并替换链接啦。