Compare commits

..

3 Commits

Author SHA1 Message Date
Daniel Luiz Alves
f3aeaf66df [RELEASE] v3.2.2-beta (#270)
Co-authored-by: Tommy Johnston <tommy@timmygstudios.com>
2025-09-25 15:08:35 -03:00
Daniel Luiz Alves
331624e2f2 UPDATE LICENCE (#252) 2025-09-09 18:28:33 -03:00
Daniel Luiz Alves
ba512ebe95 [RELEASE] v3.2.1-beta (#250) 2025-09-09 16:30:20 -03:00
157 changed files with 11451 additions and 28672 deletions

View File

@@ -1,259 +0,0 @@
# GitHub Copilot Instructions for Palmr
This file contains instructions for GitHub Copilot to help contributors work effectively with the Palmr codebase.
## Project Overview
Palmr is a flexible and open-source alternative to file transfer services like WeTransfer and SendGB. It's built with:
- **Backend**: Fastify (Node.js) with TypeScript, SQLite database, and filesystem/S3 storage
- **Frontend**: Next.js 15 + React + TypeScript + Shadcn/ui
- **Documentation**: Next.js + Fumadocs + MDX
- **Package Manager**: pnpm (v10.6.0)
- **Monorepo Structure**: Three main apps (web, server, docs) in the `apps/` directory
## Architecture and Structure
### Monorepo Layout
```
apps/
├── docs/ # Documentation site (Next.js + Fumadocs)
├── server/ # Backend API (Fastify + TypeScript)
└── web/ # Frontend application (Next.js 15)
```
### Key Technologies
- **TypeScript**: Primary language for all applications
- **Database**: Prisma ORM with SQLite (optional S3-compatible storage)
- **Authentication**: Multiple OAuth providers (Google, GitHub, Discord, etc.)
- **Internationalization**: Multi-language support with translation scripts
- **Validation**: Husky pre-push hooks for linting and type checking
## Development Workflow
### Base Branch
Always create new branches from and submit PRs to the `next` branch, not `main`.
### Commit Convention
Use Conventional Commits format for all commits:
```
<type>(<scope>): <description>
Types:
- feat: New feature
- fix: Bug fix
- docs: Documentation changes
- test: Adding or updating tests
- refactor: Code refactoring
- style: Code formatting
- chore: Maintenance tasks
```
Examples:
- `feat(web): add user authentication system`
- `fix(api): resolve null pointer exception in user service`
- `docs: update installation instructions in README`
- `test(server): add unit tests for user validation`
### Code Quality Standards
1. **Linting**: All apps use ESLint. Run `pnpm lint` before committing
2. **Formatting**: Use Prettier for code formatting. Run `pnpm format`
3. **Type Checking**: Run `pnpm type-check` to validate TypeScript
4. **Validation**: Run `pnpm validate` to run both linting and type checking
5. **Pre-push Hook**: Automatically validates all apps before pushing
### Testing Changes
- Test incrementally during development
- Run validation locally before pushing: `pnpm validate` in each app directory
- Keep changes focused on a single issue or feature
- Review your work before committing
## Application-Specific Guidelines
### Web App (`apps/web/`)
- Framework: Next.js 15 with App Router
- Port: 3000 (development)
- Scripts:
- `pnpm dev`: Start development server
- `pnpm build`: Build for production
- `pnpm validate`: Run linting and type checking
- Translations: Use Python scripts in `scripts/` directory
- `pnpm translations:check`: Check translation status
- `pnpm translations:sync`: Synchronize translations
### Server App (`apps/server/`)
- Framework: Fastify with TypeScript
- Port: 3333 (default)
- Scripts:
- `pnpm dev`: Start development server with watch mode
- `pnpm build`: Build TypeScript to JavaScript
- `pnpm validate`: Run linting and type checking
- `pnpm db:seed`: Seed database
- Database: Prisma ORM with SQLite
### Docs App (`apps/docs/`)
- Framework: Next.js with Fumadocs
- Port: 3001 (development)
- Content: MDX files in `content/docs/`
- Scripts:
- `pnpm dev`: Start development server
- `pnpm build`: Build documentation site
- `pnpm validate`: Run linting and type checking
## Code Style and Best Practices
### General Guidelines
1. **Follow Style Guidelines**: Ensure code adheres to ESLint and Prettier configurations
2. **TypeScript First**: Always use TypeScript, avoid `any` types when possible
3. **Component Organization**: Keep components focused and single-purpose
4. **Error Handling**: Implement proper error handling and logging
5. **Comments**: Add comments only when necessary to explain complex logic
6. **Imports**: Use absolute imports where configured, keep imports organized
### API Development (Server)
- Use Fastify's schema validation for all routes
- Follow REST principles for endpoint design
- Implement proper authentication and authorization
- Handle errors gracefully with appropriate status codes
- Document API endpoints in the docs app
### Frontend Development (Web)
- Use React Server Components where appropriate
- Implement proper loading and error states
- Follow accessibility best practices (WCAG guidelines)
- Optimize performance (lazy loading, code splitting)
- Use Shadcn/ui components for consistent UI
### Documentation
- Write clear, concise documentation
- Include code examples where helpful
- Update documentation when changing functionality
- Use MDX features for interactive documentation
- Follow the existing documentation structure
## Translation and Internationalization
- All user-facing strings should be translatable
- Use the Next.js internationalization system
- Translation files are in `apps/web/messages/`
- Reference file: `en-US.json`
- Run `pnpm translations:check` to verify translations
- Mark untranslated strings with `[TO_TRANSLATE]` prefix
## Common Patterns
### Authentication Providers
- Provider configurations in `apps/server/src/modules/auth-providers/providers.config.ts`
- Support for OAuth2 and OIDC protocols
- Field mappings for user data normalization
- Special handling for providers like GitHub that require additional API calls
### File Storage
- Default: Filesystem storage
- Optional: S3-compatible object storage
- File metadata stored in SQLite database
### Environment Variables
- Configure via `.env` files (not committed to repository)
- Required variables documented in README or docs
- Use environment-specific configurations
## Contributing Guidelines
### Pull Request Process
1. Fork the repository
2. Create a branch from `next`: `git checkout -b feature/your-feature upstream/next`
3. Make focused changes addressing a single issue/feature
4. Write or update tests as needed
5. Update documentation to reflect changes
6. Ensure all validations pass: `pnpm validate` in each app
7. Commit using Conventional Commits
8. Push to your fork
9. Create Pull Request targeting the `next` branch
### Code Review
- Be responsive to feedback
- Keep discussions constructive and professional
- Make requested changes promptly
- Ask questions if requirements are unclear
### What to Avoid
- Don't mix unrelated changes in a single PR
- Don't skip linting or type checking
- Don't commit directly to `main` or `next` branches
- Don't add unnecessary dependencies
- Don't ignore existing code style and patterns
- Don't remove or modify tests without good reason
## Helpful Commands
### Root Level
```bash
pnpm install # Install all dependencies
git config core.hooksPath .husky # Configure Git hooks
```
### Per App (web/server/docs)
```bash
pnpm dev # Start development server
pnpm build # Build for production
pnpm lint # Run ESLint
pnpm lint:fix # Fix ESLint issues automatically
pnpm format # Format code with Prettier
pnpm format:check # Check code formatting
pnpm type-check # Run TypeScript type checking
pnpm validate # Run lint + type-check
```
### Docker
```bash
docker-compose up # Start all services
docker-compose down # Stop all services
```
## Resources
- **Documentation**: [https://palmr.kyantech.com.br](https://palmr.kyantech.com.br)
- **Contributing Guide**: [CONTRIBUTING.md](../CONTRIBUTING.md)
- **Issue Tracker**: GitHub Issues
- **License**: Apache-2.0
## Getting Help
- Review existing documentation in `apps/docs/content/docs/`
- Check contribution guide in `CONTRIBUTING.md`
- Review existing code for patterns and examples
- Ask questions in PR discussions or issues
- Read error messages and logs carefully
## Important Notes
- **Beta Status**: This project is in beta; expect changes and improvements
- **Focus on Quality**: Prioritize code quality and maintainability over speed
- **Test Locally**: Always test your changes locally before submitting
- **Documentation Matters**: Keep documentation synchronized with code
- **Community First**: Be respectful, patient, and constructive with all contributors

3
.gitignore vendored
View File

@@ -33,5 +33,4 @@ apps/server/dist/*
.steering
data/
node_modules/
screenshots/
node_modules/

View File

@@ -1,25 +1,15 @@
FROM node:24-alpine AS base
FROM node:20-alpine AS base
# Install system dependencies
RUN apk add --no-cache \
gcompat \
supervisor \
curl \
wget \
openssl \
su-exec
# Enable pnpm
RUN corepack enable pnpm
# Install storage system for S3-compatible storage
COPY infra/install-minio.sh /tmp/install-minio.sh
RUN chmod +x /tmp/install-minio.sh && /tmp/install-minio.sh
# Install storage client (mc)
RUN wget https://dl.min.io/client/mc/release/linux-amd64/mc -O /usr/local/bin/mc && \
chmod +x /usr/local/bin/mc
# Set working directory
WORKDIR /app
@@ -129,14 +119,11 @@ RUN mkdir -p /etc/supervisor/conf.d
# Copy server start script and configuration files
COPY infra/server-start.sh /app/server-start.sh
COPY infra/start-minio.sh /app/start-minio.sh
COPY infra/minio-setup.sh /app/minio-setup.sh
COPY infra/load-minio-credentials.sh /app/load-minio-credentials.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 /app/start-minio.sh /app/minio-setup.sh /app/load-minio-credentials.sh
RUN chown -R palmr:nodejs /app/server-start.sh /app/start-minio.sh /app/minio-setup.sh /app/load-minio-credentials.sh /app/infra
RUN chmod +x /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
@@ -157,42 +144,9 @@ 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 /app/server/minio-data
mkdir -p /app/server/uploads /app/server/temp-uploads /app/server/prisma
# CRITICAL: Fix permissions BEFORE starting any services
# This runs on EVERY startup to handle updates and corrupted metadata
echo "🔐 Fixing permissions for internal storage..."
# DYNAMIC: Detect palmr user's actual UID and GID
# Works with any Docker --user configuration
PALMR_UID=\$(id -u palmr 2>/dev/null || echo "1001")
PALMR_GID=\$(id -g palmr 2>/dev/null || echo "1001")
echo " Target user: palmr (UID:\$PALMR_UID, GID:\$PALMR_GID)"
# ALWAYS remove storage system metadata to prevent corruption issues
# This is safe - storage system recreates it automatically
# User data (files) are NOT in .minio.sys, they're safe
if [ -d "/app/server/minio-data/.minio.sys" ]; then
echo " 🧹 Cleaning storage system metadata (safe, auto-regenerated)..."
rm -rf /app/server/minio-data/.minio.sys 2>/dev/null || true
fi
# Fix ownership and permissions (safe for updates)
echo " 🔧 Setting ownership and permissions..."
chown -R \$PALMR_UID:\$PALMR_GID /app/server 2>/dev/null || echo " ⚠️ chown skipped"
chmod -R 755 /app/server 2>/dev/null || echo " ⚠️ chmod skipped"
# Verify critical directories are writable
if touch /app/server/.test-write 2>/dev/null; then
rm -f /app/server/.test-write
echo " ✅ Storage directory is writable"
else
echo " ❌ FATAL: /app/server is NOT writable!"
echo " Check Docker volume permissions"
ls -la /app/server 2>/dev/null || true
fi
echo "✅ Storage ready, starting services..."
echo "Data directories ready for first run..."
# Start supervisor
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
@@ -204,7 +158,7 @@ RUN chmod +x /app/start.sh
VOLUME ["/app/server"]
# Expose ports
EXPOSE 3333 5487 9379 9378
EXPOSE 3333 5487
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \

View File

@@ -1,374 +0,0 @@
---
title: Cleanup Orphan Files
icon: Trash2
---
This guide provides detailed instructions on how to identify and remove orphan file records from your Palmr database. Orphan files are database entries that reference files that no longer exist in the storage system, typically resulting from failed uploads or interrupted transfers.
## When and why to use this tool
The orphan file cleanup script is designed to maintain database integrity by removing stale file records. Consider using this tool if:
- Users are experiencing "File not found" errors when attempting to download files that appear in the UI
- You've identified failed uploads that left incomplete database records
- You're performing routine database maintenance
- You've migrated storage systems and need to verify file consistency
- You need to free up quota space occupied by phantom file records
> **Note:** This script only removes **database records** for files that don't exist in storage. It does not delete physical files. Files that exist in storage will remain untouched.
## How the cleanup works
Palmr provides a maintenance script that scans all file records in the database and verifies their existence in the storage system (either filesystem or S3). The script operates in two modes:
- **Dry-run mode (default):** Identifies orphan files and displays what would be deleted without making any changes
- **Confirmation mode:** Actually removes the orphan database records after explicit confirmation
The script maintains safety by:
- Checking file existence before marking as orphan
- Providing detailed statistics and file listings
- Requiring explicit `--confirm` flag to delete records
- Working with both filesystem and S3 storage providers
- Preserving all files that exist in storage
## Understanding orphan files
### What are orphan files?
Orphan files occur when:
1. **Failed chunked uploads:** A large file upload starts, creates a database record, but the upload fails before completion
2. **Interrupted transfers:** Network issues or server restarts interrupt file transfers mid-process
3. **Manual deletions:** Files are manually deleted from storage without removing the database record
4. **Storage migrations:** Files are moved or lost during storage system changes
### Why they cause problems
When orphan records exist in the database:
- Users see files in the UI that cannot be downloaded
- Download attempts result in "ENOENT: no such file or directory" errors
- Storage quota calculations become inaccurate
- The system returns 500 errors instead of proper 404 responses (in older versions)
### Renamed files with suffixes
Files with duplicate names are automatically renamed with suffixes (e.g., `file (1).png`, `file (2).png`). Sometimes the upload fails after the database record is created but before the physical file is saved, creating an orphan record with a suffix.
**Example:**
```
Database record: photo (1).png → objectName: user123/1758805195682-Rjn9at692HdR.png
Physical file: Does not exist ❌
```
## Step-by-step instructions
### 1. Access the server environment
**For Docker installations:**
```bash
docker exec -it <container_name> /bin/sh
cd /app/palmr-app
```
**For bare-metal installations:**
```bash
cd /path/to/palmr/apps/server
```
### 2. Run the cleanup script in dry-run mode
First, run the script without the `--confirm` flag to see what would be deleted:
```bash
pnpm cleanup:orphan-files
```
This will:
- Scan all file records in the database
- Check if each file exists in storage
- Display a summary of orphan files
- Show what would be deleted (without actually deleting)
### 3. Review the output
The script will provide detailed information about orphan files:
```text
Starting orphan file cleanup...
Storage mode: Filesystem
Found 7 files in database
❌ Orphan: photo(1).png (cmddjchw80000gmiimqnxga2g/1758805195682-Rjn9at692HdR.png)
❌ Orphan: document.pdf (cmddjchw80000gmiimqnxga2g/1758803757558-JQxlvF816UVo.pdf)
📊 Summary:
Total files in DB: 7
✅ Files with storage: 5
❌ Orphan files: 2
🗑️ Orphan files to be deleted:
- photo(1).png (0.76 MB) - cmddjchw80000gmiimqnxga2g/1758805195682-Rjn9at692HdR.png
- document.pdf (2.45 MB) - cmddjchw80000gmiimqnxga2g/1758803757558-JQxlvF816UVo.pdf
⚠️ Dry run mode. To actually delete orphan records, run with --confirm flag:
pnpm cleanup:orphan-files:confirm
```
### 4. Confirm and execute the cleanup
If you're satisfied with the results and want to proceed with the deletion:
```bash
pnpm cleanup:orphan-files:confirm
```
This will remove the orphan database records and display a confirmation:
```text
🗑️ Deleting orphan file records...
✓ Deleted: photo(1).png
✓ Deleted: document.pdf
✅ Cleanup complete!
Deleted 2 orphan file records
```
## Example session
Below is a complete example of running the cleanup script:
```bash
$ pnpm cleanup:orphan-files
> palmr-api@3.2.3-beta cleanup:orphan-files
> tsx src/scripts/cleanup-orphan-files.ts
Starting orphan file cleanup...
Storage mode: Filesystem
Found 15 files in database
❌ Orphan: video.mp4 (user123/1758803869037-1WhtnrQioeFQ.mp4)
❌ Orphan: image(1).png (user123/1758805195682-Rjn9at692HdR.png)
❌ Orphan: image(2).png (user123/1758803757558-JQxlvF816UVo.png)
📊 Summary:
Total files in DB: 15
✅ Files with storage: 12
❌ Orphan files: 3
🗑️ Orphan files to be deleted:
- video.mp4 (97.09 MB) - user123/1758803869037-1WhtnrQioeFQ.mp4
- image(1).png (0.01 MB) - user123/1758805195682-Rjn9at692HdR.png
- image(2).png (0.76 MB) - user123/1758803757558-JQxlvF816UVo.png
⚠️ Dry run mode. To actually delete orphan records, run with --confirm flag:
pnpm cleanup:orphan-files:confirm
$ pnpm cleanup:orphan-files:confirm
> palmr-api@3.2.3-beta cleanup:orphan-files:confirm
> tsx src/scripts/cleanup-orphan-files.ts --confirm
Starting orphan file cleanup...
Storage mode: Filesystem
Found 15 files in database
❌ Orphan: video.mp4 (user123/1758803869037-1WhtnrQioeFQ.mp4)
❌ Orphan: image(1).png (user123/1758805195682-Rjn9at692HdR.png)
❌ Orphan: image(2).png (user123/1758803757558-JQxlvF816UVo.png)
📊 Summary:
Total files in DB: 15
✅ Files with storage: 12
❌ Orphan files: 3
🗑️ Orphan files to be deleted:
- video.mp4 (97.09 MB) - user123/1758803869037-1WhtnrQioeFQ.mp4
- image(1).png (0.01 MB) - user123/1758805195682-Rjn9at692HdR.png
- image(2).png (0.76 MB) - user123/1758803757558-JQxlvF816UVo.png
🗑️ Deleting orphan file records...
✓ Deleted: video.mp4
✓ Deleted: image(1).png
✓ Deleted: image(2).png
✅ Cleanup complete!
Deleted 3 orphan file records
Script completed successfully
```
## Troubleshooting common issues
### No orphan files found
```text
📊 Summary:
Total files in DB: 10
✅ Files with storage: 10
❌ Orphan files: 0
✨ No orphan files found!
```
**This is good!** It means your database is in sync with your storage system.
### Script cannot connect to database
If you see database connection errors:
1. Verify the database file exists:
```bash
ls -la prisma/palmr.db
```
2. Check database permissions:
```bash
chmod 644 prisma/palmr.db
```
3. Ensure you're in the correct directory:
```bash
pwd # Should show .../palmr/apps/server
```
### Storage provider errors
For **S3 storage:**
- Verify your S3 credentials are configured correctly
- Check that the bucket is accessible
- Ensure network connectivity to S3
For **Filesystem storage:**
- Verify the uploads directory exists and is readable
- Check file system permissions
- Ensure sufficient disk space
### Script fails to delete records
If deletion fails for specific files:
- Check database locks (close other connections)
- Verify you have write permissions to the database
- Review the error message for specific details
## Understanding the output
### File statistics
The script provides several key metrics:
- **Total files in DB:** All file records in your database
- **Files with storage:** Records where the physical file exists
- **Orphan files:** Records where the physical file is missing
### File information
For each orphan file, you'll see:
- **Name:** Display name in the UI
- **Size:** File size as recorded in the database
- **Object name:** Internal storage path
Example: `photo(1).png (0.76 MB) - user123/1758805195682-Rjn9at692HdR.png`
## Prevention and best practices
### Prevent orphan files from occurring
1. **Monitor upload failures:** Check server logs for upload errors
2. **Stable network:** Ensure reliable network connectivity for large uploads
3. **Adequate resources:** Provide sufficient disk space and memory
4. **Regular maintenance:** Run this script periodically as part of maintenance
### When to run cleanup
Consider running the cleanup script:
- **Monthly:** As part of routine database maintenance
- **After incidents:** Following server crashes or storage issues
- **Before migrations:** Before moving to new storage systems
- **When users report errors:** If download failures are reported
### Safe cleanup practices
1. **Always run dry-run first:** Review what will be deleted before confirming
2. **Backup your database:** Create a backup before running with `--confirm`
3. **Check during low usage:** Run during off-peak hours to minimize disruption
4. **Document the cleanup:** Keep records of when and why cleanup was performed
5. **Verify after cleanup:** Check that file counts match expectations
## Technical details
### How files are stored
When files are uploaded to Palmr:
1. Frontend generates a safe object name using random identifiers
2. Backend creates the final `objectName` as: `${userId}/${timestamp}-${randomId}.${extension}`
3. If a duplicate name exists, the **display name** gets a suffix, but `objectName` remains unique
4. Physical file is stored using `objectName`, display name is stored separately in database
### Storage providers
The script works with both storage providers:
- **FilesystemStorageProvider:** Uses `fs.promises.access()` to check file existence
- **S3StorageProvider:** Uses `HeadObjectCommand` to verify objects in S3 bucket
### Database schema
Files table structure:
```typescript
{
name: string // Display name (can have suffixes like "file (1).png")
objectName: string // Physical storage path (always unique)
size: bigint // File size in bytes
extension: string // File extension
userId: string // Owner of the file
folderId: string? // Parent folder (null for root)
}
```
## Related improvements
### Download validation (v3.2.3-beta+)
Starting from version 3.2.3-beta, Palmr includes enhanced download validation:
- Files are checked for existence **before** attempting download
- Returns proper 404 error if file is missing (instead of 500)
- Provides helpful error message to users
This prevents errors when trying to download orphan files that haven't been cleaned up yet.
## Security considerations
- **Read-only by default:** Dry-run mode is safe and doesn't modify data
- **Explicit confirmation:** Requires `--confirm` flag to delete records
- **No file deletion:** Only removes database records, never deletes physical files
- **Audit trail:** All actions are logged to console
- **Permission-based:** Only users with server access can run the script
> **Important:** This script does not delete physical files from storage. It only removes database records for files that don't exist. This is intentional to prevent accidental data loss.
## FAQ
**Q: Will this delete my files?**
A: No. The script only removes database records for files that are already missing from storage. Physical files are never deleted.
**Q: Can I undo the cleanup?**
A: No. Once orphan records are deleted, they cannot be recovered. Always run dry-run mode first and backup your database.
**Q: Why do orphan files have suffixes like (1), (2)?**
A: When duplicate files are uploaded, Palmr renames them with suffixes. If the upload fails after creating the database record, an orphan with a suffix remains.
**Q: How often should I run this script?**
A: Monthly maintenance is usually sufficient. Run more frequently if you experience many upload failures.
**Q: Does this work with S3 storage?**
A: Yes! The script automatically detects your storage provider (filesystem or S3) and works with both.
**Q: What if I have thousands of orphan files?**
A: The script handles large numbers efficiently. Consider running during off-peak hours for very large cleanups.
**Q: Can this fix "File not found" errors?**
A: Yes, if the errors are caused by orphan database records. The script removes those records, preventing future errors.

View File

@@ -165,27 +165,6 @@ cp .env.example .env
This creates a `.env` file with the necessary configurations for the frontend.
##### Upload Configuration
Palmr. supports configurable chunked uploading for large files. You can customize the chunk size by setting the following environment variable in your `.env` file:
```bash
NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB=100
```
**How it works:**
- If `NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB` is set, Palmr. will use this value (in megabytes) as the chunk size for all file uploads that exceed this threshold.
- If not set or left empty, Palmr. automatically calculates optimal chunk sizes based on file size:
- Files ≤ 100MB: uploaded without chunking
- Files > 100MB and ≤ 1GB: 75MB chunks
- Files > 1GB: 150MB chunks
**When to configure:**
- **Default (not set):** Recommended for most use cases. Palmr. will intelligently determine the best chunk size.
- **Custom value:** Set this if you have specific network conditions or want to optimize for your infrastructure (e.g., slower connections may benefit from smaller chunks like 50MB, while fast networks can handle larger chunks like 200MB, or the upload size per payload may be limited by a proxy like Cloudflare)
#### Install dependencies
Install all the frontend dependencies:

View File

@@ -16,7 +16,6 @@
"reverse-proxy-configuration",
"download-memory-management",
"password-reset-without-smtp",
"cleanup-orphan-files",
"oidc-authentication",
"troubleshooting",
"---Developers---",

View File

@@ -76,7 +76,6 @@ Choose your storage method based on your needs:
# - 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)
# - NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB=100 # Chunk size in MB for large file uploads (OPTIONAL - auto-calculates if not set)
volumes:
- palmr_data:/app/server
@@ -152,33 +151,32 @@ Choose your storage method based on your needs:
Customize Palmr's behavior with these environment variables:
| Variable | Default | Description |
| ---------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `ENABLE_S3` | `false` | Enable S3-compatible storage backends |
| `S3_ENDPOINT` | - | S3 server endpoint URL (required when using S3) |
| `S3_PORT` | - | S3 server port (optional when using S3) |
| `S3_USE_SSL` | - | Enable SSL for S3 connections (optional when using S3) |
| `S3_ACCESS_KEY` | - | S3 access key for authentication (required when using S3) |
| `S3_SECRET_KEY` | - | S3 secret key for authentication (required when using S3) |
| `S3_REGION` | - | S3 region configuration (optional when using S3) |
| `S3_BUCKET_NAME` | - | S3 bucket name for file storage (required when using S3) |
| `S3_FORCE_PATH_STYLE` | `false` | Force path-style S3 URLs (optional when using S3) |
| `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) |
| `PRESIGNED_URL_EXPIRATION` | `3600` | Duration in seconds for presigned URL expiration (applies to both filesystem and S3 storage) |
| `CUSTOM_PATH` | - | Custom base path for disk space detection in manual installations with symlinks |
| `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) |
| `NODE_OPTIONS` | - | Node.js options (recommended: `--expose-gc` for garbage collection in production) |
| `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 |
| `NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB` | auto-calculate | Chunk size in MB for large file uploads (see [Chunked Upload Configuration](/docs/3.2-beta/quick-start#chunked-upload-configuration)) |
| `DOWNLOAD_AUTO_SCALE` | `true` | Enable auto-scaling based on system memory |
| Variable | Default | Description |
| ------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| `ENABLE_S3` | `false` | Enable S3-compatible storage backends |
| `S3_ENDPOINT` | - | S3 server endpoint URL (required when using S3) |
| `S3_PORT` | - | S3 server port (optional when using S3) |
| `S3_USE_SSL` | - | Enable SSL for S3 connections (optional when using S3) |
| `S3_ACCESS_KEY` | - | S3 access key for authentication (required when using S3) |
| `S3_SECRET_KEY` | - | S3 secret key for authentication (required when using S3) |
| `S3_REGION` | - | S3 region configuration (optional when using S3) |
| `S3_BUCKET_NAME` | - | S3 bucket name for file storage (required when using S3) |
| `S3_FORCE_PATH_STYLE` | `false` | Force path-style S3 URLs (optional when using S3) |
| `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) |
| `PRESIGNED_URL_EXPIRATION` | `3600` | Duration in seconds for presigned URL expiration (applies to both filesystem and S3 storage) |
| `CUSTOM_PATH` | - | Custom base path for disk space detection in manual installations with symlinks |
| `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) |
| `NODE_OPTIONS` | - | Node.js options (recommended: `--expose-gc` for garbage collection in production) |
| `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
@@ -316,28 +314,6 @@ environment:
**Note:** S3 storage handles encryption through your S3 provider's encryption features.
### Chunked Upload Configuration
Palmr supports configurable chunked uploading for large files. You can customize the chunk size by setting the following environment variable:
```yaml
environment:
- NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB=100 # Chunk size in MB
```
**How it works:**
- If `NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB` is set, Palmr will use this value (in megabytes) as the chunk size for all file uploads that exceed this threshold.
- If not set or left empty, Palmr automatically calculates optimal chunk sizes based on file size:
- Files ≤ 100MB: uploaded without chunking
- Files > 100MB and ≤ 1GB: 75MB chunks
- Files > 1GB: 150MB chunks
**When to configure:**
- **Default (not set):** Recommended for most use cases. Palmr will intelligently determine the best chunk size.
- **Custom value:** Set this if you have specific network conditions or want to optimize for your infrastructure (e.g., slower connections may benefit from smaller chunks like 50MB, while fast networks can handle larger chunks like 200MB, or the upload size per payload may be limited by a proxy like Cloudflare)
---
## Maintenance

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-docs",
"version": "3.2.5-beta",
"version": "3.2.2-beta",
"description": "Docs for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@@ -13,7 +13,7 @@
"react",
"typescript"
],
"license": "Apache-2.0",
"license": "BSD-2-Clause",
"packageManager": "pnpm@10.6.0",
"scripts": {
"build": "next build",
@@ -62,4 +62,4 @@
"tw-animate-css": "^1.2.8",
"typescript": "^5.8.3"
}
}
}

View File

@@ -4,4 +4,3 @@ dist/*
uploads/*
temp-uploads/*
prisma/*.db
tsconfig.tsbuildinfo

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-api",
"version": "3.2.5-beta",
"version": "3.2.2-beta",
"description": "API for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@@ -12,7 +12,7 @@
"nodejs",
"typescript"
],
"license": "Apache-2.0",
"license": "BSD-2-Clause",
"packageManager": "pnpm@10.6.0",
"main": "index.js",
"scripts": {
@@ -25,9 +25,7 @@
"format:check": "prettier . --check",
"type-check": "npx tsc --noEmit",
"validate": "pnpm lint && pnpm type-check",
"db:seed": "ts-node prisma/seed.js",
"cleanup:orphan-files": "tsx src/scripts/cleanup-orphan-files.ts",
"cleanup:orphan-files:confirm": "tsx src/scripts/cleanup-orphan-files.ts --confirm"
"db:seed": "ts-node prisma/seed.js"
},
"prisma": {
"seed": "node prisma/seed.js"
@@ -79,4 +77,4 @@
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -59,6 +59,7 @@ model File {
shares Share[] @relation("ShareFiles")
@@index([folderId])
@@map("files")
}
@@ -277,40 +278,40 @@ enum PageLayout {
}
model TrustedDevice {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deviceHash String @unique
deviceName String?
userAgent String?
ipAddress String?
lastUsedAt DateTime @default(now())
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deviceHash String @unique
deviceName String?
userAgent String?
ipAddress String?
lastUsedAt DateTime @default(now())
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("trusted_devices")
}
model Folder {
id String @id @default(cuid())
id String @id @default(cuid())
name String
description String?
objectName String
parentId String?
parent Folder? @relation("FolderHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Folder[] @relation("FolderHierarchy")
parentId String?
parent Folder? @relation("FolderHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Folder[] @relation("FolderHierarchy")
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
files File[]
files File[]
shares Share[] @relation("ShareFolders")
shares Share[] @relation("ShareFolders")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([parentId])

View File

@@ -17,12 +17,6 @@ const defaultConfigs = [
type: "boolean",
group: "general",
},
{
key: "hideVersion",
value: "false",
type: "boolean",
group: "general",
},
{
key: "appDescription",
value: "Secure and simple file sharing - Your personal cloud",

View File

@@ -1,57 +1,10 @@
import * as fs from "fs";
import process from "node:process";
import { S3Client } from "@aws-sdk/client-s3";
import { env } from "../env";
import { StorageConfig } from "../types/storage";
/**
* Load internal storage credentials if they exist
* This provides S3-compatible storage automatically when ENABLE_S3=false
*/
function loadInternalStorageCredentials(): Partial<StorageConfig> | null {
const credentialsPath = "/app/server/.minio-credentials";
try {
if (fs.existsSync(credentialsPath)) {
const content = fs.readFileSync(credentialsPath, "utf-8");
const credentials: any = {};
content.split("\n").forEach((line) => {
const [key, value] = line.split("=");
if (key && value) {
credentials[key.trim()] = value.trim();
}
});
console.log("[STORAGE] Using internal storage system");
return {
endpoint: credentials.S3_ENDPOINT || "127.0.0.1",
port: parseInt(credentials.S3_PORT || "9379", 10),
useSSL: credentials.S3_USE_SSL === "true",
accessKey: credentials.S3_ACCESS_KEY,
secretKey: credentials.S3_SECRET_KEY,
region: credentials.S3_REGION || "default",
bucketName: credentials.S3_BUCKET_NAME || "palmr-files",
forcePathStyle: true,
};
}
} catch (error) {
console.warn("[STORAGE] Could not load internal storage credentials:", error);
}
return null;
}
/**
* Storage configuration:
* - Default (ENABLE_S3=false or not set): Internal storage (auto-configured, zero config)
* - ENABLE_S3=true: External S3 (AWS, S3-compatible, etc) using env vars
*/
const internalStorageConfig = env.ENABLE_S3 === "true" ? null : loadInternalStorageCredentials();
export const storageConfig: StorageConfig = (internalStorageConfig as StorageConfig) || {
export const storageConfig: StorageConfig = {
endpoint: env.S3_ENDPOINT || "",
port: env.S3_PORT ? Number(env.S3_PORT) : undefined,
useSSL: env.S3_USE_SSL === "true",
@@ -70,74 +23,21 @@ if (storageConfig.useSSL && env.S3_REJECT_UNAUTHORIZED === "false") {
}
}
/**
* Storage is ALWAYS S3-compatible:
* - ENABLE_S3=false → Internal storage (automatic)
* - ENABLE_S3=true → External S3 (AWS, S3-compatible, etc)
*/
const hasValidConfig = storageConfig.endpoint && storageConfig.accessKey && storageConfig.secretKey;
export const s3Client = hasValidConfig
? new S3Client({
endpoint: storageConfig.useSSL
? `https://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`
: `http://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`,
region: storageConfig.region,
credentials: {
accessKeyId: storageConfig.accessKey,
secretAccessKey: storageConfig.secretKey,
},
forcePathStyle: storageConfig.forcePathStyle,
})
: null;
export const s3Client =
env.ENABLE_S3 === "true"
? new S3Client({
endpoint: storageConfig.useSSL
? `https://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`
: `http://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`,
region: storageConfig.region,
credentials: {
accessKeyId: storageConfig.accessKey,
secretAccessKey: storageConfig.secretKey,
},
forcePathStyle: storageConfig.forcePathStyle,
})
: null;
export const bucketName = storageConfig.bucketName;
/**
* Storage is always S3-compatible
* ENABLE_S3=true means EXTERNAL S3, otherwise uses internal storage
*/
export const isS3Enabled = s3Client !== null;
export const isExternalS3 = env.ENABLE_S3 === "true";
export const isInternalStorage = s3Client !== null && env.ENABLE_S3 !== "true";
/**
* Creates a public S3 client for presigned URL generation.
* - Internal storage (ENABLE_S3=false): Uses STORAGE_URL (e.g., https://syrg.palmr.com)
* - External S3 (ENABLE_S3=true): Uses the original S3 endpoint configuration
*
* @returns S3Client configured with public endpoint, or null if S3 is disabled
*/
export function createPublicS3Client(): S3Client | null {
if (!s3Client) {
return null;
}
let publicEndpoint: string;
if (isInternalStorage) {
// Internal storage: use STORAGE_URL
if (!env.STORAGE_URL) {
throw new Error(
"[STORAGE] STORAGE_URL environment variable is required when using internal storage (ENABLE_S3=false). " +
"Set STORAGE_URL to your public storage URL with protocol (e.g., https://syrg.palmr.com or http://192.168.1.100:9379)"
);
}
publicEndpoint = env.STORAGE_URL;
} else {
// External S3: use the original endpoint configuration
publicEndpoint = storageConfig.useSSL
? `https://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`
: `http://${storageConfig.endpoint}${storageConfig.port ? `:${storageConfig.port}` : ""}`;
}
return new S3Client({
endpoint: publicEndpoint,
region: storageConfig.region,
credentials: {
accessKeyId: storageConfig.accessKey,
secretAccessKey: storageConfig.secretKey,
},
forcePathStyle: storageConfig.forcePathStyle,
});
}
export const isS3Enabled = env.ENABLE_S3 === "true";

View File

@@ -1,8 +1,9 @@
import { z } from "zod";
const envSchema = z.object({
// Storage configuration
ENABLE_S3: z.union([z.literal("true"), z.literal("false")]).default("false"),
ENCRYPTION_KEY: z.string().optional(),
DISABLE_FILESYSTEM_ENCRYPTION: z.union([z.literal("true"), z.literal("false")]).default("true"),
S3_ENDPOINT: z.string().optional(),
S3_PORT: z.string().optional(),
S3_USE_SSL: z.string().optional(),
@@ -12,16 +13,26 @@ const envSchema = z.object({
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"),
// Legacy encryption vars (kept for backward compatibility but not used with S3/Garage)
ENCRYPTION_KEY: z.string().optional(),
DISABLE_FILESYSTEM_ENCRYPTION: z.union([z.literal("true"), z.literal("false")]).default("true"),
// Application configuration
PRESIGNED_URL_EXPIRATION: z.string().optional().default("3600"),
SECURE_SITE: z.union([z.literal("true"), z.literal("false")]).default("false"),
STORAGE_URL: z.string().optional(), // Storage URL for internal storage presigned URLs (required when ENABLE_S3=false, e.g., https://syrg.palmr.com or http://192.168.1.100:9379)
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)),
CUSTOM_PATH: z.string().optional(),
});

View File

@@ -1,3 +1,4 @@
import { isS3Enabled } from "../../config/storage.config";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
@@ -22,8 +23,8 @@ export class AppService {
async getSystemInfo() {
return {
storageProvider: "s3",
s3Enabled: true,
storageProvider: isS3Enabled ? "s3" : "filesystem",
s3Enabled: isS3Enabled,
};
}

View File

@@ -617,11 +617,6 @@ export class AuthProvidersService {
return await this.linkProviderToExistingUser(existingUser, provider.id, String(externalId), userInfo);
}
// Check if auto-registration is disabled
if (provider.autoRegister === false) {
throw new Error(`User registration via ${provider.displayName || provider.name} is disabled`);
}
return await this.createNewUserWithProvider(userInfo, provider.id, String(externalId));
}

View File

@@ -3,12 +3,6 @@ import { FastifyReply, FastifyRequest } from "fastify";
import { env } from "../../env";
import { prisma } from "../../shared/prisma";
import {
generateUniqueFileName,
generateUniqueFileNameForRename,
parseFileName,
} from "../../utils/file-name-generator";
import { getContentType } from "../../utils/mime-types";
import { ConfigService } from "../config/service";
import {
CheckFileInput,
@@ -28,30 +22,31 @@ export class FileController {
private fileService = new FileService();
private configService = new ConfigService();
async getPresignedUrl(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const { filename, extension } = request.query as { filename: string; extension: string };
if (!filename || !extension) {
return reply.status(400).send({ error: "filename and extension are required" });
}
async getPresignedUrl(request: FastifyRequest, reply: FastifyReply) {
try {
// JWT already verified by preValidation in routes.ts
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
const { filename, extension } = request.query as {
filename?: string;
extension?: string;
};
if (!filename || !extension) {
return reply.status(400).send({
error: "The 'filename' and 'extension' parameters are required.",
});
}
// Generate unique object name
const objectName = `${userId}/${Date.now()}-${Math.random().toString(36).substring(7)}-${filename}.${extension}`;
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const objectName = `${userId}/${Date.now()}-${filename}.${extension}`;
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return reply.status(200).send({ url, objectName });
return reply.send({ url, objectName });
} catch (error) {
console.error("Error in getPresignedUrl:", error);
return reply.status(500).send({ error: "Internal server error" });
return reply.status(500).send({ error: "Internal server error." });
}
}
@@ -98,13 +93,9 @@ export class FileController {
}
}
// Parse the filename and generate a unique name if there's a duplicate
const { baseName, extension } = parseFileName(input.name);
const uniqueName = await generateUniqueFileName(baseName, extension, userId, input.folderId);
const fileRecord = await prisma.file.create({
data: {
name: uniqueName,
name: input.name,
description: input.description,
extension: input.extension,
size: BigInt(input.size),
@@ -178,20 +169,9 @@ export class FileController {
});
}
// Check for duplicate filename and provide the suggested unique name
const { baseName, extension } = parseFileName(input.name);
const uniqueName = await generateUniqueFileName(baseName, extension, userId, input.folderId);
// Include suggestedName in response if the name was changed
const response: any = {
return reply.status(201).send({
message: "File checks succeeded.",
};
if (uniqueName !== input.name) {
response.suggestedName = uniqueName;
}
return reply.status(201).send(response);
});
} catch (error: any) {
console.error("Error in checkFile:", error);
return reply.status(400).send({ error: error.message });
@@ -200,10 +180,11 @@ export class FileController {
async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName, password } = request.query as {
const { objectName: encodedObjectName } = request.params as {
objectName: string;
password?: string;
};
const objectName = decodeURIComponent(encodedObjectName);
const { password } = request.query as { password?: string };
if (!objectName) {
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
@@ -217,6 +198,8 @@ export class FileController {
let hasAccess = false;
console.log("Requested file with password " + password);
const shares = await prisma.share.findMany({
where: {
files: {
@@ -259,8 +242,6 @@ export class FileController {
const fileName = fileRecord.name;
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
// Always use presigned URLs (works for both internal and external storage)
const url = await this.fileService.getPresignedGetUrl(objectName, expires, fileName);
return reply.send({ url, expiresIn: expires });
} catch (error) {
@@ -269,114 +250,6 @@ export class FileController {
}
}
async downloadFile(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName, password } = request.query as {
objectName: string;
password?: string;
};
if (!objectName) {
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
}
const fileRecord = await prisma.file.findFirst({ where: { objectName } });
if (!fileRecord) {
if (objectName.startsWith("reverse-shares/")) {
const reverseShareFile = await prisma.reverseShareFile.findFirst({
where: { objectName },
include: {
reverseShare: true,
},
});
if (!reverseShareFile) {
return reply.status(404).send({ error: "File not found." });
}
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId || reverseShareFile.reverseShare.creatorId !== userId) {
return reply.status(401).send({ error: "Unauthorized access to file." });
}
} catch (err) {
return reply.status(401).send({ error: "Unauthorized access to file." });
}
// Stream from S3/storage system
const stream = await this.fileService.getObjectStream(objectName);
const contentType = getContentType(reverseShareFile.name);
const fileName = reverseShareFile.name;
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
return reply.send(stream);
}
return reply.status(404).send({ error: "File not found." });
}
let hasAccess = false;
const shares = await prisma.share.findMany({
where: {
files: {
some: {
id: fileRecord.id,
},
},
},
include: {
security: true,
},
});
for (const share of shares) {
if (!share.security.password) {
hasAccess = true;
break;
} else if (password) {
const isPasswordValid = await bcrypt.compare(password, share.security.password);
if (isPasswordValid) {
hasAccess = true;
break;
}
}
}
if (!hasAccess) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (userId && fileRecord.userId === userId) {
hasAccess = true;
}
} catch (err) {}
}
if (!hasAccess) {
return reply.status(401).send({ error: "Unauthorized access to file." });
}
// Stream from S3/MinIO
const stream = await this.fileService.getObjectStream(objectName);
const contentType = getContentType(fileRecord.name);
const fileName = fileRecord.name;
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
return reply.send(stream);
} catch (error) {
console.error("Error in downloadFile:", error);
return reply.status(500).send({ error: "Internal server error." });
}
}
async listFiles(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
@@ -486,13 +359,6 @@ export class FileController {
return reply.status(403).send({ error: "Access denied." });
}
// If renaming the file, check for duplicates and auto-rename if necessary
if (updateData.name && updateData.name !== fileRecord.name) {
const { baseName, extension } = parseFileName(updateData.name);
const uniqueName = await generateUniqueFileNameForRename(baseName, extension, userId, fileRecord.folderId, id);
updateData.name = uniqueName;
}
const updatedFile = await prisma.file.update({
where: { id },
data: updateData,
@@ -578,49 +444,6 @@ export class FileController {
}
}
async embedFile(request: FastifyRequest, reply: FastifyReply) {
try {
const { id } = request.params as { id: string };
if (!id) {
return reply.status(400).send({ error: "File ID is required." });
}
const fileRecord = await prisma.file.findUnique({ where: { id } });
if (!fileRecord) {
return reply.status(404).send({ error: "File not found." });
}
const extension = fileRecord.extension.toLowerCase();
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "avif"];
const videoExts = ["mp4", "webm", "ogg", "mov", "avi", "mkv", "flv", "wmv"];
const audioExts = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "wma"];
const isMedia = imageExts.includes(extension) || videoExts.includes(extension) || audioExts.includes(extension);
if (!isMedia) {
return reply.status(403).send({
error: "Embed is only allowed for images, videos, and audio files.",
});
}
// Stream from S3/MinIO
const stream = await this.fileService.getObjectStream(fileRecord.objectName);
const contentType = getContentType(fileRecord.name);
const fileName = fileRecord.name;
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
reply.header("Cache-Control", "public, max-age=31536000"); // Cache por 1 ano
return reply.send(stream);
} catch (error) {
console.error("Error in embedFile:", error);
return reply.status(500).send({ error: "Internal server error." });
}
}
private async getAllUserFilesRecursively(userId: string): Promise<any[]> {
const rootFiles = await prisma.file.findMany({
where: { userId, folderId: null },
@@ -645,123 +468,4 @@ export class FileController {
return allFiles;
}
// Multipart upload endpoints
async createMultipartUpload(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const { filename, extension } = request.body as { filename: string; extension: string };
if (!filename || !extension) {
return reply.status(400).send({ error: "filename and extension are required" });
}
// Generate unique object name (same pattern as simple upload)
const objectName = `${userId}/${Date.now()}-${Math.random().toString(36).substring(7)}-${filename}.${extension}`;
const uploadId = await this.fileService.createMultipartUpload(objectName);
return reply.status(200).send({
uploadId,
objectName,
message: "Multipart upload initialized",
});
} catch (error) {
console.error("[Multipart] Error creating multipart upload:", error);
return reply.status(500).send({ error: "Failed to create multipart upload" });
}
}
async getMultipartPartUrl(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const { uploadId, objectName, partNumber } = request.query as {
uploadId: string;
objectName: string;
partNumber: string;
};
if (!uploadId || !objectName || !partNumber) {
return reply.status(400).send({ error: "uploadId, objectName, and partNumber are required" });
}
const partNum = parseInt(partNumber);
if (isNaN(partNum) || partNum < 1 || partNum > 10000) {
return reply.status(400).send({ error: "partNumber must be between 1 and 10000" });
}
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPartUrl(objectName, uploadId, partNum, expires);
return reply.status(200).send({ url });
} catch (error) {
console.error("[Multipart] Error getting part URL:", error);
return reply.status(500).send({ error: "Failed to get presigned URL for part" });
}
}
async completeMultipartUpload(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const { uploadId, objectName, parts } = request.body as {
uploadId: string;
objectName: string;
parts: Array<{ PartNumber: number; ETag: string }>;
};
if (!uploadId || !objectName || !parts || !Array.isArray(parts)) {
return reply.status(400).send({ error: "uploadId, objectName, and parts are required" });
}
await this.fileService.completeMultipartUpload(objectName, uploadId, parts);
return reply.status(200).send({
message: "Multipart upload completed successfully",
objectName,
});
} catch (error) {
console.error("[Multipart] Error completing multipart upload:", error);
return reply.status(500).send({ error: "Failed to complete multipart upload" });
}
}
async abortMultipartUpload(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const { uploadId, objectName } = request.body as {
uploadId: string;
objectName: string;
};
if (!uploadId || !objectName) {
return reply.status(400).send({ error: "uploadId and objectName are required" });
}
await this.fileService.abortMultipartUpload(objectName, uploadId);
return reply.status(200).send({
message: "Multipart upload aborted successfully",
});
} catch (error) {
console.error("[Multipart] Error aborting multipart upload:", error);
return reply.status(500).send({ error: "Failed to abort multipart upload" });
}
}
}

View File

@@ -106,15 +106,17 @@ export async function fileRoutes(app: FastifyInstance) {
);
app.get(
"/files/download-url",
"/files/:objectName/download",
{
schema: {
tags: ["File"],
operationId: "getDownloadUrl",
summary: "Get Download URL",
description: "Generates a pre-signed URL for downloading a file",
querystring: z.object({
params: z.object({
objectName: z.string().min(1, "The objectName is required"),
}),
querystring: z.object({
password: z.string().optional().describe("Share password if required"),
}),
response: {
@@ -131,46 +133,6 @@ export async function fileRoutes(app: FastifyInstance) {
fileController.getDownloadUrl.bind(fileController)
);
app.get(
"/embed/:id",
{
schema: {
tags: ["File"],
operationId: "embedFile",
summary: "Embed File (Public Access)",
description:
"Returns a media file (image/video/audio) for public embedding without authentication. Only works for media files.",
params: z.object({
id: z.string().min(1, "File ID is required").describe("The file ID"),
}),
response: {
400: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message - not a media file") }),
404: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.embedFile.bind(fileController)
);
app.get(
"/files/download",
{
schema: {
tags: ["File"],
operationId: "downloadFile",
summary: "Download File",
description: "Downloads a file directly (returns file content)",
querystring: z.object({
objectName: z.string().min(1, "The objectName is required"),
password: z.string().optional().describe("Share password if required"),
}),
},
},
fileController.downloadFile.bind(fileController)
);
app.get(
"/files",
{
@@ -309,122 +271,4 @@ export async function fileRoutes(app: FastifyInstance) {
},
fileController.deleteFile.bind(fileController)
);
// Multipart upload routes
app.post(
"/files/multipart/create",
{
preValidation,
schema: {
tags: ["File"],
operationId: "createMultipartUpload",
summary: "Create Multipart Upload",
description:
"Initializes a multipart upload for large files (≥100MB). Returns uploadId for subsequent part uploads.",
body: z.object({
filename: z.string().min(1).describe("The filename without extension"),
extension: z.string().min(1).describe("The file extension"),
}),
response: {
200: z.object({
uploadId: z.string().describe("The upload ID for this multipart upload"),
objectName: z.string().describe("The object name in storage"),
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
fileController.createMultipartUpload.bind(fileController)
);
app.get(
"/files/multipart/part-url",
{
preValidation,
schema: {
tags: ["File"],
operationId: "getMultipartPartUrl",
summary: "Get Presigned URL for Part",
description: "Gets a presigned URL for uploading a specific part of a multipart upload",
querystring: z.object({
uploadId: z.string().min(1).describe("The multipart upload ID"),
objectName: z.string().min(1).describe("The object name"),
partNumber: z.string().min(1).describe("The part number (1-10000)"),
}),
response: {
200: z.object({
url: z.string().describe("The presigned URL for uploading this part"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
fileController.getMultipartPartUrl.bind(fileController)
);
app.post(
"/files/multipart/complete",
{
preValidation,
schema: {
tags: ["File"],
operationId: "completeMultipartUpload",
summary: "Complete Multipart Upload",
description: "Completes a multipart upload by combining all uploaded parts",
body: z.object({
uploadId: z.string().min(1).describe("The multipart upload ID"),
objectName: z.string().min(1).describe("The object name"),
parts: z
.array(
z.object({
PartNumber: z.number().min(1).max(10000).describe("The part number"),
ETag: z.string().min(1).describe("The ETag returned from uploading the part"),
})
)
.describe("Array of uploaded parts"),
}),
response: {
200: z.object({
message: z.string().describe("Success message"),
objectName: z.string().describe("The completed object name"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
fileController.completeMultipartUpload.bind(fileController)
);
app.post(
"/files/multipart/abort",
{
preValidation,
schema: {
tags: ["File"],
operationId: "abortMultipartUpload",
summary: "Abort Multipart Upload",
description: "Aborts a multipart upload and cleans up all uploaded parts",
body: z.object({
uploadId: z.string().min(1).describe("The multipart upload ID"),
objectName: z.string().min(1).describe("The object name"),
}),
response: {
200: z.object({
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
fileController.abortMultipartUpload.bind(fileController)
);
}

View File

@@ -1,3 +1,5 @@
import { isS3Enabled } from "../../config/storage.config";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
import { S3StorageProvider } from "../../providers/s3-storage.provider";
import { StorageProvider } from "../../types/storage";
@@ -5,16 +7,29 @@ export class FileService {
private storageProvider: StorageProvider;
constructor() {
// Always use S3 (Garage internal or external S3)
this.storageProvider = new S3StorageProvider();
if (isS3Enabled) {
this.storageProvider = new S3StorageProvider();
} else {
this.storageProvider = FilesystemStorageProvider.getInstance();
}
}
async getPresignedPutUrl(objectName: string, expires: number = 3600): Promise<string> {
return await this.storageProvider.getPresignedPutUrl(objectName, expires);
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
try {
return await this.storageProvider.getPresignedPutUrl(objectName, expires);
} catch (err) {
console.error("Erro no presignedPutObject:", err);
throw err;
}
}
async getPresignedGetUrl(objectName: string, expires: number = 3600, fileName?: string): Promise<string> {
return await this.storageProvider.getPresignedGetUrl(objectName, expires, fileName);
async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
try {
return await this.storageProvider.getPresignedGetUrl(objectName, expires, fileName);
} catch (err) {
console.error("Erro no presignedGetObject:", err);
throw err;
}
}
async deleteObject(objectName: string): Promise<void> {
@@ -26,38 +41,7 @@ export class FileService {
}
}
async getObjectStream(objectName: string): Promise<NodeJS.ReadableStream> {
try {
return await this.storageProvider.getObjectStream(objectName);
} catch (err) {
console.error("Error getting object stream:", err);
throw err;
}
}
// Multipart upload methods
async createMultipartUpload(objectName: string): Promise<string> {
return await this.storageProvider.createMultipartUpload(objectName);
}
async getPresignedPartUrl(
objectName: string,
uploadId: string,
partNumber: number,
expires: number = 3600
): Promise<string> {
return await this.storageProvider.getPresignedPartUrl(objectName, uploadId, partNumber, expires);
}
async completeMultipartUpload(
objectName: string,
uploadId: string,
parts: Array<{ PartNumber: number; ETag: string }>
): Promise<void> {
await this.storageProvider.completeMultipartUpload(objectName, uploadId, parts);
}
async abortMultipartUpload(objectName: string, uploadId: string): Promise<void> {
await this.storageProvider.abortMultipartUpload(objectName, uploadId);
isFilesystemMode(): boolean {
return !isS3Enabled;
}
}

View File

@@ -0,0 +1,345 @@
import * as fs from "fs";
import * as path from "path";
import { getTempFilePath } from "../../config/directories.config";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
export interface ChunkMetadata {
fileId: string;
chunkIndex: number;
totalChunks: number;
chunkSize: number;
totalSize: number;
fileName: string;
isLastChunk: boolean;
}
export interface ChunkInfo {
fileId: string;
fileName: string;
totalSize: number;
totalChunks: number;
uploadedChunks: Set<number>;
tempPath: string;
createdAt: number;
}
export class ChunkManager {
private static instance: ChunkManager;
private activeUploads = new Map<string, ChunkInfo>();
private finalizingUploads = new Set<string>(); // Track uploads currently being finalized
private cleanupInterval: NodeJS.Timeout;
private constructor() {
// Cleanup expired uploads every 30 minutes
this.cleanupInterval = setInterval(
() => {
this.cleanupExpiredUploads();
},
30 * 60 * 1000
);
}
public static getInstance(): ChunkManager {
if (!ChunkManager.instance) {
ChunkManager.instance = new ChunkManager();
}
return ChunkManager.instance;
}
/**
* Process a chunk upload with streaming
*/
async processChunk(
metadata: ChunkMetadata,
inputStream: NodeJS.ReadableStream,
originalObjectName: string
): Promise<{ isComplete: boolean; finalPath?: string }> {
const startTime = Date.now();
const { fileId, chunkIndex, totalChunks, fileName, totalSize, isLastChunk } = metadata;
console.log(`Processing chunk ${chunkIndex + 1}/${totalChunks} for file ${fileName} (${fileId})`);
let chunkInfo = this.activeUploads.get(fileId);
if (!chunkInfo) {
if (chunkIndex !== 0) {
throw new Error("First chunk must be chunk 0");
}
const tempPath = getTempFilePath(fileId);
chunkInfo = {
fileId,
fileName,
totalSize,
totalChunks,
uploadedChunks: new Set(),
tempPath,
createdAt: Date.now(),
};
this.activeUploads.set(fileId, chunkInfo);
console.log(`Created new upload session for ${fileName} at ${tempPath}`);
}
console.log(
`Validating chunk ${chunkIndex} (total: ${totalChunks}, uploaded: ${Array.from(chunkInfo.uploadedChunks).join(",")})`
);
if (chunkIndex < 0 || chunkIndex >= totalChunks) {
throw new Error(`Invalid chunk index: ${chunkIndex} (must be 0-${totalChunks - 1})`);
}
if (chunkInfo.uploadedChunks.has(chunkIndex)) {
console.log(`Chunk ${chunkIndex} already uploaded, treating as success`);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
}
console.log(`All chunks uploaded, finalizing ${fileName}`);
return await this.finalizeUpload(chunkInfo, metadata, originalObjectName);
}
return { isComplete: false };
}
const tempDir = path.dirname(chunkInfo.tempPath);
await fs.promises.mkdir(tempDir, { recursive: true });
console.log(`Temp directory ensured: ${tempDir}`);
await this.writeChunkToFile(chunkInfo.tempPath, inputStream, chunkIndex === 0);
chunkInfo.uploadedChunks.add(chunkIndex);
try {
const stats = await fs.promises.stat(chunkInfo.tempPath);
const processingTime = Date.now() - startTime;
console.log(
`Chunk ${chunkIndex + 1}/${totalChunks} uploaded successfully in ${processingTime}ms. Temp file size: ${stats.size} bytes`
);
} catch (error) {
console.warn(`Could not get temp file stats:`, error);
}
console.log(
`Checking completion: isLastChunk=${isLastChunk}, uploadedChunks.size=${chunkInfo.uploadedChunks.size}, totalChunks=${totalChunks}`
);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
}
console.log(`All chunks uploaded, finalizing ${fileName}`);
const uploadedChunksArray = Array.from(chunkInfo.uploadedChunks).sort((a, b) => a - b);
console.log(`Uploaded chunks in order: ${uploadedChunksArray.join(", ")}`);
const expectedChunks = Array.from({ length: totalChunks }, (_, i) => i);
const missingChunks = expectedChunks.filter((chunk) => !chunkInfo.uploadedChunks.has(chunk));
if (missingChunks.length > 0) {
throw new Error(`Missing chunks: ${missingChunks.join(", ")}`);
}
return await this.finalizeUpload(chunkInfo, metadata, originalObjectName);
} else {
console.log(
`Not ready for finalization: isLastChunk=${isLastChunk}, uploadedChunks.size=${chunkInfo.uploadedChunks.size}, totalChunks=${totalChunks}`
);
}
return { isComplete: false };
}
/**
* Write chunk to file using streaming
*/
private async writeChunkToFile(
filePath: string,
inputStream: NodeJS.ReadableStream,
isFirstChunk: boolean
): Promise<void> {
return new Promise((resolve, reject) => {
console.log(`Writing chunk to ${filePath} (first: ${isFirstChunk})`);
if (isFirstChunk) {
const writeStream = fs.createWriteStream(filePath, {
highWaterMark: 64 * 1024 * 1024, // 64MB buffer for better performance
});
writeStream.on("error", (error) => {
console.error("Write stream error:", error);
reject(error);
});
writeStream.on("finish", () => {
console.log("Write stream finished successfully");
resolve();
});
inputStream.pipe(writeStream);
} else {
const writeStream = fs.createWriteStream(filePath, {
flags: "a",
highWaterMark: 64 * 1024 * 1024, // 64MB buffer for better performance
});
writeStream.on("error", (error) => {
console.error("Write stream error:", error);
reject(error);
});
writeStream.on("finish", () => {
console.log("Write stream finished successfully");
resolve();
});
inputStream.pipe(writeStream);
}
});
}
/**
* Finalize upload by moving temp file to final location and encrypting (if enabled)
*/
private async finalizeUpload(
chunkInfo: ChunkInfo,
metadata: ChunkMetadata,
originalObjectName: string
): Promise<{ isComplete: boolean; finalPath: string }> {
// Mark as finalizing to prevent race conditions
this.finalizingUploads.add(chunkInfo.fileId);
try {
console.log(`Finalizing upload for ${chunkInfo.fileName}`);
const tempStats = await fs.promises.stat(chunkInfo.tempPath);
console.log(`Temp file size: ${tempStats.size} bytes, expected: ${chunkInfo.totalSize} bytes`);
if (tempStats.size !== chunkInfo.totalSize) {
console.warn(`Size mismatch! Temp: ${tempStats.size}, Expected: ${chunkInfo.totalSize}`);
}
const provider = FilesystemStorageProvider.getInstance();
const finalObjectName = originalObjectName;
const filePath = provider.getFilePath(finalObjectName);
const dir = path.dirname(filePath);
console.log(`Starting finalization: ${finalObjectName}`);
await fs.promises.mkdir(dir, { recursive: true });
const tempReadStream = fs.createReadStream(chunkInfo.tempPath, {
highWaterMark: 64 * 1024 * 1024, // 64MB buffer for better performance
});
const writeStream = fs.createWriteStream(filePath, {
highWaterMark: 64 * 1024 * 1024,
});
const encryptStream = provider.createEncryptStream();
await new Promise<void>((resolve, reject) => {
const startTime = Date.now();
tempReadStream
.pipe(encryptStream)
.pipe(writeStream)
.on("finish", () => {
const duration = Date.now() - startTime;
console.log(`File processed and saved to: ${filePath} in ${duration}ms`);
resolve();
})
.on("error", (error) => {
console.error("Error during processing:", error);
reject(error);
});
});
console.log(`File successfully uploaded and processed: ${finalObjectName}`);
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(chunkInfo.fileId);
this.finalizingUploads.delete(chunkInfo.fileId);
return { isComplete: true, finalPath: finalObjectName };
} catch (error) {
console.error("Error during finalization:", error);
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(chunkInfo.fileId);
this.finalizingUploads.delete(chunkInfo.fileId);
throw error;
}
}
/**
* Cleanup temporary file
*/
private async cleanupTempFile(tempPath: string): Promise<void> {
try {
await fs.promises.access(tempPath);
await fs.promises.unlink(tempPath);
console.log(`Temp file cleaned up: ${tempPath}`);
} catch (error: any) {
if (error.code === "ENOENT") {
console.log(`Temp file already cleaned up: ${tempPath}`);
} else {
console.warn(`Failed to cleanup temp file ${tempPath}:`, error);
}
}
}
/**
* Cleanup expired uploads (older than 2 hours)
*/
private async cleanupExpiredUploads(): Promise<void> {
const now = Date.now();
const maxAge = 2 * 60 * 60 * 1000; // 2 hours
for (const [fileId, chunkInfo] of this.activeUploads.entries()) {
if (now - chunkInfo.createdAt > maxAge) {
console.log(`Cleaning up expired upload: ${fileId}`);
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(fileId);
this.finalizingUploads.delete(fileId);
}
}
}
/**
* Get upload progress
*/
getUploadProgress(fileId: string): { uploaded: number; total: number; percentage: number } | null {
const chunkInfo = this.activeUploads.get(fileId);
if (!chunkInfo) return null;
return {
uploaded: chunkInfo.uploadedChunks.size,
total: chunkInfo.totalChunks,
percentage: Math.round((chunkInfo.uploadedChunks.size / chunkInfo.totalChunks) * 100),
};
}
/**
* Cancel upload
*/
async cancelUpload(fileId: string): Promise<void> {
const chunkInfo = this.activeUploads.get(fileId);
if (chunkInfo) {
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(fileId);
this.finalizingUploads.delete(fileId);
}
}
/**
* Cleanup on shutdown
*/
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
for (const [fileId, chunkInfo] of this.activeUploads.entries()) {
this.cleanupTempFile(chunkInfo.tempPath);
}
this.activeUploads.clear();
this.finalizingUploads.clear();
}
}

View File

@@ -0,0 +1,416 @@
import * as fs from "fs";
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();
private encodeFilenameForHeader(filename: string): string {
if (!filename || filename.trim() === "") {
return 'attachment; filename="download"';
}
let sanitized = filename
.replace(/"/g, "'")
.replace(/[\r\n\t\v\f]/g, "")
.replace(/[\\|/]/g, "-")
.replace(/[<>:|*?]/g, "");
sanitized = sanitized
.split("")
.filter((char) => {
const code = char.charCodeAt(0);
return code >= 32 && !(code >= 127 && code <= 159);
})
.join("")
.trim();
if (!sanitized) {
return 'attachment; filename="download"';
}
const asciiSafe = sanitized
.split("")
.filter((char) => {
const code = char.charCodeAt(0);
return code >= 32 && code <= 126;
})
.join("");
if (asciiSafe && asciiSafe.trim()) {
const encoded = encodeURIComponent(sanitized);
return `attachment; filename="${asciiSafe}"; filename*=UTF-8''${encoded}`;
} else {
const encoded = encodeURIComponent(sanitized);
return `attachment; filename*=UTF-8''${encoded}`;
}
}
async upload(request: FastifyRequest, reply: FastifyReply) {
try {
const { token } = request.params as { token: string };
const provider = FilesystemStorageProvider.getInstance();
const tokenData = provider.validateUploadToken(token);
if (!tokenData) {
return reply.status(400).send({ error: "Invalid or expired upload token" });
}
const chunkMetadata = this.extractChunkMetadata(request);
if (chunkMetadata) {
try {
const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
if (result.isComplete) {
provider.consumeUploadToken(token);
reply.status(200).send({
message: "File uploaded successfully",
objectName: result.finalPath,
finalObjectName: result.finalPath,
});
} else {
reply.status(200).send({
message: "Chunk uploaded successfully",
progress: this.chunkManager.getUploadProgress(chunkMetadata.fileId),
});
}
} catch (chunkError: any) {
return reply.status(400).send({
error: chunkError.message || "Chunked upload failed",
details: chunkError.toString(),
});
}
} else {
await this.uploadFileStream(request, provider, tokenData.objectName);
provider.consumeUploadToken(token);
reply.status(200).send({ message: "File uploaded successfully" });
}
} catch (error) {
return reply.status(500).send({ error: "Internal server error" });
}
}
private async uploadFileStream(request: FastifyRequest, provider: FilesystemStorageProvider, objectName: string) {
await provider.uploadFileFromStream(objectName, request.raw);
}
private extractChunkMetadata(request: FastifyRequest): ChunkMetadata | null {
const fileId = request.headers["x-file-id"] as string;
const chunkIndex = request.headers["x-chunk-index"] as string;
const totalChunks = request.headers["x-total-chunks"] as string;
const chunkSize = request.headers["x-chunk-size"] as string;
const totalSize = request.headers["x-total-size"] as string;
const fileName = request.headers["x-file-name"] as string;
const isLastChunk = request.headers["x-is-last-chunk"] as string;
if (!fileId || !chunkIndex || !totalChunks || !chunkSize || !totalSize || !fileName) {
return null;
}
const metadata = {
fileId,
chunkIndex: parseInt(chunkIndex, 10),
totalChunks: parseInt(totalChunks, 10),
chunkSize: parseInt(chunkSize, 10),
totalSize: parseInt(totalSize, 10),
fileName,
isLastChunk: isLastChunk === "true",
};
return metadata;
}
private async handleChunkedUpload(request: FastifyRequest, metadata: ChunkMetadata, originalObjectName: string) {
const stream = request.raw;
stream.on("error", (error) => {
console.error("Request stream error:", error);
});
return await this.chunkManager.processChunk(metadata, stream, originalObjectName);
}
async getUploadProgress(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileId } = request.params as { fileId: string };
const progress = this.chunkManager.getUploadProgress(fileId);
if (!progress) {
return reply.status(404).send({ error: "Upload not found" });
}
reply.status(200).send(progress);
} catch (error) {
return reply.status(500).send({ error: "Internal server error" });
}
}
async cancelUpload(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileId } = request.params as { fileId: string };
await this.chunkManager.cancelUpload(fileId);
reply.status(200).send({ message: "Upload cancelled successfully" });
} catch (error) {
return reply.status(500).send({ error: "Internal server error" });
}
}
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);
if (!tokenData) {
return reply.status(400).send({ error: "Invalid or expired download token" });
}
const filePath = provider.getFilePath(tokenData.objectName);
const stats = await fs.promises.stat(filePath);
const fileSize = stats.size;
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", getContentType(fileName));
reply.header("Accept-Ranges", "bytes");
reply.header("X-Download-ID", downloadId);
reply.raw.on("close", () => {
this.memoryManager.endDownload(downloadId);
console.log(`[DOWNLOAD] Client disconnected: ${downloadId}`);
});
reply.raw.on("error", () => {
this.memoryManager.endDownload(downloadId);
console.log(`[DOWNLOAD] Client error: ${downloadId}`);
});
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 {
reply.header("Content-Length", fileSize);
await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId);
}
provider.consumeDownloadToken(token);
} finally {
this.memoryManager.endDownload(downloadId);
}
} catch (error) {
this.memoryManager.endDownload(downloadId);
console.error(`[DOWNLOAD] Error in ${downloadId}:`, error);
return reply.status(500).send({ error: "Internal server error" });
}
}
private async downloadFileStream(
reply: FastifyReply,
provider: FilesystemStorageProvider,
objectName: string,
downloadId?: string
) {
try {
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) {
console.error("Download error:", error);
FilesystemStorageProvider.logMemoryUsage(`Download failed: ${objectName} (${downloadId})`);
if (!reply.sent) {
reply.status(500).send({ error: "Download failed" });
}
}
}
private async downloadFileRange(
reply: FastifyReply,
provider: FilesystemStorageProvider,
objectName: string,
start: number,
end: number,
downloadId?: string
) {
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

@@ -0,0 +1,123 @@
import { FastifyInstance, FastifyRequest } from "fastify";
import { z } from "zod";
import { FilesystemController } from "./controller";
export async function filesystemRoutes(app: FastifyInstance) {
const filesystemController = new FilesystemController();
app.addContentTypeParser("*", async (request: FastifyRequest, payload: any) => {
return payload;
});
app.addContentTypeParser("application/json", async (request: FastifyRequest, payload: any) => {
return payload;
});
app.put(
"/filesystem/upload/:token",
{
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024, // 1PB limit
schema: {
tags: ["Filesystem"],
operationId: "uploadToFilesystem",
summary: "Upload file to filesystem storage",
description: "Upload a file directly to the encrypted filesystem storage",
params: z.object({
token: z.string().describe("Upload token"),
}),
response: {
200: z.object({
message: z.string(),
}),
400: z.object({
error: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.upload.bind(filesystemController)
);
app.get(
"/filesystem/download/:token",
{
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024, // 1PB limit
schema: {
tags: ["Filesystem"],
operationId: "downloadFromFilesystem",
summary: "Download file from filesystem storage",
description: "Download a file directly from the encrypted filesystem storage",
params: z.object({
token: z.string().describe("Download token"),
}),
response: {
200: z.string().describe("File content"),
400: z.object({
error: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.download.bind(filesystemController)
);
app.get(
"/filesystem/upload-progress/:fileId",
{
schema: {
tags: ["Filesystem"],
operationId: "getUploadProgress",
summary: "Get chunked upload progress",
description: "Get the progress of a chunked upload",
params: z.object({
fileId: z.string().describe("File ID"),
}),
response: {
200: z.object({
uploaded: z.number(),
total: z.number(),
percentage: z.number(),
}),
404: z.object({
error: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.getUploadProgress.bind(filesystemController)
);
app.delete(
"/filesystem/cancel-upload/:fileId",
{
schema: {
tags: ["Filesystem"],
operationId: "cancelUpload",
summary: "Cancel chunked upload",
description: "Cancel an ongoing chunked upload",
params: z.object({
fileId: z.string().describe("File ID"),
}),
response: {
200: z.object({
message: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
},
filesystemController.cancelUpload.bind(filesystemController)
);
}

View File

@@ -35,13 +35,21 @@ export class FolderController {
}
}
// Check for duplicates and auto-rename if necessary
const { generateUniqueFolderName } = await import("../../utils/file-name-generator.js");
const uniqueName = await generateUniqueFolderName(input.name, userId, input.parentId);
const existingFolder = await prisma.folder.findFirst({
where: {
name: input.name,
parentId: input.parentId || null,
userId,
},
});
if (existingFolder) {
return reply.status(400).send({ error: "A folder with this name already exists in this location" });
}
const folderRecord = await prisma.folder.create({
data: {
name: uniqueName,
name: input.name,
description: input.description,
objectName: input.objectName,
parentId: input.parentId,
@@ -223,11 +231,19 @@ export class FolderController {
return reply.status(403).send({ error: "Access denied." });
}
// If renaming the folder, check for duplicates and auto-rename if necessary
if (updateData.name && updateData.name !== folderRecord.name) {
const { generateUniqueFolderName } = await import("../../utils/file-name-generator.js");
const uniqueName = await generateUniqueFolderName(updateData.name, userId, folderRecord.parentId, id);
updateData.name = uniqueName;
const duplicateFolder = await prisma.folder.findFirst({
where: {
name: updateData.name,
parentId: folderRecord.parentId,
userId,
id: { not: id },
},
});
if (duplicateFolder) {
return reply.status(400).send({ error: "A folder with this name already exists in this location" });
}
}
const updatedFolder = await prisma.folder.update({

View File

@@ -1,3 +1,5 @@
import { isS3Enabled } from "../../config/storage.config";
import { FilesystemStorageProvider } from "../../providers/filesystem-storage.provider";
import { S3StorageProvider } from "../../providers/s3-storage.provider";
import { prisma } from "../../shared/prisma";
import { StorageProvider } from "../../types/storage";
@@ -6,8 +8,11 @@ export class FolderService {
private storageProvider: StorageProvider;
constructor() {
// Always use S3 (Garage internal or external S3)
this.storageProvider = new S3StorageProvider();
if (isS3Enabled) {
this.storageProvider = new S3StorageProvider();
} else {
this.storageProvider = FilesystemStorageProvider.getInstance();
}
}
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
@@ -37,6 +42,10 @@ export class FolderService {
}
}
isFilesystemMode(): boolean {
return !isS3Enabled;
}
async getAllFilesInFolder(folderId: string, userId: string, basePath: string = ""): Promise<any[]> {
const files = await prisma.file.findMany({
where: { folderId, userId },

View File

@@ -319,12 +319,59 @@ export class ReverseShareController {
const { fileId } = request.params as { fileId: string };
// Pass request context for internal storage proxy URLs
const requestContext = { protocol: "https", host: "localhost" }; // Simplified - frontend will handle the real URL
const fileInfo = await this.reverseShareService.getFileInfo(fileId, userId);
const downloadId = `reverse-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const result = await this.reverseShareService.downloadReverseShareFile(fileId, userId, requestContext);
const { DownloadMemoryManager } = await import("../../utils/download-memory-manager.js");
const memoryManager = DownloadMemoryManager.getInstance();
return reply.send(result);
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 });
@@ -466,8 +513,12 @@ export class ReverseShareController {
return reply.status(401).send({ error: "Unauthorized" });
}
console.log(`Copy to my files: User ${userId} copying file ${fileId}`);
const file = await this.reverseShareService.copyReverseShareFileToUserFiles(fileId, userId);
console.log(`Copy to my files: Successfully copied file ${fileId}`);
return reply.send({ file, message: "File copied to your files successfully" });
} catch (error: any) {
console.error(`Copy to my files: Error:`, error.message);
@@ -485,17 +536,4 @@ export class ReverseShareController {
return reply.status(500).send({ error: "Internal server error" });
}
}
async getReverseShareMetadataByAlias(request: FastifyRequest, reply: FastifyReply) {
try {
const { alias } = request.params as { alias: string };
const metadata = await this.reverseShareService.getReverseShareMetadataByAlias(alias);
return reply.send(metadata);
} catch (error: any) {
if (error.message === "Reverse share not found") {
return reply.status(404).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
}

View File

@@ -592,32 +592,4 @@ export async function reverseShareRoutes(app: FastifyInstance) {
},
reverseShareController.copyFileToUserFiles.bind(reverseShareController)
);
app.get(
"/reverse-shares/alias/:alias/metadata",
{
schema: {
tags: ["Reverse Share"],
operationId: "getReverseShareMetadataByAlias",
summary: "Get reverse share metadata by alias for Open Graph",
description: "Get lightweight metadata for a reverse share by alias, used for social media previews",
params: z.object({
alias: z.string().describe("Alias of the reverse share"),
}),
response: {
200: z.object({
name: z.string().nullable(),
description: z.string().nullable(),
totalFiles: z.number(),
hasPassword: z.boolean(),
isExpired: z.boolean(),
isInactive: z.boolean(),
maxFiles: z.number().nullable(),
}),
404: z.object({ error: z.string() }),
},
},
},
reverseShareController.getReverseShareMetadataByAlias.bind(reverseShareController)
);
}

View File

@@ -228,21 +228,9 @@ export class ReverseShareService {
}
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
// Import storage config to check if using internal or external S3
const { isInternalStorage } = await import("../../config/storage.config.js");
if (isInternalStorage) {
// Internal storage: Use backend proxy for uploads (127.0.0.1 not accessible from client)
// Note: This would need request context, but reverse-shares are typically used by external users
// For now, we'll use presigned URLs and handle the error on the client side
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
} else {
// External S3: Use presigned URLs directly (more efficient)
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
}
return { url, expiresIn: expires };
}
async getPresignedUrlByAlias(alias: string, objectName: string, password?: string) {
@@ -270,21 +258,9 @@ export class ReverseShareService {
}
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
// Import storage config to check if using internal or external S3
const { isInternalStorage } = await import("../../config/storage.config.js");
if (isInternalStorage) {
// Internal storage: Use backend proxy for uploads (127.0.0.1 not accessible from client)
// Note: This would need request context, but reverse-shares are typically used by external users
// For now, we'll use presigned URLs and handle the error on the client side
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
} else {
// External S3: Use presigned URLs directly (more efficient)
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return { url, expiresIn: expires };
}
return { url, expiresIn: expires };
}
async registerFileUpload(reverseShareId: string, fileData: UploadToReverseShareInput, password?: string) {
@@ -410,11 +386,7 @@ export class ReverseShareService {
};
}
async downloadReverseShareFile(
fileId: string,
creatorId: string,
requestContext?: { protocol: string; host: string }
) {
async downloadReverseShareFile(fileId: string, creatorId: string) {
const file = await this.reverseShareRepository.findFileById(fileId);
if (!file) {
throw new Error("File not found");
@@ -426,19 +398,8 @@ export class ReverseShareService {
const fileName = file.name;
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
// Import storage config to check if using internal or external S3
const { isInternalStorage } = await import("../../config/storage.config.js");
if (isInternalStorage) {
// Internal storage: Use frontend proxy (much simpler!)
const url = `/api/files/download?objectName=${encodeURIComponent(file.objectName)}`;
return { url, expiresIn: expires };
} else {
// External S3: Use presigned URLs directly (more efficient, no backend proxy)
const url = await this.fileService.getPresignedGetUrl(file.objectName, expires, fileName);
return { url, expiresIn: expires };
}
const url = await this.fileService.getPresignedGetUrl(file.objectName, expires, fileName);
return { url, expiresIn: expires };
}
async deleteReverseShareFile(fileId: string, creatorId: string) {
@@ -607,59 +568,76 @@ export class ReverseShareService {
const newObjectName = `${creatorId}/${Date.now()}-${file.name}`;
// Copy file using S3 presigned URLs
const fileSizeMB = Number(file.size) / (1024 * 1024);
const needsStreaming = fileSizeMB > 100;
if (this.fileService.isFilesystemMode()) {
const { FilesystemStorageProvider } = await import("../../providers/filesystem-storage.provider.js");
const provider = FilesystemStorageProvider.getInstance();
const downloadUrl = await this.fileService.getPresignedGetUrl(file.objectName, 300);
const uploadUrl = await this.fileService.getPresignedPutUrl(newObjectName, 300);
const sourcePath = provider.getFilePath(file.objectName);
const fs = await import("fs");
let retries = 0;
const maxRetries = 3;
let success = false;
const targetPath = provider.getFilePath(newObjectName);
while (retries < maxRetries && !success) {
try {
const response = await fetch(downloadUrl, {
signal: AbortSignal.timeout(600000), // 10 minutes timeout
});
const path = await import("path");
const targetDir = path.dirname(targetPath);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`);
const { copyFile } = await import("fs/promises");
await copyFile(sourcePath, targetPath);
} else {
const fileSizeMB = Number(file.size) / (1024 * 1024);
const needsStreaming = fileSizeMB > 100;
const downloadUrl = await this.fileService.getPresignedGetUrl(file.objectName, 300);
const uploadUrl = await this.fileService.getPresignedPutUrl(newObjectName, 300);
let retries = 0;
const maxRetries = 3;
let success = false;
while (retries < maxRetries && !success) {
try {
const response = await fetch(downloadUrl, {
signal: AbortSignal.timeout(600000), // 10 minutes timeout
});
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`);
}
if (!response.body) {
throw new Error("No response body received");
}
const uploadOptions: any = {
method: "PUT",
body: response.body,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": file.size.toString(),
},
signal: AbortSignal.timeout(600000), // 10 minutes timeout
};
const uploadResponse = await fetch(uploadUrl, uploadOptions);
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Failed to upload file: ${uploadResponse.statusText} - ${errorText}`);
}
success = true;
} catch (error: any) {
retries++;
if (retries >= maxRetries) {
throw new Error(`Failed to copy file after ${maxRetries} attempts: ${error.message}`);
}
const delay = Math.min(1000 * Math.pow(2, retries - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
if (!response.body) {
throw new Error("No response body received");
}
const uploadOptions: any = {
method: "PUT",
body: response.body,
duplex: "half",
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": file.size.toString(),
},
signal: AbortSignal.timeout(9600000), // 160 minutes timeout
};
const uploadResponse = await fetch(uploadUrl, uploadOptions);
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Failed to upload file: ${uploadResponse.statusText} - ${errorText}`);
}
success = true;
} catch (error: any) {
retries++;
if (retries >= maxRetries) {
throw new Error(`Failed to copy file after ${maxRetries} attempts: ${error.message}`);
}
const delay = Math.min(1000 * Math.pow(2, retries - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
@@ -795,30 +773,4 @@ export class ReverseShareService {
updatedAt: file.updatedAt.toISOString(),
};
}
async getReverseShareMetadataByAlias(alias: string) {
const reverseShare = await this.reverseShareRepository.findByAlias(alias);
if (!reverseShare) {
throw new Error("Reverse share not found");
}
// Check if reverse share is expired
const isExpired = reverseShare.expiration ? new Date(reverseShare.expiration) < new Date() : false;
// Check if inactive
const isInactive = !reverseShare.isActive;
const totalFiles = reverseShare.files?.length || 0;
const hasPassword = !!reverseShare.password;
return {
name: reverseShare.name,
description: reverseShare.description,
totalFiles,
hasPassword,
isExpired,
isInactive,
maxFiles: reverseShare.maxFiles,
};
}
}

View File

@@ -1,174 +0,0 @@
/**
* S3 Storage Controller (Simplified)
*
* This controller handles uploads/downloads using S3-compatible storage (Garage).
* It's much simpler than the filesystem controller because:
* - Uses S3 multipart uploads (no chunk management needed)
* - Uses presigned URLs (no streaming through Node.js)
* - No memory management needed (Garage handles it)
* - No encryption needed (Garage handles it)
*
* Replaces ~800 lines of complex code with ~100 lines of simple code.
*/
import { FastifyReply, FastifyRequest } from "fastify";
import { S3StorageProvider } from "../../providers/s3-storage.provider";
export class S3StorageController {
private storageProvider = new S3StorageProvider();
/**
* Generate presigned upload URL
* Client uploads directly to S3 (Garage)
*/
async getUploadUrl(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName, expires } = request.body as { objectName: string; expires?: number };
if (!objectName) {
return reply.status(400).send({ error: "objectName is required" });
}
const expiresIn = expires || 3600; // 1 hour default
// Import storage config to check if using internal or external S3
const { isInternalStorage } = await import("../../config/storage.config.js");
let uploadUrl: string;
if (isInternalStorage) {
// Internal storage: Use frontend proxy (much simpler!)
uploadUrl = `/api/files/upload?objectName=${encodeURIComponent(objectName)}`;
} else {
// External S3: Use presigned URLs directly (more efficient)
uploadUrl = await this.storageProvider.getPresignedPutUrl(objectName, expiresIn);
}
return reply.status(200).send({
uploadUrl,
objectName,
expiresIn,
message: isInternalStorage ? "Upload via backend proxy" : "Upload directly to this URL using PUT request",
});
} catch (error) {
console.error("[S3] Error generating upload URL:", error);
return reply.status(500).send({ error: "Failed to generate upload URL" });
}
}
/**
* Generate presigned download URL
* For internal storage: Uses backend proxy
* For external S3: Uses presigned URLs directly
*/
async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName, expires, fileName } = request.query as {
objectName: string;
expires?: string;
fileName?: string;
};
if (!objectName) {
return reply.status(400).send({ error: "objectName is required" });
}
// Check if file exists
const exists = await this.storageProvider.fileExists(objectName);
if (!exists) {
return reply.status(404).send({ error: "File not found" });
}
const expiresIn = expires ? parseInt(expires, 10) : 3600;
// Import storage config to check if using internal or external S3
const { isInternalStorage } = await import("../../config/storage.config.js");
let downloadUrl: string;
if (isInternalStorage) {
// Internal storage: Use frontend proxy (much simpler!)
downloadUrl = `/api/files/download?objectName=${encodeURIComponent(objectName)}`;
} else {
// External S3: Use presigned URLs directly (more efficient)
downloadUrl = await this.storageProvider.getPresignedGetUrl(objectName, expiresIn, fileName);
}
return reply.status(200).send({
downloadUrl,
objectName,
expiresIn,
message: isInternalStorage ? "Download via backend proxy" : "Download directly from this URL",
});
} catch (error) {
console.error("[S3] Error generating download URL:", error);
return reply.status(500).send({ error: "Failed to generate download URL" });
}
}
/**
* Upload directly (for small files)
* Receives file and uploads to S3
*/
async upload(request: FastifyRequest, reply: FastifyReply) {
try {
// For large files, clients should use presigned URLs
// This is just for backward compatibility or small files
return reply.status(501).send({
error: "Not implemented",
message: "Use getUploadUrl endpoint for efficient uploads",
});
} catch (error) {
console.error("[S3] Error in upload:", error);
return reply.status(500).send({ error: "Upload failed" });
}
}
/**
* Delete object from S3
*/
async deleteObject(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName } = request.params as { objectName: string };
if (!objectName) {
return reply.status(400).send({ error: "objectName is required" });
}
await this.storageProvider.deleteObject(objectName);
return reply.status(200).send({
message: "Object deleted successfully",
objectName,
});
} catch (error) {
console.error("[S3] Error deleting object:", error);
return reply.status(500).send({ error: "Failed to delete object" });
}
}
/**
* Check if object exists
*/
async checkExists(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName } = request.query as { objectName: string };
if (!objectName) {
return reply.status(400).send({ error: "objectName is required" });
}
const exists = await this.storageProvider.fileExists(objectName);
return reply.status(200).send({
exists,
objectName,
});
} catch (error) {
console.error("[S3] Error checking existence:", error);
return reply.status(500).send({ error: "Failed to check existence" });
}
}
}

View File

@@ -1,112 +0,0 @@
/**
* S3 Storage Routes
*
* Simple routes for S3-based storage using presigned URLs.
* Much simpler than filesystem routes - no chunk management, no streaming.
*/
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { S3StorageController } from "./controller";
export async function s3StorageRoutes(app: FastifyInstance) {
const controller = new S3StorageController();
// Get presigned upload URL
app.post(
"/s3/upload-url",
{
schema: {
tags: ["S3 Storage"],
operationId: "getS3UploadUrl",
summary: "Get presigned URL for upload",
description: "Returns a presigned URL that clients can use to upload directly to S3",
body: z.object({
objectName: z.string().describe("Object name/path in S3"),
expires: z.number().optional().describe("URL expiration in seconds (default: 3600)"),
}),
response: {
200: z.object({
uploadUrl: z.string(),
objectName: z.string(),
expiresIn: z.number(),
message: z.string(),
}),
},
},
},
controller.getUploadUrl.bind(controller)
);
// Get presigned download URL
app.get(
"/s3/download-url",
{
schema: {
tags: ["S3 Storage"],
operationId: "getS3DownloadUrl",
summary: "Get presigned URL for download",
description: "Returns a presigned URL that clients can use to download directly from S3",
querystring: z.object({
objectName: z.string().describe("Object name/path in S3"),
expires: z.string().optional().describe("URL expiration in seconds (default: 3600)"),
fileName: z.string().optional().describe("Optional filename for download"),
}),
response: {
200: z.object({
downloadUrl: z.string(),
objectName: z.string(),
expiresIn: z.number(),
message: z.string(),
}),
},
},
},
controller.getDownloadUrl.bind(controller)
);
// Delete object
app.delete(
"/s3/object/:objectName",
{
schema: {
tags: ["S3 Storage"],
operationId: "deleteS3Object",
summary: "Delete object from S3",
params: z.object({
objectName: z.string().describe("Object name/path in S3"),
}),
response: {
200: z.object({
message: z.string(),
objectName: z.string(),
}),
},
},
},
controller.deleteObject.bind(controller)
);
// Check if object exists
app.get(
"/s3/exists",
{
schema: {
tags: ["S3 Storage"],
operationId: "checkS3ObjectExists",
summary: "Check if object exists in S3",
querystring: z.object({
objectName: z.string().describe("Object name/path in S3"),
}),
response: {
200: z.object({
exists: z.boolean(),
objectName: z.string(),
}),
},
},
},
controller.checkExists.bind(controller)
);
}

View File

@@ -295,17 +295,4 @@ export class ShareController {
return reply.status(400).send({ error: error.message });
}
}
async getShareMetadataByAlias(request: FastifyRequest, reply: FastifyReply) {
try {
const { alias } = request.params as { alias: string };
const metadata = await this.shareService.getShareMetadataByAlias(alias);
return reply.send(metadata);
} catch (error: any) {
if (error.message === "Share not found") {
return reply.status(404).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
}

View File

@@ -17,9 +17,6 @@ export interface IShareRepository {
findShareBySecurityId(
securityId: string
): Promise<(Share & { security: ShareSecurity; files: any[]; folders: any[] }) | null>;
findShareByAlias(
alias: string
): Promise<(Share & { security: ShareSecurity; files: any[]; folders: any[]; recipients: any[] }) | null>;
updateShare(id: string, data: Partial<Share>): Promise<Share>;
updateShareSecurity(id: string, data: Partial<ShareSecurity>): Promise<ShareSecurity>;
deleteShare(id: string): Promise<Share>;
@@ -133,41 +130,6 @@ export class PrismaShareRepository implements IShareRepository {
});
}
async findShareByAlias(alias: string) {
const shareAlias = await prisma.shareAlias.findUnique({
where: { alias },
include: {
share: {
include: {
security: true,
files: true,
folders: {
select: {
id: true,
name: true,
description: true,
objectName: true,
parentId: true,
userId: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
files: true,
children: true,
},
},
},
},
recipients: true,
},
},
},
});
return shareAlias?.share || null;
}
async updateShare(id: string, data: Partial<Share>): Promise<Share> {
return prisma.share.update({
where: { id },

View File

@@ -347,32 +347,4 @@ export async function shareRoutes(app: FastifyInstance) {
},
shareController.notifyRecipients.bind(shareController)
);
app.get(
"/shares/alias/:alias/metadata",
{
schema: {
tags: ["Share"],
operationId: "getShareMetadataByAlias",
summary: "Get share metadata by alias for Open Graph",
description: "Get lightweight metadata for a share by alias, used for social media previews",
params: z.object({
alias: z.string().describe("The share alias"),
}),
response: {
200: z.object({
name: z.string().nullable(),
description: z.string().nullable(),
totalFiles: z.number(),
totalFolders: z.number(),
hasPassword: z.boolean(),
isExpired: z.boolean(),
isMaxViewsReached: z.boolean(),
}),
404: z.object({ error: z.string() }),
},
},
},
shareController.getShareMetadataByAlias.bind(shareController)
);
}

View File

@@ -440,31 +440,4 @@ export class ShareService {
notifiedRecipients,
};
}
async getShareMetadataByAlias(alias: string) {
const share = await this.shareRepository.findShareByAlias(alias);
if (!share) {
throw new Error("Share not found");
}
// Check if share is expired
const isExpired = share.expiration ? new Date(share.expiration) < new Date() : false;
// Check if max views reached
const isMaxViewsReached = share.security.maxViews !== null ? share.views >= share.security.maxViews : false;
const totalFiles = share.files?.length || 0;
const totalFolders = share.folders?.length || 0;
const hasPassword = !!share.security.password;
return {
name: share.name,
description: share.description,
totalFiles,
totalFolders,
hasPassword,
isExpired,
isMaxViewsReached,
};
}
}

View File

@@ -0,0 +1,710 @@
import * as crypto from "crypto";
import * as fsSync from "fs";
import * as fs from "fs/promises";
import * as path from "path";
import { Transform } from "stream";
import { pipeline } from "stream/promises";
import { directoriesConfig, getTempFilePath } from "../config/directories.config";
import { env } from "../env";
import { StorageProvider } from "../types/storage";
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);
}
public static getInstance(): FilesystemStorageProvider {
if (!FilesystemStorageProvider.instance) {
FilesystemStorageProvider.instance = new FilesystemStorageProvider();
}
return FilesystemStorageProvider.instance;
}
private async ensureUploadsDir(): Promise<void> {
try {
await fs.access(this.uploadsDir);
} catch {
await fs.mkdir(this.uploadsDir, { recursive: true });
}
}
private cleanExpiredTokens(): void {
const now = Date.now();
for (const [token, data] of this.uploadTokens.entries()) {
if (now > data.expiresAt) {
this.uploadTokens.delete(token);
}
}
for (const [token, data] of this.downloadTokens.entries()) {
if (now > data.expiresAt) {
this.downloadTokens.delete(token);
}
}
}
public getFilePath(objectName: string): string {
const sanitizedName = objectName.replace(/[^a-zA-Z0-9\-_./]/g, "_");
return path.join(this.uploadsDir, sanitizedName);
}
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);
let isFirstChunk = true;
return new Transform({
highWaterMark: 64 * 1024,
transform(chunk, _encoding, callback) {
try {
if (isFirstChunk) {
this.push(iv);
isFirstChunk = false;
}
const encrypted = cipher.update(chunk);
this.push(encrypted);
callback();
} catch (error) {
callback(error as Error);
}
},
flush(callback) {
try {
const final = cipher.final();
this.push(final);
callback();
} catch (error) {
callback(error as Error);
}
},
});
}
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({
highWaterMark: 64 * 1024,
transform(chunk, _encoding, callback) {
try {
if (!iv) {
ivBuffer = Buffer.concat([ivBuffer, chunk]);
if (ivBuffer.length >= 16) {
iv = ivBuffer.subarray(0, 16);
decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
const remainingData = ivBuffer.subarray(16);
if (remainingData.length > 0) {
const decrypted = decipher.update(remainingData);
this.push(decrypted);
}
}
callback();
return;
}
if (decipher) {
const decrypted = decipher.update(chunk);
this.push(decrypted);
}
callback();
} catch (error) {
callback(error as Error);
}
},
flush(callback) {
try {
if (decipher) {
const final = decipher.final();
this.push(final);
}
callback();
} catch (error) {
callback(error as Error);
}
},
});
}
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = Date.now() + expires * 1000;
this.uploadTokens.set(token, { objectName, expiresAt });
return `/api/filesystem/upload/${token}`;
}
async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = Date.now() + expires * 1000;
this.downloadTokens.set(token, { objectName, expiresAt, fileName });
return `/api/filesystem/download/${token}`;
}
async deleteObject(objectName: string): Promise<void> {
const filePath = this.getFilePath(objectName);
try {
await fs.unlink(filePath);
} catch (error: any) {
if (error.code !== "ENOENT") {
throw error;
}
}
}
async uploadFile(objectName: string, buffer: Buffer): Promise<void> {
const filePath = this.getFilePath(objectName);
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const { Readable } = await import("stream");
const readable = Readable.from(buffer);
await this.uploadFileFromStream(objectName, readable);
}
async uploadFileFromStream(objectName: string, inputStream: NodeJS.ReadableStream): Promise<void> {
const filePath = this.getFilePath(objectName);
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const tempPath = getTempFilePath(objectName);
const tempDir = path.dirname(tempPath);
await fs.mkdir(tempDir, { recursive: true });
const writeStream = fsSync.createWriteStream(tempPath);
const encryptStream = this.createEncryptStream();
try {
await pipeline(inputStream, encryptStream, writeStream);
await fs.rename(tempPath, filePath);
} catch (error) {
await this.cleanupTempFile(tempPath);
throw error;
}
}
async downloadFile(objectName: string): Promise<Buffer> {
const filePath = this.getFilePath(objectName);
const fileBuffer = await fs.readFile(filePath);
if (this.isEncryptionDisabled) {
return fileBuffer;
}
if (fileBuffer.length > 16) {
try {
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(fileBuffer);
}
}
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 {
const key = this.createEncryptionKey();
const iv = encryptedBuffer.slice(0, 16);
const encrypted = encryptedBuffer.slice(16);
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}
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 {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
validateUploadToken(token: string): { objectName: string } | null {
const data = this.uploadTokens.get(token);
if (!data || Date.now() > data.expiresAt) {
this.uploadTokens.delete(token);
return null;
}
return { objectName: data.objectName };
}
validateDownloadToken(token: string): { objectName: string; fileName?: string } | null {
const data = this.downloadTokens.get(token);
if (!data) {
return null;
}
const now = Date.now();
if (now > data.expiresAt) {
this.downloadTokens.delete(token);
return null;
}
return { objectName: data.objectName, fileName: data.fileName };
}
consumeUploadToken(token: string): void {
this.uploadTokens.delete(token);
}
consumeDownloadToken(token: string): void {
this.downloadTokens.delete(token);
}
private async cleanupTempFile(tempPath: string): Promise<void> {
try {
await fs.unlink(tempPath);
const tempDir = path.dirname(tempPath);
try {
const files = await fs.readdir(tempDir);
if (files.length === 0) {
await fs.rmdir(tempDir);
}
} catch (dirError: any) {
if (dirError.code !== "ENOTEMPTY" && dirError.code !== "ENOENT") {
console.warn("Warning: Could not remove temp directory:", dirError.message);
}
}
} catch (cleanupError: any) {
if (cleanupError.code !== "ENOENT") {
console.error("Error deleting temp file:", cleanupError);
}
}
}
private async cleanupEmptyTempDirs(): Promise<void> {
try {
const tempUploadsDir = directoriesConfig.tempUploads;
try {
await fs.access(tempUploadsDir);
} catch {
return;
}
const items = await fs.readdir(tempUploadsDir);
for (const item of items) {
const itemPath = path.join(tempUploadsDir, item);
try {
const stat = await fs.stat(itemPath);
if (stat.isDirectory()) {
const dirContents = await fs.readdir(itemPath);
if (dirContents.length === 0) {
await fs.rmdir(itemPath);
console.log(`🧹 Cleaned up empty temp directory: ${itemPath}`);
}
} else if (stat.isFile()) {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
if (stat.mtime.getTime() < oneHourAgo) {
await fs.unlink(itemPath);
console.log(`🧹 Cleaned up stale temp file: ${itemPath}`);
}
}
} catch (error: any) {
if (error.code !== "ENOENT") {
console.warn(`Warning: Could not process temp item ${itemPath}:`, error.message);
}
}
}
} catch (error) {
console.error("Error during temp directory cleanup:", error);
}
}
}

View File

@@ -1,39 +1,17 @@
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { bucketName, createPublicS3Client, s3Client } from "../config/storage.config";
import { bucketName, s3Client } from "../config/storage.config";
import { StorageProvider } from "../types/storage";
import { getContentType } from "../utils/mime-types";
export class S3StorageProvider implements StorageProvider {
private ensureClient() {
constructor() {
if (!s3Client) {
throw new Error("S3 client is not configured. Storage is initializing, please wait...");
throw new Error(
"S3 client is not configured. Make sure ENABLE_S3=true and all S3 environment variables are set."
);
}
return s3Client;
}
/**
* Check if a character is valid in an HTTP token (RFC 2616)
* Tokens can contain: alphanumeric and !#$%&'*+-.^_`|~
* Must exclude separators: ()<>@,;:\"/[]?={} and space/tab
*/
private isTokenChar(char: string): boolean {
const code = char.charCodeAt(0);
// Basic ASCII range check
if (code < 33 || code > 126) return false;
// Exclude separator characters per RFC 2616
const separators = '()<>@,;:\\"/[]?={} \t';
return !separators.includes(char);
}
/**
@@ -63,10 +41,12 @@ export class S3StorageProvider implements StorageProvider {
return 'attachment; filename="download"';
}
// Create ASCII-safe version with only valid token characters
const asciiSafe = sanitized
.split("")
.filter((char) => this.isTokenChar(char))
.filter((char) => {
const code = char.charCodeAt(0);
return code >= 32 && code <= 126;
})
.join("");
if (asciiSafe && asciiSafe.trim()) {
@@ -79,10 +59,8 @@ export class S3StorageProvider implements StorageProvider {
}
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
// Always use public S3 client for presigned URLs (uses SERVER_IP)
const client = createPublicS3Client();
if (!client) {
throw new Error("S3 client could not be created");
if (!s3Client) {
throw new Error("S3 client is not available");
}
const command = new PutObjectCommand({
@@ -90,14 +68,12 @@ export class S3StorageProvider implements StorageProvider {
Key: objectName,
});
return await getSignedUrl(client, command, { expiresIn: expires });
return await getSignedUrl(s3Client, command, { expiresIn: expires });
}
async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
// Always use public S3 client for presigned URLs (uses SERVER_IP)
const client = createPublicS3Client();
if (!client) {
throw new Error("S3 client could not be created");
if (!s3Client) {
throw new Error("S3 client is not available");
}
let rcdFileName: string;
@@ -119,147 +95,19 @@ export class S3StorageProvider implements StorageProvider {
ResponseContentType: getContentType(rcdFileName),
});
return await getSignedUrl(client, command, { expiresIn: expires });
return await getSignedUrl(s3Client, command, { expiresIn: expires });
}
async deleteObject(objectName: string): Promise<void> {
const client = this.ensureClient();
if (!s3Client) {
throw new Error("S3 client is not available");
}
const command = new DeleteObjectCommand({
Bucket: bucketName,
Key: objectName,
});
await client.send(command);
}
async fileExists(objectName: string): Promise<boolean> {
const client = this.ensureClient();
try {
const command = new HeadObjectCommand({
Bucket: bucketName,
Key: objectName,
});
await client.send(command);
return true;
} catch (error: any) {
if (error.name === "NotFound" || error.$metadata?.httpStatusCode === 404) {
return false;
}
throw error;
}
}
/**
* Get a readable stream for downloading an object
* Used for proxying downloads through the backend
*/
async getObjectStream(objectName: string): Promise<NodeJS.ReadableStream> {
const client = this.ensureClient();
const command = new GetObjectCommand({
Bucket: bucketName,
Key: objectName,
});
const response = await client.send(command);
if (!response.Body) {
throw new Error("No body in S3 response");
}
// AWS SDK v3 returns a readable stream
return response.Body as NodeJS.ReadableStream;
}
/**
* Initialize a multipart upload
* Returns uploadId for subsequent part uploads
*/
async createMultipartUpload(objectName: string): Promise<string> {
const client = createPublicS3Client();
if (!client) {
throw new Error("S3 client could not be created");
}
const command = new CreateMultipartUploadCommand({
Bucket: bucketName,
Key: objectName,
});
const response = await client.send(command);
if (!response.UploadId) {
throw new Error("Failed to create multipart upload - no UploadId returned");
}
return response.UploadId;
}
/**
* Get presigned URL for uploading a specific part
*/
async getPresignedPartUrl(
objectName: string,
uploadId: string,
partNumber: number,
expires: number
): Promise<string> {
const client = createPublicS3Client();
if (!client) {
throw new Error("S3 client could not be created");
}
const command = new UploadPartCommand({
Bucket: bucketName,
Key: objectName,
UploadId: uploadId,
PartNumber: partNumber,
});
const url = await getSignedUrl(client, command, { expiresIn: expires });
return url;
}
/**
* Complete a multipart upload
*/
async completeMultipartUpload(
objectName: string,
uploadId: string,
parts: Array<{ PartNumber: number; ETag: string }>
): Promise<void> {
const client = this.ensureClient();
const command = new CompleteMultipartUploadCommand({
Bucket: bucketName,
Key: objectName,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.map((part) => ({
PartNumber: part.PartNumber,
ETag: part.ETag,
})),
},
});
await client.send(command);
}
/**
* Abort a multipart upload
*/
async abortMultipartUpload(objectName: string, uploadId: string): Promise<void> {
const client = this.ensureClient();
const command = new AbortMultipartUploadCommand({
Bucket: bucketName,
Key: objectName,
UploadId: uploadId,
});
await client.send(command);
await s3Client.send(command);
}
}

View File

@@ -1,96 +0,0 @@
import { S3StorageProvider } from "../providers/s3-storage.provider";
import { prisma } from "../shared/prisma";
import { StorageProvider } from "../types/storage";
/**
* Script to clean up orphan file records in the database
* (files that are registered in DB but don't exist in storage)
*/
async function cleanupOrphanFiles() {
console.log("Starting orphan file cleanup...");
console.log(`Storage mode: S3 (Garage or External)`);
// Always use S3 storage provider
const storageProvider: StorageProvider = new S3StorageProvider();
// Get all files from database
const allFiles = await prisma.file.findMany({
select: {
id: true,
name: true,
objectName: true,
userId: true,
size: true,
},
});
console.log(`Found ${allFiles.length} files in database`);
const orphanFiles: typeof allFiles = [];
const existingFiles: typeof allFiles = [];
// Check each file
for (const file of allFiles) {
const exists = await storageProvider.fileExists(file.objectName);
if (!exists) {
orphanFiles.push(file);
console.log(`❌ Orphan: ${file.name} (${file.objectName})`);
} else {
existingFiles.push(file);
}
}
console.log(`\n📊 Summary:`);
console.log(` Total files in DB: ${allFiles.length}`);
console.log(` ✅ Files with storage: ${existingFiles.length}`);
console.log(` ❌ Orphan files: ${orphanFiles.length}`);
if (orphanFiles.length === 0) {
console.log("\n✨ No orphan files found!");
return;
}
console.log(`\n🗑 Orphan files to be deleted:`);
orphanFiles.forEach((file) => {
const sizeMB = Number(file.size) / (1024 * 1024);
console.log(` - ${file.name} (${sizeMB.toFixed(2)} MB) - ${file.objectName}`);
});
// Ask for confirmation (if running interactively)
const shouldDelete = process.argv.includes("--confirm");
if (!shouldDelete) {
console.log(`\n⚠ Dry run mode. To actually delete orphan records, run with --confirm flag:`);
console.log(` node dist/scripts/cleanup-orphan-files.js --confirm`);
return;
}
console.log(`\n🗑 Deleting orphan file records...`);
let deletedCount = 0;
for (const file of orphanFiles) {
try {
await prisma.file.delete({
where: { id: file.id },
});
deletedCount++;
console.log(` ✓ Deleted: ${file.name}`);
} catch (error) {
console.error(` ✗ Failed to delete ${file.name}:`, error);
}
}
console.log(`\n✅ Cleanup complete!`);
console.log(` Deleted ${deletedCount} orphan file records`);
}
// Run the cleanup
cleanupOrphanFiles()
.then(() => {
console.log("\nScript completed successfully");
process.exit(0);
})
.catch((error) => {
console.error("\n❌ Script failed:", error);
process.exit(1);
});

View File

@@ -1,305 +0,0 @@
/**
* Automatic Migration Script: Filesystem → S3 (Garage)
*
* This script runs automatically on server start and:
* 1. Detects existing filesystem files
* 2. Migrates them to S3 in background
* 3. Updates database references
* 4. Keeps filesystem as fallback during migration
* 5. Zero downtime, zero user intervention
*/
import { createReadStream } from "fs";
import * as fs from "fs/promises";
import * as path from "path";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { directoriesConfig } from "../config/directories.config";
import { bucketName, s3Client } from "../config/storage.config";
import { prisma } from "../shared/prisma";
interface MigrationStats {
totalFiles: number;
migratedFiles: number;
failedFiles: number;
skippedFiles: number;
totalSizeBytes: number;
startTime: number;
endTime?: number;
}
const MIGRATION_STATE_FILE = path.join(directoriesConfig.uploads, ".migration-state.json");
const MIGRATION_BATCH_SIZE = 10; // Migrate 10 files at a time
const MIGRATION_DELAY_MS = 100; // Small delay between batches to avoid overwhelming
export class FilesystemToS3Migrator {
private stats: MigrationStats = {
totalFiles: 0,
migratedFiles: 0,
failedFiles: 0,
skippedFiles: 0,
totalSizeBytes: 0,
startTime: Date.now(),
};
/**
* Check if migration is needed and should run
*/
async shouldMigrate(): Promise<boolean> {
// Only migrate if S3 client is available
if (!s3Client) {
console.log("[MIGRATION] S3 not configured, skipping migration");
return false;
}
// Check if migration already completed
try {
const stateExists = await fs
.access(MIGRATION_STATE_FILE)
.then(() => true)
.catch(() => false);
if (stateExists) {
const state = JSON.parse(await fs.readFile(MIGRATION_STATE_FILE, "utf-8"));
if (state.completed) {
console.log("[MIGRATION] Migration already completed");
return false;
}
console.log("[MIGRATION] Previous migration incomplete, resuming...");
this.stats = { ...state, startTime: Date.now() };
return true;
}
} catch (error) {
console.warn("[MIGRATION] Could not read migration state:", error);
}
// Check if there are files to migrate
try {
const uploadsDir = directoriesConfig.uploads;
const files = await this.scanDirectory(uploadsDir);
if (files.length === 0) {
console.log("[MIGRATION] No filesystem files found, nothing to migrate");
await this.markMigrationComplete();
return false;
}
console.log(`[MIGRATION] Found ${files.length} files to migrate`);
this.stats.totalFiles = files.length;
return true;
} catch (error) {
console.error("[MIGRATION] Error scanning files:", error);
return false;
}
}
/**
* Run the migration process
*/
async migrate(): Promise<void> {
console.log("[MIGRATION] Starting automatic filesystem → S3 migration");
console.log("[MIGRATION] This runs in background, zero downtime");
try {
const uploadsDir = directoriesConfig.uploads;
const files = await this.scanDirectory(uploadsDir);
// Process in batches
for (let i = 0; i < files.length; i += MIGRATION_BATCH_SIZE) {
const batch = files.slice(i, i + MIGRATION_BATCH_SIZE);
await Promise.all(
batch.map((file) =>
this.migrateFile(file).catch((error) => {
console.error(`[MIGRATION] Failed to migrate ${file}:`, error);
this.stats.failedFiles++;
})
)
);
// Save progress
await this.saveState();
// Small delay between batches
if (i + MIGRATION_BATCH_SIZE < files.length) {
await new Promise((resolve) => setTimeout(resolve, MIGRATION_DELAY_MS));
}
// Log progress
const progress = Math.round(((i + batch.length) / files.length) * 100);
console.log(`[MIGRATION] Progress: ${progress}% (${this.stats.migratedFiles}/${files.length})`);
}
this.stats.endTime = Date.now();
await this.markMigrationComplete();
const durationSeconds = Math.round((this.stats.endTime - this.stats.startTime) / 1000);
const sizeMB = Math.round(this.stats.totalSizeBytes / 1024 / 1024);
console.log("[MIGRATION] ✓✓✓ Migration completed successfully!");
console.log(`[MIGRATION] Stats:`);
console.log(` - Total files: ${this.stats.totalFiles}`);
console.log(` - Migrated: ${this.stats.migratedFiles}`);
console.log(` - Failed: ${this.stats.failedFiles}`);
console.log(` - Skipped: ${this.stats.skippedFiles}`);
console.log(` - Total size: ${sizeMB}MB`);
console.log(` - Duration: ${durationSeconds}s`);
} catch (error) {
console.error("[MIGRATION] Migration failed:", error);
await this.saveState();
throw error;
}
}
/**
* Scan directory recursively for files
*/
private async scanDirectory(dir: string, baseDir: string = dir): Promise<string[]> {
const files: string[] = [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// Skip special files and directories
if (entry.name.startsWith(".") || entry.name === "temp-uploads") {
continue;
}
if (entry.isDirectory()) {
const subFiles = await this.scanDirectory(fullPath, baseDir);
files.push(...subFiles);
} else if (entry.isFile()) {
// Get relative path for S3 key
const relativePath = path.relative(baseDir, fullPath);
files.push(relativePath);
}
}
} catch (error) {
console.warn(`[MIGRATION] Could not scan directory ${dir}:`, error);
}
return files;
}
/**
* Migrate a single file to S3
*/
private async migrateFile(relativeFilePath: string): Promise<void> {
const fullPath = path.join(directoriesConfig.uploads, relativeFilePath);
try {
// Check if file still exists
const stats = await fs.stat(fullPath);
if (!stats.isFile()) {
this.stats.skippedFiles++;
return;
}
// S3 object name (preserve directory structure)
const objectName = relativeFilePath.replace(/\\/g, "/");
// Check if already exists in S3
if (s3Client) {
try {
const { HeadObjectCommand } = await import("@aws-sdk/client-s3");
await s3Client.send(
new HeadObjectCommand({
Bucket: bucketName,
Key: objectName,
})
);
// Already exists in S3, skip
console.log(`[MIGRATION] Already in S3: ${objectName}`);
this.stats.skippedFiles++;
return;
} catch (error: any) {
// Not found, proceed with migration
if (error.$metadata?.httpStatusCode !== 404) {
throw error;
}
}
}
// Upload to S3
if (s3Client) {
const fileStream = createReadStream(fullPath);
await s3Client.send(
new PutObjectCommand({
Bucket: bucketName,
Key: objectName,
Body: fileStream,
})
);
this.stats.migratedFiles++;
this.stats.totalSizeBytes += stats.size;
console.log(`[MIGRATION] ✓ Migrated: ${objectName} (${Math.round(stats.size / 1024)}KB)`);
// Delete filesystem file after successful migration to free up space
try {
await fs.unlink(fullPath);
console.log(`[MIGRATION] 🗑️ Deleted from filesystem: ${relativeFilePath}`);
} catch (unlinkError) {
console.warn(`[MIGRATION] Warning: Could not delete ${relativeFilePath}:`, unlinkError);
}
}
} catch (error) {
console.error(`[MIGRATION] Failed to migrate ${relativeFilePath}:`, error);
this.stats.failedFiles++;
throw error;
}
}
/**
* Save migration state
*/
private async saveState(): Promise<void> {
try {
await fs.writeFile(MIGRATION_STATE_FILE, JSON.stringify({ ...this.stats, completed: false }, null, 2));
} catch (error) {
console.warn("[MIGRATION] Could not save state:", error);
}
}
/**
* Mark migration as complete
*/
private async markMigrationComplete(): Promise<void> {
try {
await fs.writeFile(MIGRATION_STATE_FILE, JSON.stringify({ ...this.stats, completed: true }, null, 2));
console.log("[MIGRATION] Migration marked as complete");
} catch (error) {
console.warn("[MIGRATION] Could not mark migration complete:", error);
}
}
}
/**
* Auto-run migration on import (called by server.ts)
*/
export async function runAutoMigration(): Promise<void> {
const migrator = new FilesystemToS3Migrator();
if (await migrator.shouldMigrate()) {
// Run in background, don't block server start
setTimeout(async () => {
try {
await migrator.migrate();
} catch (error) {
console.error("[MIGRATION] Auto-migration failed:", error);
console.log("[MIGRATION] Will retry on next server restart");
}
}, 5000); // Start after 5 seconds
console.log("[MIGRATION] Background migration scheduled");
}
}

View File

@@ -1,21 +1,27 @@
import * as fs from "fs/promises";
import crypto from "node:crypto";
import path from "path";
import fastifyMultipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import { buildApp } from "./app";
import { directoriesConfig } from "./config/directories.config";
import { env } from "./env";
import { appRoutes } from "./modules/app/routes";
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 { folderRoutes } from "./modules/folder/routes";
import { healthRoutes } from "./modules/health/routes";
import { reverseShareRoutes } from "./modules/reverse-share/routes";
import { s3StorageRoutes } from "./modules/s3-storage/routes";
import { shareRoutes } from "./modules/share/routes";
import { storageRoutes } from "./modules/storage/routes";
import { twoFactorRoutes } from "./modules/two-factor/routes";
import { userRoutes } from "./modules/user/routes";
import { IS_RUNNING_IN_CONTAINER } from "./utils/container-detection";
if (typeof globalThis.crypto === "undefined") {
globalThis.crypto = crypto.webcrypto as any;
@@ -46,14 +52,6 @@ async function startServer() {
await ensureDirectories();
// Import storage config once at the beginning
const { isInternalStorage, isExternalS3 } = await import("./config/storage.config.js");
// Run automatic migration from legacy storage to S3-compatible storage
// Transparently migrates any existing files
const { runAutoMigration } = await import("./scripts/migrate-filesystem-to-s3.js");
await runAutoMigration();
await app.register(fastifyMultipart, {
limits: {
fieldNameSize: 100,
@@ -65,29 +63,29 @@ async function startServer() {
},
});
// No static files needed - S3 serves files directly via presigned URLs
if (env.ENABLE_S3 !== "true") {
await app.register(fastifyStatic, {
root: directoriesConfig.uploads,
prefix: "/uploads/",
decorateReply: false,
});
}
app.register(authRoutes);
app.register(authProvidersRoutes, { prefix: "/auth" });
app.register(twoFactorRoutes, { prefix: "/auth" });
app.register(userRoutes);
app.register(folderRoutes);
app.register(fileRoutes);
app.register(folderRoutes);
app.register(downloadQueueRoutes);
app.register(shareRoutes);
app.register(reverseShareRoutes);
app.register(storageRoutes);
app.register(appRoutes);
app.register(healthRoutes);
// Always use S3-compatible storage routes
app.register(s3StorageRoutes);
if (isInternalStorage) {
console.log("📦 Using internal storage (auto-configured)");
} else if (isExternalS3) {
console.log("📦 Using external S3 storage (AWS/S3-compatible)");
} else {
console.log("⚠️ WARNING: Storage not configured! Storage may not work.");
if (env.ENABLE_S3 !== "true") {
app.register(filesystemRoutes);
}
await app.listen({
@@ -95,11 +93,36 @@ async function startServer() {
host: "0.0.0.0",
});
console.log(`🌴 Palmr server running on port 3333`);
let authProviders = "Disabled";
try {
const { AuthProvidersService } = await import("./modules/auth-providers/service.js");
const authService = new AuthProvidersService();
const enabledProviders = await authService.getEnabledProviders();
authProviders = enabledProviders.length > 0 ? `Enabled (${enabledProviders.length} providers)` : "Disabled";
} catch (error) {
console.error("Error getting auth providers status:", error);
}
// Cleanup on shutdown
process.on("SIGINT", () => process.exit(0));
process.on("SIGTERM", () => process.exit(0));
console.log(`🌴 Palmr server running on port 3333 🌴`);
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:");
console.log(` - API Reference: http://localhost:3333/docs\n`);
process.on("SIGINT", async () => {
const chunkManager = ChunkManager.getInstance();
chunkManager.destroy();
process.exit(0);
});
process.on("SIGTERM", async () => {
const chunkManager = ChunkManager.getInstance();
chunkManager.destroy();
process.exit(0);
});
}
startServer().catch((err) => {

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

@@ -2,18 +2,6 @@ export interface StorageProvider {
getPresignedPutUrl(objectName: string, expires: number): Promise<string>;
getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string>;
deleteObject(objectName: string): Promise<void>;
fileExists(objectName: string): Promise<boolean>;
getObjectStream(objectName: string): Promise<NodeJS.ReadableStream>;
// Multipart upload methods
createMultipartUpload(objectName: string): Promise<string>;
getPresignedPartUrl(objectName: string, uploadId: string, partNumber: number, expires: number): Promise<string>;
completeMultipartUpload(
objectName: string,
uploadId: string,
parts: Array<{ PartNumber: number; ETag: string }>
): Promise<void>;
abortMultipartUpload(objectName: string, uploadId: string): Promise<void>;
}
export interface StorageConfig {

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

@@ -1,206 +0,0 @@
import { prisma } from "../shared/prisma";
/**
* Generates a unique filename by checking for duplicates in the database
* and appending a numeric suffix if necessary (e.g., file (1).txt, file (2).txt)
*
* @param baseName - The original filename without extension
* @param extension - The file extension
* @param userId - The user ID who owns the file
* @param folderId - The folder ID where the file will be stored (null for root)
* @returns A unique filename with extension
*/
export async function generateUniqueFileName(
baseName: string,
extension: string,
userId: string,
folderId: string | null | undefined
): Promise<string> {
const fullName = `${baseName}.${extension}`;
const targetFolderId = folderId || null;
// Check if the original filename exists in the target folder
const existingFile = await prisma.file.findFirst({
where: {
name: fullName,
userId,
folderId: targetFolderId,
},
});
// If no duplicate, return the original name
if (!existingFile) {
return fullName;
}
// Find the next available suffix number
let suffix = 1;
let uniqueName = `${baseName} (${suffix}).${extension}`;
while (true) {
const duplicateFile = await prisma.file.findFirst({
where: {
name: uniqueName,
userId,
folderId: targetFolderId,
},
});
if (!duplicateFile) {
return uniqueName;
}
suffix++;
uniqueName = `${baseName} (${suffix}).${extension}`;
}
}
/**
* Generates a unique filename for rename operations by checking for duplicates
* and appending a numeric suffix if necessary (e.g., file (1).txt, file (2).txt)
*
* @param baseName - The original filename without extension
* @param extension - The file extension
* @param userId - The user ID who owns the file
* @param folderId - The folder ID where the file will be stored (null for root)
* @param excludeFileId - The ID of the file being renamed (to exclude from duplicate check)
* @returns A unique filename with extension
*/
export async function generateUniqueFileNameForRename(
baseName: string,
extension: string,
userId: string,
folderId: string | null | undefined,
excludeFileId: string
): Promise<string> {
const fullName = `${baseName}.${extension}`;
const targetFolderId = folderId || null;
// Check if the original filename exists in the target folder (excluding current file)
const existingFile = await prisma.file.findFirst({
where: {
name: fullName,
userId,
folderId: targetFolderId,
id: { not: excludeFileId },
},
});
// If no duplicate, return the original name
if (!existingFile) {
return fullName;
}
// Find the next available suffix number
let suffix = 1;
let uniqueName = `${baseName} (${suffix}).${extension}`;
while (true) {
const duplicateFile = await prisma.file.findFirst({
where: {
name: uniqueName,
userId,
folderId: targetFolderId,
id: { not: excludeFileId },
},
});
if (!duplicateFile) {
return uniqueName;
}
suffix++;
uniqueName = `${baseName} (${suffix}).${extension}`;
}
}
/**
* Generates a unique folder name by checking for duplicates in the database
* and appending a numeric suffix if necessary (e.g., folder (1), folder (2))
*
* @param name - The original folder name
* @param userId - The user ID who owns the folder
* @param parentId - The parent folder ID (null for root)
* @param excludeFolderId - The ID of the folder being renamed (to exclude from duplicate check)
* @returns A unique folder name
*/
export async function generateUniqueFolderName(
name: string,
userId: string,
parentId: string | null | undefined,
excludeFolderId?: string
): Promise<string> {
const targetParentId = parentId || null;
// Build the where clause
const whereClause: any = {
name,
userId,
parentId: targetParentId,
};
// Exclude the current folder if this is a rename operation
if (excludeFolderId) {
whereClause.id = { not: excludeFolderId };
}
// Check if the original folder name exists in the target location
const existingFolder = await prisma.folder.findFirst({
where: whereClause,
});
// If no duplicate, return the original name
if (!existingFolder) {
return name;
}
// Find the next available suffix number
let suffix = 1;
let uniqueName = `${name} (${suffix})`;
while (true) {
const whereClauseForSuffix: any = {
name: uniqueName,
userId,
parentId: targetParentId,
};
if (excludeFolderId) {
whereClauseForSuffix.id = { not: excludeFolderId };
}
const duplicateFolder = await prisma.folder.findFirst({
where: whereClauseForSuffix,
});
if (!duplicateFolder) {
return uniqueName;
}
suffix++;
uniqueName = `${name} (${suffix})`;
}
}
/**
* Parses a filename into base name and extension
*
* @param filename - The full filename with extension
* @returns Object with baseName and extension
*/
export function parseFileName(filename: string): { baseName: string; extension: string } {
const lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex === -1 || lastDotIndex === 0) {
// No extension or hidden file with no name before dot
return {
baseName: filename,
extension: "",
};
}
return {
baseName: filename.substring(0, lastDotIndex),
extension: filename.substring(lastDotIndex + 1),
};
}

View File

@@ -1,5 +1,2 @@
API_BASE_URL=http:localhost:3333
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
# Configuration options
NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB=
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US

View File

@@ -6,9 +6,7 @@
"token_expired": "انتهت صلاحية الرمز المميز. حاول مرة أخرى.",
"config_error": "خطأ في التكوين. اتصل بالدعم الفني.",
"auth_failed": "فشل في المصادقة. حاول مرة أخرى."
},
"authenticationFailed": "فشلت المصادقة",
"successfullyAuthenticated": "تم المصادقة بنجاح!"
}
},
"authProviders": {
"title": "مزودي المصادقة",
@@ -152,9 +150,7 @@
"move": "نقل",
"rename": "إعادة تسمية",
"search": "بحث",
"share": "مشاركة",
"copied": "تم النسخ",
"copy": "نسخ"
"share": "مشاركة"
},
"createShare": {
"title": "إنشاء مشاركة",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "تفاصيل المشاركة",
"selectFiles": "اختيار الملفات"
},
"errors": {
"nameRequired": "اسم المشاركة مطلوب",
"selectItems": "يرجى اختيار ملف أو مجلد واحد على الأقل"
},
"itemsSelected": "{count} عنصر محدد",
"passwordPlaceholder": "أدخل كلمة المرور",
"selectItemsPrompt": "اختر الملفات والمجلدات للمشاركة"
}
},
"customization": {
"breadcrumb": "التخصيص",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "معاينة الملف",
"description": "معاينة وتنزيل الملف",
"loading": "جاري التحميل...",
"notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.",
"downloadToView": "استخدم زر التحميل لتنزيل الملف.",
@@ -349,8 +337,7 @@
"addToShare": "إضافة إلى المشاركة",
"removeFromShare": "إزالة من المشاركة",
"saveChanges": "حفظ التغييرات",
"editFolder": "تحرير المجلد",
"itemsSelected": "{count} عنصر محدد"
"editFolder": "تحرير المجلد"
},
"files": {
"title": "جميع الملفات",
@@ -386,12 +373,7 @@
"description": "ارفع ملفك الأول أو أنشئ مجلدًا للبدء"
},
"files": "ملفات",
"folders": "مجلدات",
"errors": {
"moveItemsFailed": "فشل نقل العناصر. يرجى المحاولة مرة أخرى.",
"cannotMoveHere": "لا يمكن نقل العناصر إلى هذا الموقع"
},
"openFolder": "فتح المجلد"
"folders": "مجلدات"
},
"filesTable": {
"ariaLabel": "جدول الملفات",
@@ -554,10 +536,7 @@
"movingTo": "النقل إلى:",
"title": "نقل {count, plural, =1 {عنصر} other {عناصر}}",
"description": "نقل {count, plural, =1 {عنصر} other {عناصر}} إلى موقع جديد",
"success": "تم نقل {count} {count, plural, =1 {عنصر} other {عناصر}} بنجاح",
"errors": {
"moveFailed": "فشل نقل العناصر"
}
"success": "تم نقل {count} {count, plural, =1 {عنصر} other {عناصر}} بنجاح"
},
"navbar": {
"logoAlt": "شعار التطبيق",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "رفع الملفات - Palmr",
"description": "رفع الملفات عبر الرابط المشترك",
"descriptionWithLimit": "تحميل الملفات (الحد الأقصى {limit} ملفات)"
"description": "رفع الملفات عبر الرابط المشترك"
},
"layout": {
"defaultTitle": "رفع الملفات",
@@ -1167,10 +1145,14 @@
"components": {
"fileRow": {
"addDescription": "إضافة وصف...",
"anonymous": "مجهول"
"anonymous": "مجهول",
"deleteError": "خطأ في حذف الملف",
"deleteSuccess": "تم حذف الملف بنجاح"
},
"fileActions": {
"edit": "تحرير",
"save": "حفظ",
"cancel": "إلغاء",
"preview": "معاينة",
"download": "تحميل",
"delete": "حذف",
@@ -1318,10 +1300,6 @@
"passwordAuthEnabled": {
"title": "المصادقة بالكلمة السرية",
"description": "تمكين أو تعطيل المصادقة بالكلمة السرية"
},
"hideVersion": {
"title": "إخفاء الإصدار",
"description": "إخفاء إصدار Palmr في تذييل جميع الصفحات"
}
},
"buttons": {
@@ -1383,16 +1361,22 @@
"description": "قد يكون تم حذف هذه المشاركة أو انتهت صلاحيتها."
},
"pageTitle": "المشاركة",
"downloadAll": "تحميل الكل",
"metadata": {
"defaultDescription": "مشاركة الملفات بشكل آمن",
"filesShared": "{count, plural, =1 {ملف واحد تمت مشاركته} other {# ملفات تمت مشاركتها}}"
}
"downloadAll": "تحميل الكل"
},
"shareActions": {
"deleteTitle": "حذف المشاركة",
"deleteConfirmation": "هل أنت متأكد من أنك تريد حذف هذه المشاركة؟ لا يمكن التراجع عن هذا الإجراء.",
"editTitle": "تحرير المشاركة",
"nameLabel": "اسم المشاركة",
"descriptionLabel": "الوصف",
"descriptionPlaceholder": "أدخل وصفاً (اختياري)",
"expirationLabel": "تاريخ انتهاء الصلاحية",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "الحد الأقصى للمشاهدات",
"maxViewsPlaceholder": "اتركه فارغاً للمشاهدات غير المحدودة",
"passwordProtection": "محمي بكلمة مرور",
"passwordLabel": "كلمة المرور",
"passwordPlaceholder": "أدخل كلمة المرور",
"newPasswordLabel": "كلمة المرور الجديدة (اتركها فارغة للاحتفاظ بالحالية)",
"newPasswordPlaceholder": "أدخل كلمة المرور الجديدة",
"manageFilesTitle": "إدارة الملفات",
@@ -1411,9 +1395,7 @@
"linkDescriptionFile": "إنشاء رابط مخصص لمشاركة الملف",
"linkDescriptionFolder": "إنشاء رابط مخصص لمشاركة المجلد",
"linkReady": "رابط المشاركة جاهز:",
"linkTitle": "إنشاء رابط",
"itemsSelected": "{count} عنصر محدد",
"manageFilesDescription": "اختر الملفات والمجلدات لتضمينها في هذه المشاركة"
"linkTitle": "إنشاء رابط"
},
"shareDetails": {
"title": "تفاصيل المشاركة",
@@ -1429,6 +1411,7 @@
"noLink": "لم يتم إنشاء رابط بعد",
"copyLink": "نسخ الرابط",
"openLink": "فتح في علامة تبويب جديدة",
"linkCopied": "تم نسخ الرابط إلى الحافظة",
"views": "المشاهدات",
"dates": "التواريخ",
"created": "تم الإنشاء",
@@ -1476,6 +1459,28 @@
"expires": "تنتهي صلاحيته:",
"expirationDate": "تاريخ انتهاء الصلاحية"
},
"shareFile": {
"title": "مشاركة ملف",
"linkTitle": "إنشاء رابط",
"nameLabel": "اسم المشاركة",
"namePlaceholder": "أدخل اسم المشاركة",
"descriptionLabel": "الوصف",
"descriptionPlaceholder": "أدخل وصفاً (اختياري)",
"expirationLabel": "تاريخ انتهاء الصلاحية",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "الحد الأقصى للمشاهدات",
"maxViewsPlaceholder": "اتركه فارغاً للمشاهدات غير المحدودة",
"passwordProtection": "محمي بكلمة مرور",
"passwordLabel": "كلمة المرور",
"passwordPlaceholder": "أدخل كلمة المرور",
"linkDescription": "إنشاء رابط مخصص لمشاركة الملف",
"aliasLabel": "اسم مستعار للرابط",
"aliasPlaceholder": "أدخل اسماً مستعاراً مخصصاً",
"linkReady": "رابط المشاركة جاهز:",
"createShare": "إنشاء مشاركة",
"generateLink": "إنشاء رابط",
"copyLink": "نسخ الرابط"
},
"shareManager": {
"deleteSuccess": "تم حذف المشاركة بنجاح",
"deleteError": "فشل في حذف المشاركة",
@@ -1505,10 +1510,7 @@
"noFilesToDownload": "لا توجد ملفات متاحة للتنزيل",
"singleShareZipName": "{ShareName} _files.zip",
"zipDownloadError": "فشل في إنشاء ملف مضغوط",
"zipDownloadSuccess": "تم تنزيل ملف zip بنجاح",
"errors": {
"multipleDownloadNotSupported": "تنزيل مشاركات متعددة غير مدعوم حالياً - يرجى تنزيل المشاركات بشكل فردي"
}
"zipDownloadSuccess": "تم تنزيل ملف zip بنجاح"
},
"shareMultipleFiles": {
"title": "مشاركة ملفات متعددة",
@@ -1921,21 +1923,5 @@
"passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
},
"embedCode": {
"title": "تضمين الوسائط",
"description": "استخدم هذه الأكواد لتضمين هذه الوسائط في المنتديات أو المواقع الإلكترونية أو المنصات الأخرى",
"tabs": {
"directLink": "رابط مباشر",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "عنوان URL مباشر لملف الوسائط",
"htmlDescription": "استخدم هذا الكود لتضمين الوسائط في صفحات HTML",
"bbcodeDescription": "استخدم هذا الكود لتضمين الوسائط في المنتديات التي تدعم BBCode"
},
"contextMenu": {
"newFolder": "مجلد جديد",
"uploadFile": "رفع ملف"
}
}

View File

@@ -6,9 +6,7 @@
"token_expired": "Token abgelaufen. Versuchen Sie es erneut.",
"config_error": "Konfigurationsfehler. Kontaktieren Sie den Support.",
"auth_failed": "Authentifizierung fehlgeschlagen. Versuchen Sie es erneut."
},
"authenticationFailed": "Authentifizierung fehlgeschlagen",
"successfullyAuthenticated": "Erfolgreich authentifiziert!"
}
},
"authProviders": {
"title": "Authentifizierungsanbieter",
@@ -152,9 +150,7 @@
"move": "Verschieben",
"rename": "Umbenennen",
"search": "Suchen",
"share": "Teilen",
"copied": "Kopiert",
"copy": "Kopieren"
"share": "Teilen"
},
"createShare": {
"title": "Freigabe Erstellen",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "Freigabe-Details",
"selectFiles": "Dateien auswählen"
},
"errors": {
"nameRequired": "Freigabename ist erforderlich",
"selectItems": "Bitte wählen Sie mindestens eine Datei oder einen Ordner aus"
},
"itemsSelected": "{count} Elemente ausgewählt",
"passwordPlaceholder": "Passwort eingeben",
"selectItemsPrompt": "Wählen Sie Dateien und Ordner zum Teilen aus"
}
},
"customization": {
"breadcrumb": "Anpassung",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "Datei-Vorschau",
"description": "Vorschau und Download der Datei",
"loading": "Laden...",
"notAvailable": "Vorschau für diesen Dateityp nicht verfügbar.",
"downloadToView": "Verwenden Sie die Download-Schaltfläche, um die Datei herunterzuladen.",
@@ -349,8 +337,7 @@
"addToShare": "Zur Freigabe hinzufügen",
"removeFromShare": "Aus Freigabe entfernen",
"saveChanges": "Änderungen Speichern",
"editFolder": "Ordner bearbeiten",
"itemsSelected": "{count} Elemente ausgewählt"
"editFolder": "Ordner bearbeiten"
},
"files": {
"title": "Alle Dateien",
@@ -386,12 +373,7 @@
"description": "Laden Sie Ihre erste Datei hoch oder erstellen Sie einen Ordner, um zu beginnen"
},
"files": "Dateien",
"folders": "Ordner",
"errors": {
"moveItemsFailed": "Verschieben der Elemente fehlgeschlagen. Bitte erneut versuchen.",
"cannotMoveHere": "Elemente können nicht an diesen Speicherort verschoben werden"
},
"openFolder": "Ordner öffnen"
"folders": "Ordner"
},
"filesTable": {
"ariaLabel": "Dateien-Tabelle",
@@ -554,10 +536,7 @@
"movingTo": "Verschieben nach:",
"title": "{count, plural, =1 {Element} other {Elemente}} verschieben",
"description": "{count, plural, =1 {Element} other {Elemente}} an einen neuen Ort verschieben",
"success": "Erfolgreich {count} {count, plural, =1 {Element} other {Elemente}} verschoben",
"errors": {
"moveFailed": "Verschieben der Elemente fehlgeschlagen"
}
"success": "Erfolgreich {count} {count, plural, =1 {Element} other {Elemente}} verschoben"
},
"navbar": {
"logoAlt": "Anwendungslogo",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "Dateien senden - Palmr",
"description": "Senden Sie Dateien über den geteilten Link",
"descriptionWithLimit": "Dateien hochladen (max. {limit} Dateien)"
"description": "Senden Sie Dateien über den geteilten Link"
},
"layout": {
"defaultTitle": "Dateien senden",
@@ -1171,6 +1149,8 @@
},
"fileActions": {
"edit": "Bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"preview": "Vorschau",
"download": "Herunterladen",
"delete": "Löschen",
@@ -1318,10 +1298,6 @@
"passwordAuthEnabled": {
"title": "Passwort-Authentifizierung",
"description": "Passwort-basierte Authentifizierung aktivieren oder deaktivieren"
},
"hideVersion": {
"title": "Version Ausblenden",
"description": "Die Palmr-Version in der Fußzeile aller Seiten ausblenden"
}
},
"buttons": {
@@ -1383,17 +1359,23 @@
"description": "Diese Freigabe wurde möglicherweise gelöscht oder ist abgelaufen."
},
"pageTitle": "Freigabe",
"downloadAll": "Alle herunterladen",
"metadata": {
"defaultDescription": "Dateien sicher teilen",
"filesShared": "{count, plural, =1 {1 Datei geteilt} other {# Dateien geteilt}}"
}
"downloadAll": "Alle herunterladen"
},
"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",
"descriptionPlaceholder": "Geben Sie eine Beschreibung ein (optional)",
"expirationLabel": "Ablaufdatum",
"expirationPlaceholder": "TT.MM.JJJJ HH:MM",
"maxViewsLabel": "Maximale Ansichten",
"maxViewsPlaceholder": "Leer lassen für unbegrenzt",
"passwordProtection": "Passwort-geschützt",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Passwort eingeben",
"newPasswordLabel": "Neues Passwort (leer lassen, um das aktuelle zu behalten)",
"newPasswordPlaceholder": "Neues Passwort eingeben",
"manageFilesTitle": "Dateien Verwalten",
@@ -1411,9 +1393,7 @@
"linkDescriptionFile": "Erstellen Sie einen benutzerdefinierten Link zum Teilen der Datei",
"linkDescriptionFolder": "Erstellen Sie einen benutzerdefinierten Link zum Teilen des Ordners",
"linkReady": "Ihr Freigabe-Link ist bereit:",
"linkTitle": "Link generieren",
"itemsSelected": "{count} Elemente ausgewählt",
"manageFilesDescription": "Wählen Sie Dateien und Ordner aus, die in diese Freigabe aufgenommen werden sollen"
"linkTitle": "Link generieren"
},
"shareDetails": {
"title": "Freigabe-Details",
@@ -1429,6 +1409,7 @@
"noLink": "Noch kein Link generiert",
"copyLink": "Link kopieren",
"openLink": "In neuem Tab öffnen",
"linkCopied": "Link in Zwischenablage kopiert",
"views": "Ansichten",
"dates": "Daten",
"created": "Erstellt",
@@ -1476,6 +1457,28 @@
"expires": "Läuft ab:",
"expirationDate": "Ablaufdatum"
},
"shareFile": {
"title": "Datei Freigeben",
"linkTitle": "Link Generieren",
"nameLabel": "Freigabe-Name",
"namePlaceholder": "Freigabe-Namen eingeben",
"descriptionLabel": "Beschreibung",
"descriptionPlaceholder": "Geben Sie eine Beschreibung ein (optional)",
"expirationLabel": "Ablaufdatum",
"expirationPlaceholder": "TT.MM.JJJJ HH:MM",
"maxViewsLabel": "Maximale Ansichten",
"maxViewsPlaceholder": "Leer lassen für unbegrenzt",
"passwordProtection": "Passwort-geschützt",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Passwort eingeben",
"linkDescription": "Einen benutzerdefinierten Link zum Teilen der Datei generieren",
"aliasLabel": "Link-Alias",
"aliasPlaceholder": "Benutzerdefinierten Alias eingeben",
"linkReady": "Ihr Freigabe-Link ist bereit:",
"createShare": "Freigabe Erstellen",
"generateLink": "Link Generieren",
"copyLink": "Link Kopieren"
},
"shareManager": {
"deleteSuccess": "Freigabe erfolgreich gelöscht",
"deleteError": "Fehler beim Löschen der Freigabe",
@@ -1505,10 +1508,7 @@
"noFilesToDownload": "Keine Dateien zum Download zur Verfügung stehen",
"singleShareZipName": "{ShareName} _files.zip",
"zipDownloadError": "Die ZIP -Datei erstellt nicht",
"zipDownloadSuccess": "ZIP -Datei erfolgreich heruntergeladen",
"errors": {
"multipleDownloadNotSupported": "Download mehrerer Freigaben wird noch nicht unterstützt - bitte laden Sie Freigaben einzeln herunter"
}
"zipDownloadSuccess": "ZIP -Datei erfolgreich heruntergeladen"
},
"shareMultipleFiles": {
"title": "Mehrere Dateien Teilen",
@@ -1921,21 +1921,5 @@
"passwordRequired": "Passwort ist erforderlich",
"nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich"
},
"embedCode": {
"title": "Medien einbetten",
"description": "Verwenden Sie diese Codes, um diese Medien in Foren, Websites oder anderen Plattformen einzubetten",
"tabs": {
"directLink": "Direkter Link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Direkte URL zur Mediendatei",
"htmlDescription": "Verwenden Sie diesen Code, um die Medien in HTML-Seiten einzubetten",
"bbcodeDescription": "Verwenden Sie diesen Code, um die Medien in Foren einzubetten, die BBCode unterstützen"
},
"contextMenu": {
"newFolder": "Neuer Ordner",
"uploadFile": "Datei hochladen"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,5 @@
{
"auth": {
"successfullyAuthenticated": "Successfully authenticated!",
"authenticationFailed": "Authentication failed",
"errors": {
"account_inactive": "Account inactive. Please contact the administrator.",
"registration_disabled": "SSO registration is disabled.",
@@ -10,10 +8,6 @@
"auth_failed": "Authentication failed. Please try again."
}
},
"contextMenu": {
"newFolder": "New folder",
"uploadFile": "Upload file"
},
"authProviders": {
"title": "Authentication Providers",
"description": "Configure external authentication providers for SSO",
@@ -156,9 +150,7 @@
"rename": "Rename",
"move": "Move",
"share": "Share",
"search": "Search",
"copy": "Copy",
"copied": "Copied"
"search": "Search"
},
"createShare": {
"title": "Create Share",
@@ -172,16 +164,9 @@
"maxViewsPlaceholder": "Leave empty for unlimited",
"passwordProtection": "Password Protected",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter password",
"create": "Create Share",
"success": "Share created successfully",
"error": "Failed to create share",
"errors": {
"nameRequired": "Share name is required",
"selectItems": "Please select at least one file or folder"
},
"itemsSelected": "{count} items selected",
"selectItemsPrompt": "Select files and folders to share",
"tabs": {
"shareDetails": "Share Details",
"selectFiles": "Select Files"
@@ -317,7 +302,6 @@
},
"filePreview": {
"title": "Preview File",
"description": "Preview and download file",
"loading": "Loading...",
"notAvailable": "Preview not available for this file type",
"downloadToView": "Use the download button to view this file",
@@ -348,7 +332,6 @@
"uploadNewFiles": "Upload new files to add them",
"fileCount": "{count, plural, =1 {file} other {files}}",
"filesSelected": "{count, plural, =0 {No files selected} =1 {1 file selected} other {# files selected}}",
"itemsSelected": "{count} items selected",
"editFile": "Edit file",
"editFolder": "Edit folder",
"previewFile": "Preview file",
@@ -376,11 +359,6 @@
"bulkDeleteTitle": "Delete Selected Items",
"bulkDeleteConfirmation": "Are you sure you want to delete {count, plural, =1 {1 item} other {# items}}? This action cannot be undone.",
"totalFiles": "{count, plural, =0 {No files} =1 {1 file} other {# files}}",
"openFolder": "Open folder",
"errors": {
"moveItemsFailed": "Failed to move items. Please try again.",
"cannotMoveHere": "Cannot move items to this location"
},
"empty": {
"title": "No files or folders yet",
"description": "Upload your first file or create a folder to get started"
@@ -558,10 +536,7 @@
"movingTo": "Moving to:",
"title": "Move {count, plural, =1 {Item} other {Items}}",
"description": "Move {count, plural, =1 {item} other {items}} to a new location",
"success": "Successfully moved {count} {count, plural, =1 {item} other {items}}",
"errors": {
"moveFailed": "Failed to move items"
}
"success": "Successfully moved {count} {count, plural, =1 {item} other {items}}"
},
"navbar": {
"logoAlt": "App Logo",
@@ -1082,8 +1057,7 @@
"upload": {
"metadata": {
"title": "Send Files - Palmr",
"description": "Send files through the shared link",
"descriptionWithLimit": "Upload files (max {limit} files)"
"description": "Send files through the shared link"
},
"layout": {
"defaultTitle": "Send Files",
@@ -1237,10 +1211,6 @@
"title": "Show Home Page",
"description": "Show Home Page after installation"
},
"hideVersion": {
"title": "Hide Version",
"description": "Hide the Palmr version from the footer on all pages"
},
"smtpEnabled": {
"title": "SMTP Enabled",
"description": "Enable or disable SMTP email functionality"
@@ -1387,11 +1357,7 @@
"title": "Share Not Found",
"description": "This share may have been deleted or expired."
},
"pageTitle": "Share",
"metadata": {
"defaultDescription": "Share files securely",
"filesShared": "{count, plural, =1 {1 file shared} other {# files shared}}"
}
"pageTitle": "Share"
},
"shareActions": {
"fileTitle": "Share File",
@@ -1411,9 +1377,7 @@
"newPasswordLabel": "New Password (leave empty to keep current)",
"newPasswordPlaceholder": "Enter new password",
"manageFilesTitle": "Manage Files",
"manageFilesDescription": "Select files and folders to include in this share",
"manageRecipientsTitle": "Manage Recipients",
"itemsSelected": "{count} items selected",
"editSuccess": "Share updated successfully",
"editError": "Failed to update share",
"bulkDeleteConfirmation": "Are you sure you want to delete {count, plural, =1 {1 share} other {# shares}}? This action cannot be undone.",
@@ -1507,9 +1471,6 @@
"creatingZip": "Creating ZIP file...",
"zipDownloadSuccess": "ZIP file downloaded successfully",
"zipDownloadError": "Failed to create ZIP file",
"errors": {
"multipleDownloadNotSupported": "Multiple share download not yet supported - please download shares individually"
},
"singleShareZipName": "{shareName}_files.zip",
"multipleSharesZipName": "{count}_shares_files.zip",
"defaultShareName": "Share"
@@ -1911,18 +1872,6 @@
"userr": "User"
}
},
"embedCode": {
"title": "Embed Media",
"description": "Use these codes to embed this media in forums, websites, or other platforms",
"tabs": {
"directLink": "Direct Link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Direct URL to the media file",
"htmlDescription": "Use this code to embed the media in HTML pages",
"bbcodeDescription": "Use this code to embed the media in forums that support BBCode"
},
"validation": {
"firstNameRequired": "First name is required",
"lastNameRequired": "Last name is required",
@@ -1938,4 +1887,4 @@
"nameRequired": "Name is required",
"required": "This field is required"
}
}
}

View File

@@ -6,13 +6,7 @@
"token_expired": "Token expirado. Por favor, inténtelo de nuevo.",
"config_error": "Error de configuración. Por favor, contacte al soporte.",
"auth_failed": "Error de autenticación. Por favor, inténtelo de nuevo."
},
"authenticationFailed": "Autenticación fallida",
"successfullyAuthenticated": "¡Autenticado exitosamente!"
},
"contextMenu": {
"newFolder": "Nueva carpeta",
"uploadFile": "Subir archivo"
}
},
"authProviders": {
"title": "Proveedores de Autenticación",
@@ -156,9 +150,7 @@
"move": "Mover",
"rename": "Renombrar",
"search": "Buscar",
"share": "Compartir",
"copied": "Copiado",
"copy": "Copiar"
"share": "Compartir"
},
"createShare": {
"title": "Crear Compartir",
@@ -180,14 +172,7 @@
"tabs": {
"shareDetails": "Detalles del compartido",
"selectFiles": "Seleccionar archivos"
},
"errors": {
"nameRequired": "El nombre del compartir es obligatorio",
"selectItems": "Por favor seleccione al menos un archivo o carpeta"
},
"itemsSelected": "{count} elementos seleccionados",
"passwordPlaceholder": "Ingrese contraseña",
"selectItemsPrompt": "Seleccione archivos y carpetas para compartir"
}
},
"customization": {
"breadcrumb": "Personalización",
@@ -317,7 +302,6 @@
},
"filePreview": {
"title": "Vista Previa del Archivo",
"description": "Vista previa y descarga de archivo",
"loading": "Cargando...",
"notAvailable": "Vista previa no disponible para este tipo de archivo.",
"downloadToView": "Use el botón de descarga para descargar el archivo.",
@@ -353,8 +337,7 @@
"addToShare": "Agregar a compartición",
"removeFromShare": "Quitar de compartición",
"saveChanges": "Guardar Cambios",
"editFolder": "Editar carpeta",
"itemsSelected": "{count} elementos seleccionados"
"editFolder": "Editar carpeta"
},
"files": {
"title": "Todos los Archivos",
@@ -390,12 +373,7 @@
"description": "Suba su primer archivo o cree una carpeta para comenzar"
},
"files": "archivos",
"folders": "carpetas",
"errors": {
"moveItemsFailed": "Error al mover elementos. Por favor, inténtelo de nuevo.",
"cannotMoveHere": "No se pueden mover elementos a esta ubicación"
},
"openFolder": "Abrir carpeta"
"folders": "carpetas"
},
"filesTable": {
"ariaLabel": "Tabla de archivos",
@@ -558,10 +536,7 @@
"movingTo": "Moviendo a:",
"title": "Mover {count, plural, =1 {elemento} other {elementos}}",
"description": "Mover {count, plural, =1 {elemento} other {elementos}} a una nueva ubicación",
"success": "Se movieron exitosamente {count} {count, plural, =1 {elemento} other {elementos}}",
"errors": {
"moveFailed": "Error al mover elementos"
}
"success": "Se movieron exitosamente {count} {count, plural, =1 {elemento} other {elementos}}"
},
"navbar": {
"logoAlt": "Logo de la aplicación",
@@ -1081,8 +1056,7 @@
"upload": {
"metadata": {
"title": "Enviar Archivos - Palmr",
"description": "Envía archivos a través del enlace compartido",
"descriptionWithLimit": "Subir archivos (máx. {limit} archivos)"
"description": "Envía archivos a través del enlace compartido"
},
"layout": {
"defaultTitle": "Enviar Archivos",
@@ -1175,6 +1149,8 @@
},
"fileActions": {
"edit": "Editar",
"save": "Guardar",
"cancel": "Cancelar",
"preview": "Vista previa",
"download": "Descargar",
"delete": "Eliminar",
@@ -1322,10 +1298,6 @@
"passwordAuthEnabled": {
"title": "Autenticación por Contraseña",
"description": "Habilitar o deshabilitar la autenticación basada en contraseña"
},
"hideVersion": {
"title": "Ocultar Versión",
"description": "Ocultar la versión de Palmr en el pie de página de todas las páginas"
}
},
"buttons": {
@@ -1387,17 +1359,23 @@
"description": "Esta compartición puede haber sido eliminada o haber expirado."
},
"pageTitle": "Compartición",
"downloadAll": "Descargar Todo",
"metadata": {
"defaultDescription": "Compartir archivos de forma segura",
"filesShared": "{count, plural, =1 {1 archivo compartido} other {# archivos compartidos}}"
}
"downloadAll": "Descargar Todo"
},
"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",
"descriptionPlaceholder": "Ingrese una descripción (opcional)",
"expirationLabel": "Fecha de Expiración",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Vistas Máximas",
"maxViewsPlaceholder": "Deje vacío para ilimitado",
"passwordProtection": "Protegido por Contraseña",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingrese contraseña",
"newPasswordLabel": "Nueva Contraseña (deje vacío para mantener la actual)",
"newPasswordPlaceholder": "Ingrese nueva contraseña",
"manageFilesTitle": "Administrar Archivos",
@@ -1415,9 +1393,7 @@
"linkDescriptionFile": "Genere un enlace personalizado para compartir el archivo",
"linkDescriptionFolder": "Genere un enlace personalizado para compartir la carpeta",
"linkReady": "Su enlace de compartición está listo:",
"linkTitle": "Generar enlace",
"itemsSelected": "{count} elementos seleccionados",
"manageFilesDescription": "Selecciona archivos y carpetas para incluir en este compartido"
"linkTitle": "Generar enlace"
},
"shareDetails": {
"title": "Detalles del Compartir",
@@ -1433,6 +1409,7 @@
"noLink": "Ningún enlace generado aún",
"copyLink": "Copiar enlace",
"openLink": "Abrir en nueva pestaña",
"linkCopied": "Enlace copiado al portapapeles",
"views": "Visualizaciones",
"dates": "Fechas",
"created": "Creado",
@@ -1480,6 +1457,28 @@
"expires": "Expira:",
"expirationDate": "Fecha de Expiración"
},
"shareFile": {
"title": "Compartir Archivo",
"linkTitle": "Generar Enlace",
"nameLabel": "Nombre del Compartir",
"namePlaceholder": "Ingrese nombre del compartir",
"descriptionLabel": "Descripción",
"descriptionPlaceholder": "Ingrese una descripción (opcional)",
"expirationLabel": "Fecha de Expiración",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Vistas Máximas",
"maxViewsPlaceholder": "Deje vacío para ilimitado",
"passwordProtection": "Protegido por Contraseña",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingrese contraseña",
"linkDescription": "Genere un enlace personalizado para compartir el archivo",
"aliasLabel": "Alias del Enlace",
"aliasPlaceholder": "Ingrese alias personalizado",
"linkReady": "Su enlace de compartir está listo:",
"createShare": "Crear Compartir",
"generateLink": "Generar Enlace",
"copyLink": "Copiar Enlace"
},
"shareManager": {
"deleteSuccess": "Compartición eliminada exitosamente",
"deleteError": "Error al eliminar la compartición",
@@ -1509,10 +1508,7 @@
"noFilesToDownload": "No hay archivos disponibles para descargar",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "No se pudo crear un archivo zip",
"zipDownloadSuccess": "Archivo zip descargado correctamente",
"errors": {
"multipleDownloadNotSupported": "Descarga múltiple de compartidos aún no soportada - por favor descarga los compartidos individualmente"
}
"zipDownloadSuccess": "Archivo zip descargado correctamente"
},
"shareMultipleFiles": {
"title": "Compartir Múltiples Archivos",
@@ -1925,17 +1921,5 @@
"passwordRequired": "Se requiere la contraseña",
"nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio"
},
"embedCode": {
"title": "Insertar multimedia",
"description": "Utiliza estos códigos para insertar este contenido multimedia en foros, sitios web u otras plataformas",
"tabs": {
"directLink": "Enlace directo",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL directa al archivo multimedia",
"htmlDescription": "Utiliza este código para insertar el contenido multimedia en páginas HTML",
"bbcodeDescription": "Utiliza este código para insertar el contenido multimedia en foros que admiten BBCode"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,7 @@
"token_expired": "Jeton expiré. Veuillez réessayer.",
"config_error": "Erreur de configuration. Veuillez contacter le support.",
"auth_failed": "Échec de l'authentification. Veuillez réessayer."
},
"authenticationFailed": "Échec de l'authentification",
"successfullyAuthenticated": "Authentification réussie !"
}
},
"authProviders": {
"title": "Fournisseurs d'authentification",
@@ -152,9 +150,7 @@
"move": "Déplacer",
"rename": "Renommer",
"search": "Rechercher",
"share": "Partager",
"copied": "Copié",
"copy": "Copier"
"share": "Partager"
},
"createShare": {
"title": "Créer un Partage",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "Détails du partage",
"selectFiles": "Sélectionner les fichiers"
},
"errors": {
"nameRequired": "Le nom du partage est requis",
"selectItems": "Veuillez sélectionner au moins un fichier ou dossier"
},
"itemsSelected": "{count, plural, =0 {Aucun élément sélectionné} =1 {1 élément sélectionné} other {# éléments sélectionnés}}",
"passwordPlaceholder": "Entrez le mot de passe",
"selectItemsPrompt": "Sélectionnez les fichiers et dossiers à partager"
}
},
"customization": {
"breadcrumb": "Personnalisation",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "Aperçu du Fichier",
"description": "Aperçu et téléchargement du fichier",
"loading": "Chargement...",
"notAvailable": "Aperçu non disponible pour ce type de fichier.",
"downloadToView": "Utilisez le bouton de téléchargement pour télécharger le fichier.",
@@ -349,8 +337,7 @@
"addToShare": "Ajouter au partage",
"removeFromShare": "Retirer du partage",
"saveChanges": "Sauvegarder les Modifications",
"editFolder": "Modifier le dossier",
"itemsSelected": "{count, plural, =0 {Aucun élément sélectionné} =1 {1 élément sélectionné} other {# éléments sélectionnés}}"
"editFolder": "Modifier le dossier"
},
"files": {
"title": "Tous les Fichiers",
@@ -386,12 +373,7 @@
"description": "Téléchargez votre premier fichier ou créez un dossier pour commencer"
},
"files": "fichiers",
"folders": "dossiers",
"errors": {
"moveItemsFailed": "Échec du déplacement des éléments. Veuillez réessayer.",
"cannotMoveHere": "Impossible de déplacer les éléments vers cet emplacement"
},
"openFolder": "Ouvrir le dossier"
"folders": "dossiers"
},
"filesTable": {
"ariaLabel": "Tableau des fichiers",
@@ -554,10 +536,7 @@
"movingTo": "Déplacement vers :",
"title": "Déplacer {count, plural, =1 {élément} other {éléments}}",
"description": "Déplacer {count, plural, =1 {élément} other {éléments}} vers un nouvel emplacement",
"success": "{count} {count, plural, =1 {élément déplacé} other {éléments déplacés}} avec succès",
"errors": {
"moveFailed": "Échec du déplacement des éléments"
}
"success": "{count} {count, plural, =1 {élément déplacé} other {éléments déplacés}} avec succès"
},
"navbar": {
"logoAlt": "Logo de l'Application",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "Envoyer des Fichiers - Palmr",
"description": "Envoyez des fichiers via le lien partagé",
"descriptionWithLimit": "Télécharger des fichiers (max {limit} fichiers)"
"description": "Envoyez des fichiers via le lien partagé"
},
"layout": {
"defaultTitle": "Envoyer des Fichiers",
@@ -1171,6 +1149,8 @@
},
"fileActions": {
"edit": "Modifier",
"save": "Enregistrer",
"cancel": "Annuler",
"preview": "Aperçu",
"download": "Télécharger",
"delete": "Supprimer",
@@ -1321,10 +1301,6 @@
"passwordAuthEnabled": {
"title": "Authentification par Mot de Passe",
"description": "Activer ou désactiver l'authentification basée sur mot de passe"
},
"hideVersion": {
"title": "Masquer la Version",
"description": "Masquer la version de Palmr dans le pied de page de toutes les pages"
}
},
"buttons": {
@@ -1383,17 +1359,23 @@
"description": "Ce partage a peut-être été supprimé ou a expiré."
},
"pageTitle": "Partage",
"downloadAll": "Tout Télécharger",
"metadata": {
"defaultDescription": "Partager des fichiers en toute sécurité",
"filesShared": "{count, plural, =1 {1 fichier partagé} other {# fichiers partagés}}"
}
"downloadAll": "Tout Télécharger"
},
"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",
"descriptionPlaceholder": "Entrez une description (optionnel)",
"expirationLabel": "Date d'Expiration",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Vues Maximales",
"maxViewsPlaceholder": "Laissez vide pour illimité",
"passwordProtection": "Protégé par Mot de Passe",
"passwordLabel": "Mot de Passe",
"passwordPlaceholder": "Entrez le mot de passe",
"newPasswordLabel": "Nouveau Mot de Passe (laissez vide pour conserver l'actuel)",
"newPasswordPlaceholder": "Entrez le nouveau mot de passe",
"manageFilesTitle": "Gérer les Fichiers",
@@ -1411,9 +1393,7 @@
"linkDescriptionFile": "Générez un lien personnalisé pour partager le fichier",
"linkDescriptionFolder": "Générez un lien personnalisé pour partager le dossier",
"linkReady": "Votre lien de partage est prêt :",
"linkTitle": "Générer un lien",
"itemsSelected": "{count, plural, =0 {Aucun élément sélectionné} =1 {1 élément sélectionné} other {# éléments sélectionnés}}",
"manageFilesDescription": "Sélectionnez les fichiers et dossiers à inclure dans ce partage"
"linkTitle": "Générer un lien"
},
"shareDetails": {
"title": "Détails du Partage",
@@ -1429,6 +1409,7 @@
"noLink": "Aucun lien généré pour le moment",
"copyLink": "Copier le lien",
"openLink": "Ouvrir dans un nouvel onglet",
"linkCopied": "Lien copié dans le presse-papiers",
"views": "Vues",
"dates": "Dates",
"created": "Créé",
@@ -1476,6 +1457,28 @@
"expires": "Expire:",
"expirationDate": "Date d'Expiration"
},
"shareFile": {
"title": "Partager un Fichier",
"linkTitle": "Générer un Lien",
"nameLabel": "Nom du Partage",
"namePlaceholder": "Entrez le nom du partage",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Entrez une description (optionnel)",
"expirationLabel": "Date d'Expiration",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Vues Maximales",
"maxViewsPlaceholder": "Laissez vide pour illimité",
"passwordProtection": "Protégé par Mot de Passe",
"passwordLabel": "Mot de Passe",
"passwordPlaceholder": "Entrez le mot de passe",
"linkDescription": "Générez un lien personnalisé pour partager le fichier",
"aliasLabel": "Alias du Lien",
"aliasPlaceholder": "Entrez un alias personnalisé",
"linkReady": "Votre lien de partage est prêt :",
"createShare": "Créer un Partage",
"generateLink": "Générer un Lien",
"copyLink": "Copier le Lien"
},
"shareManager": {
"deleteSuccess": "Partage supprimé avec succès",
"deleteError": "Échec de la suppression du partage",
@@ -1505,10 +1508,7 @@
"noFilesToDownload": "Aucun fichier disponible en téléchargement",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "Échec de la création du fichier zip",
"zipDownloadSuccess": "Fichier zip téléchargé avec succès",
"errors": {
"multipleDownloadNotSupported": "Le téléchargement de plusieurs partages n'est pas encore pris en charge - veuillez télécharger les partages individuellement"
}
"zipDownloadSuccess": "Fichier zip téléchargé avec succès"
},
"shareMultipleFiles": {
"title": "Partager Plusieurs Fichiers",
@@ -1921,21 +1921,5 @@
"passwordRequired": "Le mot de passe est requis",
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
},
"embedCode": {
"title": "Intégrer le média",
"description": "Utilisez ces codes pour intégrer ce média dans des forums, sites web ou autres plateformes",
"tabs": {
"directLink": "Lien direct",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL directe vers le fichier média",
"htmlDescription": "Utilisez ce code pour intégrer le média dans des pages HTML",
"bbcodeDescription": "Utilisez ce code pour intégrer le média dans des forums prenant en charge BBCode"
},
"contextMenu": {
"newFolder": "Nouveau dossier",
"uploadFile": "Télécharger un fichier"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,7 @@
"token_expired": "टोकन समाप्त हो गया है। कृपया पुनः प्रयास करें।",
"config_error": "कॉन्फ़िगरेशन त्रुटि। कृपया सहायता से संपर्क करें।",
"auth_failed": "प्रमाणीकरण विफल। कृपया पुनः प्रयास करें।"
},
"authenticationFailed": "प्रमाणीकरण विफल",
"successfullyAuthenticated": "सफलतापूर्वक प्रमाणित!"
}
},
"authProviders": {
"title": "प्रमाणीकरण प्रदाता",
@@ -152,9 +150,7 @@
"move": "स्थानांतरित करें",
"rename": "नाम बदलें",
"search": "खोजें",
"share": "साझा करें",
"copied": "कॉपी किया गया",
"copy": "कॉपी करें"
"share": "साझा करें"
},
"createShare": {
"title": "साझाकरण बनाएं",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "साझाकरण विवरण",
"selectFiles": "फ़ाइलें चुनें"
},
"errors": {
"nameRequired": "शेयर का नाम आवश्यक है",
"selectItems": "कृपया कम से कम एक फ़ाइल या फ़ोल्डर चुनें"
},
"itemsSelected": "{count, plural, =0 {कोई आइटम चयनित नहीं} =1 {1 आइटम चयनित} other {# आइटम चयनित}}",
"passwordPlaceholder": "पासवर्ड दर्ज करें",
"selectItemsPrompt": "साझा करने के लिए फ़ाइलें और फ़ोल्डर चुनें"
}
},
"customization": {
"breadcrumb": "अनुकूलन",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "फ़ाइल पूर्वावलोकन",
"description": "फ़ाइल पूर्वावलोकन और डाउनलोड",
"loading": "लोड हो रहा है...",
"notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।",
"downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।",
@@ -349,8 +337,7 @@
"addToShare": "साझाकरण में जोड़ें",
"removeFromShare": "साझाकरण से हटाएं",
"saveChanges": "परिवर्तन सहेजें",
"editFolder": "फ़ोल्डर संपादित करें",
"itemsSelected": "{count, plural, =0 {कोई आइटम चयनित नहीं} =1 {1 आइटम चयनित} other {# आइटम चयनित}}"
"editFolder": "फ़ोल्डर संपादित करें"
},
"files": {
"title": "सभी फाइलें",
@@ -386,12 +373,7 @@
"description": "आरंभ करने के लिए अपनी पहली फ़ाइल अपलोड करें या फ़ोल्डर बनाएं"
},
"files": "फ़ाइलें",
"folders": "फ़ोल्डर",
"errors": {
"moveItemsFailed": "आइटम स्थानांतरित करने में विफल। कृपया पुनः प्रयास करें।",
"cannotMoveHere": "इस स्थान पर आइटम स्थानांतरित नहीं कर सकते"
},
"openFolder": "फ़ोल्डर खोलें"
"folders": "फ़ोल्डर"
},
"filesTable": {
"ariaLabel": "फाइल तालिका",
@@ -554,10 +536,7 @@
"movingTo": "यहाँ स्थानांतरित कर रहे हैं:",
"title": "आइटम स्थानांतरित करें",
"description": "आइटम को नए स्थान पर स्थानांतरित करें",
"success": "{count} आइटम सफलतापूर्वक स्थानांतरित किए गए",
"errors": {
"moveFailed": "आइटम स्थानांतरित करने में विफल"
}
"success": "{count} आइटम सफलतापूर्वक स्थानांतरित किए गए"
},
"navbar": {
"logoAlt": "एप्लिकेशन लोगो",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "फ़ाइलें भेजें - पाल्मर",
"description": "साझा किए गए लिंक के माध्यम से फ़ाइलें भेजें",
"descriptionWithLimit": "फ़ाइलें अपलोड करें (अधिकतम {limit} फ़ाइलें)"
"description": "साझा किए गए लिंक के माध्यम से फ़ाइलें भेजें"
},
"layout": {
"defaultTitle": "फ़ाइलें भेजें",
@@ -1171,6 +1149,8 @@
},
"fileActions": {
"edit": "संपादित करें",
"save": "सहेजें",
"cancel": "रद्द करें",
"preview": "पूर्वावलोकन",
"download": "डाउनलोड",
"delete": "हटाएं",
@@ -1318,10 +1298,6 @@
"passwordAuthEnabled": {
"title": "पासवर्ड प्रमाणीकरण",
"description": "पासवर्ड आधारित प्रमाणीकरण सक्षम या अक्षम करें"
},
"hideVersion": {
"title": "संस्करण छुपाएं",
"description": "सभी पृष्ठों के फुटर में Palmr संस्करण छुपाएं"
}
},
"buttons": {
@@ -1383,16 +1359,22 @@
"description": "यह साझाकरण हटा दिया गया हो सकता है या समाप्त हो चुका है।"
},
"pageTitle": "साझाकरण",
"downloadAll": "सभी डाउनलोड करें",
"metadata": {
"defaultDescription": "फाइलों को सुरक्षित रूप से साझा करें",
"filesShared": "{count, plural, =1 {1 फ़ाइल साझा की गई} other {# फ़ाइलें साझा की गईं}}"
}
"downloadAll": "सभी डाउनलोड करें"
},
"shareActions": {
"deleteTitle": "साझाकरण हटाएं",
"deleteConfirmation": "क्या आप वाकई इस साझाकरण को हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।",
"editTitle": "साझाकरण संपादित करें",
"nameLabel": "साझाकरण नाम",
"descriptionLabel": "विवरण",
"descriptionPlaceholder": "विवरण दर्ज करें (वैकल्पिक)",
"expirationLabel": "समाप्ति तिथि",
"expirationPlaceholder": "DD/MM/YYYY HH:MM",
"maxViewsLabel": "अधिकतम दृश्य",
"maxViewsPlaceholder": "असीमित के लिए खाली छोड़ें",
"passwordProtection": "पासवर्ड संरक्षित",
"passwordLabel": "पासवर्ड",
"passwordPlaceholder": "पासवर्ड दर्ज करें",
"newPasswordLabel": "नया पासवर्ड (वर्तमान रखने के लिए खाली छोड़ें)",
"newPasswordPlaceholder": "नया पासवर्ड दर्ज करें",
"manageFilesTitle": "फाइलें प्रबंधित करें",
@@ -1411,9 +1393,7 @@
"linkDescriptionFile": "फ़ाइल साझा करने के लिए कस्टम लिंक जेनरेट करें",
"linkDescriptionFolder": "फ़ोल्डर साझा करने के लिए कस्टम लिंक जेनरेट करें",
"linkReady": "आपका साझाकरण लिंक तैयार है:",
"linkTitle": "लिंक जेनरेट करें",
"itemsSelected": "{count, plural, =0 {कोई आइटम चयनित नहीं} =1 {1 आइटम चयनित} other {# आइटम चयनित}}",
"manageFilesDescription": "इस साझाकरण में शामिल करने के लिए फ़ाइलें और फ़ोल्डर चुनें"
"linkTitle": "लिंक जेनरेट करें"
},
"shareDetails": {
"title": "साझाकरण विवरण",
@@ -1429,6 +1409,7 @@
"noLink": "अभी तक कोई लिंक जेनरेट नहीं किया गया",
"copyLink": "लिंक कॉपी करें",
"openLink": "नए टैब में खोलें",
"linkCopied": "लिंक क्लिपबोर्ड में कॉपी कर दिया गया",
"views": "दृश्य",
"dates": "तिथियां",
"created": "बनाया गया",
@@ -1476,6 +1457,28 @@
"expires": "समाप्त होता है:",
"expirationDate": "समाप्ति तिथि"
},
"shareFile": {
"title": "फाइल साझा करें",
"linkTitle": "लिंक जेनरेट करें",
"nameLabel": "साझाकरण नाम",
"namePlaceholder": "साझाकरण नाम दर्ज करें",
"descriptionLabel": "विवरण",
"descriptionPlaceholder": "विवरण दर्ज करें (वैकल्पिक)",
"expirationLabel": "समाप्ति तिथि",
"expirationPlaceholder": "DD/MM/YYYY HH:MM",
"maxViewsLabel": "अधिकतम दृश्य",
"maxViewsPlaceholder": "असीमित के लिए खाली छोड़ें",
"passwordProtection": "पासवर्ड संरक्षित",
"passwordLabel": "पासवर्ड",
"passwordPlaceholder": "पासवर्ड दर्ज करें",
"linkDescription": "फाइल साझा करने के लिए कस्टम लिंक जेनरेट करें",
"aliasLabel": "लिंक उपनाम",
"aliasPlaceholder": "कस्टम उपनाम दर्ज करें",
"linkReady": "आपका साझाकरण लिंक तैयार है:",
"createShare": "साझाकरण बनाएं",
"generateLink": "लिंक जेनरेट करें",
"copyLink": "लिंक कॉपी करें"
},
"shareManager": {
"deleteSuccess": "साझाकरण सफलतापूर्वक हटाया गया",
"deleteError": "साझाकरण हटाने में त्रुटि",
@@ -1505,10 +1508,7 @@
"noFilesToDownload": "डाउनलोड करने के लिए कोई फाइलें उपलब्ध नहीं हैं",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "ज़िप फ़ाइल बनाने में विफल",
"zipDownloadSuccess": "ज़िप फ़ाइल सफलतापूर्वक डाउनलोड की गई",
"errors": {
"multipleDownloadNotSupported": "कई साझाकरण डाउनलोड अभी तक समर्थित नहीं है - कृपया साझाकरण को अलग-अलग डाउनलोड करें"
}
"zipDownloadSuccess": "ज़िप फ़ाइल सफलतापूर्वक डाउनलोड की गई"
},
"shareMultipleFiles": {
"title": "कई फाइलें साझा करें",
@@ -1921,21 +1921,5 @@
"passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
},
"embedCode": {
"title": "मीडिया एम्बेड करें",
"description": "इस मीडिया को मंचों, वेबसाइटों या अन्य प्लेटफार्मों में एम्बेड करने के लिए इन कोड का उपयोग करें",
"tabs": {
"directLink": "सीधा लिंक",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "मीडिया फ़ाइल का सीधा URL",
"htmlDescription": "HTML पेजों में मीडिया एम्बेड करने के लिए इस कोड का उपयोग करें",
"bbcodeDescription": "BBCode का समर्थन करने वाले मंचों में मीडिया एम्बेड करने के लिए इस कोड का उपयोग करें"
},
"contextMenu": {
"newFolder": "नया फ़ोल्डर",
"uploadFile": "फ़ाइल अपलोड करें"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,7 @@
"token_expired": "Token scaduto. Riprova.",
"config_error": "Errore di configurazione. Contatta il supporto.",
"auth_failed": "Autenticazione fallita. Riprova."
},
"authenticationFailed": "Autenticazione fallita",
"successfullyAuthenticated": "Autenticazione completata con successo!"
}
},
"authProviders": {
"title": "Provider di Autenticazione",
@@ -152,9 +150,7 @@
"move": "Sposta",
"rename": "Rinomina",
"search": "Cerca",
"share": "Condividi",
"copied": "Copiato",
"copy": "Copia"
"share": "Condividi"
},
"createShare": {
"title": "Crea Condivisione",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "Dettagli condivisione",
"selectFiles": "Seleziona file"
},
"errors": {
"nameRequired": "Il nome della condivisione è obbligatorio",
"selectItems": "Seleziona almeno un file o una cartella"
},
"itemsSelected": "{count, plural, =0 {Nessun elemento selezionato} =1 {1 elemento selezionato} other {# elementi selezionati}}",
"passwordPlaceholder": "Inserisci password",
"selectItemsPrompt": "Seleziona file e cartelle da condividere"
}
},
"customization": {
"breadcrumb": "Personalizzazione",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "Anteprima File",
"description": "Anteprima e download del file",
"loading": "Caricamento...",
"notAvailable": "Anteprima non disponibile per questo tipo di file.",
"downloadToView": "Utilizzare il pulsante di download per scaricare il file.",
@@ -349,8 +337,7 @@
"addToShare": "Aggiungi alla condivisione",
"removeFromShare": "Rimuovi dalla condivisione",
"saveChanges": "Salva Modifiche",
"editFolder": "Modifica cartella",
"itemsSelected": "{count, plural, =0 {Nessun elemento selezionato} =1 {1 elemento selezionato} other {# elementi selezionati}}"
"editFolder": "Modifica cartella"
},
"files": {
"title": "Tutti i File",
@@ -386,12 +373,7 @@
"description": "Carica il tuo primo file o crea una cartella per iniziare"
},
"files": "file",
"folders": "cartelle",
"errors": {
"moveItemsFailed": "Impossibile spostare gli elementi. Riprova.",
"cannotMoveHere": "Impossibile spostare gli elementi in questa posizione"
},
"openFolder": "Apri cartella"
"folders": "cartelle"
},
"filesTable": {
"ariaLabel": "Tabella dei file",
@@ -554,10 +536,7 @@
"movingTo": "Spostamento in:",
"title": "Sposta {count, plural, =1 {elemento} other {elementi}}",
"description": "Sposta {count, plural, =1 {elemento} other {elementi}} in una nuova posizione",
"success": "Spostati con successo {count} {count, plural, =1 {elemento} other {elementi}}",
"errors": {
"moveFailed": "Impossibile spostare gli elementi"
}
"success": "Spostati con successo {count} {count, plural, =1 {elemento} other {elementi}}"
},
"navbar": {
"logoAlt": "Logo dell'App",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "Invia File - Palmr",
"description": "Invia file attraverso il link condiviso",
"descriptionWithLimit": "Carica file (max {limit} file)"
"description": "Invia file attraverso il link condiviso"
},
"layout": {
"defaultTitle": "Invia File",
@@ -1171,6 +1149,8 @@
},
"fileActions": {
"edit": "Modifica",
"save": "Salva",
"cancel": "Annulla",
"preview": "Anteprima",
"download": "Scarica",
"delete": "Elimina",
@@ -1318,10 +1298,6 @@
"passwordAuthEnabled": {
"title": "Autenticazione Password",
"description": "Abilita o disabilita l'autenticazione basata su password"
},
"hideVersion": {
"title": "Nascondi Versione",
"description": "Nascondi la versione di Palmr nel piè di pagina di tutte le pagine"
}
},
"buttons": {
@@ -1383,17 +1359,23 @@
"description": "Questa condivisione potrebbe essere stata eliminata o è scaduta."
},
"pageTitle": "Condivisione",
"downloadAll": "Scarica Tutto",
"metadata": {
"defaultDescription": "Condividi file in modo sicuro",
"filesShared": "{count, plural, =1 {1 file condiviso} other {# file condivisi}}"
}
"downloadAll": "Scarica Tutto"
},
"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",
"descriptionPlaceholder": "Inserisci una descrizione (opzionale)",
"expirationLabel": "Data di Scadenza",
"expirationPlaceholder": "GG/MM/AAAA HH:MM",
"maxViewsLabel": "Visualizzazioni Massime",
"maxViewsPlaceholder": "Lasciare vuoto per illimitato",
"passwordProtection": "Protetto da Password",
"passwordLabel": "Password",
"passwordPlaceholder": "Inserisci password",
"newPasswordLabel": "Nuova Password (lasciare vuoto per mantenere quella attuale)",
"newPasswordPlaceholder": "Inserisci nuova password",
"manageFilesTitle": "Gestisci File",
@@ -1411,9 +1393,7 @@
"linkDescriptionFile": "Genera un collegamento personalizzato per condividere il file",
"linkDescriptionFolder": "Genera un collegamento personalizzato per condividere la cartella",
"linkReady": "Il tuo collegamento di condivisione è pronto:",
"linkTitle": "Genera collegamento",
"itemsSelected": "{count, plural, =0 {Nessun elemento selezionato} =1 {1 elemento selezionato} other {# elementi selezionati}}",
"manageFilesDescription": "Seleziona file e cartelle da includere in questa condivisione"
"linkTitle": "Genera collegamento"
},
"shareDetails": {
"title": "Dettagli Condivisione",
@@ -1429,6 +1409,7 @@
"noLink": "Nessun link generato ancora",
"copyLink": "Copia link",
"openLink": "Apri in nuova scheda",
"linkCopied": "Link copiato negli appunti",
"views": "Visualizzazioni",
"dates": "Date",
"created": "Creato",
@@ -1476,6 +1457,28 @@
"expires": "Scade:",
"expirationDate": "Data di Scadenza"
},
"shareFile": {
"title": "Condividi File",
"linkTitle": "Genera Link",
"nameLabel": "Nome Condivisione",
"namePlaceholder": "Inserisci nome condivisione",
"descriptionLabel": "Descrizione",
"descriptionPlaceholder": "Inserisci una descrizione (opzionale)",
"expirationLabel": "Data di Scadenza",
"expirationPlaceholder": "GG/MM/AAAA HH:MM",
"maxViewsLabel": "Visualizzazioni Massime",
"maxViewsPlaceholder": "Lasciare vuoto per illimitato",
"passwordProtection": "Protetto da Password",
"passwordLabel": "Password",
"passwordPlaceholder": "Inserisci password",
"linkDescription": "Genera un link personalizzato per condividere il file",
"aliasLabel": "Alias Link",
"aliasPlaceholder": "Inserisci alias personalizzato",
"linkReady": "Il tuo link di condivisione è pronto:",
"createShare": "Crea Condivisione",
"generateLink": "Genera Link",
"copyLink": "Copia Link"
},
"shareManager": {
"deleteSuccess": "Condivisione eliminata con successo",
"deleteError": "Errore durante l'eliminazione della condivisione",
@@ -1505,10 +1508,7 @@
"noFilesToDownload": "Nessun file disponibile per il download",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "Impossibile creare un file zip",
"zipDownloadSuccess": "File zip scaricato correttamente",
"errors": {
"multipleDownloadNotSupported": "Il download di più condivisioni non è ancora supportato - scarica le condivisioni singolarmente"
}
"zipDownloadSuccess": "File zip scaricato correttamente"
},
"shareMultipleFiles": {
"title": "Condividi File Multipli",
@@ -1921,21 +1921,5 @@
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
"nameRequired": "Il nome è obbligatorio",
"required": "Questo campo è obbligatorio"
},
"embedCode": {
"title": "Incorpora contenuto multimediale",
"description": "Usa questi codici per incorporare questo contenuto multimediale in forum, siti web o altre piattaforme",
"tabs": {
"directLink": "Link diretto",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL diretto al file multimediale",
"htmlDescription": "Usa questo codice per incorporare il contenuto multimediale nelle pagine HTML",
"bbcodeDescription": "Usa questo codice per incorporare il contenuto multimediale nei forum che supportano BBCode"
},
"contextMenu": {
"newFolder": "Nuova cartella",
"uploadFile": "Carica file"
}
}

View File

@@ -6,9 +6,7 @@
"token_expired": "トークンの有効期限が切れました。もう一度お試しください。",
"config_error": "設定エラー。サポートにお問い合わせください。",
"auth_failed": "認証に失敗しました。もう一度お試しください。"
},
"authenticationFailed": "認証に失敗しました",
"successfullyAuthenticated": "認証に成功しました!"
}
},
"authProviders": {
"title": "認証プロバイダー",
@@ -152,9 +150,7 @@
"move": "移動",
"rename": "名前を変更",
"search": "検索",
"share": "共有",
"copied": "コピーしました",
"copy": "コピー"
"share": "共有"
},
"createShare": {
"title": "共有を作成",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "共有の詳細",
"selectFiles": "ファイルを選択"
},
"errors": {
"nameRequired": "共有名は必須です",
"selectItems": "少なくとも1つのファイルまたはフォルダを選択してください"
},
"itemsSelected": "{count, plural, =0 {アイテムが選択されていません} =1 {1つのアイテムが選択されています} other {#つのアイテムが選択されています}}",
"passwordPlaceholder": "パスワードを入力してください",
"selectItemsPrompt": "共有するファイルとフォルダを選択してください"
}
},
"customization": {
"breadcrumb": "カスタマイズ",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "ファイルプレビュー",
"description": "ファイルをプレビューしてダウンロード",
"loading": "読み込み中...",
"notAvailable": "このファイルタイプのプレビューは利用できません。",
"downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。",
@@ -349,8 +337,7 @@
"addToShare": "共有に追加",
"removeFromShare": "共有から削除",
"saveChanges": "変更を保存",
"editFolder": "フォルダを編集",
"itemsSelected": "{count, plural, =0 {アイテムが選択されていません} =1 {1つのアイテムが選択されています} other {#つのアイテムが選択されています}}"
"editFolder": "フォルダを編集"
},
"files": {
"title": "すべてのファイル",
@@ -386,12 +373,7 @@
"description": "最初のファイルをアップロードするか、フォルダを作成して始めましょう"
},
"files": "ファイル",
"folders": "フォルダ",
"errors": {
"moveItemsFailed": "アイテムの移動に失敗しました。もう一度お試しください。",
"cannotMoveHere": "この場所にアイテムを移動できません"
},
"openFolder": "フォルダを開く"
"folders": "フォルダ"
},
"filesTable": {
"ariaLabel": "ファイルテーブル",
@@ -554,10 +536,7 @@
"movingTo": "移動先:",
"title": "アイテムを移動",
"description": "アイテムを新しい場所に移動",
"success": "{count}個のアイテムが正常に移動されました",
"errors": {
"moveFailed": "アイテムの移動に失敗しました"
}
"success": "{count}個のアイテムが正常に移動されました"
},
"navbar": {
"logoAlt": "アプリケーションロゴ",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "ファイルを送信 - Palmr",
"description": "共有リンクを通じてファイルを送信",
"descriptionWithLimit": "ファイルをアップロード(最大{limit}ファイル)"
"description": "共有リンクを通じてファイルを送信"
},
"layout": {
"defaultTitle": "ファイルを送信",
@@ -1171,6 +1149,8 @@
},
"fileActions": {
"edit": "編集",
"save": "保存",
"cancel": "キャンセル",
"preview": "プレビュー",
"download": "ダウンロード",
"delete": "削除",
@@ -1318,10 +1298,6 @@
"passwordAuthEnabled": {
"title": "パスワード認証",
"description": "パスワード認証を有効または無効にする"
},
"hideVersion": {
"title": "バージョンを非表示",
"description": "すべてのページのフッターにあるPalmrバージョンを非表示にする"
}
},
"buttons": {
@@ -1383,16 +1359,22 @@
"description": "この共有は削除されたか、期限が切れている可能性があります。"
},
"pageTitle": "共有",
"downloadAll": "すべてダウンロード",
"metadata": {
"defaultDescription": "ファイルを安全に共有",
"filesShared": "{count, plural, =1 {1 ファイルが共有されました} other {# ファイルが共有されました}}"
}
"downloadAll": "すべてダウンロード"
},
"shareActions": {
"deleteTitle": "共有を削除",
"deleteConfirmation": "この共有を削除しますか?この操作は元に戻すことができません。",
"editTitle": "共有を編集",
"nameLabel": "共有名",
"descriptionLabel": "説明",
"descriptionPlaceholder": "説明を入力してください(任意)",
"expirationLabel": "有効期限",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "最大表示回数",
"maxViewsPlaceholder": "無制限の場合は空白にしてください",
"passwordProtection": "パスワード保護",
"passwordLabel": "パスワード",
"passwordPlaceholder": "パスワードを入力してください",
"newPasswordLabel": "新しいパスワード(現在のパスワードを保持する場合は空白)",
"newPasswordPlaceholder": "新しいパスワードを入力してください",
"manageFilesTitle": "ファイル管理",
@@ -1411,9 +1393,7 @@
"linkDescriptionFile": "ファイルを共有するためのカスタムリンクを生成",
"linkDescriptionFolder": "フォルダを共有するためのカスタムリンクを生成",
"linkReady": "共有リンクの準備ができました:",
"linkTitle": "リンクを生成",
"itemsSelected": "{count, plural, =0 {アイテムが選択されていません} =1 {1つのアイテムが選択されています} other {#つのアイテムが選択されています}}",
"manageFilesDescription": "この共有に含めるファイルとフォルダを選択してください"
"linkTitle": "リンクを生成"
},
"shareDetails": {
"title": "共有詳細",
@@ -1429,6 +1409,7 @@
"noLink": "まだリンクが生成されていません",
"copyLink": "リンクをコピー",
"openLink": "新しいタブで開く",
"linkCopied": "リンクがクリップボードにコピーされました",
"views": "表示回数",
"dates": "日付",
"created": "作成日",
@@ -1476,6 +1457,28 @@
"expires": "期限:",
"expirationDate": "有効期限"
},
"shareFile": {
"title": "ファイル共有",
"linkTitle": "リンク生成",
"nameLabel": "共有名",
"namePlaceholder": "共有名を入力してください",
"descriptionLabel": "説明",
"descriptionPlaceholder": "説明を入力してください(任意)",
"expirationLabel": "有効期限",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "最大表示回数",
"maxViewsPlaceholder": "無制限の場合は空白にしてください",
"passwordProtection": "パスワード保護",
"passwordLabel": "パスワード",
"passwordPlaceholder": "パスワードを入力してください",
"linkDescription": "ファイルを共有するためのカスタムリンクを生成する",
"aliasLabel": "リンクエイリアス",
"aliasPlaceholder": "カスタムエイリアスを入力してください",
"linkReady": "共有リンクの準備ができました:",
"createShare": "共有を作成",
"generateLink": "リンク生成",
"copyLink": "リンクコピー"
},
"shareManager": {
"deleteSuccess": "共有が正常に削除されました",
"deleteError": "共有の削除に失敗しました",
@@ -1505,10 +1508,7 @@
"noFilesToDownload": "ダウンロードできるファイルはありません",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "zipファイルの作成に失敗しました",
"zipDownloadSuccess": "zipファイルは正常にダウンロードされました",
"errors": {
"multipleDownloadNotSupported": "複数の共有のダウンロードはまだサポートされていません - 共有を個別にダウンロードしてください"
}
"zipDownloadSuccess": "zipファイルは正常にダウンロードされました"
},
"shareMultipleFiles": {
"title": "複数ファイルを共有",
@@ -1921,21 +1921,5 @@
"passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
},
"embedCode": {
"title": "メディアを埋め込む",
"description": "これらのコードを使用して、このメディアをフォーラム、ウェブサイト、またはその他のプラットフォームに埋め込みます",
"tabs": {
"directLink": "直接リンク",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "メディアファイルへの直接URL",
"htmlDescription": "このコードを使用してHTMLページにメディアを埋め込みます",
"bbcodeDescription": "BBCodeをサポートするフォーラムにメディアを埋め込むには、このコードを使用します"
},
"contextMenu": {
"newFolder": "新規フォルダ",
"uploadFile": "ファイルをアップロード"
}
}

View File

@@ -6,9 +6,7 @@
"token_expired": "토큰이 만료되었습니다. 다시 시도하세요.",
"config_error": "구성 오류. 지원팀에 문의하세요.",
"auth_failed": "인증에 실패했습니다. 다시 시도하세요."
},
"authenticationFailed": "인증에 실패했습니다",
"successfullyAuthenticated": "인증에 성공했습니다!"
}
},
"authProviders": {
"title": "인증 제공자",
@@ -152,9 +150,7 @@
"move": "이동",
"rename": "이름 변경",
"search": "검색",
"share": "공유",
"copied": "복사됨",
"copy": "복사"
"share": "공유"
},
"createShare": {
"title": "공유 생성",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "공유 세부사항",
"selectFiles": "파일 선택"
},
"errors": {
"nameRequired": "공유 이름은 필수입니다",
"selectItems": "최소 하나의 파일 또는 폴더를 선택해주세요"
},
"itemsSelected": "{count, plural, =0 {선택된 항목 없음} =1 {1개 항목 선택됨} other {#개 항목 선택됨}}",
"passwordPlaceholder": "비밀번호를 입력하세요",
"selectItemsPrompt": "공유할 파일과 폴더를 선택하세요"
}
},
"customization": {
"breadcrumb": "커스터마이징",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "파일 미리보기",
"description": "파일 미리보기 및 다운로드",
"loading": "로딩 중...",
"notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.",
"downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.",
@@ -349,8 +337,7 @@
"addToShare": "공유에 추가",
"removeFromShare": "공유에서 제거",
"saveChanges": "변경사항 저장",
"editFolder": "폴더 편집",
"itemsSelected": "{count, plural, =0 {선택된 항목 없음} =1 {1개 항목 선택됨} other {#개 항목 선택됨}}"
"editFolder": "폴더 편집"
},
"files": {
"title": "모든 파일",
@@ -386,12 +373,7 @@
"description": "첫 번째 파일을 업로드하거나 폴더를 만들어 시작하세요"
},
"files": "파일",
"folders": "폴더",
"errors": {
"moveItemsFailed": "항목 이동에 실패했습니다. 다시 시도해주세요.",
"cannotMoveHere": "이 위치로 항목을 이동할 수 없습니다"
},
"openFolder": "폴더 열기"
"folders": "폴더"
},
"filesTable": {
"ariaLabel": "파일 테이블",
@@ -554,10 +536,7 @@
"movingTo": "이동 위치:",
"title": "항목 이동",
"description": "항목을 새 위치로 이동",
"success": "{count}개 항목이 성공적으로 이동되었습니다",
"errors": {
"moveFailed": "항목 이동에 실패했습니다"
}
"success": "{count}개 항목이 성공적으로 이동되었습니다"
},
"navbar": {
"logoAlt": "애플리케이션 로고",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "파일 보내기 - Palmr",
"description": "공유된 링크를 통해 파일 보내기",
"descriptionWithLimit": "파일 업로드 (최대 {limit}개 파일)"
"description": "공유된 링크를 통해 파일 보내기"
},
"layout": {
"defaultTitle": "파일 보내기",
@@ -1171,6 +1149,8 @@
},
"fileActions": {
"edit": "편집",
"save": "저장",
"cancel": "취소",
"preview": "미리보기",
"download": "다운로드",
"delete": "삭제",
@@ -1318,10 +1298,6 @@
"passwordAuthEnabled": {
"title": "비밀번호 인증",
"description": "비밀번호 기반 인증 활성화 또는 비활성화"
},
"hideVersion": {
"title": "버전 숨기기",
"description": "모든 페이지의 바닥글에서 Palmr 버전 숨기기"
}
},
"buttons": {
@@ -1383,16 +1359,22 @@
"description": "이 공유는 삭제되었거나 만료되었을 수 있습니다."
},
"pageTitle": "공유",
"downloadAll": "모두 다운로드",
"metadata": {
"defaultDescription": "파일을 안전하게 공유",
"filesShared": "{count, plural, =1 {1개 파일 공유됨} other {#개 파일 공유됨}}"
}
"downloadAll": "모두 다운로드"
},
"shareActions": {
"deleteTitle": "공유 삭제",
"deleteConfirmation": "이 공유를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"editTitle": "공유 편집",
"nameLabel": "공유 이름",
"descriptionLabel": "설명",
"descriptionPlaceholder": "설명을 입력하세요 (선택사항)",
"expirationLabel": "만료 날짜",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "최대 조회수",
"maxViewsPlaceholder": "무제한은 공백으로 두세요",
"passwordProtection": "비밀번호 보호",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "비밀번호를 입력하세요",
"newPasswordLabel": "새 비밀번호 (현재 비밀번호를 유지하려면 공백으로 두세요)",
"newPasswordPlaceholder": "새 비밀번호를 입력하세요",
"manageFilesTitle": "파일 관리",
@@ -1411,9 +1393,7 @@
"linkDescriptionFile": "파일을 공유할 사용자 정의 링크 생성",
"linkDescriptionFolder": "폴더를 공유할 사용자 정의 링크 생성",
"linkReady": "공유 링크가 준비되었습니다:",
"linkTitle": "링크 생성",
"itemsSelected": "{count}개 항목 선택됨",
"manageFilesDescription": "이 공유에 포함할 파일 및 폴더 선택"
"linkTitle": "링크 생성"
},
"shareDetails": {
"title": "공유 세부 정보",
@@ -1429,6 +1409,7 @@
"noLink": "아직 링크가 생성되지 않았습니다",
"copyLink": "링크 복사",
"openLink": "새 탭에서 열기",
"linkCopied": "링크가 클립보드에 복사되었습니다",
"views": "조회수",
"dates": "날짜",
"created": "생성됨",
@@ -1476,6 +1457,28 @@
"expires": "만료:",
"expirationDate": "만료 날짜"
},
"shareFile": {
"title": "파일 공유",
"linkTitle": "링크 생성",
"nameLabel": "공유 이름",
"namePlaceholder": "공유 이름을 입력하세요",
"descriptionLabel": "설명",
"descriptionPlaceholder": "설명을 입력하세요 (선택사항)",
"expirationLabel": "만료 날짜",
"expirationPlaceholder": "YYYY/MM/DD HH:MM",
"maxViewsLabel": "최대 조회수",
"maxViewsPlaceholder": "무제한은 공백으로 두세요",
"passwordProtection": "비밀번호 보호",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "비밀번호를 입력하세요",
"linkDescription": "파일을 공유할 맞춤 링크를 생성하세요",
"aliasLabel": "링크 별칭",
"aliasPlaceholder": "맞춤 별칭을 입력하세요",
"linkReady": "공유 링크가 준비되었습니다:",
"createShare": "공유 생성",
"generateLink": "링크 생성",
"copyLink": "링크 복사"
},
"shareManager": {
"deleteSuccess": "공유가 성공적으로 삭제되었습니다",
"deleteError": "공유 삭제에 실패했습니다",
@@ -1505,10 +1508,7 @@
"noFilesToDownload": "다운로드 할 수있는 파일이 없습니다",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "zip 파일을 만들지 못했습니다",
"zipDownloadSuccess": "zip 파일이 성공적으로 다운로드되었습니다",
"errors": {
"multipleDownloadNotSupported": "여러 공유 다운로드는 아직 지원되지 않습니다 - 공유를 개별적으로 다운로드해주세요"
}
"zipDownloadSuccess": "zip 파일이 성공적으로 다운로드되었습니다"
},
"shareMultipleFiles": {
"title": "여러 파일 공유",
@@ -1921,21 +1921,5 @@
"passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
},
"embedCode": {
"title": "미디어 삽입",
"description": "이 코드를 사용하여 포럼, 웹사이트 또는 기타 플랫폼에 이 미디어를 삽입하세요",
"tabs": {
"directLink": "직접 링크",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "미디어 파일에 대한 직접 URL",
"htmlDescription": "이 코드를 사용하여 HTML 페이지에 미디어를 삽입하세요",
"bbcodeDescription": "BBCode를 지원하는 포럼에 미디어를 삽입하려면 이 코드를 사용하세요"
},
"contextMenu": {
"newFolder": "새 폴더",
"uploadFile": "파일 업로드"
}
}

View File

@@ -6,9 +6,7 @@
"token_expired": "Token verlopen. Probeer het opnieuw.",
"config_error": "Configuratiefout. Neem contact op met support.",
"auth_failed": "Authenticatie mislukt. Probeer het opnieuw."
},
"authenticationFailed": "Authenticatie mislukt",
"successfullyAuthenticated": "Succesvol geauthenticeerd!"
}
},
"authProviders": {
"title": "Authenticatie Providers",
@@ -152,9 +150,7 @@
"move": "Verplaatsen",
"rename": "Hernoemen",
"search": "Zoeken",
"share": "Delen",
"copied": "Gekopieerd",
"copy": "Kopiëren"
"share": "Delen"
},
"createShare": {
"title": "Delen Maken",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "Deel details",
"selectFiles": "Bestanden selecteren"
},
"errors": {
"nameRequired": "Deelnaam is verplicht",
"selectItems": "Selecteer ten minste één bestand of map"
},
"itemsSelected": "{count} items geselecteerd",
"passwordPlaceholder": "Voer wachtwoord in",
"selectItemsPrompt": "Selecteer bestanden en mappen om te delen"
}
},
"customization": {
"breadcrumb": "Aanpassing",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "Bestandsvoorbeeld",
"description": "Bestand bekijken en downloaden",
"loading": "Laden...",
"notAvailable": "Voorbeeld niet beschikbaar voor dit bestandstype.",
"downloadToView": "Gebruik de downloadknop om het bestand te downloaden.",
@@ -349,8 +337,7 @@
"addToShare": "Toevoegen aan share",
"removeFromShare": "Verwijderen uit share",
"saveChanges": "Wijzigingen Opslaan",
"editFolder": "Map bewerken",
"itemsSelected": "{count} items geselecteerd"
"editFolder": "Map bewerken"
},
"files": {
"title": "Alle Bestanden",
@@ -386,12 +373,7 @@
"description": "Upload uw eerste bestand of maak een map om te beginnen"
},
"files": "bestanden",
"folders": "mappen",
"errors": {
"moveItemsFailed": "Verplaatsen van items mislukt. Probeer het opnieuw.",
"cannotMoveHere": "Kan items niet naar deze locatie verplaatsen"
},
"openFolder": "Map openen"
"folders": "mappen"
},
"filesTable": {
"ariaLabel": "Bestanden tabel",
@@ -554,10 +536,7 @@
"movingTo": "Verplaatsen naar:",
"title": "{count, plural, =1 {Item} other {Items}} verplaatsen",
"description": "{count, plural, =1 {Item} other {Items}} naar een nieuwe locatie verplaatsen",
"success": "{count} {count, plural, =1 {item} other {items}} succesvol verplaatst",
"errors": {
"moveFailed": "Verplaatsen van items mislukt"
}
"success": "{count} {count, plural, =1 {item} other {items}} succesvol verplaatst"
},
"navbar": {
"logoAlt": "Applicatie Logo",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "Bestanden Verzenden - Palmr",
"description": "Verzend bestanden via de gedeelde link",
"descriptionWithLimit": "Upload bestanden (max {limit} bestanden)"
"description": "Verzend bestanden via de gedeelde link"
},
"layout": {
"defaultTitle": "Bestanden Verzenden",
@@ -1171,6 +1149,8 @@
},
"fileActions": {
"edit": "Bewerken",
"save": "Opslaan",
"cancel": "Annuleren",
"preview": "Voorvertoning",
"download": "Downloaden",
"delete": "Verwijderen",
@@ -1318,10 +1298,6 @@
"passwordAuthEnabled": {
"title": "Wachtwoord Authenticatie",
"description": "Wachtwoord-gebaseerde authenticatie inschakelen of uitschakelen"
},
"hideVersion": {
"title": "Versie Verbergen",
"description": "Verberg de Palmr-versie in de voettekst van alle pagina's"
}
},
"buttons": {
@@ -1383,16 +1359,22 @@
"description": "Dit delen is mogelijk verwijderd of verlopen."
},
"pageTitle": "Delen",
"downloadAll": "Alles Downloaden",
"metadata": {
"defaultDescription": "Bestanden veilig delen",
"filesShared": "{count, plural, =1 {1 bestand gedeeld} other {# bestanden gedeeld}}"
}
"downloadAll": "Alles Downloaden"
},
"shareActions": {
"deleteTitle": "Delen Verwijderen",
"deleteConfirmation": "Weet je zeker dat je dit delen wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"editTitle": "Delen Bewerken",
"nameLabel": "Delen Naam",
"descriptionLabel": "Beschrijving",
"descriptionPlaceholder": "Voer een beschrijving in (optioneel)",
"expirationLabel": "Vervaldatum",
"expirationPlaceholder": "DD/MM/JJJJ UU:MM",
"maxViewsLabel": "Maximale Weergaven",
"maxViewsPlaceholder": "Laat leeg voor onbeperkt",
"passwordProtection": "Wachtwoord Beveiligd",
"passwordLabel": "Wachtwoord",
"passwordPlaceholder": "Voer wachtwoord in",
"newPasswordLabel": "Nieuw Wachtwoord (laat leeg om huidig te behouden)",
"newPasswordPlaceholder": "Voer nieuw wachtwoord in",
"manageFilesTitle": "Bestanden Beheren",
@@ -1411,9 +1393,7 @@
"linkDescriptionFile": "Genereer een aangepaste link om het bestand te delen",
"linkDescriptionFolder": "Genereer een aangepaste link om de map te delen",
"linkReady": "Uw deel-link is klaar:",
"linkTitle": "Link genereren",
"itemsSelected": "{count} items geselecteerd",
"manageFilesDescription": "Selecteer bestanden en mappen om in dit deel op te nemen"
"linkTitle": "Link genereren"
},
"shareDetails": {
"title": "Delen Details",
@@ -1429,6 +1409,7 @@
"noLink": "Nog geen link gegenereerd",
"copyLink": "Link kopiëren",
"openLink": "Openen in nieuw tabblad",
"linkCopied": "Link gekopieerd naar klembord",
"views": "Weergaven",
"dates": "Data",
"created": "Aangemaakt",
@@ -1476,6 +1457,28 @@
"expires": "Verloopt:",
"expirationDate": "Vervaldatum"
},
"shareFile": {
"title": "Bestand Delen",
"linkTitle": "Link Genereren",
"nameLabel": "Delen Naam",
"namePlaceholder": "Voer delen naam in",
"descriptionLabel": "Beschrijving",
"descriptionPlaceholder": "Voer een beschrijving in (optioneel)",
"expirationLabel": "Vervaldatum",
"expirationPlaceholder": "DD/MM/JJJJ UU:MM",
"maxViewsLabel": "Maximale Weergaven",
"maxViewsPlaceholder": "Laat leeg voor onbeperkt",
"passwordProtection": "Wachtwoord Beveiligd",
"passwordLabel": "Wachtwoord",
"passwordPlaceholder": "Voer wachtwoord in",
"linkDescription": "Genereer een aangepaste link om het bestand te delen",
"aliasLabel": "Link Alias",
"aliasPlaceholder": "Voer aangepaste alias in",
"linkReady": "Jouw delen link is klaar:",
"createShare": "Delen Aanmaken",
"generateLink": "Link Genereren",
"copyLink": "Link Kopiëren"
},
"shareManager": {
"deleteSuccess": "Delen succesvol verwijderd",
"deleteError": "Fout bij het verwijderen van delen",
@@ -1505,10 +1508,7 @@
"noFilesToDownload": "Geen bestanden beschikbaar om te downloaden",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "Kan zip -bestand niet maken",
"zipDownloadSuccess": "Zipbestand met succes gedownload",
"errors": {
"multipleDownloadNotSupported": "Download van meerdere delen wordt nog niet ondersteund - download delen afzonderlijk"
}
"zipDownloadSuccess": "Zipbestand met succes gedownload"
},
"shareMultipleFiles": {
"title": "Meerdere Bestanden Delen",
@@ -1921,21 +1921,5 @@
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
"nameRequired": "Naam is verplicht",
"required": "Dit veld is verplicht"
},
"embedCode": {
"title": "Media insluiten",
"description": "Gebruik deze codes om deze media in te sluiten in forums, websites of andere platforms",
"tabs": {
"directLink": "Directe link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Directe URL naar het mediabestand",
"htmlDescription": "Gebruik deze code om de media in te sluiten in HTML-pagina's",
"bbcodeDescription": "Gebruik deze code om de media in te sluiten in forums die BBCode ondersteunen"
},
"contextMenu": {
"newFolder": "Nieuwe map",
"uploadFile": "Bestand uploaden"
}
}

View File

@@ -6,9 +6,7 @@
"token_expired": "Token wygasł. Spróbuj ponownie.",
"config_error": "Błąd konfiguracji. Skontaktuj się z pomocą techniczną.",
"auth_failed": "Uwierzytelnienie nie powiodło się. Spróbuj ponownie."
},
"authenticationFailed": "Uwierzytelnienie nie powiodło się",
"successfullyAuthenticated": "Pomyślnie uwierzytelniono!"
}
},
"authProviders": {
"title": "Dostawcy uwierzytelniania",
@@ -152,9 +150,7 @@
"move": "Przenieś",
"rename": "Zmień nazwę",
"search": "Szukaj",
"share": "Udostępnij",
"copied": "Skopiowano",
"copy": "Kopiuj"
"share": "Udostępnij"
},
"createShare": {
"title": "Utwórz Udostępnienie",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "Szczegóły udostępniania",
"selectFiles": "Wybierz pliki"
},
"errors": {
"nameRequired": "Nazwa udostępnienia jest wymagana",
"selectItems": "Wybierz co najmniej jeden plik lub folder"
},
"itemsSelected": "Wybrano {count} elementów",
"passwordPlaceholder": "Wprowadź hasło",
"selectItemsPrompt": "Wybierz pliki i foldery do udostępnienia"
}
},
"customization": {
"breadcrumb": "Personalizacja",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "Podgląd pliku",
"description": "Podgląd i pobieranie pliku",
"loading": "Ładowanie...",
"notAvailable": "Podgląd niedostępny dla tego typu pliku",
"downloadToView": "Użyj przycisku pobierania, aby wyświetlić ten plik",
@@ -349,8 +337,7 @@
"addToShare": "Dodaj do udostępnienia",
"removeFromShare": "Usuń z udostępnienia",
"saveChanges": "Zapisz zmiany",
"editFolder": "Edytuj folder",
"itemsSelected": "Wybrano {count} elementów"
"editFolder": "Edytuj folder"
},
"files": {
"title": "Wszystkie pliki",
@@ -386,12 +373,7 @@
"description": "Prześlij swój pierwszy plik lub utwórz folder, aby rozpocząć"
},
"files": "pliki",
"folders": "foldery",
"errors": {
"moveItemsFailed": "Nie udało się przenieść elementów. Spróbuj ponownie.",
"cannotMoveHere": "Nie można przenieść elementów do tej lokalizacji"
},
"openFolder": "Otwórz folder"
"folders": "foldery"
},
"filesTable": {
"ariaLabel": "Tabela plików",
@@ -554,10 +536,7 @@
"movingTo": "Przenoszenie do:",
"title": "Przenieś {count, plural, =1 {element} other {elementy}}",
"description": "Przenieś {count, plural, =1 {element} other {elementy}} do nowej lokalizacji",
"success": "Pomyślnie przeniesiono {count} {count, plural, =1 {element} other {elementów}}",
"errors": {
"moveFailed": "Nie udało się przenieść elementów"
}
"success": "Pomyślnie przeniesiono {count} {count, plural, =1 {element} other {elementów}}"
},
"navbar": {
"logoAlt": "Logo aplikacji",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "Wyślij pliki - Palmr",
"description": "Wysyłaj pliki za pośrednictwem udostępnionego linku",
"descriptionWithLimit": "Prześlij pliki (maks. {limit} plików)"
"description": "Wysyłaj pliki za pośrednictwem udostępnionego linku"
},
"layout": {
"defaultTitle": "Wyślij pliki",
@@ -1171,6 +1149,8 @@
},
"fileActions": {
"edit": "Edytuj",
"save": "Zapisz",
"cancel": "Anuluj",
"preview": "Podgląd",
"download": "Pobierz",
"delete": "Usuń",
@@ -1318,10 +1298,6 @@
"passwordAuthEnabled": {
"title": "Uwierzytelnianie hasłem",
"description": "Włącz lub wyłącz uwierzytelnianie oparte na haśle"
},
"hideVersion": {
"title": "Ukryj Wersję",
"description": "Ukryj wersję Palmr w stopce wszystkich stron"
}
},
"buttons": {
@@ -1383,16 +1359,22 @@
"description": "To udostępnienie mogło zostać usunięte lub wygasło."
},
"pageTitle": "Udostępnij",
"downloadAll": "Pobierz wszystkie",
"metadata": {
"defaultDescription": "Bezpiecznie udostępniaj pliki",
"filesShared": "{count, plural, =1 {1 plik udostępniony} other {# plików udostępnionych}}"
}
"downloadAll": "Pobierz wszystkie"
},
"shareActions": {
"deleteTitle": "Usuń udostępnienie",
"deleteConfirmation": "Czy na pewno chcesz usunąć to udostępnienie? Tej operacji nie można cofnąć.",
"editTitle": "Edytuj udostępnienie",
"nameLabel": "Nazwa udostępnienia",
"descriptionLabel": "Opis",
"descriptionPlaceholder": "Wpisz opis (opcjonalnie)",
"expirationLabel": "Data wygaśnięcia",
"expirationPlaceholder": "MM/DD/RRRR GG:MM",
"maxViewsLabel": "Maksymalna liczba wyświetleń",
"maxViewsPlaceholder": "Pozostaw puste dla nieograniczonej liczby",
"passwordProtection": "Chronione hasłem",
"passwordLabel": "Hasło",
"passwordPlaceholder": "Wprowadź hasło",
"newPasswordLabel": "Nowe hasło (pozostaw puste, aby zachować bieżące)",
"newPasswordPlaceholder": "Wprowadź nowe hasło",
"manageFilesTitle": "Zarządzaj plikami",
@@ -1411,9 +1393,7 @@
"linkDescriptionFile": "Wygeneruj niestandardowy link do udostępnienia pliku",
"linkDescriptionFolder": "Wygeneruj niestandardowy link do udostępnienia folderu",
"linkReady": "Twój link udostępniania jest gotowy:",
"linkTitle": "Generuj link",
"itemsSelected": "Wybrano {count} elementów",
"manageFilesDescription": "Wybierz pliki i foldery do uwzględnienia w tym udostępnieniu"
"linkTitle": "Generuj link"
},
"shareDetails": {
"title": "Szczegóły udostępnienia",
@@ -1429,6 +1409,7 @@
"noLink": "Brak wygenerowanego linku",
"copyLink": "Skopiuj link",
"openLink": "Otwórz w nowej karcie",
"linkCopied": "Link skopiowany do schowka",
"views": "Wyświetlenia",
"dates": "Daty",
"created": "Utworzono",
@@ -1476,6 +1457,28 @@
"noExpiration": "To udostępnienie nigdy nie wygaśnie i pozostanie dostępne bezterminowo."
}
},
"shareFile": {
"title": "Udostępnij plik",
"linkTitle": "Generuj link",
"nameLabel": "Nazwa udostępnienia",
"namePlaceholder": "Wprowadź nazwę udostępnienia",
"descriptionLabel": "Opis",
"descriptionPlaceholder": "Wprowadź opis (opcjonalnie)",
"expirationLabel": "Data wygaśnięcia",
"expirationPlaceholder": "MM/DD/RRRR GG:MM",
"maxViewsLabel": "Maksymalna liczba wyświetleń",
"maxViewsPlaceholder": "Pozostaw puste dla nieograniczonej liczby",
"passwordProtection": "Chronione hasłem",
"passwordLabel": "Hasło",
"passwordPlaceholder": "Wprowadź hasło",
"linkDescription": "Wygeneruj niestandardowy link do udostępniania pliku",
"aliasLabel": "Alias linku",
"aliasPlaceholder": "Wprowadź własny alias",
"linkReady": "Twój link do udostępniania jest gotowy:",
"createShare": "Utwórz udostępnienie",
"generateLink": "Generuj link",
"copyLink": "Skopiuj link"
},
"shareManager": {
"deleteSuccess": "Udostępnienie usunięte pomyślnie",
"deleteError": "Nie udało się usunąć udostępnienia",
@@ -1505,10 +1508,7 @@
"noFilesToDownload": "Brak plików do pobrania",
"singleShareZipName": "{ShaRename} _files.zip",
"zipDownloadError": "Nie udało się utworzyć pliku zip",
"zipDownloadSuccess": "Plik zip pobrany pomyślnie",
"errors": {
"multipleDownloadNotSupported": "Pobieranie wielu udostępnień nie jest jeszcze obsługiwane - pobierz udostępnienia pojedynczo"
}
"zipDownloadSuccess": "Plik zip pobrany pomyślnie"
},
"shareMultipleFiles": {
"title": "Udostępnij wiele plików",
@@ -1921,21 +1921,5 @@
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
"nameRequired": "Nazwa jest wymagana",
"required": "To pole jest wymagane"
},
"embedCode": {
"title": "Osadź multimedia",
"description": "Użyj tych kodów, aby osadzić te multimedia na forach, stronach internetowych lub innych platformach",
"tabs": {
"directLink": "Link bezpośredni",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Bezpośredni adres URL pliku multimedialnego",
"htmlDescription": "Użyj tego kodu, aby osadzić multimedia na stronach HTML",
"bbcodeDescription": "Użyj tego kodu, aby osadzić multimedia na forach obsługujących BBCode"
},
"contextMenu": {
"newFolder": "Nowy folder",
"uploadFile": "Prześlij plik"
}
}

View File

@@ -6,13 +6,7 @@
"token_expired": "Token expirado. Tente novamente.",
"config_error": "Erro de configuração. Contate o suporte.",
"auth_failed": "Falha na autenticação. Tente novamente."
},
"authenticationFailed": "Falha na autenticação",
"successfullyAuthenticated": "Autenticado com sucesso!"
},
"contextMenu": {
"newFolder": "Nova pasta",
"uploadFile": "Enviar arquivo"
}
},
"authProviders": {
"title": "Provedores de autenticação",
@@ -156,9 +150,7 @@
"move": "Mover",
"rename": "Renomear",
"search": "Pesquisar",
"share": "Compartilhar",
"copied": "Copiado",
"copy": "Copiar"
"share": "Compartilhar"
},
"createShare": {
"title": "Criar compartilhamento",
@@ -180,14 +172,7 @@
"tabs": {
"shareDetails": "Detalhes do compartilhamento",
"selectFiles": "Selecionar arquivos"
},
"errors": {
"nameRequired": "O nome do compartilhamento é obrigatório",
"selectItems": "Por favor, selecione pelo menos um arquivo ou pasta"
},
"itemsSelected": "{count} itens selecionados",
"passwordPlaceholder": "Digite a senha",
"selectItemsPrompt": "Selecione arquivos e pastas para compartilhar"
}
},
"customization": {
"breadcrumb": "Personalização",
@@ -317,7 +302,6 @@
},
"filePreview": {
"title": "Visualizar Arquivo",
"description": "Visualizar e baixar arquivo",
"loading": "Carregando...",
"notAvailable": "Preview não disponível para este tipo de arquivo.",
"downloadToView": "Use o botão de download para baixar o arquivo.",
@@ -353,8 +337,7 @@
"addToShare": "Adicionar ao compartilhamento",
"removeFromShare": "Remover do compartilhamento",
"saveChanges": "Salvar Alterações",
"editFolder": "Editar pasta",
"itemsSelected": "{count} itens selecionados"
"editFolder": "Editar pasta"
},
"files": {
"title": "Todos os Arquivos",
@@ -390,12 +373,7 @@
"description": "Carregue seu primeiro arquivo ou crie uma pasta para começar"
},
"files": "arquivos",
"folders": "pastas",
"errors": {
"moveItemsFailed": "Falha ao mover itens. Por favor, tente novamente.",
"cannotMoveHere": "Não é possível mover itens para este local"
},
"openFolder": "Abrir pasta"
"folders": "pastas"
},
"filesTable": {
"ariaLabel": "Tabela de arquivos",
@@ -558,10 +536,7 @@
"movingTo": "Movendo para:",
"title": "Mover {count, plural, =1 {item} other {itens}}",
"description": "Mover {count, plural, =1 {item} other {itens}} para um novo local",
"success": "Movidos com sucesso {count} {count, plural, =1 {item} other {itens}}",
"errors": {
"moveFailed": "Falha ao mover itens"
}
"success": "Movidos com sucesso {count} {count, plural, =1 {item} other {itens}}"
},
"navbar": {
"logoAlt": "Logo do aplicativo",
@@ -936,6 +911,7 @@
"size": "Tamanho",
"sender": "Enviado por",
"date": "Data",
"invalidDate": "Data inválida",
"actions": "Ações"
},
"actions": {
@@ -1081,8 +1057,7 @@
"upload": {
"metadata": {
"title": "Enviar Arquivos - Palmr",
"description": "Envie arquivos através do link compartilhado",
"descriptionWithLimit": "Enviar arquivos (máx. {limit} arquivos)"
"description": "Envie arquivos através do link compartilhado"
},
"layout": {
"defaultTitle": "Enviar Arquivos",
@@ -1175,6 +1150,8 @@
},
"fileActions": {
"edit": "Editar",
"save": "Salvar",
"cancel": "Cancelar",
"preview": "Visualizar",
"download": "Baixar",
"delete": "Excluir",
@@ -1330,10 +1307,6 @@
"passwordAuthEnabled": {
"title": "Autenticação por Senha",
"description": "Ative ou desative a autenticação baseada em senha"
},
"hideVersion": {
"title": "Ocultar Versão",
"description": "Ocultar a versão do Palmr no rodapé de todas as páginas"
}
},
"buttons": {
@@ -1387,11 +1360,7 @@
"description": "Este compartilhamento pode ter sido excluído ou expirado."
},
"pageTitle": "Compartilhamento",
"downloadAll": "Baixar todos",
"metadata": {
"defaultDescription": "Compartilhar arquivos com segurança",
"filesShared": "{count, plural, =1 {1 arquivo compartilhado} other {# arquivos compartilhados}}"
}
"downloadAll": "Baixar todos"
},
"shareActions": {
"deleteTitle": "Excluir Compartilhamento",
@@ -1400,6 +1369,16 @@
"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",
"nameLabel": "Nome do Compartilhamento",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite uma descrição (opcional)",
"expirationLabel": "Data de Expiração",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Máximo de Visualizações",
"maxViewsPlaceholder": "Deixe vazio para ilimitado",
"passwordProtection": "Protegido por Senha",
"passwordLabel": "Senha",
"passwordPlaceholder": "Digite a senha",
"newPasswordLabel": "Nova Senha (deixe vazio para manter a atual)",
"newPasswordPlaceholder": "Digite a nova senha",
"manageFilesTitle": "Gerenciar Arquivos",
@@ -1415,9 +1394,7 @@
"linkDescriptionFile": "Gere um link personalizado para compartilhar o arquivo",
"linkDescriptionFolder": "Gere um link personalizado para compartilhar a pasta",
"linkReady": "Seu link de compartilhamento está pronto:",
"linkTitle": "Gerar link",
"itemsSelected": "{count} itens selecionados",
"manageFilesDescription": "Selecione arquivos e pastas para incluir neste compartilhamento"
"linkTitle": "Gerar link"
},
"shareDetails": {
"title": "Detalhes do Compartilhamento",
@@ -1433,6 +1410,7 @@
"noLink": "Nenhum link gerado ainda",
"copyLink": "Copiar link",
"openLink": "Abrir em nova guia",
"linkCopied": "Link copiado para a área de transferência",
"views": "Visualizações",
"dates": "Datas",
"created": "Criado",
@@ -1480,6 +1458,28 @@
"expires": "Expira:",
"expirationDate": "Data de expiração"
},
"shareFile": {
"title": "Compartilhar arquivo",
"linkTitle": "Gerar link",
"nameLabel": "Nome do compartilhamento",
"namePlaceholder": "Digite o nome do compartilhamento",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite uma descrição (opcional)",
"expirationLabel": "Data de Expiração",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Máximo de Visualizações",
"maxViewsPlaceholder": "Deixe vazio para ilimitado",
"passwordProtection": "Protegido por senha",
"passwordLabel": "Senha",
"passwordPlaceholder": "Digite a senha",
"linkDescription": "Gere um link personalizado para compartilhar o arquivo",
"aliasLabel": "Alias do link",
"aliasPlaceholder": "Digite um alias personalizado",
"linkReady": "Seu link de compartilhamento está pronto:",
"createShare": "Criar compartilhamento",
"generateLink": "Gerar link",
"copyLink": "Copiar link"
},
"shareManager": {
"deleteSuccess": "Compartilhamento excluído com sucesso",
"deleteError": "Falha ao excluir compartilhamento",
@@ -1509,10 +1509,7 @@
"noFilesToDownload": "Nenhum arquivo disponível para download",
"singleShareZipName": "{sharename}.zip",
"zipDownloadError": "Falha ao criar o arquivo zip",
"zipDownloadSuccess": "Arquivo ZIP baixado com sucesso",
"errors": {
"multipleDownloadNotSupported": "Download de múltiplos compartilhamentos ainda não é suportado - por favor, baixe os compartilhamentos individualmente"
}
"zipDownloadSuccess": "FILE DE ZIP FILHADO COMBONHADO com sucesso"
},
"shareMultipleFiles": {
"title": "Compartilhar Múltiplos Arquivos",
@@ -1925,17 +1922,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"
},
"embedCode": {
"title": "Incorporar mídia",
"description": "Use estes códigos para incorporar esta mídia em fóruns, sites ou outras plataformas",
"tabs": {
"directLink": "Link direto",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL direto para o arquivo de mídia",
"htmlDescription": "Use este código para incorporar a mídia em páginas HTML",
"bbcodeDescription": "Use este código para incorporar a mídia em fóruns que suportam BBCode"
}
}

View File

@@ -6,9 +6,7 @@
"token_expired": "Token expirado. Tente novamente.",
"config_error": "Erro de configuração. Contate o suporte.",
"auth_failed": "Falha na autenticação. Tente novamente."
},
"authenticationFailed": "Ошибка аутентификации",
"successfullyAuthenticated": "Успешно аутентифицирован!"
}
},
"authProviders": {
"title": "Провайдеры аутентификации",
@@ -152,9 +150,7 @@
"move": "Переместить",
"rename": "Переименовать",
"search": "Поиск",
"share": "Поделиться",
"copied": "Скопировано",
"copy": "Копировать"
"share": "Поделиться"
},
"createShare": {
"title": "Создать общий доступ",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "Детали общего доступа",
"selectFiles": "Выбрать файлы"
},
"errors": {
"nameRequired": "Требуется имя общего ресурса",
"selectItems": "Выберите хотя бы один файл или папку"
},
"itemsSelected": "Выбрано элементов: {count}",
"passwordPlaceholder": "Введите пароль",
"selectItemsPrompt": "Выберите файлы и папки для общего доступа"
}
},
"customization": {
"breadcrumb": "Настройка",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "Предварительный просмотр файла",
"description": "Просмотр и загрузка файла",
"loading": "Загрузка...",
"notAvailable": "Предварительный просмотр недоступен для этого типа файла.",
"downloadToView": "Используйте кнопку загрузки для скачивания файла.",
@@ -349,8 +337,7 @@
"addToShare": "Добавить в общий доступ",
"removeFromShare": "Удалить из общего доступа",
"saveChanges": "Сохранить Изменения",
"editFolder": "Редактировать папку",
"itemsSelected": "Выбрано элементов: {count}"
"editFolder": "Редактировать папку"
},
"files": {
"title": "Все файлы",
@@ -386,12 +373,7 @@
"description": "Загрузите свой первый файл или создайте папку для начала работы"
},
"files": "файлы",
"folders": "папки",
"errors": {
"moveItemsFailed": "Не удалось переместить элементы. Попробуйте еще раз.",
"cannotMoveHere": "Невозможно переместить элементы в это место"
},
"openFolder": "Открыть папку"
"folders": "папки"
},
"filesTable": {
"ariaLabel": "Таблица файлов",
@@ -554,10 +536,7 @@
"movingTo": "Перемещение в:",
"title": "Переместить {count, plural, =1 {элемент} other {элементы}}",
"description": "Переместить {count, plural, =1 {элемент} other {элементы}} в новое место",
"success": "Успешно перемещено {count} {count, plural, =1 {элемент} other {элементов}}",
"errors": {
"moveFailed": "Не удалось переместить элементы"
}
"success": "Успешно перемещено {count} {count, plural, =1 {элемент} other {элементов}}"
},
"navbar": {
"logoAlt": "Логотип приложения",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "Отправка файлов - Palmr",
"description": "Отправка файлов через общую ссылку",
"descriptionWithLimit": "Загрузить файлы (макс. {limit} файлов)"
"description": "Отправка файлов через общую ссылку"
},
"layout": {
"defaultTitle": "Отправка файлов",
@@ -1171,6 +1149,8 @@
},
"fileActions": {
"edit": "Редактировать",
"save": "Сохранить",
"cancel": "Отмена",
"preview": "Предпросмотр",
"download": "Скачать",
"delete": "Удалить",
@@ -1318,10 +1298,6 @@
"passwordAuthEnabled": {
"title": "Парольная аутентификация",
"description": "Включить или отключить парольную аутентификацию"
},
"hideVersion": {
"title": "Скрыть Версию",
"description": "Скрыть версию Palmr в нижнем колонтитуле всех страниц"
}
},
"buttons": {
@@ -1383,16 +1359,22 @@
"description": "Этот общий доступ может быть удален или истек."
},
"pageTitle": "Общий доступ",
"downloadAll": "Скачать все",
"metadata": {
"defaultDescription": "Безопасный обмен файлами",
"filesShared": "{count, plural, =1 {1 файл отправлен} other {# файлов отправлено}}"
}
"downloadAll": "Скачать все"
},
"shareActions": {
"deleteTitle": "Удалить Общий Доступ",
"deleteConfirmation": "Вы уверены, что хотите удалить этот общий доступ? Это действие нельзя отменить.",
"editTitle": "Редактировать Общий Доступ",
"nameLabel": "Название Общего Доступа",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Введите описание (опционально)",
"expirationLabel": "Дата Истечения",
"expirationPlaceholder": "ДД.ММ.ГГГГ ЧЧ:ММ",
"maxViewsLabel": "Максимальные Просмотры",
"maxViewsPlaceholder": "Оставьте пустым для неограниченного",
"passwordProtection": "Защищено Паролем",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите пароль",
"newPasswordLabel": "Новый Пароль (оставьте пустым, чтобы сохранить текущий)",
"newPasswordPlaceholder": "Введите новый пароль",
"manageFilesTitle": "Управление Файлами",
@@ -1411,9 +1393,7 @@
"linkDescriptionFile": "Создайте пользовательскую ссылку для обмена файлом",
"linkDescriptionFolder": "Создайте пользовательскую ссылку для обмена папкой",
"linkReady": "Ваша ссылка для обмена готова:",
"linkTitle": "Создать ссылку",
"itemsSelected": "Выбрано элементов: {count}",
"manageFilesDescription": "Выберите файлы и папки для включения в этот общий доступ"
"linkTitle": "Создать ссылку"
},
"shareDetails": {
"title": "Детали Общего Доступа",
@@ -1429,6 +1409,7 @@
"noLink": "Ссылка еще не сгенерирована",
"copyLink": "Скопировать ссылку",
"openLink": "Открыть в новой вкладке",
"linkCopied": "Ссылка скопирована в буфер обмена",
"views": "Просмотры",
"dates": "Даты",
"created": "Создано",
@@ -1476,6 +1457,28 @@
"expires": "Истекает:",
"expirationDate": "Дата истечения"
},
"shareFile": {
"title": "Поделиться Файлом",
"linkTitle": "Сгенерировать Ссылку",
"nameLabel": "Название Общего Доступа",
"namePlaceholder": "Введите название общего доступа",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Введите описание (опционально)",
"expirationLabel": "Дата Истечения",
"expirationPlaceholder": "ДД.ММ.ГГГГ ЧЧ:ММ",
"maxViewsLabel": "Максимальные Просмотры",
"maxViewsPlaceholder": "Оставьте пустым для неограниченного",
"passwordProtection": "Защищено Паролем",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите пароль",
"linkDescription": "Сгенерируйте пользовательскую ссылку для отправки файла",
"aliasLabel": "Псевдоним Ссылки",
"aliasPlaceholder": "Введите пользовательский псевдоним",
"linkReady": "Ваша ссылка для общего доступа готова:",
"createShare": "Создать Общий Доступ",
"generateLink": "Сгенерировать Ссылку",
"copyLink": "Скопировать Ссылку"
},
"shareManager": {
"deleteSuccess": "Общий доступ успешно удален",
"deleteError": "Ошибка при удалении общего доступа",
@@ -1505,10 +1508,7 @@
"noFilesToDownload": "Нет файлов для скачивания",
"singleShareZipName": "{shareme} _files.zip",
"zipDownloadError": "Не удалось создать zip -файл",
"zipDownloadSuccess": "Zip -файл успешно загружен",
"errors": {
"multipleDownloadNotSupported": "Множественная загрузка общих ресурсов пока не поддерживается - загружайте общие ресурсы по отдельности"
}
"zipDownloadSuccess": "Zip -файл успешно загружен"
},
"shareMultipleFiles": {
"title": "Поделиться Несколькими Файлами",
@@ -1921,21 +1921,5 @@
"passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
},
"embedCode": {
"title": "Встроить медиа",
"description": "Используйте эти коды для встраивания этого медиа на форумах, веб-сайтах или других платформах",
"tabs": {
"directLink": "Прямая ссылка",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Прямой URL-адрес медиафайла",
"htmlDescription": "Используйте этот код для встраивания медиа в HTML-страницы",
"bbcodeDescription": "Используйте этот код для встраивания медиа на форумах, поддерживающих BBCode"
},
"contextMenu": {
"newFolder": "Новая папка",
"uploadFile": "Загрузить файл"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,7 @@
"token_expired": "Token süresi doldu. Lütfen tekrar deneyin.",
"config_error": "Yapılandırma hatası. Lütfen destek ile iletişime geçin.",
"auth_failed": "Kimlik doğrulama başarısız. Lütfen tekrar deneyin."
},
"authenticationFailed": "Kimlik doğrulama başarısız",
"successfullyAuthenticated": "Başarıyla kimlik doğrulandı!"
}
},
"authProviders": {
"title": "Kimlik Doğrulama Sağlayıcıları",
@@ -152,9 +150,7 @@
"move": "Taşı",
"rename": "Yeniden Adlandır",
"search": "Ara",
"share": "Paylaş",
"copied": "Kopyalandı",
"copy": "Kopyala"
"share": "Paylaş"
},
"createShare": {
"title": "Paylaşım Oluştur",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "Paylaşım Detayları",
"selectFiles": "Dosyaları Seç"
},
"errors": {
"nameRequired": "Paylaşım adı gerekli",
"selectItems": "Lütfen en az bir dosya veya klasör seçin"
},
"itemsSelected": "{count} öğe seçildi",
"passwordPlaceholder": "Şifre girin",
"selectItemsPrompt": "Paylaşmak için dosya ve klasörleri seçin"
}
},
"customization": {
"breadcrumb": "Özelleştirme",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "Dosya Önizleme",
"description": "Dosyayı önizleyin ve indirin",
"loading": "Yükleniyor...",
"notAvailable": "Bu dosya türü için önizleme mevcut değil.",
"downloadToView": "Dosyayı indirmek için indirme düğmesini kullanın.",
@@ -349,8 +337,7 @@
"addToShare": "Paylaşıma ekle",
"removeFromShare": "Paylaşımdan kaldır",
"saveChanges": "Değişiklikleri Kaydet",
"editFolder": "Klasörü düzenle",
"itemsSelected": "{count} öğe seçildi"
"editFolder": "Klasörü düzenle"
},
"files": {
"title": "Tüm Dosyalar",
@@ -386,12 +373,7 @@
"description": "Başlamak için ilk dosyanızı yükleyin veya bir klasör oluşturun"
},
"files": "dosyalar",
"folders": "klasörler",
"errors": {
"moveItemsFailed": "Öğeler taşınamadı. Lütfen tekrar deneyin.",
"cannotMoveHere": "Öğeler bu konuma taşınamaz"
},
"openFolder": "Klasörü aç"
"folders": "klasörler"
},
"filesTable": {
"ariaLabel": "Dosya Tablosu",
@@ -554,10 +536,7 @@
"movingTo": "Taşınıyor:",
"title": "{count, plural, =1 {Öğe} other {Öğeler}} Taşı",
"description": "{count, plural, =1 {Öğeyi} other {Öğeleri}} yeni konuma taşı",
"success": "{count} {count, plural, =1 {öğe} other {öğe}} başarıyla taşındı",
"errors": {
"moveFailed": "Öğeler taşınamadı"
}
"success": "{count} {count, plural, =1 {öğe} other {öğe}} başarıyla taşındı"
},
"navbar": {
"logoAlt": "Uygulama Logosu",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "Dosya Gönder - Palmr",
"description": "Paylaşılan bağlantı üzerinden dosya gönderin",
"descriptionWithLimit": "Dosya yükle (maks. {limit} dosya)"
"description": "Paylaşılan bağlantı üzerinden dosya gönderin"
},
"layout": {
"defaultTitle": "Dosya Gönder",
@@ -1171,6 +1149,8 @@
},
"fileActions": {
"edit": "Düzenle",
"save": "Kaydet",
"cancel": "İptal",
"preview": "Önizle",
"download": "İndir",
"delete": "Sil",
@@ -1318,10 +1298,6 @@
"passwordAuthEnabled": {
"title": "Şifre Doğrulama",
"description": "Şifre tabanlı doğrulamayı etkinleştirme veya devre dışı bırakma"
},
"hideVersion": {
"title": "Sürümü Gizle",
"description": "Tüm sayfaların alt bilgisinde Palmr sürümünü gizle"
}
},
"buttons": {
@@ -1383,16 +1359,22 @@
"description": "Bu paylaşım silinmiş veya süresi dolmuş olabilir."
},
"pageTitle": "Paylaşım",
"downloadAll": "Tümünü İndir",
"metadata": {
"defaultDescription": "Dosyaları güvenli bir şekilde paylaşın",
"filesShared": "{count, plural, =1 {1 dosya paylaşıldı} other {# dosya paylaşıldı}}"
}
"downloadAll": "Tümünü İndir"
},
"shareActions": {
"deleteTitle": "Paylaşımı Sil",
"deleteConfirmation": "Bu paylaşımı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"editTitle": "Paylaşımı Düzenle",
"nameLabel": "Paylaşım Adı",
"descriptionLabel": "Açıklama",
"descriptionPlaceholder": "Açıklama girin (isteğe bağlı)",
"expirationLabel": "Son Kullanma Tarihi",
"expirationPlaceholder": "DD/MM/YYYY HH:MM",
"maxViewsLabel": "Maksimum Görüntüleme",
"maxViewsPlaceholder": "Sınırsız için boş bırakın",
"passwordProtection": "Şifre Korumalı",
"passwordLabel": "Şifre",
"passwordPlaceholder": "Şifre girin",
"newPasswordLabel": "Yeni Şifre (mevcut şifreyi korumak için boş bırakın)",
"newPasswordPlaceholder": "Yeni şifre girin",
"manageFilesTitle": "Dosyaları Yönet",
@@ -1411,9 +1393,7 @@
"linkDescriptionFile": "Dosyayı paylaşmak için özel bağlantı oluşturun",
"linkDescriptionFolder": "Klasörü paylaşmak için özel bağlantı oluşturun",
"linkReady": "Paylaşım bağlantınız hazır:",
"linkTitle": "Bağlantı Oluştur",
"itemsSelected": "{count} öğe seçildi",
"manageFilesDescription": "Bu paylaşıma dahil edilecek dosya ve klasörleri seçin"
"linkTitle": "Bağlantı Oluştur"
},
"shareDetails": {
"title": "Paylaşım Detayları",
@@ -1429,6 +1409,7 @@
"noLink": "Henüz bağlantı oluşturulmadı",
"copyLink": "Bağlantıyı kopyala",
"openLink": "Yeni sekmede aç",
"linkCopied": "Bağlantı panoya kopyalandı",
"views": "Görüntüleme",
"dates": "Tarihler",
"created": "Oluşturuldu",
@@ -1476,6 +1457,28 @@
"expires": "Sona erer:",
"expirationDate": "Son Kullanma Tarihi"
},
"shareFile": {
"title": "Dosya Paylaş",
"linkTitle": "Bağlantı Oluştur",
"nameLabel": "Paylaşım Adı",
"namePlaceholder": "Paylaşım adı girin",
"descriptionLabel": "Açıklama",
"descriptionPlaceholder": "Açıklama girin (isteğe bağlı)",
"expirationLabel": "Son Kullanma Tarihi",
"expirationPlaceholder": "DD/MM/YYYY HH:MM",
"maxViewsLabel": "Maksimum Görüntüleme",
"maxViewsPlaceholder": "Sınırsız için boş bırakın",
"passwordProtection": "Şifre Korumalı",
"passwordLabel": "Şifre",
"passwordPlaceholder": "Şifre girin",
"linkDescription": "Dosyayı paylaşmak için özel bağlantı oluşturun",
"aliasLabel": "Bağlantı Takma Adı",
"aliasPlaceholder": "Özel takma ad girin",
"linkReady": "Paylaşım bağlantınız hazır:",
"createShare": "Paylaşım Oluştur",
"generateLink": "Bağlantı Oluştur",
"copyLink": "Bağlantıyı Kopyala"
},
"shareManager": {
"deleteSuccess": "Paylaşım başarıyla silindi",
"deleteError": "Paylaşım silinemedi",
@@ -1505,10 +1508,7 @@
"noFilesToDownload": "İndirilebilecek dosya yok",
"singleShareZipName": "{Sharename} _files.zip",
"zipDownloadError": "Zip dosyası oluşturulamadı",
"zipDownloadSuccess": "Zip dosyası başarıyla indirildi",
"errors": {
"multipleDownloadNotSupported": "Çoklu paylaşım indirme henüz desteklenmiyor - lütfen paylaşımları ayrı ayrı indirin"
}
"zipDownloadSuccess": "Zip dosyası başarıyla indirildi"
},
"shareMultipleFiles": {
"title": "Çoklu Dosya Paylaş",
@@ -1921,21 +1921,5 @@
"passwordRequired": "Şifre gerekli",
"nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur"
},
"embedCode": {
"title": "Medyayı Yerleştir",
"description": "Bu medyayı forumlara, web sitelerine veya diğer platformlara yerleştirmek için bu kodları kullanın",
"tabs": {
"directLink": "Doğrudan Bağlantı",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Medya dosyasının doğrudan URL'si",
"htmlDescription": "Medyayı HTML sayfalarına yerleştirmek için bu kodu kullanın",
"bbcodeDescription": "BBCode destekleyen forumlara medyayı yerleştirmek için bu kodu kullanın"
},
"contextMenu": {
"newFolder": "Yeni klasör",
"uploadFile": "Dosya yükle"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,12 @@
{
"auth": {
"errors": {
"account_inactive": "账户未激活。请联系管理员。",
"registration_disabled": "通过SSO注册已被禁用。",
"token_expired": "令牌已过期。请重试。",
"config_error": "配置错误。请联系支持人员。",
"auth_failed": "认证失败。请重试。"
},
"authenticationFailed": "身份验证失败",
"successfullyAuthenticated": "身份验证成功!"
"account_inactive": "Conta inativa. Entre em contato com o administrador.",
"registration_disabled": "Registro via SSO está desabilitado.",
"token_expired": "Token expirado. Tente novamente.",
"config_error": "Erro de configuração. Contate o suporte.",
"auth_failed": "Falha na autenticação. Tente novamente."
}
},
"authProviders": {
"title": "身份验证提供商",
@@ -152,9 +150,7 @@
"move": "移动",
"rename": "重命名",
"search": "搜索",
"share": "分享",
"copied": "已复制",
"copy": "复制"
"share": "分享"
},
"createShare": {
"title": "创建分享",
@@ -176,14 +172,7 @@
"tabs": {
"shareDetails": "分享详情",
"selectFiles": "选择文件"
},
"errors": {
"nameRequired": "分享名称为必填项",
"selectItems": "请至少选择一个文件或文件夹"
},
"itemsSelected": "已选择 {count} 项",
"passwordPlaceholder": "输入密码",
"selectItemsPrompt": "选择要分享的文件和文件夹"
}
},
"customization": {
"breadcrumb": "自定义",
@@ -313,7 +302,6 @@
},
"filePreview": {
"title": "文件预览",
"description": "预览和下载文件",
"loading": "加载中...",
"notAvailable": "此文件类型不支持预览。",
"downloadToView": "使用下载按钮下载文件。",
@@ -349,8 +337,7 @@
"addToShare": "添加到共享",
"removeFromShare": "从共享中移除",
"saveChanges": "保存更改",
"editFolder": "编辑文件夹",
"itemsSelected": "已选择 {count} 项"
"editFolder": "编辑文件夹"
},
"files": {
"title": "所有文件",
@@ -386,12 +373,7 @@
"description": "上传您的第一个文件或创建文件夹以开始使用"
},
"files": "文件",
"folders": "文件夹",
"errors": {
"moveItemsFailed": "移动项目失败,请重试。",
"cannotMoveHere": "无法将项目移动到此位置"
},
"openFolder": "打开文件夹"
"folders": "文件夹"
},
"filesTable": {
"ariaLabel": "文件表格",
@@ -554,10 +536,7 @@
"movingTo": "移动到:",
"title": "移动 {count, plural, =1 {项目} other {项目}}",
"description": "将 {count, plural, =1 {项目} other {项目}} 移动到新位置",
"success": "成功移动了 {count} 个项目",
"errors": {
"moveFailed": "移动项目失败"
}
"success": "成功移动了 {count} 个项目"
},
"navbar": {
"logoAlt": "应用Logo",
@@ -1077,8 +1056,7 @@
"upload": {
"metadata": {
"title": "上传文件 - Palmr",
"description": "通过共享链接上传文件",
"descriptionWithLimit": "上传文件(最多 {limit} 个文件)"
"description": "通过共享链接上传文件"
},
"layout": {
"defaultTitle": "上传文件",
@@ -1171,6 +1149,8 @@
},
"fileActions": {
"edit": "编辑",
"save": "保存",
"cancel": "取消",
"preview": "预览",
"download": "下载",
"delete": "删除",
@@ -1318,10 +1298,6 @@
"passwordAuthEnabled": {
"title": "密码认证",
"description": "启用或禁用基于密码的认证"
},
"hideVersion": {
"title": "隐藏版本",
"description": "在所有页面的页脚中隐藏Palmr版本"
}
},
"buttons": {
@@ -1383,22 +1359,28 @@
"description": "该共享可能已被删除或已过期。"
},
"pageTitle": "共享",
"downloadAll": "下载所有",
"metadata": {
"defaultDescription": "安全共享文件",
"filesShared": "{count, plural, =1 {已共享 1 个文件} other {已共享 # 个文件}}"
}
"downloadAll": "下载所有"
},
"shareActions": {
"deleteTitle": "删除共享",
"deleteConfirmation": "您确定要删除此共享吗?此操作不可撤销。",
"editTitle": "编辑共享",
"nameLabel": "共享名称",
"expirationLabel": "过期日期",
"expirationPlaceholder": "MM/DD/YYYY HH:MM",
"maxViewsLabel": "最大查看次数",
"maxViewsPlaceholder": "留空表示无限",
"passwordProtection": "密码保护",
"passwordLabel": "密码",
"passwordPlaceholder": "请输入密码",
"newPasswordLabel": "新密码(留空表示保持当前密码)",
"newPasswordPlaceholder": "请输入新密码",
"manageFilesTitle": "管理文件",
"manageRecipientsTitle": "管理收件人",
"editSuccess": "共享更新成功",
"editError": "更新共享失败",
"descriptionPlaceholder": "输入描述(可选)",
"descriptionLabel": "描述",
"bulkDeleteConfirmation": "您确定要删除{count, plural, =1 {1个共享} other {#个共享}}吗?此操作无法撤销。",
"bulkDeleteTitle": "删除选中的共享",
"addDescriptionPlaceholder": "添加描述...",
@@ -1411,9 +1393,7 @@
"linkDescriptionFile": "生成自定义链接以分享文件",
"linkDescriptionFolder": "生成自定义链接以分享文件夹",
"linkReady": "您的分享链接已准备好:",
"linkTitle": "生成链接",
"itemsSelected": "已选择 {count} 项",
"manageFilesDescription": "选择要包含在此分享中的文件和文件夹"
"linkTitle": "生成链接"
},
"shareDetails": {
"title": "共享详情",
@@ -1443,6 +1423,7 @@
"generateLink": "生成链接",
"noLink": "尚未生成链接",
"description": "描述",
"linkCopied": "链接已复制到剪贴板",
"editSecurity": "编辑安全",
"editExpiration": "编辑过期",
"clickToEnlargeQrCode": "点击放大QR Code",
@@ -1476,6 +1457,28 @@
"expires": "过期:",
"expirationDate": "过期日期"
},
"shareFile": {
"title": "分享文件",
"linkTitle": "生成链接",
"nameLabel": "分享名称",
"namePlaceholder": "输入分享名称",
"expirationLabel": "过期日期",
"expirationPlaceholder": "MM/DD/YYYY HH:MM",
"maxViewsLabel": "最大查看次数",
"maxViewsPlaceholder": "留空为无限制",
"passwordProtection": "密码保护",
"passwordLabel": "密码",
"passwordPlaceholder": "输入密码",
"linkDescription": "生成自定义链接来分享文件",
"aliasLabel": "链接别名",
"aliasPlaceholder": "输入自定义别名",
"linkReady": "您的分享链接已准备就绪:",
"createShare": "创建分享",
"generateLink": "生成链接",
"copyLink": "复制链接",
"descriptionLabel": "描述",
"descriptionPlaceholder": "输入描述(可选)"
},
"shareManager": {
"deleteSuccess": "共享删除成功",
"deleteError": "共享删除失败",
@@ -1505,10 +1508,7 @@
"noFilesToDownload": "无需下载文件",
"singleShareZipName": "{sharename} _files.zip",
"zipDownloadError": "无法创建zip文件",
"zipDownloadSuccess": "zip文件成功下载了",
"errors": {
"multipleDownloadNotSupported": "暂不支持多个分享下载 - 请分别下载各个分享"
}
"zipDownloadSuccess": "zip文件成功下载了"
},
"shareMultipleFiles": {
"title": "分享多个文件",
@@ -1684,11 +1684,7 @@
"copyToClipboard": "复制到剪贴板",
"savedMessage": "我已保存备用码",
"available": "可用备用码:{count}个",
"instructions": [
"• 将这些代码保存在安全的位置",
"• 每个备用码只能使用一次",
"• 您可以随时生成新的备用码"
]
"instructions": ["• 将这些代码保存在安全的位置", "• 每个备用码只能使用一次", "• 您可以随时生成新的备用码"]
},
"verification": {
"title": "双重认证",
@@ -1921,21 +1917,5 @@
"passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
},
"embedCode": {
"title": "嵌入媒体",
"description": "使用这些代码将此媒体嵌入到论坛、网站或其他平台中",
"tabs": {
"directLink": "直接链接",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "媒体文件的直接URL",
"htmlDescription": "使用此代码将媒体嵌入HTML页面",
"bbcodeDescription": "使用此代码将媒体嵌入支持BBCode的论坛"
},
"contextMenu": {
"newFolder": "新建文件夹",
"uploadFile": "上传文件"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-web",
"version": "3.2.5-beta",
"version": "3.2.2-beta",
"description": "Frontend for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@@ -11,7 +11,7 @@
"react",
"typescript"
],
"license": "Apache-2.0",
"license": "BSD-2-Clause Copyright 2023 Kyantech",
"packageManager": "pnpm@10.6.0",
"scripts": {
"dev": "next dev -p 3000",
@@ -27,8 +27,7 @@
"translations:check": "python3 scripts/run_translations.py check",
"translations:sync": "python3 scripts/run_translations.py sync",
"translations:dry-run": "python3 scripts/run_translations.py all --dry-run",
"translations:help": "python3 scripts/run_translations.py help",
"translations:prune": "python3 scripts/prune_translations.py"
"translations:help": "python3 scripts/run_translations.py help"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
@@ -37,7 +36,6 @@
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
@@ -51,8 +49,6 @@
"@radix-ui/react-tabs": "^1.1.12",
"@tabler/icons-react": "^3.34.0",
"@types/react-dropzone": "^5.1.0",
"@uppy/aws-s3": "^4.3.2",
"@uppy/core": "^4.5.2",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -104,4 +100,4 @@
"tailwindcss": "4.1.11",
"typescript": "5.8.3"
}
}
}

5109
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,157 +0,0 @@
#!/usr/bin/env python3
"""
Prune extra translation keys using en-US.json as the reference.
Removes keys that exist in target language files but not in the reference.
Usage examples:
python3 prune_translations.py --dry-run
python3 prune_translations.py --messages-dir ../messages
python3 prune_translations.py --reference en-US.json
"""
import json
from pathlib import Path
from typing import Dict, Any, Set, List, Tuple
import argparse
def load_json_file(file_path: Path) -> Dict[str, Any]:
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"Error loading {file_path}: {e}")
return {}
def save_json_file(file_path: Path, data: Dict[str, Any], indent: int = 2) -> bool:
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=indent, separators=(',', ': '))
f.write('\n')
return True
except Exception as e:
print(f"Error saving {file_path}: {e}")
return False
def get_all_keys(data: Dict[str, Any], prefix: str = '') -> Set[str]:
keys: Set[str] = set()
for key, value in data.items():
current_key = f"{prefix}.{key}" if prefix else key
keys.add(current_key)
if isinstance(value, dict):
keys.update(get_all_keys(value, current_key))
return keys
def delete_nested_key(data: Dict[str, Any], key_path: str) -> bool:
"""Delete a nested key using a dotted path. Returns True if deleted."""
keys = key_path.split('.')
current: Any = data
for key in keys[:-1]:
if not isinstance(current, dict) or key not in current:
return False
current = current[key]
last = keys[-1]
if isinstance(current, dict) and last in current:
del current[last]
return True
return False
def prune_file(reference: Dict[str, Any], target: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
ref_keys = get_all_keys(reference)
tgt_keys = get_all_keys(target)
extras = sorted(list(tgt_keys - ref_keys))
if not extras:
return target, []
# Work on a copy
pruned = json.loads(json.dumps(target))
for key in extras:
delete_nested_key(pruned, key)
return pruned, extras
def prune_translations(messages_dir: Path, reference_file: str = 'en-US.json', dry_run: bool = False) -> int:
reference_path = messages_dir / reference_file
if not reference_path.exists():
print(f"Reference file not found: {reference_path}")
return 1
print(f"Loading reference: {reference_file}")
ref = load_json_file(reference_path)
if not ref:
print("Error loading reference file")
return 1
files = [p for p in messages_dir.glob('*.json') if p.name != reference_file]
if not files:
print("No translation files found")
return 0
ref_count = len(get_all_keys(ref))
print(f"Reference keys: {ref_count}")
print(f"Processing {len(files)} files...\n")
total_removed = 0
changed_files = 0
for p in sorted(files):
data = load_json_file(p)
if not data:
print(f"{p.name}: ❌ load error")
continue
before = len(get_all_keys(data))
pruned, extras = prune_file(ref, data)
after = len(get_all_keys(pruned))
if not extras:
print(f"{p.name}: ✅ no extras ({before}/{ref_count})")
continue
print(f"{p.name}: 🧹 removing {len(extras)} extra key(s)")
# Optionally show first few extras
for k in extras[:5]:
print(f" - {k}")
if len(extras) > 5:
print(f" ... and {len(extras) - 5} more")
total_removed += len(extras)
changed_files += 1
if not dry_run:
if save_json_file(p, pruned):
print(f" ✅ saved ({after}/{ref_count})")
else:
print(f" ❌ save error")
else:
print(f" 📝 [DRY RUN] not saved")
print()
print("=" * 60)
print("SUMMARY")
print("=" * 60)
print(f"Files changed: {changed_files}")
print(f"Extra keys removed: {total_removed}")
if dry_run:
print("Mode: DRY RUN")
return 0
def main():
parser = argparse.ArgumentParser(description='Prune extra translation keys using reference file')
parser.add_argument('--messages-dir', type=Path, default=Path(__file__).parent.parent / 'messages', help='Messages directory')
parser.add_argument('--reference', default='en-US.json', help='Reference filename')
parser.add_argument('--dry-run', action='store_true', help='Only show what would be removed')
args = parser.parse_args()
if not args.messages_dir.exists():
print(f"Directory not found: {args.messages_dir}")
return 1
return prune_translations(args.messages_dir, args.reference, args.dry_run)
if __name__ == '__main__':
exit(main())

View File

@@ -1,7 +1,8 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { IconCheck, IconFile, IconMail, IconUpload, IconUser, IconX } from "@tabler/icons-react";
import axios from "axios";
import { useTranslations } from "next-intl";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
@@ -12,103 +13,118 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import { useUppyUpload } from "@/hooks/useUppyUpload";
import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints";
import { getSystemInfo } from "@/http/endpoints/app";
import { ChunkedUploader } from "@/utils/chunked-upload";
import { formatFileSize } from "@/utils/format-file-size";
import { UPLOAD_CONFIG } from "../constants";
import { FileUploadSectionProps } from "../types";
import { FILE_STATUS, UPLOAD_CONFIG, UPLOAD_PROGRESS } from "../constants";
import { FileUploadSectionProps, FileWithProgress } from "../types";
export function FileUploadSection({ reverseShare, password, alias, onUploadSuccess }: FileUploadSectionProps) {
const [files, setFiles] = useState<FileWithProgress[]>([]);
const [uploaderName, setUploaderName] = useState("");
const [uploaderEmail, setUploaderEmail] = useState("");
const [description, setDescription] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [isS3Enabled, setIsS3Enabled] = useState<boolean | null>(null);
const t = useTranslations();
const { addFiles, startUpload, removeFile, retryUpload, fileUploads, isUploading } = useUppyUpload({
onValidate: async (file) => {
// Client-side validations
if (reverseShare.maxFileSize && file.size > reverseShare.maxFileSize) {
const error = t("reverseShares.upload.errors.fileTooLarge", {
useEffect(() => {
const fetchSystemInfo = async () => {
try {
const response = await getSystemInfo();
setIsS3Enabled(response.data.s3Enabled);
} catch (error) {
console.warn("Failed to fetch system info, defaulting to filesystem mode:", error);
setIsS3Enabled(false);
}
};
fetchSystemInfo();
}, []);
const validateFileSize = useCallback(
(file: File): string | null => {
if (!reverseShare.maxFileSize) return null;
if (file.size > reverseShare.maxFileSize) {
return t("reverseShares.upload.errors.fileTooLarge", {
maxSize: formatFileSize(reverseShare.maxFileSize),
});
toast.error(error);
throw new Error(error);
}
return null;
},
[reverseShare.maxFileSize, t]
);
if (reverseShare.allowedFileTypes) {
const extension = file.name.split(".").pop()?.toLowerCase();
const allowed = reverseShare.allowedFileTypes.split(",").map((t) => t.trim().toLowerCase());
if (extension && !allowed.includes(extension)) {
const error = t("reverseShares.upload.errors.fileTypeNotAllowed", {
allowedTypes: reverseShare.allowedFileTypes,
});
toast.error(error);
throw new Error(error);
}
const validateFileType = useCallback(
(file: File): string | null => {
if (!reverseShare.allowedFileTypes) return null;
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
const fileExtension = file.name.split(".").pop()?.toLowerCase();
if (fileExtension && !allowedTypes.includes(fileExtension)) {
return t("reverseShares.upload.errors.fileTypeNotAllowed", {
allowedTypes: reverseShare.allowedFileTypes,
});
}
return null;
},
[reverseShare.allowedFileTypes, t]
);
if (reverseShare.maxFiles) {
const totalFiles = fileUploads.length + 1 + reverseShare.currentFileCount;
if (totalFiles > reverseShare.maxFiles) {
const error = t("reverseShares.upload.errors.maxFilesExceeded", {
maxFiles: reverseShare.maxFiles,
});
toast.error(error);
throw new Error(error);
}
}
},
onBeforeUpload: async (file) => {
const timestamp = Date.now();
const sanitizedFileName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
return `reverse-shares/${alias}/${timestamp}-${sanitizedFileName}`;
},
getPresignedUrl: async (objectName) => {
const response = await getPresignedUrlForUploadByAlias(
alias,
{ objectName },
password ? { password } : undefined
);
return { url: response.data.url, method: "PUT" };
},
onAfterUpload: async (fileId, file, objectName) => {
const fileExtension = file.name.split(".").pop() || "";
const validateFileCount = useCallback((): string | null => {
if (!reverseShare.maxFiles) return null;
await registerFileUploadByAlias(
alias,
{
name: file.name,
description: description || undefined,
extension: fileExtension,
size: file.size,
objectName,
uploaderEmail: uploaderEmail || undefined,
uploaderName: uploaderName || undefined,
},
password ? { password } : undefined
);
},
onSuccess: () => {
const successCount = fileUploads.filter((u) => u.status === "success").length;
const totalFiles = files.length + 1 + reverseShare.currentFileCount;
if (totalFiles > reverseShare.maxFiles) {
return t("reverseShares.upload.errors.maxFilesExceeded", {
maxFiles: reverseShare.maxFiles,
});
}
return null;
}, [reverseShare.maxFiles, reverseShare.currentFileCount, files.length, t]);
if (successCount > 0) {
toast.success(
t("reverseShares.upload.success.countMessage", {
count: successCount,
})
);
onUploadSuccess?.();
}
const validateFile = useCallback(
(file: File): string | null => {
return validateFileSize(file) || validateFileType(file) || validateFileCount();
},
[validateFileSize, validateFileType, validateFileCount]
);
const createFileWithProgress = (file: File): FileWithProgress => ({
file,
progress: UPLOAD_PROGRESS.INITIAL,
status: FILE_STATUS.PENDING,
});
const processAcceptedFiles = useCallback(
(acceptedFiles: File[]): FileWithProgress[] => {
const validFiles: FileWithProgress[] = [];
for (const file of acceptedFiles) {
const validationError = validateFile(file);
if (validationError) {
toast.error(validationError);
continue;
}
validFiles.push(createFileWithProgress(file));
}
return validFiles;
},
[validateFile]
);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
addFiles(acceptedFiles);
const newFiles = processAcceptedFiles(acceptedFiles);
setFiles((previousFiles) => [...previousFiles, ...newFiles]);
},
[addFiles]
[processAcceptedFiles]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
@@ -117,8 +133,132 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
disabled: isUploading,
});
const removeFile = (index: number) => {
setFiles((previousFiles) => previousFiles.filter((_, i) => i !== index));
};
const updateFileStatus = (index: number, updates: Partial<FileWithProgress>) => {
setFiles((previousFiles) => previousFiles.map((file, i) => (i === index ? { ...file, ...updates } : file)));
};
const generateObjectName = (fileName: string): string => {
const timestamp = Date.now();
return `reverse-shares/${alias}/${timestamp}-${fileName}`;
};
const getFileExtension = (fileName: string): string => {
return fileName.split(".").pop() || "";
};
const calculateUploadTimeout = (fileSize: number): number => {
const baseTimeout = 300000;
const fileSizeMB = fileSize / (1024 * 1024);
if (fileSizeMB > 500) {
const extraMB = fileSizeMB - 500;
const extraMinutes = Math.ceil(extraMB / 100);
return baseTimeout + extraMinutes * 60000;
}
return baseTimeout;
};
const uploadFileToStorage = async (
file: File,
presignedUrl: string,
onProgress?: (progress: number) => void
): Promise<void> => {
const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size, isS3Enabled ?? undefined);
if (shouldUseChunked) {
const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
const result = await ChunkedUploader.uploadFile({
file,
url: presignedUrl,
chunkSize,
isS3Enabled: isS3Enabled ?? undefined,
onProgress,
});
if (!result.success) {
throw new Error(result.error || "Chunked upload failed");
}
} else {
const uploadTimeout = calculateUploadTimeout(file.size);
await axios.put(presignedUrl, file, {
headers: {
"Content-Type": file.type,
},
timeout: uploadTimeout,
maxContentLength: Infinity,
maxBodyLength: Infinity,
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = (progressEvent.loaded / progressEvent.total) * 100;
onProgress(Math.round(progress));
}
},
});
}
};
const registerUploadedFile = async (file: File, objectName: string): Promise<void> => {
const fileExtension = getFileExtension(file.name);
await registerFileUploadByAlias(
alias,
{
name: file.name,
description: description || undefined,
extension: fileExtension,
size: file.size,
objectName,
uploaderEmail: uploaderEmail || undefined,
uploaderName: uploaderName || undefined,
},
password ? { password } : undefined
);
};
const uploadFile = async (fileWithProgress: FileWithProgress, index: number): Promise<void> => {
const { file } = fileWithProgress;
try {
updateFileStatus(index, {
status: FILE_STATUS.UPLOADING,
progress: UPLOAD_PROGRESS.INITIAL,
});
const objectName = generateObjectName(file.name);
const presignedResponse = await getPresignedUrlForUploadByAlias(
alias,
{ objectName },
password ? { password } : undefined
);
await uploadFileToStorage(file, presignedResponse.data.url, (progress) => {
updateFileStatus(index, { progress });
});
updateFileStatus(index, { progress: UPLOAD_PROGRESS.COMPLETE });
await registerUploadedFile(file, objectName);
updateFileStatus(index, { status: FILE_STATUS.SUCCESS });
} catch (error: any) {
const errorMessage = error.response?.data?.error || t("reverseShares.upload.errors.uploadFailed");
updateFileStatus(index, {
status: FILE_STATUS.ERROR,
error: errorMessage,
});
toast.error(errorMessage);
}
};
const validateUploadRequirements = (): boolean => {
if (fileUploads.length === 0) {
if (files.length === 0) {
toast.error(t("reverseShares.upload.errors.selectAtLeastOneFile"));
return false;
}
@@ -139,13 +279,36 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
return true;
};
const processAllUploads = async (): Promise<void> => {
const uploadPromises = files.map((fileWithProgress, index) => uploadFile(fileWithProgress, index));
await Promise.all(uploadPromises);
const successfulUploads = files.filter((file) => file.status === FILE_STATUS.SUCCESS);
if (successfulUploads.length > 0) {
toast.success(
t("reverseShares.upload.success.countMessage", {
count: successfulUploads.length,
})
);
}
};
const handleUpload = async () => {
if (!validateUploadRequirements()) return;
startUpload();
setIsUploading(true);
try {
await processAllUploads();
} catch {
} finally {
setIsUploading(false);
}
};
const getCanUpload = (): boolean => {
if (fileUploads.length === 0 || isUploading) return false;
if (files.length === 0 || isUploading) return false;
const nameRequired = reverseShare.nameFieldRequired === "REQUIRED";
const emailRequired = reverseShare.emailFieldRequired === "REQUIRED";
@@ -162,8 +325,16 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
};
const canUpload = getCanUpload();
const allFilesProcessed = fileUploads.every((file) => file.status === "success" || file.status === "error");
const hasSuccessfulUploads = fileUploads.some((file) => file.status === "success");
const allFilesProcessed = files.every(
(file) => file.status === FILE_STATUS.SUCCESS || file.status === FILE_STATUS.ERROR
);
const hasSuccessfulUploads = files.some((file) => file.status === FILE_STATUS.SUCCESS);
useEffect(() => {
if (allFilesProcessed && hasSuccessfulUploads && files.length > 0) {
onUploadSuccess?.();
}
}, [allFilesProcessed, hasSuccessfulUploads, files.length, onUploadSuccess]);
const getDragActiveStyles = () => {
if (isDragActive) {
@@ -183,7 +354,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
const renderFileRestrictions = () => {
const calculateRemainingFiles = (): number => {
if (!reverseShare.maxFiles) return 0;
const currentTotal = reverseShare.currentFileCount + fileUploads.length;
const currentTotal = reverseShare.currentFileCount + files.length;
const remaining = reverseShare.maxFiles - currentTotal;
return Math.max(0, remaining);
};
@@ -216,8 +387,8 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
);
};
const renderFileStatusBadge = (fileStatus: string) => {
if (fileStatus === "success") {
const renderFileStatusBadge = (fileWithProgress: FileWithProgress) => {
if (fileWithProgress.status === FILE_STATUS.SUCCESS) {
return (
<Badge variant="default" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<IconCheck className="h-3 w-3 mr-1" />
@@ -226,41 +397,51 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
);
}
if (fileStatus === "error") {
if (fileWithProgress.status === FILE_STATUS.ERROR) {
return <Badge variant="destructive">{t("reverseShares.upload.fileList.statusError")}</Badge>;
}
return null;
};
const renderFileItem = (upload: any) => (
<div key={upload.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
const renderFileItem = (fileWithProgress: FileWithProgress, index: number) => (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<IconFile className="h-5 w-5 text-gray-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{upload.file.name}</p>
<p className="text-xs text-gray-500">{formatFileSize(upload.file.size)}</p>
{upload.status === "uploading" && <Progress value={upload.progress} className="mt-2 h-2" />}
{upload.status === "error" && upload.error && <p className="text-xs text-red-500 mt-1">{upload.error}</p>}
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{fileWithProgress.file.name}</p>
<p className="text-xs text-gray-500">{formatFileSize(fileWithProgress.file.size)}</p>
{fileWithProgress.status === FILE_STATUS.UPLOADING && (
<Progress value={fileWithProgress.progress} className="mt-2 h-2" />
)}
{fileWithProgress.status === FILE_STATUS.ERROR && (
<p className="text-xs text-red-500 mt-1">{fileWithProgress.error}</p>
)}
</div>
<div className="flex items-center gap-2">
{renderFileStatusBadge(upload.status)}
{upload.status === "pending" && (
<Button size="sm" variant="ghost" onClick={() => removeFile(upload.id)} disabled={isUploading}>
{renderFileStatusBadge(fileWithProgress)}
{fileWithProgress.status === FILE_STATUS.PENDING && (
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
<IconX className="h-4 w-4" />
</Button>
)}
{upload.status === "error" && (
{fileWithProgress.status === FILE_STATUS.ERROR && (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => retryUpload(upload.id)}
onClick={() => {
setFiles((prev) =>
prev.map((file, i) =>
i === index ? { ...file, status: FILE_STATUS.PENDING, error: undefined } : file
)
);
}}
disabled={isUploading}
title={t("reverseShares.upload.errors.retry")}
>
<IconUpload className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => removeFile(upload.id)} disabled={isUploading}>
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
<IconX className="h-4 w-4" />
</Button>
</div>
@@ -282,10 +463,10 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
{renderFileRestrictions()}
</div>
{fileUploads.length > 0 && (
{files.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-gray-900 dark:text-white">{t("reverseShares.upload.fileList.title")}</h4>
{fileUploads.map(renderFileItem)}
{files.map(renderFileItem)}
</div>
)}
@@ -347,7 +528,7 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
<Button onClick={handleUpload} disabled={!canUpload} className="w-full text-white" size="lg" variant="default">
{isUploading
? t("reverseShares.upload.form.uploading")
: t("reverseShares.upload.form.uploadButton", { count: fileUploads.length })}
: t("reverseShares.upload.form.uploadButton", { count: files.length })}
</Button>
{allFilesProcessed && hasSuccessfulUploads && (

View File

@@ -1,18 +1,12 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useSecureConfigValue } from "@/hooks/use-secure-configs";
import packageJson from "../../../../../../package.json";
const { version } = packageJson;
export function TransparentFooter() {
const t = useTranslations();
const { value: hideVersion } = useSecureConfigValue("hideVersion");
const shouldHideVersion = hideVersion === "true";
return (
<footer className="absolute bottom-0 left-0 right-0 z-50 w-full flex items-center justify-center py-3 h-16 pointer-events-none">
@@ -28,7 +22,7 @@ export function TransparentFooter() {
Kyantech Solutions
</p>
</Link>
{!shouldHideVersion && <span className="text-white text-[11px] mt-1">v{version}</span>}
<span className="text-white text-[11px] mt-1">v{version}</span>
</div>
</footer>
);

View File

@@ -1,91 +1,12 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
async function getReverseShareMetadata(alias: string) {
try {
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
const response = await fetch(`${API_BASE_URL}/reverse-shares/alias/${alias}/metadata`, {
cache: "no-store",
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Error fetching reverse share metadata:", error);
return null;
}
}
async function getAppInfo() {
try {
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
const response = await fetch(`${API_BASE_URL}/app/info`, {
cache: "no-store",
});
if (!response.ok) {
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
}
return await response.json();
} catch (error) {
console.error("Error fetching app info:", error);
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
}
}
async function getBaseUrl(): Promise<string> {
const headersList = await headers();
const protocol = headersList.get("x-forwarded-proto") || "http";
const host = headersList.get("x-forwarded-host") || headersList.get("host") || "localhost:3000";
return `${protocol}://${host}`;
}
export async function generateMetadata({ params }: { params: { alias: string } }): Promise<Metadata> {
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
const metadata = await getReverseShareMetadata(params.alias);
const appInfo = await getAppInfo();
const title = metadata?.name || t("reverseShares.upload.metadata.title");
const description =
metadata?.description ||
(metadata?.maxFiles
? t("reverseShares.upload.metadata.descriptionWithLimit", { limit: metadata.maxFiles })
: t("reverseShares.upload.metadata.description"));
const baseUrl = await getBaseUrl();
const shareUrl = `${baseUrl}/r/${params.alias}`;
return {
title,
description,
openGraph: {
title,
description,
url: shareUrl,
siteName: appInfo.appName || "Palmr",
type: "website",
images: appInfo.appLogo
? [
{
url: appInfo.appLogo,
width: 1200,
height: 630,
alt: appInfo.appName || "Palmr",
},
]
: [],
},
twitter: {
card: "summary_large_image",
title,
description,
images: appInfo.appLogo ? [appInfo.appLogo] : [],
},
title: t("reverseShares.upload.metadata.title"),
description: t("reverseShares.upload.metadata.description"),
};
}

View File

@@ -41,10 +41,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import {
copyReverseShareFileToUserFiles,
deleteReverseShareFile,
downloadReverseShareFile,
updateReverseShareFile,
} from "@/http/endpoints/reverse-shares";
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
import { bulkDownloadWithQueue, downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
import { getFileIcon } from "@/utils/file-icons";
import { truncateFileName } from "@/utils/file-utils";
import { ReverseShare } from "../hooks/use-reverse-shares";
@@ -471,21 +471,13 @@ export function ReceivedFilesModal({
const handleDownload = async (file: ReverseShareFile) => {
try {
const loadingToast = toast.loading(t("reverseShares.modals.receivedFiles.downloading") || "Downloading...");
const response = await downloadReverseShareFile(file.id);
const link = document.createElement("a");
link.href = response.data.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.dismiss(loadingToast);
toast.success(t("reverseShares.modals.receivedFiles.downloadSuccess"));
await downloadReverseShareWithQueue(file.id, file.name, {
onComplete: () => toast.success(t("reverseShares.modals.receivedFiles.downloadSuccess")),
onFail: () => toast.error(t("reverseShares.modals.receivedFiles.downloadError")),
});
} catch (error) {
console.error("Download error:", error);
toast.error(t("reverseShares.modals.receivedFiles.downloadError"));
// Error already handled in downloadReverseShareWithQueue
}
};
@@ -608,28 +600,25 @@ export function ReceivedFilesModal({
if (selectedFileObjects.length === 0) return;
try {
const loadingToast = toast.loading(t("shareManager.creatingZip"));
const zipName = `${reverseShare.name || t("reverseShares.defaultLinkName")}_files.zip`;
try {
// Download files individually
for (const file of selectedFileObjects) {
const response = await downloadReverseShareFile(file.id);
const link = document.createElement("a");
link.href = response.data.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.promise(
bulkDownloadWithQueue(
selectedFileObjects.map((file) => ({
name: file.name,
id: file.id,
isReverseShare: true,
})),
zipName
).then(() => {
setSelectedFiles(new Set());
}),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
}
toast.dismiss(loadingToast);
toast.success(t("shareManager.zipDownloadSuccess"));
setSelectedFiles(new Set());
} catch (error) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.zipDownloadError"));
throw error;
}
);
} catch (error) {
console.error("Error creating ZIP:", error);
}
@@ -911,7 +900,16 @@ export function ReceivedFilesModal({
</Dialog>
{previewFile && (
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
<ReverseShareFilePreviewModal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
file={{
id: previewFile.id,
name: previewFile.name,
objectName: previewFile.objectName,
extension: previewFile.extension,
}}
/>
)}
</>
);

View File

@@ -6,11 +6,24 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { deleteReverseShareFile, downloadReverseShareFile } from "@/http/endpoints/reverse-shares";
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
import { deleteReverseShareFile } from "@/http/endpoints/reverse-shares";
import { downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
import { getFileIcon } from "@/utils/file-icons";
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
interface ReverseShareFile {
id: string;
name: string;
description: string | null;
extension: string;
size: string;
objectName: string;
uploaderEmail: string | null;
uploaderName: string | null;
createdAt: string;
updatedAt: string;
}
interface ReceivedFilesSectionProps {
files: ReverseShareFile[];
onFileDeleted?: () => void;
@@ -55,21 +68,13 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
const handleDownload = async (file: ReverseShareFile) => {
try {
const loadingToast = toast.loading(t("reverseShares.modals.details.downloading") || "Downloading...");
const response = await downloadReverseShareFile(file.id);
const link = document.createElement("a");
link.href = response.data.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.dismiss(loadingToast);
toast.success(t("reverseShares.modals.details.downloadSuccess"));
await downloadReverseShareWithQueue(file.id, file.name, {
onComplete: () => toast.success(t("reverseShares.modals.details.downloadSuccess")),
onFail: () => toast.error(t("reverseShares.modals.details.downloadError")),
});
} catch (error) {
console.error("Download error:", error);
toast.error(t("reverseShares.modals.details.downloadError"));
// Error already handled in downloadReverseShareWithQueue
}
};
@@ -154,7 +159,16 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
</div>
{previewFile && (
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
<ReverseShareFilePreviewModal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
file={{
id: previewFile.id,
name: previewFile.name,
objectName: previewFile.objectName,
extension: previewFile.extension,
}}
/>
)}
</>
);

View File

@@ -1,20 +1,26 @@
"use client";
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
interface ReverseShareFilePreviewModalProps {
isOpen: boolean;
onClose: () => void;
file: ReverseShareFile | null;
file: {
id: string;
name: string;
objectName: string;
extension?: string;
} | null;
}
export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) {
if (!file) return null;
const adaptedFile = {
...file,
description: file.description ?? undefined,
name: file.name,
objectName: file.objectName,
type: file.extension,
id: file.id,
};
return <FilePreviewModal isOpen={isOpen} onClose={onClose} file={adaptedFile} isReverseShare={true} />;

View File

@@ -27,13 +27,13 @@ export function ReverseSharesSearch({
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">{t("reverseShares.search.title")}</h2>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={onRefresh} disabled={isRefreshing}>
<IconRefresh className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
<Button onClick={onCreateReverseShare} className="w-full sm:w-auto">
<Button onClick={onCreateReverseShare}>
<IconPlus className="h-4 w-4" />
{t("reverseShares.search.createButton")}
</Button>

View File

@@ -152,10 +152,10 @@ export function ShareFilesTable({
variant="ghost"
className="h-8 w-8 hover:bg-muted"
onClick={() => handleFolderClick(item.id)}
title={t("files.openFolder")}
title="Open folder"
>
<IconFolder className="h-4 w-4" />
<span className="sr-only">{t("files.openFolder")}</span>
<span className="sr-only">Open folder</span>
</Button>
)}
<Button

View File

@@ -91,13 +91,13 @@ export function ShareDetails({
<CardContent>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IconShare className="w-6 h-6 text-muted-foreground" />
<h1 className="text-2xl font-semibold">{share.name || t("share.details.untitled")}</h1>
</div>
{shareHasItems && hasMultipleFiles && (
<Button onClick={onBulkDownload} className="flex items-center gap-2 w-full sm:w-auto">
<Button onClick={onBulkDownload} className="flex items-center gap-2">
<IconDownload className="w-4 h-4" />
{t("share.downloadAll")}
</Button>

View File

@@ -7,7 +7,11 @@ import { toast } from "sonner";
import { getShareByAlias } from "@/http/endpoints/index";
import type { Share } from "@/http/endpoints/shares/types";
import { getCachedDownloadUrl } from "@/lib/download-url-cache";
import {
bulkDownloadShareWithQueue,
downloadFileWithQueue,
downloadShareFolderWithQueue,
} from "@/utils/download-queue-utils";
const createSlug = (name: string): string => {
return name
@@ -225,65 +229,11 @@ export function usePublicShare() {
throw new Error("Share data not available");
}
// Get all files in this folder and subfolders with their paths
const getFolderFilesWithPath = (
targetFolderId: string,
currentPath: string = ""
): Array<{ file: any; path: string }> => {
const filesWithPath: Array<{ file: any; path: string }> = [];
// Get direct files in this folder
const directFiles = share.files?.filter((f) => f.folderId === targetFolderId) || [];
directFiles.forEach((file) => {
filesWithPath.push({ file, path: currentPath });
});
// Get subfolders and process them recursively
const subfolders = share.folders?.filter((f) => f.parentId === targetFolderId) || [];
for (const subfolder of subfolders) {
const subfolderPath = currentPath ? `${currentPath}/${subfolder.name}` : subfolder.name;
filesWithPath.push(...getFolderFilesWithPath(subfolder.id, subfolderPath));
}
return filesWithPath;
};
const folderFilesWithPath = getFolderFilesWithPath(folderId);
if (folderFilesWithPath.length === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
const loadingToast = toast.loading(t("shareManager.creatingZip"));
try {
// Get presigned URLs for all files with their relative paths
const downloadItems = await Promise.all(
folderFilesWithPath.map(async ({ file, path }) => {
const url = await getCachedDownloadUrl(
file.objectName,
password ? { headers: { "x-share-password": password } } : undefined
);
return {
url,
name: path ? `${path}/${file.name}` : file.name,
};
})
);
// Create ZIP with all files
const { downloadFilesAsZip } = await import("@/utils/zip-download");
const zipName = `${folderName}.zip`;
await downloadFilesAsZip(downloadItems, zipName);
toast.dismiss(loadingToast);
toast.success(t("shareManager.zipDownloadSuccess"));
} catch (error) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.zipDownloadError"));
throw error;
}
await downloadShareFolderWithQueue(folderId, folderName, share.files || [], share.folders || [], {
silent: true,
showToasts: false,
sharePassword: password,
});
} catch (error) {
console.error("Error downloading folder:", error);
throw error;
@@ -294,30 +244,26 @@ export function usePublicShare() {
try {
if (objectName.startsWith("folder:")) {
const folderId = objectName.replace("folder:", "");
await handleFolderDownload(folderId, fileName);
return;
await toast.promise(handleFolderDownload(folderId, fileName), {
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("share.errors.downloadFailed"),
});
} else {
await toast.promise(
downloadFileWithQueue(objectName, fileName, {
silent: true,
showToasts: false,
sharePassword: password,
}),
{
loading: t("share.messages.downloadStarted"),
success: t("shareManager.downloadSuccess"),
error: t("share.errors.downloadFailed"),
}
);
}
const loadingToast = toast.loading(t("share.messages.downloadStarted"));
const url = await getCachedDownloadUrl(
objectName,
password ? { headers: { "x-share-password": password } } : undefined
);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.dismiss(loadingToast);
toast.success(t("shareManager.downloadSuccess"));
} catch (error) {
console.error("Error downloading file:", error);
toast.error(t("share.errors.downloadFailed"));
}
} catch {}
};
const handleBulkDownload = async () => {
@@ -335,6 +281,8 @@ export function usePublicShare() {
}
try {
const zipName = `${share.name || t("shareManager.defaultShareName")}.zip`;
// Prepare all items for the share-specific bulk download
const allItems: Array<{
objectName?: string;
@@ -373,43 +321,22 @@ export function usePublicShare() {
return;
}
const loadingToast = toast.loading(t("shareManager.creatingZip"));
try {
// Get presigned URLs for all files
const downloadItems = await Promise.all(
allItems
.filter((item) => item.type === "file" && item.objectName)
.map(async (item) => {
const url = await getCachedDownloadUrl(
item.objectName!,
password ? { headers: { "x-share-password": password } } : undefined
);
return {
url,
name: item.name,
};
})
);
if (downloadItems.length === 0) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.noFilesToDownload"));
return;
toast.promise(
bulkDownloadShareWithQueue(
allItems,
share.files || [],
share.folders || [],
zipName,
undefined,
true,
password
).then(() => {}),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
}
// Create ZIP with all files
const { downloadFilesAsZip } = await import("@/utils/zip-download");
const zipName = `${share.name || t("shareManager.defaultShareName")}.zip`;
await downloadFilesAsZip(downloadItems, zipName);
toast.dismiss(loadingToast);
toast.success(t("shareManager.zipDownloadSuccess"));
} catch (error) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.zipDownloadError"));
throw error;
}
);
} catch (error) {
console.error("Error creating ZIP:", error);
}
@@ -427,86 +354,62 @@ export function usePublicShare() {
}
try {
const loadingToast = toast.loading(t("shareManager.creatingZip"));
// Get all file IDs that belong to selected folders
const filesInSelectedFolders = new Set<string>();
for (const folder of folders) {
const folderFiles = share.files?.filter((f) => f.folderId === folder.id) || [];
folderFiles.forEach((f) => filesInSelectedFolders.add(f.id));
try {
// Helper function to get all files in a folder recursively with paths
const getFolderFilesWithPath = (
targetFolderId: string,
currentPath: string = ""
): Array<{ file: any; path: string }> => {
const filesWithPath: Array<{ file: any; path: string }> = [];
// Get direct files in this folder
const directFiles = share.files?.filter((f) => f.folderId === targetFolderId) || [];
directFiles.forEach((file) => {
filesWithPath.push({ file, path: currentPath });
});
// Get subfolders and process them recursively
const subfolders = share.folders?.filter((f) => f.parentId === targetFolderId) || [];
for (const subfolder of subfolders) {
const subfolderPath = currentPath ? `${currentPath}/${subfolder.name}` : subfolder.name;
filesWithPath.push(...getFolderFilesWithPath(subfolder.id, subfolderPath));
// Also check nested folders recursively
const checkNestedFolders = (parentId: string) => {
const nestedFolders = share.folders?.filter((f) => f.parentId === parentId) || [];
for (const nestedFolder of nestedFolders) {
const nestedFiles = share.files?.filter((f) => f.folderId === nestedFolder.id) || [];
nestedFiles.forEach((f) => filesInSelectedFolders.add(f.id));
checkNestedFolders(nestedFolder.id);
}
return filesWithPath;
};
const allFilesToDownload: Array<{ url: string; name: string }> = [];
// Get presigned URLs for direct files (not in folders)
const directFileItems = await Promise.all(
files.map(async (file) => {
const url = await getCachedDownloadUrl(
file.objectName,
password ? { headers: { "x-share-password": password } } : undefined
);
return {
url,
name: file.name,
};
})
);
allFilesToDownload.push(...directFileItems);
// Get presigned URLs for files in selected folders
for (const folder of folders) {
const folderFilesWithPath = getFolderFilesWithPath(folder.id, folder.name);
const folderFileItems = await Promise.all(
folderFilesWithPath.map(async ({ file, path }) => {
const url = await getCachedDownloadUrl(
file.objectName,
password ? { headers: { "x-share-password": password } } : undefined
);
return {
url,
name: path ? `${path}/${file.name}` : file.name,
};
})
);
allFilesToDownload.push(...folderFileItems);
}
if (allFilesToDownload.length === 0) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.noFilesToDownload"));
return;
}
// Create ZIP with all files
const { downloadFilesAsZip } = await import("@/utils/zip-download");
const finalZipName = `${share.name || t("shareManager.defaultShareName")}-selected.zip`;
await downloadFilesAsZip(allFilesToDownload, finalZipName);
toast.dismiss(loadingToast);
toast.success(t("shareManager.zipDownloadSuccess"));
} catch (error) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.zipDownloadError"));
throw error;
checkNestedFolders(folder.id);
}
const allItems = [
...files
.filter((file) => !filesInSelectedFolders.has(file.id))
.map((file) => ({
objectName: file.objectName,
name: file.name,
type: "file" as const,
})),
// Add only top-level folders (avoid duplicating nested folders)
...folders
.filter((folder) => {
return !folder.parentId || !folders.some((f) => f.id === folder.parentId);
})
.map((folder) => ({
id: folder.id,
name: folder.name,
type: "folder" as const,
})),
];
const zipName = `${share.name || t("shareManager.defaultShareName")}-selected.zip`;
toast.promise(
bulkDownloadShareWithQueue(
allItems,
share.files || [],
share.folders || [],
zipName,
undefined,
false,
password
).then(() => {}),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
}
);
} catch (error) {
console.error("Error creating ZIP:", error);
toast.error(t("shareManager.zipDownloadError"));

View File

@@ -1,96 +1,15 @@
import { Metadata } from "next";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
interface LayoutProps {
children: React.ReactNode;
params: { alias: string };
}
async function getShareMetadata(alias: string) {
try {
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
const response = await fetch(`${API_BASE_URL}/shares/alias/${alias}/metadata`, {
cache: "no-store",
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Error fetching share metadata:", error);
return null;
}
}
async function getAppInfo() {
try {
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
const response = await fetch(`${API_BASE_URL}/app/info`, {
cache: "no-store",
});
if (!response.ok) {
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
}
return await response.json();
} catch (error) {
console.error("Error fetching app info:", error);
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
}
}
async function getBaseUrl(): Promise<string> {
const headersList = await headers();
const protocol = headersList.get("x-forwarded-proto") || "http";
const host = headersList.get("x-forwarded-host") || headersList.get("host") || "localhost:3000";
return `${protocol}://${host}`;
}
export async function generateMetadata({ params }: { params: { alias: string } }): Promise<Metadata> {
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
const metadata = await getShareMetadata(params.alias);
const appInfo = await getAppInfo();
const title = metadata?.name || t("share.pageTitle");
const description =
metadata?.description ||
(metadata?.totalFiles
? t("share.metadata.filesShared", { count: metadata.totalFiles + (metadata.totalFolders || 0) })
: appInfo.appDescription || t("share.metadata.defaultDescription"));
const baseUrl = await getBaseUrl();
const shareUrl = `${baseUrl}/s/${params.alias}`;
return {
title,
description,
openGraph: {
title,
description,
url: shareUrl,
siteName: appInfo.appName || "Palmr",
type: "website",
images: appInfo.appLogo
? [
{
url: appInfo.appLogo,
width: 1200,
height: 630,
alt: appInfo.appName || "Palmr",
},
]
: [],
},
twitter: {
card: "summary_large_image",
title,
description,
images: appInfo.appLogo ? [appInfo.appLogo] : [],
},
title: `${t("share.pageTitle")}`,
};
}

View File

@@ -16,9 +16,9 @@ export function SharesSearch({
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">{t("shares.search.title")}</h2>
<Button onClick={onCreateShare} className="w-full sm:w-auto">
<Button onClick={onCreateShare}>
<IconPlus className="h-4 w-4" />
{t("shares.search.createButton")}
</Button>

View File

@@ -1,38 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const searchParams = req.nextUrl.searchParams;
const objectName = searchParams.get("objectName");
if (!objectName) {
return new NextResponse(JSON.stringify({ error: "objectName parameter is required" }), {
status: 400,
headers: {
"Content-Type": "application/json",
},
});
}
// Forward all query params to backend
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files/download-url?${queryString}`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
cookie: cookieHeader || "",
},
});
const data = await apiRes.json();
return new NextResponse(JSON.stringify(data), {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { detectMimeTypeWithFallback } from "@/utils/mime-types";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ objectPath: string[] }> }) {
const { objectPath } = await params;
const cookieHeader = req.headers.get("cookie");
const objectName = objectPath.join("/");
const searchParams = req.nextUrl.searchParams;
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files/${encodeURIComponent(objectName)}/download${queryString ? `?${queryString}` : ""}`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
cookie: cookieHeader || "",
},
redirect: "manual",
});
if (!apiRes.ok) {
const resBody = await apiRes.text();
return new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}
const serverContentType = apiRes.headers.get("Content-Type");
const contentDisposition = apiRes.headers.get("Content-Disposition");
const contentLength = apiRes.headers.get("Content-Length");
const acceptRanges = apiRes.headers.get("Accept-Ranges");
const contentRange = apiRes.headers.get("Content-Range");
const contentType = detectMimeTypeWithFallback(serverContentType, contentDisposition, objectName);
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
...(contentLength && { "Content-Length": contentLength }),
...(acceptRanges && { "Accept-Ranges": acceptRanges }),
...(contentRange && { "Content-Range": contentRange }),
...(contentDisposition && { "Content-Disposition": contentDisposition }),
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -1,71 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files/download${queryString ? `?${queryString}` : ""}`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
cookie: req.headers.get("cookie") || "",
...Object.fromEntries(
Array.from(req.headers.entries()).filter(
([key]) =>
key.startsWith("authorization") ||
key.startsWith("x-forwarded") ||
key === "user-agent" ||
key === "accept"
)
),
},
redirect: "manual",
});
if (!apiRes.ok) {
const errorText = await apiRes.text();
return new NextResponse(errorText, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}
// Stream the file content
const contentType = apiRes.headers.get("content-type") || "application/octet-stream";
const contentDisposition = apiRes.headers.get("content-disposition");
const contentLength = apiRes.headers.get("content-length");
const cacheControl = apiRes.headers.get("cache-control");
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
},
});
if (contentDisposition) {
res.headers.set("Content-Disposition", contentDisposition);
}
if (contentLength) {
res.headers.set("Content-Length", contentLength);
}
if (cacheControl) {
res.headers.set("Cache-Control", cacheControl);
}
return res;
} catch (error) {
console.error("Error proxying download request:", error);
return new NextResponse(JSON.stringify({ error: "Failed to download file" }), {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
}

View File

@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const body = await req.text();
const url = `${API_BASE_URL}/files/multipart/abort`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body,
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const body = await req.text();
const url = `${API_BASE_URL}/files/multipart/complete`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body,
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -1,81 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
/**
* Upload proxy for internal storage system
*
* This proxy is ONLY used when ENABLE_S3=false (internal storage mode).
* External S3 uploads use presigned URLs directly from the client.
*
* Why we need this proxy:
* 1. Security: Internal storage is not exposed to the internet
* 2. Simplicity: No need to configure CORS on storage system
* 3. Compatibility: Works in any network setup
*
* Performance note: Node.js streams the upload efficiently with minimal memory overhead
*/
async function handleUpload(req: NextRequest, method: "POST" | "PUT") {
try {
const { searchParams } = new URL(req.url);
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files/upload${queryString ? `?${queryString}` : ""}`;
const body = req.body;
const apiRes = await fetch(url, {
method: method,
headers: {
"Content-Type": req.headers.get("content-type") || "application/octet-stream",
cookie: req.headers.get("cookie") || "",
...Object.fromEntries(
Array.from(req.headers.entries()).filter(
([key]) =>
key.startsWith("authorization") ||
key.startsWith("x-forwarded") ||
key === "user-agent" ||
key === "accept"
)
),
},
body: body,
// Required for streaming request bodies in Node.js 18+ / Next.js 15
// See: https://nodejs.org/docs/latest-v18.x/api/fetch.html#request-duplex
// @ts-expect-error - duplex not yet in TypeScript types but required at runtime
duplex: "half",
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
console.error("Error proxying upload request:", error);
return new NextResponse(JSON.stringify({ error: "Failed to upload file" }), {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
}
export async function POST(req: NextRequest) {
return handleUpload(req, "POST");
}
export async function PUT(req: NextRequest) {
return handleUpload(req, "PUT");
}

View File

@@ -2,27 +2,25 @@ import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ fileId: string }> }) {
const { fileId } = await params;
const cookieHeader = req.headers.get("cookie");
const body = await req.text();
const url = `${API_BASE_URL}/files/multipart/create`;
const url = `${API_BASE_URL}/filesystem/cancel-upload/${fileId}`;
const apiRes = await fetch(url, {
method: "POST",
method: "DELETE",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body,
redirect: "manual",
});
const contentType = apiRes.headers.get("Content-Type") || "application/json";
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
"Content-Type": contentType,
},
});

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ downloadId: string }> }) {
const { downloadId } = await params;
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/filesystem/download-queue/${downloadId}`;
try {
const apiRes = await fetch(url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
console.error("Error proxying cancel download request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function DELETE(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/filesystem/download-queue`;
try {
const apiRes = await fetch(url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
console.error("Error proxying clear download queue request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/filesystem/download-queue/status`;
try {
const apiRes = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
console.error("Error proxying download queue status request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { detectMimeTypeWithFallback } from "@/utils/mime-types";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/filesystem/download/${token}`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
cookie: cookieHeader || "",
},
});
const serverContentType = apiRes.headers.get("Content-Type");
const contentDisposition = apiRes.headers.get("Content-Disposition");
const contentLength = apiRes.headers.get("Content-Length");
const contentType = detectMimeTypeWithFallback(serverContentType, contentDisposition);
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
...(contentDisposition && { "Content-Disposition": contentDisposition }),
...(contentLength && { "Content-Length": contentLength }),
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@@ -2,25 +2,25 @@ import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest) {
export async function GET(req: NextRequest, { params }: { params: Promise<{ fileId: string }> }) {
const { fileId } = await params;
const cookieHeader = req.headers.get("cookie");
const searchParams = req.nextUrl.searchParams.toString();
const url = `${API_BASE_URL}/files/multipart/part-url${searchParams ? `?${searchParams}` : ""}`;
const url = `${API_BASE_URL}/filesystem/upload-progress/${fileId}`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
cookie: cookieHeader || "",
},
redirect: "manual",
});
const contentType = apiRes.headers.get("Content-Type") || "application/json";
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
"Content-Type": contentType,
},
});

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
export const maxDuration = 120000; // 2 minutes to handle large files
export const dynamic = "force-dynamic";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function PUT(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/filesystem/upload/${token}`;
const headers: Record<string, string> = {
cookie: cookieHeader || "",
"Content-Type": req.headers.get("Content-Type") || "application/octet-stream",
"Content-Length": req.headers.get("Content-Length") || "0",
};
req.headers.forEach((value, key) => {
if (key.startsWith("x-") || key.startsWith("X-")) {
headers[key] = value;
}
});
try {
const apiRes = await fetch(url, {
method: "PUT",
headers,
body: req.body,
duplex: "half",
} as RequestInit);
const contentType = apiRes.headers.get("Content-Type") || "application/json";
let resBody;
if (contentType.includes("application/json")) {
resBody = await apiRes.text();
} else {
resBody = await apiRes.arrayBuffer();
}
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
return new NextResponse(
JSON.stringify({
error: "Proxy request failed",
details: error instanceof Error ? error.message : "Unknown error",
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
},
}
);
}
}

View File

@@ -2,7 +2,6 @@
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useAuth } from "@/contexts/auth-context";
@@ -12,7 +11,6 @@ export default function AuthCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser, setIsAuthenticated, setIsAdmin } = useAuth();
const t = useTranslations();
useEffect(() => {
const token = searchParams.get("token");
@@ -61,14 +59,14 @@ export default function AuthCallbackPage() {
setUser(userData);
setIsAdmin(isAdmin);
setIsAuthenticated(true);
toast.success(t("auth.successfullyAuthenticated"));
toast.success("Successfully authenticated!");
router.push("/dashboard");
} else {
throw new Error("No user data received");
}
} catch (error) {
console.error("Error fetching user data:", error);
toast.error(t("auth.authenticationFailed"));
toast.error("Authentication failed");
router.push("/login");
}
};

View File

@@ -34,6 +34,8 @@ export function BackgroundPickerForm() {
const applyBackground = useCallback((backgroundValues: { light: string; dark: string }) => {
document.documentElement.style.setProperty("--custom-background-light", backgroundValues.light);
document.documentElement.style.setProperty("--custom-background-dark", backgroundValues.dark);
console.log("Applied background:", backgroundValues);
}, []);
useEffect(() => {

View File

@@ -36,6 +36,8 @@ export function FontPickerForm() {
document.documentElement.style.setProperty("--font-serif", fontValue);
document.body.style.fontFamily = fontValue;
console.log("Applied font:", fontValue);
}, []);
useEffect(() => {

View File

@@ -26,6 +26,7 @@ export function RadiusPickerForm() {
const [isCollapsed, setIsCollapsed] = useState(true);
const applyRadius = useCallback((radiusValue: string) => {
document.documentElement.style.setProperty("--radius", radiusValue);
console.log("Applied radius:", radiusValue);
}, []);
useEffect(() => {

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