Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 53 additions & 33 deletions src/backend/src/modules/apps/AppIconService.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import DEFAULT_APP_ICON from './default-app-icon.js';
const require = createRequire(import.meta.url);

const ICON_SIZES = [16, 32, 64, 128, 256, 512];
const DEFAULT_ICON_SIZE = 128;
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
Expand Down Expand Up @@ -64,42 +65,54 @@ export class AppIconService extends BaseService {

/**
* AppIconService listens to this event to register the
* endpoint /app-icon/:app_uid/:size which serves the
* app icon at the requested size.
* endpoints /app-icon/:app_uid and /app-icon/:app_uid/:size
* which serve the app icon at the requested size.
*/
async ['__on_install.routes'] (_, { app }) {
Endpoint({
route: '/app-icon/:app_uid/:size',
methods: ['GET'],
handler: async (req, res) => {
// Validate parameters
let { app_uid: appUid, size } = req.params;
if ( ! ICON_SIZES.includes(Number(size)) ) {
res.status(400).send('Invalid size');
return;
}
if ( ! appUid.startsWith('app-') ) {
appUid = `app-${appUid}`;
}
const handler = async (req, res) => {
// Validate parameters
let { app_uid: appUid, size } = req.params;
const resolvedSize = Number(size ?? DEFAULT_ICON_SIZE);
if ( ! ICON_SIZES.includes(resolvedSize) ) {
res.status(400).send('Invalid size');
return;
}
if ( ! appUid.startsWith('app-') ) {
appUid = `app-${appUid}`;
}

const {
stream,
mime,
redirectUrl,
redirectCacheControl,
} = await this.#getIconStream({ appUid, size, allowRedirect: true });
const {
stream,
mime,
redirectUrl,
redirectCacheControl,
} = await this.#getIconStream({
appUid,
size: resolvedSize,
allowRedirect: true,
});

if ( redirectUrl ) {
if ( redirectCacheControl ) {
res.set('Cache-Control', redirectCacheControl);
}
return res.redirect(302, redirectUrl);
if ( redirectUrl ) {
if ( redirectCacheControl ) {
res.set('Cache-Control', redirectCacheControl);
}
return res.redirect(302, redirectUrl);
}

res.set('Content-Type', mime);
res.set('Cache-Control', 'public, max-age=3600');
stream.pipe(res);
},
res.set('Content-Type', mime);
res.set('Cache-Control', 'public, max-age=3600');
stream.pipe(res);
};

Endpoint({
route: '/app-icon/:app_uid',
methods: ['GET'],
handler,
}).attach(app);
Endpoint({
route: '/app-icon/:app_uid/:size',
methods: ['GET'],
handler,
}).attach(app);
}

Expand Down Expand Up @@ -131,7 +144,12 @@ export class AppIconService extends BaseService {
return null;
}

return `${apiBaseUrl}/app-icon/${normalizedAppUid}/${size}`;
const resolvedSize = Number(size ?? DEFAULT_ICON_SIZE);
if ( ! ICON_SIZES.includes(resolvedSize) ) {
return null;
}

return `${apiBaseUrl}/app-icon/${normalizedAppUid}/${resolvedSize}`;
}

normalizeAppUid (appUid) {
Expand Down Expand Up @@ -159,12 +177,14 @@ export class AppIconService extends BaseService {
return null;
}

const match = pathname.match(/^\/app-icon\/([^/]+)\/(\d+)\/?$/);
const match = pathname.match(/^\/app-icon\/([^/]+)(?:\/(\d+))?\/?$/);
if ( ! match ) return null;

const size = Number(match[2] ?? DEFAULT_ICON_SIZE);

return {
appUid: this.normalizeAppUid(match[1]),
size: Number(match[2]),
size,
};
}

Expand Down
21 changes: 21 additions & 0 deletions src/backend/src/modules/apps/AppIconService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ describe('AppIconService', () => {

expect(shouldRedirect).toBe(false);
});

it('parses app-icon endpoint URLs without size as default size 128', () => {
const service = Object.create(AppIconService.prototype);

const parsed = service.parseAppIconEndpointUrl('https://api.puter.localhost/app-icon/app-123');

expect(parsed).toEqual({
appUid: 'app-123',
size: 128,
});
});
});

describe('createAppIcons', () => {
Expand Down Expand Up @@ -94,6 +105,16 @@ describe('AppIconService', () => {
expect(result).toBe(`${config.api_base_url}/app-icon/app-abc/64`);
});

it('defaults to size 128 when size is not provided', () => {
const service = Object.create(AppIconService.prototype);

const result = service.getAppIconPath({
appUid: 'abc',
});

expect(result).toBe(`${config.api_base_url}/app-icon/app-abc/128`);
});

it('iconifyApps rewrites icons to the legacy app-icon endpoint path', async () => {
const service = Object.create(AppIconService.prototype);
const apps = [
Expand Down
156 changes: 147 additions & 9 deletions src/backend/src/modules/data-access/AppService.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,150 @@ import {
validate_url,
} from './lib/validation.js';

const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/[^/?#]+\/\d+\/?$/;
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 isAbsoluteUrl = value => ABSOLUTE_URL_REGEX.test(value) || value.startsWith('//');

const getCanonicalAppIconBaseUrl = () => {
const candidate = [config.api_base_url, config.origin]
.find(value => typeof value === 'string' && value.trim());
if ( ! candidate ) return null;
try {
return (new URL(candidate)).origin;
} catch {
return null;
}
};

const getAllowedAppIconOrigins = () => {
const origins = new Set();
for ( const candidate of [config.api_base_url, config.origin] ) {
if ( typeof candidate !== 'string' || !candidate ) continue;
try {
origins.add((new URL(candidate)).origin);
} catch {
// Ignore invalid config values.
}
}
return origins;
};

const getAllowedLegacyAppIconHostnames = () => {
const hostnames = new Set();
const domains = [config.static_hosting_domain, config.static_hosting_domain_alt];
for ( const domain of domains ) {
if ( typeof domain !== 'string' || !domain.trim() ) continue;
hostnames.add(`${APP_ICONS_SUBDOMAIN}.${domain.trim().toLowerCase()}`);
}
return hostnames;
};

const normalizeAppUid = appUid => (
typeof appUid === 'string' && appUid.startsWith('app-')
? appUid
: `app-${appUid}`
);

const parseAppIconEndpointPath = (value) => {
if ( typeof value !== 'string' ) return null;
const trimmed = value.trim();
if ( ! trimmed ) return null;

try {
const parsed = new URL(trimmed, 'http://localhost');
const match = parsed.pathname.match(APP_ICON_ENDPOINT_PATH_REGEX);
if ( ! match ) return null;

return {
appUid: normalizeAppUid(match[1]),
};
} catch {
return null;
}
};

const isAppIconEndpointPath = value => !!parseAppIconEndpointPath(value);

const isAllowedAppIconEndpointUrl = value => {
if ( ! isAppIconEndpointPath(value) ) return false;

const trimmed = value.trim();
if ( ! isAbsoluteUrl(trimmed) ) {
return true;
}

const isAppIconEndpointPath = (value) => {
if ( typeof value !== 'string' ) return false;
try {
const pathname = new URL(value, 'http://localhost').pathname;
return APP_ICON_ENDPOINT_PATH_REGEX.test(pathname);
const parsed = new URL(trimmed, 'http://localhost');
return getAllowedAppIconOrigins().has(parsed.origin);
} catch {
return false;
}
};

const parseLegacyHostedAppIconToEndpointPath = value => {
if ( typeof value !== 'string' ) return null;
const trimmed = value.trim();
if ( !trimmed || trimmed.startsWith('data:') ) return null;

let parsed;
try {
parsed = new URL(trimmed, 'http://localhost');
} catch {
return null;
}

const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//');
if ( isAbsoluteUrl ) {
const allowedHostnames = getAllowedLegacyAppIconHostnames();
const hostname = parsed.hostname.toLowerCase();
if ( ! allowedHostnames.has(hostname) ) {
return null;
}
}

const match = parsed.pathname.match(LEGACY_APP_ICON_FILE_PATH_REGEX);
if ( ! match ) return null;

const appUid = normalizeAppUid(match[1]);
return `/app-icon/${appUid}`;
};

const migrateRelativeAppIconEndpointUrl = value => {
if ( typeof value !== 'string' ) return value;
const trimmed = value.trim();
if ( ! trimmed ) return value;

let canonicalEndpointPath = null;
const endpointPath = parseAppIconEndpointPath(trimmed);
if ( endpointPath ) {
if ( isAbsoluteUrl(trimmed) ) {
try {
const parsed = new URL(trimmed, 'http://localhost');
if ( ! getAllowedAppIconOrigins().has(parsed.origin) ) {
return value;
}
} catch {
return value;
}
}
canonicalEndpointPath = `/app-icon/${endpointPath.appUid}`;
} else {
canonicalEndpointPath = parseLegacyHostedAppIconToEndpointPath(trimmed);
}
if ( ! canonicalEndpointPath ) return value;

const baseUrl = getCanonicalAppIconBaseUrl();
if ( ! baseUrl ) return canonicalEndpointPath;

try {
return new URL(canonicalEndpointPath, `${baseUrl}/`).toString();
} catch {
return canonicalEndpointPath;
}
};

/**
* AppService contains an instance using the repository pattern
*/
Expand Down Expand Up @@ -442,12 +574,15 @@ export default class AppService extends BaseService {
}

if ( object.icon !== undefined && object.icon !== null ) {
if ( typeof object.icon === 'string' ) {
object.icon = migrateRelativeAppIconEndpointUrl(object.icon);
}
if ( typeof object.icon === 'string' && object.icon.startsWith('data:') ) {
validate_image_base64(object.icon, { key: 'icon' });
} else if ( isAppIconEndpointPath(object.icon) ) {
} else if ( isAllowedAppIconEndpointUrl(object.icon) ) {
// Allow existing relative app icon endpoint references.
} else {
validate_url(object.icon, { key: 'icon', maxlen: 3000 });
throw APIError.create('field_invalid', null, { key: 'icon' });
}
}

Expand Down Expand Up @@ -691,12 +826,15 @@ export default class AppService extends BaseService {
}

if ( object.icon !== undefined && object.icon !== null ) {
if ( typeof object.icon === 'string' ) {
object.icon = migrateRelativeAppIconEndpointUrl(object.icon);
}
if ( typeof object.icon === 'string' && object.icon.startsWith('data:') ) {
validate_image_base64(object.icon, { key: 'icon' });
} else if ( isAppIconEndpointPath(object.icon) ) {
} else if ( isAllowedAppIconEndpointUrl(object.icon) ) {
// Allow existing relative app icon endpoint references.
} else {
validate_url(object.icon, { key: 'icon', maxlen: 3000 });
throw APIError.create('field_invalid', null, { key: 'icon' });
}
}

Expand Down
Loading
Loading