mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-04 00:20:45 +00:00
fix: allow b64 srings without mime type for app icons (#2502)
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user