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
30 changes: 29 additions & 1 deletion website/lib/download-stats-core.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export const INITIAL_GITHUB_DOWNLOADS = 24;
export const DIRECT_EVENT_PREFIX = "events/direct/";
export const DIRECT_EVENT_SLOT_COUNT = 1_024;
export const DIRECT_EVENT_ESTIMATE_CAP = 4_096;
export const INITIAL_GITHUB_ASSET_LEDGER = Object.freeze({
"490267983": 4,
"490069831": 10,
Expand Down Expand Up @@ -79,7 +81,24 @@ export function directEventKey(timestamp, uuid) {
if (!Number.isFinite(timestamp)) throw new Error("Invalid direct-download event timestamp");
if (typeof uuid !== "string" || uuid.length === 0) throw new Error("Invalid direct-download event id");
const day = new Date(timestamp).toISOString().slice(0, 10);
return `${DIRECT_EVENT_PREFIX}${day}/${timestamp}-${uuid}.event`;
const slot = stableHash32(uuid) % DIRECT_EVENT_SLOT_COUNT;
return `${DIRECT_EVENT_PREFIX}${day}/slot-${String(slot).padStart(4, "0")}.event`;
}

export function estimateDirectEventSlotCount(occupiedSlots) {
if (
!Number.isSafeInteger(occupiedSlots)
|| occupiedSlots < 0
|| occupiedSlots > DIRECT_EVENT_SLOT_COUNT
) {
throw new Error("Invalid direct-download slot count");
}
if (occupiedSlots === 0) return 0;
if (occupiedSlots === DIRECT_EVENT_SLOT_COUNT) return DIRECT_EVENT_ESTIMATE_CAP;
const estimate = Math.round(
-DIRECT_EVENT_SLOT_COUNT * Math.log1p(-occupiedSlots / DIRECT_EVENT_SLOT_COUNT),
);
return Math.min(DIRECT_EVENT_ESTIMATE_CAP, Math.max(occupiedSlots, estimate));
}

export function normalizeAssetLedger(value) {
Expand All @@ -90,3 +109,12 @@ export function normalizeAssetLedger(value) {
}
return counts;
}

function stableHash32(value) {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
49 changes: 40 additions & 9 deletions website/lib/download-stats-service.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
collectStableDmgAssetCounts,
DIRECT_EVENT_SLOT_COUNT,
directEventKey,
DIRECT_EVENT_PREFIX,
estimateDirectEventSlotCount,
githubDownloadTotal,
mergeAssetLedger,
mergePublicStats,
Expand All @@ -13,6 +15,7 @@ export const DOWNLOAD_STATS_STORE = "quithide-download-stats";
export const GITHUB_LEDGER_KEY = "state/github-assets.json";
export const PUBLIC_STATS_KEY = "public/download-stats.json";
export const DIRECT_DAILY_PREFIX = "daily/direct/";
export const REFRESH_ATTEMPT_PREFIX = "refresh/attempts/";
export const MINIMUM_REFRESH_INTERVAL_MS = 20 * 60 * 60 * 1000;

const GITHUB_RELEASES_API = "https://api.github.com/repos/jiangsir-tech/QuitHide/releases";
Expand Down Expand Up @@ -74,6 +77,7 @@ export async function refreshDownloadStats({
const timestamp = now();
const previousStats = await readPublicStats(store);
if (isFresh(previousStats.updatedAt, timestamp)) return previousStats;
if (!await acquireRefreshAttempt(store, timestamp)) return previousStats;

const [ledgerResult, githubResult, directResult] = await Promise.allSettled([
readJSON(store, GITHUB_LEDGER_KEY),
Expand Down Expand Up @@ -143,18 +147,18 @@ export async function aggregateDirectDownloadEvents({ store, timestamp }) {
const dailyCounts = await readDailyCounts(store, dailyListing?.blobs);
const archiveBeforeDay = new Date(timestamp - (2 * DAY_MS)).toISOString().slice(0, 10);

for (const [day, keys] of eventsByDay) {
for (const [day, group] of eventsByDay) {
if (day >= archiveBeforeDay) continue;
if (!dailyCounts.has(day)) {
const count = await createOrReadDailyCount(store, day, keys.length);
const count = await createOrReadDailyCount(store, day, directEventCount(group));
dailyCounts.set(day, count);
}
await deleteBestEffort(store, keys);
await deleteBestEffort(store, group.keys);
}

let direct = [...dailyCounts.values()].reduce((sum, count) => sum + count, 0);
for (const [day, keys] of eventsByDay) {
if (!dailyCounts.has(day)) direct += keys.length;
for (const [day, group] of eventsByDay) {
if (!dailyCounts.has(day)) direct += directEventCount(group);
}
return direct;
}
Expand All @@ -173,18 +177,45 @@ function isFresh(updatedAt, timestamp) {
return Number.isFinite(previous) && timestamp - previous < MINIMUM_REFRESH_INTERVAL_MS;
}

async function acquireRefreshAttempt(store, timestamp) {
const window = Math.floor(timestamp / MINIMUM_REFRESH_INTERVAL_MS);
const key = `${REFRESH_ATTEMPT_PREFIX}${window}.lock`;
try {
await store.set(key, "", { onlyIfNew: true, cacheControl: null });
return true;
} catch (error) {
if (error?.code === "PRECONDITION_FAILED") return false;
throw error;
}
}

function groupEventKeysByDay(blobs) {
const values = new Map();
for (const blob of Array.isArray(blobs) ? blobs : []) {
const match = blob?.key?.match(/^events\/direct\/(\d{4}-\d{2}-\d{2})\//);
const match = blob?.key?.match(/^events\/direct\/(\d{4}-\d{2}-\d{2})\/(.+)$/);
if (!match) continue;
const keys = values.get(match[1]) ?? [];
keys.push(blob.key);
values.set(match[1], keys);
const group = values.get(match[1]) ?? {
keys: [],
legacyCount: 0,
slots: new Set(),
};
group.keys.push(blob.key);
const slotMatch = match[2].match(/^slot-(\d{4})\.event$/);
const slot = slotMatch ? Number(slotMatch[1]) : Number.NaN;
if (Number.isSafeInteger(slot) && slot >= 0 && slot < DIRECT_EVENT_SLOT_COUNT) {
group.slots.add(slot);
} else {
group.legacyCount += 1;
}
values.set(match[1], group);
}
return values;
}

function directEventCount(group) {
return group.legacyCount + estimateDirectEventSlotCount(group.slots.size);
}

async function readDailyCounts(store, blobs) {
const values = new Map();
for (const blob of Array.isArray(blobs) ? blobs : []) {
Expand Down
33 changes: 29 additions & 4 deletions website/tests/download-stats-core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
collectStableDmgAssetCounts,
DIRECT_EVENT_ESTIMATE_CAP,
DIRECT_EVENT_SLOT_COUNT,
directEventKey,
estimateDirectEventSlotCount,
githubDownloadTotal,
INITIAL_GITHUB_ASSET_LEDGER,
initialDownloadStats,
Expand Down Expand Up @@ -129,12 +132,34 @@ test("stored data is normalized to safe monotonic defaults", () => {
}), initialDownloadStats());
});

test("direct event keys use the UTC day and unique event id", () => {
test("direct event keys use bounded daily slots instead of attacker-proportional objects", () => {
const timestamp = Date.parse("2026-07-26T23:59:58.123Z");
assert.equal(
directEventKey(timestamp, "event-123"),
`events/direct/2026-07-26/${timestamp}-event-123.event`,
const keys = new Set(
Array.from({ length: DIRECT_EVENT_SLOT_COUNT * 10 }, (_, index) => (
directEventKey(timestamp, `event-${index}`)
)),
);

assert.ok(keys.size <= DIRECT_EVENT_SLOT_COUNT);
assert.ok([...keys].every((key) => (
/^events\/direct\/2026-07-26\/slot-\d{4}\.event$/.test(key)
)));
assert.throws(() => directEventKey(Number.NaN, "event-123"), /timestamp/);
assert.throws(() => directEventKey(timestamp, ""), /event id/);
});

test("bounded direct-event slots produce a capped monotonic count estimate", () => {
assert.equal(estimateDirectEventSlotCount(0), 0);
assert.equal(estimateDirectEventSlotCount(1), 1);
assert.ok(estimateDirectEventSlotCount(100) >= 100);
assert.ok(estimateDirectEventSlotCount(100) < 120);
assert.equal(
estimateDirectEventSlotCount(DIRECT_EVENT_SLOT_COUNT),
DIRECT_EVENT_ESTIMATE_CAP,
);
assert.throws(() => estimateDirectEventSlotCount(-1), /slot count/);
assert.throws(
() => estimateDirectEventSlotCount(DIRECT_EVENT_SLOT_COUNT + 1),
/slot count/,
);
});
Loading