From e2735ff7283adbef23c101b1631c6a220f4cdc28 Mon Sep 17 00:00:00 2001 From: bctzl Date: Sat, 19 Sep 2026 20:50:14 +0800 Subject: [PATCH 1/5] Update storage.js --- lib/storage.js | 85 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 10 deletions(-) diff --git a/lib/storage.js b/lib/storage.js index a6681ff..cdf528e 100644 --- a/lib/storage.js +++ b/lib/storage.js @@ -1,26 +1,30 @@ -// 存储抽象层:Cloudflare R2 binding(Workers)→ S3 兼容对象存储 → 本地磁盘 +// 存储抽象层:Cloudflare R2 binding(Workers)→ 外部图床 API → 本地磁盘 // - Cloudflare Workers:优先用 R2 binding(wrangler.jsonc 声明 FORUMLIFY_BUCKET),无需 S3 密钥 -// - 其他平台(Vercel / 裸机):S3_* 环境变量走 S3 兼容 API;无 S3 配置则本地磁盘 uploads/ +// - 其他平台:配置 IMGBED_API + IMGBED_AUTH 走外部图床 API;无配置则本地磁盘 uploads/ import { AwsClient } from 'aws4fetch'; +// ===== 外部图床 API 配置(从环境变量读取)===== +const IMGBED_API = process.env.IMGBED_API; // 例如 https://你的图床域名/upload +const IMGBED_AUTH = process.env.IMGBED_AUTH; // 你的 authCode +const IMGBED_BASE = process.env.IMGBED_BASE || ''; // 图床返回的 URL 前缀(如果 API 返回相对路径) + const S3_ENDPOINT = process.env.S3_ENDPOINT; const S3_BUCKET = process.env.S3_BUCKET; const S3_REGION = process.env.S3_REGION || 'auto'; const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY; const S3_SECRET_KEY = process.env.S3_SECRET_ACCESS_KEY || process.env.S3_SECRET_KEY; -const S3_PUBLIC_URL = process.env.S3_PUBLIC_URL; // 公开访问前缀,如 https://cdn.example.com +const S3_PUBLIC_URL = process.env.S3_PUBLIC_URL; -// 存储模式:r2-binding > s3 > local +// 存储模式:r2-binding > external-api > s3 > local export const STORAGE_TYPE = isR2BindingAvailable() ? 'r2' - : (S3_ENDPOINT && S3_BUCKET && S3_ACCESS_KEY ? 's3' : 'local'); + : (IMGBED_API && IMGBED_AUTH ? 'external' : (S3_ENDPOINT && S3_BUCKET && S3_ACCESS_KEY ? 's3' : 'local')); const aws = STORAGE_TYPE === 's3' ? new AwsClient({ accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY, region: S3_REGION, service: 's3' }) : null; function isR2BindingAvailable() { - // 仅在 Cloudflare Workers 运行时(OpenNext 注入 globalThis.__OPENNEXT_CLOUDFLARE__)检查 binding try { const ctx = globalThis.__OPENNEXT_CLOUDFLARE__; return !!(ctx && ctx.env && ctx.env.FORUMLIFY_BUCKET); @@ -29,7 +33,6 @@ function isR2BindingAvailable() { } } -// 获取 R2 binding(Workers 上通过 getCloudflareContext 读取 env) async function getR2Bucket() { const { getCloudflareContext } = await import('@opennextjs/cloudflare'); const ctx = await getCloudflareContext({ async: true }); @@ -40,13 +43,52 @@ function objectUrl(name) { return `${S3_ENDPOINT}/${S3_BUCKET}/${name}`; } -// 保存对象 → 返回公开 URL(R2 binding 用 S3_PUBLIC_URL 或 workers.dev 路径;S3 用 S3_PUBLIC_URL 前缀;本地用 /uploads/) +// ===== 外部图床 API 辅助函数 ===== +// 调用图床 API 上传,返回 { url } +async function externalUpload(name, buffer, contentType) { + const formData = new FormData(); + const blob = new Blob([new Uint8Array(buffer)], { type: contentType || 'application/octet-stream' }); + formData.append('file', blob, name); + + const res = await fetch(`${IMGBED_API}?authCode=${encodeURIComponent(IMGBED_AUTH)}`, { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`图床上传失败 ${res.status}: ${errText}`); + } + + const result = await res.json(); + // CloudFlare-ImgBed 返回格式:{ data: [{ src: "https://..." }] } 或直接数组 + const src = result?.data?.[0]?.src || result?.[0]?.src || result?.src; + if (!src) throw new Error('图床未返回图片 URL: ' + JSON.stringify(result)); + + // 如果返回的是相对路径,补上 base + const url = src.startsWith('http') ? src : `${IMGBED_BASE}${src}`; + return { url }; +} + +// 从图床 URL 反推文件名(用于 getObject / deleteObject) +function nameFromUrl(name) { + // name 是 Forumlify 生成的 key,图床返回的 URL 里通常包含文件名 + // 这里直接用传入的 name 作为标识 + return name; +} + +// 保存对象 → 返回公开 URL export async function saveObject(name, buffer, contentType) { if (STORAGE_TYPE === 'r2') { const bucket = await getR2Bucket(); await bucket.put(name, new Uint8Array(buffer), { httpMetadata: { contentType: contentType || 'application/octet-stream' } }); return { url: publicUrl(name) }; } + + if (STORAGE_TYPE === 'external') { + return await externalUpload(name, buffer, contentType); + } + if (STORAGE_TYPE === 'local') { const { writeFile, mkdir } = await import('fs/promises'); const path = await import('path'); @@ -55,6 +97,7 @@ export async function saveObject(name, buffer, contentType) { await writeFile(path.join(dir, name), buffer); return { url: '/uploads/' + name }; } + await aws.fetch(objectUrl(name), { method: 'PUT', headers: { 'Content-Type': contentType || 'application/octet-stream' }, @@ -63,7 +106,6 @@ export async function saveObject(name, buffer, contentType) { return { url: publicUrl(name) }; } -// 生成公开 URL:优先 S3_PUBLIC_URL(自定义公开域名),否则 S3 endpoint 路径式 function publicUrl(name) { if (S3_PUBLIC_URL) { const base = S3_PUBLIC_URL.replace(/\/$/, ''); @@ -77,6 +119,8 @@ function publicUrl(name) { } // 读取对象 → Buffer 或 null +// 注意:CloudFlare-ImgBed 的 API 没有提供"按 key 读取"的接口, +// 这里通过 IMGBED_BASE + name 直接 fetch 图片内容(前提是图片 URL 可公开访问) export async function getObject(name) { if (STORAGE_TYPE === 'r2') { const bucket = await getR2Bucket(); @@ -84,6 +128,15 @@ export async function getObject(name) { if (!obj) return null; return Buffer.from(await obj.arrayBuffer()); } + + if (STORAGE_TYPE === 'external') { + // 如果 name 本身就是完整 URL,直接 fetch;否则拼上 IMGBED_BASE + const url = name.startsWith('http') ? name : `${IMGBED_BASE}${name}`; + const res = await fetch(url); + if (!res.ok) return null; + return Buffer.from(await res.arrayBuffer()); + } + if (STORAGE_TYPE === 'local') { const { readFile } = await import('fs/promises'); const path = await import('path'); @@ -93,23 +146,35 @@ export async function getObject(name) { return null; } } + const res = await aws.fetch(objectUrl(name)); if (!res.ok) return null; return Buffer.from(await res.arrayBuffer()); } -// 删除对象(可选) +// 删除对象 +// 注意:CloudFlare-ImgBed 的删除接口需要文件的存储路径和 sha, +// 仅凭 name 无法直接删除。这里对 external 模式做"尽力而为"处理: +// 如果 name 是完整 URL,尝试调用图床删除 API(需要你自己扩展)。 export async function deleteObject(name) { if (STORAGE_TYPE === 'r2') { const bucket = await getR2Bucket(); await bucket.delete(name); return; } + + if (STORAGE_TYPE === 'external') { + // CloudFlare-ImgBed 删除需要 path + sha,这里仅记录警告,不做实际删除 + console.warn(`[external] deleteObject 未实现,跳过删除: ${name}`); + return; + } + if (STORAGE_TYPE === 'local') { const { unlink } = await import('fs/promises'); const path = await import('path'); try { await unlink(path.join(process.cwd(), 'uploads', name)); } catch { /* ignore */ } return; } + await aws.fetch(objectUrl(name), { method: 'DELETE' }); } From f4e105fa2f312a2659289f59349ca3f9e768b1a1 Mon Sep 17 00:00:00 2001 From: bctzl Date: Sat, 19 Sep 2026 20:51:56 +0800 Subject: [PATCH 2/5] Update wrangler.jsonc --- wrangler.jsonc | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index cd89ee3..aa8b30d 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -13,32 +13,17 @@ }, "services": [ { - // Self-reference service binding, the service name must match the worker name - // see https://opennext.js.org/cloudflare/caching "binding": "WORKER_SELF_REFERENCE", "service": "forumlify" } ], "r2_buckets": [ - // Use R2 incremental cache - // See https://opennext.js.org/cloudflare/caching { "binding": "NEXT_INC_CACHE_R2_BUCKET", - // Create the bucket before deploying - // You can change the bucket name if you want - // See https://developers.cloudflare.com/workers/wrangler/commands/#r2-bucket-create "bucket_name": "forumlify-opennext-cache" - }, - // 用户上传图片存储桶(R2 binding,无需 S3 密钥) - // 部署时如使用 CF R2 则自动走 binding;否则回退 S3 兼容 API(S3_* 环境变量) - { - "binding": "FORUMLIFY_BUCKET", - "bucket_name": "forum" } ], "images": { - // Enable image optimization - // see https://opennext.js.org/cloudflare/howtos/image "binding": "IMAGES" } -} \ No newline at end of file +} From 204515aba8e3e8459415ee6af57e6975e54b2117 Mon Sep 17 00:00:00 2001 From: bctzl Date: Sat, 19 Sep 2026 20:54:58 +0800 Subject: [PATCH 3/5] Update wrangler.jsonc --- wrangler.jsonc | 6 ------ 1 file changed, 6 deletions(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index aa8b30d..f635c7f 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -17,12 +17,6 @@ "service": "forumlify" } ], - "r2_buckets": [ - { - "binding": "NEXT_INC_CACHE_R2_BUCKET", - "bucket_name": "forumlify-opennext-cache" - } - ], "images": { "binding": "IMAGES" } From 9bd8213ba6f36865092985a02dc699eca8bf8518 Mon Sep 17 00:00:00 2001 From: bctzl Date: Sat, 19 Sep 2026 20:55:48 +0800 Subject: [PATCH 4/5] Update open-next.config.ts --- open-next.config.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/open-next.config.ts b/open-next.config.ts index 9ef1b15..9656d0d 100644 --- a/open-next.config.ts +++ b/open-next.config.ts @@ -1,7 +1,4 @@ // default open-next.config.ts file created by @opennextjs/cloudflare import { defineCloudflareConfig } from "@opennextjs/cloudflare"; -import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"; -export default defineCloudflareConfig({ - incrementalCache: r2IncrementalCache, -}); +export default defineCloudflareConfig({}); From 8db6e772b514d7fac91384d8d0d8a9857b8357df Mon Sep 17 00:00:00 2001 From: bctzl Date: Sat, 19 Sep 2026 21:02:08 +0800 Subject: [PATCH 5/5] Update wrangler.jsonc --- wrangler.jsonc | 1 - 1 file changed, 1 deletion(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index f635c7f..80efcd6 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,5 +1,4 @@ { - "$schema": "node_modules/wrangler/config-schema.json", "main": ".open-next/worker.js", "name": "forumlify", "compatibility_date": "2026-08-08",