mirror of
https://github.com/hanzo-apps/dataroom.git
synced 2026-10-30 14:47:36 +00:00
Same failure as skipDuplicates, confirmed the same way against a database
built from this schema: Prisma does not ignore these on sqlite, it rejects
the call.
mode: "insensitive" — broke document search and listing, dataroom search,
and the two download-by-email lookups. Plain `contains` goes through LIKE,
which sqlite already treats case-insensitively for ascii, so dropping the
argument keeps the behaviour. `equals` does NOT: sqlite compares with `=`
case-sensitively, so the two lookups that matched a visitor's address that
way now narrow with `contains` and settle it with an exact comparison —
verify.ts was already doing that comparison, by-email.ts was not.
One of these was invisible to tsc: the documents where clause is assembled
from spreads into a variable, so nothing checked it against Prisma's type
and only the runtime rejected it.
array_contains — every webhook delivery was throwing instead of fanning out.
Three copies of the same query, now one `subscribers()` that matches the
trigger against the list. `Webhook.triggers` is another postgres String[]
that became Json, so it is narrowed like the rest.
99 errors -> 11, no file worse than it was.
Co-authored-by: Hanzo Dev <[email protected]>
230 lines
6.6 KiB
TypeScript
230 lines
6.6 KiB
TypeScript
import { NextApiRequest, NextApiResponse } from "next";
|
|
|
|
import { parse } from "cookie";
|
|
|
|
import {
|
|
createDataroomSession,
|
|
getDataroomSessionByLinkIdInPagesRouter,
|
|
updateDataroomSessionVerified,
|
|
} from "@/lib/auth/dataroom-auth";
|
|
import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification";
|
|
import prisma from "@/lib/prisma";
|
|
import { ratelimit } from "@/lib/kv";
|
|
import { generateOTP } from "@/lib/utils/generate-otp";
|
|
import { getIpAddress } from "@/lib/utils/ip";
|
|
|
|
const OTP_IDENTIFIER_PREFIX = "download-otp:";
|
|
|
|
export default async function handler(
|
|
req: NextApiRequest,
|
|
res: NextApiResponse,
|
|
) {
|
|
if (req.method === "GET") {
|
|
const linkId = req.query.linkId as string;
|
|
if (!linkId) {
|
|
return res.status(400).json({ error: "linkId is required" });
|
|
}
|
|
|
|
const session = await getDataroomSessionByLinkIdInPagesRouter(req, linkId);
|
|
if (!session) {
|
|
return res
|
|
.status(401)
|
|
.json({ error: "Session required", verified: false });
|
|
}
|
|
return res.status(200).json({ verified: session.verified });
|
|
}
|
|
|
|
if (req.method !== "POST") {
|
|
res.setHeader("Allow", ["GET", "POST"]);
|
|
return res.status(405).end(`Method ${req.method} Not Allowed`);
|
|
}
|
|
|
|
const { linkId, viewId: providedViewId, email, code } = req.body as {
|
|
linkId?: string;
|
|
viewId?: string;
|
|
email?: string;
|
|
code?: string;
|
|
};
|
|
|
|
if (!linkId || !email) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "linkId and email are required" });
|
|
}
|
|
|
|
// Look up the view: use provided viewId if given, otherwise find by email + linkId
|
|
const view = providedViewId
|
|
? await prisma.view.findUnique({
|
|
where: { id: providedViewId, linkId, viewType: "DATAROOM_VIEW" },
|
|
select: {
|
|
id: true,
|
|
dataroomId: true,
|
|
viewerId: true,
|
|
viewerEmail: true,
|
|
link: { select: { teamId: true } },
|
|
},
|
|
})
|
|
: // `contains` rather than `equals` because sqlite compares strings with `=`
|
|
// case-sensitively, and this has to match the address however the visitor
|
|
// typed it. `contains` goes through LIKE, which sqlite treats
|
|
// case-insensitively for ascii. It is a substring match, so one address
|
|
// can be found inside another — the exact comparison below settles it.
|
|
await prisma.view
|
|
.findMany({
|
|
where: {
|
|
linkId,
|
|
viewType: "DATAROOM_VIEW",
|
|
viewerEmail: { contains: email },
|
|
},
|
|
select: {
|
|
id: true,
|
|
dataroomId: true,
|
|
viewerId: true,
|
|
viewerEmail: true,
|
|
link: { select: { teamId: true } },
|
|
},
|
|
orderBy: { viewedAt: "desc" },
|
|
})
|
|
.then((views) =>
|
|
views.find(
|
|
(v) => v.viewerEmail?.toLowerCase() === email.toLowerCase(),
|
|
),
|
|
);
|
|
|
|
if (!view || view.viewerEmail?.toLowerCase() !== email.toLowerCase()) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: "View not found or email does not match" });
|
|
}
|
|
|
|
// Use the resolved viewId from here on
|
|
const viewId = view.id;
|
|
|
|
if (!view.link?.teamId) {
|
|
return res.status(400).json({ error: "Link has no team" });
|
|
}
|
|
|
|
if (!code) {
|
|
const { success: emailLimitSuccess } = await ratelimit(1, "30 s").limit(
|
|
`${OTP_IDENTIFIER_PREFIX}${linkId}:${email.toLowerCase()}`,
|
|
);
|
|
if (!emailLimitSuccess) {
|
|
return res.status(429).json({
|
|
error: "Please wait 30 seconds before requesting another code.",
|
|
});
|
|
}
|
|
|
|
const ipAddressValue = getIpAddress(req.headers) ?? "unknown";
|
|
const { success: ipSuccess } = await ratelimit(10, "1 m").limit(
|
|
`download-otp-ip:${ipAddressValue}`,
|
|
);
|
|
if (!ipSuccess) {
|
|
return res.status(429).json({
|
|
error: "Too many requests. Try again later.",
|
|
});
|
|
}
|
|
|
|
await prisma.verificationToken.deleteMany({
|
|
where: {
|
|
identifier: `${OTP_IDENTIFIER_PREFIX}${linkId}:${email.toLowerCase()}`,
|
|
},
|
|
});
|
|
|
|
const otpCode = generateOTP();
|
|
const expiresAt = new Date();
|
|
expiresAt.setMinutes(expiresAt.getMinutes() + 10);
|
|
|
|
await prisma.verificationToken.create({
|
|
data: {
|
|
token: otpCode,
|
|
identifier: `${OTP_IDENTIFIER_PREFIX}${linkId}:${email.toLowerCase()}`,
|
|
expires: expiresAt,
|
|
},
|
|
});
|
|
|
|
await sendOtpVerificationEmail(email, otpCode, true, view.link.teamId);
|
|
|
|
return res.status(200).json({
|
|
type: "email-verification",
|
|
message: "Verification code sent to your email.",
|
|
});
|
|
}
|
|
|
|
const { success: ipSuccess } = await ratelimit(10, "1 m").limit(
|
|
`download-verify-ip:${getIpAddress(req.headers) ?? "unknown"}`,
|
|
);
|
|
if (!ipSuccess) {
|
|
return res.status(429).json({ error: "Too many requests." });
|
|
}
|
|
|
|
const verification = await prisma.verificationToken.findUnique({
|
|
where: {
|
|
token: code,
|
|
identifier: `${OTP_IDENTIFIER_PREFIX}${linkId}:${email.toLowerCase()}`,
|
|
},
|
|
});
|
|
|
|
if (!verification) {
|
|
return res.status(401).json({
|
|
error: "Invalid or expired code. Request a new code.",
|
|
resetVerification: true,
|
|
});
|
|
}
|
|
|
|
if (Date.now() > verification.expires.getTime()) {
|
|
await prisma.verificationToken.delete({
|
|
where: { token: code },
|
|
});
|
|
return res.status(401).json({
|
|
error: "Code expired. Request a new code.",
|
|
resetVerification: true,
|
|
});
|
|
}
|
|
|
|
await prisma.verificationToken.delete({
|
|
where: { token: code },
|
|
});
|
|
|
|
await prisma.view.update({
|
|
where: { id: viewId },
|
|
data: { verified: true },
|
|
});
|
|
|
|
const cookies = parse(req.headers.cookie || "");
|
|
let sessionToken = cookies[`pm_drs_${linkId}`];
|
|
|
|
const needNewSession = async () => {
|
|
if (!view.dataroomId) return;
|
|
const ipAddressValue = getIpAddress(req.headers) ?? "unknown";
|
|
const { token, expiresAt } = await createDataroomSession(
|
|
view.dataroomId,
|
|
linkId,
|
|
viewId,
|
|
ipAddressValue,
|
|
true,
|
|
view.viewerId ?? undefined,
|
|
);
|
|
sessionToken = token;
|
|
const maxAge = Math.floor((expiresAt - Date.now()) / 1000);
|
|
res.setHeader("Set-Cookie", [
|
|
`pm_drs_${linkId}=${token}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax`,
|
|
]);
|
|
};
|
|
|
|
if (sessionToken) {
|
|
const updated = await updateDataroomSessionVerified(sessionToken, true);
|
|
// If update failed (e.g. session expired and was deleted), create a new
|
|
// session so the next request (e.g. bulk) has a valid session.
|
|
if (!updated) {
|
|
await needNewSession();
|
|
}
|
|
} else {
|
|
await needNewSession();
|
|
}
|
|
|
|
return res.status(200).json({
|
|
type: "verified",
|
|
message: "Email verified. You can receive download notifications.",
|
|
});
|
|
}
|