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/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/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/app/metrics/entities/visitor-daily.entity.ts b/apps/server/src/app/metrics/entities/visitor-daily.entity.ts new file mode 100644 index 0000000..a455bb0 --- /dev/null +++ b/apps/server/src/app/metrics/entities/visitor-daily.entity.ts @@ -0,0 +1,54 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn, Unique, 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' }) +// 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() + 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; +} 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); + } } 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 {} 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..7f989cd --- /dev/null +++ b/apps/server/src/app/metrics/visitor.service.ts @@ -0,0 +1,321 @@ +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( + // 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'], + // Conflict target: the @Unique constraint name on the entity. + // TypeORM maps this to: ON CONFLICT ON CONSTRAINT "UQ_VD_BUCKET" + 'UQ_VD_BUCKET', + ) + .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`); + } + } +} 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 { 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/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/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 4cf9da3..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,157 +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/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..eacfb0c --- /dev/null +++ b/apps/simonapi/src/app/features/stats/visitors/visitors.component.html @@ -0,0 +1,170 @@ + + + +@if (!statsService.apiKey()) { +
+
🔒
+

Enter your admin key above to view visitor statistics.

+
+} @else { +
+
+ + @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..9b4ebbf --- /dev/null +++ b/apps/simonapi/src/app/features/stats/visitors/visitors.component.ts @@ -0,0 +1,99 @@ +import { Component, inject, OnInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AdminNavComponent } from '../admin-nav.component'; +import { + StatsService, + VisitorSummary, + VisitorDailyPoint, + VisitorByApi, + VisitorByCountry, +} from '../stats.service'; + +@Component({ + selector: 'app-visitors', + standalone: true, + 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([]); + byApi = signal([]); + byCountry = signal([]); + + loading = signal(false); + errorMsg = signal(null); + + ngOnInit(): void { + if (this.statsService.apiKey()) { + 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; +} diff --git a/docker-compose.yml b/docker-compose.yml index 17ed477..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 + - ./.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" 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 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" } }