mirror of
https://github.com/kyantech/Palmr.git
synced 2025-11-02 13:03:15 +00:00
Compare commits
21 Commits
v3.1.3-bet
...
v3.1.6-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb4235550 | ||
|
|
6014b3e961 | ||
|
|
32f0a891ba | ||
|
|
124ac46eeb | ||
|
|
d3e76c19bf | ||
|
|
dd1ce189ae | ||
|
|
82e43b06c6 | ||
|
|
aab4e6d9df | ||
|
|
1f097678ce | ||
|
|
96cb4a04ec | ||
|
|
b7c4b37e89 | ||
|
|
952cf27ecb | ||
|
|
765810e4e5 | ||
|
|
36d09a7679 | ||
|
|
c6d6648942 | ||
|
|
54ca7580b0 | ||
|
|
4e53d239bb | ||
|
|
6491894f0e | ||
|
|
93e05dd913 | ||
|
|
2efe69e50b | ||
|
|
761865a6a3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,6 +30,7 @@ apps/server/dist/*
|
||||
|
||||
#DEFAULT
|
||||
.env
|
||||
.steering
|
||||
data/
|
||||
|
||||
node_modules/
|
||||
11
Dockerfile
11
Dockerfile
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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"**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ==="
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-docs",
|
||||
"version": "3.1.3-beta",
|
||||
"version": "3.1.6-beta",
|
||||
"description": "Docs for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-api",
|
||||
"version": "3.1.3-beta",
|
||||
"version": "3.1.6-beta",
|
||||
"description": "API for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
||||
@@ -147,6 +147,12 @@ const defaultConfigs = [
|
||||
type: "boolean",
|
||||
group: "auth-providers",
|
||||
},
|
||||
{
|
||||
key: "passwordAuthEnabled",
|
||||
value: "true",
|
||||
type: "boolean",
|
||||
group: "security",
|
||||
},
|
||||
{
|
||||
key: "serverUrl",
|
||||
value: "http://localhost:3333",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -18,6 +18,15 @@ export class AppController {
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemInfo(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const systemInfo = await this.appService.getSystemInfo();
|
||||
return reply.send(systemInfo);
|
||||
} catch (error: any) {
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getAllConfigs(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const configs = await this.appService.getAllConfigs();
|
||||
|
||||
@@ -53,6 +53,26 @@ export async function appRoutes(app: FastifyInstance) {
|
||||
appController.getAppInfo.bind(appController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/app/system-info",
|
||||
{
|
||||
schema: {
|
||||
tags: ["App"],
|
||||
operationId: "getSystemInfo",
|
||||
summary: "Get system information",
|
||||
description: "Get system information including storage provider",
|
||||
response: {
|
||||
200: z.object({
|
||||
storageProvider: z.enum(["s3", "filesystem"]).describe("The active storage provider"),
|
||||
s3Enabled: z.boolean().describe("Whether S3 storage is enabled"),
|
||||
}),
|
||||
400: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
appController.getSystemInfo.bind(appController)
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/app/configs/:key",
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isS3Enabled } from "../../config/storage.config";
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import { ConfigService } from "../config/service";
|
||||
|
||||
@@ -20,6 +21,13 @@ export class AppService {
|
||||
};
|
||||
}
|
||||
|
||||
async getSystemInfo() {
|
||||
return {
|
||||
storageProvider: isS3Enabled ? "s3" : "filesystem",
|
||||
s3Enabled: isS3Enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllConfigs() {
|
||||
return prisma.appConfig.findMany({
|
||||
where: {
|
||||
@@ -38,6 +46,17 @@ export class AppService {
|
||||
throw new Error("JWT Secret cannot be updated through this endpoint");
|
||||
}
|
||||
|
||||
if (key === "passwordAuthEnabled") {
|
||||
if (value === "false") {
|
||||
const canDisable = await this.configService.validatePasswordAuthDisable();
|
||||
if (!canDisable) {
|
||||
throw new Error(
|
||||
"Password authentication cannot be disabled. At least one authentication provider must be active."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = await prisma.appConfig.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
@@ -56,6 +75,15 @@ export class AppService {
|
||||
if (updates.some((update) => update.key === "jwtSecret")) {
|
||||
throw new Error("JWT Secret cannot be updated through this endpoint");
|
||||
}
|
||||
const passwordAuthUpdate = updates.find((update) => update.key === "passwordAuthEnabled");
|
||||
if (passwordAuthUpdate && passwordAuthUpdate.value === "false") {
|
||||
const canDisable = await this.configService.validatePasswordAuthDisable();
|
||||
if (!canDisable) {
|
||||
throw new Error(
|
||||
"Password authentication cannot be disabled. At least one authentication provider must be active."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const keys = updates.map((update) => update.key);
|
||||
const existingConfigs = await prisma.appConfig.findMany({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import { ConfigService } from "../config/service";
|
||||
import { UpdateAuthProviderSchema } from "./dto";
|
||||
import { AuthProvidersService } from "./service";
|
||||
import {
|
||||
@@ -39,9 +40,11 @@ const ERROR_MESSAGES = {
|
||||
|
||||
export class AuthProvidersController {
|
||||
private authProvidersService: AuthProvidersService;
|
||||
private configService: ConfigService;
|
||||
|
||||
constructor() {
|
||||
this.authProvidersService = new AuthProvidersService();
|
||||
this.configService = new ConfigService();
|
||||
}
|
||||
|
||||
private buildRequestContext(request: FastifyRequest): RequestContext {
|
||||
@@ -223,13 +226,24 @@ export class AuthProvidersController {
|
||||
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const data = request.body;
|
||||
const data = request.body as any;
|
||||
|
||||
const existingProvider = await this.authProvidersService.getProviderById(id);
|
||||
if (!existingProvider) {
|
||||
return this.sendErrorResponse(reply, 404, ERROR_MESSAGES.PROVIDER_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (data.enabled === false && existingProvider.enabled === true) {
|
||||
const canDisable = await this.configService.validateAllProvidersDisable();
|
||||
if (!canDisable) {
|
||||
return this.sendErrorResponse(
|
||||
reply,
|
||||
400,
|
||||
"Cannot disable the last authentication provider when password authentication is disabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isOfficial = this.authProvidersService.isOfficialProvider(existingProvider.name);
|
||||
|
||||
if (isOfficial) {
|
||||
@@ -300,6 +314,17 @@ export class AuthProvidersController {
|
||||
return this.sendErrorResponse(reply, 400, ERROR_MESSAGES.OFFICIAL_CANNOT_DELETE);
|
||||
}
|
||||
|
||||
if (provider.enabled) {
|
||||
const canDisable = await this.configService.validateAllProvidersDisable();
|
||||
if (!canDisable) {
|
||||
return this.sendErrorResponse(
|
||||
reply,
|
||||
400,
|
||||
"Cannot delete the last authentication provider when password authentication is disabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.authProvidersService.deleteProvider(id);
|
||||
return this.sendSuccessResponse(reply, undefined, "Provider deleted successfully");
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { ConfigService } from "../config/service";
|
||||
import {
|
||||
CompleteTwoFactorLoginSchema,
|
||||
createResetPasswordSchema,
|
||||
@@ -11,6 +12,7 @@ import { AuthService } from "./service";
|
||||
|
||||
export class AuthController {
|
||||
private authService = new AuthService();
|
||||
private configService = new ConfigService();
|
||||
|
||||
private getClientInfo(request: FastifyRequest) {
|
||||
const realIP = request.headers["x-real-ip"] as string;
|
||||
@@ -169,4 +171,15 @@ export class AuthController {
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthConfig(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||
return reply.send({
|
||||
passwordAuthEnabled: passwordAuthEnabled === "true",
|
||||
});
|
||||
} catch (error: any) {
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,4 +280,23 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
},
|
||||
authController.removeAllTrustedDevices.bind(authController)
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/auth/config",
|
||||
{
|
||||
schema: {
|
||||
tags: ["Authentication"],
|
||||
operationId: "getAuthConfig",
|
||||
summary: "Get Authentication Configuration",
|
||||
description: "Get authentication configuration settings",
|
||||
response: {
|
||||
200: z.object({
|
||||
passwordAuthEnabled: z.boolean().describe("Whether password authentication is enabled"),
|
||||
}),
|
||||
400: z.object({ error: z.string().describe("Error message") }),
|
||||
},
|
||||
},
|
||||
},
|
||||
authController.getAuthConfig.bind(authController)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ export class AuthService {
|
||||
private trustedDeviceService = new TrustedDeviceService();
|
||||
|
||||
async login(data: LoginInput, userAgent?: string, ipAddress?: string) {
|
||||
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||
if (passwordAuthEnabled === "false") {
|
||||
throw new Error("Password authentication is disabled. Please use an external authentication provider.");
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials");
|
||||
@@ -146,6 +151,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async requestPasswordReset(email: string, origin: string) {
|
||||
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||
if (passwordAuthEnabled === "false") {
|
||||
throw new Error("Password authentication is disabled. Password reset is not available.");
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUserByEmail(email);
|
||||
if (!user) {
|
||||
return;
|
||||
@@ -171,6 +181,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
|
||||
if (passwordAuthEnabled === "false") {
|
||||
throw new Error("Password authentication is disabled. Password reset is not available.");
|
||||
}
|
||||
|
||||
const resetRequest = await prisma.passwordReset.findFirst({
|
||||
where: {
|
||||
token,
|
||||
|
||||
@@ -13,6 +13,26 @@ export class ConfigService {
|
||||
return config.value;
|
||||
}
|
||||
|
||||
async setValue(key: string, value: string): Promise<void> {
|
||||
await prisma.appConfig.update({
|
||||
where: { key },
|
||||
data: { value },
|
||||
});
|
||||
}
|
||||
|
||||
async validatePasswordAuthDisable(): Promise<boolean> {
|
||||
const enabledProviders = await prisma.authProvider.findMany({
|
||||
where: { enabled: true },
|
||||
});
|
||||
|
||||
return enabledProviders.length > 0;
|
||||
}
|
||||
|
||||
async validateAllProvidersDisable(): Promise<boolean> {
|
||||
const passwordAuthEnabled = await this.getValue("passwordAuthEnabled");
|
||||
return passwordAuthEnabled === "true";
|
||||
}
|
||||
|
||||
async getGroupConfigs(group: string) {
|
||||
const configs = await prisma.appConfig.findMany({
|
||||
where: { group },
|
||||
|
||||
@@ -167,7 +167,7 @@ export class EmailService {
|
||||
});
|
||||
}
|
||||
|
||||
async sendShareNotification(to: string, shareLink: string, shareName?: string) {
|
||||
async sendShareNotification(to: string, shareLink: string, shareName?: string, senderName?: string) {
|
||||
const transporter = await this.createTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error("SMTP is not enabled");
|
||||
@@ -178,19 +178,151 @@ export class EmailService {
|
||||
const appName = await this.configService.getValue("appName");
|
||||
|
||||
const shareTitle = shareName || "Files";
|
||||
const sender = senderName || "Someone";
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
to,
|
||||
subject: `${appName} - ${shareTitle} shared with you`,
|
||||
html: `
|
||||
<h1>${appName} - Shared Files</h1>
|
||||
<p>Someone has shared "${shareTitle}" with you.</p>
|
||||
<p>Click the link below to access the shared files:</p>
|
||||
<a href="${shareLink}">
|
||||
Access Shared Files
|
||||
</a>
|
||||
<p>Note: This share may have an expiration date or view limit.</p>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${appName} - Shared Files</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5; color: #333333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); overflow: hidden; margin-top: 40px; margin-bottom: 40px;">
|
||||
<!-- Header -->
|
||||
<div style="background-color: #22B14C; padding: 30px 20px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">${appName}</h1>
|
||||
<p style="margin: 2px 0 0 0; color: #ffffff; font-size: 16px; opacity: 0.9;">Shared Files</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div style="padding: 40px 30px;">
|
||||
<div style="text-align: center; margin-bottom: 32px;">
|
||||
<h2 style="margin: 0 0 12px 0; color: #1f2937; font-size: 24px; font-weight: 600;">Files Shared With You</h2>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 16px; line-height: 1.6;">
|
||||
<strong style="color: #374151;">${sender}</strong> has shared <strong style="color: #374151;">"${shareTitle}"</strong> with you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="${shareLink}" style="display: inline-block; background-color: #22B14C; color: #ffffff; text-decoration: none; padding: 12px 24px; font-weight: 600; font-size: 16px; border: 2px solid #22B14C; border-radius: 8px; transition: all 0.3s ease;">
|
||||
Access Shared Files
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div style="background-color: #f9fafb; border-left: 4px solid #22B14C; padding: 16px 20px; margin-top: 32px;">
|
||||
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;">
|
||||
<strong>Important:</strong> This share may have an expiration date or view limit. Access it as soon as possible to ensure availability.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="background-color: #f9fafb; padding: 24px 30px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px;">
|
||||
This email was sent by <strong>${appName}</strong>
|
||||
</p>
|
||||
<p style="margin: 8px 0 0 0; color: #9ca3af; font-size: 12px;">
|
||||
If you didn't expect this email, you can safely ignore it.
|
||||
</p>
|
||||
<p style="margin: 4px 0 0 0; color: #9ca3af; font-size: 10px;">
|
||||
Powered by <a href="https://kyantech.com.br" style="color: #9ca3af; text-decoration: none;">Kyantech Solutions</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendReverseShareBatchFileNotification(
|
||||
recipientEmail: string,
|
||||
reverseShareName: string,
|
||||
fileCount: number,
|
||||
fileList: string,
|
||||
uploaderName: string
|
||||
) {
|
||||
const transporter = await this.createTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error("SMTP is not enabled");
|
||||
}
|
||||
|
||||
const fromName = await this.configService.getValue("smtpFromName");
|
||||
const fromEmail = await this.configService.getValue("smtpFromEmail");
|
||||
const appName = await this.configService.getValue("appName");
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
to: recipientEmail,
|
||||
subject: `${appName} - ${fileCount} file${fileCount > 1 ? "s" : ""} uploaded to "${reverseShareName}"`,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${appName} - File Upload Notification</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5; color: #333333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); overflow: hidden; margin-top: 40px; margin-bottom: 40px;">
|
||||
<!-- Header -->
|
||||
<div style="background-color: #22B14C; padding: 30px 20px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">${appName}</h1>
|
||||
<p style="margin: 2px 0 0 0; color: #ffffff; font-size: 16px; opacity: 0.9;">File Upload Notification</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div style="padding: 40px 30px;">
|
||||
<div style="text-align: center; margin-bottom: 32px;">
|
||||
<h2 style="margin: 0 0 12px 0; color: #1f2937; font-size: 24px; font-weight: 600;">New File Uploaded</h2>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 16px; line-height: 1.6;">
|
||||
<strong style="color: #374151;">${uploaderName}</strong> has uploaded <strong style="color: #374151;">${fileCount} file${fileCount > 1 ? "s" : ""}</strong> to your reverse share <strong style="color: #374151;">"${reverseShareName}"</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- File List -->
|
||||
<div style="background-color: #f9fafb; border-radius: 8px; padding: 16px; margin: 32px 0; border-left: 4px solid #22B14C;">
|
||||
<p style="margin: 0 0 8px 0; color: #374151; font-size: 14px;"><strong>Files (${fileCount}):</strong></p>
|
||||
<ul style="margin: 0; padding-left: 20px; color: #6b7280; font-size: 14px; line-height: 1.5;">
|
||||
${fileList
|
||||
.split(", ")
|
||||
.map((file) => `<li style="margin: 4px 0;">${file}</li>`)
|
||||
.join("")}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Info Text -->
|
||||
<div style="text-align: center; margin-top: 32px;">
|
||||
<p style="margin: 0; color: #9ca3af; font-size: 12px;">
|
||||
You can now access and manage these files through your dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="background-color: #f9fafb; padding: 24px 30px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px;">
|
||||
This email was sent by <strong>${appName}</strong>
|
||||
</p>
|
||||
<p style="margin: 8px 0 0 0; color: #9ca3af; font-size: 12px;">
|
||||
If you didn't expect this email, you can safely ignore it.
|
||||
</p>
|
||||
<p style="margin: 4px 0 0 0; color: #9ca3af; font-size: 10px;">
|
||||
Powered by <a href="https://kyantech.com.br" style="color: #9ca3af; text-decoration: none;">Kyantech Solutions</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 buffer = await provider.downloadFile(tokenData.objectName);
|
||||
reply.send(buffer);
|
||||
}
|
||||
await this.downloadFileStream(reply, provider, tokenData.objectName);
|
||||
}
|
||||
|
||||
provider.consumeDownloadToken(token);
|
||||
@@ -237,26 +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 buffer = await provider.downloadFile(objectName);
|
||||
const chunk = buffer.slice(start, end + 1);
|
||||
reply.send(chunk);
|
||||
try {
|
||||
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) {
|
||||
console.error("Range download error:", error);
|
||||
FilesystemStorageProvider.logMemoryUsage(`Range download failed: ${objectName} (${start}-${end})`);
|
||||
if (!reply.sent) {
|
||||
reply.status(500).send({ error: "Download failed" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { env } from "../../env";
|
||||
import { EmailService } from "../email/service";
|
||||
import { FileService } from "../file/service";
|
||||
import { UserService } from "../user/service";
|
||||
import {
|
||||
CreateReverseShareInput,
|
||||
ReverseShareResponseSchema,
|
||||
@@ -41,6 +43,19 @@ const prisma = new PrismaClient();
|
||||
export class ReverseShareService {
|
||||
private reverseShareRepository = new ReverseShareRepository();
|
||||
private fileService = new FileService();
|
||||
private emailService = new EmailService();
|
||||
private userService = new UserService();
|
||||
|
||||
private uploadSessions = new Map<
|
||||
string,
|
||||
{
|
||||
reverseShareId: string;
|
||||
uploaderName: string;
|
||||
uploaderEmail?: string;
|
||||
files: string[];
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
>();
|
||||
|
||||
async createReverseShare(data: CreateReverseShareInput, creatorId: string) {
|
||||
const reverseShare = await this.reverseShareRepository.create(data, creatorId);
|
||||
@@ -295,6 +310,8 @@ export class ReverseShareService {
|
||||
size: BigInt(fileData.size),
|
||||
});
|
||||
|
||||
this.addFileToUploadSession(reverseShare, fileData);
|
||||
|
||||
return this.formatFileResponse(file);
|
||||
}
|
||||
|
||||
@@ -345,6 +362,8 @@ export class ReverseShareService {
|
||||
size: BigInt(fileData.size),
|
||||
});
|
||||
|
||||
this.addFileToUploadSession(reverseShare, fileData);
|
||||
|
||||
return this.formatFileResponse(file);
|
||||
}
|
||||
|
||||
@@ -637,6 +656,55 @@ export class ReverseShareService {
|
||||
};
|
||||
}
|
||||
|
||||
private generateSessionKey(reverseShareId: string, uploaderIdentifier: string): string {
|
||||
return `${reverseShareId}-${uploaderIdentifier}`;
|
||||
}
|
||||
|
||||
private async sendBatchFileUploadNotification(reverseShare: any, uploaderName: string, fileNames: string[]) {
|
||||
try {
|
||||
const creator = await this.userService.getUserById(reverseShare.creatorId);
|
||||
const reverseShareName = reverseShare.name || "Unnamed Reverse Share";
|
||||
const fileCount = fileNames.length;
|
||||
const fileList = fileNames.join(", ");
|
||||
|
||||
await this.emailService.sendReverseShareBatchFileNotification(
|
||||
creator.email,
|
||||
reverseShareName,
|
||||
fileCount,
|
||||
fileList,
|
||||
uploaderName
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send reverse share batch file notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private addFileToUploadSession(reverseShare: any, fileData: UploadToReverseShareInput) {
|
||||
const uploaderIdentifier = fileData.uploaderEmail || fileData.uploaderName || "anonymous";
|
||||
const sessionKey = this.generateSessionKey(reverseShare.id, uploaderIdentifier);
|
||||
const uploaderName = fileData.uploaderName || "Someone";
|
||||
|
||||
const existingSession = this.uploadSessions.get(sessionKey);
|
||||
if (existingSession) {
|
||||
clearTimeout(existingSession.timeout);
|
||||
existingSession.files.push(fileData.name);
|
||||
} else {
|
||||
this.uploadSessions.set(sessionKey, {
|
||||
reverseShareId: reverseShare.id,
|
||||
uploaderName,
|
||||
uploaderEmail: fileData.uploaderEmail,
|
||||
files: [fileData.name],
|
||||
timeout: null as any,
|
||||
});
|
||||
}
|
||||
|
||||
const session = this.uploadSessions.get(sessionKey)!;
|
||||
session.timeout = setTimeout(async () => {
|
||||
await this.sendBatchFileUploadNotification(reverseShare, session.uploaderName, session.files);
|
||||
this.uploadSessions.delete(sessionKey);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private formatReverseShareResponse(reverseShare: ReverseShareData) {
|
||||
const result = {
|
||||
id: reverseShare.id,
|
||||
|
||||
@@ -2,6 +2,7 @@ import bcrypt from "bcryptjs";
|
||||
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import { EmailService } from "../email/service";
|
||||
import { UserService } from "../user/service";
|
||||
import { CreateShareInput, ShareResponseSchema, UpdateShareInput } from "./dto";
|
||||
import { IShareRepository, PrismaShareRepository } from "./repository";
|
||||
|
||||
@@ -9,6 +10,7 @@ export class ShareService {
|
||||
constructor(private readonly shareRepository: IShareRepository = new PrismaShareRepository()) {}
|
||||
|
||||
private emailService = new EmailService();
|
||||
private userService = new UserService();
|
||||
|
||||
private formatShareResponse(share: any) {
|
||||
return {
|
||||
@@ -339,11 +341,26 @@ export class ShareService {
|
||||
throw new Error("No recipients found for this share");
|
||||
}
|
||||
|
||||
// Get sender information
|
||||
let senderName = "Someone";
|
||||
try {
|
||||
const sender = await this.userService.getUserById(userId);
|
||||
if (sender.firstName && sender.lastName) {
|
||||
senderName = `${sender.firstName} ${sender.lastName}`;
|
||||
} else if (sender.firstName) {
|
||||
senderName = sender.firstName;
|
||||
} else if (sender.username) {
|
||||
senderName = sender.username;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get sender information for user ${userId}:`, error);
|
||||
}
|
||||
|
||||
const notifiedRecipients: string[] = [];
|
||||
|
||||
for (const recipient of share.recipients) {
|
||||
try {
|
||||
await this.emailService.sendShareNotification(recipient.email, shareLink, share.name || undefined);
|
||||
await this.emailService.sendShareNotification(recipient.email, shareLink, share.name || undefined, senderName);
|
||||
notifiedRecipients.push(recipient.email);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send email to ${recipient.email}:`, error);
|
||||
|
||||
@@ -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 {
|
||||
@@ -62,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);
|
||||
}
|
||||
|
||||
@@ -253,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);
|
||||
@@ -264,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 {
|
||||
@@ -313,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);
|
||||
@@ -338,9 +582,6 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up empty temporary directories periodically
|
||||
*/
|
||||
private async cleanupEmptyTempDirs(): Promise<void> {
|
||||
try {
|
||||
const tempUploadsDir = directoriesConfig.tempUploads;
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "نسيت كلمة المرور",
|
||||
"description": "أدخل بريدك الإلكتروني وسنرسل لك تعليمات إعادة تعيين كلمة المرور.",
|
||||
"resetInstructions": "تم إرسال تعليمات إعادة التعيين إلى بريدك الإلكتروني",
|
||||
"pageTitle": "نسيت كلمة المرور"
|
||||
"pageTitle": "نسيت كلمة المرور",
|
||||
"passwordAuthDisabled": "تم تعطيل المصادقة بكلمة المرور. يرجى الاتصال بالمسؤول أو استخدام مزود مصادقة خارجي."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "إنشاء رابط المشاركة",
|
||||
@@ -629,7 +630,7 @@
|
||||
},
|
||||
"status": {
|
||||
"active": "نشط",
|
||||
"inactive": "غير نشط",
|
||||
"inactive": "غير نشط",
|
||||
"expired": "منتهي الصلاحية",
|
||||
"protected": "محمي",
|
||||
"public": "عام"
|
||||
@@ -1130,6 +1131,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "الوثوق بالشهادات الموقعة ذاتياً",
|
||||
"description": "قم بتمكين هذا للوثوق بشهادات SSL/TLS الموقعة ذاتياً (مفيد لبيئات التطوير)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "المصادقة بالكلمة السرية",
|
||||
"description": "تمكين أو تعطيل المصادقة بالكلمة السرية"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1139,7 +1144,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "فشل في تحميل الإعدادات",
|
||||
"updateFailed": "فشل في تحديث الإعدادات"
|
||||
"updateFailed": "فشل في تحديث الإعدادات",
|
||||
"passwordAuthRequiresProvider": "لا يمكن تعطيل المصادقة بالكلمة السرية دون وجود على الأقل موفرين مصادقة مفعلين"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "لا توجد تغييرات للحفظ",
|
||||
@@ -1744,4 +1750,4 @@
|
||||
"description": "امسح رمز QR هذا للوصول إلى الرابط.",
|
||||
"download": "تحميل رمز QR"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "Passwort vergessen",
|
||||
"description": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen Anweisungen zum Zurücksetzen Ihres Passworts.",
|
||||
"resetInstructions": "Anweisungen zum Zurücksetzen wurden an Ihre E-Mail gesendet",
|
||||
"pageTitle": "Passwort vergessen"
|
||||
"pageTitle": "Passwort vergessen",
|
||||
"passwordAuthDisabled": "Passwortauthentifizierung ist deaktiviert. Bitte kontaktieren Sie Ihren Administrator oder verwenden Sie einen externen Authentifizierungsanbieter."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Freigabe-Link generieren",
|
||||
@@ -628,7 +629,7 @@
|
||||
"viewQrCode": "QR-Code anzeigen"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"expired": "Abgelaufen",
|
||||
"protected": "Geschützt",
|
||||
@@ -636,7 +637,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"copyLink": "Link kopieren",
|
||||
"editAlias": "Alias bearbeiten",
|
||||
"editAlias": "Alias bearbeiten",
|
||||
"createAlias": "Alias erstellen",
|
||||
"viewDetails": "Details anzeigen",
|
||||
"edit": "Bearbeiten",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"tls": "STARTTLS (Port 587)",
|
||||
"none": "Keine (Unsicher)"
|
||||
}
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Passwort-Authentifizierung",
|
||||
"description": "Passwort-basierte Authentifizierung aktivieren oder deaktivieren"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Fehler beim Laden der Einstellungen",
|
||||
"updateFailed": "Fehler beim Aktualisieren der Einstellungen"
|
||||
"updateFailed": "Fehler beim Aktualisieren der Einstellungen",
|
||||
"passwordAuthRequiresProvider": "Passwort-basierte Authentifizierung kann nicht deaktiviert werden, wenn kein aktiver Authentifizierungsanbieter vorhanden ist"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Keine Änderungen zum Speichern",
|
||||
@@ -1245,7 +1251,7 @@
|
||||
"editSecurity": "Sicherheit bearbeiten",
|
||||
"editExpiration": "Ablauf bearbeiten",
|
||||
"clickToEnlargeQrCode": "Klicken Sie zum Vergrößern des QR-Codes",
|
||||
"downloadQrCode": "QR-Code herunterladen",
|
||||
"downloadQrCode": "QR-Code herunterladen",
|
||||
"qrCode": "QR-Code"
|
||||
},
|
||||
"shareExpiration": {
|
||||
@@ -1742,4 +1748,4 @@
|
||||
"description": "Scannen Sie diesen QR-Code, um auf den Link zuzugreifen.",
|
||||
"download": "QR-Code herunterladen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "Forgot Password",
|
||||
"description": "Enter your email address and we'll send you instructions to reset your password",
|
||||
"resetInstructions": "Reset instructions sent to your email",
|
||||
"pageTitle": "Forgot Password"
|
||||
"pageTitle": "Forgot Password",
|
||||
"passwordAuthDisabled": "Password authentication is disabled. Please contact your administrator or use an external authentication provider."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Generate Share Link",
|
||||
@@ -1131,6 +1132,10 @@
|
||||
"serverUrl": {
|
||||
"title": "Server URL",
|
||||
"description": "Base URL of the Palmr server (e.g.: https://palmr.example.com)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Password Authentication",
|
||||
"description": "Enable or disable password-based authentication"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1140,7 +1145,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Failed to load settings",
|
||||
"updateFailed": "Failed to update settings"
|
||||
"updateFailed": "Failed to update settings",
|
||||
"passwordAuthRequiresProvider": "Cannot disable password authentication without having at least one active authentication provider"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "No changes to save",
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "Recuperar contraseña",
|
||||
"description": "Introduce tu dirección de correo electrónico y te enviaremos instrucciones para restablecer tu contraseña.",
|
||||
"resetInstructions": "Instrucciones de restablecimiento enviadas a tu correo electrónico",
|
||||
"pageTitle": "Recuperar contraseña"
|
||||
"pageTitle": "Recuperar contraseña",
|
||||
"passwordAuthDisabled": "La autenticación por contraseña está deshabilitada. Por favor, contacta a tu administrador o usa un proveedor de autenticación externo."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Generar enlace de compartir",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"tls": "STARTTLS (Puerto 587)",
|
||||
"none": "Ninguno (Inseguro)"
|
||||
}
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Autenticación por Contraseña",
|
||||
"description": "Habilitar o deshabilitar la autenticación basada en contraseña"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Error al cargar la configuración",
|
||||
"updateFailed": "Error al actualizar la configuración"
|
||||
"updateFailed": "Error al actualizar la configuración",
|
||||
"passwordAuthRequiresProvider": "No se puede deshabilitar la autenticación por contraseña sin tener al menos un proveedor de autenticación activo"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "No hay cambios para guardar",
|
||||
@@ -1742,4 +1748,4 @@
|
||||
"description": "Escanea este código QR para acceder al enlace.",
|
||||
"download": "Descargar Código QR"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "Mot de Passe Oublié",
|
||||
"description": "Entrez votre adresse email et nous vous enverrons les instructions pour réinitialiser votre mot de passe.",
|
||||
"resetInstructions": "Instructions de réinitialisation envoyées à votre email",
|
||||
"pageTitle": "Mot de Passe Oublié"
|
||||
"pageTitle": "Mot de Passe Oublié",
|
||||
"passwordAuthDisabled": "L'authentification par mot de passe est désactivée. Veuillez contacter votre administrateur ou utiliser un fournisseur d'authentification externe."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Générer un lien de partage",
|
||||
@@ -1131,6 +1132,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Faire Confiance aux Certificats Auto-signés",
|
||||
"description": "Activez cette option pour faire confiance aux certificats SSL/TLS auto-signés (utile pour les environnements de développement)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Authentification par Mot de Passe",
|
||||
"description": "Activer ou désactiver l'authentification basée sur mot de passe"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1140,7 +1145,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Échec du chargement des paramètres",
|
||||
"updateFailed": "Échec de la mise à jour des paramètres"
|
||||
"updateFailed": "Échec de la mise à jour des paramètres",
|
||||
"passwordAuthRequiresProvider": "Impossible de désactiver l'authentification par mot de passe sans avoir au moins un fournisseur d'authentification actif"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Aucun changement à enregistrer",
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "पासवर्ड भूल गए",
|
||||
"description": "अपना ईमेल पता दर्ज करें और हम आपको पासवर्ड रीसेट करने के निर्देश भेजेंगे।",
|
||||
"resetInstructions": "रीसेट निर्देश आपके ईमेल पर भेज दिए गए हैं",
|
||||
"pageTitle": "पासवर्ड भूल गए"
|
||||
"pageTitle": "पासवर्ड भूल गए",
|
||||
"passwordAuthDisabled": "पासवर्ड ऑथेंटिकेशन अक्टिवेटेड है। कृपया अपने एडमिन से संपर्क करें या एक बाहरी ऑथेंटिकेशन प्रोवाइडर का उपयोग करें।"
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "साझाकरण लिंक उत्पन्न करें",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "स्व-हस्ताक्षरित प्रमाणपत्रों पर विश्वास करें",
|
||||
"description": "स्व-हस्ताक्षरित SSL/TLS प्रमाणपत्रों पर विश्वास करने के लिए इसे सक्षम करें (विकास वातावरण के लिए उपयोगी)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "पासवर्ड प्रमाणीकरण",
|
||||
"description": "पासवर्ड आधारित प्रमाणीकरण सक्षम या अक्षम करें"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "सेटिंग्स लोड करने में विफल",
|
||||
"updateFailed": "सेटिंग्स अपडेट करने में विफल"
|
||||
"updateFailed": "सेटिंग्स अपडेट करने में विफल",
|
||||
"passwordAuthRequiresProvider": "कम से कम एक सक्रिय प्रमाणीकरण प्रदाता के बिना पासवर्ड प्रमाणीकरण अक्षम नहीं किया जा सकता"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "सहेजने के लिए कोई परिवर्तन नहीं",
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "Parola d'accesso Dimenticata",
|
||||
"description": "Inserisci il tuo indirizzo email e ti invieremo le istruzioni per reimpostare la parola d'accesso.",
|
||||
"resetInstructions": "Istruzioni di reimpostazione inviate alla tua email",
|
||||
"pageTitle": "Parola d'accesso Dimenticata"
|
||||
"pageTitle": "Parola d'accesso Dimenticata",
|
||||
"passwordAuthDisabled": "L'autenticazione tramite password è disabilitata. Contatta il tuo amministratore o utilizza un provider di autenticazione esterno."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Genera link di condivisione",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Accetta Certificati Auto-Firmati",
|
||||
"description": "Abilita questa opzione per accettare certificati SSL/TLS auto-firmati (utile per ambienti di sviluppo)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Autenticazione Password",
|
||||
"description": "Abilita o disabilita l'autenticazione basata su password"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Errore durante il caricamento delle impostazioni",
|
||||
"updateFailed": "Errore durante l'aggiornamento delle impostazioni"
|
||||
"updateFailed": "Errore durante l'aggiornamento delle impostazioni",
|
||||
"passwordAuthRequiresProvider": "Impossibile disabilitare l'autenticazione password senza avere almeno un provider di autenticazione attivo"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Nessuna modifica da salvare",
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "パスワードをお忘れですか?",
|
||||
"description": "メールアドレスを入力すると、パスワードリセットの指示を送信します。",
|
||||
"resetInstructions": "パスワードリセットの指示がメールに送信されました",
|
||||
"pageTitle": "パスワードをお忘れですか?"
|
||||
"pageTitle": "パスワードをお忘れですか?",
|
||||
"passwordAuthDisabled": "パスワード認証が無効になっています。管理者に連絡するか、外部認証プロバイダーを使用してください。"
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "共有リンクを生成",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "自己署名証明書を信頼",
|
||||
"description": "自己署名SSL/TLS証明書を信頼するように設定します(開発環境で便利)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "パスワード認証",
|
||||
"description": "パスワード認証を有効または無効にする"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "設定の読み込みに失敗しました",
|
||||
"updateFailed": "設定の更新に失敗しました"
|
||||
"updateFailed": "設定の更新に失敗しました",
|
||||
"passwordAuthRequiresProvider": "少なくとも1つのアクティブな認証プロバイダーがない場合、パスワード認証を無効にできません"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "保存する変更はありません",
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "비밀번호를 잊으셨나요?",
|
||||
"description": "이메일 주소를 입력하면 비밀번호 재설정 지침을 보내드립니다.",
|
||||
"resetInstructions": "비밀번호 재설정 지침이 이메일로 전송되었습니다",
|
||||
"pageTitle": "비밀번호를 잊으셨나요?"
|
||||
"pageTitle": "비밀번호를 잊으셨나요?",
|
||||
"passwordAuthDisabled": "비밀번호 인증이 비활성화되어 있습니다. 관리자에게 문의하거나 외부 인증 공급자를 사용하세요."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "공유 링크 생성",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "자체 서명된 인증서 신뢰",
|
||||
"description": "자체 서명된 SSL/TLS 인증서를 신뢰하려면 활성화하세요 (개발 환경에서 유용)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "비밀번호 인증",
|
||||
"description": "비밀번호 기반 인증 활성화 또는 비활성화"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "설정을 불러오는데 실패했습니다",
|
||||
"updateFailed": "설정 업데이트에 실패했습니다"
|
||||
"updateFailed": "설정 업데이트에 실패했습니다",
|
||||
"passwordAuthRequiresProvider": "최소 하나의 활성 인증 제공자가 없으면 비밀번호 인증을 비활성화할 수 없습니다"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "저장할 변경 사항이 없습니다",
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "Wachtwoord Vergeten",
|
||||
"description": "Voer je e-mailadres in en we sturen je instructies om je wachtwoord te resetten.",
|
||||
"resetInstructions": "Reset instructies verzonden naar je e-mail",
|
||||
"pageTitle": "Wachtwoord Vergeten"
|
||||
"pageTitle": "Wachtwoord Vergeten",
|
||||
"passwordAuthDisabled": "Wachtwoordauthenticatie is uitgeschakeld. Neem contact op met uw beheerder of gebruik een externe authenticatieprovider."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Deel-link genereren",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Vertrouw Zelf-Ondertekende Certificaten",
|
||||
"description": "Schakel dit in om zelf-ondertekende SSL/TLS certificaten te vertrouwen (handig voor ontwikkelomgevingen)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Wachtwoord Authenticatie",
|
||||
"description": "Wachtwoord-gebaseerde authenticatie inschakelen of uitschakelen"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Fout bij het laden van instellingen",
|
||||
"updateFailed": "Fout bij het bijwerken van instellingen"
|
||||
"updateFailed": "Fout bij het bijwerken van instellingen",
|
||||
"passwordAuthRequiresProvider": "Wachtwoordauthenticatie kan niet worden uitgeschakeld zonder ten minste één actieve authenticatieprovider"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Geen wijzigingen om op te slaan",
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "Zapomniałeś hasła?",
|
||||
"description": "Wprowadź swój adres e-mail, a wyślemy Ci instrukcje resetowania hasła",
|
||||
"resetInstructions": "Instrukcje resetowania wysłane na Twój adres e-mail",
|
||||
"pageTitle": "Zapomniałeś hasła?"
|
||||
"pageTitle": "Zapomniałeś hasła?",
|
||||
"passwordAuthDisabled": "Uwierzytelnianie hasłem jest wyłączone. Skontaktuj się z administratorem lub użyj zewnętrznego dostawcy uwierzytelniania."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Generuj link do udostępniania",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Zaufaj certyfikatom samopodpisanym",
|
||||
"description": "Włącz tę opcję, aby zaufać samopodpisanym certyfikatom SSL/TLS (przydatne w środowiskach deweloperskich)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Uwierzytelnianie hasłem",
|
||||
"description": "Włącz lub wyłącz uwierzytelnianie oparte na haśle"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Nie udało się załadować ustawień",
|
||||
"updateFailed": "Nie udało się zaktualizować ustawień"
|
||||
"updateFailed": "Nie udało się zaktualizować ustawień",
|
||||
"passwordAuthRequiresProvider": "Uwierzytelnianie oparte na haśle nie może być wyłączone, jeśli nie ma co najmniej jednego aktywnego dostawcy uwierzytelniania"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Brak zmian do zapisania",
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "Esqueceu a Senha",
|
||||
"description": "Digite seu endereço de email e enviaremos instruções para redefinir sua senha.",
|
||||
"resetInstructions": "Instruções de redefinição enviadas para seu email",
|
||||
"pageTitle": "Esqueceu a Senha"
|
||||
"pageTitle": "Esqueceu a Senha",
|
||||
"passwordAuthDisabled": "A autenticação por senha está desativada. Por favor, contate seu administrador ou use um provedor de autenticação externo."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Gerar link de compartilhamento",
|
||||
@@ -1136,6 +1137,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Confiar em Certificados Auto-Assinados",
|
||||
"description": "Ative isso para confiar em certificados SSL/TLS auto-assinados (útil para ambientes de desenvolvimento)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Autenticação por Senha",
|
||||
"description": "Ative ou desative a autenticação baseada em senha"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1145,7 +1150,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Falha ao carregar configurações",
|
||||
"updateFailed": "Falha ao atualizar configurações"
|
||||
"updateFailed": "Falha ao atualizar configurações",
|
||||
"passwordAuthRequiresProvider": "Não é possível desabilitar a autenticação por senha sem ter pelo menos um provedor de autenticação ativo"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Nenhuma alteração para salvar",
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "Забыли пароль",
|
||||
"description": "Введите адрес электронной почты, и мы отправим вам инструкции по сбросу пароля.",
|
||||
"resetInstructions": "Инструкции по сбросу отправлены на вашу электронную почту",
|
||||
"pageTitle": "Забыли пароль"
|
||||
"pageTitle": "Забыли пароль",
|
||||
"passwordAuthDisabled": "Парольная аутентификация отключена. Пожалуйста, свяжитесь с администратором или используйте внешний провайдер аутентификации."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Создать ссылку для обмена",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Доверять самоподписанным сертификатам",
|
||||
"description": "Включите это для доверия самоподписанным SSL/TLS сертификатам (полезно для сред разработки)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Парольная аутентификация",
|
||||
"description": "Включить или отключить парольную аутентификацию"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Ошибка загрузки настроек",
|
||||
"updateFailed": "Ошибка обновления настроек"
|
||||
"updateFailed": "Ошибка обновления настроек",
|
||||
"passwordAuthRequiresProvider": "Парольную аутентификацию нельзя отключить, если нет хотя бы одного активного поставщика аутентификации"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Изменений для сохранения нет",
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "Şifrenizi mi Unuttunuz?",
|
||||
"description": "E-posta adresinizi girin, şifre sıfırlama talimatlarını göndereceğiz.",
|
||||
"resetInstructions": "Şifre sıfırlama talimatları e-posta adresinize gönderildi",
|
||||
"pageTitle": "Şifrenizi mi Unuttunuz?"
|
||||
"pageTitle": "Şifrenizi mi Unuttunuz?",
|
||||
"passwordAuthDisabled": "Şifre doğrulama devre dışı. Lütfen yöneticinize başvurun veya dış doğrulama sağlayıcısı kullanın."
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Paylaşım Bağlantısı Oluştur",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "Kendinden İmzalı Sertifikalara Güven",
|
||||
"description": "Kendinden imzalı SSL/TLS sertifikalarına güvenmek için bunu etkinleştirin (geliştirme ortamları için kullanışlıdır)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "Şifre Doğrulama",
|
||||
"description": "Şifre tabanlı doğrulamayı etkinleştirme veya devre dışı bırakma"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Ayarlar yüklenemedi",
|
||||
"updateFailed": "Ayarlar güncellenemedi"
|
||||
"updateFailed": "Ayarlar güncellenemedi",
|
||||
"passwordAuthRequiresProvider": "En az bir aktif kimlik doğrulama sağlayıcısı olmadan şifre doğrulaması devre dışı bırakılamaz"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "Kaydedilecek değişiklik yok",
|
||||
|
||||
@@ -313,7 +313,8 @@
|
||||
"title": "忘记密码?",
|
||||
"description": "请输入您的电子邮件,我们将发送密码重置指令给您。",
|
||||
"resetInstructions": "密码重置指令已发送到您的电子邮件",
|
||||
"pageTitle": "忘记密码?"
|
||||
"pageTitle": "忘记密码?",
|
||||
"passwordAuthDisabled": "密码认证已禁用。请联系您的管理员或使用外部认证提供商。"
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "生成分享链接",
|
||||
@@ -1128,6 +1129,10 @@
|
||||
"smtpTrustSelfSigned": {
|
||||
"title": "信任自签名证书",
|
||||
"description": "启用此选项以信任自签名SSL/TLS证书(对开发环境有用)"
|
||||
},
|
||||
"passwordAuthEnabled": {
|
||||
"title": "密码认证",
|
||||
"description": "启用或禁用基于密码的认证"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -1137,7 +1142,8 @@
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "加载设置失败",
|
||||
"updateFailed": "更新设置失败"
|
||||
"updateFailed": "更新设置失败",
|
||||
"passwordAuthRequiresProvider": "没有至少一个活动认证提供者时,无法禁用密码认证"
|
||||
},
|
||||
"messages": {
|
||||
"noChanges": "没有需要保存的更改",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-web",
|
||||
"version": "3.1.3-beta",
|
||||
"version": "3.1.6-beta",
|
||||
"description": "Frontend for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints";
|
||||
import { getSystemInfo } from "@/http/endpoints/app";
|
||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import { FILE_STATUS, UPLOAD_CONFIG, UPLOAD_PROGRESS } from "../constants";
|
||||
@@ -25,9 +26,24 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
const [uploaderEmail, setUploaderEmail] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await getSystemInfo();
|
||||
setIsS3Enabled(response.data.s3Enabled);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||
setIsS3Enabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemInfo();
|
||||
}, []);
|
||||
|
||||
const validateFileSize = useCallback(
|
||||
(file: File): string | null => {
|
||||
if (!reverseShare.maxFileSize) return null;
|
||||
@@ -139,7 +155,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
presignedUrl: string,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<void> => {
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
@@ -148,6 +164,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
file,
|
||||
url: presignedUrl,
|
||||
chunkSize,
|
||||
isS3Enabled: isS3Enabled ?? undefined,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
|
||||
31
apps/web/src/app/api/(proxy)/app/system-info/route.ts
Normal file
31
apps/web/src/app/api/(proxy)/app/system-info/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const url = `${API_BASE_URL}/app/system-info`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
32
apps/web/src/app/api/(proxy)/auth/config/route.ts
Normal file
32
apps/web/src/app/api/(proxy)/auth/config/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/auth/config`;
|
||||
|
||||
const apiRes = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
statusText: apiRes.statusText,
|
||||
});
|
||||
|
||||
apiRes.headers.forEach((value, key) => {
|
||||
res.headers.set(key, value);
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error("Error proxying auth config request:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import axios from "axios";
|
||||
@@ -8,7 +9,7 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requestPasswordReset } from "@/http/endpoints";
|
||||
import { getAuthConfig, requestPasswordReset } from "@/http/endpoints";
|
||||
|
||||
export type ForgotPasswordFormData = {
|
||||
email: string;
|
||||
@@ -17,16 +18,39 @@ export type ForgotPasswordFormData = {
|
||||
export function useForgotPassword() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const [passwordAuthEnabled, setPasswordAuthEnabled] = useState(true);
|
||||
const [authConfigLoading, setAuthConfigLoading] = useState(true);
|
||||
|
||||
const forgotPasswordSchema = z.object({
|
||||
email: z.string().email(t("validation.invalidEmail")),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAuthConfig = async () => {
|
||||
try {
|
||||
const response = await getAuthConfig();
|
||||
setPasswordAuthEnabled((response as any).data.passwordAuthEnabled);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch auth config:", error);
|
||||
setPasswordAuthEnabled(true);
|
||||
} finally {
|
||||
setAuthConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAuthConfig();
|
||||
}, []);
|
||||
|
||||
const form = useForm<ForgotPasswordFormData>({
|
||||
resolver: zodResolver(forgotPasswordSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ForgotPasswordFormData) => {
|
||||
if (!passwordAuthEnabled) {
|
||||
toast.error(t("errors.passwordAuthDisabled"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await requestPasswordReset({
|
||||
email: data.email,
|
||||
@@ -46,5 +70,7 @@ export function useForgotPassword() {
|
||||
return {
|
||||
form,
|
||||
onSubmit,
|
||||
passwordAuthEnabled,
|
||||
authConfigLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { DefaultFooter } from "@/components/ui/default-footer";
|
||||
import { StaticBackgroundLights } from "../login/components/static-background-lights";
|
||||
@@ -10,6 +12,7 @@ import { useForgotPassword } from "./hooks/use-forgot-password";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const forgotPassword = useForgotPassword();
|
||||
const t = useTranslations("ForgotPassword");
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
@@ -22,7 +25,24 @@ export default function ForgotPasswordPage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
>
|
||||
<ForgotPasswordHeader />
|
||||
<ForgotPasswordForm form={forgotPassword.form} onSubmit={forgotPassword.onSubmit} />
|
||||
{forgotPassword.authConfigLoading ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : !forgotPassword.passwordAuthEnabled ? (
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-muted-foreground">{t("forgotPassword.passwordAuthDisabled")}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Link className="text-muted-foreground hover:text-primary text-sm" href="/login">
|
||||
{t("forgotPassword.backToLogin")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ForgotPasswordForm form={forgotPassword.form} onSubmit={forgotPassword.onSubmit} />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -6,6 +7,7 @@ import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getEnabledProviders } from "@/http/endpoints";
|
||||
import { createLoginSchema, type LoginFormValues } from "../schemas/schema";
|
||||
import { MultiProviderButtons } from "./multi-provider-buttons";
|
||||
import { PasswordVisibilityToggle } from "./password-visibility-toggle";
|
||||
@@ -15,21 +17,50 @@ interface LoginFormProps {
|
||||
isVisible: boolean;
|
||||
onToggleVisibility: () => void;
|
||||
onSubmit: (data: LoginFormValues) => Promise<void>;
|
||||
passwordAuthEnabled: boolean;
|
||||
authConfigLoading: boolean;
|
||||
}
|
||||
|
||||
export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: LoginFormProps) {
|
||||
export function LoginForm({
|
||||
error,
|
||||
isVisible,
|
||||
onToggleVisibility,
|
||||
onSubmit,
|
||||
passwordAuthEnabled,
|
||||
authConfigLoading,
|
||||
}: LoginFormProps) {
|
||||
const t = useTranslations();
|
||||
const loginSchema = createLoginSchema(t);
|
||||
const [hasEnabledProviders, setHasEnabledProviders] = useState(false);
|
||||
const [providersLoading, setProvidersLoading] = useState(true);
|
||||
|
||||
const loginSchema = createLoginSchema(t, passwordAuthEnabled);
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
emailOrUsername: "",
|
||||
password: "",
|
||||
password: passwordAuthEnabled ? "" : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
useEffect(() => {
|
||||
const checkProviders = async () => {
|
||||
try {
|
||||
const response = await getEnabledProviders();
|
||||
const data = response.data as any;
|
||||
setHasEnabledProviders(data.success && data.data && data.data.length > 0);
|
||||
} catch (error) {
|
||||
console.error("Error checking providers:", error);
|
||||
setHasEnabledProviders(false);
|
||||
} finally {
|
||||
setProvidersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkProviders();
|
||||
}, []);
|
||||
|
||||
const renderErrorMessage = () =>
|
||||
error && (
|
||||
<p className="text-destructive text-sm text-center bg-destructive/10 p-2 rounded-md">
|
||||
@@ -84,13 +115,41 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
|
||||
/>
|
||||
);
|
||||
|
||||
if (authConfigLoading || providersLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!passwordAuthEnabled && hasEnabledProviders) {
|
||||
return (
|
||||
<>
|
||||
{renderErrorMessage()}
|
||||
<MultiProviderButtons showSeparator={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!passwordAuthEnabled && !hasEnabledProviders) {
|
||||
return (
|
||||
<>
|
||||
{renderErrorMessage()}
|
||||
<div className="text-center py-8">
|
||||
<p className="text-destructive text-sm">{t("login.noAuthMethodsAvailable")}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderErrorMessage()}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
{renderEmailOrUsernameField()}
|
||||
{renderPasswordField()}
|
||||
{passwordAuthEnabled && renderPasswordField()}
|
||||
<Button className="w-full mt-4 cursor-pointer" variant="default" size="lg" type="submit">
|
||||
{isSubmitting ? t("login.signingIn") : t("login.signIn")}
|
||||
</Button>
|
||||
@@ -99,11 +158,13 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
|
||||
|
||||
<MultiProviderButtons />
|
||||
|
||||
<div className="flex w-full items-center justify-center px-1 mt-2">
|
||||
<Link className="text-muted-foreground hover:text-primary text-sm" href="/forgot-password">
|
||||
{t("login.forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
{passwordAuthEnabled && (
|
||||
<div className="flex w-full items-center justify-center px-1 mt-2">
|
||||
<Link className="text-muted-foreground hover:text-primary text-sm" href="/forgot-password">
|
||||
{t("login.forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import { useAppInfo } from "@/contexts/app-info-context";
|
||||
import { getEnabledProviders } from "@/http/endpoints";
|
||||
import type { EnabledAuthProvider } from "@/http/endpoints/auth/types";
|
||||
|
||||
export function MultiProviderButtons() {
|
||||
interface MultiProviderButtonsProps {
|
||||
showSeparator?: boolean;
|
||||
}
|
||||
|
||||
export function MultiProviderButtons({ showSeparator = true }: MultiProviderButtonsProps) {
|
||||
const [providers, setProviders] = useState<EnabledAuthProvider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { firstAccess } = useAppInfo();
|
||||
@@ -67,14 +71,16 @@ export function MultiProviderButtons() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
{showSeparator && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => (
|
||||
|
||||
@@ -8,7 +8,7 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { getCurrentUser, login } from "@/http/endpoints";
|
||||
import { getAuthConfig, getCurrentUser, login } from "@/http/endpoints";
|
||||
import { completeTwoFactorLogin } from "@/http/endpoints/auth/two-factor";
|
||||
import type { LoginResponse } from "@/http/endpoints/auth/two-factor/types";
|
||||
import { LoginFormValues } from "../schemas/schema";
|
||||
@@ -31,6 +31,8 @@ export function useLogin() {
|
||||
const [twoFactorUserId, setTwoFactorUserId] = useState<string | null>(null);
|
||||
const [twoFactorCode, setTwoFactorCode] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [passwordAuthEnabled, setPasswordAuthEnabled] = useState(true);
|
||||
const [authConfigLoading, setAuthConfigLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const errorParam = searchParams.get("error");
|
||||
@@ -60,6 +62,22 @@ export function useLogin() {
|
||||
}
|
||||
}, [searchParams, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAuthConfig = async () => {
|
||||
try {
|
||||
const response = await getAuthConfig();
|
||||
setPasswordAuthEnabled((response as any).data.passwordAuthEnabled);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch auth config:", error);
|
||||
setPasswordAuthEnabled(true);
|
||||
} finally {
|
||||
setAuthConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAuthConfig();
|
||||
}, []);
|
||||
|
||||
const toggleVisibility = () => setIsVisible(!isVisible);
|
||||
|
||||
const onSubmit = async (data: LoginFormValues) => {
|
||||
@@ -67,7 +85,12 @@ export function useLogin() {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await login(data);
|
||||
if (!passwordAuthEnabled) {
|
||||
setError(t("errors.passwordAuthDisabled"));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await login(data as any);
|
||||
const loginData = response.data as LoginResponse;
|
||||
|
||||
if (loginData.requiresTwoFactor && loginData.userId) {
|
||||
@@ -77,7 +100,6 @@ export function useLogin() {
|
||||
}
|
||||
|
||||
if (loginData.user) {
|
||||
// Após login bem-sucedido, buscar dados completos do usuário incluindo a imagem
|
||||
try {
|
||||
const userResponse = await getCurrentUser();
|
||||
if (userResponse?.data?.user) {
|
||||
@@ -92,7 +114,6 @@ export function useLogin() {
|
||||
console.warn("Failed to fetch complete user data, using login data:", userErr);
|
||||
}
|
||||
|
||||
// Fallback para dados do login se falhar ao buscar dados completos
|
||||
const { isAdmin, ...userData } = loginData.user;
|
||||
setUser({ ...userData, image: null });
|
||||
setIsAdmin(isAdmin);
|
||||
@@ -129,7 +150,6 @@ export function useLogin() {
|
||||
rememberDevice: rememberDevice,
|
||||
});
|
||||
|
||||
// Após two-factor login bem-sucedido, buscar dados completos do usuário incluindo a imagem
|
||||
try {
|
||||
const userResponse = await getCurrentUser();
|
||||
if (userResponse?.data?.user) {
|
||||
@@ -144,7 +164,6 @@ export function useLogin() {
|
||||
console.warn("Failed to fetch complete user data after 2FA, using response data:", userErr);
|
||||
}
|
||||
|
||||
// Fallback para dados da resposta se falhar ao buscar dados completos
|
||||
const { isAdmin, ...userData } = response.data.user;
|
||||
setUser({ ...userData, image: userData.image ?? null });
|
||||
setIsAdmin(isAdmin);
|
||||
@@ -172,5 +191,7 @@ export function useLogin() {
|
||||
setTwoFactorCode,
|
||||
onTwoFactorSubmit,
|
||||
isSubmitting,
|
||||
passwordAuthEnabled,
|
||||
authConfigLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ export default function LoginPage() {
|
||||
isVisible={login.isVisible}
|
||||
onSubmit={login.onSubmit}
|
||||
onToggleVisibility={login.toggleVisibility}
|
||||
passwordAuthEnabled={login.passwordAuthEnabled}
|
||||
authConfigLoading={login.authConfigLoading}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
@@ -3,10 +3,10 @@ import * as z from "zod";
|
||||
|
||||
type TFunction = ReturnType<typeof useTranslations>;
|
||||
|
||||
export const createLoginSchema = (t: TFunction) =>
|
||||
export const createLoginSchema = (t: TFunction, passwordAuthEnabled: boolean = true) =>
|
||||
z.object({
|
||||
emailOrUsername: z.string().min(1, t("validation.emailOrUsernameRequired")),
|
||||
password: z.string().min(1, t("validation.passwordRequired")),
|
||||
password: passwordAuthEnabled ? z.string().min(1, t("validation.passwordRequired")) : z.string().optional(),
|
||||
});
|
||||
|
||||
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
|
||||
|
||||
@@ -172,8 +172,19 @@ export function useSettings() {
|
||||
}
|
||||
|
||||
await refreshAppInfo();
|
||||
} catch {
|
||||
toast.error(t("settings.errors.updateFailed"));
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.response?.data?.error || error?.message || "";
|
||||
|
||||
if (
|
||||
errorMessage.includes("autenticação por senha") ||
|
||||
errorMessage.includes("provedor de autenticação ativo") ||
|
||||
errorMessage.includes("password authentication") ||
|
||||
errorMessage.includes("authentication provider")
|
||||
) {
|
||||
toast.error(t("settings.errors.passwordAuthRequiresProvider"));
|
||||
} else {
|
||||
toast.error(t("settings.errors.updateFailed"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
|
||||
import { getSystemInfo } from "@/http/endpoints/app";
|
||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
import { generateSafeFileName } from "@/utils/file-utils";
|
||||
@@ -43,6 +44,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [fileUploads, setFileUploads] = useState<FileUpload[]>([]);
|
||||
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
|
||||
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||
|
||||
const generateFileId = useCallback(() => {
|
||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||
@@ -124,7 +126,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
const abortController = new AbortController();
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
||||
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
@@ -134,6 +136,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
url,
|
||||
chunkSize,
|
||||
signal: abortController.signal,
|
||||
isS3Enabled: isS3Enabled ?? undefined,
|
||||
onProgress: (progress) => {
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
|
||||
},
|
||||
@@ -196,7 +199,7 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
[t, isS3Enabled]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -256,6 +259,20 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
|
||||
[uploadFile, t, createFileUpload]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await getSystemInfo();
|
||||
setIsS3Enabled(response.data.s3Enabled);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||
setIsS3Enabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemInfo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
document.addEventListener("dragleave", handleDragLeave);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
|
||||
import { getSystemInfo } from "@/http/endpoints/app";
|
||||
import { ChunkedUploader } from "@/utils/chunked-upload";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
import { generateSafeFileName } from "@/utils/file-utils";
|
||||
@@ -87,8 +88,23 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [hasShownSuccessToast, setHasShownSuccessToast] = useState(false);
|
||||
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await getSystemInfo();
|
||||
setIsS3Enabled(response.data.s3Enabled);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
|
||||
setIsS3Enabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemInfo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
fileUploads.forEach((upload) => {
|
||||
@@ -252,7 +268,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
const abortController = new AbortController();
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
||||
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
|
||||
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
|
||||
|
||||
if (shouldUseChunked) {
|
||||
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
|
||||
@@ -262,6 +278,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
url,
|
||||
chunkSize,
|
||||
signal: abortController.signal,
|
||||
isS3Enabled: isS3Enabled ?? undefined,
|
||||
onProgress: (progress) => {
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, progress } : u)));
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CheckUploadAllowedResult,
|
||||
GetAppInfoResult,
|
||||
GetDiskSpaceResult,
|
||||
GetSystemInfoResult,
|
||||
RemoveLogoResult,
|
||||
UploadLogoBody,
|
||||
UploadLogoResult,
|
||||
@@ -20,6 +21,14 @@ export const getAppInfo = <TData = GetAppInfoResult>(options?: AxiosRequestConfi
|
||||
return apiInstance.get(`/api/app/info`, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get system information including storage provider
|
||||
* @summary Get system information
|
||||
*/
|
||||
export const getSystemInfo = <TData = GetSystemInfoResult>(options?: AxiosRequestConfig): Promise<TData> => {
|
||||
return apiInstance.get(`/api/app/system-info`, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a new app logo (admin only)
|
||||
* @summary Upload app logo
|
||||
|
||||
@@ -32,6 +32,11 @@ export interface GetAppInfo200 {
|
||||
firstUserAccess: boolean;
|
||||
}
|
||||
|
||||
export interface GetSystemInfo200 {
|
||||
storageProvider: "s3" | "filesystem";
|
||||
s3Enabled: boolean;
|
||||
}
|
||||
|
||||
export interface RemoveLogo200 {
|
||||
message: string;
|
||||
}
|
||||
@@ -49,6 +54,7 @@ export interface UploadLogoBody {
|
||||
}
|
||||
|
||||
export type GetAppInfoResult = AxiosResponse<GetAppInfo200>;
|
||||
export type GetSystemInfoResult = AxiosResponse<GetSystemInfo200>;
|
||||
export type UploadLogoResult = AxiosResponse<UploadLogo200>;
|
||||
export type RemoveLogoResult = AxiosResponse<RemoveLogo200>;
|
||||
export type CheckHealthResult = AxiosResponse<CheckHealth200>;
|
||||
|
||||
@@ -99,3 +99,9 @@ export const updateProvidersOrder = <TData = UpdateProvidersOrderResult>(
|
||||
): Promise<TData> => {
|
||||
return apiInstance.put(`/api/auth/providers/order`, updateProvidersOrderBody, options);
|
||||
};
|
||||
|
||||
export const getAuthConfig = <TData = { passwordAuthEnabled: boolean }>(
|
||||
options?: AxiosRequestConfig
|
||||
): Promise<TData> => {
|
||||
return apiInstance.get(`/api/auth/config`, options);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ChunkedUploadOptions {
|
||||
onProgress?: (progress: number) => void;
|
||||
onChunkComplete?: (chunkIndex: number, totalChunks: number) => void;
|
||||
signal?: AbortSignal;
|
||||
isS3Enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ChunkedUploadResult {
|
||||
@@ -23,7 +24,7 @@ export class ChunkedUploader {
|
||||
static async uploadFile(options: ChunkedUploadOptions): Promise<ChunkedUploadResult> {
|
||||
const { file, url, chunkSize, onProgress, onChunkComplete, signal } = options;
|
||||
|
||||
if (!this.shouldUseChunkedUpload(file.size)) {
|
||||
if (!this.shouldUseChunkedUpload(file.size, options.isS3Enabled)) {
|
||||
throw new Error(
|
||||
`File ${file.name} (${(file.size / (1024 * 1024)).toFixed(2)}MB) should not use chunked upload. Use regular upload instead.`
|
||||
);
|
||||
@@ -238,8 +239,13 @@ export class ChunkedUploader {
|
||||
|
||||
/**
|
||||
* Check if file should use chunked upload
|
||||
* Only use chunked upload for filesystem storage, not for S3
|
||||
*/
|
||||
static shouldUseChunkedUpload(fileSize: number): boolean {
|
||||
static shouldUseChunkedUpload(fileSize: number, isS3Enabled?: boolean): boolean {
|
||||
if (isS3Enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const threshold = 100 * 1024 * 1024; // 100MB
|
||||
const shouldUse = fileSize > threshold;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
147
infra/check-missing.js
Normal 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
37
infra/configs.json
Normal 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
11
infra/providers.json
Normal file
@@ -0,0 +1,11 @@
|
||||
[
|
||||
"google",
|
||||
"discord",
|
||||
"github",
|
||||
"auth0",
|
||||
"kinde",
|
||||
"zitadel",
|
||||
"authentik",
|
||||
"frontegg",
|
||||
"pocketid"
|
||||
]
|
||||
@@ -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
|
||||
@@ -31,6 +31,7 @@ update_package_json() {
|
||||
update_package_json "apps/web/package.json" "Web App"
|
||||
update_package_json "apps/docs/package.json" "Documentation"
|
||||
update_package_json "apps/server/package.json" "API Server"
|
||||
update_package_json "./package.json" "Monorepo"
|
||||
|
||||
echo "🎉 Version update completed!"
|
||||
echo "📦 All package.json files now have version: $VERSION"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-monorepo",
|
||||
"version": "3.1-beta",
|
||||
"version": "3.1.6-beta",
|
||||
"description": "Palmr monorepo with Husky configuration",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
|
||||
Reference in New Issue
Block a user