Files: - StoredFile entity + FilesModule: multipart upload (configurable FABRIC_BACKEND_GUILD_FILE_MAX_BYTES, default 100MB; no type limit), authenticated download (Bearer or ?access_token=), hourly + on-boot retention sweep (FABRIC_BACKEND_GUILD_FILE_TTL_DAYS, default 7). - ApiKeyGuard also accepts ?access_token= (browser <img>/<a>). Canvas: - ChannelCanvas entity (one active per channel) + CanvasModule: GET / PUT|POST (share-replace, caller becomes sharer) / PATCH (sharer-only in-place update, version++) / DELETE (sharer-only). Emits canvas.updated / canvas.removed to the channel room. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
99 lines
3.4 KiB
TypeScript
99 lines
3.4 KiB
TypeScript
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<StoredFile>,
|
|
) {}
|
|
|
|
async onModuleInit(): Promise<void> {
|
|
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<StoredFile> {
|
|
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<StoredFile | null> {
|
|
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<Buffer> {
|
|
return fs.readFile(join(this.dir, row.storagePath));
|
|
}
|
|
|
|
// Purge every row past its retention deadline together with its blob.
|
|
async cleanup(): Promise<number> {
|
|
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;
|
|
}
|
|
}
|