mirror of
https://github.com/hanzoai/dataroom.git
synced 2026-10-30 18:41:07 +00:00
The download flow for visitor links showed a success toast but never started the actual file download. This affected Chrome, Brave, and all iOS/iPad browsers. Root causes: - nav.tsx: created an <a> element without the download attribute for cross-origin URLs. The download attribute is ignored for cross-origin URLs, and link.click() in async callbacks loses user gesture context on iOS WebKit. - download-only-viewer.tsx: used window.open() which is blocked by popup blockers in async callbacks (especially on mobile browsers). - document-card.tsx: used an iframe approach with a bug where it fell back to response.url instead of the actual downloadUrl. Fix: fetch the file as a blob and create a same-origin blob URL with the download attribute. Same-origin blob URLs always respect the download attribute and work reliably across all browsers including iOS Safari, Chrome, and Brave. Also returns fileName from the download API so the downloaded file has the correct name. Co-authored-by: Marc Seitz <[email protected]>
227 lines
7.5 KiB
TypeScript
227 lines
7.5 KiB
TypeScript
import { NextApiRequest, NextApiResponse } from "next";
|
|
|
|
import { LinkType } from "@prisma/client";
|
|
|
|
import { getFile } from "@/lib/files/get-file";
|
|
import { notifyDocumentDownload } from "@/lib/integrations/slack/events";
|
|
import prisma from "@/lib/prisma";
|
|
import { getFileNameWithPdfExtension } from "@/lib/utils";
|
|
import { getIpAddress } from "@/lib/utils/ip";
|
|
|
|
// This function can run for a maximum of 300 seconds
|
|
export const config = {
|
|
maxDuration: 300,
|
|
};
|
|
|
|
export default async function handle(
|
|
req: NextApiRequest,
|
|
res: NextApiResponse,
|
|
) {
|
|
if (req.method === "POST") {
|
|
// POST /api/links/download
|
|
const { linkId, viewId } = req.body as { linkId: string; viewId: string };
|
|
|
|
try {
|
|
const view = await prisma.view.findUnique({
|
|
where: {
|
|
id: viewId,
|
|
linkId: linkId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
viewedAt: true,
|
|
viewerEmail: true,
|
|
link: {
|
|
select: {
|
|
linkType: true,
|
|
allowDownload: true,
|
|
expiresAt: true,
|
|
isArchived: true,
|
|
deletedAt: true,
|
|
enableWatermark: true,
|
|
watermarkConfig: true,
|
|
name: true,
|
|
},
|
|
},
|
|
document: {
|
|
select: {
|
|
id: true,
|
|
teamId: true,
|
|
downloadOnly: true,
|
|
name: true,
|
|
versions: {
|
|
where: { isPrimary: true },
|
|
select: {
|
|
type: true,
|
|
file: true,
|
|
storageType: true,
|
|
numPages: true,
|
|
originalFile: true,
|
|
contentType: true,
|
|
},
|
|
take: 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// if view does not exist, we should not allow the download
|
|
if (!view) {
|
|
return res.status(404).json({ error: "Error downloading" });
|
|
}
|
|
|
|
// if document is downloadOnly, always allow. Otherwise, check link settings.
|
|
if (!view.document?.downloadOnly && !view.link.allowDownload) {
|
|
return res.status(403).json({ error: "Error downloading" });
|
|
}
|
|
|
|
// if link is archived, we should not allow the download
|
|
if (view.link.isArchived) {
|
|
return res.status(403).json({ error: "Error downloading" });
|
|
}
|
|
|
|
// if link is deleted, we should not allow the download
|
|
if (view.link.deletedAt) {
|
|
return res.status(403).json({ error: "Error downloading" });
|
|
}
|
|
|
|
// if link is expired, we should not allow the download
|
|
if (view.link.expiresAt && view.link.expiresAt < new Date()) {
|
|
return res.status(403).json({ error: "Error downloading" });
|
|
}
|
|
|
|
// if document is a Notion document, we should not allow the download
|
|
if (view.document!.versions[0].type === "notion") {
|
|
return res.status(403).json({ error: "Error downloading" });
|
|
}
|
|
|
|
// if viewedAt is longer than 30 mins ago, we should not allow the download for document links and 23 hours ago for dataroom links
|
|
if (
|
|
(view.link.linkType === LinkType.DOCUMENT_LINK &&
|
|
view.viewedAt < new Date(Date.now() - 30 * 60 * 1000)) ||
|
|
(view.link.linkType === LinkType.DATAROOM_LINK &&
|
|
view.viewedAt < new Date(Date.now() - 23 * 60 * 60 * 1000))
|
|
) {
|
|
return res.status(403).json({ error: "Error downloading" });
|
|
}
|
|
|
|
// update the view with the downloadedAt timestamp
|
|
await prisma.view.update({
|
|
where: { id: viewId },
|
|
data: { downloadedAt: new Date() },
|
|
});
|
|
|
|
if (view.document?.teamId) {
|
|
try {
|
|
await notifyDocumentDownload({
|
|
teamId: view.document.teamId,
|
|
documentId: view.document.id,
|
|
dataroomId: undefined,
|
|
linkId,
|
|
viewerEmail: view.viewerEmail ?? undefined,
|
|
viewerId: undefined,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error sending Slack notification:", error);
|
|
}
|
|
}
|
|
|
|
// get the file to be downloaded, if watermark is enabled and document is not pdf, then get the pdf file, otherwise return the original file
|
|
// if watermark is enabled and watermark config is present and document version is pdf, then get the file
|
|
// if watermark is not enabled, then get the original file
|
|
const file =
|
|
view.link.enableWatermark &&
|
|
view.link.watermarkConfig &&
|
|
view.document!.versions[0].type === "pdf"
|
|
? view.document!.versions[0].file
|
|
: (view.document!.versions[0].originalFile ??
|
|
view.document!.versions[0].file);
|
|
|
|
const downloadUrl = await getFile({
|
|
type: view.document!.versions[0].storageType,
|
|
data: file,
|
|
isDownload: true,
|
|
});
|
|
|
|
if (
|
|
view.document!.versions[0].type === "pdf" &&
|
|
view.link.enableWatermark &&
|
|
view.link.watermarkConfig
|
|
) {
|
|
const response = await fetch(
|
|
`${process.env.NEXTAUTH_URL}/api/mupdf/annotate-document`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,
|
|
},
|
|
body: JSON.stringify({
|
|
url: downloadUrl,
|
|
numPages: view.document!.versions[0].numPages,
|
|
watermarkConfig: view.link.watermarkConfig,
|
|
originalFileName: view.document!.name,
|
|
viewerData: {
|
|
email: view.viewerEmail,
|
|
date: new Date(
|
|
view.viewedAt ? view.viewedAt : new Date(),
|
|
).toLocaleDateString(),
|
|
ipAddress: getIpAddress(req.headers),
|
|
link: view.link.name,
|
|
time: new Date(
|
|
view.viewedAt ? view.viewedAt : new Date(),
|
|
).toLocaleTimeString(),
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
// Try to get the specific error details from the watermarking API
|
|
let errorMessage = "Error downloading";
|
|
try {
|
|
const errorData = await response.json();
|
|
if (errorData.error && errorData.details) {
|
|
errorMessage = `${errorData.error}: ${errorData.details}`;
|
|
} else if (errorData.error) {
|
|
errorMessage = errorData.error;
|
|
}
|
|
} catch {
|
|
// If we can't parse the error response, use generic message
|
|
errorMessage = "Error downloading";
|
|
}
|
|
|
|
return res.status(500).json({ error: errorMessage });
|
|
}
|
|
|
|
const pdfBuffer = await response.arrayBuffer();
|
|
|
|
// Set appropriate headers
|
|
res.setHeader("Content-Type", "application/pdf");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="${encodeURIComponent(getFileNameWithPdfExtension(view.document!.name))}"`,
|
|
);
|
|
res.setHeader("Content-Length", Buffer.from(pdfBuffer).length);
|
|
|
|
// Send the watermarked buffer directly
|
|
return res.send(Buffer.from(pdfBuffer));
|
|
}
|
|
|
|
return res
|
|
.status(200)
|
|
.json({ downloadUrl, fileName: view.document!.name });
|
|
} catch (error) {
|
|
return res.status(500).json({
|
|
message: "Internal Server Error",
|
|
error: (error as Error).message,
|
|
});
|
|
}
|
|
}
|
|
|
|
// We only allow POST requests
|
|
res.setHeader("Allow", ["POST"]);
|
|
return res.status(405).end(`Method ${req.method} Not Allowed`);
|
|
}
|