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>
This commit is contained in:
copilot-swe-agent[bot]
2025-10-21 14:53:23 +00:00
parent 8df303c95f
commit 1ea27d81c2
8 changed files with 459 additions and 3 deletions

View File

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

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

@@ -113,6 +113,7 @@ export class FileController {
objectName: input.objectName,
userId,
folderId: input.folderId,
expiration: input.expiration ? new Date(input.expiration) : null,
},
});
@@ -125,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,
};
@@ -429,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,
}));
@@ -502,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 = {
@@ -514,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,
};
@@ -571,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,
};

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

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