Files
Fabric.Backend.Guild/src/files/files.controller.ts
hzhang 58badf328c 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>
2026-05-15 20:17:02 +01:00

85 lines
2.4 KiB
TypeScript

import {
BadRequestException,
Controller,
Get,
Param,
Post,
Query,
Req,
Res,
UnauthorizedException,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { FilesService } from './files.service.js';
type AuthedRequest = { userId?: string };
type UploadedMulterFile = {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
};
@Controller('files')
export class FilesController {
constructor(private readonly files: FilesService) {}
@Post()
@UseInterceptors(FileInterceptor('file'))
async upload(
@Req() req: AuthedRequest,
@UploadedFile() file: UploadedMulterFile | undefined,
@Query('channelId') channelId?: string,
) {
const userId = req.userId ?? '';
if (!userId) throw new UnauthorizedException('missing user');
if (!file || !file.buffer?.length) throw new BadRequestException('no file');
if (this.files.maxBytes > 0 && file.size > this.files.maxBytes) {
throw new BadRequestException(
`file exceeds limit of ${this.files.maxBytes} bytes`,
);
}
const row = await this.files.store({
channelId: channelId ? String(channelId) : null,
uploaderUserId: userId,
originalName: file.originalname || 'file',
mimeType: file.mimetype,
buffer: file.buffer,
});
return {
fileId: row.fileId,
url: `/api/files/${row.fileId}`,
name: row.originalName,
mimeType: row.mimeType,
size: Number(row.sizeBytes),
expiresAt: row.expiresAt.toISOString(),
};
}
@Get(':fileId')
async download(
@Param('fileId') fileId: string,
@Res() res: Response,
): Promise<void> {
const row = await this.files.find(fileId);
if (!row) {
res.status(404).json({ error: 'file_not_found' });
return;
}
const blob = await this.files.readBlob(row);
const inline = /^(image|audio|video)\//.test(row.mimeType) || row.mimeType === 'application/pdf';
const safeName = row.originalName.replace(/["\r\n]/g, '_');
res.setHeader('Content-Type', row.mimeType);
res.setHeader('Content-Length', String(blob.length));
res.setHeader(
'Content-Disposition',
`${inline ? 'inline' : 'attachment'}; filename="${safeName}"`,
);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.end(blob);
}
}