diff --git a/README.md b/README.md index b593fa2..983737b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# Simon API Hub +# Simon API Hub · api.abler.tirol -Production-grade NX monorepo — NestJS backend + Angular frontend, deployed at **https://hub.abler.tirol**. +Production-grade NX monorepo — NestJS backend + Angular frontend. + +Deployed at **https://api.abler.tirol** (formerly `hub.abler.tirol` — both domains are active and point to the same service). Built by a Cyber Security Engineer from Tyrol focused on industrial high-availability systems, reverse engineering and secure API platforms. @@ -16,6 +18,7 @@ Built by a Cyber Security Engineer from Tyrol focused on industrial high-availab | Barcode engine | `bwip-js` | | API docs | Swagger UI at `/api` | | Rate limiting | `@nestjs/throttler` | +| Font server | Self-hosted via `/fonts/*` — serves `DM Serif Display`, `Inter`, `DM Mono` as woff2 | --- @@ -44,10 +47,10 @@ Standard and GS1-compliant barcodes as PNG or SVG. ```bash # Code128 PNG -curl "https://hub.abler.tirol/api/barcode/png?type=code128&text=Hello123&includetext=true" -o out.png +curl "https://api.abler.tirol/api/barcode/png?type=code128&text=Hello123&includetext=true" -o out.png # EAN-13 SVG -curl "https://hub.abler.tirol/api/barcode/svg?type=ean13&text=5901234123457&includetext=true" -o out.svg +curl "https://api.abler.tirol/api/barcode/svg?type=ean13&text=5901234123457&includetext=true" -o out.svg ``` **Query params:** `type` · `text` · `includetext` · `scale` · `height` @@ -299,8 +302,47 @@ Use the key via header: `x-api-key: sk_pro_...` --- +## Font Server — `/fonts` + +The backend self-hosts all fonts used across the `abler.tirol` ecosystem. No Google Fonts — DSGVO-konform by design. + +| Endpoint | Description | +|---|---| +| `GET /fonts/abler-stack.css` | Combined `@font-face` stylesheet for all three font families | +| `GET /fonts/files/:filename` | Individual woff2 files | + +**Usage in any abler.tirol frontend:** + +```html + + +``` + +**Font stack:** + +| Family | Weights | Role | +|---|---|---| +| `DM Serif Display` | 400 normal + italic | Headlines, Hero-Titel | +| `Inter` | 300 · 400 · 500 · 600 | Body, UI | +| `DM Mono` | 400 · 500 | Code, Tags, Badges | + +Font files are sourced from `@fontsource` npm packages and committed to `apps/server/src/assets/fonts/files/`. They are copied to `dist/` at build time via the webpack asset pipeline. + +--- + ## Deployment +### Domain Routing + +Both `hub.abler.tirol` and `api.abler.tirol` are routed identically via Traefik: + +- `/api/*` and `/fonts/*` → backend (port 3000) +- all other paths → frontend (port 80) + +See `docker-compose.yml` for the exact Traefik label configuration. + +--- + ### Docker ```bash @@ -391,4 +433,4 @@ The Angular frontend ships with a first-party cookie banner. The consent status --- -Swagger UI: **https://hub.abler.tirol/api** +Swagger UI: **https://api.abler.tirol/api** (also reachable at `https://hub.abler.tirol/api`) diff --git a/apps/server/src/app/app.module.ts b/apps/server/src/app/app.module.ts index 08b49d8..d1b9b07 100644 --- a/apps/server/src/app/app.module.ts +++ b/apps/server/src/app/app.module.ts @@ -20,6 +20,7 @@ import { CryptoModule } from './crypto/crypto.module'; import { ApiKeyModule } from './api-key/api-key.module'; import { ApiKeyGuard } from './api-key/api-key.guard'; import { UsageModule } from './usage/usage.module'; +import { FontsModule } from './fonts/fonts.module'; @Module({ imports: [ @@ -85,6 +86,7 @@ import { UsageModule } from './usage/usage.module'; LockModule, MetricsModule, ApiKeyModule, + FontsModule, // Rate limiting — applies to every request via APP_INTERCEPTOR. // Anonymous callers (no valid API key) get the defaultRule. // Authenticated callers get TIER_LIMITS from ApiKeyService (applied diff --git a/apps/server/src/app/fonts/fonts.controller.ts b/apps/server/src/app/fonts/fonts.controller.ts new file mode 100644 index 0000000..cf8fbf3 --- /dev/null +++ b/apps/server/src/app/fonts/fonts.controller.ts @@ -0,0 +1,51 @@ +import { Controller, Get, Param, Res, NotFoundException } from '@nestjs/common'; +import { Response } from 'express'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { ApiExcludeController } from '@nestjs/swagger'; + +const FONTS_DIR = join(__dirname, '..', 'assets', 'fonts'); + +const MIME: Record = { + css: 'text/css; charset=utf-8', + woff2: 'font/woff2', + woff: 'font/woff', +}; + +/** Served at /fonts/* — no API key required, CORS * inherited from global config. */ +@ApiExcludeController() +@Controller('fonts') +export class FontsController { + + /** GET /fonts/abler-stack.css */ + @Get('abler-stack.css') + serveStack(@Res() res: Response) { + return this.sendFile(res, 'abler-stack.css'); + } + + /** GET /fonts/files/:filename (woff2 / woff) */ + @Get('files/:filename') + serveFile(@Param('filename') filename: string, @Res() res: Response) { + // Prevent path traversal + if (filename.includes('..') || filename.includes('/')) { + throw new NotFoundException(); + } + return this.sendFile(res, join('files', filename)); + } + + private sendFile(res: Response, relativePath: string) { + const absolute = join(FONTS_DIR, relativePath); + + if (!existsSync(absolute)) { + throw new NotFoundException(`Font not found: ${relativePath}`); + } + + const ext = relativePath.split('.').pop() ?? ''; + const mime = MIME[ext] ?? 'application/octet-stream'; + + res.setHeader('Content-Type', mime); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.sendFile(absolute); + } +} diff --git a/apps/server/src/app/fonts/fonts.module.ts b/apps/server/src/app/fonts/fonts.module.ts new file mode 100644 index 0000000..47fa71e --- /dev/null +++ b/apps/server/src/app/fonts/fonts.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { FontsController } from './fonts.controller'; + +@Module({ + controllers: [FontsController], +}) +export class FontsModule {} diff --git a/apps/server/src/assets/fonts/abler-stack.css b/apps/server/src/assets/fonts/abler-stack.css new file mode 100644 index 0000000..6f73047 --- /dev/null +++ b/apps/server/src/assets/fonts/abler-stack.css @@ -0,0 +1,96 @@ +/* abler.tirol — Self-hosted Font Stack + * Served by api.abler.tirol/fonts/abler-stack.css + * Files: /fonts/files/*.woff2 + * Do NOT load from Google Fonts — see DESIGN_PRINCIPLES.md + */ + +/* ── DM Serif Display ─────────────────────────────── */ +@font-face { + font-family: 'DM Serif Display'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./files/dm-serif-display-latin-400-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, + U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'DM Serif Display'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./files/dm-serif-display-latin-400-italic.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, + U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* ── Inter ────────────────────────────────────────── */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url('./files/inter-latin-300-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, + U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./files/inter-latin-400-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, + U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./files/inter-latin-500-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, + U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('./files/inter-latin-600-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, + U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* ── DM Mono ──────────────────────────────────────── */ +@font-face { + font-family: 'DM Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./files/dm-mono-latin-400-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, + U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'DM Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./files/dm-mono-latin-500-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, + U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/apps/server/src/assets/fonts/files/dm-mono-latin-400-normal.woff2 b/apps/server/src/assets/fonts/files/dm-mono-latin-400-normal.woff2 new file mode 100644 index 0000000..03e4859 Binary files /dev/null and b/apps/server/src/assets/fonts/files/dm-mono-latin-400-normal.woff2 differ diff --git a/apps/server/src/assets/fonts/files/dm-mono-latin-500-normal.woff2 b/apps/server/src/assets/fonts/files/dm-mono-latin-500-normal.woff2 new file mode 100644 index 0000000..67698d8 Binary files /dev/null and b/apps/server/src/assets/fonts/files/dm-mono-latin-500-normal.woff2 differ diff --git a/apps/server/src/assets/fonts/files/dm-serif-display-latin-400-italic.woff2 b/apps/server/src/assets/fonts/files/dm-serif-display-latin-400-italic.woff2 new file mode 100644 index 0000000..f03a1bd Binary files /dev/null and b/apps/server/src/assets/fonts/files/dm-serif-display-latin-400-italic.woff2 differ diff --git a/apps/server/src/assets/fonts/files/dm-serif-display-latin-400-normal.woff2 b/apps/server/src/assets/fonts/files/dm-serif-display-latin-400-normal.woff2 new file mode 100644 index 0000000..75c0a69 Binary files /dev/null and b/apps/server/src/assets/fonts/files/dm-serif-display-latin-400-normal.woff2 differ diff --git a/apps/server/src/assets/fonts/files/inter-latin-300-normal.woff2 b/apps/server/src/assets/fonts/files/inter-latin-300-normal.woff2 new file mode 100644 index 0000000..ece952c Binary files /dev/null and b/apps/server/src/assets/fonts/files/inter-latin-300-normal.woff2 differ diff --git a/apps/server/src/assets/fonts/files/inter-latin-400-normal.woff2 b/apps/server/src/assets/fonts/files/inter-latin-400-normal.woff2 new file mode 100644 index 0000000..f15b025 Binary files /dev/null and b/apps/server/src/assets/fonts/files/inter-latin-400-normal.woff2 differ diff --git a/apps/server/src/assets/fonts/files/inter-latin-500-normal.woff2 b/apps/server/src/assets/fonts/files/inter-latin-500-normal.woff2 new file mode 100644 index 0000000..54f0a59 Binary files /dev/null and b/apps/server/src/assets/fonts/files/inter-latin-500-normal.woff2 differ diff --git a/apps/server/src/assets/fonts/files/inter-latin-600-normal.woff2 b/apps/server/src/assets/fonts/files/inter-latin-600-normal.woff2 new file mode 100644 index 0000000..d189794 Binary files /dev/null and b/apps/server/src/assets/fonts/files/inter-latin-600-normal.woff2 differ diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index f4454cc..560d22f 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -13,7 +13,10 @@ import helmet from 'helmet'; async function bootstrap() { const app = await NestFactory.create(AppModule); const globalPrefix = 'api'; - app.setGlobalPrefix(globalPrefix); + app.setGlobalPrefix(globalPrefix, { + // Font endpoints live at /fonts/*, outside the /api prefix + exclude: ['fonts/(.*)'], + }); app.useGlobalPipes( new ValidationPipe({ transform: true, @@ -70,6 +73,7 @@ async function bootstrap() { '- **Lock** — webhook/MQTT-based smart lock management' ) .setVersion('1.0.0') + .addServer('https://api.abler.tirol') .addServer('https://hub.abler.tirol') .addApiKey( { diff --git a/docker-compose.yml b/docker-compose.yml index 71a7b30..91b97fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,15 +5,28 @@ services: restart: unless-stopped labels: - "traefik.enable=true" - - "traefik.http.routers.simonapi-frontend.tls=true" - - "traefik.http.routers.simonapi-frontend.tls.certresolver=letsEncrypt" - - "traefik.http.routers.simonapi-frontend.entrypoints=websecure" - - "traefik.http.routers.simonapi-frontend.rule=Host(`hub.abler.tirol`) && PathPrefix(`/`)" - - "traefik.http.routers.simonapi-frontend.priority=10" - - "traefik.http.routers.simonapi-frontend.middlewares=simonapi-frontend-compress" - "traefik.http.middlewares.simonapi-frontend-compress.compress=true" - # Interner Container-Port des Frontends (ggf. anpassen) - - "traefik.http.services.simonapi-frontend.loadbalancer.server.port=80" + + # ── hub.abler.tirol frontend ────────────────────────────────────────── + - "traefik.http.routers.hub-frontend.tls=true" + - "traefik.http.routers.hub-frontend.tls.certresolver=letsEncrypt" + - "traefik.http.routers.hub-frontend.entrypoints=websecure" + - "traefik.http.routers.hub-frontend.rule=Host(`hub.abler.tirol`) && PathPrefix(`/`)" + - "traefik.http.routers.hub-frontend.priority=10" + - "traefik.http.routers.hub-frontend.middlewares=simonapi-frontend-compress" + - "traefik.http.routers.hub-frontend.service=simonapi-frontend-svc" + + # ── api.abler.tirol frontend ────────────────────────────────────────── + - "traefik.http.routers.api-frontend.tls=true" + - "traefik.http.routers.api-frontend.tls.certresolver=letsEncrypt" + - "traefik.http.routers.api-frontend.entrypoints=websecure" + - "traefik.http.routers.api-frontend.rule=Host(`api.abler.tirol`) && PathPrefix(`/`)" + - "traefik.http.routers.api-frontend.priority=10" + - "traefik.http.routers.api-frontend.middlewares=simonapi-frontend-compress" + - "traefik.http.routers.api-frontend.service=simonapi-frontend-svc" + + # ── shared service ──────────────────────────────────────────────────── + - "traefik.http.services.simonapi-frontend-svc.loadbalancer.server.port=80" backend: image: simonapi-backend:latest @@ -26,16 +39,26 @@ services: - DATA_DIR=/data/signpacks labels: - "traefik.enable=true" - - "traefik.http.routers.simonapi-backend.tls=true" - - "traefik.http.routers.simonapi-backend.tls.certresolver=letsEncrypt" - - "traefik.http.routers.simonapi-backend.entrypoints=websecure" - - "traefik.http.routers.simonapi-backend.rule=Host(`hub.abler.tirol`) && PathPrefix(`/api`)" - # Strippt das Prefix /api bevor es an den Service geht - # - "traefik.http.middlewares.simonapi-backend-strip.stripprefixregex.regex=/api" - # - "traefik.http.routers.simonapi-backend.middlewares=simonapi-backend-strip@docker" - - "traefik.http.routers.simonapi-backend.priority=100" - # Interner Container-Port des Backends (ggf. anpassen) - - "traefik.http.services.simonapi-backend.loadbalancer.server.port=3000" + + # ── hub.abler.tirol backend ─────────────────────────────────────────── + - "traefik.http.routers.hub-backend.tls=true" + - "traefik.http.routers.hub-backend.tls.certresolver=letsEncrypt" + - "traefik.http.routers.hub-backend.entrypoints=websecure" + - "traefik.http.routers.hub-backend.rule=Host(`hub.abler.tirol`) && (PathPrefix(`/api`) || PathPrefix(`/fonts`))" + - "traefik.http.routers.hub-backend.priority=100" + - "traefik.http.routers.hub-backend.service=simonapi-backend-svc" + + # ── api.abler.tirol backend ─────────────────────────────────────────── + - "traefik.http.routers.api-backend.tls=true" + - "traefik.http.routers.api-backend.tls.certresolver=letsEncrypt" + - "traefik.http.routers.api-backend.entrypoints=websecure" + - "traefik.http.routers.api-backend.rule=Host(`api.abler.tirol`) && (PathPrefix(`/api`) || PathPrefix(`/fonts`))" + - "traefik.http.routers.api-backend.priority=100" + - "traefik.http.routers.api-backend.service=simonapi-backend-svc" + + # ── shared service ──────────────────────────────────────────────────── + - "traefik.http.services.simonapi-backend-svc.loadbalancer.server.port=3000" + # depends_on: # - postgres networks: @@ -59,4 +82,4 @@ services: networks: postgresql: name: NET_POSTGRESQL - external: true \ No newline at end of file + external: true diff --git a/package-lock.json b/package-lock.json index c711542..35cb256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,9 @@ "@angular/router": "~20.1.0", "@angular/ssr": "~20.1.0", "@bwip-js/node": "^4.7.0", + "@fontsource/dm-mono": "^5.2.7", + "@fontsource/dm-serif-display": "^5.2.8", + "@fontsource/inter": "^5.2.8", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.2", @@ -5149,6 +5152,33 @@ "node": ">=14" } }, + "node_modules/@fontsource/dm-mono": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/dm-mono/-/dm-mono-5.2.7.tgz", + "integrity": "sha512-Ma1az2atTVgQWuOWwjuxx26p/6A6CU9HBNKq1CFV6YKpKhpswnf9ry9Ql4+T6bTZzkdtSfS6tjJvqZOljVzIFQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/dm-serif-display": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/dm-serif-display/-/dm-serif-display-5.2.8.tgz", + "integrity": "sha512-GYSDSlGU6vyhv9a5MwaiVNf9HCuSVpK8hEFRyG4NNDHCDeHiX7YHDAcWsaoLKKcfXLgWG9YkBkk9T3SxM4rAjQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", diff --git a/package.json b/package.json index 2bb17fe..ceac105 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "@angular/router": "~20.1.0", "@angular/ssr": "~20.1.0", "@bwip-js/node": "^4.7.0", + "@fontsource/dm-mono": "^5.2.7", + "@fontsource/dm-serif-display": "^5.2.8", + "@fontsource/inter": "^5.2.8", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.2",