mioku-service-webui 4.4.1 → 4.4.3

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/auth.ts CHANGED
@@ -2,6 +2,16 @@ import * as crypto from "node:crypto";
2
2
  import type { Context, Next } from "hono";
3
3
  import type { AuthConfig } from "./types";
4
4
  import { AUTH_PATH, WEEK_MS, readJsonFile, writeJsonFile } from "./utils";
5
+ import { notifyOwnersAuthTokenRefreshed } from "./system";
6
+
7
+ function safeEqual(a: string, b: string): boolean {
8
+ const bufA = Buffer.from(a);
9
+ const bufB = Buffer.from(b);
10
+ if (bufA.length !== bufB.length) {
11
+ return false;
12
+ }
13
+ return crypto.timingSafeEqual(bufA, bufB);
14
+ }
5
15
 
6
16
  export function ensureAuthConfig(): AuthConfig {
7
17
  const now = Date.now();
@@ -14,6 +24,10 @@ export function ensureAuthConfig(): AuthConfig {
14
24
  expiresAt: now + WEEK_MS,
15
25
  };
16
26
  writeJsonFile(AUTH_PATH, generated);
27
+ void notifyOwnersAuthTokenRefreshed(
28
+ generated.token,
29
+ generated.expiresAt,
30
+ ).catch(() => {});
17
31
  return generated;
18
32
  }
19
33
 
@@ -28,7 +42,7 @@ export function verifyAuthHeader(c: Context): boolean {
28
42
  }
29
43
 
30
44
  const authConfig = ensureAuthConfig();
31
- return token === authConfig.token && Date.now() < authConfig.expiresAt;
45
+ return safeEqual(token, authConfig.token) && Date.now() < authConfig.expiresAt;
32
46
  }
33
47
 
34
48
  export async function requireAuth(c: Context, next: Next): Promise<Response | void> {
@@ -40,7 +54,7 @@ export async function requireAuth(c: Context, next: Next): Promise<Response | vo
40
54
 
41
55
  export function loginWithToken(inputToken: string): { ok: boolean; expiresAt?: number } {
42
56
  const authConfig = ensureAuthConfig();
43
- if (inputToken !== authConfig.token) {
57
+ if (!safeEqual(inputToken, authConfig.token)) {
44
58
  return { ok: false };
45
59
  }
46
60
 
package/index.ts CHANGED
@@ -61,6 +61,16 @@ class WebUIRuntime {
61
61
  );
62
62
  });
63
63
 
64
+ this.app.use("*", async (c, next) => {
65
+ await next();
66
+ c.header("X-Robots-Tag", "noindex, nofollow");
67
+ });
68
+
69
+ this.app.get("/robots.txt", (c) => {
70
+ c.header("content-type", "text/plain");
71
+ return c.body("User-agent: *\nDisallow: /\n");
72
+ });
73
+
64
74
  this.app.notFound((c) => {
65
75
  if (c.req.path.startsWith("/api/")) {
66
76
  return c.json({ ok: false, error: "API_NOT_FOUND" }, 404);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-service-webui",
3
- "version": "4.4.1",
3
+ "version": "4.4.3",
4
4
  "description": "Mioku WebUI 服务",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -122,25 +122,38 @@ function resolveBackupFile(name: string): string {
122
122
  return fullPath;
123
123
  }
124
124
 
125
- async function pathSize(target: string): Promise<SizeResult> {
125
+ async function pathSize(
126
+ target: string,
127
+ visited: Set<string> = new Set(),
128
+ ): Promise<SizeResult> {
126
129
  let stat: fs.Stats;
127
130
  try {
128
- stat = await fs.promises.lstat(target);
131
+ stat = await fs.promises.stat(target);
129
132
  } catch {
130
133
  return { size: 0, files: 0, directories: 0 };
131
134
  }
132
135
 
133
- if (stat.isSymbolicLink()) {
134
- return { size: 0, files: 0, directories: 0 };
135
- }
136
-
137
136
  if (!stat.isDirectory()) {
138
137
  return { size: stat.size, files: 1, directories: 0 };
139
138
  }
140
139
 
140
+ // Follow symlinks to their real target. In a bun workspace, node_modules
141
+ // entries are symlinks into packages/* — skipping them reported 0B. Guard
142
+ // against cycles / repeated targets via realpath.
143
+ let real: string;
144
+ try {
145
+ real = await fs.promises.realpath(target);
146
+ } catch {
147
+ return { size: 0, files: 0, directories: 0 };
148
+ }
149
+ if (visited.has(real)) {
150
+ return { size: 0, files: 0, directories: 0 };
151
+ }
152
+ visited.add(real);
153
+
141
154
  const entries = await fs.promises.readdir(target).catch(() => []);
142
155
  const results = await Promise.all(
143
- entries.map((entry) => pathSize(path.join(target, entry))),
156
+ entries.map((entry) => pathSize(path.join(target, entry), visited)),
144
157
  );
145
158
  return results.reduce<SizeResult>(
146
159
  (total, item) => ({
package/routes/db.ts CHANGED
@@ -187,6 +187,18 @@ export function createPluginConfigRoutes() {
187
187
  export function createMemeRoutes() {
188
188
  const app = new Hono();
189
189
 
190
+ const memeRoot = path.resolve(CHAT_DATA_DIR, "meme");
191
+ const isInsideMeme = (target: string): boolean => {
192
+ const resolved = path.resolve(target);
193
+ return resolved === memeRoot || resolved.startsWith(`${memeRoot}${path.sep}`);
194
+ };
195
+ const isSafeSegment = (value: string): boolean =>
196
+ Boolean(value) &&
197
+ !value.includes("/") &&
198
+ !value.includes("\\") &&
199
+ value !== "." &&
200
+ value !== "..";
201
+
190
202
  app.get("/tree", (c) =>
191
203
  c.json({ ok: true, data: listMemeTree() }),
192
204
  );
@@ -200,11 +212,22 @@ export function createMemeRoutes() {
200
212
  return c.json({ ok: false, error: "FILE_REQUIRED" }, 400);
201
213
  }
202
214
 
203
- const dir = path.join(CHAT_DATA_DIR, "meme", character, emotion);
204
- fs.mkdirSync(dir, { recursive: true });
205
-
206
215
  const fileName = file.name || `upload-${Date.now()}.png`;
216
+ if (
217
+ !isSafeSegment(character) ||
218
+ !isSafeSegment(emotion) ||
219
+ !isSafeSegment(fileName)
220
+ ) {
221
+ return c.json({ ok: false, error: "INVALID_NAME" }, 400);
222
+ }
223
+
224
+ const dir = path.join(memeRoot, character, emotion);
207
225
  const filePath = path.join(dir, fileName);
226
+ if (!isInsideMeme(filePath)) {
227
+ return c.json({ ok: false, error: "INVALID_PATH" }, 400);
228
+ }
229
+
230
+ fs.mkdirSync(dir, { recursive: true });
208
231
  const buffer = Buffer.from(await file.arrayBuffer());
209
232
  fs.writeFileSync(filePath, buffer);
210
233
 
@@ -213,7 +236,10 @@ export function createMemeRoutes() {
213
236
 
214
237
  app.delete("/", async (c) => {
215
238
  const body = await c.req.json();
216
- const filePath = path.join(process.cwd(), String(body.path || ""));
239
+ const filePath = path.resolve(process.cwd(), String(body.path || ""));
240
+ if (!isInsideMeme(filePath)) {
241
+ return c.json({ ok: false, error: "INVALID_PATH" }, 400);
242
+ }
217
243
  if (!fs.existsSync(filePath)) {
218
244
  return c.json({ ok: false, error: "NOT_FOUND" }, 404);
219
245
  }
package/system.ts CHANGED
@@ -625,10 +625,10 @@ function readInstalledWebUIVersion(): string {
625
625
  }
626
626
  }
627
627
 
628
- // Fallback: read from mioku-webui package.json directly
629
- // mioku-webui is at repo root, which is ../../ from packages/mioku-service-webui
630
- // but since service runs from example/, we need example/../../mioku-webui
631
- const webuiProjectDir = path.join(process.cwd(), "..", "..", "mioku-webui");
628
+ // Fallback: read from mioku-webui package.json directly.
629
+ // The service runs from example/, so mioku-webui at the repo root is one
630
+ // level up — same resolution as resolveWebUIProjectDir().
631
+ const webuiProjectDir = resolveWebUIProjectDir();
632
632
  const webuiPkgPath = path.join(webuiProjectDir, "package.json");
633
633
  if (fs.existsSync(webuiPkgPath)) {
634
634
  const pkg = readPackageJson(webuiProjectDir);
@@ -1688,7 +1688,12 @@ async function fetchLatestMiokuUpdate(
1688
1688
  const job = (async () => {
1689
1689
  const cwd = process.cwd();
1690
1690
  const rootPkg = readRootPackageJson() || {};
1691
- const currentVersion = normalizeVersionSpec(rootPkg?.version || "unknown");
1691
+ // example/package.json has no version field; read the installed mioku
1692
+ // package so the current version matches what the dashboard reports.
1693
+ const miokuPkg = readMiokuPackageJson();
1694
+ const currentVersion = normalizeVersionSpec(
1695
+ miokuPkg?.version || rootPkg?.version || "unknown",
1696
+ );
1692
1697
 
1693
1698
  // Get latest version from npm registry
1694
1699
  let latestVersion = currentVersion;
@@ -1814,6 +1819,57 @@ async function notifyOwnerWebUIDownloaded(version: string): Promise<void> {
1814
1819
  }
1815
1820
  }
1816
1821
 
1822
+ export async function notifyOwnersAuthTokenRefreshed(
1823
+ token: string,
1824
+ expiresAt: number,
1825
+ ): Promise<void> {
1826
+ const owners = getMiokuConfig()?.owners || [];
1827
+ if (owners.length === 0) {
1828
+ return;
1829
+ }
1830
+
1831
+ const MAX_ATTEMPTS = 24;
1832
+ const RETRY_INTERVAL_MS = 5000;
1833
+
1834
+ let bots: any[] = [];
1835
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
1836
+ bots = Array.from(connectedBots.values()).filter(Boolean);
1837
+ if (bots.length > 0) {
1838
+ break;
1839
+ }
1840
+ await sleep(RETRY_INTERVAL_MS);
1841
+ }
1842
+
1843
+ if (bots.length === 0) {
1844
+ logger.warn("[webui] 密钥已刷新,但暂无在线 Bot,未能通知主人");
1845
+ return;
1846
+ }
1847
+
1848
+ const bot = bots[0];
1849
+ const settings = getWebUISettings();
1850
+ const url = `http://${settings.host}:${settings.port}`;
1851
+ const expireText = new Date(expiresAt).toLocaleString("zh-CN");
1852
+ const message = [
1853
+ "🔑 WebUI 鉴权密钥已更新",
1854
+ `地址: ${url}`,
1855
+ `新密钥: ${token}`,
1856
+ `有效期至: ${expireText}`,
1857
+ ].join("\n");
1858
+
1859
+ for (const ownerId of owners) {
1860
+ try {
1861
+ await bot.api("send_private_msg", {
1862
+ user_id: Number(ownerId),
1863
+ message,
1864
+ });
1865
+ } catch (err: any) {
1866
+ logger.warn(
1867
+ `[webui] 通知主人 ${ownerId} 密钥更新失败: ${err?.message || err}`,
1868
+ );
1869
+ }
1870
+ }
1871
+ }
1872
+
1817
1873
  export async function updateWebUIDistFromRelease(): Promise<
1818
1874
  Record<string, any>
1819
1875
  > {
package/utils.ts CHANGED
@@ -36,7 +36,7 @@ export const BACKUP_DIR = path.join(process.cwd(), "backup");
36
36
 
37
37
  export const defaultWebUISettings: WebUISettings = {
38
38
  port: 3339,
39
- host: "0.0.0.0",
39
+ host: "127.0.0.1",
40
40
  packageManager: "bun" as PackageManager,
41
41
  };
42
42
 
@@ -125,8 +125,32 @@ export function normalizePackageManager(): PackageManager {
125
125
  }
126
126
 
127
127
  export function isValidRepoUrl(url: string): boolean {
128
- const gitLike = /^(https?:\/\/|git@|ssh:\/\/).+/;
129
- return gitLike.test(url.trim());
128
+ const trimmed = url.trim();
129
+ // 拒绝空白字符:堵 `ssh://host -o ProxyCommand=...` 之类参数注入
130
+ if (!trimmed || /\s/.test(trimmed)) {
131
+ return false;
132
+ }
133
+
134
+ if (/^https?:\/\//.test(trimmed)) {
135
+ try {
136
+ const parsed = new URL(trimmed);
137
+ return Boolean(parsed.hostname) && !parsed.hostname.startsWith("-");
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ const sshMatch = trimmed.match(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)/);
144
+ if (sshMatch) {
145
+ return !sshMatch[1].startsWith("-");
146
+ }
147
+
148
+ const scpMatch = trimmed.match(/^git@([^:]+):.+/);
149
+ if (scpMatch) {
150
+ return !scpMatch[1].startsWith("-");
151
+ }
152
+
153
+ return false;
130
154
  }
131
155
 
132
156
  export function isNpmPackageName(name: string): boolean {