[storage] #38: add Backblaze B2 as a first-class storage provider

B2 doesn't map onto the generic-S3 "paste an endpoint" model: its S3-compatible
endpoint is region-scoped (s3.<region>.backblazeb2.com) and a public bucket is
addressed virtual-hosted-style, so the public download URL is
https://<bucket>.s3.<region>.backblazeb2.com/<key> — not derivable from a pasted
generic endpoint. So B2 silently failed under "Custom S3".

- s3-url: deriveB2Endpoint(region) -> https://s3.<region>.backblazeb2.com (empty for
  auto/blank/pasted-host so callers can require a real region); re-exported from
  s3Service.
- storageService: PROVIDERS.B2 + resolveB2Endpoint (explicit endpoint wins, else
  derive) + PROVIDER_TESTS[B2] (derived endpoint, forcePathStyle false).
- Public URL = S3-style virtual-hosted, auto-derived; an explicit Public Access URL
  (custom domain/CDN) overrides it. The save path now forwards the user's public URL
  to prepareS3Connection (also fixes generic-S3 ignoring a custom CDN URL when blank
  -> still derives, so no regression).
- UI: "Backblaze B2" provider option + helper; raw Endpoint hidden for B2 (region
  drives it), region shows a us-west-004 example, force-path-style toggle hidden;
  save/test derive endpoint from region with a clear "enter your region" error.
  UploadModal shows B2-specific labels.
- Tests: tests/storage-b2-provider.test.mjs (8). All s3/storage suites green (50);
  build + lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 10:05:53 -04:00
1 parent 35bb1bc576
commit e79595b902
8 files changed
+259 -19

No files matched your search

+67 -12
View File
@@ -3,6 +3,7 @@ import { PROVIDERS } from "../../services/storageService.js";
import {
prepareS3Connection,
getCorsOrigins,
deriveB2Endpoint,
} from "../../services/s3Service.js";
import { getActiveSigner } from "../../nostr/client.js";
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
@@ -31,6 +32,7 @@ export class ProfileStorageController {
this.storagePrefixWarning = null;
this.storageDefaultInput = null;
this.storageR2Helper = null;
this.storageB2Helper = null;
this.storageS3Helper = null;
this.storageForcePathStyleInput = null;
this.storageForcePathStyleLabel = null;
@@ -63,6 +65,7 @@ export class ProfileStorageController {
this.storagePrefixWarning = document.getElementById("storagePrefixWarning") || null;
this.storageDefaultInput = document.getElementById("storageDefault") || null;
this.storageR2Helper = document.getElementById("storageR2Helper") || null;
this.storageB2Helper = document.getElementById("storageB2Helper") || null;
this.storageS3Helper = document.getElementById("storageS3Helper") || null;
this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null;
this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null;
@@ -403,6 +406,9 @@ export class ProfileStorageController {
updateStorageFormVisibility() {
const provider = this.storageProviderInput?.value || "cloudflare_r2";
const isR2 = provider === "cloudflare_r2";
// B2 derives its endpoint from the region (s3.<region>.backblazeb2.com), so the
// raw Endpoint field is hidden and the region drives the connection.
const isB2 = provider === PROVIDERS.B2;
if (this.storageEndpointInput) {
const label =
@@ -416,6 +422,13 @@ export class ProfileStorageController {
if (this.storagePrefixInput) {
this.storagePrefixInput.placeholder = "https://pub-xxx.r2.dev";
}
} else if (isB2) {
// Endpoint is derived from the region; hide the raw endpoint input.
this.storageEndpointInput.parentElement.classList.add("hidden");
if (this.storagePrefixInput) {
this.storagePrefixInput.placeholder =
"Optional — derived from region/bucket (or your CDN domain)";
}
} else {
if (label) label.textContent = "Endpoint URL";
this.storageEndpointInput.placeholder = "https://s3.example.com";
@@ -427,24 +440,38 @@ export class ProfileStorageController {
}
}
// Region matters for B2 (drives the endpoint) and AWS-style S3; nudge the example.
if (this.storageRegionInput && isB2) {
this.storageRegionInput.placeholder = "us-west-004";
} else if (this.storageRegionInput) {
this.storageRegionInput.placeholder = "auto";
}
if (this.storageR2Helper) {
if (isR2) this.storageR2Helper.classList.remove("hidden");
else this.storageR2Helper.classList.add("hidden");
this.storageR2Helper.classList.toggle("hidden", !isR2);
}
if (this.storageB2Helper) {
this.storageB2Helper.classList.toggle("hidden", !isB2);
}
if (this.storageS3Helper) {
if (!isR2) this.storageS3Helper.classList.remove("hidden");
else this.storageS3Helper.classList.add("hidden");
// Generic/custom S3 helper only — not R2, not B2 (each has its own helper).
this.storageS3Helper.classList.toggle("hidden", isR2 || isB2);
}
if (this.storageForcePathStyleLabel) {
if (!isR2)
this.storageForcePathStyleLabel.classList.remove("hidden", "flex");
else this.storageForcePathStyleLabel.classList.add("hidden");
if (!isR2) {
this.storageForcePathStyleLabel.classList.add("flex");
}
// Force-path-style is a generic-S3 knob; R2 (account-derived) and B2
// (virtual-hosted) fix it internally, so hide the toggle for both.
const showForcePathStyle = !isR2 && !isB2;
this.storageForcePathStyleLabel.classList.toggle(
"hidden",
!showForcePathStyle,
);
this.storageForcePathStyleLabel.classList.toggle(
"flex",
showForcePathStyle,
);
}
}
@@ -731,6 +758,18 @@ export class ProfileStorageController {
const prefix = this.storagePrefixInput?.value?.trim() || "";
const isDefault = this.storageDefaultInput?.checked || false;
// B2: the endpoint isn't entered — derive it from the region.
if (provider === PROVIDERS.B2) {
endpointOrAccount = deriveB2Endpoint(region);
if (!endpointOrAccount) {
this.setStorageFormStatus(
"Enter your Backblaze B2 region (e.g. us-west-004).",
"error",
);
return;
}
}
if (!accessKeyId || !secretAccessKey || !bucket || !endpointOrAccount) {
this.setStorageFormStatus("Please fill in all required fields.", "error");
return;
@@ -772,6 +811,10 @@ export class ProfileStorageController {
secretAccessKey,
bucket,
forcePathStyle,
// Honor an explicit Public Access URL (e.g. a B2 custom domain / CDN in front
// of the bucket); when blank, validateS3Connection derives it from the
// endpoint + bucket (for B2: https://<bucket>.s3.<region>.backblazeb2.com).
publicBaseUrl: prefix || undefined,
origins: getCorsOrigins(),
});
endpointOrAccount = normalized.endpoint;
@@ -854,7 +897,7 @@ export class ProfileStorageController {
}
const provider = this.storageProviderInput?.value || "cloudflare_r2";
const endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
const region = this.storageRegionInput?.value?.trim() || "auto";
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
const secretAccessKey = this.storageSecretKeyInput?.value?.trim() || "";
@@ -867,6 +910,18 @@ export class ProfileStorageController {
const publicBaseUrl = this.storagePrefixInput?.value?.trim() || "";
// B2: the endpoint isn't entered — derive it from the region.
if (provider === PROVIDERS.B2) {
endpointOrAccount = deriveB2Endpoint(region);
if (!endpointOrAccount) {
this.setStorageFormStatus(
"Enter your Backblaze B2 region (e.g. us-west-004) to test.",
"error",
);
return;
}
}
if (!accessKeyId || !secretAccessKey || !endpointOrAccount) {
this.setStorageFormStatus("Missing credentials for test.", "error");
return;