mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
/**
|
|
* Dawarich API client: the wire shapes HealthLog reads and nothing more.
|
|
*
|
|
* Dawarich (https://dawarich.app) is a self-hosted location history. HealthLog
|
|
* reads four endpoints, all with `Authorization: Bearer <api key>`:
|
|
*
|
|
* GET /api/v1/timeline?start_at&end_at&distance_unit=km
|
|
* one entry per day in the Dawarich account's own zone (its timezone
|
|
* setting, the instance's `TIME_ZONE` by default; the request cannot
|
|
* name one), summing only what lies inside the window, with a `summary`:
|
|
* `total_distance`, `places_visited`, `mode_distances.{driving,train}`
|
|
* in km to one decimal. At most 31 days per request.
|
|
* GET /api/v1/visits?start_at&end_at
|
|
* visits that STARTED in the window: `started_at`, `ended_at`,
|
|
* `status`, `place.id`. The coordinates and the name in the payload
|
|
* are read past and dropped here; nothing but the place id survives.
|
|
* GET /api/v1/residency?year=YYYY
|
|
* `daily_countries` (UTC date to country name) and `countries[]`
|
|
* (country name to ISO 3166-1 alpha-2). Answers 403 on a hosted plan
|
|
* without API access; then there is no travel signal.
|
|
* GET /api/v1/places, /api/v1/places/:id
|
|
* to pick and check the home place; id and name only, never stored.
|
|
* GET /api/v1/users/me
|
|
* the cheapest authenticated call, used to check a key.
|
|
*
|
|
* Every call goes through `safeFetch`: `requirePublicHost` for a public
|
|
* instance, the operator-approved pinned dispatcher for an origin listed in
|
|
* `DAWARICH_PRIVATE_ORIGINS`. The policy is re-read on every call, so taking
|
|
* an origin off the list stops the next request.
|
|
*/
|
|
import { readBodyCapped } from "@/lib/http/read-capped";
|
|
import { SafeFetchError, safeFetch } from "@/lib/safe-fetch";
|
|
|
|
import {
|
|
configuredDawarichPrivateOrigins,
|
|
evaluateDawarichUrl,
|
|
} from "./origin";
|
|
|
|
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
|
|
/**
|
|
* The most any one answer may hold. A month of timeline with its entries and
|
|
* a year of residency stay far below it; the cap is what keeps an instance
|
|
* that streams without end from holding the worker's memory until the
|
|
* timeout.
|
|
*/
|
|
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
|
|
/** Dawarich refuses a timeline window longer than 31 days. */
|
|
export const TIMELINE_MAX_DAYS = 31;
|
|
|
|
/** The places list is read once per request and capped before filtering. */
|
|
const PLACES_PAGE_SIZE = 500;
|
|
|
|
/** At most this many places go back to the picker. */
|
|
const PLACES_RESULT_LIMIT = 200;
|
|
|
|
export interface DawarichConnection {
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
}
|
|
|
|
/** The instance answered with a status outside 2xx, or not with JSON. */
|
|
export class DawarichApiError extends Error {
|
|
readonly status: number | null;
|
|
constructor(message: string, status: number | null) {
|
|
super(message);
|
|
this.name = "DawarichApiError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
/** The address is private and not on the operator's list, or not usable at all. */
|
|
export class DawarichPolicyError extends Error {
|
|
readonly reasonCode: string;
|
|
constructor(reasonCode: string) {
|
|
super(reasonCode);
|
|
this.name = "DawarichPolicyError";
|
|
this.reasonCode = reasonCode;
|
|
}
|
|
}
|
|
|
|
async function dawarichGet(
|
|
conn: DawarichConnection,
|
|
path: string,
|
|
query: Record<string, string> = {},
|
|
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
): Promise<unknown> {
|
|
const target = evaluateDawarichUrl(
|
|
conn.baseUrl,
|
|
configuredDawarichPrivateOrigins(),
|
|
);
|
|
if (!target.allowed || !target.baseUrl || !target.origin) {
|
|
throw new DawarichPolicyError(
|
|
target.reasonCode ?? "private_origin_not_approved",
|
|
);
|
|
}
|
|
const url = new URL(`${target.baseUrl}${path}`);
|
|
for (const [key, value] of Object.entries(query)) {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await safeFetch(
|
|
url.toString(),
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${conn.apiKey}`,
|
|
},
|
|
},
|
|
{
|
|
requirePublicHost: !target.privateOriginApproved,
|
|
...(target.privateOriginApproved
|
|
? { operatorApprovedPrivateOrigin: target.origin }
|
|
: {}),
|
|
timeoutMs,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof SafeFetchError && error.kind === "private_host") {
|
|
throw new DawarichPolicyError("private_origin_not_approved");
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (res.status < 200 || res.status >= 300) {
|
|
throw new DawarichApiError(`Dawarich responded ${res.status}`, res.status);
|
|
}
|
|
const read = await readBodyCapped(res, MAX_BODY_BYTES);
|
|
if (!read.ok) {
|
|
throw new DawarichApiError("Dawarich returned an oversized body", null);
|
|
}
|
|
try {
|
|
return JSON.parse(read.text) as unknown;
|
|
} catch {
|
|
throw new DawarichApiError("Dawarich returned a non-JSON body", null);
|
|
}
|
|
}
|
|
|
|
// ── Timeline ─────────────────────────────────────────────────────────
|
|
|
|
/** One day's summary, distances in km as Dawarich reports them. */
|
|
export interface TimelineDaySummary {
|
|
date: string;
|
|
totalKm: number | null;
|
|
drivingKm: number | null;
|
|
trainKm: number | null;
|
|
placesVisited: number | null;
|
|
}
|
|
|
|
function finiteOrNull(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Read the day summaries out of a timeline answer. A mode absent from
|
|
* `mode_distances` is zero for that day (Dawarich leaves out a mode that moved
|
|
* less than half a kilometre), so it reads as 0 whenever the summary itself is
|
|
* there. Everything else in the payload (entries, bounds, names) is dropped.
|
|
*/
|
|
export function parseTimelineDays(payload: unknown): TimelineDaySummary[] {
|
|
const days = (payload as { days?: unknown } | null)?.days;
|
|
if (!Array.isArray(days)) return [];
|
|
const out: TimelineDaySummary[] = [];
|
|
for (const raw of days) {
|
|
if (!raw || typeof raw !== "object") continue;
|
|
const day = raw as { date?: unknown; summary?: unknown };
|
|
if (typeof day.date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(day.date)) {
|
|
continue;
|
|
}
|
|
const summary =
|
|
day.summary && typeof day.summary === "object"
|
|
? (day.summary as Record<string, unknown>)
|
|
: null;
|
|
if (!summary) continue;
|
|
const modes =
|
|
summary.mode_distances && typeof summary.mode_distances === "object"
|
|
? (summary.mode_distances as Record<string, unknown>)
|
|
: {};
|
|
out.push({
|
|
date: day.date,
|
|
totalKm: finiteOrNull(summary.total_distance),
|
|
drivingKm: finiteOrNull(modes.driving) ?? 0,
|
|
trainKm: finiteOrNull(modes.train) ?? 0,
|
|
placesVisited:
|
|
typeof summary.places_visited === "number" &&
|
|
Number.isInteger(summary.places_visited) &&
|
|
summary.places_visited >= 0
|
|
? summary.places_visited
|
|
: null,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function fetchTimeline(
|
|
conn: DawarichConnection,
|
|
startAt: Date,
|
|
endAt: Date,
|
|
): Promise<TimelineDaySummary[]> {
|
|
return parseTimelineDays(
|
|
await dawarichGet(conn, "/api/v1/timeline", {
|
|
start_at: startAt.toISOString(),
|
|
end_at: endAt.toISOString(),
|
|
distance_unit: "km",
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── Visits ───────────────────────────────────────────────────────────
|
|
|
|
/** A visit reduced to what time away from home needs. */
|
|
export interface VisitSpan {
|
|
placeId: number | null;
|
|
startMs: number;
|
|
endMs: number;
|
|
}
|
|
|
|
/**
|
|
* Read the visits. A declined visit (the person said it did not happen) is
|
|
* dropped; so is one without a usable time span. Coordinates and names in the
|
|
* payload are never copied out.
|
|
*/
|
|
export function parseVisits(payload: unknown): VisitSpan[] {
|
|
if (!Array.isArray(payload)) return [];
|
|
const out: VisitSpan[] = [];
|
|
for (const raw of payload) {
|
|
if (!raw || typeof raw !== "object") continue;
|
|
const visit = raw as Record<string, unknown>;
|
|
if (visit.status === "declined") continue;
|
|
const startMs =
|
|
typeof visit.started_at === "string" ? Date.parse(visit.started_at) : NaN;
|
|
const endMs =
|
|
typeof visit.ended_at === "string" ? Date.parse(visit.ended_at) : NaN;
|
|
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
|
|
if (endMs <= startMs) continue;
|
|
const place =
|
|
visit.place && typeof visit.place === "object"
|
|
? (visit.place as Record<string, unknown>)
|
|
: null;
|
|
const placeId =
|
|
typeof place?.id === "number" && Number.isInteger(place.id)
|
|
? place.id
|
|
: null;
|
|
out.push({ placeId, startMs, endMs });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function fetchVisits(
|
|
conn: DawarichConnection,
|
|
startAt: Date,
|
|
endAt: Date,
|
|
): Promise<VisitSpan[]> {
|
|
return parseVisits(
|
|
await dawarichGet(conn, "/api/v1/visits", {
|
|
start_at: startAt.toISOString(),
|
|
end_at: endAt.toISOString(),
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── Residency ────────────────────────────────────────────────────────
|
|
|
|
/** UTC date to ISO 3166-1 alpha-2, for the days the year holds a country. */
|
|
export type DailyCountries = Map<string, string>;
|
|
|
|
/**
|
|
* Read `daily_countries` and translate each country name through the
|
|
* `countries[]` table into its alpha-2 code. A name without a code is dropped:
|
|
* a country HealthLog cannot name is no basis for a travel day.
|
|
*/
|
|
export function parseResidency(payload: unknown): DailyCountries {
|
|
const out: DailyCountries = new Map();
|
|
if (!payload || typeof payload !== "object") return out;
|
|
const body = payload as Record<string, unknown>;
|
|
const codeByName = new Map<string, string>();
|
|
if (Array.isArray(body.countries)) {
|
|
for (const raw of body.countries) {
|
|
if (!raw || typeof raw !== "object") continue;
|
|
const country = raw as Record<string, unknown>;
|
|
if (
|
|
typeof country.country_name === "string" &&
|
|
typeof country.iso_a2 === "string" &&
|
|
/^[A-Za-z]{2}$/.test(country.iso_a2)
|
|
) {
|
|
codeByName.set(country.country_name, country.iso_a2.toUpperCase());
|
|
}
|
|
}
|
|
}
|
|
const daily =
|
|
body.daily_countries && typeof body.daily_countries === "object"
|
|
? (body.daily_countries as Record<string, unknown>)
|
|
: {};
|
|
for (const [date, name] of Object.entries(daily)) {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || typeof name !== "string") continue;
|
|
const code = codeByName.get(name);
|
|
if (code) out.set(date, code);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* One year of residency, or null when the instance does not offer it (a
|
|
* hosted plan without API access answers 403, an older instance 404). Any
|
|
* other failure is thrown like every other call.
|
|
*/
|
|
export async function fetchResidency(
|
|
conn: DawarichConnection,
|
|
year: number,
|
|
): Promise<DailyCountries | null> {
|
|
try {
|
|
return parseResidency(
|
|
await dawarichGet(conn, "/api/v1/residency", { year: String(year) }),
|
|
);
|
|
} catch (error) {
|
|
if (
|
|
error instanceof DawarichApiError &&
|
|
(error.status === 403 || error.status === 404)
|
|
) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ── Places ───────────────────────────────────────────────────────────
|
|
|
|
export interface DawarichPlace {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
/** Id and name of each place, filtered by `q`, sorted by name, capped. */
|
|
export function parsePlaces(payload: unknown, q?: string): DawarichPlace[] {
|
|
if (!Array.isArray(payload)) return [];
|
|
const needle = q?.trim().toLocaleLowerCase() ?? "";
|
|
const out: DawarichPlace[] = [];
|
|
for (const raw of payload) {
|
|
if (!raw || typeof raw !== "object") continue;
|
|
const place = raw as Record<string, unknown>;
|
|
if (typeof place.id !== "number" || !Number.isInteger(place.id)) continue;
|
|
const name = typeof place.name === "string" ? place.name.trim() : "";
|
|
if (!name) continue;
|
|
if (needle && !name.toLocaleLowerCase().includes(needle)) continue;
|
|
out.push({ id: place.id, name: name.slice(0, 200) });
|
|
}
|
|
out.sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id);
|
|
return out.slice(0, PLACES_RESULT_LIMIT);
|
|
}
|
|
|
|
export async function fetchPlaces(
|
|
conn: DawarichConnection,
|
|
q?: string,
|
|
): Promise<DawarichPlace[]> {
|
|
return parsePlaces(
|
|
await dawarichGet(conn, "/api/v1/places", {
|
|
filter: "all",
|
|
page: "1",
|
|
per_page: String(PLACES_PAGE_SIZE),
|
|
}),
|
|
q,
|
|
);
|
|
}
|
|
|
|
/** Whether the instance knows a place with this id. */
|
|
export async function placeExists(
|
|
conn: DawarichConnection,
|
|
id: number,
|
|
): Promise<boolean> {
|
|
try {
|
|
await dawarichGet(conn, `/api/v1/places/${id}`);
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof DawarichApiError && error.status === 404) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Check the key with the cheapest authenticated call. */
|
|
export async function probeDawarich(
|
|
conn: DawarichConnection,
|
|
timeoutMs?: number,
|
|
): Promise<void> {
|
|
await dawarichGet(conn, "/api/v1/users/me", {}, timeoutMs);
|
|
}
|