| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { data } from "react-router"; |
| 3 | |
| 4 | import { notifyClient, type NotifyPreferencesChange, type PresenceChange, type PushDevice, type PushSubscriptionJson } from "@g1t/contracts"; |
| 5 | import type { SoundSettingsChange } from "@g1t/contracts/sounds"; |
| 6 | |
| 7 | import type { Route } from "./+types/api"; |
| 8 | import { repoFor } from "../../lib/access.server"; |
| 9 | import { identity } from "../../lib/services.server"; |
| 10 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; |
| 11 | |
| 12 | function client() { |
| 13 | if (!env.NOTIFY) throw data({ error: "Notifications are not set up here." }, { status: 503 }); |
| 14 | return notifyClient(env.NOTIFY); |
| 15 | } |
| 16 | |
| 17 | /** |
| 18 | * The signed-in person's notification settings, as JSON: `GET` their |
| 19 | * status (with `?endpoint=` for whether this browser gets pushes, and how |
| 20 | * many phones do), `POST` a change: `subscribe`, `unsubscribe`, |
| 21 | * `register_device` and `unregister_device` (the phone app, with its Expo |
| 22 | * push token), `preferences`, `test`, `presence` (your status, being away, |
| 23 | * Do Not Disturb), `sounds` (what you hear when chat moves, kept with |
| 24 | * the account by identity), or `watch_repo`, for a repository page that |
| 25 | * updates live: its tabs then hear what changes there (`repo`, its |
| 26 | * `owner/name`), once the person is found to be able to read it |
| 27 | * (lib/repo-live.ts). |
| 28 | * |
| 29 | * The phone app calls this with its access token and no `Origin`, which |
| 30 | * the same-origin check lets through (lib/same-origin.ts). |
| 31 | */ |
| 32 | export async function loader({ context, request }: Route.LoaderArgs) { |
| 33 | const user = requireUser(context, request); |
| 34 | const endpoint = new URL(request.url).searchParams.get("endpoint"); |
| 35 | const status = await client().status(user, endpoint); |
| 36 | return Response.json(status, { headers: { "cache-control": "no-store" } }); |
| 37 | } |
| 38 | |
| 39 | type Sent = { |
| 40 | intent?: string; |
| 41 | subscription?: PushSubscriptionJson & { expirationTime?: number | null }; |
| 42 | endpoint?: string; |
| 43 | device?: PushDevice; |
| 44 | token?: string; |
| 45 | preferences?: NotifyPreferencesChange; |
| 46 | change?: PresenceChange | SoundSettingsChange; |
| 47 | repo?: string; |
| 48 | }; |
| 49 | |
| 50 | export async function action({ context, request }: Route.ActionArgs) { |
| 51 | assertSameOrigin(request); |
| 52 | const user = requireUser(context, request); |
| 53 | const sent = (await request.json().catch(() => ({}))) as Sent; |
| 54 | const notify = client(); |
| 55 | switch (sent.intent) { |
| 56 | case "subscribe": { |
| 57 | const s = sent.subscription; |
| 58 | if (!s?.endpoint || !s.keys?.p256dh || !s.keys?.auth) return Response.json({ ok: false }, { status: 400 }); |
| 59 | const subscription: PushSubscriptionJson = { endpoint: s.endpoint, keys: { p256dh: s.keys.p256dh, auth: s.keys.auth } }; |
| 60 | return Response.json(await notify.subscribe(user, subscription, request.headers.get("user-agent"))); |
| 61 | } |
| 62 | case "unsubscribe": |
| 63 | return Response.json(await notify.unsubscribe(user, String(sent.endpoint ?? ""))); |
| 64 | case "register_device": { |
| 65 | const d = sent.device; |
| 66 | if (typeof d?.token !== "string" || (d.platform !== "android" && d.platform !== "ios")) return Response.json({ ok: false }, { status: 400 }); |
| 67 | const device: PushDevice = { token: d.token, platform: d.platform, name: typeof d.name === "string" ? d.name : null }; |
| 68 | const registered = await notify.registerDevice(user, device); |
| 69 | // The service checks the token is Expo's; `off` is this g1t sending phones nothing (PHONE_PUSH=off), not a mistake. |
| 70 | return Response.json(registered, { status: registered.ok || registered.off ? 200 : 400 }); |
| 71 | } |
| 72 | case "unregister_device": |
| 73 | return Response.json(await notify.unregisterDevice(user, String(sent.token ?? ""))); |
| 74 | case "preferences": |
| 75 | return Response.json(await notify.setPreferences(user, sent.preferences ?? {})); |
| 76 | case "test": |
| 77 | return Response.json(await notify.test(user)); |
| 78 | case "sounds": { |
| 79 | const saved = await identity.setSoundSettings(user, (sent.change ?? {}) as SoundSettingsChange); |
| 80 | if (!saved.ok) return Response.json({ ok: false, error: saved.error.message }, { status: 400 }); |
| 81 | return Response.json(saved.value); |
| 82 | } |
| 83 | case "presence": { |
| 84 | // Set by hand here: a calendar's or an integration's comes through their own door. |
| 85 | const change = (sent.change ?? {}) as PresenceChange; |
| 86 | const status = change.status ? { ...change.status, source: "manual" as const } : change.status; |
| 87 | return Response.json(await notify.setPresence(user, { ...change, ...("status" in change ? { status } : {}) })); |
| 88 | } |
| 89 | case "watch_repo": { |
| 90 | // Only a repository they can read: the same lookup its pages make. |
| 91 | const [owner, name, ...rest] = String(sent.repo ?? "").split("/"); |
| 92 | if (!owner || !name || rest.length) return Response.json({ ok: false }, { status: 400 }); |
| 93 | const found = await repoFor(context, { owner, repo: name }); |
| 94 | if (!found.ok) return Response.json({ ok: false }, { status: 404 }); |
| 95 | const repo = found.value; |
| 96 | const watched = await notify.watchRepo(user, repo.id, `${repo.namespace}/${repo.name}`); |
| 97 | return Response.json({ ...watched, repo_id: repo.id }); |
| 98 | } |
| 99 | default: |
| 100 | return Response.json({ ok: false }, { status: 400 }); |
| 101 | } |
| 102 | } |