[分享]Backblaze B2 (私密桶) + Cloudflare Snippets 无限量访问B2存储桶

2026-04-11 15:271阅读0评论SEO资源
  • 内容介绍
  • 文章标签
  • 相关推荐
问题描述:

大家应该都知道 B2私密桶+Cloudflare Worker可以实现免费图床/存储等。

并且之前在哪里看到说B2和Cloudflare是流量联盟,之间的流量不计费

但是单独一个账号的Cloudflare Worker本身每天有一定的限量,而Cloudflare Snippets是不限量的。各位大佬这么多域名应该或多或少都有那么一两个域名是激活了Cloudflare Snippets的吧

因此和各位大佬分享一下利用AI生成的一个采用Cloudflare Snipptes对接上B2私密桶,实现不限量的访问B2存储桶的方式 (如果有误,请各位大佬狠狠指出)

B2私密桶+Cloudflare Snippets方案:

  1. 选择开通了Snippets的域名并创建片段

    17745286754801885×685 64.3 KB

  2. 粘贴如下代码(纯AI请各位大佬检查)

    // ==================== 用户配置区域 ==================== // 所有桶映射:键为桶标识,值为桶配置 const BUCKETS = { 'default': { keyID: 'abcdefghijklmno0000000001', // B2_APPLICATION_KEY_ID applicationKey: 'ABCDEFGHIJK+LMNOPQRSTUVWXY+1234', // B2_APPLICATION_KEY endpoint: 's3.ca-east-006.backblazeb2.com', // B2_ENDPOINT bucketName: 'A-B-C-D', // BUCKET_NAME }, 'bucket-2': { keyID: 'abcdefghijklmno0000000002', applicationKey: 'ABCDEFGHIJK+LMNOPQRSTUVWXY+2345', endpoint: 's3.eu-central-003.backblazeb2.com', bucketName: 'B-C-D-E', }, // 按需添加更多桶 }; // 挂载点前缀映射(优化版):键为桶标识,值为该桶的挂载点数组 const MOUNT_POINTS = { 'default': ['/default', '/A-B-C-D'], 'bucket-2': ['/abcdef', '/B-C-D-E'], // 可多个挂载点指向同一个桶 // 'bucket-3': ['/photos', '/photos2'], // 其他桶 // 按需添加 }; // 默认桶键名(必须存在于 BUCKETS) const defBucketKey = 'default'; // 映射文件存储桶键名(必须存在于 BUCKETS) const mapBucketKey = 'default'; // 映射文件名 const mapFile = 'mappings.json'; // 自定义映射文件 URL(留空则自动从映射桶获取) const customMapURL = ''; // 应指向完整 URL,键为桶中实际对象路径(不含挂载点) // 缓存有效期(秒) const cacheTTL = 60; // 是否允许列出桶根目录 const allowList = false; // 触发下载的路径前缀(可多个,如 ['download', 'd']) const downloadPrefixes = ['download', 'd']; // 需过滤的请求头 const skipHeaders = ['x-forwarded-proto', 'x-real-ip', 'accept-encoding', 'cf-connecting-ip', 'cf-ray', 'cf-visitor']; // Range 请求重试次数 const RANGE_RETRY_ATTEMPTS = 3; // ====================================================== // ==================== 派生配置 ==================== const defBucket = BUCKETS[defBucketKey]; const mapBucket = BUCKETS[mapBucketKey]; // ====================================================== // ==================== 工具函数 ==================== let mapCache = null, lastFetch = 0; const extractRegion = ep => ep.match(/^s3\.([\w-]+)\.backblazeb2\.com$/)?.[1] || 'us-east-1'; const createS3Client = cfg => new AwsClient({ accesskeyID: cfg.keyID, secretAccessKey: cfg.applicationKey, sessionToken: void 0, service: 's3', region: extractRegion(cfg.endpoint), cache: void 0, retries: void 0, initRetryMs: void 0, }); async function fetchMapping() { if (customMapURL) { const res = await fetch(customMapURL); return res.ok ? await res.json() : (res.status === 404 ? {} : null); } const client = createS3Client(mapBucket); const url = `https://${mapBucket.bucketName}.${mapBucket.endpoint}/${mapFile}`; const signed = await client.sign(url, { method: 'GET' }); const res = await fetch(signed.url, { headers: signed.headers }); return res.ok ? await res.json() : (res.status === 404 ? {} : null); } async function getMapping(force = false) { const now = Date.now() / 1000; if (force || !mapCache || now - lastFetch > cacheTTL) { const map = await fetchMapping(); if (map !== null) { mapCache = map; lastFetch = now; } } return mapCache || {}; } function route(path, mapping) { // 1. 映射文件精确匹配优先(键应为桶中实际对象路径,如 "/qbobo1.png") const bucketId = mapping[path]; if (bucketId && BUCKETS[bucketId]) { return { bucket: BUCKETS[bucketId], objectKey: path.replace(/^\//, '') }; } // 2. 遍历所有桶的挂载点,找到最长匹配的挂载点 let matchedMount = null; let matchedBucketId = null; for (const [bid, mounts] of Object.entries(MOUNT_POINTS)) { for (const mount of mounts) { if (path === mount || path.startsWith(mount + '/')) { if (!matchedMount || mount.length > matchedMount.length) { matchedMount = mount; matchedBucketId = bid; } } } } if (matchedMount && matchedBucketId) { const bucket = BUCKETS[matchedBucketId]; if (bucket) { const objectKey = path.slice(matchedMount.length).replace(/^\//, ''); return { bucket, objectKey }; } } // 3. 回退到默认桶 return { bucket: defBucket, objectKey: path.replace(/^\//, '') }; } // ====================================================== // ==================== 请求处理核心(支持 PUT 和 Range 重试)==================== async function handleRequest(req) { const url = new URL(req.url); // 处理 OPTIONS 预检(允许 PUT 方法) if (req.method === 'OPTIONS') { return new Response(null, { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, PUT, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Max-Age': '86400', }, }); } // 允许的请求方法:GET, HEAD, PUT(上传) if (!['GET', 'HEAD', 'PUT'].includes(req.method)) { return new Response(null, { status: 405 }); } // 处理下载前缀(仅对 GET/HEAD 有效,PUT 忽略下载前缀) let rawPath = url.pathname; let isDownload = false; if (req.method !== 'PUT') { // 只有 GET/HEAD 才可能触发下载 let matchedPrefix = ''; for (const p of downloadPrefixes) { if (rawPath === '/' + p || rawPath.startsWith('/' + p + '/')) { if (p.length > matchedPrefix.length) { matchedPrefix = p; isDownload = true; } } } if (isDownload) { rawPath = rawPath.slice(matchedPrefix.length + 1); if (!rawPath.startsWith('/')) rawPath = '/' + rawPath; } } const path = decodeURIComponent(rawPath); const mapping = await getMapping(); const { bucket, objectKey } = route(path, mapping); if (!objectKey && !allowList) return new Response(null, { status: 404 }); // 对对象键进行编码,确保 URL 合法(分段编码,保留斜杠) const encodedKey = objectKey.split('/').map(encodeURIComponent).join('/'); const b2Url = `https://${bucket.bucketName}.${bucket.endpoint}/${encodedKey}`; // 过滤请求头 const headers = new Headers(); for (let [k, v] of req.headers) { const lk = k.toLowerCase(); if (!skipHeaders.includes(lk) && !lk.startsWith('cf-')) headers.set(k, v); } const client = createS3Client(bucket); const signed = await client.sign(b2Url, { method: req.method, headers, body: req.method === 'PUT' ? req.body : undefined, }); // 发起请求,对于带有 Range 头的 GET 请求进行重试 let b2Res; if (req.method === 'GET' && headers.has('range')) { let attempts = RANGE_RETRY_ATTEMPTS; do { const controller = new AbortController(); b2Res = await fetch(signed.url, { method: signed.method, headers: signed.headers, signal: controller.signal, }); if (b2Res.headers.has('content-range')) { // 成功获取 content-range,跳出循环 break; } else if (b2Res.ok) { attempts--; if (attempts > 0) { // 中止当前请求,准备重试 controller.abort(); } } else { // 非成功响应,直接跳出 break; } } while (attempts > 0); } else { // 非 Range 请求或非 GET 请求,直接请求 b2Res = await fetch(signed.url, { method: signed.method, headers: signed.headers, body: signed.body, }); } const resHeaders = new Headers(b2Res.headers); ['x-bz-content-sha1', 'x-bz-info-src_last_modified_millis', 'x-bz-upload-timestamp', 'x-bz-file-id', 'x-bz-file-name'] .forEach(h => resHeaders.delete(h)); resHeaders.set('Access-Control-Allow-Origin', '*'); // 仅对 GET/HEAD 添加下载头 if (isDownload) { const fileName = objectKey.split('/').pop() || 'download'; resHeaders.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); } return new Response(b2Res.body, { status: b2Res.status, headers: resHeaders }); } export default { fetch: handleRequest }; // ==================== aws4fetch 库(稳定,可省略)==================== const encoder = new TextEncoder(); const HOST_SERVICES = { appstream2: "appstream", cloudhsmv2: "cloudhsm", email: "ses", marketplace: "aws-marketplace", mobile: "AWSMobileHubService", pinpoint: "mobiletargeting", queue: "sqs", "git-codecommit": "codecommit", "mturk-requester-sandbox": "mturk-requester", "personalize-runtime": "personalize" }; const UNSIGNABLE_HEADERS = new Set(["authorization", "content-type", "content-length", "user-agent", "presigned-expires", "expect", "x-amzn-trace-id", "range", "connection"]); class AwsClient { constructor({ accesskeyID, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }) { this.accesskeyID = accesskeyID; this.secretAccessKey = secretAccessKey; this.sessionToken = sessionToken; this.service = service; this.region = region; this.cache = cache || new Map(); this.retries = retries != null ? retries : 10; this.initRetryMs = initRetryMs || 50; } async sign(input, init) { if (input instanceof Request) { const { method, url, headers, body } = input; init = Object.assign({ method, url, headers }, init); if (!init.body && headers.has('Content-Type')) init.body = body && headers.has('X-Amz-Content-Sha256') ? body : await input.clone().arrayBuffer(); input = url; } const signer = new AwsV4Signer(Object.assign({ url: input }, init, this, init?.aws)); const signed = Object.assign({}, init, await signer.sign()); delete signed.aws; try { return new Request(signed.url.toString(), signed); } catch (e) { if (e instanceof TypeError) return new Request(signed.url.toString(), Object.assign({ duplex: "half" }, signed)); throw e; } } async fetch(input, init) { for (let i = 0; i <= this.retries; i++) { const fetched = fetch(await this.sign(input, init)); if (i === this.retries) return fetched; const res = await fetched; if (res.status < 500 && res.status !== 429) return res; await new Promise(resolve => setTimeout(resolve, Math.random() * this.initRetryMs * Math.pow(2, i))); } } } class AwsV4Signer { constructor({ method, url, headers, body, accesskeyID, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }) { this.method = method || (body ? "POST" : "GET"); this.url = new URL(url); this.headers = new Headers(headers || {}); this.body = body; this.accesskeyID = accesskeyID; this.secretAccessKey = secretAccessKey; this.sessionToken = sessionToken; let guessedService, guessedRegion; if (!service || !region) [guessedService, guessedRegion] = guessServiceRegion(this.url); this.service = service || guessedService || ""; this.region = region || guessedRegion || "us-east-1"; this.cache = cache || new Map(); this.datetime = datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, ""); this.signQuery = signQuery; this.appendSessionToken = appendSessionToken || this.service === "iotdevicegateway"; this.headers.delete("Host"); if (this.service === "s3" && !this.signQuery && !this.headers.has("X-Amz-Content-Sha256")) this.headers.set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD"); const params = this.signQuery ? this.url.searchParams : this.headers; params.set("X-Amz-Date", this.datetime); if (this.sessionToken && !this.appendSessionToken) params.set("X-Amz-Security-Token", this.sessionToken); this.signableHeaders = ["host", ...this.headers.keys()].filter(h => allHeaders || !UNSIGNABLE_HEADERS.has(h)).sort(); this.signedHeaders = this.signableHeaders.join(";"); this.canonicalHeaders = this.signableHeaders.map(h => h + ":" + (h === "host" ? this.url.host : (this.headers.get(h) || "").replace(/\s+/g, " "))).join("\n"); this.credentialString = [this.datetime.slice(0, 8), this.region, this.service, "aws4_request"].join("/"); if (this.signQuery) { if (this.service === "s3" && !params.has("X-Amz-Expires")) params.set("X-Amz-Expires", "86400"); params.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); params.set("X-Amz-Credential", this.accesskeyID + "/" + this.credentialString); params.set("X-Amz-SignedHeaders", this.signedHeaders); } if (this.service === "s3") { try { this.encodedPath = decodeURIComponent(this.url.pathname.replace(/\+/g, " ")); } catch { this.encodedPath = this.url.pathname; } } else this.encodedPath = this.url.pathname.replace(/\/+/g, "/"); if (!singleEncode) this.encodedPath = encodeURIComponent(this.encodedPath).replace(/%2F/g, "/"); this.encodedPath = encodeRfc3986(this.encodedPath); const seenKeys = new Set(); this.encodedSearch = [...this.url.searchParams].filter(([k]) => !(this.service === "s3" && (!k || seenKeys.has(k))) && (k && (this.service !== "s3" || (seenKeys.add(k), true)))).map(p => p.map(p => encodeRfc3986(encodeURIComponent(p)))).sort(([k1, v1], [k2, v2]) => k1 < k2 ? -1 : k1 > k2 ? 1 : v1 < v2 ? -1 : v1 > v2 ? 1 : 0).map(p => p.join("=")).join("&"); } async sign() { if (this.signQuery) { this.url.searchParams.set("X-Amz-Signature", await this.signature()); if (this.sessionToken && this.appendSessionToken) this.url.searchParams.set("X-Amz-Security-Token", this.sessionToken); } else this.headers.set("Authorization", await this.authHeader()); return { method: this.method, url: this.url, headers: this.headers, body: this.body }; } async authHeader() { return `AWS4-HMAC-SHA256 Credential=${this.accesskeyID}/${this.credentialString}, SignedHeaders=${this.signedHeaders}, Signature=${await this.signature()}`; } async signature() { const date = this.datetime.slice(0, 8); const cacheKey = [this.secretAccessKey, date, this.region, this.service].join(); let k = this.cache.get(cacheKey); if (!k) { const kDate = await hmac("AWS4" + this.secretAccessKey, date); const kRegion = await hmac(kDate, this.region); const kService = await hmac(kRegion, this.service); k = await hmac(kService, "aws4_request"); this.cache.set(cacheKey, k); } return buf2hex(await hmac(k, await this.stringToSign())); } async stringToSign() { return ["AWS4-HMAC-SHA256", this.datetime, this.credentialString, buf2hex(await hash(await this.canonicalString()))].join("\n"); } async canonicalString() { return [this.method.toUpperCase(), this.encodedPath, this.encodedSearch, this.canonicalHeaders + "\n", this.signedHeaders, await this.hexBodyHash()].join("\n"); } async hexBodyHash() { let h = this.headers.get("X-Amz-Content-Sha256") || (this.service === "s3" && this.signQuery ? "UNSIGNED-PAYLOAD" : null); if (h == null) { if (this.body && typeof this.body !== "string" && !("byteLength" in this.body)) throw new Error("body must be string or ArrayBuffer"); h = buf2hex(await hash(this.body || "")); } return h; } } async function hmac(key, string) { const k = await crypto.subtle.importKey("raw", typeof key === "string" ? encoder.encode(key) : key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); return crypto.subtle.sign("HMAC", k, encoder.encode(string)); } async function hash(content) { return crypto.subtle.digest("SHA-256", typeof content === "string" ? encoder.encode(content) : content); } function buf2hex(b) { return [...new Uint8Array(b)].map(x => x.toString(16).padStart(2, '0')).join(''); } function encodeRfc3986(s) { return s.replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); } function guessServiceRegion(url) { const { hostname } = url; if (hostname.endsWith(".backblazeb2.com")) { const m = hostname.match(/^s3\.([\w-]+)\.backblazeb2\.com$/); if (m) return ["s3", m[1]]; } return ["", ""]; } // ==================== aws4fetch 库结束 ====================

  3. 点击右侧片段规则进行设置并保存:主机名-等于-自己的域名

    1774536185250765×284 10.1 KB

    1774536550096785×595 24.8 KB

  4. 该子域名需要去DNS里指向192.0.2.1并开启小黄云!

配置

  1. 配置桶认证信息

    // ==================== 用户配置区域 ==================== // 所有桶映射:键为桶标识,值为桶配置 const BUCKETS = { 'default': { keyID: 'abcdefghijklmno0000000001', // B2_APPLICATION_KEY_ID applicationKey: 'ABCDEFGHIJK+LMNOPQRSTUVWXY+1234', // B2_APPLICATION_KEY endpoint: 's3.ca-east-006.backblazeb2.com', // B2_ENDPOINT bucketName: 'A-B-C-D', // BUCKET_NAME },

  2. 挂载点配置
    用于识别openlist等上添加对象存储的挂载路径

    // 挂载点前缀映射(优化版):键为桶标识,值为该桶的挂载点数组 const MOUNT_POINTS = { 'default': ['/default', '/A-B-C-D'], 'bucket-2': ['/abcdef', '/B-C-D-E'], // 可多个挂载点指向同一个桶 // 'bucket-3': ['/photos', '/photos2'], // 其他桶 // 按需添加 };

  3. 下载触发配置
    可以自行修改前缀用于访问链接触发下载置

    // 触发下载的路径前缀(可多个,如 ['download', 'd']) const downloadPrefixes = ['download', 'd'];

    同时也作用于openlist中开启网页代理
    17745509069451071×225 5.85 KB

  4. 映射文件配用于配置
    用于多存储库时的文件路径识别,路径不存在时将从尝试从默认桶(default)中获取文件,可自定义映射文件名和URL

    // 映射文件名 const mapFile = 'mappings.json'; // 自定义映射文件 URL(留空则自动从映射桶获取) const customMapURL = ''; // 应指向完整 URL,键为桶中实际对象路径(不含挂载点)

    mappings.json格式如下

    { "/完整文件路径/文件名": "对应存储桶", "/mappings.json": "default", "/kuwo/4811846-flac24bit.json": "default", "/kuwo/audio/4811846-flac24bit.mp3": "default", "/test/fried-egg.ico": "bucket-2", "/test2.png": "bucket-2" }

使用方式

  1. 文件访问预览
  • 路径访问存储的文件,
    如:https://example.com/文件路径(不含桶名)

  • 携带挂载点前缀映射 访问,
    如:https://example.com/default/文件路径(不含桶名)
    https://example.com/A-B-C-D/文件路径(不含桶名)

  1. 下载触发
    在URL访问中路径前添加下载前缀download 进行访问,
    如:https://example.com/download/文件路径(不含桶名)
    https://example.com/download/A-B-C-D/文件路径(不含桶名)(同样支持挂载点)

有一个麻烦的点就是对于多个桶需要更新映射文件,这个应该可以通过其他方式解决(比如再创建一个Cloudflare Worker 定时扫一下桶这个用量应该比较低)
映射文件预览:https://snib2.qbobo.eu.org/mappings.json
音乐文件预览:https://snib2.qbobo.eu.org/kuwo/audio/4811846-flac24bit.mp3

网友解答:
--【壹】--:

学习了 只要有域名开通了Snippets就可以了吗(按教程操作的话)


--【贰】--:

应该是吧,之前在哪里看到说B2和Cloudflare是流量联盟,之间的流量不计费


--【叁】--:

感谢分享


--【肆】--:

收藏一波,谢谢分享


--【伍】--: MysteryQ:

桶+Cloudflare Snippets方案:

  1. 选择开通了Snippets的域

哦? 无限网盘?


--【陆】--:

啊,有一点我忘说了,你用的这个子域名需要去DNS里指向192.0.2.1并打开小黄云。佬试试是不是这个原因


--【柒】--:

免费版没有Snippets怎么解?…


--【捌】--:

感谢大佬 !


--【玖】--:

B2 是存储挺便宜的,就是流出贵


--【拾】--:

的确是这个原因,谢谢佬友解答


--【拾壹】--:

那只能用限量的worker了。我也是免费版,但是有几个域名给了snippets


--【拾贰】--:

插眼学习


--【拾叁】--:

感谢分享


--【拾肆】--:

佬,请教一下,为什么我在cloudflare里面能请求成功,但在外面浏览器直接请求却是This site can’t be reached

image964×1028 111 KB


--【拾伍】--:

是的,还需要去Backblaze注册创建存储桶,我这里直接跳过了

标签:配置优化
问题描述:

大家应该都知道 B2私密桶+Cloudflare Worker可以实现免费图床/存储等。

并且之前在哪里看到说B2和Cloudflare是流量联盟,之间的流量不计费

但是单独一个账号的Cloudflare Worker本身每天有一定的限量,而Cloudflare Snippets是不限量的。各位大佬这么多域名应该或多或少都有那么一两个域名是激活了Cloudflare Snippets的吧

因此和各位大佬分享一下利用AI生成的一个采用Cloudflare Snipptes对接上B2私密桶,实现不限量的访问B2存储桶的方式 (如果有误,请各位大佬狠狠指出)

B2私密桶+Cloudflare Snippets方案:

  1. 选择开通了Snippets的域名并创建片段

    17745286754801885×685 64.3 KB

  2. 粘贴如下代码(纯AI请各位大佬检查)

    // ==================== 用户配置区域 ==================== // 所有桶映射:键为桶标识,值为桶配置 const BUCKETS = { 'default': { keyID: 'abcdefghijklmno0000000001', // B2_APPLICATION_KEY_ID applicationKey: 'ABCDEFGHIJK+LMNOPQRSTUVWXY+1234', // B2_APPLICATION_KEY endpoint: 's3.ca-east-006.backblazeb2.com', // B2_ENDPOINT bucketName: 'A-B-C-D', // BUCKET_NAME }, 'bucket-2': { keyID: 'abcdefghijklmno0000000002', applicationKey: 'ABCDEFGHIJK+LMNOPQRSTUVWXY+2345', endpoint: 's3.eu-central-003.backblazeb2.com', bucketName: 'B-C-D-E', }, // 按需添加更多桶 }; // 挂载点前缀映射(优化版):键为桶标识,值为该桶的挂载点数组 const MOUNT_POINTS = { 'default': ['/default', '/A-B-C-D'], 'bucket-2': ['/abcdef', '/B-C-D-E'], // 可多个挂载点指向同一个桶 // 'bucket-3': ['/photos', '/photos2'], // 其他桶 // 按需添加 }; // 默认桶键名(必须存在于 BUCKETS) const defBucketKey = 'default'; // 映射文件存储桶键名(必须存在于 BUCKETS) const mapBucketKey = 'default'; // 映射文件名 const mapFile = 'mappings.json'; // 自定义映射文件 URL(留空则自动从映射桶获取) const customMapURL = ''; // 应指向完整 URL,键为桶中实际对象路径(不含挂载点) // 缓存有效期(秒) const cacheTTL = 60; // 是否允许列出桶根目录 const allowList = false; // 触发下载的路径前缀(可多个,如 ['download', 'd']) const downloadPrefixes = ['download', 'd']; // 需过滤的请求头 const skipHeaders = ['x-forwarded-proto', 'x-real-ip', 'accept-encoding', 'cf-connecting-ip', 'cf-ray', 'cf-visitor']; // Range 请求重试次数 const RANGE_RETRY_ATTEMPTS = 3; // ====================================================== // ==================== 派生配置 ==================== const defBucket = BUCKETS[defBucketKey]; const mapBucket = BUCKETS[mapBucketKey]; // ====================================================== // ==================== 工具函数 ==================== let mapCache = null, lastFetch = 0; const extractRegion = ep => ep.match(/^s3\.([\w-]+)\.backblazeb2\.com$/)?.[1] || 'us-east-1'; const createS3Client = cfg => new AwsClient({ accesskeyID: cfg.keyID, secretAccessKey: cfg.applicationKey, sessionToken: void 0, service: 's3', region: extractRegion(cfg.endpoint), cache: void 0, retries: void 0, initRetryMs: void 0, }); async function fetchMapping() { if (customMapURL) { const res = await fetch(customMapURL); return res.ok ? await res.json() : (res.status === 404 ? {} : null); } const client = createS3Client(mapBucket); const url = `https://${mapBucket.bucketName}.${mapBucket.endpoint}/${mapFile}`; const signed = await client.sign(url, { method: 'GET' }); const res = await fetch(signed.url, { headers: signed.headers }); return res.ok ? await res.json() : (res.status === 404 ? {} : null); } async function getMapping(force = false) { const now = Date.now() / 1000; if (force || !mapCache || now - lastFetch > cacheTTL) { const map = await fetchMapping(); if (map !== null) { mapCache = map; lastFetch = now; } } return mapCache || {}; } function route(path, mapping) { // 1. 映射文件精确匹配优先(键应为桶中实际对象路径,如 "/qbobo1.png") const bucketId = mapping[path]; if (bucketId && BUCKETS[bucketId]) { return { bucket: BUCKETS[bucketId], objectKey: path.replace(/^\//, '') }; } // 2. 遍历所有桶的挂载点,找到最长匹配的挂载点 let matchedMount = null; let matchedBucketId = null; for (const [bid, mounts] of Object.entries(MOUNT_POINTS)) { for (const mount of mounts) { if (path === mount || path.startsWith(mount + '/')) { if (!matchedMount || mount.length > matchedMount.length) { matchedMount = mount; matchedBucketId = bid; } } } } if (matchedMount && matchedBucketId) { const bucket = BUCKETS[matchedBucketId]; if (bucket) { const objectKey = path.slice(matchedMount.length).replace(/^\//, ''); return { bucket, objectKey }; } } // 3. 回退到默认桶 return { bucket: defBucket, objectKey: path.replace(/^\//, '') }; } // ====================================================== // ==================== 请求处理核心(支持 PUT 和 Range 重试)==================== async function handleRequest(req) { const url = new URL(req.url); // 处理 OPTIONS 预检(允许 PUT 方法) if (req.method === 'OPTIONS') { return new Response(null, { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, PUT, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Max-Age': '86400', }, }); } // 允许的请求方法:GET, HEAD, PUT(上传) if (!['GET', 'HEAD', 'PUT'].includes(req.method)) { return new Response(null, { status: 405 }); } // 处理下载前缀(仅对 GET/HEAD 有效,PUT 忽略下载前缀) let rawPath = url.pathname; let isDownload = false; if (req.method !== 'PUT') { // 只有 GET/HEAD 才可能触发下载 let matchedPrefix = ''; for (const p of downloadPrefixes) { if (rawPath === '/' + p || rawPath.startsWith('/' + p + '/')) { if (p.length > matchedPrefix.length) { matchedPrefix = p; isDownload = true; } } } if (isDownload) { rawPath = rawPath.slice(matchedPrefix.length + 1); if (!rawPath.startsWith('/')) rawPath = '/' + rawPath; } } const path = decodeURIComponent(rawPath); const mapping = await getMapping(); const { bucket, objectKey } = route(path, mapping); if (!objectKey && !allowList) return new Response(null, { status: 404 }); // 对对象键进行编码,确保 URL 合法(分段编码,保留斜杠) const encodedKey = objectKey.split('/').map(encodeURIComponent).join('/'); const b2Url = `https://${bucket.bucketName}.${bucket.endpoint}/${encodedKey}`; // 过滤请求头 const headers = new Headers(); for (let [k, v] of req.headers) { const lk = k.toLowerCase(); if (!skipHeaders.includes(lk) && !lk.startsWith('cf-')) headers.set(k, v); } const client = createS3Client(bucket); const signed = await client.sign(b2Url, { method: req.method, headers, body: req.method === 'PUT' ? req.body : undefined, }); // 发起请求,对于带有 Range 头的 GET 请求进行重试 let b2Res; if (req.method === 'GET' && headers.has('range')) { let attempts = RANGE_RETRY_ATTEMPTS; do { const controller = new AbortController(); b2Res = await fetch(signed.url, { method: signed.method, headers: signed.headers, signal: controller.signal, }); if (b2Res.headers.has('content-range')) { // 成功获取 content-range,跳出循环 break; } else if (b2Res.ok) { attempts--; if (attempts > 0) { // 中止当前请求,准备重试 controller.abort(); } } else { // 非成功响应,直接跳出 break; } } while (attempts > 0); } else { // 非 Range 请求或非 GET 请求,直接请求 b2Res = await fetch(signed.url, { method: signed.method, headers: signed.headers, body: signed.body, }); } const resHeaders = new Headers(b2Res.headers); ['x-bz-content-sha1', 'x-bz-info-src_last_modified_millis', 'x-bz-upload-timestamp', 'x-bz-file-id', 'x-bz-file-name'] .forEach(h => resHeaders.delete(h)); resHeaders.set('Access-Control-Allow-Origin', '*'); // 仅对 GET/HEAD 添加下载头 if (isDownload) { const fileName = objectKey.split('/').pop() || 'download'; resHeaders.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); } return new Response(b2Res.body, { status: b2Res.status, headers: resHeaders }); } export default { fetch: handleRequest }; // ==================== aws4fetch 库(稳定,可省略)==================== const encoder = new TextEncoder(); const HOST_SERVICES = { appstream2: "appstream", cloudhsmv2: "cloudhsm", email: "ses", marketplace: "aws-marketplace", mobile: "AWSMobileHubService", pinpoint: "mobiletargeting", queue: "sqs", "git-codecommit": "codecommit", "mturk-requester-sandbox": "mturk-requester", "personalize-runtime": "personalize" }; const UNSIGNABLE_HEADERS = new Set(["authorization", "content-type", "content-length", "user-agent", "presigned-expires", "expect", "x-amzn-trace-id", "range", "connection"]); class AwsClient { constructor({ accesskeyID, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }) { this.accesskeyID = accesskeyID; this.secretAccessKey = secretAccessKey; this.sessionToken = sessionToken; this.service = service; this.region = region; this.cache = cache || new Map(); this.retries = retries != null ? retries : 10; this.initRetryMs = initRetryMs || 50; } async sign(input, init) { if (input instanceof Request) { const { method, url, headers, body } = input; init = Object.assign({ method, url, headers }, init); if (!init.body && headers.has('Content-Type')) init.body = body && headers.has('X-Amz-Content-Sha256') ? body : await input.clone().arrayBuffer(); input = url; } const signer = new AwsV4Signer(Object.assign({ url: input }, init, this, init?.aws)); const signed = Object.assign({}, init, await signer.sign()); delete signed.aws; try { return new Request(signed.url.toString(), signed); } catch (e) { if (e instanceof TypeError) return new Request(signed.url.toString(), Object.assign({ duplex: "half" }, signed)); throw e; } } async fetch(input, init) { for (let i = 0; i <= this.retries; i++) { const fetched = fetch(await this.sign(input, init)); if (i === this.retries) return fetched; const res = await fetched; if (res.status < 500 && res.status !== 429) return res; await new Promise(resolve => setTimeout(resolve, Math.random() * this.initRetryMs * Math.pow(2, i))); } } } class AwsV4Signer { constructor({ method, url, headers, body, accesskeyID, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }) { this.method = method || (body ? "POST" : "GET"); this.url = new URL(url); this.headers = new Headers(headers || {}); this.body = body; this.accesskeyID = accesskeyID; this.secretAccessKey = secretAccessKey; this.sessionToken = sessionToken; let guessedService, guessedRegion; if (!service || !region) [guessedService, guessedRegion] = guessServiceRegion(this.url); this.service = service || guessedService || ""; this.region = region || guessedRegion || "us-east-1"; this.cache = cache || new Map(); this.datetime = datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, ""); this.signQuery = signQuery; this.appendSessionToken = appendSessionToken || this.service === "iotdevicegateway"; this.headers.delete("Host"); if (this.service === "s3" && !this.signQuery && !this.headers.has("X-Amz-Content-Sha256")) this.headers.set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD"); const params = this.signQuery ? this.url.searchParams : this.headers; params.set("X-Amz-Date", this.datetime); if (this.sessionToken && !this.appendSessionToken) params.set("X-Amz-Security-Token", this.sessionToken); this.signableHeaders = ["host", ...this.headers.keys()].filter(h => allHeaders || !UNSIGNABLE_HEADERS.has(h)).sort(); this.signedHeaders = this.signableHeaders.join(";"); this.canonicalHeaders = this.signableHeaders.map(h => h + ":" + (h === "host" ? this.url.host : (this.headers.get(h) || "").replace(/\s+/g, " "))).join("\n"); this.credentialString = [this.datetime.slice(0, 8), this.region, this.service, "aws4_request"].join("/"); if (this.signQuery) { if (this.service === "s3" && !params.has("X-Amz-Expires")) params.set("X-Amz-Expires", "86400"); params.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); params.set("X-Amz-Credential", this.accesskeyID + "/" + this.credentialString); params.set("X-Amz-SignedHeaders", this.signedHeaders); } if (this.service === "s3") { try { this.encodedPath = decodeURIComponent(this.url.pathname.replace(/\+/g, " ")); } catch { this.encodedPath = this.url.pathname; } } else this.encodedPath = this.url.pathname.replace(/\/+/g, "/"); if (!singleEncode) this.encodedPath = encodeURIComponent(this.encodedPath).replace(/%2F/g, "/"); this.encodedPath = encodeRfc3986(this.encodedPath); const seenKeys = new Set(); this.encodedSearch = [...this.url.searchParams].filter(([k]) => !(this.service === "s3" && (!k || seenKeys.has(k))) && (k && (this.service !== "s3" || (seenKeys.add(k), true)))).map(p => p.map(p => encodeRfc3986(encodeURIComponent(p)))).sort(([k1, v1], [k2, v2]) => k1 < k2 ? -1 : k1 > k2 ? 1 : v1 < v2 ? -1 : v1 > v2 ? 1 : 0).map(p => p.join("=")).join("&"); } async sign() { if (this.signQuery) { this.url.searchParams.set("X-Amz-Signature", await this.signature()); if (this.sessionToken && this.appendSessionToken) this.url.searchParams.set("X-Amz-Security-Token", this.sessionToken); } else this.headers.set("Authorization", await this.authHeader()); return { method: this.method, url: this.url, headers: this.headers, body: this.body }; } async authHeader() { return `AWS4-HMAC-SHA256 Credential=${this.accesskeyID}/${this.credentialString}, SignedHeaders=${this.signedHeaders}, Signature=${await this.signature()}`; } async signature() { const date = this.datetime.slice(0, 8); const cacheKey = [this.secretAccessKey, date, this.region, this.service].join(); let k = this.cache.get(cacheKey); if (!k) { const kDate = await hmac("AWS4" + this.secretAccessKey, date); const kRegion = await hmac(kDate, this.region); const kService = await hmac(kRegion, this.service); k = await hmac(kService, "aws4_request"); this.cache.set(cacheKey, k); } return buf2hex(await hmac(k, await this.stringToSign())); } async stringToSign() { return ["AWS4-HMAC-SHA256", this.datetime, this.credentialString, buf2hex(await hash(await this.canonicalString()))].join("\n"); } async canonicalString() { return [this.method.toUpperCase(), this.encodedPath, this.encodedSearch, this.canonicalHeaders + "\n", this.signedHeaders, await this.hexBodyHash()].join("\n"); } async hexBodyHash() { let h = this.headers.get("X-Amz-Content-Sha256") || (this.service === "s3" && this.signQuery ? "UNSIGNED-PAYLOAD" : null); if (h == null) { if (this.body && typeof this.body !== "string" && !("byteLength" in this.body)) throw new Error("body must be string or ArrayBuffer"); h = buf2hex(await hash(this.body || "")); } return h; } } async function hmac(key, string) { const k = await crypto.subtle.importKey("raw", typeof key === "string" ? encoder.encode(key) : key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); return crypto.subtle.sign("HMAC", k, encoder.encode(string)); } async function hash(content) { return crypto.subtle.digest("SHA-256", typeof content === "string" ? encoder.encode(content) : content); } function buf2hex(b) { return [...new Uint8Array(b)].map(x => x.toString(16).padStart(2, '0')).join(''); } function encodeRfc3986(s) { return s.replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()); } function guessServiceRegion(url) { const { hostname } = url; if (hostname.endsWith(".backblazeb2.com")) { const m = hostname.match(/^s3\.([\w-]+)\.backblazeb2\.com$/); if (m) return ["s3", m[1]]; } return ["", ""]; } // ==================== aws4fetch 库结束 ====================

  3. 点击右侧片段规则进行设置并保存:主机名-等于-自己的域名

    1774536185250765×284 10.1 KB

    1774536550096785×595 24.8 KB

  4. 该子域名需要去DNS里指向192.0.2.1并开启小黄云!

配置

  1. 配置桶认证信息

    // ==================== 用户配置区域 ==================== // 所有桶映射:键为桶标识,值为桶配置 const BUCKETS = { 'default': { keyID: 'abcdefghijklmno0000000001', // B2_APPLICATION_KEY_ID applicationKey: 'ABCDEFGHIJK+LMNOPQRSTUVWXY+1234', // B2_APPLICATION_KEY endpoint: 's3.ca-east-006.backblazeb2.com', // B2_ENDPOINT bucketName: 'A-B-C-D', // BUCKET_NAME },

  2. 挂载点配置
    用于识别openlist等上添加对象存储的挂载路径

    // 挂载点前缀映射(优化版):键为桶标识,值为该桶的挂载点数组 const MOUNT_POINTS = { 'default': ['/default', '/A-B-C-D'], 'bucket-2': ['/abcdef', '/B-C-D-E'], // 可多个挂载点指向同一个桶 // 'bucket-3': ['/photos', '/photos2'], // 其他桶 // 按需添加 };

  3. 下载触发配置
    可以自行修改前缀用于访问链接触发下载置

    // 触发下载的路径前缀(可多个,如 ['download', 'd']) const downloadPrefixes = ['download', 'd'];

    同时也作用于openlist中开启网页代理
    17745509069451071×225 5.85 KB

  4. 映射文件配用于配置
    用于多存储库时的文件路径识别,路径不存在时将从尝试从默认桶(default)中获取文件,可自定义映射文件名和URL

    // 映射文件名 const mapFile = 'mappings.json'; // 自定义映射文件 URL(留空则自动从映射桶获取) const customMapURL = ''; // 应指向完整 URL,键为桶中实际对象路径(不含挂载点)

    mappings.json格式如下

    { "/完整文件路径/文件名": "对应存储桶", "/mappings.json": "default", "/kuwo/4811846-flac24bit.json": "default", "/kuwo/audio/4811846-flac24bit.mp3": "default", "/test/fried-egg.ico": "bucket-2", "/test2.png": "bucket-2" }

使用方式

  1. 文件访问预览
  • 路径访问存储的文件,
    如:https://example.com/文件路径(不含桶名)

  • 携带挂载点前缀映射 访问,
    如:https://example.com/default/文件路径(不含桶名)
    https://example.com/A-B-C-D/文件路径(不含桶名)

  1. 下载触发
    在URL访问中路径前添加下载前缀download 进行访问,
    如:https://example.com/download/文件路径(不含桶名)
    https://example.com/download/A-B-C-D/文件路径(不含桶名)(同样支持挂载点)

有一个麻烦的点就是对于多个桶需要更新映射文件,这个应该可以通过其他方式解决(比如再创建一个Cloudflare Worker 定时扫一下桶这个用量应该比较低)
映射文件预览:https://snib2.qbobo.eu.org/mappings.json
音乐文件预览:https://snib2.qbobo.eu.org/kuwo/audio/4811846-flac24bit.mp3

网友解答:
--【壹】--:

学习了 只要有域名开通了Snippets就可以了吗(按教程操作的话)


--【贰】--:

应该是吧,之前在哪里看到说B2和Cloudflare是流量联盟,之间的流量不计费


--【叁】--:

感谢分享


--【肆】--:

收藏一波,谢谢分享


--【伍】--: MysteryQ:

桶+Cloudflare Snippets方案:

  1. 选择开通了Snippets的域

哦? 无限网盘?


--【陆】--:

啊,有一点我忘说了,你用的这个子域名需要去DNS里指向192.0.2.1并打开小黄云。佬试试是不是这个原因


--【柒】--:

免费版没有Snippets怎么解?…


--【捌】--:

感谢大佬 !


--【玖】--:

B2 是存储挺便宜的,就是流出贵


--【拾】--:

的确是这个原因,谢谢佬友解答


--【拾壹】--:

那只能用限量的worker了。我也是免费版,但是有几个域名给了snippets


--【拾贰】--:

插眼学习


--【拾叁】--:

感谢分享


--【拾肆】--:

佬,请教一下,为什么我在cloudflare里面能请求成功,但在外面浏览器直接请求却是This site can’t be reached

image964×1028 111 KB


--【拾伍】--:

是的,还需要去Backblaze注册创建存储桶,我这里直接跳过了

标签:配置优化