mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add saved Nostr profile storage helpers
This commit is contained in:
1 parent
ce1f2a8f37
commit
5cfdd79a18
2 files changed
+384
-11
No files matched your search
@@ -0,0 +1,73 @@
|
||||
# Nostr authentication storage
|
||||
|
||||
Bitvid keeps lightweight state in `localStorage` so the UI can remember which
|
||||
accounts signed in recently. This document explains the storage schema so future
|
||||
updates can evolve it without breaking existing users.
|
||||
|
||||
## Storage keys
|
||||
|
||||
- `bitvid:profileCache:v1` – short-lived metadata cache for profile names and
|
||||
avatars. See `js/app.js` for TTL and eviction rules.
|
||||
- `bitvid:savedProfiles:v1` – persistent list of accounts the user authenticated
|
||||
with in this browser.
|
||||
|
||||
Both keys live in the main origin scope. Clearing one should not affect the
|
||||
other.
|
||||
|
||||
## Saved profile schema
|
||||
|
||||
`bitvid:savedProfiles:v1` is a JSON object with the shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"pubkey": "<hex-encoded pubkey>",
|
||||
"npub": "<bech32 npub, optional>",
|
||||
"name": "<cached display name>",
|
||||
"picture": "<cached avatar URL>",
|
||||
"authType": "<auth strategy>"
|
||||
}
|
||||
],
|
||||
"activePubkey": "<hex pubkey or null>"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `pubkey` always stores a lowercase 64-character hex string and is the primary
|
||||
dedupe key.
|
||||
- `npub` is optional. When omitted, the UI regenerates it on load using
|
||||
`NostrTools.nip19.npubEncode`.
|
||||
- `name` and `picture` are cached hints that keep the profile switcher snappy.
|
||||
They may be empty strings and should be treated as hints, not the source of
|
||||
truth.
|
||||
- `activePubkey` tracks the last account that successfully authenticated in this
|
||||
browser. It may be `null` when the user logs out but keeps saved entries.
|
||||
|
||||
### `authType` enum
|
||||
|
||||
`authType` describes how the profile authenticated:
|
||||
|
||||
- `"nip07"` – Browser extension flow (current default).
|
||||
- `"nsec"` – Reserved for future direct key import or signer integrations. New
|
||||
auth strategies should pick a distinct string and document how migration works
|
||||
alongside any recovery tooling.
|
||||
|
||||
When the app reads stored entries it normalises unknown values back to
|
||||
`"nip07"` but keeps recognised alternatives intact.
|
||||
|
||||
## Migration notes
|
||||
|
||||
Earlier builds only persisted a single `userPubKey` string. During startup the
|
||||
app now:
|
||||
|
||||
1. Attempts to read `bitvid:savedProfiles:v1` and validate the payload.
|
||||
2. If the key is missing (or malformed) but a legacy `userPubKey` entry exists,
|
||||
it seeds `savedProfiles` with that value and writes the new structure.
|
||||
3. Once the JSON payload is written successfully, the legacy `userPubKey` entry
|
||||
is removed.
|
||||
|
||||
Future migrations should follow the same pattern: validate, normalise, write the
|
||||
new format, then clean up legacy keys to avoid data loss.
|
||||
@@ -70,7 +70,18 @@ const ADMIN_DM_IMAGE_URL =
|
||||
const BITVID_WEBSITE_URL = "https://bitvid.network/";
|
||||
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
|
||||
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
|
||||
/**
|
||||
* Local storage keys for cached profile metadata and saved authentication
|
||||
* sessions. `SAVED_PROFILES_STORAGE_KEY` stores objects shaped like
|
||||
* `{ version: 1, entries: [{ pubkey, npub, name, picture, authType }],
|
||||
* activePubkey?: string | null }`. `authType` currently supports `"nip07"`
|
||||
* and reserves `"nsec"` for future direct key flows. See docs/nostr-auth.md
|
||||
* for extension guidance.
|
||||
*/
|
||||
const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1";
|
||||
const SAVED_PROFILES_STORAGE_KEY = "bitvid:savedProfiles:v1";
|
||||
const SAVED_PROFILES_STORAGE_VERSION = 1;
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const PROFILE_CACHE_VERSION = 1;
|
||||
const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
// We probe hosted URLs often enough that a naive implementation would spam
|
||||
@@ -665,6 +676,68 @@ class bitvidApp {
|
||||
this.boundMoreMenuDocumentKeydown = null;
|
||||
// Simple cache for user profiles
|
||||
this.profileCache = new Map();
|
||||
this.savedProfiles = [];
|
||||
this.activeProfilePubkey = null;
|
||||
this.persistSavedProfiles = () => {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.savedProfiles.length && !this.activeProfilePubkey) {
|
||||
try {
|
||||
localStorage.removeItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
} catch (err) {
|
||||
console.warn("[persistSavedProfiles] Failed to remove empty payload:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: SAVED_PROFILES_STORAGE_VERSION,
|
||||
entries: this.savedProfiles.map((entry) => ({
|
||||
pubkey: entry.pubkey,
|
||||
npub:
|
||||
typeof entry.npub === "string" && entry.npub.trim()
|
||||
? entry.npub.trim()
|
||||
: null,
|
||||
name: typeof entry.name === "string" ? entry.name : "",
|
||||
picture:
|
||||
typeof entry.picture === "string" ? entry.picture : "",
|
||||
authType: entry.authType === "nsec" ? "nsec" : "nip07",
|
||||
})),
|
||||
activePubkey: this.activeProfilePubkey || null,
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SAVED_PROFILES_STORAGE_KEY,
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
try {
|
||||
localStorage.removeItem("userPubKey");
|
||||
} catch (legacyErr) {
|
||||
console.warn(
|
||||
"[persistSavedProfiles] Failed to remove legacy userPubKey entry:",
|
||||
legacyErr
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const isQuotaError =
|
||||
err &&
|
||||
(err.name === "QuotaExceededError" ||
|
||||
err.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
||||
err.code === 22 ||
|
||||
err.code === 1014);
|
||||
if (isQuotaError) {
|
||||
console.warn(
|
||||
"[persistSavedProfiles] Storage quota exceeded while saving profiles; keeping in-memory copy only.",
|
||||
err
|
||||
);
|
||||
} else {
|
||||
console.warn("[persistSavedProfiles] Failed to persist saved profiles:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.lastRenderedVideoSignature = null;
|
||||
this._lastRenderedVideoListElement = null;
|
||||
this.renderedVideoIds = new Set();
|
||||
@@ -695,6 +768,133 @@ class bitvidApp {
|
||||
}
|
||||
}
|
||||
|
||||
loadSavedProfilesFromStorage() {
|
||||
this.savedProfiles = [];
|
||||
this.activeProfilePubkey = null;
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const seenPubkeys = new Set();
|
||||
let needsRewrite = false;
|
||||
let parsed = null;
|
||||
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse saved profiles payload:", err);
|
||||
needsRewrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.version === SAVED_PROFILES_STORAGE_VERSION &&
|
||||
Array.isArray(parsed.entries)
|
||||
) {
|
||||
for (const candidate of parsed.entries) {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
needsRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedPubkey = this.normalizeHexPubkey(candidate.pubkey);
|
||||
if (!normalizedPubkey) {
|
||||
needsRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenPubkeys.has(normalizedPubkey)) {
|
||||
needsRewrite = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
seenPubkeys.add(normalizedPubkey);
|
||||
|
||||
const storedAuthType =
|
||||
candidate.authType === "nsec" ? "nsec" : "nip07";
|
||||
if (candidate.authType !== storedAuthType) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
pubkey: normalizedPubkey,
|
||||
npub:
|
||||
typeof candidate.npub === "string" && candidate.npub.trim()
|
||||
? candidate.npub.trim()
|
||||
: this.safeEncodeNpub(normalizedPubkey),
|
||||
name:
|
||||
typeof candidate.name === "string" ? candidate.name : "",
|
||||
picture:
|
||||
typeof candidate.picture === "string" ? candidate.picture : "",
|
||||
authType: storedAuthType,
|
||||
};
|
||||
|
||||
if (
|
||||
typeof candidate.npub === "string" &&
|
||||
candidate.npub.trim() !== candidate.npub
|
||||
) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (
|
||||
entry.npub &&
|
||||
typeof candidate.npub !== "string" &&
|
||||
entry.npub !== candidate.npub
|
||||
) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
this.savedProfiles.push(entry);
|
||||
}
|
||||
|
||||
const activeCandidate =
|
||||
typeof parsed.activePubkey === "string"
|
||||
? parsed.activePubkey
|
||||
: typeof parsed.activePubKey === "string"
|
||||
? parsed.activePubKey
|
||||
: null;
|
||||
const normalizedActive = this.normalizeHexPubkey(activeCandidate);
|
||||
if (normalizedActive && seenPubkeys.has(normalizedActive)) {
|
||||
this.activeProfilePubkey = normalizedActive;
|
||||
} else if (activeCandidate) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
} else if (raw) {
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (!this.savedProfiles.length) {
|
||||
const legacyPubkey = localStorage.getItem("userPubKey");
|
||||
const normalizedLegacy = this.normalizeHexPubkey(legacyPubkey);
|
||||
if (normalizedLegacy) {
|
||||
const legacyEntry = {
|
||||
pubkey: normalizedLegacy,
|
||||
npub: this.safeEncodeNpub(normalizedLegacy),
|
||||
name: "",
|
||||
picture: "",
|
||||
authType: "nip07",
|
||||
};
|
||||
this.savedProfiles = [legacyEntry];
|
||||
this.activeProfilePubkey = normalizedLegacy;
|
||||
this.persistSavedProfiles();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.activeProfilePubkey && this.savedProfiles.length) {
|
||||
this.activeProfilePubkey = this.savedProfiles[0].pubkey;
|
||||
needsRewrite = true;
|
||||
}
|
||||
|
||||
if (needsRewrite) {
|
||||
this.persistSavedProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
loadProfileCacheFromStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
@@ -886,6 +1086,7 @@ class bitvidApp {
|
||||
}
|
||||
|
||||
this.loadProfileCacheFromStorage();
|
||||
this.loadSavedProfilesFromStorage();
|
||||
|
||||
// 1. Initialize the video modal (components/video-modal.html)
|
||||
await this.initModal();
|
||||
@@ -913,7 +1114,8 @@ class bitvidApp {
|
||||
// Grab the "Subscriptions" link by its id in the sidebar
|
||||
this.subscriptionsLink = document.getElementById("subscriptionsLink");
|
||||
|
||||
const savedPubKey = localStorage.getItem("userPubKey");
|
||||
const savedPubKey =
|
||||
this.activeProfilePubkey || localStorage.getItem("userPubKey");
|
||||
if (savedPubKey) {
|
||||
// Auto-login if a pubkey was saved
|
||||
try {
|
||||
@@ -5236,23 +5438,92 @@ class bitvidApp {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a saved profile entry and clears the active pointer when it matches
|
||||
* the removed pubkey. Intended for the profile-switcher UI to prune old
|
||||
* accounts without touching cached avatars.
|
||||
*/
|
||||
removeSavedProfile(pubkey) {
|
||||
const normalized = this.normalizeHexPubkey(pubkey);
|
||||
const target = normalized || (typeof pubkey === "string" ? pubkey.trim() : "");
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextProfiles = this.savedProfiles.filter(
|
||||
(entry) => entry.pubkey !== target
|
||||
);
|
||||
if (nextProfiles.length === this.savedProfiles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.savedProfiles = nextProfiles;
|
||||
|
||||
if (this.activeProfilePubkey === target) {
|
||||
this.activeProfilePubkey = null;
|
||||
}
|
||||
|
||||
this.persistSavedProfiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called upon successful login.
|
||||
*/
|
||||
async login(pubkey, saveToStorage = true) {
|
||||
console.log("[app.js] login() called with pubkey =", pubkey);
|
||||
|
||||
this.pubkey = pubkey;
|
||||
this.currentUserNpub = this.safeEncodeNpub(pubkey);
|
||||
const normalizedPubkey = this.normalizeHexPubkey(pubkey);
|
||||
if (normalizedPubkey) {
|
||||
this.pubkey = normalizedPubkey;
|
||||
} else {
|
||||
this.pubkey = pubkey;
|
||||
}
|
||||
this.currentUserNpub = this.safeEncodeNpub(this.pubkey);
|
||||
|
||||
let reloadScheduled = false;
|
||||
if (saveToStorage) {
|
||||
try {
|
||||
localStorage.setItem("userPubKey", pubkey);
|
||||
reloadScheduled = true;
|
||||
} catch (err) {
|
||||
console.warn("[app.js] Failed to persist pubkey:", err);
|
||||
if (normalizedPubkey) {
|
||||
if (saveToStorage) {
|
||||
const profileCacheEntry = this.profileCache.get(normalizedPubkey);
|
||||
const existingIndex = this.savedProfiles.findIndex(
|
||||
(candidate) => candidate.pubkey === normalizedPubkey
|
||||
);
|
||||
const existingAuthType =
|
||||
existingIndex >= 0
|
||||
? this.savedProfiles[existingIndex].authType || "nip07"
|
||||
: "nip07";
|
||||
const entry = {
|
||||
pubkey: normalizedPubkey,
|
||||
npub: this.safeEncodeNpub(normalizedPubkey),
|
||||
name: profileCacheEntry?.profile?.name || "",
|
||||
picture: profileCacheEntry?.profile?.picture || "",
|
||||
authType: existingAuthType,
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
this.savedProfiles[existingIndex] = {
|
||||
...this.savedProfiles[existingIndex],
|
||||
...entry,
|
||||
};
|
||||
} else {
|
||||
this.savedProfiles.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.activeProfilePubkey !== normalizedPubkey) {
|
||||
this.activeProfilePubkey = normalizedPubkey;
|
||||
if (!saveToStorage && this.savedProfiles.length) {
|
||||
this.persistSavedProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
if (saveToStorage) {
|
||||
this.persistSavedProfiles();
|
||||
reloadScheduled = true;
|
||||
}
|
||||
} else if (saveToStorage) {
|
||||
console.warn(
|
||||
"[app.js] login() requested storage persistence but pubkey was invalid; skipping saved profile update."
|
||||
);
|
||||
}
|
||||
|
||||
if (reloadScheduled) {
|
||||
@@ -5263,7 +5534,7 @@ class bitvidApp {
|
||||
await this.refreshAdminPaneState();
|
||||
|
||||
try {
|
||||
await userBlocks.loadBlocks(pubkey);
|
||||
await userBlocks.loadBlocks(this.pubkey);
|
||||
} catch (error) {
|
||||
console.warn("Failed to load personal block list:", error);
|
||||
}
|
||||
@@ -5302,7 +5573,7 @@ class bitvidApp {
|
||||
}
|
||||
|
||||
// (Optional) load the user's own Nostr profile
|
||||
this.loadOwnProfile(pubkey);
|
||||
this.loadOwnProfile(this.pubkey);
|
||||
|
||||
// Refresh the video list so the user sees any private videos, etc.
|
||||
await this.loadVideos();
|
||||
@@ -5321,6 +5592,11 @@ class bitvidApp {
|
||||
this.pubkey = null;
|
||||
this.currentUserNpub = null;
|
||||
|
||||
if (this.activeProfilePubkey) {
|
||||
this.activeProfilePubkey = null;
|
||||
}
|
||||
this.persistSavedProfiles();
|
||||
|
||||
userBlocks.reset();
|
||||
this.populateBlockedList();
|
||||
|
||||
@@ -8789,6 +9065,30 @@ class bitvidApp {
|
||||
return null;
|
||||
}
|
||||
|
||||
normalizeHexPubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = pubkey.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX64_REGEX.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = this.safeDecodeNpub(trimmed);
|
||||
if (decoded && HEX64_REGEX.test(decoded)) {
|
||||
return decoded.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to fetch an older event by its ID if we can't find it in
|
||||
* this.videosMap or from a bulk fetch. Uses nostrClient.getEventById.
|
||||
|
||||
Reference in new issue
Block a user