From 3987991058b0988e548b7b32f4dfb00a3320a892 Mon Sep 17 00:00:00 2001 From: Kyle Upton <44348980+kyleaupton@users.noreply.github.com> Date: Mon, 15 Sep 2025 16:13:42 -0400 Subject: [PATCH] feat: implement whitelist for self-hosted instances (#3939) * whitelist for username based endpoints * added gist whitelist * review * fix --------- Co-authored-by: Alexandr --- api/gist.js | 18 ++++++++++++++++++ api/index.js | 39 +++++++++++++++++++++++++++++++-------- api/pin.js | 39 +++++++++++++++++++++++++++++++-------- api/top-langs.js | 39 +++++++++++++++++++++++++++++++-------- api/wakatime.js | 18 ++++++++++++++++++ readme.md | 10 ++++++---- src/common/utils.js | 9 ++++++++- src/common/whitelist.js | 10 ++++++++++ tests/api.test.js | 6 +++++- tests/pin.test.js | 6 +++++- tests/top-langs.test.js | 6 +++++- 11 files changed, 168 insertions(+), 32 deletions(-) create mode 100644 src/common/whitelist.js diff --git a/api/gist.js b/api/gist.js index 00697761..c309c8cf 100644 --- a/api/gist.js +++ b/api/gist.js @@ -4,6 +4,7 @@ import { renderError, parseBoolean, } from "../src/common/utils.js"; +import { gistWhitelist } from "../src/common/whitelist.js"; import { isLocaleAvailable } from "../src/translations.js"; import { renderGistCard } from "../src/cards/gist.js"; import { fetchGist } from "../src/fetchers/gist.js"; @@ -26,6 +27,23 @@ export default async (req, res) => { res.setHeader("Content-Type", "image/svg+xml"); + if (gistWhitelist && !gistWhitelist.includes(id)) { + return res.send( + renderError( + "This gist ID is not whitelisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + if (locale && !isLocaleAvailable(locale)) { return res.send( renderError("Something went wrong", "Language not found", { diff --git a/api/index.js b/api/index.js index ef41fa13..b5705fbc 100644 --- a/api/index.js +++ b/api/index.js @@ -1,5 +1,6 @@ import { renderStatsCard } from "../src/cards/stats.js"; import { blacklist } from "../src/common/blacklist.js"; +import { whitelist } from "../src/common/whitelist.js"; import { clampValue, CONSTANTS, @@ -41,15 +42,37 @@ export default async (req, res) => { } = req.query; res.setHeader("Content-Type", "image/svg+xml"); - if (blacklist.includes(username)) { + if (whitelist && !whitelist.includes(username)) { return res.send( - renderError("Something went wrong", "This username is blacklisted", { - title_color, - text_color, - bg_color, - border_color, - theme, - }), + renderError( + "This username is not whitelisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + + if (whitelist === undefined && blacklist.includes(username)) { + return res.send( + renderError( + "This username is blacklisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), ); } diff --git a/api/pin.js b/api/pin.js index b8fa617b..eec584d9 100644 --- a/api/pin.js +++ b/api/pin.js @@ -1,5 +1,6 @@ import { renderRepoCard } from "../src/cards/repo.js"; import { blacklist } from "../src/common/blacklist.js"; +import { whitelist } from "../src/common/whitelist.js"; import { clampValue, CONSTANTS, @@ -29,15 +30,37 @@ export default async (req, res) => { res.setHeader("Content-Type", "image/svg+xml"); - if (blacklist.includes(username)) { + if (whitelist && !whitelist.includes(username)) { return res.send( - renderError("Something went wrong", "This username is blacklisted", { - title_color, - text_color, - bg_color, - border_color, - theme, - }), + renderError( + "This username is not whitelisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + + if (whitelist === undefined && blacklist.includes(username)) { + return res.send( + renderError( + "This username is blacklisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), ); } diff --git a/api/top-langs.js b/api/top-langs.js index 1c7ed681..d63463de 100644 --- a/api/top-langs.js +++ b/api/top-langs.js @@ -1,5 +1,6 @@ import { renderTopLanguages } from "../src/cards/top-languages.js"; import { blacklist } from "../src/common/blacklist.js"; +import { whitelist } from "../src/common/whitelist.js"; import { clampValue, CONSTANTS, @@ -36,15 +37,37 @@ export default async (req, res) => { } = req.query; res.setHeader("Content-Type", "image/svg+xml"); - if (blacklist.includes(username)) { + if (whitelist && !whitelist.includes(username)) { return res.send( - renderError("Something went wrong", "This username is blacklisted", { - title_color, - text_color, - bg_color, - border_color, - theme, - }), + renderError( + "This username is not whitelisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + + if (whitelist === undefined && blacklist.includes(username)) { + return res.send( + renderError( + "This username is blacklisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), ); } diff --git a/api/wakatime.js b/api/wakatime.js index 1517cc49..69f0ce7d 100644 --- a/api/wakatime.js +++ b/api/wakatime.js @@ -6,6 +6,7 @@ import { parseBoolean, renderError, } from "../src/common/utils.js"; +import { whitelist } from "../src/common/whitelist.js"; import { fetchWakatimeStats } from "../src/fetchers/wakatime.js"; import { isLocaleAvailable } from "../src/translations.js"; @@ -36,6 +37,23 @@ export default async (req, res) => { res.setHeader("Content-Type", "image/svg+xml"); + if (whitelist && !whitelist.includes(username)) { + return res.send( + renderError( + "This username is not whitelisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + if (locale && !isLocaleAvailable(locale)) { return res.send( renderError("Something went wrong", "Language not found", { diff --git a/readme.md b/readme.md index f8704486..00752dbb 100644 --- a/readme.md +++ b/readme.md @@ -86,7 +86,7 @@ - [On Vercel](#on-vercel) - [:film\_projector: Check Out Step By Step Video Tutorial By @codeSTACKr](#film_projector-check-out-step-by-step-video-tutorial-by-codestackr) - [On other platforms](#on-other-platforms) - - [Disable rate limit protections](#disable-rate-limit-protections) + - [Available environment variables](#available-environment-variables) - [Keep your fork up to date](#keep-your-fork-up-to-date) - [:sparkling\_heart: Support the project](#sparkling_heart-support-the-project) @@ -797,11 +797,13 @@ Since the GitHub API only allows 5k requests per hour, my `https://github-readme 5. You're done 🎉 -## Disable rate limit protections +## Available environment variables -GitHub Readme Stats contains several Vercel environment variables that can be used to remove the rate limit protections: +GitHub Readme Stats provides several environment variables that can be used to customize the behavior of your self-hosted instance. These include: -* `CACHE_SECONDS`: This environment variable takes precedence over our cache minimum and maximum values and can circumvent these values for self-hosted Vercel instances. +* `CACHE_SECONDS`: This takes precedence over our cache minimum and maximum values and can circumvent these values for self-hosted instances. +* `WHITELIST`: A comma-separated list of GitHub usernames that are allowed to access your instance. If this variable is not set, all usernames are allowed. +* `GIST_WHITELIST`: A comma-separated list of GitHub gist IDs that are allowed to be accessed on your instance. If this variable is not set, all gist IDs are allowed. See [the Vercel documentation](https://vercel.com/docs/concepts/projects/environment-variables) on adding these environment variables to your Vercel instance. diff --git a/src/common/utils.js b/src/common/utils.js index 68adafdf..b12a9147 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -355,6 +355,12 @@ const UPSTREAM_API_ERRORS = [ * @param {string} message Main error message. * @param {string} secondaryMessage The secondary error message. * @param {object} options Function options. + * @param {string=} options.title_color Card title color. + * @param {string=} options.text_color Card text color. + * @param {string=} options.bg_color Card background color. + * @param {string=} options.border_color Card border color. + * @param {string=} options.theme Card theme. + * @param {boolean=} options.show_repo_link Whether to show repo link or not. * @returns {string} The SVG markup. */ const renderError = (message, secondaryMessage = "", options = {}) => { @@ -364,6 +370,7 @@ const renderError = (message, secondaryMessage = "", options = {}) => { bg_color, border_color, theme = "default", + show_repo_link = true, } = options; // returns theme based colors with proper overrides and defaults @@ -388,7 +395,7 @@ const renderError = (message, secondaryMessage = "", options = {}) => { ERROR_CARD_LENGTH - 1 }" height="99%" rx="4.5" fill="${bgColor}" stroke="${borderColor}"/> Something went wrong!${ - UPSTREAM_API_ERRORS.includes(secondaryMessage) + UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link ? "" : " file an issue at https://tiny.one/readme-stats" } diff --git a/src/common/whitelist.js b/src/common/whitelist.js new file mode 100644 index 00000000..b5df7c70 --- /dev/null +++ b/src/common/whitelist.js @@ -0,0 +1,10 @@ +const whitelist = process.env.WHITELIST + ? process.env.WHITELIST.split(",") + : undefined; + +const gistWhitelist = process.env.GIST_WHITELIST + ? process.env.GIST_WHITELIST.split(",") + : undefined; + +export { whitelist, gistWhitelist }; +export default whitelist; diff --git a/tests/api.test.js b/tests/api.test.js index 1b576b19..697c690e 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -307,7 +307,11 @@ describe("Test /api/", () => { expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toBeCalledWith( - renderError("Something went wrong", "This username is blacklisted"), + renderError( + "This username is blacklisted", + "Please deploy your own instance", + { show_repo_link: false }, + ), ); }); diff --git a/tests/pin.test.js b/tests/pin.test.js index e9adcaa8..15a93b21 100644 --- a/tests/pin.test.js +++ b/tests/pin.test.js @@ -156,7 +156,11 @@ describe("Test /api/pin", () => { expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toBeCalledWith( - renderError("Something went wrong", "This username is blacklisted"), + renderError( + "This username is blacklisted", + "Please deploy your own instance", + { show_repo_link: false }, + ), ); }); diff --git a/tests/top-langs.test.js b/tests/top-langs.test.js index 62d4f7a0..c4b0fa97 100644 --- a/tests/top-langs.test.js +++ b/tests/top-langs.test.js @@ -184,7 +184,11 @@ describe("Test /api/top-langs", () => { expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toBeCalledWith( - renderError("Something went wrong", "This username is blacklisted"), + renderError( + "This username is blacklisted", + "Please deploy your own instance", + { show_repo_link: false }, + ), ); });