Unify public share URLs
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-07-03 22:54:14 +03:00
الأصل 7a754f4bf9
التزام 4b9589a3fc
9 ملفات معدلة مع 118 إضافات و29 حذوفات

عرض الملف

@@ -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 {