feat: add hazards
This commit is contained in:
@ -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_TOKEN_URL` (the Token URL of your OIDC server)
|
||||||
- `OIDC_JWKS_URL` (the JWKS/Certificate 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)
|
- `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.
|
When configuring your OIDC server, make sure to enable Public Client and PCKE support.
|
||||||
|
|||||||
32
src/entities/Hazard.ts
Normal file
32
src/entities/Hazard.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
} from "typeorm";
|
} from "typeorm";
|
||||||
import { type Review } from "./Review";
|
import { type Review } from "./Review";
|
||||||
import { type Saved } from "./Saved";
|
import { type Saved } from "./Saved";
|
||||||
|
import type { Hazard } from "./Hazard";
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class User extends BaseEntity {
|
export class User extends BaseEntity {
|
||||||
@ -22,6 +23,9 @@ export class User extends BaseEntity {
|
|||||||
@OneToMany("Saved", (s: Saved) => s.user)
|
@OneToMany("Saved", (s: Saved) => s.user)
|
||||||
saved: Saved[];
|
saved: Saved[];
|
||||||
|
|
||||||
|
@OneToMany("Hazards", (h: Hazard) => h.user)
|
||||||
|
hazards: Hazard[];
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
default: () => "NOW()",
|
default: () => "NOW()",
|
||||||
})
|
})
|
||||||
|
|||||||
82
src/main.ts
82
src/main.ts
@ -10,6 +10,7 @@ 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 } from "typeorm";
|
import { ILike } from "typeorm";
|
||||||
|
import { Hazard } from "./entities/Hazard";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
|
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
|
||||||
@ -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) => {
|
app.get("/api/saved", async (c) => {
|
||||||
const authHeader = c.req.header("Authorization");
|
const authHeader = c.req.header("Authorization");
|
||||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user