dsh-mini-remote 0.2.0 → 0.2.1
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 +41 -0
- package/README.en.md +198 -147
- package/README.md +198 -154
- package/client/client.js +1931 -3
- package/lib/build.js +32 -1
- package/lib/devices.js +372 -0
- package/lib/index.js +1625 -32
- package/lib/mirror.js +296 -3
- package/lib/page.html +938 -71
- package/lib/page.js +4 -1
- package/lib/remotes.js +279 -0
- package/lib/server.js +937 -55
- package/package.json +1 -1
package/lib/mirror.js
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
*/
|
|
39
39
|
import { request as httpRequest } from 'node:http'
|
|
40
40
|
import { connect as netConnect } from 'node:net'
|
|
41
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
41
42
|
import { gunzipSync, inflateSync, brotliDecompressSync } from 'node:zlib'
|
|
42
43
|
|
|
43
44
|
/** 这些头是「逐跳」的,不该被转发(RFC 7230 §6.1)。 */
|
|
@@ -53,6 +54,31 @@ const HOP_BY_HOP = new Set([
|
|
|
53
54
|
*/
|
|
54
55
|
const FRAME_BLOCKERS = new Set(['x-frame-options', 'content-security-policy'])
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* **默认拒绝被嵌,只有持票的那一次放行**(2026-10-15)。
|
|
59
|
+
*
|
|
60
|
+
* `FRAME_BLOCKERS` 上面那条说的是「上游写死的禁止头要摘掉」;摘掉之后**由我们自己
|
|
61
|
+
* 定这一页许不许被嵌**。今天的两个用法不一样:
|
|
62
|
+
*
|
|
63
|
+
* · **手机页那一层**(`lib/page.html` 的 `#mirrorFrame`)是**同源** iframe——
|
|
64
|
+
* 地址是相对的 `/mini/mirror/?token=…`,和手机页同一个 host:port;
|
|
65
|
+
* · **DSH 桌面端那一层**(「远程窗口」面板)是**跨源又跨站**的 iframe——
|
|
66
|
+
* 桌面端自己的页面跑在自定义协议 `dsh-app://app/` 上(2026-10-15 实查 main.js:
|
|
67
|
+
* `protocol.handle(SCHEME…)`、`assertMainApplication` 里那句 `dsh-app://app/`),
|
|
68
|
+
* 镜像则在 `http://127.0.0.1:<我们的端口>`。
|
|
69
|
+
*
|
|
70
|
+
* 所以默认这一条就给「只许同源」:手机页照常能嵌,别的站点一律嵌不进来。
|
|
71
|
+
* 跨源那一层要嵌,地址里必须带着**一次性票**(见下面 issueTicket/redeemTicket)——
|
|
72
|
+
* 票只有本机回环上的宿主那一半拿得到,所以「谁能嵌」由票决定,而不是由一份
|
|
73
|
+
* 源白名单决定(桌面端那一侧不是个 http 地址,白名单本来也无从写起)。
|
|
74
|
+
*
|
|
75
|
+
* 注意这两个头**不是**上游的头:上游那一份已经在 writeOut 里丢掉了。
|
|
76
|
+
*/
|
|
77
|
+
const FRAME_SELF_ONLY = {
|
|
78
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
79
|
+
'content-security-policy': "frame-ancestors 'self'",
|
|
80
|
+
}
|
|
81
|
+
|
|
56
82
|
/**
|
|
57
83
|
* 那条「受信任通道」的前缀。
|
|
58
84
|
*
|
|
@@ -1052,6 +1078,88 @@ function injectHostHook(html, diag) {
|
|
|
1052
1078
|
return inject + html
|
|
1053
1079
|
}
|
|
1054
1080
|
|
|
1081
|
+
/**
|
|
1082
|
+
* ## 窗口会话的寿命:**跟着这个宿主进程走**(2026-10-15 用户拍板)
|
|
1083
|
+
*
|
|
1084
|
+
* 原来写的是「12 小时」。那个数是凭空定的,而且会留下一种半死状态:面板开着超过
|
|
1085
|
+
* 12 小时再刷新,服务器这边会话已经作废、浏览器里那枚 cookie 还在,用户看到的
|
|
1086
|
+
* 是一条莫名其妙的 401。
|
|
1087
|
+
*
|
|
1088
|
+
* 现在**没有固定寿命**:进程一起来随机生成一串密钥(就是下面这个 `PROCESS_SECRET`),
|
|
1089
|
+
* 每一枚窗口会话都用它**签**过,形状是 `v1.<随机串>.<签名>`。判定只有一条——
|
|
1090
|
+
* **签名对不对**。换一个进程就是换一串密钥,于是**上一个进程签发的会话签名当场
|
|
1091
|
+
* 对不上,一律不认**。
|
|
1092
|
+
*
|
|
1093
|
+
* 这样做的三个好处,正好对上用户提的三条:
|
|
1094
|
+
* · **不落盘**:密钥只活在这个进程的内存里,重启即失效,不需要扫磁盘;
|
|
1095
|
+
* · **不需要定时清理**:会话里根本没有「到期时刻」这种字段,也就没有清理任务;
|
|
1096
|
+
* · **不引入新的固定寿命**:寿命就是「这个进程活着」这一件事。
|
|
1097
|
+
*
|
|
1098
|
+
* ## 会话里还签着「这一扇窗是谁的」(2026-10-16 第四步)
|
|
1099
|
+
*
|
|
1100
|
+
* 光有签名只说明「**是本进程签的**」,**不认是哪台设备**。于是有一处撤不掉的洞:
|
|
1101
|
+
* 把一台设备从名册里删掉之后,它**已经开着的那扇窗照旧能用**,一直到宿主重启为止。
|
|
1102
|
+
* 所以身份要签进会话体里:`v1.<随机串>~<身份>.<签名>`。
|
|
1103
|
+
*
|
|
1104
|
+
* 身份是两样之一(由兑票那一下决定,见 lib/server.js 的 ticketWindowOwner):
|
|
1105
|
+
* · **一台设备的 id**——名册里那条记录的 id(撤销就是删掉它,见 lib/devices.js);
|
|
1106
|
+
* · **`local`**——本机那一半(桌面端面板走的就是这一条,见 lib/server.js)。
|
|
1107
|
+
*
|
|
1108
|
+
* **签名仍然只有进程密钥**:换一个宿主进程,旧会话的签名照样当场对不上(上面那三条
|
|
1109
|
+
* 一个字都没变)。身份这一节**不是权限判据**——它只是让验完会话的人能**再回头查一次
|
|
1110
|
+
* 名册**:记录还在不在,由 lib/server.js 的 windowSession 每次现查。
|
|
1111
|
+
*/
|
|
1112
|
+
const PROCESS_SECRET = randomBytes(32)
|
|
1113
|
+
const WINDOW_SESSION_VERSION = 'v1'
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* 随机串和身份之间的分隔符。
|
|
1117
|
+
*
|
|
1118
|
+
* 挑 `~` 是因为 base64url 的字母表里没有它(`A-Za-z0-9-_`),所以切得干净;
|
|
1119
|
+
* 也用不着转义——两侧都是我们自己生成的(`randomBytes(...).toString('base64url')`
|
|
1120
|
+
* 和一个 16 字节随机数转的 hex,或者 `local`)。
|
|
1121
|
+
*/
|
|
1122
|
+
const SESSION_ID_SEP = '~'
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* 签一枚窗口会话:`v1.<随机串>[~<身份>].<签名>`。
|
|
1126
|
+
*
|
|
1127
|
+
* @param {string} [deviceId] 这一扇窗是谁的:设备 id 或 `local`。
|
|
1128
|
+
* **给空就签一枚不带身份的会话**(老形状);生产路径上不会出现——
|
|
1129
|
+
* lib/server.js 兑票那一下拿不到身份就干脆不兑现(见 ticketWindowOwner)。
|
|
1130
|
+
*/
|
|
1131
|
+
function mintWindowSession(deviceId) {
|
|
1132
|
+
const nonce = randomBytes(32).toString('base64url')
|
|
1133
|
+
const who = typeof deviceId === 'string' ? deviceId : ''
|
|
1134
|
+
const body = `${WINDOW_SESSION_VERSION}.${who ? `${nonce}${SESSION_ID_SEP}${who}` : nonce}`
|
|
1135
|
+
return `${body}.${createHmac('sha256', PROCESS_SECRET).update(body).digest('base64url')}`
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** 验一枚窗口会话:形状对,而且签名是**这个进程**签的。 */
|
|
1139
|
+
function windowSessionValid(value) {
|
|
1140
|
+
if (typeof value !== 'string' || !value) return false
|
|
1141
|
+
const parts = value.split('.')
|
|
1142
|
+
if (parts.length !== 3 || parts[0] !== WINDOW_SESSION_VERSION) return false
|
|
1143
|
+
const body = `${parts[0]}.${parts[1]}`
|
|
1144
|
+
const expected = createHmac('sha256', PROCESS_SECRET).update(body).digest()
|
|
1145
|
+
const given = Buffer.from(parts[2], 'base64url')
|
|
1146
|
+
return given.length === expected.length && timingSafeEqual(given, expected)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* 这枚窗口会话是**谁的**:设备 id、`local`、`''`(签出来的老形状,没带身份),
|
|
1151
|
+
* 或者 `null`(形状不对/不是这个进程签的——**签名道理和 windowSessionValid 一样**)。
|
|
1152
|
+
*
|
|
1153
|
+
* **它本身不判权限**:签名对只说明「是本进程签的」。设备身份要拿去**回头查名册**
|
|
1154
|
+
* (见 lib/server.js 的 windowSession);记录不在了就当场拒——撤销靠的正是那一步。
|
|
1155
|
+
*/
|
|
1156
|
+
function windowSessionDevice(value) {
|
|
1157
|
+
if (!windowSessionValid(value)) return null
|
|
1158
|
+
const payload = value.split('.')[1]
|
|
1159
|
+
const at = payload.indexOf(SESSION_ID_SEP)
|
|
1160
|
+
return at < 0 ? '' : payload.slice(at + 1)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1055
1163
|
/**
|
|
1056
1164
|
* 建一个镜像代理。
|
|
1057
1165
|
*
|
|
@@ -1063,9 +1171,158 @@ function injectHostHook(html, diag) {
|
|
|
1063
1171
|
* 由插件注入(见 lib/index.js 里对 connection.authenticatedUrl 的调用)——
|
|
1064
1172
|
* 这个模块**不认识宿主**,只认识一个返回字符串的函数,好测。
|
|
1065
1173
|
* @param {(msg:string) => void} [opts.log]
|
|
1066
|
-
* @returns {{ handle, reset, available, stats }}
|
|
1174
|
+
* @returns {{ handle, upgrade, reset, available, stats, issueTicket, redeemTicket, windowValid, windowDevice, ticketOwner, ticketGranted, ticketTtlMs }}
|
|
1067
1175
|
*/
|
|
1068
1176
|
export function createMirror({ upstream, tokenUrl, log }) {
|
|
1177
|
+
/**
|
|
1178
|
+
* 「远程窗口」的两样一次性的东西:**票**和**票换来的窗口会话**。
|
|
1179
|
+
*
|
|
1180
|
+
* ## 为什么要有票
|
|
1181
|
+
*
|
|
1182
|
+
* 桌面端的「远程窗口」面板要把这一层嵌进 iframe,而进这一层的凭据是我们的
|
|
1183
|
+
* **令牌**。令牌一旦拼进 iframe 的地址,就会留在那台电脑的浏览历史里
|
|
1184
|
+
* (参考插件 `dsh-remote-web-ui` 就栽在这上面)。所以改成两步:
|
|
1185
|
+
*
|
|
1186
|
+
* ① 宿主那一半(本机回环上的软件)拿一张**一次性票**;
|
|
1187
|
+
* ② 浏览器带着票进一次门:票在那一刻兑现成一枚**窗口会话 cookie**(iframe 里
|
|
1188
|
+
* 那些请求——静态资源、`/api/*`、WebSocket——全靠它认人),而票自己
|
|
1189
|
+
* **只授权那一次**。地址里留着的那张票随后只用来回答「这一页是我嵌出来的吗」
|
|
1190
|
+
* (取景政策靠它,别的什么都打不开,见 `ticketGranted`)。
|
|
1191
|
+
*
|
|
1192
|
+
* 为什么要留票在地址里:iframe 会重新加载同一条地址,而「许不许被跨源嵌」这件事
|
|
1193
|
+
* 必须有个**cookie 之外**的判据——桌面端是跨站的,那枚 cookie 会被浏览器一路带着,
|
|
1194
|
+
* 光靠它就等于任何站点都能把这一页嵌进自己的页面(见 lib/server.js 的取景政策)。
|
|
1195
|
+
*
|
|
1196
|
+
* ## 票的规矩(照设计稿:短命、一次性、用完即废)
|
|
1197
|
+
*
|
|
1198
|
+
* · **短命**:30 秒。宿主拿到票到浏览器发出请求之间只差一次跳转,够用;
|
|
1199
|
+
* · **一次性**:`redeemTicket` 无论成败**先删再判**,并发来两次也只有一次能成;
|
|
1200
|
+
* · **换来的会话**:**没有固定寿命,跟着这个宿主进程走**(见上面 PROCESS_SECRET)。
|
|
1201
|
+
*
|
|
1202
|
+
* ## 票上还记着「这一扇窗是谁的」(2026-10-16 第五步)
|
|
1203
|
+
*
|
|
1204
|
+
* 跨机取票那条路上,**票是在对面那台电脑的浏览器里打开的**:那台浏览器
|
|
1205
|
+
* **没有设备凭据**(凭据只活在对面那个宿主进程里,永远不进浏览器),也**不是本机回环**。
|
|
1206
|
+
* 所以「兑票时看请求带了什么」这个判据在那条路上必然落空——身份只能在**发票那一刻**
|
|
1207
|
+
* 定下来,跟着票一起交给兑票那一下(见 lib/server.js 的 ticketWindowOwner)。
|
|
1208
|
+
*
|
|
1209
|
+
* **记在服务端这张表里,不是签进票里**:票本身照旧是一串 256 位随机数,设备 id
|
|
1210
|
+
* **不进对面浏览器的地址栏、不进浏览历史、也不进中间那几条链路的日志**——这条 URL
|
|
1211
|
+
* 正是我们一直在防泄漏的那一处。而一次性本来就得靠服务端记住「这张票用过了」,
|
|
1212
|
+
* 所以把主人寄存在这里**一个字节的状态都没多**。
|
|
1213
|
+
*
|
|
1214
|
+
* **窗口会话不是令牌**:它只够进镜像这一层(见 `lib/server.js` 里怎么用),
|
|
1215
|
+
* 令牌始终没进过浏览器。
|
|
1216
|
+
*/
|
|
1217
|
+
const TICKET_TTL_MS = 30_000
|
|
1218
|
+
/** 两张表都不许无限长:正常情况下同时只有个位数条目。 */
|
|
1219
|
+
const MAX_ENTRIES = 64
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* 还没兑现的票:`票 -> { expiresAt, owner }`。
|
|
1223
|
+
*
|
|
1224
|
+
* `owner` 就是**这一扇窗是谁的**(一台设备的 id,或者 `local`),由发票那一方当场
|
|
1225
|
+
* 定下(见 lib/index.js 的两条取票路由)。**兑票那一下原样取回来用**:它不判权限,
|
|
1226
|
+
* 名册那一关在 lib/server.js(每次放行都还要再查一次)。
|
|
1227
|
+
*/
|
|
1228
|
+
const tickets = new Map()
|
|
1229
|
+
const grants = new Set() // 已经兑现过的票(只用于取景放行;进程一走就没了)
|
|
1230
|
+
|
|
1231
|
+
const randomId = () => randomBytes(32).toString('base64url')
|
|
1232
|
+
const dropExpired = (map, now) => {
|
|
1233
|
+
for (const [key, rec] of map) if (rec.expiresAt <= now) map.delete(key)
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* 发一张票(只可能是宿主那一半调用:本机的 `/mini-remote/window`、跨机的
|
|
1238
|
+
* `/mini-remote/remote/window`,两条都在 lib/index.js)。
|
|
1239
|
+
*
|
|
1240
|
+
* @param {string} [owner] **这一扇窗是谁的**:本机那一半给 `local`,跨机那条给
|
|
1241
|
+
* 刚验过的设备 id(见 lib/index.js 里两处的注释)。给空就发一张**无主的票**
|
|
1242
|
+
* ——它能被读到,但兑不出来(lib/server.js 定不下身份就不兑现,fail closed)。
|
|
1243
|
+
*/
|
|
1244
|
+
function issueTicket(owner) {
|
|
1245
|
+
const now = Date.now()
|
|
1246
|
+
dropExpired(tickets, now)
|
|
1247
|
+
if (tickets.size >= MAX_ENTRIES) tickets.delete(tickets.keys().next().value)
|
|
1248
|
+
const ticket = randomId()
|
|
1249
|
+
tickets.set(ticket, {
|
|
1250
|
+
expiresAt: now + TICKET_TTL_MS,
|
|
1251
|
+
owner: typeof owner === 'string' ? owner : '',
|
|
1252
|
+
})
|
|
1253
|
+
return ticket
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* 这张票是**谁的**——**只看一眼,不消费**(消费是 `redeemTicket` 那一下)。
|
|
1258
|
+
*
|
|
1259
|
+
* 回 `''`(无主的票)、回 `null`(这张票不在表里:没发过、已经兑现过、或者过期被清了)。
|
|
1260
|
+
* **它本身不判权限**:lib/server.js 拿到之后还要按名册再查一次,本机那一半另外还要
|
|
1261
|
+
* 求"这一下是这台电脑自己发的"。
|
|
1262
|
+
*
|
|
1263
|
+
* 为什么要留这么一口「只看不消费」:身份**定不下来**时(无主、或者那台设备刚被撤销)
|
|
1264
|
+
* 票**不该被烧掉**——烧掉只让用户在网络抖一下之后白等 30 秒重新点一次,一个字节的
|
|
1265
|
+
* 安全都换不来(同一条道理见 lib/index.js 那条「限速只查不记」)。
|
|
1266
|
+
*/
|
|
1267
|
+
function ticketOwner(ticket) {
|
|
1268
|
+
if (typeof ticket !== 'string' || !ticket) return null
|
|
1269
|
+
const rec = tickets.get(ticket)
|
|
1270
|
+
return rec ? rec.owner : null
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* 用票换窗口会话。**成不成这张票都不再能授权**(一次性)。
|
|
1275
|
+
* 回 `{ session }`,票不对/过期/已经用过一律回 `null`。
|
|
1276
|
+
*
|
|
1277
|
+
* `deviceId` 是**这一扇窗是谁的**(设备 id 或 `local`),由调用方判——判据不在
|
|
1278
|
+
* 这个模块里(它不认识名册,也不认识"回环"这件事,见 lib/server.js 的
|
|
1279
|
+
* ticketWindowOwner)。**调用方照 `ticketOwner(ticket)` 读出来的那个值传进来就行。**
|
|
1280
|
+
* **身份跟着会话一起被签**,所以它改不了:拿到会话的人没法把自己说成另一台设备
|
|
1281
|
+
* (见上面 SESSION_ID_SEP 那一段)。
|
|
1282
|
+
*
|
|
1283
|
+
* 兑现过的那张票会被记进 `grants`:它**不再有任何进门的能力**,只用来回答
|
|
1284
|
+
* 「这一页是我嵌出来的吗」(见 `ticketGranted`)。留着它是因为 iframe 会重新加载
|
|
1285
|
+
* 同一条地址——那时靠它放行取景,靠窗口会话进门。
|
|
1286
|
+
*/
|
|
1287
|
+
function redeemTicket(ticket, deviceId) {
|
|
1288
|
+
if (typeof ticket !== 'string' || !ticket) return null
|
|
1289
|
+
const rec = tickets.get(ticket)
|
|
1290
|
+
// **先删再判**:并发拿同一张票来两次,第二次必然落空。
|
|
1291
|
+
tickets.delete(ticket)
|
|
1292
|
+
if (!rec || rec.expiresAt <= Date.now()) return null
|
|
1293
|
+
if (grants.size >= MAX_ENTRIES) grants.delete(grants.values().next().value)
|
|
1294
|
+
grants.add(ticket)
|
|
1295
|
+
return { session: mintWindowSession(deviceId) }
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* 这张票是不是**我们发过、而且已经兑现过**的。
|
|
1300
|
+
*
|
|
1301
|
+
* 只用来定取景政策:**「只有带合法票的请求才放行被嵌」**(设计稿门 1)。
|
|
1302
|
+
* 它本身什么都打不开——进门要的是窗口会话或者刚兑现的那一下。
|
|
1303
|
+
* 这样别有用心的站点就算把 `http://127.0.0.1:<端口>/mini/mirror/` 嵌进自己的页面,
|
|
1304
|
+
* 地址里没有票,取景政策就是「只许同源」,浏览器当场拒绝显示。
|
|
1305
|
+
*/
|
|
1306
|
+
function ticketGranted(ticket) {
|
|
1307
|
+
if (typeof ticket !== 'string' || !ticket) return false
|
|
1308
|
+
return grants.has(ticket)
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* 这枚窗口会话还作不作数:**签名是这个进程签的**就算数(见 PROCESS_SECRET)。
|
|
1313
|
+
*
|
|
1314
|
+
* **它只管签名**:身份那一节要另外查名册,由 lib/server.js 的 windowSession
|
|
1315
|
+
* 拿 windowDevice() 的返回值去查(撤销的当场失效就落在那里)。
|
|
1316
|
+
*/
|
|
1317
|
+
function windowValid(session) {
|
|
1318
|
+
return windowSessionValid(session)
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** 这枚窗口会话是**谁的**(设备 id / `local` / `''`),不是本进程签的就回 null。 */
|
|
1322
|
+
function windowDevice(session) {
|
|
1323
|
+
return windowSessionDevice(session)
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1069
1326
|
/** 上游地址**解析一次就记住**:它的 Host 要用来签 cookie,不能每次变。 */
|
|
1070
1327
|
let target = null
|
|
1071
1328
|
let targetHost = null
|
|
@@ -1197,6 +1454,14 @@ export function createMirror({ upstream, tokenUrl, log }) {
|
|
|
1197
1454
|
if (HOP_BY_HOP.has(key)) continue
|
|
1198
1455
|
// ② 我们自己那枚令牌的 cookie 不给上游;上游不认识它。
|
|
1199
1456
|
if (key === 'cookie') continue
|
|
1457
|
+
// ③ **我们自己的凭据,一个请求头都不给上游**(2026-10-16)。
|
|
1458
|
+
//
|
|
1459
|
+
// cookie 那条路早就断了(上面②),头这两条原来照原样转出去:手机页每一趟
|
|
1460
|
+
// fetch 都带着 `X-Mini-Token`,设备身份那枚 `X-Mini-Device` 同理——
|
|
1461
|
+
// 转出去就是**把口令写进上游的日志和链路**。上游压根不认识它们,
|
|
1462
|
+
// 摘掉不影响任何功能(这是「凭据绝不跟着请求转发」那条规矩的前半截;
|
|
1463
|
+
// 后半截在 lib/server.js 的 stripOurSecrets,管的是地址里那几个参数)。
|
|
1464
|
+
if (key === 'x-mini-token' || key === 'x-mini-device') continue
|
|
1200
1465
|
if (key === 'host') continue
|
|
1201
1466
|
headers[key] = v
|
|
1202
1467
|
}
|
|
@@ -1246,7 +1511,14 @@ export function createMirror({ upstream, tokenUrl, log }) {
|
|
|
1246
1511
|
})
|
|
1247
1512
|
}
|
|
1248
1513
|
|
|
1249
|
-
|
|
1514
|
+
/**
|
|
1515
|
+
* 把上游的响应发出去。
|
|
1516
|
+
*
|
|
1517
|
+
* `embed` = 「这一次是持票进来的」——由 `lib/server.js` 判定(票换来的窗口会话还在),
|
|
1518
|
+
* 传到这里只做一件事:**决定这一页许不许被跨源嵌**(见 FRAME_SELF_ONLY)。
|
|
1519
|
+
* 缺省是 false:正常路径(手机页那一层)一个字节都不变。
|
|
1520
|
+
*/
|
|
1521
|
+
function writeOut(upRes, res, reqUrl, diag, embed) {
|
|
1250
1522
|
const headers = {}
|
|
1251
1523
|
for (const [k, v] of Object.entries(upRes.headers)) {
|
|
1252
1524
|
const key = k.toLowerCase()
|
|
@@ -1257,6 +1529,12 @@ export function createMirror({ upstream, tokenUrl, log }) {
|
|
|
1257
1529
|
headers[key] = v
|
|
1258
1530
|
}
|
|
1259
1531
|
|
|
1532
|
+
/**
|
|
1533
|
+
* 取景政策:上游那一份(上面已丢掉)换成**我们自己的**。
|
|
1534
|
+
* 不持票 = 只许同源(手机页那一层是相对地址,照常能嵌;桌面端跨源那一层要凭票)。
|
|
1535
|
+
*/
|
|
1536
|
+
if (!embed) for (const [k, v] of Object.entries(FRAME_SELF_ONLY)) headers[k] = v
|
|
1537
|
+
|
|
1260
1538
|
// **给带内容指纹的静态资源补上长期缓存**(见 CACHEABLE 的说明)。
|
|
1261
1539
|
// 只补「上游本来没给」的:它给了 `no-cache` 之类就尊重它,别去覆盖别人的判断。
|
|
1262
1540
|
const path = String(reqUrl ?? '').split('?')[0]
|
|
@@ -1364,9 +1642,13 @@ export function createMirror({ upstream, tokenUrl, log }) {
|
|
|
1364
1642
|
* `opts.diag` = 「这一页是诊断模式打开的」,由 `lib/server.js` 从 `?diag=1` 里读出来传进来
|
|
1365
1643
|
* (查询串在入口那一下就被整个丢掉了,所以只能这么传,不能到这儿再自己解析)。
|
|
1366
1644
|
* **缺省是关的**:正常路径注入的东西一个字节都不变。
|
|
1645
|
+
*
|
|
1646
|
+
* `opts.embed` = 「这一次是持票进来的」,同上由 `lib/server.js` 判定后传进来
|
|
1647
|
+
* (票换来的窗口会话还作数就是 true)。**缺省 false**,取景政策回到「只许同源」。
|
|
1367
1648
|
*/
|
|
1368
1649
|
async function handle(req, res, opts) {
|
|
1369
1650
|
const diag = Boolean(opts && opts.diag)
|
|
1651
|
+
const embed = Boolean(opts && opts.embed)
|
|
1370
1652
|
try {
|
|
1371
1653
|
await ensureCookie()
|
|
1372
1654
|
} catch (err) {
|
|
@@ -1402,7 +1684,7 @@ export function createMirror({ upstream, tokenUrl, log }) {
|
|
|
1402
1684
|
}
|
|
1403
1685
|
}
|
|
1404
1686
|
|
|
1405
|
-
writeOut(upRes, res, req.url, diag)
|
|
1687
|
+
writeOut(upRes, res, req.url, diag, embed)
|
|
1406
1688
|
}
|
|
1407
1689
|
|
|
1408
1690
|
return {
|
|
@@ -1418,5 +1700,16 @@ export function createMirror({ upstream, tokenUrl, log }) {
|
|
|
1418
1700
|
/** 上游会话可能会失效(例如 DSH 重启),提供一个显式作废的入口给测试和上层用。 */
|
|
1419
1701
|
reset: () => { cookie = null },
|
|
1420
1702
|
stats: () => ({ mints, lastError }),
|
|
1703
|
+
// 「远程窗口」那一对:发票(只给宿主那一半)/ 用票换窗口会话 / 查窗口会话还作不作数 /
|
|
1704
|
+
// 查这枚会话是哪台设备的(拿去回头查名册,见 lib/server.js)/ 查**这张票是谁的**
|
|
1705
|
+
// (只看不消费,兑票那一下要先拿它定身份)/ 查这张票是不是兑现过的(取景政策用)。
|
|
1706
|
+
issueTicket,
|
|
1707
|
+
redeemTicket,
|
|
1708
|
+
windowValid,
|
|
1709
|
+
windowDevice,
|
|
1710
|
+
ticketOwner,
|
|
1711
|
+
ticketGranted,
|
|
1712
|
+
/** 票能活多久。宿主要把这个数告诉界面半边(`expiresIn`),所以露出来。 */
|
|
1713
|
+
ticketTtlMs: TICKET_TTL_MS,
|
|
1421
1714
|
}
|
|
1422
1715
|
}
|