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
63/** Point flag bits, matching `otproto::Point`. */
64export const FLAG_CHARGING = 1;
65export const FLAG_NETWORK_FIX = 2;
66export const FLAG_LOW_ACCURACY = 4;
67export const FLAG_MOCK = 8;
68
69export class ApiError extends Error {
70 constructor(
71 readonly status: number,
72 message: string,
73 ) {
74 super(message);
75 }
76}
77
78/** Thrown-away sentinel: the caller shows the login screen on a 401. */
79export const UNAUTHORIZED = 401;
80
81async function request<T>(method: string, path: string, body?: unknown, etag?: string): Promise<{ data: T | null; etag: string | null }> {
82 const headers: Record<string, string> = {};
83 // A custom header a cross-origin page cannot set without a CORS preflight we
84 // never grant. Combined with SameSite=Lax on the session cookie that is the
85 // whole CSRF defence; there is no token to store or rotate.
86 if (method !== "GET") headers["X-OT-CSRF"] = "1";
87 if (body !== undefined) headers["Content-Type"] = "application/json";
88 if (etag) headers["If-None-Match"] = etag;
89
90 const res = await fetch(path, {
91 method,
92 headers,
93 credentials: "same-origin",
94 body: body === undefined ? undefined : JSON.stringify(body),
95 });
96
97 if (res.status === 304) return { data: null, etag: etag ?? null };
98 if (!res.ok) {
99 const message = await res
100 .json()
101 .then((b) => (b as { error?: string }).error ?? res.statusText)
102 .catch(() => res.statusText);
103 throw new ApiError(res.status, message);
104 }
105 const responseEtag = res.headers.get("ETag");
106 if (res.status === 204) return { data: null, etag: responseEtag };
107 return { data: (await res.json()) as T, etag: responseEtag };
108}
109
110async function json<T>(method: string, path: string, body?: unknown): Promise<T> {
111 const { data } = await request<T>(method, path, body);
112 return data as T;
113}
114
115export const api = {
116 login: (username: string, password: string) => json<{ user: Me }>("POST", "/api/login", { username, password }),
117 logout: () => json<void>("POST", "/api/logout"),
118 me: () => json<Me>("GET", "/api/me"),
119 /** Returns null when the ETag matched, meaning nothing changed. */
120 state: (etag?: string) => request<StateResponse>("GET", "/api/state", undefined, etag),
121 tokens: () => json<TokenInfo[]>("GET", "/api/tokens"),
122 revokeToken: (id: string) => json<void>("DELETE", `/api/tokens/${id}`),
123 revokeOthers: () => json<{ revoked: number }>("POST", "/api/tokens/revoke-others"),
124 track: (userId: number, from: number, to: number, max = 2000) =>
125 json<TrackResponse>("GET", `/api/users/${userId}/track?from=${from}&to=${to}&max=${max}`),
126 changePassword: (current_password: string, new_password: string) =>
127 json<void>("POST", "/api/me/password", { current_password, new_password }),
128};
129
130/**
131 * Google encoded polyline at 1e5, the format `/track` returns.
132 *
133 * The server encodes; nothing here re-encodes, so this is the only half that
134 * has to exist.
135 */
136export function decodePolyline(encoded: string): [number, number][] {
137 const out: [number, number][] = [];
138 let index = 0;
139 let lat = 0;
140 let lon = 0;
141 while (index < encoded.length) {
142 for (let i = 0; i < 2; i++) {
143 let result = 0;
144 let shift = 0;
145 let byte: number;
146 do {
147 byte = encoded.charCodeAt(index++) - 63;
148 result |= (byte & 0x1f) << shift;
149 shift += 5;
150 } while (byte >= 0x20);
151 const delta = result & 1 ? ~(result >> 1) : result >> 1;
152 if (i === 0) lat += delta;
153 else lon += delta;
154 }
155 out.push([lat / 1e5, lon / 1e5]);
156 }
157 return out;
158}
159