Skip to content
Open
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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 51 additions & 0 deletions functions/api/donors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@

// placeholders - for now anyway - when real data loads, these going to disappear
// Monthly donors appear first; within each group sorted by recency

const PLACEHOLDERS_DONORS = [
/* so many to test if:
- It works
- How it behaves if there are 5< users
- If code breaks when it runs out of space (this is unlikely since there is so many lines) [auto suggested by VsCode auto completion yay]
- if monthly users are correctly placed before one-time donors
- to write useless memes
*/
{ name: 'Samidy', type: 'monthly', timestamp: '2026-04-29T10:00:00Z' },
{ name: 'Binimum', type: 'monthly', timestamp: '2026-04-20T09:00:00Z' },
{ name: 'John Monochrome', type: 'once', timestamp: '2026-04-27T15:00:00Z' },
{ name: 'Chroma', type: 'monthly', timestamp: '2026-04-25T12:00:00Z' },
{ name: 'Israel', type: 'once', timestamp: '2026-04-18T08:00:00Z' },
{ name: 'Tidal', type: 'once', timestamp: '2026-04-18T08:00:00Z' },
{ name: 'Kasane Teto (i think thats how you write her name)', type: 'monthly', timestamp: '2026-04-18T08:00:00Z' },
];

const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
};

export async function onRequestOptions() {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}

export async function onRequestGet(context) {
const { env } = context;

let donors = PLACEHOLDERS_DONORS;

if (env.DONORS_KV) {
const stored = await env.DONORS_KV.get('donors').catch(() => null);
if (stored) donors = JSON.parse(stored);
}
Comment on lines +37 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard KV JSON parsing to prevent endpoint-wide failure on malformed data.

JSON.parse(stored) can throw and currently bubbles out of onRequestGet, which turns donor list fetches into 500s.

Suggested fix
-    if (env.DONORS_KV) {
-        const stored = await env.DONORS_KV.get('donors').catch(() => null);
-        if (stored) donors = JSON.parse(stored);
-    }
+    if (env.DONORS_KV) {
+        const stored = await env.DONORS_KV.get('donors').catch(() => null);
+        if (stored) {
+            try {
+                const parsed = JSON.parse(stored);
+                if (Array.isArray(parsed)) donors = parsed;
+            } catch {
+                donors = PLACEHOLDERS_DONORS;
+            }
+        }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/api/donors.js` around lines 37 - 39, The KV value parsing can throw
and crash the onRequestGet handler; wrap the JSON.parse(stored) call (when using
env.DONORS_KV.get and assigning to donors) in a try/catch so malformed JSON
doesn't bubble up—on parse error fall back to a safe default (e.g., [] or
previously initialized donors) and optionally log the parse error; update the
block that reads from env.DONORS_KV.get and assigns donors to handle this
failure path safely.


donors.sort((a, b) => {
if (a.type === 'monthly' && b.type !== 'monthly') return -1;
if (a.type !== 'monthly' && b.type === 'monthly') return 1;
return new Date(b.timestamp) - new Date(a.timestamp);
});

return new Response(JSON.stringify(donors), {
status: 200,
headers: { ...CORS_HEADERS, 'Cache-Control': 'public, max-age=60' },
});
}
79 changes: 79 additions & 0 deletions functions/api/kofi-webhook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Ko-fi webhook handler | by uzif (God i fucking love claude)


// how to run this stupid shit:
// Configure the webhook URL in your Ko-fi settings: https://ko-fi.com/manage/webhooks
// Set the URL to: https://monochrome.tf/api/kofi-webhook
// Set KOFI_VERIFICATION_TOKEN in your Cloudflare Pages environment variables
// Bind a KV namespace named DONORS_KV in your Cloudflare Pages settings


const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
};

export async function onRequestOptions() {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}

export async function onRequestPost(context) {
const { request, env } = context;

try {
const formData = await request.formData();
const raw = formData.get('data');
if (!raw) {
return new Response(JSON.stringify({ error: 'Missing data' }), {
status: 400,
headers: CORS_HEADERS,
});
}

const donation = JSON.parse(raw);

if (env.KOFI_VERIFICATION_TOKEN && donation.verification_token !== env.KOFI_VERIFICATION_TOKEN) {
return new Response(JSON.stringify({ error: 'Invalid verification token' }), {
status: 401,
headers: CORS_HEADERS,
});
}
Comment on lines +35 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Webhook auth is fail-open when KOFI_VERIFICATION_TOKEN is unset.

If the env var is missing, any caller can write donor records. This should fail closed for production safety.

Suggested fix
+        if (!env.KOFI_VERIFICATION_TOKEN) {
+            return new Response(JSON.stringify({ error: 'Webhook verification token is not configured' }), {
+                status: 503,
+                headers: CORS_HEADERS,
+            });
+        }
-        if (env.KOFI_VERIFICATION_TOKEN && donation.verification_token !== env.KOFI_VERIFICATION_TOKEN) {
+        if (donation.verification_token !== env.KOFI_VERIFICATION_TOKEN) {
             return new Response(JSON.stringify({ error: 'Invalid verification token' }), {
                 status: 401,
                 headers: CORS_HEADERS,
             });
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (env.KOFI_VERIFICATION_TOKEN && donation.verification_token !== env.KOFI_VERIFICATION_TOKEN) {
return new Response(JSON.stringify({ error: 'Invalid verification token' }), {
status: 401,
headers: CORS_HEADERS,
});
}
if (!env.KOFI_VERIFICATION_TOKEN) {
return new Response(JSON.stringify({ error: 'Webhook verification token is not configured' }), {
status: 503,
headers: CORS_HEADERS,
});
}
if (donation.verification_token !== env.KOFI_VERIFICATION_TOKEN) {
return new Response(JSON.stringify({ error: 'Invalid verification token' }), {
status: 401,
headers: CORS_HEADERS,
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/api/kofi-webhook.js` around lines 35 - 40, The webhook currently
allows requests through when env.KOFI_VERIFICATION_TOKEN is unset, so change the
auth check in functions/api/kofi-webhook.js to fail closed by rejecting requests
if the token env var is missing or if donation.verification_token does not
match: update the condition around the existing verification block (the code
referencing env.KOFI_VERIFICATION_TOKEN and donation.verification_token) to
return a 401 (and log or include an explanatory error) when
env.KOFI_VERIFICATION_TOKEN is falsy OR the tokens don't match so no requests
are accepted when the expected token isn't configured.


if (!donation.is_public) {
return new Response(JSON.stringify({ ok: true, skipped: 'private' }), {
status: 200,
headers: CORS_HEADERS,
});
}

const donor = {
name: donation.from_name || 'Anonymous',
type: donation.is_subscription_payment ? 'monthly' : 'once',
timestamp: donation.timestamp || new Date().toISOString(),
};

if (!env.DONORS_KV) {
return new Response(JSON.stringify({ ok: true, stored: false, reason: 'KV not configured' }), {
status: 200,
headers: CORS_HEADERS,
});
}

const existing = JSON.parse((await env.DONORS_KV.get('donors').catch(() => null)) || '[]');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Protect against malformed KV payloads when parsing existing donors.

This parse can throw and drop valid webhook events with a 500 response.

Suggested fix
-        const existing = JSON.parse((await env.DONORS_KV.get('donors').catch(() => null)) || '[]');
+        const rawExisting = (await env.DONORS_KV.get('donors').catch(() => null)) || '[]';
+        let existing = [];
+        try {
+            const parsed = JSON.parse(rawExisting);
+            if (Array.isArray(parsed)) existing = parsed;
+        } catch {
+            existing = [];
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const existing = JSON.parse((await env.DONORS_KV.get('donors').catch(() => null)) || '[]');
const rawExisting = (await env.DONORS_KV.get('donors').catch(() => null)) || '[]';
let existing = [];
try {
const parsed = JSON.parse(rawExisting);
if (Array.isArray(parsed)) existing = parsed;
} catch {
existing = [];
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/api/kofi-webhook.js` at line 62, Wrap the JSON.parse of the KV
payload in a safe try/catch so a malformed value in env.DONORS_KV.get('donors')
doesn't throw and return 500; specifically, replace the direct parse used to set
the existing variable with logic that fetches the string with
env.DONORS_KV.get('donors'), checks for null/undefined, then attempts JSON.parse
inside a try/catch (on error set existing = [] and log the parse error) so the
webhook processing continues. Ensure you update the code path that uses the
existing variable (the same scope where existing is declared) to rely on this
fallback behavior.


const idx = existing.findIndex((d) => d.name === donor.name);
if (idx >= 0) {
if (donor.type === 'monthly') existing[idx].type = 'monthly';
existing[idx].timestamp = donor.timestamp;
} else {
existing.unshift(donor);
}

//modify the values to show more or less ocntributors (0, 100)
await env.DONORS_KV.put('donors', JSON.stringify(existing.slice(0, 100)));
Comment on lines +62 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

KV read-modify-write can lose updates under concurrent webhook deliveries.

Two near-simultaneous requests can read the same old list and overwrite each other. This is a data-loss risk for donor history.

Consider moving this path to a single-writer primitive (e.g., Durable Object) or a transactional store so updates are serialized.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/api/kofi-webhook.js` around lines 62 - 73, The current
read-modify-write against env.DONORS_KV (reading 'donors', updating
`existing`/`idx` and then put) can lose concurrent updates; replace this KV-side
concurrency pattern with a single-writer Durable Object: create a Durable Object
class (e.g., DonorsDO) that exposes an update endpoint (e.g., handleUpdate or
fetch) which receives the donor payload and performs the same logic (findIndex
by donor.name, update type/timestamp or unshift, slice(0,100)), and have the
webhook handler forward updates to the Durable Object instance (via
env.DONORS_DO.idFromName/ get and fetch) instead of directly reading/writing
env.DONORS_KV; inside the DO you may still persist to env.DONORS_KV for storage
but all mutations are serialized by the DO so concurrent webhook deliveries
won't overwrite each other.


return new Response(JSON.stringify({ ok: true }), { status: 200, headers: CORS_HEADERS });
} catch (e) {
return new Response(JSON.stringify({ error: e.message }), { status: 500, headers: CORS_HEADERS });
}
}
9 changes: 9 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5994,6 +5994,15 @@ <h2 class="section-title" style="margin-bottom: 1.5rem">Support Monochrome</h2>
</a>
</div>
</div>
<div class="donate-donors-section">
<h3 class="section-title" style="text-align: center; margin-bottom: 0.5rem">Our Donors</h3>
<p
style="text-align: center; color: var(--muted-foreground); margin-bottom: 1.5rem; font-size: 0.9rem">
Monthly supporters are shown first — thank you all for supporting the project ♥
</p>
<div class="about-contributors donate-donors-list"></div>
<div class="donate-donors-failed"></div>
</div>
</div>
<div id="page-reset-password" class="page">
<div
Expand Down
33 changes: 33 additions & 0 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,38 @@ async function loadDownloadsModule() {
return downloadsModule;
}

async function fetchDonors() {
try {
const response = await fetch('/api/donors');
if (!response.ok) return;
const donors = await response.json();
if (!Array.isArray(donors)) return;

const con = document.querySelector('.donate-donors-list');
if (!con) return;

donors.forEach((donor) => {
const div = document.createElement('div');
const icon = donor.type === 'monthly' ? '♥' : '☕';
const label = donor.type === 'monthly' ? 'Monthly Supporter' : 'One-time Donor';
div.innerHTML = `
<a href="https://ko-fi.com/monochrometf" target="_blank" rel="noopener noreferrer">
<span class="donor-icon">${icon}</span>
<span>${donor.name}</span>
<span class="contrib">${label}</span>
Comment on lines +122 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Untrusted donor name is rendered via innerHTML (XSS risk).

donor.name comes from webhook payloads (functions/api/kofi-webhook.js Line 50) and is inserted directly into HTML here. That allows script/HTML injection in the donors section.

Suggested fix
-        donors.forEach((donor) => {
-            const div = document.createElement('div');
-            const icon = donor.type === 'monthly' ? '♥' : '☕';
-            const label = donor.type === 'monthly' ? 'Monthly Supporter' : 'One-time Donor';
-            div.innerHTML = `
-            <a href="https://ko-fi.com/monochrometf" target="_blank" rel="noopener noreferrer">
-            <span class="donor-icon">${icon}</span>
-            <span>${donor.name}</span>
-            <span class="contrib">${label}</span>
-            </a>
-            `;
-            con.appendChild(div);
-        });
+        donors.forEach((donor) => {
+            const div = document.createElement('div');
+            const icon = donor.type === 'monthly' ? '♥' : '☕';
+            const label = donor.type === 'monthly' ? 'Monthly Supporter' : 'One-time Donor';
+
+            const a = document.createElement('a');
+            a.href = 'https://ko-fi.com/monochrometf';
+            a.target = '_blank';
+            a.rel = 'noopener noreferrer';
+
+            const iconSpan = document.createElement('span');
+            iconSpan.className = 'donor-icon';
+            iconSpan.textContent = icon;
+
+            const nameSpan = document.createElement('span');
+            nameSpan.textContent = donor.name || 'Anonymous';
+
+            const labelSpan = document.createElement('span');
+            labelSpan.className = 'contrib';
+            labelSpan.textContent = label;
+
+            a.append(iconSpan, nameSpan, labelSpan);
+            div.appendChild(a);
+            con.appendChild(div);
+        });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app.js` around lines 122 - 126, The donor.name value is being directly
interpolated into div.innerHTML (XSS risk); stop using innerHTML to insert
untrusted donor.name and instead construct the anchor and its child spans via
createElement and set textContent for the donor name and other user-supplied
values (or use Element.textContent / node.appendChild) so the donor.name is
escaped; update the code paths that set div.innerHTML (the place referencing
div.innerHTML and donor.name) to build DOM nodes safely and only use innerHTML
for static trusted markup like the icon if needed.

</a>
`;
con.appendChild(div);
});
} catch (e) {
const con = document.querySelector('.donate-donors-failed');
if (!con) return;
const div = document.createElement('div');
div.innerHTML = `<h4 style="text-align: center; color: var(--muted-foreground);">Failed to Fetch Donors List</h4>`;
con.appendChild(div);
}
}

async function fetchcontributors() {
try {
const response = await fetch('https://api.samidy.com/api/contributors');
Expand Down Expand Up @@ -482,6 +514,7 @@ document.addEventListener('DOMContentLoaded', async () => {
initTracker().catch(console.error);

await fetchcontributors();
fetchDonors();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle the fetchDonors() promise to satisfy lint and avoid silent async failures.

This line currently triggers @typescript-eslint/no-floating-promises in CI.

Suggested fix
-    fetchDonors();
+    void fetchDonors();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fetchDonors();
void fetchDonors();
🧰 Tools
🪛 GitHub Actions: Lint Codebase

[error] 517-517: ESLint: Promises must be awaited / handled with .catch or .then with rejection handler (or marked with void) (@typescript-eslint/no-floating-promises)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app.js` at line 517, The call to fetchDonors() is a floating promise;
update the caller to handle its returned Promise to satisfy lint and surface
errors — either make the surrounding function async and await fetchDonors() or
append a catch handler (e.g. fetchDonors().catch(err =>
console.error('fetchDonors failed', err))) so failures are logged; locate the
invocation of fetchDonors() and apply one of these fixes.

const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn);

Expand Down
13 changes: 13 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -10562,6 +10562,19 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
}
}

.donate-donors-section {
max-width: 800px;
margin: 2rem auto 0;
padding: 2rem 0;
border-top: 1px solid var(--border);
}

.donor-icon {
font-size: 28px;
display: block;
margin-bottom: 4px;
}

/* Fullscreen layout rebuild on PR 378 base */
#fullscreen-cover-overlay .fullscreen-shell {
width: 100%;
Expand Down
Loading