mirror of
https://github.com/kyantech/Palmr.git
synced 2025-11-02 04:53:26 +00:00
feat: enhance filename encoding for Content-Disposition header
- Implemented a new method to safely encode filenames for the Content-Disposition header in both FilesystemController and S3StorageProvider, improving file download handling. - Updated the upload and presigned URL generation processes to utilize the new encoding method, ensuring proper filename formatting and security. - Enhanced validation logic in FilesystemStorageProvider for download tokens to improve robustness.
This commit is contained in:
@@ -8,6 +8,50 @@ import { pipeline } from "stream/promises";
|
||||
export class FilesystemController {
|
||||
private fileService = new FileService();
|
||||
|
||||
/**
|
||||
* Safely encode filename for Content-Disposition header
|
||||
*/
|
||||
private encodeFilenameForHeader(filename: string): string {
|
||||
if (!filename || filename.trim() === "") {
|
||||
return 'attachment; filename="download"';
|
||||
}
|
||||
|
||||
let sanitized = filename
|
||||
.replace(/"/g, "'")
|
||||
.replace(/[\r\n\t\v\f]/g, "")
|
||||
.replace(/[\\|/]/g, "-")
|
||||
.replace(/[<>:|*?]/g, "");
|
||||
|
||||
sanitized = sanitized
|
||||
.split("")
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= 32 && !(code >= 127 && code <= 159);
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
|
||||
if (!sanitized) {
|
||||
return 'attachment; filename="download"';
|
||||
}
|
||||
|
||||
const asciiSafe = sanitized
|
||||
.split("")
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= 32 && code <= 126;
|
||||
})
|
||||
.join("");
|
||||
|
||||
if (asciiSafe && asciiSafe.trim()) {
|
||||
const encoded = encodeURIComponent(sanitized);
|
||||
return `attachment; filename="${asciiSafe}"; filename*=UTF-8''${encoded}`;
|
||||
} else {
|
||||
const encoded = encodeURIComponent(sanitized);
|
||||
return `attachment; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
}
|
||||
|
||||
async upload(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { token } = request.params as { token: string };
|
||||
@@ -100,6 +144,7 @@ export class FilesystemController {
|
||||
const provider = FilesystemStorageProvider.getInstance();
|
||||
|
||||
const tokenData = provider.validateDownloadToken(token);
|
||||
|
||||
if (!tokenData) {
|
||||
return reply.status(400).send({ error: "Invalid or expired download token" });
|
||||
}
|
||||
@@ -109,7 +154,7 @@ export class FilesystemController {
|
||||
const isLargeFile = stats.size > 50 * 1024 * 1024;
|
||||
|
||||
const fileName = tokenData.fileName || "download";
|
||||
reply.header("Content-Disposition", `attachment; filename="${fileName}"`);
|
||||
reply.header("Content-Disposition", this.encodeFilenameForHeader(fileName));
|
||||
reply.header("Content-Type", "application/octet-stream");
|
||||
reply.header("Content-Length", stats.size);
|
||||
|
||||
|
||||
@@ -261,10 +261,18 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
|
||||
validateDownloadToken(token: string): { objectName: string; fileName?: string } | null {
|
||||
const data = this.downloadTokens.get(token);
|
||||
if (!data || Date.now() > data.expiresAt) {
|
||||
if (data) this.downloadTokens.delete(token);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (now > data.expiresAt) {
|
||||
this.downloadTokens.delete(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { objectName: data.objectName, fileName: data.fileName };
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,50 @@ export class S3StorageProvider implements StorageProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely encode filename for Content-Disposition header
|
||||
*/
|
||||
private encodeFilenameForHeader(filename: string): string {
|
||||
if (!filename || filename.trim() === "") {
|
||||
return 'attachment; filename="download"';
|
||||
}
|
||||
|
||||
let sanitized = filename
|
||||
.replace(/"/g, "'")
|
||||
.replace(/[\r\n\t\v\f]/g, "")
|
||||
.replace(/[\\|/]/g, "-")
|
||||
.replace(/[<>:|*?]/g, "");
|
||||
|
||||
sanitized = sanitized
|
||||
.split("")
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= 32 && !(code >= 127 && code <= 159);
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
|
||||
if (!sanitized) {
|
||||
return 'attachment; filename="download"';
|
||||
}
|
||||
|
||||
const asciiSafe = sanitized
|
||||
.split("")
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= 32 && code <= 126;
|
||||
})
|
||||
.join("");
|
||||
|
||||
if (asciiSafe && asciiSafe.trim()) {
|
||||
const encoded = encodeURIComponent(sanitized);
|
||||
return `attachment; filename="${asciiSafe}"; filename*=UTF-8''${encoded}`;
|
||||
} else {
|
||||
const encoded = encodeURIComponent(sanitized);
|
||||
return `attachment; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
}
|
||||
|
||||
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
|
||||
if (!s3Client) {
|
||||
throw new Error("S3 client is not available");
|
||||
@@ -45,7 +89,7 @@ export class S3StorageProvider implements StorageProvider {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: objectName,
|
||||
ResponseContentDisposition: `attachment; filename="${rcdFileName}"`,
|
||||
ResponseContentDisposition: this.encodeFilenameForHeader(rcdFileName),
|
||||
});
|
||||
|
||||
return await getSignedUrl(s3Client, command, { expiresIn: expires });
|
||||
|
||||
@@ -31,9 +31,10 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
const [pdfAsBlob, setPdfAsBlob] = useState(false);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [pdfLoadFailed, setPdfLoadFailed] = useState(false);
|
||||
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && file.objectName) {
|
||||
if (isOpen && file.objectName && !isLoadingPreview) {
|
||||
setIsLoading(true);
|
||||
setPreviewUrl(null);
|
||||
setVideoBlob(null);
|
||||
@@ -46,18 +47,32 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) {
|
||||
if (previewUrl && previewUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
if (videoBlob) {
|
||||
if (videoBlob && videoBlob.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(videoBlob);
|
||||
}
|
||||
};
|
||||
}, [previewUrl, videoBlob]);
|
||||
|
||||
const loadPreview = async () => {
|
||||
if (!file.objectName) return;
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (previewUrl && previewUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
if (videoBlob && videoBlob.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(videoBlob);
|
||||
setVideoBlob(null);
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadPreview = async () => {
|
||||
if (!file.objectName || isLoadingPreview) return;
|
||||
|
||||
setIsLoadingPreview(true);
|
||||
try {
|
||||
const encodedObjectName = encodeURIComponent(file.objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
@@ -81,11 +96,11 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
toast.error(t("filePreview.loadError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadingPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadVideoPreview = async (url: string) => {
|
||||
console.log("Loading video as blob for streaming support");
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
@@ -95,7 +110,6 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setVideoBlob(blobUrl);
|
||||
console.log("Video blob loaded successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to load video as blob:", error);
|
||||
setPreviewUrl(url);
|
||||
@@ -103,7 +117,6 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
};
|
||||
|
||||
const loadAudioPreview = async (url: string) => {
|
||||
console.log("Loading audio as blob for streaming support");
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
@@ -113,7 +126,6 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setPreviewUrl(blobUrl);
|
||||
console.log("Audio blob loaded successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to load audio as blob:", error);
|
||||
setPreviewUrl(url);
|
||||
@@ -121,7 +133,6 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
};
|
||||
|
||||
const loadPdfPreview = async (url: string) => {
|
||||
console.log("Loading PDF as blob to avoid auto-download");
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
@@ -133,13 +144,11 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
const blobUrl = URL.createObjectURL(finalBlob);
|
||||
setPreviewUrl(blobUrl);
|
||||
setPdfAsBlob(true);
|
||||
console.log("PDF blob loaded successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to load PDF as blob:", error);
|
||||
setPreviewUrl(url);
|
||||
setTimeout(() => {
|
||||
if (!pdfLoadFailed && !pdfAsBlob) {
|
||||
console.log("PDF load timeout, trying blob method...");
|
||||
handlePdfLoadError();
|
||||
}
|
||||
}, 4000);
|
||||
@@ -147,9 +156,8 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
};
|
||||
|
||||
const handlePdfLoadError = async () => {
|
||||
if (pdfLoadFailed || pdfAsBlob) return; // Evitar loops
|
||||
if (pdfLoadFailed || pdfAsBlob) return;
|
||||
|
||||
console.log("PDF load failed, trying blob method...");
|
||||
setPdfLoadFailed(true);
|
||||
|
||||
if (downloadUrl) {
|
||||
@@ -161,15 +169,15 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
console.log("Starting download process...");
|
||||
let downloadUrlToUse = downloadUrl;
|
||||
|
||||
if (!downloadUrlToUse) {
|
||||
const encodedObjectName = encodeURIComponent(file.objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
const freshUrl = response.data.url;
|
||||
downloadUrlToUse = response.data.url;
|
||||
}
|
||||
|
||||
console.log("Got fresh download URL:", freshUrl);
|
||||
|
||||
const fileResponse = await fetch(freshUrl);
|
||||
const fileResponse = await fetch(downloadUrlToUse);
|
||||
if (!fileResponse.ok) {
|
||||
throw new Error(`Download failed: ${fileResponse.status} - ${fileResponse.statusText}`);
|
||||
}
|
||||
@@ -185,8 +193,6 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log("Download completed successfully");
|
||||
} catch (error) {
|
||||
toast.error(t("filePreview.downloadError"));
|
||||
console.error("Download error:", error);
|
||||
|
||||
Reference in New Issue
Block a user