mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-24 08:33:43 +00:00
Compare commits
9 Commits
v3.2.4-bet
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
807507325f | ||
|
|
0ef8266b65 | ||
|
|
1ea27d81c2 | ||
|
|
8df303c95f | ||
|
|
0742cb2110 | ||
|
|
cb4ed3f581 | ||
|
|
148676513d | ||
|
|
42a5b7a796 | ||
|
|
59fccd9a93 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-docs",
|
||||
"version": "3.2.4-beta",
|
||||
"version": "3.2.5-beta",
|
||||
"description": "Docs for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
||||
388
apps/server/MIGRATION_FILE_EXPIRATION.md
Normal file
388
apps/server/MIGRATION_FILE_EXPIRATION.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# File Expiration Feature - Migration Guide
|
||||
|
||||
This guide helps you migrate to the new file expiration feature introduced in Palmr v3.2.5-beta.
|
||||
|
||||
## What's New
|
||||
|
||||
The file expiration feature allows files to have an optional expiration date. When files expire, they can be automatically deleted by a maintenance script, helping with:
|
||||
|
||||
- **Security**: Reducing risk of confidential data exposure
|
||||
- **Storage Management**: Automatically freeing up server space
|
||||
- **Convenience**: Eliminating the need for manual file deletion
|
||||
- **Legal Compliance**: Facilitating adherence to data retention regulations (e.g., GDPR)
|
||||
|
||||
## Database Changes
|
||||
|
||||
A new optional `expiration` field has been added to the `File` model:
|
||||
|
||||
```prisma
|
||||
model File {
|
||||
// ... existing fields
|
||||
expiration DateTime? // NEW: Optional expiration date
|
||||
// ... existing fields
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### 1. Backup Your Database
|
||||
|
||||
Before running the migration, **always backup your database**:
|
||||
|
||||
```bash
|
||||
# For SQLite (default)
|
||||
cp apps/server/prisma/palmr.db apps/server/prisma/palmr.db.backup
|
||||
|
||||
# Or use the built-in backup command if available
|
||||
pnpm db:backup
|
||||
```
|
||||
|
||||
### 2. Run the Migration
|
||||
|
||||
The migration will automatically run when you start the server, or you can run it manually:
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
pnpm prisma migrate deploy
|
||||
```
|
||||
|
||||
This adds the `expiration` column to the `files` table. **All existing files will have `null` expiration (never expire).**
|
||||
|
||||
### 3. Verify the Migration
|
||||
|
||||
Check that the migration was successful:
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
pnpm prisma studio
|
||||
```
|
||||
|
||||
Look at the `files` table and verify the new `expiration` column exists.
|
||||
|
||||
## API Changes
|
||||
|
||||
### File Registration (Upload)
|
||||
|
||||
**Before:**
|
||||
```json
|
||||
{
|
||||
"name": "document.pdf",
|
||||
"description": "My document",
|
||||
"extension": "pdf",
|
||||
"size": 1024000,
|
||||
"objectName": "user123/document.pdf"
|
||||
}
|
||||
```
|
||||
|
||||
**After (optional expiration):**
|
||||
```json
|
||||
{
|
||||
"name": "document.pdf",
|
||||
"description": "My document",
|
||||
"extension": "pdf",
|
||||
"size": 1024000,
|
||||
"objectName": "user123/document.pdf",
|
||||
"expiration": "2025-12-31T23:59:59.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
The `expiration` field is **optional** - omitting it or setting it to `null` means the file never expires.
|
||||
|
||||
### File Update
|
||||
|
||||
You can now update a file's expiration date:
|
||||
|
||||
```bash
|
||||
PATCH /files/:id
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expiration": "2026-01-31T23:59:59.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
To remove expiration:
|
||||
```json
|
||||
{
|
||||
"expiration": null
|
||||
}
|
||||
```
|
||||
|
||||
### File Listing
|
||||
|
||||
File list responses now include the `expiration` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"id": "file123",
|
||||
"name": "document.pdf",
|
||||
// ... other fields
|
||||
"expiration": "2025-12-31T23:59:59.000Z",
|
||||
"createdAt": "2025-10-21T10:00:00.000Z",
|
||||
"updatedAt": "2025-10-21T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Setting Up Automatic Cleanup
|
||||
|
||||
The file expiration feature includes a maintenance script that automatically deletes expired files.
|
||||
|
||||
### Manual Execution
|
||||
|
||||
**Dry-run mode** (preview what would be deleted):
|
||||
```bash
|
||||
cd apps/server
|
||||
pnpm cleanup:expired-files
|
||||
```
|
||||
|
||||
**Confirm mode** (actually delete):
|
||||
```bash
|
||||
cd apps/server
|
||||
pnpm cleanup:expired-files:confirm
|
||||
```
|
||||
|
||||
### Automated Scheduling
|
||||
|
||||
#### Option 1: Cron Job (Recommended for Linux/Unix)
|
||||
|
||||
Add to crontab to run daily at 2 AM:
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
Add this line:
|
||||
```
|
||||
0 2 * * * cd /path/to/Palmr/apps/server && /usr/bin/pnpm cleanup:expired-files:confirm >> /var/log/palmr-cleanup.log 2>&1
|
||||
```
|
||||
|
||||
#### Option 2: Systemd Timer (Linux)
|
||||
|
||||
Create `/etc/systemd/system/palmr-cleanup.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Palmr Expired Files Cleanup
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=palmr
|
||||
WorkingDirectory=/path/to/Palmr/apps/server
|
||||
ExecStart=/usr/bin/pnpm cleanup:expired-files:confirm
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
```
|
||||
|
||||
Create `/etc/systemd/system/palmr-cleanup.timer`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Daily Palmr Cleanup
|
||||
Requires=palmr-cleanup.service
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
OnCalendar=02:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
Enable:
|
||||
```bash
|
||||
sudo systemctl enable palmr-cleanup.timer
|
||||
sudo systemctl start palmr-cleanup.timer
|
||||
```
|
||||
|
||||
#### Option 3: Docker Compose
|
||||
|
||||
Add a scheduled service to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
palmr-cleanup:
|
||||
image: palmr:latest
|
||||
command: sh -c "while true; do sleep 86400; pnpm cleanup:expired-files:confirm; done"
|
||||
environment:
|
||||
- DATABASE_URL=file:/data/palmr.db
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./uploads:/uploads
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Or use an external scheduler with a one-shot container:
|
||||
```yaml
|
||||
services:
|
||||
palmr-cleanup:
|
||||
image: palmr:latest
|
||||
command: pnpm cleanup:expired-files:confirm
|
||||
environment:
|
||||
- DATABASE_URL=file:/data/palmr.db
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./uploads:/uploads
|
||||
restart: "no"
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
This feature is **fully backward compatible**:
|
||||
|
||||
- Existing files automatically have `expiration = null` (never expire)
|
||||
- The `expiration` field is optional in all API endpoints
|
||||
- No changes required to existing client code
|
||||
- Files without expiration dates continue to work exactly as before
|
||||
|
||||
## Client Implementation Examples
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
// Upload file with expiration
|
||||
const uploadWithExpiration = async (file: File) => {
|
||||
// Set expiration to 30 days from now
|
||||
const expiration = new Date();
|
||||
expiration.setDate(expiration.getDate() + 30);
|
||||
|
||||
const response = await fetch('/api/files', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: file.name,
|
||||
extension: file.name.split('.').pop(),
|
||||
size: file.size,
|
||||
objectName: `user/${Date.now()}-${file.name}`,
|
||||
expiration: expiration.toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Update file expiration
|
||||
const updateExpiration = async (fileId: string, days: number) => {
|
||||
const expiration = new Date();
|
||||
expiration.setDate(expiration.getDate() + days);
|
||||
|
||||
const response = await fetch(`/api/files/${fileId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
expiration: expiration.toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Remove expiration (make file permanent)
|
||||
const removExpiration = async (fileId: string) => {
|
||||
const response = await fetch(`/api/files/${fileId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
expiration: null,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
};
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
import requests
|
||||
|
||||
# Upload file with expiration
|
||||
def upload_with_expiration(file_data):
|
||||
expiration = datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
response = requests.post('http://localhost:3333/files', json={
|
||||
'name': file_data['name'],
|
||||
'extension': file_data['extension'],
|
||||
'size': file_data['size'],
|
||||
'objectName': file_data['objectName'],
|
||||
'expiration': expiration.isoformat() + 'Z'
|
||||
})
|
||||
|
||||
return response.json()
|
||||
|
||||
# Update expiration
|
||||
def update_expiration(file_id, days):
|
||||
expiration = datetime.utcnow() + timedelta(days=days)
|
||||
|
||||
response = requests.patch(f'http://localhost:3333/files/{file_id}', json={
|
||||
'expiration': expiration.isoformat() + 'Z'
|
||||
})
|
||||
|
||||
return response.json()
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start with dry-run**: Always test the cleanup script in dry-run mode first
|
||||
2. **Monitor logs**: Keep track of what files are being deleted
|
||||
3. **User notifications**: Consider notifying users before their files expire
|
||||
4. **Grace period**: Set expiration dates with a buffer for important files
|
||||
5. **Backup strategy**: Maintain backups before enabling automatic deletion
|
||||
6. **Documentation**: Document your expiration policies for users
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Migration Fails
|
||||
|
||||
If the migration fails:
|
||||
|
||||
1. Check database connectivity
|
||||
2. Ensure you have write permissions
|
||||
3. Verify the database file isn't locked
|
||||
4. Try running `pnpm prisma migrate reset` (WARNING: this will delete all data)
|
||||
|
||||
### Cleanup Script Not Deleting Files
|
||||
|
||||
1. Verify files have expiration dates set and are in the past
|
||||
2. Check script is running with `--confirm` flag
|
||||
3. Review logs for specific errors
|
||||
3. Ensure script has permissions to delete from storage
|
||||
|
||||
### Need to Rollback
|
||||
|
||||
If you need to rollback the migration:
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
|
||||
# View migration history
|
||||
pnpm prisma migrate status
|
||||
|
||||
# Rollback (requires manual SQL for production)
|
||||
# SQLite example:
|
||||
sqlite3 prisma/palmr.db "ALTER TABLE files DROP COLUMN expiration;"
|
||||
```
|
||||
|
||||
Note: Prisma doesn't support automatic rollback. You must manually reverse the migration or restore from backup.
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
- Create an issue on GitHub
|
||||
- Check the documentation at https://palmr.kyantech.com.br
|
||||
- Review the scripts README at `apps/server/src/scripts/README.md`
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 3.2.5-beta
|
||||
|
||||
- Added optional `expiration` field to File model
|
||||
- Created `cleanup-expired-files` maintenance script
|
||||
- Updated File DTOs to support expiration in create/update operations
|
||||
- Added API documentation for expiration field
|
||||
- Created comprehensive documentation for setup and usage
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-api",
|
||||
"version": "3.2.4-beta",
|
||||
"version": "3.2.5-beta",
|
||||
"description": "API for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
@@ -27,7 +27,9 @@
|
||||
"validate": "pnpm lint && pnpm type-check",
|
||||
"db:seed": "ts-node prisma/seed.js",
|
||||
"cleanup:orphan-files": "tsx src/scripts/cleanup-orphan-files.ts",
|
||||
"cleanup:orphan-files:confirm": "tsx src/scripts/cleanup-orphan-files.ts --confirm"
|
||||
"cleanup:orphan-files:confirm": "tsx src/scripts/cleanup-orphan-files.ts --confirm",
|
||||
"cleanup:expired-files": "tsx src/scripts/cleanup-expired-files.ts",
|
||||
"cleanup:expired-files:confirm": "tsx src/scripts/cleanup-expired-files.ts --confirm"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "node prisma/seed.js"
|
||||
@@ -78,5 +80,14 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@prisma/client",
|
||||
"@prisma/engines",
|
||||
"esbuild",
|
||||
"prisma",
|
||||
"sharp"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT,
|
||||
"image" TEXT,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"twoFactorSecret" TEXT,
|
||||
"twoFactorBackupCodes" TEXT,
|
||||
"twoFactorVerified" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "files" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"extension" TEXT NOT NULL,
|
||||
"size" BIGINT NOT NULL,
|
||||
"objectName" TEXT NOT NULL,
|
||||
"expiration" DATETIME,
|
||||
"userId" TEXT NOT NULL,
|
||||
"folderId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "files_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "files_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "folders" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "shares" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiration" DATETIME,
|
||||
"description" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"creatorId" TEXT,
|
||||
"securityId" TEXT NOT NULL,
|
||||
CONSTRAINT "shares_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "shares_securityId_fkey" FOREIGN KEY ("securityId") REFERENCES "share_security" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "share_security" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"password" TEXT,
|
||||
"maxViews" INTEGER,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "share_recipients" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"email" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"shareId" TEXT NOT NULL,
|
||||
CONSTRAINT "share_recipients_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "shares" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "app_configs" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"group" TEXT NOT NULL,
|
||||
"isSystem" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "login_attempts" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 1,
|
||||
"lastAttempt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "login_attempts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "password_resets" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "password_resets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "share_aliases" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"alias" TEXT NOT NULL,
|
||||
"shareId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "share_aliases_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "shares" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "auth_providers" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"icon" TEXT,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"issuerUrl" TEXT,
|
||||
"clientId" TEXT,
|
||||
"clientSecret" TEXT,
|
||||
"redirectUri" TEXT,
|
||||
"scope" TEXT DEFAULT 'openid profile email',
|
||||
"authorizationEndpoint" TEXT,
|
||||
"tokenEndpoint" TEXT,
|
||||
"userInfoEndpoint" TEXT,
|
||||
"metadata" TEXT,
|
||||
"autoRegister" BOOLEAN NOT NULL DEFAULT true,
|
||||
"adminEmailDomains" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_auth_providers" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"provider" TEXT,
|
||||
"externalId" TEXT NOT NULL,
|
||||
"metadata" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "user_auth_providers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "user_auth_providers_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "auth_providers" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reverse_shares" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT,
|
||||
"description" TEXT,
|
||||
"expiration" DATETIME,
|
||||
"maxFiles" INTEGER,
|
||||
"maxFileSize" BIGINT,
|
||||
"allowedFileTypes" TEXT,
|
||||
"password" TEXT,
|
||||
"pageLayout" TEXT NOT NULL DEFAULT 'DEFAULT',
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"nameFieldRequired" TEXT NOT NULL DEFAULT 'OPTIONAL',
|
||||
"emailFieldRequired" TEXT NOT NULL DEFAULT 'OPTIONAL',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"creatorId" TEXT NOT NULL,
|
||||
CONSTRAINT "reverse_shares_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reverse_share_files" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"extension" TEXT NOT NULL,
|
||||
"size" BIGINT NOT NULL,
|
||||
"objectName" TEXT NOT NULL,
|
||||
"uploaderEmail" TEXT,
|
||||
"uploaderName" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"reverseShareId" TEXT NOT NULL,
|
||||
CONSTRAINT "reverse_share_files_reverseShareId_fkey" FOREIGN KEY ("reverseShareId") REFERENCES "reverse_shares" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reverse_share_aliases" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"alias" TEXT NOT NULL,
|
||||
"reverseShareId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "reverse_share_aliases_reverseShareId_fkey" FOREIGN KEY ("reverseShareId") REFERENCES "reverse_shares" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "trusted_devices" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"deviceHash" TEXT NOT NULL,
|
||||
"deviceName" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"ipAddress" TEXT,
|
||||
"lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "trusted_devices_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folders" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"objectName" TEXT NOT NULL,
|
||||
"parentId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "folders_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "folders" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "folders_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ShareFiles" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
CONSTRAINT "_ShareFiles_A_fkey" FOREIGN KEY ("A") REFERENCES "files" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "_ShareFiles_B_fkey" FOREIGN KEY ("B") REFERENCES "shares" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ShareFolders" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
CONSTRAINT "_ShareFolders_A_fkey" FOREIGN KEY ("A") REFERENCES "folders" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "_ShareFolders_B_fkey" FOREIGN KEY ("B") REFERENCES "shares" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "files_folderId_idx" ON "files"("folderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "shares_securityId_key" ON "shares"("securityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "app_configs_key_key" ON "app_configs"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "login_attempts_userId_key" ON "login_attempts"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "password_resets_token_key" ON "password_resets"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_aliases_alias_key" ON "share_aliases"("alias");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "share_aliases_shareId_key" ON "share_aliases"("shareId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "auth_providers_name_key" ON "auth_providers"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_auth_providers_userId_providerId_key" ON "user_auth_providers"("userId", "providerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_auth_providers_providerId_externalId_key" ON "user_auth_providers"("providerId", "externalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "reverse_share_aliases_alias_key" ON "reverse_share_aliases"("alias");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "reverse_share_aliases_reverseShareId_key" ON "reverse_share_aliases"("reverseShareId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "trusted_devices_deviceHash_key" ON "trusted_devices"("deviceHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_userId_idx" ON "folders"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_parentId_idx" ON "folders"("parentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_ShareFiles_AB_unique" ON "_ShareFiles"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ShareFiles_B_index" ON "_ShareFiles"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_ShareFolders_AB_unique" ON "_ShareFolders"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ShareFolders_B_index" ON "_ShareFolders"("B");
|
||||
3
apps/server/prisma/migrations/migration_lock.toml
Normal file
3
apps/server/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
@@ -40,12 +40,13 @@ model User {
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
extension String
|
||||
size BigInt
|
||||
objectName String
|
||||
expiration DateTime?
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as fs from "fs";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
generateUniqueFileNameForRename,
|
||||
parseFileName,
|
||||
} from "../../utils/file-name-generator";
|
||||
import { getContentType } from "../../utils/mime-types";
|
||||
import { ConfigService } from "../config/service";
|
||||
import {
|
||||
CheckFileInput,
|
||||
@@ -111,6 +113,7 @@ export class FileController {
|
||||
objectName: input.objectName,
|
||||
userId,
|
||||
folderId: input.folderId,
|
||||
expiration: input.expiration ? new Date(input.expiration) : null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -123,6 +126,7 @@ export class FileController {
|
||||
objectName: fileRecord.objectName,
|
||||
userId: fileRecord.userId,
|
||||
folderId: fileRecord.folderId,
|
||||
expiration: fileRecord.expiration?.toISOString() || null,
|
||||
createdAt: fileRecord.createdAt,
|
||||
updatedAt: fileRecord.updatedAt,
|
||||
};
|
||||
@@ -200,11 +204,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." });
|
||||
@@ -218,7 +221,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: {
|
||||
@@ -270,6 +274,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();
|
||||
@@ -315,6 +431,11 @@ export class FileController {
|
||||
userId: file.userId,
|
||||
folderId: file.folderId,
|
||||
relativePath: file.relativePath || null,
|
||||
expiration: file.expiration
|
||||
? file.expiration instanceof Date
|
||||
? file.expiration.toISOString()
|
||||
: file.expiration
|
||||
: null,
|
||||
createdAt: file.createdAt,
|
||||
updatedAt: file.updatedAt,
|
||||
}));
|
||||
@@ -388,7 +509,14 @@ export class FileController {
|
||||
|
||||
const updatedFile = await prisma.file.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
data: {
|
||||
...updateData,
|
||||
expiration: updateData.expiration
|
||||
? new Date(updateData.expiration)
|
||||
: updateData.expiration === null
|
||||
? null
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const fileResponse = {
|
||||
@@ -400,6 +528,7 @@ export class FileController {
|
||||
objectName: updatedFile.objectName,
|
||||
userId: updatedFile.userId,
|
||||
folderId: updatedFile.folderId,
|
||||
expiration: updatedFile.expiration?.toISOString() || null,
|
||||
createdAt: updatedFile.createdAt,
|
||||
updatedAt: updatedFile.updatedAt,
|
||||
};
|
||||
@@ -457,6 +586,7 @@ export class FileController {
|
||||
objectName: updatedFile.objectName,
|
||||
userId: updatedFile.userId,
|
||||
folderId: updatedFile.folderId,
|
||||
expiration: updatedFile.expiration?.toISOString() || null,
|
||||
createdAt: updatedFile.createdAt,
|
||||
updatedAt: updatedFile.updatedAt,
|
||||
};
|
||||
@@ -471,6 +601,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 },
|
||||
|
||||
@@ -10,6 +10,7 @@ export const RegisterFileSchema = z.object({
|
||||
}),
|
||||
objectName: z.string().min(1, "O objectName é obrigatório"),
|
||||
folderId: z.string().optional(),
|
||||
expiration: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const CheckFileSchema = z.object({
|
||||
@@ -22,6 +23,7 @@ export const CheckFileSchema = z.object({
|
||||
}),
|
||||
objectName: z.string().min(1, "O objectName é obrigatório"),
|
||||
folderId: z.string().optional(),
|
||||
expiration: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export type RegisterFileInput = z.infer<typeof RegisterFileSchema>;
|
||||
@@ -30,6 +32,7 @@ export type CheckFileInput = z.infer<typeof CheckFileSchema>;
|
||||
export const UpdateFileSchema = z.object({
|
||||
name: z.string().optional().describe("The file name"),
|
||||
description: z.string().optional().nullable().describe("The file description"),
|
||||
expiration: z.string().datetime().optional().nullable().describe("The file expiration date"),
|
||||
});
|
||||
|
||||
export const MoveFileSchema = z.object({
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function fileRoutes(app: FastifyInstance) {
|
||||
objectName: z.string().describe("The object name of the file"),
|
||||
userId: z.string().describe("The user ID"),
|
||||
folderId: z.string().nullable().describe("The folder ID"),
|
||||
expiration: z.string().nullable().describe("The file expiration date"),
|
||||
createdAt: z.date().describe("The file creation date"),
|
||||
updatedAt: z.date().describe("The file last update date"),
|
||||
}),
|
||||
@@ -106,17 +107,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 +132,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",
|
||||
{
|
||||
@@ -156,6 +195,7 @@ export async function fileRoutes(app: FastifyInstance) {
|
||||
userId: z.string().describe("The user ID"),
|
||||
folderId: z.string().nullable().describe("The folder ID"),
|
||||
relativePath: z.string().nullable().describe("The relative path (only for recursive listing)"),
|
||||
expiration: z.string().nullable().describe("The file expiration date"),
|
||||
createdAt: z.date().describe("The file creation date"),
|
||||
updatedAt: z.date().describe("The file last update date"),
|
||||
})
|
||||
@@ -192,6 +232,7 @@ export async function fileRoutes(app: FastifyInstance) {
|
||||
objectName: z.string().describe("The object name of the file"),
|
||||
userId: z.string().describe("The user ID"),
|
||||
folderId: z.string().nullable().describe("The folder ID"),
|
||||
expiration: z.string().nullable().describe("The file expiration date"),
|
||||
createdAt: z.date().describe("The file creation date"),
|
||||
updatedAt: z.date().describe("The file last update date"),
|
||||
}),
|
||||
@@ -231,6 +272,7 @@ export async function fileRoutes(app: FastifyInstance) {
|
||||
objectName: z.string().describe("The object name of the file"),
|
||||
userId: z.string().describe("The user ID"),
|
||||
folderId: z.string().nullable().describe("The folder ID"),
|
||||
expiration: z.string().nullable().describe("The file expiration date"),
|
||||
createdAt: z.date().describe("The file creation date"),
|
||||
updatedAt: z.date().describe("The file last update date"),
|
||||
}),
|
||||
|
||||
@@ -84,7 +84,6 @@ export class FilesystemController {
|
||||
const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
|
||||
|
||||
if (result.isComplete) {
|
||||
provider.consumeUploadToken(token);
|
||||
reply.status(200).send({
|
||||
message: "File uploaded successfully",
|
||||
objectName: result.finalPath,
|
||||
@@ -104,7 +103,6 @@ export class FilesystemController {
|
||||
}
|
||||
} else {
|
||||
await this.uploadFileStream(request, provider, tokenData.objectName);
|
||||
provider.consumeUploadToken(token);
|
||||
reply.status(200).send({ message: "File uploaded successfully" });
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -271,8 +269,6 @@ export class FilesystemController {
|
||||
reply.header("Content-Length", fileSize);
|
||||
await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId);
|
||||
}
|
||||
|
||||
provider.consumeDownloadToken(token);
|
||||
} finally {
|
||||
this.memoryManager.endDownload(downloadId);
|
||||
}
|
||||
|
||||
@@ -192,13 +192,9 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
return `/api/filesystem/upload/${token}`;
|
||||
}
|
||||
|
||||
async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = Date.now() + expires * 1000;
|
||||
|
||||
this.downloadTokens.set(token, { objectName, expiresAt, fileName });
|
||||
|
||||
return `/api/filesystem/download/${token}`;
|
||||
async getPresignedGetUrl(objectName: string): Promise<string> {
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
return `/api/files/download?objectName=${encodedObjectName}`;
|
||||
}
|
||||
|
||||
async deleteObject(objectName: string): Promise<void> {
|
||||
@@ -636,13 +632,8 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
return { objectName: data.objectName, fileName: data.fileName };
|
||||
}
|
||||
|
||||
consumeUploadToken(token: string): void {
|
||||
this.uploadTokens.delete(token);
|
||||
}
|
||||
|
||||
consumeDownloadToken(token: string): void {
|
||||
this.downloadTokens.delete(token);
|
||||
}
|
||||
// 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 {
|
||||
|
||||
236
apps/server/src/scripts/README.md
Normal file
236
apps/server/src/scripts/README.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Palmr Maintenance Scripts
|
||||
|
||||
This directory contains maintenance scripts for the Palmr server application.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
### 1. Cleanup Expired Files (`cleanup-expired-files.ts`)
|
||||
|
||||
Automatically deletes files that have reached their expiration date. This script is designed to be run periodically (e.g., via cron job) to maintain storage hygiene and comply with data retention policies.
|
||||
|
||||
#### Features
|
||||
|
||||
- **Automatic Deletion**: Removes both the file metadata from the database and the actual file from storage
|
||||
- **Dry-Run Mode**: Preview what would be deleted without actually removing files
|
||||
- **Storage Agnostic**: Works with both filesystem and S3-compatible storage
|
||||
- **Detailed Logging**: Provides clear output about what files were found and deleted
|
||||
- **Error Handling**: Continues processing even if individual files fail to delete
|
||||
|
||||
#### Usage
|
||||
|
||||
**Dry-run mode** (preview without deleting):
|
||||
```bash
|
||||
pnpm cleanup:expired-files
|
||||
```
|
||||
|
||||
**Confirm mode** (actually delete expired files):
|
||||
```bash
|
||||
pnpm cleanup:expired-files:confirm
|
||||
```
|
||||
|
||||
Or directly with tsx:
|
||||
```bash
|
||||
tsx src/scripts/cleanup-expired-files.ts --confirm
|
||||
```
|
||||
|
||||
#### Output Example
|
||||
|
||||
```
|
||||
🧹 Starting expired files cleanup...
|
||||
📦 Storage mode: Filesystem
|
||||
📊 Found 2 expired files
|
||||
|
||||
🗑️ Expired files to be deleted:
|
||||
- document.pdf (2.45 MB) - Expired: 2025-10-20T10:30:00.000Z
|
||||
- image.jpg (1.23 MB) - Expired: 2025-10-21T08:15:00.000Z
|
||||
|
||||
🗑️ Deleting expired files...
|
||||
✓ Deleted: document.pdf
|
||||
✓ Deleted: image.jpg
|
||||
|
||||
✅ Cleanup complete!
|
||||
Deleted: 2 files (3.68 MB)
|
||||
```
|
||||
|
||||
#### Setting Up Automated Cleanup
|
||||
|
||||
To run this script automatically, you can set up a cron job:
|
||||
|
||||
##### Using crontab (Linux/Unix)
|
||||
|
||||
1. Edit your crontab:
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
2. Add a line to run the cleanup daily at 2 AM:
|
||||
```
|
||||
0 2 * * * cd /path/to/Palmr/apps/server && pnpm cleanup:expired-files:confirm >> /var/log/palmr-cleanup.log 2>&1
|
||||
```
|
||||
|
||||
##### Using systemd timer (Linux)
|
||||
|
||||
1. Create a service file `/etc/systemd/system/palmr-cleanup-expired.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Palmr Expired Files Cleanup
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=palmr
|
||||
WorkingDirectory=/path/to/Palmr/apps/server
|
||||
ExecStart=/usr/bin/pnpm cleanup:expired-files:confirm
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
```
|
||||
|
||||
2. Create a timer file `/etc/systemd/system/palmr-cleanup-expired.timer`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Run Palmr Expired Files Cleanup Daily
|
||||
Requires=palmr-cleanup-expired.service
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
OnCalendar=02:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
3. Enable and start the timer:
|
||||
```bash
|
||||
sudo systemctl enable palmr-cleanup-expired.timer
|
||||
sudo systemctl start palmr-cleanup-expired.timer
|
||||
```
|
||||
|
||||
##### Using Docker
|
||||
|
||||
If running Palmr in Docker, you can add the cleanup command to your compose file or create a separate service:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
palmr-cleanup:
|
||||
image: palmr:latest
|
||||
command: pnpm cleanup:expired-files:confirm
|
||||
environment:
|
||||
- DATABASE_URL=file:/data/palmr.db
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./uploads:/uploads
|
||||
restart: "no"
|
||||
```
|
||||
|
||||
Then schedule it with your host's cron or a container orchestration tool.
|
||||
|
||||
#### Best Practices
|
||||
|
||||
1. **Test First**: Always run in dry-run mode first to preview what will be deleted
|
||||
2. **Monitor Logs**: Keep track of cleanup operations by logging output
|
||||
3. **Regular Schedule**: Run the cleanup at least daily to prevent storage bloat
|
||||
4. **Off-Peak Hours**: Schedule cleanup during low-traffic periods
|
||||
5. **Backup Strategy**: Ensure you have backups before enabling automatic deletion
|
||||
|
||||
### 2. Cleanup Orphan Files (`cleanup-orphan-files.ts`)
|
||||
|
||||
Removes file records from the database that no longer have corresponding files in storage. This can happen if files are manually deleted from storage or if an upload fails partway through.
|
||||
|
||||
#### Usage
|
||||
|
||||
**Dry-run mode**:
|
||||
```bash
|
||||
pnpm cleanup:orphan-files
|
||||
```
|
||||
|
||||
**Confirm mode**:
|
||||
```bash
|
||||
pnpm cleanup:orphan-files:confirm
|
||||
```
|
||||
|
||||
## File Expiration Feature
|
||||
|
||||
Files in Palmr can now have an optional expiration date. When a file expires, it becomes eligible for automatic deletion by the cleanup script.
|
||||
|
||||
### Setting Expiration During Upload
|
||||
|
||||
When registering a file, include the `expiration` field with an ISO 8601 datetime string:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "document.pdf",
|
||||
"description": "Confidential document",
|
||||
"extension": "pdf",
|
||||
"size": 2048000,
|
||||
"objectName": "user123/document.pdf",
|
||||
"expiration": "2025-12-31T23:59:59.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Updating File Expiration
|
||||
|
||||
You can update a file's expiration date at any time:
|
||||
|
||||
```bash
|
||||
PATCH /files/:id
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expiration": "2026-01-31T23:59:59.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
To remove an expiration date (file never expires):
|
||||
|
||||
```json
|
||||
{
|
||||
"expiration": null
|
||||
}
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **Temporary Shares**: Share files that automatically delete after a certain period
|
||||
- **Compliance**: Meet data retention requirements (e.g., GDPR)
|
||||
- **Storage Management**: Automatically free up space by removing old files
|
||||
- **Security**: Reduce risk of sensitive data exposure by limiting file lifetime
|
||||
- **Trial Periods**: Automatically clean up files from trial or demo accounts
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Scripts run with the same permissions as the application
|
||||
- Deleted files cannot be recovered unless backups are in place
|
||||
- Always test scripts in a development environment first
|
||||
- Monitor script execution and review logs regularly
|
||||
- Consider implementing file versioning or soft deletes for critical data
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Script Fails to Connect to Database
|
||||
|
||||
Ensure the `DATABASE_URL` environment variable is set correctly in your `.env` file.
|
||||
|
||||
### Files Not Being Deleted
|
||||
|
||||
1. Check that files actually have an expiration date set
|
||||
2. Verify the expiration date is in the past
|
||||
3. Ensure the script has appropriate permissions to delete files
|
||||
4. Check application logs for specific error messages
|
||||
|
||||
### Storage Provider Issues
|
||||
|
||||
If using S3-compatible storage, ensure:
|
||||
- Credentials are valid and have delete permissions
|
||||
- Network connectivity to the S3 endpoint is working
|
||||
- Bucket exists and is accessible
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new maintenance scripts:
|
||||
|
||||
1. Follow the existing naming convention
|
||||
2. Include dry-run and confirm modes
|
||||
3. Provide clear logging output
|
||||
4. Handle errors gracefully
|
||||
5. Update this README with usage instructions
|
||||
123
apps/server/src/scripts/cleanup-expired-files.ts
Normal file
123
apps/server/src/scripts/cleanup-expired-files.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
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 automatically delete expired files
|
||||
* This script should be run periodically (e.g., via cron job)
|
||||
*/
|
||||
async function cleanupExpiredFiles() {
|
||||
console.log("🧹 Starting expired files cleanup...");
|
||||
console.log(`📦 Storage mode: ${isS3Enabled ? "S3" : "Filesystem"}`);
|
||||
|
||||
let storageProvider: StorageProvider;
|
||||
if (isS3Enabled) {
|
||||
storageProvider = new S3StorageProvider();
|
||||
} else {
|
||||
storageProvider = FilesystemStorageProvider.getInstance();
|
||||
}
|
||||
|
||||
// Get all expired files
|
||||
const now = new Date();
|
||||
const expiredFiles = await prisma.file.findMany({
|
||||
where: {
|
||||
expiration: {
|
||||
lte: now,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
objectName: true,
|
||||
userId: true,
|
||||
size: true,
|
||||
expiration: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`📊 Found ${expiredFiles.length} expired files`);
|
||||
|
||||
if (expiredFiles.length === 0) {
|
||||
console.log("\n✨ No expired files found!");
|
||||
return {
|
||||
deletedCount: 0,
|
||||
failedCount: 0,
|
||||
totalSize: 0,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`\n🗑️ Expired files to be deleted:`);
|
||||
expiredFiles.forEach((file) => {
|
||||
const sizeMB = Number(file.size) / (1024 * 1024);
|
||||
console.log(` - ${file.name} (${sizeMB.toFixed(2)} MB) - Expired: ${file.expiration?.toISOString()}`);
|
||||
});
|
||||
|
||||
// Ask for confirmation (if running interactively)
|
||||
const shouldDelete = process.argv.includes("--confirm");
|
||||
|
||||
if (!shouldDelete) {
|
||||
console.log(`\n⚠️ Dry run mode. To actually delete expired files, run with --confirm flag:`);
|
||||
console.log(` pnpm cleanup:expired-files:confirm`);
|
||||
return {
|
||||
deletedCount: 0,
|
||||
failedCount: 0,
|
||||
totalSize: 0,
|
||||
dryRun: true,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`\n🗑️ Deleting expired files...`);
|
||||
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
let totalSize = BigInt(0);
|
||||
|
||||
for (const file of expiredFiles) {
|
||||
try {
|
||||
// Delete from storage first
|
||||
await storageProvider.deleteObject(file.objectName);
|
||||
|
||||
// Then delete from database
|
||||
await prisma.file.delete({
|
||||
where: { id: file.id },
|
||||
});
|
||||
|
||||
deletedCount++;
|
||||
totalSize += file.size;
|
||||
console.log(` ✓ Deleted: ${file.name}`);
|
||||
} catch (error) {
|
||||
failedCount++;
|
||||
console.error(` ✗ Failed to delete ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const totalSizeMB = Number(totalSize) / (1024 * 1024);
|
||||
|
||||
console.log(`\n✅ Cleanup complete!`);
|
||||
console.log(` Deleted: ${deletedCount} files (${totalSizeMB.toFixed(2)} MB)`);
|
||||
if (failedCount > 0) {
|
||||
console.log(` Failed: ${failedCount} files`);
|
||||
}
|
||||
|
||||
return {
|
||||
deletedCount,
|
||||
failedCount,
|
||||
totalSize: totalSizeMB,
|
||||
};
|
||||
}
|
||||
|
||||
// Run the cleanup
|
||||
cleanupExpiredFiles()
|
||||
.then((result) => {
|
||||
console.log("\n✨ Script completed successfully");
|
||||
if (result.dryRun) {
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(result.failedCount > 0 ? 1 : 0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("\n❌ Script failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -150,7 +150,9 @@
|
||||
"move": "نقل",
|
||||
"rename": "إعادة تسمية",
|
||||
"search": "بحث",
|
||||
"share": "مشاركة"
|
||||
"share": "مشاركة",
|
||||
"copied": "تم النسخ",
|
||||
"copy": "نسخ"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "إنشاء مشاركة",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "معاينة الملف",
|
||||
"description": "معاينة وتنزيل الملف",
|
||||
"loading": "جاري التحميل...",
|
||||
"notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.",
|
||||
"downloadToView": "استخدم زر التحميل لتنزيل الملف.",
|
||||
@@ -1932,5 +1935,17 @@
|
||||
"passwordRequired": "كلمة المرور مطلوبة",
|
||||
"nameRequired": "الاسم مطلوب",
|
||||
"required": "هذا الحقل مطلوب"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "تضمين الصورة",
|
||||
"description": "استخدم هذه الأكواد لتضمين هذه الصورة في المنتديات أو المواقع الإلكترونية أو المنصات الأخرى",
|
||||
"tabs": {
|
||||
"directLink": "رابط مباشر",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "عنوان URL مباشر لملف الصورة",
|
||||
"htmlDescription": "استخدم هذا الكود لتضمين الصورة في صفحات HTML",
|
||||
"bbcodeDescription": "استخدم هذا الكود لتضمين الصورة في المنتديات التي تدعم BBCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -1930,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,9 @@
|
||||
"rename": "Rename",
|
||||
"move": "Move",
|
||||
"share": "Share",
|
||||
"search": "Search"
|
||||
"search": "Search",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Create Share",
|
||||
@@ -302,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",
|
||||
@@ -1881,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",
|
||||
@@ -1896,4 +1911,4 @@
|
||||
"nameRequired": "Name is required",
|
||||
"required": "This field is required"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -1930,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -1930,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,9 @@
|
||||
"move": "स्थानांतरित करें",
|
||||
"rename": "नाम बदलें",
|
||||
"search": "खोजें",
|
||||
"share": "साझा करें"
|
||||
"share": "साझा करें",
|
||||
"copied": "कॉपी किया गया",
|
||||
"copy": "कॉपी करें"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "साझाकरण बनाएं",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "फ़ाइल पूर्वावलोकन",
|
||||
"description": "फ़ाइल पूर्वावलोकन और डाउनलोड",
|
||||
"loading": "लोड हो रहा है...",
|
||||
"notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।",
|
||||
"downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "पासवर्ड आवश्यक है",
|
||||
"nameRequired": "नाम आवश्यक है",
|
||||
"required": "यह फ़ील्ड आवश्यक है"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "छवि एम्बेड करें",
|
||||
"description": "इस छवि को मंचों, वेबसाइटों या अन्य प्लेटफार्मों में एम्बेड करने के लिए इन कोड का उपयोग करें",
|
||||
"tabs": {
|
||||
"directLink": "सीधा लिंक",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "छवि फ़ाइल का सीधा URL",
|
||||
"htmlDescription": "HTML पेजों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें",
|
||||
"bbcodeDescription": "BBCode का समर्थन करने वाले मंचों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -1930,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,9 @@
|
||||
"move": "移動",
|
||||
"rename": "名前を変更",
|
||||
"search": "検索",
|
||||
"share": "共有"
|
||||
"share": "共有",
|
||||
"copied": "コピーしました",
|
||||
"copy": "コピー"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "共有を作成",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "ファイルプレビュー",
|
||||
"description": "ファイルをプレビューしてダウンロード",
|
||||
"loading": "読み込み中...",
|
||||
"notAvailable": "このファイルタイプのプレビューは利用できません。",
|
||||
"downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "パスワードは必須です",
|
||||
"nameRequired": "名前は必須です",
|
||||
"required": "このフィールドは必須です"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "画像を埋め込む",
|
||||
"description": "これらのコードを使用して、この画像をフォーラム、ウェブサイト、またはその他のプラットフォームに埋め込みます",
|
||||
"tabs": {
|
||||
"directLink": "直接リンク",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "画像ファイルへの直接URL",
|
||||
"htmlDescription": "このコードを使用してHTMLページに画像を埋め込みます",
|
||||
"bbcodeDescription": "BBCodeをサポートするフォーラムに画像を埋め込むには、このコードを使用します"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,9 @@
|
||||
"move": "이동",
|
||||
"rename": "이름 변경",
|
||||
"search": "검색",
|
||||
"share": "공유"
|
||||
"share": "공유",
|
||||
"copied": "복사됨",
|
||||
"copy": "복사"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "공유 생성",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "파일 미리보기",
|
||||
"description": "파일 미리보기 및 다운로드",
|
||||
"loading": "로딩 중...",
|
||||
"notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.",
|
||||
"downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "비밀번호는 필수입니다",
|
||||
"nameRequired": "이름은 필수입니다",
|
||||
"required": "이 필드는 필수입니다"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "이미지 삽입",
|
||||
"description": "이 코드를 사용하여 포럼, 웹사이트 또는 기타 플랫폼에 이 이미지를 삽입하세요",
|
||||
"tabs": {
|
||||
"directLink": "직접 링크",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "이미지 파일에 대한 직접 URL",
|
||||
"htmlDescription": "이 코드를 사용하여 HTML 페이지에 이미지를 삽입하세요",
|
||||
"bbcodeDescription": "BBCode를 지원하는 포럼에 이미지를 삽입하려면 이 코드를 사용하세요"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -1930,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -1930,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -1931,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,9 @@
|
||||
"move": "Переместить",
|
||||
"rename": "Переименовать",
|
||||
"search": "Поиск",
|
||||
"share": "Поделиться"
|
||||
"share": "Поделиться",
|
||||
"copied": "Скопировано",
|
||||
"copy": "Копировать"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "Создать общий доступ",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "Предварительный просмотр файла",
|
||||
"description": "Просмотр и загрузка файла",
|
||||
"loading": "Загрузка...",
|
||||
"notAvailable": "Предварительный просмотр недоступен для этого типа файла.",
|
||||
"downloadToView": "Используйте кнопку загрузки для скачивания файла.",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "Требуется пароль",
|
||||
"nameRequired": "Требуется имя",
|
||||
"required": "Это поле обязательно"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "Встроить изображение",
|
||||
"description": "Используйте эти коды для встраивания этого изображения на форумах, веб-сайтах или других платформах",
|
||||
"tabs": {
|
||||
"directLink": "Прямая ссылка",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "Прямой URL-адрес файла изображения",
|
||||
"htmlDescription": "Используйте этот код для встраивания изображения в HTML-страницы",
|
||||
"bbcodeDescription": "Используйте этот код для встраивания изображения на форумах, поддерживающих BBCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -1930,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,9 @@
|
||||
"move": "移动",
|
||||
"rename": "重命名",
|
||||
"search": "搜索",
|
||||
"share": "分享"
|
||||
"share": "分享",
|
||||
"copied": "已复制",
|
||||
"copy": "复制"
|
||||
},
|
||||
"createShare": {
|
||||
"title": "创建分享",
|
||||
@@ -302,6 +304,7 @@
|
||||
},
|
||||
"filePreview": {
|
||||
"title": "文件预览",
|
||||
"description": "预览和下载文件",
|
||||
"loading": "加载中...",
|
||||
"notAvailable": "此文件类型不支持预览。",
|
||||
"downloadToView": "使用下载按钮下载文件。",
|
||||
@@ -1930,5 +1933,17 @@
|
||||
"passwordRequired": "密码为必填项",
|
||||
"nameRequired": "名称为必填项",
|
||||
"required": "此字段为必填项"
|
||||
},
|
||||
"embedCode": {
|
||||
"title": "嵌入图片",
|
||||
"description": "使用这些代码将此图片嵌入到论坛、网站或其他平台中",
|
||||
"tabs": {
|
||||
"directLink": "直接链接",
|
||||
"html": "HTML",
|
||||
"bbcode": "BBCode"
|
||||
},
|
||||
"directLinkDescription": "图片文件的直接URL",
|
||||
"htmlDescription": "使用此代码将图片嵌入HTML页面",
|
||||
"bbcodeDescription": "使用此代码将图片嵌入支持BBCode的论坛"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-web",
|
||||
"version": "3.2.4-beta",
|
||||
"version": "3.2.5-beta",
|
||||
"description": "Frontend for Palmr",
|
||||
"private": true,
|
||||
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
|
||||
|
||||
@@ -900,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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,23 +7,11 @@ import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
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;
|
||||
@@ -159,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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ReverseSharesSearch({
|
||||
<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 flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<Button variant="outline" size="icon" onClick={onRefresh} disabled={isRefreshing} className="sm:w-auto">
|
||||
<Button variant="outline" size="icon" onClick={onRefresh} disabled={isRefreshing}>
|
||||
<IconRefresh className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
<Button onClick={onCreateReverseShare} className="w-full sm:w-auto">
|
||||
|
||||
38
apps/web/src/app/api/(proxy)/files/download-url/route.ts
Normal file
38
apps/web/src/app/api/(proxy)/files/download-url/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
71
apps/web/src/app/e/[id]/route.ts
Normal file
71
apps/web/src/app/e/[id]/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
151
apps/web/src/components/files/embed-code-display.tsx
Normal file
151
apps/web/src/components/files/embed-code-display.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/components/files/media-embed-link.tsx
Normal file
72
apps/web/src/components/files/media-embed-link.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -187,8 +187,7 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
|
||||
|
||||
let url = downloadUrl;
|
||||
if (!url) {
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
const response = await getDownloadUrl(objectName);
|
||||
url = response.data.url;
|
||||
}
|
||||
|
||||
|
||||
@@ -181,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 },
|
||||
|
||||
@@ -80,7 +80,8 @@ export const getDownloadUrl = <TData = GetDownloadUrlResult>(
|
||||
objectName: string,
|
||||
options?: AxiosRequestConfig
|
||||
): Promise<TData> => {
|
||||
return apiInstance.get(`/api/files/download/${objectName}`, options);
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
return apiInstance.get(`/api/files/download-url?objectName=${encodedObjectName}`, options);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,8 +21,7 @@ async function waitForDownloadReady(objectName: string, fileName: string): Promi
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
const response = await getDownloadUrl(objectName);
|
||||
|
||||
if (response.status !== 202) {
|
||||
return response.data.url;
|
||||
@@ -98,13 +97,12 @@ export async function downloadFileWithQueue(
|
||||
options.onStart?.(downloadId);
|
||||
}
|
||||
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
|
||||
// getDownloadUrl already handles encoding
|
||||
const params: Record<string, string> = {};
|
||||
if (sharePassword) params.password = sharePassword;
|
||||
|
||||
const response = await getDownloadUrl(
|
||||
encodedObjectName,
|
||||
objectName,
|
||||
Object.keys(params).length > 0
|
||||
? {
|
||||
params: { ...params },
|
||||
@@ -208,13 +206,12 @@ export async function downloadFileAsBlobWithQueue(
|
||||
downloadUrl = response.data.url;
|
||||
}
|
||||
} else {
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
|
||||
// getDownloadUrl already handles encoding
|
||||
const params: Record<string, string> = {};
|
||||
if (sharePassword) params.password = sharePassword;
|
||||
|
||||
const response = await getDownloadUrl(
|
||||
encodedObjectName,
|
||||
objectName,
|
||||
Object.keys(params).length > 0
|
||||
? {
|
||||
params: { ...params },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "palmr-monorepo",
|
||||
"version": "3.2.4-beta",
|
||||
"version": "3.2.5-beta",
|
||||
"description": "Palmr monorepo with Husky configuration",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
|
||||
Reference in New Issue
Block a user