Merge branch 'main' into fix/issues-4561-4562-4566-4582

Resolve const-block conflict in openai_gateway_grok_cache.go:
keep #4590's client tool cache constants, drop grokFreeRolling24hTokenLimit
(moved to pkg/xai as IsGrokFreeRolling24hTokenLimit with legacy 2M support).
This commit is contained in:
shaw
2026-07-20 11:25:11 +08:00
117 changed files with 7309 additions and 469 deletions
+150 -2
View File
@@ -54,6 +54,81 @@
</div>
</div>
<!-- Async image object storage -->
<div class="card p-6">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.backup.imageStorage.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.backup.imageStorage.description') }}
</p>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="imageStorageForm.enabled" type="checkbox" />
<span>{{ t('admin.backup.imageStorage.enabled') }}</span>
</label>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="imageStorageForm.reuse_backup_s3" type="checkbox" />
<span>{{ t('admin.backup.imageStorage.reuseBackupS3') }}</span>
</label>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.bucket') }}</label>
<input v-model="imageStorageForm.bucket" class="input w-full" :placeholder="imageStorageForm.reuse_backup_s3 ? t('admin.backup.imageStorage.bucketInherited') : ''" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.prefix') }}</label>
<input v-model="imageStorageForm.prefix" class="input w-full" placeholder="images/" />
</div>
<template v-if="!imageStorageForm.reuse_backup_s3">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.endpoint') }}</label>
<input v-model="imageStorageForm.endpoint" class="input w-full" placeholder="https://<account_id>.r2.cloudflarestorage.com" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.region') }}</label>
<input v-model="imageStorageForm.region" class="input w-full" placeholder="auto" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.accessKeyId') }}</label>
<input v-model="imageStorageForm.access_key_id" class="input w-full" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.secretAccessKey') }}</label>
<input v-model="imageStorageForm.secret_access_key" type="password" class="input w-full" :placeholder="imageStorageSecretConfigured ? t('admin.backup.s3.secretConfigured') : ''" />
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
<input v-model="imageStorageForm.force_path_style" type="checkbox" />
<span>{{ t('admin.backup.s3.forcePathStyle') }}</span>
</label>
</template>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.publicBaseUrl') }}</label>
<input v-model="imageStorageForm.public_base_url" class="input w-full" :placeholder="t('admin.backup.imageStorage.publicBaseUrlPlaceholder')" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.presignExpiryHours') }}</label>
<input v-model.number="imageStorageForm.presign_expiry_hours" type="number" min="1" class="input w-full" />
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary btn-sm" :disabled="testingImageStorage" @click="testImageStorage">
{{ testingImageStorage ? t('common.loading') : t('admin.backup.s3.testConnection') }}
</button>
<button type="button" class="btn btn-primary btn-sm" :disabled="savingImageStorage" @click="saveImageStorageConfig">
{{ savingImageStorage ? t('common.loading') : t('common.save') }}
</button>
</div>
</div>
<!-- Schedule Config -->
<div class="card p-6">
<div class="mb-4">
@@ -284,7 +359,12 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api'
import { useAppStore } from '@/stores'
import type { BackupS3Config, BackupScheduleConfig, BackupRecord } from '@/api/admin/backup'
import type {
BackupS3Config,
BackupScheduleConfig,
BackupRecord,
ImageStorageConfig,
} from '@/api/admin/backup'
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
@@ -317,6 +397,26 @@ const s3SecretConfigured = ref(false)
const savingS3 = ref(false)
const testingS3 = ref(false)
// Async image object storage. Shares the S3 client with backups, so the default is
// to reuse the credentials configured above and only differ by prefix.
const imageStorageForm = ref<ImageStorageConfig>({
enabled: false,
reuse_backup_s3: true,
bucket: '',
prefix: 'images/',
public_base_url: '',
presign_expiry_hours: 24,
max_download_bytes: 33554432,
endpoint: '',
region: 'auto',
access_key_id: '',
secret_access_key: '',
force_path_style: false,
})
const imageStorageSecretConfigured = ref(false)
const savingImageStorage = ref(false)
const testingImageStorage = ref(false)
// Schedule config
const scheduleForm = ref<BackupScheduleConfig>({
enabled: false,
@@ -485,6 +585,54 @@ async function saveS3Config() {
}
}
async function loadImageStorageConfig() {
try {
const { config, secret_configured } = await adminAPI.backup.getImageStorageConfig()
imageStorageForm.value = {
...config,
prefix: config.prefix || 'images/',
region: config.region || 'auto',
secret_access_key: '',
}
imageStorageSecretConfigured.value = secret_configured
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
}
}
async function saveImageStorageConfig() {
savingImageStorage.value = true
try {
await backupStepUp.run(() => adminAPI.backup.updateImageStorageConfig(imageStorageForm.value))
appStore.showSuccess(t('admin.backup.imageStorage.saved'))
await loadImageStorageConfig()
} catch (error) {
if (isStepUpCancelled(error)) {
savingImageStorage.value = false
return
}
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
savingImageStorage.value = false
}
}
async function testImageStorage() {
testingImageStorage.value = true
try {
const result = await adminAPI.backup.testImageStorageConnection(imageStorageForm.value)
if (result.ok) {
appStore.showSuccess(result.message || t('admin.backup.s3.testSuccess'))
} else {
appStore.showError(result.message || t('admin.backup.s3.testFailed'))
}
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
testingImageStorage.value = false
}
}
async function testS3() {
testingS3.value = true
try {
@@ -642,7 +790,7 @@ function formatDate(value?: string): string {
onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityChange)
await Promise.all([loadS3Config(), loadSchedule(), loadBackups()])
await Promise.all([loadS3Config(), loadImageStorageConfig(), loadSchedule(), loadBackups()])
// 如果有正在 running 的备份,恢复轮询
const runningBackup = backups.value.find(r => r.status === 'running')
+212 -1
View File
@@ -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 状态
@@ -8393,7 +8454,8 @@ const form = reactive<SettingsForm>({
turnstile_site_key: "",
turnstile_secret_key: "",
turnstile_secret_key_configured: false,
api_key_acl_trust_forwarded_ip: 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
@@ -351,7 +351,7 @@
: 'text-gray-700 dark:text-gray-300'
"
>
{{ formatDateOnly(value) }}
{{ formatDateTimeToMinute(value) }}
</span>
<div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500">
{{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }}
@@ -598,7 +598,7 @@
<span class="font-medium text-gray-900 dark:text-white">
{{
extendingSubscription.expires_at
? formatDateOnly(extendingSubscription.expires_at)
? formatDateTimeToMinute(extendingSubscription.expires_at)
: t('admin.subscriptions.noExpiration')
}}
</span>
@@ -764,7 +764,7 @@ import { adminAPI } from '@/api/admin'
import type { UserSubscription, Group, GroupPlatform, SubscriptionType } from '@/types'
import type { SimpleUser } from '@/api/admin/usage'
import type { Column } from '@/components/common/types'
import { formatDateOnly } from '@/utils/format'
import { formatDateTimeToMinute } from '@/utils/format'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
@@ -367,6 +367,8 @@ const baseSettingsResponse = {
turnstile_enabled: false,
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,
@@ -652,6 +654,64 @@ describe("admin SettingsView payment visible method controls", () => {
expect(wrapper.text()).not.toContain("支付来源");
});
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();
await flushPromises();
await openSecurityTab(wrapper);
const card = wrapper
.findAll(".card")
.find((node) => node.text().includes("admin.settings.apiKeyAcl.title"));
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"],
}),
);
});
it("links payment guidance to README sections instead of removed payment docs", async () => {
const wrapper = mountView();
@@ -286,6 +286,29 @@ const fallbackPlaceholders = [
"{{report_type}}",
"{{report_start_time}}",
"{{report_end_time}}",
"{{report_summary_display}}",
"{{report_detail_display}}",
"{{report_total_requests}}",
"{{report_success_count}}",
"{{report_sla_error_count}}",
"{{report_business_limited_count}}",
"{{report_sla}}",
"{{report_error_rate}}",
"{{report_upstream_error_rate}}",
"{{report_upstream_error_count_excl_429_529}}",
"{{report_upstream_429_count}}",
"{{report_upstream_529_count}}",
"{{report_latency_p50}}",
"{{report_latency_p99}}",
"{{report_ttft_p50}}",
"{{report_ttft_p99}}",
"{{report_tokens}}",
"{{report_qps_current}}",
"{{report_qps_peak}}",
"{{report_qps_avg}}",
"{{report_tps_current}}",
"{{report_tps_peak}}",
"{{report_tps_avg}}",
"{{report_html}}",
];
@@ -374,7 +397,7 @@ const eventDisplayMeta: Record<string, EventDisplayMeta> = {
},
"ops.scheduled_report": {
label: "运维定时报表",
timing: "运维日报、周报、错误摘要或账号健康报表到达配置的发送时间时发送。",
timing: "运维日报、周报、错误摘要或账号健康报表到达配置的发送时间时发送;日报和周报的完整指标均可在模板中编辑。",
categoryLabel: "运维",
},
};
@@ -437,7 +460,7 @@ const eventDisplayMetaEn: Record<string, EventDisplayMeta> = {
},
"ops.scheduled_report": {
label: "Ops Scheduled Report",
timing: "Sent when a configured daily, weekly, error digest, or account health report reaches its scheduled send time.",
timing: "Sent when a configured daily, weekly, error digest, or account health report reaches its scheduled send time. Every daily and weekly summary metric is editable in this template.",
categoryLabel: "Ops",
},
};
@@ -505,7 +528,9 @@ const selectedEventDescription = computed(() => {
});
const placeholderList = computed(() => {
const combined = [...placeholders.value, ...fallbackPlaceholders];
const combined = placeholders.value.length
? placeholders.value
: fallbackPlaceholders;
return Array.from(
new Set(
combined