diff --git a/changelog.md b/changelog.md index cb9ef3f..025619a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-02-03 - 4.0.0 - BREAKING CHANGE(config) +convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing + +- Removed server-side 'updateConfiguration' TypedHandler and the private updateConfiguration() method; getConfiguration remains as a read-only handler. +- Removed IReq_UpdateConfiguration interface from request typings; IReq_GetConfiguration marked as read-only. +- Removed client-side editing functionality: ops-view-config editing state and methods, Edit/Save/Cancel buttons, and updateConfigurationAction; ops-view-config enhanced to display read-only configuration (badges for booleans, array pills, icons, formatted numbers/bytes, empty states, etc.). +- Tests updated: replaced configuration update tests with verifyIdentity tests and added a read-only configuration access test. +- Documentation updated to reflect configuration is read-only (readme.md, ts_web/readme.md, ts_interfaces/readme.md, readme.hints.md). +- Dependencies adjusted: bumped @push.rocks/smartdata to ^7.0.15 and added @push.rocks/smartmongo ^5.1.0; ts/plugins updated to import/export smartmongo. + ## 2026-02-02 - 3.1.0 - feat(web) determine initial UI view from URL and wire selected view to appdash; add interface and web README files; bump various dependencies diff --git a/package.json b/package.json index 333224f..09e7502 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@push.rocks/projectinfo": "^5.0.2", "@push.rocks/qenv": "^6.1.3", "@push.rocks/smartacme": "^8.0.0", - "@push.rocks/smartdata": "^5.16.7", + "@push.rocks/smartdata": "^7.0.15", "@push.rocks/smartdns": "^7.6.1", "@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartguard": "^3.1.0", @@ -44,6 +44,7 @@ "@push.rocks/smartlog": "^3.1.10", "@push.rocks/smartmail": "^2.2.0", "@push.rocks/smartmetrics": "^2.0.10", + "@push.rocks/smartmongo": "^5.1.0", "@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpromise": "^4.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94c14b9..70bb6ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,8 +39,8 @@ importers: specifier: ^8.0.0 version: 8.0.0(socks@2.8.7) '@push.rocks/smartdata': - specifier: ^5.16.7 - version: 5.16.7(socks@2.8.7) + specifier: ^7.0.15 + version: 7.0.15(socks@2.8.7) '@push.rocks/smartdns': specifier: ^7.6.1 version: 7.6.1 @@ -62,6 +62,9 @@ importers: '@push.rocks/smartmetrics': specifier: ^2.0.10 version: 2.0.10 + '@push.rocks/smartmongo': + specifier: ^5.1.0 + version: 5.1.0(socks@2.8.7) '@push.rocks/smartnetwork': specifier: ^4.4.0 version: 4.4.0 @@ -934,6 +937,9 @@ packages: '@push.rocks/smartdata@5.16.7': resolution: {integrity: sha512-bu/YSIjQcwxWXkAsuhqE6zs7eT+bTIKV8+/H7TbbjpzeioLCyB3dZ/41cLZk37c/EYt4d4GHgZ0ww80OiKOUMg==} + '@push.rocks/smartdata@7.0.15': + resolution: {integrity: sha512-j09BUekmjiGZuvXmdGBiIpBTXFFnxrzG4rOBjZvPO/hG1BwNrvSkIVq20mIwdYomn8JGgya6oJ4Y7NL+FKTqEA==} + '@push.rocks/smartdelay@3.0.5': resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==} @@ -1033,6 +1039,9 @@ packages: '@push.rocks/smartmongo@2.2.0': resolution: {integrity: sha512-ovVCNoJ3D0aBuKtoKaQWWQKvBngaGJq9fAPQigzji1EHsS1XyGpXWCpe5nq/ptGvBROOcpqZcOFEGAcrnb+OjA==} + '@push.rocks/smartmongo@5.1.0': + resolution: {integrity: sha512-2tpKf8K+SMdLHOEpafgKPIN+ypWTLwHc33hCUDNMQ1KaL7vokkavA44+fHxQydOGPMtDi22tSMFeVMCcUSzs4w==} + '@push.rocks/smartmustache@3.0.2': resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==} @@ -1958,6 +1967,9 @@ packages: '@types/whatwg-url@11.0.5': resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} + '@types/whatwg-url@13.0.0': + resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==} + '@types/which@3.0.4': resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==} @@ -2145,6 +2157,10 @@ packages: resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} engines: {node: '>=16.20.1'} + bson@7.1.1: + resolution: {integrity: sha512-TtJgBB+QyOlWjrbM+8bRgH84VM/xrDjyBFgSgGrfZF4xvt6gbEDtcswm27Tn9F9TWsjQybxT8b8VpCP/oJK4Dw==} + engines: {node: '>=20.19.0'} + buffer-crc32@0.2.13: resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=} @@ -3343,6 +3359,10 @@ packages: mongodb-connection-string-url@3.0.2: resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==} + mongodb-connection-string-url@7.0.1: + resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} + engines: {node: '>=20.19.0'} + mongodb-memory-server-core@10.4.3: resolution: {integrity: sha512-IPjlw73IoSYopnqBibQKxmAXMbOEPf5uGAOsBcaUiNH/TOI7V19WO+K7n5KYtnQ9FqzLGLpvwCGuPOTBSg4s5Q==} engines: {node: '>=16.20.1'} @@ -3378,6 +3398,33 @@ packages: socks: optional: true + mongodb@7.0.0: + resolution: {integrity: sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.806.0 + '@mongodb-js/zstd': ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: '>=7.0.0 <7.1.0' + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -6042,6 +6089,35 @@ snapshots: - supports-color - vue + '@push.rocks/smartdata@7.0.15(socks@2.8.7)': + dependencies: + '@push.rocks/lik': 6.2.2 + '@push.rocks/smartdelay': 3.0.5 + '@push.rocks/smartlog': 3.1.10 + '@push.rocks/smartmongo': 2.2.0(socks@2.8.7) + '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartrx': 3.0.10 + '@push.rocks/smartstring': 4.1.0 + '@push.rocks/smarttime': 4.1.1 + '@push.rocks/smartunique': 3.0.9 + '@push.rocks/taskbuffer': 3.5.0 + '@tsclass/tsclass': 9.3.0 + mongodb: 7.0.0(socks@2.8.7) + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - '@nuxt/kit' + - bare-abort-controller + - gcp-metadata + - kerberos + - mongodb-client-encryption + - react + - react-native-b4a + - snappy + - socks + - supports-color + - vue + '@push.rocks/smartdelay@3.0.5': dependencies: '@push.rocks/smartpromise': 4.2.3 @@ -6319,6 +6395,32 @@ snapshots: - supports-color - vue + '@push.rocks/smartmongo@5.1.0(socks@2.8.7)': + dependencies: + '@push.rocks/mongodump': 1.1.0(socks@2.8.7) + '@push.rocks/smartdata': 5.16.7(socks@2.8.7) + '@push.rocks/smartfs': 1.3.1 + '@push.rocks/smartpath': 5.1.0 + '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartrx': 3.0.10 + bson: 6.10.4 + mingo: 7.2.0 + mongodb-memory-server: 10.4.3(socks@2.8.7) + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - '@nuxt/kit' + - bare-abort-controller + - gcp-metadata + - kerberos + - mongodb-client-encryption + - react + - react-native-b4a + - snappy + - socks + - supports-color + - vue + '@push.rocks/smartmustache@3.0.2': dependencies: handlebars: 4.7.8 @@ -7609,6 +7711,10 @@ snapshots: dependencies: '@types/webidl-conversions': 7.0.3 + '@types/whatwg-url@13.0.0': + dependencies: + '@types/webidl-conversions': 7.0.3 + '@types/which@3.0.4': {} '@types/wrap-ansi@3.0.0': {} @@ -7806,6 +7912,8 @@ snapshots: bson@6.10.4: {} + bson@7.1.1: {} + buffer-crc32@0.2.13: {} buffer-equal-constant-time@1.0.1: {} @@ -9313,6 +9421,11 @@ snapshots: '@types/whatwg-url': 11.0.5 whatwg-url: 14.2.0 + mongodb-connection-string-url@7.0.1: + dependencies: + '@types/whatwg-url': 13.0.0 + whatwg-url: 14.2.0 + mongodb-memory-server-core@10.4.3(socks@2.8.7): dependencies: async-mutex: 0.5.0 @@ -9363,6 +9476,14 @@ snapshots: optionalDependencies: socks: 2.8.7 + mongodb@7.0.0(socks@2.8.7): + dependencies: + '@mongodb-js/saslprep': 1.4.5 + bson: 7.1.1 + mongodb-connection-string-url: 7.0.1 + optionalDependencies: + socks: 2.8.7 + ms@2.1.3: {} mute-stream@1.0.0: {} diff --git a/readme.hints.md b/readme.hints.md index 506f299..52a2e3b 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -1264,4 +1264,56 @@ Login state was using `'soft'` mode in Smartstate which is memory-only: 2. `ts/opsserver/handlers/admin.handler.ts`: JWT expiry changed from 7 days to 24 hours 3. `ts_web/elements/ops-dashboard.ts`: Added JWT expiry check on session restore - Validates stored JWT hasn't expired before auto-logging in - - Clears expired sessions and shows login form \ No newline at end of file + - Clears expired sessions and shows login form + +## Config UI Read-Only Conversion (2026-02-03) + +### Overview +The configuration UI has been converted from an editable interface to a read-only display. DcRouter is configured through code or remotely, not through the UI. + +### Changes Made + +1. **Backend (`ts/opsserver/handlers/config.handler.ts`)**: + - Removed `updateConfiguration` handler + - Removed `updateConfiguration()` private method + - Kept `getConfiguration` handler (read-only) + +2. **Interfaces (`ts_interfaces/requests/config.ts`)**: + - Removed `IReq_UpdateConfiguration` interface + - Kept `IReq_GetConfiguration` interface + +3. **Frontend (`ts_web/elements/ops-view-config.ts`)**: + - Removed `editingSection` and `editedConfig` state properties + - Removed `startEdit()`, `cancelEdit()`, `saveConfig()` methods + - Removed Edit/Save/Cancel buttons + - Removed warning banner about immediate changes + - Enhanced read-only display with: + - Status badges for boolean values (enabled/disabled) + - Array display as pills/tags with counts + - Section icons (mail, globe, network, shield) + - Better formatting for numbers and byte sizes + - Empty state handling ("Not configured", "None configured") + - Info note explaining configuration is read-only + +4. **State Management (`ts_web/appstate.ts`)**: + - Removed `updateConfigurationAction` + - Kept `fetchConfigurationAction` (read-only) + +5. **Tests (`test/test.protected-endpoint.ts`)**: + - Replaced `updateConfiguration` tests with `verifyIdentity` tests + - Added test for read-only config access + - Kept auth flow testing with different protected endpoint + +6. **Documentation**: + - `readme.md`: Updated API endpoints to show config as read-only + - `ts_web/readme.md`: Removed `updateConfigurationAction` from actions list + - `ts_interfaces/readme.md`: Removed `IReq_UpdateConfiguration` from table + +### Visual Display Features +- Boolean values shown as colored badges (green=enabled, red=disabled) +- Arrays displayed as pills with count summaries +- Section headers with relevant Lucide icons +- Numbers formatted with locale separators +- Byte sizes auto-formatted (B, KB, MB, GB) +- Time values shown with "seconds" suffix +- Nested objects with visual indentation \ No newline at end of file diff --git a/readme.md b/readme.md index 82cf5e8..35ceeef 100644 --- a/readme.md +++ b/readme.md @@ -933,7 +933,7 @@ The OpsServer provides a web-based management interface: ### Features - **Real-time Statistics**: View connections, email throughput, DNS queries, RADIUS sessions -- **Configuration Management**: Update routes and settings via API +- **Configuration Display**: View current configuration (read-only) - **Log Viewer**: Access system logs with filtering - **Security Dashboard**: Monitor threats and blocked connections @@ -948,9 +948,8 @@ POST /typedrequest { method: 'getHealthStatus' } // Server statistics POST /typedrequest { method: 'getServerStatistics' } -// Configuration +// Configuration (read-only) POST /typedrequest { method: 'getConfiguration' } -POST /typedrequest { method: 'updateConfiguration', data: { ... } } // Logs POST /typedrequest { method: 'getLogs', data: { level: 'info', limit: 100 } } diff --git a/test/test.protected-endpoint.ts b/test/test.protected-endpoint.ts index b8db665..ae3cff2 100644 --- a/test/test.protected-endpoint.ts +++ b/test/test.protected-endpoint.ts @@ -10,7 +10,7 @@ tap.test('should start DCRouter with OpsServer', async () => { testDcRouter = new DcRouter({ // Minimal config for testing }); - + await testDcRouter.start(); expect(testDcRouter.opsServer).toBeInstanceOf(Object); }); @@ -20,49 +20,40 @@ tap.test('should login as admin', async () => { 'http://localhost:3000/typedrequest', 'adminLoginWithUsernameAndPassword' ); - + const response = await loginRequest.fire({ username: 'admin', password: 'admin' }); - + expect(response).toHaveProperty('identity'); adminIdentity = response.identity; console.log('Admin logged in with JWT'); }); -tap.test('should allow admin to update configuration', async () => { - const updateRequest = new TypedRequest( +tap.test('should allow admin to verify identity', async () => { + const verifyRequest = new TypedRequest( 'http://localhost:3000/typedrequest', - 'updateConfiguration' + 'verifyIdentity' ); - - const response = await updateRequest.fire({ + + const response = await verifyRequest.fire({ identity: adminIdentity, - section: 'security', - config: { - rateLimit: true, - spamDetection: true - } }); - - expect(response).toHaveProperty('updated'); - expect(response.updated).toBeTrue(); + + expect(response).toHaveProperty('valid'); + expect(response.valid).toBeTrue(); + console.log('Admin identity verified successfully'); }); -tap.test('should reject configuration update without identity', async () => { - const updateRequest = new TypedRequest( +tap.test('should reject verify identity without identity', async () => { + const verifyRequest = new TypedRequest( 'http://localhost:3000/typedrequest', - 'updateConfiguration' + 'verifyIdentity' ); - + try { - await updateRequest.fire({ - section: 'security', - config: { - rateLimit: false - } - }); + await verifyRequest.fire({} as any); expect(true).toBeFalse(); // Should not reach here } catch (error) { expect(error).toBeTruthy(); @@ -70,22 +61,18 @@ tap.test('should reject configuration update without identity', async () => { } }); -tap.test('should reject configuration update with invalid JWT', async () => { - const updateRequest = new TypedRequest( +tap.test('should reject verify identity with invalid JWT', async () => { + const verifyRequest = new TypedRequest( 'http://localhost:3000/typedrequest', - 'updateConfiguration' + 'verifyIdentity' ); - + try { - await updateRequest.fire({ + await verifyRequest.fire({ identity: { ...adminIdentity, jwt: 'invalid.jwt.token' }, - section: 'security', - config: { - rateLimit: false - } }); expect(true).toBeFalse(); // Should not reach here } catch (error) { @@ -99,17 +86,34 @@ tap.test('should allow access to public endpoints without auth', async () => { 'http://localhost:3000/typedrequest', 'getHealthStatus' ); - + // No identity provided const response = await healthRequest.fire({}); - + expect(response).toHaveProperty('health'); expect(response.health.healthy).toBeTrue(); console.log('Public endpoint accessible without auth'); }); +tap.test('should allow read-only config access', async () => { + const configRequest = new TypedRequest( + 'http://localhost:3000/typedrequest', + 'getConfiguration' + ); + + // Config is read-only and doesn't require auth + const response = await configRequest.fire({}); + + expect(response).toHaveProperty('config'); + expect(response.config).toHaveProperty('email'); + expect(response.config).toHaveProperty('dns'); + expect(response.config).toHaveProperty('proxy'); + expect(response.config).toHaveProperty('security'); + console.log('Configuration read successfully'); +}); + tap.test('should stop DCRouter', async () => { await testDcRouter.stop(); }); -export default tap.start(); \ No newline at end of file +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index f81f33c..4640a75 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '3.1.0', + version: '4.0.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/cache/classes.cache.cleaner.ts b/ts/cache/classes.cache.cleaner.ts new file mode 100644 index 0000000..4e17b52 --- /dev/null +++ b/ts/cache/classes.cache.cleaner.ts @@ -0,0 +1,170 @@ +import * as plugins from '../plugins.js'; +import { logger } from '../logger.js'; +import { CacheDb } from './classes.cachedb.js'; + +// Import document classes for cleanup +import { CachedEmail } from './documents/classes.cached.email.js'; +import { CachedIPReputation } from './documents/classes.cached.ip.reputation.js'; +import { CachedBounce } from './documents/classes.cached.bounce.js'; +import { CachedSuppression } from './documents/classes.cached.suppression.js'; +import { CachedDKIMKey } from './documents/classes.cached.dkim.js'; + +/** + * Configuration for the cache cleaner + */ +export interface ICacheCleanerOptions { + /** Cleanup interval in milliseconds (default: 1 hour) */ + intervalMs?: number; + /** Enable verbose logging */ + verbose?: boolean; +} + +/** + * CacheCleaner - Periodically removes expired documents from the cache + * + * Runs on a configurable interval (default: hourly) and queries each + * collection for documents where expiresAt < now(), then deletes them. + */ +export class CacheCleaner { + private cleanupInterval: ReturnType | null = null; + private isRunning: boolean = false; + private options: Required; + private cacheDb: CacheDb; + + constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) { + this.cacheDb = cacheDb; + this.options = { + intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default + verbose: options.verbose || false, + }; + } + + /** + * Start the periodic cleanup process + */ + public start(): void { + if (this.isRunning) { + logger.log('warn', 'CacheCleaner already running'); + return; + } + + this.isRunning = true; + + // Run cleanup immediately on start + this.runCleanup().catch((error) => { + logger.log('error', `Initial cache cleanup failed: ${error.message}`); + }); + + // Schedule periodic cleanup + this.cleanupInterval = setInterval(() => { + this.runCleanup().catch((error) => { + logger.log('error', `Cache cleanup failed: ${error.message}`); + }); + }, this.options.intervalMs); + + logger.log( + 'info', + `CacheCleaner started with interval: ${this.options.intervalMs / 1000 / 60} minutes` + ); + } + + /** + * Stop the periodic cleanup process + */ + public stop(): void { + if (!this.isRunning) { + return; + } + + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + this.isRunning = false; + logger.log('info', 'CacheCleaner stopped'); + } + + /** + * Run a single cleanup cycle + */ + public async runCleanup(): Promise { + if (!this.cacheDb.isReady()) { + logger.log('warn', 'CacheDb not ready, skipping cleanup'); + return; + } + + const now = new Date(); + const results: { collection: string; deleted: number }[] = []; + + try { + // Clean CachedEmail documents + const emailsDeleted = await this.cleanCollection(CachedEmail, now); + results.push({ collection: 'CachedEmail', deleted: emailsDeleted }); + + // Clean CachedIPReputation documents + const ipReputationDeleted = await this.cleanCollection(CachedIPReputation, now); + results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted }); + + // Clean CachedBounce documents + const bouncesDeleted = await this.cleanCollection(CachedBounce, now); + results.push({ collection: 'CachedBounce', deleted: bouncesDeleted }); + + // Clean CachedSuppression documents (but not permanent ones) + const suppressionDeleted = await this.cleanCollection(CachedSuppression, now); + results.push({ collection: 'CachedSuppression', deleted: suppressionDeleted }); + + // Clean CachedDKIMKey documents + const dkimDeleted = await this.cleanCollection(CachedDKIMKey, now); + results.push({ collection: 'CachedDKIMKey', deleted: dkimDeleted }); + + // Log results + const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0); + if (totalDeleted > 0 || this.options.verbose) { + const summary = results + .filter((r) => r.deleted > 0) + .map((r) => `${r.collection}: ${r.deleted}`) + .join(', '); + logger.log( + 'info', + `Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}` + ); + } + } catch (error) { + logger.log('error', `Cache cleanup error: ${error.message}`); + throw error; + } + } + + /** + * Clean expired documents from a specific collection + */ + private async cleanCollection( + documentClass: { deleteMany: (filter: any) => Promise }, + now: Date + ): Promise { + try { + const result = await documentClass.deleteMany({ + expiresAt: { $lt: now }, + }); + return result?.deletedCount || 0; + } catch (error) { + logger.log('error', `Error cleaning collection: ${error.message}`); + return 0; + } + } + + /** + * Check if the cleaner is running + */ + public isActive(): boolean { + return this.isRunning; + } + + /** + * Get the cleanup interval in milliseconds + */ + public getIntervalMs(): number { + return this.options.intervalMs; + } +} diff --git a/ts/cache/classes.cached.document.ts b/ts/cache/classes.cached.document.ts new file mode 100644 index 0000000..b7a434c --- /dev/null +++ b/ts/cache/classes.cached.document.ts @@ -0,0 +1,108 @@ +import * as plugins from '../plugins.js'; + +/** + * Base class for all cached documents with TTL support + * + * Extends smartdata's SmartDataDbDoc to add: + * - Automatic timestamps (createdAt, lastAccessedAt) + * - TTL/expiration support (expiresAt) + * - Helper methods for TTL management + */ +export abstract class CachedDocument> extends plugins.smartdata.SmartDataDbDoc { + /** + * Timestamp when the document was created + */ + @plugins.smartdata.svDb() + public createdAt: Date = new Date(); + + /** + * Timestamp when the document expires and should be cleaned up + */ + @plugins.smartdata.svDb() + public expiresAt: Date; + + /** + * Timestamp of last access (for LRU-style eviction if needed) + */ + @plugins.smartdata.svDb() + public lastAccessedAt: Date = new Date(); + + /** + * Set the TTL (time to live) for this document + * @param ttlMs Time to live in milliseconds + */ + public setTTL(ttlMs: number): void { + this.expiresAt = new Date(Date.now() + ttlMs); + } + + /** + * Set TTL using days + * @param days Number of days until expiration + */ + public setTTLDays(days: number): void { + this.setTTL(days * 24 * 60 * 60 * 1000); + } + + /** + * Set TTL using hours + * @param hours Number of hours until expiration + */ + public setTTLHours(hours: number): void { + this.setTTL(hours * 60 * 60 * 1000); + } + + /** + * Check if this document has expired + */ + public isExpired(): boolean { + if (!this.expiresAt) { + return false; // No expiration set + } + return new Date() > this.expiresAt; + } + + /** + * Update the lastAccessedAt timestamp + */ + public touch(): void { + this.lastAccessedAt = new Date(); + } + + /** + * Get remaining TTL in milliseconds + * Returns 0 if expired, -1 if no expiration set + */ + public getRemainingTTL(): number { + if (!this.expiresAt) { + return -1; + } + const remaining = this.expiresAt.getTime() - Date.now(); + return remaining > 0 ? remaining : 0; + } + + /** + * Extend the TTL by the specified milliseconds from now + * @param ttlMs Additional time to live in milliseconds + */ + public extendTTL(ttlMs: number): void { + this.expiresAt = new Date(Date.now() + ttlMs); + } + + /** + * Set the document to never expire (100 years in the future) + */ + public setNeverExpires(): void { + this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000); + } +} + +/** + * TTL constants in milliseconds + */ +export const TTL = { + HOURS_1: 1 * 60 * 60 * 1000, + HOURS_24: 24 * 60 * 60 * 1000, + DAYS_7: 7 * 24 * 60 * 60 * 1000, + DAYS_30: 30 * 24 * 60 * 60 * 1000, + DAYS_90: 90 * 24 * 60 * 60 * 1000, +} as const; diff --git a/ts/cache/classes.cachedb.ts b/ts/cache/classes.cachedb.ts new file mode 100644 index 0000000..6f3e672 --- /dev/null +++ b/ts/cache/classes.cachedb.ts @@ -0,0 +1,152 @@ +import * as plugins from '../plugins.js'; +import { logger } from '../logger.js'; + +/** + * Configuration options for CacheDb + */ +export interface ICacheDbOptions { + /** Base storage path for TsmDB data (default: /etc/dcrouter/tsmdb) */ + storagePath?: string; + /** Database name (default: dcrouter) */ + dbName?: string; + /** Enable debug logging */ + debug?: boolean; +} + +/** + * CacheDb - Wrapper around LocalTsmDb and smartdata + * + * Provides persistent caching using smartdata as the ORM layer + * and LocalTsmDb as the embedded database engine. + */ +export class CacheDb { + private static instance: CacheDb | null = null; + + private localTsmDb: plugins.smartmongo.LocalTsmDb; + private smartdataDb: plugins.smartdata.SmartdataDb; + private options: Required; + private isStarted: boolean = false; + + constructor(options: ICacheDbOptions = {}) { + this.options = { + storagePath: options.storagePath || '/etc/dcrouter/tsmdb', + dbName: options.dbName || 'dcrouter', + debug: options.debug || false, + }; + } + + /** + * Get or create the singleton instance + */ + public static getInstance(options?: ICacheDbOptions): CacheDb { + if (!CacheDb.instance) { + CacheDb.instance = new CacheDb(options); + } + return CacheDb.instance; + } + + /** + * Reset the singleton instance (useful for testing) + */ + public static resetInstance(): void { + CacheDb.instance = null; + } + + /** + * Start the cache database + * - Initializes LocalTsmDb with file persistence + * - Connects smartdata to the LocalTsmDb via Unix socket + */ + public async start(): Promise { + if (this.isStarted) { + logger.log('warn', 'CacheDb already started'); + return; + } + + try { + // Ensure storage directory exists + await plugins.fsUtils.ensureDir(this.options.storagePath); + + // Create LocalTsmDb instance + this.localTsmDb = new plugins.smartmongo.LocalTsmDb({ + dbDir: this.options.storagePath, + }); + + // Start LocalTsmDb and get connection URI + await this.localTsmDb.start(); + const mongoDescriptor = this.localTsmDb.mongoDescriptor; + + if (this.options.debug) { + logger.log('debug', `LocalTsmDb started with descriptor: ${JSON.stringify(mongoDescriptor)}`); + } + + // Initialize smartdata with the connection + this.smartdataDb = new plugins.smartdata.SmartdataDb(mongoDescriptor); + await this.smartdataDb.init(); + + this.isStarted = true; + logger.log('info', `CacheDb started at ${this.options.storagePath}`); + } catch (error) { + logger.log('error', `Failed to start CacheDb: ${error.message}`); + throw error; + } + } + + /** + * Stop the cache database + */ + public async stop(): Promise { + if (!this.isStarted) { + return; + } + + try { + // Close smartdata connection + if (this.smartdataDb) { + await this.smartdataDb.close(); + } + + // Stop LocalTsmDb + if (this.localTsmDb) { + await this.localTsmDb.stop(); + } + + this.isStarted = false; + logger.log('info', 'CacheDb stopped'); + } catch (error) { + logger.log('error', `Error stopping CacheDb: ${error.message}`); + throw error; + } + } + + /** + * Get the smartdata database instance + */ + public getDb(): plugins.smartdata.SmartdataDb { + if (!this.isStarted) { + throw new Error('CacheDb not started. Call start() first.'); + } + return this.smartdataDb; + } + + /** + * Check if the database is ready + */ + public isReady(): boolean { + return this.isStarted; + } + + /** + * Get the storage path + */ + public getStoragePath(): string { + return this.options.storagePath; + } + + /** + * Get the database name + */ + public getDbName(): string { + return this.options.dbName; + } +} diff --git a/ts/cache/documents/classes.cached.bounce.ts b/ts/cache/documents/classes.cached.bounce.ts new file mode 100644 index 0000000..bdcf785 --- /dev/null +++ b/ts/cache/documents/classes.cached.bounce.ts @@ -0,0 +1,244 @@ +import * as plugins from '../../plugins.js'; +import { CachedDocument, TTL } from '../classes.cached.document.js'; +import { CacheDb } from '../classes.cachedb.js'; + +/** + * Helper to get the smartdata database instance + */ +const getDb = () => CacheDb.getInstance().getDb(); + +/** + * Bounce type classification + */ +export type TBounceType = 'hard' | 'soft' | 'complaint' | 'unknown'; + +/** + * Bounce category for detailed classification + */ +export type TBounceCategory = + | 'invalid-recipient' + | 'mailbox-full' + | 'domain-not-found' + | 'connection-failed' + | 'policy-rejection' + | 'spam-rejection' + | 'rate-limited' + | 'other'; + +/** + * CachedBounce - Stores email bounce records + * + * Tracks bounce events for emails to help with deliverability + * analysis and suppression list management. + */ +@plugins.smartdata.Collection(() => getDb()) +export class CachedBounce extends CachedDocument { + /** + * Unique identifier for this bounce record + */ + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public id: string; + + /** + * Email address that bounced + */ + @plugins.smartdata.svDb() + public recipient: string; + + /** + * Sender email address + */ + @plugins.smartdata.svDb() + public sender: string; + + /** + * Recipient domain + */ + @plugins.smartdata.svDb() + public domain: string; + + /** + * Type of bounce (hard/soft/complaint) + */ + @plugins.smartdata.svDb() + public bounceType: TBounceType; + + /** + * Detailed bounce category + */ + @plugins.smartdata.svDb() + public bounceCategory: TBounceCategory; + + /** + * SMTP response code + */ + @plugins.smartdata.svDb() + public smtpCode: number; + + /** + * Full SMTP response message + */ + @plugins.smartdata.svDb() + public smtpResponse: string; + + /** + * Diagnostic code from DSN + */ + @plugins.smartdata.svDb() + public diagnosticCode: string; + + /** + * Original message ID that bounced + */ + @plugins.smartdata.svDb() + public originalMessageId: string; + + /** + * Number of bounces for this recipient + */ + @plugins.smartdata.svDb() + public bounceCount: number = 1; + + /** + * Timestamp of the first bounce + */ + @plugins.smartdata.svDb() + public firstBounceAt: Date; + + /** + * Timestamp of the most recent bounce + */ + @plugins.smartdata.svDb() + public lastBounceAt: Date; + + constructor() { + super(); + this.setTTL(TTL.DAYS_30); // Default 30-day TTL + this.bounceType = 'unknown'; + this.bounceCategory = 'other'; + this.firstBounceAt = new Date(); + this.lastBounceAt = new Date(); + } + + /** + * Create a new bounce record + */ + public static createNew(): CachedBounce { + const bounce = new CachedBounce(); + bounce.id = plugins.uuid.v4(); + return bounce; + } + + /** + * Find bounces by recipient email + */ + public static async findByRecipient(recipient: string): Promise { + return await CachedBounce.getInstances({ + recipient, + }); + } + + /** + * Find bounces by domain + */ + public static async findByDomain(domain: string): Promise { + return await CachedBounce.getInstances({ + domain, + }); + } + + /** + * Find all hard bounces + */ + public static async findHardBounces(): Promise { + return await CachedBounce.getInstances({ + bounceType: 'hard', + }); + } + + /** + * Find bounces by category + */ + public static async findByCategory(category: TBounceCategory): Promise { + return await CachedBounce.getInstances({ + bounceCategory: category, + }); + } + + /** + * Check if a recipient has recent hard bounces + */ + public static async hasRecentHardBounce(recipient: string): Promise { + const bounces = await CachedBounce.getInstances({ + recipient, + bounceType: 'hard', + }); + return bounces.length > 0; + } + + /** + * Record an additional bounce for the same recipient + */ + public recordAdditionalBounce(smtpCode?: number, smtpResponse?: string): void { + this.bounceCount++; + this.lastBounceAt = new Date(); + if (smtpCode) { + this.smtpCode = smtpCode; + } + if (smtpResponse) { + this.smtpResponse = smtpResponse; + } + this.touch(); + } + + /** + * Extract domain from recipient email + */ + public updateDomain(): void { + if (this.recipient) { + const match = this.recipient.match(/@([^>]+)>?$/); + if (match) { + this.domain = match[1].toLowerCase(); + } + } + } + + /** + * Classify bounce based on SMTP code + */ + public classifyFromSmtpCode(code: number): void { + this.smtpCode = code; + + // 5xx = permanent failure (hard bounce) + if (code >= 500 && code < 600) { + this.bounceType = 'hard'; + + if (code === 550) { + this.bounceCategory = 'invalid-recipient'; + } else if (code === 551) { + this.bounceCategory = 'policy-rejection'; + } else if (code === 552) { + this.bounceCategory = 'mailbox-full'; + } else if (code === 553) { + this.bounceCategory = 'invalid-recipient'; + } else if (code === 554) { + this.bounceCategory = 'spam-rejection'; + } + } + // 4xx = temporary failure (soft bounce) + else if (code >= 400 && code < 500) { + this.bounceType = 'soft'; + + if (code === 421) { + this.bounceCategory = 'rate-limited'; + } else if (code === 450) { + this.bounceCategory = 'mailbox-full'; + } else if (code === 451) { + this.bounceCategory = 'connection-failed'; + } else if (code === 452) { + this.bounceCategory = 'rate-limited'; + } + } + } +} diff --git a/ts/cache/documents/classes.cached.dkim.ts b/ts/cache/documents/classes.cached.dkim.ts new file mode 100644 index 0000000..7120b4d --- /dev/null +++ b/ts/cache/documents/classes.cached.dkim.ts @@ -0,0 +1,241 @@ +import * as plugins from '../../plugins.js'; +import { CachedDocument, TTL } from '../classes.cached.document.js'; +import { CacheDb } from '../classes.cachedb.js'; + +/** + * Helper to get the smartdata database instance + */ +const getDb = () => CacheDb.getInstance().getDb(); + +/** + * CachedDKIMKey - Stores DKIM key pairs for email signing + * + * Caches DKIM private/public key pairs per domain and selector. + * Default TTL is 90 days (typical key rotation interval). + */ +@plugins.smartdata.Collection(() => getDb()) +export class CachedDKIMKey extends CachedDocument { + /** + * Composite key: domain:selector + */ + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public domainSelector: string; + + /** + * Domain for this DKIM key + */ + @plugins.smartdata.svDb() + public domain: string; + + /** + * DKIM selector (e.g., 'mta', 'default', '2024') + */ + @plugins.smartdata.svDb() + public selector: string; + + /** + * Private key in PEM format + */ + @plugins.smartdata.svDb() + public privateKey: string; + + /** + * Public key in PEM format + */ + @plugins.smartdata.svDb() + public publicKey: string; + + /** + * Public key for DNS TXT record (base64, no headers) + */ + @plugins.smartdata.svDb() + public publicKeyDns: string; + + /** + * Key size in bits (e.g., 1024, 2048) + */ + @plugins.smartdata.svDb() + public keySize: number = 2048; + + /** + * Key algorithm (e.g., 'rsa-sha256') + */ + @plugins.smartdata.svDb() + public algorithm: string = 'rsa-sha256'; + + /** + * When the key was generated + */ + @plugins.smartdata.svDb() + public generatedAt: Date; + + /** + * When the key was last rotated + */ + @plugins.smartdata.svDb() + public rotatedAt: Date; + + /** + * Previous selector (for key rotation) + */ + @plugins.smartdata.svDb() + public previousSelector: string; + + /** + * Number of emails signed with this key + */ + @plugins.smartdata.svDb() + public signCount: number = 0; + + /** + * Whether this key is currently active + */ + @plugins.smartdata.svDb() + public isActive: boolean = true; + + constructor() { + super(); + this.setTTL(TTL.DAYS_90); // Default 90-day TTL + this.generatedAt = new Date(); + } + + /** + * Create the composite key from domain and selector + */ + public static createDomainSelector(domain: string, selector: string): string { + return `${domain.toLowerCase()}:${selector.toLowerCase()}`; + } + + /** + * Create a new DKIM key entry + */ + public static createNew(domain: string, selector: string): CachedDKIMKey { + const key = new CachedDKIMKey(); + key.domain = domain.toLowerCase(); + key.selector = selector.toLowerCase(); + key.domainSelector = CachedDKIMKey.createDomainSelector(domain, selector); + return key; + } + + /** + * Find by domain and selector + */ + public static async findByDomainSelector( + domain: string, + selector: string + ): Promise { + const domainSelector = CachedDKIMKey.createDomainSelector(domain, selector); + return await CachedDKIMKey.getInstance({ + domainSelector, + }); + } + + /** + * Find all keys for a domain + */ + public static async findByDomain(domain: string): Promise { + return await CachedDKIMKey.getInstances({ + domain: domain.toLowerCase(), + }); + } + + /** + * Find the active key for a domain + */ + public static async findActiveForDomain(domain: string): Promise { + const keys = await CachedDKIMKey.getInstances({ + domain: domain.toLowerCase(), + isActive: true, + }); + return keys.length > 0 ? keys[0] : null; + } + + /** + * Find all active keys + */ + public static async findAllActive(): Promise { + return await CachedDKIMKey.getInstances({ + isActive: true, + }); + } + + /** + * Set the key pair + */ + public setKeyPair(privateKey: string, publicKey: string, publicKeyDns?: string): void { + this.privateKey = privateKey; + this.publicKey = publicKey; + this.publicKeyDns = publicKeyDns || this.extractPublicKeyDns(publicKey); + this.generatedAt = new Date(); + } + + /** + * Extract the base64 public key for DNS from PEM format + */ + private extractPublicKeyDns(publicKeyPem: string): string { + // Remove PEM headers and newlines + return publicKeyPem + .replace(/-----BEGIN PUBLIC KEY-----/g, '') + .replace(/-----END PUBLIC KEY-----/g, '') + .replace(/\s/g, ''); + } + + /** + * Generate the DNS TXT record value + */ + public getDnsTxtRecord(): string { + return `v=DKIM1; k=rsa; p=${this.publicKeyDns}`; + } + + /** + * Get the full DNS record name + */ + public getDnsRecordName(): string { + return `${this.selector}._domainkey.${this.domain}`; + } + + /** + * Record that this key was used to sign an email + */ + public recordSign(): void { + this.signCount++; + this.touch(); + } + + /** + * Deactivate this key (e.g., during rotation) + */ + public deactivate(): void { + this.isActive = false; + } + + /** + * Activate this key + */ + public activate(): void { + this.isActive = true; + } + + /** + * Rotate to a new selector + */ + public rotate(newSelector: string): void { + this.previousSelector = this.selector; + this.selector = newSelector.toLowerCase(); + this.domainSelector = CachedDKIMKey.createDomainSelector(this.domain, this.selector); + this.rotatedAt = new Date(); + this.signCount = 0; + // Reset TTL on rotation + this.setTTL(TTL.DAYS_90); + } + + /** + * Check if key needs rotation (based on age or sign count) + */ + public needsRotation(maxAgeDays: number = 90, maxSignCount: number = 1000000): boolean { + const ageMs = Date.now() - this.generatedAt.getTime(); + const ageDays = ageMs / (24 * 60 * 60 * 1000); + return ageDays > maxAgeDays || this.signCount > maxSignCount; + } +} diff --git a/ts/cache/documents/classes.cached.email.ts b/ts/cache/documents/classes.cached.email.ts new file mode 100644 index 0000000..c33e33d --- /dev/null +++ b/ts/cache/documents/classes.cached.email.ts @@ -0,0 +1,230 @@ +import * as plugins from '../../plugins.js'; +import { CachedDocument, TTL } from '../classes.cached.document.js'; +import { CacheDb } from '../classes.cachedb.js'; + +/** + * Email status in the cache + */ +export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred'; + +/** + * Helper to get the smartdata database instance + */ +const getDb = () => CacheDb.getInstance().getDb(); + +/** + * CachedEmail - Stores email queue items in the cache + * + * Used for persistent email queue storage, tracking delivery status, + * and maintaining email history for the configured TTL period. + */ +@plugins.smartdata.Collection(() => getDb()) +export class CachedEmail extends CachedDocument { + /** + * Unique identifier for this email + */ + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public id: string; + + /** + * Email message ID (RFC 822 Message-ID header) + */ + @plugins.smartdata.svDb() + public messageId: string; + + /** + * Sender email address (envelope from) + */ + @plugins.smartdata.svDb() + public from: string; + + /** + * Recipient email addresses + */ + @plugins.smartdata.svDb() + public to: string[]; + + /** + * CC recipients + */ + @plugins.smartdata.svDb() + public cc: string[]; + + /** + * BCC recipients + */ + @plugins.smartdata.svDb() + public bcc: string[]; + + /** + * Email subject + */ + @plugins.smartdata.svDb() + public subject: string; + + /** + * Raw RFC822 email content + */ + @plugins.smartdata.svDb() + public rawContent: string; + + /** + * Current status of the email + */ + @plugins.smartdata.svDb() + public status: TCachedEmailStatus; + + /** + * Number of delivery attempts + */ + @plugins.smartdata.svDb() + public attempts: number = 0; + + /** + * Maximum number of delivery attempts + */ + @plugins.smartdata.svDb() + public maxAttempts: number = 3; + + /** + * Timestamp for next delivery attempt + */ + @plugins.smartdata.svDb() + public nextAttempt: Date; + + /** + * Last error message if delivery failed + */ + @plugins.smartdata.svDb() + public lastError: string; + + /** + * Timestamp when the email was successfully delivered + */ + @plugins.smartdata.svDb() + public deliveredAt: Date; + + /** + * Sender domain (for querying/filtering) + */ + @plugins.smartdata.svDb() + public senderDomain: string; + + /** + * Priority level (higher = more important) + */ + @plugins.smartdata.svDb() + public priority: number = 0; + + /** + * JSON-serialized route data + */ + @plugins.smartdata.svDb() + public routeData: string; + + /** + * DKIM signature status + */ + @plugins.smartdata.svDb() + public dkimSigned: boolean = false; + + constructor() { + super(); + this.setTTL(TTL.DAYS_30); // Default 30-day TTL + this.status = 'pending'; + this.to = []; + this.cc = []; + this.bcc = []; + } + + /** + * Create a new CachedEmail with a unique ID + */ + public static createNew(): CachedEmail { + const email = new CachedEmail(); + email.id = plugins.uuid.v4(); + return email; + } + + /** + * Find an email by ID + */ + public static async findById(id: string): Promise { + return await CachedEmail.getInstance({ + id, + }); + } + + /** + * Find all emails with a specific status + */ + public static async findByStatus(status: TCachedEmailStatus): Promise { + return await CachedEmail.getInstances({ + status, + }); + } + + /** + * Find all emails pending delivery (status = pending and nextAttempt <= now) + */ + public static async findPendingForDelivery(): Promise { + const now = new Date(); + return await CachedEmail.getInstances({ + status: 'pending', + nextAttempt: { $lte: now }, + }); + } + + /** + * Find emails by sender domain + */ + public static async findBySenderDomain(domain: string): Promise { + return await CachedEmail.getInstances({ + senderDomain: domain, + }); + } + + /** + * Mark as delivered + */ + public markDelivered(): void { + this.status = 'delivered'; + this.deliveredAt = new Date(); + } + + /** + * Mark as failed with error + */ + public markFailed(error: string): void { + this.status = 'failed'; + this.lastError = error; + } + + /** + * Increment attempt counter and schedule next attempt + */ + public scheduleRetry(delayMs: number = 5 * 60 * 1000): void { + this.attempts++; + this.status = 'deferred'; + this.nextAttempt = new Date(Date.now() + delayMs); + + // If max attempts reached, mark as failed + if (this.attempts >= this.maxAttempts) { + this.status = 'failed'; + this.lastError = `Max attempts (${this.maxAttempts}) reached`; + } + } + + /** + * Extract sender domain from email address + */ + public updateSenderDomain(): void { + if (this.from) { + const match = this.from.match(/@([^>]+)>?$/); + if (match) { + this.senderDomain = match[1].toLowerCase(); + } + } + } +} diff --git a/ts/cache/documents/classes.cached.ip.reputation.ts b/ts/cache/documents/classes.cached.ip.reputation.ts new file mode 100644 index 0000000..bcd28c3 --- /dev/null +++ b/ts/cache/documents/classes.cached.ip.reputation.ts @@ -0,0 +1,237 @@ +import * as plugins from '../../plugins.js'; +import { CachedDocument, TTL } from '../classes.cached.document.js'; +import { CacheDb } from '../classes.cachedb.js'; + +/** + * Helper to get the smartdata database instance + */ +const getDb = () => CacheDb.getInstance().getDb(); + +/** + * IP reputation result data + */ +export interface IIPReputationData { + score: number; + isSpam: boolean; + isProxy: boolean; + isTor: boolean; + isVPN: boolean; + country?: string; + asn?: string; + org?: string; + blacklists?: string[]; +} + +/** + * CachedIPReputation - Stores IP reputation lookup results + * + * Caches the results of IP reputation checks to avoid repeated + * external API calls. Default TTL is 24 hours. + */ +@plugins.smartdata.Collection(() => getDb()) +export class CachedIPReputation extends CachedDocument { + /** + * IP address (unique identifier) + */ + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public ipAddress: string; + + /** + * Reputation score (0-100, higher = better) + */ + @plugins.smartdata.svDb() + public score: number; + + /** + * Whether the IP is flagged as spam source + */ + @plugins.smartdata.svDb() + public isSpam: boolean; + + /** + * Whether the IP is a known proxy + */ + @plugins.smartdata.svDb() + public isProxy: boolean; + + /** + * Whether the IP is a Tor exit node + */ + @plugins.smartdata.svDb() + public isTor: boolean; + + /** + * Whether the IP is a VPN endpoint + */ + @plugins.smartdata.svDb() + public isVPN: boolean; + + /** + * Country code (ISO 3166-1 alpha-2) + */ + @plugins.smartdata.svDb() + public country: string; + + /** + * Autonomous System Number + */ + @plugins.smartdata.svDb() + public asn: string; + + /** + * Organization name + */ + @plugins.smartdata.svDb() + public org: string; + + /** + * List of blacklists the IP appears on + */ + @plugins.smartdata.svDb() + public blacklists: string[]; + + /** + * Number of times this IP has been checked + */ + @plugins.smartdata.svDb() + public checkCount: number = 0; + + /** + * Number of connections from this IP + */ + @plugins.smartdata.svDb() + public connectionCount: number = 0; + + /** + * Number of emails received from this IP + */ + @plugins.smartdata.svDb() + public emailCount: number = 0; + + /** + * Number of spam emails from this IP + */ + @plugins.smartdata.svDb() + public spamCount: number = 0; + + constructor() { + super(); + this.setTTL(TTL.HOURS_24); // Default 24-hour TTL + this.blacklists = []; + this.score = 50; // Default neutral score + this.isSpam = false; + this.isProxy = false; + this.isTor = false; + this.isVPN = false; + } + + /** + * Create from reputation data + */ + public static fromReputationData(ipAddress: string, data: IIPReputationData): CachedIPReputation { + const cached = new CachedIPReputation(); + cached.ipAddress = ipAddress; + cached.score = data.score; + cached.isSpam = data.isSpam; + cached.isProxy = data.isProxy; + cached.isTor = data.isTor; + cached.isVPN = data.isVPN; + cached.country = data.country || ''; + cached.asn = data.asn || ''; + cached.org = data.org || ''; + cached.blacklists = data.blacklists || []; + cached.checkCount = 1; + return cached; + } + + /** + * Convert to reputation data object + */ + public toReputationData(): IIPReputationData { + this.touch(); + return { + score: this.score, + isSpam: this.isSpam, + isProxy: this.isProxy, + isTor: this.isTor, + isVPN: this.isVPN, + country: this.country, + asn: this.asn, + org: this.org, + blacklists: this.blacklists, + }; + } + + /** + * Find by IP address + */ + public static async findByIP(ipAddress: string): Promise { + return await CachedIPReputation.getInstance({ + ipAddress, + }); + } + + /** + * Find all IPs flagged as spam + */ + public static async findSpamIPs(): Promise { + return await CachedIPReputation.getInstances({ + isSpam: true, + }); + } + + /** + * Find IPs with score below threshold + */ + public static async findLowScoreIPs(threshold: number): Promise { + return await CachedIPReputation.getInstances({ + score: { $lt: threshold }, + }); + } + + /** + * Record a connection from this IP + */ + public recordConnection(): void { + this.connectionCount++; + this.touch(); + } + + /** + * Record an email from this IP + */ + public recordEmail(isSpam: boolean = false): void { + this.emailCount++; + if (isSpam) { + this.spamCount++; + } + this.touch(); + } + + /** + * Update the reputation data + */ + public updateReputation(data: IIPReputationData): void { + this.score = data.score; + this.isSpam = data.isSpam; + this.isProxy = data.isProxy; + this.isTor = data.isTor; + this.isVPN = data.isVPN; + this.country = data.country || this.country; + this.asn = data.asn || this.asn; + this.org = data.org || this.org; + this.blacklists = data.blacklists || this.blacklists; + this.checkCount++; + this.touch(); + // Refresh TTL on update + this.setTTL(TTL.HOURS_24); + } + + /** + * Check if this IP should be blocked + */ + public shouldBlock(): boolean { + return this.isSpam || this.score < 20 || this.blacklists.length > 2; + } +} diff --git a/ts/cache/documents/classes.cached.suppression.ts b/ts/cache/documents/classes.cached.suppression.ts new file mode 100644 index 0000000..3ccdd3e --- /dev/null +++ b/ts/cache/documents/classes.cached.suppression.ts @@ -0,0 +1,262 @@ +import * as plugins from '../../plugins.js'; +import { CachedDocument, TTL } from '../classes.cached.document.js'; +import { CacheDb } from '../classes.cachedb.js'; + +/** + * Helper to get the smartdata database instance + */ +const getDb = () => CacheDb.getInstance().getDb(); + +/** + * Reason for suppression + */ +export type TSuppressionReason = + | 'hard-bounce' + | 'soft-bounce-exceeded' + | 'complaint' + | 'unsubscribe' + | 'manual' + | 'spam-trap' + | 'invalid-address'; + +/** + * CachedSuppression - Stores email suppression list entries + * + * Emails to addresses in the suppression list should not be sent. + * Supports both temporary (30-day) and permanent suppression. + */ +@plugins.smartdata.Collection(() => getDb()) +export class CachedSuppression extends CachedDocument { + /** + * Email address to suppress (unique identifier) + */ + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public email: string; + + /** + * Reason for suppression + */ + @plugins.smartdata.svDb() + public reason: TSuppressionReason; + + /** + * Human-readable description of why this address is suppressed + */ + @plugins.smartdata.svDb() + public description: string; + + /** + * Whether this is a permanent suppression + */ + @plugins.smartdata.svDb() + public permanent: boolean = false; + + /** + * Number of times we've tried to send to this address after suppression + */ + @plugins.smartdata.svDb() + public blockedAttempts: number = 0; + + /** + * Domain of the suppressed email + */ + @plugins.smartdata.svDb() + public domain: string; + + /** + * Related bounce record ID (if suppressed due to bounce) + */ + @plugins.smartdata.svDb() + public relatedBounceId: string; + + /** + * Source that caused the suppression (e.g., campaign ID, message ID) + */ + @plugins.smartdata.svDb() + public source: string; + + /** + * Date when the suppression was first created + */ + @plugins.smartdata.svDb() + public suppressedAt: Date; + + constructor() { + super(); + this.setTTL(TTL.DAYS_30); // Default 30-day TTL + this.suppressedAt = new Date(); + this.blockedAttempts = 0; + } + + /** + * Create a new suppression entry + */ + public static createNew(email: string, reason: TSuppressionReason): CachedSuppression { + const suppression = new CachedSuppression(); + suppression.email = email.toLowerCase().trim(); + suppression.reason = reason; + suppression.updateDomain(); + + // Hard bounces and spam traps should be permanent + if (reason === 'hard-bounce' || reason === 'spam-trap' || reason === 'complaint') { + suppression.setPermanent(); + } + + return suppression; + } + + /** + * Make this suppression permanent (never expires) + */ + public setPermanent(): void { + this.permanent = true; + this.setNeverExpires(); + } + + /** + * Make this suppression temporary with specific TTL + */ + public setTemporary(ttlMs: number): void { + this.permanent = false; + this.setTTL(ttlMs); + } + + /** + * Extract domain from email + */ + public updateDomain(): void { + if (this.email) { + const match = this.email.match(/@(.+)$/); + if (match) { + this.domain = match[1].toLowerCase(); + } + } + } + + /** + * Check if an email is suppressed + */ + public static async isSuppressed(email: string): Promise { + const normalizedEmail = email.toLowerCase().trim(); + const entry = await CachedSuppression.getInstance({ + email: normalizedEmail, + }); + return entry !== null && !entry.isExpired(); + } + + /** + * Get suppression entry for an email + */ + public static async findByEmail(email: string): Promise { + const normalizedEmail = email.toLowerCase().trim(); + return await CachedSuppression.getInstance({ + email: normalizedEmail, + }); + } + + /** + * Find all suppressions for a domain + */ + public static async findByDomain(domain: string): Promise { + return await CachedSuppression.getInstances({ + domain: domain.toLowerCase(), + }); + } + + /** + * Find all permanent suppressions + */ + public static async findPermanent(): Promise { + return await CachedSuppression.getInstances({ + permanent: true, + }); + } + + /** + * Find all suppressions by reason + */ + public static async findByReason(reason: TSuppressionReason): Promise { + return await CachedSuppression.getInstances({ + reason, + }); + } + + /** + * Record a blocked attempt to send to this address + */ + public recordBlockedAttempt(): void { + this.blockedAttempts++; + this.touch(); + } + + /** + * Remove suppression (delete from database) + */ + public static async remove(email: string): Promise { + const normalizedEmail = email.toLowerCase().trim(); + const entry = await CachedSuppression.getInstance({ + email: normalizedEmail, + }); + if (entry) { + await entry.delete(); + return true; + } + return false; + } + + /** + * Add or update a suppression entry + */ + public static async addOrUpdate( + email: string, + reason: TSuppressionReason, + options?: { + permanent?: boolean; + description?: string; + source?: string; + relatedBounceId?: string; + } + ): Promise { + const normalizedEmail = email.toLowerCase().trim(); + + // Check if already suppressed + let entry = await CachedSuppression.findByEmail(normalizedEmail); + + if (entry) { + // Update existing entry + entry.reason = reason; + if (options?.permanent) { + entry.setPermanent(); + } + if (options?.description) { + entry.description = options.description; + } + if (options?.source) { + entry.source = options.source; + } + if (options?.relatedBounceId) { + entry.relatedBounceId = options.relatedBounceId; + } + entry.touch(); + } else { + // Create new entry + entry = CachedSuppression.createNew(normalizedEmail, reason); + if (options?.permanent) { + entry.setPermanent(); + } + if (options?.description) { + entry.description = options.description; + } + if (options?.source) { + entry.source = options.source; + } + if (options?.relatedBounceId) { + entry.relatedBounceId = options.relatedBounceId; + } + } + + await entry.save(); + return entry; + } +} diff --git a/ts/cache/documents/index.ts b/ts/cache/documents/index.ts new file mode 100644 index 0000000..909bfb7 --- /dev/null +++ b/ts/cache/documents/index.ts @@ -0,0 +1,5 @@ +export * from './classes.cached.email.js'; +export * from './classes.cached.ip.reputation.js'; +export * from './classes.cached.bounce.js'; +export * from './classes.cached.suppression.js'; +export * from './classes.cached.dkim.js'; diff --git a/ts/cache/index.ts b/ts/cache/index.ts new file mode 100644 index 0000000..a398f7b --- /dev/null +++ b/ts/cache/index.ts @@ -0,0 +1,7 @@ +// Core cache infrastructure +export * from './classes.cachedb.js'; +export * from './classes.cached.document.js'; +export * from './classes.cache.cleaner.js'; + +// Document classes +export * from './documents/index.js'; diff --git a/ts/opsserver/handlers/config.handler.ts b/ts/opsserver/handlers/config.handler.ts index 06f12c3..a2d6c71 100644 --- a/ts/opsserver/handlers/config.handler.ts +++ b/ts/opsserver/handlers/config.handler.ts @@ -1,19 +1,18 @@ import * as plugins from '../../plugins.js'; import type { OpsServer } from '../classes.opsserver.js'; import * as interfaces from '../../../ts_interfaces/index.js'; -import { requireAdminIdentity } from '../helpers/guards.js'; export class ConfigHandler { public typedrouter = new plugins.typedrequest.TypedRouter(); - + constructor(private opsServerRef: OpsServer) { // Add this handler's router to the parent this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); this.registerHandlers(); } - + private registerHandlers(): void { - // Get Configuration Handler + // Get Configuration Handler (read-only) this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getConfiguration', @@ -26,33 +25,6 @@ export class ConfigHandler { } ) ); - - // Update Configuration Handler - this.typedrouter.addTypedHandler( - new plugins.typedrequest.TypedHandler( - 'updateConfiguration', - async (dataArg, toolsArg) => { - try { - // Require admin access to update configuration - await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); - - const updatedConfig = await this.updateConfiguration(dataArg.section, dataArg.config); - return { - updated: true, - config: updatedConfig, - }; - } catch (error) { - if (error instanceof plugins.typedrequest.TypedResponseError) { - throw error; - } - return { - updated: false, - config: null, - }; - } - } - ) - ); } private async getConfiguration(section?: string): Promise<{ @@ -133,31 +105,4 @@ export class ConfigHandler { }, }; } - - private async updateConfiguration(section: string, config: any): Promise { - // TODO: Implement actual configuration updates - // This would involve: - // 1. Validating the configuration changes - // 2. Applying them to the running services - // 3. Persisting them to storage - // 4. Potentially restarting affected services - - // For now, just validate and return the config - if (section === 'email' && config.maxMessageSize && config.maxMessageSize < 1024) { - throw new Error('Maximum message size must be at least 1KB'); - } - - if (section === 'dns' && config.ttl && (config.ttl < 0 || config.ttl > 86400)) { - throw new Error('DNS TTL must be between 0 and 86400 seconds'); - } - - if (section === 'proxy' && config.maxConnections && config.maxConnections < 1) { - throw new Error('Maximum connections must be at least 1'); - } - - // In a real implementation, apply the changes here - // For now, return the current configuration - const currentConfig = await this.getConfiguration(section); - return currentConfig; - } } \ No newline at end of file diff --git a/ts/plugins.ts b/ts/plugins.ts index 12b755c..9b0b086 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -51,6 +51,7 @@ import * as smartjwt from '@push.rocks/smartjwt'; import * as smartlog from '@push.rocks/smartlog'; import * as smartmail from '@push.rocks/smartmail'; import * as smartmetrics from '@push.rocks/smartmetrics'; +import * as smartmongo from '@push.rocks/smartmongo'; import * as smartnetwork from '@push.rocks/smartnetwork'; import * as smartpath from '@push.rocks/smartpath'; import * as smartproxy from '@push.rocks/smartproxy'; @@ -61,7 +62,7 @@ import * as smartrule from '@push.rocks/smartrule'; import * as smartrx from '@push.rocks/smartrx'; import * as smartunique from '@push.rocks/smartunique'; -export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrule, smartrx, smartunique }; +export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartmongo, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrule, smartrx, smartunique }; // Define SmartLog types for use in error handling export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug'; diff --git a/ts_interfaces/readme.md b/ts_interfaces/readme.md index d1835e2..df42db0 100644 --- a/ts_interfaces/readme.md +++ b/ts_interfaces/readme.md @@ -97,8 +97,7 @@ TypedRequest interfaces for the OpsServer API: #### Configuration Requests | Interface | Method | Description | |-----------|--------|-------------| -| `IReq_GetConfiguration` | `getConfiguration` | Get current config | -| `IReq_UpdateConfiguration` | `updateConfiguration` | Update system config | +| `IReq_GetConfiguration` | `getConfiguration` | Get current config (read-only) | #### Log Requests | Interface | Method | Description | diff --git a/ts_interfaces/requests/config.ts b/ts_interfaces/requests/config.ts index b5d42a3..cf959e7 100644 --- a/ts_interfaces/requests/config.ts +++ b/ts_interfaces/requests/config.ts @@ -1,7 +1,7 @@ import * as plugins from '../plugins.js'; import * as authInterfaces from '../data/auth.js'; -// Get Configuration +// Get Configuration (read-only) export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_GetConfiguration @@ -15,21 +15,4 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im config: any; section?: string; }; -} - -// Update Configuration -export interface IReq_UpdateConfiguration extends plugins.typedrequestInterfaces.implementsTR< - plugins.typedrequestInterfaces.ITypedRequest, - IReq_UpdateConfiguration -> { - method: 'updateConfiguration'; - request: { - identity?: authInterfaces.IIdentity; - section: string; - config: any; - }; - response: { - updated: boolean; - config: any; - }; } \ No newline at end of file diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index f81f33c..4640a75 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '3.1.0', + version: '4.0.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 5a07137..f9ec83c 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -267,17 +267,17 @@ export const fetchAllStatsAction = statsStatePart.createAction(async (statePartA } }); -// Fetch Configuration Action +// Fetch Configuration Action (read-only) export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg) => { const context = getActionContext(); - + const currentState = statePartArg.getState(); try { const configRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_GetConfiguration >('/typedrequest', 'getConfiguration'); - + const response = await configRequest.fire({ identity: context.identity, }); @@ -296,35 +296,6 @@ export const fetchConfigurationAction = configStatePart.createAction(async (stat } }); -// Update Configuration Action -export const updateConfigurationAction = configStatePart.createAction<{ - section: string; - config: any; -}>(async (statePartArg, dataArg) => { - const context = getActionContext(); - if (!context.identity) { - throw new Error('Must be logged in to update configuration'); - } - - const updateRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< - interfaces.requests.IReq_UpdateConfiguration - >('/typedrequest', 'updateConfiguration'); - - const response = await updateRequest.fire({ - identity: context.identity, - section: dataArg.section, - config: dataArg.config, - }); - - if (response.updated) { - // Refresh configuration - await configStatePart.dispatchAction(fetchConfigurationAction, null); - return statePartArg.getState(); - } - - return statePartArg.getState(); -}); - // Fetch Recent Logs Action export const fetchRecentLogsAction = logStatePart.createAction<{ limit?: number; diff --git a/ts_web/elements/ops-view-config.ts b/ts_web/elements/ops-view-config.ts index 00d6f22..b4519f6 100644 --- a/ts_web/elements/ops-view-config.ts +++ b/ts_web/elements/ops-view-config.ts @@ -9,6 +9,7 @@ import { state, css, cssManager, + type TemplateResult, } from '@design.estate/dees-element'; @customElement('ops-view-config') @@ -20,12 +21,6 @@ export class OpsViewConfig extends DeesElement { error: null, }; - @state() - accessor editingSection: string | null = null; - - @state() - accessor editedConfig: any = null; - constructor() { super(); const subscription = appstate.configStatePart @@ -61,6 +56,14 @@ export class OpsViewConfig extends DeesElement { font-size: 18px; font-weight: 600; color: ${cssManager.bdTheme('#333', '#ccc')}; + display: flex; + align-items: center; + gap: 12px; + } + + .sectionTitle dees-icon { + font-size: 20px; + color: ${cssManager.bdTheme('#666', '#888')}; } .sectionContent { @@ -71,12 +74,18 @@ export class OpsViewConfig extends DeesElement { margin-bottom: 20px; } + .configField:last-child { + margin-bottom: 0; + } + .fieldLabel { - font-size: 14px; + font-size: 13px; font-weight: 600; color: ${cssManager.bdTheme('#666', '#999')}; margin-bottom: 8px; display: block; + text-transform: uppercase; + letter-spacing: 0.5px; } .fieldValue { @@ -84,41 +93,77 @@ export class OpsViewConfig extends DeesElement { font-size: 14px; color: ${cssManager.bdTheme('#333', '#ccc')}; background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')}; - padding: 8px 12px; - border-radius: 4px; + padding: 10px 14px; + border-radius: 6px; border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')}; } - .configEditor { - width: 100%; - min-height: 200px; - font-family: 'Consolas', 'Monaco', monospace; - font-size: 14px; - padding: 12px; - border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')}; - border-radius: 4px; - background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')}; - resize: vertical; + .fieldValue.empty { + color: ${cssManager.bdTheme('#999', '#666')}; + font-style: italic; } - .buttonGroup { - display: flex; - gap: 8px; - margin-top: 16px; + .nestedFields { + margin-left: 16px; + padding-left: 16px; + border-left: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')}; } - .warning { - background: ${cssManager.bdTheme('#fff3cd', '#4a4a1a')}; - border: 1px solid ${cssManager.bdTheme('#ffeaa7', '#666633')}; - border-radius: 4px; - padding: 12px; - margin-bottom: 16px; - color: ${cssManager.bdTheme('#856404', '#ffcc66')}; - display: flex; + /* Status badge styles */ + .statusBadge { + display: inline-flex; align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + } + + .statusBadge.enabled { + background: ${cssManager.bdTheme('#d4edda', '#1a3d1a')}; + color: ${cssManager.bdTheme('#155724', '#66cc66')}; + } + + .statusBadge.disabled { + background: ${cssManager.bdTheme('#f8d7da', '#3d1a1a')}; + color: ${cssManager.bdTheme('#721c24', '#cc6666')}; + } + + .statusBadge dees-icon { + font-size: 14px; + } + + /* Array/list display */ + .arrayItems { + display: flex; + flex-wrap: wrap; gap: 8px; } + .arrayItem { + display: inline-flex; + align-items: center; + background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')}; + color: ${cssManager.bdTheme('#0066cc', '#66aaff')}; + padding: 4px 12px; + border-radius: 16px; + font-size: 13px; + font-family: 'Consolas', 'Monaco', monospace; + } + + .arrayCount { + font-size: 12px; + color: ${cssManager.bdTheme('#999', '#666')}; + margin-bottom: 8px; + } + + /* Numeric value formatting */ + .numericValue { + font-weight: 600; + color: ${cssManager.bdTheme('#0066cc', '#66aaff')}; + } + .errorMessage { background: ${cssManager.bdTheme('#fee', '#4a1f1f')}; border: 1px solid ${cssManager.bdTheme('#fcc', '#6a2f2f')}; @@ -133,13 +178,30 @@ export class OpsViewConfig extends DeesElement { padding: 40px; color: ${cssManager.bdTheme('#666', '#999')}; } + + .infoNote { + background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')}; + border: 1px solid ${cssManager.bdTheme('#b3d7ff', '#2a4a6d')}; + border-radius: 8px; + padding: 16px; + margin-bottom: 24px; + color: ${cssManager.bdTheme('#004085', '#88ccff')}; + display: flex; + align-items: center; + gap: 12px; + } + + .infoNote dees-icon { + font-size: 20px; + flex-shrink: 0; + } `, ]; public render() { return html` Configuration - + ${this.configState.isLoading ? html`
@@ -150,118 +212,175 @@ export class OpsViewConfig extends DeesElement { Error loading configuration: ${this.configState.error}
` : this.configState.config ? html` -
- - Changes to configuration will take effect immediately. Please be careful when editing production settings. +
+ + This view displays the current running configuration. DcRouter is configured through code or remote management.
- ${this.renderConfigSection('email', 'Email Configuration', this.configState.config?.email)} - ${this.renderConfigSection('dns', 'DNS Configuration', this.configState.config?.dns)} - ${this.renderConfigSection('proxy', 'Proxy Configuration', this.configState.config?.proxy)} - ${this.renderConfigSection('security', 'Security Configuration', this.configState.config?.security)} + ${this.renderConfigSection('email', 'Email', 'lucide:mail', this.configState.config?.email)} + ${this.renderConfigSection('dns', 'DNS', 'lucide:globe', this.configState.config?.dns)} + ${this.renderConfigSection('proxy', 'Proxy', 'lucide:network', this.configState.config?.proxy)} + ${this.renderConfigSection('security', 'Security', 'lucide:shield', this.configState.config?.security)} ` : html`
No configuration loaded
`} `; } - private renderConfigSection(key: string, title: string, config: any) { - const isEditing = this.editingSection === key; - + private renderConfigSection(key: string, title: string, icon: string, config: any) { + const isEnabled = config?.enabled ?? false; + return html`
-

${title}

-
- ${isEditing ? html` - this.saveConfig(key)} - type="highlighted" - > - Save - - this.cancelEdit()} - > - Cancel - - ` : html` - this.startEdit(key, config)} - > - Edit - - `} -
+

+ + ${title} +

+ ${this.renderStatusBadge(isEnabled)}
- ${isEditing ? html` - - ` : html` - ${this.renderConfigFields(config)} + ${config ? this.renderConfigFields(config) : html` +
Not configured
`}
`; } - private renderConfigFields(config: any, prefix = '') { + private renderStatusBadge(enabled: boolean): TemplateResult { + return enabled + ? html`Enabled` + : html`Disabled`; + } + + private renderConfigFields(config: any, prefix = ''): TemplateResult | TemplateResult[] { if (!config || typeof config !== 'object') { - return html`
${config}
`; + return html`
${this.formatValue(config)}
`; } return Object.entries(config).map(([key, value]) => { const fieldName = prefix ? `${prefix}.${key}` : key; - - if (typeof value === 'object' && !Array.isArray(value)) { + const displayName = this.formatFieldName(key); + + // Handle boolean values with badges + if (typeof value === 'boolean') { return html`
- - ${this.renderConfigFields(value, fieldName)} + + ${this.renderStatusBadge(value)}
`; } - + + // Handle arrays + if (Array.isArray(value)) { + return html` +
+ + ${this.renderArrayValue(value, key)} +
+ `; + } + + // Handle nested objects + if (typeof value === 'object' && value !== null) { + return html` +
+ +
+ ${this.renderConfigFields(value, fieldName)} +
+
+ `; + } + + // Handle primitive values return html`
- -
- ${Array.isArray(value) ? value.join(', ') : value} -
+ +
${this.formatValue(value, key)}
`; }); } - private startEdit(section: string, config: any) { - this.editingSection = section; - this.editedConfig = JSON.stringify(config, null, 2); - } - - private cancelEdit() { - this.editingSection = null; - this.editedConfig = null; - } - - private async saveConfig(section: string) { - try { - const parsedConfig = JSON.parse(this.editedConfig); - - await appstate.configStatePart.dispatchAction(appstate.updateConfigurationAction, { - section, - config: parsedConfig, - }); - - this.editingSection = null; - this.editedConfig = null; - - // Configuration updated successfully - } catch (error) { - console.error(`Error updating configuration:`, error); + private renderArrayValue(arr: any[], fieldKey: string): TemplateResult { + if (arr.length === 0) { + return html`
None configured
`; } + + // Determine if we should show as pills/tags + const showAsPills = arr.every(item => typeof item === 'string' || typeof item === 'number'); + + if (showAsPills) { + const itemLabel = this.getArrayItemLabel(fieldKey, arr.length); + return html` +
${arr.length} ${itemLabel}
+
+ ${arr.map(item => html`${item}`)} +
+ `; + } + + // For complex arrays, show as JSON + return html` +
+ ${arr.length} items configured +
+ `; } -} \ No newline at end of file + + private getArrayItemLabel(fieldKey: string, count: number): string { + const labels: Record = { + ports: ['port', 'ports'], + domains: ['domain', 'domains'], + nameservers: ['nameserver', 'nameservers'], + blockList: ['IP', 'IPs'], + }; + + const label = labels[fieldKey] || ['item', 'items']; + return count === 1 ? label[0] : label[1]; + } + + private formatFieldName(key: string): string { + // Convert camelCase to readable format + return key + .replace(/([A-Z])/g, ' $1') + .replace(/^./, str => str.toUpperCase()) + .trim(); + } + + private formatValue(value: any, fieldKey?: string): string | TemplateResult { + if (value === null || value === undefined) { + return html`Not set`; + } + + if (typeof value === 'number') { + // Format bytes + if (fieldKey?.toLowerCase().includes('size') || fieldKey?.toLowerCase().includes('bytes')) { + return html`${this.formatBytes(value)}`; + } + // Format time values + if (fieldKey?.toLowerCase().includes('ttl') || fieldKey?.toLowerCase().includes('timeout')) { + return html`${value} seconds`; + } + // Format port numbers + if (fieldKey?.toLowerCase().includes('port')) { + return html`${value}`; + } + // Format counts with separators + return html`${value.toLocaleString()}`; + } + + return String(value); + } + + private formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} diff --git a/ts_web/readme.md b/ts_web/readme.md index 4240b67..39cbe34 100644 --- a/ts_web/readme.md +++ b/ts_web/readme.md @@ -128,8 +128,7 @@ interface IConfigState { - `loginAction` - Authenticate user - `logoutAction` - End session - `fetchAllStatsAction` - Refresh all statistics -- `fetchConfigurationAction` - Load configuration -- `updateConfigurationAction` - Save configuration changes +- `fetchConfigurationAction` - Load configuration (read-only) ## Client-Side Routing