feat: store file key instead of urls

This commit is contained in:
Marc Seitz
2026-02-16 14:36:08 +11:00
parent 519004a8b9
commit 30d9225e24
8 changed files with 212 additions and 47 deletions
+16 -31
View File
@@ -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);
}}
+83
View File
@@ -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 });
}
+1
View File
@@ -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;
+21 -9
View File
@@ -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 {
+14 -1
View File
@@ -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({