diff --git a/README.md b/README.md index faa7294..f5b4336 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,25 @@ configured* in the admin and returns a clean "not configured" over MCP. Set the env for whichever targets you want, grant a role or token `nimbuscms.blog:syndicate`, and the target appears on the Syndication page. +### Diagrams (inline SVG) + +Dev.to and Hashnode sanitise raw HTML and reject inline ``, so a post whose body +has inline SVG diagrams would fail (Dev.to answers `403`) or lose the figure. Before a +post is sent, each inline `` is rendered to a PNG, hosted on this site, and +swapped for a normal markdown image, so the diagram shows on the cross-post. The +stored post keeps its inline SVG unchanged; only the outgoing copy is transformed. + +- Rendering uses **`rsvg-convert`** (the `librsvg2-bin` package), with a base font + package (e.g. `fonts-dejavu-core`) so diagram text renders. If `rsvg-convert` is not + installed, or a diagram cannot be rendered, that diagram degrades to a short pointer + to the canonical original rather than failing the cross-post. +- Diagrams render onto a solid light background with a dark ink for `currentColor`, so + a `currentColor`-based diagram is legible on any platform theme. +- Images are content-addressed under `uploads/nimbuscms.blog/diagrams/`, so a diagram + renders once and re-syndication reuses it. No database table, nothing to migrate. +- The SVG is treated as untrusted: scripts, event handlers, `foreignObject`, a + DOCTYPE/entity, and any external reference are refused (that diagram falls back). + ### Share links (Hacker News, Reddit) Hacker News and Reddit are link-submission communities, not blogs, so the plugin diff --git a/src/BlogPlugin.php b/src/BlogPlugin.php index db78f77..ec723ef 100644 --- a/src/BlogPlugin.php +++ b/src/BlogPlugin.php @@ -87,6 +87,17 @@ public function register(PluginContext $context): void // One Syndicator behind two surfaces: a capability-gated admin page and the // MCP toolset, both on nimbuscms.blog:syndicate. Target credentials are read // from server env only; the reader is published-only (ADR 0029). + // Inline SVG diagrams don't survive Dev.to/Hashnode (they sanitise raw HTML and + // reject inline SVG), so the outgoing body has each rendered to a hosted + // PNG and swapped for a markdown image. Content-addressed under the site's + // uploads dir, so a diagram renders once and re-syndication reuses it; a render + // failure degrades to a canonical pointer, never a failed cross-post. The stored + // post keeps its inline SVG untouched. + $diagrams = new DiagramInliner( + new SvgRasterizer(), + new UploadsDiagramStore(Config::uploadPath(), Config::uploadUrl(), Config::appUrl(), self::ID), + ); + $syndicator = new Syndicator( [ 'devto' => new DevToTarget(new CurlHttpClient(), Env::get('DEVTO_API_KEY')), @@ -96,6 +107,7 @@ public function register(PluginContext $context): void static fn (string $slug): ?array => $context->content()->entryBySlug(self::COLLECTION, $slug), Config::appUrl(), '/' . self::COLLECTION, + static fn (string $body, string $canonical): string => $diagrams->inline($body, $canonical), ); // The agent surface (ADR 0016) — same service, same capability. diff --git a/src/DevToTarget.php b/src/DevToTarget.php index 17a9b58..b00a941 100644 --- a/src/DevToTarget.php +++ b/src/DevToTarget.php @@ -60,6 +60,9 @@ public function push(array $post, ?string $externalId): array $body, ); + if ($resp['status'] === 403) { + throw new SyndicationError('Dev.to rejected the article body (HTTP 403), likely raw HTML or SVG in the post.'); + } if ($resp['status'] < 200 || $resp['status'] >= 300) { throw new SyndicationError('Dev.to returned HTTP ' . $resp['status'] . '.'); } diff --git a/src/DiagramInliner.php b/src/DiagramInliner.php new file mode 100644 index 0000000..3823f4d --- /dev/null +++ b/src/DiagramInliner.php @@ -0,0 +1,81 @@ +` + * becomes a normal markdown image pointing at a rendered, hosted PNG, so the diagram + * actually shows on the cross-post. The stored body on this site is never changed; + * only the copy handed to a target is transformed. + * + * A diagram is rendered and hosted once (content-addressed), so re-syndication reuses + * the same image. If a diagram cannot be rendered or hosted, it degrades to a short + * pointer to the canonical original rather than failing the whole cross-post. Fenced + * code blocks and surrounding prose are left exactly as they are. + */ +final class DiagramInliner +{ + public function __construct(private Rasterizer $rasterizer, private DiagramStore $store) + { + } + + public function inline(string $body, string $canonical): string + { + if (stripos($body, ' shown as example code in a fence is left untouched. + $parts = preg_split('/(```[\s\S]*?```|~~~[\s\S]*?~~~)/', $body, -1, PREG_SPLIT_DELIM_CAPTURE); + if ($parts === false) { + return $body; + } + foreach ($parts as $i => $part) { + if ($i % 2 === 1) { + continue; // a captured fenced block + } + $parts[$i] = $this->replaceSvg($part, $canonical); + } + return implode('', $parts); + } + + private function replaceSvg(string $text, string $canonical): string + { + $out = preg_replace_callback('//i', function (array $m) use ($canonical): string { + $svg = $m[0]; + $hash = hash('sha256', $svg); + if (!$this->store->has($hash)) { + $png = $this->rasterizer->toPng($svg); + if ($png === null || !$this->store->store($hash, $png)) { + return $this->fallback($canonical); + } + } + return '![' . $this->alt($svg) . '](' . $this->store->url($hash) . ')'; + }, $text); + + return $out ?? $text; + } + + /** A pointer used when a diagram cannot be rendered, so the post still works. */ + private function fallback(string $canonical): string + { + return "\n\n> Diagram omitted in this cross-post. See the original for the full figure:\n> " . $canonical . "\n\n"; + } + + /** Alt text from the SVG's aria-label if it has one, else a plain label. */ + private function alt(string $svg): string + { + if (preg_match('/aria-label\s*=\s*"([^"]*)"/i', $svg, $m) === 1 + || preg_match("/aria-label\s*=\s*'([^']*)'/i", $svg, $m) === 1) { + $label = trim(str_replace(["\n", "\r", '[', ']'], ' ', $m[1])); + if ($label !== '') { + return $label; + } + } + return 'Diagram'; + } +} diff --git a/src/DiagramStore.php b/src/DiagramStore.php new file mode 100644 index 0000000..b6df125 --- /dev/null +++ b/src/DiagramStore.php @@ -0,0 +1,23 @@ + 'application/json', ], $body); + if ($resp['status'] === 403) { + throw new SyndicationError('Hashnode rejected the request (HTTP 403), likely raw HTML or SVG in the post, or a token without Pro access.'); + } if ($resp['status'] < 200 || $resp['status'] >= 300) { throw new SyndicationError('Hashnode returned HTTP ' . $resp['status'] . '.'); } diff --git a/src/Rasterizer.php b/src/Rasterizer.php new file mode 100644 index 0000000..2216596 --- /dev/null +++ b/src/Rasterizer.php @@ -0,0 +1,15 @@ +isSafe($svg)) { + return null; + } + + $dir = sys_get_temp_dir() . '/blogsvg_' . bin2hex(random_bytes(6)); + if (!@mkdir($dir, 0o700, true)) { + return null; + } + try { + $in = $dir . '/in.svg'; + $css = $dir . '/ink.css'; + $out = $dir . '/out.png'; + if (@file_put_contents($in, $svg) === false || @file_put_contents($css, 'svg{color:' . $this->ink . '}') === false) { + return null; + } + $ok = $this->run([ + $this->binary, + '-w', (string) $this->width, + '--keep-aspect-ratio', + '-b', $this->background, + '-s', $css, + '-f', 'png', + '-o', $out, + $in, + ]); + if (!$ok || !is_file($out)) { + return null; + } + $png = @file_get_contents($out); + return is_string($png) && $png !== '' ? $png : null; + } finally { + foreach (glob($dir . '/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($dir); + } + } + + /** Reject the SVG constructs that make rasterizing untrusted markup dangerous. */ + private function isSafe(string $svg): bool + { + if ($svg === '' || strlen($svg) > $this->maxBytes) { + return false; + } + if (preg_match('/^\s*]/i', $svg) !== 1) { + return false; + } + if (preg_match('/ $argv + */ + private function run(array $argv): bool + { + $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = @proc_open($argv, $descriptors, $pipes); + if (!is_resource($proc)) { + return false; + } + fclose($pipes[0]); + $deadline = microtime(true) + $this->timeoutSeconds; + $code = -1; + while (true) { + $status = proc_get_status($proc); + if ($status['running'] === false) { + $code = $status['exitcode']; + break; + } + if (microtime(true) > $deadline) { + proc_terminate($proc, 9); + break; + } + usleep(50000); + } + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($proc); + return $code === 0; + } +} diff --git a/src/Syndicator.php b/src/Syndicator.php index c9bc2a6..39fce46 100644 --- a/src/Syndicator.php +++ b/src/Syndicator.php @@ -17,9 +17,14 @@ */ final class Syndicator { + /** @var \Closure(string,string):string body transform for the outgoing copy (body, canonical) */ + private \Closure $transformBody; + /** * @param array $targets keyed by target id * @param \Closure(string):(array|null) $fetchBySlug published post view-model, or null + * @param ?\Closure(string,string):string $transformBody rewrite the body sent to a target (e.g. inline + * diagrams); receives (body, canonical) and returns the outgoing body. Identity if omitted. */ public function __construct( private array $targets, @@ -27,7 +32,9 @@ public function __construct( private \Closure $fetchBySlug, private string $siteUrl, private string $basePath = '/blog', + ?\Closure $transformBody = null, ) { + $this->transformBody = $transformBody ?? static fn (string $body, string $canonical): string => $body; } /** @return list */ @@ -109,12 +116,13 @@ public function shareLinks(string $slug): array */ private function payload(array $post): array { - $fields = is_array($post['fields'] ?? null) ? $post['fields'] : []; + $fields = is_array($post['fields'] ?? null) ? $post['fields'] : []; + $canonical = $this->canonical($post, $fields); return [ 'title' => (string) ($post['title'] ?? ''), - 'body' => (string) ($fields['body'] ?? ''), + 'body' => ($this->transformBody)((string) ($fields['body'] ?? ''), $canonical), 'tags' => $this->tags((string) ($fields['tags'] ?? '')), - 'canonical' => $this->canonical($post, $fields), + 'canonical' => $canonical, ]; } diff --git a/src/UploadsDiagramStore.php b/src/UploadsDiagramStore.php new file mode 100644 index 0000000..19ef608 --- /dev/null +++ b/src/UploadsDiagramStore.php @@ -0,0 +1,53 @@ +//diagrams/ + * /.png`, served at `//diagrams//.png`. + * The path is derived only from the content hash (sharded by its first two hex chars), + * so it is stable and recomputable, which is what makes the file itself the cache. + * + * The bytes are generated by the plugin (a rasterized diagram), not an untrusted + * upload, so this writes the file directly rather than going through the core media + * pipeline (which plugins cannot reach anyway); the absolute URL is built with the + * same `appUrl + uploadUrl` convention core uses everywhere it needs one. + */ +final class UploadsDiagramStore implements DiagramStore +{ + public function __construct( + private string $baseDir, + private string $urlPrefix, + private string $appUrl, + private string $pluginId, + ) { + } + + public function has(string $hash): bool + { + return is_file($this->baseDir . '/' . $this->relativePath($hash)); + } + + public function url(string $hash): string + { + return rtrim($this->appUrl, '/') . '/' . rtrim(trim($this->urlPrefix, '/'), '/') . '/' . $this->relativePath($hash); + } + + public function store(string $hash, string $png): bool + { + $path = $this->baseDir . '/' . $this->relativePath($hash); + $dir = dirname($path); + if (!is_dir($dir) && !@mkdir($dir, 0o755, true) && !is_dir($dir)) { + return false; + } + return @file_put_contents($path, $png) !== false; + } + + private function relativePath(string $hash): string + { + return $this->pluginId . '/diagrams/' . substr($hash, 0, 2) . '/' . $hash . '.png'; + } +} diff --git a/tests/DevToTargetTest.php b/tests/DevToTargetTest.php index d6e4dc0..0538dae 100644 --- a/tests/DevToTargetTest.php +++ b/tests/DevToTargetTest.php @@ -52,6 +52,15 @@ public function test_a_non_2xx_becomes_a_clear_error(): void (new DevToTarget(new FakeHttpClient(422, '{"error":"nope"}'), 'k'))->push($this->post(), null); } + public function test_a_403_is_translated_to_a_body_rejection_message(): void + { + // Forem answers 403 when it rejects the body (e.g. raw HTML/SVG); the message + // must point there, not at the API key. + $this->expectException(SyndicationError::class); + $this->expectExceptionMessage('raw HTML or SVG'); + (new DevToTarget(new FakeHttpClient(403, '{}'), 'k'))->push($this->post(), null); + } + public function test_unconfigured_reports_and_refuses(): void { $target = new DevToTarget(new FakeHttpClient(200, '{}'), null); diff --git a/tests/DiagramInlinerTest.php b/tests/DiagramInlinerTest.php new file mode 100644 index 0000000..782316a --- /dev/null +++ b/tests/DiagramInlinerTest.php @@ -0,0 +1,97 @@ +\n\nMore prose."; + + $out = $inliner->inline($body, self::CANON); + + self::assertStringNotContainsString('inline('', self::CANON); + self::assertStringContainsString('![Diagram](https://danmat.dev/uploads/', $out); + } + + public function test_it_leaves_svg_inside_a_fenced_code_block_untouched(): void + { + $inliner = new DiagramInliner(new FakeRasterizer(), new FakeDiagramStore()); + $body = "Before.\n\n```html\n\n```\n\nAfter."; + + $out = $inliner->inline($body, self::CANON); + + self::assertStringContainsString("```html\n\n```", $out, 'the fenced example is preserved verbatim'); + self::assertStringNotContainsString('![Diagram]', $out); + } + + public function test_it_transforms_prose_svg_but_not_the_fenced_one(): void + { + $inliner = new DiagramInliner(new FakeRasterizer(), new FakeDiagramStore()); + $body = "\n\n```\n\n```"; + + $out = $inliner->inline($body, self::CANON); + + self::assertStringContainsString('![real](https://danmat.dev/uploads/', $out); + self::assertStringContainsString("```\n\n```", $out); + } + + public function test_it_renders_and_hosts_each_distinct_diagram_once(): void + { + $raster = new FakeRasterizer(); + $store = new FakeDiagramStore(); + $inliner = new DiagramInliner($raster, $store); + // Same svg twice + a different one → two stored images. + $svg = ''; + $inliner->inline($svg . "\n\n" . $svg . "\n\n" . '', self::CANON); + self::assertCount(2, $store->saved); + } + + public function test_a_render_failure_falls_back_to_the_canonical_pointer(): void + { + $inliner = new DiagramInliner(new FakeRasterizer(null), new FakeDiagramStore()); + $out = $inliner->inline('Prose.\n\n', self::CANON); + + self::assertStringNotContainsString('inline('', self::CANON); + self::assertStringContainsString('Diagram omitted in this cross-post', $out); + self::assertStringNotContainsString('![', $out); + } + + public function test_a_body_with_no_svg_is_returned_unchanged(): void + { + $inliner = new DiagramInliner(new FakeRasterizer(), new FakeDiagramStore()); + $body = "# Title\n\nJust prose and `inline code`."; + self::assertSame($body, $inliner->inline($body, self::CANON)); + } +} diff --git a/tests/FakeDiagramStore.php b/tests/FakeDiagramStore.php new file mode 100644 index 0000000..9304131 --- /dev/null +++ b/tests/FakeDiagramStore.php @@ -0,0 +1,37 @@ + hash => png */ + public array $saved = []; + + public function __construct(public bool $failStore = false) + { + } + + public function has(string $hash): bool + { + return isset($this->saved[$hash]); + } + + public function url(string $hash): string + { + return 'https://danmat.dev/uploads/nimbuscms.blog/diagrams/' . substr($hash, 0, 2) . '/' . $hash . '.png'; + } + + public function store(string $hash, string $png): bool + { + if ($this->failStore) { + return false; + } + $this->saved[$hash] = $png; + return true; + } +} diff --git a/tests/FakeRasterizer.php b/tests/FakeRasterizer.php new file mode 100644 index 0000000..0e8cf0e --- /dev/null +++ b/tests/FakeRasterizer.php @@ -0,0 +1,23 @@ +lastSvg = $svg; + return $this->png; + } +} diff --git a/tests/HashnodeTargetTest.php b/tests/HashnodeTargetTest.php index d2c88f9..31ddf27 100644 --- a/tests/HashnodeTargetTest.php +++ b/tests/HashnodeTargetTest.php @@ -76,6 +76,13 @@ public function test_a_non_2xx_becomes_a_clear_error(): void $this->target(new FakeHttpClient(500, ''))->push($this->post(), null); } + public function test_a_403_is_translated_to_a_clear_message(): void + { + $this->expectException(SyndicationError::class); + $this->expectExceptionMessage('raw HTML or SVG'); + $this->target(new FakeHttpClient(403, ''))->push($this->post(), null); + } + public function test_unconfigured_without_a_publication_reports_and_refuses(): void { $target = new HashnodeTarget(new FakeHttpClient(200, '{}'), 'tok', null); diff --git a/tests/SvgRasterizerTest.php b/tests/SvgRasterizerTest.php new file mode 100644 index 0000000..36ee5d4 --- /dev/null +++ b/tests/SvgRasterizerTest.php @@ -0,0 +1,86 @@ + and write PNG bytes there, exit 0. + $this->stub = sys_get_temp_dir() . '/rz_stub_' . bin2hex(random_bytes(4)) . '.sh'; + file_put_contents($this->stub, "#!/bin/sh\nout=\nwhile [ \$# -gt 0 ]; do\n if [ \"\$1\" = \"-o\" ]; then out=\"\$2\"; fi\n shift\ndone\nprintf 'PNGDATA' > \"\$out\"\n"); + chmod($this->stub, 0o700); + } + + protected function tearDown(): void + { + @unlink($this->stub); + } + + private function raster(): SvgRasterizer + { + return new SvgRasterizer($this->stub); + } + + public function test_a_safe_svg_is_rendered(): void + { + $png = $this->raster()->toPng(''); + self::assertSame('PNGDATA', $png, 'a safe svg reaches the renderer'); + } + + /** + * @return iterable + */ + public static function unsafeSvgs(): iterable + { + yield 'script' => ['']; + yield 'event handler' => ['']; + yield 'foreignObject' => ['x']; + yield 'doctype/entity' => [']>']; + yield 'external href' => ['']; + yield 'external xlink' => ['']; + yield 'javascript href' => ['']; + yield 'not an svg' => ['
not svg
']; + yield 'empty' => ['']; + } + + #[DataProvider('unsafeSvgs')] + public function test_an_unsafe_or_malformed_svg_is_refused(string $svg): void + { + self::assertNull($this->raster()->toPng($svg), 'unsafe svg must not be rendered'); + } + + public function test_an_oversized_svg_is_refused(): void + { + $huge = '' . str_repeat('', 100000) . ''; + self::assertNull($this->raster()->toPng($huge)); + } + + public function test_a_fragment_href_is_allowed(): void + { + // An in-document reference (#id) is safe and must not be treated as external. + $png = $this->raster()->toPng(''); + self::assertSame('PNGDATA', $png); + } + + public function test_a_missing_binary_degrades_to_null(): void + { + $png = (new SvgRasterizer('rsvg-convert-does-not-exist-xyz'))->toPng(''); + self::assertNull($png, 'no rasterizer installed → null, not an error'); + } +} diff --git a/tests/SyndicatorTest.php b/tests/SyndicatorTest.php index 0072b6b..5b901ae 100644 --- a/tests/SyndicatorTest.php +++ b/tests/SyndicatorTest.php @@ -88,6 +88,26 @@ public function test_a_target_failure_records_error_and_rethrows(): void } } + public function test_the_body_transform_is_applied_to_the_outgoing_payload(): void + { + $t = new FakeTarget(); + // The transform receives the raw body + canonical and rewrites the body sent out. + $syndicator = new Syndicator( + ['devto' => $t], + new FakeStore(), + static fn (string $s): ?array => $s === 'hello' + ? ['id' => 7, 'slug' => 'hello', 'title' => 'Hello', 'fields' => ['body' => 'raw ', 'tags' => '', 'canonical_url' => '']] + : null, + 'https://danmat.dev', + '/blog', + static fn (string $body, string $canonical): string => 'TRANSFORMED for ' . $canonical, + ); + + $syndicator->syndicate('hello', 'devto', 'now'); + self::assertNotNull($t->lastCall); + self::assertSame('TRANSFORMED for https://danmat.dev/blog/hello', $t->lastCall['post']['body']); + } + public function test_share_links_point_at_the_self_canonical(): void { $links = $this->make(new FakeTarget(), new FakeStore())->shareLinks('hello');