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:
Daniel Luiz Alves
2025-06-02 14:57:03 -03:00
parent 1a34236208
commit 5f4f8acbca
4 changed files with 130 additions and 27 deletions

View File

@@ -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);

View File

@@ -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 };
}

View File

@@ -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 });

View File

@@ -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);