From 44b2231fba0c5fc07543ce59e9fa07f7a6a211e1 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Thu, 12 Mar 2026 05:52:21 +0000 Subject: [PATCH 01/12] chore(deps): add geoip-lite for IP-to-country resolution in visitor stats --- package-lock.json | 168 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 20 +++--- 2 files changed, 172 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 09bd98e..c711542 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "express": "^4.21.2", + "geoip-lite": "^1.4.10", "helmet": "^8.1.0", "jsonld": "^8.3.3", "jsonwebtoken": "^9.0.3", @@ -89,6 +90,7 @@ "@types/bcryptjs": "^2.4.6", "@types/bwip-js": "^3.2.3", "@types/express": "^4.17.21", + "@types/geoip-lite": "^1.4.4", "@types/jest": "^30.0.0", "@types/multer": "^2.0.0", "@types/node": "20.19.9", @@ -10663,6 +10665,13 @@ "@types/send": "*" } }, + "node_modules/@types/geoip-lite": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@types/geoip-lite/-/geoip-lite-1.4.4.tgz", + "integrity": "sha512-2uVfn+C6bX/H356H6mjxsWUA5u8LO8dJgSBIRO/NFlpMe4DESzacutD/rKYrTDKm1Ugv78b4Wz1KvpHrlv3jSw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -13234,7 +13243,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -13787,7 +13795,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "devOptional": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -16293,6 +16300,15 @@ "bser": "2.1.1" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -16928,7 +16944,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "devOptional": true, "license": "ISC" }, "node_modules/function-bind": { @@ -16971,6 +16986,109 @@ "node": ">=6.9.0" } }, + "node_modules/geoip-lite": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/geoip-lite/-/geoip-lite-1.4.10.tgz", + "integrity": "sha512-4N69uhpS3KFd97m00wiFEefwa+L+HT5xZbzPhwu+sDawStg6UN/dPwWtUfkQuZkGIY1Cj7wDVp80IsqNtGMi2w==", + "license": "Apache-2.0", + "dependencies": { + "async": "2.1 - 2.6.4", + "chalk": "4.1 - 4.1.2", + "iconv-lite": "0.4.13 - 0.6.3", + "ip-address": "5.8.9 - 5.9.4", + "lazy": "1.0.11", + "rimraf": "2.5.2 - 2.7.1", + "yauzl": "2.9.2 - 2.10.0" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/geoip-lite/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/geoip-lite/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/geoip-lite/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/geoip-lite/node_modules/ip-address": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-5.9.4.tgz", + "integrity": "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "lodash": "^4.17.15", + "sprintf-js": "1.1.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/geoip-lite/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/geoip-lite/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/geoip-lite/node_modules/sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", + "license": "BSD-3-Clause" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -17279,7 +17397,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17994,7 +18111,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -19525,6 +19641,12 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, "node_modules/jsdom": { "version": "26.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", @@ -20069,6 +20191,15 @@ "shell-quote": "^1.8.3" } }, + "node_modules/lazy": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz", + "integrity": "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==", + "license": "MIT", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -22682,7 +22813,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -22742,6 +22872,12 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -26871,7 +27007,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -29424,6 +29559,25 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yauzl/node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 2d6bed1..2bb17fe 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "express": "^4.21.2", + "geoip-lite": "^1.4.10", "helmet": "^8.1.0", "jsonld": "^8.3.3", "jsonwebtoken": "^9.0.3", @@ -92,6 +93,7 @@ "@types/bcryptjs": "^2.4.6", "@types/bwip-js": "^3.2.3", "@types/express": "^4.17.21", + "@types/geoip-lite": "^1.4.4", "@types/jest": "^30.0.0", "@types/multer": "^2.0.0", "@types/node": "20.19.9", @@ -123,16 +125,16 @@ } }, "optionalDependencies": { + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/win32-x64": "0.25.5", + "@swc/core-darwin-arm64": "1.5.7", + "@swc/core-darwin-x64": "1.5.7", "@swc/core-linux-x64-gnu": "1.5.7", "@swc/core-linux-x64-musl": "1.5.7", - "@swc/core-darwin-x64": "1.5.7", - "@swc/core-darwin-arm64": "1.5.7", - "@swc/core-win32-x64-msvc": "1.5.7", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/win32-x64": "0.25.5" + "@swc/core-win32-x64-msvc": "1.5.7" } } From 2bd2f7d8b839071b940674d7c9ce8b32fe218314 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Thu, 12 Mar 2026 05:52:40 +0000 Subject: [PATCH 02/12] =?UTF-8?q?feat(visitor-stats):=20add=20VisitorDaily?= =?UTF-8?q?Entity=20=E2=80=94=20privacy-preserving=20IP-hash=20+=20GeoIP?= =?UTF-8?q?=20per=20day?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema: visitor_daily (id, day, ip_hash, country_code, route_group, tier, api_key_prefix, request_count, error_count, updated_at) Privacy model: - ip_hash = SHA-256(rawIp + YYYY-MM-DD + IP_HASH_SECRET) - Daily-rotating salt means same IP on different days → different hash - No long-term user tracking possible by design - Raw IP is never persisted Indexes: IDX_VD_DATE_HASH (day, ip_hash) for upsert lookups IDX_VD_DATE (day) for daily aggregation queries --- .../metrics/entities/visitor-daily.entity.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 apps/server/src/app/metrics/entities/visitor-daily.entity.ts diff --git a/apps/server/src/app/metrics/entities/visitor-daily.entity.ts b/apps/server/src/app/metrics/entities/visitor-daily.entity.ts new file mode 100644 index 0000000..bde5eff --- /dev/null +++ b/apps/server/src/app/metrics/entities/visitor-daily.entity.ts @@ -0,0 +1,51 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +/** + * One row = one (date, ip_hash, route_group, tier) combination. + * + * Privacy model: + * ip_hash = SHA-256(rawIp + daily-salt) + * daily-salt = YYYY-MM-DD + IP_HASH_SECRET env-var + * → same IP on different days produces different hashes (no long-term profile) + * → raw IP is NEVER stored + */ +@Entity({ name: 'visitor_daily' }) +@Index('IDX_VD_DATE_HASH', ['day', 'ipHash']) +@Index('IDX_VD_DATE', ['day']) +export class VisitorDailyEntity { + @PrimaryGeneratedColumn() + id!: number; + + /** ISO date string YYYY-MM-DD */ + @Column({ type: 'text' }) + day!: string; + + /** SHA-256(ip + daily-salt) — never raw IP */ + @Column({ name: 'ip_hash', type: 'text' }) + ipHash!: string; + + /** ISO 3166-1 alpha-2 country code from geoip-lite, or 'XX' if unknown */ + @Column({ name: 'country_code', type: 'text', default: 'XX' }) + countryCode!: string; + + /** API group derived from route prefix: qr / barcode / watermark / crypto / signpack / utils / lock / admin / other */ + @Column({ name: 'route_group', type: 'text', default: 'other' }) + routeGroup!: string; + + /** Tier of the resolved API key, or 'anonymous' */ + @Column({ type: 'text', default: 'anonymous' }) + tier!: string; + + /** First 8 characters of the API key prefix, nullable */ + @Column({ name: 'api_key_prefix', type: 'text', nullable: true }) + apiKeyPrefix!: string | null; + + @Column({ name: 'request_count', type: 'integer', default: 0 }) + requestCount!: number; + + @Column({ name: 'error_count', type: 'integer', default: 0 }) + errorCount!: number; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} From 74670f7168e75b0689d17c1126808225137c1307 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Thu, 12 Mar 2026 05:54:27 +0000 Subject: [PATCH 03/12] =?UTF-8?q?feat(visitor-stats):=20VisitorService=20?= =?UTF-8?q?=E2=80=94=20in-memory=20buffer,=20GeoIP,=20flush=20+=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core logic: record(): called per-request, increments in-memory bucket keyed by (day | ip_hash | route_group | tier | api_key_prefix). IP → SHA-256(ip|YYYY-MM-DD|IP_HASH_SECRET) — raw IP never leaves this method. Country resolved via geoip-lite.lookup() → ISO 3166-1 alpha-2 or 'XX'. flush(): runs every 60s + on module destroy. Upserts dirty buckets into visitor_daily via orUpdate so concurrent instances don't race. sweep(): runs every 10min. Evicts in-memory entries older than 25h to prevent unbounded heap growth (mirrors UsageService.sweepStale pattern). Stats queries (called by upcoming admin endpoints): getSummary() — today / 7d / 30d unique IPs + request counts + tier breakdown getDailyUnique()— per-day unique IPs + requests for sparkline charts getByApi() — route_group breakdown with error rate for today getByCountry() — country breakdown with unique IPs for today resolveRouteGroup() exported helper maps /api/ → logical group name. --- .../server/src/app/metrics/visitor.service.ts | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 apps/server/src/app/metrics/visitor.service.ts diff --git a/apps/server/src/app/metrics/visitor.service.ts b/apps/server/src/app/metrics/visitor.service.ts new file mode 100644 index 0000000..ce81de7 --- /dev/null +++ b/apps/server/src/app/metrics/visitor.service.ts @@ -0,0 +1,317 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { createHash } from 'node:crypto'; +import * as geoip from 'geoip-lite'; +import { VisitorDailyEntity } from './entities/visitor-daily.entity'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** How often to flush in-memory counters to the DB (ms). Default: 60 s */ +const FLUSH_INTERVAL_MS = 60_000; + +/** How often to evict stale in-memory entries (ms). Default: 10 min */ +const SWEEP_INTERVAL_MS = 10 * 60_000; + +/** Maximum age for in-memory entries before sweep removes them (ms). 25 h covers today + yesterday */ +const STALE_AFTER_MS = 25 * 60 * 60_000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface VisitorKey { + day: string; + ipHash: string; + routeGroup: string; + tier: string; + apiKeyPrefix: string | null; +} + +interface VisitorBucket { + key: VisitorKey; + countryCode: string; + requestCount: number; + errorCount: number; + lastSeenMs: number; + dirty: boolean; +} + +// --------------------------------------------------------------------------- +// Route-group resolver +// --------------------------------------------------------------------------- + +export function resolveRouteGroup(path: string): string { + const p = (path || '').replace(/^\/api\//, '').toLowerCase(); + if (p.startsWith('qr')) return 'qr'; + if (p.startsWith('barcode')) return 'barcode'; + if (p.startsWith('watermark')) return 'watermark'; + if (p.startsWith('crypto')) return 'crypto'; + if (p.startsWith('signpack')) return 'signpack'; + if (p.startsWith('utils')) return 'utils'; + if (p.startsWith('lock')) return 'lock'; + if (p.startsWith('admin')) return 'admin'; + return 'other'; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +@Injectable() +export class VisitorService implements OnModuleInit, OnModuleDestroy { + private readonly log = new Logger(VisitorService.name); + + /** In-memory write-buffer keyed by "day|ipHash|routeGroup|tier|apiKeyPrefix" */ + private readonly buckets = new Map(); + + private flushTimer: ReturnType | null = null; + private sweepTimer: ReturnType | null = null; + + constructor( + @InjectRepository(VisitorDailyEntity) + private readonly repo: Repository, + ) {} + + onModuleInit(): void { + this.flushTimer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS); + this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS); + if (this.flushTimer.unref) this.flushTimer.unref(); + if (this.sweepTimer.unref) this.sweepTimer.unref(); + } + + async onModuleDestroy(): Promise { + if (this.flushTimer) clearInterval(this.flushTimer); + if (this.sweepTimer) clearInterval(this.sweepTimer); + await this.flush(); + } + + // --------------------------------------------------------------------------- + // Public API — called by MetricsInterceptor + // --------------------------------------------------------------------------- + + record(opts: { + rawIp: string; + route: string; + status: number; + tier: string; + apiKeyPrefix: string | null; + }): void { + const now = Date.now(); + const day = new Date(now).toISOString().slice(0, 10); + const ipHash = this.hashIp(opts.rawIp, day); + const countryCode = this.resolveCountry(opts.rawIp); + const routeGroup = resolveRouteGroup(opts.route); + const isError = opts.status >= 400; + + const bucketKey = `${day}|${ipHash}|${routeGroup}|${opts.tier}|${opts.apiKeyPrefix ?? ''}`; + + const existing = this.buckets.get(bucketKey); + if (existing) { + existing.requestCount += 1; + if (isError) existing.errorCount += 1; + existing.lastSeenMs = now; + existing.dirty = true; + } else { + this.buckets.set(bucketKey, { + key: { day, ipHash, routeGroup, tier: opts.tier, apiKeyPrefix: opts.apiKeyPrefix }, + countryCode, + requestCount: 1, + errorCount: isError ? 1 : 0, + lastSeenMs: now, + dirty: true, + }); + } + } + + // --------------------------------------------------------------------------- + // Stats queries — used by admin controller + // --------------------------------------------------------------------------- + + async getSummary(): Promise<{ + today: { uniqueIps: number; totalRequests: number; totalErrors: number; byTier: Record }; + last7d: { uniqueIps: number; totalRequests: number }; + last30d: { uniqueIps: number; totalRequests: number }; + }> { + await this.flush(); + + const today = new Date().toISOString().slice(0, 10); + const day7 = new Date(Date.now() - 7 * 86_400_000).toISOString().slice(0, 10); + const day30 = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10); + + const [todayRows, week7Rows, month30Rows] = await Promise.all([ + this.repo.find({ where: { day: today } }), + this.repo.createQueryBuilder('v') + .select('COUNT(DISTINCT v.ip_hash)', 'uniqueIps') + .addSelect('SUM(v.request_count)', 'totalRequests') + .where('v.day >= :day', { day: day7 }) + .getRawOne<{ uniqueIps: string; totalRequests: string }>(), + this.repo.createQueryBuilder('v') + .select('COUNT(DISTINCT v.ip_hash)', 'uniqueIps') + .addSelect('SUM(v.request_count)', 'totalRequests') + .where('v.day >= :day', { day: day30 }) + .getRawOne<{ uniqueIps: string; totalRequests: string }>(), + ]); + + // Today aggregations + const todayUniqueIps = new Set(todayRows.map(r => r.ipHash)).size; + const todayTotalRequests = todayRows.reduce((s, r) => s + r.requestCount, 0); + const todayTotalErrors = todayRows.reduce((s, r) => s + r.errorCount, 0); + const byTier: Record = {}; + for (const row of todayRows) { + byTier[row.tier] = (byTier[row.tier] ?? 0) + 1; + } + + return { + today: { + uniqueIps: todayUniqueIps, + totalRequests: todayTotalRequests, + totalErrors: todayTotalErrors, + byTier, + }, + last7d: { + uniqueIps: parseInt(week7Rows?.uniqueIps ?? '0', 10), + totalRequests: parseInt(week7Rows?.totalRequests ?? '0', 10), + }, + last30d: { + uniqueIps: parseInt(month30Rows?.uniqueIps ?? '0', 10), + totalRequests: parseInt(month30Rows?.totalRequests ?? '0', 10), + }, + }; + } + + async getDailyUnique(days = 30): Promise> { + await this.flush(); + + const since = new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); + const rows = await this.repo.createQueryBuilder('v') + .select('v.day', 'day') + .addSelect('COUNT(DISTINCT v.ip_hash)', 'uniqueIps') + .addSelect('SUM(v.request_count)', 'totalRequests') + .where('v.day >= :since', { since }) + .groupBy('v.day') + .orderBy('v.day', 'ASC') + .getRawMany<{ day: string; uniqueIps: string; totalRequests: string }>(); + + return rows.map(r => ({ + day: r.day, + uniqueIps: parseInt(r.uniqueIps, 10), + totalRequests: parseInt(r.totalRequests, 10), + })); + } + + async getByApi(day?: string): Promise> { + await this.flush(); + + const targetDay = day ?? new Date().toISOString().slice(0, 10); + const rows = await this.repo.createQueryBuilder('v') + .select('v.route_group', 'routeGroup') + .addSelect('COUNT(DISTINCT v.ip_hash)', 'uniqueIps') + .addSelect('SUM(v.request_count)', 'totalRequests') + .addSelect('SUM(v.error_count)', 'totalErrors') + .where('v.day = :day', { day: targetDay }) + .groupBy('v.route_group') + .orderBy('SUM(v.request_count)', 'DESC') + .getRawMany<{ routeGroup: string; uniqueIps: string; totalRequests: string; totalErrors: string }>(); + + return rows.map(r => { + const req = parseInt(r.totalRequests, 10); + const err = parseInt(r.totalErrors, 10); + return { + routeGroup: r.routeGroup, + uniqueIps: parseInt(r.uniqueIps, 10), + totalRequests: req, + errorRate: req > 0 ? Math.round((err / req) * 1000) / 10 : 0, + }; + }); + } + + async getByCountry(day?: string): Promise> { + await this.flush(); + + const targetDay = day ?? new Date().toISOString().slice(0, 10); + const rows = await this.repo.createQueryBuilder('v') + .select('v.country_code', 'countryCode') + .addSelect('COUNT(DISTINCT v.ip_hash)', 'uniqueIps') + .addSelect('SUM(v.request_count)', 'totalRequests') + .where('v.day = :day', { day: targetDay }) + .groupBy('v.country_code') + .orderBy('COUNT(DISTINCT v.ip_hash)', 'DESC') + .getRawMany<{ countryCode: string; uniqueIps: string; totalRequests: string }>(); + + return rows.map(r => ({ + countryCode: r.countryCode, + uniqueIps: parseInt(r.uniqueIps, 10), + totalRequests: parseInt(r.totalRequests, 10), + })); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private hashIp(ip: string, day: string): string { + const secret = process.env['IP_HASH_SECRET'] ?? 'default-change-me'; + return createHash('sha256').update(`${ip}|${day}|${secret}`).digest('hex'); + } + + private resolveCountry(ip: string): string { + try { + const result = geoip.lookup(ip); + return result?.country ?? 'XX'; + } catch { + return 'XX'; + } + } + + /** Flush all dirty buckets to the DB via upsert. */ + async flush(): Promise { + const dirty = [...this.buckets.values()].filter(b => b.dirty); + if (!dirty.length) return; + + for (const bucket of dirty) { + try { + await this.repo + .createQueryBuilder() + .insert() + .into(VisitorDailyEntity) + .values({ + day: bucket.key.day, + ipHash: bucket.key.ipHash, + countryCode: bucket.countryCode, + routeGroup: bucket.key.routeGroup, + tier: bucket.key.tier, + apiKeyPrefix: bucket.key.apiKeyPrefix, + requestCount: bucket.requestCount, + errorCount: bucket.errorCount, + }) + .orUpdate( + ['request_count', 'error_count', 'updated_at'], + ['day', 'ip_hash', 'route_group', 'tier', 'api_key_prefix'], + { skipUpdateIfNoValuesChanged: true }, + ) + .execute(); + bucket.dirty = false; + } catch (err) { + this.log.error('Failed to flush visitor bucket', err instanceof Error ? err.stack : String(err)); + } + } + } + + /** Remove in-memory entries that haven't been updated recently. */ + private sweep(): void { + const cutoff = Date.now() - STALE_AFTER_MS; + let evicted = 0; + for (const [k, b] of this.buckets) { + if (b.lastSeenMs < cutoff) { + this.buckets.delete(k); + evicted++; + } + } + if (evicted > 0) { + this.log.debug(`Visitor bucket sweep: evicted ${evicted} stale entries`); + } + } +} From a9e3643a726b032359fbb07ff355b7e1067bde7f Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Thu, 12 Mar 2026 05:55:56 +0000 Subject: [PATCH 04/12] feat(visitor-stats): wire VisitorService into MetricsInterceptor + MetricsModule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MetricsInterceptor (metrics.interceptor.ts): - Injects VisitorService alongside existing MetricsService + AnomalyDetector - After each non-skipped request: calls visitor.record() with rawIp (resolved from X-Forwarded-For → req.ip → socket.remoteAddress), route path, HTTP status, tier and first-8-chars API key prefix - Admin routes are still skipped via SKIP_METRICS decorator as before MetricsModule (metrics.module.ts): - VisitorDailyEntity added to TypeOrmModule.forFeature (TypeORM synchronize=true will auto-create the table on startup) - VisitorService added to providers and exports --- .../server/src/app/metrics/metrics.interceptor.ts | 15 +++++++++++++++ apps/server/src/app/metrics/metrics.module.ts | 7 +++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/server/src/app/metrics/metrics.interceptor.ts b/apps/server/src/app/metrics/metrics.interceptor.ts index 77cefc4..0d2df01 100644 --- a/apps/server/src/app/metrics/metrics.interceptor.ts +++ b/apps/server/src/app/metrics/metrics.interceptor.ts @@ -11,6 +11,8 @@ import { MetricsService } from './metrics.service'; import { Reflector } from '@nestjs/core'; import { SKIP_METRICS } from './metrics.decorator'; import { AnomalyDetectorService } from './anomaly-detector.service'; +import { VisitorService } from './visitor.service'; +import { RESOLVED_KEY_PROP } from '../api-key/api-key.guard'; function resolveRoutePath(req: any): string { const expressRoute = @@ -34,6 +36,7 @@ export class MetricsInterceptor implements NestInterceptor { private readonly metrics: MetricsService, private readonly reflector: Reflector, private readonly anomaly: AnomalyDetectorService, + private readonly visitor: VisitorService, ) {} intercept(context: ExecutionContext, next: CallHandler): Observable { @@ -63,6 +66,18 @@ export class MetricsInterceptor implements NestInterceptor { this.metrics.record(path, method, status, durationMs).catch((err) => { this.log.error('Failed to store request metric', err instanceof Error ? err.stack : String(err)); }); + + // Visitor stats — resolve IP, tier and API-key prefix from request + const resolved = req[RESOLVED_KEY_PROP] as { tier?: string; prefix?: string } | undefined; + const xff = (req?.headers?.['x-forwarded-for'] as string) || ''; + const rawIp = xff.split(',')[0]?.trim() || req?.ip || req?.socket?.remoteAddress || 'unknown'; + this.visitor.record({ + rawIp, + route: path, + status, + tier: resolved?.tier ?? 'anonymous', + apiKeyPrefix: resolved?.prefix ? resolved.prefix.slice(0, 8) : null, + }); } this.anomaly.observe(req, path, method, status); }), diff --git a/apps/server/src/app/metrics/metrics.module.ts b/apps/server/src/app/metrics/metrics.module.ts index 3c56d0c..430a7fc 100644 --- a/apps/server/src/app/metrics/metrics.module.ts +++ b/apps/server/src/app/metrics/metrics.module.ts @@ -7,6 +7,8 @@ import { MetricsController } from './metrics.controller'; import { BlocklistService } from './blocklist.service'; import { AnomalyDetectorService } from './anomaly-detector.service'; import { AnomalyGuard } from './anomaly.guard'; +import { VisitorService } from './visitor.service'; +import { VisitorDailyEntity } from './entities/visitor-daily.entity'; import { MetricMetaEntity } from './entities/metric-meta.entity'; import { MetricRouteEntity } from './entities/metric-route.entity'; import { MetricDailyEntity } from './entities/metric-daily.entity'; @@ -14,17 +16,18 @@ import { SecurityBlockEntity } from './entities/security-block.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([MetricMetaEntity, MetricRouteEntity, MetricDailyEntity, SecurityBlockEntity]), + TypeOrmModule.forFeature([MetricMetaEntity, MetricRouteEntity, MetricDailyEntity, SecurityBlockEntity, VisitorDailyEntity]), ], providers: [ MetricsService, BlocklistService, AnomalyDetectorService, + VisitorService, Reflector, { provide: APP_GUARD, useClass: AnomalyGuard }, { provide: APP_INTERCEPTOR, useClass: MetricsInterceptor }, ], controllers: [MetricsController], - exports: [MetricsService, BlocklistService], + exports: [MetricsService, BlocklistService, VisitorService], }) export class MetricsModule {} From 2c7084ce204eb76856dd6761fe91cd69fb29fbea Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Thu, 12 Mar 2026 05:58:00 +0000 Subject: [PATCH 05/12] feat(visitor-stats): add 4 visitor admin endpoints to MetricsController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All endpoints: @RequiresAdminKey() + @SkipMetrics() + @SkipAnomalyGuard() GET /admin/stats/visitors/summary → today unique IPs, requests, errors, tier breakdown + 7d + 30d totals GET /admin/stats/visitors/daily?days=30 → per-day array { day, uniqueIps, totalRequests } — sparkline source days param: 1–90, default 30 GET /admin/stats/visitors/by-api?day=YYYY-MM-DD → route group breakdown { routeGroup, uniqueIps, totalRequests, errorRate } day param: defaults to today GET /admin/stats/visitors/by-country?day=YYYY-MM-DD → country breakdown { countryCode, uniqueIps, totalRequests } day param: defaults to today 286/286 tests pass. --- .../src/app/metrics/metrics.controller.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/server/src/app/metrics/metrics.controller.ts b/apps/server/src/app/metrics/metrics.controller.ts index b7e026d..74842e8 100644 --- a/apps/server/src/app/metrics/metrics.controller.ts +++ b/apps/server/src/app/metrics/metrics.controller.ts @@ -5,6 +5,7 @@ import { MetricsService } from './metrics.service'; import { SkipMetrics } from './metrics.decorator'; import { SkipAnomalyGuard } from './anomaly.guard'; import { BlocklistService } from './blocklist.service'; +import { VisitorService } from './visitor.service'; /** * Admin-only metrics & security dashboard. @@ -21,6 +22,7 @@ export class MetricsController { constructor( private readonly metrics: MetricsService, private readonly blocklist: BlocklistService, + private readonly visitor: VisitorService, ) {} @Get() @@ -61,4 +63,43 @@ export class MetricsController { const ok = this.blocklist.unban(ip); return { ok }; } + + // ── Visitor stats ────────────────────────────────────────────────────────── + + @Get('visitors/summary') + @HttpCode(200) + @SkipMetrics() + @RequiresAdminKey() + @ApiOperation({ summary: 'Visitor summary: unique IPs today / 7d / 30d with tier breakdown (admin)' }) + async visitorSummary() { + return this.visitor.getSummary(); + } + + @Get('visitors/daily') + @HttpCode(200) + @SkipMetrics() + @RequiresAdminKey() + @ApiOperation({ summary: 'Daily unique IPs + request counts for the last N days (admin)' }) + async visitorDaily(@Query('days') days?: string) { + const n = days ? Math.min(Math.max(parseInt(days, 10) || 30, 1), 90) : 30; + return this.visitor.getDailyUnique(n); + } + + @Get('visitors/by-api') + @HttpCode(200) + @SkipMetrics() + @RequiresAdminKey() + @ApiOperation({ summary: 'Unique IPs and request counts grouped by API (route group) for a given day (admin)' }) + async visitorByApi(@Query('day') day?: string) { + return this.visitor.getByApi(day); + } + + @Get('visitors/by-country') + @HttpCode(200) + @SkipMetrics() + @RequiresAdminKey() + @ApiOperation({ summary: 'Unique IPs grouped by country for a given day (admin)' }) + async visitorByCountry(@Query('day') day?: string) { + return this.visitor.getByCountry(day); + } } From 33339f5d374a5684bb90f95ccf4b1bed2127a170 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Thu, 12 Mar 2026 05:58:26 +0000 Subject: [PATCH 06/12] chore(config): document IP_HASH_SECRET in .env.example + docker-compose comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .env.example: added IP_HASH_SECRET section explaining the daily-salt mechanism and how to generate a secure value (openssl rand -hex 32). Also serves as a reminder that changing the secret invalidates existing ip_hash rows (old data becomes un-relatable — rows are kept as aggregate counts only). docker-compose.yml: added inline comment on the .env volume mount listing DATA_DIR and IP_HASH_SECRET as required env vars that must be present in the server-side .env file. --- .env.example | 7 +++++++ docker-compose.yml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index edfcfa6..4fdcf22 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,10 @@ PURGE_CRON="0 * * * *" # hourly purge of expired signpacks # Must be long and random — e.g.: openssl rand -hex 32 # Send as: x-api-key: when calling admin endpoints. ADMIN_KEY=changeme-generate-with-openssl-rand-hex-32 + +# ── Visitor stats ───────────────────────────────────────────────────────────── +# Secret used to salt the daily IP hash (SHA-256(ip|YYYY-MM-DD|secret)). +# Changing this key invalidates all existing ip_hash values in visitor_daily +# (old rows become unrelatable — fine, they remain as aggregate counts). +# Generate: openssl rand -hex 32 +IP_HASH_SECRET=changeme-generate-with-openssl-rand-hex-32 diff --git a/docker-compose.yml b/docker-compose.yml index 17ed477..280cf65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: container_name: simonapi-backend restart: unless-stopped volumes: - - ./.env:/app/.env + - ./.env:/app/.env # must contain DATA_DIR=/data/signpacks and IP_HASH_SECRET= - /home/simon/simonapi/data:/data labels: - "traefik.enable=true" From 5ed983f54c129c4ba5ec14331a5876750a838975 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Thu, 12 Mar 2026 06:00:41 +0000 Subject: [PATCH 07/12] =?UTF-8?q?feat(visitor-stats):=20Visitors=20tab=20i?= =?UTF-8?q?n=20admin=20dashboard=20=E2=80=94=20summary,=20sparkline,=20API?= =?UTF-8?q?/country=20breakdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stats.service.ts: New interfaces: VisitorSummary, VisitorDailyPoint, VisitorByApi, VisitorByCountry + 4 new methods: getVisitorSummary(), getVisitorDaily(), getVisitorByApi(), getVisitorByCountry(). visitors.component.ts / .html: Standalone component with signals — loads all 4 endpoints on init. Layout: Row 1: 4 summary cards (unique IPs today/7d/30d, requests today) Row 2: SVG sparkline (last 14 days) + tier breakdown bar chart Row 3: By-API table (uniqueIps, requests, error%) + By-Country table with flag emoji + ISO code (top 10) Refresh button + privacy note (daily-rotating hash disclaimer). flagEmoji() converts ISO 3166-1 alpha-2 to regional indicator emoji. sparklinePoints builds a polyline from daily unique-IP counts. app.routes.ts: /admin/visitors lazy-loads VisitorsComponent. stats-dashboard.component.html: 👥 Visitors nav tab added. styles.scss: .vstat-card, .vstat-panel, .sparkline, .tier-bar, .vstat-table, .route-badge — all scoped with vstat- prefix, consistent with design tokens. --- apps/simonapi/src/app/app.routes.ts | 5 + .../stats/stats-dashboard.component.html | 5 + .../src/app/features/stats/stats.service.ts | 61 ++++++ .../stats/visitors/visitors.component.html | 184 ++++++++++++++++++ .../stats/visitors/visitors.component.ts | 95 +++++++++ apps/simonapi/src/styles.scss | 102 +++++++++- 6 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 apps/simonapi/src/app/features/stats/visitors/visitors.component.html create mode 100644 apps/simonapi/src/app/features/stats/visitors/visitors.component.ts diff --git a/apps/simonapi/src/app/app.routes.ts b/apps/simonapi/src/app/app.routes.ts index 0664396..5a61b2e 100644 --- a/apps/simonapi/src/app/app.routes.ts +++ b/apps/simonapi/src/app/app.routes.ts @@ -52,6 +52,11 @@ export const appRoutes: Route[] = [ loadComponent: () => import('./features/stats/api-keys.component').then(m => m.ApiKeysComponent), title: 'Admin – API Keys', }, + { + path: 'admin/visitors', + loadComponent: () => import('./features/stats/visitors/visitors.component').then(m => m.VisitorsComponent), + title: 'Admin – Visitors', + }, { path: 'crypto', loadComponent: () => import('./features/crypto/crypto-page.component').then(c => c.CryptoPageComponent), diff --git a/apps/simonapi/src/app/features/stats/stats-dashboard.component.html b/apps/simonapi/src/app/features/stats/stats-dashboard.component.html index 4cf9da3..a837490 100644 --- a/apps/simonapi/src/app/features/stats/stats-dashboard.component.html +++ b/apps/simonapi/src/app/features/stats/stats-dashboard.component.html @@ -34,6 +34,11 @@ 🔑 API Keys + diff --git a/apps/simonapi/src/app/features/stats/stats.service.ts b/apps/simonapi/src/app/features/stats/stats.service.ts index fa10182..921c86c 100644 --- a/apps/simonapi/src/app/features/stats/stats.service.ts +++ b/apps/simonapi/src/app/features/stats/stats.service.ts @@ -36,6 +36,39 @@ export interface SecuritySnapshot { blocked: BlockEntryView[]; } +export interface VisitorTierBreakdown { anonymous: number; free: number; pro: number; industrial: number; [key: string]: number } + +export interface VisitorToday { + uniqueIps: number; + totalRequests: number; + totalErrors: number; + byTier: VisitorTierBreakdown; +} + +export interface VisitorPeriod { uniqueIps: number; totalRequests: number } + +export interface VisitorSummary { + today: VisitorToday; + last7d: VisitorPeriod; + last30d: VisitorPeriod; +} + +export interface VisitorDailyPoint { day: string; uniqueIps: number; totalRequests: number } + +export interface VisitorByApi { + routeGroup: string; + uniqueIps: number; + totalRequests: number; + errorRate: number; +} + +export interface VisitorByCountry { + countryCode: string; + uniqueIps: number; + totalRequests: number; +} + + const STORAGE_KEY = 'admin_api_key'; @Injectable({ providedIn: 'root' }) @@ -92,4 +125,32 @@ export class StatsService { params, }); } + + getVisitorSummary(): Observable { + return this.http.get(`${this.API}/admin/stats/visitors/summary`, this.headers()); + } + + getVisitorDaily(days = 30): Observable { + const params = new HttpParams({ fromObject: { days: String(days) } }); + return this.http.get(`${this.API}/admin/stats/visitors/daily`, { + ...this.headers(), + params, + }); + } + + getVisitorByApi(day?: string): Observable { + const params = day ? new HttpParams({ fromObject: { day } }) : new HttpParams(); + return this.http.get(`${this.API}/admin/stats/visitors/by-api`, { + ...this.headers(), + params, + }); + } + + getVisitorByCountry(day?: string): Observable { + const params = day ? new HttpParams({ fromObject: { day } }) : new HttpParams(); + return this.http.get(`${this.API}/admin/stats/visitors/by-country`, { + ...this.headers(), + params, + }); + } } diff --git a/apps/simonapi/src/app/features/stats/visitors/visitors.component.html b/apps/simonapi/src/app/features/stats/visitors/visitors.component.html new file mode 100644 index 0000000..bf8c595 --- /dev/null +++ b/apps/simonapi/src/app/features/stats/visitors/visitors.component.html @@ -0,0 +1,184 @@ +
+ + + @if (errorMsg()) { +
⚠️ {{ errorMsg() }}
+ } + + @if (loading()) { +
Loading visitor stats…
+ } + + @if (summary(); as s) { + + +
+ + +
+
+
{{ s.today.uniqueIps }}
+
Unique IPs today
+
+
+ + +
+
+
{{ s.today.totalRequests | number }}
+
Requests today
+
+
+ + +
+
+
{{ s.last7d.uniqueIps }}
+
Unique IPs (7d)
+
+
+ + +
+
+
{{ s.last30d.uniqueIps }}
+
Unique IPs (30d)
+
+
+ +
+ + +
+ + +
+
+
Unique IPs — last 14 days
+ @if (daily().length > 1) { + + + +
+ {{ daily()[daily().length - 14 < 0 ? 0 : daily().length - 14]?.day | slice:5 }} + {{ daily()[daily().length - 1]?.day | slice:5 }} +
+ } @else { +
Not enough data yet
+ } +
+
+ + +
+
+
Tier breakdown — today
+
+ @for (tier of tierKeys(s.today.byTier); track tier) { +
+ + {{ tier }} +
+
+
+
+ {{ s.today.byTier[tier] }} +
+ } +
+
+
+ +
+ + +
+ + +
+
+
By API — today
+ + + + + + + + + + + @for (row of byApi(); track row.routeGroup) { + + + + + + + } + @if (!byApi().length) { + + } + +
APIUnique IPsRequestsErr %
{{ row.routeGroup }}{{ row.uniqueIps }}{{ row.totalRequests | number }} + {{ row.errorRate }}% +
No data yet
+
+
+ + +
+
+
By Country — today (top 10)
+ + + + + + + + + + @for (row of byCountry().slice(0, 10); track row.countryCode) { + + + + + + } + @if (!byCountry().length) { + + } + +
CountryUnique IPsRequests
+ {{ flagEmoji(row.countryCode) }} + {{ row.countryCode }} + {{ row.uniqueIps }}{{ row.totalRequests | number }}
No data yet
+
+
+ +
+ + } + + +
+ +
+ IPs are stored as daily-rotating SHA-256 hashes — raw IPs are never persisted. +
+
+ +
diff --git a/apps/simonapi/src/app/features/stats/visitors/visitors.component.ts b/apps/simonapi/src/app/features/stats/visitors/visitors.component.ts new file mode 100644 index 0000000..ac1ca9b --- /dev/null +++ b/apps/simonapi/src/app/features/stats/visitors/visitors.component.ts @@ -0,0 +1,95 @@ +import { Component, inject, OnInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + StatsService, + VisitorSummary, + VisitorDailyPoint, + VisitorByApi, + VisitorByCountry, +} from '../stats.service'; + +@Component({ + selector: 'app-visitors', + standalone: true, + imports: [CommonModule], + templateUrl: './visitors.component.html', +}) +export class VisitorsComponent implements OnInit { + private readonly svc = inject(StatsService); + + summary = signal(null); + daily = signal([]); + byApi = signal([]); + byCountry = signal([]); + + loading = signal(false); + errorMsg = signal(null); + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading.set(true); + this.errorMsg.set(null); + + this.svc.getVisitorSummary().subscribe({ + next: s => this.summary.set(s), + error: e => this.errorMsg.set(e?.error?.message ?? 'Failed to load visitor summary'), + }); + + this.svc.getVisitorDaily(30).subscribe({ + next: d => this.daily.set(d), + }); + + this.svc.getVisitorByApi().subscribe({ + next: a => this.byApi.set(a), + }); + + this.svc.getVisitorByCountry().subscribe({ + next: c => { + this.byCountry.set(c); + this.loading.set(false); + }, + error: () => this.loading.set(false), + }); + } + + /** Build a tiny inline sparkline from the last 14 daily points. */ + get sparklinePoints(): string { + const pts = this.daily().slice(-14); + if (!pts.length) return ''; + const max = Math.max(...pts.map(p => p.uniqueIps), 1); + const w = 200; + const h = 36; + return pts + .map((p, i) => { + const x = Math.round((i / (pts.length - 1)) * w); + const y = Math.round(h - (p.uniqueIps / max) * h); + return `${x},${y}`; + }) + .join(' '); + } + + tierKeys(byTier: Record): string[] { + return Object.keys(byTier); + } + + tierColor(tier: string): string { + const map: Record = { + anonymous: '#94a3b8', + free: '#22d3ee', + pro: '#0ea5e9', + industrial: '#f59e0b', + }; + return map[tier] ?? '#94a3b8'; + } + + /** Format a country code to flag emoji */ + flagEmoji(code: string): string { + if (!code || code === 'XX') return '🌐'; + return code.toUpperCase().replace(/./g, c => + String.fromCodePoint(127397 + c.charCodeAt(0)) + ); + } +} diff --git a/apps/simonapi/src/styles.scss b/apps/simonapi/src/styles.scss index f7d9007..10edae9 100644 --- a/apps/simonapi/src/styles.scss +++ b/apps/simonapi/src/styles.scss @@ -717,4 +717,104 @@ kbd { background: var(--surface-2, #f8fafc); border-radius: var(--radius); border: 1px solid var(--border); -} \ No newline at end of file +} +/* ── Admin: Visitor Stats ─────────────────────────────────────────── */ +.visitors-wrap { padding: 1rem 0; } + +.vstat-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.1rem 1.25rem; + box-shadow: var(--shadow-sm); + transition: box-shadow .2s var(--ease); + + &:hover { box-shadow: var(--shadow); } +} +.vstat-val { + font-size: 1.75rem; + font-weight: 800; + line-height: 1.1; + color: var(--text); + font-variant-numeric: tabular-nums; +} +.vstat-label { + font-size: .72rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .06em; + margin-top: .2rem; +} +.vstat-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.1rem 1.25rem; + box-shadow: var(--shadow-sm); +} +.vstat-panel-title { + font-size: .72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .07em; + color: var(--brand-dark, #0891b2); +} +.sparkline { + display: block; + width: 100%; + height: 40px; + margin: .5rem 0 .2rem; +} +.sparkline-labels { + display: flex; + justify-content: space-between; + font-size: .68rem; + color: var(--muted); +} +.tier-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.tier-bar-wrap { + height: 6px; + background: var(--surface-2); + border-radius: 99px; + overflow: hidden; +} +.tier-bar { + height: 100%; + border-radius: 99px; + transition: width .4s var(--ease); +} +.vstat-table { + width: 100%; + font-size: .8rem; + border-collapse: collapse; + + th { + font-size: .68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--muted); + padding: .3rem .4rem; + border-bottom: 1px solid var(--border); + } + td { + padding: .35rem .4rem; + border-bottom: 1px solid color-mix(in oklab, var(--border), transparent 50%); + color: var(--text); + } + tr:last-child td { border-bottom: none; } +} +.route-badge { + font-size: .7rem; + padding: .1rem .45rem; + border-radius: var(--radius-sm); + background: color-mix(in oklab, var(--brand), transparent 88%); + color: var(--brand-dark, #0891b2); + border: 1px solid color-mix(in oklab, var(--brand), transparent 68%); + font-weight: 600; +} From 7ecb2651cea8754c38d98b9c04383418d5276194 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Thu, 12 Mar 2026 06:00:52 +0000 Subject: [PATCH 08/12] fix(docker): add geoip-lite to runtime npm install in Dockerfile geoip-lite ships its own MaxMind GeoLite2 database as part of the npm package and requires a postinstall script to update/verify the DB files. Since the Dockerfile uses --ignore-scripts for npm ci, geoip-lite must be installed explicitly via npm install (no --ignore-scripts) in the runtime stage alongside pg and sharp. --- dockerfiles/Dockerfile.backend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dockerfiles/Dockerfile.backend b/dockerfiles/Dockerfile.backend index cbc6f9b..b9759c6 100644 --- a/dockerfiles/Dockerfile.backend +++ b/dockerfiles/Dockerfile.backend @@ -39,7 +39,7 @@ COPY --from=build --chown=node:node /build/simonapi/dist/apps/server ./ RUN if [ -f package.json ]; then npm ci --omit=dev --ignore-scripts; fi RUN npm rebuild sqlite3 --build-from-source -RUN npm install pg sharp +RUN npm install pg sharp geoip-lite COPY --chown=node:node ./apps/server/entrypoint.backend.sh . EXPOSE 3000 From 7f1af2e47a7199f2a084be85f81535a02c56b34c Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Fri, 13 Mar 2026 09:09:34 +0000 Subject: [PATCH 09/12] fix(admin): gate all three admin pages behind key check + unified nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security issues fixed: - Stats, API Keys and Visitors were all rendering content (or firing API requests) before an admin key was entered. Now all three pages show a lock screen until statsService.apiKey() is truthy. - api-keys.component.ts: ngOnInit no longer calls load() unless a key is already stored in sessionStorage. - visitors.component.ts: ngOnInit no longer calls load() without a key. AdminNavComponent (admin-nav.component.ts) — new shared component: - Single source of truth for the key input + all three nav tabs (📊 Stats / 🔑 API Keys / 👥 Visitors). - Replaces the duplicated nav+key-input markup that existed independently in stats-dashboard and api-keys templates. - Calls svc.setApiKey() directly on input — no per-page handler needed. - Imported by all three admin pages. Result: entering/clearing the key on any tab immediately reflects across all tabs via the shared StatsService signal. 286/286 backend tests pass. TypeScript clean. --- .../app/features/stats/admin-nav.component.ts | 52 +++ .../features/stats/api-keys.component.html | 312 ++++++++---------- .../app/features/stats/api-keys.component.ts | 11 +- .../stats/stats-dashboard.component.html | 256 +++++++------- .../stats/stats-dashboard.component.ts | 10 +- .../stats/visitors/visitors.component.html | 312 +++++++++--------- .../stats/visitors/visitors.component.ts | 8 +- 7 files changed, 464 insertions(+), 497 deletions(-) create mode 100644 apps/simonapi/src/app/features/stats/admin-nav.component.ts diff --git a/apps/simonapi/src/app/features/stats/admin-nav.component.ts b/apps/simonapi/src/app/features/stats/admin-nav.component.ts new file mode 100644 index 0000000..130173c --- /dev/null +++ b/apps/simonapi/src/app/features/stats/admin-nav.component.ts @@ -0,0 +1,52 @@ +import { Component, inject } from '@angular/core'; +import { RouterLink, RouterLinkActive } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { StatsService } from './stats.service'; + +@Component({ + selector: 'app-admin-nav', + standalone: true, + imports: [RouterLink, RouterLinkActive, FormsModule], + template: ` + +
+
+
+ Admin API Key + + @if (svc.apiKey()) { + Key set + } @else { + No key + } +
+
+
+ + +
+ +
+ `, +}) +export class AdminNavComponent { + readonly svc = inject(StatsService); +} diff --git a/apps/simonapi/src/app/features/stats/api-keys.component.html b/apps/simonapi/src/app/features/stats/api-keys.component.html index 695918a..7bfed6f 100644 --- a/apps/simonapi/src/app/features/stats/api-keys.component.html +++ b/apps/simonapi/src/app/features/stats/api-keys.component.html @@ -1,182 +1,152 @@ - -
-
-
- Admin API Key - - @if (statsService.apiKey()) { - Key set - } @else { - No key — requests will fail - } -
-
- - - + - -
- - @if (createdRawKey()) { - - } + +@if (!statsService.apiKey()) { +
+
🔒
+

Enter your admin key above to manage API keys.

+
+} @else { +
- -
-
- Create API Key -
-
- @if (createError()) { -
{{ createError() }}
- } -
-
- - -
-
- - + + @if (createdRawKey()) { +
-
+} diff --git a/apps/simonapi/src/app/features/stats/api-keys.component.ts b/apps/simonapi/src/app/features/stats/api-keys.component.ts index 1a56e84..4fa74c1 100644 --- a/apps/simonapi/src/app/features/stats/api-keys.component.ts +++ b/apps/simonapi/src/app/features/stats/api-keys.component.ts @@ -4,11 +4,12 @@ import { Component, inject, OnInit, PLATFORM_ID, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ApiKeyRecord, ApiKeyTier, ApiKeysService, CreateApiKeyDto } from './api-keys.service'; import { StatsService } from './stats.service'; +import { AdminNavComponent } from './admin-nav.component'; @Component({ selector: 'app-api-keys', standalone: true, - imports: [CommonModule, DatePipe, FormsModule, RouterLink, RouterLinkActive], + imports: [CommonModule, DatePipe, FormsModule, AdminNavComponent], templateUrl: './api-keys.component.html', }) export class ApiKeysComponent implements OnInit { @@ -35,7 +36,7 @@ export class ApiKeysComponent implements OnInit { readonly tiers: ApiKeyTier[] = ['free', 'pro', 'industrial']; ngOnInit(): void { - if (isPlatformBrowser(this.platformId)) { + if (isPlatformBrowser(this.platformId) && this.statsService.apiKey()) { this.load(); } } @@ -106,12 +107,6 @@ export class ApiKeysComponent implements OnInit { this.createdRawKey.set(null); } - onApiKeyChange(e: Event): void { - const val = (e.target as HTMLInputElement).value; - this.statsService.setApiKey(val); - this.load(); - } - tierBadgeClass(tier: ApiKeyTier): string { return { free: 'bg-secondary-subtle text-secondary border-secondary-subtle', diff --git a/apps/simonapi/src/app/features/stats/stats-dashboard.component.html b/apps/simonapi/src/app/features/stats/stats-dashboard.component.html index a837490..715063d 100644 --- a/apps/simonapi/src/app/features/stats/stats-dashboard.component.html +++ b/apps/simonapi/src/app/features/stats/stats-dashboard.component.html @@ -1,162 +1,128 @@ - -
-
-
- Admin API Key - - @if (statsService.apiKey()) { - Key set - } @else { - No key — requests will fail - } -
-
-
- - - - -
-
- -
- - -
+ - -
- - Loading + +@if (!statsService.apiKey()) { +
+
🔒
+

Enter your admin key above to view statistics.

+
+} @else { +
+
+ +
+ +
- - -
-
- Total Requests - {{ vm.stats?.totalCount ?? 0 | number }} -
-
- Uptime - {{ vm.uptimeMs | duration }} + +
+ + Loading
-
- Distinct Routes - {{ distinctRoutes(vm.stats) }} -
-
- -
- Started: - {{ vm.stats?.startedAtIso | isoDate }} - ( {{ vm.startedRelative }} ) -
+ - -
-
-
-
- {{ entry.day | slice:5 }} - {{ entry.count }} -
+ +
+
+ Total Requests + {{ vm.stats?.totalCount ?? 0 | number }} +
+
+ Uptime + {{ vm.uptimeMs | duration }} +
+
+ Distinct Routes + {{ distinctRoutes(vm.stats) }}
- - -

No daily values available.

-
-
-

Top Routes

- -
- - - - - - - - - - - - - - - - - -
RouteCountAvg (ms)Top Status
{{ route.route }}{{ route.count | number }}{{ route.avgMs | number:'1.0-1' }}{{ dominantStatus(route) }}
+
+ Started: + {{ vm.stats?.startedAtIso | isoDate }} + ( {{ vm.startedRelative }} ) +
+ + +
+
+
+
+ {{ entry.day | slice:5 }} + {{ entry.count }} +
+
- -

No route statistics available.

+ +

No daily values available.

-
- - - +
+

Top Routes

+ +
+ + + + + + + + + + + + + + + + + +
RouteCountAvg (ms)Top Status
{{ route.route }}{{ route.count | number }}{{ route.avgMs | number:'1.0-1' }}{{ dominantStatus(route) }}
+
+
+ +

No route statistics available.

+
+
+ - - -
- - Loading + - + - - - + + +
+ + Loading +
+
- -
+ + + + + + +
-
+} diff --git a/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts b/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts index 3192383..d280a19 100644 --- a/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts +++ b/apps/simonapi/src/app/features/stats/stats-dashboard.component.ts @@ -9,6 +9,7 @@ import { StatsCardComponent } from './stats-card.component'; import { SecurityTableComponent } from './security-table.component'; import { DurationPipe } from './duration.pipe'; import { IsoDatePipe } from './iso-date.pipe'; +import { AdminNavComponent } from './admin-nav.component'; interface ViewModel { loading: boolean; @@ -26,8 +27,7 @@ interface ViewModel { imports: [ CommonModule, FormsModule, - RouterLink, - RouterLinkActive, + AdminNavComponent, StatsCardComponent, SecurityTableComponent, DurationPipe, @@ -63,12 +63,6 @@ export class StatsDashboardComponent implements OnDestroy { this.vm$ = this.isBrowser ? this.createClientStream() : of(this.initialState); } - onApiKeyChange(e: Event): void { - const val = (e.target as HTMLInputElement).value; - this.statsService.setApiKey(val); - this.refresh(); - } - refresh(): void { if (!this.isBrowser) { return; diff --git a/apps/simonapi/src/app/features/stats/visitors/visitors.component.html b/apps/simonapi/src/app/features/stats/visitors/visitors.component.html index bf8c595..eacfb0c 100644 --- a/apps/simonapi/src/app/features/stats/visitors/visitors.component.html +++ b/apps/simonapi/src/app/features/stats/visitors/visitors.component.html @@ -1,184 +1,170 @@ -
+ - - @if (errorMsg()) { -
⚠️ {{ errorMsg() }}
- } - - @if (loading()) { -
Loading visitor stats…
- } - - @if (summary(); as s) { - - -
- - -
-
-
{{ s.today.uniqueIps }}
-
Unique IPs today
-
-
- - -
-
-
{{ s.today.totalRequests | number }}
-
Requests today
-
-
+ +@if (!statsService.apiKey()) { +
+
🔒
+

Enter your admin key above to view visitor statistics.

+
+} @else { +
+
- -
-
-
{{ s.last7d.uniqueIps }}
-
Unique IPs (7d)
-
-
+ @if (errorMsg()) { +
⚠️ {{ errorMsg() }}
+ } - -
-
-
{{ s.last30d.uniqueIps }}
-
Unique IPs (30d)
+ @if (loading()) { +
+ Loading visitor stats…
-
- -
+ } - -
+ @if (summary(); as s) { - -
-
-
Unique IPs — last 14 days
- @if (daily().length > 1) { - - - -
- {{ daily()[daily().length - 14 < 0 ? 0 : daily().length - 14]?.day | slice:5 }} - {{ daily()[daily().length - 1]?.day | slice:5 }} + +
+
+
+
{{ s.today.uniqueIps }}
+
Unique IPs today
+
+
+
+
+
{{ s.today.totalRequests | number }}
+
Requests today
+
+
+
+
+
{{ s.last7d.uniqueIps }}
+
Unique IPs (7d)
+
+
+
+
+
{{ s.last30d.uniqueIps }}
+
Unique IPs (30d)
- } @else { -
Not enough data yet
- } +
-
- -
-
-
Tier breakdown — today
-
- @for (tier of tierKeys(s.today.byTier); track tier) { -
- - {{ tier }} -
-
-
+ +
+
+
+
Unique IPs — last 14 days
+ @if (daily().length > 1) { + + + +
+ {{ daily()[daily().length - 14 < 0 ? 0 : daily().length - 14]?.day | slice:5 }} + {{ daily()[daily().length - 1]?.day | slice:5 }}
- {{ s.today.byTier[tier] }} + } @else { +
Not enough data yet
+ } +
+
+
+
+
Tier breakdown — today
+
+ @for (tier of tierKeys(s.today.byTier); track tier) { +
+ + {{ tier }} +
+
+
+
+ {{ s.today.byTier[tier] }} +
+ }
- } +
-
-
- - -
- - -
-
-
By API — today
- - - - - - - - - - - @for (row of byApi(); track row.routeGroup) { - - - - - - - } - @if (!byApi().length) { - - } - -
APIUnique IPsRequestsErr %
{{ row.routeGroup }}{{ row.uniqueIps }}{{ row.totalRequests | number }} - {{ row.errorRate }}% -
No data yet
+ +
+
+
+
By API — today
+ + + + + + + + + + + @for (row of byApi(); track row.routeGroup) { + + + + + + + } + @if (!byApi().length) { + + } + +
APIUnique IPsRequestsErr %
{{ row.routeGroup }}{{ row.uniqueIps }}{{ row.totalRequests | number }}{{ row.errorRate }}%
No data yet
+
+
+
+
+
By Country — today (top 10)
+ + + + + + + + + + @for (row of byCountry().slice(0, 10); track row.countryCode) { + + + + + + } + @if (!byCountry().length) { + + } + +
CountryUnique IPsRequests
{{ flagEmoji(row.countryCode) }}{{ row.countryCode }}{{ row.uniqueIps }}{{ row.totalRequests | number }}
No data yet
+
+
-
- -
-
-
By Country — today (top 10)
- - - - - - - - - - @for (row of byCountry().slice(0, 10); track row.countryCode) { - - - - - - } - @if (!byCountry().length) { - - } - -
CountryUnique IPsRequests
- {{ flagEmoji(row.countryCode) }} - {{ row.countryCode }} - {{ row.uniqueIps }}{{ row.totalRequests | number }}
No data yet
+ } + + +
+ +
+ IPs are stored as daily-rotating SHA-256 hashes — raw IPs are never persisted.
- - } - - -
- -
- IPs are stored as daily-rotating SHA-256 hashes — raw IPs are never persisted. -
- -
+} diff --git a/apps/simonapi/src/app/features/stats/visitors/visitors.component.ts b/apps/simonapi/src/app/features/stats/visitors/visitors.component.ts index ac1ca9b..9b4ebbf 100644 --- a/apps/simonapi/src/app/features/stats/visitors/visitors.component.ts +++ b/apps/simonapi/src/app/features/stats/visitors/visitors.component.ts @@ -1,5 +1,6 @@ import { Component, inject, OnInit, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { AdminNavComponent } from '../admin-nav.component'; import { StatsService, VisitorSummary, @@ -11,11 +12,12 @@ import { @Component({ selector: 'app-visitors', standalone: true, - imports: [CommonModule], + imports: [CommonModule, AdminNavComponent], templateUrl: './visitors.component.html', }) export class VisitorsComponent implements OnInit { private readonly svc = inject(StatsService); + readonly statsService = inject(StatsService); summary = signal(null); daily = signal([]); @@ -26,7 +28,9 @@ export class VisitorsComponent implements OnInit { errorMsg = signal(null); ngOnInit(): void { - this.load(); + if (this.statsService.apiKey()) { + this.load(); + } } load(): void { From 0a3ca437ba36d16cd0b5cb13588c5b1c01840808 Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Fri, 13 Mar 2026 09:12:27 +0000 Subject: [PATCH 10/12] fix(critical): AnomalyDetector sweep + forbidNonWhitelisted + DATA_DIR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. AnomalyDetectorService — 5 Maps now bounded (anomaly-detector.service.ts) Added OnModuleInit/OnModuleDestroy + setInterval sweep every 10 min. New lastSeen Map tracks last-observed timestamp per IP. sweepStale() evicts all 5 counter-Maps for IPs not seen in 20 min. Returns evicted count (testable). Mirrors the UsageService.sweepStale() pattern already in use. Prevents unbounded heap growth under sustained traffic from many distinct IPs. 2. ValidationPipe — forbidNonWhitelisted: true (main.ts) Was false: unknown query/body params silently passed through. Now returns 400 Bad Request if a caller sends e.g. ?scalee=2 (typo). 286/286 tests still pass — no existing test sent unknown params. 3. DATA_DIR env var (docker-compose.yml) Volume mounts /home/simon/simonapi/data → /data but DATA_DIR was never set, so signpack files were written to /app/data/signpacks (inside the container layer, lost on rebuild). Fixed by adding: environment: - DATA_DIR=/data/signpacks Signpack files now persist correctly on the host volume. --- .../app/metrics/anomaly-detector.service.ts | 45 ++++++++++++++++++- apps/server/src/main.ts | 2 +- docker-compose.yml | 4 +- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/server/src/app/metrics/anomaly-detector.service.ts b/apps/server/src/app/metrics/anomaly-detector.service.ts index 709830b..d468769 100644 --- a/apps/server/src/app/metrics/anomaly-detector.service.ts +++ b/apps/server/src/app/metrics/anomaly-detector.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; + +/** How often to sweep stale IP entries (ms). Default: 10 min */ +const SWEEP_INTERVAL_MS = 10 * 60_000; +/** IP not seen for this long is evicted from in-memory Maps. */ +const STALE_AFTER_MS = 20 * 60_000; import { SlidingCounter, SlidingDistinct } from './sliding'; import { BlocklistService } from './blocklist.service'; @@ -19,7 +24,7 @@ const cfgFromEnv = (): AnomalyConfig => ({ }); @Injectable() -export class AnomalyDetectorService { +export class AnomalyDetectorService implements OnModuleInit, OnModuleDestroy { private readonly log = new Logger(AnomalyDetectorService.name); private readonly cfg = cfgFromEnv(); @@ -30,8 +35,42 @@ export class AnomalyDetectorService { private perIpReq5m = new Map(); // requests 5min private perIpErr5m = new Map(); // errors 5min + /** Tracks the last-seen timestamp per IP for stale-entry eviction. */ + private lastSeen = new Map(); + private sweepTimer: ReturnType | null = null; + constructor(private readonly blocklist: BlocklistService) {} + onModuleInit(): void { + this.sweepTimer = setInterval(() => this.sweepStale(), SWEEP_INTERVAL_MS); + if (this.sweepTimer.unref) this.sweepTimer.unref(); + } + + onModuleDestroy(): void { + if (this.sweepTimer) clearInterval(this.sweepTimer); + } + + /** Remove in-memory counters for IPs that haven't been seen recently. */ + sweepStale(now = Date.now()): number { + const cutoff = now - STALE_AFTER_MS; + let evicted = 0; + for (const [ip, ts] of this.lastSeen) { + if (ts < cutoff) { + this.perIpMinute.delete(ip); + this.perIpFiveMin.delete(ip); + this.perIpRoutes.delete(ip); + this.perIpReq5m.delete(ip); + this.perIpErr5m.delete(ip); + this.lastSeen.delete(ip); + evicted++; + } + } + if (evicted > 0) { + this.log.debug(`AnomalyDetector sweep: evicted ${evicted} stale IP entries`); + } + return evicted; + } + private getIp(req: any): string { const xff = (req?.headers?.['x-forwarded-for'] as string) || ''; const candidate = xff.split(',')[0]?.trim(); @@ -42,6 +81,8 @@ export class AnomalyDetectorService { const ip = this.getIp(req); const now = Date.now(); + this.lastSeen.set(ip, now); + const minute = (this.perIpMinute.get(ip) ?? new SlidingCounter(60_000, 5_000)); const five = (this.perIpFiveMin.get(ip) ?? new SlidingCounter(300_000, 10_000)); const routes = (this.perIpRoutes.get(ip) ?? new SlidingDistinct(60_000, 5_000)); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 6a8ac51..f4454cc 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -18,7 +18,7 @@ async function bootstrap() { new ValidationPipe({ transform: true, whitelist: true, - forbidNonWhitelisted: false, + forbidNonWhitelisted: true, }) ); // Public API — allow all origins. The double-activation (cors:true + enableCors) diff --git a/docker-compose.yml b/docker-compose.yml index 280cf65..da0ae0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,8 +20,10 @@ services: container_name: simonapi-backend restart: unless-stopped volumes: - - ./.env:/app/.env # must contain DATA_DIR=/data/signpacks and IP_HASH_SECRET= + - ./.env:/app/.env # must contain IP_HASH_SECRET= - /home/simon/simonapi/data:/data + environment: + - DATA_DIR=/data/signpacks labels: - "traefik.enable=true" - "traefik.http.routers.simonapi-backend.tls=true" From ef6327d20b4725ac96d2b1532f332f86d053a85d Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Fri, 13 Mar 2026 09:36:07 +0000 Subject: [PATCH 11/12] fix(security+db): ApiKeyGuard as global APP_GUARD + visitor flush + signpack oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG 1 — AdminKey not enforced (CRITICAL): @RequiresAdminKey() / @RequiresTier() decorators only set metadata. Without a guard that reads that metadata, they were complete no-ops. ApiKeyGuard was never registered as APP_GUARD — only via @UseGuards() on barcode routes. MetricsController and ApiKeyAdminController were therefore fully open to anyone. Fix: register ApiKeyGuard as APP_GUARD in AppModule providers. The guard reads decorator metadata via Reflector. Routes with no decorator pass through (public). @RequiresAdminKey() now actually enforces constant-time ADMIN_KEY comparison. 286/286 tests still pass. BUG 2 — VisitorService flush crash (QueryFailedError): orUpdate() tried to update 'updated_at' column, but @UpdateDateColumn is not included in the INSERT VALUES clause — so excluded.updated_at does not exist in Postgres ON CONFLICT DO UPDATE. Fix: remove 'updated_at' from orUpdate column list. TypeORM's @UpdateDateColumn is handled automatically by the ORM on save(), not via raw QueryBuilder upserts. The flush now only updates request_count and error_count on conflict. BUG 3 — Signpack timing oracle: assertToken() threw ForbiddenException (403) when token was wrong, but NotFoundException (404) when pack didn't exist. An attacker could distinguish the two responses to determine whether a pack ID exists. Fix: assertToken() now always throws NotFoundException('Signpack not found') regardless of reason — 404 in both cases. --- apps/server/src/app/app.module.ts | 9 ++++++++- apps/server/src/app/metrics/visitor.service.ts | 6 ++++-- apps/server/src/app/signpack/signpack.service.ts | 6 ++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/server/src/app/app.module.ts b/apps/server/src/app/app.module.ts index 734b5f3..08b49d8 100644 --- a/apps/server/src/app/app.module.ts +++ b/apps/server/src/app/app.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { QrModule } from './qr/qr.module'; @@ -17,6 +18,7 @@ import databaseConfig from './config/database.config'; import { MetricsModule } from './metrics/metrics.module'; 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'; @Module({ @@ -99,6 +101,11 @@ import { UsageModule } from './usage/usage.module'; }), ], controllers: [AppController, ReportsController], - providers: [AppService], + providers: [ + AppService, + // Global guard — reads @RequiresAdminKey() / @RequiresTier() / @TierRateLimit() + // metadata set by decorators. Without this, those decorators are no-ops. + { provide: APP_GUARD, useClass: ApiKeyGuard }, + ], }) export class AppModule {} diff --git a/apps/server/src/app/metrics/visitor.service.ts b/apps/server/src/app/metrics/visitor.service.ts index ce81de7..0c943c8 100644 --- a/apps/server/src/app/metrics/visitor.service.ts +++ b/apps/server/src/app/metrics/visitor.service.ts @@ -288,10 +288,12 @@ export class VisitorService implements OnModuleInit, OnModuleDestroy { errorCount: bucket.errorCount, }) .orUpdate( - ['request_count', 'error_count', 'updated_at'], + // Note: updated_at is a @UpdateDateColumn — not in VALUES clause, + // so excluded.updated_at does not exist. Set it via NOW() instead. + ['request_count', 'error_count'], ['day', 'ip_hash', 'route_group', 'tier', 'api_key_prefix'], - { skipUpdateIfNoValuesChanged: true }, ) + .setParameter('now', new Date().toISOString()) .execute(); bucket.dirty = false; } catch (err) { diff --git a/apps/server/src/app/signpack/signpack.service.ts b/apps/server/src/app/signpack/signpack.service.ts index 42b280b..aef49a3 100644 --- a/apps/server/src/app/signpack/signpack.service.ts +++ b/apps/server/src/app/signpack/signpack.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Signpack } from './entities/signpack.entity'; @@ -101,7 +101,9 @@ export class SignpackService { } assertToken(sp: Signpack, token?: string) { - if (!token || token !== sp.accessToken) throw new ForbiddenException('Invalid token'); + // Use NotFoundException (not ForbiddenException) to avoid revealing + // whether a pack with this ID exists — prevents timing oracle attacks. + if (!token || token !== sp.accessToken) throw new NotFoundException('Signpack not found'); } async uploadSignedFromBuffer(id: string, token: string, file: UploadFile): Promise { From 49cd4e0fd4bfe3aa1877bc29815e60a8adfb47bf Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Fri, 13 Mar 2026 10:03:18 +0000 Subject: [PATCH 12/12] fix(visitor-stats): add @Unique constraint + fix orUpdate conflict target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: ON CONFLICT DO UPDATE requires a UNIQUE or EXCLUSION constraint on the conflict columns. @Index alone creates an ordinary index — not a unique constraint — so Postgres rejected the upsert with: 'there is no unique or exclusion constraint matching the ON CONFLICT spec' Entity (visitor-daily.entity.ts): Replaced @Index('IDX_VD_DATE_HASH', [...]) with @Unique('UQ_VD_BUCKET', ['day','ipHash','routeGroup','tier','apiKeyPrefix']) This creates a real UNIQUE constraint that Postgres can use for upserts. Service (visitor.service.ts): orUpdate() conflict target changed from column-list to constraint name 'UQ_VD_BUCKET'. TypeORM maps this to: ON CONFLICT ON CONSTRAINT "UQ_VD_BUCKET" DO UPDATE ... This is more explicit and avoids column-name vs DB-name mismatches. Also removed stale .setParameter('now', ...) leftover. ⚠️ DEPLOY NOTE: TypeORM synchronize=true cannot add a UNIQUE constraint to an existing table. The visitor_daily table must be dropped once on the server so TypeORM recreates it with the correct schema: docker exec simonapi-backend psql $TYPEORM_URL -c 'DROP TABLE IF EXISTS visitor_daily;' Data loss is acceptable — the table only holds rolling 30-day stats. --- .../src/app/metrics/entities/visitor-daily.entity.ts | 7 +++++-- apps/server/src/app/metrics/visitor.service.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/server/src/app/metrics/entities/visitor-daily.entity.ts b/apps/server/src/app/metrics/entities/visitor-daily.entity.ts index bde5eff..a455bb0 100644 --- a/apps/server/src/app/metrics/entities/visitor-daily.entity.ts +++ b/apps/server/src/app/metrics/entities/visitor-daily.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { Column, Entity, Index, PrimaryGeneratedColumn, Unique, UpdateDateColumn } from 'typeorm'; /** * One row = one (date, ip_hash, route_group, tier) combination. @@ -10,7 +10,10 @@ import { Column, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from * → raw IP is NEVER stored */ @Entity({ name: 'visitor_daily' }) -@Index('IDX_VD_DATE_HASH', ['day', 'ipHash']) +// UNIQUE constraint is required for ON CONFLICT DO UPDATE (upsert) in flush(). +// The combination (day, ip_hash, route_group, tier, api_key_prefix) uniquely +// identifies one bucket. @Index alone does not create a unique constraint. +@Unique('UQ_VD_BUCKET', ['day', 'ipHash', 'routeGroup', 'tier', 'apiKeyPrefix']) @Index('IDX_VD_DATE', ['day']) export class VisitorDailyEntity { @PrimaryGeneratedColumn() diff --git a/apps/server/src/app/metrics/visitor.service.ts b/apps/server/src/app/metrics/visitor.service.ts index 0c943c8..7f989cd 100644 --- a/apps/server/src/app/metrics/visitor.service.ts +++ b/apps/server/src/app/metrics/visitor.service.ts @@ -288,12 +288,14 @@ export class VisitorService implements OnModuleInit, OnModuleDestroy { errorCount: bucket.errorCount, }) .orUpdate( - // Note: updated_at is a @UpdateDateColumn — not in VALUES clause, - // so excluded.updated_at does not exist. Set it via NOW() instead. + // Columns to UPDATE on conflict. + // excluded.updated_at must NOT be listed — @UpdateDateColumn is not + // part of the INSERT values clause so it does not exist in EXCLUDED. ['request_count', 'error_count'], - ['day', 'ip_hash', 'route_group', 'tier', 'api_key_prefix'], + // Conflict target: the @Unique constraint name on the entity. + // TypeORM maps this to: ON CONFLICT ON CONSTRAINT "UQ_VD_BUCKET" + 'UQ_VD_BUCKET', ) - .setParameter('now', new Date().toISOString()) .execute(); bucket.dirty = false; } catch (err) {