diff --git a/src/backend/src/modules/apps/AppIconService.js b/src/backend/src/modules/apps/AppIconService.js index c73c71d02..3983e3297 100644 --- a/src/backend/src/modules/apps/AppIconService.js +++ b/src/backend/src/modules/apps/AppIconService.js @@ -35,6 +35,7 @@ const require = createRequire(import.meta.url); const ICON_SIZES = [16, 32, 64, 128, 256, 512]; const DEFAULT_ICON_SIZE = 128; +const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; const LEGACY_ICON_FILENAME = ({ appUid, size }) => `${appUid}-${size}.png`; const ORIGINAL_ICON_FILENAME = ({ appUid }) => `${appUid}.png`; const REDIRECT_MAX_AGE_SIZE = 30 * 24 * 60 * 60; // 1 month @@ -165,6 +166,31 @@ export class AppIconService extends BaseService { ); } + isRawBase64ImageString (value) { + if ( typeof value !== 'string' ) return false; + const trimmed = value.trim(); + if ( !trimmed || trimmed.length < 16 ) return false; + if ( ! RAW_BASE64_REGEX.test(trimmed) ) return false; + if ( trimmed.length % 4 !== 0 ) return false; + + try { + const decoded = Buffer.from(trimmed, 'base64'); + if ( decoded.length === 0 ) return false; + const normalizedInput = trimmed.replace(/=+$/, ''); + const reencoded = decoded.toString('base64').replace(/=+$/, ''); + return normalizedInput === reencoded; + } catch { + return false; + } + } + + normalizeRawBase64ImageString (value) { + if ( typeof value !== 'string' ) return value; + const trimmed = value.trim(); + if ( ! this.isRawBase64ImageString(trimmed) ) return value; + return `data:image/png;base64,${trimmed}`; + } + parseAppIconEndpointUrl (iconUrl) { if ( typeof iconUrl !== 'string' || iconUrl.startsWith('data:') ) { return null; @@ -626,6 +652,8 @@ export class AppIconService extends BaseService { return null; } + iconUrl = this.normalizeRawBase64ImageString(iconUrl); + if ( iconUrl.startsWith('data:') ) { const [metadata, base64] = iconUrl.split(','); return { diff --git a/src/backend/src/modules/apps/AppIconService.test.js b/src/backend/src/modules/apps/AppIconService.test.js index e9f587c35..0e1e67b4c 100644 --- a/src/backend/src/modules/apps/AppIconService.test.js +++ b/src/backend/src/modules/apps/AppIconService.test.js @@ -44,6 +44,15 @@ describe('AppIconService', () => { size: 128, }); }); + + it('normalizes raw base64 icon strings to png data URLs', () => { + const service = Object.create(AppIconService.prototype); + const rawBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'; + + const result = service.normalizeRawBase64ImageString(rawBase64); + + expect(result).toBe(`data:image/png;base64,${rawBase64}`); + }); }); describe('createAppIcons', () => { diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index b2c45d7e9..24609b00f 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -24,8 +24,34 @@ const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/([^/?#]+)(?:\/(\d+))?\/?$/; const LEGACY_APP_ICON_FILE_PATH_REGEX = /^\/(app-[^/?#]+?)(?:-(\d+))?\.png$/; const APP_ICONS_SUBDOMAIN = 'puter-app-icons'; const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; +const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; const isAbsoluteUrl = value => ABSOLUTE_URL_REGEX.test(value) || value.startsWith('//'); +const isRawBase64ImageString = value => { + if ( typeof value !== 'string' ) return false; + const trimmed = value.trim(); + if ( !trimmed || trimmed.length < 16 ) return false; + if ( ! RAW_BASE64_REGEX.test(trimmed) ) return false; + if ( trimmed.length % 4 !== 0 ) return false; + + try { + const decoded = Buffer.from(trimmed, 'base64'); + if ( decoded.length === 0 ) return false; + const normalizedInput = trimmed.replace(/=+$/, ''); + const reencoded = decoded.toString('base64').replace(/=+$/, ''); + return normalizedInput === reencoded; + } catch { + return false; + } +}; + +const normalizeRawBase64ImageString = value => { + if ( typeof value !== 'string' ) return value; + const trimmed = value.trim(); + if ( ! isRawBase64ImageString(trimmed) ) return value; + return `data:image/png;base64,${trimmed}`; +}; + const getCanonicalAppIconBaseUrl = () => { const candidate = [config.api_base_url, config.origin] .find(value => typeof value === 'string' && value.trim()); @@ -114,8 +140,7 @@ const parseLegacyHostedAppIconToEndpointPath = value => { return null; } - const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); - if ( isAbsoluteUrl ) { + if ( isAbsoluteUrl(trimmed) ) { const allowedHostnames = getAllowedLegacyAppIconHostnames(); const hostname = parsed.hostname.toLowerCase(); if ( ! allowedHostnames.has(hostname) ) { @@ -575,6 +600,7 @@ export default class AppService extends BaseService { if ( object.icon !== undefined && object.icon !== null ) { if ( typeof object.icon === 'string' ) { + object.icon = normalizeRawBase64ImageString(object.icon); object.icon = migrateRelativeAppIconEndpointUrl(object.icon); } if ( typeof object.icon === 'string' && object.icon.startsWith('data:') ) { @@ -827,6 +853,7 @@ export default class AppService extends BaseService { if ( object.icon !== undefined && object.icon !== null ) { if ( typeof object.icon === 'string' ) { + object.icon = normalizeRawBase64ImageString(object.icon); object.icon = migrateRelativeAppIconEndpointUrl(object.icon); } if ( typeof object.icon === 'string' && object.icon.startsWith('data:') ) { diff --git a/src/backend/src/modules/data-access/AppService.test.js b/src/backend/src/modules/data-access/AppService.test.js index 676321325..7fb3ebedd 100644 --- a/src/backend/src/modules/data-access/AppService.test.js +++ b/src/backend/src/modules/data-access/AppService.test.js @@ -826,6 +826,28 @@ describe('AppService', () => { })); }); + it('should accept raw base64 icon and normalize to data URL on create', async () => { + setupContextForWrite(createMockUserActor(1)); + mockDb.read.mockResolvedValue([createMockAppRow()]); + + const rawBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'; + const crudQ = AppService.IMPLEMENTS['crud-q']; + await crudQ.create.call(appService, { + object: { + name: 'test-app', + title: 'Test', + index_url: 'https://example.com', + icon: rawBase64, + }, + }); + + expect(mockEventService.emit).toHaveBeenCalledWith( + 'app.new-icon', + expect.objectContaining({ + data_url: `data:image/png;base64,${rawBase64}`, + })); + }); + it('should migrate relative app-icon endpoint path to absolute URL on create', async () => { setupContextForWrite(createMockUserActor(1)); mockDb.read.mockResolvedValue([createMockAppRow()]); @@ -1181,6 +1203,26 @@ describe('AppService', () => { })); }); + it('should accept raw base64 icon and normalize to data URL on update', async () => { + setupContextForWrite(createMockUserActor(1)); + + const rawBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'; + const crudQ = AppService.IMPLEMENTS['crud-q']; + await crudQ.update.call(appService, { + object: { + uid: 'app-uid-123', + icon: rawBase64, + }, + }); + + expect(mockEventService.emit).toHaveBeenCalledWith( + 'app.new-icon', + expect.objectContaining({ + app_uid: 'app-uid-123', + data_url: `data:image/png;base64,${rawBase64}`, + })); + }); + it('should migrate relative app-icon endpoint path to absolute URL on update', async () => { setupContextForWrite(createMockUserActor(1)); validate_url.mockImplementation((_value, { key }) => { diff --git a/src/backend/src/om/proptypes/__all__.js b/src/backend/src/om/proptypes/__all__.js index 72c4924d6..995212f3a 100644 --- a/src/backend/src/om/proptypes/__all__.js +++ b/src/backend/src/om/proptypes/__all__.js @@ -30,8 +30,34 @@ const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/([^/?#]+)(?:\/(\d+))?\/?$/; const LEGACY_APP_ICON_FILE_PATH_REGEX = /^\/(app-[^/?#]+?)(?:-(\d+))?\.png$/; const APP_ICONS_SUBDOMAIN = 'puter-app-icons'; const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; +const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; const isAbsoluteUrl = value => ABSOLUTE_URL_REGEX.test(value) || value.startsWith('//'); +const isRawBase64ImageString = value => { + if ( typeof value !== 'string' ) return false; + const trimmed = value.trim(); + if ( !trimmed || trimmed.length < 16 ) return false; + if ( ! RAW_BASE64_REGEX.test(trimmed) ) return false; + if ( trimmed.length % 4 !== 0 ) return false; + + try { + const decoded = Buffer.from(trimmed, 'base64'); + if ( decoded.length === 0 ) return false; + const normalizedInput = trimmed.replace(/=+$/, ''); + const reencoded = decoded.toString('base64').replace(/=+$/, ''); + return normalizedInput === reencoded; + } catch { + return false; + } +}; + +const normalizeRawBase64ImageString = value => { + if ( typeof value !== 'string' ) return value; + const trimmed = value.trim(); + if ( ! isRawBase64ImageString(trimmed) ) return value; + return `data:image/png;base64,${trimmed}`; +}; + const getCanonicalAppIconBaseUrl = () => { const candidate = [config.api_base_url, config.origin] .find(value => typeof value === 'string' && value.trim()); @@ -117,8 +143,7 @@ const parseLegacyHostedAppIconToEndpointPath = value => { return null; } - const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); - if ( isAbsoluteUrl ) { + if ( isAbsoluteUrl(trimmed) ) { const allowedHostnames = getAllowedLegacyAppIconHostnames(); const hostname = parsed.hostname.toLowerCase(); if ( ! allowedHostnames.has(hostname) ) { @@ -283,7 +308,7 @@ module.exports = { if ( typeof value !== 'string' ) { throw new OMTypeError({ expected: 'string', got: typeof value }); } - return migrateRelativeAppIconEndpointUrl(value); + return migrateRelativeAppIconEndpointUrl(normalizeRawBase64ImageString(value)); }, validate (value) { if ( typeof value !== 'string' ) { @@ -304,6 +329,10 @@ module.exports = { return true; } + if ( isRawBase64ImageString(trimmed) ) { + return true; + } + if ( isAllowedAppIconEndpointUrl(trimmed) ) { return true; } diff --git a/src/backend/src/om/proptypes/__all__.test.js b/src/backend/src/om/proptypes/__all__.test.js index f900940bc..439a2ef9a 100644 --- a/src/backend/src/om/proptypes/__all__.test.js +++ b/src/backend/src/om/proptypes/__all__.test.js @@ -17,6 +17,10 @@ describe('OM image-base64 proptype', () => { expect(validateIcon('data:image/png;base64,abc123')).toBe(true); }); + it('accepts raw base64 icon strings', () => { + expect(validateIcon('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ')).toBe(true); + }); + it('accepts absolute app-icon endpoint URLs', () => { expect(validateIcon('https://api.puter.localhost/app-icon/app-uid-123/64')).toBe(true); }); @@ -37,6 +41,11 @@ describe('OM image-base64 proptype', () => { expect(adaptIcon('/app-icon/app-uid-123/64')).toBe('https://api.puter.localhost/app-icon/app-uid-123'); }); + it('normalizes raw base64 icon strings to png data URLs', () => { + expect(adaptIcon('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ')) + .toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'); + }); + it('migrates legacy app-icons host URLs to absolute app-icon endpoint URLs', () => { expect(adaptIcon('https://puter-app-icons.puter.site/app-uid-123-64.png')) .toBe('https://api.puter.localhost/app-icon/app-uid-123');