fix: 全站 bug 审查修复 (前端9项 + 后端5项)
前端修复: - P0: App.vue http 未导入导致全局搜索崩溃 - P1: TOTP 登录流程断裂 (TotpRequiredError 非 ApiError 子类) - P1: ServersPage 编辑服务器 domain 被端口字符串污染 - P1: PushPage Set[0] 无效 → values().next().value - P1: SettingsPage API 返回字符串未转数字 - P2: api/index.ts getList 重复定义移除 - P2: LoginPage 锁定倒计时 now ref + watch 更新 - P2: TerminalPage 路径验证正则放松 (允许空格/中文) - P2: useWebSocket CONNECTING/CLOSING 状态关闭旧连接 后端修复: - P1: websocket.py redis.keys() → scan_iter() 避免阻塞 - P1: auth_service.py TTL 仅在 key 无 TTL 时设置 - P1: servers.py 批量操作加 asyncio.Lock 避免并发 session commit - P2: settings.py asyncio.get_event_loop() → get_running_loop() - P2: settings.py datetime.utcfromtimestamp() → datetime.fromtimestamp(tz=utc) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"name": "Frontend Dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"cwd": "frontend",
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
|
||||
@@ -236,7 +236,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { api } from '@/api'
|
||||
import { api, http } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -107,12 +107,6 @@ export const http = {
|
||||
/** Upload FormData (multipart, auto Content-Type with boundary) */
|
||||
upload: <T = any>(path: string, formData: FormData) =>
|
||||
api<T>(path, { method: 'POST', body: formData }),
|
||||
|
||||
/** GET a list that returns a bare array — wraps in {items, total} */
|
||||
getList: async <T = any>(path: string, params?: Record<string, any>) => {
|
||||
const arr = await api<T[]>(path, { method: 'GET', params })
|
||||
return { items: (arr || []) as T[], total: (arr || []).length }
|
||||
},
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
||||
@@ -49,7 +49,14 @@ export function useWebSocket() {
|
||||
})
|
||||
|
||||
function connect() {
|
||||
if (!auth.token || ws.value?.readyState === WebSocket.OPEN) return
|
||||
if (!auth.token) return
|
||||
if (ws.value?.readyState === WebSocket.OPEN) return
|
||||
// Close stale connection if still CONNECTING/CLOSING
|
||||
if (ws.value) {
|
||||
ws.value.onclose = null // prevent reconnect loop
|
||||
ws.value.close()
|
||||
ws.value = null
|
||||
}
|
||||
|
||||
const url = `${getWsUrl()}?token=${auth.token}`
|
||||
const socket = new WebSocket(url)
|
||||
|
||||
@@ -89,10 +89,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ApiError, http } from '@/api'
|
||||
import { ApiError, TotpRequiredError, http } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -145,12 +145,23 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
if (rotateTimer) clearInterval(rotateTimer)
|
||||
if (lockoutTimer) clearInterval(lockoutTimer)
|
||||
})
|
||||
|
||||
const now = ref(Date.now())
|
||||
let lockoutTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const remainingMinutes = computed(() => {
|
||||
if (!lockoutUntil.value) return 0
|
||||
const diff = new Date(lockoutUntil.value).getTime() - Date.now()
|
||||
return Math.max(1, Math.ceil(diff / 60000))
|
||||
const diff = new Date(lockoutUntil.value).getTime() - now.value
|
||||
return Math.max(0, Math.ceil(diff / 60000))
|
||||
})
|
||||
|
||||
watch(lockoutUntil, (val) => {
|
||||
if (lockoutTimer) { clearInterval(lockoutTimer); lockoutTimer = null }
|
||||
if (val) {
|
||||
lockoutTimer = setInterval(() => { now.value = Date.now() }, 30000)
|
||||
}
|
||||
})
|
||||
|
||||
async function doLogin() {
|
||||
@@ -167,13 +178,13 @@ async function doLogin() {
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.push(redirect)
|
||||
} catch (e: any) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e instanceof TotpRequiredError) {
|
||||
needTotp.value = true
|
||||
error.value = e.message
|
||||
} else if (e instanceof ApiError) {
|
||||
if (e.status === 429) {
|
||||
lockoutUntil.value = new Date(Date.now() + 15 * 60 * 1000).toISOString()
|
||||
error.value = '登录尝试过多,账户已锁定 15 分钟'
|
||||
} else if (e.message.includes('TOTP') || e.message.includes('totp') || e.message.includes('验证码')) {
|
||||
needTotp.value = true
|
||||
error.value = e.message
|
||||
} else {
|
||||
failedAttempts.value++
|
||||
error.value = e.message
|
||||
|
||||
@@ -496,7 +496,7 @@ async function doPreview() {
|
||||
previewData.value = await http.post('/sync/preview', {
|
||||
source_path: effectiveSourcePath(),
|
||||
target_path: targetPath.value,
|
||||
server_id: selectedIds.value[0],
|
||||
server_id: selectedIds.value.values().next().value,
|
||||
sync_mode: syncMode.value,
|
||||
})
|
||||
showPreview.value = true
|
||||
|
||||
@@ -464,7 +464,7 @@ function editServer(item: ServerApiItem) {
|
||||
editing.value = true
|
||||
editingId.value = item.id
|
||||
form.value = {
|
||||
name: item.name, address: `${item.domain}:${item.port}`, category: item.category || '',
|
||||
name: item.name, address: item.domain || '', category: item.category || '',
|
||||
platform_id: item.platform_id, ssh_user: item.username || '',
|
||||
ssh_port: item.port || 22, password: '',
|
||||
}
|
||||
|
||||
@@ -252,7 +252,12 @@ async function loadSettings() {
|
||||
const key = item.key
|
||||
if (key && knownKeys.includes(key)) {
|
||||
const val = item.value
|
||||
;(settings.value as Record<string, unknown>)[key] = val
|
||||
// Type-cast numeric strings back to numbers for number inputs
|
||||
if (typeof val === 'string' && /^\d+(\.\d+)?$/.test(val)) {
|
||||
(settings.value as Record<string, unknown>)[key] = Number(val)
|
||||
} else {
|
||||
(settings.value as Record<string, unknown>)[key] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -273,7 +273,7 @@ const snackbar = useSnackbar()
|
||||
const initialServerId = computed(() => Number(route.query.server_id) || 0)
|
||||
const initialPath = computed(() => {
|
||||
const p = (route.query.path as string) || ''
|
||||
return p && /^[a-zA-Z0-9/._\-]+$/.test(p) ? p : ''
|
||||
return p && /^[^\x00-\x1f\x7f"']+$/.test(p) ? p : ''
|
||||
})
|
||||
|
||||
// ── localStorage keys ──
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
+12
-7
@@ -757,6 +757,7 @@ async def batch_detect_path(
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
|
||||
sem = asyncio.Semaphore(5)
|
||||
db_lock = asyncio.Lock()
|
||||
results = []
|
||||
|
||||
async def _detect_one(sid: int):
|
||||
@@ -786,9 +787,10 @@ async def batch_detect_path(
|
||||
# 取目录路径(可能有多个结果,取第一个)
|
||||
first_match = found.split("\n")[0].strip()
|
||||
target_dir = os.path.dirname(first_match)
|
||||
# 更新服务器 target_path
|
||||
server.target_path = target_dir
|
||||
await db.commit()
|
||||
# 更新服务器 target_path — lock to avoid concurrent session commits
|
||||
async with db_lock:
|
||||
server.target_path = target_dir
|
||||
await db.commit()
|
||||
return BatchAgentResultItem(
|
||||
server_id=sid, server_name=server_name, success=True,
|
||||
stdout=f"target_path → {target_dir}",
|
||||
@@ -833,6 +835,7 @@ async def batch_uninstall_agent(
|
||||
raise HTTPException(status_code=400, detail="NEXUS_API_BASE_URL 未配置")
|
||||
|
||||
sem = asyncio.Semaphore(5)
|
||||
db_lock = asyncio.Lock()
|
||||
results = []
|
||||
|
||||
async def _uninstall_one(sid: int):
|
||||
@@ -860,10 +863,12 @@ async def batch_uninstall_agent(
|
||||
await redis.delete(f"{REDIS_KEY_PREFIX}{sid}")
|
||||
except Exception as redis_err:
|
||||
logger.warning(f"Failed to clear Redis heartbeat for server {sid}: {redis_err}")
|
||||
server.is_online = False
|
||||
server.agent_version = None
|
||||
server.agent_api_key = None
|
||||
await db.commit()
|
||||
# Lock to avoid concurrent session commits
|
||||
async with db_lock:
|
||||
server.is_online = False
|
||||
server.agent_version = None
|
||||
server.agent_api_key = None
|
||||
await db.commit()
|
||||
|
||||
return BatchAgentResultItem(
|
||||
server_id=sid, server_name=server_name, success=ok,
|
||||
|
||||
@@ -74,7 +74,7 @@ async def _is_private_host(hostname: str) -> bool:
|
||||
pass # Not a literal IP, do DNS resolution
|
||||
# F-2: Async DNS resolution (non-blocking)
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
infos = await loop.getaddrinfo(hostname, None, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM)
|
||||
for _family, _, _, _, sockaddr in infos:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
@@ -157,12 +157,12 @@ _MAX_AGE_DAYS = 7 # auto-clean older than 7 days
|
||||
def _cleanup_old_wallpapers() -> int:
|
||||
"""Remove wallpapers older than 7 days. Returns count removed."""
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
cutoff = datetime.utcnow() - timedelta(days=_MAX_AGE_DAYS)
|
||||
from datetime import datetime, timedelta, timezone
|
||||
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=_MAX_AGE_DAYS)
|
||||
removed = 0
|
||||
for f in sorted(_WALLPAPER_DIR.glob("*.jpg")):
|
||||
try:
|
||||
mtime = datetime.utcfromtimestamp(f.stat().st_mtime)
|
||||
mtime = datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc).replace(tzinfo=None)
|
||||
if mtime < cutoff:
|
||||
f.unlink()
|
||||
removed += 1
|
||||
|
||||
@@ -125,12 +125,9 @@ class ConnectionManager:
|
||||
redis = get_redis()
|
||||
# Each worker periodically writes its count to Redis with TTL
|
||||
await redis.set(f"ws:count:{id(self)}", len(self._connections), ex=90)
|
||||
# Sum all worker counts
|
||||
keys = await redis.keys("ws:count:*")
|
||||
if not keys:
|
||||
return len(self._connections)
|
||||
# Sum all worker counts — use SCAN instead of KEYS to avoid blocking
|
||||
total = 0
|
||||
for key in keys:
|
||||
async for key in redis.scan_iter(match="ws:count:*"):
|
||||
val = await redis.get(key)
|
||||
if val:
|
||||
total += int(val)
|
||||
|
||||
@@ -537,8 +537,10 @@ class AuthService:
|
||||
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
|
||||
token_hash = self._hash_token(token)
|
||||
await redis.sadd(key, token_hash)
|
||||
# TTL = refresh token lifetime + small buffer
|
||||
await redis.expire(key, JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400 + 3600)
|
||||
# Only set expire if key has no TTL yet (avoid resetting TTL on every refresh)
|
||||
ttl = await redis.ttl(key)
|
||||
if ttl < 0:
|
||||
await redis.expire(key, JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400 + 3600)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store refresh token in Redis for admin {admin_id}: {e}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user