从存储到分发:使用Backblaze B2私有桶和Cloudflare Workers公开分发原声音轨


前言

众所周知我有一个原声音轨播放网站,原本我图省事将音乐和网页文件放置在一起的,但时间长了就慢慢有问题出现了:服务器空间不够了 其实是贵 ,我的服务器只有可怜的3G磁盘,安装一些七七八八的服务后就基本没空间了,而原声音轨又很占空间,这使得我无法继续添加新的音乐了。

于是我开始寻找能够储存文件并分发的云端服务,Backblaze在各个方面都很符合我的要求,免费计划有10GB的空间可以耍,最重要的是,它Backblaze是Cloudflare带宽联盟的成员,走大善人Cloudflare代理流量免费!

开始白嫖

第一步,上传文件

创建桶和密钥

我已经注册过了Backblaze,所以只需要创建个私有桶就行了(公共桶要有付款记录或者支付一刀验证),新用户注册记得去创建一下主密钥,并将key ID和key记好放到安全的地方

这里我创建好了桶

接下来就是创建一个密钥,我不推荐使用主密钥,很不安全,我这里创建了个仅能访问音乐桶的只读密钥

桶创建好了,密钥也创建好了,接下来开始上传文件,虽然Backblaze官方提供了个浏览器上传接口,但那玩意上传贼慢,还不支持多线程,上传一两个小文件还好,一要批量上传那简直是噩梦,所以我选择了命令行上传

要现安装一下Backblaze B2 CLI,Backblaze B2 CLI是官方推出的命令行工具,这里建议现安装Python,然后使用pip一键安装。

打开命令提示窗口(Win+R,输入cmd),输入

pip install b2

然后等,安装完成后输入

b2 authorize-account

接着它会提示你输入Application Key ID,这里我们输入主密钥的ID,回车输入主密钥的Key,注意输入Key时是不会显示的,输入好后回车就好了,如果出现下图的一大串内容就说明你成功授权了Backblaze B2 CLI

接着开始上传,输入

b2 sync "C:\<本地路径>" "b2://<存储桶名>/<文件夹>"

自己将命令中的路径替换成你要上传的路径,存储桶中如果不存在你所指定的文件夹会自动创建,然后等待上传完成吧,控制台里每打印一个文件名就说明上传好了一个文件名,如果控制台打印的文件名乱码也不用慌,上传的文件名是不会错的

上传完成!

创建CloudflareWorkers访问私有桶里的东西

创建或登录Cloudflare账号,然后创建Workers,名字随意不影响的,接着编辑代码,将以下代码粘贴进去

⬇⬇⬇⬇⬇

点击展开代码
var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });


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 {
  static {
    __name(this, "AwsClient");
  }
  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.toString() }, 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 {
  static {
    __name(this, "AwsV4Signer");
  }
  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));
}
__name(hmac, "hmac");
async function hash(content) {
  return crypto.subtle.digest("SHA-256", typeof content === "string" ? encoder.encode(content) : content);
}
__name(hash, "hash");
var HEX_CHARS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"];
function buf2hex(arrayBuffer) {
  const buffer = new Uint8Array(arrayBuffer);
  let out = "";
  for (let idx = 0; idx < buffer.length; idx++) {
    const n = buffer[idx];
    out += HEX_CHARS[n >>> 4 & 15];
    out += HEX_CHARS[n & 15];
  }
  return out;
}
__name(buf2hex, "buf2hex");
function encodeRfc3986(urlEncodedStr) {
  return urlEncodedStr.replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
}
__name(encodeRfc3986, "encodeRfc3986");
function guessServiceRegion(url, headers) {
  const { hostname, pathname } = url;
  if (hostname.endsWith(".on.aws")) {
    const match2 = hostname.match(/^[^.]{1,63}\.lambda-url\.([^.]{1,63})\.on\.aws$/);
    return match2 != null ? ["lambda", match2[1] || ""] : ["", ""];
  }
  if (hostname.endsWith(".r2.cloudflarestorage.com")) {
    return ["s3", "auto"];
  }
  if (hostname.endsWith(".backblazeb2.com")) {
    const match2 = hostname.match(/^(?:[^.]{1,63}\.)?s3\.([^.]{1,63})\.backblazeb2\.com$/);
    return match2 != null ? ["s3", match2[1] || ""] : ["", ""];
  }
  const match = hostname.replace("dualstack.", "").match(/([^.]{1,63})\.(?:([^.]{0,63})\.)?amazonaws\.com(?:\.cn)?$/);
  let service = match && match[1] || "";
  let region = match && match[2];
  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 || ""];
}
__name(guessServiceRegion, "guessServiceRegion");

// index.js
var UNSIGNABLE_HEADERS2 = [
  "x-forwarded-proto",
  "x-real-ip",
  "accept-encoding",
  "if-match",
  "if-modified-since",
  "if-none-match",
  "if-range",
  "if-unmodified-since"
];
var HTTPS_PROTOCOL = "https:";
var HTTPS_PORT = "443";
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])))
  );
}
__name(filterHeaders, "filterHeaders");
function createHeadResponse(response) {
  return new Response(null, {
    headers: response.headers,
    status: response.status,
    statusText: response.statusText
  });
}
__name(createHeadResponse, "createHeadResponse");
var index_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(/^\//, "").replace(/\/$/, "");
    const isRootRequest = path === "";
    if (isRootRequest || url.pathname.startsWith("/api/")) {
      const objectPath = isRootRequest ? "" : url.pathname.replace(/^\/api\//, "");
      url.pathname = objectPath;
      url.hostname = `${env["BUCKET_NAME"]}.${env["B2_ENDPOINT"]}`;
      const headers = filterHeaders(request.headers, env);
      const client = new AwsClient({
        "accessKeyId": env["B2_APPLICATION_KEY_ID"],
        "secretAccessKey": env["B2_APPLICATION_KEY"],
        "service": "s3"
      });
      const signedRequest = await client.sign(url.toString(), {
        method: request.method,
        headers
      });
      const response = await fetch(signedRequest);
      if (request.method === "HEAD") {
        return createHeadResponse(response);
      }
      return response;
    }
    return new Response("Not Found", { status: 404 });
  }
};
export {
  index_default as default
};

然后点击部署,部署完成后在设置-变量与机密里设置变量名,依次填入刚刚创建的只读密钥Key、存储桶名称,存储桶访问Endpoint是一个域名,在你的桶信息里

纯文本 ALLOW_LIST_BUCKET true
纯文本 B2_APPLICATION_KEY 你的Key
纯文本 B2_APPLICATION_KEY_ID 你的KeyID
纯文本 B2_ENDPOINT 你的访问ENDPOINT
纯文本 BUCKET_NAME 存储桶名称

全部添加好后你可以打开Workers自带的域名后面加上/api/,例如:
cloudflare-b2.abab.workers.dev/api/
如果出现了如下内容就说明成功了!

添加CDN缓存

鉴于CloudflareWorkers每天只有(其实很多)10万次免费请求,而且每次都重新请求时效性不高,所以我们可以人CloudflareCDN缓存下来,打开Backblaze,找到你刚刚创建的桶,点击桶设定,在桶信息里添加如下内容:

{"cache-control":"public, max-age=15552000"}

这个意思是让Cloudflare缓存内容,max-age标识缓存时间,我设置成了半年(因为我的音乐基本不会变动)你可以视情况修改,我建议经常更新的内容设置成24小时或72小时(max-age的单位是秒)

怎么知道Cloudflare是否缓存了你的内容?很简单,上传张图片到桶的根目录,然后打开图片 https://你的Workers域名.workers.dev/api/图片名.jpg ,多刷新两次,然后按F12→网络→点击图片请求,如果cf-cache-status这一栏显示HIT就说明缓存成功了

完成!

如果你一步步做并完成了没有报错的话,那么恭喜你有了一个任意文件床,你可以通过命令行上传任意文件,然后分发给大家下载、播放,鉴于CloudflareWorkers域名大概率被墙,你还可以设置自定义域

搭配上Cloudflare的CDN,使用起来非常Nice,我的原声音轨播放网站已经完全迁至BackblazeB2储存,并且因为储存空间不再受到限制,我还添加了死亡细胞的原版、8bit版的原声音轨!

去我的原声音轨播放界面看看?点击前往

对了,关于Backblaze和Cloudflare是流量联盟成员,Backblaze的流量走Cloudflare免费,这一点没有错,值得注意的是Backblaze的计费方式,Backblaze走Cloudflare的流量免费,这个不是不计费,而是先记在当日账单上,月底再扣除(会扣得很干净滴,不会收钱),所以如果你的流量限额设置得很低的话会导致当日账单满了而拒绝连接,所以最好把限额设置得高一点,Key要保管好啊。



  目录