From ef3debcdb36a20430c9d055585641c5e80d7390a Mon Sep 17 00:00:00 2001 From: Jannik Date: Mon, 22 Sep 2025 20:14:32 +0200 Subject: [PATCH] feat: initial work on stores --- bun.lock | 3 +- package.json | 3 +- src/db.ts | 3 +- src/entities/Saved.ts | 3 + src/entities/Stores.ts | 54 +++++++++++++ src/entities/User.ts | 7 ++ src/main.ts | 170 ++++++++++++++++++++++++++++++++++++++++- src/stores.ts | 44 +++++++++++ 8 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 src/entities/Stores.ts create mode 100644 src/stores.ts diff --git a/bun.lock b/bun.lock index 3db0622..74fd81e 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "testcontainers": "^11.5.1", "typeorm": "^0.3.26", "typescript-eslint": "^8.34.1", + "zod": "^4.1.11", }, "devDependencies": { "@types/bun": "latest", @@ -715,7 +716,7 @@ "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], - "zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="], + "zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], diff --git a/package.json b/package.json index a7315d1..ea1a410 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "reflect-metadata": "^0.2.2", "testcontainers": "^11.5.1", "typeorm": "^0.3.26", - "typescript-eslint": "^8.34.1" + "typescript-eslint": "^8.34.1", + "zod": "^4.1.11" } } diff --git a/src/db.ts b/src/db.ts index 4e77a9d..2b4948b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -4,6 +4,7 @@ import { User } from "./entities/User"; import { Review } from "./entities/Review"; import { Saved } from "./entities/Saved"; import { Hazard } from "./entities/Hazard"; +import { Store } from "./entities/Stores"; let db: DataSource | null = null; @@ -13,7 +14,7 @@ export function getDb(forceSync = false): DataSource { type: "postgres", url: process.env.DATABASE_URL, synchronize: process.argv.includes("sync") || forceSync, - entities: [User, Review, Saved, Hazard], + entities: [User, Review, Saved, Hazard, Store], }); } return db; diff --git a/src/entities/Saved.ts b/src/entities/Saved.ts index 569dbf9..b802035 100644 --- a/src/entities/Saved.ts +++ b/src/entities/Saved.ts @@ -7,6 +7,9 @@ import { } from "typeorm"; import { type User } from "./User"; +/** + * @deprecated Use stores instead + */ @Entity() export class Saved extends BaseEntity { @PrimaryGeneratedColumn() diff --git a/src/entities/Stores.ts b/src/entities/Stores.ts new file mode 100644 index 0000000..6378fab --- /dev/null +++ b/src/entities/Stores.ts @@ -0,0 +1,54 @@ +import { + BaseEntity, + Column, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { type User } from "./User"; + +export const STORE_PRIVACY = ["public", "friends", "private"] as const; +export type StorePrivacy = (typeof STORE_PRIVACY)[number]; + +export const STORE_TYPE = ["route", "location", "vehicle"] as const; +export type StoreType = (typeof STORE_TYPE)[number]; + +@Entity() +export class Store extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne("User", (u: User) => u.stores) + user: User; + + @Column() + name: string; + + @Column({ + type: "enum", + enum: STORE_TYPE + }) + type: StoreType; + + @Column({ + type: "text", + }) + data: string; + + @Column({ + type: "enum", + enum: STORE_PRIVACY, + default: "private", + }) + privacy: StorePrivacy; + + @Column({ + default: () => "NOW()", + }) + modified_at: Date; + + @Column({ + default: () => "NOW()", + }) + created_at: Date; +} diff --git a/src/entities/User.ts b/src/entities/User.ts index 6855fe3..95e7c99 100644 --- a/src/entities/User.ts +++ b/src/entities/User.ts @@ -8,6 +8,7 @@ import { import { type Review } from "./Review"; import { type Saved } from "./Saved"; import type { Hazard } from "./Hazard"; +import type { Store } from "./Stores"; @Entity() export class User extends BaseEntity { @@ -20,9 +21,15 @@ export class User extends BaseEntity { @OneToMany("Review", (r: Review) => r.user) reviews: Review[]; + /** + * @deprecated Use stores instead + */ @OneToMany("Saved", (s: Saved) => s.user) saved: Saved[]; + @OneToMany("Store", (s: Store) => s.user) + stores: Store[]; + @OneToMany("Hazard", (h: Hazard) => h.user) hazards: Hazard[]; diff --git a/src/main.ts b/src/main.ts index a4a1a7e..c1921be 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,8 +9,10 @@ import type { WSContext } from "hono/ws"; import { Review } from "./entities/Review"; import { User } from "./entities/User"; import { Saved } from "./entities/Saved"; -import { ILike } from "typeorm"; +import { ILike, type FindOptionsWhere } from "typeorm"; import { Hazard } from "./entities/Hazard"; +import { Store, STORE_PRIVACY, STORE_TYPE } from "./entities/Stores"; +import { storeTypes } from "./stores"; const app = new Hono(); const { upgradeWebSocket, websocket } = createBunWebSocket(); @@ -46,6 +48,14 @@ app.get("/api/config", (c) => { capabilities.push("fuel"); } + if (process.env.HAZARDS_ENABLED) { + capabilities.push("hazards"); + } + + if (process.env.STORES_ENABLED) { + capabilities.push("stores"); + } + return c.json({ name: "TrafficCue Server", env: process.env.ENV || "dev", @@ -362,6 +372,164 @@ app.delete("/api/saved", async (c) => { return c.json({ success: true }); }); +if (process.env.STORES_ENABLED) { + app.get("/api/stores/:user?", async (c) => { + const authHeader = c.req.header("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Unauthorized" }, 401); + } + const token = authHeader.split(" ")[1]; + if (!token) { + return c.json({ error: "Unauthorized" }, 401); + } + const isValid = await verifyToken(token); + if (!isValid) { + return c.json({ error: "Unauthorized" }, 401); + } + + const uid = await getTokenUID(token); + if (!uid) { + return c.json({ error: "Unauthorized" }, 401); + } + + // const user = await User.findOneBy( + // c.req.param("user") + // ? { username: c.req.param("user") } + // : { id: uid } + // ); + // if(!user) { + // return c.json({ error: "Invalid user ID" }, 400); + // } + + const where: FindOptionsWhere = c.req.param("user") + ? { user: { username: c.req.param("user") }, privacy: "public" } + : { user: { id: uid } }; + + const stores = await Store.find({ + where + }); + + return c.json(stores); + }); + + app.get("/api/store/:user?/:name", async (c) => { + const authHeader = c.req.header("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Unauthorized" }, 401); + } + const token = authHeader.split(" ")[1]; + if (!token) { + return c.json({ error: "Unauthorized" }, 401); + } + const isValid = await verifyToken(token); + if (!isValid) { + return c.json({ error: "Unauthorized" }, 401); + } + + const uid = await getTokenUID(token); + if (!uid) { + return c.json({ error: "Unauthorized" }, 401); + } + + const where: FindOptionsWhere = c.req.param("user") + ? { name: c.req.param("name"), user: { username: c.req.param("user") }, privacy: "public" } + : { name: c.req.param("name"), user: { id: uid } }; + + const store = await Store.findOne({ + where + }); + + return c.json(store); + }); + + app.put("/api/store", async (c) => { + const { name, data, privacy, type } = await c.req.json(); + if (!name || !data || !privacy || !type) { + return c.json({ error: "name, data, privacy and type are required" }, 400); + } + if(!STORE_PRIVACY.includes(privacy)) { + return c.json({ error: "Invalid privacy setting" }, 400); + } + if(!STORE_TYPE.includes(type)) { + return c.json({ error: "Invalid store type" }, 400); + } + + const authHeader = c.req.header("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Unauthorized" }, 401); + } + const token = authHeader.split(" ")[1]; + if (!token) { + return c.json({ error: "Unauthorized" }, 401); + } + const isValid = await verifyToken(token); + if (!isValid) { + return c.json({ error: "Unauthorized" }, 401); + } + + const uid = await getTokenUID(token); + if (!uid) { + return c.json({ error: "Unauthorized" }, 401); + } + + // Verify the store data is valid (prevent abusing the store as cloud storage) + const verifier = storeTypes[type as keyof typeof storeTypes]; + if(!(await verifier.safeParseAsync(JSON.parse(data))).success) { + return c.json({ error: "Invalid store data" }, 400); + } + + const user = await User.findOneBy({ id: uid }); + if (!user) { + return c.json({ error: "Invalid user ID" }, 400); + } + + const existingStore = await Store.findOneBy({ user: { id: uid }, name, type }); + + const store = existingStore || new Store(); + store.user = user; + store.name = name; + store.data = JSON.stringify(JSON.parse(data)); // Ensure data is minified JSON + store.privacy = privacy; + store.type = type; + await store.save(); + + return c.json({ success: true }); + }); + + app.delete("/api/store", async (c) => { + const { name } = await c.req.json(); + if (!name) { + return c.json({ error: "name is required" }, 400); + } + + const authHeader = c.req.header("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Unauthorized" }, 401); + } + const token = authHeader.split(" ")[1]; + if (!token) { + return c.json({ error: "Unauthorized" }, 401); + } + const isValid = await verifyToken(token); + if (!isValid) { + return c.json({ error: "Unauthorized" }, 401); + } + + const uid = await getTokenUID(token); + if (!uid) { + return c.json({ error: "Unauthorized" }, 401); + } + + const store = await Store.findOneBy({ user: { id: uid }, name }); + if (!store) { + return c.json({ error: "No such store found" }, 400); + } + await store.remove(); + + return c.json({ success: true }); + }); +} + if (process.env.TANKERKOENIG_API_KEY) { app.get("/api/fuel/list", async (c) => { // pass GET query parameters to the tankerkoenig API diff --git a/src/stores.ts b/src/stores.ts new file mode 100644 index 0000000..44aa124 --- /dev/null +++ b/src/stores.ts @@ -0,0 +1,44 @@ +import z from "zod"; +import type { StoreType } from "./entities/Stores"; + +export const locationStore = z.object({ + lat: z.number().min(-90).max(90), + lng: z.number().min(-180).max(180), + name: z.string().min(1).max(100) +}).strict(); + +export const vehicleStore = z.object({ + name: z.string().min(1).max(100), + legalMaxSpeed: z.number().min(0).max(300), + actualMaxSpeed: z.number().min(0).max(300), + type: z.enum([ + "car", "truck", "motorcycle", "bicycle", "motor_scooter" + ]), + weight: z.number().min(0).max(100000).optional(), + width: z.number().min(0).max(10).optional(), + axisLoad: z.number().min(0).max(100000).optional(), + height: z.number().min(0).max(20).optional(), + length: z.number().min(0).max(100).optional(), + emissionClass: z.string().min(1).max(50), + fuelType: z.enum(["petrol", "diesel", "electric"]), + preferredFuel: z.string().min(1).max(50) +}).strict(); + +export const routeStore = z.object({ + locations: z.array(z.object({ + lat: z.number().min(-90).max(90), + lon: z.number().min(-180).max(180) + })), + legs: z.array(z.object({ + shape: z.string(), + maneuvers: z.array(z.object({ + type: z.number() + })) + })) +}) + +export const storeTypes: Record = { + location: locationStore, + vehicle: vehicleStore, + route: routeStore, +};