diff --git a/apps/api/package.json b/apps/api/package.json index 6293403..00c6447 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,6 +23,7 @@ "drizzle-orm": "catalog:", "drizzle-zod": "catalog:", "hono": "catalog:", + "ofetch": "catalog:", "postgres": "catalog:", "zod": "catalog:" }, diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 74f1f83..2dfad9d 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -110,6 +110,11 @@ export async function logout(token: string) { db.update(accountsTable).set({ token: "" }).where(eq(accountsTable.token, token)) } +/** + * Get the account ID for a given token + * @param token The user's token + * @returns the account ID + */ export async function getAccount(token: string): Promise { const account = await db .select({ diff --git a/apps/api/src/lib/helpers.ts b/apps/api/src/lib/helpers.ts index 8b8ed69..c11861f 100644 --- a/apps/api/src/lib/helpers.ts +++ b/apps/api/src/lib/helpers.ts @@ -1,43 +1,9 @@ import type { Context } from "hono" -import { HTTPException } from "hono/http-exception" -import type { CustomHeader, RequestHeader } from "hono/utils/headers" import { ContentfulStatusCode } from "hono/utils/http-status" import { ADMIN_EMAIL } from "@karr/config" -import { isUUIDv4 } from "@karr/util" import logger from "@karr/util/logger" -/** - * Check if a request is authenticated - * @param c The Hono context object - * @returns True if the request is authenticated, false otherwise - */ -export function checkAuth(value: Record): { - id: string -} { - const authorization = value["authorization"] - - if (authorization === undefined || authorization === "") { - throw new HTTPException(400, { - message: "Authencation token is required in Authorization header" - }) - } - - // TODO(@finxol): verify the JWT - const id: string = authorization - - // check the id is a valid UUID - if (!isUUIDv4(id)) { - throw new HTTPException(400, { - message: "Invalid user ID" - }) - } - - logger.debug(`User ID: ${id}`) - - return { id } -} - /** * Template for a function that returns a response object * @param c The Hono context object diff --git a/apps/api/src/routes/federation/helpers.ts b/apps/api/src/routes/federation/helpers.ts new file mode 100644 index 0000000..7b53233 --- /dev/null +++ b/apps/api/src/routes/federation/helpers.ts @@ -0,0 +1,31 @@ +import { FetchError, ofetch } from "ofetch" + +import { FEDERATION_TARGETS } from "@karr/config" +import { Trip } from "@karr/db/schemas/trips.js" +import logger from "@karr/util/logger" + +import { DataResponse } from "@/lib/types" + +export async function getFederatedTrips(): Promise { + const trips: Trip[] = [] + try { + for await (const target of FEDERATION_TARGETS) { + const t = await ofetch>( + "/api/v1/federation/trips/search", + { + baseURL: target.url, + headers: { + Cookie: `auth-token=federation` + } + } + ) + for (const trip of t.data) { + trip.origin = target.name + trips.push(trip) + } + } + } catch (error) { + logger.error("Error fetching trips from federation:", (error as FetchError)?.data) + } + return trips +} diff --git a/apps/api/src/routes/federation/index.ts b/apps/api/src/routes/federation/index.ts new file mode 100644 index 0000000..ad479c6 --- /dev/null +++ b/apps/api/src/routes/federation/index.ts @@ -0,0 +1,12 @@ +import { Hono } from "hono" + +import trips from "./trips" + +/** + * Federation endpoint + */ +const hono = new Hono() + +hono.route("/trips", trips) + +export default hono diff --git a/apps/api/src/routes/federation/trips.ts b/apps/api/src/routes/federation/trips.ts new file mode 100644 index 0000000..cb138b1 --- /dev/null +++ b/apps/api/src/routes/federation/trips.ts @@ -0,0 +1,18 @@ +import { Hono } from "hono" + +import logger from "@karr/util/logger" + +import { getTrips } from "@/db/trips" +import { handleRequest } from "@/lib/helpers" + +/** + * Federation trips endpoint + */ +const hono = new Hono() + +hono.get("/search", async (c) => { + logger.debug("GET /trips") + return handleRequest(c, getTrips) +}) + +export default hono diff --git a/apps/api/src/routes/trips.ts b/apps/api/src/routes/trips.ts index 3171b95..ff4ddc5 100644 --- a/apps/api/src/routes/trips.ts +++ b/apps/api/src/routes/trips.ts @@ -7,6 +7,7 @@ import logger from "@karr/util/logger" import { addTrip, deleteTrip, getTrips } from "@/db/trips" import { handleRequest, responseErrorObject } from "@/lib/helpers" import type { DataResponse } from "@/lib/types.d.ts" +import { getFederatedTrips } from "./federation/helpers" const hono = new Hono() @@ -44,7 +45,10 @@ hono.get("/search", (c) => { const immediatePromise: Promise = getTrips().then(sendData) // Get the trips from the federated servers - const slowerPromises: Promise[] = [getSlowerData().then(sendData)] + const slowerPromises: Promise[] = [ + getFederatedTrips().then(sendData) + // getSlowerData().then(sendData) + ] await Promise.all([...tripsToSend, immediatePromise, ...slowerPromises]) logger.debug("All data sent") @@ -123,6 +127,7 @@ export default hono // ================ For SSE tests ================ // =============================================== +//eslint-disable-next-line @typescript-eslint/no-unused-vars function getSlowerData(): Promise { return new Promise((resolve) => { setTimeout(() => { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index a17af6d..31b5250 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -3,10 +3,11 @@ import { getCookie } from "hono/cookie" import { cors } from "hono/cors" import { validator } from "hono/validator" -import { API_VERSION, PRODUCTION } from "@karr/config" +import { API_VERSION, FEDERATION, PRODUCTION } from "@karr/config" import account from "@/routes/account" import auth from "@/routes/auth" +import federation from "@/routes/federation" import system from "@/routes/system" import trips from "@/routes/trips" import user from "@/routes/user" @@ -59,14 +60,16 @@ export const build = (): Hono => { c, { message: "Unauthorized", - cause: "Auth token is required in Authorization header" + cause: "Auth token is required in cookie" }, 401 ) } // TODO(@finxol): verify the JWT - const id: string | null = await getAccount(authtoken) + const id: string | null = + // very unsafe, but it's just for the PoC + authtoken === "federation" ? "federation" : await getAccount(authtoken) if (id === null) { return responseErrorObject( @@ -102,5 +105,9 @@ export const build = (): Hono => { hono.route(`/${API_VERSION}/account`, account) hono.route(`/${API_VERSION}/trips`, trips) + if (FEDERATION) { + hono.route(`/${API_VERSION}/federation`, federation) + } + return hono } diff --git a/apps/web/src/app/trips/search/trips.tsx b/apps/web/src/app/trips/search/trips.tsx index 16ea69b..171e1f7 100644 --- a/apps/web/src/app/trips/search/trips.tsx +++ b/apps/web/src/app/trips/search/trips.tsx @@ -2,9 +2,10 @@ import { useEffect, useState } from "react" import { useQuery, useQueryClient } from "@tanstack/react-query" -import { Trash as IconTrash } from "lucide-react" +import { Earth as IconEarth, House as IconHouse, Trash as IconTrash } from "lucide-react" import { TripSchema, type Trip } from "@karr/db/schemas/trips.js" +import { Badge } from "@karr/ui/components/badge" import { Button } from "@karr/ui/components/button" import { Card, @@ -167,7 +168,13 @@ function FetchTrips({ userid }: { userid: string }) {
{trips.map((trip: Trip) => { const t = TripSchema.parse(trip) - return + return ( + + ) })} {loading && }
@@ -201,7 +208,23 @@ function TripCard({

{trip.price} €

- + + {trip.origin ? ( + + + {trip.origin} + + ) : ( + + + + )}

{trip.account.split("-")[0]}

diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index 3bdd692..6fcb8ba 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -13,6 +13,8 @@ export const { LOG_TIMESTAMP, LOG_LEVEL, ADMIN_EMAIL, + FEDERATION, + FEDERATION_TARGETS, API_VERSION, APPLICATION_NAME, PRODUCTION diff --git a/packages/config/src/default_config.json b/packages/config/src/default_config.json index b2ac47f..2a3f6c2 100644 --- a/packages/config/src/default_config.json +++ b/packages/config/src/default_config.json @@ -9,5 +9,7 @@ "API_PORT": 1993, "API_BASE": "/api", "LOG_LEVEL": "info", - "LOG_TIMESTAMP": true + "LOG_TIMESTAMP": true, + "FEDERATION": true, + "FEDERATION_TARGETS": [] } diff --git a/packages/config/src/loader.ts b/packages/config/src/loader.ts index 414acdf..d73928a 100644 --- a/packages/config/src/loader.ts +++ b/packages/config/src/loader.ts @@ -159,6 +159,19 @@ export function loadFullConfig(): FullConfig { config.ADMIN_EMAIL = process.env.ADMIN_EMAIL } + if (process.env.FEDERATION) { + config.FEDERATION = !(process.env.LOG_TIMESTAMP === "false") + } + + if (process.env.FEDERATION_TARGETS) { + config.FEDERATION_TARGETS = process.env.FEDERATION_TARGETS.split(",").map( + (target) => ({ + name: target, + url: target + }) + ) + } + return FullConfigSchema.parse(config) } diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 681e556..fc1205e 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -16,6 +16,19 @@ export const ConfigFileSchema = z LOG_TIMESTAMP: z.boolean().optional(), LOG_LEVEL: LogLevelSchema.optional(), ADMIN_EMAIL: z.string().email().optional(), + FEDERATION: z.boolean().optional(), + FEDERATION_TARGETS: z + .array( + z.object({ + name: z.string(), + url: z.string() + }), + { + message: + "Invalid federation target. Needs am array of objects with name and url" + } + ) + .optional(), DB_CONFIG: z .object({ host: z.string().optional(), @@ -57,6 +70,14 @@ export const FullConfigSchema = z : "info" ), ADMIN_EMAIL: z.string().email().optional(), + FEDERATION: z.boolean(), + // TODO: move this to settings to be editable via the UI + FEDERATION_TARGETS: z.array( + z.object({ + name: z.string(), + url: z.union([z.string().url(), z.string().ip()]) + }) + ), API_VERSION: z.enum(["v1"]).default(staticConfig.API_VERSION), APPLICATION_NAME: z.string().default(staticConfig.APPLICATION_NAME), PRODUCTION: z.boolean().default(process.env.NODE_ENV === "production") diff --git a/packages/config/src/test/default_config.test.ts b/packages/config/src/test/default_config.test.ts index 8418626..8748b30 100644 --- a/packages/config/src/test/default_config.test.ts +++ b/packages/config/src/test/default_config.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import defaultConfig from "@/default_config.json" with { type: "json" } -import { ConfigFileSchema } from "@/schema.js" +import { ConfigFileSchema, FullConfigSchema } from "@/schema.js" describe("default config", () => { it("should validate against ConfigFileSchema", () => { @@ -12,4 +12,13 @@ describe("default config", () => { } expect(result.success).toBe(true) }) + + it("should validate against FullConfigSchema", () => { + const result = FullConfigSchema.safeParse(defaultConfig) + // If you want to be more specific about errors when validation fails + if (!result.success) { + console.error(result.error.issues) + } + expect(result.success).toBe(true) + }) }) diff --git a/packages/db/src/schemas/trips.ts b/packages/db/src/schemas/trips.ts index 25f7ccc..ba898be 100644 --- a/packages/db/src/schemas/trips.ts +++ b/packages/db/src/schemas/trips.ts @@ -18,6 +18,7 @@ export const tripsTable = pgTable("Trips", { export const TripSchema = z.object({ id: z.string().uuid(), + origin: z.string().optional().nullable(), from: z.string(), to: z.string(), departure: z.string(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a225626..a450aa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,8 +176,8 @@ catalogs: specifier: ^4.19.2 version: 4.19.2 turbo: - specifier: ^2.3.3 - version: 2.3.3 + specifier: ^2.3.4 + version: 2.3.4 typescript: specifier: ^5.7.2 version: 5.7.2 @@ -225,7 +225,7 @@ importers: version: 0.6.10(@ianvs/prettier-plugin-sort-imports@4.4.0(prettier@3.4.2))(prettier@3.4.2) turbo: specifier: catalog:tooling - version: 2.3.3 + version: 2.3.4 typescript: specifier: catalog:tooling version: 5.7.2 @@ -256,6 +256,9 @@ importers: hono: specifier: 'catalog:' version: 4.6.17 + ofetch: + specifier: 'catalog:' + version: 1.4.1 postgres: specifier: 'catalog:' version: 3.4.5 @@ -4714,38 +4717,38 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo-darwin-64@2.3.3: - resolution: {integrity: sha512-bxX82xe6du/3rPmm4aCC5RdEilIN99VUld4HkFQuw+mvFg6darNBuQxyWSHZTtc25XgYjQrjsV05888w1grpaA==} + turbo-darwin-64@2.3.4: + resolution: {integrity: sha512-uOi/cUIGQI7uakZygH+cZQ5D4w+aMLlVCN2KTGot+cmefatps2ZmRRufuHrEM0Rl63opdKD8/JIu+54s25qkfg==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.3.3: - resolution: {integrity: sha512-DYbQwa3NsAuWkCUYVzfOUBbSUBVQzH5HWUFy2Kgi3fGjIWVZOFk86ss+xsWu//rlEAfYwEmopigsPYSmW4X15A==} + turbo-darwin-arm64@2.3.4: + resolution: {integrity: sha512-IIM1Lq5R+EGMtM1YFGl4x8Xkr0MWb4HvyU8N4LNoQ1Be5aycrOE+VVfH+cDg/Q4csn+8bxCOxhRp5KmUflrVTQ==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.3.3: - resolution: {integrity: sha512-eHj9OIB0dFaP6BxB88jSuaCLsOQSYWBgmhy2ErCu6D2GG6xW3b6e2UWHl/1Ho9FsTg4uVgo4DB9wGsKa5erjUA==} + turbo-linux-64@2.3.4: + resolution: {integrity: sha512-1aD2EfR7NfjFXNH3mYU5gybLJEFi2IGOoKwoPLchAFRQ6OEJQj201/oNo9CDL75IIrQo64/NpEgVyZtoPlfhfA==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.3.3: - resolution: {integrity: sha512-NmDE/NjZoDj1UWBhMtOPmqFLEBKhzGS61KObfrDEbXvU3lekwHeoPvAMfcovzswzch+kN2DrtbNIlz+/rp8OCg==} + turbo-linux-arm64@2.3.4: + resolution: {integrity: sha512-MxTpdKwxCaA5IlybPxgGLu54fT2svdqTIxRd0TQmpRJIjM0s4kbM+7YiLk0mOh6dGqlWPUsxz/A0Mkn8Xr5o7Q==} cpu: [arm64] os: [linux] - turbo-windows-64@2.3.3: - resolution: {integrity: sha512-O2+BS4QqjK3dOERscXqv7N2GXNcqHr9hXumkMxDj/oGx9oCatIwnnwx34UmzodloSnJpgSqjl8iRWiY65SmYoQ==} + turbo-windows-64@2.3.4: + resolution: {integrity: sha512-yyCrWqcRGu1AOOlrYzRnizEtdkqi+qKP0MW9dbk9OsMDXaOI5jlWtTY/AtWMkLw/czVJ7yS9Ex1vi9DB6YsFvw==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.3.3: - resolution: {integrity: sha512-dW4ZK1r6XLPNYLIKjC4o87HxYidtRRcBeo/hZ9Wng2XM/MqqYkAyzJXJGgRMsc0MMEN9z4+ZIfnSNBrA0b08ag==} + turbo-windows-arm64@2.3.4: + resolution: {integrity: sha512-PggC3qH+njPfn1PDVwKrQvvQby8X09ufbqZ2Ha4uSu+5TvPorHHkAbZVHKYj2Y+tvVzxRzi4Sv6NdHXBS9Be5w==} cpu: [arm64] os: [win32] - turbo@2.3.3: - resolution: {integrity: sha512-DUHWQAcC8BTiUZDRzAYGvpSpGLiaOQPfYXlCieQbwUvmml/LRGIe3raKdrOPOoiX0DYlzxs2nH6BoWJoZrj8hA==} + turbo@2.3.4: + resolution: {integrity: sha512-1kiLO5C0Okh5ay1DbHsxkPsw9Sjsbjzm6cF85CpWjR0BIyBFNDbKqtUyqGADRS1dbbZoQanJZVj4MS5kk8J42Q==} hasBin: true type-check@0.4.0: @@ -8936,32 +8939,32 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo-darwin-64@2.3.3: + turbo-darwin-64@2.3.4: optional: true - turbo-darwin-arm64@2.3.3: + turbo-darwin-arm64@2.3.4: optional: true - turbo-linux-64@2.3.3: + turbo-linux-64@2.3.4: optional: true - turbo-linux-arm64@2.3.3: + turbo-linux-arm64@2.3.4: optional: true - turbo-windows-64@2.3.3: + turbo-windows-64@2.3.4: optional: true - turbo-windows-arm64@2.3.3: + turbo-windows-arm64@2.3.4: optional: true - turbo@2.3.3: + turbo@2.3.4: optionalDependencies: - turbo-darwin-64: 2.3.3 - turbo-darwin-arm64: 2.3.3 - turbo-linux-64: 2.3.3 - turbo-linux-arm64: 2.3.3 - turbo-windows-64: 2.3.3 - turbo-windows-arm64: 2.3.3 + turbo-darwin-64: 2.3.4 + turbo-darwin-arm64: 2.3.4 + turbo-linux-64: 2.3.4 + turbo-linux-arm64: 2.3.4 + turbo-windows-64: 2.3.4 + turbo-windows-arm64: 2.3.4 type-check@0.4.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 38e4dfa..02044ac 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,7 +46,7 @@ catalog: catalogs: tooling: - "turbo": ^2.3.3 + "turbo": ^2.3.4 "@turbo/gen": ^2.3.3 "husky": ^9.1.7 "@commitlint/cli": ^19.6.1 diff --git a/turbo.json b/turbo.json index 3b757cb..4e78021 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,8 @@ "ADMIN_EMAIL", "LOG_LEVEL", "LOG_TIMESTAMP", + "FEDERATION", + "FEDERATION_TARGETS", "DB_HOST", "DB_PORT", "DB_SSL",