mirror of
https://github.com/kyantech/Palmr.git
synced 2025-11-10 17:05:45 +00:00
Compare commits
25 Commits
v3.0.0-bet
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd14c28be1 | ||
|
|
3c084a6686 | ||
|
|
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 "Starting Palmr Application..."
|
||||||
echo "Storage Mode: \${ENABLE_S3:-false}"
|
echo "Storage Mode: \${ENABLE_S3:-false}"
|
||||||
|
echo "Secure Site: \${SECURE_SITE:-false}"
|
||||||
echo "Database: SQLite"
|
echo "Database: SQLite"
|
||||||
|
|
||||||
# Set global environment variables
|
# Set global environment variables
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"configuring-smtp",
|
"configuring-smtp",
|
||||||
"available-languages",
|
"available-languages",
|
||||||
"uid-gid-configuration",
|
"uid-gid-configuration",
|
||||||
|
"reverse-proxy-configuration",
|
||||||
"password-reset-without-smtp",
|
"password-reset-without-smtp",
|
||||||
"oidc-authentication",
|
"oidc-authentication",
|
||||||
"---Developers---",
|
"---Developers---",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- ENABLE_S3=false
|
- ENABLE_S3=false
|
||||||
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
|
- 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:
|
ports:
|
||||||
- "5487:5487" # Web interface
|
- "5487:5487" # Web interface
|
||||||
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
|
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
|
||||||
@@ -91,6 +92,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- ENABLE_S3=false
|
- ENABLE_S3=false
|
||||||
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
|
- 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
|
# Optional: Set custom UID/GID for file permissions
|
||||||
# - PALMR_UID=1000
|
# - PALMR_UID=1000
|
||||||
# - PALMR_GID=1000
|
# - PALMR_GID=1000
|
||||||
@@ -121,9 +123,12 @@ Configure Palmr. behavior through environment variables:
|
|||||||
| ---------------- | ------- | ------------------------------------------------------- |
|
| ---------------- | ------- | ------------------------------------------------------- |
|
||||||
| `ENABLE_S3` | `false` | Enable S3-compatible storage |
|
| `ENABLE_S3` | `false` | Enable S3-compatible storage |
|
||||||
| `ENCRYPTION_KEY` | - | **Required**: Minimum 32 characters for file encryption |
|
| `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.
|
> **⚠️ 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
|
### Generate Secure Encryption Keys
|
||||||
|
|
||||||
Need a strong key for `ENCRYPTION_KEY`? Use our built-in generator to create cryptographically secure 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_REGION: z.string().optional(),
|
||||||
S3_BUCKET_NAME: z.string().optional(),
|
S3_BUCKET_NAME: z.string().optional(),
|
||||||
S3_FORCE_PATH_STYLE: z.union([z.literal("true"), z.literal("false")]).default("false"),
|
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"),
|
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 fs from "fs";
|
||||||
import path from "path";
|
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)) {
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { env } from "../../env";
|
||||||
import { LoginSchema, RequestPasswordResetSchema, createResetPasswordSchema } from "./dto";
|
import { LoginSchema, RequestPasswordResetSchema, createResetPasswordSchema } from "./dto";
|
||||||
import { AuthService } from "./service";
|
import { AuthService } from "./service";
|
||||||
import { FastifyReply, FastifyRequest } from "fastify";
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
@@ -17,8 +18,8 @@ export class AuthController {
|
|||||||
reply.setCookie("token", token, {
|
reply.setCookie("token", token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
secure: false,
|
secure: env.SECURE_SITE === "true" ? true : false,
|
||||||
sameSite: "strict",
|
sameSite: env.SECURE_SITE === "true" ? "lax" : "strict",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.send({ user });
|
return reply.send({ user });
|
||||||
|
|||||||
@@ -2,12 +2,35 @@ import { ConfigService } from "../config/service";
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export class StorageService {
|
export class StorageService {
|
||||||
private configService = new ConfigService();
|
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(
|
async getDiskSpace(
|
||||||
userId?: string,
|
userId?: string,
|
||||||
@@ -20,11 +43,14 @@ export class StorageService {
|
|||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
|
const isDocker = this._isDocker();
|
||||||
|
const pathToCheck = isDocker ? "/app/server/uploads" : ".";
|
||||||
|
|
||||||
const command = process.platform === "win32"
|
const command = process.platform === "win32"
|
||||||
? "wmic logicaldisk get size,freespace,caption"
|
? "wmic logicaldisk get size,freespace,caption"
|
||||||
: process.platform === "darwin"
|
: process.platform === "darwin"
|
||||||
? "df -k ."
|
? `df -k ${pathToCheck}`
|
||||||
: "df -B1 .";
|
: `df -B1 ${pathToCheck}`;
|
||||||
|
|
||||||
const { stdout } = await execAsync(command);
|
const { stdout } = await execAsync(command);
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export async function userRoutes(app: FastifyInstance) {
|
|||||||
const usersCount = await prisma.user.count();
|
const usersCount = await prisma.user.count();
|
||||||
|
|
||||||
if (usersCount > 0) {
|
if (usersCount > 0) {
|
||||||
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
if (!request.user.isAdmin) {
|
if (!request.user.isAdmin) {
|
||||||
return reply
|
return reply
|
||||||
@@ -21,14 +22,19 @@ export async function userRoutes(app: FastifyInstance) {
|
|||||||
.send({ error: "Access restricted to administrators" })
|
.send({ error: "Access restricted to administrators" })
|
||||||
.description("Access restricted to administrators");
|
.description("Access restricted to administrators");
|
||||||
}
|
}
|
||||||
}
|
} catch (authErr) {
|
||||||
} catch (err) {
|
console.error(authErr);
|
||||||
console.error(err);
|
|
||||||
return reply
|
return reply
|
||||||
.status(401)
|
.status(401)
|
||||||
.send({ error: "Unauthorized: a valid token is required to access this resource." })
|
.send({ error: "Unauthorized: a valid token is required to access this resource." })
|
||||||
.description("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(500).send({ error: "Internal server error" }).description("Internal server error");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createRegisterSchema = async () => {
|
const createRegisterSchema = async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { env } from "../env";
|
import { env } from "../env";
|
||||||
import { StorageProvider } from "../types/storage";
|
import { StorageProvider } from "../types/storage";
|
||||||
|
import { IS_RUNNING_IN_CONTAINER } from "../utils/container-detection";
|
||||||
import * as crypto from "crypto";
|
import * as crypto from "crypto";
|
||||||
import * as fsSync from "fs";
|
import * as fsSync from "fs";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
@@ -9,12 +10,14 @@ import { pipeline } from "stream/promises";
|
|||||||
|
|
||||||
export class FilesystemStorageProvider implements StorageProvider {
|
export class FilesystemStorageProvider implements StorageProvider {
|
||||||
private static instance: FilesystemStorageProvider;
|
private static instance: FilesystemStorageProvider;
|
||||||
private uploadsDir = path.join(process.cwd(), "uploads");
|
private uploadsDir: string;
|
||||||
private encryptionKey = env.ENCRYPTION_KEY;
|
private encryptionKey = env.ENCRYPTION_KEY;
|
||||||
private uploadTokens = new Map<string, { objectName: string; expiresAt: number }>();
|
private uploadTokens = new Map<string, { objectName: string; expiresAt: number }>();
|
||||||
private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
|
private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
|
this.uploadsDir = IS_RUNNING_IN_CONTAINER ? "/app/server/uploads" : path.join(process.cwd(), "uploads");
|
||||||
|
|
||||||
this.ensureUploadsDir();
|
this.ensureUploadsDir();
|
||||||
setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
|
setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
|
||||||
}
|
}
|
||||||
@@ -214,8 +217,10 @@ export class FilesystemStorageProvider implements StorageProvider {
|
|||||||
if (encryptedBuffer.length > 16) {
|
if (encryptedBuffer.length > 16) {
|
||||||
try {
|
try {
|
||||||
return this.decryptFileBuffer(encryptedBuffer);
|
return this.decryptFileBuffer(encryptedBuffer);
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.warn("Failed to decrypt with new method, trying legacy format");
|
if (error instanceof Error) {
|
||||||
|
console.warn("Failed to decrypt with new method, trying legacy format", error.message);
|
||||||
|
}
|
||||||
return this.decryptFileLegacy(encryptedBuffer);
|
return this.decryptFileLegacy(encryptedBuffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { reverseShareRoutes } from "./modules/reverse-share/routes";
|
|||||||
import { shareRoutes } from "./modules/share/routes";
|
import { shareRoutes } from "./modules/share/routes";
|
||||||
import { storageRoutes } from "./modules/storage/routes";
|
import { storageRoutes } from "./modules/storage/routes";
|
||||||
import { userRoutes } from "./modules/user/routes";
|
import { userRoutes } from "./modules/user/routes";
|
||||||
|
import { IS_RUNNING_IN_CONTAINER } from "./utils/container-detection";
|
||||||
import fastifyMultipart from "@fastify/multipart";
|
import fastifyMultipart from "@fastify/multipart";
|
||||||
import fastifyStatic from "@fastify/static";
|
import fastifyStatic from "@fastify/static";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
@@ -26,21 +27,22 @@ if (typeof global.crypto === "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureDirectories() {
|
async function ensureDirectories() {
|
||||||
const uploadsDir = path.join(process.cwd(), "uploads");
|
const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
|
||||||
const tempChunksDir = path.join(process.cwd(), "temp-chunks");
|
const uploadsDir = path.join(baseDir, "uploads");
|
||||||
|
const tempChunksDir = path.join(baseDir, "temp-chunks");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(uploadsDir);
|
await fs.access(uploadsDir);
|
||||||
} catch {
|
} catch {
|
||||||
await fs.mkdir(uploadsDir, { recursive: true });
|
await fs.mkdir(uploadsDir, { recursive: true });
|
||||||
console.log("📁 Created uploads directory");
|
console.log(`📁 Created uploads directory: ${uploadsDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(tempChunksDir);
|
await fs.access(tempChunksDir);
|
||||||
} catch {
|
} catch {
|
||||||
await fs.mkdir(tempChunksDir, { recursive: true });
|
await fs.mkdir(tempChunksDir, { recursive: true });
|
||||||
console.log("📁 Created temp-chunks directory");
|
console.log(`📁 Created temp-chunks directory: ${tempChunksDir}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +64,11 @@ async function startServer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (env.ENABLE_S3 !== "true") {
|
if (env.ENABLE_S3 !== "true") {
|
||||||
|
const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
|
||||||
|
const uploadsPath = path.join(baseDir, "uploads");
|
||||||
|
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
root: path.join(process.cwd(), "uploads"),
|
root: uploadsPath,
|
||||||
prefix: "/uploads/",
|
prefix: "/uploads/",
|
||||||
decorateReply: false,
|
decorateReply: false,
|
||||||
});
|
});
|
||||||
|
|||||||
45
apps/server/src/utils/container-detection.ts
Normal file
45
apps/server/src/utils/container-detection.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import * as fsSync from "fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the application is running inside a container environment.
|
||||||
|
* Checks common container indicators like /.dockerenv and cgroup file patterns.
|
||||||
|
*
|
||||||
|
* This function caches its result after the first call for performance.
|
||||||
|
*
|
||||||
|
* @returns {boolean} True if running in a container, false otherwise.
|
||||||
|
*/
|
||||||
|
function isRunningInContainer(): boolean {
|
||||||
|
try {
|
||||||
|
if (fsSync.existsSync("/.dockerenv")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cgroupContent = fsSync.readFileSync("/proc/self/cgroup", "utf8");
|
||||||
|
const containerPatterns = [
|
||||||
|
"docker",
|
||||||
|
"containerd",
|
||||||
|
"lxc",
|
||||||
|
"kubepods",
|
||||||
|
"pod",
|
||||||
|
"/containers/",
|
||||||
|
"system.slice/container-",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of containerPatterns) {
|
||||||
|
if (cgroupContent.includes(pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fsSync.existsSync("/.well-known/container")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
console.warn("Could not perform full container detection:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IS_RUNNING_IN_CONTAINER = isRunningInContainer();
|
||||||
@@ -2,12 +2,12 @@ import { useState } from "react";
|
|||||||
import { IconDownload, IconEye } from "@tabler/icons-react";
|
import { IconDownload, IconEye } from "@tabler/icons-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { getFileIcon } from "@/utils/file-icons";
|
import { getFileIcon } from "@/utils/file-icons";
|
||||||
import { formatFileSize } from "@/utils/format-file-size";
|
import { formatFileSize } from "@/utils/format-file-size";
|
||||||
import { ShareFilesTableProps } from "../types";
|
import { ShareFilesTableProps } from "../types";
|
||||||
|
import { ShareFilePreviewModal } from "./share-file-preview-modal";
|
||||||
|
|
||||||
export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
|
export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -99,7 +99,14 @@ export function ShareFilesTable({ files, onDownload }: ShareFilesTableProps) {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedFile && <FilePreviewModal isOpen={isPreviewOpen} onClose={handleClosePreview} file={selectedFile} />}
|
{selectedFile && (
|
||||||
|
<ShareFilePreviewModal
|
||||||
|
isOpen={isPreviewOpen}
|
||||||
|
onClose={handleClosePreview}
|
||||||
|
file={selectedFile}
|
||||||
|
onDownload={onDownload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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 }> }) {
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ shareId: string }> }) {
|
||||||
const cookieHeader = req.headers.get("cookie");
|
const cookieHeader = req.headers.get("cookie");
|
||||||
const { shareId } = await params;
|
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",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
cookie: cookieHeader || "",
|
cookie: cookieHeader || "",
|
||||||
},
|
},
|
||||||
|
body: body,
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export default function AuthCallbackPage() {
|
|||||||
if (token) {
|
if (token) {
|
||||||
Cookies.set("token", token, {
|
Cookies.set("token", token, {
|
||||||
path: "/",
|
path: "/",
|
||||||
secure: false,
|
secure: window.location.protocol === "https:",
|
||||||
sameSite: "strict",
|
sameSite: window.location.protocol === "https:" ? "lax" : "strict",
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ export function useLogin() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
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();
|
const userResponse = await getCurrentUser();
|
||||||
if (!userResponse?.data?.user) {
|
if (!userResponse?.data?.user) {
|
||||||
throw new Error("No user data");
|
throw new Error("No user data");
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function LanguageSwitcher() {
|
|||||||
maxAge: COOKIE_MAX_AGE,
|
maxAge: COOKIE_MAX_AGE,
|
||||||
path: "/",
|
path: "/",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure: false,
|
secure: window.location.protocol === "https:",
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
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();
|
const response = await getCurrentUser();
|
||||||
if (!response?.data?.user) {
|
if (!response?.data?.user) {
|
||||||
throw new Error("No user data");
|
throw new Error("No user data");
|
||||||
|
|||||||
@@ -36,29 +36,49 @@ echo "💾 Database: $DATABASE_URL"
|
|||||||
echo "📁 Creating data directories..."
|
echo "📁 Creating data directories..."
|
||||||
mkdir -p /app/server/prisma /app/server/uploads /app/server/temp-chunks /app/server/uploads/logo
|
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)
|
# Check if it's a first run (no database file exists)
|
||||||
if [ ! -f "/app/server/prisma/palmr.db" ]; then
|
if [ ! -f "/app/server/prisma/palmr.db" ]; then
|
||||||
echo "🚀 First run detected - setting up database..."
|
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..."
|
echo "🗄️ Creating database schema..."
|
||||||
|
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
|
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..."
|
echo "🌱 Seeding database..."
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
|
||||||
|
else
|
||||||
node ./prisma/seed.js
|
node ./prisma/seed.js
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✅ Database setup completed!"
|
echo "✅ Database setup completed!"
|
||||||
else
|
else
|
||||||
echo "♻️ Existing database found"
|
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..."
|
echo "🔧 Checking for schema updates..."
|
||||||
|
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
|
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..."
|
echo "🔍 Verifying database configurations..."
|
||||||
CONFIG_COUNT=$(node -e "
|
CONFIG_COUNT=$(
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
su-exec $TARGET_UID:$TARGET_GID node -e "
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require('@prisma/client');
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
prisma.appConfig.count()
|
prisma.appConfig.count()
|
||||||
@@ -70,12 +90,32 @@ else
|
|||||||
console.log(0);
|
console.log(0);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
" 2>/dev/null || echo "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
|
if [ "$CONFIG_COUNT" -eq "0" ]; then
|
||||||
echo "🌱 No configurations found, running seed..."
|
echo "🌱 No configurations found, running seed..."
|
||||||
# Always run seed from application directory where node_modules is available
|
# 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
|
node ./prisma/seed.js
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "✅ Found $CONFIG_COUNT configurations"
|
echo "✅ Found $CONFIG_COUNT configurations"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user