From 93e05dd913733e58c493fa88fb75ce96be168f0e Mon Sep 17 00:00:00 2001 From: Daniel Luiz Alves Date: Mon, 21 Jul 2025 11:50:13 -0300 Subject: [PATCH] feat: add system information endpoint and integrate S3 support - Implemented a new endpoint to retrieve system information, including the active storage provider and S3 status. - Updated the AppService to fetch system information and return relevant data. - Integrated system information fetching in the FileUploadSection, GlobalDropZone, and UploadFileModal components to adjust upload behavior based on S3 availability. - Enhanced chunked upload logic to conditionally use chunked uploads based on the storage provider. --- .gitignore | 1 + apps/server/src/modules/app/controller.ts | 9 ++++++ apps/server/src/modules/app/routes.ts | 20 ++++++++++++ apps/server/src/modules/app/service.ts | 8 +++++ .../components/file-upload-section.tsx | 19 +++++++++++- .../app/api/(proxy)/app/system-info/route.ts | 31 +++++++++++++++++++ .../components/general/global-drop-zone.tsx | 19 +++++++++++- .../components/modals/upload-file-modal.tsx | 19 +++++++++++- apps/web/src/http/endpoints/app/index.ts | 9 ++++++ apps/web/src/http/endpoints/app/types.ts | 6 ++++ apps/web/src/utils/chunked-upload.ts | 10 ++++-- 11 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/app/api/(proxy)/app/system-info/route.ts diff --git a/.gitignore b/.gitignore index 323e9c9..cc6d117 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ apps/server/dist/* #DEFAULT .env +.steering data/ node_modules/ \ No newline at end of file diff --git a/apps/server/src/modules/app/controller.ts b/apps/server/src/modules/app/controller.ts index 0db59f7..131c6aa 100644 --- a/apps/server/src/modules/app/controller.ts +++ b/apps/server/src/modules/app/controller.ts @@ -18,6 +18,15 @@ export class AppController { } } + async getSystemInfo(request: FastifyRequest, reply: FastifyReply) { + try { + const systemInfo = await this.appService.getSystemInfo(); + return reply.send(systemInfo); + } catch (error: any) { + return reply.status(400).send({ error: error.message }); + } + } + async getAllConfigs(request: FastifyRequest, reply: FastifyReply) { try { const configs = await this.appService.getAllConfigs(); diff --git a/apps/server/src/modules/app/routes.ts b/apps/server/src/modules/app/routes.ts index 3708d1b..9cb7474 100644 --- a/apps/server/src/modules/app/routes.ts +++ b/apps/server/src/modules/app/routes.ts @@ -53,6 +53,26 @@ export async function appRoutes(app: FastifyInstance) { appController.getAppInfo.bind(appController) ); + app.get( + "/app/system-info", + { + schema: { + tags: ["App"], + operationId: "getSystemInfo", + summary: "Get system information", + description: "Get system information including storage provider", + response: { + 200: z.object({ + storageProvider: z.enum(["s3", "filesystem"]).describe("The active storage provider"), + s3Enabled: z.boolean().describe("Whether S3 storage is enabled"), + }), + 400: z.object({ error: z.string().describe("Error message") }), + }, + }, + }, + appController.getSystemInfo.bind(appController) + ); + app.patch( "/app/configs/:key", { diff --git a/apps/server/src/modules/app/service.ts b/apps/server/src/modules/app/service.ts index 8e053cc..d6c5cd2 100644 --- a/apps/server/src/modules/app/service.ts +++ b/apps/server/src/modules/app/service.ts @@ -1,3 +1,4 @@ +import { isS3Enabled } from "../../config/storage.config"; import { prisma } from "../../shared/prisma"; import { ConfigService } from "../config/service"; @@ -20,6 +21,13 @@ export class AppService { }; } + async getSystemInfo() { + return { + storageProvider: isS3Enabled ? "s3" : "filesystem", + s3Enabled: isS3Enabled, + }; + } + async getAllConfigs() { return prisma.appConfig.findMany({ where: { diff --git a/apps/web/src/app/(shares)/r/[alias]/components/file-upload-section.tsx b/apps/web/src/app/(shares)/r/[alias]/components/file-upload-section.tsx index 387a389..a40acad 100644 --- a/apps/web/src/app/(shares)/r/[alias]/components/file-upload-section.tsx +++ b/apps/web/src/app/(shares)/r/[alias]/components/file-upload-section.tsx @@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label"; import { Progress } from "@/components/ui/progress"; import { Textarea } from "@/components/ui/textarea"; import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints"; +import { getSystemInfo } from "@/http/endpoints/app"; import { ChunkedUploader } from "@/utils/chunked-upload"; import { formatFileSize } from "@/utils/format-file-size"; import { FILE_STATUS, UPLOAD_CONFIG, UPLOAD_PROGRESS } from "../constants"; @@ -25,9 +26,24 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce const [uploaderEmail, setUploaderEmail] = useState(""); const [description, setDescription] = useState(""); const [isUploading, setIsUploading] = useState(false); + const [isS3Enabled, setIsS3Enabled] = useState(null); const t = useTranslations(); + useEffect(() => { + const fetchSystemInfo = async () => { + try { + const response = await getSystemInfo(); + setIsS3Enabled(response.data.s3Enabled); + } catch (error) { + console.warn("Failed to fetch system info, defaulting to filesystem mode:", error); + setIsS3Enabled(false); + } + }; + + fetchSystemInfo(); + }, []); + const validateFileSize = useCallback( (file: File): string | null => { if (!reverseShare.maxFileSize) return null; @@ -139,7 +155,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce presignedUrl: string, onProgress?: (progress: number) => void ): Promise => { - const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size); + const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined); if (shouldUseChunked) { const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size); @@ -148,6 +164,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce file, url: presignedUrl, chunkSize, + isS3Enabled: isS3Enabled ?? undefined, onProgress, }); diff --git a/apps/web/src/app/api/(proxy)/app/system-info/route.ts b/apps/web/src/app/api/(proxy)/app/system-info/route.ts new file mode 100644 index 0000000..40b8917 --- /dev/null +++ b/apps/web/src/app/api/(proxy)/app/system-info/route.ts @@ -0,0 +1,31 @@ +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 url = `${API_BASE_URL}/app/system-info`; + + const apiRes = await fetch(url, { + method: "GET", + headers: { + cookie: cookieHeader || "", + }, + redirect: "manual", + }); + + const resBody = await apiRes.text(); + const res = new NextResponse(resBody, { + status: apiRes.status, + headers: { + "Content-Type": "application/json", + }, + }); + + const setCookie = apiRes.headers.getSetCookie?.() || []; + if (setCookie.length > 0) { + res.headers.set("Set-Cookie", setCookie.join(",")); + } + + return res; +} diff --git a/apps/web/src/components/general/global-drop-zone.tsx b/apps/web/src/components/general/global-drop-zone.tsx index 6cef4e3..cce3534 100644 --- a/apps/web/src/components/general/global-drop-zone.tsx +++ b/apps/web/src/components/general/global-drop-zone.tsx @@ -9,6 +9,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints"; +import { getSystemInfo } from "@/http/endpoints/app"; import { ChunkedUploader } from "@/utils/chunked-upload"; import { getFileIcon } from "@/utils/file-icons"; import { generateSafeFileName } from "@/utils/file-utils"; @@ -43,6 +44,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) { const [isDragOver, setIsDragOver] = useState(false); const [fileUploads, setFileUploads] = useState([]); const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false); + const [isS3Enabled, setIsS3Enabled] = useState(null); const generateFileId = useCallback(() => { return Date.now().toString() + Math.random().toString(36).substr(2, 9); @@ -124,7 +126,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) { const abortController = new AbortController(); setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u))); - const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size); + const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined); if (shouldUseChunked) { const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size); @@ -134,6 +136,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) { url, chunkSize, signal: abortController.signal, + isS3Enabled: isS3Enabled ?? undefined, onProgress: (progress) => { setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u))); }, @@ -256,6 +259,20 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) { [uploadFile, t, createFileUpload] ); + useEffect(() => { + const fetchSystemInfo = async () => { + try { + const response = await getSystemInfo(); + setIsS3Enabled(response.data.s3Enabled); + } catch (error) { + console.warn("Failed to fetch system info, defaulting to filesystem mode:", error); + setIsS3Enabled(false); + } + }; + + fetchSystemInfo(); + }, []); + useEffect(() => { document.addEventListener("dragover", handleDragOver); document.addEventListener("dragleave", handleDragLeave); diff --git a/apps/web/src/components/modals/upload-file-modal.tsx b/apps/web/src/components/modals/upload-file-modal.tsx index 1fba594..7c5d901 100644 --- a/apps/web/src/components/modals/upload-file-modal.tsx +++ b/apps/web/src/components/modals/upload-file-modal.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Progress } from "@/components/ui/progress"; import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints"; +import { getSystemInfo } from "@/http/endpoints/app"; import { ChunkedUploader } from "@/utils/chunked-upload"; import { getFileIcon } from "@/utils/file-icons"; import { generateSafeFileName } from "@/utils/file-utils"; @@ -87,8 +88,23 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP const [isDragOver, setIsDragOver] = useState(false); const [showConfirmation, setShowConfirmation] = useState(false); const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false); + const [isS3Enabled, setIsS3Enabled] = useState(null); const fileInputRef = useRef(null); + useEffect(() => { + const fetchSystemInfo = async () => { + try { + const response = await getSystemInfo(); + setIsS3Enabled(response.data.s3Enabled); + } catch (error) { + console.warn("Failed to fetch system info, defaulting to filesystem mode:", error); + setIsS3Enabled(false); + } + }; + + fetchSystemInfo(); + }, []); + useEffect(() => { return () => { fileUploads.forEach((upload) => { @@ -252,7 +268,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP const abortController = new AbortController(); setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u))); - const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size); + const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined); if (shouldUseChunked) { const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size); @@ -262,6 +278,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP url, chunkSize, signal: abortController.signal, + isS3Enabled: isS3Enabled ?? undefined, onProgress: (progress) => { setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u))); }, diff --git a/apps/web/src/http/endpoints/app/index.ts b/apps/web/src/http/endpoints/app/index.ts index 3801432..4025595 100644 --- a/apps/web/src/http/endpoints/app/index.ts +++ b/apps/web/src/http/endpoints/app/index.ts @@ -7,6 +7,7 @@ import type { CheckUploadAllowedResult, GetAppInfoResult, GetDiskSpaceResult, + GetSystemInfoResult, RemoveLogoResult, UploadLogoBody, UploadLogoResult, @@ -20,6 +21,14 @@ export const getAppInfo = (options?: AxiosRequestConfi return apiInstance.get(`/api/app/info`, options); }; +/** + * Get system information including storage provider + * @summary Get system information + */ +export const getSystemInfo = (options?: AxiosRequestConfig): Promise => { + return apiInstance.get(`/api/app/system-info`, options); +}; + /** * Upload a new app logo (admin only) * @summary Upload app logo diff --git a/apps/web/src/http/endpoints/app/types.ts b/apps/web/src/http/endpoints/app/types.ts index f4b6324..ca1c2d2 100644 --- a/apps/web/src/http/endpoints/app/types.ts +++ b/apps/web/src/http/endpoints/app/types.ts @@ -32,6 +32,11 @@ export interface GetAppInfo200 { firstUserAccess: boolean; } +export interface GetSystemInfo200 { + storageProvider: "s3" | "filesystem"; + s3Enabled: boolean; +} + export interface RemoveLogo200 { message: string; } @@ -49,6 +54,7 @@ export interface UploadLogoBody { } export type GetAppInfoResult = AxiosResponse; +export type GetSystemInfoResult = AxiosResponse; export type UploadLogoResult = AxiosResponse; export type RemoveLogoResult = AxiosResponse; export type CheckHealthResult = AxiosResponse; diff --git a/apps/web/src/utils/chunked-upload.ts b/apps/web/src/utils/chunked-upload.ts index e259535..a37317a 100644 --- a/apps/web/src/utils/chunked-upload.ts +++ b/apps/web/src/utils/chunked-upload.ts @@ -7,6 +7,7 @@ export interface ChunkedUploadOptions { onProgress?: (progress: number) => void; onChunkComplete?: (chunkIndex: number, totalChunks: number) => void; signal?: AbortSignal; + isS3Enabled?: boolean; } export interface ChunkedUploadResult { @@ -23,7 +24,7 @@ export class ChunkedUploader { static async uploadFile(options: ChunkedUploadOptions): Promise { const { file, url, chunkSize, onProgress, onChunkComplete, signal } = options; - if (!this.shouldUseChunkedUpload(file.size)) { + if (!this.shouldUseChunkedUpload(file.size, options.isS3Enabled)) { throw new Error( `File ${file.name} (${(file.size / (1024 * 1024)).toFixed(2)}MB) should not use chunked upload. Use regular upload instead.` ); @@ -238,8 +239,13 @@ export class ChunkedUploader { /** * Check if file should use chunked upload + * Only use chunked upload for filesystem storage, not for S3 */ - static shouldUseChunkedUpload(fileSize: number): boolean { + static shouldUseChunkedUpload(fileSize: number, isS3Enabled?: boolean): boolean { + if (isS3Enabled) { + return false; + } + const threshold = 100 * 1024 * 1024; // 100MB const shouldUse = fileSize > threshold;