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:
h z
2026-05-15 20:17:02 +01:00
parent b3fcefb5ec
commit 58badf328c
11 changed files with 523 additions and 2 deletions

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