diff --git a/api/v2/controllers/DraftController.php b/api/v2/controllers/DraftController.php index e82be3fc4..c63b386f5 100644 --- a/api/v2/controllers/DraftController.php +++ b/api/v2/controllers/DraftController.php @@ -20,15 +20,29 @@ class DraftController private int $retentionDays; /** - * Creates the controller instance and ensures the storage directory exists. + * Creates the controller instance and resolves storage configuration. */ public function __construct() { $this->storageRoot = rtrim(getenv('ELMO_DRAFT_STORAGE') ?: (__DIR__ . '/../../../storage/drafts'), DIRECTORY_SEPARATOR); $this->retentionDays = (int) (getenv('ELMO_DRAFT_RETENTION_DAYS') ?: 30); + } + /** + * Ensures the storage root directory exists and is writable. + * + * @throws \RuntimeException When the directory cannot be created or is not writable. + */ + private function ensureStorageRoot(): void + { if (!is_dir($this->storageRoot)) { - mkdir($this->storageRoot, 0775, true); + // Suppress warning from race when another process creates the dir concurrently + if (!@mkdir($this->storageRoot, 0775, true) && !is_dir($this->storageRoot)) { + throw new \RuntimeException('DraftController: cannot create storage directory: ' . $this->storageRoot); + } + } + if (!is_writable($this->storageRoot)) { + throw new \RuntimeException('DraftController: storage directory is not writable: ' . $this->storageRoot); } } @@ -52,7 +66,14 @@ public function create(array $vars = [], ?array $body = null): void $draftId = bin2hex(random_bytes(16)); $record = $this->createRecord($draftId, $sessionId, $payload['payload']); - $this->persistRecord($record); + + try { + $this->persistRecord($record); + } catch (\RuntimeException $e) { + error_log($e->getMessage()); + $this->respond(500, ['error' => 'Failed to persist draft']); + return; + } $this->respond(201, $this->responseMetadata($record)); } @@ -97,7 +118,14 @@ public function update(array $vars = [], ?array $body = null): void $record['updatedAt'] = $this->now(); $record['checksum'] = $this->checksum($record['payload']); - $this->persistRecord($record); + try { + $this->persistRecord($record); + } catch (\RuntimeException $e) { + error_log($e->getMessage()); + $this->respond(500, ['error' => 'Failed to persist draft']); + return; + } + $this->respond(200, $this->responseMetadata($record)); } @@ -126,7 +154,7 @@ public function get(array $vars = [], ?array $body = null): void } if (!$record) { - $this->respond(404, ['error' => 'Draft not found']); + $this->respond(204, null); return; } @@ -287,12 +315,27 @@ private function createRecord(string $draftId, string $sessionId, array $payload */ private function persistRecord(array $record): void { + $this->ensureStorageRoot(); + $dir = $this->sessionDirectory($record['sessionId']); if (!is_dir($dir)) { - mkdir($dir, 0775, true); + if (!@mkdir($dir, 0775, true) && !is_dir($dir)) { + throw new \RuntimeException('DraftController: cannot create session directory: ' . $dir); + } } - file_put_contents($this->recordPath($record['sessionId'], $record['id']), json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $path = $this->recordPath($record['sessionId'], $record['id']); + + try { + $json = json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException('DraftController: failed to encode draft as JSON: ' . $e->getMessage()); + } + + $bytes = file_put_contents($path, $json); + if ($bytes === false) { + throw new \RuntimeException('DraftController: failed to write draft file: ' . $path); + } } /** diff --git a/api/v2/docs/swagger.yaml b/api/v2/docs/swagger.yaml index ea9633b1b..9b81c5ea7 100644 --- a/api/v2/docs/swagger.yaml +++ b/api/v2/docs/swagger.yaml @@ -1189,12 +1189,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - "404": - description: Draft not found for the current session - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + "204": + description: Draft not found for the current session (no body) "500": description: Internal server error content: diff --git a/doc/changelog.html b/doc/changelog.html index 992e64595..c601e173d 100644 --- a/doc/changelog.html +++ b/doc/changelog.html @@ -19,6 +19,11 @@

  • ORCID checksum validation using ISO 7064 Mod 11-2 algorithm.
  • +
  • Fixes: + +
  • diff --git a/js/descriptionTypes.js b/js/descriptionTypes.js index 050b6c1fa..462ec99f7 100644 --- a/js/descriptionTypes.js +++ b/js/descriptionTypes.js @@ -77,14 +77,24 @@ function initDescriptionTypes() { // Store active slugs globally for the help system window.ELMO_ACTIVE_DESCRIPTION_TYPES = activeSlugs; - // Re-apply translations to newly added elements - if (typeof window.applyTranslations === 'function') { - window.applyTranslations(); + // Re-apply translations to newly added elements. + // Wrapped in try/catch so the promise always resolves – translations + // will be re-applied when language.js finishes loading anyway. + try { + if (typeof window.applyTranslations === 'function') { + window.applyTranslations(); + } + } catch (_ignored) { + // Non-critical: translations will be applied later } // Sync help icon visibility with current help status - if (typeof updateHelpStatus === 'function') { - updateHelpStatus(); + try { + if (typeof updateHelpStatus === 'function') { + updateHelpStatus(); + } + } catch (_ignored) { + // Non-critical } resolve(activeSlugs); diff --git a/js/freekeywordTags.js b/js/freekeywordTags.js index caef82584..53ff3bfdb 100644 --- a/js/freekeywordTags.js +++ b/js/freekeywordTags.js @@ -146,7 +146,6 @@ document.addEventListener('DOMContentLoaded', function () { // Check if the array is empty if (data.length === 0) { - console.log("ELMO currently has no curated keywords."); return; } diff --git a/js/language.js b/js/language.js index a358becb0..ff869a076 100644 --- a/js/language.js +++ b/js/language.js @@ -69,6 +69,12 @@ function getNestedValue(obj, path) { * Applies the loaded translations to all UI elements */ function applyTranslations() { + // Guard: skip if translations have not been loaded yet (race condition + // when descriptionTypes.js AJAX resolves before the language file). + if (!translations || !translations.general) { + return; + } + // Set document title document.title = translations.general.logoTitle; diff --git a/js/services/autosaveService.js b/js/services/autosaveService.js index 9d3a0de70..50fb8c1b5 100644 --- a/js/services/autosaveService.js +++ b/js/services/autosaveService.js @@ -294,11 +294,24 @@ class AutosaveService { credentials: 'include' }); - if (response.status === 204 || response.status === 404) { + if (response.status === 204) { + // Clear stale draft ID so the next save creates a fresh draft + if (this.draftId) { + this.draftId = null; + this.removeStoredDraftId(); + } this.updateStatus('idle'); return; } + if (response.status === 404) { + // Since the API now returns 204 for "not found", a 404 always + // indicates a misconfigured route or unavailable endpoint. + const errorMessage = await this.extractErrorMessage(response); + this.updateStatus('error', errorMessage || 'Draft endpoint not found'); + return; + } + if (!response.ok) { const errorMessage = await this.extractErrorMessage(response); this.updateStatus('error', errorMessage || 'Unable to load autosaved draft'); diff --git a/modals.html b/modals.html index 13dcef650..4271f1e9b 100644 --- a/modals.html +++ b/modals.html @@ -1,11 +1,11 @@ -