wekan/client/lib/localStorageValidator.js

278 lines
7.4 KiB
JavaScript

/**
* LocalStorage Validation and Cleanup Utility
*
* Validates and cleans up per-user UI state stored in localStorage
* for non-logged-in users (swimlane heights, list widths, collapse states)
*/
// Maximum age for localStorage data (90 days)
const MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
// Maximum number of boards to keep per storage key
const MAX_BOARDS_PER_KEY = 50;
// Maximum number of items per board
const MAX_ITEMS_PER_BOARD = 100;
/**
* Validate that a value is a valid positive number
*/
function isValidNumber(value, min = 0, max = 10000) {
if (typeof value !== 'number') return false;
if (isNaN(value)) return false;
if (!isFinite(value)) return false;
if (value < min || value > max) return false;
return true;
}
/**
* Validate that a value is a valid boolean
*/
function isValidBoolean(value) {
return typeof value === 'boolean';
}
/**
* Validate and clean swimlane heights data
* Structure: { boardId: { swimlaneId: height, ... }, ... }
*/
function validateSwimlaneHeights(data) {
if (!data || typeof data !== 'object') return {};
const cleaned = {};
const boardIds = Object.keys(data).slice(0, MAX_BOARDS_PER_KEY);
for (const boardId of boardIds) {
if (typeof boardId !== 'string' || boardId.length === 0) continue;
const boardData = data[boardId];
if (!boardData || typeof boardData !== 'object') continue;
const swimlaneIds = Object.keys(boardData).slice(0, MAX_ITEMS_PER_BOARD);
const cleanedBoard = {};
for (const swimlaneId of swimlaneIds) {
if (typeof swimlaneId !== 'string' || swimlaneId.length === 0) continue;
const height = boardData[swimlaneId];
// Valid swimlane heights: -1 (auto) or 50-2000 pixels
if (isValidNumber(height, -1, 2000)) {
cleanedBoard[swimlaneId] = height;
}
}
if (Object.keys(cleanedBoard).length > 0) {
cleaned[boardId] = cleanedBoard;
}
}
return cleaned;
}
/**
* Validate and clean list widths data
* Structure: { boardId: { listId: width, ... }, ... }
*/
function validateListWidths(data) {
if (!data || typeof data !== 'object') return {};
const cleaned = {};
const boardIds = Object.keys(data).slice(0, MAX_BOARDS_PER_KEY);
for (const boardId of boardIds) {
if (typeof boardId !== 'string' || boardId.length === 0) continue;
const boardData = data[boardId];
if (!boardData || typeof boardData !== 'object') continue;
const listIds = Object.keys(boardData).slice(0, MAX_ITEMS_PER_BOARD);
const cleanedBoard = {};
for (const listId of listIds) {
if (typeof listId !== 'string' || listId.length === 0) continue;
const width = boardData[listId];
// Valid list widths: 100-1000 pixels
if (isValidNumber(width, 100, 1000)) {
cleanedBoard[listId] = width;
}
}
if (Object.keys(cleanedBoard).length > 0) {
cleaned[boardId] = cleanedBoard;
}
}
return cleaned;
}
/**
* Validate and clean collapsed states data
* Structure: { boardId: { itemId: boolean, ... }, ... }
*/
function validateCollapsedStates(data) {
if (!data || typeof data !== 'object') return {};
const cleaned = {};
const boardIds = Object.keys(data).slice(0, MAX_BOARDS_PER_KEY);
for (const boardId of boardIds) {
if (typeof boardId !== 'string' || boardId.length === 0) continue;
const boardData = data[boardId];
if (!boardData || typeof boardData !== 'object') continue;
const itemIds = Object.keys(boardData).slice(0, MAX_ITEMS_PER_BOARD);
const cleanedBoard = {};
for (const itemId of itemIds) {
if (typeof itemId !== 'string' || itemId.length === 0) continue;
const collapsed = boardData[itemId];
if (isValidBoolean(collapsed)) {
cleanedBoard[itemId] = collapsed;
}
}
if (Object.keys(cleanedBoard).length > 0) {
cleaned[boardId] = cleanedBoard;
}
}
return cleaned;
}
/**
* Validate and clean a single localStorage key
*/
function validateAndCleanKey(key, validator) {
try {
const stored = localStorage.getItem(key);
if (!stored) return;
const data = JSON.parse(stored);
const cleaned = validator(data);
// Only write back if data changed
const cleanedStr = JSON.stringify(cleaned);
if (cleanedStr !== stored) {
if (Object.keys(cleaned).length > 0) {
localStorage.setItem(key, cleanedStr);
} else {
localStorage.removeItem(key);
}
}
} catch (e) {
console.warn(`Error validating localStorage key ${key}:`, e);
// Remove corrupted data
try {
localStorage.removeItem(key);
} catch (removeError) {
console.error(`Failed to remove corrupted localStorage key ${key}:`, removeError);
}
}
}
/**
* Validate and clean all Wekan localStorage data
* Called on app startup and periodically
*/
export function validateAndCleanLocalStorage() {
if (typeof localStorage === 'undefined') return;
try {
// Validate swimlane heights
validateAndCleanKey('wekan-swimlane-heights', validateSwimlaneHeights);
// Validate list widths
validateAndCleanKey('wekan-list-widths', validateListWidths);
// Validate list constraints
validateAndCleanKey('wekan-list-constraints', validateListWidths);
// Validate collapsed lists
validateAndCleanKey('wekan-collapsed-lists', validateCollapsedStates);
// Validate collapsed swimlanes
validateAndCleanKey('wekan-collapsed-swimlanes', validateCollapsedStates);
// Record last cleanup time
localStorage.setItem('wekan-last-cleanup', Date.now().toString());
} catch (e) {
console.error('Error during localStorage validation:', e);
}
}
/**
* Check if cleanup is needed (once per day)
*/
export function shouldRunCleanup() {
if (typeof localStorage === 'undefined') return false;
try {
const lastCleanup = localStorage.getItem('wekan-last-cleanup');
if (!lastCleanup) return true;
const lastCleanupTime = parseInt(lastCleanup, 10);
if (isNaN(lastCleanupTime)) return true;
const timeSince = Date.now() - lastCleanupTime;
// Run cleanup once per day
return timeSince > 24 * 60 * 60 * 1000;
} catch (e) {
return true;
}
}
/**
* Get validated data from localStorage
*/
export function getValidatedLocalStorageData(key, validator) {
if (typeof localStorage === 'undefined') return {};
try {
const stored = localStorage.getItem(key);
if (!stored) return {};
const data = JSON.parse(stored);
return validator(data);
} catch (e) {
console.warn(`Error reading localStorage key ${key}:`, e);
return {};
}
}
/**
* Set validated data to localStorage
*/
export function setValidatedLocalStorageData(key, data, validator) {
if (typeof localStorage === 'undefined') return false;
try {
const validated = validator(data);
localStorage.setItem(key, JSON.stringify(validated));
return true;
} catch (e) {
console.error(`Error writing localStorage key ${key}:`, e);
return false;
}
}
// Export validators for use by other modules
export const validators = {
swimlaneHeights: validateSwimlaneHeights,
listWidths: validateListWidths,
collapsedStates: validateCollapsedStates,
isValidNumber,
isValidBoolean,
};
// Auto-cleanup on module load if needed
if (Meteor.isClient) {
Meteor.startup(() => {
if (shouldRunCleanup()) {
validateAndCleanLocalStorage();
}
});
}