mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
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:
@@ -60,6 +60,52 @@ export async function testS3Connection(config: BackupS3Config): Promise<TestS3Re
|
||||
return data
|
||||
}
|
||||
|
||||
// Async image object storage
|
||||
//
|
||||
// Shares the S3 client with backups, so `reuse_backup_s3` borrows the endpoint and
|
||||
// credentials configured above and only keeps its own bucket/prefix.
|
||||
export interface ImageStorageConfig {
|
||||
enabled: boolean
|
||||
reuse_backup_s3: boolean
|
||||
bucket: string
|
||||
prefix: string
|
||||
public_base_url: string
|
||||
presign_expiry_hours: number
|
||||
max_download_bytes: number
|
||||
endpoint: string
|
||||
region: string
|
||||
access_key_id: string
|
||||
secret_access_key?: string
|
||||
force_path_style: boolean
|
||||
}
|
||||
|
||||
export interface ImageStorageConfigResponse {
|
||||
config: ImageStorageConfig
|
||||
secret_configured: boolean
|
||||
}
|
||||
|
||||
export async function getImageStorageConfig(): Promise<ImageStorageConfigResponse> {
|
||||
const { data } = await apiClient.get<ImageStorageConfigResponse>('/admin/backups/image-storage')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateImageStorageConfig(
|
||||
config: ImageStorageConfig,
|
||||
): Promise<ImageStorageConfig> {
|
||||
const { data } = await apiClient.put<ImageStorageConfig>('/admin/backups/image-storage', config)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function testImageStorageConnection(
|
||||
config: ImageStorageConfig,
|
||||
): Promise<TestS3Response> {
|
||||
const { data } = await apiClient.post<TestS3Response>(
|
||||
'/admin/backups/image-storage/test',
|
||||
config,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
// Schedule
|
||||
export async function getSchedule(): Promise<BackupScheduleConfig> {
|
||||
const { data } = await apiClient.get<BackupScheduleConfig>('/admin/backups/schedule')
|
||||
@@ -106,6 +152,9 @@ export const backupAPI = {
|
||||
getS3Config,
|
||||
updateS3Config,
|
||||
testS3Connection,
|
||||
getImageStorageConfig,
|
||||
updateImageStorageConfig,
|
||||
testImageStorageConnection,
|
||||
getSchedule,
|
||||
updateSchedule,
|
||||
createBackup,
|
||||
|
||||
@@ -455,6 +455,7 @@ export interface SystemSettings {
|
||||
turnstile_site_key: string;
|
||||
turnstile_secret_key_configured: boolean;
|
||||
api_key_acl_trust_forwarded_ip: boolean;
|
||||
forwarded_client_ip_headers: string[];
|
||||
|
||||
// LinuxDo Connect OAuth settings
|
||||
linuxdo_connect_enabled: boolean;
|
||||
@@ -757,6 +758,7 @@ export interface UpdateSettingsRequest {
|
||||
turnstile_site_key?: string;
|
||||
turnstile_secret_key?: string;
|
||||
api_key_acl_trust_forwarded_ip?: boolean;
|
||||
forwarded_client_ip_headers?: string[];
|
||||
linuxdo_connect_enabled?: boolean;
|
||||
linuxdo_connect_client_id?: string;
|
||||
linuxdo_connect_client_secret?: string;
|
||||
|
||||
@@ -61,12 +61,22 @@ export async function getRollbackVersions(): Promise<{ versions: RollbackVersion
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place update/rollback downloads a full release binary from GitHub, which
|
||||
* can take several minutes on slow links. The global 30s axios timeout would
|
||||
* abort the request mid-download (#4504), so these calls wait as long as the
|
||||
* backend allows (15 minutes server-side).
|
||||
*/
|
||||
const UPDATE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Perform system update
|
||||
* Downloads and applies the latest version
|
||||
*/
|
||||
export async function performUpdate(): Promise<UpdateResult> {
|
||||
const { data } = await apiClient.post<UpdateResult>('/admin/system/update')
|
||||
const { data } = await apiClient.post<UpdateResult>('/admin/system/update', undefined, {
|
||||
timeout: UPDATE_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -77,7 +87,8 @@ export async function performUpdate(): Promise<UpdateResult> {
|
||||
export async function rollback(version?: string): Promise<UpdateResult> {
|
||||
const { data } = await apiClient.post<UpdateResult>(
|
||||
'/admin/system/rollback',
|
||||
version ? { version } : undefined
|
||||
version ? { version } : undefined,
|
||||
{ timeout: UPDATE_REQUEST_TIMEOUT_MS }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -428,6 +428,26 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Grok OAuth client-tool prompt cache opt-in -->
|
||||
<div
|
||||
v-if="account.platform === 'grok' && account.type === 'oauth'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.grokClientToolCache.title') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.grokClientToolCache.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="grokClientToolCacheEnabled"
|
||||
data-testid="grok-client-tool-cache-toggle"
|
||||
:aria-label="t('admin.accounts.grokClientToolCache.title')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grok OAuth Custom Upstream URL (仅改写转发端点,OAuth 授权/刷新不受影响) -->
|
||||
<div
|
||||
v-if="account.platform === 'grok' && account.type === 'oauth'"
|
||||
@@ -2699,6 +2719,7 @@ const allowedModels = ref<string[]>([])
|
||||
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
|
||||
const MAX_POOL_MODE_RETRY_COUNT = 10
|
||||
const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429]
|
||||
const GROK_CLIENT_TOOL_CACHE_EXTRA_KEY = 'grok_client_tool_cache_enabled'
|
||||
const poolModeEnabled = ref(false)
|
||||
const poolModeRetryCount = ref(DEFAULT_POOL_MODE_RETRY_COUNT)
|
||||
const poolModeRetryStatusCodesInput = ref('')
|
||||
@@ -2747,6 +2768,9 @@ const headerOverrideCapable = computed(
|
||||
// Grok OAuth 自定义上游地址(仅转发端点;OAuth 授权/令牌刷新不受影响)
|
||||
const grokOAuthCustomBaseUrlEnabled = ref(false)
|
||||
const grokOAuthBaseUrl = ref('')
|
||||
// Grok Free OAuth accounts use client-tool prompt caching by default. Keep an
|
||||
// explicit false in the account extra as the opt-out signal.
|
||||
const grokClientToolCacheEnabled = ref(true)
|
||||
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(false)
|
||||
@@ -3402,6 +3426,14 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
// Load Grok OAuth custom upstream URL state(存储的官方地址视同未定制)
|
||||
grokOAuthCustomBaseUrlEnabled.value = false
|
||||
grokOAuthBaseUrl.value = ''
|
||||
const grokClientToolCacheSetting =
|
||||
newAccount.platform === 'grok' && newAccount.type === 'oauth'
|
||||
? newAccount.extra?.[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY]
|
||||
: undefined
|
||||
grokClientToolCacheEnabled.value =
|
||||
newAccount.platform === 'grok' &&
|
||||
newAccount.type === 'oauth' &&
|
||||
(grokClientToolCacheSetting === undefined || grokClientToolCacheSetting === true)
|
||||
if (newAccount.platform === 'grok' && newAccount.type === 'oauth' && newAccount.credentials) {
|
||||
const grokCreds = newAccount.credentials as Record<string, unknown>
|
||||
if (isCustomGrokBaseUrl(grokCreds.base_url)) {
|
||||
@@ -4288,6 +4320,14 @@ const handleSubmit = async () => {
|
||||
applyHeaderOverride(newCredentials, headerOverrideEnabled.value, headerOverrideRows.value, 'edit')
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
|
||||
const newExtra: Record<string, unknown> = {
|
||||
...((props.account.extra as Record<string, unknown>) || {})
|
||||
}
|
||||
// Persist both states so a disabled account remains opted out when the
|
||||
// backend applies the default-enabled policy to missing values.
|
||||
newExtra[GROK_CLIENT_TOOL_CACHE_EXTRA_KEY] = grokClientToolCacheEnabled.value
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
// OpenAI: 手动覆盖订阅档位 plan_type(Plus/Pro/Free)。仅 OAuth 非影子账号:
|
||||
|
||||
@@ -67,7 +67,10 @@ const BaseDialogStub = defineComponent({
|
||||
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
|
||||
})
|
||||
|
||||
function buildGrokOAuthAccount(credentials: Record<string, unknown> = {}) {
|
||||
function buildGrokOAuthAccount(
|
||||
credentials: Record<string, unknown> = {},
|
||||
extra: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
id: 5,
|
||||
name: 'Grok OAuth',
|
||||
@@ -80,7 +83,7 @@ function buildGrokOAuthAccount(credentials: Record<string, unknown> = {}) {
|
||||
...credentials
|
||||
},
|
||||
credentials_status: { has_access_token: true, has_refresh_token: true },
|
||||
extra: {},
|
||||
extra,
|
||||
proxy_id: null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
@@ -225,4 +228,99 @@ describe('EditAccountModal Grok OAuth upstream config', () => {
|
||||
'x-xai-token-auth': 'xai-grok-cli'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the client-tool cache switch only for Grok OAuth accounts', () => {
|
||||
const grokOAuthWrapper = mountModal(buildGrokOAuthAccount())
|
||||
expect(grokOAuthWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(true)
|
||||
|
||||
const grokAPIKeyWrapper = mountModal({
|
||||
...buildGrokOAuthAccount(),
|
||||
type: 'apikey',
|
||||
credentials: { api_key: 'xai-test', base_url: 'https://api.x.ai/v1' }
|
||||
})
|
||||
expect(grokAPIKeyWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(false)
|
||||
|
||||
const openAIOAuthWrapper = mountModal({
|
||||
...buildGrokOAuthAccount(),
|
||||
platform: 'openai'
|
||||
})
|
||||
expect(openAIOAuthWrapper.find('[data-testid="grok-client-tool-cache-toggle"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('loads and disables client-tool caching while preserving unrelated extra fields', async () => {
|
||||
const account = buildGrokOAuthAccount({}, {
|
||||
grok_client_tool_cache_enabled: true,
|
||||
custom_setting: 'keep-me'
|
||||
})
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
const toggle = wrapper.get('[data-testid="grok-client-tool-cache-toggle"]')
|
||||
expect(toggle.attributes('aria-checked')).toBe('true')
|
||||
|
||||
await toggle.trigger('click')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.extra?.custom_setting).toBe('keep-me')
|
||||
expect(payload?.extra?.grok_client_tool_cache_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults client-tool caching on when the setting is missing and persists explicit true', async () => {
|
||||
const account = buildGrokOAuthAccount({}, { custom_setting: 'keep-me' })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
const toggle = wrapper.get('[data-testid="grok-client-tool-cache-toggle"]')
|
||||
expect(toggle.attributes('aria-checked')).toBe('true')
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.extra).toMatchObject({
|
||||
grok_client_tool_cache_enabled: true,
|
||||
custom_setting: 'keep-me'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an explicit false opt-out when saving an untouched account', async () => {
|
||||
const account = buildGrokOAuthAccount(
|
||||
{},
|
||||
{
|
||||
grok_client_tool_cache_enabled: false,
|
||||
custom_setting: 'keep-me'
|
||||
}
|
||||
)
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
const toggle = wrapper.get('[data-testid="grok-client-tool-cache-toggle"]')
|
||||
expect(toggle.attributes('aria-checked')).toBe('false')
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
const payload = updateAccountMock.mock.calls[0]?.[1]
|
||||
expect(payload?.extra).toMatchObject({
|
||||
grok_client_tool_cache_enabled: false,
|
||||
custom_setting: 'keep-me'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows malformed cache settings as disabled so the UI matches the fail-closed backend', () => {
|
||||
const account = buildGrokOAuthAccount(
|
||||
{},
|
||||
{
|
||||
grok_client_tool_cache_enabled: 'true',
|
||||
custom_setting: 'keep-me'
|
||||
}
|
||||
)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
expect(
|
||||
wrapper.get('[data-testid="grok-client-tool-cache-toggle"]').attributes('aria-checked')
|
||||
).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -641,6 +641,10 @@ export default {
|
||||
official: 'Official API'
|
||||
}
|
||||
},
|
||||
grokClientToolCache: {
|
||||
title: 'Client Tool Cache (May Change Automatic Tool Selection)',
|
||||
hint: 'For detected Grok Free OAuth accounts, this is enabled by default for client function tools such as Codex and Trae. Turn it off to opt out if the automatic tool-selection behavior is not acceptable.'
|
||||
},
|
||||
autoPauseOnExpired: 'Auto Pause On Expired',
|
||||
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
|
||||
autoPause5hThreshold: '5h Usage Threshold (%)',
|
||||
|
||||
@@ -102,6 +102,19 @@ export default {
|
||||
testFailed: 'S3 connection test failed',
|
||||
saved: 'S3 configuration saved'
|
||||
},
|
||||
imageStorage: {
|
||||
title: 'Async image object storage',
|
||||
description: 'Enables the asynchronous image endpoints and offloads generated images to object storage, keeping only short links in Redis. Shares the S3 client with backups and takes effect on save — no restart needed.',
|
||||
enabled: 'Enable async image tasks',
|
||||
reuseBackupS3: 'Reuse the backup S3 configuration above (different bucket/prefix only)',
|
||||
bucket: 'Bucket',
|
||||
bucketInherited: 'Leave empty to use the backup bucket',
|
||||
prefix: 'Key prefix',
|
||||
publicBaseUrl: 'Public base URL',
|
||||
publicBaseUrlPlaceholder: 'Leave empty to return presigned links',
|
||||
presignExpiryHours: 'Presigned link TTL (hours)',
|
||||
saved: 'Async image object storage saved'
|
||||
},
|
||||
schedule: {
|
||||
title: 'Scheduled Backup',
|
||||
description: 'Configure automatic scheduled backups',
|
||||
|
||||
@@ -153,7 +153,14 @@ export default {
|
||||
'Choose which client IP is used by API Key allowlists/denylists, admin audit logs, and session IP/UA binding',
|
||||
trustForwardedIp: 'Trust forwarded client IP',
|
||||
trustForwardedIpHint:
|
||||
'Disabled by default. Enable only when the origin is reachable only through Cloudflare or Nginx reverse proxy. When enabled, API Key IP allowlists/denylists, admin audit logs, and session IP/UA binding use CF-Connecting-IP, X-Real-IP, or X-Forwarded-For, matching the request IP shown in usage records. Toggling this switch changes the IP fingerprint of existing sessions; with session binding enabled they must sign in again.'
|
||||
'Enabled by default for upgrade compatibility. When enabled, raw CF-Connecting-IP, X-Real-IP, or X-Forwarded-For values take over server.trusted_proxies for client-IP resolution. Disable it to enforce the Gin trusted-proxy chain configured by server.trusted_proxies. Only enable takeover mode when the origin cannot be reached directly. Changing this switch changes existing session IP fingerprints.',
|
||||
forwardedClientIpHeaders: 'Custom client-IP headers',
|
||||
forwardedClientIpHeadersHint: 'Add CDN or proxy header names to check before the built-in headers.',
|
||||
forwardedClientIpHeadersPlaceholder: 'X-Client-IP',
|
||||
forwardedClientIpHeadersRiskHint: 'These raw headers can be spoofed when the origin is reachable directly. Restrict origin access before trusting them.',
|
||||
forwardedClientIpHeaderInvalid: 'Enter a valid HTTP header name.',
|
||||
forwardedClientIpHeadersLimit: 'At most {max} custom client-IP headers are allowed.',
|
||||
removeForwardedClientIpHeader: 'Remove {header}'
|
||||
},
|
||||
linuxdo: {
|
||||
title: 'LinuxDo Connect Login',
|
||||
|
||||
@@ -734,6 +734,10 @@ export default {
|
||||
official: '官方 API'
|
||||
}
|
||||
},
|
||||
grokClientToolCache: {
|
||||
title: '客户端工具缓存(可能改变自动工具选择)',
|
||||
hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,默认会为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;如不接受自动工具选择行为,可关闭此开关退出。'
|
||||
},
|
||||
autoPauseOnExpired: '过期自动暂停调度',
|
||||
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
|
||||
autoPause5hThreshold: '5h 用量阈值(%)',
|
||||
|
||||
@@ -102,6 +102,19 @@ export default {
|
||||
testFailed: 'S3 连接测试失败',
|
||||
saved: 'S3 配置已保存'
|
||||
},
|
||||
imageStorage: {
|
||||
title: '异步生图对象存储',
|
||||
description: '开启后,异步生图接口可用,生成结果转存到对象存储,只把短链接写入 Redis。与备份共用同一套 S3 客户端,保存后立即生效,无需重启。',
|
||||
enabled: '启用异步生图',
|
||||
reuseBackupS3: '复用上方备份的 S3 配置(只用不同的存储桶/前缀)',
|
||||
bucket: '存储桶',
|
||||
bucketInherited: '留空则沿用备份存储桶',
|
||||
prefix: 'Key 前缀',
|
||||
publicBaseUrl: '公开访问域名',
|
||||
publicBaseUrlPlaceholder: '留空则返回预签名临时链接',
|
||||
presignExpiryHours: '预签名链接有效期(小时)',
|
||||
saved: '异步生图对象存储配置已保存'
|
||||
},
|
||||
schedule: {
|
||||
title: '定时备份',
|
||||
description: '配置自动定时备份',
|
||||
|
||||
@@ -152,7 +152,14 @@ export default {
|
||||
description: '控制 API Key 白/黑名单、操作审计日志与会话 IP/UA 绑定使用哪个客户端 IP 判断',
|
||||
trustForwardedIp: '信任反代传递的客户端 IP',
|
||||
trustForwardedIpHint:
|
||||
'默认关闭。仅在源站只允许 Cloudflare 或 Nginx 反代访问时开启;开启后 API Key IP 白/黑名单、操作审计日志与会话 IP/UA 绑定会使用 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For,与使用记录中的请求 IP 保持一致。切换本开关会改变已登录会话的 IP 指纹,开启会话绑定时现有会话需重新登录。'
|
||||
'为保证升级兼容默认开启。开启后 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For 会直接接管客户端 IP 解析并覆盖 server.trusted_proxies;关闭后严格使用 server.trusted_proxies 配置的 Gin 可信代理链。仅在源站无法被直接访问时开启接管模式。切换会改变现有会话的 IP 指纹。',
|
||||
forwardedClientIpHeaders: '自定义客户端 IP 请求头',
|
||||
forwardedClientIpHeadersHint: '添加 CDN 或反代请求头名称,解析时优先于内置请求头。',
|
||||
forwardedClientIpHeadersPlaceholder: 'X-Client-IP',
|
||||
forwardedClientIpHeadersRiskHint: '源站可被直接访问时,这些原始请求头可被伪造;请先限制源站访问再信任它们。',
|
||||
forwardedClientIpHeaderInvalid: '请输入有效的 HTTP 请求头名称。',
|
||||
forwardedClientIpHeadersLimit: '自定义客户端 IP 请求头最多允许 {max} 个。',
|
||||
removeForwardedClientIpHeader: '移除 {header}'
|
||||
},
|
||||
linuxdo: {
|
||||
title: 'LinuxDo Connect 登录',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatDateTimeToMinute } from '../format'
|
||||
|
||||
describe('formatDateTimeToMinute', () => {
|
||||
it('formats local date and time without seconds', () => {
|
||||
const value = new Date(2026, 6, 19, 20, 30, 45)
|
||||
|
||||
expect(formatDateTimeToMinute(value, 'en-GB')).toBe('19/07/2026, 20:30')
|
||||
})
|
||||
|
||||
it('returns an empty string for an invalid date', () => {
|
||||
expect(formatDateTimeToMinute(new Date('invalid'), 'en-GB')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -149,6 +149,27 @@ export function formatDateTime(
|
||||
return formatDate(date, options, localeOverride)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间(精确到分钟)
|
||||
*/
|
||||
export function formatDateTimeToMinute(
|
||||
date: string | Date | null | undefined,
|
||||
localeOverride?: string
|
||||
): string {
|
||||
return formatDate(
|
||||
date,
|
||||
{
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
},
|
||||
localeOverride
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为 date 控件值(YYYY-MM-DD,使用本地时间)
|
||||
*/
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -256,7 +256,7 @@ import subscriptionsAPI from '@/api/subscriptions'
|
||||
import type { UserSubscription } from '@/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatDateOnly } from '@/utils/format'
|
||||
import { formatDateTimeToMinute } from '@/utils/format'
|
||||
import { hasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
|
||||
import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors'
|
||||
import { getRemainingDurationParts, isOneTimeDailyQuota, type RemainingDurationParts } from '@/utils/subscriptionQuota'
|
||||
@@ -322,7 +322,7 @@ function formatExpirationDate(expiresAt: string): string {
|
||||
return t('userSubscriptions.status.expired')
|
||||
}
|
||||
|
||||
const dateStr = formatDateOnly(expires)
|
||||
const dateStr = formatDateTimeToMinute(expires)
|
||||
|
||||
if (days === 0) {
|
||||
return `${dateStr} (${t('common.today')})`
|
||||
|
||||
Reference in New Issue
Block a user