feat: further work on stores
This commit is contained in:
@ -7,16 +7,13 @@ import {
|
|||||||
} from "typeorm";
|
} from "typeorm";
|
||||||
import { type User } from "./User";
|
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 const STORE_TYPE = ["route", "location", "vehicle"] as const;
|
||||||
export type StoreType = (typeof STORE_TYPE)[number];
|
export type StoreType = (typeof STORE_TYPE)[number];
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class Store extends BaseEntity {
|
export class Store extends BaseEntity {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn("uuid")
|
||||||
id: number;
|
id: string;
|
||||||
|
|
||||||
@ManyToOne("User", (u: User) => u.stores)
|
@ManyToOne("User", (u: User) => u.stores)
|
||||||
user: User;
|
user: User;
|
||||||
@ -35,13 +32,6 @@ export class Store extends BaseEntity {
|
|||||||
})
|
})
|
||||||
data: string;
|
data: string;
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: "enum",
|
|
||||||
enum: STORE_PRIVACY,
|
|
||||||
default: "private",
|
|
||||||
})
|
|
||||||
privacy: StorePrivacy;
|
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
default: () => "NOW()",
|
default: () => "NOW()",
|
||||||
})
|
})
|
||||||
|
|||||||
147
src/main.ts
147
src/main.ts
@ -9,10 +9,9 @@ import type { WSContext } from "hono/ws";
|
|||||||
import { Review } from "./entities/Review";
|
import { Review } from "./entities/Review";
|
||||||
import { User } from "./entities/User";
|
import { User } from "./entities/User";
|
||||||
import { Saved } from "./entities/Saved";
|
import { Saved } from "./entities/Saved";
|
||||||
import { ILike, type FindOptionsWhere } from "typeorm";
|
import { ILike } from "typeorm";
|
||||||
import { Hazard } from "./entities/Hazard";
|
import { Hazard } from "./entities/Hazard";
|
||||||
import { Store, STORE_PRIVACY, STORE_TYPE } from "./entities/Stores";
|
import { sync, SyncPayload } from "./stores";
|
||||||
import { storeTypes } from "./stores";
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
|
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
|
||||||
@ -373,7 +372,7 @@ app.delete("/api/saved", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (process.env.STORES_ENABLED) {
|
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");
|
const authHeader = c.req.header("Authorization");
|
||||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
@ -392,141 +391,23 @@ if (process.env.STORES_ENABLED) {
|
|||||||
return c.json({ error: "Unauthorized" }, 401);
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// const user = await User.findOneBy(
|
const user = await User.findOneBy(
|
||||||
// c.req.param("user")
|
c.req.param("user")
|
||||||
// ? { username: c.req.param("user") }
|
? { username: c.req.param("user") }
|
||||||
// : { id: uid }
|
: { id: uid }
|
||||||
// );
|
);
|
||||||
// if(!user) {
|
if(!user) {
|
||||||
// return c.json({ error: "Invalid user ID" }, 400);
|
|
||||||
// }
|
|
||||||
|
|
||||||
const where: FindOptionsWhere<Store> = 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<Store> = 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);
|
return c.json({ error: "Invalid user ID" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingStore = await Store.findOneBy({ user: { id: uid }, name, type });
|
const payload = SyncPayload.safeParse(await c.req.json());
|
||||||
|
if (!payload.success) {
|
||||||
const store = existingStore || new Store();
|
return c.json({ error: "Invalid payload", details: payload.error }, 400);
|
||||||
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");
|
const { changes } = await sync(payload.data, user);
|
||||||
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);
|
return c.json({ changes });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import z from "zod";
|
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({
|
export const locationStore = z.object({
|
||||||
lat: z.number().min(-90).max(90),
|
lat: z.number().min(-90).max(90),
|
||||||
@ -42,3 +43,46 @@ export const storeTypes: Record<StoreType, z.ZodSchema> = {
|
|||||||
vehicle: vehicleStore,
|
vehicle: vehicleStore,
|
||||||
route: routeStore,
|
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<typeof SyncPayload>;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user