feat(server): add endpoint to create share with direct file uploads

Co-authored-by: danielalves96 <62755605+danielalves96@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-10-21 14:35:51 +00:00
parent fcb9fd5b14
commit 71e99b1ed2
5 changed files with 262 additions and 1 deletions

View File

@@ -1,7 +1,9 @@
import { MultipartFile } from "@fastify/multipart";
import { FastifyReply, FastifyRequest } from "fastify";
import {
CreateShareSchema,
CreateShareWithFilesSchema,
UpdateShareItemsSchema,
UpdateSharePasswordSchema,
UpdateShareRecipientsSchema,
@@ -32,6 +34,67 @@ export class ShareController {
}
}
async createShareWithFiles(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const parts = request.parts();
const uploadedFiles: MultipartFile[] = [];
let formData: any = {};
for await (const part of parts) {
if (part.type === "file") {
uploadedFiles.push(part as MultipartFile);
} else {
// Handle form fields
const fieldName = part.fieldname;
const value = (part as any).value;
// Parse JSON fields
if (fieldName === "existingFiles" || fieldName === "existingFolders" || fieldName === "recipients") {
try {
formData[fieldName] = JSON.parse(value);
} catch (e) {
formData[fieldName] = value;
}
} else if (fieldName === "maxViews") {
formData[fieldName] = value ? parseInt(value) : null;
} else {
formData[fieldName] = value;
}
}
}
// Validate at least one file or folder is provided
const hasExistingFiles = formData.existingFiles && formData.existingFiles.length > 0;
const hasExistingFolders = formData.existingFolders && formData.existingFolders.length > 0;
const hasNewFiles = uploadedFiles.length > 0;
if (!hasExistingFiles && !hasExistingFolders && !hasNewFiles) {
return reply.status(400).send({
error: "At least one file or folder must be selected or uploaded to create a share",
});
}
// Validate the form data against the schema (excluding file validation)
const input = CreateShareWithFilesSchema.parse(formData);
// Create the share with uploaded files
const share = await this.shareService.createShareWithFiles(input, uploadedFiles, userId);
return reply.status(201).send({ share });
} catch (error: any) {
console.error("Create Share With Files Error:", error);
if (error.errors) {
return reply.status(400).send({ error: error.errors });
}
return reply.status(400).send({ error: error.message || "Unknown error occurred" });
}
}
async listUserShares(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();

View File

@@ -136,6 +136,24 @@ export const CreateShareAliasSchema = z.object({
.describe("The custom alias for the share"),
});
export const CreateShareWithFilesSchema = z.object({
name: z.string().optional().describe("The share name"),
description: z.string().optional().describe("The share description"),
expiration: z
.string()
.datetime({
message: "Data de expiração deve estar no formato ISO 8601 (ex: 2025-02-06T13:20:49Z)",
})
.optional(),
existingFiles: z.array(z.string()).optional().describe("Existing file IDs to include"),
existingFolders: z.array(z.string()).optional().describe("Existing folder IDs to include"),
password: z.string().optional().describe("The share password"),
maxViews: z.number().optional().nullable().describe("The maximum number of views"),
recipients: z.array(z.string().email()).optional().describe("The recipient emails"),
folderId: z.string().optional().nullable().describe("Folder ID to upload new files to"),
});
export type CreateShareInput = z.infer<typeof CreateShareSchema>;
export type CreateShareWithFilesInput = z.infer<typeof CreateShareWithFilesSchema>;
export type UpdateShareInput = z.infer<typeof UpdateShareSchema>;
export type ShareResponse = z.infer<typeof ShareResponseSchema>;

View File

@@ -46,6 +46,29 @@ export async function shareRoutes(app: FastifyInstance) {
shareController.createShare.bind(shareController)
);
app.post(
"/shares/create-with-files",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "createShareWithFiles",
summary: "Create a new share with file uploads",
description:
"Create a new share and upload new files directly in a single action. Supports multipart/form-data.",
consumes: ["multipart/form-data"],
response: {
201: z.object({
share: ShareResponseSchema,
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
shareController.createShareWithFiles.bind(shareController)
);
app.get(
"/shares/me",
{

View File

@@ -1,10 +1,18 @@
import * as fs from "fs/promises";
import * as path from "path";
import { MultipartFile } from "@fastify/multipart";
import bcrypt from "bcryptjs";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
import { S3StorageProvider } from "../../providers/s3-storage.provider";
import { prisma } from "../../shared/prisma";
import { generateUniqueFileName, parseFileName } from "../../utils/file-name-generator";
import { ConfigService } from "../config/service";
import { EmailService } from "../email/service";
import { FileService } from "../file/service";
import { FolderService } from "../folder/service";
import { UserService } from "../user/service";
import { CreateShareInput, ShareResponseSchema, UpdateShareInput } from "./dto";
import { CreateShareInput, CreateShareWithFilesInput, ShareResponseSchema, UpdateShareInput } from "./dto";
import { IShareRepository, PrismaShareRepository } from "./repository";
export class ShareService {
@@ -113,6 +121,141 @@ export class ShareService {
return ShareResponseSchema.parse(await this.formatShareResponse(shareWithRelations));
}
async createShareWithFiles(data: CreateShareWithFilesInput, uploadedFiles: MultipartFile[], userId: string) {
const configService = new ConfigService();
const fileService = new FileService();
const { password, maxViews, existingFiles, existingFolders, folderId, ...shareData } = data;
// Validate existing files
if (existingFiles && existingFiles.length > 0) {
const files = await prisma.file.findMany({
where: {
id: { in: existingFiles },
userId: userId,
},
});
const notFoundFiles = existingFiles.filter((id) => !files.some((file) => file.id === id));
if (notFoundFiles.length > 0) {
throw new Error(`Files not found or access denied: ${notFoundFiles.join(", ")}`);
}
}
// Validate existing folders
if (existingFolders && existingFolders.length > 0) {
const folders = await prisma.folder.findMany({
where: {
id: { in: existingFolders },
userId: userId,
},
});
const notFoundFolders = existingFolders.filter((id) => !folders.some((folder) => folder.id === id));
if (notFoundFolders.length > 0) {
throw new Error(`Folders not found or access denied: ${notFoundFolders.join(", ")}`);
}
}
// Validate folder if specified
if (folderId) {
const folder = await prisma.folder.findFirst({
where: { id: folderId, userId },
});
if (!folder) {
throw new Error("Folder not found or access denied.");
}
}
const maxFileSize = BigInt(await configService.getValue("maxFileSize"));
const maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
// Check user storage
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const currentStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
// Upload new files and create file records
const newFileIds: string[] = [];
for (const uploadedFile of uploadedFiles) {
const buffer = await uploadedFile.toBuffer();
const fileSize = BigInt(buffer.length);
// Validate file size
if (fileSize > maxFileSize) {
const maxSizeMB = Number(maxFileSize) / (1024 * 1024);
throw new Error(`File ${uploadedFile.filename} exceeds the maximum allowed size of ${maxSizeMB}MB`);
}
// Check storage space
if (currentStorage + fileSize > maxTotalStorage) {
const availableSpace = Number(maxTotalStorage - currentStorage) / (1024 * 1024);
throw new Error(`Insufficient storage space. You have ${availableSpace.toFixed(2)}MB available`);
}
// Parse filename
const { baseName, extension } = parseFileName(uploadedFile.filename);
const uniqueName = await generateUniqueFileName(baseName, extension, userId, folderId || null);
// Generate object name
const objectName = `${userId}/${Date.now()}-${baseName}.${extension}`;
// Upload file to storage
if (fileService.isFilesystemMode()) {
// For filesystem mode, we need to use the filesystem provider
const provider = FilesystemStorageProvider.getInstance();
await provider.uploadFile(objectName, buffer);
} else {
// For S3 mode
const provider = new S3StorageProvider();
await provider.uploadFile(objectName, buffer);
}
// Create file record in database
const fileRecord = await prisma.file.create({
data: {
name: uniqueName,
extension: extension,
size: fileSize,
objectName: objectName,
userId,
folderId: folderId || null,
},
});
newFileIds.push(fileRecord.id);
}
// Combine existing and new file IDs
const allFileIds = [...(existingFiles || []), ...newFileIds];
// Validate at least one file or folder
if (allFileIds.length === 0 && (!existingFolders || existingFolders.length === 0)) {
throw new Error("At least one file or folder must be selected or uploaded to create a share");
}
// Create share security
const security = await prisma.shareSecurity.create({
data: {
password: password ? await bcrypt.hash(password, 10) : null,
maxViews: maxViews,
},
});
// Create share
const share = await this.shareRepository.createShare({
...shareData,
files: allFileIds,
folders: existingFolders,
securityId: security.id,
creatorId: userId,
});
const shareWithRelations = await this.shareRepository.findShareById(share.id);
return ShareResponseSchema.parse(await this.formatShareResponse(shareWithRelations));
}
async getShare(shareId: string, password?: string, userId?: string) {
const share = await this.shareRepository.findShareById(shareId);

View File

@@ -143,4 +143,18 @@ export class S3StorageProvider implements StorageProvider {
throw error;
}
}
async uploadFile(objectName: string, buffer: Buffer): Promise<void> {
if (!s3Client) {
throw new Error("S3 client is not available");
}
const command = new PutObjectCommand({
Bucket: bucketName,
Key: objectName,
Body: buffer,
});
await s3Client.send(command);
}
}