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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changelog

## 0.3.2 — Unreleased

Open Graph cards from the sidecar (`POST /v1/og`, `@mapsight/ssr-sidecar`
0.2) for the host's own public OG endpoint. No change for hosts that do not
use it.

- `SsrOgImageClient::fetch(EmbedRequest)` posts the placement's render
payload to `{ssrUrl}/v1/og` and returns `SsrOgImage` (`contentType`,
`body`), or null when `ssrUrl` is unset, the request URL has no
`?feature=` / `?module=`, the breaker is open, or the sidecar has no card
(404) or fails. Serve the static card then. Transport:
`SsrOgImageTransport` / `NativeSsrOgImageTransport` (cURL, else streams).
- `EmbedRequest::$ogImageEndpoint` (explicit field, else
`config.ogImageEndpoint`, else env `MAPSIGHT_OG_IMAGE_ENDPOINT`): the
absolute URL of that endpoint, forwarded as `options.ogImageEndpoint`. The
sidecar then links `{endpoint}?feature=…&module=…&src=…` as the
`og:image` of a place. Part of `SsrCacheKey`.
- Internal `SsrRequestPayload` builds the v1 body and headers for both the
renderer and the card client, so they resolve the same place.
- The card transport rejects redirects, non-image or empty responses and
images larger than 4 MiB. Requests retain the 256 KiB body cap.

## 0.3.1 — 2026-10-29

PHP 8.5 deprecation fixes for the 0.3 line. No API change.
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,43 @@ echo $result->html;
]);
```

## Open Graph cards

Sidecar 0.2 renders a place's Open Graph card on `POST /v1/og` (same body as
`/v1/render`). The sidecar stays off public ingress, so the host serves the
card from an endpoint of its own and tells the sidecar about it:

```php
use OpenMapsight\Embed\SsrOgImageClient;

// On the page: `ogImageEndpoint` makes the sidecar link
// {endpoint}?feature=…&module=…&src=… as og:image of a place.
$request = new EmbedRequest(
preset: 'simpleMap',
containerId: 'mapsight-embed-1',
config: $mapOptions, // the same preset options used on the page
ssrUrl: 'http://127.0.0.1:4123',
requestUrl: '/map/?feature=poi-1', // use the endpoint's validated share params
pageOrigin: 'https://www.example.com',
ogImageEndpoint: 'https://www.example.com/mapsight/og.php',
);

// On that endpoint, with the same EmbedRequest (requestUrl carrying the
// feature, module and src params of the request):
$image = (new SsrOgImageClient())->fetch($request);
if ($image === null) {
header('Location: https://www.example.com/plan/img/og-default.png', true, 302);
exit;
}
header('Content-Type: ' . $image->contentType);
header('Cache-Control: public, max-age=3600');
echo $image->body;
```

`fetch()` is fail-open like the renderer: null without `ssrUrl`, without
`?feature=` / `?module=` on `requestUrl`, while the breaker is open, and on
a sidecar `404` or error.

```bash
composer install
composer test
Expand Down
26 changes: 26 additions & 0 deletions src/Embed/EmbedRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public function __construct(
public readonly ?string $requestUrl = null,
public readonly ?string $pageOrigin = null,
public readonly ?string $ogImage = null,
public readonly ?string $ogImageEndpoint = null,
) {
if ($this->preset === '') {
throw new \InvalidArgumentException('preset must not be empty');
Expand Down Expand Up @@ -94,6 +95,31 @@ public function resolvedPageOrigin(): ?string
return null;
}

/**
* Absolute URL of the host's own Open Graph endpoint (a `SsrOgImageClient`
* behind it). Places then link `{endpoint}?feature=…` instead of the
* static card. Explicit field, then config.ogImageEndpoint, then env
* MAPSIGHT_OG_IMAGE_ENDPOINT.
*/
public function resolvedOgImageEndpoint(): ?string
{
if ($this->ogImageEndpoint !== null && $this->ogImageEndpoint !== '') {
return $this->ogImageEndpoint;
}

$fromConfig = $this->config['ogImageEndpoint'] ?? null;
if (is_string($fromConfig) && $fromConfig !== '') {
return $fromConfig;
}

$fromEnv = getenv('MAPSIGHT_OG_IMAGE_ENDPOINT');
if (is_string($fromEnv) && $fromEnv !== '') {
return $fromEnv;
}

return null;
}

/**
* Absolute or root-absolute static default OG card. Null lets the sidecar
* use `{pageOrigin}/plan/img/og-default.png`.
Expand Down
186 changes: 186 additions & 0 deletions src/Embed/NativeSsrOgImageTransport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

declare(strict_types=1);

namespace OpenMapsight\Embed;

/**
* POST {ssrUrl}/v1/og → the card's bytes, via cURL when available, else PHP streams.
*/
final class NativeSsrOgImageTransport implements SsrOgImageTransport
{
private const MAX_BODY_BYTES = 262144;

/** Cards are a few dozen KB; anything larger is not a card. */
public const MAX_IMAGE_BYTES = 4194304;

public function postOgImage(
string $url,
array $payload,
float $timeoutSeconds,
array $headers = [],
float $connectTimeoutSeconds = 0.1,
): ?SsrOgImage {
$json = json_encode($payload, JSON_THROW_ON_ERROR);
if (strlen($json) > self::MAX_BODY_BYTES) {
throw new \RuntimeException('SSR request body exceeds size cap');
}

if (function_exists('curl_init')) {
[$status, $contentType, $body] = $this->postWithCurl($url, $json, $timeoutSeconds, $connectTimeoutSeconds, $headers);
} else {
[$status, $contentType, $body] = $this->postWithStreams($url, $json, $timeoutSeconds, $headers);
}

return self::finish($status, $contentType, $body);
}

/**
* @param array<string, string> $headers
* @return array{0: int, 1: ?string, 2: string}
*/
private function postWithCurl(
string $url,
string $json,
float $timeoutSeconds,
float $connectTimeoutSeconds,
array $headers,
): array {
$headerLines = ['Content-Type: application/json'];
$headers += ['Accept' => 'image/png, application/json'];
foreach ($headers as $name => $value) {
$headerLines[] = $name . ': ' . $value;
}

$handle = curl_init($url);
if ($handle === false) {
throw new \RuntimeException('SSR og request failed');
}

$body = '';
$limited = false;
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_WRITEFUNCTION => static function (mixed $handle, string $chunk) use (&$body, &$limited): int {
// Keep one extra byte to let the shared response validation
// reject the image while retaining HTTP status and headers.
$remaining = self::MAX_IMAGE_BYTES + 1 - strlen($body);
if (strlen($chunk) > $remaining) {
$body .= substr($chunk, 0, $remaining);
$limited = true;

return 0;
}
$body .= $chunk;

return strlen($chunk);
},
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => max(1, (int) round($connectTimeoutSeconds * 1000)),
CURLOPT_TIMEOUT_MS => max(1, (int) round($timeoutSeconds * 1000)),
]);

$completed = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($handle, CURLINFO_CONTENT_TYPE);
$error = curl_error($handle);
unset($handle);

if ($completed === false && !$limited) {
throw new \RuntimeException($error !== '' ? $error : 'SSR og request failed');
}

return [$status, is_string($contentType) && $contentType !== '' ? $contentType : null, $body];
}

/**
* @param array<string, string> $headers
* @return array{0: int, 1: ?string, 2: string}
*/
private function postWithStreams(string $url, string $json, float $timeoutSeconds, array $headers): array
{
$headerLines = [
'Content-Type: application/json',
'Content-Length: ' . strlen($json),
];
$headers += ['Accept' => 'image/png, application/json'];
foreach ($headers as $name => $value) {
$headerLines[] = $name . ': ' . $value;
}

$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headerLines) . "\r\n",
'content' => $json,
'timeout' => $timeoutSeconds,
'ignore_errors' => true,
'follow_location' => 0,
],
]);

// Response headers come from the stream metadata: PHP 8.5 deprecates
// $http_response_header, and http_get_last_response_headers() needs 8.4.
$stream = @fopen($url, 'rb', false, $context);
if ($stream === false) {
throw new \RuntimeException('SSR og request failed');
}

try {
$headers = stream_get_meta_data($stream)['wrapper_data'] ?? null;
$body = stream_get_contents($stream, self::MAX_IMAGE_BYTES + 1);
$timedOut = stream_get_meta_data($stream)['timed_out'] ?? false;
} finally {
fclose($stream);
}

if ($body === false || $timedOut) {
throw new \RuntimeException('SSR og request failed');
}

$status = 0;
$contentType = null;
$contentLength = null;
if (is_array($headers)) {
if (is_string($headers[0] ?? null) && preg_match('/\s(\d{3})\s/', $headers[0], $matches) === 1) {
$status = (int) $matches[1];
}
foreach ($headers as $line) {
if (is_string($line) && stripos($line, 'Content-Type:') === 0) {
$contentType = trim(substr($line, strlen('Content-Type:')));
}
if (is_string($line) && preg_match('/^Content-Length:\s*([0-9]+)\s*$/i', $line, $matches) === 1) {
$contentLength = (int) $matches[1];
}
}
}

if (strlen($body) <= self::MAX_IMAGE_BYTES && $contentLength !== null && strlen($body) !== $contentLength) {
throw new \RuntimeException('SSR og response is incomplete');
}

return [$status, $contentType, $body];
}

private static function finish(int $status, ?string $contentType, string $body): ?SsrOgImage
{
if ($status === 404) {
return null;
}
if ($status < 200 || $status >= 300) {
throw new \RuntimeException('SSR og HTTP status ' . $status);
}
$type = strtolower(trim((string) strtok((string) $contentType, ';')));
if (!str_starts_with($type, 'image/')) {
throw new \RuntimeException('SSR og response Content-Type must be an image');
}
if ($body === '' || strlen($body) > self::MAX_IMAGE_BYTES) {
throw new \RuntimeException('SSR og response has no usable image');
}

return new SsrOgImage($type, $body);
}
}
56 changes: 2 additions & 54 deletions src/Embed/Renderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,11 @@ public function renderDocument(EmbedRequest $request): RenderedEmbed
try {
$transport = $this->ssrTransport ?? new NativeSsrTransport();
$renderUrl = rtrim($request->ssrUrl, '/') . '/v1/render';
$payload = [
'v' => 1,
'preset' => $request->preset,
'options' => $this->ssrOptions($request),
];
if ($request->requestId !== null && $request->requestId !== '') {
$payload['requestId'] = $request->requestId;
}
if ($request->assetVersion !== null && $request->assetVersion !== '') {
$payload['assetVersion'] = $request->assetVersion;
}
$document = $transport->postJson(
$renderUrl,
$payload,
SsrRequestPayload::body($request),
$request->ssrTimeoutSeconds,
self::ssrHeaders($request),
SsrRequestPayload::headers($request),
$request->ssrConnectTimeoutSeconds,
);
$this->circuitBreaker->recordSuccess();
Expand Down Expand Up @@ -105,33 +94,6 @@ public function renderDocument(EmbedRequest $request): RenderedEmbed
return new RenderedEmbed(implode("\n", $parts) . "\n", $pageMeta);
}

/**
* @return array<string, mixed>
*/
private function ssrOptions(EmbedRequest $request): array
{
$options = array_merge($request->config, [
'containerId' => $request->containerId,
]);
if ($request->containerClassName !== '') {
$options['containerClassName'] = $request->containerClassName;
}
$requestUrl = $request->resolvedRequestUrl();
if ($requestUrl !== null) {
$options['requestUrl'] = $requestUrl;
}
$pageOrigin = $request->resolvedPageOrigin();
if ($pageOrigin !== null) {
$options['pageOrigin'] = $pageOrigin;
}
$ogImage = $request->resolvedOgImage();
if ($ogImage !== null) {
$options['ogImage'] = $ogImage;
}

return $options;
}

private function pageMetaForRequest(EmbedRequest $request, ?PlacePageMeta $pageMeta): ?PlacePageMeta
{
if (!$request->requestHasPageMetaParam()) {
Expand All @@ -141,20 +103,6 @@ private function pageMetaForRequest(EmbedRequest $request, ?PlacePageMeta $pageM
return $pageMeta;
}

/** @return array<string, string> */
private static function ssrHeaders(EmbedRequest $request): array
{
$headers = ['Accept' => 'application/json'];
if ($request->requestId !== null && $request->requestId !== '') {
$headers['X-Request-Id'] = $request->requestId;
}
if ($request->assetVersion !== null && $request->assetVersion !== '') {
$headers['X-Mapsight-Asset-Version'] = $request->assetVersion;
}

return $headers;
}

private function emptyContainer(EmbedRequest $request): string
{
$id = $this->escapeAttr($request->containerId);
Expand Down
4 changes: 3 additions & 1 deletion src/Embed/SsrCacheKey.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* Stable key for a v1 SSR placement: config + locale/deviceClass + assetVersion
* + requestUrl + contract. requestUrl must be in the key so `?module=` /
* `?feature=` (and similar search) does not reuse another URL's HTML.
* pageOrigin / ogImage are in the key so cached pageMeta stays absolute.
* pageOrigin / ogImage / ogImageEndpoint are in the key so cached pageMeta
* stays absolute and links the right card.
*/
final class SsrCacheKey
{
Expand All @@ -26,6 +27,7 @@ public static function for(EmbedRequest $request): string
'requestUrl' => $request->resolvedRequestUrl(),
'pageOrigin' => $request->resolvedPageOrigin(),
'ogImage' => $request->resolvedOgImage(),
'ogImageEndpoint' => $request->resolvedOgImageEndpoint(),
];

return hash(
Expand Down
Loading
Loading