@rmdes/indiekit-endpoint-donation 0.1.0-alpha.1 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
 
4
4
  import express from "express";
5
+ import rateLimit from "express-rate-limit";
5
6
  import { waitForReady } from "@rmdes/indiekit-startup-gate";
6
7
 
7
8
  import { dashboardController } from "./lib/controllers/dashboard.js";
@@ -89,25 +90,40 @@ export default class DonationEndpoint {
89
90
  router.post("/sync", syncController.runOnce);
90
91
  router.post("/rebuild", syncController.triggerRebuild);
91
92
 
93
+ // Stats JSON (donor-data-sanitized but still admin-only as defense
94
+ // in depth — the public Eleventy site reads MongoDB directly via the
95
+ // _data/donations.js loader, not via this HTTP endpoint).
96
+ router.get("/stats.json", statsController.json);
97
+ router.get("/stats/:campaignId.json", statsController.byCampaign);
98
+
92
99
  return router;
93
100
  }
94
101
 
95
- // Public routes — Stripe webhook + JSON stats consumed by the static site.
102
+ // Public routes — Stripe webhook ONLY. Everything else (stats, donations,
103
+ // campaigns) is under the admin-protected `routes` getter above.
104
+ //
105
+ // Rate-limit the webhook endpoint to bound damage from a misbehaving
106
+ // upstream OR a forged-but-unsigned flood. Stripe typically sends one
107
+ // event at a time with exponential backoff on failure — 60/min/IP is
108
+ // well above legitimate traffic and well below "abuse" threshold.
96
109
  get routesPublic() {
97
110
  const router = express.Router();
98
111
 
99
- // Stripe sends raw body; mount express.raw() at this route only so
100
- // signature verification works. Indiekit's global JSON parser would
101
- // otherwise consume the body before we can verify the signature.
112
+ const webhookLimiter = rateLimit({
113
+ windowMs: 60 * 1000, // 1 minute window
114
+ max: 60, // 60 requests per IP per window
115
+ message: { error: "rate_limited" },
116
+ standardHeaders: true,
117
+ legacyHeaders: false,
118
+ });
119
+
102
120
  router.post(
103
121
  "/webhook",
122
+ webhookLimiter,
104
123
  express.raw({ type: "application/json" }),
105
124
  webhookController.receive,
106
125
  );
107
126
 
108
- router.get("/stats.json", statsController.json);
109
- router.get("/stats/:campaignId.json", statsController.byCampaign);
110
-
111
127
  return router;
112
128
  }
113
129
 
@@ -1,20 +1,23 @@
1
1
  /**
2
- * Campaign list + detail + manual override.
2
+ * Campaign list + detail + local-override edit.
3
+ * Admin-only (session middleware). Inputs validated to prevent NoSQL
4
+ * injection via untyped params + body fields.
5
+ *
3
6
  * Campaigns are authoritatively defined in Stripe Products — this admin
4
- * UI is read-mostly. The only writable fields are local overrides
5
- * (e.g. a "do not display on /transparence/" flag if you want to hide
6
- * a campaign from the public page without archiving it in Stripe).
7
+ * UI is read-mostly. The only writable fields are local overrides:
8
+ * - hidden_on_public (hide from /soutenir/ + /transparence/)
9
+ * - display_order (sort hint)
7
10
  * @module controllers/campaigns
8
11
  */
9
12
 
10
13
  import { listCampaigns, getCampaign, upsertCampaign } from "../storage/campaigns.js";
11
14
  import { sumByCampaign } from "../storage/donations.js";
15
+ import * as v from "../validate.js";
12
16
 
13
17
  async function list(request, response, next) {
14
18
  try {
15
19
  const { application } = request.app.locals;
16
20
  const campaigns = await listCampaigns(application);
17
- // Augment each campaign with its current donation total.
18
21
  const augmented = await Promise.all(
19
22
  campaigns.map(async (c) => {
20
23
  const { total, count } = await sumByCampaign(application, c.stripe_product_id);
@@ -32,7 +35,10 @@ async function list(request, response, next) {
32
35
  async function detail(request, response, next) {
33
36
  try {
34
37
  const { application } = request.app.locals;
35
- const c = await getCampaign(application, request.params.id);
38
+ let productId;
39
+ try { productId = v.safeId(request.params.id); }
40
+ catch { return response.status(400).render("donation-not-found"); }
41
+ const c = await getCampaign(application, productId);
36
42
  if (!c) return response.status(404).render("donation-not-found");
37
43
  const { total, count } = await sumByCampaign(application, c.stripe_product_id);
38
44
  response.render("donation-campaign-edit", {
@@ -46,15 +52,19 @@ async function detail(request, response, next) {
46
52
  async function update(request, response, next) {
47
53
  try {
48
54
  const { application } = request.app.locals;
49
- const c = await getCampaign(application, request.params.id);
55
+ let productId;
56
+ try { productId = v.safeId(request.params.id); }
57
+ catch { return response.status(400).render("donation-not-found"); }
58
+ const c = await getCampaign(application, productId);
50
59
  if (!c) return response.status(404).render("donation-not-found");
51
- // Only local overrides editable; everything else syncs from Stripe.
60
+
61
+ const display_order = v.nonNegInt(request.body.display_order) ?? (c.display_order ?? 100);
52
62
  await upsertCampaign(application, {
53
63
  ...c,
54
- hidden_on_public: request.body.hidden_on_public === "on",
55
- display_order: Number(request.body.display_order ?? c.display_order ?? 100),
64
+ hidden_on_public: v.checkbox(request.body.hidden_on_public),
65
+ display_order,
56
66
  });
57
- response.redirect(`${application.donationEndpoint}/campaigns/${request.params.id}`);
67
+ response.redirect(`${application.donationEndpoint}/campaigns/${productId}`);
58
68
  } catch (err) { next(err); }
59
69
  }
60
70
 
@@ -1,21 +1,25 @@
1
1
  /**
2
2
  * Toggle a donation's public-display consent flag.
3
- * Used both by the admin UI (manual toggle from /donations/:id) and
4
- * eventually by the donor self-service link (v2).
3
+ * Admin-only (session middleware). Input validated to prevent NoSQL
4
+ * injection via untyped params.
5
5
  * @module controllers/consent
6
6
  */
7
7
 
8
8
  import { getDonation, updateConsent } from "../storage/donations.js";
9
9
  import { triggerRebuild } from "../build/trigger.js";
10
+ import * as v from "../validate.js";
10
11
 
11
12
  async function toggle(request, response, next) {
12
13
  try {
13
14
  const { application } = request.app.locals;
14
- const current = await getDonation(application, request.params.id);
15
+ let id;
16
+ try { id = v.safeId(request.params.id); }
17
+ catch { return response.status(400).render("donation-not-found"); }
18
+ const current = await getDonation(application, id);
15
19
  if (!current) return response.status(404).render("donation-not-found");
16
- await updateConsent(application, request.params.id, !current.donor?.consent_public);
20
+ await updateConsent(application, id, !current.donor?.consent_public);
17
21
  await triggerRebuild(application);
18
- response.redirect(`${application.donationEndpoint}/donations/${request.params.id}`);
22
+ response.redirect(`${application.donationEndpoint}/donations/${id}`);
19
23
  } catch (err) { next(err); }
20
24
  }
21
25
 
@@ -1,19 +1,30 @@
1
1
  /**
2
- * Donations list + detail + edit + delete.
2
+ * Donations list + detail + edit + delete. Admin-only (session middleware).
3
+ * All inputs validated through lib/validate.js before reaching MongoDB.
3
4
  * @module controllers/donations
4
5
  */
5
6
 
6
7
  import { listDonations, getDonation, upsertDonation, removeDonation } from "../storage/donations.js";
8
+ import * as v from "../validate.js";
7
9
 
8
10
  async function list(request, response, next) {
9
11
  try {
10
12
  const { application } = request.app.locals;
11
- const { campaign_id, source, page = 1, limit = 50 } = request.query;
13
+ // Filters from query string: never trust as Mongo operators.
14
+ let campaign_id = null;
15
+ if (request.query.campaign_id) {
16
+ try { campaign_id = v.safeId(request.query.campaign_id); } catch { /* ignore */ }
17
+ }
18
+ const sourceVal = v.str(request.query.source, { max: 30, allowEmpty: true });
19
+ const source = ["stripe", "givewp_migrated", "manual"].includes(sourceVal) ? sourceVal : null;
20
+ const limit = v.nonNegInt(request.query.limit, { max: 200 }) ?? 50;
21
+ const page = v.nonNegInt(request.query.page, { max: 10000 }) ?? 1;
22
+
12
23
  const items = await listDonations(application, {
13
24
  campaign_id,
14
25
  source,
15
- limit: Number(limit),
16
- skip: (Number(page) - 1) * Number(limit),
26
+ limit,
27
+ skip: (page - 1) * limit,
17
28
  includeRefunded: true,
18
29
  });
19
30
  response.render("donation-donations", {
@@ -28,7 +39,10 @@ async function list(request, response, next) {
28
39
  async function detail(request, response, next) {
29
40
  try {
30
41
  const { application } = request.app.locals;
31
- const donation = await getDonation(application, request.params.id);
42
+ let id;
43
+ try { id = v.safeId(request.params.id); }
44
+ catch { return response.status(400).render("donation-not-found"); }
45
+ const donation = await getDonation(application, id);
32
46
  if (!donation) return response.status(404).render("donation-not-found");
33
47
  response.render("donation-donation-edit", {
34
48
  title: donation.stripe_id,
@@ -39,30 +53,39 @@ async function detail(request, response, next) {
39
53
  }
40
54
 
41
55
  async function update(request, response, next) {
42
- // TODO: validate inputs, only allow editing display name / message / consent flag.
43
- // Stripe data (amount, date, campaign, donor_id) is canonical from Stripe — not editable.
44
56
  try {
45
57
  const { application } = request.app.locals;
46
- const current = await getDonation(application, request.params.id);
58
+ let id;
59
+ try { id = v.safeId(request.params.id); }
60
+ catch { return response.status(400).render("donation-not-found"); }
61
+ const current = await getDonation(application, id);
47
62
  if (!current) return response.status(404).render("donation-not-found");
63
+
64
+ // Only editable fields: display name, message, consent flag.
65
+ // Stripe-canonical fields (amount, date, campaign, donor_id) are NEVER mutated.
66
+ const display = v.str(request.body.display, { max: 60, allowEmpty: true });
67
+ const message = v.str(request.body.message, { max: 200, allowEmpty: true });
48
68
  const next_ = {
49
69
  ...current,
50
70
  donor: {
51
71
  ...current.donor,
52
- display: request.body.display ?? current.donor.display,
53
- message: request.body.message ?? current.donor.message,
54
- consent_public: request.body.consent_public === "on",
72
+ display: display || null,
73
+ message: message || null,
74
+ consent_public: v.checkbox(request.body.consent_public),
55
75
  },
56
76
  };
57
77
  await upsertDonation(application, next_);
58
- response.redirect(`${application.donationEndpoint}/donations/${request.params.id}`);
78
+ response.redirect(`${application.donationEndpoint}/donations/${id}`);
59
79
  } catch (err) { next(err); }
60
80
  }
61
81
 
62
82
  async function remove(request, response, next) {
63
83
  try {
64
84
  const { application } = request.app.locals;
65
- await removeDonation(application, request.params.id);
85
+ let id;
86
+ try { id = v.safeId(request.params.id); }
87
+ catch { return response.status(400).render("donation-not-found"); }
88
+ await removeDonation(application, id);
66
89
  response.redirect(`${application.donationEndpoint}/donations`);
67
90
  } catch (err) { next(err); }
68
91
  }
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Manual offline-donation entry — fills the gap for cash/bank-transfer
3
- * gifts that never went through Stripe.
3
+ * gifts that never went through Stripe. Admin-only (session middleware).
4
4
  * @module controllers/manual
5
5
  */
6
6
 
7
7
  import { upsertDonation } from "../storage/donations.js";
8
8
  import { listCampaigns } from "../storage/campaigns.js";
9
9
  import { triggerRebuild } from "../build/trigger.js";
10
+ import * as v from "../validate.js";
10
11
 
11
12
  async function form(request, response, next) {
12
13
  try {
@@ -21,36 +22,57 @@ async function form(request, response, next) {
21
22
  }
22
23
 
23
24
  async function create(request, response, next) {
25
+ const { application } = request.app.locals;
26
+ const fail = (reason) =>
27
+ response.status(400).render("donation-manual-error", {
28
+ title: request.__("donation.manual.error.title"),
29
+ message: reason,
30
+ baseUrl: application.donationEndpoint,
31
+ });
32
+
24
33
  try {
25
- const { application } = request.app.locals;
26
- const { amount, currency, campaign_id, donor_display, message, consent_public, date } = request.body;
27
-
28
- const amount_cents = Math.round(Number(amount) * 100);
29
- if (!amount_cents || !campaign_id || !date) {
30
- return response.status(400).render("donation-manual-error", {
31
- title: request.__("donation.manual.error.title"),
32
- message: request.__("donation.manual.error.required"),
33
- baseUrl: application.donationEndpoint,
34
- });
34
+ // Native validation see lib/validate.js for the helpers.
35
+ const amount_cents = v.cents(request.body.amount);
36
+ if (amount_cents === null) return fail("Montant invalide.");
37
+
38
+ let campaign_id;
39
+ try {
40
+ campaign_id = v.safeId(request.body.campaign_id);
41
+ } catch {
42
+ return fail("Identifiant de campagne invalide.");
35
43
  }
36
44
 
45
+ const date = v.isoDate(request.body.date);
46
+ if (!date) return fail("Date invalide.");
47
+
48
+ const currencyCode = v.currency(request.body.currency)
49
+ || application.donationConfig?.currency
50
+ || "EUR";
51
+
52
+ const donor_display = v.str(request.body.donor_display, { max: 60, allowEmpty: true });
53
+ const message = v.str(request.body.message, { max: 200, allowEmpty: true });
54
+ const note = v.str(request.body.note, { max: 500, allowEmpty: true });
55
+
37
56
  const id = `manual_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
38
57
  const donation = {
39
58
  stripe_id: id,
40
59
  date: new Date(date),
41
60
  amount_cents,
42
- currency: currency || application.donationConfig?.currency || "EUR",
61
+ currency: currencyCode,
43
62
  campaign_id,
44
63
  recurring: false,
45
64
  donor: {
46
65
  donor_id: `manual_${id}`,
47
- display: donor_display?.trim() || null,
48
- consent_public: consent_public === "on",
49
- message: message?.trim() || null,
66
+ display: donor_display || null,
67
+ consent_public: v.checkbox(request.body.consent_public),
68
+ message: message || null,
50
69
  },
51
70
  source: "manual",
52
71
  refunded: false,
53
- meta: { entered_by: request.session?.me ?? "unknown", note: request.body.note ?? null },
72
+ meta: {
73
+ entered_by: typeof request.session?.me === "string" ? request.session.me : "unknown",
74
+ note: note || null,
75
+ },
54
76
  };
55
77
  await upsertDonation(application, donation);
56
78
  await triggerRebuild(application);
@@ -11,12 +11,15 @@ import { listCampaigns } from "../storage/campaigns.js";
11
11
  async function json(request, response, next) {
12
12
  try {
13
13
  const { application } = request.app.locals;
14
- const [stats, campaigns, donations] = await Promise.all([
14
+ const [stats, allCampaigns, donations] = await Promise.all([
15
15
  lifetimeStats(application),
16
16
  listCampaigns(application),
17
17
  listDonations(application, { limit: 100 }),
18
18
  ]);
19
19
 
20
+ // Public endpoint — never surface campaigns the admin chose to hide.
21
+ const campaigns = allCampaigns.filter((c) => !c.hidden_on_public);
22
+
20
23
  // Augment campaigns with their current totals.
21
24
  const enriched = await Promise.all(
22
25
  campaigns.map(async (c) => {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Native input validation helpers. Used by admin controllers to coerce
3
+ * request.body / request.params fields to safe types BEFORE they hit
4
+ * MongoDB or get rendered back to clients.
5
+ *
6
+ * Why no Zod or similar: the plugin keeps its dependency footprint
7
+ * minimal. These helpers cover the narrow set of inputs the donation
8
+ * plugin actually accepts and reject everything else as bad input.
9
+ */
10
+
11
+ /** Coerce to a non-empty string of bounded length. Returns null if invalid. */
12
+ export function str(value, { max = 200, allowEmpty = false } = {}) {
13
+ if (typeof value !== "string") return null;
14
+ const trimmed = value.trim();
15
+ if (!allowEmpty && !trimmed) return null;
16
+ return trimmed.slice(0, max);
17
+ }
18
+
19
+ /** Coerce to a positive integer (cents). Returns null on invalid input. */
20
+ export function cents(value, { min = 1, max = 1_000_000_00 } = {}) {
21
+ // Accept "12,50" (FR) and "12.50" (en). Floor-fail on garbage.
22
+ const cleaned = typeof value === "string"
23
+ ? value.replace(",", ".").trim()
24
+ : value;
25
+ const n = Number(cleaned);
26
+ if (!Number.isFinite(n) || n <= 0) return null;
27
+ const c = Math.round(n * 100);
28
+ if (c < min || c > max) return null;
29
+ return c;
30
+ }
31
+
32
+ /** Coerce to ISO 8601 date string (YYYY-MM-DD or full). Returns null if invalid. */
33
+ export function isoDate(value) {
34
+ if (typeof value !== "string") return null;
35
+ const d = new Date(value);
36
+ if (Number.isNaN(d.getTime())) return null;
37
+ return d.toISOString();
38
+ }
39
+
40
+ /** Coerce to a non-negative integer (for display_order etc). */
41
+ export function nonNegInt(value, { max = 1000 } = {}) {
42
+ const n = Number(value);
43
+ if (!Number.isFinite(n) || n < 0) return null;
44
+ if (n > max) return null;
45
+ return Math.floor(n);
46
+ }
47
+
48
+ /** Coerce HTML form checkbox value to boolean. */
49
+ export function checkbox(value) {
50
+ return value === "on" || value === "true" || value === true;
51
+ }
52
+
53
+ /**
54
+ * Coerce a Stripe / Mongo identifier to a string. Strips any object/array
55
+ * shape that could be mistaken for a Mongo query operator (e.g. {$ne: null}).
56
+ * Throws on truly hostile input so the caller can return 400.
57
+ */
58
+ export function safeId(value, { maxLen = 80 } = {}) {
59
+ if (typeof value !== "string") {
60
+ throw new Error("id must be a string");
61
+ }
62
+ // Allow alphanumerics, underscore, hyphen — Stripe IDs and our own.
63
+ if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length > maxLen) {
64
+ throw new Error("id contains invalid characters or is too long");
65
+ }
66
+ return value;
67
+ }
68
+
69
+ /** Allow-listed currency code. */
70
+ export function currency(value, { allowed = ["EUR", "USD", "GBP", "CHF"] } = {}) {
71
+ if (typeof value !== "string") return null;
72
+ const upper = value.toUpperCase();
73
+ return allowed.includes(upper) ? upper : null;
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-donation",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "Stripe-backed donation endpoint for Indiekit. Treats Stripe Products as campaigns, PaymentIntents/Checkout Sessions as donations, exposes admin UI + public stats API + rebuild trigger.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -37,6 +37,7 @@
37
37
  "@indiekit/frontend": "^1.0.0-beta.27",
38
38
  "@rmdes/indiekit-startup-gate": "^1.0.0",
39
39
  "express": "^5.0.0",
40
+ "express-rate-limit": "^8.5.2",
40
41
  "stripe": "^17.0.0"
41
42
  },
42
43
  "peerDependencies": {