This commit is contained in:
@@ -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',
|
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',
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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(): {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user