@dytsou/github-readme-stats 1.0.1

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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/api/gist.js +98 -0
  3. package/api/index.js +146 -0
  4. package/api/pin.js +114 -0
  5. package/api/status/pat-info.js +193 -0
  6. package/api/status/up.js +129 -0
  7. package/api/top-langs.js +163 -0
  8. package/api/wakatime.js +129 -0
  9. package/package.json +102 -0
  10. package/readme.md +412 -0
  11. package/src/calculateRank.js +87 -0
  12. package/src/cards/gist.js +151 -0
  13. package/src/cards/index.js +4 -0
  14. package/src/cards/repo.js +199 -0
  15. package/src/cards/stats.js +607 -0
  16. package/src/cards/top-languages.js +968 -0
  17. package/src/cards/types.d.ts +67 -0
  18. package/src/cards/wakatime.js +482 -0
  19. package/src/common/Card.js +294 -0
  20. package/src/common/I18n.js +41 -0
  21. package/src/common/access.js +69 -0
  22. package/src/common/api-utils.js +221 -0
  23. package/src/common/blacklist.js +10 -0
  24. package/src/common/cache.js +153 -0
  25. package/src/common/color.js +194 -0
  26. package/src/common/envs.js +15 -0
  27. package/src/common/error.js +84 -0
  28. package/src/common/fmt.js +90 -0
  29. package/src/common/html.js +43 -0
  30. package/src/common/http.js +24 -0
  31. package/src/common/icons.js +63 -0
  32. package/src/common/index.js +13 -0
  33. package/src/common/languageColors.json +651 -0
  34. package/src/common/log.js +14 -0
  35. package/src/common/ops.js +124 -0
  36. package/src/common/render.js +261 -0
  37. package/src/common/retryer.js +97 -0
  38. package/src/common/worker-adapter.js +148 -0
  39. package/src/common/worker-env.js +48 -0
  40. package/src/fetchers/gist.js +114 -0
  41. package/src/fetchers/repo.js +118 -0
  42. package/src/fetchers/stats.js +350 -0
  43. package/src/fetchers/top-languages.js +192 -0
  44. package/src/fetchers/types.d.ts +118 -0
  45. package/src/fetchers/wakatime.js +109 -0
  46. package/src/index.js +2 -0
  47. package/src/translations.js +1105 -0
  48. package/src/worker.ts +116 -0
  49. package/themes/README.md +229 -0
  50. package/themes/index.js +467 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Anurag Hazra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/api/gist.js ADDED
@@ -0,0 +1,98 @@
1
+ // @ts-check
2
+
3
+ import { renderGistCard } from "../src/cards/gist.js";
4
+ import { guardAccess } from "../src/common/access.js";
5
+ import {
6
+ createValidatedColorOptions,
7
+ handleApiError,
8
+ setSvgContentType,
9
+ } from "../src/common/api-utils.js";
10
+ import {
11
+ CACHE_TTL,
12
+ resolveCacheSeconds,
13
+ setCacheHeaders,
14
+ } from "../src/common/cache.js";
15
+ import { parseBoolean } from "../src/common/ops.js";
16
+ import { fetchGist } from "../src/fetchers/gist.js";
17
+ import { isLocaleAvailable } from "../src/translations.js";
18
+
19
+ // @ts-ignore
20
+ export default async (req, res) => {
21
+ const {
22
+ id,
23
+ title_color,
24
+ icon_color,
25
+ text_color,
26
+ bg_color,
27
+ theme,
28
+ cache_seconds,
29
+ locale: rawLocale,
30
+ border_radius,
31
+ border_color,
32
+ show_owner,
33
+ hide_border,
34
+ } = req.query;
35
+
36
+ // Only allow supported locales - validate and sanitize to prevent XSS
37
+ const locale =
38
+ typeof rawLocale === "string" && isLocaleAvailable(rawLocale)
39
+ ? rawLocale.toLowerCase()
40
+ : undefined;
41
+
42
+ setSvgContentType(res);
43
+
44
+ // Create validated color options once for reuse
45
+ const colorOptions = createValidatedColorOptions({
46
+ title_color,
47
+ text_color,
48
+ bg_color,
49
+ border_color,
50
+ theme,
51
+ });
52
+
53
+ const access = guardAccess({
54
+ res,
55
+ id,
56
+ type: "gist",
57
+ colors: colorOptions,
58
+ });
59
+ if (!access.isPassed) {
60
+ return access.result;
61
+ }
62
+
63
+ try {
64
+ const gistData = await fetchGist(id);
65
+ const cacheSeconds = resolveCacheSeconds({
66
+ requested: parseInt(cache_seconds, 10),
67
+ def: CACHE_TTL.GIST_CARD.DEFAULT,
68
+ min: CACHE_TTL.GIST_CARD.MIN,
69
+ max: CACHE_TTL.GIST_CARD.MAX,
70
+ });
71
+
72
+ setCacheHeaders(res, cacheSeconds);
73
+
74
+ return res.send(
75
+ renderGistCard(gistData, {
76
+ title_color,
77
+ icon_color,
78
+ text_color,
79
+ bg_color,
80
+ theme,
81
+ border_radius: (() => {
82
+ // Validate border_radius: must be a finite number between 0 and 50
83
+ const num = parseFloat(border_radius);
84
+ if (isNaN(num) || !isFinite(num) || num < 0 || num > 50) {
85
+ return undefined; // Let card use its default
86
+ }
87
+ return num;
88
+ })(),
89
+ border_color,
90
+ locale,
91
+ show_owner: parseBoolean(show_owner),
92
+ hide_border: parseBoolean(hide_border),
93
+ }),
94
+ );
95
+ } catch (err) {
96
+ return handleApiError({ res, error: err, colorOptions });
97
+ }
98
+ };
package/api/index.js ADDED
@@ -0,0 +1,146 @@
1
+ // @ts-check
2
+
3
+ import { renderStatsCard } from "../src/cards/stats.js";
4
+ import { guardAccess } from "../src/common/access.js";
5
+ import {
6
+ createValidatedColorOptions,
7
+ handleApiError,
8
+ setSvgContentType,
9
+ } from "../src/common/api-utils.js";
10
+ import {
11
+ CACHE_TTL,
12
+ resolveCacheSeconds,
13
+ setCacheHeaders,
14
+ } from "../src/common/cache.js";
15
+ import { parseArray, parseBoolean } from "../src/common/ops.js";
16
+ import { clampValue } from "../src/common/ops.js";
17
+ import { fetchStats } from "../src/fetchers/stats.js";
18
+ import { isLocaleAvailable } from "../src/translations.js";
19
+
20
+ // @ts-ignore
21
+ export default async (req, res) => {
22
+ const {
23
+ username,
24
+ hide,
25
+ hide_title,
26
+ hide_border,
27
+ card_width,
28
+ hide_rank,
29
+ show_icons,
30
+ include_all_commits,
31
+ commits_year,
32
+ line_height,
33
+ title_color,
34
+ ring_color,
35
+ icon_color,
36
+ text_color,
37
+ text_bold,
38
+ bg_color,
39
+ theme,
40
+ cache_seconds,
41
+ exclude_repo,
42
+ custom_title,
43
+ locale: rawLocale,
44
+ disable_animations,
45
+ border_radius,
46
+ number_format,
47
+ number_precision,
48
+ border_color,
49
+ rank_icon,
50
+ show,
51
+ } = req.query;
52
+
53
+ // Only allow supported locales - validate and sanitize to prevent XSS
54
+ const locale =
55
+ typeof rawLocale === "string" && isLocaleAvailable(rawLocale)
56
+ ? rawLocale.toLowerCase()
57
+ : undefined;
58
+
59
+ setSvgContentType(res);
60
+
61
+ // Create validated color options once for reuse
62
+ const colorOptions = createValidatedColorOptions({
63
+ title_color,
64
+ text_color,
65
+ bg_color,
66
+ border_color,
67
+ theme,
68
+ });
69
+
70
+ const access = guardAccess({
71
+ res,
72
+ id: username,
73
+ type: "username",
74
+ colors: colorOptions,
75
+ });
76
+ if (!access.isPassed) {
77
+ return access.result;
78
+ }
79
+
80
+ try {
81
+ const showStats = parseArray(show);
82
+ const stats = await fetchStats(
83
+ username,
84
+ parseBoolean(include_all_commits),
85
+ parseArray(exclude_repo),
86
+ showStats.includes("prs_merged") ||
87
+ showStats.includes("prs_merged_percentage"),
88
+ showStats.includes("discussions_started"),
89
+ showStats.includes("discussions_answered"),
90
+ parseInt(commits_year, 10),
91
+ );
92
+ const cacheSeconds = resolveCacheSeconds({
93
+ requested: parseInt(cache_seconds, 10),
94
+ def: CACHE_TTL.STATS_CARD.DEFAULT,
95
+ min: CACHE_TTL.STATS_CARD.MIN,
96
+ max: CACHE_TTL.STATS_CARD.MAX,
97
+ });
98
+
99
+ setCacheHeaders(res, cacheSeconds);
100
+
101
+ // Sanitize border_radius: parse, clamp, only include if valid
102
+ const borderRadiusNum = Number(border_radius);
103
+ const sanitizedBorderRadius =
104
+ Number.isFinite(borderRadiusNum) && border_radius !== undefined
105
+ ? clampValue(borderRadiusNum, 0, 50)
106
+ : undefined;
107
+
108
+ const renderOptions = {
109
+ hide: parseArray(hide),
110
+ show_icons: parseBoolean(show_icons),
111
+ hide_title: parseBoolean(hide_title),
112
+ hide_border: parseBoolean(hide_border),
113
+ card_width: parseInt(card_width, 10),
114
+ hide_rank: parseBoolean(hide_rank),
115
+ include_all_commits: parseBoolean(include_all_commits),
116
+ commits_year: parseInt(commits_year, 10),
117
+ line_height,
118
+ title_color,
119
+ ring_color,
120
+ icon_color,
121
+ text_color,
122
+ text_bold: parseBoolean(text_bold),
123
+ bg_color,
124
+ theme,
125
+ // Validate custom_title is a string (prevents array from duplicate query params)
126
+ // Card.js handles HTML encoding internally
127
+ custom_title: typeof custom_title === "string" ? custom_title : undefined,
128
+ border_color,
129
+ number_format,
130
+ number_precision: parseInt(number_precision, 10),
131
+ locale,
132
+ disable_animations: parseBoolean(disable_animations),
133
+ rank_icon,
134
+ show: showStats,
135
+ };
136
+
137
+ // Only include border_radius if it's valid, otherwise let Card use default
138
+ if (sanitizedBorderRadius !== undefined) {
139
+ renderOptions.border_radius = sanitizedBorderRadius;
140
+ }
141
+
142
+ return res.send(renderStatsCard(stats, renderOptions));
143
+ } catch (err) {
144
+ return handleApiError({ res, error: err, colorOptions });
145
+ }
146
+ };
package/api/pin.js ADDED
@@ -0,0 +1,114 @@
1
+ // @ts-check
2
+
3
+ import { renderRepoCard } from "../src/cards/repo.js";
4
+ import { guardAccess } from "../src/common/access.js";
5
+ import {
6
+ createValidatedColorOptions,
7
+ handleApiError,
8
+ setSvgContentType,
9
+ } from "../src/common/api-utils.js";
10
+ import {
11
+ CACHE_TTL,
12
+ resolveCacheSeconds,
13
+ setCacheHeaders,
14
+ } from "../src/common/cache.js";
15
+ import { clampValue, parseBoolean } from "../src/common/ops.js";
16
+ import { fetchRepo } from "../src/fetchers/repo.js";
17
+ import { isLocaleAvailable } from "../src/translations.js";
18
+
19
+ // @ts-ignore
20
+ export default async (req, res) => {
21
+ const {
22
+ username,
23
+ repo,
24
+ hide_border,
25
+ title_color,
26
+ icon_color,
27
+ text_color,
28
+ bg_color,
29
+ theme,
30
+ show_owner,
31
+ cache_seconds,
32
+ locale: rawLocale,
33
+ border_radius: rawBorderRadius,
34
+ border_color,
35
+ description_lines_count,
36
+ } = req.query;
37
+
38
+ // Only allow supported locales - validate and sanitize to prevent XSS
39
+ // Validate and sanitize border_radius to prevent XSS
40
+ const border_radius = (() => {
41
+ const br = parseFloat(rawBorderRadius);
42
+ if (isNaN(br)) {
43
+ return 4.5;
44
+ }
45
+ // Clamp to reasonable range; SVG border radius shouldn't exceed half width/height.
46
+ return Math.max(0, Math.min(br, 50));
47
+ })();
48
+ const locale =
49
+ typeof rawLocale === "string" && isLocaleAvailable(rawLocale)
50
+ ? rawLocale.toLowerCase()
51
+ : undefined;
52
+
53
+ setSvgContentType(res);
54
+
55
+ // Create validated color options once for reuse
56
+ const colorOptions = createValidatedColorOptions({
57
+ title_color,
58
+ text_color,
59
+ bg_color,
60
+ border_color,
61
+ theme,
62
+ });
63
+
64
+ const access = guardAccess({
65
+ res,
66
+ id: username,
67
+ type: "username",
68
+ colors: colorOptions,
69
+ });
70
+ if (!access.isPassed) {
71
+ return access.result;
72
+ }
73
+
74
+ try {
75
+ const repoData = await fetchRepo(username, repo);
76
+ const cacheSeconds = resolveCacheSeconds({
77
+ requested: parseInt(cache_seconds, 10),
78
+ def: CACHE_TTL.PIN_CARD.DEFAULT,
79
+ min: CACHE_TTL.PIN_CARD.MIN,
80
+ max: CACHE_TTL.PIN_CARD.MAX,
81
+ });
82
+
83
+ setCacheHeaders(res, cacheSeconds);
84
+
85
+ // Sanitize border_radius: parse, clamp, only include if valid
86
+ const borderRadiusNum = Number(border_radius);
87
+ const sanitizedBorderRadius =
88
+ Number.isFinite(borderRadiusNum) && border_radius !== undefined
89
+ ? clampValue(borderRadiusNum, 0, 50)
90
+ : undefined;
91
+
92
+ const renderOptions = {
93
+ hide_border: parseBoolean(hide_border),
94
+ title_color,
95
+ icon_color,
96
+ text_color,
97
+ bg_color,
98
+ theme,
99
+ border_color,
100
+ show_owner: parseBoolean(show_owner),
101
+ locale,
102
+ description_lines_count,
103
+ };
104
+
105
+ // Only include border_radius if it's valid, otherwise let Card use default
106
+ if (sanitizedBorderRadius !== undefined) {
107
+ renderOptions.border_radius = sanitizedBorderRadius;
108
+ }
109
+
110
+ return res.send(renderRepoCard(repoData, renderOptions));
111
+ } catch (err) {
112
+ return handleApiError({ res, error: err, colorOptions });
113
+ }
114
+ };
@@ -0,0 +1,193 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @file Contains a simple cloud function that can be used to check which PATs are no
5
+ * longer working. It returns a list of valid PATs, expired PATs and PATs with errors.
6
+ *
7
+ * @description This function is currently rate limited to 1 request per 5 minutes.
8
+ */
9
+
10
+ import { request } from "../../src/common/http.js";
11
+ import { logger } from "../../src/common/log.js";
12
+ import { dateDiff } from "../../src/common/ops.js";
13
+ import { encodeHTML } from "../../src/common/html.js";
14
+ import { setJsonContentType } from "../../src/common/api-utils.js";
15
+
16
+ export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 5 minutes
17
+
18
+ /**
19
+ * Simple uptime check fetcher for the PATs.
20
+ *
21
+ * @param {Record<string, unknown>} variables Fetcher variables.
22
+ * @param {string} token GitHub token.
23
+ * @returns {Promise<import('axios').AxiosResponse>} The response.
24
+ */
25
+ const uptimeFetcher = (variables, token) => {
26
+ return request(
27
+ {
28
+ query: `
29
+ query {
30
+ rateLimit {
31
+ remaining
32
+ resetAt
33
+ },
34
+ }`,
35
+ variables,
36
+ },
37
+ {
38
+ Authorization: `bearer ${token}`,
39
+ },
40
+ );
41
+ };
42
+
43
+ /**
44
+ * Retrieves all PAT environment variable keys.
45
+ *
46
+ * @returns {string[]} Array of PAT environment variable names.
47
+ */
48
+ const getAllPATs = () => {
49
+ return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key));
50
+ };
51
+
52
+ /**
53
+ * @typedef {(variables: Record<string, unknown>, token: string) => Promise<import('axios').AxiosResponse>} Fetcher
54
+ */
55
+
56
+ /**
57
+ * @typedef {Object} PATDetails
58
+ * @property {string} status - The PAT status.
59
+ * @property {number} [remaining] - Remaining API calls.
60
+ * @property {string} [resetIn] - Time until rate limit reset.
61
+ * @property {{ type: string, message: string }} [error] - Error details.
62
+ */
63
+
64
+ /**
65
+ * @typedef {Object} PATInfo
66
+ * @property {string[]} validPATs - List of valid PATs.
67
+ * @property {string[]} expiredPATs - List of expired PATs.
68
+ * @property {string[]} exhaustedPATs - List of rate-limited PATs.
69
+ * @property {string[]} suspendedPATs - List of suspended PATs.
70
+ * @property {string[]} errorPATs - List of PATs with errors.
71
+ * @property {Record<string, PATDetails>} details - Detailed status of each PAT.
72
+ */
73
+
74
+ /**
75
+ * Check whether any of the PATs is expired.
76
+ *
77
+ * @param {Fetcher} fetcher The fetcher function.
78
+ * @param {Record<string, unknown>} variables Fetcher variables.
79
+ * @returns {Promise<PATInfo>} The response.
80
+ */
81
+ const getPATInfo = async (fetcher, variables) => {
82
+ /** @type {Record<string, PATDetails>} */
83
+ const details = {};
84
+ const PATs = getAllPATs();
85
+
86
+ for (const pat of PATs) {
87
+ try {
88
+ const token = process.env[pat];
89
+ if (!token) {
90
+ continue;
91
+ }
92
+
93
+ const response = await fetcher(variables, token);
94
+ const errors = response.data.errors;
95
+ const hasErrors = Boolean(errors);
96
+ const errorType = errors?.[0]?.type;
97
+ const isRateLimited =
98
+ (hasErrors && errorType === "RATE_LIMITED") ||
99
+ response.data.data?.rateLimit?.remaining === 0;
100
+
101
+ // Store PATs with errors
102
+ if (hasErrors && errorType !== "RATE_LIMITED") {
103
+ details[pat] = {
104
+ status: "error",
105
+ error: {
106
+ type: errors[0].type,
107
+ message: errors[0].message,
108
+ },
109
+ };
110
+ continue;
111
+ }
112
+
113
+ if (isRateLimited) {
114
+ const now = new Date();
115
+ const resetAt = new Date(response.data?.data?.rateLimit?.resetAt);
116
+ details[pat] = {
117
+ status: "exhausted",
118
+ remaining: 0,
119
+ resetIn: dateDiff(resetAt, now) + " minutes",
120
+ };
121
+ } else {
122
+ details[pat] = {
123
+ status: "valid",
124
+ remaining: response.data.data.rateLimit.remaining,
125
+ };
126
+ }
127
+ } catch (err) {
128
+ // Handle known error responses
129
+ const errorMessage = err?.response?.data?.message?.toLowerCase();
130
+ if (errorMessage === "bad credentials") {
131
+ details[pat] = { status: "expired" };
132
+ } else if (errorMessage === "sorry. your account was suspended.") {
133
+ details[pat] = { status: "suspended" };
134
+ } else {
135
+ throw err;
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Filters PATs by status.
142
+ * @param {string} status - The status to filter by.
143
+ * @returns {string[]} PATs with the specified status.
144
+ */
145
+ const filterPATsByStatus = (status) => {
146
+ return Object.keys(details).filter((pat) => details[pat].status === status);
147
+ };
148
+
149
+ // Sort details by key for consistent output
150
+ const sortedDetails = Object.keys(details)
151
+ .sort()
152
+ .reduce((obj, key) => {
153
+ obj[key] = details[key];
154
+ return obj;
155
+ }, /** @type {Record<string, PATDetails>} */ ({}));
156
+
157
+ return {
158
+ validPATs: filterPATsByStatus("valid"),
159
+ expiredPATs: filterPATsByStatus("expired"),
160
+ exhaustedPATs: filterPATsByStatus("exhausted"),
161
+ suspendedPATs: filterPATsByStatus("suspended"),
162
+ errorPATs: filterPATsByStatus("error"),
163
+ details: sortedDetails,
164
+ };
165
+ };
166
+
167
+ /**
168
+ * Cloud function that returns information about the used PATs.
169
+ *
170
+ * @param {unknown} _req The request (unused).
171
+ * @param {import('express').Response} res The response.
172
+ * @returns {Promise<void>} The response.
173
+ */
174
+ export default async (_req, res) => {
175
+ setJsonContentType(res);
176
+
177
+ try {
178
+ const PATsInfo = await getPATInfo(uptimeFetcher, {});
179
+ if (PATsInfo) {
180
+ res.setHeader(
181
+ "Cache-Control",
182
+ `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`,
183
+ );
184
+ }
185
+ res.send(JSON.stringify(PATsInfo, null, 2));
186
+ } catch (err) {
187
+ logger.error(err);
188
+ res.setHeader("Cache-Control", "no-store");
189
+ const errorMessage = err instanceof Error ? err.message : "Unknown error";
190
+ const safeMessage = encodeHTML(errorMessage);
191
+ res.send("Something went wrong: " + safeMessage);
192
+ }
193
+ };
@@ -0,0 +1,129 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @file Contains a simple cloud function that can be used to check if the PATs are still
5
+ * functional.
6
+ *
7
+ * @description This function is currently rate limited to 1 request per 5 minutes.
8
+ */
9
+
10
+ import { request } from "../../src/common/http.js";
11
+ import retryer from "../../src/common/retryer.js";
12
+ import { logger } from "../../src/common/log.js";
13
+ import { encodeHTML } from "../../src/common/html.js";
14
+ import { setJsonContentType } from "../../src/common/api-utils.js";
15
+
16
+ export const RATE_LIMIT_SECONDS = 60 * 5; // 1 request per 5 minutes
17
+
18
+ /**
19
+ * Simple uptime check fetcher for the PATs.
20
+ *
21
+ * @param {Record<string, unknown>} variables Fetcher variables.
22
+ * @param {string} token GitHub token.
23
+ * @returns {Promise<import('axios').AxiosResponse>} The response.
24
+ */
25
+ const uptimeFetcher = (variables, token) => {
26
+ return request(
27
+ {
28
+ query: `
29
+ query {
30
+ rateLimit {
31
+ remaining
32
+ }
33
+ }
34
+ `,
35
+ variables,
36
+ },
37
+ {
38
+ Authorization: `bearer ${token}`,
39
+ },
40
+ );
41
+ };
42
+
43
+ /**
44
+ * @typedef {Object} ShieldsResponse
45
+ * @property {number} schemaVersion - Shields.io schema version.
46
+ * @property {string} label - Badge label.
47
+ * @property {"up" | "down"} message - Status message.
48
+ * @property {"brightgreen" | "red"} color - Badge color.
49
+ * @property {boolean} isError - Whether this is an error state.
50
+ */
51
+
52
+ /**
53
+ * Creates JSON response for shields.io dynamic card generation.
54
+ *
55
+ * @param {boolean} up Whether the PATs are up or not.
56
+ * @returns {ShieldsResponse} Dynamic shields.io JSON response object.
57
+ * @see https://shields.io/endpoint
58
+ */
59
+ const shieldsUptimeBadge = (up) => ({
60
+ schemaVersion: 1,
61
+ label: "Public Instance",
62
+ message: up ? "up" : "down",
63
+ color: up ? "brightgreen" : "red",
64
+ isError: true,
65
+ });
66
+
67
+ /**
68
+ * Validates and normalizes the response type parameter.
69
+ *
70
+ * @param {string|undefined} type - The type parameter from query.
71
+ * @returns {"shields" | "json" | "boolean"} Normalized type value.
72
+ */
73
+ const normalizeResponseType = (type) => {
74
+ const normalized = typeof type === "string" ? type.toLowerCase() : "boolean";
75
+ if (normalized === "shields" || normalized === "json") {
76
+ return normalized;
77
+ }
78
+ return "boolean";
79
+ };
80
+
81
+ /**
82
+ * Cloud function that returns whether the PATs are still functional.
83
+ *
84
+ * @param {import('express').Request} req The request.
85
+ * @param {import('express').Response} res The response.
86
+ * @returns {Promise<void>} Nothing.
87
+ */
88
+ export default async (req, res) => {
89
+ const responseType = normalizeResponseType(req.query.type);
90
+
91
+ setJsonContentType(res);
92
+
93
+ try {
94
+ let PATsValid = true;
95
+ try {
96
+ await retryer(uptimeFetcher, {});
97
+ } catch {
98
+ // PAT validation failed - mark as invalid
99
+ PATsValid = false;
100
+ }
101
+
102
+ if (PATsValid) {
103
+ res.setHeader(
104
+ "Cache-Control",
105
+ `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`,
106
+ );
107
+ } else {
108
+ res.setHeader("Cache-Control", "no-store");
109
+ }
110
+
111
+ switch (responseType) {
112
+ case "shields":
113
+ res.send(shieldsUptimeBadge(PATsValid));
114
+ break;
115
+ case "json":
116
+ res.send({ up: PATsValid });
117
+ break;
118
+ default:
119
+ res.send(PATsValid);
120
+ break;
121
+ }
122
+ } catch (err) {
123
+ logger.error(err);
124
+ res.setHeader("Cache-Control", "no-store");
125
+ const errorMessage = err instanceof Error ? err.message : "Unknown error";
126
+ const safeMessage = encodeHTML(errorMessage);
127
+ res.send("Something went wrong: " + safeMessage);
128
+ }
129
+ };