From c3a243b3605b730c838bebcee98a453f988cd4ef Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 27 Jul 2026 17:38:24 -0400 Subject: [PATCH] feautre: enws uplaod --- src/news/news.controller.ts | 273 ++++++++++++++++++++++++++++++++++++ src/news/news.service.ts | 47 ++++++- 2 files changed, 318 insertions(+), 2 deletions(-) diff --git a/src/news/news.controller.ts b/src/news/news.controller.ts index fc8996b2..23c47b19 100644 --- a/src/news/news.controller.ts +++ b/src/news/news.controller.ts @@ -1,7 +1,10 @@ import { + BadRequestException, + Body, Controller, Post, Get, + InternalServerErrorException, Param, Req, Res, @@ -20,10 +23,19 @@ import { HasuraAction } from "../hasura/hasura.controller"; import { SystemService } from "src/system/system.service"; import { SystemSettingName } from "src/system/enums/SystemSettingName"; import { isRoleAbove } from "src/utilities/isRoleAbove"; +import { S3Service } from "../s3/s3.service"; +import { signUploadToken } from "../steam-match-history/uploadToken"; import { User } from "../auth/types/User"; import { e_player_roles_enum } from "generated"; import { NewsService } from "./news.service"; +const VIDEO_MAX_SIZE = 512 * 1024 * 1024; +// Anything at or under this posts straight through the API like news images. +// Only bigger files need the multipart bypass — Cloudflare caps proxied +// request bodies at ~100MB and times slow ones out. +const DIRECT_MAX_SIZE = 90 * 1024 * 1024; +const UPLOAD_CHUNK_SIZE = 64 * 1024 * 1024; + @Controller("news") export class NewsController { private readonly logger = new Logger(NewsController.name); @@ -31,6 +43,7 @@ export class NewsController { constructor( private readonly news: NewsService, private readonly system: SystemService, + private readonly s3: S3Service, ) {} @HasuraAction() @@ -103,6 +116,156 @@ export class NewsController { return { success: true, filename }; } + @Post("upload/video") + @UseInterceptors(FileInterceptor("file")) + public async uploadVideo( + @Req() request: Request, + @UploadedFile( + new ParseFilePipe({ + validators: [ + new MaxFileSizeValidator({ maxSize: DIRECT_MAX_SIZE }), + new FileTypeValidator({ fileType: /video\/mp4/ }), + ], + }), + ) + file: Express.Multer.File, + ) { + await this.assertCanPost(request.user as User | undefined); + + // The mimetype above is client-claimed; confirm the bytes really are mp4. + if (!this.hasMp4MagicBytes(file.buffer.subarray(0, 12))) { + throw new BadRequestException("file content does not match its type"); + } + + const filename = await this.news.uploadVideo(file.buffer); + return { success: true, filename }; + } + + @Post("upload/initiate") + public async initiateVideoUpload( + @Req() request: Request, + @Body() body: { fileSize?: number }, + ): Promise<{ + uploadId: string; + key: string; + chunkSize: number; + parts: Array<{ partNumber: number; url: string }>; + }> { + const user = await this.assertCanPost(request.user as User | undefined); + + const fileSize = Number(body.fileSize); + if (!Number.isFinite(fileSize) || fileSize <= 0) { + throw new BadRequestException("invalid file size"); + } + if (fileSize <= DIRECT_MAX_SIZE) { + // Small files must use /upload/video; the multipart bypass exists only + // for bodies too big to proxy through Cloudflare. + throw new BadRequestException("file is small enough to upload directly"); + } + if (fileSize > VIDEO_MAX_SIZE) { + throw new BadRequestException("file exceeds 512MB limit"); + } + + const filename = this.news.generateFilename("mp4"); + const key = this.news.mediaKey(filename); + const uploadId = await this.s3.createMultipartUpload(key); + const partCount = Math.ceil(fileSize / UPLOAD_CHUNK_SIZE); + const workerUrl = await this.news.getCloudflareWorkerUrl(); + + // Each part URL carries a short-lived HMAC bound to this key+uploadId so + // the worker never signs an arbitrary write (same scheme as event media + // and demo uploads). Without a worker we presign against S3 directly. + let uploadToken: string | null = null; + if (workerUrl) { + const signingSecret = process.env.S3_SECRET; + if (!signingSecret) { + throw new InternalServerErrorException( + "S3_SECRET is not configured; cannot authorize worker uploads", + ); + } + uploadToken = signUploadToken(signingSecret, key, uploadId); + } + + const parts: Array<{ partNumber: number; url: string }> = []; + for (let partNumber = 1; partNumber <= partCount; partNumber++) { + parts.push({ + partNumber, + url: workerUrl + ? `${workerUrl}/${key}?partNumber=${partNumber}&uploadId=${encodeURIComponent(uploadId)}&token=${encodeURIComponent(uploadToken!)}` + : await this.s3.getPresignedPartUrl(key, uploadId, partNumber), + }); + } + + this.logger.log( + `news video initiate steam_id=${user.steam_id} key=${key} parts=${partCount} bytes=${fileSize}`, + ); + + return { uploadId, key, chunkSize: UPLOAD_CHUNK_SIZE, parts }; + } + + @Post("upload/complete") + public async completeVideoUpload( + @Req() request: Request, + @Body() body: { uploadId?: string; key?: string }, + ): Promise<{ success: boolean; filename: string }> { + const user = await this.assertCanPost(request.user as User | undefined); + const key = this.news.assertMediaKey(body.key, "mp4"); + if (!body.uploadId) { + throw new BadRequestException("uploadId required"); + } + + try { + await this.s3.completeMultipartUpload(key, body.uploadId); + } catch (error) { + try { + await this.s3.abortMultipartUpload(key, body.uploadId); + } catch (abortError) { + this.logger.warn( + `abort after failed complete key=${key}: ${abortError}`, + ); + } + throw new BadRequestException( + `could not assemble upload: ${(error as Error)?.message ?? error}`, + ); + } + + // /initiate trusts the client-claimed fileSize and presigned part PUTs are + // uncapped, so the real assembled size is enforced here. + const { size } = await this.s3.stat(key); + if (size > VIDEO_MAX_SIZE) { + await this.s3.remove(key); + throw new BadRequestException("file exceeds 512MB limit"); + } + + const header = await this.s3.readPrefix(key, 12); + if (!this.hasMp4MagicBytes(header)) { + await this.s3.remove(key); + throw new BadRequestException("file content does not match its type"); + } + + this.logger.log(`news video complete steam_id=${user.steam_id} key=${key}`); + + return { success: true, filename: key.split("/").pop()! }; + } + + @Post("upload/abort") + public async abortVideoUpload( + @Req() request: Request, + @Body() body: { uploadId?: string; key?: string }, + ): Promise<{ success: boolean }> { + await this.assertCanPost(request.user as User | undefined); + const key = this.news.assertMediaKey(body.key, "mp4"); + if (!body.uploadId) { + throw new BadRequestException("uploadId required"); + } + try { + await this.s3.abortMultipartUpload(key, body.uploadId); + } catch (error) { + this.logger.warn(`abort multipart upload failed key=${key}: ${error}`); + } + return { success: true }; + } + @Post(":slug/view") public async trackView(@Param("slug") slug: string) { await this.news.trackView(slug); @@ -151,6 +314,116 @@ export class NewsController { result.stream.pipe(res); } + // Range support is not optional: iOS