@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.
- package/LICENSE +21 -0
- package/api/gist.js +98 -0
- package/api/index.js +146 -0
- package/api/pin.js +114 -0
- package/api/status/pat-info.js +193 -0
- package/api/status/up.js +129 -0
- package/api/top-langs.js +163 -0
- package/api/wakatime.js +129 -0
- package/package.json +102 -0
- package/readme.md +412 -0
- package/src/calculateRank.js +87 -0
- package/src/cards/gist.js +151 -0
- package/src/cards/index.js +4 -0
- package/src/cards/repo.js +199 -0
- package/src/cards/stats.js +607 -0
- package/src/cards/top-languages.js +968 -0
- package/src/cards/types.d.ts +67 -0
- package/src/cards/wakatime.js +482 -0
- package/src/common/Card.js +294 -0
- package/src/common/I18n.js +41 -0
- package/src/common/access.js +69 -0
- package/src/common/api-utils.js +221 -0
- package/src/common/blacklist.js +10 -0
- package/src/common/cache.js +153 -0
- package/src/common/color.js +194 -0
- package/src/common/envs.js +15 -0
- package/src/common/error.js +84 -0
- package/src/common/fmt.js +90 -0
- package/src/common/html.js +43 -0
- package/src/common/http.js +24 -0
- package/src/common/icons.js +63 -0
- package/src/common/index.js +13 -0
- package/src/common/languageColors.json +651 -0
- package/src/common/log.js +14 -0
- package/src/common/ops.js +124 -0
- package/src/common/render.js +261 -0
- package/src/common/retryer.js +97 -0
- package/src/common/worker-adapter.js +148 -0
- package/src/common/worker-env.js +48 -0
- package/src/fetchers/gist.js +114 -0
- package/src/fetchers/repo.js +118 -0
- package/src/fetchers/stats.js +350 -0
- package/src/fetchers/top-languages.js +192 -0
- package/src/fetchers/types.d.ts +118 -0
- package/src/fetchers/wakatime.js +109 -0
- package/src/index.js +2 -0
- package/src/translations.js +1105 -0
- package/src/worker.ts +116 -0
- package/themes/README.md +229 -0
- package/themes/index.js +467 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { encodeHTML, escapeCSSValue } from "./html.js";
|
|
4
|
+
import { flexLayout } from "./render.js";
|
|
5
|
+
|
|
6
|
+
class Card {
|
|
7
|
+
/**
|
|
8
|
+
* Creates a new card instance.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} args Card arguments.
|
|
11
|
+
* @param {number=} args.width Card width.
|
|
12
|
+
* @param {number=} args.height Card height.
|
|
13
|
+
* @param {number=} args.border_radius Card border radius.
|
|
14
|
+
* @param {string=} args.customTitle Card custom title.
|
|
15
|
+
* @param {string=} args.defaultTitle Card default title.
|
|
16
|
+
* @param {string=} args.titlePrefixIcon Card title prefix icon.
|
|
17
|
+
* @param {object} [args.colors={}] Card colors arguments.
|
|
18
|
+
* @param {string=} args.colors.titleColor Card title color.
|
|
19
|
+
* @param {string=} args.colors.textColor Card text color.
|
|
20
|
+
* @param {string=} args.colors.iconColor Card icon color.
|
|
21
|
+
* @param {string|string[]=} args.colors.bgColor Card background color.
|
|
22
|
+
* @param {string=} args.colors.borderColor Card border color.
|
|
23
|
+
*/
|
|
24
|
+
constructor({
|
|
25
|
+
width = 100,
|
|
26
|
+
height = 100,
|
|
27
|
+
border_radius = 4.5,
|
|
28
|
+
colors = {},
|
|
29
|
+
customTitle,
|
|
30
|
+
defaultTitle = "",
|
|
31
|
+
titlePrefixIcon,
|
|
32
|
+
}) {
|
|
33
|
+
this.width = width;
|
|
34
|
+
this.height = height;
|
|
35
|
+
|
|
36
|
+
this.hideBorder = false;
|
|
37
|
+
this.hideTitle = false;
|
|
38
|
+
|
|
39
|
+
this.border_radius = border_radius;
|
|
40
|
+
|
|
41
|
+
// returns theme based colors with proper overrides and defaults
|
|
42
|
+
this.colors = colors;
|
|
43
|
+
this.title =
|
|
44
|
+
customTitle === undefined
|
|
45
|
+
? encodeHTML(defaultTitle)
|
|
46
|
+
: encodeHTML(customTitle);
|
|
47
|
+
|
|
48
|
+
this.css = "";
|
|
49
|
+
|
|
50
|
+
this.paddingX = 25;
|
|
51
|
+
this.paddingY = 35;
|
|
52
|
+
this.titlePrefixIcon = titlePrefixIcon;
|
|
53
|
+
this.animations = true;
|
|
54
|
+
this.a11yTitle = "";
|
|
55
|
+
this.a11yDesc = "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
61
|
+
disableAnimations() {
|
|
62
|
+
this.animations = false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {Object} props The props object.
|
|
67
|
+
* @param {string} props.title Accessibility title.
|
|
68
|
+
* @param {string} props.desc Accessibility description.
|
|
69
|
+
* @returns {void}
|
|
70
|
+
*/
|
|
71
|
+
setAccessibilityLabel({ title, desc }) {
|
|
72
|
+
this.a11yTitle = title;
|
|
73
|
+
this.a11yDesc = desc;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} value The CSS to add to the card.
|
|
78
|
+
* @returns {void}
|
|
79
|
+
*/
|
|
80
|
+
setCSS(value) {
|
|
81
|
+
this.css = value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {boolean} value Whether to hide the border or not.
|
|
86
|
+
* @returns {void}
|
|
87
|
+
*/
|
|
88
|
+
setHideBorder(value) {
|
|
89
|
+
this.hideBorder = value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {boolean} value Whether to hide the title or not.
|
|
94
|
+
* @returns {void}
|
|
95
|
+
*/
|
|
96
|
+
setHideTitle(value) {
|
|
97
|
+
this.hideTitle = value;
|
|
98
|
+
if (value) {
|
|
99
|
+
this.height -= 30;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} text The title to set.
|
|
105
|
+
* @returns {void}
|
|
106
|
+
*/
|
|
107
|
+
setTitle(text) {
|
|
108
|
+
this.title = text;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @returns {string} The rendered card title.
|
|
113
|
+
*/
|
|
114
|
+
renderTitle() {
|
|
115
|
+
const titleText = `
|
|
116
|
+
<text
|
|
117
|
+
x="0"
|
|
118
|
+
y="0"
|
|
119
|
+
class="header"
|
|
120
|
+
data-testid="header"
|
|
121
|
+
>${this.title}</text>
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
const prefixIcon = `
|
|
125
|
+
<svg
|
|
126
|
+
class="icon"
|
|
127
|
+
x="0"
|
|
128
|
+
y="-13"
|
|
129
|
+
viewBox="0 0 16 16"
|
|
130
|
+
version="1.1"
|
|
131
|
+
width="16"
|
|
132
|
+
height="16"
|
|
133
|
+
>
|
|
134
|
+
${this.titlePrefixIcon}
|
|
135
|
+
</svg>
|
|
136
|
+
`;
|
|
137
|
+
return `
|
|
138
|
+
<g
|
|
139
|
+
data-testid="card-title"
|
|
140
|
+
transform="translate(${this.paddingX}, ${this.paddingY})"
|
|
141
|
+
>
|
|
142
|
+
${flexLayout({
|
|
143
|
+
items: [this.titlePrefixIcon ? prefixIcon : "", titleText],
|
|
144
|
+
gap: 25,
|
|
145
|
+
}).join("")}
|
|
146
|
+
</g>
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @returns {string} The rendered card gradient.
|
|
152
|
+
*/
|
|
153
|
+
renderGradient() {
|
|
154
|
+
if (typeof this.colors.bgColor !== "object") {
|
|
155
|
+
return "";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const gradients = this.colors.bgColor.slice(1);
|
|
159
|
+
return typeof this.colors.bgColor === "object"
|
|
160
|
+
? `
|
|
161
|
+
<defs>
|
|
162
|
+
<linearGradient
|
|
163
|
+
id="gradient"
|
|
164
|
+
gradientTransform="rotate(${this.colors.bgColor[0]})"
|
|
165
|
+
gradientUnits="userSpaceOnUse"
|
|
166
|
+
>
|
|
167
|
+
${gradients.map((grad, index) => {
|
|
168
|
+
let offset = (index * 100) / (gradients.length - 1);
|
|
169
|
+
return `<stop offset="${offset}%" stop-color="#${grad}" />`;
|
|
170
|
+
})}
|
|
171
|
+
</linearGradient>
|
|
172
|
+
</defs>
|
|
173
|
+
`
|
|
174
|
+
: "";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Retrieves css animations for a card.
|
|
179
|
+
*
|
|
180
|
+
* @returns {string} Animation css.
|
|
181
|
+
*/
|
|
182
|
+
getAnimations = () => {
|
|
183
|
+
return `
|
|
184
|
+
/* Animations */
|
|
185
|
+
@keyframes scaleInAnimation {
|
|
186
|
+
from {
|
|
187
|
+
transform: translate(-5px, 5px) scale(0);
|
|
188
|
+
}
|
|
189
|
+
to {
|
|
190
|
+
transform: translate(-5px, 5px) scale(1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
@keyframes fadeInAnimation {
|
|
194
|
+
from {
|
|
195
|
+
opacity: 0;
|
|
196
|
+
}
|
|
197
|
+
to {
|
|
198
|
+
opacity: 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
`;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Renders the card with inner body HTML/SVG.
|
|
206
|
+
*
|
|
207
|
+
* **Security Model:**
|
|
208
|
+
* This method accepts pre-built SVG content from internal rendering functions.
|
|
209
|
+
* All user-controlled data (custom_title, descriptions, language names, etc.)
|
|
210
|
+
* MUST be sanitized using `escapeHtml()` or `escapeCSSValue()` BEFORE being
|
|
211
|
+
* included in the body content. The sanitization happens at the data entry points
|
|
212
|
+
* in the individual card rendering functions (stats.js, repo.js, gist.js, etc.),
|
|
213
|
+
* not at this aggregation point.
|
|
214
|
+
*
|
|
215
|
+
* This is a trusted internal API - the body parameter contains SVG elements
|
|
216
|
+
* that have already been constructed with proper escaping of dynamic values.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} body The inner body of the card (pre-sanitized SVG content).
|
|
219
|
+
* @returns {string} The rendered card.
|
|
220
|
+
*/
|
|
221
|
+
render(body) {
|
|
222
|
+
// Security: body contains pre-sanitized SVG from internal render functions.
|
|
223
|
+
// User inputs are escaped at source using escapeHtml() before reaching here.
|
|
224
|
+
// Sanitize color values to prevent XSS in SVG attributes
|
|
225
|
+
const safeTitleColor = escapeCSSValue(this.colors.titleColor || "");
|
|
226
|
+
const safeBorderColor = escapeCSSValue(this.colors.borderColor || "");
|
|
227
|
+
const safeBgColor =
|
|
228
|
+
typeof this.colors.bgColor === "object"
|
|
229
|
+
? "url(#gradient)"
|
|
230
|
+
: escapeCSSValue(this.colors.bgColor || "");
|
|
231
|
+
|
|
232
|
+
return `
|
|
233
|
+
<svg
|
|
234
|
+
width="${this.width}"
|
|
235
|
+
height="${this.height}"
|
|
236
|
+
viewBox="0 0 ${this.width} ${this.height}"
|
|
237
|
+
fill="none"
|
|
238
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
239
|
+
role="img"
|
|
240
|
+
aria-labelledby="descId"
|
|
241
|
+
>
|
|
242
|
+
<title id="titleId">${encodeHTML(this.a11yTitle)}</title>
|
|
243
|
+
<desc id="descId">${encodeHTML(this.a11yDesc)}</desc>
|
|
244
|
+
<style>
|
|
245
|
+
.header {
|
|
246
|
+
font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif;
|
|
247
|
+
fill: ${safeTitleColor};
|
|
248
|
+
animation: fadeInAnimation 0.8s ease-in-out forwards;
|
|
249
|
+
}
|
|
250
|
+
@supports(-moz-appearance: auto) {
|
|
251
|
+
/* Selector detects Firefox */
|
|
252
|
+
.header { font-size: 15.5px; }
|
|
253
|
+
}
|
|
254
|
+
${this.css}
|
|
255
|
+
|
|
256
|
+
${process.env.NODE_ENV === "test" ? "" : this.getAnimations()}
|
|
257
|
+
${
|
|
258
|
+
this.animations === false
|
|
259
|
+
? `* { animation-duration: 0s !important; animation-delay: 0s !important; }`
|
|
260
|
+
: ""
|
|
261
|
+
}
|
|
262
|
+
</style>
|
|
263
|
+
|
|
264
|
+
${this.renderGradient()}
|
|
265
|
+
|
|
266
|
+
<rect
|
|
267
|
+
data-testid="card-bg"
|
|
268
|
+
x="0.5"
|
|
269
|
+
y="0.5"
|
|
270
|
+
rx="${this.border_radius}"
|
|
271
|
+
height="99%"
|
|
272
|
+
stroke="${safeBorderColor}"
|
|
273
|
+
width="${this.width - 1}"
|
|
274
|
+
fill="${safeBgColor}"
|
|
275
|
+
stroke-opacity="${this.hideBorder ? 0 : 1}"
|
|
276
|
+
/>
|
|
277
|
+
|
|
278
|
+
${this.hideTitle ? "" : this.renderTitle()}
|
|
279
|
+
|
|
280
|
+
<g
|
|
281
|
+
data-testid="main-card-body"
|
|
282
|
+
transform="translate(0, ${
|
|
283
|
+
this.hideTitle ? this.paddingX : this.paddingY + 20
|
|
284
|
+
})"
|
|
285
|
+
>
|
|
286
|
+
${body}
|
|
287
|
+
</g>
|
|
288
|
+
</svg>
|
|
289
|
+
`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export { Card };
|
|
294
|
+
export default Card;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const FALLBACK_LOCALE = "en";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* I18n translation class.
|
|
7
|
+
*/
|
|
8
|
+
class I18n {
|
|
9
|
+
/**
|
|
10
|
+
* Constructor.
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} options Options.
|
|
13
|
+
* @param {string=} options.locale Locale.
|
|
14
|
+
* @param {any} options.translations Translations.
|
|
15
|
+
*/
|
|
16
|
+
constructor({ locale, translations }) {
|
|
17
|
+
this.locale = locale || FALLBACK_LOCALE;
|
|
18
|
+
this.translations = translations;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get translation.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} str String to translate.
|
|
25
|
+
* @returns {string} Translated string.
|
|
26
|
+
*/
|
|
27
|
+
t(str) {
|
|
28
|
+
if (!this.translations[str]) {
|
|
29
|
+
throw new Error(`${str} Translation string not found`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!this.translations[str][this.locale]) {
|
|
33
|
+
throw new Error(`'${str}' translation not found for requested locale'`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return this.translations[str][this.locale];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { I18n };
|
|
41
|
+
export default I18n;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { renderError } from "./render.js";
|
|
4
|
+
import { blacklist } from "./blacklist.js";
|
|
5
|
+
import { whitelist, gistWhitelist } from "./envs.js";
|
|
6
|
+
|
|
7
|
+
const NOT_WHITELISTED_USERNAME_MESSAGE = "This username is not whitelisted";
|
|
8
|
+
const NOT_WHITELISTED_GIST_MESSAGE = "This gist ID is not whitelisted";
|
|
9
|
+
const BLACKLISTED_MESSAGE = "This username is blacklisted";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Guards access using whitelist/blacklist.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} args The parameters object.
|
|
15
|
+
* @param {any} args.res The response object.
|
|
16
|
+
* @param {string} args.id Resource identifier (username or gist id).
|
|
17
|
+
* @param {"username"|"gist"|"wakatime"} args.type The type of identifier.
|
|
18
|
+
* @param {{ title_color?: string, text_color?: string, bg_color?: string, border_color?: string, theme?: string }} args.colors Color options for the error card.
|
|
19
|
+
* @returns {{ isPassed: boolean, result?: any }} The result object indicating success or failure.
|
|
20
|
+
*/
|
|
21
|
+
const guardAccess = ({ res, id, type, colors }) => {
|
|
22
|
+
if (!["username", "gist", "wakatime"].includes(type)) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
'Invalid type. Expected "username", "gist", or "wakatime".',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const currentWhitelist = type === "gist" ? gistWhitelist : whitelist;
|
|
29
|
+
const notWhitelistedMsg =
|
|
30
|
+
type === "gist"
|
|
31
|
+
? NOT_WHITELISTED_GIST_MESSAGE
|
|
32
|
+
: NOT_WHITELISTED_USERNAME_MESSAGE;
|
|
33
|
+
|
|
34
|
+
if (Array.isArray(currentWhitelist) && !currentWhitelist.includes(id)) {
|
|
35
|
+
const result = res.send(
|
|
36
|
+
renderError({
|
|
37
|
+
message: notWhitelistedMsg,
|
|
38
|
+
secondaryMessage: "Please deploy your own instance",
|
|
39
|
+
renderOptions: {
|
|
40
|
+
...colors,
|
|
41
|
+
show_repo_link: false,
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
return { isPassed: false, result };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (
|
|
49
|
+
type === "username" &&
|
|
50
|
+
currentWhitelist === undefined &&
|
|
51
|
+
blacklist.includes(id)
|
|
52
|
+
) {
|
|
53
|
+
const result = res.send(
|
|
54
|
+
renderError({
|
|
55
|
+
message: BLACKLISTED_MESSAGE,
|
|
56
|
+
secondaryMessage: "Please deploy your own instance",
|
|
57
|
+
renderOptions: {
|
|
58
|
+
...colors,
|
|
59
|
+
show_repo_link: false,
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
return { isPassed: false, result };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { isPassed: true };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export { guardAccess };
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Shared API utilities for request handling, validation, and error responses.
|
|
5
|
+
* Centralizes common patterns to reduce code duplication and improve maintainability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { renderError } from "./render.js";
|
|
9
|
+
import { validateColor, validateTheme } from "./color.js";
|
|
10
|
+
import { MissingParamError, retrieveSecondaryMessage } from "./error.js";
|
|
11
|
+
import { setErrorCacheHeaders } from "./cache.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} ColorOptions
|
|
15
|
+
* @property {string|undefined} title_color
|
|
16
|
+
* @property {string|undefined} text_color
|
|
17
|
+
* @property {string|undefined} bg_color
|
|
18
|
+
* @property {string|undefined} border_color
|
|
19
|
+
* @property {string|undefined} theme
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} RawColorParams
|
|
24
|
+
* @property {string} [title_color]
|
|
25
|
+
* @property {string} [text_color]
|
|
26
|
+
* @property {string} [bg_color]
|
|
27
|
+
* @property {string} [border_color]
|
|
28
|
+
* @property {string} [theme]
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates validated color options from raw query parameters.
|
|
33
|
+
* All color values are validated and sanitized to prevent XSS attacks.
|
|
34
|
+
*
|
|
35
|
+
* @param {RawColorParams} params - Raw color parameters from query string.
|
|
36
|
+
* @returns {ColorOptions} Validated and sanitized color options.
|
|
37
|
+
*/
|
|
38
|
+
const createValidatedColorOptions = ({
|
|
39
|
+
title_color,
|
|
40
|
+
text_color,
|
|
41
|
+
bg_color,
|
|
42
|
+
border_color,
|
|
43
|
+
theme,
|
|
44
|
+
}) => ({
|
|
45
|
+
title_color: validateColor(title_color),
|
|
46
|
+
text_color: validateColor(text_color),
|
|
47
|
+
bg_color: validateColor(bg_color),
|
|
48
|
+
border_color: validateColor(border_color),
|
|
49
|
+
theme: validateTheme(theme),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {Object} ErrorResponseOptions
|
|
54
|
+
* @property {any} res - Express response object.
|
|
55
|
+
* @property {Error|unknown} error - The error that occurred.
|
|
56
|
+
* @property {ColorOptions} colorOptions - Validated color options for error card styling.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Patterns that indicate error messages containing user-controlled data.
|
|
61
|
+
* These patterns are replaced with safe generic alternatives to prevent XSS.
|
|
62
|
+
* @type {ReadonlyArray<{pattern: RegExp, replacement: string}>}
|
|
63
|
+
*/
|
|
64
|
+
const UNSAFE_MESSAGE_PATTERNS = [
|
|
65
|
+
{
|
|
66
|
+
pattern: /translation not found for/i,
|
|
67
|
+
replacement: "Invalid locale specified",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
pattern: /Could not resolve to a User with the login of/i,
|
|
71
|
+
replacement: "User not found",
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Sanitizes error messages to prevent XSS by filtering out messages that
|
|
77
|
+
* contain user-controlled data (like usernames or locales embedded in errors).
|
|
78
|
+
* Other error messages in this codebase are hardcoded and safe.
|
|
79
|
+
* Note: renderError also applies HTML encoding as an additional safety layer.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} message - The error message to sanitize.
|
|
82
|
+
* @returns {string} A safe error message.
|
|
83
|
+
*/
|
|
84
|
+
const sanitizeErrorMessage = (message) => {
|
|
85
|
+
if (!message || typeof message !== "string") {
|
|
86
|
+
return "An error occurred";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Replace messages containing user-controlled data with safe alternatives
|
|
90
|
+
for (const { pattern, replacement } of UNSAFE_MESSAGE_PATTERNS) {
|
|
91
|
+
if (pattern.test(message)) {
|
|
92
|
+
return replacement;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Other error messages in this codebase are hardcoded strings (safe)
|
|
97
|
+
// renderError will HTML-encode the output as an additional safety layer
|
|
98
|
+
return message;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Handles API errors by setting cache headers and sending a rendered error response.
|
|
103
|
+
* Centralizes error handling logic to ensure consistent behavior across all API endpoints.
|
|
104
|
+
*
|
|
105
|
+
* @param {ErrorResponseOptions} options - Error handling options.
|
|
106
|
+
* @returns {any} The response result.
|
|
107
|
+
*/
|
|
108
|
+
const handleApiError = ({ res, error, colorOptions }) => {
|
|
109
|
+
setErrorCacheHeaders(res);
|
|
110
|
+
|
|
111
|
+
if (error instanceof Error) {
|
|
112
|
+
// Sanitize error message to prevent XSS from user-controlled data in exceptions
|
|
113
|
+
const safeMessage = sanitizeErrorMessage(error.message);
|
|
114
|
+
const rawSecondary = retrieveSecondaryMessage(error);
|
|
115
|
+
const safeSecondary = rawSecondary
|
|
116
|
+
? sanitizeErrorMessage(rawSecondary)
|
|
117
|
+
: undefined;
|
|
118
|
+
|
|
119
|
+
return res.send(
|
|
120
|
+
renderError({
|
|
121
|
+
message: safeMessage,
|
|
122
|
+
secondaryMessage: safeSecondary,
|
|
123
|
+
renderOptions: {
|
|
124
|
+
...colorOptions,
|
|
125
|
+
show_repo_link: !(error instanceof MissingParamError),
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return res.send(
|
|
132
|
+
renderError({
|
|
133
|
+
message: "An unknown error occurred",
|
|
134
|
+
renderOptions: colorOptions,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {Object} ValidationErrorOptions
|
|
141
|
+
* @property {any} res - Express response object.
|
|
142
|
+
* @property {string} message - Primary error message.
|
|
143
|
+
* @property {string} [secondaryMessage] - Secondary error message.
|
|
144
|
+
* @property {ColorOptions} colorOptions - Validated color options for error card styling.
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Sends a validation error response with consistent styling.
|
|
149
|
+
* Used for parameter validation failures before main processing.
|
|
150
|
+
*
|
|
151
|
+
* @param {ValidationErrorOptions} options - Validation error options.
|
|
152
|
+
* @returns {any} The response result.
|
|
153
|
+
*/
|
|
154
|
+
const sendValidationError = ({
|
|
155
|
+
res,
|
|
156
|
+
message,
|
|
157
|
+
secondaryMessage = "",
|
|
158
|
+
colorOptions,
|
|
159
|
+
}) => {
|
|
160
|
+
return res.send(
|
|
161
|
+
renderError({
|
|
162
|
+
message,
|
|
163
|
+
secondaryMessage,
|
|
164
|
+
renderOptions: colorOptions,
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Sets the standard SVG content type header.
|
|
171
|
+
*
|
|
172
|
+
* @param {any} res - Express response object.
|
|
173
|
+
*/
|
|
174
|
+
const setSvgContentType = (res) => {
|
|
175
|
+
res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Sets the standard JSON content type header.
|
|
180
|
+
*
|
|
181
|
+
* @param {any} res - Express response object.
|
|
182
|
+
*/
|
|
183
|
+
const setJsonContentType = (res) => {
|
|
184
|
+
res.setHeader("Content-Type", "application/json");
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Parses and validates a numeric parameter with bounds checking.
|
|
189
|
+
*
|
|
190
|
+
* @param {string|undefined} value - The value to parse.
|
|
191
|
+
* @param {number|undefined} defaultValue - Default value if parsing fails.
|
|
192
|
+
* @param {number} [min] - Minimum allowed value.
|
|
193
|
+
* @param {number} [max] - Maximum allowed value.
|
|
194
|
+
* @returns {number|undefined} The parsed and clamped value.
|
|
195
|
+
*/
|
|
196
|
+
const parseNumericParam = (value, defaultValue, min, max) => {
|
|
197
|
+
if (value === undefined || value === null) {
|
|
198
|
+
return defaultValue;
|
|
199
|
+
}
|
|
200
|
+
const parsed = parseFloat(value);
|
|
201
|
+
if (isNaN(parsed)) {
|
|
202
|
+
return defaultValue;
|
|
203
|
+
}
|
|
204
|
+
let result = parsed;
|
|
205
|
+
if (min !== undefined) {
|
|
206
|
+
result = Math.max(min, result);
|
|
207
|
+
}
|
|
208
|
+
if (max !== undefined) {
|
|
209
|
+
result = Math.min(max, result);
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export {
|
|
215
|
+
createValidatedColorOptions,
|
|
216
|
+
handleApiError,
|
|
217
|
+
sendValidationError,
|
|
218
|
+
setSvgContentType,
|
|
219
|
+
setJsonContentType,
|
|
220
|
+
parseNumericParam,
|
|
221
|
+
};
|