Merge branch 'user-pats'

This commit is contained in:
martin-mfg
2026-01-05 12:56:57 +01:00
14 changed files with 84 additions and 29 deletions
+2 -2
View File
@@ -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");
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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;
+36 -1
View File
@@ -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;
}
}
}
+23 -13
View File
@@ -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<AxiosResponse>} 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<any>} 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;
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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.
@@ -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;
+1 -1
View File
@@ -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);
}
@@ -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) {
@@ -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,