getContent(); if ($fileContent === '') { $fileContent = '{"elements":[],"scrollToContent":true}'; } return json_decode($fileContent, true, 512, JSON_THROW_ON_ERROR); } /** * @throws NotPermittedException * @throws GenericFileException * @throws LockedException * @throws JsonException */ public function updateContent(File $file, array $data): void { $fileId = $file->getId(); $incoming = $this->normalizeIncomingData($data); if ($this->isEffectivelyEmptyPayload($incoming)) { $this->logger->debug('Skipping whiteboard save because payload is empty', [ 'app' => 'whiteboard', 'fileId' => $fileId, ]); return; } try { $current = $this->normalizeStoredData($this->getContent($file)); } catch (JsonException $e) { $this->logger->warning('Existing whiteboard content is invalid JSON, resetting to defaults', [ 'app' => 'whiteboard', 'fileId' => $fileId, 'error' => $e->getMessage(), ]); $current = $this->getEmptyState(); } $merged = $this->mergeData($current, $incoming); $canonicalCurrent = $this->canonicalize($current); $canonicalMerged = $this->canonicalize($merged); if ($canonicalCurrent === $canonicalMerged) { $this->logger->debug('Skipping whiteboard save because payload matches stored content', [ 'app' => 'whiteboard', 'fileId' => $fileId, ]); return; } try { $encodedPayload = json_encode($canonicalMerged, JSON_THROW_ON_ERROR); } catch (JsonException $e) { $this->logger->error('Failed to encode whiteboard content before saving', [ 'app' => 'whiteboard', 'fileId' => $fileId, 'error' => $e->getMessage(), ]); throw $e; } $maxRetries = 3; $baseDelay = 1000000; // 1 second for ($attempt = 0; $attempt < $maxRetries; $attempt++) { try { $file->putContent($encodedPayload); return; } catch (LockedException $e) { if ($attempt === $maxRetries - 1) { $this->logger->error('Whiteboard file write failed after retries', [ 'app' => 'whiteboard', 'fileId' => $fileId, 'error' => $e->getMessage(), ]); throw $e; } $delay = (int)($baseDelay * ((int)(2 ** $attempt))); $this->logger->warning('Whiteboard file locked, retrying', [ 'app' => 'whiteboard', 'fileId' => $fileId, 'attempt' => $attempt + 1, ]); usleep($delay); } } } /** * @return array */ private function getEmptyState(): array { return [ 'elements' => [], 'files' => [], 'scrollToContent' => true, ]; } /** * @param array $payload * * @return array */ private function unwrapData(array $payload): array { if (array_key_exists('data', $payload) && is_array($payload['data'])) { return $payload['data']; } return $payload; } /** * @param array $incoming * * @return array */ private function normalizeIncomingData(array $incoming): array { $incoming = $this->unwrapData($incoming); if (empty($incoming)) { return $this->getEmptyState(); } $normalized = []; if (array_key_exists('elements', $incoming) && is_array($incoming['elements'])) { $normalized['elements'] = $this->sanitizeElements($incoming['elements']); } if (array_key_exists('files', $incoming)) { $normalized['files'] = is_array($incoming['files']) ? $this->sanitizeFiles($incoming['files']) : []; } if (array_key_exists('appState', $incoming) && is_array($incoming['appState'])) { $normalized['appState'] = $this->sanitizeAppState($incoming['appState']); } if (array_key_exists('scrollToContent', $incoming)) { $normalized['scrollToContent'] = (bool)$incoming['scrollToContent']; } return $normalized; } /** * @param array $payload */ private function isEffectivelyEmptyPayload(array $payload): bool { $hasFiles = array_key_exists('files', $payload) && is_array($payload['files']) && !empty($payload['files']); if ($hasFiles) { return false; } $hasAppState = array_key_exists('appState', $payload) && is_array($payload['appState']) && !empty($payload['appState']); if ($hasAppState) { return false; } if (array_key_exists('scrollToContent', $payload) && $payload['scrollToContent'] !== true) { return false; } if (!array_key_exists('elements', $payload) || !is_array($payload['elements'])) { return false; } if (!empty($payload['elements'])) { return false; } foreach ($payload as $key => $_value) { if (!in_array($key, ['elements', 'files', 'appState', 'scrollToContent'], true)) { return false; } } return true; } /** * @param array $stored * * @return array * * @throws JsonException */ private function normalizeStoredData(array $stored): array { $stored = $this->unwrapData($stored); if (empty($stored)) { return $this->getEmptyState(); } $normalized = $this->getEmptyState(); if (array_key_exists('elements', $stored) && is_array($stored['elements'])) { $normalized['elements'] = $this->sanitizeElements($stored['elements']); } if (array_key_exists('files', $stored) && is_array($stored['files'])) { $normalized['files'] = $this->sanitizeFiles($stored['files']); } if (array_key_exists('appState', $stored) && is_array($stored['appState'])) { $normalized['appState'] = $this->sanitizeAppState($stored['appState']); } elseif (array_key_exists('appState', $stored) && $stored['appState'] === null) { unset($normalized['appState']); } if (array_key_exists('scrollToContent', $stored)) { $normalized['scrollToContent'] = (bool)$stored['scrollToContent']; } return $normalized; } /** * @param array $current * @param array $incoming * * @return array */ private function mergeData(array $current, array $incoming): array { $merged = $current; if (array_key_exists('elements', $incoming)) { $merged['elements'] = $incoming['elements']; } if (array_key_exists('files', $incoming)) { $merged['files'] = $incoming['files']; } if (array_key_exists('appState', $incoming)) { if ($incoming['appState'] === null) { unset($merged['appState']); } else { $merged['appState'] = $incoming['appState']; } } if (array_key_exists('scrollToContent', $incoming)) { $merged['scrollToContent'] = (bool)$incoming['scrollToContent']; } return $merged; } /** * @param array $data * * @return array */ private function sanitizeElements(array $data): array { $elements = []; foreach ($data as $element) { if (is_array($element)) { $elements[] = $element; } } return $elements; } /** * @param array $files * * @return array */ private function sanitizeFiles(array $files): array { $sanitized = []; foreach ($files as $key => $file) { if ($file === null) { continue; } if (is_array($file)) { $sanitized[$key] = $file; } } if (!empty($sanitized)) { ksort($sanitized); } return $sanitized; } /** * @param array $appState * * @return array */ private function sanitizeAppState(array $appState): array { unset($appState['collaborators'], $appState['selectedElementIds']); if (!empty($appState)) { ksort($appState); } return $appState; } /** * @param mixed $value * * @return mixed */ private function canonicalize($value) { if (is_array($value)) { if (!$this->isList($value)) { ksort($value); } foreach ($value as $key => $item) { $value[$key] = $this->canonicalize($item); } } return $value; } private function isList(array $array): bool { if (function_exists('array_is_list')) { return array_is_list($array); } $expectedKey = 0; foreach ($array as $key => $_) { if ($key !== $expectedKey) { return false; } $expectedKey++; } return true; } }