Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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 |

---

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
<link rel="preconnect" href="https://api.abler.tirol" />
<link rel="stylesheet" href="https://api.abler.tirol/fonts/abler-stack.css" />
```

**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
Expand Down Expand Up @@ -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`)
2 changes: 2 additions & 0 deletions apps/server/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions apps/server/src/app/fonts/fonts.controller.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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);
}
}
7 changes: 7 additions & 0 deletions apps/server/src/app/fonts/fonts.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { FontsController } from './fonts.controller';

@Module({
controllers: [FontsController],
})
export class FontsModule {}
96 changes: 96 additions & 0 deletions apps/server/src/assets/fonts/abler-stack.css
Original file line number Diff line number Diff line change
@@ -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;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
6 changes: 5 additions & 1 deletion apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(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,
Expand Down Expand Up @@ -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(
{
Expand Down
61 changes: 42 additions & 19 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -59,4 +82,4 @@ services:
networks:
postgresql:
name: NET_POSTGRESQL
external: true
external: true
Loading
Loading