Compare commits

...

4 Commits

Author SHA1 Message Date
Daniel Luiz Alves
cd14c28be1 refactor: simplify Docker environment detection for file storage paths (#77) 2025-06-19 03:02:31 -03:00
Daniel Luiz Alves
3c084a6686 refactor: simplify Docker environment detection for file storage paths
- Replaced manual Docker detection logic with a utility constant for determining if the application is running in a container.
- Updated file storage paths in both server and filesystem storage provider to use the new constant for improved readability and maintainability.
2025-06-19 02:49:47 -03:00
Daniel Luiz Alves
6a1381684b refactor: replace FilePreviewModal with ShareFilePreviewModal (#76) 2025-06-19 02:01:07 -03:00
Daniel Luiz Alves
dc20770fe6 refactor: replace FilePreviewModal with ShareFilePreviewModal in files table component
- Updated the files table component to use ShareFilePreviewModal for file previews.
- Removed the unused import of FilePreviewModal and added the new import for ShareFilePreviewModal.
2025-06-19 01:46:50 -03:00
5 changed files with 383 additions and 48 deletions

View File

@@ -1,5 +1,6 @@
import { env } from "../env";
import { StorageProvider } from "../types/storage";
import { IS_RUNNING_IN_CONTAINER } from "../utils/container-detection";
import * as crypto from "crypto";
import * as fsSync from "fs";
import * as fs from "fs/promises";
@@ -15,25 +16,12 @@ export class FilesystemStorageProvider implements StorageProvider {
private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
private constructor() {
this.uploadsDir = this.isDocker() ? "/app/server/uploads" : path.join(process.cwd(), "uploads");
this.uploadsDir = IS_RUNNING_IN_CONTAINER ? "/app/server/uploads" : path.join(process.cwd(), "uploads");
this.ensureUploadsDir();
setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
}
private isDocker(): boolean {
try {
fsSync.statSync("/.dockerenv");
return true;
} catch {
try {
return fsSync.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
} catch {
return false;
}
}
}
public static getInstance(): FilesystemStorageProvider {
if (!FilesystemStorageProvider.instance) {
FilesystemStorageProvider.instance = new FilesystemStorageProvider();
@@ -229,8 +217,10 @@ export class FilesystemStorageProvider implements StorageProvider {
if (encryptedBuffer.length > 16) {
try {
return this.decryptFileBuffer(encryptedBuffer);
} catch (error) {
console.warn("Failed to decrypt with new method, trying legacy format");
} catch (error: unknown) {
if (error instanceof Error) {
console.warn("Failed to decrypt with new method, trying legacy format", error.message);
}
return this.decryptFileLegacy(encryptedBuffer);
}
}

View File

@@ -11,9 +11,9 @@ import { reverseShareRoutes } from "./modules/reverse-share/routes";
import { shareRoutes } from "./modules/share/routes";
import { storageRoutes } from "./modules/storage/routes";
import { userRoutes } from "./modules/user/routes";
import { IS_RUNNING_IN_CONTAINER } from "./utils/container-detection";
import fastifyMultipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import * as fsSync from "fs";
import * as fs from "fs/promises";
import crypto from "node:crypto";
import path from "path";
@@ -27,21 +27,7 @@ if (typeof global.crypto === "undefined") {
}
async function ensureDirectories() {
// Use /app/server paths in Docker, current directory for local development
const isDocker = (() => {
try {
fsSync.statSync("/.dockerenv");
return true;
} catch {
try {
return fsSync.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
} catch {
return false;
}
}
})();
const baseDir = isDocker ? "/app/server" : process.cwd();
const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
const uploadsDir = path.join(baseDir, "uploads");
const tempChunksDir = path.join(baseDir, "temp-chunks");
@@ -78,20 +64,7 @@ async function startServer() {
});
if (env.ENABLE_S3 !== "true") {
const isDocker = (() => {
try {
fsSync.statSync("/.dockerenv");
return true;
} catch {
try {
return fsSync.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
} catch {
return false;
}
}
})();
const baseDir = isDocker ? "/app/server" : process.cwd();
const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
const uploadsPath = path.join(baseDir, "uploads");
await app.register(fastifyStatic, {

View File

@@ -0,0 +1,45 @@
import * as fsSync from "fs";
/**
* Determines if the application is running inside a container environment.
* Checks common container indicators like /.dockerenv and cgroup file patterns.
*
* This function caches its result after the first call for performance.
*
* @returns {boolean} True if running in a container, false otherwise.
*/
function isRunningInContainer(): boolean {
try {
if (fsSync.existsSync("/.dockerenv")) {
return true;
}
const cgroupContent = fsSync.readFileSync("/proc/self/cgroup", "utf8");
const containerPatterns = [
"docker",
"containerd",
"lxc",
"kubepods",
"pod",
"/containers/",
"system.slice/container-",
];
for (const pattern of containerPatterns) {
if (cgroupContent.includes(pattern)) {
return true;
}
}
if (fsSync.existsSync("/.well-known/container")) {
return true;
}
} catch (e: unknown) {
if (e instanceof Error) {
console.warn("Could not perform full container detection:", e.message);
}
}
return false;
}
export const IS_RUNNING_IN_CONTAINER = isRunningInContainer();

View File

@@ -2,12 +2,12 @@ import { useState } from "react";
import { IconDownload, IconEye } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { getFileIcon } from "@/utils/file-icons";
import { formatFileSize } from "@/utils/format-file-size";
import { ShareFilesTableProps } from "../types";
import { ShareFilePreviewModal } from "./share-file-preview-modal";
export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
const t = useTranslations();
@@ -99,7 +99,14 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
</Table>
</div>
{selectedFile && <FilePreviewModal isOpen={isPreviewOpen} onClose={handleClosePreview} file={selectedFile} />}
{selectedFile && (
<ShareFilePreviewModal
isOpen={isPreviewOpen}
onClose={handleClosePreview}
file={selectedFile}
onDownload={onDownload}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,320 @@
"use client";
import { useEffect, useState } from "react";
import { IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getDownloadUrl } from "@/http/endpoints";
import { getFileIcon } from "@/utils/file-icons";
interface ShareFilePreviewModalProps {
isOpen: boolean;
onClose: () => void;
file: {
name: string;
objectName: string;
type?: string;
};
onDownload: (objectName: string, fileName: string) => void;
}
export function ShareFilePreviewModal({ isOpen, onClose, file, onDownload }: ShareFilePreviewModalProps) {
const t = useTranslations();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [videoBlob, setVideoBlob] = useState<string | null>(null);
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 && !isLoadingPreview) {
setIsLoading(true);
setPreviewUrl(null);
setVideoBlob(null);
setPdfAsBlob(false);
setDownloadUrl(null);
setPdfLoadFailed(false);
loadPreview();
}
}, [file.objectName, isOpen]);
useEffect(() => {
return () => {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
}
};
}, [previewUrl, videoBlob]);
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);
const url = response.data.url;
setDownloadUrl(url);
const fileType = getFileType();
if (fileType === "video") {
await loadVideoPreview(url);
} else if (fileType === "audio") {
await loadAudioPreview(url);
} else if (fileType === "pdf") {
await loadPdfPreview(url);
} else {
setPreviewUrl(url);
}
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setIsLoading(false);
setIsLoadingPreview(false);
}
};
const loadVideoPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setVideoBlob(blobUrl);
} catch (error) {
console.error("Failed to load video as blob:", error);
setPreviewUrl(url);
}
};
const loadAudioPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
} catch (error) {
console.error("Failed to load audio as blob:", error);
setPreviewUrl(url);
}
};
const loadPdfPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const finalBlob = new Blob([blob], { type: "application/pdf" });
const blobUrl = URL.createObjectURL(finalBlob);
setPreviewUrl(blobUrl);
setPdfAsBlob(true);
} catch (error) {
console.error("Failed to load PDF as blob:", error);
setPreviewUrl(url);
setTimeout(() => {
if (!pdfLoadFailed && !pdfAsBlob) {
handlePdfLoadError();
}
}, 4000);
}
};
const handlePdfLoadError = async () => {
if (pdfLoadFailed || pdfAsBlob) return;
setPdfLoadFailed(true);
if (downloadUrl) {
setTimeout(() => {
loadPdfPreview(downloadUrl);
}, 500);
}
};
const handleDownload = () => {
onDownload(file.objectName, file.name);
};
const getFileType = () => {
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension === "pdf") return "pdf";
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) return "image";
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) return "audio";
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) return "video";
return "other";
};
const renderPreview = () => {
const fileType = getFileType();
const { icon: FileIcon, color } = getFileIcon(file.name);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
</div>
);
}
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
if (!previewUrl && fileType !== "video") {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
switch (fileType) {
case "pdf":
return (
<ScrollArea className="w-full">
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
{pdfAsBlob ? (
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
/>
) : pdfLoadFailed ? (
<div className="flex items-center justify-center h-full min-h-[600px]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
</div>
</div>
) : (
<div className="w-full h-full min-h-[600px] relative">
<object
data={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
type="application/pdf"
className="w-full h-full min-h-[600px]"
onError={handlePdfLoadError}
>
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
onError={handlePdfLoadError}
/>
</object>
</div>
)}
</div>
</ScrollArea>
);
case "image":
return (
<AspectRatio ratio={16 / 9} className="bg-muted">
<img src={previewUrl!} alt={file.name} className="object-contain w-full h-full rounded-md" />
</AspectRatio>
);
case "audio":
return (
<div className="flex flex-col items-center justify-center gap-6 py-4">
<CustomAudioPlayer src={mediaUrl!} />
</div>
);
case "video":
return (
<div className="flex flex-col items-center justify-center gap-4 py-6">
<div className="w-full max-w-4xl">
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
<source src={mediaUrl!} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`text-6xl ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{(() => {
const FileIcon = getFileIcon(file.name).icon;
return <FileIcon size={24} />;
})()}
<span className="truncate">{file.name}</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">{renderPreview()}</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
<Button onClick={handleDownload}>
<IconDownload className="h-4 w-4" />
{t("common.download")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}