Unify public share URLs
Deploy To Ghaymah / deploy (push) Has been cancelled

This commit is contained in:
boutmoun123
2026-07-03 22:54:14 +03:00
parent 7a754f4bf9
commit 4b9589a3fc
9 changed files with 118 additions and 29 deletions
+37
View 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)}`;
+6
View File
@@ -4,6 +4,12 @@ export default () => ({
host: process.env.HOST ?? '0.0.0.0', host: process.env.HOST ?? '0.0.0.0',
publicBaseUrl: publicBaseUrl:
process.env.PUBLIC_BASE_URL ?? `http://localhost:${Number(process.env.PORT ?? 4000)}`, 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: responseEnvelopeEnabled:
(process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true', (process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true',
globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1', globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1',
+2
View File
@@ -5,6 +5,8 @@ export const validationSchema = Joi.object({
PORT: Joi.number().default(4000), PORT: Joi.number().default(4000),
HOST: Joi.string().default('0.0.0.0'), HOST: Joi.string().default('0.0.0.0'),
PUBLIC_BASE_URL: Joi.string().uri().optional(), 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), RESPONSE_ENVELOPE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
GLOBAL_PREFIX: Joi.string().default('api/v1'), GLOBAL_PREFIX: Joi.string().default('api/v1'),
CORS_ORIGINS: Joi.string().allow('').optional(), CORS_ORIGINS: Joi.string().allow('').optional(),
+3 -2
View File
@@ -376,7 +376,7 @@ describe('MediaService', () => {
it('returns a simple share payload', async () => { it('returns a simple share payload', async () => {
const service = createService('', { const service = createService('', {
config: { publicBaseUrl: 'https://api.example.com' }, config: { shareBaseUrl: 'https://oudelaa.com/' },
mediaRepository: { mediaRepository: {
findAiMusicArchiveItemByUser: jest findAiMusicArchiveItemByUser: jest
.fn() .fn()
@@ -390,10 +390,11 @@ describe('MediaService', () => {
); );
expect(result).toMatchObject({ expect(result).toMatchObject({
shareUrl: 'https://api.example.com/ai-music/archive/archive-1', shareUrl: 'https://oudelaa.com/ai/music/archive-1',
title: 'Share me', title: 'Share me',
audioUrl: '/uploads/ai-music/generated.wav', 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 () => { it('shares an archive item to the feed as an audio post', async () => {
+4 -7
View File
@@ -9,6 +9,7 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import { GoogleAuth } from 'google-auth-library'; import { GoogleAuth } from 'google-auth-library';
import { buildAiMusicShareUrl, resolveShareBaseUrl } from '../../common/utils/share-url.util';
import { generateWaveformPeakSetFromBuffer } from '../../common/utils/waveform.util'; import { generateWaveformPeakSetFromBuffer } from '../../common/utils/waveform.util';
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
import { MediaProbeService } from '../../infrastructure/storage/media-probe.service'; import { MediaProbeService } from '../../infrastructure/storage/media-probe.service';
@@ -331,7 +332,7 @@ export class MediaService {
const formatted = this.formatAiMusicArchiveItem(item); const formatted = this.formatAiMusicArchiveItem(item);
return { return {
shareUrl: `${this.resolvePublicAppUrl()}/ai-music/archive/${encodeURIComponent(item.id)}`, shareUrl: buildAiMusicShareUrl(this.resolveShareBaseUrl(), item.id),
title: formatted.title, title: formatted.title,
audioUrl: formatted.audioUrl, audioUrl: formatted.audioUrl,
}; };
@@ -444,12 +445,8 @@ export class MediaService {
return compactPrompt.length > 80 ? `${compactPrompt.slice(0, 77)}...` : compactPrompt; return compactPrompt.length > 80 ? `${compactPrompt.slice(0, 77)}...` : compactPrompt;
} }
private resolvePublicAppUrl(): string { private resolveShareBaseUrl(): string {
const configured = return resolveShareBaseUrl(this.configService);
process.env.PUBLIC_APP_URL?.trim() ||
this.configService.get<string>('publicBaseUrl', { infer: true }) ||
'';
return (configured || 'https://oudelaa.com').replace(/\/+$/, '');
} }
private resolveGoogleApplicationCredentials(): { private resolveGoogleApplicationCredentials(): {
+37 -1
View File
@@ -4,7 +4,18 @@ import { PostVisibility } from '../../common/enums/post-visibility.enum';
import { PostSchema } from './schemas/post.schema'; import { PostSchema } from './schemas/post.schema';
import { PostsService } from './posts.service'; 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 = { const postsRepository = {
findMany: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0), count: jest.fn().mockResolvedValue(0),
@@ -37,6 +48,7 @@ const createService = () => {
const service = new PostsService( const service = new PostsService(
connection as any, connection as any,
configService as any,
postsRepository as any, postsRepository as any,
usersRepository as any, usersRepository as any,
{} as any, {} as any,
@@ -292,6 +304,30 @@ describe('PostsService post sharing', () => {
); );
expect(result.shareCount).toBe(5); 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', () => { describe('Post schema response aliases', () => {
+6 -3
View File
@@ -5,6 +5,7 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Connection, Types } from 'mongoose'; import { Connection, Types } from 'mongoose';
import { InjectConnection } from '@nestjs/mongoose'; import { InjectConnection } from '@nestjs/mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum'; 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 { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media'; import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
import { resolveManagedFileUrl } from '../../common/utils/public-url.util'; 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 { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { import {
buildWaveformPeakSet, buildWaveformPeakSet,
@@ -101,6 +103,7 @@ export class PostsService {
constructor( constructor(
@InjectConnection() private readonly connection: Connection, @InjectConnection() private readonly connection: Connection,
private readonly configService: ConfigService,
private readonly postsRepository: PostsRepository, private readonly postsRepository: PostsRepository,
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
private readonly storageService: ManagedStorageService, private readonly storageService: ManagedStorageService,
@@ -2064,11 +2067,11 @@ export class PostsService {
} }
private buildPostShareUrl(postId: string): string { private buildPostShareUrl(postId: string): string {
return `${this.resolvePublicAppUrl()}/posts/${encodeURIComponent(postId)}`; return buildPostShareUrl(this.resolveShareBaseUrl(), postId);
} }
private resolvePublicAppUrl(): string { private resolveShareBaseUrl(): string {
return (process.env.PUBLIC_APP_URL?.trim() || 'https://oudelaa.com').replace(/\/+$/, ''); return resolveShareBaseUrl(this.configService);
} }
private resolveAvatarUrl(value: unknown): string { private resolveAvatarUrl(value: unknown): string {
+16 -5
View File
@@ -17,7 +17,8 @@ const createService = (options: {
newFollowersThisWeek?: number; newFollowersThisWeek?: number;
newFollowersThisMonth?: number; newFollowersThisMonth?: number;
recentActivity?: Record<string, any>[]; recentActivity?: Record<string, any>[];
publicAppUrl?: string; publicWebUrl?: string;
shareBaseUrl?: string;
} = {}) => { } = {}) => {
const userId = new Types.ObjectId().toString(); const userId = new Types.ObjectId().toString();
const user = options.user ?? { const user = options.user ?? {
@@ -115,7 +116,17 @@ const createService = (options: {
connection as any, connection as any,
usersRepository as any, usersRepository as any,
{ logSuperAdminAction: jest.fn() } 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, { saveFile: jest.fn(), deleteFile: jest.fn() } as any,
); );
@@ -281,7 +292,7 @@ describe('UsersService follow counts', () => {
describe('UsersService profile sharing', () => { describe('UsersService profile sharing', () => {
it('returns profileShareUrl with username in profile overview', async () => { 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); const result = await service.getProfileOverview(userId, userId);
@@ -300,7 +311,7 @@ describe('UsersService profile sharing', () => {
postsCount: 0, postsCount: 0,
isDisabled: false, isDisabled: false,
}, },
publicAppUrl: 'https://oudelaa.com', shareBaseUrl: 'https://oudelaa.com',
}); });
const result = await service.getProfileOverview(userId, userId); const result = await service.getProfileOverview(userId, userId);
@@ -310,7 +321,7 @@ describe('UsersService profile sharing', () => {
it('returns profile overview by username', async () => { it('returns profile overview by username', async () => {
const { service, userId, usersRepository } = createService({ const { service, userId, usersRepository } = createService({
publicAppUrl: 'https://oudelaa.com', publicWebUrl: 'https://oudelaa.com',
}); });
const result = await service.getProfileOverviewByUsername('Artist_One', userId); const result = await service.getProfileOverviewByUsername('Artist_One', userId);
+7 -11
View File
@@ -15,6 +15,10 @@ import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media
import { hashValue } from '../../common/utils/hash.util'; import { hashValue } from '../../common/utils/hash.util';
import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveManagedFileUrl } from '../../common/utils/public-url.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 { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
@@ -1122,13 +1126,7 @@ export class UsersService {
} }
private buildProfileShareUrl(user: UserDocument | Record<string, any>): string { private buildProfileShareUrl(user: UserDocument | Record<string, any>): string {
const appUrl = this.resolvePublicAppUrl(); return buildCanonicalProfileShareUrl(this.resolveShareBaseUrl(), user);
const username = typeof user.username === 'string' ? user.username.trim() : '';
if (username) {
return `${appUrl}/u/${encodeURIComponent(username)}`;
}
return `${appUrl}/profile/${this.extractUserId(user)}`;
} }
private async assertPostShareableForViewer(viewerUserId: string, postId: string): Promise<void> { private async assertPostShareableForViewer(viewerUserId: string, postId: string): Promise<void> {
@@ -1212,10 +1210,8 @@ export class UsersService {
}; };
} }
private resolvePublicAppUrl(): string { private resolveShareBaseUrl(): string {
const configured = return resolveShareBaseUrl(this.configService);
this.configService.get<string>('PUBLIC_APP_URL') ?? process.env.PUBLIC_APP_URL ?? '';
return (configured.trim() || 'https://oudelaa.com').replace(/\/+$/, '');
} }
private extractUserId(user: UserDocument | Record<string, any>): string { private extractUserId(user: UserDocument | Record<string, any>): string {