mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
feat: implement file download and preview features with improved URL handling (#315)
This commit is contained in:
committed by
GitHub
parent
91a5a24c8b
commit
59fccd9a93
@@ -1,3 +1,4 @@
|
|||||||
|
import * as fs from "fs";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { FastifyReply, FastifyRequest } from "fastify";
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
generateUniqueFileNameForRename,
|
generateUniqueFileNameForRename,
|
||||||
parseFileName,
|
parseFileName,
|
||||||
} from "../../utils/file-name-generator";
|
} from "../../utils/file-name-generator";
|
||||||
|
import { getContentType } from "../../utils/mime-types";
|
||||||
import { ConfigService } from "../config/service";
|
import { ConfigService } from "../config/service";
|
||||||
import {
|
import {
|
||||||
CheckFileInput,
|
CheckFileInput,
|
||||||
@@ -200,11 +202,10 @@ export class FileController {
|
|||||||
|
|
||||||
async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
|
async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { objectName: encodedObjectName } = request.params as {
|
const { objectName, password } = request.query as {
|
||||||
objectName: string;
|
objectName: string;
|
||||||
|
password?: string;
|
||||||
};
|
};
|
||||||
const objectName = decodeURIComponent(encodedObjectName);
|
|
||||||
const { password } = request.query as { password?: string };
|
|
||||||
|
|
||||||
if (!objectName) {
|
if (!objectName) {
|
||||||
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
|
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
|
||||||
@@ -218,7 +219,8 @@ export class FileController {
|
|||||||
|
|
||||||
let hasAccess = false;
|
let hasAccess = false;
|
||||||
|
|
||||||
console.log("Requested file with password " + password);
|
// Don't log raw passwords. Log only whether a password was provided (for debugging access flow).
|
||||||
|
console.log(`Requested file access for object="${objectName}" passwordProvided=${password ? true : false}`);
|
||||||
|
|
||||||
const shares = await prisma.share.findMany({
|
const shares = await prisma.share.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -270,6 +272,118 @@ export class FileController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async downloadFile(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const { objectName, password } = request.query as {
|
||||||
|
objectName: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!objectName) {
|
||||||
|
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileRecord = await prisma.file.findFirst({ where: { objectName } });
|
||||||
|
|
||||||
|
if (!fileRecord) {
|
||||||
|
if (objectName.startsWith("reverse-shares/")) {
|
||||||
|
const reverseShareFile = await prisma.reverseShareFile.findFirst({
|
||||||
|
where: { objectName },
|
||||||
|
include: {
|
||||||
|
reverseShare: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reverseShareFile) {
|
||||||
|
return reply.status(404).send({ error: "File not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
const userId = (request as any).user?.userId;
|
||||||
|
|
||||||
|
if (!userId || reverseShareFile.reverseShare.creatorId !== userId) {
|
||||||
|
return reply.status(401).send({ error: "Unauthorized access to file." });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(401).send({ error: "Unauthorized access to file." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageProvider = (this.fileService as any).storageProvider;
|
||||||
|
const filePath = storageProvider.getFilePath(objectName);
|
||||||
|
|
||||||
|
const contentType = getContentType(reverseShareFile.name);
|
||||||
|
const fileName = reverseShareFile.name;
|
||||||
|
|
||||||
|
reply.header("Content-Type", contentType);
|
||||||
|
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(filePath);
|
||||||
|
return reply.send(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(404).send({ error: "File not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasAccess = false;
|
||||||
|
|
||||||
|
const shares = await prisma.share.findMany({
|
||||||
|
where: {
|
||||||
|
files: {
|
||||||
|
some: {
|
||||||
|
id: fileRecord.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
security: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const share of shares) {
|
||||||
|
if (!share.security.password) {
|
||||||
|
hasAccess = true;
|
||||||
|
break;
|
||||||
|
} else if (password) {
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, share.security.password);
|
||||||
|
if (isPasswordValid) {
|
||||||
|
hasAccess = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
const userId = (request as any).user?.userId;
|
||||||
|
if (userId && fileRecord.userId === userId) {
|
||||||
|
hasAccess = true;
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
return reply.status(401).send({ error: "Unauthorized access to file." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageProvider = (this.fileService as any).storageProvider;
|
||||||
|
const filePath = storageProvider.getFilePath(objectName);
|
||||||
|
|
||||||
|
const contentType = getContentType(fileRecord.name);
|
||||||
|
const fileName = fileRecord.name;
|
||||||
|
|
||||||
|
reply.header("Content-Type", contentType);
|
||||||
|
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(filePath);
|
||||||
|
return reply.send(stream);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in downloadFile:", error);
|
||||||
|
return reply.status(500).send({ error: "Internal server error." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async listFiles(request: FastifyRequest, reply: FastifyReply) {
|
async listFiles(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
|
@@ -106,17 +106,15 @@ export async function fileRoutes(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/files/:objectName/download",
|
"/files/download-url",
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ["File"],
|
tags: ["File"],
|
||||||
operationId: "getDownloadUrl",
|
operationId: "getDownloadUrl",
|
||||||
summary: "Get Download URL",
|
summary: "Get Download URL",
|
||||||
description: "Generates a pre-signed URL for downloading a file",
|
description: "Generates a pre-signed URL for downloading a file",
|
||||||
params: z.object({
|
|
||||||
objectName: z.string().min(1, "The objectName is required"),
|
|
||||||
}),
|
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
|
objectName: z.string().min(1, "The objectName is required"),
|
||||||
password: z.string().optional().describe("Share password if required"),
|
password: z.string().optional().describe("Share password if required"),
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
@@ -133,6 +131,23 @@ export async function fileRoutes(app: FastifyInstance) {
|
|||||||
fileController.getDownloadUrl.bind(fileController)
|
fileController.getDownloadUrl.bind(fileController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/files/download",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ["File"],
|
||||||
|
operationId: "downloadFile",
|
||||||
|
summary: "Download File",
|
||||||
|
description: "Downloads a file directly (returns file content)",
|
||||||
|
querystring: z.object({
|
||||||
|
objectName: z.string().min(1, "The objectName is required"),
|
||||||
|
password: z.string().optional().describe("Share password if required"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fileController.downloadFile.bind(fileController)
|
||||||
|
);
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/files",
|
"/files",
|
||||||
{
|
{
|
||||||
|
@@ -84,7 +84,6 @@ export class FilesystemController {
|
|||||||
const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
|
const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
|
||||||
|
|
||||||
if (result.isComplete) {
|
if (result.isComplete) {
|
||||||
provider.consumeUploadToken(token);
|
|
||||||
reply.status(200).send({
|
reply.status(200).send({
|
||||||
message: "File uploaded successfully",
|
message: "File uploaded successfully",
|
||||||
objectName: result.finalPath,
|
objectName: result.finalPath,
|
||||||
@@ -104,7 +103,6 @@ export class FilesystemController {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.uploadFileStream(request, provider, tokenData.objectName);
|
await this.uploadFileStream(request, provider, tokenData.objectName);
|
||||||
provider.consumeUploadToken(token);
|
|
||||||
reply.status(200).send({ message: "File uploaded successfully" });
|
reply.status(200).send({ message: "File uploaded successfully" });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -271,8 +269,6 @@ export class FilesystemController {
|
|||||||
reply.header("Content-Length", fileSize);
|
reply.header("Content-Length", fileSize);
|
||||||
await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId);
|
await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.consumeDownloadToken(token);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.memoryManager.endDownload(downloadId);
|
this.memoryManager.endDownload(downloadId);
|
||||||
}
|
}
|
||||||
|
@@ -192,13 +192,9 @@ export class FilesystemStorageProvider implements StorageProvider {
|
|||||||
return `/api/filesystem/upload/${token}`;
|
return `/api/filesystem/upload/${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
|
async getPresignedGetUrl(objectName: string): Promise<string> {
|
||||||
const token = crypto.randomBytes(32).toString("hex");
|
const encodedObjectName = encodeURIComponent(objectName);
|
||||||
const expiresAt = Date.now() + expires * 1000;
|
return `/api/files/download?objectName=${encodedObjectName}`;
|
||||||
|
|
||||||
this.downloadTokens.set(token, { objectName, expiresAt, fileName });
|
|
||||||
|
|
||||||
return `/api/filesystem/download/${token}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteObject(objectName: string): Promise<void> {
|
async deleteObject(objectName: string): Promise<void> {
|
||||||
@@ -636,13 +632,8 @@ export class FilesystemStorageProvider implements StorageProvider {
|
|||||||
return { objectName: data.objectName, fileName: data.fileName };
|
return { objectName: data.objectName, fileName: data.fileName };
|
||||||
}
|
}
|
||||||
|
|
||||||
consumeUploadToken(token: string): void {
|
// Tokens are automatically cleaned up by cleanExpiredTokens() every 5 minutes
|
||||||
this.uploadTokens.delete(token);
|
// No need to manually consume tokens - allows reuse for previews, range requests, etc.
|
||||||
}
|
|
||||||
|
|
||||||
consumeDownloadToken(token: string): void {
|
|
||||||
this.downloadTokens.delete(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async cleanupTempFile(tempPath: string): Promise<void> {
|
private async cleanupTempFile(tempPath: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "معاينة الملف",
|
"title": "معاينة الملف",
|
||||||
|
"description": "معاينة وتنزيل الملف",
|
||||||
"loading": "جاري التحميل...",
|
"loading": "جاري التحميل...",
|
||||||
"notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.",
|
"notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.",
|
||||||
"downloadToView": "استخدم زر التحميل لتنزيل الملف.",
|
"downloadToView": "استخدم زر التحميل لتنزيل الملف.",
|
||||||
@@ -1933,4 +1934,4 @@
|
|||||||
"nameRequired": "الاسم مطلوب",
|
"nameRequired": "الاسم مطلوب",
|
||||||
"required": "هذا الحقل مطلوب"
|
"required": "هذا الحقل مطلوب"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "Datei-Vorschau",
|
"title": "Datei-Vorschau",
|
||||||
|
"description": "Vorschau und Download der Datei",
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
"notAvailable": "Vorschau für diesen Dateityp nicht verfügbar.",
|
"notAvailable": "Vorschau für diesen Dateityp nicht verfügbar.",
|
||||||
"downloadToView": "Verwenden Sie die Download-Schaltfläche, um die Datei herunterzuladen.",
|
"downloadToView": "Verwenden Sie die Download-Schaltfläche, um die Datei herunterzuladen.",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "Name ist erforderlich",
|
"nameRequired": "Name ist erforderlich",
|
||||||
"required": "Dieses Feld ist erforderlich"
|
"required": "Dieses Feld ist erforderlich"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "Preview File",
|
"title": "Preview File",
|
||||||
|
"description": "Preview and download file",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"notAvailable": "Preview not available for this file type",
|
"notAvailable": "Preview not available for this file type",
|
||||||
"downloadToView": "Use the download button to view this file",
|
"downloadToView": "Use the download button to view this file",
|
||||||
@@ -1896,4 +1897,4 @@
|
|||||||
"nameRequired": "Name is required",
|
"nameRequired": "Name is required",
|
||||||
"required": "This field is required"
|
"required": "This field is required"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "Vista Previa del Archivo",
|
"title": "Vista Previa del Archivo",
|
||||||
|
"description": "Vista previa y descarga de archivo",
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
"notAvailable": "Vista previa no disponible para este tipo de archivo.",
|
"notAvailable": "Vista previa no disponible para este tipo de archivo.",
|
||||||
"downloadToView": "Use el botón de descarga para descargar el archivo.",
|
"downloadToView": "Use el botón de descarga para descargar el archivo.",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "El nombre es obligatorio",
|
"nameRequired": "El nombre es obligatorio",
|
||||||
"required": "Este campo es obligatorio"
|
"required": "Este campo es obligatorio"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "Aperçu du Fichier",
|
"title": "Aperçu du Fichier",
|
||||||
|
"description": "Aperçu et téléchargement du fichier",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"notAvailable": "Aperçu non disponible pour ce type de fichier.",
|
"notAvailable": "Aperçu non disponible pour ce type de fichier.",
|
||||||
"downloadToView": "Utilisez le bouton de téléchargement pour télécharger le fichier.",
|
"downloadToView": "Utilisez le bouton de téléchargement pour télécharger le fichier.",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "Nome é obrigatório",
|
"nameRequired": "Nome é obrigatório",
|
||||||
"required": "Este campo é obrigatório"
|
"required": "Este campo é obrigatório"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "फ़ाइल पूर्वावलोकन",
|
"title": "फ़ाइल पूर्वावलोकन",
|
||||||
|
"description": "फ़ाइल पूर्वावलोकन और डाउनलोड",
|
||||||
"loading": "लोड हो रहा है...",
|
"loading": "लोड हो रहा है...",
|
||||||
"notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।",
|
"notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।",
|
||||||
"downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।",
|
"downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "नाम आवश्यक है",
|
"nameRequired": "नाम आवश्यक है",
|
||||||
"required": "यह फ़ील्ड आवश्यक है"
|
"required": "यह फ़ील्ड आवश्यक है"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "Anteprima File",
|
"title": "Anteprima File",
|
||||||
|
"description": "Anteprima e download del file",
|
||||||
"loading": "Caricamento...",
|
"loading": "Caricamento...",
|
||||||
"notAvailable": "Anteprima non disponibile per questo tipo di file.",
|
"notAvailable": "Anteprima non disponibile per questo tipo di file.",
|
||||||
"downloadToView": "Utilizzare il pulsante di download per scaricare il file.",
|
"downloadToView": "Utilizzare il pulsante di download per scaricare il file.",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "Il nome è obbligatorio",
|
"nameRequired": "Il nome è obbligatorio",
|
||||||
"required": "Questo campo è obbligatorio"
|
"required": "Questo campo è obbligatorio"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "ファイルプレビュー",
|
"title": "ファイルプレビュー",
|
||||||
|
"description": "ファイルをプレビューしてダウンロード",
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
"notAvailable": "このファイルタイプのプレビューは利用できません。",
|
"notAvailable": "このファイルタイプのプレビューは利用できません。",
|
||||||
"downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。",
|
"downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "名前は必須です",
|
"nameRequired": "名前は必須です",
|
||||||
"required": "このフィールドは必須です"
|
"required": "このフィールドは必須です"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "파일 미리보기",
|
"title": "파일 미리보기",
|
||||||
|
"description": "파일 미리보기 및 다운로드",
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
"notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.",
|
"notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.",
|
||||||
"downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.",
|
"downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "이름은 필수입니다",
|
"nameRequired": "이름은 필수입니다",
|
||||||
"required": "이 필드는 필수입니다"
|
"required": "이 필드는 필수입니다"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "Bestandsvoorbeeld",
|
"title": "Bestandsvoorbeeld",
|
||||||
|
"description": "Bestand bekijken en downloaden",
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
"notAvailable": "Voorbeeld niet beschikbaar voor dit bestandstype.",
|
"notAvailable": "Voorbeeld niet beschikbaar voor dit bestandstype.",
|
||||||
"downloadToView": "Gebruik de downloadknop om het bestand te downloaden.",
|
"downloadToView": "Gebruik de downloadknop om het bestand te downloaden.",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "Naam is verplicht",
|
"nameRequired": "Naam is verplicht",
|
||||||
"required": "Dit veld is verplicht"
|
"required": "Dit veld is verplicht"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "Podgląd pliku",
|
"title": "Podgląd pliku",
|
||||||
|
"description": "Podgląd i pobieranie pliku",
|
||||||
"loading": "Ładowanie...",
|
"loading": "Ładowanie...",
|
||||||
"notAvailable": "Podgląd niedostępny dla tego typu pliku",
|
"notAvailable": "Podgląd niedostępny dla tego typu pliku",
|
||||||
"downloadToView": "Użyj przycisku pobierania, aby wyświetlić ten plik",
|
"downloadToView": "Użyj przycisku pobierania, aby wyświetlić ten plik",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "Nazwa jest wymagana",
|
"nameRequired": "Nazwa jest wymagana",
|
||||||
"required": "To pole jest wymagane"
|
"required": "To pole jest wymagane"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "Visualizar Arquivo",
|
"title": "Visualizar Arquivo",
|
||||||
|
"description": "Visualizar e baixar arquivo",
|
||||||
"loading": "Carregando...",
|
"loading": "Carregando...",
|
||||||
"notAvailable": "Preview não disponível para este tipo de arquivo.",
|
"notAvailable": "Preview não disponível para este tipo de arquivo.",
|
||||||
"downloadToView": "Use o botão de download para baixar o arquivo.",
|
"downloadToView": "Use o botão de download para baixar o arquivo.",
|
||||||
@@ -1932,4 +1933,4 @@
|
|||||||
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
|
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
|
||||||
"usernameSpaces": "O nome de usuário não pode conter espaços"
|
"usernameSpaces": "O nome de usuário não pode conter espaços"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "Предварительный просмотр файла",
|
"title": "Предварительный просмотр файла",
|
||||||
|
"description": "Просмотр и загрузка файла",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
"notAvailable": "Предварительный просмотр недоступен для этого типа файла.",
|
"notAvailable": "Предварительный просмотр недоступен для этого типа файла.",
|
||||||
"downloadToView": "Используйте кнопку загрузки для скачивания файла.",
|
"downloadToView": "Используйте кнопку загрузки для скачивания файла.",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "Требуется имя",
|
"nameRequired": "Требуется имя",
|
||||||
"required": "Это поле обязательно"
|
"required": "Это поле обязательно"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "Dosya Önizleme",
|
"title": "Dosya Önizleme",
|
||||||
|
"description": "Dosyayı önizleyin ve indirin",
|
||||||
"loading": "Yükleniyor...",
|
"loading": "Yükleniyor...",
|
||||||
"notAvailable": "Bu dosya türü için önizleme mevcut değil.",
|
"notAvailable": "Bu dosya türü için önizleme mevcut değil.",
|
||||||
"downloadToView": "Dosyayı indirmek için indirme düğmesini kullanın.",
|
"downloadToView": "Dosyayı indirmek için indirme düğmesini kullanın.",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "İsim gereklidir",
|
"nameRequired": "İsim gereklidir",
|
||||||
"required": "Bu alan zorunludur"
|
"required": "Bu alan zorunludur"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -302,6 +302,7 @@
|
|||||||
},
|
},
|
||||||
"filePreview": {
|
"filePreview": {
|
||||||
"title": "文件预览",
|
"title": "文件预览",
|
||||||
|
"description": "预览和下载文件",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"notAvailable": "此文件类型不支持预览。",
|
"notAvailable": "此文件类型不支持预览。",
|
||||||
"downloadToView": "使用下载按钮下载文件。",
|
"downloadToView": "使用下载按钮下载文件。",
|
||||||
@@ -1931,4 +1932,4 @@
|
|||||||
"nameRequired": "名称为必填项",
|
"nameRequired": "名称为必填项",
|
||||||
"required": "此字段为必填项"
|
"required": "此字段为必填项"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -900,16 +900,7 @@ export function ReceivedFilesModal({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{previewFile && (
|
{previewFile && (
|
||||||
<ReverseShareFilePreviewModal
|
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
|
||||||
isOpen={!!previewFile}
|
|
||||||
onClose={() => setPreviewFile(null)}
|
|
||||||
file={{
|
|
||||||
id: previewFile.id,
|
|
||||||
name: previewFile.name,
|
|
||||||
objectName: previewFile.objectName,
|
|
||||||
extension: previewFile.extension,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@@ -7,23 +7,11 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { deleteReverseShareFile } from "@/http/endpoints/reverse-shares";
|
import { deleteReverseShareFile } from "@/http/endpoints/reverse-shares";
|
||||||
|
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
|
||||||
import { downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
|
import { downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
|
||||||
import { getFileIcon } from "@/utils/file-icons";
|
import { getFileIcon } from "@/utils/file-icons";
|
||||||
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
|
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
|
||||||
|
|
||||||
interface ReverseShareFile {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
extension: string;
|
|
||||||
size: string;
|
|
||||||
objectName: string;
|
|
||||||
uploaderEmail: string | null;
|
|
||||||
uploaderName: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReceivedFilesSectionProps {
|
interface ReceivedFilesSectionProps {
|
||||||
files: ReverseShareFile[];
|
files: ReverseShareFile[];
|
||||||
onFileDeleted?: () => void;
|
onFileDeleted?: () => void;
|
||||||
@@ -159,16 +147,7 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{previewFile && (
|
{previewFile && (
|
||||||
<ReverseShareFilePreviewModal
|
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
|
||||||
isOpen={!!previewFile}
|
|
||||||
onClose={() => setPreviewFile(null)}
|
|
||||||
file={{
|
|
||||||
id: previewFile.id,
|
|
||||||
name: previewFile.name,
|
|
||||||
objectName: previewFile.objectName,
|
|
||||||
extension: previewFile.extension,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@@ -1,26 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
|
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
|
||||||
|
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
|
||||||
|
|
||||||
interface ReverseShareFilePreviewModalProps {
|
interface ReverseShareFilePreviewModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
file: {
|
file: ReverseShareFile | null;
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
objectName: string;
|
|
||||||
extension?: string;
|
|
||||||
} | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) {
|
export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) {
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
|
|
||||||
const adaptedFile = {
|
const adaptedFile = {
|
||||||
name: file.name,
|
...file,
|
||||||
objectName: file.objectName,
|
description: file.description ?? undefined,
|
||||||
type: file.extension,
|
|
||||||
id: file.id,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return <FilePreviewModal isOpen={isOpen} onClose={onClose} file={adaptedFile} isReverseShare={true} />;
|
return <FilePreviewModal isOpen={isOpen} onClose={onClose} file={adaptedFile} isReverseShare={true} />;
|
||||||
|
@@ -30,7 +30,7 @@ export function ReverseSharesSearch({
|
|||||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||||
<h2 className="text-xl font-semibold">{t("reverseShares.search.title")}</h2>
|
<h2 className="text-xl font-semibold">{t("reverseShares.search.title")}</h2>
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||||
<Button variant="outline" size="icon" onClick={onRefresh} disabled={isRefreshing} className="sm:w-auto">
|
<Button variant="outline" size="icon" onClick={onRefresh} disabled={isRefreshing}>
|
||||||
<IconRefresh className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
<IconRefresh className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onCreateReverseShare} className="w-full sm:w-auto">
|
<Button onClick={onCreateReverseShare} className="w-full sm:w-auto">
|
||||||
|
38
apps/web/src/app/api/(proxy)/files/download-url/route.ts
Normal file
38
apps/web/src/app/api/(proxy)/files/download-url/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const cookieHeader = req.headers.get("cookie");
|
||||||
|
const searchParams = req.nextUrl.searchParams;
|
||||||
|
const objectName = searchParams.get("objectName");
|
||||||
|
|
||||||
|
if (!objectName) {
|
||||||
|
return new NextResponse(JSON.stringify({ error: "objectName parameter is required" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward all query params to backend
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${API_BASE_URL}/files/download-url?${queryString}`;
|
||||||
|
|
||||||
|
const apiRes = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
cookie: cookieHeader || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await apiRes.json();
|
||||||
|
|
||||||
|
return new NextResponse(JSON.stringify(data), {
|
||||||
|
status: apiRes.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
@@ -4,13 +4,22 @@ import { detectMimeTypeWithFallback } from "@/utils/mime-types";
|
|||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ objectPath: string[] }> }) {
|
export async function GET(req: NextRequest) {
|
||||||
const { objectPath } = await params;
|
|
||||||
const cookieHeader = req.headers.get("cookie");
|
const cookieHeader = req.headers.get("cookie");
|
||||||
const objectName = objectPath.join("/");
|
|
||||||
const searchParams = req.nextUrl.searchParams;
|
const searchParams = req.nextUrl.searchParams;
|
||||||
|
const objectName = searchParams.get("objectName");
|
||||||
|
|
||||||
|
if (!objectName) {
|
||||||
|
return new NextResponse(JSON.stringify({ error: "objectName parameter is required" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const queryString = searchParams.toString();
|
const queryString = searchParams.toString();
|
||||||
const url = `${API_BASE_URL}/files/${encodeURIComponent(objectName)}/download${queryString ? `?${queryString}` : ""}`;
|
const url = `${API_BASE_URL}/files/download?${queryString}`;
|
||||||
|
|
||||||
const apiRes = await fetch(url, {
|
const apiRes = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
@@ -4,7 +4,14 @@ import { IconDownload } from "@tabler/icons-react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { useFilePreview } from "@/hooks/use-file-preview";
|
import { useFilePreview } from "@/hooks/use-file-preview";
|
||||||
import { getFileIcon } from "@/utils/file-icons";
|
import { getFileIcon } from "@/utils/file-icons";
|
||||||
import { FilePreviewRenderer } from "./previews";
|
import { FilePreviewRenderer } from "./previews";
|
||||||
@@ -44,6 +51,7 @@ export function FilePreviewModal({
|
|||||||
})()}
|
})()}
|
||||||
<span className="truncate">{file.name}</span>
|
<span className="truncate">{file.name}</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">{t("filePreview.description")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<FilePreviewRenderer
|
<FilePreviewRenderer
|
||||||
|
@@ -163,8 +163,7 @@ export function FilesGrid({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
loadingUrls.current.add(file.objectName);
|
loadingUrls.current.add(file.objectName);
|
||||||
const encodedObjectName = encodeURIComponent(file.objectName);
|
const response = await getDownloadUrl(file.objectName);
|
||||||
const response = await getDownloadUrl(encodedObjectName);
|
|
||||||
|
|
||||||
if (!componentMounted.current) break;
|
if (!componentMounted.current) break;
|
||||||
|
|
||||||
|
@@ -187,8 +187,7 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
|
|||||||
|
|
||||||
let url = downloadUrl;
|
let url = downloadUrl;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
const encodedObjectName = encodeURIComponent(objectName);
|
const response = await getDownloadUrl(objectName);
|
||||||
const response = await getDownloadUrl(encodedObjectName);
|
|
||||||
url = response.data.url;
|
url = response.data.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -181,12 +181,11 @@ export function useFilePreview({ file, isOpen, isReverseShare = false, sharePass
|
|||||||
const response = await downloadReverseShareFile(file.id!);
|
const response = await downloadReverseShareFile(file.id!);
|
||||||
url = response.data.url;
|
url = response.data.url;
|
||||||
} else {
|
} else {
|
||||||
const encodedObjectName = encodeURIComponent(file.objectName);
|
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (sharePassword) params.password = sharePassword;
|
if (sharePassword) params.password = sharePassword;
|
||||||
|
|
||||||
const response = await getDownloadUrl(
|
const response = await getDownloadUrl(
|
||||||
encodedObjectName,
|
file.objectName,
|
||||||
Object.keys(params).length > 0
|
Object.keys(params).length > 0
|
||||||
? {
|
? {
|
||||||
params: { ...params },
|
params: { ...params },
|
||||||
|
@@ -80,7 +80,8 @@ export const getDownloadUrl = <TData = GetDownloadUrlResult>(
|
|||||||
objectName: string,
|
objectName: string,
|
||||||
options?: AxiosRequestConfig
|
options?: AxiosRequestConfig
|
||||||
): Promise<TData> => {
|
): Promise<TData> => {
|
||||||
return apiInstance.get(`/api/files/download/${objectName}`, options);
|
const encodedObjectName = encodeURIComponent(objectName);
|
||||||
|
return apiInstance.get(`/api/files/download-url?objectName=${encodedObjectName}`, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -21,8 +21,7 @@ async function waitForDownloadReady(objectName: string, fileName: string): Promi
|
|||||||
|
|
||||||
while (attempts < maxAttempts) {
|
while (attempts < maxAttempts) {
|
||||||
try {
|
try {
|
||||||
const encodedObjectName = encodeURIComponent(objectName);
|
const response = await getDownloadUrl(objectName);
|
||||||
const response = await getDownloadUrl(encodedObjectName);
|
|
||||||
|
|
||||||
if (response.status !== 202) {
|
if (response.status !== 202) {
|
||||||
return response.data.url;
|
return response.data.url;
|
||||||
@@ -98,13 +97,12 @@ export async function downloadFileWithQueue(
|
|||||||
options.onStart?.(downloadId);
|
options.onStart?.(downloadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const encodedObjectName = encodeURIComponent(objectName);
|
// getDownloadUrl already handles encoding
|
||||||
|
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (sharePassword) params.password = sharePassword;
|
if (sharePassword) params.password = sharePassword;
|
||||||
|
|
||||||
const response = await getDownloadUrl(
|
const response = await getDownloadUrl(
|
||||||
encodedObjectName,
|
objectName,
|
||||||
Object.keys(params).length > 0
|
Object.keys(params).length > 0
|
||||||
? {
|
? {
|
||||||
params: { ...params },
|
params: { ...params },
|
||||||
@@ -208,13 +206,12 @@ export async function downloadFileAsBlobWithQueue(
|
|||||||
downloadUrl = response.data.url;
|
downloadUrl = response.data.url;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const encodedObjectName = encodeURIComponent(objectName);
|
// getDownloadUrl already handles encoding
|
||||||
|
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (sharePassword) params.password = sharePassword;
|
if (sharePassword) params.password = sharePassword;
|
||||||
|
|
||||||
const response = await getDownloadUrl(
|
const response = await getDownloadUrl(
|
||||||
encodedObjectName,
|
objectName,
|
||||||
Object.keys(params).length > 0
|
Object.keys(params).length > 0
|
||||||
? {
|
? {
|
||||||
params: { ...params },
|
params: { ...params },
|
||||||
|
Reference in New Issue
Block a user