mirror of
https://github.com/wekan/wekan.git
synced 2025-12-27 12:48:49 +01:00
278 lines
7.4 KiB
JavaScript
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();
|
|
}
|
|
});
|
|
}
|