Skip to content

Commit c90e387

Browse files
authored
fix(gittensor-impact-card): encodeURIComponent + XML-escape API values (#4124)
#4121 auto-merged before two follow-up fixes could land on that branch (a merge-timing race, not a revert): repo.replace("/", "%2F") only replaces the first slash (works today by luck; encodeURIComponent is the correct general encoding), and api.gittensor.io response values were interpolated into SVG <text> nodes without XML-escaping (Superagent P2 finding on the sibling awesome-claude PR — a future non-numeric API field could break the generated SVG's XML structure).
1 parent f087b77 commit c90e387

1 file changed

Lines changed: 89 additions & 19 deletions

File tree

scripts/gittensor-impact-card.mjs

Lines changed: 89 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,32 @@ const THEME = {
2525

2626
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2727
const repoRoot = path.resolve(__dirname, "..");
28-
const repoIconPath = path.join(repoRoot, "apps/gittensory-ui/public/brand/gittensory-icon-citron.svg");
28+
const repoIconPath = path.join(
29+
repoRoot,
30+
"apps/gittensory-ui/public/brand/gittensory-icon-citron.svg",
31+
);
2932

3033
function compact(n) {
3134
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
3235
if (n >= 1000) return (n / 1000).toFixed(0) + "k";
3336
return String(n);
3437
}
3538

39+
// api.gittensor.io values (and the repo name) end up as SVG <text> content,
40+
// so escape XML special chars rather than trust they're clean numbers/strings.
41+
function escapeXml(value) {
42+
return String(value)
43+
.replace(/&/g, "&amp;")
44+
.replace(/</g, "&lt;")
45+
.replace(/>/g, "&gt;")
46+
.replace(/"/g, "&quot;")
47+
.replace(/'/g, "&#x27;");
48+
}
49+
3650
async function fetchJson(url) {
37-
const res = await fetch(url, { headers: { "User-Agent": "gittensor-impact-card/1.0" } });
51+
const res = await fetch(url, {
52+
headers: { "User-Agent": "gittensor-impact-card/1.0" },
53+
});
3854
if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
3955
return res.json();
4056
}
@@ -70,8 +86,16 @@ function sparkline(x, y, w, h, values, mutedColor, accentColor, cardBg) {
7086
const range = max - min || 1;
7187
const n = values.length;
7288
const stepX = w / (n - 1);
73-
const pts = values.map((v, i) => [x + i * stepX, y + h - ((v - min) / range) * h]);
74-
const svgPath = pts.map(([px, py], i) => `${i === 0 ? "M" : "L"}${px.toFixed(1)},${py.toFixed(1)}`).join(" ");
89+
const pts = values.map((v, i) => [
90+
x + i * stepX,
91+
y + h - ((v - min) / range) * h,
92+
]);
93+
const svgPath = pts
94+
.map(
95+
([px, py], i) =>
96+
`${i === 0 ? "M" : "L"}${px.toFixed(1)},${py.toFixed(1)}`,
97+
)
98+
.join(" ");
7599
const [lastX, lastY] = pts[pts.length - 1];
76100
return `
77101
<path d="${svgPath}" fill="none" stroke="${mutedColor}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
@@ -88,37 +112,79 @@ function meter(x, y, w, h, value, max, accentColor, trackColor) {
88112

89113
function render({ repo, impact, buckets, gtLogoB64, repoIconB64 }) {
90114
const { cardBg, fg, muted, accent, accentTrack, border, radius } = THEME;
91-
const W = 1200, H = 420;
115+
const W = 1200,
116+
H = 420;
92117
const pad = 56;
93118
const cols = 4;
94119
const colW = (W - 2 * pad) / cols;
95120
const font = "'DM Sans', ui-sans-serif, system-ui, -apple-system, sans-serif";
96-
const displayFont = "'Space Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif";
121+
const displayFont =
122+
"'Space Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif";
97123
const sparkW = colW - 40;
98124
const sparkH = 56;
99125
const sparkY = 150;
100126

101127
const stats = [
102-
{ type: "sparkline", series: buckets.prBuckets, value: impact.totalPRs.toLocaleString(), label: "merged PRs" },
103-
{ type: "sparkline", series: buckets.contributorBuckets, value: String(impact.totalContributors), label: "contributors" },
104-
{ type: "sparkline", series: buckets.locBuckets, value: compact(impact.totalLinesChanged), label: "lines changed" },
105-
{ type: "meter", raw: impact.emissionShare * 100, max: 100, value: `${(impact.emissionShare * 100).toFixed(1)}%`, label: "emission share" },
128+
{
129+
type: "sparkline",
130+
series: buckets.prBuckets,
131+
value: impact.totalPRs.toLocaleString(),
132+
label: "merged PRs",
133+
},
134+
{
135+
type: "sparkline",
136+
series: buckets.contributorBuckets,
137+
value: String(impact.totalContributors),
138+
label: "contributors",
139+
},
140+
{
141+
type: "sparkline",
142+
series: buckets.locBuckets,
143+
value: compact(impact.totalLinesChanged),
144+
label: "lines changed",
145+
},
146+
{
147+
type: "meter",
148+
raw: impact.emissionShare * 100,
149+
max: 100,
150+
value: `${(impact.emissionShare * 100).toFixed(1)}%`,
151+
label: "emission share",
152+
},
106153
];
107154

108155
let statsSvg = "";
109156
stats.forEach((s, i) => {
110157
const x = pad + i * colW;
111158
if (s.type === "meter") {
112-
statsSvg += meter(x, sparkY + sparkH / 2 - 7, sparkW, 14, s.raw, s.max, accent, accentTrack);
159+
statsSvg += meter(
160+
x,
161+
sparkY + sparkH / 2 - 7,
162+
sparkW,
163+
14,
164+
s.raw,
165+
s.max,
166+
accent,
167+
accentTrack,
168+
);
113169
} else {
114-
statsSvg += sparkline(x, sparkY, sparkW, sparkH, s.series, muted, accent, cardBg);
170+
statsSvg += sparkline(
171+
x,
172+
sparkY,
173+
sparkW,
174+
sparkH,
175+
s.series,
176+
muted,
177+
accent,
178+
cardBg,
179+
);
115180
}
116181
statsSvg += `
117-
<text x="${x}" y="${sparkY + sparkH + 78}" font-family="${font}" font-size="60" font-weight="700" fill="${fg}">${s.value}</text>
118-
<text x="${x}" y="${sparkY + sparkH + 112}" font-family="${font}" font-size="21" font-weight="500" fill="${muted}">${s.label}</text>`;
182+
<text x="${x}" y="${sparkY + sparkH + 78}" font-family="${font}" font-size="60" font-weight="700" fill="${fg}">${escapeXml(s.value)}</text>
183+
<text x="${x}" y="${sparkY + sparkH + 112}" font-family="${font}" font-size="21" font-weight="500" fill="${muted}">${escapeXml(s.label)}</text>`;
119184
});
120185

121-
const logoW = 48, logoH = 48 / (708 / 567); // gittensor.io/gt-logo.svg aspect ratio
186+
const logoW = 48,
187+
logoH = 48 / (708 / 567); // gittensor.io/gt-logo.svg aspect ratio
122188
const repoIconSize = 26;
123189
const repoIconX = W - pad - repoIconSize;
124190
const repoIconY = 396 - 14 - (repoIconSize - 16);
@@ -132,25 +198,29 @@ function render({ repo, impact, buckets, gtLogoB64, repoIconB64 }) {
132198
${statsSvg}
133199
<text x="${pad}" y="396" font-family="${font}" font-size="19" font-weight="400" fill="${muted}">Updated weekly &#183; gittensor.io</text>
134200
<image href="data:image/svg+xml;base64,${repoIconB64}" x="${repoIconX}" y="${repoIconY}" width="${repoIconSize}" height="${repoIconSize}"/>
135-
<text x="${repoTextX}" y="396" font-family="${displayFont}" font-size="20" font-weight="500" letter-spacing="-0.01em" fill="${fg}" text-anchor="end">${repo}</text>
201+
<text x="${repoTextX}" y="396" font-family="${displayFont}" font-size="20" font-weight="500" letter-spacing="-0.01em" fill="${fg}" text-anchor="end">${escapeXml(repo)}</text>
136202
</svg>`;
137203
}
138204

139205
async function main() {
140206
const [repo, outFile] = process.argv.slice(2);
141207
if (!repo || !outFile) {
142-
console.error("Usage: node scripts/gittensor-impact-card.mjs <owner/repo> <out-file.svg>");
208+
console.error(
209+
"Usage: node scripts/gittensor-impact-card.mjs <owner/repo> <out-file.svg>",
210+
);
143211
process.exit(1);
144212
}
145-
const encoded = repo.replace("/", "%2F");
213+
const encoded = encodeURIComponent(repo);
146214
const [impact, prs, gtLogoSvg] = await Promise.all([
147215
fetchJson(`https://api.gittensor.io/repos/${encoded}/impact`),
148216
fetchJson(`https://api.gittensor.io/repos/${encoded}/prs`),
149217
fetch("https://gittensor.io/gt-logo.svg").then((r) => r.text()),
150218
]);
151219
const buckets = bucketWeekly(prs, new Date());
152220
const gtLogoB64 = Buffer.from(gtLogoSvg).toString("base64");
153-
const repoIconB64 = Buffer.from(readFileSync(repoIconPath)).toString("base64");
221+
const repoIconB64 = Buffer.from(readFileSync(repoIconPath)).toString(
222+
"base64",
223+
);
154224

155225
const svg = render({ repo, impact, buckets, gtLogoB64, repoIconB64 });
156226
writeFileSync(outFile, svg);

0 commit comments

Comments
 (0)