mirror of
				https://github.com/kyantech/Palmr.git
				synced 2025-11-04 05:53:23 +00:00 
			
		
		
		
	Compare commits
	
		
			7 Commits
		
	
	
		
			v3.2.4-bet
			...
			copilot/fi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					7fc29d0353 | ||
| 
						 | 
					4111364e94 | ||
| 
						 | 
					66a6b2ab1d | ||
| 
						 | 
					cb4ed3f581 | ||
| 
						 | 
					148676513d | ||
| 
						 | 
					42a5b7a796 | ||
| 
						 | 
					59fccd9a93 | 
@@ -73,9 +73,9 @@ ENV NODE_ENV=production
 | 
				
			|||||||
ENV NEXT_TELEMETRY_DISABLED=1
 | 
					ENV NEXT_TELEMETRY_DISABLED=1
 | 
				
			||||||
ENV API_BASE_URL=http://127.0.0.1:3333
 | 
					ENV API_BASE_URL=http://127.0.0.1:3333
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Define build arguments for user/group configuration (defaults to current values)
 | 
					# Define build arguments for user/group configuration (defaults to standard Linux values)
 | 
				
			||||||
ARG PALMR_UID=1001
 | 
					ARG PALMR_UID=1000
 | 
				
			||||||
ARG PALMR_GID=1001
 | 
					ARG PALMR_GID=1000
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Create application user with configurable UID/GID
 | 
					# Create application user with configurable UID/GID
 | 
				
			||||||
RUN addgroup --system --gid ${PALMR_GID} nodejs
 | 
					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.
 | 
					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
 | 
					## The Permission Problem
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -35,9 +41,19 @@ drwxr-xr-x 2 user user 4096 Jan 15 10:00 uploads/
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
## Quick Fix
 | 
					## 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
 | 
					```yaml
 | 
				
			||||||
services:
 | 
					services:
 | 
				
			||||||
@@ -55,14 +71,14 @@ services:
 | 
				
			|||||||
    restart: unless-stopped
 | 
					    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
 | 
					```bash
 | 
				
			||||||
# Create directories with correct ownership
 | 
					# Create directories with Palmr's default ownership (1000:1000)
 | 
				
			||||||
mkdir -p uploads temp-uploads
 | 
					mkdir -p uploads temp-uploads
 | 
				
			||||||
chown -R 1001:1001 uploads temp-uploads
 | 
					sudo chown -R 1000:1000 uploads temp-uploads
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Environment Variables
 | 
					## Environment Variables
 | 
				
			||||||
@@ -71,8 +87,8 @@ Configure permissions using these optional environment variables:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
| Variable    | Description                      | Default | Example |
 | 
					| Variable    | Description                      | Default | Example |
 | 
				
			||||||
| ----------- | -------------------------------- | ------- | ------- |
 | 
					| ----------- | -------------------------------- | ------- | ------- |
 | 
				
			||||||
| `PALMR_UID` | User ID for container processes  | `1001`  | `1000`  |
 | 
					| `PALMR_UID` | User ID for container processes  | `1000`  | `1000`  |
 | 
				
			||||||
| `PALMR_GID` | Group ID for container processes | `1001`  | `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:
 | 
					UID/GID configuration is **required** when:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- ✅ Using bind mounts (most common case)
 | 
					- ✅ Using bind mounts AND your host system uses non-standard UID/GID (not 1000:1000)
 | 
				
			||||||
- ✅ Encountering "permission denied" errors
 | 
					- ✅ Encountering "permission denied" errors with bind mounts
 | 
				
			||||||
- ✅ Deploying on NAS systems (Synology, QNAP, etc.)
 | 
					- ✅ Deploying on NAS systems (Synology, QNAP, etc.) with non-standard user IDs
 | 
				
			||||||
- ✅ Host system uses different default UID/GID values
 | 
					- ✅ Running multiple containers that need to share files with specific ownership
 | 
				
			||||||
- ✅ Running multiple containers that need to share files
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
UID/GID configuration is **optional** when:
 | 
					UID/GID configuration is **NOT needed** when:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- ❌ Using Docker named volumes (Docker manages permissions)
 | 
					- ❌ Using Docker named volumes (Docker manages permissions automatically)
 | 
				
			||||||
- ❌ Not using bind mounts
 | 
					- ❌ Your host system uses the standard UID:GID 1000:1000 (most Linux desktop systems)
 | 
				
			||||||
- ❌ No permission errors occurring
 | 
					- ❌ 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",
 | 
					  "name": "palmr-docs",
 | 
				
			||||||
  "version": "3.2.4-beta",
 | 
					  "version": "3.2.5-beta",
 | 
				
			||||||
  "description": "Docs for Palmr",
 | 
					  "description": "Docs for Palmr",
 | 
				
			||||||
  "private": true,
 | 
					  "private": true,
 | 
				
			||||||
  "author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
 | 
					  "author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "name": "palmr-api",
 | 
					  "name": "palmr-api",
 | 
				
			||||||
  "version": "3.2.4-beta",
 | 
					  "version": "3.2.5-beta",
 | 
				
			||||||
  "description": "API for Palmr",
 | 
					  "description": "API for Palmr",
 | 
				
			||||||
  "private": true,
 | 
					  "private": true,
 | 
				
			||||||
  "author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
 | 
					  "author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -617,6 +617,11 @@ export class AuthProvidersService {
 | 
				
			|||||||
      return await this.linkProviderToExistingUser(existingUser, provider.id, String(externalId), userInfo);
 | 
					      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));
 | 
					    return await this.createNewUserWithProvider(userInfo, provider.id, String(externalId));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import * as fs from "fs";
 | 
				
			||||||
import bcrypt from "bcryptjs";
 | 
					import bcrypt from "bcryptjs";
 | 
				
			||||||
import { FastifyReply, FastifyRequest } from "fastify";
 | 
					import { FastifyReply, FastifyRequest } from "fastify";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -8,6 +9,7 @@ import {
 | 
				
			|||||||
  generateUniqueFileNameForRename,
 | 
					  generateUniqueFileNameForRename,
 | 
				
			||||||
  parseFileName,
 | 
					  parseFileName,
 | 
				
			||||||
} from "../../utils/file-name-generator";
 | 
					} from "../../utils/file-name-generator";
 | 
				
			||||||
 | 
					import { getContentType } from "../../utils/mime-types";
 | 
				
			||||||
import { ConfigService } from "../config/service";
 | 
					import { ConfigService } from "../config/service";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  CheckFileInput,
 | 
					  CheckFileInput,
 | 
				
			||||||
@@ -200,11 +202,10 @@ export class FileController {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
 | 
					  async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const { objectName: encodedObjectName } = request.params as {
 | 
					      const { objectName, password } = request.query as {
 | 
				
			||||||
        objectName: string;
 | 
					        objectName: string;
 | 
				
			||||||
 | 
					        password?: string;
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
      const objectName = decodeURIComponent(encodedObjectName);
 | 
					 | 
				
			||||||
      const { password } = request.query as { password?: string };
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (!objectName) {
 | 
					      if (!objectName) {
 | 
				
			||||||
        return reply.status(400).send({ error: "The 'objectName' parameter is required." });
 | 
					        return reply.status(400).send({ error: "The 'objectName' parameter is required." });
 | 
				
			||||||
@@ -218,7 +219,8 @@ export class FileController {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      let hasAccess = false;
 | 
					      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({
 | 
					      const shares = await prisma.share.findMany({
 | 
				
			||||||
        where: {
 | 
					        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) {
 | 
					  async listFiles(request: FastifyRequest, reply: FastifyReply) {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await request.jwtVerify();
 | 
					      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[]> {
 | 
					  private async getAllUserFilesRecursively(userId: string): Promise<any[]> {
 | 
				
			||||||
    const rootFiles = await prisma.file.findMany({
 | 
					    const rootFiles = await prisma.file.findMany({
 | 
				
			||||||
      where: { userId, folderId: null },
 | 
					      where: { userId, folderId: null },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -106,17 +106,15 @@ export async function fileRoutes(app: FastifyInstance) {
 | 
				
			|||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  app.get(
 | 
					  app.get(
 | 
				
			||||||
    "/files/:objectName/download",
 | 
					    "/files/download-url",
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      schema: {
 | 
					      schema: {
 | 
				
			||||||
        tags: ["File"],
 | 
					        tags: ["File"],
 | 
				
			||||||
        operationId: "getDownloadUrl",
 | 
					        operationId: "getDownloadUrl",
 | 
				
			||||||
        summary: "Get Download URL",
 | 
					        summary: "Get Download URL",
 | 
				
			||||||
        description: "Generates a pre-signed URL for downloading a file",
 | 
					        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({
 | 
					        querystring: z.object({
 | 
				
			||||||
 | 
					          objectName: z.string().min(1, "The objectName is required"),
 | 
				
			||||||
          password: z.string().optional().describe("Share password if required"),
 | 
					          password: z.string().optional().describe("Share password if required"),
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
        response: {
 | 
					        response: {
 | 
				
			||||||
@@ -133,6 +131,46 @@ export async function fileRoutes(app: FastifyInstance) {
 | 
				
			|||||||
    fileController.getDownloadUrl.bind(fileController)
 | 
					    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(
 | 
					  app.get(
 | 
				
			||||||
    "/files",
 | 
					    "/files",
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -84,7 +84,6 @@ export class FilesystemController {
 | 
				
			|||||||
          const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
 | 
					          const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          if (result.isComplete) {
 | 
					          if (result.isComplete) {
 | 
				
			||||||
            provider.consumeUploadToken(token);
 | 
					 | 
				
			||||||
            reply.status(200).send({
 | 
					            reply.status(200).send({
 | 
				
			||||||
              message: "File uploaded successfully",
 | 
					              message: "File uploaded successfully",
 | 
				
			||||||
              objectName: result.finalPath,
 | 
					              objectName: result.finalPath,
 | 
				
			||||||
@@ -104,7 +103,6 @@ export class FilesystemController {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        await this.uploadFileStream(request, provider, tokenData.objectName);
 | 
					        await this.uploadFileStream(request, provider, tokenData.objectName);
 | 
				
			||||||
        provider.consumeUploadToken(token);
 | 
					 | 
				
			||||||
        reply.status(200).send({ message: "File uploaded successfully" });
 | 
					        reply.status(200).send({ message: "File uploaded successfully" });
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
@@ -271,8 +269,6 @@ export class FilesystemController {
 | 
				
			|||||||
          reply.header("Content-Length", fileSize);
 | 
					          reply.header("Content-Length", fileSize);
 | 
				
			||||||
          await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId);
 | 
					          await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					 | 
				
			||||||
        provider.consumeDownloadToken(token);
 | 
					 | 
				
			||||||
      } finally {
 | 
					      } finally {
 | 
				
			||||||
        this.memoryManager.endDownload(downloadId);
 | 
					        this.memoryManager.endDownload(downloadId);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -192,13 +192,9 @@ export class FilesystemStorageProvider implements StorageProvider {
 | 
				
			|||||||
    return `/api/filesystem/upload/${token}`;
 | 
					    return `/api/filesystem/upload/${token}`;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
 | 
					  async getPresignedGetUrl(objectName: string): Promise<string> {
 | 
				
			||||||
    const token = crypto.randomBytes(32).toString("hex");
 | 
					    const encodedObjectName = encodeURIComponent(objectName);
 | 
				
			||||||
    const expiresAt = Date.now() + expires * 1000;
 | 
					    return `/api/files/download?objectName=${encodedObjectName}`;
 | 
				
			||||||
 | 
					 | 
				
			||||||
    this.downloadTokens.set(token, { objectName, expiresAt, fileName });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return `/api/filesystem/download/${token}`;
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async deleteObject(objectName: string): Promise<void> {
 | 
					  async deleteObject(objectName: string): Promise<void> {
 | 
				
			||||||
@@ -636,13 +632,8 @@ export class FilesystemStorageProvider implements StorageProvider {
 | 
				
			|||||||
    return { objectName: data.objectName, fileName: data.fileName };
 | 
					    return { objectName: data.objectName, fileName: data.fileName };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  consumeUploadToken(token: string): void {
 | 
					  // Tokens are automatically cleaned up by cleanExpiredTokens() every 5 minutes
 | 
				
			||||||
    this.uploadTokens.delete(token);
 | 
					  // No need to manually consume tokens - allows reuse for previews, range requests, etc.
 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  consumeDownloadToken(token: string): void {
 | 
					 | 
				
			||||||
    this.downloadTokens.delete(token);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private async cleanupTempFile(tempPath: string): Promise<void> {
 | 
					  private async cleanupTempFile(tempPath: string): Promise<void> {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -150,7 +150,9 @@
 | 
				
			|||||||
    "move": "نقل",
 | 
					    "move": "نقل",
 | 
				
			||||||
    "rename": "إعادة تسمية",
 | 
					    "rename": "إعادة تسمية",
 | 
				
			||||||
    "search": "بحث",
 | 
					    "search": "بحث",
 | 
				
			||||||
    "share": "مشاركة"
 | 
					    "share": "مشاركة",
 | 
				
			||||||
 | 
					    "copied": "تم النسخ",
 | 
				
			||||||
 | 
					    "copy": "نسخ"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "إنشاء مشاركة",
 | 
					    "title": "إنشاء مشاركة",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "معاينة الملف",
 | 
					    "title": "معاينة الملف",
 | 
				
			||||||
 | 
					    "description": "معاينة وتنزيل الملف",
 | 
				
			||||||
    "loading": "جاري التحميل...",
 | 
					    "loading": "جاري التحميل...",
 | 
				
			||||||
    "notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.",
 | 
					    "notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.",
 | 
				
			||||||
    "downloadToView": "استخدم زر التحميل لتنزيل الملف.",
 | 
					    "downloadToView": "استخدم زر التحميل لتنزيل الملف.",
 | 
				
			||||||
@@ -1932,5 +1935,17 @@
 | 
				
			|||||||
    "passwordRequired": "كلمة المرور مطلوبة",
 | 
					    "passwordRequired": "كلمة المرور مطلوبة",
 | 
				
			||||||
    "nameRequired": "الاسم مطلوب",
 | 
					    "nameRequired": "الاسم مطلوب",
 | 
				
			||||||
    "required": "هذا الحقل مطلوب"
 | 
					    "required": "هذا الحقل مطلوب"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "embedCode": {
 | 
				
			||||||
 | 
					    "title": "تضمين الصورة",
 | 
				
			||||||
 | 
					    "description": "استخدم هذه الأكواد لتضمين هذه الصورة في المنتديات أو المواقع الإلكترونية أو المنصات الأخرى",
 | 
				
			||||||
 | 
					    "tabs": {
 | 
				
			||||||
 | 
					      "directLink": "رابط مباشر",
 | 
				
			||||||
 | 
					      "html": "HTML",
 | 
				
			||||||
 | 
					      "bbcode": "BBCode"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "directLinkDescription": "عنوان URL مباشر لملف الصورة",
 | 
				
			||||||
 | 
					    "htmlDescription": "استخدم هذا الكود لتضمين الصورة في صفحات HTML",
 | 
				
			||||||
 | 
					    "bbcodeDescription": "استخدم هذا الكود لتضمين الصورة في المنتديات التي تدعم BBCode"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -150,7 +150,9 @@
 | 
				
			|||||||
    "move": "Verschieben",
 | 
					    "move": "Verschieben",
 | 
				
			||||||
    "rename": "Umbenennen",
 | 
					    "rename": "Umbenennen",
 | 
				
			||||||
    "search": "Suchen",
 | 
					    "search": "Suchen",
 | 
				
			||||||
    "share": "Teilen"
 | 
					    "share": "Teilen",
 | 
				
			||||||
 | 
					    "copied": "Kopiert",
 | 
				
			||||||
 | 
					    "copy": "Kopieren"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "Freigabe Erstellen",
 | 
					    "title": "Freigabe Erstellen",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "Datei-Vorschau",
 | 
					    "title": "Datei-Vorschau",
 | 
				
			||||||
 | 
					    "description": "Vorschau und Download der Datei",
 | 
				
			||||||
    "loading": "Laden...",
 | 
					    "loading": "Laden...",
 | 
				
			||||||
    "notAvailable": "Vorschau für diesen Dateityp nicht verfügbar.",
 | 
					    "notAvailable": "Vorschau für diesen Dateityp nicht verfügbar.",
 | 
				
			||||||
    "downloadToView": "Verwenden Sie die Download-Schaltfläche, um die Datei herunterzuladen.",
 | 
					    "downloadToView": "Verwenden Sie die Download-Schaltfläche, um die Datei herunterzuladen.",
 | 
				
			||||||
@@ -1930,5 +1933,17 @@
 | 
				
			|||||||
    "passwordRequired": "Passwort ist erforderlich",
 | 
					    "passwordRequired": "Passwort ist erforderlich",
 | 
				
			||||||
    "nameRequired": "Name ist erforderlich",
 | 
					    "nameRequired": "Name ist erforderlich",
 | 
				
			||||||
    "required": "Dieses Feld 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",
 | 
					    "rename": "Rename",
 | 
				
			||||||
    "move": "Move",
 | 
					    "move": "Move",
 | 
				
			||||||
    "share": "Share",
 | 
					    "share": "Share",
 | 
				
			||||||
    "search": "Search"
 | 
					    "search": "Search",
 | 
				
			||||||
 | 
					    "copy": "Copy",
 | 
				
			||||||
 | 
					    "copied": "Copied"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "Create Share",
 | 
					    "title": "Create Share",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "Preview File",
 | 
					    "title": "Preview File",
 | 
				
			||||||
 | 
					    "description": "Preview and download file",
 | 
				
			||||||
    "loading": "Loading...",
 | 
					    "loading": "Loading...",
 | 
				
			||||||
    "notAvailable": "Preview not available for this file type",
 | 
					    "notAvailable": "Preview not available for this file type",
 | 
				
			||||||
    "downloadToView": "Use the download button to view this file",
 | 
					    "downloadToView": "Use the download button to view this file",
 | 
				
			||||||
@@ -1881,6 +1884,18 @@
 | 
				
			|||||||
      "userr": "User"
 | 
					      "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": {
 | 
					  "validation": {
 | 
				
			||||||
    "firstNameRequired": "First name is required",
 | 
					    "firstNameRequired": "First name is required",
 | 
				
			||||||
    "lastNameRequired": "Last name is required",
 | 
					    "lastNameRequired": "Last name is required",
 | 
				
			||||||
@@ -1896,4 +1911,4 @@
 | 
				
			|||||||
    "nameRequired": "Name is required",
 | 
					    "nameRequired": "Name is required",
 | 
				
			||||||
    "required": "This field is required"
 | 
					    "required": "This field is required"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -150,7 +150,9 @@
 | 
				
			|||||||
    "move": "Mover",
 | 
					    "move": "Mover",
 | 
				
			||||||
    "rename": "Renombrar",
 | 
					    "rename": "Renombrar",
 | 
				
			||||||
    "search": "Buscar",
 | 
					    "search": "Buscar",
 | 
				
			||||||
    "share": "Compartir"
 | 
					    "share": "Compartir",
 | 
				
			||||||
 | 
					    "copied": "Copiado",
 | 
				
			||||||
 | 
					    "copy": "Copiar"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "Crear Compartir",
 | 
					    "title": "Crear Compartir",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "Vista Previa del Archivo",
 | 
					    "title": "Vista Previa del Archivo",
 | 
				
			||||||
 | 
					    "description": "Vista previa y descarga de archivo",
 | 
				
			||||||
    "loading": "Cargando...",
 | 
					    "loading": "Cargando...",
 | 
				
			||||||
    "notAvailable": "Vista previa no disponible para este tipo de archivo.",
 | 
					    "notAvailable": "Vista previa no disponible para este tipo de archivo.",
 | 
				
			||||||
    "downloadToView": "Use el botón de descarga para descargar el archivo.",
 | 
					    "downloadToView": "Use el botón de descarga para descargar el archivo.",
 | 
				
			||||||
@@ -1930,5 +1933,17 @@
 | 
				
			|||||||
    "passwordRequired": "Se requiere la contraseña",
 | 
					    "passwordRequired": "Se requiere la contraseña",
 | 
				
			||||||
    "nameRequired": "El nombre es obligatorio",
 | 
					    "nameRequired": "El nombre es obligatorio",
 | 
				
			||||||
    "required": "Este campo 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",
 | 
					    "move": "Déplacer",
 | 
				
			||||||
    "rename": "Renommer",
 | 
					    "rename": "Renommer",
 | 
				
			||||||
    "search": "Rechercher",
 | 
					    "search": "Rechercher",
 | 
				
			||||||
    "share": "Partager"
 | 
					    "share": "Partager",
 | 
				
			||||||
 | 
					    "copied": "Copié",
 | 
				
			||||||
 | 
					    "copy": "Copier"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "Créer un Partage",
 | 
					    "title": "Créer un Partage",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "Aperçu du Fichier",
 | 
					    "title": "Aperçu du Fichier",
 | 
				
			||||||
 | 
					    "description": "Aperçu et téléchargement du fichier",
 | 
				
			||||||
    "loading": "Chargement...",
 | 
					    "loading": "Chargement...",
 | 
				
			||||||
    "notAvailable": "Aperçu non disponible pour ce type de fichier.",
 | 
					    "notAvailable": "Aperçu non disponible pour ce type de fichier.",
 | 
				
			||||||
    "downloadToView": "Utilisez le bouton de téléchargement pour télécharger le 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",
 | 
					    "passwordRequired": "Le mot de passe est requis",
 | 
				
			||||||
    "nameRequired": "Nome é obrigatório",
 | 
					    "nameRequired": "Nome é obrigatório",
 | 
				
			||||||
    "required": "Este campo é 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": "स्थानांतरित करें",
 | 
					    "move": "स्थानांतरित करें",
 | 
				
			||||||
    "rename": "नाम बदलें",
 | 
					    "rename": "नाम बदलें",
 | 
				
			||||||
    "search": "खोजें",
 | 
					    "search": "खोजें",
 | 
				
			||||||
    "share": "साझा करें"
 | 
					    "share": "साझा करें",
 | 
				
			||||||
 | 
					    "copied": "कॉपी किया गया",
 | 
				
			||||||
 | 
					    "copy": "कॉपी करें"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "साझाकरण बनाएं",
 | 
					    "title": "साझाकरण बनाएं",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "फ़ाइल पूर्वावलोकन",
 | 
					    "title": "फ़ाइल पूर्वावलोकन",
 | 
				
			||||||
 | 
					    "description": "फ़ाइल पूर्वावलोकन और डाउनलोड",
 | 
				
			||||||
    "loading": "लोड हो रहा है...",
 | 
					    "loading": "लोड हो रहा है...",
 | 
				
			||||||
    "notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।",
 | 
					    "notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।",
 | 
				
			||||||
    "downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।",
 | 
					    "downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।",
 | 
				
			||||||
@@ -1930,5 +1933,17 @@
 | 
				
			|||||||
    "passwordRequired": "पासवर्ड आवश्यक है",
 | 
					    "passwordRequired": "पासवर्ड आवश्यक है",
 | 
				
			||||||
    "nameRequired": "नाम आवश्यक है",
 | 
					    "nameRequired": "नाम आवश्यक है",
 | 
				
			||||||
    "required": "यह फ़ील्ड आवश्यक है"
 | 
					    "required": "यह फ़ील्ड आवश्यक है"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "embedCode": {
 | 
				
			||||||
 | 
					    "title": "छवि एम्बेड करें",
 | 
				
			||||||
 | 
					    "description": "इस छवि को मंचों, वेबसाइटों या अन्य प्लेटफार्मों में एम्बेड करने के लिए इन कोड का उपयोग करें",
 | 
				
			||||||
 | 
					    "tabs": {
 | 
				
			||||||
 | 
					      "directLink": "सीधा लिंक",
 | 
				
			||||||
 | 
					      "html": "HTML",
 | 
				
			||||||
 | 
					      "bbcode": "BBCode"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "directLinkDescription": "छवि फ़ाइल का सीधा URL",
 | 
				
			||||||
 | 
					    "htmlDescription": "HTML पेजों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें",
 | 
				
			||||||
 | 
					    "bbcodeDescription": "BBCode का समर्थन करने वाले मंचों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -150,7 +150,9 @@
 | 
				
			|||||||
    "move": "Sposta",
 | 
					    "move": "Sposta",
 | 
				
			||||||
    "rename": "Rinomina",
 | 
					    "rename": "Rinomina",
 | 
				
			||||||
    "search": "Cerca",
 | 
					    "search": "Cerca",
 | 
				
			||||||
    "share": "Condividi"
 | 
					    "share": "Condividi",
 | 
				
			||||||
 | 
					    "copied": "Copiato",
 | 
				
			||||||
 | 
					    "copy": "Copia"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "Crea Condivisione",
 | 
					    "title": "Crea Condivisione",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "Anteprima File",
 | 
					    "title": "Anteprima File",
 | 
				
			||||||
 | 
					    "description": "Anteprima e download del file",
 | 
				
			||||||
    "loading": "Caricamento...",
 | 
					    "loading": "Caricamento...",
 | 
				
			||||||
    "notAvailable": "Anteprima non disponibile per questo tipo di file.",
 | 
					    "notAvailable": "Anteprima non disponibile per questo tipo di file.",
 | 
				
			||||||
    "downloadToView": "Utilizzare il pulsante di download per scaricare il file.",
 | 
					    "downloadToView": "Utilizzare il pulsante di download per scaricare il file.",
 | 
				
			||||||
@@ -1930,5 +1933,17 @@
 | 
				
			|||||||
    "passwordMinLength": "La password deve contenere almeno 6 caratteri",
 | 
					    "passwordMinLength": "La password deve contenere almeno 6 caratteri",
 | 
				
			||||||
    "nameRequired": "Il nome è obbligatorio",
 | 
					    "nameRequired": "Il nome è obbligatorio",
 | 
				
			||||||
    "required": "Questo campo è 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": "移動",
 | 
					    "move": "移動",
 | 
				
			||||||
    "rename": "名前を変更",
 | 
					    "rename": "名前を変更",
 | 
				
			||||||
    "search": "検索",
 | 
					    "search": "検索",
 | 
				
			||||||
    "share": "共有"
 | 
					    "share": "共有",
 | 
				
			||||||
 | 
					    "copied": "コピーしました",
 | 
				
			||||||
 | 
					    "copy": "コピー"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "共有を作成",
 | 
					    "title": "共有を作成",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "ファイルプレビュー",
 | 
					    "title": "ファイルプレビュー",
 | 
				
			||||||
 | 
					    "description": "ファイルをプレビューしてダウンロード",
 | 
				
			||||||
    "loading": "読み込み中...",
 | 
					    "loading": "読み込み中...",
 | 
				
			||||||
    "notAvailable": "このファイルタイプのプレビューは利用できません。",
 | 
					    "notAvailable": "このファイルタイプのプレビューは利用できません。",
 | 
				
			||||||
    "downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。",
 | 
					    "downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。",
 | 
				
			||||||
@@ -1930,5 +1933,17 @@
 | 
				
			|||||||
    "passwordRequired": "パスワードは必須です",
 | 
					    "passwordRequired": "パスワードは必須です",
 | 
				
			||||||
    "nameRequired": "名前は必須です",
 | 
					    "nameRequired": "名前は必須です",
 | 
				
			||||||
    "required": "このフィールドは必須です"
 | 
					    "required": "このフィールドは必須です"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "embedCode": {
 | 
				
			||||||
 | 
					    "title": "画像を埋め込む",
 | 
				
			||||||
 | 
					    "description": "これらのコードを使用して、この画像をフォーラム、ウェブサイト、またはその他のプラットフォームに埋め込みます",
 | 
				
			||||||
 | 
					    "tabs": {
 | 
				
			||||||
 | 
					      "directLink": "直接リンク",
 | 
				
			||||||
 | 
					      "html": "HTML",
 | 
				
			||||||
 | 
					      "bbcode": "BBCode"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "directLinkDescription": "画像ファイルへの直接URL",
 | 
				
			||||||
 | 
					    "htmlDescription": "このコードを使用してHTMLページに画像を埋め込みます",
 | 
				
			||||||
 | 
					    "bbcodeDescription": "BBCodeをサポートするフォーラムに画像を埋め込むには、このコードを使用します"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -150,7 +150,9 @@
 | 
				
			|||||||
    "move": "이동",
 | 
					    "move": "이동",
 | 
				
			||||||
    "rename": "이름 변경",
 | 
					    "rename": "이름 변경",
 | 
				
			||||||
    "search": "검색",
 | 
					    "search": "검색",
 | 
				
			||||||
    "share": "공유"
 | 
					    "share": "공유",
 | 
				
			||||||
 | 
					    "copied": "복사됨",
 | 
				
			||||||
 | 
					    "copy": "복사"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "공유 생성",
 | 
					    "title": "공유 생성",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "파일 미리보기",
 | 
					    "title": "파일 미리보기",
 | 
				
			||||||
 | 
					    "description": "파일 미리보기 및 다운로드",
 | 
				
			||||||
    "loading": "로딩 중...",
 | 
					    "loading": "로딩 중...",
 | 
				
			||||||
    "notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.",
 | 
					    "notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.",
 | 
				
			||||||
    "downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.",
 | 
					    "downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.",
 | 
				
			||||||
@@ -1930,5 +1933,17 @@
 | 
				
			|||||||
    "passwordRequired": "비밀번호는 필수입니다",
 | 
					    "passwordRequired": "비밀번호는 필수입니다",
 | 
				
			||||||
    "nameRequired": "이름은 필수입니다",
 | 
					    "nameRequired": "이름은 필수입니다",
 | 
				
			||||||
    "required": "이 필드는 필수입니다"
 | 
					    "required": "이 필드는 필수입니다"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "embedCode": {
 | 
				
			||||||
 | 
					    "title": "이미지 삽입",
 | 
				
			||||||
 | 
					    "description": "이 코드를 사용하여 포럼, 웹사이트 또는 기타 플랫폼에 이 이미지를 삽입하세요",
 | 
				
			||||||
 | 
					    "tabs": {
 | 
				
			||||||
 | 
					      "directLink": "직접 링크",
 | 
				
			||||||
 | 
					      "html": "HTML",
 | 
				
			||||||
 | 
					      "bbcode": "BBCode"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "directLinkDescription": "이미지 파일에 대한 직접 URL",
 | 
				
			||||||
 | 
					    "htmlDescription": "이 코드를 사용하여 HTML 페이지에 이미지를 삽입하세요",
 | 
				
			||||||
 | 
					    "bbcodeDescription": "BBCode를 지원하는 포럼에 이미지를 삽입하려면 이 코드를 사용하세요"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -150,7 +150,9 @@
 | 
				
			|||||||
    "move": "Verplaatsen",
 | 
					    "move": "Verplaatsen",
 | 
				
			||||||
    "rename": "Hernoemen",
 | 
					    "rename": "Hernoemen",
 | 
				
			||||||
    "search": "Zoeken",
 | 
					    "search": "Zoeken",
 | 
				
			||||||
    "share": "Delen"
 | 
					    "share": "Delen",
 | 
				
			||||||
 | 
					    "copied": "Gekopieerd",
 | 
				
			||||||
 | 
					    "copy": "Kopiëren"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "Delen Maken",
 | 
					    "title": "Delen Maken",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "Bestandsvoorbeeld",
 | 
					    "title": "Bestandsvoorbeeld",
 | 
				
			||||||
 | 
					    "description": "Bestand bekijken en downloaden",
 | 
				
			||||||
    "loading": "Laden...",
 | 
					    "loading": "Laden...",
 | 
				
			||||||
    "notAvailable": "Voorbeeld niet beschikbaar voor dit bestandstype.",
 | 
					    "notAvailable": "Voorbeeld niet beschikbaar voor dit bestandstype.",
 | 
				
			||||||
    "downloadToView": "Gebruik de downloadknop om het bestand te downloaden.",
 | 
					    "downloadToView": "Gebruik de downloadknop om het bestand te downloaden.",
 | 
				
			||||||
@@ -1930,5 +1933,17 @@
 | 
				
			|||||||
    "passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
 | 
					    "passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
 | 
				
			||||||
    "nameRequired": "Naam is verplicht",
 | 
					    "nameRequired": "Naam is verplicht",
 | 
				
			||||||
    "required": "Dit veld 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ś",
 | 
					    "move": "Przenieś",
 | 
				
			||||||
    "rename": "Zmień nazwę",
 | 
					    "rename": "Zmień nazwę",
 | 
				
			||||||
    "search": "Szukaj",
 | 
					    "search": "Szukaj",
 | 
				
			||||||
    "share": "Udostępnij"
 | 
					    "share": "Udostępnij",
 | 
				
			||||||
 | 
					    "copied": "Skopiowano",
 | 
				
			||||||
 | 
					    "copy": "Kopiuj"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "Utwórz Udostępnienie",
 | 
					    "title": "Utwórz Udostępnienie",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "Podgląd pliku",
 | 
					    "title": "Podgląd pliku",
 | 
				
			||||||
 | 
					    "description": "Podgląd i pobieranie pliku",
 | 
				
			||||||
    "loading": "Ładowanie...",
 | 
					    "loading": "Ładowanie...",
 | 
				
			||||||
    "notAvailable": "Podgląd niedostępny dla tego typu pliku",
 | 
					    "notAvailable": "Podgląd niedostępny dla tego typu pliku",
 | 
				
			||||||
    "downloadToView": "Użyj przycisku pobierania, aby wyświetlić ten plik",
 | 
					    "downloadToView": "Użyj przycisku pobierania, aby wyświetlić ten plik",
 | 
				
			||||||
@@ -1930,5 +1933,17 @@
 | 
				
			|||||||
    "passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
 | 
					    "passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
 | 
				
			||||||
    "nameRequired": "Nazwa jest wymagana",
 | 
					    "nameRequired": "Nazwa jest wymagana",
 | 
				
			||||||
    "required": "To pole jest wymagane"
 | 
					    "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",
 | 
					    "move": "Mover",
 | 
				
			||||||
    "rename": "Renomear",
 | 
					    "rename": "Renomear",
 | 
				
			||||||
    "search": "Pesquisar",
 | 
					    "search": "Pesquisar",
 | 
				
			||||||
    "share": "Compartilhar"
 | 
					    "share": "Compartilhar",
 | 
				
			||||||
 | 
					    "copied": "Copiado",
 | 
				
			||||||
 | 
					    "copy": "Copiar"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "Criar compartilhamento",
 | 
					    "title": "Criar compartilhamento",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "Visualizar Arquivo",
 | 
					    "title": "Visualizar Arquivo",
 | 
				
			||||||
 | 
					    "description": "Visualizar e baixar arquivo",
 | 
				
			||||||
    "loading": "Carregando...",
 | 
					    "loading": "Carregando...",
 | 
				
			||||||
    "notAvailable": "Preview não disponível para este tipo de arquivo.",
 | 
					    "notAvailable": "Preview não disponível para este tipo de arquivo.",
 | 
				
			||||||
    "downloadToView": "Use o botão de download para baixar o arquivo.",
 | 
					    "downloadToView": "Use o botão de download para baixar o arquivo.",
 | 
				
			||||||
@@ -1931,5 +1934,17 @@
 | 
				
			|||||||
    "lastNameRequired": "O sobrenome é necessário",
 | 
					    "lastNameRequired": "O sobrenome é necessário",
 | 
				
			||||||
    "usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
 | 
					    "usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
 | 
				
			||||||
    "usernameSpaces": "O nome de usuário não pode conter espaços"
 | 
					    "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": "Переместить",
 | 
					    "move": "Переместить",
 | 
				
			||||||
    "rename": "Переименовать",
 | 
					    "rename": "Переименовать",
 | 
				
			||||||
    "search": "Поиск",
 | 
					    "search": "Поиск",
 | 
				
			||||||
    "share": "Поделиться"
 | 
					    "share": "Поделиться",
 | 
				
			||||||
 | 
					    "copied": "Скопировано",
 | 
				
			||||||
 | 
					    "copy": "Копировать"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "Создать общий доступ",
 | 
					    "title": "Создать общий доступ",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "Предварительный просмотр файла",
 | 
					    "title": "Предварительный просмотр файла",
 | 
				
			||||||
 | 
					    "description": "Просмотр и загрузка файла",
 | 
				
			||||||
    "loading": "Загрузка...",
 | 
					    "loading": "Загрузка...",
 | 
				
			||||||
    "notAvailable": "Предварительный просмотр недоступен для этого типа файла.",
 | 
					    "notAvailable": "Предварительный просмотр недоступен для этого типа файла.",
 | 
				
			||||||
    "downloadToView": "Используйте кнопку загрузки для скачивания файла.",
 | 
					    "downloadToView": "Используйте кнопку загрузки для скачивания файла.",
 | 
				
			||||||
@@ -1930,5 +1933,17 @@
 | 
				
			|||||||
    "passwordRequired": "Требуется пароль",
 | 
					    "passwordRequired": "Требуется пароль",
 | 
				
			||||||
    "nameRequired": "Требуется имя",
 | 
					    "nameRequired": "Требуется имя",
 | 
				
			||||||
    "required": "Это поле обязательно"
 | 
					    "required": "Это поле обязательно"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "embedCode": {
 | 
				
			||||||
 | 
					    "title": "Встроить изображение",
 | 
				
			||||||
 | 
					    "description": "Используйте эти коды для встраивания этого изображения на форумах, веб-сайтах или других платформах",
 | 
				
			||||||
 | 
					    "tabs": {
 | 
				
			||||||
 | 
					      "directLink": "Прямая ссылка",
 | 
				
			||||||
 | 
					      "html": "HTML",
 | 
				
			||||||
 | 
					      "bbcode": "BBCode"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "directLinkDescription": "Прямой URL-адрес файла изображения",
 | 
				
			||||||
 | 
					    "htmlDescription": "Используйте этот код для встраивания изображения в HTML-страницы",
 | 
				
			||||||
 | 
					    "bbcodeDescription": "Используйте этот код для встраивания изображения на форумах, поддерживающих BBCode"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -150,7 +150,9 @@
 | 
				
			|||||||
    "move": "Taşı",
 | 
					    "move": "Taşı",
 | 
				
			||||||
    "rename": "Yeniden Adlandır",
 | 
					    "rename": "Yeniden Adlandır",
 | 
				
			||||||
    "search": "Ara",
 | 
					    "search": "Ara",
 | 
				
			||||||
    "share": "Paylaş"
 | 
					    "share": "Paylaş",
 | 
				
			||||||
 | 
					    "copied": "Kopyalandı",
 | 
				
			||||||
 | 
					    "copy": "Kopyala"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "Paylaşım Oluştur",
 | 
					    "title": "Paylaşım Oluştur",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "Dosya Önizleme",
 | 
					    "title": "Dosya Önizleme",
 | 
				
			||||||
 | 
					    "description": "Dosyayı önizleyin ve indirin",
 | 
				
			||||||
    "loading": "Yükleniyor...",
 | 
					    "loading": "Yükleniyor...",
 | 
				
			||||||
    "notAvailable": "Bu dosya türü için önizleme mevcut değil.",
 | 
					    "notAvailable": "Bu dosya türü için önizleme mevcut değil.",
 | 
				
			||||||
    "downloadToView": "Dosyayı indirmek için indirme düğmesini kullanın.",
 | 
					    "downloadToView": "Dosyayı indirmek için indirme düğmesini kullanın.",
 | 
				
			||||||
@@ -1930,5 +1933,17 @@
 | 
				
			|||||||
    "passwordRequired": "Şifre gerekli",
 | 
					    "passwordRequired": "Şifre gerekli",
 | 
				
			||||||
    "nameRequired": "İsim gereklidir",
 | 
					    "nameRequired": "İsim gereklidir",
 | 
				
			||||||
    "required": "Bu alan zorunludur"
 | 
					    "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": "移动",
 | 
					    "move": "移动",
 | 
				
			||||||
    "rename": "重命名",
 | 
					    "rename": "重命名",
 | 
				
			||||||
    "search": "搜索",
 | 
					    "search": "搜索",
 | 
				
			||||||
    "share": "分享"
 | 
					    "share": "分享",
 | 
				
			||||||
 | 
					    "copied": "已复制",
 | 
				
			||||||
 | 
					    "copy": "复制"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "createShare": {
 | 
					  "createShare": {
 | 
				
			||||||
    "title": "创建分享",
 | 
					    "title": "创建分享",
 | 
				
			||||||
@@ -302,6 +304,7 @@
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  "filePreview": {
 | 
					  "filePreview": {
 | 
				
			||||||
    "title": "文件预览",
 | 
					    "title": "文件预览",
 | 
				
			||||||
 | 
					    "description": "预览和下载文件",
 | 
				
			||||||
    "loading": "加载中...",
 | 
					    "loading": "加载中...",
 | 
				
			||||||
    "notAvailable": "此文件类型不支持预览。",
 | 
					    "notAvailable": "此文件类型不支持预览。",
 | 
				
			||||||
    "downloadToView": "使用下载按钮下载文件。",
 | 
					    "downloadToView": "使用下载按钮下载文件。",
 | 
				
			||||||
@@ -1930,5 +1933,17 @@
 | 
				
			|||||||
    "passwordRequired": "密码为必填项",
 | 
					    "passwordRequired": "密码为必填项",
 | 
				
			||||||
    "nameRequired": "名称为必填项",
 | 
					    "nameRequired": "名称为必填项",
 | 
				
			||||||
    "required": "此字段为必填项"
 | 
					    "required": "此字段为必填项"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "embedCode": {
 | 
				
			||||||
 | 
					    "title": "嵌入图片",
 | 
				
			||||||
 | 
					    "description": "使用这些代码将此图片嵌入到论坛、网站或其他平台中",
 | 
				
			||||||
 | 
					    "tabs": {
 | 
				
			||||||
 | 
					      "directLink": "直接链接",
 | 
				
			||||||
 | 
					      "html": "HTML",
 | 
				
			||||||
 | 
					      "bbcode": "BBCode"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "directLinkDescription": "图片文件的直接URL",
 | 
				
			||||||
 | 
					    "htmlDescription": "使用此代码将图片嵌入HTML页面",
 | 
				
			||||||
 | 
					    "bbcodeDescription": "使用此代码将图片嵌入支持BBCode的论坛"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "name": "palmr-web",
 | 
					  "name": "palmr-web",
 | 
				
			||||||
  "version": "3.2.4-beta",
 | 
					  "version": "3.2.5-beta",
 | 
				
			||||||
  "description": "Frontend for Palmr",
 | 
					  "description": "Frontend for Palmr",
 | 
				
			||||||
  "private": true,
 | 
					  "private": true,
 | 
				
			||||||
  "author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
 | 
					  "author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -900,16 +900,7 @@ export function ReceivedFilesModal({
 | 
				
			|||||||
      </Dialog>
 | 
					      </Dialog>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {previewFile && (
 | 
					      {previewFile && (
 | 
				
			||||||
        <ReverseShareFilePreviewModal
 | 
					        <ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
 | 
				
			||||||
          isOpen={!!previewFile}
 | 
					 | 
				
			||||||
          onClose={() => setPreviewFile(null)}
 | 
					 | 
				
			||||||
          file={{
 | 
					 | 
				
			||||||
            id: previewFile.id,
 | 
					 | 
				
			||||||
            name: previewFile.name,
 | 
					 | 
				
			||||||
            objectName: previewFile.objectName,
 | 
					 | 
				
			||||||
            extension: previewFile.extension,
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,23 +7,11 @@ import { toast } from "sonner";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import { Button } from "@/components/ui/button";
 | 
					import { Button } from "@/components/ui/button";
 | 
				
			||||||
import { deleteReverseShareFile } from "@/http/endpoints/reverse-shares";
 | 
					import { deleteReverseShareFile } from "@/http/endpoints/reverse-shares";
 | 
				
			||||||
 | 
					import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
 | 
				
			||||||
import { downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
 | 
					import { downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
 | 
				
			||||||
import { getFileIcon } from "@/utils/file-icons";
 | 
					import { getFileIcon } from "@/utils/file-icons";
 | 
				
			||||||
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
 | 
					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 {
 | 
					interface ReceivedFilesSectionProps {
 | 
				
			||||||
  files: ReverseShareFile[];
 | 
					  files: ReverseShareFile[];
 | 
				
			||||||
  onFileDeleted?: () => void;
 | 
					  onFileDeleted?: () => void;
 | 
				
			||||||
@@ -159,16 +147,7 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
 | 
				
			|||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {previewFile && (
 | 
					      {previewFile && (
 | 
				
			||||||
        <ReverseShareFilePreviewModal
 | 
					        <ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
 | 
				
			||||||
          isOpen={!!previewFile}
 | 
					 | 
				
			||||||
          onClose={() => setPreviewFile(null)}
 | 
					 | 
				
			||||||
          file={{
 | 
					 | 
				
			||||||
            id: previewFile.id,
 | 
					 | 
				
			||||||
            name: previewFile.name,
 | 
					 | 
				
			||||||
            objectName: previewFile.objectName,
 | 
					 | 
				
			||||||
            extension: previewFile.extension,
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,26 +1,20 @@
 | 
				
			|||||||
"use client";
 | 
					"use client";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
 | 
					import { FilePreviewModal } from "@/components/modals/file-preview-modal";
 | 
				
			||||||
 | 
					import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface ReverseShareFilePreviewModalProps {
 | 
					interface ReverseShareFilePreviewModalProps {
 | 
				
			||||||
  isOpen: boolean;
 | 
					  isOpen: boolean;
 | 
				
			||||||
  onClose: () => void;
 | 
					  onClose: () => void;
 | 
				
			||||||
  file: {
 | 
					  file: ReverseShareFile | null;
 | 
				
			||||||
    id: string;
 | 
					 | 
				
			||||||
    name: string;
 | 
					 | 
				
			||||||
    objectName: string;
 | 
					 | 
				
			||||||
    extension?: string;
 | 
					 | 
				
			||||||
  } | null;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) {
 | 
					export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) {
 | 
				
			||||||
  if (!file) return null;
 | 
					  if (!file) return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const adaptedFile = {
 | 
					  const adaptedFile = {
 | 
				
			||||||
    name: file.name,
 | 
					    ...file,
 | 
				
			||||||
    objectName: file.objectName,
 | 
					    description: file.description ?? undefined,
 | 
				
			||||||
    type: file.extension,
 | 
					 | 
				
			||||||
    id: file.id,
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return <FilePreviewModal isOpen={isOpen} onClose={onClose} file={adaptedFile} isReverseShare={true} />;
 | 
					  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">
 | 
					      <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>
 | 
					        <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">
 | 
					        <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" : ""}`} />
 | 
					            <IconRefresh className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
 | 
				
			||||||
          </Button>
 | 
					          </Button>
 | 
				
			||||||
          <Button onClick={onCreateReverseShare} className="w-full sm:w-auto">
 | 
					          <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";
 | 
					const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ objectPath: string[] }> }) {
 | 
					export async function GET(req: NextRequest) {
 | 
				
			||||||
  const { objectPath } = await params;
 | 
					 | 
				
			||||||
  const cookieHeader = req.headers.get("cookie");
 | 
					  const cookieHeader = req.headers.get("cookie");
 | 
				
			||||||
  const objectName = objectPath.join("/");
 | 
					 | 
				
			||||||
  const searchParams = req.nextUrl.searchParams;
 | 
					  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 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, {
 | 
					  const apiRes = await fetch(url, {
 | 
				
			||||||
    method: "GET",
 | 
					    method: "GET",
 | 
				
			||||||
							
								
								
									
										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>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -3,10 +3,20 @@
 | 
				
			|||||||
import { IconDownload } from "@tabler/icons-react";
 | 
					import { IconDownload } from "@tabler/icons-react";
 | 
				
			||||||
import { useTranslations } from "next-intl";
 | 
					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 { 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 { useFilePreview } from "@/hooks/use-file-preview";
 | 
				
			||||||
import { getFileIcon } from "@/utils/file-icons";
 | 
					import { getFileIcon } from "@/utils/file-icons";
 | 
				
			||||||
 | 
					import { getFileType } from "@/utils/file-types";
 | 
				
			||||||
import { FilePreviewRenderer } from "./previews";
 | 
					import { FilePreviewRenderer } from "./previews";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface FilePreviewModalProps {
 | 
					interface FilePreviewModalProps {
 | 
				
			||||||
@@ -32,6 +42,10 @@ export function FilePreviewModal({
 | 
				
			|||||||
}: FilePreviewModalProps) {
 | 
					}: FilePreviewModalProps) {
 | 
				
			||||||
  const t = useTranslations();
 | 
					  const t = useTranslations();
 | 
				
			||||||
  const previewState = useFilePreview({ file, isOpen, isReverseShare, sharePassword });
 | 
					  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 (
 | 
					  return (
 | 
				
			||||||
    <Dialog open={isOpen} onOpenChange={onClose}>
 | 
					    <Dialog open={isOpen} onOpenChange={onClose}>
 | 
				
			||||||
@@ -44,6 +58,7 @@ export function FilePreviewModal({
 | 
				
			|||||||
            })()}
 | 
					            })()}
 | 
				
			||||||
            <span className="truncate">{file.name}</span>
 | 
					            <span className="truncate">{file.name}</span>
 | 
				
			||||||
          </DialogTitle>
 | 
					          </DialogTitle>
 | 
				
			||||||
 | 
					          <DialogDescription className="sr-only">{t("filePreview.description")}</DialogDescription>
 | 
				
			||||||
        </DialogHeader>
 | 
					        </DialogHeader>
 | 
				
			||||||
        <div className="flex-1 overflow-auto">
 | 
					        <div className="flex-1 overflow-auto">
 | 
				
			||||||
          <FilePreviewRenderer
 | 
					          <FilePreviewRenderer
 | 
				
			||||||
@@ -59,6 +74,16 @@ export function FilePreviewModal({
 | 
				
			|||||||
            description={file.description}
 | 
					            description={file.description}
 | 
				
			||||||
            onDownload={previewState.handleDownload}
 | 
					            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>
 | 
					        </div>
 | 
				
			||||||
        <DialogFooter>
 | 
					        <DialogFooter>
 | 
				
			||||||
          <Button variant="outline" onClick={onClose}>
 | 
					          <Button variant="outline" onClick={onClose}>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -163,8 +163,7 @@ export function FilesGrid({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        try {
 | 
					        try {
 | 
				
			||||||
          loadingUrls.current.add(file.objectName);
 | 
					          loadingUrls.current.add(file.objectName);
 | 
				
			||||||
          const encodedObjectName = encodeURIComponent(file.objectName);
 | 
					          const response = await getDownloadUrl(file.objectName);
 | 
				
			||||||
          const response = await getDownloadUrl(encodedObjectName);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          if (!componentMounted.current) break;
 | 
					          if (!componentMounted.current) break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -187,8 +187,7 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      let url = downloadUrl;
 | 
					      let url = downloadUrl;
 | 
				
			||||||
      if (!url) {
 | 
					      if (!url) {
 | 
				
			||||||
        const encodedObjectName = encodeURIComponent(objectName);
 | 
					        const response = await getDownloadUrl(objectName);
 | 
				
			||||||
        const response = await getDownloadUrl(encodedObjectName);
 | 
					 | 
				
			||||||
        url = response.data.url;
 | 
					        url = response.data.url;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -181,12 +181,11 @@ export function useFilePreview({ file, isOpen, isReverseShare = false, sharePass
 | 
				
			|||||||
        const response = await downloadReverseShareFile(file.id!);
 | 
					        const response = await downloadReverseShareFile(file.id!);
 | 
				
			||||||
        url = response.data.url;
 | 
					        url = response.data.url;
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        const encodedObjectName = encodeURIComponent(file.objectName);
 | 
					 | 
				
			||||||
        const params: Record<string, string> = {};
 | 
					        const params: Record<string, string> = {};
 | 
				
			||||||
        if (sharePassword) params.password = sharePassword;
 | 
					        if (sharePassword) params.password = sharePassword;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const response = await getDownloadUrl(
 | 
					        const response = await getDownloadUrl(
 | 
				
			||||||
          encodedObjectName,
 | 
					          file.objectName,
 | 
				
			||||||
          Object.keys(params).length > 0
 | 
					          Object.keys(params).length > 0
 | 
				
			||||||
            ? {
 | 
					            ? {
 | 
				
			||||||
                params: { ...params },
 | 
					                params: { ...params },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -80,7 +80,8 @@ export const getDownloadUrl = <TData = GetDownloadUrlResult>(
 | 
				
			|||||||
  objectName: string,
 | 
					  objectName: string,
 | 
				
			||||||
  options?: AxiosRequestConfig
 | 
					  options?: AxiosRequestConfig
 | 
				
			||||||
): Promise<TData> => {
 | 
					): Promise<TData> => {
 | 
				
			||||||
  return apiInstance.get(`/api/files/download/${objectName}`, options);
 | 
					  const encodedObjectName = encodeURIComponent(objectName);
 | 
				
			||||||
 | 
					  return apiInstance.get(`/api/files/download-url?objectName=${encodedObjectName}`, options);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,8 +21,7 @@ async function waitForDownloadReady(objectName: string, fileName: string): Promi
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  while (attempts < maxAttempts) {
 | 
					  while (attempts < maxAttempts) {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const encodedObjectName = encodeURIComponent(objectName);
 | 
					      const response = await getDownloadUrl(objectName);
 | 
				
			||||||
      const response = await getDownloadUrl(encodedObjectName);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (response.status !== 202) {
 | 
					      if (response.status !== 202) {
 | 
				
			||||||
        return response.data.url;
 | 
					        return response.data.url;
 | 
				
			||||||
@@ -98,13 +97,12 @@ export async function downloadFileWithQueue(
 | 
				
			|||||||
      options.onStart?.(downloadId);
 | 
					      options.onStart?.(downloadId);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const encodedObjectName = encodeURIComponent(objectName);
 | 
					    // getDownloadUrl already handles encoding
 | 
				
			||||||
 | 
					 | 
				
			||||||
    const params: Record<string, string> = {};
 | 
					    const params: Record<string, string> = {};
 | 
				
			||||||
    if (sharePassword) params.password = sharePassword;
 | 
					    if (sharePassword) params.password = sharePassword;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const response = await getDownloadUrl(
 | 
					    const response = await getDownloadUrl(
 | 
				
			||||||
      encodedObjectName,
 | 
					      objectName,
 | 
				
			||||||
      Object.keys(params).length > 0
 | 
					      Object.keys(params).length > 0
 | 
				
			||||||
        ? {
 | 
					        ? {
 | 
				
			||||||
            params: { ...params },
 | 
					            params: { ...params },
 | 
				
			||||||
@@ -208,13 +206,12 @@ export async function downloadFileAsBlobWithQueue(
 | 
				
			|||||||
        downloadUrl = response.data.url;
 | 
					        downloadUrl = response.data.url;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      const encodedObjectName = encodeURIComponent(objectName);
 | 
					      // getDownloadUrl already handles encoding
 | 
				
			||||||
 | 
					 | 
				
			||||||
      const params: Record<string, string> = {};
 | 
					      const params: Record<string, string> = {};
 | 
				
			||||||
      if (sharePassword) params.password = sharePassword;
 | 
					      if (sharePassword) params.password = sharePassword;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const response = await getDownloadUrl(
 | 
					      const response = await getDownloadUrl(
 | 
				
			||||||
        encodedObjectName,
 | 
					        objectName,
 | 
				
			||||||
        Object.keys(params).length > 0
 | 
					        Object.keys(params).length > 0
 | 
				
			||||||
          ? {
 | 
					          ? {
 | 
				
			||||||
              params: { ...params },
 | 
					              params: { ...params },
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,10 +6,13 @@ echo "🌴 Starting Palmr Server..."
 | 
				
			|||||||
TARGET_UID=${PALMR_UID:-1000}
 | 
					TARGET_UID=${PALMR_UID:-1000}
 | 
				
			||||||
TARGET_GID=${PALMR_GID:-1000}
 | 
					TARGET_GID=${PALMR_GID:-1000}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
if [ -n "$PALMR_UID" ] || [ -n "$PALMR_GID" ]; then
 | 
					echo "🔧 Runtime UID/GID: $TARGET_UID:$TARGET_GID"
 | 
				
			||||||
    echo "🔧 Runtime UID/GID: $TARGET_UID:$TARGET_GID"
 | 
					
 | 
				
			||||||
    
 | 
					# Check if we need to update ownership
 | 
				
			||||||
    echo "🔐 Updating file 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 /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"
 | 
					    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",
 | 
					  "name": "palmr-monorepo",
 | 
				
			||||||
  "version": "3.2.4-beta",
 | 
					  "version": "3.2.5-beta",
 | 
				
			||||||
  "description": "Palmr monorepo with Husky configuration",
 | 
					  "description": "Palmr monorepo with Husky configuration",
 | 
				
			||||||
  "private": true,
 | 
					  "private": true,
 | 
				
			||||||
  "packageManager": "pnpm@10.6.0",
 | 
					  "packageManager": "pnpm@10.6.0",
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user