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
273 changes: 273 additions & 0 deletions src/news/news.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import {
BadRequestException,
Body,
Controller,
Post,
Get,
InternalServerErrorException,
Param,
Req,
Res,
Expand All @@ -20,17 +23,27 @@ 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);

constructor(
private readonly news: NewsService,
private readonly system: SystemService,
private readonly s3: S3Service,
) {}

@HasuraAction()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -151,6 +314,116 @@ export class NewsController {
result.stream.pipe(res);
}

// Range support is not optional: iOS <video> refuses to play a 200-only
// response, the same reason clip downloads serve 206s.
@Get("video/:filename")
public async serveVideo(
@Param("filename") filename: string,
@Req() request: Request,
@Res() response: Response,
) {
if (!/^[0-9a-f]{24}\.mp4$/.test(filename)) {
throw new NotFoundException("Video not found");
}

const key = this.news.mediaKey(filename);

let size: number;
try {
({ size } = await this.s3.stat(key));
} catch (error) {
if ((error as { code?: string })?.code === "NotFound") {
throw new NotFoundException("Video not found");
}
this.logger.error(`failed to stat ${key}: ${(error as Error)?.message}`);
response.status(500).json({ error: "internal" });
return;
}

response.setHeader("Content-Type", "video/mp4");
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("X-Content-Type-Options", "nosniff");
// Filenames are content-random and never reused, so this is safe to hold
// in shared caches (news articles are public once published).
response.setHeader("Cache-Control", "public, max-age=31536000, immutable");

const rangeHeader = request.headers.range;
const range = rangeHeader ? this.parseRange(rangeHeader, size) : null;

if (rangeHeader && !range) {
response.setHeader("Content-Range", `bytes */${size}`);
response.status(416).end();
return;
}

try {
if (range) {
const length = range.end - range.start + 1;
response.status(206);
response.setHeader(
"Content-Range",
`bytes ${range.start}-${range.end}/${size}`,
);
response.setHeader("Content-Length", String(length));
this.pipeWithCleanup(
await this.s3.getPartial(key, range.start, length),
response,
);
} else {
response.status(200);
response.setHeader("Content-Length", String(size));
this.pipeWithCleanup(await this.s3.get(key), response);
}
} catch (error) {
this.logger.error(`failed to stream ${key}: ${(error as Error)?.message}`);
if (!response.headersSent) {
response.status(500).json({ error: "internal" });
} else {
response.destroy();
}
}
}

private hasMp4MagicBytes(header: Buffer): boolean {
return header.length >= 8 && header.subarray(4, 8).toString() === "ftyp";
}

private parseRange(
header: string,
size: number,
): { start: number; end: number } | null {
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return null;
const startStr = match[1];
const endStr = match[2];
let start: number;
let end: number;
if (startStr === "" && endStr === "") return null;
if (startStr === "") {
const suffix = parseInt(endStr, 10);
if (!Number.isFinite(suffix) || suffix <= 0) return null;
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = parseInt(startStr, 10);
end = endStr === "" ? size - 1 : parseInt(endStr, 10);
}
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
if (start < 0 || end < start || start >= size) return null;
if (end >= size) end = size - 1;
return { start, end };
}

private pipeWithCleanup(
stream: NodeJS.ReadableStream,
response: Response,
): void {
response.on("close", () => {
(stream as unknown as { destroy?: () => void }).destroy?.();
});
stream.pipe(response);
}

private async assertCanPost(user?: User): Promise<User> {
if (!user) {
throw new ForbiddenException("Authentication required");
Expand Down
47 changes: 45 additions & 2 deletions src/news/news.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const EXTENSION_BY_MIMETYPE: Record<string, string> = {
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
"video/mp4": "mp4",
};

@Injectable()
Expand Down Expand Up @@ -225,14 +226,53 @@ export class NewsService {
);
}

public generateFilename(extension: string): string {
return `${crypto.randomBytes(12).toString("hex")}.${extension}`;
}

public mediaKey(filename: string): string {
return `${NewsService.IMAGE_PREFIX}/${filename}`;
}

// Keys handed back by the client on multipart complete/abort are untrusted:
// pin them to the news prefix and the exact shape generateFilename produces
// so a caller can never point the assembler at somebody else's object.
public assertMediaKey(key: string | undefined, extension: string): string {
const expected = new RegExp(
`^${NewsService.IMAGE_PREFIX}/[0-9a-f]{24}\\.${extension}$`,
);
if (!key || !expected.test(key)) {
throw new BadRequestException("invalid upload key");
}
return key;
}

public async uploadImage(buffer: Buffer, mimetype: string): Promise<string> {
const ext = EXTENSION_BY_MIMETYPE[mimetype] || "png";
const filename = `${crypto.randomBytes(12).toString("hex")}.${ext}`;
await this.s3.put(`${NewsService.IMAGE_PREFIX}/${filename}`, buffer);
const filename = this.generateFilename(ext);
await this.s3.put(this.mediaKey(filename), buffer, mimetype);
this.logger.log(`Uploaded news image ${filename}`);
return filename;
}

public async uploadVideo(buffer: Buffer): Promise<string> {
const filename = this.generateFilename("mp4");
await this.s3.put(this.mediaKey(filename), buffer, "video/mp4");
this.logger.log(`Uploaded news video ${filename}`);
return filename;
}

// Mirrors EventsService.getCloudflareWorkerUrl — when a worker is configured
// the part PUTs must route through it (it answers the CORS preflight and
// signs the B2 write itself) rather than using a presigned S3 URL.
public async getCloudflareWorkerUrl(): Promise<string | null> {
const rows = await this.postgres.query<Array<{ value: string }>>(
`SELECT value FROM public.settings WHERE name = 'cloudflare_worker_url' LIMIT 1`,
);
const value = rows.at(0)?.value?.trim();
return value ? value.replace(/\/+$/, "") : null;
}

public async getImageStream(
filename: string,
): Promise<{ stream: Readable; contentType: string; etag?: string } | null> {
Expand Down Expand Up @@ -287,6 +327,9 @@ export class NewsService {
if (filename.endsWith(".gif")) {
return "image/gif";
}
if (filename.endsWith(".mp4")) {
return "video/mp4";
}
return "application/octet-stream";
}
}
Loading