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):
-
+
+
+
+
+
+
+
+
@@ -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():
'',
'',
- '',
+ '',
+ '',
+ '',
+ '',
+ '',
'',
+ '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 '' in page
+ assert '' 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, "/")