feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -1,9 +1,11 @@
|
||||
import type { LookupFunction } from "node:net";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { createRequestHandler } from "./lib/request-handler";
|
||||
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
|
||||
|
||||
export interface Station {
|
||||
id: string;
|
||||
@@ -31,51 +33,126 @@ export interface Timetable {
|
||||
}
|
||||
|
||||
const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
|
||||
const MAX_STATION_RESULTS = 100;
|
||||
const MAX_TIMETABLE_ENTRIES = 100;
|
||||
const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const;
|
||||
|
||||
export interface TimetableResolvedAddress {
|
||||
address: string;
|
||||
family: 4 | 6;
|
||||
}
|
||||
|
||||
export const timetableSearchStationsRequestHandler = createRequestHandler<
|
||||
Station[],
|
||||
{ baseUrl: string; query: string }
|
||||
{ baseUrl: string; query: string; pinnedAddresses?: TimetableResolvedAddress[] }
|
||||
>({
|
||||
async requestAsync(input) {
|
||||
return await searchStationsAsync(input.baseUrl, input.query);
|
||||
return await searchStationsAsync(input.baseUrl, input.query, input.pinnedAddresses);
|
||||
},
|
||||
cacheTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
export const timetableGetTimetableRequestHandler = createRequestHandler<
|
||||
Timetable,
|
||||
{ baseUrl: string; stationId: string; limit: number }
|
||||
{ baseUrl: string; stationId: string; limit: number; pinnedAddresses?: TimetableResolvedAddress[] }
|
||||
>({
|
||||
async requestAsync(input) {
|
||||
return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit });
|
||||
return await getTimetableAsync(
|
||||
input.baseUrl,
|
||||
{ stationId: input.stationId, limit: input.limit },
|
||||
input.pinnedAddresses,
|
||||
);
|
||||
},
|
||||
cacheTtlMs: 60_000,
|
||||
});
|
||||
|
||||
const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<string, string | number>) => {
|
||||
const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
|
||||
const url = new URL(`${normalizeTimetableBaseUrl(baseUrl)}${path}`);
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
url.searchParams.set(key, value.toString());
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const searchStationsAsync = async (baseUrl: string, query: string): Promise<Station[]> => {
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
|
||||
);
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
const normalizeLookupHostname = (hostname: string) =>
|
||||
hostname
|
||||
.replace(/^\[(.*)\]$/, "$1")
|
||||
.toLowerCase()
|
||||
.replace(/\.$/, "");
|
||||
|
||||
const data = await searchSchema.parseAsync(await response.json());
|
||||
return data
|
||||
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
|
||||
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
|
||||
.filter((item) => item !== null);
|
||||
const createPinnedLookup = (baseUrl: string, addresses: TimetableResolvedAddress[]): LookupFunction => {
|
||||
const expectedHostname = normalizeLookupHostname(new URL(baseUrl).hostname);
|
||||
|
||||
return (hostname, options, callback) => {
|
||||
const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||
const candidates = requestedFamily ? addresses.filter(({ family }) => family === requestedFamily) : addresses;
|
||||
if (normalizeLookupHostname(hostname) !== expectedHostname || candidates.length === 0) {
|
||||
const error = new Error("Pinned timetable address is unavailable") as NodeJS.ErrnoException;
|
||||
error.code = "ENOTFOUND";
|
||||
callback(error, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.all) {
|
||||
callback(null, candidates);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = candidates[0];
|
||||
if (!selected) return callback(new Error("Pinned timetable address is unavailable"), []);
|
||||
callback(null, selected.address, selected.family);
|
||||
};
|
||||
};
|
||||
|
||||
const createPinnedTimetableDispatcherAsync = async (baseUrl: string, addresses: TimetableResolvedAddress[]) => {
|
||||
// Proxies resolve CONNECT targets independently, which would defeat DNS pinning. Force this security-sensitive
|
||||
// configuration request to connect directly. The original URL remains unchanged for the Host header and TLS SNI.
|
||||
return await createCertificateAgentAsync(
|
||||
{ lookup: createPinnedLookup(baseUrl, addresses) },
|
||||
{
|
||||
autoSelectFamily: addresses.length > 1,
|
||||
bodyTimeout: timetableFetchOptions.bodyTimeout,
|
||||
httpProxy: "",
|
||||
httpsProxy: "",
|
||||
noProxy: "*",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const searchStationsAsync = async (
|
||||
baseUrl: string,
|
||||
query: string,
|
||||
pinnedAddresses?: TimetableResolvedAddress[],
|
||||
): Promise<Station[]> => {
|
||||
let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
|
||||
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>> | undefined;
|
||||
try {
|
||||
response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
|
||||
{ ...timetableFetchOptions, dispatcher },
|
||||
);
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const body = await readBoundedTimetableJsonAsync(response);
|
||||
const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
|
||||
return data
|
||||
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
|
||||
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
|
||||
.filter((item) => item !== null);
|
||||
} catch (error) {
|
||||
if (response?.body) await response.body.cancel().catch(() => {});
|
||||
await dispatcher?.destroy().catch(() => {});
|
||||
dispatcher = undefined;
|
||||
throw error;
|
||||
} finally {
|
||||
await dispatcher?.close();
|
||||
}
|
||||
};
|
||||
|
||||
const getTimetableAsync = async (
|
||||
baseUrl: string,
|
||||
options: { stationId: string; limit: number },
|
||||
pinnedAddresses?: TimetableResolvedAddress[],
|
||||
): Promise<Timetable> => {
|
||||
const now = new Date();
|
||||
const date = new Intl.DateTimeFormat("en-US", {
|
||||
@@ -90,43 +167,59 @@ const getTimetableAsync = async (
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/stationboard.json", {
|
||||
stop: options.stationId,
|
||||
limit: options.limit,
|
||||
show_delays: 1,
|
||||
show_tracks: 1,
|
||||
date,
|
||||
time,
|
||||
}),
|
||||
);
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
|
||||
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>> | undefined;
|
||||
try {
|
||||
response = await fetchWithTrustedCertificatesAsync(
|
||||
buildUrl(baseUrl, "/timetable/api/stationboard.json", {
|
||||
stop: options.stationId,
|
||||
limit: options.limit,
|
||||
show_delays: 1,
|
||||
show_tracks: 1,
|
||||
date,
|
||||
time,
|
||||
}),
|
||||
{ ...timetableFetchOptions, dispatcher },
|
||||
);
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const data = await timetableSchema.parseAsync(await response.json());
|
||||
return {
|
||||
stationId: options.stationId,
|
||||
timestamp: now,
|
||||
entries: data.connections.map((connection) => {
|
||||
const color = connection.color.split("~")[0];
|
||||
return {
|
||||
timestamp: connection.time,
|
||||
line: connection.line
|
||||
? {
|
||||
name: connection.line,
|
||||
color: color && color.length >= 1 ? `#${color}` : null,
|
||||
}
|
||||
: null,
|
||||
location: connection.terminal.name,
|
||||
delay: connection.dep_delay,
|
||||
platform: connection.track
|
||||
? {
|
||||
name: connection.track.replace("!", ""),
|
||||
hasChanged: connection.track.includes("!"),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
const body = await readBoundedTimetableJsonAsync(response);
|
||||
const envelope = timetableEnvelopeSchema.parse(body);
|
||||
const data = await timetableSchema.parseAsync({
|
||||
connections: envelope.connections.slice(0, Math.min(options.limit, MAX_TIMETABLE_ENTRIES)),
|
||||
});
|
||||
return {
|
||||
stationId: options.stationId,
|
||||
timestamp: now,
|
||||
entries: data.connections.map((connection) => {
|
||||
const color = connection.color.split("~")[0];
|
||||
return {
|
||||
timestamp: connection.time,
|
||||
line: connection.line
|
||||
? {
|
||||
name: connection.line,
|
||||
color: color && color.length >= 1 ? `#${color}` : null,
|
||||
}
|
||||
: null,
|
||||
location: connection.terminal.name,
|
||||
delay: connection.dep_delay,
|
||||
platform: connection.track
|
||||
? {
|
||||
name: connection.track.replace("!", ""),
|
||||
hasChanged: connection.track.includes("!"),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (response?.body) await response.body.cancel().catch(() => {});
|
||||
await dispatcher?.destroy().catch(() => {});
|
||||
dispatcher = undefined;
|
||||
throw error;
|
||||
} finally {
|
||||
await dispatcher?.close();
|
||||
}
|
||||
};
|
||||
|
||||
const timetableSchema = z.object({
|
||||
@@ -145,6 +238,8 @@ const timetableSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
const timetableEnvelopeSchema = z.object({ connections: z.array(z.unknown()) });
|
||||
|
||||
const searchSchema = z.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
|
||||
Reference in new issue
Block a user