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 +4 -0
- package/assets/whale-widget.js +68 -9
- package/lib/index.js +82 -21
- package/package.json +1 -1
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
|
- **今日已用显示 `--`**:先等一次成功的余额观测;统计从该观测时刻开始,起点之前的消费不在此区间内。
|
package/assets/whale-widget.js
CHANGED
|
@@ -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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
-
|
|
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
|
|
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
|
-
// ——
|
|
590
|
-
// dsh 的 connection 服务提供 requestRejection(req):Host/Origin 被伪造(DNS
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
|
|
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
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
}
|
|
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.
|
|
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.
|
|
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