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:
84
src/files/files.controller.ts
Normal file
84
src/files/files.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user