Skip to content
Open
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
7 changes: 6 additions & 1 deletion apps/api-v2/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
JWT_SECRET=topsecret
JWT_SECRET=topsecret

AWS_REGION=reg-1
AWS_ACCESS_KEY=thirdtopsecret
AWS_SECRET_KEY=fourthtopsecret
AWS_UPLOAD_BUCKET_NAME=uploads
5 changes: 4 additions & 1 deletion apps/api-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.787.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
Expand All @@ -31,7 +32,8 @@
"class-validator": "^0.14.2",
"helmet": "^8.1.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"sharp": "^0.34.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
Expand All @@ -45,6 +47,7 @@
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
Expand Down
10 changes: 5 additions & 5 deletions apps/api-v2/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,11 @@ del /claims/[claimId]/images/[imgId] \

## Showcases

get /showcases \
get /[teamId]/showcases \
post /showcases \
put /showcases/[showId] \
del /showcases/[showId]
get /showcases \
get /[teamId]/showcases \
post /showcases \
put /showcases/[showId] \
del /showcases/[showId]

## Members

Expand Down
2 changes: 2 additions & 0 deletions apps/api-v2/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ApplicationsModule } from './sections/applications/applications.module'
import { ApplicationTemplatesModule } from './sections/applications/templates/application-templates.module';
import { AuthModule } from './sections/auth/auth.module';
import { ClaimsModule } from './sections/claims/claims.module';
import { ShowcasesModule } from './sections/showcases/showcases.module';
import { StatusModule } from './sections/status/status.module';
import { UtilityModule } from './sections/utility/utility.module';

Expand All @@ -22,6 +23,7 @@ import { UtilityModule } from './sections/utility/utility.module';
AuthModule,
ClaimsModule,
ConfigModule.forRoot({ isGlobal: true, cache: true }),
ShowcasesModule,
StatusModule,
UtilityModule,
],
Expand Down
94 changes: 94 additions & 0 deletions apps/api-v2/src/common/db/external/s3.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

/**
* Default endpoint of the BuildTheEarth CDN, which fronts the S3 compatible
* storage the v1 API already writes to.
*/
export const DEFAULT_S3_ENDPOINT = 'https://cdn.buildtheearth.net';

/**
* Thin wrapper around the object storage that holds user uploads.
*
* The credentials are optional: an instance without them still starts, but every
* call fails with a 503 instead. That keeps the rest of the API usable in
* development, where the CDN credentials are usually not configured.
*/
@Injectable()
export class S3Service {
private readonly logger = new Logger(S3Service.name);
private readonly client: S3Client | null = null;
private readonly uploadBucket: string | undefined;

constructor(private readonly configService: ConfigService) {
const accessKeyId = this.configService.get<string>('AWS_ACCESS_KEY');
const secretAccessKey = this.configService.get<string>('AWS_SECRET_KEY');
const region = this.configService.get<string>('AWS_REGION');

this.uploadBucket = this.configService.get<string>('AWS_UPLOAD_BUCKET_NAME');

if (!accessKeyId || !secretAccessKey || !region || !this.uploadBucket) {
this.logger.warn('AWS configuration is missing. S3Service will reject every request.');
return;
}

this.client = new S3Client({
credentials: { accessKeyId, secretAccessKey },
region,
endpoint: this.configService.get<string>('AWS_ENDPOINT') ?? DEFAULT_S3_ENDPOINT,
forcePathStyle: true,
});
}

/**
* Whether the service has enough configuration to talk to the bucket.
*/
get isConfigured(): boolean {
return this.client !== null;
}

/**
* Writes an object to the upload bucket.
* @param key Key to store the object under.
* @param body Raw bytes of the object.
* @param contentType MIME type reported to clients that fetch the object.
* @throws ServiceUnavailableException if the service is not configured.
*/
async putObject(key: string, body: Buffer, contentType: string): Promise<void> {
const client = this.requireClient();

await client.send(
new PutObjectCommand({
Bucket: this.uploadBucket,
Key: key,
Body: body,
ContentType: contentType,
}),
);
}

/**
* Removes an object from the upload bucket.
* @param key Key the object is stored under.
* @throws ServiceUnavailableException if the service is not configured.
*/
async deleteObject(key: string): Promise<void> {
const client = this.requireClient();

await client.send(
new DeleteObjectCommand({
Bucket: this.uploadBucket,
Key: key,
}),
);
}

private requireClient(): S3Client {
if (!this.client) {
throw new ServiceUnavailableException('File storage is not configured');
}

return this.client;
}
}
137 changes: 137 additions & 0 deletions apps/api-v2/src/common/uploads/uploads.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { BadRequestException, Injectable, Logger, PayloadTooLargeException } from '@nestjs/common';
import { randomBytes } from 'crypto';
import sharp from 'sharp';
import { PrismaService } from '../db/prisma.service';
import { S3Service } from '../db/external/s3.service';

/**
* Largest image the API accepts. Also handed to multer, which rejects anything
* bigger before it is buffered in full.
*/
export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;

/**
* Image formats the CDN is expected to serve back.
*/
export const ALLOWED_UPLOAD_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif'];

@Injectable()
export class UploadsService {
private readonly logger = new Logger(UploadsService.name);

constructor(
private readonly prisma: PrismaService,
private readonly s3: S3Service,
) {}

/**
* Stores an image in the upload bucket and records it as an Upload row.
* @param file The multipart file to store.
* @returns The created upload.
* @throws BadRequestException if the file is missing, of an unsupported type or unreadable.
* @throws PayloadTooLargeException if the file exceeds MAX_UPLOAD_BYTES.
*/
async createFromFile(file: Express.Multer.File) {
if (!file?.buffer?.length) {
throw new BadRequestException('No image was uploaded');
}

if (!ALLOWED_UPLOAD_MIME_TYPES.includes(file.mimetype)) {
throw new BadRequestException(
`Unsupported image type ${file.mimetype}. Allowed types are: ${ALLOWED_UPLOAD_MIME_TYPES.join(', ')}`,
);
}

if (file.size > MAX_UPLOAD_BYTES) {
throw new PayloadTooLargeException(`Images may be at most ${MAX_UPLOAD_BYTES} bytes`);
}

const { width, height } = await this.readDimensions(file.buffer);
const hash = await this.buildPlaceholder(file.buffer);
const key = randomBytes(32).toString('hex');

await this.s3.putObject(key, file.buffer, file.mimetype);

try {
return await this.prisma.upload.create({
data: { name: key, hash, width, height },
});
} catch (error) {
// The object is already in the bucket at this point, and nothing references
// it, so it would stay there forever if we left it behind.
await this.s3.deleteObject(key).catch((cleanupError: unknown) => {
this.logger.error(`Failed to remove orphaned upload ${key}`, cleanupError);
});

throw error;
}
}

/**
* Deletes an upload and the object behind it, unless something still points at it.
*
* Uploads are shared: the same row can back a claim image as well as any number
* of showcases, so removing one showcase must not pull the image out from under
* the others.
* @param uploadId ID of the upload to remove.
* @returns Whether the upload was deleted.
*/
async deleteIfUnreferenced(uploadId: string): Promise<boolean> {
const upload = await this.prisma.upload.findUnique({
where: { id: uploadId },
select: {
id: true,
name: true,
claimId: true,
_count: { select: { Showcase: true } },
},
});

if (!upload || upload.claimId || upload._count.Showcase > 0) {
return false;
}

await this.prisma.upload.delete({ where: { id: upload.id } });
await this.s3.deleteObject(upload.name);

return true;
}

/**
* Reads the real dimensions of an image, which the frontend needs to reserve
* space for it before it has loaded.
* @throws BadRequestException if the buffer is not an image sharp can read.
*/
private async readDimensions(buffer: Buffer): Promise<{ width: number; height: number }> {
const metadata = await sharp(buffer)
.metadata()
.catch(() => {
throw new BadRequestException('The uploaded file could not be read as an image');
});

if (!metadata.width || !metadata.height) {
throw new BadRequestException('The uploaded file could not be read as an image');
}

return { width: metadata.width, height: metadata.height };
}

/**
* Builds the blurred placeholder that is stored as the upload hash and rendered
* while the full image loads.
*
* This is plaiceholder's `base64` output, reimplemented on top of sharp: v1 uses
* plaiceholder itself, but it ships as ESM only and api-v2 compiles to CommonJS.
* The pipeline is kept identical so both APIs produce interchangeable hashes.
*/
private async buildPlaceholder(buffer: Buffer): Promise<string> {
const { data, info } = await sharp(buffer)
.resize(4, 4, { fit: 'inside' })
.toFormat('png')
.modulate({ brightness: 1, saturation: 1.2 })
.normalise()
.toBuffer({ resolveWithObject: true });

return `data:image/${info.format};base64,${data.toString('base64')}`;
}
}
39 changes: 39 additions & 0 deletions apps/api-v2/src/sections/showcases/dto/create.showcase.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { IsISO8601, IsNotEmpty, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';

export class CreateShowcaseDto {
@ApiProperty({
description: 'The title of the showcase.',
example: 'Empire State Building',
})
@IsString()
@IsNotEmpty()
@MaxLength(255)
title: string;

@ApiPropertyOptional({
description: 'The city the showcase was built in.',
example: 'New York',
})
@IsString()
@IsOptional()
@MaxLength(255)
city?: string;

@ApiPropertyOptional({
description: 'The timestamp when the showcase was created. Defaults to the current time.',
example: '2025-04-19T16:45:18.767Z',
})
@IsISO8601()
@IsOptional()
createdAt?: string;

@ApiPropertyOptional({
description:
'An existing upload to link this showcase to, instead of sending a new image. Mutually exclusive with the image file.',
example: '00000000-0000-0000-0000-000000000000',
})
@IsUUID()
@IsOptional()
uploadId?: string;
}
Loading