mirror of
				https://github.com/kyantech/Palmr.git
				synced 2025-11-03 21:43:20 +00:00 
			
		
		
		
	Compare commits
	
		
			4 Commits
		
	
	
		
			copilot/ad
			...
			copilot/fi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					7fc29d0353 | ||
| 
						 | 
					4111364e94 | ||
| 
						 | 
					66a6b2ab1d | ||
| 
						 | 
					cb4ed3f581 | 
@@ -73,9 +73,9 @@ ENV NODE_ENV=production
 | 
			
		||||
ENV NEXT_TELEMETRY_DISABLED=1
 | 
			
		||||
ENV API_BASE_URL=http://127.0.0.1:3333
 | 
			
		||||
 | 
			
		||||
# Define build arguments for user/group configuration (defaults to current values)
 | 
			
		||||
ARG PALMR_UID=1001
 | 
			
		||||
ARG PALMR_GID=1001
 | 
			
		||||
# Define build arguments for user/group configuration (defaults to standard Linux values)
 | 
			
		||||
ARG PALMR_UID=1000
 | 
			
		||||
ARG PALMR_GID=1000
 | 
			
		||||
 | 
			
		||||
# Create application user with configurable UID/GID
 | 
			
		||||
RUN addgroup --system --gid ${PALMR_GID} nodejs
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,13 @@ Configure user and group permissions for seamless bind mount compatibility acros
 | 
			
		||||
 | 
			
		||||
Palmr. supports runtime UID/GID configuration to resolve permission conflicts when using bind mounts. This eliminates the need for manual permission management on your host system.
 | 
			
		||||
 | 
			
		||||
**⚠️ Important**: Palmr uses **UID 1000, GID 1000** by default, which matches the standard Linux convention. However, some systems may use different UID/GID values, which can cause permission issues with bind mounts.
 | 
			
		||||
**✅ Good News**: Palmr uses **UID 1000, GID 1000** by default, which matches the standard Linux convention for the first user. For most systems, you won't need to configure these values.
 | 
			
		||||
 | 
			
		||||
**⚠️ When to Configure**: Only set PALMR_UID/PALMR_GID if:
 | 
			
		||||
- You're using bind mounts AND your host system uses different UID/GID values (e.g., NAS systems)
 | 
			
		||||
- You're experiencing permission errors with bind mounts
 | 
			
		||||
 | 
			
		||||
**Note**: Setting these values triggers ownership updates on startup, which can take 1-2 minutes. If left at defaults, startup is fast (~5 seconds).
 | 
			
		||||
 | 
			
		||||
## The Permission Problem
 | 
			
		||||
 | 
			
		||||
@@ -35,9 +41,19 @@ drwxr-xr-x 2 user user 4096 Jan 15 10:00 uploads/
 | 
			
		||||
 | 
			
		||||
## Quick Fix
 | 
			
		||||
 | 
			
		||||
### Option 1: Set Palmr to Use Standard UID/GID (Recommended)
 | 
			
		||||
### For Most Users: No Configuration Needed
 | 
			
		||||
 | 
			
		||||
Add these environment variables to your `docker-compose.yaml`:
 | 
			
		||||
If your host system uses the standard Linux UID:GID of 1000:1000 (which is the case for most desktop Linux systems), you don't need to set PALMR_UID or PALMR_GID at all. Just use the default docker-compose.yaml as-is.
 | 
			
		||||
 | 
			
		||||
To check if you need configuration:
 | 
			
		||||
```bash
 | 
			
		||||
id
 | 
			
		||||
# If output shows uid=1000 and gid=1000, you don't need to configure anything
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Option 1: Set Palmr to Match Your Host UID/GID (For Non-Standard Systems)
 | 
			
		||||
 | 
			
		||||
If your system uses different values (common on NAS devices), add these environment variables to your `docker-compose.yaml`:
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
services:
 | 
			
		||||
@@ -55,14 +71,14 @@ services:
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Option 2: Change Host Directory Permissions
 | 
			
		||||
### Option 2: Change Host Directory Permissions (Alternative)
 | 
			
		||||
 | 
			
		||||
If you prefer to keep Palmr's defaults:
 | 
			
		||||
If you prefer not to set environment variables and your host uses different UID/GID:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Create directories with correct ownership
 | 
			
		||||
# Create directories with Palmr's default ownership (1000:1000)
 | 
			
		||||
mkdir -p uploads temp-uploads
 | 
			
		||||
chown -R 1001:1001 uploads temp-uploads
 | 
			
		||||
sudo chown -R 1000:1000 uploads temp-uploads
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Environment Variables
 | 
			
		||||
@@ -71,8 +87,8 @@ Configure permissions using these optional environment variables:
 | 
			
		||||
 | 
			
		||||
| Variable    | Description                      | Default | Example |
 | 
			
		||||
| ----------- | -------------------------------- | ------- | ------- |
 | 
			
		||||
| `PALMR_UID` | User ID for container processes  | `1001`  | `1000`  |
 | 
			
		||||
| `PALMR_GID` | Group ID for container processes | `1001`  | `1000`  |
 | 
			
		||||
| `PALMR_UID` | User ID for container processes  | `1000`  | `1000`  |
 | 
			
		||||
| `PALMR_GID` | Group ID for container processes | `1000`  | `1000`  |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@@ -230,17 +246,19 @@ sudo chown -R $(id -u):$(id -g) uploads temp-uploads
 | 
			
		||||
 | 
			
		||||
UID/GID configuration is **required** when:
 | 
			
		||||
 | 
			
		||||
- ✅ Using bind mounts (most common case)
 | 
			
		||||
- ✅ Encountering "permission denied" errors
 | 
			
		||||
- ✅ Deploying on NAS systems (Synology, QNAP, etc.)
 | 
			
		||||
- ✅ Host system uses different default UID/GID values
 | 
			
		||||
- ✅ Running multiple containers that need to share files
 | 
			
		||||
- ✅ Using bind mounts AND your host system uses non-standard UID/GID (not 1000:1000)
 | 
			
		||||
- ✅ Encountering "permission denied" errors with bind mounts
 | 
			
		||||
- ✅ Deploying on NAS systems (Synology, QNAP, etc.) with non-standard user IDs
 | 
			
		||||
- ✅ Running multiple containers that need to share files with specific ownership
 | 
			
		||||
 | 
			
		||||
UID/GID configuration is **optional** when:
 | 
			
		||||
UID/GID configuration is **NOT needed** when:
 | 
			
		||||
 | 
			
		||||
- ❌ Using Docker named volumes (Docker manages permissions)
 | 
			
		||||
- ❌ Not using bind mounts
 | 
			
		||||
- ❌ No permission errors occurring
 | 
			
		||||
- ❌ Using Docker named volumes (Docker manages permissions automatically)
 | 
			
		||||
- ❌ Your host system uses the standard UID:GID 1000:1000 (most Linux desktop systems)
 | 
			
		||||
- ❌ Not using bind mounts at all
 | 
			
		||||
- ❌ No permission errors are occurring
 | 
			
		||||
 | 
			
		||||
**Performance Note**: Configuring custom UID/GID values triggers a recursive ownership update on container startup, which can take 1-2 minutes depending on data volume. If you use the defaults (1000:1000), startup is much faster (~5 seconds).
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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>",
 | 
			
		||||
 
 | 
			
		||||
@@ -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>",
 | 
			
		||||
 
 | 
			
		||||
@@ -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>;
 | 
			
		||||
 
 | 
			
		||||
@@ -6,10 +6,13 @@ echo "🌴 Starting Palmr Server..."
 | 
			
		||||
TARGET_UID=${PALMR_UID:-1000}
 | 
			
		||||
TARGET_GID=${PALMR_GID:-1000}
 | 
			
		||||
 | 
			
		||||
if [ -n "$PALMR_UID" ] || [ -n "$PALMR_GID" ]; then
 | 
			
		||||
    echo "🔧 Runtime UID/GID: $TARGET_UID:$TARGET_GID"
 | 
			
		||||
    
 | 
			
		||||
    echo "🔐 Updating file ownership..."
 | 
			
		||||
echo "🔧 Runtime UID/GID: $TARGET_UID:$TARGET_GID"
 | 
			
		||||
 | 
			
		||||
# Check if we need to update ownership
 | 
			
		||||
# Only run chown if explicitly configured via environment variables
 | 
			
		||||
# This prevents unnecessary slowdowns on default configurations
 | 
			
		||||
if ([ -n "$PALMR_UID" ] || [ -n "$PALMR_GID" ]) && [ "$(id -u)" = "0" ]; then
 | 
			
		||||
    echo "🔐 Updating file ownership to match runtime configuration..."
 | 
			
		||||
    chown -R $TARGET_UID:$TARGET_GID /app/palmr-app 2>/dev/null || echo "⚠️ Some ownership changes may have failed"
 | 
			
		||||
    chown -R $TARGET_UID:$TARGET_GID /home/palmr 2>/dev/null || echo "⚠️ Some home directory ownership changes may have failed"
 | 
			
		||||
    
 | 
			
		||||
 
 | 
			
		||||
@@ -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