mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 10:27:46 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -921,6 +921,8 @@ const showDetail = ref(false)
|
||||
const selectedTask = ref<AsyncTaskDetail | null>(null)
|
||||
const detailAutoRefresh = ref(false)
|
||||
let detailRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
const authenticatedVideoUrls = ref<Record<string, string>>({})
|
||||
let authenticatedVideoLoadGeneration = 0
|
||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||
let overviewRefreshInFlight = false
|
||||
|
||||
@@ -1015,8 +1017,10 @@ async function refreshOverview() {
|
||||
// 打开任务详情
|
||||
async function openTaskDetail(task: AsyncTaskItem) {
|
||||
try {
|
||||
selectedTask.value = await asyncTasksApi.getDetail(task.id)
|
||||
const detail = await asyncTasksApi.getDetail(task.id)
|
||||
selectedTask.value = detail
|
||||
showDetail.value = true
|
||||
await loadAuthenticatedVideo(detail)
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: '获取任务详情失败',
|
||||
@@ -1030,7 +1034,9 @@ async function openTaskDetail(task: AsyncTaskItem) {
|
||||
async function refreshTaskDetail() {
|
||||
if (!selectedTask.value) return
|
||||
try {
|
||||
selectedTask.value = await asyncTasksApi.getDetail(selectedTask.value.id)
|
||||
const detail = await asyncTasksApi.getDetail(selectedTask.value.id)
|
||||
selectedTask.value = detail
|
||||
await loadAuthenticatedVideo(detail)
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: '刷新失败',
|
||||
@@ -1079,6 +1085,7 @@ function stopDetailAutoRefresh() {
|
||||
// 关闭详情抽屉
|
||||
function closeDetail() {
|
||||
stopDetailAutoRefresh()
|
||||
clearAuthenticatedVideoUrls()
|
||||
showDetail.value = false
|
||||
selectedTask.value = null
|
||||
}
|
||||
@@ -1215,16 +1222,52 @@ function formatFileSize(bytes: number | null): string {
|
||||
return `${size.toFixed(unitIndex > 0 ? 2 : 0)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
// 获取视频 URL(需要认证的 Google URL 使用代理)
|
||||
function getVideoUrl(taskId: string, originalUrl: string): string {
|
||||
// Google API 链接需要代理
|
||||
if (originalUrl.includes('generativelanguage.googleapis.com')) {
|
||||
// 从 localStorage 获取 token 作为 query param
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
return `/api/admin/video-tasks/${taskId}/video?token=${encodeURIComponent(token)}`
|
||||
function authenticatedVideoKey(taskId: string, originalUrl: string): string {
|
||||
return `${taskId}\n${originalUrl}`
|
||||
}
|
||||
|
||||
function requiresAuthenticatedVideoProxy(originalUrl: string): boolean {
|
||||
try {
|
||||
return new URL(originalUrl).hostname === 'generativelanguage.googleapis.com'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuthenticatedVideoUrls() {
|
||||
authenticatedVideoLoadGeneration += 1
|
||||
for (const objectUrl of Object.values(authenticatedVideoUrls.value)) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
authenticatedVideoUrls.value = {}
|
||||
}
|
||||
|
||||
async function loadAuthenticatedVideo(task: AsyncTaskDetail) {
|
||||
const candidates = [task.video_url, ...(task.video_urls || [])]
|
||||
.filter((value): value is string => Boolean(value && requiresAuthenticatedVideoProxy(value)))
|
||||
if (candidates.length === 0) return
|
||||
|
||||
const generation = ++authenticatedVideoLoadGeneration
|
||||
try {
|
||||
const blob = await asyncTasksApi.getVideoBlob(task.id)
|
||||
if (generation !== authenticatedVideoLoadGeneration || selectedTask.value?.id !== task.id) {
|
||||
return
|
||||
}
|
||||
return `/api/admin/video-tasks/${taskId}/video`
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
const next = { ...authenticatedVideoUrls.value }
|
||||
for (const originalUrl of candidates) {
|
||||
next[authenticatedVideoKey(task.id, originalUrl)] = objectUrl
|
||||
}
|
||||
authenticatedVideoUrls.value = next
|
||||
} catch (error) {
|
||||
log.error('Failed to load authenticated video preview', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取视频 URL(需要认证的 Google URL 使用 Bearer 请求后的临时 Blob URL)
|
||||
function getVideoUrl(taskId: string, originalUrl: string): string {
|
||||
if (requiresAuthenticatedVideoProxy(originalUrl)) {
|
||||
return authenticatedVideoUrls.value[authenticatedVideoKey(taskId, originalUrl)] || ''
|
||||
}
|
||||
return originalUrl
|
||||
}
|
||||
@@ -1339,6 +1382,7 @@ onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopAutoRefresh()
|
||||
stopDetailAutoRefresh()
|
||||
clearAuthenticatedVideoUrls()
|
||||
clearTimeout(filterTimeout)
|
||||
})
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -379,7 +379,8 @@ async function handleEdit(node: ProxyNode) {
|
||||
name: detail.name,
|
||||
proxy_url: detail.proxy_url || '',
|
||||
username: detail.proxy_username || '',
|
||||
password: detail.proxy_password || '',
|
||||
// 密码是 write-only;留空时更新接口会保留已存密码。
|
||||
password: '',
|
||||
region: detail.region || '',
|
||||
}
|
||||
addMode.value = 'manual'
|
||||
|
||||
@@ -389,7 +389,6 @@ async function loadRewards() {
|
||||
offset: 0
|
||||
})
|
||||
rewards.value = response.items
|
||||
stats.value = response.stats
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
|
||||
@@ -247,6 +247,11 @@ import {
|
||||
} from '@/features/users/components/user-management-config'
|
||||
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
entitlementsWillReplaceExisting,
|
||||
isPlanEntitlementReplacementCandidate,
|
||||
usagePolicyEntitlementLabels,
|
||||
} from '@/utils/billingEntitlements'
|
||||
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
|
||||
import { log } from '@/utils/logger'
|
||||
import { useBatchSelection } from '@/composables/useBatchSelection'
|
||||
@@ -558,7 +563,7 @@ function formatPlanDuration(plan: BillingPlan): string {
|
||||
}
|
||||
|
||||
function entitlementLabels(items: BillingEntitlement[] | undefined): string[] {
|
||||
return (items || []).map((item) => {
|
||||
return (items || []).flatMap((item) => {
|
||||
if (item.type === 'wallet_credit') {
|
||||
return `${legacyT('附赠余额')} $${Number(item.amount_usd || 0).toFixed(2)}`
|
||||
}
|
||||
@@ -568,12 +573,19 @@ function entitlementLabels(items: BillingEntitlement[] | undefined): string[] {
|
||||
if (item.type === 'membership_group') {
|
||||
return legacyT('会员权益')
|
||||
}
|
||||
return item.type
|
||||
if (item.type === 'usage_policy') {
|
||||
return usagePolicyEntitlementLabels(item)
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function hasPackageEntitlement(items: BillingEntitlement[] | undefined): boolean {
|
||||
return (items || []).some((item) => item.type === 'daily_quota' || item.type === 'membership_group')
|
||||
return (items || []).some((item) =>
|
||||
item.type === 'daily_quota'
|
||||
|| item.type === 'membership_group'
|
||||
|| item.type === 'usage_policy'
|
||||
)
|
||||
}
|
||||
|
||||
async function loadUserWallets(options: { cacheTtlMs?: number } = {}) {
|
||||
@@ -869,6 +881,21 @@ async function loadAvailableBillingPlans() {
|
||||
|
||||
async function grantPlanToSelectedUser() {
|
||||
if (!selectedUser.value || !selectedGrantPlanId.value) return
|
||||
const selectedPlan = availableBillingPlans.value.find(
|
||||
plan => plan.id === selectedGrantPlanId.value,
|
||||
)
|
||||
const replacesExisting = selectedPlan && userPlanEntitlements.value.some(item =>
|
||||
isPlanEntitlementReplacementCandidate(item)
|
||||
&& entitlementsWillReplaceExisting(selectedPlan.entitlements, item.entitlements)
|
||||
)
|
||||
if (replacesExisting) {
|
||||
const confirmed = await confirmDanger(
|
||||
legacyT('发放成功后,冲突的旧套餐及其组合权益会整包失效。确定继续发放吗?'),
|
||||
legacyT('确认替换旧套餐'),
|
||||
legacyT('继续发放'),
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
grantingUserPlan.value = true
|
||||
try {
|
||||
const response = await usersApi.grantUserPlan(selectedUser.value.id, {
|
||||
|
||||
@@ -1215,7 +1215,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="canFailRefund(currentRefund.status)"
|
||||
v-if="canFailRefund(currentRefund)"
|
||||
class="rounded-xl border border-border/60 p-4 space-y-2"
|
||||
>
|
||||
<Label>驳回原因</Label>
|
||||
@@ -1264,7 +1264,7 @@
|
||||
{{ submittingRefundAction ? '提交中...' : '确认完成' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canFailRefund(currentRefund.status)"
|
||||
v-if="canFailRefund(currentRefund)"
|
||||
variant="destructive"
|
||||
:disabled="submittingRefundAction"
|
||||
@click="submitFailRefund"
|
||||
@@ -2121,8 +2121,12 @@ function canProcessRefund(status: string) {
|
||||
return status === 'pending_approval' || status === 'approved'
|
||||
}
|
||||
|
||||
function canFailRefund(status: string) {
|
||||
return status === 'processing' || status === 'pending_approval' || status === 'approved'
|
||||
function canFailRefund(refund: Pick<AdminGlobalRefund, 'status' | 'refund_mode' | 'gateway_refund_id' | 'payout_proof'>) {
|
||||
if (refund.status === 'pending_approval' || refund.status === 'approved') return true
|
||||
return refund.status === 'processing'
|
||||
&& refund.refund_mode === 'offline_payout'
|
||||
&& !refund.gateway_refund_id?.trim()
|
||||
&& !refund.payout_proof
|
||||
}
|
||||
|
||||
function canCompleteRefund(status: string) {
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
注意:完整备份会先导入配置数据,再导入用户数据;文件包含用户、用户组、API Keys、Key 用量、钱包快照与统计聚合。正常导出的 API Keys 会在导入时使用目标系统密钥重新加密;仅当备份中包含 key_encrypted 等未解密密文字段时,才需要目标系统使用兼容的 ENCRYPTION_KEY。
|
||||
注意:完整备份会先导入配置数据,再导入用户数据。用户密码及用户和独立余额 API Key 凭据不会导出或恢复;仅保留账户、Key 元数据、用量、钱包快照与统计聚合。导入后的这些 Keys 默认禁用,需重置用户密码并重新签发 Keys 后再使用。
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -304,6 +304,7 @@
|
||||
:model-value="referralRechargePercent"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
class="mt-1"
|
||||
@update:model-value="$emit('update:referralRechargePercent', Number($event))"
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
已存在的配置将被导入的配置覆盖
|
||||
</template>
|
||||
<template v-else>
|
||||
如果发现任何冲突,导入将中止并回滚
|
||||
如果发现任何冲突,导入将在写入前预检并中止
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -59,13 +59,13 @@
|
||||
已存在的用户将被导入的数据覆盖
|
||||
</template>
|
||||
<template v-else>
|
||||
如果发现任何冲突,导入将中止并回滚
|
||||
如果发现任何冲突,导入将在写入前预检并中止
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
注意:用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作。
|
||||
注意:备份不会导出或恢复用户密码及 API Key 凭据。导入仅恢复账户、Key 元数据、用量与钱包数据;导入后的用户和独立余额 API Keys 默认禁用,需重置用户密码并重新签发 Keys 后再使用。
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('useSystemConfig', () => {
|
||||
const state = useSystemConfig()
|
||||
await state.loadSystemConfig()
|
||||
|
||||
expect(state.systemConfig.value.request_record_level).toBe('full')
|
||||
expect(state.systemConfig.value.request_record_level).toBe('basic')
|
||||
expect(state.systemConfig.value).not.toHaveProperty('max_request_body_size')
|
||||
expect(state.systemConfig.value).not.toHaveProperty('max_response_body_size')
|
||||
})
|
||||
|
||||
@@ -136,7 +136,7 @@ function createDefaultConfig(): SystemConfig {
|
||||
// 格式转换
|
||||
enable_format_conversion: false,
|
||||
// 请求记录
|
||||
request_record_level: 'full',
|
||||
request_record_level: 'basic',
|
||||
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
|
||||
// 请求记录清理
|
||||
enable_auto_cleanup: true,
|
||||
|
||||
Reference in New Issue
Block a user