fix: allow b64 srings without mime type for app icons (#2502)

This commit is contained in:
Daniel Salazar
2026-02-16 19:28:44 -08:00
committed by GitHub
parent 44bb5953b4
commit 00befdc192
6 changed files with 149 additions and 5 deletions
@@ -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 {
@@ -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', () => {
@@ -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:') ) {
@@ -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 }) => {
+32 -3
View File
@@ -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;
}
@@ -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');