From 092a79f635b01eb70490aaad63fb7d8521f3a402 Mon Sep 17 00:00:00 2001 From: Peifan Li Date: Sat, 3 Jan 2026 12:58:35 -0500 Subject: [PATCH] feat: Add endpoint for retrieving reset password cooldown --- backend/src/controllers/passwordController.ts | 14 +++++ backend/src/routes/settingsRoutes.ts | 2 + backend/src/services/passwordService.ts | 32 +++++++++- frontend/src/pages/LoginPage.tsx | 61 +++++++++++++++++-- frontend/src/utils/locales/ar.ts | 1 + frontend/src/utils/locales/de.ts | 1 + frontend/src/utils/locales/en.ts | 1 + frontend/src/utils/locales/es.ts | 1 + frontend/src/utils/locales/fr.ts | 1 + frontend/src/utils/locales/ja.ts | 1 + frontend/src/utils/locales/ko.ts | 1 + frontend/src/utils/locales/pt.ts | 1 + frontend/src/utils/locales/ru.ts | 1 + frontend/src/utils/locales/zh.ts | 60 ++++++++++-------- 14 files changed, 148 insertions(+), 30 deletions(-) diff --git a/backend/src/controllers/passwordController.ts b/backend/src/controllers/passwordController.ts index 7624365..88dab90 100644 --- a/backend/src/controllers/passwordController.ts +++ b/backend/src/controllers/passwordController.ts @@ -40,6 +40,20 @@ export const verifyPassword = async ( } }; +/** + * Get the remaining cooldown time for password reset + * Errors are automatically handled by asyncHandler middleware + */ +export const getResetPasswordCooldown = async ( + _req: Request, + res: Response +): Promise => { + const remainingCooldown = passwordService.getResetPasswordCooldown(); + res.json({ + cooldown: remainingCooldown, + }); +}; + /** * Reset password to a random 8-character string * Errors are automatically handled by asyncHandler middleware diff --git a/backend/src/routes/settingsRoutes.ts b/backend/src/routes/settingsRoutes.ts index a4a534f..7ae5f21 100644 --- a/backend/src/routes/settingsRoutes.ts +++ b/backend/src/routes/settingsRoutes.ts @@ -20,6 +20,7 @@ import { } from "../controllers/hookController"; import { getPasswordEnabled, + getResetPasswordCooldown, resetPassword, verifyPassword, } from "../controllers/passwordController"; @@ -54,6 +55,7 @@ router.get("/cloudflared/status", asyncHandler(getCloudflaredStatus)); // Password routes router.get("/password-enabled", asyncHandler(getPasswordEnabled)); +router.get("/reset-password-cooldown", asyncHandler(getResetPasswordCooldown)); router.post("/verify-password", asyncHandler(verifyPassword)); router.post("/reset-password", asyncHandler(resetPassword)); diff --git a/backend/src/services/passwordService.ts b/backend/src/services/passwordService.ts index 1333c21..d45fb78 100644 --- a/backend/src/services/passwordService.ts +++ b/backend/src/services/passwordService.ts @@ -109,6 +109,28 @@ export async function hashPassword(password: string): Promise { return await bcrypt.hash(password, salt); } +const RESET_PASSWORD_COOLDOWN = 60 * 60 * 1000; // 1 hour in milliseconds + +/** + * Get the remaining cooldown time for password reset + * Returns the remaining time in milliseconds, or 0 if no cooldown + */ +export function getResetPasswordCooldown(): number { + const settings = storageService.getSettings(); + const mergedSettings = { ...defaultSettings, ...settings }; + + const lastResetTime = (mergedSettings as any).lastPasswordResetTime as number | undefined; + + if (!lastResetTime) { + return 0; + } + + const timeSinceLastReset = Date.now() - lastResetTime; + const remainingCooldown = RESET_PASSWORD_COOLDOWN - timeSinceLastReset; + + return remainingCooldown > 0 ? remainingCooldown : 0; +} + /** * Reset password to a random 8-character string * Returns the new password (should be logged, not sent to frontend) @@ -131,6 +153,13 @@ export async function resetPassword(): Promise { throw new Error("Password reset is not allowed when password login is disabled"); } + // Check cooldown period (1 hour) + const remainingCooldown = getResetPasswordCooldown(); + if (remainingCooldown > 0) { + const minutes = Math.ceil(remainingCooldown / (60 * 1000)); + throw new Error(`Password reset is on cooldown. Please wait ${minutes} minute${minutes !== 1 ? 's' : ''} before trying again.`); + } + // Generate random 8-character password using cryptographically secure random const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; @@ -142,9 +171,10 @@ export async function resetPassword(): Promise { // Hash the new password const hashedPassword = await hashPassword(newPassword); - // Update settings with new password + // Update settings with new password and reset timestamp mergedSettings.password = hashedPassword; mergedSettings.loginEnabled = true; // Ensure login is enabled + (mergedSettings as any).lastPasswordResetTime = Date.now(); storageService.saveSettings(mergedSettings); diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 11e986c..532c820 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -39,6 +39,7 @@ const LoginPage: React.FC = () => { const [alertTitle, setAlertTitle] = useState(''); const [alertMessage, setAlertMessage] = useState(''); const [websiteName, setWebsiteName] = useState('MyTube'); + const [resetPasswordCooldown, setResetPasswordCooldown] = useState(0); // in milliseconds const { t } = useLanguage(); const { login } = useAuth(); const queryClient = useQueryClient(); @@ -97,6 +98,27 @@ const LoginPage: React.FC = () => { const passkeysExist = passkeysData?.exists || false; + // Fetch reset password cooldown from backend + const { data: cooldownData } = useQuery({ + queryKey: ['resetPasswordCooldown'], + queryFn: async () => { + try { + const response = await axios.get(`${API_URL}/settings/reset-password-cooldown`, { timeout: 5000 }); + return response.data; + } catch (error) { + return { cooldown: 0 }; + } + }, + retry: 1, + retryDelay: 1000, + enabled: !isCheckingConnection && !isConnectionError, + refetchInterval: (query) => { + // Refetch every second if there's an active cooldown + const cooldown = query.state.data?.cooldown || 0; + return cooldown > 0 ? 1000 : false; + }, + }); + // Initialize wait time from server response useEffect(() => { if (statusData && statusData.waitTime) { @@ -104,6 +126,13 @@ const LoginPage: React.FC = () => { } }, [statusData]); + // Update reset password cooldown from server response + useEffect(() => { + if (cooldownData && cooldownData.cooldown !== undefined) { + setResetPasswordCooldown(cooldownData.cooldown); + } + }, [cooldownData]); + // Auto-login only if login is not required useEffect(() => { if (statusData && statusData.loginRequired === false) { @@ -124,6 +153,19 @@ const LoginPage: React.FC = () => { } }, [waitTime]); + // Countdown timer for reset password cooldown (updates local state while server refetches) + useEffect(() => { + if (resetPasswordCooldown > 0) { + const interval = setInterval(() => { + setResetPasswordCooldown((prev) => { + const newTime = prev - 1000; + return newTime > 0 ? newTime : 0; + }); + }, 1000); + return () => clearInterval(interval); + } + }, [resetPasswordCooldown]); + // Use dark theme for login page to match app style const theme = getTheme('dark'); @@ -196,13 +238,22 @@ const LoginPage: React.FC = () => { setShowResetModal(false); setError(''); setWaitTime(0); + // Invalidate queries to refresh cooldown status queryClient.invalidateQueries({ queryKey: ['healthCheck'] }); + queryClient.invalidateQueries({ queryKey: ['resetPasswordCooldown'] }); // Show success message showAlert(t('success'), t('resetPasswordSuccess')); }, onError: (err: any) => { console.error('Reset password error:', err); - showAlert(t('error'), t('loginFailed')); + if (err.response && err.response.data && err.response.data.message) { + // Server returned a specific error message (likely cooldown) + showAlert(t('error'), err.response.data.message); + // Refresh cooldown status + queryClient.invalidateQueries({ queryKey: ['resetPasswordCooldown'] }); + } else { + showAlert(t('error'), t('loginFailed')); + } } }); @@ -442,9 +493,11 @@ const LoginPage: React.FC = () => { startIcon={} onClick={() => setShowResetModal(true)} sx={{ mb: 2 }} - disabled={resetPasswordMutation.isPending} + disabled={resetPasswordMutation.isPending || resetPasswordCooldown > 0} > - {t('resetPassword')} + {resetPasswordCooldown > 0 + ? `${t('resetPassword')} (${formatWaitTime(resetPasswordCooldown)})` + : t('resetPassword')} )} {!allowResetPassword && passwordLoginAllowed && ( @@ -489,7 +542,7 @@ const LoginPage: React.FC = () => { onClose={() => setShowResetModal(false)} onConfirm={handleResetPassword} title={t('resetPasswordTitle')} - message={t('resetPasswordMessage')} + message={`${t('resetPasswordMessage')}\n\n${t('resetPasswordScriptGuide')}`} confirmText={t('resetPasswordConfirm')} cancelText={t('cancel')} isDanger={true} diff --git a/frontend/src/utils/locales/ar.ts b/frontend/src/utils/locales/ar.ts index 7057dac..9892442 100644 --- a/frontend/src/utils/locales/ar.ts +++ b/frontend/src/utils/locales/ar.ts @@ -315,6 +315,7 @@ export const ar = { resetPasswordSuccess: "تم إعادة تعيين كلمة المرور. تحقق من سجلات الخادم للحصول على كلمة المرور الجديدة.", resetPasswordDisabledInfo: "تم تعطيل إعادة تعيين كلمة المرور. لإعادة تعيين كلمة المرور، قم بتشغيل الأمر التالي في دليل الخادم:\n\nnpm run reset-password\n\nأو:\n\nts-node scripts/reset-password.ts\n\nسيؤدي هذا إلى إنشاء كلمة مرور عشوائية جديدة وتمكين تسجيل الدخول بكلمة المرور.", + resetPasswordScriptGuide: "لإعادة تعيين كلمة المرور يدوياً، قم بتشغيل الأمر التالي في دليل الخادم:\n\nnpm run reset-password\n\nأو:\n\nts-node scripts/reset-password.ts\n\nإذا لم يتم توفير كلمة مرور، سيتم إنشاء كلمة مرور عشوائية مكونة من 8 أحرف.", waitTimeMessage: "يرجى الانتظار {time} قبل المحاولة مرة أخرى.", tooManyAttempts: "محاولات فاشلة كثيرة جداً.", // Passkeys diff --git a/frontend/src/utils/locales/de.ts b/frontend/src/utils/locales/de.ts index 4313e11..69bda3f 100644 --- a/frontend/src/utils/locales/de.ts +++ b/frontend/src/utils/locales/de.ts @@ -315,6 +315,7 @@ export const de = { resetPasswordSuccess: "Das Passwort wurde zurückgesetzt. Überprüfen Sie die Backend-Protokolle für das neue Passwort.", resetPasswordDisabledInfo: "Die Passwort-Zurücksetzung ist deaktiviert. Um Ihr Passwort zurückzusetzen, führen Sie den folgenden Befehl im Backend-Verzeichnis aus:\n\nnpm run reset-password\n\nOder:\n\nts-node scripts/reset-password.ts\n\nDies generiert ein neues zufälliges Passwort und aktiviert die Passwort-Anmeldung.", + resetPasswordScriptGuide: "Um das Passwort manuell zurückzusetzen, führen Sie den folgenden Befehl im Backend-Verzeichnis aus:\n\nnpm run reset-password\n\nOder:\n\nts-node scripts/reset-password.ts\n\nWenn kein Passwort angegeben wird, wird ein zufälliges 8-stelliges Passwort generiert.", waitTimeMessage: "Bitte warten Sie {time}, bevor Sie es erneut versuchen.", tooManyAttempts: "Zu viele fehlgeschlagene Versuche.", // Passkeys diff --git a/frontend/src/utils/locales/en.ts b/frontend/src/utils/locales/en.ts index d8d49c6..b9794c2 100644 --- a/frontend/src/utils/locales/en.ts +++ b/frontend/src/utils/locales/en.ts @@ -329,6 +329,7 @@ export const en = { resetPasswordSuccess: "Password has been reset. Check backend logs for the new password.", resetPasswordDisabledInfo: "Password reset is disabled. To reset your password, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nThis will generate a new random password and enable password login.", + resetPasswordScriptGuide: "To reset password manually, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nIf no password is provided, a random 8-character password will be generated.", waitTimeMessage: "Please wait {time} before trying again.", tooManyAttempts: "Too many failed attempts.", // Passkeys diff --git a/frontend/src/utils/locales/es.ts b/frontend/src/utils/locales/es.ts index 4ac6868..2adaf9b 100644 --- a/frontend/src/utils/locales/es.ts +++ b/frontend/src/utils/locales/es.ts @@ -338,6 +338,7 @@ export const es = { resetPasswordSuccess: "La contraseña ha sido restablecida. Consulte los registros del backend para obtener la nueva contraseña.", resetPasswordDisabledInfo: "El restablecimiento de contraseña está deshabilitado. Para restablecer su contraseña, ejecute el siguiente comando en el directorio del backend:\n\nnpm run reset-password\n\nO:\n\nts-node scripts/reset-password.ts\n\nEsto generará una nueva contraseña aleatoria y habilitará el inicio de sesión con contraseña.", + resetPasswordScriptGuide: "Para restablecer la contraseña manualmente, ejecute el siguiente comando en el directorio del backend:\n\nnpm run reset-password\n\nO:\n\nts-node scripts/reset-password.ts\n\nSi no se proporciona una contraseña, se generará una contraseña aleatoria de 8 caracteres.", waitTimeMessage: "Por favor espere {time} antes de intentar nuevamente.", tooManyAttempts: "Demasiados intentos fallidos.", // Passkeys diff --git a/frontend/src/utils/locales/fr.ts b/frontend/src/utils/locales/fr.ts index e0355e7..abb4b0c 100644 --- a/frontend/src/utils/locales/fr.ts +++ b/frontend/src/utils/locales/fr.ts @@ -338,6 +338,7 @@ export const fr = { resetPasswordSuccess: "Le mot de passe a été réinitialisé. Consultez les journaux du backend pour le nouveau mot de passe.", resetPasswordDisabledInfo: "La réinitialisation du mot de passe est désactivée. Pour réinitialiser votre mot de passe, exécutez la commande suivante dans le répertoire backend :\n\nnpm run reset-password\n\nOu :\n\nts-node scripts/reset-password.ts\n\nCela générera un nouveau mot de passe aléatoire et activera la connexion par mot de passe.", + resetPasswordScriptGuide: "Pour réinitialiser le mot de passe manuellement, exécutez la commande suivante dans le répertoire backend :\n\nnpm run reset-password\n\nOu :\n\nts-node scripts/reset-password.ts\n\nSi aucun mot de passe n'est fourni, un mot de passe aléatoire de 8 caractères sera généré.", waitTimeMessage: "Veuillez attendre {time} avant de réessayer.", tooManyAttempts: "Trop de tentatives échouées.", // Passkeys diff --git a/frontend/src/utils/locales/ja.ts b/frontend/src/utils/locales/ja.ts index 632bb55..f2a9c16 100644 --- a/frontend/src/utils/locales/ja.ts +++ b/frontend/src/utils/locales/ja.ts @@ -322,6 +322,7 @@ export const ja = { resetPasswordSuccess: "パスワードがリセットされました。新しいパスワードについては、バックエンドログを確認してください。", resetPasswordDisabledInfo: "パスワードリセットは無効になっています。パスワードをリセットするには、バックエンドディレクトリで次のコマンドを実行してください:\n\nnpm run reset-password\n\nまたは:\n\nts-node scripts/reset-password.ts\n\nこれにより、新しいランダムパスワードが生成され、パスワードログインが有効になります。", + resetPasswordScriptGuide: "パスワードを手動でリセットするには、バックエンドディレクトリで次のコマンドを実行してください:\n\nnpm run reset-password\n\nまたは:\n\nts-node scripts/reset-password.ts\n\nパスワードが提供されない場合、ランダムな8文字のパスワードが生成されます。", waitTimeMessage: "再試行する前に {time} お待ちください。", tooManyAttempts: "失敗した試行が多すぎます。", // Passkeys diff --git a/frontend/src/utils/locales/ko.ts b/frontend/src/utils/locales/ko.ts index 4a5890a..5f798fd 100644 --- a/frontend/src/utils/locales/ko.ts +++ b/frontend/src/utils/locales/ko.ts @@ -317,6 +317,7 @@ export const ko = { resetPasswordSuccess: "비밀번호가 재설정되었습니다. 새 비밀번호는 백엔드 로그를 확인하세요.", resetPasswordDisabledInfo: "비밀번호 재설정이 비활성화되어 있습니다. 비밀번호를 재설정하려면 백엔드 디렉토리에서 다음 명령을 실행하세요:\n\nnpm run reset-password\n\n또는:\n\nts-node scripts/reset-password.ts\n\n이렇게 하면 새로운 임의의 비밀번호가 생성되고 비밀번호 로그인이 활성화됩니다.", + resetPasswordScriptGuide: "비밀번호를 수동으로 재설정하려면 백엔드 디렉토리에서 다음 명령을 실행하세요:\n\nnpm run reset-password\n\n또는:\n\nts-node scripts/reset-password.ts\n\n비밀번호가 제공되지 않으면 임의의 8자 비밀번호가 생성됩니다.", waitTimeMessage: "다시 시도하기 전에 {time} 기다려 주세요.", tooManyAttempts: "실패한 시도가 너무 많습니다.", // Passkeys diff --git a/frontend/src/utils/locales/pt.ts b/frontend/src/utils/locales/pt.ts index d9cf32d..10378ff 100644 --- a/frontend/src/utils/locales/pt.ts +++ b/frontend/src/utils/locales/pt.ts @@ -333,6 +333,7 @@ export const pt = { resetPasswordSuccess: "A senha foi redefinida. Verifique os logs do backend para a nova senha.", resetPasswordDisabledInfo: "A redefinição de senha está desabilitada. Para redefinir sua senha, execute o seguinte comando no diretório do backend:\n\nnpm run reset-password\n\nOu:\n\nts-node scripts/reset-password.ts\n\nIsso gerará uma nova senha aleatória e habilitará o login com senha.", + resetPasswordScriptGuide: "Para redefinir a senha manualmente, execute o seguinte comando no diretório do backend:\n\nnpm run reset-password\n\nOu:\n\nts-node scripts/reset-password.ts\n\nSe nenhuma senha for fornecida, uma senha aleatória de 8 caracteres será gerada.", waitTimeMessage: "Por favor, aguarde {time} antes de tentar novamente.", tooManyAttempts: "Muitas tentativas falharam.", // Passkeys diff --git a/frontend/src/utils/locales/ru.ts b/frontend/src/utils/locales/ru.ts index e54b57e..fe2cae8 100644 --- a/frontend/src/utils/locales/ru.ts +++ b/frontend/src/utils/locales/ru.ts @@ -333,6 +333,7 @@ export const ru = { resetPasswordSuccess: "Пароль был сброшен. Проверьте логи бэкенда для нового пароля.", resetPasswordDisabledInfo: "Сброс пароля отключен. Чтобы сбросить пароль, выполните следующую команду в директории бэкенда:\n\nnpm run reset-password\n\nИли:\n\nts-node scripts/reset-password.ts\n\nЭто сгенерирует новый случайный пароль и включит вход по паролю.", + resetPasswordScriptGuide: "Чтобы вручную сбросить пароль, выполните следующую команду в директории бэкенда:\n\nnpm run reset-password\n\nИли:\n\nts-node scripts/reset-password.ts\n\nЕсли пароль не указан, будет сгенерирован случайный 8-символьный пароль.", waitTimeMessage: "Пожалуйста, подождите {time} перед повторной попыткой.", tooManyAttempts: "Слишком много неудачных попыток.", // Passkeys diff --git a/frontend/src/utils/locales/zh.ts b/frontend/src/utils/locales/zh.ts index 6a552ba..837e368 100644 --- a/frontend/src/utils/locales/zh.ts +++ b/frontend/src/utils/locales/zh.ts @@ -53,9 +53,11 @@ export const zh = { columnsCount: "{count} 列", enableLogin: "启用登录保护", allowPasswordLogin: "允许密码登录", - allowPasswordLoginHelper: "禁用后,密码登录将不可用。要禁用密码登录,您必须至少有一个通行密钥。", + allowPasswordLoginHelper: + "禁用后,密码登录将不可用。要禁用密码登录,您必须至少有一个通行密钥。", allowResetPassword: "允许重置密码", - allowResetPasswordHelper: "禁用后,登录页面将不显示重置密码按钮,并且重置密码 API 将被阻止。", + allowResetPasswordHelper: + "禁用后,登录页面将不显示重置密码按钮,并且重置密码 API 将被阻止。", password: "密码", enterPassword: "输入密码", togglePasswordVisibility: "切换密码可见性", @@ -304,7 +306,10 @@ export const zh = { "您确定要重置密码吗?当前密码将被重置为一个随机的8位字符串,并显示在后端日志中。", resetPasswordConfirm: "重置", resetPasswordSuccess: "密码已重置。请查看后端日志以获取新密码。", - resetPasswordDisabledInfo: "密码重置已禁用。要重置密码,请在后端目录运行以下命令:\n\nnpm run reset-password\n\n或:\n\nts-node scripts/reset-password.ts\n\n这将生成一个新的随机密码并启用密码登录。", + resetPasswordDisabledInfo: + "密码重置已禁用。要重置密码,请在后端目录运行以下命令:\n\nnpm run reset-password\n\n或:\n\nts-node scripts/reset-password.ts\n\n这将生成一个新的随机密码并启用密码登录。", + resetPasswordScriptGuide: + "要手动重置密码,请在后端目录运行以下命令:\n\nnpm run reset-password\n\n或:\n\nts-node scripts/reset-password.ts\n\n如果未提供密码,将生成一个随机的8位密码。", waitTimeMessage: "请等待 {time} 后再试。", tooManyAttempts: "失败尝试次数过多。", // Passkeys @@ -312,8 +317,10 @@ export const zh = { creatingPasskey: "创建中...", passkeyCreated: "通行密钥创建成功", passkeyCreationFailed: "创建通行密钥失败,请重试。", - passkeyWebAuthnNotSupported: "此浏览器不支持 WebAuthn。请使用支持 WebAuthn 的现代浏览器。", - passkeyRequiresHttps: "WebAuthn 需要 HTTPS 或 localhost。请通过 HTTPS 访问应用程序,或使用 localhost 而不是 IP 地址。", + passkeyWebAuthnNotSupported: + "此浏览器不支持 WebAuthn。请使用支持 WebAuthn 的现代浏览器。", + passkeyRequiresHttps: + "WebAuthn 需要 HTTPS 或 localhost。请通过 HTTPS 访问应用程序,或使用 localhost 而不是 IP 地址。", removePasskeys: "删除所有通行密钥", removePasskeysTitle: "删除所有通行密钥", removePasskeysMessage: "您确定要删除所有通行密钥吗?此操作无法撤销。", @@ -322,7 +329,8 @@ export const zh = { loginWithPasskey: "使用通行密钥登录", authenticating: "验证中...", passkeyLoginFailed: "通行密钥验证失败,请重试。", - passkeyErrorPermissionDenied: "用户代理或平台在当前上下文中不允许该请求,可能是因为用户拒绝了权限。", + passkeyErrorPermissionDenied: + "用户代理或平台在当前上下文中不允许该请求,可能是因为用户拒绝了权限。", passkeyErrorAlreadyRegistered: "该认证器之前已注册。", linkCopied: "链接已复制到剪贴板", copyFailed: "复制链接失败", @@ -480,7 +488,8 @@ export const zh = { taskDeleted: "任务已成功删除", clearFinishedTasks: "清除已完成任务", tasksCleared: "已成功清除已完成的任务", - confirmClearFinishedTasks: "您确定要清除所有已完成的任务(包括已完成和已取消)吗?这只会将其从列表中移除,不会删除任何已下载的文件。", + confirmClearFinishedTasks: + "您确定要清除所有已完成的任务(包括已完成和已取消)吗?这只会将其从列表中移除,不会删除任何已下载的文件。", clear: "清除", // Existing Video Detection @@ -636,24 +645,25 @@ export const zh = { copyUrl: "复制链接", new: "新", // Task Hooks - taskHooks: '任务钩子', - taskHooksDescription: '在任务生命周期的特定时间点执行自定义 Shell 命令。可用环境变量: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH。', - taskHooksWarning: '警告:命令将以服务器权限运行。请谨慎使用。', - enterPasswordToUploadHook: '请输入密码以上传此 Hook 脚本。', - riskCommandDetected: '检测到危险命令:{command}。上传已拒绝。', - hookTaskBeforeStart: '任务开始前', - hookTaskBeforeStartHelper: '在下载开始前执行。', - hookTaskSuccess: '任务成功', - hookTaskSuccessHelper: '在下载成功后,云上传/删除前执行 (等待完成)。', - hookTaskFail: '任务失败', - hookTaskFailHelper: '当任务失败时执行。', - hookTaskCancel: '任务取消', - hookTaskCancelHelper: '当任务被手动取消时执行。', - found: '已找到', - notFound: '未设置', - deleteHook: '删除钩子脚本', - confirmDeleteHook: '确定要删除此钩子脚本吗?', - uploadHook: '上传 .sh', + taskHooks: "任务钩子", + taskHooksDescription: + "在任务生命周期的特定时间点执行自定义 Shell 命令。可用环境变量: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH。", + taskHooksWarning: "警告:命令将以服务器权限运行。请谨慎使用。", + enterPasswordToUploadHook: "请输入密码以上传此 Hook 脚本。", + riskCommandDetected: "检测到危险命令:{command}。上传已拒绝。", + hookTaskBeforeStart: "任务开始前", + hookTaskBeforeStartHelper: "在下载开始前执行。", + hookTaskSuccess: "任务成功", + hookTaskSuccessHelper: "在下载成功后,云上传/删除前执行 (等待完成)。", + hookTaskFail: "任务失败", + hookTaskFailHelper: "当任务失败时执行。", + hookTaskCancel: "任务取消", + hookTaskCancelHelper: "当任务被手动取消时执行。", + found: "已找到", + notFound: "未设置", + deleteHook: "删除钩子脚本", + confirmDeleteHook: "确定要删除此钩子脚本吗?", + uploadHook: "上传 .sh", disclaimerTitle: "免责声明", disclaimerText: