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 +16 -2
- package/index.ts +10 -0
- package/package.json +1 -1
- package/routes/data-management.ts +20 -7
- package/routes/db.ts +30 -4
- package/system.ts +61 -5
- package/utils.ts +27 -3
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
|
|
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
|
|
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
|
@@ -122,25 +122,38 @@ function resolveBackupFile(name: string): string {
|
|
|
122
122
|
return fullPath;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
async function pathSize(
|
|
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.
|
|
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.
|
|
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
|
|
630
|
-
//
|
|
631
|
-
const webuiProjectDir =
|
|
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
|
-
|
|
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: "
|
|
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
|
|
129
|
-
|
|
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 {
|