koishi-plugin-kkk 1.0.5 → 1.0.7
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 +20 -0
- package/client/index.ts +46 -14
- package/dist/index.js +1 -1
- package/lib/index.js +1 -1
- package/lib/webui.js +117 -15
- package/package.json +1 -1
- package/src/index.ts +2 -2
- package/src/webui.ts +113 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 1.0.7
|
|
4
|
+
|
|
5
|
+
### 修复
|
|
6
|
+
- **面板报「加载配置失败 / 鉴权失败: 缺少authorization」**:面板自己的登录 token 存在服务端内存里,
|
|
7
|
+
Koishi 一重启就失效,而自动登录脚本看到 localStorage 里已经有 token 就跳过登录,
|
|
8
|
+
于是一直拿着废 token 请求、所有接口 401。现在:
|
|
9
|
+
- 每次打开面板都会重新换一个新的 token(不再因为 localStorage 有值就跳过);
|
|
10
|
+
- 接口鉴权同时接受「控制台换来的面板 cookie」,即使前端 token 过期也能正常读写配置。
|
|
11
|
+
- 1.0.6 引入的登录校验配合这次修复才算完整:现在(装了 auth 插件时)未登录打不开面板、
|
|
12
|
+
登录后配置能正常加载与保存。
|
|
13
|
+
|
|
14
|
+
## 1.0.6
|
|
15
|
+
|
|
16
|
+
### 变更
|
|
17
|
+
- **配置面板默认要求登录**(`webUiAuth` 默认开):装了 auth 插件的部署,未登录时打开 `/kkk` 会直接看到
|
|
18
|
+
「需要先登录 Koishi 控制台」,静态资源与接口同样不放行。做法和控制台页面一致 ——
|
|
19
|
+
控制台里的「kkk 配置」页通过带权限校验的 RPC 向服务端换取一次性面板 token,再拼进 iframe 地址;
|
|
20
|
+
拿不到 token(未登录/权限不足)就不加载面板。没装 auth 插件的部署不受影响(本来也没有登录这回事)。
|
|
21
|
+
- 面板页面不再需要「HTTP 鉴权密钥」:能进来就说明已经授权,登录态由服务端自动补齐。
|
|
22
|
+
|
|
3
23
|
## 1.0.5
|
|
4
24
|
|
|
5
25
|
### 修复
|
package/client/index.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* kkk 的控制台入口(浏览器端)。
|
|
3
3
|
*
|
|
4
|
-
* 用官方工具 `koishi-console build .` 打包(入口必须是 client/index.ts
|
|
5
|
-
* 产物在 dist/index.js,依赖已一起 bundle,浏览器可直接加载。
|
|
4
|
+
* 用官方工具 `koishi-console build .` 打包(入口必须是 client/index.ts),产物在 dist/index.js。
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* 面板本体是插件自带的 `/kkk`(独立页面)。qq-chat 那种做法是把界面注册成控制台页面、
|
|
7
|
+
* 天然跟着控制台登录态;独立 URL 没有这层保护,所以这里向服务端要一个「面板 token」:
|
|
8
|
+
* 1. `send('kkk/panel-token')` —— 服务端那个监听器带 authority: 4,
|
|
9
|
+
* 未登录 / 权限不足的控制台客户端根本调不到;
|
|
10
|
+
* 2. 拿到 token 后拼进 iframe 地址(/kkk?panel=<token>),服务端校验通过会给面板发 cookie,
|
|
11
|
+
* 后续静态资源与接口都靠它放行;
|
|
12
|
+
* 3. 拿不到 token(没登录)就只显示提示,不加载面板。
|
|
12
13
|
*/
|
|
13
|
-
import {
|
|
14
|
+
import { send } from '@koishijs/client'
|
|
15
|
+
import { computed, defineComponent, h, onMounted, onUnmounted, ref } from 'vue'
|
|
14
16
|
|
|
15
17
|
export const name = 'kkk-console'
|
|
16
18
|
|
|
@@ -23,16 +25,18 @@ export function apply (ctx: any) {
|
|
|
23
25
|
path: '/kkk-config',
|
|
24
26
|
desc: '解析 / 面板 / 画质 / 图片切片等设置,改完直接生效',
|
|
25
27
|
authority: 4,
|
|
26
|
-
// 图标名要来自控制台内置图标集(activity:*),写错了侧边栏会是一片空白
|
|
27
28
|
icon: 'activity:plugin',
|
|
28
29
|
component: defineComponent({
|
|
29
30
|
setup () {
|
|
30
31
|
const style = ref<Record<string, string>>({})
|
|
32
|
+
const status = ref<{ authRequired: boolean } | null>(null)
|
|
33
|
+
const panelToken = ref('')
|
|
34
|
+
const denied = ref(false)
|
|
31
35
|
const sync = () => {
|
|
32
36
|
const aside = document.querySelector('nav.layout-activity') as HTMLElement | null
|
|
33
|
-
const
|
|
37
|
+
const footer = document.querySelector('footer.layout-status') as HTMLElement | null
|
|
34
38
|
const left = aside ? Math.round(aside.getBoundingClientRect().right) || FALLBACK_ASIDE : FALLBACK_ASIDE
|
|
35
|
-
const bottom = (
|
|
39
|
+
const bottom = (footer ? Math.round(footer.getBoundingClientRect().height) : FALLBACK_STATUS) + 8
|
|
36
40
|
style.value = {
|
|
37
41
|
position: 'fixed',
|
|
38
42
|
left: left + 'px',
|
|
@@ -46,17 +50,45 @@ export function apply (ctx: any) {
|
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
let timer: any = null
|
|
49
|
-
onMounted(() => {
|
|
53
|
+
onMounted(async () => {
|
|
50
54
|
sync()
|
|
51
|
-
// 侧边栏会随窗口宽度/悬浮展开,量一次不够,定时校准最省心
|
|
52
55
|
timer = setInterval(sync, 1000)
|
|
53
56
|
window.addEventListener('resize', sync)
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch('/kkk/api/status').then((r) => r.json())
|
|
59
|
+
status.value = (res && res.data) || null
|
|
60
|
+
} catch {
|
|
61
|
+
status.value = null
|
|
62
|
+
}
|
|
63
|
+
if (!status.value || status.value.authRequired) {
|
|
64
|
+
try {
|
|
65
|
+
const token = await send('kkk/panel-token')
|
|
66
|
+
panelToken.value = typeof token === 'string' ? token : ''
|
|
67
|
+
} catch {
|
|
68
|
+
panelToken.value = ''
|
|
69
|
+
}
|
|
70
|
+
denied.value = !panelToken.value
|
|
71
|
+
}
|
|
54
72
|
})
|
|
55
73
|
onUnmounted(() => {
|
|
56
74
|
if (timer) clearInterval(timer)
|
|
57
75
|
window.removeEventListener('resize', sync)
|
|
58
76
|
})
|
|
59
|
-
|
|
77
|
+
const src = computed(() => {
|
|
78
|
+
const s = status.value
|
|
79
|
+
if (s && s.authRequired === false) return '/kkk'
|
|
80
|
+
return panelToken.value ? '/kkk?panel=' + encodeURIComponent(panelToken.value) : ''
|
|
81
|
+
})
|
|
82
|
+
return () => {
|
|
83
|
+
if (src.value) return h('iframe', { src: src.value, style: style.value })
|
|
84
|
+
return h('div', {
|
|
85
|
+
style: { display: 'flex', flexDirection: 'column', gap: '12px', alignItems: 'center', justifyContent: 'center', height: '100%', fontFamily: 'system-ui', color: '#666' }
|
|
86
|
+
}, [
|
|
87
|
+
h('h2', { style: { margin: 0, fontSize: '20px' } }, denied.value ? '请先登录 Koishi 控制台' : '正在打开配置面板……'),
|
|
88
|
+
denied.value ? h('p', { style: { margin: 0 } }, '登录后即可打开 kkk 配置面板。') : null,
|
|
89
|
+
denied.value ? h('a', { href: '/login', style: { color: '#3b82f6' } }, '去登录') : null
|
|
90
|
+
])
|
|
91
|
+
}
|
|
60
92
|
}
|
|
61
93
|
})
|
|
62
94
|
})
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{defineComponent as
|
|
1
|
+
import{send as v}from"@koishijs/client";import{defineComponent as y,ref as u,onMounted as h,onUnmounted as m,computed as g,h as o}from"vue";const A="kkk-console",f=64,x=28;function I(p){p.page({name:"kkk 配置",path:"/kkk-config",desc:"解析 / 面板 / 画质 / 图片切片等设置,改完直接生效",authority:4,icon:"activity:plugin",component:y({setup(){const r=u({}),t=u(null),n=u(""),l=u(false),a=()=>{const e=document.querySelector("nav.layout-activity"),i=document.querySelector("footer.layout-status"),d=e&&Math.round(e.getBoundingClientRect().right)||f,k=(i?Math.round(i.getBoundingClientRect().height):x)+8;r.value={position:"fixed",left:d+"px",top:"0px",width:"calc(100vw - "+d+"px)",height:"calc(100vh - "+k+"px)",border:"0",display:"block",background:"#fff",zIndex:"1"}};let s=null;h(async()=>{a(),s=setInterval(a,1e3),window.addEventListener("resize",a);try{const e=await fetch("/kkk/api/status").then(i=>i.json());t.value=e&&e.data||null}catch{t.value=null}if(!t.value||t.value.authRequired){try{const e=await v("kkk/panel-token");n.value=typeof e=="string"?e:""}catch{n.value=""}l.value=!n.value}}),m(()=>{s&&clearInterval(s),window.removeEventListener("resize",a)});const c=g(()=>{const e=t.value;return e&&e.authRequired===false?"/kkk":n.value?"/kkk?panel="+encodeURIComponent(n.value):""});return()=>c.value?o("iframe",{src:c.value,style:r.value}):o("div",{style:{display:"flex",flexDirection:"column",gap:"12px",alignItems:"center",justifyContent:"center",height:"100%",fontFamily:"system-ui",color:"#666"}},[o("h2",{style:{margin:0,fontSize:"20px"}},l.value?"请先登录 Koishi 控制台":"正在打开配置面板……"),l.value?o("p",{style:{margin:0}},"登录后即可打开 kkk 配置面板。"):null,l.value?o("a",{href:"/login",style:{color:"#3b82f6"}},"去登录"):null])}})})}export{I as apply,A as name};
|
package/lib/index.js
CHANGED
|
@@ -143,7 +143,7 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
143
143
|
dataPath: koishi_1.Schema.string().default('data').description('数据目录:配置、数据库、临时文件都放在这里'),
|
|
144
144
|
debug: koishi_1.Schema.boolean().default(false).description('在日志里输出调试信息,排查问题时才需要打开'),
|
|
145
145
|
autoParse: koishi_1.Schema.boolean().default(true).description('群里有人发链接(或回复一条带链接的消息)就自动解析,不用打指令'),
|
|
146
|
-
webUiAuth: koishi_1.Schema.boolean().default(
|
|
146
|
+
webUiAuth: koishi_1.Schema.boolean().default(true).description('配置面板 /kkk 是否要求先登录 Koishi 控制台(默认开)。装了 auth 插件的部署只有登录后才能打开面板;没装 auth 插件时本来就没有登录这一说,这里不生效'),
|
|
147
147
|
}).collapse().description('Koishi 原生设置(一般不用改,已折叠)'),
|
|
148
148
|
}),
|
|
149
149
|
koishi_1.Schema.object({
|
package/lib/webui.js
CHANGED
|
@@ -78,7 +78,8 @@ function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
|
|
|
78
78
|
// 面板把登录态存在 localStorage 的 accessToken / userId / refreshToken 三个键里,
|
|
79
79
|
// 登录页只是个表单壳子 —— 与其去点 DOM(React 受控组件很脆),不如直接调登录接口把凭据写进去再刷新。
|
|
80
80
|
try {
|
|
81
|
-
|
|
81
|
+
// 每次都重新换一次:面板自己的 token 存在服务端内存里,Koishi 重启就失效,
|
|
82
|
+
// 之前这里看到 localStorage 有值就直接 return,结果一直拿着废 token 请求,全部 401。
|
|
82
83
|
// 防呆:万一接口一直不给凭据,也别在这里无限刷新
|
|
83
84
|
if (sessionStorage.getItem('kkk-autologin') === '1') return;
|
|
84
85
|
sessionStorage.setItem('kkk-autologin', '1');
|
|
@@ -100,9 +101,9 @@ function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
|
|
|
100
101
|
const raw = readWebFile('index.html');
|
|
101
102
|
if (!raw)
|
|
102
103
|
return Buffer.from('<h1>KKK Config</h1><p>assets/web/index.html 缺失</p>');
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
// 面板自己的登录态(Karın 版那套 accessToken)由这里自动补上:
|
|
105
|
+
// 能走到这一步说明请求已经通过鉴权(免登录,或者带着控制台换来的面板 token),
|
|
106
|
+
// 所以直接帮用户登进去,不用再面对一个「请输入 HTTP 鉴权密钥」的表单。
|
|
106
107
|
const text = raw.toString('utf-8');
|
|
107
108
|
return Buffer.from(text.includes('</body>') ? text.replace('</body>', autoLoginScript + '</body>') : text + autoLoginScript);
|
|
108
109
|
};
|
|
@@ -110,28 +111,75 @@ function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
|
|
|
110
111
|
/**
|
|
111
112
|
* 面板(/kkk)是否需要登录。
|
|
112
113
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
114
|
+
* 默认**要求登录**:装了 auth 插件的部署,只有登录 Koishi 控制台后
|
|
115
|
+
* (由控制台页面通过 RPC 换取面板 token)才能打开面板;
|
|
116
|
+
* 没装 auth 插件的部署本来就没有登录这回事,这里不生效。
|
|
117
|
+
* 想改成完全公开,把配置里的 `webUiAuth` 关掉即可。
|
|
118
|
+
*/
|
|
119
|
+
const authRequired = () => config?.webUiAuth !== false && !!ctx.get?.('auth');
|
|
120
|
+
/** 打开面板时控制台会把登录 token 一起带过来,用它换一个面板自己的 cookie */
|
|
121
|
+
const tokenAuthed = async (id, token) => {
|
|
122
|
+
const aid = Number(id);
|
|
123
|
+
const value = String(token || '');
|
|
124
|
+
if (!Number.isFinite(aid) || !value)
|
|
125
|
+
return false;
|
|
126
|
+
const database = ctx.get?.('database');
|
|
127
|
+
if (!database?.get)
|
|
128
|
+
return false;
|
|
129
|
+
try {
|
|
130
|
+
const rows = await database.get('token', { id: aid, token: value }, ['expiredAt']);
|
|
131
|
+
return !!(rows?.[0] && Number(rows[0].expiredAt) > Date.now());
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* 控制台登录态。
|
|
139
|
+
*
|
|
140
|
+
* 两条路:
|
|
141
|
+
* 1. 控制台页面(左侧边栏「kkk 配置」)会带上 `?uid=&token=`,校验通过后落到面板自己的 cookie;
|
|
142
|
+
* 2. 之后页面里的静态资源和接口都靠这个 cookie 放行。
|
|
143
|
+
* 没装 auth 插件时 `authRequired()` 为假,一律放行。
|
|
115
144
|
*/
|
|
116
|
-
const authRequired = () => config?.webUiAuth === true;
|
|
117
|
-
/** 控制台登录态:auth 插件的 cookie 形如 name=id:token,用它查 token 表 */
|
|
118
145
|
const consoleAuthed = async (request) => {
|
|
119
146
|
if (!authRequired())
|
|
120
147
|
return true;
|
|
148
|
+
const cookie = String(request?.headers?.cookie || '');
|
|
149
|
+
const fromCookie = new RegExp(COOKIE_NAME + '=(\\d+):([0-9a-f]+)').exec(cookie);
|
|
150
|
+
if (fromCookie && await tokenAuthed(fromCookie[1], fromCookie[2]))
|
|
151
|
+
return true;
|
|
152
|
+
const query = request?.query ?? {};
|
|
153
|
+
// 控制台页面换来的面板 token(cookie 或 query 都认)
|
|
154
|
+
const fromPanelCookie = new RegExp(COOKIE_NAME + '_panel=([0-9a-f]+)').exec(cookie);
|
|
155
|
+
if (fromPanelCookie && panelTokenValid(fromPanelCookie[1]))
|
|
156
|
+
return true;
|
|
157
|
+
if (panelTokenValid(query.panel))
|
|
158
|
+
return true;
|
|
159
|
+
if (await tokenAuthed(query.uid, query.token))
|
|
160
|
+
return true;
|
|
161
|
+
// 兼容一些老版本控制台把 auth 放在 cookie 里的写法(name=id:token)
|
|
121
162
|
const database = ctx.get?.('database');
|
|
122
163
|
if (!database?.get)
|
|
123
164
|
return false;
|
|
124
|
-
const cookie = String(request?.headers?.cookie || '');
|
|
125
165
|
for (const match of cookie.matchAll(/(?:^|;\s*)([\w-]+)=(\d+):([0-9a-f]+)/g)) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (rows?.[0] && Number(rows[0].expiredAt) > Date.now())
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
|
-
catch { /* 试下一个 cookie */ }
|
|
166
|
+
if (await tokenAuthed(match[2], match[3]))
|
|
167
|
+
return true;
|
|
132
168
|
}
|
|
133
169
|
return false;
|
|
134
170
|
};
|
|
171
|
+
/** 面板自己的 cookie:`id:token` */
|
|
172
|
+
const rememberPanelToken = (response) => {
|
|
173
|
+
const query = response?.request?.query ?? {};
|
|
174
|
+
const maxAge = Math.floor(TOKEN_TTL / 1000);
|
|
175
|
+
if (query.panel && panelTokenValid(query.panel)) {
|
|
176
|
+
response.set('Set-Cookie', COOKIE_NAME + '_panel=' + String(query.panel) + '; Path=/kkk; HttpOnly; Max-Age=' + maxAge);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (query.uid && query.token) {
|
|
180
|
+
response.set('Set-Cookie', COOKIE_NAME + '=' + query.uid + ':' + query.token + '; Path=/kkk; HttpOnly; Max-Age=' + maxAge);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
135
183
|
/**
|
|
136
184
|
* 整理写回 koishi.yml 的那份配置:QQ 适配器的字段统一收进 `qq` 分组。
|
|
137
185
|
*
|
|
@@ -152,6 +200,45 @@ function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
|
|
|
152
200
|
next.qq = group;
|
|
153
201
|
return next;
|
|
154
202
|
};
|
|
203
|
+
/* ---------------- 控制台 RPC:换一个面板专用 token ---------------- */
|
|
204
|
+
/**
|
|
205
|
+
* 面板 token:控制台页面(左侧边栏「kkk 配置」)通过 RPC 向服务端要一个,
|
|
206
|
+
* 再拼到 iframe 地址上(`/kkk?panel=<token>`)。服务端校验通过后给面板发 cookie,
|
|
207
|
+
* 后续静态资源与接口都靠它放行。
|
|
208
|
+
*
|
|
209
|
+
* 关键点:RPC 监听器带 `authority: 4`,**未登录或权限不足的客户端根本调不到**,
|
|
210
|
+
* 所以「不登录就打不开面板」这件事是服务端强制的,不依赖前端自觉。
|
|
211
|
+
*/
|
|
212
|
+
const panelTokens = new Map();
|
|
213
|
+
const PANEL_TOKEN_TTL = 10 * 60 * 1000;
|
|
214
|
+
const issuePanelToken = () => {
|
|
215
|
+
const value = node_crypto_1.default.randomBytes(16).toString('hex');
|
|
216
|
+
panelTokens.set(value, Date.now() + PANEL_TOKEN_TTL);
|
|
217
|
+
return value;
|
|
218
|
+
};
|
|
219
|
+
const panelTokenValid = (value) => {
|
|
220
|
+
const key = String(value || '');
|
|
221
|
+
const expire = panelTokens.get(key);
|
|
222
|
+
if (!expire)
|
|
223
|
+
return false;
|
|
224
|
+
if (expire < Date.now()) {
|
|
225
|
+
panelTokens.delete(key);
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
};
|
|
230
|
+
try {
|
|
231
|
+
const consoleService = ctx.console;
|
|
232
|
+
consoleService?.addListener?.('kkk/panel-token', function () {
|
|
233
|
+
// this 是发起调用的控制台客户端,auth 由 auth 插件写入
|
|
234
|
+
if (!this?.auth)
|
|
235
|
+
throw new Error('请先登录 Koishi 控制台');
|
|
236
|
+
return issuePanelToken();
|
|
237
|
+
}, { authority: 4 });
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
logger.debug('[kkk] 注册面板 token RPC 失败: ' + String(error?.message ?? error));
|
|
241
|
+
}
|
|
155
242
|
const tokens = new Map();
|
|
156
243
|
const issueToken = () => {
|
|
157
244
|
const accessToken = node_crypto_1.default.randomBytes(16).toString('hex');
|
|
@@ -163,6 +250,10 @@ function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
|
|
|
163
250
|
const authed = (request) => {
|
|
164
251
|
if (!authRequired())
|
|
165
252
|
return true;
|
|
253
|
+
// 面板 cookie(控制台换来的)本身就是授权凭据,接口请求直接用账号密码也不用再走一遍
|
|
254
|
+
const panelCookie = new RegExp(COOKIE_NAME + '_panel=([0-9a-f]+)').exec(String(request?.headers?.cookie || ''));
|
|
255
|
+
if (panelCookie && panelTokenValid(panelCookie[1]))
|
|
256
|
+
return true;
|
|
166
257
|
const header = String(request?.headers?.authorization || request?.headers?.['x-access-token'] || '');
|
|
167
258
|
if (header && tokens.has(header.replace(/^Bearer\s+/i, '')))
|
|
168
259
|
return true;
|
|
@@ -254,6 +345,16 @@ function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
|
|
|
254
345
|
fail(response, 500, String(error?.message ?? error));
|
|
255
346
|
}
|
|
256
347
|
});
|
|
348
|
+
/**
|
|
349
|
+
* 面板鉴权状态。控制台页面(iframe 的父页面)用它决定是直接打开面板还是提示先登录:
|
|
350
|
+
* 未登录时不要把 token 传进来,也就不会给面板发 cookie。
|
|
351
|
+
*/
|
|
352
|
+
server.get('/kkk/api/status', async (response) => {
|
|
353
|
+
ok(response, {
|
|
354
|
+
authRequired: authRequired(),
|
|
355
|
+
authed: await consoleAuthed(response),
|
|
356
|
+
}, '');
|
|
357
|
+
});
|
|
257
358
|
/* ---------------- 数据接口 ---------------- */
|
|
258
359
|
const botList = () => {
|
|
259
360
|
const bots = ctx.bots ?? [];
|
|
@@ -339,6 +440,7 @@ function registerWebUi({ ctx, config, rawConfig, logger, pluginRoot }) {
|
|
|
339
440
|
server.get(route, async (response) => {
|
|
340
441
|
if (await pageDenied(response))
|
|
341
442
|
return;
|
|
443
|
+
rememberPanelToken(response);
|
|
342
444
|
response.type = 'text/html; charset=utf-8';
|
|
343
445
|
response.body = indexHtml();
|
|
344
446
|
});
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -95,7 +95,7 @@ export interface Config {
|
|
|
95
95
|
debug: boolean
|
|
96
96
|
/** 消息里的链接自动解析 */
|
|
97
97
|
autoParse: boolean
|
|
98
|
-
/** 配置面板(/kkk)是否要求先登录 Koishi
|
|
98
|
+
/** 配置面板(/kkk)是否要求先登录 Koishi 控制台(默认开;装了 auth 插件时生效) */
|
|
99
99
|
webUiAuth: boolean
|
|
100
100
|
/** QQ 平台解析前先发交互面板(Markdown + 按钮)让用户选解析内容和画质 */
|
|
101
101
|
qqPanel: boolean
|
|
@@ -139,7 +139,7 @@ export const Config: Schema<Config> = Schema.intersect([
|
|
|
139
139
|
dataPath: Schema.string().default('data').description('数据目录:配置、数据库、临时文件都放在这里'),
|
|
140
140
|
debug: Schema.boolean().default(false).description('在日志里输出调试信息,排查问题时才需要打开'),
|
|
141
141
|
autoParse: Schema.boolean().default(true).description('群里有人发链接(或回复一条带链接的消息)就自动解析,不用打指令'),
|
|
142
|
-
webUiAuth: Schema.boolean().default(
|
|
142
|
+
webUiAuth: Schema.boolean().default(true).description('配置面板 /kkk 是否要求先登录 Koishi 控制台(默认开)。装了 auth 插件的部署只有登录后才能打开面板;没装 auth 插件时本来就没有登录这一说,这里不生效'),
|
|
143
143
|
}).collapse().description('Koishi 原生设置(一般不用改,已折叠)'),
|
|
144
144
|
}),
|
|
145
145
|
Schema.object({
|
package/src/webui.ts
CHANGED
|
@@ -86,7 +86,8 @@ export function registerWebUi ({ ctx, config, rawConfig, logger, pluginRoot }: W
|
|
|
86
86
|
// 面板把登录态存在 localStorage 的 accessToken / userId / refreshToken 三个键里,
|
|
87
87
|
// 登录页只是个表单壳子 —— 与其去点 DOM(React 受控组件很脆),不如直接调登录接口把凭据写进去再刷新。
|
|
88
88
|
try {
|
|
89
|
-
|
|
89
|
+
// 每次都重新换一次:面板自己的 token 存在服务端内存里,Koishi 重启就失效,
|
|
90
|
+
// 之前这里看到 localStorage 有值就直接 return,结果一直拿着废 token 请求,全部 401。
|
|
90
91
|
// 防呆:万一接口一直不给凭据,也别在这里无限刷新
|
|
91
92
|
if (sessionStorage.getItem('kkk-autologin') === '1') return;
|
|
92
93
|
sessionStorage.setItem('kkk-autologin', '1');
|
|
@@ -108,8 +109,9 @@ export function registerWebUi ({ ctx, config, rawConfig, logger, pluginRoot }: W
|
|
|
108
109
|
const indexHtml = (): Buffer => {
|
|
109
110
|
const raw = readWebFile('index.html')
|
|
110
111
|
if (!raw) return Buffer.from('<h1>KKK Config</h1><p>assets/web/index.html 缺失</p>')
|
|
111
|
-
//
|
|
112
|
-
|
|
112
|
+
// 面板自己的登录态(Karın 版那套 accessToken)由这里自动补上:
|
|
113
|
+
// 能走到这一步说明请求已经通过鉴权(免登录,或者带着控制台换来的面板 token),
|
|
114
|
+
// 所以直接帮用户登进去,不用再面对一个「请输入 HTTP 鉴权密钥」的表单。
|
|
113
115
|
const text = raw.toString('utf-8')
|
|
114
116
|
return Buffer.from(text.includes('</body>') ? text.replace('</body>', autoLoginScript + '</body>') : text + autoLoginScript)
|
|
115
117
|
}
|
|
@@ -119,26 +121,69 @@ export function registerWebUi ({ ctx, config, rawConfig, logger, pluginRoot }: W
|
|
|
119
121
|
/**
|
|
120
122
|
* 面板(/kkk)是否需要登录。
|
|
121
123
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
+
* 默认**要求登录**:装了 auth 插件的部署,只有登录 Koishi 控制台后
|
|
125
|
+
* (由控制台页面通过 RPC 换取面板 token)才能打开面板;
|
|
126
|
+
* 没装 auth 插件的部署本来就没有登录这回事,这里不生效。
|
|
127
|
+
* 想改成完全公开,把配置里的 `webUiAuth` 关掉即可。
|
|
124
128
|
*/
|
|
125
|
-
const authRequired = () => (config as any)?.webUiAuth
|
|
129
|
+
const authRequired = () => (config as any)?.webUiAuth !== false && !!(ctx as any).get?.('auth')
|
|
126
130
|
|
|
127
|
-
/**
|
|
131
|
+
/** 打开面板时控制台会把登录 token 一起带过来,用它换一个面板自己的 cookie */
|
|
132
|
+
const tokenAuthed = async (id: unknown, token: unknown): Promise<boolean> => {
|
|
133
|
+
const aid = Number(id)
|
|
134
|
+
const value = String(token || '')
|
|
135
|
+
if (!Number.isFinite(aid) || !value) return false
|
|
136
|
+
const database: any = (ctx as any).get?.('database')
|
|
137
|
+
if (!database?.get) return false
|
|
138
|
+
try {
|
|
139
|
+
const rows = await database.get('token', { id: aid, token: value }, ['expiredAt'])
|
|
140
|
+
return !!(rows?.[0] && Number(rows[0].expiredAt) > Date.now())
|
|
141
|
+
} catch {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 控制台登录态。
|
|
148
|
+
*
|
|
149
|
+
* 两条路:
|
|
150
|
+
* 1. 控制台页面(左侧边栏「kkk 配置」)会带上 `?uid=&token=`,校验通过后落到面板自己的 cookie;
|
|
151
|
+
* 2. 之后页面里的静态资源和接口都靠这个 cookie 放行。
|
|
152
|
+
* 没装 auth 插件时 `authRequired()` 为假,一律放行。
|
|
153
|
+
*/
|
|
128
154
|
const consoleAuthed = async (request: any): Promise<boolean> => {
|
|
129
155
|
if (!authRequired()) return true
|
|
156
|
+
const cookie = String(request?.headers?.cookie || '')
|
|
157
|
+
const fromCookie = new RegExp(COOKIE_NAME + '=(\\d+):([0-9a-f]+)').exec(cookie)
|
|
158
|
+
if (fromCookie && await tokenAuthed(fromCookie[1], fromCookie[2])) return true
|
|
159
|
+
const query = request?.query ?? {}
|
|
160
|
+
// 控制台页面换来的面板 token(cookie 或 query 都认)
|
|
161
|
+
const fromPanelCookie = new RegExp(COOKIE_NAME + '_panel=([0-9a-f]+)').exec(cookie)
|
|
162
|
+
if (fromPanelCookie && panelTokenValid(fromPanelCookie[1])) return true
|
|
163
|
+
if (panelTokenValid(query.panel)) return true
|
|
164
|
+
if (await tokenAuthed(query.uid, query.token)) return true
|
|
165
|
+
// 兼容一些老版本控制台把 auth 放在 cookie 里的写法(name=id:token)
|
|
130
166
|
const database: any = (ctx as any).get?.('database')
|
|
131
167
|
if (!database?.get) return false
|
|
132
|
-
const cookie = String(request?.headers?.cookie || '')
|
|
133
168
|
for (const match of cookie.matchAll(/(?:^|;\s*)([\w-]+)=(\d+):([0-9a-f]+)/g)) {
|
|
134
|
-
|
|
135
|
-
const rows = await database.get('token', { id: Number(match[2]), token: match[3] }, ['expiredAt'])
|
|
136
|
-
if (rows?.[0] && Number(rows[0].expiredAt) > Date.now()) return true
|
|
137
|
-
} catch { /* 试下一个 cookie */ }
|
|
169
|
+
if (await tokenAuthed(match[2], match[3])) return true
|
|
138
170
|
}
|
|
139
171
|
return false
|
|
140
172
|
}
|
|
141
173
|
|
|
174
|
+
/** 面板自己的 cookie:`id:token` */
|
|
175
|
+
const rememberPanelToken = (response: any) => {
|
|
176
|
+
const query = response?.request?.query ?? {}
|
|
177
|
+
const maxAge = Math.floor(TOKEN_TTL / 1000)
|
|
178
|
+
if (query.panel && panelTokenValid(query.panel)) {
|
|
179
|
+
response.set('Set-Cookie', COOKIE_NAME + '_panel=' + String(query.panel) + '; Path=/kkk; HttpOnly; Max-Age=' + maxAge)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (query.uid && query.token) {
|
|
183
|
+
response.set('Set-Cookie', COOKIE_NAME + '=' + query.uid + ':' + query.token + '; Path=/kkk; HttpOnly; Max-Age=' + maxAge)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
142
187
|
/**
|
|
143
188
|
* 整理写回 koishi.yml 的那份配置:QQ 适配器的字段统一收进 `qq` 分组。
|
|
144
189
|
*
|
|
@@ -159,6 +204,47 @@ export function registerWebUi ({ ctx, config, rawConfig, logger, pluginRoot }: W
|
|
|
159
204
|
return next
|
|
160
205
|
}
|
|
161
206
|
|
|
207
|
+
/* ---------------- 控制台 RPC:换一个面板专用 token ---------------- */
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 面板 token:控制台页面(左侧边栏「kkk 配置」)通过 RPC 向服务端要一个,
|
|
211
|
+
* 再拼到 iframe 地址上(`/kkk?panel=<token>`)。服务端校验通过后给面板发 cookie,
|
|
212
|
+
* 后续静态资源与接口都靠它放行。
|
|
213
|
+
*
|
|
214
|
+
* 关键点:RPC 监听器带 `authority: 4`,**未登录或权限不足的客户端根本调不到**,
|
|
215
|
+
* 所以「不登录就打不开面板」这件事是服务端强制的,不依赖前端自觉。
|
|
216
|
+
*/
|
|
217
|
+
const panelTokens = new Map<string, number>()
|
|
218
|
+
const PANEL_TOKEN_TTL = 10 * 60 * 1000
|
|
219
|
+
|
|
220
|
+
const issuePanelToken = (): string => {
|
|
221
|
+
const value = crypto.randomBytes(16).toString('hex')
|
|
222
|
+
panelTokens.set(value, Date.now() + PANEL_TOKEN_TTL)
|
|
223
|
+
return value
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const panelTokenValid = (value: unknown): boolean => {
|
|
227
|
+
const key = String(value || '')
|
|
228
|
+
const expire = panelTokens.get(key)
|
|
229
|
+
if (!expire) return false
|
|
230
|
+
if (expire < Date.now()) {
|
|
231
|
+
panelTokens.delete(key)
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const consoleService: any = (ctx as any).console
|
|
239
|
+
consoleService?.addListener?.('kkk/panel-token', function (this: any) {
|
|
240
|
+
// this 是发起调用的控制台客户端,auth 由 auth 插件写入
|
|
241
|
+
if (!this?.auth) throw new Error('请先登录 Koishi 控制台')
|
|
242
|
+
return issuePanelToken()
|
|
243
|
+
}, { authority: 4 })
|
|
244
|
+
} catch (error: any) {
|
|
245
|
+
logger.debug('[kkk] 注册面板 token RPC 失败: ' + String(error?.message ?? error))
|
|
246
|
+
}
|
|
247
|
+
|
|
162
248
|
const tokens = new Map<string, number>()
|
|
163
249
|
|
|
164
250
|
const issueToken = () => {
|
|
@@ -171,6 +257,9 @@ export function registerWebUi ({ ctx, config, rawConfig, logger, pluginRoot }: W
|
|
|
171
257
|
|
|
172
258
|
const authed = (request: any): boolean => {
|
|
173
259
|
if (!authRequired()) return true
|
|
260
|
+
// 面板 cookie(控制台换来的)本身就是授权凭据,接口请求直接用账号密码也不用再走一遍
|
|
261
|
+
const panelCookie = new RegExp(COOKIE_NAME + '_panel=([0-9a-f]+)').exec(String(request?.headers?.cookie || ''))
|
|
262
|
+
if (panelCookie && panelTokenValid(panelCookie[1])) return true
|
|
174
263
|
const header = String(request?.headers?.authorization || request?.headers?.['x-access-token'] || '')
|
|
175
264
|
if (header && tokens.has(header.replace(/^Bearer\s+/i, ''))) return true
|
|
176
265
|
const cookie = String(request?.headers?.cookie || '')
|
|
@@ -263,6 +352,17 @@ export function registerWebUi ({ ctx, config, rawConfig, logger, pluginRoot }: W
|
|
|
263
352
|
}
|
|
264
353
|
})
|
|
265
354
|
|
|
355
|
+
/**
|
|
356
|
+
* 面板鉴权状态。控制台页面(iframe 的父页面)用它决定是直接打开面板还是提示先登录:
|
|
357
|
+
* 未登录时不要把 token 传进来,也就不会给面板发 cookie。
|
|
358
|
+
*/
|
|
359
|
+
server.get('/kkk/api/status', async (response: any) => {
|
|
360
|
+
ok(response, {
|
|
361
|
+
authRequired: authRequired(),
|
|
362
|
+
authed: await consoleAuthed(response),
|
|
363
|
+
}, '')
|
|
364
|
+
})
|
|
365
|
+
|
|
266
366
|
/* ---------------- 数据接口 ---------------- */
|
|
267
367
|
|
|
268
368
|
const botList = () => {
|
|
@@ -348,6 +448,7 @@ export function registerWebUi ({ ctx, config, rawConfig, logger, pluginRoot }: W
|
|
|
348
448
|
for (const route of ['/kkk', '/kkk/', '/kkk/login']) {
|
|
349
449
|
server.get(route, async (response: any) => {
|
|
350
450
|
if (await pageDenied(response)) return
|
|
451
|
+
rememberPanelToken(response)
|
|
351
452
|
response.type = 'text/html; charset=utf-8'
|
|
352
453
|
response.body = indexHtml()
|
|
353
454
|
})
|