Compare commits
1 Commits
b3fcefb5ec
...
58badf328c
| Author | SHA1 | Date | |
|---|---|---|---|
| 58badf328c |
@@ -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: [
|
||||
|
||||
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()
|
||||
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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
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