api.ts
⎇
Raw
1// The whole server contract, in one file.
2//
3// Types are hand-written to mirror the `Serialize` structs in
4// `crates/otserver/src/api.rs`. A Rust test (`api::tests::the_json_shape_is_the
5// _one_the_web_ui_expects`) asserts the exact JSON key set of every response
6// type, so a renamed field fails `cargo test` instead of failing in a browser.
7
8export interface Me {
9 id: number;
10 username: string;
11 display_name: string;
12 is_admin: boolean;
13 server_time: number;
14}
15
16export interface Position {
17 ts: number;
18 /** Degrees x 1e7. Integers end to end, so no float-formatting drift. */
19 lat_e7: number;
20 lon_e7: number;
21 acc_dm: number | null;
22 alt_m: number | null;
23 spd_cms: number | null;
24 brg_cdeg: number | null;
25 bat_pct: number | null;
26 flags: number;
27 recv_at: number;
28}
29
30export interface PersonState {
31 user_id: number;
32 display_name: string;
33 is_self: boolean;
34 position?: Position;
35}
36
37export interface StateResponse {
38 server_time: number;
39 people: PersonState[];
40}
41
42export interface TokenInfo {
43 /** A decimal string: a u64 does not fit exactly in a JS number. */
44 token_id: string;
45 name: string;
46 platform: string;
47 app_version: number | null;
48 os_api_level: number | null;
49 last_seen_at: number | null;
50 last_src_ip: string | null;
51 last_transport: string | null;
52 created_at: number;
53}
54
55export interface TrackResponse {
56 user_id: number;
57 from: number;
58 to: number;
59 polyline: string;
60 point_count: number;
61}
62
63export interface ShareInfo {
64 id: number;
65 viewer_user_id: number;
66 viewer_username: string;
67 viewer_display_name: string;
68 trail_visible: boolean;
69 precision_m: number;
70 expires_at: number | null;
71 created_at: number;
72}
73
74export interface CreateShare {
75 username: string;
76 trail_visible: boolean;
77 precision_m: number;
78 /** Seconds from now. `null` means the share does not expire. */
79 expires_in_s: number | null;
80}
81
82/** Point flag bits, matching `otproto::Point`. */
83export const FLAG_CHARGING = 1;
84export const FLAG_NETWORK_FIX = 2;
85export const FLAG_LOW_ACCURACY = 4;
86export const FLAG_MOCK = 8;
87
88export class ApiError extends Error {
89 constructor(
90 readonly status: number,
91 message: string,
92 ) {
93 super(message);
94 }
95}
96
97/** Thrown-away sentinel: the caller shows the login screen on a 401. */
98export const UNAUTHORIZED = 401;
99
100async function request<T>(method: string, path: string, body?: unknown, etag?: string): Promise<{ data: T | null; etag: string | null }> {
101 const headers: Record<string, string> = {};
102 // A custom header a cross-origin page cannot set without a CORS preflight we
103 // never grant. Combined with SameSite=Lax on the session cookie that is the
104 // whole CSRF defence; there is no token to store or rotate.
105 if (method !== "GET") headers["X-OT-CSRF"] = "1";
106 if (body !== undefined) headers["Content-Type"] = "application/json";
107 if (etag) headers["If-None-Match"] = etag;
108
109 const res = await fetch(path, {
110 method,
111 headers,
112 credentials: "same-origin",
113 body: body === undefined ? undefined : JSON.stringify(body),
114 });
115
116 if (res.status === 304) return { data: null, etag: etag ?? null };
117 if (!res.ok) {
118 const message = await res
119 .json()
120 .then((b) => (b as { error?: string }).error ?? res.statusText)
121 .catch(() => res.statusText);
122 throw new ApiError(res.status, message);
123 }
124 const responseEtag = res.headers.get("ETag");
125 if (res.status === 204) return { data: null, etag: responseEtag };
126 return { data: (await res.json()) as T, etag: responseEtag };
127}
128
129async function json<T>(method: string, path: string, body?: unknown): Promise<T> {
130 const { data } = await request<T>(method, path, body);
131 return data as T;
132}
133
134export const api = {
135 login: (username: string, password: string) => json<{ user: Me }>("POST", "/api/login", { username, password }),
136 logout: () => json<void>("POST", "/api/logout"),
137 me: () => json<Me>("GET", "/api/me"),
138 /** Returns null when the ETag matched, meaning nothing changed. */
139 state: (etag?: string) => request<StateResponse>("GET", "/api/state", undefined, etag),
140 tokens: () => json<TokenInfo[]>("GET", "/api/tokens"),
141 revokeToken: (id: string) => json<void>("DELETE", `/api/tokens/${id}`),
142 revokeOthers: () => json<{ revoked: number }>("POST", "/api/tokens/revoke-others"),
143 /** Outgoing, still-live shares only, newest first. */
144 shares: () => json<ShareInfo[]>("GET", "/api/shares"),
145 createShare: (body: CreateShare) => json<ShareInfo>("POST", "/api/shares", body),
146 revokeShare: (id: number) => json<void>("DELETE", `/api/shares/${id}`),
147 track: (userId: number, from: number, to: number, max = 2000) =>
148 json<TrackResponse>("GET", `/api/users/${userId}/track?from=${from}&to=${to}&max=${max}`),
149 changePassword: (current_password: string, new_password: string) =>
150 json<void>("POST", "/api/me/password", { current_password, new_password }),
151};
152
153/**
154 * Google encoded polyline at 1e5, the format `/track` returns.
155 *
156 * The server encodes; nothing here re-encodes, so this is the only half that
157 * has to exist.
158 */
159export function decodePolyline(encoded: string): [number, number][] {
160 const out: [number, number][] = [];
161 let index = 0;
162 let lat = 0;
163 let lon = 0;
164 while (index < encoded.length) {
165 for (let i = 0; i < 2; i++) {
166 let result = 0;
167 let shift = 0;
168 let byte: number;
169 do {
170 byte = encoded.charCodeAt(index++) - 63;
171 result |= (byte & 0x1f) << shift;
172 shift += 5;
173 } while (byte >= 0x20);
174 const delta = result & 1 ? ~(result >> 1) : result >> 1;
175 if (i === 0) lat += delta;
176 else lon += delta;
177 }
178 out.push([lat / 1e5, lon / 1e5]);
179 }
180 return out;
181}
182