All checks were successful
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m43s
CI / Auth e2e pack (push) Successful in 1m48s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
Implements the FileStorage abstraction (uploads/<pondId>/<fileId> on the mounted volume), the attachments model, and POST /ponds/:id/files, GET /media/:fileId, DELETE /files/:id. Uploads are validated by sniffing magic bytes rather than trusting the client's Content-Type/filename (catches a renamed .html-as-.png), checked against the max_file_bytes and storage_bytes quotas, and served with nosniff + immutable caching. Closes #27
136 lines
4.4 KiB
TypeScript
136 lines
4.4 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import type { Readable } from 'node:stream';
|
|
|
|
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
PayloadTooLargeException,
|
|
} from '@nestjs/common';
|
|
import { AttachmentView } from '@dorfteich/shared';
|
|
import { Attachment, User } from '@prisma/client';
|
|
import { PinoLogger } from 'nestjs-pino';
|
|
|
|
import { InterimAccessService } from '../ponds/interim-access.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { QuotaService } from '../quotas/quota.service';
|
|
|
|
import { FileStorageService } from './file-storage.service';
|
|
import { sniffImageMimeType } from './magic-bytes';
|
|
|
|
export interface FileDownload {
|
|
attachment: Attachment;
|
|
stream: Readable;
|
|
}
|
|
|
|
/** File storage and image-upload API (issue #27, ADR 0011). */
|
|
@Injectable()
|
|
export class FilesService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly access: InterimAccessService,
|
|
private readonly quotas: QuotaService,
|
|
private readonly storage: FileStorageService,
|
|
private readonly logger: PinoLogger,
|
|
) {
|
|
this.logger.setContext(FilesService.name);
|
|
}
|
|
|
|
viewOf(attachment: Attachment): AttachmentView {
|
|
return {
|
|
id: attachment.id,
|
|
pondId: attachment.pondId,
|
|
pageId: attachment.pageId,
|
|
fileName: attachment.fileName,
|
|
mimeType: attachment.mimeType,
|
|
sizeBytes: attachment.sizeBytes,
|
|
createdAt: attachment.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async upload(
|
|
user: User,
|
|
pondId: string,
|
|
file: { buffer: Buffer; size: number; originalname: string },
|
|
): Promise<AttachmentView> {
|
|
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
|
this.access.assertCanModify(user, pond);
|
|
|
|
// Bytes decide, not the client-declared Content-Type or extension —
|
|
// catches a renamed .html-as-.png (ADR 0011 acceptance criterion).
|
|
const mimeType = sniffImageMimeType(file.buffer);
|
|
if (!mimeType) {
|
|
throw new BadRequestException({ code: 'unsupported_file_type' });
|
|
}
|
|
|
|
const maxFileBytes = await this.quotas.getEffective('max_file_bytes', {
|
|
userId: pond.ownerId,
|
|
pondId: pond.id,
|
|
});
|
|
if (file.size > maxFileBytes) {
|
|
throw new PayloadTooLargeException({
|
|
code: 'file_too_large',
|
|
details: { limitBytes: maxFileBytes },
|
|
});
|
|
}
|
|
|
|
const id = randomUUID();
|
|
// Consume the storage budget before touching the disk so a race never
|
|
// leaves bytes written without a matching reservation.
|
|
await this.quotas.checkAndConsume(pond.id, pond.ownerId, file.size);
|
|
|
|
try {
|
|
await this.storage.save(pond.id, id, file.buffer);
|
|
const attachment = await this.prisma.attachment.create({
|
|
data: {
|
|
id,
|
|
pondId: pond.id,
|
|
fileName: file.originalname,
|
|
mimeType,
|
|
sizeBytes: file.size,
|
|
storagePath: `${pond.id}/${id}`,
|
|
uploadedBy: user.id,
|
|
},
|
|
});
|
|
this.logger.info(
|
|
{ attachmentId: id, pondId: pond.id, userId: user.id },
|
|
'audit: file uploaded',
|
|
);
|
|
return this.viewOf(attachment);
|
|
} catch (error) {
|
|
// Roll back the reservation and any bytes already written so usage
|
|
// never drifts from what is actually on the volume/in the database.
|
|
await this.quotas.release(pond.id, file.size);
|
|
await this.storage.delete(pond.id, id);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async download(user: User, id: string): Promise<FileDownload> {
|
|
const attachment = await this.prisma.attachment.findFirst({
|
|
where: { id },
|
|
include: { pond: true },
|
|
});
|
|
if (!attachment) throw new NotFoundException();
|
|
this.access.assertCanSee(user, attachment.pond);
|
|
return { attachment, stream: this.storage.createReadStream(attachment.pondId, attachment.id) };
|
|
}
|
|
|
|
async remove(user: User, id: string): Promise<void> {
|
|
const attachment = await this.prisma.attachment.findFirst({
|
|
where: { id },
|
|
include: { pond: true },
|
|
});
|
|
if (!attachment) throw new NotFoundException();
|
|
this.access.assertCanModify(user, attachment.pond);
|
|
|
|
await this.prisma.attachment.delete({ where: { id: attachment.id } });
|
|
await this.storage.delete(attachment.pondId, attachment.id);
|
|
await this.quotas.release(attachment.pondId, attachment.sizeBytes);
|
|
this.logger.info(
|
|
{ attachmentId: id, pondId: attachment.pondId, userId: user.id },
|
|
'audit: file deleted',
|
|
);
|
|
}
|
|
}
|