feat: expand backend admin marketplace and scaling
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
فشلت بعض الفحوصات
/ deploy (push) Failing after 1m22s
هذا الالتزام موجود في:
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { SuperAdminPermission } from '../../modules/superadmin/superadmin-permissions';
|
||||
|
||||
export const SUPERADMIN_PERMISSIONS_KEY = 'superadmin_permissions';
|
||||
|
||||
export const SuperAdminPermissions = (...permissions: SuperAdminPermission[]) =>
|
||||
SetMetadata(SUPERADMIN_PERMISSIONS_KEY, permissions);
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { SortOrder } from '../enums/sort-order.enum';
|
||||
import { APP_CONSTANTS } from '../../config/constants';
|
||||
|
||||
export class PaginationQueryDto {
|
||||
@ApiPropertyOptional({ default: APP_CONSTANTS.DEFAULT_PAGE })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = APP_CONSTANTS.DEFAULT_PAGE;
|
||||
|
||||
@ApiPropertyOptional({ default: APP_CONSTANTS.DEFAULT_LIMIT, maximum: APP_CONSTANTS.MAX_LIMIT })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@@ -16,7 +20,20 @@ export class PaginationQueryDto {
|
||||
@Max(APP_CONSTANTS.MAX_LIMIT)
|
||||
limit?: number = APP_CONSTANTS.DEFAULT_LIMIT;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Cursor token for cursor-based endpoints' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cursor?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: SortOrder,
|
||||
default: SortOrder.DESC,
|
||||
description: 'Used by offset-based list endpoints. Cursor feeds may ignore it.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||
)
|
||||
@IsEnum(SortOrder)
|
||||
sortOrder?: SortOrder = SortOrder.DESC;
|
||||
}
|
||||
|
||||
5
src/common/enums/moderation-status.enum.ts
Normal file
5
src/common/enums/moderation-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum ModerationStatus {
|
||||
ACTIVE = 'active',
|
||||
HIDDEN = 'hidden',
|
||||
FLAGGED = 'flagged',
|
||||
}
|
||||
@@ -3,4 +3,7 @@ export enum NotificationType {
|
||||
COMMENT = 'comment',
|
||||
FOLLOW = 'follow',
|
||||
MESSAGE = 'message',
|
||||
SAVE = 'save',
|
||||
SHARE = 'share',
|
||||
MENTION = 'mention',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum PostType {
|
||||
TEXT = 'text',
|
||||
IMAGE = 'image',
|
||||
VIDEO = 'video',
|
||||
AUDIO = 'audio',
|
||||
}
|
||||
|
||||
5
src/common/enums/repair-request-status.enum.ts
Normal file
5
src/common/enums/repair-request-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum RepairRequestStatus {
|
||||
PENDING = 'pending',
|
||||
ACCEPTED = 'accepted',
|
||||
COMPLETED = 'completed',
|
||||
}
|
||||
4
src/common/enums/sort-order.enum.ts
Normal file
4
src/common/enums/sort-order.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum SortOrder {
|
||||
ASC = 'asc',
|
||||
DESC = 'desc',
|
||||
}
|
||||
33
src/common/guards/superadmin-permissions.guard.ts
Normal file
33
src/common/guards/superadmin-permissions.guard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||
import { SUPERADMIN_PERMISSIONS_KEY } from '../decorators/superadmin-permissions.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class SuperAdminPermissionsGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
||||
SUPERADMIN_PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredPermissions?.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<{ user?: JwtPayload }>();
|
||||
const payload = request.user;
|
||||
const grantedPermissions = new Set(payload?.permissions ?? []);
|
||||
const hasAllPermissions = requiredPermissions.every((permission) =>
|
||||
grantedPermissions.has(permission),
|
||||
);
|
||||
|
||||
if (!hasAllPermissions) {
|
||||
throw new ForbiddenException('Missing superadmin permission');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,17 @@ import {
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AppCacheService } from '../../infrastructure/cache/app-cache.service';
|
||||
import { THROTTLE_META_KEY, ThrottleMeta } from '../decorators/throttle.decorator';
|
||||
|
||||
type Bucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ThrottleGuard implements CanActivate {
|
||||
private readonly buckets = new Map<string, Bucket>();
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly cacheService: AppCacheService,
|
||||
) {}
|
||||
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const meta = this.reflector.getAllAndOverride<ThrottleMeta>(THROTTLE_META_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
@@ -28,23 +25,25 @@ export class ThrottleGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request & { ip?: string; originalUrl?: string }>();
|
||||
const ip = req.ip ?? 'unknown';
|
||||
const route = req.originalUrl ?? 'unknown-route';
|
||||
const key = `${ip}:${route}`;
|
||||
const now = Date.now();
|
||||
const existing = this.buckets.get(key);
|
||||
const req = context.switchToHttp().getRequest<
|
||||
Request & {
|
||||
ip?: string;
|
||||
originalUrl?: string;
|
||||
baseUrl?: string;
|
||||
route?: { path?: string };
|
||||
user?: { sub?: string };
|
||||
}
|
||||
>();
|
||||
const actorKey = req.user?.sub ?? req.ip ?? 'unknown';
|
||||
const routePath = `${req.baseUrl ?? ''}${req.route?.path ?? req.originalUrl ?? 'unknown-route'}`;
|
||||
const windowSeconds = Math.max(1, Math.ceil(meta.windowMs / 1000));
|
||||
const bucketKey = `rate-limit:${routePath}:${actorKey}`;
|
||||
const currentCount = await this.cacheService.incr(bucketKey, windowSeconds);
|
||||
|
||||
if (!existing || now > existing.resetAt) {
|
||||
this.buckets.set(key, { count: 1, resetAt: now + meta.windowMs });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existing.count >= meta.limit) {
|
||||
if (currentCount > meta.limit) {
|
||||
throw new HttpException('Too many requests, please try again later', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ export interface JwtPayload {
|
||||
role?: string;
|
||||
tokenType: 'access' | 'refresh' | 'superadmin_access' | 'superadmin_refresh';
|
||||
email?: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
21
src/common/utils/array-transform.util.spec.ts
Normal file
21
src/common/utils/array-transform.util.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { toNumberArray, toStringArray } from './array-transform.util';
|
||||
|
||||
describe('array transform utils', () => {
|
||||
it('wraps a single string as an array', () => {
|
||||
expect(toStringArray({ value: '69e8d1f7d1f72ba6416d864b' } as any)).toEqual([
|
||||
'69e8d1f7d1f72ba6416d864b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses a JSON string array', () => {
|
||||
expect(toStringArray({ value: '["a","b"]' } as any)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('keeps array input as an array', () => {
|
||||
expect(toStringArray({ value: ['a', 'b'] } as any)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('parses numeric arrays from JSON strings', () => {
|
||||
expect(toNumberArray({ value: '[1,2,3]' } as any)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
69
src/common/utils/array-transform.util.ts
Normal file
69
src/common/utils/array-transform.util.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { TransformFnParams } from 'class-transformer';
|
||||
|
||||
const parseArrayInput = (value: unknown): unknown[] | unknown | undefined => {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => {
|
||||
const parsed = parseArrayInput(item);
|
||||
if (typeof parsed === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
try {
|
||||
return parseArrayInput(JSON.parse(trimmed));
|
||||
} catch {
|
||||
return [trimmed];
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.includes(',')) {
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [trimmed];
|
||||
};
|
||||
|
||||
export const toStringArray = ({ value }: TransformFnParams): string[] | unknown | undefined => {
|
||||
const parsed = parseArrayInput(value);
|
||||
if (typeof parsed === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return parsed.map((item) => String(item).trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
export const toNumberArray = ({ value }: TransformFnParams): number[] | unknown | undefined => {
|
||||
const parsed = parseArrayInput(value);
|
||||
if (typeof parsed === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return parsed.map((item) => Number(item));
|
||||
};
|
||||
@@ -1,7 +1,24 @@
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
export const hashValue = async (value: string, saltRounds: number): Promise<string> =>
|
||||
bcrypt.hash(value, saltRounds);
|
||||
|
||||
export const compareHash = async (value: string, hashedValue: string): Promise<boolean> =>
|
||||
bcrypt.compare(value, hashedValue);
|
||||
|
||||
export const hashHighEntropyValue = (value: string, secret: string): string =>
|
||||
`sha256:${createHmac('sha256', secret).update(value).digest('hex')}`;
|
||||
|
||||
export const compareStoredHighEntropyValue = async (
|
||||
value: string,
|
||||
storedValue: string,
|
||||
secret: string,
|
||||
): Promise<boolean> => {
|
||||
if (storedValue.startsWith('sha256:')) {
|
||||
const nextValue = hashHighEntropyValue(value, secret);
|
||||
return timingSafeEqual(Buffer.from(nextValue), Buffer.from(storedValue));
|
||||
}
|
||||
|
||||
return compareHash(value, storedValue);
|
||||
};
|
||||
|
||||
37
src/common/utils/pagination.util.spec.ts
Normal file
37
src/common/utils/pagination.util.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { buildPaginatedResponse } from './pagination.util';
|
||||
|
||||
describe('pagination util', () => {
|
||||
it('builds offset pagination metadata', () => {
|
||||
const result = buildPaginatedResponse(['a', 'b'], {
|
||||
page: 2,
|
||||
limit: 2,
|
||||
total: 5,
|
||||
offset: 2,
|
||||
});
|
||||
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.totalPages).toBe(3);
|
||||
expect(result.pagination.hasNextPage).toBe(true);
|
||||
expect(result.pagination.hasPreviousPage).toBe(true);
|
||||
expect(result.pagination.nextPage).toBe(3);
|
||||
expect(result.pagination.previousPage).toBe(1);
|
||||
expect(result.pagination.mode).toBe('offset');
|
||||
});
|
||||
|
||||
it('builds cursor pagination metadata', () => {
|
||||
const result = buildPaginatedResponse(['a'], {
|
||||
page: 1,
|
||||
limit: 2,
|
||||
total: 3,
|
||||
offset: 0,
|
||||
currentCursor: 'cursor-a',
|
||||
nextCursor: 'cursor-b',
|
||||
mode: 'cursor',
|
||||
});
|
||||
|
||||
expect(result.nextCursor).toBe('cursor-b');
|
||||
expect(result.pagination.currentCursor).toBe('cursor-a');
|
||||
expect(result.pagination.nextCursor).toBe('cursor-b');
|
||||
expect(result.pagination.mode).toBe('cursor');
|
||||
});
|
||||
});
|
||||
70
src/common/utils/pagination.util.ts
Normal file
70
src/common/utils/pagination.util.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export type PaginatedResponseOptions = {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
offset: number;
|
||||
currentCursor?: string | null;
|
||||
nextCursor?: string | null;
|
||||
mode?: 'offset' | 'cursor';
|
||||
};
|
||||
|
||||
export type PaginatedResponse<T> = {
|
||||
items: T[];
|
||||
count: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
nextCursor: string | null;
|
||||
pagination: {
|
||||
mode: 'offset' | 'cursor';
|
||||
page: number;
|
||||
limit: number;
|
||||
count: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
nextPage: number | null;
|
||||
previousPage: number | null;
|
||||
currentCursor: string | null;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export const buildPaginatedResponse = <T>(
|
||||
items: T[],
|
||||
options: PaginatedResponseOptions,
|
||||
): PaginatedResponse<T> => {
|
||||
const count = items.length;
|
||||
const totalPages = Math.ceil(options.total / options.limit) || 1;
|
||||
const hasNextPage = options.offset + count < options.total;
|
||||
const hasPreviousPage = options.offset > 0;
|
||||
const mode =
|
||||
options.mode ??
|
||||
(options.currentCursor !== undefined || options.nextCursor !== undefined ? 'cursor' : 'offset');
|
||||
|
||||
return {
|
||||
items,
|
||||
count,
|
||||
page: options.page,
|
||||
limit: options.limit,
|
||||
total: options.total,
|
||||
totalPages,
|
||||
nextCursor: options.nextCursor ?? null,
|
||||
pagination: {
|
||||
mode,
|
||||
page: options.page,
|
||||
limit: options.limit,
|
||||
count,
|
||||
total: options.total,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
nextPage: hasNextPage ? options.page + 1 : null,
|
||||
previousPage: hasPreviousPage ? Math.max(1, options.page - 1) : null,
|
||||
currentCursor: options.currentCursor ?? null,
|
||||
nextCursor: options.nextCursor ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
29
src/common/utils/public-url.util.spec.ts
Normal file
29
src/common/utils/public-url.util.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { resolveManagedFileUrl, resolveManagedFileUrls } from './public-url.util';
|
||||
|
||||
describe('public url util', () => {
|
||||
const originalPublicBaseUrl = process.env.PUBLIC_BASE_URL;
|
||||
const originalStorageBasePath = process.env.STORAGE_BASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.PUBLIC_BASE_URL = 'http://192.168.1.12:4000';
|
||||
process.env.STORAGE_BASE_PATH = 'uploads';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PUBLIC_BASE_URL = originalPublicBaseUrl;
|
||||
process.env.STORAGE_BASE_PATH = originalStorageBasePath;
|
||||
});
|
||||
|
||||
it('resolves a local managed file url', () => {
|
||||
expect(resolveManagedFileUrl('/uploads/posts/images/file.png')).toBe(
|
||||
'http://192.168.1.12:4000/uploads/posts/images/file.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves arrays of local managed file urls', () => {
|
||||
expect(resolveManagedFileUrls(['/uploads/a.png', '/uploads/b.png'])).toEqual([
|
||||
'http://192.168.1.12:4000/uploads/a.png',
|
||||
'http://192.168.1.12:4000/uploads/b.png',
|
||||
]);
|
||||
});
|
||||
});
|
||||
27
src/common/utils/public-url.util.ts
Normal file
27
src/common/utils/public-url.util.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
const getUploadsBasePath = (): string =>
|
||||
`/${(process.env.STORAGE_BASE_PATH ?? 'uploads').replace(/^\/+|\/+$/g, '')}/`;
|
||||
|
||||
export const resolveManagedFileUrl = (fileUrl: unknown): unknown => {
|
||||
if (typeof fileUrl !== 'string' || !fileUrl.trim()) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
if (!fileUrl.startsWith(getUploadsBasePath())) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
const baseUrl = (process.env.PUBLIC_BASE_URL ?? '').replace(/\/$/, '');
|
||||
if (!baseUrl) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
return `${baseUrl}${fileUrl}`;
|
||||
};
|
||||
|
||||
export const resolveManagedFileUrls = (fileUrls: unknown): unknown => {
|
||||
if (!Array.isArray(fileUrls)) {
|
||||
return fileUrls;
|
||||
}
|
||||
|
||||
return fileUrls.map((fileUrl) => resolveManagedFileUrl(fileUrl));
|
||||
};
|
||||
16
src/common/utils/query-transform.util.spec.ts
Normal file
16
src/common/utils/query-transform.util.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { toBoolean } from './query-transform.util';
|
||||
|
||||
describe('query transform util', () => {
|
||||
it('converts "true" and "false" string values correctly', () => {
|
||||
expect(toBoolean({ value: 'true' })).toBe(true);
|
||||
expect(toBoolean({ value: 'TRUE' })).toBe(true);
|
||||
expect(toBoolean({ value: 'false' })).toBe(false);
|
||||
expect(toBoolean({ value: 'FALSE' })).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves unrelated values unchanged', () => {
|
||||
expect(toBoolean({ value: '0' })).toBe('0');
|
||||
expect(toBoolean({ value: 'hello' })).toBe('hello');
|
||||
expect(toBoolean({ value: 1 })).toBe(1);
|
||||
});
|
||||
});
|
||||
17
src/common/utils/query-transform.util.ts
Normal file
17
src/common/utils/query-transform.util.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const toBoolean = ({ value }: { value: unknown }): unknown => {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (value === true || value === false) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
13
src/common/utils/sort.util.spec.ts
Normal file
13
src/common/utils/sort.util.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { SortOrder } from '../enums/sort-order.enum';
|
||||
import { resolveMongoSortDirection } from './sort.util';
|
||||
|
||||
describe('sort util', () => {
|
||||
it('returns ascending for asc', () => {
|
||||
expect(resolveMongoSortDirection(SortOrder.ASC)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns descending by default', () => {
|
||||
expect(resolveMongoSortDirection(undefined)).toBe(-1);
|
||||
expect(resolveMongoSortDirection(SortOrder.DESC)).toBe(-1);
|
||||
});
|
||||
});
|
||||
7
src/common/utils/sort.util.ts
Normal file
7
src/common/utils/sort.util.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SortOrder } from '../enums/sort-order.enum';
|
||||
|
||||
export type MongoSortDirection = 1 | -1;
|
||||
|
||||
export const resolveMongoSortDirection = (
|
||||
sortOrder?: SortOrder | null,
|
||||
): MongoSortDirection => (sortOrder === SortOrder.ASC ? 1 : -1);
|
||||
101
src/common/utils/waveform.util.ts
Normal file
101
src/common/utils/waveform.util.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
const DEFAULT_SAMPLES = 48;
|
||||
|
||||
const scaleToRange = (values: number[]): number[] => {
|
||||
if (!values.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const max = Math.max(...values);
|
||||
if (max <= 0) {
|
||||
return values.map(() => 0);
|
||||
}
|
||||
|
||||
return values.map((value) => Math.max(0, Math.min(100, Math.round((value / max) * 100))));
|
||||
};
|
||||
|
||||
export const normalizeWaveformPeaks = (
|
||||
input: number[] | undefined,
|
||||
maxSamples = DEFAULT_SAMPLES,
|
||||
): number[] => {
|
||||
if (!input?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cleaned = input
|
||||
.map((value) => (Number.isFinite(value) ? Math.abs(value) : 0))
|
||||
.filter((value) => value > 0);
|
||||
|
||||
if (!cleaned.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (cleaned.length <= maxSamples) {
|
||||
return scaleToRange(cleaned);
|
||||
}
|
||||
|
||||
const windowSize = cleaned.length / maxSamples;
|
||||
const compressed: number[] = [];
|
||||
|
||||
for (let i = 0; i < maxSamples; i += 1) {
|
||||
const start = Math.floor(i * windowSize);
|
||||
const end = Math.min(cleaned.length, Math.floor((i + 1) * windowSize));
|
||||
const slice = cleaned.slice(start, Math.max(start + 1, end));
|
||||
const peak = Math.max(...slice);
|
||||
compressed.push(peak);
|
||||
}
|
||||
|
||||
return scaleToRange(compressed);
|
||||
};
|
||||
|
||||
export const generateWaveformPeaksFromBuffer = (
|
||||
buffer: Buffer,
|
||||
samples = DEFAULT_SAMPLES,
|
||||
): number[] => {
|
||||
if (!buffer.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chunkSize = Math.max(1, Math.ceil(buffer.length / samples));
|
||||
const peaks: number[] = [];
|
||||
|
||||
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
|
||||
const chunk = buffer.subarray(offset, Math.min(buffer.length, offset + chunkSize));
|
||||
let total = 0;
|
||||
let localPeak = 0;
|
||||
|
||||
for (const byte of chunk) {
|
||||
const centered = Math.abs(byte - 128);
|
||||
total += centered;
|
||||
localPeak = Math.max(localPeak, centered);
|
||||
}
|
||||
|
||||
const average = chunk.length ? total / chunk.length : 0;
|
||||
peaks.push(Math.max(average, localPeak * 0.65));
|
||||
}
|
||||
|
||||
return normalizeWaveformPeaks(peaks, samples);
|
||||
};
|
||||
|
||||
export const generateWaveformPeaksFromSeed = (
|
||||
seed: string,
|
||||
samples = DEFAULT_SAMPLES,
|
||||
): number[] => {
|
||||
const source = seed.trim() || 'audio';
|
||||
let hash = 2166136261;
|
||||
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
hash ^= source.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
|
||||
const peaks: number[] = [];
|
||||
let state = hash >>> 0;
|
||||
for (let i = 0; i < samples; i += 1) {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
const base = 18 + (state % 65);
|
||||
const accent = i % 6 === 0 ? 18 : i % 3 === 0 ? 10 : 0;
|
||||
peaks.push(base + accent);
|
||||
}
|
||||
|
||||
return normalizeWaveformPeaks(peaks, samples);
|
||||
};
|
||||
المرجع في مشكلة جديدة
حظر مستخدم