diff --git a/nuxt.config.ts b/nuxt.config.ts index 15186bf..0f4bb9a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -41,6 +41,7 @@ export default defineNuxtConfig({ envPrefix: 'PN_WEBSITE_' }, + trustProxy: false, githubApiToken: '', stripeSecretKey: '', stripeWebhookSecret: '', @@ -65,6 +66,7 @@ export default defineNuxtConfig({ discourseSsoSecret: '', apiBase: 'https://api.pretendo.cc', apiBaseHost: 'api.pretendo.cc', + redisUrl: '', public: { baseUrl: 'https://pretendo.network', diff --git a/package-lock.json b/package-lock.json index abb13d5..2f0c0be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "eslint": "^9.39.5", "feed": "^6.0.0", "hcaptcha": "^0.2.0", + "ioredis": "^5.11.1", "mlly": "^1.8.2", "mongodb": "^7.5.0", "nice-grpc": "^2.1.17", @@ -35,6 +36,7 @@ "nuxt": "^4.5.2", "octokit": "^5.0.5", "papr": "^17.1.1", + "rate-limiter-flexible": "^11.2.0", "reka-ui": "^2.10.3", "stripe": "^22.4.0", "text-mask-core": "^5.1.2", @@ -19167,6 +19169,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/rate-limiter-flexible": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-11.2.0.tgz", + "integrity": "sha512-L0eIK+BmFMi6NcvmtEg7RSswOFKi9MMD5RhBIypFfOve+G6Jl1Xbb8qEHgK3uRzNtkOFYF0L9f7P4rSf2PnUVw==", + "license": "ISC" + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", diff --git a/package.json b/package.json index 3167e55..72ae410 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "eslint": "^9.39.5", "feed": "^6.0.0", "hcaptcha": "^0.2.0", + "ioredis": "^5.11.1", "mlly": "^1.8.2", "mongodb": "^7.5.0", "nice-grpc": "^2.1.17", @@ -40,6 +41,7 @@ "nuxt": "^4.5.2", "octokit": "^5.0.5", "papr": "^17.1.1", + "rate-limiter-flexible": "^11.2.0", "reka-ui": "^2.10.3", "stripe": "^22.4.0", "text-mask-core": "^5.1.2", diff --git a/server/api/progress.get.ts b/server/api/progress.get.ts index 144caef..e2d1907 100644 --- a/server/api/progress.get.ts +++ b/server/api/progress.get.ts @@ -1,5 +1,6 @@ import { getGithubProjects } from '../utils/getGithubProgress'; import { getStripeDonations } from '../utils/getStripeDonations'; +import { useCacher } from '../utils/cache'; import type { GetProgress, ProgressItem } from '#shared/api-types'; const donationGoalCents = 3000 * 100; @@ -7,8 +8,9 @@ const donationGoalCents = 3000 * 100; export default defineEventHandler(async (event): Promise => { const octokit = useOctokit(event); const stripe = useStripe(event); - const donationData = await getStripeDonations(stripe); - const { projects } = await getGithubProjects(octokit); + const cacher = useCacher(event); + const donationData = await getStripeDonations(cacher, stripe); + const { projects } = await getGithubProjects(cacher, octokit); const items: ProgressItem[] = projects.map((v) => { const totalTasks = v.tasks.length; diff --git a/server/utils/cache.ts b/server/utils/cache.ts new file mode 100644 index 0000000..6b64731 --- /dev/null +++ b/server/utils/cache.ts @@ -0,0 +1,79 @@ +import Redis from 'ioredis'; +import type { H3Event } from 'h3'; + +let cacher: Cacher | null = null; + +export type Cacher = { + set(key: string, val: T, cacheAgeMs?: number): Promise; + get(key: string): Promise; +}; + +function createCacher(event: H3Event): Cacher { + const config = useRuntimeConfig(event); + if (config.redisUrl) { + const redis = new Redis(config.redisUrl); + const prefix = `pn:website:cache`; + return { + async get(key) { + try { + const result = await redis.get(`${prefix}:${key}`); + if (result) { + return JSON.parse(result); + } + } catch { + // Catch connection & serialisation errors + } + return null; + }, + async set(key, val, cacheAgeMs) { + try { + const fullKey = `${prefix}:${key}`; + await redis.set(fullKey, JSON.stringify(val)); + if (cacheAgeMs) { + await redis.expire(fullKey, Math.round(cacheAgeMs / 1000)); + } + } catch { + // Catch connection & serialisation errors + } + } + }; + } + + // Memory cacher + const store = new Map(); + return { + async get(key) { + try { + const result = store.get(key); + if (result) { + if (result.expiresAt && result.expiresAt < new Date()) { + store.delete(key); + return null; // Expired + } + return JSON.parse(result.value); + } + } catch { + // catch Serialisation errors + } + return null; + }, + async set(key, val, cacheAgeMs) { + try { + store.set(key, { + expiresAt: cacheAgeMs ? new Date(Date.now() + cacheAgeMs) : null, + value: JSON.stringify(val) + }); + } catch { + // Catch serialisation errors + } + } + }; +} + +export function useCacher(event: H3Event): Cacher | null { + if (!cacher) { + cacher = createCacher(event); + } + + return cacher; +} diff --git a/server/utils/getGithubProgress.ts b/server/utils/getGithubProgress.ts index 2cabee3..f77f2ff 100644 --- a/server/utils/getGithubProgress.ts +++ b/server/utils/getGithubProgress.ts @@ -16,8 +16,6 @@ export type GithubProjectResponse = { }; const orgName = 'PretendoNetwork'; -const cacheMaxAgeMs = 60 * 60 * 1000; // 1 hour -let cache: { response: GithubProjectResponse; createdAt: Date } | null = null; const getProjectsWithItemsV2GQL = ` query getProjectsV2($orgName: String!, $cursor: String) { @@ -128,19 +126,24 @@ async function getGithubProjectsData(octokit: Octokit): Promise { - if (!cache || new Date(cache.createdAt.getTime() + cacheMaxAgeMs) < new Date() || ignoreCache) { - // No github credentials, assume there are no projects - if (!octokit) { - return { - projects: [] - }; - } +const cacheKey = 'githubProjects'; +const cacheMaxAgeMs = 60 * 60 * 1000; // 1 hour - cache = { - createdAt: new Date(), - response: await getGithubProjectsData(octokit) +export async function getGithubProjects(cacher: Cacher, octokit: Octokit | null, ignoreCache = false): Promise { + if (!octokit) { + return { + projects: [] }; } - return cache.response; + + if (!ignoreCache) { + const cached = await cacher.get(cacheKey); + if (cached) { + return cached; + } + } + + const data = await getGithubProjectsData(octokit); + await cacher.set(cacheKey, data, cacheMaxAgeMs); + return data; } diff --git a/server/utils/getStripeDonations.ts b/server/utils/getStripeDonations.ts index 7393312..33a0ca6 100644 --- a/server/utils/getStripeDonations.ts +++ b/server/utils/getStripeDonations.ts @@ -5,9 +5,6 @@ export type StripeDonationResponse = { totalDonationsCents: number; }; -const cacheMaxAgeMs = 60 * 60 * 1000; // 1 hour -let cache: { response: StripeDonationResponse; createdAt: Date } | null = null; - async function getStripeDonationData(stripe: Stripe): Promise { const donationData: StripeDonationResponse = { donatorCount: 0, @@ -29,21 +26,26 @@ async function getStripeDonationData(stripe: Stripe): Promise { - if (!cache || new Date(cache.createdAt.getTime() + cacheMaxAgeMs) < new Date() || ignoreCache) { - // No credentials, fill in blank data - if (!stripe) { - return { - donatorCount: 0, - totalDonationsCents: 0 - }; - } +const cacheKey = 'stripeDonations'; +const cacheMaxAgeMs = 60 * 60 * 1000; // 1 hour - cache = { - createdAt: new Date(), - response: await getStripeDonationData(stripe) +export async function getStripeDonations(cacher: Cacher, stripe: Stripe | null, ignoreCache = false): Promise { + if (!stripe) { + // No credentials, fill in blank data + return { + donatorCount: 0, + totalDonationsCents: 0 }; } - return cache.response; + if (!ignoreCache) { + const cached = await cacher.get(cacheKey); + if (cached) { + return cached; + } + } + + const data = await getStripeDonationData(stripe); + await cacher.set(cacheKey, data, cacheMaxAgeMs); + return data; } diff --git a/src/plugins/types.d.ts b/src/plugins/types.d.ts index d5acb69..a7d9caf 100644 --- a/src/plugins/types.d.ts +++ b/src/plugins/types.d.ts @@ -6,6 +6,8 @@ declare module '#app' { declare module 'nuxt/schema' { interface RuntimeConfig { + trustProxy: boolean; + githubApiToken: string; stripeSecretKey: string; @@ -38,6 +40,8 @@ declare module 'nuxt/schema' { apiBase: string; apiBaseHost: string; + + redisUrl: string; } interface PublicRuntimeConfig {