From 2256f469ad2a499b3a9b76bf347f393aa615e6c9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:28:13 -0300 Subject: [PATCH 1/3] refactor(deploy): move fragment sync to production workflow Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/deploy.yml | 42 +- README.md | 12 + bootstrap.php | 2 - docker-compose.yml | 6 - scripts/publish_fragments.php | 67 ++++ .../Fragments/FragmentWebhookPublisher.php | 369 ++++++++++++++++++ .../FragmentWebhookPublisherTest.php | 229 +++++++++++ 7 files changed, 718 insertions(+), 9 deletions(-) create mode 100644 scripts/publish_fragments.php create mode 100644 support/Fragments/FragmentWebhookPublisher.php create mode 100644 tests/Unit/Support/Fragments/FragmentWebhookPublisherTest.php diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9b85888a..288052df 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: @@ -31,3 +30,44 @@ jobs: build_dir: build_production fqdn: libresign.coop jekyll: false + + - name: Resolve merged PR context + id: merged-pr + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: context.sha, + }); + + const mergedPullRequest = data.find((pullRequest) => { + const baseRepo = pullRequest.base?.repo; + + return Boolean( + pullRequest.merged_at + && pullRequest.base?.ref === 'main' + && baseRepo?.owner?.login === owner + && baseRepo?.name === repo + ); + }); + + core.setOutput('merged', mergedPullRequest ? 'true' : 'false'); + core.setOutput('pr_number', mergedPullRequest ? String(mergedPullRequest.number) : ''); + core.setOutput('pr_head_ref', mergedPullRequest?.head?.ref ?? ''); + core.setOutput('pr_merged_at', mergedPullRequest?.merged_at ?? ''); + + - name: Publish production fragments + if: steps.merged-pr.outputs.merged == 'true' + env: + LIBRESIGN_HEADER_WEBHOOK_URL: ${{ secrets.LIBRESIGN_HEADER_WEBHOOK_URL }} + LIBRESIGN_HEADER_WEBHOOK_SECRET: ${{ secrets.LIBRESIGN_HEADER_WEBHOOK_SECRET }} + LIBRESIGN_FOOTER_WEBHOOK_URL: ${{ secrets.LIBRESIGN_FOOTER_WEBHOOK_URL }} + LIBRESIGN_FOOTER_WEBHOOK_SECRET: ${{ secrets.LIBRESIGN_FOOTER_WEBHOOK_SECRET }} + LIBRESIGN_MERGED_PR_NUMBER: ${{ steps.merged-pr.outputs.pr_number }} + LIBRESIGN_MERGED_PR_HEAD_REF: ${{ steps.merged-pr.outputs.pr_head_ref }} + LIBRESIGN_MERGED_PR_MERGED_AT: ${{ steps.merged-pr.outputs.pr_merged_at }} + run: php scripts/publish_fragments.php build_production diff --git a/README.md b/README.md index e0b88e08..f62a673c 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,15 @@ Help to translate the project on Weblate platform: https://hosted.weblate.org/pr - `lang/` is managed by Weblate and automation PRs. - Source strings are refreshed automatically by GitHub Actions daily at `02:00` (cron). - Normal builds must not modify `lang/`. + +## Production fragment sync + +- Header and footer fragments are **not** pushed during the Jigsaw build anymore. +- Fragment publication now runs only in the production deploy workflow, after the site build/deploy step. +- The workflow only publishes fragments when the deployed `main` commit is associated with a merged PR in `LibreSign/site`. +- Required GitHub secrets for production publication: + - `LIBRESIGN_HEADER_WEBHOOK_URL` + - `LIBRESIGN_HEADER_WEBHOOK_SECRET` + - `LIBRESIGN_FOOTER_WEBHOOK_URL` + - `LIBRESIGN_FOOTER_WEBHOOK_SECRET` +- The webhook consumer should validate both the HMAC headers (`X-LibreSign-Timestamp` and `X-LibreSign-Signature`) and the `deployment` payload fields such as repository, ref, event, and merged PR metadata. 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/scripts/publish_fragments.php b/scripts/publish_fragments.php new file mode 100644 index 00000000..e899b561 --- /dev/null +++ b/scripts/publish_fragments.php @@ -0,0 +1,67 @@ +#!/usr/bin/env php + 'github-actions', + 'repository' => getenv('GITHUB_REPOSITORY') ?: null, + 'ref' => getenv('GITHUB_REF') ?: null, + 'event' => getenv('GITHUB_EVENT_NAME') ?: null, + 'sha' => getenv('GITHUB_SHA') ?: null, + 'actor' => getenv('GITHUB_ACTOR') ?: null, + 'workflow' => getenv('GITHUB_WORKFLOW') ?: null, + 'run_id' => getenv('GITHUB_RUN_ID') ?: null, + 'run_attempt' => getenv('GITHUB_RUN_ATTEMPT') ?: null, + 'server_url' => getenv('GITHUB_SERVER_URL') ?: null, + 'environment' => 'production', + 'pull_request_number' => getenv('LIBRESIGN_MERGED_PR_NUMBER') ?: null, + 'pull_request_merged_at' => getenv('LIBRESIGN_MERGED_PR_MERGED_AT') ?: null, + 'pull_request_head_ref' => getenv('LIBRESIGN_MERGED_PR_HEAD_REF') ?: null, +], static fn (mixed $value): bool => $value !== null && $value !== ''); + +$webhooks = [ + 'header' => [ + 'url' => trim((string) getenv('LIBRESIGN_HEADER_WEBHOOK_URL')), + 'secret' => trim((string) getenv('LIBRESIGN_HEADER_WEBHOOK_SECRET')), + ], + 'footer' => [ + 'url' => trim((string) getenv('LIBRESIGN_FOOTER_WEBHOOK_URL')), + 'secret' => trim((string) getenv('LIBRESIGN_FOOTER_WEBHOOK_SECRET')), + ], +]; + +$totalPublished = 0; + +foreach ($webhooks as $fragmentType => $webhook) { + if ($webhook['url'] === '' || $webhook['secret'] === '') { + fwrite(STDOUT, sprintf("Skipping %s fragments: webhook URL/secret not configured.\n", $fragmentType)); + continue; + } + + $published = $publisher->publish( + $fragmentType, + $destinationPath, + $webhook['url'], + $webhook['secret'], + $deployment, + ); + + fwrite(STDOUT, sprintf("Published %d %s fragment payload(s).\n", $published, $fragmentType)); + $totalPublished += $published; +} + +fwrite(STDOUT, sprintf("Fragment webhook sync complete. Published %d payload(s).\n", $totalPublished)); diff --git a/support/Fragments/FragmentWebhookPublisher.php b/support/Fragments/FragmentWebhookPublisher.php new file mode 100644 index 00000000..53d93223 --- /dev/null +++ b/support/Fragments/FragmentWebhookPublisher.php @@ -0,0 +1,369 @@ + + */ + private const FRAGMENT_CONFIG = [ + 'header' => [ + 'html_fragment_suffix' => '/header/index.html', + 'asset_base_token' => '__LIBRESIGN_HEADER_ASSET_BASE_URL__', + 'css_entry' => 'source/_assets/scss/header-fragment.scss', + 'js_entry' => 'source/_assets/js/header-fragment.js', + ], + 'footer' => [ + 'html_fragment_suffix' => '/footer/index.html', + 'asset_base_token' => '__LIBRESIGN_FOOTER_ASSET_BASE_URL__', + 'css_entry' => 'source/_assets/scss/footer-fragment.scss', + 'js_entry' => 'source/_assets/js/footer-fragment.js', + ], + ]; + + /** + * @param array $deployment + */ + public function publish( + string $fragmentType, + string $destinationPath, + string $webhookUrl, + string $secret, + array $deployment = [], + ): int { + $webhookUrl = trim($webhookUrl); + $secret = trim($secret); + + if ($webhookUrl === '' || $secret === '') { + return 0; + } + + $published = 0; + foreach ($this->buildPayloads($fragmentType, $destinationPath, $deployment) as $payload) { + $this->dispatch($webhookUrl, $secret, $payload); + $published++; + } + + return $published; + } + + /** + * @param array $deployment + * @return list> + */ + public function buildPayloads(string $fragmentType, string $destinationPath, array $deployment = []): array + { + $config = self::FRAGMENT_CONFIG[$fragmentType] ?? null; + if (! is_array($config)) { + return []; + } + + $manifest = $this->loadManifest($destinationPath . '/assets/build/manifest.json'); + if ($manifest === []) { + return []; + } + + $cssArtifactPath = $this->resolveBuildArtifactPath($destinationPath, $manifest, $config['css_entry']); + $jsArtifactPath = $this->resolveBuildArtifactPath($destinationPath, $manifest, $config['js_entry']); + + if ($cssArtifactPath === '' || $jsArtifactPath === '') { + return []; + } + + $payloads = []; + + foreach ($this->discoverFragmentFiles($destinationPath, $config['html_fragment_suffix']) as $fragmentFile) { + $payload = $this->buildPayload( + $fragmentType, + $fragmentFile, + $destinationPath, + $cssArtifactPath, + $jsArtifactPath, + $config['asset_base_token'], + $deployment, + ); + + if ($payload !== []) { + $payloads[] = $payload; + } + } + + return $payloads; + } + + /** + * @return array + */ + 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 : []; + } + + /** + * @param array $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, '/'); + } + + /** + * @return list + */ + private function discoverFragmentFiles(string $destinationPath, string $htmlFragmentSuffix): 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, $htmlFragmentSuffix) + ) { + $files[] = $pathname; + } + } + + sort($files); + + return $files; + } + + /** + * @param array $deployment + * @return array + */ + private function buildPayload( + string $fragmentType, + string $fragmentFile, + string $destinationPath, + string $cssArtifactPath, + string $jsArtifactPath, + string $assetBaseToken, + array $deployment = [], + ): 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 = $assetBaseToken . '/' . 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), '', str_replace('\\', '/', $fragmentFile)), '/'); + $locale = $this->extractLocaleFromFragmentPath($relativeFragmentPath, $fragmentType); + + $payload = [ + 'fragment_type' => $fragmentType, + 'locale' => $locale, + 'version' => hash('sha256', $html . "\n" . $css . "\n" . $js), + 'generated_at' => gmdate(DATE_ATOM), + 'html' => $html, + 'css' => $css, + 'js' => $js, + 'assets' => $assets, + ]; + + if ($deployment !== []) { + $payload['deployment'] = $deployment; + } + + return $payload; + } + + /** + * @return list + */ + private function extractHtmlAssetPaths(string $html): array + { + preg_match_all('/(?:src)="([^"]+)"/i', $html, $matches); + + return $this->normalizeAssetPaths($matches[1] ?? []); + } + + /** + * @return list + */ + private function extractCssAssetPaths(string $css): array + { + preg_match_all('/url\(([^)]+)\)/i', $css, $matches); + + return $this->normalizeAssetPaths($matches[1] ?? []); + } + + /** + * @param array $values + * @return list + */ + 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, '/'), + '/' + ); + } + + /** + * @return list + */ + private function assetPublicUrlPatterns(string $assetPath): array + { + $path = '/' . ltrim($assetPath, '/'); + + return [ + 'http://localhost:8081' . $path, + 'https://libresign.coop' . $path, + $path, + ]; + } + + /** + * @param array $payload + */ + protected function dispatch(string $webhookUrl, string $secret, array $payload): void + { + $body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (! is_string($body)) { + throw new RuntimeException('Unable to encode fragment webhook payload.'); + } + + $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, + ], + ]); + + $result = @file_get_contents($webhookUrl, false, $context); + $headers = $http_response_header ?? []; + $statusCode = $this->extractStatusCode($headers); + + if ($result === false || $statusCode < 200 || $statusCode >= 300) { + throw new RuntimeException(sprintf( + 'Fragment webhook dispatch failed for %s with status %s.', + $webhookUrl, + $statusCode > 0 ? (string) $statusCode : 'unknown' + )); + } + } + + /** + * @param array $headers + */ + private function extractStatusCode(array $headers): int + { + $statusLine = $headers[0] ?? ''; + if (! is_string($statusLine)) { + return 0; + } + + if (preg_match('/\s(\d{3})\s/', $statusLine, $matches) !== 1) { + return 0; + } + + return (int) $matches[1]; + } + + private function extractLocaleFromFragmentPath(string $relativeFragmentPath, string $fragmentType): string + { + $normalized = trim(str_replace('\\', '/', $relativeFragmentPath), '/'); + + if (! str_starts_with($normalized, 'fragments/')) { + return ''; + } + + $suffix = '/' . $fragmentType . '/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/Support/Fragments/FragmentWebhookPublisherTest.php b/tests/Unit/Support/Fragments/FragmentWebhookPublisherTest.php new file mode 100644 index 00000000..af34ab28 --- /dev/null +++ b/tests/Unit/Support/Fragments/FragmentWebhookPublisherTest.php @@ -0,0 +1,229 @@ +tempDir = sys_get_temp_dir() . '/libresign-fragment-webhook-' . bin2hex(random_bytes(8)); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + $this->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + public function testBuildPayloadsReturnsEmptyArrayWhenManifestIsMissing(): void + { + $publisher = new FragmentWebhookPublisher(); + + self::assertSame([], $publisher->buildPayloads('header', $this->tempDir)); + } + + public function testBuildPayloadsBuildsSortedHeaderPayloadsWithDeploymentMetadata(): void + { + $publisher = new FragmentWebhookPublisher(); + $destinationPath = $this->tempDir . '/site-output'; + + $this->writeManifest($destinationPath, [ + 'source/_assets/scss/header-fragment.scss' => ['file' => 'assets/header-fragment.css'], + 'source/_assets/js/header-fragment.js' => ['file' => 'assets/header-fragment.js'], + ]); + + $this->writeFile( + $destinationPath . '/fragments/header/index.html', + $this->fragmentHtml('http://localhost:8081/assets/images/logo.svg') + ); + $this->writeFile( + $destinationPath . '/fragments/fr/header/index.html', + $this->fragmentHtml('/assets/images/logo.svg') + ); + $this->writeFile( + $destinationPath . '/fragments/pt-BR/header/index.html', + $this->fragmentHtml('https://libresign.coop/assets/images/logo.svg') + ); + $this->writeFile( + $destinationPath . '/fragments/pt-BR/footer/index.html', + '
ignore
' + ); + + $this->writeFile( + $destinationPath . '/assets/build/assets/header-fragment.css', + ".fragment-root { background-image: url('/assets/images/logo.svg'); src: url(\"/assets/build/assets/font.woff2\") format('woff2'); }" + ); + $this->writeFile( + $destinationPath . '/assets/build/assets/header-fragment.js', + 'console.log("header");' + ); + $this->writeFile($destinationPath . '/assets/images/logo.svg', ''); + $this->writeFile($destinationPath . '/assets/build/assets/font.woff2', 'fake-font'); + + $deployment = [ + 'source' => 'github-actions', + 'repository' => 'LibreSign/site', + 'ref' => 'refs/heads/main', + 'event' => 'push', + 'sha' => 'abc123', + 'environment' => 'production', + ]; + + $payloads = $publisher->buildPayloads('header', $destinationPath, $deployment); + + self::assertCount(3, $payloads); + self::assertSame(['fr', '', 'pt-BR'], array_column($payloads, 'locale')); + + $firstPayload = $payloads[0]; + self::assertSame('header', $firstPayload['fragment_type']); + self::assertSame($deployment, $firstPayload['deployment']); + self::assertStringNotContainsString('data-fragment-css=', $firstPayload['html']); + self::assertStringNotContainsString('data-fragment-js=', $firstPayload['html']); + self::assertStringContainsString('__LIBRESIGN_HEADER_ASSET_BASE_URL__/images/logo.svg', $firstPayload['html']); + self::assertStringContainsString('__LIBRESIGN_HEADER_ASSET_BASE_URL__/images/logo.svg', $firstPayload['css']); + self::assertStringContainsString('__LIBRESIGN_HEADER_ASSET_BASE_URL__/build/assets/font.woff2', $firstPayload['css']); + self::assertSame('console.log("header");', $firstPayload['js']); + self::assertArrayHasKey('generated_at', $firstPayload); + self::assertSame( + hash('sha256', $firstPayload['html'] . "\n" . $firstPayload['css'] . "\n" . $firstPayload['js']), + $firstPayload['version'] + ); + + self::assertCount(2, $firstPayload['assets']); + self::assertSame('images/logo.svg', $firstPayload['assets'][0]['path']); + self::assertSame(base64_encode(''), $firstPayload['assets'][0]['content_base64']); + self::assertSame('build/assets/font.woff2', $firstPayload['assets'][1]['path']); + } + + public function testPublishDispatchesOneWebhookPerFooterFragment(): void + { + $publisher = new RecordingFragmentWebhookPublisher(); + $destinationPath = $this->tempDir . '/site-output'; + + $this->writeManifest($destinationPath, [ + 'source/_assets/scss/footer-fragment.scss' => ['file' => 'assets/footer-fragment.css'], + 'source/_assets/js/footer-fragment.js' => ['file' => 'assets/footer-fragment.js'], + ]); + + $this->writeFile( + $destinationPath . '/fragments/footer/index.html', + $this->fragmentHtml('/assets/images/logo.svg') + ); + $this->writeFile( + $destinationPath . '/fragments/pt/footer/index.html', + $this->fragmentHtml('/assets/images/logo.svg') + ); + $this->writeFile( + $destinationPath . '/assets/build/assets/footer-fragment.css', + ".fragment-root { background-image: url('/assets/images/logo.svg'); }" + ); + $this->writeFile( + $destinationPath . '/assets/build/assets/footer-fragment.js', + 'console.log("footer");' + ); + $this->writeFile($destinationPath . '/assets/images/logo.svg', ''); + + $count = $publisher->publish( + 'footer', + $destinationPath, + 'https://example.test/footer-webhook', + 'super-secret', + ['repository' => 'LibreSign/site', 'ref' => 'refs/heads/main'] + ); + + self::assertSame(2, $count); + self::assertCount(2, $publisher->dispatches); + self::assertSame('https://example.test/footer-webhook', $publisher->dispatches[0]['webhookUrl']); + self::assertSame('super-secret', $publisher->dispatches[0]['secret']); + self::assertSame('footer', $publisher->dispatches[0]['payload']['fragment_type']); + self::assertCount(1, $publisher->dispatches[0]['payload']['assets']); + self::assertSame('images/logo.svg', $publisher->dispatches[0]['payload']['assets'][0]['path']); + self::assertSame('pt', $publisher->dispatches[1]['payload']['locale']); + } + + private function fragmentHtml(string $assetUrl): string + { + return << + Logo + +HTML; + } + + /** + * @param array> $manifest + */ + private function writeManifest(string $destinationPath, array $manifest): void + { + $this->writeFile( + $destinationPath . '/assets/build/manifest.json', + json_encode($manifest, JSON_THROW_ON_ERROR) + ); + } + + private function writeFile(string $path, string $contents): void + { + $directory = dirname($path); + if (! is_dir($directory)) { + mkdir($directory, 0777, true); + } + + file_put_contents($path, $contents); + } + + private function deleteDirectory(string $path): void + { + if (! is_dir($path)) { + return; + } + + $items = scandir($path); + if ($items === false) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $fullPath = $path . '/' . $item; + if (is_dir($fullPath)) { + $this->deleteDirectory($fullPath); + continue; + } + + unlink($fullPath); + } + + rmdir($path); + } +} + +final class RecordingFragmentWebhookPublisher extends FragmentWebhookPublisher +{ + /** + * @var list}> + */ + public array $dispatches = []; + + protected function dispatch(string $webhookUrl, string $secret, array $payload): void + { + $this->dispatches[] = [ + 'webhookUrl' => $webhookUrl, + 'secret' => $secret, + 'payload' => $payload, + ]; + } +} From 65154d746206ba1f94e6c67d76383cfbba8dd8d8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:31:03 -0300 Subject: [PATCH 2/3] refactor(deploy): reduce fragment secret surface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/deploy.yml | 7 +++---- README.md | 7 ++----- scripts/publish_fragments.php | 9 ++++----- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 288052df..e13ba8d2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -63,10 +63,9 @@ jobs: - name: Publish production fragments if: steps.merged-pr.outputs.merged == 'true' env: - LIBRESIGN_HEADER_WEBHOOK_URL: ${{ secrets.LIBRESIGN_HEADER_WEBHOOK_URL }} - LIBRESIGN_HEADER_WEBHOOK_SECRET: ${{ secrets.LIBRESIGN_HEADER_WEBHOOK_SECRET }} - LIBRESIGN_FOOTER_WEBHOOK_URL: ${{ secrets.LIBRESIGN_FOOTER_WEBHOOK_URL }} - LIBRESIGN_FOOTER_WEBHOOK_SECRET: ${{ secrets.LIBRESIGN_FOOTER_WEBHOOK_SECRET }} + LIBRESIGN_HEADER_WEBHOOK_URL: ${{ vars.LIBRESIGN_HEADER_WEBHOOK_URL }} + LIBRESIGN_FOOTER_WEBHOOK_URL: ${{ vars.LIBRESIGN_FOOTER_WEBHOOK_URL }} + LIBRESIGN_FRAGMENT_WEBHOOK_SECRET: ${{ secrets.LIBRESIGN_FRAGMENT_WEBHOOK_SECRET }} LIBRESIGN_MERGED_PR_NUMBER: ${{ steps.merged-pr.outputs.pr_number }} LIBRESIGN_MERGED_PR_HEAD_REF: ${{ steps.merged-pr.outputs.pr_head_ref }} LIBRESIGN_MERGED_PR_MERGED_AT: ${{ steps.merged-pr.outputs.pr_merged_at }} diff --git a/README.md b/README.md index f62a673c..6965fafc 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,6 @@ Help to translate the project on Weblate platform: https://hosted.weblate.org/pr - Header and footer fragments are **not** pushed during the Jigsaw build anymore. - Fragment publication now runs only in the production deploy workflow, after the site build/deploy step. - The workflow only publishes fragments when the deployed `main` commit is associated with a merged PR in `LibreSign/site`. -- Required GitHub secrets for production publication: - - `LIBRESIGN_HEADER_WEBHOOK_URL` - - `LIBRESIGN_HEADER_WEBHOOK_SECRET` - - `LIBRESIGN_FOOTER_WEBHOOK_URL` - - `LIBRESIGN_FOOTER_WEBHOOK_SECRET` +- The only sensitive repository configuration for production publication is the shared HMAC secret: `LIBRESIGN_FRAGMENT_WEBHOOK_SECRET`. +- Webhook URLs are regular repository configuration (`LIBRESIGN_HEADER_WEBHOOK_URL` and `LIBRESIGN_FOOTER_WEBHOOK_URL`), not GitHub secrets. - The webhook consumer should validate both the HMAC headers (`X-LibreSign-Timestamp` and `X-LibreSign-Signature`) and the `deployment` payload fields such as repository, ref, event, and merged PR metadata. diff --git a/scripts/publish_fragments.php b/scripts/publish_fragments.php index e899b561..9587767a 100644 --- a/scripts/publish_fragments.php +++ b/scripts/publish_fragments.php @@ -16,6 +16,7 @@ } $publisher = new FragmentWebhookPublisher(); +$secret = trim((string) getenv('LIBRESIGN_FRAGMENT_WEBHOOK_SECRET')); $deployment = array_filter([ 'source' => 'github-actions', 'repository' => getenv('GITHUB_REPOSITORY') ?: null, @@ -36,19 +37,17 @@ $webhooks = [ 'header' => [ 'url' => trim((string) getenv('LIBRESIGN_HEADER_WEBHOOK_URL')), - 'secret' => trim((string) getenv('LIBRESIGN_HEADER_WEBHOOK_SECRET')), ], 'footer' => [ 'url' => trim((string) getenv('LIBRESIGN_FOOTER_WEBHOOK_URL')), - 'secret' => trim((string) getenv('LIBRESIGN_FOOTER_WEBHOOK_SECRET')), ], ]; $totalPublished = 0; foreach ($webhooks as $fragmentType => $webhook) { - if ($webhook['url'] === '' || $webhook['secret'] === '') { - fwrite(STDOUT, sprintf("Skipping %s fragments: webhook URL/secret not configured.\n", $fragmentType)); + if ($webhook['url'] === '' || $secret === '') { + fwrite(STDOUT, sprintf("Skipping %s fragments: webhook URL or shared secret not configured.\n", $fragmentType)); continue; } @@ -56,7 +55,7 @@ $fragmentType, $destinationPath, $webhook['url'], - $webhook['secret'], + $secret, $deployment, ); From 5eea6f92e5c8fc068c32eacd3e693016d73a9527 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:05:21 -0300 Subject: [PATCH 3/3] refactor: remove obsolete site fragment publishing Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/deploy.yml | 40 -- README.md | 9 - listeners/PushFooterFragments.php | 256 ------------ listeners/PushHeaderFragments.php | 256 ------------ scripts/publish_fragments.php | 66 ---- .../Fragments/FragmentWebhookPublisher.php | 369 ------------------ tests/Unit/Listeners/PushFragmentsTest.php | 347 ---------------- .../FragmentWebhookPublisherTest.php | 229 ----------- 8 files changed, 1572 deletions(-) delete mode 100644 listeners/PushFooterFragments.php delete mode 100644 listeners/PushHeaderFragments.php delete mode 100644 scripts/publish_fragments.php delete mode 100644 support/Fragments/FragmentWebhookPublisher.php delete mode 100644 tests/Unit/Listeners/PushFragmentsTest.php delete mode 100644 tests/Unit/Support/Fragments/FragmentWebhookPublisherTest.php diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e13ba8d2..e7c65cee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,43 +30,3 @@ jobs: build_dir: build_production fqdn: libresign.coop jekyll: false - - - name: Resolve merged PR context - id: merged-pr - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { owner, repo } = context.repo; - const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner, - repo, - commit_sha: context.sha, - }); - - const mergedPullRequest = data.find((pullRequest) => { - const baseRepo = pullRequest.base?.repo; - - return Boolean( - pullRequest.merged_at - && pullRequest.base?.ref === 'main' - && baseRepo?.owner?.login === owner - && baseRepo?.name === repo - ); - }); - - core.setOutput('merged', mergedPullRequest ? 'true' : 'false'); - core.setOutput('pr_number', mergedPullRequest ? String(mergedPullRequest.number) : ''); - core.setOutput('pr_head_ref', mergedPullRequest?.head?.ref ?? ''); - core.setOutput('pr_merged_at', mergedPullRequest?.merged_at ?? ''); - - - name: Publish production fragments - if: steps.merged-pr.outputs.merged == 'true' - env: - LIBRESIGN_HEADER_WEBHOOK_URL: ${{ vars.LIBRESIGN_HEADER_WEBHOOK_URL }} - LIBRESIGN_FOOTER_WEBHOOK_URL: ${{ vars.LIBRESIGN_FOOTER_WEBHOOK_URL }} - LIBRESIGN_FRAGMENT_WEBHOOK_SECRET: ${{ secrets.LIBRESIGN_FRAGMENT_WEBHOOK_SECRET }} - LIBRESIGN_MERGED_PR_NUMBER: ${{ steps.merged-pr.outputs.pr_number }} - LIBRESIGN_MERGED_PR_HEAD_REF: ${{ steps.merged-pr.outputs.pr_head_ref }} - LIBRESIGN_MERGED_PR_MERGED_AT: ${{ steps.merged-pr.outputs.pr_merged_at }} - run: php scripts/publish_fragments.php build_production diff --git a/README.md b/README.md index 6965fafc..e0b88e08 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,3 @@ Help to translate the project on Weblate platform: https://hosted.weblate.org/pr - `lang/` is managed by Weblate and automation PRs. - Source strings are refreshed automatically by GitHub Actions daily at `02:00` (cron). - Normal builds must not modify `lang/`. - -## Production fragment sync - -- Header and footer fragments are **not** pushed during the Jigsaw build anymore. -- Fragment publication now runs only in the production deploy workflow, after the site build/deploy step. -- The workflow only publishes fragments when the deployed `main` commit is associated with a merged PR in `LibreSign/site`. -- The only sensitive repository configuration for production publication is the shared HMAC secret: `LIBRESIGN_FRAGMENT_WEBHOOK_SECRET`. -- Webhook URLs are regular repository configuration (`LIBRESIGN_HEADER_WEBHOOK_URL` and `LIBRESIGN_FOOTER_WEBHOOK_URL`), not GitHub secrets. -- The webhook consumer should validate both the HMAC headers (`X-LibreSign-Timestamp` and `X-LibreSign-Signature`) and the `deployment` payload fields such as repository, ref, event, and merged PR metadata. 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/scripts/publish_fragments.php b/scripts/publish_fragments.php deleted file mode 100644 index 9587767a..00000000 --- a/scripts/publish_fragments.php +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env php - 'github-actions', - 'repository' => getenv('GITHUB_REPOSITORY') ?: null, - 'ref' => getenv('GITHUB_REF') ?: null, - 'event' => getenv('GITHUB_EVENT_NAME') ?: null, - 'sha' => getenv('GITHUB_SHA') ?: null, - 'actor' => getenv('GITHUB_ACTOR') ?: null, - 'workflow' => getenv('GITHUB_WORKFLOW') ?: null, - 'run_id' => getenv('GITHUB_RUN_ID') ?: null, - 'run_attempt' => getenv('GITHUB_RUN_ATTEMPT') ?: null, - 'server_url' => getenv('GITHUB_SERVER_URL') ?: null, - 'environment' => 'production', - 'pull_request_number' => getenv('LIBRESIGN_MERGED_PR_NUMBER') ?: null, - 'pull_request_merged_at' => getenv('LIBRESIGN_MERGED_PR_MERGED_AT') ?: null, - 'pull_request_head_ref' => getenv('LIBRESIGN_MERGED_PR_HEAD_REF') ?: null, -], static fn (mixed $value): bool => $value !== null && $value !== ''); - -$webhooks = [ - 'header' => [ - 'url' => trim((string) getenv('LIBRESIGN_HEADER_WEBHOOK_URL')), - ], - 'footer' => [ - 'url' => trim((string) getenv('LIBRESIGN_FOOTER_WEBHOOK_URL')), - ], -]; - -$totalPublished = 0; - -foreach ($webhooks as $fragmentType => $webhook) { - if ($webhook['url'] === '' || $secret === '') { - fwrite(STDOUT, sprintf("Skipping %s fragments: webhook URL or shared secret not configured.\n", $fragmentType)); - continue; - } - - $published = $publisher->publish( - $fragmentType, - $destinationPath, - $webhook['url'], - $secret, - $deployment, - ); - - fwrite(STDOUT, sprintf("Published %d %s fragment payload(s).\n", $published, $fragmentType)); - $totalPublished += $published; -} - -fwrite(STDOUT, sprintf("Fragment webhook sync complete. Published %d payload(s).\n", $totalPublished)); diff --git a/support/Fragments/FragmentWebhookPublisher.php b/support/Fragments/FragmentWebhookPublisher.php deleted file mode 100644 index 53d93223..00000000 --- a/support/Fragments/FragmentWebhookPublisher.php +++ /dev/null @@ -1,369 +0,0 @@ - - */ - private const FRAGMENT_CONFIG = [ - 'header' => [ - 'html_fragment_suffix' => '/header/index.html', - 'asset_base_token' => '__LIBRESIGN_HEADER_ASSET_BASE_URL__', - 'css_entry' => 'source/_assets/scss/header-fragment.scss', - 'js_entry' => 'source/_assets/js/header-fragment.js', - ], - 'footer' => [ - 'html_fragment_suffix' => '/footer/index.html', - 'asset_base_token' => '__LIBRESIGN_FOOTER_ASSET_BASE_URL__', - 'css_entry' => 'source/_assets/scss/footer-fragment.scss', - 'js_entry' => 'source/_assets/js/footer-fragment.js', - ], - ]; - - /** - * @param array $deployment - */ - public function publish( - string $fragmentType, - string $destinationPath, - string $webhookUrl, - string $secret, - array $deployment = [], - ): int { - $webhookUrl = trim($webhookUrl); - $secret = trim($secret); - - if ($webhookUrl === '' || $secret === '') { - return 0; - } - - $published = 0; - foreach ($this->buildPayloads($fragmentType, $destinationPath, $deployment) as $payload) { - $this->dispatch($webhookUrl, $secret, $payload); - $published++; - } - - return $published; - } - - /** - * @param array $deployment - * @return list> - */ - public function buildPayloads(string $fragmentType, string $destinationPath, array $deployment = []): array - { - $config = self::FRAGMENT_CONFIG[$fragmentType] ?? null; - if (! is_array($config)) { - return []; - } - - $manifest = $this->loadManifest($destinationPath . '/assets/build/manifest.json'); - if ($manifest === []) { - return []; - } - - $cssArtifactPath = $this->resolveBuildArtifactPath($destinationPath, $manifest, $config['css_entry']); - $jsArtifactPath = $this->resolveBuildArtifactPath($destinationPath, $manifest, $config['js_entry']); - - if ($cssArtifactPath === '' || $jsArtifactPath === '') { - return []; - } - - $payloads = []; - - foreach ($this->discoverFragmentFiles($destinationPath, $config['html_fragment_suffix']) as $fragmentFile) { - $payload = $this->buildPayload( - $fragmentType, - $fragmentFile, - $destinationPath, - $cssArtifactPath, - $jsArtifactPath, - $config['asset_base_token'], - $deployment, - ); - - if ($payload !== []) { - $payloads[] = $payload; - } - } - - return $payloads; - } - - /** - * @return array - */ - 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 : []; - } - - /** - * @param array $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, '/'); - } - - /** - * @return list - */ - private function discoverFragmentFiles(string $destinationPath, string $htmlFragmentSuffix): 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, $htmlFragmentSuffix) - ) { - $files[] = $pathname; - } - } - - sort($files); - - return $files; - } - - /** - * @param array $deployment - * @return array - */ - private function buildPayload( - string $fragmentType, - string $fragmentFile, - string $destinationPath, - string $cssArtifactPath, - string $jsArtifactPath, - string $assetBaseToken, - array $deployment = [], - ): 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 = $assetBaseToken . '/' . 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), '', str_replace('\\', '/', $fragmentFile)), '/'); - $locale = $this->extractLocaleFromFragmentPath($relativeFragmentPath, $fragmentType); - - $payload = [ - 'fragment_type' => $fragmentType, - 'locale' => $locale, - 'version' => hash('sha256', $html . "\n" . $css . "\n" . $js), - 'generated_at' => gmdate(DATE_ATOM), - 'html' => $html, - 'css' => $css, - 'js' => $js, - 'assets' => $assets, - ]; - - if ($deployment !== []) { - $payload['deployment'] = $deployment; - } - - return $payload; - } - - /** - * @return list - */ - private function extractHtmlAssetPaths(string $html): array - { - preg_match_all('/(?:src)="([^"]+)"/i', $html, $matches); - - return $this->normalizeAssetPaths($matches[1] ?? []); - } - - /** - * @return list - */ - private function extractCssAssetPaths(string $css): array - { - preg_match_all('/url\(([^)]+)\)/i', $css, $matches); - - return $this->normalizeAssetPaths($matches[1] ?? []); - } - - /** - * @param array $values - * @return list - */ - 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, '/'), - '/' - ); - } - - /** - * @return list - */ - private function assetPublicUrlPatterns(string $assetPath): array - { - $path = '/' . ltrim($assetPath, '/'); - - return [ - 'http://localhost:8081' . $path, - 'https://libresign.coop' . $path, - $path, - ]; - } - - /** - * @param array $payload - */ - protected function dispatch(string $webhookUrl, string $secret, array $payload): void - { - $body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if (! is_string($body)) { - throw new RuntimeException('Unable to encode fragment webhook payload.'); - } - - $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, - ], - ]); - - $result = @file_get_contents($webhookUrl, false, $context); - $headers = $http_response_header ?? []; - $statusCode = $this->extractStatusCode($headers); - - if ($result === false || $statusCode < 200 || $statusCode >= 300) { - throw new RuntimeException(sprintf( - 'Fragment webhook dispatch failed for %s with status %s.', - $webhookUrl, - $statusCode > 0 ? (string) $statusCode : 'unknown' - )); - } - } - - /** - * @param array $headers - */ - private function extractStatusCode(array $headers): int - { - $statusLine = $headers[0] ?? ''; - if (! is_string($statusLine)) { - return 0; - } - - if (preg_match('/\s(\d{3})\s/', $statusLine, $matches) !== 1) { - return 0; - } - - return (int) $matches[1]; - } - - private function extractLocaleFromFragmentPath(string $relativeFragmentPath, string $fragmentType): string - { - $normalized = trim(str_replace('\\', '/', $relativeFragmentPath), '/'); - - if (! str_starts_with($normalized, 'fragments/')) { - return ''; - } - - $suffix = '/' . $fragmentType . '/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', '
default
'); - $this->writeFile($destinationPath . '/fragments/pt-BR/' . $fragmentName . '/index.html', '
pt-BR
'); - $this->writeFile($destinationPath . '/fragments/fr/' . $fragmentName . '/index.html', '
fr
'); - $this->writeFile($destinationPath . '/fragments/pt-BR/other/index.html', '
ignore
'); - $this->writeFile($destinationPath . '/fragments/' . ($fragmentName === 'header' ? 'footer' : 'header') . '/index.html', '
ignore sibling
'); - - $files = $this->invokePrivateMethod($listener, 'discoverFragmentFiles', [$destinationPath]); - $relativePaths = array_map( - static fn (string $file): string => ltrim(str_replace(str_replace('\\', '/', $destinationPath), '', str_replace('\\', '/', $file)), '/'), - $files - ); - - self::assertSame($expectedRelativePaths, $relativePaths); - } - - #[DataProvider('listenerProvider')] - public function testNormalizeAssetPathsFiltersAssetsAndRemovesDuplicates(string $listenerClass): void - { - $listener = new $listenerClass(); - - $paths = $this->invokePrivateMethod($listener, 'normalizeAssetPaths', [[ - 'http://localhost:8081/assets/images/logo.svg', - '/assets/images/logo.svg', - "'/assets/images/logo.svg'", - '/assets/build/assets/header-fragment.css', - '/not-assets/ignore.svg', - 'data:image/png;base64,abc', - '', - ]]); - - self::assertSame([ - 'assets/images/logo.svg', - 'assets/build/assets/header-fragment.css', - ], $paths); - } - - #[DataProvider('listenerProvider')] - public function testNormalizeStoredAssetPathRemovesLeadingAssetsDirectory(string $listenerClass): void - { - $listener = new $listenerClass(); - - self::assertSame( - 'images/logo.svg', - $this->invokePrivateMethod($listener, 'normalizeStoredAssetPath', ['assets/images/logo.svg']) - ); - - self::assertSame( - 'build/assets/file.woff2', - $this->invokePrivateMethod($listener, 'normalizeStoredAssetPath', ['/assets/build/assets/file.woff2']) - ); - } - - #[DataProvider('buildPayloadProvider')] - public function testBuildPayloadStripsFragmentAttrsTokenizesAssetsAndExtractsLocale( - string $listenerClass, - string $assetBaseToken, - string $fragmentName, - string $locale, - string $cssEntry, - string $jsEntry - ): void { - $listener = new $listenerClass(); - $destinationPath = $this->tempDir . '/site-output'; - - $fragmentPath = $destinationPath . '/fragments/' . $locale . '/' . $fragmentName . '/index.html'; - $cssArtifactPath = $destinationPath . '/assets/build/assets/' . basename($cssEntry); - $jsArtifactPath = $destinationPath . '/assets/build/assets/' . basename($jsEntry); - - $logoPath = $destinationPath . '/assets/images/logo.svg'; - $fontPath = $destinationPath . '/assets/build/assets/font.woff2'; - - $html = << - Logo - Logo duplicate - -HTML; - - $css = <<writeFile($fragmentPath, $html); - $this->writeFile($cssArtifactPath, $css); - $this->writeFile($jsArtifactPath, $js); - $this->writeFile($logoPath, ''); - $this->writeFile($fontPath, 'fake-font'); - - $payload = $this->invokePrivateMethod($listener, 'buildPayload', [ - $fragmentPath, - $destinationPath, - $cssArtifactPath, - $jsArtifactPath, - ]); - - self::assertSame($locale, $payload['locale']); - self::assertArrayHasKey('generated_at', $payload); - self::assertSame(hash('sha256', $payload['html'] . "\n" . $payload['css'] . "\n" . $payload['js']), $payload['version']); - - self::assertStringNotContainsString('data-fragment-css=', $payload['html']); - self::assertStringNotContainsString('data-fragment-js=', $payload['html']); - self::assertStringContainsString($assetBaseToken . '/images/logo.svg', $payload['html']); - self::assertStringContainsString($assetBaseToken . '/images/logo.svg', $payload['css']); - self::assertStringContainsString($assetBaseToken . '/build/assets/font.woff2', $payload['css']); - self::assertSame($js, $payload['js']); - - self::assertCount(2, $payload['assets']); - self::assertSame([ - 'path' => 'images/logo.svg', - 'content_base64' => base64_encode(''), - 'mime' => mime_content_type($logoPath) ?: 'application/octet-stream', - ], $payload['assets'][0]); - self::assertSame('build/assets/font.woff2', $payload['assets'][1]['path']); - self::assertSame(base64_encode('fake-font'), $payload['assets'][1]['content_base64']); - } - - #[DataProvider('extractLocaleProvider')] - public function testExtractLocaleFromFragmentPathReturnsExpectedValue( - string $listenerClass, - string $relativePath, - string $expectedLocale - ): void { - $listener = new $listenerClass(); - - self::assertSame( - $expectedLocale, - $this->invokePrivateMethod($listener, 'extractLocaleFromFragmentPath', [$relativePath]) - ); - } - - public static function listenerProvider(): iterable - { - yield 'header' => [PushHeaderFragments::class, 'LIBRESIGN_PUBLISH_HEADER_FRAGMENTS']; - yield 'footer' => [PushFooterFragments::class, 'LIBRESIGN_PUBLISH_FOOTER_FRAGMENTS']; - } - - public static function fragmentStructureProvider(): iterable - { - yield 'header fragments' => [ - PushHeaderFragments::class, - 'header', - [ - 'fragments/fr/header/index.html', - 'fragments/header/index.html', - 'fragments/pt-BR/header/index.html', - ], - ]; - - yield 'footer fragments' => [ - PushFooterFragments::class, - 'footer', - [ - 'fragments/footer/index.html', - 'fragments/fr/footer/index.html', - 'fragments/pt-BR/footer/index.html', - ], - ]; - } - - public static function buildPayloadProvider(): iterable - { - yield 'header payload' => [ - PushHeaderFragments::class, - '__LIBRESIGN_HEADER_ASSET_BASE_URL__', - 'header', - 'pt-BR', - 'header-fragment.css', - 'header-fragment.js', - ]; - - yield 'footer payload' => [ - PushFooterFragments::class, - '__LIBRESIGN_FOOTER_ASSET_BASE_URL__', - 'footer', - 'pt', - 'footer-fragment.css', - 'footer-fragment.js', - ]; - } - - public static function extractLocaleProvider(): iterable - { - yield 'header default locale' => [PushHeaderFragments::class, 'fragments/header/index.html', '']; - yield 'header localized locale' => [PushHeaderFragments::class, 'fragments/pt-BR/header/index.html', 'pt-BR']; - yield 'header ignores non header path' => [PushHeaderFragments::class, 'fragments/pt-BR/footer/index.html', '']; - yield 'footer default locale' => [PushFooterFragments::class, 'fragments/footer/index.html', '']; - yield 'footer localized locale' => [PushFooterFragments::class, 'fragments/fr/footer/index.html', 'fr']; - yield 'footer ignores non footer path' => [PushFooterFragments::class, 'fragments/fr/header/index.html', '']; - } - - /** - * @param object $instance - * @param array $args - */ - private function invokePrivateMethod(object $instance, string $method, array $args = []): mixed - { - $reflection = new ReflectionClass($instance); - $methodReflection = $reflection->getMethod($method); - $methodReflection->setAccessible(true); - - return $methodReflection->invokeArgs($instance, $args); - } - - private function writeFile(string $path, string $contents): void - { - $directory = dirname($path); - if (! is_dir($directory)) { - mkdir($directory, 0777, true); - } - - file_put_contents($path, $contents); - } - - private function deleteDirectory(string $path): void - { - if (! is_dir($path)) { - return; - } - - $items = scandir($path); - if ($items === false) { - return; - } - - foreach ($items as $item) { - if ($item === '.' || $item === '..') { - continue; - } - - $fullPath = $path . '/' . $item; - if (is_dir($fullPath)) { - $this->deleteDirectory($fullPath); - continue; - } - - unlink($fullPath); - } - - rmdir($path); - } -} diff --git a/tests/Unit/Support/Fragments/FragmentWebhookPublisherTest.php b/tests/Unit/Support/Fragments/FragmentWebhookPublisherTest.php deleted file mode 100644 index af34ab28..00000000 --- a/tests/Unit/Support/Fragments/FragmentWebhookPublisherTest.php +++ /dev/null @@ -1,229 +0,0 @@ -tempDir = sys_get_temp_dir() . '/libresign-fragment-webhook-' . bin2hex(random_bytes(8)); - mkdir($this->tempDir, 0777, true); - } - - protected function tearDown(): void - { - $this->deleteDirectory($this->tempDir); - - parent::tearDown(); - } - - public function testBuildPayloadsReturnsEmptyArrayWhenManifestIsMissing(): void - { - $publisher = new FragmentWebhookPublisher(); - - self::assertSame([], $publisher->buildPayloads('header', $this->tempDir)); - } - - public function testBuildPayloadsBuildsSortedHeaderPayloadsWithDeploymentMetadata(): void - { - $publisher = new FragmentWebhookPublisher(); - $destinationPath = $this->tempDir . '/site-output'; - - $this->writeManifest($destinationPath, [ - 'source/_assets/scss/header-fragment.scss' => ['file' => 'assets/header-fragment.css'], - 'source/_assets/js/header-fragment.js' => ['file' => 'assets/header-fragment.js'], - ]); - - $this->writeFile( - $destinationPath . '/fragments/header/index.html', - $this->fragmentHtml('http://localhost:8081/assets/images/logo.svg') - ); - $this->writeFile( - $destinationPath . '/fragments/fr/header/index.html', - $this->fragmentHtml('/assets/images/logo.svg') - ); - $this->writeFile( - $destinationPath . '/fragments/pt-BR/header/index.html', - $this->fragmentHtml('https://libresign.coop/assets/images/logo.svg') - ); - $this->writeFile( - $destinationPath . '/fragments/pt-BR/footer/index.html', - '
ignore
' - ); - - $this->writeFile( - $destinationPath . '/assets/build/assets/header-fragment.css', - ".fragment-root { background-image: url('/assets/images/logo.svg'); src: url(\"/assets/build/assets/font.woff2\") format('woff2'); }" - ); - $this->writeFile( - $destinationPath . '/assets/build/assets/header-fragment.js', - 'console.log("header");' - ); - $this->writeFile($destinationPath . '/assets/images/logo.svg', ''); - $this->writeFile($destinationPath . '/assets/build/assets/font.woff2', 'fake-font'); - - $deployment = [ - 'source' => 'github-actions', - 'repository' => 'LibreSign/site', - 'ref' => 'refs/heads/main', - 'event' => 'push', - 'sha' => 'abc123', - 'environment' => 'production', - ]; - - $payloads = $publisher->buildPayloads('header', $destinationPath, $deployment); - - self::assertCount(3, $payloads); - self::assertSame(['fr', '', 'pt-BR'], array_column($payloads, 'locale')); - - $firstPayload = $payloads[0]; - self::assertSame('header', $firstPayload['fragment_type']); - self::assertSame($deployment, $firstPayload['deployment']); - self::assertStringNotContainsString('data-fragment-css=', $firstPayload['html']); - self::assertStringNotContainsString('data-fragment-js=', $firstPayload['html']); - self::assertStringContainsString('__LIBRESIGN_HEADER_ASSET_BASE_URL__/images/logo.svg', $firstPayload['html']); - self::assertStringContainsString('__LIBRESIGN_HEADER_ASSET_BASE_URL__/images/logo.svg', $firstPayload['css']); - self::assertStringContainsString('__LIBRESIGN_HEADER_ASSET_BASE_URL__/build/assets/font.woff2', $firstPayload['css']); - self::assertSame('console.log("header");', $firstPayload['js']); - self::assertArrayHasKey('generated_at', $firstPayload); - self::assertSame( - hash('sha256', $firstPayload['html'] . "\n" . $firstPayload['css'] . "\n" . $firstPayload['js']), - $firstPayload['version'] - ); - - self::assertCount(2, $firstPayload['assets']); - self::assertSame('images/logo.svg', $firstPayload['assets'][0]['path']); - self::assertSame(base64_encode(''), $firstPayload['assets'][0]['content_base64']); - self::assertSame('build/assets/font.woff2', $firstPayload['assets'][1]['path']); - } - - public function testPublishDispatchesOneWebhookPerFooterFragment(): void - { - $publisher = new RecordingFragmentWebhookPublisher(); - $destinationPath = $this->tempDir . '/site-output'; - - $this->writeManifest($destinationPath, [ - 'source/_assets/scss/footer-fragment.scss' => ['file' => 'assets/footer-fragment.css'], - 'source/_assets/js/footer-fragment.js' => ['file' => 'assets/footer-fragment.js'], - ]); - - $this->writeFile( - $destinationPath . '/fragments/footer/index.html', - $this->fragmentHtml('/assets/images/logo.svg') - ); - $this->writeFile( - $destinationPath . '/fragments/pt/footer/index.html', - $this->fragmentHtml('/assets/images/logo.svg') - ); - $this->writeFile( - $destinationPath . '/assets/build/assets/footer-fragment.css', - ".fragment-root { background-image: url('/assets/images/logo.svg'); }" - ); - $this->writeFile( - $destinationPath . '/assets/build/assets/footer-fragment.js', - 'console.log("footer");' - ); - $this->writeFile($destinationPath . '/assets/images/logo.svg', ''); - - $count = $publisher->publish( - 'footer', - $destinationPath, - 'https://example.test/footer-webhook', - 'super-secret', - ['repository' => 'LibreSign/site', 'ref' => 'refs/heads/main'] - ); - - self::assertSame(2, $count); - self::assertCount(2, $publisher->dispatches); - self::assertSame('https://example.test/footer-webhook', $publisher->dispatches[0]['webhookUrl']); - self::assertSame('super-secret', $publisher->dispatches[0]['secret']); - self::assertSame('footer', $publisher->dispatches[0]['payload']['fragment_type']); - self::assertCount(1, $publisher->dispatches[0]['payload']['assets']); - self::assertSame('images/logo.svg', $publisher->dispatches[0]['payload']['assets'][0]['path']); - self::assertSame('pt', $publisher->dispatches[1]['payload']['locale']); - } - - private function fragmentHtml(string $assetUrl): string - { - return << - Logo - -HTML; - } - - /** - * @param array> $manifest - */ - private function writeManifest(string $destinationPath, array $manifest): void - { - $this->writeFile( - $destinationPath . '/assets/build/manifest.json', - json_encode($manifest, JSON_THROW_ON_ERROR) - ); - } - - private function writeFile(string $path, string $contents): void - { - $directory = dirname($path); - if (! is_dir($directory)) { - mkdir($directory, 0777, true); - } - - file_put_contents($path, $contents); - } - - private function deleteDirectory(string $path): void - { - if (! is_dir($path)) { - return; - } - - $items = scandir($path); - if ($items === false) { - return; - } - - foreach ($items as $item) { - if ($item === '.' || $item === '..') { - continue; - } - - $fullPath = $path . '/' . $item; - if (is_dir($fullPath)) { - $this->deleteDirectory($fullPath); - continue; - } - - unlink($fullPath); - } - - rmdir($path); - } -} - -final class RecordingFragmentWebhookPublisher extends FragmentWebhookPublisher -{ - /** - * @var list}> - */ - public array $dispatches = []; - - protected function dispatch(string $webhookUrl, string $secret, array $payload): void - { - $this->dispatches[] = [ - 'webhookUrl' => $webhookUrl, - 'secret' => $secret, - 'payload' => $payload, - ]; - } -}