From addc4391fdcbd2930e3ec965668a6f9a32ebf9e2 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:01:29 +0100 Subject: [PATCH 1/4] use user PAT if available --- backend/api-renamed/downgrade.js | 4 +-- backend/api-renamed/status/up.js | 2 +- backend/api-renamed/user-access.js | 4 +-- backend/src/common/database.js | 37 ++++++++++++++++++++++++++- backend/src/common/retryer.js | 36 ++++++++++++++++---------- backend/src/fetchers/gist.js | 2 +- backend/src/fetchers/repo.js | 2 +- backend/src/fetchers/stats.js | 4 +-- backend/src/fetchers/top-languages.js | 2 +- 9 files changed, 69 insertions(+), 24 deletions(-) diff --git a/backend/api-renamed/downgrade.js b/backend/api-renamed/downgrade.js index 08975e04..39b69db6 100644 --- a/backend/api-renamed/downgrade.js +++ b/backend/api-renamed/downgrade.js @@ -1,4 +1,4 @@ -import { getUserAccess, deleteUser } from "../src/common/database.js"; +import { getUserAccessByKey, deleteUser } from "../src/common/database.js"; import axios from "axios"; import { logger } from "../src/common/log.js"; @@ -23,7 +23,7 @@ export default async (req, res) => { } // get token and private access status - const userAccess = await getUserAccess(user_key); + const userAccess = await getUserAccessByKey(user_key); if (!userAccess) { res.statusCode = 404; res.send("user not found"); diff --git a/backend/api-renamed/status/up.js b/backend/api-renamed/status/up.js index a942d0d7..05b5e263 100644 --- a/backend/api-renamed/status/up.js +++ b/backend/api-renamed/status/up.js @@ -87,7 +87,7 @@ export default async (req, res) => { try { let PATsValid = true; try { - await retryer(uptimeFetcher, {}); + await retryer(uptimeFetcher, null, {}); } catch (err) { // Resolve eslint no-unused-vars err; diff --git a/backend/api-renamed/user-access.js b/backend/api-renamed/user-access.js index dfadac35..a41af4cf 100644 --- a/backend/api-renamed/user-access.js +++ b/backend/api-renamed/user-access.js @@ -1,5 +1,5 @@ import { logger } from "../src/common/log.js"; -import { getUserAccess } from "../src/common/database.js"; +import { getUserAccessByKey } from "../src/common/database.js"; /** * @param {any} req The request. @@ -8,7 +8,7 @@ import { getUserAccess } from "../src/common/database.js"; export default async (req, res) => { const { user_key } = req.query; try { - const result = await getUserAccess(user_key); + const result = await getUserAccessByKey(user_key); if (!result) { res.statusCode = 404; diff --git a/backend/src/common/database.js b/backend/src/common/database.js index cfe92d0a..5c5e4950 100644 --- a/backend/src/common/database.js +++ b/backend/src/common/database.js @@ -197,7 +197,7 @@ export async function deleteUser(userKey) { * @param {string} userKey user key of the user to fetch information for * @returns {Promise<{token: string, privateAccess: boolean} | null>} token and private access status, or null if user not found */ -export async function getUserAccess(userKey) { +export async function getUserAccessByKey(userKey) { if (!pool) { return null; } @@ -225,3 +225,38 @@ export async function getUserAccess(userKey) { } } } + +/** + * Fetches token and private access status for a given username. + * + * @param {string} userName GitHub username of the user to fetch information for + * @returns {Promise<{token: string, privateAccess: boolean} | null>} token and private access status, or null if user not found + */ +export async function getUserAccessByName(userName) { + if (!pool) { + return null; + } + + const query = ` + SELECT access_token, private_access + FROM authenticated_users + WHERE user_id = $1 + LIMIT 1 + `; + try { + const { rows } = await pool.query(query, [userName]); + if (rows.length === 0) { + return null; + } + return { + token: rows[0].access_token, + privateAccess: rows[0].private_access + }; + } catch (err) { + if (err.code === "42P01") { + return null; + } else { + throw err; + } + } +} diff --git a/backend/src/common/retryer.js b/backend/src/common/retryer.js index 83366a94..ff36f3e9 100644 --- a/backend/src/common/retryer.js +++ b/backend/src/common/retryer.js @@ -2,19 +2,12 @@ import { CustomError } from "./error.js"; import { logger } from "./log.js"; +import { getUserAccessByKey, getUserAccessByName } from "./database.js"; function getRandomInt(max) { return Math.floor(Math.random() * max); } -// Script variables. - -// Count the number of GitHub API tokens available. -const PATs = Object.keys(process.env).filter((key) => - /PAT_\d*$/.exec(key), -).length; -const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs; - /** * @typedef {import("axios").AxiosResponse} AxiosResponse Axios response. * @typedef {(variables: any, token: string, retriesForTests?: number) => Promise} FetcherFunction Fetcher function. @@ -24,22 +17,39 @@ const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs; * Try to execute the fetcher function until it succeeds or the max number of retries is reached. * * @param {FetcherFunction} fetcher The fetcher function. + * @param username GitHub username of the user whose PAT to use, if available * @param {any} variables Object with arguments to pass to the fetcher function. * @returns {Promise} The response from the fetcher function. */ -const retryer = async (fetcher, variables) => { +const retryer = async (fetcher, username, variables) => { + let userPAT; + if (username) { + userPAT = await getUserAccessByName(username); + } + + let PATs; + if (userPAT) { + PATs = [userPAT.token]; + } else { + // Count the number of GitHub API tokens available. + PATs = Object.keys(process.env).filter((key) => + /PAT_\d*$/.exec(key), + ); + } + const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs.length; + if (!RETRIES) { throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS); } - const startPAT = getRandomInt(PATs); + const startPAT = getRandomInt(PATs.length); for (let retries = 0; retries < RETRIES; retries++) { - const currentPAT = ((startPAT + retries) % PATs) + 1; + const currentPAT = ((startPAT + retries) % PATs.length); try { let response = await fetcher( variables, // @ts-ignore - process.env[`PAT_${currentPAT}`], + PATs[currentPAT], // used in tests for faking rate limit retries, ); @@ -89,5 +99,5 @@ const retryer = async (fetcher, variables) => { ); }; -export { retryer, RETRIES }; +export { retryer }; export default retryer; diff --git a/backend/src/fetchers/gist.js b/backend/src/fetchers/gist.js index d9cccc67..ea4ec183 100644 --- a/backend/src/fetchers/gist.js +++ b/backend/src/fetchers/gist.js @@ -90,7 +90,7 @@ const fetchGist = async (id) => { if (!id) { throw new MissingParamError(["id"], "/api/gist?id=GIST_ID"); } - const res = await retryer(fetcher, { gistName: id }); + const res = await retryer(fetcher, null, { gistName: id }); if (res.data.errors) { throw new Error(res.data.errors[0].message); } diff --git a/backend/src/fetchers/repo.js b/backend/src/fetchers/repo.js index 6d45f4e7..74d76f84 100644 --- a/backend/src/fetchers/repo.js +++ b/backend/src/fetchers/repo.js @@ -99,7 +99,7 @@ const fetchRepo = async ( throw new MissingParamError(["repo"], urlExample); } - let res = await retryer(fetcher, { login: owner, repo: reponame }); + let res = await retryer(fetcher, username, { login: owner, repo: reponame }); const data = res.data.data; diff --git a/backend/src/fetchers/stats.js b/backend/src/fetchers/stats.js index e5234064..7daab0a5 100644 --- a/backend/src/fetchers/stats.js +++ b/backend/src/fetchers/stats.js @@ -139,7 +139,7 @@ const statsFetcher = async ({ startTime, ownerAffiliations, }; - let res = await retryer(fetcher, variables); + let res = await retryer(fetcher, username, variables); if (res.data.errors) { return res; } @@ -221,7 +221,7 @@ const totalItemsFetcher = async (username, repo, owner, type, filter) => { let res; try { - res = await retryer(fetchTotalItems, { + res = await retryer(fetchTotalItems, username, { login: username, repo, owner, diff --git a/backend/src/fetchers/top-languages.js b/backend/src/fetchers/top-languages.js index c72ca089..e49319f1 100644 --- a/backend/src/fetchers/top-languages.js +++ b/backend/src/fetchers/top-languages.js @@ -73,7 +73,7 @@ const fetchTopLanguages = async ( } ownerAffiliations = parseOwnerAffiliations(ownerAffiliations); - const res = await retryer(fetcher, { login: username, ownerAffiliations }); + const res = await retryer(fetcher, username, { login: username, ownerAffiliations }); if (res.data.errors) { logger.error(res.data.errors); From 150a433a3d23067b99383d88d846c134262e2631 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Sun, 4 Jan 2026 22:50:33 +0100 Subject: [PATCH 2/4] fix race condition which logged out users --- frontend/frontend/src/pages/App/AppTrends.js | 2 +- frontend/frontend/src/redux/actions/userActions.js | 4 ++-- frontend/frontend/src/redux/reducers/user.js | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/frontend/src/pages/App/AppTrends.js b/frontend/frontend/src/pages/App/AppTrends.js index bbedee4c..1d971d81 100644 --- a/frontend/frontend/src/pages/App/AppTrends.js +++ b/frontend/frontend/src/pages/App/AppTrends.js @@ -67,7 +67,7 @@ function App() { if (userKey && userKey.length > 0) { const userAccess = await getUserMetadata(userKey); if (userAccess === null) { - dispatch(_logout()); + dispatch(_logout(userKey)); } else { setUserAccess(userAccess); } diff --git a/frontend/frontend/src/redux/actions/userActions.js b/frontend/frontend/src/redux/actions/userActions.js index 92d9094e..3740f76c 100644 --- a/frontend/frontend/src/redux/actions/userActions.js +++ b/frontend/frontend/src/redux/actions/userActions.js @@ -6,8 +6,8 @@ export function login(userId, userKey) { return { type: LOGIN, payload: { userId, userKey } }; } -export function logout() { - return { type: LOGOUT, payload: {} }; +export function logout(userKey = null) { + return { type: LOGOUT, payload: { userKey: userKey } }; } export function setUserAccess(token, privateAccess) { diff --git a/frontend/frontend/src/redux/reducers/user.js b/frontend/frontend/src/redux/reducers/user.js index de1ebc76..2a6b7e68 100644 --- a/frontend/frontend/src/redux/reducers/user.js +++ b/frontend/frontend/src/redux/reducers/user.js @@ -19,6 +19,12 @@ export default (state = initialState, action) => { userKey: action.payload.userKey, }; case types.LOGOUT: + if ( + action.payload.userKey === null || + action.payload.userKey !== localStorage.getItem('userKey') + ) { + return; + } localStorage.clear(); return { userId: null, From 2031c8f9d051b46c494868bba38667e72ce2d582 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:38:59 +0100 Subject: [PATCH 3/4] improve docs on private contributions --- docs/advanced_documentation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/advanced_documentation.md b/docs/advanced_documentation.md index 4caabe11..b3d1c104 100644 --- a/docs/advanced_documentation.md +++ b/docs/advanced_documentation.md @@ -12,7 +12,7 @@ ## GitHub Stats Card > [!WARNING] -> By default, the stats card only shows statistics like stars, commits, and pull requests from public repositories. To show private statistics on the stats card, you should [deploy your own instance](deploy.md) using your own GitHub API token. +> By default, the stats card only shows statistics like stars, commits, and pull requests from public repositories. To show private statistics on the stats card, [allow GitHub-Stats-Extended to access your private contributions](fork.md#private-contributions-support) or [deploy your own instance](deploy.md). > [!NOTE] > Available ranks are S (top 1%), A+ (12.5%), A (25%), A- (37.5%), B+ (50%), B (62.5%), B- (75%), C+ (87.5%) and C (everyone). This ranking scheme is based on the [Japanese academic grading](https://wikipedia.org/wiki/Academic_grading_in_Japan) system. The global percentile is calculated as a weighted sum of percentiles for each statistic (number of commits, pull requests, reviews, issues, stars, and followers), based on the cumulative distribution function of the [exponential](https://wikipedia.org/wiki/exponential_distribution) and the [log-normal](https://wikipedia.org/wiki/Log-normal_distribution) distributions. The implementation can be investigated at [src/calculateRank.js](https://github.com/stats-organization/github-stats-extended/blob/master/backend/src/calculateRank.js). The circle around the rank shows 100 minus the global percentile. @@ -381,7 +381,7 @@ Use [show\_owner](#options-1) query option to include the gist's owner username The top languages card shows your most frequently used languages. > [!WARNING] -> By default, the language card shows language results only from public repositories. To include languages used in private repositories, you should [deploy your own instance](deploy.md) using your own GitHub API token. +> By default, the language card shows language results only from public repositories. To include languages used in private repositories, [allow GitHub-Stats-Extended to access your private contributions](fork.md#private-contributions-support) or [deploy your own instance](deploy.md). > [!WARNING] > This card shows language usage only inside your own non-forked repositories, not depending on who the author of the commits is. It does not include your contributions into another users/organizations repositories. Currently there are no way to get this data from GitHub API. If you want this behavior to be improved you can support [this feature request](https://github.com/orgs/community/discussions/18230) created by [@rickstaa](https://github.com/rickstaa) inside GitHub Community. From b9e86b42d17294f78b16601ce11691d68f50e1ae Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:51:28 +0100 Subject: [PATCH 4/4] frontend fix: clear cache when upgrading/downgrading access --- frontend/frontend/src/components/Card/SVG.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/frontend/src/components/Card/SVG.js b/frontend/frontend/src/components/Card/SVG.js index cdcd247d..58e78f32 100644 --- a/frontend/frontend/src/components/Card/SVG.js +++ b/frontend/frontend/src/components/Card/SVG.js @@ -29,6 +29,10 @@ const SvgInline = (props) => { setShouldMock(stage === 0 || !isAuthenticated); }, [isAuthenticated, props.stage]); + useEffect(async () => { + await axios.storage.clear(); + }, [userToken]); + useEffect(() => { const loadSvg = async () => { process.env.PAT_1 = userToken;