Compare commits
20
Commits
e461de7ca2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2104680c4e | ||
|
|
3b7cc2e993 | ||
|
|
adcec5f67f | ||
|
|
e7f0aa1c12 | ||
|
|
ff20d1cb11 | ||
|
|
80b73b62e4 | ||
|
|
06b16b9033 | ||
|
|
d73aa076ad | ||
|
|
729b7f566f | ||
|
|
d74fd3a248 | ||
|
|
b7c6c5df7b | ||
|
|
8fb05b3f5b | ||
|
|
fcd08cbaac | ||
|
|
fc3a4bb7ba | ||
|
|
e8d693bd20 | ||
|
|
7740c3efa0 | ||
|
|
f40a834c8c | ||
|
|
3774b40987 | ||
|
|
c4146a2d65 | ||
|
|
e7fa124013 |
+15
-5
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -136,3 +136,8 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
# SQLite database files
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
sqlite*
|
||||||
|
sqldiff
|
||||||
+10
-3
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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 (8–256). | `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`
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/badge
|
||||||
|
```
|
||||||
|
|
||||||
- **GitHub ikon, piros label háttér, fehér szöveg:**
|
```markdown
|
||||||
`/badge?icon=github&bglabel=red&color=white`
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/badge?icon=github&bglabel=red&color=white
|
||||||
|
```
|
||||||
|
|
||||||
- **Szöveg balra, ikon jobbra:**
|
```markdown
|
||||||
`/badge?labelpos=left`
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/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`
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/badge?icon=docker&label=Docker&style=flat&bgicon=%232496ED&color=white
|
||||||
|
```
|
||||||
|
|
||||||
- **Nagyobb ikon és szöveg:**
|
```markdown
|
||||||
`/badge?size=48`
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/badge?icon=nginx&labelpos=left
|
||||||
|
```
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/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 (8–256). | `16`, `20`, `32` |
|
||||||
|
| `fontweight`| Nem | `normal` | Szöveg vastagsága. | `normal`, `bold`, `600` |
|
||||||
|
|
||||||
|
#### Tag példák
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/tag
|
||||||
|
```
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/tag?tag=build&label=passing&bgtag=%23555&bglabel=green&color=white
|
||||||
|
```
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/tag?tag=coverage&label=98%25&bgtag=%23555&bglabel=%23007ec6&color=white
|
||||||
|
```
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/tag?tag=license&label=AGPL--v3&bgtag=gray&bglabel=blue&color=white
|
||||||
|
```
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/tag?tag=version&label=v0.0.1&style=flat&bgtag=%23333&bglabel=%23007ec6&color=white
|
||||||
|
```
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
```
|
||||||
|
/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.
|
||||||
Generated
+4668
-83
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-39
@@ -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,8 +145,12 @@ 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) {
|
||||||
res.status(500).send('Internal Server Error');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'));
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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.
|
||||||
|
* < → < > → > & → & " → " ' → '
|
||||||
|
*/
|
||||||
|
const escapeSvg = (value) =>
|
||||||
|
String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
@@ -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 };
|
||||||
@@ -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, 100–900.
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
@@ -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 };
|
||||||
@@ -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('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('"');
|
||||||
|
});
|
||||||
|
|
||||||
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const { escapeSvg, safeColor } = require('../src/lib/sanitize');
|
||||||
|
|
||||||
|
describe('sanitize', () => {
|
||||||
|
|
||||||
|
describe('escapeSvg', () => {
|
||||||
|
it('should escape < and >', () => {
|
||||||
|
expect(escapeSvg('<script>')).toBe('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape &', () => {
|
||||||
|
expect(escapeSvg('A & B')).toBe('A & B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape double quotes', () => {
|
||||||
|
expect(escapeSvg('"hello"')).toBe('"hello"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape single quotes', () => {
|
||||||
|
expect(escapeSvg("it's")).toBe('it's');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape combined special characters', () => {
|
||||||
|
expect(escapeSvg('<img src="x" onerror=\'alert(1)\'>')).toBe(
|
||||||
|
'<img src="x" onerror='alert(1)'>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('<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('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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+/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user