为了高效利用资源(白嫖),我采用了
Cloudflare
生态实现图床功能。通过Halo
的S3插件对R2
进行图片管理,最后通过Workers
访问图片
通过 Cloudflare Dashboard
操作比较简单,本文介绍使用 Wrangler CLI
构建项目。
准备工作
注册 Cloudflare 账号
安装 Node.js
安装 Wrangler CLI
Wrangler CLI
是 Cloudflare
官方提供的用于管理 Workers
的命令行工具。
npm install -g wrangler
创建 Worker 项目
使用 Wrangler
创建一个新的 Worker
项目:
wrangler generate <YOUR_WORKER_NAME> https://github.com/cloudflare/worker-template
将 <YOUR_WORKER_NAME>
替换为自己定义的名字,或者 npm
创建一个项目模板:
npm create cloudflare@latest -- <YOUR_WORKER_NAME>
需要根据提示选择:
What would you like to start with?
Hello World example
Which template would you like to use?
Worker only
Which language do you want to use?
TypeScript
操作 R2
R2
用于存储图片、视频
创建
npx wrangler r2 bucket create <YOUR_BUCKET_NAME>
将 <YOUR_BUCKET_NAME>
替换为自己定义的名字
绑定
上面命令执行成功后会输出绑定声明,需要将其复制到 Wrangler
文件中:
wrangler.jsonc
文件
{
"r2_buckets": [
{
"binding": "BUCKET",
"bucket_name": "<YOUR_BUCKET_NAME>"
}
]
}
或者 wrangler.toml
文件
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "<YOUR_BUCKET_NAME>"
binding
: worker
中访问 R2
的变量名。
bucket_name
: R2
桶名。
上传图片
通过
S3 API
上传图片至R2
我采用的是 Halo
博客的 对象存储(Amazon S3 协议) 插件。
注意:目前通过 Halo
删除图片无法同步删除 D1
里的记录,只能通过随机图片的时候进行检查图片是否存在。
操作 D1 数据库
D1数据库
用于储存 R2
里的图片、视频信息。
创建
运行以下命令安装 D1数据库
:
npx wrangler d1 create <YOUR_D1_NAME>
将 <YOUR_D1_NAME>
替换为自己定义的名字。
绑定
上面命令执行成功后会输出绑定声明,需要将其复制到 Wrangler
文件中:
wrangler.jsonc
文件
{
"d1_databases": [
{
"binding": "DB",
"database_name": "<YOUR_D1_NAME>",
"database_id": "<YOUR_D1_ID>"
}
]
}
或者 wrangler.toml
文件
[[d1_databases]]
binding = "DB"
database_name = "<YOUR_D1_NAME>"
database_id = "<YOUR_D1_ID>"
binding
: worker
中访问 D1
的变量名。
database_name
: D1数据库
名字。
database_id
: D1数据库
ID。
修改数据库
逐行操作数据库,以下是官方参考命令:
npx wrangler d1 execute <YOUR_D1_NAME> --command "CREATE TABLE user (id INTEGER PRIMARY KEY NOT NULL, username STRING NOT NULL, password STRING NOT NULL)" --remote
npx wrangler d1 execute <YOUR_D1_NAME> --command "CREATE UNIQUE INDEX user_username ON user (username)" --remote
npx wrangler d1 execute <YOUR_D1_NAME> --command "INSERT INTO user (username, password) VALUES ('<YOUR_USERNAME>', '<YOUR_HASHED_PASSWORD>')" --remote
也可以执行 .sql
文件:
npx wrangler d1 execute <YOUR_D1_NAME> --remote --file=<YOUR_SQLFILE_PATH>
我的sql文件:
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE,
prefix TEXT,
create_by TEXT,
create_ip TEXT,
create_date TEXT,
type TEXT
);
拓展: --local
是本地数据库 --remote
远程数据库
环境变量
修改 wrangler.jsonc
文件,添加以下:
"vars": { "RANDOM_DIRS": "YOUR_RANDOM_DIRS" ,
"ALLOWED_IMAGE_EXTENSIONS":".png,.jpg,.jpeg,.webp,.gif,.bmp,.svg",
"ALLOWED_VIDEO_EXTENSIONS":".mp4,.mov,.avi,.mkv,.webm"
}
YOUR_RANDOM_DIRS
支持多个文件夹,以 ,
分隔,比如: dir1,dir2
ALLOWED_IMAGE_EXTENSIONS
允许的图片格式。
ALLOWED_VIDEO_EXTENSIONS
允许的视频格式。
修改 Worker 代码
以下代码主要包含功能:
根据URL获取图片
随机获取一张图片或视频(支持缓存、支持指定
R2
文件夹)根据
R2
里的图片、视频文件将信息插入到D1数据库
const PREFIX_IMAGES = "/你自定义的请求图片路径/";
const PREFIX_INSERT= "/你自定义的新增图片记录路径";
const PREFIX_RANDOM= "/你自定义的随机图片路径";
function getCNTime() {
const now = new Date();
const cnTime = new Date(now.getTime() + 8 * 60 * 60 * 1000);
return cnTime.toISOString().replace('T', ' ').replace('Z', '');
}
/**
* 自动添加Image信息到D1
* @param {*} ip
* @param {*} bucket
* @param {*} db
* @returns
*/
async function autoInsertImageInfo(ip, env) {
let ALLOWED_IMAGE_EXTENSIONS;
let ALLOWED_VIDEO_EXTENSIONS;
//检查环境变量,没有则使用默认值
if (typeof env.ALLOWED_IMAGE_EXTENSIONS == "undefined" || env.ALLOWED_IMAGE_EXTENSIONS == null || env.ALLOWED_IMAGE_EXTENSIONS == "")
ALLOWED_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"];
else
ALLOWED_IMAGE_EXTENSIONS = env.ALLOWED_IMAGE_EXTENSIONS.split(',');
if (typeof env.ALLOWED_VIDEO_EXTENSIONS == "undefined" || env.ALLOWED_VIDEO_EXTENSIONS == null || env.ALLOWED_VIDEO_EXTENSIONS == "")
ALLOWED_VIDEO_EXTENSIONS = [".mp4", ".mov", ".avi", ".mkv", ".webm"];
else
ALLOWED_VIDEO_EXTENSIONS = env.ALLOWED_VIDEO_EXTENSIONS.split(',');
let cursor = undefined;
let count = 0;
do {
const list = await env.BUCKET_IMAGES.list({ cursor, limit: 1000 });
cursor = list.cursor;
for (const obj of list.objects) {
const key = obj.key;
const lowerKey = key.toLowerCase();
// 判断是图片还是视频
let fileType;
if (ALLOWED_IMAGE_EXTENSIONS.some(ext => lowerKey.endsWith(ext))) {
fileType = "image";
} else if (ALLOWED_VIDEO_EXTENSIONS.some(ext => lowerKey.endsWith(ext))) {
fileType = "video";
} else
// 非图片非视频,跳过
continue;
const prefix = key.includes("/") ? key.substring(0, key.lastIndexOf("/")) : "";
const timestamp = getCNTime();
await env.DB_IMG.prepare(
`INSERT OR IGNORE INTO images (key, prefix,create_by, create_date,create_ip,type) VALUES (?, ?, ?, ?, ?, ?)`
).bind(key, prefix, "admin", timestamp, ip, fileType).run();
count++;
}
} while (cursor);
return new Response("Insert Success,Count: " + count, { status: 200 });
}
/**
* 获取随机文件列表(带缓存)
* @param {*} db
* @param {*} url
* @param {*} dir R2文件夹
* @returns
*/
async function getRandomFileList(db, url, dir) {
const cache = caches.default;
const cacheRes = await cache.match(`${url.origin}${PREFIX_RANDOM}?dir=${dir}`);
if (cacheRes) {
return JSON.parse(await cacheRes.text());
}
let items;
try {
const db_result = await db.prepare(`SELECT key,type FROM images WHERE prefix = ?`)
.bind(dir)
.all();
items = db_result.results;
}
catch (error) {
return new Response('SQL 查询失败。请先检查你的 SQL 是否有误;如果确认无误,请联系管理员。', {
status: 400,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
}
const results = items.map(r => ({
name: r.key,
type: r.type,
url: `${url.origin}${PREFIX_IMAGES}${r.key}`
}));
if (results.length > 0)
// 缓存结果,缓存时间为24小时
await cache.put(`${url.origin}${PREFIX_RANDOM}?dir=${dir}`, new Response(JSON.stringify(results), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=72000"
}
}));
return results
}
/**
* 处理直接获取的url
* 20250508 by Tim
* @param {*} key
* @param {*} bucket
* @returns
*/
async function handleFetch(key, bucket, db) {
// 防止有中文乱码
const decodedKey = decodeURIComponent(key);
const object = await bucket.get(decodedKey);
if (!object) {
//先删除db记录
await db.prepare(
`DELETE FROM images
WHERE key = ?`
).bind(decodedKey).run();
return new Response("Object not found", { status: 404 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("ETag", object.httpEtag);
return new Response(object.body, {
headers
});
}
/**
* 处理随机图片API
* @param {*} url
* @param {*} searchParams searchParams对象
* @param {*} env
* @returns
*/
async function handleRandom(url, searchParams, env) {
if (typeof env.RANDOM_DIRS == "undefined" || env.RANDOM_DIRS == null || env.RANDOM_DIRS == "") {
return new Response('Please Configure Random Dir.', { status: 500 });
}
const allowedDirList = env.RANDOM_DIRS.split(',');//允许随机的dir列表
const allowedDirListFormatted = allowedDirList.map(item => {
return item.trim().replace(/^\/+/, '').replace(/\/{2,}/g, '/').replace(/\/$/, '');//去掉首位的‘/’
});
//请求的dir
const dir = (searchParams.get("dir") || '').replace(/^\/+/, '').replace(/\/{2,}/g, '/').replace(/\/$/, '');//去掉首位的‘/’
// 检查是否在允许的目录中,或是允许目录的子目录
let dirAllowed = false;
for (let i = 0; i < allowedDirListFormatted.length; i++) {
if (allowedDirListFormatted[i] === '' || dir === allowedDirListFormatted[i] || dir.startsWith(allowedDirListFormatted[i] + '/')) {
dirAllowed = true;
break;
}
}
if (!dirAllowed) {
return new Response("Directory not allowed.", { status: 403 });
}
let allRecords = await getRandomFileList(env.DB_IMG, url, dir);
// 筛选出符合fileType要求的记录
const fileType = (searchParams.get("type") || '')
if (fileType)
allRecords = allRecords.filter(item => item.type.includes(fileType));
if (allRecords.length <= 0) {
return new Response("Nothing that can be used.", { status: 200 });
}
const randomIndex = Math.floor(Math.random() * allRecords.length);
const randomKey = allRecords[randomIndex];
const randomPath = PREFIX_IMAGES + randomKey.name;
let randomUrl = url.origin + randomPath;
let form = searchParams.get("form") || '';
if (!form || form == 'img') {
return new Response(null, {
status: 302,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET', // 允许的HTTP方法
'Access-Control-Allow-Headers': '*', // 允许的请求头
'Location': randomUrl,
},
});
}
if (form == 'text')
return new Response(randomUrl, { status: 200 });
else
return new Response(JSON.stringify({ url: randomUrl }), { status: 200 });
}
export default {
async fetch(request, env, ctx) {
if (request.method !== "GET") {
return new Response("Only GET method allowed", { status: 405 });
}
const url = new URL(request.url);
const { pathname, searchParams } = url;
// return new Response(JSON.stringify(url.origin),{status:500});
// 检查URL中是否包含 "favicon.ico"
if (pathname.includes("favicon.ico")) {
// 如果包含 "favicon.ico",则返回404响应
return new Response('Not Found', {
status: 404,
statusText: 'Not Found',
headers: {
'Content-Type': 'text/plain',
},
});
}
if (pathname.startsWith(PREFIX_IMAGES)) {
return await handleFetch(pathname.slice(PREFIX_IMAGES.length), env.BUCKET_IMAGES, env.DB_IMG);
}
else if (pathname.startsWith(PREFIX_INSERT)) {
//根据R2信息,插入D1
const cfIp = request.headers.get("CF-Connecting-IP");
const xff = request.headers.get("X-Forwarded-For");
const uploaderIp = cfIp || (xff ? xff.split(",")[0].trim() : "");
return await autoInsertImageInfo(uploaderIp, env);
}
else if (pathname.startsWith(PREFIX_RANDOM)) {
//随机图片API
return await handleRandom(url, searchParams, env);
}
else {
return new Response('Address Not Found', { status: 404 });
}
},
};
部署
远程部署
npx wrangler deploy
拓展:本地运行
npx wrangler dev
评论