Compare commits

...

85 Commits

Author SHA1 Message Date
Daniel Luiz Alves
5e889956c7 chore: update documentation links and references to 3.2-beta (#227) 2025-08-21 13:35:50 -03:00
Daniel Luiz Alves
5afc6ea271 chore: update documentation links and references to 3.2-beta
- Updated meta.json to include "3.2-beta" in the pages list.
- Revised documentation links across various files to point to the new 3.2-beta version, ensuring consistency in references for OIDC authentication, installation guides, and other related content.
- Adjusted layout and routing to default to the 3.2-beta documentation.
2025-08-21 13:34:06 -03:00
Daniel Luiz Alves
cc368377c2 feat: add comprehensive documentation for v3.2-beta (#226) 2025-08-21 13:07:51 -03:00
Daniel Luiz Alves
51764be7d4 feat: add comprehensive documentation for v3.2-beta 2025-08-21 12:26:35 -03:00
Daniel Luiz Alves
fe598b4a30 [Release] v3.2.0-beta (#225) 2025-08-21 11:40:02 -03:00
Daniel Luiz Alves
80286e57d9 chore: update package versions to 3.2.0-beta across all apps 2025-08-21 11:35:09 -03:00
Daniel Luiz Alves
9f36a48d15 Merge branch 'next' of github.com:kyantech/Palmr into next 2025-08-21 11:32:00 -03:00
Daniel Luiz Alves
94286e8452 feat: implement download memory management system
- Introduced a comprehensive download memory management system to handle large file downloads efficiently, preventing crashes and optimizing resource usage.
- Added configuration options for maximum concurrent downloads, memory thresholds, and queue sizes, allowing for adaptive scaling based on system resources.
- Implemented new API endpoints for managing the download queue, including status checks and cancellation of queued downloads.
- Updated documentation to include details on the new memory management features and their configuration.
- Enhanced user experience by integrating download queue indicators in the UI, providing real-time feedback on download status and estimated wait times.
2025-08-21 11:31:46 -03:00
Daniel Luiz Alves
0ce2d6a998 Fixes translation (#220) 2025-08-20 21:15:54 -03:00
Anthony Veaudry
9e15fd7d2e fixes translation 2025-08-20 11:07:06 +03:00
Daniel Luiz Alves
736348ebe8 feat: enhance content type handling (#222) 2025-08-19 10:08:58 -03:00
Daniel Luiz Alves
ddb981cba2 fix: reorder imports in FilesystemController for clarity
- Moved the ChunkManager and ChunkMetadata imports to improve code organization and readability in the FilesystemController file.
2025-08-19 10:05:39 -03:00
Daniel Luiz Alves
724452fb40 feat: enhance content type handling in filesystem and S3 storage providers
- Updated the FilesystemController to dynamically set the Content-Type header using the getContentType utility based on the file name.
- Modified the S3StorageProvider to include the ResponseContentType in the presigned URL generation, improving the handling of file types in responses.
2025-08-19 10:04:38 -03:00
Daniel Luiz Alves
a2ac6a6268 feat: add PRESIGNED_URL_EXPIRATION configuration option (#221) 2025-08-19 09:23:46 -03:00
Daniel Luiz Alves
aecda25b25 feat: add PRESIGNED_URL_EXPIRATION configuration option
- Introduced the PRESIGNED_URL_EXPIRATION environment variable across multiple configuration files to allow users to customize the expiration time for presigned URLs.
- Updated documentation to include details on the new variable, its default value, and guidance on choosing appropriate expiration times based on security and usability needs.
- Refactored relevant code to utilize the new configuration option for generating presigned URLs in the file and reverse share services.
2025-08-19 09:18:52 -03:00
Daniel Luiz Alves
0f22b0bb23 feat: implement public configuration retrieval (#218) 2025-08-18 20:35:02 -03:00
Daniel Luiz Alves
edf6d70d69 fix: correct formatting of sensitiveKeys array in AppService
- Removed unnecessary whitespace and added a trailing comma for consistency in the sensitiveKeys array within the getPublicConfigs method.
2025-08-18 20:32:34 -03:00
Daniel Luiz Alves
a2ecd2e221 fix: add missing newline at end of route.ts file 2025-08-18 20:31:39 -03:00
Daniel Luiz Alves
2f022cae5d feat: implement public configuration retrieval and update API
- Added a new endpoint to fetch public configurations, excluding sensitive data such as SMTP credentials.
- Updated the AppController and AppService to support the new functionality.
- Introduced a corresponding route in the web application to access public configurations securely.
- Refactored hooks to utilize the new public configuration retrieval method, enhancing security by avoiding exposure of sensitive data.
2025-08-18 20:30:30 -03:00
Daniel Luiz Alves
bb3669f5b2 feat: add placeholders in multiple languages (#217) 2025-08-18 17:47:34 -03:00
Daniel Luiz Alves
87fd8caf2c feat: add bulk delete confirmation and description placeholders in multiple languages
- Introduced new translation keys for bulk delete confirmation and description placeholders across various language files, enhancing user experience by providing clearer instructions.
- Updated the file and share modals to utilize the new translation keys for improved consistency and localization.
2025-08-18 17:41:33 -03:00
Daniel Luiz Alves
e8087a7c01 refactor: improve ReceivedFilesModal layout (#216) 2025-08-18 16:37:42 -03:00
Daniel Luiz Alves
4075a7df29 refactor: improve ReceivedFilesModal layout and remove unused ScrollArea component
- Updated the layout of the ReceivedFilesModal to enhance responsiveness and usability by replacing the ScrollArea with a div that manages overflow.
- Removed the unused ScrollArea import to clean up the codebase.
2025-08-18 16:24:59 -03:00
Daniel Luiz Alves
c081b6f764 feat: Add S3_REJECT_UNAUTHORIZED environment variable (#215) 2025-08-18 16:06:06 -03:00
Daniel Luiz Alves
ecaa6d0321 feat: add S3_REJECT_UNAUTHORIZED environment variable for self-signed certificate support
- Introduced the S3_REJECT_UNAUTHORIZED variable across multiple configuration files to allow users to disable strict SSL certificate validation for self-signed certificates.
- Updated documentation to reflect the new variable and its usage in various contexts, including examples for MinIO and S3-compatible services.
- Enhanced server configuration to handle the new variable appropriately, ensuring compatibility with self-hosted S3 solutions.
2025-08-18 16:03:50 -03:00
Daniel Luiz Alves
e7ae7833ad [RELEASE] v3.1.8-beta (#187) 2025-08-01 10:15:20 -03:00
Daniel Luiz Alves
22f34f6f81 chore: increment version numbers to 3.1.8-beta across all package.json files 2025-08-01 10:04:06 -03:00
Daniel Luiz Alves
29efe0a10e refactor: format public paths in RedirectHandler for improved readability
- Reformatted the publicPaths array in the RedirectHandler component for better code clarity and maintainability.
2025-08-01 10:02:52 -03:00
Daniel Luiz Alves
965c64b468 feat: update public paths in RedirectHandler for enhanced routing
- Added new public paths ("/s/" and "/r/") to the RedirectHandler component to support additional routes for unauthenticated users.
2025-08-01 10:01:59 -03:00
Daniel Luiz Alves
ce57cda672 feat: enhance authentication flow and user redirection (#183) 2025-07-30 02:03:19 -03:00
Daniel Luiz Alves
a59857079e feat: add QR code modal translations for multiple languages
- Introduced translations for the QR code sharing modal in various languages including Arabic, German, Spanish, French, Hindi, Italian, Japanese, Korean, Dutch, Polish, Portuguese, Russian, Turkish, and Chinese.
- Removed duplicate QR code modal entries from the respective language files to maintain consistency.
2025-07-30 01:31:44 -03:00
Daniel Luiz Alves
9ae2a0c628 feat: enhance authentication flow and user redirection
- Updated the getCurrentUser method to return null for unauthorized access instead of error messages.
- Modified API documentation to reflect changes in the response structure for the current user endpoint.
- Introduced a RedirectHandler component to manage user redirection based on authentication status.
- Enhanced home and login pages to handle loading states and redirect users appropriately based on authentication.
- Improved the useHome hook to manage visibility of the home page based on user authentication status.
2025-07-30 01:29:16 -03:00
Daniel Luiz Alves
f2c514cd82 refactor: remove demo functionality and related components (#182) 2025-07-30 00:43:03 -03:00
Daniel Luiz Alves
6755230c53 refactor: remove demo functionality and related components
- Deleted the demo page and its associated client component to streamline the application.
- Removed demo-related button from the home page.
- Updated environment variable configuration by removing the DEMO_MODE setting.
- Simplified file controller and storage service logic by eliminating demo mode checks.
2025-07-30 00:40:57 -03:00
Daniel Luiz Alves
f2a0e60f20 [RELEASE] v3.1.7-beta (#181) 2025-07-29 22:54:19 -03:00
Daniel Luiz Alves
6cb21e95c4 chore: increment version numbers to 3.1.7-beta across all package.json files 2025-07-29 22:20:56 -03:00
Daniel Luiz Alves
868add68a5 feat: enhance localization with new placeholders and error messages
- Added "namePlaceholder" to share creation modals across multiple languages for improved user guidance.
- Updated error messages in various languages to maintain consistency and clarity.
- Introduced "filesQueued" message for better user feedback during file uploads in multiple languages.
2025-07-29 22:16:07 -03:00
Daniel Luiz Alves
307148d951 fix: enhance reset-password script and update navbar responsiveness
- Updated reset-password.sh to set DATABASE_URL if not already defined and ensure the database directory exists.
- Improved navbar component responsiveness by adjusting visibility classes for navigation items and added a new sponsor link.
2025-07-29 18:37:22 -03:00
Daniel Luiz Alves
9cb4235550 [Release] v3.1.6-beta (#174) 2025-07-22 19:28:55 -03:00
Daniel Luiz Alves
6014b3e961 chore: update environment variable configurations and documentation
- Updated docker-compose files to provide clearer environment variable options for S3 and filesystem encryption.
- Removed default values for ENABLE_S3 and DISABLE_FILESYSTEM_ENCRYPTION in favor of commented guidance for user customization.
- Incremented version numbers to 3.1.6-beta across all relevant package.json files.
- Enhanced documentation to reflect changes in configuration and deployment instructions, including UID/GID handling and storage options.
2025-07-22 18:53:54 -03:00
Daniel Luiz Alves
32f0a891ba refactor: update filesystem encryption handling and configuration
refactor: simplify server startup script and move provider/config checks to separate files

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

- Changed default behavior to disable filesystem encryption for improved performance.
- Updated environment variable handling for DISABLE_FILESYSTEM_ENCRYPTION and ENCRYPTION_KEY across multiple configuration files.
- Added new scripts and configuration files for managing application settings and providers.
- Adjusted Dockerfile and server start scripts to reflect changes in UID/GID handling and file management.
- Enhanced documentation to clarify encryption options and their implications.
2025-07-22 16:02:44 -03:00
Daniel Luiz Alves
124ac46eeb [Release] v3.1.5-beta (#172) 2025-07-22 01:09:27 -03:00
Daniel Luiz Alves
d3e76c19bf chore: update package versions to 3.1.5-beta across all apps 2025-07-22 00:36:11 -03:00
Daniel Luiz Alves
dd1ce189ae refactor: improve file download handling and memory management (#171) 2025-07-22 00:28:43 -03:00
Daniel Luiz Alves
82e43b06c6 refactor(filesystem): improve file download handling and memory management
- Replace separate large/small file download methods with unified stream handling
- Add memory usage tracking and garbage collection for download operations
- Implement proper stream cleanup and error handling
- Remove redundant comments and simplify interval configuration
2025-07-22 00:24:47 -03:00
Daniel Luiz Alves
aab4e6d9df [Release] v3.1.4-beta (#169) 2025-07-21 18:42:28 -03:00
Daniel Luiz Alves
1f097678ce chore: add monorepo version update to the update-versions script 2025-07-21 18:11:19 -03:00
Daniel Luiz Alves
96cb4a04ec chore: update package versions to 3.1.4-beta across all apps 2025-07-21 18:07:03 -03:00
Daniel Luiz Alves
b7c4b37e89 Feat: Implement disable password authentication (#168) 2025-07-21 18:02:39 -03:00
Daniel Luiz Alves
952cf27ecb refactor: streamline authentication and password handling
- Removed unnecessary parameters from the GET request in the auth config route.
- Adjusted import order in the forgot password hook for consistency.
- Cleaned up password validation logic in the login schema for better readability.
2025-07-21 17:59:40 -03:00
Daniel Luiz Alves
765810e4e5 feat: implement disable password authentication configuration and validation
- Added a new configuration option for enabling/disabling password authentication.
- Implemented validation to prevent disabling password authentication if no other authentication providers are active.
- Updated authentication and login services to handle scenarios based on the password authentication setting.
- Enhanced the UI to reflect the password authentication status and provide user feedback accordingly.
- Added translations and error messages for better user experience across multiple languages.
2025-07-21 17:43:54 -03:00
Daniel Luiz Alves
36d09a7679 Feat: Enhance email sharing functionality (#166) 2025-07-21 15:26:13 -03:00
Daniel Luiz Alves
c6d6648942 feat: implement batch file upload notifications in reverse share service
- Added functionality to send email notifications upon batch file uploads to reverse shares.
- Integrated EmailService to handle email sending with a structured HTML template for notifications.
- Enhanced ReverseShareService to manage upload sessions and trigger notifications after file uploads.
2025-07-21 15:24:16 -03:00
Daniel Luiz Alves
54ca7580b0 feat: enhance email sharing functionality with sender information and improved HTML template
- Updated the sendShareNotification method to include senderName as an optional parameter.
- Enhanced the email template with a more structured HTML layout for better presentation.
- Integrated user service to retrieve sender information based on user ID, improving the personalization of share notifications.
2025-07-21 14:11:42 -03:00
Daniel Luiz Alves
4e53d239bb Feat: Add system information endpoint and integrate s3 support (#165) 2025-07-21 11:57:40 -03:00
Daniel Luiz Alves
6491894f0e fix: update dependency in GlobalDropZone to include S3 status 2025-07-21 11:52:09 -03:00
Daniel Luiz Alves
93e05dd913 feat: add system information endpoint and integrate S3 support
- Implemented a new endpoint to retrieve system information, including the active storage provider and S3 status.
- Updated the AppService to fetch system information and return relevant data.
- Integrated system information fetching in the FileUploadSection, GlobalDropZone, and UploadFileModal components to adjust upload behavior based on S3 availability.
- Enhanced chunked upload logic to conditionally use chunked uploads based on the storage provider.
2025-07-21 11:50:13 -03:00
Daniel Luiz Alves
2efe69e50b Feat: improve file download handling with streaming support (#163) 2025-07-21 10:32:29 -03:00
Daniel Luiz Alves
761865a6a3 feat: improve file download handling with streaming support
- Replaced buffer-based file downloads with streaming for large files in FilesystemController.
- Added createDecryptedReadStream method in FilesystemStorageProvider to facilitate streaming decryption.
- Updated chunk download method to use streams, enhancing performance and memory efficiency.
2025-07-21 10:30:59 -03:00
Daniel Luiz Alves
25fed8db61 v3.1.3-beta (#160) 2025-07-18 12:55:29 -03:00
Daniel Luiz Alves
de42e1ca47 chore: bump version to 3.1.3-beta for all packages
- Updated version number in package.json files for apps/docs, apps/server, and apps/web to reflect the new beta release.
2025-07-18 11:43:53 -03:00
Daniel Luiz Alves
138e20d36d fix: update button and status messages for consistency
- Capitalized the "Activate" and "Deactivate" status messages for improved readability.
- Adjusted the button component in the users header to remove unnecessary margin from the icon, enhancing layout consistency.
2025-07-18 11:41:14 -03:00
Daniel Luiz Alves
433610286c Feat: QR Code implementation (#159) 2025-07-18 11:19:02 -03:00
Daniel Luiz Alves
236f94247a feat: add QR code download functionality to share modals
- Integrated QR code generation and download options in both ShareFileModal and ShareMultipleFilesModal.
- Updated UI components to include a download button for QR codes, enhancing user experience.
- Improved icon usage by adding download functionality alongside existing share options.
2025-07-18 11:14:56 -03:00
Daniel Luiz Alves
1a5c1de510 feat: enhance share functionality with QR code support across multiple languages
- Added new translations for QR code interactions in various languages.
- Updated share link details to include options for viewing and downloading QR codes.
- Enhanced user experience by providing clear instructions and descriptions related to QR code usage.
- Improved consistency in UI components for QR code visibility and actions.
2025-07-18 02:09:00 -03:00
Daniel Luiz Alves
6fb55005d4 feat: enhance reverse share functionality with QR code support
- Added QR code viewing and downloading capabilities in the reverse shares section.
- Updated UI components to include QR code options in share details and cards.
- Introduced new state management for handling QR code visibility.
- Enhanced translations for QR code interactions across multiple languages.
2025-07-18 01:50:33 -03:00
Daniel Luiz Alves
4779671323 feat: add QR code functionality for share links
- Introduced a new QrCodeModal component to display and download QR codes for shared links.
- Updated share management to include functionality for viewing QR codes.
- Enhanced the GenerateShareLinkModal to include QR code generation and download options.
- Updated UI components to support QR code viewing and downloading in share details and recent shares sections.
- Added translations and improved user experience for share link generation and QR code interactions.
2025-07-18 01:17:54 -03:00
Daniel Luiz Alves
e7876739e7 docs: enhance encryption documentation and performance considerations
- Added a section on performance implications of filesystem encryption in the architecture documentation.
- Updated the quick-start guide to link to the new performance considerations section, emphasizing the impact of encryption on resource usage and file access strategies.
2025-07-17 18:22:24 -03:00
Daniel Luiz Alves
e699e30af3 chore: add DEFAULT_LANGUAGE environment variable support (#158) 2025-07-17 17:27:51 -03:00
Daniel Luiz Alves
7541a2b085 chore: add DEFAULT_LANGUAGE environment variable support
- Updated docker-compose files to include a commented-out DEFAULT_LANGUAGE variable for setting the default application language.
- Modified the Dockerfile to export NEXT_PUBLIC_DEFAULT_LANGUAGE with a fallback to 'en-US'.
- Enhanced documentation in the quick-start guide to reflect the new DEFAULT_LANGUAGE variable and its usage.
- Updated i18n request handling to support multiple locales based on the DEFAULT_LANGUAGE environment variable.
2025-07-17 17:24:51 -03:00
Daniel Luiz Alves
24aa605973 chore: remove deprecated docker-compose-synology-test.yaml file 2025-07-17 14:22:34 -03:00
Daniel Luiz Alves
fd28445680 chore: standardize package version format to remove 'v' prefix (#154) 2025-07-15 16:59:13 -03:00
Daniel Luiz Alves
19b7448c3a chore: standardize package version format to remove 'v' prefix in docs, server, and web applications 2025-07-15 16:51:52 -03:00
Daniel Luiz Alves
53c39135af fix: fix suspense fallback (#153) 2025-07-15 16:40:41 -03:00
Daniel Luiz Alves
b9147038e6 style(demo): add empty line after "use client" directive 2025-07-15 16:39:37 -03:00
Daniel Luiz Alves
9a0b7f5c55 refactor(demo): extract demo logic into separate component for better maintainability 2025-07-15 16:37:35 -03:00
Daniel Luiz Alves
2a5f9f03ae v3.1.2-beta (#152) 2025-07-15 15:50:28 -03:00
Daniel Luiz Alves
78f6e36fc9 chore: update package versions to v3.1.2-beta for docs, server, and web applications 2025-07-15 15:22:12 -03:00
Daniel Luiz Alves
8e7aadd183 docs(demo): implement live demo functionality and demo page
- Added a Live Demo button to the home page that generates a unique demo ID and token, storing them in session storage.
- Created a new DemoPage component to validate access using the demo ID and token, and to manage demo creation and status checking.
- Introduced BackgroundLights component for visual effects on the demo page.
- Enhanced user experience with loading states and error handling during demo generation.
2025-07-15 15:21:00 -03:00
Daniel Luiz Alves
794a2782ac feat(demo): add DEMO_MODE environment variable and storage limits
- Introduced a new DEMO_MODE environment variable to toggle demo functionality.
- Updated FileController and ReverseShareService to limit user storage to 200MB when DEMO_MODE is enabled.
- Enhanced StorageService to reflect demo storage limits in disk space calculations.
- Added missing authentication providers in the settings form and server start script for better provider management.
2025-07-15 13:45:46 -03:00
Daniel Luiz Alves
383f26e777 Merge branch 'next' of github.com:kyantech/Palmr into next 2025-07-14 17:25:53 -03:00
Daniel Luiz Alves
2db88d3902 chore: add DISABLE_FILESYSTEM_ENCRYPTION option
- Enhanced comments in docker-compose files to clarify the purpose of environment variables, including optional settings for UID, GID, and filesystem encryption.
- Introduced DISABLE_FILESYSTEM_ENCRYPTION variable to allow users to disable file encryption, making the ENCRYPTION_KEY optional.
- Updated documentation in quick-start guide to reflect changes in environment variable usage and security warnings.
2025-07-14 17:25:27 -03:00
Daniel Luiz Alves
5e96633a1e Fix docker-compose.yaml (#147) 2025-07-11 12:34:24 -03:00
Daniel Luiz Alves
6c80ad8b2a Fix docker-compose.yaml (#146) 2025-07-11 12:33:09 -03:00
GeorgH93
96bd39eb25 Fix docker-compose.yaml
Move PALMR_UID, PALMR_GID and SECURE_SITE into environment, to fix the compose file
2025-07-11 16:15:17 +02:00
199 changed files with 12571 additions and 3886 deletions

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ apps/server/dist/*
#DEFAULT
.env
.steering
data/
node_modules/

View File

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

View File

@@ -1,5 +0,0 @@

> palmr-docs@3.1-beta lint /Users/daniel/clones/Palmr/apps/docs
> eslint "src/**/*.+(ts|tsx)"

View File

@@ -1,267 +0,0 @@
---
title: Quick Start (Docker)
icon: "Rocket"
---
Welcome to the fastest way to deploy <span className="font-bold">Palmr.</span> - your secure, self-hosted file sharing solution. This guide will have you up and running in minutes, whether you're new to self-hosting or an experienced developer.
Palmr. offers flexible deployment options to match your infrastructure needs. This guide focuses on Docker deployment with our recommended filesystem storage, perfect for most use cases.
## Prerequisites
Ensure you have the following installed on your system:
- **Docker** - Container runtime ([installation guide](https://docs.docker.com/get-docker/))
- **Docker Compose** - Multi-container orchestration ([installation guide](https://docs.docker.com/compose/install/))
> **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).
## Storage Options
Palmr. supports two storage approaches for persistent data:
### Named Volumes (Recommended)
**Best for**: Production environments, automated deployments
- ✅ **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
### Bind Mounts
**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**: **Common Issue** - Requires UID/GID configuration (see troubleshooting below)
---
## 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:
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
ports:
- "5487:5487" # Web interface
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
volumes:
- palmr_data:/app/server # Named volume for the application data
restart: unless-stopped # Restart the container unless it is stopped
volumes:
palmr_data:
```
> **Note:** If you haveing problem with uploading files, try to change the `PALMR_UID` and `PALMR_GID` to the UID and GID of the user running the container. You can find the UID and GID of the user running the container with the command `id -u` and `id -g`. in Linux systems the default user is `1000` and the default group is `1000`. For test you can add the environment variables below to the `docker-compose.yaml` file and restart the container.
```yaml
environment:
- PALMR_UID=1000 # UID for the container processes (default is 1001)
- PALMR_GID=1000 # GID for the container processes (default is 1001)
```
> **Note:** For more information about UID and GID, see our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide.
### Deployment
```bash
docker-compose up -d
```
---
## Option 2: Bind Mounts
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
- PALMR_UID=1000 # UID for the container processes (default is 1001)
- PALMR_GID=1000 # GID for the container processes (default is 1001)
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 up -d
```
> **Permission Configuration**: If you encounter permission issues with bind mounts (common on NAS systems), see our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for automatic permission handling.
---
## Environment Variables
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.1-beta/reverse-proxy-configuration) guide for proper setup.
### Generate Secure Encryption Keys
Need a strong key for `ENCRYPTION_KEY`? Use our built-in generator to create cryptographically secure keys:
<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.1-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=your-secure-key-min-32-chars \
-p 5487:5487 \
-p 3333:3333 \
-v palmr_data:/app/server \
--restart unless-stopped \
kyantech/palmr:latest
```
**Bind Mount:**
```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
```
---
## Maintenance
### Updates
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
```
---
## Next Steps
Your Palmr. instance is now ready! Explore additional configuration options:
### Advanced Configuration
- **[UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration)** - Configure user permissions for NAS systems and custom environments
- **[S3 Storage](/docs/3.1-beta/s3-configuration)** - Scale with Amazon S3 or compatible storage providers
- **[Manual Installation](/docs/3.1-beta/manual-installation)** - Manual installation and custom configurations
### Integration & Development
- **[API Reference](/docs/3.1-beta/api)** - Integrate Palmr. with your applications
- **[Architecture Guide](/docs/3.1-beta/architecture)** - Understanding Palmr. components and design
---
Need help? Visit our [GitHub Issues](https://github.com/kyantech/Palmr/issues) or community discussions.

View File

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

View File

@@ -43,6 +43,16 @@ Palmr. uses **filesystem storage** as the default storage solution, keeping thin
- Excellent performance for local file operations
- Optional S3-compatible storage support for cloud deployments and scalability
#### Performance Considerations with Encryption
By default, filesystem storage operates without encryption for optimal performance, providing fast uploads and downloads with minimal CPU overhead. This approach is ideal for most use cases where performance is prioritized.
If you need to protect sensitive files at rest, you can enable encryption by setting `DISABLE_FILESYSTEM_ENCRYPTION=false` and providing an `ENCRYPTION_KEY` in your configuration. When enabled, Palmr uses AES-256-CBC encryption, which adds CPU overhead during uploads (encryption) and downloads (decryption), particularly for large files or in resource-constrained environments like containers or low-end VMs.
For optimal performance with encryption enabled, ensure your hardware supports AES-NI acceleration (check with `cat /proc/cpuinfo | grep aes` on Linux).
As an alternative, consider using S3-compatible object storage (e.g., AWS S3 or MinIO), which can offload file storage from the local filesystem and potentially reduce local CPU overhead for encryption/decryption. See [S3 Providers](/docs/3.2-beta/s3-providers) for setup instructions.
### Fastify + Zod + TypeScript
The backend of Palmr. is powered by **Fastify**, **Zod**, and **TypeScript**, creating a robust and type-safe API layer. Fastify is a super-fast Node.js web framework optimized for performance and low overhead, designed to handle lots of concurrent requests with minimal resource usage. Zod provides runtime type validation and schema definition, ensuring all incoming data is properly validated before reaching business logic. TypeScript adds compile-time type safety throughout the entire backend codebase. This combination creates a highly reliable and maintainable backend that prevents bugs and security issues while maintaining excellent performance.
@@ -117,7 +127,7 @@ Palmr. is designed to be flexible in how you handle file storage:
**Optional S3-compatible storage:**
- Enable S3 storage by setting `ENABLE_S3=true`, look at [S3 Providers](/docs/3.1-beta/s3-providers) for more information.
- Enable S3 storage by setting `ENABLE_S3=true`, look at [S3 Providers](/docs/3.2-beta/s3-providers) for more information.
- Compatible with AWS S3, MinIO, and other S3-compatible services
- Ideal for cloud deployments and distributed setups
- Provides additional scalability and redundancy options

View File

@@ -0,0 +1,391 @@
---
title: Memory Management
icon: Download
---
import { Callout } from "fumadocs-ui/components/callout";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
Palmr implements an intelligent memory management system that prevents crashes during large file downloads (3GB+ by default), maintaining unlimited download capacity through adaptive resource control and an automatic queue system.
## How It Works
### Automatic Resource Detection
The system automatically detects available container/system memory and configures appropriate limits based on available infrastructure:
```typescript
const totalMemoryGB = require("os").totalmem() / 1024 ** 3;
```
### System Configuration
The system supports two configuration approaches that you can choose based on your needs:
<Tabs items={["Manual Configuration", "Auto-scaling (Default)"]}>
<Tab value="Manual Configuration">
Manually configure all parameters for total control over the system:
```bash
# Custom configuration (overrides auto-scaling)
DOWNLOAD_MAX_CONCURRENT=8 # Maximum simultaneous downloads
DOWNLOAD_MEMORY_THRESHOLD_MB=1536 # Memory threshold in MB
DOWNLOAD_QUEUE_SIZE=40 # Maximum queue size
DOWNLOAD_AUTO_SCALE=false # Disable auto-scaling
```
<Callout>
Manual configuration offers total control and predictability for specific environments where you know exactly the available resources.
</Callout>
</Tab>
<Tab value="Auto-scaling (Default)">
Automatic configuration based on detected system memory:
| Available Memory | Concurrent Downloads | Memory Threshold | Queue Size | Recommended Use |
|------------------|----------------------|-------------------|------------|--------------------|
| ≤ 2GB | 1 | 256MB | 5 | Development |
| 2GB - 4GB | 2 | 512MB | 10 | Small Environment |
| 4GB - 8GB | 3 | 1GB | 15 | Standard Production|
| 8GB - 16GB | 5 | 2GB | 25 | High Performance |
| > 16GB | 10 | 4GB | 50 | Enterprise |
<Callout>
Auto-scaling automatically adapts to different environments without manual configuration, perfect for flexible deployment.
</Callout>
</Tab>
</Tabs>
<Callout type="info">If environment variables are configured, they take **priority** over auto-scaling.</Callout>
## Download Queue System
### How It Works
The memory management system only activates for files larger than the configured minimum size (3GB by default). Smaller files bypass the queue system entirely and download immediately without memory management.
When a user requests a download for a large file but all slots are occupied, the system automatically queues the download instead of returning a 429 error. The queue processes downloads in FIFO order (first in, first out).
### Practical Example
Consider a system with 8GB RAM (5 concurrent downloads, queue of 25, 3GB minimum) where users want to download files of various sizes:
```bash
# Small files (< 3GB): Bypass queue entirely
[DOWNLOAD MANAGER] File document.pdf (0.05GB) below threshold (3.0GB), bypassing queue
# Large files 1-5: Start immediately
[DOWNLOAD MANAGER] Immediate start: 1734567890-abc123def
[DOWNLOAD MANAGER] Starting video1.mp4 (5.2GB)
# Large files 6-10: Automatically queued
[DOWNLOAD MANAGER] Queued: 1734567891-def456ghi (Position: 1/25)
[DOWNLOAD MANAGER] Queued file: video2.mp4 (8.1GB)
# When download 1 finishes: download 6 starts automatically
[DOWNLOAD MANAGER] Processing queue: 1734567891-def456ghi (4 remaining)
[DOWNLOAD MANAGER] Starting queued file: video2.mp4 (8.1GB)
```
### System Benefits
**User Experience**
- Users don't receive errors, they simply wait in queue
- Downloads start automatically when slots become available
- Transparent operation without client changes
- Fair processing order with FIFO queue
**Technical Features**
- Limited buffers (64KB per stream) for controlled memory usage
- Automatic backpressure control with pipeline streams
- Adaptive memory throttling based on usage patterns
- Forced garbage collection after large downloads
- Smart timeout handling (30 minutes for queued downloads)
- Automatic cleanup of orphaned downloads every 30 seconds
## Container Compatibility
The system works with Docker, Kubernetes, and any containerized environment:
<Tabs items={["Docker", "Kubernetes", "Docker Compose"]}>
<Tab value="Docker">
```bash
# Example: Container with 8GB
docker run -m 8g palmr/server
# Result: 5 concurrent downloads, queue of 25, threshold 2GB
```
</Tab>
<Tab value="Kubernetes">
```yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: palmr-server
resources:
limits:
memory: "4Gi" # Detects 4GB
cpu: "2"
requests:
memory: "2Gi"
cpu: "1"
# Result: 3 concurrent downloads, queue of 15, threshold 1GB
```
</Tab>
<Tab value="Docker Compose">
```yaml
services:
palmr-server:
image: palmr/server
deploy:
resources:
limits:
memory: 16G # Detects 16GB
# Result: 10 concurrent downloads, queue of 50, threshold 4GB
```
</Tab>
</Tabs>
## Configuration
### Environment Variables
Configure the download memory management system using these environment variables:
| Variable | Default | Description |
| ------------------------------ | ---------- | ----------------------------------------------------- |
| `DOWNLOAD_MAX_CONCURRENT` | auto-scale | Maximum number of simultaneous downloads |
| `DOWNLOAD_MEMORY_THRESHOLD_MB` | auto-scale | Memory limit in MB before throttling |
| `DOWNLOAD_QUEUE_SIZE` | auto-scale | Maximum download queue size |
| `DOWNLOAD_AUTO_SCALE` | `true` | Enable/disable auto-scaling based on system memory |
| `DOWNLOAD_MIN_FILE_SIZE_GB` | `3.0` | Minimum file size in GB to activate memory management |
### Configuration Examples by Scenario
<Tabs items={["Home Server", "Enterprise", "High Performance", "Conservative"]}>
<Tab value="Home Server">
Configuration optimized for personal use or small groups (4GB RAM):
```bash
DOWNLOAD_MAX_CONCURRENT=2
DOWNLOAD_MEMORY_THRESHOLD_MB=1024
DOWNLOAD_QUEUE_SIZE=8
DOWNLOAD_MIN_FILE_SIZE_GB=2.0
DOWNLOAD_AUTO_SCALE=false
```
</Tab>
<Tab value="Enterprise">
Configuration for corporate environments with multiple users (16GB RAM):
```bash
DOWNLOAD_MAX_CONCURRENT=12
DOWNLOAD_MEMORY_THRESHOLD_MB=4096
DOWNLOAD_QUEUE_SIZE=60
DOWNLOAD_MIN_FILE_SIZE_GB=5.0
DOWNLOAD_AUTO_SCALE=false
```
</Tab>
<Tab value="High Performance">
Configuration for maximum performance and throughput (32GB RAM):
```bash
DOWNLOAD_MAX_CONCURRENT=20
DOWNLOAD_MEMORY_THRESHOLD_MB=8192
DOWNLOAD_QUEUE_SIZE=100
DOWNLOAD_MIN_FILE_SIZE_GB=10.0
DOWNLOAD_AUTO_SCALE=false
```
</Tab>
<Tab value="Conservative">
For environments with limited or shared resources:
```bash
DOWNLOAD_MAX_CONCURRENT=3
DOWNLOAD_MEMORY_THRESHOLD_MB=1024
DOWNLOAD_QUEUE_SIZE=15
DOWNLOAD_MIN_FILE_SIZE_GB=1.0
DOWNLOAD_AUTO_SCALE=false
```
</Tab>
</Tabs>
### Additional Configuration
For optimal performance with large downloads, consider these additional settings:
```bash
# Force garbage collection (recommended for large downloads)
NODE_OPTIONS="--expose-gc"
# Adjust timeout for very large downloads
KEEP_ALIVE_TIMEOUT=300000
REQUEST_TIMEOUT=0
```
## Monitoring and Logs
### System Logs
The system provides detailed logs to track operation:
```bash
[DOWNLOAD MANAGER] System Memory: 8.0GB, Max Concurrent: 5, Memory Threshold: 2048MB, Queue Size: 25
[DOWNLOAD] Requesting slot for 1734567890-abc123def: video.mp4 (15.2GB)
[DOWNLOAD MANAGER] Queued: 1734567890-abc123def (Position: 3/25)
[DOWNLOAD MANAGER] Processing queue: 1734567890-abc123def (2 remaining)
[DOWNLOAD] Starting 1734567890-abc123def: video.mp4 (15.2GB)
[MEMORY THROTTLE] video.mp4 - Pausing stream due to high memory usage: 1843MB
[DOWNLOAD] Applying throttling: 100ms delay for 1734567890-abc123def
```
### Configuration Validation
The system automatically validates configurations at startup and provides warnings or errors:
**Warnings**
- `DOWNLOAD_MAX_CONCURRENT > 50`: May cause performance issues
- `DOWNLOAD_MEMORY_THRESHOLD_MB < 128MB`: Downloads may be throttled frequently
- `DOWNLOAD_MEMORY_THRESHOLD_MB > 16GB`: System may run out of memory
- `DOWNLOAD_QUEUE_SIZE > 1000`: May consume significant memory
- `DOWNLOAD_QUEUE_SIZE < DOWNLOAD_MAX_CONCURRENT`: Queue smaller than concurrent downloads
**Errors**
- `DOWNLOAD_MAX_CONCURRENT < 1`: Invalid value
- `DOWNLOAD_QUEUE_SIZE < 1`: Invalid value
## Queue Management APIs
The system provides REST APIs to monitor and manage the download queue:
### Get Queue Status
```http
GET /api/filesystem/download-queue/status
```
**Response:**
```json
{
"data": {
"queueLength": 3,
"maxQueueSize": 25,
"activeDownloads": 5,
"maxConcurrent": 5,
"queuedDownloads": [
{
"downloadId": "1734567890-abc123def",
"position": 1,
"waitTime": 45000,
"fileName": "video.mp4",
"fileSize": 16106127360
}
]
},
"status": "success"
}
```
### Cancel Download
```http
DELETE /api/filesystem/download-queue/{downloadId}
```
**Response:**
```json
{
"downloadId": "1734567890-abc123def",
"message": "Download cancelled successfully"
}
```
### Clear Queue (Admin)
```http
DELETE /api/filesystem/download-queue
```
**Response:**
```json
{
"clearedCount": 8,
"message": "Download queue cleared successfully"
}
```
## Troubleshooting
### Common Issues
**Downloads failing with "Download queue is full"**
_Cause:_ Too many simultaneous downloads with a full queue
_Solutions:_
- Wait for some downloads to finish
- Check for orphaned downloads in queue
- Consider increasing container resources
- Use API to clear queue if necessary
**Downloads stay too long in queue**
_Cause:_ Active downloads are slow or stuck
_Solutions:_
- Check logs for orphaned downloads
- Use API to cancel specific downloads
- Check client network connections
- Monitor memory throttling
**Very slow downloads**
_Cause:_ Active throttling due to high memory usage
_Solutions:_
- Check other processes consuming memory
- Consider increasing container resources
- Monitor throttling logs
- Check number of simultaneous downloads
## Summary
This system enables unlimited downloads (including 50TB+ files) without compromising system stability through:
**Key Features**
- Auto-configuration based on available resources
- Automatic FIFO queue system for pending downloads
- Adaptive control of simultaneous downloads
- Intelligent throttling when needed
**System Benefits**
- Management APIs to monitor and control queue
- Automatic cleanup of resources and orphaned downloads
- Full compatibility with Docker/Kubernetes
- Perfect user experience with no 429 errors
The system maintains high performance for small/medium files while preventing crashes with gigantic files, offering a seamless experience where users never see 429 errors, they simply wait in queue until their download starts automatically.

View File

@@ -5,7 +5,7 @@ icon: Cog
Hey there! Looking to run **Palmr.** your way, with complete control over every piece of the stack? This manual installation guide is for you. No Docker, no pre-built containers just the raw source code to tweak, customize, and deploy as you see fit.
> **Prefer a quicker setup?** If this hands-on approach feels like overkill, check out our [**Quick Start (Docker)**](/docs/3.1-beta/quick-start) guide for a fast, containerized deployment. This manual path is tailored for developers who want to dive deep, modify the codebase, or integrate custom services.
> **Prefer a quicker setup?** If this hands-on approach feels like overkill, check out our [**Quick Start (Docker)**](/docs/3.2-beta/quick-start) guide for a fast, containerized deployment. This manual path is tailored for developers who want to dive deep, modify the codebase, or integrate custom services.
Here's what you'll do at a glance:
@@ -232,10 +232,10 @@ pnpm serve
Palmr. is now up and running locally . Here are some suggested next steps:
- **Manage Users**: Dive into the [Users Management](/docs/3.1-beta/manage-users) guide.
- **Manage Users**: Dive into the [Users Management](/docs/3.2-beta/manage-users) guide.
- **Switch to Object Storage**: Update `.env` variables to use an S3-compatible bucket (see Quick Notes above).
- **Secure Your Instance**: Put Palmr. behind a reverse proxy like **Nginx** or **Caddy** and enable HTTPS.
- **Learn the Internals**: Explore how everything connects in the [Architecture](/docs/3.1-beta/architecture) overview.
- **Learn the Internals**: Explore how everything connects in the [Architecture](/docs/3.2-beta/architecture) overview.
Jump into whichever area fits your needs our docs are designed for exploration in any order.

View File

@@ -14,6 +14,7 @@
"available-languages",
"uid-gid-configuration",
"reverse-proxy-configuration",
"download-memory-management",
"password-reset-without-smtp",
"oidc-authentication",
"troubleshooting",
@@ -29,5 +30,5 @@
"gh-sponsor"
],
"root": true,
"title": "v3.1-beta"
}
"title": "v3.2-beta"
}

View File

@@ -360,7 +360,7 @@ With Auth0 authentication configured, you might want to:
- **Review security settings**: Ensure your authentication setup meets your security requirements
- **Monitor usage**: Keep track of authentication patterns and user activity
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.1-beta/oidc-authentication).
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.2-beta/oidc-authentication).
## Useful resources

View File

@@ -374,7 +374,7 @@ With Authentik authentication configured, you might want to:
- **Review security settings**: Ensure your authentication setup meets your security requirements
- **Monitor usage**: Keep track of authentication patterns and user activity
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.1-beta/oidc-authentication).
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.2-beta/oidc-authentication).
## Useful resources

View File

@@ -332,7 +332,7 @@ With Discord authentication configured, you might want to:
- **Review security settings**: Ensure your authentication setup meets your security requirements
- **Monitor usage**: Keep track of authentication patterns and user activity
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.1-beta/oidc-authentication).
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.2-beta/oidc-authentication).
## Useful resources

View File

@@ -314,7 +314,7 @@ With Frontegg authentication configured, you might want to:
- **Review security settings**: Ensure your authentication setup meets your security requirements
- **Monitor usage**: Keep track of authentication patterns and user activity
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.1-beta/oidc-authentication).
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.2-beta/oidc-authentication).
## Useful resources

View File

@@ -295,7 +295,7 @@ With GitHub authentication configured, you might want to:
- **Review security settings**: Ensure your authentication setup meets your security requirements
- **Monitor usage**: Keep track of authentication patterns and user activity
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.1-beta/oidc-authentication).
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.2-beta/oidc-authentication).
## Useful resources

View File

@@ -325,7 +325,7 @@ With Google authentication configured, you might want to:
- **Review security settings**: Ensure your authentication setup meets your security requirements
- **Monitor usage**: Keep track of authentication patterns and user activity
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.1-beta/oidc-authentication).
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.2-beta/oidc-authentication).
## Useful resources

View File

@@ -38,14 +38,14 @@ Before configuring OIDC authentication, ensure you have:
Palmr's OIDC implementation is compatible with any OpenID Connect compliant provider, including as official providers:
- **[Google](/docs/3.1-beta/oidc-authentication/google)**
- **[Discord](/docs/3.1-beta/oidc-authentication/discord)**
- **[Github](/docs/3.1-beta/oidc-authentication/github)**
- **[Zitadel](/docs/3.1-beta/oidc-authentication/zitadel)**
- **[Auth0](/docs/3.1-beta/oidc-authentication/auth0)**
- **[Authentik](/docs/3.1-beta/oidc-authentication/authentik)**
- **[Frontegg](/docs/3.1-beta/oidc-authentication/frontegg)**
- **[Kinde Auth](/docs/3.1-beta/oidc-authentication/kinde-auth)**
- **[Google](/docs/3.2-beta/oidc-authentication/google)**
- **[Discord](/docs/3.2-beta/oidc-authentication/discord)**
- **[Github](/docs/3.2-beta/oidc-authentication/github)**
- **[Zitadel](/docs/3.2-beta/oidc-authentication/zitadel)**
- **[Auth0](/docs/3.2-beta/oidc-authentication/auth0)**
- **[Authentik](/docs/3.2-beta/oidc-authentication/authentik)**
- **[Frontegg](/docs/3.2-beta/oidc-authentication/frontegg)**
- **[Kinde Auth](/docs/3.2-beta/oidc-authentication/kinde-auth)**
Although these are the official providers (internally tested with 100% success), you can connect any OIDC provider by providing your credentials and connection URL. We've developed a practical way to integrate virtually all OIDC providers available in the market. In this documentation, you can consult how to configure each of the official providers, as well as include other providers not listed as official. Just below, you will find instructions on how to access the OIDC provider configuration. For specific details about configuring each provider, select the desired option in the sidebar, in the "OIDC Authentication" section.

View File

@@ -359,7 +359,7 @@ With Kinde Auth authentication configured, you might want to:
- **Review security settings**: Ensure your authentication setup meets your security requirements
- **Monitor usage**: Keep track of authentication patterns and user activity
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.1-beta/oidc-authentication).
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.2-beta/oidc-authentication).
## Useful resources

View File

@@ -270,10 +270,10 @@ After configuring Pocket ID authentication:
- **User management**: Review auto-registration settings
- **Backup verification**: Test backup and restore procedures
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.1-beta/oidc-authentication).
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.2-beta/oidc-authentication).
## Useful resources
- [Pocket ID Documentation](https://docs.pocket-id.org)
- [OIDC Specification](https://openid.net/specs/openid-connect-core-1_0.html)
- [Palmr OIDC Overview](/docs/3.1-beta/oidc-authentication)
- [Palmr OIDC Overview](/docs/3.2-beta/oidc-authentication)

View File

@@ -413,7 +413,7 @@ With Zitadel authentication configured, you might want to:
- **Review security settings**: Ensure your authentication setup meets your security requirements
- **Monitor usage**: Keep track of authentication patterns and user activity
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.1-beta/oidc-authentication).
For more information about OIDC authentication in Palmr, see the [OIDC Authentication overview](/docs/3.2-beta/oidc-authentication).
## Useful resources

View File

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

View File

@@ -0,0 +1,378 @@
---
title: Quick Start (Docker)
icon: "Rocket"
---
import { Callout } from "fumadocs-ui/components/callout";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { Card, CardGrid } from "@/components/ui/card";
Welcome to the fastest way to deploy <span className="font-bold">Palmr.</span> - your secure, self-hosted file sharing solution. This guide will have you up and running in minutes, whether you're new to self-hosting or an experienced developer.
Palmr. offers flexible deployment options to match your infrastructure needs. This guide focuses on Docker deployment with our recommended filesystem storage, perfect for most use cases.
## Prerequisites
Before you begin, make sure you have:
- **Docker** - Container runtime ([installation guide](https://docs.docker.com/get-docker/))
- **Docker Compose** - Multi-container orchestration ([installation guide](https://docs.docker.com/compose/install/))
- **2GB+ available disk space** for the application and your files
- **Port 5487** available for the web interface
- **Port 3333** available for API access (optional)
<Callout>
**Platform Support**: Palmr. is developed on macOS and extensively tested on Linux servers. While we haven't formally
tested other platforms, Docker's cross-platform nature should ensure compatibility. Report any issues on our [GitHub
repository](https://github.com/kyantech/Palmr/issues).
</Callout>
## Storage Options
Palmr. supports two storage approaches for persistent data:
- **Named Volumes (Recommended)** - Docker-managed storage with optimal performance and no permission issues
- **Bind Mounts** - Direct host filesystem access, ideal for development and direct file management
## Deployment Options
Choose your storage method based on your needs:
<Tabs items={['Named Volumes (Recommended)', 'Bind Mounts']}>
<Tab value="Named Volumes (Recommended)">
Docker-managed storage that provides the best balance of performance, security, and ease of use:
- **No Permission Issues**: Docker handles all permission management automatically
- **Performance**: Optimized for container workloads with better I/O performance
- **Production Ready**: Recommended for production deployments
### Configuration
Create a `docker-compose.yml` file:
```yaml
services:
palmr:
image: kyantech/palmr:latest
container_name: palmr
restart: unless-stopped
ports:
- "5487:5487" # Web interface
# - "3333:3333" # API (optional)
environment:
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
# - ENABLE_S3=true # Set to true to enable S3-compatible storage
# - DISABLE_FILESYSTEM_ENCRYPTION=true # Set to false to enable file encryption
# - ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
# - PALMR_UID=1000 # UID for the container processes (default is 1000)
# - PALMR_GID=1000 # GID for the container processes (default is 1000)
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
# - PRESIGNED_URL_EXPIRATION=3600 # Duration in seconds for presigned URL expiration (optional, defaults to 3600 seconds / 1 hour)
# Download Memory Management (OPTIONAL - See Download Memory Management documentation)
# - DOWNLOAD_MAX_CONCURRENT=5 # Maximum simultaneous downloads (auto-scales if not set)
# - DOWNLOAD_MEMORY_THRESHOLD_MB=2048 # Memory threshold in MB before throttling (auto-scales if not set)
# - DOWNLOAD_QUEUE_SIZE=25 # Maximum queue size for pending downloads (auto-scales if not set)
# - DOWNLOAD_MIN_FILE_SIZE_GB=3.0 # Minimum file size in GB to activate memory management (default: 3.0)
# - DOWNLOAD_AUTO_SCALE=true # Enable auto-scaling based on system memory (default: true)
# - NODE_OPTIONS=--expose-gc # Enable garbage collection for large downloads (recommended for production)
volumes:
- palmr_data:/app/server
volumes:
palmr_data:
```
<Callout type="info">
**Having upload or permission issues?** Add `PALMR_UID=1000` and `PALMR_GID=1000` to your environment variables. Check our [UID/GID Configuration](/docs/3.2-beta/uid-gid-configuration) guide for more details.
</Callout>
### Deploy
```bash
docker-compose up -d
```
</Tab>
<Tab value="Bind Mounts">
Direct mapping to host filesystem directories, providing direct file access:
- **Direct Access**: Files are directly accessible from your host system
- **Development Friendly**: Easy to inspect, modify, or backup files manually
- **Platform Dependent**: May require UID/GID configuration, especially on NAS systems
### Configuration
Create a `docker-compose.yml` file:
```yaml
services:
palmr:
image: kyantech/palmr:latest
container_name: palmr
restart: unless-stopped
ports:
- "5487:5487" # Web interface
# - "3333:3333" # API (optional)
environment:
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
# - ENABLE_S3=true # Set to true to enable S3-compatible storage
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to false to enable file encryption
# - ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
# - PALMR_UID=1000 # UID for the container processes (default is 1000)
# - PALMR_GID=1000 # GID for the container processes (default is 1000)
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
# - DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
# - PRESIGNED_URL_EXPIRATION=3600 # Duration in seconds for presigned URL expiration (optional, defaults to 3600 seconds / 1 hour)
# Download Memory Management (OPTIONAL - See Download Memory Management documentation)
# - DOWNLOAD_MAX_CONCURRENT=5 # Maximum simultaneous downloads (auto-scales if not set)
# - DOWNLOAD_MEMORY_THRESHOLD_MB=2048 # Memory threshold in MB before throttling (auto-scales if not set)
# - DOWNLOAD_QUEUE_SIZE=25 # Maximum queue size for pending downloads (auto-scales if not set)
# - DOWNLOAD_MIN_FILE_SIZE_GB=3.0 # Minimum file size in GB to activate memory management (default: 3.0)
# - DOWNLOAD_AUTO_SCALE=true # Enable auto-scaling based on system memory (default: true)
# - NODE_OPTIONS=--expose-gc # Enable garbage collection for large downloads (recommended for production)
volumes:
- ./data:/app/server
```
<Callout type="info">
**Having upload or permission issues?** Add `PALMR_UID=1000` and `PALMR_GID=1000` to your environment variables. Check our [UID/GID Configuration](/docs/3.2-beta/uid-gid-configuration) guide for more details.
</Callout>
### Deploy
```bash
docker-compose up -d
```
</Tab>
</Tabs>
## Configuration
Customize Palmr's behavior with these environment variables:
| Variable | Default | Description |
| ------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| `ENABLE_S3` | `false` | Enable S3-compatible storage backends |
| `S3_REJECT_UNAUTHORIZED` | `true` | Enable strict SSL certificate validation for S3 (set to `false` for self-signed certificates) |
| `ENCRYPTION_KEY` | - | **Required when encryption is enabled**: 32+ character key for file encryption |
| `DISABLE_FILESYSTEM_ENCRYPTION` | `true` | Disable file encryption for better performance (set to `false` to enable encryption) |
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy deployments |
| `DEFAULT_LANGUAGE` | `en-US` | Default application language ([see available languages](/docs/3.2-beta/available-languages)) |
| `PALMR_UID` | `1000` | User ID for container processes (helps with file permissions) |
| `PALMR_GID` | `1000` | Group ID for container processes (helps with file permissions) |
| `DOWNLOAD_MAX_CONCURRENT` | auto-scale | Maximum number of simultaneous downloads (see [Download Memory Management](/docs/3.2-beta/download-memory-management)) |
| `DOWNLOAD_MEMORY_THRESHOLD_MB` | auto-scale | Memory threshold in MB before throttling |
| `DOWNLOAD_QUEUE_SIZE` | auto-scale | Maximum queue size for pending downloads |
| `DOWNLOAD_MIN_FILE_SIZE_GB` | `3.0` | Minimum file size in GB to activate memory management |
| `DOWNLOAD_AUTO_SCALE` | `true` | Enable auto-scaling based on system memory |
<Callout type="info">
**Performance First**: Palmr runs without encryption by default for optimal speed and lower resource usage—perfect for
most use cases.
</Callout>
<Callout type="warn">
**Encryption Notice**: To enable encryption, set `DISABLE_FILESYSTEM_ENCRYPTION=false` and provide a 32+ character
`ENCRYPTION_KEY`. **Important**: This choice is permanent—switching encryption modes after uploading files will break
access to existing uploads.
</Callout>
<Callout>
**Using a Reverse Proxy?** Set `SECURE_SITE=true` and check our [Reverse Proxy
Configuration](/docs/3.2-beta/reverse-proxy-configuration) guide for proper HTTPS setup.
</Callout>
### Generate Encryption Keys (Optional)
Need file encryption? Generate a secure key:
<KeyGenerator />
> **Pro Tip**: Only enable encryption if you're handling sensitive data. For most users, the default unencrypted mode provides better performance.
## Access Your Instance
Once deployed, open Palmr in your browser:
- **Web Interface**: `http://localhost:5487` (local) or `http://YOUR_SERVER_IP:5487` (remote)
- **API Documentation**: `http://localhost:3333/docs` (if port 3333 is exposed)
<Callout type="info">
**Learn More**: For complete API documentation, authentication, and integration examples, see our [API
Reference](/docs/3.2-beta/api) guide
</Callout>
<Callout type="warn">
**Production Ready?** Configure HTTPS with a valid SSL certificate for secure production deployments.
</Callout>
---
## Docker CLI Alternative
Prefer Docker commands over Compose? Here are the equivalent commands:
<Tabs items={["Named Volume", "Bind Mount"]}>
<Tab value="Named Volume">
```bash
docker run -d \
--name palmr \
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
# -e ENABLE_S3=true \ # Set to true to enable S3-compatible storage (OPTIONAL - default is false)
# -e DISABLE_FILESYSTEM_ENCRYPTION=false \ # Set to false to enable file encryption (ENCRYPTION_KEY becomes required) | (OPTIONAL - default is true)
# -e ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
# -e PALMR_UID=1000 # UID for the container processes (default is 1000)
# -e PALMR_GID=1000 # GID for the container processes (default is 1000)
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
-p 5487:5487 \
-p 3333:3333 \
-v palmr_data:/app/server \
--restart unless-stopped \
kyantech/palmr:latest
```
<Callout type="info">
**Permission Issues?** Add `-e PALMR_UID=1000 -e PALMR_GID=1000` to the command above. See our [UID/GID Configuration](/docs/3.2-beta/uid-gid-configuration) guide for details.
</Callout>
</Tab>
<Tab value="Bind Mount">
```bash
docker run -d \
--name palmr \
# Optional: Uncomment and configure as needed (if you don`t use, you can remove)
# -e ENABLE_S3=true \ # Set to true to enable S3-compatible storage (OPTIONAL - default is false)
# -e DISABLE_FILESYSTEM_ENCRYPTION=true \ # Set to false to enable file encryption (ENCRYPTION_KEY becomes required) | (OPTIONAL - default is true)
# -e ENCRYPTION_KEY=your-secure-key-min-32-chars # Required only if encryption is enabled
# -e PALMR_UID=1000 # UID for the container processes (default is 1000)
# -e PALMR_GID=1000 # GID for the container processes (default is 1000)
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
# -e DEFAULT_LANGUAGE=en-US # Default language for the application (optional, defaults to en-US)
-p 5487:5487 \
-p 3333:3333 \
-v $(pwd)/data:/app/server \
--restart unless-stopped \
kyantech/palmr:latest
```
<Callout type="info">
**Permission Issues?** Add `-e PALMR_UID=1000 -e PALMR_GID=1000` to the command above. See our [UID/GID Configuration](/docs/3.2-beta/uid-gid-configuration) guide for details.
</Callout>
</Tab>
</Tabs>
---
## Common Configuration Options
### Presigned URL Expiration
Palmr. uses temporary URLs (presigned URLs) for secure file access. These URLs expire after a configurable time period to enhance security.
**Default:** 1 hour (3600 seconds)
You can customize this for all storage types (filesystem or S3) by adding:
```yaml
environment:
- PRESIGNED_URL_EXPIRATION=7200 # 2 hours
```
**When to adjust:**
- **Shorter time (1800 = 30 min):** Higher security, but users may need to refresh download links
- **Longer time (7200-21600 = 2-6 hours):** Better for large file transfers, but URLs stay valid longer
- **Default (3600 = 1 hour):** Good balance for most use cases
### File Encryption
For filesystem storage, you can enable file encryption:
```yaml
environment:
- DISABLE_FILESYSTEM_ENCRYPTION=false
- ENCRYPTION_KEY=your-secure-32-character-key-here
```
**Note:** S3 storage handles encryption through your S3 provider's encryption features.
---
## Maintenance
### Updates
Keep Palmr up to date with the latest features and security patches:
```bash
docker-compose pull
docker-compose up -d
```
### Backup Your Data
**Named Volumes:**
```bash
docker run --rm -v palmr_data:/data -v $(pwd):/backup alpine tar czf /backup/palmr-backup.tar.gz -C /data .
```
**Bind Mounts:**
```bash
tar czf palmr-backup.tar.gz ./data
```
### Restore From Backup
**Named Volumes:**
```bash
docker-compose down
docker run --rm -v palmr_data:/data -v $(pwd):/backup alpine tar xzf /backup/palmr-backup.tar.gz -C /data
docker-compose up -d
```
**Bind Mounts:**
```bash
docker-compose down
tar xzf palmr-backup.tar.gz
docker-compose up -d
```
---
## What's Next?
Your Palmr instance is ready! Here's what you can explore:
### Advanced Configuration
- **[UID/GID Configuration](/docs/3.2-beta/uid-gid-configuration)** - Configure user permissions for NAS systems and custom environments
- **[Download Memory Management](/docs/3.2-beta/download-memory-management)** - Configure large file download handling and queue system
- **[S3 Storage](/docs/3.2-beta/s3-configuration)** - Scale with Amazon S3 or compatible storage providers
- **[Manual Installation](/docs/3.2-beta/manual-installation)** - Manual installation and custom configurations
### Integration & Development
- **[API Reference](/docs/3.2-beta/api)** - Integrate Palmr. with your applications
<Callout type="info">
**Need help?** Check our [Troubleshooting Guide](/docs/3.2-beta/troubleshooting) for common issues and solutions.
</Callout>
---
**Questions?** Visit our [GitHub Issues](https://github.com/kyantech/Palmr/issues) or join the community discussions.

View File

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

View File

@@ -7,6 +7,8 @@ This guide provides comprehensive configuration instructions for integrating Pal
> **Overview:** Palmr. supports any S3-compatible storage provider, giving you flexibility to choose the solution that best fits your needs and budget.
> **Note:** Some configuration options (like presigned URL expiration) apply to **all storage types**, including filesystem storage. These are marked accordingly in the documentation.
## When to use S3-compatible storage
Consider using S3-compatible storage when you need:
@@ -19,18 +21,27 @@ Consider using S3-compatible storage when you need:
## Environment variables
### General configuration (applies to all storage types)
| Variable | Description | Required | Default |
| -------------------------- | ------------------------------------------------ | -------- | --------------- |
| `PRESIGNED_URL_EXPIRATION` | Duration in seconds for presigned URL expiration | No | `3600` (1 hour) |
### S3-specific configuration
To enable S3-compatible storage, set `ENABLE_S3=true` and configure the following environment variables:
| Variable | Description | Required | Default |
| --------------------- | ----------------------------- | -------- | ----------------- |
| `S3_ENDPOINT` | S3 provider endpoint URL | Yes | - |
| `S3_PORT` | Connection port | No | Based on protocol |
| `S3_USE_SSL` | Enable SSL/TLS encryption | Yes | `true` |
| `S3_ACCESS_KEY` | Access key for authentication | Yes | - |
| `S3_SECRET_KEY` | Secret key for authentication | Yes | - |
| `S3_REGION` | Storage region | Yes | - |
| `S3_BUCKET_NAME` | Bucket/container name | Yes | - |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs | No | `false` |
| Variable | Description | Required | Default |
| ------------------------ | ---------------------------------------- | -------- | ----------------- |
| `S3_ENDPOINT` | S3 provider endpoint URL | Yes | - |
| `S3_PORT` | Connection port | No | Based on protocol |
| `S3_USE_SSL` | Enable SSL/TLS encryption | Yes | `true` |
| `S3_ACCESS_KEY` | Access key for authentication | Yes | - |
| `S3_SECRET_KEY` | Secret key for authentication | Yes | - |
| `S3_REGION` | Storage region | Yes | - |
| `S3_BUCKET_NAME` | Bucket/container name | Yes | - |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs | No | `false` |
| `S3_REJECT_UNAUTHORIZED` | Enable strict SSL certificate validation | No | `true` |
## Provider configurations
@@ -51,6 +62,7 @@ S3_SECRET_KEY=your-secret-access-key
S3_REGION=us-east-1
S3_BUCKET_NAME=your-bucket-name
S3_FORCE_PATH_STYLE=false
# PRESIGNED_URL_EXPIRATION=3600 # Optional: 1 hour (default)
```
**Getting credentials:**
@@ -81,6 +93,21 @@ S3_FORCE_PATH_STYLE=true
- Default MinIO port is 9000
- SSL can be disabled for local development
**For MinIO with self-signed SSL certificates:**
```bash
ENABLE_S3=true
S3_ENDPOINT=your-minio-domain.com
S3_PORT=9000
S3_USE_SSL=true
S3_ACCESS_KEY=your-minio-access-key
S3_SECRET_KEY=your-minio-secret-key
S3_REGION=us-east-1
S3_BUCKET_NAME=your-bucket-name
S3_FORCE_PATH_STYLE=true
S3_REJECT_UNAUTHORIZED=false # Allows self-signed certificates
```
### Google Cloud Storage
Google Cloud Storage offers competitive pricing and global infrastructure.
@@ -137,6 +164,7 @@ S3_SECRET_KEY=your-application-key
S3_REGION=us-west-002
S3_BUCKET_NAME=your-bucket-name
S3_FORCE_PATH_STYLE=false
# PRESIGNED_URL_EXPIRATION=7200 # Optional: 2 hours for large files
```
**Cost advantage:**
@@ -187,6 +215,93 @@ S3_FORCE_PATH_STYLE=false
- Use container name as bucket name
- Configure appropriate access policies
## Presigned URL configuration
Palmr. uses presigned URLs to provide secure, temporary access to files stored in **both S3-compatible storage and filesystem storage**. These URLs have a configurable expiration time to balance security and usability.
> **Note:** This configuration applies to **all storage types** (S3, filesystem, etc.), not just S3-compatible storage.
### Understanding presigned URLs
Presigned URLs are temporary URLs that allow direct access to files without exposing storage credentials or requiring authentication. They automatically expire after a specified time period, enhancing security by limiting access duration.
**How it works:**
- **S3 Storage:** URLs are signed by AWS/S3-compatible provider credentials
- **Filesystem Storage:** URLs use temporary tokens that are validated by Palmr server
**Default behavior:**
- Upload URLs: 1 hour (3600 seconds)
- Download URLs: 1 hour (3600 seconds)
### Configuring expiration time
You can customize the expiration time using the `PRESIGNED_URL_EXPIRATION` environment variable:
```bash
# Set URLs to expire after 2 hours (7200 seconds)
PRESIGNED_URL_EXPIRATION=7200
# Set URLs to expire after 30 minutes (1800 seconds)
PRESIGNED_URL_EXPIRATION=1800
# Set URLs to expire after 6 hours (21600 seconds)
PRESIGNED_URL_EXPIRATION=21600
```
### Choosing the right expiration time
**Shorter expiration (15-30 minutes):**
- [+] Higher security
- [+] Reduced risk of unauthorized access
- [-] May interrupt long uploads/downloads
- [-] Users may need to refresh links more often
**Longer expiration (2-6 hours):**
- [+] Better user experience for large files
- [+] Fewer interruptions during transfers
- [-] Longer exposure window if URLs are compromised
- [-] Potential for increased storage costs if users leave downloads incomplete
**Recommended settings:**
- **High security environments:** 1800 seconds (30 minutes)
- **Standard usage:** 3600 seconds (1 hour) - default
- **Large file transfers:** 7200-21600 seconds (2-6 hours)
### Example configurations
**For Backblaze B2 with extended expiration:**
```bash
ENABLE_S3=true
S3_ENDPOINT=s3.us-west-002.backblazeb2.com
S3_USE_SSL=true
S3_ACCESS_KEY=your-key-id
S3_SECRET_KEY=your-application-key
S3_REGION=us-west-002
S3_BUCKET_NAME=your-bucket-name
S3_FORCE_PATH_STYLE=false
PRESIGNED_URL_EXPIRATION=7200 # 2 hours for large file transfers
```
**For high-security environments:**
```bash
ENABLE_S3=true
S3_ENDPOINT=s3.amazonaws.com
S3_USE_SSL=true
S3_ACCESS_KEY=your-access-key-id
S3_SECRET_KEY=your-secret-access-key
S3_REGION=us-east-1
S3_BUCKET_NAME=your-bucket-name
S3_FORCE_PATH_STYLE=false
PRESIGNED_URL_EXPIRATION=1800 # 30 minutes for enhanced security
```
## Configuration best practices
### Security considerations
@@ -212,6 +327,19 @@ S3_FORCE_PATH_STYLE=false
- Check firewall and network connectivity
- Ensure SSL/TLS settings match provider requirements
**SSL certificate errors (self-signed certificates):**
If you encounter errors like `unable to verify the first certificate` or `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, you're likely using self-signed SSL certificates. This is common with self-hosted MinIO or other S3-compatible services.
**Solution:**
Set `S3_REJECT_UNAUTHORIZED=false` in your environment variables to allow self-signed certificates:
```bash
S3_REJECT_UNAUTHORIZED=false
```
**Note:** SSL certificate validation is enabled by default (`true`) for security. Set it to `false` only when using self-hosted S3 services with self-signed certificates.
**Authentication failures:**
- Confirm access key and secret key are correct

View File

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

View File

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

View File

@@ -1,3 +1,6 @@
{
"pages": ["3.1-beta", "2.0.0-beta"]
}
"pages": [
"3.2-beta",
"2.0.0-beta"
]
}

View File

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

View File

@@ -1,3 +1,5 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import {
@@ -57,13 +59,13 @@ const images = [
"https://res.cloudinary.com/technical-intelligence/image/upload/v1745546005/Palmr./profile_mizwvg.png",
];
const docsLink = "/docs/3.1-beta";
const docsLink = "/docs/3.2-beta";
function Hero() {
return (
<section className="relative z-[2] flex flex-col border-x border-t px-6 pt-12 pb-10 md:px-12 md:pt-16 max-md:text-center">
<h1 className="mb-8 text-6xl font-bold">
Palmr. <span className="text-[13px] font-light text-muted-foreground/50 font-mono">v3.1-beta</span>
Palmr. <span className="text-[13px] font-light text-muted-foreground/50 font-mono">v3.2-beta</span>
</h1>
<h1 className="hidden text-4xl font-medium max-w-[600px] md:block mb-4">Modern & efficient file sharing</h1>
<p className="mb-8 text-fd-muted-foreground md:max-w-[80%] md:text-xl">
@@ -79,7 +81,6 @@ function Hero() {
<Link href={docsLink}>Documentation</Link>
</div>
</PulsatingButton>
<RippleButton>
<a
href="https://github.com/kyantech/Palmr"

View File

@@ -9,6 +9,6 @@ export const { GET } = createFromSource(source, (page) => {
url: page.url,
id: page.url,
structuredData: page.data.structuredData,
tag: page.url.startsWith("/docs/3.1-beta") ? "v3.1-beta" : "v2.0.0-beta",
tag: page.url.startsWith("/docs/3.2-beta") ? "v3.2-beta" : "v2.0.0-beta",
};
});

View File

@@ -11,7 +11,7 @@ import { Sponsor } from "../components/sponsor";
export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) redirect("/docs/3.1-beta");
if (!page) redirect("/docs/3.2-beta");
const MDXContent = page.data.body;
@@ -49,7 +49,7 @@ export async function generateStaticParams() {
export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) redirect("/docs/3.1-beta");
if (!page) redirect("/docs/3.2-beta");
return {
title: page.data.title + " | Palmr. Docs",

View File

@@ -28,7 +28,7 @@ export default function Layout({ children }: { children: ReactNode }) {
<RootProvider
search={{
options: {
defaultTag: "3.1-beta",
defaultTag: "3.2-beta",
tags: [
{
name: "v2.0.0 Beta",
@@ -36,7 +36,7 @@ export default function Layout({ children }: { children: ReactNode }) {
},
{
name: "v3.0 Beta ✨",
value: "3.1-beta",
value: "3.2-beta",
props: {
style: {
border: "1px solid rgba(0,165,80,0.2)",

View File

@@ -6,61 +6,61 @@ const providers = [
{
name: "Google",
description: "Configure authentication using Google OAuth2 services",
href: "/docs/3.1-beta/oidc-authentication/google",
href: "/docs/3.2-beta/oidc-authentication/google",
icon: <Chrome className="w-4 h-4" />,
},
{
name: "Discord",
description: "Set up Discord OAuth2 for community-based authentication",
href: "/docs/3.1-beta/oidc-authentication/discord",
href: "/docs/3.2-beta/oidc-authentication/discord",
icon: <MessageSquare className="w-4 h-4" />,
},
{
name: "GitHub",
description: "Enable GitHub OAuth for developer-friendly sign-in",
href: "/docs/3.1-beta/oidc-authentication/github",
href: "/docs/3.2-beta/oidc-authentication/github",
icon: <Github className="w-4 h-4" />,
},
{
name: "Zitadel",
description: "Enterprise-grade identity and access management",
href: "/docs/3.1-beta/oidc-authentication/zitadel",
href: "/docs/3.2-beta/oidc-authentication/zitadel",
icon: <Shield className="w-4 h-4" />,
},
{
name: "Auth0",
description: "Flexible identity platform with extensive customization",
href: "/docs/3.1-beta/oidc-authentication/auth0",
href: "/docs/3.2-beta/oidc-authentication/auth0",
icon: <Lock className="w-4 h-4" />,
},
{
name: "Authentik",
description: "Open-source identity provider with modern features",
href: "/docs/3.1-beta/oidc-authentication/authentik",
href: "/docs/3.2-beta/oidc-authentication/authentik",
icon: <Key className="w-4 h-4" />,
},
{
name: "Frontegg",
description: "User management platform for B2B applications",
href: "/docs/3.1-beta/oidc-authentication/frontegg",
href: "/docs/3.2-beta/oidc-authentication/frontegg",
icon: <Egg className="w-4 h-4" />,
},
{
name: "Kinde Auth",
description: "Developer-first authentication and user management",
href: "/docs/3.1-beta/oidc-authentication/kinde-auth",
href: "/docs/3.2-beta/oidc-authentication/kinde-auth",
icon: <Users className="w-4 h-" />,
},
{
name: "Pocket ID",
description: "Open-source identity provider with OIDC support",
href: "/docs/3.1-beta/oidc-authentication/pocket-id",
href: "/docs/3.2-beta/oidc-authentication/pocket-id",
icon: <Key className="w-4 h-4" />,
},
{
name: "Other",
description: "Configure any other OIDC-compliant identity provider",
href: "/docs/3.1-beta/oidc-authentication/other",
href: "/docs/3.2-beta/oidc-authentication/other",
icon: <Settings className="w-4 h-4" />,
},
];

View File

@@ -0,0 +1,33 @@
import { motion } from "motion/react";
export function BackgroundLights() {
return (
<div className="absolute inset-0 -z-10 overflow-hidden">
<motion.div
animate={{
scale: [1, 1.1, 1],
opacity: [0.3, 0.5, 0.3],
}}
className="absolute -top-[20%] -left-[20%] w-[140%] h-[140%] bg-[radial-gradient(circle,rgba(34,197,94,0.15)_0%,transparent_70%)] dark:opacity-100 opacity-50"
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
}}
/>
<motion.div
animate={{
scale: [1, 1.1, 1],
opacity: [0.3, 0.5, 0.3],
}}
className="absolute -bottom-[20%] -right-[20%] w-[140%] h-[140%] bg-[radial-gradient(circle,rgba(34,197,94,0.15)_0%,transparent_70%)] dark:opacity-100 opacity-50"
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
delay: 2.5,
}}
/>
</div>
);
}

View File

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

View File

@@ -1,2 +1,2 @@
export const LATEST_VERSION_PATH = "/docs/3.1-beta";
export const LATEST_VERSION = "v3.1-beta";
export const LATEST_VERSION_PATH = "/docs/3.2-beta";
export const LATEST_VERSION = "v3.2-beta";

View File

@@ -1,6 +1,7 @@
# FOR FILESYSTEM STORAGE ENV VARS
ENABLE_S3=false
ENCRYPTION_KEY=change-this-key-in-production-min-32-chars
DISABLE_FILESYSTEM_ENCRYPTION=true
# ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # Required only if encryption is enabled (DISABLE_FILESYSTEM_ENCRYPTION=false)
DATABASE_URL="file:./palmr.db"
# FOR USE WITH S3 COMPATIBLE STORAGE
@@ -13,3 +14,5 @@ DATABASE_URL="file:./palmr.db"
# S3_REGION=
# S3_BUCKET_NAME=
# S3_FORCE_PATH_STYLE=
# S3_REJECT_UNAUTHORIZED=true # Set to false to disable strict SSL certificate validation for self-signed certificates (optional, defaults to true)
# PRESIGNED_URL_EXPIRATION=3600 # Duration in seconds for presigned URL expiration (optional, defaults to 3600 seconds / 1 hour)

View File

@@ -1,5 +0,0 @@

> palmr-api@3.1-beta lint /Users/daniel/clones/Palmr/apps/server
> eslint "src/**/*.+(ts|tsx)"

View File

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

View File

@@ -147,6 +147,12 @@ const defaultConfigs = [
type: "boolean",
group: "auth-providers",
},
{
key: "passwordAuthEnabled",
value: "true",
type: "boolean",
group: "security",
},
{
key: "serverUrl",
value: "http://localhost:3333",

View File

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

View File

@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import * as http from "node:http";
import fastifyCookie from "@fastify/cookie";
import { fastifyCors } from "@fastify/cors";
import fastifyJwt from "@fastify/jwt";
@@ -24,13 +25,38 @@ export async function buildApp() {
},
},
logger: {
level: "info",
level: "warn",
},
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024,
connectionTimeout: 0,
keepAliveTimeout: envTimeoutOverrides.keepAliveTimeout,
requestTimeout: envTimeoutOverrides.requestTimeout,
trustProxy: true,
maxParamLength: 500,
onProtoPoisoning: "ignore",
onConstructorPoisoning: "ignore",
ignoreTrailingSlash: true,
serverFactory: (handler: (req: any, res: any) => void) => {
const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
res.setTimeout(0);
req.setTimeout(0);
req.on("close", () => {
if (typeof global !== "undefined" && global.gc) {
setImmediate(() => global.gc!());
}
});
handler(req, res);
});
server.maxHeadersCount = 0;
server.timeout = 0;
server.keepAliveTimeout = envTimeoutOverrides.keepAliveTimeout;
server.headersTimeout = envTimeoutOverrides.keepAliveTimeout + 1000;
return server;
},
}).withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);

View File

@@ -1,3 +1,4 @@
import process from "node:process";
import { S3Client } from "@aws-sdk/client-s3";
import { env } from "../env";
@@ -14,6 +15,14 @@ export const storageConfig: StorageConfig = {
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
};
if (storageConfig.useSSL && env.S3_REJECT_UNAUTHORIZED === "false") {
const originalRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
if (!originalRejectUnauthorized) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
(global as any).PALMR_ORIGINAL_TLS_SETTING = originalRejectUnauthorized;
}
}
export const s3Client =
env.ENABLE_S3 === "true"
? new S3Client({

View File

@@ -2,7 +2,8 @@ import { z } from "zod";
const envSchema = z.object({
ENABLE_S3: z.union([z.literal("true"), z.literal("false")]).default("false"),
ENCRYPTION_KEY: z.string().optional().default("palmr-default-encryption-key-2025"),
ENCRYPTION_KEY: z.string().optional(),
DISABLE_FILESYSTEM_ENCRYPTION: z.union([z.literal("true"), z.literal("false")]).default("true"),
S3_ENDPOINT: z.string().optional(),
S3_PORT: z.string().optional(),
S3_USE_SSL: z.string().optional(),
@@ -11,8 +12,27 @@ 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"),
S3_REJECT_UNAUTHORIZED: z.union([z.literal("true"), z.literal("false")]).default("true"),
PRESIGNED_URL_EXPIRATION: z.string().optional().default("3600"),
SECURE_SITE: z.union([z.literal("true"), z.literal("false")]).default("false"),
DATABASE_URL: z.string().optional().default("file:/app/server/prisma/palmr.db"),
DOWNLOAD_MAX_CONCURRENT: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
DOWNLOAD_MEMORY_THRESHOLD_MB: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
DOWNLOAD_QUEUE_SIZE: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
DOWNLOAD_AUTO_SCALE: z.union([z.literal("true"), z.literal("false")]).default("true"),
DOWNLOAD_MIN_FILE_SIZE_GB: z
.string()
.optional()
.transform((val) => (val ? parseFloat(val) : undefined)),
});
export const env = envSchema.parse(process.env);

View File

@@ -9,7 +9,7 @@ export class AppController {
private logoService = new LogoService();
private emailService = new EmailService();
async getAppInfo(request: FastifyRequest, reply: FastifyReply) {
async getAppInfo(_request: FastifyRequest, reply: FastifyReply) {
try {
const appInfo = await this.appService.getAppInfo();
return reply.send(appInfo);
@@ -18,7 +18,16 @@ export class AppController {
}
}
async getAllConfigs(request: FastifyRequest, reply: FastifyReply) {
async getSystemInfo(_request: FastifyRequest, reply: FastifyReply) {
try {
const systemInfo = await this.appService.getSystemInfo();
return reply.send(systemInfo);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async getAllConfigs(_request: FastifyRequest, reply: FastifyReply) {
try {
const configs = await this.appService.getAllConfigs();
return reply.send({ configs });
@@ -27,6 +36,15 @@ export class AppController {
}
}
async getPublicConfigs(_request: FastifyRequest, reply: FastifyReply) {
try {
const configs = await this.appService.getPublicConfigs();
return reply.send({ configs });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async updateConfig(request: FastifyRequest, reply: FastifyReply) {
try {
const { key } = request.params as { key: string };
@@ -81,9 +99,8 @@ export class AppController {
return reply.status(400).send({ error: "Only images are allowed" });
}
// Logo files should be small (max 5MB), so we can safely use streaming to buffer
const chunks: Buffer[] = [];
const maxLogoSize = 5 * 1024 * 1024; // 5MB
const maxLogoSize = 5 * 1024 * 1024;
let totalSize = 0;
for await (const chunk of file.file) {
@@ -105,7 +122,7 @@ export class AppController {
}
}
async removeLogo(request: FastifyRequest, reply: FastifyReply) {
async removeLogo(_request: FastifyRequest, reply: FastifyReply) {
try {
await this.logoService.deleteLogo();
return reply.send({ message: "Logo removed successfully" });

View File

@@ -53,6 +53,26 @@ export async function appRoutes(app: FastifyInstance) {
appController.getAppInfo.bind(appController)
);
app.get(
"/app/system-info",
{
schema: {
tags: ["App"],
operationId: "getSystemInfo",
summary: "Get system information",
description: "Get system information including storage provider",
response: {
200: z.object({
storageProvider: z.enum(["s3", "filesystem"]).describe("The active storage provider"),
s3Enabled: z.boolean().describe("Whether S3 storage is enabled"),
}),
400: z.object({ error: z.string().describe("Error message") }),
},
},
},
appController.getSystemInfo.bind(appController)
);
app.patch(
"/app/configs/:key",
{
@@ -82,15 +102,34 @@ export async function appRoutes(app: FastifyInstance) {
appController.updateConfig.bind(appController)
);
app.get(
"/app/configs/public",
{
schema: {
tags: ["App"],
operationId: "getPublicConfigs",
summary: "List public configurations",
description: "List public configurations (excludes sensitive data like SMTP credentials)",
response: {
200: z.object({
configs: z.array(ConfigResponseSchema),
}),
400: z.object({ error: z.string().describe("Error message") }),
},
},
},
appController.getPublicConfigs.bind(appController)
);
app.get(
"/app/configs",
{
// preValidation: adminPreValidation,
preValidation: adminPreValidation,
schema: {
tags: ["App"],
operationId: "getAllConfigs",
summary: "List all configurations",
description: "List all configurations (admin only)",
description: "List all configurations including sensitive data (admin only)",
response: {
200: z.object({
configs: z.array(ConfigResponseSchema),

View File

@@ -1,3 +1,4 @@
import { isS3Enabled } from "../../config/storage.config";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
@@ -20,6 +21,13 @@ export class AppService {
};
}
async getSystemInfo() {
return {
storageProvider: isS3Enabled ? "s3" : "filesystem",
s3Enabled: isS3Enabled,
};
}
async getAllConfigs() {
return prisma.appConfig.findMany({
where: {
@@ -33,11 +41,46 @@ export class AppService {
});
}
async getPublicConfigs() {
const sensitiveKeys = [
"smtpHost",
"smtpPort",
"smtpUser",
"smtpPass",
"smtpSecure",
"smtpNoAuth",
"smtpTrustSelfSigned",
"jwtSecret",
];
return prisma.appConfig.findMany({
where: {
key: {
notIn: sensitiveKeys,
},
},
orderBy: {
group: "asc",
},
});
}
async updateConfig(key: string, value: string) {
if (key === "jwtSecret") {
throw new Error("JWT Secret cannot be updated through this endpoint");
}
if (key === "passwordAuthEnabled") {
if (value === "false") {
const canDisable = await this.configService.validatePasswordAuthDisable();
if (!canDisable) {
throw new Error(
"Password authentication cannot be disabled. At least one authentication provider must be active."
);
}
}
}
const config = await prisma.appConfig.findUnique({
where: { key },
});
@@ -56,6 +99,15 @@ export class AppService {
if (updates.some((update) => update.key === "jwtSecret")) {
throw new Error("JWT Secret cannot be updated through this endpoint");
}
const passwordAuthUpdate = updates.find((update) => update.key === "passwordAuthEnabled");
if (passwordAuthUpdate && passwordAuthUpdate.value === "false") {
const canDisable = await this.configService.validatePasswordAuthDisable();
if (!canDisable) {
throw new Error(
"Password authentication cannot be disabled. At least one authentication provider must be active."
);
}
}
const keys = updates.map((update) => update.key);
const existingConfigs = await prisma.appConfig.findMany({

View File

@@ -1,5 +1,6 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { ConfigService } from "../config/service";
import { UpdateAuthProviderSchema } from "./dto";
import { AuthProvidersService } from "./service";
import {
@@ -39,9 +40,11 @@ const ERROR_MESSAGES = {
export class AuthProvidersController {
private authProvidersService: AuthProvidersService;
private configService: ConfigService;
constructor() {
this.authProvidersService = new AuthProvidersService();
this.configService = new ConfigService();
}
private buildRequestContext(request: FastifyRequest): RequestContext {
@@ -223,13 +226,24 @@ export class AuthProvidersController {
try {
const { id } = request.params;
const data = request.body;
const data = request.body as any;
const existingProvider = await this.authProvidersService.getProviderById(id);
if (!existingProvider) {
return this.sendErrorResponse(reply, 404, ERROR_MESSAGES.PROVIDER_NOT_FOUND);
}
if (data.enabled === false && existingProvider.enabled === true) {
const canDisable = await this.configService.validateAllProvidersDisable();
if (!canDisable) {
return this.sendErrorResponse(
reply,
400,
"Cannot disable the last authentication provider when password authentication is disabled"
);
}
}
const isOfficial = this.authProvidersService.isOfficialProvider(existingProvider.name);
if (isOfficial) {
@@ -300,6 +314,17 @@ export class AuthProvidersController {
return this.sendErrorResponse(reply, 400, ERROR_MESSAGES.OFFICIAL_CANNOT_DELETE);
}
if (provider.enabled) {
const canDisable = await this.configService.validateAllProvidersDisable();
if (!canDisable) {
return this.sendErrorResponse(
reply,
400,
"Cannot delete the last authentication provider when password authentication is disabled"
);
}
}
await this.authProvidersService.deleteProvider(id);
return this.sendSuccessResponse(reply, undefined, "Provider deleted successfully");
} catch (error) {

View File

@@ -1,6 +1,7 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { env } from "../../env";
import { ConfigService } from "../config/service";
import {
CompleteTwoFactorLoginSchema,
createResetPasswordSchema,
@@ -11,6 +12,7 @@ import { AuthService } from "./service";
export class AuthController {
private authService = new AuthService();
private configService = new ConfigService();
private getClientInfo(request: FastifyRequest) {
const realIP = request.headers["x-real-ip"] as string;
@@ -111,14 +113,21 @@ export class AuthController {
async getCurrentUser(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
let userId: string | null = null;
try {
await request.jwtVerify();
userId = (request as any).user?.userId;
} catch (err) {
return reply.send({ user: null });
}
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
return reply.send({ user: null });
}
const user = await this.authService.getUserById(userId);
if (!user) {
return reply.status(404).send({ error: "User not found" });
return reply.send({ user: null });
}
return reply.send({ user });
@@ -169,4 +178,15 @@ export class AuthController {
return reply.status(400).send({ error: error.message });
}
}
async getAuthConfig(request: FastifyRequest, reply: FastifyReply) {
try {
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
return reply.send({
passwordAuthEnabled: passwordAuthEnabled === "true",
});
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
}

View File

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

View File

@@ -18,6 +18,11 @@ export class AuthService {
private trustedDeviceService = new TrustedDeviceService();
async login(data: LoginInput, userAgent?: string, ipAddress?: string) {
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
if (passwordAuthEnabled === "false") {
throw new Error("Password authentication is disabled. Please use an external authentication provider.");
}
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
if (!user) {
throw new Error("Invalid credentials");
@@ -146,6 +151,11 @@ export class AuthService {
}
async requestPasswordReset(email: string, origin: string) {
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
if (passwordAuthEnabled === "false") {
throw new Error("Password authentication is disabled. Password reset is not available.");
}
const user = await this.userRepository.findUserByEmail(email);
if (!user) {
return;
@@ -171,6 +181,11 @@ export class AuthService {
}
async resetPassword(token: string, newPassword: string) {
const passwordAuthEnabled = await this.configService.getValue("passwordAuthEnabled");
if (passwordAuthEnabled === "false") {
throw new Error("Password authentication is disabled. Password reset is not available.");
}
const resetRequest = await prisma.passwordReset.findFirst({
where: {
token,

View File

@@ -13,6 +13,26 @@ export class ConfigService {
return config.value;
}
async setValue(key: string, value: string): Promise<void> {
await prisma.appConfig.update({
where: { key },
data: { value },
});
}
async validatePasswordAuthDisable(): Promise<boolean> {
const enabledProviders = await prisma.authProvider.findMany({
where: { enabled: true },
});
return enabledProviders.length > 0;
}
async validateAllProvidersDisable(): Promise<boolean> {
const passwordAuthEnabled = await this.getValue("passwordAuthEnabled");
return passwordAuthEnabled === "true";
}
async getGroupConfigs(group: string) {
const configs = await prisma.appConfig.findMany({
where: { group },

View File

@@ -167,7 +167,7 @@ export class EmailService {
});
}
async sendShareNotification(to: string, shareLink: string, shareName?: string) {
async sendShareNotification(to: string, shareLink: string, shareName?: string, senderName?: string) {
const transporter = await this.createTransporter();
if (!transporter) {
throw new Error("SMTP is not enabled");
@@ -178,19 +178,151 @@ export class EmailService {
const appName = await this.configService.getValue("appName");
const shareTitle = shareName || "Files";
const sender = senderName || "Someone";
await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to,
subject: `${appName} - ${shareTitle} shared with you`,
html: `
<h1>${appName} - Shared Files</h1>
<p>Someone has shared "${shareTitle}" with you.</p>
<p>Click the link below to access the shared files:</p>
<a href="${shareLink}">
Access Shared Files
</a>
<p>Note: This share may have an expiration date or view limit.</p>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${appName} - Shared Files</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5; color: #333333;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); overflow: hidden; margin-top: 40px; margin-bottom: 40px;">
<!-- Header -->
<div style="background-color: #22B14C; padding: 30px 20px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">${appName}</h1>
<p style="margin: 2px 0 0 0; color: #ffffff; font-size: 16px; opacity: 0.9;">Shared Files</p>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<div style="text-align: center; margin-bottom: 32px;">
<h2 style="margin: 0 0 12px 0; color: #1f2937; font-size: 24px; font-weight: 600;">Files Shared With You</h2>
<p style="margin: 0; color: #6b7280; font-size: 16px; line-height: 1.6;">
<strong style="color: #374151;">${sender}</strong> has shared <strong style="color: #374151;">"${shareTitle}"</strong> with you.
</p>
</div>
<!-- CTA Button -->
<div style="text-align: center; margin: 32px 0;">
<a href="${shareLink}" style="display: inline-block; background-color: #22B14C; color: #ffffff; text-decoration: none; padding: 12px 24px; font-weight: 600; font-size: 16px; border: 2px solid #22B14C; border-radius: 8px; transition: all 0.3s ease;">
Access Shared Files
</a>
</div>
<!-- Info Box -->
<div style="background-color: #f9fafb; border-left: 4px solid #22B14C; padding: 16px 20px; margin-top: 32px;">
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;">
<strong>Important:</strong> This share may have an expiration date or view limit. Access it as soon as possible to ensure availability.
</p>
</div>
</div>
<!-- Footer -->
<div style="background-color: #f9fafb; padding: 24px 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; color: #6b7280; font-size: 14px;">
This email was sent by <strong>${appName}</strong>
</p>
<p style="margin: 8px 0 0 0; color: #9ca3af; font-size: 12px;">
If you didn't expect this email, you can safely ignore it.
</p>
<p style="margin: 4px 0 0 0; color: #9ca3af; font-size: 10px;">
Powered by <a href="https://kyantech.com.br" style="color: #9ca3af; text-decoration: none;">Kyantech Solutions</a>
</p>
</div>
</div>
</body>
</html>
`,
});
}
async sendReverseShareBatchFileNotification(
recipientEmail: string,
reverseShareName: string,
fileCount: number,
fileList: string,
uploaderName: string
) {
const transporter = await this.createTransporter();
if (!transporter) {
throw new Error("SMTP is not enabled");
}
const fromName = await this.configService.getValue("smtpFromName");
const fromEmail = await this.configService.getValue("smtpFromEmail");
const appName = await this.configService.getValue("appName");
await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to: recipientEmail,
subject: `${appName} - ${fileCount} file${fileCount > 1 ? "s" : ""} uploaded to "${reverseShareName}"`,
html: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${appName} - File Upload Notification</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5; color: #333333;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); overflow: hidden; margin-top: 40px; margin-bottom: 40px;">
<!-- Header -->
<div style="background-color: #22B14C; padding: 30px 20px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">${appName}</h1>
<p style="margin: 2px 0 0 0; color: #ffffff; font-size: 16px; opacity: 0.9;">File Upload Notification</p>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<div style="text-align: center; margin-bottom: 32px;">
<h2 style="margin: 0 0 12px 0; color: #1f2937; font-size: 24px; font-weight: 600;">New File Uploaded</h2>
<p style="margin: 0; color: #6b7280; font-size: 16px; line-height: 1.6;">
<strong style="color: #374151;">${uploaderName}</strong> has uploaded <strong style="color: #374151;">${fileCount} file${fileCount > 1 ? "s" : ""}</strong> to your reverse share <strong style="color: #374151;">"${reverseShareName}"</strong>.
</p>
</div>
<!-- File List -->
<div style="background-color: #f9fafb; border-radius: 8px; padding: 16px; margin: 32px 0; border-left: 4px solid #22B14C;">
<p style="margin: 0 0 8px 0; color: #374151; font-size: 14px;"><strong>Files (${fileCount}):</strong></p>
<ul style="margin: 0; padding-left: 20px; color: #6b7280; font-size: 14px; line-height: 1.5;">
${fileList
.split(", ")
.map((file) => `<li style="margin: 4px 0;">${file}</li>`)
.join("")}
</ul>
</div>
<!-- Info Text -->
<div style="text-align: center; margin-top: 32px;">
<p style="margin: 0; color: #9ca3af; font-size: 12px;">
You can now access and manage these files through your dashboard.
</p>
</div>
</div>
<!-- Footer -->
<div style="background-color: #f9fafb; padding: 24px 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; color: #6b7280; font-size: 14px;">
This email was sent by <strong>${appName}</strong>
</p>
<p style="margin: 8px 0 0 0; color: #9ca3af; font-size: 12px;">
If you didn't expect this email, you can safely ignore it.
</p>
<p style="margin: 4px 0 0 0; color: #9ca3af; font-size: 10px;">
Powered by <a href="https://kyantech.com.br" style="color: #9ca3af; text-decoration: none;">Kyantech Solutions</a>
</p>
</div>
</div>
</body>
</html>
`,
});
}

View File

@@ -1,5 +1,6 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { env } from "../../env";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
import { CheckFileInput, CheckFileSchema, RegisterFileInput, RegisterFileSchema, UpdateFileSchema } from "./dto";
@@ -27,7 +28,7 @@ export class FileController {
}
const objectName = `${userId}/${Date.now()}-${filename}.${extension}`;
const expires = 3600;
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return reply.send({ url, objectName });
@@ -171,7 +172,7 @@ export class FileController {
return reply.status(404).send({ error: "File not found." });
}
const fileName = fileRecord.name;
const expires = 3600;
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedGetUrl(objectName, expires, fileName);
return reply.send({ url, expiresIn: expires });
} catch (error) {

View File

@@ -92,7 +92,6 @@ export class ChunkManager {
console.log(`Chunk ${chunkIndex} already uploaded, treating as success`);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
// Check if already finalizing to prevent race condition
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
@@ -128,7 +127,6 @@ export class ChunkManager {
);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
// Check if already finalizing to prevent race condition
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
@@ -199,7 +197,7 @@ export class ChunkManager {
}
/**
* Finalize upload by moving temp file to final location and encrypting
* Finalize upload by moving temp file to final location and encrypting (if enabled)
*/
private async finalizeUpload(
chunkInfo: ChunkInfo,
@@ -224,7 +222,7 @@ export class ChunkManager {
const filePath = provider.getFilePath(finalObjectName);
const dir = path.dirname(filePath);
console.log(`Starting encryption and finalization: ${finalObjectName}`);
console.log(`Starting finalization: ${finalObjectName}`);
await fs.promises.mkdir(dir, { recursive: true });
@@ -236,7 +234,6 @@ export class ChunkManager {
});
const encryptStream = provider.createEncryptStream();
// Wait for encryption to complete BEFORE cleaning up temp file
await new Promise<void>((resolve, reject) => {
const startTime = Date.now();
@@ -245,18 +242,17 @@ export class ChunkManager {
.pipe(writeStream)
.on("finish", () => {
const duration = Date.now() - startTime;
console.log(`File encrypted and saved to: ${filePath} in ${duration}ms`);
console.log(`File processed and saved to: ${filePath} in ${duration}ms`);
resolve();
})
.on("error", (error) => {
console.error("Error during encryption:", error);
console.error("Error during processing:", error);
reject(error);
});
});
console.log(`File successfully uploaded and encrypted: ${finalObjectName}`);
console.log(`File successfully uploaded and processed: ${finalObjectName}`);
// Clean up temp file AFTER encryption is complete
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(chunkInfo.fileId);

View File

@@ -3,14 +3,15 @@ import { pipeline } from "stream/promises";
import { FastifyReply, FastifyRequest } from "fastify";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
import { DownloadCancelResponse, QueueClearResponse, QueueStatusResponse } from "../../types/download-queue";
import { DownloadMemoryManager } from "../../utils/download-memory-manager";
import { getContentType } from "../../utils/mime-types";
import { ChunkManager, ChunkMetadata } from "./chunk-manager";
export class FilesystemController {
private chunkManager = ChunkManager.getInstance();
private memoryManager = DownloadMemoryManager.getInstance();
/**
* Safely encode filename for Content-Disposition header
*/
private encodeFilenameForHeader(filename: string): string {
if (!filename || filename.trim() === "") {
return 'attachment; filename="download"';
@@ -103,9 +104,6 @@ export class FilesystemController {
await provider.uploadFileFromStream(objectName, request.raw);
}
/**
* Extract chunk metadata from request headers
*/
private extractChunkMetadata(request: FastifyRequest): ChunkMetadata | null {
const fileId = request.headers["x-file-id"] as string;
const chunkIndex = request.headers["x-chunk-index"] as string;
@@ -132,9 +130,6 @@ export class FilesystemController {
return metadata;
}
/**
* Handle chunked upload with streaming
*/
private async handleChunkedUpload(request: FastifyRequest, metadata: ChunkMetadata, originalObjectName: string) {
const stream = request.raw;
@@ -145,9 +140,6 @@ export class FilesystemController {
return await this.chunkManager.processChunk(metadata, stream, originalObjectName);
}
/**
* Get upload progress for chunked uploads
*/
async getUploadProgress(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileId } = request.params as { fileId: string };
@@ -164,9 +156,6 @@ export class FilesystemController {
}
}
/**
* Cancel chunked upload
*/
async cancelUpload(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileId } = request.params as { fileId: string };
@@ -180,9 +169,10 @@ export class FilesystemController {
}
async download(request: FastifyRequest, reply: FastifyReply) {
const downloadId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
try {
const { token } = request.params as { token: string };
const provider = FilesystemStorageProvider.getInstance();
const tokenData = provider.validateDownloadToken(token);
@@ -194,69 +184,233 @@ export class FilesystemController {
const filePath = provider.getFilePath(tokenData.objectName);
const stats = await fs.promises.stat(filePath);
const fileSize = stats.size;
const isLargeFile = fileSize > 50 * 1024 * 1024;
const fileName = tokenData.fileName || "download";
const fileSizeMB = fileSize / (1024 * 1024);
console.log(`[DOWNLOAD] Requesting slot for ${downloadId}: ${tokenData.objectName} (${fileSizeMB.toFixed(1)}MB)`);
try {
await this.memoryManager.requestDownloadSlot(downloadId, {
fileName,
fileSize,
objectName: tokenData.objectName,
});
} catch (error: any) {
console.warn(`[DOWNLOAD] Queue full for ${downloadId}: ${error.message}`);
return reply.status(503).send({
error: "Download queue is full",
message: error.message,
retryAfter: 60,
});
}
console.log(`[DOWNLOAD] Starting ${downloadId}: ${tokenData.objectName} (${fileSizeMB.toFixed(1)}MB)`);
this.memoryManager.startDownload(downloadId);
const range = request.headers.range;
reply.header("Content-Disposition", this.encodeFilenameForHeader(fileName));
reply.header("Content-Type", "application/octet-stream");
reply.header("Content-Type", getContentType(fileName));
reply.header("Accept-Ranges", "bytes");
reply.header("X-Download-ID", downloadId);
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
reply.raw.on("close", () => {
this.memoryManager.endDownload(downloadId);
console.log(`[DOWNLOAD] Client disconnected: ${downloadId}`);
});
reply.status(206);
reply.header("Content-Range", `bytes ${start}-${end}/${fileSize}`);
reply.header("Content-Length", chunkSize);
reply.raw.on("error", () => {
this.memoryManager.endDownload(downloadId);
console.log(`[DOWNLOAD] Client error: ${downloadId}`);
});
if (isLargeFile) {
await this.downloadLargeFileRange(reply, provider, tokenData.objectName, start, end);
try {
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
reply.status(206);
reply.header("Content-Range", `bytes ${start}-${end}/${fileSize}`);
reply.header("Content-Length", end - start + 1);
await this.downloadFileRange(reply, provider, tokenData.objectName, start, end, downloadId);
} else {
const buffer = await provider.downloadFile(tokenData.objectName);
const chunk = buffer.slice(start, end + 1);
reply.send(chunk);
reply.header("Content-Length", fileSize);
await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId);
}
} else {
reply.header("Content-Length", fileSize);
if (isLargeFile) {
await this.downloadLargeFile(reply, provider, filePath);
} else {
const buffer = await provider.downloadFile(tokenData.objectName);
reply.send(buffer);
}
provider.consumeDownloadToken(token);
} finally {
this.memoryManager.endDownload(downloadId);
}
provider.consumeDownloadToken(token);
} catch (error) {
this.memoryManager.endDownload(downloadId);
console.error(`[DOWNLOAD] Error in ${downloadId}:`, error);
return reply.status(500).send({ error: "Internal server error" });
}
}
private async downloadLargeFile(reply: FastifyReply, provider: FilesystemStorageProvider, filePath: string) {
const readStream = fs.createReadStream(filePath);
const decryptStream = provider.createDecryptStream();
private async downloadFileStream(
reply: FastifyReply,
provider: FilesystemStorageProvider,
objectName: string,
downloadId?: string
) {
try {
await pipeline(readStream, decryptStream, reply.raw);
FilesystemStorageProvider.logMemoryUsage(`Download start: ${objectName} (${downloadId})`);
const downloadStream = provider.createDownloadStream(objectName);
downloadStream.on("error", (error) => {
console.error("Download stream error:", error);
FilesystemStorageProvider.logMemoryUsage(`Download error: ${objectName} (${downloadId})`);
if (!reply.sent) {
reply.status(500).send({ error: "Download failed" });
}
});
reply.raw.on("close", () => {
if (downloadStream.readable && typeof (downloadStream as any).destroy === "function") {
(downloadStream as any).destroy();
}
FilesystemStorageProvider.logMemoryUsage(`Download client disconnect: ${objectName} (${downloadId})`);
});
if (this.memoryManager.shouldThrottleStream()) {
console.log(
`[MEMORY THROTTLE] ${objectName} - Pausing stream due to high memory usage: ${this.memoryManager.getCurrentMemoryUsageMB().toFixed(0)}MB`
);
const { Transform } = require("stream");
const memoryManager = this.memoryManager;
const throttleStream = new Transform({
highWaterMark: 256 * 1024,
transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: any) => void) {
if (memoryManager.shouldThrottleStream()) {
setImmediate(() => {
this.push(chunk);
callback();
});
} else {
this.push(chunk);
callback();
}
},
});
await pipeline(downloadStream, throttleStream, reply.raw);
} else {
await pipeline(downloadStream, reply.raw);
}
FilesystemStorageProvider.logMemoryUsage(`Download complete: ${objectName} (${downloadId})`);
} catch (error) {
throw error;
console.error("Download error:", error);
FilesystemStorageProvider.logMemoryUsage(`Download failed: ${objectName} (${downloadId})`);
if (!reply.sent) {
reply.status(500).send({ error: "Download failed" });
}
}
}
private async downloadLargeFileRange(
private async downloadFileRange(
reply: FastifyReply,
provider: FilesystemStorageProvider,
objectName: string,
start: number,
end: number
end: number,
downloadId?: string
) {
const buffer = await provider.downloadFile(objectName);
const chunk = buffer.slice(start, end + 1);
reply.send(chunk);
try {
FilesystemStorageProvider.logMemoryUsage(`Range download start: ${objectName} (${start}-${end}) (${downloadId})`);
const rangeStream = await provider.createDownloadRangeStream(objectName, start, end);
rangeStream.on("error", (error) => {
console.error("Range download stream error:", error);
FilesystemStorageProvider.logMemoryUsage(
`Range download error: ${objectName} (${start}-${end}) (${downloadId})`
);
if (!reply.sent) {
reply.status(500).send({ error: "Download failed" });
}
});
reply.raw.on("close", () => {
if (rangeStream.readable && typeof (rangeStream as any).destroy === "function") {
(rangeStream as any).destroy();
}
FilesystemStorageProvider.logMemoryUsage(
`Range download client disconnect: ${objectName} (${start}-${end}) (${downloadId})`
);
});
await pipeline(rangeStream, reply.raw);
FilesystemStorageProvider.logMemoryUsage(
`Range download complete: ${objectName} (${start}-${end}) (${downloadId})`
);
} catch (error) {
console.error("Range download error:", error);
FilesystemStorageProvider.logMemoryUsage(
`Range download failed: ${objectName} (${start}-${end}) (${downloadId})`
);
if (!reply.sent) {
reply.status(500).send({ error: "Download failed" });
}
}
}
async getQueueStatus(_request: FastifyRequest, reply: FastifyReply) {
try {
const queueStatus = this.memoryManager.getQueueStatus();
const response: QueueStatusResponse = {
status: "success",
data: queueStatus,
};
reply.status(200).send(response);
} catch (error) {
console.error("Error getting queue status:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
async cancelQueuedDownload(request: FastifyRequest, reply: FastifyReply) {
try {
const { downloadId } = request.params as { downloadId: string };
const cancelled = this.memoryManager.cancelQueuedDownload(downloadId);
if (cancelled) {
const response: DownloadCancelResponse = {
message: "Download cancelled successfully",
downloadId,
};
reply.status(200).send(response);
} else {
reply.status(404).send({
error: "Download not found in queue",
downloadId,
});
}
} catch (error) {
console.error("Error cancelling queued download:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
async clearDownloadQueue(_request: FastifyRequest, reply: FastifyReply) {
try {
const clearedCount = this.memoryManager.clearQueue();
const response: QueueClearResponse = {
message: "Download queue cleared successfully",
clearedCount,
};
reply.status(200).send(response);
} catch (error) {
console.error("Error clearing download queue:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
}

View File

@@ -0,0 +1,95 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { FilesystemController } from "./controller";
export async function downloadQueueRoutes(app: FastifyInstance) {
const filesystemController = new FilesystemController();
app.get(
"/filesystem/download-queue/status",
{
schema: {
tags: ["Download Queue"],
operationId: "getDownloadQueueStatus",
summary: "Get download queue status",
description: "Get current status of the download queue including active downloads and queue length",
response: {
200: z.object({
status: z.string(),
data: z.object({
queueLength: z.number(),
maxQueueSize: z.number(),
activeDownloads: z.number(),
maxConcurrent: z.number(),
queuedDownloads: z.array(
z.object({
downloadId: z.string(),
position: z.number(),
waitTime: z.number(),
fileName: z.string().optional(),
fileSize: z.number().optional(),
})
),
}),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.getQueueStatus.bind(filesystemController)
);
app.delete(
"/filesystem/download-queue/:downloadId",
{
schema: {
tags: ["Download Queue"],
operationId: "cancelQueuedDownload",
summary: "Cancel a queued download",
description: "Cancel a specific download that is waiting in the queue",
params: z.object({
downloadId: z.string().describe("Download ID"),
}),
response: {
200: z.object({
message: z.string(),
downloadId: z.string(),
}),
404: z.object({
error: z.string(),
downloadId: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.cancelQueuedDownload.bind(filesystemController)
);
app.delete(
"/filesystem/download-queue",
{
schema: {
tags: ["Download Queue"],
operationId: "clearDownloadQueue",
summary: "Clear entire download queue",
description: "Cancel all downloads waiting in the queue (admin operation)",
response: {
200: z.object({
message: z.string(),
clearedCount: z.number(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.clearDownloadQueue.bind(filesystemController)
);
}

View File

@@ -318,8 +318,60 @@ export class ReverseShareController {
}
const { fileId } = request.params as { fileId: string };
const result = await this.reverseShareService.downloadReverseShareFile(fileId, userId);
return reply.send(result);
const fileInfo = await this.reverseShareService.getFileInfo(fileId, userId);
const downloadId = `reverse-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const { DownloadMemoryManager } = await import("../../utils/download-memory-manager.js");
const memoryManager = DownloadMemoryManager.getInstance();
const fileSizeMB = Number(fileInfo.size) / (1024 * 1024);
console.log(
`[REVERSE-DOWNLOAD] Requesting slot for ${downloadId}: ${fileInfo.name} (${fileSizeMB.toFixed(1)}MB)`
);
try {
await memoryManager.requestDownloadSlot(downloadId, {
fileName: fileInfo.name,
fileSize: Number(fileInfo.size),
objectName: fileInfo.objectName,
});
} catch (error: any) {
console.warn(`[REVERSE-DOWNLOAD] Queued ${downloadId}: ${error.message}`);
return reply.status(202).send({
queued: true,
downloadId: downloadId,
message: "Download queued due to memory constraints",
estimatedWaitTime: error.estimatedWaitTime || 60,
});
}
console.log(`[REVERSE-DOWNLOAD] Starting ${downloadId}: ${fileInfo.name} (${fileSizeMB.toFixed(1)}MB)`);
memoryManager.startDownload(downloadId);
try {
const result = await this.reverseShareService.downloadReverseShareFile(fileId, userId);
const originalUrl = result.url;
reply.header("X-Download-ID", downloadId);
reply.raw.on("finish", () => {
memoryManager.endDownload(downloadId);
});
reply.raw.on("close", () => {
memoryManager.endDownload(downloadId);
});
reply.raw.on("error", () => {
memoryManager.endDownload(downloadId);
});
return reply.send(result);
} catch (downloadError) {
memoryManager.endDownload(downloadId);
throw downloadError;
}
} catch (error: any) {
if (error.message === "File not found") {
return reply.status(404).send({ error: error.message });

View File

@@ -401,6 +401,12 @@ export async function reverseShareRoutes(app: FastifyInstance) {
url: z.string().describe("Presigned download URL - expires after 1 hour"),
expiresIn: z.number().describe("URL expiration time in seconds (3600 = 1 hour)"),
}),
202: z.object({
queued: z.boolean().describe("Download was queued due to memory constraints"),
downloadId: z.string().describe("Download identifier for tracking"),
message: z.string().describe("Queue status message"),
estimatedWaitTime: z.number().describe("Estimated wait time in seconds"),
}),
401: z.object({ error: z.string() }),
404: z.object({ error: z.string() }),
},

View File

@@ -1,6 +1,9 @@
import { PrismaClient } from "@prisma/client";
import { env } from "../../env";
import { EmailService } from "../email/service";
import { FileService } from "../file/service";
import { UserService } from "../user/service";
import {
CreateReverseShareInput,
ReverseShareResponseSchema,
@@ -40,6 +43,19 @@ const prisma = new PrismaClient();
export class ReverseShareService {
private reverseShareRepository = new ReverseShareRepository();
private fileService = new FileService();
private emailService = new EmailService();
private userService = new UserService();
private uploadSessions = new Map<
string,
{
reverseShareId: string;
uploaderName: string;
uploaderEmail?: string;
files: string[];
timeout: NodeJS.Timeout;
}
>();
async createReverseShare(data: CreateReverseShareInput, creatorId: string) {
const reverseShare = await this.reverseShareRepository.create(data, creatorId);
@@ -211,7 +227,7 @@ export class ReverseShareService {
}
}
const expires = 3600; // 1 hour
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
@@ -241,7 +257,7 @@ export class ReverseShareService {
}
}
const expires = 3600; // 1 hour
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
@@ -294,6 +310,8 @@ export class ReverseShareService {
size: BigInt(fileData.size),
});
this.addFileToUploadSession(reverseShare, fileData);
return this.formatFileResponse(file);
}
@@ -344,9 +362,30 @@ export class ReverseShareService {
size: BigInt(fileData.size),
});
this.addFileToUploadSession(reverseShare, fileData);
return this.formatFileResponse(file);
}
async getFileInfo(fileId: string, creatorId: string) {
const file = await this.reverseShareRepository.findFileById(fileId);
if (!file) {
throw new Error("File not found");
}
if (file.reverseShare.creatorId !== creatorId) {
throw new Error("Unauthorized to access this file");
}
return {
id: file.id,
name: file.name,
size: file.size,
objectName: file.objectName,
extension: file.extension,
};
}
async downloadReverseShareFile(fileId: string, creatorId: string) {
const file = await this.reverseShareRepository.findFileById(fileId);
if (!file) {
@@ -358,7 +397,7 @@ export class ReverseShareService {
}
const fileName = file.name;
const expires = 3600; // 1 hour
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedGetUrl(file.objectName, expires, fileName);
return { url, expiresIn: expires };
}
@@ -514,6 +553,7 @@ export class ReverseShareService {
}
const maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
const userFiles = await prisma.file.findMany({
where: { userId: creatorId },
select: { size: true },
@@ -625,6 +665,55 @@ export class ReverseShareService {
};
}
private generateSessionKey(reverseShareId: string, uploaderIdentifier: string): string {
return `${reverseShareId}-${uploaderIdentifier}`;
}
private async sendBatchFileUploadNotification(reverseShare: any, uploaderName: string, fileNames: string[]) {
try {
const creator = await this.userService.getUserById(reverseShare.creatorId);
const reverseShareName = reverseShare.name || "Unnamed Reverse Share";
const fileCount = fileNames.length;
const fileList = fileNames.join(", ");
await this.emailService.sendReverseShareBatchFileNotification(
creator.email,
reverseShareName,
fileCount,
fileList,
uploaderName
);
} catch (error) {
console.error("Failed to send reverse share batch file notification:", error);
}
}
private addFileToUploadSession(reverseShare: any, fileData: UploadToReverseShareInput) {
const uploaderIdentifier = fileData.uploaderEmail || fileData.uploaderName || "anonymous";
const sessionKey = this.generateSessionKey(reverseShare.id, uploaderIdentifier);
const uploaderName = fileData.uploaderName || "Someone";
const existingSession = this.uploadSessions.get(sessionKey);
if (existingSession) {
clearTimeout(existingSession.timeout);
existingSession.files.push(fileData.name);
} else {
this.uploadSessions.set(sessionKey, {
reverseShareId: reverseShare.id,
uploaderName,
uploaderEmail: fileData.uploaderEmail,
files: [fileData.name],
timeout: null as any,
});
}
const session = this.uploadSessions.get(sessionKey)!;
session.timeout = setTimeout(async () => {
await this.sendBatchFileUploadNotification(reverseShare, session.uploaderName, session.files);
this.uploadSessions.delete(sessionKey);
}, 5000);
}
private formatReverseShareResponse(reverseShare: ReverseShareData) {
const result = {
id: reverseShare.id,

View File

@@ -2,6 +2,7 @@ import bcrypt from "bcryptjs";
import { prisma } from "../../shared/prisma";
import { EmailService } from "../email/service";
import { UserService } from "../user/service";
import { CreateShareInput, ShareResponseSchema, UpdateShareInput } from "./dto";
import { IShareRepository, PrismaShareRepository } from "./repository";
@@ -9,6 +10,7 @@ export class ShareService {
constructor(private readonly shareRepository: IShareRepository = new PrismaShareRepository()) {}
private emailService = new EmailService();
private userService = new UserService();
private formatShareResponse(share: any) {
return {
@@ -339,11 +341,26 @@ export class ShareService {
throw new Error("No recipients found for this share");
}
// Get sender information
let senderName = "Someone";
try {
const sender = await this.userService.getUserById(userId);
if (sender.firstName && sender.lastName) {
senderName = `${sender.firstName} ${sender.lastName}`;
} else if (sender.firstName) {
senderName = sender.firstName;
} else if (sender.username) {
senderName = sender.username;
}
} catch (error) {
console.error(`Failed to get sender information for user ${userId}:`, error);
}
const notifiedRecipients: string[] = [];
for (const recipient of share.recipients) {
try {
await this.emailService.sendShareNotification(recipient.email, shareLink, share.name || undefined);
await this.emailService.sendShareNotification(recipient.email, shareLink, share.name || undefined, senderName);
notifiedRecipients.push(recipient.email);
} catch (error) {
console.error(`Failed to send email to ${recipient.email}:`, error);

View File

@@ -3,6 +3,7 @@ import fs from "node:fs";
import { promisify } from "util";
import { PrismaClient } from "@prisma/client";
import { env } from "../../env";
import { IS_RUNNING_IN_CONTAINER } from "../../utils/container-detection";
import { ConfigService } from "../config/service";
@@ -21,6 +22,30 @@ export class StorageService {
return Number.isNaN(parsed) ? 0 : parsed;
}
private _parseSize(value: string): number {
if (!value) return 0;
const cleanValue = value.trim().toLowerCase();
const numericMatch = cleanValue.match(/^(\d+(?:\.\d+)?)/);
if (!numericMatch) return 0;
const numericValue = parseFloat(numericMatch[1]);
if (Number.isNaN(numericValue)) return 0;
if (cleanValue.includes("t")) {
return Math.round(numericValue * 1024 * 1024 * 1024 * 1024);
} else if (cleanValue.includes("g")) {
return Math.round(numericValue * 1024 * 1024 * 1024);
} else if (cleanValue.includes("m")) {
return Math.round(numericValue * 1024 * 1024);
} else if (cleanValue.includes("k")) {
return Math.round(numericValue * 1024);
} else {
return Math.round(numericValue);
}
}
private async _tryDiskSpaceCommand(command: string): Promise<{ total: number; available: number } | null> {
try {
const { stdout, stderr } = await execAsync(command);
@@ -54,16 +79,61 @@ export class StorageService {
}
} 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 (command.includes("findmnt")) {
if (lines.length >= 1) {
const parts = lines[0].trim().split(/\s+/);
if (parts.length >= 2) {
const [availStr, sizeStr] = parts;
available = this._parseSize(availStr);
total = this._parseSize(sizeStr);
}
}
} else if (command.includes("stat -f")) {
let blockSize = 0;
let totalBlocks = 0;
let freeBlocks = 0;
for (const line of lines) {
if (line.includes("Block size:")) {
blockSize = this._safeParseInt(line.split(":")[1].trim());
} else if (line.includes("Total blocks:")) {
totalBlocks = this._safeParseInt(line.split(":")[1].trim());
} else if (line.includes("Free blocks:")) {
freeBlocks = this._safeParseInt(line.split(":")[1].trim());
}
}
if (blockSize > 0 && totalBlocks > 0) {
total = totalBlocks * blockSize;
available = freeBlocks * blockSize;
} else {
return null;
}
} else if (command.includes("--output=")) {
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 2) {
const [availStr, sizeStr] = parts;
available = this._safeParseInt(availStr) * 1024;
total = this._safeParseInt(sizeStr) * 1024;
}
}
} else {
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 if (command.includes("-h")) {
total = this._parseSize(size);
available = this._parseSize(avail);
} else {
total = this._safeParseInt(size) * 1024;
available = this._safeParseInt(avail) * 1024;
}
}
}
}
@@ -72,18 +142,13 @@ export class StorageService {
if (total > 0 && available >= 0) {
return { total, available };
} else {
console.warn(`Invalid values parsed: total=${total}, available=${available}`);
return null;
}
} catch (error) {
console.warn(`Command failed: ${command}`, error);
} catch {
return null;
}
}
/**
* Gets detailed mount information for debugging
*/
private async _getMountInfo(path: string): Promise<{ filesystem: string; mountPoint: string; type: string } | null> {
try {
if (!fs.existsSync("/proc/mounts")) {
@@ -109,16 +174,11 @@ export class StorageService {
}
return bestMatch;
} catch (error) {
console.warn(`Could not get mount info for ${path}:`, error);
} catch {
return null;
}
}
/**
* Detects if a path is a bind mount or mount point by checking /proc/mounts
* Returns the actual filesystem path for bind mounts
*/
private async _detectMountPoint(path: string): Promise<string | null> {
try {
if (!fs.existsSync("/proc/mounts")) {
@@ -133,9 +193,8 @@ export class StorageService {
for (const line of lines) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const [, mountPoint] = parts;
if (parts.length >= 3) {
const [device, mountPoint, filesystem] = parts;
if (path.startsWith(mountPoint) && mountPoint.length > bestMatchLength) {
bestMatch = mountPoint;
bestMatchLength = mountPoint.length;
@@ -148,24 +207,16 @@ export class StorageService {
}
return null;
} catch (error) {
console.warn(`Could not detect mount point for ${path}:`, error);
} catch {
return null;
}
}
/**
* Gets filesystem information for a specific path, with bind mount detection
*/
private async _getFileSystemInfo(
path: string
): Promise<{ total: number; available: number; mountPoint?: string } | null> {
try {
const mountInfo = await this._getMountInfo(path);
if (mountInfo && mountInfo.mountPoint !== "/") {
console.log(`📁 Bind mount detected: ${path}${mountInfo.filesystem} (${mountInfo.type})`);
}
const mountPoint = await this._detectMountPoint(path);
const targetPath = mountPoint || path;
@@ -174,7 +225,18 @@ export class StorageService {
? ["wmic logicaldisk get size,freespace,caption"]
: process.platform === "darwin"
? [`df -k "${targetPath}"`, `df "${targetPath}"`]
: [`df -B1 "${targetPath}"`, `df -k "${targetPath}"`, `df "${targetPath}"`];
: [
`df -B1 "${targetPath}"`,
`df -k "${targetPath}"`,
`df "${targetPath}"`,
`df -h "${targetPath}"`,
`df -T "${targetPath}"`,
`stat -f "${targetPath}"`,
`findmnt -n -o AVAIL,SIZE "${targetPath}"`,
`findmnt -n -o AVAIL,SIZE,TARGET "${targetPath}"`,
`df -P "${targetPath}"`,
`df --output=avail,size "${targetPath}"`,
];
for (const command of commandsToTry) {
const result = await this._tryDiskSpaceCommand(command);
@@ -187,25 +249,54 @@ export class StorageService {
}
return null;
} catch (error) {
console.warn(`Error getting filesystem info for ${path}:`, error);
} catch {
return null;
}
}
private async _detectSynologyVolumes(): Promise<string[]> {
try {
if (!fs.existsSync("/proc/mounts")) {
return [];
}
const mountsContent = await fs.promises.readFile("/proc/mounts", "utf8");
const lines = mountsContent.split("\n").filter((line) => line.trim());
const synologyPaths: string[] = [];
for (const line of lines) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const [, mountPoint] = parts;
if (mountPoint.match(/^\/volume\d+$/)) {
synologyPaths.push(mountPoint);
}
}
}
return synologyPaths;
} catch {
return [];
}
}
private async _getDiskSpaceMultiplePaths(): Promise<{ total: number; available: number } | null> {
const pathsToTry = IS_RUNNING_IN_CONTAINER
? ["/app/server/uploads", "/app/server", "/app", "/"]
const basePaths = IS_RUNNING_IN_CONTAINER
? ["/app/server/uploads", "/app/server/temp-uploads", "/app/server/temp-chunks", "/app/server", "/app", "/"]
: [".", "./uploads", process.cwd()];
const synologyPaths = await this._detectSynologyVolumes();
const pathsToTry = [...basePaths, ...synologyPaths];
for (const pathToCheck of pathsToTry) {
if (pathToCheck.includes("uploads")) {
if (pathToCheck.includes("uploads") || pathToCheck.includes("temp-")) {
try {
if (!fs.existsSync(pathToCheck)) {
fs.mkdirSync(pathToCheck, { recursive: true });
}
} catch (err) {
console.warn(`Could not create path ${pathToCheck}:`, err);
} catch {
continue;
}
}
@@ -214,12 +305,8 @@ export class StorageService {
continue;
}
// Use the new filesystem detection method
const result = await this._getFileSystemInfo(pathToCheck);
if (result) {
if (result.mountPoint) {
console.log(`✅ Storage resolved via bind mount: ${result.mountPoint}`);
}
return { total: result.total, available: result.available };
}
}
@@ -241,7 +328,6 @@ export class StorageService {
const diskInfo = await this._getDiskSpaceMultiplePaths();
if (!diskInfo) {
console.error("❌ Could not determine disk space - system configuration issue");
throw new Error("Unable to determine actual disk space - system configuration issue");
}
@@ -256,7 +342,7 @@ export class StorageService {
diskSizeGB: Number(diskSizeGB.toFixed(2)),
diskUsedGB: Number(diskUsedGB.toFixed(2)),
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
uploadAllowed: diskAvailableGB > 0.1, // At least 100MB free
uploadAllowed: diskAvailableGB > 0.1,
};
} else if (userId) {
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
@@ -268,7 +354,6 @@ export class StorageService {
});
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
@@ -282,7 +367,7 @@ export class StorageService {
throw new Error("User ID is required for non-admin users");
} catch (error) {
console.error("Error getting disk space:", error);
console.error("Error getting disk space:", error);
throw new Error(
`Failed to get disk space information: ${error instanceof Error ? error.message : String(error)}`
);

View File

@@ -8,21 +8,28 @@ import { pipeline } from "stream/promises";
import { directoriesConfig, getTempFilePath } from "../config/directories.config";
import { env } from "../env";
import { StorageProvider } from "../types/storage";
import { IS_RUNNING_IN_CONTAINER } from "../utils/container-detection";
export class FilesystemStorageProvider implements StorageProvider {
private static instance: FilesystemStorageProvider;
private uploadsDir: string;
private encryptionKey = env.ENCRYPTION_KEY;
private isEncryptionDisabled = env.DISABLE_FILESYSTEM_ENCRYPTION === "true";
private uploadTokens = new Map<string, { objectName: string; expiresAt: number }>();
private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
private constructor() {
this.uploadsDir = directoriesConfig.uploads;
if (!this.isEncryptionDisabled && !this.encryptionKey) {
throw new Error(
"Encryption is enabled but ENCRYPTION_KEY is not provided. " +
"Please set ENCRYPTION_KEY environment variable or set DISABLE_FILESYSTEM_ENCRYPTION=true to disable encryption."
);
}
this.ensureUploadsDir();
setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
setInterval(() => this.cleanupEmptyTempDirs(), 10 * 60 * 1000); // Every 10 minutes
setInterval(() => this.cleanupEmptyTempDirs(), 10 * 60 * 1000);
}
public static getInstance(): FilesystemStorageProvider {
@@ -62,10 +69,25 @@ export class FilesystemStorageProvider implements StorageProvider {
}
private createEncryptionKey(): Buffer {
if (!this.encryptionKey) {
throw new Error(
"Encryption key is required when encryption is enabled. Please set ENCRYPTION_KEY environment variable."
);
}
return crypto.scryptSync(this.encryptionKey, "salt", 32);
}
public createEncryptStream(): Transform {
if (this.isEncryptionDisabled) {
return new Transform({
highWaterMark: 64 * 1024,
transform(chunk, _encoding, callback) {
this.push(chunk);
callback();
},
});
}
const key = this.createEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
@@ -73,7 +95,8 @@ export class FilesystemStorageProvider implements StorageProvider {
let isFirstChunk = true;
return new Transform({
transform(chunk, encoding, callback) {
highWaterMark: 64 * 1024,
transform(chunk, _encoding, callback) {
try {
if (isFirstChunk) {
this.push(iv);
@@ -101,21 +124,32 @@ export class FilesystemStorageProvider implements StorageProvider {
}
public createDecryptStream(): Transform {
if (this.isEncryptionDisabled) {
return new Transform({
highWaterMark: 64 * 1024,
transform(chunk, _encoding, callback) {
this.push(chunk);
callback();
},
});
}
const key = this.createEncryptionKey();
let iv: Buffer | null = null;
let decipher: crypto.Decipher | null = null;
let ivBuffer = Buffer.alloc(0);
return new Transform({
transform(chunk, encoding, callback) {
highWaterMark: 64 * 1024,
transform(chunk, _encoding, callback) {
try {
if (!iv) {
ivBuffer = Buffer.concat([ivBuffer, chunk]);
if (ivBuffer.length >= 16) {
iv = ivBuffer.slice(0, 16);
iv = ivBuffer.subarray(0, 16);
decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
const remainingData = ivBuffer.slice(16);
const remainingData = ivBuffer.subarray(16);
if (remainingData.length > 0) {
const decrypted = decipher.update(remainingData);
this.push(decrypted);
@@ -213,32 +247,283 @@ export class FilesystemStorageProvider implements StorageProvider {
}
}
private encryptFileBuffer(buffer: Buffer): Buffer {
const key = this.createEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
const encrypted = Buffer.concat([iv, cipher.update(buffer), cipher.final()]);
return encrypted;
}
async downloadFile(objectName: string): Promise<Buffer> {
const filePath = this.getFilePath(objectName);
const encryptedBuffer = await fs.readFile(filePath);
const fileBuffer = await fs.readFile(filePath);
if (encryptedBuffer.length > 16) {
if (this.isEncryptionDisabled) {
return fileBuffer;
}
if (fileBuffer.length > 16) {
try {
return this.decryptFileBuffer(encryptedBuffer);
return this.decryptFileBuffer(fileBuffer);
} catch (error: unknown) {
if (error instanceof Error) {
console.warn("Failed to decrypt with new method, trying legacy format", error.message);
}
return this.decryptFileLegacy(encryptedBuffer);
return this.decryptFileLegacy(fileBuffer);
}
}
return this.decryptFileLegacy(encryptedBuffer);
return this.decryptFileLegacy(fileBuffer);
}
createDownloadStream(objectName: string): NodeJS.ReadableStream {
const filePath = this.getFilePath(objectName);
const streamOptions = {
highWaterMark: 64 * 1024,
autoDestroy: true,
emitClose: true,
};
const fileStream = fsSync.createReadStream(filePath, streamOptions);
if (this.isEncryptionDisabled) {
this.setupStreamMemoryManagement(fileStream, objectName);
return fileStream;
}
const decryptStream = this.createDecryptStream();
const { PassThrough } = require("stream");
const outputStream = new PassThrough(streamOptions);
let isDestroyed = false;
let memoryCheckInterval: NodeJS.Timeout;
const cleanup = () => {
if (isDestroyed) return;
isDestroyed = true;
if (memoryCheckInterval) {
clearInterval(memoryCheckInterval);
}
try {
if (fileStream && !fileStream.destroyed) {
fileStream.destroy();
}
if (decryptStream && !decryptStream.destroyed) {
decryptStream.destroy();
}
if (outputStream && !outputStream.destroyed) {
outputStream.destroy();
}
} catch (error) {
console.warn("Error during download stream cleanup:", error);
}
setImmediate(() => {
if (global.gc) {
global.gc();
}
});
};
memoryCheckInterval = setInterval(() => {
const memUsage = process.memoryUsage();
const memoryUsageMB = memUsage.heapUsed / 1024 / 1024;
if (memoryUsageMB > 1024) {
if (!fileStream.readableFlowing) return;
console.warn(
`[MEMORY THROTTLE] ${objectName} - Pausing stream due to high memory usage: ${memoryUsageMB.toFixed(2)}MB`
);
fileStream.pause();
if (global.gc) {
global.gc();
}
setTimeout(() => {
if (!isDestroyed && fileStream && !fileStream.destroyed) {
fileStream.resume();
console.log(`[MEMORY THROTTLE] ${objectName} - Stream resumed`);
}
}, 100);
}
}, 1000);
fileStream.on("error", (error: any) => {
console.error("File stream error:", error);
cleanup();
});
decryptStream.on("error", (error: any) => {
console.error("Decrypt stream error:", error);
cleanup();
});
outputStream.on("error", (error: any) => {
console.error("Output stream error:", error);
cleanup();
});
outputStream.on("close", cleanup);
outputStream.on("finish", cleanup);
outputStream.on("pipe", (src: any) => {
if (src && src.on) {
src.on("close", cleanup);
src.on("error", cleanup);
}
});
pipeline(fileStream, decryptStream, outputStream)
.then(() => {})
.catch((error: any) => {
console.error("Pipeline error during download:", error);
cleanup();
});
this.setupStreamMemoryManagement(outputStream, objectName);
return outputStream;
}
private setupStreamMemoryManagement(stream: NodeJS.ReadableStream, objectName: string): void {
let lastMemoryLog = 0;
stream.on("data", () => {
const now = Date.now();
if (now - lastMemoryLog > 30000) {
FilesystemStorageProvider.logMemoryUsage(`Active download: ${objectName}`);
lastMemoryLog = now;
}
});
stream.on("end", () => {
FilesystemStorageProvider.logMemoryUsage(`Download completed: ${objectName}`);
setImmediate(() => {
if (global.gc) {
global.gc();
}
});
});
stream.on("close", () => {
FilesystemStorageProvider.logMemoryUsage(`Download closed: ${objectName}`);
});
}
async createDownloadRangeStream(objectName: string, start: number, end: number): Promise<NodeJS.ReadableStream> {
if (!this.isEncryptionDisabled) {
return this.createRangeStreamFromDecrypted(objectName, start, end);
}
const filePath = this.getFilePath(objectName);
return fsSync.createReadStream(filePath, { start, end });
}
private createRangeStreamFromDecrypted(objectName: string, start: number, end: number): NodeJS.ReadableStream {
const { Transform, PassThrough } = require("stream");
const filePath = this.getFilePath(objectName);
const fileStream = fsSync.createReadStream(filePath);
const decryptStream = this.createDecryptStream();
const rangeStream = new PassThrough();
let bytesRead = 0;
let rangeEnded = false;
let isDestroyed = false;
const rangeTransform = new Transform({
transform(chunk: Buffer, encoding: any, callback: any) {
if (rangeEnded || isDestroyed) {
callback();
return;
}
const chunkStart = bytesRead;
const chunkEnd = bytesRead + chunk.length - 1;
bytesRead += chunk.length;
if (chunkEnd < start) {
callback();
return;
}
if (chunkStart > end) {
rangeEnded = true;
this.end();
callback();
return;
}
let sliceStart = 0;
let sliceEnd = chunk.length;
if (chunkStart < start) {
sliceStart = start - chunkStart;
}
if (chunkEnd > end) {
sliceEnd = end - chunkStart + 1;
rangeEnded = true;
}
const slicedChunk = chunk.slice(sliceStart, sliceEnd);
this.push(slicedChunk);
if (rangeEnded) {
this.end();
}
callback();
},
flush(callback: any) {
if (global.gc) {
global.gc();
}
callback();
},
});
const cleanup = () => {
if (isDestroyed) return;
isDestroyed = true;
try {
if (fileStream && !fileStream.destroyed) {
fileStream.destroy();
}
if (decryptStream && !decryptStream.destroyed) {
decryptStream.destroy();
}
if (rangeTransform && !rangeTransform.destroyed) {
rangeTransform.destroy();
}
if (rangeStream && !rangeStream.destroyed) {
rangeStream.destroy();
}
} catch (error) {
console.warn("Error during stream cleanup:", error);
}
if (global.gc) {
global.gc();
}
};
fileStream.on("error", cleanup);
decryptStream.on("error", cleanup);
rangeTransform.on("error", cleanup);
rangeStream.on("error", cleanup);
rangeStream.on("close", cleanup);
rangeStream.on("end", cleanup);
rangeStream.on("pipe", (src: any) => {
if (src && src.on) {
src.on("close", cleanup);
src.on("error", cleanup);
}
});
fileStream.pipe(decryptStream).pipe(rangeTransform).pipe(rangeStream);
return rangeStream;
}
private decryptFileBuffer(encryptedBuffer: Buffer): Buffer {
@@ -252,11 +537,69 @@ export class FilesystemStorageProvider implements StorageProvider {
}
private decryptFileLegacy(encryptedBuffer: Buffer): Buffer {
if (!this.encryptionKey) {
throw new Error(
"Encryption key is required when encryption is enabled. Please set ENCRYPTION_KEY environment variable."
);
}
const CryptoJS = require("crypto-js");
const decrypted = CryptoJS.AES.decrypt(encryptedBuffer.toString("utf8"), this.encryptionKey);
return Buffer.from(decrypted.toString(CryptoJS.enc.Utf8), "base64");
}
static logMemoryUsage(context: string = "Unknown"): void {
const memUsage = process.memoryUsage();
const formatBytes = (bytes: number) => {
const mb = bytes / 1024 / 1024;
return `${mb.toFixed(2)} MB`;
};
const rssInMB = memUsage.rss / 1024 / 1024;
const heapUsedInMB = memUsage.heapUsed / 1024 / 1024;
if (rssInMB > 1024 || heapUsedInMB > 512) {
console.warn(`[MEMORY WARNING] ${context} - High memory usage detected:`);
console.warn(` RSS: ${formatBytes(memUsage.rss)}`);
console.warn(` Heap Used: ${formatBytes(memUsage.heapUsed)}`);
console.warn(` Heap Total: ${formatBytes(memUsage.heapTotal)}`);
console.warn(` External: ${formatBytes(memUsage.external)}`);
if (global.gc) {
console.warn(" Forcing garbage collection...");
global.gc();
const afterGC = process.memoryUsage();
console.warn(` After GC - RSS: ${formatBytes(afterGC.rss)}, Heap: ${formatBytes(afterGC.heapUsed)}`);
}
} else {
console.log(
`[MEMORY INFO] ${context} - RSS: ${formatBytes(memUsage.rss)}, Heap: ${formatBytes(memUsage.heapUsed)}`
);
}
}
static forceGarbageCollection(context: string = "Manual"): void {
if (global.gc) {
const beforeGC = process.memoryUsage();
global.gc();
const afterGC = process.memoryUsage();
const formatBytes = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB`;
console.log(`[GC] ${context} - Before: RSS ${formatBytes(beforeGC.rss)}, Heap ${formatBytes(beforeGC.heapUsed)}`);
console.log(`[GC] ${context} - After: RSS ${formatBytes(afterGC.rss)}, Heap ${formatBytes(afterGC.heapUsed)}`);
const rssSaved = beforeGC.rss - afterGC.rss;
const heapSaved = beforeGC.heapUsed - afterGC.heapUsed;
if (rssSaved > 0 || heapSaved > 0) {
console.log(`[GC] ${context} - Freed: RSS ${formatBytes(rssSaved)}, Heap ${formatBytes(heapSaved)}`);
}
} else {
console.warn(`[GC] ${context} - Garbage collection not available. Start Node.js with --expose-gc flag.`);
}
}
async fileExists(objectName: string): Promise<boolean> {
const filePath = this.getFilePath(objectName);
try {
@@ -301,9 +644,6 @@ export class FilesystemStorageProvider implements StorageProvider {
this.downloadTokens.delete(token);
}
/**
* Clean up temporary file and its parent directory if empty
*/
private async cleanupTempFile(tempPath: string): Promise<void> {
try {
await fs.unlink(tempPath);
@@ -326,9 +666,6 @@ export class FilesystemStorageProvider implements StorageProvider {
}
}
/**
* Clean up empty temporary directories periodically
*/
private async cleanupEmptyTempDirs(): Promise<void> {
try {
const tempUploadsDir = directoriesConfig.tempUploads;

View File

@@ -3,6 +3,7 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { bucketName, s3Client } from "../config/storage.config";
import { StorageProvider } from "../types/storage";
import { getContentType } from "../utils/mime-types";
export class S3StorageProvider implements StorageProvider {
constructor() {
@@ -91,6 +92,7 @@ export class S3StorageProvider implements StorageProvider {
Bucket: bucketName,
Key: objectName,
ResponseContentDisposition: this.encodeFilenameForHeader(rcdFileName),
ResponseContentType: getContentType(rcdFileName),
});
return await getSignedUrl(s3Client, command, { expiresIn: expires });

View File

@@ -12,6 +12,7 @@ import { authProvidersRoutes } from "./modules/auth-providers/routes";
import { authRoutes } from "./modules/auth/routes";
import { fileRoutes } from "./modules/file/routes";
import { ChunkManager } from "./modules/filesystem/chunk-manager";
import { downloadQueueRoutes } from "./modules/filesystem/download-queue-routes";
import { filesystemRoutes } from "./modules/filesystem/routes";
import { healthRoutes } from "./modules/health/routes";
import { reverseShareRoutes } from "./modules/reverse-share/routes";
@@ -74,17 +75,17 @@ async function startServer() {
app.register(twoFactorRoutes, { prefix: "/auth" });
app.register(userRoutes);
app.register(fileRoutes);
if (env.ENABLE_S3 !== "true") {
app.register(filesystemRoutes);
}
app.register(downloadQueueRoutes);
app.register(shareRoutes);
app.register(reverseShareRoutes);
app.register(storageRoutes);
app.register(appRoutes);
app.register(healthRoutes);
if (env.ENABLE_S3 !== "true") {
app.register(filesystemRoutes);
}
await app.listen({
port: 3333,
host: "0.0.0.0",
@@ -101,7 +102,9 @@ async function startServer() {
}
console.log(`🌴 Palmr server running on port 3333 🌴`);
console.log(`📦 Storage mode: ${env.ENABLE_S3 === "true" ? "S3" : "Local Filesystem (Encrypted)"}`);
console.log(
`📦 Storage mode: ${env.ENABLE_S3 === "true" ? "S3" : `Local Filesystem ${env.DISABLE_FILESYSTEM_ENCRYPTION === "true" ? "(Unencrypted)" : "(Encrypted)"}`}`
);
console.log(`🔐 Auth Providers: ${authProviders}`);
console.log("\n📚 API Documentation:");

View File

@@ -0,0 +1,52 @@
/**
* TypeScript interfaces for download queue management
*/
export interface QueuedDownloadInfo {
downloadId: string;
position: number;
waitTime: number;
fileName?: string;
fileSize?: number;
}
export interface QueueStatus {
queueLength: number;
maxQueueSize: number;
activeDownloads: number;
maxConcurrent: number;
queuedDownloads: QueuedDownloadInfo[];
}
export interface DownloadCancelResponse {
message: string;
downloadId: string;
}
export interface QueueClearResponse {
message: string;
clearedCount: number;
}
export interface ApiResponse<T = any> {
status: "success" | "error";
data?: T;
error?: string;
message?: string;
}
export interface QueueStatusResponse extends ApiResponse<QueueStatus> {
status: "success";
data: QueueStatus;
}
export interface DownloadSlotRequest {
fileName?: string;
fileSize?: number;
objectName: string;
}
export interface ActiveDownloadInfo {
startTime: number;
memoryAtStart: number;
}

View File

@@ -0,0 +1,423 @@
import { ActiveDownloadInfo, DownloadSlotRequest, QueuedDownloadInfo, QueueStatus } from "../types/download-queue";
interface QueuedDownload {
downloadId: string;
queuedAt: number;
resolve: () => void;
reject: (error: Error) => void;
metadata?: DownloadSlotRequest;
}
export class DownloadMemoryManager {
private static instance: DownloadMemoryManager;
private activeDownloads = new Map<string, ActiveDownloadInfo>();
private downloadQueue: QueuedDownload[] = [];
private maxConcurrentDownloads: number;
private memoryThresholdMB: number;
private maxQueueSize: number;
private cleanupInterval: NodeJS.Timeout;
private isAutoScalingEnabled: boolean;
private minFileSizeGB: number;
private constructor() {
const { env } = require("../env");
const totalMemoryGB = require("os").totalmem() / 1024 ** 3;
this.isAutoScalingEnabled = env.DOWNLOAD_AUTO_SCALE === "true";
if (env.DOWNLOAD_MAX_CONCURRENT !== undefined) {
this.maxConcurrentDownloads = env.DOWNLOAD_MAX_CONCURRENT;
} else if (this.isAutoScalingEnabled) {
this.maxConcurrentDownloads = this.calculateDefaultConcurrentDownloads(totalMemoryGB);
} else {
this.maxConcurrentDownloads = 3;
}
if (env.DOWNLOAD_MEMORY_THRESHOLD_MB !== undefined) {
this.memoryThresholdMB = env.DOWNLOAD_MEMORY_THRESHOLD_MB;
} else if (this.isAutoScalingEnabled) {
this.memoryThresholdMB = this.calculateDefaultMemoryThreshold(totalMemoryGB);
} else {
this.memoryThresholdMB = 1024;
}
if (env.DOWNLOAD_QUEUE_SIZE !== undefined) {
this.maxQueueSize = env.DOWNLOAD_QUEUE_SIZE;
} else if (this.isAutoScalingEnabled) {
this.maxQueueSize = this.calculateDefaultQueueSize(totalMemoryGB);
} else {
this.maxQueueSize = 15;
}
if (env.DOWNLOAD_MIN_FILE_SIZE_GB !== undefined) {
this.minFileSizeGB = env.DOWNLOAD_MIN_FILE_SIZE_GB;
} else {
this.minFileSizeGB = 3.0;
}
this.validateConfiguration();
console.log(`[DOWNLOAD MANAGER] Configuration loaded:`);
console.log(` System Memory: ${totalMemoryGB.toFixed(1)}GB`);
console.log(
` Max Concurrent: ${this.maxConcurrentDownloads} ${env.DOWNLOAD_MAX_CONCURRENT !== undefined ? "(ENV)" : "(AUTO)"}`
);
console.log(
` Memory Threshold: ${this.memoryThresholdMB}MB ${env.DOWNLOAD_MEMORY_THRESHOLD_MB !== undefined ? "(ENV)" : "(AUTO)"}`
);
console.log(` Queue Size: ${this.maxQueueSize} ${env.DOWNLOAD_QUEUE_SIZE !== undefined ? "(ENV)" : "(AUTO)"}`);
console.log(
` Min File Size: ${this.minFileSizeGB}GB ${env.DOWNLOAD_MIN_FILE_SIZE_GB !== undefined ? "(ENV)" : "(DEFAULT)"}`
);
console.log(` Auto-scaling: ${this.isAutoScalingEnabled ? "enabled" : "disabled"}`);
this.cleanupInterval = setInterval(() => {
this.cleanupStaleDownloads();
}, 30000);
}
public static getInstance(): DownloadMemoryManager {
if (!DownloadMemoryManager.instance) {
DownloadMemoryManager.instance = new DownloadMemoryManager();
}
return DownloadMemoryManager.instance;
}
private calculateDefaultConcurrentDownloads(totalMemoryGB: number): number {
if (totalMemoryGB > 16) return 10;
if (totalMemoryGB > 8) return 5;
if (totalMemoryGB > 4) return 3;
if (totalMemoryGB > 2) return 2;
return 1;
}
private calculateDefaultMemoryThreshold(totalMemoryGB: number): number {
if (totalMemoryGB > 16) return 4096; // 4GB
if (totalMemoryGB > 8) return 2048; // 2GB
if (totalMemoryGB > 4) return 1024; // 1GB
if (totalMemoryGB > 2) return 512; // 512MB
return 256; // 256MB
}
private calculateDefaultQueueSize(totalMemoryGB: number): number {
if (totalMemoryGB > 16) return 50; // Large queue for powerful servers
if (totalMemoryGB > 8) return 25; // Medium queue
if (totalMemoryGB > 4) return 15; // Small queue
if (totalMemoryGB > 2) return 10; // Very small queue
return 5; // Minimal queue
}
private validateConfiguration(): void {
const warnings: string[] = [];
const errors: string[] = [];
if (this.maxConcurrentDownloads < 1) {
errors.push(`DOWNLOAD_MAX_CONCURRENT must be >= 1, got: ${this.maxConcurrentDownloads}`);
}
if (this.maxConcurrentDownloads > 50) {
warnings.push(
`DOWNLOAD_MAX_CONCURRENT is very high (${this.maxConcurrentDownloads}), this may cause performance issues`
);
}
if (this.memoryThresholdMB < 128) {
warnings.push(
`DOWNLOAD_MEMORY_THRESHOLD_MB is very low (${this.memoryThresholdMB}MB), downloads may be throttled frequently`
);
}
if (this.memoryThresholdMB > 16384) {
warnings.push(
`DOWNLOAD_MEMORY_THRESHOLD_MB is very high (${this.memoryThresholdMB}MB), system may run out of memory`
);
}
if (this.maxQueueSize < 1) {
errors.push(`DOWNLOAD_QUEUE_SIZE must be >= 1, got: ${this.maxQueueSize}`);
}
if (this.maxQueueSize > 1000) {
warnings.push(`DOWNLOAD_QUEUE_SIZE is very high (${this.maxQueueSize}), this may consume significant memory`);
}
if (this.minFileSizeGB < 0.1) {
warnings.push(
`DOWNLOAD_MIN_FILE_SIZE_GB is very low (${this.minFileSizeGB}GB), most downloads will use memory management`
);
}
if (this.minFileSizeGB > 50) {
warnings.push(
`DOWNLOAD_MIN_FILE_SIZE_GB is very high (${this.minFileSizeGB}GB), memory management may rarely activate`
);
}
const recommendedQueueSize = this.maxConcurrentDownloads * 5;
if (this.maxQueueSize < this.maxConcurrentDownloads) {
warnings.push(
`DOWNLOAD_QUEUE_SIZE (${this.maxQueueSize}) is smaller than DOWNLOAD_MAX_CONCURRENT (${this.maxConcurrentDownloads})`
);
} else if (this.maxQueueSize < recommendedQueueSize) {
warnings.push(
`DOWNLOAD_QUEUE_SIZE (${this.maxQueueSize}) might be too small. Recommended: ${recommendedQueueSize} (5x concurrent downloads)`
);
}
if (warnings.length > 0) {
console.warn(`[DOWNLOAD MANAGER] Configuration warnings:`);
warnings.forEach((warning) => console.warn(` - ${warning}`));
}
if (errors.length > 0) {
console.error(`[DOWNLOAD MANAGER] Configuration errors:`);
errors.forEach((error) => console.error(` - ${error}`));
throw new Error(`Invalid download manager configuration: ${errors.join(", ")}`);
}
}
public async requestDownloadSlot(downloadId: string, metadata?: DownloadSlotRequest): Promise<void> {
if (metadata?.fileSize) {
const fileSizeGB = metadata.fileSize / 1024 ** 3;
if (fileSizeGB < this.minFileSizeGB) {
console.log(
`[DOWNLOAD MANAGER] File ${metadata.fileName || "unknown"} (${fileSizeGB.toFixed(2)}GB) below threshold (${this.minFileSizeGB}GB), bypassing queue`
);
return Promise.resolve();
}
}
if (this.canStartImmediately()) {
console.log(`[DOWNLOAD MANAGER] Immediate start: ${downloadId}`);
return Promise.resolve();
}
if (this.downloadQueue.length >= this.maxQueueSize) {
const error = new Error(`Download queue is full: ${this.downloadQueue.length}/${this.maxQueueSize}`);
throw error;
}
return new Promise<void>((resolve, reject) => {
const queuedDownload: QueuedDownload = {
downloadId,
queuedAt: Date.now(),
resolve,
reject,
metadata,
};
this.downloadQueue.push(queuedDownload);
const position = this.downloadQueue.length;
console.log(`[DOWNLOAD MANAGER] Queued: ${downloadId} (Position: ${position}/${this.maxQueueSize})`);
if (metadata?.fileName && metadata?.fileSize) {
const sizeMB = (metadata.fileSize / (1024 * 1024)).toFixed(1);
console.log(`[DOWNLOAD MANAGER] Queued file: ${metadata.fileName} (${sizeMB}MB)`);
}
});
}
private canStartImmediately(): boolean {
const currentMemoryMB = this.getCurrentMemoryUsage();
if (currentMemoryMB > this.memoryThresholdMB) {
return false;
}
if (this.activeDownloads.size >= this.maxConcurrentDownloads) {
return false;
}
return true;
}
public canStartDownload(): { allowed: boolean; reason?: string } {
if (this.canStartImmediately()) {
return { allowed: true };
}
const currentMemoryMB = this.getCurrentMemoryUsage();
if (currentMemoryMB > this.memoryThresholdMB) {
return {
allowed: false,
reason: `Memory usage too high: ${currentMemoryMB.toFixed(0)}MB > ${this.memoryThresholdMB}MB`,
};
}
return {
allowed: false,
reason: `Too many concurrent downloads: ${this.activeDownloads.size}/${this.maxConcurrentDownloads}`,
};
}
public startDownload(downloadId: string): void {
const memUsage = process.memoryUsage();
this.activeDownloads.set(downloadId, {
startTime: Date.now(),
memoryAtStart: memUsage.rss + memUsage.external,
});
console.log(
`[DOWNLOAD MANAGER] Started: ${downloadId} (${this.activeDownloads.size}/${this.maxConcurrentDownloads} active)`
);
}
public endDownload(downloadId: string): void {
const downloadInfo = this.activeDownloads.get(downloadId);
this.activeDownloads.delete(downloadId);
if (downloadInfo) {
const duration = Date.now() - downloadInfo.startTime;
const memUsage = process.memoryUsage();
const currentMemory = memUsage.rss + memUsage.external;
const memoryDiff = currentMemory - downloadInfo.memoryAtStart;
console.log(
`[DOWNLOAD MANAGER] Ended: ${downloadId} (Duration: ${(duration / 1000).toFixed(1)}s, Memory delta: ${(memoryDiff / 1024 / 1024).toFixed(1)}MB)`
);
if (memoryDiff > 100 * 1024 * 1024 && global.gc) {
setImmediate(() => {
global.gc!();
console.log(`[DOWNLOAD MANAGER] Forced GC after download ${downloadId}`);
});
}
}
this.processQueue();
}
private processQueue(): void {
if (this.downloadQueue.length === 0 || !this.canStartImmediately()) {
return;
}
const nextDownload = this.downloadQueue.shift();
if (!nextDownload) {
return;
}
console.log(
`[DOWNLOAD MANAGER] Processing queue: ${nextDownload.downloadId} (${this.downloadQueue.length} remaining)`
);
if (nextDownload.metadata?.fileName && nextDownload.metadata?.fileSize) {
const sizeMB = (nextDownload.metadata.fileSize / (1024 * 1024)).toFixed(1);
console.log(`[DOWNLOAD MANAGER] Starting queued file: ${nextDownload.metadata.fileName} (${sizeMB}MB)`);
}
nextDownload.resolve();
}
public getActiveDownloadsCount(): number {
return this.activeDownloads.size;
}
private getCurrentMemoryUsage(): number {
const usage = process.memoryUsage();
return (usage.rss + usage.external) / (1024 * 1024);
}
public getCurrentMemoryUsageMB(): number {
return this.getCurrentMemoryUsage();
}
public getQueueStatus(): QueueStatus {
return {
queueLength: this.downloadQueue.length,
maxQueueSize: this.maxQueueSize,
activeDownloads: this.activeDownloads.size,
maxConcurrent: this.maxConcurrentDownloads,
queuedDownloads: this.downloadQueue.map((download, index) => ({
downloadId: download.downloadId,
position: index + 1,
waitTime: Date.now() - download.queuedAt,
fileName: download.metadata?.fileName,
fileSize: download.metadata?.fileSize,
})),
};
}
public cancelQueuedDownload(downloadId: string): boolean {
const index = this.downloadQueue.findIndex((item) => item.downloadId === downloadId);
if (index === -1) {
return false;
}
const canceledDownload = this.downloadQueue.splice(index, 1)[0];
canceledDownload.reject(new Error(`Download ${downloadId} was cancelled`));
console.log(`[DOWNLOAD MANAGER] Cancelled queued download: ${downloadId} (was at position ${index + 1})`);
return true;
}
private cleanupStaleDownloads(): void {
const now = Date.now();
const staleThreshold = 10 * 60 * 1000; // 10 minutes
const queueStaleThreshold = 30 * 60 * 1000;
for (const [downloadId, info] of this.activeDownloads.entries()) {
if (now - info.startTime > staleThreshold) {
console.warn(`[DOWNLOAD MANAGER] Cleaning up stale active download: ${downloadId}`);
this.activeDownloads.delete(downloadId);
}
}
const initialQueueLength = this.downloadQueue.length;
this.downloadQueue = this.downloadQueue.filter((download) => {
if (now - download.queuedAt > queueStaleThreshold) {
console.warn(`[DOWNLOAD MANAGER] Cleaning up stale queued download: ${download.downloadId}`);
download.reject(new Error(`Download ${download.downloadId} timed out in queue`));
return false;
}
return true;
});
if (this.downloadQueue.length < initialQueueLength) {
console.log(
`[DOWNLOAD MANAGER] Cleaned up ${initialQueueLength - this.downloadQueue.length} stale queued downloads`
);
}
this.processQueue();
}
public shouldThrottleStream(): boolean {
const currentMemoryMB = this.getCurrentMemoryUsageMB();
return currentMemoryMB > this.memoryThresholdMB * 0.8;
}
public getThrottleDelay(): number {
const currentMemoryMB = this.getCurrentMemoryUsageMB();
const thresholdRatio = currentMemoryMB / this.memoryThresholdMB;
if (thresholdRatio > 0.9) return 200;
if (thresholdRatio > 0.8) return 100;
return 50;
}
public destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.downloadQueue.forEach((download) => {
download.reject(new Error("Download manager is shutting down"));
});
this.activeDownloads.clear();
this.downloadQueue = [];
console.log("[DOWNLOAD MANAGER] Shutdown completed");
}
public clearQueue(): number {
const clearedCount = this.downloadQueue.length;
this.downloadQueue.forEach((download) => {
download.reject(new Error("Queue was cleared by administrator"));
});
this.downloadQueue = [];
console.log(`[DOWNLOAD MANAGER] Cleared queue: ${clearedCount} downloads cancelled`);
return clearedCount;
}
}

View File

@@ -0,0 +1,378 @@
/**
* Utility for detecting MIME types based on file extensions
* Fallback to application/octet-stream if extension is unknown
*/
const mimeTypeMap: Record<string, string> = {
// Images
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".bmp": "image/bmp",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".tiff": "image/tiff",
".tif": "image/tiff",
".avif": "image/avif",
".heic": "image/heic",
".heif": "image/heif",
".jxl": "image/jxl",
".psd": "image/vnd.adobe.photoshop",
".raw": "image/x-canon-cr2",
".cr2": "image/x-canon-cr2",
".nef": "image/x-nikon-nef",
".arw": "image/x-sony-arw",
".dng": "image/x-adobe-dng",
".xcf": "image/x-xcf",
".pbm": "image/x-portable-bitmap",
".pgm": "image/x-portable-graymap",
".ppm": "image/x-portable-pixmap",
".pnm": "image/x-portable-anymap",
// Documents
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".docm": "application/vnd.ms-word.document.macroEnabled.12",
".dotx": "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
".dotm": "application/vnd.ms-word.template.macroEnabled.12",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
".xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template",
".xltm": "application/vnd.ms-excel.template.macroEnabled.12",
".xlsb": "application/vnd.ms-excel.sheet.binary.macroEnabled.12",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".pptm": "application/vnd.ms-powerpoint.presentation.macroEnabled.12",
".potx": "application/vnd.openxmlformats-officedocument.presentationml.template",
".potm": "application/vnd.ms-powerpoint.template.macroEnabled.12",
".ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
".ppsm": "application/vnd.ms-powerpoint.slideshow.macroEnabled.12",
".odt": "application/vnd.oasis.opendocument.text",
".ods": "application/vnd.oasis.opendocument.spreadsheet",
".odp": "application/vnd.oasis.opendocument.presentation",
".odg": "application/vnd.oasis.opendocument.graphics",
".odf": "application/vnd.oasis.opendocument.formula",
".odb": "application/vnd.oasis.opendocument.database",
".odc": "application/vnd.oasis.opendocument.chart",
".odi": "application/vnd.oasis.opendocument.image",
// Text and Code
".txt": "text/plain",
".html": "text/html",
".htm": "text/html",
".css": "text/css",
".js": "application/javascript",
".mjs": "application/javascript",
".ts": "text/typescript",
".tsx": "text/tsx",
".jsx": "text/jsx",
".json": "application/json",
".xml": "application/xml",
".csv": "text/csv",
".yaml": "text/yaml",
".yml": "text/yaml",
".toml": "text/plain",
".ini": "text/plain",
".cfg": "text/plain",
".conf": "text/plain",
".log": "text/plain",
".md": "text/markdown",
".markdown": "text/markdown",
".rst": "text/x-rst",
".tex": "text/x-tex",
".latex": "text/x-latex",
".rtf": "application/rtf",
".ps": "application/postscript",
".eps": "application/postscript",
// Programming Languages
".c": "text/x-c",
".cc": "text/x-c",
".cpp": "text/x-c",
".cxx": "text/x-c",
".h": "text/x-c",
".hpp": "text/x-c",
".hxx": "text/x-c",
".java": "text/x-java-source",
".class": "application/java-vm",
".jar": "application/java-archive",
".war": "application/java-archive",
".py": "text/x-python",
".pyw": "text/x-python",
".rb": "text/x-ruby",
".php": "text/x-php",
".pl": "text/x-perl",
".pm": "text/x-perl",
".sh": "text/x-shellscript",
".bash": "text/x-shellscript",
".zsh": "text/x-shellscript",
".fish": "text/x-shellscript",
".bat": "text/x-msdos-batch",
".cmd": "text/x-msdos-batch",
".ps1": "text/plain",
".psm1": "text/plain",
".go": "text/x-go",
".rs": "text/x-rust",
".swift": "text/x-swift",
".kt": "text/x-kotlin",
".scala": "text/x-scala",
".clj": "text/x-clojure",
".hs": "text/x-haskell",
".elm": "text/x-elm",
".dart": "text/x-dart",
".r": "text/x-r",
".R": "text/x-r",
".sql": "text/x-sql",
".vb": "text/x-vb",
".cs": "text/x-csharp",
".fs": "text/x-fsharp",
".lua": "text/x-lua",
".m": "text/x-objc",
".mm": "text/x-objc",
// Audio
".mp3": "audio/mpeg",
".mp2": "audio/mpeg",
".m4a": "audio/mp4",
".m4b": "audio/mp4",
".m4p": "audio/mp4",
".wav": "audio/wav",
".wave": "audio/wav",
".aiff": "audio/aiff",
".aif": "audio/aiff",
".aifc": "audio/aiff",
".flac": "audio/flac",
".ogg": "audio/ogg",
".oga": "audio/ogg",
".opus": "audio/opus",
".aac": "audio/aac",
".wma": "audio/x-ms-wma",
".ac3": "audio/ac3",
".amr": "audio/amr",
".au": "audio/basic",
".snd": "audio/basic",
".mid": "audio/midi",
".midi": "audio/midi",
".kar": "audio/midi",
".ra": "audio/x-realaudio",
".ram": "audio/x-realaudio",
".3gp": "audio/3gpp",
".3g2": "audio/3gpp2",
".spx": "audio/speex",
".wv": "audio/x-wavpack",
".ape": "audio/x-ape",
".mpc": "audio/x-musepack",
// Video
".mp4": "video/mp4",
".m4v": "video/mp4",
".avi": "video/x-msvideo",
".mov": "video/quicktime",
".qt": "video/quicktime",
".wmv": "video/x-ms-wmv",
".asf": "video/x-ms-asf",
".flv": "video/x-flv",
".f4v": "video/x-f4v",
".webm": "video/webm",
".mkv": "video/x-matroska",
".mka": "audio/x-matroska",
".mks": "video/x-matroska",
".ogv": "video/ogg",
".ogm": "video/ogg",
".mxf": "application/mxf",
".m2ts": "video/mp2t",
".mts": "video/mp2t",
".vob": "video/dvd",
".mpg": "video/mpeg",
".mpeg": "video/mpeg",
".m1v": "video/mpeg",
".m2v": "video/mpeg",
".rm": "application/vnd.rn-realmedia",
".rmvb": "application/vnd.rn-realmedia-vbr",
".divx": "video/divx",
".xvid": "video/x-xvid",
// Archives and Compression
".zip": "application/zip",
".rar": "application/vnd.rar",
".7z": "application/x-7z-compressed",
".tar": "application/x-tar",
".tar.gz": "application/gzip",
".tgz": "application/gzip",
".tar.bz2": "application/x-bzip2",
".tbz2": "application/x-bzip2",
".tar.xz": "application/x-xz",
".txz": "application/x-xz",
".tar.lz": "application/x-lzip",
".tar.Z": "application/x-compress",
".Z": "application/x-compress",
".gz": "application/gzip",
".bz2": "application/x-bzip2",
".xz": "application/x-xz",
".lz": "application/x-lzip",
".lzma": "application/x-lzma",
".lzo": "application/x-lzop",
".arj": "application/x-arj",
".ace": "application/x-ace-compressed",
".cab": "application/vnd.ms-cab-compressed",
".iso": "application/x-iso9660-image",
".dmg": "application/x-apple-diskimage",
".img": "application/x-img",
".bin": "application/octet-stream",
".cue": "application/x-cue",
".nrg": "application/x-nrg",
".mdf": "application/x-mdf",
".toast": "application/x-toast",
// Executables and System
".exe": "application/vnd.microsoft.portable-executable",
".dll": "application/vnd.microsoft.portable-executable",
".msi": "application/x-msdownload",
".msp": "application/x-msdownload",
".deb": "application/vnd.debian.binary-package",
".rpm": "application/x-rpm",
".pkg": "application/x-newton-compatible-pkg",
".apk": "application/vnd.android.package-archive",
".ipa": "application/octet-stream",
".app": "application/octet-stream",
".snap": "application/x-snap",
".flatpak": "application/vnd.flatpak",
".appimage": "application/x-appimage",
// Adobe and Design
".ai": "application/illustrator",
".indd": "application/x-indesign",
".idml": "application/vnd.adobe.indesign-idml-package",
".sketch": "application/x-sketch",
".fig": "application/x-figma",
".xd": "application/vnd.adobe.xd",
// Fonts
".ttf": "font/ttf",
".otf": "font/otf",
".woff": "font/woff",
".woff2": "font/woff2",
".eot": "application/vnd.ms-fontobject",
".fon": "application/x-font-bdf",
".bdf": "application/x-font-bdf",
".pcf": "application/x-font-pcf",
".pfb": "application/x-font-type1",
".pfm": "application/x-font-type1",
".afm": "application/x-font-afm",
// E-books
".epub": "application/epub+zip",
".mobi": "application/x-mobipocket-ebook",
".azw": "application/vnd.amazon.ebook",
".azw3": "application/vnd.amazon.ebook",
".fb2": "application/x-fictionbook+xml",
".lit": "application/x-ms-reader",
".pdb": "application/vnd.palm",
".prc": "application/vnd.palm",
".tcr": "application/x-psion3-s",
// CAD and 3D
".dwg": "image/vnd.dwg",
".dxf": "image/vnd.dxf",
".step": "application/step",
".stp": "application/step",
".iges": "application/iges",
".igs": "application/iges",
".stl": "application/sla",
".obj": "application/x-tgif",
".3ds": "application/x-3ds",
".dae": "model/vnd.collada+xml",
".ply": "application/ply",
".x3d": "model/x3d+xml",
// Database
".db": "application/x-sqlite3",
".sqlite": "application/x-sqlite3",
".sqlite3": "application/x-sqlite3",
".mdb": "application/x-msaccess",
".accdb": "application/x-msaccess",
// Virtual Machine and Disk Images
".vmdk": "application/x-vmdk",
".vdi": "application/x-virtualbox-vdi",
".vhd": "application/x-virtualbox-vhd",
".vhdx": "application/x-virtualbox-vhdx",
".ova": "application/x-virtualbox-ova",
".ovf": "application/x-virtualbox-ovf",
".qcow2": "application/x-qemu-disk",
// Scientific and Math
".mat": "application/x-matlab-data",
".nc": "application/x-netcdf",
".cdf": "application/x-netcdf",
".hdf": "application/x-hdf",
".h5": "application/x-hdf5",
// Misc Application Formats
".torrent": "application/x-bittorrent",
".rss": "application/rss+xml",
".atom": "application/atom+xml",
".gpx": "application/gpx+xml",
".kml": "application/vnd.google-earth.kml+xml",
".kmz": "application/vnd.google-earth.kmz",
".ics": "text/calendar",
".vcs": "text/x-vcalendar",
".vcf": "text/x-vcard",
".p7s": "application/pkcs7-signature",
".p7m": "application/pkcs7-mime",
".p12": "application/x-pkcs12",
".pfx": "application/x-pkcs12",
".cer": "application/x-x509-ca-cert",
".crt": "application/x-x509-ca-cert",
".pem": "application/x-pem-file",
".key": "application/x-pem-file",
};
/**
* Get MIME type from file extension
* @param filename - The filename or extension (with or without leading dot)
* @returns MIME type string, defaults to 'application/octet-stream' if unknown
*/
export function getMimeType(filename: string): string {
if (!filename) {
return "application/octet-stream";
}
let extension: string;
if (filename.startsWith(".")) {
extension = filename.toLowerCase();
} else if (!filename.includes(".")) {
extension = "." + filename.toLowerCase();
} else {
const lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex === -1) {
return "application/octet-stream";
}
extension = filename.substring(lastDotIndex).toLowerCase();
}
return mimeTypeMap[extension] || "application/octet-stream";
}
/**
* Check if a MIME type represents an image
* @param mimeType - The MIME type to check
* @returns true if the MIME type is an image type
*/
export function isImageMimeType(mimeType: string): boolean {
return mimeType.startsWith("image/");
}
/**
* Get appropriate Content-Type header value for a file
* @param filename - The filename to detect type for
* @returns Content-Type header value
*/
export function getContentType(filename: string): string {
return getMimeType(filename);
}

View File

@@ -1 +1,2 @@
API_BASE_URL=http:localhost:3333
API_BASE_URL=http:localhost:3333
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US

View File

@@ -1,5 +0,0 @@

> palmr-web@3.1-beta lint /Users/daniel/clones/Palmr/apps/web
> eslint "src/**/*.+(ts|tsx)"

View File

@@ -159,7 +159,48 @@
"passwordLabel": "كلمة المرور",
"create": "إنشاء مشاركة",
"success": "تم إنشاء المشاركة بنجاح",
"error": "فشل في إنشاء المشاركة"
"error": "فشل في إنشاء المشاركة",
"namePlaceholder": "أدخل اسمًا لمشاركتك"
},
"customization": {
"breadcrumb": "التخصيص",
"colors": {
"title": "ألوان السمة",
"description": "اختر لون السمة الرئيسي المفضل لديك",
"presets": "الألوان المتاحة",
"presetsDescription": "اختر من بين السمات اللونية المتاحة",
"reset": "إعادة التعيين إلى الافتراضي"
},
"fonts": {
"title": "الخطوط",
"description": "اختر عائلة الخط المفضلة لديك",
"available": "الخطوط المتاحة",
"availableDescription": "اختر من بين عائلات الخطوط المتاحة",
"reset": "إعادة التعيين إلى الافتراضي"
},
"radius": {
"title": "حواف الإطار",
"description": "تخصيص استدارة عناصر الواجهة",
"available": "خيارات الاستدارة",
"availableDescription": "اختر كيف يجب أن تظهر الزوايا المستديرة",
"reset": "إعادة التعيين إلى الافتراضي"
},
"background": {
"title": "ألوان الخلفية",
"description": "تخصيص ألوان الخلفية للوضعين الفاتح والداكن",
"lightMode": "الوضع الفاتح",
"darkMode": "الوضع الداكن",
"availableDescription": "اختر ألوان الخلفية لكل من السمات الفاتحة والداكنة",
"reset": "إعادة التعيين إلى الافتراضي"
},
"theme": {
"title": "وضع السمة",
"description": "اختر بين السمة الفاتحة أو الداكنة أو سمة النظام",
"selectTheme": "تفضيلات السمة",
"availableDescription": "حدد وضع السمة المفضل لديك",
"reset": "إعادة التعيين إلى النظام"
},
"pageTitle": "التخصيص"
},
"dashboard": {
"loadError": "فشل في تحميل بيانات لوحة التحكم",
@@ -175,6 +216,39 @@
"filesToDelete": "الملفات المراد حذفها",
"sharesToDelete": "المشاركات التي سيتم حذفها"
},
"downloadQueue": {
"downloadQueued": "تم إضافة التنزيل إلى قائمة الانتظار: {fileName}",
"queuedDescription": "سيبدأ التنزيل تلقائياً عندما يتوفر موقع",
"queuePosition": "التنزيل في قائمة الانتظار في الموقع {position}: {fileName}",
"estimatedWait": "وقت الانتظار المقدر: {time}",
"queueFull": "قائمة انتظار التنزيل ممتلئة",
"queueFullDescription": "يرجى المحاولة مرة أخرى بعد بضع دقائق عندما تتوفر مساحة في قائمة الانتظار",
"cancelSuccess": "تم إلغاء التنزيل بنجاح",
"cancelError": "فشل إلغاء التنزيل: {error}",
"status": {
"pending": "جارٍ التحضير...",
"queued": "في قائمة الانتظار",
"downloading": "جارٍ التنزيل",
"completed": "مكتمل",
"failed": "فشل"
},
"waitTime": {
"seconds": "{seconds} ثانية",
"minutes": "{minutes} دقيقة",
"hoursMinutes": "{hours} ساعة {minutes} دقيقة"
},
"indicator": {
"title": "التنزيلات",
"downloads": "قائمة التنزيل",
"active": "نشط",
"queued": "في قائمة الانتظار",
"position": "الموقع {position}",
"estimatedWait": "الانتظار: {time}",
"unknownFile": "ملف غير معروف",
"noDownloads": "لا توجد تنزيلات قيد التقدم",
"refresh": "تحديث قائمة الانتظار"
}
},
"emptyState": {
"noFiles": "لم يتم رفع أي ملفات بعد",
"uploadFile": "رفع ملف"
@@ -202,7 +276,8 @@
"descriptionPlaceholder": "أدخل وصف الملف",
"deleteFile": "حذف الملف",
"deleteConfirmation": "هل أنت متأكد أنك تريد حذف ؟",
"deleteWarning": "هذا الإجراء لا يمكن التراجع عنه."
"deleteWarning": "هذا الإجراء لا يمكن التراجع عنه.",
"addDescriptionPlaceholder": "إضافة وصف..."
},
"fileManager": {
"downloadError": "فشل في تنزيل الملف",
@@ -270,7 +345,9 @@
"table": "جدول",
"grid": "شبكة"
},
"totalFiles": "{count, plural, =0 {لا توجد ملفات} =1 {ملف واحد} other {# ملفات}}"
"totalFiles": "{count, plural, =0 {لا توجد ملفات} =1 {ملف واحد} other {# ملفات}}",
"bulkDeleteConfirmation": "هل أنت متأكد من رغبتك في حذف {count, plural, =1 {ملف واحد} other {# ملفات}}؟ لا يمكن التراجع عن هذا الإجراء.",
"bulkDeleteTitle": "حذف الملفات المحددة"
},
"filesTable": {
"ariaLabel": "جدول الملفات",
@@ -313,7 +390,8 @@
"title": "نسيت كلمة المرور",
"description": "أدخل بريدك الإلكتروني وسنرسل لك تعليمات إعادة تعيين كلمة المرور.",
"resetInstructions": "تم إرسال تعليمات إعادة التعيين إلى بريدك الإلكتروني",
"pageTitle": "نسيت كلمة المرور"
"pageTitle": "نسيت كلمة المرور",
"passwordAuthDisabled": "تم تعطيل المصادقة بكلمة المرور. يرجى الاتصال بالمسؤول أو استخدام مزود مصادقة خارجي."
},
"generateShareLink": {
"generateTitle": "إنشاء رابط المشاركة",
@@ -327,7 +405,12 @@
"copyButton": "نسخ الرابط",
"success": "تم إنشاء الرابط بنجاح",
"error": "فشل في إنشاء الرابط",
"copied": "تم نسخ الرابط إلى الحافظة"
"copied": "تم نسخ الرابط إلى الحافظة",
"readyDescription": "رابط المشاركة الخاص بك جاهز. يمكنك مسح رمز QR مباشرة، أو تنزيله للاستخدام لاحقًا، أو نسخ الرابط أدناه.",
"tabs": {
"link": "الرابط",
"qrcode": "رمز QR"
}
},
"home": {
"description": "البديل مفتوح المصدر لـ WeTransfer. شارك ملفاتك بأمان، دون تتبع أو قيود.",
@@ -355,6 +438,12 @@
"stats": "{iconCount} أيقونة من {libraryCount} مكتبة",
"categoryBadge": "{category} ({count} أيقونات)"
},
"imageEdit": {
"title": "تعديل الصورة",
"rotate": "تدوير",
"zoom": "تكبير/تصغير",
"cropInstructions": "اسحب لإعادة تحديد الموضع، غير حجم الزوايا لضبط منطقة القص"
},
"login": {
"welcome": "مرحبا بك",
"signInToContinue": "قم بتسجيل الدخول للمتابعة",
@@ -395,11 +484,30 @@
"profile": "الملف الشخصي",
"settings": "الإعدادات",
"usersManagement": "إدارة المستخدمين",
"logout": "تسجيل الخروج"
"logout": "تسجيل الخروج",
"customization": "التخصيص"
},
"navigation": {
"dashboard": "لوحة التحكم"
},
"notifications": {
"permissionGranted": "تم تمكين إشعارات التنزيل",
"permissionDenied": "تم تعطيل إشعارات التنزيل",
"downloadComplete": {
"title": "اكتمل التنزيل",
"body": "اكتمل تنزيل {fileName}"
},
"downloadFailed": {
"title": "فشل التنزيل",
"body": "فشل تنزيل {fileName}: {error}",
"unknownError": "خطأ غير معروف"
},
"queueProcessing": {
"title": "بدء التنزيل",
"body": "يتم الآن تنزيل {fileName}{position}",
"position": " (كان #{position} في قائمة الانتظار)"
}
},
"profile": {
"password": {
"title": "تغيير كلمة المرور",
@@ -442,6 +550,11 @@
},
"pageTitle": "الملف الشخصي"
},
"qrCodeModal": {
"title": "مشاركة رمز QR",
"description": "امسح رمز QR هذا للوصول إلى الرابط.",
"download": "تحميل رمز QR"
},
"quickAccess": {
"files": {
"title": "ملفاتي",
@@ -613,7 +726,8 @@
"createLink": "إنشاء رابط",
"delete": "حذف",
"copyLinkTitle": "نسخ الرابط",
"createLinkCTA": "إنشاء رابط استلام"
"createLinkCTA": "إنشاء رابط استلام",
"viewQrCode": "عرض رمز QR"
},
"status": {
"active": "نشط",
@@ -629,7 +743,8 @@
"viewDetails": "عرض التفاصيل",
"edit": "تحرير",
"delete": "حذف",
"viewFiles": "الملفات المستلمة"
"viewFiles": "الملفات المستلمة",
"viewQrCode": "عرض رمز QR"
},
"empty": {
"title": "لم يتم إنشاء روابط استلام",
@@ -775,7 +890,8 @@
"timeout": "انتهت مهلة عملية النسخ. يرجى المحاولة مرة أخرى باستخدام ملف أصغر أو التحقق من اتصالك.",
"failed": "فشلت عملية النسخ. يرجى المحاولة مرة أخرى.",
"aborted": "تم إلغاء عملية النسخ بسبب انتهاء المهلة."
}
},
"invalidDate": "تاريخ غير صحيح"
}
},
"form": {
@@ -1117,6 +1233,10 @@
"smtpTrustSelfSigned": {
"title": "الوثوق بالشهادات الموقعة ذاتياً",
"description": "قم بتمكين هذا للوثوق بشهادات SSL/TLS الموقعة ذاتياً (مفيد لبيئات التطوير)"
},
"passwordAuthEnabled": {
"title": "المصادقة بالكلمة السرية",
"description": "تمكين أو تعطيل المصادقة بالكلمة السرية"
}
},
"buttons": {
@@ -1126,7 +1246,8 @@
},
"errors": {
"loadFailed": "فشل في تحميل الإعدادات",
"updateFailed": "فشل في تحديث الإعدادات"
"updateFailed": "فشل في تحديث الإعدادات",
"passwordAuthRequiresProvider": "لا يمكن تعطيل المصادقة بالكلمة السرية دون وجود على الأقل موفرين مصادقة مفعلين"
},
"messages": {
"noChanges": "لا توجد تغييرات للحفظ",
@@ -1200,7 +1321,8 @@
"editSuccess": "تم تحديث المشاركة بنجاح",
"editError": "فشل في تحديث المشاركة",
"bulkDeleteConfirmation": "هل أنت متأكد من أنك تريد حذف {count, plural, =1 {مشاركة واحدة} other {# مشاركات}} محددة؟ لا يمكن التراجع عن هذا الإجراء.",
"bulkDeleteTitle": "حذف المشاركات المحددة"
"bulkDeleteTitle": "حذف المشاركات المحددة",
"addDescriptionPlaceholder": "إضافة وصف..."
},
"shareDetails": {
"title": "تفاصيل المشاركة",
@@ -1232,7 +1354,10 @@
"invalidDate": "تاريخ غير صحيح",
"loadError": "فشل في تحميل تفاصيل المشاركة",
"editSecurity": "تحرير الأمان",
"editExpiration": "تحرير انتهاء الصلاحية"
"editExpiration": "تحرير انتهاء الصلاحية",
"clickToEnlargeQrCode": "انقر لتكبير رمز QR",
"downloadQrCode": "تحميل رمز QR",
"qrCode": "رمز QR"
},
"shareExpiration": {
"neverExpires": "لا تنتهي صلاحيته أبداً",
@@ -1419,7 +1544,8 @@
"copyLink": "نسخ الرابط",
"notifyRecipients": "إشعار المستقبلين",
"delete": "حذف",
"downloadShareFiles": "قم بتنزيل جميع الملفات"
"downloadShareFiles": "قم بتنزيل جميع الملفات",
"viewQrCode": "عرض رمز QR"
},
"bulkActions": {
"delete": "حذف",
@@ -1636,7 +1762,8 @@
"title": "إفلات الملفات للرفع",
"description": "حرر للرفع ملفاتك"
},
"pasteSuccess": "{count, plural, =1 {تم لصق الصورة ورفعها بنجاح} other {تم لصق # صور ورفعها بنجاح}}"
"pasteSuccess": "{count, plural, =1 {تم لصق الصورة ورفعها بنجاح} other {تم لصق # صور ورفعها بنجاح}}",
"filesQueued": "{count, plural, one {# ملف في الصف} other {# ملفات في الصف}}"
},
"users": {
"modes": {
@@ -1721,11 +1848,5 @@
"passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
},
"imageEdit": {
"title": "تعديل الصورة",
"rotate": "تدوير",
"zoom": "تكبير/تصغير",
"cropInstructions": "اسحب لإعادة تحديد الموضع، غير حجم الزوايا لضبط منطقة القص"
}
}
}

View File

@@ -159,7 +159,48 @@
"passwordLabel": "Passwort",
"create": "Freigabe Erstellen",
"success": "Freigabe erfolgreich erstellt",
"error": "Fehler beim Erstellen der Freigabe"
"error": "Fehler beim Erstellen der Freigabe",
"namePlaceholder": "Geben Sie einen Namen für Ihre Freigabe ein"
},
"customization": {
"breadcrumb": "Anpassung",
"colors": {
"title": "Farbthema",
"description": "Wählen Sie Ihre bevorzugte Primärfarbe für das Theme",
"presets": "Verfügbare Farben",
"presetsDescription": "Wählen Sie aus verfügbaren Farbthemen",
"reset": "Auf Standard zurücksetzen"
},
"fonts": {
"title": "Typografie",
"description": "Wählen Sie Ihre bevorzugte Schriftart",
"available": "Verfügbare Schriftarten",
"availableDescription": "Wählen Sie aus verfügbaren Schriftfamilien",
"reset": "Auf Standard zurücksetzen"
},
"radius": {
"title": "Rahmenradius",
"description": "Passen Sie die Rundung der Oberflächenelemente an",
"available": "Rundungsoptionen",
"availableDescription": "Wählen Sie, wie abgerundet die Ecken erscheinen sollen",
"reset": "Auf Standard zurücksetzen"
},
"background": {
"title": "Hintergrundfarben",
"description": "Passen Sie Hintergrundfarben für Hell- und Dunkelmodus an",
"lightMode": "Hellmodus",
"darkMode": "Dunkelmodus",
"availableDescription": "Wählen Sie Hintergrundfarben für helle und dunkle Themes",
"reset": "Auf Standard zurücksetzen"
},
"theme": {
"title": "Themenmodus",
"description": "Wählen Sie zwischen hellem, dunklem oder Systemmodus",
"selectTheme": "Theme-Einstellung",
"availableDescription": "Wählen Sie Ihren bevorzugten Themenmodus",
"reset": "Auf System zurücksetzen"
},
"pageTitle": "Anpassung"
},
"dashboard": {
"loadError": "Fehler beim Laden der Dashboard-Daten",
@@ -175,6 +216,39 @@
"filesToDelete": "Zu löschende Dateien",
"sharesToDelete": "Freigaben, die gelöscht werden"
},
"downloadQueue": {
"downloadQueued": "Download in Warteschlange: {fileName}",
"queuedDescription": "Ihr Download startet automatisch, sobald ein Platz frei wird",
"queuePosition": "Download an Position {position} in der Warteschlange: {fileName}",
"estimatedWait": "Geschätzte Wartezeit: {time}",
"queueFull": "Download-Warteschlange ist voll",
"queueFullDescription": "Bitte versuchen Sie es in einigen Minuten erneut, wenn die Warteschlange Platz hat",
"cancelSuccess": "Download erfolgreich abgebrochen",
"cancelError": "Download konnte nicht abgebrochen werden: {error}",
"status": {
"pending": "Wird vorbereitet...",
"queued": "In Warteschlange",
"downloading": "Wird heruntergeladen",
"completed": "Abgeschlossen",
"failed": "Fehlgeschlagen"
},
"waitTime": {
"seconds": "{seconds}s",
"minutes": "{minutes}m",
"hoursMinutes": "{hours}h {minutes}m"
},
"indicator": {
"title": "Downloads",
"downloads": "Download-Warteschlange",
"active": "Aktiv",
"queued": "In Warteschlange",
"position": "Position {position}",
"estimatedWait": "Wartezeit: {time}",
"unknownFile": "Unbekannte Datei",
"noDownloads": "Keine laufenden Downloads",
"refresh": "Warteschlange aktualisieren"
}
},
"emptyState": {
"noFiles": "Noch keine Dateien hochgeladen",
"uploadFile": "Datei hochladen"
@@ -200,6 +274,7 @@
"extension": "Erweiterung",
"descriptionLabel": "Beschreibung",
"descriptionPlaceholder": "Dateibeschreibung eingeben",
"addDescriptionPlaceholder": "Beschreibung hinzufügen...",
"deleteFile": "Datei löschen",
"deleteConfirmation": "Sind Sie sicher, dass Sie löschen möchten?",
"deleteWarning": "Diese Aktion kann nicht rückgängig gemacht werden."
@@ -266,6 +341,8 @@
"bulkDownloadFileError": "Fehler beim Herunterladen der Datei {fileName}",
"bulkDeleteSuccess": "{count, plural, =1 {1 Datei erfolgreich gelöscht} other {# Dateien erfolgreich gelöscht}}",
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Dateien",
"bulkDeleteTitle": "Ausgewählte Dateien Löschen",
"bulkDeleteConfirmation": "Sind Sie sicher, dass Sie {count, plural, =1 {1 Datei} other {# Dateien}} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"viewMode": {
"table": "Tabelle",
"grid": "Raster"
@@ -313,7 +390,8 @@
"title": "Passwort vergessen",
"description": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen Anweisungen zum Zurücksetzen Ihres Passworts.",
"resetInstructions": "Anweisungen zum Zurücksetzen wurden an Ihre E-Mail gesendet",
"pageTitle": "Passwort vergessen"
"pageTitle": "Passwort vergessen",
"passwordAuthDisabled": "Passwortauthentifizierung ist deaktiviert. Bitte kontaktieren Sie Ihren Administrator oder verwenden Sie einen externen Authentifizierungsanbieter."
},
"generateShareLink": {
"generateTitle": "Freigabe-Link generieren",
@@ -327,7 +405,12 @@
"copyButton": "Link kopieren",
"success": "Link erfolgreich generiert",
"error": "Fehler beim Generieren des Links",
"copied": "Link in die Zwischenablage kopiert"
"copied": "Link in die Zwischenablage kopiert",
"readyDescription": "Ihr Freigabe-Link ist bereit. Sie können den QR-Code direkt scannen, ihn für die spätere Verwendung herunterladen oder den Link unten kopieren.",
"tabs": {
"link": "Link",
"qrcode": "QR-Code"
}
},
"home": {
"description": "Die Open-Source-Alternative zu WeTransfer. Teilen Sie Dateien sicher, ohne Tracking oder Einschränkungen.",
@@ -355,6 +438,12 @@
"stats": "{iconCount} Symbole aus {libraryCount} Bibliotheken",
"categoryBadge": "{category} ({count} Symbole)"
},
"imageEdit": {
"title": "Bild bearbeiten",
"rotate": "Drehen",
"zoom": "Zoom",
"cropInstructions": "Ziehen Sie, um die Position zu ändern, ändern Sie die Größe der Ecken, um die Zuschneidefläche anzupassen"
},
"login": {
"welcome": "Willkommen zu",
"signInToContinue": "Melden Sie sich an, um fortzufahren",
@@ -395,11 +484,30 @@
"profile": "Profil",
"settings": "Einstellungen",
"usersManagement": "Benutzerverwaltung",
"logout": "Abmelden"
"logout": "Abmelden",
"customization": "Anpassung"
},
"navigation": {
"dashboard": "Übersicht"
},
"notifications": {
"permissionGranted": "Download-Benachrichtigungen aktiviert",
"permissionDenied": "Download-Benachrichtigungen deaktiviert",
"downloadComplete": {
"title": "Download abgeschlossen",
"body": "{fileName} wurde erfolgreich heruntergeladen"
},
"downloadFailed": {
"title": "Download fehlgeschlagen",
"body": "Fehler beim Herunterladen von {fileName}: {error}",
"unknownError": "Unbekannter Fehler"
},
"queueProcessing": {
"title": "Download startet",
"body": "{fileName} wird jetzt heruntergeladen{position}",
"position": " (war #{position} in der Warteschlange)"
}
},
"profile": {
"password": {
"title": "Passwort ändern",
@@ -442,6 +550,11 @@
},
"pageTitle": "Profil"
},
"qrCodeModal": {
"title": "QR-Code teilen",
"description": "Scannen Sie diesen QR-Code, um auf den Link zuzugreifen.",
"download": "QR-Code herunterladen"
},
"quickAccess": {
"files": {
"title": "Meine Dateien",
@@ -613,7 +726,8 @@
"createLink": "Link erstellen",
"delete": "Löschen",
"copyLinkTitle": "Link kopieren",
"createLinkCTA": "Empfangslink erstellen"
"createLinkCTA": "Empfangslink erstellen",
"viewQrCode": "QR-Code anzeigen"
},
"status": {
"active": "Aktiv",
@@ -629,7 +743,8 @@
"viewDetails": "Details anzeigen",
"edit": "Bearbeiten",
"delete": "Löschen",
"viewFiles": "Empfangene Dateien"
"viewFiles": "Empfangene Dateien",
"viewQrCode": "QR-Code anzeigen"
},
"empty": {
"title": "Keine Empfangslinks erstellt",
@@ -730,6 +845,7 @@
"noFiles": "Noch keine Dateien empfangen",
"noFilesDescription": "Über diesen Link gesendete Dateien erscheinen hier",
"fileCount": "{count, plural, =0 {Keine Dateien} =1 {1 Datei} other {# Dateien}}",
"invalidDate": "Ungültiges Datum",
"totalSize": "Gesamtgröße: {size}",
"columns": {
"file": "Datei",
@@ -1115,6 +1231,10 @@
"tls": "STARTTLS (Port 587)",
"none": "Keine (Unsicher)"
}
},
"passwordAuthEnabled": {
"title": "Passwort-Authentifizierung",
"description": "Passwort-basierte Authentifizierung aktivieren oder deaktivieren"
}
},
"buttons": {
@@ -1124,7 +1244,8 @@
},
"errors": {
"loadFailed": "Fehler beim Laden der Einstellungen",
"updateFailed": "Fehler beim Aktualisieren der Einstellungen"
"updateFailed": "Fehler beim Aktualisieren der Einstellungen",
"passwordAuthRequiresProvider": "Passwort-basierte Authentifizierung kann nicht deaktiviert werden, wenn kein aktiver Authentifizierungsanbieter vorhanden ist"
},
"messages": {
"noChanges": "Keine Änderungen zum Speichern",
@@ -1180,6 +1301,7 @@
"shareActions": {
"deleteTitle": "Freigabe Löschen",
"deleteConfirmation": "Sind Sie sicher, dass Sie diese Freigabe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"addDescriptionPlaceholder": "Beschreibung hinzufügen...",
"editTitle": "Freigabe Bearbeiten",
"nameLabel": "Freigabe-Name",
"descriptionLabel": "Beschreibung",
@@ -1230,7 +1352,10 @@
"invalidDate": "Ungültiges Datum",
"loadError": "Fehler beim Laden der Freigabe-Details",
"editSecurity": "Sicherheit bearbeiten",
"editExpiration": "Ablauf bearbeiten"
"editExpiration": "Ablauf bearbeiten",
"clickToEnlargeQrCode": "Klicken Sie zum Vergrößern des QR-Codes",
"downloadQrCode": "QR-Code herunterladen",
"qrCode": "QR-Code"
},
"shareExpiration": {
"neverExpires": "Läuft nie ab",
@@ -1417,7 +1542,8 @@
"copyLink": "Link Kopieren",
"notifyRecipients": "Empfänger Benachrichtigen",
"delete": "Löschen",
"downloadShareFiles": "Laden Sie alle Dateien herunter"
"downloadShareFiles": "Laden Sie alle Dateien herunter",
"viewQrCode": "QR-Code anzeigen"
},
"bulkActions": {
"delete": "Löschen",
@@ -1634,7 +1760,8 @@
"title": "Dateien zum Hochladen ablegen",
"description": "Loslassen, um Ihre Dateien hochzuladen"
},
"pasteSuccess": "{count, plural, =1 {Bild erfolgreich eingefügt und hochgeladen} other {# Bilder erfolgreich eingefügt und hochgeladen}}"
"pasteSuccess": "{count, plural, =1 {Bild erfolgreich eingefügt und hochgeladen} other {# Bilder erfolgreich eingefügt und hochgeladen}}",
"filesQueued": "{count, plural, one {# Datei in der Warteschlange} other {# Dateien in der Warteschlange}}"
},
"users": {
"modes": {
@@ -1719,11 +1846,5 @@
"passwordRequired": "Passwort ist erforderlich",
"nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich"
},
"imageEdit": {
"title": "Bild bearbeiten",
"rotate": "Drehen",
"zoom": "Zoom",
"cropInstructions": "Ziehen Sie, um die Position zu ändern, ändern Sie die Größe der Ecken, um die Zuschneidefläche anzupassen"
}
}
}

View File

@@ -149,6 +149,7 @@
"createShare": {
"title": "Create Share",
"nameLabel": "Share Name",
"namePlaceholder": "Enter a name for your share",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Enter a description (optional)",
"expirationLabel": "Expiration Date",
@@ -161,6 +162,46 @@
"success": "Share created successfully",
"error": "Failed to create share"
},
"customization": {
"breadcrumb": "Customization",
"colors": {
"title": "Theme Colors",
"description": "Choose your preferred primary color theme",
"presets": "Available Colors",
"presetsDescription": "Select from available color themes",
"reset": "Reset to Default"
},
"fonts": {
"title": "Typography",
"description": "Choose your preferred font family",
"available": "Available Fonts",
"availableDescription": "Select from available font families",
"reset": "Reset to Default"
},
"radius": {
"title": "Border Radius",
"description": "Customize the roundness of interface elements",
"available": "Roundness Options",
"availableDescription": "Choose how rounded corners should appear",
"reset": "Reset to Default"
},
"background": {
"title": "Background Colors",
"description": "Customize background colors for light and dark modes",
"lightMode": "Light Mode",
"darkMode": "Dark Mode",
"availableDescription": "Choose background colors for both light and dark themes",
"reset": "Reset to Default"
},
"theme": {
"title": "Theme Mode",
"description": "Choose between light, dark, or system theme",
"selectTheme": "Theme Preference",
"availableDescription": "Select your preferred theme mode",
"reset": "Reset to System"
},
"pageTitle": "Customization"
},
"dashboard": {
"loadError": "Failed to load dashboard data",
"linkCopied": "Link copied to clipboard",
@@ -175,6 +216,39 @@
"filesToDelete": "Files to be deleted",
"sharesToDelete": "Shares to be deleted"
},
"downloadQueue": {
"downloadQueued": "Download queued: {fileName}",
"queuedDescription": "Your download will start automatically when a slot becomes available",
"queuePosition": "Download queued at position {position}: {fileName}",
"estimatedWait": "Estimated wait time: {time}",
"queueFull": "Download queue is full",
"queueFullDescription": "Please try again in a few minutes when the queue has space",
"cancelSuccess": "Download cancelled successfully",
"cancelError": "Failed to cancel download: {error}",
"status": {
"pending": "Preparing...",
"queued": "In queue",
"downloading": "Downloading",
"completed": "Completed",
"failed": "Failed"
},
"waitTime": {
"seconds": "{seconds}s",
"minutes": "{minutes}m",
"hoursMinutes": "{hours}h {minutes}m"
},
"indicator": {
"title": "Downloads",
"downloads": "Download Queue",
"active": "Active",
"queued": "Queued",
"position": "Position {position}",
"estimatedWait": "Wait: {time}",
"unknownFile": "Unknown file",
"noDownloads": "No downloads in progress",
"refresh": "Refresh Queue"
}
},
"emptyState": {
"noFiles": "No files uploaded yet",
"uploadFile": "Upload File"
@@ -200,6 +274,7 @@
"extension": "Extension",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Enter file description",
"addDescriptionPlaceholder": "Add description...",
"deleteFile": "Delete File",
"deleteConfirmation": "Are you sure you want to delete this file?",
"deleteWarning": "This action cannot be undone."
@@ -266,6 +341,8 @@
"bulkDownloadFileError": "Error downloading file {fileName}",
"bulkDeleteSuccess": "{count, plural, =1 {1 file deleted successfully} other {# files deleted successfully}}",
"bulkDeleteError": "Error deleting selected files",
"bulkDeleteTitle": "Delete Selected Files",
"bulkDeleteConfirmation": "Are you sure you want to delete {count, plural, =1 {1 file} other {# files}}? This action cannot be undone.",
"totalFiles": "{count, plural, =0 {No files} =1 {1 file} other {# files}}",
"viewMode": {
"table": "Table",
@@ -313,21 +390,27 @@
"title": "Forgot Password",
"description": "Enter your email address and we'll send you instructions to reset your password",
"resetInstructions": "Reset instructions sent to your email",
"pageTitle": "Forgot Password"
"pageTitle": "Forgot Password",
"passwordAuthDisabled": "Password authentication is disabled. Please contact your administrator or use an external authentication provider."
},
"generateShareLink": {
"generateTitle": "Generate Share Link",
"updateTitle": "Update Share Link",
"generateDescription": "Generate a link to share your files",
"updateDescription": "Update the alias for this share link",
"aliasPlaceholder": "Enter alias",
"linkReady": "Your share link is ready:",
"generateDescription": "Generate a custom link for this share. You can customize the URL to make it more memorable.",
"updateDescription": "Update the custom link for this share. You can customize the URL to make it more memorable.",
"aliasPlaceholder": "Custom ID for link",
"linkReady": "Your share link is ready. You can copy it now.",
"readyDescription": "Your share link is ready. You can scan the QR code directly, download it for later use, or copy the link below.",
"generateButton": "Generate Link",
"updateButton": "Update Link",
"copyButton": "Copy Link",
"success": "Link generated successfully",
"error": "Failed to generate link",
"copied": "Link copied to clipboard"
"copied": "Link copied to clipboard",
"tabs": {
"link": "Link",
"qrcode": "QR Code"
}
},
"home": {
"description": "The open-source alternative to WeTransfer. Share files securely, without tracking or limitations.",
@@ -355,6 +438,12 @@
"stats": "{iconCount} icons from {libraryCount} libraries",
"categoryBadge": "{category} ({count} icons)"
},
"imageEdit": {
"title": "Edit Image",
"rotate": "Rotate",
"zoom": "Zoom",
"cropInstructions": "Drag to reposition, resize corners to adjust crop area"
},
"login": {
"welcome": "Welcome to",
"signInToContinue": "Sign in to continue",
@@ -393,6 +482,7 @@
"logoAlt": "App Logo",
"profileMenu": "Profile Menu",
"profile": "Profile",
"customization": "Customization",
"settings": "Settings",
"usersManagement": "User Management",
"logout": "Log Out"
@@ -400,11 +490,23 @@
"navigation": {
"dashboard": "Dashboard"
},
"imageEdit": {
"title": "Edit Image",
"rotate": "Rotate",
"zoom": "Zoom",
"cropInstructions": "Drag to reposition, resize corners to adjust crop area"
"notifications": {
"permissionGranted": "Download notifications enabled",
"permissionDenied": "Download notifications disabled",
"downloadComplete": {
"title": "Download Complete",
"body": "{fileName} has finished downloading"
},
"downloadFailed": {
"title": "Download Failed",
"body": "Failed to download {fileName}: {error}",
"unknownError": "Unknown error"
},
"queueProcessing": {
"title": "Download Starting",
"body": "{fileName} is now downloading{position}",
"position": " (was #{position} in queue)"
}
},
"profile": {
"password": {
@@ -448,6 +550,11 @@
},
"pageTitle": "Profile"
},
"qrCodeModal": {
"title": "Share QR Code",
"description": "Scan this QR code to access the link.",
"download": "Download QR Code"
},
"quickAccess": {
"files": {
"title": "My Files",
@@ -613,6 +720,7 @@
"expired": "Expired",
"expires": "Expires",
"viewDetails": "View details",
"viewQrCode": "View QR Code",
"copyLink": "Copy Link",
"openInNewTab": "Open in New Tab",
"editLink": "Edit Link",
@@ -635,7 +743,8 @@
"viewDetails": "View Details",
"edit": "Edit",
"delete": "Delete",
"viewFiles": "Received Files"
"viewFiles": "Received Files",
"viewQrCode": "View QR Code"
},
"empty": {
"title": "No receive links created",
@@ -736,6 +845,7 @@
"noFiles": "No files received yet",
"noFilesDescription": "Files sent through this link will appear here",
"fileCount": "{count, plural, =0 {No files} =1 {1 file} other {# files}}",
"invalidDate": "Invalid date",
"totalSize": "Total size: {size}",
"columns": {
"file": "File",
@@ -1119,6 +1229,10 @@
"serverUrl": {
"title": "Server URL",
"description": "Base URL of the Palmr server (e.g.: https://palmr.example.com)"
},
"passwordAuthEnabled": {
"title": "Password Authentication",
"description": "Enable or disable password-based authentication"
}
},
"buttons": {
@@ -1128,7 +1242,8 @@
},
"errors": {
"loadFailed": "Failed to load settings",
"updateFailed": "Failed to update settings"
"updateFailed": "Failed to update settings",
"passwordAuthRequiresProvider": "Cannot disable password authentication without having at least one active authentication provider"
},
"messages": {
"noChanges": "No changes to save",
@@ -1184,6 +1299,7 @@
"shareActions": {
"deleteTitle": "Delete Share",
"deleteConfirmation": "Are you sure you want to delete this share? This action cannot be undone.",
"addDescriptionPlaceholder": "Add description...",
"editTitle": "Edit Share",
"nameLabel": "Share Name",
"descriptionLabel": "Description",
@@ -1206,34 +1322,36 @@
},
"shareDetails": {
"title": "Share Details",
"subtitle": "Detailed information about this share",
"subtitle": "View and manage details for this share",
"basicInfo": "Basic Information",
"name": "Name",
"description": "Description",
"noDescription": "No description provided",
"untitled": "Untitled",
"shareLink": "Share Link",
"editLink": "Edit Link",
"generateLink": "Generate Link",
"noLink": "No link generated yet",
"copyLink": "Copy link",
"openLink": "Open in new tab",
"linkCopied": "Link copied to clipboard",
"views": "Views",
"dates": "Dates",
"security": "Security",
"files": "Files",
"recipients": "Recipients",
"views": "Views",
"created": "Created",
"expires": "Expires",
"never": "Never",
"security": "Security",
"editSecurity": "Edit Security",
"editExpiration": "Edit Expiration",
"untitled": "Untitled Share",
"noDescription": "No description",
"notAvailable": "N/A",
"invalidDate": "Invalid date",
"passwordProtected": "Password Protected",
"publicAccess": "Public Access",
"maxViews": "Max Views:",
"files": "Files",
"recipients": "Recipients",
"notAvailable": "N/A",
"invalidDate": "Invalid date",
"noLink": "No link generated",
"generateLink": "Generate Link",
"editLink": "Edit Link",
"copyLink": "Copy Link",
"openLink": "Open Link",
"editSecurity": "Edit Security",
"editExpiration": "Edit Expiration",
"qrCode": "QR Code",
"downloadQrCode": "Download QR Code",
"clickToEnlargeQrCode": "Click to enlarge QR Code",
"loadError": "Failed to load share details"
},
"shareExpiration": {
@@ -1419,6 +1537,7 @@
"generateLink": "Generate Link",
"editLink": "Edit Link",
"copyLink": "Copy Link",
"viewQrCode": "View QR Code",
"notifyRecipients": "Notify Recipients",
"downloadShareFiles": "Download All Files",
"delete": "Delete"
@@ -1613,6 +1732,7 @@
"selectFile": "Click to select a file",
"selectMultipleFiles": "Click to select one or multiple files",
"dragAndDrop": "or drag and drop files here",
"filesQueued": "{count, plural, one {# file queued for upload} other {# files queued for upload}}",
"preview": "Preview",
"uploadProgress": "Upload progress",
"upload": "Upload",
@@ -1688,8 +1808,8 @@
"status": {
"title": "Confirm Status Change",
"confirmation": "Are you sure you want to {action} user {firstName} {lastName}?",
"activate": "activate",
"deactivate": "deactivate",
"activate": "Activate",
"deactivate": "Deactivate",
"user": "User"
},
"header": {
@@ -1724,4 +1844,4 @@
"nameRequired": "Name is required",
"required": "This field is required"
}
}
}

View File

@@ -159,7 +159,48 @@
"passwordLabel": "Contraseña",
"create": "Crear Compartir",
"success": "Compartir creado exitosamente",
"error": "Error al crear compartir"
"error": "Error al crear compartir",
"namePlaceholder": "Ingrese un nombre para su compartir"
},
"customization": {
"breadcrumb": "Personalización",
"colors": {
"title": "Colores del Tema",
"description": "Elige tu color primario preferido para el tema",
"presets": "Colores Disponibles",
"presetsDescription": "Selecciona entre los temas de colores disponibles",
"reset": "Restablecer por Defecto"
},
"fonts": {
"title": "Tipografía",
"description": "Elige tu familia de fuentes preferida",
"available": "Fuentes Disponibles",
"availableDescription": "Selecciona entre las familias de fuentes disponibles",
"reset": "Restablecer por Defecto"
},
"radius": {
"title": "Radio del Borde",
"description": "Personaliza la redondez de los elementos de la interfaz",
"available": "Opciones de Redondez",
"availableDescription": "Elige cómo deben aparecer las esquinas redondeadas",
"reset": "Restablecer por Defecto"
},
"background": {
"title": "Colores de Fondo",
"description": "Personaliza los colores de fondo para los modos claro y oscuro",
"lightMode": "Modo Claro",
"darkMode": "Modo Oscuro",
"availableDescription": "Elige los colores de fondo para los temas claro y oscuro",
"reset": "Restablecer por Defecto"
},
"theme": {
"title": "Modo del Tema",
"description": "Elige entre tema claro, oscuro o del sistema",
"selectTheme": "Preferencia de Tema",
"availableDescription": "Selecciona tu modo de tema preferido",
"reset": "Restablecer al Sistema"
},
"pageTitle": "Personalización"
},
"dashboard": {
"loadError": "Error al cargar los datos del tablero",
@@ -175,6 +216,39 @@
"filesToDelete": "Archivos que serán eliminados",
"sharesToDelete": "Compartidos que serán eliminados"
},
"downloadQueue": {
"downloadQueued": "Descarga en cola: {fileName}",
"queuedDescription": "Tu descarga comenzará automáticamente cuando haya un espacio disponible",
"queuePosition": "Descarga en cola en posición {position}: {fileName}",
"estimatedWait": "Tiempo estimado de espera: {time}",
"queueFull": "Cola de descarga llena",
"queueFullDescription": "Por favor, inténtalo de nuevo en unos minutos cuando la cola tenga espacio",
"cancelSuccess": "Descarga cancelada exitosamente",
"cancelError": "Error al cancelar la descarga: {error}",
"status": {
"pending": "Preparando...",
"queued": "En cola",
"downloading": "Descargando",
"completed": "Completado",
"failed": "Fallido"
},
"waitTime": {
"seconds": "{seconds}s",
"minutes": "{minutes}m",
"hoursMinutes": "{hours}h {minutes}m"
},
"indicator": {
"title": "Descargas",
"downloads": "Cola de Descargas",
"active": "Activas",
"queued": "En Cola",
"position": "Posición {position}",
"estimatedWait": "Espera: {time}",
"unknownFile": "Archivo desconocido",
"noDownloads": "No hay descargas en progreso",
"refresh": "Actualizar Cola"
}
},
"emptyState": {
"noFiles": "Aún no se han subido archivos",
"uploadFile": "Subir archivo"
@@ -200,6 +274,7 @@
"extension": "Extensión",
"descriptionLabel": "Descripción",
"descriptionPlaceholder": "Introduce una descripción del archivo",
"addDescriptionPlaceholder": "Agregar descripción...",
"deleteFile": "Eliminar archivo",
"deleteConfirmation": "¿Estás seguro de que deseas eliminar ?",
"deleteWarning": "Esta acción no se puede deshacer."
@@ -266,6 +341,8 @@
"bulkDownloadFileError": "Error al descargar archivo {fileName}",
"bulkDeleteSuccess": "{count, plural, =1 {1 archivo eliminado exitosamente} other {# archivos eliminados exitosamente}}",
"bulkDeleteError": "Error al eliminar archivos seleccionados",
"bulkDeleteTitle": "Eliminar Archivos Seleccionados",
"bulkDeleteConfirmation": "¿Está seguro de que desea eliminar {count, plural, =1 {1 archivo} other {# archivos}}? Esta acción no se puede deshacer.",
"viewMode": {
"table": "Tabla",
"grid": "Cuadrícula"
@@ -313,7 +390,8 @@
"title": "Recuperar contraseña",
"description": "Introduce tu dirección de correo electrónico y te enviaremos instrucciones para restablecer tu contraseña.",
"resetInstructions": "Instrucciones de restablecimiento enviadas a tu correo electrónico",
"pageTitle": "Recuperar contraseña"
"pageTitle": "Recuperar contraseña",
"passwordAuthDisabled": "La autenticación por contraseña está deshabilitada. Por favor, contacta a tu administrador o usa un proveedor de autenticación externo."
},
"generateShareLink": {
"generateTitle": "Generar enlace de compartir",
@@ -327,7 +405,12 @@
"copyButton": "Copiar enlace",
"success": "Enlace generado exitosamente",
"error": "Error al generar enlace",
"copied": "Enlace copiado al portapapeles"
"copied": "Enlace copiado al portapapeles",
"readyDescription": "Tu enlace de compartir está listo. Puedes escanear el código QR directamente, descargarlo para usarlo más tarde, o copiar el enlace a continuación.",
"tabs": {
"link": "Enlace",
"qrcode": "Código QR"
}
},
"home": {
"description": "La alternativa de código abierto a WeTransfer. Comparte archivos de forma segura, sin rastreo ni limitaciones.",
@@ -355,6 +438,12 @@
"stats": "{iconCount} iconos de {libraryCount} bibliotecas",
"categoryBadge": "{category} ({count} iconos)"
},
"imageEdit": {
"title": "Editar Imagen",
"rotate": "Rotar",
"zoom": "Zoom",
"cropInstructions": "Arrastra para reubicar, ajusta las esquinas para ajustar el área de recorte"
},
"login": {
"welcome": "Bienvenido a",
"signInToContinue": "Inicia sesión para continuar",
@@ -395,11 +484,30 @@
"profile": "Perfil",
"settings": "Configuración",
"usersManagement": "Gestión de usuarios",
"logout": "Cerrar sesión"
"logout": "Cerrar sesión",
"customization": "Personalización"
},
"navigation": {
"dashboard": "Panel de control"
},
"notifications": {
"permissionGranted": "Notificaciones de descarga habilitadas",
"permissionDenied": "Notificaciones de descarga deshabilitadas",
"downloadComplete": {
"title": "Descarga Completada",
"body": "{fileName} ha terminado de descargarse"
},
"downloadFailed": {
"title": "Descarga Fallida",
"body": "Error al descargar {fileName}: {error}",
"unknownError": "Error desconocido"
},
"queueProcessing": {
"title": "Descarga Iniciando",
"body": "{fileName} está descargándose ahora{position}",
"position": " (estaba en posición #{position} en la cola)"
}
},
"profile": {
"password": {
"title": "Cambiar contraseña",
@@ -442,6 +550,11 @@
},
"pageTitle": "Perfil"
},
"qrCodeModal": {
"title": "Compartir Código QR",
"description": "Escanea este código QR para acceder al enlace.",
"download": "Descargar Código QR"
},
"quickAccess": {
"files": {
"title": "Mis archivos",
@@ -613,7 +726,8 @@
"createLink": "Crear Enlace",
"delete": "Eliminar",
"copyLinkTitle": "Copiar enlace",
"createLinkCTA": "Crear Enlace de Recepción"
"createLinkCTA": "Crear Enlace de Recepción",
"viewQrCode": "Ver Código QR"
},
"status": {
"active": "Activo",
@@ -629,7 +743,8 @@
"viewDetails": "Ver Detalles",
"edit": "Editar",
"delete": "Eliminar",
"viewFiles": "Archivos Recibidos"
"viewFiles": "Archivos Recibidos",
"viewQrCode": "Ver Código QR"
},
"empty": {
"title": "Ningún enlace de recepción creado",
@@ -730,6 +845,7 @@
"noFiles": "Ningún archivo recibido aún",
"noFilesDescription": "Los archivos enviados a través de este enlace aparecerán aquí",
"fileCount": "{count, plural, =0 {Ningún archivo} =1 {1 archivo} other {# archivos}}",
"invalidDate": "Fecha inválida",
"totalSize": "Tamaño total: {size}",
"columns": {
"file": "Archivo",
@@ -1115,6 +1231,10 @@
"tls": "STARTTLS (Puerto 587)",
"none": "Ninguno (Inseguro)"
}
},
"passwordAuthEnabled": {
"title": "Autenticación por Contraseña",
"description": "Habilitar o deshabilitar la autenticación basada en contraseña"
}
},
"buttons": {
@@ -1124,7 +1244,8 @@
},
"errors": {
"loadFailed": "Error al cargar la configuración",
"updateFailed": "Error al actualizar la configuración"
"updateFailed": "Error al actualizar la configuración",
"passwordAuthRequiresProvider": "No se puede deshabilitar la autenticación por contraseña sin tener al menos un proveedor de autenticación activo"
},
"messages": {
"noChanges": "No hay cambios para guardar",
@@ -1180,6 +1301,7 @@
"shareActions": {
"deleteTitle": "Eliminar Compartir",
"deleteConfirmation": "¿Estás seguro de que deseas eliminar esta compartición? Esta acción no se puede deshacer.",
"addDescriptionPlaceholder": "Agregar descripción...",
"editTitle": "Editar Compartir",
"nameLabel": "Nombre del Compartir",
"descriptionLabel": "Descripción",
@@ -1230,7 +1352,10 @@
"invalidDate": "Fecha inválida",
"loadError": "Error al cargar detalles del compartir",
"editSecurity": "Editar Seguridad",
"editExpiration": "Editar Expiración"
"editExpiration": "Editar Expiración",
"clickToEnlargeQrCode": "Haz clic para ampliar el Código QR",
"downloadQrCode": "Descargar Código QR",
"qrCode": "Código QR"
},
"shareExpiration": {
"neverExpires": "Nunca Expira",
@@ -1417,7 +1542,8 @@
"copyLink": "Copiar Enlace",
"notifyRecipients": "Notificar Destinatarios",
"delete": "Eliminar",
"downloadShareFiles": "Descargar todos los archivos"
"downloadShareFiles": "Descargar todos los archivos",
"viewQrCode": "Ver Código QR"
},
"bulkActions": {
"delete": "Eliminar",
@@ -1634,7 +1760,8 @@
"title": "Suelta archivos para subir",
"description": "Suelta para subir tus archivos"
},
"pasteSuccess": "{count, plural, =1 {Imagen pegada y subida exitosamente} other {# imágenes pegadas y subidas exitosamente}}"
"pasteSuccess": "{count, plural, =1 {Imagen pegada y subida exitosamente} other {# imágenes pegadas y subidas exitosamente}}",
"filesQueued": "{count, plural, one {# archivo en cola para subir} other {# archivos en cola para subir}}"
},
"users": {
"modes": {
@@ -1719,11 +1846,5 @@
"passwordRequired": "Se requiere la contraseña",
"nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio"
},
"imageEdit": {
"title": "Editar Imagen",
"rotate": "Rotar",
"zoom": "Zoom",
"cropInstructions": "Arrastra para reubicar, ajusta las esquinas para ajustar el área de recorte"
}
}
}

View File

@@ -159,7 +159,48 @@
"passwordLabel": "Mot de Passe",
"create": "Créer un Partage",
"success": "Partage créé avec succès",
"error": "Échec de la création du partage"
"error": "Échec de la création du partage",
"namePlaceholder": "Entrez un nom pour votre partage"
},
"customization": {
"breadcrumb": "Personnalisation",
"colors": {
"title": "Couleurs du Thème",
"description": "Choisissez votre thème de couleur principale préféré",
"presets": "Couleurs Disponibles",
"presetsDescription": "Sélectionnez parmi les thèmes de couleurs disponibles",
"reset": "Réinitialiser par Défaut"
},
"fonts": {
"title": "Typographie",
"description": "Choisissez votre famille de polices préférée",
"available": "Polices Disponibles",
"availableDescription": "Sélectionnez parmi les familles de polices disponibles",
"reset": "Réinitialiser par Défaut"
},
"radius": {
"title": "Rayon des Bordures",
"description": "Personnalisez l'arrondi des éléments de l'interface",
"available": "Options d'Arrondi",
"availableDescription": "Choisissez l'apparence des coins arrondis",
"reset": "Réinitialiser par Défaut"
},
"background": {
"title": "Couleurs d'Arrière-plan",
"description": "Personnalisez les couleurs d'arrière-plan pour les modes clair et sombre",
"lightMode": "Mode Clair",
"darkMode": "Mode Sombre",
"availableDescription": "Choisissez les couleurs d'arrière-plan pour les thèmes clair et sombre",
"reset": "Réinitialiser par Défaut"
},
"theme": {
"title": "Mode de Thème",
"description": "Choisissez entre le thème clair, sombre ou système",
"selectTheme": "Préférence de Thème",
"availableDescription": "Sélectionnez votre mode de thème préféré",
"reset": "Réinitialiser au Système"
},
"pageTitle": "Personnalisation"
},
"dashboard": {
"loadError": "Échec du chargement des données du tableau de bord",
@@ -175,6 +216,39 @@
"filesToDelete": "Fichiers à supprimer",
"sharesToDelete": "Partages qui seront supprimés"
},
"downloadQueue": {
"downloadQueued": "Téléchargement en file d'attente : {fileName}",
"queuedDescription": "Votre téléchargement démarrera automatiquement lorsqu'un emplacement sera disponible",
"queuePosition": "Téléchargement en position {position} : {fileName}",
"estimatedWait": "Temps d'attente estimé : {time}",
"queueFull": "La file d'attente est pleine",
"queueFullDescription": "Veuillez réessayer dans quelques minutes lorsque la file d'attente aura de l'espace",
"cancelSuccess": "Téléchargement annulé avec succès",
"cancelError": "Échec de l'annulation du téléchargement : {error}",
"status": {
"pending": "En préparation...",
"queued": "En file d'attente",
"downloading": "Téléchargement en cours",
"completed": "Terminé",
"failed": "Échoué"
},
"waitTime": {
"seconds": "{seconds}s",
"minutes": "{minutes}m",
"hoursMinutes": "{hours}h {minutes}m"
},
"indicator": {
"title": "Téléchargements",
"downloads": "File d'Attente des Téléchargements",
"active": "Actif",
"queued": "En Attente",
"position": "Position {position}",
"estimatedWait": "Attente : {time}",
"unknownFile": "Fichier inconnu",
"noDownloads": "Aucun téléchargement en cours",
"refresh": "Actualiser la File d'Attente"
}
},
"emptyState": {
"noFiles": "Aucun fichier téléchargé pour le moment",
"uploadFile": "Envoyer un Fichier"
@@ -200,6 +274,7 @@
"extension": "Extension",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Entrez la description du fichier",
"addDescriptionPlaceholder": "Ajouter une description...",
"deleteFile": "Supprimer le Fichier",
"deleteConfirmation": "Êtes-vous sûr de vouloir supprimer ?",
"deleteWarning": "Cette action ne peut pas être annulée."
@@ -266,6 +341,8 @@
"bulkDownloadFileError": "Erreur lors du téléchargement du fichier {fileName}",
"bulkDeleteSuccess": "{count, plural, =1 {1 fichier supprimé avec succès} other {# fichiers supprimés avec succès}}",
"bulkDeleteError": "Erreur lors de la suppression des fichiers sélectionnés",
"bulkDeleteTitle": "Supprimer les Fichiers Sélectionnés",
"bulkDeleteConfirmation": "Êtes-vous sûr de vouloir supprimer {count, plural, =1 {1 fichier} other {# fichiers}} ? Cette action ne peut pas être annulée.",
"viewMode": {
"table": "Tableau",
"grid": "Grille"
@@ -313,7 +390,8 @@
"title": "Mot de Passe Oublié",
"description": "Entrez votre adresse email et nous vous enverrons les instructions pour réinitialiser votre mot de passe.",
"resetInstructions": "Instructions de réinitialisation envoyées à votre email",
"pageTitle": "Mot de Passe Oublié"
"pageTitle": "Mot de Passe Oublié",
"passwordAuthDisabled": "L'authentification par mot de passe est désactivée. Veuillez contacter votre administrateur ou utiliser un fournisseur d'authentification externe."
},
"generateShareLink": {
"generateTitle": "Générer un lien de partage",
@@ -327,7 +405,12 @@
"copyButton": "Copier le lien",
"success": "Lien généré avec succès",
"error": "Échec de la génération du lien",
"copied": "Lien copié dans le presse-papiers"
"copied": "Lien copié dans le presse-papiers",
"readyDescription": "Votre lien de partage est prêt. Vous pouvez scanner le QR code directement, le télécharger pour une utilisation ultérieure, ou copier le lien ci-dessous.",
"tabs": {
"link": "Lien",
"qrcode": "QR Code"
}
},
"home": {
"description": "L'alternative open-source à WeTransfer. Partagez des fichiers en toute sécurité, sans suivi ni limitations.",
@@ -355,6 +438,12 @@
"stats": "{iconCount} icônes de {libraryCount} bibliothèques",
"categoryBadge": "{category} ({count} icônes)"
},
"imageEdit": {
"title": "Modifier l'Image",
"rotate": "Tourner",
"zoom": "Zoom",
"cropInstructions": "Glisser pour répositionner, redimensionner les coins pour ajuster la zone de découpe"
},
"login": {
"welcome": "Bienvenue à",
"signInToContinue": "Connectez-vous pour continuer",
@@ -395,11 +484,30 @@
"profile": "Profil",
"settings": "Paramètres",
"usersManagement": "Gestion des Utilisateurs",
"logout": "Déconnexion"
"logout": "Déconnexion",
"customization": "Personnalisation"
},
"navigation": {
"dashboard": "Tableau de bord"
},
"notifications": {
"permissionGranted": "Notifications de téléchargement activées",
"permissionDenied": "Notifications de téléchargement désactivées",
"downloadComplete": {
"title": "Téléchargement Terminé",
"body": "{fileName} a fini de télécharger"
},
"downloadFailed": {
"title": "Échec du Téléchargement",
"body": "Échec du téléchargement de {fileName} : {error}",
"unknownError": "Erreur inconnue"
},
"queueProcessing": {
"title": "Démarrage du Téléchargement",
"body": "{fileName} est en cours de téléchargement{position}",
"position": " (était n°{position} dans la file d'attente)"
}
},
"profile": {
"password": {
"title": "Changer le Mot de Passe",
@@ -442,6 +550,11 @@
},
"pageTitle": "Profil"
},
"qrCodeModal": {
"title": "Code QR de Partage",
"description": "Scannez ce code QR pour accéder au lien.",
"download": "Télécharger le Code QR"
},
"quickAccess": {
"files": {
"title": "Mes Fichiers",
@@ -613,7 +726,8 @@
"createLink": "Créer un Lien",
"delete": "Supprimer",
"copyLinkTitle": "Copier le lien",
"createLinkCTA": "Créer un Lien de Réception"
"createLinkCTA": "Créer un Lien de Réception",
"viewQrCode": "Voir le QR Code"
},
"status": {
"active": "Actif",
@@ -629,7 +743,8 @@
"viewDetails": "Voir les Détails",
"edit": "Modifier",
"delete": "Supprimer",
"viewFiles": "Fichiers Reçus"
"viewFiles": "Fichiers Reçus",
"viewQrCode": "Voir le QR Code"
},
"empty": {
"title": "Aucun lien de réception créé",
@@ -730,6 +845,7 @@
"noFiles": "Aucun fichier reçu pour le moment",
"noFilesDescription": "Les fichiers envoyés via ce lien apparaîtront ici",
"fileCount": "{count, plural, =0 {Aucun fichier} =1 {1 fichier} other {# fichiers}}",
"invalidDate": "Date invalide",
"totalSize": "Taille totale : {size}",
"columns": {
"file": "Fichier",
@@ -1118,6 +1234,10 @@
"smtpTrustSelfSigned": {
"title": "Faire Confiance aux Certificats Auto-signés",
"description": "Activez cette option pour faire confiance aux certificats SSL/TLS auto-signés (utile pour les environnements de développement)"
},
"passwordAuthEnabled": {
"title": "Authentification par Mot de Passe",
"description": "Activer ou désactiver l'authentification basée sur mot de passe"
}
},
"buttons": {
@@ -1127,7 +1247,8 @@
},
"errors": {
"loadFailed": "Échec du chargement des paramètres",
"updateFailed": "Échec de la mise à jour des paramètres"
"updateFailed": "Échec de la mise à jour des paramètres",
"passwordAuthRequiresProvider": "Impossible de désactiver l'authentification par mot de passe sans avoir au moins un fournisseur d'authentification actif"
},
"messages": {
"noChanges": "Aucun changement à enregistrer",
@@ -1180,6 +1301,7 @@
"shareActions": {
"deleteTitle": "Supprimer le Partage",
"deleteConfirmation": "Êtes-vous sûr de vouloir supprimer ce partage ? Cette action ne peut pas être annulée.",
"addDescriptionPlaceholder": "Ajouter une description...",
"editTitle": "Modifier le Partage",
"nameLabel": "Nom du Partage",
"descriptionLabel": "Description",
@@ -1230,7 +1352,10 @@
"invalidDate": "Date invalide",
"loadError": "Échec du chargement des détails du partage",
"editSecurity": "Modifier la Sécurité",
"editExpiration": "Modifier l'Expiration"
"editExpiration": "Modifier l'Expiration",
"clickToEnlargeQrCode": "Cliquez pour agrandir le Code QR",
"downloadQrCode": "Télécharger le Code QR",
"qrCode": "Code QR"
},
"shareExpiration": {
"neverExpires": "N'expire Jamais",
@@ -1417,7 +1542,8 @@
"copyLink": "Copier le Lien",
"notifyRecipients": "Notifier les Destinataires",
"delete": "Supprimer",
"downloadShareFiles": "Télécharger tous les fichiers"
"downloadShareFiles": "Télécharger tous les fichiers",
"viewQrCode": "Voir le QR Code"
},
"bulkActions": {
"delete": "Supprimer",
@@ -1634,7 +1760,8 @@
"title": "Déposer des fichiers pour télécharger",
"description": "Relâchez pour télécharger vos fichiers"
},
"pasteSuccess": "{count, plural, =1 {Image collée et téléchargée avec succès} other {# images collées et téléchargées avec succès}}"
"pasteSuccess": "{count, plural, =1 {Image collée et téléchargée avec succès} other {# images collées et téléchargées avec succès}}",
"filesQueued": "{count, plural, one {# fichier en attente de téléchargement} other {# fichiers en attente de téléchargement}}"
},
"users": {
"modes": {
@@ -1719,11 +1846,5 @@
"passwordRequired": "Le mot de passe est requis",
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
},
"imageEdit": {
"title": "Modifier l'Image",
"rotate": "Tourner",
"zoom": "Zoom",
"cropInstructions": "Glisser pour répositionner, redimensionner les coins pour ajuster la zone de découpe"
}
}
}

View File

@@ -159,7 +159,48 @@
"passwordLabel": "पासवर्ड",
"create": "साझाकरण बनाएं",
"success": "साझाकरण सफलतापूर्वक बनाया गया",
"error": "साझाकरण बनाने में विफल"
"error": "साझाकरण बनाने में विफल",
"namePlaceholder": "अपने साझाकरण के लिए एक नाम दर्ज करें"
},
"customization": {
"breadcrumb": "अनुकूलन",
"colors": {
"title": "थीम रंग",
"description": "अपना पसंदीदा प्राथमिक रंग थीम चुनें",
"presets": "उपलब्ध रंग",
"presetsDescription": "उपलब्ध रंग थीम में से चुनें",
"reset": "डिफ़ॉल्ट पर रीसेट करें"
},
"fonts": {
"title": "टाइपोग्राफी",
"description": "अपना पसंदीदा फ़ॉन्ट परिवार चुनें",
"available": "उपलब्ध फ़ॉन्ट",
"availableDescription": "उपलब्ध फ़ॉन्ट परिवारों में से चुनें",
"reset": "डिफ़ॉल्ट पर रीसेट करें"
},
"radius": {
"title": "बॉर्डर रेडियस",
"description": "इंटरफ़ेस तत्वों की गोलाई को अनुकूलित करें",
"available": "गोलाई विकल्प",
"availableDescription": "कोने कितने गोल दिखाई दें यह चुनें",
"reset": "डिफ़ॉल्ट पर रीसेट करें"
},
"background": {
"title": "पृष्ठभूमि रंग",
"description": "लाइट और डार्क मोड के लिए पृष्ठभूमि रंग अनुकूलित करें",
"lightMode": "लाइट मोड",
"darkMode": "डार्क मोड",
"availableDescription": "लाइट और डार्क थीम दोनों के लिए पृष्ठभूमि रंग चुनें",
"reset": "डिफ़ॉल्ट पर रीसेट करें"
},
"theme": {
"title": "थीम मोड",
"description": "लाइट, डार्क या सिस्टम थीम में से चुनें",
"selectTheme": "थीम प्राथमिकता",
"availableDescription": "अपना पसंदीदा थीम मोड चुनें",
"reset": "सिस्टम पर रीसेट करें"
},
"pageTitle": "अनुकूलन"
},
"dashboard": {
"loadError": "डैशबोर्ड डेटा लोड करने में त्रुटि",
@@ -175,6 +216,39 @@
"filesToDelete": "हटाई जाने वाली फाइलें",
"sharesToDelete": "साझाकरण जो हटाए जाएंगे"
},
"downloadQueue": {
"downloadQueued": "डाउनलोड कतार में: {fileName}",
"queuedDescription": "जब स्लॉट उपलब्ध होगा तब आपका डाउनलोड स्वचालित रूप से शुरू हो जाएगा",
"queuePosition": "स्थिति {position} पर डाउनलोड कतारबद्ध: {fileName}",
"estimatedWait": "अनुमानित प्रतीक्षा समय: {time}",
"queueFull": "डाउनलोड कतार भरी हुई है",
"queueFullDescription": "कृपया कुछ मिनटों में फिर से प्रयास करें जब कतार में जगह हो",
"cancelSuccess": "डाउनलोड सफलतापूर्वक रद्द किया गया",
"cancelError": "डाउनलोड रद्द करने में विफल: {error}",
"status": {
"pending": "तैयारी हो रही है...",
"queued": "कतार में",
"downloading": "डाउनलोड हो रहा है",
"completed": "पूर्ण",
"failed": "विफल"
},
"waitTime": {
"seconds": "{seconds}से",
"minutes": "{minutes}मि",
"hoursMinutes": "{hours}घं {minutes}मि"
},
"indicator": {
"title": "डाउनलोड",
"downloads": "डाउनलोड कतार",
"active": "सक्रिय",
"queued": "कतारबद्ध",
"position": "स्थिति {position}",
"estimatedWait": "प्रतीक्षा: {time}",
"unknownFile": "अज्ञात फ़ाइल",
"noDownloads": "कोई डाउनलोड प्रगति में नहीं है",
"refresh": "कतार रीफ्रेश करें"
}
},
"emptyState": {
"noFiles": "अभी तक कोई फाइल अपलोड नहीं हुई",
"uploadFile": "फाइल अपलोड करें"
@@ -202,7 +276,8 @@
"descriptionPlaceholder": "फाइल का विवरण दर्ज करें",
"deleteFile": "फाइल हटाएं",
"deleteConfirmation": "क्या आप वाकई हटाना चाहते हैं?",
"deleteWarning": "यह क्रिया अपरिवर्तनीय है।"
"deleteWarning": "यह क्रिया अपरिवर्तनीय है।",
"addDescriptionPlaceholder": "विवरण जोड़ें..."
},
"fileManager": {
"downloadError": "फाइल डाउनलोड करने में त्रुटि",
@@ -270,7 +345,9 @@
"table": "तालिका",
"grid": "ग्रिड"
},
"totalFiles": "{count, plural, =0 {कोई फ़ाइल नहीं} =1 {1 फ़ाइल} other {# फ़ाइलें}}"
"totalFiles": "{count, plural, =0 {कोई फ़ाइल नहीं} =1 {1 फ़ाइल} other {# फ़ाइलें}}",
"bulkDeleteConfirmation": "क्या आप वास्तव में {count, plural, =1 {1 फाइल} other {# फाइलों}} को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
"bulkDeleteTitle": "चयनित फाइलों को हटाएं"
},
"filesTable": {
"ariaLabel": "फाइल तालिका",
@@ -313,7 +390,8 @@
"title": "पासवर्ड भूल गए",
"description": "अपना ईमेल पता दर्ज करें और हम आपको पासवर्ड रीसेट करने के निर्देश भेजेंगे।",
"resetInstructions": "रीसेट निर्देश आपके ईमेल पर भेज दिए गए हैं",
"pageTitle": "पासवर्ड भूल गए"
"pageTitle": "पासवर्ड भूल गए",
"passwordAuthDisabled": "पासवर्ड ऑथेंटिकेशन अक्टिवेटेड है। कृपया अपने एडमिन से संपर्क करें या एक बाहरी ऑथेंटिकेशन प्रोवाइडर का उपयोग करें।"
},
"generateShareLink": {
"generateTitle": "साझाकरण लिंक उत्पन्न करें",
@@ -327,7 +405,12 @@
"copyButton": "लिंक कॉपी करें",
"success": "लिंक सफलतापूर्वक उत्पन्न हुआ",
"error": "लिंक उत्पन्न करने में विफल",
"copied": "लिंक क्लिपबोर्ड में कॉपी किया गया"
"copied": "लिंक क्लिपबोर्ड में कॉपी किया गया",
"readyDescription": "आपका साझाकरण लिंक तैयार है। आप डायरेक्ट रूप से QR कोड स्कैन कर सकते हैं, इसे बाद में उपयोग के लिए डाउनलोड कर सकते हैं, या नीचे लिंक कॉपी कर सकते हैं।",
"tabs": {
"link": "लिंक",
"qrcode": "QR कोड"
}
},
"home": {
"description": "WeTransfer का ओपन-सोर्स विकल्प। फाइलें सुरक्षित रूप से साझा करें, बिना ट्रैकिंग या सीमाओं के।",
@@ -355,6 +438,12 @@
"stats": "{libraryCount} लाइब्रेरी से {iconCount} आइकन",
"categoryBadge": "{category} ({count} आइकन)"
},
"imageEdit": {
"title": "छवि संपादित करें",
"rotate": "घुमाएं",
"zoom": "ज़ूम",
"cropInstructions": "छवि को पुनः स्थानांतरित करने के लिए खींचें, कोणों को समायोजित करने के लिए आकार बदलें"
},
"login": {
"welcome": "स्वागत है में",
"signInToContinue": "जारी रखने के लिए साइन इन करें",
@@ -395,11 +484,30 @@
"profile": "प्रोफ़ाइल",
"settings": "सेटिंग्स",
"usersManagement": "उपयोगकर्ता प्रबंधन",
"logout": "लॉग आउट"
"logout": "लॉग आउट",
"customization": "अनुकूलन"
},
"navigation": {
"dashboard": "डैशबोर्ड"
},
"notifications": {
"permissionGranted": "डाउनलोड सूचनाएं सक्षम की गईं",
"permissionDenied": "डाउनलोड सूचनाएं अक्षम की गईं",
"downloadComplete": {
"title": "डाउनलोड पूर्ण",
"body": "{fileName} का डाउनलोड समाप्त हो गया है"
},
"downloadFailed": {
"title": "डाउनलोड विफल",
"body": "{fileName} डाउनलोड करने में विफल: {error}",
"unknownError": "अज्ञात त्रुटि"
},
"queueProcessing": {
"title": "डाउनलोड प्रारंभ",
"body": "{fileName} अब डाउनलोड हो रहा है{position}",
"position": " (कतार में #{position} था)"
}
},
"profile": {
"password": {
"title": "पासवर्ड बदलें",
@@ -442,6 +550,11 @@
},
"pageTitle": "प्रोफ़ाइल"
},
"qrCodeModal": {
"title": "QR कोड साझा करें",
"description": "इस QR कोड को स्कैन करके लिंक तक पहुंच सकते हैं।",
"download": "QR कोड डाउनलोड करें"
},
"quickAccess": {
"files": {
"title": "मेरी फाइलें",
@@ -613,7 +726,8 @@
"createLink": "लिंक बनाएं",
"delete": "हटाएं",
"copyLinkTitle": "लिंक कॉपी करें",
"createLinkCTA": "प्राप्ति लिंक बनाएं"
"createLinkCTA": "प्राप्ति लिंक बनाएं",
"viewQrCode": "QR कोड देखें"
},
"status": {
"active": "सक्रिय",
@@ -629,7 +743,8 @@
"viewDetails": "विवरण देखें",
"edit": "संपादित करें",
"delete": "हटाएं",
"viewFiles": "प्राप्त फ़ाइलें"
"viewFiles": "प्राप्त फ़ाइलें",
"viewQrCode": "QR कोड देखें"
},
"empty": {
"title": "कोई प्राप्ति लिंक नहीं बनाया गया",
@@ -775,7 +890,8 @@
"timeout": "कॉपी ऑपरेशन का समय समाप्त हो गया। कृपया छोटी फ़ाइल के साथ पुनः प्रयास करें या अपना कनेक्शन जांचें।",
"failed": "कॉपी ऑपरेशन विफल हो गया। कृपया पुनः प्रयास करें।",
"aborted": "टाइमआउट के कारण कॉपी ऑपरेशन रद्द कर दिया गया।"
}
},
"invalidDate": "अमान्य दिनांक"
}
},
"form": {
@@ -1115,6 +1231,10 @@
"smtpTrustSelfSigned": {
"title": "स्व-हस्ताक्षरित प्रमाणपत्रों पर विश्वास करें",
"description": "स्व-हस्ताक्षरित SSL/TLS प्रमाणपत्रों पर विश्वास करने के लिए इसे सक्षम करें (विकास वातावरण के लिए उपयोगी)"
},
"passwordAuthEnabled": {
"title": "पासवर्ड प्रमाणीकरण",
"description": "पासवर्ड आधारित प्रमाणीकरण सक्षम या अक्षम करें"
}
},
"buttons": {
@@ -1124,7 +1244,8 @@
},
"errors": {
"loadFailed": "सेटिंग्स लोड करने में विफल",
"updateFailed": "सेटिंग्स अपडेट करने में विफल"
"updateFailed": "सेटिंग्स अपडेट करने में विफल",
"passwordAuthRequiresProvider": "कम से कम एक सक्रिय प्रमाणीकरण प्रदाता के बिना पासवर्ड प्रमाणीकरण अक्षम नहीं किया जा सकता"
},
"messages": {
"noChanges": "सहेजने के लिए कोई परिवर्तन नहीं",
@@ -1198,7 +1319,8 @@
"editSuccess": "साझाकरण सफलतापूर्वक अपडेट किया गया",
"editError": "साझाकरण अपडेट करने में विफल",
"bulkDeleteConfirmation": "क्या आप वाकई {count, plural, =1 {1 साझाकरण} other {# साझाकरण}} हटाना चाहते हैं? इस क्रिया को पूर्ववत नहीं किया जा सकता।",
"bulkDeleteTitle": "चयनित साझाकरण हटाएं"
"bulkDeleteTitle": "चयनित साझाकरण हटाएं",
"addDescriptionPlaceholder": "विवरण जोड़ें..."
},
"shareDetails": {
"title": "साझाकरण विवरण",
@@ -1230,7 +1352,10 @@
"invalidDate": "अमान्य तिथि",
"loadError": "साझाकरण विवरण लोड करने में विफल",
"editSecurity": "सुरक्षा संपादित करें",
"editExpiration": "समाप्ति संपादित करें"
"editExpiration": "समाप्ति संपादित करें",
"clickToEnlargeQrCode": "QR कोड को बड़ा करने के लिए क्लिक करें",
"downloadQrCode": "QR कोड डाउनलोड करें",
"qrCode": "QR कोड"
},
"shareExpiration": {
"neverExpires": "कभी समाप्त नहीं होता",
@@ -1417,7 +1542,8 @@
"copyLink": "लिंक कॉपी करें",
"notifyRecipients": "प्राप्तकर्ताओं को सूचित करें",
"delete": "हटाएं",
"downloadShareFiles": "सभी फ़ाइलें डाउनलोड करें"
"downloadShareFiles": "सभी फ़ाइलें डाउनलोड करें",
"viewQrCode": "QR कोड देखें"
},
"bulkActions": {
"delete": "हटाएं",
@@ -1634,7 +1760,8 @@
"title": "अपलोड करने के लिए फ़ाइलें छोड़ें",
"description": "अपनी फ़ाइलें अपलोड करने के लिए छोड़ें"
},
"pasteSuccess": "{count, plural, =1 {छवि सफलतापूर्वक चिपकाई और अपलोड की गई} other {# छवियाँ सफलतापूर्वक चिपकाई और अपलोड की गईं}}"
"pasteSuccess": "{count, plural, =1 {छवि सफलतापूर्वक चिपकाई और अपलोड की गई} other {# छवियाँ सफलतापूर्वक चिपकाई और अपलोड की गईं}}",
"filesQueued": "{count, plural, one {# फ़ाइल अपलोड के लिए इंतजार में है} other {# फ़ाइलें अपलोड के लिए इंतजार में हैं}}"
},
"users": {
"modes": {
@@ -1719,11 +1846,5 @@
"passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
},
"imageEdit": {
"title": "छवि संपादित करें",
"rotate": "घुमाएं",
"zoom": "ज़ूम",
"cropInstructions": "छवि को पुनः स्थानांतरित करने के लिए खींचें, कोणों को समायोजित करने के लिए आकार बदलें"
}
}
}

View File

@@ -159,7 +159,48 @@
"passwordLabel": "Password",
"create": "Crea Condivisione",
"success": "Condivisione creata con successo",
"error": "Errore nella creazione della condivisione"
"error": "Errore nella creazione della condivisione",
"namePlaceholder": "Inserisci un nome per la tua condivisione"
},
"customization": {
"breadcrumb": "Personalizzazione",
"colors": {
"title": "Colori del Tema",
"description": "Scegli il tuo colore primario preferito per il tema",
"presets": "Colori Disponibili",
"presetsDescription": "Seleziona tra i temi colore disponibili",
"reset": "Ripristina Predefinito"
},
"fonts": {
"title": "Tipografia",
"description": "Scegli la tua famiglia di caratteri preferita",
"available": "Caratteri Disponibili",
"availableDescription": "Seleziona tra le famiglie di caratteri disponibili",
"reset": "Ripristina Predefinito"
},
"radius": {
"title": "Raggio dei Bordi",
"description": "Personalizza la rotondità degli elementi dell'interfaccia",
"available": "Opzioni di Rotondità",
"availableDescription": "Scegli come dovrebbero apparire gli angoli arrotondati",
"reset": "Ripristina Predefinito"
},
"background": {
"title": "Colori di Sfondo",
"description": "Personalizza i colori di sfondo per le modalità chiara e scura",
"lightMode": "Modalità Chiara",
"darkMode": "Modalità Scura",
"availableDescription": "Scegli i colori di sfondo per entrambi i temi chiari e scuri",
"reset": "Ripristina Predefinito"
},
"theme": {
"title": "Modalità Tema",
"description": "Scegli tra tema chiaro, scuro o di sistema",
"selectTheme": "Preferenza Tema",
"availableDescription": "Seleziona la tua modalità tema preferita",
"reset": "Ripristina Sistema"
},
"pageTitle": "Personalizzazione"
},
"dashboard": {
"loadError": "Errore durante il caricamento dei dati del pannello di controllo",
@@ -175,6 +216,39 @@
"filesToDelete": "File da eliminare",
"sharesToDelete": "Condivisioni che saranno eliminate"
},
"downloadQueue": {
"downloadQueued": "Download in coda: {fileName}",
"queuedDescription": "Il tuo download inizierà automaticamente quando si libera uno slot",
"queuePosition": "Download in coda alla posizione {position}: {fileName}",
"estimatedWait": "Tempo di attesa stimato: {time}",
"queueFull": "Coda di download piena",
"queueFullDescription": "Riprova tra qualche minuto quando la coda avrà spazio",
"cancelSuccess": "Download annullato con successo",
"cancelError": "Impossibile annullare il download: {error}",
"status": {
"pending": "Preparazione...",
"queued": "In coda",
"downloading": "Download in corso",
"completed": "Completato",
"failed": "Fallito"
},
"waitTime": {
"seconds": "{seconds}s",
"minutes": "{minutes}m",
"hoursMinutes": "{hours}h {minutes}m"
},
"indicator": {
"title": "Download",
"downloads": "Coda Download",
"active": "Attivi",
"queued": "In Coda",
"position": "Posizione {position}",
"estimatedWait": "Attesa: {time}",
"unknownFile": "File sconosciuto",
"noDownloads": "Nessun download in corso",
"refresh": "Aggiorna Coda"
}
},
"emptyState": {
"noFiles": "Nessun file caricato ancora",
"uploadFile": "Carica File"
@@ -200,6 +274,7 @@
"extension": "Estensione",
"descriptionLabel": "Descrizione",
"descriptionPlaceholder": "Inserisci descrizione del file",
"addDescriptionPlaceholder": "Aggiungi descrizione...",
"deleteFile": "Elimina File",
"deleteConfirmation": "Sei sicuro di voler eliminare ?",
"deleteWarning": "Questa azione non può essere annullata."
@@ -266,6 +341,8 @@
"bulkDownloadFileError": "Errore nel download del file {fileName}",
"bulkDeleteSuccess": "{count, plural, =1 {1 file eliminato con successo} other {# file eliminati con successo}}",
"bulkDeleteError": "Errore nell'eliminazione dei file selezionati",
"bulkDeleteTitle": "Elimina File Selezionati",
"bulkDeleteConfirmation": "Sei sicuro di voler eliminare {count, plural, =1 {1 file} other {# file}}? Questa azione non può essere annullata.",
"viewMode": {
"table": "Tabella",
"grid": "Griglia"
@@ -313,7 +390,8 @@
"title": "Parola d'accesso Dimenticata",
"description": "Inserisci il tuo indirizzo email e ti invieremo le istruzioni per reimpostare la parola d'accesso.",
"resetInstructions": "Istruzioni di reimpostazione inviate alla tua email",
"pageTitle": "Parola d'accesso Dimenticata"
"pageTitle": "Parola d'accesso Dimenticata",
"passwordAuthDisabled": "L'autenticazione tramite password è disabilitata. Contatta il tuo amministratore o utilizza un provider di autenticazione esterno."
},
"generateShareLink": {
"generateTitle": "Genera link di condivisione",
@@ -327,7 +405,12 @@
"copyButton": "Copia link",
"success": "Link generato con successo",
"error": "Errore nella generazione del link",
"copied": "Link copiato negli appunti"
"copied": "Link copiato negli appunti",
"readyDescription": "Il tuo link di condivisione è pronto. Puoi scansionare il codice QR direttamente, scaricarlo per un uso successivo, o copiare il link qui sotto.",
"tabs": {
"link": "Link",
"qrcode": "QR Code"
}
},
"home": {
"description": "L'alternativa open-source a WeTransfer. Condividi file in sicurezza, senza tracciamento o limitazioni.",
@@ -355,6 +438,12 @@
"stats": "{iconCount} icone da {libraryCount} librerie",
"categoryBadge": "{category} ({count} icone)"
},
"imageEdit": {
"title": "Modifica Immagine",
"rotate": "Ruota",
"zoom": "Zoom",
"cropInstructions": "Trascina per riposizionare, ridimensiona gli angoli per adattare l'area di ritaglio"
},
"login": {
"welcome": "Benvenuto in",
"signInToContinue": "Accedi per continuare",
@@ -395,11 +484,30 @@
"profile": "Profilo",
"settings": "Impostazioni",
"usersManagement": "Gestione Utenti",
"logout": "Logout"
"logout": "Logout",
"customization": "Personalizzazione"
},
"navigation": {
"dashboard": "Pannello di controllo"
},
"notifications": {
"permissionGranted": "Notifiche download abilitate",
"permissionDenied": "Notifiche download disabilitate",
"downloadComplete": {
"title": "Download Completato",
"body": "Il download di {fileName} è terminato"
},
"downloadFailed": {
"title": "Download Fallito",
"body": "Impossibile scaricare {fileName}: {error}",
"unknownError": "Errore sconosciuto"
},
"queueProcessing": {
"title": "Download in Avvio",
"body": "{fileName} sta ora scaricando{position}",
"position": " (era #{position} in coda)"
}
},
"profile": {
"password": {
"title": "Cambia Parola d'accesso",
@@ -442,6 +550,11 @@
},
"pageTitle": "Profilo"
},
"qrCodeModal": {
"title": "Condividi QR Code",
"description": "Scansiona questo codice QR per accedere al link.",
"download": "Scarica QR Code"
},
"quickAccess": {
"files": {
"title": "I Miei File",
@@ -613,7 +726,8 @@
"createLink": "Crea Link",
"delete": "Elimina",
"copyLinkTitle": "Copia link",
"createLinkCTA": "Crea Link di Ricezione"
"createLinkCTA": "Crea Link di Ricezione",
"viewQrCode": "Visualizza QR Code"
},
"status": {
"active": "Attivo",
@@ -629,7 +743,8 @@
"viewDetails": "Vedi Dettagli",
"edit": "Modifica",
"delete": "Elimina",
"viewFiles": "File Ricevuti"
"viewFiles": "File Ricevuti",
"viewQrCode": "Visualizza QR Code"
},
"empty": {
"title": "Nessun link di ricezione creato",
@@ -730,6 +845,7 @@
"noFiles": "Nessun file ricevuto ancora",
"noFilesDescription": "I file inviati attraverso questo link appariranno qui",
"fileCount": "{count, plural, =0 {Nessun file} =1 {1 file} other {# file}}",
"invalidDate": "Data non valida",
"totalSize": "Dimensione totale: {size}",
"columns": {
"file": "File",
@@ -1115,6 +1231,10 @@
"smtpTrustSelfSigned": {
"title": "Accetta Certificati Auto-Firmati",
"description": "Abilita questa opzione per accettare certificati SSL/TLS auto-firmati (utile per ambienti di sviluppo)"
},
"passwordAuthEnabled": {
"title": "Autenticazione Password",
"description": "Abilita o disabilita l'autenticazione basata su password"
}
},
"buttons": {
@@ -1124,7 +1244,8 @@
},
"errors": {
"loadFailed": "Errore durante il caricamento delle impostazioni",
"updateFailed": "Errore durante l'aggiornamento delle impostazioni"
"updateFailed": "Errore durante l'aggiornamento delle impostazioni",
"passwordAuthRequiresProvider": "Impossibile disabilitare l'autenticazione password senza avere almeno un provider di autenticazione attivo"
},
"messages": {
"noChanges": "Nessuna modifica da salvare",
@@ -1180,6 +1301,7 @@
"shareActions": {
"deleteTitle": "Elimina Condivisione",
"deleteConfirmation": "Sei sicuro di voler eliminare questa condivisione? Questa azione non può essere annullata.",
"addDescriptionPlaceholder": "Aggiungi descrizione...",
"editTitle": "Modifica Condivisione",
"nameLabel": "Nome Condivisione",
"descriptionLabel": "Descrizione",
@@ -1230,7 +1352,10 @@
"invalidDate": "Data non valida",
"loadError": "Errore nel caricamento dei dettagli della condivisione",
"editSecurity": "Modifica Sicurezza",
"editExpiration": "Modifica Scadenza"
"editExpiration": "Modifica Scadenza",
"clickToEnlargeQrCode": "Clicca per ingrandire il QR Code",
"downloadQrCode": "Scarica QR Code",
"qrCode": "QR Code"
},
"shareExpiration": {
"neverExpires": "Non Scade Mai",
@@ -1417,7 +1542,8 @@
"copyLink": "Copia Link",
"notifyRecipients": "Notifica Destinatari",
"delete": "Elimina",
"downloadShareFiles": "Scarica tutti i file"
"downloadShareFiles": "Scarica tutti i file",
"viewQrCode": "Visualizza QR Code"
},
"bulkActions": {
"delete": "Elimina",
@@ -1634,7 +1760,8 @@
"title": "Rilascia i file per caricarli",
"description": "Rilascia per caricare i tuoi file"
},
"pasteSuccess": "{count, plural, =1 {Immagine incollata e caricata con successo} other {# immagini incollate e caricate con successo}}"
"pasteSuccess": "{count, plural, =1 {Immagine incollata e caricata con successo} other {# immagini incollate e caricate con successo}}",
"filesQueued": "{count, plural, one {# file in coda per il caricamento} other {# files in coda per il caricamento}}"
},
"users": {
"modes": {
@@ -1719,11 +1846,5 @@
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
"nameRequired": "Il nome è obbligatorio",
"required": "Questo campo è obbligatorio"
},
"imageEdit": {
"title": "Modifica Immagine",
"rotate": "Ruota",
"zoom": "Zoom",
"cropInstructions": "Trascina per riposizionare, ridimensiona gli angoli per adattare l'area di ritaglio"
}
}
}

View File

@@ -159,7 +159,48 @@
"passwordLabel": "パスワード",
"create": "共有を作成",
"success": "共有が正常に作成されました",
"error": "共有の作成に失敗しました"
"error": "共有の作成に失敗しました",
"namePlaceholder": "共有の名前を入力してください"
},
"customization": {
"breadcrumb": "カスタマイズ",
"colors": {
"title": "テーマカラー",
"description": "お好みのプライマリカラーテーマを選択してください",
"presets": "利用可能な色",
"presetsDescription": "利用可能なカラーテーマから選択",
"reset": "デフォルトにリセット"
},
"fonts": {
"title": "タイポグラフィ",
"description": "お好みのフォントファミリーを選択してください",
"available": "利用可能なフォント",
"availableDescription": "利用可能なフォントファミリーから選択",
"reset": "デフォルトにリセット"
},
"radius": {
"title": "角丸",
"description": "インターフェース要素の丸みをカスタマイズ",
"available": "丸みのオプション",
"availableDescription": "角の丸みの表示方法を選択",
"reset": "デフォルトにリセット"
},
"background": {
"title": "背景色",
"description": "ライトモードとダークモードの背景色をカスタマイズ",
"lightMode": "ライトモード",
"darkMode": "ダークモード",
"availableDescription": "ライトテーマとダークテーマの背景色を選択",
"reset": "デフォルトにリセット"
},
"theme": {
"title": "テーマモード",
"description": "ライト、ダーク、またはシステムテーマから選択",
"selectTheme": "テーマ設定",
"availableDescription": "お好みのテーマモードを選択",
"reset": "システム設定に戻す"
},
"pageTitle": "カスタマイズ"
},
"dashboard": {
"loadError": "ダッシュボードデータの読み込みに失敗しました",
@@ -175,6 +216,39 @@
"filesToDelete": "削除するファイル",
"sharesToDelete": "削除される共有"
},
"downloadQueue": {
"downloadQueued": "ダウンロードキューに追加: {fileName}",
"queuedDescription": "空きスロットができ次第、自動的にダウンロードが開始されます",
"queuePosition": "順番{position}でキューに追加: {fileName}",
"estimatedWait": "推定待ち時間: {time}",
"queueFull": "ダウンロードキューが満杯です",
"queueFullDescription": "キューに空きができるまで数分お待ちください",
"cancelSuccess": "ダウンロードをキャンセルしました",
"cancelError": "ダウンロードのキャンセルに失敗: {error}",
"status": {
"pending": "準備中...",
"queued": "キュー待ち",
"downloading": "ダウンロード中",
"completed": "完了",
"failed": "失敗"
},
"waitTime": {
"seconds": "{seconds}秒",
"minutes": "{minutes}分",
"hoursMinutes": "{hours}時間{minutes}分"
},
"indicator": {
"title": "ダウンロード",
"downloads": "ダウンロードキュー",
"active": "実行中",
"queued": "待機中",
"position": "順番 {position}",
"estimatedWait": "待ち時間: {time}",
"unknownFile": "不明なファイル",
"noDownloads": "進行中のダウンロードはありません",
"refresh": "キューを更新"
}
},
"emptyState": {
"noFiles": "まだファイルがアップロードされていません",
"uploadFile": "ファイルをアップロード"
@@ -202,7 +276,8 @@
"descriptionPlaceholder": "ファイルの説明を入力してください",
"deleteFile": "ファイルを削除",
"deleteConfirmation": "を削除してもよろしいですか?",
"deleteWarning": "この操作は元に戻せません。"
"deleteWarning": "この操作は元に戻せません。",
"addDescriptionPlaceholder": "説明を追加..."
},
"fileManager": {
"downloadError": "ファイルのダウンロードに失敗しました",
@@ -270,7 +345,9 @@
"table": "テーブル",
"grid": "グリッド"
},
"totalFiles": "{count, plural, =0 {ファイルなし} =1 {1ファイル} other {#ファイル}}"
"totalFiles": "{count, plural, =0 {ファイルなし} =1 {1ファイル} other {#ファイル}}",
"bulkDeleteConfirmation": "{count, plural, =1 {1つのファイル} other {#つのファイル}}を削除してよろしいですか?この操作は元に戻せません。",
"bulkDeleteTitle": "選択したファイルを削除"
},
"filesTable": {
"ariaLabel": "ファイルテーブル",
@@ -313,7 +390,8 @@
"title": "パスワードをお忘れですか?",
"description": "メールアドレスを入力すると、パスワードリセットの指示を送信します。",
"resetInstructions": "パスワードリセットの指示がメールに送信されました",
"pageTitle": "パスワードをお忘れですか?"
"pageTitle": "パスワードをお忘れですか?",
"passwordAuthDisabled": "パスワード認証が無効になっています。管理者に連絡するか、外部認証プロバイダーを使用してください。"
},
"generateShareLink": {
"generateTitle": "共有リンクを生成",
@@ -327,7 +405,12 @@
"copyButton": "リンクをコピー",
"success": "リンクが正常に生成されました",
"error": "リンクの生成に失敗しました",
"copied": "リンクがクリップボードにコピーされました"
"copied": "リンクがクリップボードにコピーされました",
"readyDescription": "共有リンクが準備できました。QRコードを直接スキャンして、後で使用するためにダウンロードするか、リンクをコピーしてください。",
"tabs": {
"link": "リンク",
"qrcode": "QRコード"
}
},
"home": {
"description": "WeTransferのオープンソース代替です。トラッキングや制限なしに安全にファイルを共有します。",
@@ -355,6 +438,12 @@
"stats": "{libraryCount}ライブラリから{iconCount}個のアイコン",
"categoryBadge": "{category}{count}個のアイコン)"
},
"imageEdit": {
"title": "画像を編集",
"rotate": "回転",
"zoom": "ズーム",
"cropInstructions": "位置を変更するにはドラッグし、カット領域を調整するには角をリサイズしてください"
},
"login": {
"welcome": "ようこそへ",
"signInToContinue": "続行するにはサインインしてください",
@@ -395,11 +484,30 @@
"profile": "プロフィール",
"settings": "設定",
"usersManagement": "ユーザー管理",
"logout": "ログアウト"
"logout": "ログアウト",
"customization": "カスタマイズ"
},
"navigation": {
"dashboard": "ダッシュボード"
},
"notifications": {
"permissionGranted": "ダウンロード通知が有効になりました",
"permissionDenied": "ダウンロード通知が無効になりました",
"downloadComplete": {
"title": "ダウンロード完了",
"body": "{fileName}のダウンロードが完了しました"
},
"downloadFailed": {
"title": "ダウンロード失敗",
"body": "{fileName}のダウンロードに失敗: {error}",
"unknownError": "不明なエラー"
},
"queueProcessing": {
"title": "ダウンロード開始",
"body": "{fileName}のダウンロードを開始しています{position}",
"position": "(キュー内{position}番目)"
}
},
"profile": {
"password": {
"title": "パスワードを変更",
@@ -442,6 +550,11 @@
},
"pageTitle": "プロフィール"
},
"qrCodeModal": {
"title": "QRコードを共有",
"description": "このQRコードをスキャンしてリンクにアクセスしてください。",
"download": "QRコードをダウンロード"
},
"quickAccess": {
"files": {
"title": "マイファイル",
@@ -613,7 +726,8 @@
"createLink": "リンクを作成",
"delete": "削除",
"copyLinkTitle": "リンクをコピー",
"createLinkCTA": "受信リンクを作成"
"createLinkCTA": "受信リンクを作成",
"viewQrCode": "QRコードを表示"
},
"status": {
"active": "有効",
@@ -629,7 +743,8 @@
"viewDetails": "詳細を表示",
"edit": "編集",
"delete": "削除",
"viewFiles": "受信済みファイル"
"viewFiles": "受信済みファイル",
"viewQrCode": "QRコードを表示"
},
"empty": {
"title": "受信リンクが作成されていません",
@@ -775,7 +890,8 @@
"timeout": "コピー操作がタイムアウトしました。より小さいファイルで再試行するか、接続を確認してください。",
"failed": "コピー操作に失敗しました。もう一度お試しください。",
"aborted": "タイムアウトによりコピー操作がキャンセルされました。"
}
},
"invalidDate": "無効な日付"
}
},
"form": {
@@ -1115,6 +1231,10 @@
"smtpTrustSelfSigned": {
"title": "自己署名証明書を信頼",
"description": "自己署名SSL/TLS証明書を信頼するように設定します開発環境で便利"
},
"passwordAuthEnabled": {
"title": "パスワード認証",
"description": "パスワード認証を有効または無効にする"
}
},
"buttons": {
@@ -1124,7 +1244,8 @@
},
"errors": {
"loadFailed": "設定の読み込みに失敗しました",
"updateFailed": "設定の更新に失敗しました"
"updateFailed": "設定の更新に失敗しました",
"passwordAuthRequiresProvider": "少なくとも1つのアクティブな認証プロバイダーがない場合、パスワード認証を無効にできません"
},
"messages": {
"noChanges": "保存する変更はありません",
@@ -1198,7 +1319,8 @@
"editSuccess": "共有が正常に更新されました",
"editError": "共有の更新に失敗しました",
"bulkDeleteConfirmation": "{count, plural, =1 {1つの共有} other {#つの共有}}を削除してもよろしいですか?この操作は元に戻せません。",
"bulkDeleteTitle": "選択した共有を削除"
"bulkDeleteTitle": "選択した共有を削除",
"addDescriptionPlaceholder": "説明を追加..."
},
"shareDetails": {
"title": "共有詳細",
@@ -1230,7 +1352,10 @@
"invalidDate": "無効な日付",
"loadError": "共有詳細の読み込みに失敗しました",
"editSecurity": "セキュリティを編集",
"editExpiration": "期限を編集"
"editExpiration": "期限を編集",
"clickToEnlargeQrCode": "QRコードを拡大",
"downloadQrCode": "QRコードをダウンロード",
"qrCode": "QRコード"
},
"shareExpiration": {
"neverExpires": "期限なし",
@@ -1417,7 +1542,8 @@
"copyLink": "リンクコピー",
"notifyRecipients": "受信者に通知",
"delete": "削除",
"downloadShareFiles": "すべてのファイルをダウンロードします"
"downloadShareFiles": "すべてのファイルをダウンロードします",
"viewQrCode": "QRコードを表示"
},
"bulkActions": {
"delete": "削除",
@@ -1634,7 +1760,8 @@
"title": "アップロードするファイルをドロップ",
"description": "ファイルをアップロードするにはリリースしてください"
},
"pasteSuccess": "{count, plural, =1 {画像が貼り付けられ、正常にアップロードされました} other {#個の画像が貼り付けられ、正常にアップロードされました}}"
"pasteSuccess": "{count, plural, =1 {画像が貼り付けられ、正常にアップロードされました} other {#個の画像が貼り付けられ、正常にアップロードされました}}",
"filesQueued": "{count, plural, one {# ファイルがアップロード待ち} other {# ファイルがアップロード待ち}}"
},
"users": {
"modes": {
@@ -1719,11 +1846,5 @@
"passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
},
"imageEdit": {
"title": "画像を編集",
"rotate": "回転",
"zoom": "ズーム",
"cropInstructions": "位置を変更するにはドラッグし、カット領域を調整するには角をリサイズしてください"
}
}
}

View File

@@ -159,7 +159,48 @@
"passwordLabel": "비밀번호",
"create": "공유 생성",
"success": "공유가 성공적으로 생성되었습니다",
"error": "공유 생성에 실패했습니다"
"error": "공유 생성에 실패했습니다",
"namePlaceholder": "공유 이름을 입력하세요"
},
"customization": {
"breadcrumb": "커스터마이징",
"colors": {
"title": "테마 색상",
"description": "선호하는 기본 색상 테마를 선택하세요",
"presets": "사용 가능한 색상",
"presetsDescription": "사용 가능한 색상 테마에서 선택하세요",
"reset": "기본값으로 재설정"
},
"fonts": {
"title": "타이포그래피",
"description": "선호하는 글꼴을 선택하세요",
"available": "사용 가능한 글꼴",
"availableDescription": "사용 가능한 글꼴 중에서 선택하세요",
"reset": "기본값으로 재설정"
},
"radius": {
"title": "테두리 반경",
"description": "인터페이스 요소의 둥근 정도를 조정하세요",
"available": "둥근 정도 옵션",
"availableDescription": "모서리의 둥근 정도를 선택하세요",
"reset": "기본값으로 재설정"
},
"background": {
"title": "배경 색상",
"description": "라이트 모드와 다크 모드의 배경 색상을 조정하세요",
"lightMode": "라이트 모드",
"darkMode": "다크 모드",
"availableDescription": "라이트와 다크 테마의 배경 색상을 선택하세요",
"reset": "기본값으로 재설정"
},
"theme": {
"title": "테마 모드",
"description": "라이트, 다크 또는 시스템 테마 중 선택하세요",
"selectTheme": "테마 설정",
"availableDescription": "선호하는 테마 모드를 선택하세요",
"reset": "시스템 설정으로 재설정"
},
"pageTitle": "커스터마이징"
},
"dashboard": {
"loadError": "대시보드 데이터를 불러오는데 실패했습니다",
@@ -175,6 +216,39 @@
"filesToDelete": "삭제할 파일",
"sharesToDelete": "삭제될 공유"
},
"downloadQueue": {
"downloadQueued": "다운로드 대기 중: {fileName}",
"queuedDescription": "슬롯이 사용 가능해지면 자동으로 다운로드가 시작됩니다",
"queuePosition": "다운로드 대기 순서 {position}번: {fileName}",
"estimatedWait": "예상 대기 시간: {time}",
"queueFull": "다운로드 대기열이 가득 찼습니다",
"queueFullDescription": "대기열에 여유가 생길 때까지 잠시 후 다시 시도해 주세요",
"cancelSuccess": "다운로드가 성공적으로 취소되었습니다",
"cancelError": "다운로드 취소 실패: {error}",
"status": {
"pending": "준비 중...",
"queued": "대기 중",
"downloading": "다운로드 중",
"completed": "완료됨",
"failed": "실패"
},
"waitTime": {
"seconds": "{seconds}초",
"minutes": "{minutes}분",
"hoursMinutes": "{hours}시간 {minutes}분"
},
"indicator": {
"title": "다운로드",
"downloads": "다운로드 대기열",
"active": "진행 중",
"queued": "대기 중",
"position": "순서 {position}",
"estimatedWait": "대기: {time}",
"unknownFile": "알 수 없는 파일",
"noDownloads": "진행 중인 다운로드 없음",
"refresh": "대기열 새로고침"
}
},
"emptyState": {
"noFiles": "아직 파일이 업로드되지 않았습니다",
"uploadFile": "파일 업로드"
@@ -202,7 +276,8 @@
"descriptionPlaceholder": "파일 설명을 입력하세요",
"deleteFile": "파일 삭제",
"deleteConfirmation": "을(를) 삭제하시겠습니까?",
"deleteWarning": "이 작업은 취소할 수 없습니다."
"deleteWarning": "이 작업은 취소할 수 없습니다.",
"addDescriptionPlaceholder": "설명 추가..."
},
"fileManager": {
"downloadError": "파일 다운로드에 실패했습니다",
@@ -270,7 +345,9 @@
"table": "테이블",
"grid": "그리드"
},
"totalFiles": "{count, plural, =0 {파일 없음} =1 {1개 파일} other {#개 파일}}"
"totalFiles": "{count, plural, =0 {파일 없음} =1 {1개 파일} other {#개 파일}}",
"bulkDeleteConfirmation": "{count, plural, =1 {1개 파일} other {#개 파일}}을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"bulkDeleteTitle": "선택한 파일 삭제"
},
"filesTable": {
"ariaLabel": "파일 테이블",
@@ -313,7 +390,8 @@
"title": "비밀번호를 잊으셨나요?",
"description": "이메일 주소를 입력하면 비밀번호 재설정 지침을 보내드립니다.",
"resetInstructions": "비밀번호 재설정 지침이 이메일로 전송되었습니다",
"pageTitle": "비밀번호를 잊으셨나요?"
"pageTitle": "비밀번호를 잊으셨나요?",
"passwordAuthDisabled": "비밀번호 인증이 비활성화되어 있습니다. 관리자에게 문의하거나 외부 인증 공급자를 사용하세요."
},
"generateShareLink": {
"generateTitle": "공유 링크 생성",
@@ -327,7 +405,12 @@
"copyButton": "링크 복사",
"success": "링크가 성공적으로 생성되었습니다",
"error": "링크 생성에 실패했습니다",
"copied": "링크가 클립보드에 복사되었습니다"
"copied": "링크가 클립보드에 복사되었습니다",
"readyDescription": "공유 링크가 준비되었습니다. QR 코드를 직접 스캔하여 링크에 접근하거나, 나중에 사용하기 위해 다운로드하거나, 아래 링크를 복사할 수 있습니다.",
"tabs": {
"link": "링크",
"qrcode": "QR 코드"
}
},
"home": {
"description": "WeTransfer의 오픈소스 대안입니다. 추적이나 제한 없이 파일을 안전하게 공유하세요.",
@@ -355,6 +438,12 @@
"stats": "{libraryCount}개의 라이브러리에서 {iconCount}개의 아이콘",
"categoryBadge": "{category} ({count}개의 아이콘)"
},
"imageEdit": {
"title": "이미지 편집",
"rotate": "회전",
"zoom": "확대/축소",
"cropInstructions": "위치를 변경하려면 드래그하고, 자르기 영역을 조정하려면 모서리를 확대/축소하세요"
},
"login": {
"welcome": "에 오신 것을 환영합니다",
"signInToContinue": "계속하려면 로그인하세요",
@@ -395,11 +484,30 @@
"profile": "프로필",
"settings": "설정",
"usersManagement": "사용자 관리",
"logout": "로그아웃"
"logout": "로그아웃",
"customization": "사용자 정의"
},
"navigation": {
"dashboard": "대시보드"
},
"notifications": {
"permissionGranted": "다운로드 알림이 활성화되었습니다",
"permissionDenied": "다운로드 알림이 비활성화되었습니다",
"downloadComplete": {
"title": "다운로드 완료",
"body": "{fileName} 다운로드가 완료되었습니다"
},
"downloadFailed": {
"title": "다운로드 실패",
"body": "{fileName} 다운로드 실패: {error}",
"unknownError": "알 수 없는 오류"
},
"queueProcessing": {
"title": "다운로드 시작",
"body": "{fileName} 다운로드가 시작되었습니다{position}",
"position": " (대기열 #{position}번이었음)"
}
},
"profile": {
"password": {
"title": "비밀번호 변경",
@@ -442,6 +550,11 @@
},
"pageTitle": "프로필"
},
"qrCodeModal": {
"title": "QR 코드 공유",
"description": "이 QR 코드를 스캔하여 링크에 접근할 수 있습니다.",
"download": "QR 코드 다운로드"
},
"quickAccess": {
"files": {
"title": "내 파일",
@@ -613,7 +726,8 @@
"createLink": "링크 생성",
"delete": "삭제",
"copyLinkTitle": "링크 복사",
"createLinkCTA": "수신 링크 생성"
"createLinkCTA": "수신 링크 생성",
"viewQrCode": "QR 코드 보기"
},
"status": {
"active": "활성",
@@ -629,7 +743,8 @@
"viewDetails": "상세 보기",
"edit": "편집",
"delete": "삭제",
"viewFiles": "받은 파일"
"viewFiles": "받은 파일",
"viewQrCode": "QR 코드 보기"
},
"empty": {
"title": "생성된 수신 링크 없음",
@@ -775,7 +890,8 @@
"timeout": "복사 작업 시간이 초과되었습니다. 더 작은 파일로 다시 시도하거나 연결을 확인하십시오.",
"failed": "복사 작업이 실패했습니다. 다시 시도해 주세요.",
"aborted": "시간 초과로 인해 복사 작업이 취소되었습니다."
}
},
"invalidDate": "잘못된 날짜"
}
},
"form": {
@@ -1115,6 +1231,10 @@
"smtpTrustSelfSigned": {
"title": "자체 서명된 인증서 신뢰",
"description": "자체 서명된 SSL/TLS 인증서를 신뢰하려면 활성화하세요 (개발 환경에서 유용)"
},
"passwordAuthEnabled": {
"title": "비밀번호 인증",
"description": "비밀번호 기반 인증 활성화 또는 비활성화"
}
},
"buttons": {
@@ -1124,7 +1244,8 @@
},
"errors": {
"loadFailed": "설정을 불러오는데 실패했습니다",
"updateFailed": "설정 업데이트에 실패했습니다"
"updateFailed": "설정 업데이트에 실패했습니다",
"passwordAuthRequiresProvider": "최소 하나의 활성 인증 제공자가 없으면 비밀번호 인증을 비활성화할 수 없습니다"
},
"messages": {
"noChanges": "저장할 변경 사항이 없습니다",
@@ -1198,7 +1319,8 @@
"editSuccess": "공유가 성공적으로 업데이트되었습니다",
"editError": "공유 업데이트에 실패했습니다",
"bulkDeleteConfirmation": "{count, plural, =1 {1개의 공유} other {#개의 공유}}를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"bulkDeleteTitle": "선택한 공유 삭제"
"bulkDeleteTitle": "선택한 공유 삭제",
"addDescriptionPlaceholder": "설명 추가..."
},
"shareDetails": {
"title": "공유 세부 정보",
@@ -1230,7 +1352,10 @@
"invalidDate": "잘못된 날짜",
"loadError": "공유 세부 정보 로드에 실패했습니다",
"editSecurity": "보안 편집",
"editExpiration": "만료 편집"
"editExpiration": "만료 편집",
"clickToEnlargeQrCode": "QR 코드 확대",
"downloadQrCode": "QR 코드 다운로드",
"qrCode": "QR 코드"
},
"shareExpiration": {
"neverExpires": "만료되지 않음",
@@ -1417,7 +1542,8 @@
"copyLink": "링크 복사",
"notifyRecipients": "받는 사람에게 알림",
"delete": "삭제",
"downloadShareFiles": "모든 파일을 다운로드하십시오"
"downloadShareFiles": "모든 파일을 다운로드하십시오",
"viewQrCode": "QR 코드 보기"
},
"bulkActions": {
"delete": "삭제",
@@ -1634,7 +1760,8 @@
"title": "업로드할 파일 드롭",
"description": "파일을 업로드하려면 놓으세요"
},
"pasteSuccess": "{count, plural, =1 {이미지가 성공적으로 붙여넣기 및 업로드되었습니다} other {# 개의 이미지가 성공적으로 붙여넣기 및 업로드되었습니다}}"
"pasteSuccess": "{count, plural, =1 {이미지가 성공적으로 붙여넣기 및 업로드되었습니다} other {# 개의 이미지가 성공적으로 붙여넣기 및 업로드되었습니다}}",
"filesQueued": "{count, plural, one {# 파일이 업로드 대기 중} other {# 파일이 업로드 대기 중}}"
},
"users": {
"modes": {
@@ -1719,11 +1846,5 @@
"passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
},
"imageEdit": {
"title": "이미지 편집",
"rotate": "회전",
"zoom": "확대/축소",
"cropInstructions": "위치를 변경하려면 드래그하고, 자르기 영역을 조정하려면 모서리를 확대/축소하세요"
}
}
}

View File

@@ -159,7 +159,48 @@
"passwordLabel": "Wachtwoord",
"create": "Delen Maken",
"success": "Delen succesvol aangemaakt",
"error": "Fout bij het aanmaken van delen"
"error": "Fout bij het aanmaken van delen",
"namePlaceholder": "Voer een naam in voor uw delen"
},
"customization": {
"breadcrumb": "Aanpassing",
"colors": {
"title": "Thema Kleuren",
"description": "Kies je gewenste primaire kleurenthema",
"presets": "Beschikbare Kleuren",
"presetsDescription": "Selecteer uit beschikbare kleurenthema's",
"reset": "Standaardinstellingen Herstellen"
},
"fonts": {
"title": "Typografie",
"description": "Kies je gewenste lettertype",
"available": "Beschikbare Lettertypen",
"availableDescription": "Selecteer uit beschikbare lettertypen",
"reset": "Standaardinstellingen Herstellen"
},
"radius": {
"title": "Randradius",
"description": "Pas de rondheid van interface-elementen aan",
"available": "Rondheidsopties",
"availableDescription": "Kies hoe afgerond hoeken moeten verschijnen",
"reset": "Standaardinstellingen Herstellen"
},
"background": {
"title": "Achtergrondkleuren",
"description": "Pas achtergrondkleuren aan voor lichte en donkere modi",
"lightMode": "Lichte Modus",
"darkMode": "Donkere Modus",
"availableDescription": "Kies achtergrondkleuren voor zowel lichte als donkere thema's",
"reset": "Standaardinstellingen Herstellen"
},
"theme": {
"title": "Thema Modus",
"description": "Kies tussen licht, donker of systeemthema",
"selectTheme": "Thema Voorkeur",
"availableDescription": "Selecteer je gewenste thema modus",
"reset": "Terugzetten naar Systeem"
},
"pageTitle": "Aanpassing"
},
"dashboard": {
"loadError": "Fout bij het laden van controlepaneel gegevens",
@@ -175,6 +216,39 @@
"filesToDelete": "Te verwijderen bestanden",
"sharesToDelete": "Delen die worden verwijderd"
},
"downloadQueue": {
"downloadQueued": "Download in wachtrij: {fileName}",
"queuedDescription": "Je download start automatisch wanneer er een slot beschikbaar komt",
"queuePosition": "Download in wachtrij op positie {position}: {fileName}",
"estimatedWait": "Geschatte wachttijd: {time}",
"queueFull": "Downloadwachtrij is vol",
"queueFullDescription": "Probeer het over enkele minuten opnieuw wanneer er ruimte is in de wachtrij",
"cancelSuccess": "Download succesvol geannuleerd",
"cancelError": "Download annuleren mislukt: {error}",
"status": {
"pending": "Voorbereiden...",
"queued": "In wachtrij",
"downloading": "Downloaden",
"completed": "Voltooid",
"failed": "Mislukt"
},
"waitTime": {
"seconds": "{seconds}s",
"minutes": "{minutes}m",
"hoursMinutes": "{hours}u {minutes}m"
},
"indicator": {
"title": "Downloads",
"downloads": "Download Wachtrij",
"active": "Actief",
"queued": "In Wachtrij",
"position": "Positie {position}",
"estimatedWait": "Wachttijd: {time}",
"unknownFile": "Onbekend bestand",
"noDownloads": "Geen downloads actief",
"refresh": "Wachtrij Vernieuwen"
}
},
"emptyState": {
"noFiles": "Nog geen bestanden geüpload",
"uploadFile": "Bestand Uploaden"
@@ -202,7 +276,8 @@
"descriptionPlaceholder": "Voer bestandsbeschrijving in",
"deleteFile": "Bestand Verwijderen",
"deleteConfirmation": "Weet je zeker dat je wilt verwijderen?",
"deleteWarning": "Deze actie kan niet ongedaan worden gemaakt."
"deleteWarning": "Deze actie kan niet ongedaan worden gemaakt.",
"addDescriptionPlaceholder": "Beschrijving toevoegen..."
},
"fileManager": {
"downloadError": "Fout bij het downloaden van bestand",
@@ -270,7 +345,9 @@
"table": "Tabel",
"grid": "Raster"
},
"totalFiles": "{count, plural, =0 {Geen bestanden} =1 {1 bestand} other {# bestanden}}"
"totalFiles": "{count, plural, =0 {Geen bestanden} =1 {1 bestand} other {# bestanden}}",
"bulkDeleteConfirmation": "Weet je zeker dat je {count, plural, =1 {1 bestand} other {# bestanden}} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"bulkDeleteTitle": "Geselecteerde Bestanden Verwijderen"
},
"filesTable": {
"ariaLabel": "Bestanden tabel",
@@ -313,7 +390,8 @@
"title": "Wachtwoord Vergeten",
"description": "Voer je e-mailadres in en we sturen je instructies om je wachtwoord te resetten.",
"resetInstructions": "Reset instructies verzonden naar je e-mail",
"pageTitle": "Wachtwoord Vergeten"
"pageTitle": "Wachtwoord Vergeten",
"passwordAuthDisabled": "Wachtwoordauthenticatie is uitgeschakeld. Neem contact op met uw beheerder of gebruik een externe authenticatieprovider."
},
"generateShareLink": {
"generateTitle": "Deel-link genereren",
@@ -327,7 +405,12 @@
"copyButton": "Link kopiëren",
"success": "Link succesvol gegenereerd",
"error": "Fout bij het genereren van link",
"copied": "Link gekopieerd naar klembord"
"copied": "Link gekopieerd naar klembord",
"readyDescription": "Uw deel-link is klaar. U kunt de QR-code direct scannen, downloaden voor later gebruik, of de link hieronder kopiëren.",
"tabs": {
"link": "Link",
"qrcode": "QR Code"
}
},
"home": {
"description": "Het open-source alternatief voor WeTransfer. Deel bestanden veilig, zonder tracking of beperkingen.",
@@ -355,6 +438,12 @@
"stats": "{iconCount} pictogrammen van {libraryCount} bibliotheken",
"categoryBadge": "{category} ({count} pictogrammen)"
},
"imageEdit": {
"title": "Afbeelding bewerken",
"rotate": "Draai",
"zoom": "Vergroot",
"cropInstructions": "Sleep om te herpositioneren, verander de grootte van de hoeken om de uitsnijdgebied aan te passen"
},
"login": {
"welcome": "Welkom bij",
"signInToContinue": "Log in om door te gaan",
@@ -395,11 +484,30 @@
"profile": "Profiel",
"settings": "Instellingen",
"usersManagement": "Gebruikersbeheer",
"logout": "Uitloggen"
"logout": "Uitloggen",
"customization": "Aanpassen"
},
"navigation": {
"dashboard": "Controlepaneel"
},
"notifications": {
"permissionGranted": "Download meldingen ingeschakeld",
"permissionDenied": "Download meldingen uitgeschakeld",
"downloadComplete": {
"title": "Download Voltooid",
"body": "{fileName} is klaar met downloaden"
},
"downloadFailed": {
"title": "Download Mislukt",
"body": "Downloaden van {fileName} mislukt: {error}",
"unknownError": "Onbekende fout"
},
"queueProcessing": {
"title": "Download Start",
"body": "{fileName} wordt nu gedownload{position}",
"position": " (was #{position} in wachtrij)"
}
},
"profile": {
"password": {
"title": "Wachtwoord Wijzigen",
@@ -442,6 +550,11 @@
},
"pageTitle": "Profiel"
},
"qrCodeModal": {
"title": "QR Code Delen",
"description": "Scan deze QR-code om toegang te krijgen tot de link.",
"download": "QR Code Downloaden"
},
"quickAccess": {
"files": {
"title": "Mijn Bestanden",
@@ -613,7 +726,8 @@
"createLink": "Link Aanmaken",
"delete": "Verwijderen",
"copyLinkTitle": "Link kopiëren",
"createLinkCTA": "Ontvangstlink Aanmaken"
"createLinkCTA": "Ontvangstlink Aanmaken",
"viewQrCode": "QR Code Bekijken"
},
"status": {
"active": "Actief",
@@ -629,7 +743,8 @@
"viewDetails": "Details Bekijken",
"edit": "Bewerken",
"delete": "Verwijderen",
"viewFiles": "Ontvangen Bestanden"
"viewFiles": "Ontvangen Bestanden",
"viewQrCode": "QR Code Bekijken"
},
"empty": {
"title": "Geen ontvangstlinks aangemaakt",
@@ -775,7 +890,8 @@
"timeout": "Kopieeroperatie verlopen. Probeer het opnieuw met een kleiner bestand of controleer uw verbinding.",
"failed": "Kopieeroperatie mislukt. Probeer het opnieuw.",
"aborted": "Kopieeroperatie is geannuleerd vanwege een time-out."
}
},
"invalidDate": "Ongeldige datum"
}
},
"form": {
@@ -1115,6 +1231,10 @@
"smtpTrustSelfSigned": {
"title": "Vertrouw Zelf-Ondertekende Certificaten",
"description": "Schakel dit in om zelf-ondertekende SSL/TLS certificaten te vertrouwen (handig voor ontwikkelomgevingen)"
},
"passwordAuthEnabled": {
"title": "Wachtwoord Authenticatie",
"description": "Wachtwoord-gebaseerde authenticatie inschakelen of uitschakelen"
}
},
"buttons": {
@@ -1124,7 +1244,8 @@
},
"errors": {
"loadFailed": "Fout bij het laden van instellingen",
"updateFailed": "Fout bij het bijwerken van instellingen"
"updateFailed": "Fout bij het bijwerken van instellingen",
"passwordAuthRequiresProvider": "Wachtwoordauthenticatie kan niet worden uitgeschakeld zonder ten minste één actieve authenticatieprovider"
},
"messages": {
"noChanges": "Geen wijzigingen om op te slaan",
@@ -1198,7 +1319,8 @@
"editSuccess": "Delen succesvol bijgewerkt",
"editError": "Fout bij bijwerken van delen",
"bulkDeleteConfirmation": "Weet je zeker dat je {count, plural, =1 {1 deel} other {# delen}} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"bulkDeleteTitle": "Geselecteerde Delen Verwijderen"
"bulkDeleteTitle": "Geselecteerde Delen Verwijderen",
"addDescriptionPlaceholder": "Beschrijving toevoegen..."
},
"shareDetails": {
"title": "Delen Details",
@@ -1230,7 +1352,10 @@
"invalidDate": "Ongeldige datum",
"loadError": "Fout bij laden van delen details",
"editSecurity": "Beveiliging Bewerken",
"editExpiration": "Vervaldatum Bewerken"
"editExpiration": "Vervaldatum Bewerken",
"clickToEnlargeQrCode": "Klik om QR Code te vergroten",
"downloadQrCode": "QR Code Downloaden",
"qrCode": "QR Code"
},
"shareExpiration": {
"neverExpires": "Verloopt Nooit",
@@ -1417,7 +1542,8 @@
"copyLink": "Link Kopiëren",
"notifyRecipients": "Ontvangers Informeren",
"delete": "Verwijderen",
"downloadShareFiles": "Download alle bestanden"
"downloadShareFiles": "Download alle bestanden",
"viewQrCode": "QR Code Bekijken"
},
"bulkActions": {
"delete": "Verwijderen",
@@ -1634,7 +1760,8 @@
"title": "Sleep bestanden om te uploaden",
"description": "Laat los om je bestanden te uploaden"
},
"pasteSuccess": "{count, plural, =1 {Afbeelding geplakt en succesvol geüpload} other {# afbeeldingen geplakt en succesvol geüpload}}"
"pasteSuccess": "{count, plural, =1 {Afbeelding geplakt en succesvol geüpload} other {# afbeeldingen geplakt en succesvol geüpload}}",
"filesQueued": "{count, plural, one {# bestand in de wachtrij voor upload} other {# bestanden in de wachtrij voor upload}}"
},
"users": {
"modes": {
@@ -1719,11 +1846,5 @@
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
"nameRequired": "Naam is verplicht",
"required": "Dit veld is verplicht"
},
"imageEdit": {
"title": "Afbeelding bewerken",
"rotate": "Draai",
"zoom": "Vergroot",
"cropInstructions": "Sleep om te herpositioneren, verander de grootte van de hoeken om de uitsnijdgebied aan te passen"
}
}
}

View File

@@ -159,7 +159,48 @@
"passwordLabel": "Hasło",
"create": "Utwórz Udostępnienie",
"success": "Udostępnienie utworzone pomyślnie",
"error": "Nie udało się utworzyć udostępnienia"
"error": "Nie udało się utworzyć udostępnienia",
"namePlaceholder": "Wprowadź nazwę dla swojego udostępnienia"
},
"customization": {
"breadcrumb": "Personalizacja",
"colors": {
"title": "Kolory motywu",
"description": "Wybierz preferowany kolor podstawowy motywu",
"presets": "Dostępne kolory",
"presetsDescription": "Wybierz spośród dostępnych motywów kolorystycznych",
"reset": "Przywróć domyślne"
},
"fonts": {
"title": "Typografia",
"description": "Wybierz preferowaną rodzinę czcionek",
"available": "Dostępne czcionki",
"availableDescription": "Wybierz spośród dostępnych rodzin czcionek",
"reset": "Przywróć domyślne"
},
"radius": {
"title": "Zaokrąglenie krawędzi",
"description": "Dostosuj zaokrąglenie elementów interfejsu",
"available": "Opcje zaokrąglenia",
"availableDescription": "Wybierz stopień zaokrąglenia narożników",
"reset": "Przywróć domyślne"
},
"background": {
"title": "Kolory tła",
"description": "Dostosuj kolory tła dla trybów jasnego i ciemnego",
"lightMode": "Tryb jasny",
"darkMode": "Tryb ciemny",
"availableDescription": "Wybierz kolory tła dla motywów jasnego i ciemnego",
"reset": "Przywróć domyślne"
},
"theme": {
"title": "Tryb motywu",
"description": "Wybierz między trybem jasnym, ciemnym lub systemowym",
"selectTheme": "Preferencje motywu",
"availableDescription": "Wybierz preferowany tryb motywu",
"reset": "Przywróć ustawienia systemowe"
},
"pageTitle": "Personalizacja"
},
"dashboard": {
"loadError": "Nie udało się załadować danych panelu głównego",
@@ -175,6 +216,39 @@
"filesToDelete": "Pliki do usunięcia",
"sharesToDelete": "Udostępnienia do usunięcia"
},
"downloadQueue": {
"downloadQueued": "Pobieranie w kolejce: {fileName}",
"queuedDescription": "Pobieranie rozpocznie się automatycznie, gdy zwolni się miejsce",
"queuePosition": "Pobieranie w kolejce na pozycji {position}: {fileName}",
"estimatedWait": "Szacowany czas oczekiwania: {time}",
"queueFull": "Kolejka pobierania jest pełna",
"queueFullDescription": "Spróbuj ponownie za kilka minut, gdy w kolejce będzie miejsce",
"cancelSuccess": "Pobieranie anulowane pomyślnie",
"cancelError": "Nie udało się anulować pobierania: {error}",
"status": {
"pending": "Przygotowywanie...",
"queued": "W kolejce",
"downloading": "Pobieranie",
"completed": "Zakończone",
"failed": "Nie powiodło się"
},
"waitTime": {
"seconds": "{seconds}s",
"minutes": "{minutes}m",
"hoursMinutes": "{hours}g {minutes}m"
},
"indicator": {
"title": "Pobierania",
"downloads": "Kolejka pobierania",
"active": "Aktywne",
"queued": "W kolejce",
"position": "Pozycja {position}",
"estimatedWait": "Oczekiwanie: {time}",
"unknownFile": "Nieznany plik",
"noDownloads": "Brak aktywnych pobierań",
"refresh": "Odśwież kolejkę"
}
},
"emptyState": {
"noFiles": "Brak przesłanych plików",
"uploadFile": "Prześlij plik"
@@ -202,7 +276,8 @@
"descriptionPlaceholder": "Wprowadź opis pliku",
"deleteFile": "Usuń plik",
"deleteConfirmation": "Czy na pewno chcesz usunąć ten plik?",
"deleteWarning": "Tej operacji nie można cofnąć."
"deleteWarning": "Tej operacji nie można cofnąć.",
"addDescriptionPlaceholder": "Dodaj opis..."
},
"fileManager": {
"downloadError": "Nie udało się pobrać pliku",
@@ -270,7 +345,9 @@
"viewMode": {
"table": "Tabela",
"grid": "Siatka"
}
},
"bulkDeleteConfirmation": "Czy na pewno chcesz usunąć {count, plural, =1 {1 plik} other {# plików}}? Ta akcja nie może zostać cofnięta.",
"bulkDeleteTitle": "Usuń Wybrane Pliki"
},
"filesTable": {
"ariaLabel": "Tabela plików",
@@ -313,7 +390,8 @@
"title": "Zapomniałeś hasła?",
"description": "Wprowadź swój adres e-mail, a wyślemy Ci instrukcje resetowania hasła",
"resetInstructions": "Instrukcje resetowania wysłane na Twój adres e-mail",
"pageTitle": "Zapomniałeś hasła?"
"pageTitle": "Zapomniałeś hasła?",
"passwordAuthDisabled": "Uwierzytelnianie hasłem jest wyłączone. Skontaktuj się z administratorem lub użyj zewnętrznego dostawcy uwierzytelniania."
},
"generateShareLink": {
"generateTitle": "Generuj link do udostępniania",
@@ -327,7 +405,12 @@
"copyButton": "Skopiuj link",
"success": "Link wygenerowany pomyślnie",
"error": "Nie udało się wygenerować linku",
"copied": "Link skopiowany do schowka"
"copied": "Link skopiowany do schowka",
"readyDescription": "Twój link do udostępniania jest gotowy. Możesz skanować kod QR bezpośrednio, pobrać go do późniejszego użycia lub skopiować link poniżej.",
"tabs": {
"link": "Link",
"qrcode": "QR Code"
}
},
"home": {
"description": "Otwartoźródłowa alternatywa dla WeTransfer. Udostępniaj pliki bezpiecznie, bez śledzenia i ograniczeń.",
@@ -355,6 +438,12 @@
"stats": "{iconCount} ikon z {libraryCount} bibliotek",
"categoryBadge": "{category} ({count} ikon)"
},
"imageEdit": {
"title": "Edytuj obraz",
"rotate": "Obróć",
"zoom": "Powiększ",
"cropInstructions": "Przeciągnij, aby przesunąć, zmień rozmiar rogów, aby dostosować obszar przycięcia"
},
"login": {
"welcome": "Witaj w",
"signInToContinue": "Zaloguj się, aby kontynuować",
@@ -395,11 +484,30 @@
"profile": "Profil",
"settings": "Ustawienia",
"usersManagement": "Zarządzanie użytkownikami",
"logout": "Wyloguj się"
"logout": "Wyloguj się",
"customization": "Dostosowanie"
},
"navigation": {
"dashboard": "Panel główny"
},
"notifications": {
"permissionGranted": "Powiadomienia o pobieraniu włączone",
"permissionDenied": "Powiadomienia o pobieraniu wyłączone",
"downloadComplete": {
"title": "Pobieranie zakończone",
"body": "Plik {fileName} został pobrany"
},
"downloadFailed": {
"title": "Błąd pobierania",
"body": "Nie udało się pobrać pliku {fileName}: {error}",
"unknownError": "Nieznany błąd"
},
"queueProcessing": {
"title": "Rozpoczęcie pobierania",
"body": "Trwa pobieranie pliku {fileName}{position}",
"position": " (był #{position} w kolejce)"
}
},
"profile": {
"password": {
"title": "Zmień hasło",
@@ -442,6 +550,11 @@
},
"pageTitle": "Profil"
},
"qrCodeModal": {
"title": "Udostępnij kod QR",
"description": "Skanuj ten kod QR, aby uzyskać dostęp do linku.",
"download": "Pobierz kod QR"
},
"quickAccess": {
"files": {
"title": "Moje pliki",
@@ -613,7 +726,8 @@
"createLink": "Utwórz link",
"delete": "Usuń",
"copyLinkTitle": "Skopiuj link",
"createLinkCTA": "Utwórz link do odbierania"
"createLinkCTA": "Utwórz link do odbierania",
"viewQrCode": "Wyświetl kod QR"
},
"status": {
"active": "Aktywny",
@@ -629,7 +743,8 @@
"viewDetails": "Wyświetl szczegóły",
"edit": "Edytuj",
"delete": "Usuń",
"viewFiles": "Odebrane pliki"
"viewFiles": "Odebrane pliki",
"viewQrCode": "Wyświetl kod QR"
},
"empty": {
"title": "Brak utworzonych linków do odbierania",
@@ -775,7 +890,8 @@
"timeout": "Operacja kopiowania przekroczyła limit czasu. Spróbuj ponownie z mniejszym plikiem lub sprawdź swoje połączenie.",
"failed": "Operacja kopiowania nie powiodła się. Spróbuj ponownie.",
"aborted": "Operacja kopiowania została anulowana z powodu przekroczenia limitu czasu."
}
},
"invalidDate": "Nieprawidłowa data"
}
},
"form": {
@@ -1115,6 +1231,10 @@
"smtpTrustSelfSigned": {
"title": "Zaufaj certyfikatom samopodpisanym",
"description": "Włącz tę opcję, aby zaufać samopodpisanym certyfikatom SSL/TLS (przydatne w środowiskach deweloperskich)"
},
"passwordAuthEnabled": {
"title": "Uwierzytelnianie hasłem",
"description": "Włącz lub wyłącz uwierzytelnianie oparte na haśle"
}
},
"buttons": {
@@ -1124,7 +1244,8 @@
},
"errors": {
"loadFailed": "Nie udało się załadować ustawień",
"updateFailed": "Nie udało się zaktualizować ustawień"
"updateFailed": "Nie udało się zaktualizować ustawień",
"passwordAuthRequiresProvider": "Uwierzytelnianie oparte na haśle nie może być wyłączone, jeśli nie ma co najmniej jednego aktywnego dostawcy uwierzytelniania"
},
"messages": {
"noChanges": "Brak zmian do zapisania",
@@ -1198,7 +1319,8 @@
"editSuccess": "Udostępnienie zaktualizowane pomyślnie",
"editError": "Nie udało się zaktualizować udostępnienia",
"bulkDeleteConfirmation": "Czy na pewno chcesz usunąć {count, plural, =1 {1 udostępnienie} other {# udostępnień}}? Tej operacji nie można cofnąć.",
"bulkDeleteTitle": "Usuń wybrane udostępnienia"
"bulkDeleteTitle": "Usuń wybrane udostępnienia",
"addDescriptionPlaceholder": "Dodaj opis..."
},
"shareDetails": {
"title": "Szczegóły udostępnienia",
@@ -1230,7 +1352,10 @@
"recipients": "Odbiorcy",
"notAvailable": "N/A",
"invalidDate": "Nieprawidłowa data",
"loadError": "Nie udało się załadować szczegółów udostępnienia"
"loadError": "Nie udało się załadować szczegółów udostępnienia",
"clickToEnlargeQrCode": "Kliknij, aby powiększyć kod QR",
"downloadQrCode": "Pobierz kod QR",
"qrCode": "Kod QR"
},
"shareExpiration": {
"title": "Ustawienia wygaśnięcia udostępnienia",
@@ -1417,7 +1542,8 @@
"copyLink": "Skopiuj link",
"notifyRecipients": "Powiadom odbiorców",
"delete": "Usuń",
"downloadShareFiles": "Pobierz wszystkie pliki"
"downloadShareFiles": "Pobierz wszystkie pliki",
"viewQrCode": "Wyświetl kod QR"
},
"bulkActions": {
"delete": "Usuń",
@@ -1634,7 +1760,8 @@
"title": "Upuść pliki, aby przesłać",
"description": "Zwolnij, aby przesłać pliki"
},
"pasteSuccess": "{count, plural, =1 {Obraz wklejony i przesłany pomyślnie} other {# obrazy wklejone i przesłane pomyślnie}}"
"pasteSuccess": "{count, plural, =1 {Obraz wklejony i przesłany pomyślnie} other {# obrazy wklejone i przesłane pomyślnie}}",
"filesQueued": "{count, plural, one {# plik w kolejce do przesyłania} other {# pliki w kolejce do przesyłania}}"
},
"users": {
"modes": {
@@ -1719,11 +1846,5 @@
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
"nameRequired": "Nazwa jest wymagana",
"required": "To pole jest wymagane"
},
"imageEdit": {
"title": "Edytuj obraz",
"rotate": "Obróć",
"zoom": "Powiększ",
"cropInstructions": "Przeciągnij, aby przesunąć, zmień rozmiar rogów, aby dostosować obszar przycięcia"
}
}
}

View File

@@ -159,7 +159,48 @@
"passwordLabel": "Senha",
"create": "Criar compartilhamento",
"success": "Compartilhamento criado com sucesso",
"error": "Falha ao criar compartilhamento"
"error": "Falha ao criar compartilhamento",
"namePlaceholder": "Digite um nome para seu compartilhamento"
},
"customization": {
"breadcrumb": "Personalização",
"colors": {
"title": "Cores do Tema",
"description": "Escolha sua cor primária preferida para o tema",
"presets": "Cores Disponíveis",
"presetsDescription": "Selecione entre os temas de cores disponíveis",
"reset": "Restaurar Padrão"
},
"fonts": {
"title": "Tipografia",
"description": "Escolha sua família de fontes preferida",
"available": "Fontes Disponíveis",
"availableDescription": "Selecione entre as famílias de fontes disponíveis",
"reset": "Restaurar Padrão"
},
"radius": {
"title": "Borda Arredondada",
"description": "Personalize o arredondamento dos elementos da interface",
"available": "Opções de Arredondamento",
"availableDescription": "Escolha como os cantos arredondados devem aparecer",
"reset": "Restaurar Padrão"
},
"background": {
"title": "Cores de Fundo",
"description": "Personalize as cores de fundo para os modos claro e escuro",
"lightMode": "Modo Claro",
"darkMode": "Modo Escuro",
"availableDescription": "Escolha as cores de fundo para os temas claro e escuro",
"reset": "Restaurar Padrão"
},
"theme": {
"title": "Modo do Tema",
"description": "Escolha entre tema claro, escuro ou do sistema",
"selectTheme": "Preferência de Tema",
"availableDescription": "Selecione seu modo de tema preferido",
"reset": "Restaurar para Sistema"
},
"pageTitle": "Personalização"
},
"dashboard": {
"loadError": "Falha ao carregar dados do painel",
@@ -175,6 +216,39 @@
"filesToDelete": "Arquivos que serão excluídos",
"sharesToDelete": "Compartilhamentos que serão excluídos"
},
"downloadQueue": {
"downloadQueued": "Download na fila: {fileName}",
"queuedDescription": "Seu download começará automaticamente quando uma vaga estiver disponível",
"queuePosition": "Download na posição {position} da fila: {fileName}",
"estimatedWait": "Tempo estimado de espera: {time}",
"queueFull": "Fila de download está cheia",
"queueFullDescription": "Por favor, tente novamente em alguns minutos quando houver espaço na fila",
"cancelSuccess": "Download cancelado com sucesso",
"cancelError": "Falha ao cancelar download: {error}",
"status": {
"pending": "Preparando...",
"queued": "Na fila",
"downloading": "Baixando",
"completed": "Concluído",
"failed": "Falhou"
},
"waitTime": {
"seconds": "{seconds}s",
"minutes": "{minutes}m",
"hoursMinutes": "{hours}h {minutes}m"
},
"indicator": {
"title": "Downloads",
"downloads": "Fila de Download",
"active": "Ativos",
"queued": "Na Fila",
"position": "Posição {position}",
"estimatedWait": "Espera: {time}",
"unknownFile": "Arquivo desconhecido",
"noDownloads": "Nenhum download em andamento",
"refresh": "Atualizar Fila"
}
},
"emptyState": {
"noFiles": "Nenhum arquivo enviado ainda",
"uploadFile": "Enviar arquivo"
@@ -200,6 +274,7 @@
"extension": "Extensão",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite a descrição do arquivo",
"addDescriptionPlaceholder": "Adicionar descrição...",
"deleteFile": "Excluir arquivo",
"deleteConfirmation": "Tem certeza que deseja excluir ?",
"deleteWarning": "Esta ação não pode ser desfeita."
@@ -266,6 +341,8 @@
"bulkDownloadFileError": "Erro ao baixar arquivo {fileName}",
"bulkDeleteSuccess": "{count, plural, =1 {1 arquivo excluído com sucesso} other {# arquivos excluídos com sucesso}}",
"bulkDeleteError": "Erro ao excluir arquivos selecionados",
"bulkDeleteTitle": "Excluir Arquivos Selecionados",
"bulkDeleteConfirmation": "Tem certeza que deseja excluir {count, plural, =1 {1 arquivo} other {# arquivos}}? Esta ação não pode ser desfeita.",
"totalFiles": "{count, plural, =0 {Nenhum arquivo} =1 {1 arquivo} other {# arquivos}}",
"viewMode": {
"table": "Tabela",
@@ -313,7 +390,8 @@
"title": "Esqueceu a Senha",
"description": "Digite seu endereço de email e enviaremos instruções para redefinir sua senha.",
"resetInstructions": "Instruções de redefinição enviadas para seu email",
"pageTitle": "Esqueceu a Senha"
"pageTitle": "Esqueceu a Senha",
"passwordAuthDisabled": "A autenticação por senha está desativada. Por favor, contate seu administrador ou use um provedor de autenticação externo."
},
"generateShareLink": {
"generateTitle": "Gerar link de compartilhamento",
@@ -327,7 +405,12 @@
"copyButton": "Copiar link",
"success": "Link gerado com sucesso",
"error": "Erro ao gerar link",
"copied": "Link copiado para a área de transferência"
"copied": "Link copiado para a área de transferência",
"readyDescription": "Seu link de compartilhamento está pronto. Você pode escanear o código QR diretamente, baixá-lo para uso posterior ou copiar o link abaixo.",
"tabs": {
"link": "Link",
"qrcode": "QR Code"
}
},
"home": {
"description": "A alternativa open-source ao WeTransfer. Compartilhe arquivos com segurança, sem rastreamento ou limitações.",
@@ -355,6 +438,12 @@
"stats": "{iconCount} ícones de {libraryCount} bibliotecas",
"categoryBadge": "{category} ({count} ícones)"
},
"imageEdit": {
"title": "Editar imagem",
"rotate": "Girar",
"zoom": "Ampliar",
"cropInstructions": "Arraste para reposicionar, redimensione os cantos para ajustar a área de recorte"
},
"login": {
"welcome": "Bem-vindo ao",
"signInToContinue": "Faça login para continuar",
@@ -395,11 +484,30 @@
"profile": "Perfil",
"settings": "Configurações",
"usersManagement": "Gerenciar usuários",
"logout": "Sair"
"logout": "Sair",
"customization": "Personalização"
},
"navigation": {
"dashboard": "Painel"
},
"notifications": {
"permissionGranted": "Notificações de download ativadas",
"permissionDenied": "Notificações de download desativadas",
"downloadComplete": {
"title": "Download Concluído",
"body": "{fileName} terminou de baixar"
},
"downloadFailed": {
"title": "Download Falhou",
"body": "Falha ao baixar {fileName}: {error}",
"unknownError": "Erro desconhecido"
},
"queueProcessing": {
"title": "Download Iniciando",
"body": "{fileName} está sendo baixado agora{position}",
"position": " (estava na posição #{position} da fila)"
}
},
"profile": {
"password": {
"title": "Alterar Senha",
@@ -442,6 +550,11 @@
},
"pageTitle": "Perfil"
},
"qrCodeModal": {
"title": "Compartilhar QR Code",
"description": "Escaneie este código QR para acessar o link.",
"download": "Baixar QR Code"
},
"quickAccess": {
"files": {
"title": "Meus Arquivos",
@@ -613,7 +726,8 @@
"createLink": "Criar Link",
"delete": "Excluir",
"copyLinkTitle": "Copiar link",
"createLinkCTA": "Criar Link de Recebimento"
"createLinkCTA": "Criar Link de Recebimento",
"viewQrCode": "Visualizar QR Code"
},
"status": {
"active": "Ativo",
@@ -629,7 +743,8 @@
"viewDetails": "Ver Detalhes",
"edit": "Editar",
"delete": "Excluir",
"viewFiles": "Arquivos Recebidos"
"viewFiles": "Arquivos Recebidos",
"viewQrCode": "Ver QR Code"
},
"empty": {
"title": "Nenhum link de recebimento criado",
@@ -736,6 +851,7 @@
"size": "Tamanho",
"sender": "Enviado por",
"date": "Data",
"invalidDate": "Data inválida",
"actions": "Ações"
},
"actions": {
@@ -775,7 +891,8 @@
"timeout": "A operação de cópia expirou. Por favor, tente novamente com um arquivo menor ou verifique sua conexão.",
"failed": "A operação de cópia falhou. Por favor, tente novamente.",
"aborted": "A operação de cópia foi cancelada devido ao tempo limite."
}
},
"invalidDate": "Data inválida"
}
},
"form": {
@@ -1123,6 +1240,10 @@
"smtpTrustSelfSigned": {
"title": "Confiar em Certificados Auto-Assinados",
"description": "Ative isso para confiar em certificados SSL/TLS auto-assinados (útil para ambientes de desenvolvimento)"
},
"passwordAuthEnabled": {
"title": "Autenticação por Senha",
"description": "Ative ou desative a autenticação baseada em senha"
}
},
"buttons": {
@@ -1132,7 +1253,8 @@
},
"errors": {
"loadFailed": "Falha ao carregar configurações",
"updateFailed": "Falha ao atualizar configurações"
"updateFailed": "Falha ao atualizar configurações",
"passwordAuthRequiresProvider": "Não é possível desabilitar a autenticação por senha sem ter pelo menos um provedor de autenticação ativo"
},
"messages": {
"noChanges": "Nenhuma alteração para salvar",
@@ -1180,6 +1302,7 @@
"shareActions": {
"deleteTitle": "Excluir Compartilhamento",
"deleteConfirmation": "Tem certeza que deseja excluir este compartilhamento? Esta ação não pode ser desfeita.",
"addDescriptionPlaceholder": "Adicionar descrição...",
"bulkDeleteTitle": "Excluir Compartilhamentos Selecionados",
"bulkDeleteConfirmation": "Tem certeza que deseja excluir {count, plural, =1 {1 compartilhamento} other {# compartilhamentos}} selecionado(s)? Esta ação não pode ser desfeita.",
"editTitle": "Editar Compartilhamento",
@@ -1230,7 +1353,10 @@
"invalidDate": "Data inválida",
"loadError": "Falha ao carregar detalhes do compartilhamento",
"editSecurity": "Editar Segurança",
"editExpiration": "Editar Expiração"
"editExpiration": "Editar Expiração",
"clickToEnlargeQrCode": "Clique para ampliar o QR Code",
"downloadQrCode": "Baixar QR Code",
"qrCode": "QR Code"
},
"shareExpiration": {
"neverExpires": "Nunca Expira",
@@ -1425,7 +1551,8 @@
"copyLink": "Copiar Link",
"notifyRecipients": "Notificar Destinatários",
"delete": "Excluir",
"downloadShareFiles": "Baixar todos os arquivos"
"downloadShareFiles": "Baixar todos os arquivos",
"viewQrCode": "Visualizar QR Code"
}
},
"storageUsage": {
@@ -1634,7 +1761,8 @@
"continue": "Continuar Uploads",
"cancel": "Cancelar Uploads"
},
"pasteSuccess": "{count, plural, =1 {Imagem colada e enviada com sucesso} other {# imagens coladas e enviadas com sucesso}}"
"pasteSuccess": "{count, plural, =1 {Imagem colada e enviada com sucesso} other {# imagens coladas e enviadas com sucesso}}",
"filesQueued": "{count, plural, one {# arquivo na fila para upload} other {# arquivos na fila para upload}}"
},
"users": {
"modes": {
@@ -1719,11 +1847,5 @@
"lastNameRequired": "O sobrenome é necessário",
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
"usernameSpaces": "O nome de usuário não pode conter espaços"
},
"imageEdit": {
"title": "Editar imagem",
"rotate": "Girar",
"zoom": "Ampliar",
"cropInstructions": "Arraste para reposicionar, redimensione os cantos para ajustar a área de recorte"
}
}
}

View File

@@ -159,7 +159,48 @@
"success": "Общий доступ успешно создан",
"error": "Не удалось создать общий доступ",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Введите описание (опционально)"
"descriptionPlaceholder": "Введите описание (опционально)",
"namePlaceholder": "Введите имя для вашего общего доступа"
},
"customization": {
"breadcrumb": "Настройка",
"colors": {
"title": "Цвета темы",
"description": "Выберите предпочтительный основной цвет темы",
"presets": "Доступные цвета",
"presetsDescription": "Выберите из доступных цветовых тем",
"reset": "Сбросить по умолчанию"
},
"fonts": {
"title": "Типография",
"description": "Выберите предпочтительное семейство шрифтов",
"available": "Доступные шрифты",
"availableDescription": "Выберите из доступных семейств шрифтов",
"reset": "Сбросить по умолчанию"
},
"radius": {
"title": "Радиус границ",
"description": "Настройте закругление элементов интерфейса",
"available": "Варианты закругления",
"availableDescription": "Выберите, как должны выглядеть закругленные углы",
"reset": "Сбросить по умолчанию"
},
"background": {
"title": "Цвета фона",
"description": "Настройте цвета фона для светлого и темного режимов",
"lightMode": "Светлый режим",
"darkMode": "Темный режим",
"availableDescription": "Выберите цвета фона для светлой и темной тем",
"reset": "Сбросить по умолчанию"
},
"theme": {
"title": "Режим темы",
"description": "Выберите между светлой, темной или системной темой",
"selectTheme": "Предпочтительная тема",
"availableDescription": "Выберите предпочтительный режим темы",
"reset": "Сбросить на системную"
},
"pageTitle": "Настройка"
},
"dashboard": {
"loadError": "Ошибка загрузки данных панели управления",
@@ -175,6 +216,39 @@
"filesToDelete": "Файлы для удаления",
"sharesToDelete": "Общие папки, которые будут удалены"
},
"downloadQueue": {
"downloadQueued": "Загрузка в очереди: {fileName}",
"queuedDescription": "Ваша загрузка начнется автоматически, когда освободится слот",
"queuePosition": "Загрузка в очереди на позиции {position}: {fileName}",
"estimatedWait": "Расчетное время ожидания: {time}",
"queueFull": "Очередь загрузки заполнена",
"queueFullDescription": "Пожалуйста, повторите попытку через несколько минут, когда в очереди появится место",
"cancelSuccess": "Загрузка успешно отменена",
"cancelError": "Не удалось отменить загрузку: {error}",
"status": {
"pending": "Подготовка...",
"queued": "В очереди",
"downloading": "Загрузка",
"completed": "Завершено",
"failed": "Не удалось"
},
"waitTime": {
"seconds": "{seconds} сек",
"minutes": "{minutes} мин",
"hoursMinutes": "{hours}ч {minutes}мин"
},
"indicator": {
"title": "Загрузки",
"downloads": "Очередь загрузки",
"active": "Активные",
"queued": "В очереди",
"position": "Позиция {position}",
"estimatedWait": "Ожидание: {time}",
"unknownFile": "Неизвестный файл",
"noDownloads": "Нет активных загрузок",
"refresh": "Обновить очередь"
}
},
"emptyState": {
"noFiles": "Файлы еще не загружены",
"uploadFile": "Загрузить файл"
@@ -202,7 +276,8 @@
"descriptionPlaceholder": "Введите описание файла",
"deleteFile": "Удалить файл",
"deleteConfirmation": "Вы уверены, что хотите удалить ?",
"deleteWarning": "Это действие необратимо."
"deleteWarning": "Это действие необратимо.",
"addDescriptionPlaceholder": "Добавить описание..."
},
"fileManager": {
"downloadError": "Ошибка скачивания файла",
@@ -270,7 +345,9 @@
"viewMode": {
"table": "Таблица",
"grid": "Сетка"
}
},
"bulkDeleteConfirmation": "Вы уверены, что хотите удалить {count, plural, =1 {1 файл} other {# файлов}}? Это действие нельзя отменить.",
"bulkDeleteTitle": "Удалить Выбранные Файлы"
},
"filesTable": {
"ariaLabel": "Таблица файлов",
@@ -313,7 +390,8 @@
"title": "Забыли пароль",
"description": "Введите адрес электронной почты, и мы отправим вам инструкции по сбросу пароля.",
"resetInstructions": "Инструкции по сбросу отправлены на вашу электронную почту",
"pageTitle": "Забыли пароль"
"pageTitle": "Забыли пароль",
"passwordAuthDisabled": "Парольная аутентификация отключена. Пожалуйста, свяжитесь с администратором или используйте внешний провайдер аутентификации."
},
"generateShareLink": {
"generateTitle": "Создать ссылку для обмена",
@@ -327,7 +405,12 @@
"copyButton": "Копировать ссылку",
"success": "Ссылка успешно создана",
"error": "Ошибка при создании ссылки",
"copied": "Ссылка скопирована в буфер обмена"
"copied": "Ссылка скопирована в буфер обмена",
"readyDescription": "Ваша ссылка для обмена готова. Вы можете сканировать QR-код напрямую, скачать его для последующего использования или скопировать ссылку ниже.",
"tabs": {
"link": "Ссылка",
"qrcode": "QR-код"
}
},
"home": {
"description": "Открытая альтернатива WeTransfer. Делитесь файлами безопасно, без отслеживания и ограничений.",
@@ -355,6 +438,12 @@
"stats": "{iconCount} иконок из {libraryCount} библиотек",
"categoryBadge": "{category} ({count} иконок)"
},
"imageEdit": {
"title": "Редактировать изображение",
"rotate": "Повернуть",
"zoom": "Увеличить",
"cropInstructions": "Перетащите, чтобы переместить, измените размер углов, чтобы отрегулировать область обрезки"
},
"login": {
"welcome": "Добро пожаловать в",
"signInToContinue": "Войдите, чтобы продолжить",
@@ -395,11 +484,30 @@
"profile": "Профиль",
"settings": "Настройки",
"usersManagement": "Управление пользователями",
"logout": "Выйти"
"logout": "Выйти",
"customization": "Настройки"
},
"navigation": {
"dashboard": "Панель управления"
},
"notifications": {
"permissionGranted": "Уведомления о загрузках включены",
"permissionDenied": "Уведомления о загрузках отключены",
"downloadComplete": {
"title": "Загрузка завершена",
"body": "Файл {fileName} успешно загружен"
},
"downloadFailed": {
"title": "Ошибка загрузки",
"body": "Не удалось загрузить {fileName}: {error}",
"unknownError": "Неизвестная ошибка"
},
"queueProcessing": {
"title": "Начало загрузки",
"body": "Файл {fileName} загружается{position}",
"position": " (был №{position} в очереди)"
}
},
"profile": {
"password": {
"title": "Изменить пароль",
@@ -442,6 +550,11 @@
},
"pageTitle": "Профиль"
},
"qrCodeModal": {
"title": "Поделиться QR-кодом",
"description": "Отсканируйте этот QR-код, чтобы получить доступ к ссылке.",
"download": "Скачать QR-код"
},
"quickAccess": {
"files": {
"title": "Мои файлы",
@@ -613,7 +726,8 @@
"createLink": "Создать ссылку",
"delete": "Удалить",
"copyLinkTitle": "Копировать ссылку",
"createLinkCTA": "Создать ссылку для получения"
"createLinkCTA": "Создать ссылку для получения",
"viewQrCode": "Просмотр QR-кода"
},
"status": {
"active": "Активно",
@@ -629,7 +743,8 @@
"viewDetails": "Просмотр деталей",
"edit": "Редактировать",
"delete": "Удалить",
"viewFiles": "Полученные файлы"
"viewFiles": "Полученные файлы",
"viewQrCode": "Просмотр QR-кода"
},
"empty": {
"title": "Нет созданных ссылок для получения",
@@ -775,7 +890,8 @@
"timeout": "Время операции копирования истекло. Пожалуйста, попробуйте еще раз с файлом меньшего размера или проверьте подключение.",
"failed": "Ошибка операции копирования. Пожалуйста, попробуйте еще раз.",
"aborted": "Операция копирования была отменена из-за истечения времени ожидания."
}
},
"invalidDate": "Неверная дата"
}
},
"form": {
@@ -1115,6 +1231,10 @@
"smtpTrustSelfSigned": {
"title": "Доверять самоподписанным сертификатам",
"description": "Включите это для доверия самоподписанным SSL/TLS сертификатам (полезно для сред разработки)"
},
"passwordAuthEnabled": {
"title": "Парольная аутентификация",
"description": "Включить или отключить парольную аутентификацию"
}
},
"buttons": {
@@ -1124,7 +1244,8 @@
},
"errors": {
"loadFailed": "Ошибка загрузки настроек",
"updateFailed": "Ошибка обновления настроек"
"updateFailed": "Ошибка обновления настроек",
"passwordAuthRequiresProvider": "Парольную аутентификацию нельзя отключить, если нет хотя бы одного активного поставщика аутентификации"
},
"messages": {
"noChanges": "Изменений для сохранения нет",
@@ -1198,7 +1319,8 @@
"editSuccess": "Общий доступ успешно обновлен",
"editError": "Ошибка обновления общего доступа",
"bulkDeleteConfirmation": "Вы уверены, что хотите удалить {count, plural, =1 {1 общую папку} other {# общих папок}}? Это действие нельзя отменить.",
"bulkDeleteTitle": "Удалить Выбранные Общие Папки"
"bulkDeleteTitle": "Удалить Выбранные Общие Папки",
"addDescriptionPlaceholder": "Добавить описание..."
},
"shareDetails": {
"title": "Детали Общего Доступа",
@@ -1230,7 +1352,10 @@
"invalidDate": "Неверная дата",
"loadError": "Ошибка загрузки деталей общего доступа",
"editSecurity": "Изменить безопасность",
"editExpiration": "Изменить срок действия"
"editExpiration": "Изменить срок действия",
"clickToEnlargeQrCode": "Нажмите, чтобы увеличить QR-код",
"downloadQrCode": "Скачать QR-код",
"qrCode": "QR-код"
},
"shareExpiration": {
"neverExpires": "Никогда не истекает",
@@ -1417,7 +1542,8 @@
"copyLink": "Скопировать Ссылку",
"notifyRecipients": "Уведомить Получателей",
"delete": "Удалить",
"downloadShareFiles": "Загрузите все файлы"
"downloadShareFiles": "Загрузите все файлы",
"viewQrCode": "Просмотр QR-кода"
},
"bulkActions": {
"delete": "Удалить",
@@ -1634,7 +1760,8 @@
"title": "Перетащите файлы для загрузки",
"description": "Отпустите, чтобы загрузить файлы"
},
"pasteSuccess": "{count, plural, =1 {Изображение вставлено и успешно загружено} other {# изображений вставлено и успешно загружено}}"
"pasteSuccess": "{count, plural, =1 {Изображение вставлено и успешно загружено} other {# изображений вставлено и успешно загружено}}",
"filesQueued": "{count, plural, one {# файл в очереди для загрузки} other {# файлов в очереди для загрузки}}"
},
"users": {
"modes": {
@@ -1719,11 +1846,5 @@
"passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
},
"imageEdit": {
"title": "Редактировать изображение",
"rotate": "Повернуть",
"zoom": "Увеличить",
"cropInstructions": "Перетащите, чтобы переместить, измените размер углов, чтобы отрегулировать область обрезки"
}
}
}

View File

@@ -159,7 +159,48 @@
"success": "Paylaşım başarıyla oluşturuldu",
"error": "Paylaşım oluşturulamadı",
"descriptionLabel": "Açıklama",
"descriptionPlaceholder": "Açıklama girin (isteğe bağlı)"
"descriptionPlaceholder": "Açıklama girin (isteğe bağlı)",
"namePlaceholder": "Paylaşımınız için bir ad girin"
},
"customization": {
"breadcrumb": "Özelleştirme",
"colors": {
"title": "Tema Renkleri",
"description": "Tercih ettiğiniz birincil renk temasını seçin",
"presets": "Mevcut Renkler",
"presetsDescription": "Mevcut renk temalarından seçim yapın",
"reset": "Varsayılana Sıfırla"
},
"fonts": {
"title": "Tipografi",
"description": "Tercih ettiğiniz yazı tipini seçin",
"available": "Mevcut Yazı Tipleri",
"availableDescription": "Mevcut yazı tipi ailelerinden seçim yapın",
"reset": "Varsayılana Sıfırla"
},
"radius": {
"title": "Kenar Yuvarlaklığı",
"description": "Arayüz öğelerinin yuvarlaklığını özelleştirin",
"available": "Yuvarlaklık Seçenekleri",
"availableDescription": "Köşelerin ne kadar yuvarlak görüneceğini seçin",
"reset": "Varsayılana Sıfırla"
},
"background": {
"title": "Arka Plan Renkleri",
"description": "Açık ve karanlık modlar için arka plan renklerini özelleştirin",
"lightMode": "Açık Mod",
"darkMode": "Karanlık Mod",
"availableDescription": "Hem açık hem de karanlık temalar için arka plan renklerini seçin",
"reset": "Varsayılana Sıfırla"
},
"theme": {
"title": "Tema Modu",
"description": "Açık, karanlık veya sistem teması arasından seçim yapın",
"selectTheme": "Tema Tercihi",
"availableDescription": "Tercih ettiğiniz tema modunu seçin",
"reset": "Sisteme Sıfırla"
},
"pageTitle": "Özelleştirme"
},
"dashboard": {
"loadError": "Gösterge paneli verileri yüklenemedi",
@@ -175,6 +216,39 @@
"filesToDelete": "Silinecek dosyalar",
"sharesToDelete": "Silinecek paylaşımlar"
},
"downloadQueue": {
"downloadQueued": "İndirme sıraya alındı: {fileName}",
"queuedDescription": "Bir slot uygun hale geldiğinde indirmeniz otomatik olarak başlayacak",
"queuePosition": "{position}. sırada indirme: {fileName}",
"estimatedWait": "Tahmini bekleme süresi: {time}",
"queueFull": "İndirme kuyruğu dolu",
"queueFullDescription": "Lütfen kuyrukta yer açıldığında birkaç dakika sonra tekrar deneyin",
"cancelSuccess": "İndirme başarıyla iptal edildi",
"cancelError": "İndirme iptal edilemedi: {error}",
"status": {
"pending": "Hazırlanıyor...",
"queued": "Kuyrukta",
"downloading": "İndiriliyor",
"completed": "Tamamlandı",
"failed": "Başarısız"
},
"waitTime": {
"seconds": "{seconds}sn",
"minutes": "{minutes}dk",
"hoursMinutes": "{hours}sa {minutes}dk"
},
"indicator": {
"title": "İndirmeler",
"downloads": "İndirme Kuyruğu",
"active": "Aktif",
"queued": "Sırada",
"position": "Konum {position}",
"estimatedWait": "Bekleme: {time}",
"unknownFile": "Bilinmeyen dosya",
"noDownloads": "Devam eden indirme yok",
"refresh": "Kuyruğu Yenile"
}
},
"emptyState": {
"noFiles": "Henüz dosya yüklenmedi",
"uploadFile": "Dosya Yükle"
@@ -202,7 +276,8 @@
"descriptionPlaceholder": "Dosya açıklamasını girin",
"deleteFile": "Dosyayı Sil",
"deleteConfirmation": " dosyasını silmek istediğinize emin misiniz?",
"deleteWarning": "Bu işlem geri alınamaz."
"deleteWarning": "Bu işlem geri alınamaz.",
"addDescriptionPlaceholder": "Açıklama ekle..."
},
"fileManager": {
"downloadError": "Dosya indirilemedi",
@@ -270,7 +345,9 @@
"viewMode": {
"table": "Tablo",
"grid": "Izgara"
}
},
"bulkDeleteConfirmation": "{count, plural, =1 {1 dosyayı} other {# dosyayı}} silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"bulkDeleteTitle": "Seçili Dosyaları Sil"
},
"filesTable": {
"ariaLabel": "Dosya Tablosu",
@@ -313,7 +390,8 @@
"title": "Şifrenizi mi Unuttunuz?",
"description": "E-posta adresinizi girin, şifre sıfırlama talimatlarını göndereceğiz.",
"resetInstructions": "Şifre sıfırlama talimatları e-posta adresinize gönderildi",
"pageTitle": "Şifrenizi mi Unuttunuz?"
"pageTitle": "Şifrenizi mi Unuttunuz?",
"passwordAuthDisabled": "Şifre doğrulama devre dışı. Lütfen yöneticinize başvurun veya dış doğrulama sağlayıcısı kullanın."
},
"generateShareLink": {
"generateTitle": "Paylaşım Bağlantısı Oluştur",
@@ -327,7 +405,12 @@
"copyButton": "Bağlantıyı Kopyala",
"success": "Bağlantı başarıyla oluşturuldu",
"error": "Bağlantı oluşturulamadı",
"copied": "Bağlantı panoya kopyalandı"
"copied": "Bağlantı panoya kopyalandı",
"readyDescription": "Paylaşım bağlantınız hazır. QR kodu doğrudan tarayabilir, daha sonra kullanmak için indirebilir veya aşağıdaki bağlantıyı kopyalayabilirsiniz.",
"tabs": {
"link": "Bağlantı",
"qrcode": "QR Kodu"
}
},
"home": {
"description": "WeTransfer'e açık kaynaklı alternatif. Takip veya kısıtlama olmadan dosyalarınızı güvenle paylaşın.",
@@ -355,6 +438,12 @@
"stats": "{libraryCount} kütüphaneden {iconCount} simge",
"categoryBadge": "{category} ({count} simge)"
},
"imageEdit": {
"title": "Resmi Düzenle",
"rotate": "Döndür",
"zoom": "Yakınlaştır",
"cropInstructions": "Yerleştirmek için sürükleyin, kırpma alanını ayarlamak için köşeleri yeniden boyutlandırın"
},
"login": {
"welcome": "Hoş geldiniz'e",
"signInToContinue": "Devam etmek için oturum açın",
@@ -395,11 +484,30 @@
"profile": "Profil",
"settings": "Ayarlar",
"usersManagement": "Kullanıcı Yönetimi",
"logout": "Oturumu Kapat"
"logout": "Oturumu Kapat",
"customization": "Özelleştirme"
},
"navigation": {
"dashboard": "Gösterge Paneli"
},
"notifications": {
"permissionGranted": "İndirme bildirimleri etkinleştirildi",
"permissionDenied": "İndirme bildirimleri devre dışı bırakıldı",
"downloadComplete": {
"title": "İndirme Tamamlandı",
"body": "{fileName} indirmesi tamamlandı"
},
"downloadFailed": {
"title": "İndirme Başarısız",
"body": "{fileName} indirilemedi: {error}",
"unknownError": "Bilinmeyen hata"
},
"queueProcessing": {
"title": "İndirme Başlıyor",
"body": "{fileName} şimdi indiriliyor{position}",
"position": " (kuyrukta #{position} sıradaydı)"
}
},
"profile": {
"password": {
"title": "Şifreyi Değiştir",
@@ -442,6 +550,11 @@
},
"pageTitle": "Profil"
},
"qrCodeModal": {
"title": "QR Kodu Paylaş",
"description": "Bu QR kodu tarayarak bağlantıya erişebilirsiniz.",
"download": "QR Kodu İndir"
},
"quickAccess": {
"files": {
"title": "Benim Dosyalarım",
@@ -613,7 +726,8 @@
"createLink": "Bağlantı Oluştur",
"delete": "Sil",
"copyLinkTitle": "Bağlantıyı kopyala",
"createLinkCTA": "Alma Bağlantısı Oluştur"
"createLinkCTA": "Alma Bağlantısı Oluştur",
"viewQrCode": "QR Kodu Görüntüle"
},
"status": {
"active": "Aktif",
@@ -629,7 +743,8 @@
"viewDetails": "Detayları Görüntüle",
"edit": "Düzenle",
"delete": "Sil",
"viewFiles": "Alınan Dosyalar"
"viewFiles": "Alınan Dosyalar",
"viewQrCode": "QR Kodu Görüntüle"
},
"empty": {
"title": "Alma bağlantısı oluşturulmadı",
@@ -775,7 +890,8 @@
"timeout": "Kopyalama işlemi zaman aşımına uğradı. Lütfen daha küçük bir dosya ile tekrar deneyin veya bağlantınızı kontrol edin.",
"failed": "Kopyalama işlemi başarısız oldu. Lütfen tekrar deneyin.",
"aborted": "Kopyalama işlemi zaman aşımı nedeniyle iptal edildi."
}
},
"invalidDate": "Geçersiz tarih"
}
},
"form": {
@@ -1115,6 +1231,10 @@
"smtpTrustSelfSigned": {
"title": "Kendinden İmzalı Sertifikalara Güven",
"description": "Kendinden imzalı SSL/TLS sertifikalarına güvenmek için bunu etkinleştirin (geliştirme ortamları için kullanışlıdır)"
},
"passwordAuthEnabled": {
"title": "Şifre Doğrulama",
"description": "Şifre tabanlı doğrulamayı etkinleştirme veya devre dışı bırakma"
}
},
"buttons": {
@@ -1124,7 +1244,8 @@
},
"errors": {
"loadFailed": "Ayarlar yüklenemedi",
"updateFailed": "Ayarlar güncellenemedi"
"updateFailed": "Ayarlar güncellenemedi",
"passwordAuthRequiresProvider": "En az bir aktif kimlik doğrulama sağlayıcısı olmadan şifre doğrulaması devre dışı bırakılamaz"
},
"messages": {
"noChanges": "Kaydedilecek değişiklik yok",
@@ -1198,7 +1319,8 @@
"editSuccess": "Paylaşım başarıyla güncellendi",
"editError": "Paylaşım güncelleme başarısız",
"bulkDeleteConfirmation": "{count, plural, =1 {1 paylaşımı} other {# paylaşımı}} silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"bulkDeleteTitle": "Seçili Paylaşımları Sil"
"bulkDeleteTitle": "Seçili Paylaşımları Sil",
"addDescriptionPlaceholder": "Açıklama ekle..."
},
"shareDetails": {
"title": "Paylaşım Detayları",
@@ -1230,7 +1352,10 @@
"invalidDate": "Geçersiz tarih",
"loadError": "Paylaşım detaylarını yükleme başarısız",
"editSecurity": "Güvenlik Düzenle",
"editExpiration": "Son Kullanma Düzenle"
"editExpiration": "Son Kullanma Düzenle",
"clickToEnlargeQrCode": "QR Kodu Büyüt",
"downloadQrCode": "QR Kodu İndir",
"qrCode": "QR Kodu"
},
"shareExpiration": {
"neverExpires": "Asla Sona Ermez",
@@ -1417,7 +1542,8 @@
"copyLink": "Bağlantıyı Kopyala",
"notifyRecipients": "Alıcıları Bilgilendir",
"delete": "Sil",
"downloadShareFiles": "Tüm dosyaları indirin"
"downloadShareFiles": "Tüm dosyaları indirin",
"viewQrCode": "QR Kodu Görüntüle"
},
"bulkActions": {
"delete": "Sil",
@@ -1634,7 +1760,8 @@
"title": "Yüklemek için dosyaları bırakın",
"description": "Dosyalarınızı yüklemek için bırakın"
},
"pasteSuccess": "{count, plural, =1 {Görüntü yapıştırıldı ve başarıyla yüklendi} other {# görüntü yapıştırıldı ve başarıyla yüklendi}}"
"pasteSuccess": "{count, plural, =1 {Görüntü yapıştırıldı ve başarıyla yüklendi} other {# görüntü yapıştırıldı ve başarıyla yüklendi}}",
"filesQueued": "{count, plural, one {# dosya yükleme için bekliyor} other {# dosya yükleme için bekliyor}}"
},
"users": {
"modes": {
@@ -1719,11 +1846,5 @@
"passwordRequired": "Şifre gerekli",
"nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur"
},
"imageEdit": {
"title": "Resmi Düzenle",
"rotate": "Döndür",
"zoom": "Yakınlaştır",
"cropInstructions": "Yerleştirmek için sürükleyin, kırpma alanını ayarlamak için köşeleri yeniden boyutlandırın"
}
}
}

View File

@@ -159,7 +159,48 @@
"passwordLabel": "密码",
"create": "创建分享",
"success": "分享创建成功",
"error": "创建分享失败"
"error": "创建分享失败",
"namePlaceholder": "输入分享名称"
},
"customization": {
"breadcrumb": "自定义",
"colors": {
"title": "主题颜色",
"description": "选择您喜欢的主题颜色",
"presets": "可用颜色",
"presetsDescription": "从可用的颜色主题中选择",
"reset": "恢复默认值"
},
"fonts": {
"title": "字体",
"description": "选择您喜欢的字体系列",
"available": "可用字体",
"availableDescription": "从可用的字体系列中选择",
"reset": "恢复默认值"
},
"radius": {
"title": "边框圆角",
"description": "自定义界面元素的圆角程度",
"available": "圆角选项",
"availableDescription": "选择圆角的显示方式",
"reset": "恢复默认值"
},
"background": {
"title": "背景颜色",
"description": "自定义明暗模式的背景颜色",
"lightMode": "明亮模式",
"darkMode": "暗黑模式",
"availableDescription": "为明暗主题选择背景颜色",
"reset": "恢复默认值"
},
"theme": {
"title": "主题模式",
"description": "在明亮、暗黑或系统主题之间选择",
"selectTheme": "主题偏好",
"availableDescription": "选择您喜欢的主题模式",
"reset": "恢复系统设置"
},
"pageTitle": "自定义"
},
"dashboard": {
"loadError": "加载仪表盘数据失败",
@@ -175,6 +216,39 @@
"filesToDelete": "要删除的文件",
"sharesToDelete": "将被删除的共享"
},
"downloadQueue": {
"downloadQueued": "已加入下载队列:{fileName}",
"queuedDescription": "当有空闲位置时,您的下载将自动开始",
"queuePosition": "在队列位置 {position} 等待下载:{fileName}",
"estimatedWait": "预计等待时间:{time}",
"queueFull": "下载队列已满",
"queueFullDescription": "请等待几分钟后当队列有空位时再试",
"cancelSuccess": "下载已成功取消",
"cancelError": "取消下载失败:{error}",
"status": {
"pending": "准备中...",
"queued": "排队中",
"downloading": "下载中",
"completed": "已完成",
"failed": "失败"
},
"waitTime": {
"seconds": "{seconds}秒",
"minutes": "{minutes}分",
"hoursMinutes": "{hours}小时 {minutes}分"
},
"indicator": {
"title": "下载",
"downloads": "下载队列",
"active": "进行中",
"queued": "排队中",
"position": "位置 {position}",
"estimatedWait": "等待:{time}",
"unknownFile": "未知文件",
"noDownloads": "当前没有下载任务",
"refresh": "刷新队列"
}
},
"emptyState": {
"noFiles": "尚未上传任何文件",
"uploadFile": "上传文件"
@@ -202,7 +276,8 @@
"descriptionPlaceholder": "请输入文件描述",
"deleteFile": "删除文件",
"deleteConfirmation": "您确定要删除{fileName}吗?",
"deleteWarning": "此操作不可撤销。"
"deleteWarning": "此操作不可撤销。",
"addDescriptionPlaceholder": "添加描述..."
},
"fileManager": {
"downloadError": "文件下载失败",
@@ -270,7 +345,9 @@
"table": "表格",
"grid": "网格"
},
"totalFiles": "{count, plural, =0 {无文件} =1 {1个文件} other {#个文件}}"
"totalFiles": "{count, plural, =0 {无文件} =1 {1个文件} other {#个文件}}",
"bulkDeleteConfirmation": "您确定要删除 {count, plural, =1 {1 个文件} other {# 个文件}}吗?此操作无法撤销。",
"bulkDeleteTitle": "删除所选文件"
},
"filesTable": {
"ariaLabel": "文件表格",
@@ -313,7 +390,8 @@
"title": "忘记密码?",
"description": "请输入您的电子邮件,我们将发送密码重置指令给您。",
"resetInstructions": "密码重置指令已发送到您的电子邮件",
"pageTitle": "忘记密码?"
"pageTitle": "忘记密码?",
"passwordAuthDisabled": "密码认证已禁用。请联系您的管理员或使用外部认证提供商。"
},
"generateShareLink": {
"generateTitle": "生成分享链接",
@@ -327,7 +405,12 @@
"copyButton": "复制链接",
"success": "链接生成成功",
"error": "链接生成失败",
"copied": "链接已复制到剪贴板"
"copied": "链接已复制到剪贴板",
"readyDescription": "您的分享链接已准备就绪:",
"tabs": {
"link": "链接",
"qrcode": "QR Code"
}
},
"home": {
"description": "WeTransfer的开源替代方案。安全分享文件无需跟踪或限制。",
@@ -355,6 +438,12 @@
"stats": "来自 {libraryCount} 个库的 {iconCount} 个图标",
"categoryBadge": "{category}{count} 个图标)"
},
"imageEdit": {
"title": "编辑图片",
"rotate": "旋转",
"zoom": "缩放",
"cropInstructions": "拖动以重新定位,调整角落大小以调整裁剪区域"
},
"login": {
"welcome": "欢迎您",
"signInToContinue": "请登录以继续",
@@ -395,11 +484,30 @@
"profile": "个人资料",
"settings": "设置",
"usersManagement": "用户管理",
"logout": "退出登录"
"logout": "退出登录",
"customization": "自定义"
},
"navigation": {
"dashboard": "仪表盘"
},
"notifications": {
"permissionGranted": "下载通知已启用",
"permissionDenied": "下载通知已禁用",
"downloadComplete": {
"title": "下载完成",
"body": "{fileName} 已下载完成"
},
"downloadFailed": {
"title": "下载失败",
"body": "下载 {fileName} 失败:{error}",
"unknownError": "未知错误"
},
"queueProcessing": {
"title": "开始下载",
"body": "{fileName} 正在下载{position}",
"position": "(队列中第 {position} 位)"
}
},
"profile": {
"password": {
"title": "修改密码",
@@ -442,6 +550,11 @@
},
"pageTitle": "个人资料"
},
"qrCodeModal": {
"title": "分享QR Code",
"description": "扫描此QR Code以访问链接。",
"download": "下载QR Code"
},
"quickAccess": {
"files": {
"title": "我的文件",
@@ -613,7 +726,8 @@
"createLink": "创建链接",
"delete": "删除",
"copyLinkTitle": "复制链接",
"createLinkCTA": "创建接收链接"
"createLinkCTA": "创建接收链接",
"viewQrCode": "查看QR Code"
},
"status": {
"active": "活动",
@@ -629,7 +743,8 @@
"viewDetails": "查看详情",
"edit": "编辑",
"delete": "删除",
"viewFiles": "已接收文件"
"viewFiles": "已接收文件",
"viewQrCode": "查看QR Code"
},
"empty": {
"title": "未创建接收链接",
@@ -775,7 +890,8 @@
"timeout": "复制操作超时。请尝试使用较小的文件或检查您的连接。",
"failed": "复制操作失败。请重试。",
"aborted": "由于超时,复制操作已取消。"
}
},
"invalidDate": "无效日期"
}
},
"form": {
@@ -1115,6 +1231,10 @@
"smtpTrustSelfSigned": {
"title": "信任自签名证书",
"description": "启用此选项以信任自签名SSL/TLS证书对开发环境有用"
},
"passwordAuthEnabled": {
"title": "密码认证",
"description": "启用或禁用基于密码的认证"
}
},
"buttons": {
@@ -1124,7 +1244,8 @@
},
"errors": {
"loadFailed": "加载设置失败",
"updateFailed": "更新设置失败"
"updateFailed": "更新设置失败",
"passwordAuthRequiresProvider": "没有至少一个活动认证提供者时,无法禁用密码认证"
},
"messages": {
"noChanges": "没有需要保存的更改",
@@ -1198,7 +1319,8 @@
"descriptionPlaceholder": "输入描述(可选)",
"descriptionLabel": "描述",
"bulkDeleteConfirmation": "您确定要删除{count, plural, =1 {1个共享} other {#个共享}}吗?此操作无法撤销。",
"bulkDeleteTitle": "删除选中的共享"
"bulkDeleteTitle": "删除选中的共享",
"addDescriptionPlaceholder": "添加描述..."
},
"shareDetails": {
"title": "共享详情",
@@ -1230,7 +1352,10 @@
"description": "描述",
"linkCopied": "链接已复制到剪贴板",
"editSecurity": "编辑安全",
"editExpiration": "编辑过期"
"editExpiration": "编辑过期",
"clickToEnlargeQrCode": "点击放大QR Code",
"downloadQrCode": "下载QR Code",
"qrCode": "QR Code"
},
"shareExpiration": {
"neverExpires": "永不过期",
@@ -1417,7 +1542,8 @@
"copyLink": "复制链接",
"notifyRecipients": "通知收件人",
"delete": "删除",
"downloadShareFiles": "下载所有文件"
"downloadShareFiles": "下载所有文件",
"viewQrCode": "查看QR Code"
},
"bulkActions": {
"delete": "删除",
@@ -1483,11 +1609,7 @@
"copyToClipboard": "复制到剪贴板",
"savedMessage": "我已保存备用码",
"available": "可用备用码:{count}个",
"instructions": [
"• 将这些代码保存在安全的位置",
"• 每个备用码只能使用一次",
"• 您可以随时生成新的备用码"
]
"instructions": ["• 将这些代码保存在安全的位置", "• 每个备用码只能使用一次", "• 您可以随时生成新的备用码"]
},
"verification": {
"title": "双重认证",
@@ -1634,7 +1756,8 @@
"title": "拖放文件以上传",
"description": "松开以上传您的文件"
},
"pasteSuccess": "{count, plural, =1 {图片已成功粘贴并上传} other {# 张图片已成功粘贴并上传}}"
"pasteSuccess": "{count, plural, =1 {图片已成功粘贴并上传} other {# 张图片已成功粘贴并上传}}",
"filesQueued": "{count, plural, one {# 文件正在等待上传} other {# 文件正在等待上传}}"
},
"users": {
"modes": {
@@ -1719,11 +1842,5 @@
"passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
},
"imageEdit": {
"title": "编辑图片",
"rotate": "旋转",
"zoom": "缩放",
"cropInstructions": "拖动以重新定位,调整角落大小以调整裁剪区域"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-web",
"version": "v3.1.1-beta",
"version": "3.2.0-beta",
"description": "Frontend for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@@ -35,6 +35,7 @@
"@radix-ui/react-aspect-ratio": "^1.1.3",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
@@ -70,6 +71,7 @@
"react-hook-form": "^7.59.0",
"react-icons": "^5.5.0",
"react-image-crop": "^11.0.10",
"react-qr-code": "^2.0.18",
"react-qr-reader": "3.0.0-beta-1",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
@@ -98,4 +100,4 @@
"tailwindcss": "4.1.11",
"typescript": "5.8.3"
}
}
}

4766
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More