dsh-instruction-memory 1.3.3 → 1.4.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/lib/index.js CHANGED
@@ -26,12 +26,21 @@
26
26
  */
27
27
 
28
28
  import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
29
- import { watch } from 'node:fs'
29
+ import { readFileSync, watch } from 'node:fs'
30
30
  import { randomUUID } from 'node:crypto'
31
31
  import { isIP } from 'node:net'
32
32
  import { homedir, hostname } from 'node:os'
33
33
  import { basename, dirname, isAbsolute, join } from 'node:path'
34
34
  import { fileURLToPath } from 'node:url'
35
+ import {
36
+ FEEDBACK_ENV,
37
+ FEEDBACK_LIMITS,
38
+ buildFeedbackMessage,
39
+ feedbackCooldownMs,
40
+ feedbackConfigFrom,
41
+ normalizeFeedback,
42
+ sendSmtpMail,
43
+ } from './mailer.js'
35
44
 
36
45
  export const name = 'instruction-memory'
37
46
 
@@ -121,6 +130,31 @@ const BACKUP_FILE_SUFFIX = '.bak'
121
130
  */
122
131
  const SECTION_ORDER = 950
123
132
  const API_PREFIX = '/instruction-memory/api'
133
+ /**
134
+ * Feedback mail: the optional config file next to the store. Kept OUT of
135
+ * the repository on purpose — the operator's SMTP credentials must never be
136
+ * committed, and a published package must work with the environment variables
137
+ * alone.
138
+ */
139
+ const FEEDBACK_CONFIG_FILE_NAME = 'feedback.json'
140
+
141
+ /**
142
+ * The plugin version reported in a feedback mail. Read from the package rather
143
+ * than duplicated here: a hard-coded copy would drift from package.json on the
144
+ * first release and quietly mislabel every report.
145
+ *
146
+ * `../package.json` resolves inside an installed package because npm always
147
+ * ships package.json next to lib/. A broken install must not take the plugin
148
+ * down, so an unreadable manifest degrades to '未知'.
149
+ */
150
+ const PLUGIN_VERSION = (() => {
151
+ try {
152
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
153
+ return typeof manifest.version === 'string' && manifest.version !== '' ? manifest.version : '未知'
154
+ } catch {
155
+ return '未知'
156
+ }
157
+ })()
124
158
 
125
159
  /**
126
160
  * Resolve this plugin's data file under the harness home.
@@ -204,6 +238,10 @@ export const LIMITS = Object.freeze({
204
238
  minBudget: MIN_BUDGET,
205
239
  maxBudget: MAX_BUDGET,
206
240
  maxHistory: MAX_HISTORY,
241
+ /** Feedback form ceilings, enforced by lib/mailer.js and mirrored in the panel. */
242
+ maxFeedbackTitle: FEEDBACK_LIMITS.maxTitle,
243
+ maxFeedbackBody: FEEDBACK_LIMITS.maxBody,
244
+ maxFeedbackContact: FEEDBACK_LIMITS.maxContact,
207
245
  })
208
246
 
209
247
  /** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
@@ -2436,6 +2474,9 @@ export function apply(ctx) {
2436
2474
  maxHistory: MAX_HISTORY,
2437
2475
  maxBackupDir: MAX_BACKUP_DIR,
2438
2476
  maxBodyChars: MAX_BODY_CHARS,
2477
+ maxFeedbackTitle: FEEDBACK_LIMITS.maxTitle,
2478
+ maxFeedbackBody: FEEDBACK_LIMITS.maxBody,
2479
+ maxFeedbackContact: FEEDBACK_LIMITS.maxContact,
2439
2480
  },
2440
2481
  }
2441
2482
  }
@@ -3590,6 +3631,145 @@ export function apply(ctx) {
3590
3631
  return settle('导入完成:' + parts.join(';'), saved)
3591
3632
  })
3592
3633
 
3634
+ /* ---------------- feedback mail ---------------- */
3635
+
3636
+ /**
3637
+ * Wall-clock of the last accepted send. Anti-spam, not a security boundary:
3638
+ * the route is reachable from every same-origin page of the host, and on a
3639
+ * non-loopback bind from the whole LAN. One message per cooldown is enough
3640
+ * for a human reporting a problem and stops a double submit from sending two
3641
+ * mails. The recipient is fixed by configuration, so the route can never be
3642
+ * used to mail a third party.
3643
+ */
3644
+ let feedbackLastSentAt = 0
3645
+
3646
+ /**
3647
+ * Where the optional config file lives. `DSH_INSTRUCTION_MEMORY_FEEDBACK_CONFIG`
3648
+ * wins; otherwise the file sits next to the store (`<DSH_HOME>/instruction-memory/
3649
+ * feedback.json`), outside the repository and outside the published package.
3650
+ */
3651
+ const feedbackConfigPath = () => {
3652
+ const configured = typeof process.env[FEEDBACK_ENV.config] === 'string'
3653
+ ? process.env[FEEDBACK_ENV.config].trim()
3654
+ : ''
3655
+ return configured !== '' ? configured : join(dirname(resolveStorePath()), FEEDBACK_CONFIG_FILE_NAME)
3656
+ }
3657
+
3658
+ /**
3659
+ * Read the optional config file. A missing file is normal (environment-only
3660
+ * setups); an unreadable or malformed one is reported instead of being
3661
+ * silently ignored, because silently falling back to the environment is how a
3662
+ * typo sends mail from the wrong account.
3663
+ */
3664
+ const readFeedbackConfigFile = async (path) => {
3665
+ let text
3666
+ try {
3667
+ text = await readFile(path, 'utf8')
3668
+ } catch (err) {
3669
+ if (err !== null && typeof err === 'object' && err.code === 'ENOENT') return null
3670
+ throw new Error('无法读取反馈配置文件 ' + path + ':' + describeError(err))
3671
+ }
3672
+ try {
3673
+ return JSON.parse(text)
3674
+ } catch (err) {
3675
+ throw new Error('反馈配置文件不是有效的 JSON(' + path + '):' + describeError(err))
3676
+ }
3677
+ }
3678
+
3679
+ /**
3680
+ * The environment block of a report. Versions and platform come from the Host
3681
+ * (authoritative); locale / time zone / user agent are presentation facts the
3682
+ * panel reports, sanitised again inside the mailer before they reach a header
3683
+ * or the body. Instruction content is deliberately absent.
3684
+ */
3685
+ const feedbackEnvironment = (args) => {
3686
+ const field = (value) => (typeof value === 'string' ? value : '')
3687
+ return {
3688
+ pluginVersion: PLUGIN_VERSION,
3689
+ dshVersion: field(process.env.DSH_VERSION) || field(process.env.DSH_RUNTIME_VERSION),
3690
+ node: process.version,
3691
+ platform: process.platform,
3692
+ arch: process.arch,
3693
+ locale: field(args.locale),
3694
+ timezone: field(args.timezone),
3695
+ userAgent: field(args.userAgent),
3696
+ }
3697
+ }
3698
+
3699
+ /**
3700
+ * `send-feedback` handler. Deliberately returns a small `{ ok, message }`
3701
+ * envelope instead of the usual snapshot one: a send changes no persisted
3702
+ * state, and handing the panel back its own snapshot would make the form
3703
+ * absorb (and re-sync) the whole panel for nothing.
3704
+ */
3705
+ const sendFeedback = async (args) => {
3706
+ const normalized = normalizeFeedback(args)
3707
+ if (!normalized.ok) return { ok: false, message: normalized.message }
3708
+
3709
+ const cooldown = feedbackCooldownMs(process.env)
3710
+ const now = Date.now()
3711
+ if (cooldown > 0 && feedbackLastSentAt > 0 && now - feedbackLastSentAt < cooldown) {
3712
+ const waitSeconds = Math.max(1, Math.ceil((cooldown - (now - feedbackLastSentAt)) / 1000))
3713
+ return { ok: false, message: '反馈发送过于频繁,请 ' + waitSeconds + ' 秒后再试' }
3714
+ }
3715
+
3716
+ const configPath = feedbackConfigPath()
3717
+ let file = null
3718
+ try {
3719
+ file = await readFeedbackConfigFile(configPath)
3720
+ } catch (err) {
3721
+ logInfo('feedback config unusable: ' + describeError(err))
3722
+ return { ok: false, message: describeError(err) }
3723
+ }
3724
+ const readiness = feedbackConfigFrom({ env: process.env, file, configPath })
3725
+ if (!readiness.ok) {
3726
+ logDebug('feedback not configured: ' + readiness.message)
3727
+ return { ok: false, message: readiness.message }
3728
+ }
3729
+
3730
+ const feedback = normalized.value
3731
+ const message = buildFeedbackMessage({
3732
+ feedback,
3733
+ environment: feedbackEnvironment(args),
3734
+ from: readiness.config.from,
3735
+ to: readiness.config.to,
3736
+ now: new Date(),
3737
+ })
3738
+
3739
+ // SMTP servers sometimes echo the mailbox inside a rejection ("550 no such
3740
+ // user <addr>"). The panel never needs to show the maintainer's address
3741
+ // back, so both UI text and the log keep it masked.
3742
+ const maskAddresses = (text) => {
3743
+ let out = String(text)
3744
+ for (const address of [readiness.config.to, readiness.config.from]) {
3745
+ if (address !== '') out = out.split(address).join('[邮箱已隐藏]')
3746
+ }
3747
+ return out
3748
+ }
3749
+
3750
+ try {
3751
+ const sent = await sendSmtpMail({
3752
+ smtp: readiness.config.smtp,
3753
+ from: readiness.config.from,
3754
+ to: readiness.config.to,
3755
+ subject: message.subject,
3756
+ text: message.text,
3757
+ messageId: message.messageId,
3758
+ replyTo: message.replyTo,
3759
+ })
3760
+ feedbackLastSentAt = Date.now()
3761
+ logInfo('feedback sent: type=' + feedback.type + ' id=' + sent.messageId)
3762
+ return { ok: true, message: '反馈邮件已发送,感谢你的反馈!', messageId: sent.messageId }
3763
+ } catch (err) {
3764
+ // The failure reason is the only actionable thing a maintainer can act
3765
+ // on; it never contains the password (see mailer.js), and the configured
3766
+ // mailboxes are masked, so logging it is safe and makes reports debuggable.
3767
+ const reason = maskAddresses(describeError(err))
3768
+ logInfo('feedback send failed: ' + reason)
3769
+ return { ok: false, message: '反馈发送失败:' + reason + '(详细原因已写入宿主日志)' }
3770
+ }
3771
+ }
3772
+
3593
3773
  const dispatch = async (method, args) => {
3594
3774
  await boot
3595
3775
  switch (method) {
@@ -3663,6 +3843,8 @@ export function apply(ctx) {
3663
3843
  return importData(
3664
3844
  args !== null && typeof args === 'object' ? args.payload : null,
3665
3845
  args !== null && typeof args === 'object' ? args.baseRev : undefined)
3846
+ case 'send-feedback':
3847
+ return sendFeedback(args)
3666
3848
  case 'batch':
3667
3849
  return batchEntries(
3668
3850
  args !== null && typeof args === 'object' ? args.op : null,