mirror of
				https://github.com/kyantech/Palmr.git
				synced 2025-11-03 21:43:20 +00:00 
			
		
		
		
	Compare commits
	
		
			23 Commits
		
	
	
		
			v3.0.0-bet
			...
			v3.0.0-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					6a1381684b | ||
| 
						 | 
					dc20770fe6 | ||
| 
						 | 
					6e526f7f88 | ||
| 
						 | 
					858852c8cd | ||
| 
						 | 
					363dedbb2c | ||
| 
						 | 
					cd215c79b8 | ||
| 
						 | 
					98586efbcd | ||
| 
						 | 
					c724e644c7 | ||
| 
						 | 
					555ff18a87 | ||
| 
						 | 
					5100e1591b | ||
| 
						 | 
					6de29bbf07 | ||
| 
						 | 
					39c47be940 | ||
| 
						 | 
					76d96816bc | ||
| 
						 | 
					b3e7658a76 | ||
| 
						 | 
					61a579aeb3 | ||
| 
						 | 
					cc9c375774 | ||
| 
						 | 
					016006ba3d | ||
| 
						 | 
					cbc567c6a8 | ||
| 
						 | 
					25b4d886f7 | ||
| 
						 | 
					98953e042b | ||
| 
						 | 
					9e06a67593 | ||
| 
						 | 
					9682f96905 | ||
| 
						 | 
					d2c69c3b36 | 
@@ -132,6 +132,7 @@ set -e
 | 
			
		||||
 | 
			
		||||
echo "Starting Palmr Application..."
 | 
			
		||||
echo "Storage Mode: \${ENABLE_S3:-false}"
 | 
			
		||||
echo "Secure Site: \${SECURE_SITE:-false}"
 | 
			
		||||
echo "Database: SQLite"
 | 
			
		||||
 | 
			
		||||
# Set global environment variables
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@
 | 
			
		||||
    "configuring-smtp",
 | 
			
		||||
    "available-languages",
 | 
			
		||||
    "uid-gid-configuration",
 | 
			
		||||
    "reverse-proxy-configuration",
 | 
			
		||||
    "password-reset-without-smtp",
 | 
			
		||||
    "oidc-authentication",
 | 
			
		||||
    "---Developers---",
 | 
			
		||||
 
 | 
			
		||||
@@ -56,6 +56,7 @@ services:
 | 
			
		||||
    environment:
 | 
			
		||||
      - ENABLE_S3=false
 | 
			
		||||
      - ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
 | 
			
		||||
      # - SECURE_SITE=false # Set to true if you are using a reverse proxy
 | 
			
		||||
    ports:
 | 
			
		||||
      - "5487:5487" # Web interface
 | 
			
		||||
      - "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
 | 
			
		||||
@@ -91,6 +92,7 @@ services:
 | 
			
		||||
    environment:
 | 
			
		||||
      - ENABLE_S3=false
 | 
			
		||||
      - ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
 | 
			
		||||
      # - SECURE_SITE=false # Set to true if you are using a reverse proxy
 | 
			
		||||
      # Optional: Set custom UID/GID for file permissions
 | 
			
		||||
      # - PALMR_UID=1000
 | 
			
		||||
      # - PALMR_GID=1000
 | 
			
		||||
@@ -121,9 +123,12 @@ Configure Palmr. behavior through environment variables:
 | 
			
		||||
| ---------------- | ------- | ------------------------------------------------------- |
 | 
			
		||||
| `ENABLE_S3`      | `false` | Enable S3-compatible storage                            |
 | 
			
		||||
| `ENCRYPTION_KEY` | -       | **Required**: Minimum 32 characters for file encryption |
 | 
			
		||||
| `SECURE_SITE`    | `false` | Enable secure cookies for HTTPS/reverse proxy setups    |
 | 
			
		||||
 | 
			
		||||
> **⚠️ Security Warning**: Always change the `ENCRYPTION_KEY` in production. This key encrypts your files - losing it makes files permanently inaccessible.
 | 
			
		||||
 | 
			
		||||
> **🔗 Reverse Proxy**: If deploying behind a reverse proxy (Traefik, Nginx, etc.), set `SECURE_SITE=true` and review our [Reverse Proxy Configuration](/docs/3.0-beta/reverse-proxy-configuration) guide for proper setup.
 | 
			
		||||
 | 
			
		||||
### Generate Secure Encryption Keys
 | 
			
		||||
 | 
			
		||||
Need a strong key for `ENCRYPTION_KEY`? Use our built-in generator to create cryptographically secure keys:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										199
									
								
								apps/docs/content/docs/3.0-beta/reverse-proxy-configuration.mdx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								apps/docs/content/docs/3.0-beta/reverse-proxy-configuration.mdx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,199 @@
 | 
			
		||||
---
 | 
			
		||||
title: Reverse Proxy Configuration
 | 
			
		||||
icon: "Shield"
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
When deploying **Palmr.** behind a reverse proxy (like Traefik, Nginx, or Cloudflare), you need to configure secure cookie settings to ensure proper authentication. This guide covers the `SECURE_SITE` environment variable and related proxy configurations.
 | 
			
		||||
 | 
			
		||||
## Overview
 | 
			
		||||
 | 
			
		||||
Reverse proxies terminate SSL/TLS connections and forward requests to Palmr., which can cause authentication issues if cookies aren't configured properly for HTTPS environments. The `SECURE_SITE` environment variable controls cookie security settings to handle these scenarios.
 | 
			
		||||
 | 
			
		||||
## The SECURE_SITE Environment Variable
 | 
			
		||||
 | 
			
		||||
The `SECURE_SITE` variable configures how Palmr. handles authentication cookies based on your deployment environment:
 | 
			
		||||
 | 
			
		||||
### Configuration Options
 | 
			
		||||
 | 
			
		||||
| Value   | Cookie Settings                       | Use Case                            |
 | 
			
		||||
| ------- | ------------------------------------- | ----------------------------------- |
 | 
			
		||||
| `true`  | `secure: true`, `sameSite: "lax"`     | HTTPS/Production with reverse proxy |
 | 
			
		||||
| `false` | `secure: false`, `sameSite: "strict"` | HTTP/Development (default)          |
 | 
			
		||||
 | 
			
		||||
### When to Use SECURE_SITE=true
 | 
			
		||||
 | 
			
		||||
Set `SECURE_SITE=true` in the following scenarios:
 | 
			
		||||
 | 
			
		||||
- ✅ **Reverse Proxy with HTTPS**: Traefik, Nginx, HAProxy with SSL termination
 | 
			
		||||
- ✅ **Cloud Providers**: Cloudflare, AWS ALB, Azure Application Gateway
 | 
			
		||||
- ✅ **CDN with HTTPS**: Any CDN that terminates SSL
 | 
			
		||||
- ✅ **Production Deployments**: When users access via HTTPS
 | 
			
		||||
 | 
			
		||||
### When to Use SECURE_SITE=false
 | 
			
		||||
 | 
			
		||||
Keep `SECURE_SITE=false` (default) for:
 | 
			
		||||
 | 
			
		||||
- ✅ **Local Development**: Running on `http://localhost`
 | 
			
		||||
- ✅ **Direct HTTP Access**: No reverse proxy involved
 | 
			
		||||
- ✅ **Testing Environments**: When using HTTP
 | 
			
		||||
- ✅ **HTTP Reverse Proxy**: Nginx, Apache, etc. without SSL termination
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## HTTP Reverse Proxy Setup
 | 
			
		||||
 | 
			
		||||
**Docker Compose for HTTP Nginx:**
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
environment:
 | 
			
		||||
  - SECURE_SITE=false # HTTP = false
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
> **⚠️ HTTP Security**: Remember that HTTP transmits data in plain text. Consider using HTTPS in production environments.
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## Troubleshooting Authentication Issues
 | 
			
		||||
 | 
			
		||||
### Common Symptoms
 | 
			
		||||
 | 
			
		||||
If you experience authentication issues behind a reverse proxy:
 | 
			
		||||
 | 
			
		||||
- ❌ Login appears successful but redirects to login page
 | 
			
		||||
- ❌ "No Authorization was found in request.cookies" errors
 | 
			
		||||
- ❌ API requests return 401 Unauthorized
 | 
			
		||||
- ❌ User registration fails silently
 | 
			
		||||
 | 
			
		||||
### Diagnostic Steps
 | 
			
		||||
 | 
			
		||||
1. **Check Browser Developer Tools**:
 | 
			
		||||
 | 
			
		||||
   - Look for cookies in Application/Storage tab
 | 
			
		||||
   - Verify cookie has `Secure` flag when using HTTPS
 | 
			
		||||
   - Check if `SameSite` attribute is appropriate
 | 
			
		||||
 | 
			
		||||
2. **Verify Environment Variables**:
 | 
			
		||||
 | 
			
		||||
   ```bash
 | 
			
		||||
   docker exec -it palmr env | grep SECURE_SITE
 | 
			
		||||
   ```
 | 
			
		||||
 | 
			
		||||
3. **Test Cookie Settings**:
 | 
			
		||||
   - With `SECURE_SITE=false`: Should work on HTTP
 | 
			
		||||
   - With `SECURE_SITE=true`: Should work on HTTPS
 | 
			
		||||
 | 
			
		||||
### Common Fixes
 | 
			
		||||
 | 
			
		||||
**Problem**: Authentication fails with reverse proxy
 | 
			
		||||
 | 
			
		||||
**Solution**: Set `SECURE_SITE=true` and ensure proper headers:
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
environment:
 | 
			
		||||
  - SECURE_SITE=true
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
**Problem**: Mixed content errors
 | 
			
		||||
 | 
			
		||||
**Solution**: Ensure proxy passes correct headers:
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
# Traefik
 | 
			
		||||
- "traefik.http.middlewares.palmr-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
 | 
			
		||||
 | 
			
		||||
# Nginx
 | 
			
		||||
proxy_set_header X-Forwarded-Proto $scheme;
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
**Problem**: Authentication fails with HTTP reverse proxy
 | 
			
		||||
 | 
			
		||||
**Solution**: Use `SECURE_SITE=false` and ensure proper cookie headers:
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
environment:
 | 
			
		||||
  - SECURE_SITE=false # For HTTP proxy
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
```nginx
 | 
			
		||||
# Nginx - Add these headers for cookie handling
 | 
			
		||||
proxy_set_header Cookie $http_cookie;
 | 
			
		||||
proxy_pass_header Set-Cookie;
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
**Problem**: SQLite "readonly database" error with bind mounts
 | 
			
		||||
 | 
			
		||||
**Solution**: Configure proper UID/GID permissions:
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
environment:
 | 
			
		||||
  - PALMR_UID=1000 # Your host UID (check with: id)
 | 
			
		||||
  - PALMR_GID=1000 # Your host GID
 | 
			
		||||
  - ENCRYPTION_KEY=your-key-here
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
> **💡 Note**: Check your host UID/GID with `id` command and use those values. See [UID/GID Configuration](/docs/3.0-beta/uid-gid-configuration) for detailed setup.
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## Security Considerations
 | 
			
		||||
 | 
			
		||||
> **⚠️ Important**: Always use HTTPS in production environments. The `SECURE_SITE=true` setting ensures cookies are only sent over encrypted connections.
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## Advanced Configuration
 | 
			
		||||
 | 
			
		||||
### Multiple Domains
 | 
			
		||||
 | 
			
		||||
If serving Palmr. on multiple domains, ensure consistent cookie settings:
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
environment:
 | 
			
		||||
  - SECURE_SITE=true # Use for all HTTPS domains
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Development vs Production
 | 
			
		||||
 | 
			
		||||
Use environment-specific configurations:
 | 
			
		||||
 | 
			
		||||
**Development (HTTP):**
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
environment:
 | 
			
		||||
  - SECURE_SITE=false
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
**Production (HTTPS):**
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
environment:
 | 
			
		||||
  - SECURE_SITE=true
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Health Checks
 | 
			
		||||
 | 
			
		||||
Add health checks to ensure proper proxy configuration:
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
services:
 | 
			
		||||
  palmr:
 | 
			
		||||
    # ... other config
 | 
			
		||||
    healthcheck:
 | 
			
		||||
      test: ["CMD", "curl", "-f", "http://localhost:5487/api/health"]
 | 
			
		||||
      interval: 30s
 | 
			
		||||
      timeout: 10s
 | 
			
		||||
      retries: 3
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## Need Help?
 | 
			
		||||
 | 
			
		||||
If you're still experiencing issues after following this guide:
 | 
			
		||||
 | 
			
		||||
1. **Check the Logs**: `docker logs palmr`
 | 
			
		||||
2. **Verify Headers**: Use browser dev tools or `curl -I`
 | 
			
		||||
3. **Test Direct Access**: Try accessing Palmr. directly (bypassing proxy)
 | 
			
		||||
4. **Open an Issue**: [Report bugs on GitHub](https://github.com/kyantech/Palmr/issues)
 | 
			
		||||
 | 
			
		||||
> **💡 Pro Tip**: When reporting issues, include your reverse proxy configuration and any relevant error messages from both Palmr. and your proxy logs.
 | 
			
		||||
@@ -11,6 +11,7 @@ const envSchema = z.object({
 | 
			
		||||
  S3_REGION: z.string().optional(),
 | 
			
		||||
  S3_BUCKET_NAME: z.string().optional(),
 | 
			
		||||
  S3_FORCE_PATH_STYLE: z.union([z.literal("true"), z.literal("false")]).default("false"),
 | 
			
		||||
  SECURE_SITE: z.union([z.literal("true"), z.literal("false")]).default("false"),
 | 
			
		||||
  DATABASE_URL: z.string().optional().default("file:/app/server/prisma/palmr.db"),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,21 @@ import { FastifyReply, FastifyRequest } from "fastify";
 | 
			
		||||
import fs from "fs";
 | 
			
		||||
import path from "path";
 | 
			
		||||
 | 
			
		||||
const uploadsDir = path.join(process.cwd(), "uploads/logo");
 | 
			
		||||
const isDocker = (() => {
 | 
			
		||||
  try {
 | 
			
		||||
    require("fs").statSync("/.dockerenv");
 | 
			
		||||
    return true;
 | 
			
		||||
  } catch {
 | 
			
		||||
    try {
 | 
			
		||||
      return require("fs").readFileSync("/proc/self/cgroup", "utf8").includes("docker");
 | 
			
		||||
    } catch {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
})();
 | 
			
		||||
 | 
			
		||||
const baseDir = isDocker ? "/app/server" : process.cwd();
 | 
			
		||||
const uploadsDir = path.join(baseDir, "uploads/logo");
 | 
			
		||||
if (!fs.existsSync(uploadsDir)) {
 | 
			
		||||
  fs.mkdirSync(uploadsDir, { recursive: true });
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import { env } from "../../env";
 | 
			
		||||
import { LoginSchema, RequestPasswordResetSchema, createResetPasswordSchema } from "./dto";
 | 
			
		||||
import { AuthService } from "./service";
 | 
			
		||||
import { FastifyReply, FastifyRequest } from "fastify";
 | 
			
		||||
@@ -17,8 +18,8 @@ export class AuthController {
 | 
			
		||||
      reply.setCookie("token", token, {
 | 
			
		||||
        httpOnly: true,
 | 
			
		||||
        path: "/",
 | 
			
		||||
        secure: false,
 | 
			
		||||
        sameSite: "strict",
 | 
			
		||||
        secure: env.SECURE_SITE === "true" ? true : false,
 | 
			
		||||
        sameSite: env.SECURE_SITE === "true" ? "lax" : "strict",
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      return reply.send({ user });
 | 
			
		||||
 
 | 
			
		||||
@@ -2,12 +2,35 @@ import { ConfigService } from "../config/service";
 | 
			
		||||
import { PrismaClient } from "@prisma/client";
 | 
			
		||||
import { exec } from "child_process";
 | 
			
		||||
import { promisify } from "util";
 | 
			
		||||
import fs from 'node:fs';
 | 
			
		||||
 | 
			
		||||
const execAsync = promisify(exec);
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
export class StorageService {
 | 
			
		||||
  private configService = new ConfigService();
 | 
			
		||||
  private isDockerCached = undefined;
 | 
			
		||||
 | 
			
		||||
  private _hasDockerEnv() {
 | 
			
		||||
    try {
 | 
			
		||||
      fs.statSync('/.dockerenv');
 | 
			
		||||
      return true;
 | 
			
		||||
    } catch {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _hasDockerCGroup() {
 | 
			
		||||
    try {
 | 
			
		||||
      return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker');
 | 
			
		||||
    } catch {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _isDocker() {
 | 
			
		||||
    return this.isDockerCached ?? (this._hasDockerEnv() || this._hasDockerCGroup());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getDiskSpace(
 | 
			
		||||
    userId?: string,
 | 
			
		||||
@@ -20,11 +43,14 @@ export class StorageService {
 | 
			
		||||
  }> {
 | 
			
		||||
    try {
 | 
			
		||||
      if (isAdmin) {
 | 
			
		||||
        const command = process.platform === "win32" 
 | 
			
		||||
          ? "wmic logicaldisk get size,freespace,caption" 
 | 
			
		||||
          : process.platform === "darwin" 
 | 
			
		||||
            ? "df -k ." 
 | 
			
		||||
            : "df -B1 .";
 | 
			
		||||
        const isDocker = this._isDocker();
 | 
			
		||||
        const pathToCheck = isDocker ? "/app/server/uploads" : ".";
 | 
			
		||||
 | 
			
		||||
        const command = process.platform === "win32"
 | 
			
		||||
          ? "wmic logicaldisk get size,freespace,caption"
 | 
			
		||||
          : process.platform === "darwin"
 | 
			
		||||
            ? `df -k ${pathToCheck}`
 | 
			
		||||
            : `df -B1 ${pathToCheck}`;
 | 
			
		||||
 | 
			
		||||
        const { stdout } = await execAsync(command);
 | 
			
		||||
        let total = 0;
 | 
			
		||||
 
 | 
			
		||||
@@ -14,20 +14,26 @@ export async function userRoutes(app: FastifyInstance) {
 | 
			
		||||
      const usersCount = await prisma.user.count();
 | 
			
		||||
 | 
			
		||||
      if (usersCount > 0) {
 | 
			
		||||
        await request.jwtVerify();
 | 
			
		||||
        if (!request.user.isAdmin) {
 | 
			
		||||
        try {
 | 
			
		||||
          await request.jwtVerify();
 | 
			
		||||
          if (!request.user.isAdmin) {
 | 
			
		||||
            return reply
 | 
			
		||||
              .status(403)
 | 
			
		||||
              .send({ error: "Access restricted to administrators" })
 | 
			
		||||
              .description("Access restricted to administrators");
 | 
			
		||||
          }
 | 
			
		||||
        } catch (authErr) {
 | 
			
		||||
          console.error(authErr);
 | 
			
		||||
          return reply
 | 
			
		||||
            .status(403)
 | 
			
		||||
            .send({ error: "Access restricted to administrators" })
 | 
			
		||||
            .description("Access restricted to administrators");
 | 
			
		||||
            .status(401)
 | 
			
		||||
            .send({ error: "Unauthorized: a valid token is required to access this resource." })
 | 
			
		||||
            .description("Unauthorized: a valid token is required to access this resource.");
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      // If usersCount is 0, allow the request to proceed without authentication
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error(err);
 | 
			
		||||
      return reply
 | 
			
		||||
        .status(401)
 | 
			
		||||
        .send({ error: "Unauthorized: a valid token is required to access this resource." })
 | 
			
		||||
        .description("Unauthorized: a valid token is required to access this resource.");
 | 
			
		||||
      return reply.status(500).send({ error: "Internal server error" }).description("Internal server error");
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,16 +9,31 @@ import { pipeline } from "stream/promises";
 | 
			
		||||
 | 
			
		||||
export class FilesystemStorageProvider implements StorageProvider {
 | 
			
		||||
  private static instance: FilesystemStorageProvider;
 | 
			
		||||
  private uploadsDir = path.join(process.cwd(), "uploads");
 | 
			
		||||
  private uploadsDir: string;
 | 
			
		||||
  private encryptionKey = env.ENCRYPTION_KEY;
 | 
			
		||||
  private uploadTokens = new Map<string, { objectName: string; expiresAt: number }>();
 | 
			
		||||
  private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
 | 
			
		||||
 | 
			
		||||
  private constructor() {
 | 
			
		||||
    this.uploadsDir = this.isDocker() ? "/app/server/uploads" : path.join(process.cwd(), "uploads");
 | 
			
		||||
 | 
			
		||||
    this.ensureUploadsDir();
 | 
			
		||||
    setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private isDocker(): boolean {
 | 
			
		||||
    try {
 | 
			
		||||
      fsSync.statSync("/.dockerenv");
 | 
			
		||||
      return true;
 | 
			
		||||
    } catch {
 | 
			
		||||
      try {
 | 
			
		||||
        return fsSync.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
 | 
			
		||||
      } catch {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static getInstance(): FilesystemStorageProvider {
 | 
			
		||||
    if (!FilesystemStorageProvider.instance) {
 | 
			
		||||
      FilesystemStorageProvider.instance = new FilesystemStorageProvider();
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ import { storageRoutes } from "./modules/storage/routes";
 | 
			
		||||
import { userRoutes } from "./modules/user/routes";
 | 
			
		||||
import fastifyMultipart from "@fastify/multipart";
 | 
			
		||||
import fastifyStatic from "@fastify/static";
 | 
			
		||||
import * as fsSync from "fs";
 | 
			
		||||
import * as fs from "fs/promises";
 | 
			
		||||
import crypto from "node:crypto";
 | 
			
		||||
import path from "path";
 | 
			
		||||
@@ -26,21 +27,36 @@ if (typeof global.crypto === "undefined") {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function ensureDirectories() {
 | 
			
		||||
  const uploadsDir = path.join(process.cwd(), "uploads");
 | 
			
		||||
  const tempChunksDir = path.join(process.cwd(), "temp-chunks");
 | 
			
		||||
  // Use /app/server paths in Docker, current directory for local development
 | 
			
		||||
  const isDocker = (() => {
 | 
			
		||||
    try {
 | 
			
		||||
      fsSync.statSync("/.dockerenv");
 | 
			
		||||
      return true;
 | 
			
		||||
    } catch {
 | 
			
		||||
      try {
 | 
			
		||||
        return fsSync.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
 | 
			
		||||
      } catch {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  })();
 | 
			
		||||
 | 
			
		||||
  const baseDir = isDocker ? "/app/server" : process.cwd();
 | 
			
		||||
  const uploadsDir = path.join(baseDir, "uploads");
 | 
			
		||||
  const tempChunksDir = path.join(baseDir, "temp-chunks");
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    await fs.access(uploadsDir);
 | 
			
		||||
  } catch {
 | 
			
		||||
    await fs.mkdir(uploadsDir, { recursive: true });
 | 
			
		||||
    console.log("📁 Created uploads directory");
 | 
			
		||||
    console.log(`📁 Created uploads directory: ${uploadsDir}`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    await fs.access(tempChunksDir);
 | 
			
		||||
  } catch {
 | 
			
		||||
    await fs.mkdir(tempChunksDir, { recursive: true });
 | 
			
		||||
    console.log("📁 Created temp-chunks directory");
 | 
			
		||||
    console.log(`📁 Created temp-chunks directory: ${tempChunksDir}`);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -62,8 +78,24 @@ async function startServer() {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (env.ENABLE_S3 !== "true") {
 | 
			
		||||
    const isDocker = (() => {
 | 
			
		||||
      try {
 | 
			
		||||
        fsSync.statSync("/.dockerenv");
 | 
			
		||||
        return true;
 | 
			
		||||
      } catch {
 | 
			
		||||
        try {
 | 
			
		||||
          return fsSync.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
 | 
			
		||||
        } catch {
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    })();
 | 
			
		||||
 | 
			
		||||
    const baseDir = isDocker ? "/app/server" : process.cwd();
 | 
			
		||||
    const uploadsPath = path.join(baseDir, "uploads");
 | 
			
		||||
 | 
			
		||||
    await app.register(fastifyStatic, {
 | 
			
		||||
      root: path.join(process.cwd(), "uploads"),
 | 
			
		||||
      root: uploadsPath,
 | 
			
		||||
      prefix: "/uploads/",
 | 
			
		||||
      decorateReply: false,
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
@@ -2,12 +2,12 @@ import { useState } from "react";
 | 
			
		||||
import { IconDownload, IconEye } from "@tabler/icons-react";
 | 
			
		||||
import { useTranslations } from "next-intl";
 | 
			
		||||
 | 
			
		||||
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
 | 
			
		||||
import { getFileIcon } from "@/utils/file-icons";
 | 
			
		||||
import { formatFileSize } from "@/utils/format-file-size";
 | 
			
		||||
import { ShareFilesTableProps } from "../types";
 | 
			
		||||
import { ShareFilePreviewModal } from "./share-file-preview-modal";
 | 
			
		||||
 | 
			
		||||
export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
@@ -99,7 +99,14 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
 | 
			
		||||
        </Table>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {selectedFile && <FilePreviewModal isOpen={isPreviewOpen} onClose={handleClosePreview} file={selectedFile} />}
 | 
			
		||||
      {selectedFile && (
 | 
			
		||||
        <ShareFilePreviewModal
 | 
			
		||||
          isOpen={isPreviewOpen}
 | 
			
		||||
          onClose={handleClosePreview}
 | 
			
		||||
          file={selectedFile}
 | 
			
		||||
          onDownload={onDownload}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,320 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { IconDownload } from "@tabler/icons-react";
 | 
			
		||||
import { useTranslations } from "next-intl";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
 | 
			
		||||
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
 | 
			
		||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
 | 
			
		||||
import { ScrollArea } from "@/components/ui/scroll-area";
 | 
			
		||||
import { getDownloadUrl } from "@/http/endpoints";
 | 
			
		||||
import { getFileIcon } from "@/utils/file-icons";
 | 
			
		||||
 | 
			
		||||
interface ShareFilePreviewModalProps {
 | 
			
		||||
  isOpen: boolean;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  file: {
 | 
			
		||||
    name: string;
 | 
			
		||||
    objectName: string;
 | 
			
		||||
    type?: string;
 | 
			
		||||
  };
 | 
			
		||||
  onDownload: (objectName: string, fileName: string) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ShareFilePreviewModal({ isOpen, onClose, file, onDownload }: ShareFilePreviewModalProps) {
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(true);
 | 
			
		||||
  const [videoBlob, setVideoBlob] = useState<string | null>(null);
 | 
			
		||||
  const [pdfAsBlob, setPdfAsBlob] = useState(false);
 | 
			
		||||
  const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
 | 
			
		||||
  const [pdfLoadFailed, setPdfLoadFailed] = useState(false);
 | 
			
		||||
  const [isLoadingPreview, setIsLoadingPreview] = useState(false);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (isOpen && file.objectName && !isLoadingPreview) {
 | 
			
		||||
      setIsLoading(true);
 | 
			
		||||
      setPreviewUrl(null);
 | 
			
		||||
      setVideoBlob(null);
 | 
			
		||||
      setPdfAsBlob(false);
 | 
			
		||||
      setDownloadUrl(null);
 | 
			
		||||
      setPdfLoadFailed(false);
 | 
			
		||||
      loadPreview();
 | 
			
		||||
    }
 | 
			
		||||
  }, [file.objectName, isOpen]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    return () => {
 | 
			
		||||
      if (previewUrl && previewUrl.startsWith("blob:")) {
 | 
			
		||||
        URL.revokeObjectURL(previewUrl);
 | 
			
		||||
      }
 | 
			
		||||
      if (videoBlob && videoBlob.startsWith("blob:")) {
 | 
			
		||||
        URL.revokeObjectURL(videoBlob);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
  }, [previewUrl, videoBlob]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!isOpen) {
 | 
			
		||||
      if (previewUrl && previewUrl.startsWith("blob:")) {
 | 
			
		||||
        URL.revokeObjectURL(previewUrl);
 | 
			
		||||
        setPreviewUrl(null);
 | 
			
		||||
      }
 | 
			
		||||
      if (videoBlob && videoBlob.startsWith("blob:")) {
 | 
			
		||||
        URL.revokeObjectURL(videoBlob);
 | 
			
		||||
        setVideoBlob(null);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }, [isOpen]);
 | 
			
		||||
 | 
			
		||||
  const loadPreview = async () => {
 | 
			
		||||
    if (!file.objectName || isLoadingPreview) return;
 | 
			
		||||
 | 
			
		||||
    setIsLoadingPreview(true);
 | 
			
		||||
    try {
 | 
			
		||||
      const encodedObjectName = encodeURIComponent(file.objectName);
 | 
			
		||||
      const response = await getDownloadUrl(encodedObjectName);
 | 
			
		||||
      const url = response.data.url;
 | 
			
		||||
 | 
			
		||||
      setDownloadUrl(url);
 | 
			
		||||
 | 
			
		||||
      const fileType = getFileType();
 | 
			
		||||
 | 
			
		||||
      if (fileType === "video") {
 | 
			
		||||
        await loadVideoPreview(url);
 | 
			
		||||
      } else if (fileType === "audio") {
 | 
			
		||||
        await loadAudioPreview(url);
 | 
			
		||||
      } else if (fileType === "pdf") {
 | 
			
		||||
        await loadPdfPreview(url);
 | 
			
		||||
      } else {
 | 
			
		||||
        setPreviewUrl(url);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("Failed to load preview:", error);
 | 
			
		||||
      toast.error(t("filePreview.loadError"));
 | 
			
		||||
    } finally {
 | 
			
		||||
      setIsLoading(false);
 | 
			
		||||
      setIsLoadingPreview(false);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const loadVideoPreview = async (url: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch(url);
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`HTTP error! status: ${response.status}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const blob = await response.blob();
 | 
			
		||||
      const blobUrl = URL.createObjectURL(blob);
 | 
			
		||||
      setVideoBlob(blobUrl);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("Failed to load video as blob:", error);
 | 
			
		||||
      setPreviewUrl(url);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const loadAudioPreview = async (url: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch(url);
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`HTTP error! status: ${response.status}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const blob = await response.blob();
 | 
			
		||||
      const blobUrl = URL.createObjectURL(blob);
 | 
			
		||||
      setPreviewUrl(blobUrl);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("Failed to load audio as blob:", error);
 | 
			
		||||
      setPreviewUrl(url);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const loadPdfPreview = async (url: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch(url);
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`HTTP error! status: ${response.status}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const blob = await response.blob();
 | 
			
		||||
      const finalBlob = new Blob([blob], { type: "application/pdf" });
 | 
			
		||||
      const blobUrl = URL.createObjectURL(finalBlob);
 | 
			
		||||
      setPreviewUrl(blobUrl);
 | 
			
		||||
      setPdfAsBlob(true);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("Failed to load PDF as blob:", error);
 | 
			
		||||
      setPreviewUrl(url);
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        if (!pdfLoadFailed && !pdfAsBlob) {
 | 
			
		||||
          handlePdfLoadError();
 | 
			
		||||
        }
 | 
			
		||||
      }, 4000);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handlePdfLoadError = async () => {
 | 
			
		||||
    if (pdfLoadFailed || pdfAsBlob) return;
 | 
			
		||||
 | 
			
		||||
    setPdfLoadFailed(true);
 | 
			
		||||
 | 
			
		||||
    if (downloadUrl) {
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        loadPdfPreview(downloadUrl);
 | 
			
		||||
      }, 500);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleDownload = () => {
 | 
			
		||||
    onDownload(file.objectName, file.name);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getFileType = () => {
 | 
			
		||||
    const extension = file.name.split(".").pop()?.toLowerCase();
 | 
			
		||||
 | 
			
		||||
    if (extension === "pdf") return "pdf";
 | 
			
		||||
    if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) return "image";
 | 
			
		||||
    if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) return "audio";
 | 
			
		||||
    if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) return "video";
 | 
			
		||||
 | 
			
		||||
    return "other";
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const renderPreview = () => {
 | 
			
		||||
    const fileType = getFileType();
 | 
			
		||||
    const { icon: FileIcon, color } = getFileIcon(file.name);
 | 
			
		||||
 | 
			
		||||
    if (isLoading) {
 | 
			
		||||
      return (
 | 
			
		||||
        <div className="flex flex-col items-center justify-center h-96 gap-4">
 | 
			
		||||
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
 | 
			
		||||
          <p className="text-muted-foreground">{t("filePreview.loading")}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
 | 
			
		||||
 | 
			
		||||
    if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
 | 
			
		||||
      return (
 | 
			
		||||
        <div className="flex flex-col items-center justify-center h-96 gap-4">
 | 
			
		||||
          <FileIcon className={`h-12 w-12 ${color}`} />
 | 
			
		||||
          <p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
 | 
			
		||||
          <p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!previewUrl && fileType !== "video") {
 | 
			
		||||
      return (
 | 
			
		||||
        <div className="flex flex-col items-center justify-center h-96 gap-4">
 | 
			
		||||
          <FileIcon className={`h-12 w-12 ${color}`} />
 | 
			
		||||
          <p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
 | 
			
		||||
          <p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    switch (fileType) {
 | 
			
		||||
      case "pdf":
 | 
			
		||||
        return (
 | 
			
		||||
          <ScrollArea className="w-full">
 | 
			
		||||
            <div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
 | 
			
		||||
              {pdfAsBlob ? (
 | 
			
		||||
                <iframe
 | 
			
		||||
                  src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
 | 
			
		||||
                  className="w-full h-full min-h-[600px]"
 | 
			
		||||
                  title={file.name}
 | 
			
		||||
                  style={{ border: "none" }}
 | 
			
		||||
                />
 | 
			
		||||
              ) : pdfLoadFailed ? (
 | 
			
		||||
                <div className="flex items-center justify-center h-full min-h-[600px]">
 | 
			
		||||
                  <div className="flex flex-col items-center gap-4">
 | 
			
		||||
                    <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
 | 
			
		||||
                    <p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <div className="w-full h-full min-h-[600px] relative">
 | 
			
		||||
                  <object
 | 
			
		||||
                    data={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
 | 
			
		||||
                    type="application/pdf"
 | 
			
		||||
                    className="w-full h-full min-h-[600px]"
 | 
			
		||||
                    onError={handlePdfLoadError}
 | 
			
		||||
                  >
 | 
			
		||||
                    <iframe
 | 
			
		||||
                      src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
 | 
			
		||||
                      className="w-full h-full min-h-[600px]"
 | 
			
		||||
                      title={file.name}
 | 
			
		||||
                      style={{ border: "none" }}
 | 
			
		||||
                      onError={handlePdfLoadError}
 | 
			
		||||
                    />
 | 
			
		||||
                  </object>
 | 
			
		||||
                </div>
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
          </ScrollArea>
 | 
			
		||||
        );
 | 
			
		||||
      case "image":
 | 
			
		||||
        return (
 | 
			
		||||
          <AspectRatio ratio={16 / 9} className="bg-muted">
 | 
			
		||||
            <img src={previewUrl!} alt={file.name} className="object-contain w-full h-full rounded-md" />
 | 
			
		||||
          </AspectRatio>
 | 
			
		||||
        );
 | 
			
		||||
      case "audio":
 | 
			
		||||
        return (
 | 
			
		||||
          <div className="flex flex-col items-center justify-center gap-6 py-4">
 | 
			
		||||
            <CustomAudioPlayer src={mediaUrl!} />
 | 
			
		||||
          </div>
 | 
			
		||||
        );
 | 
			
		||||
      case "video":
 | 
			
		||||
        return (
 | 
			
		||||
          <div className="flex flex-col items-center justify-center gap-4 py-6">
 | 
			
		||||
            <div className="w-full max-w-4xl">
 | 
			
		||||
              <video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
 | 
			
		||||
                <source src={mediaUrl!} />
 | 
			
		||||
                {t("filePreview.videoNotSupported")}
 | 
			
		||||
              </video>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        );
 | 
			
		||||
      default:
 | 
			
		||||
        return (
 | 
			
		||||
          <div className="flex flex-col items-center justify-center h-96 gap-4">
 | 
			
		||||
            <FileIcon className={`text-6xl ${color}`} />
 | 
			
		||||
            <p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
 | 
			
		||||
            <p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog open={isOpen} onOpenChange={onClose}>
 | 
			
		||||
      <DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
 | 
			
		||||
        <DialogHeader>
 | 
			
		||||
          <DialogTitle className="flex items-center gap-2">
 | 
			
		||||
            {(() => {
 | 
			
		||||
              const FileIcon = getFileIcon(file.name).icon;
 | 
			
		||||
              return <FileIcon size={24} />;
 | 
			
		||||
            })()}
 | 
			
		||||
            <span className="truncate">{file.name}</span>
 | 
			
		||||
          </DialogTitle>
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
        <div className="flex-1 overflow-auto">{renderPreview()}</div>
 | 
			
		||||
        <DialogFooter>
 | 
			
		||||
          <Button variant="outline" onClick={onClose}>
 | 
			
		||||
            {t("common.close")}
 | 
			
		||||
          </Button>
 | 
			
		||||
          <Button onClick={handleDownload}>
 | 
			
		||||
            <IconDownload className="h-4 w-4" />
 | 
			
		||||
            {t("common.download")}
 | 
			
		||||
          </Button>
 | 
			
		||||
        </DialogFooter>
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -3,12 +3,15 @@ import { NextRequest, NextResponse } from "next/server";
 | 
			
		||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ shareId: string }> }) {
 | 
			
		||||
  const cookieHeader = req.headers.get("cookie");
 | 
			
		||||
  const { shareId } = await params;
 | 
			
		||||
  const body = await req.text();
 | 
			
		||||
 | 
			
		||||
  const apiRes = await fetch(`${process.env.API_BASE_URL}/shares/${shareId}/recipients/notify`, {
 | 
			
		||||
  const apiRes = await fetch(`${process.env.API_BASE_URL}/shares/${shareId}/notify`, {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    headers: {
 | 
			
		||||
      "Content-Type": "application/json",
 | 
			
		||||
      cookie: cookieHeader || "",
 | 
			
		||||
    },
 | 
			
		||||
    body: body,
 | 
			
		||||
    redirect: "manual",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -14,8 +14,8 @@ export default function AuthCallbackPage() {
 | 
			
		||||
    if (token) {
 | 
			
		||||
      Cookies.set("token", token, {
 | 
			
		||||
        path: "/",
 | 
			
		||||
        secure: false,
 | 
			
		||||
        sameSite: "strict",
 | 
			
		||||
        secure: window.location.protocol === "https:",
 | 
			
		||||
        sameSite: window.location.protocol === "https:" ? "lax" : "strict",
 | 
			
		||||
        httpOnly: false,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -49,6 +49,17 @@ export function useLogin() {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const checkAuth = async () => {
 | 
			
		||||
      try {
 | 
			
		||||
        const appInfoResponse = await fetch("/api/app/info");
 | 
			
		||||
        const appInfo = await appInfoResponse.json();
 | 
			
		||||
 | 
			
		||||
        if (appInfo.firstUserAccess) {
 | 
			
		||||
          setUser(null);
 | 
			
		||||
          setIsAdmin(false);
 | 
			
		||||
          setIsAuthenticated(false);
 | 
			
		||||
          setIsInitialized(true);
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const userResponse = await getCurrentUser();
 | 
			
		||||
        if (!userResponse?.data?.user) {
 | 
			
		||||
          throw new Error("No user data");
 | 
			
		||||
 
 | 
			
		||||
@@ -49,7 +49,7 @@ export function LanguageSwitcher() {
 | 
			
		||||
      maxAge: COOKIE_MAX_AGE,
 | 
			
		||||
      path: "/",
 | 
			
		||||
      sameSite: "lax",
 | 
			
		||||
      secure: false,
 | 
			
		||||
      secure: window.location.protocol === "https:",
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    router.refresh();
 | 
			
		||||
 
 | 
			
		||||
@@ -41,6 +41,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const checkAuth = async () => {
 | 
			
		||||
      try {
 | 
			
		||||
        const appInfoResponse = await fetch("/api/app/info");
 | 
			
		||||
        const appInfo = await appInfoResponse.json();
 | 
			
		||||
 | 
			
		||||
        if (appInfo.firstUserAccess) {
 | 
			
		||||
          setUser(null);
 | 
			
		||||
          setIsAdmin(false);
 | 
			
		||||
          setIsAuthenticated(false);
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const response = await getCurrentUser();
 | 
			
		||||
        if (!response?.data?.user) {
 | 
			
		||||
          throw new Error("No user data");
 | 
			
		||||
 
 | 
			
		||||
@@ -36,46 +36,86 @@ echo "💾 Database: $DATABASE_URL"
 | 
			
		||||
echo "📁 Creating data directories..."
 | 
			
		||||
mkdir -p /app/server/prisma /app/server/uploads /app/server/temp-chunks /app/server/uploads/logo
 | 
			
		||||
 | 
			
		||||
# Fix ownership of database directory BEFORE database operations
 | 
			
		||||
if [ "$(id -u)" = "0" ]; then
 | 
			
		||||
    echo "🔐 Ensuring proper ownership before database operations..."
 | 
			
		||||
    chown -R $TARGET_UID:$TARGET_GID /app/server/prisma 2>/dev/null || true
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Check if it's a first run (no database file exists)
 | 
			
		||||
if [ ! -f "/app/server/prisma/palmr.db" ]; then
 | 
			
		||||
    echo "🚀 First run detected - setting up database..."
 | 
			
		||||
    
 | 
			
		||||
    # Create database with proper schema path
 | 
			
		||||
    # Create database with proper schema path - run as target user to avoid permission issues
 | 
			
		||||
    echo "🗄️ Creating database schema..."
 | 
			
		||||
    npx prisma db push --schema=./prisma/schema.prisma --skip-generate
 | 
			
		||||
    if [ "$(id -u)" = "0" ]; then
 | 
			
		||||
        su-exec $TARGET_UID:$TARGET_GID npx prisma db push --schema=./prisma/schema.prisma --skip-generate
 | 
			
		||||
    else
 | 
			
		||||
        npx prisma db push --schema=./prisma/schema.prisma --skip-generate
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Run seed script from application directory (where node_modules is)
 | 
			
		||||
    # Run seed script from application directory (where node_modules is) - as target user
 | 
			
		||||
    echo "🌱 Seeding database..."
 | 
			
		||||
    node ./prisma/seed.js
 | 
			
		||||
    if [ "$(id -u)" = "0" ]; then
 | 
			
		||||
        su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
 | 
			
		||||
    else
 | 
			
		||||
        node ./prisma/seed.js
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    echo "✅ Database setup completed!"
 | 
			
		||||
else
 | 
			
		||||
    echo "♻️ Existing database found"
 | 
			
		||||
    
 | 
			
		||||
    # Always run migrations to ensure schema is up to date
 | 
			
		||||
    # Always run migrations to ensure schema is up to date - as target user
 | 
			
		||||
    echo "🔧 Checking for schema updates..."
 | 
			
		||||
    npx prisma db push --schema=./prisma/schema.prisma --skip-generate
 | 
			
		||||
    if [ "$(id -u)" = "0" ]; then
 | 
			
		||||
        su-exec $TARGET_UID:$TARGET_GID npx prisma db push --schema=./prisma/schema.prisma --skip-generate
 | 
			
		||||
    else
 | 
			
		||||
        npx prisma db push --schema=./prisma/schema.prisma --skip-generate
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    # Check if configurations exist
 | 
			
		||||
    # Check if configurations exist - as target user
 | 
			
		||||
    echo "🔍 Verifying database configurations..."
 | 
			
		||||
    CONFIG_COUNT=$(node -e "
 | 
			
		||||
        const { PrismaClient } = require('@prisma/client');
 | 
			
		||||
        const prisma = new PrismaClient();
 | 
			
		||||
        prisma.appConfig.count()
 | 
			
		||||
            .then(count => {
 | 
			
		||||
                console.log(count);
 | 
			
		||||
                process.exit(0);
 | 
			
		||||
            })
 | 
			
		||||
            .catch(() => {
 | 
			
		||||
                console.log(0);
 | 
			
		||||
                process.exit(0);
 | 
			
		||||
            });
 | 
			
		||||
    " 2>/dev/null || echo "0")
 | 
			
		||||
    CONFIG_COUNT=$(
 | 
			
		||||
        if [ "$(id -u)" = "0" ]; then
 | 
			
		||||
            su-exec $TARGET_UID:$TARGET_GID node -e "
 | 
			
		||||
                const { PrismaClient } = require('@prisma/client');
 | 
			
		||||
                const prisma = new PrismaClient();
 | 
			
		||||
                prisma.appConfig.count()
 | 
			
		||||
                    .then(count => {
 | 
			
		||||
                        console.log(count);
 | 
			
		||||
                        process.exit(0);
 | 
			
		||||
                    })
 | 
			
		||||
                    .catch(() => {
 | 
			
		||||
                        console.log(0);
 | 
			
		||||
                        process.exit(0);
 | 
			
		||||
                    });
 | 
			
		||||
            " 2>/dev/null || echo "0"
 | 
			
		||||
        else
 | 
			
		||||
            node -e "
 | 
			
		||||
                const { PrismaClient } = require('@prisma/client');
 | 
			
		||||
                const prisma = new PrismaClient();
 | 
			
		||||
                prisma.appConfig.count()
 | 
			
		||||
                    .then(count => {
 | 
			
		||||
                        console.log(count);
 | 
			
		||||
                        process.exit(0);
 | 
			
		||||
                    })
 | 
			
		||||
                    .catch(() => {
 | 
			
		||||
                        console.log(0);
 | 
			
		||||
                        process.exit(0);
 | 
			
		||||
                    });
 | 
			
		||||
            " 2>/dev/null || echo "0"
 | 
			
		||||
        fi
 | 
			
		||||
    )
 | 
			
		||||
    
 | 
			
		||||
    if [ "$CONFIG_COUNT" -eq "0" ]; then
 | 
			
		||||
        echo "🌱 No configurations found, running seed..."
 | 
			
		||||
        # Always run seed from application directory where node_modules is available
 | 
			
		||||
        node ./prisma/seed.js
 | 
			
		||||
        # Always run seed from application directory where node_modules is available - as target user
 | 
			
		||||
        if [ "$(id -u)" = "0" ]; then
 | 
			
		||||
            su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
 | 
			
		||||
        else
 | 
			
		||||
            node ./prisma/seed.js
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        echo "✅ Found $CONFIG_COUNT configurations"
 | 
			
		||||
    fi
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user