import { randomBytes } from 'node:crypto'; import { promises as fs } from 'node:fs'; import { join, resolve } from 'node:path'; import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { LessThan, Repository } from 'typeorm'; import { StoredFile } from '../entities/stored-file.entity.js'; const DAY_MS = 24 * 60 * 60 * 1000; const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // hourly @Injectable() export class FilesService implements OnModuleInit, OnModuleDestroy { private readonly log = new Logger('FilesService'); private timer: NodeJS.Timeout | null = null; // Storage root; the guild operator may relocate / resize this freely. readonly dir = resolve( process.env.FABRIC_BACKEND_GUILD_FILE_DIR ?? join(process.cwd(), '.data', 'files'), ); // 0 / unset => no cap (default per product: 100MB, operator-configurable). readonly maxBytes = Number( process.env.FABRIC_BACKEND_GUILD_FILE_MAX_BYTES ?? 100 * 1024 * 1024, ); readonly ttlDays = Number(process.env.FABRIC_BACKEND_GUILD_FILE_TTL_DAYS ?? 7); constructor( @InjectRepository(StoredFile) private readonly repo: Repository, ) {} async onModuleInit(): Promise { await fs.mkdir(this.dir, { recursive: true }); this.log.log( `files dir=${this.dir} maxBytes=${this.maxBytes} ttlDays=${this.ttlDays}`, ); // sweep on boot, then hourly void this.cleanup(); this.timer = setInterval(() => void this.cleanup(), CLEANUP_INTERVAL_MS); this.timer.unref?.(); } onModuleDestroy(): void { if (this.timer) clearInterval(this.timer); this.timer = null; } async store(input: { channelId: string | null; uploaderUserId: string; originalName: string; mimeType: string; buffer: Buffer; }): Promise { const fileId = randomBytes(18).toString('base64url'); const storagePath = fileId; // flat layout, opaque name await fs.writeFile(join(this.dir, storagePath), input.buffer); const row = this.repo.create({ fileId, channelId: input.channelId, uploaderUserId: input.uploaderUserId, originalName: input.originalName.slice(0, 255), mimeType: (input.mimeType || 'application/octet-stream').slice(0, 150), sizeBytes: input.buffer.length, storagePath, expiresAt: new Date(Date.now() + this.ttlDays * DAY_MS), }); return this.repo.save(row); } async find(fileId: string): Promise { const row = await this.repo.findOne({ where: { fileId } }); if (!row) return null; if (row.expiresAt.getTime() <= Date.now()) return null; // treat as gone return row; } async readBlob(row: StoredFile): Promise { return fs.readFile(join(this.dir, row.storagePath)); } // Purge every row past its retention deadline together with its blob. async cleanup(): Promise { const expired = await this.repo.find({ where: { expiresAt: LessThan(new Date()) } }); let removed = 0; for (const row of expired) { try { await fs.rm(join(this.dir, row.storagePath), { force: true }); } catch { /* best effort: drop the row regardless */ } await this.repo.delete({ id: row.id }); removed++; } if (removed) this.log.log(`retention sweep removed ${removed} expired file(s)`); return removed; } }