diff --git a/README.md b/README.md index bb167e6..148b3e8 100644 --- a/README.md +++ b/README.md @@ -64,5 +64,6 @@ You can run this yourself to host your own instance, or contribute to the offici - `OIDC_TOKEN_URL` (the Token URL of your OIDC server) - `OIDC_JWKS_URL` (the JWKS/Certificate URL of your OIDC server) - `REVIEWS_ENABLED` (optional, set to `true` to enable POI reviews by users, requires OIDC) + - `HAZARDS_ENABLED` (optional, set to `true` to enable hazard reporting by users, requires OIDC) When configuring your OIDC server, make sure to enable Public Client and PCKE support. diff --git a/src/entities/Hazard.ts b/src/entities/Hazard.ts new file mode 100644 index 0000000..c13c8f1 --- /dev/null +++ b/src/entities/Hazard.ts @@ -0,0 +1,32 @@ +import { BaseEntity, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import type { User } from "./User"; + +@Entity() +export class Hazard extends BaseEntity { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ + type: "float" + }) + latitude: number; + + @Column({ + type: "float" + }) + longitude: number; + + @Column() + type: string; + + @Column() + votes: number; + + @Column({ + default: () => "NOW()", + }) + created_at: Date; + + @ManyToOne("User", (u: User) => u.hazards) + user: User; +} \ No newline at end of file diff --git a/src/entities/User.ts b/src/entities/User.ts index 254c5e4..2b5e2e6 100644 --- a/src/entities/User.ts +++ b/src/entities/User.ts @@ -7,6 +7,7 @@ import { } from "typeorm"; import { type Review } from "./Review"; import { type Saved } from "./Saved"; +import type { Hazard } from "./Hazard"; @Entity() export class User extends BaseEntity { @@ -22,6 +23,9 @@ export class User extends BaseEntity { @OneToMany("Saved", (s: Saved) => s.user) saved: Saved[]; + @OneToMany("Hazards", (h: Hazard) => h.user) + hazards: Hazard[]; + @Column({ default: () => "NOW()", }) diff --git a/src/main.ts b/src/main.ts index ac45aba..63e9f8a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,7 @@ import { Review } from "./entities/Review"; import { User } from "./entities/User"; import { Saved } from "./entities/Saved"; import { ILike } from "typeorm"; +import { Hazard } from "./entities/Hazard"; const app = new Hono(); const { upgradeWebSocket, websocket } = createBunWebSocket(); @@ -165,6 +166,87 @@ if (process.env.REVIEWS_ENABLED) { }); } +const VALID_HAZARD_TYPES = [ + "bumpy-road" +]; + +if (process.env.HAZARDS_ENABLED) { + app.get("/api/hazards", async (c) => { + const { lat, lon, radius } = c.req.query(); + if (!lat || !lon || !radius) { + return c.json({ error: "Latitude, longitude, and radius are required" }, 400); + } + // Remove unnecessary precision from lat/lon + const nlat = Number(parseFloat(lat).toFixed(4)); + const nlon = Number(parseFloat(lon).toFixed(4)); + const nradius = Number(radius); + if(isNaN(nradius) || nradius <= 0) { + return c.json({ error: "Invalid radius" }, 400); + } + if(nradius > 1000) { + return c.json({ error: "Radius too large, max is 1000 km" }, 400); + } + console.log(`Fetching hazards for lat: ${lat}, lon: ${lon}, radius: ${radius} km`); + const hazards = await Hazard.createQueryBuilder("hazard") // Crazy math to get a rough bounding box for the radius in km + .where("hazard.latitude BETWEEN :minLat AND :maxLat", { minLat: nlat - nradius / 110.574, maxLat: nlat + nradius / 110.574 }) + .andWhere("hazard.longitude BETWEEN :minLon AND :maxLon", { minLon: nlon - nradius / (111.320 * Math.cos(nlat * (Math.PI / 180))), maxLon: nlon + nradius / (111.320 * Math.cos(nlat * (Math.PI / 180))) }) + .getMany(); + return c.json(hazards); + }); + + app.post("/api/hazards", async (c) => { + const { type, lat, lon } = await c.req.json(); + if (!type || !lat || !lon) { + return c.json( + { error: "Type, latitude, and longitude are required" }, + 400, + ); + } + if(!VALID_HAZARD_TYPES.includes(type)) { + return c.json( + { error: "Invalid hazard 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); + } + + const user = await User.findOneBy({ id: uid }); + if (!user) { + return c.json({ error: "Invalid user ID" }, 400); + } + + // Remove unnecessary precision from lat/lon + const nlat = Number(parseFloat(lat).toFixed(4)); + const nlon = Number(parseFloat(lon).toFixed(4)); + + const hazard = new Hazard(); + hazard.latitude = nlat; + hazard.longitude = nlon; + hazard.type = type; + hazard.user = user; + await hazard.save(); + + return c.json({ success: true }); + }); +} + app.get("/api/saved", async (c) => { const authHeader = c.req.header("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) {