mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 01:42:27 +00:00
feat(sync): Storage pane UI for encrypted settings sync (opt-in toggle + restore)
Wires the storage-credential sync (6f7f4196) into the profile Storage pane.
UI (components/profile-modal.html): a "Cross-device sync" card shown only when
storage is unlocked — an opt-in toggle ("Sync these settings to my Nostr account
(encrypted)"), a "Restore from my Nostr account" button, the public-relay /
key-compromise warning copy, and a status line.
Controller (ProfileStorageController):
- toggle on -> storageSyncService.enable() (encrypt + publish), with rollback +
message if nothing is stored yet; toggle off -> disable() (wipe the note)
- restore -> pull() + re-render the pane so the restored connection shows
- after a successful Save, re-push the synced copy when sync is enabled
Also routes the core's relay READ through the subscription manager (injected
listEvents) instead of pool.list, satisfying lint:pool-access for the new file;
the facade supplies it via nostrClient.getSubscriptionManager().list.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
6f7f419636
commit
7376ff91e0
5 files changed
+185
-3
No files matched your search
@@ -942,6 +942,33 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="storageFormStatus" class="text-sm" role="status" aria-live="polite"></p>
|
<p id="storageFormStatus" class="text-sm" role="status" aria-live="polite"></p>
|
||||||
|
|
||||||
|
<div id="profileStorageSync" class="mt-2 rounded-lg border border-default p-4 space-y-3 hidden">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h4 class="text-sm font-semibold text-text">Cross-device sync</h4>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
Save an <strong>encrypted</strong> copy of these storage
|
||||||
|
settings to your Nostr account so you don't have to
|
||||||
|
re-enter them on other devices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-start gap-2 text-sm text-muted-strong">
|
||||||
|
<input id="storageSyncToggle" type="checkbox" class="checkbox mt-0.5" />
|
||||||
|
<span>Sync these settings to my Nostr account (encrypted)</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-status-warning">
|
||||||
|
The encrypted copy lives on public relays. It is readable
|
||||||
|
only with your Nostr key, but anyone who controls your key
|
||||||
|
could decrypt it — and the relays can see that your account
|
||||||
|
stores bitvid settings.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button id="storageSyncRestoreBtn" type="button" class="btn-ghost focus-ring">
|
||||||
|
Restore from my Nostr account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="storageSyncStatus" class="text-xs" role="status" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export function createEncryptedSyncManager(deps = {}) {
|
|||||||
getWriteRelays,
|
getWriteRelays,
|
||||||
getReadRelays,
|
getReadRelays,
|
||||||
getPool,
|
getPool,
|
||||||
|
listEvents,
|
||||||
publishEventToRelays,
|
publishEventToRelays,
|
||||||
summarizePublishResults,
|
summarizePublishResults,
|
||||||
signEvent,
|
signEvent,
|
||||||
@@ -206,14 +207,15 @@ export function createEncryptedSyncManager(deps = {}) {
|
|||||||
|
|
||||||
const relays =
|
const relays =
|
||||||
(typeof getReadRelays === "function" ? getReadRelays() : []) || [];
|
(typeof getReadRelays === "function" ? getReadRelays() : []) || [];
|
||||||
const pool = typeof getPool === "function" ? getPool() : null;
|
if (typeof listEvents !== "function" || !relays.length) {
|
||||||
if (!pool || !relays.length) {
|
|
||||||
return { found: false, error: "no-relays" };
|
return { found: false, error: "no-relays" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reads go through the injected listEvents (subscription manager in prod) —
|
||||||
|
// never pool.list directly (lint:pool-access).
|
||||||
let events = [];
|
let events = [];
|
||||||
try {
|
try {
|
||||||
events = await pool.list(relays, [
|
events = await listEvents(relays, [
|
||||||
{ kinds: [APP_DATA_KIND], authors: [pubkey], "#d": [tag], limit: 20 },
|
{ kinds: [APP_DATA_KIND], authors: [pubkey], "#d": [tag], limit: 20 },
|
||||||
]);
|
]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ export const encryptedSync = createEncryptedSyncManager({
|
|||||||
: [],
|
: [],
|
||||||
getReadRelays: resolveReadRelays,
|
getReadRelays: resolveReadRelays,
|
||||||
getPool: () => nostrClient?.pool || null,
|
getPool: () => nostrClient?.pool || null,
|
||||||
|
// Reads are routed through the subscription manager (deduped + health-gated),
|
||||||
|
// never pool.list directly (lint:pool-access).
|
||||||
|
listEvents: (relays, filters) => {
|
||||||
|
const manager =
|
||||||
|
typeof nostrClient?.getSubscriptionManager === "function"
|
||||||
|
? nostrClient.getSubscriptionManager()
|
||||||
|
: null;
|
||||||
|
if (!manager || typeof manager.list !== "function") {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
return manager.list({ filters, relays });
|
||||||
|
},
|
||||||
publishEventToRelays,
|
publishEventToRelays,
|
||||||
summarizePublishResults,
|
summarizePublishResults,
|
||||||
signEvent: async (template) => {
|
signEvent: async (template) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "../../services/s3Service.js";
|
} from "../../services/s3Service.js";
|
||||||
import { getActiveSigner } from "../../nostr/client.js";
|
import { getActiveSigner } from "../../nostr/client.js";
|
||||||
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
|
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
|
||||||
|
import { storageSyncService } from "../../services/storageSyncService.js";
|
||||||
|
|
||||||
export class ProfileStorageController {
|
export class ProfileStorageController {
|
||||||
constructor(mainController) {
|
constructor(mainController) {
|
||||||
@@ -34,6 +35,11 @@ export class ProfileStorageController {
|
|||||||
this.storageForcePathStyleInput = null;
|
this.storageForcePathStyleInput = null;
|
||||||
this.storageForcePathStyleLabel = null;
|
this.storageForcePathStyleLabel = null;
|
||||||
|
|
||||||
|
this.storageSyncSection = null;
|
||||||
|
this.storageSyncToggle = null;
|
||||||
|
this.storageSyncRestoreBtn = null;
|
||||||
|
this.storageSyncStatus = null;
|
||||||
|
|
||||||
this.storageUnlockFailure = null;
|
this.storageUnlockFailure = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +66,10 @@ export class ProfileStorageController {
|
|||||||
this.storageS3Helper = document.getElementById("storageS3Helper") || null;
|
this.storageS3Helper = document.getElementById("storageS3Helper") || null;
|
||||||
this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null;
|
this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null;
|
||||||
this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null;
|
this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null;
|
||||||
|
this.storageSyncSection = document.getElementById("profileStorageSync") || null;
|
||||||
|
this.storageSyncToggle = document.getElementById("storageSyncToggle") || null;
|
||||||
|
this.storageSyncRestoreBtn = document.getElementById("storageSyncRestoreBtn") || null;
|
||||||
|
this.storageSyncStatus = document.getElementById("storageSyncStatus") || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerEventListeners() {
|
registerEventListeners() {
|
||||||
@@ -98,6 +108,18 @@ export class ProfileStorageController {
|
|||||||
this.handlePublicUrlInput();
|
this.handlePublicUrlInput();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.storageSyncToggle instanceof HTMLElement) {
|
||||||
|
this.storageSyncToggle.addEventListener("change", () => {
|
||||||
|
void this.handleToggleSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.storageSyncRestoreBtn instanceof HTMLElement) {
|
||||||
|
this.storageSyncRestoreBtn.addEventListener("click", () => {
|
||||||
|
void this.handleRestoreSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async populateStoragePane() {
|
async populateStoragePane() {
|
||||||
@@ -188,9 +210,115 @@ export class ProfileStorageController {
|
|||||||
this.storageFormSection.classList.add("hidden");
|
this.storageFormSection.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.renderSyncSection(isUnlocked ? pubkey : "");
|
||||||
this.updateStorageFormVisibility();
|
this.updateStorageFormVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderSyncSection(pubkey) {
|
||||||
|
if (!(this.storageSyncSection instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only meaningful once unlocked: there is something to sync and the signer
|
||||||
|
// that encrypts is present.
|
||||||
|
if (!pubkey || !storageSyncService.isAvailable()) {
|
||||||
|
this.storageSyncSection.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.storageSyncSection.classList.remove("hidden");
|
||||||
|
if (this.storageSyncToggle instanceof HTMLInputElement) {
|
||||||
|
this.storageSyncToggle.checked = storageSyncService.isEnabled(pubkey);
|
||||||
|
}
|
||||||
|
this.setSyncStatus("");
|
||||||
|
}
|
||||||
|
|
||||||
|
setSyncStatus(message, tone = "info") {
|
||||||
|
if (!(this.storageSyncStatus instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.storageSyncStatus.textContent = message || "";
|
||||||
|
const toneClass =
|
||||||
|
tone === "success"
|
||||||
|
? "text-status-success"
|
||||||
|
: tone === "error"
|
||||||
|
? "text-status-danger"
|
||||||
|
: "text-muted";
|
||||||
|
this.storageSyncStatus.className = `text-xs ${toneClass}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleToggleSync() {
|
||||||
|
const pubkey = this.mainController.normalizeHexPubkey(
|
||||||
|
this.mainController.getActivePubkey()
|
||||||
|
);
|
||||||
|
if (!pubkey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const enabled =
|
||||||
|
this.storageSyncToggle instanceof HTMLInputElement
|
||||||
|
? this.storageSyncToggle.checked
|
||||||
|
: false;
|
||||||
|
try {
|
||||||
|
if (enabled) {
|
||||||
|
this.setSyncStatus("Encrypting and publishing…");
|
||||||
|
const result = await storageSyncService.enable(pubkey);
|
||||||
|
if (result?.ok) {
|
||||||
|
this.setSyncStatus(
|
||||||
|
`Synced to ${result.accepted}/${result.total} relays.`,
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
this.mainController.showSuccess("Storage settings synced (encrypted).");
|
||||||
|
} else {
|
||||||
|
// Roll the toggle back so it reflects reality.
|
||||||
|
if (this.storageSyncToggle instanceof HTMLInputElement) {
|
||||||
|
this.storageSyncToggle.checked = false;
|
||||||
|
}
|
||||||
|
await storageSyncService.disable(pubkey).catch(() => {});
|
||||||
|
this.setSyncStatus(
|
||||||
|
result?.error === "nothing-to-sync"
|
||||||
|
? "Nothing to sync yet — save a connection first."
|
||||||
|
: "Could not publish the encrypted copy. Try again.",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.setSyncStatus("Removing the synced copy…");
|
||||||
|
await storageSyncService.disable(pubkey);
|
||||||
|
this.setSyncStatus("Sync turned off; the synced copy was cleared.");
|
||||||
|
this.mainController.showSuccess("Storage sync turned off.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
devLogger.error("[ProfileModal] Storage sync toggle failed:", error);
|
||||||
|
this.setSyncStatus("Sync failed. Please try again.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRestoreSync() {
|
||||||
|
const pubkey = this.mainController.normalizeHexPubkey(
|
||||||
|
this.mainController.getActivePubkey()
|
||||||
|
);
|
||||||
|
if (!pubkey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.setSyncStatus("Fetching and decrypting…");
|
||||||
|
const result = await storageSyncService.pull(pubkey);
|
||||||
|
if (result?.found && result.imported) {
|
||||||
|
this.setSyncStatus("Restored from your Nostr account.", "success");
|
||||||
|
this.mainController.showSuccess("Storage settings restored.");
|
||||||
|
// Re-render the pane so the restored connection shows.
|
||||||
|
await this.populateStoragePane();
|
||||||
|
} else if (result?.found && !result.imported) {
|
||||||
|
this.setSyncStatus("Found a copy but could not import it.", "error");
|
||||||
|
} else if (result?.cleared) {
|
||||||
|
this.setSyncStatus("No synced settings found (it was cleared).");
|
||||||
|
} else {
|
||||||
|
this.setSyncStatus("No synced settings found on your account.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
devLogger.error("[ProfileModal] Storage sync restore failed:", error);
|
||||||
|
this.setSyncStatus("Restore failed. Please try again.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fillStorageForm(conn) {
|
fillStorageForm(conn) {
|
||||||
if (!conn) return;
|
if (!conn) return;
|
||||||
const {
|
const {
|
||||||
@@ -594,6 +722,18 @@ export class ProfileStorageController {
|
|||||||
await storageService.saveConnection(pubkey, "default", payload, meta);
|
await storageService.saveConnection(pubkey, "default", payload, meta);
|
||||||
this.setStorageFormStatus("Connection saved.", "success");
|
this.setStorageFormStatus("Connection saved.", "success");
|
||||||
this.mainController.showSuccess("Storage connection saved.");
|
this.mainController.showSuccess("Storage connection saved.");
|
||||||
|
|
||||||
|
// Keep the encrypted synced copy current if the user opted in.
|
||||||
|
if (storageSyncService.isEnabled(pubkey)) {
|
||||||
|
try {
|
||||||
|
const syncResult = await storageSyncService.push(pubkey);
|
||||||
|
if (syncResult?.ok) {
|
||||||
|
this.setSyncStatus("Synced copy updated.", "success");
|
||||||
|
}
|
||||||
|
} catch (syncError) {
|
||||||
|
devLogger.warn("[ProfileModal] Storage re-sync after save failed:", syncError);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
devLogger.error("Failed to save connection:", error);
|
devLogger.error("Failed to save connection:", error);
|
||||||
this.setStorageFormStatus("Failed to save connection.", "error");
|
this.setStorageFormStatus("Failed to save connection.", "error");
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function makeManager({ nip44 = true } = {}) {
|
|||||||
getWriteRelays: () => ["wss://write.relay"],
|
getWriteRelays: () => ["wss://write.relay"],
|
||||||
getReadRelays: () => ["wss://read.relay"],
|
getReadRelays: () => ["wss://read.relay"],
|
||||||
getPool: () => relay,
|
getPool: () => relay,
|
||||||
|
listEvents: (relays, filters) => relay.list(relays, filters),
|
||||||
publishEventToRelays,
|
publishEventToRelays,
|
||||||
summarizePublishResults,
|
summarizePublishResults,
|
||||||
signEvent: async (tpl) => ({
|
signEvent: async (tpl) => ({
|
||||||
|
|||||||
Reference in new issue
Block a user