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:
@@ -13,6 +13,8 @@ import { MessagingModule } from './messaging/messaging.module.js';
|
|||||||
import { EventsModule } from './events/events.module.js';
|
import { EventsModule } from './events/events.module.js';
|
||||||
import { RealtimeModule } from './realtime/realtime.module.js';
|
import { RealtimeModule } from './realtime/realtime.module.js';
|
||||||
import { MembersModule } from './members/members.module.js';
|
import { MembersModule } from './members/members.module.js';
|
||||||
|
import { FilesModule } from './files/files.module.js';
|
||||||
|
import { CanvasModule } from './canvas/canvas.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -24,6 +26,8 @@ import { MembersModule } from './members/members.module.js';
|
|||||||
ChannelsModule,
|
ChannelsModule,
|
||||||
MembersModule,
|
MembersModule,
|
||||||
MessagingModule,
|
MessagingModule,
|
||||||
|
FilesModule,
|
||||||
|
CanvasModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController, MetricsController],
|
controllers: [HealthController, MetricsController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
58
src/canvas/canvas.controller.ts
Normal file
58
src/canvas/canvas.controller.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/canvas/canvas.module.ts
Normal file
14
src/canvas/canvas.module.ts
Normal 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 {}
|
||||||
147
src/canvas/canvas.service.ts
Normal file
147
src/canvas/canvas.service.ts
Normal 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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,11 @@ import { introspectGuildToken } from './center-auth.js';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiKeyGuard implements CanActivate {
|
export class ApiKeyGuard implements CanActivate {
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
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 ?? '';
|
const path = req.path ?? '';
|
||||||
|
|
||||||
// allow health check without auth
|
// allow health check without auth
|
||||||
@@ -19,7 +23,13 @@ export class ApiKeyGuard implements CanActivate {
|
|||||||
|
|
||||||
const auth = req.headers['authorization'];
|
const auth = req.headers['authorization'];
|
||||||
const authValue = Array.isArray(auth) ? auth[0] : auth;
|
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');
|
if (!token) throw new UnauthorizedException('missing bearer token');
|
||||||
|
|
||||||
const result = await introspectGuildToken(token);
|
const result = await introspectGuildToken(token);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { GuildRole } from './entities/guild-role.entity.js';
|
|||||||
import { GuildMember } from './entities/guild-member.entity.js';
|
import { GuildMember } from './entities/guild-member.entity.js';
|
||||||
import { GuildMemberRole } from './entities/guild-member-role.entity.js';
|
import { GuildMemberRole } from './entities/guild-member-role.entity.js';
|
||||||
import { IdempotencyRecord } from './entities/idempotency-record.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 => ({
|
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||||
type: 'mysql',
|
type: 'mysql',
|
||||||
@@ -32,6 +34,8 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
|||||||
GuildMember,
|
GuildMember,
|
||||||
GuildMemberRole,
|
GuildMemberRole,
|
||||||
IdempotencyRecord,
|
IdempotencyRecord,
|
||||||
|
StoredFile,
|
||||||
|
ChannelCanvas,
|
||||||
],
|
],
|
||||||
synchronize: (process.env.FABRIC_BACKEND_GUILD_DB_SYNC ?? 'true') === 'true',
|
synchronize: (process.env.FABRIC_BACKEND_GUILD_DB_SYNC ?? 'true') === 'true',
|
||||||
logging: (process.env.FABRIC_BACKEND_GUILD_DB_LOGGING ?? 'false') === 'true',
|
logging: (process.env.FABRIC_BACKEND_GUILD_DB_LOGGING ?? 'false') === 'true',
|
||||||
|
|||||||
46
src/entities/channel-canvas.entity.ts
Normal file
46
src/entities/channel-canvas.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
43
src/entities/stored-file.entity.ts
Normal file
43
src/entities/stored-file.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/files/files.module.ts
Normal file
13
src/files/files.module.ts
Normal 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 {}
|
||||||
98
src/files/files.service.ts
Normal file
98
src/files/files.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user