Cap /app/<name> meta descriptions at 150 characters (#3698)

The app shell fed the row's full description into <meta name=
"description">, og:description and twitter:description. Long
descriptions now collapse whitespace and cut at a word boundary with an
ellipsis, never exceeding 150 characters.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Nariman Jelveh
2026-08-30 20:17:04 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 5065bcbf0a
commit 3e8b2735bb
2 changed files with 65 additions and 2 deletions
@@ -278,6 +278,47 @@ describe('HomepageController GET /app/:name', () => {
expect(html).toContain('Cool App');
});
it('caps description, og:description and twitter:description at 150 chars', async () => {
const { userId } = await makeUser();
const name = `long-${Math.random().toString(36).slice(2, 10)}`;
const longDescription =
'This drawing app has brushes, layers, filters and more. '.repeat(
8,
);
await server.stores.app.create(
{
name,
title: 'Wordy App',
description: longDescription,
index_url: `https://example.com/${name}/`,
},
{ ownerUserId: userId },
);
const { res, captured } = makeRes();
await callRoute(
'get',
'/app/:name',
makeReq({ params: { name }, path: `/app/${name}` }),
res,
);
const html = String(captured.body);
for (const tag of [
'name="description"',
'property="og:description"',
'name="twitter:description"',
]) {
const match = new RegExp(`${tag} content="([^"]*)"`).exec(html);
expect(match, `no ${tag} tag`).toBeTruthy();
const content = match![1];
expect(content.length).toBeLessThanOrEqual(150);
expect(content.endsWith('…')).toBe(true);
// Cut at a word boundary, not mid-word.
expect(longDescription).toContain(content.slice(0, -1));
}
});
it('does not leak a private app index_url or owner id to an anonymous visitor', async () => {
const { userId } = await makeUser();
const name = `priv-${Math.random().toString(36).slice(2, 10)}`;
@@ -28,6 +28,23 @@ import type {
LaunchOptions,
} from '../../services/homepage/PuterHomepageService';
/** Longest description/og:description/twitter:description on `/app/<name>`. */
export const APP_META_DESCRIPTION_MAX = 150;
/**
* App descriptions can run long; search snippets and social cards want a
* short blurb. Collapses whitespace, then cuts at a word boundary with an
* ellipsis so the result never exceeds `APP_META_DESCRIPTION_MAX`.
*/
export function appMetaDescription(text: string): string {
const clean = text.replace(/\s+/g, ' ').trim();
if (clean.length <= APP_META_DESCRIPTION_MAX) return clean;
const head = clean.slice(0, APP_META_DESCRIPTION_MAX - 1);
const wordEnd = head.lastIndexOf(' ');
const cut = wordEnd > 0 ? head.slice(0, wordEnd) : head;
return `${cut.replace(/[\s,;:.!?…—-]+$/, '')}`;
}
/**
* Routes that render the Puter GUI shell, plus a catch-all static fallback
* under `<gui_assets_root>/src` for non-dist/src paths (images, fonts, lib
@@ -114,10 +131,15 @@ export class HomepageController extends PuterController {
// through the apps driver before launching, and that read is
// where the entitlement gate and hosted-backing guard run.
const clientApp = toAppShellView(app);
// Feeds <meta name="description">, og:description and
// twitter:description; scrapers truncate long blurbs anyway.
const description = appMetaDescription(
String(app.description ?? ''),
);
await sendShell(req, res, {
title: String(app.title ?? name),
description: String(app.description ?? ''),
short_description: String(app.description ?? ''),
description,
short_description: description,
icon: typeof app.icon === 'string' ? app.icon : undefined,
social_media_image:
typeof metadata.social_image === 'string'