From 135738fdfc0f7db886d2aac36396b2f15896513b Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 2 Sep 2026 08:32:08 +0200 Subject: [PATCH] fix: Profilbild-Upload auf JSON/Base64 umgestellt statt multipart POST /profile/avatar schlug live mit net::ERR_FAILED fehl (Verbindung abgebrochen, keine Antwort) - selbst bei einer winzigen Testdatei. CORS-Preflight lief sauber durch, nur der eigentliche multipart-POST nicht; passt zum bereits dokumentierten Cloudflare-Verhalten bei multipart-Uploads (siehe .gitea/workflows/*-image.yml). Umgangen durch Umstellung auf denselben JSON-Content-Type, der fuer alle anderen Endpunkte nachweislich zuverlaessig funktioniert: Bild wird clientseitig als data:-URL kodiert und im JSON-Body gesendet. Globales JSON-Body-Limit auf 5mb angehoben (deckt 2MB-Bild plus Base64-Overhead). --- ApiCore-Backend/src/app.config.ts | 5 +++ .../src/profile/avatar-mime-types.ts | 2 ++ .../src/profile/dto/upload-avatar.dto.ts | 18 ++++++++++ .../src/profile/profile.controller.ts | 33 +++---------------- .../src/profile/profile.service.spec.ts | 28 +++++++++------- .../src/profile/profile.service.ts | 18 +++++++--- ApiCore-Frontend/src/lib/profile/api.ts | 26 ++++++++++++--- 7 files changed, 81 insertions(+), 49 deletions(-) create mode 100644 ApiCore-Backend/src/profile/dto/upload-avatar.dto.ts diff --git a/ApiCore-Backend/src/app.config.ts b/ApiCore-Backend/src/app.config.ts index 1e7a7f6..3f67c90 100644 --- a/ApiCore-Backend/src/app.config.ts +++ b/ApiCore-Backend/src/app.config.ts @@ -10,6 +10,11 @@ export function configureApp(app: NestExpressApplication): void { // real client IP from X-Forwarded-For — required for per-client throttling // to work correctly rather than rate-limiting all traffic together. app.set('trust proxy', 1); + // Default 100kb JSON body limit is too small for the avatar upload, which + // sends the image as a base64 data: URL in the JSON body (not multipart - + // see profile/dto/upload-avatar.dto.ts for why) - 5mb comfortably covers + // the 2MB decoded size cap plus base64 overhead. + app.useBodyParser('json', { limit: '5mb' }); app.use(helmet()); app.use(cookieParser()); app.use(requestLoggerMiddleware); diff --git a/ApiCore-Backend/src/profile/avatar-mime-types.ts b/ApiCore-Backend/src/profile/avatar-mime-types.ts index 6b283e5..6433ba8 100644 --- a/ApiCore-Backend/src/profile/avatar-mime-types.ts +++ b/ApiCore-Backend/src/profile/avatar-mime-types.ts @@ -1,3 +1,5 @@ +export const MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024; + export const ALLOWED_AVATAR_MIME_TYPES: Record = { 'image/jpeg': '.jpg', 'image/png': '.png', diff --git a/ApiCore-Backend/src/profile/dto/upload-avatar.dto.ts b/ApiCore-Backend/src/profile/dto/upload-avatar.dto.ts new file mode 100644 index 0000000..8d488b5 --- /dev/null +++ b/ApiCore-Backend/src/profile/dto/upload-avatar.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MaxLength } from 'class-validator'; + +// Base64 data: URL instead of multipart/form-data - some reverse-proxy +// setups mishandle multipart uploads (seen with this deployment's own +// container registry pushes, see .gitea/workflows/*-image.yml), while plain +// JSON POSTs have proven reliable end to end. 3_000_000 chars covers the +// 2 MB size cap (MAX_AVATAR_SIZE_BYTES) plus base64's ~33% overhead and the +// data: URL prefix, with headroom. +export class UploadAvatarDto { + @ApiProperty({ + description: + 'Bild als data: URL, z.B. "data:image/png;base64,...". Erlaubt: image/jpeg, image/png, image/webp.', + }) + @IsString() + @MaxLength(3_000_000) + data!: string; +} diff --git a/ApiCore-Backend/src/profile/profile.controller.ts b/ApiCore-Backend/src/profile/profile.controller.ts index 0be39cb..d8d1382 100644 --- a/ApiCore-Backend/src/profile/profile.controller.ts +++ b/ApiCore-Backend/src/profile/profile.controller.ts @@ -1,5 +1,4 @@ import { - BadRequestException, Body, Controller, Delete, @@ -8,26 +7,21 @@ import { Patch, Post, Res, - UploadedFile, - UseInterceptors, } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import type { Response } from 'express'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle'; -import { isAllowedAvatarMimeType } from './avatar-mime-types'; import { ChangePasswordDto } from './dto/change-password.dto'; import { DeactivateAccountDto } from './dto/deactivate-account.dto'; import { DisableMfaDto } from './dto/disable-mfa.dto'; import { MfaConfirmDto } from './dto/mfa-confirm.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; +import { UploadAvatarDto } from './dto/upload-avatar.dto'; import { ProfileService } from './profile.service'; -const MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024; - /** * Deliberately its own top-level path, not nested under /users/:id - * "/users/me" would collide with UsersController's PATCH/GET ":id" route @@ -76,31 +70,12 @@ export class ProfileController { } @Post('avatar') - @ApiConsumes('multipart/form-data') - @UseInterceptors( - FileInterceptor('file', { - limits: { fileSize: MAX_AVATAR_SIZE_BYTES }, - fileFilter: (_req, file, callback) => { - if (!isAllowedAvatarMimeType(file.mimetype)) { - callback( - new BadRequestException('Nicht unterstützter Dateityp'), - false, - ); - return; - } - callback(null, true); - }, - }), - ) @HttpCode(204) uploadAvatar( - @UploadedFile() file: Express.Multer.File | undefined, + @Body() dto: UploadAvatarDto, @CurrentUser() actor: AuthenticatedUser, ) { - if (!file) { - throw new BadRequestException('No file uploaded'); - } - return this.profileService.uploadAvatar(actor.id, file); + return this.profileService.uploadAvatar(actor.id, dto.data); } @Delete('avatar') diff --git a/ApiCore-Backend/src/profile/profile.service.spec.ts b/ApiCore-Backend/src/profile/profile.service.spec.ts index a2b3742..feb1803 100644 --- a/ApiCore-Backend/src/profile/profile.service.spec.ts +++ b/ApiCore-Backend/src/profile/profile.service.spec.ts @@ -178,19 +178,25 @@ describe('ProfileService', () => { }); describe('uploadAvatar', () => { - const file = { - buffer: Buffer.from('binary'), - originalname: 'me.jpg', - mimetype: 'image/jpeg', - size: 1234, - } as Express.Multer.File; + const dataUrl = `data:image/jpeg;base64,${Buffer.from('binary').toString('base64')}`; + + it('rejects a value that is not a data: URL', async () => { + await expect( + service.uploadAvatar('user-1', 'not-a-data-url'), + ).rejects.toBeInstanceOf(BadRequestException); + }); it('rejects an unsupported mime type', async () => { + const zipDataUrl = dataUrl.replace('image/jpeg', 'application/zip'); await expect( - service.uploadAvatar('user-1', { - ...file, - mimetype: 'application/zip', - }), + service.uploadAvatar('user-1', zipDataUrl), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects a file larger than the 2 MB cap', async () => { + const oversized = `data:image/jpeg;base64,${Buffer.alloc(3 * 1024 * 1024).toString('base64')}`; + await expect( + service.uploadAvatar('user-1', oversized), ).rejects.toBeInstanceOf(BadRequestException); }); @@ -201,7 +207,7 @@ describe('ProfileService', () => { }); prisma.user.update.mockResolvedValue({}); - await service.uploadAvatar('user-1', file); + await service.uploadAvatar('user-1', dataUrl); expect(fs.writeFile).toHaveBeenCalled(); expect(fs.unlink).toHaveBeenCalledWith('/uploads/avatars/old.jpg'); diff --git a/ApiCore-Backend/src/profile/profile.service.ts b/ApiCore-Backend/src/profile/profile.service.ts index 02860dc..a5b04a6 100644 --- a/ApiCore-Backend/src/profile/profile.service.ts +++ b/ApiCore-Backend/src/profile/profile.service.ts @@ -16,6 +16,7 @@ import { PermissionResolverService } from '../auth/permissions/permission-resolv import { UsersService } from '../users/users.service'; import { ALLOWED_AVATAR_MIME_TYPES, + MAX_AVATAR_SIZE_BYTES, mimeTypeForExtension, } from './avatar-mime-types'; import { UpdateProfileDto } from './dto/update-profile.dto'; @@ -141,19 +142,28 @@ export class ProfileService { await this.usersService.deactivate(userId, userId); } - async uploadAvatar(userId: string, file: Express.Multer.File): Promise { - if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) { + async uploadAvatar(userId: string, dataUrl: string): Promise { + const match = /^data:(image\/(?:jpeg|png|webp));base64,(.+)$/.exec(dataUrl); + if (!match) { + throw new BadRequestException('Ungültiges Bildformat'); + } + const [, mimeType, base64] = match; + if (!(mimeType in ALLOWED_AVATAR_MIME_TYPES)) { throw new BadRequestException('Nicht unterstützter Dateityp'); } + const buffer = Buffer.from(base64, 'base64'); + if (buffer.length > MAX_AVATAR_SIZE_BYTES) { + throw new BadRequestException('Datei zu groß (max. 2 MB)'); + } const user = await this.requireUser(userId); - const extension = ALLOWED_AVATAR_MIME_TYPES[file.mimetype]; + const extension = ALLOWED_AVATAR_MIME_TYPES[mimeType]; const directory = avatarStorageRoot(); await fs.mkdir(directory, { recursive: true }); const avatarPath = path.join( directory, `${crypto.randomUUID()}${extension}`, ); - await fs.writeFile(avatarPath, file.buffer); + await fs.writeFile(avatarPath, buffer); if (user.avatarPath) { await fs.unlink(user.avatarPath).catch(() => undefined); } diff --git a/ApiCore-Frontend/src/lib/profile/api.ts b/ApiCore-Frontend/src/lib/profile/api.ts index a0baa7c..80138a5 100644 --- a/ApiCore-Frontend/src/lib/profile/api.ts +++ b/ApiCore-Frontend/src/lib/profile/api.ts @@ -1,4 +1,4 @@ -import { apiFetch, apiFetchBlob, apiFetchFormData } from "@/lib/auth/api-client"; +import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client"; import { MfaEnrollment, ProfileSummary, UpdateProfileInput } from "./types"; export function updateProfile(input: UpdateProfileInput): Promise { @@ -22,10 +22,26 @@ export function deactivateAccount(currentPassword: string): Promise { }); } -export function uploadAvatar(file: File): Promise { - const formData = new FormData(); - formData.append("file", file); - return apiFetchFormData("/profile/avatar", formData); +// Sent as a base64 data: URL in a plain JSON body rather than +// multipart/form-data: this deployment's Cloudflare proxy has a known +// history of mishandling multipart uploads regardless of size (see the +// registry-push workaround in .gitea/workflows/*-image.yml), while plain +// JSON POSTs to this same backend have been reliable all along. +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} + +export async function uploadAvatar(file: File): Promise { + const data = await fileToDataUrl(file); + return apiFetch("/profile/avatar", { + method: "POST", + body: JSON.stringify({ data }), + }); } export function deleteAvatar(): Promise {