diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9b85888a..e7c65cee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,7 +10,6 @@ jobs: build: runs-on: ubuntu-latest env: - LIBRESIGN_PUBLISH_FOOTER_FRAGMENTS: 'true' WC_CONSUMER_KEY: ${{ secrets.WC_CONSUMER_KEY }} WC_CONSUMER_SECRET: ${{ secrets.WC_CONSUMER_SECRET }} steps: diff --git a/bootstrap.php b/bootstrap.php index f24cc496..9698dfe1 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -17,6 +17,4 @@ RemoveTranslationFiles::class, App\Listeners\GenerateSitemap::class, App\Listeners\CleanupCollectionDirectories::class, - App\Listeners\PushHeaderFragments::class, - App\Listeners\PushFooterFragments::class, ]); diff --git a/docker-compose.yml b/docker-compose.yml index ef7b94c3..ca07e010 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,11 +37,5 @@ services: - CAMPAIGN_ID=${CAMPAIGN_ID:-123} - URL_SUITECRM=${URL_SUITECRM:-123} - URL_SITE=${URL_SITE:-http://localhost} - - LIBRESIGN_PUBLISH_HEADER_FRAGMENTS=${LIBRESIGN_PUBLISH_HEADER_FRAGMENTS:-false} - - LIBRESIGN_HEADER_WEBHOOK_URL=${LIBRESIGN_HEADER_WEBHOOK_URL:-} - - LIBRESIGN_HEADER_WEBHOOK_SECRET=${LIBRESIGN_HEADER_WEBHOOK_SECRET:-} - - LIBRESIGN_PUBLISH_FOOTER_FRAGMENTS=${LIBRESIGN_PUBLISH_FOOTER_FRAGMENTS:-false} - - LIBRESIGN_FOOTER_WEBHOOK_URL=${LIBRESIGN_FOOTER_WEBHOOK_URL:-} - - LIBRESIGN_FOOTER_WEBHOOK_SECRET=${LIBRESIGN_FOOTER_WEBHOOK_SECRET:-} - WC_CONSUMER_KEY=${WC_CONSUMER_KEY:-} - WC_CONSUMER_SECRET=${WC_CONSUMER_SECRET:-} diff --git a/listeners/PushFooterFragments.php b/listeners/PushFooterFragments.php deleted file mode 100644 index 5b7c3287..00000000 --- a/listeners/PushFooterFragments.php +++ /dev/null @@ -1,256 +0,0 @@ -shouldPublish()) { - return; - } - - $webhookUrl = trim((string) getenv('LIBRESIGN_FOOTER_WEBHOOK_URL')); - $secret = trim((string) getenv('LIBRESIGN_FOOTER_WEBHOOK_SECRET')); - - if ($webhookUrl === '' || $secret === '') { - return; - } - - $destinationPath = $jigsaw->getDestinationPath(); - $manifest = $this->loadManifest($destinationPath . '/assets/build/manifest.json'); - if ($manifest === []) { - return; - } - - $cssArtifactPath = $this->resolveBuildArtifactPath($destinationPath, $manifest, 'source/_assets/scss/footer-fragment.scss'); - $jsArtifactPath = $this->resolveBuildArtifactPath($destinationPath, $manifest, 'source/_assets/js/footer-fragment.js'); - - if ($cssArtifactPath === '' || $jsArtifactPath === '') { - return; - } - - $fragments = $this->discoverFragmentFiles($destinationPath); - - foreach ($fragments as $fragmentFile) { - $payload = $this->buildPayload( - $fragmentFile, - $destinationPath, - $cssArtifactPath, - $jsArtifactPath - ); - - if ($payload === []) { - continue; - } - - $this->dispatch($webhookUrl, $secret, $payload); - } - } - - private function loadManifest(string $manifestPath): array - { - if (! is_file($manifestPath)) { - return []; - } - - $manifest = json_decode((string) file_get_contents($manifestPath), true); - return is_array($manifest) ? $manifest : []; - } - - private function resolveBuildArtifactPath(string $destinationPath, array $manifest, string $entry): string - { - $file = $manifest[$entry]['file'] ?? ''; - if (! is_string($file) || $file === '') { - return ''; - } - - return $destinationPath . '/assets/build/' . ltrim($file, '/'); - } - - private function discoverFragmentFiles(string $destinationPath): array - { - $files = []; - - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($destinationPath, RecursiveDirectoryIterator::SKIP_DOTS) - ); - - foreach ($iterator as $fileInfo) { - if (! $fileInfo->isFile()) { - continue; - } - - $pathname = str_replace('\\', '/', $fileInfo->getPathname()); - $relativePath = ltrim(str_replace(str_replace('\\', '/', $destinationPath), '', $pathname), '/'); - - if ( - str_starts_with($relativePath, 'fragments/') - && str_ends_with($relativePath, self::HTML_FRAGMENT_SUFFIX) - ) { - $files[] = $pathname; - } - } - - sort($files); - - return $files; - } - - private function buildPayload(string $fragmentFile, string $destinationPath, string $cssArtifactPath, string $jsArtifactPath): array - { - $html = (string) file_get_contents($fragmentFile); - $css = (string) file_get_contents($cssArtifactPath); - $js = (string) file_get_contents($jsArtifactPath); - - $html = preg_replace('/\s+data-fragment-css="[^"]+"/', '', $html) ?? $html; - $html = preg_replace('/\s+data-fragment-js="[^"]+"/', '', $html) ?? $html; - - $assets = []; - - $htmlAssetPaths = $this->extractHtmlAssetPaths($html); - $cssAssetPaths = $this->extractCssAssetPaths($css); - $assetPaths = array_values(array_unique(array_merge($htmlAssetPaths, $cssAssetPaths))); - - foreach ($assetPaths as $assetPath) { - $absolutePath = $destinationPath . '/' . ltrim($assetPath, '/'); - if (! is_file($absolutePath)) { - continue; - } - - $normalizedPath = $this->normalizeStoredAssetPath($assetPath); - $tokenizedUrl = self::ASSET_BASE_TOKEN . '/' . ltrim($normalizedPath, '/'); - - $html = str_replace($this->assetPublicUrlPatterns($assetPath), $tokenizedUrl, $html); - $css = str_replace($this->assetPublicUrlPatterns($assetPath), $tokenizedUrl, $css); - - $assets[] = [ - 'path' => $normalizedPath, - 'content_base64' => base64_encode((string) file_get_contents($absolutePath)), - 'mime' => mime_content_type($absolutePath) ?: 'application/octet-stream', - ]; - } - - $relativeFragmentPath = ltrim(str_replace(str_replace('\\', '/', $destinationPath), '', $fragmentFile), '/'); - $locale = $this->extractLocaleFromFragmentPath($relativeFragmentPath); - - return [ - 'locale' => $locale, - 'version' => hash('sha256', $html . "\n" . $css . "\n" . $js), - 'generated_at' => gmdate(DATE_ATOM), - 'html' => $html, - 'css' => $css, - 'js' => $js, - 'assets' => $assets, - ]; - } - - private function extractHtmlAssetPaths(string $html): array - { - preg_match_all('/(?:src)=\"([^\"]+)\"/i', $html, $matches); - return $this->normalizeAssetPaths($matches[1] ?? []); - } - - private function extractCssAssetPaths(string $css): array - { - preg_match_all('/url\(([^)]+)\)/i', $css, $matches); - return $this->normalizeAssetPaths($matches[1] ?? []); - } - - private function normalizeAssetPaths(array $values): array - { - $paths = []; - - foreach ($values as $value) { - $value = trim((string) $value, " \t\n\r\0\x0B'\""); - $path = parse_url($value, PHP_URL_PATH); - if (! is_string($path) || $path === '') { - continue; - } - - if (! str_starts_with($path, '/assets/')) { - continue; - } - - $paths[] = ltrim($path, '/'); - } - - return array_values(array_unique($paths)); - } - - private function normalizeStoredAssetPath(string $assetPath): string - { - return ltrim(preg_replace('#^assets/#', '', ltrim($assetPath, '/')) ?? ltrim($assetPath, '/'), '/'); - } - - private function assetPublicUrlPatterns(string $assetPath): array - { - $path = '/' . ltrim($assetPath, '/'); - - return [ - 'http://localhost:8081' . $path, - 'https://libresign.coop' . $path, - $path, - ]; - } - - private function dispatch(string $webhookUrl, string $secret, array $payload): void - { - $body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if (! is_string($body)) { - return; - } - - $timestamp = (string) time(); - $signature = hash_hmac('sha256', $timestamp . "\n" . $body, $secret); - - $context = stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => implode("\r\n", [ - 'Content-Type: application/json', - 'Content-Length: ' . strlen($body), - 'X-LibreSign-Timestamp: ' . $timestamp, - 'X-LibreSign-Signature: sha256=' . $signature, - ]), - 'content' => $body, - 'timeout' => 20, - 'ignore_errors' => true, - ], - ]); - - @file_get_contents($webhookUrl, false, $context); - } - - private function shouldPublish(): bool - { - return filter_var(getenv(self::PUBLISH_FLAG), FILTER_VALIDATE_BOOL) === true; - } - - private function extractLocaleFromFragmentPath(string $relativeFragmentPath): string - { - $normalized = trim(str_replace('\\', '/', $relativeFragmentPath), '/'); - - if (! str_starts_with($normalized, 'fragments/')) { - return ''; - } - - $suffix = '/footer/index.html'; - if (! str_ends_with($normalized, $suffix)) { - return ''; - } - - $candidate = substr($normalized, strlen('fragments/')); - $candidate = substr($candidate, 0, -strlen($suffix)); - - return trim((string) $candidate, '/'); - } -} diff --git a/listeners/PushHeaderFragments.php b/listeners/PushHeaderFragments.php deleted file mode 100644 index 8cc49618..00000000 --- a/listeners/PushHeaderFragments.php +++ /dev/null @@ -1,256 +0,0 @@ -shouldPublish()) { - return; - } - - $webhookUrl = trim((string) getenv('LIBRESIGN_HEADER_WEBHOOK_URL')); - $secret = trim((string) getenv('LIBRESIGN_HEADER_WEBHOOK_SECRET')); - - if ($webhookUrl === '' || $secret === '') { - return; - } - - $destinationPath = $jigsaw->getDestinationPath(); - $manifest = $this->loadManifest($destinationPath . '/assets/build/manifest.json'); - if ($manifest === []) { - return; - } - - $cssArtifactPath = $this->resolveBuildArtifactPath($destinationPath, $manifest, 'source/_assets/scss/header-fragment.scss'); - $jsArtifactPath = $this->resolveBuildArtifactPath($destinationPath, $manifest, 'source/_assets/js/header-fragment.js'); - - if ($cssArtifactPath === '' || $jsArtifactPath === '') { - return; - } - - $fragments = $this->discoverFragmentFiles($destinationPath); - - foreach ($fragments as $fragmentFile) { - $payload = $this->buildPayload( - $fragmentFile, - $destinationPath, - $cssArtifactPath, - $jsArtifactPath - ); - - if ($payload === []) { - continue; - } - - $this->dispatch($webhookUrl, $secret, $payload); - } - } - - private function loadManifest(string $manifestPath): array - { - if (! is_file($manifestPath)) { - return []; - } - - $manifest = json_decode((string) file_get_contents($manifestPath), true); - return is_array($manifest) ? $manifest : []; - } - - private function resolveBuildArtifactPath(string $destinationPath, array $manifest, string $entry): string - { - $file = $manifest[$entry]['file'] ?? ''; - if (! is_string($file) || $file === '') { - return ''; - } - - return $destinationPath . '/assets/build/' . ltrim($file, '/'); - } - - private function discoverFragmentFiles(string $destinationPath): array - { - $files = []; - - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($destinationPath, RecursiveDirectoryIterator::SKIP_DOTS) - ); - - foreach ($iterator as $fileInfo) { - if (! $fileInfo->isFile()) { - continue; - } - - $pathname = str_replace('\\', '/', $fileInfo->getPathname()); - $relativePath = ltrim(str_replace(str_replace('\\', '/', $destinationPath), '', $pathname), '/'); - - if ( - str_starts_with($relativePath, 'fragments/') - && str_ends_with($relativePath, self::HTML_FRAGMENT_SUFFIX) - ) { - $files[] = $pathname; - } - } - - sort($files); - - return $files; - } - - private function buildPayload(string $fragmentFile, string $destinationPath, string $cssArtifactPath, string $jsArtifactPath): array - { - $html = (string) file_get_contents($fragmentFile); - $css = (string) file_get_contents($cssArtifactPath); - $js = (string) file_get_contents($jsArtifactPath); - - $html = preg_replace('/\s+data-fragment-css="[^"]+"/', '', $html) ?? $html; - $html = preg_replace('/\s+data-fragment-js="[^"]+"/', '', $html) ?? $html; - - $assets = []; - - $htmlAssetPaths = $this->extractHtmlAssetPaths($html); - $cssAssetPaths = $this->extractCssAssetPaths($css); - $assetPaths = array_values(array_unique(array_merge($htmlAssetPaths, $cssAssetPaths))); - - foreach ($assetPaths as $assetPath) { - $absolutePath = $destinationPath . '/' . ltrim($assetPath, '/'); - if (! is_file($absolutePath)) { - continue; - } - - $normalizedPath = $this->normalizeStoredAssetPath($assetPath); - $tokenizedUrl = self::ASSET_BASE_TOKEN . '/' . ltrim($normalizedPath, '/'); - - $html = str_replace($this->assetPublicUrlPatterns($assetPath), $tokenizedUrl, $html); - $css = str_replace($this->assetPublicUrlPatterns($assetPath), $tokenizedUrl, $css); - - $assets[] = [ - 'path' => $normalizedPath, - 'content_base64' => base64_encode((string) file_get_contents($absolutePath)), - 'mime' => mime_content_type($absolutePath) ?: 'application/octet-stream', - ]; - } - - $relativeFragmentPath = ltrim(str_replace(str_replace('\\', '/', $destinationPath), '', $fragmentFile), '/'); - $locale = $this->extractLocaleFromFragmentPath($relativeFragmentPath); - - return [ - 'locale' => $locale, - 'version' => hash('sha256', $html . "\n" . $css . "\n" . $js), - 'generated_at' => gmdate(DATE_ATOM), - 'html' => $html, - 'css' => $css, - 'js' => $js, - 'assets' => $assets, - ]; - } - - private function extractHtmlAssetPaths(string $html): array - { - preg_match_all('/(?:src)=\"([^\"]+)\"/i', $html, $matches); - return $this->normalizeAssetPaths($matches[1] ?? []); - } - - private function extractCssAssetPaths(string $css): array - { - preg_match_all('/url\(([^)]+)\)/i', $css, $matches); - return $this->normalizeAssetPaths($matches[1] ?? []); - } - - private function normalizeAssetPaths(array $values): array - { - $paths = []; - - foreach ($values as $value) { - $value = trim((string) $value, " \t\n\r\0\x0B'\""); - $path = parse_url($value, PHP_URL_PATH); - if (! is_string($path) || $path === '') { - continue; - } - - if (! str_starts_with($path, '/assets/')) { - continue; - } - - $paths[] = ltrim($path, '/'); - } - - return array_values(array_unique($paths)); - } - - private function normalizeStoredAssetPath(string $assetPath): string - { - return ltrim(preg_replace('#^assets/#', '', ltrim($assetPath, '/')) ?? ltrim($assetPath, '/'), '/'); - } - - private function assetPublicUrlPatterns(string $assetPath): array - { - $path = '/' . ltrim($assetPath, '/'); - - return [ - 'http://localhost:8081' . $path, - 'https://libresign.coop' . $path, - $path, - ]; - } - - private function dispatch(string $webhookUrl, string $secret, array $payload): void - { - $body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if (! is_string($body)) { - return; - } - - $timestamp = (string) time(); - $signature = hash_hmac('sha256', $timestamp . "\n" . $body, $secret); - - $context = stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => implode("\r\n", [ - 'Content-Type: application/json', - 'Content-Length: ' . strlen($body), - 'X-LibreSign-Timestamp: ' . $timestamp, - 'X-LibreSign-Signature: sha256=' . $signature, - ]), - 'content' => $body, - 'timeout' => 20, - 'ignore_errors' => true, - ], - ]); - - @file_get_contents($webhookUrl, false, $context); - } - - private function shouldPublish(): bool - { - return filter_var(getenv(self::PUBLISH_FLAG), FILTER_VALIDATE_BOOL) === true; - } - - private function extractLocaleFromFragmentPath(string $relativeFragmentPath): string - { - $normalized = trim(str_replace('\\', '/', $relativeFragmentPath), '/'); - - if (! str_starts_with($normalized, 'fragments/')) { - return ''; - } - - $suffix = '/header/index.html'; - if (! str_ends_with($normalized, $suffix)) { - return ''; - } - - $candidate = substr($normalized, strlen('fragments/')); - $candidate = substr($candidate, 0, -strlen($suffix)); - - return trim((string) $candidate, '/'); - } -} diff --git a/tests/Unit/Listeners/PushFragmentsTest.php b/tests/Unit/Listeners/PushFragmentsTest.php deleted file mode 100644 index 046f3f8a..00000000 --- a/tests/Unit/Listeners/PushFragmentsTest.php +++ /dev/null @@ -1,347 +0,0 @@ -tempDir = sys_get_temp_dir() . '/libresign-push-fragments-' . bin2hex(random_bytes(8)); - mkdir($this->tempDir, 0777, true); - } - - protected function tearDown(): void - { - $this->deleteDirectory($this->tempDir); - - putenv('LIBRESIGN_PUBLISH_HEADER_FRAGMENTS'); - putenv('LIBRESIGN_PUBLISH_FOOTER_FRAGMENTS'); - - parent::tearDown(); - } - - #[DataProvider('listenerProvider')] - public function testShouldPublishDependsOnEnvironmentFlag(string $listenerClass, string $publishFlag): void - { - $listener = new $listenerClass(); - - putenv($publishFlag . '=false'); - self::assertFalse($this->invokePrivateMethod($listener, 'shouldPublish')); - - putenv($publishFlag . '=true'); - self::assertTrue($this->invokePrivateMethod($listener, 'shouldPublish')); - } - - #[DataProvider('listenerProvider')] - public function testLoadManifestReturnsDecodedArrayAndEmptyArrayWhenMissing(string $listenerClass): void - { - $listener = new $listenerClass(); - $manifestPath = $this->tempDir . '/manifest.json'; - - self::assertSame([], $this->invokePrivateMethod($listener, 'loadManifest', [$manifestPath])); - - file_put_contents($manifestPath, json_encode([ - 'source/_assets/scss/example.scss' => ['file' => 'assets/example.css'], - ], JSON_THROW_ON_ERROR)); - - self::assertSame( - ['source/_assets/scss/example.scss' => ['file' => 'assets/example.css']], - $this->invokePrivateMethod($listener, 'loadManifest', [$manifestPath]) - ); - } - - #[DataProvider('listenerProvider')] - public function testResolveBuildArtifactPathUsesManifestFile(string $listenerClass): void - { - $listener = new $listenerClass(); - $destinationPath = '/tmp/build'; - $manifest = [ - 'source/_assets/scss/example.scss' => ['file' => 'assets/example-123.css'], - ]; - - self::assertSame( - '/tmp/build/assets/build/assets/example-123.css', - $this->invokePrivateMethod($listener, 'resolveBuildArtifactPath', [ - $destinationPath, - $manifest, - 'source/_assets/scss/example.scss', - ]) - ); - - self::assertSame( - '', - $this->invokePrivateMethod($listener, 'resolveBuildArtifactPath', [ - $destinationPath, - $manifest, - 'source/_assets/scss/missing.scss', - ]) - ); - } - - #[DataProvider('fragmentStructureProvider')] - public function testDiscoverFragmentFilesReturnsSortedMatchingFragmentFilesOnly( - string $listenerClass, - string $fragmentName, - array $expectedRelativePaths - ): void { - $listener = new $listenerClass(); - $destinationPath = $this->tempDir . '/build'; - - $this->writeFile($destinationPath . '/fragments/' . $fragmentName . '/index.html', '