dsh-whale-widget 0.3.10 → 0.3.11

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/README.md CHANGED
@@ -353,6 +353,10 @@ curl http://127.0.0.1:3080/dsh-whale/audio.json
353
353
  ## 常见问题
354
354
 
355
355
  - **挂件不出现**:确认安装命令成功;`dsh --profile web --dump-config` 里能看到 `dsh-whale-widget`;重启 `dsh web` 后 F5。
356
+ - **本插件所有接口(`/dsh-whale/*`)的访问校验**:默认只接受**回环地址**(`127.0.0.1` / `localhost` / `[::1]`)的请求,并拒绝跨站标记(`Sec-Fetch-Site: cross-site`)与 Origin 和 Host 不同源的请求 —— 这是防「恶意网页读写本机接口」的信任栅栏(issue #92 / #136)。如果你把 `dsh web` 放在**反向代理或局域网地址**后面,请用环境变量声明允许的 Host(逗号分隔,可带端口),否则会被 403:
357
+ ```bash
358
+ DSHW_TRUSTED_HOSTS=dsh.example.com,10.0.0.5:3080 dsh web
359
+ ```
356
360
  - **图片/音效不显示、没声音**:确认插件包内 `assets/` 完整(`DSniang1.png`、`*.mp3`、`minecraft-exp-orb.wav` 等);缺失时相关功能静默降级。
357
361
  - **余额报「未配置 DEEPSEEK_API_KEY」**:去 DSH 凭据里配置。
358
362
  - **今日已用显示 `--`**:先等一次成功的余额观测;统计从该观测时刻开始,起点之前的消费不在此区间内。
@@ -8722,8 +8722,35 @@ function visibleTopZ() {
8722
8722
  try { n = eff(getters[i]()) } catch (err) { n = 0 }
8723
8723
  if (n > top) top = n
8724
8724
  }
8725
+ // v756(issue #142):给第三方 fork / 以后的扩展模块留一个**运行时登记口** ——
8726
+ // 它们新增的浮层不必再回上游源码里插一行,只要 `window.dshwRegisterMask(el)` 登记一次,
8727
+ // 这里就会把它算进"当前可见最高层"(隐藏的会被 eff() 判成 0)。返回一个注销函数。
8728
+ try {
8729
+ var extra = dshwExtraMasks || []
8730
+ for (var k = 0; k < extra.length; k++) {
8731
+ var n2 = 0
8732
+ try { n2 = eff(extra[k]) } catch (err) { n2 = 0 }
8733
+ if (n2 > top) top = n2
8734
+ }
8735
+ } catch (err) {}
8725
8736
  return top
8726
8737
  }
8738
+ // 运行时登记的额外浮层(issue #142)。只存引用,不持有任何别的东西;登记失败的入口一律静默忽略。
8739
+ var dshwExtraMasks = []
8740
+ try {
8741
+ window.dshwRegisterMask = function (el) {
8742
+ try {
8743
+ if (!el || dshwExtraMasks.indexOf(el) >= 0) return function () {}
8744
+ dshwExtraMasks.push(el)
8745
+ return function () {
8746
+ try {
8747
+ var i = dshwExtraMasks.indexOf(el)
8748
+ if (i >= 0) dshwExtraMasks.splice(i, 1)
8749
+ } catch (err) {}
8750
+ }
8751
+ } catch (err) { return function () {} }
8752
+ }
8753
+ } catch (err) {}
8727
8754
  // 「永远在打开它的那个窗口之上」:取 本层段起点 与 当前可见最高层+10 的较大值。
8728
8755
  // 用在裁剪 / GIF / 音频裁剪 / 模块编辑器这些**既可能从主菜单(10000)打开、也可能从资源管理(20300)、
8729
8756
  // 泡泡编辑器(20500)、模型设置(29000)里打开**的窗口上 —— 固定层号在后者场景会被父窗口盖住。
@@ -12807,7 +12834,14 @@ function dshwvToast(msg) {
12807
12834
  }
12808
12835
  function configSaveFailNotice(detail) {
12809
12836
  try { console.error('[dsh-whale] 设置保存失败:', detail) } catch (err) {}
12810
- dshwvToast('⚠ 设置保存失败:' + String(detail || '').slice(0, 120) +
12837
+ // v756(issue #143):toast 是按 HTML 设计的(其余调用点传的字面量里带 `<br>`),而这里的 detail
12838
+ // 是**动态值**(服务端 JSON 回包的 error 字段 / fetch 异常消息),直接拼进 innerHTML 就是一处
12839
+ // 「动态数据进 HTML 位置」。当前来源都在本地信任边界内、构造不出真实利用,但把 provider 回包、
12840
+ // 模型名之类接进同一个 toast 时它会立刻变成真洞 —— 所以只转义这一个动态值,toast 自身的
12841
+ // `<br>` 保留。
12842
+ var safe = String(detail || '').slice(0, 120)
12843
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
12844
+ dshwvToast('⚠ 设置保存失败:' + safe +
12811
12845
  '<br>已自动重试一次。若持续失败,请检查 DSH 数据目录是否可写。')
12812
12846
  }
12813
12847
  function configPayload() {
@@ -14832,6 +14866,19 @@ function setupHitTest(url) {
14832
14866
  var dx = 610 - dw // right bottom
14833
14867
  var dy = 610 - dh
14834
14868
  ctx.drawImage(probe, dx, dy, dw, dh)
14869
+ // v756(issue #144):画布读回可能被浏览器**抹白** —— Firefox 开启「隐私保护 / resistFingerprinting」
14870
+ // 时 getImageData 会返回全透明(有些版本直接抛错)。那样 isWhaleHit 的 `data[3] > 10` 处处为假,
14871
+ // 而 hitReady 仍为 true ⇒ 走不到矩形回退分支 ⇒ **整只挂件点不动**。
14872
+ // 这里建立命中图时就扫一遍格子:一个不透明像素都取不到 → 判为命中图不可用,退回矩形判定。
14873
+ var opaque = 0
14874
+ for (var gy = 0; gy < 6; gy++) {
14875
+ for (var gx = 0; gx < 6; gx++) {
14876
+ var px = Math.min(609, Math.floor(dx + dw * (gx + 0.5) / 6))
14877
+ var py = Math.min(609, Math.floor(dy + dh * (gy + 0.5) / 6))
14878
+ if (ctx.getImageData(px, py, 1, 1).data[3] > 10) opaque++
14879
+ }
14880
+ }
14881
+ if (!opaque) { hitReady = false; hitFailed = true; return }
14835
14882
  hitReady = true
14836
14883
  } catch (err) {
14837
14884
  hitFailed = true
@@ -14845,18 +14892,21 @@ function setupHitTest(url) {
14845
14892
  probe.src = url || IMG_URL
14846
14893
  } catch (err) {}
14847
14894
  }
14895
+ // 退回「图像矩形」判定:命中图不可用(加载失败 / 画布读不出来)时只认挂件图片的矩形区域,
14896
+ // 绝不把整页当成命中区(那会让全页面点不动)。
14897
+ function whaleRectHit(e) {
14898
+ try {
14899
+ var fr = img.getBoundingClientRect()
14900
+ if (!fr || fr.width <= 0 || fr.height <= 0) return false
14901
+ return e.clientX >= fr.left && e.clientX <= fr.right && e.clientY >= fr.top && e.clientY <= fr.bottom
14902
+ } catch (err) { return false }
14903
+ }
14848
14904
  function isWhaleHit(e) {
14849
14905
  // 命中图未就绪/失败时:绝不默认“全屏都是鲸鱼”。
14850
14906
  // 加载中 → 返回 false(不拦截页面);加载失败 → 退回图像矩形区域,仅挂件区域可拖。
14851
14907
  if (!hitCanvas || !hitReady) {
14852
14908
  if (!hitFailed) return false
14853
- try {
14854
- var fr = img.getBoundingClientRect()
14855
- if (!fr || fr.width <= 0 || fr.height <= 0) return false
14856
- return e.clientX >= fr.left && e.clientX <= fr.right && e.clientY >= fr.top && e.clientY <= fr.bottom
14857
- } catch (err) {
14858
- return false
14859
- }
14909
+ return whaleRectHit(e)
14860
14910
  }
14861
14911
  try {
14862
14912
  var r = img.getBoundingClientRect()
@@ -14865,7 +14915,16 @@ function isWhaleHit(e) {
14865
14915
  var ly = (e.clientY - r.top) / r.height * 610
14866
14916
  if (lx < 0 || ly < 0 || lx >= 610 || ly >= 610) return false
14867
14917
  if (state.flip) lx = 610 - lx
14868
- var data = hitCanvas.getContext('2d').getImageData(Math.floor(lx), Math.floor(ly), 1, 1).data
14918
+ var data
14919
+ try {
14920
+ data = hitCanvas.getContext('2d').getImageData(Math.floor(lx), Math.floor(ly), 1, 1).data
14921
+ } catch (err) {
14922
+ // v756(issue #144):画布读不出来(隐私保护/跨域污染)→ 立刻降级为矩形判定,
14923
+ // 并且**记住**这个状态,后续调用直接走矩形,不再每次抛错。
14924
+ hitReady = false
14925
+ hitFailed = true
14926
+ return whaleRectHit(e)
14927
+ }
14869
14928
  return data[3] > 10
14870
14929
  } catch (err) {
14871
14930
  return false
package/lib/index.js CHANGED
@@ -586,28 +586,89 @@ export default {
586
586
  name: 'whale-balance-widget',
587
587
  inject: ['webServer', 'credentials', 'connection'],
588
588
  apply(ctx) {
589
- // —— 浏览器信任栅栏(对应 issue #92)——
590
- // dsh 的 connection 服务提供 requestRejection(req):Host/Origin 被伪造(DNS 重绑定)
591
- // 或未认证的请求必须被拒。官方插件(如 dsh-host-open-in-app)在自定义路由里都会先调用;
592
- // 本插件所有 /dsh-whale/* 路由同样必须走一遍,否则接口可被任意网页读写。
593
- function rejected(req, res) {
589
+ // —— 浏览器信任栅栏(issue #92 建立,issue #136 补 fail-closed)——
590
+ // dsh 的 connection 服务提供 requestRejection(req):Host/Origin 被伪造(DNS 重绑定)或未认证的
591
+ // 请求必须被拒。**但这个方法在较老的 dsh 上不存在**(#136 实测:0.1.5-rc.2/rc.3 有、0.1.0-rc.3 没有),
592
+ // 而旧实现在"服务缺失"与"栅栏自己抛异常"两条路径上都是 fail-open(放行)—— 于是 22 条 /dsh-whale/*
593
+ // 路由会**集体**失去校验:伪造 Host 可读写、普通跨站 POST 可直接写配置,而 api-models.json 还连着
594
+ // ctx.credentials(可 set/delete key)。现在改成三层:
595
+ // ① **插件自己的轻量校验**(永远先跑;正常浏览器与本地脚本都不受影响):
596
+ // Host 必须是回环权威或 DSHW_TRUSTED_HOSTS 里显式声明的,`Sec-Fetch-Site: cross-site` 一律拒,
597
+ // 带 Origin 时必须与 Host 同源;
598
+ // ② 宿主栅栏可用 → **委托**它(不重写它的语义:它里面还有一层浏览器鉴权 401),返回码照用;
599
+ // **它抛异常则按拒绝处理**(旧实现这条路径静默放行,且连 warn 都没有);
600
+ // ③ 宿主栅栏不可用 → 只靠 ①,并在**插件加载时**就打一条醒目 warn(不是等第一次请求才打)。
601
+ const TRUSTED_AUTHORITIES = String(process.env.DSHW_TRUSTED_HOSTS || '')
602
+ .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
603
+ // 只认回环:localhost / *.localhost / 127.0.0.0/8(逐段校验,防 `127.0.0.1.evil.com` 这类相似域名)/ ::1
604
+ function isLoopbackHostname(hn) {
605
+ const h = String(hn || '').toLowerCase().replace(/^\[/, '').replace(/\]$/, '')
606
+ if (!h) return false
607
+ if (h === 'localhost' || h.endsWith('.localhost')) return true
608
+ if (h === '::1') return true
609
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
610
+ if (!m) return false
611
+ if (Number(m[1]) !== 127) return false
612
+ return [m[2], m[3], m[4]].every((x) => Number(x) <= 255)
613
+ }
614
+ // 通过 → null;否则返回应当拒绝的状态码
615
+ function selfRejection(req) {
594
616
  try {
595
- const conn = ctx.get('connection') || ctx.connection
596
- if (!conn || typeof conn.requestRejection !== 'function') {
597
- // fail-open:此刻栅栏不可用(connection 服务缺失)。inject 已声明该服务,正常不会走到这里;
598
- // 打一条 warn 便于发现"栅栏实际失效",而不是静默放行
599
- if (!rejected.warned) {
600
- rejected.warned = true
601
- try { console.warn('[whale-balance] 信任栅栏不可用:connection 服务缺失,自定义路由将放行处理') } catch (err) {}
602
- }
603
- return false
617
+ const headers = (req && req.headers) || {}
618
+ let hostUrl = null
619
+ try { hostUrl = new URL('http://' + String(headers.host || '')) } catch (err) { return 403 } // 缺 Host / 畸形 → 拒
620
+ const hostname = hostUrl.hostname
621
+ const authority = hostUrl.host
622
+ if (!isLoopbackHostname(hostname)) {
623
+ const listed = TRUSTED_AUTHORITIES.some((e) => (e.indexOf(':') >= 0 ? e === authority.toLowerCase() : e === hostname.toLowerCase()))
624
+ if (!listed) return 403
625
+ }
626
+ const site = String(headers['sec-fetch-site'] || '').toLowerCase()
627
+ if (site === 'cross-site') return 403
628
+ const origin = headers.origin
629
+ if (typeof origin === 'string' && origin && origin !== 'null') {
630
+ let originUrl = null
631
+ try { originUrl = new URL(origin) } catch (err) { return 403 }
632
+ if (originUrl.host.toLowerCase() !== authority.toLowerCase()) return 403
604
633
  }
605
- const code = conn.requestRejection(req)
606
- if (code === undefined || code === null || code === false) return false
607
- res.statusCode = typeof code === 'number' ? code : 403
608
- res.end()
634
+ return null
635
+ } catch (err) { return 403 } // 校验器自身出错 → 拒绝(fail-closed)
636
+ }
637
+ function connectionFence() {
638
+ try {
639
+ const conn = ctx.get('connection') || ctx.connection
640
+ return conn && typeof conn.requestRejection === 'function' ? conn : null
641
+ } catch (err) { return null }
642
+ }
643
+ if (!connectionFence()) {
644
+ try {
645
+ console.warn('[whale-balance] 宿主信任栅栏不可用(connection.requestRejection 缺失):已启用插件自带的回环/同源校验,'
646
+ + '所有路由(含写接口)仍受它保护;若本机 dsh 部署在非回环地址(反代/局域网),请用环境变量 '
647
+ + 'DSHW_TRUSTED_HOSTS 声明允许的 Host(逗号分隔,可带端口)。')
648
+ } catch (err) {}
649
+ }
650
+ function rejected(req, res) {
651
+ const deny = (code) => {
652
+ try { res.statusCode = code || 403; res.end() } catch (err) {}
609
653
  return true
610
- } catch (err) { return false }
654
+ }
655
+ const self = selfRejection(req)
656
+ if (self !== null) return deny(self)
657
+ const conn = connectionFence()
658
+ if (!conn) return false // ① 已通过;此刻没有宿主栅栏可委托(加载时已 warn)
659
+ let code
660
+ try {
661
+ code = conn.requestRejection(req)
662
+ } catch (err) {
663
+ // 旧实现这里是 `return false`:栅栏自己抛异常被当成"没被拒"而静默放行(#136 的第二条路径)
664
+ if (!rejected.warned) {
665
+ rejected.warned = true
666
+ try { console.warn('[whale-balance] 信任栅栏抛异常,已按拒绝处理:' + String((err && err.message) || err)) } catch (e2) {}
667
+ }
668
+ return deny(403)
669
+ }
670
+ if (code === undefined || code === null || code === false) return false
671
+ return deny(typeof code === 'number' ? code : 403)
611
672
  }
612
673
  // 统一注册入口:所有路由自动套上信任栅栏
613
674
  function registerRoute(route) {
@@ -985,7 +1046,7 @@ export default {
985
1046
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
986
1047
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
987
1048
  return {
988
- ok: true, version: '0.3.10', today: todayData, days7,
1049
+ ok: true, version: '0.3.11', today: todayData, days7,
989
1050
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
990
1051
  all: {
991
1052
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -2185,7 +2246,7 @@ export default {
2185
2246
  const summary = daySummary(led, todayKey())
2186
2247
  const nowSec = Math.floor(Date.now() / 1000)
2187
2248
  return {
2188
- ...visible, version: '0.3.10', isPeak: isPeakTime(nowSec),
2249
+ ...visible, version: '0.3.11', isPeak: isPeakTime(nowSec),
2189
2250
  // 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
2190
2251
  peakNextChangeAt: nextPeakChangeAt(nowSec),
2191
2252
  peakHolidays: HOLIDAY_VALLEY_LIST,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",