crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 700a507b31c60d1fe1efa2c5dfa2be63a05ab003
parent f3bc3a93131bb96e30c7edffc6285ae212c0c290
Author: Michael Camilleri <[email protected]>
Date:   Sat, 13 Jun 2026 08:17:07 +0900

Serve share links through a link-shortening worker

This commit adds a third Cloudflare Worker, crossmate-link, and routes
the share sheet's public links through it. A copied link previously
exposed the raw iCloud share URL, so chat apps rendered iCloud's generic
preview and the pasted text carried the puzzle title as a '#Title_Slug'
fragment. The short form is now 'https://crossmate.inqk.net/s/<token>':
link-preview crawlers receive a 200 page with Crossmate's own Open Graph
tags (Apple Messages fetches previews with a crawler user agent, so
iMessage is covered) while everyone else is 302-redirected to iCloud,
and no puzzle title appears anywhere in the URL.

The worker is a stateless pass-through. The app lifts the share token
out of the CKShare URL locally — no network call, no create API — and
the worker rebuilds the iCloud target by concatenation, so there is
nothing stored that could expire or need auth. Both sides restrict the
token to RFC 3986 unreserved characters, which is what keeps the
concatenation from acting as an open redirector, and any URL that fails
the checks degrades to sharing the working raw link. The preview image
is a copy of the app icon bundled into the worker as a wrangler Data
module, keeping the Workers directory flat. Invite Pings keep sending
raw iCloud URLs because the recipient feeds them to
CKFetchShareMetadataOperation rather than to a person.

The worker origin reaches the app through CrossmateShareLinkBaseURL in
Info.plist, filled from CROSSMATE_SHARE_LINK_BASE_URL in Local.xcconfig
like the push and engagement endpoints; a checkout without the setting
shares raw iCloud links unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 14++++++++++++++
MCrossmate/CrossmateApp.swift | 99-------------------------------------------------------------------------------
MCrossmate/Info.plist | 2++
ACrossmate/MarketingPuzzleScreenshotView.swift | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Services/ShareLinkShortener.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GameShareItem.swift | 7+++++--
AScripts/wrangle-link.sh | 7+++++++
ATests/Unit/ShareLinkShortenerTests.swift | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AWorkers/link-worker.js | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AWorkers/og.png | 0
AWorkers/wrangler.link.toml | 20++++++++++++++++++++
Mproject.yml | 2++
12 files changed, 400 insertions(+), 101 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; 3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */; }; 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */; }; + 41290C86E72D6567C43C31B7 /* ShareLinkShortenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */; }; 43E311FBD68B7D35A4D29743 /* PushPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */; }; 449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */; }; 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; }; @@ -78,6 +79,7 @@ 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; 7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; + 779D1955F350B507A47B1E5B /* ShareLinkShortener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B50A841D92D1F2B173E7DF /* ShareLinkShortener.swift */; }; 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; 7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */; }; 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507B4DC893CE8AC4778CBACE /* NotificationService.swift */; }; @@ -117,6 +119,7 @@ AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; }; AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */; }; AB05765D2C3F4841026344E5 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF633D73818BD59F759FAC4 /* AboutView.swift */; }; + AB6D98C7A78D91D7BEFB4A4C /* MarketingPuzzleScreenshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEFE2C7623A899BAABD85F4 /* MarketingPuzzleScreenshotView.swift */; }; AD50D3E3401C7BF9ED012768 /* AnnouncementBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */; }; AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; }; AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; }; @@ -209,6 +212,7 @@ /* Begin PBXFileReference section */ 03D59EA74A4BA084AD97478D /* AccountPushCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPushCoordinator.swift; sourceTree = "<group>"; }; + 057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkShortenerTests.swift; sourceTree = "<group>"; }; 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; }; 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; }; 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerPresenceGraceTests.swift; sourceTree = "<group>"; }; @@ -268,6 +272,7 @@ 50EE8A159CC553623F6F7DE4 /* ReplayControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayControls.swift; sourceTree = "<group>"; }; 51318FC5DAE02D35CB005729 /* NotificationService.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordBuilder.swift; sourceTree = "<group>"; }; + 52B50A841D92D1F2B173E7DF /* ShareLinkShortener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkShortener.swift; sourceTree = "<group>"; }; 56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; }; 5ABB557BA10CBE9909056882 /* GameShareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameShareItem.swift; sourceTree = "<group>"; }; 5C74683332956B0D1CA37589 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = "<group>"; }; @@ -302,6 +307,7 @@ 88E8AACB638FE5724B534B41 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplayTests.swift; sourceTree = "<group>"; }; 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushClient.swift; sourceTree = "<group>"; }; + 8AEFE2C7623A899BAABD85F4 /* MarketingPuzzleScreenshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketingPuzzleScreenshotView.swift; sourceTree = "<group>"; }; 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPushPlannerTests.swift; sourceTree = "<group>"; }; 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStore.swift; sourceTree = "<group>"; }; 8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCoordinator.swift; sourceTree = "<group>"; }; @@ -471,6 +477,7 @@ 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */, 847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */, 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */, + 057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */, 0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */, 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */, ABB371EF2574E95782CB05FD /* Sync */, @@ -526,6 +533,7 @@ 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */, 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */, 9447F0FE34C63810C6F1D8BE /* Info.plist */, + 8AEFE2C7623A899BAABD85F4 /* MarketingPuzzleScreenshotView.swift */, 0BF60C84D92A9024AC1A53FC /* Media.xcassets */, 41DB2417FF67A47FE6890256 /* Models */, 565DBAFC8DB2589B3F0AF90E /* Persistence */, @@ -661,6 +669,7 @@ 3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */, 7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */, CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */, + 52B50A841D92D1F2B173E7DF /* ShareLinkShortener.swift */, ); path = Services; sourceTree = "<group>"; @@ -847,6 +856,7 @@ C58F15CBEADA72032B54009D /* ReplayControlsTests.swift in Sources */, AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */, 07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */, + 41290C86E72D6567C43C31B7 /* ShareLinkShortenerTests.swift in Sources */, BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */, 025377AF80D45967CE910423 /* SyncMonitorTests.swift in Sources */, 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, @@ -915,6 +925,7 @@ F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */, 66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */, + AB6D98C7A78D91D7BEFB4A4C /* MarketingPuzzleScreenshotView.swift in Sources */, 91703E54DB4679C1911BF994 /* Moves.swift in Sources */, 4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */, 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */, @@ -956,6 +967,7 @@ B48DE7079BE2F31D2367C5F7 /* SessionPushPlanner.swift in Sources */, 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */, BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */, + 779D1955F350B507A47B1E5B /* ShareLinkShortener.swift in Sources */, AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */, 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */, 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */, @@ -1109,6 +1121,7 @@ CODE_SIGN_STYLE = Automatic; CROSSMATE_ENGAGEMENT_SOCKET_URL = "$(inherited)"; CROSSMATE_PUSH_BASE_URL = "$(inherited)"; + CROSSMATE_SHARE_LINK_BASE_URL = "$(inherited)"; INFOPLIST_FILE = Crossmate/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1132,6 +1145,7 @@ CODE_SIGN_STYLE = Automatic; CROSSMATE_ENGAGEMENT_SOCKET_URL = "$(inherited)"; CROSSMATE_PUSH_BASE_URL = "$(inherited)"; + CROSSMATE_SHARE_LINK_BASE_URL = "$(inherited)"; INFOPLIST_FILE = Crossmate/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -441,105 +441,6 @@ private extension UIApplication { } } -// MARK: - Marketing Screenshots - -@MainActor -private struct MarketingPuzzleScreenshotView: View { - @State private var session: PlayerSession - @State private var navigationPath: [UUID] - private let roster: PlayerRoster - private let gameID: UUID - - init(services: AppServices) { - let model = Self.makeModel() - _session = State(initialValue: model.session) - _navigationPath = State(initialValue: [model.gameID]) - self.gameID = model.gameID - self.roster = PlayerRoster( - previewGameID: model.gameID, - localName: services.preferences.name, - localColor: services.preferences.color, - remoteSelection: PlayerRoster.RemoteSelection( - authorID: "marketing-teammate", - row: 1, - col: 10, - direction: .down, - color: .red, - updatedAt: Date() - ) - ) - } - - var body: some View { - NavigationStack(path: $navigationPath) { - Color(.systemBackground) - .navigationDestination(for: UUID.self) { destination in - if destination == gameID { - PuzzleView( - session: session, - roster: roster, - loadReplay: { .unavailable } - ) - .navigationTitle("") - .navigationBarTitleDisplayMode(.inline) - } - } - } - .preferredColorScheme(.light) - } - - private static func makeModel() -> (session: PlayerSession, gameID: UUID) { - let source = """ - Title: Crossmate - Publisher: Collaborative Crossword App for iOS & iPadOS - CmVer: 3 - Author: Crossmate - - - ALTO#OGRE#PIPER - RAID#TREE#ADANA - MUNI#HOER#RAREE - ORACLEOFOMAHA## - RAS#ALMS#ADOBES - IT#OIL##FEE#ONO - BUCK#ROGER#PLAN - ###CROSSMATE### - ANAIS#ATA#ALICE - SIRE#RESURRECTS - SON#POL##AND### - ONEARM#SPCA#BAA - ##LEAPINLIZARDS - HAITI#PEAS#SEES - DANAE#ORES#ADES - - - A37. Possibly the best crossword app ~ CROSSMATE - """ - let puzzle = try! Puzzle(xd: XD.parse(source)) - let game = Game(puzzle: puzzle) - let gameID = UUID(uuidString: "43524F53-534D-4154-452D-53484F545321")! - let mutator = GameMutator( - game: game, - gameID: gameID, - movesUpdater: nil, - isShared: true - ) - let session = PlayerSession(game: game, mutator: mutator) - for (offset, letter) in Array("CROSSMA").enumerated() { - game.setLetter( - String(letter), - atRow: 7, - atCol: 3 + offset, - pencil: false - ) - } - session.direction = .across - session.selectedRow = 7 - session.selectedCol = 10 - return (session, gameID) - } -} - // MARK: - Game Destination /// Loads a game when navigated to. diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist @@ -44,6 +44,8 @@ <string>$(CROSSMATE_ENGAGEMENT_SOCKET_URL)</string> <key>CrossmatePushBaseURL</key> <string>$(CROSSMATE_PUSH_BASE_URL)</string> + <key>CrossmateShareLinkBaseURL</key> + <string>$(CROSSMATE_SHARE_LINK_BASE_URL)</string> <key>ITSAppUsesNonExemptEncryption</key> <false/> <key>LSRequiresIPhoneOS</key> diff --git a/Crossmate/MarketingPuzzleScreenshotView.swift b/Crossmate/MarketingPuzzleScreenshotView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +@MainActor +struct MarketingPuzzleScreenshotView: View { + @State private var session: PlayerSession + @State private var navigationPath: [UUID] + private let roster: PlayerRoster + private let gameID: UUID + + init(services: AppServices) { + let model = Self.makeModel() + _session = State(initialValue: model.session) + _navigationPath = State(initialValue: [model.gameID]) + self.gameID = model.gameID + self.roster = PlayerRoster( + previewGameID: model.gameID, + localName: services.preferences.name, + localColor: services.preferences.color, + remoteSelection: PlayerRoster.RemoteSelection( + authorID: "marketing-teammate", + row: 6, + col: 11, + direction: .down, + color: .red, + updatedAt: Date() + ) + ) + } + + var body: some View { + NavigationStack(path: $navigationPath) { + Color(.systemBackground) + .navigationDestination(for: UUID.self) { destination in + if destination == gameID { + PuzzleView( + session: session, + roster: roster, + loadReplay: { .unavailable } + ) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + } + } + } + .preferredColorScheme(.light) + } + + private static func makeModel() -> (session: PlayerSession, gameID: UUID) { + let source = """ + Title: Crossmate + Publisher: Collaborative Crossword App for iOS & iPadOS + CmVer: 3 + Author: Crossmate + + + ALTO#OGRE#PIPER + RAID#TREE#ADANA + MUNI#HOER#RAREE + ORACLEOFOMAHA## + RAS#ALMS#ADOBES + IT#OIL##FEE#ONO + BUCK#ROGER#PLAN + ###CROSSMATE### + ANAIS#ATA#ALICE + SIRE#RESURRECTS + SON#POL##AND### + ONEARM#SPCA#BAA + ##LEAPINLIZARDS + HAITI#PEAS#SEES + DANAE#ORES#ADES + + + A37. The best crossword app, perhaps ~ CROSSMATE + """ + let puzzle = try! Puzzle(xd: XD.parse(source)) + let game = Game(puzzle: puzzle) + let gameID = UUID(uuidString: "43524F53-534D-4154-452D-53484F545321")! + let mutator = GameMutator( + game: game, + gameID: gameID, + movesUpdater: nil, + isShared: true + ) + let session = PlayerSession(game: game, mutator: mutator) + for (offset, letter) in Array("CROSSMA").enumerated() { + game.setLetter( + String(letter), + atRow: 7, + atCol: 3 + offset, + pencil: false + ) + } + session.direction = .across + session.selectedRow = 7 + session.selectedCol = 10 + return (session, gameID) + } +} diff --git a/Crossmate/Services/ShareLinkShortener.swift b/Crossmate/Services/ShareLinkShortener.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Rewrites a game's CKShare URL into the short link served by the Crossmate +/// link worker (`Workers/link-worker.js`). The worker 302-redirects +/// recipients to iCloud and serves Crossmate's own Open Graph metadata to +/// link-preview crawlers, so shared links carry a Crossmate preview instead +/// of iCloud's. The iCloud share token is the only state, making the rewrite +/// purely local: no network call, and the short link stays valid for exactly +/// as long as the share itself. +/// +/// Only links handed to people (the share sheet's copy/send actions) are +/// shortened. URLs that the app consumes programmatically — the invite Ping's +/// `gameShareURL`, anything fed to `CKFetchShareMetadataOperation` — must +/// remain raw iCloud URLs. +enum ShareLinkShortener { + + /// The link worker's origin, from `CrossmateShareLinkBaseURL` in + /// Info.plist (filled by `CROSSMATE_SHARE_LINK_BASE_URL` in + /// `Local.xcconfig`). `nil` on a checkout without the setting, in which + /// case the raw iCloud URL is shared unchanged. + static let configuredBaseURL: URL? = { + guard + let raw = Bundle.main.object(forInfoDictionaryKey: "CrossmateShareLinkBaseURL") as? String, + case let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty + else { return nil } + return URL(string: trimmed) + }() + + static func shortURL(for shareURL: URL) -> URL { + shortURL(for: shareURL, 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 { + guard let baseURL, let token = shareToken(from: shareURL) else { + return shareURL + } + return baseURL.appending(path: "s/\(token)") + } + + /// 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. + private static func shareToken(from url: URL) -> String? { + guard + url.scheme == "https", + let host = url.host(), + host == "www.icloud.com" || host == "icloud.com", + url.pathComponents.count == 3, + url.pathComponents[1] == "share" + else { return nil } + + // Mirrors the worker's token validation: RFC 3986 unreserved + // characters only, so the token can never smuggle path or query + // structure into either URL. + let token = url.pathComponents[2] + guard + (8...128).contains(token.count), + token.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || "._~-".contains($0)) }) + else { return nil } + return token + } +} diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameShareItem.swift @@ -253,7 +253,8 @@ struct GameShareSheet: View { defer { isLoadingExistingLink = false } do { - shareURL = try await shareController.existingShareLink(for: gameID) + shareURL = (try await shareController.existingShareLink(for: gameID)) + .map { ShareLinkShortener.shortURL(for: $0) } } catch { errorMessage = describe(error) } @@ -267,7 +268,9 @@ struct GameShareSheet: View { defer { isCreating = false } do { - shareURL = try await shareController.createShareLink(for: gameID) + shareURL = ShareLinkShortener.shortURL( + for: try await shareController.createShareLink(for: gameID) + ) } catch { errorMessage = describe(error) } diff --git a/Scripts/wrangle-link.sh b/Scripts/wrangle-link.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +exec wrangler --config "$REPO_ROOT/Workers/wrangler.link.toml" "$@" diff --git a/Tests/Unit/ShareLinkShortenerTests.swift b/Tests/Unit/ShareLinkShortenerTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("Share link shortener") +struct ShareLinkShortenerTests { + + private let base = URL(string: "https://crossmate.example.net")! + + @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) + #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) + #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) + #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) + } + + @Test( + "passes through URLs that are not a CKShare link", + arguments: [ + "https://example.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw", + "http://www.icloud.com/share/0a1BcDeFgHiJkLmNoPqRsTuVw", + "https://www.icloud.com/notes/0a1BcDeFgHiJkLmNoPqRsTuVw", + "https://www.icloud.com/share", + "https://www.icloud.com/share/0a1BcDeF/extra" + ] + ) + func passesThroughForeignURLs(raw: String) { + let url = URL(string: raw)! + #expect(ShareLinkShortener.shortURL(for: url, baseURL: base) == url) + } + + @Test("rejects tokens outside the unreserved charset or length bounds") + func rejectsMalformedTokens() { + // 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) + + let tooShort = URL(string: "https://www.icloud.com/share/0a1Bc")! + #expect(ShareLinkShortener.shortURL(for: tooShort, baseURL: base) == tooShort) + } +} diff --git a/Workers/link-worker.js b/Workers/link-worker.js @@ -0,0 +1,121 @@ +// Crossmate link worker: a stateless pass-through shortener for CKShare +// links. The app rewrites an iCloud share URL +// +// https://www.icloud.com/share/<token>#<Title_Slug> +// +// into +// +// https://<this worker>/s/<token> +// +// 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. + +// Bundled by the Data module rule in wrangler.link.toml; served at /og.png. +import ogImage from "./og.png"; + +const ICLOUD_SHARE_BASE = "https://www.icloud.com/share/"; + +// iCloud share tokens are short base62-ish strings. Restricting the charset +// to RFC 3986 unreserved characters means the redirect target cannot contain +// a path separator, query, or authority — the worker can only ever redirect +// into https://www.icloud.com/share/, never act as an open redirector. +const TOKEN_PATTERN = /^[A-Za-z0-9._~-]{8,128}$/; + +// 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 +// still gets through via the page's meta refresh and visible link. +const CRAWLER_PATTERN = new RegExp( + [ + "facebookexternalhit", "facebot", "twitterbot", "slackbot", "discordbot", + "telegrambot", "whatsapp", "linkedinbot", "pinterest", "redditbot", + "applebot", "googlebot", "bingbot", "duckduckbot", "yandex", "mastodon", + "bluesky", "skypeuripreview", "iframely", "embedly", "snapchat", "vkshare", + "bot", "crawler", "spider", "preview" + ].join("|"), + "i" +); + +export default { + async fetch(request) { + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response("Method not allowed", { status: 405 }); + } + + const url = new URL(request.url); + if (url.pathname === "/og.png") { + return new Response(ogImage, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=86400" + } + }); + } + + const match = url.pathname.match(/^\/s\/([^/]+)$/); + if (!match) { + return new Response("Not found", { status: 404 }); + } + + const token = match[1]; + if (!TOKEN_PATTERN.test(token)) { + return new Response("Not found", { status: 404 }); + } + const target = ICLOUD_SHARE_BASE + token; + + const userAgent = request.headers.get("User-Agent") || ""; + if (!CRAWLER_PATTERN.test(userAgent)) { + // The token→target mapping is immutable, so the redirect can sit in + // the edge cache indefinitely; an hour keeps revalidation cheap. + return new Response(null, { + status: 302, + headers: { + "Location": target, + "Cache-Control": "public, max-age=3600" + } + }); + } + + return new Response(previewPage(url.origin, token, target), { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "public, max-age=3600" + } + }); + } +}; + +function previewPage(origin, token, target) { + const title = "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. + return `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>${title}</title> +<meta property="og:type" content="website"> +<meta property="og:site_name" content="Crossmate"> +<meta property="og:title" content="${title}"> +<meta property="og:description" content="${description}"> +<meta property="og:image" content="${origin}/og.png"> +<meta property="og:url" content="${origin}/s/${token}"> +<meta name="twitter:card" content="summary"> +<meta http-equiv="refresh" content="0; url=${target}"> +</head> +<body> +<p><a href="${target}">Open the puzzle in Crossmate</a></p> +</body> +</html> +`; +} diff --git a/Workers/og.png b/Workers/og.png Binary files differ. diff --git a/Workers/wrangler.link.toml b/Workers/wrangler.link.toml @@ -0,0 +1,20 @@ +name = "crossmate-link" +main = "link-worker.js" +compatibility_date = "2026-05-25" +# The hostname below is what recipients see in link previews, so the ugly +# workers.dev fallback stays off. The custom domain requires inqk.net to be a +# zone on the same Cloudflare account; the route is created on first deploy. +workers_dev = false +preview_urls = false + +routes = [ + { pattern = "crossmate.inqk.net", custom_domain = true } +] + +# The Open Graph preview image is bundled into the worker as a data module +# (see the import in link-worker.js) so the Workers directory stays flat +# instead of carrying a one-file static-assets subdirectory. +[[rules]] +type = "Data" +globs = ["**/*.png"] +fallthrough = true diff --git a/project.yml b/project.yml @@ -73,6 +73,7 @@ targets: CKSharingSupported: true CrossmateEngagementSocketURL: $(CROSSMATE_ENGAGEMENT_SOCKET_URL) CrossmatePushBaseURL: $(CROSSMATE_PUSH_BASE_URL) + CrossmateShareLinkBaseURL: $(CROSSMATE_SHARE_LINK_BASE_URL) CrossmateAPSEnvironment: $(APS_ENVIRONMENT) LSSupportsOpeningDocumentsInPlace: false UILaunchScreen: {} @@ -88,6 +89,7 @@ targets: CODE_SIGN_ENTITLEMENTS: Crossmate/Crossmate.entitlements CROSSMATE_ENGAGEMENT_SOCKET_URL: $(inherited) CROSSMATE_PUSH_BASE_URL: $(inherited) + CROSSMATE_SHARE_LINK_BASE_URL: $(inherited) APP_ATTEST_ENVIRONMENT: production TARGETED_DEVICE_FAMILY: "1,2" CODE_SIGN_STYLE: Automatic