From 58badf328c2c1523b228007cad43a7780a80d4d7 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 15 May 2026 20:17:02 +0100 Subject: [PATCH] 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 /). 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) --- src/app.module.ts | 4 + src/canvas/canvas.controller.ts | 58 ++++++++++ src/canvas/canvas.module.ts | 14 +++ src/canvas/canvas.service.ts | 147 ++++++++++++++++++++++++++ src/common/api-key.guard.ts | 14 ++- src/database.config.ts | 4 + src/entities/channel-canvas.entity.ts | 46 ++++++++ src/entities/stored-file.entity.ts | 43 ++++++++ src/files/files.controller.ts | 84 +++++++++++++++ src/files/files.module.ts | 13 +++ src/files/files.service.ts | 98 +++++++++++++++++ 11 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 src/canvas/canvas.controller.ts create mode 100644 src/canvas/canvas.module.ts create mode 100644 src/canvas/canvas.service.ts create mode 100644 src/entities/channel-canvas.entity.ts create mode 100644 src/entities/stored-file.entity.ts create mode 100644 src/files/files.controller.ts create mode 100644 src/files/files.module.ts create mode 100644 src/files/files.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 33adfcd..20edcc6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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: [ diff --git a/src/canvas/canvas.controller.ts b/src/canvas/canvas.controller.ts new file mode 100644 index 0000000..97f275e --- /dev/null +++ b/src/canvas/canvas.controller.ts @@ -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)); + } +} diff --git a/src/canvas/canvas.module.ts b/src/canvas/canvas.module.ts new file mode 100644 index 0000000..e7f8f99 --- /dev/null +++ b/src/canvas/canvas.module.ts @@ -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 {} diff --git a/src/canvas/canvas.service.ts b/src/canvas/canvas.service.ts new file mode 100644 index 0000000..7aec79e --- /dev/null +++ b/src/canvas/canvas.service.ts @@ -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, + @InjectRepository(ChannelMember) + private readonly memberRepo: Repository, + @InjectRepository(ChannelCanvas) + private readonly canvasRepo: Repository, + 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' }; + } +} diff --git a/src/common/api-key.guard.ts b/src/common/api-key.guard.ts index ac7b9d8..eb27a83 100644 --- a/src/common/api-key.guard.ts +++ b/src/common/api-key.guard.ts @@ -9,7 +9,11 @@ import { introspectGuildToken } from './center-auth.js'; @Injectable() export class ApiKeyGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { - const req = context.switchToHttp().getRequest<{ path?: string; headers: Record }>(); + const req = context.switchToHttp().getRequest<{ + path?: string; + headers: Record; + query?: Record; + }>(); 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 / (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); diff --git a/src/database.config.ts b/src/database.config.ts index 5d25642..b3659d3 100644 --- a/src/database.config.ts +++ b/src/database.config.ts @@ -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', diff --git a/src/entities/channel-canvas.entity.ts b/src/entities/channel-canvas.entity.ts new file mode 100644 index 0000000..09ec542 --- /dev/null +++ b/src/entities/channel-canvas.entity.ts @@ -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; +} diff --git a/src/entities/stored-file.entity.ts b/src/entities/stored-file.entity.ts new file mode 100644 index 0000000..b9ff013 --- /dev/null +++ b/src/entities/stored-file.entity.ts @@ -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; +} diff --git a/src/files/files.controller.ts b/src/files/files.controller.ts new file mode 100644 index 0000000..10d193b --- /dev/null +++ b/src/files/files.controller.ts @@ -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 { + 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); + } +} diff --git a/src/files/files.module.ts b/src/files/files.module.ts new file mode 100644 index 0000000..a0f7619 --- /dev/null +++ b/src/files/files.module.ts @@ -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 {} diff --git a/src/files/files.service.ts b/src/files/files.service.ts new file mode 100644 index 0000000..e4dee4d --- /dev/null +++ b/src/files/files.service.ts @@ -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, + ) {} + + async onModuleInit(): Promise { + 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 { + 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 { + 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 { + return fs.readFile(join(this.dir, row.storagePath)); + } + + // Purge every row past its retention deadline together with its blob. + async cleanup(): Promise { + 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; + } +}