最早这个博客是托管在 GitHub pages 上的,绑定的域名是在阿里云注册的,几个月前偶然一次机会,在 Cloudflare 上注册了一个自己喜欢的域名。于是就也通过 Cloudflare pages 托管了相同的 GitHub pages 仓库,因此目前使用 Cloudflare pages 或者 GitHub pages 均可访问,内容都来自同一个 GitHub repo
使用 Images
就在我无聊把玩赛博菩萨的各种功能的时候,发现 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; const ALLOWED_REFERERS = [];
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) { 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.js
中 IMAGE_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"); const sharp = require("sharp");
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 MAX_WIDTH = 800; const MAX_HEIGHT = 600;
const abbrlink = process.argv[2]; if (!abbrlink) { console.error("请提供 abbrlink\n 例如: npm run upload e6a2e300"); process.exit(1); }
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");
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; }
async function calculateOptimalSize(imagePath) { const metadata = await sharp(imagePath).metadata(); const { width, height } = metadata;
if (width <= MAX_WIDTH && height <= MAX_HEIGHT) { return { width, height, zoom: 100 }; }
const widthScale = MAX_WIDTH / width; const heightScale = MAX_HEIGHT / height; const scale = Math.min(widthScale, heightScale);
const newWidth = Math.round(width * scale); const newHeight = Math.round(height * scale); const zoom = Math.round(scale * 100);
return { width: newWidth, height: newHeight, zoom: zoom }; }
function extractImageTags(content) { const regex = /<img\s+src="([^"]+)"(?:\s+title="([^"]*)")?[^>]*>/g; const matches = []; let match;
while ((match = regex.exec(content)) !== null) { matches.push({ fullTag: match[0], localPath: match[1], title: match[2] || "", }); }
return matches; }
async function uploadToCloudflare(filePath) { const fileName = path.basename(filePath); const newFileName = `${abbrlink}-${fileName}`;
const form = new FormData(); form.append("file", fs.createReadStream(filePath), { filename: newFileName }); 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) { throw new Error("上传失败: " + JSON.stringify(response.data)); }
return response.data.result.id; }
async function main() { console.log(`处理文章: ${abbrlink}`);
const postPath = findPostByAbbrlink(abbrlink); if (!postPath) { console.error(`${abbrlink}: 未找到该文章`); process.exit(1); }
let content = fs.readFileSync(postPath, "utf-8"); const imageTags = extractImageTags(content);
if (imageTags.length === 0) { console.log(`${abbrlink}: 没有找到图片`); return; }
console.log(`${abbrlink}: 图片数量 ${imageTags.length}`);
for (const tag of imageTags) { try { if (tag.localPath.startsWith("http")) { console.log(`${abbrlink}: 跳过 URL 图片 ${tag.localPath}`); continue; }
const imagePath = path.isAbsolute(tag.localPath) ? tag.localPath : path.resolve(__dirname, "..", tag.localPath);
if (!fs.existsSync(imagePath)) { console.error(`${abbrlink}: 图片不存在 ${imagePath}`); continue; }
console.log(`${abbrlink}: 正在处理 ${tag.localPath}`);
const { width, height, zoom } = await calculateOptimalSize(imagePath); console.log(`${abbrlink}: 自动缩放 ${width}x${height} (${zoom}%)`);
const imageId = await uploadToCloudflare(imagePath); console.log(`${abbrlink}: 上传成功 ${imageId}`);
const newUrl = `${IMAGE_BASE_URL}/${imageId}/origin`; const titleText = tag.title || "图片"; const newTag = `<img src="${newUrl}" title="${titleText}" style="zoom:${zoom}%;" />`;
content = content.replace(tag.fullTag, newTag); console.log(`${abbrlink}: 处理完成 ${tag.localPath} -> ${newUrl} (${zoom}%)`); } catch (error) { console.error(`${abbrlink}: 处理失败: ${tag.localPath}`, error.message); } }
fs.writeFileSync(postPath, content, "utf-8"); console.log("处理完成"); }
main();
|
并将该脚本添加到 package.json
{ "scripts": { "upload": "node tools/upload-images.js" } }
|
然后就可以通过 npm run upload xxxxxxxx
自动上传该文章中出现的图片并替换链接啦。
使用 R2
如果不想使用 Images 的付费功能,也可以使用 R2 的免费套餐搭建。其中 R2 的免费套餐包含 10G 存储空间、 A 类操作 100w/月 和 B 类操作 1000w/月。
详情参考 Cloudflare Docs R2
如果直接把域名绑定到 R2 Bucket
上开启公网访问,很容易一夜之间倾家荡产。所以关闭 R2 Bucket
公网访问,使用 Workers 免费计划的 10w/天 的请求量天然的限制避免被刷流量。
详情参考 Cloudflare Docs Workers
首先创建一个 R2 Bucket
,名称自定义。然后新建一个 Workers 代码如下
export default { async fetch(request, env) { if (request.method !== "GET") { return new Response("Method Not Allowed", { status: 405 }); }
const url = new URL(request.url); const path = decodeURIComponent(url.pathname.slice(1)); if (!path) return new Response("Not Found", { status: 404 });
const cacheKey = new Request(`${url.origin}${url.pathname}`);
try { const cached = await caches.default.match(cacheKey); if (cached) return cached;
const file = await env.IMAGES.get(path); if (!file) return new Response("Not Found", { status: 404 });
const response = new Response(file.body, { headers: { "Content-Type": file.httpMetadata?.contentType || "application/octet-stream", "Cache-Control": "public, max-age=21600", }, });
await caches.default.put(cacheKey, response.clone()); return response; } catch { return new Response("Server Error", { status: 500 }); } }, };
|
然后在 Workers 绑定处添加绑定上面创建的 R2 Bucket
,名称只需要与 Workers 代码中的名称一致即可。