Fixes slack verification and timeout errors (#2972)

* Fixing slack create event

* Fixes verify working 90% of the time :S

* Verify V2

* Fixing verify - not needed on interaction payload

* Fixing verifcation + response

* Fix verifcation

* DM user error/success

* Remove random 200

* Timeout return

* Adding Zod schema verification

* Added validations, cleanup

* Type fixes

* Update event.ts

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2022-06-11 22:30:52 +01:00
committed by GitHub
co-authored by Peer Richelsen zomars kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
parent 8fa0d0f8bb
commit c03ff0a543
15 changed files with 167 additions and 184 deletions
+2 -1
View File
@@ -237,7 +237,8 @@ async function handler(req: NextApiRequest) {
throw new HttpError({ statusCode: 400, message: error.message });
}
const eventType = !eventTypeId ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId);
const eventType =
!eventTypeId && !!eventTypeSlug ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId);
if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" });
let users = !eventTypeId
+30 -34
View File
@@ -1,45 +1,41 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type { NextApiRequest } from "next";
import { stringify } from "querystring";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { getSlackAppKeys } from "../lib/utils";
let client_id = "";
const scopes = ["commands", "users:read", "users:read.email", "chat:write", "chat:write.public"];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
async function handler(req: NextApiRequest) {
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
if (req.method === "GET") {
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
const appKeys = await getAppKeysFromSlug("slack");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (!client_id) return res.status(400).json({ message: "Slack client_id missing" });
// Get user
await prisma.user.findFirst({
rejectOnNotFound: true,
where: {
id: req.session.user.id,
},
select: {
id: true,
},
});
const params = {
client_id,
scope: scopes.join(","),
};
const query = stringify(params);
const url = `https://slack.com/oauth/v2/authorize?${query}&user_`;
// const url =
// "https://slack.com/oauth/v2/authorize?client_id=3194129032064.3178385871204&scope=chat:write,commands&user_scope=";
return res.status(200).json({ url });
}
return res.status(404).json({ error: "Not Found" });
const { client_id } = await getSlackAppKeys();
// Get user
await prisma.user.findFirst({
rejectOnNotFound: true,
where: {
id: req.session.user.id,
},
select: {
id: true,
},
});
const params = {
client_id,
scope: scopes.join(","),
};
const query = stringify(params);
const url = `https://slack.com/oauth/v2/authorize?${query}&user_`;
// const url =
// "https://slack.com/oauth/v2/authorize?client_id=3194129032064.3178385871204&scope=chat:write,commands&user_scope=";
return { url };
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
@@ -1,57 +1,60 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import { z } from "zod";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { getSlackAppKeys } from "../lib/utils";
let client_id = "";
let client_secret = "";
const callbackQuerySchema = z.object({
code: z.string().min(1),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
if (req.method === "GET") {
// Get user
const { code } = req.query;
// Get user
const parsedCallbackQuery = callbackQuerySchema.safeParse(req.query);
if (!code) {
res.redirect("/apps/installed"); // Redirect to where the user was if they cancel the signup or if the oauth fails
}
if (!parsedCallbackQuery.success) {
return res.redirect("/apps/installed"); // Redirect to where the user was if they cancel the signup or if the oauth fails
}
const appKeys = await getAppKeysFromSlug("slack");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
if (!client_id) return res.status(400).json({ message: "Slack client_id missing" });
if (!client_secret) return res.status(400).json({ message: "Slack client_secret missing" });
const { code } = parsedCallbackQuery.data;
const { client_id, client_secret } = await getSlackAppKeys();
const query = {
client_secret,
client_id,
code,
};
const params = stringify(query);
console.log("params", params);
const query = {
client_secret,
client_id,
code,
};
const params = stringify(query);
const url = `https://slack.com/api/oauth.v2.access?${params}`;
const result = await fetch(url);
const responseBody = await result.json();
const url = `https://slack.com/api/oauth.v2.access?${params}`;
const result = await fetch(url);
const responseBody = await result.json();
await prisma.user.update({
where: {
id: req.session.user.id,
},
data: {
credentials: {
create: {
type: "slack_app",
key: responseBody,
},
await prisma.user.update({
where: {
id: req.session.user.id,
},
data: {
credentials: {
create: {
type: "slack_app",
key: responseBody,
appId: "slack",
},
},
});
return res.redirect("/apps/installed");
}
},
});
return res.redirect("/apps/installed");
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
@@ -1,8 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { showCreateEventMessage, showTodayMessage } from "../lib";
import showLinksMessage from "../lib/showLinksMessage";
import slackVerify from "../lib/slackVerify";
export enum SlackAppCommands {
CREATE_EVENT = "create-event",
@@ -10,20 +12,28 @@ export enum SlackAppCommands {
LINKS = "links",
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
const command = req.body.command.split("/").pop();
await slackVerify(req, res);
switch (command) {
case SlackAppCommands.CREATE_EVENT:
return await showCreateEventMessage(req, res);
case SlackAppCommands.TODAY:
return await showTodayMessage(req, res);
case SlackAppCommands.LINKS:
return await showLinksMessage(req, res);
default:
return res.status(404).json({ message: `Command not found` });
}
const commandHandlerBodySchema = z.object({
command: z.string().min(1),
user_id: z.string(),
trigger_id: z.string(),
channel_id: z.string().optional(),
});
async function handler(req: NextApiRequest, res: NextApiResponse) {
const body = commandHandlerBodySchema.parse(req.body);
const command = body.command.split("/").pop();
switch (command) {
case SlackAppCommands.CREATE_EVENT:
return await showCreateEventMessage(req, res);
case SlackAppCommands.TODAY:
return await showTodayMessage(req, res);
case SlackAppCommands.LINKS:
return await showLinksMessage(req, res);
default:
return res.status(404).json({ message: `Command not found` });
}
return res.status(400).json({ message: "Invalid request" });
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(handler) }),
});
@@ -1,7 +1,6 @@
import { NextApiRequest, NextApiResponse } from "next";
import createEvent from "../lib/actions/createEvent";
import slackVerify from "../lib/slackVerify";
enum InteractionEvents {
CREATE_EVENT = "cal.event.create",
@@ -9,14 +8,11 @@ enum InteractionEvents {
export default async function interactiveHandler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
await slackVerify(req, res);
const payload = JSON.parse(req.body.payload);
const actions = payload.view.callback_id;
// I've not found a case where actions is ever > than 1 when this function is called.
switch (actions) {
case InteractionEvents.CREATE_EVENT:
return await createEvent(req, res);
await createEvent(req, res);
default:
return res.status(200).end(); // Techincally an invalid request but we don't want to return an throw an error to slack - 200 just does nothing
}
@@ -1,46 +1,24 @@
import { WebClient } from "@slack/web-api";
import dayjs from "dayjs";
import { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { WEBAPP_URL } from "@calcom/lib/constants";
import db from "@calcom/prisma";
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
import { WhereCredsEqualsId } from "../WhereCredsEqualsID";
import { getUserEmail } from "../utils";
import BookingSuccess from "../views/BookingSuccess";
// TODO: Move this type to a shared location - being used in more than one package.
export type BookingCreateBody = {
email: string;
end: string;
web3Details?: {
userWallet: string;
userSignature: unknown;
};
eventTypeId: number;
guests?: string[];
location: string;
name: string;
notes?: string;
rescheduleUid?: string;
start: string;
timeZone: string;
user?: string | string[];
language: string;
customInputs: { label: string; value: string }[];
metadata: {
[key: string]: string;
};
};
export default async function createEvent(req: NextApiRequest, res: NextApiResponse) {
const {
user,
view: {
state: { values },
id: view_id,
},
response_url,
} = JSON.parse(req.body.payload);
// This is a mess I have no idea why slack makes getting infomation this hard.
const {
eventName: {
@@ -90,9 +68,11 @@ export default async function createEvent(req: NextApiRequest, res: NextApiRespo
},
});
const slackCredentials = foundUser?.credentials[0].key; // Only one slack credential for user
const SlackCredentialsSchema = z.object({
access_token: z.string(),
});
// @ts-ignore access_token must exist on slackCredentials otherwise we have wouldnt have reached this endpoint
const slackCredentials = SlackCredentialsSchema.parse(foundUser?.credentials[0].key); // Only one slack credential for user
const access_token = slackCredentials?.access_token;
// https://api.slack.com/authentication/best-practices#verifying since we verify the request is coming from slack we can store the access_token in the DB.
@@ -100,9 +80,7 @@ export default async function createEvent(req: NextApiRequest, res: NextApiRespo
// This could get a bit weird as there is a 3 second limit until the post times ou
// Compute all users that have been selected and get their email.
const invitedGuestsEmails = selected_users.map(
async (userId: string) => await getUserEmail(client, userId)
);
const invitedGuestsEmails = selected_users.map((userId: string) => getUserEmail(client, userId));
const startDate = dayjs(`${selected_date} ${selected_time}`, "YYYY-MM-DD HH:mm");
@@ -124,36 +102,18 @@ export default async function createEvent(req: NextApiRequest, res: NextApiRespo
notes: "This event was created with slack.",
};
if (startDate < dayjs()) {
client.chat.postMessage({
token: access_token,
channel: user.id,
text: `Error: Day must not be in the past`,
});
return res.status(200).send("");
}
fetch(`${WEBAPP_URL}/api/book/event`, {
const response = await fetch(`${WEBAPP_URL}/api/book/event`, {
method: "POST",
body: JSON.stringify(PostData),
headers: {
"Content-Type": "application/json",
},
})
.then(() => {
client.chat.postMessage({
token: access_token,
channel: user.id, // We just dm the user here as there is no point posting this message publicly - In future it might be worth pinging all the members of the invite also?
text: "Booking has been created.",
});
return res.status(200).send(""); // Slack requires a 200 to be sent to clear the modal. This makes it massive pain to update the user that the event has been created.
})
.catch((e) => {
client.chat.postMessage({
token: access_token,
channel: user.id,
text: `Error: ${e}`,
});
return res.status(200).send("");
});
});
const body = await response.json();
client.chat.postMessage({
token: access_token,
channel: user.id,
text: body.errorCode ? `Error: ${body.errorCode}` : "Booking has been created.",
});
return res.status(200).send("");
}
@@ -5,10 +5,12 @@ import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
import slackVerify from "./slackVerify";
import { CreateEventModal, NoUserMessage } from "./views";
export default async function showCreateEventMessage(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
await slackVerify(req, res);
const data = await prisma.credential.findFirst({
...WhereCredsEqualsId(body.user_id),
@@ -35,5 +37,5 @@ export default async function showCreateEventMessage(req: NextApiRequest, res: N
trigger_id: body.trigger_id,
view: CreateEventModal(data),
});
res.status(200).end();
return res.status(200).end();
}
@@ -1,15 +1,17 @@
import { Prisma } from "@prisma/client";
import { KnownBlock, WebClient } from "@slack/web-api";
import { WebClient } from "@slack/web-api";
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
import { CreateEventModal, NoUserMessage } from "./views";
import slackVerify from "./slackVerify";
import { NoUserMessage } from "./views";
import ShowLinks from "./views/ShowLinks";
export default async function showLinksMessage(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
await slackVerify(req, res);
const data = await prisma.credential.findFirst({
...WhereCredsEqualsId(body.user_id),
@@ -40,9 +42,8 @@ export default async function showLinksMessage(req: NextApiRequest, res: NextApi
slackClient.chat.postMessage({
channel: body.channel_id,
text: `${data.user?.username}'s Cal.com Links`,
//@ts-ignore this doesnt need to be of type Block[] - an object works completely fine
blocks,
});
res.status(200).end();
return res.status(200).end();
}
@@ -5,11 +5,12 @@ import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { WhereCredsEqualsId } from "./WhereCredsEqualsID";
import slackVerify from "./slackVerify";
import { NoUserMessage, TodayMessage } from "./views";
export default async function showCreateEventMessage(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
await slackVerify(req, res);
const foundUser = await prisma.credential.findFirst({
...WhereCredsEqualsId(body.user_id),
include: {
@@ -50,5 +51,5 @@ export default async function showCreateEventMessage(req: NextApiRequest, res: N
},
});
res.status(200).json(TodayMessage(bookings));
return res.status(200).json(TodayMessage(bookings));
}
@@ -3,17 +3,14 @@ import dayjs from "dayjs";
import { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
let signingSecret = "";
import { getSlackAppKeys } from "./utils";
export default async function slackVerify(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
const timeStamp = req.headers["x-slack-request-timestamp"] as string; // Always returns a string and not a string[]
const slackSignature = req.headers["x-slack-signature"] as string;
const currentTime = dayjs().unix();
let { signing_secret } = await getAppKeysFromSlug("slack");
if (typeof signing_secret === "string") signingSecret = signing_secret;
const { signing_secret: signingSecret } = await getSlackAppKeys();
const [version, hash] = slackSignature.split("=");
if (!timeStamp) {
return res.status(400).json({ message: "Missing X-Slack-Request-Timestamp header" });
@@ -27,10 +24,13 @@ export default async function slackVerify(req: NextApiRequest, res: NextApiRespo
return res.status(400).json({ message: "Request is too old" });
}
const signature_base = `v0:${timeStamp}:${stringify(body)}`;
const signed_sig = "v0=" + createHmac("sha256", signingSecret).update(signature_base).digest("hex");
const hmac = createHmac("sha256", signingSecret);
if (signed_sig !== slackSignature) {
return res.status(400).json({ message: "Invalid signature" });
hmac.update(`${version}:${timeStamp}:${stringify(req.body)}`);
const signed_sig = hmac.digest("hex");
console.log({ signed_sig, hash, match: signed_sig === hash });
if (signed_sig !== hash) {
throw new Error("Hashes do not match ");
}
}
+15 -5
View File
@@ -1,8 +1,18 @@
import { WebClient } from "@slack/web-api";
import { z } from "zod";
const getUserEmail = async (client: WebClient, userId: string) =>
await (
await client.users.info({ user: userId })
).user?.profile?.email;
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
export { getUserEmail };
export const getUserEmail = async (client: WebClient, userId: string) =>
(await client.users.info({ user: userId })).user?.profile?.email;
const slackAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
signing_secret: z.string(),
});
export const getSlackAppKeys = async () => {
const appKeys = await getAppKeysFromSlug("slack");
return slackAppKeysSchema.parse(appKeys);
};
@@ -13,7 +13,7 @@ const CreateEventModal = (
} | null;
})
| null,
invalidInput: boolean = false
invalidInput = false
) => {
return Modal({ title: "Create Booking", submit: "Create", callbackId: "cal.event.create" })
.blocks(
@@ -1,15 +1,15 @@
import { Booking } from "@prisma/client";
import dayjs from "dayjs";
import { Modal, Blocks, Elements, Bits, Message } from "slack-block-builder";
import { Blocks, Elements, Message } from "slack-block-builder";
import { BASE_URL } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
const TodayMessage = (bookings: Booking[]) => {
if (bookings.length === 0) {
return Message()
.blocks(Blocks.Section({ text: "You do not have any bookings for today." }))
.asUser()
.buildToJSON();
.buildToObject();
}
return Message()
.blocks(
@@ -18,7 +18,7 @@ const TodayMessage = (bookings: Booking[]) => {
bookings.map((booking) =>
Blocks.Section({
text: `${booking.title} | ${dayjs(booking.startTime).format("HH:mm")}`,
}).accessory(Elements.Button({ text: "Cancel", url: `${BASE_URL}/cancel/${booking.uid}` }))
}).accessory(Elements.Button({ text: "Cancel", url: `${WEBAPP_URL}/cancel/${booking.uid}` }))
)
)
.buildToObject();
@@ -8,7 +8,8 @@
"dependencies": {
"@calcom/prisma": "*",
"@slack/web-api": "^6.7.0",
"slack-block-builder": "^2.5.0"
"slack-block-builder": "^2.5.0",
"zod": "^3.17.3"
},
"devDependencies": {
"@calcom/types": "*"
+4 -2
View File
@@ -67,7 +67,7 @@ export const bookingCreateBodySchema = z.object({
})
.optional(),
eventTypeId: z.number(),
eventTypeSlug: z.string(),
eventTypeSlug: z.string().optional(),
guests: z.array(z.string()).optional(),
location: z.string(),
name: z.string(),
@@ -81,10 +81,12 @@ export const bookingCreateBodySchema = z.object({
bookingUid: z.string().optional(),
customInputs: z.array(z.object({ label: z.string(), value: z.union([z.string(), z.boolean()]) })),
metadata: z.record(z.string()),
hasHashedBookingLink: z.boolean(),
hasHashedBookingLink: z.boolean().optional(),
hashedLink: z.string().nullish(),
});
export type BookingCreateBody = z.input<typeof bookingCreateBodySchema>;
export const extendedBookingCreateBody = bookingCreateBodySchema.merge(
z.object({
noEmail: z.boolean().optional(),