From 6559693cd1b79e08cdedf4ab77a0fec76a38fcba Mon Sep 17 00:00:00 2001 From: mrjvs Date: Wed, 3 Sep 2025 17:14:36 +0200 Subject: [PATCH] feat: add/move utils for handling CDN files --- src/cdn.ts | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/util.ts | 9 +++++ 2 files changed, 121 insertions(+) create mode 100644 src/cdn.ts diff --git a/src/cdn.ts b/src/cdn.ts new file mode 100644 index 0000000..84c0ca2 --- /dev/null +++ b/src/cdn.ts @@ -0,0 +1,112 @@ +import path from 'node:path'; +import { Stream } from 'node:stream'; +import fs from 'fs-extra'; +import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3 } from '@aws-sdk/client-s3'; +import { config, disabledFeatures } from '@/config-manager'; +import { fileStatOrNull } from './util'; +import { logger } from './logger'; +import type { Response } from 'express'; +import type { Readable } from 'node:stream'; +import type { S3Client } from '@aws-sdk/client-s3'; +import type { NodeJsClient } from '@smithy/types'; + +let s3: NodeJsClient | null = null; +if (!disabledFeatures.s3) { + s3 = new S3({ + forcePathStyle: true, + endpoint: config.cdn.s3.endpoint, + region: config.cdn.s3.region, + credentials: { + accessKeyId: config.cdn.s3.key, + secretAccessKey: config.cdn.s3.secret + } + }); +} + +export const cdnNamespace = { + spr: 'spr' +} as const; +export type CdnNamespace = keyof typeof cdnNamespace; + +function buildKey(namespace: CdnNamespace, key: string): string { + return `${namespace}/${key}`; +} + +function buildLocalCdnPath(fullKey: string): string { + return path.join(config.cdn.disk_path, fullKey); +} + +export async function getCdnFileAsStream(namespace: CdnNamespace, key: string): Promise { + const fullKey = buildKey(namespace, key); + + if (!s3) { + const filePath = buildLocalCdnPath(fullKey); + const fileInfo = await fileStatOrNull(filePath); + + if (!fileInfo) { + return null; + } + + return fs.createReadStream(filePath); + } + + const response = await s3.send(new GetObjectCommand({ + Key: fullKey, + Bucket: config.cdn.s3.bucket + })); + + if (!response.Body) { + return null; + } + + return response.Body; +} + +export async function uploadCdnFile(namespace: CdnNamespace, key: string, data: Buffer): Promise { + const fullKey = buildKey(namespace, key); + + if (!s3) { + const filePath = buildLocalCdnPath(fullKey); + const folder = path.dirname(filePath); + await fs.ensureDir(folder); + await fs.writeFile(filePath, data); + return; + } + + await s3.send(new PutObjectCommand({ + Key: fullKey, + Bucket: config.cdn.s3.bucket, + Body: data, + ACL: 'private' + })); +} + +export async function deleteCdnFile(namespace: CdnNamespace, key: string): Promise { + const fullKey = buildKey(namespace, key); + + if (!s3) { + const filePath = buildLocalCdnPath(fullKey); + const fileInfo = await fileStatOrNull(filePath); + if (!fileInfo || !fileInfo.isFile()) { + return; // Not found or not a file + } + + await fs.unlink(filePath); + return; + } + + await s3.send(new DeleteObjectCommand({ + Key: fullKey, + Bucket: config.cdn.s3.bucket + })); +} + +export function streamFileToResponse(response: Response, stream: Readable, headers: Record = {}): void { + response.setHeaders(new Headers(headers)); + Stream.pipeline(stream, response, (err) => { + if (err) { + logger.error('Error with response stream: ' + err.message); + response.end(); + } + }); +} diff --git a/src/util.ts b/src/util.ts index 25fa012..f8a5308 100644 --- a/src/util.ts +++ b/src/util.ts @@ -16,6 +16,7 @@ import type { GetUserFriendPIDsResponse } from '@pretendonetwork/grpc/friends/ge import type { NodeJsClient } from '@smithy/types'; import type { Response } from 'express'; import type { Readable } from 'node:stream'; +import type { Stats } from 'node:fs'; let s3: NodeJsClient; @@ -98,6 +99,14 @@ export function isValidFileNotifyCondition(condition: string): boolean { return VALID_FILE_NOTIFY_CONDITIONS.includes(condition); } +export async function fileStatOrNull(filePath: string): Promise { + try { + return await fs.stat(filePath); + } catch { + return null; + } +} + export async function getUserDataByPID(pid: number): Promise { try { return await gRPCAccountClient.getUserData({