Compare commits

..

No commits in common. "231ea8149d8c265d91ac56ec99b99d65f881ed60" and "fd3878d590ae39f370fe44a2442831b7279cdb4a" have entirely different histories.

11 changed files with 72 additions and 149 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -17,15 +17,12 @@
"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,8 +10,6 @@ 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: {}; }; }
@ -245,17 +243,6 @@ 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,30 +13,26 @@ 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 const AuthRequestSchema = z.object({ export interface AuthRequest extends ServerlessEventObject<{}, UserAgentHeader> {
door: z.string(), door?: string;
key: z.string(), key?: string;
ip: z.string().optional(), ip: string;
timeout: z.number().gt(0, "Timeout cannot be 0").optional(), timeout?: string;
}); }
export type AuthRequest = z.infer<typeof AuthRequestSchema>; export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequest> = withMetrics('auth', async (context, event, callback, metricsRegistry) => {
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 req = AuthRequestSchema.parse(event); let door = event.door;
let pin = event.key;
// now we have validated obj if (!door || !pin) {
let door = req.door; response.setStatusCode(400);
let pin = req.key; return callback(null, response);
}
const db = createDynaBridgeClient(context); const db = createDynaBridgeClient(context);
@ -44,10 +40,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequestTwil
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);
setResponseJson(response, 404, { response.setStatusCode(404);
err: `Door ${door} not found`,
});
return callback(null, response); return callback(null, response);
} }
@ -65,9 +58,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequestTwil
} }
if (!method) { if (!method) {
setResponseJson(response, 401, { response.setStatusCode(401);
err: "Invalid PIN",
});
return callback(null, response); return callback(null, response);
} }
@ -81,7 +72,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequestTwil
}; };
// take timeout from the query string // take timeout from the query string
const timeout = event.timeout || config.timeout; const timeout = event.timeout ? parseInt(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));
@ -90,18 +81,21 @@ export const handler: ServerlessFunctionSignature<TwilioContext, AuthRequestTwil
if (isOpen && lock) { if (isOpen && lock) {
const fingerprint = JSON.parse(lock.fingerprint); const fingerprint = JSON.parse(lock.fingerprint);
setResponseJson(response, 200, { response
status: DoorStatus.CLOSED, .setStatusCode(200)
fingerprint, .appendHeader('Content-Type', 'application/json')
}); .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
setResponseJson(response, 200, { .setStatusCode(200)
msg: `Opened the door "${door}" for ${timeout}s` .appendHeader('Content-Type', 'application/json')
}); .setBody({ msg: `Opened the door "${door}" for ${timeout}s` });
} }
// destroy the internal client after // destroy the internal client after

View File

@ -5,42 +5,33 @@
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 { UserAgentHeader } from "../../../utils/blockUserAgent"; import { shouldBlockRequest, 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, EditDoorConfigReqSchema, editDoorToDoorConfig, getDoorConfigID, getEditDoorConfigID } from "../../../schema/DoorConfig"; import { createEditDoorConfig, EditDoorConfigReq, editDoorToDoorConfig, getDoorConfigID, getEditDoorConfigID } from "../../../schema/DoorConfig";
import { z } from "zod";
import { withMetrics } from "../../../common/DoormanHandler";
import { setResponseJson } from "../../../utils/responseUtils";
import { zu } from "zod_utilz"; export interface EditRequest extends ServerlessEventObject<{}, UserAgentHeader> {
door?: string;
approvalId?: string;
newConfig?: string;
}
// @ts-ignore export const handler: ServerlessFunctionSignature<TwilioContext, EditRequest> = async function(context, event, callback) {
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();
const req = EditRequestSchema.parse(event); if (shouldBlockRequest(event)) {
response.setStatusCode(200);
return callback(null, response);
}
let door = req.door; let door = event.door;
let approvalId = req.approvalId; let approvalId = event.approvalId;
let newConfigRaw = req.newConfig; let newConfigString = event.newConfig;
const db = createDynaBridgeClient(context); const db = createDynaBridgeClient(context);
// approve path // approve path
if (approvalId) { if (door && 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) {
@ -48,7 +39,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, EditRequestTwil
return callback(null, response); return callback(null, response);
} }
await db.entities.doorConfig.save(editDoorToDoorConfig(newConfig)); 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}"`;
@ -69,21 +60,16 @@ export const handler: ServerlessFunctionSignature<TwilioContext, EditRequestTwil
return callback(null, response); return callback(null, response);
} }
if (!newConfigRaw) { if (!door || !newConfigString) {
setResponseJson(response, 400, { response.setStatusCode(400);
err: "Missing new config",
});
return callback(null, response); return callback(null, response);
} }
const newConfig = EditDoorConfigReqSchema.parse(newConfigRaw); const newConfig: EditDoorConfigReq = JSON.parse(newConfigString);
const config = await db.entities.doorConfig.findById(getDoorConfigID(door)); const config = await db.entities.doorConfig.findById(getDoorConfigID(door));
if (!config) { if (!config) {
setResponseJson(response, 404, { response.setStatusCode(404);
err: `Door not found ${door}`,
});
return callback(null, response); return callback(null, response);
} }
@ -92,24 +78,14 @@ export const handler: ServerlessFunctionSignature<TwilioContext, EditRequestTwil
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);
const params: EditRequest = { // newConfig.discordUsers = undefined;
door, // newConfig.fallbackNumbers = undefined;
approvalId: editDoorConfig.approvalId, // newConfig.status = undefined;
};
const approvalUrl = `https://doorman.chromart.cc/api/door/edit?` + (new URLSearchParams(params as any)).toString(); const approvalUrl = `https://doorman.chromart.cc/api/door/edit?door=${door}&approvalId=${editDoorConfig.approvalId as string}`;
console.log(approvalUrl); console.log(approvalUrl);
// send update to discord users // send update to discord users
@ -125,13 +101,14 @@ export const handler: ServerlessFunctionSignature<TwilioContext, EditRequestTwil
await Promise.all(discordPromises); await Promise.all(discordPromises);
setResponseJson(response, 200, { response
msg: 'Created configuration change', .setStatusCode(200)
}); .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', 'zod_utilz']; const bundledModules = ['dynabridge'];
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().default(""), pin: z.string(),
pressKey: z.string(), pressKey: z.string(),
greeting: z.string().optional(), greeting: z.string().optional(),
timeout: z.number(), timeout: z.number(),
@ -39,8 +39,7 @@ 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 const EditDoorConfigReqSchema = EditDoorConfigSchema.omit({ "PK": true, "SK": true, "approvalId": true }); export type EditDoorConfigReq = Omit<EditDoorConfig, "PK" | "SK" | "approvalId">;
export type EditDoorConfigReq = z.infer<typeof EditDoorConfigReqSchema>;
export const DoorConfigEntity: DynaBridgeEntity<DoorConfig> = { export const DoorConfigEntity: DynaBridgeEntity<DoorConfig> = {
tableName: "doorman", tableName: "doorman",

View File

@ -1,17 +0,0 @@
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,9 +1,6 @@
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",
@ -21,7 +18,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(() => {
@ -41,21 +38,15 @@ export const AuthComponent = ({ door, secret, authMode, onError, onUnlock, runCh
return "null"; return "null";
}); });
const body: AuthRequest = { fetch(`/api/door/auth?key=${key}&rotatingKey=${key}&door=${door}&ip=${ip}`)
door, .then(async res => {
ip, if (res.status !== 200) {
key, setError("Incorrect PIN");
}; onError && onError(await res.json());
return;
fetchUrlEncoded('/api/door/auth', body) }
.then(async res => { onUnlock();
if (res.status !== 200) { })
setError("Incorrect PIN");
onError && onError(await res.json());
return;
}
onUnlock();
});
} }
return ( return (

View File

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