From 4f267fb83e4023d1e7272562d27a44bb51eedf33 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 11 Aug 2026 16:31:27 -0700 Subject: [PATCH] feat: app user feedback system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add puter.ui.showFeedbackDialog(), letting users send feedback to an app's developer. In the app environment the Puter desktop renders the dialog; on a third-party website a puter.com popup hosts it. The message is stored in a new app_feedback table and emailed to the app owner's confirmed email — it never passes through the app's own code. Feedback is strictly opt-in per app via a new apps.feedback_enabled column (a real column, not an app-metadata key, so Dev Center's whole-blob metadata saves can't silently erase it), settable through the existing puter.apps.update path (feedbackEnabled). Backend follows the layered stack: AppFeedbackStore (durable count queries) -> AppFeedbackService (opt-in check, message normalization, abuse caps, best-effort owner email) -> AppFeedbackController (POST /app-feedback, GET /app-feedback/target). New app-user-feedback email template uses the escaping-safe nl2br triple-stash. Defensive by design: - requireUserActor blocks app tokens, so feedback can't be submitted programmatically; guiOriginOnly keeps cross-origin pages out. - App identity comes only from the validated IPC sender (desktop) or the browser-attested opener origin (popup), never from message contents. - The send-feedback popup action is in NON_AUTH_POPUP_ACTIONS, so it never delivers a token to the opener. - Layered limits: route rate limits, plus DB-count caps that fail closed when the limiter backend is down, plus a per-app daily owner-email cap. - Owner email is fully best-effort: an unconfigured transport, unconfirmed/unsubscribed/suspended owner, or send failure never fails the request or blocks storage. - The dialog and SDK method are resolve-only and always settle, so a caller is never left hanging. Migrations for sqlite/mysql/postgres, puter.js types, docs, backend tests (sqlite + postgres), and a Playwright e2e spec are included. --- .../database/SqliteDatabaseClient.test.ts | 2 +- .../clients/database/SqliteDatabaseClient.ts | 1 + .../migrations/mysql/mysql_mig_20.sql | 48 ++ .../migrations/postgres/postgres_mig_9.sql | 47 ++ .../migrations/sqlite/0065_app-feedback.sql | 51 ++ src/backend/clients/email/templates.ts | 18 + .../feedback/AppFeedbackController.test.ts | 589 ++++++++++++++++++ .../feedback/AppFeedbackController.ts | 159 +++++ src/backend/controllers/index.ts | 2 + src/backend/drivers/apps/AppDriver.js | 8 + .../services/feedback/AppFeedbackService.ts | 299 +++++++++ src/backend/services/index.ts | 5 + src/backend/stores/app/AppStore.js | 2 + .../stores/appFeedback/AppFeedbackStore.ts | 137 ++++ src/backend/stores/index.ts | 3 + src/docs/src/Apps/create.md | 1 + src/docs/src/Apps/update.md | 1 + src/docs/src/UI.md | 1 + src/docs/src/UI/showFeedbackDialog.md | 47 ++ src/docs/src/sidebar.js | 8 + src/gui/src/IPC.js | 53 ++ src/gui/src/UI/UIWindowAppFeedback.js | 254 ++++++++ src/gui/src/css/style.css | 4 + src/gui/src/i18n/translations/en.js | 8 + src/gui/src/initgui.js | 61 ++ src/gui/src/util/popupAuth.js | 2 +- src/puter-js/src/modules/UI.js | 151 +++++ .../src/modules/apps/lib/appObject.js | 1 + .../tests/e2e/fixtures/send-feedback.html | 63 ++ .../e2e/specs/showFeedbackDialog.spec.js | 146 +++++ src/puter-js/types/modules/apps.d.ts | 15 + src/puter-js/types/modules/ui.d.ts | 9 + 32 files changed, 2194 insertions(+), 2 deletions(-) create mode 100644 src/backend/clients/database/migrations/mysql/mysql_mig_20.sql create mode 100644 src/backend/clients/database/migrations/postgres/postgres_mig_9.sql create mode 100644 src/backend/clients/database/migrations/sqlite/0065_app-feedback.sql create mode 100644 src/backend/controllers/feedback/AppFeedbackController.test.ts create mode 100644 src/backend/controllers/feedback/AppFeedbackController.ts create mode 100644 src/backend/services/feedback/AppFeedbackService.ts create mode 100644 src/backend/stores/appFeedback/AppFeedbackStore.ts create mode 100644 src/docs/src/UI/showFeedbackDialog.md create mode 100644 src/gui/src/UI/UIWindowAppFeedback.js create mode 100644 src/puter-js/tests/e2e/fixtures/send-feedback.html create mode 100644 src/puter-js/tests/e2e/specs/showFeedbackDialog.spec.js diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index c378b3548..c7000bb73 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.test.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts @@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js'; import { SqliteDatabaseClient } from './SqliteDatabaseClient.js'; /** Highest schema version the migration table can reach. */ -const CURRENT_SCHEMA_VERSION = 60; +const CURRENT_SCHEMA_VERSION = 61; const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238'; const sqliteConfig = ( diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index df684c743..1f3642387 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -94,6 +94,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [57, ['0062_blocked-app-origins.sql']], [58, ['0063_add_suspended_reason.sql']], [59, ['0064_abuse-moderation-events.sql']], + [60, ['0065_app-feedback.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_20.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_20.sql new file mode 100644 index 000000000..55b789069 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_20.sql @@ -0,0 +1,48 @@ +-- 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 . + +-- User-to-developer feedback for apps that opt in. Mirrors SQLite migration +-- 0065. Opt-in is the new `apps.feedback_enabled` column (developer-writable +-- through the regular `puter.apps.update` path). Each row of `app_feedback` +-- is one message a signed-in user submitted through the GUI feedback dialog; +-- a copy is emailed to the app owner unless the per-app daily email cap +-- suppressed it (`email_sent` records which). `app_uid` is denormalized +-- alongside `app_id` so rows stay attributable after an app is deleted. +-- `created_at` is unix seconds. +-- +-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which +-- always runs first) and the table uses `CREATE TABLE IF NOT EXISTS`, so the +-- directory can replay safely. + +CALL _puter_add_col('apps', 'feedback_enabled', '`feedback_enabled` tinyint(1) DEFAULT ''0'''); + +CREATE TABLE IF NOT EXISTS `app_feedback` ( + `id` INT NOT NULL AUTO_INCREMENT, + `uid` CHAR(36) NOT NULL, + `app_id` INT NOT NULL, + `app_uid` CHAR(40) NOT NULL, + `user_id` INT NOT NULL, + `message` TEXT NOT NULL, + `source_env` VARCHAR(16) DEFAULT NULL, + `source_origin` VARCHAR(2048) DEFAULT NULL, + `email_sent` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` BIGINT NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_app_feedback_uid` (`uid`), + KEY `idx_app_feedback_app_created` (`app_id`, `created_at`), + KEY `idx_app_feedback_user_created` (`user_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_9.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_9.sql new file mode 100644 index 000000000..124937534 --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_9.sql @@ -0,0 +1,47 @@ +-- 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 . + +-- User-to-developer feedback for apps that opt in. Mirrors SQLite migration +-- 0065 / MySQL mysql_mig_20. Opt-in is the new `apps.feedback_enabled` column +-- (developer-writable through the regular `puter.apps.update` path). Each row +-- of `app_feedback` is one message a signed-in user submitted through the GUI +-- feedback dialog; a copy is emailed to the app owner unless the per-app +-- daily email cap suppressed it (`email_sent` records which). `app_uid` is +-- denormalized alongside `app_id` so rows stay attributable after an app is +-- deleted. `created_at` is unix seconds. +-- +-- Idempotent via IF NOT EXISTS. + +ALTER TABLE apps ADD COLUMN IF NOT EXISTS feedback_enabled boolean NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS app_feedback ( + id BIGSERIAL PRIMARY KEY, + uid CHAR(36) NOT NULL UNIQUE, + app_id BIGINT NOT NULL, + app_uid VARCHAR(40) NOT NULL, + user_id BIGINT NOT NULL, + message TEXT NOT NULL, + source_env VARCHAR(16) DEFAULT NULL, + source_origin VARCHAR(2048) DEFAULT NULL, + email_sent BOOLEAN NOT NULL DEFAULT FALSE, + created_at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_app_feedback_app_created + ON app_feedback (app_id, created_at); +CREATE INDEX IF NOT EXISTS idx_app_feedback_user_created + ON app_feedback (user_id, created_at); diff --git a/src/backend/clients/database/migrations/sqlite/0065_app-feedback.sql b/src/backend/clients/database/migrations/sqlite/0065_app-feedback.sql new file mode 100644 index 000000000..161d7591a --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0065_app-feedback.sql @@ -0,0 +1,51 @@ +-- 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 . + +-- User-to-developer feedback for apps that opt in. Opt-in is the new +-- `apps.feedback_enabled` column (developer-writable through the regular +-- `puter.apps.update` path; a dedicated column rather than a `metadata` key +-- because Dev Center saves replace the whole metadata blob and would erase +-- it). Each row of `app_feedback` is one message a signed-in user submitted +-- through the GUI feedback dialog; a copy is emailed to the app owner unless +-- the per-app daily email cap suppressed it (`email_sent` records which). +-- `app_uid` is denormalized alongside `app_id` so rows stay attributable +-- after an app is deleted (abuse forensics). `source_env` is 'app' (desktop +-- dialog) or 'web' (puter.com popup opened from an external site); +-- `source_origin` is the popup opener's browser-attested origin, null for +-- desktop submissions. `created_at` is unix seconds. The (user_id, +-- created_at) and (app_id, created_at) indexes serve the sliding-window +-- rate-limit counts in AppFeedbackStore. + +ALTER TABLE apps ADD COLUMN "feedback_enabled" tinyint(1) DEFAULT '0'; + +CREATE TABLE IF NOT EXISTS `app_feedback` ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "uid" TEXT NOT NULL UNIQUE, + "app_id" INTEGER NOT NULL, + "app_uid" TEXT NOT NULL, + "user_id" INTEGER NOT NULL, + "message" TEXT NOT NULL, + "source_env" TEXT DEFAULT NULL, -- 'app' | 'web' + "source_origin" TEXT DEFAULT NULL, -- attested opener origin (web popups) + "email_sent" INTEGER NOT NULL DEFAULT 0, + "created_at" INTEGER NOT NULL -- unix seconds +); + +CREATE INDEX IF NOT EXISTS idx_app_feedback_app_created + ON `app_feedback` (`app_id`, `created_at`); +CREATE INDEX IF NOT EXISTS idx_app_feedback_user_created + ON `app_feedback` (`user_id`, `created_at`); diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts index 1343d1b36..986235b78 100644 --- a/src/backend/clients/email/templates.ts +++ b/src/backend/clients/email/templates.ts @@ -75,6 +75,24 @@ Please update {{app_title}}.
{{nl2br message}}

Best,
The Puter Team +

+ `, + }, + 'app-user-feedback': { + subject: 'New user feedback for {{app_title}}', + html: ` +

Hi{{#if owner_username}} {{owner_username}}{{/if}},

+

+{{sender_username}} sent feedback about {{app_title}}: +

+
{{{nl2br message}}}
+

+You're receiving this because user feedback is enabled for your app. To stop +receiving these emails, turn off feedback for the app (e.g. +puter.apps.update({ name: '{{app_name}}', feedbackEnabled: false })). +

+

Best,
+The Puter Team

`, }, diff --git a/src/backend/controllers/feedback/AppFeedbackController.test.ts b/src/backend/controllers/feedback/AppFeedbackController.test.ts new file mode 100644 index 000000000..87531e917 --- /dev/null +++ b/src/backend/controllers/feedback/AppFeedbackController.test.ts @@ -0,0 +1,589 @@ +/* + * 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 . + */ + +import type { Request, RequestHandler, Response } from 'express'; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { v4 as uuidv4 } from 'uuid'; +import type { Actor } from '../../core/actor.js'; +import { PuterRouter } from '../../core/http/PuterRouter.js'; +import { PuterServer } from '../../server.js'; +import { AppFeedbackService } from '../../services/feedback/AppFeedbackService.js'; +import { setupTestServer } from '../../testUtil.js'; + +// Boots one real PuterServer (in-memory sqlite + mocked externals) and +// registers AppFeedbackController's decorated routes onto a fresh +// PuterRouter. Tests drive the captured handlers with stub req/res; the +// stores/services underneath are the live wired ones, so rows land in the +// real `app_feedback` table. + +let server: PuterServer; +let router: PuterRouter; + +beforeAll(async () => { + server = await setupTestServer(); + router = new PuterRouter(); + server.controllers.appFeedback.registerRoutes(router); +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const makeUser = async (): Promise<{ actor: Actor; userId: number }> => { + const username = `fdbk-${Math.random().toString(36).slice(2, 10)}`; + const created = await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + }); + const refreshed = (await server.stores.user.getById(created.id))!; + return { + userId: refreshed.id, + actor: { + user: { + id: refreshed.id, + uuid: refreshed.uuid, + username: refreshed.username, + email: refreshed.email ?? null, + email_confirmed: true, + } as Actor['user'], + }, + }; +}; + +const makeApp = async ( + ownerUserId: number, + opts: { feedbackEnabled?: boolean; indexUrl?: string } = {}, +) => { + const name = `fdbk-app-${Math.random().toString(36).slice(2, 10)}`; + return await server.stores.app.create( + { + name, + title: `Feedback Test ${name}`, + index_url: opts.indexUrl ?? `https://${name}.example.com`, + ...(opts.feedbackEnabled ? { feedback_enabled: 1 } : {}), + }, + { ownerUserId }, + ); +}; + +const confirmOwnerEmail = async (userId: number) => { + await server.clients.db.write( + 'UPDATE `user` SET `email_confirmed` = ? WHERE `id` = ?', + [server.clients.db.booleanValue(true), userId], + ); + const user = await server.stores.user.getById(userId); + if (user) await server.stores.user.invalidate(user); +}; + +const makeReq = (init: { + body?: unknown; + actor?: Actor; + query?: Record; +}): Request => { + return { + body: init.body ?? {}, + query: init.query ?? {}, + headers: {}, + actor: init.actor, + } as unknown as Request; +}; + +const makeRes = () => { + const captured: { statusCode: number; body: unknown } = { + statusCode: 200, + body: undefined, + }; + const res = { + json: vi.fn((value: unknown) => { + captured.body = value; + return res; + }), + status: vi.fn((code: number) => { + captured.statusCode = code; + return res; + }), + }; + return { res: res as unknown as Response, captured }; +}; + +const findRoute = (method: string, path: string) => { + const route = router.routes.find( + (r) => r.method === method && r.path === path, + ); + if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`); + return route; +}; + +const callRoute = async ( + method: string, + path: string, + req: Request, + res: Response, +) => { + const handler: RequestHandler = findRoute(method, path).handler; + await handler(req, res, () => { + throw new Error('handler called next() unexpectedly'); + }); +}; + +const submit = (actor: Actor, body: unknown) => { + const { res, captured } = makeRes(); + return callRoute('post', '/', makeReq({ body, actor }), res).then( + () => captured, + ); +}; + +// ── Route gates ───────────────────────────────────────────────────── + +describe('AppFeedbackController route options', () => { + it('rejects app actors and cross-origin pages on submit', () => { + const { options } = findRoute('post', '/'); + // requireUserActor is what makes feedback impossible to submit + // programmatically with an app token; guiOriginOnly keeps + // cross-origin browser pages out even with a leaked user token. + expect(options.requireUserActor).toBe(true); + expect(options.guiOriginOnly).toBe(true); + }); + + it('stacks a per-user budget with a per-IP backstop', () => { + const { options } = findRoute('post', '/'); + const limits = options.rateLimit; + expect(Array.isArray(limits)).toBe(true); + const keys = (limits as Array<{ key?: unknown }>).map((l) => l.key); + expect(keys).toContain('user'); + expect(keys).toContain('ip'); + }); + + it('requires a user actor on the target pre-flight too', () => { + const { options } = findRoute('get', '/target'); + expect(options.requireUserActor).toBe(true); + }); +}); + +// ── GET /app-feedback/target ──────────────────────────────────────── + +describe('AppFeedbackController GET /target', () => { + it('throws 400 when neither or both of app/origin are given', async () => { + const { actor } = await makeUser(); + for (const query of [ + {}, + { app: 'x', origin: 'https://x.example.com' }, + ]) { + const { res } = makeRes(); + await expect( + callRoute('get', '/target', makeReq({ query, actor }), res), + ).rejects.toMatchObject({ statusCode: 400 }); + } + }); + + it('reports enabled:false for an unknown app', async () => { + const { actor } = await makeUser(); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/target', + makeReq({ query: { app: 'no-such-app-xyz' }, actor }), + res, + ); + expect(captured.body).toEqual({ enabled: false, app: null }); + }); + + it('reports enabled:false for an app that has not opted in', async () => { + const { userId: ownerId } = await makeUser(); + const app = await makeApp(ownerId); + const { actor } = await makeUser(); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/target', + makeReq({ query: { app: app.name }, actor }), + res, + ); + expect(captured.body).toMatchObject({ enabled: false }); + }); + + it('reports enabled:true with canonical title/name for an opted-in app', async () => { + const { userId: ownerId } = await makeUser(); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + const { actor } = await makeUser(); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/target', + makeReq({ query: { app: app.uid }, actor }), + res, + ); + expect(captured.body).toEqual({ + enabled: true, + app: { name: app.name, title: app.title }, + }); + }); + + it('resolves an origin to the app whose index_url it matches', async () => { + const { userId: ownerId } = await makeUser(); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + const origin = new URL(app.index_url).origin; + const { actor } = await makeUser(); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/target', + makeReq({ query: { origin }, actor }), + res, + ); + expect(captured.body).toEqual({ + enabled: true, + app: { name: app.name, title: app.title }, + }); + }); + + it('reports enabled:false for an origin with no registered app', async () => { + const { actor } = await makeUser(); + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/target', + makeReq({ + query: { origin: 'https://nobody-registered.example.com' }, + actor, + }), + res, + ); + expect(captured.body).toEqual({ enabled: false, app: null }); + }); +}); + +// ── POST /app-feedback ────────────────────────────────────────────── + +describe('AppFeedbackController POST /', () => { + it('throws 400 when message is missing or not a string', async () => { + const { userId: ownerId } = await makeUser(); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + const { actor } = await makeUser(); + for (const message of [undefined, 12345, '']) { + await expect( + submit(actor, { app: app.name, message }), + ).rejects.toMatchObject({ statusCode: 400 }); + } + }); + + it('throws 400 when both app and origin are given', async () => { + const { actor } = await makeUser(); + await expect( + submit(actor, { + app: 'x', + origin: 'https://x.example.com', + message: 'hi', + }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('throws 403 feedback_not_enabled when the app has not opted in', async () => { + const { userId: ownerId } = await makeUser(); + const app = await makeApp(ownerId); + const { actor } = await makeUser(); + await expect( + submit(actor, { app: app.name, message: 'hi there' }), + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'feedback_not_enabled', + }); + }); + + it('throws 403 for an unknown app and an unknown origin alike', async () => { + const { actor } = await makeUser(); + await expect( + submit(actor, { app: 'no-such-app-xyz', message: 'hi' }), + ).rejects.toMatchObject({ statusCode: 403 }); + await expect( + submit(actor, { + origin: 'https://nobody-registered.example.com', + message: 'hi', + }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('throws 400 when the message exceeds the length limit', async () => { + const { userId: ownerId } = await makeUser(); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + const { actor } = await makeUser(); + await expect( + submit(actor, { + app: app.name, + message: 'x'.repeat( + AppFeedbackService.MESSAGE_MAX_LENGTH + 1, + ), + }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('stores a normalized row and responds with an empty object', async () => { + const { userId: ownerId } = await makeUser(); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + const { actor, userId } = await makeUser(); + const captured = await submit(actor, { + app: app.uid, + message: ' Great\r\napp! ', + context: 'app', + }); + expect(captured.body).toEqual({}); + + const rows = (await server.clients.db.read( + 'SELECT * FROM `app_feedback` WHERE `user_id` = ?', + [userId], + )) as Array>; + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + app_uid: app.uid, + message: 'Great\napp!', + source_env: 'app', + source_origin: null, + }); + // Engine-agnostic reads: pg returns BIGINT as string and BOOLEAN as + // boolean, sqlite returns numbers for both. + expect(Number(rows[0].app_id)).toBe(app.id); + expect(Boolean(rows[0].email_sent)).toBe(false); + expect(typeof rows[0].uid).toBe('string'); + expect(Number.isFinite(Number(rows[0].created_at))).toBe(true); + }); + + it('records the attested origin on web submissions', async () => { + const { userId: ownerId } = await makeUser(); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + const origin = new URL(app.index_url).origin; + const { actor, userId } = await makeUser(); + await submit(actor, { origin, message: 'from the web', context: 'web' }); + + const rows = (await server.clients.db.read( + 'SELECT `source_env`, `source_origin` FROM `app_feedback` WHERE `user_id` = ?', + [userId], + )) as Array>; + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + source_env: 'web', + source_origin: origin, + }); + }); + + it('enforces the per-user-per-app daily cap with 429', async () => { + const { userId: ownerId } = await makeUser(); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + const { actor, userId } = await makeUser(); + const since = Math.floor(Date.now() / 1000); + for (let i = 0; i < AppFeedbackService.PER_USER_APP_DAILY_LIMIT; i++) { + await server.stores.appFeedback.create({ + appId: app.id, + appUid: app.uid, + userId, + message: `seed ${i}`, + }); + } + expect( + await server.stores.appFeedback.countByUserAndAppSince( + userId, + app.id, + since - 60, + ), + ).toBe(AppFeedbackService.PER_USER_APP_DAILY_LIMIT); + await expect( + submit(actor, { app: app.name, message: 'one too many' }), + ).rejects.toMatchObject({ + statusCode: 429, + legacyCode: 'too_many_requests', + }); + }); + + it('enforces the per-user daily cap across apps with 429', async () => { + const { userId: ownerId } = await makeUser(); + const target = await makeApp(ownerId, { feedbackEnabled: true }); + const other = await makeApp(ownerId, { feedbackEnabled: true }); + const { actor, userId } = await makeUser(); + for (let i = 0; i < AppFeedbackService.PER_USER_DAILY_LIMIT; i++) { + await server.stores.appFeedback.create({ + appId: other.id, + appUid: other.uid, + userId, + message: `seed ${i}`, + }); + } + await expect( + submit(actor, { app: target.name, message: 'over the limit' }), + ).rejects.toMatchObject({ statusCode: 429 }); + }); +}); + +// ── Owner email delivery ──────────────────────────────────────────── + +describe('AppFeedbackService owner email', () => { + const mockEmailReady = () => { + vi.spyOn(server.clients.email, 'isConfigured', 'get').mockReturnValue( + true, + ); + return vi + .spyOn(server.clients.email, 'send') + .mockResolvedValue(undefined); + }; + + it('emails the confirmed owner and marks the row emailed', async () => { + const send = mockEmailReady(); + const { userId: ownerId } = await makeUser(); + await confirmOwnerEmail(ownerId); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + const { actor, userId } = await makeUser(); + const sender = (await server.stores.user.getById(userId))!; + + await submit(actor, { app: app.name, message: 'hello dev' }); + + const owner = (await server.stores.user.getById(ownerId))!; + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith( + owner.email, + 'app-user-feedback', + expect.objectContaining({ + owner_username: owner.username, + sender_username: sender.username, + app_name: app.name, + message: 'hello dev', + }), + ); + + const rows = (await server.clients.db.read( + 'SELECT `email_sent` FROM `app_feedback` WHERE `user_id` = ?', + [userId], + )) as Array<{ email_sent: unknown }>; + expect(Boolean(rows[0]?.email_sent)).toBe(true); + }); + + it('stores but does not email when the owner email is unconfirmed', async () => { + const send = mockEmailReady(); + const { userId: ownerId } = await makeUser(); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + const { actor, userId } = await makeUser(); + + await submit(actor, { app: app.name, message: 'hello dev' }); + + expect(send).not.toHaveBeenCalled(); + const rows = (await server.clients.db.read( + 'SELECT `email_sent` FROM `app_feedback` WHERE `user_id` = ?', + [userId], + )) as Array<{ email_sent: unknown }>; + expect(Boolean(rows[0]?.email_sent)).toBe(false); + }); + + it('suppresses email past the per-app daily cap but still stores', async () => { + const send = mockEmailReady(); + const { userId: ownerId } = await makeUser(); + await confirmOwnerEmail(ownerId); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + + // Seed the cap with already-emailed rows from other users. + for ( + let i = 0; + i < AppFeedbackService.PER_APP_DAILY_EMAIL_LIMIT; + i++ + ) { + const { userId: seedUserId } = await makeUser(); + const row = await server.stores.appFeedback.create({ + appId: app.id, + appUid: app.uid, + userId: seedUserId, + message: `seed ${i}`, + }); + await server.stores.appFeedback.markEmailSent(row.id); + } + + const { actor, userId } = await makeUser(); + const captured = await submit(actor, { + app: app.name, + message: 'past the cap', + }); + expect(captured.body).toEqual({}); + expect(send).not.toHaveBeenCalled(); + + const rows = (await server.clients.db.read( + 'SELECT `email_sent` FROM `app_feedback` WHERE `user_id` = ?', + [userId], + )) as Array<{ email_sent: unknown }>; + expect(rows).toHaveLength(1); + expect(Boolean(rows[0]?.email_sent)).toBe(false); + }); + + it('a failing email send never fails the request', async () => { + vi.spyOn(server.clients.email, 'isConfigured', 'get').mockReturnValue( + true, + ); + vi.spyOn(server.clients.email, 'send').mockRejectedValue( + new Error('smtp down'), + ); + const { userId: ownerId } = await makeUser(); + await confirmOwnerEmail(ownerId); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + const { actor, userId } = await makeUser(); + + const captured = await submit(actor, { + app: app.name, + message: 'still stored', + }); + expect(captured.body).toEqual({}); + const rows = (await server.clients.db.read( + 'SELECT `email_sent` FROM `app_feedback` WHERE `user_id` = ?', + [userId], + )) as Array<{ email_sent: unknown }>; + expect(rows).toHaveLength(1); + expect(Boolean(rows[0]?.email_sent)).toBe(false); + }); +}); + +// ── Message normalization ─────────────────────────────────────────── + +describe('AppFeedbackService.normalizeMessage', () => { + it('unifies newlines, strips control chars, and trims', () => { + const service = server.services.appFeedback; + expect(service.normalizeMessage(' a\r\nb\rc ')).toBe( + 'a\nb\nc', + ); + expect(service.normalizeMessage('keep\ttabs\nand\nnewlines')).toBe( + 'keep\ttabs\nand\nnewlines', + ); + expect(service.normalizeMessage('a\u0000b\u0007c\u007F')).toBe('abc'); + }); + + it('returns null for non-strings and whitespace-only input', () => { + const service = server.services.appFeedback; + expect(service.normalizeMessage(42)).toBeNull(); + expect(service.normalizeMessage(' \n\t ')).toBeNull(); + expect(service.normalizeMessage(null)).toBeNull(); + }); +}); diff --git a/src/backend/controllers/feedback/AppFeedbackController.ts b/src/backend/controllers/feedback/AppFeedbackController.ts new file mode 100644 index 000000000..0fa4cc2f5 --- /dev/null +++ b/src/backend/controllers/feedback/AppFeedbackController.ts @@ -0,0 +1,159 @@ +/* + * 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 . + */ + +import type { Request, Response } from 'express'; +import { Controller, Get, Post } from '../../core/http/decorators.js'; +import { HttpError } from '../../core/http/HttpError.js'; +import { AppFeedbackService } from '../../services/feedback/AppFeedbackService.js'; +import { PuterController } from '../types.js'; + +/** + * Endpoints behind the "send feedback to this app's developer" dialog + * (`puter.ui.showFeedbackDialog()`). Only the GUI (desktop dialog or the + * puter.com popup) calls these; apps cannot — both routes reject app actors, + * which is what makes feedback impossible to submit programmatically on a + * user's behalf. + * + * The target may be named either by `app` (uid or name — the desktop knows + * which app asked) or by `origin` (external site — the popup passes its + * browser-attested opener origin). Exactly one must be provided. + */ + +/** Sanity cap on the raw body field; the service enforces the real limit. */ +const RAW_MESSAGE_CAP = 50_000; + +const readTargetParam = (value: unknown): string | undefined => { + return typeof value === 'string' && value.length > 0 && value.length <= 3000 + ? value + : undefined; +}; + +@Controller('/app-feedback') +export class AppFeedbackController extends PuterController { + /** + * GET /app-feedback/target — pre-flight for the dialog: whether the target + * app accepts feedback, plus its canonical title/name for display. Reveals + * nothing that `puter.apps.get` doesn't already. + */ + @Get('/target', { + subdomain: 'api', + requireUserActor: true, + rateLimit: { + scope: 'app-feedback-target', + limit: 60, + window: 60_000, + key: 'user', + }, + }) + async target(req: Request, res: Response): Promise { + const app = readTargetParam(req.query.app); + const origin = readTargetParam(req.query.origin); + if (!app === !origin) { + throw new HttpError( + 400, + 'Exactly one of `app` and `origin` is required', + { legacyCode: 'bad_request' }, + ); + } + + const service = this.services.appFeedback as AppFeedbackService; + res.json(await service.getTarget({ app, origin })); + } + + /** + * POST /app-feedback — store one feedback message and email the app's + * developer. Strict limits: the route limits below are the cheap first + * line; AppFeedbackService enforces durable per-user/per-app daily caps + * from the database (the route limiter fails open, the DB caps don't). + */ + @Post('/', { + subdomain: 'api', + requireUserActor: true, + // Submissions only ever originate from our own GUI pages (desktop + // dialog / popup). Cross-origin browser pages get stopped here even + // if they somehow hold a user token; non-browser clients still pass + // and are handled by the caps. + guiOriginOnly: true, + rateLimit: [ + { + scope: 'app-feedback-user', + limit: 5, + window: 30 * 60_000, + key: 'user', + }, + // IP backstop so freshly minted accounts can't stack per-user + // budgets from one machine. + { + scope: 'app-feedback-ip', + limit: 30, + window: 24 * 60 * 60_000, + key: 'ip', + }, + ], + }) + async submit(req: Request, res: Response): Promise { + const body = req.body ?? {}; + const app = readTargetParam(body.app); + const origin = readTargetParam(body.origin); + if (!app === !origin) { + throw new HttpError( + 400, + 'Exactly one of `app` and `origin` is required', + { legacyCode: 'bad_request' }, + ); + } + + const message = body.message; + if (typeof message !== 'string' || message.length === 0) { + throw new HttpError(400, '`message` is required', { + legacyCode: 'bad_request', + }); + } + if (message.length > RAW_MESSAGE_CAP) { + throw new HttpError( + 400, + `\`message\` is too long (max ${AppFeedbackService.MESSAGE_MAX_LENGTH} characters)`, + { legacyCode: 'bad_request' }, + ); + } + + const sourceEnv = + body.context === 'app' || body.context === 'web' + ? body.context + : undefined; + + const userId = req.actor?.user?.id; + if (!userId) { + throw new HttpError(401, 'Unauthorized', { + legacyCode: 'unauthorized', + }); + } + + const service = this.services.appFeedback as AppFeedbackService; + await service.submit({ + userId, + app, + origin, + message, + sourceEnv, + sourceOrigin: origin ?? null, + }); + res.json({}); + } +} diff --git a/src/backend/controllers/index.ts b/src/backend/controllers/index.ts index 1902efd05..cda804451 100644 --- a/src/backend/controllers/index.ts +++ b/src/backend/controllers/index.ts @@ -18,6 +18,7 @@ */ import { AppController } from './apps/AppController.js'; +import { AppFeedbackController } from './feedback/AppFeedbackController.js'; import { AuthController } from './auth/AuthController.js'; import { BroadcastController } from './broadcast/BroadcastController.js'; import { DesktopController } from './desktop/DesktopController.js'; @@ -43,6 +44,7 @@ export const puterControllers = { staticPages: StaticPagesController, auth: AuthController, apps: AppController, + appFeedback: AppFeedbackController, desktop: DesktopController, hosting: HostingController, system: SystemController, diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index 70ce9e5f7..891d852c5 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -686,6 +686,13 @@ export class AppDriver extends PuterDriver { ? 1 : 0; } + if (object.feedback_enabled !== undefined) { + out.feedback_enabled = validateBool(object.feedback_enabled, { + key: 'feedback_enabled', + }) + ? 1 + : 0; + } if (object.metadata !== undefined) { const meta = validateJsonObject(object.metadata, { key: 'metadata', @@ -946,6 +953,7 @@ export class AppDriver extends PuterDriver { index_url: app.index_url, background: Boolean(app.background), maximize_on_start: Boolean(app.maximize_on_start), + feedback_enabled: Boolean(app.feedback_enabled), godmode: Boolean(app.godmode), is_private: Boolean(app.is_private), protected: Boolean(app.protected), diff --git a/src/backend/services/feedback/AppFeedbackService.ts b/src/backend/services/feedback/AppFeedbackService.ts new file mode 100644 index 000000000..8bb6f4653 --- /dev/null +++ b/src/backend/services/feedback/AppFeedbackService.ts @@ -0,0 +1,299 @@ +/* + * 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 . + */ + +import { HttpError } from '../../core/http/HttpError.js'; +import { PuterService } from '../types.js'; + +/** + * User-to-developer app feedback ("send feedback to this app's developer"). + * + * Feedback is strictly opt-in per app via the `apps.feedback_enabled` column, + * which developers set through the regular `puter.apps.update` path + * (`feedbackEnabled` in puter.js). Submissions are stored in `app_feedback` and + * a copy is emailed to the app owner's confirmed email, subject to the caps + * below. + * + * Trust model: the submit endpoint only accepts user actors (never app tokens), + * so an app cannot submit feedback programmatically — every message passes + * through the GUI dialog (desktop) or the puter.com popup (external sites), + * i.e. through a page the user actually typed into. App identity is likewise + * never taken from the app: the desktop resolves it from its own process + * registry, and the popup resolves it from the browser-attested opener origin + * via AuthService.appUidFromOrigin. + * + * Abuse posture, layered: + * + * 1. Route rate limits (controller) — cheap first line, but the limiter fails open + * when its backend is down. + * 2. Durable DB-count caps (here) — per (user, app) and per user per day. These + * read the `app_feedback` table itself, so they hold across restarts and + * nodes, and fail closed with the insert. + * 3. Per-app daily email cap — bounds how much mail one app can generate to its + * owner regardless of how many distinct users submit. Feedback past the cap + * is still stored, just not emailed. + */ +export class AppFeedbackService extends PuterService { + /** Max feedback message length, in characters (after normalization). */ + static readonly MESSAGE_MAX_LENGTH = 4000; + /** Max feedback rows one user may create for one app per day. */ + static readonly PER_USER_APP_DAILY_LIMIT = 3; + /** Max feedback rows one user may create across all apps per day. */ + static readonly PER_USER_DAILY_LIMIT = 10; + /** Max owner emails one app may generate per day; rest is store-only. */ + static readonly PER_APP_DAILY_EMAIL_LIMIT = 20; + + /** + * Normalize a raw feedback message: unify newlines, strip control + * characters (except newline and tab), and trim. Returns the normalized + * string, or null when nothing usable remains. + */ + normalizeMessage(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const normalized = raw + .replace(/\r\n?/g, '\n') + // Strip C0 control chars (keeping \t and \n) and DEL. + + .replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '') + .trim(); + return normalized.length > 0 ? normalized : null; + } + + /** + * Resolve the target app for a feedback interaction. Exactly one of `app` + * (uid or name) / `origin` (external site origin, resolved through the same + * path token acquisition uses) must be provided. + * + * Returns the app row or null when no such app exists. Throws 400 on an + * unparseable origin. A blocked origin resolves to null rather than + * throwing — to a feedback caller "blocked" and "unknown" mean the same + * thing: nobody is accepting feedback there. + */ + async resolveTargetApp({ + app, + origin, + }: { + app?: string; + origin?: string; + }): Promise | null> { + if (app) { + return app.startsWith('app-') + ? await this.stores.app.getByUid(app) + : await this.stores.app.getByName(app); + } + if (origin) { + let uid; + try { + uid = await this.services.auth.appUidFromOrigin(origin); + } catch (e) { + if (e instanceof HttpError && e.legacyCode === 'app_blocked') { + return null; + } + throw e; + } + return await this.stores.app.getByUid(uid); + } + return null; + } + + /** + * Whether `app` (a row from AppStore) currently accepts user feedback: the + * developer opted in and the app has an owner to deliver to. + */ + acceptsFeedback(app: Record | null): boolean { + return Boolean(app && app.feedback_enabled && app.owner_user_id); + } + + /** + * Pre-flight for the feedback dialog: does this target accept feedback, and + * what should the dialog display? `app` fields are limited to what the + * dialog needs — `name` is included because it's unique and + * format-restricted, so the dialog can show it under the free-form title as + * an anti-impersonation measure. + */ + async getTarget(params: { app?: string; origin?: string }): Promise<{ + enabled: boolean; + app: { name: string; title: string } | null; + }> { + const app = await this.resolveTargetApp(params); + return { + enabled: this.acceptsFeedback(app), + app: app + ? { name: String(app.name), title: String(app.title) } + : null, + }; + } + + /** + * Store one feedback message and email it to the app's owner (best effort). + * Caller (controller) has already authenticated the user and validated the + * message's type and raw length; this method owns the business rules. + * + * @returns The stored row's public uid. + */ + async submit({ + userId, + app, + origin, + message, + sourceEnv, + sourceOrigin, + }: { + userId: number; + app?: string; + origin?: string; + message: string; + sourceEnv?: 'app' | 'web'; + sourceOrigin?: string | null; + }): Promise<{ uid: string }> { + const targetApp = await this.resolveTargetApp({ app, origin }); + if (!this.acceptsFeedback(targetApp)) { + throw new HttpError( + 403, + 'This app is not accepting feedback right now', + { legacyCode: 'feedback_not_enabled' }, + ); + } + const appId = Number(targetApp!.id); + const appUid = String(targetApp!.uid); + + const normalized = this.normalizeMessage(message); + if (!normalized) { + throw new HttpError(400, '`message` must not be empty', { + legacyCode: 'bad_request', + }); + } + if (normalized.length > AppFeedbackService.MESSAGE_MAX_LENGTH) { + throw new HttpError( + 400, + `\`message\` is too long (max ${AppFeedbackService.MESSAGE_MAX_LENGTH} characters)`, + { legacyCode: 'bad_request' }, + ); + } + + // Durable caps. Deliberately DB-backed (see class doc); the counts + // ride the (user_id, created_at) / (app_id, created_at) indexes. + const since = Math.floor(Date.now() / 1000) - 24 * 60 * 60; + const [userAppCount, userCount] = await Promise.all([ + this.stores.appFeedback.countByUserAndAppSince( + userId, + appId, + since, + ), + this.stores.appFeedback.countByUserSince(userId, since), + ]); + if ( + userAppCount >= AppFeedbackService.PER_USER_APP_DAILY_LIMIT || + userCount >= AppFeedbackService.PER_USER_DAILY_LIMIT + ) { + throw new HttpError( + 429, + 'You have sent a lot of feedback recently — please try again later', + { legacyCode: 'too_many_requests' }, + ); + } + + const row = await this.stores.appFeedback.create({ + appId, + appUid, + userId, + message: normalized, + sourceEnv: sourceEnv ?? null, + sourceOrigin: sourceOrigin ?? null, + }); + + // Email delivery is best-effort: any failure past this point must + // not fail the request — the feedback is already stored. + try { + await this.#emailOwner({ + app: targetApp!, + appId, + feedbackId: row.id, + message: normalized, + senderUserId: userId, + since, + }); + } catch (e) { + console.warn('[app-feedback] owner email failed:', e); + } + + return { uid: row.uid }; + } + + /** + * Deliver one feedback email to the app owner if every delivery + * precondition holds; otherwise silently skip (the row stays stored with + * `email_sent = 0`). Preconditions: transport configured, owner exists with + * a confirmed non-blocklisted email, owner not suspended and not + * unsubscribed, per-app daily email cap not reached. + */ + async #emailOwner({ + app, + appId, + feedbackId, + message, + senderUserId, + since, + }: { + app: Record; + appId: number; + feedbackId: number; + message: string; + senderUserId: number; + since: number; + }): Promise { + if (!this.clients.email.isConfigured) return; + + const owner = await this.stores.user.getById(Number(app.owner_user_id)); + if ( + !owner || + !owner.email || + !owner.email_confirmed || + owner.suspended || + Boolean(owner.unsubscribed) + ) { + return; + } + if (!(await this.clients.email.validate(owner.email))) return; + + const emailedToday = + await this.stores.appFeedback.countEmailedByAppSince(appId, since); + if (emailedToday >= AppFeedbackService.PER_APP_DAILY_EMAIL_LIMIT) { + return; + } + + const sender = await this.stores.user.getById(senderUserId); + + await this.clients.email.send(owner.email, 'app-user-feedback', { + owner_username: owner.username, + // The sender's username is already visible to the app itself + // (puter.auth.getUser), so surfacing it here discloses nothing + // new — and the dialog tells the user it will be shared. The + // sender's email is never included. + sender_username: sender?.username ?? 'A Puter user', + // Collapse whitespace so a crafted title can't break the + // subject header or spoof extra lines in the body. + app_title: String(app.title ?? app.name).replace(/\s+/g, ' '), + app_name: String(app.name), + app_link: `${this.config.origin}/app/${encodeURIComponent(String(app.name))}`, + message, + }); + + await this.stores.appFeedback.markEmailSent(feedbackId); + } +} diff --git a/src/backend/services/index.ts b/src/backend/services/index.ts index f91a48121..ce93cc308 100644 --- a/src/backend/services/index.ts +++ b/src/backend/services/index.ts @@ -27,6 +27,7 @@ import { AuthService } from './auth/AuthService'; import { OIDCService } from './auth/OIDCService'; import { TokenService } from './auth/TokenService'; import { BroadcastService } from './broadcast/BroadcastService'; +import { AppFeedbackService } from './feedback/AppFeedbackService'; import { FSService } from './fs/FSService'; import { ServerHealthService } from './health/ServerHealthService'; import { PuterHomepageService } from './homepage/PuterHomepageService'; @@ -61,6 +62,7 @@ declare module './types' { suggestedApps: SuggestedAppsService; socket: SocketService; notification: NotificationService; + appFeedback: AppFeedbackService; broadcast: BroadcastService; oidc: OIDCService; appIcon: AppIconService; @@ -99,6 +101,9 @@ export const puterServices = { suggestedApps: SuggestedAppsService, socket: SocketService, notification: NotificationService, + // Declared after `auth` (origin → app uid resolution happens through + // AuthService.appUidFromOrigin). + appFeedback: AppFeedbackService, broadcast: BroadcastService, oidc: OIDCService, appIcon: AppIconService, diff --git a/src/backend/stores/app/AppStore.js b/src/backend/stores/app/AppStore.js index 0124c04a5..ada84646d 100644 --- a/src/backend/stores/app/AppStore.js +++ b/src/backend/stores/app/AppStore.js @@ -127,6 +127,7 @@ const APP_BOOLEAN_COLUMNS = new Set([ 'background', 'protected', 'is_private', + 'feedback_enabled', ]); export class AppStore extends PuterStore { @@ -1017,6 +1018,7 @@ export class AppStore extends PuterStore { 'approved_for_listing', 'approved_for_opening_items', 'approved_for_incentive_program', + 'feedback_enabled', ]) { if (row[key] !== undefined) row[key] = Boolean(row[key]); } diff --git a/src/backend/stores/appFeedback/AppFeedbackStore.ts b/src/backend/stores/appFeedback/AppFeedbackStore.ts new file mode 100644 index 000000000..d02ff9982 --- /dev/null +++ b/src/backend/stores/appFeedback/AppFeedbackStore.ts @@ -0,0 +1,137 @@ +/* + * 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 . + */ + +import { v4 as uuidv4 } from 'uuid'; +import { PuterStore } from '../types'; + +/** One row of the `app_feedback` table. Timestamps are unix seconds. */ +export interface AppFeedbackRow { + id: number; + uid: string; + app_id: number; + app_uid: string; + user_id: number; + message: string; + source_env: string | null; + source_origin: string | null; + email_sent: boolean; + created_at: number; +} + +/** + * Persistence for user-to-developer app feedback (`app_feedback` table). + * + * The count methods back AppFeedbackService's durable abuse caps. They query + * the DB rather than a cache/limiter on purpose: the rate-limit middleware + * fails open when its backend is unreachable, and a feature that emails a third + * party needs limits that fail closed. Both counts are served by the (user_id, + * created_at) / (app_id, created_at) indexes. + */ +export class AppFeedbackStore extends PuterStore { + // -- Reads -------------------------------------------------------- + + /** Feedback rows this user submitted (any app) since `sinceUnixSeconds`. */ + async countByUserSince( + userId: number, + sinceUnixSeconds: number, + ): Promise { + const rows = await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `app_feedback` WHERE `user_id` = ? AND `created_at` >= ?', + [userId, sinceUnixSeconds], + ); + return Number(rows[0]?.n ?? 0); + } + + /** Feedback rows this user submitted for one app since `sinceUnixSeconds`. */ + async countByUserAndAppSince( + userId: number, + appId: number, + sinceUnixSeconds: number, + ): Promise { + const rows = await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `app_feedback` WHERE `user_id` = ? AND `app_id` = ? AND `created_at` >= ?', + [userId, appId, sinceUnixSeconds], + ); + return Number(rows[0]?.n ?? 0); + } + + /** + * Feedback rows for this app that were emailed to the owner since + * `sinceUnixSeconds`. Backs the per-app daily email cap. + */ + async countEmailedByAppSince( + appId: number, + sinceUnixSeconds: number, + ): Promise { + const rows = await this.clients.db.read( + 'SELECT COUNT(*) AS n FROM `app_feedback` WHERE `app_id` = ? AND `email_sent` = ? AND `created_at` >= ?', + [appId, this.clients.db.booleanValue(true), sinceUnixSeconds], + ); + return Number(rows[0]?.n ?? 0); + } + + // -- Writes ------------------------------------------------------- + + /** + * Insert one feedback row. `email_sent` starts false; the service flips it + * with {@link markEmailSent} after the owner email actually goes out, so the + * email cap only counts delivered mail. + */ + async create(fields: { + appId: number; + appUid: string; + userId: number; + message: string; + sourceEnv?: string | null; + sourceOrigin?: string | null; + }): Promise<{ id: number; uid: string }> { + const uid = uuidv4(); + const createdAt = Math.floor(Date.now() / 1000); + const result = await this.clients.db.write( + 'INSERT INTO `app_feedback` (`uid`, `app_id`, `app_uid`, `user_id`, `message`, `source_env`, `source_origin`, `email_sent`, `created_at`) ' + + `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)${this.clients.db.returningIdClause()}`, + [ + uid, + fields.appId, + fields.appUid, + fields.userId, + fields.message, + fields.sourceEnv ?? null, + fields.sourceOrigin ?? null, + this.clients.db.booleanValue(false), + createdAt, + ], + ); + const insertId = result?.insertId; + if (!insertId) { + throw new Error( + 'Failed to record app feedback — no insertId returned', + ); + } + return { id: Number(insertId), uid }; + } + + /** Record that the owner email for this row was sent. */ + async markEmailSent(id: number): Promise { + await this.clients.db.write( + 'UPDATE `app_feedback` SET `email_sent` = ? WHERE `id` = ?', + [this.clients.db.booleanValue(true), id], + ); + } +} diff --git a/src/backend/stores/index.ts b/src/backend/stores/index.ts index d5052c816..ccd6c9c19 100644 --- a/src/backend/stores/index.ts +++ b/src/backend/stores/index.ts @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +import { AppFeedbackStore } from './appFeedback/AppFeedbackStore.js'; import { AppStore } from './app/AppStore.js'; import { FSEntryStore } from './fs/FSEntryStore.js'; import { GroupStore } from './group/GroupStore.js'; @@ -45,6 +46,7 @@ declare module './types.js' { meteringBuffer: MeteringBufferStore; user: UserStore; app: AppStore; + appFeedback: AppFeedbackStore; fsEntry: FSEntryStore; s3Object: S3ObjectStore; subdomain: SubdomainStore; @@ -71,6 +73,7 @@ export const puterStores = { meteringBuffer: MeteringBufferStore, user: UserStore, app: AppStore, + appFeedback: AppFeedbackStore, fsEntry: FSEntryStore, s3Object: S3ObjectStore, subdomain: SubdomainStore, diff --git a/src/docs/src/Apps/create.md b/src/docs/src/Apps/create.md index 98c8df06d..e2155e511 100755 --- a/src/docs/src/Apps/create.md +++ b/src/docs/src/Apps/create.md @@ -48,6 +48,7 @@ An object containing the options for the app to create. The object can contain t - `filetypeAssociations` (Array) (optional): An array of strings representing the filetypes that the app can open. Defaults to `[]`. File extentions and MIME types are supported; For example, `[".txt", ".md", "application/pdf"]` would allow the app to open `.txt`, `.md`, and PDF files. - `dedupeName` (Boolean) (optional) - Whether to deduplicate the app name if it already exists. Defaults to `false`. - `background` (Boolean) (optional) - Whether the app should run in the background. Defaults to `false`. +- `feedbackEnabled` (Boolean) (optional) - Whether users can send feedback to you through [`puter.ui.showFeedbackDialog()`](/UI/showFeedbackDialog/). Defaults to `false`. - `metadata` (Object) (optional) - An object containing custom metadata for the app. This can be used to store arbitrary key-value pairs associated with the app. ## Return value diff --git a/src/docs/src/Apps/update.md b/src/docs/src/Apps/update.md index 765c64f5f..8d874f54c 100755 --- a/src/docs/src/Apps/update.md +++ b/src/docs/src/Apps/update.md @@ -24,6 +24,7 @@ An object containing the attributes to update. The object can contain the follow - `icon` (optional): The new icon of the app. - `maximizeOnStart` (optional): Whether the app should be maximized when it is started. Defaults to `false`. - `background` (optional): Whether the app should run in the background. Defaults to `false`. +- `feedbackEnabled` (optional): Whether users can send feedback to you through [`puter.ui.showFeedbackDialog()`](/UI/showFeedbackDialog/). Defaults to `false`. - `filetypeAssociations` (optional): An array of strings representing the filetypes that the app can open. Defaults to `[]`. File extentions and MIME types are supported; For example, `[".txt", ".md", "application/pdf"]` would allow the app to open `.txt`, `.md`, and PDF files. - `metadata` (optional): An object containing custom metadata for the app. This can be used to store arbitrary key-value pairs associated with the app. diff --git a/src/docs/src/UI.md b/src/docs/src/UI.md index 62e3e5bcd..d5d61edb1 100644 --- a/src/docs/src/UI.md +++ b/src/docs/src/UI.md @@ -14,6 +14,7 @@ The UI API provides a comprehensive set of tools for creating rich user interfac - **[`puter.ui.alert()`](/UI/alert/)** - Show alert dialogs - **[`puter.ui.notify()`](/UI/notify/)** - Show desktop notifications - **[`puter.ui.prompt()`](/UI/prompt/)** - Show input prompts +- **[`puter.ui.showFeedbackDialog()`](/UI/showFeedbackDialog/)** - Let the user send feedback to your app's developer ### Window Management - **[`puter.ui.createWindow()`](/UI/createWindow/)** - Create new windows diff --git a/src/docs/src/UI/showFeedbackDialog.md b/src/docs/src/UI/showFeedbackDialog.md new file mode 100644 index 000000000..134236d5d --- /dev/null +++ b/src/docs/src/UI/showFeedbackDialog.md @@ -0,0 +1,47 @@ +--- +title: puter.ui.showFeedbackDialog() +description: Opens a dialog the user can use to send feedback to your app. +platforms: [ websites, apps] +--- + +Opens a dialog the user can use to send you — the app's developer — feedback about your app. The message is delivered by Puter: it is stored and emailed to the email address on your Puter account. The feedback never passes through your app's code, and the dialog tells the user their username will be shared with you so you can follow up. + +Inside Puter, the dialog is rendered by the desktop environment. On a website, a puter.com popup hosts the dialog (signing the user in first if needed). + +**Feedback is opt-in.** Users can only send feedback if you've enabled it for your app by setting `feedbackEnabled`: + +```js +await puter.apps.update('my-app', { feedbackEnabled: true }); +``` + +If feedback isn't enabled, the dialog tells the user the app isn't accepting feedback. To protect you and your users, Puter enforces limits on the size and frequency of feedback messages. + +## Syntax +```js +puter.ui.showFeedbackDialog() +``` + +## Parameters +None. + +## Return value +A `Promise` that resolves to `true` if the user submitted feedback, and `false` if the dialog was dismissed or feedback is unavailable. It never rejects. + +## Examples + +```html;ui-show-feedback-dialog + + + + + + + +``` diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js index 19e788bca..3417d4670 100755 --- a/src/docs/src/sidebar.js +++ b/src/docs/src/sidebar.js @@ -942,6 +942,14 @@ let sidebar = [ source: '/UI/showDirectoryPicker.md', path: '/UI/showDirectoryPicker', }, + { + title: 'showFeedbackDialog()', + page_title: 'puter.ui.showFeedbackDialog()', + title_tag: 'puter.ui.showFeedbackDialog()', + icon: '/assets/img/function.svg', + source: '/UI/showFeedbackDialog.md', + path: '/UI/showFeedbackDialog', + }, { title: 'showFontPicker()', page_title: 'puter.ui.showFontPicker()', diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index dddc39adc..13f5bc225 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -28,6 +28,7 @@ import UIItem from './UI/UIItem.js'; import UIPopover from './UI/UIPopover.js'; import UIPrompt from './UI/UIPrompt.js'; import UIWindow from './UI/UIWindow.js'; +import UIWindowAppFeedback from './UI/UIWindowAppFeedback.js'; import UIWindowColorPicker from './UI/UIWindowColorPicker.js'; import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js'; import UIWindowFontPicker from './UI/UIWindowFontPicker.js'; @@ -1384,6 +1385,58 @@ const ipc_listener = async (event, handled) => { $(target_iframe).get(0)?.focus({ preventScroll: true }); } //-------------------------------------------------------- + // showFeedbackDialog + //-------------------------------------------------------- + else if ( event.data.msg === 'showFeedbackDialog' ) { + // Always respond, even on failure, so the SDK's promise settles + // instead of hanging forever. The app can close its own window while + // the dialog is up, which tears down the iframe — posting into it + // must not throw. + const respond = (sent) => { + target_iframe?.contentWindow?.postMessage({ + msg: 'feedbackDialogClosed', + sent: sent === true, + original_msg_id: msg_id, + }, '*'); + }; + + // auth + try { + if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) ) + { + respond(false); + return; + } + } catch ( e ) { + // `ipc_listener` has no outer catch, so a throw from the signup + // window would escape before any reply and hang the app. + console.error('IPC showFeedbackDialog: auth gate failed', e); + respond(false); + return; + } + + let sent = false; + try { + // The target app is this message's sender — identified by the + // GUI's own registry (`data-app_uuid` on the window that owns the + // validated source iframe), never by anything in the message, so + // an app can only ever open the feedback dialog for itself. + sent = await UIWindowAppFeedback({ + app: app_uuid || app_name, + source: 'app', + window_options: { + parent_uuid: event.data.appInstanceID, + disable_parent_window: true, + }, + }); + } catch ( e ) { + console.error('IPC showFeedbackDialog failed', e); + } + + respond(sent === true); + $(target_iframe).get(0)?.focus({ preventScroll: true }); + } + //-------------------------------------------------------- // showFontPicker //-------------------------------------------------------- else if ( event.data.msg === 'showFontPicker' ) { diff --git a/src/gui/src/UI/UIWindowAppFeedback.js b/src/gui/src/UI/UIWindowAppFeedback.js new file mode 100644 index 000000000..3998cb082 --- /dev/null +++ b/src/gui/src/UI/UIWindowAppFeedback.js @@ -0,0 +1,254 @@ +/* + * 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 . + */ + +import UIWindow from './UIWindow.js'; + +// Keep in sync with AppFeedbackService.MESSAGE_MAX_LENGTH on the backend. +const MESSAGE_MAX_LENGTH = 4000; +const SUCCESS_AUTOCLOSE_MS = 1600; + +/** + * "Send feedback to this app's developer" dialog, behind + * `puter.ui.showFeedbackDialog()`. Not to be confused with UIWindowFeedback, + * which is Puter's own Contact Us form. + * + * The target is named by exactly one of `options.app` (app uid or name — the + * desktop IPC path knows which app asked) or `options.origin` (the popup + * path's browser-attested opener origin). The dialog pre-flights the target + * against `GET /app-feedback/target` — feedback is opt-in per app, and the + * server is the authority on the app's canonical title — then submits to + * `POST /app-feedback`. + * + * @param {{ + * app?: string, + * origin?: string, + * source?: 'app' | 'web', + * window_options?: object, + * }} options + * @returns {Promise} true iff feedback was submitted successfully. + * Resolves false on cancel/close/unavailable — never rejects, so IPC and + * popup callers can always report an answer. + */ +async function UIWindowAppFeedback (options) { + options = options ?? {}; + + return new Promise((resolve) => { + let settled = false; + let sending = false; + let el_window; + const settle = (sent) => { + if ( settled ) return; + settled = true; + resolve(sent === true); + }; + + // The setup below is async; a synchronous executor with this backstop + // guarantees the promise settles even if UIWindow (or anything else + // before the on_close handler is wired) throws — the IPC caller + // awaits this promise and must always get an answer. + (async () => { + const authToken = puter.authToken ?? window.auth_token; + const target_params = options.app + ? { app: options.app } + : { origin: options.origin }; + + let h = ''; + h += '
'; + // loading pane + h += `
${i18n('loading')}…
`; + // unavailable / error pane + h += ''; + // form pane + h += ''; + // success pane + h += ''; + h += '
'; + + el_window = await UIWindow({ + title: i18n('app_feedback_title'), + icon: null, + uid: null, + is_dir: false, + body_content: h, + has_head: true, + selectable_body: false, + draggable_body: false, + allow_context_menu: false, + is_resizable: false, + is_droppable: false, + init_center: true, + allow_native_ctxmenu: false, + allow_user_select: false, + width: 380, + height: 'auto', + dominant: true, + show_in_taskbar: false, + ...options.window_options, + on_close: () => { + $(document).off(`keydown.app-feedback-${win_id}`); + settle(false); + }, + window_class: 'window-app-feedback', + body_css: { + width: 'initial', + height: '100%', + 'background-color': 'rgb(245 247 249)', + 'backdrop-filter': 'blur(3px)', + }, + }); + const win_id = $(el_window).attr('data-id'); + + const showPane = (pane) => { + $(el_window).find('.app-feedback-loading, .app-feedback-unavailable, .app-feedback-form, .app-feedback-success').hide(); + $(el_window).find(`.app-feedback-${pane}`).show(); + }; + const showUnavailable = (messageKey) => { + $(el_window).find('.app-feedback-unavailable-message').text(i18n(messageKey)); + showPane('unavailable'); + }; + + // Escape closes unless a submit is in flight. The global Escape + // handler in keyboard.js skips windows while a textarea has focus, + // so the dialog needs its own (namespaced, removed in on_close). + $(document).on(`keydown.app-feedback-${win_id}`, (e) => { + if ( e.key !== 'Escape' || sending ) return; + if ( ! $(el_window).hasClass('window-active') ) return; + $(el_window).close(); + }); + + $(el_window).find('.app-feedback-close-btn, .app-feedback-cancel-btn').on('click', () => { + $(el_window).close(); + }); + + $(el_window).find('.app-feedback-message').on('input', function () { + $(el_window).find('.app-feedback-counter').text(`${this.value.length} / ${MESSAGE_MAX_LENGTH}`); + $(el_window).find('.app-feedback-send-btn').prop('disabled', sending || this.value.trim() === ''); + }); + + // -- Pre-flight: is this app accepting feedback, and what is it + // called? The server is the authority — a title passed by the caller + // could impersonate another app. + try { + const res = await fetch(`${window.api_origin}/app-feedback/target?${new URLSearchParams(target_params)}`, { + headers: { 'Authorization': `Bearer ${authToken}` }, + }); + if ( ! res.ok ) throw new Error(`target check responded ${res.status}`); + const target = await res.json(); + if ( ! target.enabled ) { + showUnavailable('app_feedback_not_available'); + return; + } + $(el_window).find('.app-feedback-target-title').text(target.app?.title ?? ''); + $(el_window).find('.app-feedback-target-name').text(target.app?.name ?? ''); + showPane('form'); + $(el_window).find('.app-feedback-message').get(0)?.focus({ preventScroll: true }); + } catch ( e ) { + console.error('app-feedback: target check failed', e); + showUnavailable('app_feedback_error'); + return; + } + + const send = async () => { + const $btn = $(el_window).find('.app-feedback-send-btn'); + const message = String($(el_window).find('.app-feedback-message').val() || '').trim(); + if ( ! message || sending ) return; + sending = true; + $btn.prop('disabled', true); + $(el_window).find('.app-feedback-error').hide(); + try { + const res = await fetch(`${window.api_origin}/app-feedback`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ + ...target_params, + message, + context: options.source, + }), + }); + if ( ! res.ok ) { + let code; + try { + code = (await res.json())?.code; + } catch ( _e ) { + // Non-JSON error body; fall through to the generic message. + } + if ( code === 'feedback_not_enabled' ) { + showUnavailable('app_feedback_not_available'); + return; + } + const key = res.status === 429 ? 'app_feedback_rate_limited' : 'app_feedback_error'; + $(el_window).find('.app-feedback-error').text(i18n(key)).show(); + return; + } + showPane('success'); + settle(true); + setTimeout(() => $(el_window).close(), SUCCESS_AUTOCLOSE_MS); + } catch ( e ) { + // Shown inside the form so the message survives for a retry. + console.error('app-feedback: submit failed', e); + $(el_window).find('.app-feedback-error').text(i18n('app_feedback_error')).show(); + } finally { + sending = false; + $(el_window).find('.app-feedback-send-btn').prop('disabled', + settled || String($(el_window).find('.app-feedback-message').val() || '').trim() === ''); + } + }; + + $(el_window).find('.app-feedback-send-btn').on('click', send); + // Enter alone belongs to the textarea (feedback may need paragraphs). + $(el_window).find('.app-feedback-message').on('keydown', (e) => { + if ( e.key === 'Enter' && (e.metaKey || e.ctrlKey) ) { + e.preventDefault(); + send(); + } + }); + })().catch((e) => { + console.error('app-feedback: dialog failed to open', e); + try { $(el_window).close(); } catch ( _e ) {} + settle(false); + }); + }); +} + +export default UIWindowAppFeedback; diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index a37c00a48..0a3ca51d0 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -5872,6 +5872,10 @@ html.dark-mode .usage-table-show-less:hover { height: initial !important; } +.device-phone .window.window-app-feedback { + height: initial !important; +} + .device-phone .window.window-filedialog { transform: none; width: 100% !important; diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index ef31a5a81..18952d664 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -42,6 +42,14 @@ const en = { ai_app_unavailable: 'AI app is not available. Please try again later.', all_fields_required: 'All fields are required.', allow: 'Allow', + app_feedback_c2a: 'Your feedback will be sent directly to the developer of this app.', + app_feedback_error: 'Something went wrong. Please try again.', + app_feedback_not_available: 'This app is not accepting feedback right now.', + app_feedback_placeholder: 'What is working well? What could be better?', + app_feedback_privacy_note: 'Your username will be shared with the developer so they can follow up.', + app_feedback_rate_limited: "You've sent a lot of feedback recently. Please try again later.", + app_feedback_sent: 'Feedback sent. Thank you!', + app_feedback_title: 'Send Feedback', app_group_default_name: 'Folder', app_group_name_aria: 'Folder name', app_group_open: 'Open', diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index afeae91f1..3ca73b678 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -23,6 +23,7 @@ import UIAlert from './UI/UIAlert.js'; import UIComponentWindow from './UI/UIComponentWindow.js'; import UIDesktop from './UI/UIDesktop.js'; import UIWindow from './UI/UIWindow.js'; +import UIWindowAppFeedback from './UI/UIWindowAppFeedback.js'; import UIWindowAuthMe from './UI/UIWindowAuthMe.js'; import UIWindowChangeUsername from './UI/UIWindowChangeUsername.js'; import UIWindowCopyToken from './UI/UIWindowCopyToken.js'; @@ -735,6 +736,66 @@ const postAuthActions = async (action) => { window.open('', '_self').close(); } } + + // ------------------------------------------------------------------------------------- + // Action: Send Feedback — show the app-feedback dialog for the site that opened this + // popup and report whether feedback was sent back to the opener. Runs post-auth so + // signed-out users go through sign-in/signup first. + // ------------------------------------------------------------------------------------- + if ( action === 'send-feedback' ) { + const msg_id = window.url_query_params.get('msg_id'); + // Browser-attested only, same rule as request-permission above: the + // origin names the app the feedback is recorded against (the server + // resolves origin → app), so a link must not get to state it. The + // dialog itself refuses when the resolved app hasn't opted in. + const origin = window.openerOrigin; + + // Whatever happens, the requester must get an answer and the popup + // must close — otherwise it wedges open with the caller's promise + // pending until the user closes it by hand. + let sent = false; + try { + if ( ! origin ) { + throw new Error('no opener origin; not prompting'); + } + sent = await UIWindowAppFeedback({ + origin, + source: 'web', + window_options: { + has_head: false, + cover_page: true, + }, + }); + } catch (e) { + console.error('send-feedback action failed', e); + } + + // `postMessage` throws a SyntaxError on a targetOrigin that isn't a + // parseable URL — an unparseable one would take out the answer *and* + // the close below. + let target_origin = '*'; + try { + target_origin = origin ? new URL(origin).origin : '*'; + } catch (e) { + console.error('send-feedback: unusable origin', origin); + } + const messageTarget = window.embedded_in_popup ? window.opener : window.parent; + try { + messageTarget?.postMessage({ + msg: 'feedbackDialogClosed', + sent: sent === true, + original_msg_id: msg_id, + }, target_origin); + } catch (e) { + console.error('send-feedback: could not answer the requester', e); + } + + // The popup exists only to host this dialog; close it once answered. + if ( window.embedded_in_popup ) { + window.close(); + window.open('', '_self').close(); + } + } }; const launch_services = async function (options) { diff --git a/src/gui/src/util/popupAuth.js b/src/gui/src/util/popupAuth.js index 81b3b9ff7..f99acf83f 100644 --- a/src/gui/src/util/popupAuth.js +++ b/src/gui/src/util/popupAuth.js @@ -34,7 +34,7 @@ */ /** Popup actions that exist to answer a question, not to authenticate. */ -const NON_AUTH_POPUP_ACTIONS = new Set(['request-permission']); +const NON_AUTH_POPUP_ACTIONS = new Set(['request-permission', 'send-feedback']); /** * Whether a popup running `action` may post `puter.token` to its opener. diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 5a52c8f1a..efaa65aa4 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -1671,6 +1671,157 @@ class UI extends EventListener { }); }; + /** + * Opens a dialog the user can use to send feedback to this app's + * developer. The message is delivered by Puter (stored and emailed to the + * developer); it never passes through the app. Requires the developer to + * have opted in by setting `feedbackEnabled` on the app — otherwise the + * dialog tells the user feedback is unavailable. + * + * In the `app` environment the Puter desktop renders the dialog; in the + * `web` environment a puter.com popup hosts it (signing the user in first + * if needed). Every other environment resolves `false`. + * + * @returns {Promise} `true` when the user submitted feedback, + * `false` when the dialog was dismissed or feedback is unavailable. + * Never rejects. + */ + async showFeedbackDialog () { + if ( this.env === 'app' ) { + const result = await this.#postMessageAsync('showFeedbackDialog', {}); + return result?.sent === true; + } + + // The popup flow is for third-party websites only. In every other + // environment it either can't work (workers and node have no window + // to open a popup from) or makes no sense. Those callers resolve + // false rather than reject — dialogs are resolve-only. + if ( this.env !== 'web' ) { + return false; + } + if ( ! globalThis.open || ! globalThis.document ) { + return false; + } + + // See requestPermission: canonical-to-canonical origin comparison. A + // configured origin that can't parse can't host the dialog at all. + let gui_origin; + try { + gui_origin = new URL(puter.defaultGUIOrigin).origin; + } catch (e) { + return false; + } + + // How long to wait, after the popup is observed closed, for a result + // message that may still be in flight. + const CLOSE_GRACE_MS = 1000; + + return new Promise((resolve) => { + // Unique per request and not reused across page loads — same + // stale-popup collision reasoning as requestPermission. The app's + // identity is deliberately NOT in this URL: the GUI derives it + // from the browser-attested opener origin, so a link can't open a + // feedback dialog in another app's name. + const msg_id = `${this.#messageID++}-${Math.random().toString(36).slice(2, 10)}`; + const url = `${gui_origin}/action/send-feedback?embedded_in_popup=true&msg_id=${encodeURIComponent(msg_id)}`; + + // Guards against settling more than once across the message, + // popup-closed, and dialog-cancel code paths. + let settled = false; + let checkClosed = null; + let popupWindow = null; + let consentDialog = null; + + const cleanup = () => { + if ( checkClosed ) { + clearInterval(checkClosed); + checkClosed = null; + } + window.removeEventListener('message', messageHandler); + consentDialog?.remove(); + consentDialog = null; + }; + + const settle = (sent) => { + if ( settled ) return; + settled = true; + cleanup(); + resolve(sent === true); + }; + + const messageHandler = (e) => { + // Only accept the result from the Puter GUI origin AND from + // the popup we opened; msg_id binds it to this request. The + // GUI echoes msg_id back as a string, which the loose `!=` + // compares correctly. + if ( e.origin !== gui_origin ) return; + if ( popupWindow && e.source !== popupWindow ) return; + if ( e.data?.original_msg_id != msg_id ) return; + if ( e.data?.msg !== 'feedbackDialogClosed' ) return; + settle(e.data.sent === true); + }; + window.addEventListener('message', messageHandler); + + const watchPopup = (popup) => { + if ( settled ) return; + if ( ! popup ) { + settle(false); + return; + } + // Pin the expected event.source before anything can return + // early. + popupWindow = popup; + // A severed opener relationship (COOP) means the popup can't + // post the result back and `popup.closed` tells us nothing. + // Unlike a permission grant, a feedback submission can't be + // read back from the server, so the outcome is unknowable + // here: report false now rather than hang. The popup stays + // open — the user can still send their feedback. + if ( window.crossOriginIsolated || popup.closed ) { + settle(false); + return; + } + checkClosed = setInterval(() => { + if ( ! popup.closed ) return; + clearInterval(checkClosed); + checkClosed = null; + // The GUI posts the result and then closes the popup, and + // cross-process postMessage delivery is not ordered + // relative to `closed` becoming true — give an in-flight + // result its grace period before treating the close as a + // dismissal. + setTimeout(() => settle(false), CLOSE_GRACE_MS); + }, 100); + }; + + // Every path out of here resolves a boolean, so anything that + // throws while launching has to resolve false rather than reject. + try { + if ( hasUserActivation() ) { + // Unique window name per request: window.open() reuses a + // window with the same name, which would hijack a popup an + // earlier, still-pending request is waiting on. + watchPopup(openAuthPopup(url, `puter-feedback-${msg_id}`)); + } else { + // No user gesture: a popup opened now would be blocked. + // Show a consent dialog first; the popup is then opened + // from the user's click on it. + const dialog = new PuterDialog(() => {}, () => {}, { + popupURL: url, + popupName: `puter-feedback-${msg_id}`, + onLaunch: (popup) => watchPopup(popup), + onCancel: () => settle(false), + }); + consentDialog = dialog; + document.body.appendChild(dialog); + dialog.open(); + } + } catch (e) { + settle(false); + } + }); + }; + /** * Greys out a menubar item so it cannot be clicked. * diff --git a/src/puter-js/src/modules/apps/lib/appObject.js b/src/puter-js/src/modules/apps/lib/appObject.js index c91035e16..320bd89d2 100644 --- a/src/puter-js/src/modules/apps/lib/appObject.js +++ b/src/puter-js/src/modules/apps/lib/appObject.js @@ -26,4 +26,5 @@ export const toAppObject = (raw) => ({ background: raw.background, filetype_associations: raw.filetypeAssociations, metadata: raw.metadata, + feedback_enabled: raw.feedbackEnabled, }); diff --git a/src/puter-js/tests/e2e/fixtures/send-feedback.html b/src/puter-js/tests/e2e/fixtures/send-feedback.html new file mode 100644 index 000000000..8e6a8fa77 --- /dev/null +++ b/src/puter-js/tests/e2e/fixtures/send-feedback.html @@ -0,0 +1,63 @@ + + + + + puter-js test fixture: showFeedbackDialog + + + +

puter-js showFeedbackDialog fixture

+

Loading puter.js…

+ + + +
+ + + + + + diff --git a/src/puter-js/tests/e2e/specs/showFeedbackDialog.spec.js b/src/puter-js/tests/e2e/specs/showFeedbackDialog.spec.js new file mode 100644 index 000000000..3bbbcd770 --- /dev/null +++ b/src/puter-js/tests/e2e/specs/showFeedbackDialog.spec.js @@ -0,0 +1,146 @@ +import { test, expect } from '@playwright/test'; +import { registerTestApp, deleteTestApp, gotoTestApp, waitForPuterReady, FIXTURE_URL } from '../helpers/testApp.js'; + +const FEEDBACK_FIXTURE_URL = FIXTURE_URL.replace( + 'menubar-contextmenu.html', + 'send-feedback.html', +); + +/** Flips the app's opt-in flag through the same path developers use. */ +async function setFeedbackEnabled (page, appName, enabled) { + await page.goto('/'); + await waitForPuterReady(page); + const result = await page.evaluate( + async ({ name, value }) => { + try { + const app = await window.puter.apps.update(name, { feedbackEnabled: value }); + return { ok: true, feedback_enabled: app.feedback_enabled }; + } catch (e) { + return { ok: false, error: String(e?.message ?? e) }; + } + }, + { name: appName, value: enabled }, + ); + if ( ! result.ok ) { + throw new Error(`apps.update({ feedbackEnabled }) failed: ${result.error}`); + } + // The flag round-trips through the driver's client serialization, so a + // wrong value here means the opt-in never landed and every assertion + // downstream would test the unavailable pane instead. + if ( result.feedback_enabled !== enabled ) { + throw new Error(`feedback_enabled is ${result.feedback_enabled}, expected ${enabled}`); + } +} + +test.describe('puter.ui.showFeedbackDialog (env=app)', () => { + test('submitting feedback resolves true', async ({ page }) => { + const appName = await registerTestApp(page, { fixtureURL: FEEDBACK_FIXTURE_URL }); + try { + await setFeedbackEnabled(page, appName, true); + const appFrame = await gotoTestApp(page, appName); + + await appFrame.locator('#send-feedback').click(); + const dialog = page.locator('.window.window-app-feedback'); + await expect(dialog).toBeVisible(); + + // The form pane only appears after the server-side target check + // confirms the app opted in; the app is named by its canonical + // server-known title and unique name (anti-impersonation). + const form = dialog.locator('.app-feedback-form'); + await expect(form).toBeVisible({ timeout: 15_000 }); + await expect(dialog.locator('.app-feedback-target-name')).toHaveText(appName); + + // Nothing to send until something has been written. + const sendBtn = dialog.locator('.app-feedback-send-btn'); + await expect(sendBtn).toBeDisabled(); + await dialog.locator('.app-feedback-message').fill('The new editor is great!'); + await expect(sendBtn).toBeEnabled(); + + await sendBtn.click(); + await expect(dialog.locator('.app-feedback-success')).toBeVisible(); + await expect(appFrame.locator('#log [data-entry="feedback:true"]')).toBeVisible(); + // The dialog closes itself shortly after the success pane. + await expect(dialog).toBeHidden({ timeout: 10_000 }); + } finally { + await deleteTestApp(page, appName); + } + }); + + test('cancel resolves false', async ({ page }) => { + const appName = await registerTestApp(page, { fixtureURL: FEEDBACK_FIXTURE_URL }); + try { + await setFeedbackEnabled(page, appName, true); + const appFrame = await gotoTestApp(page, appName); + + await appFrame.locator('#send-feedback').click(); + const dialog = page.locator('.window.window-app-feedback'); + await expect(dialog.locator('.app-feedback-form')).toBeVisible({ timeout: 15_000 }); + await dialog.locator('.app-feedback-cancel-btn').click(); + + await expect(appFrame.locator('#log [data-entry="feedback:false"]')).toBeVisible(); + await expect(dialog).toBeHidden(); + } finally { + await deleteTestApp(page, appName); + } + }); + + test('an app that has not opted in gets the unavailable notice', async ({ page }) => { + const appName = await registerTestApp(page, { fixtureURL: FEEDBACK_FIXTURE_URL }); + try { + const appFrame = await gotoTestApp(page, appName); + + await appFrame.locator('#send-feedback').click(); + const dialog = page.locator('.window.window-app-feedback'); + await expect(dialog.locator('.app-feedback-unavailable')).toBeVisible({ timeout: 15_000 }); + // No form to type into — feedback is strictly opt-in. + await expect(dialog.locator('.app-feedback-form')).toBeHidden(); + await dialog.locator('.app-feedback-close-btn').click(); + + await expect(appFrame.locator('#log [data-entry="feedback:false"]')).toBeVisible(); + } finally { + await deleteTestApp(page, appName); + } + }); +}); + +test.describe('puter.ui.showFeedbackDialog (env=gui)', () => { + test('resolves false on the Puter desktop itself', async ({ page }) => { + await page.goto('/'); + await waitForPuterReady(page); + const sent = await page.evaluate(() => window.puter.ui.showFeedbackDialog()); + expect(sent).toBe(false); + }); +}); + +test.describe('puter.ui.showFeedbackDialog (env=web popup)', () => { + test('popup hosts the dialog, reports false on close, and never hands the opener a token', async ({ page }) => { + // Prime the GUI session (storageState sign-in) so the popup is authed. + await page.goto('/'); + await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 }); + + // Load the fixture directly (env=web, third-party origin, signed out). + await page.goto(FEEDBACK_FIXTURE_URL); + await page.locator('body.ready').waitFor({ timeout: 60_000 }); + + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('#send-feedback').click(), + ]); + + // The popup renders the feedback dialog for whichever app the + // browser-attested opener origin resolves to. On a shared dev DB + // that app is not deterministic, so this asserts the dialog shell + // rather than a specific pane. + const dialog = popup.locator('.window.window-app-feedback'); + await expect(dialog).toBeVisible({ timeout: 60_000 }); + + // Closing the popup without submitting reports a dismissal. + await popup.close(); + await expect(page.locator('#log [data-entry="feedback:false"]')).toBeVisible({ timeout: 15_000 }); + + // send-feedback is a NON_AUTH popup action: hosting the dialog must + // not have signed the site in as a side effect. + const authToken = await page.evaluate(() => window.puter.authToken ?? null); + expect(authToken).toBeNull(); + }); +}); diff --git a/src/puter-js/types/modules/apps.d.ts b/src/puter-js/types/modules/apps.d.ts index 657b163a8..0e695a87f 100644 --- a/src/puter-js/types/modules/apps.d.ts +++ b/src/puter-js/types/modules/apps.d.ts @@ -41,6 +41,11 @@ export interface App { maximize_on_start?: boolean; /** Whether the app should run in the background. Default is `false`. */ background?: boolean; + /** + * Whether users can send feedback to the app's developer through + * `puter.ui.showFeedbackDialog()`. Default is `false`. + */ + feedback_enabled?: boolean; /** * The file types that the app can open. Each string is in the format * `"."` or `"mime/type"`, e.g. `[".txt", "image/png"]`. For a @@ -129,6 +134,11 @@ export interface CreateAppOptions { maximizeOnStart?: boolean; /** Whether the app should run in the background. Defaults to `false`. */ background?: boolean; + /** + * Whether users can send feedback to the app's developer through + * `puter.ui.showFeedbackDialog()`. Defaults to `false`. + */ + feedbackEnabled?: boolean; /** * The filetypes that the app can open. File extensions and MIME types are * supported, e.g. `[".txt", ".md", "application/pdf"]`. Defaults to `[]`. @@ -159,6 +169,11 @@ export interface UpdateAppAttributes { maximizeOnStart?: boolean; /** Whether the app should run in the background. Defaults to `false`. */ background?: boolean; + /** + * Whether users can send feedback to the app's developer through + * `puter.ui.showFeedbackDialog()`. Defaults to `false`. + */ + feedbackEnabled?: boolean; /** * The filetypes that the app can open. File extensions and MIME types are * supported, e.g. `[".txt", ".md", "application/pdf"]`. Defaults to `[]`. diff --git a/src/puter-js/types/modules/ui.d.ts b/src/puter-js/types/modules/ui.d.ts index ec304ec6f..8a83bd9ec 100644 --- a/src/puter-js/types/modules/ui.d.ts +++ b/src/puter-js/types/modules/ui.d.ts @@ -317,6 +317,15 @@ export class UI { * origin. Resolves to `true` only if the permission was granted. */ requestPermission (options: { permission: string }): Promise; + /** + * Opens a dialog the user can use to send feedback to this app's developer. + * The message is stored and emailed to the developer by Puter; it never + * passes through the app. Requires the developer to have opted in by + * setting `feedbackEnabled` on the app. Inside the Puter GUI the dialog is + * shown on the desktop; on the web it opens a popup on the Puter origin. + * Resolves to `true` only if the user submitted feedback; never rejects. + */ + showFeedbackDialog (): Promise; /** * Presents a directory picker for the user's Puter cloud storage. Resolves to * one `FSItem` or an array of `FSItem` objects depending on selection count.