feat(guild): file upload/retention + channel canvas
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>
This commit is contained in:
98
src/files/files.service.ts
Normal file
98
src/files/files.service.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user