ShareLinkShortener.swift (5467B)
1 import Foundation 2 3 /// Rewrites a game's CKShare URL into the short link served by the Crossmate 4 /// link worker (`Workers/link-worker.js`). The worker 302-redirects 5 /// recipients to iCloud and serves Crossmate's own Open Graph metadata to 6 /// link-preview crawlers, so shared links carry a Crossmate preview instead 7 /// of iCloud's. The iCloud share token is the only state, making the rewrite 8 /// purely local: no network call, and the short link stays valid for exactly 9 /// as long as the share itself. 10 /// 11 /// Only links handed to people (the share sheet's copy/send actions) are 12 /// shortened. URLs that the app consumes programmatically — the invite Ping's 13 /// `gameShareURL`, anything fed to `CKFetchShareMetadataOperation` — must 14 /// remain raw iCloud URLs. 15 enum ShareLinkShortener { 16 17 /// Custom titles are capped well below the old 80 so the link stays short; 18 /// a longer puzzle name is truncated rather than bloating the URL. 19 static let maxCustomTitleLength = 32 20 21 /// The English weekday names that, followed by " Crossword", form the NYT 22 /// puzzle titles `NYTToXDConverter` emits. A title matching one of these is 23 /// sent as its single-digit index instead of a base64url blob — the common 24 /// case (`"Monday Crossword"` → `1`) collapses to one character. 25 static let weekdayTitles = [ 26 "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" 27 ] 28 29 /// The link worker's origin, from `CrossmateShareLinkBaseURL` in 30 /// Info.plist (filled by `CROSSMATE_SHARE_LINK_BASE_URL` in 31 /// `Local.xcconfig`). `nil` on a checkout without the setting, in which 32 /// case the raw iCloud URL is shared unchanged. 33 static let configuredBaseURL: URL? = { 34 guard 35 let raw = Bundle.main.object(forInfoDictionaryKey: "CrossmateShareLinkBaseURL") as? String, 36 case let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines), 37 !trimmed.isEmpty 38 else { return nil } 39 return URL(string: trimmed) 40 }() 41 42 static func shortURL(for shareURL: URL, title: String?, shape: GridSilhouette.Grid? = nil) -> URL { 43 shortURL(for: shareURL, title: title, shape: shape, baseURL: configuredBaseURL) 44 } 45 46 /// Returns `shareURL` unchanged whenever it does not look like a CKShare 47 /// link the worker is known to handle, so an unexpected URL shape from 48 /// CloudKit degrades to sharing the working raw link rather than minting 49 /// a short link that 404s. 50 /// 51 /// The optional title and grid-silhouette segments are both decoration the 52 /// worker uses only for the link preview; each is self-typed by its leading 53 /// character (`t`/digit for the title, `s`/`f` for the shape), so the worker 54 /// can tell them apart regardless of which are present. Neither affects the 55 /// redirect, which keys on the token alone. 56 static func shortURL(for shareURL: URL, title: String?, shape: GridSilhouette.Grid?, baseURL: URL?) -> URL { 57 guard let baseURL, let token = shareToken(from: shareURL) else { 58 return shareURL 59 } 60 var path = "s/\(token)" 61 if let titleSegment = encodedTitle(title) { 62 path += "/\(titleSegment)" 63 } 64 if let shape, 65 let shapeSegment = GridSilhouette.encode(width: shape.width, height: shape.height, blocks: shape.blocks) { 66 path += "/\(shapeSegment)" 67 } 68 return baseURL.appending(path: path) 69 } 70 71 /// The game title rides the short link as an extra path segment so the 72 /// worker can personalise the link preview. A standard NYT `"<Weekday> 73 /// Crossword"` title becomes a single digit; any other title is unpadded 74 /// base64url under a `t` tag, keeping it an opaque blob rather than a 75 /// legible puzzle name. The worker treats the segment as decoration: it 76 /// never affects where the link redirects. 77 private static func encodedTitle(_ title: String?) -> String? { 78 let trimmed = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) 79 guard !trimmed.isEmpty else { return nil } 80 if let weekday = weekdayTitles.firstIndex(where: { "\($0) Crossword" == trimmed }) { 81 return String(weekday) 82 } 83 let capped = String(trimmed.prefix(maxCustomTitleLength)) 84 return "t" + GridSilhouette.base64URLEncode([UInt8](capped.utf8)) 85 } 86 87 /// Extracts the token from `https://www.icloud.com/share/<token>#…`. The 88 /// title-slug fragment is deliberately dropped — keeping a legible 89 /// puzzle title out of the shared URL is part of the point of the short 90 /// link, and iCloud's acceptance flow keys on the token alone. 91 private static func shareToken(from url: URL) -> String? { 92 guard 93 url.scheme == "https", 94 let host = url.host(), 95 host == "www.icloud.com" || host == "icloud.com", 96 url.pathComponents.count == 3, 97 url.pathComponents[1] == "share" 98 else { return nil } 99 100 // Mirrors the worker's token validation: RFC 3986 unreserved 101 // characters only, so the token can never smuggle path or query 102 // structure into either URL. 103 let token = url.pathComponents[2] 104 guard 105 (8...128).contains(token.count), 106 token.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || "._~-".contains($0)) }) 107 else { return nil } 108 return token 109 } 110 }