commit a43aae8727779253a7c11e691dcc58047023e9e5
parent 700a507b31c60d1fe1efa2c5dfa2be63a05ab003
Author: Michael Camilleri <[email protected]>
Date: Sat, 13 Jun 2026 08:36:06 +0900
Name the puzzle in short-link previews
This commit personalises the link preview for a shared game: the card
now reads 'Solve "Saturday Stumper" together on Crossmate' instead of
the generic line for every puzzle. The link worker is stateless, so the
only place the title can come from is the URL itself; the app appends
it to the short link as an extra path segment encoded as unpadded
base64url, keeping the pasted text one opaque blob with no legible
puzzle name and no query string.
The segment is decoration only. The redirect to iCloud keys on the
token alone and ignores the title, so a stale or mangled segment still
lands on the right share, and the canonical og:url stays the token-only
form so scrapers collapse title variants onto one card. Because the
decoded text lands inside HTML the worker serves, it is treated as
hostile input: charset-checked while encoded, decoded as strict UTF-8,
stripped of control characters, length-capped and entity-escaped at
the interpolation site, with any failure falling back to the generic
preview.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
4 files changed, 124 insertions(+), 30 deletions(-)
diff --git a/Crossmate/Services/ShareLinkShortener.swift b/Crossmate/Services/ShareLinkShortener.swift
@@ -27,25 +27,45 @@ enum ShareLinkShortener {
return URL(string: trimmed)
}()
- static func shortURL(for shareURL: URL) -> URL {
- shortURL(for: shareURL, baseURL: configuredBaseURL)
+ static func shortURL(for shareURL: URL, title: String?) -> URL {
+ shortURL(for: shareURL, title: title, baseURL: configuredBaseURL)
}
/// Returns `shareURL` unchanged whenever it does not look like a CKShare
/// link the worker is known to handle, so an unexpected URL shape from
/// CloudKit degrades to sharing the working raw link rather than minting
/// a short link that 404s.
- static func shortURL(for shareURL: URL, baseURL: URL?) -> URL {
+ static func shortURL(for shareURL: URL, title: String?, baseURL: URL?) -> URL {
guard let baseURL, let token = shareToken(from: shareURL) else {
return shareURL
}
- return baseURL.appending(path: "s/\(token)")
+ guard let encodedTitle = encodedTitle(title) else {
+ return baseURL.appending(path: "s/\(token)")
+ }
+ return baseURL.appending(path: "s/\(token)/\(encodedTitle)")
+ }
+
+ /// The game title rides the short link as an extra path segment so the
+ /// worker can personalise the link preview, encoded as unpadded base64url
+ /// to keep the URL one opaque blob instead of a legible puzzle name. The
+ /// worker treats the segment as decoration: it never affects where the
+ /// link redirects.
+ private static func encodedTitle(_ title: String?) -> String? {
+ let trimmed = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ // Matches the worker's MAX_TITLE_LENGTH; anything longer is
+ // truncated there anyway, so sending it would only bloat the URL.
+ let capped = String(trimmed.prefix(80))
+ return Data(capped.utf8).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
}
/// Extracts the token from `https://www.icloud.com/share/<token>#…`. The
- /// title-slug fragment is deliberately dropped — keeping puzzle titles
- /// out of the shared URL is part of the point of the short link, and
- /// iCloud's acceptance flow keys on the token alone.
+ /// title-slug fragment is deliberately dropped — keeping a legible
+ /// puzzle title out of the shared URL is part of the point of the short
+ /// link, and iCloud's acceptance flow keys on the token alone.
private static func shareToken(from url: URL) -> String? {
guard
url.scheme == "https",
diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameShareItem.swift
@@ -254,7 +254,7 @@ struct GameShareSheet: View {
do {
shareURL = (try await shareController.existingShareLink(for: gameID))
- .map { ShareLinkShortener.shortURL(for: $0) }
+ .map { ShareLinkShortener.shortURL(for: $0, title: title) }
} catch {
errorMessage = describe(error)
}
@@ -269,7 +269,8 @@ struct GameShareSheet: View {
do {
shareURL = ShareLinkShortener.shortURL(
- for: try await shareController.createShareLink(for: gameID)
+ for: try await shareController.createShareLink(for: gameID),
+ title: title
)
} catch {
errorMessage = describe(error)
diff --git a/Tests/Unit/ShareLinkShortenerTests.swift b/Tests/Unit/ShareLinkShortenerTests.swift
@@ -11,28 +11,58 @@ struct ShareLinkShortenerTests {
@Test("rewrites an iCloud share URL to the worker short form")
func rewritesShareURL() {
let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw")!
- let short = ShareLinkShortener.shortURL(for: share, baseURL: base)
+ let short = ShareLinkShortener.shortURL(for: share, title: nil, baseURL: base)
#expect(short.absoluteString == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw")
}
@Test("drops the title-slug fragment from the short link")
func dropsFragment() {
let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw#Saturday_Stumper")!
- let short = ShareLinkShortener.shortURL(for: share, baseURL: base)
+ let short = ShareLinkShortener.shortURL(for: share, title: nil, baseURL: base)
#expect(short.absoluteString == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw")
}
+ @Test("encodes the game title as an unpadded base64url path segment")
+ func encodesTitleSegment() {
+ let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw#Saturday_Stumper")!
+ let short = ShareLinkShortener.shortURL(for: share, title: "Saturday Stumper", baseURL: base)
+ #expect(
+ short.absoluteString
+ == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw/U2F0dXJkYXkgU3R1bXBlcg"
+ )
+ }
+
+ @Test("maps base64 plus and slash into the URL-safe alphabet")
+ func encodesTitleURLSafely() {
+ let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw")!
+ // The em dash and π force '+' into the standard base64 encoding.
+ let short = ShareLinkShortener.shortURL(for: share, title: "Sunday? Yes — π!", baseURL: base)
+ #expect(
+ short.absoluteString
+ == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw/U3VuZGF5PyBZZXMg4oCUIM-AIQ"
+ )
+ }
+
+ @Test("omits the title segment for an empty or whitespace title")
+ func omitsBlankTitle() {
+ let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw")!
+ for title in [nil, "", " ", "\n"] as [String?] {
+ let short = ShareLinkShortener.shortURL(for: share, title: title, baseURL: base)
+ #expect(short.absoluteString == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw")
+ }
+ }
+
@Test("accepts the bare icloud.com host")
func acceptsBareHost() {
let share = URL(string: "https://icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw")!
- let short = ShareLinkShortener.shortURL(for: share, baseURL: base)
+ let short = ShareLinkShortener.shortURL(for: share, title: nil, baseURL: base)
#expect(short.absoluteString == "https://crossmate.example.net/s/0a1BcDeFgHiJkLmNoPqRsTuVw")
}
@Test("passes through unchanged when no base URL is configured")
func passesThroughWithoutBase() {
let share = URL(string: "https://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw#Title")!
- #expect(ShareLinkShortener.shortURL(for: share, baseURL: nil) == share)
+ #expect(ShareLinkShortener.shortURL(for: share, title: "Title", baseURL: nil) == share)
}
@Test(
@@ -47,7 +77,7 @@ struct ShareLinkShortenerTests {
)
func passesThroughForeignURLs(raw: String) {
let url = URL(string: raw)!
- #expect(ShareLinkShortener.shortURL(for: url, baseURL: base) == url)
+ #expect(ShareLinkShortener.shortURL(for: url, title: nil, baseURL: base) == url)
}
@Test("rejects tokens outside the unreserved charset or length bounds")
@@ -55,9 +85,9 @@ struct ShareLinkShortenerTests {
// The encoded slash decodes into the last path component; passing it
// through to the worker would change the redirect target's shape.
let smuggled = URL(string: "https://www.icloud.com/share/0a1BcDeF%2F..%2FbAd")!
- #expect(ShareLinkShortener.shortURL(for: smuggled, baseURL: base) == smuggled)
+ #expect(ShareLinkShortener.shortURL(for: smuggled, title: nil, baseURL: base) == smuggled)
let tooShort = URL(string: "https://www.icloud.com/share/0a1Bc")!
- #expect(ShareLinkShortener.shortURL(for: tooShort, baseURL: base) == tooShort)
+ #expect(ShareLinkShortener.shortURL(for: tooShort, title: nil, baseURL: base) == tooShort)
}
}
diff --git a/Workers/link-worker.js b/Workers/link-worker.js
@@ -5,13 +5,16 @@
//
// into
//
-// https://<this worker>/s/<token>
+// https://<this worker>/s/<token>[/<base64url game title>]
//
// so that the link people actually paste into chat is short, carries no
-// puzzle title, and serves Crossmate's own Open Graph metadata instead of
-// iCloud's. Link-preview crawlers get a 200 HTML page with OG tags; everyone
-// else gets a 302 straight to iCloud. The share token is the entire state,
-// so nothing is stored and a link can never expire on this side.
+// legible puzzle title, and serves Crossmate's own Open Graph metadata
+// instead of iCloud's. Link-preview crawlers get a 200 HTML page with OG
+// tags; everyone else gets a 302 straight to iCloud. The share token is the
+// entire state, so nothing is stored and a link can never expire on this
+// side. The optional second segment exists only to personalise the preview
+// title — it is ignored by the redirect, so a link with a stale or mangled
+// title still lands on the right share.
// Bundled by the Data module rule in wrangler.link.toml; served at /og.png.
import ogImage from "./og.png";
@@ -24,6 +27,13 @@ const ICLOUD_SHARE_BASE = "https://www.icloud.com/share/";
// into https://www.icloud.com/share/, never act as an open redirector.
const TOKEN_PATTERN = /^[A-Za-z0-9._~-]{8,128}$/;
+// The optional title segment is unpadded base64url, encoded by
+// ShareLinkShortener in the app. The cap bounds the decode work and the
+// rendered tag; anything that fails the pattern or the decode falls back to
+// the generic preview rather than erroring.
+const TITLE_SEGMENT_PATTERN = /^[A-Za-z0-9_-]{1,256}$/;
+const MAX_TITLE_LENGTH = 80;
+
// Apple Messages fetches previews with a Facebook/Twitter crawler UA, so the
// explicit names cover iMessage as well. The generic bot/crawler/spider tail
// catches the long tail of preview fetchers; a human misclassified as a bot
@@ -55,7 +65,7 @@ export default {
});
}
- const match = url.pathname.match(/^\/s\/([^/]+)$/);
+ const match = url.pathname.match(/^\/s\/([^/]+)(?:\/([^/]+))?$/);
if (!match) {
return new Response("Not found", { status: 404 });
}
@@ -79,7 +89,7 @@ export default {
});
}
- return new Response(previewPage(url.origin, token, target), {
+ return new Response(previewPage(url.origin, token, target, match[2]), {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
@@ -89,24 +99,29 @@ export default {
}
};
-function previewPage(origin, token, target) {
- const title = "Solve a crossword together on Crossmate";
+function previewPage(origin, token, target, encodedTitle) {
+ const gameTitle = decodeTitle(encodedTitle);
+ const title = gameTitle
+ ? `Solve ‘${gameTitle}’ together on Crossmate`
+ : "Solve a crossword together on Crossmate";
const description =
"You’re invited to a collaborative crossword. " +
"Open the link to join the puzzle in Crossmate.";
- // The meta refresh and visible link are a fallback for anything the UA
- // sniff misclassifies as a crawler: real crawlers ignore the refresh and
- // read the OG tags, while a human lands on iCloud after a beat.
+ // og:url stays the token-only canonical form so scrapers collapse links
+ // that differ only in their title segment onto the same card. The meta
+ // refresh and visible link are a fallback for anything the UA sniff
+ // misclassifies as a crawler: real crawlers ignore the refresh and read
+ // the OG tags, while a human lands on iCloud after a beat.
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>${title}</title>
+<title>${escapeHTML(title)}</title>
<meta property="og:type" content="website">
<meta property="og:site_name" content="Crossmate">
-<meta property="og:title" content="${title}">
+<meta property="og:title" content="${escapeHTML(title)}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${origin}/og.png">
<meta property="og:url" content="${origin}/s/${token}">
@@ -119,3 +134,31 @@ function previewPage(origin, token, target) {
</html>
`;
}
+
+// The decoded title lands inside HTML the worker serves, so it is treated as
+// hostile input end to end: charset-checked while encoded, decoded strictly
+// (an invalid UTF-8 sequence falls back to the generic preview), stripped of
+// control characters, capped, and entity-escaped at the interpolation site.
+function decodeTitle(encoded) {
+ if (!encoded || !TITLE_SEGMENT_PATTERN.test(encoded)) {
+ return null;
+ }
+ try {
+ const base64 = encoded.replaceAll("-", "+").replaceAll("_", "/");
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
+ const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
+ const cleaned = text.replace(/[\x00-\x1f\x7f]/g, "").trim();
+ return cleaned ? cleaned.slice(0, MAX_TITLE_LENGTH) : null;
+ } catch {
+ return null;
+ }
+}
+
+function escapeHTML(text) {
+ return text
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}