fix(proxy): address PR review findings for auth, credentials, and health stats

This commit is contained in:
rexname
2026-03-17 16:58:44 +07:00
parent 8091b6b508
commit 04b50329fc
10 changed files with 114 additions and 7 deletions

View File

@@ -155,18 +155,25 @@ export default function ProxyRegistryManager() {
setSaving(true);
setError(null);
const payload = {
const normalizedUsername = form.username.trim();
const normalizedPassword = form.password.trim();
const payload: Record<string, unknown> = {
...(editingId ? { id: editingId } : {}),
name: form.name.trim(),
type: form.type,
host: form.host.trim(),
port: Number(form.port || 8080),
username: form.username.trim(),
password: form.password.trim(),
region: form.region.trim() || null,
notes: form.notes.trim() || null,
status: form.status,
};
if (!editingId || normalizedUsername.length > 0) {
payload.username = normalizedUsername;
}
if (!editingId || normalizedPassword.length > 0) {
payload.password = normalizedPassword;
}
try {
const res = await fetch("/api/settings/proxies", {
@@ -474,6 +481,7 @@ export default function ProxyRegistryManager() {
<input
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.username}
placeholder={editingId ? "Leave blank to keep current username" : ""}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
/>
</div>
@@ -483,6 +491,7 @@ export default function ProxyRegistryManager() {
type="password"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.password}
placeholder={editingId ? "Leave blank to keep current password" : ""}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
/>
</div>

View File

@@ -1,12 +1,17 @@
import { migrateLegacyProxyConfigToRegistry } from "@/lib/localDb";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { z } from "zod";
const migrateLegacyProxySchema = z.object({
force: z.boolean().optional(),
});
export async function POST(request: Request) {
let force = false;
let rawBody: unknown;
try {
const body = await request.json().catch(() => ({}));
force = body?.force === true;
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
@@ -16,6 +21,17 @@ export async function POST(request: Request) {
}
try {
const validation = validateBody(migrateLegacyProxySchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const force = validation.data.force === true;
const result = await migrateLegacyProxyConfigToRegistry({ force });
return Response.json(result);
} catch (error) {

View File

@@ -2,6 +2,7 @@ import { assignProxyToScope, getProxyAssignments, resolveProxyForConnection } fr
import { proxyAssignmentSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
function toPagination(searchParams: URLSearchParams) {
@@ -11,6 +12,9 @@ function toPagination(searchParams: URLSearchParams) {
}
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const proxyId = searchParams.get("proxy_id");
@@ -42,6 +46,9 @@ export async function GET(request: Request) {
}
export async function PUT(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();

View File

@@ -2,9 +2,13 @@ import { bulkAssignProxyToScope } from "@/lib/localDb";
import { bulkProxyAssignmentSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
export async function PUT(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();

View File

@@ -1,7 +1,11 @@
import { getProxyHealthStats } from "@/lib/localDb";
import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const hours = Number(searchParams.get("hours") || 24);

View File

@@ -9,6 +9,7 @@ import {
import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
function toPagination(searchParams: URLSearchParams) {
const limit = Math.max(1, Math.min(200, Number(searchParams.get("limit") || 50)));
@@ -17,6 +18,9 @@ function toPagination(searchParams: URLSearchParams) {
}
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
@@ -48,6 +52,9 @@ export async function GET(request: Request) {
}
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
@@ -78,6 +85,9 @@ export async function POST(request: Request) {
}
export async function PATCH(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
@@ -113,6 +123,9 @@ export async function PATCH(request: Request) {
}
export async function DELETE(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");

View File

@@ -0,0 +1,18 @@
import { isAuthenticated, isAuthRequired } from "@/shared/utils/apiAuth";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function requireManagementAuth(request: Request): Promise<Response | null> {
if (!(await isAuthRequired())) {
return null;
}
if (await isAuthenticated(request)) {
return null;
}
return createErrorResponse({
status: 401,
message: "Authentication required",
type: "invalid_request",
});
}

View File

@@ -197,9 +197,23 @@ export async function updateProxy(id: string, payload: Partial<ProxyPayload>) {
const existing = await getProxyById(id, { includeSecrets: true });
if (!existing) return null;
const incomingUsername =
typeof payload.username === "string" ? payload.username.trim() : undefined;
const incomingPassword =
typeof payload.password === "string" ? payload.password.trim() : undefined;
const merged = {
...existing,
...payload,
// Preserve stored credentials unless caller explicitly sends non-empty replacements.
username:
incomingUsername === undefined || incomingUsername.length === 0
? existing.username
: incomingUsername,
password:
incomingPassword === undefined || incomingPassword.length === 0
? existing.password
: incomingPassword,
updatedAt: new Date().toISOString(),
};
@@ -508,6 +522,7 @@ export async function getProxyHealthStats(options?: { hours?: number }) {
LEFT JOIN proxy_logs l
ON l.proxy_host = p.host
AND l.proxy_type = p.type
AND l.proxy_port = p.port
AND l.timestamp >= ?
GROUP BY p.id, p.name, p.type, p.host, p.port
ORDER BY p.name ASC`

View File

@@ -207,6 +207,21 @@ export default function ProxyConfigModal({
await fetch(`/api/settings/proxy?${clearParams.toString()}`, { method: "DELETE" });
}
} else {
const clearAssignmentRes = await fetch("/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope,
scopeId: level === "global" ? null : levelId,
proxyId: null,
}),
});
const clearAssignmentPayload = await clearAssignmentRes.json().catch(() => ({}));
if (!clearAssignmentRes.ok) {
setFormError(clearAssignmentPayload?.error?.message || "Failed to clear saved proxy");
return;
}
const proxy = {
type: proxyType,
host: host.trim(),
@@ -226,6 +241,9 @@ export default function ProxyConfigModal({
return;
}
setHasOwnProxy(true);
if (mode === "custom") {
setSelectedProxyId("");
}
onSaved?.();
} catch (error) {
console.error("Error saving proxy:", error);

View File

@@ -211,6 +211,9 @@ test.describe("Proxy Registry smoke flow", () => {
.toBe(1);
await row.getByRole("button", { name: "Delete" }).click();
await expect(page.locator("table")).not.toContainText("Registry Smoke Proxy");
await expect
.poll(() => state.proxies.some((proxy) => proxy.name === "Registry Smoke Proxy"))
.toBe(false);
await expect(page.locator("tr", { hasText: "Registry Smoke Proxy" })).toHaveCount(0);
});
});