mioku-service-webui 4.4.0 → 4.4.2
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 +19 -2
- package/package.json +1 -1
- package/port-utils.ts +159 -0
- package/routes/data-management.ts +1 -1
- package/routes/db.ts +30 -4
- package/system.ts +112 -2
- 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
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
createStrangerRoutes,
|
|
31
31
|
createAccessControlRoutes,
|
|
32
32
|
} from "./routes";
|
|
33
|
+
import { freePort } from "./port-utils";
|
|
33
34
|
|
|
34
35
|
export interface WebUIServiceAPI {
|
|
35
36
|
getSettings(): WebUISettings;
|
|
@@ -44,7 +45,7 @@ class WebUIRuntime {
|
|
|
44
45
|
logger.info(`[webui-action] ${action}${text}`);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
public initRoutes(): void {
|
|
48
|
+
public async initRoutes(): Promise<void> {
|
|
48
49
|
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({
|
|
49
50
|
app: this.app,
|
|
50
51
|
});
|
|
@@ -60,6 +61,16 @@ class WebUIRuntime {
|
|
|
60
61
|
);
|
|
61
62
|
});
|
|
62
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
|
+
|
|
63
74
|
this.app.notFound((c) => {
|
|
64
75
|
if (c.req.path.startsWith("/api/")) {
|
|
65
76
|
return c.json({ ok: false, error: "API_NOT_FOUND" }, 404);
|
|
@@ -172,6 +183,12 @@ class WebUIRuntime {
|
|
|
172
183
|
});
|
|
173
184
|
|
|
174
185
|
const settings = getWebUISettings();
|
|
186
|
+
const freed = await freePort(settings.port);
|
|
187
|
+
if (freed.remainingPids.length > 0) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`webui 端口 ${settings.port} 被占用且无法结束 (PID: ${freed.remainingPids.join(", ")})`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
175
192
|
const server = serve({
|
|
176
193
|
fetch: this.app.fetch,
|
|
177
194
|
port: settings.port,
|
|
@@ -232,7 +249,7 @@ const webUIService: MiokuService = {
|
|
|
232
249
|
initBuiltinDatasources();
|
|
233
250
|
ensureAuthConfig();
|
|
234
251
|
await ensureWebUIDist();
|
|
235
|
-
runtime.initRoutes();
|
|
252
|
+
await runtime.initRoutes();
|
|
236
253
|
},
|
|
237
254
|
|
|
238
255
|
async dispose() {
|
package/package.json
CHANGED
package/port-utils.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { logger } from "mioki";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export interface FreePortLogger {
|
|
8
|
+
info: (msg: string) => void;
|
|
9
|
+
warn: (msg: string) => void;
|
|
10
|
+
error: (msg: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FreePortOptions {
|
|
14
|
+
logger?: FreePortLogger;
|
|
15
|
+
graceMs?: number;
|
|
16
|
+
sigkillDelayMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FreePortResult {
|
|
20
|
+
port: number;
|
|
21
|
+
wasOccupied: boolean;
|
|
22
|
+
killedPids: number[];
|
|
23
|
+
remainingPids: number[];
|
|
24
|
+
lsofAvailable: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sleep(ms: number): Promise<void> {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parsePids(raw: string): number[] {
|
|
32
|
+
const pids = new Set<number>();
|
|
33
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
34
|
+
const trimmed = line.trim();
|
|
35
|
+
if (!trimmed) continue;
|
|
36
|
+
const pid = Number(trimmed);
|
|
37
|
+
if (Number.isFinite(pid) && pid > 0 && Math.floor(pid) === pid) {
|
|
38
|
+
pids.add(pid);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Array.from(pids);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function listListeningPids(
|
|
45
|
+
port: number,
|
|
46
|
+
): Promise<{ pids: number[]; lsofAvailable: boolean }> {
|
|
47
|
+
try {
|
|
48
|
+
const { stdout } = await execFileAsync("lsof", [
|
|
49
|
+
"-nP",
|
|
50
|
+
`-iTCP:${port}`,
|
|
51
|
+
"-sTCP:LISTEN",
|
|
52
|
+
"-t",
|
|
53
|
+
]);
|
|
54
|
+
return { pids: parsePids(stdout), lsofAvailable: true };
|
|
55
|
+
} catch (err: any) {
|
|
56
|
+
if (err?.code === "ENOENT") {
|
|
57
|
+
return { pids: [], lsofAvailable: false };
|
|
58
|
+
}
|
|
59
|
+
return { pids: [], lsofAvailable: true };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function sendSignal(
|
|
64
|
+
pid: number,
|
|
65
|
+
signal: NodeJS.Signals,
|
|
66
|
+
): Promise<boolean> {
|
|
67
|
+
if (pid === process.pid) return false;
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, signal);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function freePort(
|
|
77
|
+
port: number,
|
|
78
|
+
options: FreePortOptions = {},
|
|
79
|
+
): Promise<FreePortResult> {
|
|
80
|
+
const log = options.logger ?? (logger as unknown as FreePortLogger);
|
|
81
|
+
const graceMs = options.graceMs ?? 1500;
|
|
82
|
+
const sigkillDelayMs = options.sigkillDelayMs ?? 500;
|
|
83
|
+
|
|
84
|
+
const initial = await listListeningPids(port);
|
|
85
|
+
|
|
86
|
+
if (!initial.lsofAvailable) {
|
|
87
|
+
log.warn(
|
|
88
|
+
`未检测到 lsof 命令,无法定位端口 ${port} 的占用进程,建议安装 lsof`,
|
|
89
|
+
);
|
|
90
|
+
return {
|
|
91
|
+
port,
|
|
92
|
+
wasOccupied: false,
|
|
93
|
+
killedPids: [],
|
|
94
|
+
remainingPids: [],
|
|
95
|
+
lsofAvailable: false,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const occupied = initial.pids.filter((pid) => pid !== process.pid);
|
|
100
|
+
if (occupied.length === 0) {
|
|
101
|
+
return {
|
|
102
|
+
port,
|
|
103
|
+
wasOccupied: false,
|
|
104
|
+
killedPids: [],
|
|
105
|
+
remainingPids: [],
|
|
106
|
+
lsofAvailable: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
log.warn(
|
|
111
|
+
`端口 ${port} 被占用,进程 PID: ${occupied.join(", ")},正在尝试结束...`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const killed: number[] = [];
|
|
115
|
+
for (const pid of occupied) {
|
|
116
|
+
if (await sendSignal(pid, "SIGTERM")) {
|
|
117
|
+
killed.push(pid);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (killed.length > 0) {
|
|
122
|
+
await sleep(graceMs);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let remaining = (await listListeningPids(port)).pids.filter(
|
|
126
|
+
(pid) => pid !== process.pid,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (remaining.length > 0) {
|
|
130
|
+
log.warn(
|
|
131
|
+
`端口 ${port} 仍被占用 (PID: ${remaining.join(", ")}),发送 SIGKILL 强制结束...`,
|
|
132
|
+
);
|
|
133
|
+
for (const pid of remaining) {
|
|
134
|
+
if (await sendSignal(pid, "SIGKILL")) {
|
|
135
|
+
killed.push(pid);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await sleep(sigkillDelayMs);
|
|
139
|
+
remaining = (await listListeningPids(port)).pids.filter(
|
|
140
|
+
(pid) => pid !== process.pid,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (remaining.length > 0) {
|
|
145
|
+
log.error(
|
|
146
|
+
`端口 ${port} 仍被占用,无法结束进程 PID: ${remaining.join(", ")}`,
|
|
147
|
+
);
|
|
148
|
+
} else {
|
|
149
|
+
log.info(`端口 ${port} 已释放`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
port,
|
|
154
|
+
wasOccupied: true,
|
|
155
|
+
killedPids: killed,
|
|
156
|
+
remainingPids: remaining,
|
|
157
|
+
lsofAvailable: true,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -514,7 +514,7 @@ export function createDataManagementRoutes() {
|
|
|
514
514
|
|
|
515
515
|
app.post("/node-modules/refresh", async (c) => {
|
|
516
516
|
const settings = getWebUISettings();
|
|
517
|
-
const command = getInstallCommand(
|
|
517
|
+
const command = getInstallCommand();
|
|
518
518
|
const startedAt = Date.now();
|
|
519
519
|
const result = await runCommand(command.cmd, command.args, process.cwd());
|
|
520
520
|
const finishedAt = Date.now();
|
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
|
@@ -96,6 +96,66 @@ const BOOT_CONFIG_PATH = path.join(
|
|
|
96
96
|
"base.json",
|
|
97
97
|
);
|
|
98
98
|
|
|
99
|
+
export interface BootSystemCommandsConfig {
|
|
100
|
+
update: {
|
|
101
|
+
enabled: boolean;
|
|
102
|
+
selectTimeoutMs: number;
|
|
103
|
+
};
|
|
104
|
+
install: {
|
|
105
|
+
enabled: boolean;
|
|
106
|
+
};
|
|
107
|
+
market: {
|
|
108
|
+
enabled: boolean;
|
|
109
|
+
};
|
|
110
|
+
restart: {
|
|
111
|
+
enabled: boolean;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const DEFAULT_BOOT_SYSTEM_COMMANDS_CONFIG: BootSystemCommandsConfig = {
|
|
116
|
+
update: {
|
|
117
|
+
enabled: true,
|
|
118
|
+
selectTimeoutMs: 60000,
|
|
119
|
+
},
|
|
120
|
+
install: {
|
|
121
|
+
enabled: true,
|
|
122
|
+
},
|
|
123
|
+
market: {
|
|
124
|
+
enabled: true,
|
|
125
|
+
},
|
|
126
|
+
restart: {
|
|
127
|
+
enabled: true,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function normalizeBootSystemCommandsConfig(
|
|
132
|
+
input: any,
|
|
133
|
+
): BootSystemCommandsConfig {
|
|
134
|
+
const merged = deepMerge(
|
|
135
|
+
cloneJson(DEFAULT_BOOT_SYSTEM_COMMANDS_CONFIG),
|
|
136
|
+
input || {},
|
|
137
|
+
);
|
|
138
|
+
const rawTimeout = Number(merged?.update?.selectTimeoutMs);
|
|
139
|
+
return {
|
|
140
|
+
update: {
|
|
141
|
+
enabled: Boolean(merged?.update?.enabled),
|
|
142
|
+
selectTimeoutMs:
|
|
143
|
+
Number.isFinite(rawTimeout) && rawTimeout >= 1000
|
|
144
|
+
? Math.floor(rawTimeout)
|
|
145
|
+
: DEFAULT_BOOT_SYSTEM_COMMANDS_CONFIG.update.selectTimeoutMs,
|
|
146
|
+
},
|
|
147
|
+
install: {
|
|
148
|
+
enabled: Boolean(merged?.install?.enabled),
|
|
149
|
+
},
|
|
150
|
+
market: {
|
|
151
|
+
enabled: Boolean(merged?.market?.enabled),
|
|
152
|
+
},
|
|
153
|
+
restart: {
|
|
154
|
+
enabled: Boolean(merged?.restart?.enabled),
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
99
159
|
interface AccessRuleConfig {
|
|
100
160
|
whitelist: Array<string | number>;
|
|
101
161
|
blacklist: Array<string | number>;
|
|
@@ -1345,8 +1405,7 @@ export async function getManagedPackageDetail(
|
|
|
1345
1405
|
const missingServices = checkDependentServices(pkg);
|
|
1346
1406
|
|
|
1347
1407
|
const isSystemPlugin = target === "plugin" ? isSystemPluginName(name) : false;
|
|
1348
|
-
const isSystemService =
|
|
1349
|
-
target === "service" ? isSystemServiceName(name) : false;
|
|
1408
|
+
const isSystemService = false;
|
|
1350
1409
|
return {
|
|
1351
1410
|
ok: true,
|
|
1352
1411
|
data: {
|
|
@@ -1755,6 +1814,57 @@ async function notifyOwnerWebUIDownloaded(version: string): Promise<void> {
|
|
|
1755
1814
|
}
|
|
1756
1815
|
}
|
|
1757
1816
|
|
|
1817
|
+
export async function notifyOwnersAuthTokenRefreshed(
|
|
1818
|
+
token: string,
|
|
1819
|
+
expiresAt: number,
|
|
1820
|
+
): Promise<void> {
|
|
1821
|
+
const owners = getMiokuConfig()?.owners || [];
|
|
1822
|
+
if (owners.length === 0) {
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
const MAX_ATTEMPTS = 24;
|
|
1827
|
+
const RETRY_INTERVAL_MS = 5000;
|
|
1828
|
+
|
|
1829
|
+
let bots: any[] = [];
|
|
1830
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
|
1831
|
+
bots = Array.from(connectedBots.values()).filter(Boolean);
|
|
1832
|
+
if (bots.length > 0) {
|
|
1833
|
+
break;
|
|
1834
|
+
}
|
|
1835
|
+
await sleep(RETRY_INTERVAL_MS);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
if (bots.length === 0) {
|
|
1839
|
+
logger.warn("[webui] 密钥已刷新,但暂无在线 Bot,未能通知主人");
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const bot = bots[0];
|
|
1844
|
+
const settings = getWebUISettings();
|
|
1845
|
+
const url = `http://${settings.host}:${settings.port}`;
|
|
1846
|
+
const expireText = new Date(expiresAt).toLocaleString("zh-CN");
|
|
1847
|
+
const message = [
|
|
1848
|
+
"🔑 WebUI 鉴权密钥已更新",
|
|
1849
|
+
`地址: ${url}`,
|
|
1850
|
+
`新密钥: ${token}`,
|
|
1851
|
+
`有效期至: ${expireText}`,
|
|
1852
|
+
].join("\n");
|
|
1853
|
+
|
|
1854
|
+
for (const ownerId of owners) {
|
|
1855
|
+
try {
|
|
1856
|
+
await bot.api("send_private_msg", {
|
|
1857
|
+
user_id: Number(ownerId),
|
|
1858
|
+
message,
|
|
1859
|
+
});
|
|
1860
|
+
} catch (err: any) {
|
|
1861
|
+
logger.warn(
|
|
1862
|
+
`[webui] 通知主人 ${ownerId} 密钥更新失败: ${err?.message || err}`,
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1758
1868
|
export async function updateWebUIDistFromRelease(): Promise<
|
|
1759
1869
|
Record<string, any>
|
|
1760
1870
|
> {
|
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 {
|