图床:Backblaze B2 (私密桶) + Cloudflare Workers + PicGo

使用Backblaze B2私密桶、Cloudflare Workers和PicGo搭建安全可靠的个人图床方案

前言

图床是博客写作中必不可少的工具,一个好的图床不仅要保证图片访问速度快,而且要安全可靠。本文将介绍如何使用Backblaze B2的私密存储桶、Cloudflare Workers和PicGo搭建一个既安全又高效的个人图床方案。

方案优势

  1. 安全性高:使用Backblaze B2私密桶存储,避免直接暴露图片URL
  2. 访问速度快:通过Cloudflare CDN加速访问
  3. 成本低廉:Backblaze B2存储费用极低,Cloudflare Workers有免费额度
  4. 使用便捷:配合PicGo实现一键上传

步骤一:注册并配置Backblaze B2

1. 注册Backblaze账号

首先访问Backblaze官网注册一个账号。

2. 创建私有存储桶

  1. 登录Backblaze B2控制台
  2. 点击”创建存储桶”按钮
  3. 输入存储桶名称(自定义,全局唯一)
  4. 选择”私有”作为存储桶类型
  5. 其他选项保持默认
  6. 点击”创建”按钮完成创建

重要提示:创建完成后,请记得复制您的存储桶名称及Endpoint,后续配置会用到

3. 创建应用密钥

  1. 在B2控制台中点击”Application Keys”
  2. 点击”Add a New Application Key”
  3. 输入密钥名称(可随意命名)
  4. 其他选项保持默认
  5. 点击创建按钮

重要提示:创建完成后,请立即复制显示的KeyID和applicationKey,这些信息只会显示一次!

步骤二:配置Cloudflare Workers

1. 创建Workers

  1. 登录Cloudflare控制台
  2. 进入”Workers”页面
  3. 点击”创建”按钮
    创建workers
  4. 选择”从Hello World!开始”
    选择从Hello World!开始
  5. 给Workers命名
  6. 点击”部署”按钮

2. 编辑代码

  1. 点击”编辑代码”按钮
    编辑代码

  2. 将下面的代码粘贴到编辑器中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    // node_modules/aws4fetch/dist/aws4fetch.esm.mjs
    var encoder = new TextEncoder();
    var 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"
    };
    var UNSIGNABLE_HEADERS = /* @__PURE__ */ new Set([
    "authorization",
    "content-type",
    "content-length",
    "user-agent",
    "presigned-expires",
    "expect",
    "x-amzn-trace-id",
    "range",
    "connection"
    ]);
    var AwsClient = class {
    constructor({ accesskeyID, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }) {
    if (accesskeyID == null)
    throw new TypeError("accesskeyID is a required option");
    if (secretAccessKey == null)
    throw new TypeError("secretAccessKey is a required option");
    this.accesskeyID = accesskeyID;
    this.secretAccessKey = secretAccessKey;
    this.sessionToken = sessionToken;
    this.service = service;
    this.region = region;
    this.cache = cache || /* @__PURE__ */ 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 == null && headers.has("Content-Type")) {
    init.body = body != null && headers.has("X-Amz-Content-Sha256") ? body : await input.clone().arrayBuffer();
    }
    input = url;
    }
    const signer = new AwsV4Signer(Object.assign({ url: input }, init, this, init && 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)));
    }
    throw new Error("An unknown error occurred, ensure retries is not negative");
    }
    };
    var AwsV4Signer = class {
    constructor({ method, url, headers, body, accesskeyID, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }) {
    if (url == null)
    throw new TypeError("url is a required option");
    if (accesskeyID == null)
    throw new TypeError("accesskeyID is a required option");
    if (secretAccessKey == null)
    throw new TypeError("secretAccessKey is a required option");
    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.headers);
    }
    this.service = service || guessedService || "";
    this.region = region || guessedRegion || "us-east-1";
    this.cache = cache || /* @__PURE__ */ new Map();
    this.datetime = datetime || (/* @__PURE__ */ 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((header) => allHeaders || !UNSIGNABLE_HEADERS.has(header)).sort();
    this.signedHeaders = this.signableHeaders.join(";");
    this.canonicalHeaders = this.signableHeaders.map((header) => header + ":" + (header === "host" ? this.url.host : (this.headers.get(header) || "").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 (e) {
    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 = /* @__PURE__ */ new Set();
    this.encodedSearch = [...this.url.searchParams].filter(([k]) => {
    if (!k)
    return false;
    if (this.service === "s3") {
    if (seenKeys.has(k))
    return false;
    seenKeys.add(k);
    }
    return true;
    }).map((pair) => pair.map((p) => encodeRfc3986(encodeURIComponent(p)))).sort(([k1, v1], [k2, v2]) => k1 < k2 ? -1 : k1 > k2 ? 1 : v1 < v2 ? -1 : v1 > v2 ? 1 : 0).map((pair) => pair.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()
    ].join(", ");
    }
    async signature() {
    const date = this.datetime.slice(0, 8);
    const cacheKey = [this.secretAccessKey, date, this.region, this.service].join();
    let kCredentials = this.cache.get(cacheKey);
    if (!kCredentials) {
    const kDate = await hmac("AWS4" + this.secretAccessKey, date);
    const kRegion = await hmac(kDate, this.region);
    const kService = await hmac(kRegion, this.service);
    kCredentials = await hmac(kService, "aws4_request");
    this.cache.set(cacheKey, kCredentials);
    }
    return buf2hex(await hmac(kCredentials, 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 hashHeader = this.headers.get("X-Amz-Content-Sha256") || (this.service === "s3" && this.signQuery ? "UNSIGNED-PAYLOAD" : null);
    if (hashHeader == null) {
    if (this.body && typeof this.body !== "string" && !("byteLength" in this.body)) {
    throw new Error("body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header");
    }
    hashHeader = buf2hex(await hash(this.body || ""));
    }
    return hashHeader;
    }
    };
    async function hmac(key, string) {
    const cryptoKey = await crypto.subtle.importKey(
    "raw",
    typeof key === "string" ? encoder.encode(key) : key,
    { name: "HMAC", hash: { name: "SHA-256" } },
    false,
    ["sign"]
    );
    return crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(string));
    }
    async function hash(content) {
    return crypto.subtle.digest("SHA-256", typeof content === "string" ? encoder.encode(content) : content);
    }
    function buf2hex(buffer) {
    return Array.prototype.map.call(new Uint8Array(buffer), (x) => ("0" + x.toString(16)).slice(-2)).join("");
    }
    function encodeRfc3986(urlEncodedStr) {
    return urlEncodedStr.replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
    }
    function guessServiceRegion(url, headers) {
    const { hostname, pathname } = url;
    if (hostname.endsWith(".r2.cloudflarestorage.com")) {
    return ["s3", "auto"];
    }
    if (hostname.endsWith(".backblazeb2.com")) {
    const match2 = hostname.match(/^(?:[^.]+\.)?s3\.([^.]+)\.backblazeb2\.com$/);
    return match2 != null ? ["s3", match2[1]] : ["", ""];
    }
    const match = hostname.replace("dualstack.", "").match(/([^.]+)\.(?:([^.]*)\.)?amazonaws\.com(?:\.cn)?$/);
    let [service, region] = (match || ["", ""]).slice(1, 3);
    if (region === "us-gov") {
    region = "us-gov-west-1";
    } else if (region === "s3" || region === "s3-accelerate") {
    region = "us-east-1";
    service = "s3";
    } else if (service === "iot") {
    if (hostname.startsWith("iot.")) {
    service = "execute-api";
    } else if (hostname.startsWith("data.jobs.iot.")) {
    service = "iot-jobs-data";
    } else {
    service = pathname === "/mqtt" ? "iotdevicegateway" : "iotdata";
    }
    } else if (service === "autoscaling") {
    const targetPrefix = (headers.get("X-Amz-Target") || "").split(".")[0];
    if (targetPrefix === "AnyScaleFrontendService") {
    service = "application-autoscaling";
    } else if (targetPrefix === "AnyScaleScalingPlannerFrontendService") {
    service = "autoscaling-plans";
    }
    } else if (region == null && service.startsWith("s3-")) {
    region = service.slice(3).replace(/^fips-|^external-1/, "");
    service = "s3";
    } else if (service.endsWith("-fips")) {
    service = service.slice(0, -5);
    } else if (region && /-\d$/.test(service) && !/-\d$/.test(region)) {
    [service, region] = [region, service];
    }
    return [HOST_SERVICES[service] || service, region];
    }

    // index.js
    var UNSIGNABLE_HEADERS2 = [
    // These headers appear in the request, but are not passed upstream
    "x-forwarded-proto",
    "x-real-ip",
    // We can't include accept-encoding in the signature because Cloudflare
    // sets the incoming accept-encoding header to "gzip, br", then modifies
    // the outgoing request to set accept-encoding to "gzip".
    // Not cool, Cloudflare!
    "accept-encoding"
    ];
    var HTTPS_PROTOCOL = "https:";
    var HTTPS_PORT = "443";
    var RANGE_RETRY_ATTEMPTS = 3;
    function filterHeaders(headers, env) {
    return new Headers(Array.from(headers.entries()).filter(
    (pair) => !UNSIGNABLE_HEADERS2.includes(pair[0]) && !pair[0].startsWith("cf-") && !("ALLOWED_HEADERS" in env && !env.ALLOWED_HEADERS.includes(pair[0]))
    ));
    }
    var my_proxy_default = {
    async fetch(request, env) {
    if (!["GET", "HEAD"].includes(request.method)) {
    return new Response(null, {
    status: 405,
    statusText: "Method Not Allowed"
    });
    }
    const url = new URL(request.url);
    url.protocol = HTTPS_PROTOCOL;
    url.port = HTTPS_PORT;
    let path = url.pathname.replace(/^\//, "");
    path = path.replace(/\/$/, "");
    const pathSegments = path.split("/");
    if (env.ALLOW_LIST_BUCKET !== "true") {
    if (env.BUCKET_NAME === "$path" && pathSegments.length < 2 || env.BUCKET_NAME !== "$path" && path.length === 0) {
    return new Response(null, {
    status: 404,
    statusText: "Not Found"
    });
    }
    }
    switch (env.BUCKET_NAME) {
    case "$path":
    url.hostname = env.B2_ENDPOINT;
    break;
    case "$host":
    url.hostname = url.hostname.split(".")[0] + "." + env.B2_ENDPOINT;
    break;
    default:
    url.hostname = env.BUCKET_NAME + "." + env.B2_ENDPOINT;
    break;
    }
    const headers = filterHeaders(request.headers, env);
    const endpointRegex = /^s3\.([a-zA-Z0-9-]+)\.backblazeb2\.com$/;
    const [, aws_region] = env.B2_ENDPOINT.match(endpointRegex);
    const client = new AwsClient({
    "accesskeyID": env.B2_APPLICATION_KEY_ID,
    "secretAccessKey": env.B2_APPLICATION_KEY,
    "service": "s3",
    "region": aws_region
    });
    const signedRequest = await client.sign(url.toString(), {
    method: request.method,
    headers
    });
    if (signedRequest.headers.has("range")) {
    let attempts = RANGE_RETRY_ATTEMPTS;
    let response;
    do {
    let controller = new AbortController();
    response = await fetch(signedRequest.url, {
    method: signedRequest.method,
    headers: signedRequest.headers,
    signal: controller.signal
    });
    if (response.headers.has("content-range")) {
    if (attempts < RANGE_RETRY_ATTEMPTS) {
    console.log(`Retry for ${signedRequest.url} succeeded - response has content-range header`);
    }
    break;
    } else if (response.ok) {
    attempts -= 1;
    console.error(`Range header in request for ${signedRequest.url} but no content-range header in response. Will retry ${attempts} more times`);
    if (attempts > 0) {
    controller.abort();
    }
    } else {
    break;
    }
    } while (attempts > 0);
    if (attempts <= 0) {
    console.error(`Tried range request for ${signedRequest.url} ${RANGE_RETRY_ATTEMPTS} times, but no content-range in response.`);
    }
    return response;
    }
    return fetch(signedRequest);
    }
    };
    export {
    my_proxy_default as default
    };
    /*! Bundled license information:

    aws4fetch/dist/aws4fetch.esm.mjs:
    (**
    * @license MIT <https://opensource.org/licenses/MIT>
    * @copyright Michael Hart 2022
    *)
    */
    //# sourceMappingURL=index.js.map
  3. 点击”部署”按钮

2. 配置环境变量

  1. 点击”设置”->”添加变量”
    添加变量
  2. 添加以下变量(都设为”纯文本”类型):
变量名
ALLOW_LIST_BUCKET false
BUCKET_NAME 你的存储桶的名字
B2_ENDPOINT 你的地区的endpoint(如 s3.ca-east-006.backblazeb2.com)
B2_APPLICATION_KEY_ID 你复制的KeyID
B2_APPLICATION_KEY 你复制的applicationKey

变量展示
3. 点击”保存”按钮

3. 配置自定义域名

  1. 点击”设置”->”域和路由”->”添加”
    添加自定义域名
  2. 输入您想使用的域名(如:b2.xyuns.cc)
  3. 按照提示完成DNS配置
  4. 点击”确认”按钮

步骤三:配置PicGo

1. 安装PicGo

访问PicGo官网下载并安装最新版本。

2. 安装S3插件

  1. 打开PicGo
  2. 点击”插件设置”
  3. 搜索”s3”并安装插件

3. 配置S3图床

  1. 点击”图床设置” -> “Amazon S3”
  2. 填写以下信息:
配置项
图床配置名 随便起
应用密钥ID 第二步保存的 keyID
应用密钥 第二步保存的 applicationKey
桶名 第一步创建的私密桶名
文件路径 {fullName} (我的是 {fullName} ,因为我上传前会把图片按日期和文章命名好)
地区 留空
自定义节点 第一步记下的 EndPoint (我的是 https://s3.us-west-004.backblazeb2.com)
代理 留空
自定义输出URL模板 第五步自定义的域名 (我的是 https://b2.xyuns.cc/{path} )
其他选项保持默认
  1. 点击”确定”保存设置

4. 修改B2存储桶CORS

本文以Windows系统为例

  1. 下载Backblaze B2 CLI工具,点击下载
  2. 打开命令行工具,输入以下命令
    1
    b2-windows.exe update-bucket Bucket --corsRules “[{"corsRuleName":"downloadFromAnyOriginWithUpload","allowedOrigins":["*"],"allowedHeaders":["authorization","content-type","x-bz-file-name","x-bz-content-sha1"],"allowedOperations":["b2_download_file_by_id","b2_download_file_by_name","b2_upload_file","b2_upload_part","s3_put","s3_post","s3_get","s3_delete"],"exposeHeaders":["ETag"],"maxAgeSeconds":3600}]”
    将Bucket替换为你的Bucket名称

5. 测试上传

  1. 在PicGo主界面拖入一张图片
  2. 上传成功后,复制链接并在浏览器中打开验证

总结

通过以上步骤,我们搭建了一个基于Backblaze B2私密桶、Cloudflare Workers和PicGo的图床方案。这个方案不仅安全可靠,而且成本低廉,非常适合个人博客使用。

由于使用了私密桶存储,图片原始链接不会被直接访问,所有请求都通过Cloudflare Workers进行认证和转发,既保证了访问速度,又提高了安全性。

希望这个教程对你有所帮助!如有任何问题,欢迎在评论区留言讨论。