Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
"dependencies": {
"@article30/shared": "workspace:*",
"@aws-sdk/client-s3": "^3.1043.0",
"@aws-sdk/s3-request-presigner": "^3.1043.0",
"@nestjs/common": "^11.1.19",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-express": "^11.1.19",
Expand Down
103 changes: 103 additions & 0 deletions backend/src/common/authorization/document-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { LinkedEntity, Role, DOCUMENT_READ_ROLES, FOLLOW_UP_READ_ROLES } from '@article30/shared';
import { EntityType } from '@prisma/client';
import type { Document, FollowUpAttachment } from '@prisma/client';
import type { PrismaService } from '../../prisma/prisma.service';
import type { RequestUser } from '../types/request-user';
import { ownsTreatment, treatmentOwnershipWhere, isProcessOwner } from './treatment-ownership';

function ensureRole(
user: RequestUser | undefined,
allowed: readonly Role[],
): asserts user is RequestUser {
// RequestUser.role is the Prisma-generated Role type; allowed comes from
// @article30/shared. Same string values, nominally different types - cast
// through readonly string[] to bridge them without losing call-site safety.
if (!user || !(allowed as readonly string[]).includes(user.role)) {
throw new ForbiddenException();
}
}

export async function assertCanReadDocument(
user: RequestUser | undefined,
document: Document,
prisma: PrismaService,
): Promise<void> {
ensureRole(user, DOCUMENT_READ_ROLES);
if (!isProcessOwner(user)) return;

switch (document.linkedEntity) {
case LinkedEntity.TREATMENT: {
const treatment = await prisma.treatment.findUnique({
where: { id: document.linkedEntityId },
select: { createdBy: true, assignedTo: true },
});
if (!treatment || !ownsTreatment(treatment, user.id)) {
throw new NotFoundException();
}
return;
}
case LinkedEntity.VIOLATION: {
const violation = await prisma.violation.findUnique({
where: { id: document.linkedEntityId },
select: { createdBy: true, assignedTo: true },
});
if (!violation) throw new NotFoundException();
if (violation.createdBy === user.id || violation.assignedTo === user.id) return;
const linked = await prisma.violationTreatment.findFirst({
where: {
violationId: document.linkedEntityId,
treatment: treatmentOwnershipWhere(user.id),
},
select: { violationId: true },
});
if (!linked) throw new NotFoundException();
return;
}
case LinkedEntity.CHECKLIST_ITEM:
// Org-wide artefact - no per-user scoping beyond the role gate.
return;
default: {
const _exhaustive: never = document.linkedEntity;
throw new NotFoundException();
}
}
}

export async function assertCanReadFollowUpAttachment(
user: RequestUser | undefined,
attachment: FollowUpAttachment,
prisma: PrismaService,
): Promise<void> {
ensureRole(user, FOLLOW_UP_READ_ROLES);
if (!isProcessOwner(user)) return;

switch (attachment.entityType) {
case EntityType.VIOLATION: {
const violation = await prisma.violation.findUnique({
where: { id: attachment.entityId },
select: { createdBy: true, assignedTo: true },
});
if (!violation) throw new NotFoundException();
if (violation.createdBy === user.id || violation.assignedTo === user.id) return;
const linked = await prisma.violationTreatment.findFirst({
where: { violationId: attachment.entityId, treatment: treatmentOwnershipWhere(user.id) },
select: { violationId: true },
});
if (!linked) throw new NotFoundException();
return;
}
case EntityType.DSR: {
const linked = await prisma.dsrTreatmentProcessingLog.findFirst({
where: { dsrId: attachment.entityId, treatment: treatmentOwnershipWhere(user.id) },
select: { dsrId: true },
});
if (!linked) throw new NotFoundException();
return;
}
default: {
const _exhaustive: never = attachment.entityType;
throw new NotFoundException();
}
}
}
68 changes: 56 additions & 12 deletions backend/src/modules/documents/documents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,57 @@ import {
Controller,
Delete,
Get,
Headers,
Param,
Post,
Query,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { WRITE_ROLES } from '@article30/shared';
import { Response } from 'express';
import { DOCUMENT_READ_ROLES, WRITE_ROLES } from '@article30/shared';
import { DocumentsService } from './documents.service';
import { StorageService } from './storage.service';
import { UploadDocumentDto } from './dto/upload-document.dto';
import { Roles } from '../../common/decorators/roles.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequestUser } from '../../common/types/request-user';
import { PrismaService } from '../../prisma/prisma.service';
import { assertCanReadDocument } from '../../common/authorization/document-access';

const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
const UPLOAD_RATE_LIMIT = 10;
const UPLOAD_RATE_TTL_MS = 60_000;

const RFC8187_RESERVED = /[!'()*]/g;

function encodeRfc8187(value: string): string {
// encodeURIComponent already handles most chars; we additionally percent-encode
// !'()* because they are syntactically significant in filename*=UTF-8''<value>
// (notably "'" terminates the language tag).
return encodeURIComponent(value).replace(
RFC8187_RESERVED,
c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
}

@ApiTags('documents')
@Controller('documents')
export class DocumentsController {
constructor(private readonly documentsService: DocumentsService) {}
constructor(
private readonly documentsService: DocumentsService,
private readonly storage: StorageService,
private readonly prisma: PrismaService,
) {}

@Post('upload')
@Roles(...WRITE_ROLES)
@Throttle({ default: { limit: UPLOAD_RATE_LIMIT, ttl: UPLOAD_RATE_TTL_MS } })
// Pre-buffer cap matches the service-layer post-buffer check.
// Requests over the limit get a 413 before bytes land in the Node heap.
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: MAX_FILE_SIZE_BYTES },
}),
)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_FILE_SIZE_BYTES } }))
upload(
@UploadedFile() file: Express.Multer.File,
@Body() dto: UploadDocumentDto,
Expand All @@ -52,8 +68,36 @@ export class DocumentsController {
}

@Get(':id/download')
getDownloadUrl(@Param('id') id: string) {
return this.documentsService.getDownloadUrl(id);
@Roles(...DOCUMENT_READ_ROLES)
@ApiOkResponse({
description: 'Streams the file. Returns 206 with Content-Range when a Range header is sent.',
content: { '*/*': { schema: { type: 'string', format: 'binary' } } },
})
async download(
@Param('id') id: string,
@Headers('range') range: string | undefined,
@CurrentUser() user: RequestUser,
@Res() res: Response,
) {
const document = await this.documentsService.findById(id);
await assertCanReadDocument(user, document, this.prisma);

const obj = await this.storage.getObject(document.s3Key, range);

res.status(obj.statusCode);
res.setHeader('Content-Type', document.mimeType);
res.setHeader('Content-Length', String(obj.contentLength));
res.setHeader('Accept-Ranges', 'bytes');
if (obj.contentRange) res.setHeader('Content-Range', obj.contentRange);
if (obj.etag) res.setHeader('ETag', obj.etag);
res.setHeader(
'Content-Disposition',
`inline; filename*=UTF-8''${encodeRfc8187(document.filename)}`,
);
res.setHeader('Cache-Control', 'private, no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');

obj.body.pipe(res);
}

@Delete(':id')
Expand Down
6 changes: 2 additions & 4 deletions backend/src/modules/documents/documents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,12 @@ export class DocumentsService {
});
}

async getDownloadUrl(id: string) {
async findById(id: string) {
const document = await this.prisma.document.findUnique({ where: { id } });
if (!document) {
throw new NotFoundException('Document not found');
}

const url = await this.storage.getPresignedUrl(document.s3Key);
return { url, filename: document.filename };
return document;
}

async delete(id: string) {
Expand Down
56 changes: 50 additions & 6 deletions backend/src/modules/documents/storage.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
Injectable,
Logger,
OnModuleInit,
NotFoundException,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Readable } from 'node:stream';
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
GetObjectCommandOutput,
DeleteObjectCommand,
CreateBucketCommand,
HeadBucketCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

@Injectable()
export class StorageService implements OnModuleInit {
Expand Down Expand Up @@ -70,10 +78,46 @@ export class StorageService implements OnModuleInit {
);
}

async getPresignedUrl(key: string): Promise<string> {
const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
const PRESIGNED_URL_EXPIRY_SECONDS = 900;
return getSignedUrl(this.ensureClient(), command, { expiresIn: PRESIGNED_URL_EXPIRY_SECONDS });
async getObject(
key: string,
range?: string,
): Promise<{
body: Readable;
contentType: string;
contentLength: number;
contentRange?: string;
etag?: string;
statusCode: 200 | 206;
}> {
let response: GetObjectCommandOutput;
try {
response = await this.ensureClient().send(
new GetObjectCommand({ Bucket: this.bucket, Key: key, Range: range }),
);
} catch (err: unknown) {
const name = (err as { name?: string })?.name;
if (name === 'NoSuchKey' || name === 'NotFound') {
throw new NotFoundException('Object not found');
}
if (name === 'InvalidRange') {
throw new HttpException(
'Range Not Satisfiable',
HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
);
}
throw err;
}
if (!response.Body) {
throw new Error(`S3 GetObject returned no body for key: ${key}`);
}
return {
body: response.Body as Readable,
contentType: response.ContentType ?? 'application/octet-stream',
contentLength: response.ContentLength ?? 0,
contentRange: response.ContentRange,
etag: response.ETag ?? undefined,
statusCode: response.ContentRange ? 206 : 200,
};
}

async delete(key: string): Promise<void> {
Expand Down
Loading
Loading