From 8d2eb1d9da296f66fcffcc51f74d19bdf7a2f928 Mon Sep 17 00:00:00 2001 From: Jannik Date: Fri, 26 Sep 2025 19:49:39 +0200 Subject: [PATCH] feat: further work on stores --- src/entities/Stores.ts | 14 +--- src/main.ts | 147 ++++------------------------------------- src/stores.ts | 46 ++++++++++++- 3 files changed, 61 insertions(+), 146 deletions(-) diff --git a/src/entities/Stores.ts b/src/entities/Stores.ts index 6378fab..30f7fec 100644 --- a/src/entities/Stores.ts +++ b/src/entities/Stores.ts @@ -7,16 +7,13 @@ import { } 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; + @PrimaryGeneratedColumn("uuid") + id: string; @ManyToOne("User", (u: User) => u.stores) user: User; @@ -35,13 +32,6 @@ export class Store extends BaseEntity { }) data: string; - @Column({ - type: "enum", - enum: STORE_PRIVACY, - default: "private", - }) - privacy: StorePrivacy; - @Column({ default: () => "NOW()", }) diff --git a/src/main.ts b/src/main.ts index c1921be..ed81b9b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,10 +9,9 @@ import type { WSContext } from "hono/ws"; import { Review } from "./entities/Review"; import { User } from "./entities/User"; import { Saved } from "./entities/Saved"; -import { ILike, type FindOptionsWhere } from "typeorm"; +import { ILike } from "typeorm"; import { Hazard } from "./entities/Hazard"; -import { Store, STORE_PRIVACY, STORE_TYPE } from "./entities/Stores"; -import { storeTypes } from "./stores"; +import { sync, SyncPayload } from "./stores"; const app = new Hono(); const { upgradeWebSocket, websocket } = createBunWebSocket(); @@ -373,7 +372,7 @@ app.delete("/api/saved", async (c) => { }); if (process.env.STORES_ENABLED) { - app.get("/api/stores/:user?", async (c) => { + app.post("/api/stores/sync", async (c) => { const authHeader = c.req.header("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { return c.json({ error: "Unauthorized" }, 401); @@ -392,141 +391,23 @@ if (process.env.STORES_ENABLED) { 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) { + 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 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 payload = SyncPayload.safeParse(await c.req.json()); + if (!payload.success) { + return c.json({ error: "Invalid payload", details: payload.error }, 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 { changes } = await sync(payload.data, user); - 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 }); + return c.json({ changes }); }); } diff --git a/src/stores.ts b/src/stores.ts index 44aa124..d0f4154 100644 --- a/src/stores.ts +++ b/src/stores.ts @@ -1,5 +1,6 @@ import z from "zod"; -import type { StoreType } from "./entities/Stores"; +import { Store, type StoreType } from "./entities/Stores"; +import type { User } from "./entities/User"; export const locationStore = z.object({ lat: z.number().min(-90).max(90), @@ -42,3 +43,46 @@ export const storeTypes: Record = { vehicle: vehicleStore, route: routeStore, }; + +export const SyncPayload = z.object({ + changes: z.array(z.object({ + id: z.uuid(), + operation: z.enum(["create", "update", "delete"]), + data: z.string(), + modified_at: z.string().refine(val => !isNaN(Date.parse(val)), { message: "Invalid date" }) + })), + stores: z.array(z.object({ + id: z.uuid(), + modified_at: z.string().refine(val => !isNaN(Date.parse(val)), { message: "Invalid date" }) + })) +}) + +export type SyncPayload = z.infer; + +export async function sync(payload: SyncPayload, user: User) { + const changes: Store[] = []; + // Apply changes from client + for(const change of payload.changes) { + const store = await Store.findOneBy({ id: change.id }); + if(!store) continue; + if(store.user.id !== user.id) { + // Not the owner of this store + changes.push(store); // Send back the store to the client to overwrite their changes + continue; + } + store.data = change.data; + store.modified_at = new Date(change.modified_at); + await store.save(); + } + + // Find stores that are out of date on the client + const allStores = await Store.findBy({ user: { id: user.id } }); // TODO: include friends' public stores, TODO: use SQL query to only get modified stores + for(const store of allStores) { + const clientStore = payload.stores.find(s => s.id === store.id); + if(!clientStore || new Date(clientStore.modified_at) < store.modified_at) { + changes.push(store); // Client doesn't have this store or it's out of date + } + } + + return { changes }; +}