diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index da3e99312..ef3839340 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -20,12 +20,12 @@ import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, - ghostfolioPrefix, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { canDeleteAssetProfile, - getAssetProfileIdentifier + getAssetProfileIdentifier, + isValidCustomAssetProfileSymbol } from '@ghostfolio/common/helper'; import { ActivitiesResponse, @@ -48,7 +48,6 @@ import { Type as ActivityType } from '@prisma/client'; import { Big } from 'big.js'; -import { isUUID } from 'class-validator'; import { endOfToday, isAfter } from 'date-fns'; import { groupBy, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; @@ -204,10 +203,9 @@ export class ActivitiesService { let symbol: string; if ( - data.SymbolProfile.connectOrCreate.create.symbol.startsWith( - `${ghostfolioPrefix}_` - ) || - isUUID(data.SymbolProfile.connectOrCreate.create.symbol) + isValidCustomAssetProfileSymbol( + data.SymbolProfile.connectOrCreate.create.symbol + ) ) { // Connect custom asset profile (clone) symbol = data.SymbolProfile.connectOrCreate.create.symbol; diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 4c608e0fd..b7499b62f 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -7,6 +7,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { + ghostfolioPrefix, PROPERTY_CURRENCIES, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_USER_SIGNUP_ENABLED @@ -14,7 +15,8 @@ import { import { applyAssetProfileOverrides, getAssetProfileIdentifier, - getCurrencyFromSymbol + getCurrencyFromSymbol, + hasGhostfolioPrefix } from '@ghostfolio/common/helper'; import { AdminData, @@ -63,6 +65,12 @@ export class AdminService { > { try { if (dataSource === 'MANUAL') { + if (!hasGhostfolioPrefix(symbol)) { + throw new BadRequestException( + `symbol "${symbol}" must start with the prefix "${ghostfolioPrefix}_" for the data source "${dataSource}"` + ); + } + return this.symbolProfileService.add({ currency, dataSource, diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 4c273f2da..6ef7bdcbd 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -9,6 +9,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { DEFAULT_CURRENCY, DERIVED_CURRENCIES, + ghostfolioPrefix, PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config'; @@ -21,6 +22,7 @@ import { getStartOfUtcDate, isCurrency, isDerivedCurrency, + isValidCustomAssetProfileSymbol, isValidSearchQuery } from '@ghostfolio/common/helper'; import { @@ -289,6 +291,15 @@ export class DataProviderService implements OnModuleInit { continue; } + if ( + dataSource === DataSource.MANUAL && + !isValidCustomAssetProfileSymbol(symbol) + ) { + throw new Error( + `${activityPath}.symbol "${symbol}" must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source "${DataSource.MANUAL}"` + ); + } + let assetProfile: Partial = { currency }; try { diff --git a/libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts b/libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts index 60afcb558..e9b26a5b6 100644 --- a/libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts +++ b/libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts @@ -1,7 +1,8 @@ import { MarketData } from '@ghostfolio/common/interfaces'; +import { IsCustomAssetProfileSymbolConstraint } from '@ghostfolio/common/validator-constraints/is-custom-asset-profile-symbol'; import { DataSource } from '@prisma/client'; -import { IsArray, IsIn, IsOptional } from 'class-validator'; +import { IsArray, IsIn, IsOptional, Validate } from 'class-validator'; import { CreateAssetProfileDto } from './create-asset-profile.dto'; @@ -14,4 +15,7 @@ export class CreateAssetProfileWithMarketDataDto extends CreateAssetProfileDto { @IsArray() @IsOptional() marketData?: MarketData[]; + + @Validate(IsCustomAssetProfileSymbolConstraint) + override symbol: string; } diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index 42441a619..669c42e32 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -10,7 +10,8 @@ import { isAccountExcluded, isCurrency, isCurrencySymbol, - isSplitRatio + isSplitRatio, + isValidCustomAssetProfileSymbol } from '@ghostfolio/common/helper'; describe('Helper', () => { @@ -326,4 +327,32 @@ describe('Helper', () => { ); }); }); + + describe('Is valid custom asset profile symbol', () => { + it('Empty symbol', () => { + expect(isValidCustomAssetProfileSymbol('')).toEqual(false); + }); + + it('Free-text symbol', () => { + expect(isValidCustomAssetProfileSymbol('Penthouse Apartment')).toEqual( + false + ); + }); + + it('Stock symbol', () => { + expect(isValidCustomAssetProfileSymbol('AAPL')).toEqual(false); + }); + + it('Symbol with Ghostfolio prefix', () => { + expect(isValidCustomAssetProfileSymbol('GF_PENTHOUSE_APARTMENT')).toEqual( + true + ); + }); + + it('UUID', () => { + expect( + isValidCustomAssetProfileSymbol('7e91b7d4-1430-4212-8380-289a06c9bbc1') + ).toEqual(true); + }); + }); }); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 22f969d02..6c0d6ea45 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -8,7 +8,7 @@ import { SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; -import { isISO4217CurrencyCode } from 'class-validator'; +import { isISO4217CurrencyCode, isUUID } from 'class-validator'; import { getDate, getMonth, @@ -41,6 +41,7 @@ import { DERIVED_CURRENCIES, ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolStocks, + ghostfolioPrefix, SEARCH_QUERY_MINIMUM_LENGTH, TAG_ID_EXCLUDE_FROM_ANALYSIS } from './config'; @@ -466,6 +467,14 @@ export function getYesterday() { return subDays(new Date(Date.UTC(year, month, day)), 1); } +export function hasGhostfolioPrefix(aSymbol: string) { + if (!aSymbol) { + return false; + } + + return aSymbol.startsWith(`${ghostfolioPrefix}_`); +} + export function interpolate(template: string, context: any) { return template?.replace(/[$]{([^}]+)}/g, (_, objectPath) => { const properties = objectPath.split('.'); @@ -548,6 +557,10 @@ export function isSplitRatio({ ); } +export function isValidCustomAssetProfileSymbol(aSymbol: string) { + return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol); +} + export function isValidSearchQuery(aQuery: string) { return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH; } diff --git a/libs/common/src/lib/validator-constraints/is-custom-asset-profile-symbol.ts b/libs/common/src/lib/validator-constraints/is-custom-asset-profile-symbol.ts new file mode 100644 index 000000000..ea6e8e97e --- /dev/null +++ b/libs/common/src/lib/validator-constraints/is-custom-asset-profile-symbol.ts @@ -0,0 +1,18 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface +} from 'class-validator'; + +import { ghostfolioPrefix } from '../config'; +import { isValidCustomAssetProfileSymbol } from '../helper'; + +@ValidatorConstraint({ name: 'isCustomAssetProfileSymbol' }) +export class IsCustomAssetProfileSymbolConstraint implements ValidatorConstraintInterface { + public defaultMessage() { + return `$property must be a UUID or start with the prefix "${ghostfolioPrefix}_"`; + } + + public validate(aSymbol: string) { + return isValidCustomAssetProfileSymbol(aSymbol); + } +}