From 7f0b1e5fdc176656ffdaae5116aabdf28784a124 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Mon, 5 Oct 2026 21:36:08 -0600 Subject: [PATCH] Add a share-preview image to the landing page, served at /og.png src/arcade/og.png is a 1200x630 card (the page's name, DESCRIPTION and the play domain, in its dark colours). The landing page now carries og:image and twitter:image at https:///og.png with type, width, height and alt, and a summary_large_image Twitter card. GET /og.png is answered on the API host only, like /robots.txt, so a game's own /og.png is never shadowed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BZQMN1M5hQKfUpPwDkMjqd --- CHANGELOG.md | 10 +++++++--- README.md | 1 + src/arcade/landing.py | 39 ++++++++++++++++++++++++++++++++++++--- src/arcade/og.png | Bin 0 -> 15145 bytes src/arcade/server.py | 6 ++++++ tests/test_landing.py | 22 +++++++++++++++++++--- tests/test_server.py | 22 ++++++++++++++++++++++ 7 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 src/arcade/og.png diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e1431..4e1a66f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,13 @@ All notable changes to this project are documented in this file. The format is b ### Added - Share-preview metadata on the landing page: `og:type`, `og:site_name`, `og:title`, - `og:description`, `og:url` (`https://play./`) and a `summary` `twitter:card` with its title - and description, so a shared link to `play.` unfurls with a title and description. No - `og:image` is set, as the repository has no image to point at. + `og:description`, `og:url` (`https://play./`) and a `twitter:card` with its title and + description, so a shared link to `play.` unfurls with a title and description. +- A share-preview image: `og:image` and `twitter:image` (`https://play./og.png`) with + `og:image:type`, `og:image:width` (1200), `og:image:height` (630) and alt text, and the Twitter + card is now `summary_large_image`. The image, `src/arcade/og.png`, is a 1200x630 PNG of the + page's name, description and domain in its dark colours. `GET /og.png` serves it as `image/png` on + the API host only, never on a game's host, so it cannot shadow a static game's own `/og.png`. - `GET /robots.txt` on `play.`: `User-agent: *` / `Allow: /` as `text/plain`. Like `/version.json`, it is answered on the API host only, never on a game's host, so it cannot shadow a static game's own `/robots.txt`. diff --git a/README.md b/README.md index d86573d..27cf6b8 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ out for a phone first, and is sent with `Cache-Control: public, max-age=60`. Set | `POST` | `/api/games//current` | `{"version": "…"}`: repoints the game to a stored version. This is rollback. **404** for a version not on disk. | | `GET` | `/api/games`, `/api/games/` | slug, title, repo, url, aliases, kind, isolation, current, versions, **plays** (page loads by people; crawlers and scripts are excluded by User-Agent). Needs no token, never shows hashes, and is readable cross-origin (`Access-Control-Allow-Origin: *`) so the portal can show play counts. | | `GET` | `/version.json` | `{"version": ""}`: the version of arcade itself that is running (`arcade.__version__`), with `Cache-Control: no-store`, so a deploy can be verified by the version it reports. Needs no token. Answered on `play.` only: on a game's host `/version.json` is the game's own path. | +| `GET` | `/og.png` | The landing page's share-preview image (`og:image`, `twitter:image`): a 1200x630 PNG, `src/arcade/og.png`, with `Cache-Control: public, max-age=86400`. Needs no token. Answered on `play.` only: on a game's host `/og.png` is the game's own path. | The token is `Authorization: Bearer `, and each game's token deploys only that game. An unknown slug and a wrong token both get 403. The last `ARCADE_KEEP_VERSIONS` (5) versions per game diff --git a/src/arcade/landing.py b/src/arcade/landing.py index ce0c2d5..566317f 100644 --- a/src/arcade/landing.py +++ b/src/arcade/landing.py @@ -13,15 +13,37 @@ """ import html +import os CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'" # Already HTML-escaped; shared by , the description and the share-preview -# tags (og:* and twitter:*). There is no og:image: the repository holds no image -# to point at, and the CSP keeps the page itself free of any. +# tags (og:* and twitter:*). TITLE = "Play — browser games by Daniel McCoy Stephenson" DESCRIPTION = "Browser games by Daniel McCoy Stephenson, served by arcade. Nothing to install." +# The link-preview card named by og:image and twitter:image: og.png beside this +# module, a 1200x630 image of the page's name, DESCRIPTION and the play domain +# in the page's dark colours. It is served at /og.png on the API host only (a +# game's host never answers it, like /robots.txt). The page itself never loads +# it, so the CSP's "img-src data:" is unaffected. If the image is redrawn at +# another size, change the dimensions here with it. +OG_IMAGE_PATH = "/og.png" +OG_IMAGE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "og.png") +OG_IMAGE_WIDTH = 1200 +OG_IMAGE_HEIGHT = 630 +OG_IMAGE_ALT = "Play: browser games by Daniel McCoy Stephenson, served by arcade. Nothing to install." +_ogImage = [] + + +def ogImage(): + """The PNG bytes of the share card, read once and kept.""" + if not _ogImage: + with open(OG_IMAGE_FILE, "rb") as handle: + _ogImage.append(handle.read()) + return _ogImage[0] + + # Served at /robots.txt on the API host only. A game's host never answers it: # a static game may ship its own robots.txt and must not be shadowed. ROBOTS = "User-agent: *\nAllow: /\n" @@ -95,9 +117,16 @@ def render(registry, domain, portalUrl, deployed=lambda slug: True): <meta property="og:title" content="%(title)s"> <meta property="og:description" content="%(description)s"> <meta property="og:url" content="https://%(domain)s/"> -<meta name="twitter:card" content="summary"> +<meta property="og:image" content="https://%(domain)s%(ogImage)s"> +<meta property="og:image:type" content="image/png"> +<meta property="og:image:width" content="%(ogImageWidth)d"> +<meta property="og:image:height" content="%(ogImageHeight)d"> +<meta property="og:image:alt" content="%(ogImageAlt)s"> +<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:title" content="%(title)s"> <meta name="twitter:description" content="%(description)s"> +<meta name="twitter:image" content="https://%(domain)s%(ogImage)s"> +<meta name="twitter:image:alt" content="%(ogImageAlt)s"> <link rel="icon" href="data:,"> <style>%(style)s</style> </head> @@ -119,6 +148,10 @@ def render(registry, domain, portalUrl, deployed=lambda slug: True): "domain": escape(domain), "title": TITLE, "description": DESCRIPTION, + "ogImage": OG_IMAGE_PATH, + "ogImageWidth": OG_IMAGE_WIDTH, + "ogImageHeight": OG_IMAGE_HEIGHT, + "ogImageAlt": escape(OG_IMAGE_ALT), "style": _STYLE, "portal": portal, "portalLabel": portalLabel, diff --git a/src/arcade/og.png b/src/arcade/og.png new file mode 100644 index 0000000000000000000000000000000000000000..96842071cad6807302f6b7f10a30b0cd7a4c79ab GIT binary patch literal 15145 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�=GT;(2)-iPekF2x*vy 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 literal 0 HcmV?d00001 diff --git a/src/arcade/server.py b/src/arcade/server.py index f5f30d2..0ad9832 100644 --- a/src/arcade/server.py +++ b/src/arcade/server.py @@ -410,6 +410,12 @@ def _api(self, path, read): # belongs to the game. self._send(200, landing.ROBOTS.encode("utf-8"), headers=(("Cache-Control", "public, max-age=3600"),)) return + if read and path == landing.OG_IMAGE_PATH: + # The landing page's share card. API host only, like robots.txt: + # on a game's host the path belongs to the game. + headers = (("Cache-Control", "public, max-age=86400"),) + self._send(200, landing.ogImage(), "image/png", headers=headers) + return if read and path == "/api/games": registry = arcade.registry.registry self._json(200, {"games": [self._describe(game) for game in registry]}, public=True) diff --git a/tests/test_landing.py b/tests/test_landing.py index e5de7f0..711f6d6 100644 --- a/tests/test_landing.py +++ b/tests/test_landing.py @@ -1,3 +1,5 @@ +import struct + from arcade import landing from arcade.registry import Game, Registry @@ -35,16 +37,30 @@ def test_the_page_carries_share_preview_tags_on_its_own_domain(): '<meta property="og:title" content="Play — browser games by Daniel McCoy Stephenson">', '<meta property="og:description" content="Browser games by Daniel McCoy Stephenson', '<meta property="og:url" content="https://play.example.com/">', - '<meta name="twitter:card" content="summary">', + '<meta property="og:image" content="https://play.example.com/og.png">', + '<meta property="og:image:type" content="image/png">', + '<meta property="og:image:width" content="1200">', + '<meta property="og:image:height" content="630">', + '<meta property="og:image:alt" content="Play: browser games by Daniel McCoy Stephenson', + '<meta name="twitter:card" content="summary_large_image">', '<meta name="twitter:title" content="Play —', '<meta name="twitter:description" content="Browser games', + '<meta name="twitter:image" content="https://play.example.com/og.png">', + '<meta name="twitter:image:alt" content="Play: browser games', ): assert tag in page, tag assert "localhost" not in page - # No image exists to preview, and none is invented. - assert "og:image" not in page and "twitter:image" not in page + + +def test_the_share_image_is_a_png_of_the_size_the_tags_state(): + data = landing.ogImage() + assert data[:8] == b"\x89PNG\r\n\x1a\n" + # IHDR follows the signature: big-endian width and height at bytes 16 and 20. + assert struct.unpack(">II", data[16:24]) == (landing.OG_IMAGE_WIDTH, landing.OG_IMAGE_HEIGHT) == (1200, 630) + assert len(data) < 150 * 1024 def test_the_share_preview_url_follows_the_configured_domain(): page = landing.render(Registry([]), "play.danielstephenson.dev", "https://danielstephenson.dev/play").decode("utf-8") assert '<meta property="og:url" content="https://play.danielstephenson.dev/">' in page + assert '<meta property="og:image" content="https://play.danielstephenson.dev/og.png">' in page diff --git a/tests/test_server.py b/tests/test_server.py index bcd0a75..63a4320 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -6,6 +6,7 @@ import pytest from arcade import __version__ as arcade_version +from arcade import landing from arcade.server import ISOLATION_HEADERS, Arcade, Config, makeServer from helpers import ASSETS, OTHER_SHA, OTHER_TOKEN, TOKEN, bundleFiles, game, registryText, tarBundle @@ -125,6 +126,27 @@ def test_robots_txt_on_a_game_host_belongs_to_the_game(arcade): assert request(arcade, "GET", host, "/robots.txt")[0].status == 404, host +def test_the_api_host_serves_the_share_image(arcade): + with open(landing.OG_IMAGE_FILE, "rb") as handle: + expected = handle.read() + for method in ("GET", "HEAD"): + response, data = request(arcade, method, API, "/og.png") + assert response.status == 200 + assert response.getheader("Content-Type") == "image/png" + assert response.getheader("Content-Length") == str(len(expected)) + assert data == (expected if method == "GET" else b"") + + +def test_og_png_on_a_game_host_belongs_to_the_game(arcade): + site = dict(_site("0.1"), **{"og.png": b"the game's own card"}) + assert upload(arcade, slug="rps", version="0.1", body=tarBundle(site))[0].status == 201 + response, data = request(arcade, "GET", "rps." + DOMAIN, "/og.png") + assert (response.status, data) == (200, b"the game's own card") + upload(arcade) + for host in (TIDEWATER, "tidewater.example.org", "nope." + DOMAIN, "localhost"): + assert request(arcade, "GET", host, "/og.png")[0].status == 404, host + + def test_unknown_hosts_are_404_and_still_isolated(arcade): for host in ("example.com", "nope." + DOMAIN, "a.b." + DOMAIN, ""): response, _ = request(arcade, "GET", host, "/")