mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
- Updated the FilesystemController to dynamically set the Content-Type header using the getContentType utility based on the file name. - Modified the S3StorageProvider to include the ResponseContentType in the presigned URL generation, improving the handling of file types in responses.
284 lines
9.6 KiB
TypeScript
284 lines
9.6 KiB
TypeScript
import * as fs from "fs";
|
|
import { pipeline } from "stream/promises";
|
|
import { FastifyReply, FastifyRequest } from "fastify";
|
|
|
|
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
|
|
import { ChunkManager, ChunkMetadata } from "./chunk-manager";
|
|
import { getContentType } from "../../utils/mime-types";
|
|
|
|
export class FilesystemController {
|
|
private chunkManager = ChunkManager.getInstance();
|
|
|
|
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 };
|
|
|
|
const provider = FilesystemStorageProvider.getInstance();
|
|
|
|
const tokenData = provider.validateUploadToken(token);
|
|
|
|
if (!tokenData) {
|
|
return reply.status(400).send({ error: "Invalid or expired upload token" });
|
|
}
|
|
|
|
const chunkMetadata = this.extractChunkMetadata(request);
|
|
|
|
if (chunkMetadata) {
|
|
try {
|
|
const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
|
|
|
|
if (result.isComplete) {
|
|
provider.consumeUploadToken(token);
|
|
reply.status(200).send({
|
|
message: "File uploaded successfully",
|
|
objectName: result.finalPath,
|
|
finalObjectName: result.finalPath,
|
|
});
|
|
} else {
|
|
reply.status(200).send({
|
|
message: "Chunk uploaded successfully",
|
|
progress: this.chunkManager.getUploadProgress(chunkMetadata.fileId),
|
|
});
|
|
}
|
|
} catch (chunkError: any) {
|
|
return reply.status(400).send({
|
|
error: chunkError.message || "Chunked upload failed",
|
|
details: chunkError.toString(),
|
|
});
|
|
}
|
|
} else {
|
|
await this.uploadFileStream(request, provider, tokenData.objectName);
|
|
provider.consumeUploadToken(token);
|
|
reply.status(200).send({ message: "File uploaded successfully" });
|
|
}
|
|
} catch (error) {
|
|
return reply.status(500).send({ error: "Internal server error" });
|
|
}
|
|
}
|
|
|
|
private async uploadFileStream(request: FastifyRequest, provider: FilesystemStorageProvider, objectName: string) {
|
|
await provider.uploadFileFromStream(objectName, request.raw);
|
|
}
|
|
|
|
private extractChunkMetadata(request: FastifyRequest): ChunkMetadata | null {
|
|
const fileId = request.headers["x-file-id"] as string;
|
|
const chunkIndex = request.headers["x-chunk-index"] as string;
|
|
const totalChunks = request.headers["x-total-chunks"] as string;
|
|
const chunkSize = request.headers["x-chunk-size"] as string;
|
|
const totalSize = request.headers["x-total-size"] as string;
|
|
const fileName = request.headers["x-file-name"] as string;
|
|
const isLastChunk = request.headers["x-is-last-chunk"] as string;
|
|
|
|
if (!fileId || !chunkIndex || !totalChunks || !chunkSize || !totalSize || !fileName) {
|
|
return null;
|
|
}
|
|
|
|
const metadata = {
|
|
fileId,
|
|
chunkIndex: parseInt(chunkIndex, 10),
|
|
totalChunks: parseInt(totalChunks, 10),
|
|
chunkSize: parseInt(chunkSize, 10),
|
|
totalSize: parseInt(totalSize, 10),
|
|
fileName,
|
|
isLastChunk: isLastChunk === "true",
|
|
};
|
|
|
|
return metadata;
|
|
}
|
|
|
|
private async handleChunkedUpload(request: FastifyRequest, metadata: ChunkMetadata, originalObjectName: string) {
|
|
const stream = request.raw;
|
|
|
|
stream.on("error", (error) => {
|
|
console.error("Request stream error:", error);
|
|
});
|
|
|
|
return await this.chunkManager.processChunk(metadata, stream, originalObjectName);
|
|
}
|
|
|
|
async getUploadProgress(request: FastifyRequest, reply: FastifyReply) {
|
|
try {
|
|
const { fileId } = request.params as { fileId: string };
|
|
|
|
const progress = this.chunkManager.getUploadProgress(fileId);
|
|
|
|
if (!progress) {
|
|
return reply.status(404).send({ error: "Upload not found" });
|
|
}
|
|
|
|
reply.status(200).send(progress);
|
|
} catch (error) {
|
|
return reply.status(500).send({ error: "Internal server error" });
|
|
}
|
|
}
|
|
|
|
async cancelUpload(request: FastifyRequest, reply: FastifyReply) {
|
|
try {
|
|
const { fileId } = request.params as { fileId: string };
|
|
|
|
await this.chunkManager.cancelUpload(fileId);
|
|
|
|
reply.status(200).send({ message: "Upload cancelled successfully" });
|
|
} catch (error) {
|
|
return reply.status(500).send({ error: "Internal server error" });
|
|
}
|
|
}
|
|
|
|
async download(request: FastifyRequest, reply: FastifyReply) {
|
|
try {
|
|
const { token } = request.params as { token: string };
|
|
|
|
const provider = FilesystemStorageProvider.getInstance();
|
|
|
|
const tokenData = provider.validateDownloadToken(token);
|
|
|
|
if (!tokenData) {
|
|
return reply.status(400).send({ error: "Invalid or expired download token" });
|
|
}
|
|
|
|
const filePath = provider.getFilePath(tokenData.objectName);
|
|
const stats = await fs.promises.stat(filePath);
|
|
const fileSize = stats.size;
|
|
|
|
const fileName = tokenData.fileName || "download";
|
|
const range = request.headers.range;
|
|
|
|
reply.header("Content-Disposition", this.encodeFilenameForHeader(fileName));
|
|
reply.header("Content-Type", getContentType(fileName));
|
|
reply.header("Accept-Ranges", "bytes");
|
|
|
|
if (range) {
|
|
const parts = range.replace(/bytes=/, "").split("-");
|
|
const start = parseInt(parts[0], 10);
|
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
|
|
|
reply.status(206);
|
|
reply.header("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
|
reply.header("Content-Length", end - start + 1);
|
|
|
|
await this.downloadFileRange(reply, provider, tokenData.objectName, start, end);
|
|
} else {
|
|
reply.header("Content-Length", fileSize);
|
|
await this.downloadFileStream(reply, provider, tokenData.objectName);
|
|
}
|
|
|
|
provider.consumeDownloadToken(token);
|
|
} catch (error) {
|
|
return reply.status(500).send({ error: "Internal server error" });
|
|
}
|
|
}
|
|
|
|
private async downloadFileStream(reply: FastifyReply, provider: FilesystemStorageProvider, objectName: string) {
|
|
try {
|
|
FilesystemStorageProvider.logMemoryUsage(`Download start: ${objectName}`);
|
|
|
|
const downloadStream = provider.createDownloadStream(objectName);
|
|
|
|
downloadStream.on("error", (error) => {
|
|
console.error("Download stream error:", error);
|
|
FilesystemStorageProvider.logMemoryUsage(`Download error: ${objectName}`);
|
|
if (!reply.sent) {
|
|
reply.status(500).send({ error: "Download failed" });
|
|
}
|
|
});
|
|
|
|
reply.raw.on("close", () => {
|
|
if (downloadStream.readable && typeof (downloadStream as any).destroy === "function") {
|
|
(downloadStream as any).destroy();
|
|
}
|
|
FilesystemStorageProvider.logMemoryUsage(`Download client disconnect: ${objectName}`);
|
|
});
|
|
|
|
await pipeline(downloadStream, reply.raw);
|
|
|
|
FilesystemStorageProvider.logMemoryUsage(`Download complete: ${objectName}`);
|
|
} catch (error) {
|
|
console.error("Download error:", error);
|
|
FilesystemStorageProvider.logMemoryUsage(`Download failed: ${objectName}`);
|
|
if (!reply.sent) {
|
|
reply.status(500).send({ error: "Download failed" });
|
|
}
|
|
}
|
|
}
|
|
|
|
private async downloadFileRange(
|
|
reply: FastifyReply,
|
|
provider: FilesystemStorageProvider,
|
|
objectName: string,
|
|
start: number,
|
|
end: number
|
|
) {
|
|
try {
|
|
FilesystemStorageProvider.logMemoryUsage(`Range download start: ${objectName} (${start}-${end})`);
|
|
|
|
const rangeStream = await provider.createDownloadRangeStream(objectName, start, end);
|
|
|
|
rangeStream.on("error", (error) => {
|
|
console.error("Range download stream error:", error);
|
|
FilesystemStorageProvider.logMemoryUsage(`Range download error: ${objectName} (${start}-${end})`);
|
|
if (!reply.sent) {
|
|
reply.status(500).send({ error: "Download failed" });
|
|
}
|
|
});
|
|
|
|
reply.raw.on("close", () => {
|
|
if (rangeStream.readable && typeof (rangeStream as any).destroy === "function") {
|
|
(rangeStream as any).destroy();
|
|
}
|
|
FilesystemStorageProvider.logMemoryUsage(`Range download client disconnect: ${objectName} (${start}-${end})`);
|
|
});
|
|
|
|
await pipeline(rangeStream, reply.raw);
|
|
|
|
FilesystemStorageProvider.logMemoryUsage(`Range download complete: ${objectName} (${start}-${end})`);
|
|
} catch (error) {
|
|
console.error("Range download error:", error);
|
|
FilesystemStorageProvider.logMemoryUsage(`Range download failed: ${objectName} (${start}-${end})`);
|
|
if (!reply.sent) {
|
|
reply.status(500).send({ error: "Download failed" });
|
|
}
|
|
}
|
|
}
|
|
}
|