feat: store file key instead of urls
This commit is contained in:
@@ -786,18 +786,15 @@ export default function LinksTable({
|
||||
}, [targetType]);
|
||||
|
||||
// Handle toggle and persist to localStorage
|
||||
const handleAllLinksToggle = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsAllLinksOpen(open);
|
||||
try {
|
||||
// Store "true" when collapsed, "false" when expanded
|
||||
localStorage.setItem(ALL_LINKS_COLLAPSED_KEY, String(!open));
|
||||
} catch (e) {
|
||||
console.warn("Could not write to localStorage:", e);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleAllLinksToggle = useCallback((open: boolean) => {
|
||||
setIsAllLinksOpen(open);
|
||||
try {
|
||||
// Store "true" when collapsed, "false" when expanded
|
||||
localStorage.setItem(ALL_LINKS_COLLAPSED_KEY, String(!open));
|
||||
} catch (e) {
|
||||
console.warn("Could not write to localStorage:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isDataroom = targetType === "DATAROOM";
|
||||
|
||||
@@ -839,9 +836,7 @@ export default function LinksTable({
|
||||
</ButtonTooltip>
|
||||
) : null}
|
||||
<ButtonTooltip
|
||||
content={
|
||||
link.name || `Link #${link.id.slice(-5)}`
|
||||
}
|
||||
content={link.name || `Link #${link.id.slice(-5)}`}
|
||||
>
|
||||
<span className="max-w-[150px] truncate md:max-w-[170px] lg:max-w-[200px] xl:max-w-[230px] 2xl:max-w-[300px]">
|
||||
{link.name || `Link #${link.id.slice(-5)}`}
|
||||
@@ -903,9 +898,7 @@ export default function LinksTable({
|
||||
link={link}
|
||||
isFree={isFree}
|
||||
onCopy={handleCopyToClipboard}
|
||||
isProcessing={isDocumentProcessing(
|
||||
primaryVersion,
|
||||
)}
|
||||
isProcessing={isDocumentProcessing(primaryVersion)}
|
||||
primaryVersion={primaryVersion}
|
||||
mutateDocument={mutateDocument}
|
||||
isPopoverOpen={popoverOpen === link.id}
|
||||
@@ -915,9 +908,7 @@ export default function LinksTable({
|
||||
link={link}
|
||||
onCopy={handleCopyToClipboard}
|
||||
onPreview={handlePreviewLink}
|
||||
isProcessing={isDocumentProcessing(
|
||||
primaryVersion,
|
||||
)}
|
||||
isProcessing={isDocumentProcessing(primaryVersion)}
|
||||
/>
|
||||
{isMobile ? (
|
||||
<ButtonTooltip content="Edit link">
|
||||
@@ -1023,10 +1014,7 @@ export default function LinksTable({
|
||||
) : null}
|
||||
<TableCell>
|
||||
<CollapsibleTrigger
|
||||
disabled={
|
||||
isDataroom ||
|
||||
link._count.views === 0
|
||||
}
|
||||
disabled={isDataroom || link._count.views === 0}
|
||||
>
|
||||
<div className="flex items-center space-x-1 [&[data-state=open]>svg.chevron]:rotate-180">
|
||||
<BarChart className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -1036,8 +1024,7 @@ export default function LinksTable({
|
||||
views
|
||||
</span>
|
||||
</p>
|
||||
{!isDataroom &&
|
||||
link._count.views > 0 ? (
|
||||
{!isDataroom && link._count.views > 0 ? (
|
||||
<ChevronDown className="chevron h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1108,8 +1095,7 @@ export default function LinksTable({
|
||||
</DropdownMenuItem>
|
||||
{/* Dataroom-only: Edit File Permissions */}
|
||||
{isDataroom &&
|
||||
link.audienceType !==
|
||||
LinkAudienceType.GROUP && (
|
||||
link.audienceType !== LinkAudienceType.GROUP && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleEditPermissions(link)}
|
||||
disabled={isLoading}
|
||||
@@ -1145,8 +1131,7 @@ export default function LinksTable({
|
||||
onClick={() => {
|
||||
setSelectedEmbedLink({
|
||||
id: link.id,
|
||||
name:
|
||||
link.name || `Link #${link.id.slice(-5)}`,
|
||||
name: link.name || `Link #${link.id.slice(-5)}`,
|
||||
});
|
||||
setEmbedModalOpen(true);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
|
||||
|
||||
export interface S3KeyInfo {
|
||||
bucket: string;
|
||||
key: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
const THREE_DAYS_IN_SECONDS = 3 * 24 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Parse an S3 presigned URL to extract bucket, key, and region.
|
||||
* Supports both path-style and virtual-hosted-style URLs.
|
||||
*
|
||||
* Path-style: https://s3.{region}.amazonaws.com/{bucket}/{key}
|
||||
* Virtual-hosted: https://{bucket}.s3.{region}.amazonaws.com/{key}
|
||||
*/
|
||||
export function parseS3PresignedUrl(presignedUrl: string): S3KeyInfo {
|
||||
const url = new URL(presignedUrl);
|
||||
const hostname = url.hostname;
|
||||
|
||||
// Path-style: s3.{region}.amazonaws.com
|
||||
const pathStyleMatch = hostname.match(/^s3\.([^.]+)\.amazonaws\.com$/);
|
||||
if (pathStyleMatch) {
|
||||
const region = pathStyleMatch[1];
|
||||
// Path is /{bucket}/{key...}
|
||||
const pathParts = url.pathname.slice(1).split("/");
|
||||
const bucket = decodeURIComponent(pathParts[0]);
|
||||
const key = pathParts.slice(1).map(decodeURIComponent).join("/");
|
||||
return { bucket, key, region };
|
||||
}
|
||||
|
||||
// Virtual-hosted-style: {bucket}.s3.{region}.amazonaws.com
|
||||
const virtualMatch = hostname.match(/^(.+)\.s3\.([^.]+)\.amazonaws\.com$/);
|
||||
if (virtualMatch) {
|
||||
const bucket = virtualMatch[1];
|
||||
const region = virtualMatch[2];
|
||||
const key = decodeURIComponent(url.pathname.slice(1));
|
||||
return { bucket, key, region };
|
||||
}
|
||||
|
||||
// Virtual-hosted without region: {bucket}.s3.amazonaws.com (us-east-1)
|
||||
const virtualNoRegionMatch = hostname.match(/^(.+)\.s3\.amazonaws\.com$/);
|
||||
if (virtualNoRegionMatch) {
|
||||
const bucket = virtualNoRegionMatch[1];
|
||||
const key = decodeURIComponent(url.pathname.slice(1));
|
||||
return { bucket: bucket, key, region: "us-east-1" };
|
||||
}
|
||||
|
||||
throw new Error(`Unable to parse S3 URL: ${presignedUrl}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh presigned URL for an S3 object using team credentials.
|
||||
* Uses long-term IAM credentials which allow presigned URLs up to 7 days.
|
||||
*/
|
||||
export async function generateFreshPresignedUrl(
|
||||
teamId: string,
|
||||
s3Key: S3KeyInfo,
|
||||
expiresInSeconds: number = THREE_DAYS_IN_SECONDS,
|
||||
): Promise<string> {
|
||||
const config = await getTeamStorageConfigById(teamId);
|
||||
|
||||
const client = new S3Client({
|
||||
region: s3Key.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
});
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: s3Key.bucket,
|
||||
Key: s3Key.key,
|
||||
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(s3Key.key.split("/").pop() || "download.zip")}"`,
|
||||
ResponseCacheControl: "no-cache, no-store, must-revalidate",
|
||||
});
|
||||
|
||||
return getSignedUrl(client, command, { expiresIn: expiresInSeconds });
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface DownloadJob {
|
||||
processedFiles: number;
|
||||
progress: number; // 0-100
|
||||
downloadUrls?: string[]; // Multiple ZIPs if large (S3 presigned URLs auto-expire)
|
||||
downloadS3Keys?: { bucket: string; key: string; region: string }[]; // S3 object references for on-demand presigning
|
||||
error?: string;
|
||||
teamId: string;
|
||||
userId: string;
|
||||
|
||||
@@ -130,7 +130,7 @@ export const bulkDownloadTask = task({
|
||||
fileCount: fileKeys.length,
|
||||
});
|
||||
|
||||
const downloadUrl = await processDownloadBatch({
|
||||
const result = await processDownloadBatch({
|
||||
teamId,
|
||||
folderStructure,
|
||||
fileKeys,
|
||||
@@ -152,7 +152,8 @@ export const bulkDownloadTask = task({
|
||||
status: "COMPLETED",
|
||||
processedFiles: fileKeys.length,
|
||||
progress: 100,
|
||||
downloadUrls: [downloadUrl],
|
||||
downloadUrls: [result.downloadUrl],
|
||||
downloadS3Keys: result.s3KeyInfo ? [result.s3KeyInfo] : undefined,
|
||||
completedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(
|
||||
Date.now() + 3 * 24 * 60 * 60 * 1000,
|
||||
@@ -173,13 +174,13 @@ export const bulkDownloadTask = task({
|
||||
|
||||
logger.info("Bulk download task completed successfully", {
|
||||
jobId,
|
||||
downloadUrls: [downloadUrl],
|
||||
downloadUrls: [result.downloadUrl],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
jobId,
|
||||
downloadUrls: [downloadUrl],
|
||||
downloadUrls: [result.downloadUrl],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,6 +196,8 @@ export const bulkDownloadTask = task({
|
||||
const batches = splitFilesIntoBatches(folderStructure, fileKeys);
|
||||
const totalBatches = batches.length;
|
||||
const downloadUrls: string[] = [];
|
||||
const downloadS3Keys: { bucket: string; key: string; region: string }[] =
|
||||
[];
|
||||
|
||||
logger.info("Created file batches", {
|
||||
jobId,
|
||||
@@ -219,7 +222,7 @@ export const bulkDownloadTask = task({
|
||||
});
|
||||
|
||||
try {
|
||||
const downloadUrl = await processDownloadBatch({
|
||||
const result = await processDownloadBatch({
|
||||
teamId,
|
||||
folderStructure: batch.folderStructure,
|
||||
fileKeys: batch.fileKeys,
|
||||
@@ -236,7 +239,10 @@ export const bulkDownloadTask = task({
|
||||
),
|
||||
});
|
||||
|
||||
downloadUrls.push(downloadUrl);
|
||||
downloadUrls.push(result.downloadUrl);
|
||||
if (result.s3KeyInfo) {
|
||||
downloadS3Keys.push(result.s3KeyInfo);
|
||||
}
|
||||
|
||||
// Calculate progress
|
||||
const processedFiles = batches
|
||||
@@ -253,7 +259,7 @@ export const bulkDownloadTask = task({
|
||||
logger.info(`Batch ${batchNumber} completed`, {
|
||||
jobId,
|
||||
batchNumber,
|
||||
downloadUrl,
|
||||
downloadUrl: result.downloadUrl,
|
||||
progress,
|
||||
});
|
||||
} catch (batchError) {
|
||||
@@ -275,6 +281,7 @@ export const bulkDownloadTask = task({
|
||||
processedFiles: fileKeys.length,
|
||||
progress: 100,
|
||||
downloadUrls,
|
||||
downloadS3Keys: downloadS3Keys.length > 0 ? downloadS3Keys : undefined,
|
||||
completedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days
|
||||
});
|
||||
@@ -332,6 +339,11 @@ interface ProcessDownloadBatchParams {
|
||||
expirationHours?: number;
|
||||
}
|
||||
|
||||
interface ProcessDownloadBatchResult {
|
||||
downloadUrl: string;
|
||||
s3KeyInfo?: { bucket: string; key: string; region: string };
|
||||
}
|
||||
|
||||
async function processDownloadBatch({
|
||||
teamId,
|
||||
folderStructure,
|
||||
@@ -343,7 +355,7 @@ async function processDownloadBatch({
|
||||
totalParts,
|
||||
zipFileName,
|
||||
expirationHours = 72,
|
||||
}: ProcessDownloadBatchParams): Promise<string> {
|
||||
}: ProcessDownloadBatchParams): Promise<ProcessDownloadBatchResult> {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || "https://app.papermark.com";
|
||||
const internalApiKey = process.env.INTERNAL_API_KEY;
|
||||
|
||||
@@ -377,7 +389,7 @@ async function processDownloadBatch({
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.downloadUrl;
|
||||
return { downloadUrl: data.downloadUrl, s3KeyInfo: data.s3KeyInfo };
|
||||
}
|
||||
|
||||
interface FileBatch {
|
||||
|
||||
@@ -87,7 +87,20 @@ export default async function handler(
|
||||
|
||||
const body = JSON.parse(payload.body);
|
||||
|
||||
return res.status(200).json({ downloadUrl: body.downloadUrl });
|
||||
// Parse the presigned URL to extract S3 key info for on-demand re-signing
|
||||
let s3KeyInfo: { bucket: string; key: string; region: string } | undefined;
|
||||
try {
|
||||
const { parseS3PresignedUrl } = await import(
|
||||
"@/lib/files/bulk-download-presign"
|
||||
);
|
||||
s3KeyInfo = parseS3PresignedUrl(body.downloadUrl);
|
||||
} catch {
|
||||
// Non-fatal: fall back to stored presigned URL
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ downloadUrl: body.downloadUrl, s3KeyInfo });
|
||||
} catch (error) {
|
||||
console.error("Error processing download batch:", error);
|
||||
return res.status(500).json({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getDataroomSessionByLinkIdInPagesRouter } from "@/lib/auth/dataroom-auth";
|
||||
import { generateFreshPresignedUrl } from "@/lib/files/bulk-download-presign";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { downloadJobStore } from "@/lib/redis-download-job-store";
|
||||
|
||||
@@ -14,9 +15,14 @@ export default async function handler(
|
||||
}
|
||||
|
||||
const linkId = req.query.linkId as string;
|
||||
const { jobId, partIndex } = req.query as { jobId: string; partIndex: string };
|
||||
const { jobId, partIndex } = req.query as {
|
||||
jobId: string;
|
||||
partIndex: string;
|
||||
};
|
||||
if (!linkId || !jobId || partIndex == null) {
|
||||
return res.status(400).json({ error: "linkId, jobId and partIndex required" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "linkId, jobId and partIndex required" });
|
||||
}
|
||||
|
||||
const session = await getDataroomSessionByLinkIdInPagesRouter(req, linkId);
|
||||
@@ -44,7 +50,9 @@ export default async function handler(
|
||||
job.viewerEmail?.toLowerCase().trim() !==
|
||||
view.viewerEmail?.toLowerCase().trim()
|
||||
) {
|
||||
return res.status(403).json({ error: "Job does not belong to this viewer" });
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Job does not belong to this viewer" });
|
||||
}
|
||||
|
||||
if (job.status !== "COMPLETED" || !job.downloadUrls?.length) {
|
||||
@@ -56,6 +64,19 @@ export default async function handler(
|
||||
return res.status(400).json({ error: "Invalid part index" });
|
||||
}
|
||||
|
||||
// Generate a fresh presigned URL using long-term IAM credentials
|
||||
// to avoid the Lambda STS token expiration issue
|
||||
const s3Key = job.downloadS3Keys?.[index];
|
||||
if (s3Key && job.teamId) {
|
||||
try {
|
||||
const freshUrl = await generateFreshPresignedUrl(job.teamId, s3Key);
|
||||
return res.redirect(302, freshUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate fresh presigned URL:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to stored presigned URL (may fail if STS token expired)
|
||||
const presignedUrl = job.downloadUrls[index];
|
||||
res.redirect(302, presignedUrl);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { generateFreshPresignedUrl } from "@/lib/files/bulk-download-presign";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { downloadJobStore } from "@/lib/redis-download-job-store";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
@@ -68,6 +69,25 @@ export default async function handler(
|
||||
.json({ error: "Job does not belong to this dataroom" });
|
||||
}
|
||||
|
||||
// Generate fresh presigned URLs using long-term IAM credentials
|
||||
let freshDownloadUrls: string[] | undefined;
|
||||
if (
|
||||
job.status === "COMPLETED" &&
|
||||
job.downloadS3Keys?.length &&
|
||||
job.downloadUrls?.length
|
||||
) {
|
||||
try {
|
||||
freshDownloadUrls = await Promise.all(
|
||||
job.downloadS3Keys.map((s3Key) =>
|
||||
generateFreshPresignedUrl(teamId, s3Key),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate fresh presigned URLs:", error);
|
||||
freshDownloadUrls = job.downloadUrls;
|
||||
}
|
||||
}
|
||||
|
||||
// Return full status for polling
|
||||
return res.status(200).json({
|
||||
id: job.id,
|
||||
@@ -75,7 +95,10 @@ export default async function handler(
|
||||
progress: job.progress,
|
||||
totalFiles: job.totalFiles,
|
||||
processedFiles: job.processedFiles,
|
||||
downloadUrls: job.status === "COMPLETED" ? job.downloadUrls : undefined,
|
||||
downloadUrls:
|
||||
job.status === "COMPLETED"
|
||||
? (freshDownloadUrls ?? job.downloadUrls)
|
||||
: undefined,
|
||||
error: job.status === "FAILED" ? job.error : undefined,
|
||||
isReady: job.status === "COMPLETED" && !!job.downloadUrls?.length,
|
||||
dataroomName: job.dataroomName,
|
||||
|
||||
@@ -3,8 +3,12 @@ import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { generateFreshPresignedUrl } from "@/lib/files/bulk-download-presign";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { downloadJobStore } from "@/lib/redis-download-job-store";
|
||||
import {
|
||||
type DownloadJob,
|
||||
downloadJobStore,
|
||||
} from "@/lib/redis-download-job-store";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
export default async function handler(
|
||||
@@ -71,7 +75,30 @@ export default async function handler(
|
||||
return false;
|
||||
});
|
||||
|
||||
return res.status(200).json(userJobs);
|
||||
// Generate fresh presigned URLs for completed jobs
|
||||
const jobsWithFreshUrls = await Promise.all(
|
||||
userJobs.map(async (job: DownloadJob) => {
|
||||
if (
|
||||
job.status === "COMPLETED" &&
|
||||
job.downloadS3Keys?.length &&
|
||||
job.downloadUrls?.length
|
||||
) {
|
||||
try {
|
||||
const freshUrls = await Promise.all(
|
||||
job.downloadS3Keys.map((s3Key) =>
|
||||
generateFreshPresignedUrl(teamId, s3Key),
|
||||
),
|
||||
);
|
||||
return { ...job, downloadUrls: freshUrls };
|
||||
} catch {
|
||||
return job;
|
||||
}
|
||||
}
|
||||
return job;
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json(jobsWithFreshUrls);
|
||||
} catch (error) {
|
||||
console.error("Error fetching download jobs:", error);
|
||||
return res.status(500).json({
|
||||
|
||||
Reference in New Issue
Block a user