crossmate

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

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 }