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 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
- * 页面本体是个 iframe,指向插件自己的配置面板 `/kkk`(原版 SPA,免登录)。
8
- * 注意:控制台把自定义页面直接挂在 #app 下,**不在布局里**,所以左侧那条图标栏
9
- * (nav.layout-activity,fixed、64px、z-index 100)和底部状态栏会盖在页面上,
10
- * 而 iframe 默认高度只有 150px。这里在挂载后量一下这两条的实际尺寸,
11
- * 把 iframe 摆到它们右边的剩余区域,并随窗口变化跟着更新。
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 { defineComponent, h, onMounted, onUnmounted, ref } from 'vue'
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 status = document.querySelector('footer.layout-status') as HTMLElement | null
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 = (status ? Math.round(status.getBoundingClientRect().height) : FALLBACK_STATUS) + 8
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
- return () => h('iframe', { src: '/kkk', style: style.value })
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 l,ref as u,onMounted as d,onUnmounted as p,h as f}from"vue";const h="kkk-console",r=64,k=28;function v(s){s.page({name:"kkk 配置",path:"/kkk-config",desc:"解析 / 面板 / 画质 / 图片切片等设置,改完直接生效",authority:4,icon:"activity:plugin",component:l({setup(){const n=u({}),t=()=>{const o=document.querySelector("nav.layout-activity"),i=document.querySelector("footer.layout-status"),c=o&&Math.round(o.getBoundingClientRect().right)||r,a=(i?Math.round(i.getBoundingClientRect().height):k)+8;n.value={position:"fixed",left:c+"px",top:"0px",width:"calc(100vw - "+c+"px)",height:"calc(100vh - "+a+"px)",border:"0",display:"block",background:"#fff",zIndex:"1"}};let e=null;return d(()=>{t(),e=setInterval(t,1e3),window.addEventListener("resize",t)}),p(()=>{e&&clearInterval(e),window.removeEventListener("resize",t)}),()=>f("iframe",{src:"/kkk",style:n.value})}})})}export{v as apply,h as name};
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(false).description('配置面板 /kkk 是否要求先登录 Koishi 控制台。默认关(免登录,打开就能改配置);装了 auth 插件且希望面板也走登录时再打开'),
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
- if (localStorage.getItem('accessToken')) return;
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
- // 只有「不需要登录」时才自动登录;开了 auth 插件就得让用户自己登控制台
104
- if (authRequired())
105
- return raw;
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
- * 默认**免登录**:装了 auth 插件的生产环境也会直接进界面(否则用户要先登控制台才能改配置,很别扭)。
114
- * 要恢复成「必须登录控制台」,把配置里的 `webUiAuth` 打开即可。
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
- try {
127
- const rows = await database.get('token', { id: Number(match[2]), token: match[3] }, ['expiredAt']);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-kkk",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
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(false).description('配置面板 /kkk 是否要求先登录 Koishi 控制台。默认关(免登录,打开就能改配置);装了 auth 插件且希望面板也走登录时再打开'),
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
- if (localStorage.getItem('accessToken')) return;
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
- // 只有「不需要登录」时才自动登录;开了 auth 插件就得让用户自己登控制台
112
- if (authRequired()) return raw
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
- * 默认**免登录**:装了 auth 插件的生产环境也会直接进界面(否则用户要先登控制台才能改配置,很别扭)。
123
- * 要恢复成「必须登录控制台」,把配置里的 `webUiAuth` 打开即可。
124
+ * 默认**要求登录**:装了 auth 插件的部署,只有登录 Koishi 控制台后
125
+ * (由控制台页面通过 RPC 换取面板 token)才能打开面板;
126
+ * 没装 auth 插件的部署本来就没有登录这回事,这里不生效。
127
+ * 想改成完全公开,把配置里的 `webUiAuth` 关掉即可。
124
128
  */
125
- const authRequired = () => (config as any)?.webUiAuth === true
129
+ const authRequired = () => (config as any)?.webUiAuth !== false && !!(ctx as any).get?.('auth')
126
130
 
127
- /** 控制台登录态:auth 插件的 cookie 形如 name=id:token,用它查 token 表 */
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
- try {
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
  })