diff --git a/packages/api/prisma/migrations/20211101110733_/migration.sql b/packages/api/prisma/migrations/20211101110733_/migration.sql new file mode 100644 index 000000000..0aaa99c8b --- /dev/null +++ b/packages/api/prisma/migrations/20211101110733_/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "cad" ADD COLUMN "apiTokenId" TEXT; + +-- CreateTable +CREATE TABLE "ApiToken" ( + "id" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "token" TEXT, + "routes" TEXT[], + + CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "cad" ADD CONSTRAINT "cad_apiTokenId_fkey" FOREIGN KEY ("apiTokenId") REFERENCES "ApiToken"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 2e6fd183d..aed0afa0c 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -28,6 +28,8 @@ model cad { disabledFeatures Feature[] miscCadSettings MiscCadSettings? @relation(fields: [miscCadSettingsId], references: [id]) miscCadSettingsId String? + apiToken ApiToken? @relation(fields: [apiTokenId], references: [id]) + apiTokenId String? } model MiscCadSettings { @@ -45,6 +47,16 @@ model MiscCadSettings { cad cad[] } +model ApiToken { + id String @id @default(cuid()) + enabled Boolean @default(false) + token String? + // empty = * + routes String[] + + cad cad[] +} + model User { id String @id @default(cuid()) username String @unique @db.VarChar(255) diff --git a/packages/api/src/controllers/admin/Values.ts b/packages/api/src/controllers/admin/Values.ts index 4793c3b25..5b10e569e 100644 --- a/packages/api/src/controllers/admin/Values.ts +++ b/packages/api/src/controllers/admin/Values.ts @@ -1,19 +1,11 @@ import { ValueType, PrismaClient } from ".prisma/client"; import { CREATE_PENAL_CODE_SCHEMA, validate, VALUE_SCHEMA } from "@snailycad/schemas"; -import { - Get, - Controller, - PathParams, - UseBeforeEach, - UseBefore, - BodyParams, - QueryParams, -} from "@tsed/common"; +import { Get, Controller, PathParams, UseBeforeEach, BodyParams, QueryParams } from "@tsed/common"; import { Delete, JsonRequestBody, Patch, Post, Put } from "@tsed/schema"; import { prisma } from "../../lib/prisma"; -import { IsAdmin } from "../../middlewares/Permissions"; import { IsValidPath } from "../../middlewares/ValidPath"; import { BadRequest, NotFound } from "@tsed/exceptions"; +import { IsAuth } from "../../middlewares"; type NameType = Exclude< keyof PrismaClient, @@ -42,7 +34,7 @@ const GET_VALUES: Partial> }; @Controller("/admin/values/:path") -@UseBeforeEach(IsValidPath) +@UseBeforeEach(IsAuth, IsValidPath) export class ValuesController { @Get("/") async getValueByPath(@PathParams("path") path: string, @QueryParams("paths") rawPaths: string) { @@ -89,7 +81,6 @@ export class ValuesController { return values; } - @UseBefore(IsAdmin) @Post("/") async createValueByPath(@BodyParams() body: JsonRequestBody, @PathParams("path") path: string) { const type = this.getTypeFromPath(path); @@ -241,7 +232,6 @@ export class ValuesController { return value; } - @UseBefore(IsAdmin) @Delete("/:id") async deleteValueByPathAndId(@PathParams("id") id: string, @PathParams("path") path: string) { const type = this.getTypeFromPath(path); @@ -284,7 +274,6 @@ export class ValuesController { return true; } - @UseBefore(IsAdmin) @Patch("/:id") async patchValueByPathAndId( @BodyParams() body: JsonRequestBody, @@ -465,7 +454,6 @@ export class ValuesController { } @Put("/positions") - @UseBefore(IsAdmin) async updatePositions(@BodyParams() body: JsonRequestBody) { const ids = body.get("ids"); diff --git a/packages/api/src/controllers/admin/manage/Businesses.ts b/packages/api/src/controllers/admin/manage/Businesses.ts index 08bef41f3..a1dbaf97c 100644 --- a/packages/api/src/controllers/admin/manage/Businesses.ts +++ b/packages/api/src/controllers/admin/manage/Businesses.ts @@ -5,9 +5,9 @@ import { BodyParams, Context, PathParams } from "@tsed/platform-params"; import { Delete, Get, JsonRequestBody } from "@tsed/schema"; import { userProperties } from "../../../lib/auth"; import { prisma } from "../../../lib/prisma"; -import { IsAuth, IsAdmin } from "../../../middlewares"; +import { IsAuth } from "../../../middlewares"; -@UseBeforeEach(IsAuth, IsAdmin) +@UseBeforeEach(IsAuth) @Controller("/businesses-admin") export class ManageBusinessesController { @Get("/") diff --git a/packages/api/src/controllers/admin/manage/CadSettings.ts b/packages/api/src/controllers/admin/manage/CadSettings.ts index dd6823983..6d72aa31c 100644 --- a/packages/api/src/controllers/admin/manage/CadSettings.ts +++ b/packages/api/src/controllers/admin/manage/CadSettings.ts @@ -6,12 +6,13 @@ import { } from "@snailycad/schemas"; import { Controller } from "@tsed/di"; import { BodyParams, Context } from "@tsed/platform-params"; -import { Get, JsonRequestBody, Put } from "@tsed/schema"; +import { Delete, Get, JsonRequestBody, Put } from "@tsed/schema"; import { prisma } from "../../../lib/prisma"; -import { IsAuth, IsOwner } from "../../../middlewares"; +import { IsAuth } from "../../../middlewares"; import { BadRequest } from "@tsed/exceptions"; import { UseBefore } from "@tsed/common"; import { Socket } from "../../../services/SocketService"; +import { nanoid } from "nanoid"; @Controller("/cad-settings") export class ManageCitizensController { @@ -34,7 +35,7 @@ export class ManageCitizensController { return { ...cad, registrationCode: !!cad!.registrationCode }; } - @UseBefore(IsAuth, IsOwner) + @UseBefore(IsAuth) @Put("/") async updateCadSettings(@Context() ctx: Context, @BodyParams() body: JsonRequestBody) { const error = validate(CAD_SETTINGS_SCHEMA, body.toJSON(), true); @@ -63,7 +64,7 @@ export class ManageCitizensController { return updated; } - @UseBefore(IsAuth, IsOwner) + @UseBefore(IsAuth) @Put("/features") async updateDisabledFeatures(@Context() ctx: Context, @BodyParams() body: JsonRequestBody) { const error = validate(DISABLED_FEATURES_SCHEMA, body.toJSON(), true); @@ -83,7 +84,7 @@ export class ManageCitizensController { return updated; } - @UseBefore(IsAuth, IsOwner) + @UseBefore(IsAuth) @Put("/misc") async updateMiscSettings(@Context() ctx: Context, @BodyParams() body: JsonRequestBody) { const error = validate(CAD_MISC_SETTINGS_SCHEMA, body.toJSON(), true); @@ -106,4 +107,61 @@ export class ManageCitizensController { return updated; } + + @UseBefore(IsAuth) + @Put("/api-token") + async updateApiToken(@Context() ctx: Context, @BodyParams() body: JsonRequestBody) { + const cad = ctx.get("cad"); + + const existing = + cad.apiTokenId && + (await prisma.apiToken.findFirst({ + where: { + id: cad.apiTokenId, + }, + })); + + if (existing) { + const updated = await prisma.apiToken.update({ + where: { + id: existing.id, + }, + data: { + enabled: body.get("enabled"), + }, + }); + + return updated; + } + + const apiToken = await prisma.apiToken.create({ + data: { + cad: { connect: { id: cad.id } }, + token: nanoid(56), + }, + }); + + return apiToken; + } + + @UseBefore(IsAuth) + @Delete("/api-token") + async regenerateApiToken(@Context() ctx: Context) { + const cad = ctx.get("cad"); + + if (!cad.apiTokenId) { + throw new BadRequest("noApiTokenId"); + } + + const updated = await prisma.apiToken.update({ + where: { + id: cad.apiTokenId, + }, + data: { + token: nanoid(56), + }, + }); + + return updated; + } } diff --git a/packages/api/src/controllers/admin/manage/Citizens.ts b/packages/api/src/controllers/admin/manage/Citizens.ts index f87945c5d..8c3c4a760 100644 --- a/packages/api/src/controllers/admin/manage/Citizens.ts +++ b/packages/api/src/controllers/admin/manage/Citizens.ts @@ -5,9 +5,9 @@ import { BodyParams, Context, PathParams } from "@tsed/platform-params"; import { Delete, Get, JsonRequestBody } from "@tsed/schema"; import { userProperties } from "../../../lib/auth"; import { prisma } from "../../../lib/prisma"; -import { IsAuth, IsAdmin } from "../../../middlewares"; +import { IsAuth } from "../../../middlewares"; -@UseBeforeEach(IsAuth, IsAdmin) +@UseBeforeEach(IsAuth) @Controller("/citizens") export class ManageCitizensController { @Get("/") diff --git a/packages/api/src/controllers/admin/manage/Units.ts b/packages/api/src/controllers/admin/manage/Units.ts index 204e3dd1d..77b5f0e41 100644 --- a/packages/api/src/controllers/admin/manage/Units.ts +++ b/packages/api/src/controllers/admin/manage/Units.ts @@ -5,11 +5,11 @@ import { UseBeforeEach } from "@tsed/platform-middlewares"; import { Get, JsonRequestBody, Put } from "@tsed/schema"; import { unitProperties } from "../../../lib/officer"; import { prisma } from "../../../lib/prisma"; -import { IsAuth, IsSupervisor } from "../../../middlewares"; +import { IsAuth } from "../../../middlewares"; const include = unitProperties; -@UseBeforeEach(IsAuth, IsSupervisor) +@UseBeforeEach(IsAuth) @Controller("/units") export class ManageUnitsController { @Get("/") diff --git a/packages/api/src/controllers/admin/manage/Users.ts b/packages/api/src/controllers/admin/manage/Users.ts index 9e88f66a4..4fe290de0 100644 --- a/packages/api/src/controllers/admin/manage/Users.ts +++ b/packages/api/src/controllers/admin/manage/Users.ts @@ -6,13 +6,13 @@ import { UseBeforeEach } from "@tsed/platform-middlewares"; import { Delete, Get, JsonRequestBody, Post, Put } from "@tsed/schema"; import { userProperties } from "../../../lib/auth"; import { prisma } from "../../../lib/prisma"; -import { IsAuth, IsAdmin } from "../../../middlewares"; +import { IsAuth } from "../../../middlewares"; import { BAN_SCHEMA, UPDATE_USER_SCHEMA, validate } from "@snailycad/schemas"; import { Socket } from "../../../services/SocketService"; import { nanoid } from "nanoid"; import { genSaltSync, hashSync } from "bcrypt"; -@UseBeforeEach(IsAuth, IsAdmin) +@UseBeforeEach(IsAuth) @Controller("/users") export class ManageUsersController { private socket: Socket; @@ -22,7 +22,9 @@ export class ManageUsersController { @Get("/") async getUsers() { - const users = await prisma.user.findMany(); + const users = await prisma.user.findMany({ + select: userProperties, + }); return users; } diff --git a/packages/api/src/controllers/dispatch/Calls911Controller.ts b/packages/api/src/controllers/dispatch/Calls911Controller.ts index f1fe13cf4..cbb583485 100644 --- a/packages/api/src/controllers/dispatch/Calls911Controller.ts +++ b/packages/api/src/controllers/dispatch/Calls911Controller.ts @@ -7,8 +7,7 @@ import { BadRequest, NotFound } from "@tsed/exceptions"; import { prisma } from "../../lib/prisma"; import { Socket } from "../../services/SocketService"; import { UseBeforeEach } from "@tsed/platform-middlewares"; -import { IsAuth, IsDispatch } from "../../middlewares"; -import { UseBefore } from "@tsed/common"; +import { IsAuth } from "../../middlewares"; import { ShouldDoType, Officer, EmsFdDeputy } from ".prisma/client"; import { unitProperties } from "../../lib/officer"; @@ -71,7 +70,6 @@ export class Calls911Controller { return this.officerOrDeputyToUnit(call); } - @UseBefore(IsDispatch) @Put("/:id") async update911Call( @PathParams("id") id: string, @@ -167,7 +165,6 @@ export class Calls911Controller { return this.officerOrDeputyToUnit(updated); } - @UseBefore(IsDispatch) @Delete("/:id") async end911Call(@PathParams("id") id: string) { const call = await prisma.call911.findUnique({ @@ -181,7 +178,6 @@ export class Calls911Controller { return true; } - @UseBefore(IsDispatch) @Post("/events/:callId") async createCallEvent(@PathParams("callId") callId: string, @BodyParams() body: JsonRequestBody) { if (!body.get("description")) { @@ -208,7 +204,6 @@ export class Calls911Controller { return event; } - @UseBefore(IsDispatch) @Put("/events/:callId/:eventId") async updateCallEvent( @PathParams("callId") callId: string, @@ -252,7 +247,6 @@ export class Calls911Controller { return updated; } - @UseBefore(IsDispatch) @Delete("/events/:callId/:eventId") async deleteCallEvent( @PathParams("callId") callId: string, diff --git a/packages/api/src/controllers/dispatch/DispatchController.ts b/packages/api/src/controllers/dispatch/DispatchController.ts index 55f426edc..a4467d638 100644 --- a/packages/api/src/controllers/dispatch/DispatchController.ts +++ b/packages/api/src/controllers/dispatch/DispatchController.ts @@ -4,8 +4,8 @@ import { BodyParams, Context } from "@tsed/platform-params"; import { BadRequest } from "@tsed/exceptions"; import { prisma } from "../../lib/prisma"; import { Socket } from "../../services/SocketService"; -import { UseBefore, UseBeforeEach } from "@tsed/platform-middlewares"; -import { IsAuth, IsDispatch } from "../../middlewares"; +import { UseBeforeEach } from "@tsed/platform-middlewares"; +import { IsAuth } from "../../middlewares"; import { cad } from ".prisma/client"; @Controller("/dispatch") @@ -44,7 +44,6 @@ export class Calls911Controller { return { deputies, officers }; } - @UseBefore(IsDispatch) @Post("/aop") async updateAreaOfPlay(@Context("cad") cad: cad, @BodyParams() body: JsonRequestBody) { if (!body.get("aop")) { @@ -66,7 +65,6 @@ export class Calls911Controller { return updated; } - @UseBefore(IsDispatch) @Post("/signal-100") async setSignal100(@Context("cad") cad: cad, @BodyParams("value") value: boolean) { if (typeof value !== "boolean") { diff --git a/packages/api/src/controllers/dispatch/SearchController.ts b/packages/api/src/controllers/dispatch/SearchController.ts index ddd036d89..6133725b2 100644 --- a/packages/api/src/controllers/dispatch/SearchController.ts +++ b/packages/api/src/controllers/dispatch/SearchController.ts @@ -3,10 +3,10 @@ import { Post } from "@tsed/schema"; import { NotFound } from "@tsed/exceptions"; import { BodyParams } from "@tsed/platform-params"; import { prisma } from "../../lib/prisma"; -import { IsAuth, IsDispatch } from "../../middlewares"; +import { IsAuth } from "../../middlewares"; @Controller("/search") -@UseBeforeEach(IsAuth, IsDispatch) +@UseBeforeEach(IsAuth) export class SearchController { @Post("/address") async searchAddress(@BodyParams("address") address: string) { diff --git a/packages/api/src/controllers/ems-fd/EmsFdController.ts b/packages/api/src/controllers/ems-fd/EmsFdController.ts index c0b53f5ec..83a86add6 100644 --- a/packages/api/src/controllers/ems-fd/EmsFdController.ts +++ b/packages/api/src/controllers/ems-fd/EmsFdController.ts @@ -6,7 +6,6 @@ import { Req, MultipartFile, PlatformMulterFile, - UseBefore, } from "@tsed/common"; import { Delete, Get, JsonRequestBody, Post, Put } from "@tsed/schema"; import { @@ -21,7 +20,7 @@ import { prisma } from "../../lib/prisma"; import { cad, ShouldDoType, MiscCadSettings, User } from ".prisma/client"; import { setCookie } from "../../utils/setCookie"; import { AllowedFileExtension, allowedFileExtensions, Cookie } from "@snailycad/config"; -import { IsAuth, IsEmsFd } from "../../middlewares"; +import { IsAuth } from "../../middlewares"; import { signJWT } from "../../utils/jwt"; import { Socket } from "../../services/SocketService"; import { getWebhookData, sendDiscordWebhook } from "../../lib/discord"; @@ -38,7 +37,6 @@ export class EmsFdController { this.socket = socket; } - @UseBefore(IsEmsFd) @Get("/") async getUserDeputies(@Context("user") user: User) { const deputies = await prisma.emsFdDeputy.findMany({ @@ -59,7 +57,6 @@ export class EmsFdController { return { deputies, citizens }; } - @UseBefore(IsEmsFd) @Post("/") async createEmsFdDeputy(@BodyParams() body: JsonRequestBody, @Context("user") user: User) { const error = validate(CREATE_OFFICER_SCHEMA, body.toJSON(), true); @@ -105,7 +102,6 @@ export class EmsFdController { return deputy; } - @UseBefore(IsEmsFd) @Put("/:id") async updateDeputy( @PathParams("id") deputyId: string, @@ -272,7 +268,6 @@ export class EmsFdController { return updatedDeputy; } - @UseBefore(IsEmsFd) @Delete("/:id") async deleteDeputy(@PathParams("id") id: string, @Context() ctx: Context) { const deputy = await prisma.emsFdDeputy.findFirst({ @@ -373,7 +368,6 @@ export class EmsFdController { return updated; } - @UseBefore(IsEmsFd) @Post("/image/:id") async uploadImageToOfficer( @Context("user") user: User, diff --git a/packages/api/src/controllers/leo/LeoController.ts b/packages/api/src/controllers/leo/LeoController.ts index 5cc95b171..cd88c5c7a 100644 --- a/packages/api/src/controllers/leo/LeoController.ts +++ b/packages/api/src/controllers/leo/LeoController.ts @@ -15,7 +15,7 @@ import { prisma } from "../../lib/prisma"; import { Officer, cad, ShouldDoType, MiscCadSettings, User } from ".prisma/client"; import { setCookie } from "../../utils/setCookie"; import { AllowedFileExtension, allowedFileExtensions, Cookie } from "@snailycad/config"; -import { IsAuth, IsLeo } from "../../middlewares"; +import { IsAuth } from "../../middlewares"; import { signJWT } from "../../utils/jwt"; import { ActiveOfficer } from "../../middlewares/ActiveOfficer"; import { Socket } from "../../services/SocketService"; @@ -33,7 +33,6 @@ export class LeoController { this.socket = socket; } - @UseBefore(IsLeo) @Get("/") async getUserOfficers(@Context() ctx: Context) { const officers = await prisma.officer.findMany({ @@ -54,7 +53,6 @@ export class LeoController { return { officers, citizens }; } - @UseBefore(IsLeo) @Post("/") async createOfficer(@BodyParams() body: JsonRequestBody, @Context("user") user: User) { const error = validate(CREATE_OFFICER_SCHEMA, body.toJSON(), true); @@ -101,7 +99,6 @@ export class LeoController { return officer; } - @UseBefore(IsLeo) @Put("/:id") async updateOfficer( @PathParams("id") officerId: string, @@ -301,7 +298,6 @@ export class LeoController { return updatedOfficer; } - @UseBefore(IsLeo) @Delete("/:id") async deleteOfficer(@PathParams("id") officerId: string, @Context() ctx: Context) { const officer = await prisma.officer.findFirst({ @@ -324,7 +320,6 @@ export class LeoController { return true; } - @UseBefore(IsLeo) @Get("/logs") async getOfficerLogs(@Context() ctx: Context) { const logs = await prisma.officerLog.findMany({ @@ -366,7 +361,6 @@ export class LeoController { return Array.isArray(officers) ? officers : [officers]; } - @UseBefore(IsLeo) @Post("/image/:id") async uploadImageToOfficer( @Context("user") user: User, @@ -441,7 +435,6 @@ export class LeoController { this.socket.emitPanicButtonLeo(fullOfficer); } - @UseBefore(IsLeo) @Get("/impounded-vehicles") async getImpoundedVehicles() { const vehicles = await prisma.impoundedVehicle.findMany({ @@ -456,7 +449,6 @@ export class LeoController { return vehicles; } - @UseBefore(IsLeo) @Delete("/impounded-vehicles/:id") async checkoutImpoundedVehicle(@PathParams("id") id: string) { const vehicle = await prisma.impoundedVehicle.findUnique({ diff --git a/packages/api/src/controllers/leo/SearchController.ts b/packages/api/src/controllers/leo/SearchController.ts index 1871d1c7c..a3e716d11 100644 --- a/packages/api/src/controllers/leo/SearchController.ts +++ b/packages/api/src/controllers/leo/SearchController.ts @@ -118,12 +118,12 @@ export class SearchController { return null; } - // not using Prisma's `OR` since it doesn't seem to be working 🤔 - let vehicle = await prisma.registeredVehicle.findFirst({ + const vehicle = await prisma.registeredVehicle.findFirst({ where: { - plate: { - startsWith: plateOrVin.toUpperCase(), - }, + OR: [ + { plate: { startsWith: plateOrVin.toUpperCase() } }, + { vinNumber: { startsWith: plateOrVin } }, + ], }, include: { citizen: true, @@ -132,21 +132,6 @@ export class SearchController { }, }); - if (!vehicle) { - vehicle = await prisma.registeredVehicle.findFirst({ - where: { - vinNumber: { - startsWith: plateOrVin, - }, - }, - include: { - citizen: true, - model: { include: { value: true } }, - registrationStatus: true, - }, - }); - } - if (!vehicle) { throw new NotFound("vehicleNotFound"); } diff --git a/packages/api/src/lib/auth.ts b/packages/api/src/lib/auth.ts index 695a53e7f..fae0800ed 100644 --- a/packages/api/src/lib/auth.ts +++ b/packages/api/src/lib/auth.ts @@ -4,6 +4,7 @@ import { parse } from "cookie"; import { Cookie } from "@snailycad/config"; import { verifyJWT } from "../utils/jwt"; import { prisma } from "./prisma"; +import { User } from ".prisma/client"; export const userProperties = { id: true, @@ -23,31 +24,37 @@ export const userProperties = { tempPassword: true, }; -export async function getSessionUser(req: Req) { +export async function getSessionUser(req: Req, throwErrors = false): Promise { const header = req.headers.cookie; - if (!header) { + if (throwErrors && !header) { throw new Unauthorized("Unauthorized"); } - const cookie = parse(header)[Cookie.Session]; + const cookie = parse(header ?? "")[Cookie.Session]; const jwtPayload = verifyJWT(cookie!); - if (!jwtPayload) { + if (throwErrors && !jwtPayload) { throw new Unauthorized("Unauthorized"); } - const user = await prisma.user.findUnique({ - where: { - id: jwtPayload.userId, - }, - select: userProperties, - }); + const user = jwtPayload + ? await prisma.user.findUnique({ + where: { + id: jwtPayload?.userId, + }, + select: userProperties, + }) + : null; + + if (!throwErrors && !user) { + return null as unknown as User; + } - if (!user) { + if (throwErrors && !user) { throw new NotFound("notFound"); } - const { tempPassword, ...rest } = user; - return { ...rest, hasTempPassword: !!tempPassword }; + const { tempPassword, ...rest } = user! ?? {}; + return { ...rest, tempPassword: null, hasTempPassword: !!tempPassword } as unknown as User; } diff --git a/packages/api/src/lib/officer.ts b/packages/api/src/lib/officer.ts index 6ea00c706..e312854bd 100644 --- a/packages/api/src/lib/officer.ts +++ b/packages/api/src/lib/officer.ts @@ -1,9 +1,9 @@ +import { User } from ".prisma/client"; import { Cookie } from "@snailycad/config"; import { Req, Context } from "@tsed/common"; import { BadRequest, Forbidden, Unauthorized } from "@tsed/exceptions"; import { parse } from "cookie"; import { verifyJWT } from "../utils/jwt"; -import { getSessionUser } from "./auth"; import { prisma } from "./prisma"; export const unitProperties = { @@ -14,14 +14,12 @@ export const unitProperties = { rank: true, }; -export async function getActiveOfficer(req: Req, userId: string, ctx: Context) { +export async function getActiveOfficer(req: Req, user: User, ctx: Context) { const header = req.headers.cookie; if (!header) { throw new BadRequest("noActiveOfficer"); } - const user = await getSessionUser(req); - if (!user.isDispatch || !user.isLeo) { throw new Forbidden("Invalid Permissions"); } @@ -49,7 +47,7 @@ export async function getActiveOfficer(req: Req, userId: string, ctx: Context) { const officer = await prisma.officer.findFirst({ where: { - userId, + userId: user.id, id: jwtPayload?.officerId, }, include: unitProperties, diff --git a/packages/api/src/middlewares/ActiveOfficer.ts b/packages/api/src/middlewares/ActiveOfficer.ts index e36ff6e88..4d0a4dbf8 100644 --- a/packages/api/src/middlewares/ActiveOfficer.ts +++ b/packages/api/src/middlewares/ActiveOfficer.ts @@ -6,7 +6,7 @@ import { getActiveOfficer } from "../lib/officer"; export class ActiveOfficer implements MiddlewareMethods { async use(@Req() req: Req, @Context() ctx: Context) { const user = await getSessionUser(req); - const officer = await getActiveOfficer(req, user.id, ctx); + const officer = await getActiveOfficer(req, user, ctx); ctx.set("activeOfficer", officer); } diff --git a/packages/api/src/middlewares/IsAuth.ts b/packages/api/src/middlewares/IsAuth.ts index 788a81426..7c4ce2f9e 100644 --- a/packages/api/src/middlewares/IsAuth.ts +++ b/packages/api/src/middlewares/IsAuth.ts @@ -1,9 +1,11 @@ import { Rank, User } from ".prisma/client"; +import { API_TOKEN_HEADER, DISABLED_API_TOKEN_ROUTES, PERMISSION_ROUTES } from "@snailycad/config"; import { Context, Middleware, Req, MiddlewareMethods } from "@tsed/common"; +import { BadRequest, Forbidden, Unauthorized } from "@tsed/exceptions"; import { getSessionUser } from "../lib/auth"; import { prisma } from "../lib/prisma"; -const CAD_SELECT = (user: Pick) => ({ +const CAD_SELECT = (user?: Pick) => ({ id: true, name: true, areaOfPlay: true, @@ -11,9 +13,11 @@ const CAD_SELECT = (user: Pick) => ({ towWhitelisted: true, whitelisted: true, disabledFeatures: true, - liveMapSocketURl: user.rank === Rank.OWNER, - registrationCode: user.rank === Rank.OWNER, - steamApiKey: user.rank === Rank.OWNER, + liveMapSocketURl: user?.rank === Rank.OWNER, + registrationCode: user?.rank === Rank.OWNER, + steamApiKey: user?.rank === Rank.OWNER, + apiTokenId: user?.rank === Rank.OWNER, + apiToken: user?.rank === Rank.OWNER, discordWebhookURL: true, miscCadSettings: true, miscCadSettingsId: true, @@ -22,7 +26,38 @@ const CAD_SELECT = (user: Pick) => ({ @Middleware() export class IsAuth implements MiddlewareMethods { async use(@Req() req: Req, @Context() ctx: Context) { - const user = await getSessionUser(req); + const header = req.headers[API_TOKEN_HEADER]; + + let user; + if (header) { + const cad = await prisma.cad.findFirst({ + select: { + apiToken: true, + }, + }); + + if (!cad?.apiToken?.enabled) { + throw new Unauthorized("Unauthorized"); + } + + if (cad.apiToken.token !== header) { + throw new Unauthorized("Unauthorized"); + } + + const isDisabled = isRouteDisabled(req); + if (isDisabled) { + throw new BadRequest("routeIsDisabled"); + } + } else { + user = await getSessionUser(req, true); + ctx.set("user", user); + + const hasPermission = hasPermissionForReq(req, user); + + if (!hasPermission) { + throw new Forbidden("Invalid Permissions"); + } + } let cad = await prisma.cad.findFirst({ select: CAD_SELECT(user), @@ -49,6 +84,60 @@ export class IsAuth implements MiddlewareMethods { } ctx.set("cad", cad); - ctx.set("user", user); } } + +function isRouteDisabled(req: Req) { + const url = req.originalUrl.toLowerCase(); + const requestMethod = req.method as any; + + const route = DISABLED_API_TOKEN_ROUTES.find(([r]) => r.startsWith(url) || url.startsWith(r)); + + if (route) { + const [, methods] = route; + + if (typeof methods === "string" && methods === "*") { + return true; + } else if (Array.isArray(methods) && methods.includes(requestMethod)) { + return true; + } + + return false; + } + + return false; +} + +function hasPermissionForReq(req: Req, user: User) { + const url = req.originalUrl.toLowerCase(); + const requestMethod = req.method as any; + + const [route] = PERMISSION_ROUTES.filter(([m, r]) => { + if (typeof r === "string") { + const isTrue = r.startsWith(url) || url.startsWith(r); + + if (m === "*") { + return isTrue; + } + + return m.includes(requestMethod.toUpperCase()) && isTrue; + } + + const isTrue = r.test(url) || url.match(r); + + if (m === "*") { + return isTrue; + } + + return m.includes(requestMethod) && isTrue; + }); + + if (route) { + const [, , callback] = route; + const hasPermission = callback(user); + + return hasPermission; + } + + return true; +} diff --git a/packages/api/src/middlewares/Permissions.ts b/packages/api/src/middlewares/Permissions.ts deleted file mode 100644 index 5f361e441..000000000 --- a/packages/api/src/middlewares/Permissions.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { User } from ".prisma/client"; -import { Middleware, MiddlewareMethods, Req } from "@tsed/common"; -import { Forbidden } from "@tsed/exceptions"; -import { getSessionUser } from "../lib/auth"; - -@Middleware() -export class IsAdmin implements MiddlewareMethods { - async use(@Req() req: Req) { - const user = await getSessionUser(req); - - if (!user) { - throw new Forbidden("Invalid Permissions"); - } - - await admin(user); - } -} - -@Middleware() -export class IsSupervisor implements MiddlewareMethods { - async use(@Req() req: Req) { - const user = await getSessionUser(req); - - if (!user) { - throw new Forbidden("Invalid Permissions"); - } - - const isSupervisor = await supervisor(user); - - if (!isSupervisor) { - await admin(user); - } - } -} - -@Middleware() -export class IsOwner implements MiddlewareMethods { - async use(@Req() req: Req) { - const user = await getSessionUser(req); - - if (!user) { - throw new Forbidden("Invalid Permissions"); - } - - if (user.rank !== "OWNER") { - throw new Forbidden("Invalid Permissions"); - } - } -} - -@Middleware() -export class IsDispatch implements MiddlewareMethods { - async use(@Req() req: Req) { - const user = await getSessionUser(req); - - if (!user) { - throw new Forbidden("Invalid Permissions"); - } - - if (!user.isDispatch) { - throw new Forbidden("Invalid Permissions"); - } - } -} - -@Middleware() -export class IsEmsFd implements MiddlewareMethods { - async use(@Req() req: Req) { - const user = await getSessionUser(req); - - if (!user) { - throw new Forbidden("Invalid Permissions"); - } - - if (!user.isEmsFd) { - throw new Forbidden("Invalid Permissions"); - } - } -} - -@Middleware() -export class IsLeo implements MiddlewareMethods { - async use(@Req() req: Req) { - const user = await getSessionUser(req); - - if (!user) { - throw new Forbidden("Invalid Permissions"); - } - - if (!user.isLeo) { - throw new Forbidden("Invalid Permissions"); - } - } -} - -async function admin(user: Pick) { - if (!["OWNER", "ADMIN"].includes(user.rank)) { - throw new Forbidden("Invalid Permissions"); - } - - return true; -} - -async function supervisor(user: Pick) { - if (!user.isSupervisor) { - throw new Forbidden("Invalid Permissions"); - } - - return true; -} diff --git a/packages/api/src/middlewares/index.ts b/packages/api/src/middlewares/index.ts index e33cfd50a..304322d70 100644 --- a/packages/api/src/middlewares/index.ts +++ b/packages/api/src/middlewares/index.ts @@ -1,3 +1,2 @@ export * from "./IsAuth"; export * from "./ValidPath"; -export * from "./Permissions"; diff --git a/packages/client/src/components/admin/manage/ApiTokenTab.tsx b/packages/client/src/components/admin/manage/ApiTokenTab.tsx new file mode 100644 index 000000000..c90aba61f --- /dev/null +++ b/packages/client/src/components/admin/manage/ApiTokenTab.tsx @@ -0,0 +1,111 @@ +import * as React from "react"; +import { Tab } from "@headlessui/react"; +import { Button } from "components/Button"; +import { FormField } from "components/form/FormField"; +import { PasswordInput } from "components/form/Input"; +import { Toggle } from "components/form/Toggle"; +import { Loader } from "components/Loader"; +import { useAuth } from "context/AuthContext"; +import { Formik } from "formik"; +import useFetch from "lib/useFetch"; +import { useTranslations } from "use-intl"; + +export const ApiTokenTab = () => { + const common = useTranslations("Common"); + const { state, execute } = useFetch(); + const { cad } = useAuth(); + + const [token, setToken] = React.useState(""); + + async function onSubmit(values: typeof INITIAL_VALUES) { + const { json } = await execute("/admin/manage/cad-settings/api-token", { + method: "PUT", + data: values, + }); + + if (json.token) { + setToken(json.token); + } + } + + async function handleRegenerate() { + const { json } = await execute("/admin/manage/cad-settings/api-token", { + method: "DELETE", + }); + + if (json.token) { + setToken(json.token); + } + } + + function handleClick(e: React.MouseEvent) { + const t = e.target as HTMLInputElement; + t.select(); + } + + const INITIAL_VALUES = { + enabled: cad?.apiToken?.enabled ?? false, + token: cad?.apiToken?.token ?? "", + }; + + return ( + +

Public API access

+ +

+ Read more info about{" "} + + Public API Access here + +

+ + + {({ handleChange, handleSubmit, values }) => ( +
+ + + + + + + + +
+ {cad?.apiTokenId ? ( + + ) : null} + +
+
+ )} +
+
+ ); +}; diff --git a/packages/client/src/pages/admin/manage/cad-settings.tsx b/packages/client/src/pages/admin/manage/cad-settings.tsx index c0a100b34..dbee83881 100644 --- a/packages/client/src/pages/admin/manage/cad-settings.tsx +++ b/packages/client/src/pages/admin/manage/cad-settings.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { useAuth } from "context/AuthContext"; -import { useRouter } from "next/router"; import { rank } from "types/prisma"; import { AdminLayout } from "components/admin/AdminLayout"; import { useTranslations } from "use-intl"; @@ -22,16 +21,16 @@ import { TabsContainer } from "components/tabs/TabsContainer"; import { Tab } from "@headlessui/react"; import { MiscFeatures } from "components/admin/manage/MiscFeatures"; import { requestAll } from "lib/utils"; +import { ApiTokenTab } from "components/admin/manage/ApiTokenTab"; export default function CadSettings() { const { state, execute } = useFetch(); const { user, cad, setCad } = useAuth(); - const router = useRouter(); const t = useTranslations("Management"); const common = useTranslations("Common"); - const SETTINGS_TABS = [t("GENERAL_SETTINGS"), t("FEATURES"), t("MISC_SETTINGS")]; + const SETTINGS_TABS = [t("GENERAL_SETTINGS"), t("FEATURES"), t("MISC_SETTINGS"), "Api Token"]; async function onSubmit(values: typeof INITIAL_VALUES) { const { json } = await execute("/admin/manage/cad-settings", { @@ -44,12 +43,6 @@ export default function CadSettings() { } } - React.useEffect(() => { - if (user?.rank !== rank.OWNER) { - // router.push("/403"); - } - }, [user, router]); - if (user?.rank !== rank.OWNER) { return null; } @@ -156,6 +149,8 @@ export default function CadSettings() { + + ); diff --git a/packages/client/src/pages/citizen/[id]/edit.tsx b/packages/client/src/pages/citizen/[id]/edit.tsx index ae20e257f..a5a745c07 100644 --- a/packages/client/src/pages/citizen/[id]/edit.tsx +++ b/packages/client/src/pages/citizen/[id]/edit.tsx @@ -35,12 +35,6 @@ export default function EditCitizen() { const { citizen } = useCitizen(); const { gender, ethnicity } = useValues(); - React.useEffect(() => { - if (!citizen) { - console.log("citizen not found"); - } - }, [citizen]); - if (!citizen) { return null; } @@ -294,6 +288,12 @@ export const getServerSideProps: GetServerSideProps = async ({ query, locale, re headers: req.headers, }).catch(() => ({ data: null })); + if (!data) { + return { + notFound: true, + }; + } + return { props: { values, diff --git a/packages/client/src/types/prisma.ts b/packages/client/src/types/prisma.ts index 60e14aff4..2fe445006 100644 --- a/packages/client/src/types/prisma.ts +++ b/packages/client/src/types/prisma.ts @@ -15,8 +15,9 @@ export type cad = { discordWebhookURL: string | null; whitelisted: boolean; towWhitelisted: boolean; + apiTokenId: string | null; disabledFeatures: Feature[]; -} & { miscCadSettings: MiscCadSettings }; +} & { miscCadSettings: MiscCadSettings; apiToken: ApiToken | null }; /** * Model MiscCadSettings @@ -35,6 +36,17 @@ export type MiscCadSettings = { allowDuplicateCitizenNames: boolean; }; +/** + * Model ApiToken + */ + +export type ApiToken = { + id: string; + enabled: boolean; + token: string | null; + routes: string[]; +}; + /** * Model User */ diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index dd64727c3..388f460f4 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -4,7 +4,9 @@ export enum Cookie { ActiveDeputy = "snaily-cad-active-ems-fd-deputy", } +export const API_TOKEN_HEADER = "snaily-cad-api-token" as const; export type AllowedFileExtension = typeof allowedFileExtensions[number]; export const allowedFileExtensions = ["image/png", "image/gif", "image/jpeg", "image/jpg"] as const; export * from "./socket-events"; +export * from "./routes"; diff --git a/packages/config/src/routes.ts b/packages/config/src/routes.ts new file mode 100644 index 000000000..0d353ade7 --- /dev/null +++ b/packages/config/src/routes.ts @@ -0,0 +1,69 @@ +export type Method = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; + +/** + * `*` = all methods + */ +export type DisabledRoute = [string, Method[] | "*"]; + +export const DISABLED_API_TOKEN_ROUTES: DisabledRoute[] = [ + ["/v1/user", "*"], + ["/v1/admin/manage/cad-settings", "*"], + ["/v1/admin/manage/", ["POST", "DELETE", "PUT", "PATCH"]], + ["/v1/admin/values", ["POST", "DELETE", "PUT", "PATCH"]], + ["/v1/citizen", ["POST", "DELETE", "PUT", "PATCH"]], + // todo: add other routes +]; + +type _User = { + rank: "OWNER" | "USER" | "ADMIN"; + isLeo: boolean; + isDispatch: boolean; + isEmsFd: boolean; + isSupervisor: boolean; +}; +export type PermissionRoute = [Method[] | "*", string | RegExp, (user: _User) => boolean]; + +export const PERMISSION_ROUTES: PermissionRoute[] = [ + [ + ["PUT"], + // /v1/leo/:officerId/status + /\/v1\/(leo|ems-fd)\/[A-Z0-9]+\/status/i, + (u) => u.isLeo || u.isSupervisor || u.isDispatch || u.isEmsFd, + ], + [["GET"], /\/v1\/leo\/active-(officers|officer)/, (u) => u.isLeo || u.isDispatch], + ["*", "/v1/leo", (u) => u.isLeo], + + [["POST"], "/v1/search/name", (u) => u.isLeo || u.isDispatch], + [["POST"], "/v1/search/weapon", (u) => u.isLeo || u.isDispatch], + [["POST"], "/v1/search/vehicle", (u) => u.isLeo || u.isDispatch], + + [["GET"], /\/v1\/ems-fd\/active-(deputies|deputy)/, (u) => u.isEmsFd || u.isDispatch], + ["*", "/v1/ems-fd", (u) => u.isEmsFd], + + [["POST"], "/v1/search/medical-records", (u) => u.isEmsFd], + + [["GET"], "/v1/bolos", (u) => u.isLeo || u.isDispatch || u.isEmsFd], + [["POST", "PUT", "DELETE"], "/v1/bolos", (u) => u.isLeo || u.isDispatch], + + ["*", "/v1/admin/manage/cad-settings", (u) => ["OWNER"].includes(u.rank)], + [ + ["GET", "PATCH", "DELETE", "PUT", "POST"], + "/v1/admin/manage/units", + (u) => u.isSupervisor || ["ADMIN", "OWNER"].includes(u.rank), + ], + [ + ["GET", "PATCH", "DELETE", "PUT", "POST"], + "/v1/admin/manage/", + (u) => ["ADMIN", "OWNER"].includes(u.rank), + ], + [ + ["PATCH", "DELETE", "PUT", "POST"], + "/v1/admin/values/", + (u) => ["ADMIN", "OWNER"].includes(u.rank), + ], + [["PUT", "DELETE", "POST"], "/v1/911-calls/events", (u) => u.isDispatch], + [["POST"], "/v1/911-calls/assign-to/", (u) => u.isLeo || u.isEmsFd], + [["PUT", "DELETE"], "/v1/911-calls", (u) => u.isDispatch], + ["*", "/v1/dispatch", (u) => u.isDispatch], + ["*", "/v1/search/address", (u) => u.isDispatch], +];