mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-22 05:57:57 +00:00
fix: don't stream back bytes for icons (#2486)
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled
* decrease global check alerts * fix: don't stream back bytes for icons
This commit is contained in:
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
const APIError = require('../../api/APIError');
|
||||
const { Context } = require('../../util/context');
|
||||
const { stream_to_buffer } = require('../../util/streamutil');
|
||||
const { get_apps, suggestedAppsForFsEntries } = require('../../helpers');
|
||||
const { ECMAP } = require('../ECMAP');
|
||||
const { TYPE_DIRECTORY, TYPE_SYMLINK } = require('../FSNodeContext');
|
||||
@@ -131,25 +130,12 @@ class HLReadDir extends HLFilesystemOperation {
|
||||
const entry = await child.getSafeEntry();
|
||||
if ( !no_thumbs && entry.associated_app ) {
|
||||
const svc_appIcon = this.context.get('services').get('app-icon');
|
||||
const iconResult = await svc_appIcon.getIconStream({
|
||||
appIcon: entry.associated_app.icon,
|
||||
const iconPath = svc_appIcon.getAppIconPath({
|
||||
appUid: entry.associated_app.uid ?? entry.associated_app.uuid,
|
||||
size: 64,
|
||||
});
|
||||
|
||||
if ( iconResult.dataUrl ?? iconResult.data_url ) {
|
||||
entry.associated_app.icon = iconResult.dataUrl ?? iconResult.data_url;
|
||||
} else {
|
||||
try {
|
||||
const buffer = await stream_to_buffer(iconResult.stream);
|
||||
const respDataUrl = `data:${iconResult.mime};base64,${buffer.toString('base64')}`;
|
||||
entry.associated_app.icon = respDataUrl;
|
||||
} catch (e) {
|
||||
const svc_error = this.context.get('services').get('error-service');
|
||||
svc_error.report('hl_readdir:icon-stream', {
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
if ( iconPath ) {
|
||||
entry.associated_app.icon = iconPath;
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
|
||||
@@ -1919,13 +1919,12 @@ async function get_taskbar_items (user, { icon_size, no_icons } = {}) {
|
||||
delete item.icon;
|
||||
} else {
|
||||
const svc_appIcon = _servicesHolder.services.get('app-icon');
|
||||
const iconResult = await svc_appIcon.getIconStream({
|
||||
appIcon: item.icon,
|
||||
const iconUrl = svc_appIcon.getSizedIconUrl({
|
||||
appUid: item.uid,
|
||||
size: icon_size,
|
||||
});
|
||||
|
||||
item.icon = await iconResult.get_data_url();
|
||||
item.icon = iconUrl;;
|
||||
}
|
||||
|
||||
// add to final object
|
||||
|
||||
@@ -30,7 +30,6 @@ import { DB_WRITE } from '../../services/database/consts.js';
|
||||
import { Endpoint } from '../../util/expressutil.js';
|
||||
import { buffer_to_stream, stream_to_buffer } from '../../util/streamutil.js';
|
||||
import DEFAULT_APP_ICON from './default-app-icon.js';
|
||||
import IconResult from './lib/IconResult.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -88,7 +87,7 @@ export class AppIconService extends BaseService {
|
||||
mime,
|
||||
redirectUrl,
|
||||
redirectCacheControl,
|
||||
} = await this.getIconStream({ appUid, size, allowRedirect: true });
|
||||
} = await this.#getIconStream({ appUid, size, allowRedirect: true });
|
||||
|
||||
if ( redirectUrl ) {
|
||||
if ( redirectCacheControl ) {
|
||||
@@ -109,35 +108,30 @@ export class AppIconService extends BaseService {
|
||||
}
|
||||
|
||||
async iconifyApps ({ apps, size }) {
|
||||
return await Promise.all(apps.map(async app => {
|
||||
const iconResult = await this.getIconStream({
|
||||
appIcon: app.icon,
|
||||
return apps.map(app => {
|
||||
const iconPath = this.getAppIconPath({
|
||||
appUid: app.uid ?? app.uuid,
|
||||
size,
|
||||
});
|
||||
|
||||
if ( iconResult.dataUrl ?? iconResult.data_url ) {
|
||||
app.icon = iconResult.dataUrl ?? iconResult.data_url;
|
||||
return app;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await stream_to_buffer(iconResult.stream);
|
||||
const respDataUrl = `data:${iconResult.mime};base64,${buffer.toString('base64')}`;
|
||||
|
||||
app.icon = respDataUrl;
|
||||
} catch (e) {
|
||||
this.errors.report('get-launch-apps:icon-stream', {
|
||||
source: e,
|
||||
});
|
||||
if ( iconPath ) {
|
||||
app.icon = iconPath;
|
||||
}
|
||||
return app;
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async getIconStream (params) {
|
||||
const result = await this.#getIconStream(params);
|
||||
return new IconResult(result);
|
||||
getAppIconPath ({ appUid, size }) {
|
||||
const normalizedAppUid = this.normalizeAppUid(appUid);
|
||||
if ( typeof normalizedAppUid !== 'string' || !normalizedAppUid ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiBaseUrl = String(config.api_base_url || '').replace(/\/+$/, '');
|
||||
if ( ! apiBaseUrl ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${apiBaseUrl}/app-icon/${normalizedAppUid}/${size}`;
|
||||
}
|
||||
|
||||
normalizeAppUid (appUid) {
|
||||
|
||||
@@ -82,98 +82,44 @@ describe('AppIconService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIconStream', () => {
|
||||
const createServiceInstance = () => new AppIconService({
|
||||
services: { get: vi.fn() },
|
||||
config: {},
|
||||
name: 'app-icon',
|
||||
args: {},
|
||||
});
|
||||
describe('icon URL mapping', () => {
|
||||
it('builds a legacy app-icon path with normalized app uid', () => {
|
||||
const service = Object.create(AppIconService.prototype);
|
||||
|
||||
it('redirects to puter-app-icons subsite sized file when requested size exists', async () => {
|
||||
const legacyNode = {
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const legacyRoot = {
|
||||
getChild: vi.fn().mockResolvedValue(legacyNode),
|
||||
};
|
||||
|
||||
const service = createServiceInstance();
|
||||
service.errors = { report: vi.fn() };
|
||||
service.getAppIcons = vi.fn().mockResolvedValue(legacyRoot);
|
||||
service.getSizedIconUrl = vi.fn().mockReturnValue('https://puter-app-icons.site.puter.localhost/app-abc-64.png');
|
||||
|
||||
const result = await service.getIconStream({
|
||||
appUid: 'app-abc',
|
||||
const result = service.getAppIconPath({
|
||||
appUid: 'abc',
|
||||
size: 64,
|
||||
allowRedirect: true,
|
||||
});
|
||||
|
||||
expect(result.redirectUrl).toBe('https://puter-app-icons.site.puter.localhost/app-abc-64.png');
|
||||
expect(result.redirectCacheControl).toContain('max-age=2592000');
|
||||
expect(result).toBe(`${config.api_base_url}/app-icon/app-abc/64`);
|
||||
});
|
||||
|
||||
it('redirects to original and queues resize when requested size is missing', async () => {
|
||||
const legacyNode = {
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const legacyRoot = {
|
||||
getChild: vi.fn().mockResolvedValue(legacyNode),
|
||||
};
|
||||
const originalNode = {
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
it('iconifyApps rewrites icons to the legacy app-icon endpoint path', async () => {
|
||||
const service = Object.create(AppIconService.prototype);
|
||||
const apps = [
|
||||
{ uid: 'app-abc', icon: 'data:image/png;base64,AA==' },
|
||||
{ uuid: 'def', icon: 'https://example.com/icon.png' },
|
||||
];
|
||||
|
||||
const service = createServiceInstance();
|
||||
service.errors = { report: vi.fn() };
|
||||
service.getAppIcons = vi.fn().mockResolvedValue(legacyRoot);
|
||||
service.getOriginalIconLookup = vi.fn().mockResolvedValue({
|
||||
node: originalNode,
|
||||
isFlatOriginal: true,
|
||||
});
|
||||
service.getOriginalIconUrl = vi.fn().mockReturnValue('https://puter-app-icons.site.puter.localhost/app-abc.png');
|
||||
service.queueMissingSizeFromOriginal = vi.fn();
|
||||
|
||||
const result = await service.getIconStream({
|
||||
appUid: 'app-abc',
|
||||
size: 128,
|
||||
allowRedirect: true,
|
||||
});
|
||||
|
||||
expect(result.redirectUrl).toBe('https://puter-app-icons.site.puter.localhost/app-abc.png');
|
||||
expect(result.redirectCacheControl).toContain('max-age=604800');
|
||||
expect(service.queueMissingSizeFromOriginal).toHaveBeenCalledWith({
|
||||
appUid: 'app-abc',
|
||||
const result = await service.iconifyApps({
|
||||
apps,
|
||||
size: 128,
|
||||
});
|
||||
|
||||
expect(result[0].icon).toBe(`${config.api_base_url}/app-icon/app-abc/128`);
|
||||
expect(result[1].icon).toBe(`${config.api_base_url}/app-icon/app-def/128`);
|
||||
});
|
||||
|
||||
it('redirects to app icon URL when no cached icon exists and URL is eligible', async () => {
|
||||
const redirectUrl = `https://dev-center-app-id.${config.static_hosting_domain}/raw-icon.png`;
|
||||
it('iconifyApps leaves icon unchanged when app uid is missing', async () => {
|
||||
const service = Object.create(AppIconService.prototype);
|
||||
const apps = [{ icon: 'existing-icon' }];
|
||||
|
||||
const legacyNode = {
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const legacyRoot = {
|
||||
getChild: vi.fn().mockResolvedValue(legacyNode),
|
||||
};
|
||||
|
||||
const service = createServiceInstance();
|
||||
service.errors = { report: vi.fn() };
|
||||
service.getAppIcons = vi.fn().mockResolvedValue(legacyRoot);
|
||||
service.getOriginalIconLookup = vi.fn().mockResolvedValue({
|
||||
node: null,
|
||||
isFlatOriginal: false,
|
||||
const result = await service.iconifyApps({
|
||||
apps,
|
||||
size: 128,
|
||||
});
|
||||
|
||||
const result = await service.getIconStream({
|
||||
appUid: 'app-abc',
|
||||
appIcon: redirectUrl,
|
||||
size: 256,
|
||||
allowRedirect: true,
|
||||
});
|
||||
|
||||
expect(result.redirectUrl).toBe(redirectUrl);
|
||||
expect(result[0].icon).toBe('existing-icon');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -190,13 +190,13 @@ export default class AppService extends BaseService {
|
||||
const icon_size = params.icon_size;
|
||||
const svc_appIcon = this.context.get('services').get('app-icon');
|
||||
try {
|
||||
const iconResult = await svc_appIcon.getIconStream({
|
||||
const iconPath = svc_appIcon.getAppIconPath({
|
||||
appUid: row.uid,
|
||||
appIcon: row.icon,
|
||||
size: icon_size,
|
||||
});
|
||||
console.log('this is working it looks like');
|
||||
app.icon = await iconResult.get_data_url();
|
||||
if ( iconPath ) {
|
||||
app.icon = iconPath;
|
||||
}
|
||||
} catch (e) {
|
||||
const svc_error = this.context.get('services').get('error-service');
|
||||
svc_error.report('AppES:read_transform', { source: e });
|
||||
@@ -318,12 +318,13 @@ export default class AppService extends BaseService {
|
||||
const icon_size = params.icon_size;
|
||||
const svc_appIcon = this.context.get('services').get('app-icon');
|
||||
try {
|
||||
const iconResult = await svc_appIcon.getIconStream({
|
||||
const iconPath = svc_appIcon.getAppIconPath({
|
||||
appUid: row.uid,
|
||||
appIcon: row.icon,
|
||||
size: icon_size,
|
||||
});
|
||||
app.icon = await iconResult.get_data_url();
|
||||
if ( iconPath ) {
|
||||
app.icon = iconPath;
|
||||
}
|
||||
} catch (e) {
|
||||
const svc_error = this.context.get('services').get('error-service');
|
||||
svc_error.report('AppES:read_transform', { source: e });
|
||||
|
||||
@@ -316,9 +316,7 @@ describe('AppService', () => {
|
||||
mockDb.read.mockResolvedValue([mockRow]);
|
||||
|
||||
const mockIconService = {
|
||||
getIconStream: vi.fn().mockResolvedValue({
|
||||
get_data_url: vi.fn().mockResolvedValue('data:image/png;base64,abc123'),
|
||||
}),
|
||||
getAppIconPath: vi.fn().mockReturnValue('/app-icon/app-uid-123/64'),
|
||||
};
|
||||
|
||||
appService.context = {
|
||||
@@ -341,12 +339,11 @@ describe('AppService', () => {
|
||||
params: { icon_size: 64 },
|
||||
});
|
||||
|
||||
expect(mockIconService.getIconStream).toHaveBeenCalledWith({
|
||||
expect(mockIconService.getAppIconPath).toHaveBeenCalledWith({
|
||||
appUid: 'app-uid-123',
|
||||
appIcon: 'icon.png',
|
||||
size: 64,
|
||||
});
|
||||
expect(result.icon).toBe('data:image/png;base64,abc123');
|
||||
expect(result.icon).toBe('/app-icon/app-uid-123/64');
|
||||
});
|
||||
|
||||
it('should keep original icon when icon service throws', async () => {
|
||||
@@ -358,7 +355,9 @@ describe('AppService', () => {
|
||||
};
|
||||
|
||||
const mockIconService = {
|
||||
getIconStream: vi.fn().mockRejectedValue(new Error('Icon fetch failed')),
|
||||
getAppIconPath: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Icon fetch failed');
|
||||
}),
|
||||
};
|
||||
|
||||
appService.context = {
|
||||
@@ -480,9 +479,7 @@ describe('AppService', () => {
|
||||
mockDb.read.mockResolvedValue(mockRows);
|
||||
|
||||
const mockIconService = {
|
||||
getIconStream: vi.fn().mockImplementation(({ appUid }) => ({
|
||||
get_data_url: vi.fn().mockResolvedValue(`data:image/png;base64,${appUid}`),
|
||||
})),
|
||||
getAppIconPath: vi.fn().mockImplementation(({ appUid, size }) => `/app-icon/${appUid}/${size}`),
|
||||
};
|
||||
|
||||
appService.context = {
|
||||
@@ -504,9 +501,9 @@ describe('AppService', () => {
|
||||
params: { icon_size: 32 },
|
||||
});
|
||||
|
||||
expect(mockIconService.getIconStream).toHaveBeenCalledTimes(2);
|
||||
expect(result[0].icon).toBe('data:image/png;base64,app-1');
|
||||
expect(result[1].icon).toBe('data:image/png;base64,app-2');
|
||||
expect(mockIconService.getAppIconPath).toHaveBeenCalledTimes(2);
|
||||
expect(result[0].icon).toBe('/app-icon/app-1/32');
|
||||
expect(result[1].icon).toBe('/app-icon/app-2/32');
|
||||
});
|
||||
|
||||
it('should return empty array when no apps exist', async () => {
|
||||
|
||||
@@ -337,12 +337,13 @@ class AppES extends BaseES {
|
||||
if ( icon_size ) {
|
||||
const svc_appIcon = this.context.get('services').get('app-icon');
|
||||
try {
|
||||
const iconResult = await svc_appIcon.getIconStream({
|
||||
const iconPath = svc_appIcon.getAppIconPath({
|
||||
appUid: await entity.get('uid'),
|
||||
appIcon: await entity.get('icon'),
|
||||
size: icon_size,
|
||||
});
|
||||
await entity.set('icon', await iconResult.get_data_url());
|
||||
if ( iconPath ) {
|
||||
await entity.set('icon', iconPath);
|
||||
}
|
||||
} catch (e) {
|
||||
const svc_error = this.context.get('services').get('error-service');
|
||||
svc_error.report('AppES:read_transform', { source: e });
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
const { AdvancedBase } = require('@heyputer/putility');
|
||||
const { BaseES } = require('./BaseES');
|
||||
|
||||
const APIError = require('../../api/APIError');
|
||||
const { Entity } = require('./Entity');
|
||||
const { WeakConstructorFeature } = require('../../traits/WeakConstructorFeature');
|
||||
@@ -62,7 +61,7 @@ class SQLES extends BaseES {
|
||||
return [` WHERE ${id_col} = ?`, [uid]];
|
||||
}
|
||||
|
||||
if ( ! uid.hasOwnProperty('predicate') ) {
|
||||
if ( ! Object.prototype.hasOwnProperty.call(uid, 'predicate') ) {
|
||||
throw new Error('SQLES.read does not understand this input: ' +
|
||||
'object with no predicate property');
|
||||
}
|
||||
@@ -164,7 +163,7 @@ class SQLES extends BaseES {
|
||||
}
|
||||
},
|
||||
|
||||
async delete (uid, extra) {
|
||||
async delete (uid) {
|
||||
const id_prop = this.om.properties[this.om.primary_identifier];
|
||||
let id_col =
|
||||
id_prop.descriptor.sql?.column_name ?? id_prop.name;
|
||||
@@ -304,7 +303,7 @@ class SQLES extends BaseES {
|
||||
}
|
||||
|
||||
if ( value && options.use_id ) {
|
||||
if ( value.hasOwnProperty('id') ) {
|
||||
if ( Object.prototype.hasOwnProperty.call(value, 'id') ) {
|
||||
value = value.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,17 +22,8 @@ import { get_apps } from '../helpers.js';
|
||||
import { DB_READ } from '../services/database/consts.js';
|
||||
|
||||
const iconify_apps = async (context, { apps, size }) => {
|
||||
return await Promise.all(apps.map(async app => {
|
||||
const svc_appIcon = context.services.get('app-icon');
|
||||
const iconResult = await svc_appIcon.getIconStream({
|
||||
appIcon: app.icon,
|
||||
appUid: app.uid ?? app.uuid,
|
||||
size,
|
||||
});
|
||||
|
||||
app.icon = await iconResult.get_data_url();
|
||||
return app;
|
||||
}));
|
||||
const svc_appIcon = context.services.get('app-icon');
|
||||
return await svc_appIcon.iconifyApps({ apps, size });
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------//
|
||||
|
||||
@@ -683,35 +683,37 @@ export class MeteringService {
|
||||
}
|
||||
|
||||
async #checkRateOfChange () {
|
||||
const globalUsage = await this.getGlobalUsage();
|
||||
const now = Date.now();
|
||||
const lastChange = await this.#superUserService.sudo(async () => {
|
||||
return this.#kvStore.get({ key: `${METRICS_PREFIX}:lastGlobalUsageCheck` }) as Promise<{ total: number, timestamp: number } | null>;
|
||||
});
|
||||
|
||||
const currTotal = globalUsage.total;
|
||||
if ( !lastChange || (now - lastChange.timestamp) > 4 * 60 * 1000 ) {
|
||||
// only checked if more than 4 minutes from last check
|
||||
const globalUsage = await this.getGlobalUsage();
|
||||
const currTotal = globalUsage.total;
|
||||
|
||||
if ( lastChange ) {
|
||||
const timeDelta = now - lastChange.timestamp;
|
||||
const usageDelta = currTotal - lastChange.total;
|
||||
const usagePerMinute = (usageDelta / (timeDelta / 60000));
|
||||
if ( lastChange ) {
|
||||
const timeDelta = now - lastChange.timestamp;
|
||||
const usageDelta = currTotal - lastChange.total;
|
||||
const usagePerMinute = (usageDelta / (timeDelta / 60000));
|
||||
|
||||
if ( usagePerMinute > MeteringService.MAX_GLOBAL_USAGE_PER_MINUTE ) {
|
||||
this.#alarmService.create('metering:excessiveGlobalUsageRate', `Global usage rate is excessive: ${usagePerMinute} micro-cents per minute`, {
|
||||
usagePerMinute,
|
||||
maxAllowedPerMinute: MeteringService.MAX_GLOBAL_USAGE_PER_MINUTE,
|
||||
});
|
||||
if ( usagePerMinute > MeteringService.MAX_GLOBAL_USAGE_PER_MINUTE ) {
|
||||
this.#alarmService.create('metering:excessiveGlobalUsageRate', `Global usage rate is excessive: ${usagePerMinute} micro-cents per minute`, {
|
||||
usagePerMinute,
|
||||
maxAllowedPerMinute: MeteringService.MAX_GLOBAL_USAGE_PER_MINUTE,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.#superUserService.sudo(async () => {
|
||||
await this.#kvStore.set({
|
||||
key: `${METRICS_PREFIX}:lastGlobalUsageCheck`,
|
||||
value: {
|
||||
total: currTotal,
|
||||
timestamp: now,
|
||||
},
|
||||
await this.#superUserService.sudo(async () => {
|
||||
await this.#kvStore.set({
|
||||
key: `${METRICS_PREFIX}:lastGlobalUsageCheck`,
|
||||
value: {
|
||||
total: currTotal,
|
||||
timestamp: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user