diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index ae21a9d25..c08671a84 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -20,6 +20,12 @@ import { validate_url, } from './lib/validation.js'; +const APP_ICON_ENDPOINT_PATH_REGEX = /^\/?app-icon\/[^/?#]+\/[^/?#]+\/?$/; + +const isAppIconEndpointPath = (value) => { + return typeof value === 'string' && APP_ICON_ENDPOINT_PATH_REGEX.test(value); +}; + /** * AppService contains an instance using the repository pattern */ @@ -385,6 +391,8 @@ export default class AppService extends BaseService { if ( object.icon !== undefined && object.icon !== null ) { if ( typeof object.icon === 'string' && object.icon.startsWith('data:') ) { validate_image_base64(object.icon, { key: 'icon' }); + } else if ( isAppIconEndpointPath(object.icon) ) { + // Allow existing relative app icon endpoint references. } else { validate_url(object.icon, { key: 'icon', maxlen: 3000 }); } @@ -631,6 +639,8 @@ export default class AppService extends BaseService { if ( object.icon !== undefined && object.icon !== null ) { if ( typeof object.icon === 'string' && object.icon.startsWith('data:') ) { validate_image_base64(object.icon, { key: 'icon' }); + } else if ( isAppIconEndpointPath(object.icon) ) { + // Allow existing relative app icon endpoint references. } else { validate_url(object.icon, { key: 'icon', maxlen: 3000 }); } diff --git a/src/backend/src/modules/data-access/AppService.test.js b/src/backend/src/modules/data-access/AppService.test.js index 6915d1089..1157c95fd 100644 --- a/src/backend/src/modules/data-access/AppService.test.js +++ b/src/backend/src/modules/data-access/AppService.test.js @@ -806,6 +806,33 @@ describe('AppService', () => { })); }); + it('should allow relative app-icon endpoint path for icon', async () => { + setupContextForWrite(createMockUserActor(1)); + mockDb.read.mockResolvedValue([createMockAppRow()]); + validate_url.mockImplementation((_value, { key }) => { + if ( key === 'icon' ) { + throw new Error('icon should not be validated as a URL'); + } + }); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await crudQ.create.call(appService, { + object: { + name: 'test-app', + title: 'Test', + index_url: 'https://example.com', + icon: '/app-icon/app-uid-123/64', + }, + }); + + expect(mockEventService.emit).toHaveBeenCalledWith( + 'app.new-icon', + expect.objectContaining({ + data_url: '/app-icon/app-uid-123/64', + })); + expect(validate_url).toHaveBeenCalledWith('https://example.com', expect.objectContaining({ key: 'index_url' })); + }); + it('should handle filetype_associations', async () => { setupContextForWrite(createMockUserActor(1)); mockDb.read.mockResolvedValue([createMockAppRow()]); @@ -1060,6 +1087,30 @@ describe('AppService', () => { })); }); + it('should allow relative app-icon endpoint path when updating icon', async () => { + setupContextForWrite(createMockUserActor(1)); + validate_url.mockImplementation((_value, { key }) => { + if ( key === 'icon' ) { + throw new Error('icon should not be validated as a URL'); + } + }); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await crudQ.update.call(appService, { + object: { + uid: 'app-uid-123', + icon: '/app-icon/app-uid-123/64', + }, + }); + + expect(mockEventService.emit).toHaveBeenCalledWith( + 'app.new-icon', + expect.objectContaining({ + app_uid: 'app-uid-123', + data_url: '/app-icon/app-uid-123/64', + })); + }); + it('should emit app.rename event when name changes', async () => { setupContextForWrite(createMockUserActor(1));