Implement safe storage utility and update i18n handling to use it

This commit is contained in:
alam00000
2026-08-14 15:13:42 +05:30
parent e1a4a31458
commit 706e74f084
4 changed files with 104 additions and 13 deletions
+13 -3
View File
@@ -1,5 +1,8 @@
import i18next from 'i18next';
import HttpBackend from 'i18next-http-backend';
import { getStoredItem, setStoredItem } from '../utils/safe-storage.js';
import enCommon from '../../../public/locales/en/common.json';
import enTools from '../../../public/locales/en/tools.json';
// Supported languages
export const supportedLanguages = [
@@ -73,7 +76,7 @@ export const getLanguageFromUrl = (): SupportedLanguage => {
return langMatch[1] as SupportedLanguage;
}
const storedLang = localStorage.getItem('i18nextLng');
const storedLang = getStoredItem('i18nextLng');
if (
storedLang &&
supportedLanguages.includes(storedLang as SupportedLanguage)
@@ -110,7 +113,7 @@ export const initI18n = async (): Promise<typeof i18next> => {
const currentLang = getLanguageFromUrl();
localStorage.setItem('i18nextLng', currentLang);
setStoredItem('i18nextLng', currentLang);
await i18next.use(HttpBackend).init({
lng: currentLang,
@@ -119,6 +122,13 @@ export const initI18n = async (): Promise<typeof i18next> => {
ns: ['common', 'tools'],
defaultNS: 'common',
preload: [currentLang],
partialBundledLanguages: true,
resources: {
en: {
common: enCommon,
tools: enTools,
},
},
backend: {
loadPath: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}locales/{{lng}}/{{ns}}.json`,
},
@@ -139,7 +149,7 @@ export const t = (key: string, options?: Record<string, unknown>): string => {
export const changeLanguage = (lang: SupportedLanguage): void => {
if (!supportedLanguages.includes(lang)) return;
localStorage.setItem('i18nextLng', lang);
setStoredItem('i18nextLng', lang);
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
let relativePath = window.location.pathname;
+12 -10
View File
@@ -22,6 +22,11 @@ import {
isToolDisabled,
isCurrentPageDisabled,
} from './utils/disabled-tools.js';
import {
getStoredItem,
setStoredItem,
removeStoredItem,
} from './utils/safe-storage.js';
declare const __BRAND_NAME__: string;
const init = async () => {
@@ -290,17 +295,14 @@ const init = async () => {
let collapsedCategories: string[] = [];
try {
const stored = localStorage.getItem('collapsedCategories');
const stored = getStoredItem('collapsedCategories');
if (stored) collapsedCategories = JSON.parse(stored);
} catch {
localStorage.removeItem('collapsedCategories');
removeStoredItem('collapsedCategories');
}
function saveCollapsedCategories() {
localStorage.setItem(
'collapsedCategories',
JSON.stringify(collapsedCategories)
);
setStoredItem('collapsedCategories', JSON.stringify(collapsedCategories));
}
const filteredCategories = categories
@@ -615,7 +617,7 @@ const init = async () => {
) as HTMLInputElement;
const toolInterface = document.getElementById('tool-interface');
const savedFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
const savedFullWidth = getStoredItem('fullWidthMode') !== 'false';
if (fullWidthToggle) {
fullWidthToggle.checked = savedFullWidth;
applyFullWidthMode(savedFullWidth);
@@ -652,7 +654,7 @@ const init = async () => {
if (fullWidthToggle) {
fullWidthToggle.addEventListener('change', (e) => {
const enabled = (e.target as HTMLInputElement).checked;
localStorage.setItem('fullWidthMode', enabled.toString());
setStoredItem('fullWidthMode', enabled.toString());
applyFullWidthMode(enabled);
});
}
@@ -661,7 +663,7 @@ const init = async () => {
'compact-mode-toggle'
) as HTMLInputElement;
const savedCompactMode = localStorage.getItem('compactMode') === 'true';
const savedCompactMode = getStoredItem('compactMode') === 'true';
if (compactModeToggle) {
compactModeToggle.checked = savedCompactMode;
}
@@ -681,7 +683,7 @@ const init = async () => {
if (compactModeToggle) {
compactModeToggle.addEventListener('change', (e) => {
const enabled = (e.target as HTMLInputElement).checked;
localStorage.setItem('compactMode', enabled.toString());
setStoredItem('compactMode', enabled.toString());
applyCompactMode(enabled);
});
}
+25
View File
@@ -0,0 +1,25 @@
export const getStoredItem = (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
export const setStoredItem = (key: string, value: string): boolean => {
try {
localStorage.setItem(key, value);
return true;
} catch {
return false;
}
};
export const removeStoredItem = (key: string): boolean => {
try {
localStorage.removeItem(key);
return true;
} catch {
return false;
}
};
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const blockStorage = () => {
Object.defineProperty(window, 'localStorage', {
configurable: true,
get() {
throw new DOMException('The operation is insecure.', 'SecurityError');
},
});
};
const failLocaleFetches = () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new TypeError('NetworkError')))
);
};
describe('i18n resilience on hardened browsers', () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
});
it('getLanguageFromUrl does not throw when localStorage access is blocked', async () => {
blockStorage();
const { getLanguageFromUrl } = await import('../js/i18n/i18n');
expect(() => getLanguageFromUrl()).not.toThrow();
expect(getLanguageFromUrl()).toBe('en');
});
it('initI18n succeeds with blocked storage and failing locale fetches', async () => {
blockStorage();
failLocaleFetches();
const { initI18n, t } = await import('../js/i18n/i18n');
await expect(initI18n()).resolves.toBeDefined();
expect(t('simpleMode.title')).not.toBe('simpleMode.title');
});
it('t() serves bundled English instead of raw keys when fetches fail', async () => {
failLocaleFetches();
const { initI18n, t } = await import('../js/i18n/i18n');
await initI18n();
expect(t('simpleMode.title')).toBe('PDF Tools');
expect(t('tools:pdfToTiff.name')).toBe('PDF to TIFF');
expect(t('common.error')).not.toBe('common.error');
});
it('changeLanguage does not throw when storage is blocked', async () => {
blockStorage();
const { changeLanguage } = await import('../js/i18n/i18n');
expect(() => changeLanguage('de')).not.toThrow();
});
});