mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-09 07:46:13 -04:00
Add 413 error message handler to browser extension (#1786)
This commit is contained in:
committed by
Leendert de Borst
parent
38b0c866a6
commit
01bf953d9c
@@ -14,6 +14,7 @@ import { getItemWithFallback } from '@/utils/StorageUtility';
|
||||
import { ApiAuthError } from '@/utils/types/errors/ApiAuthError';
|
||||
import { AppErrorCode, formatErrorWithCode } from '@/utils/types/errors/AppErrorCodes';
|
||||
import { NetworkError } from '@/utils/types/errors/NetworkError';
|
||||
import { PayloadTooLargeError } from '@/utils/types/errors/PayloadTooLargeError';
|
||||
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
|
||||
import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse';
|
||||
import type { DuplicateCheckResponse } from '@/utils/types/messaging/DuplicateCheckResponse';
|
||||
@@ -682,13 +683,22 @@ export async function handleUploadVault(
|
||||
} catch (error) {
|
||||
console.error('Failed to upload vault:', error);
|
||||
|
||||
// Check if error is UPLOAD_OUTDATED (E-802) - server has newer vault
|
||||
const errorMessage = error instanceof Error ? error.message : '';
|
||||
|
||||
// Check if error is UPLOAD_OUTDATED (E-802) - server has newer vault
|
||||
if (errorMessage.includes('E-802')) {
|
||||
// Return status 2 (Outdated) so caller can handle merge
|
||||
return { success: false, status: 2, error: errorMessage };
|
||||
}
|
||||
|
||||
/*
|
||||
* Pass through any error already tagged with an E-XXX code (e.g. E-804 for HTTP 413).
|
||||
* Stripping the targeted code and replacing it with E-801 would lose the actionable message.
|
||||
*/
|
||||
if (/E-\d{3}/.test(errorMessage)) {
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
|
||||
// E-801: Upload failed for other reasons
|
||||
return { success: false, error: formatErrorWithCode(await t('common.errors.unknownError'), AppErrorCode.UPLOAD_FAILED) };
|
||||
}
|
||||
@@ -810,7 +820,15 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<Vaul
|
||||
};
|
||||
|
||||
const webApi = new WebApiService();
|
||||
const response = await webApi.post<Vault, VaultPostResponse>('Vault', newVault);
|
||||
let response: VaultPostResponse;
|
||||
try {
|
||||
response = await webApi.post<Vault, VaultPostResponse>('Vault', newVault);
|
||||
} catch (err) {
|
||||
if (err instanceof PayloadTooLargeError) {
|
||||
throw new Error(formatErrorWithCode(await t('common.errors.vaultTooLarge'), AppErrorCode.UPLOAD_TOO_LARGE));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Check if response is successful (.status === 0)
|
||||
if (response.status === 0) {
|
||||
@@ -1111,11 +1129,47 @@ export async function handleCheckSyncStatus(): Promise<SyncStatusCheckResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a sync error message to local storage so the popup can surface it
|
||||
* even when the failing sync was triggered from the background (e.g. follow-up
|
||||
* syncs after pending mutations). Cleared on the next successful sync.
|
||||
*
|
||||
* Skips errors that already have dedicated UX:
|
||||
* - requiresLogout: handled by the forced re-login flow
|
||||
* - wasOffline: handled by the offline indicator
|
||||
*/
|
||||
async function persistSyncErrorState(result: FullVaultSyncResult): Promise<void> {
|
||||
if (result.requiresLogout || result.wasOffline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = result.errorKey
|
||||
? await t('common.errors.' + result.errorKey)
|
||||
: result.error;
|
||||
|
||||
if (errorMessage) {
|
||||
await storage.setItem('local:lastSyncError', errorMessage);
|
||||
} else if (result.success) {
|
||||
await storage.removeItem('local:lastSyncError');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full vault sync orchestration that runs entirely in background context.
|
||||
* This ensures sync completes even if popup closes mid-operation.
|
||||
* Wraps the internal implementation with sync-error persistence so the popup
|
||||
* can show a targeted alert for failures even if it wasn't open at the time.
|
||||
*/
|
||||
export async function handleFullVaultSync(): Promise<FullVaultSyncResult> {
|
||||
const result = await handleFullVaultSyncInternal();
|
||||
await persistSyncErrorState(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation of the full vault sync. Wrapped by handleFullVaultSync
|
||||
* so the result can be persisted to local storage for the popup to surface.
|
||||
*/
|
||||
async function handleFullVaultSyncInternal(): Promise<FullVaultSyncResult> {
|
||||
// Check if sync is already in progress
|
||||
if (isSyncInProgress) {
|
||||
// Mark that we need to sync again after current sync completes
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
|
||||
import { ClipboardCountdownBar } from '@/entrypoints/popup/components/ClipboardCountdownBar';
|
||||
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
|
||||
import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav';
|
||||
import Header from '@/entrypoints/popup/components/Layout/Header';
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
/**
|
||||
* Route configuration type.
|
||||
@@ -32,6 +35,8 @@ type DefaultLayoutProps = {
|
||||
const DefaultLayout: React.FC<DefaultLayoutProps> = ({ routes, headerButtons, message, children }) => {
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { syncError, clearSyncError } = useDb();
|
||||
|
||||
// Reset scroll position when route changes
|
||||
useEffect(() => {
|
||||
@@ -76,6 +81,16 @@ const DefaultLayout: React.FC<DefaultLayoutProps> = ({ routes, headerButtons, me
|
||||
</main>
|
||||
|
||||
<BottomNav />
|
||||
|
||||
<Modal
|
||||
isOpen={syncError !== null}
|
||||
onClose={clearSyncError}
|
||||
onConfirm={clearSyncError}
|
||||
title={t('sync.syncErrorTitle')}
|
||||
message={syncError ?? ''}
|
||||
confirmText={t('sync.syncErrorDismiss')}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,6 +76,16 @@ type DbContextType = {
|
||||
*/
|
||||
refreshSyncState: () => Promise<void>;
|
||||
hasPendingMigrations: () => Promise<boolean>;
|
||||
/**
|
||||
* Last sync error message persisted by the background sync. Surfaced as a popup
|
||||
* alert. Null when no error is pending. Updated reactively via storage.watch so
|
||||
* background-initiated sync failures show up immediately while popup is open.
|
||||
*/
|
||||
syncError: string | null;
|
||||
/**
|
||||
* Dismiss the current sync error (clears both React state and persisted storage).
|
||||
*/
|
||||
clearSyncError: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DbContext = createContext<DbContextType | undefined>(undefined);
|
||||
@@ -126,6 +136,12 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
*/
|
||||
const [serverRevision, setServerRevision] = useState(0);
|
||||
|
||||
/**
|
||||
* Last sync error written by the background sync. Driven by storage so background-only
|
||||
* syncs (e.g. follow-up syncs after pending mutations) reach the user.
|
||||
*/
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Check if email errors should be suppressed.
|
||||
* Errors are suppressed when vault has local changes not yet synced,
|
||||
@@ -153,19 +169,42 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
* Load the offline mode and sync state from local storage.
|
||||
*/
|
||||
const loadSyncState = async (): Promise<void> => {
|
||||
const [offlineMode, dirty, revision] = await Promise.all([
|
||||
const [offlineMode, dirty, revision, lastError] = await Promise.all([
|
||||
storage.getItem('local:isOfflineMode') as Promise<boolean | null>,
|
||||
storage.getItem('local:isDirty') as Promise<boolean | null>,
|
||||
storage.getItem('local:serverRevision') as Promise<number | null>
|
||||
storage.getItem('local:serverRevision') as Promise<number | null>,
|
||||
storage.getItem('local:lastSyncError') as Promise<string | null>
|
||||
]);
|
||||
isOfflineRef.current = offlineMode ?? false;
|
||||
setIsOfflineState(offlineMode ?? false);
|
||||
setIsDirty(dirty ?? false);
|
||||
setServerRevision(revision ?? 0);
|
||||
setSyncError(lastError ?? null);
|
||||
};
|
||||
loadSyncState();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Subscribe to background-driven sync error updates so a popup alert appears
|
||||
* even when the failing sync wasn't triggered by anything in the popup itself.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const unwatch = storage.watch<string | null>('local:lastSyncError', (newValue) => {
|
||||
setSyncError(newValue ?? null);
|
||||
});
|
||||
return (): void => {
|
||||
unwatch();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Dismiss the current sync error from both React state and persisted storage.
|
||||
*/
|
||||
const clearSyncError = useCallback(async (): Promise<void> => {
|
||||
setSyncError(null);
|
||||
await storage.removeItem('local:lastSyncError');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Load a decrypted vault into memory (SQLite client).
|
||||
*/
|
||||
@@ -325,7 +364,9 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
getVaultMetadata,
|
||||
refreshSyncState,
|
||||
hasPendingMigrations,
|
||||
}), [sqliteClient, dbInitialized, dbAvailable, isOffline, getIsOffline, isDirty, isSyncing, isUploading, serverRevision, setIsOffline, shouldSuppressEmailErrors, loadDatabase, loadStoredDatabase, storeEncryptionKey, storeEncryptionKeyDerivationParams, clearDatabase, getVaultMetadata, refreshSyncState, hasPendingMigrations]);
|
||||
syncError,
|
||||
clearSyncError,
|
||||
}), [sqliteClient, dbInitialized, dbAvailable, isOffline, getIsOffline, isDirty, isSyncing, isUploading, serverRevision, setIsOffline, shouldSuppressEmailErrors, loadDatabase, loadStoredDatabase, storeEncryptionKey, storeEncryptionKeyDerivationParams, clearDatabase, getVaultMetadata, refreshSyncState, hasPendingMigrations, syncError, clearSyncError]);
|
||||
|
||||
return (
|
||||
<DbContext.Provider value={contextValue}>
|
||||
|
||||
@@ -127,6 +127,18 @@ export function useVaultMutate(): {
|
||||
*/
|
||||
void sendMessage('FULL_VAULT_SYNC', {}, 'background').then(async (result) => {
|
||||
const syncResult = result as FullVaultSyncResult;
|
||||
if (!syncResult.success && (syncResult.error || syncResult.errorKey)) {
|
||||
/*
|
||||
* Permanent failure (e.g. HTTP 413 vault too large). Stop polling and clear the upload
|
||||
* spinner.
|
||||
*/
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
dbContext.setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
if (syncResult.hasNewVault) {
|
||||
await dbContext.loadStoredDatabase();
|
||||
}
|
||||
|
||||
@@ -122,8 +122,11 @@ export const useVaultSync = (): { syncVault: (options?: VaultSyncOptions) => Pro
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
// Always clear syncing/uploading states when done
|
||||
/*
|
||||
* Always clear syncing/uploading states when done including on failure.
|
||||
*/
|
||||
dbContext.setIsSyncing(false);
|
||||
dbContext.setIsUploading(false);
|
||||
}
|
||||
}, [app, dbContext, t]);
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"passwordChanged": "Your password has changed since the last time you logged in. Please login again for security reasons.",
|
||||
"syncConflictMaxRetries": "Could not sync vault after multiple attempts. Please try again later.",
|
||||
"mergeFailed": "Failed to merge vault changes. Please try again.",
|
||||
"vaultTooLarge": "The vault is too large for the server to accept. Try to remove some items or attachments to reduce the size and try again.",
|
||||
"invalidCode": "Please enter a valid 6-digit authentication code.",
|
||||
"serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.",
|
||||
"wrongPassword": "Incorrect password. Please try again.",
|
||||
@@ -546,6 +547,8 @@
|
||||
},
|
||||
"sync": {
|
||||
"offline": "Offline",
|
||||
"tapToRetry": "Tap to retry sync"
|
||||
"tapToRetry": "Tap to retry sync",
|
||||
"syncErrorTitle": "Sync Failed",
|
||||
"syncErrorDismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { logoutEventEmitter } from '@/events/LogoutEventEmitter';
|
||||
import { AppInfo } from "./AppInfo";
|
||||
import { ApiAuthError } from './types/errors/ApiAuthError';
|
||||
import { NetworkError } from './types/errors/NetworkError';
|
||||
import { PayloadTooLargeError } from './types/errors/PayloadTooLargeError';
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
@@ -89,6 +90,10 @@ export class WebApiService {
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 413 && throwOnError) {
|
||||
throw new PayloadTooLargeError(`Request rejected with HTTP 413: payload exceeds server limit`);
|
||||
}
|
||||
|
||||
if (!response.ok && throwOnError) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export enum AppErrorCode {
|
||||
UPLOAD_FAILED = 'E-801',
|
||||
UPLOAD_OUTDATED = 'E-802', // Server has newer version
|
||||
UPLOAD_ENCRYPT_FAILED = 'E-803',
|
||||
UPLOAD_TOO_LARGE = 'E-804', // Server rejected upload with HTTP 413 (vault exceeds MAX_UPLOAD_SIZE_MB)
|
||||
|
||||
// Migration/version errors (E-9xx)
|
||||
MIGRATION_CHECK_FAILED = 'E-901',
|
||||
@@ -159,6 +160,7 @@ export function getErrorTranslationKey(code: AppErrorCode): string {
|
||||
[AppErrorCode.UPLOAD_FAILED]: 'common.errors.unknownErrorTryAgain',
|
||||
[AppErrorCode.UPLOAD_OUTDATED]: 'common.errors.unknownErrorTryAgain',
|
||||
[AppErrorCode.UPLOAD_ENCRYPT_FAILED]: 'common.errors.unknownErrorTryAgain',
|
||||
[AppErrorCode.UPLOAD_TOO_LARGE]: 'common.errors.vaultTooLarge',
|
||||
|
||||
// Migration/version errors (E-9xx)
|
||||
[AppErrorCode.MIGRATION_CHECK_FAILED]: 'common.errors.unknownErrorTryAgain',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Thrown when the server rejects a request with HTTP 413 (Request Entity Too Large).
|
||||
* For vault uploads this signals the encrypted vault exceeds the server's MAX_UPLOAD_SIZE_MB limit.
|
||||
*/
|
||||
export class PayloadTooLargeError extends Error {
|
||||
/**
|
||||
* Creates a new instance of PayloadTooLargeError.
|
||||
*
|
||||
* @param message - The error message.
|
||||
*/
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PayloadTooLargeError';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user