mirror of
https://github.com/hanzo-apps/dataroom.git
synced 2026-10-30 14:47:36 +00:00
Use RFC 5987 filename* parameter to include the UTF-8 encoded original filename alongside the ASCII-safe slug, so downloads show the proper CJK name in modern browsers. Also adds missing Content-Disposition to put-file-server and stream-file-server uploads. Co-authored-by: Hanzo Dev <[email protected]>
342 lines
8.6 KiB
TypeScript
342 lines
8.6 KiB
TypeScript
import { DocumentStorageType } from "@prisma/client";
|
|
import { upload } from "@vercel/blob/client";
|
|
import { match } from "ts-pattern";
|
|
|
|
import { newId } from "@/lib/id-helper";
|
|
import { getPagesCount } from "@/lib/utils/get-page-number-count";
|
|
import type {
|
|
MultipartCompleteRequest,
|
|
MultipartGetPartUrlsRequest,
|
|
MultipartInitiateRequest,
|
|
} from "@/lib/zod/schemas/multipart";
|
|
|
|
import { SUPPORTED_DOCUMENT_MIME_TYPES } from "../constants";
|
|
|
|
/**
|
|
* Uploads a file to the configured storage backend (S3 or Vercel).
|
|
*
|
|
* For S3 uploads:
|
|
* - Files larger than 10MB automatically use multipart upload with pre-signed URLs
|
|
* - Files are uploaded in 10MB chunks with parallel processing (batches of 3)
|
|
* - Provides better performance and reliability for large files
|
|
* - Falls back to single upload for smaller files or on multipart failure
|
|
*
|
|
* @param file - The file to upload
|
|
* @param teamId - The team ID for storage configuration
|
|
* @param docId - Optional document ID (generated if not provided)
|
|
* @returns Upload result with storage type, key/URL, page count, and file size
|
|
*/
|
|
export const putFile = async ({
|
|
file,
|
|
teamId,
|
|
docId,
|
|
}: {
|
|
file: File;
|
|
teamId: string;
|
|
docId?: string;
|
|
}): Promise<{
|
|
type: DocumentStorageType | null;
|
|
data: string | null;
|
|
numPages: number | undefined;
|
|
fileSize: number | undefined;
|
|
}> => {
|
|
const NEXT_PUBLIC_UPLOAD_TRANSPORT = process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT;
|
|
|
|
const { type, data, numPages, fileSize } = await match(
|
|
NEXT_PUBLIC_UPLOAD_TRANSPORT,
|
|
)
|
|
.with("s3", async () => putFileInS3({ file, teamId, docId }))
|
|
.with("vercel", async () => putFileInVercel(file))
|
|
.otherwise(() => {
|
|
return {
|
|
type: null,
|
|
data: null,
|
|
numPages: undefined,
|
|
fileSize: undefined,
|
|
};
|
|
});
|
|
|
|
return { type, data, numPages, fileSize };
|
|
};
|
|
|
|
const putFileInVercel = async (file: File) => {
|
|
const newBlob = await upload(file.name, file, {
|
|
access: "public",
|
|
handleUploadUrl: "/api/file/browser-upload",
|
|
});
|
|
|
|
let numPages: number = 1;
|
|
// get page count for pdf files
|
|
if (file.type === "application/pdf") {
|
|
const body = await file.arrayBuffer();
|
|
numPages = await getPagesCount(body);
|
|
}
|
|
|
|
return {
|
|
type: DocumentStorageType.VERCEL_BLOB,
|
|
data: newBlob.url,
|
|
numPages: numPages,
|
|
fileSize: file.size,
|
|
};
|
|
};
|
|
|
|
// Multipart upload threshold: 10MB
|
|
const MULTIPART_THRESHOLD = 10 * 1024 * 1024;
|
|
const PART_SIZE = 10 * 1024 * 1024; // 10MB chunks
|
|
|
|
const putFileInS3 = async ({
|
|
file,
|
|
teamId,
|
|
docId,
|
|
}: {
|
|
file: File;
|
|
teamId: string;
|
|
docId?: string;
|
|
}) => {
|
|
if (!docId) {
|
|
docId = newId("doc");
|
|
}
|
|
|
|
if (
|
|
!SUPPORTED_DOCUMENT_MIME_TYPES.includes(file.type) &&
|
|
!file.name.endsWith(".dwg") &&
|
|
!file.name.endsWith(".dxf") &&
|
|
!file.name.endsWith(".xlsm")
|
|
) {
|
|
throw new Error(
|
|
"Only PDF, Powerpoint, Word, and Excel, ZIP files are supported",
|
|
);
|
|
}
|
|
|
|
// Check if file should use multipart upload
|
|
if (file.size > MULTIPART_THRESHOLD) {
|
|
return await putFileMultipart({ file, teamId, docId });
|
|
} else {
|
|
return await putFileSingle({ file, teamId, docId });
|
|
}
|
|
};
|
|
|
|
// Single file upload (existing logic)
|
|
const putFileSingle = async ({
|
|
file,
|
|
teamId,
|
|
docId,
|
|
}: {
|
|
file: File;
|
|
teamId: string;
|
|
docId: string;
|
|
}) => {
|
|
const presignedResponse = await fetch(
|
|
`${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/get-presigned-post-url`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
fileName: file.name,
|
|
contentType: file.type,
|
|
teamId: teamId,
|
|
docId: docId,
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!presignedResponse.ok) {
|
|
throw new Error(
|
|
`Failed to get presigned post url, failed with status code ${presignedResponse.status}`,
|
|
);
|
|
}
|
|
|
|
const { url, key, fileName, contentDisposition } =
|
|
(await presignedResponse.json()) as {
|
|
url: string;
|
|
key: string;
|
|
fileName: string;
|
|
contentDisposition: string;
|
|
};
|
|
|
|
const response = await fetch(url, {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": file.type,
|
|
"Content-Disposition": contentDisposition,
|
|
},
|
|
body: file,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to upload file "${file.name}", failed with status code ${response.status}`,
|
|
);
|
|
}
|
|
|
|
let numPages: number = 1;
|
|
// get page count for pdf files
|
|
if (file.type === "application/pdf") {
|
|
const body = await file.arrayBuffer();
|
|
numPages = await getPagesCount(body);
|
|
}
|
|
|
|
return {
|
|
type: DocumentStorageType.S3_PATH,
|
|
data: key,
|
|
numPages: numPages,
|
|
fileSize: file.size,
|
|
};
|
|
};
|
|
|
|
// Multipart file upload for large files
|
|
const putFileMultipart = async ({
|
|
file,
|
|
teamId,
|
|
docId,
|
|
}: {
|
|
file: File;
|
|
teamId: string;
|
|
docId: string;
|
|
}) => {
|
|
try {
|
|
// Step 1: Initiate multipart upload
|
|
const initiateResponse = await fetch(
|
|
`${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/multipart`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
action: "initiate",
|
|
fileName: file.name,
|
|
contentType: file.type,
|
|
teamId: teamId,
|
|
docId: docId,
|
|
} satisfies MultipartInitiateRequest),
|
|
},
|
|
);
|
|
|
|
if (!initiateResponse.ok) {
|
|
throw new Error(
|
|
`Failed to initiate multipart upload, status: ${initiateResponse.status}`,
|
|
);
|
|
}
|
|
|
|
const { uploadId, key, fileName } = await initiateResponse.json();
|
|
|
|
// Step 2: Get pre-signed URLs for parts
|
|
const partUrlsResponse = await fetch(
|
|
`${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/multipart`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
action: "get-part-urls",
|
|
fileName: file.name,
|
|
contentType: file.type,
|
|
teamId: teamId,
|
|
docId: docId,
|
|
uploadId: uploadId,
|
|
fileSize: file.size,
|
|
partSize: PART_SIZE,
|
|
} satisfies MultipartGetPartUrlsRequest),
|
|
},
|
|
);
|
|
|
|
if (!partUrlsResponse.ok) {
|
|
throw new Error(
|
|
`Failed to get part URLs, status: ${partUrlsResponse.status}`,
|
|
);
|
|
}
|
|
|
|
const { urls } = await partUrlsResponse.json();
|
|
|
|
// Step 3: Upload parts in parallel (batches of 3)
|
|
const uploadPart = async ({
|
|
partNumber,
|
|
url,
|
|
}: {
|
|
partNumber: number;
|
|
url: string;
|
|
}) => {
|
|
const start = (partNumber - 1) * PART_SIZE;
|
|
const end = Math.min(start + PART_SIZE, file.size);
|
|
const chunk = file.slice(start, end);
|
|
|
|
const response = await fetch(url, {
|
|
method: "PUT",
|
|
body: chunk,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to upload part ${partNumber}, status: ${response.status}`,
|
|
);
|
|
}
|
|
|
|
const etag = response.headers.get("ETag");
|
|
if (!etag) {
|
|
throw new Error(`Missing ETag in response for part ${partNumber}`);
|
|
}
|
|
|
|
return { PartNumber: partNumber, ETag: etag };
|
|
};
|
|
|
|
// Upload parts in batches to avoid overwhelming the connection
|
|
const batchSize = 5;
|
|
const parts: Array<{ PartNumber: number; ETag: string }> = [];
|
|
|
|
for (let i = 0; i < urls.length; i += batchSize) {
|
|
const batch = urls.slice(i, i + batchSize);
|
|
const batchResults = await Promise.all(batch.map(uploadPart));
|
|
parts.push(...batchResults);
|
|
}
|
|
|
|
// Step 4: Complete multipart upload
|
|
const completeResponse = await fetch(
|
|
`${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/multipart`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
action: "complete",
|
|
fileName: file.name,
|
|
contentType: file.type,
|
|
teamId: teamId,
|
|
docId: docId,
|
|
uploadId: uploadId,
|
|
parts: parts,
|
|
} satisfies MultipartCompleteRequest),
|
|
},
|
|
);
|
|
|
|
if (!completeResponse.ok) {
|
|
throw new Error(
|
|
`Failed to complete multipart upload, status: ${completeResponse.status}`,
|
|
);
|
|
}
|
|
|
|
let numPages: number = 1;
|
|
// get page count for pdf files
|
|
if (file.type === "application/pdf") {
|
|
const body = await file.arrayBuffer();
|
|
numPages = await getPagesCount(body);
|
|
}
|
|
|
|
return {
|
|
type: DocumentStorageType.S3_PATH,
|
|
data: key,
|
|
numPages: numPages,
|
|
fileSize: file.size,
|
|
};
|
|
} catch (error) {
|
|
console.error("Multipart upload failed:", error);
|
|
// Fallback to single upload on error
|
|
console.log("Falling back to single upload...");
|
|
return await putFileSingle({ file, teamId, docId });
|
|
}
|
|
};
|