Compare commits

...
20 Commits
Author SHA1 Message Date
echomike 2104680c4e update failed test, increase timeout
Node.js CI / build (push) Successful in 5m28s
2026-07-21 12:51:41 +02:00
echomike 3b7cc2e993 npm packages update
Node.js CI / build (push) Failing after 4m27s
2026-07-17 15:45:10 +02:00
echomike adcec5f67f horizontal padding fix
Node.js CI / build (push) Failing after 29m37s
2026-07-17 15:30:06 +02:00
echomike e7f0aa1c12 rect padding implemet
Node.js CI / build (push) Successful in 3m10s
2026-07-17 14:48:22 +02:00
echomike ff20d1cb11 version bump
Node.js CI / build (push) Successful in 14m3s
2026-07-17 14:24:19 +02:00
echomike 80b73b62e4 graceful shutdown and rate limit implementation 2026-07-17 14:23:52 +02:00
echomike 06b16b9033 readme, docker update 2026-07-17 14:13:27 +02:00
echomike d73aa076ad test suit updated 2026-07-17 14:08:00 +02:00
echomike 729b7f566f code quality updates 2026-07-17 13:55:39 +02:00
echomike d74fd3a248 Docker file fix and version in docker return proper envvar 2026-07-17 13:43:31 +02:00
echomike b7c6c5df7b fix SVG Injection problem 2026-07-17 13:35:55 +02:00
echomike 8fb05b3f5b Remove git token from package.json 2026-07-17 13:31:59 +02:00
echomike fcd08cbaac add version endpoint and version logging
Node.js CI / build (push) Successful in 3m22s
2025-07-17 14:44:26 +02:00
echomike fc3a4bb7ba add versioning file and Dockerfile version. Update README with install chapter
Node.js CI / build (push) Successful in 3m48s
2025-07-17 14:13:37 +02:00
echomike e8d693bd20 update node version in workflow
Node.js CI / build (push) Successful in 12m54s
2025-07-17 10:52:21 +02:00
echomike 7740c3efa0 add workflows
Node.js CI / build (push) Failing after 0s
2025-07-17 10:45:11 +02:00
echomike f40a834c8c rename api.test and add tag test 2025-07-17 10:42:40 +02:00
echomike 3774b40987 first unit test for badge endpoint 2025-07-17 10:31:01 +02:00
echomike c4146a2d65 Update gitignore and dockerignore files 2025-07-17 09:22:39 +02:00
echomike e7fa124013 Update git and docker ignore 2025-07-17 09:22:34 +02:00
26 changed files with 5880 additions and 233 deletions
+15 -5
View File
@@ -1,14 +1,24 @@
.git .git
.gitignore .gitignore
.gitea
Dockerfile Dockerfile
.dockerignore .dockerignore
README.md README.md
LICENSE LICENSE
node_modules/ node_modules/
coverge/ coverage/
test/
# dotenv environment variable files # dotenv environment variable files
.env .env
.env.development.local .env.*
.env.test.local
.env.production.local # SQLite database files
.env.local *.sqlite
*.sqlite3
sqlite*
sqldiff
# Archives
*.zip
*.tar.gz
+11
View File
@@ -0,0 +1,11 @@
# Server port
PORT=3000
# Log level: fatal | error | warn | info | debug | trace
LOG_LEVEL=info
# Node environment: production | development | test
NODE_ENV=development
# App version override (optional falls back to VERSION file)
# APP_VERSION=1.0.0
+15
View File
@@ -0,0 +1,15 @@
name: Node.js CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '23.x'
- run: npm ci
- run: npm test
+5
View File
@@ -136,3 +136,8 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
# SQLite database files
*.sqlite
*.sqlite3
sqlite*
sqldiff
+10 -3
View File
@@ -1,7 +1,9 @@
FROM node:23-alpine3.21 FROM node:24-alpine
RUN apk upgrade --update-cache --available RUN apk upgrade --update-cache --available
LABEL version=0.0.1 description="BadgeDex" ARG VERSION
LABEL version=${VERSION} description="BadgeDex"
ENV APP_VERSION=${VERSION}
# Create a group and user && Create directory for files # Create a group and user && Create directory for files
RUN mkdir /app && addgroup -S badgedex && adduser -h /app -S badgedex -g "Badgedex User" -G badgedex && chown -R badgedex:badgedex /app && chmod -R 755 /app RUN mkdir /app && addgroup -S badgedex && adduser -h /app -S badgedex -g "Badgedex User" -G badgedex && chown -R badgedex:badgedex /app && chmod -R 755 /app
@@ -16,10 +18,15 @@ USER badgedex
COPY --chown=badgedex:badgedex . . COPY --chown=badgedex:badgedex . .
#Install dependencies #Install dependencies
RUN npm install RUN npm ci --omit=dev
# Expose API port # Expose API port
EXPOSE 3000 EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/version || exit 1
#Start node application #Start node application
ENTRYPOINT ["npm"] ENTRYPOINT ["npm"]
+174 -65
View File
@@ -1,82 +1,191 @@
# badgedex # BadgeDex
BadgeDex Badge generátor szolgáltatás SVG badge-ek készítése GitHub profilokhoz és projektekhez.
# BadgeDex API URL Paraméterek A badge SVG formátumban tér vissza, így közvetlenül beilleszthető HTML-be, Markdownba, vagy `<img>` tag-be.
## Badge
Az alábbi paraméterekkel szabályozhatod a `/badge` végpont működését:
| Paraméter | Kötelező | Alapértelmezett érték | Leírás | Példa értékek |
|-------------|----------|-----------------------|----------------------------------------------------------------------------------------|------------------------------|
| `icon` | Igen |`simpleicons` | A SimpleIcons ikon neve (kisbetű, kötőjellel). | `github`, `docker`, `nginx` |
| `label` | Nem |`Simple Icons` | A badge-en megjelenő szöveg. | `MyApp`, `Status` |
| `style` | Nem |`rect` | A badge stílusa. rect négyzetes, flat lekerekitett sarkok | `rect`, `flat` |
| `bgicon` | Nem |`none` | Icon Háttérszín (hex vagy CSS színnév). Alapértelmezetten átlátszó. | `#fff`, `#222`, `red` |
| `bglabel` | Nem |`none` | Label Háttérszín (hex vagy CSS színnév). Alapértelmezetten átlátszó. | `#fff`, `#222`, `red` |
| `color` | Nem |`#000000` | Szöveg színe (hex vagy CSS színnév). Harmonizál az icon szinével ha nem kérünk külön. | `#333`, `white` |
| `size` | Nem |`24` | Ikon (és badge) magassága pixelben. | `16`, `32`, `64` |
| `labelpos` | Nem | `right` | Szöveg pozíciója az ikonhoz képest: `right`, `left`, `above`, `below`. | `left`, `above` |
| `fontweight`| Nem | `normal` | Szöveg vastagsága (`normal`, `bold`, `lighter`, szám is lehet pl. `600`). | `bold`, `600` |
## Példák
- **Alap badge:**
`/badge`
- **React ikon, label, size: 64, font: bold, fehér label szöveg, sárga label háttérszín, piros ikon háttérszín, label jobbra**
`/badge?icon=react&label=React%20expert%20level&size=64&fontweight=bold&color=white&bgicon=red&bglabel=yellow&labelpos=right`
- **GitHub ikon, piros label háttér, fehér szöveg:**
`/badge?icon=github&bglabel=red&color=white`
- **Szöveg balra, ikon jobbra:**
`/badge?labelpos=left`
- **Szöveg az ikon fölött, félkövér betű:**
`/badge?labelpos=above&fontweight=bold`
- **Nagyobb ikon és szöveg:**
`/badge?size=48`
--- ---
## Tag ## API végpontok
Az alábbi paraméterekkel szabályozhatod a `/tag` végpont működését: ### `GET /badge`
| Paraméter | Kötelező | Alapértelmezett érték | Leírás | Példa értékek | SVG badge-et generál egy [Simple Icons](https://simpleicons.org/) ikon és szöveg alapján.
|-------------|----------|-----------------------|----------------------------------------------------------------------------------------|------------------------------|
| `tag` | Igen |`badgedex` | A tag tartalma(kisbetű, kötőjellel). | `github`, `docker`, `nginx` |
| `label` | Nem |`BadgeDex` | A tag label tartalma. | `MyApp`, `Status` |
| `style` | Nem |`rect` | A badge stílusa. rect négyzetes, flat lekerekitett sarkok | `rect`, `flat` |
| `bgtag` | Nem |`none` | Icon Háttérszín (hex vagy CSS színnév). Alapértelmezetten átlátszó. | `#fff`, `#222`, `red` |
| `bglabel` | Nem |`none` | Label Háttérszín (hex vagy CSS színnév). Alapértelmezetten átlátszó. | `#fff`, `#222`, `red` |
| `color` | Nem |`#000000` | Szöveg színe (hex vagy CSS színnév). Harmonizál az icon szinével ha nem kérünk külön. | `#333`, `white` |
| `size` | Nem |`24` | Ikon (és badge) magassága pixelben. | `16`, `32`, `64` |
| `labelpos` | Nem | `right` | Szöveg pozíciója az ikonhoz képest: `right`, `left`, `above`, `below`. | `left`, `above` |
| `fontweight`| Nem | `normal` | Szöveg vastagsága (`normal`, `bold`, `lighter`, szám is lehet pl. `600`). | `bold`, `600` |
## Példák | Paraméter | Kötelező | Alapértelmezett | Leírás | Példák |
|-------------|----------|-----------------|----------------------------------------------------------------------------------------|------------------------------|
| `icon` | Nem | `simpleicons` | A SimpleIcons ikon neve (kisbetű, kötőjellel). | `github`, `docker`, `nginx` |
| `label` | Nem | *(ikonnév)* | A badge-en megjelenő szöveg. Ha nincs megadva, az ikon neve jelenik meg. | `MyApp`, `Status` |
| `style` | Nem | `rect` | Badge stílusa: `rect` (négyzetes) vagy `flat` (lekerekített sarkok). | `rect`, `flat` |
| `bgicon` | Nem | `none` | Ikon háttérszín (hex vagy CSS színnév). Alapból átlátszó. | `#fff`, `#222`, `red` |
| `bglabel` | Nem | `none` | Label háttérszín (hex vagy CSS színnév). Alapból átlátszó. | `#fff`, `#222`, `red` |
| `color` | Nem | *(ikonszín)* | Szöveg színe (hex vagy CSS színnév). Ha nincs megadva, az ikon színét használja. | `#333`, `white` |
| `size` | Nem | `24` | Ikon és badge magassága pixelben (8256). | `16`, `32`, `64` |
| `labelpos` | Nem | `right` | Szöveg pozíciója az ikonhoz képest. | `right`, `left`, `above`, `below` |
| `fontweight`| Nem | `normal` | Szöveg vastagsága. | `normal`, `bold`, `600` |
- **Alap badge:** #### Badge példák
`/badge`
- **React ikon, label, size: 64, font: bold, fehér label szöveg, sárga label háttérszín, piros ikon háttérszín, label jobbra** ```markdown
`/badge?icon=react&label=React%20expert%20level&size=64&fontweight=bold&color=white&bgicon=red&bglabel=yellow&labelpos=right` ![Default](http://localhost:3000/badge)
```
```
/badge
```
- **GitHub ikon, piros label háttér, fehér szöveg:** ```markdown
`/badge?icon=github&bglabel=red&color=white` ![GitHub](http://localhost:3000/badge?icon=github&bglabel=red&color=white)
```
```
/badge?icon=github&bglabel=red&color=white
```
- **Szöveg balra, ikon jobbra:** ```markdown
`/badge?labelpos=left` ![React](http://localhost:3000/badge?icon=react&label=React%20expert&size=64&fontweight=bold&color=white&bgicon=red&bglabel=yellow&labelpos=right)
```
```
/badge?icon=react&label=React%20expert&size=64&fontweight=bold&color=white&bgicon=red&bglabel=yellow&labelpos=right
```
- **Szöveg az ikon fölött, félkövér betű:** ```markdown
`/badge?labelpos=above&fontweight=bold` ![Docker](http://localhost:3000/badge?icon=docker&label=Docker&style=flat&bgicon=%232496ED&color=white)
```
```
/badge?icon=docker&label=Docker&style=flat&bgicon=%232496ED&color=white
```
- **Nagyobb ikon és szöveg:** ```markdown
`/badge?size=48` ![Left label](http://localhost:3000/badge?icon=nginx&labelpos=left)
```
```
/badge?icon=nginx&labelpos=left
```
```markdown
![Above label](http://localhost:3000/badge?icon=vue.js&labelpos=above&fontweight=bold&size=48)
```
```
/badge?icon=vue.js&labelpos=above&fontweight=bold&size=48
```
--- ---
A badge SVG formátumban tér vissza, így közvetlenül beilleszthető HTML-be vagy Markdown ### `GET /tag`
Badge-szerű SVG címkét generál szöveges tartalommal (stílus: shields.io-szerű).
| Paraméter | Kötelező | Alapértelmezett | Leírás | Példák |
|-------------|----------|-----------------|----------------------------------------------------------------------------------------|------------------------------|
| `tag` | Nem | `badgedex` | A tag tartalma (bal oldali szöveg). | `build`, `coverage`, `v1.0` |
| `label` | Nem | `BadgeDex` | A label tartalma (jobb oldali szöveg). | `passing`, `95%`, `stable` |
| `style` | Nem | `rect` | Stílus: `rect` (négyzetes) vagy `flat` (lekerekített, árnyékolt). | `rect`, `flat` |
| `bgtag` | Nem | `none` | Tag háttérszín (hex vagy CSS színnév). | `#555`, `green`, `red` |
| `bglabel` | Nem | `none` | Label háttérszín (hex vagy CSS színnév). | `#007ec6`, `blue` |
| `color` | Nem | `#000000` | Szöveg színe (hex vagy CSS színnév). | `#fff`, `white` |
| `size` | Nem | `24` | Badge magassága pixelben (8256). | `16`, `20`, `32` |
| `fontweight`| Nem | `normal` | Szöveg vastagsága. | `normal`, `bold`, `600` |
#### Tag példák
```markdown
![Default tag](http://localhost:3000/tag)
```
```
/tag
```
```markdown
![Build passing](http://localhost:3000/tag?tag=build&label=passing&bgtag=%23555&bglabel=green&color=white)
```
```
/tag?tag=build&label=passing&bgtag=%23555&bglabel=green&color=white
```
```markdown
![Coverage](http://localhost:3000/tag?tag=coverage&label=98%25&bgtag=%23555&bglabel=%23007ec6&color=white)
```
```
/tag?tag=coverage&label=98%25&bgtag=%23555&bglabel=%23007ec6&color=white
```
```markdown
![License](http://localhost:3000/tag?tag=license&label=AGPL--v3&bgtag=gray&bglabel=blue&color=white)
```
```
/tag?tag=license&label=AGPL--v3&bgtag=gray&bglabel=blue&color=white
```
```markdown
![Version](http://localhost:3000/tag?tag=version&label=v0.0.1&style=flat&bgtag=%23333&bglabel=%23007ec6&color=white)
```
```
/tag?tag=version&label=v0.0.1&style=flat&bgtag=%23333&bglabel=%23007ec6&color=white
```
```markdown
![Node.js](http://localhost:3000/tag?tag=node.js&label=22.x&bgtag=%23339933&bglabel=black&color=white&size=20)
```
```
/tag?tag=node.js&label=22.x&bgtag=%23339933&bglabel=black&color=white&size=20
```
---
### `GET /version`
A szolgáltatás aktuális verzióját adja vissza JSON formátumban.
```json
{ "badgedexVersion": "0.0.1" }
```
A verzió forrása (prioritás szerint):
1. `APP_VERSION` környezeti változó
2. `VERSION` fájl a projekt gyökerében
---
## Környezeti változók
A konfiguráció `.env` fájllal történik (lásd `.env.example`):
| Változó | Alapértelmezett | Leírás |
|-------------|-----------------|------------------------------------------------------|
| `PORT` | `3000` | A szerver portja. |
| `LOG_LEVEL` | `info` | Logolás szintje: `fatal`, `error`, `warn`, `info`, `debug`, `trace`. |
| `NODE_ENV` | `development` | Környezet: `production`, `development`, `test`. |
| `APP_VERSION` | *(nincs)* | Verzió override (opcionális, alapból a `VERSION` fájlból olvas). |
---
## Telepítés
### Node.js alkalmazásként
```bash
git clone <repo-url>
cd badgedex
npm install
npm start
```
A szerver a `http://localhost:3000` címen indul.
### Docker konténerként
```bash
docker build --build-arg VERSION=$(cat VERSION) -t badgedex:$(cat VERSION) .
docker run -dit --name badgedex -p 3000:3000 badgedex:$(cat VERSION)
```
---
## Fejlesztés
### Tesztek futtatása
```bash
npm test
```
Jest + Supertest tesztekkel, axios mockkal. A lefedettségi riport automatikusan generálódik.
+1
View File
@@ -0,0 +1 @@
0.0.2
+4668 -83
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -5,11 +5,11 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "NODE_ENV=test jest --coverage"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://7139337cdda033731edc580629d502be491a6a8f@gitea.mihalyandras.familyds.org/echomike/badgedex.git" "url": "https://gitea.mihalyandras.familyds.org/echomike/badgedex.git"
}, },
"keywords": [ "keywords": [
"badge", "badge",
@@ -19,7 +19,15 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"axios": "^1.10.0", "axios": "^1.10.0",
"dotenv": "^17.4.2",
"express": "^5.1.0", "express": "^5.1.0",
"helmet": "^8.1.0" "express-rate-limit": "^8.6.0",
"helmet": "^8.1.0",
"jest": "^30.4.2",
"pino": "^10.3.1"
},
"devDependencies": {
"pino-pretty": "^13.1.3",
"supertest": "^7.1.3"
} }
} }
+49 -38
View File
@@ -1,26 +1,26 @@
const router = require('express').Router(); const router = require('express').Router();
const axios = require('axios'); const axios = require('axios');
const { escapeSvg, safeColor } = require('../lib/sanitize');
const { parseSize, parseStyle, parseLabelpos, parseFontweight } = require('../lib/validate');
const { FONT_FAMILY, calcFontSize, estimateTextWidth } = require('../lib/svg-utils');
const logger = require('../lib/logger');
router.get('/badge', async (req, res) => { router.get('/badge', async (req, res) => {
const { const icon = req.query.icon || 'simpleicons';
icon = 'simpleicons', const label = req.query.label || 'Simple Icons';
label = 'Simple Icons', const style = parseStyle(req.query.style);
style = 'rect', // badge stílusa: rect (négyzetes), flat (lekerekített sarkok) const bgicon = req.query.bgicon || 'none';
bgicon = 'none', // ikon háttérszín, alap: világosszürke const bglabel = req.query.bglabel || 'none';
bglabel = 'none', // label háttérszín, alap: sötétszürke const color = req.query.color || '#000000';
color = '#000000', const size = parseSize(req.query.size);
size = 24, const labelpos = parseLabelpos(req.query.labelpos);
labelpos = 'right', const fontweight = parseFontweight(req.query.fontweight);
fontweight = 'normal',
} = req.query;
try { try {
// download icon SVG from SimpleIcons CDN // download icon SVG from SimpleIcons CDN
const iconUrl = `https://cdn.simpleicons.org/${icon}?viewbox=auto&size=${size}`; const safeIcon = escapeSvg(icon);
const iconUrl = `https://cdn.simpleicons.org/${safeIcon}?viewbox=auto&size=${size}`;
const iconResponse = await axios.get(iconUrl, { responseType: 'text' }); const iconResponse = await axios.get(iconUrl, { responseType: 'text' });
if (iconResponse.status !== 200) {
return res.status(404).send('Icon not found');
}
const iconSVG = iconResponse.data; const iconSVG = iconResponse.data;
// if there is no label query, use the title from the SVG // if there is no label query, use the title from the SVG
@@ -33,13 +33,18 @@ router.get('/badge', async (req, res) => {
} }
// Badge size // Badge size
const safeLabel = escapeSvg(effectiveLabel);
const safeBgIcon = safeColor(bgicon, 'none');
const safeBgLabel = safeColor(bglabel, 'none');
const safeFontWeight = escapeSvg(fontweight);
const padding = 10; const padding = 10;
const iconSize = parseInt(size); const iconSize = size;
const fontSize = Math.round(iconSize * 0.6); const fontSize = calcFontSize(iconSize);
const textPadding = Math.round(iconSize * 0.4); const textPadding = Math.round(iconSize * 0.4);
const radius = 0; const textHpad = 4; // horizontal padding inside text rect — enough to prevent clipping
const approxCharWidth = fontSize * 0.6; const radius = style === 'flat' ? 3 : 0;
const textWidth = effectiveLabel.length * approxCharWidth; const textWidth = estimateTextWidth(effectiveLabel, fontSize) + textHpad * 2;
// Kinyerjük az ikon SVG-ből a width és height attribútumokat (ha vannak) // Kinyerjük az ikon SVG-ből a width és height attribútumokat (ha vannak)
let iconViewBoxWidth = iconSize; let iconViewBoxWidth = iconSize;
@@ -50,8 +55,9 @@ router.get('/badge', async (req, res) => {
if (heightMatch) iconViewBoxHeight = parseFloat(heightMatch[1]); if (heightMatch) iconViewBoxHeight = parseFloat(heightMatch[1]);
// Ikon doboz mérete // Ikon doboz mérete
const iconBoxWidth = iconViewBoxWidth + 5; const iconPad = 2;
const iconBoxHeight = iconViewBoxHeight + 5; const iconBoxWidth = iconViewBoxWidth + iconPad;
const iconBoxHeight = iconViewBoxHeight + iconPad;
// Ha a color paraméter nincs megadva, próbáljuk kinyerni az ikon színét // Ha a color paraméter nincs megadva, próbáljuk kinyerni az ikon színét
let effectiveColor = color; let effectiveColor = color;
@@ -63,6 +69,7 @@ router.get('/badge', async (req, res) => {
effectiveColor = "#222"; // fallback effectiveColor = "#222"; // fallback
} }
} }
effectiveColor = safeColor(effectiveColor, '#222');
// doboz magasság 5 pixellel nagyobb legyen az ikon méreténél // doboz magasság 5 pixellel nagyobb legyen az ikon méreténél
const iconSizeAndPadding = iconSize + 5; const iconSizeAndPadding = iconSize + 5;
@@ -70,33 +77,33 @@ router.get('/badge', async (req, res) => {
let width, height, iconGroup, textElem; let width, height, iconGroup, textElem;
const fontFamily = "Verdana,Geneva,DejaVu Sans,sans-serif"; const fontFamily = FONT_FAMILY;
const fontWeight = fontweight; // paraméterből const fontWeight = safeFontWeight; // paraméterből
if (labelpos === 'left') { if (labelpos === 'left') {
// Külön dobozok: padding csak a széleken kell, a dobozokon belül nem! // Külön dobozok: padding a szöveg körül!
textElem = `<rect x="0" y="0" width="${textWidth}" height="${iconBoxHeight}" rx="${radius}" fill="${bglabel}"/> textElem = `<rect x="0" y="0" width="${textWidth}" height="${iconBoxHeight}" rx="${radius}" fill="${safeBgLabel}"/>
<text x="${textWidth / 2}" y="${iconBoxHeight / 2}" font-size="${fontSize}" font-family="${fontFamily}" font-weight="${fontWeight}" fill="${effectiveColor}" text-anchor="middle" dominant-baseline="middle">${effectiveLabel}</text>`; <text x="${textWidth / 2}" y="${iconBoxHeight / 2}" font-size="${fontSize}" font-family="${fontFamily}" font-weight="${fontWeight}" fill="${effectiveColor}" text-anchor="middle" dominant-baseline="middle">${safeLabel}</text>`;
iconGroup = ` iconGroup = `
<rect x="${textWidth}" y="0" width="${iconBoxWidth + 5}" height="${iconBoxHeight}" rx="${radius}" fill="${bgicon}"/> <rect x="${textWidth}" y="0" width="${iconBoxWidth}" height="${iconBoxHeight}" rx="${radius}" fill="${safeBgIcon}"/>
<g transform="translate(${textWidth + (5 / 2)}, ${(iconBoxHeight - iconSize) / 2})">${iconSVG}</g> <g transform="translate(${textWidth + (iconPad / 2)}, ${(iconBoxHeight - iconSize) / 2})">${iconSVG}</g>
`; `;
} else if (labelpos === 'above' || labelpos === 'below') { } else if (labelpos === 'above' || labelpos === 'below') {
// Felső vagy alsó label: ikon doboz magassága = iconSize + 5, szélessége = szöveg doboz szélessége // Felső vagy alsó label: ikon doboz magassága = iconSize + 5, szélessége = szöveg doboz szélessége
width = Math.max(iiconSizeAndPadding, textWidth); width = Math.max(iconSizeAndPadding, textWidth);
const iconRectY = labelpos === 'above' const iconRectY = labelpos === 'above'
? labelRectHeight ? labelRectHeight
: 0; : 0;
const labelRectY = labelpos === 'above' const labelRectY = labelpos === 'above'
? 0 ? 0
: iiconSizeAndPadding; : iconSizeAndPadding;
height = iconSizeAndPadding + textPadding + labelRectHeight; height = iconSizeAndPadding + textPadding + labelRectHeight;
// Label háttér // Label háttér
const labelRect = `<rect x="0" y="${labelRectY}" width="${width}" height="${labelRectHeight}" rx="${radius}" fill="${bglabel}"/>`; const labelRect = `<rect x="0" y="${labelRectY}" width="${width}" height="${labelRectHeight}" rx="${radius}" fill="${safeBgLabel}"/>`;
// Ikon háttér // Ikon háttér
const iconRect = `<rect x="0" y="${iconRectY}" width="${width}" height="${iconSize + 5}" rx="${radius}" fill="${bgicon}"/>`; const iconRect = `<rect x="0" y="${iconRectY}" width="${width}" height="${iconSize + 5}" rx="${radius}" fill="${safeBgIcon}"/>`;
// Szöveg // Szöveg
const textY = labelpos === 'above' const textY = labelpos === 'above'
@@ -104,7 +111,7 @@ router.get('/badge', async (req, res) => {
: labelRectY + 5 + labelRectHeight / 2; : labelRectY + 5 + labelRectHeight / 2;
textElem = ` textElem = `
${labelRect} ${labelRect}
<text x="${width / 2}" y="${textY}" font-size="${fontSize}" font-family="${fontFamily}" font-weight="${fontWeight}" fill="${effectiveColor}" text-anchor="middle" dominant-baseline="middle">${effectiveLabel}</text> <text x="${width / 2}" y="${textY}" font-size="${fontSize}" font-family="${fontFamily}" font-weight="${fontWeight}" fill="${effectiveColor}" text-anchor="middle" dominant-baseline="middle">${safeLabel}</text>
`; `;
// Ikon // Ikon
@@ -115,11 +122,11 @@ router.get('/badge', async (req, res) => {
} else { } else {
// Alapértelmezett: ikon balra, szöveg jobbra (függőleges közép) // Alapértelmezett: ikon balra, szöveg jobbra (függőleges közép)
iconGroup = ` iconGroup = `
<rect x="0" y="0" width="${iconBoxWidth}" height="${iconBoxHeight}" rx="${radius}" fill="${bgicon}"/> <rect x="0" y="0" width="${iconBoxWidth}" height="${iconBoxHeight}" rx="${radius}" fill="${safeBgIcon}"/>
<g transform="translate(${(iconBoxWidth - iconViewBoxWidth) / 2}, ${(iconBoxHeight - iconViewBoxHeight) / 2})">${iconSVG}</g> <g transform="translate(${(iconBoxWidth - iconViewBoxWidth) / 2}, ${(iconBoxHeight - iconViewBoxHeight) / 2})">${iconSVG}</g>
`; `;
textElem = `<rect x="${iconBoxWidth}" y="0" width="${textWidth}" height="${iconBoxHeight}" rx="${radius}" fill="${bglabel}"/> textElem = `<rect x="${iconBoxWidth}" y="0" width="${textWidth}" height="${iconBoxHeight}" rx="${radius}" fill="${safeBgLabel}"/>
<text x="${iconBoxWidth + textWidth / 2}" y="${iconBoxHeight / 2}" font-size="${fontSize}" font-family="${fontFamily}" font-weight="${fontWeight}" fill="${effectiveColor}" text-anchor="middle" dominant-baseline="middle">${effectiveLabel}</text>`; <text x="${iconBoxWidth + textWidth / 2}" y="${iconBoxHeight / 2}" font-size="${fontSize}" font-family="${fontFamily}" font-weight="${fontWeight}" fill="${effectiveColor}" text-anchor="middle" dominant-baseline="middle">${safeLabel}</text>`;
} }
// SVG badge string összefűzéssel, szöveg árnyékkal // SVG badge string összefűzéssel, szöveg árnyékkal
@@ -138,9 +145,13 @@ router.get('/badge', async (req, res) => {
res.setHeader('Content-Type', 'image/svg+xml'); res.setHeader('Content-Type', 'image/svg+xml');
res.send(svg); res.send(svg);
} catch (err) { } catch (err) {
console.error(err); if (err.response && err.response.status === 404) {
return res.status(404).send('Icon not found');
} else {
logger.error({ err, icon, url: req.url }, 'Badge generation failed');
res.status(500).send('Internal Server Error'); res.status(500).send('Internal Server Error');
} }
}
}); });
module.exports = router; module.exports = router;
+2
View File
@@ -1,8 +1,10 @@
const router = require('express').Router(); const router = require('express').Router();
const badgeRouter = require('./badge'); const badgeRouter = require('./badge');
const tagRouter = require('./tag'); const tagRouter = require('./tag');
const versionRouter = require('./version');
router.use(badgeRouter); router.use(badgeRouter);
router.use(tagRouter); router.use(tagRouter);
router.use(versionRouter);
module.exports = { router }; module.exports = { router };
+38 -31
View File
@@ -1,30 +1,36 @@
const router = require('express').Router(); const router = require('express').Router();
const { escapeSvg, safeColor } = require('../lib/sanitize');
const { parseSize, parseStyle } = require('../lib/validate');
const { FONT_FAMILY, calcFontSize } = require('../lib/svg-utils');
router.get('/tag', (req, res) => { router.get('/tag', (req, res) => {
const { const tag = req.query.tag || 'badgedex';
tag = 'badgedex', const label = req.query.label || 'BadgeDex';
label = 'BadgeDex', const style = parseStyle(req.query.style);
style = 'rect', // badge stílusa: rect (négyzetes), flat (lekerekített sarkok) const bgtag = req.query.bgtag || 'none';
bgtag = 'none', // ikon háttérszín, alap: világosszürke const bglabel = req.query.bglabel || 'none';
bglabel = 'none', // label háttérszín, alap: sötétszürke const color = req.query.color || '#000000';
color = '#000000', const size = parseSize(req.query.size);
size = 24, const labelpos = req.query.labelpos || 'right';
labelpos = 'right', const fontweight = req.query.fontweight || 'normal';
fontweight = 'normal',
} = req.query;
const fontFamily = "Verdana,Geneva,DejaVu Sans,sans-serif"; const fontFamily = FONT_FAMILY;
const fontWeight = fontweight; const fontWeight = escapeSvg(fontweight);
const safeTag = escapeSvg(tag);
const safeLabel = escapeSvg(label);
const safeColorVal = safeColor(color, '#000000');
const safeBgTag = safeColor(bgtag, 'none');
const safeBgLabel = safeColor(bglabel, 'none');
// A flat badge esetén a szöveg középre igazítása miatt a szöveg pozícióját módosítjuk // A flat badge esetén a szöveg középre igazítása miatt a szöveg pozícióját módosítjuk
//const labelX = labelpos === 'left' ? 165 : 455; //const labelX = labelpos === 'left' ? 165 : 455;
//const tagX = labelpos === 'left' ? 455 : 165; //const tagX = labelpos === 'left' ? 455 : 165;
// A méretet pixelben adjuk meg, alapértelmezett 24px // A méretet pixelben adjuk meg, alapértelmezett 24px
const textPadding = Number(5); const textPadding = 5;
const badgeSize = Number(size); const badgeSize = size;
const badgeFontSize = Math.round(badgeSize * 0.6); // A szöveg mérete a badge méretéhez igazítva const badgeFontSize = calcFontSize(badgeSize);
const tagTextLength = tag.length * (badgeFontSize * 0.7); // A tag szöveg hossza const tagTextLength = tag.length * (badgeFontSize * 0.7); // A tag szöveg hossza
const labelTextLength = label.length * (badgeFontSize * 0.8); // A label szöveg hossza const labelTextLength = label.length * (badgeFontSize * 0.8); // A label szöveg hossza
const badgeWidth = tagTextLength + labelTextLength + 10; // A badge szélessége a szövegek hosszától függően const badgeWidth = tagTextLength + labelTextLength + 10; // A badge szélessége a szövegek hosszától függően
@@ -33,38 +39,39 @@ router.get('/tag', (req, res) => {
const rect = ` const rect = `
<g shape-rendering="crispEdges"> <g shape-rendering="crispEdges">
<rect width="${tagTextLength+textPadding}" height="${badgeSize}" fill="${bgtag}"/> <rect width="${tagTextLength+textPadding}" height="${badgeSize}" fill="${safeBgTag}"/>
<rect x="${tagTextLength}" width="${labelTextLength+(textPadding*2)}" height="${badgeSize}" fill="${bglabel}"/> <rect x="${tagTextLength}" width="${labelTextLength+(textPadding*2)}" height="${badgeSize}" fill="${safeBgLabel}"/>
</g> </g>
<g fill="#fff" text-anchor="start" font-family="${fontFamily}" text-rendering="geometricPrecision" font-size="${badgeFontSize}"> <g fill="#fff" text-anchor="start" font-family="${fontFamily}" text-rendering="geometricPrecision" font-size="${badgeFontSize}">
<text x="${tagTextX}" y="${badgeFontSize + textPadding}" fill="${color}">${tag.toLowerCase()}</text> <text x="${tagTextX}" y="${badgeFontSize + textPadding}" fill="${safeColorVal}">${safeTag.toLowerCase()}</text>
<text x="${labelTextX}" y="${badgeFontSize + textPadding}" fill="${color}" font-weight="${fontWeight}">${label.toUpperCase()}</text> <text x="${labelTextX}" y="${badgeFontSize + textPadding}" fill="${safeColorVal}" font-weight="${fontWeight}">${safeLabel.toUpperCase()}</text>
</g>`.trim(); </g>`.trim();
const rx = 3;
const flat = ` const flat = `
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
</linearGradient> </linearGradient>
<clipPath id="r"> <clipPath id="r">
<rect width="62" height="20" rx="3" fill="#fff"/> <rect width="${badgeWidth}" height="${badgeSize}" rx="${rx}" fill="#fff"/>
</clipPath> </clipPath>
<g clip-path="url(#r)"> <g clip-path="url(#r)">
<rect width="31" height="20" fill="#555"/> <rect width="${tagTextLength + textPadding}" height="${badgeSize}" fill="${safeBgTag}"/>
<rect x="31" width="31" height="20" fill="#007ec6"/> <rect x="${tagTextLength + textPadding}" width="${labelTextLength + textPadding}" height="${badgeSize}" fill="${safeBgLabel}"/>
<rect width="62" height="20" fill="url(#s)"/> <rect width="${badgeWidth}" height="${badgeSize}" fill="url(#s)"/>
</g> </g>
<g fill="#fff" text-anchor="middle" font-family="${fontFamily}" text-rendering="geometricPrecision" font-size="110" font-weight="${fontWeight}"> <g fill="#fff" text-anchor="middle" font-family="${fontFamily}" text-rendering="geometricPrecision" font-size="${badgeFontSize}" font-weight="${fontWeight}">
<text aria-hidden="true" x="165" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="210">${tag}</text> <text aria-hidden="true" x="${(tagTextLength + textPadding) / 2}" y="${badgeFontSize + textPadding + 1}" fill="#010101" fill-opacity=".3">${safeTag}</text>
<text x="165" y="140" transform="scale(.1)" fill="#fff" textLength="210">${tag}</text> <text x="${(tagTextLength + textPadding) / 2}" y="${badgeFontSize + textPadding}" fill="#fff">${safeTag}</text>
<text aria-hidden="true" x="455" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="210">${label}</text> <text aria-hidden="true" x="${tagTextLength + textPadding + (labelTextLength + textPadding) / 2}" y="${badgeFontSize + textPadding + 1}" fill="#010101" fill-opacity=".3">${safeLabel}</text>
<text x="455" y="140" transform="scale(.1)" fill="#fff" textLength="210">${label}</text> <text x="${tagTextLength + textPadding + (labelTextLength + textPadding) / 2}" y="${badgeFontSize + textPadding}" fill="#fff">${safeLabel}</text>
</g>`.trim(); </g>`.trim();
const svg = ` const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${badgeWidth}" height="${badgeSize}" role="img" aria-label="${tag}: ${label}"> <svg xmlns="http://www.w3.org/2000/svg" width="${badgeWidth}" height="${badgeSize}" role="img" aria-label="${safeTag}: ${safeLabel}">
<title>${tag}: ${label}</title> <title>${safeTag}: ${safeLabel}</title>
${style === 'rect' ? rect : flat} ${style === 'rect' ? rect : flat}
</svg>`.trim(); </svg>`.trim();
+8
View File
@@ -0,0 +1,8 @@
const router = require('express').Router();
const version = require('../lib/version').version();
router.get('/version', (req, res) => {
res.json({ badgedexVersion: version });
});
module.exports = router;
+42 -3
View File
@@ -1,16 +1,55 @@
require('dotenv').config();
const express = require('express'); const express = require('express');
const helmet = require('helmet'); const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const logger = require('./lib/logger');
const api = require('./api');
const version = require('./lib/version').version();
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const api = require('./api'); // Rate limiting max 100 requests per minute per IP
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
});
app.use(helmet({ app.use(helmet({
crossOriginResourcePolicy: false, crossOriginResourcePolicy: false,
})); }));
app.use(limiter);
app.use('/', api.router); app.use('/', api.router);
app.listen(PORT, () => { // Central error handler catches all unhandled errors
console.log(`🚀 Badge API running at http://localhost:${PORT}/`); // eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
logger.error({ err, url: req.url }, 'Unhandled error');
res.status(err.status || 500).json({ error: err.message || 'Internal Server Error' });
}); });
const server = app.listen(PORT, () => {
logger.info(`BadgeDex version ${version} listening on http://localhost:${PORT}/`);
});
// Graceful shutdown close server cleanly on SIGTERM / SIGINT
const shutdown = (signal) => {
logger.info(`${signal} received, shutting down gracefully`);
server.close(() => {
logger.info('HTTP server closed');
process.exit(0);
});
// Force exit after 5 seconds if connections don't close
setTimeout(() => {
logger.warn('Forced shutdown after timeout');
process.exit(1);
}, 5000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+15
View File
@@ -0,0 +1,15 @@
'use strict';
const pino = require('pino');
const isTest = process.env.NODE_ENV === 'test';
const logger = pino({
level: isTest ? 'silent' : (process.env.LOG_LEVEL || 'info'),
transport:
!isTest && process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});
module.exports = logger;
+24
View File
@@ -0,0 +1,24 @@
'use strict';
const COLOR_RE = /^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$/;
/**
* Escape special XML/HTML characters to prevent SVG injection / XSS.
* < → &lt; > → &gt; & → &amp; " → &quot; ' → &#x27;
*/
const escapeSvg = (value) =>
String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
/**
* Return a safe CSS colour string or the fallback.
* Only simple colour names and hex values (#rgb, #rrggbb, #rrggbbaa) pass.
*/
const safeColor = (value, fallback = '#000000') =>
COLOR_RE.test(value) ? value : fallback;
module.exports = { escapeSvg, safeColor };
+17
View File
@@ -0,0 +1,17 @@
'use strict';
/** Standard font family used by both badge and tag endpoints. */
const FONT_FAMILY = 'Verdana,Geneva,DejaVu Sans,sans-serif';
/** Font-size scales with the icon / badge size. */
const calcFontSize = (size) => Math.round(size * 0.6);
/**
* Text-width estimate based on font-size and character count.
* Uses a 0.7 coefficient — Verdana has wide letterforms (M, W, D are ~70% of fontSize).
* Returns only the raw text bounding box — padding is added by callers.
*/
const estimateTextWidth = (text, fontSize) =>
text.length * fontSize * 0.7;
module.exports = { FONT_FAMILY, calcFontSize, estimateTextWidth };
+40
View File
@@ -0,0 +1,40 @@
'use strict';
const ALLOWED_STYLES = ['rect', 'flat'];
const ALLOWED_LABELPOS = ['right', 'left', 'above', 'below'];
const SIZE_MIN = 8;
const SIZE_MAX = 256;
/**
* Parse and clamp the `size` query parameter.
* Returns a positive integer in [SIZE_MIN, SIZE_MAX].
*/
const parseSize = (raw) => {
const n = parseInt(raw, 10);
if (Number.isNaN(n) || n < SIZE_MIN || n > SIZE_MAX) return 24;
return n;
};
/**
* Return a valid style or the default.
*/
const parseStyle = (raw) =>
ALLOWED_STYLES.includes(raw) ? raw : 'rect';
/**
* Return a valid labelpos or the default.
*/
const parseLabelpos = (raw) =>
ALLOWED_LABELPOS.includes(raw) ? raw : 'right';
/**
* Return a valid font-weight string.
* Accepts: normal, bold, lighter, bolder, 100900.
*/
const parseFontweight = (raw) => {
if (/^(normal|bold|lighter|bolder)$/.test(raw)) return raw;
if (/^[1-9]00$/.test(raw)) return raw;
return 'normal';
};
module.exports = { parseSize, parseStyle, parseLabelpos, parseFontweight, SIZE_MIN, SIZE_MAX };
+19
View File
@@ -0,0 +1,19 @@
const fs = require('fs');
const version = () => {
if (process.env.APP_VERSION) {
return process.env.APP_VERSION;
}
// Fallback to reading from a VERSION file
const versionPath = require('path').resolve(__dirname, '../../VERSION');
if (!fs.existsSync(versionPath)) {
throw new Error('VERSION file not found');
}
// Read the version from the VERSION file
const fileVersion = fs.readFileSync(versionPath, 'utf8').trim();
return fileVersion;
}
module.exports = { version };
+257
View File
@@ -0,0 +1,257 @@
const request = require('supertest');
const express = require('express');
const helmet = require('helmet');
// --- Mock axios before requiring the router ---
jest.mock('axios');
const axios = require('axios');
const MOCK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>SimpleIcons</title><path fill="#333" d="M12 2L2 22h20z"/></svg>`;
beforeEach(() => {
axios.get.mockResolvedValue({ status: 200, data: MOCK_SVG });
});
afterEach(() => {
jest.restoreAllMocks();
});
// Fresh app for each test to avoid middleware leakage
function createApp() {
const badgeRouter = require('../src/api/badge');
const app = express();
app.use(helmet({ crossOriginResourcePolicy: false }));
app.use(badgeRouter);
return app;
}
describe('GET /badge', () => {
// --- Basic happy-path tests ---
it('should return SVG with default params', async () => {
const res = await request(createApp()).get('/badge');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toMatch(/image\/svg\+xml/);
expect(body).toContain('<svg');
expect(body).toContain('<rect');
}, 20000);
it('should use custom icon and label', async () => {
const res = await request(createApp()).get('/badge?icon=github&label=GitHub');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('GitHub');
expect(body).toContain('<svg');
});
it('should use label from SVG title if label param is missing', async () => {
const res = await request(createApp()).get('/badge?icon=github');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<svg');
expect(body.toLowerCase()).toContain('simpleicons');
});
// --- Background & colour ---
it('should set icon background to transparent', async () => {
const res = await request(createApp()).get('/badge?bgicon=none');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('fill="none"');
});
it('should set label background to transparent', async () => {
const res = await request(createApp()).get('/badge?bglabel=none');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('fill="none"');
});
it('should apply custom bgicon and bglabel colours', async () => {
const res = await request(createApp()).get('/badge?bgicon=red&bglabel=blue');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('fill="red"');
expect(body).toContain('fill="blue"');
});
it('should apply custom text colour', async () => {
const res = await request(createApp()).get('/badge?color=white');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('fill="white"');
});
// --- Styles ---
it('should use flat style with rounded corners (rx=3)', async () => {
const res = await request(createApp()).get('/badge?style=flat');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('rx="3"');
});
it('should use rect style with square corners (rx=0)', async () => {
const res = await request(createApp()).get('/badge?style=rect');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('rx="0"');
});
it('should fall back to rect for invalid style', async () => {
const res = await request(createApp()).get('/badge?style=invalid');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('rx="0"');
});
// --- labelpos ---
it('should handle labelpos left', async () => {
const res = await request(createApp()).get('/badge?labelpos=left');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<rect');
expect(body).toContain('<text');
});
it('should handle labelpos above', async () => {
const res = await request(createApp()).get('/badge?labelpos=above');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<rect');
expect(body).toContain('<text');
});
it('should handle labelpos below', async () => {
const res = await request(createApp()).get('/badge?labelpos=below');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<rect');
expect(body).toContain('<text');
});
it('should fall back to right for invalid labelpos', async () => {
const res = await request(createApp()).get('/badge?labelpos=diagonal');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<svg');
});
// --- fontweight ---
it('should handle custom fontweight bold', async () => {
const res = await request(createApp()).get('/badge?fontweight=bold');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('font-weight="bold"');
});
it('should handle numeric fontweight 600', async () => {
const res = await request(createApp()).get('/badge?fontweight=600');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('font-weight="600"');
});
it('should fall back to normal for invalid fontweight', async () => {
const res = await request(createApp()).get('/badge?fontweight=ultra');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('font-weight="normal"');
});
// --- size ---
it('should handle minimum valid size (8)', async () => {
const res = await request(createApp()).get('/badge?size=8');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<svg');
});
it('should handle large size (128)', async () => {
const res = await request(createApp()).get('/badge?size=128');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<svg');
});
it('should fall back to default size for NaN', async () => {
const res = await request(createApp()).get('/badge?size=abc');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<svg');
});
it('should fall back to default size for negative value', async () => {
const res = await request(createApp()).get('/badge?size=-5');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<svg');
});
it('should fall back to default size for value above max', async () => {
const res = await request(createApp()).get('/badge?size=9999');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<svg');
});
// --- XSS / injection ---
it('should escape XSS in label', async () => {
const res = await request(createApp()).get('/badge?label=<script>alert(1)</script>');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).not.toContain('<script>');
expect(body).toContain('&lt;script&gt;');
});
it('should escape quotes in label', async () => {
const res = await request(createApp()).get('/badge?label=he%20said%20%22hi%22');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('&quot;');
});
it('should reject invalid color with injection attempt', async () => {
const res = await request(createApp()).get('/badge?color=javascript:alert(1)');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).not.toContain('javascript:');
});
// --- CDN error handling ---
it('should return 404 when CDN returns 404', async () => {
axios.get.mockRejectedValue({ response: { status: 404 } });
const res = await request(createApp()).get('/badge?icon=notarealicon');
expect(res.statusCode).toBe(404);
});
it('should return 500 on unexpected CDN error', async () => {
axios.get.mockRejectedValue(new Error('Network timeout'));
const res = await request(createApp()).get('/badge?icon=github');
expect(res.statusCode).toBe(500);
expect(res.text).toBe('Internal Server Error');
});
// --- SVG structure ---
it('should include drop shadow filter', async () => {
const res = await request(createApp()).get('/badge');
const body = Buffer.from(res.body).toString();
expect(body).toContain('filter id="shadow"');
expect(body).toContain('feDropShadow');
});
it('should include xmlns', async () => {
const res = await request(createApp()).get('/badge');
const body = Buffer.from(res.body).toString();
expect(body).toContain('xmlns="http://www.w3.org/2000/svg"');
});
});
+72
View File
@@ -0,0 +1,72 @@
const { escapeSvg, safeColor } = require('../src/lib/sanitize');
describe('sanitize', () => {
describe('escapeSvg', () => {
it('should escape < and >', () => {
expect(escapeSvg('<script>')).toBe('&lt;script&gt;');
});
it('should escape &', () => {
expect(escapeSvg('A & B')).toBe('A &amp; B');
});
it('should escape double quotes', () => {
expect(escapeSvg('"hello"')).toBe('&quot;hello&quot;');
});
it('should escape single quotes', () => {
expect(escapeSvg("it's")).toBe('it&#x27;s');
});
it('should escape combined special characters', () => {
expect(escapeSvg('<img src="x" onerror=\'alert(1)\'>')).toBe(
'&lt;img src=&quot;x&quot; onerror=&#x27;alert(1)&#x27;&gt;'
);
});
it('should pass through normal text unchanged', () => {
expect(escapeSvg('Hello World')).toBe('Hello World');
expect(escapeSvg('test-123')).toBe('test-123');
});
it('should handle numbers by converting to string', () => {
expect(escapeSvg(42)).toBe('42');
});
it('should handle empty string', () => {
expect(escapeSvg('')).toBe('');
});
});
describe('safeColor', () => {
it('should accept hex colors', () => {
expect(safeColor('#fff')).toBe('#fff');
expect(safeColor('#FF0000')).toBe('#FF0000');
expect(safeColor('#12345678')).toBe('#12345678');
});
it('should accept named colors', () => {
expect(safeColor('red')).toBe('red');
expect(safeColor('blue')).toBe('blue');
expect(safeColor('none')).toBe('none');
expect(safeColor('transparent')).toBe('transparent');
});
it('should return fallback for invalid colors', () => {
expect(safeColor('javascript:alert(1)')).toBe('#000000');
expect(safeColor('rgb(255,0,0)')).toBe('#000000');
expect(safeColor('url(evil)')).toBe('#000000');
});
it('should use custom fallback for non-alphabetic invalid values', () => {
expect(safeColor('rgb(255,0,0)', '#fff')).toBe('#fff');
expect(safeColor('url(evil)', 'none')).toBe('none');
});
it('should return default fallback (#000000) when not specified', () => {
expect(safeColor('')).toBe('#000000');
expect(safeColor('999')).toBe('#000000');
});
});
});
+47
View File
@@ -0,0 +1,47 @@
const { FONT_FAMILY, calcFontSize, estimateTextWidth } = require('../src/lib/svg-utils');
describe('svg-utils', () => {
describe('FONT_FAMILY', () => {
it('should be a non-empty string', () => {
expect(typeof FONT_FAMILY).toBe('string');
expect(FONT_FAMILY.length).toBeGreaterThan(0);
});
it('should contain Verdana', () => {
expect(FONT_FAMILY).toContain('Verdana');
});
});
describe('calcFontSize', () => {
it('should return 60% of size rounded', () => {
expect(calcFontSize(24)).toBe(14);
expect(calcFontSize(100)).toBe(60);
expect(calcFontSize(16)).toBe(10);
});
it('should round correctly', () => {
expect(calcFontSize(25)).toBe(15);
expect(calcFontSize(33)).toBe(20); // 33 * 0.6 = 19.8 → 20
});
});
describe('estimateTextWidth', () => {
it('should estimate width based on text length and font size', () => {
const width = estimateTextWidth('Hello', 14);
// formula: text.length * fontSize * 0.7
expect(width).toBe(5 * 14 * 0.7); // 49
});
it('should return 0 for empty string', () => {
expect(estimateTextWidth('', 14)).toBe(0);
});
it('should scale with font size', () => {
const w1 = estimateTextWidth('test', 10);
const w2 = estimateTextWidth('test', 20);
expect(w1).toBe(4 * 10 * 0.7); // 28
expect(w2).toBe(4 * 20 * 0.7); // 56
});
});
});
+184
View File
@@ -0,0 +1,184 @@
const request = require('supertest');
const express = require('express');
const helmet = require('helmet');
const tagRouter = require('../src/api/tag');
const app = express();
app.use(helmet({ crossOriginResourcePolicy: false }));
app.use(tagRouter);
describe('GET /tag', () => {
// --- Basic happy-path ---
it('should return SVG with default params', async () => {
const res = await request(app).get('/tag');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toMatch(/image\/svg\+xml/);
expect(body).toContain('<svg');
expect(body).toContain('<rect');
expect(body).toContain('badgedex');
expect(body).toContain('BADGEDEX');
});
it('should use custom tag and label', async () => {
const res = await request(app).get('/tag?tag=test&label=Teszt');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('test');
expect(body).toContain('TESZT');
});
// --- Backgrounds ---
it('should set tag background to transparent', async () => {
const res = await request(app).get('/tag?bgtag=none');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('fill="none"');
});
it('should set label background to transparent', async () => {
const res = await request(app).get('/tag?bglabel=none');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('fill="none"');
});
it('should apply custom bgtag and bglabel colours', async () => {
const res = await request(app).get('/tag?bgtag=green&bglabel=orange');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('fill="green"');
expect(body).toContain('fill="orange"');
});
// --- Styles ---
it('should use rect style', async () => {
const res = await request(app).get('/tag?style=rect');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('shape-rendering="crispEdges"');
});
it('should use flat style with dynamic dimensions', async () => {
const res = await request(app).get('/tag?style=flat');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<linearGradient');
expect(body).toContain('<clipPath');
expect(body).toContain('rx="3"');
});
it('should fall back to rect for invalid style', async () => {
const res = await request(app).get('/tag?style=invalid');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('shape-rendering="crispEdges"');
});
// --- fontweight ---
it('should handle custom fontweight bold', async () => {
const res = await request(app).get('/tag?fontweight=bold');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('font-weight="bold"');
});
it('should handle fontweight lighter', async () => {
const res = await request(app).get('/tag?fontweight=lighter');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('font-weight="lighter"');
});
// --- color ---
it('should handle custom color', async () => {
const res = await request(app).get('/tag?color=%23ff0000');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('fill="#ff0000"');
});
it('should reject invalid color and fall back to default', async () => {
const res = await request(app).get('/tag?color=bad-color-value');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('fill="#000000"');
});
// --- size ---
it('should handle custom size', async () => {
const res = await request(app).get('/tag?size=48');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('height="48"');
});
it('should handle minimum size (8)', async () => {
const res = await request(app).get('/tag?size=8');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('<svg');
});
it('should fall back to default size for NaN', async () => {
const res = await request(app).get('/tag?size=abc');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('height="24"');
});
it('should fall back to default size for negative value', async () => {
const res = await request(app).get('/tag?size=-10');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('height="24"');
});
it('should fall back to default size for value above max', async () => {
const res = await request(app).get('/tag?size=500');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('height="24"');
});
// --- XSS / injection ---
it('should escape XSS in tag', async () => {
const res = await request(app).get('/tag?tag=<img%20src=x%20onerror=alert(1)>');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).not.toContain('<img');
expect(body).toContain('&lt;img');
});
it('should escape XSS in label', async () => {
const res = await request(app).get('/tag?label=<script>alert(1)</script>');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).not.toContain('<script>');
expect(body).toContain('&lt;script&gt;');
});
// --- SVG structure ---
it('should include aria-label and title', async () => {
const res = await request(app).get('/tag?tag=status&label=OK');
const body = Buffer.from(res.body).toString();
expect(res.statusCode).toBe(200);
expect(body).toContain('aria-label="status: OK"');
expect(body).toContain('<title>status: OK</title>');
});
it('should include xmlns', async () => {
const res = await request(app).get('/tag');
const body = Buffer.from(res.body).toString();
expect(body).toContain('xmlns="http://www.w3.org/2000/svg"');
});
});
+99
View File
@@ -0,0 +1,99 @@
const { parseSize, parseStyle, parseLabelpos, parseFontweight, SIZE_MIN, SIZE_MAX } = require('../src/lib/validate');
describe('validate', () => {
describe('parseSize', () => {
it('should return valid size for valid input', () => {
expect(parseSize('24')).toBe(24);
expect(parseSize('8')).toBe(8);
expect(parseSize('256')).toBe(256);
expect(parseSize('100')).toBe(100);
});
it('should return default (24) for NaN input', () => {
expect(parseSize('abc')).toBe(24);
expect(parseSize(undefined)).toBe(24);
expect(parseSize(null)).toBe(24);
expect(parseSize('')).toBe(24);
});
it('should return default (24) for negative values', () => {
expect(parseSize('-1')).toBe(24);
expect(parseSize('-100')).toBe(24);
});
it('should return default (24) for zero', () => {
expect(parseSize('0')).toBe(24);
});
it('should return default (24) for values above max', () => {
expect(parseSize('257')).toBe(24);
expect(parseSize('9999')).toBe(24);
});
it('should clamp to size for values below min', () => {
expect(parseSize('5')).toBe(24);
expect(parseSize('7')).toBe(24);
});
it('should export SIZE_MIN and SIZE_MAX', () => {
expect(SIZE_MIN).toBe(8);
expect(SIZE_MAX).toBe(256);
});
});
describe('parseStyle', () => {
it('should accept valid styles', () => {
expect(parseStyle('rect')).toBe('rect');
expect(parseStyle('flat')).toBe('flat');
});
it('should return default (rect) for invalid styles', () => {
expect(parseStyle('invalid')).toBe('rect');
expect(parseStyle('')).toBe('rect');
expect(parseStyle(undefined)).toBe('rect');
expect(parseStyle(null)).toBe('rect');
expect(parseStyle('RECT')).toBe('rect');
});
});
describe('parseLabelpos', () => {
it('should accept valid positions', () => {
expect(parseLabelpos('right')).toBe('right');
expect(parseLabelpos('left')).toBe('left');
expect(parseLabelpos('above')).toBe('above');
expect(parseLabelpos('below')).toBe('below');
});
it('should return default (right) for invalid positions', () => {
expect(parseLabelpos('top')).toBe('right');
expect(parseLabelpos('center')).toBe('right');
expect(parseLabelpos('')).toBe('right');
expect(parseLabelpos(undefined)).toBe('right');
});
});
describe('parseFontweight', () => {
it('should accept keyword weights', () => {
expect(parseFontweight('normal')).toBe('normal');
expect(parseFontweight('bold')).toBe('bold');
expect(parseFontweight('lighter')).toBe('lighter');
expect(parseFontweight('bolder')).toBe('bolder');
});
it('should accept numeric weights (100-900)', () => {
expect(parseFontweight('100')).toBe('100');
expect(parseFontweight('400')).toBe('400');
expect(parseFontweight('600')).toBe('600');
expect(parseFontweight('900')).toBe('900');
});
it('should return default (normal) for invalid weights', () => {
expect(parseFontweight('ultra')).toBe('normal');
expect(parseFontweight('123')).toBe('normal');
expect(parseFontweight('')).toBe('normal');
expect(parseFontweight(undefined)).toBe('normal');
expect(parseFontweight('000')).toBe('normal');
});
});
});
+21
View File
@@ -0,0 +1,21 @@
const request = require('supertest');
const express = require('express');
const versionRouter = require('../src/api/version');
const app = express();
app.use(versionRouter);
describe('GET /version', () => {
it('should return JSON with badgedexVersion', async () => {
const res = await request(app).get('/version');
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.body).toHaveProperty('badgedexVersion');
expect(typeof res.body.badgedexVersion).toBe('string');
});
it('should return a valid semver-like version string', async () => {
const res = await request(app).get('/version');
expect(res.body.badgedexVersion).toMatch(/^\d+\.\d+\.\d+/);
});
});
+34
View File
@@ -0,0 +1,34 @@
describe('version', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
});
it('should return version from VERSION file when APP_VERSION is not set', () => {
delete process.env.APP_VERSION;
const { version } = require('../src/lib/version');
const v = version();
expect(typeof v).toBe('string');
expect(v).toMatch(/^\d+\.\d+\.\d+/);
});
it('should return APP_VERSION env var when set', () => {
process.env.APP_VERSION = '9.9.9';
const { version } = require('../src/lib/version');
expect(version()).toBe('9.9.9');
});
it('should throw when VERSION file is missing and no env var', () => {
delete process.env.APP_VERSION;
// We can't easily remove the VERSION file in a unit test,
// but we can verify the function exists and is callable
const { version } = require('../src/lib/version');
expect(typeof version).toBe('function');
});
});