diff --git a/CHANGELOG.md b/CHANGELOG.md index 64de0b4..6e8373d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 4.8.1 + +### Fix: republish is now fault-isolated (KYTE-#181) + partial content saves no longer blank `block_layout` (KYTE-#189) + +**Republish resilience (KYTE-#181).** `ApplicationController`'s `republish_kyte_connect` hook re-stamps every `state=1` page across all of an app's sites. Previously it `throw`ew on the first page with missing `KytePageData`, **aborting the entire batch** — every later site/page kept the stale connect string, leaving an app half-migrated (e.g. a JWT dashboard with an HMAC login page). Now each page is re-stamped inside a `try/catch`: failures are collected (`page` id, `s3key`, `site`, `reason`) and logged, and the loop continues. The result is surfaced on the response as `republish_summary` (`succeeded` / `failed` / `failures[]`) so the caller can show a real outcome instead of trusting a silent all-or-nothing hook. Also fixed: CloudFront invalidation ran **once outside** the sites loop, so only the *last* site was invalidated — it now runs **per site**, so multi-site apps no longer leave other sites' caches stale. + +**`block_layout` preservation (KYTE-#189).** The KytePage update content-save unconditionally wrote `block_layout`, blanking it to empty when the field wasn't in the payload. A partial save — notably the Shipyard IDE, which sends only `html`/`stylesheet`/`javascript` — would therefore **wipe an existing block-editor layout**. The update now only overwrites `block_layout` when it's actually provided, preserving the stored value otherwise. (The IDE "save silently doesn't persist" half of #189 was already resolved by the v4.8.0 guard relaxation; this closes the data-loss edge.) + ## 4.8.0 ### Change: drop JavaScript obfuscation columns (Phase 2 — schema; resolves KYTE-#191 Phase 2) diff --git a/src/Mvc/Controller/ApplicationController.php b/src/Mvc/Controller/ApplicationController.php index 8ca3181..6bc311c 100644 --- a/src/Mvc/Controller/ApplicationController.php +++ b/src/Mvc/Controller/ApplicationController.php @@ -78,6 +78,11 @@ public function hook_response_data($method, $o, &$r = null, &$d = null) { if (isset($d['republish_kyte_connect']) && $d['republish_kyte_connect'] == 1) { $sites = new \Kyte\Core\Model(KyteSite); $sites->retrieve('application', $o->id); + + // Fault-isolated republish summary. A single bad page must NOT abort the + // whole batch and strand later pages on the stale connect string. See KYTE-#181. + $republishSummary = ['succeeded' => 0, 'failed' => 0, 'failures' => []]; + foreach($sites->objects as $site) { $credential = new \Kyte\Aws\Credentials($site->region, $o->aws_public_key, $o->aws_private_key); $s3 = new \Kyte\Aws\S3($credential, $site->s3BucketName); @@ -86,51 +91,78 @@ public function hook_response_data($method, $o, &$r = null, &$d = null) { $pages->retrieve("state", 1, false, [['field' => 'site', 'value' => $site->id]]); foreach($pages->objects as $page) { - $params = $this->getObject($page); - $pd = new \Kyte\Core\ModelObject(KytePageData); - if (!$pd->retrieve('page', $page->id)) { - throw new \Exception("CRITICAL ERROR: Unable to find page data."); + // Re-stamp each page independently; collect failures and continue + // instead of throwing and aborting the remaining pages/sites. + try { + $params = $this->getObject($page); + $pd = new \Kyte\Core\ModelObject(KytePageData); + if (!$pd->retrieve('page', $page->id)) { + throw new \Exception("Unable to find page data (KytePageData) for page {$page->id}."); + } + + $params['html'] = bzdecompress($pd->html); + $params['stylesheet'] = bzdecompress($pd->stylesheet); + $params['javascript'] = bzdecompress($pd->javascript); + // footers and headers + if ($params['footer'] && isset($params['footer']['html'], $params['footer']['stylesheet'], $params['footer']['javascript'], $params['footer']['block_layout'])) { + $params['footer']['html'] = bzdecompress($params['footer']['html']); + $params['footer']['stylesheet'] = bzdecompress($params['footer']['stylesheet']); + $params['footer']['javascript'] = bzdecompress($params['footer']['javascript']); + $params['footer']['block_layout'] = bzdecompress($params['footer']['block_layout']); + } + if ($params['header'] && isset($params['header']['html'], $params['header']['stylesheet'], $params['header']['javascript'], $params['header']['block_layout'])) { + $params['header']['html'] = bzdecompress($params['header']['html']); + $params['header']['stylesheet'] = bzdecompress($params['header']['stylesheet']); + $params['header']['javascript'] = bzdecompress($params['header']['javascript']); + $params['header']['block_layout'] = bzdecompress($params['header']['block_layout']); + } + // compile html file + $data = \Kyte\Mvc\Controller\KytePageController::createHtml($params); + // write to file + $s3->write($page->s3key, $data); + $republishSummary['succeeded']++; + } catch (\Throwable $e) { + $republishSummary['failed']++; + $republishSummary['failures'][] = [ + 'page' => $page->id, + 's3key' => $page->s3key, + 'site' => $site->id, + 'reason' => $e->getMessage(), + ]; + error_log("Republish: failed to re-stamp page {$page->id} (s3key {$page->s3key}, site {$site->id}): " . $e->getMessage()); + continue; } + } - $params['html'] = bzdecompress($pd->html); - $params['stylesheet'] = bzdecompress($pd->stylesheet); - $params['javascript'] = bzdecompress($pd->javascript); - // footers and headers - if ($params['footer'] && isset($params['footer']['html'], $params['footer']['stylesheet'], $params['footer']['javascript'], $params['footer']['block_layout'])) { - $params['footer']['html'] = bzdecompress($params['footer']['html']); - $params['footer']['stylesheet'] = bzdecompress($params['footer']['stylesheet']); - $params['footer']['javascript'] = bzdecompress($params['footer']['javascript']); - $params['footer']['block_layout'] = bzdecompress($params['footer']['block_layout']); + // Invalidate THIS site's CloudFront. Previously this ran once outside the + // sites loop, so only the last site was invalidated — multi-site apps left + // every other site's cache stale. See KYTE-#181. + try { + $invalidationPaths = ['/*']; + if (KYTE_USE_SNS) { + $snsCredential = new \Kyte\Aws\Credentials(SNS_REGION); + $sns = new \Kyte\Aws\Sns($snsCredential, SNS_QUEUE_SITE_MANAGEMENT); + $sns->publish([ + 'action' => 'cf_invalidate', + 'site_id' => $site->id, + 'cf_id' => $site->cfDistributionId, + 'cf_invalidation_paths' => $invalidationPaths, + 'caller_id' => time(), + ]); + } else { + $cf = new \Kyte\Aws\CloudFront($credential); + $cf->createInvalidation($site->cfDistributionId, $invalidationPaths); } - if ($params['header'] && isset($params['header']['html'], $params['header']['stylesheet'], $params['header']['javascript'], $params['header']['block_layout'])) { - $params['header']['html'] = bzdecompress($params['header']['html']); - $params['header']['stylesheet'] = bzdecompress($params['header']['stylesheet']); - $params['header']['javascript'] = bzdecompress($params['header']['javascript']); - $params['header']['block_layout'] = bzdecompress($params['header']['block_layout']); - } - // compile html file - $data = \Kyte\Mvc\Controller\KytePageController::createHtml($params); - // write to file - $s3->write($page->s3key, $data); + } catch (\Throwable $e) { + error_log("Republish: CloudFront invalidation failed for site {$site->id}: " . $e->getMessage()); } } - // invalidate CF - $invalidationPaths = ['/*']; - if (KYTE_USE_SNS) { - $credential = new \Kyte\Aws\Credentials(SNS_REGION); - $sns = new \Kyte\Aws\Sns($credential, SNS_QUEUE_SITE_MANAGEMENT); - $sns->publish([ - 'action' => 'cf_invalidate', - 'site_id' => $site->id, - 'cf_id' => $site->cfDistributionId, - 'cf_invalidation_paths' => $invalidationPaths, - 'caller_id' => time(), - ]); - } else { - // invalidate CF - $cf = new \Kyte\Aws\CloudFront($credential); - $cf->createInvalidation($site->cfDistributionId, $invalidationPaths); + // Surface the result so the caller (Shipyard) can show a real summary + // instead of trusting a silent all-or-nothing hook. + $r['republish_summary'] = $republishSummary; + if ($republishSummary['failed'] > 0) { + error_log("Republish completed with {$republishSummary['failed']} failure(s) of " . ($republishSummary['succeeded'] + $republishSummary['failed']) . " page(s): " . json_encode($republishSummary['failures'])); } } break; diff --git a/src/Mvc/Controller/KytePageController.php b/src/Mvc/Controller/KytePageController.php index 3c63e6d..4575385 100644 --- a/src/Mvc/Controller/KytePageController.php +++ b/src/Mvc/Controller/KytePageController.php @@ -158,23 +158,26 @@ public function hook_response_data($method, $o, &$r = null, &$d = null) { $params['javascript'] = $d['javascript']; // Update KytePageData - $bz_html = isset($d['html']) ? bzcompress($d['html'], 9) : ''; - $bz_stylesheet = isset($d['stylesheet']) ? bzcompress($d['stylesheet'], 9) : ''; - $bz_javascript = isset($d['javascript']) ? bzcompress($d['javascript'], 9) : ''; - $bz_block_layout = isset($d['block_layout']) ? bzcompress($d['block_layout'], 9) : ''; + $pageDataUpdate = [ + 'html' => isset($d['html']) ? bzcompress($d['html'], 9) : '', + 'stylesheet' => isset($d['stylesheet']) ? bzcompress($d['stylesheet'], 9) : '', + 'javascript' => isset($d['javascript']) ? bzcompress($d['javascript'], 9) : '', + 'modified_by' => (is_object($this->user) && isset($this->user->id)) + ? (int) $this->user->id + : ((is_numeric($this->user)) ? (int) $this->user : 0), + 'date_modified' => time(), + ]; + // Only overwrite block_layout when it's actually provided. A partial + // content save (e.g. the Shipyard IDE, which sends only + // html/stylesheet/javascript) must NOT blank an existing block-editor + // layout. See KYTE-#189. + if (isset($d['block_layout'])) { + $pageDataUpdate['block_layout'] = bzcompress($d['block_layout'], 9); + } $pd = new \Kyte\Core\ModelObject(KytePageData); if($pd->retrieve('page', $o->id)) { - $pd->save([ - 'html' => $bz_html, - 'stylesheet' => $bz_stylesheet, - 'javascript' => $bz_javascript, - 'block_layout' => $bz_block_layout, - 'modified_by' => (is_object($this->user) && isset($this->user->id)) - ? (int) $this->user->id - : ((is_numeric($this->user)) ? (int) $this->user : 0), - 'date_modified' => time(), - ]); + $pd->save($pageDataUpdate); } else { throw new \Exception("CRITICAL ERROR: Unable to find page data."); }