mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[ui] #38: add a CORS setup helper modal for B2 / S3-compatible buckets
Confirmed against the real B2 account: the web-console "Share everything..." presets write DOWNLOAD-only CORS (s3_get/s3_head, b2_download_*) with NO s3_put/s3_post, so bitvid's browser uploads CORS-fail. The web UI can't set custom rules — it points users at the B2 CLI. Adds a "CORS setup help" button (shown for B2 / Custom S3, hidden for R2) that opens a modal with: - the exact CORS rules JSON in B2-native shape (upload + ranged-playback ops; origins pre-filled from getCorsOrigins()), with a Copy button, - the `b2 update-bucket --corsRules '<json>' <bucket> allPublic` command to apply it, - step-by-step + the "use a key with writeBucketCors and bitvid sets it for you" tip. CORS logic extracted to js/ui/profileModal/storageCorsHelp.js (pure builders + StorageCorsHelp modal controller) so ProfileStorageController stays under the 1000-line cap. Tests: tests/storage-cors-help.test.mjs (8 — asserts the rules include the upload ops the presets omit, origin pre-fill, command shape, modal open/close/visibility). Build + lint clean; profile-modal + storage suites 32/32. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
e79595b902
commit
d7ad151698
5 files changed
+397
-25
No files matched your search
@@ -973,6 +973,9 @@
|
||||
<button id="storageClearBtn" type="button" class="btn-ghost focus-ring text-status-danger">
|
||||
Clear
|
||||
</button>
|
||||
<button id="storageCorsHelpBtn" type="button" class="btn-ghost focus-ring text-xs hidden">
|
||||
CORS setup help
|
||||
</button>
|
||||
</div>
|
||||
<p id="storageFormStatus" class="text-sm" role="status" aria-live="polite"></p>
|
||||
|
||||
@@ -1838,3 +1841,88 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage CORS setup helper (B2 / S3-compatible). Shown on demand from the
|
||||
Storage pane; renders above the profile modal via modal-always-on-top. -->
|
||||
<div
|
||||
id="storageCorsModal"
|
||||
class="bv-modal modal-always-on-top hidden items-start justify-center md:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="storageCorsModalTitle"
|
||||
aria-describedby="storageCorsModalIntro"
|
||||
data-ds="new"
|
||||
>
|
||||
<div class="bv-modal-backdrop" data-storage-cors-dismiss></div>
|
||||
|
||||
<div class="modal-sheet max-h-modal-shell overflow-hidden flex flex-col w-full max-w-2xl" tabindex="-1">
|
||||
<div class="modal-header">
|
||||
<h2 id="storageCorsModalTitle" class="text-xl font-bold text-text">
|
||||
Allow browser uploads (bucket CORS)
|
||||
</h2>
|
||||
<button
|
||||
id="storageCorsModalClose"
|
||||
class="modal-close"
|
||||
type="button"
|
||||
aria-label="Close CORS setup help"
|
||||
data-storage-cors-dismiss
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body overflow-y-auto p-5 space-y-4 text-sm text-muted-strong">
|
||||
<p id="storageCorsModalIntro">
|
||||
Backblaze B2's web console CORS presets ("Share everything…") only allow
|
||||
<strong>downloads</strong>. Uploading from your browser uses an S3
|
||||
<code>PUT</code>/<code>POST</code>, which needs the <strong>custom</strong>
|
||||
rules below. Backblaze can only apply custom rules via its command-line tool.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-text">CORS rules JSON</span>
|
||||
<button id="storageCorsCopyJsonBtn" type="button" class="btn-ghost focus-ring text-xs">
|
||||
Copy JSON
|
||||
</button>
|
||||
</div>
|
||||
<pre class="card border border-default bg-surface-strong/40 p-3 overflow-x-auto text-xs leading-relaxed"><code id="storageCorsJson" class="whitespace-pre text-text"></code></pre>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Origins are pre-filled from this site. Add your other bitvid domains, or use
|
||||
<code>["*"]</code> to allow every origin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-text">Apply it (B2 command-line tool)</span>
|
||||
<button id="storageCorsCopyCmdBtn" type="button" class="btn-ghost focus-ring text-xs">
|
||||
Copy command
|
||||
</button>
|
||||
</div>
|
||||
<pre class="card border border-default bg-surface-strong/40 p-3 overflow-x-auto text-xs leading-relaxed"><code id="storageCorsCli" class="whitespace-pre text-text"></code></pre>
|
||||
</div>
|
||||
|
||||
<ol class="list-decimal space-y-1 pl-5 text-xs text-muted">
|
||||
<li>Install Backblaze's <strong>B2 CLI</strong> (<code>pip install b2</code>) on your computer.</li>
|
||||
<li>Run the command above (it authorizes with your key, then sets the rules).</li>
|
||||
<li>Wait ~1 minute, then retry your upload in bitvid.</li>
|
||||
</ol>
|
||||
|
||||
<p class="text-xs text-muted">
|
||||
Prefer the web console? Its presets are fine for <strong>playback</strong> of
|
||||
already-uploaded files — only browser <strong>uploads</strong> need the custom
|
||||
rules above. (Tip: a B2 application key with the <code>writeBucketCors</code>
|
||||
capability lets bitvid set this for you automatically when you Save.)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer flex items-center justify-end gap-3 border-t border-border/60 p-4">
|
||||
<button id="storageCorsDoneBtn" type="button" class="btn focus-ring" data-storage-cors-dismiss>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { getActiveSigner } from "../../nostr/client.js";
|
||||
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
|
||||
import { storageSyncService } from "../../services/storageSyncService.js";
|
||||
import { StorageCorsHelp } from "./storageCorsHelp.js";
|
||||
|
||||
export class ProfileStorageController {
|
||||
constructor(mainController) {
|
||||
@@ -34,6 +35,10 @@ export class ProfileStorageController {
|
||||
this.storageR2Helper = null;
|
||||
this.storageB2Helper = null;
|
||||
this.storageS3Helper = null;
|
||||
// CORS setup helper modal (B2 / Custom S3) — owns its own DOM + behavior.
|
||||
this.corsHelp = new StorageCorsHelp({
|
||||
getBucket: () => this.storageBucketInput?.value?.trim() || "",
|
||||
});
|
||||
this.storageForcePathStyleInput = null;
|
||||
this.storageForcePathStyleLabel = null;
|
||||
|
||||
@@ -67,6 +72,7 @@ export class ProfileStorageController {
|
||||
this.storageR2Helper = document.getElementById("storageR2Helper") || null;
|
||||
this.storageB2Helper = document.getElementById("storageB2Helper") || null;
|
||||
this.storageS3Helper = document.getElementById("storageS3Helper") || null;
|
||||
this.corsHelp.cacheDom(document);
|
||||
this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null;
|
||||
this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null;
|
||||
this.storageSyncSection = document.getElementById("profileStorageSync") || null;
|
||||
@@ -123,6 +129,8 @@ export class ProfileStorageController {
|
||||
void this.handleRestoreSync();
|
||||
});
|
||||
}
|
||||
|
||||
this.corsHelp.registerEventListeners();
|
||||
}
|
||||
|
||||
async populateStoragePane() {
|
||||
@@ -406,8 +414,7 @@ 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.
|
||||
// B2 derives its endpoint from the region, so its raw Endpoint field is hidden.
|
||||
const isB2 = provider === PROVIDERS.B2;
|
||||
|
||||
if (this.storageEndpointInput) {
|
||||
@@ -461,8 +468,7 @@ export class ProfileStorageController {
|
||||
}
|
||||
|
||||
if (this.storageForcePathStyleLabel) {
|
||||
// Force-path-style is a generic-S3 knob; R2 (account-derived) and B2
|
||||
// (virtual-hosted) fix it internally, so hide the toggle for both.
|
||||
// Force-path-style is a generic-S3 knob; R2 and B2 fix it internally.
|
||||
const showForcePathStyle = !isR2 && !isB2;
|
||||
this.storageForcePathStyleLabel.classList.toggle(
|
||||
"hidden",
|
||||
@@ -473,6 +479,9 @@ export class ProfileStorageController {
|
||||
showForcePathStyle,
|
||||
);
|
||||
}
|
||||
|
||||
// Manual CORS helper is for S3-compatible providers (B2 / Custom S3), not R2.
|
||||
this.corsHelp.setVisible(!isR2);
|
||||
}
|
||||
|
||||
getStorageUnlockFailureMessage(error) {
|
||||
@@ -515,12 +524,10 @@ export class ProfileStorageController {
|
||||
this.storageUnlockFailure = null;
|
||||
}
|
||||
|
||||
// After a page reload, a persisted ("remember this key") nsec session restores the
|
||||
// logged-in pubkey + UI but NOT the in-memory signer — the private key is held only
|
||||
// passphrase-encrypted. Storage unlock then has no signer (or a decrypt-less stub),
|
||||
// and the generic "No active signer / cannot decrypt" errors are misleading because
|
||||
// the user still looks logged in. Detect this exact case (a saved nsec key for the
|
||||
// account we're unlocking) so we can tell them to re-unlock their saved key instead.
|
||||
// After a page reload, a persisted nsec session restores the logged-in pubkey + UI
|
||||
// but NOT the in-memory signer (the key is passphrase-encrypted). Storage unlock then
|
||||
// has no usable signer, and the generic "No active signer" error is misleading.
|
||||
// Detect this exact case (a saved nsec key for the account we're unlocking).
|
||||
getLockedStoredNsecSession(pubkey) {
|
||||
const client = this.mainController.services?.nostrClient;
|
||||
if (!client || typeof client.getStoredSessionActorMetadata !== "function") {
|
||||
@@ -555,11 +562,8 @@ export class ProfileStorageController {
|
||||
this.setStorageUnlockFailureState(error);
|
||||
this.mainController.showError(this.getStorageUnlockFailureMessage(error));
|
||||
|
||||
// When the user actively clicked "Unlock Storage", open the login modal straight
|
||||
// to its existing unlock-saved-key (passphrase) flow so they can re-unlock in one
|
||||
// step. After they unlock, the signer is active app-wide and re-opening the storage
|
||||
// pane auto-unlocks. We don't auto-open from the passive pane render (would be
|
||||
// surprising); the status text + button still guide them there.
|
||||
// On an explicit "Unlock Storage" click, open the login modal's unlock-saved-key
|
||||
// (passphrase) flow so the user re-unlocks in one step (not from passive render).
|
||||
if (autoOpenLogin) {
|
||||
const openLoginModal = this.mainController.services?.openLoginModal;
|
||||
if (typeof openLoginModal === "function") {
|
||||
@@ -758,9 +762,8 @@ 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);
|
||||
endpointOrAccount = deriveB2Endpoint(region); // derived from region for B2
|
||||
if (!endpointOrAccount) {
|
||||
this.setStorageFormStatus(
|
||||
"Enter your Backblaze B2 region (e.g. us-west-004).",
|
||||
@@ -811,9 +814,8 @@ 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).
|
||||
// Honor an explicit Public Access URL (custom domain / CDN); when blank,
|
||||
// validateS3Connection derives it from the endpoint + bucket.
|
||||
publicBaseUrl: prefix || undefined,
|
||||
origins: getCorsOrigins(),
|
||||
});
|
||||
@@ -910,9 +912,8 @@ 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);
|
||||
endpointOrAccount = deriveB2Endpoint(region); // derived from region for B2
|
||||
if (!endpointOrAccount) {
|
||||
this.setStorageFormStatus(
|
||||
"Enter your Backblaze B2 region (e.g. us-west-004) to test.",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Storage CORS setup helper (B2 / S3-compatible providers).
|
||||
//
|
||||
// Browser uploads to a bucket use an S3 PUT/POST, which the bucket must allow via
|
||||
// CORS. Backblaze B2's web-console presets only "share" (download) files, so uploads
|
||||
// stay CORS-blocked until custom rules are applied with B2's CLI. This module renders
|
||||
// a small modal with the exact rules (origins pre-filled) + the command to apply them.
|
||||
|
||||
import { devLogger } from "../../utils/logger.js";
|
||||
import { getCorsOrigins } from "../../services/s3Service.js";
|
||||
|
||||
// The CORS rules bitvid needs for in-browser uploads + ranged playback, in B2's
|
||||
// native rules shape. Mirrors ensureBucketCors (GET/HEAD/PUT/POST/DELETE + ranged-
|
||||
// playback expose headers) but adds the upload operations the B2 web-console presets
|
||||
// omit (they only share downloads).
|
||||
export function buildBucketCorsRules(origins) {
|
||||
const allowedOrigins =
|
||||
Array.isArray(origins) && origins.length ? origins : ["*"];
|
||||
return [
|
||||
{
|
||||
corsRuleName: "bitvidBrowserAccess",
|
||||
allowedOrigins,
|
||||
allowedHeaders: ["*"],
|
||||
allowedOperations: ["s3_head", "s3_get", "s3_put", "s3_post", "s3_delete"],
|
||||
exposeHeaders: ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
|
||||
maxAgeSeconds: 3600,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The exact B2 CLI command that applies the rules (the path B2's web console points
|
||||
// custom-rule users to). Compact JSON so it pastes as one line.
|
||||
export function buildB2CorsCommand(bucket, rules) {
|
||||
const bucketName = (bucket && bucket.trim()) || "YOUR_BUCKET";
|
||||
const compact = JSON.stringify(rules);
|
||||
return `b2 update-bucket --corsRules '${compact}' ${bucketName} allPublic`;
|
||||
}
|
||||
|
||||
export class StorageCorsHelp {
|
||||
constructor({ getBucket } = {}) {
|
||||
this.getBucket = typeof getBucket === "function" ? getBucket : () => "";
|
||||
this.helpBtn = null;
|
||||
this.modal = null;
|
||||
this.jsonEl = null;
|
||||
this.cliEl = null;
|
||||
this.copyJsonBtn = null;
|
||||
this.copyCmdBtn = null;
|
||||
}
|
||||
|
||||
cacheDom(doc = document) {
|
||||
this.helpBtn = doc.getElementById("storageCorsHelpBtn") || null;
|
||||
this.modal = doc.getElementById("storageCorsModal") || null;
|
||||
this.jsonEl = doc.getElementById("storageCorsJson") || null;
|
||||
this.cliEl = doc.getElementById("storageCorsCli") || null;
|
||||
this.copyJsonBtn = doc.getElementById("storageCorsCopyJsonBtn") || null;
|
||||
this.copyCmdBtn = doc.getElementById("storageCorsCopyCmdBtn") || null;
|
||||
}
|
||||
|
||||
registerEventListeners() {
|
||||
if (this.helpBtn instanceof HTMLElement) {
|
||||
this.helpBtn.addEventListener("click", () => this.open());
|
||||
}
|
||||
if (this.modal instanceof HTMLElement) {
|
||||
this.modal
|
||||
.querySelectorAll("[data-storage-cors-dismiss]")
|
||||
.forEach((el) => el.addEventListener("click", () => this.close()));
|
||||
this.modal.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.copyJsonBtn instanceof HTMLElement) {
|
||||
this.copyJsonBtn.addEventListener("click", () => {
|
||||
void this.copy(this.jsonEl?.textContent, this.copyJsonBtn, "Copy JSON");
|
||||
});
|
||||
}
|
||||
if (this.copyCmdBtn instanceof HTMLElement) {
|
||||
this.copyCmdBtn.addEventListener("click", () => {
|
||||
void this.copy(this.cliEl?.textContent, this.copyCmdBtn, "Copy command");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// R2 auto-applies CORS via its own flow; the manual helper is for the S3-compatible
|
||||
// providers (B2 / Custom S3) where the bucket owner sets it.
|
||||
setVisible(show) {
|
||||
if (this.helpBtn instanceof HTMLElement) {
|
||||
this.helpBtn.classList.toggle("hidden", !show);
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
if (!(this.modal instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
let origins = [];
|
||||
try {
|
||||
origins = getCorsOrigins();
|
||||
} catch (error) {
|
||||
origins = [];
|
||||
}
|
||||
const rules = buildBucketCorsRules(origins);
|
||||
if (this.jsonEl) {
|
||||
this.jsonEl.textContent = JSON.stringify(rules, null, 2);
|
||||
}
|
||||
if (this.cliEl) {
|
||||
this.cliEl.textContent = buildB2CorsCommand(this.getBucket(), rules);
|
||||
}
|
||||
this.modal.classList.remove("hidden");
|
||||
const sheet = this.modal.querySelector(".modal-sheet");
|
||||
if (sheet instanceof HTMLElement) {
|
||||
sheet.focus();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.modal instanceof HTMLElement) {
|
||||
this.modal.classList.add("hidden");
|
||||
}
|
||||
if (this.helpBtn instanceof HTMLElement) {
|
||||
this.helpBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async copy(text, button, restoreLabel) {
|
||||
const value = typeof text === "string" ? text : "";
|
||||
if (!value || !navigator?.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
if (button instanceof HTMLElement) {
|
||||
button.textContent = "Copied!";
|
||||
setTimeout(() => {
|
||||
button.textContent = restoreLabel;
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[ProfileModal] Failed to copy CORS text:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Storage CORS setup helper (B2 / S3-compatible): the rules + command bitvid shows a
|
||||
// user so in-browser uploads work. The crucial property: the rules include the UPLOAD
|
||||
// operations (s3_put / s3_post) that B2's web-console "share" presets omit — that gap
|
||||
// is exactly why uploads CORS-fail until these custom rules are applied.
|
||||
|
||||
import test, { beforeEach, afterEach } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
buildBucketCorsRules,
|
||||
buildB2CorsCommand,
|
||||
StorageCorsHelp,
|
||||
} from "../js/ui/profileModal/storageCorsHelp.js";
|
||||
|
||||
test("CORS rules include the upload operations the B2 presets omit", () => {
|
||||
const [rule] = buildBucketCorsRules(["https://bitvid.network"]);
|
||||
for (const op of ["s3_put", "s3_post"]) {
|
||||
assert.ok(
|
||||
rule.allowedOperations.includes(op),
|
||||
`rule must allow ${op} (browser upload) — the whole point of this helper`,
|
||||
);
|
||||
}
|
||||
// ...and still allow ranged playback reads.
|
||||
assert.ok(rule.allowedOperations.includes("s3_get"));
|
||||
assert.ok(rule.allowedOperations.includes("s3_head"));
|
||||
assert.deepEqual(rule.exposeHeaders, [
|
||||
"ETag",
|
||||
"Content-Length",
|
||||
"Content-Range",
|
||||
"Accept-Ranges",
|
||||
]);
|
||||
});
|
||||
|
||||
test("CORS rules use the given origins, and fall back to every-origin when none", () => {
|
||||
assert.deepEqual(
|
||||
buildBucketCorsRules(["https://a.example", "https://b.example"])[0]
|
||||
.allowedOrigins,
|
||||
["https://a.example", "https://b.example"],
|
||||
);
|
||||
assert.deepEqual(buildBucketCorsRules([])[0].allowedOrigins, ["*"]);
|
||||
assert.deepEqual(buildBucketCorsRules(undefined)[0].allowedOrigins, ["*"]);
|
||||
});
|
||||
|
||||
test("the B2 CLI command targets the bucket as allPublic with compact one-line JSON", () => {
|
||||
const rules = buildBucketCorsRules(["https://bitvid.network"]);
|
||||
const cmd = buildB2CorsCommand("bitvid", rules);
|
||||
assert.match(cmd, /^b2 update-bucket --corsRules '.*' bitvid allPublic$/);
|
||||
assert.ok(!cmd.includes("\n"), "command must be a single pasteable line");
|
||||
// The embedded JSON is valid and carries the upload op.
|
||||
const json = cmd.slice(cmd.indexOf("'") + 1, cmd.lastIndexOf("'"));
|
||||
assert.ok(JSON.parse(json)[0].allowedOperations.includes("s3_put"));
|
||||
});
|
||||
|
||||
test("a missing bucket name degrades to an obvious placeholder (not an empty arg)", () => {
|
||||
assert.match(buildB2CorsCommand("", buildBucketCorsRules([])), /YOUR_BUCKET allPublic$/);
|
||||
});
|
||||
|
||||
// --- DOM behavior ---
|
||||
let dom;
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM(
|
||||
`<!doctype html><html><body>
|
||||
<button id="storageCorsHelpBtn" class="hidden"></button>
|
||||
<div id="storageCorsModal" class="hidden">
|
||||
<div class="modal-sheet" tabindex="-1"></div>
|
||||
<button data-storage-cors-dismiss id="x"></button>
|
||||
<code id="storageCorsJson"></code>
|
||||
<code id="storageCorsCli"></code>
|
||||
<button id="storageCorsCopyJsonBtn"></button>
|
||||
<button id="storageCorsCopyCmdBtn"></button>
|
||||
</div>
|
||||
</body></html>`,
|
||||
{ url: "https://unstable.bitvid.network/" },
|
||||
);
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.window = dom.window;
|
||||
});
|
||||
afterEach(() => {
|
||||
delete globalThis.document;
|
||||
delete globalThis.HTMLElement;
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
test("open() fills the JSON + command from the current origin and reveals the modal", () => {
|
||||
const help = new StorageCorsHelp({ getBucket: () => "bitvid" });
|
||||
help.cacheDom(dom.window.document);
|
||||
help.registerEventListeners();
|
||||
|
||||
help.open();
|
||||
|
||||
const modal = dom.window.document.getElementById("storageCorsModal");
|
||||
assert.ok(!modal.classList.contains("hidden"), "modal is shown");
|
||||
|
||||
const json = dom.window.document.getElementById("storageCorsJson").textContent;
|
||||
const rule = JSON.parse(json)[0];
|
||||
assert.deepEqual(
|
||||
rule.allowedOrigins,
|
||||
["https://unstable.bitvid.network"],
|
||||
"origins pre-filled from getCorsOrigins()",
|
||||
);
|
||||
|
||||
const cli = dom.window.document.getElementById("storageCorsCli").textContent;
|
||||
assert.match(cli, /bitvid allPublic$/);
|
||||
});
|
||||
|
||||
test("dismiss controls hide the modal again", () => {
|
||||
const help = new StorageCorsHelp({ getBucket: () => "bitvid" });
|
||||
help.cacheDom(dom.window.document);
|
||||
help.registerEventListeners();
|
||||
help.open();
|
||||
dom.window.document.getElementById("x").click();
|
||||
assert.ok(
|
||||
dom.window.document.getElementById("storageCorsModal").classList.contains("hidden"),
|
||||
);
|
||||
});
|
||||
|
||||
test("setVisible toggles the help button for S3-compatible vs R2", () => {
|
||||
const help = new StorageCorsHelp({ getBucket: () => "" });
|
||||
help.cacheDom(dom.window.document);
|
||||
const btn = dom.window.document.getElementById("storageCorsHelpBtn");
|
||||
help.setVisible(true);
|
||||
assert.ok(!btn.classList.contains("hidden"));
|
||||
help.setVisible(false);
|
||||
assert.ok(btn.classList.contains("hidden"));
|
||||
});
|
||||
@@ -860,9 +860,24 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro
|
||||
- Tests: `tests/storage-b2-provider.test.mjs` (8 — endpoint derivation, virtual-
|
||||
hosted public URL, end-to-end validate, explicit override, object URLs). All
|
||||
s3/storage suites green (50); build + lint clean.
|
||||
- [ ] **VERIFY live** with a real B2 Application Key + public bucket on unstable: pick
|
||||
Backblaze B2, enter region (e.g. `us-west-004`) + bucket + key/secret, Test → passes;
|
||||
upload a video → plays from `https://<bucket>.s3.<region>.backblazeb2.com/...`.
|
||||
- [x] **VALIDATED against a real B2 account 2026-06-30.** Read-only `b2_authorize_account`
|
||||
on the live `bitvid` bucket confirmed the derivation EXACTLY: B2's reported
|
||||
`s3ApiUrl` = `https://s3.us-west-004.backblazeb2.com` = `deriveB2Endpoint("us-west-004")`;
|
||||
bucket is `allPublic` → S3-style playback URL works. Key file caps
|
||||
(read/write/list/delete) ✓.
|
||||
- [x] **CORS helper modal — DONE 2026-06-30.** Empirically confirmed the gotcha: B2's
|
||||
web-console "Share everything…" presets write DOWNLOAD-only CORS (`s3_get/s3_head`,
|
||||
`b2_download_*`) — **no `s3_put/s3_post`** — so browser uploads CORS-fail. Added a
|
||||
"CORS setup help" button (shown for B2 / Custom S3) opening a modal that shows the
|
||||
exact B2-native CORS rules JSON (upload + ranged-playback ops, origins pre-filled
|
||||
from `getCorsOrigins()`) + the `b2 update-bucket --corsRules …` command to apply
|
||||
them, with copy buttons and step-by-step (incl. the "use a key with
|
||||
`writeBucketCors` and bitvid sets it for you" tip). Extracted to
|
||||
`js/ui/profileModal/storageCorsHelp.js` (keeps the controller <1000 lines). Tests:
|
||||
`tests/storage-cors-help.test.mjs` (8). Build + lint clean.
|
||||
- [ ] **VERIFY live** on unstable: pick Backblaze B2, enter region (`us-west-004`) +
|
||||
bucket + key/secret, Test → passes; apply the CORS helper's rules; upload a video →
|
||||
plays from `https://<bucket>.s3.<region>.backblazeb2.com/...`.
|
||||
|
||||
### 39. Image uploads should prefer uploading to configured storage (UX)
|
||||
- [ ] Anywhere the user adds an image, prefer letting them **upload an image file to
|
||||
|
||||
Reference in new issue
Block a user