From 59fccd9a9377368359d00aac30bb3f59c18b6b8b Mon Sep 17 00:00:00 2001 From: Daniel Luiz Alves Date: Tue, 21 Oct 2025 10:00:13 -0300 Subject: [PATCH 1/4] feat: implement file download and preview features with improved URL handling (#315) --- apps/server/src/modules/file/controller.ts | 122 +++++++++++++++++- apps/server/src/modules/file/routes.ts | 23 +++- .../src/modules/filesystem/controller.ts | 4 - .../providers/filesystem-storage.provider.ts | 19 +-- apps/web/messages/ar-SA.json | 3 +- apps/web/messages/de-DE.json | 3 +- apps/web/messages/en-US.json | 3 +- apps/web/messages/es-ES.json | 3 +- apps/web/messages/fr-FR.json | 3 +- apps/web/messages/hi-IN.json | 3 +- apps/web/messages/it-IT.json | 3 +- apps/web/messages/ja-JP.json | 3 +- apps/web/messages/ko-KR.json | 3 +- apps/web/messages/nl-NL.json | 3 +- apps/web/messages/pl-PL.json | 3 +- apps/web/messages/pt-BR.json | 3 +- apps/web/messages/ru-RU.json | 3 +- apps/web/messages/tr-TR.json | 3 +- apps/web/messages/zh-CN.json | 3 +- .../components/received-files-modal.tsx | 11 +- .../components/received-files-section.tsx | 25 +--- .../reverse-share-file-preview-modal.tsx | 14 +- .../components/reverse-shares-search.tsx | 2 +- .../api/(proxy)/files/download-url/route.ts | 38 ++++++ .../download/{[...objectPath] => }/route.ts | 17 ++- .../components/modals/file-preview-modal.tsx | 10 +- apps/web/src/components/tables/files-grid.tsx | 3 +- .../src/hooks/use-enhanced-file-manager.ts | 3 +- apps/web/src/hooks/use-file-preview.ts | 3 +- apps/web/src/http/endpoints/files/index.ts | 3 +- apps/web/src/utils/download-queue-utils.ts | 13 +- 31 files changed, 250 insertions(+), 105 deletions(-) create mode 100644 apps/web/src/app/api/(proxy)/files/download-url/route.ts rename apps/web/src/app/api/(proxy)/files/download/{[...objectPath] => }/route.ts (81%) diff --git a/apps/server/src/modules/file/controller.ts b/apps/server/src/modules/file/controller.ts index 21c08ab..b3bad03 100644 --- a/apps/server/src/modules/file/controller.ts +++ b/apps/server/src/modules/file/controller.ts @@ -1,3 +1,4 @@ +import * as fs from "fs"; import bcrypt from "bcryptjs"; import { FastifyReply, FastifyRequest } from "fastify"; @@ -8,6 +9,7 @@ import { generateUniqueFileNameForRename, parseFileName, } from "../../utils/file-name-generator"; +import { getContentType } from "../../utils/mime-types"; import { ConfigService } from "../config/service"; import { CheckFileInput, @@ -200,11 +202,10 @@ export class FileController { async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) { try { - const { objectName: encodedObjectName } = request.params as { + const { objectName, password } = request.query as { objectName: string; + password?: string; }; - const objectName = decodeURIComponent(encodedObjectName); - const { password } = request.query as { password?: string }; if (!objectName) { return reply.status(400).send({ error: "The 'objectName' parameter is required." }); @@ -218,7 +219,8 @@ export class FileController { 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({ 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) { try { await request.jwtVerify(); diff --git a/apps/server/src/modules/file/routes.ts b/apps/server/src/modules/file/routes.ts index 659021b..e8c67a5 100644 --- a/apps/server/src/modules/file/routes.ts +++ b/apps/server/src/modules/file/routes.ts @@ -106,17 +106,15 @@ export async function fileRoutes(app: FastifyInstance) { ); app.get( - "/files/:objectName/download", + "/files/download-url", { schema: { tags: ["File"], operationId: "getDownloadUrl", summary: "Get Download URL", 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({ + objectName: z.string().min(1, "The objectName is required"), password: z.string().optional().describe("Share password if required"), }), response: { @@ -133,6 +131,23 @@ export async function fileRoutes(app: FastifyInstance) { 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( "/files", { diff --git a/apps/server/src/modules/filesystem/controller.ts b/apps/server/src/modules/filesystem/controller.ts index 4250283..e736877 100644 --- a/apps/server/src/modules/filesystem/controller.ts +++ b/apps/server/src/modules/filesystem/controller.ts @@ -84,7 +84,6 @@ export class FilesystemController { 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, @@ -104,7 +103,6 @@ export class FilesystemController { } } else { await this.uploadFileStream(request, provider, tokenData.objectName); - provider.consumeUploadToken(token); reply.status(200).send({ message: "File uploaded successfully" }); } } catch (error) { @@ -271,8 +269,6 @@ export class FilesystemController { reply.header("Content-Length", fileSize); await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId); } - - provider.consumeDownloadToken(token); } finally { this.memoryManager.endDownload(downloadId); } diff --git a/apps/server/src/providers/filesystem-storage.provider.ts b/apps/server/src/providers/filesystem-storage.provider.ts index 78e7ab1..d6a47d3 100644 --- a/apps/server/src/providers/filesystem-storage.provider.ts +++ b/apps/server/src/providers/filesystem-storage.provider.ts @@ -192,13 +192,9 @@ export class FilesystemStorageProvider implements StorageProvider { return `/api/filesystem/upload/${token}`; } - async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise { - const token = crypto.randomBytes(32).toString("hex"); - const expiresAt = Date.now() + expires * 1000; - - this.downloadTokens.set(token, { objectName, expiresAt, fileName }); - - return `/api/filesystem/download/${token}`; + async getPresignedGetUrl(objectName: string): Promise { + const encodedObjectName = encodeURIComponent(objectName); + return `/api/files/download?objectName=${encodedObjectName}`; } async deleteObject(objectName: string): Promise { @@ -636,13 +632,8 @@ export class FilesystemStorageProvider implements StorageProvider { return { objectName: data.objectName, fileName: data.fileName }; } - consumeUploadToken(token: string): void { - this.uploadTokens.delete(token); - } - - consumeDownloadToken(token: string): void { - this.downloadTokens.delete(token); - } + // Tokens are automatically cleaned up by cleanExpiredTokens() every 5 minutes + // No need to manually consume tokens - allows reuse for previews, range requests, etc. private async cleanupTempFile(tempPath: string): Promise { try { diff --git a/apps/web/messages/ar-SA.json b/apps/web/messages/ar-SA.json index f8d3934..ff1fdc0 100644 --- a/apps/web/messages/ar-SA.json +++ b/apps/web/messages/ar-SA.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "معاينة الملف", + "description": "معاينة وتنزيل الملف", "loading": "جاري التحميل...", "notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.", "downloadToView": "استخدم زر التحميل لتنزيل الملف.", @@ -1933,4 +1934,4 @@ "nameRequired": "الاسم مطلوب", "required": "هذا الحقل مطلوب" } -} +} \ No newline at end of file diff --git a/apps/web/messages/de-DE.json b/apps/web/messages/de-DE.json index c142241..ce541b8 100644 --- a/apps/web/messages/de-DE.json +++ b/apps/web/messages/de-DE.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "Datei-Vorschau", + "description": "Vorschau und Download der Datei", "loading": "Laden...", "notAvailable": "Vorschau für diesen Dateityp nicht verfügbar.", "downloadToView": "Verwenden Sie die Download-Schaltfläche, um die Datei herunterzuladen.", @@ -1931,4 +1932,4 @@ "nameRequired": "Name ist erforderlich", "required": "Dieses Feld ist erforderlich" } -} +} \ No newline at end of file diff --git a/apps/web/messages/en-US.json b/apps/web/messages/en-US.json index 469c1f8..6a5c37d 100644 --- a/apps/web/messages/en-US.json +++ b/apps/web/messages/en-US.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "Preview File", + "description": "Preview and download file", "loading": "Loading...", "notAvailable": "Preview not available for this file type", "downloadToView": "Use the download button to view this file", @@ -1896,4 +1897,4 @@ "nameRequired": "Name is required", "required": "This field is required" } -} +} \ No newline at end of file diff --git a/apps/web/messages/es-ES.json b/apps/web/messages/es-ES.json index 309abc5..56c076e 100644 --- a/apps/web/messages/es-ES.json +++ b/apps/web/messages/es-ES.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "Vista Previa del Archivo", + "description": "Vista previa y descarga de archivo", "loading": "Cargando...", "notAvailable": "Vista previa no disponible para este tipo de archivo.", "downloadToView": "Use el botón de descarga para descargar el archivo.", @@ -1931,4 +1932,4 @@ "nameRequired": "El nombre es obligatorio", "required": "Este campo es obligatorio" } -} +} \ No newline at end of file diff --git a/apps/web/messages/fr-FR.json b/apps/web/messages/fr-FR.json index 1c75b33..3ed504b 100644 --- a/apps/web/messages/fr-FR.json +++ b/apps/web/messages/fr-FR.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "Aperçu du Fichier", + "description": "Aperçu et téléchargement du fichier", "loading": "Chargement...", "notAvailable": "Aperçu non disponible pour ce type de fichier.", "downloadToView": "Utilisez le bouton de téléchargement pour télécharger le fichier.", @@ -1931,4 +1932,4 @@ "nameRequired": "Nome é obrigatório", "required": "Este campo é obrigatório" } -} +} \ No newline at end of file diff --git a/apps/web/messages/hi-IN.json b/apps/web/messages/hi-IN.json index 1631063..72cc2ba 100644 --- a/apps/web/messages/hi-IN.json +++ b/apps/web/messages/hi-IN.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "फ़ाइल पूर्वावलोकन", + "description": "फ़ाइल पूर्वावलोकन और डाउनलोड", "loading": "लोड हो रहा है...", "notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।", "downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।", @@ -1931,4 +1932,4 @@ "nameRequired": "नाम आवश्यक है", "required": "यह फ़ील्ड आवश्यक है" } -} +} \ No newline at end of file diff --git a/apps/web/messages/it-IT.json b/apps/web/messages/it-IT.json index 1c4a2a8..e8d327e 100644 --- a/apps/web/messages/it-IT.json +++ b/apps/web/messages/it-IT.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "Anteprima File", + "description": "Anteprima e download del file", "loading": "Caricamento...", "notAvailable": "Anteprima non disponibile per questo tipo di file.", "downloadToView": "Utilizzare il pulsante di download per scaricare il file.", @@ -1931,4 +1932,4 @@ "nameRequired": "Il nome è obbligatorio", "required": "Questo campo è obbligatorio" } -} +} \ No newline at end of file diff --git a/apps/web/messages/ja-JP.json b/apps/web/messages/ja-JP.json index cdcb190..5973b06 100644 --- a/apps/web/messages/ja-JP.json +++ b/apps/web/messages/ja-JP.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "ファイルプレビュー", + "description": "ファイルをプレビューしてダウンロード", "loading": "読み込み中...", "notAvailable": "このファイルタイプのプレビューは利用できません。", "downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。", @@ -1931,4 +1932,4 @@ "nameRequired": "名前は必須です", "required": "このフィールドは必須です" } -} +} \ No newline at end of file diff --git a/apps/web/messages/ko-KR.json b/apps/web/messages/ko-KR.json index bbc7283..0e377c9 100644 --- a/apps/web/messages/ko-KR.json +++ b/apps/web/messages/ko-KR.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "파일 미리보기", + "description": "파일 미리보기 및 다운로드", "loading": "로딩 중...", "notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.", "downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.", @@ -1931,4 +1932,4 @@ "nameRequired": "이름은 필수입니다", "required": "이 필드는 필수입니다" } -} +} \ No newline at end of file diff --git a/apps/web/messages/nl-NL.json b/apps/web/messages/nl-NL.json index 678576e..30f9102 100644 --- a/apps/web/messages/nl-NL.json +++ b/apps/web/messages/nl-NL.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "Bestandsvoorbeeld", + "description": "Bestand bekijken en downloaden", "loading": "Laden...", "notAvailable": "Voorbeeld niet beschikbaar voor dit bestandstype.", "downloadToView": "Gebruik de downloadknop om het bestand te downloaden.", @@ -1931,4 +1932,4 @@ "nameRequired": "Naam is verplicht", "required": "Dit veld is verplicht" } -} +} \ No newline at end of file diff --git a/apps/web/messages/pl-PL.json b/apps/web/messages/pl-PL.json index 910f793..1d16b5f 100644 --- a/apps/web/messages/pl-PL.json +++ b/apps/web/messages/pl-PL.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "Podgląd pliku", + "description": "Podgląd i pobieranie pliku", "loading": "Ładowanie...", "notAvailable": "Podgląd niedostępny dla tego typu pliku", "downloadToView": "Użyj przycisku pobierania, aby wyświetlić ten plik", @@ -1931,4 +1932,4 @@ "nameRequired": "Nazwa jest wymagana", "required": "To pole jest wymagane" } -} +} \ No newline at end of file diff --git a/apps/web/messages/pt-BR.json b/apps/web/messages/pt-BR.json index 3501a09..eadacc4 100644 --- a/apps/web/messages/pt-BR.json +++ b/apps/web/messages/pt-BR.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "Visualizar Arquivo", + "description": "Visualizar e baixar arquivo", "loading": "Carregando...", "notAvailable": "Preview não disponível para este tipo de 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", "usernameSpaces": "O nome de usuário não pode conter espaços" } -} +} \ No newline at end of file diff --git a/apps/web/messages/ru-RU.json b/apps/web/messages/ru-RU.json index 857caf2..307e14c 100644 --- a/apps/web/messages/ru-RU.json +++ b/apps/web/messages/ru-RU.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "Предварительный просмотр файла", + "description": "Просмотр и загрузка файла", "loading": "Загрузка...", "notAvailable": "Предварительный просмотр недоступен для этого типа файла.", "downloadToView": "Используйте кнопку загрузки для скачивания файла.", @@ -1931,4 +1932,4 @@ "nameRequired": "Требуется имя", "required": "Это поле обязательно" } -} +} \ No newline at end of file diff --git a/apps/web/messages/tr-TR.json b/apps/web/messages/tr-TR.json index 13256eb..d8cff94 100644 --- a/apps/web/messages/tr-TR.json +++ b/apps/web/messages/tr-TR.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "Dosya Önizleme", + "description": "Dosyayı önizleyin ve indirin", "loading": "Yükleniyor...", "notAvailable": "Bu dosya türü için önizleme mevcut değil.", "downloadToView": "Dosyayı indirmek için indirme düğmesini kullanın.", @@ -1931,4 +1932,4 @@ "nameRequired": "İsim gereklidir", "required": "Bu alan zorunludur" } -} +} \ No newline at end of file diff --git a/apps/web/messages/zh-CN.json b/apps/web/messages/zh-CN.json index 00cb2d8..1e44794 100644 --- a/apps/web/messages/zh-CN.json +++ b/apps/web/messages/zh-CN.json @@ -302,6 +302,7 @@ }, "filePreview": { "title": "文件预览", + "description": "预览和下载文件", "loading": "加载中...", "notAvailable": "此文件类型不支持预览。", "downloadToView": "使用下载按钮下载文件。", @@ -1931,4 +1932,4 @@ "nameRequired": "名称为必填项", "required": "此字段为必填项" } -} +} \ No newline at end of file diff --git a/apps/web/src/app/(shares)/reverse-shares/components/received-files-modal.tsx b/apps/web/src/app/(shares)/reverse-shares/components/received-files-modal.tsx index f7fbcc9..4eea8eb 100644 --- a/apps/web/src/app/(shares)/reverse-shares/components/received-files-modal.tsx +++ b/apps/web/src/app/(shares)/reverse-shares/components/received-files-modal.tsx @@ -900,16 +900,7 @@ export function ReceivedFilesModal({ {previewFile && ( - setPreviewFile(null)} - file={{ - id: previewFile.id, - name: previewFile.name, - objectName: previewFile.objectName, - extension: previewFile.extension, - }} - /> + setPreviewFile(null)} file={previewFile} /> )} ); diff --git a/apps/web/src/app/(shares)/reverse-shares/components/received-files-section.tsx b/apps/web/src/app/(shares)/reverse-shares/components/received-files-section.tsx index 990e45c..ad5d494 100644 --- a/apps/web/src/app/(shares)/reverse-shares/components/received-files-section.tsx +++ b/apps/web/src/app/(shares)/reverse-shares/components/received-files-section.tsx @@ -7,23 +7,11 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { deleteReverseShareFile } from "@/http/endpoints/reverse-shares"; +import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types"; import { downloadReverseShareWithQueue } from "@/utils/download-queue-utils"; import { getFileIcon } from "@/utils/file-icons"; 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 { files: ReverseShareFile[]; onFileDeleted?: () => void; @@ -159,16 +147,7 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect {previewFile && ( - setPreviewFile(null)} - file={{ - id: previewFile.id, - name: previewFile.name, - objectName: previewFile.objectName, - extension: previewFile.extension, - }} - /> + setPreviewFile(null)} file={previewFile} /> )} ); diff --git a/apps/web/src/app/(shares)/reverse-shares/components/reverse-share-file-preview-modal.tsx b/apps/web/src/app/(shares)/reverse-shares/components/reverse-share-file-preview-modal.tsx index 8f21cad..4439335 100644 --- a/apps/web/src/app/(shares)/reverse-shares/components/reverse-share-file-preview-modal.tsx +++ b/apps/web/src/app/(shares)/reverse-shares/components/reverse-share-file-preview-modal.tsx @@ -1,26 +1,20 @@ "use client"; import { FilePreviewModal } from "@/components/modals/file-preview-modal"; +import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types"; interface ReverseShareFilePreviewModalProps { isOpen: boolean; onClose: () => void; - file: { - id: string; - name: string; - objectName: string; - extension?: string; - } | null; + file: ReverseShareFile | null; } export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) { if (!file) return null; const adaptedFile = { - name: file.name, - objectName: file.objectName, - type: file.extension, - id: file.id, + ...file, + description: file.description ?? undefined, }; return ; diff --git a/apps/web/src/app/(shares)/reverse-shares/components/reverse-shares-search.tsx b/apps/web/src/app/(shares)/reverse-shares/components/reverse-shares-search.tsx index 69ce666..5c584aa 100644 --- a/apps/web/src/app/(shares)/reverse-shares/components/reverse-shares-search.tsx +++ b/apps/web/src/app/(shares)/reverse-shares/components/reverse-shares-search.tsx @@ -30,7 +30,7 @@ export function ReverseSharesSearch({

{t("reverseShares.search.title")}

- +
+

{t("embedCode.directLinkDescription")}

+ + + +
+ + +
+

{t("embedCode.htmlDescription")}

+
+ + +
+ + +
+

{t("embedCode.bbcodeDescription")}

+
+ +
+ + + ); +} diff --git a/apps/web/src/components/files/media-embed-link.tsx b/apps/web/src/components/files/media-embed-link.tsx new file mode 100644 index 0000000..d95c476 --- /dev/null +++ b/apps/web/src/components/files/media-embed-link.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; +import { useTranslations } from "next-intl"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; + +interface MediaEmbedLinkProps { + fileId: string; +} + +export function MediaEmbedLink({ fileId }: MediaEmbedLinkProps) { + const t = useTranslations(); + const [copied, setCopied] = useState(false); + const [embedUrl, setEmbedUrl] = useState(""); + + useEffect(() => { + if (typeof window !== "undefined") { + const origin = window.location.origin; + const url = `${origin}/e/${fileId}`; + setEmbedUrl(url); + } + }, [fileId]); + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(embedUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy:", error); + } + }; + + return ( + + +
+
+ +

{t("embedCode.directLinkDescription")}

+
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/modals/file-preview-modal.tsx b/apps/web/src/components/modals/file-preview-modal.tsx index b00e6f6..72bfdad 100644 --- a/apps/web/src/components/modals/file-preview-modal.tsx +++ b/apps/web/src/components/modals/file-preview-modal.tsx @@ -3,6 +3,8 @@ import { IconDownload } from "@tabler/icons-react"; import { useTranslations } from "next-intl"; +import { EmbedCodeDisplay } from "@/components/files/embed-code-display"; +import { MediaEmbedLink } from "@/components/files/media-embed-link"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -14,6 +16,7 @@ import { } from "@/components/ui/dialog"; import { useFilePreview } from "@/hooks/use-file-preview"; import { getFileIcon } from "@/utils/file-icons"; +import { getFileType } from "@/utils/file-types"; import { FilePreviewRenderer } from "./previews"; interface FilePreviewModalProps { @@ -39,6 +42,10 @@ export function FilePreviewModal({ }: FilePreviewModalProps) { const t = useTranslations(); const previewState = useFilePreview({ file, isOpen, isReverseShare, sharePassword }); + const fileType = getFileType(file.name); + const isImage = fileType === "image"; + const isVideo = fileType === "video"; + const isAudio = fileType === "audio"; return ( @@ -67,6 +74,16 @@ export function FilePreviewModal({ description={file.description} onDownload={previewState.handleDownload} /> + {isImage && previewState.previewUrl && !previewState.isLoading && file.id && ( +
+ +
+ )} + {(isVideo || isAudio) && !previewState.isLoading && file.id && ( +
+ +
+ )}