| 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); `watch_repos` the same for several at once (`repos`, |
| 28 | * at most WATCH_REPOS_MAX: Home watches its workspace's). |
| 29 | * |
| 30 | * The phone app calls this with its access token and no `Origin`, which |
| 31 | * the same-origin check lets through (lib/same-origin.ts). |
| 32 | */ |
| 33 | export async function loader({ context, request }: Route.LoaderArgs) { |
| 34 | const user = requireUser(context, request); |
| 35 | const endpoint = new URL(request.url).searchParams.get("endpoint"); |
| 36 | const status = await client().status(user, endpoint); |
| 37 | return Response.json(status, { headers: { "cache-control": "no-store" } }); |
| 38 | } |
| 39 | |
| 40 | type Sent = { |
| 41 | intent?: string; |
| 42 | subscription?: PushSubscriptionJson & { expirationTime?: number | null }; |
| 43 | endpoint?: string; |
| 44 | device?: PushDevice; |
| 45 | token?: string; |
| 46 | preferences?: NotifyPreferencesChange; |
| 47 | change?: PresenceChange | SoundSettingsChange; |
| 48 | repo?: string; |
| 49 | repos?: unknown; |
| 50 | }; |
| 51 | |
| 52 | /** The most repositories one `watch_repos` asks for (notify keeps 50 per person). */ |
| 53 | const WATCH_REPOS_MAX = 25; |
| 54 | |
| 55 | /** Watches `path` (`owner/name`) for `user` when they can read it; the repository's id, or null. */ |
| 56 | async function watchOne(context: Route.ActionArgs["context"], notify: ReturnType<typeof client>, user: ReturnType<typeof requireUser>, path: string) { |
| 57 | // Only a repository they can read: the same lookup its pages make. |
| 58 | const [owner, name, ...rest] = path.split("/"); |
| 59 | if (!owner || !name || rest.length) return null; |
| 60 | const found = await repoFor(context, { owner, repo: name }); |
| 61 | if (!found.ok) return null; |
| 62 | const repo = found.value; |
| 63 | const watched = await notify.watchRepo(user, repo.id, `${repo.namespace}/${repo.name}`); |
| 64 | return { ...watched, repo_id: repo.id }; |
| 65 | } |
| 66 | |
| 67 | export async function action({ context, request }: Route.ActionArgs) { |
| 68 | assertSameOrigin(request); |
| 69 | const user = requireUser(context, request); |
| 70 | const sent = (await request.json().catch(() => ({}))) as Sent; |
| 71 | const notify = client(); |
| 72 | switch (sent.intent) { |
| 73 | case "subscribe": { |
| 74 | const s = sent.subscription; |
| 75 | if (!s?.endpoint || !s.keys?.p256dh || !s.keys?.auth) return Response.json({ ok: false }, { status: 400 }); |
| 76 | const subscription: PushSubscriptionJson = { endpoint: s.endpoint, keys: { p256dh: s.keys.p256dh, auth: s.keys.auth } }; |
| 77 | return Response.json(await notify.subscribe(user, subscription, request.headers.get("user-agent"))); |
| 78 | } |
| 79 | case "unsubscribe": |
| 80 | return Response.json(await notify.unsubscribe(user, String(sent.endpoint ?? ""))); |
| 81 | case "register_device": { |
| 82 | const d = sent.device; |
| 83 | if (typeof d?.token !== "string" || (d.platform !== "android" && d.platform !== "ios")) return Response.json({ ok: false }, { status: 400 }); |
| 84 | const device: PushDevice = { token: d.token, platform: d.platform, name: typeof d.name === "string" ? d.name : null }; |
| 85 | const registered = await notify.registerDevice(user, device); |
| 86 | // The service checks the token is Expo's; `off` is this g1t sending phones nothing (PHONE_PUSH=off), not a mistake. |
| 87 | return Response.json(registered, { status: registered.ok || registered.off ? 200 : 400 }); |
| 88 | } |
| 89 | case "unregister_device": |
| 90 | return Response.json(await notify.unregisterDevice(user, String(sent.token ?? ""))); |
| 91 | case "preferences": |
| 92 | return Response.json(await notify.setPreferences(user, sent.preferences ?? {})); |
| 93 | case "test": |
| 94 | return Response.json(await notify.test(user)); |
| 95 | case "sounds": { |
| 96 | const saved = await identity.setSoundSettings(user, (sent.change ?? {}) as SoundSettingsChange); |
| 97 | if (!saved.ok) return Response.json({ ok: false, error: saved.error.message }, { status: 400 }); |
| 98 | return Response.json(saved.value); |
| 99 | } |
| 100 | case "presence": { |
| 101 | // Set by hand here: a calendar's or an integration's comes through their own door. |
| 102 | const change = (sent.change ?? {}) as PresenceChange; |
| 103 | const status = change.status ? { ...change.status, source: "manual" as const } : change.status; |
| 104 | return Response.json(await notify.setPresence(user, { ...change, ...("status" in change ? { status } : {}) })); |
| 105 | } |
| 106 | case "watch_repo": { |
| 107 | const path = String(sent.repo ?? ""); |
| 108 | if (path.split("/").length !== 2) return Response.json({ ok: false }, { status: 400 }); |
| 109 | const watched = await watchOne(context, notify, user, path); |
| 110 | return watched ? Response.json(watched) : Response.json({ ok: false }, { status: 404 }); |
| 111 | } |
| 112 | case "watch_repos": { |
| 113 | const paths = Array.isArray(sent.repos) ? [...new Set(sent.repos.filter((path): path is string => typeof path === "string"))] : []; |
| 114 | if (paths.length === 0 || paths.length > WATCH_REPOS_MAX) return Response.json({ ok: false }, { status: 400 }); |
| 115 | const watched = (await Promise.all(paths.map((path) => watchOne(context, notify, user, path).catch(() => null)))).filter((one) => one?.ok); |
| 116 | return Response.json({ ok: watched.length > 0, repo_ids: watched.map((one) => one!.repo_id) }); |
| 117 | } |
| 118 | default: |
| 119 | return Response.json({ ok: false }, { status: 400 }); |
| 120 | } |
| 121 | } |