diff --git a/docs/content.md b/docs/content.md index 89320ae..d6b7058 100644 --- a/docs/content.md +++ b/docs/content.md @@ -114,7 +114,7 @@ extra: - **authors** — list of author slugs (referencing files in `content/authors/`) - **image** — featured image URL (absolute, or root-relative path resolved against `base_url`); used as `og:image` for social sharing. Falls back to the site-level `image` in `config.yaml` - **summary** — manual excerpt; if omitted, auto-generated from content -- **permalink** — per-entry URL override; takes precedence over collection pattern +- **permalink** — per-entry URL override; takes precedence over collection pattern. Must start with `/` and must not contain `.` or `..` path segments - **layout** — template layout name (default: collection-specific or `entry`) - **theme** — theme name for this entry; overrides the site-level default (see [Templates](templates.md)) - **weight** — integer for custom sorting in non-blog collections (lower = first) diff --git a/src/Console/BuildCommand.php b/src/Console/BuildCommand.php index 4e1e959..8f66014 100644 --- a/src/Console/BuildCommand.php +++ b/src/Console/BuildCommand.php @@ -397,6 +397,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int $fileToPermalink[$relativePath] = PermalinkResolver::applyLanguagePrefix($basePermalink, $page->language, $siteConfig->i18n); } + $duplicatePermalinks = $this->duplicatePermalinks($fileToPermalink); + if ($duplicatePermalinks !== []) { + foreach ($duplicatePermalinks as $permalink => $sources) { + $output->writeln(sprintf( + 'Duplicate permalink "%s" is used by: %s', + OutputFormatter::escape($permalink), + OutputFormatter::escape(implode(', ', $sources)), + )); + } + + $this->writeProfile($output, $profile); + return ExitCode::DATAERR; + } + $crossRefResolver = new CrossReferenceResolver($fileToPermalink); $profile->switchTo('diagnostics'); @@ -441,7 +455,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $sourcePath = $entry->filePath; $relativePath = substr($sourcePath, strlen($contentDir) + 1); $permalink = $fileToPermalink[$relativePath]; - $filePath = $outputDir . $permalink . 'index.html'; + try { + $filePath = $this->outputFilePath($outputDir, $permalink); + } catch (RuntimeException $e) { + $output->writeln('' . OutputFormatter::escape($e->getMessage()) . ''); + $this->writeProfile($output, $profile); + return ExitCode::DATAERR; + } if ($entry->redirectTo !== '') { $redirectTasks[] = ['entry' => $entry, 'filePath' => $filePath, 'permalink' => $permalink, 'sourcePath' => $sourcePath]; @@ -558,7 +578,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $sourcePath = $page->filePath; $basePermalink = $page->permalink !== '' ? $page->permalink : '/' . $page->slug . '/'; $permalink = PermalinkResolver::applyLanguagePrefix($basePermalink, $page->language, $siteConfig->i18n); - $filePath = $outputDir . $permalink . 'index.html'; + try { + $filePath = $this->outputFilePath($outputDir, $permalink); + } catch (RuntimeException $e) { + $output->writeln('' . OutputFormatter::escape($e->getMessage()) . ''); + $this->writeProfile($output, $profile); + return ExitCode::DATAERR; + } if ($changedSet !== null && !isset($changedSet[$sourcePath])) { $manifest?->record($sourcePath, [$filePath]); @@ -1216,6 +1242,48 @@ private function prepareOutputDir(string $outputDir): void } } + /** + * @param array $fileToPermalink + * @return array> + */ + private function duplicatePermalinks(array $fileToPermalink): array + { + $sourcesByPermalink = []; + foreach ($fileToPermalink as $source => $permalink) { + $sourcesByPermalink[$permalink][] = $source; + } + + return array_filter( + $sourcesByPermalink, + static fn (array $sources): bool => count($sources) > 1, + ); + } + + private function outputFilePath(string $outputDir, string $permalink): string + { + return $outputDir . '/' . $this->permalinkOutputPath($permalink) . 'index.html'; + } + + private function permalinkOutputPath(string $permalink): string + { + if (!str_starts_with($permalink, '/')) { + throw new RuntimeException(sprintf('Permalink "%s" must start with "/".', $permalink)); + } + + if ($permalink === '/') { + return ''; + } + + $segments = explode('/', trim($permalink, '/')); + foreach ($segments as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..') { + throw new RuntimeException(sprintf('Permalink "%s" contains an unsafe path segment.', $permalink)); + } + } + + return implode('/', $segments) . '/'; + } + private function resolvePath(string $path, string $rootPath): string { if (str_starts_with($path, '/')) { diff --git a/tests/Unit/Console/BuildCommandTest.php b/tests/Unit/Console/BuildCommandTest.php index 6670f79..4f65503 100644 --- a/tests/Unit/Console/BuildCommandTest.php +++ b/tests/Unit/Console/BuildCommandTest.php @@ -177,6 +177,67 @@ public function testBuildReportsInvalidSiteConfigWithoutTrace(): void assertStringNotContainsString('#0 ', $outputText); } + public function testBuildFailsForDuplicatePermalinks(): void + { + $tempDir = sys_get_temp_dir() . '/yiipress-build-duplicate-permalink-test-' . uniqid(); + $contentDir = $tempDir . '/content'; + $outputDir = $tempDir . '/output'; + mkdir($contentDir . '/blog', 0o755, true); + $this->tempContentDirs[] = $tempDir; + + file_put_contents($contentDir . '/config.yaml', "title: Duplicate Site\nlanguages: [en]\n"); + file_put_contents($contentDir . '/blog/_collection.yaml', "title: Blog\npermalink: /blog/:slug/\n"); + file_put_contents($contentDir . '/blog/first.md', "---\ntitle: First\npermalink: /same/\n---\n\nFirst.\n"); + file_put_contents($contentDir . '/blog/second.md', "---\ntitle: Second\npermalink: /same/\n---\n\nSecond.\n"); + + $yii = dirname(__DIR__, 3) . '/yii'; + exec( + $yii . ' build' + . ' --content-dir=' . escapeshellarg($contentDir) + . ' --output-dir=' . escapeshellarg($outputDir) + . ' --no-cache' + . ' 2>&1', + $output, + $exitCode, + ); + + $outputText = implode("\n", $output); + + assertSame(ExitCode::DATAERR, $exitCode, $outputText); + assertStringContainsString('Duplicate permalink "/same/"', $outputText); + assertFalse(is_file($outputDir . '/same/index.html')); + } + + public function testBuildFailsForUnsafePermalinkPathSegments(): void + { + $tempDir = sys_get_temp_dir() . '/yiipress-build-unsafe-permalink-test-' . uniqid(); + $contentDir = $tempDir . '/content'; + $outputDir = $tempDir . '/output'; + mkdir($contentDir . '/blog', 0o755, true); + $this->tempContentDirs[] = $tempDir; + + file_put_contents($contentDir . '/config.yaml', "title: Unsafe Site\nlanguages: [en]\n"); + file_put_contents($contentDir . '/blog/_collection.yaml', "title: Blog\npermalink: /blog/:slug/\n"); + file_put_contents($contentDir . '/blog/post.md', "---\ntitle: Escape\npermalink: /../../outside/\n---\n\nEscape.\n"); + + $yii = dirname(__DIR__, 3) . '/yii'; + exec( + $yii . ' build' + . ' --content-dir=' . escapeshellarg($contentDir) + . ' --output-dir=' . escapeshellarg($outputDir) + . ' --no-cache' + . ' 2>&1', + $output, + $exitCode, + ); + + $outputText = implode("\n", $output); + + assertSame(ExitCode::DATAERR, $exitCode, $outputText); + assertStringContainsString('contains an unsafe path segment', $outputText); + assertFalse(is_file($tempDir . '/outside/index.html')); + } + public function testBuildOutputContainsRenderedHtml(): void { $yii = dirname(__DIR__, 3) . '/yii';