Compare commits

..

9 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
807507325f docs: add migration guide for file expiration feature
Co-authored-by: danielalves96 <62755605+danielalves96@users.noreply.github.com>
2025-10-21 15:05:50 +00:00
copilot-swe-agent[bot]
0ef8266b65 docs: add comprehensive documentation for file expiration and cleanup scripts
Co-authored-by: danielalves96 <62755605+danielalves96@users.noreply.github.com>
2025-10-21 15:01:32 +00:00
copilot-swe-agent[bot]
1ea27d81c2 feat(server): add file expiration feature with automatic cleanup
- Add expiration field to File model in Prisma schema
- Create database migration for file expiration
- Update File DTOs to support expiration during upload/edit
- Create scheduled cleanup script for expired files
- Update file controller to handle expiration field in all operations
- Update API routes to include expiration in responses
- Add npm scripts for running cleanup (dry-run and confirm modes)

Co-authored-by: danielalves96 <62755605+danielalves96@users.noreply.github.com>
2025-10-21 14:53:23 +00:00
copilot-swe-agent[bot]
8df303c95f Initial exploration and analysis complete
Co-authored-by: danielalves96 <62755605+danielalves96@users.noreply.github.com>
2025-10-21 14:45:50 +00:00
copilot-swe-agent[bot]
0742cb2110 Initial plan 2025-10-21 14:38:05 +00:00
Daniel Luiz Alves
cb4ed3f581 version: update package versions from 3.2.4-beta to 3.2.5-beta across all packages 2025-10-21 11:24:11 -03:00
Copilot
148676513d fix: issue with OIDC Google auto-registration for users (#314)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danielalves96 <62755605+danielalves96@users.noreply.github.com>
2025-10-21 11:15:48 -03:00
Copilot
42a5b7a796 feat: add functionality to embed uploaded images with BBCode or HTML (#296) 2025-10-21 11:14:46 -03:00
Daniel Luiz Alves
59fccd9a93 feat: implement file download and preview features with improved URL handling (#315) 2025-10-21 10:00:13 -03:00
46 changed files with 1955 additions and 127 deletions

View File

@@ -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>",

View 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

View File

@@ -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"
]
}
}

View File

@@ -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");

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

View File

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

View File

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

View File

@@ -1,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 },

View File

@@ -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({

View File

@@ -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"),
}),

View File

@@ -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);
}

View File

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

View 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

View 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);
});

View File

@@ -150,7 +150,9 @@
"move": "نقل",
"rename": "إعادة تسمية",
"search": "بحث",
"share": "مشاركة"
"share": "مشاركة",
"copied": "تم النسخ",
"copy": "نسخ"
},
"createShare": {
"title": "إنشاء مشاركة",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "معاينة الملف",
"description": "معاينة وتنزيل الملف",
"loading": "جاري التحميل...",
"notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.",
"downloadToView": "استخدم زر التحميل لتنزيل الملف.",
@@ -1932,5 +1935,17 @@
"passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
},
"embedCode": {
"title": "تضمين الصورة",
"description": "استخدم هذه الأكواد لتضمين هذه الصورة في المنتديات أو المواقع الإلكترونية أو المنصات الأخرى",
"tabs": {
"directLink": "رابط مباشر",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "عنوان URL مباشر لملف الصورة",
"htmlDescription": "استخدم هذا الكود لتضمين الصورة في صفحات HTML",
"bbcodeDescription": "استخدم هذا الكود لتضمين الصورة في المنتديات التي تدعم BBCode"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Verschieben",
"rename": "Umbenennen",
"search": "Suchen",
"share": "Teilen"
"share": "Teilen",
"copied": "Kopiert",
"copy": "Kopieren"
},
"createShare": {
"title": "Freigabe Erstellen",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Datei-Vorschau",
"description": "Vorschau und Download der Datei",
"loading": "Laden...",
"notAvailable": "Vorschau für diesen Dateityp nicht verfügbar.",
"downloadToView": "Verwenden Sie die Download-Schaltfläche, um die Datei herunterzuladen.",
@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Mover",
"rename": "Renombrar",
"search": "Buscar",
"share": "Compartir"
"share": "Compartir",
"copied": "Copiado",
"copy": "Copiar"
},
"createShare": {
"title": "Crear Compartir",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Vista Previa del Archivo",
"description": "Vista previa y descarga de archivo",
"loading": "Cargando...",
"notAvailable": "Vista previa no disponible para este tipo de archivo.",
"downloadToView": "Use el botón de descarga para descargar el archivo.",
@@ -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"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Déplacer",
"rename": "Renommer",
"search": "Rechercher",
"share": "Partager"
"share": "Partager",
"copied": "Copié",
"copy": "Copier"
},
"createShare": {
"title": "Créer un Partage",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Aperçu du Fichier",
"description": "Aperçu et téléchargement du fichier",
"loading": "Chargement...",
"notAvailable": "Aperçu non disponible pour ce type de fichier.",
"downloadToView": "Utilisez le bouton de téléchargement pour télécharger le fichier.",
@@ -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"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "स्थानांतरित करें",
"rename": "नाम बदलें",
"search": "खोजें",
"share": "साझा करें"
"share": "साझा करें",
"copied": "कॉपी किया गया",
"copy": "कॉपी करें"
},
"createShare": {
"title": "साझाकरण बनाएं",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "फ़ाइल पूर्वावलोकन",
"description": "फ़ाइल पूर्वावलोकन और डाउनलोड",
"loading": "लोड हो रहा है...",
"notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।",
"downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।",
@@ -1930,5 +1933,17 @@
"passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
},
"embedCode": {
"title": "छवि एम्बेड करें",
"description": "इस छवि को मंचों, वेबसाइटों या अन्य प्लेटफार्मों में एम्बेड करने के लिए इन कोड का उपयोग करें",
"tabs": {
"directLink": "सीधा लिंक",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "छवि फ़ाइल का सीधा URL",
"htmlDescription": "HTML पेजों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें",
"bbcodeDescription": "BBCode का समर्थन करने वाले मंचों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Sposta",
"rename": "Rinomina",
"search": "Cerca",
"share": "Condividi"
"share": "Condividi",
"copied": "Copiato",
"copy": "Copia"
},
"createShare": {
"title": "Crea Condivisione",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Anteprima File",
"description": "Anteprima e download del file",
"loading": "Caricamento...",
"notAvailable": "Anteprima non disponibile per questo tipo di file.",
"downloadToView": "Utilizzare il pulsante di download per scaricare il file.",
@@ -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"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "移動",
"rename": "名前を変更",
"search": "検索",
"share": "共有"
"share": "共有",
"copied": "コピーしました",
"copy": "コピー"
},
"createShare": {
"title": "共有を作成",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "ファイルプレビュー",
"description": "ファイルをプレビューしてダウンロード",
"loading": "読み込み中...",
"notAvailable": "このファイルタイプのプレビューは利用できません。",
"downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。",
@@ -1930,5 +1933,17 @@
"passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
},
"embedCode": {
"title": "画像を埋め込む",
"description": "これらのコードを使用して、この画像をフォーラム、ウェブサイト、またはその他のプラットフォームに埋め込みます",
"tabs": {
"directLink": "直接リンク",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "画像ファイルへの直接URL",
"htmlDescription": "このコードを使用してHTMLページに画像を埋め込みます",
"bbcodeDescription": "BBCodeをサポートするフォーラムに画像を埋め込むには、このコードを使用します"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "이동",
"rename": "이름 변경",
"search": "검색",
"share": "공유"
"share": "공유",
"copied": "복사됨",
"copy": "복사"
},
"createShare": {
"title": "공유 생성",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "파일 미리보기",
"description": "파일 미리보기 및 다운로드",
"loading": "로딩 중...",
"notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.",
"downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.",
@@ -1930,5 +1933,17 @@
"passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
},
"embedCode": {
"title": "이미지 삽입",
"description": "이 코드를 사용하여 포럼, 웹사이트 또는 기타 플랫폼에 이 이미지를 삽입하세요",
"tabs": {
"directLink": "직접 링크",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "이미지 파일에 대한 직접 URL",
"htmlDescription": "이 코드를 사용하여 HTML 페이지에 이미지를 삽입하세요",
"bbcodeDescription": "BBCode를 지원하는 포럼에 이미지를 삽입하려면 이 코드를 사용하세요"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Verplaatsen",
"rename": "Hernoemen",
"search": "Zoeken",
"share": "Delen"
"share": "Delen",
"copied": "Gekopieerd",
"copy": "Kopiëren"
},
"createShare": {
"title": "Delen Maken",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Bestandsvoorbeeld",
"description": "Bestand bekijken en downloaden",
"loading": "Laden...",
"notAvailable": "Voorbeeld niet beschikbaar voor dit bestandstype.",
"downloadToView": "Gebruik de downloadknop om het bestand te downloaden.",
@@ -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"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Przenieś",
"rename": "Zmień nazwę",
"search": "Szukaj",
"share": "Udostępnij"
"share": "Udostępnij",
"copied": "Skopiowano",
"copy": "Kopiuj"
},
"createShare": {
"title": "Utwórz Udostępnienie",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Podgląd pliku",
"description": "Podgląd i pobieranie pliku",
"loading": "Ładowanie...",
"notAvailable": "Podgląd niedostępny dla tego typu pliku",
"downloadToView": "Użyj przycisku pobierania, aby wyświetlić ten plik",
@@ -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"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Mover",
"rename": "Renomear",
"search": "Pesquisar",
"share": "Compartilhar"
"share": "Compartilhar",
"copied": "Copiado",
"copy": "Copiar"
},
"createShare": {
"title": "Criar compartilhamento",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Visualizar Arquivo",
"description": "Visualizar e baixar arquivo",
"loading": "Carregando...",
"notAvailable": "Preview não disponível para este tipo de arquivo.",
"downloadToView": "Use o botão de download para baixar o arquivo.",
@@ -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"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Переместить",
"rename": "Переименовать",
"search": "Поиск",
"share": "Поделиться"
"share": "Поделиться",
"copied": "Скопировано",
"copy": "Копировать"
},
"createShare": {
"title": "Создать общий доступ",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Предварительный просмотр файла",
"description": "Просмотр и загрузка файла",
"loading": "Загрузка...",
"notAvailable": "Предварительный просмотр недоступен для этого типа файла.",
"downloadToView": "Используйте кнопку загрузки для скачивания файла.",
@@ -1930,5 +1933,17 @@
"passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
},
"embedCode": {
"title": "Встроить изображение",
"description": "Используйте эти коды для встраивания этого изображения на форумах, веб-сайтах или других платформах",
"tabs": {
"directLink": "Прямая ссылка",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Прямой URL-адрес файла изображения",
"htmlDescription": "Используйте этот код для встраивания изображения в HTML-страницы",
"bbcodeDescription": "Используйте этот код для встраивания изображения на форумах, поддерживающих BBCode"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Taşı",
"rename": "Yeniden Adlandır",
"search": "Ara",
"share": "Paylaş"
"share": "Paylaş",
"copied": "Kopyalandı",
"copy": "Kopyala"
},
"createShare": {
"title": "Paylaşım Oluştur",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Dosya Önizleme",
"description": "Dosyayı önizleyin ve indirin",
"loading": "Yükleniyor...",
"notAvailable": "Bu dosya türü için önizleme mevcut değil.",
"downloadToView": "Dosyayı indirmek için indirme düğmesini kullanın.",
@@ -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"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "移动",
"rename": "重命名",
"search": "搜索",
"share": "分享"
"share": "分享",
"copied": "已复制",
"copy": "复制"
},
"createShare": {
"title": "创建分享",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "文件预览",
"description": "预览和下载文件",
"loading": "加载中...",
"notAvailable": "此文件类型不支持预览。",
"downloadToView": "使用下载按钮下载文件。",
@@ -1930,5 +1933,17 @@
"passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
},
"embedCode": {
"title": "嵌入图片",
"description": "使用这些代码将此图片嵌入到论坛、网站或其他平台中",
"tabs": {
"directLink": "直接链接",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "图片文件的直接URL",
"htmlDescription": "使用此代码将图片嵌入HTML页面",
"bbcodeDescription": "使用此代码将图片嵌入支持BBCode的论坛"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-web",
"version": "3.2.4-beta",
"version": "3.2.5-beta",
"description": "Frontend for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -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} />
)}
</>
);

View File

@@ -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} />
)}
</>
);

View File

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

View File

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

View File

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

View File

@@ -4,13 +4,22 @@ import { detectMimeTypeWithFallback } from "@/utils/mime-types";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ objectPath: string[] }> }) {
const { objectPath } = await params;
export async function GET(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const objectName = objectPath.join("/");
const searchParams = req.nextUrl.searchParams;
const objectName = searchParams.get("objectName");
if (!objectName) {
return new NextResponse(JSON.stringify({ error: "objectName parameter is required" }), {
status: 400,
headers: {
"Content-Type": "application/json",
},
});
}
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files/${encodeURIComponent(objectName)}/download${queryString ? `?${queryString}` : ""}`;
const url = `${API_BASE_URL}/files/download?${queryString}`;
const apiRes = await fetch(url, {
method: "GET",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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 },

View File

@@ -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);
};
/**

View File

@@ -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 },

View File

@@ -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",