最早这个博客是托管在 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 ; 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 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 ); } 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 ; } 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; } function isUrl (path ) { return path.startsWith ("http://" ) || path.startsWith ("https://" ); } function resolveLocalImagePath (localPath ) { if (path.isAbsolute (localPath)) { return localPath; } return path.resolve (__dirname, ".." , localPath); } 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 { 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 ; } 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
自动上传该文章中出现的图片并替换链接啦。