mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
Compare commits
6
Commits
v1.7.1
...
8942e5bd49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8942e5bd49 | ||
|
|
d722815999 | ||
|
|
ff85698edb | ||
|
|
c3dc53bac1 | ||
|
|
1acc31eda0 | ||
|
|
c694f1bfce |
@@ -9,7 +9,8 @@
|
||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||
export const BACKUP_DEFAULT_S3_REGION = 'auto';
|
||||
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
||||
export const BACKUP_DEFAULT_S3_ROOT_PATH = '';
|
||||
export const BACKUP_DEFAULT_WEBDAV_REMOTE_PATH = 'nodewarden';
|
||||
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||
|
||||
@@ -109,14 +110,14 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
||||
region: BACKUP_DEFAULT_S3_REGION,
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||
rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
|
||||
};
|
||||
}
|
||||
return {
|
||||
baseUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
remotePath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||
remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -275,11 +275,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
||||
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
||||
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
||||
pendingAuthRequests={props.pendingAuthRequests}
|
||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||
onNotify={props.onNotify}
|
||||
|
||||
@@ -56,6 +56,7 @@ type PendingRestoreIntegrity =
|
||||
type PendingBackupVerification =
|
||||
| { action: 'export' }
|
||||
| { action: 'saveSettings' }
|
||||
| { action: 'deleteDestination'; destinationId: string; settings: AdminBackupSettings }
|
||||
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
|
||||
| { action: 'runRemoteBackup' }
|
||||
| { action: 'downloadRemote'; path: string }
|
||||
@@ -240,7 +241,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const backupPasswordPromptTitle =
|
||||
pendingBackupVerification?.action === 'export'
|
||||
? t('txt_backup_export')
|
||||
: pendingBackupVerification?.action === 'saveSettings'
|
||||
: pendingBackupVerification?.action === 'saveSettings' || pendingBackupVerification?.action === 'deleteDestination'
|
||||
? t('txt_backup_save_settings')
|
||||
: pendingBackupVerification?.action === 'runRemoteBackup'
|
||||
? t('txt_backup_run_manual')
|
||||
@@ -501,10 +502,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
|
||||
};
|
||||
|
||||
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
|
||||
setBackupPasswordValue('');
|
||||
setConfirmDeleteDestinationOpen(false);
|
||||
}
|
||||
|
||||
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) {
|
||||
setSavingSettings(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
const saved = await props.onSaveSettings(nextSettings);
|
||||
const saved = await props.onSaveSettings(masterPassword, payload);
|
||||
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
|
||||
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
|
||||
setSavedSettings(saved);
|
||||
@@ -865,6 +872,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
await executeExport(masterPassword);
|
||||
} else if (request.action === 'saveSettings') {
|
||||
await executeSaveSettings(masterPassword);
|
||||
} else if (request.action === 'deleteDestination') {
|
||||
await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
|
||||
} else if (request.action === 'import') {
|
||||
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||
} else if (request.action === 'runRemoteBackup') {
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
<section className="card">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h3 className="flush-title">{t('txt_device_management')}</h3>
|
||||
<h3 className="flush-title">{t('txt_authorized_devices')}</h3>
|
||||
<div className="muted-inline section-note">
|
||||
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||
</div>
|
||||
@@ -111,10 +111,6 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
|
||||
{!!props.error && (
|
||||
<div className="local-error">
|
||||
<span>{props.error}</span>
|
||||
|
||||
@@ -2,10 +2,9 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import qrcode from 'qrcode-generator';
|
||||
import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, Profile } from '@/lib/types';
|
||||
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
||||
|
||||
interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
@@ -23,11 +22,6 @@ interface SettingsPageProps {
|
||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||
pendingAuthRequests: AuthRequest[];
|
||||
pendingAuthRequestsLoading: boolean;
|
||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
@@ -515,15 +509,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PendingAuthRequestsPanel
|
||||
pendingAuthRequests={props.pendingAuthRequests}
|
||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||
/>
|
||||
|
||||
<section className="settings-module sensitive-actions-module">
|
||||
<div className="sensitive-actions-grid">
|
||||
<div className="sensitive-action">
|
||||
|
||||
@@ -54,21 +54,18 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
||||
<>
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
|
||||
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
|
||||
<a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
|
||||
{t('txt_backup_recommend_koofr_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_3')}
|
||||
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_3')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_4')}
|
||||
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_4')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>5.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
|
||||
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
|
||||
<a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
|
||||
{t('txt_backup_recommend_koofr_step_5_suffix')}
|
||||
</div>
|
||||
@@ -98,13 +95,10 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_1')}
|
||||
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_2')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
|
||||
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_3')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,18 +106,87 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
|
||||
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
|
||||
<a href="https://infini-cloud.net/en/modules/mypage/usage/" target="_blank" rel="noreferrer">My Page</a>
|
||||
{t('txt_backup_recommend_infinicloud_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
|
||||
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
|
||||
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'backblaze-b2':
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_backblaze_step_2_prefix')}{' '}
|
||||
<a href={provider.bucketsUrl} target="_blank" rel="noreferrer">Buckets</a>
|
||||
{t('txt_backup_recommend_backblaze_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_backblaze_step_3')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_backblaze_step_4_prefix')}{' '}
|
||||
<a href={provider.applicationKeysUrl} target="_blank" rel="noreferrer">Application Keys</a>
|
||||
{t('txt_backup_recommend_backblaze_step_4_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_backblaze_step_5')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'cloudflare-r2':
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_cloudflare_r2_step_1_prefix')}{' '}
|
||||
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_bucket_link')}</a>
|
||||
{t('txt_backup_recommend_cloudflare_r2_step_1_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_cloudflare_r2_step_2_prefix')}{' '}
|
||||
<a href={provider.apiTokenUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_api_link')}</a>
|
||||
{t('txt_backup_recommend_cloudflare_r2_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_cloudflare_r2_step_3')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_cloudflare_r2_step_4')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>5.</strong> {t('txt_backup_recommend_cloudflare_r2_step_5')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'tigris':
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_tigris_step_2_prefix')}{' '}
|
||||
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">Create Bucket</a>
|
||||
{t('txt_backup_recommend_tigris_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_tigris_step_3_prefix')}{' '}
|
||||
<a href={provider.accessKeyUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_tigris_access_key_link')}</a>
|
||||
{t('txt_backup_recommend_tigris_step_3_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_tigris_step_4')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_tigris_step_5')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -147,6 +210,9 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
<div className="backup-inline-note">
|
||||
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
|
||||
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary')
|
||||
: props.selectedRecommendedProvider.id === 'backblaze-b2' ? t('txt_backup_recommend_backblaze_summary')
|
||||
: props.selectedRecommendedProvider.id === 'cloudflare-r2' ? t('txt_backup_recommend_cloudflare_r2_summary')
|
||||
: props.selectedRecommendedProvider.id === 'tigris' ? t('txt_backup_recommend_tigris_summary')
|
||||
: t('txt_backup_recommend_pcloud_summary')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,7 +453,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="nodewarden/backups"
|
||||
placeholder="nodewarden"
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
@@ -504,7 +570,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="nodewarden/backups"
|
||||
placeholder=""
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||
import { hasLinkedStorages } from '@/lib/backup-recommendations';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
||||
|
||||
const MOBILE_RECOMMENDATIONS_QUERY = '(max-width: 760px)';
|
||||
|
||||
interface BackupOperationsSidebarProps {
|
||||
disableWhileBusy: boolean;
|
||||
exporting: boolean;
|
||||
@@ -18,7 +21,30 @@ interface BackupOperationsSidebarProps {
|
||||
onSelectProvider: (providerId: string) => void;
|
||||
}
|
||||
|
||||
function getDefaultRecommendationsOpen() {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return true;
|
||||
}
|
||||
return !window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY).matches;
|
||||
}
|
||||
|
||||
export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
||||
const [recommendationsOpen, setRecommendationsOpen] = useState(getDefaultRecommendationsOpen);
|
||||
const [recommendationsTouched, setRecommendationsTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function' || recommendationsTouched) {
|
||||
return;
|
||||
}
|
||||
|
||||
const media = window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY);
|
||||
const syncOpenState = () => setRecommendationsOpen(!media.matches);
|
||||
|
||||
syncOpenState();
|
||||
media.addEventListener('change', syncOpenState);
|
||||
return () => media.removeEventListener('change', syncOpenState);
|
||||
}, [recommendationsTouched]);
|
||||
|
||||
return (
|
||||
<aside className="backup-operations-sidebar">
|
||||
<div className="section-head">
|
||||
@@ -41,7 +67,14 @@ export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details className="backup-recommendations-disclosure">
|
||||
<details
|
||||
className="backup-recommendations-disclosure"
|
||||
open={recommendationsOpen}
|
||||
onToggle={(event) => {
|
||||
setRecommendationsTouched(true);
|
||||
setRecommendationsOpen((event.currentTarget as HTMLDetailsElement).open);
|
||||
}}
|
||||
>
|
||||
<summary className="backup-recommendations-summary">
|
||||
<span>
|
||||
<strong>{t('txt_backup_recommend_title')}</strong>
|
||||
|
||||
@@ -234,11 +234,19 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
const normalizedName = String(name || '').trim() || t('txt_account_passkey');
|
||||
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
|
||||
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
|
||||
const pending = await createAccountPasskeyCredential(options);
|
||||
const pending = await createAccountPasskeyCredential(options, directUnlock);
|
||||
let keySet = null;
|
||||
let savedWithoutDirectUnlock = false;
|
||||
if (directUnlock) {
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
if (!pending.supportsPrf) {
|
||||
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
|
||||
if (!shouldSaveLoginOnly) {
|
||||
onNotify('warning', t('txt_account_passkey_not_saved'));
|
||||
return null;
|
||||
}
|
||||
savedWithoutDirectUnlock = true;
|
||||
} else {
|
||||
try {
|
||||
keySet = await buildAccountPasskeyPrfKeySet(pending, {
|
||||
symEncKey: session.symEncKey,
|
||||
@@ -254,6 +262,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
savedWithoutDirectUnlock = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const credential = await saveAccountPasskey(authedFetch, {
|
||||
name: normalizedName,
|
||||
token: pending.token,
|
||||
|
||||
@@ -150,6 +150,22 @@ function shouldRetryWithLegacyPrf(error: unknown): boolean {
|
||||
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
|
||||
}
|
||||
|
||||
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
|
||||
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
|
||||
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
|
||||
return (
|
||||
name === 'NotSupportedError' ||
|
||||
name === 'SyntaxError' ||
|
||||
name === 'TypeError' ||
|
||||
(name === 'UnknownError' && /transient/i.test(message))
|
||||
);
|
||||
}
|
||||
|
||||
async function canRequestPrfExtension(): Promise<boolean> {
|
||||
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getPublicKeyCredentialWithPrf(
|
||||
options: PublicKeyCredentialRequestOptions,
|
||||
salt: Uint8Array,
|
||||
@@ -265,17 +281,38 @@ export async function assertAccountPasskey(
|
||||
}
|
||||
|
||||
export async function createAccountPasskeyCredential(
|
||||
response: { options: unknown; token: string }
|
||||
response: { options: unknown; token: string },
|
||||
requestPrf: boolean = false
|
||||
): Promise<PendingAccountPasskeyCredential> {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||
}
|
||||
const nativeOptions = cloneCreationOptions(response.options);
|
||||
(nativeOptions as any).extensions = {
|
||||
const createWithOptions = async (options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential> => {
|
||||
const credential = await navigator.credentials.create({ publicKey: options });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_no_passkey_created'));
|
||||
}
|
||||
return credential;
|
||||
};
|
||||
let credential: PublicKeyCredential;
|
||||
if (requestPrf && await canRequestPrfExtension()) {
|
||||
const prfOptions: PublicKeyCredentialCreationOptions = {
|
||||
...nativeOptions,
|
||||
extensions: {
|
||||
...((nativeOptions as any).extensions || {}),
|
||||
prf: {},
|
||||
} as any,
|
||||
};
|
||||
const credential = await navigator.credentials.create({ publicKey: nativeOptions });
|
||||
try {
|
||||
credential = await createWithOptions(prfOptions);
|
||||
} catch (error) {
|
||||
if (!shouldRetryCreateWithoutPrf(error)) throw error;
|
||||
credential = await createWithOptions(nativeOptions);
|
||||
}
|
||||
} else {
|
||||
credential = await createWithOptions(nativeOptions);
|
||||
}
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_no_passkey_created'));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface RecommendedStorageLink {
|
||||
}
|
||||
|
||||
export interface RecommendedProviderBase {
|
||||
id: 'infinicloud' | 'koofr' | 'pcloud';
|
||||
id: 'infinicloud' | 'koofr' | 'pcloud' | 'backblaze-b2' | 'cloudflare-r2' | 'tigris';
|
||||
name: string;
|
||||
capacity: string;
|
||||
protocol: 'webdav' | 's3';
|
||||
@@ -28,7 +28,25 @@ export interface PcloudProvider extends RecommendedProviderBase {
|
||||
id: 'pcloud';
|
||||
}
|
||||
|
||||
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider;
|
||||
export interface BackblazeB2Provider extends RecommendedProviderBase {
|
||||
id: 'backblaze-b2';
|
||||
bucketsUrl: string;
|
||||
applicationKeysUrl: string;
|
||||
}
|
||||
|
||||
export interface CloudflareR2Provider extends RecommendedProviderBase {
|
||||
id: 'cloudflare-r2';
|
||||
bucketUrl: string;
|
||||
apiTokenUrl: string;
|
||||
}
|
||||
|
||||
export interface TigrisProvider extends RecommendedProviderBase {
|
||||
id: 'tigris';
|
||||
bucketUrl: string;
|
||||
accessKeyUrl: string;
|
||||
}
|
||||
|
||||
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider | BackblazeB2Provider | CloudflareR2Provider | TigrisProvider;
|
||||
|
||||
export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
|
||||
{
|
||||
@@ -61,6 +79,33 @@ export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
|
||||
signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7',
|
||||
hasAffiliateLink: true,
|
||||
},
|
||||
{
|
||||
id: 'backblaze-b2',
|
||||
name: 'Backblaze B2',
|
||||
capacity: '10G',
|
||||
protocol: 's3',
|
||||
signupUrl: 'https://secure.backblaze.com/user_signin.htm',
|
||||
bucketsUrl: 'https://secure.backblaze.com/b2_buckets.htm',
|
||||
applicationKeysUrl: 'https://secure.backblaze.com/app_keys.htm',
|
||||
},
|
||||
{
|
||||
id: 'cloudflare-r2',
|
||||
name: 'Cloudflare R2',
|
||||
capacity: '10G',
|
||||
protocol: 's3',
|
||||
signupUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
|
||||
bucketUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
|
||||
apiTokenUrl: 'https://dash.cloudflare.com/?to=/:account/r2/api-tokens/create?type=user',
|
||||
},
|
||||
{
|
||||
id: 'tigris',
|
||||
name: 'Tigris',
|
||||
capacity: '5G',
|
||||
protocol: 's3',
|
||||
signupUrl: 'https://console.storage.dev/signup',
|
||||
bucketUrl: 'https://console.storage.dev/createbucket',
|
||||
accessKeyUrl: 'https://console.storage.dev/createaccesskey',
|
||||
},
|
||||
];
|
||||
|
||||
export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider {
|
||||
|
||||
@@ -85,6 +85,37 @@ const en: Record<string, string> = {
|
||||
"txt_backup_recommend_pcloud_step_1": "Register a pCloud account with just your email address.",
|
||||
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ as the WebDAV server URL.",
|
||||
"txt_backup_recommend_pcloud_step_3": "Use your registration email as the WebDAV username and your account password as the WebDAV password.",
|
||||
"txt_backup_recommend_backblaze_summary": "S3-compatible object storage with 10 GB free and no credit card required.",
|
||||
"txt_backup_recommend_backblaze_step_1": "Register or sign in to a Backblaze account.",
|
||||
"txt_backup_recommend_backblaze_step_2_prefix": "Open",
|
||||
"txt_backup_recommend_backblaze_step_2_suffix": ", click Create a Bucket, enter only the bucket name, leave the other settings unchanged, and create it.",
|
||||
"txt_backup_recommend_backblaze_step_3": "After creation, put the displayed Endpoint into S3 Endpoint URL, use the bucket name for Bucket Name, and use the middle segment of the endpoint, such as us-west-004, for Region.",
|
||||
"txt_backup_recommend_backblaze_step_4_prefix": "Open",
|
||||
"txt_backup_recommend_backblaze_step_4_suffix": ", click Add a New Application Key, enter any Name of Key, leave the other settings unchanged, and create it.",
|
||||
"txt_backup_recommend_backblaze_step_5": "Use keyID as the access key and applicationKey as the secret key.",
|
||||
"txt_backup_recommend_cloudflare_r2_summary": "S3-compatible object storage with 10 GB free, but it requires credit card verification.",
|
||||
"txt_backup_recommend_cloudflare_r2_bucket_link": "Create bucket page",
|
||||
"txt_backup_recommend_cloudflare_r2_api_link": "API token page",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Open the",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", enter only the bucket name, and create it directly.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Open the",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", select Object Read & Write for permissions, and create it directly.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore the token value after creation. Fill Access Key ID into Access ID, and Secret Access Key into Access Password.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_4": "Copy the address shown below into S3 Endpoint URL, fill Bucket Name exactly as shown, and leave Region as auto.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_5": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
|
||||
"txt_backup_recommend_s3_path_prefix_step": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
|
||||
"txt_backup_recommend_tigris_summary": "S3-compatible object storage with 5 GB free and no credit card required.",
|
||||
"txt_backup_recommend_tigris_signup_link": "signup page",
|
||||
"txt_backup_recommend_tigris_bucket_link": "Create Bucket page",
|
||||
"txt_backup_recommend_tigris_access_key_link": "Create Access Key page",
|
||||
"txt_backup_recommend_tigris_step_1_prefix": "Open the",
|
||||
"txt_backup_recommend_tigris_step_1_suffix": ", sign up, and log in to Tigris.",
|
||||
"txt_backup_recommend_tigris_step_2_prefix": "Open",
|
||||
"txt_backup_recommend_tigris_step_2_suffix": ", enter only the bucket name, leave everything else unchanged, and create it.",
|
||||
"txt_backup_recommend_tigris_step_3_prefix": "Then open the",
|
||||
"txt_backup_recommend_tigris_step_3_suffix": ", use any name you like, and create it.",
|
||||
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM after creation. Fill the other displayed values into the backup page using the matching field names.",
|
||||
"txt_backup_recommend_tigris_step_5": "Finally, click Manage Key Permissions and turn on Admin Access, otherwise writing backups will fail.",
|
||||
"txt_backup_add_destination": "Add Destination",
|
||||
"txt_backup_schedule_panel_title": "Automatic Schedule",
|
||||
"txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.",
|
||||
|
||||
@@ -85,6 +85,37 @@ const es: Record<string, string> = {
|
||||
"txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.",
|
||||
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.",
|
||||
"txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.",
|
||||
"txt_backup_recommend_backblaze_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis y sin tarjeta de crédito.",
|
||||
"txt_backup_recommend_backblaze_step_1": "Registre o inicie sesión en una cuenta de Backblaze.",
|
||||
"txt_backup_recommend_backblaze_step_2_prefix": "Abra",
|
||||
"txt_backup_recommend_backblaze_step_2_suffix": ", haga clic en Create a Bucket, introduzca solo el nombre del bucket, deje lo demás sin cambios y créelo.",
|
||||
"txt_backup_recommend_backblaze_step_3": "Después de crearlo, ponga el Endpoint mostrado en S3 Endpoint URL, use el nombre del bucket en Bucket Name y la parte central del endpoint, como us-west-004, en Region.",
|
||||
"txt_backup_recommend_backblaze_step_4_prefix": "Abra",
|
||||
"txt_backup_recommend_backblaze_step_4_suffix": ", haga clic en Add a New Application Key, introduzca cualquier Name of Key, deje lo demás sin cambios y créelo.",
|
||||
"txt_backup_recommend_backblaze_step_5": "Use keyID como clave de acceso y applicationKey como clave secreta.",
|
||||
"txt_backup_recommend_cloudflare_r2_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis, pero requiere verificación con tarjeta de crédito.",
|
||||
"txt_backup_recommend_cloudflare_r2_bucket_link": "página para crear bucket",
|
||||
"txt_backup_recommend_cloudflare_r2_api_link": "página de token API",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Abra la",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", introduzca solo el nombre del bucket y créelo directamente.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Abra la",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", seleccione Object Read & Write en permisos y créelo directamente.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore el valor del token después de crearlo. Use Access Key ID como ID de acceso y Secret Access Key como contraseña de acceso.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_4": "Copie la dirección mostrada abajo en S3 Endpoint URL, rellene Bucket Name tal como aparece y deje Region en auto.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_5": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
|
||||
"txt_backup_recommend_s3_path_prefix_step": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
|
||||
"txt_backup_recommend_tigris_summary": "Almacenamiento de objetos compatible con S3 con 5 GB gratis y sin tarjeta de crédito.",
|
||||
"txt_backup_recommend_tigris_signup_link": "página de registro",
|
||||
"txt_backup_recommend_tigris_bucket_link": "página Create Bucket",
|
||||
"txt_backup_recommend_tigris_access_key_link": "página Create Access Key",
|
||||
"txt_backup_recommend_tigris_step_1_prefix": "Abra la",
|
||||
"txt_backup_recommend_tigris_step_1_suffix": ", regístrese e inicie sesión en Tigris.",
|
||||
"txt_backup_recommend_tigris_step_2_prefix": "Abra",
|
||||
"txt_backup_recommend_tigris_step_2_suffix": ", introduzca solo el nombre del bucket, deje todo lo demás sin cambios y créelo.",
|
||||
"txt_backup_recommend_tigris_step_3_prefix": "Luego abra la",
|
||||
"txt_backup_recommend_tigris_step_3_suffix": ", use cualquier nombre y créela.",
|
||||
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM después de crearla. Rellene los demás valores mostrados en la página de copia de seguridad usando los nombres correspondientes.",
|
||||
"txt_backup_recommend_tigris_step_5": "Por último, haga clic en Manage Key Permissions y active Admin Access; de lo contrario, no podrá escribir copias de seguridad.",
|
||||
"txt_backup_add_destination": "Añadir destino",
|
||||
"txt_backup_schedule_panel_title": "Programación automática",
|
||||
"txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.",
|
||||
|
||||
@@ -86,6 +86,37 @@ const ru: Record<string, string> = {
|
||||
"txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.",
|
||||
"txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.",
|
||||
"txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.",
|
||||
"txt_backup_recommend_backblaze_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ и без кредитной карты.",
|
||||
"txt_backup_recommend_backblaze_step_1": "Зарегистрируйте учетную запись Backblaze или войдите в нее.",
|
||||
"txt_backup_recommend_backblaze_step_2_prefix": "Откройте",
|
||||
"txt_backup_recommend_backblaze_step_2_suffix": ", нажмите Create a Bucket, введите только имя bucket, оставьте остальные настройки без изменений и создайте его.",
|
||||
"txt_backup_recommend_backblaze_step_3": "После создания вставьте показанный Endpoint в S3 Endpoint URL, имя bucket укажите в Bucket Name, а среднюю часть endpoint, например us-west-004, используйте как Region.",
|
||||
"txt_backup_recommend_backblaze_step_4_prefix": "Откройте",
|
||||
"txt_backup_recommend_backblaze_step_4_suffix": ", нажмите Add a New Application Key, введите любое Name of Key, оставьте остальные настройки без изменений и создайте ключ.",
|
||||
"txt_backup_recommend_backblaze_step_5": "Используйте keyID как ключ доступа, а applicationKey как секретный ключ.",
|
||||
"txt_backup_recommend_cloudflare_r2_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ, но с обязательной проверкой кредитной карты.",
|
||||
"txt_backup_recommend_cloudflare_r2_bucket_link": "страницу создания bucket",
|
||||
"txt_backup_recommend_cloudflare_r2_api_link": "страницу API token",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Откройте",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", введите только имя bucket и сразу создайте его.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Откройте",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", выберите Object Read & Write в разрешениях и сразу создайте токен.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_3": "После создания игнорируйте token value. Введите Access Key ID как ID доступа, а Secret Access Key как пароль доступа.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_4": "Вставьте показанный ниже адрес в S3 Endpoint URL, заполните Bucket Name как показано и оставьте Region в значении auto.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_5": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
|
||||
"txt_backup_recommend_s3_path_prefix_step": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
|
||||
"txt_backup_recommend_tigris_summary": "S3-совместимое объектное хранилище с бесплатными 5 ГБ и без кредитной карты.",
|
||||
"txt_backup_recommend_tigris_signup_link": "страницу регистрации",
|
||||
"txt_backup_recommend_tigris_bucket_link": "страницу Create Bucket",
|
||||
"txt_backup_recommend_tigris_access_key_link": "страницу Create Access Key",
|
||||
"txt_backup_recommend_tigris_step_1_prefix": "Откройте",
|
||||
"txt_backup_recommend_tigris_step_1_suffix": ", зарегистрируйтесь и войдите в Tigris.",
|
||||
"txt_backup_recommend_tigris_step_2_prefix": "Откройте",
|
||||
"txt_backup_recommend_tigris_step_2_suffix": ", введите только имя bucket, ничего больше не меняйте и создайте его.",
|
||||
"txt_backup_recommend_tigris_step_3_prefix": "Затем откройте",
|
||||
"txt_backup_recommend_tigris_step_3_suffix": ", введите любое имя и создайте ключ.",
|
||||
"txt_backup_recommend_tigris_step_4": "После создания игнорируйте Endpoint URL IAM. Остальные показанные значения заполните на странице резервного копирования по совпадающим названиям полей.",
|
||||
"txt_backup_recommend_tigris_step_5": "В конце нажмите Manage Key Permissions и включите Admin Access, иначе запись резервных копий не будет работать.",
|
||||
"txt_backup_add_destination": "Добавить пункт назначения",
|
||||
"txt_backup_schedule_panel_title": "Автоматическое расписание",
|
||||
"txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.",
|
||||
|
||||
@@ -85,6 +85,37 @@ const zhCN: Record<string, string> = {
|
||||
"txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。",
|
||||
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。",
|
||||
"txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。",
|
||||
"txt_backup_recommend_backblaze_summary": "兼容 S3 的对象存储,免费容量 10 GB,无需信用卡。",
|
||||
"txt_backup_recommend_backblaze_step_1": "先注册或登录 Backblaze 账号。",
|
||||
"txt_backup_recommend_backblaze_step_2_prefix": "打开",
|
||||
"txt_backup_recommend_backblaze_step_2_suffix": ",点击创建一个桶,只输入桶名字,其他地方不修改,然后创建。",
|
||||
"txt_backup_recommend_backblaze_step_3": "创建后显示的 Endpoint 填到 S3 端点 URL;桶名字填到存储桶名称;区域填 Endpoint 中间那段,例如 us-west-004。",
|
||||
"txt_backup_recommend_backblaze_step_4_prefix": "打开",
|
||||
"txt_backup_recommend_backblaze_step_4_suffix": ",点击 Add a New Application Key,随便输入 Name of Key,其他地方不动,然后创建。",
|
||||
"txt_backup_recommend_backblaze_step_5": "生成结果里的 keyID 填到 访问 ID,applicationKey 填到 访问密码。",
|
||||
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的对象存储,免费容量 10 GB,需要信用卡认证。",
|
||||
"txt_backup_recommend_cloudflare_r2_bucket_link": "创建储存桶页面",
|
||||
"txt_backup_recommend_cloudflare_r2_api_link": "API 创建页面",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打开",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只输入存储桶名称,直接创建。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打开",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",权限全选“对象读和写”,直接创建。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_3": "创建后令牌值不用管;Access Key ID 填到 访问 ID,Secret Access Key 填到 访问密码。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_4": "把下面显示的地址填到 S3 端点 URL;存储桶名称如实填写;区域保持 auto 不改。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_5": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
|
||||
"txt_backup_recommend_s3_path_prefix_step": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
|
||||
"txt_backup_recommend_tigris_summary": "兼容 S3 的对象存储。免费容量 5 GB,无需信用卡。",
|
||||
"txt_backup_recommend_tigris_signup_link": "注册页面",
|
||||
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 页面",
|
||||
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 页面",
|
||||
"txt_backup_recommend_tigris_step_1_prefix": "打开",
|
||||
"txt_backup_recommend_tigris_step_1_suffix": ",注册并登录 Tigris。",
|
||||
"txt_backup_recommend_tigris_step_2_prefix": "打开",
|
||||
"txt_backup_recommend_tigris_step_2_suffix": ",只输入桶的名字,其他地方不动,直接创建。",
|
||||
"txt_backup_recommend_tigris_step_3_prefix": "然后打开",
|
||||
"txt_backup_recommend_tigris_step_3_suffix": ",名字随意,直接创建。",
|
||||
"txt_backup_recommend_tigris_step_4": "创建后显示的 Endpoint URL IAM 不用管;其余显示出来的内容按名称填写到备份页面里。",
|
||||
"txt_backup_recommend_tigris_step_5": "最后点击 Manage Key Permissions,把 Admin Access 打开,否则无法写入。",
|
||||
"txt_backup_add_destination": "新增地点",
|
||||
"txt_backup_schedule_panel_title": "自动备份计划",
|
||||
"txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。",
|
||||
@@ -266,15 +297,15 @@ const zhCN: Record<string, string> = {
|
||||
"txt_backup_webdav_username": "WebDAV 用户名",
|
||||
"txt_backup_webdav_password": "WebDAV 密码",
|
||||
"txt_backup_webdav_path": "远程目录",
|
||||
"txt_backup_s3_endpoint": "S3 端点",
|
||||
"txt_backup_s3_addressing_style": "S3 寻址方式",
|
||||
"txt_backup_s3_endpoint": "S3 端点 URL",
|
||||
"txt_backup_s3_addressing_style": "寻址方式",
|
||||
"txt_backup_s3_addressing_path_style": "path-style(默认)",
|
||||
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||
"txt_backup_s3_bucket": "存储桶",
|
||||
"txt_backup_s3_bucket": "存储桶名称",
|
||||
"txt_backup_s3_region": "区域",
|
||||
"txt_backup_s3_access_key": "访问密钥",
|
||||
"txt_backup_s3_secret_key": "秘密密钥",
|
||||
"txt_backup_s3_path": "远程路径",
|
||||
"txt_backup_s3_access_key": "访问 ID",
|
||||
"txt_backup_s3_secret_key": "访问密码",
|
||||
"txt_backup_s3_path": "路径前缀",
|
||||
"txt_backup_reserved_name": "预留类型名称",
|
||||
"txt_backup_reserved_notes": "预留备注",
|
||||
"txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明",
|
||||
|
||||
@@ -85,6 +85,37 @@ const zhTW: Record<string, string> = {
|
||||
"txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。",
|
||||
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。",
|
||||
"txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。",
|
||||
"txt_backup_recommend_backblaze_summary": "兼容 S3 的對象儲存,免費容量 10 GB,無需信用卡。",
|
||||
"txt_backup_recommend_backblaze_step_1": "先註冊或登入 Backblaze 賬號。",
|
||||
"txt_backup_recommend_backblaze_step_2_prefix": "打開",
|
||||
"txt_backup_recommend_backblaze_step_2_suffix": ",點擊創建一個桶,只輸入桶名字,其他地方不修改,然後創建。",
|
||||
"txt_backup_recommend_backblaze_step_3": "創建後顯示的 Endpoint 填到 S3 端點 URL;桶名字填到儲存桶名稱;區域填 Endpoint 中間那段,例如 us-west-004。",
|
||||
"txt_backup_recommend_backblaze_step_4_prefix": "打開",
|
||||
"txt_backup_recommend_backblaze_step_4_suffix": ",點擊 Add a New Application Key,隨便輸入 Name of Key,其他地方不動,然後創建。",
|
||||
"txt_backup_recommend_backblaze_step_5": "生成結果裡的 keyID 填存取金鑰,applicationKey 填秘密金鑰。",
|
||||
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的對象儲存,免費容量 10 GB,需要信用卡驗證。",
|
||||
"txt_backup_recommend_cloudflare_r2_bucket_link": "創建儲存桶頁面",
|
||||
"txt_backup_recommend_cloudflare_r2_api_link": "API 創建頁面",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打開",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只輸入儲存桶名稱,直接創建。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打開",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",權限全選「對象讀和寫」,直接創建。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_3": "創建後令牌值不用管;Access Key ID 填到存取 ID,Secret Access Key 填到存取密碼。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_4": "把下面顯示的地址填到 S3 端點 URL;儲存桶名稱如實填寫;區域保持 auto 不改。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_5": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
|
||||
"txt_backup_recommend_s3_path_prefix_step": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
|
||||
"txt_backup_recommend_tigris_summary": "兼容 S3 的對象儲存。免費容量 5 GB,無需信用卡。",
|
||||
"txt_backup_recommend_tigris_signup_link": "註冊頁面",
|
||||
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 頁面",
|
||||
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 頁面",
|
||||
"txt_backup_recommend_tigris_step_1_prefix": "打開",
|
||||
"txt_backup_recommend_tigris_step_1_suffix": ",註冊並登入 Tigris。",
|
||||
"txt_backup_recommend_tigris_step_2_prefix": "打開",
|
||||
"txt_backup_recommend_tigris_step_2_suffix": ",只輸入桶的名字,其他地方不動,直接創建。",
|
||||
"txt_backup_recommend_tigris_step_3_prefix": "然後打開",
|
||||
"txt_backup_recommend_tigris_step_3_suffix": ",名字隨意,直接創建。",
|
||||
"txt_backup_recommend_tigris_step_4": "創建後顯示的 Endpoint URL IAM 不用管;其餘顯示出來的內容按名稱填寫到備份頁面裡。",
|
||||
"txt_backup_recommend_tigris_step_5": "最後點擊 Manage Key Permissions,把 Admin Access 打開,否則無法寫入。",
|
||||
"txt_backup_add_destination": "新增地點",
|
||||
"txt_backup_schedule_panel_title": "自動備份計劃",
|
||||
"txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。",
|
||||
|
||||
@@ -351,6 +351,15 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .backup-recommendation-step a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .backup-recommendation-step a:hover,
|
||||
:root[data-theme='dark'] .backup-recommendation-step a:focus-visible {
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .restore-progress-overlay {
|
||||
background: var(--overlay-strong);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
@@ -193,6 +193,18 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.backup-recommendation-step a {
|
||||
color: #1d4ed8;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.backup-recommendation-step a:hover,
|
||||
.backup-recommendation-step a:focus-visible {
|
||||
color: #1742b0;
|
||||
}
|
||||
|
||||
.backup-recommendation-inline-note {
|
||||
color: #475467;
|
||||
line-height: 1.5;
|
||||
|
||||
Reference in New Issue
Block a user