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",