Compare commits

..

23 Commits

Author SHA1 Message Date
Daniel Luiz Alves
cb4ed3f581 version: update package versions from 3.2.4-beta to 3.2.5-beta across all packages 2025-10-21 11:24:11 -03:00
Copilot
148676513d fix: issue with OIDC Google auto-registration for users (#314)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danielalves96 <62755605+danielalves96@users.noreply.github.com>
2025-10-21 11:15:48 -03:00
Copilot
42a5b7a796 feat: add functionality to embed uploaded images with BBCode or HTML (#296) 2025-10-21 11:14:46 -03:00
Daniel Luiz Alves
59fccd9a93 feat: implement file download and preview features with improved URL handling (#315) 2025-10-21 10:00:13 -03:00
Daniel Luiz Alves
91a5a24c8b fix: update license from BSD-2-Clause to Apache-2.0 in package.json files 2025-10-20 14:17:54 -03:00
Daniel Luiz Alves
ff83364870 version: update package versions from 3.2.3-beta to 3.2.4-beta across all packages 2025-10-20 14:15:58 -03:00
Copilot
df31b325f6 fix: issue allowing multiple files with the same name - auto-rename on upload and rename operations (#309) 2025-10-20 14:13:51 -03:00
Copilot
cce9847242 feat: add preview feature for social media sharing (#293) 2025-10-20 10:56:19 -03:00
Copilot
39dc94b7f8 fix: file upload failure for utf8 names over 100MiB (#290) 2025-10-20 10:50:37 -03:00
Copilot
ab5ea156a3 fix(server): Remove RFC 2616 separator chars from Content-Disposition filename (#291) 2025-10-20 10:49:46 -03:00
Copilot
4ff1eb28d9 chore: upgrade Node.js from v20 to v24 for extended LTS support (#298) 2025-10-20 10:48:16 -03:00
Copilot
17080e4465 feat: add option to hide Palmr version in footer (#297) 2025-10-20 10:42:06 -03:00
Copilot
c798c1bb1d fix: drag-and-drop file upload to save in correct directory (#288) 2025-10-20 10:31:30 -03:00
Copilot
0d7f9ca2b3 fix: downloading multiple files from Receive Files (#287) 2025-10-20 10:28:22 -03:00
Copilot
f78ecab2ed fix: update mobile responsive layout - buttons overflowing viewport (#283) 2025-10-20 10:17:42 -03:00
Copilot
fcc877738f fix(web): paste functionality in input fields except password inputs (#286)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danielalves96 <62755605+danielalves96@users.noreply.github.com>
2025-10-09 11:32:26 -03:00
Copilot
92722692f9 Add comprehensive GitHub Copilot instructions (#285) 2025-10-09 11:31:00 -03:00
Daniel Luiz Alves
95ac0f195b chore: bump version to 3.2.3-beta for all packages 2025-10-02 10:18:37 -03:00
Daniel Luiz Alves
d6c9b0d7d2 docs: add configurable upload chunk size option to environment variables and docs 2025-10-02 10:14:37 -03:00
Hakim Bawa
59f9e19ffb feat: make upload chunk size configurable (#273) 2025-10-02 10:04:52 -03:00
Daniel Stefani
6086d2a0ac feat: add fallback mechanism when fs.rename is unsupported (#272) 2025-10-02 10:01:06 -03:00
Daniel Luiz Alves
6b979a22fb docs: add beta version warning to README 2025-09-25 15:00:39 -03:00
Daniel Luiz Alves
e4bae380c9 chore: bump version to 3.2.2-beta for all packages 2025-09-25 14:30:01 -03:00
108 changed files with 9446 additions and 3443 deletions

259
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,259 @@
# 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,4 +33,5 @@ apps/server/dist/*
.steering
data/
node_modules/
node_modules/
screenshots/

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS base
FROM node:24-alpine AS base
# Install system dependencies
RUN apk add --no-cache \

View File

@@ -6,6 +6,17 @@
**Palmr.** is a **flexible** and **open-source** alternative to file transfer services like **WeTransfer**, **SendGB**, **Send Anywhere**, and **Files.fm**.
<div align="center">
<div style="background: linear-gradient(135deg, #ff4757, #ff3838); padding: 20px; border-radius: 12px; margin: 20px 0; box-shadow: 0 4px 15px rgba(255, 71, 87, 0.3); border: 2px solid #ff3838;">
<h3 style="color: white; margin: 0 0 10px 0; font-size: 18px; font-weight: bold;">
⚠️ BETA VERSION
</h3>
<p style="color: white; margin: 0; font-size: 14px; opacity: 0.95;">
<strong>This project is currently in beta phase.</strong><br>
Not recommended for production environments.
</p>
</div>
</div>
🔗 **For detailed documentation visit:** [Palmr. - Documentation](https://palmr.kyantech.com.br)

View File

@@ -0,0 +1,374 @@
---
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,6 +165,27 @@ 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,6 +16,7 @@
"reverse-proxy-configuration",
"download-memory-management",
"password-reset-without-smtp",
"cleanup-orphan-files",
"oidc-authentication",
"troubleshooting",
"---Developers---",

View File

@@ -76,6 +76,7 @@ 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
@@ -151,32 +152,33 @@ 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 |
| `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 |
| `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 |
<Callout type="info">
**Performance First**: Palmr runs without encryption by default for optimal speed and lower resource usage—perfect for
@@ -314,6 +316,28 @@ 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.1-beta",
"version": "3.2.5-beta",
"description": "Docs for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@@ -13,7 +13,7 @@
"react",
"typescript"
],
"license": "BSD-2-Clause",
"license": "Apache-2.0",
"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,3 +4,4 @@ dist/*
uploads/*
temp-uploads/*
prisma/*.db
tsconfig.tsbuildinfo

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-api",
"version": "3.2.1-beta",
"version": "3.2.5-beta",
"description": "API for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@@ -12,7 +12,7 @@
"nodejs",
"typescript"
],
"license": "BSD-2-Clause",
"license": "Apache-2.0",
"packageManager": "pnpm@10.6.0",
"main": "index.js",
"scripts": {
@@ -25,7 +25,9 @@
"format:check": "prettier . --check",
"type-check": "npx tsc --noEmit",
"validate": "pnpm lint && pnpm type-check",
"db:seed": "ts-node prisma/seed.js"
"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"
},
"prisma": {
"seed": "node prisma/seed.js"
@@ -42,9 +44,7 @@
"@fastify/swagger-ui": "^5.2.3",
"@prisma/client": "^6.11.0",
"@scalar/fastify-api-reference": "^1.32.1",
"@types/archiver": "^6.0.3",
"@types/crypto-js": "^4.2.2",
"archiver": "^7.0.1",
"bcryptjs": "^2.4.3",
"crypto-js": "^4.2.0",
"fastify": "^5.4.0",
@@ -79,4 +79,4 @@
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,6 @@ model File {
shares Share[] @relation("ShareFiles")
@@index([folderId])
@@map("files")
}
@@ -278,40 +277,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,6 +17,12 @@ 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

@@ -23,18 +23,21 @@ if (storageConfig.useSSL && env.S3_REJECT_UNAUTHORIZED === "false") {
}
}
export const s3Client = 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,
});
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;
export const isS3Enabled = true;
export const isS3Enabled = env.ENABLE_S3 === "true";

View File

@@ -1,18 +1,38 @@
import { z } from "zod";
const envSchema = z.object({
S3_ENDPOINT: z.string().min(1, "S3_ENDPOINT is required"),
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(),
S3_ACCESS_KEY: z.string().min(1, "S3_ACCESS_KEY is required"),
S3_SECRET_KEY: z.string().min(1, "S3_SECRET_KEY is required"),
S3_REGION: z.string().min(1, "S3_REGION is required"),
S3_BUCKET_NAME: z.string().min(1, "S3_BUCKET_NAME is required"),
S3_ACCESS_KEY: z.string().optional(),
S3_SECRET_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
S3_BUCKET_NAME: z.string().optional(),
S3_FORCE_PATH_STYLE: z.union([z.literal("true"), z.literal("false")]).default("false"),
S3_REJECT_UNAUTHORIZED: z.union([z.literal("true"), z.literal("false")]).default("true"),
PRESIGNED_URL_EXPIRATION: z.string().optional().default("3600"),
SECURE_SITE: z.union([z.literal("true"), z.literal("false")]).default("false"),
DATABASE_URL: z.string().optional().default("file:/app/server/prisma/palmr.db"),
DOWNLOAD_MAX_CONCURRENT: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
DOWNLOAD_MEMORY_THRESHOLD_MB: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
DOWNLOAD_QUEUE_SIZE: z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : undefined)),
DOWNLOAD_AUTO_SCALE: z.union([z.literal("true"), z.literal("false")]).default("true"),
DOWNLOAD_MIN_FILE_SIZE_GB: z
.string()
.optional()
.transform((val) => (val ? parseFloat(val) : undefined)),
CUSTOM_PATH: z.string().optional(),
});

View File

@@ -617,6 +617,11 @@ 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

@@ -1,263 +0,0 @@
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import archiver from "archiver";
import { FastifyReply, FastifyRequest } from "fastify";
import { bucketName, s3Client } from "../../config/storage.config";
import { prisma } from "../../shared/prisma";
import { ReverseShareService } from "../reverse-share/service";
export class BulkDownloadController {
private reverseShareService = new ReverseShareService();
async downloadFiles(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileIds, folderIds, zipName } = request.body as {
fileIds: string[];
folderIds: string[];
zipName: string;
};
if (!fileIds.length && !folderIds.length) {
return reply.status(400).send({ error: "No files or folders to download" });
}
// Get files from database
const files = await prisma.file.findMany({
where: {
id: { in: fileIds },
},
select: {
id: true,
name: true,
objectName: true,
size: true,
},
});
// Get folders and their files
const folders = await prisma.folder.findMany({
where: {
id: { in: folderIds },
},
include: {
files: {
select: {
id: true,
name: true,
objectName: true,
size: true,
},
},
},
});
// Create ZIP stream
const archive = archiver("zip", {
zlib: { level: 9 },
});
reply.raw.setHeader("Content-Type", "application/zip");
reply.raw.setHeader("Content-Disposition", `attachment; filename="${zipName}"`);
reply.raw.setHeader("Transfer-Encoding", "chunked");
archive.pipe(reply.raw);
// Add files to ZIP
for (const file of files) {
try {
const downloadUrl = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: bucketName,
Key: file.objectName,
}),
{ expiresIn: 300 } // 5 minutes
);
const response = await fetch(downloadUrl);
if (response.ok) {
const buffer = await response.arrayBuffer();
archive.append(Buffer.from(buffer), { name: file.name });
}
} catch (error) {
console.error(`Error downloading file ${file.name}:`, error);
}
}
// Add folder files to ZIP
for (const folder of folders) {
for (const file of folder.files) {
try {
const downloadUrl = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: bucketName,
Key: file.objectName,
}),
{ expiresIn: 300 }
);
const response = await fetch(downloadUrl);
if (response.ok) {
const buffer = await response.arrayBuffer();
archive.append(Buffer.from(buffer), {
name: `${folder.name}/${file.name}`,
});
}
} catch (error) {
console.error(`Error downloading file ${file.name}:`, error);
}
}
}
await archive.finalize();
} catch (error) {
console.error("Bulk download error:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
async downloadFolder(request: FastifyRequest, reply: FastifyReply) {
try {
const { folderId, folderName } = request.params as {
folderId: string;
folderName: string;
};
// Get folder and all its files recursively
const folder = await prisma.folder.findUnique({
where: { id: folderId },
include: {
files: {
select: {
id: true,
name: true,
objectName: true,
size: true,
},
},
},
});
if (!folder) {
return reply.status(404).send({ error: "Folder not found" });
}
// Create ZIP stream
const archive = archiver("zip", {
zlib: { level: 9 },
});
reply.raw.setHeader("Content-Type", "application/zip");
reply.raw.setHeader("Content-Disposition", `attachment; filename="${folderName}.zip"`);
reply.raw.setHeader("Transfer-Encoding", "chunked");
archive.pipe(reply.raw);
// Add all files to ZIP
for (const file of folder.files) {
try {
const downloadUrl = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: bucketName,
Key: file.objectName,
}),
{ expiresIn: 300 }
);
const response = await fetch(downloadUrl);
if (response.ok) {
const buffer = await response.arrayBuffer();
archive.append(Buffer.from(buffer), { name: file.name });
}
} catch (error) {
console.error(`Error downloading file ${file.name}:`, error);
}
}
await archive.finalize();
} catch (error) {
console.error("Folder download error:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
async downloadReverseShareFiles(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
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 { fileIds, zipName } = request.body as {
fileIds: string[];
zipName: string;
};
if (!fileIds.length) {
return reply.status(400).send({ error: "No files to download" });
}
// Get reverse share files from database
const files = await prisma.reverseShareFile.findMany({
where: {
id: { in: fileIds },
reverseShare: {
creatorId: userId, // Only allow creator to download
},
},
select: {
id: true,
name: true,
objectName: true,
size: true,
},
});
if (files.length === 0) {
return reply.status(404).send({ error: "No files found or unauthorized" });
}
// Create ZIP stream
const archive = archiver("zip", {
zlib: { level: 9 },
});
reply.raw.setHeader("Content-Type", "application/zip");
reply.raw.setHeader("Content-Disposition", `attachment; filename="${zipName}"`);
reply.raw.setHeader("Transfer-Encoding", "chunked");
archive.pipe(reply.raw);
// Add files to ZIP
for (const file of files) {
try {
const downloadUrl = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: bucketName,
Key: file.objectName,
}),
{ expiresIn: 300 } // 5 minutes
);
const response = await fetch(downloadUrl);
if (response.ok) {
const buffer = await response.arrayBuffer();
archive.append(Buffer.from(buffer), { name: file.name });
}
} catch (error) {
console.error(`Error downloading reverse share file ${file.name}:`, error);
}
}
await archive.finalize();
} catch (error) {
console.error("Reverse share bulk download error:", error);
return reply.status(500).send({ error: "Internal server error" });
}
}
}

View File

@@ -1,88 +0,0 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { BulkDownloadController } from "./controller";
export async function bulkDownloadRoutes(app: FastifyInstance) {
const bulkDownloadController = new BulkDownloadController();
const preValidation = async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Token inválido ou ausente." });
}
};
app.post(
"/bulk-download",
{
schema: {
tags: ["Bulk Download"],
operationId: "bulkDownloadFiles",
summary: "Download multiple files as ZIP",
description: "Downloads multiple files and folders as a ZIP archive",
body: z.object({
fileIds: z.array(z.string()).describe("Array of file IDs to download"),
folderIds: z.array(z.string()).describe("Array of folder IDs to download"),
zipName: z.string().describe("Name of the ZIP file"),
}),
response: {
200: z.string().describe("ZIP file stream"),
400: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
bulkDownloadController.downloadFiles.bind(bulkDownloadController)
);
app.get(
"/bulk-download/folder/:folderId/:folderName",
{
schema: {
tags: ["Bulk Download"],
operationId: "downloadFolder",
summary: "Download folder as ZIP",
description: "Downloads a folder and all its files as a ZIP archive",
params: z.object({
folderId: z.string().describe("Folder ID"),
folderName: z.string().describe("Folder name"),
}),
response: {
200: z.string().describe("ZIP file stream"),
404: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
bulkDownloadController.downloadFolder.bind(bulkDownloadController)
);
app.post(
"/bulk-download/reverse-share",
{
preValidation,
schema: {
tags: ["Bulk Download"],
operationId: "bulkDownloadReverseShareFiles",
summary: "Download multiple reverse share files as ZIP",
description:
"Downloads multiple reverse share files as a ZIP archive. Only the creator of the reverse share can download files.",
body: z.object({
fileIds: z.array(z.string()).describe("Array of reverse share file IDs to download"),
zipName: z.string().describe("Name of the ZIP file"),
}),
response: {
200: z.string().describe("ZIP file stream"),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Unauthorized") }),
404: z.object({ error: z.string().describe("No files found or unauthorized") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
bulkDownloadController.downloadReverseShareFiles.bind(bulkDownloadController)
);
}

View File

@@ -1,8 +1,15 @@
import * as fs from "fs";
import bcrypt from "bcryptjs";
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,
@@ -93,9 +100,13 @@ 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: input.name,
name: uniqueName,
description: input.description,
extension: input.extension,
size: BigInt(input.size),
@@ -169,9 +180,20 @@ export class FileController {
});
}
return reply.status(201).send({
// 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 = {
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 });
@@ -180,11 +202,10 @@ export class FileController {
async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName: encodedObjectName } = request.params as {
const { objectName, password } = request.query 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." });
@@ -198,7 +219,8 @@ export class FileController {
let hasAccess = false;
console.log("Requested file with password " + password);
// Don't log raw passwords. Log only whether a password was provided (for debugging access flow).
console.log(`Requested file access for object="${objectName}" passwordProvided=${password ? true : false}`);
const shares = await prisma.share.findMany({
where: {
@@ -250,6 +272,118 @@ 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." });
}
const storageProvider = (this.fileService as any).storageProvider;
const filePath = storageProvider.getFilePath(objectName);
const contentType = getContentType(reverseShareFile.name);
const fileName = reverseShareFile.name;
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
const stream = fs.createReadStream(filePath);
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." });
}
const storageProvider = (this.fileService as any).storageProvider;
const filePath = storageProvider.getFilePath(objectName);
const contentType = getContentType(fileRecord.name);
const fileName = fileRecord.name;
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
const stream = fs.createReadStream(filePath);
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();
@@ -359,6 +493,13 @@ 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,
@@ -444,6 +585,51 @@ 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.",
});
}
const storageProvider = (this.fileService as any).storageProvider;
const filePath = storageProvider.getFilePath(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
const stream = fs.createReadStream(filePath);
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 },

View File

@@ -106,17 +106,15 @@ export async function fileRoutes(app: FastifyInstance) {
);
app.get(
"/files/:objectName/download",
"/files/download-url",
{
schema: {
tags: ["File"],
operationId: "getDownloadUrl",
summary: "Get Download URL",
description: "Generates a pre-signed URL for downloading a file",
params: z.object({
objectName: z.string().min(1, "The objectName is required"),
}),
querystring: z.object({
objectName: z.string().min(1, "The objectName is required"),
password: z.string().optional().describe("Share password if required"),
}),
response: {
@@ -133,6 +131,46 @@ 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",
{

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,7 +7,11 @@ export class FileService {
private storageProvider: StorageProvider;
constructor() {
this.storageProvider = new S3StorageProvider();
if (isS3Enabled) {
this.storageProvider = new S3StorageProvider();
} else {
this.storageProvider = FilesystemStorageProvider.getInstance();
}
}
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
@@ -34,4 +40,8 @@ export class FileService {
throw err;
}
}
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,444 @@
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();
/**
* 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);
}
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"';
}
// Create ASCII-safe version with only valid token characters
const asciiSafe = sanitized
.split("")
.filter((char) => this.isTokenChar(char))
.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) {
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);
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 encodedFileName = request.headers["x-file-name"] as string;
const isLastChunk = request.headers["x-is-last-chunk"] as string;
if (!fileId || !chunkIndex || !totalChunks || !chunkSize || !totalSize || !encodedFileName) {
return null;
}
// Decode the base64-encoded filename to handle UTF-8 characters
let fileName: string;
try {
fileName = decodeURIComponent(escape(Buffer.from(encodedFileName, "base64").toString("binary")));
} catch (error) {
// Fallback to the encoded value if decoding fails (for backward compatibility)
fileName = encodedFileName;
}
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 fileExists = await provider.fileExists(tokenData.objectName);
if (!fileExists) {
console.error(`[DOWNLOAD] File not found: ${tokenData.objectName}`);
return reply.status(404).send({
error: "File not found",
message:
"The requested file does not exist on the server. It may have been deleted or the upload was incomplete.",
});
}
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);
}
} 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,21 +35,13 @@ export class FolderController {
}
}
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" });
}
// 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 folderRecord = await prisma.folder.create({
data: {
name: input.name,
name: uniqueName,
description: input.description,
objectName: input.objectName,
parentId: input.parentId,
@@ -231,19 +223,11 @@ 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 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 { generateUniqueFolderName } = await import("../../utils/file-name-generator.js");
const uniqueName = await generateUniqueFolderName(updateData.name, userId, folderRecord.parentId, id);
updateData.name = uniqueName;
}
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,7 +8,11 @@ export class FolderService {
private storageProvider: StorageProvider;
constructor() {
this.storageProvider = new S3StorageProvider();
if (isS3Enabled) {
this.storageProvider = new S3StorageProvider();
} else {
this.storageProvider = FilesystemStorageProvider.getInstance();
}
}
async getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
@@ -36,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

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

@@ -568,57 +568,76 @@ export class ReverseShareService {
const newObjectName = `${creatorId}/${Date.now()}-${file.name}`;
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,
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));
}
}
@@ -754,4 +773,30 @@ 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();
// 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

@@ -295,4 +295,17 @@ 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,6 +17,9 @@ 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>;
@@ -130,6 +133,41 @@ 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,4 +347,32 @@ 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,4 +440,31 @@ 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();
// Check if max views reached
const isMaxViewsReached = share.security.maxViews !== null && share.views >= share.security.maxViews;
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,715 @@
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): Promise<string> {
const encodedObjectName = encodeURIComponent(objectName);
return `/api/files/download?objectName=${encodedObjectName}`;
}
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 this.moveFile(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 };
}
// Tokens are automatically cleaned up by cleanExpiredTokens() every 5 minutes
// No need to manually consume tokens - allows reuse for previews, range requests, etc.
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);
}
}
private async moveFile(src: string, dest: string): Promise<void> {
try {
await fs.rename(src, dest);
} catch (err: any) {
if (err.code === "EXDEV") {
// cross-device: fallback to copy + delete
await fs.copyFile(src, dest);
await fs.unlink(src);
} else {
throw err;
}
}
}
}

View File

@@ -1,4 +1,4 @@
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { bucketName, s3Client } from "../config/storage.config";
@@ -14,6 +14,20 @@ export class S3StorageProvider implements StorageProvider {
}
}
/**
* 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);
}
/**
* Safely encode filename for Content-Disposition header
*/
@@ -41,12 +55,10 @@ export class S3StorageProvider implements StorageProvider {
return 'attachment; filename="download"';
}
// Create ASCII-safe version with only valid token characters
const asciiSafe = sanitized
.split("")
.filter((char) => {
const code = char.charCodeAt(0);
return code >= 32 && code <= 126;
})
.filter((char) => this.isTokenChar(char))
.join("");
if (asciiSafe && asciiSafe.trim()) {
@@ -110,4 +122,25 @@ export class S3StorageProvider implements StorageProvider {
await s3Client.send(command);
}
async fileExists(objectName: string): Promise<boolean> {
if (!s3Client) {
throw new Error("S3 client is not available");
}
try {
const command = new HeadObjectCommand({
Bucket: bucketName,
Key: objectName,
});
await s3Client.send(command);
return true;
} catch (error: any) {
if (error.name === "NotFound" || error.$metadata?.httpStatusCode === 404) {
return false;
}
throw error;
}
}
}

View File

@@ -0,0 +1,102 @@
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";
/**
* 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: ${isS3Enabled ? "S3" : "Filesystem"}`);
let storageProvider: StorageProvider;
if (isS3Enabled) {
storageProvider = new S3StorageProvider();
} else {
storageProvider = FilesystemStorageProvider.getInstance();
}
// 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,12 +1,19 @@
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 { bulkDownloadRoutes } from "./modules/bulk-download/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";
@@ -14,6 +21,7 @@ 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;
@@ -23,9 +31,27 @@ if (typeof global.crypto === "undefined") {
(global as any).crypto = crypto.webcrypto;
}
async function ensureDirectories() {
const dirsToCreate = [
{ path: directoriesConfig.uploads, name: "uploads" },
{ path: directoriesConfig.tempUploads, name: "temp-uploads" },
];
for (const dir of dirsToCreate) {
try {
await fs.access(dir.path);
} catch {
await fs.mkdir(dir.path, { recursive: true });
console.log(`📁 Created ${dir.name} directory: ${dir.path}`);
}
}
}
async function startServer() {
const app = await buildApp();
await ensureDirectories();
await app.register(fastifyMultipart, {
limits: {
fieldNameSize: 100,
@@ -37,19 +63,31 @@ async function startServer() {
},
});
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(fileRoutes);
app.register(folderRoutes);
app.register(downloadQueueRoutes);
app.register(shareRoutes);
app.register(reverseShareRoutes);
app.register(storageRoutes);
app.register(bulkDownloadRoutes);
app.register(appRoutes);
app.register(healthRoutes);
if (env.ENABLE_S3 !== "true") {
app.register(filesystemRoutes);
}
await app.listen({
port: 3333,
host: "0.0.0.0",
@@ -66,16 +104,23 @@ async function startServer() {
}
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);
});
}

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,6 +2,7 @@ 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>;
}
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

@@ -0,0 +1,206 @@
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,2 +1,5 @@
API_BASE_URL=http:localhost:3333
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
# Configuration options
NEXT_PUBLIC_UPLOAD_CHUNK_SIZE_MB=

View File

@@ -150,7 +150,9 @@
"move": "نقل",
"rename": "إعادة تسمية",
"search": "بحث",
"share": "مشاركة"
"share": "مشاركة",
"copied": "تم النسخ",
"copy": "نسخ"
},
"createShare": {
"title": "إنشاء مشاركة",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "معاينة الملف",
"description": "معاينة وتنزيل الملف",
"loading": "جاري التحميل...",
"notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.",
"downloadToView": "استخدم زر التحميل لتنزيل الملف.",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "لوحة التحكم"
},
"notifications": {
"permissionGranted": "تم تمكين إشعارات التنزيل",
"permissionDenied": "تم تعطيل إشعارات التنزيل",
"downloadComplete": {
"title": "اكتمل التنزيل",
"body": "اكتمل تنزيل {fileName}"
},
"downloadFailed": {
"title": "فشل التنزيل",
"body": "فشل تنزيل {fileName}: {error}",
"unknownError": "خطأ غير معروف"
},
"queueProcessing": {
"title": "بدء التنزيل",
"body": "يتم الآن تنزيل {fileName}{position}",
"position": " (كان #{position} في قائمة الانتظار)"
}
},
"profile": {
"password": {
"title": "تغيير كلمة المرور",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "رفع الملفات - Palmr",
"description": "رفع الملفات عبر الرابط المشترك"
"description": "رفع الملفات عبر الرابط المشترك",
"descriptionWithLimit": "تحميل الملفات (الحد الأقصى {limit} ملفات)"
},
"layout": {
"defaultTitle": "رفع الملفات",
@@ -1282,6 +1304,10 @@
"passwordAuthEnabled": {
"title": "المصادقة بالكلمة السرية",
"description": "تمكين أو تعطيل المصادقة بالكلمة السرية"
},
"hideVersion": {
"title": "إخفاء الإصدار",
"description": "إخفاء إصدار Palmr في تذييل جميع الصفحات"
}
},
"buttons": {
@@ -1343,7 +1369,11 @@
"description": "قد يكون تم حذف هذه المشاركة أو انتهت صلاحيتها."
},
"pageTitle": "المشاركة",
"downloadAll": "تحميل الكل"
"downloadAll": "تحميل الكل",
"metadata": {
"defaultDescription": "مشاركة الملفات بشكل آمن",
"filesShared": "{count, plural, =1 {ملف واحد تمت مشاركته} other {# ملفات تمت مشاركتها}}"
}
},
"shareActions": {
"deleteTitle": "حذف المشاركة",
@@ -1905,5 +1935,17 @@
"passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
},
"embedCode": {
"title": "تضمين الصورة",
"description": "استخدم هذه الأكواد لتضمين هذه الصورة في المنتديات أو المواقع الإلكترونية أو المنصات الأخرى",
"tabs": {
"directLink": "رابط مباشر",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "عنوان URL مباشر لملف الصورة",
"htmlDescription": "استخدم هذا الكود لتضمين الصورة في صفحات HTML",
"bbcodeDescription": "استخدم هذا الكود لتضمين الصورة في المنتديات التي تدعم BBCode"
}
}

View File

@@ -150,7 +150,9 @@
"move": "Verschieben",
"rename": "Umbenennen",
"search": "Suchen",
"share": "Teilen"
"share": "Teilen",
"copied": "Kopiert",
"copy": "Kopieren"
},
"createShare": {
"title": "Freigabe Erstellen",
@@ -302,6 +304,7 @@
},
"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.",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "Übersicht"
},
"notifications": {
"permissionGranted": "Download-Benachrichtigungen aktiviert",
"permissionDenied": "Download-Benachrichtigungen deaktiviert",
"downloadComplete": {
"title": "Download abgeschlossen",
"body": "{fileName} wurde erfolgreich heruntergeladen"
},
"downloadFailed": {
"title": "Download fehlgeschlagen",
"body": "Fehler beim Herunterladen von {fileName}: {error}",
"unknownError": "Unbekannter Fehler"
},
"queueProcessing": {
"title": "Download startet",
"body": "{fileName} wird jetzt heruntergeladen{position}",
"position": " (war #{position} in der Warteschlange)"
}
},
"profile": {
"password": {
"title": "Passwort ändern",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "Dateien senden - Palmr",
"description": "Senden Sie Dateien über den geteilten Link"
"description": "Senden Sie Dateien über den geteilten Link",
"descriptionWithLimit": "Dateien hochladen (max. {limit} Dateien)"
},
"layout": {
"defaultTitle": "Dateien senden",
@@ -1280,6 +1302,10 @@
"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": {
@@ -1341,7 +1367,11 @@
"description": "Diese Freigabe wurde möglicherweise gelöscht oder ist abgelaufen."
},
"pageTitle": "Freigabe",
"downloadAll": "Alle herunterladen"
"downloadAll": "Alle herunterladen",
"metadata": {
"defaultDescription": "Dateien sicher teilen",
"filesShared": "{count, plural, =1 {1 Datei geteilt} other {# Dateien geteilt}}"
}
},
"shareActions": {
"deleteTitle": "Freigabe Löschen",
@@ -1903,5 +1933,17 @@
"passwordRequired": "Passwort ist erforderlich",
"nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich"
},
"embedCode": {
"title": "Bild einbetten",
"description": "Verwenden Sie diese Codes, um dieses Bild in Foren, Websites oder anderen Plattformen einzubetten",
"tabs": {
"directLink": "Direkter Link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Direkte URL zur Bilddatei",
"htmlDescription": "Verwenden Sie diesen Code, um das Bild in HTML-Seiten einzubetten",
"bbcodeDescription": "Verwenden Sie diesen Code, um das Bild in Foren einzubetten, die BBCode unterstützen"
}
}

View File

@@ -125,10 +125,7 @@
"zipNameLabel": "ZIP file name",
"zipNamePlaceholder": "Enter file name",
"description": "{count, plural, =1 {1 file will be compressed} other {# files will be compressed}}",
"download": "Download ZIP",
"creatingZip": "Creating ZIP file...",
"zipCreated": "ZIP file created successfully, download started",
"zipError": "Failed to create ZIP file"
"download": "Download ZIP"
},
"common": {
"loading": "Loading, please wait...",
@@ -153,7 +150,9 @@
"rename": "Rename",
"move": "Move",
"share": "Share",
"search": "Search"
"search": "Search",
"copy": "Copy",
"copied": "Copied"
},
"createShare": {
"title": "Create Share",
@@ -305,6 +304,7 @@
},
"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",
@@ -553,6 +553,24 @@
"navigation": {
"dashboard": "Dashboard"
},
"notifications": {
"permissionGranted": "Download notifications enabled",
"permissionDenied": "Download notifications disabled",
"downloadComplete": {
"title": "Download Complete",
"body": "{fileName} has finished downloading"
},
"downloadFailed": {
"title": "Download Failed",
"body": "Failed to download {fileName}: {error}",
"unknownError": "Unknown error"
},
"queueProcessing": {
"title": "Download Starting",
"body": "{fileName} is now downloading{position}",
"position": " (was #{position} in queue)"
}
},
"profile": {
"password": {
"title": "Change Password",
@@ -1042,7 +1060,8 @@
"upload": {
"metadata": {
"title": "Send Files - Palmr",
"description": "Send files through the shared link"
"description": "Send files through the shared link",
"descriptionWithLimit": "Upload files (max {limit} files)"
},
"layout": {
"defaultTitle": "Send Files",
@@ -1196,6 +1215,10 @@
"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"
@@ -1342,7 +1365,11 @@
"title": "Share Not Found",
"description": "This share may have been deleted or expired."
},
"pageTitle": "Share"
"pageTitle": "Share",
"metadata": {
"defaultDescription": "Share files securely",
"filesShared": "{count, plural, =1 {1 file shared} other {# files shared}}"
}
},
"shareActions": {
"fileTitle": "Share File",
@@ -1857,6 +1884,18 @@
"userr": "User"
}
},
"embedCode": {
"title": "Embed Image",
"description": "Use these codes to embed this image in forums, websites, or other platforms",
"tabs": {
"directLink": "Direct Link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Direct URL to the image file",
"htmlDescription": "Use this code to embed the image in HTML pages",
"bbcodeDescription": "Use this code to embed the image in forums that support BBCode"
},
"validation": {
"firstNameRequired": "First name is required",
"lastNameRequired": "Last name is required",

View File

@@ -150,7 +150,9 @@
"move": "Mover",
"rename": "Renombrar",
"search": "Buscar",
"share": "Compartir"
"share": "Compartir",
"copied": "Copiado",
"copy": "Copiar"
},
"createShare": {
"title": "Crear Compartir",
@@ -302,6 +304,7 @@
},
"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.",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "Panel de control"
},
"notifications": {
"permissionGranted": "Notificaciones de descarga habilitadas",
"permissionDenied": "Notificaciones de descarga deshabilitadas",
"downloadComplete": {
"title": "Descarga Completada",
"body": "{fileName} ha terminado de descargarse"
},
"downloadFailed": {
"title": "Descarga Fallida",
"body": "Error al descargar {fileName}: {error}",
"unknownError": "Error desconocido"
},
"queueProcessing": {
"title": "Descarga Iniciando",
"body": "{fileName} está descargándose ahora{position}",
"position": " (estaba en posición #{position} en la cola)"
}
},
"profile": {
"password": {
"title": "Cambiar contraseña",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "Enviar Archivos - Palmr",
"description": "Envía archivos a través del enlace compartido"
"description": "Envía archivos a través del enlace compartido",
"descriptionWithLimit": "Subir archivos (máx. {limit} archivos)"
},
"layout": {
"defaultTitle": "Enviar Archivos",
@@ -1280,6 +1302,10 @@
"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": {
@@ -1341,7 +1367,11 @@
"description": "Esta compartición puede haber sido eliminada o haber expirado."
},
"pageTitle": "Compartición",
"downloadAll": "Descargar Todo"
"downloadAll": "Descargar Todo",
"metadata": {
"defaultDescription": "Compartir archivos de forma segura",
"filesShared": "{count, plural, =1 {1 archivo compartido} other {# archivos compartidos}}"
}
},
"shareActions": {
"deleteTitle": "Eliminar Compartir",
@@ -1903,5 +1933,17 @@
"passwordRequired": "Se requiere la contraseña",
"nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio"
},
"embedCode": {
"title": "Insertar imagen",
"description": "Utiliza estos códigos para insertar esta imagen en foros, sitios web u otras plataformas",
"tabs": {
"directLink": "Enlace directo",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL directa al archivo de imagen",
"htmlDescription": "Utiliza este código para insertar la imagen en páginas HTML",
"bbcodeDescription": "Utiliza este código para insertar la imagen en foros que admiten BBCode"
}
}

View File

@@ -150,7 +150,9 @@
"move": "Déplacer",
"rename": "Renommer",
"search": "Rechercher",
"share": "Partager"
"share": "Partager",
"copied": "Copié",
"copy": "Copier"
},
"createShare": {
"title": "Créer un Partage",
@@ -302,6 +304,7 @@
},
"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.",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "Tableau de bord"
},
"notifications": {
"permissionGranted": "Notifications de téléchargement activées",
"permissionDenied": "Notifications de téléchargement désactivées",
"downloadComplete": {
"title": "Téléchargement Terminé",
"body": "{fileName} a fini de télécharger"
},
"downloadFailed": {
"title": "Échec du Téléchargement",
"body": "Échec du téléchargement de {fileName} : {error}",
"unknownError": "Erreur inconnue"
},
"queueProcessing": {
"title": "Démarrage du Téléchargement",
"body": "{fileName} est en cours de téléchargement{position}",
"position": " (était n°{position} dans la file d'attente)"
}
},
"profile": {
"password": {
"title": "Changer le Mot de Passe",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "Envoyer des Fichiers - Palmr",
"description": "Envoyez des fichiers via le lien partagé"
"description": "Envoyez des fichiers via le lien partagé",
"descriptionWithLimit": "Télécharger des fichiers (max {limit} fichiers)"
},
"layout": {
"defaultTitle": "Envoyer des Fichiers",
@@ -1283,6 +1305,10 @@
"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": {
@@ -1341,7 +1367,11 @@
"description": "Ce partage a peut-être été supprimé ou a expiré."
},
"pageTitle": "Partage",
"downloadAll": "Tout Télécharger"
"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}}"
}
},
"shareActions": {
"deleteTitle": "Supprimer le Partage",
@@ -1903,5 +1933,17 @@
"passwordRequired": "Le mot de passe est requis",
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
},
"embedCode": {
"title": "Intégrer l'image",
"description": "Utilisez ces codes pour intégrer cette image dans des forums, sites web ou autres plateformes",
"tabs": {
"directLink": "Lien direct",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL directe vers le fichier image",
"htmlDescription": "Utilisez ce code pour intégrer l'image dans des pages HTML",
"bbcodeDescription": "Utilisez ce code pour intégrer l'image dans des forums prenant en charge BBCode"
}
}

View File

@@ -150,7 +150,9 @@
"move": "स्थानांतरित करें",
"rename": "नाम बदलें",
"search": "खोजें",
"share": "साझा करें"
"share": "साझा करें",
"copied": "कॉपी किया गया",
"copy": "कॉपी करें"
},
"createShare": {
"title": "साझाकरण बनाएं",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "फ़ाइल पूर्वावलोकन",
"description": "फ़ाइल पूर्वावलोकन और डाउनलोड",
"loading": "लोड हो रहा है...",
"notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।",
"downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "डैशबोर्ड"
},
"notifications": {
"permissionGranted": "डाउनलोड सूचनाएं सक्षम की गईं",
"permissionDenied": "डाउनलोड सूचनाएं अक्षम की गईं",
"downloadComplete": {
"title": "डाउनलोड पूर्ण",
"body": "{fileName} का डाउनलोड समाप्त हो गया है"
},
"downloadFailed": {
"title": "डाउनलोड विफल",
"body": "{fileName} डाउनलोड करने में विफल: {error}",
"unknownError": "अज्ञात त्रुटि"
},
"queueProcessing": {
"title": "डाउनलोड प्रारंभ",
"body": "{fileName} अब डाउनलोड हो रहा है{position}",
"position": " (कतार में #{position} था)"
}
},
"profile": {
"password": {
"title": "पासवर्ड बदलें",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "फ़ाइलें भेजें - पाल्मर",
"description": "साझा किए गए लिंक के माध्यम से फ़ाइलें भेजें"
"description": "साझा किए गए लिंक के माध्यम से फ़ाइलें भेजें",
"descriptionWithLimit": "फ़ाइलें अपलोड करें (अधिकतम {limit} फ़ाइलें)"
},
"layout": {
"defaultTitle": "फ़ाइलें भेजें",
@@ -1280,6 +1302,10 @@
"passwordAuthEnabled": {
"title": "पासवर्ड प्रमाणीकरण",
"description": "पासवर्ड आधारित प्रमाणीकरण सक्षम या अक्षम करें"
},
"hideVersion": {
"title": "संस्करण छुपाएं",
"description": "सभी पृष्ठों के फुटर में Palmr संस्करण छुपाएं"
}
},
"buttons": {
@@ -1341,7 +1367,11 @@
"description": "यह साझाकरण हटा दिया गया हो सकता है या समाप्त हो चुका है।"
},
"pageTitle": "साझाकरण",
"downloadAll": "सभी डाउनलोड करें"
"downloadAll": "सभी डाउनलोड करें",
"metadata": {
"defaultDescription": "फाइलों को सुरक्षित रूप से साझा करें",
"filesShared": "{count, plural, =1 {1 फ़ाइल साझा की गई} other {# फ़ाइलें साझा की गईं}}"
}
},
"shareActions": {
"deleteTitle": "साझाकरण हटाएं",
@@ -1903,5 +1933,17 @@
"passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
},
"embedCode": {
"title": "छवि एम्बेड करें",
"description": "इस छवि को मंचों, वेबसाइटों या अन्य प्लेटफार्मों में एम्बेड करने के लिए इन कोड का उपयोग करें",
"tabs": {
"directLink": "सीधा लिंक",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "छवि फ़ाइल का सीधा URL",
"htmlDescription": "HTML पेजों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें",
"bbcodeDescription": "BBCode का समर्थन करने वाले मंचों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें"
}
}

View File

@@ -150,7 +150,9 @@
"move": "Sposta",
"rename": "Rinomina",
"search": "Cerca",
"share": "Condividi"
"share": "Condividi",
"copied": "Copiato",
"copy": "Copia"
},
"createShare": {
"title": "Crea Condivisione",
@@ -302,6 +304,7 @@
},
"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.",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "Pannello di controllo"
},
"notifications": {
"permissionGranted": "Notifiche download abilitate",
"permissionDenied": "Notifiche download disabilitate",
"downloadComplete": {
"title": "Download Completato",
"body": "Il download di {fileName} è terminato"
},
"downloadFailed": {
"title": "Download Fallito",
"body": "Impossibile scaricare {fileName}: {error}",
"unknownError": "Errore sconosciuto"
},
"queueProcessing": {
"title": "Download in Avvio",
"body": "{fileName} sta ora scaricando{position}",
"position": " (era #{position} in coda)"
}
},
"profile": {
"password": {
"title": "Cambia Parola d'accesso",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "Invia File - Palmr",
"description": "Invia file attraverso il link condiviso"
"description": "Invia file attraverso il link condiviso",
"descriptionWithLimit": "Carica file (max {limit} file)"
},
"layout": {
"defaultTitle": "Invia File",
@@ -1280,6 +1302,10 @@
"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": {
@@ -1341,7 +1367,11 @@
"description": "Questa condivisione potrebbe essere stata eliminata o è scaduta."
},
"pageTitle": "Condivisione",
"downloadAll": "Scarica Tutto"
"downloadAll": "Scarica Tutto",
"metadata": {
"defaultDescription": "Condividi file in modo sicuro",
"filesShared": "{count, plural, =1 {1 file condiviso} other {# file condivisi}}"
}
},
"shareActions": {
"deleteTitle": "Elimina Condivisione",
@@ -1903,5 +1933,17 @@
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
"nameRequired": "Il nome è obbligatorio",
"required": "Questo campo è obbligatorio"
},
"embedCode": {
"title": "Incorpora immagine",
"description": "Usa questi codici per incorporare questa immagine in forum, siti web o altre piattaforme",
"tabs": {
"directLink": "Link diretto",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL diretto al file immagine",
"htmlDescription": "Usa questo codice per incorporare l'immagine nelle pagine HTML",
"bbcodeDescription": "Usa questo codice per incorporare l'immagine nei forum che supportano BBCode"
}
}

View File

@@ -150,7 +150,9 @@
"move": "移動",
"rename": "名前を変更",
"search": "検索",
"share": "共有"
"share": "共有",
"copied": "コピーしました",
"copy": "コピー"
},
"createShare": {
"title": "共有を作成",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "ファイルプレビュー",
"description": "ファイルをプレビューしてダウンロード",
"loading": "読み込み中...",
"notAvailable": "このファイルタイプのプレビューは利用できません。",
"downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "ダッシュボード"
},
"notifications": {
"permissionGranted": "ダウンロード通知が有効になりました",
"permissionDenied": "ダウンロード通知が無効になりました",
"downloadComplete": {
"title": "ダウンロード完了",
"body": "{fileName}のダウンロードが完了しました"
},
"downloadFailed": {
"title": "ダウンロード失敗",
"body": "{fileName}のダウンロードに失敗: {error}",
"unknownError": "不明なエラー"
},
"queueProcessing": {
"title": "ダウンロード開始",
"body": "{fileName}のダウンロードを開始しています{position}",
"position": "(キュー内{position}番目)"
}
},
"profile": {
"password": {
"title": "パスワードを変更",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "ファイルを送信 - Palmr",
"description": "共有リンクを通じてファイルを送信"
"description": "共有リンクを通じてファイルを送信",
"descriptionWithLimit": "ファイルをアップロード(最大{limit}ファイル)"
},
"layout": {
"defaultTitle": "ファイルを送信",
@@ -1280,6 +1302,10 @@
"passwordAuthEnabled": {
"title": "パスワード認証",
"description": "パスワード認証を有効または無効にする"
},
"hideVersion": {
"title": "バージョンを非表示",
"description": "すべてのページのフッターにあるPalmrバージョンを非表示にする"
}
},
"buttons": {
@@ -1341,7 +1367,11 @@
"description": "この共有は削除されたか、期限が切れている可能性があります。"
},
"pageTitle": "共有",
"downloadAll": "すべてダウンロード"
"downloadAll": "すべてダウンロード",
"metadata": {
"defaultDescription": "ファイルを安全に共有",
"filesShared": "{count, plural, =1 {1 ファイルが共有されました} other {# ファイルが共有されました}}"
}
},
"shareActions": {
"deleteTitle": "共有を削除",
@@ -1903,5 +1933,17 @@
"passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
},
"embedCode": {
"title": "画像を埋め込む",
"description": "これらのコードを使用して、この画像をフォーラム、ウェブサイト、またはその他のプラットフォームに埋め込みます",
"tabs": {
"directLink": "直接リンク",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "画像ファイルへの直接URL",
"htmlDescription": "このコードを使用してHTMLページに画像を埋め込みます",
"bbcodeDescription": "BBCodeをサポートするフォーラムに画像を埋め込むには、このコードを使用します"
}
}

View File

@@ -150,7 +150,9 @@
"move": "이동",
"rename": "이름 변경",
"search": "검색",
"share": "공유"
"share": "공유",
"copied": "복사됨",
"copy": "복사"
},
"createShare": {
"title": "공유 생성",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "파일 미리보기",
"description": "파일 미리보기 및 다운로드",
"loading": "로딩 중...",
"notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.",
"downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "대시보드"
},
"notifications": {
"permissionGranted": "다운로드 알림이 활성화되었습니다",
"permissionDenied": "다운로드 알림이 비활성화되었습니다",
"downloadComplete": {
"title": "다운로드 완료",
"body": "{fileName} 다운로드가 완료되었습니다"
},
"downloadFailed": {
"title": "다운로드 실패",
"body": "{fileName} 다운로드 실패: {error}",
"unknownError": "알 수 없는 오류"
},
"queueProcessing": {
"title": "다운로드 시작",
"body": "{fileName} 다운로드가 시작되었습니다{position}",
"position": " (대기열 #{position}번이었음)"
}
},
"profile": {
"password": {
"title": "비밀번호 변경",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "파일 보내기 - Palmr",
"description": "공유된 링크를 통해 파일 보내기"
"description": "공유된 링크를 통해 파일 보내기",
"descriptionWithLimit": "파일 업로드 (최대 {limit}개 파일)"
},
"layout": {
"defaultTitle": "파일 보내기",
@@ -1280,6 +1302,10 @@
"passwordAuthEnabled": {
"title": "비밀번호 인증",
"description": "비밀번호 기반 인증 활성화 또는 비활성화"
},
"hideVersion": {
"title": "버전 숨기기",
"description": "모든 페이지의 바닥글에서 Palmr 버전 숨기기"
}
},
"buttons": {
@@ -1341,7 +1367,11 @@
"description": "이 공유는 삭제되었거나 만료되었을 수 있습니다."
},
"pageTitle": "공유",
"downloadAll": "모두 다운로드"
"downloadAll": "모두 다운로드",
"metadata": {
"defaultDescription": "파일을 안전하게 공유",
"filesShared": "{count, plural, =1 {1개 파일 공유됨} other {#개 파일 공유됨}}"
}
},
"shareActions": {
"deleteTitle": "공유 삭제",
@@ -1903,5 +1933,17 @@
"passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
},
"embedCode": {
"title": "이미지 삽입",
"description": "이 코드를 사용하여 포럼, 웹사이트 또는 기타 플랫폼에 이 이미지를 삽입하세요",
"tabs": {
"directLink": "직접 링크",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "이미지 파일에 대한 직접 URL",
"htmlDescription": "이 코드를 사용하여 HTML 페이지에 이미지를 삽입하세요",
"bbcodeDescription": "BBCode를 지원하는 포럼에 이미지를 삽입하려면 이 코드를 사용하세요"
}
}

View File

@@ -150,7 +150,9 @@
"move": "Verplaatsen",
"rename": "Hernoemen",
"search": "Zoeken",
"share": "Delen"
"share": "Delen",
"copied": "Gekopieerd",
"copy": "Kopiëren"
},
"createShare": {
"title": "Delen Maken",
@@ -302,6 +304,7 @@
},
"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.",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "Controlepaneel"
},
"notifications": {
"permissionGranted": "Download meldingen ingeschakeld",
"permissionDenied": "Download meldingen uitgeschakeld",
"downloadComplete": {
"title": "Download Voltooid",
"body": "{fileName} is klaar met downloaden"
},
"downloadFailed": {
"title": "Download Mislukt",
"body": "Downloaden van {fileName} mislukt: {error}",
"unknownError": "Onbekende fout"
},
"queueProcessing": {
"title": "Download Start",
"body": "{fileName} wordt nu gedownload{position}",
"position": " (was #{position} in wachtrij)"
}
},
"profile": {
"password": {
"title": "Wachtwoord Wijzigen",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "Bestanden Verzenden - Palmr",
"description": "Verzend bestanden via de gedeelde link"
"description": "Verzend bestanden via de gedeelde link",
"descriptionWithLimit": "Upload bestanden (max {limit} bestanden)"
},
"layout": {
"defaultTitle": "Bestanden Verzenden",
@@ -1280,6 +1302,10 @@
"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": {
@@ -1341,7 +1367,11 @@
"description": "Dit delen is mogelijk verwijderd of verlopen."
},
"pageTitle": "Delen",
"downloadAll": "Alles Downloaden"
"downloadAll": "Alles Downloaden",
"metadata": {
"defaultDescription": "Bestanden veilig delen",
"filesShared": "{count, plural, =1 {1 bestand gedeeld} other {# bestanden gedeeld}}"
}
},
"shareActions": {
"deleteTitle": "Delen Verwijderen",
@@ -1903,5 +1933,17 @@
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
"nameRequired": "Naam is verplicht",
"required": "Dit veld is verplicht"
},
"embedCode": {
"title": "Afbeelding insluiten",
"description": "Gebruik deze codes om deze afbeelding in te sluiten in forums, websites of andere platforms",
"tabs": {
"directLink": "Directe link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Directe URL naar het afbeeldingsbestand",
"htmlDescription": "Gebruik deze code om de afbeelding in te sluiten in HTML-pagina's",
"bbcodeDescription": "Gebruik deze code om de afbeelding in te sluiten in forums die BBCode ondersteunen"
}
}

View File

@@ -150,7 +150,9 @@
"move": "Przenieś",
"rename": "Zmień nazwę",
"search": "Szukaj",
"share": "Udostępnij"
"share": "Udostępnij",
"copied": "Skopiowano",
"copy": "Kopiuj"
},
"createShare": {
"title": "Utwórz Udostępnienie",
@@ -302,6 +304,7 @@
},
"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",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "Panel główny"
},
"notifications": {
"permissionGranted": "Powiadomienia o pobieraniu włączone",
"permissionDenied": "Powiadomienia o pobieraniu wyłączone",
"downloadComplete": {
"title": "Pobieranie zakończone",
"body": "Plik {fileName} został pobrany"
},
"downloadFailed": {
"title": "Błąd pobierania",
"body": "Nie udało się pobrać pliku {fileName}: {error}",
"unknownError": "Nieznany błąd"
},
"queueProcessing": {
"title": "Rozpoczęcie pobierania",
"body": "Trwa pobieranie pliku {fileName}{position}",
"position": " (był #{position} w kolejce)"
}
},
"profile": {
"password": {
"title": "Zmień hasło",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "Wyślij pliki - Palmr",
"description": "Wysyłaj pliki za pośrednictwem udostępnionego linku"
"description": "Wysyłaj pliki za pośrednictwem udostępnionego linku",
"descriptionWithLimit": "Prześlij pliki (maks. {limit} plików)"
},
"layout": {
"defaultTitle": "Wyślij pliki",
@@ -1280,6 +1302,10 @@
"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": {
@@ -1341,7 +1367,11 @@
"description": "To udostępnienie mogło zostać usunięte lub wygasło."
},
"pageTitle": "Udostępnij",
"downloadAll": "Pobierz wszystkie"
"downloadAll": "Pobierz wszystkie",
"metadata": {
"defaultDescription": "Bezpiecznie udostępniaj pliki",
"filesShared": "{count, plural, =1 {1 plik udostępniony} other {# plików udostępnionych}}"
}
},
"shareActions": {
"deleteTitle": "Usuń udostępnienie",
@@ -1903,5 +1933,17 @@
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
"nameRequired": "Nazwa jest wymagana",
"required": "To pole jest wymagane"
},
"embedCode": {
"title": "Osadź obraz",
"description": "Użyj tych kodów, aby osadzić ten obraz na forach, stronach internetowych lub innych platformach",
"tabs": {
"directLink": "Link bezpośredni",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Bezpośredni adres URL pliku obrazu",
"htmlDescription": "Użyj tego kodu, aby osadzić obraz na stronach HTML",
"bbcodeDescription": "Użyj tego kodu, aby osadzić obraz na forach obsługujących BBCode"
}
}

View File

@@ -150,7 +150,9 @@
"move": "Mover",
"rename": "Renomear",
"search": "Pesquisar",
"share": "Compartilhar"
"share": "Compartilhar",
"copied": "Copiado",
"copy": "Copiar"
},
"createShare": {
"title": "Criar compartilhamento",
@@ -302,6 +304,7 @@
},
"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.",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "Painel"
},
"notifications": {
"permissionGranted": "Notificações de download ativadas",
"permissionDenied": "Notificações de download desativadas",
"downloadComplete": {
"title": "Download Concluído",
"body": "{fileName} terminou de baixar"
},
"downloadFailed": {
"title": "Download Falhou",
"body": "Falha ao baixar {fileName}: {error}",
"unknownError": "Erro desconhecido"
},
"queueProcessing": {
"title": "Download Iniciando",
"body": "{fileName} está sendo baixado agora{position}",
"position": " (estava na posição #{position} da fila)"
}
},
"profile": {
"password": {
"title": "Alterar Senha",
@@ -1039,7 +1060,8 @@
"upload": {
"metadata": {
"title": "Enviar Arquivos - Palmr",
"description": "Envie arquivos através do link compartilhado"
"description": "Envie arquivos através do link compartilhado",
"descriptionWithLimit": "Enviar arquivos (máx. {limit} arquivos)"
},
"layout": {
"defaultTitle": "Enviar Arquivos",
@@ -1289,6 +1311,10 @@
"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": {
@@ -1342,7 +1368,11 @@
"description": "Este compartilhamento pode ter sido excluído ou expirado."
},
"pageTitle": "Compartilhamento",
"downloadAll": "Baixar todos"
"downloadAll": "Baixar todos",
"metadata": {
"defaultDescription": "Compartilhar arquivos com segurança",
"filesShared": "{count, plural, =1 {1 arquivo compartilhado} other {# arquivos compartilhados}}"
}
},
"shareActions": {
"deleteTitle": "Excluir Compartilhamento",
@@ -1904,5 +1934,17 @@
"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 imagem",
"description": "Use estes códigos para incorporar esta imagem em fóruns, sites ou outras plataformas",
"tabs": {
"directLink": "Link direto",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL direto para o arquivo de imagem",
"htmlDescription": "Use este código para incorporar a imagem em páginas HTML",
"bbcodeDescription": "Use este código para incorporar a imagem em fóruns que suportam BBCode"
}
}

View File

@@ -150,7 +150,9 @@
"move": "Переместить",
"rename": "Переименовать",
"search": "Поиск",
"share": "Поделиться"
"share": "Поделиться",
"copied": "Скопировано",
"copy": "Копировать"
},
"createShare": {
"title": "Создать общий доступ",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Предварительный просмотр файла",
"description": "Просмотр и загрузка файла",
"loading": "Загрузка...",
"notAvailable": "Предварительный просмотр недоступен для этого типа файла.",
"downloadToView": "Используйте кнопку загрузки для скачивания файла.",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "Панель управления"
},
"notifications": {
"permissionGranted": "Уведомления о загрузках включены",
"permissionDenied": "Уведомления о загрузках отключены",
"downloadComplete": {
"title": "Загрузка завершена",
"body": "Файл {fileName} успешно загружен"
},
"downloadFailed": {
"title": "Ошибка загрузки",
"body": "Не удалось загрузить {fileName}: {error}",
"unknownError": "Неизвестная ошибка"
},
"queueProcessing": {
"title": "Начало загрузки",
"body": "Файл {fileName} загружается{position}",
"position": " (был №{position} в очереди)"
}
},
"profile": {
"password": {
"title": "Изменить пароль",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "Отправка файлов - Palmr",
"description": "Отправка файлов через общую ссылку"
"description": "Отправка файлов через общую ссылку",
"descriptionWithLimit": "Загрузить файлы (макс. {limit} файлов)"
},
"layout": {
"defaultTitle": "Отправка файлов",
@@ -1280,6 +1302,10 @@
"passwordAuthEnabled": {
"title": "Парольная аутентификация",
"description": "Включить или отключить парольную аутентификацию"
},
"hideVersion": {
"title": "Скрыть Версию",
"description": "Скрыть версию Palmr в нижнем колонтитуле всех страниц"
}
},
"buttons": {
@@ -1341,7 +1367,11 @@
"description": "Этот общий доступ может быть удален или истек."
},
"pageTitle": "Общий доступ",
"downloadAll": "Скачать все"
"downloadAll": "Скачать все",
"metadata": {
"defaultDescription": "Безопасный обмен файлами",
"filesShared": "{count, plural, =1 {1 файл отправлен} other {# файлов отправлено}}"
}
},
"shareActions": {
"deleteTitle": "Удалить Общий Доступ",
@@ -1903,5 +1933,17 @@
"passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
},
"embedCode": {
"title": "Встроить изображение",
"description": "Используйте эти коды для встраивания этого изображения на форумах, веб-сайтах или других платформах",
"tabs": {
"directLink": "Прямая ссылка",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Прямой URL-адрес файла изображения",
"htmlDescription": "Используйте этот код для встраивания изображения в HTML-страницы",
"bbcodeDescription": "Используйте этот код для встраивания изображения на форумах, поддерживающих BBCode"
}
}

View File

@@ -150,7 +150,9 @@
"move": "Taşı",
"rename": "Yeniden Adlandır",
"search": "Ara",
"share": "Paylaş"
"share": "Paylaş",
"copied": "Kopyalandı",
"copy": "Kopyala"
},
"createShare": {
"title": "Paylaşım Oluştur",
@@ -302,6 +304,7 @@
},
"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.",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "Gösterge Paneli"
},
"notifications": {
"permissionGranted": "İndirme bildirimleri etkinleştirildi",
"permissionDenied": "İndirme bildirimleri devre dışı bırakıldı",
"downloadComplete": {
"title": "İndirme Tamamlandı",
"body": "{fileName} indirmesi tamamlandı"
},
"downloadFailed": {
"title": "İndirme Başarısız",
"body": "{fileName} indirilemedi: {error}",
"unknownError": "Bilinmeyen hata"
},
"queueProcessing": {
"title": "İndirme Başlıyor",
"body": "{fileName} şimdi indiriliyor{position}",
"position": " (kuyrukta #{position} sıradaydı)"
}
},
"profile": {
"password": {
"title": "Şifreyi Değiştir",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "Dosya Gönder - Palmr",
"description": "Paylaşılan bağlantı üzerinden dosya gönderin"
"description": "Paylaşılan bağlantı üzerinden dosya gönderin",
"descriptionWithLimit": "Dosya yükle (maks. {limit} dosya)"
},
"layout": {
"defaultTitle": "Dosya Gönder",
@@ -1280,6 +1302,10 @@
"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": {
@@ -1341,7 +1367,11 @@
"description": "Bu paylaşım silinmiş veya süresi dolmuş olabilir."
},
"pageTitle": "Paylaşım",
"downloadAll": "Tümünü İndir"
"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ı}}"
}
},
"shareActions": {
"deleteTitle": "Paylaşımı Sil",
@@ -1903,5 +1933,17 @@
"passwordRequired": "Şifre gerekli",
"nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur"
},
"embedCode": {
"title": "Resmi Yerleştir",
"description": "Bu görüntüyü 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": "Resim dosyasının doğrudan URL'si",
"htmlDescription": "Resmi HTML sayfalarına yerleştirmek için bu kodu kullanın",
"bbcodeDescription": "BBCode destekleyen forumlara resmi yerleştirmek için bu kodu kullanın"
}
}

View File

@@ -150,7 +150,9 @@
"move": "移动",
"rename": "重命名",
"search": "搜索",
"share": "分享"
"share": "分享",
"copied": "已复制",
"copy": "复制"
},
"createShare": {
"title": "创建分享",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "文件预览",
"description": "预览和下载文件",
"loading": "加载中...",
"notAvailable": "此文件类型不支持预览。",
"downloadToView": "使用下载按钮下载文件。",
@@ -550,6 +553,24 @@
"navigation": {
"dashboard": "仪表盘"
},
"notifications": {
"permissionGranted": "下载通知已启用",
"permissionDenied": "下载通知已禁用",
"downloadComplete": {
"title": "下载完成",
"body": "{fileName} 已下载完成"
},
"downloadFailed": {
"title": "下载失败",
"body": "下载 {fileName} 失败:{error}",
"unknownError": "未知错误"
},
"queueProcessing": {
"title": "开始下载",
"body": "{fileName} 正在下载{position}",
"position": "(队列中第 {position} 位)"
}
},
"profile": {
"password": {
"title": "修改密码",
@@ -1038,7 +1059,8 @@
"upload": {
"metadata": {
"title": "上传文件 - Palmr",
"description": "通过共享链接上传文件"
"description": "通过共享链接上传文件",
"descriptionWithLimit": "上传文件(最多 {limit} 个文件)"
},
"layout": {
"defaultTitle": "上传文件",
@@ -1280,6 +1302,10 @@
"passwordAuthEnabled": {
"title": "密码认证",
"description": "启用或禁用基于密码的认证"
},
"hideVersion": {
"title": "隐藏版本",
"description": "在所有页面的页脚中隐藏Palmr版本"
}
},
"buttons": {
@@ -1341,7 +1367,11 @@
"description": "该共享可能已被删除或已过期。"
},
"pageTitle": "共享",
"downloadAll": "下载所有"
"downloadAll": "下载所有",
"metadata": {
"defaultDescription": "安全共享文件",
"filesShared": "{count, plural, =1 {已共享 1 个文件} other {已共享 # 个文件}}"
}
},
"shareActions": {
"deleteTitle": "删除共享",
@@ -1903,5 +1933,17 @@
"passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
},
"embedCode": {
"title": "嵌入图片",
"description": "使用这些代码将此图片嵌入到论坛、网站或其他平台中",
"tabs": {
"directLink": "直接链接",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "图片文件的直接URL",
"htmlDescription": "使用此代码将图片嵌入HTML页面",
"bbcodeDescription": "使用此代码将图片嵌入支持BBCode的论坛"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-web",
"version": "3.2.1-beta",
"version": "3.2.5-beta",
"description": "Frontend for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@@ -11,7 +11,7 @@
"react",
"typescript"
],
"license": "BSD-2-Clause Copyright 2023 Kyantech",
"license": "Apache-2.0",
"packageManager": "pnpm@10.6.0",
"scripts": {
"dev": "next dev -p 3000",
@@ -100,4 +100,4 @@
"tailwindcss": "4.1.11",
"typescript": "5.8.3"
}
}
}

View File

@@ -1,12 +1,18 @@
"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">
@@ -22,7 +28,7 @@ export function TransparentFooter() {
Kyantech Solutions
</p>
</Link>
<span className="text-white text-[11px] mt-1">v{version}</span>
{!shouldHideVersion && <span className="text-white text-[11px] mt-1">v{version}</span>}
</div>
</footer>
);

View File

@@ -1,12 +1,91 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
export async function generateMetadata(): Promise<Metadata> {
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> {
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: t("reverseShares.upload.metadata.title"),
description: t("reverseShares.upload.metadata.description"),
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] : [],
},
};
}

View File

@@ -38,14 +38,13 @@ import {
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { bulkDownloadReverseShareFiles } from "@/http/endpoints/bulk-download";
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";
@@ -472,20 +471,13 @@ export function ReceivedFilesModal({
const handleDownload = async (file: ReverseShareFile) => {
try {
const response = await downloadReverseShareFile(file.id);
// Direct S3 download
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.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
}
};
@@ -610,31 +602,23 @@ export function ReceivedFilesModal({
try {
const zipName = `${reverseShare.name || t("reverseShares.defaultLinkName")}_files.zip`;
const fileIds = selectedFileObjects.map((file) => file.id);
// Show creating ZIP toast
const creatingToast = toast.loading(t("bulkDownload.creatingZip"));
const blob = await bulkDownloadReverseShareFiles({
fileIds,
zipName,
});
// Update toast to success
toast.dismiss(creatingToast);
toast.success(t("bulkDownload.zipCreated"));
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = zipName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
setSelectedFiles(new Set());
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"),
}
);
} catch (error) {
console.error("Error creating ZIP:", error);
}
@@ -916,16 +900,7 @@ export function ReceivedFilesModal({
</Dialog>
{previewFile && (
<ReverseShareFilePreviewModal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
file={{
id: previewFile.id,
name: previewFile.name,
objectName: previewFile.objectName,
extension: previewFile.extension,
}}
/>
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
)}
</>
);

View File

@@ -6,23 +6,12 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { deleteReverseShareFile, downloadReverseShareFile } from "@/http/endpoints/reverse-shares";
import { deleteReverseShareFile } from "@/http/endpoints/reverse-shares";
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
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;
@@ -67,20 +56,13 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
const handleDownload = async (file: ReverseShareFile) => {
try {
const response = await downloadReverseShareFile(file.id);
// Direct S3 download
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.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
}
};
@@ -165,16 +147,7 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
</div>
{previewFile && (
<ReverseShareFilePreviewModal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
file={{
id: previewFile.id,
name: previewFile.name,
objectName: previewFile.objectName,
extension: previewFile.extension,
}}
/>
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
)}
</>
);

View File

@@ -1,26 +1,20 @@
"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: {
id: string;
name: string;
objectName: string;
extension?: string;
} | null;
file: ReverseShareFile | null;
}
export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) {
if (!file) return null;
const adaptedFile = {
name: file.name,
objectName: file.objectName,
type: file.extension,
id: file.id,
...file,
description: file.description ?? undefined,
};
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 justify-between items-center">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<h2 className="text-xl font-semibold">{t("reverseShares.search.title")}</h2>
<div className="flex items-center gap-2">
<div className="flex flex-col sm:flex-row items-stretch sm: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}>
<Button onClick={onCreateReverseShare} className="w-full sm:w-auto">
<IconPlus className="h-4 w-4" />
{t("reverseShares.search.createButton")}
</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 items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<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">
<Button onClick={onBulkDownload} className="flex items-center gap-2 w-full sm:w-auto">
<IconDownload className="w-4 h-4" />
{t("share.downloadAll")}
</Button>

View File

@@ -5,10 +5,13 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { getDownloadUrl } from "@/http/endpoints";
import { bulkDownloadFiles, downloadFolder } from "@/http/endpoints/bulk-download";
import { getShareByAlias } from "@/http/endpoints/index";
import type { Share } from "@/http/endpoints/shares/types";
import {
bulkDownloadShareWithQueue,
downloadFileWithQueue,
downloadShareFolderWithQueue,
} from "@/utils/download-queue-utils";
const createSlug = (name: string): string => {
return name
@@ -226,17 +229,11 @@ export function usePublicShare() {
throw new Error("Share data not available");
}
const blob = await downloadFolder(folderId, folderName);
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${folderName}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
await downloadShareFolderWithQueue(folderId, folderName, share.files || [], share.folders || [], {
silent: true,
showToasts: false,
sharePassword: password,
});
} catch (error) {
console.error("Error downloading folder:", error);
throw error;
@@ -253,24 +250,18 @@ export function usePublicShare() {
error: t("share.errors.downloadFailed"),
});
} else {
const encodedObjectName = encodeURIComponent(objectName);
const params: Record<string, string> = {};
if (password) params.password = password;
const response = await getDownloadUrl(
encodedObjectName,
Object.keys(params).length > 0 ? { params } : undefined
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"),
}
);
// Direct S3 download
const link = document.createElement("a");
link.href = response.data.url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success(t("shareManager.downloadSuccess"));
}
} catch {}
};
@@ -330,31 +321,22 @@ export function usePublicShare() {
return;
}
const fileIds = share.files?.map((file) => file.id) || [];
const folderIds = share.folders?.map((folder) => folder.id) || [];
// Show creating ZIP toast
const creatingToast = toast.loading(t("bulkDownload.creatingZip"));
const blob = await bulkDownloadFiles({
fileIds,
folderIds,
zipName,
});
// Update toast to success
toast.dismiss(creatingToast);
toast.success(t("bulkDownload.zipCreated"));
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = zipName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
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"),
}
);
} catch (error) {
console.error("Error creating ZIP:", error);
}
@@ -390,33 +372,44 @@ export function usePublicShare() {
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`;
const fileIds = files.map((file) => file.id);
const folderIds = folders.map((folder) => folder.id);
// Show creating ZIP toast
const creatingToast = toast.loading(t("bulkDownload.creatingZip"));
const blob = await bulkDownloadFiles({
fileIds,
folderIds,
zipName,
});
// Update toast to success
toast.dismiss(creatingToast);
toast.success(t("bulkDownload.zipCreated"));
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = zipName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
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,15 +1,96 @@
import { Metadata } from "next";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
interface LayoutProps {
children: React.ReactNode;
params: { alias: string };
}
export async function generateMetadata(): Promise<Metadata> {
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> {
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: `${t("share.pageTitle")}`,
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] : [],
},
};
}

View File

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

View File

@@ -1,39 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ folderId: string; folderName: string }> }
) {
try {
const cookieHeader = request.headers.get("cookie");
const { folderId, folderName } = await params;
const apiRes = await fetch(`${API_BASE_URL}/bulk-download/folder/${folderId}/${folderName}`, {
method: "GET",
headers: {
cookie: cookieHeader || "",
},
});
if (!apiRes.ok) {
const errorText = await apiRes.text();
return NextResponse.json({ error: errorText }, { status: apiRes.status });
}
// For binary responses (ZIP files), we need to handle them differently
const buffer = await apiRes.arrayBuffer();
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename=${folderName}.zip`,
},
});
} catch (error) {
console.error("Folder download proxy error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

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 POST(request: NextRequest) {
try {
const cookieHeader = request.headers.get("cookie");
const body = await request.text();
const apiRes = await fetch(`${API_BASE_URL}/bulk-download/reverse-share`, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body,
});
if (!apiRes.ok) {
const errorText = await apiRes.text();
return NextResponse.json({ error: errorText }, { status: apiRes.status });
}
// For binary responses (ZIP files), we need to handle them differently
const buffer = await apiRes.arrayBuffer();
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": apiRes.headers.get("Content-Disposition") || "attachment; filename=download.zip",
},
});
} catch (error) {
console.error("Reverse share bulk download proxy error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

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 POST(request: NextRequest) {
try {
const cookieHeader = request.headers.get("cookie");
const body = await request.text();
const apiRes = await fetch(`${API_BASE_URL}/bulk-download`, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body,
});
if (!apiRes.ok) {
const errorText = await apiRes.text();
return NextResponse.json({ error: errorText }, { status: apiRes.status });
}
// For binary responses (ZIP files), we need to handle them differently
const buffer = await apiRes.arrayBuffer();
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": apiRes.headers.get("Content-Disposition") || "attachment; filename=download.zip",
},
});
} catch (error) {
console.error("Bulk download proxy error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

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 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

@@ -4,13 +4,22 @@ 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;
export async function GET(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const objectName = objectPath.join("/");
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",
},
});
}
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files/${encodeURIComponent(objectName)}/download${queryString ? `?${queryString}` : ""}`;
const url = `${API_BASE_URL}/files/download?${queryString}`;
const apiRes = await fetch(url, {
method: "GET",

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

@@ -15,13 +15,13 @@ export function RecentFiles({ files, fileManager, onOpenUploadModal }: RecentFil
return (
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<CardTitle className="text-xl font-semibold flex items-center gap-2">
<IconCloudUpload className="text-xl text-gray-500" />
{t("recentFiles.title")}
</CardTitle>
<div className="flex items-center gap-2">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Button
className="font-semibold text-sm cursor-pointer"
variant="outline"

View File

@@ -16,13 +16,13 @@ export function RecentShares({ shares, shareManager, onOpenCreateModal, onCopyLi
<Card>
<CardContent>
<div className="flex flex-col gap-6">
<div className="flex justify-between items-center">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<IconShare className="text-xl text-gray-500" />
{t("recentShares.title")}
</h2>
<div className="flex items-center gap-2">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Button
className="font-semibold text-sm cursor-pointer"
variant="outline"

View File

@@ -46,6 +46,8 @@ export default function DashboardPage() {
icon={<IconLayoutDashboardFilled className="text-xl" />}
showBreadcrumb={false}
title={t("dashboard.pageTitle")}
pendingDownloads={fileManager.pendingDownloads}
onCancelDownload={fileManager.cancelPendingDownload}
>
<StorageUsage diskSpace={diskSpace} diskSpaceError={diskSpaceError} onRetry={handleRetryDiskSpace} />
<QuickAccessCards />

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
/**
* Short public embed endpoint: /e/{id}
* No authentication required
* Only works for media files (images, videos, audio)
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (!id) {
return new NextResponse(JSON.stringify({ error: "File ID is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const url = `${API_BASE_URL}/embed/${id}`;
try {
const apiRes = await fetch(url, {
method: "GET",
redirect: "manual",
});
if (!apiRes.ok) {
const errorText = await apiRes.text();
return new NextResponse(errorText, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}
const blob = await apiRes.blob();
const contentType = apiRes.headers.get("content-type") || "application/octet-stream";
const contentDisposition = apiRes.headers.get("content-disposition");
const cacheControl = apiRes.headers.get("cache-control");
const res = new NextResponse(blob, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
},
});
if (contentDisposition) {
res.headers.set("Content-Disposition", contentDisposition);
}
if (cacheControl) {
res.headers.set("Cache-Control", cacheControl);
} else {
res.headers.set("Cache-Control", "public, max-age=31536000");
}
return res;
} catch (error) {
console.error("Error proxying embed request:", error);
return new NextResponse(JSON.stringify({ error: "Failed to fetch file" }), {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
}

View File

@@ -8,16 +8,16 @@ export function Header({ onUpload, onCreateFolder }: HeaderProps) {
const t = useTranslations();
return (
<div className="flex justify-between items-center">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<h2 className="text-xl font-semibold">{t("files.title")}</h2>
<div className="flex items-center gap-2">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
{onCreateFolder && (
<Button variant="outline" onClick={onCreateFolder}>
<Button variant="outline" onClick={onCreateFolder} className="w-full sm:w-auto">
<IconFolderPlus className="h-4 w-4" />
{t("folderActions.createFolder")}
</Button>
)}
<Button variant="default" onClick={onUpload}>
<Button variant="default" onClick={onUpload} className="w-full sm:w-auto">
<IconCloudUpload className="h-4 w-4" />
{t("files.uploadFile")}
</Button>

View File

@@ -114,11 +114,16 @@ export default function FilesPage() {
return (
<ProtectedRoute>
<GlobalDropZone onSuccess={loadFiles}>
<GlobalDropZone
onSuccess={loadFiles}
currentFolderId={currentPath.length > 0 ? currentPath[currentPath.length - 1].id : null}
>
<FileManagerLayout
breadcrumbLabel={t("files.breadcrumb")}
icon={<IconFolderOpen size={20} />}
title={t("files.pageTitle")}
pendingDownloads={fileManager.pendingDownloads}
onCancelDownload={fileManager.cancelPendingDownload}
>
<Card>
<CardContent>

View File

@@ -35,6 +35,7 @@ export const createFieldDescriptions = (t: ReturnType<typeof createTranslator>)
appName: t("settings.fields.appName.description"),
appDescription: t("settings.fields.appDescription.description"),
showHomePage: t("settings.fields.showHomePage.description"),
hideVersion: t("settings.fields.hideVersion.description"),
firstUserAccess: t("settings.fields.firstUserAccess.description"),
serverUrl: t("settings.fields.serverUrl.description"),
@@ -70,6 +71,7 @@ export const createFieldTitles = (t: ReturnType<typeof createTranslator>) => ({
appName: t("settings.fields.appName.title"),
appDescription: t("settings.fields.appDescription.title"),
showHomePage: t("settings.fields.showHomePage.title"),
hideVersion: t("settings.fields.hideVersion.title"),
firstUserAccess: t("settings.fields.firstUserAccess.title"),
serverUrl: t("settings.fields.serverUrl.title"),

View File

@@ -0,0 +1,268 @@
"use client";
import { useEffect, useState } from "react";
import {
IconAlertCircle,
IconBell,
IconBellOff,
IconClock,
IconDownload,
IconLoader2,
IconX,
} from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useDownloadQueue } from "@/hooks/use-download-queue";
import { usePushNotifications } from "@/hooks/use-push-notifications";
import { formatFileSize } from "@/utils/format-file-size";
interface PendingDownload {
downloadId: string;
fileName: string;
objectName: string;
startTime: number;
status: "pending" | "queued" | "downloading" | "completed" | "failed";
}
interface DownloadQueueIndicatorProps {
pendingDownloads?: PendingDownload[];
onCancelDownload?: (downloadId: string) => void;
className?: string;
}
export function DownloadQueueIndicator({
pendingDownloads = [],
onCancelDownload,
className = "",
}: DownloadQueueIndicatorProps) {
const t = useTranslations();
const shouldAutoRefresh = pendingDownloads.length > 0;
const { queueStatus, refreshQueue, cancelDownload, getEstimatedWaitTime } = useDownloadQueue(shouldAutoRefresh);
const notifications = usePushNotifications();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (pendingDownloads.length > 0 || (queueStatus && queueStatus.queueLength > 0)) {
setIsOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingDownloads.length, queueStatus?.queueLength]);
const totalDownloads = pendingDownloads.length + (queueStatus?.queueLength || 0);
const activeDownloads = queueStatus?.activeDownloads || 0;
if (totalDownloads === 0 && activeDownloads === 0) {
return null;
}
const getStatusIcon = (status: string) => {
switch (status) {
case "pending":
return <IconLoader2 className="h-4 w-4 animate-spin text-blue-500" />;
case "queued":
return <IconClock className="h-4 w-4 text-yellow-500" />;
case "downloading":
return <IconDownload className="h-4 w-4 text-green-500" />;
case "completed":
return <IconDownload className="h-4 w-4 text-green-600" />;
case "failed":
return <IconAlertCircle className="h-4 w-4 text-red-500" />;
default:
return <IconLoader2 className="h-4 w-4 animate-spin" />;
}
};
const getStatusText = (status: string) => {
switch (status) {
case "pending":
return t("downloadQueue.status.pending");
case "queued":
return t("downloadQueue.status.queued");
case "downloading":
return t("downloadQueue.status.downloading");
case "completed":
return t("downloadQueue.status.completed");
case "failed":
return t("downloadQueue.status.failed");
default:
return status;
}
};
return (
<div className={`fixed bottom-6 right-6 z-50 max-w-sm ${className}`} data-download-indicator>
<div className="flex flex-col gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(!isOpen)}
className="min-w-fit bg-background/80 backdrop-blur-md border-border/50 shadow-lg hover:shadow-xl transition-all duration-200 text-sm font-medium"
>
<IconDownload className="h-4 w-4 mr-2 text-primary" />
Downloads
{totalDownloads > 0 && (
<Badge variant="secondary" className="ml-2 text-xs font-semibold bg-primary/10 text-primary border-0">
{totalDownloads}
</Badge>
)}
</Button>
{isOpen && (
<div className="border border-border/50 rounded-xl bg-background/95 backdrop-blur-md shadow-xl animate-in slide-in-from-bottom-2 duration-200">
<div className="p-4 border-b border-border/50">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm text-foreground">Download Manager</h3>
<div className="flex items-center gap-2">
{notifications.isSupported && (
<Button
variant="ghost"
size="sm"
onClick={notifications.requestPermission}
className="h-7 w-7 p-0 rounded-md hover:bg-muted/80"
title={
notifications.hasPermission
? t("notifications.permissionGranted")
: "Enable download notifications"
}
>
{notifications.hasPermission ? (
<IconBell className="h-3.5 w-3.5 text-green-600" />
) : (
<IconBellOff className="h-3.5 w-3.5 text-muted-foreground" />
)}
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setIsOpen(false)}
className="h-7 w-7 p-0 rounded-md hover:bg-muted/80"
>
<IconX className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</div>
{queueStatus && (
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Active:</span>
<span className="font-medium text-foreground">
{activeDownloads}/{queueStatus.maxConcurrent}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Queued:</span>
<span className="font-medium text-foreground">
{queueStatus.queueLength}/{queueStatus.maxQueueSize}
</span>
</div>
{queueStatus.maxConcurrent > 0 && (
<div className="space-y-1">
<Progress value={(activeDownloads / queueStatus.maxConcurrent) * 100} className="h-1.5" />
<p className="text-xs text-muted-foreground">
{Math.round((activeDownloads / queueStatus.maxConcurrent) * 100)}% capacity
</p>
</div>
)}
</div>
)}
</div>
<div className="p-3 space-y-2">
{pendingDownloads.map((download) => (
<div
key={download.downloadId}
className="group flex items-center justify-between p-2.5 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors border border-transparent hover:border-border/50"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="shrink-0">{getStatusIcon(download.status)}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate leading-tight">{download.fileName}</p>
<p className="text-xs text-muted-foreground mt-0.5">{getStatusText(download.status)}</p>
</div>
</div>
{(download.status === "pending" || download.status === "queued") && onCancelDownload && (
<Button
variant="ghost"
size="sm"
onClick={() => onCancelDownload(download.downloadId)}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 hover:bg-destructive/10 hover:text-destructive"
>
<IconX className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
{(queueStatus?.queuedDownloads || []).map((download) => {
const waitTime = getEstimatedWaitTime(download.downloadId);
return (
<div
key={download.downloadId}
className="group flex items-center justify-between p-2.5 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors border border-transparent hover:border-border/50"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="shrink-0">
<IconClock className="h-4 w-4 text-amber-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate leading-tight">
{download.fileName || t("downloadQueue.indicator.unknownFile")}
</p>
<div className="text-xs text-muted-foreground space-y-0.5">
<div className="flex items-center gap-2">
<span>#{download.position} in queue</span>
{download.fileSize && (
<span className="text-muted-foreground/70"> {formatFileSize(download.fileSize)}</span>
)}
</div>
{waitTime && <p className="text-xs text-muted-foreground/80">~{waitTime} remaining</p>}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => cancelDownload(download.downloadId)}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 hover:bg-destructive/10 hover:text-destructive"
>
<IconX className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
{totalDownloads === 0 && (
<div className="text-center py-8">
<IconDownload className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No active downloads</p>
</div>
)}
</div>
{queueStatus && queueStatus.queueLength > 0 && (
<div className="p-3 border-t border-border/50">
<Button
variant="outline"
size="sm"
onClick={refreshQueue}
className="w-full text-xs font-medium hover:bg-muted/80"
>
Refresh Queue
</Button>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface EmbedCodeDisplayProps {
imageUrl: string;
fileName: string;
fileId: string;
}
export function EmbedCodeDisplay({ imageUrl, fileName, fileId }: EmbedCodeDisplayProps) {
const t = useTranslations();
const [copiedType, setCopiedType] = useState<string | null>(null);
const [fullUrl, setFullUrl] = useState<string>("");
useEffect(() => {
if (typeof window !== "undefined") {
const origin = window.location.origin;
const embedUrl = `${origin}/e/${fileId}`;
setFullUrl(embedUrl);
}
}, [fileId]);
const directLink = fullUrl || imageUrl;
const htmlCode = `<img src="${directLink}" alt="${fileName}" />`;
const bbCode = `[img]${directLink}[/img]`;
const copyToClipboard = async (text: string, type: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedType(type);
setTimeout(() => setCopiedType(null), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
return (
<Card>
<CardContent>
<div className="space-y-4">
<div>
<Label className="text-sm font-semibold">{t("embedCode.title")}</Label>
<p className="text-xs text-muted-foreground mt-1">{t("embedCode.description")}</p>
</div>
<Tabs defaultValue="direct" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="direct" className="cursor-pointer">
{t("embedCode.tabs.directLink")}
</TabsTrigger>
<TabsTrigger value="html" className="cursor-pointer">
{t("embedCode.tabs.html")}
</TabsTrigger>
<TabsTrigger value="bbcode" className="cursor-pointer">
{t("embedCode.tabs.bbcode")}
</TabsTrigger>
</TabsList>
<TabsContent value="direct" className="space-y-2">
<div className="flex gap-2">
<input
type="text"
readOnly
value={directLink}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button
size="default"
variant="outline"
onClick={() => copyToClipboard(directLink, "direct")}
className="shrink-0 h-full"
>
{copiedType === "direct" ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("embedCode.directLinkDescription")}</p>
</TabsContent>
<TabsContent value="html" className="space-y-2">
<div className="flex gap-2">
<input
type="text"
readOnly
value={htmlCode}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button variant="outline" onClick={() => copyToClipboard(htmlCode, "html")} className="shrink-0 h-full">
{copiedType === "html" ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("embedCode.htmlDescription")}</p>
</TabsContent>
<TabsContent value="bbcode" className="space-y-2">
<div className="flex gap-2">
<input
type="text"
readOnly
value={bbCode}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button variant="outline" onClick={() => copyToClipboard(bbCode, "bbcode")} className="shrink-0 h-full">
{copiedType === "bbcode" ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("embedCode.bbcodeDescription")}</p>
</TabsContent>
</Tabs>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
interface MediaEmbedLinkProps {
fileId: string;
}
export function MediaEmbedLink({ fileId }: MediaEmbedLinkProps) {
const t = useTranslations();
const [copied, setCopied] = useState(false);
const [embedUrl, setEmbedUrl] = useState<string>("");
useEffect(() => {
if (typeof window !== "undefined") {
const origin = window.location.origin;
const url = `${origin}/e/${fileId}`;
setEmbedUrl(url);
}
}, [fileId]);
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(embedUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
return (
<Card>
<CardContent>
<div className="space-y-3">
<div>
<Label className="text-sm font-semibold">{t("embedCode.title")}</Label>
<p className="text-xs text-muted-foreground mt-1">{t("embedCode.directLinkDescription")}</p>
</div>
<div className="flex gap-2">
<input
type="text"
readOnly
value={embedUrl}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button size="default" variant="outline" onClick={copyToClipboard} className="shrink-0 h-full">
{copied ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -243,8 +243,13 @@ export function GlobalDropZone({ onSuccess, children, currentFolderId }: GlobalD
const handlePaste = useCallback(
(event: ClipboardEvent) => {
event.preventDefault();
event.stopPropagation();
const target = event.target as HTMLElement;
const isInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
const isPasswordInput = target.tagName === "INPUT" && (target as HTMLInputElement).type === "password";
if (isInput && !isPasswordInput) {
return;
}
const items = event.clipboardData?.items;
if (!items) return;
@@ -253,6 +258,9 @@ export function GlobalDropZone({ onSuccess, children, currentFolderId }: GlobalD
if (imageItems.length === 0) return;
event.preventDefault();
event.stopPropagation();
const newUploads: FileUpload[] = [];
imageItems.forEach((item) => {

View File

@@ -3,6 +3,7 @@ import Link from "next/link";
import { IconLayoutDashboard } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { DownloadQueueIndicator } from "@/components/download-queue-indicator";
import { Navbar } from "@/components/layout/navbar";
import {
Breadcrumb,
@@ -20,6 +21,14 @@ interface FileManagerLayoutProps {
icon: ReactNode;
breadcrumbLabel?: string;
showBreadcrumb?: boolean;
pendingDownloads?: Array<{
downloadId: string;
fileName: string;
objectName: string;
startTime: number;
status: "pending" | "queued" | "downloading" | "completed" | "failed";
}>;
onCancelDownload?: (downloadId: string) => void;
}
export function FileManagerLayout({
@@ -28,6 +37,8 @@ export function FileManagerLayout({
icon,
breadcrumbLabel,
showBreadcrumb = true,
pendingDownloads = [],
onCancelDownload,
}: FileManagerLayoutProps) {
const t = useTranslations();
@@ -68,6 +79,8 @@ export function FileManagerLayout({
</div>
</div>
<DefaultFooter />
<DownloadQueueIndicator pendingDownloads={pendingDownloads} onCancelDownload={onCancelDownload} />
</div>
);
}

View File

@@ -3,10 +3,20 @@
import { IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { EmbedCodeDisplay } from "@/components/files/embed-code-display";
import { MediaEmbedLink } from "@/components/files/media-embed-link";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useFilePreview } from "@/hooks/use-file-preview";
import { getFileIcon } from "@/utils/file-icons";
import { getFileType } from "@/utils/file-types";
import { FilePreviewRenderer } from "./previews";
interface FilePreviewModalProps {
@@ -32,6 +42,10 @@ export function FilePreviewModal({
}: FilePreviewModalProps) {
const t = useTranslations();
const previewState = useFilePreview({ file, isOpen, isReverseShare, sharePassword });
const fileType = getFileType(file.name);
const isImage = fileType === "image";
const isVideo = fileType === "video";
const isAudio = fileType === "audio";
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -44,6 +58,7 @@ export function FilePreviewModal({
})()}
<span className="truncate">{file.name}</span>
</DialogTitle>
<DialogDescription className="sr-only">{t("filePreview.description")}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto">
<FilePreviewRenderer
@@ -59,6 +74,16 @@ export function FilePreviewModal({
description={file.description}
onDownload={previewState.handleDownload}
/>
{isImage && previewState.previewUrl && !previewState.isLoading && file.id && (
<div className="mt-4 mb-2">
<EmbedCodeDisplay imageUrl={previewState.previewUrl} fileName={file.name} fileId={file.id} />
</div>
)}
{(isVideo || isAudio) && !previewState.isLoading && file.id && (
<div className="mt-4 mb-2">
<MediaEmbedLink fileId={file.id} />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>

View File

@@ -163,8 +163,7 @@ export function FilesGrid({
try {
loadingUrls.current.add(file.objectName);
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const response = await getDownloadUrl(file.objectName);
if (!componentMounted.current) break;

View File

@@ -1,12 +1,19 @@
"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 DefaultFooter() {
const t = useTranslations();
const { value: hideVersion } = useSecureConfigValue("hideVersion");
const shouldHideVersion = hideVersion === "true";
return (
<footer className="w-full flex items-center justify-center py-3 h-16">
@@ -20,7 +27,7 @@ export function DefaultFooter() {
<span className="text-default-600 text-xs sm:text-sm">{t("footer.poweredBy")}</span>
<p className="text-primary text-xs sm:text-sm">Kyantech Solutions</p>
</Link>
<span className="text-default-500 text-[11px] mt-1">v{version}</span>
{!shouldHideVersion && <span className="text-default-500 text-[11px] mt-1">v{version}</span>}
</div>
</footer>
);

View File

@@ -0,0 +1,144 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import {
cancelQueuedDownload,
getDownloadQueueStatus,
type DownloadQueueStatus,
} from "@/http/endpoints/download-queue";
export interface DownloadQueueHook {
queueStatus: DownloadQueueStatus | null;
isLoading: boolean;
error: string | null;
refreshQueue: () => Promise<void>;
cancelDownload: (downloadId: string) => Promise<void>;
getQueuePosition: (downloadId: string) => number | null;
isDownloadQueued: (downloadId: string) => boolean;
getEstimatedWaitTime: (downloadId: string) => string | null;
}
export function useDownloadQueue(autoRefresh = true, initialIntervalMs = 3000) {
const t = useTranslations();
const [queueStatus, setQueueStatus] = useState<DownloadQueueStatus | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentInterval, setCurrentInterval] = useState(initialIntervalMs);
const [noActivityCount, setNoActivityCount] = useState(0);
const refreshQueue = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const response = await getDownloadQueueStatus();
const newStatus = response.data;
const hasActivity = newStatus.activeDownloads > 0 || newStatus.queueLength > 0;
const previousActivity = (queueStatus?.activeDownloads || 0) > 0 || (queueStatus?.queueLength || 0) > 0;
const statusChanged = JSON.stringify(queueStatus) !== JSON.stringify(newStatus);
if (!hasActivity && !previousActivity && !statusChanged) {
setNoActivityCount((prev) => prev + 1);
} else {
setNoActivityCount(0);
setCurrentInterval(initialIntervalMs);
}
setQueueStatus(newStatus);
} catch (err: any) {
const errorMessage = err?.response?.data?.error || err?.message || "Failed to fetch queue status";
setError(errorMessage);
console.error("Error fetching download queue status:", err);
} finally {
setIsLoading(false);
}
}, [queueStatus, initialIntervalMs]);
const cancelDownload = useCallback(
async (downloadId: string) => {
try {
await cancelQueuedDownload(downloadId);
toast.success(t("downloadQueue.cancelSuccess"));
await refreshQueue();
} catch (err: any) {
const errorMessage = err?.response?.data?.error || err?.message || "Failed to cancel download";
toast.error(t("downloadQueue.cancelError", { error: errorMessage }));
console.error("Error cancelling download:", err);
}
},
[refreshQueue, t]
);
const getQueuePosition = useCallback(
(downloadId: string): number | null => {
if (!queueStatus) return null;
const download = queueStatus.queuedDownloads.find((d) => d.downloadId === downloadId);
return download?.position || null;
},
[queueStatus]
);
const isDownloadQueued = useCallback(
(downloadId: string): boolean => {
if (!queueStatus) return false;
return queueStatus.queuedDownloads.some((d) => d.downloadId === downloadId);
},
[queueStatus]
);
const getEstimatedWaitTime = useCallback(
(downloadId: string): string | null => {
if (!queueStatus) return null;
const download = queueStatus.queuedDownloads.find((d) => d.downloadId === downloadId);
if (!download) return null;
const waitTimeMs = download.waitTime;
const waitTimeSeconds = Math.floor(waitTimeMs / 1000);
if (waitTimeSeconds < 60) {
return t("downloadQueue.waitTime.seconds", { seconds: waitTimeSeconds });
} else if (waitTimeSeconds < 3600) {
const minutes = Math.floor(waitTimeSeconds / 60);
return t("downloadQueue.waitTime.minutes", { minutes });
} else {
const hours = Math.floor(waitTimeSeconds / 3600);
const minutes = Math.floor((waitTimeSeconds % 3600) / 60);
return t("downloadQueue.waitTime.hoursMinutes", { hours, minutes });
}
},
[queueStatus, t]
);
useEffect(() => {
if (!autoRefresh) return;
let actualInterval = currentInterval;
if (noActivityCount > 5) {
console.log("[DOWNLOAD QUEUE] No activity detected, stopping polling");
return;
} else if (noActivityCount > 2) {
actualInterval = 10000;
setCurrentInterval(10000);
}
refreshQueue();
const interval = setInterval(refreshQueue, actualInterval);
return () => clearInterval(interval);
}, [autoRefresh, refreshQueue, currentInterval, noActivityCount]);
return {
queueStatus,
isLoading,
error,
refreshQueue,
cancelDownload,
getQueuePosition,
isDownloadQueued,
getEstimatedWaitTime,
};
}

View File

@@ -1,10 +1,11 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { deleteFile, getDownloadUrl, updateFile } from "@/http/endpoints";
import { bulkDownloadFiles, downloadFolder } from "@/http/endpoints/bulk-download";
import { deleteFolder, registerFolder, updateFolder } from "@/http/endpoints/folders";
import { useDownloadQueue } from "./use-download-queue";
import { usePushNotifications } from "./use-push-notifications";
interface FileToRename {
id: string;
@@ -150,6 +151,8 @@ export interface EnhancedFileManagerHook {
export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSelection?: () => void) {
const t = useTranslations();
const downloadQueue = useDownloadQueue(true, 3000);
const notifications = usePushNotifications();
const [previewFile, setPreviewFile] = useState<PreviewFile | null>(null);
const [fileToRename, setFileToRename] = useState<FileToRename | null>(null);
@@ -171,33 +174,123 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
const [foldersToShare, setFoldersToShare] = useState<BulkFolder[] | null>(null);
const [foldersToDownload, setFoldersToDownload] = useState<BulkFolder[] | null>(null);
const startActualDownload = async (
downloadId: string,
objectName: string,
fileName: string,
downloadUrl?: string
) => {
try {
setPendingDownloads((prev) =>
prev.map((d) => (d.downloadId === downloadId ? { ...d, status: "downloading" } : d))
);
let url = downloadUrl;
if (!url) {
const response = await getDownloadUrl(objectName);
url = response.data.url;
}
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const wasQueued = pendingDownloads.some((d) => d.downloadId === downloadId);
if (wasQueued) {
setPendingDownloads((prev) =>
prev.map((d) => (d.downloadId === downloadId ? { ...d, status: "completed" } : d))
);
const completedDownload = pendingDownloads.find((d) => d.downloadId === downloadId);
if (completedDownload) {
const fileSize = completedDownload.startTime ? Date.now() - completedDownload.startTime : undefined;
await notifications.notifyDownloadComplete(fileName, fileSize);
}
setTimeout(() => {
setPendingDownloads((prev) => prev.filter((d) => d.downloadId !== downloadId));
}, 5000);
}
if (!wasQueued) {
toast.success(t("files.downloadStart", { fileName }));
}
} catch (error: any) {
const wasQueued = pendingDownloads.some((d) => d.downloadId === downloadId);
if (wasQueued) {
setPendingDownloads((prev) => prev.map((d) => (d.downloadId === downloadId ? { ...d, status: "failed" } : d)));
const errorMessage =
error?.response?.data?.message || error?.message || t("notifications.downloadFailed.unknownError");
await notifications.notifyDownloadFailed(fileName, errorMessage);
setTimeout(() => {
setPendingDownloads((prev) => prev.filter((d) => d.downloadId !== downloadId));
}, 10000);
}
if (!pendingDownloads.some((d) => d.downloadId === downloadId)) {
toast.error(t("files.downloadError"));
}
throw error;
}
};
useEffect(() => {
if (!downloadQueue.queueStatus) return;
pendingDownloads.forEach(async (download) => {
if (download.status === "queued") {
const stillQueued = downloadQueue.queueStatus?.queuedDownloads.find((qd) => qd.fileName === download.fileName);
if (!stillQueued) {
console.log(`[DOWNLOAD] Processing queued download: ${download.fileName}`);
await notifications.notifyQueueProcessing(download.fileName);
await startActualDownload(download.downloadId, download.objectName, download.fileName);
}
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [downloadQueue.queueStatus, pendingDownloads, notifications]);
const setClearSelectionCallback = useCallback((callback: () => void) => {
setClearSelectionCallbackState(() => callback);
}, []);
const handleDownload = async (objectName: string, fileName: string) => {
try {
const encodedObjectName = encodeURIComponent(objectName);
const response = await getDownloadUrl(encodedObjectName);
const { downloadFileWithQueue } = await import("@/utils/download-queue-utils");
// Direct S3 download - no queue needed
const link = document.createElement("a");
link.href = response.data.url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success(t("shareManager.downloadSuccess"));
await toast.promise(
downloadFileWithQueue(objectName, fileName, {
silent: true,
showToasts: false,
}),
{
loading: t("share.messages.downloadStarted"),
success: t("shareManager.downloadSuccess"),
error: t("share.errors.downloadFailed"),
}
);
} catch (error) {
console.error("Download error:", error);
toast.error(t("share.errors.downloadFailed"));
}
};
const cancelPendingDownload = async (downloadId: string) => {
// Queue functionality removed - just remove from local state
setPendingDownloads((prev) => prev.filter((d) => d.downloadId !== downloadId));
try {
await downloadQueue.cancelDownload(downloadId);
setPendingDownloads((prev) => prev.filter((d) => d.downloadId !== downloadId));
} catch (error) {
console.error("Error cancelling download:", error);
}
};
const getDownloadStatus = useCallback(
@@ -271,78 +364,68 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
const handleSingleFolderDownload = async (folderId: string, folderName: string) => {
try {
// Show creating ZIP toast
const creatingToast = toast.loading(t("bulkDownload.creatingZip"));
const { downloadFolderWithQueue } = await import("@/utils/download-queue-utils");
const blob = await downloadFolder(folderId, folderName);
// Update toast to success
toast.dismiss(creatingToast);
toast.success(t("bulkDownload.zipCreated"));
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${folderName}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
await toast.promise(
downloadFolderWithQueue(folderId, folderName, {
silent: true,
showToasts: false,
}),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("share.errors.downloadFailed"),
}
);
} catch (error) {
console.error("Folder download error:", error);
toast.error(t("bulkDownload.zipError"));
console.error("Error downloading folder:", error);
}
};
const handleBulkDownloadWithZip = async (files: BulkFile[], zipName: string) => {
try {
const folders = foldersToDownload || [];
const { bulkDownloadWithQueue } = await import("@/utils/download-queue-utils");
if (files.length === 0 && folders.length === 0) {
const allItems = [
...files.map((file) => ({
objectName: file.objectName,
name: file.relativePath || file.name,
isReverseShare: false,
type: "file" as const,
})),
...folders.map((folder) => ({
id: folder.id,
name: folder.name,
type: "folder" as const,
})),
];
if (allItems.length === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
const fileIds = files.map((file) => file.id);
const folderIds = folders.map((folder) => folder.id);
// Show creating ZIP toast
const creatingToast = toast.loading(t("bulkDownload.creatingZip"));
const blob = await bulkDownloadFiles({
fileIds,
folderIds,
zipName,
});
// Update toast to success
toast.dismiss(creatingToast);
toast.success(t("bulkDownload.zipCreated"));
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = zipName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
setBulkDownloadModalOpen(false);
setFilesToDownload(null);
setFoldersToDownload(null);
if (clearSelectionCallback) {
clearSelectionCallback();
}
toast.promise(
bulkDownloadWithQueue(allItems, zipName, undefined, false).then(() => {
setBulkDownloadModalOpen(false);
setFilesToDownload(null);
setFoldersToDownload(null);
if (clearSelectionCallback) {
clearSelectionCallback();
}
}),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
}
);
} catch (error) {
console.error("Error in bulk download:", error);
setBulkDownloadModalOpen(false);
setFilesToDownload(null);
setFoldersToDownload(null);
toast.error(t("bulkDownload.zipError"));
}
};

View File

@@ -4,6 +4,7 @@ import { toast } from "sonner";
import { getDownloadUrl } from "@/http/endpoints";
import { downloadReverseShareFile } from "@/http/endpoints/reverse-shares";
import { downloadFileWithQueue, downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
import { getFileExtension, getFileType, type FileType } from "@/utils/file-types";
interface FilePreviewState {
@@ -180,12 +181,11 @@ export function useFilePreview({ file, isOpen, isReverseShare = false, sharePass
const response = await downloadReverseShareFile(file.id!);
url = response.data.url;
} else {
const encodedObjectName = encodeURIComponent(file.objectName);
const params: Record<string, string> = {};
if (sharePassword) params.password = sharePassword;
const response = await getDownloadUrl(
encodedObjectName,
file.objectName,
Object.keys(params).length > 0
? {
params: { ...params },
@@ -242,40 +242,17 @@ export function useFilePreview({ file, isOpen, isReverseShare = false, sharePass
try {
if (isReverseShare) {
const response = await downloadReverseShareFile(file.id!);
// Direct S3 download
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.success(t("filePreview.downloadSuccess"));
await downloadReverseShareWithQueue(file.id!, file.name, {
onFail: () => toast.error(t("filePreview.downloadError")),
});
} else {
const encodedObjectName = encodeURIComponent(file.objectName);
const params: Record<string, string> = {};
if (sharePassword) params.password = sharePassword;
const response = await getDownloadUrl(
encodedObjectName,
Object.keys(params).length > 0 ? { params } : undefined
);
// Direct S3 download
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.success(t("filePreview.downloadSuccess"));
await downloadFileWithQueue(file.objectName, file.name, {
sharePassword,
onFail: () => toast.error(t("filePreview.downloadError")),
});
}
} catch (error) {
console.error("Download error:", error);
toast.error(t("filePreview.downloadError"));
}
}, [isReverseShare, file.id, file.objectName, file.name, sharePassword, t]);

View File

@@ -0,0 +1,185 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
interface NotificationOptions {
title: string;
body: string;
icon?: string;
badge?: string;
tag?: string;
requireInteraction?: boolean;
silent?: boolean;
data?: any;
}
export function usePushNotifications() {
const t = useTranslations();
const [permissionGranted, setPermissionGranted] = useState(false);
const isSupported = useRef(typeof window !== "undefined" && "Notification" in window);
const requestPermission = useCallback(async (): Promise<boolean> => {
if (!isSupported.current) {
console.warn("Push notifications are not supported in this browser");
return false;
}
try {
const permission = await Notification.requestPermission();
const granted = permission === "granted";
setPermissionGranted(granted);
if (permission === "granted") {
console.log("🔔 Push notifications enabled");
toast.success(t("notifications.permissionGranted"));
} else if (permission === "denied") {
console.warn("🚫 Push notifications denied");
toast.warning(t("notifications.permissionDenied"));
} else {
console.info("⏸️ Push notifications dismissed");
}
return granted;
} catch (error) {
console.error("Error requesting notification permission:", error);
return false;
}
}, [t]);
const sendNotification = useCallback(
async (options: NotificationOptions): Promise<boolean> => {
if (!isSupported.current) {
console.warn("Push notifications not supported");
return false;
}
if (Notification.permission !== "granted") {
const granted = await requestPermission();
if (!granted) return false;
}
try {
const notification = new Notification(options.title, {
body: options.body,
icon: options.icon || "/favicon.ico",
badge: options.badge,
tag: options.tag,
requireInteraction: options.requireInteraction ?? false,
silent: options.silent ?? false,
data: options.data,
});
if (!options.requireInteraction) {
setTimeout(() => {
notification.close();
}, 5000);
}
notification.onclick = (event) => {
event.preventDefault();
window.focus();
notification.close();
if (options.data?.action === "focus-downloads") {
const downloadIndicator = document.querySelector("[data-download-indicator]");
if (downloadIndicator) {
downloadIndicator.scrollIntoView({ behavior: "smooth" });
}
}
};
return true;
} catch (error) {
console.error("Error sending notification:", error);
return false;
}
},
[requestPermission]
);
useEffect(() => {
if (isSupported.current) {
setPermissionGranted(Notification.permission === "granted");
}
}, []);
const notifyDownloadComplete = useCallback(
async (fileName: string, fileSize?: number) => {
const sizeText = fileSize ? ` (${(fileSize / 1024 / 1024).toFixed(1)}MB)` : "";
return sendNotification({
title: t("notifications.downloadComplete.title"),
body: t("notifications.downloadComplete.body", {
fileName: fileName + sizeText,
}),
icon: "/favicon.ico",
tag: `download-complete-${Date.now()}`,
requireInteraction: false,
data: {
action: "focus-downloads",
type: "download-complete",
fileName,
fileSize,
},
});
},
[sendNotification, t]
);
const notifyDownloadFailed = useCallback(
async (fileName: string, error?: string) => {
return sendNotification({
title: t("notifications.downloadFailed.title"),
body: t("notifications.downloadFailed.body", {
fileName,
error: error || t("notifications.downloadFailed.unknownError"),
}),
icon: "/favicon.ico",
tag: `download-failed-${Date.now()}`,
requireInteraction: true,
data: {
action: "focus-downloads",
type: "download-failed",
fileName,
error,
},
});
},
[sendNotification, t]
);
const notifyQueueProcessing = useCallback(
async (fileName: string, position?: number) => {
const positionText = position ? t("notifications.queueProcessing.position", { position }) : "";
return sendNotification({
title: t("notifications.queueProcessing.title"),
body: t("notifications.queueProcessing.body", {
fileName,
position: positionText,
}),
icon: "/favicon.ico",
tag: `queue-processing-${Date.now()}`,
requireInteraction: false,
silent: true,
data: {
action: "focus-downloads",
type: "queue-processing",
fileName,
position,
},
});
},
[sendNotification, t]
);
return {
isSupported: isSupported.current,
hasPermission: permissionGranted,
requestPermission,
sendNotification,
notifyDownloadComplete,
notifyDownloadFailed,
notifyQueueProcessing,
};
}

View File

@@ -4,17 +4,10 @@ import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import {
addRecipients,
createShareAlias,
deleteShare,
getDownloadUrl,
notifyRecipients,
updateShare,
} from "@/http/endpoints";
import { bulkDownloadFiles } from "@/http/endpoints/bulk-download";
import { addRecipients, createShareAlias, deleteShare, notifyRecipients, updateShare } from "@/http/endpoints";
import { updateFolder } from "@/http/endpoints/folders";
import type { Share } from "@/http/endpoints/shares/types";
import { bulkDownloadShareWithQueue, downloadFileWithQueue } from "@/utils/download-queue-utils";
export interface ShareManagerHook {
shareToDelete: Share | null;
@@ -237,31 +230,20 @@ export function useShareManager(onSuccess: () => void) {
return;
}
const fileIds = share.files?.map((file) => file.id) || [];
const folderIds = share.folders?.map((folder) => folder.id) || [];
// Show creating ZIP toast
const creatingToast = toast.loading(t("bulkDownload.creatingZip"));
const blob = await bulkDownloadFiles({
fileIds,
folderIds,
zipName,
});
// Update toast to success
toast.dismiss(creatingToast);
toast.success(t("bulkDownload.zipCreated"));
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = zipName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.promise(
bulkDownloadShareWithQueue(allItems, share.files || [], share.folders || [], zipName, undefined, true).then(
() => {
if (clearSelectionCallback) {
clearSelectionCallback();
}
}
),
{
loading: t("shareManager.creatingZip"),
success: t("shareManager.zipDownloadSuccess"),
error: t("shareManager.zipDownloadError"),
}
);
} else {
toast.error("Multiple share download not yet supported - please download shares individually");
}
@@ -291,21 +273,12 @@ export function useShareManager(onSuccess: () => void) {
if (totalFiles === 1 && totalFolders === 0) {
const file = share.files[0];
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
// Direct S3 download
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.success(t("shareManager.downloadSuccess"));
await downloadFileWithQueue(file.objectName, file.name, {
onComplete: () => toast.success(t("shareManager.downloadSuccess")),
onFail: () => toast.error(t("shareManager.downloadError")),
});
} catch (error) {
console.error("Download error:", error);
toast.error(t("shareManager.downloadError"));
}
} else {
const zipName = t("shareManager.singleShareZipName", {

View File

@@ -1,33 +0,0 @@
import apiInstance from "@/config/api";
export interface BulkDownloadRequest {
fileIds: string[];
folderIds: string[];
zipName: string;
}
export interface BulkDownloadResponse {
success: boolean;
message: string;
}
export const bulkDownloadFiles = async (data: BulkDownloadRequest): Promise<Blob> => {
const response = await apiInstance.post("/api/files/bulk-download", data, {
responseType: "blob",
});
return response.data;
};
export const downloadFolder = async (folderId: string, folderName: string): Promise<Blob> => {
const response = await apiInstance.get(`/api/files/bulk-download/folder/${folderId}/${folderName}`, {
responseType: "blob",
});
return response.data;
};
export const bulkDownloadReverseShareFiles = async (data: { fileIds: string[]; zipName: string }): Promise<Blob> => {
const response = await apiInstance.post("/api/files/bulk-download/reverse-share", data, {
responseType: "blob",
});
return response.data;
};

View File

@@ -0,0 +1,63 @@
import type { AxiosRequestConfig } from "axios";
import apiInstance from "@/config/api";
export interface QueuedDownload {
downloadId: string;
position: number;
waitTime: number;
fileName?: string;
fileSize?: number;
}
export interface DownloadQueueStatus {
queueLength: number;
maxQueueSize: number;
activeDownloads: number;
maxConcurrent: number;
queuedDownloads: QueuedDownload[];
}
export interface DownloadQueueStatusResult {
status: string;
data: DownloadQueueStatus;
}
export interface CancelDownloadResult {
message: string;
downloadId: string;
}
export interface ClearQueueResult {
message: string;
clearedCount: number;
}
/**
* Get current download queue status
* @summary Get Download Queue Status
*/
export const getDownloadQueueStatus = <TData = DownloadQueueStatusResult>(
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.get(`/api/filesystem/download-queue/status`, options);
};
/**
* Cancel a specific queued download
* @summary Cancel Queued Download
*/
export const cancelQueuedDownload = <TData = CancelDownloadResult>(
downloadId: string,
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.delete(`/api/filesystem/download-queue/${downloadId}`, options);
};
/**
* Clear the entire download queue (admin operation)
* @summary Clear Download Queue
*/
export const clearDownloadQueue = <TData = ClearQueueResult>(options?: AxiosRequestConfig): Promise<TData> => {
return apiInstance.delete(`/api/filesystem/download-queue`, options);
};

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