diff --git a/tests/bonanza-review-hardening.test.mjs b/tests/bonanza-review-hardening.test.mjs index 0879c8d6..59a88425 100644 --- a/tests/bonanza-review-hardening.test.mjs +++ b/tests/bonanza-review-hardening.test.mjs @@ -9,7 +9,7 @@ const sourcePath = new URL( const source = readFileSync(sourcePath, "utf8"); test("review hardening invariants stay present", () => { - assert.match(source, /^\/\/ @version\s+1\.5\.11$/m); + assert.match(source, /^\/\/ @version\s+1\.5\.12$/m); assert.doesNotMatch(source, /pollChatFallback/); assert.doesNotMatch(source, /onlyguardians/i); assert.match(source, /async function getLatestMainChatReplayBoundary\(\)/); @@ -28,6 +28,26 @@ test("review hardening invariants stay present", () => { ); }); +test("BON gift notification cleanup is scoped and non-blocking", () => { + assert.match(source, /async function markGiveawayBonNotificationsRead\(\{ hostName, startTs, endTs \} = \{\}\)/); + assert.match(source, /giftNotificationOverlapsWindow\(notification, start, end\)/); + assert.match(source, /const ts = Number\.isFinite\(utcTs\) \? utcTs : localTs/); + assert.match(source, /return \(ts \+ resolutionMs\) >= start && ts <= end/); + assert.doesNotMatch(source, /ts <= \(end \+ resolutionMs\)/); + assert.match(source, /input\[name="_method"\][\s\S]*?PATCH/); + assert.match(source, /actionUrl\.pathname\.startsWith\(notificationsPath \+ "\/"\)/); + assert.doesNotMatch(source, /notifications\/mass-update/); + + const completion = source.indexOf('giveawayData.settlement.phase = "complete"'); + const stop = source.indexOf("const stopped = stopGiveaway()", completion); + const cleanup = source.indexOf("void markGiveawayBonNotificationsRead(notificationCleanupContext)", stop); + assert.ok(completion >= 0 && stop > completion && cleanup > stop); + assert.doesNotMatch( + source.slice(stop, cleanup + 100), + /await\s+markGiveawayBonNotificationsRead/ + ); +}); + test("public update metadata is split from the install payload", () => { const header = source.match(/\/\/ ==UserScript==[\s\S]*?\/\/ ==\/UserScript==/)?.[0] || ""; diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 2d74421a..5d378b75 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -2,7 +2,7 @@ // @name DarkPeers BONanza Giveaway | Maghuro Fork // @namespace https://darkpeers.org/users/maghuro // @description BON giveaways on DarkPeers with an optional direct contribution to the BON Pool -// @version 1.5.11 +// @version 1.5.12 // @author 🤖 T.R.A.V.I.S., Maghuro & M.A.E.S.T.R.O. // @homepageURL https://darkpeers.org/users/maghuro // @updateURL https://gist.githubusercontent.com/maghuro/da2dbfec94951990cbc54e75a9aee318/raw/DarkPeers_BONanza_Giveaway.meta.js @@ -231,6 +231,11 @@ // explicitly, preserve that status through finalization, and infer/sanitize // rehearsal-only v1.5.9 statements so dry-run success cannot be mistaken for // proof of a real BON movement. +// - v1.5.12 adds best-effort post-settlement cleanup for unread BON gift +// notifications received during the giveaway window. It reuses UNIT3D's own +// per-notification CSRF-protected PATCH forms, never mass-marks unrelated +// notifications, and runs fire-and-forget so cleanup failure cannot block or +// roll back giveaway settlement. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. @@ -5008,6 +5013,27 @@ body.host-panel-dragging * { const rawNote = String(bodyMatch[3] || "").replace(/\s+/g, " ").trim(); const message = /^no note$/i.test(rawNote) ? "" : rawNote; + // UNIT3D renders one CSRF-protected PATCH form per notification. + // Preserve the form's own action/hidden fields so cleanup can mirror + // the site's supported "mark read" operation instead of guessing an API. + const markReadForm = Array.from(row.querySelectorAll('form[method="POST"], form[method="post"]')) + .find(form => + String(form.querySelector('input[name="_method"]')?.value || "").toUpperCase() === "PATCH" + ) || null; + const markReadButton = markReadForm?.querySelector("button"); + const markReadAction = String(markReadForm?.getAttribute("action") || "").trim(); + const markReadFields = markReadForm + ? Array.from(markReadForm.querySelectorAll("input[name]")) + .map(input => [ + String(input.getAttribute("name") || "").trim(), + String(input.value ?? "") + ]) + .filter(([name]) => !!name) + : []; + const unread = + cells[0].classList.contains("notification--unread") || + (!!markReadButton && !markReadButton.disabled); + return { sender, recipient: host, @@ -5015,7 +5041,10 @@ body.host-panel-dragging * { message, rawTimestamp, createdAtTs, - createdAtAltTs + createdAtAltTs, + unread, + markReadAction, + markReadFields }; }) .filter(item => @@ -5026,6 +5055,139 @@ body.host-panel-dragging * { ); } + function giftNotificationOverlapsWindow(notification, startTs, endTs) { + const start = Number(startTs); + const end = Number(endTs); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return false; + + // DarkPeers notification datetimes are rendered without an explicit zone, + // but the existing clock-calibration path treats them as UTC-scale event + // time. Prefer that interpretation and only fall back to browser-local time + // if the UTC parse is unavailable. + const utcTs = Number(notification?.createdAtAltTs); + const localTs = Number(notification?.createdAtTs); + const ts = Number.isFinite(utcTs) ? utcTs : localTs; + if (!Number.isFinite(ts)) return false; + + // Timestamp precision describes the notification's own represented interval. + // Never extend the giveaway's upper cutoff, otherwise a late gift can be + // swept into the just-finished giveaway. + const resolutionMs = unit3dTimestampResolutionMs(notification?.rawTimestamp); + return (ts + resolutionMs) >= start && ts <= end; + } + + async function markGiveawayBonNotificationsRead({ hostName, startTs, endTs } = {}) { + // Cleanup is deliberately outside settlement correctness. It never runs in + // rehearsal mode and every failure is absorbed by this helper/caller. + if (REHEARSAL_MODE) { + return { matched: 0, marked: 0, failed: 0, skipped: "rehearsal" }; + } + + const host = String(hostName || "").trim(); + const start = Number(startTs); + const end = Number(endTs); + const senderSlug = getAuthenticatedUserSlug(); + if ( + !host || + !senderSlug || + !Number.isFinite(start) || + !Number.isFinite(end) || + end < start + ) { + return { matched: 0, marked: 0, failed: 0, skipped: "invalid-context" }; + } + + const notificationsPath = `/users/${encodeURIComponent(decodeURIComponent(senderSlug))}/notifications`; + const targetsByAction = new Map(); + const maxPages = 6; + + for (let page = 1; page <= maxPages; page++) { + try { + const notificationsUrl = new URL(notificationsPath, location.origin); + if (page > 1) notificationsUrl.searchParams.set("page", String(page)); + notificationsUrl.searchParams.set("_dpgw_cleanup", String(Date.now())); + + const res = await fetchWithTimeout( + notificationsUrl, + { + credentials: "same-origin", + cache: "no-store" + }, + 5000 + ); + if (!res?.ok) continue; + + const rows = parseGiftNotificationsPage(await res.text(), host); + for (const notification of rows) { + if (!notification.unread) continue; + if (!notification.markReadAction || !notification.markReadFields?.length) continue; + if (!giftNotificationOverlapsWindow(notification, start, end)) continue; + + let actionUrl; + try { + actionUrl = new URL(notification.markReadAction, location.origin); + } catch { + continue; + } + + // Never POST a parsed form away from DarkPeers or outside this + // authenticated user's notification routes. + if (actionUrl.origin !== location.origin) continue; + if (!actionUrl.pathname.startsWith(notificationsPath + "/")) continue; + + targetsByAction.set(actionUrl.href, { + actionUrl, + fields: notification.markReadFields + }); + } + } catch (e) { + console.warn(`[BON Giveaway] Notification cleanup page ${page} skipped:`, e); + } + } + + const targets = Array.from(targetsByAction.values()); + let marked = 0; + let failed = 0; + const concurrency = 4; + + for (let offset = 0; offset < targets.length; offset += concurrency) { + const batch = targets.slice(offset, offset + concurrency); + const results = await Promise.allSettled(batch.map(async target => { + const body = new URLSearchParams(); + for (const [name, value] of target.fields) body.append(name, value); + + const res = await fetchWithTimeout( + target.actionUrl, + { + method: "POST", + credentials: "same-origin", + cache: "no-store", + redirect: "follow", + headers: { + "Accept": "text/html", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + body: body.toString() + }, + 5000 + ); + if (!res?.ok) { + throw new Error(`HTTP ${res?.status || "unknown"}`); + } + })); + + for (const result of results) { + if (result.status === "fulfilled") marked += 1; + else failed += 1; + } + } + + console.info( + `[BON Giveaway] BON notification cleanup: matched=${targets.length}, marked=${marked}, failed=${failed}` + ); + return { matched: targets.length, marked, failed }; + } + function giftHistoryBaseKey(item) { const sender = normalizeUserKey(item?.sender); const recipient = normalizeUserKey(item?.recipient); @@ -9446,7 +9608,21 @@ body.host-panel-dragging * { giveawayData.settlement.completedAt = Date.now(); snapshotGiveaway({ force: true }); } - stopGiveaway(); + + // Capture the notification window before stopGiveaway() clears runtime + // state. Cleanup starts only after settlement is terminal and is explicitly + // fire-and-forget: notification failures can never block or roll back BON + // transfers, verification, statements, stats, or snapshot retirement. + const notificationCleanupContext = { + hostName: giveawayData?.host || "", + startTs: giveawayStartTime instanceof Date ? giveawayStartTime.getTime() : null, + endTs: settlementCutoffTs + }; + const stopped = stopGiveaway(); + if (stopped) { + void markGiveawayBonNotificationsRead(notificationCleanupContext) + .catch(e => console.warn("[BON Giveaway] BON notification cleanup failed:", e)); + } } function clearWinnersStatusUI() { diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index 310ae887..7a887730 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -2,7 +2,7 @@ // @name DarkPeers BONanza Giveaway | Maghuro Fork // @namespace https://darkpeers.org/users/maghuro // @description BON giveaways on DarkPeers with an optional direct contribution to the BON Pool -// @version 1.5.11 +// @version 1.5.12 // @author 🤖 T.R.A.V.I.S., Maghuro & M.A.E.S.T.R.O. // @homepageURL https://darkpeers.org/users/maghuro // @updateURL https://gist.githubusercontent.com/maghuro/da2dbfec94951990cbc54e75a9aee318/raw/DarkPeers_BONanza_Giveaway.meta.js @@ -231,6 +231,11 @@ // explicitly, preserve that status through finalization, and infer/sanitize // rehearsal-only v1.5.9 statements so dry-run success cannot be mistaken for // proof of a real BON movement. +// - v1.5.12 adds best-effort post-settlement cleanup for unread BON gift +// notifications received during the giveaway window. It reuses UNIT3D's own +// per-notification CSRF-protected PATCH forms, never mass-marks unrelated +// notifications, and runs fire-and-forget so cleanup failure cannot block or +// roll back giveaway settlement. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. diff --git a/userscripts/giveaway/src/09-sponsorship-polling.js b/userscripts/giveaway/src/09-sponsorship-polling.js index 89ee8648..9e4384fc 100644 --- a/userscripts/giveaway/src/09-sponsorship-polling.js +++ b/userscripts/giveaway/src/09-sponsorship-polling.js @@ -148,6 +148,27 @@ const rawNote = String(bodyMatch[3] || "").replace(/\s+/g, " ").trim(); const message = /^no note$/i.test(rawNote) ? "" : rawNote; + // UNIT3D renders one CSRF-protected PATCH form per notification. + // Preserve the form's own action/hidden fields so cleanup can mirror + // the site's supported "mark read" operation instead of guessing an API. + const markReadForm = Array.from(row.querySelectorAll('form[method="POST"], form[method="post"]')) + .find(form => + String(form.querySelector('input[name="_method"]')?.value || "").toUpperCase() === "PATCH" + ) || null; + const markReadButton = markReadForm?.querySelector("button"); + const markReadAction = String(markReadForm?.getAttribute("action") || "").trim(); + const markReadFields = markReadForm + ? Array.from(markReadForm.querySelectorAll("input[name]")) + .map(input => [ + String(input.getAttribute("name") || "").trim(), + String(input.value ?? "") + ]) + .filter(([name]) => !!name) + : []; + const unread = + cells[0].classList.contains("notification--unread") || + (!!markReadButton && !markReadButton.disabled); + return { sender, recipient: host, @@ -155,7 +176,10 @@ message, rawTimestamp, createdAtTs, - createdAtAltTs + createdAtAltTs, + unread, + markReadAction, + markReadFields }; }) .filter(item => @@ -166,6 +190,139 @@ ); } + function giftNotificationOverlapsWindow(notification, startTs, endTs) { + const start = Number(startTs); + const end = Number(endTs); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return false; + + // DarkPeers notification datetimes are rendered without an explicit zone, + // but the existing clock-calibration path treats them as UTC-scale event + // time. Prefer that interpretation and only fall back to browser-local time + // if the UTC parse is unavailable. + const utcTs = Number(notification?.createdAtAltTs); + const localTs = Number(notification?.createdAtTs); + const ts = Number.isFinite(utcTs) ? utcTs : localTs; + if (!Number.isFinite(ts)) return false; + + // Timestamp precision describes the notification's own represented interval. + // Never extend the giveaway's upper cutoff, otherwise a late gift can be + // swept into the just-finished giveaway. + const resolutionMs = unit3dTimestampResolutionMs(notification?.rawTimestamp); + return (ts + resolutionMs) >= start && ts <= end; + } + + async function markGiveawayBonNotificationsRead({ hostName, startTs, endTs } = {}) { + // Cleanup is deliberately outside settlement correctness. It never runs in + // rehearsal mode and every failure is absorbed by this helper/caller. + if (REHEARSAL_MODE) { + return { matched: 0, marked: 0, failed: 0, skipped: "rehearsal" }; + } + + const host = String(hostName || "").trim(); + const start = Number(startTs); + const end = Number(endTs); + const senderSlug = getAuthenticatedUserSlug(); + if ( + !host || + !senderSlug || + !Number.isFinite(start) || + !Number.isFinite(end) || + end < start + ) { + return { matched: 0, marked: 0, failed: 0, skipped: "invalid-context" }; + } + + const notificationsPath = `/users/${encodeURIComponent(decodeURIComponent(senderSlug))}/notifications`; + const targetsByAction = new Map(); + const maxPages = 6; + + for (let page = 1; page <= maxPages; page++) { + try { + const notificationsUrl = new URL(notificationsPath, location.origin); + if (page > 1) notificationsUrl.searchParams.set("page", String(page)); + notificationsUrl.searchParams.set("_dpgw_cleanup", String(Date.now())); + + const res = await fetchWithTimeout( + notificationsUrl, + { + credentials: "same-origin", + cache: "no-store" + }, + 5000 + ); + if (!res?.ok) continue; + + const rows = parseGiftNotificationsPage(await res.text(), host); + for (const notification of rows) { + if (!notification.unread) continue; + if (!notification.markReadAction || !notification.markReadFields?.length) continue; + if (!giftNotificationOverlapsWindow(notification, start, end)) continue; + + let actionUrl; + try { + actionUrl = new URL(notification.markReadAction, location.origin); + } catch { + continue; + } + + // Never POST a parsed form away from DarkPeers or outside this + // authenticated user's notification routes. + if (actionUrl.origin !== location.origin) continue; + if (!actionUrl.pathname.startsWith(notificationsPath + "/")) continue; + + targetsByAction.set(actionUrl.href, { + actionUrl, + fields: notification.markReadFields + }); + } + } catch (e) { + console.warn(`[BON Giveaway] Notification cleanup page ${page} skipped:`, e); + } + } + + const targets = Array.from(targetsByAction.values()); + let marked = 0; + let failed = 0; + const concurrency = 4; + + for (let offset = 0; offset < targets.length; offset += concurrency) { + const batch = targets.slice(offset, offset + concurrency); + const results = await Promise.allSettled(batch.map(async target => { + const body = new URLSearchParams(); + for (const [name, value] of target.fields) body.append(name, value); + + const res = await fetchWithTimeout( + target.actionUrl, + { + method: "POST", + credentials: "same-origin", + cache: "no-store", + redirect: "follow", + headers: { + "Accept": "text/html", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + body: body.toString() + }, + 5000 + ); + if (!res?.ok) { + throw new Error(`HTTP ${res?.status || "unknown"}`); + } + })); + + for (const result of results) { + if (result.status === "fulfilled") marked += 1; + else failed += 1; + } + } + + console.info( + `[BON Giveaway] BON notification cleanup: matched=${targets.length}, marked=${marked}, failed=${failed}` + ); + return { matched: targets.length, marked, failed }; + } + function giftHistoryBaseKey(item) { const sender = normalizeUserKey(item?.sender); const recipient = normalizeUserKey(item?.recipient); diff --git a/userscripts/giveaway/src/11-winner-selection-payouts.js b/userscripts/giveaway/src/11-winner-selection-payouts.js index d7524702..b93208bb 100644 --- a/userscripts/giveaway/src/11-winner-selection-payouts.js +++ b/userscripts/giveaway/src/11-winner-selection-payouts.js @@ -1708,7 +1708,21 @@ giveawayData.settlement.completedAt = Date.now(); snapshotGiveaway({ force: true }); } - stopGiveaway(); + + // Capture the notification window before stopGiveaway() clears runtime + // state. Cleanup starts only after settlement is terminal and is explicitly + // fire-and-forget: notification failures can never block or roll back BON + // transfers, verification, statements, stats, or snapshot retirement. + const notificationCleanupContext = { + hostName: giveawayData?.host || "", + startTs: giveawayStartTime instanceof Date ? giveawayStartTime.getTime() : null, + endTs: settlementCutoffTs + }; + const stopped = stopGiveaway(); + if (stopped) { + void markGiveawayBonNotificationsRead(notificationCleanupContext) + .catch(e => console.warn("[BON Giveaway] BON notification cleanup failed:", e)); + } } function clearWinnersStatusUI() {