mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-10 15:25:49 +00:00
App payloads carried only the /app-icon endpoint URL, which 302s to the icons hosting subdomain. Networks that mangle that redirect render no icon at all, and every icon load pays a round trip for the hop. Ship the direct subdomain URL as `iconCdnUrl` alongside it (taskbar items, installedApps, recent/recommended launch apps, suggested apps), and have the GUI load that first with the endpoint URL as a one-shot retry - desktop taskbar, start menu, dashboard app grid and recents. Only rows whose `icon` column is already an http(s) URL get one: a data: column means the resize pipeline has not written anything to the subdomain yet. Also folds the four copies of the generated-size list into one exported APP_ICON_SIZES.
102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import type { Request, Response } from 'express';
|
|
import { Context } from '@heyputer/backend/src/core';
|
|
import { HttpError } from '@heyputer/backend/src/core/http';
|
|
import { extension } from '@heyputer/backend/src/extensions';
|
|
import {
|
|
getAppIconCdnUrl,
|
|
getAppIconUrl,
|
|
} from '@heyputer/backend/src/util/appIcon.js';
|
|
|
|
const clients = extension.import('client');
|
|
|
|
const ALLOWED_ORDER_BY = [
|
|
'id',
|
|
'name',
|
|
'uid',
|
|
'title',
|
|
'installed_at',
|
|
] as const;
|
|
const ORDER_BY_FIELD_MAP: Record<string, string> = {
|
|
id: 'apps.id',
|
|
name: 'apps.name',
|
|
uid: 'apps.uid',
|
|
title: 'apps.title',
|
|
installed_at: 'installed_at',
|
|
};
|
|
|
|
export const handleInstalledApps = async (
|
|
req: Request,
|
|
res: Response,
|
|
): Promise<void> => {
|
|
const actor = Context.get('actor');
|
|
if (!actor?.user?.id) throw new HttpError(401, 'Authentication required');
|
|
|
|
const orderBy = String(req.query.orderBy ?? 'installed_at');
|
|
if (!(ALLOWED_ORDER_BY as readonly string[]).includes(orderBy)) {
|
|
throw new HttpError(
|
|
400,
|
|
`Invalid orderBy. Allowed: ${ALLOWED_ORDER_BY.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
const page = Math.max(Number(req.query.page) || 1, 1);
|
|
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 100);
|
|
const offset = (page - 1) * limit;
|
|
const orderByField = ORDER_BY_FIELD_MAP[orderBy];
|
|
const sortDirection = req.query.desc ? 'DESC' : 'ASC';
|
|
|
|
const installedApps = (await clients.db.read(
|
|
`SELECT
|
|
apps.name,
|
|
apps.uid,
|
|
apps.title,
|
|
apps.description,
|
|
apps.icon,
|
|
apps.index_url,
|
|
apps.owner_user_id,
|
|
MIN(perm.dt) AS installed_at
|
|
FROM apps
|
|
LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id
|
|
WHERE perm.user_id = ?
|
|
GROUP BY apps.id, apps.name, apps.uid, apps.title, apps.description
|
|
ORDER BY ${orderByField} ${sortDirection}
|
|
LIMIT ?
|
|
OFFSET ?`,
|
|
[actor.user.id, limit, offset],
|
|
)) as Array<Record<string, unknown>>;
|
|
|
|
const apiBaseUrl = extension.config.api_base_url as string | undefined;
|
|
res.json(
|
|
installedApps.map((app) => {
|
|
// An app with no owner_user_id (null/empty) isn't owned by a Puter
|
|
// user — it's an "external" app. Derive a flag and don't leak the
|
|
// raw owner id to the client.
|
|
const { owner_user_id, ...rest } = app;
|
|
const external = owner_user_id == null || owner_user_id === '';
|
|
return {
|
|
...rest,
|
|
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
|
// Direct subdomain URL for the client to try before iconUrl.
|
|
iconCdnUrl: getAppIconCdnUrl(app, extension.config),
|
|
external,
|
|
};
|
|
}),
|
|
);
|
|
};
|
|
|
|
extension.get(
|
|
'/installedApps',
|
|
{
|
|
subdomain: 'api',
|
|
requireUserActor: true,
|
|
allowFullAccessToken: true,
|
|
rateLimit: {
|
|
scope: 'installed-apps',
|
|
limit: 120,
|
|
window: 60_000,
|
|
key: 'user',
|
|
},
|
|
},
|
|
handleInstalledApps,
|
|
);
|