mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-20 20:26:21 +00:00
feat: send app icons as a subdomain URL plus an API fallback (#3734)
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.
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { extension } from '../src/backend/extensions.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import { handleInstalledApps } from './installedApps.ts';
|
||||
@@ -51,13 +52,14 @@ const seedUser = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const seedApp = async (ownerUserId: number) => {
|
||||
const seedApp = async (ownerUserId: number, overrides = {}) => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
return server.stores.app.create(
|
||||
{
|
||||
name: `iaapp_${slug}`,
|
||||
title: `Installed App ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
...overrides,
|
||||
},
|
||||
{ ownerUserId },
|
||||
);
|
||||
@@ -145,6 +147,43 @@ describe('installedApps extension — handleInstalledApps', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('pairs iconUrl with the direct subdomain URL for a generated icon', async () => {
|
||||
const user = await seedUser();
|
||||
// The resize pipeline leaves an http(s) icon column behind, which is
|
||||
// what says the sized PNG is on the icons subdomain.
|
||||
const app = await seedApp(user.id as number, {
|
||||
icon: 'https://api.puter.com/app-icon/x',
|
||||
});
|
||||
await grantInstalled(app!.id as number, user.id as number);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
const list = captured.body as Array<Record<string, unknown>>;
|
||||
const { protocol, static_hosting_domain } = extension.config;
|
||||
expect(list[0].iconCdnUrl).toBe(
|
||||
`${protocol}://puter-app-icons.${static_hosting_domain}/${app!.uid}-256.png`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports no subdomain URL for an app with no generated icon', async () => {
|
||||
const user = await seedUser();
|
||||
const app = await seedApp(user.id as number);
|
||||
await grantInstalled(app!.id as number, user.id as number);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
const list = captured.body as Array<Record<string, unknown>>;
|
||||
expect(list[0].iconCdnUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('flags apps with no owner_user_id as external', async () => {
|
||||
const user = await seedUser();
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
@@ -2,7 +2,10 @@ 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 { getAppIconUrl } from '@heyputer/backend/src/util/appIcon.js';
|
||||
import {
|
||||
getAppIconCdnUrl,
|
||||
getAppIconUrl,
|
||||
} from '@heyputer/backend/src/util/appIcon.js';
|
||||
|
||||
const clients = extension.import('client');
|
||||
|
||||
@@ -73,6 +76,8 @@ export const handleInstalledApps = async (
|
||||
return {
|
||||
...rest,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
// Direct subdomain URL for the client to try before iconUrl.
|
||||
iconCdnUrl: getAppIconCdnUrl(app, extension.config),
|
||||
external,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { isCardFallbackEligible } from '@heyputer/backend/src/util/cardFallback.js';
|
||||
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
|
||||
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
|
||||
import type { Request, Response } from 'express';
|
||||
import TimeAgo from 'javascript-time-ago';
|
||||
@@ -114,13 +115,12 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const oidcOnly = user.password === null;
|
||||
const ALLOWED_ICON_SIZES = new Set([16, 32, 64, 128, 256, 512]);
|
||||
const rawIconSize =
|
||||
typeof req.query?.icon_size === 'string'
|
||||
? Number(req.query.icon_size)
|
||||
: undefined;
|
||||
const iconSize =
|
||||
rawIconSize !== undefined && ALLOWED_ICON_SIZES.has(rawIconSize)
|
||||
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
|
||||
? rawIconSize
|
||||
: undefined;
|
||||
const noIcons = !iconSize;
|
||||
@@ -201,6 +201,7 @@ export const handleWhoami = async (
|
||||
stores,
|
||||
services,
|
||||
apiBaseUrl: String(extension.config.api_base_url ?? ''),
|
||||
config: extension.config,
|
||||
},
|
||||
{ iconSize, noIcons },
|
||||
)
|
||||
@@ -291,7 +292,8 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
{ offering?: Record<string, unknown> } | undefined;
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isAccessTokenActor, isAppActor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { driversContainers } from '../../exports.js';
|
||||
import {
|
||||
APP_ICON_SIZES,
|
||||
ICON_DATA_URL_MIME_ALLOWLIST,
|
||||
isTrustedIconHost,
|
||||
} from '../../util/appIcon.js';
|
||||
@@ -414,8 +415,6 @@ export class AppController extends PuterController {
|
||||
//
|
||||
// ⚠ FLAG: Missing sharp-based resize pipeline; serves the original.
|
||||
|
||||
const ICON_SIZES = [16, 32, 64, 128, 256, 512];
|
||||
|
||||
// Neutering headers for any response that echoes an icon byte
|
||||
// stream on the main origin. `image/svg+xml` is in our MIME
|
||||
// allow-list — it's a legitimate image format, and our own
|
||||
@@ -457,7 +456,7 @@ export class AppController extends PuterController {
|
||||
res.status(400).send('Missing app_uid');
|
||||
return;
|
||||
}
|
||||
if (!ICON_SIZES.includes(size)) {
|
||||
if (!APP_ICON_SIZES.includes(size)) {
|
||||
res.status(400).send('Invalid size');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4654,6 +4654,7 @@ export class AuthController extends PuterController {
|
||||
services: this.services,
|
||||
apiBaseUrl: (this.config as { api_base_url?: string })
|
||||
.api_base_url,
|
||||
config: this.config,
|
||||
} as never,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -331,6 +331,55 @@ describe('LegacyFSController inline routes', () => {
|
||||
expect(entry).not.toHaveProperty('owner_user_id');
|
||||
});
|
||||
|
||||
it('pairs a recent app icon with a direct subdomain URL at the asked size', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const app = (await (
|
||||
server.stores.app.create as unknown as (
|
||||
fields: Record<string, unknown>,
|
||||
opts: { ownerUserId: number },
|
||||
) => Promise<{ uid: string; name: string }>
|
||||
)(
|
||||
{
|
||||
name: `recent-${uuidv4()}`,
|
||||
title: 'Recent App',
|
||||
index_url: 'https://recent.example.test/',
|
||||
// An http(s) icon column is what the resize pipeline leaves
|
||||
// behind, so the sized PNG is on the icons subdomain.
|
||||
icon: 'https://api.puter.com/app-icon/x',
|
||||
},
|
||||
{ ownerUserId: userId },
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)',
|
||||
[app.uid, userId, Math.floor(Date.now() / 1000)],
|
||||
);
|
||||
|
||||
const runWithIconSize = async (query: Record<string, unknown>) => {
|
||||
const { res, captured } = makeRes();
|
||||
await routeHandler('get', '/get-launch-apps')(
|
||||
makeReq({ actor, query }),
|
||||
res,
|
||||
(() => {}) as never,
|
||||
);
|
||||
const body = captured.body as {
|
||||
recent: Array<Record<string, unknown>>;
|
||||
};
|
||||
return body.recent.find((item) => item.uuid === app.uid)!;
|
||||
};
|
||||
|
||||
const { protocol, static_hosting_domain } = controller.config;
|
||||
const base = `${protocol}://puter-app-icons.${static_hosting_domain}`;
|
||||
|
||||
expect((await runWithIconSize({ icon_size: '64' })).iconCdnUrl).toBe(
|
||||
`${base}/${app.uid}-64.png`,
|
||||
);
|
||||
// An unusable size falls back to the default rather than pointing at a
|
||||
// file the pipeline never wrote.
|
||||
expect((await runWithIconSize({ icon_size: '65' })).iconCdnUrl).toBe(
|
||||
`${base}/${app.uid}-256.png`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a zero cache timestamp when unauthenticated', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await routeHandler('get', '/cache/last-change-timestamp')(
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
NON_OWNER_SIGNATURE_TTL_SECONDS,
|
||||
verifySignature,
|
||||
} from '../../util/fileSigning.js';
|
||||
import { APP_ICON_SIZES, getAppIconCdnUrl } from '../../util/appIcon.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
import { maskEntryPath } from '../../services/fs/sharePathMask.js';
|
||||
import {
|
||||
@@ -293,6 +294,14 @@ export class LegacyFSController extends PuterController {
|
||||
? await recommendedSvc.getRecommendedApps()
|
||||
: [];
|
||||
|
||||
// The direct icon URL names a size; honour the one the
|
||||
// caller asked for so the client isn't handed a 256px PNG for
|
||||
// a 64px slot. `icon` keeps the raw column either way.
|
||||
const requestedIconSize = Number(req.query.icon_size);
|
||||
const iconSize = APP_ICON_SIZES.includes(requestedIconSize)
|
||||
? requestedIconSize
|
||||
: undefined;
|
||||
|
||||
let recent: unknown[] = [];
|
||||
const userId = req.actor?.user?.id;
|
||||
if (userId) {
|
||||
@@ -346,6 +355,13 @@ export class LegacyFSController extends PuterController {
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
icon: app.icon ?? null,
|
||||
// Direct subdomain URL for the client to try
|
||||
// before `icon`.
|
||||
iconCdnUrl: getAppIconCdnUrl(
|
||||
app,
|
||||
this.config,
|
||||
iconSize,
|
||||
),
|
||||
godmode: Boolean(app.godmode),
|
||||
maximize_on_start: Boolean(app.maximize_on_start),
|
||||
index_url: backingGone ? null : app.index_url,
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import type { LayerInstances } from '../../types';
|
||||
import { APP_ICON_SIZES, getAppIconsBaseUrl } from '../../util/appIcon.js';
|
||||
import { isUniqueViolation } from '../../util/dbError.js';
|
||||
import type { puterServices } from '../index';
|
||||
import { PuterService } from '../types.js';
|
||||
|
||||
const ICON_SIZES = [16, 32, 64, 128, 256, 512] as const;
|
||||
const APP_ICONS_SUBDOMAIN = 'puter-app-icons';
|
||||
const APP_ICONS_PATH_PREFIX = '/system/app_icons';
|
||||
|
||||
@@ -146,17 +146,7 @@ export class AppIconService extends PuterService {
|
||||
}
|
||||
|
||||
#iconsBaseUrl(): string | null {
|
||||
const cfg = this.config;
|
||||
const host = cfg.static_hosting_domain ?? cfg.static_hosting_domain_alt;
|
||||
if (!host) return null;
|
||||
const protocol = cfg.protocol ?? 'https';
|
||||
// Externally-visible port. Mirrors what PuterHomepageService et al.
|
||||
// do — non-80/443 deployments (local dev, reverse-proxied setups on
|
||||
// non-standard ports) would otherwise get a hostname with no port.
|
||||
const pubPort = cfg.pub_port;
|
||||
const portSuffix =
|
||||
pubPort && pubPort !== 80 && pubPort !== 443 ? `:${pubPort}` : '';
|
||||
return `${protocol}://${APP_ICONS_SUBDOMAIN}.${host}${portSuffix}`;
|
||||
return getAppIconsBaseUrl(this.config);
|
||||
}
|
||||
|
||||
// -- Bootstrap ---------------------------------------------------
|
||||
@@ -258,7 +248,7 @@ export class AppIconService extends PuterService {
|
||||
this.#writeIcon(ORIGINAL_ICON_FILENAME(appUid), originalPng),
|
||||
);
|
||||
|
||||
for (const size of ICON_SIZES) {
|
||||
for (const size of APP_ICON_SIZES) {
|
||||
const sizedPng = await this.#sharp(inputBuffer)
|
||||
.resize(size)
|
||||
.png()
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { getAppIconUrl } from '../../util/appIcon.js';
|
||||
import type { AppIconHostConfig } from '../../util/appIcon.js';
|
||||
import { getAppIconCdnUrl, getAppIconUrl } from '../../util/appIcon.js';
|
||||
import { PuterService } from '../types.js';
|
||||
|
||||
/**
|
||||
@@ -62,7 +63,7 @@ export class RecommendedAppsService extends PuterService {
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
for (const name of RECOMMENDED_APP_NAMES) {
|
||||
const app = await this.stores.app.getByName(name);
|
||||
if (app) results.push(toAppSummary(app, apiBaseUrl));
|
||||
if (app) results.push(toAppSummary(app, apiBaseUrl, this.config));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -71,12 +72,15 @@ export class RecommendedAppsService extends PuterService {
|
||||
function toAppSummary(
|
||||
app: Record<string, unknown>,
|
||||
apiBaseUrl: string | undefined,
|
||||
config: AppIconHostConfig,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
uuid: app.uid,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
icon: getAppIconUrl(app, { apiBaseUrl }) ?? app.icon ?? null,
|
||||
// Direct subdomain URL for the client to try before `icon`.
|
||||
iconCdnUrl: getAppIconCdnUrl(app, config),
|
||||
godmode: Boolean(app.godmode),
|
||||
maximize_on_start: Boolean(app.maximize_on_start),
|
||||
index_url: app.index_url,
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
*/
|
||||
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { getAppIconUrl } from '../../util/appIcon.js';
|
||||
import type { AppIconHostConfig } from '../../util/appIcon.js';
|
||||
import { getAppIconCdnUrl, getAppIconUrl } from '../../util/appIcon.js';
|
||||
import { hostedIndexUrlBackingIsUnavailable } from '../../util/hostedAppBacking.js';
|
||||
import { PuterService } from '../types.js';
|
||||
|
||||
@@ -319,19 +320,22 @@ export class SuggestedAppsService extends PuterService {
|
||||
|
||||
return candidates
|
||||
.filter((_app, index) => !availability[index])
|
||||
.map((app) => toAppSummary(app, apiBaseUrl));
|
||||
.map((app) => toAppSummary(app, apiBaseUrl, this.config));
|
||||
}
|
||||
}
|
||||
|
||||
function toAppSummary(
|
||||
app: Record<string, unknown>,
|
||||
apiBaseUrl: string | undefined,
|
||||
config: AppIconHostConfig,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
uuid: app.uid,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
icon: getAppIconUrl(app, { apiBaseUrl }) ?? app.icon ?? null,
|
||||
// Direct subdomain URL for the client to try before `icon`.
|
||||
iconCdnUrl: getAppIconCdnUrl(app, config),
|
||||
godmode: Boolean(app.godmode),
|
||||
maximize_on_start: Boolean(app.maximize_on_start),
|
||||
index_url: app.index_url,
|
||||
|
||||
Binary file not shown.
+63
-10
@@ -17,19 +17,23 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Always routes through the backend `/app-icon/<uid>/<size>` endpoint rather
|
||||
// than the `puter-app-icons` subdomain directly. Some apps (especially those
|
||||
// imported with a URL icon column that predates the sharp pipeline) only have
|
||||
// the original PNG on the subdomain and no sized variants — a direct subdomain
|
||||
// URL like `<uid>-256.png` 404s in that case. The backend endpoint self-heals:
|
||||
// it can fall back to the original, decode data URLs inline, or serve the
|
||||
// default placeholder. Mirrors v1's `getAppIconPath`.
|
||||
// Icon URLs come in a pair. `getAppIconUrl` builds the backend
|
||||
// `/app-icon/<uid>/<size>` endpoint URL, which self-heals — it falls back to
|
||||
// the un-resized original, decodes data URLs inline, or serves the default
|
||||
// placeholder (mirroring v1's `getAppIconPath`). `getAppIconCdnUrl` builds the
|
||||
// direct `puter-app-icons` subdomain URL the endpoint would redirect to.
|
||||
// Clients load the direct one first and keep the endpoint as a fallback: the
|
||||
// direct URL saves a redirect and survives networks that break on one, while
|
||||
// the endpoint covers apps that only have the original PNG on the subdomain
|
||||
// (a direct `<uid>-256.png` 404s for those).
|
||||
|
||||
export const DEFAULT_APP_ICON_SIZE = 256;
|
||||
|
||||
// Subdomain where AppIconService publishes generated icons. Mirrors the
|
||||
// constant in AppIconService; duplicated here to avoid a dependency cycle
|
||||
// between the util layer and the service layer.
|
||||
// The sizes AppIconService generates, and so the only ones the endpoint and
|
||||
// the direct subdomain URLs can serve.
|
||||
export const APP_ICON_SIZES: readonly number[] = [16, 32, 64, 128, 256, 512];
|
||||
|
||||
// Subdomain where AppIconService publishes generated icons.
|
||||
const APP_ICONS_SUBDOMAIN = 'puter-app-icons';
|
||||
|
||||
// MIME types accepted on the write path for `data:` icon URLs. Anything
|
||||
@@ -55,6 +59,11 @@ interface TrustedIconHostConfig {
|
||||
api_base_url?: string;
|
||||
}
|
||||
|
||||
export interface AppIconHostConfig extends TrustedIconHostConfig {
|
||||
protocol?: string;
|
||||
pub_port?: number;
|
||||
}
|
||||
|
||||
const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
const BASE64_CHARS_REGEX = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
// `data:<type>/<subtype>[;param[=value]]…,<payload>`. The parameter list is
|
||||
@@ -403,3 +412,47 @@ export function getAppIconUrl(
|
||||
}
|
||||
return `${normalizedApiBaseUrl}/app-icon/${normalizedUid}/${iconSize}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL of the subdomain AppIconService publishes generated icons to. Keeps
|
||||
* the externally-visible port on deployments not served from 80/443.
|
||||
*/
|
||||
export function getAppIconsBaseUrl(config: AppIconHostConfig): string | null {
|
||||
const host =
|
||||
config.static_hosting_domain ?? config.static_hosting_domain_alt;
|
||||
if (!host) return null;
|
||||
const protocol = config.protocol ?? 'https';
|
||||
const pubPort = config.pub_port;
|
||||
const portSuffix =
|
||||
pubPort && pubPort !== 80 && pubPort !== 443 ? `:${pubPort}` : '';
|
||||
return `${protocol}://${APP_ICONS_SUBDOMAIN}.${host}${portSuffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct subdomain URL for an app's icon — the target `/app-icon/<uid>/<size>`
|
||||
* redirects to — or null when the app has nothing published there.
|
||||
*
|
||||
* Only rows whose `icon` is already an http(s) URL get one: a `data:` column
|
||||
* means the resize pipeline hasn't run, so no file exists yet, and the endpoint
|
||||
* serves those inline anyway. Callers ship this alongside the endpoint URL so
|
||||
* clients can try it first.
|
||||
*/
|
||||
export function getAppIconCdnUrl(
|
||||
app: Record<string, unknown>,
|
||||
config: AppIconHostConfig,
|
||||
size?: number,
|
||||
): string | null {
|
||||
const appUid = (app.uid ?? app.uuid) as string | undefined;
|
||||
if (!appUid) return null;
|
||||
const icon = app.icon;
|
||||
if (typeof icon !== 'string' || !/^https?:\/\//i.test(icon)) return null;
|
||||
|
||||
const base = getAppIconsBaseUrl(config);
|
||||
if (!base) return null;
|
||||
|
||||
const normalizedUid = appUid.startsWith('app-') ? appUid : `app-${appUid}`;
|
||||
const iconSize = Number.isFinite(Number(size))
|
||||
? Number(size)
|
||||
: DEFAULT_APP_ICON_SIZE;
|
||||
return `${base}/${normalizedUid}-${iconSize}.png`;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('getTaskbarItems', () => {
|
||||
clients: server.clients,
|
||||
stores: server.stores,
|
||||
apiBaseUrl: 'https://api.puter.com',
|
||||
config: { static_hosting_domain: 'puter.site' },
|
||||
} as unknown as Parameters<typeof getTaskbarItems>[1];
|
||||
});
|
||||
|
||||
@@ -172,6 +173,37 @@ describe('getTaskbarItems', () => {
|
||||
expect('icon' in withoutIcon[0]).toBe(false);
|
||||
});
|
||||
|
||||
it('pairs the icon URL with the direct subdomain URL when one exists', async () => {
|
||||
const owner = await makeUser([]);
|
||||
// An http(s) icon column is what the resize pipeline leaves behind, so
|
||||
// the sized PNG is on the subdomain.
|
||||
const app = await makeApp(owner.id, {
|
||||
icon: 'https://api.puter.com/app-icon/x',
|
||||
});
|
||||
const user = await makeUser([{ name: app.name, type: 'app' }]);
|
||||
|
||||
const items = await getTaskbarItems(user as never, deps, {
|
||||
iconSize: 64,
|
||||
});
|
||||
expect(items[0].icon).toBe(
|
||||
`https://api.puter.com/app-icon/${app.uid}/64`,
|
||||
);
|
||||
expect(items[0].iconCdnUrl).toBe(
|
||||
`https://puter-app-icons.puter.site/${app.uid}-64.png`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports no subdomain URL for an app whose icon was never generated', async () => {
|
||||
const owner = await makeUser([]);
|
||||
const app = await makeApp(owner.id);
|
||||
const user = await makeUser([{ name: app.name, type: 'app' }]);
|
||||
|
||||
const items = await getTaskbarItems(user as never, deps, {
|
||||
iconSize: 64,
|
||||
});
|
||||
expect(items[0].iconCdnUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the raw icon column when no API base URL is configured', async () => {
|
||||
const owner = await makeUser([]);
|
||||
const app = await makeApp(owner.id, {
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getAppIconUrl } from './appIcon.js';
|
||||
import type { AppIconHostConfig } from './appIcon.js';
|
||||
import { getAppIconCdnUrl, getAppIconUrl } from './appIcon.js';
|
||||
|
||||
interface TaskbarEntry {
|
||||
name?: string;
|
||||
@@ -33,6 +34,7 @@ interface TaskbarOptions {
|
||||
|
||||
interface TaskbarDeps {
|
||||
apiBaseUrl?: string;
|
||||
config?: AppIconHostConfig;
|
||||
clients: {
|
||||
db: {
|
||||
write: (query: string, params?: unknown[]) => Promise<unknown>;
|
||||
@@ -117,6 +119,10 @@ export async function getTaskbarItems(
|
||||
} else {
|
||||
item.icon =
|
||||
getAppIconUrl(app, deps, options.iconSize) ?? app.icon ?? null;
|
||||
// Direct subdomain URL for the client to try before `icon`.
|
||||
item.iconCdnUrl = deps.config
|
||||
? getAppIconCdnUrl(app, deps.config, options.iconSize)
|
||||
: null;
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
|
||||
import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js';
|
||||
import { parseRemovedApps, serializeRemovedApps, REMOVED_APPS_KV_KEY } from './removedApps.js';
|
||||
import { appTileLink } from './appLink.js';
|
||||
import { appIconAttrs } from '../../helpers/appIcon.js';
|
||||
import { is_window_on_screen, user_facing_windows } from '../../helpers/windowVisibility.js';
|
||||
import {
|
||||
APP_GROUPS_KV_KEY,
|
||||
@@ -209,13 +210,13 @@ function buildTileHtml (app) {
|
||||
// installedApps reports icon: null when an app has no icon at all; its
|
||||
// iconUrl would be a wasted fetch, so use the bundled default instead.
|
||||
// Strictly null — launch-list entries carry no icon key (undefined).
|
||||
const iconUrl = app.icon === null
|
||||
? window.icons['app-default.svg']
|
||||
: (app.iconUrl || window.icons['app.svg']);
|
||||
const iconAttrs = app.icon === null
|
||||
? `src="${html_encode(window.icons['app-default.svg'])}"`
|
||||
: appIconAttrs(app, window.icons['app.svg']);
|
||||
|
||||
let h = `<div class="myapps-tile" role="button" tabindex="-1" data-app-name="${html_encode(app.name)}" data-app-title="${html_encode(title)}" data-app-uid="${html_encode(app.uid || '')}" data-target-link="${html_encode(targetLink)}" title="${html_encode(title)}">`;
|
||||
h += '<div class="myapps-tile-icon">';
|
||||
h += `<img src="${html_encode(iconUrl)}" alt="" draggable="false">`;
|
||||
h += `<img ${iconAttrs} alt="" draggable="false">`;
|
||||
// iOS-style uninstall badge; only shown while reorder mode is on (CSS).
|
||||
// tabindex=-1 keeps it out of the grid's roving-tabindex tab order.
|
||||
if ( ! APP_NAMES_NO_UNINSTALL.has((app.name || '').toLowerCase()) ) {
|
||||
@@ -292,10 +293,10 @@ function buildGroupTileHtml (group, apps) {
|
||||
h += '<div class="myapps-tile-icon myapps-group-icon">';
|
||||
h += '<div class="myapps-group-icon-grid">';
|
||||
for ( const app of shown ) {
|
||||
const iconUrl = app.icon === null
|
||||
? window.icons['app-default.svg']
|
||||
: (app.iconUrl || window.icons['app.svg']);
|
||||
h += `<img src="${html_encode(iconUrl)}" alt="" draggable="false">`;
|
||||
const iconAttrs = app.icon === null
|
||||
? `src="${html_encode(window.icons['app-default.svg'])}"`
|
||||
: appIconAttrs(app, window.icons['app.svg']);
|
||||
h += `<img ${iconAttrs} alt="" draggable="false">`;
|
||||
}
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
@@ -3158,6 +3159,7 @@ const TabApps = {
|
||||
index_url: app.index_url || null,
|
||||
external: app.external ?? false,
|
||||
iconUrl: app.iconUrl || app.icon || null,
|
||||
iconCdnUrl: app.iconCdnUrl || null,
|
||||
}));
|
||||
|
||||
// Build seen set from launch apps
|
||||
@@ -3319,6 +3321,7 @@ const TabApps = {
|
||||
index_url: info.index_url || null,
|
||||
external: false,
|
||||
iconUrl: info.icon || null,
|
||||
iconCdnUrl: info.iconCdnUrl || null,
|
||||
};
|
||||
if ( ! this._pendingInstalls ) this._pendingInstalls = new Map();
|
||||
this._pendingInstalls.set(appName, app);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
|
||||
import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js';
|
||||
import { usageBudget } from './usageBudget.js';
|
||||
import { appIconAttrs } from '../../helpers/appIcon.js';
|
||||
|
||||
// How long a completed usage load stays fresh enough to skip a repeat. Long
|
||||
// enough to absorb the init/onActivate/routing burst on a single dashboard
|
||||
@@ -50,7 +51,7 @@ function buildRecentAppsHTML() {
|
||||
|
||||
h += `<div class="bento-recent-app" data-app-name="${html_encode(app_info.name)}" data-target-link="${html_encode(app_info.target_link)}">`;
|
||||
// Icon
|
||||
h += `<img class="bento-recent-app-icon" src="${html_encode(app_info.icon || window.icons['app.svg'])}">`;
|
||||
h += `<img class="bento-recent-app-icon" ${appIconAttrs(app_info, window.icons['app.svg'])}>`;
|
||||
// Title
|
||||
h += `<span class="bento-recent-app-title">${html_encode(app_info.title)}</span>`;
|
||||
h += '</div>';
|
||||
|
||||
@@ -21,6 +21,7 @@ import UITaskbarItem from './UITaskbarItem.js';
|
||||
import UIPopover from './UIPopover.js';
|
||||
import launch_app from '../helpers/launchApp.js';
|
||||
import UIContextMenu from './UIContextMenu.js';
|
||||
import { appIconAttrs, appIconSrc } from '../helpers/appIcon.js';
|
||||
|
||||
async function UITaskbar (options) {
|
||||
window.global_element_id++;
|
||||
@@ -139,8 +140,9 @@ async function UITaskbar (options) {
|
||||
for ( let index = 0; index < window.launch_recent_apps_count && index < window.launch_apps.recent.length; index++ ) {
|
||||
const app_info = window.launch_apps.recent[index];
|
||||
apps_str += `<div title="${html_encode(app_info.title)}" data-name="${html_encode(app_info.name)}" class="start-app-card">`;
|
||||
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(app_info.icon)}" data-app-title="${html_encode(app_info.title)}">`;
|
||||
apps_str += `<img class="start-app-icon" src="${html_encode(app_info.icon ? app_info.icon : window.icons['app.svg'])}">`;
|
||||
const icon = appIconSrc(app_info);
|
||||
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(icon.src)}" data-app-icon-fallback="${html_encode(icon.fallback)}" data-app-title="${html_encode(app_info.title)}">`;
|
||||
apps_str += `<img class="start-app-icon" ${appIconAttrs(app_info, window.icons['app.svg'])}>`;
|
||||
apps_str += `<span class="start-app-title">${html_encode(app_info.title)}</span>`;
|
||||
apps_str += '</div>';
|
||||
apps_str += '</div>';
|
||||
@@ -158,8 +160,9 @@ async function UITaskbar (options) {
|
||||
for ( let index = 0; index < window.launch_apps.recommended.length; index++ ) {
|
||||
const app_info = window.launch_apps.recommended[index];
|
||||
apps_str += `<div title="${html_encode(app_info.title)}" data-name="${html_encode(app_info.name)}" class="start-app-card">`;
|
||||
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(app_info.icon)}" data-app-title="${html_encode(app_info.title)}">`;
|
||||
apps_str += `<img class="start-app-icon" src="${html_encode(app_info.icon ? app_info.icon : window.icons['app.svg'])}">`;
|
||||
const icon = appIconSrc(app_info);
|
||||
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(icon.src)}" data-app-icon-fallback="${html_encode(icon.fallback)}" data-app-title="${html_encode(app_info.title)}">`;
|
||||
apps_str += `<img class="start-app-icon" ${appIconAttrs(app_info, window.icons['app.svg'])}>`;
|
||||
apps_str += `<span class="start-app-title">${html_encode(app_info.title)}</span>`;
|
||||
apps_str += '</div>';
|
||||
apps_str += '</div>';
|
||||
@@ -266,6 +269,7 @@ async function UITaskbar (options) {
|
||||
// No taskbar item yet: create a new pinned one
|
||||
UITaskbarItem({
|
||||
icon: e.currentTarget.dataset.appIcon,
|
||||
iconFallback: e.currentTarget.dataset.appIconFallback,
|
||||
app: e.currentTarget.dataset.appName,
|
||||
name: e.currentTarget.dataset.appTitle,
|
||||
keep_in_taskbar: true,
|
||||
@@ -351,9 +355,11 @@ async function UITaskbar (options) {
|
||||
if ( window.user.taskbar_items && window.user.taskbar_items.length > 0 ) {
|
||||
for ( let index = 0; index < window.user.taskbar_items.length; index++ ) {
|
||||
const app_info = window.user.taskbar_items[index];
|
||||
const icon = appIconSrc(app_info);
|
||||
// add taskbar item for each app
|
||||
UITaskbarItem({
|
||||
icon: app_info.icon,
|
||||
icon: icon.src,
|
||||
iconFallback: icon.fallback,
|
||||
app: app_info.name,
|
||||
name: app_info.title,
|
||||
keep_in_taskbar: true,
|
||||
@@ -456,6 +462,7 @@ window.make_taskbar_sortable = function () {
|
||||
|
||||
let item = UITaskbarItem({
|
||||
icon: $(ui.item).attr('data-app-icon'),
|
||||
iconFallback: $(ui.item).attr('data-app-icon-fallback'),
|
||||
app: $(ui.item).attr('data-app-name'),
|
||||
name: $(ui.item).attr('data-app-title'),
|
||||
append_to_taskbar: false,
|
||||
|
||||
@@ -21,6 +21,7 @@ import UIContextMenu from './UIContextMenu.js';
|
||||
import path from '../lib/path.js';
|
||||
import launch_app from '../helpers/launchApp.js';
|
||||
import { user_facing_windows } from '../helpers/windowVisibility.js';
|
||||
import { appIconFallbackAttr } from '../helpers/appIcon.js';
|
||||
|
||||
let tray_item_id = 1;
|
||||
|
||||
@@ -53,16 +54,20 @@ function UITaskbarItem (options) {
|
||||
style= "${options.style ? html_encode(options.style) : ''}"
|
||||
>`;
|
||||
let icon = options.icon ? options.icon : window.icons['app.svg'];
|
||||
// App icons carry a retry URL (see helpers/appIcon.js); the bundled icons
|
||||
// below never need one.
|
||||
let icon_fallback = options.iconFallback ?? '';
|
||||
if ( options.app === 'explorer' )
|
||||
{
|
||||
icon = window.icons['folders.svg'];
|
||||
icon_fallback = '';
|
||||
}
|
||||
|
||||
// taskbar icon
|
||||
h += '<div class="taskbar-icon">';
|
||||
// Don't add img tag for separator
|
||||
if ( options.app !== 'separator' ) {
|
||||
h += `<img src="${html_encode(icon)}" style="${options.group === 'apps' ? 'filter:none;' : ''}">`;
|
||||
h += `<img src="${html_encode(icon)}"${appIconFallbackAttr(icon_fallback)} style="${options.group === 'apps' ? 'filter:none;' : ''}">`;
|
||||
}
|
||||
h += '</div>';
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// App icons come from the backend as two URLs: `iconCdnUrl` points straight at
|
||||
// the icons hosting subdomain, and `icon`/`iconUrl` at the API's /app-icon
|
||||
// endpoint, which redirects there. We load the direct URL and keep the
|
||||
// endpoint as a retry — the direct one skips a redirect some networks mangle,
|
||||
// the endpoint covers apps whose sized variant was never generated (it falls
|
||||
// back to the original, or serves the icon inline).
|
||||
|
||||
const FALLBACK_ATTR = 'data-icon-fallback';
|
||||
|
||||
/**
|
||||
* Pick the URL to load an app's icon from, plus the one URL to retry with.
|
||||
*
|
||||
* @param {Object} app - App record from the backend (taskbar item, launch app,
|
||||
* installedApps row). Reads `iconCdnUrl` and `iconUrl`/`icon`.
|
||||
* @param {string} [defaultIcon] - Used when the app carries no icon at all.
|
||||
* @returns {{ src: string; fallback: string }} `fallback` is empty when there
|
||||
* is nothing left to try.
|
||||
*/
|
||||
export function appIconSrc(app, defaultIcon = '') {
|
||||
const cdn = typeof app?.iconCdnUrl === 'string' ? app.iconCdnUrl : '';
|
||||
const endpoint =
|
||||
(typeof app?.iconUrl === 'string' ? app.iconUrl : '') ||
|
||||
(typeof app?.icon === 'string' ? app.icon : '');
|
||||
|
||||
if (cdn && endpoint && cdn !== endpoint) {
|
||||
return { src: cdn, fallback: endpoint };
|
||||
}
|
||||
return { src: cdn || endpoint || defaultIcon, fallback: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The attribute that marks an `<img>` as retryable, ready to interpolate into
|
||||
* an HTML string (empty when there is no retry URL).
|
||||
*
|
||||
* @param {string} fallback
|
||||
* @returns {string}
|
||||
*/
|
||||
export function appIconFallbackAttr(fallback) {
|
||||
return fallback ? ` ${FALLBACK_ATTR}="${html_encode(fallback)}"` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* `src` and the retry attribute in one go, for the common `<img
|
||||
* ${appIconAttrs(app, def)}>` case.
|
||||
*
|
||||
* @param {Object} app
|
||||
* @param {string} [defaultIcon]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function appIconAttrs(app, defaultIcon = '') {
|
||||
const { src, fallback } = appIconSrc(app, defaultIcon);
|
||||
return `src="${html_encode(src)}"${appIconFallbackAttr(fallback)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a failed app-icon `<img>` against its fallback URL, once. Returns
|
||||
* whether a retry was made.
|
||||
*
|
||||
* @param {HTMLImageElement} img
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function applyAppIconFallback(img) {
|
||||
const next = img?.dataset?.iconFallback;
|
||||
if (!next) return false;
|
||||
// One shot: a fallback that fails too must not loop.
|
||||
delete img.dataset.iconFallback;
|
||||
img.src = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the retry for every app icon on the page. Called once at GUI init;
|
||||
* `error` doesn't bubble, hence the capture phase.
|
||||
*
|
||||
* @param {Document | Element} [root]
|
||||
*/
|
||||
export function installAppIconFallback(root = document) {
|
||||
root.addEventListener(
|
||||
'error',
|
||||
(event) => {
|
||||
const el = event.target;
|
||||
if (!el || el.tagName !== 'IMG') return;
|
||||
applyAppIconFallback(el);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it, beforeAll } from 'vitest';
|
||||
import {
|
||||
appIconAttrs,
|
||||
appIconFallbackAttr,
|
||||
appIconSrc,
|
||||
applyAppIconFallback,
|
||||
} from './appIcon.js';
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.html_encode = (str) =>
|
||||
String(str ?? '').replace(/&/g, '&').replace(/"/g, '"');
|
||||
});
|
||||
|
||||
const CDN = 'https://puter-app-icons.puter.site/app-1-256.png';
|
||||
const ENDPOINT = 'https://api.puter.com/app-icon/app-1';
|
||||
|
||||
describe('appIconSrc', () => {
|
||||
it('loads the subdomain URL first and keeps the endpoint as a retry', () => {
|
||||
expect(appIconSrc({ iconCdnUrl: CDN, icon: ENDPOINT })).toEqual({
|
||||
src: CDN,
|
||||
fallback: ENDPOINT,
|
||||
});
|
||||
expect(appIconSrc({ iconCdnUrl: CDN, iconUrl: ENDPOINT })).toEqual({
|
||||
src: CDN,
|
||||
fallback: ENDPOINT,
|
||||
});
|
||||
});
|
||||
|
||||
it('has nothing to retry when the backend sent one URL', () => {
|
||||
expect(appIconSrc({ icon: ENDPOINT })).toEqual({
|
||||
src: ENDPOINT,
|
||||
fallback: '',
|
||||
});
|
||||
expect(appIconSrc({ iconCdnUrl: CDN })).toEqual({
|
||||
src: CDN,
|
||||
fallback: '',
|
||||
});
|
||||
expect(appIconSrc({ iconCdnUrl: CDN, icon: CDN })).toEqual({
|
||||
src: CDN,
|
||||
fallback: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the bundled default for an app with no icon', () => {
|
||||
expect(appIconSrc({}, 'default.svg').src).toBe('default.svg');
|
||||
expect(appIconSrc(null, 'default.svg').src).toBe('default.svg');
|
||||
expect(appIconSrc({ icon: null, iconCdnUrl: null }).src).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appIconFallbackAttr / appIconAttrs', () => {
|
||||
it('emits the retry attribute only when there is a retry URL', () => {
|
||||
expect(appIconFallbackAttr(ENDPOINT)).toBe(
|
||||
` data-icon-fallback="${ENDPOINT}"`,
|
||||
);
|
||||
expect(appIconFallbackAttr('')).toBe('');
|
||||
});
|
||||
|
||||
it('encodes both URLs into img attributes', () => {
|
||||
expect(appIconAttrs({ iconCdnUrl: CDN, icon: ENDPOINT })).toBe(
|
||||
`src="${CDN}" data-icon-fallback="${ENDPOINT}"`,
|
||||
);
|
||||
expect(appIconAttrs({ icon: 'a"b' })).toBe('src="a"b"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAppIconFallback', () => {
|
||||
it('swaps in the retry URL once', () => {
|
||||
const img = { dataset: { iconFallback: ENDPOINT }, src: CDN };
|
||||
expect(applyAppIconFallback(img)).toBe(true);
|
||||
expect(img.src).toBe(ENDPOINT);
|
||||
|
||||
// A failing retry must not loop back onto itself.
|
||||
expect(applyAppIconFallback(img)).toBe(false);
|
||||
expect(img.src).toBe(ENDPOINT);
|
||||
});
|
||||
|
||||
it('leaves images without a retry URL alone', () => {
|
||||
const img = { dataset: {}, src: CDN };
|
||||
expect(applyAppIconFallback(img)).toBe(false);
|
||||
expect(img.src).toBe(CDN);
|
||||
expect(applyAppIconFallback(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import init_device_signals from './helpers/deviceSignals.js';
|
||||
import { holdsPermissions } from './helpers/holdsPermissions.js';
|
||||
import item_icon from './helpers/itemIcon.js';
|
||||
import { installAppIconFallback } from './helpers/appIcon.js';
|
||||
import launch_app from './helpers/launchApp.js';
|
||||
import { parse_url_paths } from './helpers/urlPaths.js';
|
||||
import update_last_touch_coordinates from './helpers/updateLastTouchCoordinates.js';
|
||||
@@ -1189,6 +1190,10 @@ window.initgui = async function (options) {
|
||||
// dispatch id needs gui_params.preludeSdkKey.
|
||||
init_device_signals();
|
||||
|
||||
// Retry app icons that fail on the icons subdomain against the API
|
||||
// endpoint. One listener for the whole GUI, desktop and dashboard alike.
|
||||
installAppIconFallback();
|
||||
|
||||
let picked_a_user_for_sdk_login = false;
|
||||
|
||||
// update SDK if auth_token is different from the one in the SDK
|
||||
|
||||
Reference in New Issue
Block a user