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
17 changes: 17 additions & 0 deletions src/core/errors/common/payload-too-large.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { CustomError } from "./custom.error";

/**
* Error thrown when an upload exceeds the size the endpoint accepts.
*
* @extends CustomError
*/
export class PayloadTooLargeError extends CustomError {
/**
* Creates a new PayloadTooLargeError instance.
*
* @param message - Description of the limit that was exceeded
*/
constructor(message: string) {
super(message, 413);
}
}
1 change: 1 addition & 0 deletions src/core/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ export * from "./common/bad-request.error";
export * from "./common/conflict.error";
export * from "./common/custom.error";
export * from "./common/not-found.error";
export * from "./common/payload-too-large.error";
export * from "./common/forbidden.error";
export * from "./common/translation-failed.error";
7 changes: 7 additions & 0 deletions src/core/ports/services/crypto.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export interface CryptoPort {
*/
generateRandomHex(bytes: number): string;

/**
* Generates a random UUID.
*
* @returns A version 4 UUID string.
*/
generateUuid(): string;

/**
* Compares two strings in constant time to prevent timing attacks.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* A raster image format this API accepts for article covers.
*/
export interface DetectedImageType {
/** File extension to use in the storage key */
extension: "jpg" | "png" | "gif" | "webp" | "avif";

/** MIME type derived from the bytes, not from the client */
mimeType: string;
}

/**
* Compares a run of bytes against an expected signature.
*
* @param buffer - The uploaded bytes
* @param offset - Where the signature should start
* @param signature - The expected byte values
* @returns True when every byte matches
*/
function matches(buffer: Buffer, offset: number, signature: number[]): boolean {
if (buffer.length < offset + signature.length) return false;

for (let i = 0; i < signature.length; i++) {
if (buffer[offset + i] !== signature[i]) return false;
}

return true;
}

/**
* Identifies an image by its magic bytes.
*
* The client-supplied MIME type and file name are deliberately not consulted.
* Both are attacker-controlled: a request can claim `image/png` while carrying
* an SVG, and a name like `cover.png.html` reads as an image to a naive
* extension check. Reading the bytes is the only statement about the file the
* uploader cannot forge.
*
* SVG has no signature to match and is therefore rejected for free, which is
* the intended outcome: it is a scriptable document format rather than a
* raster image, and serving one from the CDN would be a stored XSS.
*
* @param buffer - The uploaded bytes
* @returns The detected type, or null when the bytes are not a supported image
*/
export function detectImageType(buffer: Buffer): DetectedImageType | null {
// JPEG: FF D8 FF
if (matches(buffer, 0, [0xff, 0xd8, 0xff])) {
return { extension: "jpg", mimeType: "image/jpeg" };
}

// PNG: 89 50 4E 47 0D 0A 1A 0A
if (matches(buffer, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
return { extension: "png", mimeType: "image/png" };
}

// GIF: "GIF8"
if (matches(buffer, 0, [0x47, 0x49, 0x46, 0x38])) {
return { extension: "gif", mimeType: "image/gif" };
}

// WEBP: "RIFF" then "WEBP" at byte 8
if (
matches(buffer, 0, [0x52, 0x49, 0x46, 0x46]) &&
matches(buffer, 8, [0x57, 0x45, 0x42, 0x50])
) {
return { extension: "webp", mimeType: "image/webp" };
}

// AVIF: "ftypavif" at byte 4
if (matches(buffer, 4, [0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66])) {
return { extension: "avif", mimeType: "image/avif" };
}

return null;
}
7 changes: 7 additions & 0 deletions src/core/use-cases/article/upload-article-cover/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Article cover upload module exports.
*/

export * from "./upload-article-cover.usecase";
export * from "./upload-article-cover-usecase.input";
export * from "./detect-image-type";
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Input for uploading an article cover image.
*
* There is no mimeType or file name field on purpose: both are supplied by the
* client and neither is trusted. The type is read from the bytes.
*/
export interface UploadArticleCoverUseCaseInput {
/** The authenticated uploader; the key is scoped to them */
userId: string;

/** The uploaded bytes */
fileBuffer: Buffer;

/** Whether the transport truncated the stream at its size limit */
truncated?: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { CryptoPort } from "@core/ports/services/crypto.port";
import type { StoragePort } from "@core/ports/services/storage.port";
import { InvalidFileTypeError, PayloadTooLargeError } from "@core/errors";
import type { UploadArticleCoverUseCaseInput } from "./upload-article-cover-usecase.input";
import { detectImageType } from "./detect-image-type";

/** Largest cover image accepted, in bytes. */
const MAX_COVER_BYTES = 5 * 1024 * 1024;

/**
* Use case for uploading an article cover image.
*
* Returns a storage key rather than a URL. The article body accepts only that
* key, validated against the uploader's own prefix, which is what keeps
* arbitrary client-supplied URLs out of stored content.
*/
export class UploadArticleCoverUseCase {
/**
* Creates a new instance of UploadArticleCoverUseCase.
*
* @param storageService - Object storage receiving the image
* @param cryptoService - Source of the random file name
*/
constructor(
private readonly storageService: StoragePort,
private readonly cryptoService: CryptoPort,
) {}

/**
* Executes the upload.
*
* The file name is generated, never derived from the upload: a client name
* can carry a path traversal or a second extension, and neither can survive
* a name the server invents.
*
* @param input - The uploader and the raw bytes
* @returns The storage key of the stored image
*
* @throws PayloadTooLargeError - When the image exceeds the size limit
* @throws InvalidFileTypeError - When the bytes are not a supported image
*/
async execute(input: UploadArticleCoverUseCaseInput): Promise<string> {
if (input.truncated || input.fileBuffer.byteLength > MAX_COVER_BYTES) {
throw new PayloadTooLargeError(
"Cover image must be 5 MB or smaller.",
);
}

const detected = detectImageType(input.fileBuffer);

if (!detected) {
throw new InvalidFileTypeError(
"Cover image must be a JPEG, PNG, GIF, WEBP or AVIF file.",
);
}

const key =
"articles/covers/" +
input.userId +
"/" +
this.cryptoService.generateUuid() +
"." +
detected.extension;

return await this.storageService.upload(
key,
input.fileBuffer,
detected.mimeType,
);
}
}
47 changes: 47 additions & 0 deletions src/http/controllers/article.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { DeleteArticleUseCase } from "@core/use-cases/article/delete-articl
import type { GetArticlesUseCase } from "@core/use-cases/article/get-articles";
import type { GetArticleUseCase } from "@core/use-cases/article/get-article";
import type { GetMyArticlesUseCase } from "@core/use-cases/article/get-my-articles";
import type { UploadArticleCoverUseCase } from "@core/use-cases/article/upload-article-cover";
import { NoMediaProvidedError } from "@core/errors";
import { ArticlePrismaMapper } from "@infrastructure/persistence/mappers/article-prisma.mapper";
import type { CreateArticleBody } from "@typings/schemas/article/create-article.schema";
import type { UpdateArticleBody } from "@typings/schemas/article/update-article.schema";
Expand All @@ -33,6 +35,7 @@ export class ArticleController {
* @param getArticlesUseCase - Use case for the public article list
* @param getArticleUseCase - Use case for reading one article by slug
* @param getMyArticlesUseCase - Use case for an author's own articles
* @param uploadArticleCoverUseCase - Use case for storing a cover image
*/
constructor(
private readonly createArticleUseCase: CreateArticleUseCase,
Expand All @@ -43,6 +46,7 @@ export class ArticleController {
private readonly getArticlesUseCase: GetArticlesUseCase,
private readonly getArticleUseCase: GetArticleUseCase,
private readonly getMyArticlesUseCase: GetMyArticlesUseCase,
private readonly uploadArticleCoverUseCase: UploadArticleCoverUseCase,
) {}

/**
Expand Down Expand Up @@ -283,6 +287,49 @@ export class ArticleController {
});
}

/**
* Stores a cover image and returns the key the article body accepts.
*
* Only the bytes are passed on: the multipart part's mimetype and filename
* are both client-controlled and neither is forwarded anywhere.
*
* @param request - A multipart request carrying exactly one file
* @param reply - The Fastify reply object
* @returns A 200 response with the storage key and its public URL
*/
async uploadCover(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
if (!request.isMultipart()) {
throw new NoMediaProvidedError(
"Please send a multipart/form-data request with one image file.",
);
}

const file = await request.file();

if (!file) {
throw new NoMediaProvidedError("No cover image was provided.");
}

const fileBuffer = await file.toBuffer();

const coverImageKey = await this.uploadArticleCoverUseCase.execute({
userId: request.user.id,
fileBuffer,
truncated: file.file.truncated,
});

return reply.status(200).send({
data: {
coverImageKey,
coverImageUrl: this.cdnUrl(request) + "/" + coverImageKey,
},
meta: { timestamp: new Date().toISOString() },
});
}

/**
* Resolves the CDN base URL, without a trailing slash.
*
Expand Down
6 changes: 6 additions & 0 deletions src/http/plugins/di/use-cases.di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { DeleteArticleUseCase } from "@core/use-cases/article/delete-article";
import { GetArticlesUseCase } from "@core/use-cases/article/get-articles";
import { GetArticleUseCase } from "@core/use-cases/article/get-article";
import { GetMyArticlesUseCase } from "@core/use-cases/article/get-my-articles";
import { UploadArticleCoverUseCase } from "@core/use-cases/article/upload-article-cover";

/**
* Dependency injection module for use cases
Expand Down Expand Up @@ -452,4 +453,9 @@ export const useCasesModule = {
* Use case for an author's own article list
*/
getMyArticlesUseCase: asClass(GetMyArticlesUseCase).singleton(),

/**
* Use case for storing an article cover image
*/
uploadArticleCoverUseCase: asClass(UploadArticleCoverUseCase).singleton(),
};
16 changes: 16 additions & 0 deletions src/http/routes/article/article.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
getMyArticlesQuerySchema,
type GetMyArticlesQuery,
} from "@typings/schemas/article/get-my-articles.schema";
import { UploadCoverResponseSchema } from "@typings/schemas/article/upload-cover.schema";

/**
* Registers the article write endpoints.
Expand Down Expand Up @@ -117,6 +118,21 @@ export function articleRoutes(fastify: FastifyInstance): void {
articleController.create.bind(articleController),
);

// No body schema: declaring one would make Fastify try to validate a
// multipart stream. The file is validated by its bytes in the use case.
fastify.post(
"/articles/cover",
{
onRequest: [fastify.authenticate],
schema: {
response: { 200: UploadCoverResponseSchema },
tags: ["Article"],
},
config: { rateLimit: RateLimitPolicies.SENSITIVE },
},
articleController.uploadCover.bind(articleController),
);

fastify.patch<{
Params: ArticleIdParams;
Body: UpdateArticleBody;
Expand Down
15 changes: 15 additions & 0 deletions src/http/types/schemas/article/upload-cover.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Type as FBType, type Static } from "@fastify/type-provider-typebox";
import { ResponseSchema } from "../create-response-schema";

/**
* The upload returns both forms: the key, which is what the article body
* accepts, and the URL, which is what a client renders.
*/
export const UploadCoverResponseSchema = ResponseSchema(
FBType.Object({
coverImageKey: FBType.String(),
coverImageUrl: FBType.String(),
}),
);

export type UploadCoverResponse = Static<typeof UploadCoverResponseSchema>;
5 changes: 5 additions & 0 deletions src/infrastructure/security/crypto.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
randomInt,
createHash,
randomBytes,
randomUUID,
timingSafeEqual as cryptoTimingSafeEqual,
} from "crypto";
import type { CryptoPort } from "@core/ports/services/crypto.port";
Expand All @@ -11,6 +12,10 @@ export class CryptoService implements CryptoPort {
return randomBytes(bytes).toString("hex");
}

generateUuid(): string {
return randomUUID();
}

generateOtp(length: number = 8): string {
const max = Math.pow(10, length);
const otp = randomInt(0, max).toString();
Expand Down
Loading