mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-24 00:23:39 +00:00
Compare commits
5 Commits
copilot/ad
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7d38b3e70 | ||
|
|
1eeafaf377 | ||
|
|
f9f20462ef | ||
|
|
07f4485ddf | ||
|
|
cb4ed3f581 |
@@ -67,7 +67,7 @@ Choose your storage method based on your needs:
|
||||
# - ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
|
||||
# - PALMR_UID=1000 # UID for the container processes (default is 1000)
|
||||
# - PALMR_GID=1000 # GID for the container processes (default is 1000)
|
||||
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||
# - SECURE_SITE=false # Set to true for HTTPS/reverse proxy (enables cross-origin cookies for Safari)
|
||||
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
|
||||
# - PRESIGNED_URL_EXPIRATION=3600 # Duration in seconds for presigned URL expiration (optional, defaults to 3600 seconds / 1 hour)
|
||||
# - DOWNLOAD_MAX_CONCURRENT=5 # Maximum simultaneous downloads (auto-scales if not set)
|
||||
@@ -122,7 +122,7 @@ Choose your storage method based on your needs:
|
||||
# - ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
|
||||
# - PALMR_UID=1000 # UID for the container processes (default is 1000)
|
||||
# - PALMR_GID=1000 # GID for the container processes (default is 1000)
|
||||
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||
# - SECURE_SITE=false # Set to true for HTTPS/reverse proxy (enables cross-origin cookies for Safari)
|
||||
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
|
||||
# - PRESIGNED_URL_EXPIRATION=3600 # Duration in seconds for presigned URL expiration (optional, defaults to 3600 seconds / 1 hour)
|
||||
# - DOWNLOAD_MAX_CONCURRENT=5 # Maximum simultaneous downloads (auto-scales if not set)
|
||||
@@ -168,7 +168,7 @@ Customize Palmr's behavior with these environment variables:
|
||||
| `DISABLE_FILESYSTEM_ENCRYPTION` | `true` | Disable file encryption for better performance (set to `false` to enable encryption) |
|
||||
| `PRESIGNED_URL_EXPIRATION` | `3600` | Duration in seconds for presigned URL expiration (applies to both filesystem and S3 storage) |
|
||||
| `CUSTOM_PATH` | - | Custom base path for disk space detection in manual installations with symlinks |
|
||||
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy deployments |
|
||||
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy deployments. Required for Safari cross-site tracking compatibility when frontend and backend are on different domains |
|
||||
| `DEFAULT_LANGUAGE` | `en-US` | Default application language ([see available languages](/docs/3.2-beta/available-languages)) |
|
||||
| `PALMR_UID` | `1000` | User ID for container processes (helps with file permissions) |
|
||||
| `PALMR_GID` | `1000` | Group ID for container processes (helps with file permissions) |
|
||||
@@ -238,7 +238,7 @@ Prefer Docker commands over Compose? Here are the equivalent commands:
|
||||
# -e ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
|
||||
# -e PALMR_UID=1000 # UID for the container processes (default is 1000)
|
||||
# -e PALMR_GID=1000 # GID for the container processes (default is 1000)
|
||||
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||
# -e SECURE_SITE=false # Set to true for HTTPS/reverse proxy (enables cross-origin cookies for Safari)
|
||||
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
|
||||
-p 5487:5487 \
|
||||
-p 3333:3333 \
|
||||
@@ -265,7 +265,7 @@ Prefer Docker commands over Compose? Here are the equivalent commands:
|
||||
# -e ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
|
||||
# -e PALMR_UID=1000 # UID for the container processes (default is 1000)
|
||||
# -e PALMR_GID=1000 # GID for the container processes (default is 1000)
|
||||
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||
# -e SECURE_SITE=false # Set to true for HTTPS/reverse proxy (enables cross-origin cookies for Safari)
|
||||
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
|
||||
-p 5487:5487 \
|
||||
-p 3333:3333 \
|
||||
|
||||
@@ -17,8 +17,10 @@ The `SECURE_SITE` variable configures how Palmr. handles authentication cookies
|
||||
|
||||
| Value | Cookie Settings | Use Case |
|
||||
| ------- | ------------------------------------- | ----------------------------------- |
|
||||
| `true` | `secure: true`, `sameSite: "lax"` | HTTPS/Production with reverse proxy |
|
||||
| `false` | `secure: false`, `sameSite: "strict"` | HTTP/Development (default) |
|
||||
| `true` | `secure: true`, `sameSite: "none"` | HTTPS/Production with reverse proxy |
|
||||
| `false` | `secure: false`, `sameSite: "lax"` | HTTP/Development (default) |
|
||||
|
||||
> **🔒 Safari Cross-Site Tracking**: When `SECURE_SITE=true`, cookies use `sameSite: "none"` to support Safari's Cross-Site Tracking prevention when the frontend and backend are on different domains/subdomains.
|
||||
|
||||
### When to Use SECURE_SITE=true
|
||||
|
||||
|
||||
@@ -194,6 +194,45 @@ docker exec palmr stat /app/server/uploads/your-file.txt
|
||||
|
||||
See our [OIDC Configuration Guide](/docs/3.0-beta/oidc-authentication) for detailed setup.
|
||||
|
||||
### Safari: Images Don't Render and Downloads Are Corrupted
|
||||
|
||||
**Symptoms:**
|
||||
- Images show as broken/loading icon in Safari
|
||||
- Downloaded files are corrupted
|
||||
- Works fine on localhost but fails on production domain
|
||||
- Only affects Safari with "Cross-Site Tracking Prevention" enabled
|
||||
|
||||
**Cause:**
|
||||
Safari blocks cookies when the frontend and backend are on different domains/subdomains due to Cross-Site Tracking prevention.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. **Enable secure cookies in your server `.env`:**
|
||||
|
||||
```bash
|
||||
SECURE_SITE=true
|
||||
```
|
||||
|
||||
2. **Ensure HTTPS is enabled:**
|
||||
|
||||
The `sameSite: none` cookie attribute requires HTTPS. Make sure your reverse proxy (nginx, Traefik, etc.) is configured with SSL/TLS.
|
||||
|
||||
3. **Restart the server:**
|
||||
|
||||
```bash
|
||||
docker-compose down && docker-compose up -d
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Check browser dev tools → Application → Cookies
|
||||
- Look for the `token` cookie with:
|
||||
- ✅ `Secure` flag enabled
|
||||
- ✅ `SameSite=None`
|
||||
- ✅ `HttpOnly` flag enabled
|
||||
|
||||
> **💡 Note**: This requires HTTPS. If using HTTP in development, keep `SECURE_SITE=false`.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Network Issues
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-docs",
|
||||
"version": "3.2.4-beta",
|
||||
"version": "3.2.5-beta",
|
||||
"description": "Docs for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
||||
@@ -4,6 +4,9 @@ DISABLE_FILESYSTEM_ENCRYPTION=true
|
||||
# ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # Required only if encryption is enabled (DISABLE_FILESYSTEM_ENCRYPTION=false)
|
||||
DATABASE_URL="file:./palmr.db"
|
||||
|
||||
# SECURITY SETTINGS
|
||||
# SECURE_SITE=true # Set to true when using HTTPS in production. This enables secure cookies with SameSite=none, allowing cross-origin requests (required when frontend and backend are on different domains/subdomains)
|
||||
|
||||
# FOR USE WITH S3 COMPATIBLE STORAGE
|
||||
# ENABLE_S3=true
|
||||
# S3_ENDPOINT=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-api",
|
||||
"version": "3.2.4-beta",
|
||||
"version": "3.2.5-beta",
|
||||
"description": "API for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
||||
@@ -124,7 +124,7 @@ export class AuthProvidersController {
|
||||
reply.setCookie("token", token, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: "lax",
|
||||
sameSite: isSecure ? "none" : "lax",
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ export class AuthController {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
secure: env.SECURE_SITE === "true" ? true : false,
|
||||
sameSite: env.SECURE_SITE === "true" ? "lax" : "strict",
|
||||
sameSite: env.SECURE_SITE === "true" ? "none" : "lax",
|
||||
});
|
||||
|
||||
return reply.send({ user });
|
||||
@@ -74,7 +74,7 @@ export class AuthController {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
secure: env.SECURE_SITE === "true" ? true : false,
|
||||
sameSite: env.SECURE_SITE === "true" ? "lax" : "strict",
|
||||
sameSite: env.SECURE_SITE === "true" ? "none" : "lax",
|
||||
});
|
||||
|
||||
return reply.send({ user });
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { MultipartFile } from "@fastify/multipart";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import {
|
||||
CreateShareSchema,
|
||||
CreateShareWithFilesSchema,
|
||||
UpdateShareItemsSchema,
|
||||
UpdateSharePasswordSchema,
|
||||
UpdateShareRecipientsSchema,
|
||||
@@ -34,67 +32,6 @@ 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,24 +136,6 @@ 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,29 +46,6 @@ 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,18 +1,10 @@
|
||||
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, CreateShareWithFilesInput, ShareResponseSchema, UpdateShareInput } from "./dto";
|
||||
import { CreateShareInput, ShareResponseSchema, UpdateShareInput } from "./dto";
|
||||
import { IShareRepository, PrismaShareRepository } from "./repository";
|
||||
|
||||
export class ShareService {
|
||||
@@ -121,141 +113,6 @@ 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,18 +143,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-web",
|
||||
"version": "3.2.4-beta",
|
||||
"version": "3.2.5-beta",
|
||||
"description": "Frontend for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { IconCalendar, IconEye, IconLock, IconShare, IconUpload, IconX } from "@tabler/icons-react";
|
||||
import { IconCalendar, IconEye, IconLock, IconShare } 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";
|
||||
@@ -14,8 +13,7 @@ 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, createShareWithFiles } from "@/http/endpoints";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import { createShare } from "@/http/endpoints";
|
||||
|
||||
interface CreateShareModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -41,7 +39,6 @@ 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);
|
||||
@@ -88,35 +85,18 @@ 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;
|
||||
}
|
||||
|
||||
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");
|
||||
if (selectedItems.length === 0) {
|
||||
toast.error("Please select at least one file or folder");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,40 +106,23 @@ 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));
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
|
||||
toast.success(t("createShare.success"));
|
||||
onSuccess();
|
||||
@@ -183,10 +146,8 @@ 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 && totalCount > 0;
|
||||
const canSubmit = formData.name.trim().length > 0 && selectedCount > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
@@ -200,7 +161,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-3">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="details">{t("createShare.tabs.shareDetails")}</TabsTrigger>
|
||||
<TabsTrigger value="files" disabled={!canProceedToFiles}>
|
||||
{t("createShare.tabs.selectFiles")}
|
||||
@@ -210,14 +171,6 @@ 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">
|
||||
@@ -367,87 +320,6 @@ 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")}
|
||||
|
||||
@@ -10,8 +10,6 @@ import type {
|
||||
CreateShareAliasResult,
|
||||
CreateShareBody,
|
||||
CreateShareResult,
|
||||
CreateShareWithFilesBody,
|
||||
CreateShareWithFilesResult,
|
||||
DeleteShareResult,
|
||||
GetShareByAliasParams,
|
||||
GetShareByAliasResult,
|
||||
@@ -41,63 +39,6 @@ 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,19 +139,6 @@ 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;
|
||||
@@ -199,7 +186,6 @@ 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>;
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
# - PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
|
||||
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs for see all supported languages
|
||||
# - PRESIGNED_URL_EXPIRATION=3600 # Duration in seconds for presigned URL expiration (OPTIONAL - default is 3600 seconds / 1 hour)
|
||||
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
|
||||
# - SECURE_SITE=true # Set to true for HTTPS/reverse proxy deployments. Enables cross-origin cookies for Safari compatibility (OPTIONAL - default is false)
|
||||
|
||||
# Download Memory Management Configuration (OPTIONAL - See documentation for details)
|
||||
# - DOWNLOAD_MAX_CONCURRENT=5 # Maximum number of simultaneous downloads (OPTIONAL - auto-scales based on system memory if not set)
|
||||
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
# - PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
|
||||
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs for see all supported languages
|
||||
# - PRESIGNED_URL_EXPIRATION=3600 # Duration in seconds for presigned URL expiration (OPTIONAL - default is 3600 seconds / 1 hour)
|
||||
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
|
||||
# - SECURE_SITE=true # Set to true for HTTPS/reverse proxy deployments. Enables cross-origin cookies for Safari compatibility (OPTIONAL - default is false)
|
||||
|
||||
# Download Memory Management Configuration (OPTIONAL - See documentation for details)
|
||||
# - DOWNLOAD_MAX_CONCURRENT=5 # Maximum number of simultaneous downloads (OPTIONAL - auto-scales based on system memory if not set)
|
||||
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
# - PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
|
||||
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs for see all supported languages
|
||||
# - PRESIGNED_URL_EXPIRATION=3600 # Duration in seconds for presigned URL expiration (OPTIONAL - default is 3600 seconds / 1 hour)
|
||||
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
|
||||
# - SECURE_SITE=true # Set to true for HTTPS/reverse proxy deployments. Enables cross-origin cookies for Safari compatibility (OPTIONAL - default is false)
|
||||
|
||||
# Download Memory Management Configuration (OPTIONAL - See documentation for details)
|
||||
# - DOWNLOAD_MAX_CONCURRENT=5 # Maximum number of simultaneous downloads (OPTIONAL - auto-scales based on system memory if not set)
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
# - PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
|
||||
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs to see all supported languages
|
||||
# - PRESIGNED_URL_EXPIRATION=3600 # Duration in seconds for presigned URL expiration (OPTIONAL - default is 3600 seconds / 1 hour)
|
||||
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
|
||||
# - SECURE_SITE=true # Set to true for HTTPS/reverse proxy deployments. Enables cross-origin cookies for Safari compatibility (OPTIONAL - default is false)
|
||||
|
||||
# Download Memory Management Configuration (OPTIONAL - See documentation for details)
|
||||
# - DOWNLOAD_MAX_CONCURRENT=5 # Maximum number of simultaneous downloads (OPTIONAL - auto-scales based on system memory if not set)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-monorepo",
|
||||
"version": "3.2.4-beta",
|
||||
"version": "3.2.5-beta",
|
||||
"description": "Palmr monorepo with Husky configuration",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
|
||||
Reference in New Issue
Block a user