Compare commits

...

2 Commits

Author SHA1 Message Date
231ea8149d add validation to edit route
All checks were successful
Build and push image for doorman-homeassistant / docker (push) Successful in 31s
Build and push Doorman UI / API / docker (push) Successful in 2m15s
Build and push image for doorman-homeassistant / deploy-gitainer (push) Successful in 9s
2025-06-07 14:15:41 -07:00
0979d03a27 add common validation path. Refactor auth route to use it 2025-06-07 13:43:16 -07:00
11 changed files with 149 additions and 72 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -17,12 +17,15 @@
"discord-oauth2": "^2.12.1", "discord-oauth2": "^2.12.1",
"discord.js": "^14.19.3", "discord.js": "^14.19.3",
"dynabridge": "^0.3.8", "dynabridge": "^0.3.8",
"is-deep-subset": "^0.1.1",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"promise.timeout": "^1.2.0", "promise.timeout": "^1.2.0",
"twilio": "^3.84.1", "twilio": "^3.84.1",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-loki": "^6.1.3", "winston-loki": "^6.1.3",
"zod": "^3.25.42" "zod": "^3.25.42",
"zod-validation-error": "^3.4.1",
"zod_utilz": "^0.8.4"
}, },
"devDependencies": { "devDependencies": {
"twilio-run": "^3.5.4", "twilio-run": "^3.5.4",

View File

@ -10,6 +10,8 @@ import VoiceResponse from 'twilio/lib/twiml/VoiceResponse';
import { createLogger, format, Logger, transports } from "winston"; import { createLogger, format, Logger, transports } from "winston";
import LokiTransport from "winston-loki"; import LokiTransport from "winston-loki";
import pTimeout, { TimeoutError } from "promise.timeout"; import pTimeout, { TimeoutError } from "promise.timeout";
import { ZodError } from "zod";
import { fromError } from "zod-validation-error";
export type BaseEvent = { request: { cookies: {}; headers: {}; }; } export type BaseEvent = { request: { cookies: {}; headers: {}; }; }
@ -243,6 +245,17 @@ export function withMetrics<T extends DoormanLambdaContext, U extends BaseEvent>
} }
callbackResult = failFastCallbackMethod(); callbackResult = failFastCallbackMethod();
} else if (e instanceof ZodError) {
// global catch for validation errors
const response = new Twilio.Response();
response.setStatusCode(400);
response.setHeaders({
"Content-Type": "application/json"
});
// return nice to read error message from ZOD
response.setBody({ err: fromError(e).toString() });
callbackResult = [null, response];
} }
} }
} }

View File

@ -13,26 +13,30 @@ import { AuthMetrics, registerMetrics } from "../../../metrics/AuthMetrics";
import { Counter } from "prom-client"; import { Counter } from "prom-client";
import { DoorConfig, getDoorConfigID } from "../../../schema/DoorConfig"; import { DoorConfig, getDoorConfigID } from "../../../schema/DoorConfig";
import { createLockStatusWithTimeout, getLockStatusID, isLockOpen } from "../../../schema/LockStatus"; import { createLockStatusWithTimeout, getLockStatusID, isLockOpen } from "../../../schema/LockStatus";
import { z } from "zod";
import { setResponseJson } from "../../../utils/responseUtils";
export interface AuthRequest extends ServerlessEventObject<{}, UserAgentHeader> { export const AuthRequestSchema = z.object({
door?: string; door: z.string(),
key?: string; key: z.string(),
ip: string; ip: z.string().optional(),
timeout?: string; timeout: z.number().gt(0, "Timeout cannot be 0").optional(),
} });
export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequest> = withMetrics('auth', async (context, event, callback, metricsRegistry) => { export type AuthRequest = z.infer<typeof AuthRequestSchema>;
export interface AuthRequestTwilio extends ServerlessEventObject<AuthRequest, UserAgentHeader> { };
export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequestTwilio> = withMetrics('auth', async (context, event, callback, metricsRegistry) => {
const response = new Twilio.Response(); const response = new Twilio.Response();
registerMetrics(metricsRegistry); registerMetrics(metricsRegistry);
let door = event.door; let req = AuthRequestSchema.parse(event);
let pin = event.key;
if (!door || !pin) { // now we have validated obj
response.setStatusCode(400); let door = req.door;
return callback(null, response); let pin = req.key;
}
const db = createDynaBridgeClient(context); const db = createDynaBridgeClient(context);
@ -40,7 +44,10 @@ export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequest> =
if (!config) { if (!config) {
getMetricFromRegistry<Counter>(metricsRegistry, AuthMetrics.DOOR_CONFIG_NOT_FOUND).inc({ door }, 1); getMetricFromRegistry<Counter>(metricsRegistry, AuthMetrics.DOOR_CONFIG_NOT_FOUND).inc({ door }, 1);
response.setStatusCode(404); setResponseJson(response, 404, {
err: `Door ${door} not found`,
});
return callback(null, response); return callback(null, response);
} }
@ -58,7 +65,9 @@ export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequest> =
} }
if (!method) { if (!method) {
response.setStatusCode(401); setResponseJson(response, 401, {
err: "Invalid PIN",
});
return callback(null, response); return callback(null, response);
} }
@ -72,7 +81,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequest> =
}; };
// take timeout from the query string // take timeout from the query string
const timeout = event.timeout ? parseInt(event.timeout) : config.timeout; const timeout = event.timeout || config.timeout;
// check lock status if locked, then unlock. If unlocked then lock // check lock status if locked, then unlock. If unlocked then lock
const lock = await db.entities.lockStatus.findById(getLockStatusID(door)); const lock = await db.entities.lockStatus.findById(getLockStatusID(door));
@ -81,21 +90,18 @@ export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequest> =
if (isOpen && lock) { if (isOpen && lock) {
const fingerprint = JSON.parse(lock.fingerprint); const fingerprint = JSON.parse(lock.fingerprint);
response setResponseJson(response, 200, {
.setStatusCode(200) status: DoorStatus.CLOSED,
.appendHeader('Content-Type', 'application/json') fingerprint,
.setBody({ });
status: DoorStatus.CLOSED,
fingerprint,
});
await db.entities.lockStatus.deleteById(getLockStatusID(door)); await db.entities.lockStatus.deleteById(getLockStatusID(door));
} else { } else {
await db.entities.lockStatus.save(createLockStatusWithTimeout(door, timeout, fingerprint)); await db.entities.lockStatus.save(createLockStatusWithTimeout(door, timeout, fingerprint));
response
.setStatusCode(200) setResponseJson(response, 200, {
.appendHeader('Content-Type', 'application/json') msg: `Opened the door "${door}" for ${timeout}s`
.setBody({ msg: `Opened the door "${door}" for ${timeout}s` }); });
} }
// destroy the internal client after // destroy the internal client after

View File

@ -5,33 +5,42 @@
import { ServerlessEventObject, ServerlessFunctionSignature } from "@twilio-labs/serverless-runtime-types/types"; import { ServerlessEventObject, ServerlessFunctionSignature } from "@twilio-labs/serverless-runtime-types/types";
import { TwilioContext } from "../../../types/TwilioContext"; import { TwilioContext } from "../../../types/TwilioContext";
import { shouldBlockRequest, UserAgentHeader } from "../../../utils/blockUserAgent"; import { UserAgentHeader } from "../../../utils/blockUserAgent";
import { createDynaBridgeClient } from "../../../utils/ddb"; import { createDynaBridgeClient } from "../../../utils/ddb";
import { sendMessageToUser } from "../../../utils/discord"; import { sendMessageToUser } from "../../../utils/discord";
import { createEditDoorConfig, EditDoorConfigReq, editDoorToDoorConfig, getDoorConfigID, getEditDoorConfigID } from "../../../schema/DoorConfig"; import { createEditDoorConfig, EditDoorConfigReqSchema, editDoorToDoorConfig, getDoorConfigID, getEditDoorConfigID } from "../../../schema/DoorConfig";
import { z } from "zod";
import { withMetrics } from "../../../common/DoormanHandler";
import { setResponseJson } from "../../../utils/responseUtils";
export interface EditRequest extends ServerlessEventObject<{}, UserAgentHeader> { import { zu } from "zod_utilz";
door?: string;
approvalId?: string;
newConfig?: string;
}
export const handler: ServerlessFunctionSignature<TwilioContext, EditRequest> = async function(context, event, callback) { // @ts-ignore
import isDeepSubset from "is-deep-subset";
export const EditRequestSchema = z.object({
door: z.string(),
approvalId: z.string().optional(),
newConfig: zu.stringToJSON().optional(),
});
export type EditRequest = z.infer<typeof EditRequestSchema>;
export interface EditRequestTwilio extends ServerlessEventObject<EditRequest, UserAgentHeader> { }
export const handler: ServerlessFunctionSignature<TwilioContext, EditRequestTwilio> = withMetrics("edit", async (context, event, callback, metricsRegistry) => {
const response = new Twilio.Response(); const response = new Twilio.Response();
if (shouldBlockRequest(event)) { const req = EditRequestSchema.parse(event);
response.setStatusCode(200);
return callback(null, response);
}
let door = event.door; let door = req.door;
let approvalId = event.approvalId; let approvalId = req.approvalId;
let newConfigString = event.newConfig; let newConfigRaw = req.newConfig;
const db = createDynaBridgeClient(context); const db = createDynaBridgeClient(context);
// approve path // approve path
if (door && approvalId) { if (approvalId) {
const newConfig = await db.entities.editDoorConfig.findById(getEditDoorConfigID(door)); const newConfig = await db.entities.editDoorConfig.findById(getEditDoorConfigID(door));
if (!newConfig || newConfig.approvalId !== approvalId) { if (!newConfig || newConfig.approvalId !== approvalId) {
@ -39,7 +48,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, EditRequest> =
return callback(null, response); return callback(null, response);
} }
db.entities.doorConfig.save(editDoorToDoorConfig(newConfig)); await db.entities.doorConfig.save(editDoorToDoorConfig(newConfig));
// send update to discord users // send update to discord users
const updateMessage = `Configuration change \`${approvalId}\` was approved @ Door "${door}"`; const updateMessage = `Configuration change \`${approvalId}\` was approved @ Door "${door}"`;
@ -60,16 +69,21 @@ export const handler: ServerlessFunctionSignature<TwilioContext, EditRequest> =
return callback(null, response); return callback(null, response);
} }
if (!door || !newConfigString) { if (!newConfigRaw) {
response.setStatusCode(400); setResponseJson(response, 400, {
err: "Missing new config",
});
return callback(null, response); return callback(null, response);
} }
const newConfig: EditDoorConfigReq = JSON.parse(newConfigString); const newConfig = EditDoorConfigReqSchema.parse(newConfigRaw);
const config = await db.entities.doorConfig.findById(getDoorConfigID(door)); const config = await db.entities.doorConfig.findById(getDoorConfigID(door));
if (!config) { if (!config) {
response.setStatusCode(404); setResponseJson(response, 404, {
err: `Door not found ${door}`,
});
return callback(null, response); return callback(null, response);
} }
@ -78,14 +92,24 @@ export const handler: ServerlessFunctionSignature<TwilioContext, EditRequest> =
newConfig.pin = config.pin; newConfig.pin = config.pin;
} }
// if nothing changed, we should throw since this is a pointless change
if (isDeepSubset(config, newConfig)) {
setResponseJson(response, 400, {
err: "Nothing changed in the new config",
});
return callback(null, response);
}
const editDoorConfig = createEditDoorConfig(door, newConfig); const editDoorConfig = createEditDoorConfig(door, newConfig);
await db.entities.editDoorConfig.save(editDoorConfig); await db.entities.editDoorConfig.save(editDoorConfig);
// newConfig.discordUsers = undefined; const params: EditRequest = {
// newConfig.fallbackNumbers = undefined; door,
// newConfig.status = undefined; approvalId: editDoorConfig.approvalId,
};
const approvalUrl = `https://doorman.chromart.cc/api/door/edit?door=${door}&approvalId=${editDoorConfig.approvalId as string}`; const approvalUrl = `https://doorman.chromart.cc/api/door/edit?` + (new URLSearchParams(params as any)).toString();
console.log(approvalUrl); console.log(approvalUrl);
// send update to discord users // send update to discord users
@ -101,14 +125,13 @@ export const handler: ServerlessFunctionSignature<TwilioContext, EditRequest> =
await Promise.all(discordPromises); await Promise.all(discordPromises);
response setResponseJson(response, 200, {
.setStatusCode(200) msg: 'Created configuration change',
.appendHeader('Content-Type', 'application/json') });
.setBody({ msg: "Created Configuration change" });
// destroy the internal client after // destroy the internal client after
// @ts-ignore // @ts-ignore
db.ddbClient.destroy(); db.ddbClient.destroy();
return callback(null, response); return callback(null, response);
}; });

View File

@ -12,7 +12,7 @@ const imports = functionFiles.forEach(file => require('./' + path.relative('src'
console.log("functions to build:", functionFiles); console.log("functions to build:", functionFiles);
const bundledModules = ['dynabridge']; const bundledModules = ['dynabridge', 'zod_utilz'];
const externalModules = Object.keys(require('../package.json').dependencies) const externalModules = Object.keys(require('../package.json').dependencies)
.filter(dep => !bundledModules.includes(dep)); .filter(dep => !bundledModules.includes(dep));

View File

@ -14,7 +14,7 @@ export const DoorConfigSchema = z.object({
buzzerCode: z.string(), buzzerCode: z.string(),
discordUsers: z.array(z.string()), discordUsers: z.array(z.string()),
fallbackNumbers: z.array(z.string()), fallbackNumbers: z.array(z.string()),
pin: z.string(), pin: z.string().default(""),
pressKey: z.string(), pressKey: z.string(),
greeting: z.string().optional(), greeting: z.string().optional(),
timeout: z.number(), timeout: z.number(),
@ -39,7 +39,8 @@ export const getEditDoorConfigID = (doorName: string): string[] => {
export type DoorConfig = z.infer<typeof DoorConfigSchema>; export type DoorConfig = z.infer<typeof DoorConfigSchema>;
export type EditDoorConfig = z.infer<typeof EditDoorConfigSchema>; export type EditDoorConfig = z.infer<typeof EditDoorConfigSchema>;
export type EditDoorConfigReq = Omit<EditDoorConfig, "PK" | "SK" | "approvalId">; export const EditDoorConfigReqSchema = EditDoorConfigSchema.omit({ "PK": true, "SK": true, "approvalId": true });
export type EditDoorConfigReq = z.infer<typeof EditDoorConfigReqSchema>;
export const DoorConfigEntity: DynaBridgeEntity<DoorConfig> = { export const DoorConfigEntity: DynaBridgeEntity<DoorConfig> = {
tableName: "doorman", tableName: "doorman",

View File

@ -0,0 +1,17 @@
import { TwilioResponse } from "@twilio-labs/serverless-runtime-types/types"
/**
* Fill a twilio response object with JSON and set the appropriate headers
* @param response
* @param statusCode
* @param json
* @returns twilio response
*/
export const setResponseJson = (response: TwilioResponse, statusCode: number, json: any): TwilioResponse => {
response
.setStatusCode(statusCode)
.appendHeader('Content-Type', 'application/json')
.setBody(json);
return response;
}

View File

@ -1,6 +1,9 @@
import { Button, FormField, Icon, Input, SpaceBetween } from "@cloudscape-design/components"; import { Button, FormField, Icon, Input, SpaceBetween } from "@cloudscape-design/components";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { AuthRequest } from "../../../doorman-api/src/functions/api/door/auth";
import { fetchUrlEncoded } from "../helpers/FetchHelper";
// COPIED FROM SERVER // COPIED FROM SERVER
export enum IAuthMode { export enum IAuthMode {
FIXED_PIN = "Fixed Pin", FIXED_PIN = "Fixed Pin",
@ -18,7 +21,7 @@ export interface IAuthComponentProps {
} }
export const AuthComponent = ({ door, secret, authMode, onError, onUnlock, runCheck }: IAuthComponentProps) => { export const AuthComponent = ({ door, secret, authMode, onError, onUnlock, runCheck }: IAuthComponentProps) => {
const [ key, setKey ] = useState(secret); const [ key, setKey ] = useState(secret || "");
const [ error, setError ] = useState(""); const [ error, setError ] = useState("");
useEffect(() => { useEffect(() => {
@ -38,15 +41,21 @@ export const AuthComponent = ({ door, secret, authMode, onError, onUnlock, runCh
return "null"; return "null";
}); });
fetch(`/api/door/auth?key=${key}&rotatingKey=${key}&door=${door}&ip=${ip}`) const body: AuthRequest = {
.then(async res => { door,
if (res.status !== 200) { ip,
setError("Incorrect PIN"); key,
onError && onError(await res.json()); };
return;
} fetchUrlEncoded('/api/door/auth', body)
onUnlock(); .then(async res => {
}) if (res.status !== 200) {
setError("Incorrect PIN");
onError && onError(await res.json());
return;
}
onUnlock();
});
} }
return ( return (

View File

@ -0,0 +1,5 @@
export const fetchUrlEncoded = (url: string, body: Record<string, any>): Promise<Response> => {
const params = new URLSearchParams(body);
return fetch(url + '?' + params.toString());
}

View File

@ -183,7 +183,7 @@ export function DoorPage() {
authMode={IAuthMode.FIXED_PIN} authMode={IAuthMode.FIXED_PIN}
runCheck={submitPin} runCheck={submitPin}
door={door} door={door}
secret={secret} secret={secret}
onUnlock={() => { onUnlock={() => {
setStep(2); setStep(2);
dismissInProgressAlerts(); dismissInProgressAlerts();