Compare commits

...

20 Commits

Author SHA1 Message Date
Daniel Luiz Alves
e7ae7833ad [RELEASE] v3.1.8-beta (#187) 2025-08-01 10:15:20 -03:00
Daniel Luiz Alves
22f34f6f81 chore: increment version numbers to 3.1.8-beta across all package.json files 2025-08-01 10:04:06 -03:00
Daniel Luiz Alves
29efe0a10e refactor: format public paths in RedirectHandler for improved readability
- Reformatted the publicPaths array in the RedirectHandler component for better code clarity and maintainability.
2025-08-01 10:02:52 -03:00
Daniel Luiz Alves
965c64b468 feat: update public paths in RedirectHandler for enhanced routing
- Added new public paths ("/s/" and "/r/") to the RedirectHandler component to support additional routes for unauthenticated users.
2025-08-01 10:01:59 -03:00
Daniel Luiz Alves
ce57cda672 feat: enhance authentication flow and user redirection (#183) 2025-07-30 02:03:19 -03:00
Daniel Luiz Alves
a59857079e feat: add QR code modal translations for multiple languages
- Introduced translations for the QR code sharing modal in various languages including Arabic, German, Spanish, French, Hindi, Italian, Japanese, Korean, Dutch, Polish, Portuguese, Russian, Turkish, and Chinese.
- Removed duplicate QR code modal entries from the respective language files to maintain consistency.
2025-07-30 01:31:44 -03:00
Daniel Luiz Alves
9ae2a0c628 feat: enhance authentication flow and user redirection
- Updated the getCurrentUser method to return null for unauthorized access instead of error messages.
- Modified API documentation to reflect changes in the response structure for the current user endpoint.
- Introduced a RedirectHandler component to manage user redirection based on authentication status.
- Enhanced home and login pages to handle loading states and redirect users appropriately based on authentication.
- Improved the useHome hook to manage visibility of the home page based on user authentication status.
2025-07-30 01:29:16 -03:00
Daniel Luiz Alves
f2c514cd82 refactor: remove demo functionality and related components (#182) 2025-07-30 00:43:03 -03:00
Daniel Luiz Alves
6755230c53 refactor: remove demo functionality and related components
- Deleted the demo page and its associated client component to streamline the application.
- Removed demo-related button from the home page.
- Updated environment variable configuration by removing the DEMO_MODE setting.
- Simplified file controller and storage service logic by eliminating demo mode checks.
2025-07-30 00:40:57 -03:00
Daniel Luiz Alves
f2a0e60f20 [RELEASE] v3.1.7-beta (#181) 2025-07-29 22:54:19 -03:00
Daniel Luiz Alves
6cb21e95c4 chore: increment version numbers to 3.1.7-beta across all package.json files 2025-07-29 22:20:56 -03:00
Daniel Luiz Alves
868add68a5 feat: enhance localization with new placeholders and error messages
- Added "namePlaceholder" to share creation modals across multiple languages for improved user guidance.
- Updated error messages in various languages to maintain consistency and clarity.
- Introduced "filesQueued" message for better user feedback during file uploads in multiple languages.
2025-07-29 22:16:07 -03:00
Daniel Luiz Alves
307148d951 fix: enhance reset-password script and update navbar responsiveness
- Updated reset-password.sh to set DATABASE_URL if not already defined and ensure the database directory exists.
- Improved navbar component responsiveness by adjusting visibility classes for navigation items and added a new sponsor link.
2025-07-29 18:37:22 -03:00
Daniel Luiz Alves
9cb4235550 [Release] v3.1.6-beta (#174) 2025-07-22 19:28:55 -03:00
Daniel Luiz Alves
6014b3e961 chore: update environment variable configurations and documentation
- Updated docker-compose files to provide clearer environment variable options for S3 and filesystem encryption.
- Removed default values for ENABLE_S3 and DISABLE_FILESYSTEM_ENCRYPTION in favor of commented guidance for user customization.
- Incremented version numbers to 3.1.6-beta across all relevant package.json files.
- Enhanced documentation to reflect changes in configuration and deployment instructions, including UID/GID handling and storage options.
2025-07-22 18:53:54 -03:00
Daniel Luiz Alves
32f0a891ba refactor: update filesystem encryption handling and configuration
refactor: simplify server startup script and move provider/config checks to separate files

docs: update documentation to reflect encryption changes and default UID/GID values

- Changed default behavior to disable filesystem encryption for improved performance.
- Updated environment variable handling for DISABLE_FILESYSTEM_ENCRYPTION and ENCRYPTION_KEY across multiple configuration files.
- Added new scripts and configuration files for managing application settings and providers.
- Adjusted Dockerfile and server start scripts to reflect changes in UID/GID handling and file management.
- Enhanced documentation to clarify encryption options and their implications.
2025-07-22 16:02:44 -03:00
Daniel Luiz Alves
124ac46eeb [Release] v3.1.5-beta (#172) 2025-07-22 01:09:27 -03:00
Daniel Luiz Alves
d3e76c19bf chore: update package versions to 3.1.5-beta across all apps 2025-07-22 00:36:11 -03:00
Daniel Luiz Alves
dd1ce189ae refactor: improve file download handling and memory management (#171) 2025-07-22 00:28:43 -03:00
Daniel Luiz Alves
82e43b06c6 refactor(filesystem): improve file download handling and memory management
- Replace separate large/small file download methods with unified stream handling
- Add memory usage tracking and garbage collection for download operations
- Implement proper stream cleanup and error handling
- Remove redundant comments and simplify interval configuration
2025-07-22 00:24:47 -03:00
61 changed files with 1261 additions and 1040 deletions

View File

@@ -82,7 +82,7 @@ RUN addgroup --system --gid ${PALMR_GID} nodejs
RUN adduser --system --uid ${PALMR_UID} --ingroup nodejs palmr
# Create application directories
RUN mkdir -p /app/palmr-app /app/web /home/palmr/.npm /home/palmr/.cache
RUN mkdir -p /app/palmr-app /app/web /app/infra /home/palmr/.npm /home/palmr/.cache
RUN chown -R palmr:nodejs /app /home/palmr
# === Copy Server Files to /app/palmr-app (separate from /app/server for bind mounts) ===
@@ -117,10 +117,13 @@ WORKDIR /app
# Create supervisor configuration
RUN mkdir -p /etc/supervisor/conf.d
# Copy server start script
# Copy server start script and configuration files
COPY infra/server-start.sh /app/server-start.sh
COPY infra/configs.json /app/infra/configs.json
COPY infra/providers.json /app/infra/providers.json
COPY infra/check-missing.js /app/infra/check-missing.js
RUN chmod +x /app/server-start.sh
RUN chown palmr:nodejs /app/server-start.sh
RUN chown -R palmr:nodejs /app/server-start.sh /app/infra
# Copy supervisor configuration
COPY infra/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
@@ -133,7 +136,7 @@ set -e
echo "Starting Palmr Application..."
echo "Storage Mode: \${ENABLE_S3:-false}"
echo "Secure Site: \${SECURE_SITE:-false}"
echo "Encryption: \${DISABLE_FILESYSTEM_ENCRYPTION:-false}"
echo "Encryption: \${DISABLE_FILESYSTEM_ENCRYPTION:-true}"
echo "Database: SQLite"
# Set global environment variables

View File

@@ -30,8 +30,6 @@ services:
```bash
docker run -d \
--name palmr \
-e ENABLE_S3=false \
-e ENCRYPTION_KEY=change-this-key-in-production-min-32-chars \
-p 5487:5487 \
-p 3333:3333 \
-v palmr_data:/app/server \

View File

@@ -45,9 +45,9 @@ Palmr. uses **filesystem storage** as the default storage solution, keeping thin
#### Performance Considerations with Encryption
By default, filesystem storage uses encryption (AES-256-CBC) to protect files at rest, which adds CPU overhead during uploads (encryption) and downloads (decryption). This can make operations slower and consume more resources, particularly for large files or in resource-constrained environments like containers or low-end VMs.
By default, filesystem storage operates without encryption for optimal performance, providing fast uploads and downloads with minimal CPU overhead. This approach is ideal for most use cases where performance is prioritized.
If performance is a priority and you don't need encryption (e.g., for non-sensitive data or testing), you can disable it by setting the environment variable `DISABLE_FILESYSTEM_ENCRYPTION=true` in your `.env` file or Docker configuration. Note that disabling encryption stores files in plaintext on disk, reducing security.
If you need to protect sensitive files at rest, you can enable encryption by setting `DISABLE_FILESYSTEM_ENCRYPTION=false` and providing an `ENCRYPTION_KEY` in your configuration. When enabled, Palmr uses AES-256-CBC encryption, which adds CPU overhead during uploads (encryption) and downloads (decryption), particularly for large files or in resource-constrained environments like containers or low-end VMs.
For optimal performance with encryption enabled, ensure your hardware supports AES-NI acceleration (check with `cat /proc/cpuinfo | grep aes` on Linux).

View File

@@ -47,12 +47,12 @@ docker exec -it <container_name_or_id> /bin/sh
Replace `<container_name_or_id>` with the name or ID of your Palmr container. This command opens an interactive shell session inside the container, allowing you to execute commands directly.
### 3. Navigate to the server directory
### 3. Navigate to the application directory
Once inside the container, navigate to the server directory where the reset script is located:
Once inside the container, navigate to the application directory where the reset script is located:
```bash
cd /app/server
cd /app/palmr-app
```
This directory contains the necessary scripts and configurations for managing Palmr's backend operations.
@@ -135,11 +135,11 @@ If you encounter issues while running the script, refer to the following solutio
- Confirm that the `prisma/palmr.db` file exists and has the correct permissions.
- Verify that the container has access to the database volume.
- **Error: "Script must be run from server directory"**
This error appears if you are not in the correct directory. Navigate to the server directory with:
- **Error: "Script must be run from application directory"**
This error appears if you are not in the correct directory. Navigate to the application directory with:
```bash
cd /app/server
cd /app/palmr-app
```
- **Error: "User not found"**

View File

@@ -3,224 +3,250 @@ title: Quick Start (Docker)
icon: "Rocket"
---
import { Callout } from "fumadocs-ui/components/callout";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { Card, CardGrid } from "@/components/ui/card";
Welcome to the fastest way to deploy <span className="font-bold">Palmr.</span> - your secure, self-hosted file sharing solution. This guide will have you up and running in minutes, whether you're new to self-hosting or an experienced developer.
Palmr. offers flexible deployment options to match your infrastructure needs. This guide focuses on Docker deployment with our recommended filesystem storage, perfect for most use cases.
## Prerequisites
Ensure you have the following installed on your system:
Before you begin, make sure you have:
- **Docker** - Container runtime ([installation guide](https://docs.docker.com/get-docker/))
- **Docker Compose** - Multi-container orchestration ([installation guide](https://docs.docker.com/compose/install/))
- **2GB+ available disk space** for the application and your files
- **Port 5487** available for the web interface
- **Port 3333** available for API access (optional)
> **Platform Support**: Palmr. is developed on macOS and extensively tested on Linux servers. While we haven't formally tested other platforms, Docker's cross-platform nature should ensure compatibility. Report any issues on our [GitHub repository](https://github.com/kyantech/Palmr/issues).
<Callout>
**Platform Support**: Palmr. is developed on macOS and extensively tested on Linux servers. While we haven't formally
tested other platforms, Docker's cross-platform nature should ensure compatibility. Report any issues on our [GitHub
repository](https://github.com/kyantech/Palmr/issues).
</Callout>
## Storage Options
Palmr. supports two storage approaches for persistent data:
### Named Volumes (Recommended)
- **Named Volumes (Recommended)** - Docker-managed storage with optimal performance and no permission issues
- **Bind Mounts** - Direct host filesystem access, ideal for development and direct file management
**Best for**: Production environments, automated deployments
## Deployment Options
- ✅ **Managed by Docker**: No permission issues or manual path management
- ✅ **Optimized Performance**: Docker-native storage optimization
- ✅ **Cross-platform**: Consistent behavior across operating systems
- ✅ **Simplified Backups**: Docker volume commands for backup/restore
Choose your storage method based on your needs:
### Bind Mounts
<Tabs items={['Named Volumes (Recommended)', 'Bind Mounts']}>
<Tab value="Named Volumes (Recommended)">
Docker-managed storage that provides the best balance of performance, security, and ease of use:
**Best for**: Development, direct file access requirements
- **No Permission Issues**: Docker handles all permission management automatically
- **Performance**: Optimized for container workloads with better I/O performance
- **Production Ready**: Recommended for production deployments
- ✅ **Direct Access**: Files stored in local directory you specify
- ✅ **Transparent Storage**: Direct filesystem access from host
- ✅ **Custom Backup**: Use existing file system backup solutions
- ⚠️ **Permission Considerations**: **Common Issue** - Requires UID/GID configuration (see troubleshooting below)
### Configuration
---
Create a `docker-compose.yml` file:
## Option 1: Named Volumes (Recommended)
```yaml
services:
palmr:
image: kyantech/palmr:latest
container_name: palmr
restart: unless-stopped
ports:
- "5487:5487" # Web interface
# - "3333:3333" # API (optional)
environment:
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
# - ENABLE_S3=true # Set to true to enable S3-compatible storage
# - DISABLE_FILESYSTEM_ENCRYPTION=true # Set to false to enable file encryption
# - ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
# - PALMR_UID=1000 # UID for the container processes (default is 1000)
# - PALMR_GID=1000 # GID for the container processes (default is 1000)
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
volumes:
- palmr_data:/app/server
Named volumes provide the best performance and are managed entirely by Docker.
### Configuration
Use the provided `docker-compose.yaml` for named volumes:
```yaml
services:
palmr:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional)
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
ports:
- "5487:5487" # Web interface
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
volumes:
- palmr_data:/app/server # Named volume for the application data
restart: unless-stopped # Restart the container unless it is stopped
palmr_data:
```
volumes:
palmr_data:
```
<Callout type="info">
**Having upload or permission issues?** Add `PALMR_UID=1000` and `PALMR_GID=1000` to your environment variables. Check our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for more details.
</Callout>
> **Note:** If you haveing problem with uploading files, try to change the `PALMR_UID` and `PALMR_GID` to the UID and GID of the user running the container. You can find the UID and GID of the user running the container with the command `id -u` and `id -g`. in Linux systems the default user is `1000` and the default group is `1000`. For test you can add the environment variables below to the `docker-compose.yaml` file and restart the container.
### Deploy
```yaml
environment:
- PALMR_UID=1000 # UID for the container processes (default is 1001)
- PALMR_GID=1000 # GID for the container processes (default is 1001)
```
```bash
docker-compose up -d
```
> **Note:** For more information about UID and GID, see our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide.
</Tab>
<Tab value="Bind Mounts">
Direct mapping to host filesystem directories, providing direct file access:
### Deployment
- **Direct Access**: Files are directly accessible from your host system
- **Development Friendly**: Easy to inspect, modify, or backup files manually
- **Platform Dependent**: May require UID/GID configuration, especially on NAS systems
```bash
docker-compose up -d
```
### Configuration
---
Create a `docker-compose.yml` file:
## Option 2: Bind Mounts
```yaml
services:
palmr:
image: kyantech/palmr:latest
container_name: palmr
restart: unless-stopped
ports:
- "5487:5487" # Web interface
# - "3333:3333" # API (optional)
environment:
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
# - ENABLE_S3=true # Set to true to enable S3-compatible storage
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to false to enable file encryption
# - ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
# - PALMR_UID=1000 # UID for the container processes (default is 1000)
# - PALMR_GID=1000 # GID for the container processes (default is 1000)
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
volumes:
- ./data:/app/server
```
Bind mounts store data in a local directory, providing direct file system access.
<Callout type="info">
**Having upload or permission issues?** Add `PALMR_UID=1000` and `PALMR_GID=1000` to your environment variables. Check our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for more details.
</Callout>
### Configuration
### Deploy
To use bind mounts, **replace the content** of your `docker-compose.yaml` with the following configuration (you can also reference `docker-compose-bind-mount-example.yaml` as a template):
```bash
docker-compose up -d
```
```yaml
services:
palmr:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
- PALMR_UID=1000 # UID for the container processes (default is 1001)
- PALMR_GID=1000 # GID for the container processes (default is 1001)
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional)
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
volumes:
# Bind mount for persistent data (uploads, database, temp files)
- ./data:/app/server # Local directory for the application data
restart: unless-stopped # Restart the container unless it is stopped
```
</Tab>
</Tabs>
### Deployment
## Configuration
```bash
docker-compose up -d
```
Customize Palmr's behavior with these environment variables:
> **Permission Configuration**: If you encounter permission issues with bind mounts (common on NAS systems), see our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for automatic permission handling.
| Variable | Default | Description |
| ------------------------------- | ------- | -------------------------------------------------------------------------------------------- |
| `ENABLE_S3` | `false` | Enable S3-compatible storage backends |
| `ENCRYPTION_KEY` | - | **Required when encryption is enabled**: 32+ character key for file encryption |
| `DISABLE_FILESYSTEM_ENCRYPTION` | `true` | Disable file encryption for better performance (set to `false` to enable encryption) |
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy deployments |
| `DEFAULT_LANGUAGE` | `en-US` | Default application language ([see available languages](/docs/3.1-beta/available-languages)) |
| `PALMR_UID` | `1000` | User ID for container processes (helps with file permissions) |
| `PALMR_GID` | `1000` | Group ID for container processes (helps with file permissions) |
---
<Callout type="info">
**Performance First**: Palmr runs without encryption by default for optimal speed and lower resource usage—perfect for
most use cases.
</Callout>
## Environment Variables
<Callout type="warn">
**Encryption Notice**: To enable encryption, set `DISABLE_FILESYSTEM_ENCRYPTION=false` and provide a 32+ character
`ENCRYPTION_KEY`. **Important**: This choice is permanent—switching encryption modes after uploading files will break
access to existing uploads.
</Callout>
Configure Palmr. behavior through environment variables:
<Callout>
**Using a Reverse Proxy?** Set `SECURE_SITE=true` and check our [Reverse Proxy
Configuration](/docs/3.1-beta/reverse-proxy-configuration) guide for proper HTTPS setup.
</Callout>
| Variable | Default | Description |
| ------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
| `ENABLE_S3` | `false` | Enable S3-compatible storage |
| `ENCRYPTION_KEY` | - | **Required** (unless encryption disabled): Minimum 32 characters for file encryption |
| `DISABLE_FILESYSTEM_ENCRYPTION` | `false` | Disable file encryption for direct filesystem access |
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy setups |
| `DEFAULT_LANGUAGE` | `en-US` | Set the default application language (see supported languages in docs [here](/docs/3.1-beta/available-languages)) |
| `PALMR_UID` | `1001` | Set the UID for the container processes (OPTIONAL - default is 1001) |
| `PALMR_GID` | `1001` | Set the GID for the container processes (OPTIONAL - default is 1001) |
### Generate Encryption Keys (Optional)
> **⚠️ Security Warning**: Always change the `ENCRYPTION_KEY` in production when encryption is enabled. This key encrypts your files - losing it makes files permanently inaccessible.
> **🔓 File Encryption Control**: The `DISABLE_FILESYSTEM_ENCRYPTION` variable allows you to store files without encryption for direct filesystem access. When set to `true`, the `ENCRYPTION_KEY` becomes optional. **Important**: Once set, this configuration is permanent for your deployment. Switching between encrypted and unencrypted modes will break file access for existing uploads. Choose your strategy before uploading files. For more details on performance implications of encryption, see [Performance Considerations with Encryption](/docs/3.1-beta/architecture#performance-considerations-with-encryption).
> **🔗 Reverse Proxy**: If deploying behind a reverse proxy (Traefik, Nginx, etc.), set `SECURE_SITE=true` and review our [Reverse Proxy Configuration](/docs/3.1-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:
Need file encryption? Generate a secure key:
<KeyGenerator />
> **💡 Pro Tip**: If you're using `DISABLE_FILESYSTEM_ENCRYPTION=true`, you can skip the `ENCRYPTION_KEY` entirely for a simpler setup. However, remember that files will be stored unencrypted on your filesystem.
> **Pro Tip**: Only enable encryption if you're handling sensitive data. For most users, the default unencrypted mode provides better performance.
---
## Access Your Instance
## Accessing Palmr.
Once deployed, open Palmr in your browser:
Once deployed, access Palmr. through your web browser:
- **Web Interface**: `http://localhost:5487` (local) or `http://YOUR_SERVER_IP:5487` (remote)
- **API Documentation**: `http://localhost:3333/docs` (if port 3333 is exposed)
- **Local**: `http://localhost:5487`
- **Server**: `http://YOUR_SERVER_IP:5487`
<Callout type="info">
**Learn More**: For complete API documentation, authentication, and integration examples, see our [API
Reference](/docs/3.1-beta/api) guide
</Callout>
### API Access (Optional)
If you exposed port 3333 in your configuration, you can also access:
- **API Documentation**: `http://localhost:3333/docs` (local) or `http://YOUR_SERVER_IP:3333/docs` (server)
- **API Endpoints**: Available at `http://localhost:3333` (local) or `http://YOUR_SERVER_IP:3333` (server)
> **📚 Learn More**: For complete API documentation, authentication, and integration examples, see our [API Reference](/docs/3.1-beta/api) guide.
> **💡 Production Tip**: For production deployments, configure HTTPS with a valid SSL certificate for enhanced security.
<Callout type="warn">
**Production Ready?** Configure HTTPS with a valid SSL certificate for secure production deployments.
</Callout>
---
## Docker CLI Alternative
Prefer using Docker directly? Both storage options are supported:
Prefer Docker commands over Compose? Here are the equivalent commands:
**Named Volume:**
<Tabs items={["Named Volume", "Bind Mount"]}>
<Tab value="Named Volume">
```bash
docker run -d \
--name palmr \
-e ENABLE_S3=false \
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
# -e PALMR_UID=1000 # UID for the container processes (default is 1001)
# -e PALMR_GID=1000 # GID for the container processes (default is 1001)
# -e DISABLE_FILESYSTEM_ENCRYPTION=true # Uncomment to disable file encryption (ENCRYPTION_KEY becomes optional)
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
-p 5487:5487 \
-p 3333:3333 \
-v palmr_data:/app/server \
--restart unless-stopped \
kyantech/palmr:latest
```
```bash
docker run -d \
--name palmr \
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
# -e ENABLE_S3=true \ # Set to true to enable S3-compatible storage (OPTIONAL - default is false)
# -e DISABLE_FILESYSTEM_ENCRYPTION=false \ # Set to false to enable file encryption (ENCRYPTION_KEY becomes required) | (OPTIONAL - default is true)
# -e ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
# -e PALMR_UID=1000 # UID for the container processes (default is 1000)
# -e PALMR_GID=1000 # GID for the container processes (default is 1000)
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
-p 5487:5487 \
-p 3333:3333 \
-v palmr_data:/app/server \
--restart unless-stopped \
kyantech/palmr:latest
```
> **Permission Configuration**: If you encounter permission issues with bind mounts (common on NAS systems), see our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for automatic permission handling.
**Bind Mount:**
<Callout type="info">
**Permission Issues?** Add `-e PALMR_UID=1000 -e PALMR_GID=1000` to the command above. See our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for details.
</Callout>
```bash
docker run -d \
--name palmr \
-e ENABLE_S3=false \
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
-e PALMR_UID=1000 # UID for the container processes (default is 1001)
-e PALMR_GID=1000 # GID for the container processes (default is 1001)
# -e DISABLE_FILESYSTEM_ENCRYPTION=true # Uncomment to disable file encryption (ENCRYPTION_KEY becomes optional)
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
-p 5487:5487 \
-p 3333:3333 \
-v $(pwd)/data:/app/server \
--restart unless-stopped \
kyantech/palmr:latest
```
</Tab>
<Tab value="Bind Mount">
```bash
docker run -d \
--name palmr \
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
# -e ENABLE_S3=true \ # Set to true to enable S3-compatible storage (OPTIONAL - default is false)
# -e DISABLE_FILESYSTEM_ENCRYPTION=true \ # Set to false to enable file encryption (ENCRYPTION_KEY becomes required) | (OPTIONAL - default is true)
# -e ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
# -e PALMR_UID=1000 # UID for the container processes (default is 1000)
# -e PALMR_GID=1000 # GID for the container processes (default is 1000)
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
-p 5487:5487 \
-p 3333:3333 \
-v $(pwd)/data:/app/server \
--restart unless-stopped \
kyantech/palmr:latest
```
<Callout type="info">
**Permission Issues?** Add `-e PALMR_UID=1000 -e PALMR_GID=1000` to the command above. See our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for details.
</Callout>
</Tab>
</Tabs>
---
@@ -228,52 +254,50 @@ docker run -d \
### Updates
Keep Palmr. current with the latest features and security fixes:
Keep Palmr up to date with the latest features and security patches:
```bash
docker-compose pull
docker-compose up -d
```
### Backup & Restore
### Backup Your Data
The backup method depends on which storage option you're using:
**Named Volume Backup:**
**Named Volumes:**
```bash
docker run --rm \
-v palmr_data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/palmr-backup.tar.gz -C /data .
docker run --rm -v palmr_data:/data -v $(pwd):/backup alpine tar czf /backup/palmr-backup.tar.gz -C /data .
```
**Named Volume Restore:**
```bash
docker run --rm \
-v palmr_data:/data \
-v $(pwd):/backup \
alpine tar xzf /backup/palmr-backup.tar.gz -C /data
```
**Bind Mount Backup:**
**Bind Mounts:**
```bash
tar czf palmr-backup.tar.gz ./data
```
**Bind Mount Restore:**
### Restore From Backup
**Named Volumes:**
```bash
docker-compose down
docker run --rm -v palmr_data:/data -v $(pwd):/backup alpine tar xzf /backup/palmr-backup.tar.gz -C /data
docker-compose up -d
```
**Bind Mounts:**
```bash
docker-compose down
tar xzf palmr-backup.tar.gz
docker-compose up -d
```
---
## Next Steps
## What's Next?
Your Palmr. instance is now ready! Explore additional configuration options:
Your Palmr instance is ready! Here's what you can explore:
### Advanced Configuration
@@ -284,8 +308,11 @@ Your Palmr. instance is now ready! Explore additional configuration options:
### Integration & Development
- **[API Reference](/docs/3.1-beta/api)** - Integrate Palmr. with your applications
- **[Architecture Guide](/docs/3.1-beta/architecture)** - Understanding Palmr. components and design
<Callout type="info">
**Need help?** Check our [Troubleshooting Guide](/docs/3.1-beta/troubleshooting) for common issues and solutions.
</Callout>
---
Need help? Visit our [GitHub Issues](https://github.com/kyantech/Palmr/issues) or community discussions.
**Questions?** Visit our [GitHub Issues](https://github.com/kyantech/Palmr/issues) or join the community discussions.

View File

@@ -127,7 +127,8 @@ proxy_pass_header Set-Cookie;
environment:
- PALMR_UID=1000 # Your host UID (check with: id)
- PALMR_GID=1000 # Your host GID
- ENCRYPTION_KEY=your-key-here
- DISABLE_FILESYSTEM_ENCRYPTION=true # Set to false to enable file encryption
# - ENCRYPTION_KEY=your-key-here # Required only if encryption is enabled
```
> **💡 Note**: Check your host UID/GID with `id` command and use those values. See [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) for detailed setup.

View File

@@ -17,7 +17,7 @@ docker-compose logs palmr | grep -i "permission\|denied\|eacces"
# Common error messages:
# EACCES: permission denied, open '/app/server/uploads/file.txt'
# Error: EACCES: permission denied, mkdir '/app/server/temp-chunks'
# Error: EACCES: permission denied, mkdir '/app/server/temp-uploads'
```
### The Root Cause
@@ -25,7 +25,7 @@ docker-compose logs palmr | grep -i "permission\|denied\|eacces"
**Palmr. defaults**: UID 1001, GID 1001
**Linux standard**: UID 1000, GID 1000
When using bind mounts, your host directories are owned by UID 1000, but Palmr. runs as UID 1001.
When using bind mounts, your host directories may have different ownership than Palmr's default UID/GID.
### Solution 1: Environment Variables (Recommended)
@@ -63,8 +63,8 @@ If you prefer to keep Palmr's defaults:
chown -R 1001:1001 ./data
# For separate upload/temp directories
mkdir -p uploads temp-chunks
chown -R 1001:1001 uploads temp-chunks
mkdir -p uploads temp-uploads
chown -R 1001:1001 uploads temp-uploads
```
### Solution 3: Docker Volume (Avoid the Issue)
@@ -109,16 +109,19 @@ docker-compose logs palmr
2. **Invalid encryption key**
```bash
# Error: Encryption key must be at least 32 characters
# Fix: Update ENCRYPTION_KEY in docker-compose.yaml
# Error: Encryption key must be at least 32 characters (only if encryption is enabled)
# Fix: Either disable encryption or provide a valid key
environment:
- ENCRYPTION_KEY=your-very-long-secure-key-at-least-32-characters
- DISABLE_FILESYSTEM_ENCRYPTION=true # Disable encryption (default)
# OR enable encryption with:
# - DISABLE_FILESYSTEM_ENCRYPTION=false
# - ENCRYPTION_KEY=your-very-long-secure-key-at-least-32-characters
```
3. **Missing environment variables**
```bash
# Check required variables are set
docker exec palmr env | grep -E "ENCRYPTION_KEY|DATABASE_URL"
# Check variables are set (encryption is optional)
docker exec palmr env | grep -E "DISABLE_FILESYSTEM_ENCRYPTION|ENCRYPTION_KEY|DATABASE_URL"
```
### Container Starts But App Doesn't Load
@@ -151,7 +154,7 @@ curl http://localhost:3333/health
```bash
docker exec palmr ls -la /app/server/uploads/
# Should show ownership by palmr user
# Should show ownership by palmr user (UID 1001)
```
3. **Check upload limits:**
@@ -178,13 +181,13 @@ docker exec palmr stat /app/server/uploads/your-file.txt
```bash
# Using the built-in reset script
docker exec -it palmr /app/reset-password.sh
docker exec -it palmr /app/palmr-app/reset-password.sh
```
2. **Check database permissions:**
```bash
docker exec palmr ls -la /app/server/prisma/
# palmr.db should be writable by palmr user
# palmr.db should be writable by palmr user (UID 1001)
```
### OIDC Authentication Not Working
@@ -243,8 +246,8 @@ docker exec palmr ls -la /app/server/prisma/palmr.db
# Check database logs
docker-compose logs palmr | grep -i database
# Verify Prisma schema
docker exec palmr npx prisma db push --schema=./prisma/schema.prisma
# Verify Prisma schema (run from palmr-app directory)
docker exec palmr sh -c "cd /app/palmr-app && npx prisma db push"
```
### Database Corruption
@@ -283,7 +286,7 @@ docker-compose up -d
3. **Check temp directory permissions:**
```bash
docker exec palmr ls -la /app/server/temp-chunks/
docker exec palmr ls -la /app/server/temp-uploads/
```
### High Memory Usage
@@ -318,16 +321,19 @@ docker port palmr
echo "4. File Permissions:"
docker exec palmr ls -la /app/server/
echo "5. Environment Variables:"
docker exec palmr env | grep -E "PALMR_|ENCRYPTION_|DATABASE_"
echo "5. Application Files:"
docker exec palmr ls -la /app/palmr-app/
echo "6. API Health:"
echo "6. Environment Variables:"
docker exec palmr env | grep -E "PALMR_|DISABLE_FILESYSTEM_ENCRYPTION|ENCRYPTION_|DATABASE_"
echo "7. API Health:"
curl -s http://localhost:3333/health || echo "API not accessible"
echo "7. Web Interface:"
echo "8. Web Interface:"
curl -s -o /dev/null -w "%{http_code}" http://localhost:5487 || echo "Web interface not accessible"
echo "8. Disk Space:"
echo "9. Disk Space:"
df -h
echo "=== End Health Check ==="

View File

@@ -9,15 +9,15 @@ Configure user and group permissions for seamless bind mount compatibility acros
Palmr. supports runtime UID/GID configuration to resolve permission conflicts when using bind mounts. This eliminates the need for manual permission management on your host system.
**⚠️ Important**: Palmr uses **UID 1001, GID 1001** by default, which is different from the standard Linux convention of **UID 1000, GID 1000**. This is the most common cause of permission issues with bind mounts.
**⚠️ Important**: Palmr uses **UID 1000, GID 1000** by default, which matches the standard Linux convention. However, some systems may use different UID/GID values, which can cause permission issues with bind mounts.
## The Permission Problem
### Why This Happens
- **Palmr Default**: UID 1001, GID 1001 (container)
- **Palmr Default**: UID 1000, GID 1000 (container)
- **Linux Standard**: UID 1000, GID 1000 (most host systems)
- **Result**: Container can't write to host directories
- **Result**: Usually compatible, but some systems may use different values
### Common Error Scenarios
@@ -30,7 +30,7 @@ EACCES: permission denied, open '/app/server/uploads/file.txt'
# Or when checking permissions:
$ ls -la uploads/
drwxr-xr-x 2 user user 4096 Jan 15 10:00 uploads/
# Container tries to write as UID 1001, but directory is owned by UID 1000
# Container tries to write with different UID/GID than directory owner
```
## Quick Fix
@@ -45,15 +45,13 @@ services:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=your-secure-key-min-32-chars
- PALMR_UID=1000
- PALMR_GID=1000
ports:
- "5487:5487"
volumes:
- ./uploads:/app/server/uploads:rw
- ./temp-chunks:/app/server/temp-chunks:rw
- ./temp-uploads:/app/server/temp-uploads:rw
restart: unless-stopped
```
@@ -63,8 +61,8 @@ If you prefer to keep Palmr's defaults:
```bash
# Create directories with correct ownership
mkdir -p uploads temp-chunks
chown -R 1001:1001 uploads temp-chunks
mkdir -p uploads temp-uploads
chown -R 1001:1001 uploads temp-uploads
```
## Environment Variables
@@ -104,8 +102,6 @@ services:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=your-secure-key-min-32-chars
- PALMR_UID=1000
- PALMR_GID=1000
ports:
@@ -123,8 +119,6 @@ services:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=your-secure-key-min-32-chars
- PALMR_UID=1026
- PALMR_GID=100
ports:
@@ -142,8 +136,6 @@ services:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=your-secure-key-min-32-chars
- PALMR_UID=1000
- PALMR_GID=100
ports:
@@ -166,7 +158,7 @@ services:
id
# 2. Check directory ownership
ls -la uploads/ temp-chunks/
ls -la uploads/ temp-uploads/
# 3. Fix via environment variables (preferred)
# Add to docker-compose.yaml:
@@ -174,7 +166,7 @@ ls -la uploads/ temp-chunks/
# - PALMR_GID=1000
# 4. Or fix via chown (alternative)
chown -R 1001:1001 uploads temp-chunks
chown -R 1001:1001 uploads temp-uploads
```
**Error**: Container starts but files aren't accessible
@@ -225,11 +217,11 @@ cat /etc/passwd | grep -v nobody
```bash
# Check if directories exist and are writable
test -w uploads && echo "uploads writable" || echo "uploads NOT writable"
test -w temp-chunks && echo "temp-chunks writable" || echo "temp-chunks NOT writable"
test -w temp-uploads && echo "temp-uploads writable" || echo "temp-uploads NOT writable"
# Create directories with correct permissions
mkdir -p uploads temp-chunks
sudo chown -R $(id -u):$(id -g) uploads temp-chunks
mkdir -p uploads temp-uploads
sudo chown -R $(id -u):$(id -g) uploads temp-uploads
```
---
@@ -270,7 +262,7 @@ To add UID/GID configuration to running installations:
cp -r ./data ./data-backup
# or
cp -r ./uploads ./uploads-backup
cp -r ./temp-chunks ./temp-chunks-backup
cp -r ./temp-uploads ./temp-uploads-backup
```
3. **Check your UID/GID**
@@ -344,4 +336,4 @@ For most users experiencing permission issues with bind mounts:
```
3. **Restart**: `docker-compose down && docker-compose up -d`
This resolves the mismatch between Palmr's default UID 1001 and the standard Linux UID 1000.
This ensures compatibility between Palmr's UID/GID and your host system's file ownership.

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-docs",
"version": "3.1.4-beta",
"version": "3.1.8-beta",
"description": "Docs for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -12,7 +12,6 @@ import {
LayoutIcon,
LockIcon,
MousePointer,
RadioIcon,
RocketIcon,
SearchIcon,
TimerIcon,
@@ -82,23 +81,6 @@ function Hero() {
<Link href={docsLink}>Documentation</Link>
</div>
</PulsatingButton>
<RippleButton
onClick={() => {
const demoId = `${Math.random().toString(36).substr(2, 9)}`;
const token = `${Math.random().toString(36).substr(2, 12)}`;
sessionStorage.setItem("demo_token", token);
sessionStorage.setItem("demo_id", demoId);
sessionStorage.setItem("demo_expires", (Date.now() + 5 * 60 * 1000).toString());
window.location.href = `/demo?id=${demoId}&token=${token}`;
}}
>
<div className="flex gap-2 items-center">
<RadioIcon size={18} />
Live Demo
</div>
</RippleButton>
<RippleButton>
<a
href="https://github.com/kyantech/Palmr"

View File

@@ -1,225 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Palmtree } from "lucide-react";
import { motion } from "motion/react";
import { BackgroundLights } from "@/components/ui/background-lights";
import { Button } from "@/components/ui/button";
interface DemoStatus {
status: "waiting" | "ready";
url: string | null;
}
interface CreateDemoResponse {
message: string;
url: string | null;
}
function DemoClientInner() {
const searchParams = useSearchParams();
const demoId = searchParams.get("id");
const token = searchParams.get("token");
const [status, setStatus] = useState<DemoStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const validateAccess = () => {
const storedToken = sessionStorage.getItem("demo_token");
const storedId = sessionStorage.getItem("demo_id");
const expiresAt = sessionStorage.getItem("demo_expires");
if (!demoId || !token || !storedToken || !storedId || !expiresAt) {
return false;
}
if (token !== storedToken || demoId !== storedId || Date.now() > parseInt(expiresAt)) {
return false;
}
return true;
};
if (!validateAccess()) {
setError("Unauthorized access. Please use the Live Demo button to access this page.");
setIsLoading(false);
return;
}
const createDemo = async () => {
try {
const response = await fetch("https://palmr-demo-manager.kyantech.com.br/create-demo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
palmr_demo_instance_id: demoId,
}),
});
if (!response.ok) {
throw new Error("Failed to create demo");
}
const data: CreateDemoResponse = await response.json();
console.log("Demo creation response:", data);
} catch (err) {
console.error("Error creating demo:", err);
setError("Failed to create demo. Please try again.");
setIsLoading(false);
}
};
const checkStatus = async () => {
try {
const response = await fetch(`https://palmr-demo-manager.kyantech.com.br/status/${demoId}`);
if (!response.ok) {
throw new Error("Failed to check demo status");
}
const data: DemoStatus = await response.json();
setStatus(data);
if (data.status === "ready" && data.url) {
setIsLoading(false);
}
} catch (err) {
console.error("Error checking status:", err);
setError("Failed to check demo status. Please try again.");
setIsLoading(false);
}
};
createDemo();
const interval = setInterval(checkStatus, 5000); // Check every 5 seconds
checkStatus();
return () => {
clearInterval(interval);
sessionStorage.removeItem("demo_token");
sessionStorage.removeItem("demo_id");
sessionStorage.removeItem("demo_expires");
};
}, [demoId, token]);
const handleGoToDemo = () => {
if (status?.url) {
window.open(status.url, "_blank");
}
window.location.href = "/";
};
if (error) {
return (
<div className="fixed inset-0 bg-background">
<BackgroundLights />
<div className="relative flex flex-col items-center justify-center h-full">
<div className="text-center space-y-6 max-w-md">
<h1 className="text-2xl font-bold text-destructive">Error</h1>
<p className="text-muted-foreground">{error}</p>
<Button
onClick={() => {
sessionStorage.removeItem("demo_token");
sessionStorage.removeItem("demo_id");
sessionStorage.removeItem("demo_expires");
window.location.href = "/";
}}
>
Go Back
</Button>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="fixed inset-0 bg-background">
<BackgroundLights />
<div className="flex flex-col items-center gap-6 text-center h-full justify-center">
<div className="space-y-4">
<h1 className="text-2xl font-bold">Your demo is being generated, please wait...</h1>
<p className="text-muted-foreground max-w-lg">
This demo will be available for 30 minutes for testing. After that, all data will be permanently deleted
and become inaccessible. You can test Palmr. with a 200MB storage limit.
</p>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-background">
<BackgroundLights />
<div className="relative flex flex-col items-center justify-center h-full">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="container mx-auto max-w-7xl px-6 flex-grow"
>
<section className="relative flex flex-col items-center justify-center gap-6 m-auto h-full">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="inline-block max-w-xl text-center justify-center"
>
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<motion.span
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
className="text-4xl lg:text-3xl font-semibold tracking-tight text-primary"
>
Your demo is ready!
</motion.span>
<motion.span
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6, duration: 0.5 }}
className="text-3xl leading-9 font-semibold tracking-tight"
>
Click the button below to test
</motion.span>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.8 }}
className="flex flex-col items-center gap-6"
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 1.2, duration: 0.5 }}
>
<Button onClick={handleGoToDemo} className="flex items-center gap-2 px-8 py-4 text-lg">
<Palmtree className="h-5 w-5" />
Go to Palmr. Demo
</Button>
</motion.div>
</motion.div>
</section>
</motion.div>
</div>
</div>
);
}
export default function DemoClient() {
return <DemoClientInner />;
}

View File

@@ -1,13 +0,0 @@
"use client";
import { Suspense } from "react";
import DemoClient from "./components/demo-client";
export default function DemoPage() {
return (
<Suspense>
<DemoClient />
</Suspense>
);
}

View File

@@ -5,14 +5,15 @@ import { cn } from "@/lib/utils";
interface CardProps {
title: string;
description: string;
description?: string;
href?: string;
icon?: ReactNode;
className?: string;
onClick?: () => void;
children?: ReactNode;
}
export const Card = ({ title, description, href, icon, className, onClick }: CardProps) => {
export const Card = ({ title, description, href, icon, className, onClick, children }: CardProps) => {
const cardContent = (
<div
className={cn(
@@ -37,9 +38,16 @@ export const Card = ({ title, description, href, icon, className, onClick }: Car
<h3 className="font-medium text-sm text-foreground mb-1 group-hover:text-primary transition-colors duration-200 mt-3 text-decoration-none">
{title}
</h3>
<p className="text-xs text-muted-foreground/80 leading-relaxed line-clamp-2 group-hover:text-muted-foreground transition-colors duration-200">
{description}
</p>
{description && (
<p className="text-xs text-muted-foreground/80 leading-relaxed line-clamp-2 group-hover:text-muted-foreground transition-colors duration-200">
{description}
</p>
)}
{children && (
<div className="text-xs text-muted-foreground/80 leading-relaxed group-hover:text-muted-foreground transition-colors duration-200 mt-2">
{children}
</div>
)}
</div>
<div className="flex-shrink-0 ml-2">
<div className="w-5 h-5 rounded-full bg-muted/40 flex items-center justify-center opacity-0 group-hover:opacity-100 group-hover:bg-primary/10 transition-all duration-200">

View File

@@ -1,6 +1,7 @@
# FOR FILESYSTEM STORAGE ENV VARS
ENABLE_S3=false
ENCRYPTION_KEY=change-this-key-in-production-min-32-chars
DISABLE_FILESYSTEM_ENCRYPTION=true
# ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # Required only if encryption is enabled (DISABLE_FILESYSTEM_ENCRYPTION=false)
DATABASE_URL="file:./palmr.db"
# FOR USE WITH S3 COMPATIBLE STORAGE

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-api",
"version": "3.1.4-beta",
"version": "3.1.8-beta",
"description": "API for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -6,7 +6,7 @@
echo "🔐 Palmr Password Reset Tool"
echo "============================="
# Check if we're in the right directory
# Check if we're in the right directory and set DATABASE_URL
if [ ! -f "package.json" ]; then
echo "❌ Error: This script must be run from the server directory (/app/server)"
echo " Current directory: $(pwd)"
@@ -14,18 +14,26 @@ if [ ! -f "package.json" ]; then
exit 1
fi
# Set DATABASE_URL if not already set
if [ -z "$DATABASE_URL" ]; then
export DATABASE_URL="file:/app/server/prisma/palmr.db"
fi
# Ensure database directory exists
mkdir -p /app/server/prisma
# Function to check if tsx is available
check_tsx() {
# Check if tsx binary exists in node_modules
if [ -f "node_modules/.bin/tsx" ]; then
return 0
fi
# Fallback: try npx
if npx tsx --version >/dev/null 2>&1; then
return 0
fi
return 1
}
@@ -39,7 +47,7 @@ install_tsx_only() {
else
return 1
fi
return $?
}
@@ -62,7 +70,7 @@ ensure_prisma() {
if [ -d "node_modules/@prisma/client" ] && [ -f "node_modules/@prisma/client/index.js" ]; then
return 0
fi
echo "📦 Generating Prisma client..."
if npx prisma generate --silent >/dev/null 2>&1; then
echo "✅ Prisma client ready"
@@ -81,14 +89,14 @@ if check_tsx; then
echo "✅ tsx is ready"
else
echo "📦 tsx not found, installing..."
# Try quick tsx-only install first
if install_tsx_only && check_tsx; then
echo "✅ tsx installed successfully"
else
echo "⚠️ Quick install failed, installing all dependencies..."
install_all_deps
# Final check
if ! check_tsx; then
echo "❌ Error: tsx is still not available after full installation"
@@ -119,4 +127,4 @@ if [ -f "node_modules/.bin/tsx" ]; then
node_modules/.bin/tsx src/scripts/reset-password.ts "$@"
else
npx tsx src/scripts/reset-password.ts "$@"
fi
fi

View File

@@ -2,8 +2,8 @@ import { z } from "zod";
const envSchema = z.object({
ENABLE_S3: z.union([z.literal("true"), z.literal("false")]).default("false"),
ENCRYPTION_KEY: z.string().optional().default("palmr-default-encryption-key-2025"),
DISABLE_FILESYSTEM_ENCRYPTION: z.union([z.literal("true"), z.literal("false")]).default("false"),
ENCRYPTION_KEY: z.string().optional(),
DISABLE_FILESYSTEM_ENCRYPTION: z.union([z.literal("true"), z.literal("false")]).default("true"),
S3_ENDPOINT: z.string().optional(),
S3_PORT: z.string().optional(),
S3_USE_SSL: z.string().optional(),
@@ -14,7 +14,6 @@ const envSchema = z.object({
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"),
DEMO_MODE: z.union([z.literal("true"), z.literal("false")]).default("false"),
});
export const env = envSchema.parse(process.env);

View File

@@ -113,14 +113,21 @@ export class AuthController {
async getCurrentUser(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
let userId: string | null = null;
try {
await request.jwtVerify();
userId = (request as any).user?.userId;
} catch (err) {
return reply.send({ user: null });
}
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
return reply.send({ user: null });
}
const user = await this.authService.getUserById(userId);
if (!user) {
return reply.status(404).send({ error: "User not found" });
return reply.send({ user: null });
}
return reply.send({ user });

View File

@@ -153,33 +153,29 @@ export async function authRoutes(app: FastifyInstance) {
tags: ["Authentication"],
operationId: "getCurrentUser",
summary: "Get Current User",
description: "Returns the current authenticated user's information",
description: "Returns the current authenticated user's information or null if not authenticated",
response: {
200: z.object({
user: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().nullable().describe("User profile image URL"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
200: z.union([
z.object({
user: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().nullable().describe("User profile image URL"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
}),
}),
401: z.object({ error: z.string().describe("Error message") }),
z.object({
user: z.null().describe("No user when not authenticated"),
}),
]),
},
},
preValidation: async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
},
},
authController.getCurrentUser.bind(authController)
);

View File

@@ -56,17 +56,7 @@ export class FileController {
});
}
// Check if DEMO_MODE is enabled
const isDemoMode = env.DEMO_MODE === "true";
let maxTotalStorage: bigint;
if (isDemoMode) {
// In demo mode, limit all users to 200MB
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
} else {
// Normal behavior - use maxTotalStoragePerUser configuration
maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
}
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const userFiles = await prisma.file.findMany({
where: { userId },
@@ -138,17 +128,7 @@ export class FileController {
});
}
// Check if DEMO_MODE is enabled
const isDemoMode = env.DEMO_MODE === "true";
let maxTotalStorage: bigint;
if (isDemoMode) {
// In demo mode, limit all users to 200MB
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
} else {
// Normal behavior - use maxTotalStoragePerUser configuration
maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
}
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const userFiles = await prisma.file.findMany({
where: { userId },

View File

@@ -8,9 +8,6 @@ import { ChunkManager, ChunkMetadata } from "./chunk-manager";
export class FilesystemController {
private chunkManager = ChunkManager.getInstance();
/**
* Safely encode filename for Content-Disposition header
*/
private encodeFilenameForHeader(filename: string): string {
if (!filename || filename.trim() === "") {
return 'attachment; filename="download"';
@@ -103,9 +100,6 @@ export class FilesystemController {
await provider.uploadFileFromStream(objectName, request.raw);
}
/**
* Extract chunk metadata from request headers
*/
private extractChunkMetadata(request: FastifyRequest): ChunkMetadata | null {
const fileId = request.headers["x-file-id"] as string;
const chunkIndex = request.headers["x-chunk-index"] as string;
@@ -132,9 +126,6 @@ export class FilesystemController {
return metadata;
}
/**
* Handle chunked upload with streaming
*/
private async handleChunkedUpload(request: FastifyRequest, metadata: ChunkMetadata, originalObjectName: string) {
const stream = request.raw;
@@ -145,9 +136,6 @@ export class FilesystemController {
return await this.chunkManager.processChunk(metadata, stream, originalObjectName);
}
/**
* Get upload progress for chunked uploads
*/
async getUploadProgress(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileId } = request.params as { fileId: string };
@@ -164,9 +152,6 @@ export class FilesystemController {
}
}
/**
* Cancel chunked upload
*/
async cancelUpload(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileId } = request.params as { fileId: string };
@@ -194,7 +179,6 @@ export class FilesystemController {
const filePath = provider.getFilePath(tokenData.objectName);
const stats = await fs.promises.stat(filePath);
const fileSize = stats.size;
const isLargeFile = fileSize > 50 * 1024 * 1024;
const fileName = tokenData.fileName || "download";
const range = request.headers.range;
@@ -207,28 +191,15 @@ export class FilesystemController {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
reply.status(206);
reply.header("Content-Range", `bytes ${start}-${end}/${fileSize}`);
reply.header("Content-Length", chunkSize);
reply.header("Content-Length", end - start + 1);
if (isLargeFile) {
await this.downloadLargeFileRange(reply, provider, tokenData.objectName, start, end);
} else {
const buffer = await provider.downloadFile(tokenData.objectName);
const chunk = buffer.slice(start, end + 1);
reply.send(chunk);
}
await this.downloadFileRange(reply, provider, tokenData.objectName, start, end);
} else {
reply.header("Content-Length", fileSize);
if (isLargeFile) {
await this.downloadLargeFile(reply, provider, filePath);
} else {
const stream = provider.createDecryptedReadStream(tokenData.objectName);
reply.send(stream);
}
await this.downloadFileStream(reply, provider, tokenData.objectName);
}
provider.consumeDownloadToken(token);
@@ -237,32 +208,75 @@ export class FilesystemController {
}
}
private async downloadLargeFile(reply: FastifyReply, provider: FilesystemStorageProvider, filePath: string) {
const readStream = fs.createReadStream(filePath);
const decryptStream = provider.createDecryptStream();
private async downloadFileStream(reply: FastifyReply, provider: FilesystemStorageProvider, objectName: string) {
try {
await pipeline(readStream, decryptStream, reply.raw);
FilesystemStorageProvider.logMemoryUsage(`Download start: ${objectName}`);
const downloadStream = provider.createDownloadStream(objectName);
downloadStream.on("error", (error) => {
console.error("Download stream error:", error);
FilesystemStorageProvider.logMemoryUsage(`Download error: ${objectName}`);
if (!reply.sent) {
reply.status(500).send({ error: "Download failed" });
}
});
reply.raw.on("close", () => {
if (downloadStream.readable && typeof (downloadStream as any).destroy === "function") {
(downloadStream as any).destroy();
}
FilesystemStorageProvider.logMemoryUsage(`Download client disconnect: ${objectName}`);
});
await pipeline(downloadStream, reply.raw);
FilesystemStorageProvider.logMemoryUsage(`Download complete: ${objectName}`);
} catch (error) {
throw error;
console.error("Download error:", error);
FilesystemStorageProvider.logMemoryUsage(`Download failed: ${objectName}`);
if (!reply.sent) {
reply.status(500).send({ error: "Download failed" });
}
}
}
private async downloadLargeFileRange(
private async downloadFileRange(
reply: FastifyReply,
provider: FilesystemStorageProvider,
objectName: string,
start: number,
end: number
) {
const filePath = provider.getFilePath(objectName);
const readStream = fs.createReadStream(filePath, { start, end });
const decryptStream = provider.createDecryptStream();
try {
await pipeline(readStream, decryptStream, reply.raw);
FilesystemStorageProvider.logMemoryUsage(`Range download start: ${objectName} (${start}-${end})`);
const rangeStream = await provider.createDownloadRangeStream(objectName, start, end);
rangeStream.on("error", (error) => {
console.error("Range download stream error:", error);
FilesystemStorageProvider.logMemoryUsage(`Range download error: ${objectName} (${start}-${end})`);
if (!reply.sent) {
reply.status(500).send({ error: "Download failed" });
}
});
reply.raw.on("close", () => {
if (rangeStream.readable && typeof (rangeStream as any).destroy === "function") {
(rangeStream as any).destroy();
}
FilesystemStorageProvider.logMemoryUsage(`Range download client disconnect: ${objectName} (${start}-${end})`);
});
await pipeline(rangeStream, reply.raw);
FilesystemStorageProvider.logMemoryUsage(`Range download complete: ${objectName} (${start}-${end})`);
} catch (error) {
throw error;
console.error("Range download error:", error);
FilesystemStorageProvider.logMemoryUsage(`Range download failed: ${objectName} (${start}-${end})`);
if (!reply.sent) {
reply.status(500).send({ error: "Download failed" });
}
}
}
}

View File

@@ -533,17 +533,7 @@ export class ReverseShareService {
throw new Error(`File size exceeds the maximum allowed size of ${maxSizeMB}MB`);
}
// Check if DEMO_MODE is enabled
const isDemoMode = env.DEMO_MODE === "true";
let maxTotalStorage: bigint;
if (isDemoMode) {
// In demo mode, limit all users to 200MB
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
} else {
// Normal behavior - use maxTotalStoragePerUser configuration
maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
}
const maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
const userFiles = await prisma.file.findMany({
where: { userId: creatorId },

View File

@@ -324,89 +324,45 @@ export class StorageService {
uploadAllowed: boolean;
}> {
try {
const isDemoMode = env.DEMO_MODE === "true";
if (isAdmin) {
if (isDemoMode) {
const demoMaxStorage = 200 * 1024 * 1024;
const demoMaxStorageGB = this._ensureNumber(demoMaxStorage / (1024 * 1024 * 1024), 0);
const diskInfo = await this._getDiskSpaceMultiplePaths();
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(demoMaxStorageGB - usedStorageGB, 0);
return {
diskSizeGB: Number(demoMaxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
} else {
const diskInfo = await this._getDiskSpaceMultiplePaths();
if (!diskInfo) {
throw new Error("Unable to determine actual disk space - system configuration issue");
}
const { total, available } = diskInfo;
const used = total - available;
const diskSizeGB = this._ensureNumber(total / (1024 * 1024 * 1024), 0);
const diskUsedGB = this._ensureNumber(used / (1024 * 1024 * 1024), 0);
const diskAvailableGB = this._ensureNumber(available / (1024 * 1024 * 1024), 0);
return {
diskSizeGB: Number(diskSizeGB.toFixed(2)),
diskUsedGB: Number(diskUsedGB.toFixed(2)),
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
uploadAllowed: diskAvailableGB > 0.1,
};
if (!diskInfo) {
throw new Error("Unable to determine actual disk space - system configuration issue");
}
const { total, available } = diskInfo;
const used = total - available;
const diskSizeGB = this._ensureNumber(total / (1024 * 1024 * 1024), 0);
const diskUsedGB = this._ensureNumber(used / (1024 * 1024 * 1024), 0);
const diskAvailableGB = this._ensureNumber(available / (1024 * 1024 * 1024), 0);
return {
diskSizeGB: Number(diskSizeGB.toFixed(2)),
diskUsedGB: Number(diskUsedGB.toFixed(2)),
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
uploadAllowed: diskAvailableGB > 0.1,
};
} else if (userId) {
if (isDemoMode) {
const demoMaxStorage = 200 * 1024 * 1024;
const demoMaxStorageGB = this._ensureNumber(demoMaxStorage / (1024 * 1024 * 1024), 0);
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(demoMaxStorageGB - usedStorageGB, 0);
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
return {
diskSizeGB: Number(demoMaxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
} else {
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
return {
diskSizeGB: Number(maxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
}
return {
diskSizeGB: Number(maxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
}
throw new Error("User ID is required for non-admin users");

View File

@@ -20,9 +20,16 @@ export class FilesystemStorageProvider implements StorageProvider {
private constructor() {
this.uploadsDir = directoriesConfig.uploads;
if (!this.isEncryptionDisabled && !this.encryptionKey) {
throw new Error(
"Encryption is enabled but ENCRYPTION_KEY is not provided. " +
"Please set ENCRYPTION_KEY environment variable or set DISABLE_FILESYSTEM_ENCRYPTION=true to disable encryption."
);
}
this.ensureUploadsDir();
setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
setInterval(() => this.cleanupEmptyTempDirs(), 10 * 60 * 1000); // Every 10 minutes
setInterval(() => this.cleanupEmptyTempDirs(), 10 * 60 * 1000);
}
public static getInstance(): FilesystemStorageProvider {
@@ -32,14 +39,6 @@ export class FilesystemStorageProvider implements StorageProvider {
return FilesystemStorageProvider.instance;
}
public createDecryptedReadStream(objectName: string): NodeJS.ReadableStream {
const filePath = this.getFilePath(objectName);
const fileStream = fsSync.createReadStream(filePath);
const decryptStream = this.createDecryptStream();
return fileStream.pipe(decryptStream);
}
private async ensureUploadsDir(): Promise<void> {
try {
await fs.access(this.uploadsDir);
@@ -70,6 +69,11 @@ export class FilesystemStorageProvider implements StorageProvider {
}
private createEncryptionKey(): Buffer {
if (!this.encryptionKey) {
throw new Error(
"Encryption key is required when encryption is enabled. Please set ENCRYPTION_KEY environment variable."
);
}
return crypto.scryptSync(this.encryptionKey, "salt", 32);
}
@@ -261,6 +265,183 @@ export class FilesystemStorageProvider implements StorageProvider {
return this.decryptFileLegacy(fileBuffer);
}
createDownloadStream(objectName: string): NodeJS.ReadableStream {
const filePath = this.getFilePath(objectName);
const fileStream = fsSync.createReadStream(filePath);
if (this.isEncryptionDisabled) {
fileStream.on("end", () => {
if (global.gc) {
global.gc();
}
});
fileStream.on("close", () => {
if (global.gc) {
global.gc();
}
});
return fileStream;
}
const decryptStream = this.createDecryptStream();
let isDestroyed = false;
const cleanup = () => {
if (isDestroyed) return;
isDestroyed = true;
try {
if (fileStream && !fileStream.destroyed) {
fileStream.destroy();
}
if (decryptStream && !decryptStream.destroyed) {
decryptStream.destroy();
}
} catch (error) {
console.warn("Error during download stream cleanup:", error);
}
if (global.gc) {
global.gc();
}
};
fileStream.on("error", cleanup);
decryptStream.on("error", cleanup);
decryptStream.on("end", cleanup);
decryptStream.on("close", cleanup);
decryptStream.on("pipe", (src: any) => {
if (src && src.on) {
src.on("close", cleanup);
src.on("error", cleanup);
}
});
return fileStream.pipe(decryptStream);
}
async createDownloadRangeStream(objectName: string, start: number, end: number): Promise<NodeJS.ReadableStream> {
if (!this.isEncryptionDisabled) {
return this.createRangeStreamFromDecrypted(objectName, start, end);
}
const filePath = this.getFilePath(objectName);
return fsSync.createReadStream(filePath, { start, end });
}
private createRangeStreamFromDecrypted(objectName: string, start: number, end: number): NodeJS.ReadableStream {
const { Transform, PassThrough } = require("stream");
const filePath = this.getFilePath(objectName);
const fileStream = fsSync.createReadStream(filePath);
const decryptStream = this.createDecryptStream();
const rangeStream = new PassThrough();
let bytesRead = 0;
let rangeEnded = false;
let isDestroyed = false;
const rangeTransform = new Transform({
transform(chunk: Buffer, encoding: any, callback: any) {
if (rangeEnded || isDestroyed) {
callback();
return;
}
const chunkStart = bytesRead;
const chunkEnd = bytesRead + chunk.length - 1;
bytesRead += chunk.length;
if (chunkEnd < start) {
callback();
return;
}
if (chunkStart > end) {
rangeEnded = true;
this.end();
callback();
return;
}
let sliceStart = 0;
let sliceEnd = chunk.length;
if (chunkStart < start) {
sliceStart = start - chunkStart;
}
if (chunkEnd > end) {
sliceEnd = end - chunkStart + 1;
rangeEnded = true;
}
const slicedChunk = chunk.slice(sliceStart, sliceEnd);
this.push(slicedChunk);
if (rangeEnded) {
this.end();
}
callback();
},
flush(callback: any) {
if (global.gc) {
global.gc();
}
callback();
},
});
const cleanup = () => {
if (isDestroyed) return;
isDestroyed = true;
try {
if (fileStream && !fileStream.destroyed) {
fileStream.destroy();
}
if (decryptStream && !decryptStream.destroyed) {
decryptStream.destroy();
}
if (rangeTransform && !rangeTransform.destroyed) {
rangeTransform.destroy();
}
if (rangeStream && !rangeStream.destroyed) {
rangeStream.destroy();
}
} catch (error) {
console.warn("Error during stream cleanup:", error);
}
if (global.gc) {
global.gc();
}
};
fileStream.on("error", cleanup);
decryptStream.on("error", cleanup);
rangeTransform.on("error", cleanup);
rangeStream.on("error", cleanup);
rangeStream.on("close", cleanup);
rangeStream.on("end", cleanup);
rangeStream.on("pipe", (src: any) => {
if (src && src.on) {
src.on("close", cleanup);
src.on("error", cleanup);
}
});
fileStream.pipe(decryptStream).pipe(rangeTransform).pipe(rangeStream);
return rangeStream;
}
private decryptFileBuffer(encryptedBuffer: Buffer): Buffer {
const key = this.createEncryptionKey();
const iv = encryptedBuffer.slice(0, 16);
@@ -272,11 +453,69 @@ export class FilesystemStorageProvider implements StorageProvider {
}
private decryptFileLegacy(encryptedBuffer: Buffer): Buffer {
if (!this.encryptionKey) {
throw new Error(
"Encryption key is required when encryption is enabled. Please set ENCRYPTION_KEY environment variable."
);
}
const CryptoJS = require("crypto-js");
const decrypted = CryptoJS.AES.decrypt(encryptedBuffer.toString("utf8"), this.encryptionKey);
return Buffer.from(decrypted.toString(CryptoJS.enc.Utf8), "base64");
}
static logMemoryUsage(context: string = "Unknown"): void {
const memUsage = process.memoryUsage();
const formatBytes = (bytes: number) => {
const mb = bytes / 1024 / 1024;
return `${mb.toFixed(2)} MB`;
};
const rssInMB = memUsage.rss / 1024 / 1024;
const heapUsedInMB = memUsage.heapUsed / 1024 / 1024;
if (rssInMB > 1024 || heapUsedInMB > 512) {
console.warn(`[MEMORY WARNING] ${context} - High memory usage detected:`);
console.warn(` RSS: ${formatBytes(memUsage.rss)}`);
console.warn(` Heap Used: ${formatBytes(memUsage.heapUsed)}`);
console.warn(` Heap Total: ${formatBytes(memUsage.heapTotal)}`);
console.warn(` External: ${formatBytes(memUsage.external)}`);
if (global.gc) {
console.warn(" Forcing garbage collection...");
global.gc();
const afterGC = process.memoryUsage();
console.warn(` After GC - RSS: ${formatBytes(afterGC.rss)}, Heap: ${formatBytes(afterGC.heapUsed)}`);
}
} else {
console.log(
`[MEMORY INFO] ${context} - RSS: ${formatBytes(memUsage.rss)}, Heap: ${formatBytes(memUsage.heapUsed)}`
);
}
}
static forceGarbageCollection(context: string = "Manual"): void {
if (global.gc) {
const beforeGC = process.memoryUsage();
global.gc();
const afterGC = process.memoryUsage();
const formatBytes = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB`;
console.log(`[GC] ${context} - Before: RSS ${formatBytes(beforeGC.rss)}, Heap ${formatBytes(beforeGC.heapUsed)}`);
console.log(`[GC] ${context} - After: RSS ${formatBytes(afterGC.rss)}, Heap ${formatBytes(afterGC.heapUsed)}`);
const rssSaved = beforeGC.rss - afterGC.rss;
const heapSaved = beforeGC.heapUsed - afterGC.heapUsed;
if (rssSaved > 0 || heapSaved > 0) {
console.log(`[GC] ${context} - Freed: RSS ${formatBytes(rssSaved)}, Heap ${formatBytes(heapSaved)}`);
}
} else {
console.warn(`[GC] ${context} - Garbage collection not available. Start Node.js with --expose-gc flag.`);
}
}
async fileExists(objectName: string): Promise<boolean> {
const filePath = this.getFilePath(objectName);
try {
@@ -321,9 +560,6 @@ export class FilesystemStorageProvider implements StorageProvider {
this.downloadTokens.delete(token);
}
/**
* Clean up temporary file and its parent directory if empty
*/
private async cleanupTempFile(tempPath: string): Promise<void> {
try {
await fs.unlink(tempPath);
@@ -346,9 +582,6 @@ export class FilesystemStorageProvider implements StorageProvider {
}
}
/**
* Clean up empty temporary directories periodically
*/
private async cleanupEmptyTempDirs(): Promise<void> {
try {
const tempUploadsDir = directoriesConfig.tempUploads;

View File

@@ -159,7 +159,8 @@
"passwordLabel": "كلمة المرور",
"create": "إنشاء مشاركة",
"success": "تم إنشاء المشاركة بنجاح",
"error": "فشل في إنشاء المشاركة"
"error": "فشل في إنشاء المشاركة",
"namePlaceholder": "أدخل اسمًا لمشاركتك"
},
"dashboard": {
"loadError": "فشل في تحميل بيانات لوحة التحكم",
@@ -454,6 +455,11 @@
},
"pageTitle": "الملف الشخصي"
},
"qrCodeModal": {
"title": "مشاركة رمز QR",
"description": "امسح رمز QR هذا للوصول إلى الرابط.",
"download": "تحميل رمز QR"
},
"quickAccess": {
"files": {
"title": "ملفاتي",
@@ -1659,7 +1665,8 @@
"title": "إفلات الملفات للرفع",
"description": "حرر للرفع ملفاتك"
},
"pasteSuccess": "{count, plural, =1 {تم لصق الصورة ورفعها بنجاح} other {تم لصق # صور ورفعها بنجاح}}"
"pasteSuccess": "{count, plural, =1 {تم لصق الصورة ورفعها بنجاح} other {تم لصق # صور ورفعها بنجاح}}",
"filesQueued": "{count, plural, one {# ملف في الصف} other {# ملفات في الصف}}"
},
"users": {
"modes": {
@@ -1744,10 +1751,5 @@
"passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
},
"qrCodeModal": {
"title": "مشاركة رمز QR",
"description": "امسح رمز QR هذا للوصول إلى الرابط.",
"download": "تحميل رمز QR"
}
}
}

View File

@@ -159,7 +159,8 @@
"passwordLabel": "Passwort",
"create": "Freigabe Erstellen",
"success": "Freigabe erfolgreich erstellt",
"error": "Fehler beim Erstellen der Freigabe"
"error": "Fehler beim Erstellen der Freigabe",
"namePlaceholder": "Geben Sie einen Namen für Ihre Freigabe ein"
},
"dashboard": {
"loadError": "Fehler beim Laden der Dashboard-Daten",
@@ -454,6 +455,11 @@
},
"pageTitle": "Profil"
},
"qrCodeModal": {
"title": "QR-Code teilen",
"description": "Scannen Sie diesen QR-Code, um auf den Link zuzugreifen.",
"download": "QR-Code herunterladen"
},
"quickAccess": {
"files": {
"title": "Meine Dateien",
@@ -1657,7 +1663,8 @@
"title": "Dateien zum Hochladen ablegen",
"description": "Loslassen, um Ihre Dateien hochzuladen"
},
"pasteSuccess": "{count, plural, =1 {Bild erfolgreich eingefügt und hochgeladen} other {# Bilder erfolgreich eingefügt und hochgeladen}}"
"pasteSuccess": "{count, plural, =1 {Bild erfolgreich eingefügt und hochgeladen} other {# Bilder erfolgreich eingefügt und hochgeladen}}",
"filesQueued": "{count, plural, one {# Datei in der Warteschlange} other {# Dateien in der Warteschlange}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"passwordRequired": "Passwort ist erforderlich",
"nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich"
},
"qrCodeModal": {
"title": "QR-Code teilen",
"description": "Scannen Sie diesen QR-Code, um auf den Link zuzugreifen.",
"download": "QR-Code herunterladen"
}
}
}

View File

@@ -149,6 +149,7 @@
"createShare": {
"title": "Create Share",
"nameLabel": "Share Name",
"namePlaceholder": "Enter a name for your share",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Enter a description (optional)",
"expirationLabel": "Expiration Date",
@@ -1634,6 +1635,7 @@
"selectFile": "Click to select a file",
"selectMultipleFiles": "Click to select one or multiple files",
"dragAndDrop": "or drag and drop files here",
"filesQueued": "{count, plural, one {# file queued for upload} other {# files queued for upload}}",
"preview": "Preview",
"uploadProgress": "Upload progress",
"upload": "Upload",
@@ -1745,4 +1747,4 @@
"nameRequired": "Name is required",
"required": "This field is required"
}
}
}

View File

@@ -159,7 +159,8 @@
"passwordLabel": "Contraseña",
"create": "Crear Compartir",
"success": "Compartir creado exitosamente",
"error": "Error al crear compartir"
"error": "Error al crear compartir",
"namePlaceholder": "Ingrese un nombre para su compartir"
},
"dashboard": {
"loadError": "Error al cargar los datos del tablero",
@@ -454,6 +455,11 @@
},
"pageTitle": "Perfil"
},
"qrCodeModal": {
"title": "Compartir Código QR",
"description": "Escanea este código QR para acceder al enlace.",
"download": "Descargar Código QR"
},
"quickAccess": {
"files": {
"title": "Mis archivos",
@@ -1657,7 +1663,8 @@
"title": "Suelta archivos para subir",
"description": "Suelta para subir tus archivos"
},
"pasteSuccess": "{count, plural, =1 {Imagen pegada y subida exitosamente} other {# imágenes pegadas y subidas exitosamente}}"
"pasteSuccess": "{count, plural, =1 {Imagen pegada y subida exitosamente} other {# imágenes pegadas y subidas exitosamente}}",
"filesQueued": "{count, plural, one {# archivo en cola para subir} other {# archivos en cola para subir}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"passwordRequired": "Se requiere la contraseña",
"nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio"
},
"qrCodeModal": {
"title": "Compartir Código QR",
"description": "Escanea este código QR para acceder al enlace.",
"download": "Descargar Código QR"
}
}
}

View File

@@ -159,7 +159,8 @@
"passwordLabel": "Mot de Passe",
"create": "Créer un Partage",
"success": "Partage créé avec succès",
"error": "Échec de la création du partage"
"error": "Échec de la création du partage",
"namePlaceholder": "Entrez un nom pour votre partage"
},
"dashboard": {
"loadError": "Échec du chargement des données du tableau de bord",
@@ -454,6 +455,11 @@
},
"pageTitle": "Profil"
},
"qrCodeModal": {
"title": "Code QR de Partage",
"description": "Scannez ce code QR pour accéder au lien.",
"download": "Télécharger le Code QR"
},
"quickAccess": {
"files": {
"title": "Mes Fichiers",
@@ -1657,7 +1663,8 @@
"title": "Déposer des fichiers pour télécharger",
"description": "Relâchez pour télécharger vos fichiers"
},
"pasteSuccess": "{count, plural, =1 {Image collée et téléchargée avec succès} other {# images collées et téléchargées avec succès}}"
"pasteSuccess": "{count, plural, =1 {Image collée et téléchargée avec succès} other {# images collées et téléchargées avec succès}}",
"filesQueued": "{count, plural, one {# fichier en attente de téléchargement} other {# fichiers en attente de téléchargement}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"passwordRequired": "Le mot de passe est requis",
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
},
"qrCodeModal": {
"title": "Code QR de Partage",
"description": "Scannez ce code QR pour accéder au lien.",
"download": "Télécharger le Code QR"
}
}
}

View File

@@ -159,7 +159,8 @@
"passwordLabel": "पासवर्ड",
"create": "साझाकरण बनाएं",
"success": "साझाकरण सफलतापूर्वक बनाया गया",
"error": "साझाकरण बनाने में विफल"
"error": "साझाकरण बनाने में विफल",
"namePlaceholder": "अपने साझाकरण के लिए एक नाम दर्ज करें"
},
"dashboard": {
"loadError": "डैशबोर्ड डेटा लोड करने में त्रुटि",
@@ -454,6 +455,11 @@
},
"pageTitle": "प्रोफ़ाइल"
},
"qrCodeModal": {
"title": "QR कोड साझा करें",
"description": "इस QR कोड को स्कैन करके लिंक तक पहुंच सकते हैं।",
"download": "QR कोड डाउनलोड करें"
},
"quickAccess": {
"files": {
"title": "मेरी फाइलें",
@@ -1657,7 +1663,8 @@
"title": "अपलोड करने के लिए फ़ाइलें छोड़ें",
"description": "अपनी फ़ाइलें अपलोड करने के लिए छोड़ें"
},
"pasteSuccess": "{count, plural, =1 {छवि सफलतापूर्वक चिपकाई और अपलोड की गई} other {# छवियाँ सफलतापूर्वक चिपकाई और अपलोड की गईं}}"
"pasteSuccess": "{count, plural, =1 {छवि सफलतापूर्वक चिपकाई और अपलोड की गई} other {# छवियाँ सफलतापूर्वक चिपकाई और अपलोड की गईं}}",
"filesQueued": "{count, plural, one {# फ़ाइल अपलोड के लिए इंतजार में है} other {# फ़ाइलें अपलोड के लिए इंतजार में हैं}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
},
"qrCodeModal": {
"title": "QR कोड साझा करें",
"description": "इस QR कोड को स्कैन करके लिंक तक पहुंच सकते हैं।",
"download": "QR कोड डाउनलोड करें"
}
}
}

View File

@@ -159,7 +159,8 @@
"passwordLabel": "Password",
"create": "Crea Condivisione",
"success": "Condivisione creata con successo",
"error": "Errore nella creazione della condivisione"
"error": "Errore nella creazione della condivisione",
"namePlaceholder": "Inserisci un nome per la tua condivisione"
},
"dashboard": {
"loadError": "Errore durante il caricamento dei dati del pannello di controllo",
@@ -454,6 +455,11 @@
},
"pageTitle": "Profilo"
},
"qrCodeModal": {
"title": "Condividi QR Code",
"description": "Scansiona questo codice QR per accedere al link.",
"download": "Scarica QR Code"
},
"quickAccess": {
"files": {
"title": "I Miei File",
@@ -1657,7 +1663,8 @@
"title": "Rilascia i file per caricarli",
"description": "Rilascia per caricare i tuoi file"
},
"pasteSuccess": "{count, plural, =1 {Immagine incollata e caricata con successo} other {# immagini incollate e caricate con successo}}"
"pasteSuccess": "{count, plural, =1 {Immagine incollata e caricata con successo} other {# immagini incollate e caricate con successo}}",
"filesQueued": "{count, plural, one {# file in coda per il caricamento} other {# files in coda per il caricamento}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
"nameRequired": "Il nome è obbligatorio",
"required": "Questo campo è obbligatorio"
},
"qrCodeModal": {
"title": "Condividi QR Code",
"description": "Scansiona questo codice QR per accedere al link.",
"download": "Scarica QR Code"
}
}
}

View File

@@ -159,7 +159,8 @@
"passwordLabel": "パスワード",
"create": "共有を作成",
"success": "共有が正常に作成されました",
"error": "共有の作成に失敗しました"
"error": "共有の作成に失敗しました",
"namePlaceholder": "共有の名前を入力してください"
},
"dashboard": {
"loadError": "ダッシュボードデータの読み込みに失敗しました",
@@ -454,6 +455,11 @@
},
"pageTitle": "プロフィール"
},
"qrCodeModal": {
"title": "QRコードを共有",
"description": "このQRコードをスキャンしてリンクにアクセスしてください。",
"download": "QRコードをダウンロード"
},
"quickAccess": {
"files": {
"title": "マイファイル",
@@ -1657,7 +1663,8 @@
"title": "アップロードするファイルをドロップ",
"description": "ファイルをアップロードするにはリリースしてください"
},
"pasteSuccess": "{count, plural, =1 {画像が貼り付けられ、正常にアップロードされました} other {#個の画像が貼り付けられ、正常にアップロードされました}}"
"pasteSuccess": "{count, plural, =1 {画像が貼り付けられ、正常にアップロードされました} other {#個の画像が貼り付けられ、正常にアップロードされました}}",
"filesQueued": "{count, plural, one {# ファイルがアップロード待ち} other {# ファイルがアップロード待ち}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
},
"qrCodeModal": {
"title": "QRコードを共有",
"description": "このQRコードをスキャンしてリンクにアクセスしてください。",
"download": "QRコードをダウンロード"
}
}
}

View File

@@ -159,7 +159,8 @@
"passwordLabel": "비밀번호",
"create": "공유 생성",
"success": "공유가 성공적으로 생성되었습니다",
"error": "공유 생성에 실패했습니다"
"error": "공유 생성에 실패했습니다",
"namePlaceholder": "공유 이름을 입력하세요"
},
"dashboard": {
"loadError": "대시보드 데이터를 불러오는데 실패했습니다",
@@ -454,6 +455,11 @@
},
"pageTitle": "프로필"
},
"qrCodeModal": {
"title": "QR 코드 공유",
"description": "이 QR 코드를 스캔하여 링크에 접근할 수 있습니다.",
"download": "QR 코드 다운로드"
},
"quickAccess": {
"files": {
"title": "내 파일",
@@ -1657,7 +1663,8 @@
"title": "업로드할 파일 드롭",
"description": "파일을 업로드하려면 놓으세요"
},
"pasteSuccess": "{count, plural, =1 {이미지가 성공적으로 붙여넣기 및 업로드되었습니다} other {# 개의 이미지가 성공적으로 붙여넣기 및 업로드되었습니다}}"
"pasteSuccess": "{count, plural, =1 {이미지가 성공적으로 붙여넣기 및 업로드되었습니다} other {# 개의 이미지가 성공적으로 붙여넣기 및 업로드되었습니다}}",
"filesQueued": "{count, plural, one {# 파일이 업로드 대기 중} other {# 파일이 업로드 대기 중}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
},
"qrCodeModal": {
"title": "QR 코드 공유",
"description": "이 QR 코드를 스캔하여 링크에 접근할 수 있습니다.",
"download": "QR 코드 다운로드"
}
}
}

View File

@@ -159,7 +159,8 @@
"passwordLabel": "Wachtwoord",
"create": "Delen Maken",
"success": "Delen succesvol aangemaakt",
"error": "Fout bij het aanmaken van delen"
"error": "Fout bij het aanmaken van delen",
"namePlaceholder": "Voer een naam in voor uw delen"
},
"dashboard": {
"loadError": "Fout bij het laden van controlepaneel gegevens",
@@ -454,6 +455,11 @@
},
"pageTitle": "Profiel"
},
"qrCodeModal": {
"title": "QR Code Delen",
"description": "Scan deze QR-code om toegang te krijgen tot de link.",
"download": "QR Code Downloaden"
},
"quickAccess": {
"files": {
"title": "Mijn Bestanden",
@@ -1657,7 +1663,8 @@
"title": "Sleep bestanden om te uploaden",
"description": "Laat los om je bestanden te uploaden"
},
"pasteSuccess": "{count, plural, =1 {Afbeelding geplakt en succesvol geüpload} other {# afbeeldingen geplakt en succesvol geüpload}}"
"pasteSuccess": "{count, plural, =1 {Afbeelding geplakt en succesvol geüpload} other {# afbeeldingen geplakt en succesvol geüpload}}",
"filesQueued": "{count, plural, one {# bestand in de wachtrij voor upload} other {# bestanden in de wachtrij voor upload}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
"nameRequired": "Naam is verplicht",
"required": "Dit veld is verplicht"
},
"qrCodeModal": {
"title": "QR Code Delen",
"description": "Scan deze QR-code om toegang te krijgen tot de link.",
"download": "QR Code Downloaden"
}
}
}

View File

@@ -159,7 +159,8 @@
"passwordLabel": "Hasło",
"create": "Utwórz Udostępnienie",
"success": "Udostępnienie utworzone pomyślnie",
"error": "Nie udało się utworzyć udostępnienia"
"error": "Nie udało się utworzyć udostępnienia",
"namePlaceholder": "Wprowadź nazwę dla swojego udostępnienia"
},
"dashboard": {
"loadError": "Nie udało się załadować danych panelu głównego",
@@ -454,6 +455,11 @@
},
"pageTitle": "Profil"
},
"qrCodeModal": {
"title": "Udostępnij kod QR",
"description": "Skanuj ten kod QR, aby uzyskać dostęp do linku.",
"download": "Pobierz kod QR"
},
"quickAccess": {
"files": {
"title": "Moje pliki",
@@ -1657,7 +1663,8 @@
"title": "Upuść pliki, aby przesłać",
"description": "Zwolnij, aby przesłać pliki"
},
"pasteSuccess": "{count, plural, =1 {Obraz wklejony i przesłany pomyślnie} other {# obrazy wklejone i przesłane pomyślnie}}"
"pasteSuccess": "{count, plural, =1 {Obraz wklejony i przesłany pomyślnie} other {# obrazy wklejone i przesłane pomyślnie}}",
"filesQueued": "{count, plural, one {# plik w kolejce do przesyłania} other {# pliki w kolejce do przesyłania}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
"nameRequired": "Nazwa jest wymagana",
"required": "To pole jest wymagane"
},
"qrCodeModal": {
"title": "Udostępnij kod QR",
"description": "Skanuj ten kod QR, aby uzyskać dostęp do linku.",
"download": "Pobierz kod QR"
}
}
}

View File

@@ -159,7 +159,8 @@
"passwordLabel": "Senha",
"create": "Criar compartilhamento",
"success": "Compartilhamento criado com sucesso",
"error": "Falha ao criar compartilhamento"
"error": "Falha ao criar compartilhamento",
"namePlaceholder": "Digite um nome para seu compartilhamento"
},
"dashboard": {
"loadError": "Falha ao carregar dados do painel",
@@ -454,6 +455,11 @@
},
"pageTitle": "Perfil"
},
"qrCodeModal": {
"title": "Compartilhar QR Code",
"description": "Escaneie este código QR para acessar o link.",
"download": "Baixar QR Code"
},
"quickAccess": {
"files": {
"title": "Meus Arquivos",
@@ -643,7 +649,7 @@
"edit": "Editar",
"delete": "Excluir",
"viewFiles": "Arquivos Recebidos",
"viewQrCode": "[TO_TRANSLATE] View QR Code"
"viewQrCode": "Ver QR Code"
},
"empty": {
"title": "Nenhum link de recebimento criado",
@@ -1657,7 +1663,8 @@
"continue": "Continuar Uploads",
"cancel": "Cancelar Uploads"
},
"pasteSuccess": "{count, plural, =1 {Imagem colada e enviada com sucesso} other {# imagens coladas e enviadas com sucesso}}"
"pasteSuccess": "{count, plural, =1 {Imagem colada e enviada com sucesso} other {# imagens coladas e enviadas com sucesso}}",
"filesQueued": "{count, plural, one {# arquivo na fila para upload} other {# arquivos na fila para upload}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"lastNameRequired": "O sobrenome é necessário",
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
"usernameSpaces": "O nome de usuário não pode conter espaços"
},
"qrCodeModal": {
"title": "Compartilhar QR Code",
"description": "Escaneie este código QR para acessar o link.",
"download": "Baixar QR Code"
}
}
}

View File

@@ -159,7 +159,8 @@
"success": "Общий доступ успешно создан",
"error": "Не удалось создать общий доступ",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Введите описание (опционально)"
"descriptionPlaceholder": "Введите описание (опционально)",
"namePlaceholder": "Введите имя для вашего общего доступа"
},
"dashboard": {
"loadError": "Ошибка загрузки данных панели управления",
@@ -454,6 +455,11 @@
},
"pageTitle": "Профиль"
},
"qrCodeModal": {
"title": "Поделиться QR-кодом",
"description": "Отсканируйте этот QR-код, чтобы получить доступ к ссылке.",
"download": "Скачать QR-код"
},
"quickAccess": {
"files": {
"title": "Мои файлы",
@@ -1657,7 +1663,8 @@
"title": "Перетащите файлы для загрузки",
"description": "Отпустите, чтобы загрузить файлы"
},
"pasteSuccess": "{count, plural, =1 {Изображение вставлено и успешно загружено} other {# изображений вставлено и успешно загружено}}"
"pasteSuccess": "{count, plural, =1 {Изображение вставлено и успешно загружено} other {# изображений вставлено и успешно загружено}}",
"filesQueued": "{count, plural, one {# файл в очереди для загрузки} other {# файлов в очереди для загрузки}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
},
"qrCodeModal": {
"title": "Поделиться QR-кодом",
"description": "Отсканируйте этот QR-код, чтобы получить доступ к ссылке.",
"download": "Скачать QR-код"
}
}
}

View File

@@ -159,7 +159,8 @@
"success": "Paylaşım başarıyla oluşturuldu",
"error": "Paylaşım oluşturulamadı",
"descriptionLabel": "Açıklama",
"descriptionPlaceholder": "Açıklama girin (isteğe bağlı)"
"descriptionPlaceholder": "Açıklama girin (isteğe bağlı)",
"namePlaceholder": "Paylaşımınız için bir ad girin"
},
"dashboard": {
"loadError": "Gösterge paneli verileri yüklenemedi",
@@ -454,6 +455,11 @@
},
"pageTitle": "Profil"
},
"qrCodeModal": {
"title": "QR Kodu Paylaş",
"description": "Bu QR kodu tarayarak bağlantıya erişebilirsiniz.",
"download": "QR Kodu İndir"
},
"quickAccess": {
"files": {
"title": "Benim Dosyalarım",
@@ -1657,7 +1663,8 @@
"title": "Yüklemek için dosyaları bırakın",
"description": "Dosyalarınızı yüklemek için bırakın"
},
"pasteSuccess": "{count, plural, =1 {Görüntü yapıştırıldı ve başarıyla yüklendi} other {# görüntü yapıştırıldı ve başarıyla yüklendi}}"
"pasteSuccess": "{count, plural, =1 {Görüntü yapıştırıldı ve başarıyla yüklendi} other {# görüntü yapıştırıldı ve başarıyla yüklendi}}",
"filesQueued": "{count, plural, one {# dosya yükleme için bekliyor} other {# dosya yükleme için bekliyor}}"
},
"users": {
"modes": {
@@ -1742,10 +1749,5 @@
"passwordRequired": "Şifre gerekli",
"nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur"
},
"qrCodeModal": {
"title": "QR Kodu Paylaş",
"description": "Bu QR kodu tarayarak bağlantıya erişebilirsiniz.",
"download": "QR Kodu İndir"
}
}
}

View File

@@ -159,7 +159,8 @@
"passwordLabel": "密码",
"create": "创建分享",
"success": "分享创建成功",
"error": "创建分享失败"
"error": "创建分享失败",
"namePlaceholder": "输入分享名称"
},
"dashboard": {
"loadError": "加载仪表盘数据失败",
@@ -454,6 +455,11 @@
},
"pageTitle": "个人资料"
},
"qrCodeModal": {
"title": "分享QR Code",
"description": "扫描此QR Code以访问链接。",
"download": "下载QR Code"
},
"quickAccess": {
"files": {
"title": "我的文件",
@@ -1506,11 +1512,7 @@
"copyToClipboard": "复制到剪贴板",
"savedMessage": "我已保存备用码",
"available": "可用备用码:{count}个",
"instructions": [
"• 将这些代码保存在安全的位置",
"• 每个备用码只能使用一次",
"• 您可以随时生成新的备用码"
]
"instructions": ["• 将这些代码保存在安全的位置", "• 每个备用码只能使用一次", "• 您可以随时生成新的备用码"]
},
"verification": {
"title": "双重认证",
@@ -1657,7 +1659,8 @@
"title": "拖放文件以上传",
"description": "松开以上传您的文件"
},
"pasteSuccess": "{count, plural, =1 {图片已成功粘贴并上传} other {# 张图片已成功粘贴并上传}}"
"pasteSuccess": "{count, plural, =1 {图片已成功粘贴并上传} other {# 张图片已成功粘贴并上传}}",
"filesQueued": "{count, plural, one {# 文件正在等待上传} other {# 文件正在等待上传}}"
},
"users": {
"modes": {
@@ -1742,10 +1745,5 @@
"passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
},
"qrCodeModal": {
"title": "分享QR Code",
"description": "扫描此QR Code以访问链接。",
"download": "下载QR Code"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-web",
"version": "3.1.4-beta",
"version": "3.1.8-beta",
"description": "Frontend for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -29,7 +29,7 @@ export function Navbar() {
{appLogo && <img alt="App Logo" className="h-8 w-8 object-contain rounded" src={appLogo} />}
<p className="font-bold text-2xl">{appName}</p>
</Link>
<nav className="hidden lg:flex ml-2 gap-4">
<nav className="hidden md:flex ml-2 gap-4">
{siteConfig.navItems.map((item) => (
<Link
key={item.href}
@@ -44,7 +44,7 @@ export function Navbar() {
))}
</nav>
</div>
<div className="hidden md:flex items-center gap-2">
<div className="hidden lg:flex items-center gap-2">
<LanguageSwitcher />
<ModeToggle />
@@ -56,7 +56,7 @@ export function Navbar() {
</Button>
</div>
<div className="flex items-center gap-2 sm:hidden">
<div className="flex items-center gap-2 md:hidden">
<LanguageSwitcher />
<ModeToggle />
<Sheet open={isMenuOpen} onOpenChange={setIsMenuOpen}>
@@ -79,6 +79,16 @@ export function Navbar() {
{item.label}
</Link>
))}
<Link
href={siteConfig.links.sponsor}
target="_blank"
rel="noopener noreferrer"
className="text-foreground text-lg font-medium flex items-center gap-2"
onClick={() => setIsMenuOpen(false)}
>
<IconHeart className="h-4 w-4 text-destructive" />
Sponsor
</Link>
</div>
</div>
</SheetContent>

View File

@@ -4,34 +4,51 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { create } from "zustand";
import { useAuth } from "@/contexts/auth-context";
import { useSecureConfigValue } from "@/hooks/use-secure-configs";
interface HomeStore {
isLoading: boolean;
shouldShowHomePage: boolean;
setIsLoading: (loading: boolean) => void;
setShouldShowHomePage: (show: boolean) => void;
}
const useHomeStore = create<HomeStore>((set) => ({
isLoading: true,
shouldShowHomePage: false,
setIsLoading: (loading: boolean) => set({ isLoading: loading }),
setShouldShowHomePage: (show: boolean) => set({ shouldShowHomePage: show }),
}));
export function useHome() {
const router = useRouter();
const { isLoading, setIsLoading } = useHomeStore();
const { isLoading, shouldShowHomePage, setIsLoading, setShouldShowHomePage } = useHomeStore();
const { isAuthenticated } = useAuth();
const { value: showHomePage, isLoading: configLoading } = useSecureConfigValue("showHomePage");
useEffect(() => {
if (!configLoading) {
if (isAuthenticated === true) {
router.replace("/dashboard");
return;
}
}, [isAuthenticated, router]);
useEffect(() => {
if (!configLoading && isAuthenticated !== null) {
setIsLoading(false);
if (showHomePage !== "true") {
router.push("/login");
setShouldShowHomePage(false);
} else if (isAuthenticated === false) {
setShouldShowHomePage(true);
}
}
}, [router, showHomePage, configLoading, setIsLoading]);
}, [router, showHomePage, configLoading, isAuthenticated, setIsLoading, setShouldShowHomePage]);
return {
isLoading,
shouldShowHomePage,
};
}

View File

@@ -7,9 +7,9 @@ import { Navbar } from "./components/navbar";
import { useHome } from "./hooks/use-home";
export default function HomePage() {
const { isLoading } = useHome();
const { isLoading, shouldShowHomePage } = useHome();
if (isLoading) {
if (isLoading || !shouldShowHomePage) {
return <LoadingScreen />;
}

View File

@@ -4,6 +4,7 @@ import { getLocale } from "next-intl/server";
import "./globals.css";
import { RedirectHandler } from "@/components/auth/redirect-handler";
import { Favicon } from "@/components/layout/favicon";
import { DynamicToaster } from "@/components/ui/dynamic-toaster";
import { useAppInfo } from "@/contexts/app-info-context";
@@ -39,7 +40,9 @@ export default async function RootLayout({
<NextIntlClientProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<AuthProvider>
<ShareProvider>{children}</ShareProvider>
<RedirectHandler>
<ShareProvider>{children}</ShareProvider>
</RedirectHandler>
</AuthProvider>
<DynamicToaster />
</ThemeProvider>

View File

@@ -34,6 +34,12 @@ export function useLogin() {
const [passwordAuthEnabled, setPasswordAuthEnabled] = useState(true);
const [authConfigLoading, setAuthConfigLoading] = useState(true);
useEffect(() => {
if (isAuthenticated === true) {
router.replace("/dashboard");
}
}, [isAuthenticated, router]);
useEffect(() => {
const errorParam = searchParams.get("error");
const messageParam = searchParams.get("message");

View File

@@ -17,7 +17,7 @@ export default function LoginPage() {
const login = useLogin();
const { firstAccess } = useAppInfo();
if (login.isAuthenticated === null) {
if (login.isAuthenticated === null || login.isAuthenticated === true) {
return <LoadingScreen />;
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/contexts/auth-context";
@@ -14,16 +14,21 @@ type ProtectedRouteProps = {
export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
const { isAuthenticated, isAdmin } = useAuth();
const router = useRouter();
const [hasCheckedAuth, setHasCheckedAuth] = useState(false);
useEffect(() => {
if (isAuthenticated === false) {
router.replace("/login");
} else if (requireAdmin && isAdmin === false) {
router.replace("/dashboard");
if (isAuthenticated !== null) {
setHasCheckedAuth(true);
if (isAuthenticated === false) {
router.replace("/login");
} else if (requireAdmin && isAdmin === false) {
router.replace("/dashboard");
}
}
}, [isAuthenticated, isAdmin, requireAdmin, router]);
if (isAuthenticated === null || (requireAdmin && isAdmin === null)) {
if (!hasCheckedAuth || isAuthenticated === null || (requireAdmin && isAdmin === null)) {
return <LoadingScreen />;
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { useAuth } from "@/contexts/auth-context";
interface RedirectHandlerProps {
children: React.ReactNode;
}
const publicPaths = [
"/login",
"/forgot-password",
"/reset-password",
"/auth/callback",
"/auth/oidc/callback",
"/s/",
"/r/",
];
const homePaths = ["/"];
export function RedirectHandler({ children }: RedirectHandlerProps) {
const router = useRouter();
const pathname = usePathname();
const { isAuthenticated } = useAuth();
useEffect(() => {
if (isAuthenticated === true) {
if (publicPaths.some((path) => pathname.startsWith(path)) || homePaths.includes(pathname)) {
router.replace("/dashboard");
return;
}
} else if (isAuthenticated === false) {
if (!publicPaths.some((path) => pathname.startsWith(path)) && !homePaths.includes(pathname)) {
router.replace("/login");
return;
}
}
}, [isAuthenticated, pathname, router]);
if (isAuthenticated === null) {
return <LoadingScreen />;
}
if (
isAuthenticated === true &&
(publicPaths.some((path) => pathname.startsWith(path)) || homePaths.includes(pathname))
) {
return <LoadingScreen />;
}
if (
isAuthenticated === false &&
!publicPaths.some((path) => pathname.startsWith(path)) &&
!homePaths.includes(pathname)
) {
return <LoadingScreen />;
}
return <>{children}</>;
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { IconLogout, IconSettings, IconUser, IconUsers } from "@tabler/icons-react";
@@ -21,8 +22,22 @@ import { logout as logoutAPI } from "@/http/endpoints";
export function Navbar() {
const t = useTranslations();
const router = useRouter();
const { user, isAdmin, logout } = useAuth();
const { user, isAdmin, logout, isAuthenticated } = useAuth();
const { appName, appLogo } = useAppInfo();
const [isNavigating, setIsNavigating] = useState(false);
const handleLogoClick = async () => {
if (isNavigating || !isAuthenticated) return;
try {
setIsNavigating(true);
router.replace("/dashboard");
} catch (err) {
console.error("Error navigating to dashboard:", err);
} finally {
setTimeout(() => setIsNavigating(false), 500);
}
};
const handleLogout = async () => {
try {
@@ -39,10 +54,15 @@ export function Navbar() {
<div className="container flex h-16 max-w-screen-xl items-center mx-auto lg:px-6">
<div className="flex flex-1 items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard" className="flex items-center gap-2 cursor-pointer">
<div
onClick={handleLogoClick}
className={`flex items-center gap-2 cursor-pointer transition-opacity ${
isNavigating ? "opacity-50" : "opacity-100"
}`}
>
{appLogo && <img alt={t("navbar.logoAlt")} className="h-8 w-8 object-contain rounded" src={appLogo} />}
<p className="font-bold text-2xl">{appName}</p>
</Link>
</div>
</div>
<div className="flex items-center gap-2 cursor-pointer">

View File

@@ -37,7 +37,15 @@ export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModa
name: formData.name,
description: formData.description || undefined,
password: formData.isPasswordProtected ? formData.password : undefined,
expiration: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined,
expiration: formData.expiresAt
? (() => {
const dateValue = formData.expiresAt;
if (dateValue.length === 10) {
return new Date(dateValue + "T23:59:59").toISOString();
}
return new Date(dateValue).toISOString();
})()
: undefined,
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
files: [],
});
@@ -71,7 +79,16 @@ export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModa
<div className="flex flex-col gap-4">
<div className="space-y-2">
<Label>{t("createShare.nameLabel")}</Label>
<Input value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} />
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onPaste={(e) => {
e.preventDefault();
const pastedText = e.clipboardData.getData("text");
setFormData({ ...formData, name: pastedText });
}}
placeholder={t("createShare.namePlaceholder")}
/>
</div>
<div className="space-y-2">
@@ -93,6 +110,12 @@ export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModa
type="datetime-local"
value={formData.expiresAt}
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
onBlur={(e) => {
const value = e.target.value;
if (value && value.length === 10) {
setFormData({ ...formData, expiresAt: value + "T23:59" });
}
}}
/>
</div>

View File

@@ -124,7 +124,11 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
let previewUrl: string | undefined;
if (file.type.startsWith("image/")) {
previewUrl = URL.createObjectURL(file);
try {
previewUrl = URL.createObjectURL(file);
} catch (error) {
console.warn("Failed to create preview URL:", error);
}
}
return {
@@ -142,6 +146,10 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
const newUploads = Array.from(files).map(createFileUpload);
setFileUploads((prev) => [...prev, ...newUploads]);
setHasShownSuccessToast(false);
if (newUploads.length > 0) {
toast.info(t("uploadFile.filesQueued", { count: newUploads.length }));
}
};
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -164,7 +172,12 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
const handleDrop = (event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(false);
handleFilesSelect(event.dataTransfer.files);
event.stopPropagation();
const files = event.dataTransfer.files;
if (files.length > 0) {
handleFilesSelect(files);
}
};
const renderFileIcon = (fileName: string) => {

View File

@@ -39,11 +39,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
useEffect(() => {
let isMounted = true;
const checkAuth = async () => {
try {
const appInfoResponse = await getAppInfo();
const appInfo = appInfoResponse.data;
if (!isMounted) return;
if (appInfo.firstUserAccess) {
setUser(null);
setIsAdmin(false);
@@ -52,8 +56,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
const response = await getCurrentUser();
if (!isMounted) return;
if (!response?.data?.user) {
throw new Error("No user data");
setUser(null);
setIsAdmin(false);
setIsAuthenticated(false);
return;
}
const { isAdmin, ...userData } = response.data.user;
@@ -62,6 +72,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setIsAdmin(isAdmin);
setIsAuthenticated(true);
} catch (err) {
if (!isMounted) return;
console.error(err);
setUser(null);
setIsAdmin(false);
@@ -70,6 +82,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
checkAuth();
return () => {
isMounted = false;
};
}, []);
return (

View File

@@ -3,13 +3,14 @@ services:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY (REQUIRED if DISABLE_FILESYSTEM_ENCRYPTION is false)
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
# - ENABLE_S3=true # Set to true to enable S3-compatible storage (OPTIONAL - default is false)
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to false to enable file encryption (ENCRYPTION_KEY becomes required) | (OPTIONAL - default is true)
# - ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY (REQUIRED if DISABLE_FILESYSTEM_ENCRYPTION is false)
# - PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
# - PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs for see all supported languages
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
# - DISABLE_FILESYSTEM_ENCRYPTION=true # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional) | (OPTIONAL - default is false)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)

View File

@@ -12,8 +12,8 @@ services:
- S3_REGION=${S3_REGION:-us-east-1} # S3 region (us-east-1 is the default region) but it depends on your s3 server region
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-palmr-files} # Bucket name for the S3 storage (here we are using palmr-files as the bucket name to understand that this is the bucket for palmr)
- S3_FORCE_PATH_STYLE=true # For MinIO compatibility we have to set this to true
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# - PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
# - PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs for see all supported languages
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
ports:

View File

@@ -12,8 +12,8 @@ services:
- S3_REGION=${S3_REGION:-us-east-1} # S3 region (us-east-1 is the default region) but it depends on your s3 server region
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-palmr-files} # Bucket name for the S3 storage (here we are using palmr-files as the bucket name to understand that this is the bucket for palmr)
- S3_FORCE_PATH_STYLE=false # For S3 compatibility we have to set this to false
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# - PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
# - PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs for see all supported languages
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
ports:

View File

@@ -3,13 +3,14 @@ services:
image: kyantech/palmr:latest
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY (REQUIRED if DISABLE_FILESYSTEM_ENCRYPTION is false)
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
# - ENABLE_S3=false # Set to true to enable S3-compatible storage (OPTIONAL - default is false)
# - DISABLE_FILESYSTEM_ENCRYPTION=true # Set to false to enable file encryption (ENCRYPTION_KEY becomes required) | (OPTIONAL - default is true)
# - ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY (REQUIRED if DISABLE_FILESYSTEM_ENCRYPTION is false)
# - PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
# - PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1000) | See our UID/GID Documentation for more information
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US) | See the docs to see all supported languages
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
# - DISABLE_FILESYSTEM_ENCRYPTION=true # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional) | (OPTIONAL - default is false)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)

147
infra/check-missing.js Normal file
View File

@@ -0,0 +1,147 @@
const { PrismaClient } = require('@prisma/client');
const fs = require('fs');
const path = require('path');
const prisma = new PrismaClient();
const loadConfigs = () => {
try {
const configsPath = path.join(__dirname, 'configs.json');
const configs = JSON.parse(fs.readFileSync(configsPath, 'utf8'));
return Object.values(configs).flat();
} catch (error) {
console.error('Error loading configs:', error.message);
return [];
}
};
const loadProviders = () => {
try {
const providersPath = path.join(__dirname, 'providers.json');
return JSON.parse(fs.readFileSync(providersPath, 'utf8'));
} catch (error) {
console.error('Error loading providers:', error.message);
return [];
}
};
async function checkSeedingNeeded() {
try {
const appConfigCount = await prisma.appConfig.count();
const userCount = await prisma.user.count();
const authProviderCount = await prisma.authProvider.count();
if (appConfigCount === 0 || userCount === 0) {
console.log('true');
return;
}
if (authProviderCount === 0) {
console.log('true');
return;
}
const allConfigs = loadConfigs();
const existingConfigs = await prisma.appConfig.findMany({
where: {
key: {
in: allConfigs
}
},
select: { key: true }
});
const existingConfigKeys = existingConfigs.map(c => c.key);
const missingConfigs = allConfigs.filter(key => !existingConfigKeys.includes(key));
if (missingConfigs.length > 0) {
console.log('true');
return;
}
const expectedProviders = loadProviders();
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('true');
return;
}
console.log('false');
} catch (error) {
console.error('Error checking if seeding is needed:', error);
console.log('true');
} finally {
await prisma.$disconnect();
}
}
async function checkMissingProviders() {
try {
const expectedProviders = loadProviders();
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('Missing providers: ' + missingProviders.join(', '));
} else {
console.log('No missing providers');
}
} catch (error) {
console.error('Error checking missing providers:', error);
console.log('Error checking providers');
} finally {
await prisma.$disconnect();
}
}
async function checkMissingConfigs() {
try {
const allConfigs = loadConfigs();
const existingConfigs = await prisma.appConfig.findMany({
where: {
key: {
in: allConfigs
}
},
select: { key: true }
});
const existingConfigKeys = existingConfigs.map(c => c.key);
const missingConfigs = allConfigs.filter(key => !existingConfigKeys.includes(key));
if (missingConfigs.length > 0) {
console.log('Missing configurations: ' + missingConfigs.join(', '));
} else {
console.log('No missing configurations');
}
} catch (error) {
console.error('Error checking missing configurations:', error);
console.log('Error checking configurations');
} finally {
await prisma.$disconnect();
}
}
const command = process.argv[2];
switch (command) {
case 'check-seeding':
checkSeedingNeeded();
break;
case 'check-providers':
checkMissingProviders();
break;
case 'check-configs':
checkMissingConfigs();
break;
default:
console.error('Unknown command. Use: check-seeding, check-providers, or check-configs');
process.exit(1);
}

37
infra/configs.json Normal file
View File

@@ -0,0 +1,37 @@
{
"general": [
"appName",
"showHomePage",
"appDescription",
"appLogo",
"firstUserAccess",
"serverUrl"
],
"storage": [
"maxFileSize",
"maxTotalStoragePerUser"
],
"security": [
"jwtSecret",
"maxLoginAttempts",
"loginBlockDuration",
"passwordMinLength",
"passwordAuthEnabled",
"passwordResetTokenExpiration"
],
"email": [
"smtpEnabled",
"smtpHost",
"smtpPort",
"smtpUser",
"smtpPass",
"smtpFromName",
"smtpFromEmail",
"smtpSecure",
"smtpNoAuth",
"smtpTrustSelfSigned"
],
"auth-providers": [
"authProvidersEnabled"
]
}

11
infra/providers.json Normal file
View File

@@ -0,0 +1,11 @@
[
"google",
"discord",
"github",
"auth0",
"kinde",
"zitadel",
"authentik",
"frontegg",
"pocketid"
]

View File

@@ -3,8 +3,8 @@ set -e
echo "🌴 Starting Palmr Server..."
TARGET_UID=${PALMR_UID:-1001}
TARGET_GID=${PALMR_GID:-1001}
TARGET_UID=${PALMR_UID:-1000}
TARGET_GID=${PALMR_GID:-1000}
if [ -n "$PALMR_UID" ] || [ -n "$PALMR_GID" ]; then
echo "🔧 Runtime UID/GID: $TARGET_UID:$TARGET_GID"
@@ -35,200 +35,59 @@ if [ "$(id -u)" = "0" ]; then
chown -R $TARGET_UID:$TARGET_GID /app/server/prisma 2>/dev/null || true
fi
run_as_user() {
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID "$@"
else
"$@"
fi
}
if [ ! -f "/app/server/prisma/configs.json" ]; then
echo "📄 Copying configuration files..."
cp -f /app/infra/configs.json /app/server/prisma/configs.json 2>/dev/null || echo "⚠️ Failed to copy configs.json"
cp -f /app/infra/providers.json /app/server/prisma/providers.json 2>/dev/null || echo "⚠️ Failed to copy providers.json"
cp -f /app/infra/check-missing.js /app/server/prisma/check-missing.js 2>/dev/null || echo "⚠️ Failed to copy check-missing.js"
if [ "$(id -u)" = "0" ]; then
chown $TARGET_UID:$TARGET_GID /app/server/prisma/configs.json /app/server/prisma/providers.json /app/server/prisma/check-missing.js 2>/dev/null || true
fi
fi
if [ ! -f "/app/server/prisma/palmr.db" ]; then
echo "🚀 First run detected - setting up database..."
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
fi
run_as_user npx prisma db push --schema=./prisma/schema.prisma --skip-generate
echo "🌱 Seeding database..."
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
else
node ./prisma/seed.js
fi
run_as_user node ./prisma/seed.js
echo "✅ Database setup completed!"
else
echo "♻️ Existing database found"
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
fi
run_as_user npx prisma db push --schema=./prisma/schema.prisma --skip-generate
echo "🔍 Checking if new tables need seeding..."
NEEDS_SEEDING=$(
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID node -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkSeedingNeeded() {
try {
const appConfigCount = await prisma.appConfig.count();
const userCount = await prisma.user.count();
const authProviderCount = await prisma.authProvider.count();
if (appConfigCount === 0 || userCount === 0) {
console.log('true');
return;
}
if (authProviderCount === 0) {
console.log('true');
return;
}
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('true');
return;
}
console.log('false');
} catch (error) {
console.log('true');
} finally {
await prisma.\$disconnect();
}
}
checkSeedingNeeded();
" 2>/dev/null || echo "true"
else
node -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkSeedingNeeded() {
try {
const appConfigCount = await prisma.appConfig.count();
const userCount = await prisma.user.count();
const authProviderCount = await prisma.authProvider.count();
if (appConfigCount === 0 || userCount === 0) {
console.log('true');
return;
}
if (authProviderCount === 0) {
console.log('true');
return;
}
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('true');
return;
}
console.log('false');
} catch (error) {
console.log('true');
} finally {
await prisma.\$disconnect();
}
}
checkSeedingNeeded();
" 2>/dev/null || echo "true"
fi
)
NEEDS_SEEDING=$(run_as_user node ./prisma/check-missing.js check-seeding 2>/dev/null || echo "true")
if [ "$NEEDS_SEEDING" = "true" ]; then
echo "🌱 New tables detected or missing data, running seed..."
# Check which providers are missing for better logging
MISSING_PROVIDERS=$(
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID node -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkMissingProviders() {
try {
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('Missing providers: ' + missingProviders.join(', '));
} else {
console.log('No missing providers');
}
} catch (error) {
console.log('Error checking providers');
} finally {
await prisma.\$disconnect();
}
}
checkMissingProviders();
" 2>/dev/null || echo "Error checking providers"
else
node -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkMissingProviders() {
try {
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('Missing providers: ' + missingProviders.join(', '));
} else {
console.log('No missing providers');
}
} catch (error) {
console.log('Error checking providers');
} finally {
await prisma.\$disconnect();
}
}
checkMissingProviders();
" 2>/dev/null || echo "Error checking providers"
fi
)
MISSING_PROVIDERS=$(run_as_user node ./prisma/check-missing.js check-providers 2>/dev/null || echo "Error checking providers")
MISSING_CONFIGS=$(run_as_user node ./prisma/check-missing.js check-configs 2>/dev/null || echo "Error checking configurations")
if [ "$MISSING_PROVIDERS" != "No missing providers" ] && [ "$MISSING_PROVIDERS" != "Error checking providers" ]; then
echo "🔍 $MISSING_PROVIDERS"
fi
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
else
node ./prisma/seed.js
if [ "$MISSING_CONFIGS" != "No missing configurations" ] && [ "$MISSING_CONFIGS" != "Error checking configurations" ]; then
echo "⚙️ $MISSING_CONFIGS"
fi
run_as_user node ./prisma/seed.js
echo "✅ Seeding completed!"
else
echo "✅ All tables have data, no seeding needed"
@@ -242,4 +101,4 @@ if [ "$(id -u)" = "0" ]; then
exec su-exec $TARGET_UID:$TARGET_GID node dist/server.js
else
exec node dist/server.js
fi
fi

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-monorepo",
"version": "3.1.4-beta",
"version": "3.1.8-beta",
"description": "Palmr monorepo with Husky configuration",
"private": true,
"packageManager": "pnpm@10.6.0",