Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion tests/bonanza-review-hardening.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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\(\)/);
Expand All @@ -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] || "";

Expand Down
182 changes: 179 additions & 3 deletions userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -5008,14 +5013,38 @@ 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,
amount: bodyAmount,
message,
rawTimestamp,
createdAtTs,
createdAtAltTs
createdAtAltTs,
unread,
markReadAction,
markReadFields
};
})
.filter(item =>
Expand All @@ -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);
Expand Down Expand Up @@ -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() {
Expand Down
7 changes: 6 additions & 1 deletion userscripts/giveaway/src/00-preamble.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
maghuro marked this conversation as resolved.
// @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
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading