| 1 | import { readHostedPairingRequest } from "@t3tools/shared/remote"; |
| 2 | import * as Schema from "effect/Schema"; |
| 3 | |
| 4 | const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; |
| 5 | |
| 6 | function isIpLiteral(host: string): boolean { |
| 7 | try { |
| 8 | const hostname = new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, ""); |
| 9 | if (hostname.includes(":")) return true; |
| 10 | |
| 11 | const octets = hostname.split("."); |
| 12 | return ( |
| 13 | octets.length === 4 && |
| 14 | octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) |
| 15 | ); |
| 16 | } catch { |
| 17 | return false; |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | export class PairingQrPayloadEmptyError extends Schema.TaggedError<PairingQrPayloadEmptyError>()( |
| 22 | "PairingQrPayloadEmptyError", |
| 23 | {}, |
| 24 | ) { |
| 25 | override get message(): string { |
| 26 | return "Scanned QR code did not contain a pairing URL."; |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | export function buildPairingUrl(host: string, code: string): string { |
| 31 | const h = host.trim(); |
| 32 | const c = code.trim(); |
| 33 | if (!h) return ""; |
| 34 | if (!c) return h; |
| 35 | |
| 36 | try { |
| 37 | const url = new URL(h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`); |
| 38 | url.hash = new URLSearchParams([["token", c]]).toString(); |
| 39 | return url.toString(); |
| 40 | } catch { |
| 41 | return `${h}#token=${c}`; |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | export function parsePairingUrl(url: string): { host: string; code: string } { |
| 46 | const trimmed = url.trim(); |
| 47 | if (!trimmed) return { host: "", code: "" }; |
| 48 | |
| 49 | try { |
| 50 | const parsed = new URL(trimmed); |
| 51 | const hostedPairingRequest = readHostedPairingRequest(parsed); |
| 52 | if (hostedPairingRequest) { |
| 53 | return { |
| 54 | host: hostedPairingRequest.host.replace(/\/$/, ""), |
| 55 | code: hostedPairingRequest.token, |
| 56 | }; |
| 57 | } |
| 58 | |
| 59 | const hashParams = new URLSearchParams(parsed.hash.slice(1)); |
| 60 | const hashToken = hashParams.get("token"); |
| 61 | const queryToken = parsed.searchParams.get("token"); |
| 62 | const code = hashToken || queryToken || ""; |
| 63 | |
| 64 | parsed.hash = ""; |
| 65 | parsed.search = ""; |
| 66 | parsed.pathname = parsed.pathname.replace(/\/pair\/?$/, "/"); |
| 67 | return { host: parsed.toString().replace(/\/$/, ""), code }; |
| 68 | } catch { |
| 69 | return { host: trimmed, code: "" }; |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | export function extractPairingUrlFromQrPayload(payload: string): string { |
| 74 | const trimmed = payload.trim(); |
| 75 | if (!trimmed) { |
| 76 | throw new PairingQrPayloadEmptyError({}); |
| 77 | } |
| 78 | |
| 79 | try { |
| 80 | const url = new URL(trimmed); |
| 81 | if (url.protocol === "t3code:") { |
| 82 | const pairingUrl = url.searchParams.get(MOBILE_PAIRING_URL_PARAM)?.trim() ?? ""; |
| 83 | if (pairingUrl.length > 0) { |
| 84 | return pairingUrl; |
| 85 | } |
| 86 | } |
| 87 | } catch { |
| 88 | // Treat non-URL payloads as raw pairing-url text so the normal input validation can decide. |
| 89 | } |
| 90 | |
| 91 | return trimmed; |
| 92 | } |