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

@@ -13,6 +13,8 @@ import { MessagingModule } from './messaging/messaging.module.js';
import { EventsModule } from './events/events.module.js';
import { RealtimeModule } from './realtime/realtime.module.js';
import { MembersModule } from './members/members.module.js';
import { FilesModule } from './files/files.module.js';
import { CanvasModule } from './canvas/canvas.module.js';
@Module({
imports: [
@@ -24,6 +26,8 @@ import { MembersModule } from './members/members.module.js';
ChannelsModule,
MembersModule,
MessagingModule,
FilesModule,
CanvasModule,
],
controllers: [HealthController, MetricsController],
providers: [

View File

@@ -0,0 +1,58 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Patch,
Req,
UnauthorizedException,
} from '@nestjs/common';
import { CanvasService } from './canvas.service.js';
type AuthedRequest = { userId?: string };
type CanvasBody = { title?: string; format?: string; source?: string };
@Controller('channels/:id/canvas')
export class CanvasController {
constructor(private readonly canvas: CanvasService) {}
private uid(req: AuthedRequest): string {
const userId = req.userId ?? '';
if (!userId) throw new UnauthorizedException('missing user');
return userId;
}
@Get()
get(@Req() req: AuthedRequest, @Param('id') channelId: string) {
return this.canvas.get(channelId, this.uid(req));
}
// share / replace (caller becomes the sharer)
@Put()
@Post()
share(
@Req() req: AuthedRequest,
@Param('id') channelId: string,
@Body() body: CanvasBody,
) {
return this.canvas.share(channelId, this.uid(req), body ?? {});
}
// update in place (original sharer only)
@Patch()
update(
@Req() req: AuthedRequest,
@Param('id') channelId: string,
@Body() body: CanvasBody,
) {
return this.canvas.update(channelId, this.uid(req), body ?? {});
}
@Delete()
remove(@Req() req: AuthedRequest, @Param('id') channelId: string) {
return this.canvas.remove(channelId, this.uid(req));
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Channel } from '../entities/channel.entity.js';
import { ChannelMember } from '../entities/channel-member.entity.js';
import { ChannelCanvas } from '../entities/channel-canvas.entity.js';
import { CanvasController } from './canvas.controller.js';
import { CanvasService } from './canvas.service.js';
@Module({
imports: [TypeOrmModule.forFeature([Channel, ChannelMember, ChannelCanvas])],
controllers: [CanvasController],
providers: [CanvasService],
})
export class CanvasModule {}

View File

@@ -0,0 +1,147 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Channel } from '../entities/channel.entity.js';
import { ChannelMember } from '../entities/channel-member.entity.js';
import {
ChannelCanvas,
type CanvasFormat,
} from '../entities/channel-canvas.entity.js';
import { RealtimeGateway } from '../realtime/realtime.gateway.js';
const FORMATS: CanvasFormat[] = ['md', 'html', 'text'];
@Injectable()
export class CanvasService {
constructor(
@InjectRepository(Channel)
private readonly channelRepo: Repository<Channel>,
@InjectRepository(ChannelMember)
private readonly memberRepo: Repository<ChannelMember>,
@InjectRepository(ChannelCanvas)
private readonly canvasRepo: Repository<ChannelCanvas>,
private readonly realtime: RealtimeGateway,
) {}
private view(c: ChannelCanvas) {
return {
channelId: c.channelId,
sharerUserId: c.sharerUserId,
title: c.title,
format: c.format,
source: c.source,
version: c.version,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
};
}
private async assertChannel(channelId: string) {
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
if (!channel) throw new NotFoundException('channel not found');
return channel;
}
private async assertParticipant(channelId: string, userId: string) {
const channel = await this.assertChannel(channelId);
if (channel.isPublic) return channel;
const member = await this.memberRepo.findOne({ where: { channelId, userId } });
if (!member) throw new ForbiddenException('not a channel member');
return channel;
}
async get(channelId: string, userId: string) {
await this.assertParticipant(channelId, userId);
const c = await this.canvasRepo.findOne({ where: { channelId } });
return c ? this.view(c) : null;
}
private normalize(input: {
title?: string;
format?: string;
source?: string;
}) {
const title = String(input.title ?? '').trim().slice(0, 200) || 'Untitled';
const format = String(input.format ?? 'md') as CanvasFormat;
if (!FORMATS.includes(format)) {
throw new BadRequestException(`format must be one of: ${FORMATS.join(', ')}`);
}
const source = String(input.source ?? '');
return { title, format, source };
}
// Share / replace the channel's single active canvas (caller becomes sharer).
async share(
channelId: string,
userId: string,
input: { title?: string; format?: string; source?: string },
) {
await this.assertParticipant(channelId, userId);
const { title, format, source } = this.normalize(input);
let c = await this.canvasRepo.findOne({ where: { channelId } });
if (c) {
c.sharerUserId = userId;
c.title = title;
c.format = format;
c.source = source;
c.version = 1;
} else {
c = this.canvasRepo.create({
channelId,
sharerUserId: userId,
title,
format,
source,
version: 1,
});
}
c = await this.canvasRepo.save(c);
const v = this.view(c);
this.realtime.emitChannelEvent(channelId, 'canvas.updated', v);
return v;
}
// Update the existing canvas in place — only the original sharer.
async update(
channelId: string,
userId: string,
input: { title?: string; format?: string; source?: string },
) {
await this.assertParticipant(channelId, userId);
const c = await this.canvasRepo.findOne({ where: { channelId } });
if (!c) throw new NotFoundException('no canvas shared in this channel');
if (c.sharerUserId !== userId) {
throw new ForbiddenException('only the original sharer may update the canvas');
}
const { title, format, source } = this.normalize({
title: input.title ?? c.title,
format: input.format ?? c.format,
source: input.source ?? c.source,
});
c.title = title;
c.format = format;
c.source = source;
c.version += 1;
const saved = await this.canvasRepo.save(c);
const v = this.view(saved);
this.realtime.emitChannelEvent(channelId, 'canvas.updated', v);
return v;
}
async remove(channelId: string, userId: string) {
await this.assertParticipant(channelId, userId);
const c = await this.canvasRepo.findOne({ where: { channelId } });
if (!c) return { status: 'ok' };
if (c.sharerUserId !== userId) {
throw new ForbiddenException('only the original sharer may remove the canvas');
}
await this.canvasRepo.delete({ id: c.id });
this.realtime.emitChannelEvent(channelId, 'canvas.removed', { channelId });
return { status: 'ok' };
}
}

View File

@@ -9,7 +9,11 @@ import { introspectGuildToken } from './center-auth.js';
@Injectable()
export class ApiKeyGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<{ path?: string; headers: Record<string, string | string[] | undefined> }>();
const req = context.switchToHttp().getRequest<{
path?: string;
headers: Record<string, string | string[] | undefined>;
query?: Record<string, string | string[] | undefined>;
}>();
const path = req.path ?? '';
// allow health check without auth
@@ -19,7 +23,13 @@ export class ApiKeyGuard implements CanActivate {
const auth = req.headers['authorization'];
const authValue = Array.isArray(auth) ? auth[0] : auth;
const token = authValue?.startsWith('Bearer ') ? authValue.slice(7) : '';
let token = authValue?.startsWith('Bearer ') ? authValue.slice(7) : '';
// Browsers can't set Authorization on <img>/<a> (file downloads); accept
// the guild token via ?access_token= as a fallback. Still introspected.
if (!token) {
const qt = req.query?.['access_token'];
token = (Array.isArray(qt) ? qt[0] : qt) ?? '';
}
if (!token) throw new UnauthorizedException('missing bearer token');
const result = await introspectGuildToken(token);

View File

@@ -11,6 +11,8 @@ import { GuildRole } from './entities/guild-role.entity.js';
import { GuildMember } from './entities/guild-member.entity.js';
import { GuildMemberRole } from './entities/guild-member-role.entity.js';
import { IdempotencyRecord } from './entities/idempotency-record.entity.js';
import { StoredFile } from './entities/stored-file.entity.js';
import { ChannelCanvas } from './entities/channel-canvas.entity.js';
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
type: 'mysql',
@@ -32,6 +34,8 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
GuildMember,
GuildMemberRole,
IdempotencyRecord,
StoredFile,
ChannelCanvas,
],
synchronize: (process.env.FABRIC_BACKEND_GUILD_DB_SYNC ?? 'true') === 'true',
logging: (process.env.FABRIC_BACKEND_GUILD_DB_LOGGING ?? 'false') === 'true',

View File

@@ -0,0 +1,46 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export type CanvasFormat = 'md' | 'html' | 'text';
// One active shared document per channel (ChatGPT-canvas-like). Re-sharing
// replaces it; only the original sharer may update it in place. Pinned in
// the channel UI, independent of the message scroll.
@Entity('channel_canvas')
export class ChannelCanvas {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index({ unique: true })
@Column({ name: 'channel_id', type: 'char', length: 36 })
channelId!: string;
// who shared it; only this user may PATCH/DELETE
@Column({ name: 'sharer_user_id', type: 'varchar', length: 64 })
sharerUserId!: string;
@Column({ type: 'varchar', length: 200 })
title!: string;
@Column({ type: 'varchar', length: 8 })
format!: CanvasFormat;
// raw document source (rendered client-side per format)
@Column({ type: 'mediumtext' })
source!: string;
@Column({ type: 'int', default: 1 })
version!: number;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}

View File

@@ -0,0 +1,43 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
// An uploaded or canvas-shared file held on the guild node. Retained for a
// configurable window (default 7 days) then purged by FilesService.
@Entity('stored_files')
export class StoredFile {
@PrimaryGeneratedColumn('uuid')
id!: string;
// public, URL-safe id used in /api/files/:fileId
@Index({ unique: true })
@Column({ name: 'file_id', type: 'varchar', length: 64 })
fileId!: string;
// owning channel (best-effort context; null = not channel-scoped)
@Index()
@Column({ name: 'channel_id', type: 'char', length: 36, nullable: true })
channelId!: string | null;
@Column({ name: 'uploader_user_id', type: 'varchar', length: 64 })
uploaderUserId!: string;
@Column({ name: 'original_name', type: 'varchar', length: 255 })
originalName!: string;
@Column({ name: 'mime_type', type: 'varchar', length: 150 })
mimeType!: string;
@Column({ name: 'size_bytes', type: 'bigint' })
sizeBytes!: number;
// path on disk relative to the storage root
@Column({ name: 'storage_path', type: 'varchar', length: 300 })
storagePath!: string;
@CreateDateColumn()
createdAt!: Date;
// hard-delete deadline; rows past this are purged with their blob
@Index()
@Column({ name: 'expires_at', type: 'datetime' })
expiresAt!: Date;
}

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

13
src/files/files.module.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StoredFile } from '../entities/stored-file.entity.js';
import { FilesController } from './files.controller.js';
import { FilesService } from './files.service.js';
@Module({
imports: [TypeOrmModule.forFeature([StoredFile])],
controllers: [FilesController],
providers: [FilesService],
exports: [FilesService],
})
export class FilesModule {}

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