Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<svg>`, 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 `<svg>` 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
Expand Down
12 changes: 12 additions & 0 deletions src/BlogPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <svg> 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')),
Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/DevToTarget.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] . '.');
}
Expand Down
81 changes: 81 additions & 0 deletions src/DiagramInliner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog;

/**
* Rewrites a post body for an external platform that rejects raw inline SVG (Dev.to's
* Forem and Hashnode both do, with a 403 / sanitised drop). Each inline `<svg>…</svg>`
* 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, '<svg') === false) {
return $body;
}

// Split out fenced code blocks (``` or ~~~) and transform only the prose between
// them, so an <svg> 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('/<svg[\s\S]*?<\/svg>/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';
}
}
23 changes: 23 additions & 0 deletions src/DiagramStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog;

/**
* A content-addressed host for rendered diagram PNGs: the content hash is the identity,
* so a diagram is rendered and stored once and every later cross-post reuses the same
* public URL. The stored file is the cache, so there is no cache table (and no
* migration to run on deploy).
*/
interface DiagramStore
{
/** Is a PNG for this content hash already hosted? */
public function has(string $hash): bool;

/** The absolute public URL a PNG for this hash has (or would have). */
public function url(string $hash): string;

/** Host the PNG bytes under this hash. False on any failure (the caller falls back). */
public function store(string $hash, string $png): bool;
}
3 changes: 3 additions & 0 deletions src/HashnodeTarget.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ public function push(array $post, ?string $externalId): array
'Accept' => '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'] . '.');
}
Expand Down
15 changes: 15 additions & 0 deletions src/Rasterizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog;

/**
* Turns SVG markup into PNG bytes, or null if it cannot (unsupported markup, no
* rasterizer available, a render failure). Null is a normal outcome the caller
* handles by falling back, never an exception.
*/
interface Rasterizer
{
public function toPng(string $svg): ?string;
}
126 changes: 126 additions & 0 deletions src/SvgRasterizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Blog;

/**
* Rasterizes SVG to PNG with `rsvg-convert` (librsvg). It renders onto a solid light
* background with a concrete dark ink for `currentColor`, so a diagram that relies on
* `currentColor` (invisible when rendered standalone on transparency) reads correctly
* on any external platform's light or dark theme; declared colours (an accent hex)
* are left untouched.
*
* SECURITY: the SVG is author-supplied and treated as untrusted. Before rendering it
* is checked for the dangerous constructs an SVG can carry — scripts, event handlers,
* `foreignObject`, a DOCTYPE/entity (XXE), and any external `href` (a `file:`/remote
* reference is an SSRF / local-file-read vector, since the rendered pixels are then
* hosted publicly). Anything matching is refused (null → the caller falls back). The
* SVG is written into a fresh private temp dir and rendered with an argv array (no
* shell, so no command injection) under a wall-clock timeout; librsvg itself runs no
* scripts and fetches no network resources.
*/
final class SvgRasterizer implements Rasterizer
{
public function __construct(
private string $binary = 'rsvg-convert',
private int $width = 1200,
private string $background = '#fbfaf7',
private string $ink = '#1f2933',
private int $timeoutSeconds = 10,
private int $maxBytes = 512000,
) {
}

public function toPng(string $svg): ?string
{
if (!$this->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*<svg[\s>]/i', $svg) !== 1) {
return false;
}
if (preg_match('/<!DOCTYPE|<!ENTITY|<script|<foreignObject|\son[a-z]+\s*=|javascript:/i', $svg) === 1) {
return false;
}
// Any href/xlink:href that is not an in-document fragment (#id) is external.
if (preg_match('/(?:xlink:)?href\s*=\s*["\'](?!#)/i', $svg) === 1) {
return false;
}
return true;
}

/**
* Run an argv array with no shell, under a timeout. True only on a clean exit 0.
*
* @param list<string> $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;
}
}
14 changes: 11 additions & 3 deletions src/Syndicator.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,24 @@
*/
final class Syndicator
{
/** @var \Closure(string,string):string body transform for the outgoing copy (body, canonical) */
private \Closure $transformBody;

/**
* @param array<string,SyndicationTarget> $targets keyed by target id
* @param \Closure(string):(array<string,mixed>|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,
private SyndicationStore $store,
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<SyndicationTarget> */
Expand Down Expand Up @@ -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,
];
}

Expand Down
Loading
Loading