mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 16:14:18 +00:00
Compare commits
6 Commits
v3.2.4-bet
...
copilot/ad
Author | SHA1 | Date | |
---|---|---|---|
|
fba40cf510 | ||
|
71e99b1ed2 | ||
|
fcb9fd5b14 | ||
|
148676513d | ||
|
42a5b7a796 | ||
|
59fccd9a93 |
@@ -617,6 +617,11 @@ export class AuthProvidersService {
|
||||
return await this.linkProviderToExistingUser(existingUser, provider.id, String(externalId), userInfo);
|
||||
}
|
||||
|
||||
// Check if auto-registration is disabled
|
||||
if (provider.autoRegister === false) {
|
||||
throw new Error(`User registration via ${provider.displayName || provider.name} is disabled`);
|
||||
}
|
||||
|
||||
return await this.createNewUserWithProvider(userInfo, provider.id, String(externalId));
|
||||
}
|
||||
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import * as fs from "fs";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
generateUniqueFileNameForRename,
|
||||
parseFileName,
|
||||
} from "../../utils/file-name-generator";
|
||||
import { getContentType } from "../../utils/mime-types";
|
||||
import { ConfigService } from "../config/service";
|
||||
import {
|
||||
CheckFileInput,
|
||||
@@ -200,11 +202,10 @@ export class FileController {
|
||||
|
||||
async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { objectName: encodedObjectName } = request.params as {
|
||||
const { objectName, password } = request.query as {
|
||||
objectName: string;
|
||||
password?: string;
|
||||
};
|
||||
const objectName = decodeURIComponent(encodedObjectName);
|
||||
const { password } = request.query as { password?: string };
|
||||
|
||||
if (!objectName) {
|
||||
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
|
||||
@@ -218,7 +219,8 @@ export class FileController {
|
||||
|
||||
let hasAccess = false;
|
||||
|
||||
console.log("Requested file with password " + password);
|
||||
// Don't log raw passwords. Log only whether a password was provided (for debugging access flow).
|
||||
console.log(`Requested file access for object="${objectName}" passwordProvided=${password ? true : false}`);
|
||||
|
||||
const shares = await prisma.share.findMany({
|
||||
where: {
|
||||
@@ -270,6 +272,118 @@ export class FileController {
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFile(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { objectName, password } = request.query as {
|
||||
objectName: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
if (!objectName) {
|
||||
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
|
||||
}
|
||||
|
||||
const fileRecord = await prisma.file.findFirst({ where: { objectName } });
|
||||
|
||||
if (!fileRecord) {
|
||||
if (objectName.startsWith("reverse-shares/")) {
|
||||
const reverseShareFile = await prisma.reverseShareFile.findFirst({
|
||||
where: { objectName },
|
||||
include: {
|
||||
reverseShare: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!reverseShareFile) {
|
||||
return reply.status(404).send({ error: "File not found." });
|
||||
}
|
||||
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const userId = (request as any).user?.userId;
|
||||
|
||||
if (!userId || reverseShareFile.reverseShare.creatorId !== userId) {
|
||||
return reply.status(401).send({ error: "Unauthorized access to file." });
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(401).send({ error: "Unauthorized access to file." });
|
||||
}
|
||||
|
||||
const storageProvider = (this.fileService as any).storageProvider;
|
||||
const filePath = storageProvider.getFilePath(objectName);
|
||||
|
||||
const contentType = getContentType(reverseShareFile.name);
|
||||
const fileName = reverseShareFile.name;
|
||||
|
||||
reply.header("Content-Type", contentType);
|
||||
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||
|
||||
const stream = fs.createReadStream(filePath);
|
||||
return reply.send(stream);
|
||||
}
|
||||
|
||||
return reply.status(404).send({ error: "File not found." });
|
||||
}
|
||||
|
||||
let hasAccess = false;
|
||||
|
||||
const shares = await prisma.share.findMany({
|
||||
where: {
|
||||
files: {
|
||||
some: {
|
||||
id: fileRecord.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
security: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const share of shares) {
|
||||
if (!share.security.password) {
|
||||
hasAccess = true;
|
||||
break;
|
||||
} else if (password) {
|
||||
const isPasswordValid = await bcrypt.compare(password, share.security.password);
|
||||
if (isPasswordValid) {
|
||||
hasAccess = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const userId = (request as any).user?.userId;
|
||||
if (userId && fileRecord.userId === userId) {
|
||||
hasAccess = true;
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return reply.status(401).send({ error: "Unauthorized access to file." });
|
||||
}
|
||||
|
||||
const storageProvider = (this.fileService as any).storageProvider;
|
||||
const filePath = storageProvider.getFilePath(objectName);
|
||||
|
||||
const contentType = getContentType(fileRecord.name);
|
||||
const fileName = fileRecord.name;
|
||||
|
||||
reply.header("Content-Type", contentType);
|
||||
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||
|
||||
const stream = fs.createReadStream(filePath);
|
||||
return reply.send(stream);
|
||||
} catch (error) {
|
||||
console.error("Error in downloadFile:", error);
|
||||
return reply.status(500).send({ error: "Internal server error." });
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
@@ -471,6 +585,51 @@ export class FileController {
|
||||
}
|
||||
}
|
||||
|
||||
async embedFile(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
if (!id) {
|
||||
return reply.status(400).send({ error: "File ID is required." });
|
||||
}
|
||||
|
||||
const fileRecord = await prisma.file.findUnique({ where: { id } });
|
||||
|
||||
if (!fileRecord) {
|
||||
return reply.status(404).send({ error: "File not found." });
|
||||
}
|
||||
|
||||
const extension = fileRecord.extension.toLowerCase();
|
||||
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "avif"];
|
||||
const videoExts = ["mp4", "webm", "ogg", "mov", "avi", "mkv", "flv", "wmv"];
|
||||
const audioExts = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "wma"];
|
||||
|
||||
const isMedia = imageExts.includes(extension) || videoExts.includes(extension) || audioExts.includes(extension);
|
||||
|
||||
if (!isMedia) {
|
||||
return reply.status(403).send({
|
||||
error: "Embed is only allowed for images, videos, and audio files.",
|
||||
});
|
||||
}
|
||||
|
||||
const storageProvider = (this.fileService as any).storageProvider;
|
||||
const filePath = storageProvider.getFilePath(fileRecord.objectName);
|
||||
|
||||
const contentType = getContentType(fileRecord.name);
|
||||
const fileName = fileRecord.name;
|
||||
|
||||
reply.header("Content-Type", contentType);
|
||||
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
|
||||
reply.header("Cache-Control", "public, max-age=31536000"); // Cache por 1 ano
|
||||
|
||||
const stream = fs.createReadStream(filePath);
|
||||
return reply.send(stream);
|
||||
} catch (error) {
|
||||
console.error("Error in embedFile:", error);
|
||||
return reply.status(500).send({ error: "Internal server error." });
|
||||
}
|
||||
}
|
||||
|
||||
private async getAllUserFilesRecursively(userId: string): Promise<any[]> {
|
||||
const rootFiles = await prisma.file.findMany({
|
||||
where: { userId, folderId: null },
|
||||
|
@@ -106,17 +106,15 @@ export async function fileRoutes(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/files/:objectName/download",
|
||||
"/files/download-url",
|
||||
{
|
||||
schema: {
|
||||
tags: ["File"],
|
||||
operationId: "getDownloadUrl",
|
||||
summary: "Get Download URL",
|
||||
description: "Generates a pre-signed URL for downloading a file",
|
||||
params: z.object({
|
||||
objectName: z.string().min(1, "The objectName is required"),
|
||||
}),
|
||||
querystring: z.object({
|
||||
objectName: z.string().min(1, "The objectName is required"),
|
||||
password: z.string().optional().describe("Share password if required"),
|
||||
}),
|
||||
response: {
|
||||
@@ -133,6 +131,46 @@ export async function fileRoutes(app: FastifyInstance) {
|
||||
fileController.getDownloadUrl.bind(fileController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/embed/:id",
|
||||
{
|
||||
schema: {
|
||||
tags: ["File"],
|
||||
operationId: "embedFile",
|
||||
summary: "Embed File (Public Access)",
|
||||
description:
|
||||
"Returns a media file (image/video/audio) for public embedding without authentication. Only works for media files.",
|
||||
params: z.object({
|
||||
id: z.string().min(1, "File ID is required").describe("The file ID"),
|
||||
}),
|
||||
response: {
|
||||
400: z.object({ error: z.string().describe("Error message") }),
|
||||
403: z.object({ error: z.string().describe("Error message - not a media file") }),
|
||||
404: z.object({ error: z.string().describe("Error message") }),
|
||||
500: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
fileController.embedFile.bind(fileController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/files/download",
|
||||
{
|
||||
schema: {
|
||||
tags: ["File"],
|
||||
operationId: "downloadFile",
|
||||
summary: "Download File",
|
||||
description: "Downloads a file directly (returns file content)",
|
||||
querystring: z.object({
|
||||
objectName: z.string().min(1, "The objectName is required"),
|
||||
password: z.string().optional().describe("Share password if required"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
fileController.downloadFile.bind(fileController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/files",
|
||||
{
|
||||
|
@@ -84,7 +84,6 @@ export class FilesystemController {
|
||||
const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
|
||||
|
||||
if (result.isComplete) {
|
||||
provider.consumeUploadToken(token);
|
||||
reply.status(200).send({
|
||||
message: "File uploaded successfully",
|
||||
objectName: result.finalPath,
|
||||
@@ -104,7 +103,6 @@ export class FilesystemController {
|
||||
}
|
||||
} else {
|
||||
await this.uploadFileStream(request, provider, tokenData.objectName);
|
||||
provider.consumeUploadToken(token);
|
||||
reply.status(200).send({ message: "File uploaded successfully" });
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -271,8 +269,6 @@ export class FilesystemController {
|
||||
reply.header("Content-Length", fileSize);
|
||||
await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId);
|
||||
}
|
||||
|
||||
provider.consumeDownloadToken(token);
|
||||
} finally {
|
||||
this.memoryManager.endDownload(downloadId);
|
||||
}
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -192,13 +192,9 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
return `/api/filesystem/upload/${token}`;
|
||||
}
|
||||
|
||||
async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = Date.now() + expires * 1000;
|
||||
|
||||
this.downloadTokens.set(token, { objectName, expiresAt, fileName });
|
||||
|
||||
return `/api/filesystem/download/${token}`;
|
||||
async getPresignedGetUrl(objectName: string): Promise<string> {
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
return `/api/files/download?objectName=${encodedObjectName}`;
|
||||
}
|
||||
|
||||
async deleteObject(objectName: string): Promise<void> {
|
||||
@@ -636,13 +632,8 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
return { objectName: data.objectName, fileName: data.fileName };
|
||||
}
|
||||
|
||||
consumeUploadToken(token: string): void {
|
||||
this.uploadTokens.delete(token);
|
||||
}
|
||||
|
||||
consumeDownloadToken(token: string): void {
|
||||
this.downloadTokens.delete(token);
|
||||
}
|
||||
// Tokens are automatically cleaned up by cleanExpiredTokens() every 5 minutes
|
||||
// No need to manually consume tokens - allows reuse for previews, range requests, etc.
|
||||
|
||||
private async cleanupTempFile(tempPath: string): Promise<void> {
|
||||
try {
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -150,7 +150,9 @@
|
||||
"move": "نقل",
|
||||
"rename": "إعادة تسمية",
|
||||
"search": "بحث",
|
||||
"share": "مشاركة"
|
||||
"share": "مشاركة",
|
||||
"copied": "تم النسخ",
|
||||
"copy": "نسخ"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "إنشاء مشاركة",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "معاينة الملف",
|
||||
"description": "معاينة وتنزيل الملف",
|
||||
"loading": "جاري التحميل...",
|
||||
"notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.",
|
||||
"downloadToView": "استخدم زر التحميل لتنزيل الملف.",
|
||||
@@ -1932,5 +1935,17 @@
|
||||
"passwordRequired": "كلمة المرور مطلوبة",
|
||||
"nameRequired": "الاسم مطلوب",
|
||||
"required": "هذا الحقل مطلوب"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "تضمين الصورة",
|
||||
"description": "استخدم هذه الأكواد لتضمين هذه الصورة في المنتديات أو المواقع الإلكترونية أو المنصات الأخرى",
|
||||
"tabs": {
|
||||
"directLink": "رابط مباشر",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "عنوان URL مباشر لملف الصورة",
|
||||
"htmlDescription": "استخدم هذا الكود لتضمين الصورة في صفحات HTML",
|
||||
"bbcodeDescription": "استخدم هذا الكود لتضمين الصورة في المنتديات التي تدعم BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "Verschieben",
|
||||
"rename": "Umbenennen",
|
||||
"search": "Suchen",
|
||||
"share": "Teilen"
|
||||
"share": "Teilen",
|
||||
"copied": "Kopiert",
|
||||
"copy": "Kopieren"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Freigabe Erstellen",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Datei-Vorschau",
|
||||
"description": "Vorschau und Download der Datei",
|
||||
"loading": "Laden...",
|
||||
"notAvailable": "Vorschau für diesen Dateityp nicht verfügbar.",
|
||||
"downloadToView": "Verwenden Sie die Download-Schaltfläche, um die Datei herunterzuladen.",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
"nameRequired": "Name ist erforderlich",
|
||||
"required": "Dieses Feld ist erforderlich"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Bild einbetten",
|
||||
"description": "Verwenden Sie diese Codes, um dieses Bild in Foren, Websites oder anderen Plattformen einzubetten",
|
||||
"tabs": {
|
||||
"directLink": "Direkter Link",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Direkte URL zur Bilddatei",
|
||||
"htmlDescription": "Verwenden Sie diesen Code, um das Bild in HTML-Seiten einzubetten",
|
||||
"bbcodeDescription": "Verwenden Sie diesen Code, um das Bild in Foren einzubetten, die BBCode unterstützen"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"rename": "Rename",
|
||||
"move": "Move",
|
||||
"share": "Share",
|
||||
"search": "Search"
|
||||
"search": "Search",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Create Share",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Preview File",
|
||||
"description": "Preview and download file",
|
||||
"loading": "Loading...",
|
||||
"notAvailable": "Preview not available for this file type",
|
||||
"downloadToView": "Use the download button to view this file",
|
||||
@@ -1881,6 +1884,18 @@
|
||||
"userr": "User"
|
||||
}
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Embed Image",
|
||||
"description": "Use these codes to embed this image in forums, websites, or other platforms",
|
||||
"tabs": {
|
||||
"directLink": "Direct Link",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Direct URL to the image file",
|
||||
"htmlDescription": "Use this code to embed the image in HTML pages",
|
||||
"bbcodeDescription": "Use this code to embed the image in forums that support BBCode"
|
||||
},
|
||||
"validation": {
|
||||
"firstNameRequired": "First name is required",
|
||||
"lastNameRequired": "Last name is required",
|
||||
@@ -1896,4 +1911,4 @@
|
||||
"nameRequired": "Name is required",
|
||||
"required": "This field is required"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "Mover",
|
||||
"rename": "Renombrar",
|
||||
"search": "Buscar",
|
||||
"share": "Compartir"
|
||||
"share": "Compartir",
|
||||
"copied": "Copiado",
|
||||
"copy": "Copiar"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Crear Compartir",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Vista Previa del Archivo",
|
||||
"description": "Vista previa y descarga de archivo",
|
||||
"loading": "Cargando...",
|
||||
"notAvailable": "Vista previa no disponible para este tipo de archivo.",
|
||||
"downloadToView": "Use el botón de descarga para descargar el archivo.",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "Se requiere la contraseña",
|
||||
"nameRequired": "El nombre es obligatorio",
|
||||
"required": "Este campo es obligatorio"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Insertar imagen",
|
||||
"description": "Utiliza estos códigos para insertar esta imagen en foros, sitios web u otras plataformas",
|
||||
"tabs": {
|
||||
"directLink": "Enlace directo",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "URL directa al archivo de imagen",
|
||||
"htmlDescription": "Utiliza este código para insertar la imagen en páginas HTML",
|
||||
"bbcodeDescription": "Utiliza este código para insertar la imagen en foros que admiten BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "Déplacer",
|
||||
"rename": "Renommer",
|
||||
"search": "Rechercher",
|
||||
"share": "Partager"
|
||||
"share": "Partager",
|
||||
"copied": "Copié",
|
||||
"copy": "Copier"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Créer un Partage",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Aperçu du Fichier",
|
||||
"description": "Aperçu et téléchargement du fichier",
|
||||
"loading": "Chargement...",
|
||||
"notAvailable": "Aperçu non disponible pour ce type de fichier.",
|
||||
"downloadToView": "Utilisez le bouton de téléchargement pour télécharger le fichier.",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "Le mot de passe est requis",
|
||||
"nameRequired": "Nome é obrigatório",
|
||||
"required": "Este campo é obrigatório"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Intégrer l'image",
|
||||
"description": "Utilisez ces codes pour intégrer cette image dans des forums, sites web ou autres plateformes",
|
||||
"tabs": {
|
||||
"directLink": "Lien direct",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "URL directe vers le fichier image",
|
||||
"htmlDescription": "Utilisez ce code pour intégrer l'image dans des pages HTML",
|
||||
"bbcodeDescription": "Utilisez ce code pour intégrer l'image dans des forums prenant en charge BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "स्थानांतरित करें",
|
||||
"rename": "नाम बदलें",
|
||||
"search": "खोजें",
|
||||
"share": "साझा करें"
|
||||
"share": "साझा करें",
|
||||
"copied": "कॉपी किया गया",
|
||||
"copy": "कॉपी करें"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "साझाकरण बनाएं",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "फ़ाइल पूर्वावलोकन",
|
||||
"description": "फ़ाइल पूर्वावलोकन और डाउनलोड",
|
||||
"loading": "लोड हो रहा है...",
|
||||
"notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।",
|
||||
"downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "पासवर्ड आवश्यक है",
|
||||
"nameRequired": "नाम आवश्यक है",
|
||||
"required": "यह फ़ील्ड आवश्यक है"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "छवि एम्बेड करें",
|
||||
"description": "इस छवि को मंचों, वेबसाइटों या अन्य प्लेटफार्मों में एम्बेड करने के लिए इन कोड का उपयोग करें",
|
||||
"tabs": {
|
||||
"directLink": "सीधा लिंक",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "छवि फ़ाइल का सीधा URL",
|
||||
"htmlDescription": "HTML पेजों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें",
|
||||
"bbcodeDescription": "BBCode का समर्थन करने वाले मंचों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "Sposta",
|
||||
"rename": "Rinomina",
|
||||
"search": "Cerca",
|
||||
"share": "Condividi"
|
||||
"share": "Condividi",
|
||||
"copied": "Copiato",
|
||||
"copy": "Copia"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Crea Condivisione",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Anteprima File",
|
||||
"description": "Anteprima e download del file",
|
||||
"loading": "Caricamento...",
|
||||
"notAvailable": "Anteprima non disponibile per questo tipo di file.",
|
||||
"downloadToView": "Utilizzare il pulsante di download per scaricare il file.",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
|
||||
"nameRequired": "Il nome è obbligatorio",
|
||||
"required": "Questo campo è obbligatorio"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Incorpora immagine",
|
||||
"description": "Usa questi codici per incorporare questa immagine in forum, siti web o altre piattaforme",
|
||||
"tabs": {
|
||||
"directLink": "Link diretto",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "URL diretto al file immagine",
|
||||
"htmlDescription": "Usa questo codice per incorporare l'immagine nelle pagine HTML",
|
||||
"bbcodeDescription": "Usa questo codice per incorporare l'immagine nei forum che supportano BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "移動",
|
||||
"rename": "名前を変更",
|
||||
"search": "検索",
|
||||
"share": "共有"
|
||||
"share": "共有",
|
||||
"copied": "コピーしました",
|
||||
"copy": "コピー"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "共有を作成",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "ファイルプレビュー",
|
||||
"description": "ファイルをプレビューしてダウンロード",
|
||||
"loading": "読み込み中...",
|
||||
"notAvailable": "このファイルタイプのプレビューは利用できません。",
|
||||
"downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "パスワードは必須です",
|
||||
"nameRequired": "名前は必須です",
|
||||
"required": "このフィールドは必須です"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "画像を埋め込む",
|
||||
"description": "これらのコードを使用して、この画像をフォーラム、ウェブサイト、またはその他のプラットフォームに埋め込みます",
|
||||
"tabs": {
|
||||
"directLink": "直接リンク",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "画像ファイルへの直接URL",
|
||||
"htmlDescription": "このコードを使用してHTMLページに画像を埋め込みます",
|
||||
"bbcodeDescription": "BBCodeをサポートするフォーラムに画像を埋め込むには、このコードを使用します"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "이동",
|
||||
"rename": "이름 변경",
|
||||
"search": "검색",
|
||||
"share": "공유"
|
||||
"share": "공유",
|
||||
"copied": "복사됨",
|
||||
"copy": "복사"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "공유 생성",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "파일 미리보기",
|
||||
"description": "파일 미리보기 및 다운로드",
|
||||
"loading": "로딩 중...",
|
||||
"notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.",
|
||||
"downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "비밀번호는 필수입니다",
|
||||
"nameRequired": "이름은 필수입니다",
|
||||
"required": "이 필드는 필수입니다"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "이미지 삽입",
|
||||
"description": "이 코드를 사용하여 포럼, 웹사이트 또는 기타 플랫폼에 이 이미지를 삽입하세요",
|
||||
"tabs": {
|
||||
"directLink": "직접 링크",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "이미지 파일에 대한 직접 URL",
|
||||
"htmlDescription": "이 코드를 사용하여 HTML 페이지에 이미지를 삽입하세요",
|
||||
"bbcodeDescription": "BBCode를 지원하는 포럼에 이미지를 삽입하려면 이 코드를 사용하세요"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "Verplaatsen",
|
||||
"rename": "Hernoemen",
|
||||
"search": "Zoeken",
|
||||
"share": "Delen"
|
||||
"share": "Delen",
|
||||
"copied": "Gekopieerd",
|
||||
"copy": "Kopiëren"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Delen Maken",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Bestandsvoorbeeld",
|
||||
"description": "Bestand bekijken en downloaden",
|
||||
"loading": "Laden...",
|
||||
"notAvailable": "Voorbeeld niet beschikbaar voor dit bestandstype.",
|
||||
"downloadToView": "Gebruik de downloadknop om het bestand te downloaden.",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
|
||||
"nameRequired": "Naam is verplicht",
|
||||
"required": "Dit veld is verplicht"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Afbeelding insluiten",
|
||||
"description": "Gebruik deze codes om deze afbeelding in te sluiten in forums, websites of andere platforms",
|
||||
"tabs": {
|
||||
"directLink": "Directe link",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Directe URL naar het afbeeldingsbestand",
|
||||
"htmlDescription": "Gebruik deze code om de afbeelding in te sluiten in HTML-pagina's",
|
||||
"bbcodeDescription": "Gebruik deze code om de afbeelding in te sluiten in forums die BBCode ondersteunen"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "Przenieś",
|
||||
"rename": "Zmień nazwę",
|
||||
"search": "Szukaj",
|
||||
"share": "Udostępnij"
|
||||
"share": "Udostępnij",
|
||||
"copied": "Skopiowano",
|
||||
"copy": "Kopiuj"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Utwórz Udostępnienie",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Podgląd pliku",
|
||||
"description": "Podgląd i pobieranie pliku",
|
||||
"loading": "Ładowanie...",
|
||||
"notAvailable": "Podgląd niedostępny dla tego typu pliku",
|
||||
"downloadToView": "Użyj przycisku pobierania, aby wyświetlić ten plik",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
|
||||
"nameRequired": "Nazwa jest wymagana",
|
||||
"required": "To pole jest wymagane"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Osadź obraz",
|
||||
"description": "Użyj tych kodów, aby osadzić ten obraz na forach, stronach internetowych lub innych platformach",
|
||||
"tabs": {
|
||||
"directLink": "Link bezpośredni",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Bezpośredni adres URL pliku obrazu",
|
||||
"htmlDescription": "Użyj tego kodu, aby osadzić obraz na stronach HTML",
|
||||
"bbcodeDescription": "Użyj tego kodu, aby osadzić obraz na forach obsługujących BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "Mover",
|
||||
"rename": "Renomear",
|
||||
"search": "Pesquisar",
|
||||
"share": "Compartilhar"
|
||||
"share": "Compartilhar",
|
||||
"copied": "Copiado",
|
||||
"copy": "Copiar"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Criar compartilhamento",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Visualizar Arquivo",
|
||||
"description": "Visualizar e baixar arquivo",
|
||||
"loading": "Carregando...",
|
||||
"notAvailable": "Preview não disponível para este tipo de arquivo.",
|
||||
"downloadToView": "Use o botão de download para baixar o arquivo.",
|
||||
@@ -1931,5 +1934,17 @@
|
||||
"lastNameRequired": "O sobrenome é necessário",
|
||||
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
|
||||
"usernameSpaces": "O nome de usuário não pode conter espaços"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Incorporar imagem",
|
||||
"description": "Use estes códigos para incorporar esta imagem em fóruns, sites ou outras plataformas",
|
||||
"tabs": {
|
||||
"directLink": "Link direto",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "URL direto para o arquivo de imagem",
|
||||
"htmlDescription": "Use este código para incorporar a imagem em páginas HTML",
|
||||
"bbcodeDescription": "Use este código para incorporar a imagem em fóruns que suportam BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "Переместить",
|
||||
"rename": "Переименовать",
|
||||
"search": "Поиск",
|
||||
"share": "Поделиться"
|
||||
"share": "Поделиться",
|
||||
"copied": "Скопировано",
|
||||
"copy": "Копировать"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Создать общий доступ",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Предварительный просмотр файла",
|
||||
"description": "Просмотр и загрузка файла",
|
||||
"loading": "Загрузка...",
|
||||
"notAvailable": "Предварительный просмотр недоступен для этого типа файла.",
|
||||
"downloadToView": "Используйте кнопку загрузки для скачивания файла.",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "Требуется пароль",
|
||||
"nameRequired": "Требуется имя",
|
||||
"required": "Это поле обязательно"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Встроить изображение",
|
||||
"description": "Используйте эти коды для встраивания этого изображения на форумах, веб-сайтах или других платформах",
|
||||
"tabs": {
|
||||
"directLink": "Прямая ссылка",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Прямой URL-адрес файла изображения",
|
||||
"htmlDescription": "Используйте этот код для встраивания изображения в HTML-страницы",
|
||||
"bbcodeDescription": "Используйте этот код для встраивания изображения на форумах, поддерживающих BBCode"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "Taşı",
|
||||
"rename": "Yeniden Adlandır",
|
||||
"search": "Ara",
|
||||
"share": "Paylaş"
|
||||
"share": "Paylaş",
|
||||
"copied": "Kopyalandı",
|
||||
"copy": "Kopyala"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Paylaşım Oluştur",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Dosya Önizleme",
|
||||
"description": "Dosyayı önizleyin ve indirin",
|
||||
"loading": "Yükleniyor...",
|
||||
"notAvailable": "Bu dosya türü için önizleme mevcut değil.",
|
||||
"downloadToView": "Dosyayı indirmek için indirme düğmesini kullanın.",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "Şifre gerekli",
|
||||
"nameRequired": "İsim gereklidir",
|
||||
"required": "Bu alan zorunludur"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Resmi Yerleştir",
|
||||
"description": "Bu görüntüyü forumlara, web sitelerine veya diğer platformlara yerleştirmek için bu kodları kullanın",
|
||||
"tabs": {
|
||||
"directLink": "Doğrudan Bağlantı",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Resim dosyasının doğrudan URL'si",
|
||||
"htmlDescription": "Resmi HTML sayfalarına yerleştirmek için bu kodu kullanın",
|
||||
"bbcodeDescription": "BBCode destekleyen forumlara resmi yerleştirmek için bu kodu kullanın"
|
||||
}
|
||||
}
|
||||
}
|
@@ -150,7 +150,9 @@
|
||||
"move": "移动",
|
||||
"rename": "重命名",
|
||||
"search": "搜索",
|
||||
"share": "分享"
|
||||
"share": "分享",
|
||||
"copied": "已复制",
|
||||
"copy": "复制"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "创建分享",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "文件预览",
|
||||
"description": "预览和下载文件",
|
||||
"loading": "加载中...",
|
||||
"notAvailable": "此文件类型不支持预览。",
|
||||
"downloadToView": "使用下载按钮下载文件。",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "密码为必填项",
|
||||
"nameRequired": "名称为必填项",
|
||||
"required": "此字段为必填项"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "嵌入图片",
|
||||
"description": "使用这些代码将此图片嵌入到论坛、网站或其他平台中",
|
||||
"tabs": {
|
||||
"directLink": "直接链接",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "图片文件的直接URL",
|
||||
"htmlDescription": "使用此代码将图片嵌入HTML页面",
|
||||
"bbcodeDescription": "使用此代码将图片嵌入支持BBCode的论坛"
|
||||
}
|
||||
}
|
||||
}
|
@@ -900,16 +900,7 @@ export function ReceivedFilesModal({
|
||||
</Dialog>
|
||||
|
||||
{previewFile && (
|
||||
<ReverseShareFilePreviewModal
|
||||
isOpen={!!previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
file={{
|
||||
id: previewFile.id,
|
||||
name: previewFile.name,
|
||||
objectName: previewFile.objectName,
|
||||
extension: previewFile.extension,
|
||||
}}
|
||||
/>
|
||||
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@@ -7,23 +7,11 @@ import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { deleteReverseShareFile } from "@/http/endpoints/reverse-shares";
|
||||
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
|
||||
import { downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
|
||||
|
||||
interface ReverseShareFile {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
extension: string;
|
||||
size: string;
|
||||
objectName: string;
|
||||
uploaderEmail: string | null;
|
||||
uploaderName: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ReceivedFilesSectionProps {
|
||||
files: ReverseShareFile[];
|
||||
onFileDeleted?: () => void;
|
||||
@@ -159,16 +147,7 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
|
||||
</div>
|
||||
|
||||
{previewFile && (
|
||||
<ReverseShareFilePreviewModal
|
||||
isOpen={!!previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
file={{
|
||||
id: previewFile.id,
|
||||
name: previewFile.name,
|
||||
objectName: previewFile.objectName,
|
||||
extension: previewFile.extension,
|
||||
}}
|
||||
/>
|
||||
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@@ -1,26 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
|
||||
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
|
||||
|
||||
interface ReverseShareFilePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
file: {
|
||||
id: string;
|
||||
name: string;
|
||||
objectName: string;
|
||||
extension?: string;
|
||||
} | null;
|
||||
file: ReverseShareFile | null;
|
||||
}
|
||||
|
||||
export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) {
|
||||
if (!file) return null;
|
||||
|
||||
const adaptedFile = {
|
||||
name: file.name,
|
||||
objectName: file.objectName,
|
||||
type: file.extension,
|
||||
id: file.id,
|
||||
...file,
|
||||
description: file.description ?? undefined,
|
||||
};
|
||||
|
||||
return <FilePreviewModal isOpen={isOpen} onClose={onClose} file={adaptedFile} isReverseShare={true} />;
|
||||
|
@@ -30,7 +30,7 @@ export function ReverseSharesSearch({
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("reverseShares.search.title")}</h2>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<Button variant="outline" size="icon" onClick={onRefresh} disabled={isRefreshing} className="sm:w-auto">
|
||||
<Button variant="outline" size="icon" onClick={onRefresh} disabled={isRefreshing}>
|
||||
<IconRefresh className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
<Button onClick={onCreateReverseShare} className="w-full sm:w-auto">
|
||||
|
38
apps/web/src/app/api/(proxy)/files/download-url/route.ts
Normal file
38
apps/web/src/app/api/(proxy)/files/download-url/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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 searchParams = req.nextUrl.searchParams;
|
||||
const objectName = searchParams.get("objectName");
|
||||
|
||||
if (!objectName) {
|
||||
return new NextResponse(JSON.stringify({ error: "objectName parameter is required" }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Forward all query params to backend
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${API_BASE_URL}/files/download-url?${queryString}`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await apiRes.json();
|
||||
|
||||
return new NextResponse(JSON.stringify(data), {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
@@ -4,13 +4,22 @@ import { detectMimeTypeWithFallback } from "@/utils/mime-types";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ objectPath: string[] }> }) {
|
||||
const { objectPath } = await params;
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const objectName = objectPath.join("/");
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const objectName = searchParams.get("objectName");
|
||||
|
||||
if (!objectName) {
|
||||
return new NextResponse(JSON.stringify({ error: "objectName parameter is required" }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${API_BASE_URL}/files/${encodeURIComponent(objectName)}/download${queryString ? `?${queryString}` : ""}`;
|
||||
const url = `${API_BASE_URL}/files/download?${queryString}`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
|
||||
// Get the multipart form data directly
|
||||
const formData = await req.formData();
|
||||
|
||||
const url = `${API_BASE_URL}/shares/create-with-files`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
// Don't set Content-Type, let fetch set it with boundary
|
||||
},
|
||||
body: formData,
|
||||
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;
|
||||
}
|
71
apps/web/src/app/e/[id]/route.ts
Normal file
71
apps/web/src/app/e/[id]/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
/**
|
||||
* Short public embed endpoint: /e/{id}
|
||||
* No authentication required
|
||||
* Only works for media files (images, videos, audio)
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
return new NextResponse(JSON.stringify({ error: "File ID is required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const url = `${API_BASE_URL}/embed/${id}`;
|
||||
|
||||
try {
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (!apiRes.ok) {
|
||||
const errorText = await apiRes.text();
|
||||
return new NextResponse(errorText, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const blob = await apiRes.blob();
|
||||
|
||||
const contentType = apiRes.headers.get("content-type") || "application/octet-stream";
|
||||
const contentDisposition = apiRes.headers.get("content-disposition");
|
||||
const cacheControl = apiRes.headers.get("cache-control");
|
||||
|
||||
const res = new NextResponse(blob, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
});
|
||||
|
||||
if (contentDisposition) {
|
||||
res.headers.set("Content-Disposition", contentDisposition);
|
||||
}
|
||||
|
||||
if (cacheControl) {
|
||||
res.headers.set("Cache-Control", cacheControl);
|
||||
} else {
|
||||
res.headers.set("Cache-Control", "public, max-age=31536000");
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error("Error proxying embed request:", error);
|
||||
return new NextResponse(JSON.stringify({ error: "Failed to fetch file" }), {
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
151
apps/web/src/components/files/embed-code-display.tsx
Normal file
151
apps/web/src/components/files/embed-code-display.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface EmbedCodeDisplayProps {
|
||||
imageUrl: string;
|
||||
fileName: string;
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
export function EmbedCodeDisplay({ imageUrl, fileName, fileId }: EmbedCodeDisplayProps) {
|
||||
const t = useTranslations();
|
||||
const [copiedType, setCopiedType] = useState<string | null>(null);
|
||||
const [fullUrl, setFullUrl] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const origin = window.location.origin;
|
||||
const embedUrl = `${origin}/e/${fileId}`;
|
||||
setFullUrl(embedUrl);
|
||||
}
|
||||
}, [fileId]);
|
||||
|
||||
const directLink = fullUrl || imageUrl;
|
||||
const htmlCode = `<img src="${directLink}" alt="${fileName}" />`;
|
||||
const bbCode = `[img]${directLink}[/img]`;
|
||||
|
||||
const copyToClipboard = async (text: string, type: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedType(type);
|
||||
setTimeout(() => setCopiedType(null), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">{t("embedCode.title")}</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("embedCode.description")}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="direct" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="direct" className="cursor-pointer">
|
||||
{t("embedCode.tabs.directLink")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="html" className="cursor-pointer">
|
||||
{t("embedCode.tabs.html")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="bbcode" className="cursor-pointer">
|
||||
{t("embedCode.tabs.bbcode")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="direct" className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={directLink}
|
||||
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
|
||||
/>
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(directLink, "direct")}
|
||||
className="shrink-0 h-full"
|
||||
>
|
||||
{copiedType === "direct" ? (
|
||||
<>
|
||||
<IconCheck className="h-4 w-4 mr-1" />
|
||||
{t("common.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCopy className="h-4 w-4 mr-1" />
|
||||
{t("common.copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("embedCode.directLinkDescription")}</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="html" className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={htmlCode}
|
||||
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
|
||||
/>
|
||||
<Button variant="outline" onClick={() => copyToClipboard(htmlCode, "html")} className="shrink-0 h-full">
|
||||
{copiedType === "html" ? (
|
||||
<>
|
||||
<IconCheck className="h-4 w-4 mr-1" />
|
||||
{t("common.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCopy className="h-4 w-4 mr-1" />
|
||||
{t("common.copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("embedCode.htmlDescription")}</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bbcode" className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={bbCode}
|
||||
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
|
||||
/>
|
||||
<Button variant="outline" onClick={() => copyToClipboard(bbCode, "bbcode")} className="shrink-0 h-full">
|
||||
{copiedType === "bbcode" ? (
|
||||
<>
|
||||
<IconCheck className="h-4 w-4 mr-1" />
|
||||
{t("common.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCopy className="h-4 w-4 mr-1" />
|
||||
{t("common.copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("embedCode.bbcodeDescription")}</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
72
apps/web/src/components/files/media-embed-link.tsx
Normal file
72
apps/web/src/components/files/media-embed-link.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface MediaEmbedLinkProps {
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
export function MediaEmbedLink({ fileId }: MediaEmbedLinkProps) {
|
||||
const t = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [embedUrl, setEmbedUrl] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const origin = window.location.origin;
|
||||
const url = `${origin}/e/${fileId}`;
|
||||
setEmbedUrl(url);
|
||||
}
|
||||
}, [fileId]);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(embedUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">{t("embedCode.title")}</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("embedCode.directLinkDescription")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={embedUrl}
|
||||
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
|
||||
/>
|
||||
<Button size="default" variant="outline" onClick={copyToClipboard} className="shrink-0 h-full">
|
||||
{copied ? (
|
||||
<>
|
||||
<IconCheck className="h-4 w-4 mr-1" />
|
||||
{t("common.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCopy className="h-4 w-4 mr-1" />
|
||||
{t("common.copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { IconCalendar, IconEye, IconLock, IconShare } from "@tabler/icons-react";
|
||||
import { IconCalendar, IconEye, IconLock, IconShare, IconUpload, IconX } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FileTree, TreeFile, TreeFolder } from "@/components/tables/files-tree";
|
||||
@@ -13,7 +14,8 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createShare } from "@/http/endpoints";
|
||||
import { createShare, createShareWithFiles } from "@/http/endpoints";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
|
||||
interface CreateShareModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -39,6 +41,7 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
|
||||
const [files, setFiles] = useState<TreeFile[]>([]);
|
||||
const [folders, setFolders] = useState<TreeFolder[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [newFiles, setNewFiles] = useState<File[]>([]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(false);
|
||||
@@ -85,18 +88,35 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
|
||||
maxViews: "",
|
||||
});
|
||||
setSelectedItems([]);
|
||||
setNewFiles([]);
|
||||
setCurrentTab("details");
|
||||
}
|
||||
}, [isOpen, loadData]);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
setNewFiles((prev) => [...prev, ...acceptedFiles]);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
const removeNewFile = (index: number) => {
|
||||
setNewFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error("Share name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
toast.error("Please select at least one file or folder");
|
||||
const hasExistingItems = selectedItems.length > 0;
|
||||
const hasNewFiles = newFiles.length > 0;
|
||||
|
||||
if (!hasExistingItems && !hasNewFiles) {
|
||||
toast.error("Please select at least one file/folder or upload new files");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,23 +126,40 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
|
||||
const selectedFiles = selectedItems.filter((id) => files.some((file) => file.id === id));
|
||||
const selectedFolders = selectedItems.filter((id) => folders.some((folder) => folder.id === id));
|
||||
|
||||
await createShare({
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
password: formData.isPasswordProtected ? formData.password : undefined,
|
||||
expiration: formData.expiresAt
|
||||
? (() => {
|
||||
const dateValue = formData.expiresAt;
|
||||
if (dateValue.length === 10) {
|
||||
return new Date(dateValue + "T23:59:59").toISOString();
|
||||
}
|
||||
return new Date(dateValue).toISOString();
|
||||
})()
|
||||
: undefined,
|
||||
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
|
||||
files: selectedFiles,
|
||||
folders: selectedFolders,
|
||||
});
|
||||
const expiration = formData.expiresAt
|
||||
? (() => {
|
||||
const dateValue = formData.expiresAt;
|
||||
if (dateValue.length === 10) {
|
||||
return new Date(dateValue + "T23:59:59").toISOString();
|
||||
}
|
||||
return new Date(dateValue).toISOString();
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
// Use the new endpoint if there are new files to upload
|
||||
if (hasNewFiles) {
|
||||
await createShareWithFiles({
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
password: formData.isPasswordProtected ? formData.password : undefined,
|
||||
expiration: expiration,
|
||||
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
|
||||
existingFiles: selectedFiles.length > 0 ? selectedFiles : undefined,
|
||||
existingFolders: selectedFolders.length > 0 ? selectedFolders : undefined,
|
||||
newFiles: newFiles,
|
||||
});
|
||||
} else {
|
||||
// Use the traditional endpoint if only selecting existing files
|
||||
await createShare({
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
password: formData.isPasswordProtected ? formData.password : undefined,
|
||||
expiration: expiration,
|
||||
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
|
||||
files: selectedFiles,
|
||||
folders: selectedFolders,
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(t("createShare.success"));
|
||||
onSuccess();
|
||||
@@ -146,8 +183,10 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
|
||||
};
|
||||
|
||||
const selectedCount = selectedItems.length;
|
||||
const newFilesCount = newFiles.length;
|
||||
const totalCount = selectedCount + newFilesCount;
|
||||
const canProceedToFiles = formData.name.trim().length > 0;
|
||||
const canSubmit = formData.name.trim().length > 0 && selectedCount > 0;
|
||||
const canSubmit = formData.name.trim().length > 0 && totalCount > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
@@ -161,7 +200,7 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
|
||||
|
||||
<div className="flex flex-col gap-6 flex-1 min-h-0 w-full overflow-hidden">
|
||||
<Tabs value={currentTab} onValueChange={setCurrentTab} className="flex-1">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="details">{t("createShare.tabs.shareDetails")}</TabsTrigger>
|
||||
<TabsTrigger value="files" disabled={!canProceedToFiles}>
|
||||
{t("createShare.tabs.selectFiles")}
|
||||
@@ -171,6 +210,14 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upload" disabled={!canProceedToFiles}>
|
||||
{t("createShare.tabs.uploadFiles") || "Upload Files"}
|
||||
{newFilesCount > 0 && (
|
||||
<span className="ml-1 text-xs bg-primary text-primary-foreground rounded-full px-2 py-0.5">
|
||||
{newFilesCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4 mt-4">
|
||||
@@ -320,6 +367,87 @@ export function CreateShareModal({ isOpen, onClose, onSuccess, getAllFilesAndFol
|
||||
<Button variant="outline" onClick={() => setCurrentTab("details")}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" onClick={() => setCurrentTab("upload")}>
|
||||
{t("createShare.nextUploadFiles") || "Upload New Files"}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit || isLoading}>
|
||||
{isLoading ? t("common.creating") : t("createShare.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upload" className="space-y-4 mt-4 flex-1 min-h-0">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<IconUpload className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
{isDragActive ? (
|
||||
<p className="text-sm text-muted-foreground">Drop files here...</p>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">
|
||||
{t("createShare.upload.dragDrop") || "Drag & drop files here"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("createShare.upload.orClick") || "or click to browse"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{newFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("createShare.upload.selectedFiles") || "Selected Files"}</Label>
|
||||
<div className="max-h-[200px] overflow-y-auto space-y-2">
|
||||
{newFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 bg-muted/50 rounded-md text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<IconUpload className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeNewFile(index)}
|
||||
className="ml-2 h-6 w-6 p-0"
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{totalCount > 0 ? (
|
||||
<span>
|
||||
{totalCount} {totalCount === 1 ? "item" : "items"} selected ({selectedCount} existing,{" "}
|
||||
{newFilesCount} new)
|
||||
</span>
|
||||
) : (
|
||||
<span>No items selected</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={() => setCurrentTab("files")}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.cancel")}
|
||||
|
@@ -3,10 +3,20 @@
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { EmbedCodeDisplay } from "@/components/files/embed-code-display";
|
||||
import { MediaEmbedLink } from "@/components/files/media-embed-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useFilePreview } from "@/hooks/use-file-preview";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
import { getFileType } from "@/utils/file-types";
|
||||
import { FilePreviewRenderer } from "./previews";
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
@@ -32,6 +42,10 @@ export function FilePreviewModal({
|
||||
}: FilePreviewModalProps) {
|
||||
const t = useTranslations();
|
||||
const previewState = useFilePreview({ file, isOpen, isReverseShare, sharePassword });
|
||||
const fileType = getFileType(file.name);
|
||||
const isImage = fileType === "image";
|
||||
const isVideo = fileType === "video";
|
||||
const isAudio = fileType === "audio";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
@@ -44,6 +58,7 @@ export function FilePreviewModal({
|
||||
})()}
|
||||
<span className="truncate">{file.name}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">{t("filePreview.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<FilePreviewRenderer
|
||||
@@ -59,6 +74,16 @@ export function FilePreviewModal({
|
||||
description={file.description}
|
||||
onDownload={previewState.handleDownload}
|
||||
/>
|
||||
{isImage && previewState.previewUrl && !previewState.isLoading && file.id && (
|
||||
<div className="mt-4 mb-2">
|
||||
<EmbedCodeDisplay imageUrl={previewState.previewUrl} fileName={file.name} fileId={file.id} />
|
||||
</div>
|
||||
)}
|
||||
{(isVideo || isAudio) && !previewState.isLoading && file.id && (
|
||||
<div className="mt-4 mb-2">
|
||||
<MediaEmbedLink fileId={file.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
|
@@ -163,8 +163,7 @@ export function FilesGrid({
|
||||
|
||||
try {
|
||||
loadingUrls.current.add(file.objectName);
|
||||
const encodedObjectName = encodeURIComponent(file.objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
const response = await getDownloadUrl(file.objectName);
|
||||
|
||||
if (!componentMounted.current) break;
|
||||
|
||||
|
@@ -187,8 +187,7 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
|
||||
|
||||
let url = downloadUrl;
|
||||
if (!url) {
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
const response = await getDownloadUrl(objectName);
|
||||
url = response.data.url;
|
||||
}
|
||||
|
||||
|
@@ -181,12 +181,11 @@ export function useFilePreview({ file, isOpen, isReverseShare = false, sharePass
|
||||
const response = await downloadReverseShareFile(file.id!);
|
||||
url = response.data.url;
|
||||
} else {
|
||||
const encodedObjectName = encodeURIComponent(file.objectName);
|
||||
const params: Record<string, string> = {};
|
||||
if (sharePassword) params.password = sharePassword;
|
||||
|
||||
const response = await getDownloadUrl(
|
||||
encodedObjectName,
|
||||
file.objectName,
|
||||
Object.keys(params).length > 0
|
||||
? {
|
||||
params: { ...params },
|
||||
|
@@ -80,7 +80,8 @@ export const getDownloadUrl = <TData = GetDownloadUrlResult>(
|
||||
objectName: string,
|
||||
options?: AxiosRequestConfig
|
||||
): Promise<TData> => {
|
||||
return apiInstance.get(`/api/files/download/${objectName}`, options);
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
return apiInstance.get(`/api/files/download-url?objectName=${encodedObjectName}`, options);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@@ -10,6 +10,8 @@ import type {
|
||||
CreateShareAliasResult,
|
||||
CreateShareBody,
|
||||
CreateShareResult,
|
||||
CreateShareWithFilesBody,
|
||||
CreateShareWithFilesResult,
|
||||
DeleteShareResult,
|
||||
GetShareByAliasParams,
|
||||
GetShareByAliasResult,
|
||||
@@ -39,6 +41,63 @@ export const createShare = <TData = CreateShareResult>(
|
||||
return apiInstance.post(`/api/shares/create`, createShareBody, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new share with file uploads
|
||||
* @summary Create a new share and upload files in a single action
|
||||
*/
|
||||
export const createShareWithFiles = <TData = CreateShareWithFilesResult>(
|
||||
createShareWithFilesBody: CreateShareWithFilesBody,
|
||||
options?: AxiosRequestConfig
|
||||
): Promise<TData> => {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add text fields
|
||||
if (createShareWithFilesBody.name) {
|
||||
formData.append("name", createShareWithFilesBody.name);
|
||||
}
|
||||
if (createShareWithFilesBody.description) {
|
||||
formData.append("description", createShareWithFilesBody.description);
|
||||
}
|
||||
if (createShareWithFilesBody.expiration) {
|
||||
formData.append("expiration", createShareWithFilesBody.expiration);
|
||||
}
|
||||
if (createShareWithFilesBody.password) {
|
||||
formData.append("password", createShareWithFilesBody.password);
|
||||
}
|
||||
if (createShareWithFilesBody.maxViews !== undefined && createShareWithFilesBody.maxViews !== null) {
|
||||
formData.append("maxViews", createShareWithFilesBody.maxViews.toString());
|
||||
}
|
||||
if (createShareWithFilesBody.folderId !== undefined) {
|
||||
formData.append("folderId", createShareWithFilesBody.folderId || "");
|
||||
}
|
||||
|
||||
// Add array fields as JSON strings
|
||||
if (createShareWithFilesBody.existingFiles && createShareWithFilesBody.existingFiles.length > 0) {
|
||||
formData.append("existingFiles", JSON.stringify(createShareWithFilesBody.existingFiles));
|
||||
}
|
||||
if (createShareWithFilesBody.existingFolders && createShareWithFilesBody.existingFolders.length > 0) {
|
||||
formData.append("existingFolders", JSON.stringify(createShareWithFilesBody.existingFolders));
|
||||
}
|
||||
if (createShareWithFilesBody.recipients && createShareWithFilesBody.recipients.length > 0) {
|
||||
formData.append("recipients", JSON.stringify(createShareWithFilesBody.recipients));
|
||||
}
|
||||
|
||||
// Add files
|
||||
if (createShareWithFilesBody.newFiles && createShareWithFilesBody.newFiles.length > 0) {
|
||||
createShareWithFilesBody.newFiles.forEach((file) => {
|
||||
formData.append("files", file);
|
||||
});
|
||||
}
|
||||
|
||||
return apiInstance.post(`/api/shares/create-with-files`, formData, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a share
|
||||
* @summary Update a share
|
||||
|
@@ -139,6 +139,19 @@ export interface CreateShareBody {
|
||||
recipients?: string[];
|
||||
}
|
||||
|
||||
export interface CreateShareWithFilesBody {
|
||||
name?: string;
|
||||
description?: string;
|
||||
expiration?: string;
|
||||
existingFiles?: string[];
|
||||
existingFolders?: string[];
|
||||
password?: string;
|
||||
maxViews?: number | null;
|
||||
recipients?: string[];
|
||||
folderId?: string | null;
|
||||
newFiles?: File[];
|
||||
}
|
||||
|
||||
export interface UpdateShareBody {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -186,6 +199,7 @@ export interface GetShareByAliasParams {
|
||||
}
|
||||
|
||||
export type CreateShareResult = AxiosResponse<CreateShare201>;
|
||||
export type CreateShareWithFilesResult = AxiosResponse<CreateShare201>;
|
||||
export type UpdateShareResult = AxiosResponse<UpdateShare200>;
|
||||
export type ListUserSharesResult = AxiosResponse<ListUserShares200>;
|
||||
export type GetShareResult = AxiosResponse<GetShare200>;
|
||||
|
@@ -21,8 +21,7 @@ async function waitForDownloadReady(objectName: string, fileName: string): Promi
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
const response = await getDownloadUrl(objectName);
|
||||
|
||||
if (response.status !== 202) {
|
||||
return response.data.url;
|
||||
@@ -98,13 +97,12 @@ export async function downloadFileWithQueue(
|
||||
options.onStart?.(downloadId);
|
||||
}
|
||||
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
|
||||
// getDownloadUrl already handles encoding
|
||||
const params: Record<string, string> = {};
|
||||
if (sharePassword) params.password = sharePassword;
|
||||
|
||||
const response = await getDownloadUrl(
|
||||
encodedObjectName,
|
||||
objectName,
|
||||
Object.keys(params).length > 0
|
||||
? {
|
||||
params: { ...params },
|
||||
@@ -208,13 +206,12 @@ export async function downloadFileAsBlobWithQueue(
|
||||
downloadUrl = response.data.url;
|
||||
}
|
||||
} else {
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
|
||||
// getDownloadUrl already handles encoding
|
||||
const params: Record<string, string> = {};
|
||||
if (sharePassword) params.password = sharePassword;
|
||||
|
||||
const response = await getDownloadUrl(
|
||||
encodedObjectName,
|
||||
objectName,
|
||||
Object.keys(params).length > 0
|
||||
? {
|
||||
params: { ...params },
|
||||
|
Reference in New Issue
Block a user