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/CHANGELOG.md +32 -0
- package/README.md +124 -6
- package/lib/client.js +281 -2
- package/lib/index.js +183 -1
- package/lib/mailer.js +810 -0
- package/package.json +2 -2
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,
|