dsh-sidecard-ask 1.8.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +176 -0
- package/README.md +125 -15
- package/client.js +649 -46
- package/index.js +718 -13
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -32,8 +32,10 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
35
|
+
import net from 'node:net'
|
|
35
36
|
import { homedir } from 'node:os'
|
|
36
37
|
import { join } from 'node:path'
|
|
38
|
+
import tls from 'node:tls'
|
|
37
39
|
|
|
38
40
|
/** Plugin id: the bundle row id, the Client module id, and the log tag. */
|
|
39
41
|
export const name = 'dsh-sidecard-ask'
|
|
@@ -42,7 +44,7 @@ export const name = 'dsh-sidecard-ask'
|
|
|
42
44
|
export const inject = ['webServer']
|
|
43
45
|
|
|
44
46
|
/** Version of this plugin (kept in step with package.json by test/verify.mjs). */
|
|
45
|
-
export const PLUGIN_VERSION = '
|
|
47
|
+
export const PLUGIN_VERSION = '2.0.0'
|
|
46
48
|
|
|
47
49
|
/** Route prefix of the plugin's own API. */
|
|
48
50
|
export const ROUTE_PREFIX = '/sidecard-ask/api'
|
|
@@ -75,10 +77,12 @@ export const DEFAULT_CONFIG = {
|
|
|
75
77
|
sideProvider: 'auto',
|
|
76
78
|
/**
|
|
77
79
|
* Floating-layer (A) surface. `capsule` = one-line pill that never covers
|
|
78
|
-
* the conversation, `full` = the classic card stack
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
80
|
+
* the conversation, `full` = the classic card stack. (The former `off`
|
|
81
|
+
* "hidden behind a small launcher" shape was removed in 2.0.0; a stored
|
|
82
|
+
* `off` now fails validation and falls back to this default.) The geometry
|
|
83
|
+
* for both shapes lives in `client.js`: the layer is anchored to the frame's
|
|
84
|
+
* right edge and never crosses the conversation column's right boundary
|
|
85
|
+
* (see `FLOAT_*` there).
|
|
82
86
|
*/
|
|
83
87
|
floatMode: 'capsule',
|
|
84
88
|
/** Hard cap, in CSS px, for the floating layer's width. */
|
|
@@ -152,6 +156,35 @@ export const DEFAULT_CONFIG = {
|
|
|
152
156
|
* or half-written block simply yields no chips.
|
|
153
157
|
*/
|
|
154
158
|
followUpSuggestions: false,
|
|
159
|
+
/**
|
|
160
|
+
* Recipient of the settings page's 「反馈」 mail.
|
|
161
|
+
*
|
|
162
|
+
* The default is the maintainer's real inbox, stored as `b64:<base64>` so a
|
|
163
|
+
* plain `name@domain` string cannot be grepped out of the published tarball.
|
|
164
|
+
* That is OBFUSCATION, NOT ENCRYPTION — `decodeAddress()` ships in this same
|
|
165
|
+
* file, so anyone holding the package can recover it; it only defeats naive
|
|
166
|
+
* scraping. `DSH_SIDECARD_FEEDBACK_TO` (or the shared `DSH_CRB_FEEDBACK_TO`
|
|
167
|
+
* from dsh-conversation-recycle-bin) wins over this value, and an empty value
|
|
168
|
+
* disables the mailbox again.
|
|
169
|
+
*/
|
|
170
|
+
feedbackAddress: 'b64:MzQzMzI3NjU3N0BxcS5jb20=',
|
|
171
|
+
/**
|
|
172
|
+
* SMTP transport, editable in the settings page so a normal user never has to
|
|
173
|
+
* touch environment variables.
|
|
174
|
+
*
|
|
175
|
+
* These live in the USER layer only (`<DSH_HOME>/sidecard-ask/config.json`,
|
|
176
|
+
* outside the repository, never in a release artifact). The environment still
|
|
177
|
+
* works and still wins nothing: an empty host means "read the environment".
|
|
178
|
+
* The password is write-only over the wire: `/state` never returns it, an
|
|
179
|
+
* empty value on save means "keep the stored one", and the settings page only
|
|
180
|
+
* learns whether one exists.
|
|
181
|
+
*/
|
|
182
|
+
feedbackSmtpHost: '',
|
|
183
|
+
feedbackSmtpPort: 465,
|
|
184
|
+
feedbackSmtpSecure: true,
|
|
185
|
+
feedbackSmtpUser: '',
|
|
186
|
+
feedbackSmtpPass: '',
|
|
187
|
+
feedbackSmtpFrom: '',
|
|
155
188
|
}
|
|
156
189
|
|
|
157
190
|
/** Bounds for the `quickActions` user layer (defense in depth). */
|
|
@@ -177,7 +210,7 @@ const ENUMS = {
|
|
|
177
210
|
sideSurface: ['auto', 'native-rightbar', 'better-sidebar', 'flow'],
|
|
178
211
|
captureZones: ['auto', 'chat', 'task', 'chat+task'],
|
|
179
212
|
sideTools: ['readonly', 'inherit'],
|
|
180
|
-
floatMode: ['capsule', 'full'
|
|
213
|
+
floatMode: ['capsule', 'full'],
|
|
181
214
|
answerStyle: ['concise', 'standard', 'detailed'],
|
|
182
215
|
}
|
|
183
216
|
|
|
@@ -414,6 +447,48 @@ export function sanitizeLayer(raw, problems = []) {
|
|
|
414
447
|
if (points >= 0 && points <= MAX_CUSTOM_PERSONA_CHARS) out.customPersona = raw.customPersona
|
|
415
448
|
else problems.push(`customPersona: 必须是 ≤${MAX_CUSTOM_PERSONA_CHARS} 字符的字符串,已忽略`)
|
|
416
449
|
}
|
|
450
|
+
if (raw.feedbackAddress !== undefined) {
|
|
451
|
+
// Empty is a legal value ("mail feedback disabled"); a non-empty value must
|
|
452
|
+
// resolve to a mailbox so a typo cannot silently black-hole every report.
|
|
453
|
+
// `b64:`-encoded values are accepted and validated after decoding.
|
|
454
|
+
const value = typeof raw.feedbackAddress === 'string' ? raw.feedbackAddress.trim() : null
|
|
455
|
+
if (value !== null && (value === '' || looksLikeEmail(decodeAddress(value)))) {
|
|
456
|
+
out.feedbackAddress = value
|
|
457
|
+
} else {
|
|
458
|
+
problems.push('feedbackAddress: 必须是合法邮箱、b64: 编码的邮箱或空字符串,已忽略')
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (raw.feedbackSmtpHost !== undefined) {
|
|
462
|
+
const value = typeof raw.feedbackSmtpHost === 'string' ? raw.feedbackSmtpHost.trim() : null
|
|
463
|
+
if (value !== null && value.length <= 255) out.feedbackSmtpHost = value
|
|
464
|
+
else problems.push('feedbackSmtpHost: 必须是 1–255 字符的字符串,已忽略')
|
|
465
|
+
}
|
|
466
|
+
if (raw.feedbackSmtpPort !== undefined) {
|
|
467
|
+
const clamped = clampNumber(raw.feedbackSmtpPort, 1, 65_535)
|
|
468
|
+
if (clamped === undefined) problems.push('feedbackSmtpPort: 必须是 1–65535 的整数,已忽略')
|
|
469
|
+
else out.feedbackSmtpPort = clamped
|
|
470
|
+
}
|
|
471
|
+
if (raw.feedbackSmtpSecure !== undefined) {
|
|
472
|
+
if (typeof raw.feedbackSmtpSecure === 'boolean') out.feedbackSmtpSecure = raw.feedbackSmtpSecure
|
|
473
|
+
else problems.push('feedbackSmtpSecure: 必须是布尔值,已忽略')
|
|
474
|
+
}
|
|
475
|
+
if (raw.feedbackSmtpUser !== undefined) {
|
|
476
|
+
const value = typeof raw.feedbackSmtpUser === 'string' ? raw.feedbackSmtpUser.trim() : null
|
|
477
|
+
// Not forced to be a mailbox: some relays hand out a bare account name. An
|
|
478
|
+
// address-shaped user still matters, because it becomes the default `From:`.
|
|
479
|
+
if (value !== null && value.length <= 254) out.feedbackSmtpUser = value
|
|
480
|
+
else problems.push('feedbackSmtpUser: 必须是 1–254 字符的字符串,已忽略')
|
|
481
|
+
}
|
|
482
|
+
if (raw.feedbackSmtpPass !== undefined) {
|
|
483
|
+
const value = typeof raw.feedbackSmtpPass === 'string' ? raw.feedbackSmtpPass : null
|
|
484
|
+
if (value !== null && value.length <= 512) out.feedbackSmtpPass = value
|
|
485
|
+
else problems.push('feedbackSmtpPass: 必须是 1–512 字符的字符串,已忽略')
|
|
486
|
+
}
|
|
487
|
+
if (raw.feedbackSmtpFrom !== undefined) {
|
|
488
|
+
const value = typeof raw.feedbackSmtpFrom === 'string' ? raw.feedbackSmtpFrom.trim() : null
|
|
489
|
+
if (value !== null && (value === '' || looksLikeEmail(value))) out.feedbackSmtpFrom = value
|
|
490
|
+
else problems.push('feedbackSmtpFrom: 必须是合法邮箱或空字符串,已忽略')
|
|
491
|
+
}
|
|
417
492
|
for (const boolKey of ['showInUnclassified', 'notifyOnDone', 'contextMenu', 'followUpSuggestions', 'dockRail']) {
|
|
418
493
|
if (raw[boolKey] === undefined) continue
|
|
419
494
|
if (typeof raw[boolKey] === 'boolean') out[boolKey] = raw[boolKey]
|
|
@@ -501,6 +576,535 @@ export function buildSidePrompt(input) {
|
|
|
501
576
|
return parts.join('\n').slice(0, MAX_PROMPT_CHARS)
|
|
502
577
|
}
|
|
503
578
|
|
|
579
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
580
|
+
// Feedback mail
|
|
581
|
+
//
|
|
582
|
+
// The settings page can mail one short report to the maintainer. Two rules
|
|
583
|
+
// shape this section:
|
|
584
|
+
// 1. Neither the recipient nor a credential lives in the repository: the
|
|
585
|
+
// recipient comes from the user layer (`feedbackAddress`) or the
|
|
586
|
+
// `DSH_SIDECARD_FEEDBACK_TO` variable, the SMTP endpoint from
|
|
587
|
+
// `DSH_SIDECARD_SMTP_URL` (or the discrete `DSH_SIDECARD_SMTP_*` pair).
|
|
588
|
+
// 2. No dependency is added: the SMTP dialogue below runs on `node:net` and
|
|
589
|
+
// `node:tls` only, because the Host half may import nothing but node:
|
|
590
|
+
// built-ins (see the module header). A plugin that cannot reach an SMTP
|
|
591
|
+
// relay still works: the Client offers to copy the report text.
|
|
592
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
/** Bounds for one feedback mail, mirrored by the Client's own form checks. */
|
|
595
|
+
export const FEEDBACK_LIMITS = {
|
|
596
|
+
titleMin: 4,
|
|
597
|
+
titleMax: 120,
|
|
598
|
+
bodyMin: 10,
|
|
599
|
+
bodyMax: 4000,
|
|
600
|
+
contactMax: 120,
|
|
601
|
+
/** At most one mail per gap, and at most `maxPerWindow` per window. */
|
|
602
|
+
minGapMs: 30_000,
|
|
603
|
+
windowMs: 3_600_000,
|
|
604
|
+
maxPerWindow: 5,
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Accepted report kinds (the Client localizes their labels). */
|
|
608
|
+
export const FEEDBACK_KINDS = ['bug', 'idea', 'question', 'other']
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Pragmatic mailbox check: one `@`, a bounded local part, at least one dot in
|
|
612
|
+
* the domain, no whitespace/controls and none of the characters that would
|
|
613
|
+
* let a value break out of a `MAIL FROM:<...>` command. It deliberately does
|
|
614
|
+
* not try to be RFC 5322 — the relay is the final authority.
|
|
615
|
+
*/
|
|
616
|
+
const EMAIL_PATTERN = /^[^\s@<>()[\]\\,;:"/]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/
|
|
617
|
+
|
|
618
|
+
/** Marker that a stored address is base64-encoded (see DEFAULT_CONFIG). */
|
|
619
|
+
export const ENCODED_ADDRESS_PREFIX = 'b64:'
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Decode a `b64:`-encoded address; any other value is returned trimmed.
|
|
623
|
+
* Obfuscation only: the decoder ships with the package, so this hides the
|
|
624
|
+
* address from naive scraping, never from a reader of the source.
|
|
625
|
+
* @param {unknown} raw - a stored or supplied address.
|
|
626
|
+
* @returns {string} the address, or '' when the value is empty/undecodable.
|
|
627
|
+
*/
|
|
628
|
+
export function decodeAddress(raw) {
|
|
629
|
+
if (typeof raw !== 'string') return ''
|
|
630
|
+
const value = raw.trim()
|
|
631
|
+
if (!value.startsWith(ENCODED_ADDRESS_PREFIX)) return value
|
|
632
|
+
try {
|
|
633
|
+
return Buffer.from(value.slice(ENCODED_ADDRESS_PREFIX.length), 'base64').toString('utf8').trim()
|
|
634
|
+
} catch {
|
|
635
|
+
return ''
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Where the recipient may come from, in priority order. The second name of
|
|
641
|
+
* each pair is shared with dsh-conversation-recycle-bin: both plugins report
|
|
642
|
+
* to the same inbox through the same relay, so one export configures both.
|
|
643
|
+
*/
|
|
644
|
+
export const FEEDBACK_TO_ENV_KEYS = ['DSH_SIDECARD_FEEDBACK_TO', 'DSH_CRB_FEEDBACK_TO']
|
|
645
|
+
/** SMTP endpoint variables, in priority order (own name first, shared second). */
|
|
646
|
+
export const SMTP_URL_ENV_KEYS = ['DSH_SIDECARD_SMTP_URL', 'DSH_CRB_SMTP_URL']
|
|
647
|
+
|
|
648
|
+
/** Whether `raw` can be used as a mailbox address. */
|
|
649
|
+
export function looksLikeEmail(raw) {
|
|
650
|
+
if (typeof raw !== 'string') return false
|
|
651
|
+
const value = raw.trim()
|
|
652
|
+
return value.length > 2 && value.length <= 254 && EMAIL_PATTERN.test(value)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** `alice@example.com` → `a***@example.com` (safe to log and to echo). */
|
|
656
|
+
export function maskEmail(raw) {
|
|
657
|
+
if (typeof raw !== 'string') return ''
|
|
658
|
+
const value = raw.trim()
|
|
659
|
+
const at = value.lastIndexOf('@')
|
|
660
|
+
if (at <= 0) return value === '' ? '' : '***'
|
|
661
|
+
return `${value.slice(0, 1)}***${value.slice(at)}`
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Count code points: a 120-emoji title is 120 characters to the author. */
|
|
665
|
+
function codePoints(text) {
|
|
666
|
+
return Array.from(text).length
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Validate and shrink one feedback payload.
|
|
671
|
+
* @param {unknown} raw - the request body.
|
|
672
|
+
* @returns {{ok: true, value: object} | {ok: false, message: string}}
|
|
673
|
+
*/
|
|
674
|
+
export function sanitizeFeedbackInput(raw) {
|
|
675
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
676
|
+
return { ok: false, message: '反馈内容必须是对象' }
|
|
677
|
+
}
|
|
678
|
+
const kind = FEEDBACK_KINDS.includes(raw.kind) ? raw.kind : 'other'
|
|
679
|
+
const title = typeof raw.title === 'string' ? raw.title.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim() : ''
|
|
680
|
+
const body = typeof raw.body === 'string' ? raw.body.replace(/\r\n/g, '\n').trim() : ''
|
|
681
|
+
const contact = typeof raw.contact === 'string' ? raw.contact.trim() : ''
|
|
682
|
+
if (codePoints(title) < FEEDBACK_LIMITS.titleMin || codePoints(title) > FEEDBACK_LIMITS.titleMax) {
|
|
683
|
+
return { ok: false, message: `标题需 ${FEEDBACK_LIMITS.titleMin}–${FEEDBACK_LIMITS.titleMax} 个字符` }
|
|
684
|
+
}
|
|
685
|
+
// A CR/LF in a header-bound field is header injection; the title is the only
|
|
686
|
+
// value that reaches a header, and this is the cheap second line of defence.
|
|
687
|
+
if (/[\r\n]/.test(title)) return { ok: false, message: '标题不能包含换行' }
|
|
688
|
+
if (codePoints(body) < FEEDBACK_LIMITS.bodyMin || codePoints(body) > FEEDBACK_LIMITS.bodyMax) {
|
|
689
|
+
return { ok: false, message: `详细描述需 ${FEEDBACK_LIMITS.bodyMin}–${FEEDBACK_LIMITS.bodyMax} 个字符` }
|
|
690
|
+
}
|
|
691
|
+
if (codePoints(contact) > FEEDBACK_LIMITS.contactMax) {
|
|
692
|
+
return { ok: false, message: `联系方式最多 ${FEEDBACK_LIMITS.contactMax} 个字符` }
|
|
693
|
+
}
|
|
694
|
+
// The field is the reporter's own mailbox: it becomes the mail's `Reply-To:`,
|
|
695
|
+
// so a value that is not an address would produce a header the relay rejects.
|
|
696
|
+
// Empty stays legal — an anonymous report simply gets no Reply-To.
|
|
697
|
+
if (contact !== '' && !looksLikeEmail(contact)) {
|
|
698
|
+
return { ok: false, message: '联系方式需要填邮箱地址(会作为回信地址),留空即匿名' }
|
|
699
|
+
}
|
|
700
|
+
return { ok: true, value: { kind, title, body, contact } }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Parse one `smtp://` / `smtps://` URL into a connection descriptor.
|
|
705
|
+
*
|
|
706
|
+
* `smtps://user:pass@host:465` is implicit TLS; `smtp://user:pass@host:587`
|
|
707
|
+
* upgrades with STARTTLS (pass `?starttls=0` for a plain local relay). The
|
|
708
|
+
* sender defaults to the authenticating user; `?from=` overrides it.
|
|
709
|
+
* @param {string} raw - the URL.
|
|
710
|
+
* @returns {{ok: true, endpoint: object} | {ok: false, reason: string}}
|
|
711
|
+
*/
|
|
712
|
+
export function parseSmtpUrl(raw) {
|
|
713
|
+
if (typeof raw !== 'string' || raw.trim() === '') return { ok: false, reason: '未配置 SMTP 地址' }
|
|
714
|
+
let url
|
|
715
|
+
try {
|
|
716
|
+
url = new URL(raw.trim())
|
|
717
|
+
} catch {
|
|
718
|
+
return { ok: false, reason: 'SMTP 地址不是合法 URL' }
|
|
719
|
+
}
|
|
720
|
+
if (url.protocol !== 'smtp:' && url.protocol !== 'smtps:') {
|
|
721
|
+
return { ok: false, reason: 'SMTP 地址必须以 smtp:// 或 smtps:// 开头' }
|
|
722
|
+
}
|
|
723
|
+
const secure = url.protocol === 'smtps:'
|
|
724
|
+
const host = url.hostname
|
|
725
|
+
if (host === '') return { ok: false, reason: 'SMTP 地址缺少主机名' }
|
|
726
|
+
const port = url.port === '' ? (secure ? 465 : 587) : Number(url.port)
|
|
727
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) return { ok: false, reason: 'SMTP 端口不在 1–65535 之间' }
|
|
728
|
+
const user = decodeURIComponent(url.username ?? '')
|
|
729
|
+
const pass = decodeURIComponent(url.password ?? '')
|
|
730
|
+
if (user === '' || pass === '') return { ok: false, reason: 'SMTP 地址需要 user:pass' }
|
|
731
|
+
const from = (url.searchParams.get('from') ?? '').trim() || user
|
|
732
|
+
if (!looksLikeEmail(from)) return { ok: false, reason: '发件邮箱格式不合法(由 ?from= 指定)' }
|
|
733
|
+
const starttls = secure ? false : url.searchParams.get('starttls') !== '0'
|
|
734
|
+
return { ok: true, endpoint: { host, port, secure, starttls, user, pass, from } }
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Resolve the configured SMTP endpoint from the environment.
|
|
739
|
+
*
|
|
740
|
+
* `DSH_SIDECARD_SMTP_URL` wins. Otherwise the discrete
|
|
741
|
+
* `DSH_SIDECARD_SMTP_{HOST,PORT,USER,PASS,SECURE,FROM}` variables are read, so
|
|
742
|
+
* a password that contains `@`/`:` never has to be URL-encoded by hand.
|
|
743
|
+
*/
|
|
744
|
+
export function readSmtpFromEnv(env = process.env) {
|
|
745
|
+
// `DSH_CRB_SMTP_URL` is accepted as a fallback: the sibling plugin
|
|
746
|
+
// dsh-conversation-recycle-bin documents that exact variable, and both report
|
|
747
|
+
// to the same relay, so an operator only has to configure it once.
|
|
748
|
+
let url = ''
|
|
749
|
+
for (const key of SMTP_URL_ENV_KEYS) {
|
|
750
|
+
const value = typeof env?.[key] === 'string' ? env[key].trim() : ''
|
|
751
|
+
if (value !== '') {
|
|
752
|
+
url = value
|
|
753
|
+
break
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (url !== '') return parseSmtpUrl(url)
|
|
757
|
+
const host = typeof env.DSH_SIDECARD_SMTP_HOST === 'string' ? env.DSH_SIDECARD_SMTP_HOST.trim() : ''
|
|
758
|
+
if (host === '') return { ok: false, reason: '未配置 SMTP(DSH_SIDECARD_SMTP_URL / DSH_CRB_SMTP_URL 或 DSH_SIDECARD_SMTP_HOST)' }
|
|
759
|
+
const secureFlag = typeof env.DSH_SIDECARD_SMTP_SECURE === 'string' ? env.DSH_SIDECARD_SMTP_SECURE.trim().toLowerCase() : ''
|
|
760
|
+
const secure = secureFlag === '1' || secureFlag === 'true' || secureFlag === 'yes'
|
|
761
|
+
const port = env.DSH_SIDECARD_SMTP_PORT === undefined ? (secure ? 465 : 587) : Number(env.DSH_SIDECARD_SMTP_PORT)
|
|
762
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) return { ok: false, reason: 'DSH_SIDECARD_SMTP_PORT 不合法' }
|
|
763
|
+
const user = typeof env.DSH_SIDECARD_SMTP_USER === 'string' ? env.DSH_SIDECARD_SMTP_USER.trim() : ''
|
|
764
|
+
const pass = typeof env.DSH_SIDECARD_SMTP_PASS === 'string' ? env.DSH_SIDECARD_SMTP_PASS : ''
|
|
765
|
+
if (user === '' || pass === '') return { ok: false, reason: '缺少 DSH_SIDECARD_SMTP_USER / DSH_SIDECARD_SMTP_PASS' }
|
|
766
|
+
const from = (typeof env.DSH_SIDECARD_SMTP_FROM === 'string' ? env.DSH_SIDECARD_SMTP_FROM.trim() : '') || user
|
|
767
|
+
if (!looksLikeEmail(from)) return { ok: false, reason: 'DSH_SIDECARD_SMTP_FROM 不是合法邮箱' }
|
|
768
|
+
return { ok: true, endpoint: { host, port, secure, starttls: !secure, user, pass, from } }
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Resolve the plaintext recipient. SERVER-SIDE ONLY: this value must never be
|
|
773
|
+
* put into a response body or rendered by the Client — the settings page only
|
|
774
|
+
* ever receives `toMasked` plus the `hasRecipient` flag.
|
|
775
|
+
* @param {Record<string, unknown>} config - the effective config.
|
|
776
|
+
* @param {Record<string, string|undefined>} [env] - the process environment.
|
|
777
|
+
* @returns {string} the address, or '' when unconfigured.
|
|
778
|
+
*/
|
|
779
|
+
export function feedbackRecipient(config = {}, env = process.env) {
|
|
780
|
+
// Environment wins: a deployment must be able to redirect reports without
|
|
781
|
+
// editing the packaged default (which is a real inbox, see DEFAULT_CONFIG).
|
|
782
|
+
for (const key of FEEDBACK_TO_ENV_KEYS) {
|
|
783
|
+
const value = typeof env?.[key] === 'string' ? env[key].trim() : ''
|
|
784
|
+
if (value !== '') return decodeAddress(value)
|
|
785
|
+
}
|
|
786
|
+
return decodeAddress(typeof config.feedbackAddress === 'string' ? config.feedbackAddress.trim() : '')
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Resolve the SMTP endpoint the settings page can see and edit.
|
|
791
|
+
*
|
|
792
|
+
* **The environment wins over the page**, and that order is load-bearing: an
|
|
793
|
+
* operator who exported `DSH_SIDECARD_SMTP_URL` (or the shared
|
|
794
|
+
* `DSH_CRB_SMTP_URL`) must keep working even after somebody half-filled the SMTP
|
|
795
|
+
* fields in the settings page — a config blob with a host but no password used
|
|
796
|
+
* to mask a perfectly complete environment and report "not configured", which is
|
|
797
|
+
* exactly how a working setup turns into "反馈不好用".
|
|
798
|
+
* @param {Record<string, unknown>} config - the effective config.
|
|
799
|
+
* @param {Record<string, string|undefined>} [env] - the process environment.
|
|
800
|
+
* @returns {{ok: true, source: 'config'|'env', endpoint: object} | {ok: false, reason: string}}
|
|
801
|
+
*/
|
|
802
|
+
export function resolveSmtp(config = {}, env = process.env) {
|
|
803
|
+
const fromEnv = readSmtpFromEnv(env)
|
|
804
|
+
if (fromEnv.ok === true) return { ...fromEnv, source: 'env' }
|
|
805
|
+
const host = typeof config.feedbackSmtpHost === 'string' ? config.feedbackSmtpHost.trim() : ''
|
|
806
|
+
// Nothing in the page either: report what the environment is missing, which is
|
|
807
|
+
// the more useful of the two reasons.
|
|
808
|
+
if (host === '') return fromEnv
|
|
809
|
+
const secure = config.feedbackSmtpSecure !== false
|
|
810
|
+
const port = clampNumber(config.feedbackSmtpPort, 1, 65_535) ?? (secure ? 465 : 587)
|
|
811
|
+
const user = typeof config.feedbackSmtpUser === 'string' ? config.feedbackSmtpUser.trim() : ''
|
|
812
|
+
const pass = typeof config.feedbackSmtpPass === 'string' ? config.feedbackSmtpPass : ''
|
|
813
|
+
if (user === '' || pass === '') return { ok: false, reason: '设置页里的 SMTP 账号或密码还没填全' }
|
|
814
|
+
const from = (typeof config.feedbackSmtpFrom === 'string' ? config.feedbackSmtpFrom.trim() : '') || user
|
|
815
|
+
if (!looksLikeEmail(from)) return { ok: false, reason: 'SMTP 发件邮箱格式不合法' }
|
|
816
|
+
return { ok: true, source: 'config', endpoint: { host, port, secure, starttls: !secure, user, pass, from } }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* The SMTP settings as the page may see them: everything except the password,
|
|
821
|
+
* which is reduced to a boolean.
|
|
822
|
+
*/
|
|
823
|
+
export function describeSmtpSettings(config = {}, env = process.env) {
|
|
824
|
+
const resolved = resolveSmtp(config, env)
|
|
825
|
+
return {
|
|
826
|
+
host: typeof config.feedbackSmtpHost === 'string' ? config.feedbackSmtpHost.trim() : '',
|
|
827
|
+
port: clampNumber(config.feedbackSmtpPort, 1, 65_535) ?? 465,
|
|
828
|
+
secure: config.feedbackSmtpSecure !== false,
|
|
829
|
+
user: typeof config.feedbackSmtpUser === 'string' ? config.feedbackSmtpUser.trim() : '',
|
|
830
|
+
from: typeof config.feedbackSmtpFrom === 'string' ? config.feedbackSmtpFrom.trim() : '',
|
|
831
|
+
hasPassword: typeof config.feedbackSmtpPass === 'string' && config.feedbackSmtpPass !== '',
|
|
832
|
+
source: resolved.ok === true ? resolved.source : '',
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Where a report would go, in a shape the settings page can render.
|
|
838
|
+
* Never contains a password, and never the plaintext address: `toMasked` is all
|
|
839
|
+
* the Client gets, deliberately.
|
|
840
|
+
* @param {Record<string, unknown>} config - the effective config.
|
|
841
|
+
* @param {Record<string, string|undefined>} [env] - the process environment.
|
|
842
|
+
*/
|
|
843
|
+
export function describeFeedback(config = {}, env = process.env) {
|
|
844
|
+
const to = feedbackRecipient(config, env)
|
|
845
|
+
const base = {
|
|
846
|
+
transport: 'none',
|
|
847
|
+
configured: false,
|
|
848
|
+
hasRecipient: to !== '',
|
|
849
|
+
toMasked: maskEmail(to),
|
|
850
|
+
fromMasked: '',
|
|
851
|
+
reason: '',
|
|
852
|
+
limits: FEEDBACK_LIMITS,
|
|
853
|
+
smtp: describeSmtpSettings(config, env),
|
|
854
|
+
}
|
|
855
|
+
if (to === '') return { ...base, reason: '未配置收件邮箱:设置 feedbackAddress 或 DSH_SIDECARD_FEEDBACK_TO / DSH_CRB_FEEDBACK_TO' }
|
|
856
|
+
if (!looksLikeEmail(to)) return { ...base, reason: '收件邮箱格式不合法' }
|
|
857
|
+
const smtp = resolveSmtp(config, env)
|
|
858
|
+
if (smtp.ok !== true) return { ...base, reason: smtp.reason }
|
|
859
|
+
return { ...base, transport: 'smtp', configured: true, fromMasked: maskEmail(smtp.endpoint.from), reason: '' }
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** RFC 2047 encode one header value (the subject may be Chinese). */
|
|
863
|
+
function encodeHeader(value) {
|
|
864
|
+
return `=?UTF-8?B?${Buffer.from(String(value), 'utf8').toString('base64')}?=`
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Compose the RFC 5322 message the relay will carry.
|
|
869
|
+
*
|
|
870
|
+
* The Subject carries the plugin name and version so a maintainer can triage
|
|
871
|
+
* without opening the body; every body label is ASCII because the Host half is
|
|
872
|
+
* language neutral (the Client localizes its own UI).
|
|
873
|
+
* @returns {{subject: string, text: string, raw: string}}
|
|
874
|
+
*/
|
|
875
|
+
export function composeFeedbackMail({ version, input, environment = '', now = new Date(), to, from }) {
|
|
876
|
+
const subject = `[dsh-sidecard-ask ${version}] ${input.kind}: ${input.title}`
|
|
877
|
+
const lines = [
|
|
878
|
+
`Plugin: dsh-sidecard-ask v${version}`,
|
|
879
|
+
`Kind: ${input.kind}`,
|
|
880
|
+
`Title: ${input.title}`,
|
|
881
|
+
`Contact: ${input.contact === '' ? '(not provided)' : input.contact}`,
|
|
882
|
+
'',
|
|
883
|
+
'Body:',
|
|
884
|
+
input.body,
|
|
885
|
+
'',
|
|
886
|
+
'--- environment (attached automatically) ---',
|
|
887
|
+
environment,
|
|
888
|
+
]
|
|
889
|
+
const text = lines.join('\r\n')
|
|
890
|
+
const encodedBody = Buffer.from(text, 'utf8').toString('base64').replace(/(.{76})/g, '$1\r\n')
|
|
891
|
+
const messageId = `<${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}@dsh-sidecard-ask>`
|
|
892
|
+
// `From:` stays the authenticated account: QQ/163/Gmail reject a sender the
|
|
893
|
+
// account does not own. The reporter's own address rides along as `Reply-To:`
|
|
894
|
+
// instead, so "reply" reaches the person who filed the report. The value was
|
|
895
|
+
// validated as a mailbox and holds no CR/LF, so it cannot inject headers.
|
|
896
|
+
const replyTo = looksLikeEmail(input.contact ?? '') ? [`Reply-To: ${String(input.contact).trim()}`] : []
|
|
897
|
+
const raw = [
|
|
898
|
+
`From: ${from}`,
|
|
899
|
+
`To: ${to}`,
|
|
900
|
+
...replyTo,
|
|
901
|
+
`Subject: ${encodeHeader(subject)}`,
|
|
902
|
+
`Date: ${now.toUTCString()}`,
|
|
903
|
+
`Message-ID: ${messageId}`,
|
|
904
|
+
'MIME-Version: 1.0',
|
|
905
|
+
'Content-Type: text/plain; charset=UTF-8',
|
|
906
|
+
'Content-Transfer-Encoding: base64',
|
|
907
|
+
'',
|
|
908
|
+
encodedBody,
|
|
909
|
+
].join('\r\n')
|
|
910
|
+
return { subject, text, raw }
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** A compact, non-identifying environment block (no hostname, no user name). */
|
|
914
|
+
export function describeEnvironment({ version, dshVersion = '', now = new Date() }) {
|
|
915
|
+
return [
|
|
916
|
+
`plugin: ${version}`,
|
|
917
|
+
`dsh: ${dshVersion === '' ? 'unknown' : dshVersion}`,
|
|
918
|
+
`node: ${process.version}`,
|
|
919
|
+
`platform: ${process.platform} ${process.arch}`,
|
|
920
|
+
`at: ${now.toISOString()}`,
|
|
921
|
+
].join('\n')
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Send-rate gate: one mail per `minGapMs`, at most `maxPerWindow` per window.
|
|
926
|
+
* Every send is recorded only after it actually went out, so a relay outage
|
|
927
|
+
* never burns the user's quota.
|
|
928
|
+
* @param {{minGapMs?: number, windowMs?: number, maxPerWindow?: number}} [options]
|
|
929
|
+
* @param {() => number} [now] - clock injection for tests.
|
|
930
|
+
*/
|
|
931
|
+
export function createFeedbackGate(options = {}, now = () => Date.now()) {
|
|
932
|
+
const minGapMs = options.minGapMs ?? FEEDBACK_LIMITS.minGapMs
|
|
933
|
+
const windowMs = options.windowMs ?? FEEDBACK_LIMITS.windowMs
|
|
934
|
+
const maxPerWindow = options.maxPerWindow ?? FEEDBACK_LIMITS.maxPerWindow
|
|
935
|
+
const sent = []
|
|
936
|
+
return {
|
|
937
|
+
/** @returns {{ok: true} | {ok: false, code: string, message: string, retryAfterMs: number}} */
|
|
938
|
+
check() {
|
|
939
|
+
const at = now()
|
|
940
|
+
while (sent.length > 0 && at - sent[0] > windowMs) sent.shift()
|
|
941
|
+
if (sent.length >= maxPerWindow) {
|
|
942
|
+
return { ok: false, code: 'feedback-too-many', message: '反馈发送过于频繁,请稍后再试', retryAfterMs: Math.max(0, windowMs - (at - sent[0])) }
|
|
943
|
+
}
|
|
944
|
+
const last = sent.length > 0 ? sent[sent.length - 1] : 0
|
|
945
|
+
if (last !== 0 && at - last < minGapMs) {
|
|
946
|
+
return { ok: false, code: 'feedback-too-fast', message: '刚发过一封,请稍等半分钟再发', retryAfterMs: minGapMs - (at - last) }
|
|
947
|
+
}
|
|
948
|
+
return { ok: true }
|
|
949
|
+
},
|
|
950
|
+
/** Record a mail that the relay accepted. */
|
|
951
|
+
record() { sent.push(now()) },
|
|
952
|
+
/** Number of mails inside the current window (diagnostics/tests). */
|
|
953
|
+
get size() { return sent.length },
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Minimal SMTP reader: buffered `\r\n` lines plus whole-reply assembly, so a
|
|
959
|
+
* multi-line greeting (`250-…` / `250 …`) is never mistaken for several
|
|
960
|
+
* commands' answers.
|
|
961
|
+
*/
|
|
962
|
+
function attachSmtpReader(socket) {
|
|
963
|
+
const buffered = []
|
|
964
|
+
const waiters = []
|
|
965
|
+
let buffer = ''
|
|
966
|
+
let failure = null
|
|
967
|
+
const onData = (chunk) => {
|
|
968
|
+
buffer += chunk.toString('utf8')
|
|
969
|
+
let index = buffer.indexOf('\r\n')
|
|
970
|
+
while (index >= 0) {
|
|
971
|
+
const line = buffer.slice(0, index)
|
|
972
|
+
buffer = buffer.slice(index + 2)
|
|
973
|
+
const waiter = waiters.shift()
|
|
974
|
+
if (waiter === undefined) buffered.push(line)
|
|
975
|
+
else waiter.resolve(line)
|
|
976
|
+
index = buffer.indexOf('\r\n')
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
const fail = (error) => {
|
|
980
|
+
failure = failure ?? error
|
|
981
|
+
while (waiters.length > 0) waiters.shift().reject(failure)
|
|
982
|
+
}
|
|
983
|
+
const onError = (error) => { fail(error) }
|
|
984
|
+
const onClose = () => { fail(Object.assign(new Error('SMTP 连接已关闭'), { code: 'smtp-closed' })) }
|
|
985
|
+
socket.on('data', onData)
|
|
986
|
+
socket.on('error', onError)
|
|
987
|
+
socket.on('close', onClose)
|
|
988
|
+
return {
|
|
989
|
+
async line() {
|
|
990
|
+
if (buffered.length > 0) return buffered.shift()
|
|
991
|
+
if (failure !== null) throw failure
|
|
992
|
+
return new Promise((resolve, reject) => { waiters.push({ resolve, reject }) })
|
|
993
|
+
},
|
|
994
|
+
/** One whole reply: `{code, text}` where `text` keeps every line. */
|
|
995
|
+
async reply() {
|
|
996
|
+
let text = ''
|
|
997
|
+
let code = 0
|
|
998
|
+
for (;;) {
|
|
999
|
+
const line = await this.line()
|
|
1000
|
+
const parsed = Number(line.slice(0, 3))
|
|
1001
|
+
if (Number.isFinite(parsed) && parsed !== 0) code = parsed
|
|
1002
|
+
text = text === '' ? line : `${text}\n${line}`
|
|
1003
|
+
if (line.charAt(3) !== '-') break
|
|
1004
|
+
}
|
|
1005
|
+
return { code, text }
|
|
1006
|
+
},
|
|
1007
|
+
dispose() {
|
|
1008
|
+
socket.removeListener('data', onData)
|
|
1009
|
+
socket.removeListener('error', onError)
|
|
1010
|
+
socket.removeListener('close', onClose)
|
|
1011
|
+
},
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Open one TCP/TLS connection, resolving only once the transport is up. */
|
|
1016
|
+
function openSmtpSocket(endpoint, timeoutMs) {
|
|
1017
|
+
return new Promise((resolve, reject) => {
|
|
1018
|
+
const onError = (error) => { socket.destroy(); reject(error) }
|
|
1019
|
+
const socket = endpoint.secure
|
|
1020
|
+
? tls.connect({ host: endpoint.host, port: endpoint.port, servername: endpoint.host })
|
|
1021
|
+
: net.connect({ host: endpoint.host, port: endpoint.port })
|
|
1022
|
+
socket.once('error', onError)
|
|
1023
|
+
socket.setTimeout(timeoutMs, () => {
|
|
1024
|
+
socket.destroy()
|
|
1025
|
+
reject(Object.assign(new Error(`SMTP 连接超时(${timeoutMs}ms)`), { code: 'smtp-timeout' }))
|
|
1026
|
+
})
|
|
1027
|
+
socket.once(endpoint.secure ? 'secureConnect' : 'connect', () => {
|
|
1028
|
+
socket.removeListener('error', onError)
|
|
1029
|
+
socket.setTimeout(0)
|
|
1030
|
+
resolve(socket)
|
|
1031
|
+
})
|
|
1032
|
+
})
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** Run one SMTP verb and fail the whole dialogue when its reply is not 2xx/3xx. */
|
|
1036
|
+
async function smtpCommand(reader, socket, line, accept = [2, 3]) {
|
|
1037
|
+
if (line !== null) socket.write(`${line}\r\n`)
|
|
1038
|
+
const reply = await reader.reply()
|
|
1039
|
+
if (!accept.includes(Math.floor(reply.code / 100))) {
|
|
1040
|
+
throw Object.assign(new Error(`SMTP 拒绝:${reply.text}`), { code: 'smtp-rejected', reply: reply.text, status: reply.code })
|
|
1041
|
+
}
|
|
1042
|
+
return reply
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Deliver one already-composed message through a real SMTP dialogue.
|
|
1047
|
+
*
|
|
1048
|
+
* Sequence: greeting → EHLO → (STARTTLS) → EHLO → AUTH PLAIN (fallback LOGIN)
|
|
1049
|
+
* → MAIL FROM → RCPT TO → DATA → QUIT. `endpoint.pass` never appears in an
|
|
1050
|
+
* error message or a log line.
|
|
1051
|
+
* @returns {Promise<{ok: true, acceptedAt: string}>}
|
|
1052
|
+
*/
|
|
1053
|
+
export async function sendSmtpMessage(endpoint, mail, { timeoutMs = 20_000, now = () => new Date() } = {}) {
|
|
1054
|
+
let socket = await openSmtpSocket(endpoint, timeoutMs)
|
|
1055
|
+
let reader = attachSmtpReader(socket)
|
|
1056
|
+
const deadline = setTimeout(() => { socket.destroy() }, timeoutMs)
|
|
1057
|
+
deadline.unref?.()
|
|
1058
|
+
try {
|
|
1059
|
+
await smtpCommand(reader, socket, null)
|
|
1060
|
+
const identify = `EHLO dsh-sidecard-ask`
|
|
1061
|
+
const greeting = await smtpCommand(reader, socket, identify)
|
|
1062
|
+
// Only upgrade when the server says it can. A relay that never advertises
|
|
1063
|
+
// STARTTLS answers 500 to the verb, and a 587 endpoint on a local network
|
|
1064
|
+
// would then look "broken" for a step nobody asked for.
|
|
1065
|
+
if (endpoint.secure === false && endpoint.starttls !== false && /\bSTARTTLS\b/i.test(greeting.text)) {
|
|
1066
|
+
await smtpCommand(reader, socket, 'STARTTLS', [2])
|
|
1067
|
+
reader.dispose()
|
|
1068
|
+
const upgraded = await new Promise((resolve, reject) => {
|
|
1069
|
+
const secureSocket = tls.connect({ socket, servername: endpoint.host }, () => resolve(secureSocket))
|
|
1070
|
+
secureSocket.once('error', reject)
|
|
1071
|
+
})
|
|
1072
|
+
socket = upgraded
|
|
1073
|
+
reader = attachSmtpReader(socket)
|
|
1074
|
+
await smtpCommand(reader, socket, identify)
|
|
1075
|
+
}
|
|
1076
|
+
const token = Buffer.from(`\u0000${endpoint.user}\u0000${endpoint.pass}`, 'utf8').toString('base64')
|
|
1077
|
+
// PLAIN is one round trip; LOGIN is the fallback for relays that only
|
|
1078
|
+
// speak it. A rejected PLAIN leaves the session usable, so retrying is safe.
|
|
1079
|
+
let accepted = false
|
|
1080
|
+
try {
|
|
1081
|
+
const reply = await smtpCommand(reader, socket, `AUTH PLAIN ${token}`, [2, 3])
|
|
1082
|
+
if (Math.floor(reply.code / 100) === 3) await smtpCommand(reader, socket, token, [2])
|
|
1083
|
+
accepted = true
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
if (error?.code !== 'smtp-rejected') throw error
|
|
1086
|
+
}
|
|
1087
|
+
if (accepted === false) {
|
|
1088
|
+
await smtpCommand(reader, socket, 'AUTH LOGIN')
|
|
1089
|
+
await smtpCommand(reader, socket, Buffer.from(endpoint.user, 'utf8').toString('base64'), [3])
|
|
1090
|
+
await smtpCommand(reader, socket, Buffer.from(endpoint.pass, 'utf8').toString('base64'), [2])
|
|
1091
|
+
}
|
|
1092
|
+
await smtpCommand(reader, socket, `MAIL FROM:<${endpoint.from}>`)
|
|
1093
|
+
await smtpCommand(reader, socket, `RCPT TO:<${mail.to}>`)
|
|
1094
|
+
await smtpCommand(reader, socket, 'DATA', [3])
|
|
1095
|
+
// Dot-stuffing: a body line starting with "." would end DATA early.
|
|
1096
|
+
const payload = mail.raw.replace(/^\./gm, '..')
|
|
1097
|
+
socket.write(`${payload}\r\n.\r\n`)
|
|
1098
|
+
await smtpCommand(reader, socket, null)
|
|
1099
|
+
try { await smtpCommand(reader, socket, 'QUIT') } catch { /* the mail is already accepted */ }
|
|
1100
|
+
return { ok: true, acceptedAt: now().toISOString() }
|
|
1101
|
+
} finally {
|
|
1102
|
+
clearTimeout(deadline)
|
|
1103
|
+
reader.dispose()
|
|
1104
|
+
socket.destroy()
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
504
1108
|
/**
|
|
505
1109
|
* Reduce any thrown value to a wire error the Client can present.
|
|
506
1110
|
* @param {unknown} error - the thrown value.
|
|
@@ -618,12 +1222,13 @@ export function isTrustedRequest(req, trustedHosts = []) {
|
|
|
618
1222
|
// ────────────────────────────────────────────────────────────────────────────
|
|
619
1223
|
|
|
620
1224
|
/** Write one JSON response. */
|
|
621
|
-
function writeJson(res, status, value) {
|
|
1225
|
+
function writeJson(res, status, value, extraHeaders = {}) {
|
|
622
1226
|
const body = JSON.stringify(value)
|
|
623
1227
|
res.writeHead(status, {
|
|
624
1228
|
'content-type': 'application/json; charset=utf-8',
|
|
625
1229
|
'cache-control': 'no-store',
|
|
626
1230
|
'content-length': Buffer.byteLength(body),
|
|
1231
|
+
...extraHeaders,
|
|
627
1232
|
})
|
|
628
1233
|
res.end(body)
|
|
629
1234
|
}
|
|
@@ -633,9 +1238,15 @@ function writeOk(res, value) {
|
|
|
633
1238
|
writeJson(res, 200, { ok: true, value })
|
|
634
1239
|
}
|
|
635
1240
|
|
|
636
|
-
/**
|
|
637
|
-
|
|
638
|
-
|
|
1241
|
+
/**
|
|
1242
|
+
* Write one `{ok:false,error}` response.
|
|
1243
|
+
*
|
|
1244
|
+
* `extraHeaders` exists because `writeHead` replaces the header bag: a value
|
|
1245
|
+
* set with `setHeader` before this call would be dropped (the 429 throttle
|
|
1246
|
+
* relies on `retry-after` surviving).
|
|
1247
|
+
*/
|
|
1248
|
+
function writeError(res, status, code, message, extraHeaders = {}) {
|
|
1249
|
+
writeJson(res, status, { ok: false, error: { code, message } }, extraHeaders)
|
|
639
1250
|
}
|
|
640
1251
|
|
|
641
1252
|
/**
|
|
@@ -1377,6 +1988,25 @@ export function apply(ctx, patchConfig) {
|
|
|
1377
1988
|
*/
|
|
1378
1989
|
let clientReport = null
|
|
1379
1990
|
|
|
1991
|
+
/** Send-rate gate for `/feedback` (one mail per 30s, five per hour). */
|
|
1992
|
+
const feedbackGate = createFeedbackGate()
|
|
1993
|
+
|
|
1994
|
+
/**
|
|
1995
|
+
* Best-effort DSH version for the feedback environment block.
|
|
1996
|
+
* Every lookup is optional: a missing service must not fail the send.
|
|
1997
|
+
*/
|
|
1998
|
+
const dshVersionOf = () => {
|
|
1999
|
+
try {
|
|
2000
|
+
const runtime = ctx.get('webRuntime')
|
|
2001
|
+
const version = runtime?.version ?? runtime?.harnessVersion
|
|
2002
|
+
if (typeof version === 'string' && version !== '') return version
|
|
2003
|
+
} catch {
|
|
2004
|
+
/* optional service */
|
|
2005
|
+
}
|
|
2006
|
+
const fromEnv = typeof process.env.DSH_VERSION === 'string' ? process.env.DSH_VERSION.trim() : ''
|
|
2007
|
+
return fromEnv
|
|
2008
|
+
}
|
|
2009
|
+
|
|
1380
2010
|
/** Allow-listed, size-bounded view of one Client report. */
|
|
1381
2011
|
function sanitizeClientReport(raw) {
|
|
1382
2012
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
@@ -1442,10 +2072,16 @@ export function apply(ctx, patchConfig) {
|
|
|
1442
2072
|
|
|
1443
2073
|
/** `/state` — the Client's single source of truth at boot and after a save. */
|
|
1444
2074
|
const handleState = (res) => {
|
|
2075
|
+
// Two secrets never reach the page: the recipient stays server-side (the page
|
|
2076
|
+
// gets `hasRecipient` and `toMasked` only), and the SMTP password is
|
|
2077
|
+
// write-only (the page only learns whether one is stored).
|
|
2078
|
+
const publicConfig = { ...state.config }
|
|
2079
|
+
delete publicConfig.feedbackAddress
|
|
2080
|
+
delete publicConfig.feedbackSmtpPass
|
|
1445
2081
|
writeOk(res, {
|
|
1446
2082
|
plugin: name,
|
|
1447
2083
|
version: PLUGIN_VERSION,
|
|
1448
|
-
config:
|
|
2084
|
+
config: publicConfig,
|
|
1449
2085
|
problems: state.problems,
|
|
1450
2086
|
provenance: {
|
|
1451
2087
|
// Computed live: a save in this process must be reflected immediately.
|
|
@@ -1459,6 +2095,9 @@ export function apply(ctx, patchConfig) {
|
|
|
1459
2095
|
...engine.capabilities(),
|
|
1460
2096
|
// Informational: which process-local stream the host bridges.
|
|
1461
2097
|
streamSource: 'agent/assistant-stream',
|
|
2098
|
+
// Where a settings-page report would go, and why it cannot (if so).
|
|
2099
|
+
// Never carries a password; addresses are masked for display.
|
|
2100
|
+
feedback: describeFeedback(state.config),
|
|
1462
2101
|
},
|
|
1463
2102
|
// The Client half's last self-report (null until it posts one).
|
|
1464
2103
|
client: clientReport,
|
|
@@ -1489,6 +2128,66 @@ export function apply(ctx, patchConfig) {
|
|
|
1489
2128
|
writeOk(res, { accepted: true, at: report.at })
|
|
1490
2129
|
}
|
|
1491
2130
|
|
|
2131
|
+
/**
|
|
2132
|
+
* `/feedback` — mail one settings-page report to the configured recipient.
|
|
2133
|
+
*
|
|
2134
|
+
* Failure codes are the Client's contract: `feedback-unconfigured` (no
|
|
2135
|
+
* recipient or no SMTP), `feedback-invalid` (bounds), `feedback-too-fast` /
|
|
2136
|
+
* `feedback-too-many` (gate), `feedback-send-failed` (relay refused). The report body is never
|
|
2137
|
+
* logged; only kind, masked recipient and outcome are.
|
|
2138
|
+
*/
|
|
2139
|
+
const handleFeedback = async (req, res) => {
|
|
2140
|
+
let body
|
|
2141
|
+
try {
|
|
2142
|
+
body = await readJsonBody(req)
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
writeError(res, 400, toWireError(error, 'bad-request').code, toWireError(error).message)
|
|
2145
|
+
return
|
|
2146
|
+
}
|
|
2147
|
+
const input = sanitizeFeedbackInput(body)
|
|
2148
|
+
if (input.ok !== true) {
|
|
2149
|
+
writeError(res, 400, 'feedback-invalid', input.message)
|
|
2150
|
+
return
|
|
2151
|
+
}
|
|
2152
|
+
const status = describeFeedback(state.config)
|
|
2153
|
+
if (status.configured !== true) {
|
|
2154
|
+
writeError(res, 503, 'feedback-unconfigured', status.reason === '' ? '未配置反馈邮箱' : status.reason)
|
|
2155
|
+
return
|
|
2156
|
+
}
|
|
2157
|
+
const gate = feedbackGate.check()
|
|
2158
|
+
if (gate.ok !== true) {
|
|
2159
|
+
const retryAfter = String(Math.max(1, Math.ceil((gate.retryAfterMs ?? 1000) / 1000)))
|
|
2160
|
+
writeError(res, 429, gate.code, gate.message, { 'retry-after': retryAfter })
|
|
2161
|
+
return
|
|
2162
|
+
}
|
|
2163
|
+
const smtp = resolveSmtp(state.config)
|
|
2164
|
+
if (smtp.ok !== true) {
|
|
2165
|
+
writeError(res, 503, 'feedback-unconfigured', smtp.reason)
|
|
2166
|
+
return
|
|
2167
|
+
}
|
|
2168
|
+
// Plaintext recipient stays in this scope; it never reaches a response body.
|
|
2169
|
+
const to = feedbackRecipient(state.config)
|
|
2170
|
+
const now = new Date()
|
|
2171
|
+
const mail = composeFeedbackMail({
|
|
2172
|
+
version: PLUGIN_VERSION,
|
|
2173
|
+
input: input.value,
|
|
2174
|
+
environment: describeEnvironment({ version: PLUGIN_VERSION, dshVersion: dshVersionOf(), now }),
|
|
2175
|
+
now,
|
|
2176
|
+
to,
|
|
2177
|
+
from: smtp.endpoint.from,
|
|
2178
|
+
})
|
|
2179
|
+
try {
|
|
2180
|
+
await sendSmtpMessage(smtp.endpoint, { to, raw: mail.raw, subject: mail.subject })
|
|
2181
|
+
feedbackGate.record()
|
|
2182
|
+
ctx.logger?.info?.(`[sidecard-ask] feedback sent to ${maskEmail(to)} kind=${input.value.kind}`)
|
|
2183
|
+
writeOk(res, { sent: true, at: now.toISOString(), transport: 'smtp', toMasked: maskEmail(to) })
|
|
2184
|
+
} catch (error) {
|
|
2185
|
+
const wire = toWireError(error, 'feedback-send-failed')
|
|
2186
|
+
ctx.logger?.warn?.(`[sidecard-ask] feedback failed to ${maskEmail(to)}: ${wire.code} ${wire.message}`)
|
|
2187
|
+
writeError(res, 502, 'feedback-send-failed', wire.message)
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
1492
2191
|
/** `/ask` — one SSE stream per side answer. */
|
|
1493
2192
|
const handleAsk = async (req, res) => {
|
|
1494
2193
|
let body
|
|
@@ -1549,7 +2248,12 @@ export function apply(ctx, patchConfig) {
|
|
|
1549
2248
|
return
|
|
1550
2249
|
}
|
|
1551
2250
|
const problems = []
|
|
1552
|
-
|
|
2251
|
+
// The SMTP password is write-only: `/state` never hands it back, so the
|
|
2252
|
+
// settings form always submits an empty string for an untouched password.
|
|
2253
|
+
// Treat that empty value as "leave it alone" instead of erasing it.
|
|
2254
|
+
const patch = { ...body }
|
|
2255
|
+
if (patch.feedbackSmtpPass === '') delete patch.feedbackSmtpPass
|
|
2256
|
+
const accepted = sanitizeLayer(patch, problems)
|
|
1553
2257
|
if (problems.length > 0) {
|
|
1554
2258
|
writeError(res, 400, 'invalid-config', problems.join(';'))
|
|
1555
2259
|
return
|
|
@@ -1576,7 +2280,7 @@ export function apply(ctx, patchConfig) {
|
|
|
1576
2280
|
* The HTTP methods the plugin serves; kept in one place because the route
|
|
1577
2281
|
* registration has two shapes (see below).
|
|
1578
2282
|
*/
|
|
1579
|
-
const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose']
|
|
2283
|
+
const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
|
|
1580
2284
|
|
|
1581
2285
|
/** One request → one response; the dispatcher owns path parsing and the fence. */
|
|
1582
2286
|
const routeHandler = async (req, res) => {
|
|
@@ -1610,6 +2314,7 @@ export function apply(ctx, patchConfig) {
|
|
|
1610
2314
|
else if (method === 'config') await handleConfig(req, res)
|
|
1611
2315
|
else if (method === 'reset') handleReset(req, res)
|
|
1612
2316
|
else if (method === 'diagnose') await handleDiagnose(req, res)
|
|
2317
|
+
else if (method === 'feedback') await handleFeedback(req, res)
|
|
1613
2318
|
else writeError(res, 404, 'not-found', `未知接口 "${method}"`)
|
|
1614
2319
|
} catch (error) {
|
|
1615
2320
|
const wire = toWireError(error)
|