mirror of
https://github.com/kyantech/Palmr.git
synced 2025-11-01 20:43:39 +00:00
Compare commits
51 Commits
v3.0.0-bet
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c265b8e08d | ||
|
|
d0173a0bf9 | ||
|
|
0d346b75cc | ||
|
|
0a65917cbf | ||
|
|
f651f50180 | ||
|
|
1125665bb1 | ||
|
|
b65aac3044 | ||
|
|
a865aabed0 | ||
|
|
561e8faf33 | ||
|
|
6445b0ce3e | ||
|
|
90cd3333cb | ||
|
|
2ca0db70c3 | ||
|
|
28697fa270 | ||
|
|
d739c1b213 | ||
|
|
25a0c39135 | ||
|
|
185fa4c191 | ||
|
|
9dfb034c2e | ||
|
|
936a2b71c7 | ||
|
|
cd14c28be1 | ||
|
|
3c084a6686 | ||
|
|
6a1381684b | ||
|
|
dc20770fe6 | ||
|
|
6e526f7f88 | ||
|
|
858852c8cd | ||
|
|
363dedbb2c | ||
|
|
cd215c79b8 | ||
|
|
98586efbcd | ||
|
|
c724e644c7 | ||
|
|
555ff18a87 | ||
|
|
5100e1591b | ||
|
|
6de29bbf07 | ||
|
|
39c47be940 | ||
|
|
76d96816bc | ||
|
|
b3e7658a76 | ||
|
|
61a579aeb3 | ||
|
|
cc9c375774 | ||
|
|
016006ba3d | ||
|
|
cbc567c6a8 | ||
|
|
25b4d886f7 | ||
|
|
98953e042b | ||
|
|
9e06a67593 | ||
|
|
9682f96905 | ||
|
|
d2c69c3b36 | ||
|
|
9afe8292fa | ||
|
|
e15f50a8a8 | ||
|
|
8affdc8f95 | ||
|
|
281eff0f14 | ||
|
|
b28f1f97c4 | ||
|
|
c5660b3c6b | ||
|
|
e64f718998 | ||
|
|
f00a9dadd0 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,4 +29,5 @@ apps/server/.env
|
||||
apps/server/dist/*
|
||||
|
||||
#DEFAULT
|
||||
.env
|
||||
.env
|
||||
data/
|
||||
120
Dockerfile
120
Dockerfile
@@ -1,10 +1,11 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install system dependencies (removed netcat-openbsd since we no longer need to wait for PostgreSQL)
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
gcompat \
|
||||
supervisor \
|
||||
curl
|
||||
curl \
|
||||
su-exec
|
||||
|
||||
# Enable pnpm
|
||||
RUN corepack enable pnpm
|
||||
@@ -80,15 +81,12 @@ ARG PALMR_GID=1001
|
||||
RUN addgroup --system --gid ${PALMR_GID} nodejs
|
||||
RUN adduser --system --uid ${PALMR_UID} --ingroup nodejs palmr
|
||||
|
||||
# Create application directories and set permissions
|
||||
# Include storage directories for filesystem mode and SQLite database directory
|
||||
RUN mkdir -p /app/server /app/web /home/palmr/.npm /home/palmr/.cache \
|
||||
/app/server/uploads /app/server/temp-chunks /app/server/uploads/logo \
|
||||
/app/server/prisma
|
||||
# Create application directories
|
||||
RUN mkdir -p /app/palmr-app /app/web /home/palmr/.npm /home/palmr/.cache
|
||||
RUN chown -R palmr:nodejs /app /home/palmr
|
||||
|
||||
# === Copy Server Files ===
|
||||
WORKDIR /app/server
|
||||
# === Copy Server Files to /app/palmr-app (separate from /app/server for bind mounts) ===
|
||||
WORKDIR /app/palmr-app
|
||||
|
||||
# Copy server production files
|
||||
COPY --from=server-builder --chown=palmr:nodejs /app/server/dist ./dist
|
||||
@@ -101,8 +99,9 @@ COPY --from=server-builder --chown=palmr:nodejs /app/server/reset-password.sh ./
|
||||
COPY --from=server-builder --chown=palmr:nodejs /app/server/src/scripts/ ./src/scripts/
|
||||
RUN chmod +x ./reset-password.sh
|
||||
|
||||
# Ensure storage directories have correct permissions
|
||||
RUN chown -R palmr:nodejs /app/server/uploads /app/server/temp-chunks /app/server/prisma
|
||||
# Copy seed file to the shared location for bind mounts
|
||||
RUN mkdir -p /app/server/prisma
|
||||
COPY --from=server-builder --chown=palmr:nodejs /app/server/prisma/seed.js /app/server/prisma/seed.js
|
||||
|
||||
# === Copy Web Files ===
|
||||
WORKDIR /app/web
|
||||
@@ -116,104 +115,35 @@ COPY --from=web-builder --chown=palmr:nodejs /app/web/.next/static ./.next/stati
|
||||
WORKDIR /app
|
||||
|
||||
# Create supervisor configuration
|
||||
RUN mkdir -p /etc/supervisor/conf.d /var/log/supervisor
|
||||
RUN mkdir -p /etc/supervisor/conf.d
|
||||
|
||||
# Copy server start script
|
||||
COPY infra/server-start.sh /app/server-start.sh
|
||||
RUN chmod +x /app/server-start.sh
|
||||
RUN chown palmr:nodejs /app/server-start.sh
|
||||
|
||||
# Copy supervisor configuration (simplified without PostgreSQL dependency)
|
||||
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
# Copy supervisor configuration
|
||||
COPY infra/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
[program:server]
|
||||
command=/app/server-start.sh
|
||||
directory=/app/server
|
||||
user=palmr
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stderr_logfile=/var/log/supervisor/server.err.log
|
||||
stdout_logfile=/var/log/supervisor/server.out.log
|
||||
environment=PORT=3333,HOME="/home/palmr"
|
||||
priority=100
|
||||
|
||||
[program:web]
|
||||
command=/bin/sh -c 'echo "Waiting for API to be ready..."; while ! curl -f http://127.0.0.1:3333/health >/dev/null 2>&1; do echo "API not ready, waiting..."; sleep 2; done; echo "API is ready! Starting frontend..."; exec node server.js'
|
||||
directory=/app/web
|
||||
user=palmr
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stderr_logfile=/var/log/supervisor/web.err.log
|
||||
stdout_logfile=/var/log/supervisor/web.out.log
|
||||
environment=PORT=5487,HOSTNAME="0.0.0.0",HOME="/home/palmr",API_BASE_URL="http://127.0.0.1:3333"
|
||||
priority=200
|
||||
startsecs=10
|
||||
EOF
|
||||
|
||||
# Create main startup script with UID/GID runtime support
|
||||
# Create main startup script
|
||||
COPY <<EOF /app/start.sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Starting Palmr Application..."
|
||||
echo "Storage Mode: \${ENABLE_S3:-false}"
|
||||
echo "Secure Site: \${SECURE_SITE:-false}"
|
||||
echo "Database: SQLite"
|
||||
|
||||
# Runtime UID/GID configuration - only apply if environment variables are set
|
||||
if [ -n "\${PALMR_UID}" ] || [ -n "\${PALMR_GID}" ]; then
|
||||
RUNTIME_UID=\${PALMR_UID:-${PALMR_UID}}
|
||||
RUNTIME_GID=\${PALMR_GID:-${PALMR_GID}}
|
||||
|
||||
echo "Runtime UID/GID configuration detected: UID=\$RUNTIME_UID, GID=\$RUNTIME_GID"
|
||||
|
||||
# Get current user/group IDs
|
||||
CURRENT_UID=\$(id -u palmr 2>/dev/null || echo "${PALMR_UID}")
|
||||
CURRENT_GID=\$(id -g palmr 2>/dev/null || echo "${PALMR_GID}")
|
||||
|
||||
# Only modify if different from current
|
||||
if [ "\$CURRENT_UID" != "\$RUNTIME_UID" ] || [ "\$CURRENT_GID" != "\$RUNTIME_GID" ]; then
|
||||
echo "Adjusting user/group IDs from \$CURRENT_UID:\$CURRENT_GID to \$RUNTIME_UID:\$RUNTIME_GID"
|
||||
|
||||
# Modify group if needed
|
||||
if [ "\$CURRENT_GID" != "\$RUNTIME_GID" ]; then
|
||||
if getent group \$RUNTIME_GID >/dev/null 2>&1; then
|
||||
EXISTING_GROUP=\$(getent group \$RUNTIME_GID | cut -d: -f1)
|
||||
echo "Using existing group with GID \$RUNTIME_GID: \$EXISTING_GROUP"
|
||||
usermod -g \$EXISTING_GROUP palmr 2>/dev/null || echo "Warning: Could not change user group"
|
||||
else
|
||||
groupmod -g \$RUNTIME_GID nodejs 2>/dev/null || echo "Warning: Could not modify group GID"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Modify user if needed
|
||||
if [ "\$CURRENT_UID" != "\$RUNTIME_UID" ]; then
|
||||
if getent passwd \$RUNTIME_UID >/dev/null 2>&1; then
|
||||
EXISTING_USER=\$(getent passwd \$RUNTIME_UID | cut -d: -f1)
|
||||
echo "Warning: UID \$RUNTIME_UID already exists as user '\$EXISTING_USER'"
|
||||
echo "Container will continue but may have permission issues"
|
||||
else
|
||||
usermod -u \$RUNTIME_UID palmr 2>/dev/null || echo "Warning: Could not modify user UID"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Update file ownership for application directories
|
||||
echo "Updating file ownership for application directories..."
|
||||
chown -R palmr:nodejs /app /home/palmr 2>/dev/null || echo "Warning: Could not update all file ownership"
|
||||
else
|
||||
echo "Runtime UID/GID matches current values, no changes needed"
|
||||
fi
|
||||
else
|
||||
echo "No runtime UID/GID configuration provided, using defaults"
|
||||
fi
|
||||
# Set global environment variables
|
||||
export DATABASE_URL="file:/app/server/prisma/palmr.db"
|
||||
export UPLOAD_PATH="/app/server/uploads"
|
||||
export TEMP_CHUNKS_PATH="/app/server/temp-chunks"
|
||||
|
||||
# Ensure storage directories exist with correct permissions
|
||||
# Ensure /app/server directory exists for bind mounts
|
||||
mkdir -p /app/server/uploads /app/server/temp-chunks /app/server/uploads/logo /app/server/prisma
|
||||
chown -R palmr:nodejs /app/server/uploads /app/server/temp-chunks /app/server/prisma 2>/dev/null || echo "Warning: Could not set permissions on storage directories"
|
||||
|
||||
echo "Data directories ready for first run..."
|
||||
|
||||
# Start supervisor
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
@@ -221,8 +151,8 @@ EOF
|
||||
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
# Create volume mount points for persistent storage (filesystem mode and SQLite database)
|
||||
VOLUME ["/app/server/uploads", "/app/server/temp-chunks", "/app/server/prisma"]
|
||||
# Create volume mount points for bind mounts
|
||||
VOLUME ["/app/server"]
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 3333 5487
|
||||
@@ -232,4 +162,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:5487 || exit 1
|
||||
|
||||
# Start application
|
||||
CMD ["/app/start.sh"]
|
||||
CMD ["/app/start.sh"]
|
||||
@@ -15,6 +15,7 @@
|
||||
"configuring-smtp",
|
||||
"available-languages",
|
||||
"uid-gid-configuration",
|
||||
"reverse-proxy-configuration",
|
||||
"password-reset-without-smtp",
|
||||
"oidc-authentication",
|
||||
"---Developers---",
|
||||
|
||||
@@ -137,6 +137,22 @@ The setup process varies depending on your chosen identity provider. Here are ex
|
||||
|
||||

|
||||
|
||||
### Zitadel
|
||||
1. **Create New ProjectApp**: In your desired Zitadel project, create a new application
|
||||
2. **Name and Type**: Give your application a name and choose **WEB** as the application type
|
||||
3. **Authentication Method**: Choose Code
|
||||
4. **Set Redirect URI**: Add your Palmr callback URL to valid redirect URIs
|
||||
5. **Finish**: After reviewing the configuration create the application
|
||||
6. **Copy the client ID and client Secrat**: Copy the client id paste it into the **Client ID** of your Palmr OIDC condiguration Form, repeat for the client secret and paste it into the **Client Secret** field
|
||||
7. **Obtain your Provider URL**: In your Zitadel application go to **URLs** and copy the **Authorization Endpoint (remove the /authorize from that url)** e.g. https://auth.example.com/oauth/v2
|
||||
|
||||
**Configuration values:**
|
||||
|
||||
- **Issuer URL**: Depends on your Zitadel installation and project. Example: `https://auth.example.com/oauth/v2`
|
||||
- **Scope**: `openid profile email`
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Testing OIDC configuration
|
||||
|
||||
@@ -3,32 +3,50 @@ title: Quick Start (Docker)
|
||||
icon: "Rocket"
|
||||
---
|
||||
|
||||
Hey there! Welcome to the fastest way to launch <span className="font-bold">Palmr.</span>, your very own secure <span className="font-bold italic">file sharing solution</span>. Whether you're a first-timer to <span className="font-bold italic">self-hosting</span> or a tech wizard, we've made this process incredibly straightforward. In just a few minutes, you'll have a sleek, user-friendly <span className="font-bold italic">file sharing platform</span> running on your <span className="font-bold italic">server</span> or <span className="font-bold italic">VPS</span>.
|
||||
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.
|
||||
|
||||
This guide is all about speed and simplicity, using our built-in <span className="font-bold italic">file storage system</span> ideal for most users. While Palmr. supports advanced setups like <span className="font-bold italic">manual installation</span> or <span className="font-bold italic">Amazon S3-compatible external storage</span>, we're focusing on the easiest path with <span className="font-bold italic">Docker Compose</span>. Curious about other options? Check out the dedicated sections in our docs for those advanced configurations.
|
||||
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.
|
||||
|
||||
Let's dive in and get Palmr. up and running!
|
||||
## Prerequisites
|
||||
|
||||
## What you'll need
|
||||
Ensure you have the following installed on your system:
|
||||
|
||||
To get started, you only need two tools installed on your system. Don't worry, they're easy to set up:
|
||||
- **Docker** - Container runtime ([installation guide](https://docs.docker.com/get-docker/))
|
||||
- **Docker Compose** - Multi-container orchestration ([installation guide](https://docs.docker.com/compose/install/))
|
||||
|
||||
- **Docker** ([https://docs.docker.com](https://docs.docker.com/)) - This will run Palmr. in a container.
|
||||
- **Docker Compose** ([https://docs.docker.com/compose](https://docs.docker.com/compose/)) - This helps manage the setup with a simple configuration file.
|
||||
> **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).
|
||||
|
||||
> **Note**: Palmr. was developed on **MacOS** and thoroughly tested on **Linux servers**, ensuring top-notch performance on these platforms. We haven't tested on **Windows** or other environments yet, so there might be some hiccups. Since we're still in **beta**, bugs can pop up anywhere. If you spot an issue, we'd love your help please report it on our GitHub [issues page](https://github.com/kyantech/Palmr/issues).
|
||||
## Storage Options
|
||||
|
||||
## Setting up with Docker Compose
|
||||
Palmr. supports two storage approaches for persistent data:
|
||||
|
||||
Docker Compose is the simplest way to deploy Palmr. across different environments. Once you've got Docker and Docker Compose installed, you're ready to roll with our streamlined setup.
|
||||
### Named Volumes (Recommended)
|
||||
|
||||
In the root folder of the Palmr. project, you'll find a few compose files. For this guide, we're using `docker-compose.yaml` the only file you need to run Palmr. with file system storage. No need to build anything yourself; our pre-built images are hosted on [DockerHub](https://hub.docker.com/repositories/kyantech) and referenced in this file.
|
||||
**Best for**: Production environments, automated deployments
|
||||
|
||||
You can tweak settings directly in `docker-compose.yaml` or use environment variables (more on that later). Let's take a closer look at what's inside this file.
|
||||
- ✅ **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
|
||||
|
||||
## Exploring the docker-compose.yaml file
|
||||
### Bind Mounts
|
||||
|
||||
Here's the full content of our `docker-compose.yaml`. Feel free to copy it from here or grab it from our official repository ([Docker Compose](https://github.com/kyantech/Palmr/blob/main/docker-compose.yaml)).
|
||||
**Best for**: Development, direct file access requirements
|
||||
|
||||
- ✅ **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**: May require user/group configuration
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Named Volumes (Recommended)
|
||||
|
||||
Named volumes provide the best performance and are managed entirely by Docker.
|
||||
|
||||
### Configuration
|
||||
|
||||
Use the provided `docker-compose.yaml` for named volumes:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -38,104 +56,118 @@ services:
|
||||
environment:
|
||||
- ENABLE_S3=false
|
||||
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
|
||||
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||
ports:
|
||||
- "5487:5487" # Web port
|
||||
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY, IF YOU DONT WANT TO EXPOSE THE API, JUST REMOVE THIS LINE )
|
||||
- "5487:5487" # Web interface
|
||||
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
|
||||
volumes:
|
||||
- palmr_data:/app/server # Volume for the application data
|
||||
- palmr_data:/app/server # Named volume for the application data
|
||||
restart: unless-stopped # Restart the container unless it is stopped
|
||||
|
||||
volumes:
|
||||
palmr_data:
|
||||
```
|
||||
|
||||
We've added helpful comments in the file to guide you through customization. Let's break down what you can adjust to fit your setup.
|
||||
|
||||
---
|
||||
|
||||
### Understanding the services
|
||||
|
||||
Palmr. runs as a single service in this filesystem storage setup. Here's a quick overview:
|
||||
|
||||
| **Service** | **Image** | **Exposed Ports** | **Main Features** |
|
||||
| ----------- | ---------------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| palmr | [kyantech/palmr:latest](https://hub.docker.com/repository/docker/kyantech/palmr/general) | **3333** (API)<br/>**5487** (Web) | • Combined backend API and frontend service<br/>• Uses local filesystem storage<br/>• Has healthcheck to ensure availability |
|
||||
|
||||
---
|
||||
|
||||
### Customizing with environment variables
|
||||
|
||||
You can fine-tune Palmr. using environment variables. Here's what's available for the filesystem storage setup:
|
||||
|
||||
| **Variable** | **Default Value** | **Description** |
|
||||
| ---------------- | ------------------------------------------ | ------------------------------------------------------------ |
|
||||
| `ENABLE_S3` | false | Set to 'false' for filesystem storage or 'true' for S3/MinIO |
|
||||
| `ENCRYPTION_KEY` | change-this-key-in-production-min-32-chars | Required for filesystem encryption (minimum 32 characters) |
|
||||
|
||||
> **Important**: These variables can be set in a `.env` file at the project root or directly in your environment when running Docker Compose. The `ENCRYPTION_KEY` is crucial for securing your filesystem storage **always change it** to a unique, secure value in production. you can generate a secure key using the [KeyGenerator](/docs/3.0-beta/quick-start#generating-a-secure-encryption-key) tool.
|
||||
|
||||
---
|
||||
|
||||
### Managing persistent data
|
||||
|
||||
To ensure your data sticks around even if the container restarts, we use a persistent volume:
|
||||
|
||||
| **Volume** | **Description** |
|
||||
| ------------ | ------------------------------------- |
|
||||
| `palmr_data` | Stores all the data of Palmr. service |
|
||||
|
||||
---
|
||||
|
||||
## Launching Palmr.
|
||||
|
||||
With your `docker-compose.yaml` ready, it's time to start Palmr.! Run this command to launch everything in the background:
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This runs Palmr. in **detached mode**, meaning it operates silently in the background without flooding your terminal with logs.
|
||||
|
||||
Now, open your browser and visit:
|
||||
|
||||
```bash
|
||||
http://localhost:5487
|
||||
```
|
||||
|
||||
If you're on a server, replace `localhost` with your server's IP:
|
||||
|
||||
```bash
|
||||
http://[YOUR_SERVER_IP]:5487
|
||||
```
|
||||
|
||||
For example, if your server IP is `192.168.1.10`, the URL would be `http://192.168.1.10:5487`. Remember, this is just an example use your actual server IP.
|
||||
|
||||
> **Pro Tip**: For full functionality and security, configure your server with **HTTPS** by setting up a valid SSL certificate.
|
||||
|
||||
---
|
||||
|
||||
## Keeping Palmr. up to date
|
||||
## Option 2: Bind Mounts
|
||||
|
||||
Want the latest features and fixes? Updating Palmr. is a breeze. Run these commands to pull the newest version from DockerHub and restart the service:
|
||||
Bind mounts store data in a local directory, providing direct file system access.
|
||||
|
||||
### Configuration
|
||||
|
||||
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):
|
||||
|
||||
```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
|
||||
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
|
||||
# Optional: Set custom UID/GID for file permissions
|
||||
# - PALMR_UID=1000
|
||||
# - PALMR_GID=1000
|
||||
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
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
That's it! You're now running the latest version of Palmr.
|
||||
> **Permission Configuration**: If you encounter permission issues with bind mounts (common on NAS systems), see our [UID/GID Configuration](/docs/3.0-beta/uid-gid-configuration) guide for automatic permission handling.
|
||||
|
||||
---
|
||||
|
||||
## Running with Docker (without Compose)
|
||||
## Environment Variables
|
||||
|
||||
Prefer to skip Docker Compose and use plain Docker? No problem! Use this command to start Palmr. directly:
|
||||
Configure Palmr. behavior through environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ---------------- | ------- | ------------------------------------------------------- |
|
||||
| `ENABLE_S3` | `false` | Enable S3-compatible storage |
|
||||
| `ENCRYPTION_KEY` | - | **Required**: Minimum 32 characters for file encryption |
|
||||
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy setups |
|
||||
|
||||
> **⚠️ Security Warning**: Always change the `ENCRYPTION_KEY` in production. This key encrypts your files - losing it makes files permanently inaccessible.
|
||||
|
||||
> **🔗 Reverse Proxy**: If deploying behind a reverse proxy (Traefik, Nginx, etc.), set `SECURE_SITE=true` and review our [Reverse Proxy Configuration](/docs/3.0-beta/reverse-proxy-configuration) guide for proper setup.
|
||||
|
||||
### Generate Secure Encryption Keys
|
||||
|
||||
Need a strong key for `ENCRYPTION_KEY`? Use our built-in generator to create cryptographically secure keys:
|
||||
|
||||
<KeyGenerator />
|
||||
|
||||
---
|
||||
|
||||
## Accessing Palmr.
|
||||
|
||||
Once deployed, access Palmr. through your web browser:
|
||||
|
||||
- **Local**: `http://localhost:5487`
|
||||
- **Server**: `http://YOUR_SERVER_IP:5487`
|
||||
|
||||
### 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.0-beta/api) guide.
|
||||
|
||||
> **💡 Production Tip**: For production deployments, configure HTTPS with a valid SSL certificate for enhanced security.
|
||||
|
||||
---
|
||||
|
||||
## Docker CLI Alternative
|
||||
|
||||
Prefer using Docker directly? Both storage options are supported:
|
||||
|
||||
**Named Volume:**
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name palmr \
|
||||
-e ENABLE_S3=false \
|
||||
-e ENCRYPTION_KEY=change-this-key-in-production-min-32-chars \
|
||||
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
|
||||
-p 5487:5487 \
|
||||
-p 3333:3333 \
|
||||
-v palmr_data:/app/server \
|
||||
@@ -143,22 +175,84 @@ docker run -d \
|
||||
kyantech/palmr:latest
|
||||
```
|
||||
|
||||
This also runs in detached mode, so it's hands-off. Access Palmr. at the same URLs mentioned earlier.
|
||||
**Bind Mount:**
|
||||
|
||||
> **Critical Reminder**: Whichever method you choose, **change the `ENCRYPTION_KEY`** to a secure, unique value. This key encrypts your files on the filesystem if you lose it, your files become inaccessible.
|
||||
```bash
|
||||
docker run -d \
|
||||
--name palmr \
|
||||
-e ENABLE_S3=false \
|
||||
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
|
||||
-p 5487:5487 \
|
||||
-p 3333:3333 \
|
||||
-v $(pwd)/data:/app/server \
|
||||
--restart unless-stopped \
|
||||
kyantech/palmr:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generating a Secure Encryption Key
|
||||
## Maintenance
|
||||
|
||||
Need a strong key for `ENCRYPTION_KEY`? Use our handy Password Generator Tool below to create one:
|
||||
### Updates
|
||||
|
||||
<KeyGenerator />
|
||||
Keep Palmr. current with the latest features and security fixes:
|
||||
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Backup & Restore
|
||||
|
||||
The backup method depends on which storage option you're using:
|
||||
|
||||
**Named Volume Backup:**
|
||||
|
||||
```bash
|
||||
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:**
|
||||
|
||||
```bash
|
||||
tar czf palmr-backup.tar.gz ./data
|
||||
```
|
||||
|
||||
**Bind Mount Restore:**
|
||||
|
||||
```bash
|
||||
tar xzf palmr-backup.tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## You're all set!
|
||||
## Next Steps
|
||||
|
||||
Congratulations! You've just deployed your own secure file sharing solution with Palmr. in record time. We're thrilled to have you on board and hope you love using this powerful tool as much as we loved building it.
|
||||
Your Palmr. instance is now ready! Explore additional configuration options:
|
||||
|
||||
Got questions or ideas? Dive into the rest of our documentation or reach out via our GitHub [issues page](https://github.com/kyantech/Palmr/issues). Happy sharing!
|
||||
### Advanced Configuration
|
||||
|
||||
- **[UID/GID Configuration](/docs/3.0-beta/uid-gid-configuration)** - Configure user permissions for NAS systems and custom environments
|
||||
- **[S3 Storage](/docs/3.0-beta/s3-configuration)** - Scale with Amazon S3 or compatible storage providers
|
||||
- **[Manual Installation](/docs/3.0-beta/manual-installation)** - Manual installation and custom configurations
|
||||
|
||||
### Integration & Development
|
||||
|
||||
- **[API Reference](/docs/3.0-beta/api)** - Integrate Palmr. with your applications
|
||||
- **[Architecture Guide](/docs/3.0-beta/architecture)** - Understanding Palmr. components and design
|
||||
|
||||
---
|
||||
|
||||
Need help? Visit our [GitHub Issues](https://github.com/kyantech/Palmr/issues) or community discussions.
|
||||
|
||||
199
apps/docs/content/docs/3.0-beta/reverse-proxy-configuration.mdx
Normal file
199
apps/docs/content/docs/3.0-beta/reverse-proxy-configuration.mdx
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
title: Reverse Proxy Configuration
|
||||
icon: "Shield"
|
||||
---
|
||||
|
||||
When deploying **Palmr.** behind a reverse proxy (like Traefik, Nginx, or Cloudflare), you need to configure secure cookie settings to ensure proper authentication. This guide covers the `SECURE_SITE` environment variable and related proxy configurations.
|
||||
|
||||
## Overview
|
||||
|
||||
Reverse proxies terminate SSL/TLS connections and forward requests to Palmr., which can cause authentication issues if cookies aren't configured properly for HTTPS environments. The `SECURE_SITE` environment variable controls cookie security settings to handle these scenarios.
|
||||
|
||||
## The SECURE_SITE Environment Variable
|
||||
|
||||
The `SECURE_SITE` variable configures how Palmr. handles authentication cookies based on your deployment environment:
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Value | Cookie Settings | Use Case |
|
||||
| ------- | ------------------------------------- | ----------------------------------- |
|
||||
| `true` | `secure: true`, `sameSite: "lax"` | HTTPS/Production with reverse proxy |
|
||||
| `false` | `secure: false`, `sameSite: "strict"` | HTTP/Development (default) |
|
||||
|
||||
### When to Use SECURE_SITE=true
|
||||
|
||||
Set `SECURE_SITE=true` in the following scenarios:
|
||||
|
||||
- ✅ **Reverse Proxy with HTTPS**: Traefik, Nginx, HAProxy with SSL termination
|
||||
- ✅ **Cloud Providers**: Cloudflare, AWS ALB, Azure Application Gateway
|
||||
- ✅ **CDN with HTTPS**: Any CDN that terminates SSL
|
||||
- ✅ **Production Deployments**: When users access via HTTPS
|
||||
|
||||
### When to Use SECURE_SITE=false
|
||||
|
||||
Keep `SECURE_SITE=false` (default) for:
|
||||
|
||||
- ✅ **Local Development**: Running on `http://localhost`
|
||||
- ✅ **Direct HTTP Access**: No reverse proxy involved
|
||||
- ✅ **Testing Environments**: When using HTTP
|
||||
- ✅ **HTTP Reverse Proxy**: Nginx, Apache, etc. without SSL termination
|
||||
|
||||
---
|
||||
|
||||
## HTTP Reverse Proxy Setup
|
||||
|
||||
**Docker Compose for HTTP Nginx:**
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=false # HTTP = false
|
||||
```
|
||||
|
||||
> **⚠️ HTTP Security**: Remember that HTTP transmits data in plain text. Consider using HTTPS in production environments.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Authentication Issues
|
||||
|
||||
### Common Symptoms
|
||||
|
||||
If you experience authentication issues behind a reverse proxy:
|
||||
|
||||
- ❌ Login appears successful but redirects to login page
|
||||
- ❌ "No Authorization was found in request.cookies" errors
|
||||
- ❌ API requests return 401 Unauthorized
|
||||
- ❌ User registration fails silently
|
||||
|
||||
### Diagnostic Steps
|
||||
|
||||
1. **Check Browser Developer Tools**:
|
||||
|
||||
- Look for cookies in Application/Storage tab
|
||||
- Verify cookie has `Secure` flag when using HTTPS
|
||||
- Check if `SameSite` attribute is appropriate
|
||||
|
||||
2. **Verify Environment Variables**:
|
||||
|
||||
```bash
|
||||
docker exec -it palmr env | grep SECURE_SITE
|
||||
```
|
||||
|
||||
3. **Test Cookie Settings**:
|
||||
- With `SECURE_SITE=false`: Should work on HTTP
|
||||
- With `SECURE_SITE=true`: Should work on HTTPS
|
||||
|
||||
### Common Fixes
|
||||
|
||||
**Problem**: Authentication fails with reverse proxy
|
||||
|
||||
**Solution**: Set `SECURE_SITE=true` and ensure proper headers:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=true
|
||||
```
|
||||
|
||||
**Problem**: Mixed content errors
|
||||
|
||||
**Solution**: Ensure proxy passes correct headers:
|
||||
|
||||
```yaml
|
||||
# Traefik
|
||||
- "traefik.http.middlewares.palmr-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
|
||||
# Nginx
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
**Problem**: Authentication fails with HTTP reverse proxy
|
||||
|
||||
**Solution**: Use `SECURE_SITE=false` and ensure proper cookie headers:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=false # For HTTP proxy
|
||||
```
|
||||
|
||||
```nginx
|
||||
# Nginx - Add these headers for cookie handling
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_pass_header Set-Cookie;
|
||||
```
|
||||
|
||||
**Problem**: SQLite "readonly database" error with bind mounts
|
||||
|
||||
**Solution**: Configure proper UID/GID permissions:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- PALMR_UID=1000 # Your host UID (check with: id)
|
||||
- PALMR_GID=1000 # Your host GID
|
||||
- ENCRYPTION_KEY=your-key-here
|
||||
```
|
||||
|
||||
> **💡 Note**: Check your host UID/GID with `id` command and use those values. See [UID/GID Configuration](/docs/3.0-beta/uid-gid-configuration) for detailed setup.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
> **⚠️ Important**: Always use HTTPS in production environments. The `SECURE_SITE=true` setting ensures cookies are only sent over encrypted connections.
|
||||
|
||||
---
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Multiple Domains
|
||||
|
||||
If serving Palmr. on multiple domains, ensure consistent cookie settings:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=true # Use for all HTTPS domains
|
||||
```
|
||||
|
||||
### Development vs Production
|
||||
|
||||
Use environment-specific configurations:
|
||||
|
||||
**Development (HTTP):**
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=false
|
||||
```
|
||||
|
||||
**Production (HTTPS):**
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SECURE_SITE=true
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
Add health checks to ensure proper proxy configuration:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
palmr:
|
||||
# ... other config
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5487/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you're still experiencing issues after following this guide:
|
||||
|
||||
1. **Check the Logs**: `docker logs palmr`
|
||||
2. **Verify Headers**: Use browser dev tools or `curl -I`
|
||||
3. **Test Direct Access**: Try accessing Palmr. directly (bypassing proxy)
|
||||
4. **Open an Issue**: [Report bugs on GitHub](https://github.com/kyantech/Palmr/issues)
|
||||
|
||||
> **💡 Pro Tip**: When reporting issues, include your reverse proxy configuration and any relevant error messages from both Palmr. and your proxy logs.
|
||||
@@ -3,78 +3,62 @@ title: UID/GID Configuration
|
||||
icon: "Users"
|
||||
---
|
||||
|
||||
Having trouble with **permission denied** errors when using bind-mounted directories? This guide will help you configure <span className="font-bold">Palmr.</span> to work seamlessly with your host system's <span className="font-bold italic">user and group permissions</span>.
|
||||
Configure user and group permissions for seamless bind mount compatibility across different host systems, particularly NAS environments.
|
||||
|
||||
This is particularly common on **NAS systems** like Synology or QNAP, where the container's default UID/GID doesn't match your host system. Instead of manually changing directory ownership on your host, Palmr. now supports <span className="font-bold italic">flexible UID/GID configuration</span> to automatically handle these permission conflicts.
|
||||
## Overview
|
||||
|
||||
## The Problem
|
||||
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.
|
||||
|
||||
By default, Palmr. runs with UID 1001 and GID 1001 inside the container. When you bind-mount a directory from your host system (like `./data:/app/server`), permission conflicts can occur if:
|
||||
**Default Configuration**: UID 1001, GID 1001
|
||||
|
||||
- Your host user has a different UID/GID
|
||||
- You're running on a NAS system with specific user configurations
|
||||
- The mounted directory ownership doesn't match the container user
|
||||
## When to Configure
|
||||
|
||||
This results in errors like:
|
||||
UID/GID configuration is recommended when:
|
||||
|
||||
- **Access denied** even with 777 permissions
|
||||
- **Database connection failures** (SQLite can't create/access files)
|
||||
- **File upload failures** in the application
|
||||
|
||||
## The Solution
|
||||
|
||||
Palmr. now supports **runtime UID/GID configuration** through environment variables. The container will automatically adjust its internal user permissions to match your host system, eliminating the need to manually change directory ownership.
|
||||
- Using bind mounts with different host user permissions
|
||||
- Deploying on NAS systems (Synology, QNAP, etc.)
|
||||
- Encountering "permission denied" errors
|
||||
- Host system uses different default UID/GID values
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Configure these **optional** environment variables in your `docker-compose.yml`:
|
||||
Configure permissions using these optional environment variables:
|
||||
|
||||
| Variable | Description | Default | When to Use |
|
||||
| ----------- | ---------------------------------- | ------- | ------------------------------------------- |
|
||||
| `PALMR_UID` | User ID for the container process | 1001 | When host directory owner has different UID |
|
||||
| `PALMR_GID` | Group ID for the container process | 1001 | When host directory group has different GID |
|
||||
| Variable | Description | Default | Example |
|
||||
| ----------- | -------------------------------- | ------- | ------- |
|
||||
| `PALMR_UID` | User ID for container processes | `1001` | `1000` |
|
||||
| `PALMR_GID` | Group ID for container processes | `1001` | `1000` |
|
||||
|
||||
> **Important**: These variables are **completely optional**. If you don't set them, Palmr. works exactly as before with UID/GID 1001.
|
||||
---
|
||||
|
||||
## Finding Your Host UID/GID
|
||||
## Finding Host UID/GID
|
||||
|
||||
Before configuring, you need to find your host system's user and group IDs:
|
||||
Determine your host system's user and group IDs:
|
||||
|
||||
```bash
|
||||
# Find your user ID
|
||||
id -u
|
||||
|
||||
# Find your group ID
|
||||
id -g
|
||||
|
||||
# See both together
|
||||
# Check current user
|
||||
id
|
||||
|
||||
# Output example
|
||||
uid=1000(user) gid=1000(group) groups=1000(group),27(sudo)
|
||||
```
|
||||
|
||||
Example output:
|
||||
Use the `uid` and `gid` values for your configuration.
|
||||
|
||||
```bash
|
||||
$ id
|
||||
uid=1000(myuser) gid=1000(mygroup) groups=1000(mygroup),27(sudo)
|
||||
```
|
||||
|
||||
In this example, you would use `PALMR_UID=1000` and `PALMR_GID=1000`.
|
||||
---
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Basic Linux System
|
||||
|
||||
Most Linux desktop systems use UID/GID 1000 for the first user:
|
||||
### Standard Linux System
|
||||
|
||||
```yaml
|
||||
services:
|
||||
palmr:
|
||||
image: docker.io/kyantech/palmr:latest
|
||||
image: kyantech/palmr:latest
|
||||
container_name: palmr
|
||||
environment:
|
||||
- ENABLE_S3=false
|
||||
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars
|
||||
# Add these lines to match your host user
|
||||
- ENCRYPTION_KEY=your-secure-key-min-32-chars
|
||||
- PALMR_UID=1000
|
||||
- PALMR_GID=1000
|
||||
ports:
|
||||
@@ -86,158 +70,135 @@ services:
|
||||
|
||||
### Synology NAS
|
||||
|
||||
Synology systems commonly use these UID/GID combinations:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
palmr:
|
||||
image: docker.io/kyantech/palmr:latest
|
||||
image: kyantech/palmr:latest
|
||||
container_name: palmr
|
||||
environment:
|
||||
- ENABLE_S3=false
|
||||
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars
|
||||
# Common Synology configuration
|
||||
- ENCRYPTION_KEY=your-secure-key-min-32-chars
|
||||
- PALMR_UID=1026
|
||||
- PALMR_GID=100
|
||||
ports:
|
||||
- "5487:5487"
|
||||
volumes:
|
||||
- /volume1/docker/palmr/data:/app/server
|
||||
- /volume1/docker/palmr:/app/server
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### QNAP NAS
|
||||
|
||||
QNAP systems typically use:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
palmr:
|
||||
image: docker.io/kyantech/palmr:latest
|
||||
image: kyantech/palmr:latest
|
||||
container_name: palmr
|
||||
environment:
|
||||
- ENABLE_S3=false
|
||||
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars
|
||||
# Common QNAP configuration
|
||||
- ENCRYPTION_KEY=your-secure-key-min-32-chars
|
||||
- PALMR_UID=1000
|
||||
- PALMR_GID=100
|
||||
ports:
|
||||
- "5487:5487"
|
||||
volumes:
|
||||
- /share/Container/palmr/data:/app/server
|
||||
- /share/Container/palmr:/app/server
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### No Configuration Needed
|
||||
---
|
||||
|
||||
If you're not experiencing permission issues, you don't need to set these variables:
|
||||
## Migration Guide
|
||||
|
||||
```yaml
|
||||
services:
|
||||
palmr:
|
||||
image: docker.io/kyantech/palmr:latest
|
||||
container_name: palmr
|
||||
environment:
|
||||
- ENABLE_S3=false
|
||||
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars
|
||||
# No PALMR_UID/PALMR_GID needed - uses defaults
|
||||
ports:
|
||||
- "5487:5487"
|
||||
volumes:
|
||||
- ./data:/app/server
|
||||
restart: unless-stopped
|
||||
```
|
||||
### Existing Installations
|
||||
|
||||
## Migration for Existing Installations
|
||||
To add UID/GID configuration to running installations:
|
||||
|
||||
If you already have Palmr. running and want to add UID/GID configuration:
|
||||
1. **Stop the container**
|
||||
|
||||
### Step 1: Backup Your Data
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
2. **Backup your data**
|
||||
|
||||
```bash
|
||||
cp -r ./data ./data-backup
|
||||
```
|
||||
|
||||
3. **Update configuration**
|
||||
Add UID/GID variables to your `docker-compose.yaml`
|
||||
|
||||
4. **Restart with new configuration**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Check Configuration
|
||||
|
||||
Verify UID/GID settings are applied correctly:
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
cp -r ./data ./data-backup
|
||||
```
|
||||
# View startup logs
|
||||
docker-compose logs palmr | head -20
|
||||
|
||||
### Step 2: Update docker-compose.yml
|
||||
|
||||
Add the UID/GID environment variables to your existing configuration:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
palmr:
|
||||
image: docker.io/kyantech/palmr:latest
|
||||
container_name: palmr
|
||||
environment:
|
||||
- ENABLE_S3=false
|
||||
- ENCRYPTION_KEY=your-existing-key
|
||||
# Add these new lines
|
||||
- PALMR_UID=1000 # Your host user UID
|
||||
- PALMR_GID=1000 # Your host group GID
|
||||
# ... rest of your configuration
|
||||
```
|
||||
|
||||
### Step 3: Restart Palmr.
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
The container will automatically detect the new UID/GID configuration and adjust permissions accordingly.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Checking Current Configuration
|
||||
|
||||
To verify your container is running with the correct UID/GID:
|
||||
|
||||
```bash
|
||||
# Check the user ID inside the container
|
||||
docker exec palmr id
|
||||
|
||||
# Check file ownership in the mounted volume
|
||||
# Check file ownership
|
||||
docker exec palmr ls -la /app/server/
|
||||
|
||||
# Verify process UID/GID
|
||||
docker exec palmr ps aux | grep node
|
||||
```
|
||||
|
||||
### Permission Issues Persist
|
||||
### Troubleshooting
|
||||
|
||||
If you're still having permission issues after configuration:
|
||||
**Permission issues persist:**
|
||||
|
||||
```bash
|
||||
# Check container logs for UID/GID adjustment messages
|
||||
docker-compose logs palmr | grep -i "uid\|gid"
|
||||
# Check environment variables
|
||||
docker exec palmr env | grep PALMR
|
||||
|
||||
# Manually fix permissions if needed
|
||||
docker exec -u root palmr chown -R palmr:nodejs /app/server
|
||||
# Verify file ownership
|
||||
docker exec palmr stat /app/server/prisma/palmr.db
|
||||
|
||||
# Review configuration logs
|
||||
docker-compose logs palmr | grep -E "🔧|🔐|🔽"
|
||||
```
|
||||
|
||||
### Finding NAS-Specific UID/GID
|
||||
|
||||
For NAS systems, check your system's user management interface or use SSH:
|
||||
**NAS-specific debugging:**
|
||||
|
||||
```bash
|
||||
# On your NAS, check existing users
|
||||
# Synology - Check mount point ownership
|
||||
ls -la /volume1/docker/palmr/
|
||||
|
||||
# QNAP - Check mount point ownership
|
||||
ls -la /share/Container/palmr/
|
||||
|
||||
# Check NAS user configuration
|
||||
cat /etc/passwd | grep -v nobody
|
||||
|
||||
# Check groups
|
||||
cat /etc/group
|
||||
```
|
||||
|
||||
## How It Works
|
||||
---
|
||||
|
||||
When you set `PALMR_UID` and/or `PALMR_GID` environment variables:
|
||||
## Implementation Details
|
||||
|
||||
1. **Container startup**: The container detects the environment variables
|
||||
2. **User adjustment**: Automatically modifies the internal `palmr` user to use your specified UID/GID
|
||||
3. **Ownership update**: Updates file ownership of application directories
|
||||
4. **Conflict handling**: Gracefully handles cases where the UID/GID already exists
|
||||
5. **Logging**: Provides clear feedback about what changes were made
|
||||
The UID/GID configuration process:
|
||||
|
||||
The process is completely automatic and requires no manual intervention.
|
||||
1. **Detection** - Environment variables are read during container startup
|
||||
2. **Ownership Update** - File permissions are adjusted to match target UID/GID
|
||||
3. **Privilege Drop** - Application runs with specified user permissions via `su-exec`
|
||||
4. **Logging** - Configuration changes are logged for verification
|
||||
|
||||
## Build-Time Customization
|
||||
This approach provides automatic permission management without user creation or system modification.
|
||||
|
||||
For advanced users who want to build custom images with different default UID/GID:
|
||||
---
|
||||
|
||||
## Build-Time Configuration
|
||||
|
||||
For custom base images with different default values:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
@@ -246,18 +207,16 @@ docker build \
|
||||
-t palmr:custom .
|
||||
```
|
||||
|
||||
This sets new defaults at build time, which can still be overridden with environment variables at runtime.
|
||||
Runtime environment variables override build-time defaults.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
## Benefits
|
||||
|
||||
The UID/GID configuration feature makes Palmr. **universally compatible** with different host systems without requiring manual permission changes. Key benefits:
|
||||
- **Zero Configuration** - Works automatically when environment variables are set
|
||||
- **Universal Compatibility** - Supports any valid UID/GID combination
|
||||
- **NAS Optimized** - Tested with major NAS platforms
|
||||
- **Backward Compatible** - Existing deployments continue without modification
|
||||
- **Performance Optimized** - Lightweight implementation using `su-exec`
|
||||
|
||||
- ✅ **Automatic permission handling** - No manual `chown` commands needed
|
||||
- ✅ **NAS system compatibility** - Tested with Synology and QNAP
|
||||
- ✅ **Backward compatible** - Existing installations continue to work unchanged
|
||||
- ✅ **Optional configuration** - Only use when needed
|
||||
- ✅ **Runtime flexibility** - Change UID/GID without rebuilding images
|
||||
|
||||
If you're experiencing permission issues with bind-mounted directories, try adding the appropriate `PALMR_UID` and `PALMR_GID` environment variables to your configuration. The container will handle the rest automatically!
|
||||
For permission issues with bind mounts, add the appropriate `PALMR_UID` and `PALMR_GID` environment variables to resolve conflicts automatically.
|
||||
|
||||
BIN
apps/docs/public/assets/v3/oidc/zitadel-provider-setup.png
Normal file
BIN
apps/docs/public/assets/v3/oidc/zitadel-provider-setup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 263 KiB |
@@ -1,6 +1,7 @@
|
||||
# FOR FILESYSTEM STORAGE ENV VARS
|
||||
ENABLE_S3=false
|
||||
ENCRYPTION_KEY=change-this-key-in-production-min-32-chars
|
||||
DATABASE_URL="file:./palmr.db"
|
||||
|
||||
# FOR USE WITH S3 COMPATIBLE STORAGE
|
||||
# ENABLE_S3=true
|
||||
|
||||
@@ -4,7 +4,7 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:./palmr.db"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
|
||||
@@ -6,40 +6,24 @@
|
||||
*/
|
||||
|
||||
export const timeoutConfig = {
|
||||
// Connection timeouts
|
||||
connection: {
|
||||
// How long to wait for initial connection (0 = disabled)
|
||||
timeout: 0,
|
||||
|
||||
// Keep-alive timeout for long-running uploads/downloads
|
||||
// 20 hours should be enough for most large file operations
|
||||
keepAlive: 20 * 60 * 60 * 1000, // 20 hours in milliseconds
|
||||
},
|
||||
|
||||
// Request timeouts
|
||||
request: {
|
||||
// Global request timeout (0 = disabled, let requests run indefinitely)
|
||||
timeout: 0,
|
||||
|
||||
// Body parsing timeout for large files
|
||||
bodyTimeout: 0, // Disabled for large files
|
||||
},
|
||||
|
||||
// File operation timeouts
|
||||
file: {
|
||||
// Maximum time to wait for file upload (0 = no limit)
|
||||
uploadTimeout: 0,
|
||||
|
||||
// Maximum time to wait for file download (0 = no limit)
|
||||
downloadTimeout: 0,
|
||||
|
||||
// Streaming chunk timeout (time between chunks)
|
||||
streamTimeout: 30 * 1000, // 30 seconds between chunks
|
||||
},
|
||||
|
||||
// Token expiration (for filesystem storage)
|
||||
token: {
|
||||
// How long upload/download tokens remain valid
|
||||
expiration: 60 * 60 * 1000, // 1 hour in milliseconds
|
||||
},
|
||||
};
|
||||
@@ -52,7 +36,6 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
|
||||
const fileSizeGB = fileSizeBytes / (1024 * 1024 * 1024);
|
||||
|
||||
if (fileSizeGB > 100) {
|
||||
// For files larger than 100GB, extend token expiration
|
||||
return {
|
||||
...timeoutConfig,
|
||||
token: {
|
||||
@@ -62,7 +45,6 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
|
||||
}
|
||||
|
||||
if (fileSizeGB > 10) {
|
||||
// For files larger than 10GB, extend token expiration
|
||||
return {
|
||||
...timeoutConfig,
|
||||
token: {
|
||||
@@ -79,15 +61,12 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
|
||||
* You can set these in your .env file to override defaults
|
||||
*/
|
||||
export const envTimeoutOverrides = {
|
||||
// Override connection keep-alive if set in environment
|
||||
keepAliveTimeout: process.env.KEEP_ALIVE_TIMEOUT
|
||||
? parseInt(process.env.KEEP_ALIVE_TIMEOUT)
|
||||
: timeoutConfig.connection.keepAlive,
|
||||
|
||||
// Override request timeout if set in environment
|
||||
requestTimeout: process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT) : timeoutConfig.request.timeout,
|
||||
|
||||
// Override token expiration if set in environment
|
||||
tokenExpiration: process.env.TOKEN_EXPIRATION
|
||||
? parseInt(process.env.TOKEN_EXPIRATION)
|
||||
: timeoutConfig.token.expiration,
|
||||
|
||||
@@ -11,6 +11,8 @@ const envSchema = z.object({
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_BUCKET_NAME: z.string().optional(),
|
||||
S3_FORCE_PATH_STYLE: z.union([z.literal("true"), z.literal("false")]).default("false"),
|
||||
SECURE_SITE: z.union([z.literal("true"), z.literal("false")]).default("false"),
|
||||
DATABASE_URL: z.string().optional().default("file:/app/server/prisma/palmr.db"),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
@@ -4,7 +4,21 @@ import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const uploadsDir = path.join(process.cwd(), "uploads/logo");
|
||||
const isDocker = (() => {
|
||||
try {
|
||||
require("fs").statSync("/.dockerenv");
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
return require("fs").readFileSync("/proc/self/cgroup", "utf8").includes("docker");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const baseDir = isDocker ? "/app/server" : process.cwd();
|
||||
const uploadsDir = path.join(baseDir, "uploads/logo");
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { env } from "../../env";
|
||||
import { LoginSchema, RequestPasswordResetSchema, createResetPasswordSchema } from "./dto";
|
||||
import { AuthService } from "./service";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
@@ -17,8 +18,8 @@ export class AuthController {
|
||||
reply.setCookie("token", token, {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
secure: env.SECURE_SITE === "true" ? true : false,
|
||||
sameSite: env.SECURE_SITE === "true" ? "lax" : "strict",
|
||||
});
|
||||
|
||||
return reply.send({ user });
|
||||
|
||||
@@ -9,7 +9,7 @@ export const createPasswordSchema = async () => {
|
||||
};
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
email: z.string().email("Invalid email").describe("User email"),
|
||||
emailOrUsername: z.string().min(1, "Email or username is required").describe("User email or username"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters").describe("User password"),
|
||||
});
|
||||
export type LoginInput = z.infer<typeof LoginSchema>;
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
const passwordSchema = await createPasswordSchema();
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Invalid email").describe("User email"),
|
||||
emailOrUsername: z.string().min(1, "Email or username is required").describe("User email or username"),
|
||||
password: passwordSchema,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export class AuthService {
|
||||
private emailService = new EmailService();
|
||||
|
||||
async login(data: LoginInput) {
|
||||
const user = await this.userRepository.findUserByEmail(data.email);
|
||||
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
@@ -98,43 +98,82 @@ export class FilesystemController {
|
||||
} catch (error) {
|
||||
try {
|
||||
await fs.promises.unlink(tempPath);
|
||||
} catch (error) {
|
||||
console.error("Error deleting temp file:", error);
|
||||
} catch (cleanupError) {
|
||||
console.error("Error deleting temp file:", cleanupError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadSmallFile(request: FastifyRequest, provider: FilesystemStorageProvider, objectName: string) {
|
||||
const stream = request.body as any;
|
||||
const chunks: Buffer[] = [];
|
||||
const body = request.body as any;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
if (Buffer.isBuffer(body)) {
|
||||
if (body.length === 0) {
|
||||
throw new Error("No file data received");
|
||||
}
|
||||
await provider.uploadFile(objectName, body);
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on("end", async () => {
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
if (typeof body === "string") {
|
||||
const buffer = Buffer.from(body, "utf8");
|
||||
if (buffer.length === 0) {
|
||||
throw new Error("No file data received");
|
||||
}
|
||||
await provider.uploadFile(objectName, buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (buffer.length === 0) {
|
||||
throw new Error("No file data received");
|
||||
if (typeof body === "object" && body !== null && !body.on) {
|
||||
const buffer = Buffer.from(JSON.stringify(body), "utf8");
|
||||
if (buffer.length === 0) {
|
||||
throw new Error("No file data received");
|
||||
}
|
||||
await provider.uploadFile(objectName, buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (body && typeof body.on === "function") {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
body.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
body.on("end", async () => {
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
if (buffer.length === 0) {
|
||||
throw new Error("No file data received");
|
||||
}
|
||||
|
||||
await provider.uploadFile(objectName, buffer);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error("Error uploading small file:", error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
await provider.uploadFile(objectName, buffer);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error("Error uploading small file:", error);
|
||||
body.on("error", (error: Error) => {
|
||||
console.error("Error reading upload stream:", error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
stream.on("error", (error: Error) => {
|
||||
console.error("Error reading upload stream:", error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
try {
|
||||
const buffer = Buffer.from(body);
|
||||
if (buffer.length === 0) {
|
||||
throw new Error("No file data received");
|
||||
}
|
||||
await provider.uploadFile(objectName, buffer);
|
||||
} catch (error) {
|
||||
throw new Error(`Unsupported request body type: ${typeof body}. Expected stream, buffer, string, or object.`);
|
||||
}
|
||||
}
|
||||
|
||||
async download(request: FastifyRequest, reply: FastifyReply) {
|
||||
|
||||
@@ -9,6 +9,10 @@ export async function filesystemRoutes(app: FastifyInstance) {
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.addContentTypeParser("application/json", async (request: FastifyRequest, payload: any) => {
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.put(
|
||||
"/filesystem/upload/:token",
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ import { z } from "zod";
|
||||
export async function oidcRoutes(fastify: FastifyInstance) {
|
||||
const oidcController = new OIDCController();
|
||||
|
||||
// Get OIDC configuration
|
||||
fastify.get(
|
||||
"/config",
|
||||
{
|
||||
@@ -27,7 +26,6 @@ export async function oidcRoutes(fastify: FastifyInstance) {
|
||||
oidcController.getConfig.bind(oidcController)
|
||||
);
|
||||
|
||||
// Initiate OIDC authorization
|
||||
fastify.get(
|
||||
"/authorize",
|
||||
{
|
||||
@@ -54,7 +52,6 @@ export async function oidcRoutes(fastify: FastifyInstance) {
|
||||
oidcController.authorize.bind(oidcController)
|
||||
);
|
||||
|
||||
// Handle OIDC callback
|
||||
fastify.get(
|
||||
"/callback",
|
||||
{
|
||||
|
||||
@@ -26,7 +26,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
}
|
||||
};
|
||||
|
||||
// Create reverse share (authenticated)
|
||||
app.post(
|
||||
"/reverse-shares",
|
||||
{
|
||||
@@ -50,7 +49,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.createReverseShare.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// List user's reverse shares (authenticated)
|
||||
app.get(
|
||||
"/reverse-shares",
|
||||
{
|
||||
@@ -72,7 +70,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.listUserReverseShares.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Get reverse share by ID (authenticated)
|
||||
app.get(
|
||||
"/reverse-shares/:id",
|
||||
{
|
||||
@@ -98,7 +95,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.getReverseShare.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Update reverse share (authenticated)
|
||||
app.put(
|
||||
"/reverse-shares",
|
||||
{
|
||||
@@ -123,7 +119,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.updateReverseShare.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Update reverse share password (authenticated)
|
||||
app.put(
|
||||
"/reverse-shares/:id/password",
|
||||
{
|
||||
@@ -151,7 +146,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.updatePassword.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Delete reverse share (authenticated)
|
||||
app.delete(
|
||||
"/reverse-shares/:id",
|
||||
{
|
||||
@@ -177,7 +171,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.deleteReverseShare.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Get reverse share for upload (public)
|
||||
app.get(
|
||||
"/reverse-shares/:id/upload",
|
||||
{
|
||||
@@ -207,7 +200,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.getReverseShareForUpload.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Get reverse share for upload by alias (public)
|
||||
app.get(
|
||||
"/reverse-shares/alias/:alias/upload",
|
||||
{
|
||||
@@ -237,7 +229,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.getReverseShareForUploadByAlias.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Get presigned URL for file upload (public)
|
||||
app.post(
|
||||
"/reverse-shares/:id/presigned-url",
|
||||
{
|
||||
@@ -269,7 +260,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.getPresignedUrl.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Get presigned URL for file upload by alias (public)
|
||||
app.post(
|
||||
"/reverse-shares/alias/:alias/presigned-url",
|
||||
{
|
||||
@@ -301,7 +291,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.getPresignedUrlByAlias.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Register file upload completion (public)
|
||||
app.post(
|
||||
"/reverse-shares/:id/register-file",
|
||||
{
|
||||
@@ -333,7 +322,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.registerFileUpload.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Register file upload completion by alias (public)
|
||||
app.post(
|
||||
"/reverse-shares/alias/:alias/register-file",
|
||||
{
|
||||
@@ -365,7 +353,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.registerFileUploadByAlias.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Check password (public)
|
||||
app.post(
|
||||
"/reverse-shares/:id/check-password",
|
||||
{
|
||||
@@ -394,7 +381,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.checkPassword.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Download file from reverse share (authenticated)
|
||||
app.get(
|
||||
"/reverse-shares/files/:fileId/download",
|
||||
{
|
||||
@@ -421,7 +407,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.downloadFile.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Delete file from reverse share (authenticated)
|
||||
app.delete(
|
||||
"/reverse-shares/files/:fileId",
|
||||
{
|
||||
@@ -447,7 +432,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.deleteFile.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Create or update reverse share alias (authenticated)
|
||||
app.post(
|
||||
"/reverse-shares/:reverseShareId/alias",
|
||||
{
|
||||
@@ -486,7 +470,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.createOrUpdateAlias.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Activate reverse share (authenticated)
|
||||
app.patch(
|
||||
"/reverse-shares/:id/activate",
|
||||
{
|
||||
@@ -512,7 +495,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.activateReverseShare.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Deactivate reverse share (authenticated)
|
||||
app.patch(
|
||||
"/reverse-shares/:id/deactivate",
|
||||
{
|
||||
@@ -538,7 +520,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
|
||||
reverseShareController.deactivateReverseShare.bind(reverseShareController)
|
||||
);
|
||||
|
||||
// Update file from reverse share (authenticated)
|
||||
app.put(
|
||||
"/reverse-shares/files/:fileId",
|
||||
{
|
||||
|
||||
@@ -168,7 +168,6 @@ export class ReverseShareService {
|
||||
throw new Error("Unauthorized to delete this reverse share");
|
||||
}
|
||||
|
||||
// Delete all files associated with this reverse share
|
||||
for (const file of reverseShare.files) {
|
||||
try {
|
||||
await this.fileService.deleteObject(file.objectName);
|
||||
@@ -265,7 +264,6 @@ export class ReverseShareService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check file count limit
|
||||
if (reverseShare.maxFiles) {
|
||||
const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShareId);
|
||||
if (currentFileCount >= reverseShare.maxFiles) {
|
||||
@@ -273,12 +271,10 @@ export class ReverseShareService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check file size limit
|
||||
if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) {
|
||||
throw new Error("File size exceeds limit");
|
||||
}
|
||||
|
||||
// Check allowed file types
|
||||
if (reverseShare.allowedFileTypes) {
|
||||
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
|
||||
if (!allowedTypes.includes(fileData.extension.toLowerCase())) {
|
||||
@@ -318,7 +314,6 @@ export class ReverseShareService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check file count limit
|
||||
if (reverseShare.maxFiles) {
|
||||
const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShare.id);
|
||||
if (currentFileCount >= reverseShare.maxFiles) {
|
||||
@@ -326,12 +321,10 @@ export class ReverseShareService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check file size limit
|
||||
if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) {
|
||||
throw new Error("File size exceeds limit");
|
||||
}
|
||||
|
||||
// Check allowed file types
|
||||
if (reverseShare.allowedFileTypes) {
|
||||
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
|
||||
if (!allowedTypes.includes(fileData.extension.toLowerCase())) {
|
||||
@@ -372,10 +365,8 @@ export class ReverseShareService {
|
||||
throw new Error("Unauthorized to delete this file");
|
||||
}
|
||||
|
||||
// Delete from storage
|
||||
await this.fileService.deleteObject(file.objectName);
|
||||
|
||||
// Delete from database
|
||||
const deletedFile = await this.reverseShareRepository.deleteFile(fileId);
|
||||
return this.formatFileResponse(deletedFile);
|
||||
}
|
||||
@@ -473,7 +464,6 @@ export class ReverseShareService {
|
||||
data: { name?: string; description?: string | null },
|
||||
creatorId: string
|
||||
) {
|
||||
// Verificar se o arquivo existe e se o usuário tem permissão
|
||||
const file = await this.reverseShareRepository.findFileById(fileId);
|
||||
if (!file) {
|
||||
throw new Error("File not found");
|
||||
@@ -483,13 +473,10 @@ export class ReverseShareService {
|
||||
throw new Error("Unauthorized to edit this file");
|
||||
}
|
||||
|
||||
// Se o nome está sendo atualizado, preservar a extensão original
|
||||
const updateData = { ...data };
|
||||
if (data.name) {
|
||||
const originalExtension = file.extension;
|
||||
// Remove qualquer extensão que o usuário possa ter digitado
|
||||
const nameWithoutExtension = data.name.replace(/\.[^/.]+$/, "");
|
||||
// Adiciona a extensão original (garantindo que tenha o ponto)
|
||||
const extensionWithDot = originalExtension.startsWith(".") ? originalExtension : `.${originalExtension}`;
|
||||
updateData.name = `${nameWithoutExtension}${extensionWithDot}`;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,20 @@ export class StorageController {
|
||||
const diskSpace = await this.storageService.getDiskSpace(userId, isAdmin);
|
||||
return reply.send(diskSpace);
|
||||
} catch (error: any) {
|
||||
return reply.status(500).send({ error: error.message });
|
||||
console.error("Controller error in getDiskSpace:", error);
|
||||
|
||||
if (error.message?.includes("Unable to determine actual disk space")) {
|
||||
return reply.status(503).send({
|
||||
error: "Disk space detection unavailable - system configuration issue",
|
||||
details: "Please check system permissions and available disk utilities",
|
||||
code: "DISK_SPACE_DETECTION_FAILED",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
error: "Failed to retrieve disk space information",
|
||||
details: error.message || "Unknown error occurred",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { IS_RUNNING_IN_CONTAINER } from "../../utils/container-detection";
|
||||
import { ConfigService } from "../config/service";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { exec } from "child_process";
|
||||
import fs from "node:fs";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -9,6 +11,126 @@ const prisma = new PrismaClient();
|
||||
export class StorageService {
|
||||
private configService = new ConfigService();
|
||||
|
||||
private _ensureNumber(value: number, fallback: number = 0): number {
|
||||
if (isNaN(value) || !isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private _safeParseInt(value: string): number {
|
||||
const parsed = parseInt(value);
|
||||
return this._ensureNumber(parsed, 0);
|
||||
}
|
||||
|
||||
private async _tryDiskSpaceCommand(command: string): Promise<{ total: number; available: number } | null> {
|
||||
try {
|
||||
console.log(`Trying disk space command: ${command}`);
|
||||
const { stdout, stderr } = await execAsync(command);
|
||||
|
||||
if (stderr) {
|
||||
console.warn(`Command stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
console.log(`Command stdout: ${stdout}`);
|
||||
|
||||
let total = 0;
|
||||
let available = 0;
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const lines = stdout.trim().split("\n").slice(1);
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 3) {
|
||||
const [, size, freespace] = parts;
|
||||
total += this._safeParseInt(size);
|
||||
available += this._safeParseInt(freespace);
|
||||
}
|
||||
}
|
||||
} else if (process.platform === "darwin") {
|
||||
const lines = stdout.trim().split("\n");
|
||||
if (lines.length >= 2) {
|
||||
const parts = lines[1].trim().split(/\s+/);
|
||||
if (parts.length >= 4) {
|
||||
const [, size, , avail] = parts;
|
||||
total = this._safeParseInt(size) * 1024;
|
||||
available = this._safeParseInt(avail) * 1024;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const lines = stdout.trim().split("\n");
|
||||
if (lines.length >= 2) {
|
||||
const parts = lines[1].trim().split(/\s+/);
|
||||
if (parts.length >= 4) {
|
||||
const [, size, , avail] = parts;
|
||||
if (command.includes("-B1")) {
|
||||
total = this._safeParseInt(size);
|
||||
available = this._safeParseInt(avail);
|
||||
} else {
|
||||
total = this._safeParseInt(size) * 1024;
|
||||
available = this._safeParseInt(avail) * 1024;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (total > 0 && available >= 0) {
|
||||
console.log(`Successfully parsed disk space: ${total} bytes total, ${available} bytes available`);
|
||||
return { total, available };
|
||||
} else {
|
||||
console.warn(`Invalid values parsed: total=${total}, available=${available}`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Command failed: ${command}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _getDiskSpaceMultiplePaths(): Promise<{ total: number; available: number } | null> {
|
||||
const pathsToTry = IS_RUNNING_IN_CONTAINER
|
||||
? ["/app/server/uploads", "/app/server", "/app", "/"]
|
||||
: [".", "./uploads", process.cwd()];
|
||||
|
||||
for (const pathToCheck of pathsToTry) {
|
||||
console.log(`Trying path: ${pathToCheck}`);
|
||||
|
||||
if (pathToCheck.includes("uploads")) {
|
||||
try {
|
||||
if (!fs.existsSync(pathToCheck)) {
|
||||
fs.mkdirSync(pathToCheck, { recursive: true });
|
||||
console.log(`Created directory: ${pathToCheck}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Could not create path ${pathToCheck}:`, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(pathToCheck)) {
|
||||
console.warn(`Path does not exist: ${pathToCheck}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const commandsToTry =
|
||||
process.platform === "win32"
|
||||
? ["wmic logicaldisk get size,freespace,caption"]
|
||||
: process.platform === "darwin"
|
||||
? [`df -k "${pathToCheck}"`, `df "${pathToCheck}"`]
|
||||
: [`df -B1 "${pathToCheck}"`, `df -k "${pathToCheck}"`, `df "${pathToCheck}"`];
|
||||
|
||||
for (const command of commandsToTry) {
|
||||
const result = await this._tryDiskSpaceCommand(command);
|
||||
if (result) {
|
||||
console.log(`✅ Successfully got disk space for path: ${pathToCheck}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async getDiskSpace(
|
||||
userId?: string,
|
||||
isAdmin?: boolean
|
||||
@@ -20,46 +142,40 @@ export class StorageService {
|
||||
}> {
|
||||
try {
|
||||
if (isAdmin) {
|
||||
const command = process.platform === "win32"
|
||||
? "wmic logicaldisk get size,freespace,caption"
|
||||
: process.platform === "darwin"
|
||||
? "df -k ."
|
||||
: "df -B1 .";
|
||||
console.log(`Running in container: ${IS_RUNNING_IN_CONTAINER}`);
|
||||
|
||||
const { stdout } = await execAsync(command);
|
||||
let total = 0;
|
||||
let available = 0;
|
||||
const diskInfo = await this._getDiskSpaceMultiplePaths();
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const lines = stdout.trim().split("\n").slice(1);
|
||||
for (const line of lines) {
|
||||
const [, size, freespace] = line.trim().split(/\s+/);
|
||||
total += parseInt(size) || 0;
|
||||
available += parseInt(freespace) || 0;
|
||||
}
|
||||
} else if (process.platform === "darwin") {
|
||||
const lines = stdout.trim().split("\n");
|
||||
const [, size, , avail] = lines[1].trim().split(/\s+/);
|
||||
total = parseInt(size) * 1024;
|
||||
available = parseInt(avail) * 1024;
|
||||
} else {
|
||||
const lines = stdout.trim().split("\n");
|
||||
const [, size, , avail] = lines[1].trim().split(/\s+/);
|
||||
total = parseInt(size);
|
||||
available = parseInt(avail);
|
||||
if (!diskInfo) {
|
||||
console.error("❌ CRITICAL: Could not determine disk space using any method!");
|
||||
console.error("This indicates a serious system issue. Please check:");
|
||||
console.error("1. File system permissions");
|
||||
console.error("2. Available disk utilities (df, wmic)");
|
||||
console.error("3. Container/system configuration");
|
||||
|
||||
throw new Error("Unable to determine actual disk space - system configuration issue");
|
||||
}
|
||||
|
||||
const { total, available } = diskInfo;
|
||||
const used = total - available;
|
||||
|
||||
const diskSizeGB = this._ensureNumber(total / (1024 * 1024 * 1024), 0);
|
||||
const diskUsedGB = this._ensureNumber(used / (1024 * 1024 * 1024), 0);
|
||||
const diskAvailableGB = this._ensureNumber(available / (1024 * 1024 * 1024), 0);
|
||||
|
||||
console.log(
|
||||
`✅ Real disk space: ${diskSizeGB.toFixed(2)}GB total, ${diskUsedGB.toFixed(2)}GB used, ${diskAvailableGB.toFixed(2)}GB available`
|
||||
);
|
||||
|
||||
return {
|
||||
diskSizeGB: Number((total / (1024 * 1024 * 1024)).toFixed(2)),
|
||||
diskUsedGB: Number((used / (1024 * 1024 * 1024)).toFixed(2)),
|
||||
diskAvailableGB: Number((available / (1024 * 1024 * 1024)).toFixed(2)),
|
||||
uploadAllowed: true,
|
||||
diskSizeGB: Number(diskSizeGB.toFixed(2)),
|
||||
diskUsedGB: Number(diskUsedGB.toFixed(2)),
|
||||
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
|
||||
uploadAllowed: diskAvailableGB > 0.1, // At least 100MB free
|
||||
};
|
||||
} else if (userId) {
|
||||
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
|
||||
const maxStorageGB = Number(maxTotalStorage) / (1024 * 1024 * 1024);
|
||||
const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
|
||||
|
||||
const userFiles = await prisma.file.findMany({
|
||||
where: { userId },
|
||||
@@ -68,21 +184,24 @@ export class StorageService {
|
||||
|
||||
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
|
||||
|
||||
const usedStorageGB = Number(totalUsedStorage) / (1024 * 1024 * 1024);
|
||||
const availableStorageGB = maxStorageGB - usedStorageGB;
|
||||
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
|
||||
const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
|
||||
|
||||
return {
|
||||
diskSizeGB: maxStorageGB,
|
||||
diskUsedGB: usedStorageGB,
|
||||
diskAvailableGB: availableStorageGB,
|
||||
diskSizeGB: Number(maxStorageGB.toFixed(2)),
|
||||
diskUsedGB: Number(usedStorageGB.toFixed(2)),
|
||||
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
|
||||
uploadAllowed: availableStorageGB > 0,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("User ID is required for non-admin users");
|
||||
} catch (error) {
|
||||
console.error("Error getting disk space:", error);
|
||||
throw new Error("Failed to get disk space information");
|
||||
console.error("❌ Error getting disk space:", error);
|
||||
|
||||
throw new Error(
|
||||
`Failed to get disk space information: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface IUserRepository {
|
||||
findUserByEmail(email: string): Promise<User | null>;
|
||||
findUserById(id: string): Promise<User | null>;
|
||||
findUserByUsername(username: string): Promise<User | null>;
|
||||
findUserByEmailOrUsername(emailOrUsername: string): Promise<User | null>;
|
||||
listUsers(): Promise<User[]>;
|
||||
updateUser(data: UpdateUserInput & { password?: string }): Promise<User>;
|
||||
deleteUser(id: string): Promise<User>;
|
||||
@@ -41,6 +42,14 @@ export class PrismaUserRepository implements IUserRepository {
|
||||
return prisma.user.findUnique({ where: { username } });
|
||||
}
|
||||
|
||||
async findUserByEmailOrUsername(emailOrUsername: string): Promise<User | null> {
|
||||
return prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: emailOrUsername }, { username: emailOrUsername }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listUsers(): Promise<User[]> {
|
||||
return prisma.user.findMany();
|
||||
}
|
||||
|
||||
@@ -14,20 +14,25 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
const usersCount = await prisma.user.count();
|
||||
|
||||
if (usersCount > 0) {
|
||||
await request.jwtVerify();
|
||||
if (!request.user.isAdmin) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
if (!request.user.isAdmin) {
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Access restricted to administrators" })
|
||||
.description("Access restricted to administrators");
|
||||
}
|
||||
} catch (authErr) {
|
||||
console.error(authErr);
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Access restricted to administrators" })
|
||||
.description("Access restricted to administrators");
|
||||
.status(401)
|
||||
.send({ error: "Unauthorized: a valid token is required to access this resource." })
|
||||
.description("Unauthorized: a valid token is required to access this resource.");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: "Unauthorized: a valid token is required to access this resource." })
|
||||
.description("Unauthorized: a valid token is required to access this resource.");
|
||||
return reply.status(500).send({ error: "Internal server error" }).description("Internal server error");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { env } from "../env";
|
||||
import { StorageProvider } from "../types/storage";
|
||||
import { IS_RUNNING_IN_CONTAINER } from "../utils/container-detection";
|
||||
import * as crypto from "crypto";
|
||||
import * as fsSync from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
@@ -9,12 +10,14 @@ import { pipeline } from "stream/promises";
|
||||
|
||||
export class FilesystemStorageProvider implements StorageProvider {
|
||||
private static instance: FilesystemStorageProvider;
|
||||
private uploadsDir = path.join(process.cwd(), "uploads");
|
||||
private uploadsDir: string;
|
||||
private encryptionKey = env.ENCRYPTION_KEY;
|
||||
private uploadTokens = new Map<string, { objectName: string; expiresAt: number }>();
|
||||
private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
|
||||
|
||||
private constructor() {
|
||||
this.uploadsDir = IS_RUNNING_IN_CONTAINER ? "/app/server/uploads" : path.join(process.cwd(), "uploads");
|
||||
|
||||
this.ensureUploadsDir();
|
||||
setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
|
||||
}
|
||||
@@ -214,8 +217,10 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
if (encryptedBuffer.length > 16) {
|
||||
try {
|
||||
return this.decryptFileBuffer(encryptedBuffer);
|
||||
} catch (error) {
|
||||
console.warn("Failed to decrypt with new method, trying legacy format");
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
console.warn("Failed to decrypt with new method, trying legacy format", error.message);
|
||||
}
|
||||
return this.decryptFileLegacy(encryptedBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as readline from "readline";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Função para ler entrada do usuário de forma assíncrona
|
||||
function createReadlineInterface() {
|
||||
return readline.createInterface({
|
||||
input: process.stdin,
|
||||
@@ -17,15 +16,12 @@ function question(rl: readline.Interface, query: string): Promise<string> {
|
||||
return new Promise((resolve) => rl.question(query, resolve));
|
||||
}
|
||||
|
||||
// Função para validar formato de email básico
|
||||
function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// Função para validar senha com base nas regras do sistema
|
||||
function isValidPassword(password: string): boolean {
|
||||
// Minimum length baseado na configuração padrão do sistema (8 caracteres)
|
||||
return password.length >= 8;
|
||||
}
|
||||
|
||||
@@ -38,7 +34,6 @@ async function resetUserPassword() {
|
||||
console.log("This script allows you to reset a user's password directly from the Docker terminal.");
|
||||
console.log("⚠️ WARNING: This bypasses normal security checks. Use only when necessary!\n");
|
||||
|
||||
// Solicitar email do usuário
|
||||
let email: string;
|
||||
let user: any;
|
||||
|
||||
@@ -55,7 +50,6 @@ async function resetUserPassword() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Buscar usuário no banco de dados
|
||||
user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
select: {
|
||||
@@ -83,7 +77,6 @@ async function resetUserPassword() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Mostrar informações do usuário encontrado
|
||||
console.log("\n✅ User found:");
|
||||
console.log(` Name: ${user.firstName} ${user.lastName}`);
|
||||
console.log(` Username: ${user.username}`);
|
||||
@@ -91,14 +84,12 @@ async function resetUserPassword() {
|
||||
console.log(` Status: ${user.isActive ? "Active" : "Inactive"}`);
|
||||
console.log(` Admin: ${user.isAdmin ? "Yes" : "No"}\n`);
|
||||
|
||||
// Confirmar se deseja prosseguir
|
||||
const confirm = await question(rl, "Do you want to reset the password for this user? (y/n): ");
|
||||
if (confirm.toLowerCase() !== "y") {
|
||||
console.log("\n👋 Operation cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Solicitar nova senha
|
||||
let newPassword: string;
|
||||
while (true) {
|
||||
console.log("\n🔑 Enter new password requirements:");
|
||||
@@ -126,18 +117,15 @@ async function resetUserPassword() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Hash da senha usando bcrypt (mesmo método usado pelo sistema)
|
||||
console.log("\n🔄 Hashing password...");
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Atualizar senha no banco de dados
|
||||
console.log("💾 Updating password in database...");
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashedPassword },
|
||||
});
|
||||
|
||||
// Limpar tokens de reset de senha existentes para este usuário
|
||||
console.log("🧹 Cleaning up existing password reset tokens...");
|
||||
await prisma.passwordReset.deleteMany({
|
||||
where: {
|
||||
@@ -159,7 +147,6 @@ async function resetUserPassword() {
|
||||
}
|
||||
}
|
||||
|
||||
// Função para listar usuários (funcionalidade auxiliar)
|
||||
async function listUsers() {
|
||||
try {
|
||||
console.log("\n👥 Registered Users:");
|
||||
@@ -198,7 +185,6 @@ async function listUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -227,7 +213,6 @@ async function main() {
|
||||
await resetUserPassword();
|
||||
}
|
||||
|
||||
// Handle process termination
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\n\n👋 Goodbye!");
|
||||
await prisma.$disconnect();
|
||||
@@ -239,7 +224,6 @@ process.on("SIGTERM", async () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Run the script
|
||||
if (require.main === module) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { reverseShareRoutes } from "./modules/reverse-share/routes";
|
||||
import { shareRoutes } from "./modules/share/routes";
|
||||
import { storageRoutes } from "./modules/storage/routes";
|
||||
import { userRoutes } from "./modules/user/routes";
|
||||
import { IS_RUNNING_IN_CONTAINER } from "./utils/container-detection";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import * as fs from "fs/promises";
|
||||
@@ -26,21 +27,22 @@ if (typeof global.crypto === "undefined") {
|
||||
}
|
||||
|
||||
async function ensureDirectories() {
|
||||
const uploadsDir = path.join(process.cwd(), "uploads");
|
||||
const tempChunksDir = path.join(process.cwd(), "temp-chunks");
|
||||
const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
|
||||
const uploadsDir = path.join(baseDir, "uploads");
|
||||
const tempChunksDir = path.join(baseDir, "temp-chunks");
|
||||
|
||||
try {
|
||||
await fs.access(uploadsDir);
|
||||
} catch {
|
||||
await fs.mkdir(uploadsDir, { recursive: true });
|
||||
console.log("📁 Created uploads directory");
|
||||
console.log(`📁 Created uploads directory: ${uploadsDir}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(tempChunksDir);
|
||||
} catch {
|
||||
await fs.mkdir(tempChunksDir, { recursive: true });
|
||||
console.log("📁 Created temp-chunks directory");
|
||||
console.log(`📁 Created temp-chunks directory: ${tempChunksDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +64,11 @@ async function startServer() {
|
||||
});
|
||||
|
||||
if (env.ENABLE_S3 !== "true") {
|
||||
const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
|
||||
const uploadsPath = path.join(baseDir, "uploads");
|
||||
|
||||
await app.register(fastifyStatic, {
|
||||
root: path.join(process.cwd(), "uploads"),
|
||||
root: uploadsPath,
|
||||
prefix: "/uploads/",
|
||||
decorateReply: false,
|
||||
});
|
||||
|
||||
45
apps/server/src/utils/container-detection.ts
Normal file
45
apps/server/src/utils/container-detection.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as fsSync from "fs";
|
||||
|
||||
/**
|
||||
* Determines if the application is running inside a container environment.
|
||||
* Checks common container indicators like /.dockerenv and cgroup file patterns.
|
||||
*
|
||||
* This function caches its result after the first call for performance.
|
||||
*
|
||||
* @returns {boolean} True if running in a container, false otherwise.
|
||||
*/
|
||||
function isRunningInContainer(): boolean {
|
||||
try {
|
||||
if (fsSync.existsSync("/.dockerenv")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cgroupContent = fsSync.readFileSync("/proc/self/cgroup", "utf8");
|
||||
const containerPatterns = [
|
||||
"docker",
|
||||
"containerd",
|
||||
"lxc",
|
||||
"kubepods",
|
||||
"pod",
|
||||
"/containers/",
|
||||
"system.slice/container-",
|
||||
];
|
||||
|
||||
for (const pattern of containerPatterns) {
|
||||
if (cgroupContent.includes(pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fsSync.existsSync("/.well-known/container")) {
|
||||
return true;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
console.warn("Could not perform full container detection:", e.message);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const IS_RUNNING_IN_CONTAINER = isRunningInContainer();
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "مرحبا بك",
|
||||
"signInToContinue": "قم بتسجيل الدخول للمتابعة",
|
||||
"emailOrUsernameLabel": "البريد الإلكتروني أو اسم المستخدم",
|
||||
"emailOrUsernamePlaceholder": "أدخل بريدك الإلكتروني أو اسم المستخدم",
|
||||
"emailLabel": "البريد الإلكتروني",
|
||||
"emailPlaceholder": "أدخل بريدك الإلكتروني",
|
||||
"passwordLabel": "كلمة المرور",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "استخدام التخزين",
|
||||
"ariaLabel": "شريط تقدم استخدام التخزين",
|
||||
"used": "المستخدمة",
|
||||
"available": "المتاحة"
|
||||
"available": "متاح",
|
||||
"loading": "جارٍ التحميل...",
|
||||
"retry": "إعادة المحاولة",
|
||||
"errors": {
|
||||
"title": "معلومات التخزين غير متوفرة",
|
||||
"detectionFailed": "تعذر اكتشاف مساحة القرص. قد يكون هذا بسبب مشاكل في إعدادات النظام أو صلاحيات غير كافية.",
|
||||
"serverError": "حدث خطأ في الخادم أثناء استرجاع معلومات التخزين. يرجى المحاولة مرة أخرى لاحقاً.",
|
||||
"unknown": "حدث خطأ غير متوقع أثناء تحميل معلومات التخزين."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "تبديل السمة",
|
||||
@@ -748,6 +758,7 @@
|
||||
"uploadProgress": "تقدم الرفع",
|
||||
"upload": "رفع",
|
||||
"startUploads": "بدء الرفع",
|
||||
"retry": "إعادة المحاولة",
|
||||
"finish": "إنهاء",
|
||||
"success": "تم رفع الملف بنجاح",
|
||||
"allSuccess": "{count, plural, =1 {تم رفع الملف بنجاح} other {تم رفع # ملف بنجاح}}",
|
||||
@@ -844,6 +855,7 @@
|
||||
"passwordLength": "يجب أن تحتوي كلمة المرور على 8 أحرف على الأقل",
|
||||
"passwordsMatch": "كلمتا المرور غير متطابقتين",
|
||||
"emailRequired": "البريد الإلكتروني مطلوب",
|
||||
"emailOrUsernameRequired": "البريد الإلكتروني أو اسم المستخدم مطلوب",
|
||||
"passwordRequired": "كلمة المرور مطلوبة",
|
||||
"nameRequired": "الاسم مطلوب",
|
||||
"required": "هذا الحقل مطلوب"
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "هذا الرابط غير نشط.",
|
||||
"linkExpired": "هذا الرابط منتهي الصلاحية.",
|
||||
"uploadFailed": "خطأ في رفع الملف",
|
||||
"retry": "إعادة المحاولة",
|
||||
"fileTooLarge": "الملف كبير جداً. الحجم الأقصى: {maxSize}",
|
||||
"fileTypeNotAllowed": "نوع الملف غير مسموح به. الأنواع المقبولة: {allowedTypes}",
|
||||
"maxFilesExceeded": "الحد الأقصى المسموح به هو {maxFiles} ملف/ملفات",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "Willkommen zu",
|
||||
"signInToContinue": "Melden Sie sich an, um fortzufahren",
|
||||
"emailOrUsernameLabel": "E-Mail-Adresse oder Benutzername",
|
||||
"emailOrUsernamePlaceholder": "Geben Sie Ihre E-Mail-Adresse oder Benutzernamen ein",
|
||||
"emailLabel": "E-Mail-Adresse",
|
||||
"emailPlaceholder": "Geben Sie Ihre E-Mail-Adresse ein",
|
||||
"passwordLabel": "Passwort",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "Speichernutzung",
|
||||
"ariaLabel": "Fortschrittsbalken der Speichernutzung",
|
||||
"used": "genutzt",
|
||||
"available": "verfügbar"
|
||||
"available": "verfügbar",
|
||||
"loading": "Wird geladen...",
|
||||
"retry": "Wiederholen",
|
||||
"errors": {
|
||||
"title": "Speicherinformationen nicht verfügbar",
|
||||
"detectionFailed": "Speicherplatz konnte nicht erkannt werden. Dies kann an Systemkonfigurationsproblemen oder unzureichenden Berechtigungen liegen.",
|
||||
"serverError": "Serverfehler beim Abrufen der Speicherinformationen. Bitte versuchen Sie es später erneut.",
|
||||
"unknown": "Ein unerwarteter Fehler ist beim Laden der Speicherinformationen aufgetreten."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Design umschalten",
|
||||
@@ -748,6 +758,7 @@
|
||||
"uploadProgress": "Upload-Fortschritt",
|
||||
"upload": "Hochladen",
|
||||
"startUploads": "Uploads Starten",
|
||||
"retry": "Wiederholen",
|
||||
"finish": "Beenden",
|
||||
"success": "Datei erfolgreich hochgeladen",
|
||||
"allSuccess": "{count, plural, =1 {Datei erfolgreich hochgeladen} other {# Dateien erfolgreich hochgeladen}}",
|
||||
@@ -844,6 +855,7 @@
|
||||
"passwordLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
|
||||
"passwordsMatch": "Die Passwörter stimmen nicht überein",
|
||||
"emailRequired": "E-Mail ist erforderlich",
|
||||
"emailOrUsernameRequired": "E-Mail oder Benutzername ist erforderlich",
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
"nameRequired": "Name ist erforderlich",
|
||||
"required": "Dieses Feld ist erforderlich"
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "Dieser Link ist inaktiv.",
|
||||
"linkExpired": "Dieser Link ist abgelaufen.",
|
||||
"uploadFailed": "Fehler beim Hochladen der Datei",
|
||||
"retry": "Wiederholen",
|
||||
"fileTooLarge": "Datei zu groß. Maximale Größe: {maxSize}",
|
||||
"fileTypeNotAllowed": "Dateityp nicht erlaubt. Erlaubte Typen: {allowedTypes}",
|
||||
"maxFilesExceeded": "Maximal {maxFiles} Dateien erlaubt",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "Welcome to",
|
||||
"signInToContinue": "Sign in to continue",
|
||||
"emailOrUsernameLabel": "Email or Username",
|
||||
"emailOrUsernamePlaceholder": "Enter your email or username",
|
||||
"emailLabel": "Email Address",
|
||||
"emailPlaceholder": "Enter your email",
|
||||
"passwordLabel": "Password",
|
||||
@@ -788,7 +790,15 @@
|
||||
"title": "Storage Usage",
|
||||
"ariaLabel": "Storage usage progress bar",
|
||||
"used": "used",
|
||||
"available": "available"
|
||||
"available": "available",
|
||||
"loading": "Loading...",
|
||||
"retry": "Retry",
|
||||
"errors": {
|
||||
"title": "Storage information unavailable",
|
||||
"detectionFailed": "Unable to detect disk space. This may be due to system configuration issues or insufficient permissions.",
|
||||
"serverError": "Server error occurred while retrieving storage information. Please try again later.",
|
||||
"unknown": "An unexpected error occurred while loading storage information."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle theme",
|
||||
@@ -806,6 +816,7 @@
|
||||
"uploadProgress": "Upload progress",
|
||||
"upload": "Upload",
|
||||
"startUploads": "Start Uploads",
|
||||
"retry": "Retry",
|
||||
"finish": "Finish",
|
||||
"success": "File uploaded successfully",
|
||||
"allSuccess": "{count, plural, =1 {File uploaded successfully} other {# files uploaded successfully}}",
|
||||
@@ -901,6 +912,7 @@
|
||||
"passwordLength": "Password must be at least 8 characters long",
|
||||
"passwordsMatch": "Passwords must match",
|
||||
"emailRequired": "Email is required",
|
||||
"emailOrUsernameRequired": "Email or username is required",
|
||||
"passwordRequired": "Password is required",
|
||||
"passwordMinLength": "Password must be at least 6 characters",
|
||||
"nameRequired": "Name is required",
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "This link is inactive.",
|
||||
"linkExpired": "This link has expired.",
|
||||
"uploadFailed": "Error uploading file",
|
||||
"retry": "Retry",
|
||||
"fileTooLarge": "File too large. Maximum size: {maxSize}",
|
||||
"fileTypeNotAllowed": "File type not allowed. Accepted types: {allowedTypes}",
|
||||
"maxFilesExceeded": "Maximum of {maxFiles} files allowed",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "Bienvenido a",
|
||||
"signInToContinue": "Inicia sesión para continuar",
|
||||
"emailOrUsernameLabel": "Correo electrónico o nombre de usuario",
|
||||
"emailOrUsernamePlaceholder": "Introduce tu correo electrónico o nombre de usuario",
|
||||
"emailLabel": "Dirección de correo electrónico",
|
||||
"emailPlaceholder": "Introduce tu correo electrónico",
|
||||
"passwordLabel": "Contraseña",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "Uso de almacenamiento",
|
||||
"ariaLabel": "Barra de progreso del uso de almacenamiento",
|
||||
"used": "usados",
|
||||
"available": "disponibles"
|
||||
"available": "disponible",
|
||||
"loading": "Cargando...",
|
||||
"retry": "Reintentar",
|
||||
"errors": {
|
||||
"title": "Información de almacenamiento no disponible",
|
||||
"detectionFailed": "No se pudo detectar el espacio en disco. Esto puede deberse a problemas de configuración del sistema o permisos insuficientes.",
|
||||
"serverError": "Ocurrió un error del servidor al recuperar la información de almacenamiento. Por favor, inténtelo de nuevo más tarde.",
|
||||
"unknown": "Ocurrió un error inesperado al cargar la información de almacenamiento."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Cambiar tema",
|
||||
@@ -748,6 +758,7 @@
|
||||
"uploadProgress": "Progreso de la subida",
|
||||
"upload": "Subir",
|
||||
"startUploads": "Iniciar Subidas",
|
||||
"retry": "Reintentar",
|
||||
"finish": "Finalizar",
|
||||
"success": "Archivo subido exitosamente",
|
||||
"allSuccess": "{count, plural, =1 {Archivo subido exitosamente} other {# archivos subidos exitosamente}}",
|
||||
@@ -844,6 +855,7 @@
|
||||
"passwordLength": "La contraseña debe tener al menos 8 caracteres",
|
||||
"passwordsMatch": "Las contraseñas no coinciden",
|
||||
"emailRequired": "Se requiere el correo electrónico",
|
||||
"emailOrUsernameRequired": "Se requiere el correo electrónico o nombre de usuario",
|
||||
"passwordRequired": "Se requiere la contraseña",
|
||||
"nameRequired": "El nombre es obligatorio",
|
||||
"required": "Este campo es obligatorio"
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "Este enlace está inactivo.",
|
||||
"linkExpired": "Este enlace ha expirado.",
|
||||
"uploadFailed": "Error al subir archivo",
|
||||
"retry": "Reintentar",
|
||||
"fileTooLarge": "Archivo demasiado grande. Tamaño máximo: {maxSize}",
|
||||
"fileTypeNotAllowed": "Tipo de archivo no permitido. Tipos aceptados: {allowedTypes}",
|
||||
"maxFilesExceeded": "Máximo de {maxFiles} archivos permitidos",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "Bienvenue à",
|
||||
"signInToContinue": "Connectez-vous pour continuer",
|
||||
"emailOrUsernameLabel": "Email ou Nom d'utilisateur",
|
||||
"emailOrUsernamePlaceholder": "Entrez votre email ou nom d'utilisateur",
|
||||
"emailLabel": "Adresse e-mail",
|
||||
"emailPlaceholder": "Entrez votre e-mail",
|
||||
"passwordLabel": "Mot de passe",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "Utilisation du Stockage",
|
||||
"ariaLabel": "Barre de progression de l'utilisation du stockage",
|
||||
"used": "utilisé",
|
||||
"available": "disponible"
|
||||
"available": "disponible",
|
||||
"loading": "Chargement...",
|
||||
"retry": "Réessayer",
|
||||
"errors": {
|
||||
"title": "Informations de stockage non disponibles",
|
||||
"detectionFailed": "Impossible de détecter l'espace disque. Cela peut être dû à des problèmes de configuration système ou à des permissions insuffisantes.",
|
||||
"serverError": "Une erreur serveur s'est produite lors de la récupération des informations de stockage. Veuillez réessayer plus tard.",
|
||||
"unknown": "Une erreur inattendue s'est produite lors du chargement des informations de stockage."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Changer le thème",
|
||||
@@ -748,6 +758,7 @@
|
||||
"uploadProgress": "Progression du téléchargement",
|
||||
"upload": "Télécharger",
|
||||
"startUploads": "Commencer les Téléchargements",
|
||||
"retry": "Réessayer",
|
||||
"finish": "Terminer",
|
||||
"success": "Fichier téléchargé avec succès",
|
||||
"allSuccess": "{count, plural, =1 {Fichier téléchargé avec succès} other {# fichiers téléchargés avec succès}}",
|
||||
@@ -844,6 +855,7 @@
|
||||
"passwordLength": "Le mot de passe doit contenir au moins 8 caractères",
|
||||
"passwordsMatch": "Les mots de passe ne correspondent pas",
|
||||
"emailRequired": "L'email est requis",
|
||||
"emailOrUsernameRequired": "L'email ou le nom d'utilisateur est requis",
|
||||
"passwordRequired": "Le mot de passe est requis",
|
||||
"nameRequired": "Nome é obrigatório",
|
||||
"required": "Este campo é obrigatório"
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "Ce lien est inactif.",
|
||||
"linkExpired": "Ce lien a expiré.",
|
||||
"uploadFailed": "Erreur lors de l'envoi du fichier",
|
||||
"retry": "Réessayer",
|
||||
"fileTooLarge": "Fichier trop volumineux. Taille maximale : {maxSize}",
|
||||
"fileTypeNotAllowed": "Type de fichier non autorisé. Types acceptés : {allowedTypes}",
|
||||
"maxFilesExceeded": "Maximum de {maxFiles} fichiers autorisés",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "स्वागत है में",
|
||||
"signInToContinue": "जारी रखने के लिए साइन इन करें",
|
||||
"emailOrUsernameLabel": "ईमेल या उपयोगकर्ता नाम",
|
||||
"emailOrUsernamePlaceholder": "अपना ईमेल या उपयोगकर्ता नाम दर्ज करें",
|
||||
"emailLabel": "ईमेल पता",
|
||||
"emailPlaceholder": "अपना ईमेल दर्ज करें",
|
||||
"passwordLabel": "पासवर्ड",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "स्टोरेज उपयोग",
|
||||
"ariaLabel": "स्टोरेज उपयोग प्रगति पट्टी",
|
||||
"used": "उपयोग किया गया",
|
||||
"available": "उपलब्ध"
|
||||
"available": "उपलब्ध",
|
||||
"loading": "लोड हो रहा है...",
|
||||
"retry": "पुनः प्रयास करें",
|
||||
"errors": {
|
||||
"title": "स्टोरेज जानकारी अनुपलब्ध",
|
||||
"detectionFailed": "डिस्क स्पेस का पता लगाने में असमर्थ। यह सिस्टम कॉन्फ़िगरेशन समस्याओं या अपर्याप्त अनुमतियों के कारण हो सकता है।",
|
||||
"serverError": "स्टोरेज जानकारी प्राप्त करते समय सर्वर त्रुटि हुई। कृपया बाद में पुनः प्रयास करें।",
|
||||
"unknown": "स्टोरेज जानकारी लोड करते समय एक अनपेक्षित त्रुटि हुई।"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "थीम टॉगल करें",
|
||||
@@ -748,6 +758,7 @@
|
||||
"uploadProgress": "अपलोड प्रगति",
|
||||
"upload": "अपलोड",
|
||||
"startUploads": "अपलोड शुरू करें",
|
||||
"retry": "पुनः प्रयास करें",
|
||||
"finish": "समाप्त",
|
||||
"success": "फ़ाइल सफलतापूर्वक अपलोड की गई",
|
||||
"allSuccess": "{count, plural, =1 {फ़ाइल सफलतापूर्वक अपलोड की गई} other {# फ़ाइलें सफलतापूर्वक अपलोड की गईं}}",
|
||||
@@ -844,6 +855,7 @@
|
||||
"passwordLength": "पासवर्ड कम से कम 8 अक्षर का होना चाहिए",
|
||||
"passwordsMatch": "पासवर्ड मेल नहीं खाते",
|
||||
"emailRequired": "ईमेल आवश्यक है",
|
||||
"emailOrUsernameRequired": "ईमेल या उपयोगकर्ता नाम आवश्यक है",
|
||||
"passwordRequired": "पासवर्ड आवश्यक है",
|
||||
"nameRequired": "नाम आवश्यक है",
|
||||
"required": "यह फ़ील्ड आवश्यक है"
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "यह लिंक निष्क्रिय है।",
|
||||
"linkExpired": "यह लिंक समाप्त हो गया है।",
|
||||
"uploadFailed": "फ़ाइल अपलोड करने में त्रुटि",
|
||||
"retry": "पुनः प्रयास करें",
|
||||
"fileTooLarge": "फ़ाइल बहुत बड़ी है। अधिकतम आकार: {maxSize}",
|
||||
"fileTypeNotAllowed": "फ़ाइल प्रकार अनुमत नहीं है। स्वीकृत प्रकार: {allowedTypes}",
|
||||
"maxFilesExceeded": "अधिकतम {maxFiles} फ़ाइलें अनुमत हैं",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "Benvenuto in",
|
||||
"signInToContinue": "Accedi per continuare",
|
||||
"emailOrUsernameLabel": "Email o Nome utente",
|
||||
"emailOrUsernamePlaceholder": "Inserisci la tua email o nome utente",
|
||||
"emailLabel": "Indirizzo Email",
|
||||
"emailPlaceholder": "Inserisci la tua email",
|
||||
"passwordLabel": "Parola d'accesso",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "Utilizzo Archiviazione",
|
||||
"ariaLabel": "Barra di progresso utilizzo archiviazione",
|
||||
"used": "utilizzato",
|
||||
"available": "disponibile"
|
||||
"available": "disponibile",
|
||||
"loading": "Caricamento...",
|
||||
"retry": "Riprova",
|
||||
"errors": {
|
||||
"title": "Informazioni di archiviazione non disponibili",
|
||||
"detectionFailed": "Impossibile rilevare lo spazio su disco. Ciò potrebbe essere dovuto a problemi di configurazione del sistema o permessi insufficienti.",
|
||||
"serverError": "Si è verificato un errore del server durante il recupero delle informazioni di archiviazione. Riprova più tardi.",
|
||||
"unknown": "Si è verificato un errore imprevisto durante il caricamento delle informazioni di archiviazione."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Cambia tema",
|
||||
@@ -748,6 +758,7 @@
|
||||
"uploadProgress": "Progresso caricamento",
|
||||
"upload": "Carica",
|
||||
"startUploads": "Inizia Caricamenti",
|
||||
"retry": "Riprova",
|
||||
"finish": "Termina",
|
||||
"success": "File caricato con successo",
|
||||
"allSuccess": "{count, plural, =1 {File caricato con successo} other {# file caricati con successo}}",
|
||||
@@ -843,6 +854,7 @@
|
||||
"passwordLength": "La parola d'accesso deve essere di almeno 8 caratteri",
|
||||
"passwordsMatch": "Le parole d'accesso devono corrispondere",
|
||||
"emailRequired": "L'indirizzo email è obbligatorio",
|
||||
"emailOrUsernameRequired": "L'indirizzo email o il nome utente è obbligatorio",
|
||||
"passwordRequired": "La parola d'accesso è obbligatoria",
|
||||
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
|
||||
"nameRequired": "Il nome è obbligatorio",
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "Questo link è inattivo.",
|
||||
"linkExpired": "Questo link è scaduto.",
|
||||
"uploadFailed": "Errore durante l'invio del file",
|
||||
"retry": "Riprova",
|
||||
"fileTooLarge": "File troppo grande. Dimensione massima: {maxSize}",
|
||||
"fileTypeNotAllowed": "Tipo di file non consentito. Tipi accettati: {allowedTypes}",
|
||||
"maxFilesExceeded": "Massimo {maxFiles} file consentiti",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "ようこそへ",
|
||||
"signInToContinue": "続行するにはサインインしてください",
|
||||
"emailOrUsernameLabel": "メールアドレスまたはユーザー名",
|
||||
"emailOrUsernamePlaceholder": "メールアドレスまたはユーザー名を入力してください",
|
||||
"emailLabel": "メールアドレス",
|
||||
"emailPlaceholder": "メールアドレスを入力してください",
|
||||
"passwordLabel": "パスワード",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "ストレージ使用量",
|
||||
"ariaLabel": "ストレージ使用状況のプログレスバー",
|
||||
"used": "使用済み",
|
||||
"available": "利用可能"
|
||||
"available": "利用可能",
|
||||
"loading": "読み込み中...",
|
||||
"retry": "再試行",
|
||||
"errors": {
|
||||
"title": "ストレージ情報が利用できません",
|
||||
"detectionFailed": "ディスク容量を検出できません。システム設定の問題または権限が不足している可能性があります。",
|
||||
"serverError": "ストレージ情報の取得中にサーバーエラーが発生しました。後でもう一度お試しください。",
|
||||
"unknown": "ストレージ情報の読み込み中に予期せぬエラーが発生しました。"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "テーマを切り替える",
|
||||
@@ -761,6 +771,7 @@
|
||||
},
|
||||
"multipleTitle": "複数ファイルをアップロード",
|
||||
"startUploads": "アップロードを開始",
|
||||
"retry": "再試行",
|
||||
"allSuccess": "{count, plural, =1 {ファイルがアップロードされました} other {#個のファイルがアップロードされました}}",
|
||||
"partialSuccess": "{success}個のファイルがアップロードされ、{error}個が失敗しました",
|
||||
"dragAndDrop": "またはここにファイルをドラッグ&ドロップ"
|
||||
@@ -844,6 +855,7 @@
|
||||
"passwordLength": "パスワードは最低8文字必要です",
|
||||
"passwordsMatch": "パスワードが一致しません",
|
||||
"emailRequired": "メールアドレスは必須です",
|
||||
"emailOrUsernameRequired": "メールアドレスまたはユーザー名は必須です",
|
||||
"passwordRequired": "パスワードは必須です",
|
||||
"nameRequired": "名前は必須です",
|
||||
"required": "このフィールドは必須です"
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "このリンクは無効です。",
|
||||
"linkExpired": "このリンクは期限切れです。",
|
||||
"uploadFailed": "ファイルのアップロードに失敗しました",
|
||||
"retry": "再試行",
|
||||
"fileTooLarge": "ファイルが大きすぎます。最大サイズ: {maxSize}",
|
||||
"fileTypeNotAllowed": "このファイル形式は許可されていません。許可される形式: {allowedTypes}",
|
||||
"maxFilesExceeded": "最大 {maxFiles} ファイルまで許可されています",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "에 오신 것을 환영합니다",
|
||||
"signInToContinue": "계속하려면 로그인하세요",
|
||||
"emailOrUsernameLabel": "이메일 또는 사용자 이름",
|
||||
"emailOrUsernamePlaceholder": "이메일 또는 사용자 이름을 입력하세요",
|
||||
"emailLabel": "이메일 주소",
|
||||
"emailPlaceholder": "이메일을 입력하세요",
|
||||
"passwordLabel": "비밀번호",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "스토리지 사용량",
|
||||
"ariaLabel": "스토리지 사용량 진행 바",
|
||||
"used": "사용됨",
|
||||
"available": "사용 가능"
|
||||
"available": "사용 가능",
|
||||
"loading": "로딩 중...",
|
||||
"retry": "다시 시도",
|
||||
"errors": {
|
||||
"title": "스토리지 정보를 사용할 수 없음",
|
||||
"detectionFailed": "디스크 공간을 감지할 수 없습니다. 시스템 구성 문제 또는 권한이 부족한 것이 원인일 수 있습니다.",
|
||||
"serverError": "스토리지 정보를 검색하는 중에 서버 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
|
||||
"unknown": "스토리지 정보를 로드하는 중에 예기치 않은 오류가 발생했습니다."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "테마 전환",
|
||||
@@ -748,6 +758,7 @@
|
||||
"uploadProgress": "업로드 진행률",
|
||||
"upload": "업로드",
|
||||
"startUploads": "업로드 시작",
|
||||
"retry": "다시 시도",
|
||||
"finish": "완료",
|
||||
"success": "파일이 성공적으로 업로드되었습니다",
|
||||
"allSuccess": "{count, plural, =1 {파일이 성공적으로 업로드되었습니다} other {# 개 파일이 성공적으로 업로드되었습니다}}",
|
||||
@@ -844,6 +855,7 @@
|
||||
"passwordLength": "비밀번호는 최소 8자 이상이어야 합니다",
|
||||
"passwordsMatch": "비밀번호가 일치하지 않습니다",
|
||||
"emailRequired": "이메일은 필수입니다",
|
||||
"emailOrUsernameRequired": "이메일 또는 사용자 이름은 필수입니다",
|
||||
"passwordRequired": "비밀번호는 필수입니다",
|
||||
"nameRequired": "이름은 필수입니다",
|
||||
"required": "이 필드는 필수입니다"
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "이 링크는 비활성 상태입니다.",
|
||||
"linkExpired": "이 링크는 만료되었습니다.",
|
||||
"uploadFailed": "파일 업로드 오류",
|
||||
"retry": "다시 시도",
|
||||
"fileTooLarge": "파일이 너무 큽니다. 최대 크기: {maxSize}",
|
||||
"fileTypeNotAllowed": "허용되지 않는 파일 유형입니다. 허용된 유형: {allowedTypes}",
|
||||
"maxFilesExceeded": "최대 {maxFiles}개의 파일만 허용됩니다",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "Welkom bij",
|
||||
"signInToContinue": "Log in om door te gaan",
|
||||
"emailOrUsernameLabel": "E-mail of Gebruikersnaam",
|
||||
"emailOrUsernamePlaceholder": "Voer je e-mail of gebruikersnaam in",
|
||||
"emailLabel": "E-mailadres",
|
||||
"emailPlaceholder": "Voer je e-mail in",
|
||||
"passwordLabel": "Wachtwoord",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "Opslaggebruik",
|
||||
"ariaLabel": "Opslaggebruik voortgangsbalk",
|
||||
"used": "gebruikt",
|
||||
"available": "beschikbaar"
|
||||
"available": "beschikbaar",
|
||||
"loading": "Laden...",
|
||||
"retry": "Opnieuw proberen",
|
||||
"errors": {
|
||||
"title": "Opslaginformatie niet beschikbaar",
|
||||
"detectionFailed": "Kan schijfruimte niet detecteren. Dit kan komen door systeemconfiguratieproblemen of onvoldoende rechten.",
|
||||
"serverError": "Er is een serverfout opgetreden bij het ophalen van opslaginformatie. Probeer het later opnieuw.",
|
||||
"unknown": "Er is een onverwachte fout opgetreden bij het laden van opslaginformatie."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Thema wisselen",
|
||||
@@ -748,6 +758,7 @@
|
||||
"uploadProgress": "Upload voortgang",
|
||||
"upload": "Uploaden",
|
||||
"startUploads": "Uploads Starten",
|
||||
"retry": "Opnieuw Proberen",
|
||||
"finish": "Voltooien",
|
||||
"success": "Bestand succesvol geüpload",
|
||||
"allSuccess": "{count, plural, =1 {Bestand succesvol geüpload} other {# bestanden succesvol geüpload}}",
|
||||
@@ -843,6 +854,7 @@
|
||||
"passwordLength": "Wachtwoord moet minimaal 8 tekens zijn",
|
||||
"passwordsMatch": "Wachtwoorden moeten overeenkomen",
|
||||
"emailRequired": "E-mail is verplicht",
|
||||
"emailOrUsernameRequired": "E-mail of gebruikersnaam is verplicht",
|
||||
"passwordRequired": "Wachtwoord is verplicht",
|
||||
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
|
||||
"nameRequired": "Naam is verplicht",
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "Deze link is inactief.",
|
||||
"linkExpired": "Deze link is verlopen.",
|
||||
"uploadFailed": "Fout bij uploaden bestand",
|
||||
"retry": "Opnieuw Proberen",
|
||||
"fileTooLarge": "Bestand te groot. Maximum grootte: {maxSize}",
|
||||
"fileTypeNotAllowed": "Bestandstype niet toegestaan. Toegestane types: {allowedTypes}",
|
||||
"maxFilesExceeded": "Maximum van {maxFiles} bestanden toegestaan",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "Witaj w",
|
||||
"signInToContinue": "Zaloguj się, aby kontynuować",
|
||||
"emailOrUsernameLabel": "E-mail lub nazwa użytkownika",
|
||||
"emailOrUsernamePlaceholder": "Wprowadź swój e-mail lub nazwę użytkownika",
|
||||
"emailLabel": "Adres e-mail",
|
||||
"emailPlaceholder": "Wprowadź swój adres e-mail",
|
||||
"passwordLabel": "Hasło",
|
||||
@@ -788,7 +790,15 @@
|
||||
"title": "Użycie pamięci",
|
||||
"ariaLabel": "Pasek postępu użycia pamięci",
|
||||
"used": "użyte",
|
||||
"available": "dostępne"
|
||||
"available": "dostępne",
|
||||
"loading": "Ładowanie...",
|
||||
"retry": "Spróbuj ponownie",
|
||||
"errors": {
|
||||
"title": "Informacje o pamięci niedostępne",
|
||||
"detectionFailed": "Nie można wykryć miejsca na dysku. Może to być spowodowane problemami z konfiguracją systemu lub niewystarczającymi uprawnieniami.",
|
||||
"serverError": "Wystąpił błąd serwera podczas pobierania informacji o pamięci. Spróbuj ponownie później.",
|
||||
"unknown": "Wystąpił nieoczekiwany błąd podczas ładowania informacji o pamięci."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Przełącz motyw",
|
||||
@@ -806,6 +816,7 @@
|
||||
"uploadProgress": "Postęp przesyłania",
|
||||
"upload": "Prześlij",
|
||||
"startUploads": "Rozpocznij przesyłanie",
|
||||
"retry": "Spróbuj Ponownie",
|
||||
"finish": "Zakończ",
|
||||
"success": "Plik przesłany pomyślnie",
|
||||
"allSuccess": "{count, plural, =1 {Plik przesłany pomyślnie} other {# plików przesłanych pomyślnie}}",
|
||||
@@ -901,6 +912,7 @@
|
||||
"passwordLength": "Hasło musi mieć co najmniej 8 znaków",
|
||||
"passwordsMatch": "Hasła muszą być zgodne",
|
||||
"emailRequired": "E-mail jest wymagany",
|
||||
"emailOrUsernameRequired": "E-mail lub nazwa użytkownika jest wymagana",
|
||||
"passwordRequired": "Hasło jest wymagane",
|
||||
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
|
||||
"nameRequired": "Nazwa jest wymagana",
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "Ten link jest nieaktywny.",
|
||||
"linkExpired": "Ten link wygasł.",
|
||||
"uploadFailed": "Błąd przesyłania pliku",
|
||||
"retry": "Spróbuj Ponownie",
|
||||
"fileTooLarge": "Plik za duży. Maksymalny rozmiar: {maxSize}",
|
||||
"fileTypeNotAllowed": "Typ pliku niedozwolony. Akceptowane typy: {allowedTypes}",
|
||||
"maxFilesExceeded": "Dozwolono maksymalnie {maxFiles} plików",
|
||||
@@ -1342,4 +1355,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,17 +18,17 @@
|
||||
"click": "Clique para"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Criar Compartilhamento",
|
||||
"nameLabel": "Nome do Compartilhamento",
|
||||
"title": "Criar compartilhamento",
|
||||
"nameLabel": "Nome do compartilhamento",
|
||||
"descriptionLabel": "Descrição",
|
||||
"descriptionPlaceholder": "Digite uma descrição (opcional)",
|
||||
"expirationLabel": "Data de Expiração",
|
||||
"expirationLabel": "Data de expiração",
|
||||
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
|
||||
"maxViewsLabel": "Máximo de Visualizações",
|
||||
"maxViewsLabel": "Máximo de visualizações",
|
||||
"maxViewsPlaceholder": "Deixe vazio para ilimitado",
|
||||
"passwordProtection": "Protegido por Senha",
|
||||
"passwordLabel": "Senha",
|
||||
"create": "Criar Compartilhamento",
|
||||
"create": "Criar compartilhamento",
|
||||
"success": "Compartilhamento criado com sucesso",
|
||||
"error": "Falha ao criar compartilhamento"
|
||||
},
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"emptyState": {
|
||||
"noFiles": "Nenhum arquivo enviado ainda",
|
||||
"uploadFile": "Enviar Arquivo"
|
||||
"uploadFile": "Enviar arquivo"
|
||||
},
|
||||
"errors": {
|
||||
"invalidCredentials": "E-mail ou senha inválidos",
|
||||
@@ -53,13 +53,13 @@
|
||||
"unexpectedError": "Ocorreu um erro inesperado. Por favor, tente novamente"
|
||||
},
|
||||
"fileActions": {
|
||||
"editFile": "Editar Arquivo",
|
||||
"editFile": "Editar arquivo",
|
||||
"nameLabel": "Nome",
|
||||
"namePlaceholder": "Digite o novo nome",
|
||||
"extension": "Extensão",
|
||||
"descriptionLabel": "Descrição",
|
||||
"descriptionPlaceholder": "Digite a descrição do arquivo",
|
||||
"deleteFile": "Excluir Arquivo",
|
||||
"deleteFile": "Excluir arquivo",
|
||||
"deleteConfirmation": "Tem certeza que deseja excluir ?",
|
||||
"deleteWarning": "Esta ação não pode ser desfeita."
|
||||
},
|
||||
@@ -154,9 +154,9 @@
|
||||
"bulkActions": {
|
||||
"selected": "{count, plural, =1 {1 arquivo selecionado} other {# arquivos selecionados}}",
|
||||
"actions": "Ações",
|
||||
"download": "Baixar Selecionados",
|
||||
"share": "Compartilhar Selecionados",
|
||||
"delete": "Excluir Selecionados"
|
||||
"download": "Baixar selecionados",
|
||||
"share": "Compartilhar selecionados",
|
||||
"delete": "Excluir selecionados"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
@@ -175,23 +175,23 @@
|
||||
"pageTitle": "Esqueceu a Senha"
|
||||
},
|
||||
"generateShareLink": {
|
||||
"generateTitle": "Gerar Link de Compartilhamento",
|
||||
"updateTitle": "Atualizar Link de Compartilhamento",
|
||||
"generateTitle": "Gerar link de compartilhamento",
|
||||
"updateTitle": "Atualizar link de compartilhamento",
|
||||
"generateDescription": "Gere um link para compartilhar seus arquivos",
|
||||
"updateDescription": "Atualize o alias deste link de compartilhamento",
|
||||
"aliasPlaceholder": "Digite o alias",
|
||||
"linkReady": "Seu link de compartilhamento está pronto:",
|
||||
"generateButton": "Gerar Link",
|
||||
"updateButton": "Atualizar Link",
|
||||
"copyButton": "Copiar Link",
|
||||
"generateButton": "Gerar link",
|
||||
"updateButton": "Atualizar link",
|
||||
"copyButton": "Copiar link",
|
||||
"success": "Link gerado com sucesso",
|
||||
"error": "Erro ao gerar link",
|
||||
"copied": "Link copiado para a área de transferência"
|
||||
},
|
||||
"shareFile": {
|
||||
"title": "Compartilhar Arquivo",
|
||||
"linkTitle": "Gerar Link",
|
||||
"nameLabel": "Nome do Compartilhamento",
|
||||
"title": "Compartilhar arquivo",
|
||||
"linkTitle": "Gerar link",
|
||||
"nameLabel": "Nome do compartilhamento",
|
||||
"namePlaceholder": "Digite o nome do compartilhamento",
|
||||
"descriptionLabel": "Descrição",
|
||||
"descriptionPlaceholder": "Digite uma descrição (opcional)",
|
||||
@@ -199,16 +199,16 @@
|
||||
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
|
||||
"maxViewsLabel": "Máximo de Visualizações",
|
||||
"maxViewsPlaceholder": "Deixe vazio para ilimitado",
|
||||
"passwordProtection": "Protegido por Senha",
|
||||
"passwordProtection": "Protegido por senha",
|
||||
"passwordLabel": "Senha",
|
||||
"passwordPlaceholder": "Digite a senha",
|
||||
"linkDescription": "Gere um link personalizado para compartilhar o arquivo",
|
||||
"aliasLabel": "Alias do Link",
|
||||
"aliasLabel": "Alias do link",
|
||||
"aliasPlaceholder": "Digite um alias personalizado",
|
||||
"linkReady": "Seu link de compartilhamento está pronto:",
|
||||
"createShare": "Criar Compartilhamento",
|
||||
"generateLink": "Gerar Link",
|
||||
"copyLink": "Copiar Link"
|
||||
"createShare": "Criar compartilhamento",
|
||||
"generateLink": "Gerar link",
|
||||
"copyLink": "Copiar link"
|
||||
},
|
||||
"home": {
|
||||
"description": "A alternativa open-source ao WeTransfer. Compartilhe arquivos com segurança, sem rastreamento ou limitações.",
|
||||
@@ -223,7 +223,9 @@
|
||||
},
|
||||
"login": {
|
||||
"welcome": "Bem-vindo ao",
|
||||
"signInToContinue": "Entre para continuar",
|
||||
"signInToContinue": "Faça login para continuar",
|
||||
"emailOrUsernameLabel": "E-mail ou Nome de Usuário",
|
||||
"emailOrUsernamePlaceholder": "Digite seu e-mail ou nome de usuário",
|
||||
"emailLabel": "Endereço de E-mail",
|
||||
"emailPlaceholder": "Digite seu e-mail",
|
||||
"passwordLabel": "Senha",
|
||||
@@ -231,18 +233,18 @@
|
||||
"signIn": "Entrar",
|
||||
"signingIn": "Entrando...",
|
||||
"forgotPassword": "Esqueceu a senha?",
|
||||
"pageTitle": "Entrar",
|
||||
"pageTitle": "Login",
|
||||
"or": "ou",
|
||||
"continueWithSSO": "Continuar com SSO",
|
||||
"processing": "Processando autenticação..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
"appLogo": "Logo do Aplicativo"
|
||||
"appLogo": "Logo do aplicativo"
|
||||
},
|
||||
"buttons": {
|
||||
"upload": "Enviar Logo",
|
||||
"remove": "Remover Logo"
|
||||
"upload": "Enviar logo",
|
||||
"remove": "Remover logo"
|
||||
},
|
||||
"messages": {
|
||||
"uploadSuccess": "Logo enviado com sucesso",
|
||||
@@ -254,11 +256,11 @@
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"logoAlt": "Logo do Aplicativo",
|
||||
"logoAlt": "Logo do aplicativo",
|
||||
"profileMenu": "Menu do Perfil",
|
||||
"profile": "Perfil",
|
||||
"settings": "Configurações",
|
||||
"usersManagement": "Gerenciar Usuários",
|
||||
"usersManagement": "Gerenciar usuários",
|
||||
"logout": "Sair"
|
||||
},
|
||||
"navigation": {
|
||||
@@ -752,7 +754,15 @@
|
||||
"title": "Uso de Armazenamento",
|
||||
"ariaLabel": "Barra de progresso do uso de armazenamento",
|
||||
"used": "usado",
|
||||
"available": "disponível"
|
||||
"available": "disponível",
|
||||
"loading": "Carregando...",
|
||||
"retry": "Tentar novamente",
|
||||
"errors": {
|
||||
"title": "Informações de armazenamento indisponíveis",
|
||||
"detectionFailed": "Não foi possível detectar o espaço em disco. Isso pode ser devido a problemas de configuração do sistema ou permissões insuficientes.",
|
||||
"serverError": "Ocorreu um erro no servidor ao recuperar as informações de armazenamento. Por favor, tente novamente mais tarde.",
|
||||
"unknown": "Ocorreu um erro inesperado ao carregar as informações de armazenamento."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Alternar tema",
|
||||
@@ -770,6 +780,7 @@
|
||||
"uploadProgress": "Progresso do upload",
|
||||
"upload": "Enviar",
|
||||
"startUploads": "Iniciar Uploads",
|
||||
"retry": "Tentar Novamente",
|
||||
"finish": "Concluir",
|
||||
"success": "Arquivo enviado com sucesso",
|
||||
"allSuccess": "{count, plural, =1 {Arquivo enviado com sucesso} other {# arquivos enviados com sucesso}}",
|
||||
@@ -857,18 +868,15 @@
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"invalidEmail": "Endereço de email inválido",
|
||||
"passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
|
||||
"firstNameRequired": "Nome é obrigatório",
|
||||
"lastNameRequired": "Sobrenome é obrigatório",
|
||||
"usernameLength": "Nome de usuário deve ter pelo menos 3 caracteres",
|
||||
"usernameSpaces": "Nome de usuário não pode conter espaços",
|
||||
"invalidEmail": "Por favor, insira um endereço de e-mail válido",
|
||||
"passwordLength": "A senha deve ter pelo menos 8 caracteres",
|
||||
"passwordsMatch": "As senhas não coincidem",
|
||||
"passwordsMatch": "As senhas devem coincidir",
|
||||
"emailRequired": "Email é obrigatório",
|
||||
"emailOrUsernameRequired": "E-mail ou nome de usuário é obrigatório",
|
||||
"passwordRequired": "Senha é obrigatória",
|
||||
"required": "Este campo é obrigatório",
|
||||
"nameRequired": "Nome é obrigatório"
|
||||
"passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
|
||||
"nameRequired": "Nome é obrigatório",
|
||||
"required": "Este campo é obrigatório"
|
||||
},
|
||||
"bulkDownload": {
|
||||
"title": "Download em Lote",
|
||||
@@ -937,8 +945,8 @@
|
||||
"noExpiration": "Este compartilhamento nunca expirará e permanecerá acessível indefinidamente.",
|
||||
"title": "Sobre expiração:"
|
||||
},
|
||||
"enableExpiration": "Habilitar Expiração",
|
||||
"title": "Configurações de Expiração do Compartilhamento",
|
||||
"enableExpiration": "Habilitar expiração",
|
||||
"title": "Configurações de expiração do compartilhamento",
|
||||
"subtitle": "Configurar quando este compartilhamento expirará",
|
||||
"validation": {
|
||||
"dateMustBeFuture": "A data de expiração deve estar no futuro",
|
||||
@@ -949,7 +957,7 @@
|
||||
"updateFailed": "Falha ao atualizar configurações de expiração"
|
||||
},
|
||||
"expires": "Expira:",
|
||||
"expirationDate": "Data de Expiração"
|
||||
"expirationDate": "Data de expiração"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
@@ -961,10 +969,10 @@
|
||||
}
|
||||
},
|
||||
"reverseShares": {
|
||||
"pageTitle": "Receber Arquivos",
|
||||
"pageTitle": "Receber arquivos",
|
||||
"search": {
|
||||
"title": "Gerenciar Links de Recebimento",
|
||||
"createButton": "Criar Link",
|
||||
"title": "Gerenciar links de recebimento",
|
||||
"createButton": "Criar link",
|
||||
"placeholder": "Buscar links de recebimento...",
|
||||
"results": "Encontrados {filtered} de {total} links de recebimento"
|
||||
},
|
||||
@@ -974,13 +982,13 @@
|
||||
"status": "status",
|
||||
"access": "acesso",
|
||||
"description": "Descrição",
|
||||
"pageLayout": "Layout da Página",
|
||||
"pageLayout": "Layout da página",
|
||||
"security": "Segurança & Status",
|
||||
"limits": "Limites",
|
||||
"maxFiles": "Máximo de Arquivos",
|
||||
"maxFileSize": "Tamanho Máximo",
|
||||
"allowedTypes": "Tipos Permitidos",
|
||||
"filesReceived": "Arquivos Recebidos",
|
||||
"maxFiles": "Máximo de arquivos",
|
||||
"maxFileSize": "Tamanho máximo",
|
||||
"allowedTypes": "Tipos permitidos",
|
||||
"filesReceived": "Arquivos recebidos",
|
||||
"fileLimit": "Limite de Arquivos",
|
||||
"noLimit": "Sem limite",
|
||||
"noLinkCreated": "Nenhum link criado",
|
||||
@@ -1269,6 +1277,7 @@
|
||||
"linkInactive": "Este link está inativo.",
|
||||
"linkExpired": "Este link expirou.",
|
||||
"uploadFailed": "Erro ao enviar arquivo",
|
||||
"retry": "Tentar Novamente",
|
||||
"fileTooLarge": "Arquivo muito grande. Tamanho máximo: {maxSize}",
|
||||
"fileTypeNotAllowed": "Tipo de arquivo não permitido. Tipos aceitos: {allowedTypes}",
|
||||
"maxFilesExceeded": "Máximo de {maxFiles} arquivos permitidos",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "Добро пожаловать в",
|
||||
"signInToContinue": "Войдите, чтобы продолжить",
|
||||
"emailOrUsernameLabel": "Электронная почта или имя пользователя",
|
||||
"emailOrUsernamePlaceholder": "Введите электронную почту или имя пользователя",
|
||||
"emailLabel": "Адрес электронной почты",
|
||||
"emailPlaceholder": "Введите вашу электронную почту",
|
||||
"passwordLabel": "Пароль",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "Использование хранилища",
|
||||
"ariaLabel": "Индикатор использования хранилища",
|
||||
"used": "Использовано",
|
||||
"available": "Доступно"
|
||||
"available": "Доступно",
|
||||
"loading": "Загрузка...",
|
||||
"retry": "Повторить",
|
||||
"errors": {
|
||||
"title": "Информация о хранилище недоступна",
|
||||
"detectionFailed": "Не удалось определить свободное место на диске. Это может быть связано с проблемами конфигурации системы или недостаточными правами доступа.",
|
||||
"serverError": "Произошла ошибка сервера при получении информации о хранилище. Пожалуйста, повторите попытку позже.",
|
||||
"unknown": "Произошла непредвиденная ошибка при загрузке информации о хранилище."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Переключить тему",
|
||||
@@ -748,6 +758,7 @@
|
||||
"uploadProgress": "Прогресс загрузки",
|
||||
"upload": "Загрузить",
|
||||
"startUploads": "Начать Загрузку",
|
||||
"retry": "Повторить",
|
||||
"finish": "Завершить",
|
||||
"success": "Файл успешно загружен",
|
||||
"allSuccess": "{count, plural, =1 {Файл успешно загружен} other {# файлов успешно загружено}}",
|
||||
@@ -844,6 +855,7 @@
|
||||
"passwordLength": "Пароль должен содержать не менее 8 символов",
|
||||
"passwordsMatch": "Пароли не совпадают",
|
||||
"emailRequired": "Требуется электронная почта",
|
||||
"emailOrUsernameRequired": "Электронная почта или имя пользователя обязательно",
|
||||
"passwordRequired": "Требуется пароль",
|
||||
"nameRequired": "Требуется имя",
|
||||
"required": "Это поле обязательно"
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "Эта ссылка неактивна.",
|
||||
"linkExpired": "Срок действия этой ссылки истек.",
|
||||
"uploadFailed": "Ошибка при загрузке файла",
|
||||
"retry": "Повторить",
|
||||
"fileTooLarge": "Файл слишком большой. Максимальный размер: {maxSize}",
|
||||
"fileTypeNotAllowed": "Тип файла не разрешен. Разрешенные типы: {allowedTypes}",
|
||||
"maxFilesExceeded": "Максимально разрешено {maxFiles} файлов",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "Hoş geldiniz'e",
|
||||
"signInToContinue": "Devam etmek için oturum açın",
|
||||
"emailOrUsernameLabel": "E-posta veya Kullanıcı Adı",
|
||||
"emailOrUsernamePlaceholder": "E-posta veya kullanıcı adınızı girin",
|
||||
"emailLabel": "E-posta Adresi",
|
||||
"emailPlaceholder": "E-posta adresinizi girin",
|
||||
"passwordLabel": "Şifre",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "Depolama Kullanımı",
|
||||
"ariaLabel": "Depolama kullanım ilerleme çubuğu",
|
||||
"used": "kullanıldı",
|
||||
"available": "kullanılabilir"
|
||||
"available": "kullanılabilir",
|
||||
"loading": "Yükleniyor...",
|
||||
"retry": "Tekrar Dene",
|
||||
"errors": {
|
||||
"title": "Depolama bilgisi kullanılamıyor",
|
||||
"detectionFailed": "Disk alanı tespit edilemiyor. Bu, sistem yapılandırma sorunlarından veya yetersiz izinlerden kaynaklanıyor olabilir.",
|
||||
"serverError": "Depolama bilgisi alınırken sunucu hatası oluştu. Lütfen daha sonra tekrar deneyin.",
|
||||
"unknown": "Depolama bilgisi yüklenirken beklenmeyen bir hata oluştu."
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Temayı değiştir",
|
||||
@@ -748,6 +758,7 @@
|
||||
"uploadProgress": "Yükleme ilerlemesi",
|
||||
"upload": "Yükle",
|
||||
"startUploads": "Yüklemeleri Başlat",
|
||||
"retry": "Tekrar Dene",
|
||||
"finish": "Bitir",
|
||||
"success": "Dosya başarıyla yüklendi",
|
||||
"allSuccess": "{count, plural, =1 {Dosya başarıyla yüklendi} other {# dosya başarıyla yüklendi}}",
|
||||
@@ -844,6 +855,7 @@
|
||||
"passwordLength": "Şifre en az 8 karakter olmalıdır",
|
||||
"passwordsMatch": "Şifreler eşleşmiyor",
|
||||
"emailRequired": "E-posta gerekli",
|
||||
"emailOrUsernameRequired": "E-posta veya kullanıcı adı gereklidir",
|
||||
"passwordRequired": "Şifre gerekli",
|
||||
"nameRequired": "İsim gereklidir",
|
||||
"required": "Bu alan zorunludur"
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "Bu bağlantı pasif durumda.",
|
||||
"linkExpired": "Bu bağlantının süresi doldu.",
|
||||
"uploadFailed": "Dosya yüklenirken hata oluştu",
|
||||
"retry": "Tekrar Dene",
|
||||
"fileTooLarge": "Dosya çok büyük. Maksimum boyut: {maxSize}",
|
||||
"fileTypeNotAllowed": "Dosya türüne izin verilmiyor. İzin verilen türler: {allowedTypes}",
|
||||
"maxFilesExceeded": "Maksimum {maxFiles} dosyaya izin veriliyor",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"login": {
|
||||
"welcome": "欢迎您",
|
||||
"signInToContinue": "请登录以继续",
|
||||
"emailOrUsernameLabel": "电子邮件或用户名",
|
||||
"emailOrUsernamePlaceholder": "请输入您的电子邮件或用户名",
|
||||
"emailLabel": "电子邮件地址",
|
||||
"emailPlaceholder": "请输入您的电子邮件",
|
||||
"passwordLabel": "密码",
|
||||
@@ -730,7 +732,15 @@
|
||||
"title": "存储使用情况",
|
||||
"ariaLabel": "存储使用进度条",
|
||||
"used": "已使用:",
|
||||
"available": "可用:"
|
||||
"available": "可用",
|
||||
"loading": "加载中...",
|
||||
"retry": "重试",
|
||||
"errors": {
|
||||
"title": "存储信息不可用",
|
||||
"detectionFailed": "无法检测磁盘空间。这可能是由于系统配置问题或权限不足。",
|
||||
"serverError": "检索存储信息时发生服务器错误。请稍后重试。",
|
||||
"unknown": "加载存储信息时发生意外错误。"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切换主题",
|
||||
@@ -748,6 +758,7 @@
|
||||
"uploadProgress": "上传进度",
|
||||
"upload": "上传",
|
||||
"startUploads": "开始上传",
|
||||
"retry": "重试",
|
||||
"finish": "完成",
|
||||
"success": "文件上传成功",
|
||||
"allSuccess": "{count, plural, =1 {文件上传成功} other {# 个文件上传成功}}",
|
||||
@@ -844,6 +855,7 @@
|
||||
"passwordLength": "密码至少需要8个字符",
|
||||
"passwordsMatch": "密码不匹配",
|
||||
"emailRequired": "电子邮件为必填项",
|
||||
"emailOrUsernameRequired": "电子邮件或用户名是必填项",
|
||||
"passwordRequired": "密码为必填项",
|
||||
"nameRequired": "名称为必填项",
|
||||
"required": "此字段为必填项"
|
||||
@@ -1269,6 +1281,7 @@
|
||||
"linkInactive": "此链接已停用。",
|
||||
"linkExpired": "此链接已过期。",
|
||||
"uploadFailed": "上传文件时出错",
|
||||
"retry": "重试",
|
||||
"fileTooLarge": "文件太大。最大大小:{maxSize}",
|
||||
"fileTypeNotAllowed": "不允许的文件类型。允许的类型:{allowedTypes}",
|
||||
"maxFilesExceeded": "最多允许 {maxFiles} 个文件",
|
||||
|
||||
@@ -156,13 +156,11 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
const { file } = fileWithProgress;
|
||||
|
||||
try {
|
||||
// Start upload
|
||||
updateFileStatus(index, {
|
||||
status: FILE_STATUS.UPLOADING,
|
||||
progress: UPLOAD_PROGRESS.INITIAL,
|
||||
});
|
||||
|
||||
// Generate object name and get presigned URL
|
||||
const objectName = generateObjectName(file.name);
|
||||
const presignedResponse = await getPresignedUrlForUploadByAlias(
|
||||
alias,
|
||||
@@ -170,16 +168,12 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
password ? { password } : undefined
|
||||
);
|
||||
|
||||
// Upload to storage
|
||||
await uploadFileToStorage(file, presignedResponse.data.url);
|
||||
|
||||
// Update progress
|
||||
updateFileStatus(index, { progress: UPLOAD_PROGRESS.COMPLETE });
|
||||
|
||||
// Register file upload
|
||||
await registerUploadedFile(file, objectName);
|
||||
|
||||
// Mark as successful
|
||||
updateFileStatus(index, { status: FILE_STATUS.SUCCESS });
|
||||
} catch (error: any) {
|
||||
console.error("Upload error:", error);
|
||||
@@ -243,7 +237,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
);
|
||||
const hasSuccessfulUploads = files.some((file) => file.status === FILE_STATUS.SUCCESS);
|
||||
|
||||
// Call onUploadSuccess when all files are processed and there are successful uploads
|
||||
useEffect(() => {
|
||||
if (allFilesProcessed && hasSuccessfulUploads && files.length > 0) {
|
||||
onUploadSuccess?.();
|
||||
@@ -266,7 +259,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
};
|
||||
|
||||
const renderFileRestrictions = () => {
|
||||
// Calculate remaining files that can be uploaded
|
||||
const calculateRemainingFiles = (): number => {
|
||||
if (!reverseShare.maxFiles) return 0;
|
||||
const currentTotal = reverseShare.currentFileCount + files.length;
|
||||
@@ -339,13 +331,34 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{fileWithProgress.status === FILE_STATUS.ERROR && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setFiles((prev) =>
|
||||
prev.map((file, i) =>
|
||||
i === index ? { ...file, status: FILE_STATUS.PENDING, error: undefined } : file
|
||||
)
|
||||
);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
title={t("reverseShares.upload.retry")}
|
||||
>
|
||||
<IconUpload className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* File Drop Zone */}
|
||||
<div {...getRootProps()} className={getDropzoneStyles()}>
|
||||
<input {...getInputProps()} />
|
||||
<IconUpload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
@@ -357,7 +370,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
{renderFileRestrictions()}
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{t("reverseShares.upload.fileList.title")}</h4>
|
||||
@@ -365,7 +377,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Information */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -409,14 +420,12 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
<Button onClick={handleUpload} disabled={!canUpload} className="w-full text-white" size="lg" variant="default">
|
||||
{isUploading
|
||||
? t("reverseShares.upload.form.uploading")
|
||||
: t("reverseShares.upload.form.uploadButton", { count: files.length })}
|
||||
</Button>
|
||||
|
||||
{/* Success Message */}
|
||||
{allFilesProcessed && hasSuccessfulUploads && (
|
||||
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||
<p className="text-green-800 dark:text-green-200 font-medium">{t("reverseShares.upload.success.title")}</p>
|
||||
|
||||
@@ -66,7 +66,6 @@ export function WeTransferStatusMessage({
|
||||
}: WeTransferStatusMessageProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
// Map message types to variants
|
||||
const getVariant = (): "success" | "warning" | "error" | "info" | "neutral" => {
|
||||
switch (type) {
|
||||
case MESSAGE_TYPES.SUCCESS:
|
||||
|
||||
@@ -12,13 +12,11 @@ import { FileUploadSection } from "./file-upload-section";
|
||||
import { WeTransferStatusMessage } from "./shared/status-message";
|
||||
import { TransparentFooter } from "./transparent-footer";
|
||||
|
||||
// Função para escolher uma imagem aleatória
|
||||
const getRandomBackgroundImage = (): string => {
|
||||
const randomIndex = Math.floor(Math.random() * BACKGROUND_IMAGES.length);
|
||||
return BACKGROUND_IMAGES[randomIndex];
|
||||
};
|
||||
|
||||
// Hook para gerenciar a imagem de background
|
||||
const useBackgroundImage = () => {
|
||||
const [selectedImage, setSelectedImage] = useState<string>("");
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
@@ -42,7 +40,6 @@ const useBackgroundImage = () => {
|
||||
return { selectedImage, imageLoaded };
|
||||
};
|
||||
|
||||
// Componente para controles do header
|
||||
const HeaderControls = () => (
|
||||
<div className="absolute top-4 right-4 md:top-6 md:right-6 z-40 flex items-center gap-2">
|
||||
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
|
||||
@@ -54,7 +51,6 @@ const HeaderControls = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
// Componente para o fundo com imagem
|
||||
const BackgroundLayer = ({ selectedImage, imageLoaded }: { selectedImage: string; imageLoaded: boolean }) => (
|
||||
<>
|
||||
<div className="absolute inset-0 z-0 bg-background" />
|
||||
@@ -162,18 +158,15 @@ export function WeTransferLayout({
|
||||
<BackgroundLayer selectedImage={selectedImage} imageLoaded={imageLoaded} />
|
||||
<HeaderControls />
|
||||
|
||||
{/* Loading indicator */}
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center">
|
||||
<div className="animate-pulse text-white/70 text-sm">{t("reverseShares.upload.layout.loading")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="relative z-30 min-h-screen flex items-center justify-start p-4 md:p-8 lg:p-12 xl:p-16">
|
||||
<div className="w-full max-w-md lg:max-w-lg xl:max-w-xl">
|
||||
<div className="bg-white dark:bg-black rounded-2xl shadow-2xl p-6 md:p-8 backdrop-blur-sm border border-white/20">
|
||||
{/* Header */}
|
||||
<div className="text-left mb-6 md:mb-8">
|
||||
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{reverseShare?.name || t("reverseShares.upload.layout.defaultTitle")}
|
||||
@@ -183,7 +176,6 @@ export function WeTransferLayout({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Section */}
|
||||
{getUploadSectionContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// HTTP Status Constants
|
||||
export const HTTP_STATUS = {
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
@@ -6,13 +5,11 @@ export const HTTP_STATUS = {
|
||||
GONE: 410,
|
||||
} as const;
|
||||
|
||||
// Error Messages
|
||||
export const ERROR_MESSAGES = {
|
||||
PASSWORD_REQUIRED: "Password required",
|
||||
INVALID_PASSWORD: "Invalid password",
|
||||
} as const;
|
||||
|
||||
// Error types
|
||||
export type ErrorType = "inactive" | "notFound" | "expired" | "generic" | null;
|
||||
|
||||
export const STATUS_VARIANTS = {
|
||||
|
||||
@@ -17,7 +17,6 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
// States
|
||||
const [reverseShare, setReverseShare] = useState<ReverseShareInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
||||
@@ -25,7 +24,6 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
|
||||
const [hasUploadedSuccessfully, setHasUploadedSuccessfully] = useState(false);
|
||||
const [error, setError] = useState<{ type: ErrorType }>({ type: null });
|
||||
|
||||
// Utility functions
|
||||
const redirectToHome = () => router.push("/");
|
||||
|
||||
const checkIfMaxFilesReached = (reverseShareData: ReverseShareInfo): boolean => {
|
||||
@@ -109,23 +107,19 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
|
||||
}
|
||||
}, [alias]);
|
||||
|
||||
// Computed values
|
||||
const isMaxFilesReached = reverseShare ? checkIfMaxFilesReached(reverseShare) : false;
|
||||
const isWeTransferLayout = reverseShare?.pageLayout === "WETRANSFER";
|
||||
const hasError = error.type !== null || (!reverseShare && !isLoading && !isPasswordModalOpen);
|
||||
|
||||
// Error state booleans for backward compatibility
|
||||
const isLinkInactive = error.type === "inactive";
|
||||
const isLinkNotFound = error.type === "notFound" || (!reverseShare && !isLoading && !isPasswordModalOpen);
|
||||
const isLinkExpired = error.type === "expired";
|
||||
|
||||
return {
|
||||
// Data
|
||||
reverseShare,
|
||||
currentPassword,
|
||||
alias,
|
||||
|
||||
// States
|
||||
isLoading,
|
||||
isPasswordModalOpen,
|
||||
hasUploadedSuccessfully,
|
||||
@@ -134,12 +128,10 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
|
||||
isWeTransferLayout,
|
||||
hasError,
|
||||
|
||||
// Error states (for backward compatibility)
|
||||
isLinkInactive,
|
||||
isLinkNotFound,
|
||||
isLinkExpired,
|
||||
|
||||
// Actions
|
||||
handlePasswordSubmit,
|
||||
handlePasswordModalClose,
|
||||
handleUploadSuccess,
|
||||
|
||||
@@ -27,19 +27,16 @@ export default function ReverseShareUploadPage() {
|
||||
handleUploadSuccess,
|
||||
} = useReverseShareUpload({ alias: shareAlias });
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Password required state
|
||||
if (isPasswordModalOpen) {
|
||||
return (
|
||||
<PasswordModal isOpen={isPasswordModalOpen} onSubmit={handlePasswordSubmit} onClose={handlePasswordModalClose} />
|
||||
);
|
||||
}
|
||||
|
||||
// Error states or missing data - always use DefaultLayout for simplicity
|
||||
if (hasError) {
|
||||
return (
|
||||
<DefaultLayout
|
||||
@@ -56,7 +53,6 @@ export default function ReverseShareUploadPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Render appropriate layout for normal states
|
||||
if (isWeTransferLayout) {
|
||||
return (
|
||||
<WeTransferLayout
|
||||
|
||||
@@ -37,20 +37,12 @@ import { ReverseShare } from "../hooks/use-reverse-shares";
|
||||
import { FileSizeInput } from "./file-size-input";
|
||||
import { FileTypesTagsInput } from "./file-types-tags-input";
|
||||
|
||||
// Constants
|
||||
const DEFAULT_VALUES = {
|
||||
EMPTY_STRING: "",
|
||||
ZERO_STRING: "0",
|
||||
PAGE_LAYOUT: "DEFAULT" as const,
|
||||
} as const;
|
||||
|
||||
const FORM_SECTIONS = {
|
||||
BASIC_INFO: "basicInfo",
|
||||
EXPIRATION: "expiration",
|
||||
FILE_LIMITS: "fileLimits",
|
||||
PASSWORD: "password",
|
||||
} as const;
|
||||
|
||||
interface EditReverseShareFormData {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -168,7 +160,6 @@ export function EditReverseShareModal({
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function getFormDefaultValues(): EditReverseShareFormData {
|
||||
return {
|
||||
name: DEFAULT_VALUES.EMPTY_STRING,
|
||||
@@ -224,19 +215,16 @@ function buildUpdatePayload(data: EditReverseShareFormData, id: string): UpdateR
|
||||
isActive: data.isActive,
|
||||
};
|
||||
|
||||
// Add optional fields
|
||||
if (data.description?.trim()) {
|
||||
payload.description = data.description.trim();
|
||||
}
|
||||
|
||||
// Handle expiration
|
||||
if (data.hasExpiration && data.expiration) {
|
||||
payload.expiration = new Date(data.expiration).toISOString();
|
||||
} else if (!data.hasExpiration) {
|
||||
payload.expiration = undefined;
|
||||
}
|
||||
|
||||
// Handle file limits
|
||||
if (data.hasFileLimits) {
|
||||
payload.maxFiles = parsePositiveIntegerOrNull(data.maxFiles);
|
||||
payload.maxFileSize = parsePositiveIntegerOrNull(data.maxFileSize);
|
||||
@@ -245,10 +233,8 @@ function buildUpdatePayload(data: EditReverseShareFormData, id: string): UpdateR
|
||||
payload.maxFileSize = null;
|
||||
}
|
||||
|
||||
// Handle allowed file types
|
||||
payload.allowedFileTypes = data.allowedFileTypes?.trim() || null;
|
||||
|
||||
// Handle password
|
||||
if (data.hasPassword && data.password) {
|
||||
payload.password = data.password;
|
||||
} else if (!data.hasPassword) {
|
||||
@@ -289,7 +275,6 @@ function createLimitCheckbox(id: string, checked: boolean, onChange: (checked: b
|
||||
);
|
||||
}
|
||||
|
||||
// Section Components
|
||||
function BasicInfoSection({ form, t }: { form: any; t: any }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -442,7 +427,6 @@ function FileLimitsSection({
|
||||
|
||||
{hasFileLimits && (
|
||||
<div className="space-y-4">
|
||||
{/* Max Files Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxFiles"
|
||||
@@ -479,7 +463,6 @@ function FileLimitsSection({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Max File Size Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxFileSize"
|
||||
@@ -515,7 +498,6 @@ function FileLimitsSection({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Allowed File Types Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowedFileTypes"
|
||||
|
||||
@@ -34,10 +34,8 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
|
||||
const value = numBytes / multiplier;
|
||||
|
||||
if (value >= 1) {
|
||||
// Se o valor é >= 1 nesta unidade, usar ela
|
||||
const rounded = Math.round(value * 100) / 100; // Arredonda para 2 casas decimais
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
|
||||
// Se está muito próximo de um inteiro, usar inteiro
|
||||
if (Math.abs(rounded - Math.round(rounded)) < 0.01) {
|
||||
return { value: Math.round(rounded).toString(), unit };
|
||||
} else {
|
||||
@@ -46,7 +44,6 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback para MB
|
||||
const mbValue = numBytes / UNIT_MULTIPLIERS.MB;
|
||||
return { value: mbValue.toFixed(2), unit: "MB" as Unit };
|
||||
}
|
||||
@@ -92,7 +89,6 @@ export function FileSizeInput({ value, onChange, disabled = false, error, placeh
|
||||
};
|
||||
|
||||
const handleUnitChange = (newUnit: Unit) => {
|
||||
// Ignorar valores vazios ou inválidos que podem vir do Select quando atualizado programaticamente
|
||||
if (!newUnit || !["MB", "GB", "TB", "PB"].includes(newUnit)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ export function FileTypesTagsInput({
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
// Separadores: Enter, espaço, vírgula, pipe, traço
|
||||
if (e.key === "Enter" || e.key === " " || e.key === "," || e.key === "|" || e.key === "-") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
@@ -32,7 +31,6 @@ export function FileTypesTagsInput({
|
||||
e.preventDefault();
|
||||
removeTag(value.length - 1);
|
||||
} else if (e.key === ".") {
|
||||
// Impedir pontos
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
@@ -51,7 +49,6 @@ export function FileTypesTagsInput({
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// Remover pontos e forçar minúsculo
|
||||
const sanitizedValue = e.target.value.replace(/\./g, "").toLowerCase();
|
||||
setInputValue(sanitizedValue);
|
||||
};
|
||||
|
||||
@@ -54,14 +54,11 @@ export function GenerateAliasModal({
|
||||
},
|
||||
});
|
||||
|
||||
// Atualiza o valor padrão quando o reverseShare muda
|
||||
React.useEffect(() => {
|
||||
if (reverseShare) {
|
||||
if (reverseShare.alias?.alias) {
|
||||
// Se já tem alias, usa o existente
|
||||
form.setValue("alias", reverseShare.alias.alias);
|
||||
} else {
|
||||
// Se não tem alias, gera um novo valor padrão
|
||||
form.setValue("alias", generateDefaultAlias());
|
||||
}
|
||||
}
|
||||
@@ -75,7 +72,6 @@ export function GenerateAliasModal({
|
||||
await onCreateAlias(reverseShare.id, data.alias);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
// Erro já é tratado no hook
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -150,17 +146,15 @@ export function GenerateAliasModal({
|
||||
className="max-w-full"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
// Converter espaços em hífens e remover caracteres não permitidos
|
||||
const value = e.target.value
|
||||
.replace(/\s+/g, "-") // espaços viram hífens
|
||||
.replace(/[^a-zA-Z0-9-_]/g, "") // remove caracteres não permitidos
|
||||
.toLowerCase(); // converte para minúsculo
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-zA-Z0-9-_]/g, "")
|
||||
.toLowerCase();
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Preview do link */}
|
||||
{field.value && field.value.length >= 3 && (
|
||||
<div className="mt-2 p-2 bg-primary/5 border border-primary/20 rounded-md overflow-hidden">
|
||||
<label className="text-xs text-muted-foreground block mb-1">
|
||||
|
||||
@@ -25,7 +25,6 @@ import { getFileIcon } from "@/utils/file-icons";
|
||||
import { ReverseShare } from "../hooks/use-reverse-shares";
|
||||
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
|
||||
|
||||
// Types
|
||||
interface EditingState {
|
||||
fileId: string;
|
||||
field: string;
|
||||
@@ -36,7 +35,6 @@ interface HoverState {
|
||||
field: string;
|
||||
}
|
||||
|
||||
// Custom Hooks
|
||||
function useFileEdit() {
|
||||
const [editingFile, setEditingFile] = useState<EditingState | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
@@ -74,7 +72,6 @@ function useFileEdit() {
|
||||
};
|
||||
}
|
||||
|
||||
// Utility Functions
|
||||
const formatFileSize = (sizeString: string) => {
|
||||
const sizeInBytes = parseInt(sizeString);
|
||||
if (sizeInBytes === 0) return "0 B";
|
||||
@@ -122,7 +119,6 @@ const getSenderInitials = (file: ReverseShareFile) => {
|
||||
return "?";
|
||||
};
|
||||
|
||||
// Components
|
||||
interface EditableFieldProps {
|
||||
file: ReverseShareFile;
|
||||
field: "name" | "description";
|
||||
|
||||
@@ -103,7 +103,6 @@ export function ReverseShareCard({
|
||||
const { field } = editingField;
|
||||
let processedValue: string | number | null | boolean = editValue;
|
||||
|
||||
// Processar valores específicos
|
||||
if (field === "isActive") {
|
||||
processedValue = editValue === "true";
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
UpdateReverseShareBody,
|
||||
} from "@/http/endpoints/reverse-shares/types";
|
||||
|
||||
// Tipo baseado na resposta da API
|
||||
export type ReverseShare = ListUserReverseSharesResult["data"]["reverseShares"][0];
|
||||
|
||||
export function useReverseShares() {
|
||||
@@ -62,7 +61,6 @@ export function useReverseShares() {
|
||||
|
||||
setReverseShares(sortedReverseShares);
|
||||
|
||||
// Atualiza o reverseShare específico que está sendo visualizado
|
||||
const updatedReverseShare = allReverseShares.find((rs) => rs.id === id);
|
||||
if (updatedReverseShare) {
|
||||
if (reverseShareToViewFiles && reverseShareToViewFiles.id === id) {
|
||||
@@ -83,13 +81,11 @@ export function useReverseShares() {
|
||||
const response = await createReverseShare(data);
|
||||
const newReverseShare = response.data.reverseShare;
|
||||
|
||||
// Adiciona ao estado local
|
||||
setReverseShares((prev) => [newReverseShare as ReverseShare, ...prev]);
|
||||
|
||||
toast.success(t("reverseShares.messages.createSuccess"));
|
||||
setIsCreateModalOpen(false);
|
||||
|
||||
// Automaticamente abre o modal de alias para o reverse share criado
|
||||
setReverseShareToGenerateLink(newReverseShare as ReverseShare);
|
||||
|
||||
return newReverseShare;
|
||||
@@ -113,7 +109,6 @@ export function useReverseShares() {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Atualiza o estado local
|
||||
setReverseShares((prev) =>
|
||||
prev.map((rs) =>
|
||||
rs.id === reverseShareId
|
||||
@@ -125,7 +120,6 @@ export function useReverseShares() {
|
||||
)
|
||||
);
|
||||
|
||||
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
|
||||
if (reverseShareToViewDetails && reverseShareToViewDetails.id === reverseShareId) {
|
||||
setReverseShareToViewDetails({
|
||||
...reverseShareToViewDetails,
|
||||
@@ -145,7 +139,6 @@ export function useReverseShares() {
|
||||
try {
|
||||
await deleteReverseShare(reverseShare.id);
|
||||
|
||||
// Remove do estado local
|
||||
setReverseShares((prev) => prev.filter((rs) => rs.id !== reverseShare.id));
|
||||
|
||||
toast.success(t("reverseShares.messages.deleteSuccess"));
|
||||
@@ -163,7 +156,6 @@ export function useReverseShares() {
|
||||
const response = await updateReverseShare(data);
|
||||
const updatedReverseShare = response.data.reverseShare;
|
||||
|
||||
// Atualiza o estado local
|
||||
setReverseShares((prev) =>
|
||||
prev.map((rs) => (rs.id === data.id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
|
||||
);
|
||||
@@ -186,12 +178,10 @@ export function useReverseShares() {
|
||||
const response = await updateReverseSharePassword(id, payload);
|
||||
const updatedReverseShare = response.data.reverseShare;
|
||||
|
||||
// Atualiza o estado local
|
||||
setReverseShares((prev) =>
|
||||
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
|
||||
);
|
||||
|
||||
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
|
||||
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
|
||||
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
|
||||
}
|
||||
@@ -208,12 +198,10 @@ export function useReverseShares() {
|
||||
const response = await updateReverseShare(payload);
|
||||
const updatedReverseShare = response.data.reverseShare;
|
||||
|
||||
// Atualiza o estado local
|
||||
setReverseShares((prev) =>
|
||||
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
|
||||
);
|
||||
|
||||
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
|
||||
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
|
||||
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
|
||||
}
|
||||
@@ -232,12 +220,10 @@ export function useReverseShares() {
|
||||
const response = await updateReverseShare(payload);
|
||||
const updatedReverseShare = response.data.reverseShare;
|
||||
|
||||
// Atualiza o estado local
|
||||
setReverseShares((prev) =>
|
||||
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
|
||||
);
|
||||
|
||||
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
|
||||
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
|
||||
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
|
||||
}
|
||||
@@ -256,7 +242,6 @@ export function useReverseShares() {
|
||||
loadReverseShares();
|
||||
}, []);
|
||||
|
||||
// Sincroniza o reverseShareToViewDetails com a lista atualizada
|
||||
useEffect(() => {
|
||||
if (reverseShareToViewDetails) {
|
||||
const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewDetails.id);
|
||||
@@ -266,7 +251,6 @@ export function useReverseShares() {
|
||||
}
|
||||
}, [reverseShares, reverseShareToViewDetails?.id]);
|
||||
|
||||
// Sincroniza o reverseShareToViewFiles com a lista atualizada
|
||||
useEffect(() => {
|
||||
if (reverseShareToViewFiles) {
|
||||
const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewFiles.id);
|
||||
|
||||
@@ -51,11 +51,11 @@ export function usePublicShare() {
|
||||
await loadShare(password);
|
||||
};
|
||||
|
||||
const handleDownload = async (file: { id: string; name: string }) => {
|
||||
const handleDownload = async (objectName: string, fileName: string) => {
|
||||
try {
|
||||
const response = await getDownloadUrl(file.id);
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
const downloadUrl = response.data.url;
|
||||
const fileName = downloadUrl.split("/").pop() || file.name;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
@@ -63,6 +63,7 @@ export function usePublicShare() {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.success(t("share.messages.downloadStarted"));
|
||||
} catch (error) {
|
||||
toast.error(t("share.errors.downloadFailed"));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ obje
|
||||
const { objectPath } = await params;
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
|
||||
// Reconstruct the full objectName from the path segments
|
||||
const objectName = objectPath.join("/");
|
||||
|
||||
const url = `${process.env.API_BASE_URL}/files/${encodeURIComponent(objectName)}/download`;
|
||||
|
||||
@@ -3,12 +3,15 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ shareId: string }> }) {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const { shareId } = await params;
|
||||
const body = await req.text();
|
||||
|
||||
const apiRes = await fetch(`${process.env.API_BASE_URL}/shares/${shareId}/recipients/notify`, {
|
||||
const apiRes = await fetch(`${process.env.API_BASE_URL}/shares/${shareId}/notify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
body: body,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ export default function AuthCallbackPage() {
|
||||
if (token) {
|
||||
Cookies.set("token", token, {
|
||||
path: "/",
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
secure: window.location.protocol === "https:",
|
||||
sameSite: window.location.protocol === "https:" ? "lax" : "strict",
|
||||
httpOnly: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,76 @@
|
||||
import { IconDatabaseCog } from "@tabler/icons-react";
|
||||
import { IconAlertCircle, IconDatabaseCog, IconRefresh } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import type { StorageUsageProps } from "../types";
|
||||
import { formatStorageSize } from "../utils/format-storage-size";
|
||||
|
||||
export function StorageUsage({ diskSpace }: StorageUsageProps) {
|
||||
export function StorageUsage({ diskSpace, diskSpaceError, onRetry }: StorageUsageProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const getErrorMessage = (error: string) => {
|
||||
switch (error) {
|
||||
case "disk_detection_failed":
|
||||
return t("storageUsage.errors.detectionFailed");
|
||||
case "server_error":
|
||||
return t("storageUsage.errors.serverError");
|
||||
default:
|
||||
return t("storageUsage.errors.unknown");
|
||||
}
|
||||
};
|
||||
|
||||
if (diskSpaceError) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardContent className="">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<IconDatabaseCog className="text-gray-500" size={24} />
|
||||
{t("storageUsage.title")}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<IconAlertCircle size={20} />
|
||||
<span className="text-sm font-medium">{t("storageUsage.errors.title")}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getErrorMessage(diskSpaceError)}</p>
|
||||
{onRetry && (
|
||||
<Button variant="outline" size="sm" onClick={onRetry} className="w-fit">
|
||||
<IconRefresh size={16} className="mr-2" />
|
||||
{t("storageUsage.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!diskSpace) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardContent className="">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<IconDatabaseCog className="text-gray-500" size={24} />
|
||||
{t("storageUsage.title")}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{t("storageUsage.loading")}</span>
|
||||
<span>{t("storageUsage.loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardContent className="">
|
||||
|
||||
@@ -18,6 +18,7 @@ export function useDashboard() {
|
||||
diskAvailableGB: number;
|
||||
uploadAllowed: boolean;
|
||||
} | null>(null);
|
||||
const [diskSpaceError, setDiskSpaceError] = useState<string | null>(null);
|
||||
const [recentFiles, setRecentFiles] = useState<any[]>([]);
|
||||
const [recentShares, setRecentShares] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -34,24 +35,44 @@ export function useDashboard() {
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const [diskSpaceRes, filesRes, sharesRes] = await Promise.all([getDiskSpace(), listFiles(), listUserShares()]);
|
||||
const loadDiskSpace = async () => {
|
||||
try {
|
||||
const diskSpaceRes = await getDiskSpace();
|
||||
setDiskSpace(diskSpaceRes.data);
|
||||
setDiskSpaceError(null);
|
||||
} catch (error: any) {
|
||||
console.warn("Failed to load disk space:", error);
|
||||
setDiskSpace(null);
|
||||
|
||||
setDiskSpace(diskSpaceRes.data);
|
||||
if (error.response?.status === 503 && error.response?.data?.code === "DISK_SPACE_DETECTION_FAILED") {
|
||||
setDiskSpaceError("disk_detection_failed");
|
||||
} else if (error.response?.status >= 500) {
|
||||
setDiskSpaceError("server_error");
|
||||
} else {
|
||||
setDiskSpaceError("unknown_error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const allFiles = filesRes.data.files || [];
|
||||
const sortedFiles = [...allFiles].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
const loadFilesAndShares = async () => {
|
||||
const [filesRes, sharesRes] = await Promise.all([listFiles(), listUserShares()]);
|
||||
|
||||
setRecentFiles(sortedFiles.slice(0, 5));
|
||||
const allFiles = filesRes.data.files || [];
|
||||
const sortedFiles = [...allFiles].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
setRecentFiles(sortedFiles.slice(0, 5));
|
||||
|
||||
const allShares = sharesRes.data.shares || [];
|
||||
const sortedShares = [...allShares].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
const allShares = sharesRes.data.shares || [];
|
||||
const sortedShares = [...allShares].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
setRecentShares(sortedShares.slice(0, 5));
|
||||
};
|
||||
|
||||
setRecentShares(sortedShares.slice(0, 5));
|
||||
await Promise.allSettled([loadDiskSpace(), loadFilesAndShares()]);
|
||||
} catch (error) {
|
||||
console.error("Critical dashboard error:", error);
|
||||
toast.error(t("dashboard.loadError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -76,6 +97,7 @@ export function useDashboard() {
|
||||
return {
|
||||
isLoading,
|
||||
diskSpace,
|
||||
diskSpaceError,
|
||||
recentFiles,
|
||||
recentShares,
|
||||
modals: {
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function DashboardPage() {
|
||||
const {
|
||||
isLoading,
|
||||
diskSpace,
|
||||
diskSpaceError,
|
||||
recentFiles,
|
||||
recentShares,
|
||||
modals,
|
||||
@@ -32,6 +33,10 @@ export default function DashboardPage() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
const handleRetryDiskSpace = async () => {
|
||||
await loadDashboardData();
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<FileManagerLayout
|
||||
@@ -40,7 +45,7 @@ export default function DashboardPage() {
|
||||
showBreadcrumb={false}
|
||||
title={t("dashboard.pageTitle")}
|
||||
>
|
||||
<StorageUsage diskSpace={diskSpace} />
|
||||
<StorageUsage diskSpace={diskSpace} diskSpaceError={diskSpaceError} onRetry={handleRetryDiskSpace} />
|
||||
<QuickAccessCards />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface StorageUsageProps {
|
||||
diskAvailableGB: number;
|
||||
uploadAllowed: boolean;
|
||||
} | null;
|
||||
diskSpaceError?: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export interface DashboardModalsProps {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
emailOrUsername: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
@@ -37,18 +37,18 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
|
||||
</p>
|
||||
);
|
||||
|
||||
const renderEmailField = () => (
|
||||
const renderEmailOrUsernameField = () => (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
name="emailOrUsername"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("login.emailLabel")}</FormLabel>
|
||||
<FormLabel>{t("login.emailOrUsernameLabel")}</FormLabel>
|
||||
<FormControl className="-mb-1">
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
placeholder={t("login.emailPlaceholder")}
|
||||
type="text"
|
||||
placeholder={t("login.emailOrUsernamePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
className="bg-transparent backdrop-blur-md"
|
||||
/>
|
||||
@@ -89,7 +89,7 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
|
||||
{renderErrorMessage()}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
{renderEmailField()}
|
||||
{renderEmailOrUsernameField()}
|
||||
{renderPasswordField()}
|
||||
<Button className="w-full mt-4 cursor-pointer" variant="default" size="lg" type="submit">
|
||||
{isSubmitting ? t("login.signingIn") : t("login.signIn")}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getCurrentUser, login } from "@/http/endpoints";
|
||||
import { LoginFormValues } from "../schemas/schema";
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string(),
|
||||
emailOrUsername: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
@@ -49,6 +49,17 @@ export function useLogin() {
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const appInfoResponse = await fetch("/api/app/info");
|
||||
const appInfo = await appInfoResponse.json();
|
||||
|
||||
if (appInfo.firstUserAccess) {
|
||||
setUser(null);
|
||||
setIsAdmin(false);
|
||||
setIsAuthenticated(false);
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const userResponse = await getCurrentUser();
|
||||
if (!userResponse?.data?.user) {
|
||||
throw new Error("No user data");
|
||||
|
||||
@@ -5,7 +5,7 @@ type TFunction = ReturnType<typeof useTranslations>;
|
||||
|
||||
export const createLoginSchema = (t: TFunction) =>
|
||||
z.object({
|
||||
email: z.string().min(1, t("validation.emailRequired")).email(t("validation.invalidEmail")),
|
||||
emailOrUsername: z.string().min(1, t("validation.emailOrUsernameRequired")),
|
||||
password: z.string().min(1, t("validation.passwordRequired")),
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export function LanguageSwitcher() {
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: false,
|
||||
secure: window.location.protocol === "https:",
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getDownloadUrl } from "@/http/endpoints";
|
||||
import { useFilePreview } from "@/hooks/use-file-preview";
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
import { FilePreviewRenderer } from "./previews";
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -25,299 +21,7 @@ interface FilePreviewModalProps {
|
||||
|
||||
export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProps) {
|
||||
const t = useTranslations();
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [videoBlob, setVideoBlob] = useState<string | null>(null);
|
||||
const [pdfAsBlob, setPdfAsBlob] = useState(false);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [pdfLoadFailed, setPdfLoadFailed] = useState(false);
|
||||
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && file.objectName && !isLoadingPreview) {
|
||||
setIsLoading(true);
|
||||
setPreviewUrl(null);
|
||||
setVideoBlob(null);
|
||||
setPdfAsBlob(false);
|
||||
setDownloadUrl(null);
|
||||
setPdfLoadFailed(false);
|
||||
loadPreview();
|
||||
}
|
||||
}, [file.objectName, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl && previewUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
if (videoBlob && videoBlob.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(videoBlob);
|
||||
}
|
||||
};
|
||||
}, [previewUrl, videoBlob]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (previewUrl && previewUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
if (videoBlob && videoBlob.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(videoBlob);
|
||||
setVideoBlob(null);
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadPreview = async () => {
|
||||
if (!file.objectName || isLoadingPreview) return;
|
||||
|
||||
setIsLoadingPreview(true);
|
||||
try {
|
||||
const encodedObjectName = encodeURIComponent(file.objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
const url = response.data.url;
|
||||
|
||||
setDownloadUrl(url);
|
||||
|
||||
const fileType = getFileType();
|
||||
|
||||
if (fileType === "video") {
|
||||
await loadVideoPreview(url);
|
||||
} else if (fileType === "audio") {
|
||||
await loadAudioPreview(url);
|
||||
} else if (fileType === "pdf") {
|
||||
await loadPdfPreview(url);
|
||||
} else {
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load preview:", error);
|
||||
toast.error(t("filePreview.loadError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadingPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadVideoPreview = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setVideoBlob(blobUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to load video as blob:", error);
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAudioPreview = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setPreviewUrl(blobUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to load audio as blob:", error);
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPdfPreview = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const finalBlob = new Blob([blob], { type: "application/pdf" });
|
||||
const blobUrl = URL.createObjectURL(finalBlob);
|
||||
setPreviewUrl(blobUrl);
|
||||
setPdfAsBlob(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to load PDF as blob:", error);
|
||||
setPreviewUrl(url);
|
||||
setTimeout(() => {
|
||||
if (!pdfLoadFailed && !pdfAsBlob) {
|
||||
handlePdfLoadError();
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdfLoadError = async () => {
|
||||
if (pdfLoadFailed || pdfAsBlob) return;
|
||||
|
||||
setPdfLoadFailed(true);
|
||||
|
||||
if (downloadUrl) {
|
||||
setTimeout(() => {
|
||||
loadPdfPreview(downloadUrl);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
let downloadUrlToUse = downloadUrl;
|
||||
|
||||
if (!downloadUrlToUse) {
|
||||
const encodedObjectName = encodeURIComponent(file.objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
downloadUrlToUse = response.data.url;
|
||||
}
|
||||
|
||||
const fileResponse = await fetch(downloadUrlToUse);
|
||||
if (!fileResponse.ok) {
|
||||
throw new Error(`Download failed: ${fileResponse.status} - ${fileResponse.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await fileResponse.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error(t("filePreview.downloadError"));
|
||||
console.error("Download error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileType = () => {
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
|
||||
if (extension === "pdf") return "pdf";
|
||||
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) return "image";
|
||||
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) return "audio";
|
||||
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) return "video";
|
||||
|
||||
return "other";
|
||||
};
|
||||
|
||||
const renderPreview = () => {
|
||||
const fileType = getFileType();
|
||||
const { icon: FileIcon, color } = getFileIcon(file.name);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
|
||||
|
||||
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<FileIcon className={`h-12 w-12 ${color}`} />
|
||||
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!previewUrl && fileType !== "video") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<FileIcon className={`h-12 w-12 ${color}`} />
|
||||
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (fileType) {
|
||||
case "pdf":
|
||||
return (
|
||||
<ScrollArea className="w-full">
|
||||
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
|
||||
{pdfAsBlob ? (
|
||||
<iframe
|
||||
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
|
||||
className="w-full h-full min-h-[600px]"
|
||||
title={file.name}
|
||||
style={{ border: "none" }}
|
||||
/>
|
||||
) : pdfLoadFailed ? (
|
||||
<div className="flex items-center justify-center h-full min-h-[600px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full min-h-[600px] relative">
|
||||
<object
|
||||
data={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
|
||||
type="application/pdf"
|
||||
className="w-full h-full min-h-[600px]"
|
||||
onError={handlePdfLoadError}
|
||||
>
|
||||
<iframe
|
||||
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
|
||||
className="w-full h-full min-h-[600px]"
|
||||
title={file.name}
|
||||
style={{ border: "none" }}
|
||||
onError={handlePdfLoadError}
|
||||
/>
|
||||
</object>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<AspectRatio ratio={16 / 9} className="bg-muted">
|
||||
<img src={previewUrl!} alt={file.name} className="object-contain w-full h-full rounded-md" />
|
||||
</AspectRatio>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-6 py-4">
|
||||
<CustomAudioPlayer src={mediaUrl!} />
|
||||
</div>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-6">
|
||||
<div className="w-full max-w-4xl">
|
||||
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
|
||||
<source src={mediaUrl!} />
|
||||
{t("filePreview.videoNotSupported")}
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<FileIcon className={`text-6xl ${color}`} />
|
||||
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
const previewState = useFilePreview({ file, isOpen });
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
@@ -331,12 +35,24 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
|
||||
<span className="truncate">{file.name}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto">{renderPreview()}</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<FilePreviewRenderer
|
||||
fileType={previewState.fileType}
|
||||
fileName={file.name}
|
||||
previewUrl={previewState.previewUrl}
|
||||
videoBlob={previewState.videoBlob}
|
||||
textContent={previewState.textContent}
|
||||
isLoading={previewState.isLoading}
|
||||
pdfAsBlob={previewState.pdfAsBlob}
|
||||
pdfLoadFailed={previewState.pdfLoadFailed}
|
||||
onPdfLoadError={previewState.handlePdfLoadError}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Button onClick={handleDownload}>
|
||||
<Button onClick={previewState.handleDownload}>
|
||||
<IconDownload className="h-4 w-4" />
|
||||
{t("common.download")}
|
||||
</Button>
|
||||
|
||||
13
apps/web/src/components/modals/previews/audio-preview.tsx
Normal file
13
apps/web/src/components/modals/previews/audio-preview.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
|
||||
|
||||
interface AudioPreviewProps {
|
||||
src: string;
|
||||
}
|
||||
|
||||
export function AudioPreview({ src }: AudioPreviewProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-6 py-4">
|
||||
<CustomAudioPlayer src={src} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/web/src/components/modals/previews/default-preview.tsx
Normal file
31
apps/web/src/components/modals/previews/default-preview.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { getFileIcon } from "@/utils/file-icons";
|
||||
|
||||
interface DefaultPreviewProps {
|
||||
fileName: string;
|
||||
isLoading?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function DefaultPreview({ fileName, isLoading, message }: DefaultPreviewProps) {
|
||||
const t = useTranslations();
|
||||
const { icon: FileIcon, color } = getFileIcon(fileName);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<FileIcon className={`h-12 w-12 ${color}`} />
|
||||
<p className="text-muted-foreground">{message || t("filePreview.notAvailable")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type FileType } from "@/utils/file-types";
|
||||
import { AudioPreview } from "./audio-preview";
|
||||
import { DefaultPreview } from "./default-preview";
|
||||
import { ImagePreview } from "./image-preview";
|
||||
import { PdfPreview } from "./pdf-preview";
|
||||
import { TextPreview } from "./text-preview";
|
||||
import { VideoPreview } from "./video-preview";
|
||||
|
||||
interface FilePreviewRendererProps {
|
||||
fileType: FileType;
|
||||
fileName: string;
|
||||
previewUrl: string | null;
|
||||
videoBlob: string | null;
|
||||
textContent: string | null;
|
||||
isLoading: boolean;
|
||||
pdfAsBlob: boolean;
|
||||
pdfLoadFailed: boolean;
|
||||
onPdfLoadError: () => void;
|
||||
}
|
||||
|
||||
export function FilePreviewRenderer({
|
||||
fileType,
|
||||
fileName,
|
||||
previewUrl,
|
||||
videoBlob,
|
||||
textContent,
|
||||
isLoading,
|
||||
pdfAsBlob,
|
||||
pdfLoadFailed,
|
||||
onPdfLoadError,
|
||||
}: FilePreviewRendererProps) {
|
||||
if (isLoading) {
|
||||
return <DefaultPreview fileName={fileName} isLoading />;
|
||||
}
|
||||
|
||||
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
|
||||
|
||||
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
|
||||
return <DefaultPreview fileName={fileName} />;
|
||||
}
|
||||
|
||||
if (fileType === "text" && !textContent) {
|
||||
return <DefaultPreview fileName={fileName} />;
|
||||
}
|
||||
|
||||
if (!previewUrl && fileType !== "video" && fileType !== "text") {
|
||||
return <DefaultPreview fileName={fileName} />;
|
||||
}
|
||||
|
||||
switch (fileType) {
|
||||
case "pdf":
|
||||
return (
|
||||
<PdfPreview
|
||||
src={previewUrl!}
|
||||
fileName={fileName}
|
||||
pdfAsBlob={pdfAsBlob}
|
||||
pdfLoadFailed={pdfLoadFailed}
|
||||
onLoadError={onPdfLoadError}
|
||||
/>
|
||||
);
|
||||
|
||||
case "text":
|
||||
return <TextPreview content={textContent} fileName={fileName} />;
|
||||
|
||||
case "image":
|
||||
return <ImagePreview src={previewUrl!} alt={fileName} />;
|
||||
|
||||
case "audio":
|
||||
return <AudioPreview src={mediaUrl!} />;
|
||||
|
||||
case "video":
|
||||
return <VideoPreview src={mediaUrl!} />;
|
||||
|
||||
default:
|
||||
return <DefaultPreview fileName={fileName} />;
|
||||
}
|
||||
}
|
||||
14
apps/web/src/components/modals/previews/image-preview.tsx
Normal file
14
apps/web/src/components/modals/previews/image-preview.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
|
||||
interface ImagePreviewProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export function ImagePreview({ src, alt }: ImagePreviewProps) {
|
||||
return (
|
||||
<AspectRatio ratio={16 / 9} className="bg-muted">
|
||||
<img src={src} alt={alt} className="object-contain w-full h-full rounded-md" />
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
7
apps/web/src/components/modals/previews/index.ts
Normal file
7
apps/web/src/components/modals/previews/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { ImagePreview } from "./image-preview";
|
||||
export { VideoPreview } from "./video-preview";
|
||||
export { AudioPreview } from "./audio-preview";
|
||||
export { PdfPreview } from "./pdf-preview";
|
||||
export { TextPreview } from "./text-preview";
|
||||
export { DefaultPreview } from "./default-preview";
|
||||
export { FilePreviewRenderer } from "./file-preview-render";
|
||||
54
apps/web/src/components/modals/previews/pdf-preview.tsx
Normal file
54
apps/web/src/components/modals/previews/pdf-preview.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface PdfPreviewProps {
|
||||
src: string;
|
||||
fileName: string;
|
||||
pdfAsBlob: boolean;
|
||||
pdfLoadFailed: boolean;
|
||||
onLoadError: () => void;
|
||||
}
|
||||
|
||||
export function PdfPreview({ src, fileName, pdfAsBlob, pdfLoadFailed, onLoadError }: PdfPreviewProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<ScrollArea className="w-full">
|
||||
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
|
||||
{pdfAsBlob ? (
|
||||
<iframe
|
||||
src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
|
||||
className="w-full h-full min-h-[600px]"
|
||||
title={fileName}
|
||||
style={{ border: "none" }}
|
||||
/>
|
||||
) : pdfLoadFailed ? (
|
||||
<div className="flex items-center justify-center h-full min-h-[600px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full min-h-[600px] relative">
|
||||
<object
|
||||
data={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
|
||||
type="application/pdf"
|
||||
className="w-full h-full min-h-[600px]"
|
||||
onError={onLoadError}
|
||||
>
|
||||
<iframe
|
||||
src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
|
||||
className="w-full h-full min-h-[600px]"
|
||||
title={fileName}
|
||||
style={{ border: "none" }}
|
||||
onError={onLoadError}
|
||||
/>
|
||||
</object>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/components/modals/previews/text-preview.tsx
Normal file
40
apps/web/src/components/modals/previews/text-preview.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getFileExtension } from "@/utils/file-types";
|
||||
|
||||
interface TextPreviewProps {
|
||||
content: string | null;
|
||||
fileName: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function TextPreview({ content, fileName, isLoading }: TextPreviewProps) {
|
||||
const t = useTranslations();
|
||||
const extension = getFileExtension(fileName);
|
||||
|
||||
if (isLoading || !content) {
|
||||
return (
|
||||
<ScrollArea className="w-full max-h-[600px]">
|
||||
<div className="w-full border rounded-lg overflow-hidden bg-card">
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
<p className="text-sm text-muted-foreground">{t("filePreview.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="w-full max-h-[600px]">
|
||||
<div className="w-full border rounded-lg overflow-hidden bg-card">
|
||||
<pre className="p-4 text-sm font-mono whitespace-pre-wrap break-words overflow-x-auto">
|
||||
<code className={`language-${extension || "text"}`}>{content}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/components/modals/previews/video-preview.tsx
Normal file
20
apps/web/src/components/modals/previews/video-preview.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface VideoPreviewProps {
|
||||
src: string;
|
||||
}
|
||||
|
||||
export function VideoPreview({ src }: VideoPreviewProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-6">
|
||||
<div className="w-full max-w-4xl">
|
||||
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
|
||||
<source src={src} />
|
||||
{t("filePreview.videoNotSupported")}
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconCloudUpload,
|
||||
IconFileText,
|
||||
IconFileTypePdf,
|
||||
IconFileTypography,
|
||||
IconLoader,
|
||||
IconPhoto,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconAlertTriangle, IconCheck, IconCloudUpload, IconLoader, IconTrash, IconX } from "@tabler/icons-react";
|
||||
import axios from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
@@ -21,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 { getFileIcon } from "@/utils/file-icons";
|
||||
import { generateSafeFileName } from "@/utils/file-utils";
|
||||
import getErrorData from "@/utils/getErrorData";
|
||||
|
||||
@@ -157,11 +147,9 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
handleFilesSelect(event.dataTransfer.files);
|
||||
};
|
||||
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.startsWith("image/")) return <IconPhoto size={24} className="text-blue-500" />;
|
||||
if (fileType.includes("pdf")) return <IconFileTypePdf size={24} className="text-red-500" />;
|
||||
if (fileType.includes("word")) return <IconFileTypography size={24} className="text-blue-700" />;
|
||||
return <IconFileText size={24} className="text-muted-foreground" />;
|
||||
const renderFileIcon = (fileName: string) => {
|
||||
const { icon: FileIcon, color } = getFileIcon(fileName);
|
||||
return <FileIcon size={24} className={color} />;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: UploadStatus) => {
|
||||
@@ -420,7 +408,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
className="w-10 h-10 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(upload.file.type)
|
||||
renderFileIcon(upload.file.name)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -453,7 +441,33 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
>
|
||||
<IconX size={14} />
|
||||
</Button>
|
||||
) : upload.status === UploadStatus.SUCCESS ? null : (
|
||||
) : upload.status === UploadStatus.SUCCESS ? null : upload.status === UploadStatus.ERROR ? (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFileUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === upload.id ? { ...u, status: UploadStatus.PENDING, error: undefined } : u
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
title={t("uploadFile.retry")}
|
||||
>
|
||||
<IconLoader size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFile(upload.id)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" onClick={() => removeFile(upload.id)} className="h-8 w-8 p-0">
|
||||
<IconTrash size={14} />
|
||||
</Button>
|
||||
|
||||
@@ -41,6 +41,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const appInfoResponse = await fetch("/api/app/info");
|
||||
const appInfo = await appInfoResponse.json();
|
||||
|
||||
if (appInfo.firstUserAccess) {
|
||||
setUser(null);
|
||||
setIsAdmin(false);
|
||||
setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getCurrentUser();
|
||||
if (!response?.data?.user) {
|
||||
throw new Error("No user data");
|
||||
|
||||
255
apps/web/src/hooks/use-file-preview.ts
Normal file
255
apps/web/src/hooks/use-file-preview.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getDownloadUrl } from "@/http/endpoints";
|
||||
import { getFileExtension, getFileType, type FileType } from "@/utils/file-types";
|
||||
|
||||
interface FilePreviewState {
|
||||
previewUrl: string | null;
|
||||
videoBlob: string | null;
|
||||
textContent: string | null;
|
||||
downloadUrl: string | null;
|
||||
isLoading: boolean;
|
||||
isLoadingPreview: boolean;
|
||||
pdfAsBlob: boolean;
|
||||
pdfLoadFailed: boolean;
|
||||
}
|
||||
|
||||
interface UseFilePreviewProps {
|
||||
file: {
|
||||
name: string;
|
||||
objectName: string;
|
||||
type?: string;
|
||||
};
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export function useFilePreview({ file, isOpen }: UseFilePreviewProps) {
|
||||
const t = useTranslations();
|
||||
const [state, setState] = useState<FilePreviewState>({
|
||||
previewUrl: null,
|
||||
videoBlob: null,
|
||||
textContent: null,
|
||||
downloadUrl: null,
|
||||
isLoading: true,
|
||||
isLoadingPreview: false,
|
||||
pdfAsBlob: false,
|
||||
pdfLoadFailed: false,
|
||||
});
|
||||
|
||||
const fileType: FileType = getFileType(file.name);
|
||||
|
||||
// Reset state when file changes or modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && file.objectName && !state.isLoadingPreview) {
|
||||
resetState();
|
||||
loadPreview();
|
||||
}
|
||||
}, [file.objectName, isOpen]);
|
||||
|
||||
// Cleanup blob URLs
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupBlobUrls();
|
||||
};
|
||||
}, [state.previewUrl, state.videoBlob]);
|
||||
|
||||
// Cleanup when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
cleanupBlobUrls();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const resetState = () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
previewUrl: null,
|
||||
videoBlob: null,
|
||||
textContent: null,
|
||||
downloadUrl: null,
|
||||
pdfAsBlob: false,
|
||||
pdfLoadFailed: false,
|
||||
isLoading: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const cleanupBlobUrls = () => {
|
||||
if (state.previewUrl && state.previewUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(state.previewUrl);
|
||||
}
|
||||
if (state.videoBlob && state.videoBlob.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(state.videoBlob);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPreview = async () => {
|
||||
if (!file.objectName || state.isLoadingPreview) return;
|
||||
|
||||
setState((prev) => ({ ...prev, isLoadingPreview: true }));
|
||||
|
||||
try {
|
||||
const encodedObjectName = encodeURIComponent(file.objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
const url = response.data.url;
|
||||
|
||||
setState((prev) => ({ ...prev, downloadUrl: url }));
|
||||
|
||||
switch (fileType) {
|
||||
case "video":
|
||||
await loadVideoPreview(url);
|
||||
break;
|
||||
case "audio":
|
||||
await loadAudioPreview(url);
|
||||
break;
|
||||
case "pdf":
|
||||
await loadPdfPreview(url);
|
||||
break;
|
||||
case "text":
|
||||
await loadTextPreview(url);
|
||||
break;
|
||||
default:
|
||||
setState((prev) => ({ ...prev, previewUrl: url }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load preview:", error);
|
||||
toast.error(t("filePreview.loadError"));
|
||||
} finally {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isLoadingPreview: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const loadVideoPreview = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setState((prev) => ({ ...prev, videoBlob: blobUrl }));
|
||||
} catch (error) {
|
||||
console.error("Failed to load video as blob:", error);
|
||||
setState((prev) => ({ ...prev, previewUrl: url }));
|
||||
}
|
||||
};
|
||||
|
||||
const loadAudioPreview = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setState((prev) => ({ ...prev, previewUrl: blobUrl }));
|
||||
} catch (error) {
|
||||
console.error("Failed to load audio as blob:", error);
|
||||
setState((prev) => ({ ...prev, previewUrl: url }));
|
||||
}
|
||||
};
|
||||
|
||||
const loadPdfPreview = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const finalBlob = new Blob([blob], { type: "application/pdf" });
|
||||
const blobUrl = URL.createObjectURL(finalBlob);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
previewUrl: blobUrl,
|
||||
pdfAsBlob: true,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to load PDF as blob:", error);
|
||||
setState((prev) => ({ ...prev, previewUrl: url }));
|
||||
setTimeout(() => {
|
||||
if (!state.pdfLoadFailed && !state.pdfAsBlob) {
|
||||
handlePdfLoadError();
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTextPreview = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const extension = getFileExtension(file.name);
|
||||
|
||||
try {
|
||||
// For JSON files, validate and format
|
||||
if (extension === "json") {
|
||||
const parsed = JSON.parse(text);
|
||||
const formatted = JSON.stringify(parsed, null, 2);
|
||||
setState((prev) => ({ ...prev, textContent: formatted }));
|
||||
} else {
|
||||
// For other text files, show as-is
|
||||
setState((prev) => ({ ...prev, textContent: text }));
|
||||
}
|
||||
} catch (jsonError) {
|
||||
// If JSON parsing fails, show as plain text
|
||||
setState((prev) => ({ ...prev, textContent: text }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load text content:", error);
|
||||
setState((prev) => ({ ...prev, textContent: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdfLoadError = async () => {
|
||||
if (state.pdfLoadFailed || state.pdfAsBlob) return;
|
||||
|
||||
setState((prev) => ({ ...prev, pdfLoadFailed: true }));
|
||||
|
||||
if (state.downloadUrl) {
|
||||
setTimeout(() => {
|
||||
loadPdfPreview(state.downloadUrl!);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
let downloadUrlToUse = state.downloadUrl;
|
||||
|
||||
if (!downloadUrlToUse) {
|
||||
const encodedObjectName = encodeURIComponent(file.objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
downloadUrlToUse = response.data.url;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrlToUse;
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
toast.error(t("filePreview.downloadError"));
|
||||
console.error("Download error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
fileType,
|
||||
handleDownload,
|
||||
handlePdfLoadError,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
// Create Reverse Share
|
||||
export type CreateReverseShareBody = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
@@ -31,7 +30,6 @@ export type CreateReverseShareResult = AxiosResponse<{
|
||||
};
|
||||
}>;
|
||||
|
||||
// Update Reverse Share
|
||||
export type UpdateReverseShareBody = {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -64,7 +62,6 @@ export type UpdateReverseShareResult = AxiosResponse<{
|
||||
};
|
||||
}>;
|
||||
|
||||
// List User Reverse Shares
|
||||
export type ListUserReverseSharesResult = AxiosResponse<{
|
||||
reverseShares: {
|
||||
id: string;
|
||||
@@ -91,7 +88,6 @@ export type ListUserReverseSharesResult = AxiosResponse<{
|
||||
}[];
|
||||
}>;
|
||||
|
||||
// Get Reverse Share
|
||||
export type GetReverseShareResult = AxiosResponse<{
|
||||
reverseShare: {
|
||||
id: string;
|
||||
@@ -118,7 +114,6 @@ export type GetReverseShareResult = AxiosResponse<{
|
||||
};
|
||||
}>;
|
||||
|
||||
// Delete Reverse Share
|
||||
export type DeleteReverseShareResult = AxiosResponse<{
|
||||
reverseShare: {
|
||||
id: string;
|
||||
@@ -138,7 +133,6 @@ export type DeleteReverseShareResult = AxiosResponse<{
|
||||
};
|
||||
}>;
|
||||
|
||||
// Get Reverse Share for Upload (Public)
|
||||
export type GetReverseShareForUploadParams = {
|
||||
password?: string;
|
||||
};
|
||||
@@ -157,7 +151,6 @@ export type GetReverseShareForUploadResult = AxiosResponse<{
|
||||
};
|
||||
}>;
|
||||
|
||||
// Update Password
|
||||
export type UpdateReverseSharePasswordBody = {
|
||||
password: string | null;
|
||||
};
|
||||
@@ -181,7 +174,6 @@ export type UpdateReverseSharePasswordResult = AxiosResponse<{
|
||||
};
|
||||
}>;
|
||||
|
||||
// Presigned URL
|
||||
export type GetPresignedUrlBody = {
|
||||
objectName: string;
|
||||
};
|
||||
@@ -191,7 +183,6 @@ export type GetPresignedUrlResult = AxiosResponse<{
|
||||
expiresIn: number;
|
||||
}>;
|
||||
|
||||
// Register File Upload
|
||||
export type RegisterFileUploadBody = {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -210,7 +201,6 @@ export type RegisterFileUploadResult = AxiosResponse<{
|
||||
file: ReverseShareFile;
|
||||
}>;
|
||||
|
||||
// Check Password
|
||||
export type CheckReverseSharePasswordBody = {
|
||||
password: string;
|
||||
};
|
||||
@@ -219,18 +209,15 @@ export type CheckReverseSharePasswordResult = AxiosResponse<{
|
||||
valid: boolean;
|
||||
}>;
|
||||
|
||||
// Download File
|
||||
export type DownloadReverseShareFileResult = AxiosResponse<{
|
||||
url: string;
|
||||
expiresIn: number;
|
||||
}>;
|
||||
|
||||
// Delete File
|
||||
export type DeleteReverseShareFileResult = AxiosResponse<{
|
||||
file: ReverseShareFile;
|
||||
}>;
|
||||
|
||||
// Shared Type
|
||||
export type ReverseShareFile = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -244,7 +231,6 @@ export type ReverseShareFile = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
// Activate Reverse Share
|
||||
export type ActivateReverseShareResult = AxiosResponse<{
|
||||
reverseShare: {
|
||||
id: string;
|
||||
@@ -264,7 +250,6 @@ export type ActivateReverseShareResult = AxiosResponse<{
|
||||
};
|
||||
}>;
|
||||
|
||||
// Deactivate Reverse Share
|
||||
export type DeactivateReverseShareResult = AxiosResponse<{
|
||||
reverseShare: {
|
||||
id: string;
|
||||
@@ -284,7 +269,6 @@ export type DeactivateReverseShareResult = AxiosResponse<{
|
||||
};
|
||||
}>;
|
||||
|
||||
// Update Reverse Share File
|
||||
export type UpdateReverseShareFileBody = {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
export type LoginBody = {
|
||||
/** User email */
|
||||
email: string;
|
||||
/** User email or username */
|
||||
emailOrUsername: string;
|
||||
/**
|
||||
* User password
|
||||
* @minLength 8
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import {
|
||||
Icon,
|
||||
IconApi,
|
||||
IconAtom,
|
||||
IconBook,
|
||||
IconBrandCss3,
|
||||
IconBrandDocker,
|
||||
IconBrandGit,
|
||||
IconBrandGolang,
|
||||
IconBrandHtml5,
|
||||
IconBrandJavascript,
|
||||
IconBrandKotlin,
|
||||
IconBrandNpm,
|
||||
IconBrandPhp,
|
||||
IconBrandPython,
|
||||
IconBrandReact,
|
||||
IconBrandRust,
|
||||
IconBrandSass,
|
||||
IconBrandSwift,
|
||||
IconBrandTypescript,
|
||||
IconBrandVue,
|
||||
IconBrandYarn,
|
||||
IconBug,
|
||||
IconCloud,
|
||||
IconCode,
|
||||
IconDatabase,
|
||||
IconDeviceDesktop,
|
||||
IconFile,
|
||||
IconFileCode,
|
||||
IconFileDescription,
|
||||
@@ -8,8 +33,16 @@ import {
|
||||
IconFileText,
|
||||
IconFileTypePdf,
|
||||
IconFileZip,
|
||||
IconKey,
|
||||
IconLock,
|
||||
IconMarkdown,
|
||||
IconMath,
|
||||
IconPalette,
|
||||
IconPhoto,
|
||||
IconPresentation,
|
||||
IconSettings,
|
||||
IconTerminal,
|
||||
IconTool,
|
||||
IconVideo,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
@@ -20,56 +53,452 @@ interface FileIconMapping {
|
||||
}
|
||||
|
||||
const fileIcons: FileIconMapping[] = [
|
||||
// Images
|
||||
{
|
||||
extensions: ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"],
|
||||
extensions: ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff", "ico", "heic", "avif"],
|
||||
icon: IconPhoto,
|
||||
color: "text-blue-500",
|
||||
},
|
||||
|
||||
// Documents
|
||||
{
|
||||
extensions: ["pdf"],
|
||||
icon: IconFileTypePdf,
|
||||
color: "text-red-500",
|
||||
},
|
||||
{
|
||||
extensions: ["doc", "docx"],
|
||||
extensions: ["doc", "docx", "odt", "rtf"],
|
||||
icon: IconFileText,
|
||||
color: "text-blue-600",
|
||||
},
|
||||
{
|
||||
extensions: ["xls", "xlsx", "csv"],
|
||||
extensions: ["xls", "xlsx", "ods", "csv"],
|
||||
icon: IconFileSpreadsheet,
|
||||
color: "text-green-600",
|
||||
},
|
||||
{
|
||||
extensions: ["ppt", "pptx"],
|
||||
extensions: ["ppt", "pptx", "odp"],
|
||||
icon: IconPresentation,
|
||||
color: "text-orange-500",
|
||||
},
|
||||
|
||||
// Media
|
||||
{
|
||||
extensions: ["mp3", "wav", "ogg", "m4a"],
|
||||
extensions: ["mp3", "wav", "ogg", "m4a", "aac", "flac", "wma", "opus"],
|
||||
icon: IconFileMusic,
|
||||
color: "text-purple-500",
|
||||
},
|
||||
{
|
||||
extensions: ["mp4", "avi", "mov", "wmv", "mkv"],
|
||||
extensions: ["mp4", "avi", "mov", "wmv", "mkv", "webm", "flv", "m4v", "3gp"],
|
||||
icon: IconVideo,
|
||||
color: "text-pink-500",
|
||||
},
|
||||
|
||||
// Archives
|
||||
{
|
||||
extensions: ["zip", "rar", "7z", "tar", "gz"],
|
||||
extensions: ["zip", "rar", "7z", "tar", "gz", "bz2", "xz", "lz", "cab", "deb", "rpm"],
|
||||
icon: IconFileZip,
|
||||
color: "text-yellow-600",
|
||||
},
|
||||
|
||||
// JavaScript/TypeScript
|
||||
{
|
||||
extensions: ["html", "css", "js", "ts", "jsx", "tsx", "json", "xml"],
|
||||
icon: IconFileCode,
|
||||
extensions: ["js", "mjs", "cjs"],
|
||||
icon: IconBrandJavascript,
|
||||
color: "text-yellow-500",
|
||||
},
|
||||
{
|
||||
extensions: ["ts", "tsx"],
|
||||
icon: IconBrandTypescript,
|
||||
color: "text-blue-600",
|
||||
},
|
||||
{
|
||||
extensions: ["jsx"],
|
||||
icon: IconBrandReact,
|
||||
color: "text-cyan-500",
|
||||
},
|
||||
{
|
||||
extensions: ["vue"],
|
||||
icon: IconBrandVue,
|
||||
color: "text-green-500",
|
||||
},
|
||||
|
||||
// Web Technologies
|
||||
{
|
||||
extensions: ["html", "htm", "xhtml"],
|
||||
icon: IconBrandHtml5,
|
||||
color: "text-orange-600",
|
||||
},
|
||||
{
|
||||
extensions: ["css"],
|
||||
icon: IconBrandCss3,
|
||||
color: "text-blue-600",
|
||||
},
|
||||
{
|
||||
extensions: ["scss", "sass"],
|
||||
icon: IconBrandSass,
|
||||
color: "text-pink-600",
|
||||
},
|
||||
{
|
||||
extensions: ["less", "stylus"],
|
||||
icon: IconPalette,
|
||||
color: "text-purple-600",
|
||||
},
|
||||
|
||||
// Programming Languages
|
||||
{
|
||||
extensions: ["py", "pyw", "pyc", "pyo", "pyd"],
|
||||
icon: IconBrandPython,
|
||||
color: "text-yellow-600",
|
||||
},
|
||||
{
|
||||
extensions: ["php", "phtml"],
|
||||
icon: IconBrandPhp,
|
||||
color: "text-purple-700",
|
||||
},
|
||||
{
|
||||
extensions: ["go"],
|
||||
icon: IconBrandGolang,
|
||||
color: "text-cyan-600",
|
||||
},
|
||||
{
|
||||
extensions: ["rs"],
|
||||
icon: IconBrandRust,
|
||||
color: "text-orange-700",
|
||||
},
|
||||
{
|
||||
extensions: ["swift"],
|
||||
icon: IconBrandSwift,
|
||||
color: "text-orange-500",
|
||||
},
|
||||
{
|
||||
extensions: ["kt", "kts"],
|
||||
icon: IconBrandKotlin,
|
||||
color: "text-purple-600",
|
||||
},
|
||||
{
|
||||
extensions: ["java", "class", "jar"],
|
||||
icon: IconCode,
|
||||
color: "text-red-600",
|
||||
},
|
||||
{
|
||||
extensions: ["c", "h"],
|
||||
icon: IconCode,
|
||||
color: "text-blue-700",
|
||||
},
|
||||
{
|
||||
extensions: ["cpp", "cxx", "cc", "hpp", "hxx"],
|
||||
icon: IconCode,
|
||||
color: "text-blue-800",
|
||||
},
|
||||
{
|
||||
extensions: ["cs"],
|
||||
icon: IconCode,
|
||||
color: "text-purple-700",
|
||||
},
|
||||
{
|
||||
extensions: ["rb", "rbw", "rake"],
|
||||
icon: IconCode,
|
||||
color: "text-red-500",
|
||||
},
|
||||
{
|
||||
extensions: ["scala", "sc"],
|
||||
icon: IconCode,
|
||||
color: "text-red-700",
|
||||
},
|
||||
{
|
||||
extensions: ["clj", "cljs", "cljc", "edn"],
|
||||
icon: IconCode,
|
||||
color: "text-green-700",
|
||||
},
|
||||
{
|
||||
extensions: ["hs", "lhs"],
|
||||
icon: IconCode,
|
||||
color: "text-purple-800",
|
||||
},
|
||||
{
|
||||
extensions: ["elm"],
|
||||
icon: IconCode,
|
||||
color: "text-blue-700",
|
||||
},
|
||||
{
|
||||
extensions: ["dart"],
|
||||
icon: IconCode,
|
||||
color: "text-blue-600",
|
||||
},
|
||||
{
|
||||
extensions: ["lua"],
|
||||
icon: IconCode,
|
||||
color: "text-blue-800",
|
||||
},
|
||||
{
|
||||
extensions: ["r", "rmd"],
|
||||
icon: IconMath,
|
||||
color: "text-blue-700",
|
||||
},
|
||||
{
|
||||
extensions: ["matlab", "m"],
|
||||
icon: IconMath,
|
||||
color: "text-orange-600",
|
||||
},
|
||||
{
|
||||
extensions: ["julia", "jl"],
|
||||
icon: IconMath,
|
||||
color: "text-purple-600",
|
||||
},
|
||||
|
||||
// Shell Scripts
|
||||
{
|
||||
extensions: ["sh", "bash", "zsh", "fish"],
|
||||
icon: IconTerminal,
|
||||
color: "text-green-600",
|
||||
},
|
||||
{
|
||||
extensions: ["ps1", "psm1", "psd1"],
|
||||
icon: IconTerminal,
|
||||
color: "text-blue-700",
|
||||
},
|
||||
{
|
||||
extensions: ["bat", "cmd"],
|
||||
icon: IconTerminal,
|
||||
color: "text-gray-600",
|
||||
},
|
||||
|
||||
// Database
|
||||
{
|
||||
extensions: ["sql", "mysql", "pgsql", "sqlite", "db"],
|
||||
icon: IconDatabase,
|
||||
color: "text-blue-700",
|
||||
},
|
||||
|
||||
// Configuration Files
|
||||
{
|
||||
extensions: ["json", "json5"],
|
||||
icon: IconCode,
|
||||
color: "text-yellow-700",
|
||||
},
|
||||
{
|
||||
extensions: ["yaml", "yml"],
|
||||
icon: IconSettings,
|
||||
color: "text-purple-600",
|
||||
},
|
||||
{
|
||||
extensions: ["toml"],
|
||||
icon: IconSettings,
|
||||
color: "text-orange-600",
|
||||
},
|
||||
{
|
||||
extensions: ["xml", "xsd", "xsl", "xslt"],
|
||||
icon: IconCode,
|
||||
color: "text-orange-700",
|
||||
},
|
||||
{
|
||||
extensions: ["ini", "cfg", "conf", "config"],
|
||||
icon: IconSettings,
|
||||
color: "text-gray-600",
|
||||
},
|
||||
{
|
||||
extensions: ["txt", "md", "rtf"],
|
||||
extensions: ["env", "dotenv"],
|
||||
icon: IconKey,
|
||||
color: "text-green-700",
|
||||
},
|
||||
{
|
||||
extensions: ["properties"],
|
||||
icon: IconSettings,
|
||||
color: "text-blue-600",
|
||||
},
|
||||
|
||||
// Docker & DevOps
|
||||
{
|
||||
extensions: ["dockerfile", "containerfile"],
|
||||
icon: IconBrandDocker,
|
||||
color: "text-blue-600",
|
||||
},
|
||||
{
|
||||
extensions: ["tf", "tfvars", "hcl"],
|
||||
icon: IconCloud,
|
||||
color: "text-purple-600",
|
||||
},
|
||||
{
|
||||
extensions: ["k8s", "kubernetes"],
|
||||
icon: IconCloud,
|
||||
color: "text-blue-700",
|
||||
},
|
||||
{
|
||||
extensions: ["ansible", "playbook"],
|
||||
icon: IconTool,
|
||||
color: "text-red-600",
|
||||
},
|
||||
|
||||
// Package Managers
|
||||
{
|
||||
extensions: ["package"],
|
||||
icon: IconBrandNpm,
|
||||
color: "text-red-600",
|
||||
},
|
||||
{
|
||||
extensions: ["yarn"],
|
||||
icon: IconBrandYarn,
|
||||
color: "text-blue-600",
|
||||
},
|
||||
{
|
||||
extensions: ["cargo"],
|
||||
icon: IconBrandRust,
|
||||
color: "text-orange-700",
|
||||
},
|
||||
{
|
||||
extensions: ["gemfile"],
|
||||
icon: IconCode,
|
||||
color: "text-red-500",
|
||||
},
|
||||
{
|
||||
extensions: ["composer"],
|
||||
icon: IconBrandPhp,
|
||||
color: "text-purple-700",
|
||||
},
|
||||
{
|
||||
extensions: ["requirements", "pipfile", "poetry"],
|
||||
icon: IconBrandPython,
|
||||
color: "text-yellow-600",
|
||||
},
|
||||
{
|
||||
extensions: ["gradle", "build.gradle"],
|
||||
icon: IconTool,
|
||||
color: "text-green-700",
|
||||
},
|
||||
{
|
||||
extensions: ["pom"],
|
||||
icon: IconCode,
|
||||
color: "text-orange-600",
|
||||
},
|
||||
{
|
||||
extensions: ["makefile", "cmake"],
|
||||
icon: IconTool,
|
||||
color: "text-blue-700",
|
||||
},
|
||||
|
||||
// Git
|
||||
{
|
||||
extensions: ["gitignore", "gitattributes", "gitmodules", "gitconfig"],
|
||||
icon: IconBrandGit,
|
||||
color: "text-orange-600",
|
||||
},
|
||||
|
||||
// Documentation
|
||||
{
|
||||
extensions: ["md", "markdown"],
|
||||
icon: IconMarkdown,
|
||||
color: "text-gray-700",
|
||||
},
|
||||
{
|
||||
extensions: ["rst", "txt"],
|
||||
icon: IconFileDescription,
|
||||
color: "text-gray-500",
|
||||
},
|
||||
{
|
||||
extensions: ["adoc", "asciidoc"],
|
||||
icon: IconBook,
|
||||
color: "text-blue-600",
|
||||
},
|
||||
{
|
||||
extensions: ["tex", "latex"],
|
||||
icon: IconMath,
|
||||
color: "text-green-700",
|
||||
},
|
||||
{
|
||||
extensions: ["log"],
|
||||
icon: IconBug,
|
||||
color: "text-yellow-600",
|
||||
},
|
||||
|
||||
// Templates
|
||||
{
|
||||
extensions: ["hbs", "handlebars", "mustache"],
|
||||
icon: IconCode,
|
||||
color: "text-orange-600",
|
||||
},
|
||||
{
|
||||
extensions: ["twig"],
|
||||
icon: IconCode,
|
||||
color: "text-green-600",
|
||||
},
|
||||
{
|
||||
extensions: ["liquid"],
|
||||
icon: IconCode,
|
||||
color: "text-blue-600",
|
||||
},
|
||||
{
|
||||
extensions: ["ejs", "pug", "jade"],
|
||||
icon: IconCode,
|
||||
color: "text-brown-600",
|
||||
},
|
||||
|
||||
// Data Formats
|
||||
{
|
||||
extensions: ["graphql", "gql"],
|
||||
icon: IconApi,
|
||||
color: "text-pink-600",
|
||||
},
|
||||
{
|
||||
extensions: ["proto", "protobuf"],
|
||||
icon: IconApi,
|
||||
color: "text-blue-700",
|
||||
},
|
||||
|
||||
// Security & Certificates
|
||||
{
|
||||
extensions: ["pem", "crt", "cer", "key", "p12", "pfx"],
|
||||
icon: IconLock,
|
||||
color: "text-green-800",
|
||||
},
|
||||
|
||||
// Web Assembly
|
||||
{
|
||||
extensions: ["wasm", "wat"],
|
||||
icon: IconAtom,
|
||||
color: "text-purple-700",
|
||||
},
|
||||
|
||||
// Shaders
|
||||
{
|
||||
extensions: ["glsl", "hlsl", "vert", "frag", "geom"],
|
||||
icon: IconDeviceDesktop,
|
||||
color: "text-cyan-700",
|
||||
},
|
||||
|
||||
// Specialized
|
||||
{
|
||||
extensions: ["vim", "vimrc"],
|
||||
icon: IconCode,
|
||||
color: "text-green-800",
|
||||
},
|
||||
{
|
||||
extensions: ["eslintrc", "prettierrc", "babelrc"],
|
||||
icon: IconSettings,
|
||||
color: "text-yellow-700",
|
||||
},
|
||||
{
|
||||
extensions: ["tsconfig", "jsconfig"],
|
||||
icon: IconSettings,
|
||||
color: "text-blue-700",
|
||||
},
|
||||
{
|
||||
extensions: ["webpack", "rollup", "vite"],
|
||||
icon: IconTool,
|
||||
color: "text-cyan-600",
|
||||
},
|
||||
{
|
||||
extensions: ["lock", "sum"],
|
||||
icon: IconLock,
|
||||
color: "text-gray-600",
|
||||
},
|
||||
|
||||
// Fallback for general text/code files
|
||||
{
|
||||
extensions: ["svelte", "astro", "erb", "haml", "slim"],
|
||||
icon: IconFileCode,
|
||||
color: "text-gray-600",
|
||||
},
|
||||
];
|
||||
|
||||
export function getFileIcon(filename: string): { icon: Icon; color: string } {
|
||||
|
||||
374
apps/web/src/utils/file-types.ts
Normal file
374
apps/web/src/utils/file-types.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
export type FileType = "pdf" | "image" | "audio" | "video" | "text" | "other";
|
||||
|
||||
export function getFileType(fileName: string): FileType {
|
||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||
|
||||
if (extension === "pdf") return "pdf";
|
||||
|
||||
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) {
|
||||
return "image";
|
||||
}
|
||||
|
||||
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) {
|
||||
return "audio";
|
||||
}
|
||||
|
||||
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) {
|
||||
return "video";
|
||||
}
|
||||
|
||||
const textExtensions = [
|
||||
// Data formats
|
||||
"json",
|
||||
"json5",
|
||||
"jsonp",
|
||||
"txt",
|
||||
"csv",
|
||||
"xml",
|
||||
"svg",
|
||||
"toml",
|
||||
"yaml",
|
||||
"yml",
|
||||
"ini",
|
||||
"conf",
|
||||
"config",
|
||||
"env",
|
||||
"properties",
|
||||
|
||||
// Documentation
|
||||
"md",
|
||||
"markdown",
|
||||
"adoc",
|
||||
"asciidoc",
|
||||
"rst",
|
||||
"textile",
|
||||
"wiki",
|
||||
"log",
|
||||
|
||||
// Web technologies
|
||||
"html",
|
||||
"htm",
|
||||
"xhtml",
|
||||
"css",
|
||||
"scss",
|
||||
"sass",
|
||||
"less",
|
||||
"stylus",
|
||||
|
||||
// JavaScript ecosystem
|
||||
"js",
|
||||
"jsx",
|
||||
"ts",
|
||||
"tsx",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"vue",
|
||||
"svelte",
|
||||
"coffee",
|
||||
"coffeescript",
|
||||
|
||||
// Programming languages
|
||||
"php",
|
||||
"py",
|
||||
"pyw",
|
||||
"rb",
|
||||
"java",
|
||||
"kt",
|
||||
"kts",
|
||||
"scala",
|
||||
"clj",
|
||||
"cljs",
|
||||
"cljc",
|
||||
"hs",
|
||||
"elm",
|
||||
"f#",
|
||||
"fs",
|
||||
"fsx",
|
||||
"vb",
|
||||
"vba",
|
||||
"c",
|
||||
"cpp",
|
||||
"cxx",
|
||||
"cc",
|
||||
"h",
|
||||
"hpp",
|
||||
"hxx",
|
||||
"cs",
|
||||
"go",
|
||||
"rs",
|
||||
"swift",
|
||||
"dart",
|
||||
"r",
|
||||
"rmd",
|
||||
"pl",
|
||||
"pm",
|
||||
|
||||
// Shell scripts
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"ps1",
|
||||
"bat",
|
||||
"cmd",
|
||||
|
||||
// Database
|
||||
"sql",
|
||||
"plsql",
|
||||
"psql",
|
||||
"mysql",
|
||||
"sqlite",
|
||||
|
||||
// Configuration files
|
||||
"dockerfile",
|
||||
"containerfile",
|
||||
"gitignore",
|
||||
"gitattributes",
|
||||
"gitmodules",
|
||||
"gitconfig",
|
||||
"editorconfig",
|
||||
"eslintrc",
|
||||
"prettierrc",
|
||||
"stylelintrc",
|
||||
"babelrc",
|
||||
"browserslistrc",
|
||||
"tsconfig",
|
||||
"jsconfig",
|
||||
"webpack",
|
||||
"rollup",
|
||||
"vite",
|
||||
"astro",
|
||||
|
||||
// Package managers
|
||||
"package",
|
||||
"composer",
|
||||
"gemfile",
|
||||
"podfile",
|
||||
"pipfile",
|
||||
"poetry",
|
||||
"pyproject",
|
||||
"requirements",
|
||||
"cargo",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"sbt",
|
||||
"build.gradle",
|
||||
"build.sbt",
|
||||
"pom",
|
||||
"build",
|
||||
|
||||
// Build tools
|
||||
"makefile",
|
||||
"cmake",
|
||||
"rakefile",
|
||||
"gradle",
|
||||
"gulpfile",
|
||||
"gruntfile",
|
||||
"justfile",
|
||||
|
||||
// Templates
|
||||
"hbs",
|
||||
"handlebars",
|
||||
"mustache",
|
||||
"twig",
|
||||
"jinja",
|
||||
"jinja2",
|
||||
"liquid",
|
||||
"ejs",
|
||||
"pug",
|
||||
"jade",
|
||||
|
||||
// Data serialization
|
||||
"proto",
|
||||
"protobuf",
|
||||
"avro",
|
||||
"thrift",
|
||||
"graphql",
|
||||
"gql",
|
||||
|
||||
// Markup & styling
|
||||
"tex",
|
||||
"latex",
|
||||
"bibtex",
|
||||
"rtf",
|
||||
"org",
|
||||
"pod",
|
||||
|
||||
// Specialized formats
|
||||
"vim",
|
||||
"vimrc",
|
||||
"tmux",
|
||||
"nginx",
|
||||
"apache",
|
||||
"htaccess",
|
||||
"robots",
|
||||
"sitemap",
|
||||
"webmanifest",
|
||||
"lock",
|
||||
"sum",
|
||||
"mod",
|
||||
"workspace",
|
||||
"solution",
|
||||
"sln",
|
||||
"csproj",
|
||||
"vcxproj",
|
||||
"xcodeproj",
|
||||
|
||||
// Additional programming languages
|
||||
"lua",
|
||||
"rb",
|
||||
"php",
|
||||
"asp",
|
||||
"aspx",
|
||||
"jsp",
|
||||
"erb",
|
||||
"haml",
|
||||
"slim",
|
||||
"perl",
|
||||
"awk",
|
||||
"sed",
|
||||
"tcl",
|
||||
"groovy",
|
||||
"scala",
|
||||
"rust",
|
||||
"zig",
|
||||
"nim",
|
||||
"crystal",
|
||||
"julia",
|
||||
"matlab",
|
||||
"octave",
|
||||
"wolfram",
|
||||
"mathematica",
|
||||
"sage",
|
||||
"maxima",
|
||||
"fortran",
|
||||
"cobol",
|
||||
"ada",
|
||||
"pascal",
|
||||
"delphi",
|
||||
"basic",
|
||||
"vb6",
|
||||
"assembly",
|
||||
"asm",
|
||||
"s",
|
||||
"nasm",
|
||||
"gas",
|
||||
"lisp",
|
||||
"scheme",
|
||||
"racket",
|
||||
"clojure",
|
||||
"erlang",
|
||||
"elixir",
|
||||
"haskell",
|
||||
"ocaml",
|
||||
"fsharp",
|
||||
"prolog",
|
||||
"mercury",
|
||||
"curry",
|
||||
"clean",
|
||||
"idris",
|
||||
"agda",
|
||||
"coq",
|
||||
"lean",
|
||||
"smalltalk",
|
||||
"forth",
|
||||
"factor",
|
||||
"postscript",
|
||||
"tcl",
|
||||
"tk",
|
||||
"expect",
|
||||
"applescript",
|
||||
"powershell",
|
||||
"autohotkey",
|
||||
"ahk",
|
||||
"autoit",
|
||||
"nsis",
|
||||
|
||||
// Web assembly and low level
|
||||
"wasm",
|
||||
"wat",
|
||||
"wast",
|
||||
"wit",
|
||||
"wai",
|
||||
|
||||
// Shaders
|
||||
"glsl",
|
||||
"hlsl",
|
||||
"cg",
|
||||
"fx",
|
||||
"fxh",
|
||||
"vsh",
|
||||
"fsh",
|
||||
"vert",
|
||||
"frag",
|
||||
"geom",
|
||||
"tesc",
|
||||
"tese",
|
||||
"comp",
|
||||
|
||||
// Game development
|
||||
"gdscript",
|
||||
"gd",
|
||||
"cs",
|
||||
"boo",
|
||||
"unityscript",
|
||||
"mel",
|
||||
"maxscript",
|
||||
"haxe",
|
||||
"as",
|
||||
"actionscript",
|
||||
|
||||
// DevOps & Infrastructure
|
||||
"tf",
|
||||
"tfvars",
|
||||
"hcl",
|
||||
"nomad",
|
||||
"consul",
|
||||
"vault",
|
||||
"packer",
|
||||
"ansible",
|
||||
"puppet",
|
||||
"chef",
|
||||
"salt",
|
||||
"k8s",
|
||||
"kubernetes",
|
||||
"helm",
|
||||
"kustomize",
|
||||
"skaffold",
|
||||
"tilt",
|
||||
"buildkite",
|
||||
"circleci",
|
||||
"travis",
|
||||
"jenkins",
|
||||
"github",
|
||||
"gitlab",
|
||||
"bitbucket",
|
||||
"azure",
|
||||
"aws",
|
||||
"gcp",
|
||||
"terraform",
|
||||
"cloudformation",
|
||||
|
||||
// Documentation generators
|
||||
"jsdoc",
|
||||
"javadoc",
|
||||
"godoc",
|
||||
"rustdoc",
|
||||
"sphinx",
|
||||
"mkdocs",
|
||||
"gitbook",
|
||||
"jekyll",
|
||||
"hugo",
|
||||
"gatsby",
|
||||
];
|
||||
|
||||
if (textExtensions.includes(extension || "")) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function getFileExtension(fileName: string): string {
|
||||
return fileName.split(".").pop()?.toLowerCase() || "";
|
||||
}
|
||||
17
docker-compose-bind-mount-example.yaml
Normal file
17
docker-compose-bind-mount-example.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
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
|
||||
# Optional: Set custom UID/GID for file permissions
|
||||
# - PALMR_UID=1000
|
||||
# - PALMR_GID=1000
|
||||
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 # Volume for the application data
|
||||
restart: unless-stopped # Restart the container unless it is stopped
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
- "5487:5487" # Web port
|
||||
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
|
||||
volumes:
|
||||
- palmr_data:/app/server # Volume for the application data
|
||||
- palmr_data:/app/server # Volume for the application data (changed from /data to /app/server)
|
||||
restart: unless-stopped # Restart the container unless it is stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -1,22 +1,133 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Starting Palmr Server with SQLite..."
|
||||
echo "🌴 Starting Palmr Server..."
|
||||
|
||||
# Set proper environment
|
||||
export HOME=/home/palmr
|
||||
export NPM_CONFIG_CACHE=/home/palmr/.npm
|
||||
export PNPM_HOME=/home/palmr/.pnpm
|
||||
# === UID/GID Runtime Configuration ===
|
||||
TARGET_UID=${PALMR_UID:-1001}
|
||||
TARGET_GID=${PALMR_GID:-1001}
|
||||
|
||||
cd /app/server
|
||||
if [ -n "$PALMR_UID" ] || [ -n "$PALMR_GID" ]; then
|
||||
echo "🔧 Runtime UID/GID: $TARGET_UID:$TARGET_GID"
|
||||
|
||||
# Update ownership of critical directories to match target UID/GID
|
||||
echo "🔐 Updating file ownership..."
|
||||
chown -R $TARGET_UID:$TARGET_GID /app/palmr-app 2>/dev/null || echo "⚠️ Some ownership changes may have failed"
|
||||
chown -R $TARGET_UID:$TARGET_GID /home/palmr 2>/dev/null || echo "⚠️ Some home directory ownership changes may have failed"
|
||||
|
||||
# Update ownership of data directory if it exists
|
||||
if [ -d "/app/server" ]; then
|
||||
chown -R $TARGET_UID:$TARGET_GID /app/server 2>/dev/null || echo "⚠️ Some data directory ownership changes may have failed"
|
||||
fi
|
||||
|
||||
echo "✅ UID/GID configuration completed"
|
||||
fi
|
||||
|
||||
echo "Generating Prisma client..."
|
||||
npx prisma generate --schema=./prisma/schema.prisma
|
||||
# Ensure we're in the correct directory
|
||||
cd /app/palmr-app
|
||||
|
||||
echo "Pushing database schema..."
|
||||
npx prisma db push --accept-data-loss
|
||||
# Set the database URL
|
||||
export DATABASE_URL="file:/app/server/prisma/palmr.db"
|
||||
|
||||
echo "Running database seeds..."
|
||||
node prisma/seed.js || echo "Seeds failed or already exist, continuing..."
|
||||
echo "📂 Data directory: /app/server"
|
||||
echo "💾 Database: $DATABASE_URL"
|
||||
|
||||
echo "Starting server application..."
|
||||
exec node dist/server.js
|
||||
# Create all necessary directories
|
||||
echo "📁 Creating data directories..."
|
||||
mkdir -p /app/server/prisma /app/server/uploads /app/server/temp-chunks /app/server/uploads/logo
|
||||
|
||||
# Fix ownership of database directory BEFORE database operations
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
echo "🔐 Ensuring proper ownership before database operations..."
|
||||
chown -R $TARGET_UID:$TARGET_GID /app/server/prisma 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Check if it's a first run (no database file exists)
|
||||
if [ ! -f "/app/server/prisma/palmr.db" ]; then
|
||||
echo "🚀 First run detected - setting up database..."
|
||||
|
||||
# Create database with proper schema path - run as target user to avoid permission issues
|
||||
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 seed script from application directory (where node_modules is) - as target user
|
||||
echo "🌱 Seeding database..."
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
|
||||
else
|
||||
node ./prisma/seed.js
|
||||
fi
|
||||
|
||||
echo "✅ Database setup completed!"
|
||||
else
|
||||
echo "♻️ Existing database found"
|
||||
|
||||
# Always run migrations to ensure schema is up to date - as target user
|
||||
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
|
||||
|
||||
# Check if configurations exist - as target user
|
||||
echo "🔍 Verifying database configurations..."
|
||||
CONFIG_COUNT=$(
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
su-exec $TARGET_UID:$TARGET_GID node -e "
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
prisma.appConfig.count()
|
||||
.then(count => {
|
||||
console.log(count);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(0);
|
||||
process.exit(0);
|
||||
});
|
||||
" 2>/dev/null || echo "0"
|
||||
else
|
||||
node -e "
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
prisma.appConfig.count()
|
||||
.then(count => {
|
||||
console.log(count);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(0);
|
||||
process.exit(0);
|
||||
});
|
||||
" 2>/dev/null || echo "0"
|
||||
fi
|
||||
)
|
||||
|
||||
if [ "$CONFIG_COUNT" -eq "0" ]; then
|
||||
echo "🌱 No configurations found, running seed..."
|
||||
# Always run seed from application directory where node_modules is available - as target user
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
|
||||
else
|
||||
node ./prisma/seed.js
|
||||
fi
|
||||
else
|
||||
echo "✅ Found $CONFIG_COUNT configurations"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "🚀 Starting Palmr server..."
|
||||
|
||||
# Drop privileges using su-exec with specific UID/GID
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
echo "🔽 Dropping privileges to UID:GID $TARGET_UID:$TARGET_GID"
|
||||
exec su-exec $TARGET_UID:$TARGET_GID node dist/server.js
|
||||
else
|
||||
# We're already running as non-root
|
||||
exec node dist/server.js
|
||||
fi
|
||||
34
infra/supervisord.conf
Normal file
34
infra/supervisord.conf
Normal file
@@ -0,0 +1,34 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/dev/stdout
|
||||
logfile_maxbytes=0
|
||||
pidfile=/var/run/supervisord.pid
|
||||
loglevel=info
|
||||
|
||||
[program:server]
|
||||
command=/bin/sh -c "export DATABASE_URL='file:/app/server/prisma/palmr.db' && export UPLOAD_PATH='/app/server/uploads' && export TEMP_CHUNKS_PATH='/app/server/temp-chunks' && /app/server-start.sh"
|
||||
directory=/app/palmr-app
|
||||
user=root
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
environment=PORT=3333,HOME="/home/palmr"
|
||||
priority=100
|
||||
|
||||
[program:web]
|
||||
command=/bin/sh -c 'echo "Waiting for API to be ready..."; while ! curl -f http://127.0.0.1:3333/health >/dev/null 2>&1; do echo "API not ready, waiting..."; sleep 2; done; echo "API is ready! Starting frontend..."; exec node server.js'
|
||||
directory=/app/web
|
||||
user=palmr
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
environment=PORT=5487,HOSTNAME="0.0.0.0",HOME="/home/palmr",API_BASE_URL="http://127.0.0.1:3333"
|
||||
priority=200
|
||||
startsecs=10
|
||||
Reference in New Issue
Block a user