mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 16:20:27 +00:00
feat: 添加客户端 IP 请求头设置界面
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1654,6 +1654,66 @@
|
||||
</div>
|
||||
<Toggle v-model="form.api_key_acl_trust_forwarded_ip" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.api_key_acl_trust_forwarded_ip"
|
||||
class="border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<label
|
||||
for="forwarded-client-ip-headers"
|
||||
class="font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ t("admin.settings.apiKeyAcl.forwardedClientIpHeaders") }}
|
||||
</label>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.apiKeyAcl.forwardedClientIpHeadersHint") }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-3 rounded-lg border border-gray-300 bg-white p-2 dark:border-dark-500 dark:bg-dark-700"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
v-for="header in form.forwarded_client_ip_headers"
|
||||
:key="header"
|
||||
data-testid="forwarded-client-ip-header-tag"
|
||||
class="inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-700 dark:bg-dark-600 dark:text-gray-200"
|
||||
>
|
||||
<span>{{ header }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-dark-500 dark:hover:text-white"
|
||||
:aria-label="t('admin.settings.apiKeyAcl.removeForwardedClientIpHeader', { header })"
|
||||
@click="removeForwardedClientIpHeader(header)"
|
||||
>
|
||||
<Icon
|
||||
name="x"
|
||||
size="xs"
|
||||
class="h-3.5 w-3.5"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
<div
|
||||
class="flex min-w-[220px] flex-1 items-center gap-1 rounded border border-transparent px-2 py-1 focus-within:border-primary-300 dark:focus-within:border-primary-700"
|
||||
>
|
||||
<input
|
||||
id="forwarded-client-ip-headers"
|
||||
v-model="forwardedClientIpHeaderDraft"
|
||||
data-testid="forwarded-client-ip-headers-input"
|
||||
type="text"
|
||||
class="w-full bg-transparent text-sm font-mono text-gray-900 outline-none placeholder:text-gray-400 dark:text-white dark:placeholder:text-gray-500"
|
||||
:placeholder="t('admin.settings.apiKeyAcl.forwardedClientIpHeadersPlaceholder')"
|
||||
@keydown="handleForwardedClientIpHeaderKeydown"
|
||||
@blur="commitForwardedClientIpHeaderDraft"
|
||||
@paste="handleForwardedClientIpHeaderPaste"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.apiKeyAcl.forwardedClientIpHeadersRiskHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7754,6 +7814,7 @@ const smtpPasswordManuallyEdited = ref(false);
|
||||
const testEmailAddress = ref("");
|
||||
const registrationEmailSuffixWhitelistTags = ref<string[]>([]);
|
||||
const registrationEmailSuffixWhitelistDraft = ref("");
|
||||
const forwardedClientIpHeaderDraft = ref("");
|
||||
const tablePageSizeOptionsInput = ref("10, 20, 50, 100");
|
||||
|
||||
// Admin API Key 状态
|
||||
@@ -8394,6 +8455,7 @@ const form = reactive<SettingsForm>({
|
||||
turnstile_secret_key: "",
|
||||
turnstile_secret_key_configured: false,
|
||||
api_key_acl_trust_forwarded_ip: true,
|
||||
forwarded_client_ip_headers: [],
|
||||
// LinuxDo Connect OAuth 登录
|
||||
linuxdo_connect_enabled: false,
|
||||
linuxdo_connect_client_id: "",
|
||||
@@ -8972,6 +9034,143 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
const forwardedClientIpHeaderSeparatorKeys = new Set([
|
||||
" ",
|
||||
",",
|
||||
",",
|
||||
"Enter",
|
||||
"Tab",
|
||||
]);
|
||||
const forwardedClientIpHeaderTokenPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
const maxForwardedClientIpHeaders = 16;
|
||||
|
||||
type ForwardedClientIpHeaderResult = "added" | "duplicate" | "invalid" | "full";
|
||||
|
||||
function normalizeForwardedClientIpHeader(raw: string): string {
|
||||
const header = raw.trim();
|
||||
if (!forwardedClientIpHeaderTokenPattern.test(header)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return header
|
||||
.toLowerCase()
|
||||
.split("-")
|
||||
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join("-");
|
||||
}
|
||||
|
||||
function normalizeForwardedClientIpHeaders(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headers: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of value) {
|
||||
if (typeof raw !== "string") {
|
||||
continue;
|
||||
}
|
||||
const header = normalizeForwardedClientIpHeader(raw);
|
||||
const key = header.toLowerCase();
|
||||
if (!header || seen.has(key) || headers.length >= maxForwardedClientIpHeaders) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
headers.push(header);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function removeForwardedClientIpHeader(header: string) {
|
||||
form.forwarded_client_ip_headers = form.forwarded_client_ip_headers.filter(
|
||||
(item) => item !== header,
|
||||
);
|
||||
}
|
||||
|
||||
function addForwardedClientIpHeader(raw: string): ForwardedClientIpHeaderResult {
|
||||
const header = normalizeForwardedClientIpHeader(raw);
|
||||
if (!header) {
|
||||
return "invalid";
|
||||
}
|
||||
if (
|
||||
form.forwarded_client_ip_headers.some(
|
||||
(item) => item.toLowerCase() === header.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
return "duplicate";
|
||||
}
|
||||
if (form.forwarded_client_ip_headers.length >= maxForwardedClientIpHeaders) {
|
||||
return "full";
|
||||
}
|
||||
form.forwarded_client_ip_headers = [
|
||||
...form.forwarded_client_ip_headers,
|
||||
header,
|
||||
];
|
||||
return "added";
|
||||
}
|
||||
|
||||
function showForwardedClientIpHeaderError(result: ForwardedClientIpHeaderResult) {
|
||||
if (result === "invalid") {
|
||||
appStore.showError(t("admin.settings.apiKeyAcl.forwardedClientIpHeaderInvalid"));
|
||||
} else if (result === "full") {
|
||||
appStore.showError(
|
||||
t("admin.settings.apiKeyAcl.forwardedClientIpHeadersLimit", {
|
||||
max: maxForwardedClientIpHeaders,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function commitForwardedClientIpHeaderDraft() {
|
||||
const draft = forwardedClientIpHeaderDraft.value;
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
const result = addForwardedClientIpHeader(draft);
|
||||
showForwardedClientIpHeaderError(result);
|
||||
forwardedClientIpHeaderDraft.value = "";
|
||||
}
|
||||
|
||||
function handleForwardedClientIpHeaderKeydown(event: KeyboardEvent) {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (forwardedClientIpHeaderSeparatorKeys.has(event.key)) {
|
||||
event.preventDefault();
|
||||
commitForwardedClientIpHeaderDraft();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key === "Backspace" &&
|
||||
!forwardedClientIpHeaderDraft.value &&
|
||||
form.forwarded_client_ip_headers.length > 0
|
||||
) {
|
||||
form.forwarded_client_ip_headers.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function handleForwardedClientIpHeaderPaste(event: ClipboardEvent) {
|
||||
const text = event.clipboardData?.getData("text") || "";
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
let error: ForwardedClientIpHeaderResult | undefined;
|
||||
for (const token of text.split(/[,,;\r\n]+/)) {
|
||||
if (!token.trim()) {
|
||||
continue;
|
||||
}
|
||||
const result = addForwardedClientIpHeader(token);
|
||||
if (result === "invalid" || result === "full") {
|
||||
error = result;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
showForwardedClientIpHeaderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Quota notify email helpers
|
||||
const addQuotaNotifyEmail = () => {
|
||||
if (!form.account_quota_notify_emails) {
|
||||
@@ -9354,6 +9553,10 @@ async function loadSettings() {
|
||||
normalizeRegistrationEmailSuffixDomains(
|
||||
settings.registration_email_suffix_whitelist,
|
||||
);
|
||||
form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders(
|
||||
settings.forwarded_client_ip_headers,
|
||||
);
|
||||
forwardedClientIpHeaderDraft.value = "";
|
||||
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
|
||||
Array.isArray(settings.table_page_size_options)
|
||||
? settings.table_page_size_options
|
||||
@@ -9595,6 +9798,9 @@ async function saveSettings() {
|
||||
form.login_agreement_mode =
|
||||
form.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
|
||||
form.login_agreement_documents = normalizedLoginAgreementDocuments;
|
||||
form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders(
|
||||
form.forwarded_client_ip_headers,
|
||||
);
|
||||
|
||||
const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings(
|
||||
form.default_subscriptions,
|
||||
@@ -9728,6 +9934,7 @@ async function saveSettings() {
|
||||
turnstile_site_key: form.turnstile_site_key,
|
||||
turnstile_secret_key: form.turnstile_secret_key || undefined,
|
||||
api_key_acl_trust_forwarded_ip: form.api_key_acl_trust_forwarded_ip,
|
||||
forwarded_client_ip_headers: form.forwarded_client_ip_headers,
|
||||
linuxdo_connect_enabled: form.linuxdo_connect_enabled,
|
||||
linuxdo_connect_client_id: form.linuxdo_connect_client_id,
|
||||
linuxdo_connect_client_secret:
|
||||
@@ -9993,6 +10200,10 @@ async function saveSettings() {
|
||||
normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist,
|
||||
);
|
||||
form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders(
|
||||
updated.forwarded_client_ip_headers,
|
||||
);
|
||||
forwardedClientIpHeaderDraft.value = "";
|
||||
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
|
||||
Array.isArray(updated.table_page_size_options)
|
||||
? updated.table_page_size_options
|
||||
|
||||
@@ -368,6 +368,7 @@ const baseSettingsResponse = {
|
||||
turnstile_site_key: "",
|
||||
turnstile_secret_key_configured: false,
|
||||
api_key_acl_trust_forwarded_ip: true,
|
||||
forwarded_client_ip_headers: [],
|
||||
linuxdo_connect_enabled: false,
|
||||
linuxdo_connect_client_id: "",
|
||||
linuxdo_connect_client_secret_configured: false,
|
||||
@@ -653,10 +654,11 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
expect(wrapper.text()).not.toContain("支付来源");
|
||||
});
|
||||
|
||||
it("loads and saves the forwarded client IP takeover switch", async () => {
|
||||
it("loads, edits, validates, and saves forwarded client-IP headers", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
api_key_acl_trust_forwarded_ip: false,
|
||||
forwarded_client_ip_headers: ["cf-connecting-ip", "X-Real-IP"],
|
||||
});
|
||||
const wrapper = mountView();
|
||||
|
||||
@@ -669,14 +671,43 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
expect(card).toBeDefined();
|
||||
const toggle = card!.get('input[type="checkbox"]');
|
||||
expect((toggle.element as HTMLInputElement).checked).toBe(false);
|
||||
expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false);
|
||||
|
||||
await toggle.setValue(true);
|
||||
expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(2);
|
||||
expect(card!.text()).toContain("Cf-Connecting-Ip");
|
||||
expect(card!.text()).toContain("X-Real-Ip");
|
||||
showError.mockClear();
|
||||
|
||||
const input = card!.get('[data-testid="forwarded-client-ip-headers-input"]');
|
||||
await input.setValue("x-client-ip");
|
||||
await input.trigger("keydown", { key: "Enter" });
|
||||
await input.setValue("X-CLIENT-IP");
|
||||
await input.trigger("keydown", { key: "Enter" });
|
||||
await input.setValue("invalid header");
|
||||
await input.trigger("keydown", { key: "Enter" });
|
||||
expect(showError).toHaveBeenCalledTimes(1);
|
||||
expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(3);
|
||||
|
||||
const realIpTag = card!
|
||||
.findAll('[data-testid="forwarded-client-ip-header-tag"]')
|
||||
.find((tag) => tag.text().includes("X-Real-Ip"));
|
||||
expect(realIpTag).toBeDefined();
|
||||
await realIpTag!.get("button").trigger("click");
|
||||
expect(card!.text()).not.toContain("X-Real-Ip");
|
||||
|
||||
await toggle.setValue(false);
|
||||
expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false);
|
||||
await toggle.setValue(true);
|
||||
expect(card!.text()).toContain("X-Client-Ip");
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
api_key_acl_trust_forwarded_ip: true,
|
||||
forwarded_client_ip_headers: ["Cf-Connecting-Ip", "X-Client-Ip"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user