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 0000000..9684207 Binary files /dev/null and b/src/arcade/og.png differ 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, "/")