diff --git a/js/feedEngine/sources.js b/js/feedEngine/sources.js index 6cf89cb9..8c3aa03d 100644 --- a/js/feedEngine/sources.js +++ b/js/feedEngine/sources.js @@ -16,7 +16,7 @@ function normalizeAuthor(value) { return ""; } const trimmed = value.trim(); - return trimmed; + return trimmed ? trimmed.toLowerCase() : ""; } function normalizeActorCandidate(...values) { diff --git a/js/subscriptions.js b/js/subscriptions.js index 46ae9e16..6a3e1d64 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -20,6 +20,14 @@ import { devLogger, userLogger } from "./utils/logger.js"; import moderationService from "./services/moderationService.js"; import nostrService from "./services/nostrService.js"; +function normalizeHexPubkey(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + return trimmed ? trimmed.toLowerCase() : ""; +} + const getApp = () => getApplication(); /** @@ -122,7 +130,10 @@ class SubscriptionsManager { const subArray = Array.isArray(parsed.subPubkeys) ? parsed.subPubkeys : []; - this.subscribedPubkeys = new Set(subArray); + const normalized = subArray + .map((value) => normalizeHexPubkey(value)) + .filter((value) => Boolean(value)); + this.subscribedPubkeys = new Set(normalized); this.loaded = true; } catch (err) { @@ -131,7 +142,11 @@ class SubscriptionsManager { } isSubscribed(channelHex) { - return this.subscribedPubkeys.has(channelHex); + const normalized = normalizeHexPubkey(channelHex); + if (!normalized) { + return false; + } + return this.subscribedPubkeys.has(normalized); } getSubscribedAuthors() { @@ -139,14 +154,19 @@ class SubscriptionsManager { } async addChannel(channelHex, userPubkey) { + const normalizedChannel = normalizeHexPubkey(channelHex); if (!userPubkey) { throw new Error("No user pubkey => cannot addChannel."); } - if (this.subscribedPubkeys.has(channelHex)) { + if (!normalizedChannel) { + devLogger.warn("Attempted to subscribe to invalid pubkey", channelHex); + return; + } + if (this.subscribedPubkeys.has(normalizedChannel)) { devLogger.log("Already subscribed to", channelHex); return; } - this.subscribedPubkeys.add(channelHex); + this.subscribedPubkeys.add(normalizedChannel); await this.publishSubscriptionList(userPubkey); this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => { userLogger.warn( @@ -157,14 +177,19 @@ class SubscriptionsManager { } async removeChannel(channelHex, userPubkey) { + const normalizedChannel = normalizeHexPubkey(channelHex); if (!userPubkey) { throw new Error("No user pubkey => cannot removeChannel."); } - if (!this.subscribedPubkeys.has(channelHex)) { + if (!normalizedChannel) { + devLogger.warn("Attempted to remove invalid pubkey from subscriptions", channelHex); + return; + } + if (!this.subscribedPubkeys.has(normalizedChannel)) { devLogger.log("Channel not found in subscription list:", channelHex); return; } - this.subscribedPubkeys.delete(channelHex); + this.subscribedPubkeys.delete(normalizedChannel); await this.publishSubscriptionList(userPubkey); this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => { userLogger.warn( @@ -515,7 +540,9 @@ class SubscriptionsManager { buildFeedRuntime({ app, authors = [], limit = null } = {}) { const normalizedAuthors = Array.isArray(authors) - ? authors.filter((author) => typeof author === "string" && author) + ? authors + .map((author) => normalizeHexPubkey(author)) + .filter((author) => Boolean(author)) : []; const blacklist = diff --git a/tests/subscriptions-feed.test.mjs b/tests/subscriptions-feed.test.mjs index aa669294..086a4243 100644 --- a/tests/subscriptions-feed.test.mjs +++ b/tests/subscriptions-feed.test.mjs @@ -255,9 +255,61 @@ async function testLimitApplied() { ); } +async function testAuthorNormalization() { + const engine = createFeedEngine(); + const feedName = "subscriptions-author-normalization"; + + const videos = [ + { id: "video-mixed", pubkey: "pubkey-lower", created_at: 123 }, + ]; + + const calls = []; + const service = { + async getActiveVideosByAuthors(authors, options) { + calls.push({ authors, options }); + return videos; + }, + async getFilteredActiveVideos() { + throw new Error("case normalization run should not fall back to filtered lookup"); + }, + }; + + engine.registerFeed(feedName, { + source: createSubscriptionAuthorsSource({ service }), + stages: [ + createBlacklistFilterStage({ shouldIncludeVideo: () => true }), + createDedupeByRootStage(), + ], + sorter: createChronologicalSorter(), + }); + + const result = await engine.runFeed(feedName, { + runtime: { + subscriptionAuthors: [" PUBKEY-LOWER "], + authors: ["PUBKEY-LOWER"], + blacklistedEventIds: new Set(), + isAuthorBlocked: () => false, + }, + }); + + assert.equal(calls.length, 1, "case normalization run should call targeted lookup once"); + assert.deepEqual( + calls[0].authors, + ["pubkey-lower"], + "authors passed to the service should be lowercased", + ); + + assert.deepEqual( + result.videos.map((video) => video.id), + ["video-mixed"], + "videos should be retained when the service pubkey casing differs", + ); +} + await testBlockedCreatorsFiltered(); await testDuplicateRootsFiltered(); await testNoAuthorsSkipsLookup(); await testLimitApplied(); +await testAuthorNormalization(); console.log("subscriptions-feed tests passed");