Compare commits

...

2 Commits
v3.1.0 ... main

Author SHA1 Message Date
f3f1f58b67 v4.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-03 23:26:51 +00:00
9e0e77737b BREAKING CHANGE(config): convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing 2026-02-03 23:26:51 +00:00
25 changed files with 2130 additions and 270 deletions

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "3.1.0",
"version": "4.0.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
@@ -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",

125
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -1265,3 +1265,55 @@ Login state was using `'soft'` mode in Smartstate which is memory-only:
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
## 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

View File

@@ -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 } }

View File

@@ -31,38 +31,29 @@ tap.test('should login as admin', async () => {
console.log('Admin logged in with JWT');
});
tap.test('should allow admin to update configuration', async () => {
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
tap.test('should allow admin to verify identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'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<interfaces.requests.IReq_UpdateConfiguration>(
tap.test('should reject verify identity without identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'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<interfaces.requests.IReq_UpdateConfiguration>(
tap.test('should reject verify identity with invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'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) {
@@ -108,6 +95,23 @@ tap.test('should allow access to public endpoints without auth', async () => {
console.log('Public endpoint accessible without auth');
});
tap.test('should allow read-only config access', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'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();
});

View File

@@ -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.'
}

170
ts/cache/classes.cache.cleaner.ts vendored Normal file
View File

@@ -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<typeof setInterval> | null = null;
private isRunning: boolean = false;
private options: Required<ICacheCleanerOptions>;
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<void> {
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<T>(
documentClass: { deleteMany: (filter: any) => Promise<any> },
now: Date
): Promise<number> {
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;
}
}

108
ts/cache/classes.cached.document.ts vendored Normal file
View File

@@ -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<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
/**
* 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;

152
ts/cache/classes.cachedb.ts vendored Normal file
View File

@@ -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<ICacheDbOptions>;
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<void> {
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<void> {
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;
}
}

View File

@@ -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<CachedBounce> {
/**
* 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<CachedBounce[]> {
return await CachedBounce.getInstances({
recipient,
});
}
/**
* Find bounces by domain
*/
public static async findByDomain(domain: string): Promise<CachedBounce[]> {
return await CachedBounce.getInstances({
domain,
});
}
/**
* Find all hard bounces
*/
public static async findHardBounces(): Promise<CachedBounce[]> {
return await CachedBounce.getInstances({
bounceType: 'hard',
});
}
/**
* Find bounces by category
*/
public static async findByCategory(category: TBounceCategory): Promise<CachedBounce[]> {
return await CachedBounce.getInstances({
bounceCategory: category,
});
}
/**
* Check if a recipient has recent hard bounces
*/
public static async hasRecentHardBounce(recipient: string): Promise<boolean> {
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';
}
}
}
}

View File

@@ -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<CachedDKIMKey> {
/**
* 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<CachedDKIMKey | null> {
const domainSelector = CachedDKIMKey.createDomainSelector(domain, selector);
return await CachedDKIMKey.getInstance({
domainSelector,
});
}
/**
* Find all keys for a domain
*/
public static async findByDomain(domain: string): Promise<CachedDKIMKey[]> {
return await CachedDKIMKey.getInstances({
domain: domain.toLowerCase(),
});
}
/**
* Find the active key for a domain
*/
public static async findActiveForDomain(domain: string): Promise<CachedDKIMKey | null> {
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<CachedDKIMKey[]> {
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;
}
}

View File

@@ -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<CachedEmail> {
/**
* 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<CachedEmail | null> {
return await CachedEmail.getInstance({
id,
});
}
/**
* Find all emails with a specific status
*/
public static async findByStatus(status: TCachedEmailStatus): Promise<CachedEmail[]> {
return await CachedEmail.getInstances({
status,
});
}
/**
* Find all emails pending delivery (status = pending and nextAttempt <= now)
*/
public static async findPendingForDelivery(): Promise<CachedEmail[]> {
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<CachedEmail[]> {
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();
}
}
}
}

View File

@@ -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<CachedIPReputation> {
/**
* 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<CachedIPReputation | null> {
return await CachedIPReputation.getInstance({
ipAddress,
});
}
/**
* Find all IPs flagged as spam
*/
public static async findSpamIPs(): Promise<CachedIPReputation[]> {
return await CachedIPReputation.getInstances({
isSpam: true,
});
}
/**
* Find IPs with score below threshold
*/
public static async findLowScoreIPs(threshold: number): Promise<CachedIPReputation[]> {
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;
}
}

View File

@@ -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<CachedSuppression> {
/**
* 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<boolean> {
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<CachedSuppression | null> {
const normalizedEmail = email.toLowerCase().trim();
return await CachedSuppression.getInstance({
email: normalizedEmail,
});
}
/**
* Find all suppressions for a domain
*/
public static async findByDomain(domain: string): Promise<CachedSuppression[]> {
return await CachedSuppression.getInstances({
domain: domain.toLowerCase(),
});
}
/**
* Find all permanent suppressions
*/
public static async findPermanent(): Promise<CachedSuppression[]> {
return await CachedSuppression.getInstances({
permanent: true,
});
}
/**
* Find all suppressions by reason
*/
public static async findByReason(reason: TSuppressionReason): Promise<CachedSuppression[]> {
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<boolean> {
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<CachedSuppression> {
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;
}
}

5
ts/cache/documents/index.ts vendored Normal file
View File

@@ -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';

7
ts/cache/index.ts vendored Normal file
View File

@@ -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';

View File

@@ -1,7 +1,6 @@
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();
@@ -13,7 +12,7 @@ export class ConfigHandler {
}
private registerHandlers(): void {
// Get Configuration Handler
// Get Configuration Handler (read-only)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
'getConfiguration',
@@ -26,33 +25,6 @@ export class ConfigHandler {
}
)
);
// Update Configuration Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateConfiguration>(
'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<any> {
// 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;
}
}

View File

@@ -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';

View File

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

View File

@@ -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
@@ -16,20 +16,3 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im
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;
};
}

View File

@@ -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.'
}

View File

@@ -267,7 +267,7 @@ 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();
@@ -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;

View File

@@ -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,6 +178,23 @@ 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;
}
`,
];
@@ -150,118 +212,175 @@ export class OpsViewConfig extends DeesElement {
Error loading configuration: ${this.configState.error}
</div>
` : this.configState.config ? html`
<div class="warning">
<dees-icon name="warning"></dees-icon>
<span>Changes to configuration will take effect immediately. Please be careful when editing production settings.</span>
<div class="infoNote">
<dees-icon icon="lucide:info"></dees-icon>
<span>This view displays the current running configuration. DcRouter is configured through code or remote management.</span>
</div>
${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`
<div class="errorMessage">No configuration loaded</div>
`}
`;
}
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`
<div class="configSection">
<div class="sectionHeader">
<h3 class="sectionTitle">${title}</h3>
<div>
${isEditing ? html`
<dees-button
@click=${() => this.saveConfig(key)}
type="highlighted"
>
Save
</dees-button>
<dees-button
@click=${() => this.cancelEdit()}
>
Cancel
</dees-button>
` : html`
<dees-button
@click=${() => this.startEdit(key, config)}
>
Edit
</dees-button>
`}
</div>
<h3 class="sectionTitle">
<dees-icon icon="${icon}"></dees-icon>
${title}
</h3>
${this.renderStatusBadge(isEnabled)}
</div>
<div class="sectionContent">
${isEditing ? html`
<textarea
class="configEditor"
@input=${(e) => this.editedConfig = e.target.value}
.value=${JSON.stringify(config, null, 2)}
></textarea>
` : html`
${this.renderConfigFields(config)}
${config ? this.renderConfigFields(config) : html`
<div class="fieldValue empty">Not configured</div>
`}
</div>
</div>
`;
}
private renderConfigFields(config: any, prefix = '') {
private renderStatusBadge(enabled: boolean): TemplateResult {
return enabled
? html`<span class="statusBadge enabled"><dees-icon icon="lucide:check"></dees-icon>Enabled</span>`
: html`<span class="statusBadge disabled"><dees-icon icon="lucide:x"></dees-icon>Disabled</span>`;
}
private renderConfigFields(config: any, prefix = ''): TemplateResult | TemplateResult[] {
if (!config || typeof config !== 'object') {
return html`<div class="fieldValue">${config}</div>`;
return html`<div class="fieldValue">${this.formatValue(config)}</div>`;
}
return Object.entries(config).map(([key, value]) => {
const fieldName = prefix ? `${prefix}.${key}` : key;
const displayName = this.formatFieldName(key);
if (typeof value === 'object' && !Array.isArray(value)) {
// Handle boolean values with badges
if (typeof value === 'boolean') {
return html`
<div class="configField">
<label class="fieldLabel">${fieldName}</label>
${this.renderConfigFields(value, fieldName)}
<label class="fieldLabel">${displayName}</label>
${this.renderStatusBadge(value)}
</div>
`;
}
// Handle arrays
if (Array.isArray(value)) {
return html`
<div class="configField">
<label class="fieldLabel">${displayName}</label>
${this.renderArrayValue(value, key)}
</div>
`;
}
// Handle nested objects
if (typeof value === 'object' && value !== null) {
return html`
<div class="configField">
<label class="fieldLabel">${displayName}</label>
<div class="nestedFields">
${this.renderConfigFields(value, fieldName)}
</div>
</div>
`;
}
// Handle primitive values
return html`
<div class="configField">
<label class="fieldLabel">${fieldName}</label>
<div class="fieldValue">
${Array.isArray(value) ? value.join(', ') : value}
</div>
<label class="fieldLabel">${displayName}</label>
<div class="fieldValue">${this.formatValue(value, key)}</div>
</div>
`;
});
}
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`<div class="fieldValue empty">None configured</div>`;
}
// 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`
<div class="arrayCount">${arr.length} ${itemLabel}</div>
<div class="arrayItems">
${arr.map(item => html`<span class="arrayItem">${item}</span>`)}
</div>
`;
}
// For complex arrays, show as JSON
return html`
<div class="fieldValue">
${arr.length} items configured
</div>
`;
}
private getArrayItemLabel(fieldKey: string, count: number): string {
const labels: Record<string, [string, string]> = {
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`<span class="empty">Not set</span>`;
}
if (typeof value === 'number') {
// Format bytes
if (fieldKey?.toLowerCase().includes('size') || fieldKey?.toLowerCase().includes('bytes')) {
return html`<span class="numericValue">${this.formatBytes(value)}</span>`;
}
// Format time values
if (fieldKey?.toLowerCase().includes('ttl') || fieldKey?.toLowerCase().includes('timeout')) {
return html`<span class="numericValue">${value} seconds</span>`;
}
// Format port numbers
if (fieldKey?.toLowerCase().includes('port')) {
return html`<span class="numericValue">${value}</span>`;
}
// Format counts with separators
return html`<span class="numericValue">${value.toLocaleString()}</span>`;
}
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];
}
}

View File

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