Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3f1f58b67 | |||
| 9e0e77737b |
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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)
|
## 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
|
determine initial UI view from URL and wire selected view to appdash; add interface and web README files; bump various dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "3.1.0",
|
"version": "4.0.0",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"@push.rocks/projectinfo": "^5.0.2",
|
"@push.rocks/projectinfo": "^5.0.2",
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartacme": "^8.0.0",
|
"@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/smartdns": "^7.6.1",
|
||||||
"@push.rocks/smartfile": "^13.1.2",
|
"@push.rocks/smartfile": "^13.1.2",
|
||||||
"@push.rocks/smartguard": "^3.1.0",
|
"@push.rocks/smartguard": "^3.1.0",
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"@push.rocks/smartlog": "^3.1.10",
|
"@push.rocks/smartlog": "^3.1.10",
|
||||||
"@push.rocks/smartmail": "^2.2.0",
|
"@push.rocks/smartmail": "^2.2.0",
|
||||||
"@push.rocks/smartmetrics": "^2.0.10",
|
"@push.rocks/smartmetrics": "^2.0.10",
|
||||||
|
"@push.rocks/smartmongo": "^5.1.0",
|
||||||
"@push.rocks/smartnetwork": "^4.4.0",
|
"@push.rocks/smartnetwork": "^4.4.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
|
|||||||
125
pnpm-lock.yaml
generated
125
pnpm-lock.yaml
generated
@@ -39,8 +39,8 @@ importers:
|
|||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.0.0(socks@2.8.7)
|
version: 8.0.0(socks@2.8.7)
|
||||||
'@push.rocks/smartdata':
|
'@push.rocks/smartdata':
|
||||||
specifier: ^5.16.7
|
specifier: ^7.0.15
|
||||||
version: 5.16.7(socks@2.8.7)
|
version: 7.0.15(socks@2.8.7)
|
||||||
'@push.rocks/smartdns':
|
'@push.rocks/smartdns':
|
||||||
specifier: ^7.6.1
|
specifier: ^7.6.1
|
||||||
version: 7.6.1
|
version: 7.6.1
|
||||||
@@ -62,6 +62,9 @@ importers:
|
|||||||
'@push.rocks/smartmetrics':
|
'@push.rocks/smartmetrics':
|
||||||
specifier: ^2.0.10
|
specifier: ^2.0.10
|
||||||
version: 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':
|
'@push.rocks/smartnetwork':
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
@@ -934,6 +937,9 @@ packages:
|
|||||||
'@push.rocks/smartdata@5.16.7':
|
'@push.rocks/smartdata@5.16.7':
|
||||||
resolution: {integrity: sha512-bu/YSIjQcwxWXkAsuhqE6zs7eT+bTIKV8+/H7TbbjpzeioLCyB3dZ/41cLZk37c/EYt4d4GHgZ0ww80OiKOUMg==}
|
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':
|
'@push.rocks/smartdelay@3.0.5':
|
||||||
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
|
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
|
||||||
|
|
||||||
@@ -1033,6 +1039,9 @@ packages:
|
|||||||
'@push.rocks/smartmongo@2.2.0':
|
'@push.rocks/smartmongo@2.2.0':
|
||||||
resolution: {integrity: sha512-ovVCNoJ3D0aBuKtoKaQWWQKvBngaGJq9fAPQigzji1EHsS1XyGpXWCpe5nq/ptGvBROOcpqZcOFEGAcrnb+OjA==}
|
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':
|
'@push.rocks/smartmustache@3.0.2':
|
||||||
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
||||||
|
|
||||||
@@ -1958,6 +1967,9 @@ packages:
|
|||||||
'@types/whatwg-url@11.0.5':
|
'@types/whatwg-url@11.0.5':
|
||||||
resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==}
|
resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==}
|
||||||
|
|
||||||
|
'@types/whatwg-url@13.0.0':
|
||||||
|
resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==}
|
||||||
|
|
||||||
'@types/which@3.0.4':
|
'@types/which@3.0.4':
|
||||||
resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==}
|
resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==}
|
||||||
|
|
||||||
@@ -2145,6 +2157,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
||||||
engines: {node: '>=16.20.1'}
|
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:
|
buffer-crc32@0.2.13:
|
||||||
resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=}
|
resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=}
|
||||||
|
|
||||||
@@ -3343,6 +3359,10 @@ packages:
|
|||||||
mongodb-connection-string-url@3.0.2:
|
mongodb-connection-string-url@3.0.2:
|
||||||
resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==}
|
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:
|
mongodb-memory-server-core@10.4.3:
|
||||||
resolution: {integrity: sha512-IPjlw73IoSYopnqBibQKxmAXMbOEPf5uGAOsBcaUiNH/TOI7V19WO+K7n5KYtnQ9FqzLGLpvwCGuPOTBSg4s5Q==}
|
resolution: {integrity: sha512-IPjlw73IoSYopnqBibQKxmAXMbOEPf5uGAOsBcaUiNH/TOI7V19WO+K7n5KYtnQ9FqzLGLpvwCGuPOTBSg4s5Q==}
|
||||||
engines: {node: '>=16.20.1'}
|
engines: {node: '>=16.20.1'}
|
||||||
@@ -3378,6 +3398,33 @@ packages:
|
|||||||
socks:
|
socks:
|
||||||
optional: true
|
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:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -6042,6 +6089,35 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- 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':
|
'@push.rocks/smartdelay@3.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
@@ -6319,6 +6395,32 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- 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':
|
'@push.rocks/smartmustache@3.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
handlebars: 4.7.8
|
handlebars: 4.7.8
|
||||||
@@ -7609,6 +7711,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/webidl-conversions': 7.0.3
|
'@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/which@3.0.4': {}
|
||||||
|
|
||||||
'@types/wrap-ansi@3.0.0': {}
|
'@types/wrap-ansi@3.0.0': {}
|
||||||
@@ -7806,6 +7912,8 @@ snapshots:
|
|||||||
|
|
||||||
bson@6.10.4: {}
|
bson@6.10.4: {}
|
||||||
|
|
||||||
|
bson@7.1.1: {}
|
||||||
|
|
||||||
buffer-crc32@0.2.13: {}
|
buffer-crc32@0.2.13: {}
|
||||||
|
|
||||||
buffer-equal-constant-time@1.0.1: {}
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
@@ -9313,6 +9421,11 @@ snapshots:
|
|||||||
'@types/whatwg-url': 11.0.5
|
'@types/whatwg-url': 11.0.5
|
||||||
whatwg-url: 14.2.0
|
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):
|
mongodb-memory-server-core@10.4.3(socks@2.8.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
async-mutex: 0.5.0
|
async-mutex: 0.5.0
|
||||||
@@ -9363,6 +9476,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
socks: 2.8.7
|
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: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
mute-stream@1.0.0: {}
|
mute-stream@1.0.0: {}
|
||||||
|
|||||||
@@ -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
|
3. `ts_web/elements/ops-dashboard.ts`: Added JWT expiry check on session restore
|
||||||
- Validates stored JWT hasn't expired before auto-logging in
|
- Validates stored JWT hasn't expired before auto-logging in
|
||||||
- Clears expired sessions and shows login form
|
- 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
|
||||||
@@ -933,7 +933,7 @@ The OpsServer provides a web-based management interface:
|
|||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **Real-time Statistics**: View connections, email throughput, DNS queries, RADIUS sessions
|
- **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
|
- **Log Viewer**: Access system logs with filtering
|
||||||
- **Security Dashboard**: Monitor threats and blocked connections
|
- **Security Dashboard**: Monitor threats and blocked connections
|
||||||
|
|
||||||
@@ -948,9 +948,8 @@ POST /typedrequest { method: 'getHealthStatus' }
|
|||||||
// Server statistics
|
// Server statistics
|
||||||
POST /typedrequest { method: 'getServerStatistics' }
|
POST /typedrequest { method: 'getServerStatistics' }
|
||||||
|
|
||||||
// Configuration
|
// Configuration (read-only)
|
||||||
POST /typedrequest { method: 'getConfiguration' }
|
POST /typedrequest { method: 'getConfiguration' }
|
||||||
POST /typedrequest { method: 'updateConfiguration', data: { ... } }
|
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
POST /typedrequest { method: 'getLogs', data: { level: 'info', limit: 100 } }
|
POST /typedrequest { method: 'getLogs', data: { level: 'info', limit: 100 } }
|
||||||
|
|||||||
@@ -31,38 +31,29 @@ tap.test('should login as admin', async () => {
|
|||||||
console.log('Admin logged in with JWT');
|
console.log('Admin logged in with JWT');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should allow admin to update configuration', async () => {
|
tap.test('should allow admin to verify identity', async () => {
|
||||||
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3000/typedrequest',
|
||||||
'updateConfiguration'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await updateRequest.fire({
|
const response = await verifyRequest.fire({
|
||||||
identity: adminIdentity,
|
identity: adminIdentity,
|
||||||
section: 'security',
|
|
||||||
config: {
|
|
||||||
rateLimit: true,
|
|
||||||
spamDetection: true
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('updated');
|
expect(response).toHaveProperty('valid');
|
||||||
expect(response.updated).toBeTrue();
|
expect(response.valid).toBeTrue();
|
||||||
|
console.log('Admin identity verified successfully');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should reject configuration update without identity', async () => {
|
tap.test('should reject verify identity without identity', async () => {
|
||||||
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3000/typedrequest',
|
||||||
'updateConfiguration'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateRequest.fire({
|
await verifyRequest.fire({} as any);
|
||||||
section: 'security',
|
|
||||||
config: {
|
|
||||||
rateLimit: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(true).toBeFalse(); // Should not reach here
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeTruthy();
|
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 () => {
|
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||||
const updateRequest = new TypedRequest<interfaces.requests.IReq_UpdateConfiguration>(
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3000/typedrequest',
|
||||||
'updateConfiguration'
|
'verifyIdentity'
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateRequest.fire({
|
await verifyRequest.fire({
|
||||||
identity: {
|
identity: {
|
||||||
...adminIdentity,
|
...adminIdentity,
|
||||||
jwt: 'invalid.jwt.token'
|
jwt: 'invalid.jwt.token'
|
||||||
},
|
},
|
||||||
section: 'security',
|
|
||||||
config: {
|
|
||||||
rateLimit: false
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
expect(true).toBeFalse(); // Should not reach here
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,6 +95,23 @@ tap.test('should allow access to public endpoints without auth', async () => {
|
|||||||
console.log('Public endpoint accessible without auth');
|
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 () => {
|
tap.test('should stop DCRouter', async () => {
|
||||||
await testDcRouter.stop();
|
await testDcRouter.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '3.1.0',
|
version: '4.0.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
170
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
170
ts/cache/classes.cache.cleaner.ts
vendored
Normal 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
108
ts/cache/classes.cached.document.ts
vendored
Normal 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
152
ts/cache/classes.cachedb.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
244
ts/cache/documents/classes.cached.bounce.ts
vendored
Normal file
244
ts/cache/documents/classes.cached.bounce.ts
vendored
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
241
ts/cache/documents/classes.cached.dkim.ts
vendored
Normal file
241
ts/cache/documents/classes.cached.dkim.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
230
ts/cache/documents/classes.cached.email.ts
vendored
Normal file
230
ts/cache/documents/classes.cached.email.ts
vendored
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
237
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal file
237
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
262
ts/cache/documents/classes.cached.suppression.ts
vendored
Normal file
262
ts/cache/documents/classes.cached.suppression.ts
vendored
Normal 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
5
ts/cache/documents/index.ts
vendored
Normal 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
7
ts/cache/index.ts
vendored
Normal 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';
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
import { requireAdminIdentity } from '../helpers/guards.js';
|
|
||||||
|
|
||||||
export class ConfigHandler {
|
export class ConfigHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
@@ -13,7 +12,7 @@ export class ConfigHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
// Get Configuration Handler
|
// Get Configuration Handler (read-only)
|
||||||
this.typedrouter.addTypedHandler(
|
this.typedrouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||||
'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<{
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,7 @@ import * as smartjwt from '@push.rocks/smartjwt';
|
|||||||
import * as smartlog from '@push.rocks/smartlog';
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
import * as smartmail from '@push.rocks/smartmail';
|
import * as smartmail from '@push.rocks/smartmail';
|
||||||
import * as smartmetrics from '@push.rocks/smartmetrics';
|
import * as smartmetrics from '@push.rocks/smartmetrics';
|
||||||
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
import * as smartproxy from '@push.rocks/smartproxy';
|
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 smartrx from '@push.rocks/smartrx';
|
||||||
import * as smartunique from '@push.rocks/smartunique';
|
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
|
// Define SmartLog types for use in error handling
|
||||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||||
|
|||||||
@@ -97,8 +97,7 @@ TypedRequest interfaces for the OpsServer API:
|
|||||||
#### Configuration Requests
|
#### Configuration Requests
|
||||||
| Interface | Method | Description |
|
| Interface | Method | Description |
|
||||||
|-----------|--------|-------------|
|
|-----------|--------|-------------|
|
||||||
| `IReq_GetConfiguration` | `getConfiguration` | Get current config |
|
| `IReq_GetConfiguration` | `getConfiguration` | Get current config (read-only) |
|
||||||
| `IReq_UpdateConfiguration` | `updateConfiguration` | Update system config |
|
|
||||||
|
|
||||||
#### Log Requests
|
#### Log Requests
|
||||||
| Interface | Method | Description |
|
| Interface | Method | Description |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as authInterfaces from '../data/auth.js';
|
import * as authInterfaces from '../data/auth.js';
|
||||||
|
|
||||||
// Get Configuration
|
// Get Configuration (read-only)
|
||||||
export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
IReq_GetConfiguration
|
IReq_GetConfiguration
|
||||||
@@ -16,20 +16,3 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im
|
|||||||
section?: string;
|
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '3.1.0',
|
version: '4.0.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
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
|
// Fetch Recent Logs Action
|
||||||
export const fetchRecentLogsAction = logStatePart.createAction<{
|
export const fetchRecentLogsAction = logStatePart.createAction<{
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
state,
|
state,
|
||||||
css,
|
css,
|
||||||
cssManager,
|
cssManager,
|
||||||
|
type TemplateResult,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
@customElement('ops-view-config')
|
@customElement('ops-view-config')
|
||||||
@@ -20,12 +21,6 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor editingSection: string | null = null;
|
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor editedConfig: any = null;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const subscription = appstate.configStatePart
|
const subscription = appstate.configStatePart
|
||||||
@@ -61,6 +56,14 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle dees-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: ${cssManager.bdTheme('#666', '#888')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionContent {
|
.sectionContent {
|
||||||
@@ -71,12 +74,18 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.configField:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.fieldLabel {
|
.fieldLabel {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: ${cssManager.bdTheme('#666', '#999')};
|
color: ${cssManager.bdTheme('#666', '#999')};
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
display: block;
|
display: block;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldValue {
|
.fieldValue {
|
||||||
@@ -84,41 +93,77 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
||||||
padding: 8px 12px;
|
padding: 10px 14px;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.configEditor {
|
.fieldValue.empty {
|
||||||
width: 100%;
|
color: ${cssManager.bdTheme('#999', '#666')};
|
||||||
min-height: 200px;
|
font-style: italic;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonGroup {
|
.nestedFields {
|
||||||
display: flex;
|
margin-left: 16px;
|
||||||
gap: 8px;
|
padding-left: 16px;
|
||||||
margin-top: 16px;
|
border-left: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
/* Status badge styles */
|
||||||
background: ${cssManager.bdTheme('#fff3cd', '#4a4a1a')};
|
.statusBadge {
|
||||||
border: 1px solid ${cssManager.bdTheme('#ffeaa7', '#666633')};
|
display: inline-flex;
|
||||||
border-radius: 4px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: ${cssManager.bdTheme('#856404', '#ffcc66')};
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
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;
|
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 {
|
.errorMessage {
|
||||||
background: ${cssManager.bdTheme('#fee', '#4a1f1f')};
|
background: ${cssManager.bdTheme('#fee', '#4a1f1f')};
|
||||||
border: 1px solid ${cssManager.bdTheme('#fcc', '#6a2f2f')};
|
border: 1px solid ${cssManager.bdTheme('#fcc', '#6a2f2f')};
|
||||||
@@ -133,6 +178,23 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
padding: 40px;
|
padding: 40px;
|
||||||
color: ${cssManager.bdTheme('#666', '#999')};
|
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}
|
Error loading configuration: ${this.configState.error}
|
||||||
</div>
|
</div>
|
||||||
` : this.configState.config ? html`
|
` : this.configState.config ? html`
|
||||||
<div class="warning">
|
<div class="infoNote">
|
||||||
<dees-icon name="warning"></dees-icon>
|
<dees-icon icon="lucide:info"></dees-icon>
|
||||||
<span>Changes to configuration will take effect immediately. Please be careful when editing production settings.</span>
|
<span>This view displays the current running configuration. DcRouter is configured through code or remote management.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${this.renderConfigSection('email', 'Email Configuration', this.configState.config?.email)}
|
${this.renderConfigSection('email', 'Email', 'lucide:mail', this.configState.config?.email)}
|
||||||
${this.renderConfigSection('dns', 'DNS Configuration', this.configState.config?.dns)}
|
${this.renderConfigSection('dns', 'DNS', 'lucide:globe', this.configState.config?.dns)}
|
||||||
${this.renderConfigSection('proxy', 'Proxy Configuration', this.configState.config?.proxy)}
|
${this.renderConfigSection('proxy', 'Proxy', 'lucide:network', this.configState.config?.proxy)}
|
||||||
${this.renderConfigSection('security', 'Security Configuration', this.configState.config?.security)}
|
${this.renderConfigSection('security', 'Security', 'lucide:shield', this.configState.config?.security)}
|
||||||
` : html`
|
` : html`
|
||||||
<div class="errorMessage">No configuration loaded</div>
|
<div class="errorMessage">No configuration loaded</div>
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderConfigSection(key: string, title: string, config: any) {
|
private renderConfigSection(key: string, title: string, icon: string, config: any) {
|
||||||
const isEditing = this.editingSection === key;
|
const isEnabled = config?.enabled ?? false;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="configSection">
|
<div class="configSection">
|
||||||
<div class="sectionHeader">
|
<div class="sectionHeader">
|
||||||
<h3 class="sectionTitle">${title}</h3>
|
<h3 class="sectionTitle">
|
||||||
<div>
|
<dees-icon icon="${icon}"></dees-icon>
|
||||||
${isEditing ? html`
|
${title}
|
||||||
<dees-button
|
</h3>
|
||||||
@click=${() => this.saveConfig(key)}
|
${this.renderStatusBadge(isEnabled)}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sectionContent">
|
<div class="sectionContent">
|
||||||
${isEditing ? html`
|
${config ? this.renderConfigFields(config) : html`
|
||||||
<textarea
|
<div class="fieldValue empty">Not configured</div>
|
||||||
class="configEditor"
|
|
||||||
@input=${(e) => this.editedConfig = e.target.value}
|
|
||||||
.value=${JSON.stringify(config, null, 2)}
|
|
||||||
></textarea>
|
|
||||||
` : html`
|
|
||||||
${this.renderConfigFields(config)}
|
|
||||||
`}
|
`}
|
||||||
</div>
|
</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') {
|
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]) => {
|
return Object.entries(config).map(([key, value]) => {
|
||||||
const fieldName = prefix ? `${prefix}.${key}` : key;
|
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`
|
return html`
|
||||||
<div class="configField">
|
<div class="configField">
|
||||||
<label class="fieldLabel">${fieldName}</label>
|
<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)}
|
${this.renderConfigFields(value, fieldName)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle primitive values
|
||||||
return html`
|
return html`
|
||||||
<div class="configField">
|
<div class="configField">
|
||||||
<label class="fieldLabel">${fieldName}</label>
|
<label class="fieldLabel">${displayName}</label>
|
||||||
<div class="fieldValue">
|
<div class="fieldValue">${this.formatValue(value, key)}</div>
|
||||||
${Array.isArray(value) ? value.join(', ') : value}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private startEdit(section: string, config: any) {
|
private renderArrayValue(arr: any[], fieldKey: string): TemplateResult {
|
||||||
this.editingSection = section;
|
if (arr.length === 0) {
|
||||||
this.editedConfig = JSON.stringify(config, null, 2);
|
return html`<div class="fieldValue empty">None configured</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private cancelEdit() {
|
// Determine if we should show as pills/tags
|
||||||
this.editingSection = null;
|
const showAsPills = arr.every(item => typeof item === 'string' || typeof item === 'number');
|
||||||
this.editedConfig = null;
|
|
||||||
|
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>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveConfig(section: string) {
|
// For complex arrays, show as JSON
|
||||||
try {
|
return html`
|
||||||
const parsedConfig = JSON.parse(this.editedConfig);
|
<div class="fieldValue">
|
||||||
|
${arr.length} items configured
|
||||||
await appstate.configStatePart.dispatchAction(appstate.updateConfigurationAction, {
|
</div>
|
||||||
section,
|
`;
|
||||||
config: parsedConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.editingSection = null;
|
|
||||||
this.editedConfig = null;
|
|
||||||
|
|
||||||
// Configuration updated successfully
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error updating configuration:`, error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,8 +128,7 @@ interface IConfigState {
|
|||||||
- `loginAction` - Authenticate user
|
- `loginAction` - Authenticate user
|
||||||
- `logoutAction` - End session
|
- `logoutAction` - End session
|
||||||
- `fetchAllStatsAction` - Refresh all statistics
|
- `fetchAllStatsAction` - Refresh all statistics
|
||||||
- `fetchConfigurationAction` - Load configuration
|
- `fetchConfigurationAction` - Load configuration (read-only)
|
||||||
- `updateConfigurationAction` - Save configuration changes
|
|
||||||
|
|
||||||
## Client-Side Routing
|
## Client-Side Routing
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user