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.
This commit is contained in:
Daniel Luiz Alves
2025-07-21 11:50:13 -03:00
parent 2efe69e50b
commit 93e05dd913
11 changed files with 146 additions and 5 deletions

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ apps/server/dist/*
#DEFAULT
.env
.steering
data/
node_modules/

View File

@@ -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();

View File

@@ -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",
{

View File

@@ -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: {

View File

@@ -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<boolean | null>(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<void> => {
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,
});

View File

@@ -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;
}

View File

@@ -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<FileUpload[]>([]);
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(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);

View File

@@ -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<boolean | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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)));
},

View File

@@ -7,6 +7,7 @@ import type {
CheckUploadAllowedResult,
GetAppInfoResult,
GetDiskSpaceResult,
GetSystemInfoResult,
RemoveLogoResult,
UploadLogoBody,
UploadLogoResult,
@@ -20,6 +21,14 @@ export const getAppInfo = <TData = GetAppInfoResult>(options?: AxiosRequestConfi
return apiInstance.get(`/api/app/info`, options);
};
/**
* Get system information including storage provider
* @summary Get system information
*/
export const getSystemInfo = <TData = GetSystemInfoResult>(options?: AxiosRequestConfig): Promise<TData> => {
return apiInstance.get(`/api/app/system-info`, options);
};
/**
* Upload a new app logo (admin only)
* @summary Upload app logo

View File

@@ -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<GetAppInfo200>;
export type GetSystemInfoResult = AxiosResponse<GetSystemInfo200>;
export type UploadLogoResult = AxiosResponse<UploadLogo200>;
export type RemoveLogoResult = AxiosResponse<RemoveLogo200>;
export type CheckHealthResult = AxiosResponse<CheckHealth200>;

View File

@@ -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<ChunkedUploadResult> {
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;