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
10 changes: 7 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<base>/`) and a `summary` `twitter:card` with its title
and description, so a shared link to `play.<base>` 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.<base>/`) and a `twitter:card` with its title and
description, so a shared link to `play.<base>` unfurls with a title and description.
- A share-preview image: `og:image` and `twitter:image` (`https://play.<base>/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.<base>`: `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`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ out for a phone first, and is sent with `Cache-Control: public, max-age=60`. Set
| `POST` | `/api/games/<slug>/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>` | 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": "<x.y.z>"}`: 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.<base>` 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.<base>` only: on a game's host `/og.png` is the game's own path. |

The token is `Authorization: Bearer <token>`, 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
Expand Down
39 changes: 36 additions & 3 deletions src/arcade/landing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <title>, 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 &mdash; 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"
Expand Down Expand Up @@ -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>
Expand All @@ -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,
Expand Down
Binary file added src/arcade/og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions src/arcade/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 19 additions & 3 deletions tests/test_landing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import struct

from arcade import landing
from arcade.registry import Game, Registry

Expand Down Expand Up @@ -35,16 +37,30 @@ def test_the_page_carries_share_preview_tags_on_its_own_domain():
'<meta property="og:title" content="Play &mdash; 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 &mdash;',
'<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
22 changes: 22 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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, "/")
Expand Down
Loading