crossmate

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

push-worker.js (9632B)


      1 export class PushRegistry {
      2   constructor(state, env) {
      3     this.state = state;
      4     this.env = env;
      5     this.cachedJWT = null;
      6     this.cachedJWTExpiresAt = 0;
      7   }
      8 
      9   async fetch(request) {
     10     const url = new URL(request.url);
     11     const auth = this.authenticate(request);
     12     if (!auth.ok) {
     13       return new Response(auth.message, { status: auth.status });
     14     }
     15 
     16     if (url.pathname === "/register" && request.method === "POST") {
     17       return this.handleRegister(request);
     18     }
     19     if (url.pathname === "/register" && request.method === "DELETE") {
     20       return this.handleUnregister(request);
     21     }
     22     if (url.pathname === "/publish" && request.method === "POST") {
     23       return this.handlePublish(request);
     24     }
     25     return new Response("Not found", { status: 404 });
     26   }
     27 
     28   authenticate(request) {
     29     const header = request.headers.get("Authorization") || "";
     30     const expected = `Bearer ${this.env.PUSH_BEARER || ""}`;
     31     if (!this.env.PUSH_BEARER) {
     32       return { ok: false, status: 500, message: "Worker missing PUSH_BEARER" };
     33     }
     34     if (!timingSafeEqual(header, expected)) {
     35       return { ok: false, status: 401, message: "Bad bearer" };
     36     }
     37     return { ok: true };
     38   }
     39 
     40   async handleRegister(request) {
     41     const body = await readJSON(request);
     42     if (!body) return badRequest("Body must be JSON");
     43     const { deviceID, token, environment, addresses } = body;
     44     if (!deviceID || !token || !Array.isArray(addresses)) {
     45       return badRequest("deviceID, token, addresses required");
     46     }
     47     if (environment !== "sandbox" && environment !== "production") {
     48       return badRequest("environment must be 'sandbox' or 'production'");
     49     }
     50     // Bind this device's APNs token to each per-(account, game) address it
     51     // knows. The address is the lookup key; identity never reaches the worker.
     52     const updatedAt = Date.now();
     53     for (const address of addresses) {
     54       if (typeof address !== "string" || address.length === 0) continue;
     55       await this.state.storage.put(`addr:${address}:${deviceID}`, {
     56         token,
     57         environment,
     58         updatedAt
     59       });
     60     }
     61     return new Response(null, { status: 204 });
     62   }
     63 
     64   async handleUnregister(request) {
     65     const body = await readJSON(request);
     66     if (!body) return badRequest("Body must be JSON");
     67     const { deviceID, addresses } = body;
     68     if (!deviceID || !Array.isArray(addresses)) {
     69       return badRequest("deviceID and addresses required");
     70     }
     71     for (const address of addresses) {
     72       if (typeof address !== "string" || address.length === 0) continue;
     73       await this.state.storage.delete(`addr:${address}:${deviceID}`);
     74     }
     75     return new Response(null, { status: 204 });
     76   }
     77 
     78   async handlePublish(request) {
     79     const body = await readJSON(request);
     80     if (!body) return badRequest("Body must be JSON");
     81     const {
     82       kind,
     83       addressees,
     84       gameID,
     85       fromAuthorID,
     86       senderDeviceID,
     87       readAt,
     88       title,
     89       alertBody,
     90       background
     91     } = body;
     92     if (!kind || !Array.isArray(addressees) || addressees.length === 0) {
     93       return badRequest("kind and non-empty addressees required");
     94     }
     95 
     96     const targets = await this.resolveTargets(addressees, senderDeviceID);
     97     if (targets.length === 0) {
     98       return Response.json({ delivered: 0, removed: 0, failed: 0 });
     99     }
    100 
    101     let delivered = 0;
    102     let removed = 0;
    103     let failed = 0;
    104     for (const target of targets) {
    105       const result = await this.sendOne(target, {
    106         kind,
    107         gameID,
    108         fromAuthorID,
    109         senderDeviceID,
    110         readAt,
    111         title,
    112         body: target.body || alertBody,
    113         payload: target.payload,
    114         background: background === true
    115       });
    116       if (result === "ok") delivered += 1;
    117       else if (result === "drop") {
    118         await this.state.storage.delete(`addr:${target.address}:${target.deviceID}`);
    119         removed += 1;
    120       } else {
    121         failed += 1;
    122       }
    123     }
    124     return Response.json({ delivered, removed, failed });
    125   }
    126 
    127   async resolveTargets(addressees, senderDeviceID) {
    128     const targets = [];
    129     for (const addressee of addressees) {
    130       if (!addressee || !addressee.address) continue;
    131       const body = typeof addressee.body === "string" ? addressee.body : undefined;
    132       // Opaque, app-encoded semantics (base64 JSON of `PushPayload`). The
    133       // worker never inspects it — it just forwards it into the APNs userInfo
    134       // so the notification service extension can decode it. Keeping it opaque
    135       // is what lets the app evolve notification meaning without a worker deploy.
    136       const payload = typeof addressee.payload === "string" ? addressee.payload : undefined;
    137       const prefix = `addr:${addressee.address}:`;
    138       const map = await this.state.storage.list({ prefix });
    139       for (const [key, value] of map) {
    140         const deviceID = key.slice(prefix.length);
    141         if (senderDeviceID && deviceID === senderDeviceID) continue;
    142         targets.push({
    143           address: addressee.address,
    144           deviceID,
    145           body,
    146           payload,
    147           ...value
    148         });
    149       }
    150     }
    151     return targets;
    152   }
    153 
    154   async sendOne(target, message) {
    155     const topic = this.env.APNS_TOPIC || "net.inqk.crossmate";
    156     const host = target.environment === "sandbox"
    157       ? "api.sandbox.push.apple.com"
    158       : "api.push.apple.com";
    159     const jwt = await this.providerJWT();
    160     const alert = {};
    161     if (message.title) alert.title = message.title;
    162     if (message.body) alert.body = message.body;
    163     const apnsPayload = {
    164       aps: message.background
    165         ? { "content-available": 1 }
    166         : { alert, sound: "default", "mutable-content": 1 },
    167       kind: message.kind
    168     };
    169     if (message.gameID) apnsPayload.gameID = message.gameID;
    170     if (message.fromAuthorID) apnsPayload.fromAuthorID = message.fromAuthorID;
    171     if (message.senderDeviceID) apnsPayload.senderDeviceID = message.senderDeviceID;
    172     if (message.readAt) apnsPayload.readAt = message.readAt;
    173     // Forward the opaque app payload verbatim when present. Absent for older
    174     // app builds, which the extension handles by falling back to `kind`.
    175     if (message.payload) apnsPayload.payload = message.payload;
    176 
    177     const response = await fetch(`https://${host}/3/device/${target.token}`, {
    178       method: "POST",
    179       headers: {
    180         authorization: `bearer ${jwt}`,
    181         "apns-topic": topic,
    182         "apns-push-type": message.background ? "background" : "alert",
    183         "apns-priority": message.background ? "5" : "10",
    184         "apns-expiration": "0",
    185         "content-type": "application/json"
    186       },
    187       body: JSON.stringify(apnsPayload)
    188     });
    189 
    190     if (response.status === 200) return "ok";
    191     if (response.status === 410) return "drop";
    192     if (response.status === 400) {
    193       const text = await response.text();
    194       if (text.includes("BadDeviceToken") || text.includes("DeviceTokenNotForTopic")) {
    195         return "drop";
    196       }
    197     }
    198     return "fail";
    199   }
    200 
    201   async providerJWT() {
    202     const nowSeconds = Math.floor(Date.now() / 1000);
    203     if (this.cachedJWT && nowSeconds < this.cachedJWTExpiresAt - 60) {
    204       return this.cachedJWT;
    205     }
    206     const jwt = await signProviderJWT({
    207       keyPEM: this.env.APNS_KEY,
    208       keyID: this.env.APNS_KEY_ID,
    209       teamID: this.env.APNS_TEAM_ID,
    210       issuedAt: nowSeconds
    211     });
    212     this.cachedJWT = jwt;
    213     // Refresh well before APNs' 1-hour ceiling; the rate-limit floor is ~20 min.
    214     this.cachedJWTExpiresAt = nowSeconds + 40 * 60;
    215     return jwt;
    216   }
    217 }
    218 
    219 export default {
    220   async fetch(request, env) {
    221     const url = new URL(request.url);
    222     if (url.pathname === "/health") {
    223       return new Response("ok");
    224     }
    225     const id = env.PUSH_REGISTRY.idFromName("registry");
    226     return env.PUSH_REGISTRY.get(id).fetch(request);
    227   }
    228 };
    229 
    230 async function readJSON(request) {
    231   try {
    232     return await request.json();
    233   } catch {
    234     return null;
    235   }
    236 }
    237 
    238 function badRequest(message) {
    239   return new Response(message, { status: 400 });
    240 }
    241 
    242 async function signProviderJWT({ keyPEM, keyID, teamID, issuedAt }) {
    243   if (!keyPEM || !keyID || !teamID) {
    244     throw new Error("APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID must all be set");
    245   }
    246   const key = await importP8(keyPEM);
    247   const header = base64URLEncode(new TextEncoder().encode(JSON.stringify({
    248     alg: "ES256",
    249     kid: keyID
    250   })));
    251   const claims = base64URLEncode(new TextEncoder().encode(JSON.stringify({
    252     iss: teamID,
    253     iat: issuedAt
    254   })));
    255   const signingInput = `${header}.${claims}`;
    256   const signature = await crypto.subtle.sign(
    257     { name: "ECDSA", hash: "SHA-256" },
    258     key,
    259     new TextEncoder().encode(signingInput)
    260   );
    261   return `${signingInput}.${base64URLEncode(new Uint8Array(signature))}`;
    262 }
    263 
    264 async function importP8(pem) {
    265   const stripped = pem
    266     .replace(/-----BEGIN PRIVATE KEY-----/g, "")
    267     .replace(/-----END PRIVATE KEY-----/g, "")
    268     .replace(/\s+/g, "");
    269   const der = Uint8Array.from(atob(stripped), (char) => char.charCodeAt(0));
    270   return crypto.subtle.importKey(
    271     "pkcs8",
    272     der,
    273     { name: "ECDSA", namedCurve: "P-256" },
    274     false,
    275     ["sign"]
    276   );
    277 }
    278 
    279 function base64URLEncode(bytes) {
    280   let binary = "";
    281   for (const byte of bytes) {
    282     binary += String.fromCharCode(byte);
    283   }
    284   return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
    285 }
    286 
    287 function timingSafeEqual(a, b) {
    288   const left = new TextEncoder().encode(a);
    289   const right = new TextEncoder().encode(b);
    290   if (left.length !== right.length) return false;
    291   let diff = 0;
    292   for (let index = 0; index < left.length; index += 1) {
    293     diff |= left[index] ^ right[index];
    294   }
    295   return diff === 0;
    296 }