Browse Source

Improve symbol validation for assets with manual data source

pull/7467/head
Thomas Kaul 1 month ago
parent
commit
b83d4f2e04
  1. 12
      apps/api/src/app/activities/activities.service.ts
  2. 10
      apps/api/src/app/admin/admin.service.ts
  3. 11
      apps/api/src/services/data-provider/data-provider.service.ts
  4. 6
      libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts
  5. 31
      libs/common/src/lib/helper.spec.ts
  6. 15
      libs/common/src/lib/helper.ts
  7. 18
      libs/common/src/lib/validator-constraints/is-custom-asset-profile-symbol.ts

12
apps/api/src/app/activities/activities.service.ts

@ -20,12 +20,12 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
ghostfolioPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
getAssetProfileIdentifier getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
@ -48,7 +48,6 @@ import {
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -204,10 +203,9 @@ export class ActivitiesService {
let symbol: string; let symbol: string;
if ( if (
data.SymbolProfile.connectOrCreate.create.symbol.startsWith( isValidCustomAssetProfileSymbol(
`${ghostfolioPrefix}_` data.SymbolProfile.connectOrCreate.create.symbol
) || )
isUUID(data.SymbolProfile.connectOrCreate.create.symbol)
) { ) {
// Connect custom asset profile (clone) // Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol; symbol = data.SymbolProfile.connectOrCreate.create.symbol;

10
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
ghostfolioPrefix,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
@ -14,7 +15,8 @@ import {
import { import {
applyAssetProfileOverrides, applyAssetProfileOverrides,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol getCurrencyFromSymbol,
hasGhostfolioPrefix
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
@ -63,6 +65,12 @@ export class AdminService {
> { > {
try { try {
if (dataSource === 'MANUAL') { 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({ return this.symbolProfileService.add({
currency, currency,
dataSource, dataSource,

11
apps/api/src/services/data-provider/data-provider.service.ts

@ -9,6 +9,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
ghostfolioPrefix,
PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_DATA_SOURCE_MAPPING PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -21,6 +22,7 @@ import {
getStartOfUtcDate, getStartOfUtcDate,
isCurrency, isCurrency,
isDerivedCurrency, isDerivedCurrency,
isValidCustomAssetProfileSymbol,
isValidSearchQuery isValidSearchQuery
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -289,6 +291,15 @@ export class DataProviderService implements OnModuleInit {
continue; 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<SymbolProfile> = { currency }; let assetProfile: Partial<SymbolProfile> = { currency };
try { try {

6
libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts

@ -1,7 +1,8 @@
import { MarketData } from '@ghostfolio/common/interfaces'; import { MarketData } from '@ghostfolio/common/interfaces';
import { IsCustomAssetProfileSymbolConstraint } from '@ghostfolio/common/validator-constraints/is-custom-asset-profile-symbol';
import { DataSource } from '@prisma/client'; 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'; import { CreateAssetProfileDto } from './create-asset-profile.dto';
@ -14,4 +15,7 @@ export class CreateAssetProfileWithMarketDataDto extends CreateAssetProfileDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()
marketData?: MarketData[]; marketData?: MarketData[];
@Validate(IsCustomAssetProfileSymbolConstraint)
override symbol: string;
} }

31
libs/common/src/lib/helper.spec.ts

@ -10,7 +10,8 @@ import {
isAccountExcluded, isAccountExcluded,
isCurrency, isCurrency,
isCurrencySymbol, isCurrencySymbol,
isSplitRatio isSplitRatio,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
describe('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);
});
});
}); });

15
libs/common/src/lib/helper.ts

@ -8,7 +8,7 @@ import {
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isISO4217CurrencyCode } from 'class-validator'; import { isISO4217CurrencyCode, isUUID } from 'class-validator';
import { import {
getDate, getDate,
getMonth, getMonth,
@ -41,6 +41,7 @@ import {
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioPrefix,
SEARCH_QUERY_MINIMUM_LENGTH, SEARCH_QUERY_MINIMUM_LENGTH,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from './config'; } from './config';
@ -466,6 +467,14 @@ export function getYesterday() {
return subDays(new Date(Date.UTC(year, month, day)), 1); 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) { export function interpolate(template: string, context: any) {
return template?.replace(/[$]{([^}]+)}/g, (_, objectPath) => { return template?.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
const properties = objectPath.split('.'); 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) { export function isValidSearchQuery(aQuery: string) {
return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH; return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH;
} }

18
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);
}
}
Loading…
Cancel
Save