mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-24 00:23:39 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user