mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 12:52:26 +00:00
Add client-side guard for view event publishing
This commit is contained in:
1 parent
2599bdaabc
commit
09c8e275c4
2 files changed
+241
-37
No files matched your search
@@ -1,6 +1,7 @@
|
||||
const DEFAULT_FLAGS = Object.freeze({
|
||||
URL_FIRST_ENABLED: true, // try URL before magnet in the player
|
||||
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false,
|
||||
WSS_TRACKERS: Object.freeze([
|
||||
"wss://tracker.openwebtorrent.com",
|
||||
"wss://tracker.btorrent.xyz",
|
||||
@@ -17,6 +18,8 @@ const runtimeFlags = (() => {
|
||||
const initial = {
|
||||
URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED,
|
||||
ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1,
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO:
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
|
||||
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
||||
};
|
||||
if (globalScope) {
|
||||
@@ -92,6 +95,11 @@ export let ACCEPT_LEGACY_V1 = coerceBoolean(
|
||||
DEFAULT_FLAGS.ACCEPT_LEGACY_V1
|
||||
);
|
||||
|
||||
export let VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean(
|
||||
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
|
||||
export let WSS_TRACKERS = freezeTrackers(
|
||||
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS)
|
||||
);
|
||||
@@ -118,6 +126,20 @@ Object.defineProperty(runtimeFlags, "ACCEPT_LEGACY_V1", {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "VIEW_FILTER_INCLUDE_LEGACY_VIDEO", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
|
||||
},
|
||||
set(next) {
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean(
|
||||
next,
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
@@ -132,6 +154,7 @@ Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
|
||||
// Ensure the runtime object reflects the sanitized defaults immediately.
|
||||
runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED;
|
||||
runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1;
|
||||
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
|
||||
runtimeFlags.WSS_TRACKERS = WSS_TRACKERS;
|
||||
|
||||
export function setUrlFirstEnabled(next) {
|
||||
@@ -144,6 +167,11 @@ export function setAcceptLegacyV1(next) {
|
||||
return ACCEPT_LEGACY_V1;
|
||||
}
|
||||
|
||||
export function setViewFilterIncludeLegacyVideo(next) {
|
||||
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = next;
|
||||
return VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
|
||||
}
|
||||
|
||||
export function setWssTrackers(next) {
|
||||
runtimeFlags.WSS_TRACKERS = next;
|
||||
return WSS_TRACKERS;
|
||||
@@ -152,6 +180,9 @@ export function setWssTrackers(next) {
|
||||
export function resetRuntimeFlags() {
|
||||
setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED);
|
||||
setAcceptLegacyV1(DEFAULT_FLAGS.ACCEPT_LEGACY_V1);
|
||||
setViewFilterIncludeLegacyVideo(
|
||||
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
|
||||
);
|
||||
setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS);
|
||||
}
|
||||
|
||||
|
||||
+210
-37
@@ -12,7 +12,10 @@ import {
|
||||
WATCH_HISTORY_CACHE_TTL_MS,
|
||||
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
|
||||
} from "./config.js";
|
||||
import { ACCEPT_LEGACY_V1 } from "./constants.js";
|
||||
import {
|
||||
ACCEPT_LEGACY_V1,
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
|
||||
} from "./constants.js";
|
||||
import { accessControl } from "./accessControl.js";
|
||||
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
|
||||
import { deriveTitleFromEvent, magnetFromText } from "./videoEventUtils.js";
|
||||
@@ -47,6 +50,7 @@ const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE =
|
||||
"Timed out waiting for the NIP-07 extension. Check the extension prompt and try again.";
|
||||
const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
|
||||
const WATCH_HISTORY_CACHE_STORAGE_KEY = "bitvid:watchHistoryCache:v1";
|
||||
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
|
||||
|
||||
const WATCH_HISTORY_INDEX_IDENTIFIER_LOWER =
|
||||
typeof WATCH_HISTORY_LIST_IDENTIFIER === "string"
|
||||
@@ -95,6 +99,31 @@ const LEGACY_WATCH_HISTORY_IDENTIFIER_SET = (() => {
|
||||
return normalized;
|
||||
})();
|
||||
|
||||
const viewEventPublishMemory = new Map();
|
||||
|
||||
let ingestLocalViewEventRef = null;
|
||||
|
||||
async function loadIngestLocalViewEvent() {
|
||||
if (typeof ingestLocalViewEventRef === "function") {
|
||||
return ingestLocalViewEventRef;
|
||||
}
|
||||
try {
|
||||
const module = await import("./viewCounter.js");
|
||||
if (typeof module?.ingestLocalViewEvent === "function") {
|
||||
ingestLocalViewEventRef = module.ingestLocalViewEvent;
|
||||
return ingestLocalViewEventRef;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] Failed to load view counter ingest helper:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveWatchHistoryChunkIdentifier(snapshotId, chunkIndex) {
|
||||
const sanitizedPrefix = WATCH_HISTORY_CHUNK_V2_PREFIX.replace(/\/+$/, "");
|
||||
const safeSnapshot =
|
||||
@@ -541,13 +570,17 @@ function createVideoViewEventFilters(pointer) {
|
||||
pointerFilter["#e"] = [resolved.value];
|
||||
}
|
||||
|
||||
const legacyFilter = {
|
||||
kinds: [WATCH_HISTORY_KIND],
|
||||
"#t": ["view"],
|
||||
"#video": [resolved.value],
|
||||
};
|
||||
const filters = [pointerFilter];
|
||||
|
||||
return { pointer: resolved, filters: [pointerFilter, legacyFilter] };
|
||||
if (VIEW_FILTER_INCLUDE_LEGACY_VIDEO) {
|
||||
filters.push({
|
||||
kinds: [WATCH_HISTORY_KIND],
|
||||
"#t": ["view"],
|
||||
"#video": [resolved.value],
|
||||
});
|
||||
}
|
||||
|
||||
return { pointer: resolved, filters };
|
||||
}
|
||||
|
||||
function deriveViewEventBucketIndex(createdAtSeconds) {
|
||||
@@ -561,28 +594,124 @@ function deriveViewEventBucketIndex(createdAtSeconds) {
|
||||
return Math.floor(timestamp / windowSize);
|
||||
}
|
||||
|
||||
function deriveViewEventDedupTag(actorPubkey, pointer, createdAtSeconds) {
|
||||
if (typeof actorPubkey !== "string") {
|
||||
return null;
|
||||
}
|
||||
function getViewEventGuardWindowMs() {
|
||||
const windowSeconds = Math.max(
|
||||
1,
|
||||
Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0
|
||||
);
|
||||
return windowSeconds * 1000;
|
||||
}
|
||||
|
||||
const normalizedActor = actorPubkey.trim().toLowerCase();
|
||||
if (!normalizedActor) {
|
||||
return null;
|
||||
function deriveViewEventPointerScope(pointer) {
|
||||
const pointerValue =
|
||||
typeof pointer?.value === "string" ? pointer.value.trim().toLowerCase() : "";
|
||||
if (!pointerValue) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const pointerValueRaw =
|
||||
typeof pointer?.value === "string" ? pointer.value.trim() : "";
|
||||
if (!pointerValueRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pointerType = pointer?.type === "a" ? "a" : "e";
|
||||
const pointerValue = pointerValueRaw.toLowerCase();
|
||||
const bucket = deriveViewEventBucketIndex(createdAtSeconds);
|
||||
const scope = pointerType === "a" ? `a:${pointerValue}` : `e:${pointerValue}`;
|
||||
return `${pointerType}:${pointerValue}`;
|
||||
}
|
||||
|
||||
return `bitvid:view:${normalizedActor}:${scope}:${bucket}`;
|
||||
function hasRecentViewPublish(scope, bucketIndex) {
|
||||
if (!scope || !Number.isFinite(bucketIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowMs = getViewEventGuardWindowMs();
|
||||
const now = Date.now();
|
||||
const entry = viewEventPublishMemory.get(scope);
|
||||
|
||||
if (entry) {
|
||||
const age = now - Number(entry.seenAt);
|
||||
if (!Number.isFinite(entry.seenAt) || age >= windowMs) {
|
||||
viewEventPublishMemory.delete(scope);
|
||||
} else if (Number(entry.bucket) === bucketIndex) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
|
||||
let rawValue = null;
|
||||
try {
|
||||
rawValue = localStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to read view guard entry:", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof rawValue !== "string" || !rawValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [storedBucketRaw, storedSeenRaw] = rawValue.split(":", 2);
|
||||
const storedBucket = Number(storedBucketRaw);
|
||||
const storedSeenAt = Number(storedSeenRaw);
|
||||
|
||||
if (!Number.isFinite(storedBucket) || !Number.isFinite(storedSeenAt)) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to clear corrupt view guard entry:", error);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (now - storedSeenAt >= windowMs) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to remove expired view guard entry:", error);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
viewEventPublishMemory.set(scope, {
|
||||
bucket: storedBucket,
|
||||
seenAt: storedSeenAt,
|
||||
});
|
||||
|
||||
return storedBucket === bucketIndex;
|
||||
}
|
||||
|
||||
function rememberViewPublish(scope, bucketIndex) {
|
||||
if (!scope || !Number.isFinite(bucketIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const windowMs = getViewEventGuardWindowMs();
|
||||
const entry = viewEventPublishMemory.get(scope);
|
||||
if (entry && Number.isFinite(entry.seenAt) && now - entry.seenAt >= windowMs) {
|
||||
viewEventPublishMemory.delete(scope);
|
||||
}
|
||||
|
||||
viewEventPublishMemory.set(scope, {
|
||||
bucket: bucketIndex,
|
||||
seenAt: now,
|
||||
});
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
|
||||
try {
|
||||
localStorage.setItem(storageKey, `${bucketIndex}:${now}`);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("[nostr] Failed to persist view guard entry:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isVideoViewEvent(event, pointer) {
|
||||
@@ -598,7 +727,9 @@ function isVideoViewEvent(event, pointer) {
|
||||
let hasViewTag = false;
|
||||
let matchesPointer = false;
|
||||
|
||||
const pointerValue = typeof pointer?.value === "string" ? pointer.value : "";
|
||||
const pointerValueRaw =
|
||||
typeof pointer?.value === "string" ? pointer.value.trim() : "";
|
||||
const pointerValueLower = pointerValueRaw.toLowerCase();
|
||||
const pointerType = pointer?.type === "a" ? "a" : "e";
|
||||
|
||||
for (const tag of tags) {
|
||||
@@ -618,21 +749,34 @@ function isVideoViewEvent(event, pointer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchesPointer || !pointerValue) {
|
||||
if (matchesPointer || !pointerValueLower) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (label === "video" && value === pointerValue) {
|
||||
if (
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO &&
|
||||
label === "video" &&
|
||||
value.toLowerCase() === pointerValueLower
|
||||
) {
|
||||
matchesPointer = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pointerType === "a" && label === "a" && value === pointerValue) {
|
||||
matchesPointer = true;
|
||||
const pointerTag = normalizePointerTag(tag);
|
||||
if (!pointerTag) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pointerType === "e" && label === "e" && value === pointerValue) {
|
||||
const tagValueLower =
|
||||
typeof pointerTag.value === "string"
|
||||
? pointerTag.value.trim().toLowerCase()
|
||||
: "";
|
||||
|
||||
if (!tagValueLower || tagValueLower !== pointerValueLower) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pointerTag.type === pointerType) {
|
||||
matchesPointer = true;
|
||||
continue;
|
||||
}
|
||||
@@ -2582,6 +2726,21 @@ class NostrClient {
|
||||
? Math.floor(options.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const guardScope = deriveViewEventPointerScope(pointer);
|
||||
const guardBucket = deriveViewEventBucketIndex(createdAt);
|
||||
if (guardScope && hasRecentViewPublish(guardScope, guardBucket)) {
|
||||
if (isDevMode) {
|
||||
console.info("[nostr] Skipping duplicate view publish for scope", guardScope);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
duplicate: true,
|
||||
event: null,
|
||||
results: [],
|
||||
acceptedRelays: [],
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedActor =
|
||||
typeof actorPubkey === "string" ? actorPubkey.toLowerCase() : "";
|
||||
const normalizedLogged =
|
||||
@@ -2604,11 +2763,6 @@ class NostrClient {
|
||||
? ["e", pointer.value, pointer.relay]
|
||||
: ["e", pointer.value];
|
||||
|
||||
const dedupeTagValue = deriveViewEventDedupTag(
|
||||
actorPubkey,
|
||||
pointer,
|
||||
createdAt
|
||||
);
|
||||
const content =
|
||||
typeof options.content === "string" ? options.content : "";
|
||||
|
||||
@@ -2617,7 +2771,6 @@ class NostrClient {
|
||||
created_at: createdAt,
|
||||
pointerValue: pointer.value,
|
||||
pointerTag,
|
||||
dedupeTag: dedupeTagValue,
|
||||
includeSessionTag: usingSessionActor,
|
||||
additionalTags,
|
||||
content,
|
||||
@@ -2670,6 +2823,9 @@ class NostrClient {
|
||||
.filter((url) => typeof url === "string" && url);
|
||||
const success = acceptedRelays.length > 0;
|
||||
if (success) {
|
||||
if (guardScope) {
|
||||
rememberViewPublish(guardScope, guardBucket);
|
||||
}
|
||||
console.info(
|
||||
`[nostr] View event accepted by ${acceptedRelays.length} relay(s):`,
|
||||
acceptedRelays.join(", ")
|
||||
@@ -4265,6 +4421,23 @@ class NostrClient {
|
||||
}
|
||||
|
||||
const view = await this.publishViewEvent(pointer, options);
|
||||
|
||||
if (view?.ok && view.event) {
|
||||
try {
|
||||
const ingest = await loadIngestLocalViewEvent();
|
||||
if (typeof ingest === "function") {
|
||||
ingest({ event: view.event, pointer });
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] Failed to ingest optimistic view event:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const history = await this.updateWatchHistoryList(pointer);
|
||||
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user