mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix: harden bot accounts and download handling
This commit is contained in:
+32
-8
@@ -12,19 +12,31 @@ export const api = axios.create({
|
||||
|
||||
// Flag to prevent multiple simultaneous refresh attempts
|
||||
let isRefreshing = false
|
||||
let refreshSubscribers: Array<(token: string) => void> = []
|
||||
let refreshSubscribers: Array<{
|
||||
resolve: (token: string) => void
|
||||
reject: (error: unknown) => void
|
||||
}> = []
|
||||
|
||||
// Subscribe to token refresh
|
||||
function subscribeTokenRefresh(callback: (token: string) => void) {
|
||||
refreshSubscribers.push(callback)
|
||||
function subscribeTokenRefresh(resolve: (token: string) => void, reject: (error: unknown) => void) {
|
||||
refreshSubscribers.push({ resolve, reject })
|
||||
}
|
||||
|
||||
// Notify all subscribers about new token
|
||||
function onTokenRefreshed(newToken: string) {
|
||||
refreshSubscribers.forEach(callback => callback(newToken))
|
||||
refreshSubscribers.forEach((subscriber) => subscriber.resolve(newToken))
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
function onTokenRefreshFailed(error: unknown) {
|
||||
refreshSubscribers.forEach((subscriber) => subscriber.reject(error))
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
function isRefreshRequest(config?: InternalAxiosRequestConfig | null): boolean {
|
||||
return Boolean(config?.url?.includes('/auth/refresh'))
|
||||
}
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token
|
||||
@@ -51,16 +63,21 @@ api.interceptors.response.use(
|
||||
const originalRequest = err.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
// If 401 and not already retried
|
||||
if (err.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||
if (
|
||||
err.response?.status === 401 &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry &&
|
||||
!isRefreshRequest(originalRequest)
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
// Wait for token refresh to complete
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
subscribeTokenRefresh((token: string) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
resolve(api(originalRequest))
|
||||
})
|
||||
}, reject)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -80,10 +97,17 @@ api.interceptors.response.use(
|
||||
}
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false
|
||||
refreshSubscribers = []
|
||||
onTokenRefreshFailed(refreshError)
|
||||
useAuthStore.getState().logout()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
|
||||
// Refresh failed, logout
|
||||
isRefreshing = false
|
||||
onTokenRefreshFailed(err)
|
||||
useAuthStore.getState().logout()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { KeyRound, Pencil, Plus, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react'
|
||||
import { KeyRound, Loader2, Pencil, Plus, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
@@ -188,6 +188,7 @@ function UsersPanel() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [editingID, setEditingID] = useState<string | null>(null)
|
||||
const [editingUsername, setEditingUsername] = useState('')
|
||||
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
|
||||
const refresh = async () => {
|
||||
const [nextUsers, nextLicense] = await Promise.all([
|
||||
adminAPI.listUsers(),
|
||||
@@ -243,6 +244,7 @@ function UsersPanel() {
|
||||
}
|
||||
|
||||
const resetPassword = async (u: User) => {
|
||||
if (resettingPasswordID) return
|
||||
const nextPassword = await requestPassword({
|
||||
title: `重置 ${u.username} 的密码`,
|
||||
message: '请输入新的临时密码,至少 6 位。保存后该用户可立即使用新密码登录 Web、Bot 与第三方客户端。',
|
||||
@@ -253,6 +255,7 @@ function UsersPanel() {
|
||||
toast.error('新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
setResettingPasswordID(u.id)
|
||||
try {
|
||||
await adminAPI.resetUserPassword(u.id, nextPassword)
|
||||
toast.success('密码已重置')
|
||||
@@ -261,6 +264,8 @@ function UsersPanel() {
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'重置密码失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setResettingPasswordID(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,9 +401,10 @@ function UsersPanel() {
|
||||
<button
|
||||
className="rounded-lg border border-amber-400/40 px-2 py-1 text-xs text-amber-500 hover:bg-amber-400/10"
|
||||
title="重置密码"
|
||||
disabled={resettingPasswordID === u.id}
|
||||
onClick={() => resetPassword(u)}
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
{resettingPasswordID === u.id ? <Loader2 size={12} className="animate-spin" /> : <KeyRound size={12} />}
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
|
||||
@@ -18,6 +18,7 @@ export function DownloadClientsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<DownloadClient | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [testing, setTesting] = useState<Record<string, boolean>>({})
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
@@ -33,15 +34,17 @@ export function DownloadClientsPage() {
|
||||
}, [])
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
if (testing[id]) return
|
||||
setTesting((current) => ({ ...current, [id]: true }))
|
||||
try {
|
||||
const r = await downloadClientsAPI.test(id)
|
||||
if (r.ok) toast.success('连接成功')
|
||||
else toast.error(r.error ?? '连接失败')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'测试失败'
|
||||
const msg = apiErrorMessage(err, '测试失败')
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setTesting((current) => ({ ...current, [id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +55,7 @@ export function DownloadClientsPage() {
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'删除失败'
|
||||
const msg = apiErrorMessage(err, '删除失败')
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
@@ -126,9 +127,15 @@ export function DownloadClientsPage() {
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => onTest(c.id)}
|
||||
disabled={testing[c.id]}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Send size={12} className="inline" /> 测试
|
||||
{testing[c.id] ? (
|
||||
<Loader2 size={12} className="inline animate-spin" />
|
||||
) : (
|
||||
<Send size={12} className="inline" />
|
||||
)}{' '}
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -157,7 +164,7 @@ export function DownloadClientsPage() {
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
refresh().catch((err: unknown) => toast.error(apiErrorMessage(err, '刷新下载器列表失败')))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -187,18 +194,17 @@ function ClientFormModal({
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) await downloadClientsAPI.update(editing.id, form)
|
||||
else await downloadClientsAPI.create(form)
|
||||
toast.success('已保存')
|
||||
await onSaved()
|
||||
setSaving(false)
|
||||
onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
const msg = apiErrorMessage(err, '保存失败')
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -316,6 +322,14 @@ function ClientFormModal({
|
||||
)
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
const data = (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data
|
||||
if (data?.error) return data.error
|
||||
if (data?.message) return data.message
|
||||
if ((err as { code?: string })?.code === 'ECONNABORTED') return '请求超时,请检查服务或网络'
|
||||
return fallback
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { EyeOff, KeyRound, Save } from 'lucide-react'
|
||||
import { EyeOff, KeyRound, Loader2, Save } from 'lucide-react'
|
||||
|
||||
import { authAPI } from '../api/auth'
|
||||
import { profileAPI } from '../api/profile'
|
||||
@@ -18,16 +18,23 @@ export function ProfilePage() {
|
||||
const [hideAdult, setHideAdult] = useState(Boolean(user?.hide_adult))
|
||||
const [oldPwd, setOldPwd] = useState('')
|
||||
const [newPwd, setNewPwd] = useState('')
|
||||
const [savingProfile, setSavingProfile] = useState(false)
|
||||
const [savingPassword, setSavingPassword] = useState(false)
|
||||
|
||||
const onProfile = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (savingProfile) return
|
||||
setSavingProfile(true)
|
||||
try {
|
||||
let password: string | undefined
|
||||
const hideAdultChanged = hideAdult !== Boolean(user?.hide_adult)
|
||||
if (hideAdultChanged) {
|
||||
const usernameChanged = username.trim() !== (user?.username ?? '')
|
||||
if (hideAdultChanged || usernameChanged) {
|
||||
const input = await requestPassword({
|
||||
title: hideAdult ? '隐藏成人目录' : '取消隐藏成人目录',
|
||||
message: '此设置会同步影响 Web 与 Emby/Jellyfin/Infuse 等第三方客户端,请输入当前账号密码确认。',
|
||||
title: usernameChanged ? '修改用户名' : hideAdult ? '隐藏成人目录' : '取消隐藏成人目录',
|
||||
message: usernameChanged
|
||||
? '修改用户名后需要使用新用户名登录,请输入当前账号密码确认。'
|
||||
: '此设置会同步影响 Web 与 Emby/Jellyfin/Infuse 等第三方客户端,请输入当前账号密码确认。',
|
||||
confirmText: '保存设置',
|
||||
})
|
||||
if (!input) return
|
||||
@@ -50,11 +57,15 @@ export function ProfilePage() {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSavingProfile(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onPwd = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (savingPassword) return
|
||||
setSavingPassword(true)
|
||||
try {
|
||||
await authAPI.changePassword(oldPwd, newPwd)
|
||||
toast.success('密码已更新')
|
||||
@@ -65,6 +76,8 @@ export function ProfilePage() {
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'密码更新失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSavingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +137,9 @@ export function ProfilePage() {
|
||||
onChange={(e) => setHideAdult(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="neon-button">
|
||||
<Save size={16} /> 保存
|
||||
<button type="submit" disabled={savingProfile} className="neon-button">
|
||||
{savingProfile ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -152,8 +166,9 @@ export function ProfilePage() {
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
<button type="submit" className="neon-button">
|
||||
<KeyRound size={16} /> 更新密码
|
||||
<button type="submit" disabled={savingPassword} className="neon-button">
|
||||
{savingPassword ? <Loader2 size={16} className="animate-spin" /> : <KeyRound size={16} />}
|
||||
更新密码
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user