localStorageValidator.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. /**
  2. * LocalStorage Validation and Cleanup Utility
  3. *
  4. * Validates and cleans up per-user UI state stored in localStorage
  5. * for non-logged-in users (swimlane heights, list widths, collapse states)
  6. */
  7. // Maximum age for localStorage data (90 days)
  8. const MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
  9. // Maximum number of boards to keep per storage key
  10. const MAX_BOARDS_PER_KEY = 50;
  11. // Maximum number of items per board
  12. const MAX_ITEMS_PER_BOARD = 100;
  13. /**
  14. * Validate that a value is a valid positive number
  15. */
  16. function isValidNumber(value, min = 0, max = 10000) {
  17. if (typeof value !== 'number') return false;
  18. if (isNaN(value)) return false;
  19. if (!isFinite(value)) return false;
  20. if (value < min || value > max) return false;
  21. return true;
  22. }
  23. /**
  24. * Validate that a value is a valid boolean
  25. */
  26. function isValidBoolean(value) {
  27. return typeof value === 'boolean';
  28. }
  29. /**
  30. * Validate and clean swimlane heights data
  31. * Structure: { boardId: { swimlaneId: height, ... }, ... }
  32. */
  33. function validateSwimlaneHeights(data) {
  34. if (!data || typeof data !== 'object') return {};
  35. const cleaned = {};
  36. const boardIds = Object.keys(data).slice(0, MAX_BOARDS_PER_KEY);
  37. for (const boardId of boardIds) {
  38. if (typeof boardId !== 'string' || boardId.length === 0) continue;
  39. const boardData = data[boardId];
  40. if (!boardData || typeof boardData !== 'object') continue;
  41. const swimlaneIds = Object.keys(boardData).slice(0, MAX_ITEMS_PER_BOARD);
  42. const cleanedBoard = {};
  43. for (const swimlaneId of swimlaneIds) {
  44. if (typeof swimlaneId !== 'string' || swimlaneId.length === 0) continue;
  45. const height = boardData[swimlaneId];
  46. // Valid swimlane heights: -1 (auto) or 50-2000 pixels
  47. if (isValidNumber(height, -1, 2000)) {
  48. cleanedBoard[swimlaneId] = height;
  49. }
  50. }
  51. if (Object.keys(cleanedBoard).length > 0) {
  52. cleaned[boardId] = cleanedBoard;
  53. }
  54. }
  55. return cleaned;
  56. }
  57. /**
  58. * Validate and clean list widths data
  59. * Structure: { boardId: { listId: width, ... }, ... }
  60. */
  61. function validateListWidths(data) {
  62. if (!data || typeof data !== 'object') return {};
  63. const cleaned = {};
  64. const boardIds = Object.keys(data).slice(0, MAX_BOARDS_PER_KEY);
  65. for (const boardId of boardIds) {
  66. if (typeof boardId !== 'string' || boardId.length === 0) continue;
  67. const boardData = data[boardId];
  68. if (!boardData || typeof boardData !== 'object') continue;
  69. const listIds = Object.keys(boardData).slice(0, MAX_ITEMS_PER_BOARD);
  70. const cleanedBoard = {};
  71. for (const listId of listIds) {
  72. if (typeof listId !== 'string' || listId.length === 0) continue;
  73. const width = boardData[listId];
  74. // Valid list widths: 100-1000 pixels
  75. if (isValidNumber(width, 100, 1000)) {
  76. cleanedBoard[listId] = width;
  77. }
  78. }
  79. if (Object.keys(cleanedBoard).length > 0) {
  80. cleaned[boardId] = cleanedBoard;
  81. }
  82. }
  83. return cleaned;
  84. }
  85. /**
  86. * Validate and clean collapsed states data
  87. * Structure: { boardId: { itemId: boolean, ... }, ... }
  88. */
  89. function validateCollapsedStates(data) {
  90. if (!data || typeof data !== 'object') return {};
  91. const cleaned = {};
  92. const boardIds = Object.keys(data).slice(0, MAX_BOARDS_PER_KEY);
  93. for (const boardId of boardIds) {
  94. if (typeof boardId !== 'string' || boardId.length === 0) continue;
  95. const boardData = data[boardId];
  96. if (!boardData || typeof boardData !== 'object') continue;
  97. const itemIds = Object.keys(boardData).slice(0, MAX_ITEMS_PER_BOARD);
  98. const cleanedBoard = {};
  99. for (const itemId of itemIds) {
  100. if (typeof itemId !== 'string' || itemId.length === 0) continue;
  101. const collapsed = boardData[itemId];
  102. if (isValidBoolean(collapsed)) {
  103. cleanedBoard[itemId] = collapsed;
  104. }
  105. }
  106. if (Object.keys(cleanedBoard).length > 0) {
  107. cleaned[boardId] = cleanedBoard;
  108. }
  109. }
  110. return cleaned;
  111. }
  112. /**
  113. * Validate and clean a single localStorage key
  114. */
  115. function validateAndCleanKey(key, validator) {
  116. try {
  117. const stored = localStorage.getItem(key);
  118. if (!stored) return;
  119. const data = JSON.parse(stored);
  120. const cleaned = validator(data);
  121. // Only write back if data changed
  122. const cleanedStr = JSON.stringify(cleaned);
  123. if (cleanedStr !== stored) {
  124. if (Object.keys(cleaned).length > 0) {
  125. localStorage.setItem(key, cleanedStr);
  126. } else {
  127. localStorage.removeItem(key);
  128. }
  129. }
  130. } catch (e) {
  131. console.warn(`Error validating localStorage key ${key}:`, e);
  132. // Remove corrupted data
  133. try {
  134. localStorage.removeItem(key);
  135. } catch (removeError) {
  136. console.error(`Failed to remove corrupted localStorage key ${key}:`, removeError);
  137. }
  138. }
  139. }
  140. /**
  141. * Validate and clean all Wekan localStorage data
  142. * Called on app startup and periodically
  143. */
  144. export function validateAndCleanLocalStorage() {
  145. if (typeof localStorage === 'undefined') return;
  146. try {
  147. // Validate swimlane heights
  148. validateAndCleanKey('wekan-swimlane-heights', validateSwimlaneHeights);
  149. // Validate list widths
  150. validateAndCleanKey('wekan-list-widths', validateListWidths);
  151. // Validate list constraints
  152. validateAndCleanKey('wekan-list-constraints', validateListWidths);
  153. // Validate collapsed lists
  154. validateAndCleanKey('wekan-collapsed-lists', validateCollapsedStates);
  155. // Validate collapsed swimlanes
  156. validateAndCleanKey('wekan-collapsed-swimlanes', validateCollapsedStates);
  157. // Record last cleanup time
  158. localStorage.setItem('wekan-last-cleanup', Date.now().toString());
  159. } catch (e) {
  160. console.error('Error during localStorage validation:', e);
  161. }
  162. }
  163. /**
  164. * Check if cleanup is needed (once per day)
  165. */
  166. export function shouldRunCleanup() {
  167. if (typeof localStorage === 'undefined') return false;
  168. try {
  169. const lastCleanup = localStorage.getItem('wekan-last-cleanup');
  170. if (!lastCleanup) return true;
  171. const lastCleanupTime = parseInt(lastCleanup, 10);
  172. if (isNaN(lastCleanupTime)) return true;
  173. const timeSince = Date.now() - lastCleanupTime;
  174. // Run cleanup once per day
  175. return timeSince > 24 * 60 * 60 * 1000;
  176. } catch (e) {
  177. return true;
  178. }
  179. }
  180. /**
  181. * Get validated data from localStorage
  182. */
  183. export function getValidatedLocalStorageData(key, validator) {
  184. if (typeof localStorage === 'undefined') return {};
  185. try {
  186. const stored = localStorage.getItem(key);
  187. if (!stored) return {};
  188. const data = JSON.parse(stored);
  189. return validator(data);
  190. } catch (e) {
  191. console.warn(`Error reading localStorage key ${key}:`, e);
  192. return {};
  193. }
  194. }
  195. /**
  196. * Set validated data to localStorage
  197. */
  198. export function setValidatedLocalStorageData(key, data, validator) {
  199. if (typeof localStorage === 'undefined') return false;
  200. try {
  201. const validated = validator(data);
  202. localStorage.setItem(key, JSON.stringify(validated));
  203. return true;
  204. } catch (e) {
  205. console.error(`Error writing localStorage key ${key}:`, e);
  206. return false;
  207. }
  208. }
  209. // Export validators for use by other modules
  210. export const validators = {
  211. swimlaneHeights: validateSwimlaneHeights,
  212. listWidths: validateListWidths,
  213. collapsedStates: validateCollapsedStates,
  214. isValidNumber,
  215. isValidBoolean,
  216. };
  217. // Auto-cleanup on module load if needed
  218. if (Meteor.isClient) {
  219. Meteor.startup(() => {
  220. if (shouldRunCleanup()) {
  221. validateAndCleanLocalStorage();
  222. }
  223. });
  224. }