diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index 91f80ec58..553eefc64 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -31,7 +31,7 @@ import { normalizeRawBase64ImageString, validateIconDataUrl, } from '../../util/appIcon.js'; -import { toAppReadView } from '../../util/appView.js'; +import { isOriginBootstrapApp, toAppReadView } from '../../util/appView.js'; import { awaitLater } from '../../util/concurrency.js'; import { isUniqueViolation } from '../../util/dbError.js'; import { @@ -1088,22 +1088,6 @@ export class AppDriver extends PuterDriver { } } - /** - * Origin-bootstrap detection: rows auto-created when an unknown origin - * first needed an app row (no human-supplied metadata). Marker is `name === - * uid && title === uid` and a description starting with "App created from - * origin ". Only these rows are eligible for same-owner merging — refusing - * to merge arbitrary same-owner apps prevents accidental data loss. - */ - #isOriginBootstrapApp(app) { - if (!app || typeof app !== 'object') return false; - if (typeof app.uid !== 'string' || !app.uid) return false; - if (app.name !== app.uid) return false; - if (app.title !== app.uid) return false; - if (typeof app.description !== 'string') return false; - return app.description.startsWith('App created from origin '); - } - // -- Canonical-uid alias kvstore pair ----------------------------- // // After a merge, the source app uid → canonical uid mapping is @@ -1266,7 +1250,7 @@ export class AppDriver extends PuterDriver { if ( Number.isInteger(conflictOwnerUserId) && conflictOwnerUserId === user.id && - !this.#isOriginBootstrapApp(appToJoin) + !isOriginBootstrapApp(appToJoin) ) { // Prevent merging arbitrary same-owner apps; only allow the // auto-created origin bootstrap row to be absorbed. diff --git a/src/backend/services/notification/appMessage.test.ts b/src/backend/services/notification/appMessage.test.ts index 7ab945ef1..9cbde7cb1 100644 --- a/src/backend/services/notification/appMessage.test.ts +++ b/src/backend/services/notification/appMessage.test.ts @@ -243,4 +243,61 @@ describe('appMessageSender', () => { 'a'.repeat(120), ); }); + + describe('an app made for an origin', () => { + const uid = 'app-65d98228-cfa5-5d18-a442-f6eeaf3dcfc7'; + const originApp = (fields: Record = {}) => ({ + uid, + name: uid, + title: uid, + description: 'App created from origin https://chat.example.com', + index_url: 'https://chat.example.com', + ...fields, + }); + + it('is named by the hostname of its index_url, without a port', () => { + expect(appMessageSender(uid, originApp()).name).toBe( + 'chat.example.com', + ); + expect( + appMessageSender( + uid, + originApp({ + index_url: 'https://chat.example.com:8443/app/', + }), + ).name, + ).toBe('chat.example.com'); + }); + + it('keeps its stored name when its index_url does not parse', () => { + expect( + appMessageSender(uid, originApp({ index_url: 'not a url' })) + .name, + ).toBe(uid); + expect( + appMessageSender(uid, originApp({ index_url: undefined })).name, + ).toBe(uid); + }); + + it('keeps a title its developer set', () => { + expect( + appMessageSender(uid, originApp({ title: 'Chat' })).name, + ).toBe('Chat'); + }); + + it('leaves apps that were not made for an origin alone', () => { + expect( + appMessageSender(uid, originApp({ description: 'My chat app' })) + .name, + ).toBe(uid); + expect( + appMessageSender('app-1', { + uid: 'app-1', + name: 'chat', + title: 'Chat', + index_url: 'https://chat.example.com', + }).name, + ).toBe('Chat'); + }); + }); }); diff --git a/src/backend/services/notification/appMessage.ts b/src/backend/services/notification/appMessage.ts index c7c974e28..71b1a39f7 100644 --- a/src/backend/services/notification/appMessage.ts +++ b/src/backend/services/notification/appMessage.ts @@ -18,6 +18,7 @@ */ import { HttpError } from '../../core/http/HttpError.js'; +import { isOriginBootstrapApp } from '../../util/appView.js'; /** The one notification type an app may create. */ export const APP_MESSAGE_TYPE = 'app.message'; @@ -60,14 +61,35 @@ export type AppMessage = { export type AppMessageSender = { uid: string; name?: string; icon?: string }; +/** The hostname an app made for an origin is named by. */ +const originAppHost = (row: unknown): string | undefined => { + if (!isOriginBootstrapApp(row) || typeof row.index_url !== 'string') { + return undefined; + } + try { + return new URL(row.index_url).hostname || undefined; + } catch { + return undefined; + } +}; + /** What a message says about its app, read off the app's row. */ export const appMessageSender = ( uid: string, - row: { name?: unknown; title?: unknown; icon?: unknown } | null | undefined, + row: + | { + name?: unknown; + title?: unknown; + icon?: unknown; + description?: unknown; + index_url?: unknown; + } + | null + | undefined, ): AppMessageSender => { const sender: AppMessageSender = { uid }; // Cleaned before the empty check, so a title of only invisible characters still leaves a visible sender. - const name = [row?.title, row?.name] + const name = [originAppHost(row), row?.title, row?.name] .map((v) => typeof v === 'string' ? [...cleanText(v)] diff --git a/src/backend/stores/app/AppStore.js b/src/backend/stores/app/AppStore.js index 58fe5f4ab..4c7b31968 100644 --- a/src/backend/stores/app/AppStore.js +++ b/src/backend/stores/app/AppStore.js @@ -788,7 +788,7 @@ export class AppStore extends PuterStore { * the row stays unclaimed until a developer registers an app for the same * origin and AppDriver's merge path takes ownership. Marker shape (`name * === uid && title === uid` + description prefix) is what - * `#isOriginBootstrapApp` checks. + * `isOriginBootstrapApp` (util/appView) checks. */ async createFromOrigin( uid, diff --git a/src/backend/util/appView.test.ts b/src/backend/util/appView.test.ts index 9e748cbb3..34b9d94b0 100644 --- a/src/backend/util/appView.test.ts +++ b/src/backend/util/appView.test.ts @@ -18,7 +18,12 @@ */ import { describe, expect, it } from 'vitest'; -import { toAppReadView, toAppSummary, toAppView } from './appView.js'; +import { + isOriginBootstrapApp, + toAppReadView, + toAppSummary, + toAppView, +} from './appView.js'; const row = (overrides: Record = {}) => ({ id: 42, @@ -192,3 +197,27 @@ describe('toAppReadView', () => { ).toBe(64); }); }); + +describe('isOriginBootstrapApp', () => { + const originRow = (overrides: Record = {}) => + row({ + name: 'app-1234', + title: 'app-1234', + description: 'App created from origin https://chat.example.com', + ...overrides, + }); + + it('matches a row created for an origin', () => { + expect(isOriginBootstrapApp(originRow())).toBe(true); + }); + + it('does not match once the app has its own name, title or description', () => { + expect(isOriginBootstrapApp(originRow({ name: 'chat' }))).toBe(false); + expect(isOriginBootstrapApp(originRow({ title: 'Chat' }))).toBe(false); + expect(isOriginBootstrapApp(originRow({ description: 'Chat' }))).toBe( + false, + ); + expect(isOriginBootstrapApp(row())).toBe(false); + expect(isOriginBootstrapApp(null)).toBe(false); + }); +}); diff --git a/src/backend/util/appView.ts b/src/backend/util/appView.ts index dabf038c5..c662dca6a 100644 --- a/src/backend/util/appView.ts +++ b/src/backend/util/appView.ts @@ -25,6 +25,18 @@ import type { PrivateLaunchDecision } from './privateLaunchAccess.js'; /** A raw `apps` store row. */ export type AppRow = Record; +/** A row created for an origin, whose name and title are still its uid. */ +export function isOriginBootstrapApp(app: unknown): app is AppRow { + if (!app || typeof app !== 'object') return false; + const { uid, name, title, description } = app as AppRow; + if (typeof uid !== 'string' || !uid) return false; + if (name !== uid || title !== uid) return false; + return ( + typeof description === 'string' && + description.startsWith('App created from origin ') + ); +} + /** Client shape of an app. Never carries internal ids or the owner's id. */ export interface AppView { uid: unknown; diff --git a/src/gui/src/UI/Dashboard/TabNotifications.js b/src/gui/src/UI/Dashboard/TabNotifications.js index a747d5126..fd4b264aa 100644 --- a/src/gui/src/UI/Dashboard/TabNotifications.js +++ b/src/gui/src/UI/Dashboard/TabNotifications.js @@ -127,12 +127,21 @@ export const isLoadableIcon = (url) => { } }; +const APP_UID_PATTERN = /^app-[0-9a-f-]{36}$/; + +/** @param {import('../../helpers/notificationApi.js').PushApp} app @returns {string} Encoded */ +const appTitleHtml = (app) => { + const title = app.title || app.name || ''; + if ( ! APP_UID_PATTERN.test(title) ) return html_encode(title); + return i18n('push_app_unnamed_website'); +}; + /** @param {import('../../helpers/notificationApi.js').PushApp} app */ export const appCardHtml = (app) => { const icon = isLoadableIcon(app.icon) ? app.icon : ''; return card({ icon: ``, - title: html_encode(app.title || app.name || ''), + title: appTitleHtml(app), action: ``, extraClass: 'push-app-card', }); diff --git a/src/gui/src/UI/Dashboard/TabNotifications.test.js b/src/gui/src/UI/Dashboard/TabNotifications.test.js index a9e4bc2ce..ecb5969c3 100644 --- a/src/gui/src/UI/Dashboard/TabNotifications.test.js +++ b/src/gui/src/UI/Dashboard/TabNotifications.test.js @@ -700,6 +700,22 @@ describe('apps that can send pushes', () => { delete window.icons; }); + it('shows a readable name for an app still named by its uid', async () => { + const uid = 'app-65d98228-cfa5-5d18-a442-f6eeaf3dcfc7'; + serve({ + 'GET /push/devices': { devices: [] }, + 'GET /push/apps': { apps: [ + { uid, name: uid, title: uid, icon: null }, + { uid: 'app-2', name: 'app-2', title: 'app-2', icon: null }, + ] }, + 'GET /push/preferences': { puter: true }, + }); + await open(); + + expect($('.push-app-card strong').map((_, el) => $(el).text()).get()) + .toEqual(['push_app_unnamed_website', 'app-2']); + }); + it('shows an app name and icon containing markup as text', async () => { serve({ 'GET /push/devices': { devices: [] }, diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index b6fb1963b..975a60b12 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -466,6 +466,7 @@ const en = { publish_website_intro: 'Serves %strong% at a public address anyone can visit.', publish_worker_intro: 'Runs %strong% at a public address anyone can call.', push_app_turn_off_failed: "Couldn't turn that off. Please try again.", + push_app_unnamed_website: 'Unnamed website', push_apps: 'Apps that can send you notifications', push_apps_empty: 'No apps can send you push notifications.', push_browser_blocked: 'Notifications are blocked in your browser settings',