هذا الالتزام موجود في:
37
src/common/utils/share-url.util.ts
Normal file
37
src/common/utils/share-url.util.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
const DEFAULT_SHARE_BASE_URL = 'https://oudelaa.com';
|
||||
|
||||
export const normalizeShareBaseUrl = (value: string | undefined | null): string =>
|
||||
(value?.trim() || DEFAULT_SHARE_BASE_URL).replace(/\/+$/, '');
|
||||
|
||||
export const resolveShareBaseUrl = (configService?: ConfigService): string => {
|
||||
const configured =
|
||||
process.env.SHARE_BASE_URL?.trim() ||
|
||||
process.env.PUBLIC_WEB_URL?.trim() ||
|
||||
configService?.get<string>('shareBaseUrl', { infer: true }) ||
|
||||
configService?.get<string>('publicWebUrl', { infer: true }) ||
|
||||
'';
|
||||
|
||||
return normalizeShareBaseUrl(configured);
|
||||
};
|
||||
|
||||
export const buildProfileShareUrl = (
|
||||
baseUrl: string,
|
||||
user: { id?: unknown; _id?: unknown; username?: unknown },
|
||||
): string => {
|
||||
const username = typeof user.username === 'string' ? user.username.trim() : '';
|
||||
if (username) {
|
||||
return `${baseUrl}/u/${encodeURIComponent(username)}`;
|
||||
}
|
||||
|
||||
const candidate = user.id ?? user._id;
|
||||
const userId = candidate?.toString?.() ?? String(candidate ?? '');
|
||||
return `${baseUrl}/profile/${encodeURIComponent(userId)}`;
|
||||
};
|
||||
|
||||
export const buildPostShareUrl = (baseUrl: string, postId: string): string =>
|
||||
`${baseUrl}/posts/${encodeURIComponent(postId)}`;
|
||||
|
||||
export const buildAiMusicShareUrl = (baseUrl: string, archiveItemId: string): string =>
|
||||
`${baseUrl}/ai/music/${encodeURIComponent(archiveItemId)}`;
|
||||
@@ -4,6 +4,12 @@ export default () => ({
|
||||
host: process.env.HOST ?? '0.0.0.0',
|
||||
publicBaseUrl:
|
||||
process.env.PUBLIC_BASE_URL ?? `http://localhost:${Number(process.env.PORT ?? 4000)}`,
|
||||
publicWebUrl: (process.env.PUBLIC_WEB_URL ?? 'https://oudelaa.com').replace(/\/+$/, ''),
|
||||
shareBaseUrl: (
|
||||
process.env.SHARE_BASE_URL ??
|
||||
process.env.PUBLIC_WEB_URL ??
|
||||
'https://oudelaa.com'
|
||||
).replace(/\/+$/, ''),
|
||||
responseEnvelopeEnabled:
|
||||
(process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||
globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1',
|
||||
|
||||
@@ -5,6 +5,8 @@ export const validationSchema = Joi.object({
|
||||
PORT: Joi.number().default(4000),
|
||||
HOST: Joi.string().default('0.0.0.0'),
|
||||
PUBLIC_BASE_URL: Joi.string().uri().optional(),
|
||||
PUBLIC_WEB_URL: Joi.string().uri().optional(),
|
||||
SHARE_BASE_URL: Joi.string().uri().optional(),
|
||||
RESPONSE_ENVELOPE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||
GLOBAL_PREFIX: Joi.string().default('api/v1'),
|
||||
CORS_ORIGINS: Joi.string().allow('').optional(),
|
||||
|
||||
@@ -376,7 +376,7 @@ describe('MediaService', () => {
|
||||
|
||||
it('returns a simple share payload', async () => {
|
||||
const service = createService('', {
|
||||
config: { publicBaseUrl: 'https://api.example.com' },
|
||||
config: { shareBaseUrl: 'https://oudelaa.com/' },
|
||||
mediaRepository: {
|
||||
findAiMusicArchiveItemByUser: jest
|
||||
.fn()
|
||||
@@ -390,10 +390,11 @@ describe('MediaService', () => {
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
shareUrl: 'https://api.example.com/ai-music/archive/archive-1',
|
||||
shareUrl: 'https://oudelaa.com/ai/music/archive-1',
|
||||
title: 'Share me',
|
||||
audioUrl: '/uploads/ai-music/generated.wav',
|
||||
});
|
||||
expect(result.shareUrl).not.toContain('hosted.ghaymah.systems');
|
||||
});
|
||||
|
||||
it('shares an archive item to the feed as an audio post', async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import { buildAiMusicShareUrl, resolveShareBaseUrl } from '../../common/utils/share-url.util';
|
||||
import { generateWaveformPeakSetFromBuffer } from '../../common/utils/waveform.util';
|
||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||
import { MediaProbeService } from '../../infrastructure/storage/media-probe.service';
|
||||
@@ -331,7 +332,7 @@ export class MediaService {
|
||||
const formatted = this.formatAiMusicArchiveItem(item);
|
||||
|
||||
return {
|
||||
shareUrl: `${this.resolvePublicAppUrl()}/ai-music/archive/${encodeURIComponent(item.id)}`,
|
||||
shareUrl: buildAiMusicShareUrl(this.resolveShareBaseUrl(), item.id),
|
||||
title: formatted.title,
|
||||
audioUrl: formatted.audioUrl,
|
||||
};
|
||||
@@ -444,12 +445,8 @@ export class MediaService {
|
||||
return compactPrompt.length > 80 ? `${compactPrompt.slice(0, 77)}...` : compactPrompt;
|
||||
}
|
||||
|
||||
private resolvePublicAppUrl(): string {
|
||||
const configured =
|
||||
process.env.PUBLIC_APP_URL?.trim() ||
|
||||
this.configService.get<string>('publicBaseUrl', { infer: true }) ||
|
||||
'';
|
||||
return (configured || 'https://oudelaa.com').replace(/\/+$/, '');
|
||||
private resolveShareBaseUrl(): string {
|
||||
return resolveShareBaseUrl(this.configService);
|
||||
}
|
||||
|
||||
private resolveGoogleApplicationCredentials(): {
|
||||
|
||||
@@ -4,7 +4,18 @@ import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
||||
import { PostSchema } from './schemas/post.schema';
|
||||
import { PostsService } from './posts.service';
|
||||
|
||||
const createService = () => {
|
||||
const createService = (options: { shareBaseUrl?: string; publicWebUrl?: string } = {}) => {
|
||||
const configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'shareBaseUrl') {
|
||||
return options.shareBaseUrl;
|
||||
}
|
||||
if (key === 'publicWebUrl') {
|
||||
return options.publicWebUrl;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
const postsRepository = {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
@@ -37,6 +48,7 @@ const createService = () => {
|
||||
|
||||
const service = new PostsService(
|
||||
connection as any,
|
||||
configService as any,
|
||||
postsRepository as any,
|
||||
usersRepository as any,
|
||||
{} as any,
|
||||
@@ -292,6 +304,30 @@ describe('PostsService post sharing', () => {
|
||||
);
|
||||
expect(result.shareCount).toBe(5);
|
||||
});
|
||||
|
||||
it('normalizes trailing slash in SHARE_BASE_URL when building post links', async () => {
|
||||
const viewerId = new Types.ObjectId().toString();
|
||||
const post = buildPost();
|
||||
const { service, postsRepository } = createService({
|
||||
shareBaseUrl: 'https://oudelaa.com/',
|
||||
});
|
||||
postsRepository.findById.mockResolvedValue(post);
|
||||
|
||||
const result = await service.getShareSheet(viewerId, post.id);
|
||||
|
||||
expect(result.shareUrl).toBe(`https://oudelaa.com/posts/${post.id}`);
|
||||
});
|
||||
|
||||
it('does not use hosted.ghaymah.systems for post share URLs by default', async () => {
|
||||
const viewerId = new Types.ObjectId().toString();
|
||||
const post = buildPost();
|
||||
const { service, postsRepository } = createService();
|
||||
postsRepository.findById.mockResolvedValue(post);
|
||||
|
||||
const result = await service.getShareSheet(viewerId, post.id);
|
||||
|
||||
expect(result.shareUrl).not.toContain('hosted.ghaymah.systems');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Post schema response aliases', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Connection, Types } from 'mongoose';
|
||||
import { InjectConnection } from '@nestjs/mongoose';
|
||||
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
||||
@@ -14,6 +15,7 @@ import { ProcessingStatus } from '../../common/enums/processing-status.enum';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
|
||||
import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
|
||||
import { buildPostShareUrl, resolveShareBaseUrl } from '../../common/utils/share-url.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import {
|
||||
buildWaveformPeakSet,
|
||||
@@ -101,6 +103,7 @@ export class PostsService {
|
||||
|
||||
constructor(
|
||||
@InjectConnection() private readonly connection: Connection,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly postsRepository: PostsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly storageService: ManagedStorageService,
|
||||
@@ -2064,11 +2067,11 @@ export class PostsService {
|
||||
}
|
||||
|
||||
private buildPostShareUrl(postId: string): string {
|
||||
return `${this.resolvePublicAppUrl()}/posts/${encodeURIComponent(postId)}`;
|
||||
return buildPostShareUrl(this.resolveShareBaseUrl(), postId);
|
||||
}
|
||||
|
||||
private resolvePublicAppUrl(): string {
|
||||
return (process.env.PUBLIC_APP_URL?.trim() || 'https://oudelaa.com').replace(/\/+$/, '');
|
||||
private resolveShareBaseUrl(): string {
|
||||
return resolveShareBaseUrl(this.configService);
|
||||
}
|
||||
|
||||
private resolveAvatarUrl(value: unknown): string {
|
||||
|
||||
@@ -17,7 +17,8 @@ const createService = (options: {
|
||||
newFollowersThisWeek?: number;
|
||||
newFollowersThisMonth?: number;
|
||||
recentActivity?: Record<string, any>[];
|
||||
publicAppUrl?: string;
|
||||
publicWebUrl?: string;
|
||||
shareBaseUrl?: string;
|
||||
} = {}) => {
|
||||
const userId = new Types.ObjectId().toString();
|
||||
const user = options.user ?? {
|
||||
@@ -115,7 +116,17 @@ const createService = (options: {
|
||||
connection as any,
|
||||
usersRepository as any,
|
||||
{ logSuperAdminAction: jest.fn() } as any,
|
||||
{ get: jest.fn((key: string) => (key === 'PUBLIC_APP_URL' ? options.publicAppUrl : undefined)) } as any,
|
||||
{
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'shareBaseUrl') {
|
||||
return options.shareBaseUrl;
|
||||
}
|
||||
if (key === 'publicWebUrl') {
|
||||
return options.publicWebUrl;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as any,
|
||||
{ saveFile: jest.fn(), deleteFile: jest.fn() } as any,
|
||||
);
|
||||
|
||||
@@ -281,7 +292,7 @@ describe('UsersService follow counts', () => {
|
||||
|
||||
describe('UsersService profile sharing', () => {
|
||||
it('returns profileShareUrl with username in profile overview', async () => {
|
||||
const { service, userId } = createService({ publicAppUrl: 'https://oudelaa.com/' });
|
||||
const { service, userId } = createService({ shareBaseUrl: 'https://oudelaa.com/' });
|
||||
|
||||
const result = await service.getProfileOverview(userId, userId);
|
||||
|
||||
@@ -300,7 +311,7 @@ describe('UsersService profile sharing', () => {
|
||||
postsCount: 0,
|
||||
isDisabled: false,
|
||||
},
|
||||
publicAppUrl: 'https://oudelaa.com',
|
||||
shareBaseUrl: 'https://oudelaa.com',
|
||||
});
|
||||
|
||||
const result = await service.getProfileOverview(userId, userId);
|
||||
@@ -310,7 +321,7 @@ describe('UsersService profile sharing', () => {
|
||||
|
||||
it('returns profile overview by username', async () => {
|
||||
const { service, userId, usersRepository } = createService({
|
||||
publicAppUrl: 'https://oudelaa.com',
|
||||
publicWebUrl: 'https://oudelaa.com',
|
||||
});
|
||||
|
||||
const result = await service.getProfileOverviewByUsername('Artist_One', userId);
|
||||
|
||||
@@ -15,6 +15,10 @@ import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media
|
||||
import { hashValue } from '../../common/utils/hash.util';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
|
||||
import {
|
||||
buildProfileShareUrl as buildCanonicalProfileShareUrl,
|
||||
resolveShareBaseUrl,
|
||||
} from '../../common/utils/share-url.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
@@ -1122,13 +1126,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
private buildProfileShareUrl(user: UserDocument | Record<string, any>): string {
|
||||
const appUrl = this.resolvePublicAppUrl();
|
||||
const username = typeof user.username === 'string' ? user.username.trim() : '';
|
||||
if (username) {
|
||||
return `${appUrl}/u/${encodeURIComponent(username)}`;
|
||||
}
|
||||
|
||||
return `${appUrl}/profile/${this.extractUserId(user)}`;
|
||||
return buildCanonicalProfileShareUrl(this.resolveShareBaseUrl(), user);
|
||||
}
|
||||
|
||||
private async assertPostShareableForViewer(viewerUserId: string, postId: string): Promise<void> {
|
||||
@@ -1212,10 +1210,8 @@ export class UsersService {
|
||||
};
|
||||
}
|
||||
|
||||
private resolvePublicAppUrl(): string {
|
||||
const configured =
|
||||
this.configService.get<string>('PUBLIC_APP_URL') ?? process.env.PUBLIC_APP_URL ?? '';
|
||||
return (configured.trim() || 'https://oudelaa.com').replace(/\/+$/, '');
|
||||
private resolveShareBaseUrl(): string {
|
||||
return resolveShareBaseUrl(this.configService);
|
||||
}
|
||||
|
||||
private extractUserId(user: UserDocument | Record<string, any>): string {
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم