feat: expand backend admin marketplace and scaling
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
هذا الالتزام موجود في:
102
src/infrastructure/cache/app-cache.service.ts
مباع
Normal file
102
src/infrastructure/cache/app-cache.service.ts
مباع
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
|
||||
type MemoryEntry = {
|
||||
value: string;
|
||||
expiresAt: number | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AppCacheService {
|
||||
private readonly memory = new Map<string, MemoryEntry>();
|
||||
|
||||
constructor(private readonly redisService: RedisService) {}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const redis = this.redisService.getClient();
|
||||
const fullKey = this.buildKey(key);
|
||||
|
||||
if (redis) {
|
||||
const value = await redis.get(fullKey);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
|
||||
const memoryEntry = this.memory.get(fullKey);
|
||||
if (!memoryEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (memoryEntry.expiresAt && memoryEntry.expiresAt <= Date.now()) {
|
||||
this.memory.delete(fullKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(memoryEntry.value) as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
const redis = this.redisService.getClient();
|
||||
const fullKey = this.buildKey(key);
|
||||
const serialized = JSON.stringify(value);
|
||||
|
||||
if (redis) {
|
||||
if (ttlSeconds && ttlSeconds > 0) {
|
||||
await redis.set(fullKey, serialized, 'EX', ttlSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
await redis.set(fullKey, serialized);
|
||||
return;
|
||||
}
|
||||
|
||||
const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
|
||||
this.memory.set(fullKey, { value: serialized, expiresAt });
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
const redis = this.redisService.getClient();
|
||||
const fullKey = this.buildKey(key);
|
||||
if (redis) {
|
||||
await redis.del(fullKey);
|
||||
return;
|
||||
}
|
||||
|
||||
this.memory.delete(fullKey);
|
||||
}
|
||||
|
||||
async remember<T>(key: string, ttlSeconds: number, factory: () => Promise<T>): Promise<T> {
|
||||
const cached = await this.get<T>(key);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const value = await factory();
|
||||
await this.set(key, value, ttlSeconds);
|
||||
return value;
|
||||
}
|
||||
|
||||
async incr(key: string, ttlSeconds?: number): Promise<number> {
|
||||
const redis = this.redisService.getClient();
|
||||
const fullKey = this.buildKey(key);
|
||||
|
||||
if (redis) {
|
||||
const nextValue = await redis.incr(fullKey);
|
||||
if (ttlSeconds && ttlSeconds > 0 && nextValue === 1) {
|
||||
await redis.expire(fullKey, ttlSeconds);
|
||||
}
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
const existing = await this.get<number>(key);
|
||||
const nextValue = (existing ?? 0) + 1;
|
||||
await this.set(key, nextValue, ttlSeconds);
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
private buildKey(key: string): string {
|
||||
return `${this.redisService.getKeyPrefix()}:${key}`;
|
||||
}
|
||||
}
|
||||
10
src/infrastructure/cache/cache.module.ts
مباع
Normal file
10
src/infrastructure/cache/cache.module.ts
مباع
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AppCacheService } from './app-cache.service';
|
||||
import { FeedVersionService } from './feed-version.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AppCacheService, FeedVersionService],
|
||||
exports: [AppCacheService, FeedVersionService],
|
||||
})
|
||||
export class CacheModule {}
|
||||
23
src/infrastructure/cache/feed-version.service.ts
مباع
Normal file
23
src/infrastructure/cache/feed-version.service.ts
مباع
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AppCacheService } from './app-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class FeedVersionService {
|
||||
private static readonly GLOBAL_VERSION_KEY = 'feed:global:version';
|
||||
|
||||
constructor(private readonly cacheService: AppCacheService) {}
|
||||
|
||||
async getGlobalVersion(): Promise<number> {
|
||||
const current = await this.cacheService.get<number>(FeedVersionService.GLOBAL_VERSION_KEY);
|
||||
if (typeof current === 'number' && current > 0) {
|
||||
return current;
|
||||
}
|
||||
|
||||
await this.cacheService.set(FeedVersionService.GLOBAL_VERSION_KEY, 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async bumpGlobalVersion(): Promise<number> {
|
||||
return this.cacheService.incr(FeedVersionService.GLOBAL_VERSION_KEY);
|
||||
}
|
||||
}
|
||||
91
src/infrastructure/logging/app-logger.service.ts
Normal file
91
src/infrastructure/logging/app-logger.service.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Injectable, LoggerService } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
type AppLogLevel = 'error' | 'warn' | 'log' | 'debug' | 'verbose';
|
||||
|
||||
@Injectable()
|
||||
export class AppLoggerService implements LoggerService {
|
||||
private readonly levelPriority: Record<AppLogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
log: 2,
|
||||
debug: 3,
|
||||
verbose: 4,
|
||||
};
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
log(message: any, context?: string): void {
|
||||
this.write('log', message, undefined, context);
|
||||
}
|
||||
|
||||
error(message: any, trace?: string, context?: string): void {
|
||||
this.write('error', message, trace, context);
|
||||
}
|
||||
|
||||
warn(message: any, context?: string): void {
|
||||
this.write('warn', message, undefined, context);
|
||||
}
|
||||
|
||||
debug(message: any, context?: string): void {
|
||||
this.write('debug', message, undefined, context);
|
||||
}
|
||||
|
||||
verbose(message: any, context?: string): void {
|
||||
this.write('verbose', message, undefined, context);
|
||||
}
|
||||
|
||||
logHttp(payload: Record<string, unknown>): void {
|
||||
this.write('log', 'http_request', undefined, 'HttpLogger', payload);
|
||||
}
|
||||
|
||||
private write(
|
||||
level: AppLogLevel,
|
||||
message: any,
|
||||
trace?: string,
|
||||
context?: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
): void {
|
||||
if (!this.shouldLog(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry: Record<string, unknown> = {
|
||||
level,
|
||||
timestamp: new Date().toISOString(),
|
||||
context: context ?? 'Application',
|
||||
...extra,
|
||||
};
|
||||
|
||||
if (typeof message === 'string') {
|
||||
entry.message = message;
|
||||
} else if (message instanceof Error) {
|
||||
entry.message = message.message;
|
||||
entry.errorName = message.name;
|
||||
entry.stack = message.stack;
|
||||
} else {
|
||||
entry.message = 'structured_log';
|
||||
entry.payload = message;
|
||||
}
|
||||
|
||||
if (trace) {
|
||||
entry.trace = trace;
|
||||
}
|
||||
|
||||
const serialized = `${JSON.stringify(entry)}\n`;
|
||||
if (level === 'error') {
|
||||
process.stderr.write(serialized);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(serialized);
|
||||
}
|
||||
|
||||
private shouldLog(level: AppLogLevel): boolean {
|
||||
const configuredLevel =
|
||||
(this.configService.get<string>('logging.level', { infer: true }) as AppLogLevel | undefined) ??
|
||||
'log';
|
||||
|
||||
return this.levelPriority[level] <= this.levelPriority[configuredLevel];
|
||||
}
|
||||
}
|
||||
9
src/infrastructure/logging/logging.module.ts
Normal file
9
src/infrastructure/logging/logging.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AppLoggerService } from './app-logger.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AppLoggerService],
|
||||
exports: [AppLoggerService],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
145
src/infrastructure/queue/app-queue.service.ts
Normal file
145
src/infrastructure/queue/app-queue.service.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Injectable,
|
||||
OnApplicationBootstrap,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JobsOptions, Queue, Worker } from 'bullmq';
|
||||
import { AppLoggerService } from '../logging/app-logger.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
|
||||
type JobProcessor = (payload: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
@Injectable()
|
||||
export class AppQueueService
|
||||
implements OnModuleInit, OnApplicationBootstrap, OnModuleDestroy
|
||||
{
|
||||
private readonly processors = new Map<string, JobProcessor>();
|
||||
private queue: Queue | null = null;
|
||||
private worker: Worker | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly logger: AppLoggerService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
// Intentionally empty. Processors are usually registered by other providers before bootstrap.
|
||||
}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
if (!this.isQueueEnabled() || !this.redisService.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queueName = this.getQueueName();
|
||||
const queueConnection = this.redisService.createQueueClient();
|
||||
const workerConnection = this.redisService.createQueueClient();
|
||||
if (!queueConnection || !workerConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.queue = new Queue(queueName, {
|
||||
connection: queueConnection,
|
||||
defaultJobOptions: this.getDefaultJobOptions(),
|
||||
});
|
||||
|
||||
this.worker = new Worker(
|
||||
queueName,
|
||||
async (job) => {
|
||||
const processor = this.processors.get(job.name);
|
||||
if (!processor) {
|
||||
throw new Error(`No processor registered for job "${job.name}"`);
|
||||
}
|
||||
|
||||
await processor(job.data as Record<string, unknown>);
|
||||
},
|
||||
{
|
||||
connection: workerConnection,
|
||||
concurrency:
|
||||
this.configService.get<number>('queue.workerConcurrency', { infer: true }) ?? 5,
|
||||
},
|
||||
);
|
||||
|
||||
this.worker.on('failed', (job, error) => {
|
||||
this.logger.error(
|
||||
{
|
||||
queue: queueName,
|
||||
jobName: job?.name,
|
||||
jobId: job?.id,
|
||||
error: error.message,
|
||||
},
|
||||
undefined,
|
||||
AppQueueService.name,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
registerProcessor(jobName: string, processor: JobProcessor): void {
|
||||
this.processors.set(jobName, processor);
|
||||
}
|
||||
|
||||
async enqueue(
|
||||
jobName: string,
|
||||
payload: Record<string, unknown>,
|
||||
options: JobsOptions = {},
|
||||
): Promise<void> {
|
||||
if (this.queue) {
|
||||
await this.queue.add(jobName, payload, {
|
||||
...this.getDefaultJobOptions(),
|
||||
...options,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const processor = this.processors.get(jobName);
|
||||
if (!processor) {
|
||||
return;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
void processor(payload).catch((error: Error) => {
|
||||
this.logger.error(
|
||||
{
|
||||
jobName,
|
||||
payload,
|
||||
error: error.message,
|
||||
},
|
||||
error.stack,
|
||||
AppQueueService.name,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.worker?.close();
|
||||
await this.queue?.close();
|
||||
this.worker = null;
|
||||
this.queue = null;
|
||||
}
|
||||
|
||||
private isQueueEnabled(): boolean {
|
||||
return this.configService.get<boolean>('queue.enabled', { infer: true }) ?? false;
|
||||
}
|
||||
|
||||
private getQueueName(): string {
|
||||
return this.configService.get<string>('queue.name', { infer: true }) ?? 'app-jobs';
|
||||
}
|
||||
|
||||
private getDefaultJobOptions(): JobsOptions {
|
||||
return {
|
||||
attempts:
|
||||
this.configService.get<number>('queue.defaultJobAttempts', { infer: true }) ?? 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay:
|
||||
this.configService.get<number>('queue.defaultJobBackoffMs', { infer: true }) ?? 1000,
|
||||
},
|
||||
removeOnComplete:
|
||||
this.configService.get<boolean>('queue.removeOnComplete', { infer: true }) ?? true,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/infrastructure/queue/queue.module.ts
Normal file
9
src/infrastructure/queue/queue.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AppQueueService } from './app-queue.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AppQueueService],
|
||||
exports: [AppQueueService],
|
||||
})
|
||||
export class QueueModule {}
|
||||
9
src/infrastructure/redis/redis.module.ts
Normal file
9
src/infrastructure/redis/redis.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
79
src/infrastructure/redis/redis.service.ts
Normal file
79
src/infrastructure/redis/redis.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis, { RedisOptions } from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
private client: Redis | null = null;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
|
||||
}
|
||||
|
||||
getKeyPrefix(): string {
|
||||
return this.configService.get<string>('redis.keyPrefix', { infer: true }) ?? 'oudelaa';
|
||||
}
|
||||
|
||||
getClient(): Redis | null {
|
||||
if (!this.isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.client) {
|
||||
this.client = this.createClient({ maxRetriesPerRequest: null });
|
||||
}
|
||||
|
||||
return this.client;
|
||||
}
|
||||
|
||||
createPubSubClients(): { pubClient: Redis; subClient: Redis } | null {
|
||||
if (!this.isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pubClient = this.createClient({ maxRetriesPerRequest: null });
|
||||
const subClient = pubClient.duplicate();
|
||||
return { pubClient, subClient };
|
||||
}
|
||||
|
||||
createQueueClient(): Redis | null {
|
||||
if (!this.isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.createClient({ maxRetriesPerRequest: null });
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.client) {
|
||||
void this.client.quit().catch(() => this.client?.disconnect());
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
|
||||
private createClient(overrides: Partial<RedisOptions> = {}): Redis {
|
||||
const url = this.configService.get<string>('redis.url', { infer: true }) ?? '';
|
||||
const baseOptions: RedisOptions = {
|
||||
host: this.configService.get<string>('redis.host', { infer: true }) ?? '127.0.0.1',
|
||||
port: this.configService.get<number>('redis.port', { infer: true }) ?? 6379,
|
||||
username: this.configService.get<string>('redis.username', { infer: true }) || undefined,
|
||||
password: this.configService.get<string>('redis.password', { infer: true }) || undefined,
|
||||
db: this.configService.get<number>('redis.db', { infer: true }) ?? 0,
|
||||
lazyConnect: false,
|
||||
enableReadyCheck: true,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
if (url) {
|
||||
return new Redis(url, {
|
||||
...overrides,
|
||||
lazyConnect: false,
|
||||
enableReadyCheck: true,
|
||||
});
|
||||
}
|
||||
|
||||
return new Redis(baseOptions);
|
||||
}
|
||||
}
|
||||
45
src/infrastructure/socket/redis-io.adapter.ts
Normal file
45
src/infrastructure/socket/redis-io.adapter.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { INestApplicationContext } from '@nestjs/common';
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import { ServerOptions } from 'socket.io';
|
||||
import Redis from 'ioredis';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
|
||||
export class RedisIoAdapter extends IoAdapter {
|
||||
private adapterConstructor: ReturnType<typeof createAdapter> | null = null;
|
||||
private pubClient: Redis | null = null;
|
||||
private subClient: Redis | null = null;
|
||||
|
||||
constructor(
|
||||
app: INestApplicationContext,
|
||||
private readonly redisService: RedisService,
|
||||
) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
async connectToRedis(): Promise<void> {
|
||||
const clients = this.redisService.createPubSubClients();
|
||||
if (!clients) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pubClient = clients.pubClient;
|
||||
this.subClient = clients.subClient;
|
||||
this.adapterConstructor = createAdapter(this.pubClient as any, this.subClient as any);
|
||||
}
|
||||
|
||||
createIOServer(port: number, options?: ServerOptions) {
|
||||
const server = super.createIOServer(port, options);
|
||||
if (this.adapterConstructor) {
|
||||
server.adapter(this.adapterConstructor);
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pubClient?.quit().catch(() => this.pubClient?.disconnect());
|
||||
await this.subClient?.quit().catch(() => this.subClient?.disconnect());
|
||||
this.pubClient = null;
|
||||
this.subClient = null;
|
||||
}
|
||||
}
|
||||
210
src/infrastructure/storage/managed-storage.service.ts
Normal file
210
src/infrastructure/storage/managed-storage.service.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdir, unlink, writeFile } from 'fs/promises';
|
||||
import { join, posix } from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class ManagedStorageService implements OnModuleDestroy {
|
||||
private s3Client: S3Client | null = null;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async saveFile(params: {
|
||||
folderSegments: string[];
|
||||
extension: string;
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fileNamePrefix?: string;
|
||||
}): Promise<string> {
|
||||
const fileName = `${params.fileNamePrefix ?? 'file'}-${randomUUID()}${params.extension}`;
|
||||
const provider = this.getProvider();
|
||||
const basePath = this.getBasePath();
|
||||
const normalizedSegments = params.folderSegments.map((segment) =>
|
||||
segment.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
|
||||
);
|
||||
const objectKey = posix.join(basePath, ...normalizedSegments, fileName);
|
||||
|
||||
if (provider === 's3') {
|
||||
const client = this.getS3Client();
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
Bucket: this.getS3Bucket(),
|
||||
Key: objectKey,
|
||||
Body: params.buffer,
|
||||
ContentType: params.contentType || undefined,
|
||||
},
|
||||
});
|
||||
await upload.done();
|
||||
return this.resolvePublicUrl(objectKey);
|
||||
}
|
||||
|
||||
const uploadDir = join(process.cwd(), ...objectKey.split('/').slice(0, -1));
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
await writeFile(join(process.cwd(), ...objectKey.split('/')), params.buffer);
|
||||
return `/${objectKey}`;
|
||||
}
|
||||
|
||||
async deleteFile(fileUrl?: string): Promise<void> {
|
||||
if (!fileUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.getProvider() === 's3') {
|
||||
const objectKey = this.resolveS3ObjectKey(fileUrl);
|
||||
if (!objectKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.getS3Client();
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.getS3Bucket(),
|
||||
Key: objectKey,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const relativePath = this.resolveLocalRelativePath(fileUrl);
|
||||
if (!relativePath || relativePath.includes('..')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(join(process.cwd(), relativePath.replace(/\//g, '\\')));
|
||||
} catch {
|
||||
// Ignore cleanup failures for already-missing files.
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.s3Client = null;
|
||||
}
|
||||
|
||||
private getProvider(): 'local' | 's3' {
|
||||
return (this.configService.get<string>('storage.provider', { infer: true }) as
|
||||
| 'local'
|
||||
| 's3'
|
||||
| undefined) ?? 'local';
|
||||
}
|
||||
|
||||
private getBasePath(): string {
|
||||
return (this.configService.get<string>('storage.basePath', { infer: true }) ?? 'uploads')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
private getS3Bucket(): string {
|
||||
const bucket = this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '';
|
||||
if (!bucket) {
|
||||
throw new BadRequestException('S3 bucket is not configured');
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
private getS3Client(): S3Client {
|
||||
if (this.s3Client) {
|
||||
return this.s3Client;
|
||||
}
|
||||
|
||||
const region = this.configService.get<string>('storage.s3.region', { infer: true }) ?? 'auto';
|
||||
const endpoint = this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '';
|
||||
const accessKeyId =
|
||||
this.configService.get<string>('storage.s3.accessKeyId', { infer: true }) ?? '';
|
||||
const secretAccessKey =
|
||||
this.configService.get<string>('storage.s3.secretAccessKey', { infer: true }) ?? '';
|
||||
const forcePathStyle =
|
||||
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
|
||||
|
||||
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
||||
throw new BadRequestException('S3 storage settings are not fully configured');
|
||||
}
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
forcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
});
|
||||
|
||||
return this.s3Client;
|
||||
}
|
||||
|
||||
private resolvePublicUrl(objectKey: string): string {
|
||||
const publicBaseUrl =
|
||||
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
if (publicBaseUrl) {
|
||||
return `${publicBaseUrl}/${objectKey}`;
|
||||
}
|
||||
|
||||
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const bucket = this.getS3Bucket();
|
||||
const forcePathStyle =
|
||||
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
|
||||
|
||||
if (!endpoint) {
|
||||
throw new BadRequestException('storage.publicBaseUrl or storage.s3.endpoint is required');
|
||||
}
|
||||
|
||||
return forcePathStyle ? `${endpoint}/${bucket}/${objectKey}` : `${endpoint}/${objectKey}`;
|
||||
}
|
||||
|
||||
private resolveLocalRelativePath(fileUrl: string): string | null {
|
||||
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
|
||||
if (!normalizedUrl.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedPrefix = `/${this.getBasePath()}/`;
|
||||
if (!normalizedUrl.startsWith(expectedPrefix) && normalizedUrl !== `/${this.getBasePath()}`) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizedUrl.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
private resolveS3ObjectKey(fileUrl: string): string | null {
|
||||
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
|
||||
const publicBaseUrl =
|
||||
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
|
||||
if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) {
|
||||
return normalizedUrl.slice(publicBaseUrl.length + 1);
|
||||
}
|
||||
|
||||
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const bucket = this.getS3Bucket();
|
||||
const forcePathStyle =
|
||||
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
|
||||
|
||||
if (endpoint && normalizedUrl.startsWith(`${endpoint}/`)) {
|
||||
const pathPart = normalizedUrl.slice(endpoint.length + 1);
|
||||
if (forcePathStyle) {
|
||||
const expectedPrefix = `${bucket}/`;
|
||||
return pathPart.startsWith(expectedPrefix) ? pathPart.slice(expectedPrefix.length) : null;
|
||||
}
|
||||
return pathPart;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
9
src/infrastructure/storage/storage.module.ts
Normal file
9
src/infrastructure/storage/storage.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ManagedStorageService } from './managed-storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ManagedStorageService],
|
||||
exports: [ManagedStorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم