Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 734715f461 Merge branch 'feat/app-user-feedback' of https://github.com/HeyPuter/puter into feat/app-user-feedback 2026-08-12 13:32:34 -07:00
jelveh b290c1d922 Merge remote-tracking branch 'origin/main' into feat/app-user-feedback 2026-08-12 09:22:00 -07:00
jelveh dd068e31d0 Require verified users for app feedback API
Add `requireVerified: true` to both app feedback routes (`GET /target` and `POST /`) in `AppFeedbackController`. This tightens access control so only verified user accounts can fetch feedback targets or submit app feedback.
2026-08-12 09:13:11 -07:00
jelveh 90cffbfcde refactor: prep the feedback cap error once instead of via a factory
Both throw sites are in one call and only one can ever run, so a plain
const reads the same and drops a function that existed only to defer a
constructor.
2026-08-11 22:11:24 -07:00
jelveh 25acf14c8b test: cover the feedback service and store layers directly
The service owns every feedback business rule — target resolution,
eligibility, message normalization, the durable caps, and the owner-email
preconditions — but was only reachable through the controller's tests. Give
it and the store their own suites so a regression names the layer it broke.

Service coverage adds the branches the route tests could not reach: a
blocked origin resolving to null rather than surfacing a 403, owners who are
suspended or unsubscribed, length measured after normalization, the 24h cap
window boundary, subject-header injection via the app title, and the email
links being rooted at config.origin.

The two describes already labelled `AppFeedbackService ...` move out of the
controller test, which keeps only the caller-facing promise that a failed
send still returns success.
2026-08-11 22:10:04 -07:00
Nariman Jelveh be781c5f1b fix: restore the feedback Send button's gradient on hover
1c2d33f14 added :not(:disabled) to the base .app-feedback-btn hover
rule, which raised its specificity above the primary button's hover rule
— so hovering Send swapped the blue gradient for the light hover
background, leaving white text on a pale pill. Give the primary hover
rule the same :not(:disabled) so it wins by source order again.
2026-08-11 19:57:56 -07:00
Nariman Jelveh 4cb0730875 feat: point the feedback email footer at the Dev Center
The footer told developers to turn feedback off via a puter.apps.update
one-liner, but the toggle lives in the Dev Center app settings — and
Dev Center is where apps get feedback enabled by default in the first
place. Reword it to 'manage it in the Dev Center' with a link built
from config.origin (like app_link) so it holds on self-hosted
deployments.
2026-08-11 19:19:38 -07:00
Nariman Jelveh 00b2fcdea3 docs: document the COOP false-resolve limitation of showFeedbackDialog
Under COOP the popup's opener link is severed, so the SDK deliberately
resolves false while the popup stays open and the user can still submit
(a feedback submission has no server read-back the way a permission
grant does). The documented contract ('resolves to true if the user
submitted feedback') was silently wrong on cross-origin-isolated pages —
state the limitation in the doc and the SDK jsdoc: false means 'not
confirmed', not 'not sent'.
2026-08-11 18:31:39 -07:00
Nariman Jelveh f70347b579 docs: state exactly what showFeedbackDialog shares with the developer
The doc claimed the dialog 'tells the user their username will be
shared', while the dialog's note talked only about the email address and
the implementation shares the username always plus the email (as
Reply-To) only when verified. Describe the actual disclosure: username
always, email when verified.
2026-08-11 18:31:02 -07:00
Nariman Jelveh 3d9691b11a fix: stop HTML-escaping email subject lines
Subjects were compiled with default Handlebars escaping, so the
app-user-feedback subject rendered a title like "Bob's App & Games" as
"Bob's App & Games" — literal entities in the recipient's mail
client. Subjects are plain-text headers, not HTML; compile them with
noEscape. Header safety is unaffected: the transport encodes newlines
and free-form values collapse whitespace upstream.
2026-08-11 18:30:29 -07:00
Nariman Jelveh c2fe813929 fix: make the feedback dialog's privacy note match what is shared
The note unconditionally said 'Your email address will be shared with
the developer so they can respond', but AppFeedbackService shares the
username always and the email only when it exists and is verified — an
unverified or temp-user sender was promised a reply path that never
materializes, and nobody was told about the username.

Show 'username and email' when the signed-in user's email is verified,
and 'username' otherwise.
2026-08-11 18:29:22 -07:00
Nariman Jelveh ef7fc05dcc fix: reject feedback origins longer than the source_origin column
readTargetParam accepted values up to 3000 chars but the raw origin is
stored verbatim into source_origin VARCHAR(2048) (MySQL/Postgres), so a
2049-3000 char origin passed every validation and then blew up the
INSERT with an HTTP 500 on Postgres/strict MySQL — or was silently
truncated on non-strict MySQL, corrupting the abuse-forensics value the
column exists for. Cap the param at the column size.
2026-08-11 18:27:42 -07:00
Nariman Jelveh 1c2d33f149 fix: gate feedback dialog Cancel/X on an in-flight submit
Escape and backdrop clicks were already ignored while the POST was
pending, but the X, Close, and Cancel buttons weren't — clicking one
mid-send settled the promise false and tore down the overlay while the
submission still landed server-side: the developer got the email, the
app was told sent=false, and a user who resubmitted 'the failed one'
sent a duplicate and burned a daily-cap slot.

Apply the same !sending gate to the buttons and disable them visually
while the send is in flight.
2026-08-11 18:26:47 -07:00
Nariman Jelveh b1fdc8d665 fix: carry feedback_enabled in suggested/recommended app summaries
launch_app uses options.app_obj verbatim when provided, and the
suggested-apps launch paths (open_item.js, UIWindowSearch.js,
UIDesktop.js) pass summaries from toAppSummary — which omitted
feedback_enabled. So an opted-in editor launched by opening a .txt file
showed no Send Feedback control in the dashboard drawer, while the same
app launched from the Apps tab (full puter.apps.get object) did.
2026-08-11 18:23:43 -07:00
Nariman Jelveh 1ecc23cf83 fix: guard app-triggered feedback dialog against desktop-lockout loops
The showFeedbackDialog IPC handler had no re-entry or abuse guard, and
the dialog it opens is a full-viewport overlay above the taskbar and
every window — so 'while (true) await puter.ui.showFeedbackDialog()'
kept the desktop permanently covered (for signed-out users, the same
loop spams the full-page signup window instead). Every dismissal just
settled the promise and let the app immediately reopen it.

Allow one dialog at a time, and back off reopens per app after each
dismissal that sent nothing: 10s, then 60s, then blocked until page
reload. A successful send resets the backoff, and user-initiated paths
(dashboard drawer) are unaffected since they don't go through IPC.
2026-08-11 18:21:43 -07:00
Nariman Jelveh 7230580b18 fix: settle showFeedbackDialog instead of hanging on older host GUIs
In the app environment showFeedbackDialog awaited an IPC reply with no
capability check. A host GUI that predates this feature (self-hosted
Puter running the live js.puter.com SDK) has no handler for the message
and never replies, so the promise documented as 'never rejects' also
never resolved.

The GUI now advertises the IPC dialogs it can answer via a
puter.gui_features param on the app iframe URL, and the SDK resolves
false when 'feedback-dialog' isn't listed. A reply timeout could not
substitute: legitimate replies only arrive when the user closes the
dialog, so any timeout would false-negative while the user is typing.
Older SDKs ignore the extra param.
2026-08-11 18:19:46 -07:00
Nariman Jelveh 1460751b41 fix: refuse feedback when the deployment cannot deliver it
With no email transport configured (the common self-hosted default),
submissions were stored in app_feedback — a table with no read path
beyond the abuse-cap COUNTs — the owner email was silently skipped, and
the sender was still shown 'Feedback sent. Thank you!'. The developer
never learns the feedback exists while the user believes it was
delivered.

Gate acceptsFeedback on clients.email.isConfigured so the pre-flight
reports enabled:false (the dialog shows its 'not accepting feedback'
pane) and submit returns 403 instead of swallowing messages. Owner-level
store-without-email cases (unconfirmed owner email, per-app email cap
overflow) keep their existing deliberate semantics.
2026-08-11 18:16:14 -07:00
Nariman Jelveh 4f96d371f4 fix: don't mint a user-app token as a side effect of the feedback popup
Every embedded_in_popup boot ran the user-app token exchange, and the
exchange is a write: /auth/get-user-app-token bootstraps an app row for
the opener origin, grants flag:app-is-authenticated (what makes the
site count as connected to the account), and creates its AppData dir.
So merely opening — or immediately cancelling — a send-feedback popup
recorded a user<->site relationship the read-only feedback flow never
needs: the server resolves the feedback target from the attested origin
without any of it.

Gate the exchange behind runsUserAppTokenExchange(action) in all three
popup paths that mint (main postAuthActions exchange, temp-user signup
success, manual signup fallback). request-permission keeps the exchange
since grants are written against the app row it bootstraps.
2026-08-11 18:13:13 -07:00
Nariman Jelveh c5cf1dc390 docs: disclose the Dev Center's feedback-on-by-default in SDK docs/types
The Dev Center deliberately creates apps with feedbackEnabled (see
32c950de5), but the SDK docs, apps.d.ts, and AppFeedbackService's class
doc all described feedback as strictly opt-in / default-false with no
qualification — so a Dev Center developer reading them would wrongly
conclude feedback is off for their app. State the Dev Center behavior
alongside the API default, and correct the update-path docs: an omitted
feedbackEnabled leaves the current value unchanged rather than
defaulting to false.
2026-08-11 18:07:47 -07:00
Nariman Jelveh 503a450b40 fix: make feedback daily caps fail closed under concurrent submissions
The per-user and per-user-per-app caps were check-then-insert and the
per-app email cap was count-then-send, so parallel requests (or multiple
nodes, or the route limiter failing open) could all read a stale
under-cap count and push past every limit — the exact scenario the
DB-backed caps exist to stop.

Now the user caps recount after the insert (own row included) and roll
the row back with 429 if a burst breached them, and the email cap claims
its slot (email_sent=1) before sending, recounts, and releases the slot
if over cap or if the send fails.
2026-08-11 18:06:05 -07:00
Nariman Jelveh f4dfbd735b fix: resolve 'app-'-prefixed app names in feedback target lookup
APP_NAME_REGEX allows names beginning with "app-" (e.g. the seeded
app-center), but resolveTargetApp's startsWith('app-') heuristic sent
those to a uid-only lookup with no name fallback, so feedback for such
apps 403'd even when enabled. Use AppStore.resolveApp (uid, then name)
like the rest of the codebase.
2026-08-11 18:03:15 -07:00
Nariman Jelveh 81402918d2 fix: feedback modal polish + share sender email
Address four issues with the app feedback UI:

- Dashboard app-drawer: the extra "feedback" control pushed the close
  button past the drawer's derived width and clipped it. A `has-feedback`
  modifier widens the surface by one button + gap so all three controls
  fit. The control's glyph is now a message bubble with text lines, which
  reads more clearly at 14px than the previous bare speech bubble.

- The feedback dialog is no longer a UIWindow. It's a from-scratch
  overlay modal in the spirit of the dashboard modals (uninstall,
  add-app): a fixed scrim + centered card with self-contained,
  theme-aware color tokens (light default + dark override), a bottom-sheet
  layout on narrow screens, backdrop/Escape close, and an entrance
  transition. This renders consistently across the three contexts it's
  opened from (desktop app-IPC, dashboard drawer, standalone popup), so
  the callers no longer pass UIWindow-specific window_options.

- Feedback now shares the sender's email (not just their username) with
  the developer so they can respond: the owner email sets Reply-To to the
  sender and shows the address in the body — but only when the sender's
  email is verified (an unverified address could be anyone's, so it's
  never used as a reply target). EmailClient.send gains an optional
  replyTo. The dialog note now says the email will be shared.

Tests: e2e updated for the new modal (7 pass); backend feedback suite
covers the verified/unverified sender-email split (sqlite + postgres);
EmailClient + GUI unit suites pass; type-check clean.
2026-08-11 17:11:36 -07:00
Nariman Jelveh 32c950de5b feat: enable app feedback by default in Dev Center
New apps created in Dev Center now have feedbackEnabled set on creation,
so users can send the developer feedback without any extra setup. A "User
Feedback" toggle in the app's settings lets developers turn it off (and
back on); it's wired into the save payload, the dirty-state tracking, and
the reset-to-original path like the neighboring toggles.

The Save update omits feedbackEnabled unless the toggle is present, and
the backend leaves an omitted field untouched, so the default survives
the create-then-save flow Dev Center runs. Add an SDK apps-suite guard
covering that round-trip (create-on -> unrelated update keeps it -> can
be turned off).
2026-08-11 16:47:12 -07:00
Nariman Jelveh 792361507e feat: add feedback control to the dashboard app-drawer
Surface the feedback dialog directly from the app window's chrome in
dashboard mode: apps that opt in (apps.feedbackEnabled) get a "Send
Feedback" button in the dashboard app-drawer, next to minimize/close.
It opens the same UIWindowAppFeedback dialog, targeting this app by uid.

The control is only rendered when the app opted in — feedback_enabled is
threaded from the launched app's metadata into the window options — and
the dialog still re-checks opt-in server-side, so a stale flag can't send
anywhere. Reuses the existing .dashboard-app-drawer-btn styling and the
app_feedback_title i18n string, so no new CSS or strings.

Adds e2e coverage: the control appears and opens the dialog for an
opted-in app, and is absent for an app that hasn't opted in.
2026-08-11 16:39:05 -07:00
Nariman Jelveh 4f267fb83e feat: app user feedback system
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.
2026-08-11 16:31:27 -07:00
48 changed files with 3931 additions and 108 deletions

No files matched your search

@@ -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 = (
@@ -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 {
@@ -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 <https://www.gnu.org/licenses/>.
-- 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;
@@ -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 <https://www.gnu.org/licenses/>.
-- 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);
@@ -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 <https://www.gnu.org/licenses/>.
-- 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`);
@@ -179,6 +179,29 @@ describe('EmailClient — template rendering', () => {
expect(captured.html).toContain('<strong>424242</strong>');
});
it('does not HTML-escape values in the plain-text subject header', async () => {
const client = startClient();
const captured: { subject?: string } = {};
vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => {
captured.subject = options.subject;
return null;
});
await client.send('[email protected]', 'app-user-feedback', {
owner_username: 'dev',
sender_username: 'user',
sender_email: null,
app_title: "Bob's App & Games",
app_name: 'bobs-app',
app_link: 'https://puter.example/app/bobs-app',
message: 'hi',
});
// A subject is not HTML — entities would render literally in the
// recipient's mail client.
expect(captured.subject).toBe("New user feedback for Bob's App & Games");
});
it('escapes html and converts newlines in nl2br values', async () => {
const client = startClient();
let html = '';
+15 -2
View File
@@ -152,11 +152,16 @@ export class EmailClient extends PuterClient {
// -- Public API: sending ------------------------------------------
/** Render a template and send it to `to`. */
/**
* Render a template and send it to `to`. `options.replyTo` sets the
* Reply-To header (e.g. so a recipient can respond to the originator of the
* message rather than the no-reply From address).
*/
async send<T extends EmailTemplateName>(
to: string,
template: T,
values: Record<string, unknown> = {},
options: { replyTo?: string } = {},
): Promise<void> {
const compiled = this.compiledTemplates[template];
if (!compiled) {
@@ -168,6 +173,7 @@ export class EmailClient extends PuterClient {
to,
subject: compiled.subject(values),
html: compiled.html(values),
...(options.replyTo ? { replyTo: options.replyTo } : {}),
});
}
@@ -285,7 +291,14 @@ export class EmailClient extends PuterClient {
private compileTemplates(): void {
for (const [name, template] of Object.entries(EMAIL_TEMPLATES)) {
this.compiledTemplates[name as EmailTemplateName] = {
subject: handlebars.compile(template.subject),
// Subjects are plain-text headers: HTML-escaping would put
// literal entities in front of the recipient (&amp; etc.).
// Header safety is handled elsewhere — the transport encodes
// newlines, and free-form values (e.g. app_title) collapse
// whitespace upstream.
subject: handlebars.compile(template.subject, {
noEscape: true,
}),
html: handlebars.compile(dedent(template.html)),
};
}
+19
View File
@@ -75,6 +75,25 @@ Please update <a href="https://puter.com/app/{{app_name}}">{{app_title}}</a>.
<blockquote>{{nl2br message}}</blockquote>
<p>Best,<br />
The Puter Team
</p>
`,
},
'app-user-feedback': {
subject: 'New user feedback for {{app_title}}',
html: `
<p>Hi{{#if owner_username}} {{owner_username}}{{/if}},</p>
<p>
<strong>{{sender_username}}</strong>{{#if sender_email}} (<a href="mailto:{{sender_email}}">{{sender_email}}</a>){{/if}} sent feedback about <a href="{{app_link}}">{{app_title}}</a>:
</p>
<blockquote>{{{nl2br message}}}</blockquote>
{{#if sender_email}}<p>Just reply to this email to respond to them directly.</p>{{/if}}
<p>
You're receiving this because feedback is enabled for
<a href="{{app_link}}">{{app_title}}</a> — manage it in the
<a href="{{dev_center_link}}">Dev Center</a> under your app's settings.
</p>
<p>Best,<br />
The Puter Team
</p>
`,
},
+4 -5
View File
@@ -540,11 +540,10 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -0,0 +1,594 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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; name?: string } = {},
) => {
const name =
opts.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 },
);
};
// Feedback is only offered when the deployment can deliver it (email
// transport configured); most tests want that baseline without asserting
// anything about the mail itself.
const mockEmailConfigured = () =>
vi.spyOn(server.clients.email, 'isConfigured', 'get').mockReturnValue(
true,
);
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<string, unknown>;
}): 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 () => {
mockEmailConfigured();
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 () => {
mockEmailConfigured();
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 opted-in app whose name starts with "app-"', async () => {
mockEmailConfigured();
const { userId: ownerId } = await makeUser();
const name = `app-fdbk-${Math.random().toString(36).slice(2, 10)}`;
const app = await makeApp(ownerId, { feedbackEnabled: true, name });
const { actor } = await makeUser();
const { res, captured } = makeRes();
await callRoute(
'get',
'/target',
makeReq({ query: { app: name }, 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 () => {
mockEmailConfigured();
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 when the email transport is unconfigured', async () => {
// No mockEmailConfigured(): this is the self-hosted no-SMTP default.
// Feedback that can never be delivered must not be solicited.
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.name }, actor }),
res,
);
expect(captured.body).toMatchObject({ enabled: false });
});
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 400 when the origin exceeds the stored column size', async () => {
// source_origin is VARCHAR(2048) on MySQL/Postgres; a longer origin
// must be rejected up front, not fail (or silently truncate) at the
// INSERT after passing every other validation.
mockEmailConfigured();
const { userId: ownerId } = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const origin = `${new URL(app.index_url).origin}/${'x'.repeat(2500)}`;
const { actor } = await makeUser();
await expect(
submit(actor, { origin, message: 'hi' }),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 403 feedback_not_enabled when the app has not opted in', async () => {
mockEmailConfigured();
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 403 feedback_not_enabled when the email transport is unconfigured', async () => {
// No mockEmailConfigured(): the opted-in app must still refuse — a
// stored row nothing can read, sold to the sender as delivered, is
// worse than an honest refusal.
const { userId: ownerId } = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const { actor } = await makeUser();
await expect(
submit(actor, { app: app.name, message: 'into the void' }),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'feedback_not_enabled',
});
});
it('throws 400 when the message exceeds the length limit', async () => {
mockEmailConfigured();
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 () => {
mockEmailConfigured();
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<Record<string, unknown>>;
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 () => {
mockEmailConfigured();
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<Record<string, unknown>>;
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 () => {
mockEmailConfigured();
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('rolls back the stored row when a concurrent burst breaches the cap', async () => {
mockEmailConfigured();
const { userId: ownerId } = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const { actor, userId } = await makeUser();
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}`,
});
}
// Simulate the losing side of the check-then-insert race: the
// pre-insert check reads a stale under-cap count; the post-insert
// recount (real implementation) sees the truth.
vi.spyOn(
server.stores.appFeedback,
'countByUserAndAppSince',
).mockResolvedValueOnce(0);
await expect(
submit(actor, { app: app.name, message: 'raced past the cap' }),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'too_many_requests',
});
const rows = (await server.clients.db.read(
'SELECT COUNT(*) AS n FROM `app_feedback` WHERE `user_id` = ?',
[userId],
)) as Array<{ n: unknown }>;
expect(Number(rows[0]?.n)).toBe(
AppFeedbackService.PER_USER_APP_DAILY_LIMIT,
);
});
it('enforces the per-user daily cap across apps with 429', async () => {
mockEmailConfigured();
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 ────────────────────────────────────────────
// Which submissions get emailed, and what the mail contains, is service
// logic covered in AppFeedbackService.test.ts. What the controller owes the
// caller is that mail trouble never becomes the sender's problem.
describe('AppFeedbackController owner email', () => {
it('a failing email send never fails the request', async () => {
mockEmailConfigured();
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);
});
});
@@ -0,0 +1,169 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
// Upper bound on the `app`/`origin` target params. Must not exceed the
// `source_origin` column (VARCHAR(2048) on MySQL/Postgres): the raw origin is
// stored verbatim, and a longer value would fail the INSERT after passing
// every validation — or be silently truncated on non-strict MySQL.
const TARGET_PARAM_MAX_LENGTH = 2048;
const readTargetParam = (value: unknown): string | undefined => {
return typeof value === 'string' &&
value.length > 0 &&
value.length <= TARGET_PARAM_MAX_LENGTH
? 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,
requireVerified: true,
rateLimit: {
scope: 'app-feedback-target',
limit: 60,
window: 60_000,
key: 'user',
},
})
async target(req: Request, res: Response): Promise<void> {
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,
requireVerified: 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<void> {
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({});
}
}
+1 -2
View File
@@ -2178,8 +2178,7 @@ export class FSController extends PuterController {
// the ActorUser type. Access via the escape hatch until a proper
// storage-quota mechanism is in place.
const actorUser = req.actor?.user as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const candidates = [
this.#toStorageCapacityCandidate(actorUser?.free_storage),
@@ -828,9 +828,7 @@ export class LegacyFSController extends PuterController {
// Trash, and `null`/`{}` when restoring. See
// `src/gui/src/helpers.js` → `window.move_items`.
newMetadata: (body.new_metadata ?? undefined) as
| Record<string, unknown>
| null
| undefined,
Record<string, unknown> | null | undefined,
});
const oldPath = source.path;
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
@@ -1276,8 +1274,7 @@ export class LegacyFSController extends PuterController {
}
type SignedOrEmpty =
| (SignedFile & { path?: string })
| Record<string, never>;
(SignedFile & { path?: string }) | Record<string, never>;
const result: { signatures: SignedOrEmpty[]; token?: string } = {
signatures: [],
};
@@ -1843,10 +1840,7 @@ export class LegacyFSController extends PuterController {
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
| 'see'
| 'list'
| 'read'
| 'write';
'see' | 'list' | 'read' | 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',
+2
View File
@@ -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,
+1 -6
View File
@@ -26,12 +26,7 @@ import type { Actor } from '../actor';
* it instead of accepting any token that authenticates.
*/
export type TokenSource =
| 'body'
| 'header'
| 'x-api-key'
| 'cookie'
| 'query'
| 'handshake';
'body' | 'header' | 'x-api-key' | 'cookie' | 'query' | 'handshake';
/**
* Every route method PuterRouter exposes. Mirrors the express router surface
+8
View File
@@ -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),
@@ -75,6 +75,10 @@ function toAppSummary(
godmode: Boolean(app.godmode),
maximize_on_start: Boolean(app.maximize_on_start),
index_url: app.index_url,
// Launched straight from this summary as `app_obj` — see
// SuggestedAppsService.toAppSummary; the drawer's feedback control
// renders off this flag.
feedback_enabled: Boolean(app.feedback_enabled),
// An app with no owner isn't owned by a Puter user — it's an
// "external" (origin-bootstrapped) app.
external: app.owner_user_id == null || app.owner_user_id === '',
@@ -338,5 +338,10 @@ function toAppSummary(
godmode: Boolean(app.godmode),
maximize_on_start: Boolean(app.maximize_on_start),
index_url: app.index_url,
// The GUI launches straight from this summary as `app_obj` (no
// re-read through AppDriver), so any launch-relevant flag omitted
// here silently disappears on those paths — e.g. the dashboard
// drawer's feedback control renders off this.
feedback_enabled: Boolean(app.feedback_enabled),
};
}
@@ -0,0 +1,681 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { AppFeedbackService } from './AppFeedbackService.js';
// Drives the real wired service against the real stores and in-memory
// database; only the email transport (a genuine external boundary) is
// stubbed. The controller's own tests cover request parsing and route gates —
// everything here is the business logic the controller delegates to.
// The email links the service builds are rooted at `config.origin`, which the
// default test config leaves unset.
const TEST_ORIGIN = 'https://puter.test';
let server: PuterServer;
let service: AppFeedbackService;
beforeAll(async () => {
server = await setupTestServer({ origin: TEST_ORIGIN } as never);
service = server.services.appFeedback;
});
afterAll(async () => {
await server?.shutdown();
});
afterEach(() => {
vi.restoreAllMocks();
});
const makeUser = async (): Promise<number> => {
const username = `fdbk-svc-${Math.random().toString(36).slice(2, 10)}`;
const user = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
});
return user.id;
};
const makeApp = async (
ownerUserId: number,
opts: { feedbackEnabled?: boolean; name?: string; title?: string } = {},
) => {
const name =
opts.name ?? `fdbk-svc-app-${Math.random().toString(36).slice(2, 10)}`;
return await server.stores.app.create(
{
name,
title: opts.title ?? `Feedback Service Test ${name}`,
index_url: `https://${name}.example.com`,
...(opts.feedbackEnabled ? { feedback_enabled: 1 } : {}),
},
{ ownerUserId },
);
};
// Feedback is only offered when the deployment can deliver it; most tests
// want that baseline without asserting anything about the mail itself.
const mockEmailConfigured = () =>
vi.spyOn(server.clients.email, 'isConfigured', 'get').mockReturnValue(true);
const mockEmailReady = () => {
mockEmailConfigured();
return vi.spyOn(server.clients.email, 'send').mockResolvedValue(undefined);
};
// Columns the user store has no setter for; written directly the way the
// admin tooling does, then the cached row is dropped.
const setUserFlags = async (
userId: number,
flags: Partial<{
email_confirmed: boolean;
suspended: boolean;
unsubscribed: boolean;
}>,
) => {
for (const [column, value] of Object.entries(flags)) {
await server.clients.db.write(
`UPDATE \`user\` SET \`${column}\` = ? WHERE \`id\` = ?`,
[server.clients.db.booleanValue(Boolean(value)), userId],
);
}
await server.stores.user.invalidateById(userId);
};
// An owner who can actually receive mail: the default for delivery tests.
const makeDeliverableOwner = async (): Promise<number> => {
const ownerId = await makeUser();
await setUserFlags(ownerId, { email_confirmed: true });
return ownerId;
};
const feedbackRows = async (userId: number) =>
(await server.clients.db.read(
'SELECT * FROM `app_feedback` WHERE `user_id` = ? ORDER BY `id`',
[userId],
)) as Array<Record<string, unknown>>;
// -- Message normalization ---------------------------------------------
describe('AppFeedbackService.normalizeMessage', () => {
it('unifies newlines, strips control chars, and trims', () => {
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', () => {
expect(service.normalizeMessage(42)).toBeNull();
expect(service.normalizeMessage(' \n\t ')).toBeNull();
expect(service.normalizeMessage(null)).toBeNull();
expect(service.normalizeMessage(undefined)).toBeNull();
// Nothing but control characters normalizes away to nothing.
expect(service.normalizeMessage('\u0000\u0007')).toBeNull();
});
});
// -- Target resolution --------------------------------------------------
describe('AppFeedbackService.resolveTargetApp', () => {
it('resolves by uid and by name, including names starting with "app-"', async () => {
const ownerId = await makeUser();
const app = await makeApp(ownerId);
const prefixed = await makeApp(ownerId, {
name: `app-fdbk-${Math.random().toString(36).slice(2, 10)}`,
});
expect(await service.resolveTargetApp({ app: app.uid })).toMatchObject({
id: app.id,
});
expect(await service.resolveTargetApp({ app: app.name })).toMatchObject(
{ id: app.id },
);
// A "app-"-prefixed *name* must not be mistaken for a uid and lost.
expect(
await service.resolveTargetApp({ app: prefixed.name }),
).toMatchObject({ id: prefixed.id });
});
it('returns null when neither app nor origin is given, and for unknown apps', async () => {
expect(await service.resolveTargetApp({})).toBeNull();
expect(
await service.resolveTargetApp({ app: 'no-such-app-xyz' }),
).toBeNull();
});
it('resolves an origin to the app whose index_url it matches', async () => {
const ownerId = await makeUser();
const app = await makeApp(ownerId);
const origin = new URL(app.index_url).origin;
expect(await service.resolveTargetApp({ origin })).toMatchObject({
id: app.id,
});
});
it('treats a blocked origin as unknown rather than an error', async () => {
// To a feedback caller "blocked" and "unknown" mean the same thing:
// nobody is accepting feedback there. Surfacing the 403 would tell
// any page whether its origin is on the blocklist.
const ownerId = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const origin = new URL(app.index_url).origin;
await server.clients.db.write(
'INSERT INTO `blocked_app_origins` (`domain`, `include_subdomains`, `reason`) VALUES (?, ?, ?)',
[new URL(origin).host, 0, 'test'],
);
server.services.appOriginBlocklist.invalidate();
try {
expect(await service.resolveTargetApp({ origin })).toBeNull();
} finally {
await server.clients.db.write(
'DELETE FROM `blocked_app_origins` WHERE `domain` = ?',
[new URL(origin).host],
);
server.services.appOriginBlocklist.invalidate();
}
});
it('throws 400 on an unparseable origin', async () => {
await expect(
service.resolveTargetApp({ origin: 'not a url' }),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// -- Eligibility --------------------------------------------------------
describe('AppFeedbackService.acceptsFeedback', () => {
const app = { feedback_enabled: 1, owner_user_id: 7 };
it('requires opt-in, an owner, and a configured email transport', () => {
mockEmailConfigured();
expect(service.acceptsFeedback(app)).toBe(true);
expect(service.acceptsFeedback(null)).toBe(false);
expect(service.acceptsFeedback({ ...app, feedback_enabled: 0 })).toBe(
false,
);
expect(service.acceptsFeedback({ ...app, owner_user_id: null })).toBe(
false,
);
});
it('is false without an email transport, even for an opted-in app', () => {
// The self-hosted no-SMTP default. Feedback rows have no other read
// path, so soliciting them here would store-and-lose every message
// while telling the sender it was delivered.
expect(server.clients.email.isConfigured).toBe(false);
expect(service.acceptsFeedback(app)).toBe(false);
});
});
describe('AppFeedbackService.getTarget', () => {
it('returns the dialog fields for an opted-in app', async () => {
mockEmailConfigured();
const ownerId = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
expect(await service.getTarget({ app: app.uid })).toEqual({
enabled: true,
app: { name: app.name, title: app.title },
});
});
it('reports a resolved-but-ineligible app as disabled, still naming it', async () => {
// The dialog needs the name/title to say *which* app declined.
mockEmailConfigured();
const ownerId = await makeUser();
const app = await makeApp(ownerId);
expect(await service.getTarget({ app: app.name })).toEqual({
enabled: false,
app: { name: app.name, title: app.title },
});
});
it('reports an unknown target as disabled with no app', async () => {
mockEmailConfigured();
expect(await service.getTarget({ app: 'no-such-app-xyz' })).toEqual({
enabled: false,
app: null,
});
});
});
// -- Submission ---------------------------------------------------------
describe('AppFeedbackService.submit', () => {
it('stores the normalized message and returns the row uid', async () => {
mockEmailConfigured();
const ownerId = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const userId = await makeUser();
const result = await service.submit({
userId,
app: app.uid,
message: ' Great\r\napp! ',
sourceEnv: 'app',
});
const rows = await feedbackRows(userId);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
uid: result.uid,
app_uid: app.uid,
message: 'Great\napp!',
source_env: 'app',
source_origin: null,
});
expect(Number(rows[0].app_id)).toBe(app.id);
});
it('stores the attested origin for web submissions', async () => {
mockEmailConfigured();
const ownerId = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const origin = new URL(app.index_url).origin;
const userId = await makeUser();
await service.submit({
userId,
origin,
message: 'from the web',
sourceEnv: 'web',
sourceOrigin: origin,
});
expect((await feedbackRows(userId))[0]).toMatchObject({
source_env: 'web',
source_origin: origin,
});
});
it('throws 403 feedback_not_enabled for opted-out, unknown, and undeliverable targets', async () => {
const ownerId = await makeUser();
const userId = await makeUser();
mockEmailConfigured();
const optedOut = await makeApp(ownerId);
await expect(
service.submit({ userId, app: optedOut.name, message: 'hi' }),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'feedback_not_enabled',
});
await expect(
service.submit({ userId, app: 'no-such-app-xyz', message: 'hi' }),
).rejects.toMatchObject({ statusCode: 403 });
// Same refusal when the deployment has no email transport at all.
vi.restoreAllMocks();
const enabled = await makeApp(ownerId, { feedbackEnabled: true });
await expect(
service.submit({ userId, app: enabled.name, message: 'hi' }),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'feedback_not_enabled',
});
expect(await feedbackRows(userId)).toHaveLength(0);
});
it('throws 400 for a message that is empty or too long after normalization', async () => {
mockEmailConfigured();
const ownerId = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const userId = await makeUser();
for (const message of [' ', '\u0000\u0007', '\r\n \t ']) {
await expect(
service.submit({ userId, app: app.name, message }),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
});
}
await expect(
service.submit({
userId,
app: app.name,
message: 'x'.repeat(AppFeedbackService.MESSAGE_MAX_LENGTH + 1),
}),
).rejects.toMatchObject({ statusCode: 400 });
// A message at exactly the limit is fine — the cap is inclusive.
await expect(
service.submit({
userId,
app: app.name,
message: 'x'.repeat(AppFeedbackService.MESSAGE_MAX_LENGTH),
}),
).resolves.toMatchObject({ uid: expect.any(String) });
expect(await feedbackRows(userId)).toHaveLength(1);
});
it('measures length after normalization, not before', async () => {
// Padding and \r\n line endings must not push an otherwise-legal
// message over the limit.
mockEmailConfigured();
const ownerId = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const userId = await makeUser();
const body = 'a\r\n'.repeat(AppFeedbackService.MESSAGE_MAX_LENGTH / 2);
await expect(
service.submit({ userId, app: app.name, message: ` ${body} ` }),
).resolves.toMatchObject({ uid: expect.any(String) });
});
it('enforces the per-user-per-app daily cap with 429', async () => {
mockEmailConfigured();
const ownerId = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const userId = await makeUser();
for (let i = 0; i < AppFeedbackService.PER_USER_APP_DAILY_LIMIT; i++) {
await expect(
service.submit({
userId,
app: app.name,
message: `message ${i}`,
}),
).resolves.toMatchObject({ uid: expect.any(String) });
}
await expect(
service.submit({ userId, app: app.name, message: 'one too many' }),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'too_many_requests',
});
expect(await feedbackRows(userId)).toHaveLength(
AppFeedbackService.PER_USER_APP_DAILY_LIMIT,
);
});
it('enforces the per-user daily cap across apps with 429', async () => {
mockEmailConfigured();
const ownerId = await makeUser();
const target = await makeApp(ownerId, { feedbackEnabled: true });
const userId = await makeUser();
const other = await makeApp(ownerId, { feedbackEnabled: true });
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}`,
});
}
// Under the per-app cap for `target`, over the all-apps cap.
await expect(
service.submit({
userId,
app: target.name,
message: 'over the limit',
}),
).rejects.toMatchObject({ statusCode: 429 });
});
it('counts only rows inside the 24h window toward the caps', async () => {
mockEmailConfigured();
const ownerId = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const userId = await makeUser();
const yesterday = Math.floor(Date.now() / 1000) - 25 * 60 * 60;
for (let i = 0; i < AppFeedbackService.PER_USER_APP_DAILY_LIMIT; i++) {
const row = await server.stores.appFeedback.create({
appId: app.id,
appUid: app.uid,
userId,
message: `stale ${i}`,
});
await server.clients.db.write(
'UPDATE `app_feedback` SET `created_at` = ? WHERE `id` = ?',
[yesterday, row.id],
);
}
await expect(
service.submit({ userId, app: app.name, message: 'new day' }),
).resolves.toMatchObject({ uid: expect.any(String) });
});
it('rolls the stored row back when a concurrent burst breaches the cap', async () => {
mockEmailConfigured();
const ownerId = await makeUser();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const userId = await makeUser();
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}`,
});
}
// Simulate the losing side of the check-then-insert race: the
// pre-insert check reads a stale under-cap count; the post-insert
// recount sees the truth and must undo the insert.
vi.spyOn(
server.stores.appFeedback,
'countByUserAndAppSince',
).mockResolvedValueOnce(0);
await expect(
service.submit({
userId,
app: app.name,
message: 'raced past the cap',
}),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'too_many_requests',
});
expect(await feedbackRows(userId)).toHaveLength(
AppFeedbackService.PER_USER_APP_DAILY_LIMIT,
);
});
});
// -- Owner email delivery -----------------------------------------------
describe('AppFeedbackService owner email', () => {
it('emails the owner and shares a verified sender address as reply-to', async () => {
const send = mockEmailReady();
const ownerId = await makeDeliverableOwner();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const userId = await makeUser();
// Only a verified sender email is shared and used as reply-to.
await setUserFlags(userId, { email_confirmed: true });
const sender = (await server.stores.user.getById(userId))!;
const owner = (await server.stores.user.getById(ownerId))!;
await service.submit({ userId, app: app.name, message: 'hello dev' });
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
owner.email,
'app-user-feedback',
expect.objectContaining({
owner_username: owner.username,
sender_username: sender.username,
sender_email: sender.email,
app_name: app.name,
app_title: app.title,
message: 'hello dev',
}),
expect.objectContaining({ replyTo: sender.email }),
);
expect(Boolean((await feedbackRows(userId))[0].email_sent)).toBe(true);
});
it('builds app and Dev Center links from the deployment origin', async () => {
// Both links must follow config.origin so they resolve on
// self-hosted deployments, not just puter.com.
const send = mockEmailReady();
const ownerId = await makeDeliverableOwner();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const userId = await makeUser();
await service.submit({ userId, app: app.name, message: 'links' });
const [, , values] = send.mock.calls[0];
expect(values).toMatchObject({
app_link: `${TEST_ORIGIN}/app/${encodeURIComponent(app.name)}`,
dev_center_link: `${TEST_ORIGIN}/app/dev-center`,
});
});
it('collapses whitespace in the app title so it cannot forge header lines', async () => {
// app_title lands in the subject; a newline there would let a
// developer-controlled title inject its own headers or body lines.
const send = mockEmailReady();
const ownerId = await makeDeliverableOwner();
const app = await makeApp(ownerId, {
feedbackEnabled: true,
title: 'Evil\r\nBcc: [email protected]\tApp',
});
const userId = await makeUser();
await service.submit({ userId, app: app.name, message: 'hi' });
const [, , values] = send.mock.calls[0];
expect((values as Record<string, unknown>).app_title).toBe(
'Evil Bcc: [email protected] App',
);
});
it('does not share an unverified sender email and sets no reply-to', async () => {
const send = mockEmailReady();
const ownerId = await makeDeliverableOwner();
const app = await makeApp(ownerId, { feedbackEnabled: true });
// Sender's email is left unverified — it could be anyone's, so
// pointing the developer's reply at it is not safe.
const userId = await makeUser();
await service.submit({ userId, app: app.name, message: 'hello dev' });
expect(send).toHaveBeenCalledTimes(1);
const [, , values, options] = send.mock.calls[0];
expect((values as Record<string, unknown>).sender_email).toBeNull();
expect(
(options as { replyTo?: string } | undefined)?.replyTo,
).toBeUndefined();
// Delivery still happens; only the reply path is withheld.
expect(Boolean((await feedbackRows(userId))[0].email_sent)).toBe(true);
});
it('stores without emailing when the owner cannot or will not receive mail', async () => {
const cases: Array<[string, Parameters<typeof setUserFlags>[1]]> = [
['unconfirmed email', { email_confirmed: false }],
['suspended', { email_confirmed: true, suspended: true }],
['unsubscribed', { email_confirmed: true, unsubscribed: true }],
];
for (const [label, flags] of cases) {
const send = mockEmailReady();
const ownerId = await makeUser();
await setUserFlags(ownerId, flags);
const app = await makeApp(ownerId, { feedbackEnabled: true });
const userId = await makeUser();
await service.submit({
userId,
app: app.name,
message: `owner is ${label}`,
});
expect(send, label).not.toHaveBeenCalled();
const rows = await feedbackRows(userId);
expect(rows, label).toHaveLength(1);
expect(Boolean(rows[0].email_sent), label).toBe(false);
vi.restoreAllMocks();
}
});
it('stores but does not email past the per-app daily email cap', async () => {
const send = mockEmailReady();
const ownerId = await makeDeliverableOwner();
const app = await makeApp(ownerId, { feedbackEnabled: true });
// Seed the cap with already-emailed rows from other users — the cap
// bounds mail per app, not per sender.
for (let i = 0; i < AppFeedbackService.PER_APP_DAILY_EMAIL_LIMIT; i++) {
const row = await server.stores.appFeedback.create({
appId: app.id,
appUid: app.uid,
userId: await makeUser(),
message: `seed ${i}`,
});
await server.stores.appFeedback.markEmailSent(row.id);
}
const userId = await makeUser();
await expect(
service.submit({ userId, app: app.name, message: 'past the cap' }),
).resolves.toMatchObject({ uid: expect.any(String) });
expect(send).not.toHaveBeenCalled();
const rows = await feedbackRows(userId);
expect(rows).toHaveLength(1);
// The claimed slot was released, so the cap count stays exact.
expect(Boolean(rows[0].email_sent)).toBe(false);
expect(
await server.stores.appFeedback.countEmailedByAppSince(
app.id,
Math.floor(Date.now() / 1000) - 24 * 60 * 60,
),
).toBe(AppFeedbackService.PER_APP_DAILY_EMAIL_LIMIT);
});
it('keeps the feedback and releases the email slot when the send fails', async () => {
mockEmailConfigured();
vi.spyOn(server.clients.email, 'send').mockRejectedValue(
new Error('smtp down'),
);
const ownerId = await makeDeliverableOwner();
const app = await makeApp(ownerId, { feedbackEnabled: true });
const userId = await makeUser();
await expect(
service.submit({ userId, app: app.name, message: 'still stored' }),
).resolves.toMatchObject({ uid: expect.any(String) });
const rows = await feedbackRows(userId);
expect(rows).toHaveLength(1);
// Slot released — a failed send must not consume the app's daily
// email budget.
expect(Boolean(rows[0].email_sent)).toBe(false);
});
});
@@ -0,0 +1,359 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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 gated per app by the `apps.feedback_enabled` column — off unless
* enabled. The Dev Center enables it when it creates an app (its "User
* Feedback" settings toggle turns it off); apps created through
* `puter.apps.create`/`update` default to off and opt in via `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<Record<string, unknown> | null> {
if (app) {
// Names may legally start with "app-" (e.g. "app-center"), so a
// prefix heuristic would misroute them; try uid first, then name.
return await this.stores.app.resolveApp(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, the app has an owner to deliver to, and this
* deployment can deliver at all (email transport configured). Without a
* transport every submission would be stored-and-lost — the rows have no
* other read path — while the sender is told it was sent, so the feature
* reports itself unavailable instead.
*/
acceptsFeedback(app: Record<string, unknown> | null): boolean {
return Boolean(
this.clients.email.isConfigured &&
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.
// `includeOwnRow` distinguishes the pre-insert check (this
// submission not yet counted) from the post-insert recount (it is).
const since = Math.floor(Date.now() / 1000) - 24 * 60 * 60;
const capsBreached = async (
includeOwnRow: boolean,
): Promise<boolean> => {
const slack = includeOwnRow ? 1 : 0;
const [userAppCount, userCount] = await Promise.all([
this.stores.appFeedback.countByUserAndAppSince(
userId,
appId,
since,
),
this.stores.appFeedback.countByUserSince(userId, since),
]);
return (
userAppCount >=
AppFeedbackService.PER_USER_APP_DAILY_LIMIT + slack ||
userCount >= AppFeedbackService.PER_USER_DAILY_LIMIT + slack
);
};
const tooManyError = new HttpError(
429,
'You have sent a lot of feedback recently — please try again later',
{ legacyCode: 'too_many_requests' },
);
if (await capsBreached(false)) {
throw tooManyError;
}
const row = await this.stores.appFeedback.create({
appId,
appUid,
userId,
message: normalized,
sourceEnv: sourceEnv ?? null,
sourceOrigin: sourceOrigin ?? null,
});
// The pre-check is check-then-insert, so parallel submissions (or
// multiple nodes) can all pass it on the same stale count. Recount
// with this row included and roll it back if a concurrent burst
// pushed past a cap — these caps must fail closed, not just usually
// hold.
if (await capsBreached(true)) {
await this.stores.appFeedback.deleteById(row.id);
throw tooManyError;
}
// 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<string, unknown>;
appId: number;
feedbackId: number;
message: string;
senderUserId: number;
since: number;
}): Promise<void> {
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;
// Claim an email-cap slot *before* sending: flip email_sent, recount
// with the claim included, and release the slot if a concurrent
// burst pushed past the cap. Counting before sending would fail
// open — parallel submissions could each read an under-cap count and
// all send. The cost is that a crash mid-send burns a slot without
// delivering; the cap is an upper bound, not a quota owed.
await this.stores.appFeedback.markEmailSent(feedbackId);
const emailedToday =
await this.stores.appFeedback.countEmailedByAppSince(appId, since);
if (emailedToday > AppFeedbackService.PER_APP_DAILY_EMAIL_LIMIT) {
await this.stores.appFeedback.unmarkEmailSent(feedbackId);
return;
}
const sender = await this.stores.user.getById(senderUserId);
// Share the sender's email so the developer can respond — but only
// when it's verified. An unverified address can be anyone's (typed at
// signup, never proven), so using it as Reply-To would let a sender
// point the developer's reply at a stranger's inbox. Unverified
// senders still get their feedback delivered, just without a
// reply path. The dialog's privacy note mirrors this split (see
// app_feedback_privacy_note / app_feedback_privacy_note_no_email).
const senderEmail =
sender?.email && sender.email_confirmed ? sender.email : null;
try {
await this.clients.email.send(
owner.email,
'app-user-feedback',
{
owner_username: owner.username,
sender_username: sender?.username ?? 'A Puter user',
sender_email: senderEmail,
// 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))}`,
// The footer's "manage it" pointer — built from
// config.origin like app_link so it holds on self-hosted
// deployments.
dev_center_link: `${this.config.origin}/app/dev-center`,
message,
},
senderEmail ? { replyTo: senderEmail } : {},
);
} catch (e) {
// Release the claimed slot — the mail never went out.
await this.stores.appFeedback.unmarkEmailSent(feedbackId);
throw e;
}
}
}
+5
View File
@@ -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,
+2
View File
@@ -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]);
}
@@ -0,0 +1,330 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { AppFeedbackStore } from './AppFeedbackStore.js';
// Runs against the real in-memory database, so the SQL (and the engine's
// boolean/bigint representations) is exercised rather than described.
let server: PuterServer;
let store: AppFeedbackStore;
beforeAll(async () => {
server = await setupTestServer();
store = server.stores.appFeedback;
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async (): Promise<number> => {
const username = `fdbk-store-${Math.random().toString(36).slice(2, 10)}`;
const user = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
});
return user.id;
};
const makeApp = async (ownerUserId: number) => {
const name = `fdbk-store-app-${Math.random().toString(36).slice(2, 10)}`;
return await server.stores.app.create(
{
name,
title: `Feedback Store Test ${name}`,
index_url: `https://${name}.example.com`,
},
{ ownerUserId },
);
};
// Each test gets its own user and app so the counts can't see other tests'
// rows — the table is shared across the whole file.
const makeScope = async () => {
const ownerId = await makeUser();
const [app, userId] = await Promise.all([makeApp(ownerId), makeUser()]);
return { app, userId };
};
const readRow = async (id: number) => {
const rows = (await server.clients.db.read(
'SELECT * FROM `app_feedback` WHERE `id` = ?',
[id],
)) as Array<Record<string, unknown>>;
return rows[0] ?? null;
};
// Backdate a row so window-scoped counts can be tested without waiting.
const setCreatedAt = async (id: number, createdAt: number) => {
await server.clients.db.write(
'UPDATE `app_feedback` SET `created_at` = ? WHERE `id` = ?',
[createdAt, id],
);
};
const HOUR = 60 * 60;
const now = () => Math.floor(Date.now() / 1000);
describe('AppFeedbackStore.create', () => {
it('inserts a row with a fresh uid, email_sent false, and a unix timestamp', async () => {
const { app, userId } = await makeScope();
const before = now();
const created = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'hello dev',
});
expect(created.id).toBeGreaterThan(0);
expect(created.uid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
const row = (await readRow(created.id))!;
expect(row).toMatchObject({
uid: created.uid,
app_uid: app.uid,
message: 'hello dev',
});
// Engine-agnostic reads: pg returns BIGINT as string and BOOLEAN as
// boolean, sqlite returns numbers for both.
expect(Number(row.app_id)).toBe(app.id);
expect(Number(row.user_id)).toBe(userId);
expect(Boolean(row.email_sent)).toBe(false);
expect(Number(row.created_at)).toBeGreaterThanOrEqual(before);
expect(Number(row.created_at)).toBeLessThanOrEqual(now());
});
it('persists source_env / source_origin and defaults them to null', async () => {
const { app, userId } = await makeScope();
const withSource = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'from the web',
sourceEnv: 'web',
sourceOrigin: 'https://example.com',
});
expect(await readRow(withSource.id)).toMatchObject({
source_env: 'web',
source_origin: 'https://example.com',
});
const withoutSource = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'from the desktop',
});
expect(await readRow(withoutSource.id)).toMatchObject({
source_env: null,
source_origin: null,
});
});
it('gives every row a distinct uid', async () => {
const { app, userId } = await makeScope();
const rows = await Promise.all(
[1, 2, 3].map((n) =>
store.create({
appId: app.id,
appUid: app.uid,
userId,
message: `msg ${n}`,
}),
),
);
expect(new Set(rows.map((r) => r.uid)).size).toBe(3);
});
});
describe('AppFeedbackStore counts', () => {
it('countByUserSince counts only this user and only inside the window', async () => {
const { app, userId } = await makeScope();
const otherUserId = await makeUser();
const since = now() - 24 * HOUR;
const recent = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'recent',
});
const old = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'old',
});
await setCreatedAt(old.id, since - HOUR);
await store.create({
appId: app.id,
appUid: app.uid,
userId: otherUserId,
message: 'someone else',
});
expect(await store.countByUserSince(userId, since)).toBe(1);
// The boundary is inclusive — a row exactly at `since` still counts.
const row = (await readRow(recent.id))!;
expect(
await store.countByUserSince(userId, Number(row.created_at)),
).toBe(1);
expect(await store.countByUserSince(otherUserId, since)).toBe(1);
});
it('countByUserSince spans apps, countByUserAndAppSince does not', async () => {
const { app, userId } = await makeScope();
const otherApp = await makeApp(await makeUser());
const since = now() - 24 * HOUR;
for (const target of [app, app, otherApp]) {
await store.create({
appId: target.id,
appUid: target.uid,
userId,
message: 'hi',
});
}
expect(await store.countByUserSince(userId, since)).toBe(3);
expect(await store.countByUserAndAppSince(userId, app.id, since)).toBe(
2,
);
expect(
await store.countByUserAndAppSince(userId, otherApp.id, since),
).toBe(1);
});
it('countEmailedByAppSince counts only emailed rows in the window', async () => {
const { app, userId } = await makeScope();
const since = now() - 24 * HOUR;
const emailed = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'emailed',
});
await store.markEmailSent(emailed.id);
// Not emailed.
await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'stored only',
});
// Emailed, but yesterday.
const stale = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'emailed long ago',
});
await store.markEmailSent(stale.id);
await setCreatedAt(stale.id, since - HOUR);
expect(await store.countEmailedByAppSince(app.id, since)).toBe(1);
});
it('returns 0 rather than null when an app has no feedback', async () => {
const { app, userId } = await makeScope();
const since = now() - 24 * HOUR;
expect(await store.countEmailedByAppSince(app.id, since)).toBe(0);
expect(await store.countByUserSince(userId, since)).toBe(0);
expect(await store.countByUserAndAppSince(userId, app.id, since)).toBe(
0,
);
});
});
describe('AppFeedbackStore email-sent claim', () => {
it('marks and unmarks a single row, and the count follows', async () => {
const { app, userId } = await makeScope();
const since = now() - 24 * HOUR;
const row = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'claimed',
});
const sibling = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'untouched',
});
await store.markEmailSent(row.id);
expect(Boolean((await readRow(row.id))!.email_sent)).toBe(true);
expect(Boolean((await readRow(sibling.id))!.email_sent)).toBe(false);
expect(await store.countEmailedByAppSince(app.id, since)).toBe(1);
await store.unmarkEmailSent(row.id);
expect(Boolean((await readRow(row.id))!.email_sent)).toBe(false);
expect(await store.countEmailedByAppSince(app.id, since)).toBe(0);
});
});
describe('AppFeedbackStore.deleteById', () => {
it('removes the row and leaves the rest of the app alone', async () => {
const { app, userId } = await makeScope();
const since = now() - 24 * HOUR;
const doomed = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'rolled back',
});
const kept = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'kept',
});
await store.deleteById(doomed.id);
expect(await readRow(doomed.id)).toBeNull();
expect(await readRow(kept.id)).not.toBeNull();
expect(await store.countByUserSince(userId, since)).toBe(1);
});
it('is a no-op for an id that no longer exists', async () => {
const { app, userId } = await makeScope();
const row = await store.create({
appId: app.id,
appUid: app.uid,
userId,
message: 'gone',
});
await store.deleteById(row.id);
await expect(store.deleteById(row.id)).resolves.toBeUndefined();
});
});
@@ -0,0 +1,158 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<number> {
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<number> {
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<number> {
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 claims it
* with {@link markEmailSent} before sending and releases it with
* {@link unmarkEmailSent} if the cap was breached or the send failed, so the
* email cap fails closed under concurrent submissions.
*/
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 (claim a cap slot). */
async markEmailSent(id: number): Promise<void> {
await this.#setEmailSent(id, true);
}
/**
* Revert {@link markEmailSent} — releases a claimed email-cap slot when the
* cap turned out breached or the send failed.
*/
async unmarkEmailSent(id: number): Promise<void> {
await this.#setEmailSent(id, false);
}
async #setEmailSent(id: number, sent: boolean): Promise<void> {
await this.clients.db.write(
'UPDATE `app_feedback` SET `email_sent` = ? WHERE `id` = ?',
[this.clients.db.booleanValue(sent), id],
);
}
/** Delete one feedback row. Backs the service's cap-race rollback. */
async deleteById(id: number): Promise<void> {
await this.clients.db.write(
'DELETE FROM `app_feedback` WHERE `id` = ?',
[id],
);
}
}
+3
View File
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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,
+9 -19
View File
@@ -759,8 +759,7 @@ export class SystemKVStore extends PuterStore {
fetched = response.Item ? [response.Item as KvCachedItem] : [];
fetchUnits = Number(
(response.ConsumedCapacity?.CapacityUnits as
| number
| undefined) ?? 0,
number | undefined) ?? 0,
);
}
@@ -832,8 +831,7 @@ export class SystemKVStore extends PuterStore {
probeUsage,
writeUsage(
response.ConsumedCapacity?.CapacityUnits as
| number
| undefined,
number | undefined,
),
),
};
@@ -927,8 +925,7 @@ export class SystemKVStore extends PuterStore {
probeUsage,
writeUsage(
(response.ConsumedCapacity?.CapacityUnits as
| number
| undefined) ?? 1,
number | undefined) ?? 1,
),
),
};
@@ -960,9 +957,7 @@ export class SystemKVStore extends PuterStore {
| { key: string; value: unknown }[]
| {
items:
| string[]
| unknown[]
| { key: string; value: unknown }[];
string[] | unknown[] | { key: string; value: unknown }[];
cursor?: string;
total?: number;
}
@@ -1041,8 +1036,7 @@ export class SystemKVStore extends PuterStore {
usage,
readUsage(
(response.ConsumedCapacity?.CapacityUnits as
| number
| undefined) ?? 1,
number | undefined) ?? 1,
),
);
return response;
@@ -1059,8 +1053,7 @@ export class SystemKVStore extends PuterStore {
const skip = await runQuery(remaining, startKey, 'COUNT');
remaining -= Number(skip.Count ?? 0);
startKey = skip.LastEvaluatedKey as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
if (!startKey) {
exhausted = remaining > 0;
break;
@@ -1083,8 +1076,7 @@ export class SystemKVStore extends PuterStore {
>),
);
nextKey = response.LastEvaluatedKey as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
pages++;
if (normalizedLimit === undefined) {
// Legacy full listing: follow continuation pages so the
@@ -1120,8 +1112,7 @@ export class SystemKVStore extends PuterStore {
const counted = await runQuery(0, countKey, 'COUNT');
total += Number(counted.Count ?? 0);
countKey = counted.LastEvaluatedKey as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
} while (countKey);
}
@@ -1473,8 +1464,7 @@ export class SystemKVStore extends PuterStore {
probeUsage,
writeUsage(
(response.ConsumedCapacity?.CapacityUnits as
| number
| undefined) ?? 1,
number | undefined) ?? 1,
),
),
};
+14 -1
View File
@@ -191,6 +191,9 @@ async function create_app (title, source_path = null, items = null) {
maximizeOnStart: false,
background: false,
dedupeName: true,
// New apps accept user feedback by default; developers can turn this
// off in the app's settings (the "User Feedback" toggle).
feedbackEnabled: true,
metadata: {
window_resizable: true,
fullpage_on_landing: true,
@@ -509,6 +512,12 @@ function generate_edit_app_section (app) {
</div>
<h3 style="font-size: 23px; border-bottom: 1px solid #EEE; margin-top: 50px; margin-bottom: 0px;">Misc</h3>
<div style="margin-top:30px;">
<input type="checkbox" id="edit-app-user-feedback" name="edit-app-user-feedback" value="true" ${app.feedback_enabled ? 'checked' : ''}>
<label for="edit-app-user-feedback" style="display: inline;">User Feedback</label>
<p>When enabled, users can send you feedback about this app from within Puter. Feedback is delivered to your account's email address.</p>
</div>
<div style="margin-top:30px;">
<input type="checkbox" id="edit-app-locked" name="edit-app-locked" value="true" ${app.metadata?.locked ? 'checked' : ''}>
<label for="edit-app-locked" style="display: inline;">Delete Protection${lock_svg}</label>
@@ -586,6 +595,7 @@ function trackOriginalValues () {
locked: $('#edit-app-locked').is(':checked'),
fullPageOnLanding: $('#edit-app-fullpage-on-landing').is(':checked'),
setTitleToFile: $('#edit-app-set-title-to-file').is(':checked'),
userFeedback: $('#edit-app-user-feedback').is(':checked'),
},
};
}
@@ -626,7 +636,8 @@ function hasChanges () {
$('#edit-app-hide-titlebar').is(':checked') !== originalValues.checkboxes.hideTitleBar ||
$('#edit-app-locked').is(':checked') !== originalValues.checkboxes.locked ||
$('#edit-app-fullpage-on-landing').is(':checked') !== originalValues.checkboxes.fullPageOnLanding ||
$('#edit-app-set-title-to-file').is(':checked') !== originalValues.checkboxes.setTitleToFile
$('#edit-app-set-title-to-file').is(':checked') !== originalValues.checkboxes.setTitleToFile ||
$('#edit-app-user-feedback').is(':checked') !== originalValues.checkboxes.userFeedback
);
}
@@ -674,6 +685,7 @@ function resetToOriginalValues () {
$('#edit-app-locked').prop('checked', originalValues.checkboxes.locked);
$('#edit-app-fullpage-on-landing').prop('checked', originalValues.checkboxes.fullPageOnLanding);
$('#edit-app-set-title-to-file').prop('checked', originalValues.checkboxes.setTitleToFile);
$('#edit-app-user-feedback').prop('checked', originalValues.checkboxes.userFeedback);
if ( originalValues.icon ) {
$('#edit-app-icon').css('background-image', `url(${originalValues.icon})`);
@@ -1284,6 +1296,7 @@ async function saveEditedApp (mode) {
description: description,
maximizeOnStart: $('#edit-app-maximize-on-start').is(':checked'),
background: $('#edit-app-background').is(':checked'),
feedbackEnabled: $('#edit-app-user-feedback').is(':checked'),
metadata: {
fullpage_on_landing: $('#edit-app-fullpage-on-landing').is(':checked'),
social_image: socialImageUrl,
+1
View File
@@ -48,6 +48,7 @@ An object containing the options for the app to create. The object can contain t
- `filetypeAssociations` (Array<String>) (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
+1
View File
@@ -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/). Omitted leaves the app's current value unchanged.
- `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.
+1
View File
@@ -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
+49
View File
@@ -0,0 +1,49 @@
---
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 what is shared with you: their username, plus their email address (as the email's reply-to, so you can respond) when their email is verified.
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 it's enabled for your app. Apps created in the Dev Center have feedback enabled at creation — you can turn it off with the "User Feedback" toggle in the app's settings. Apps created through `puter.apps.create` default to off; enable it 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.
On a website that is cross-origin isolated (COOP severs the popup's connection to your page), the promise resolves `false` even though the popup stays open and the user may still submit their feedback there. Treat `false` as "not confirmed", not "not sent".
## Examples
```html;ui-show-feedback-dialog
<html>
<body>
<button id="feedback">Send us feedback</button>
<script src="https://js.puter.com/v2/"></script>
<script>
document.getElementById('feedback').addEventListener('click', async () => {
const sent = await puter.ui.showFeedbackDialog();
if (sent) {
puter.print('Thanks for your feedback!');
}
});
</script>
</body>
</html>
```
+8
View File
@@ -942,6 +942,14 @@ let sidebar = [
source: '/UI/showDirectoryPicker.md',
path: '/UI/showDirectoryPicker',
},
{
title: '<code>showFeedbackDialog()</code>',
page_title: '<code>puter.ui.showFeedbackDialog()</code>',
title_tag: 'puter.ui.showFeedbackDialog()',
icon: '/assets/img/function.svg',
source: '/UI/showFeedbackDialog.md',
path: '/UI/showFeedbackDialog',
},
{
title: '<code>showFontPicker()</code>',
page_title: '<code>puter.ui.showFontPicker()</code>',
+98
View File
@@ -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';
@@ -40,6 +41,39 @@ import { PROCESS_IPC_ATTACHED } from './definitions.js';
import TeePromise from './util/TeePromise.js';
window.ipc_handlers = {};
// Abuse guard for the app-triggered feedback dialog (`showFeedbackDialog`
// handler below). The dialog is a full-viewport modal reachable from app IPC
// with no user-gesture requirement, so an unguarded loop of
// `showFeedbackDialog()` calls would cover the desktop — taskbar and the
// app's own close button included — forever. One dialog may be open at a
// time, and every dismissal that sent nothing backs off the app's next open
// (10s, then 60s, then blocked until the page reloads); a successful send
// resets the backoff.
const FEEDBACK_DISMISS_BACKOFF_MS = [10_000, 60_000, Infinity];
let feedback_dialog_open = false;
const feedback_dialog_backoff = new Map(); // app uid/name -> { dismissals, until }
const feedback_dialog_may_open = (app_key) => {
if ( feedback_dialog_open ) return false;
const entry = feedback_dialog_backoff.get(app_key);
return ! entry || Date.now() >= entry.until;
};
const record_feedback_dialog_outcome = (app_key, sent) => {
if ( sent ) {
feedback_dialog_backoff.delete(app_key);
return;
}
const dismissals = (feedback_dialog_backoff.get(app_key)?.dismissals ?? 0) + 1;
const backoff = FEEDBACK_DISMISS_BACKOFF_MS[
Math.min(dismissals, FEEDBACK_DISMISS_BACKOFF_MS.length) - 1
];
feedback_dialog_backoff.set(app_key, {
dismissals,
until: Date.now() + backoff,
});
};
/**
* In Puter, apps are loaded in iframes and communicate with the graphical user interface (GUI), and each other, using the postMessage API.
* The following sets up an Inter-Process Messaging System between apps and the GUI that enables communication
@@ -1391,6 +1425,70 @@ 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,
}, '*');
};
// Re-entry / dismissal-backoff guard (see the state at the top of
// this file). Checked before the auth gate too: for a signed-out
// user the gate opens a full-page signup window, which an
// unguarded loop could spam just as effectively as the dialog.
const guard_key = app_uuid || app_name;
if ( ! feedback_dialog_may_open(guard_key) ) {
respond(false);
return;
}
feedback_dialog_open = true;
let sent = false;
try {
// 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;
}
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',
});
} catch ( e ) {
console.error('IPC showFeedbackDialog failed', e);
}
respond(sent === true);
$(target_iframe).get(0)?.focus({ preventScroll: true });
} finally {
feedback_dialog_open = false;
record_feedback_dialog_outcome(guard_key, sent);
}
}
//--------------------------------------------------------
// showFontPicker
//--------------------------------------------------------
else if ( event.data.msg === 'showFontPicker' ) {
+29 -3
View File
@@ -25,6 +25,7 @@ import UIWindowLogin from './UIWindowLogin.js';
import UIWindowItemProperties from './UIWindowItemProperties.js';
import new_context_menu_item from '../helpers/new_context_menu_item.js';
import refresh_item_container from '../helpers/refresh_item_container.js';
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
import launch_app from '../helpers/launch_app.js';
import publish_as_website from '../helpers/publish_as_website.js';
@@ -4589,18 +4590,32 @@ function attach_dashboard_app_drawer (el_window, options) {
: launch_icon;
const title = options.title || app_name;
// A "Send Feedback" control, shown only when the developer opted the app
// in (apps.feedbackEnabled → options.feedback_enabled). The dialog also
// re-checks opt-in server-side, so a stale flag can't send anywhere.
const feedback_enabled = options.feedback_enabled === true
|| options.feedback_enabled === 1;
const feedback_label = i18n('app_feedback_title');
// A message/comment glyph (bubble with text lines) — clearer at this size
// than a bare speech bubble, which reads as a magnifier.
const feedback_btn = feedback_enabled ? `
<button type="button" class="dashboard-app-drawer-btn dashboard-app-drawer-feedback" title="${feedback_label}" aria-label="${feedback_label}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><line x1="7.5" y1="9" x2="16.5" y2="9"/><line x1="7.5" y1="12.5" x2="13" y2="12.5"/></svg>
</button>` : '';
// The toggle comes FIRST in the DOM so Tab reaches it before the
// controls' buttons; both layers are absolutely positioned (see
// dashboard.css), so DOM order doesn't affect the visuals.
// dashboard.css), so DOM order doesn't affect the visuals. `has-feedback`
// widens the surface so the extra control doesn't clip the close button.
const $drawer = $(`
<div class="dashboard-app-drawer collapsed">
<div class="dashboard-app-drawer collapsed${feedback_enabled ? ' has-feedback' : ''}">
<button type="button" class="dashboard-app-drawer-toggle" aria-expanded="false" title="App controls" aria-label="App controls">
<span class="dashboard-app-drawer-grabber" aria-hidden="true"></span>
</button>
<div class="dashboard-app-drawer-clip">
<div class="dashboard-app-drawer-controls">
<img class="dashboard-app-drawer-icon" src="${html_encode(icon)}" alt="" draggable="false">
<span class="dashboard-app-drawer-title">${html_encode(title)}</span>
<span class="dashboard-app-drawer-title">${html_encode(title)}</span>${feedback_btn}
<button type="button" class="dashboard-app-drawer-btn dashboard-app-drawer-minimize" title="Minimize to Dashboard" aria-label="Minimize to Dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
@@ -4727,6 +4742,17 @@ function attach_dashboard_app_drawer (el_window, options) {
e.stopPropagation();
$(el_window).close();
});
// Opens the feedback dialog for THIS app. Identify it by uid when we
// have one (unique, unspoofable) and fall back to the name; the dialog
// is modal over the app window it belongs to.
$drawer.find('.dashboard-app-drawer-feedback').on('click', function (e) {
e.stopPropagation();
collapse();
UIWindowAppFeedback({
app: options.app_uuid || app_name,
source: 'app',
});
});
// showWindow forces the drawer shut when the window is restored — the
// intro already ran on open, and a restore should bring back the app,
+254
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
// Keep in sync with AppFeedbackService.MESSAGE_MAX_LENGTH on the backend.
const MESSAGE_MAX_LENGTH = 4000;
const SUCCESS_AUTOCLOSE_MS = 1600;
let feedback_modal_seq = 0;
/**
* "Send feedback to this app's developer" modal, behind
* `puter.ui.showFeedbackDialog()`. Not to be confused with UIWindowFeedback,
* which is Puter's own Contact Us form.
*
* Deliberately NOT a UIWindow: it's a from-scratch, theme-aware overlay in
* the same spirit as the dashboard modals (uninstall, add-app), so it renders
* consistently in every context it's opened from — the desktop (app IPC), the
* dashboard app-drawer, and the standalone puter.com popup.
*
* The target is named by exactly one of `options.app` (app uid or name — the
* desktop/drawer paths know which app asked) or `options.origin` (the popup
* path's browser-attested opener origin). The modal 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' }} options
* @returns {Promise<boolean>} 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) => {
const modal_id = `app-feedback-${++feedback_modal_seq}`;
let settled = false;
let sending = false;
let closed = false;
const authToken = puter.authToken ?? window.auth_token;
const target_params = options.app
? { app: options.app }
: { origin: options.origin };
// Mirror what AppFeedbackService actually shares: the username
// always, the sender's email only when it exists and is verified
// (unverified addresses get no Reply-To, so the developer cannot
// respond). The note must not promise either more or less.
const shares_email = Boolean(window.user?.email && window.user?.email_confirmed);
const privacy_note_key = shares_email
? 'app_feedback_privacy_note'
: 'app_feedback_privacy_note_no_email';
const titleId = `${modal_id}-title`;
const h = `
<div class="app-feedback-overlay" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
<div class="app-feedback-modal" tabindex="-1">
<div class="app-feedback-head">
<h2 class="app-feedback-title" id="${titleId}">${i18n('app_feedback_title')}</h2>
<button type="button" class="app-feedback-x" aria-label="${html_encode(i18n('close'))}" title="${html_encode(i18n('close'))}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
</div>
<div class="app-feedback-body">
<div class="app-feedback-loading">${i18n('loading')}…</div>
<div class="app-feedback-unavailable" style="display:none;">
<p class="app-feedback-unavailable-message"></p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn app-feedback-btn-primary app-feedback-close-btn">${i18n('close')}</button>
</div>
</div>
<div class="app-feedback-form" style="display:none;">
<div class="app-feedback-target">
<div class="app-feedback-target-title"></div>
<div class="app-feedback-target-name"></div>
</div>
<p class="app-feedback-c2a">${i18n('app_feedback_c2a')}</p>
<textarea class="app-feedback-message" maxlength="${MESSAGE_MAX_LENGTH}" placeholder="${html_encode(i18n('app_feedback_placeholder'))}"></textarea>
<div class="app-feedback-counter">0 / ${MESSAGE_MAX_LENGTH}</div>
<p class="app-feedback-note">${i18n(privacy_note_key)}</p>
<p class="app-feedback-error" role="alert" style="display:none;"></p>
<div class="app-feedback-actions">
<button type="button" class="app-feedback-btn app-feedback-cancel-btn">${i18n('cancel')}</button>
<button type="button" class="app-feedback-btn app-feedback-btn-primary app-feedback-send-btn" disabled>${i18n('send')}</button>
</div>
</div>
<div class="app-feedback-success" style="display:none;">
<div class="app-feedback-success-check" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</div>
<p class="app-feedback-success-text">${i18n('app_feedback_sent')}</p>
</div>
</div>
</div>
</div>
`;
const $overlay = $(h);
$('body').append($overlay);
// Kick the entrance transition on the next frame.
requestAnimationFrame(() => $overlay.addClass('app-feedback-open'));
const settle = (sent) => {
if ( settled ) return;
settled = true;
resolve(sent === true);
};
const close = () => {
if ( closed ) return;
closed = true;
$(document).off(`keydown.${modal_id}`);
$overlay.removeClass('app-feedback-open');
// Let the exit transition play, then remove.
setTimeout(() => $overlay.remove(), 160);
settle(false); // no-op if already settled true on success
};
const showPane = (pane) => {
$overlay.find('.app-feedback-loading, .app-feedback-unavailable, .app-feedback-form, .app-feedback-success').hide();
$overlay.find(`.app-feedback-${pane}`).show();
};
const showUnavailable = (messageKey) => {
$overlay.find('.app-feedback-unavailable-message').text(i18n(messageKey));
showPane('unavailable');
};
// Escape closes (unless a submit is in flight); backdrop click closes.
$(document).on(`keydown.${modal_id}`, (e) => {
if ( e.key === 'Escape' && ! sending ) close();
});
$overlay.on('mousedown', (e) => {
if ( e.target === $overlay.get(0) && ! sending ) close();
});
// Same in-flight gate as Escape/backdrop: closing while the POST is
// pending would settle false for a submission that still lands
// server-side — the developer gets the email while the app is told
// nothing was sent.
$overlay.find('.app-feedback-x, .app-feedback-close-btn, .app-feedback-cancel-btn').on('click', () => {
if ( ! sending ) close();
});
$overlay.find('.app-feedback-message').on('input', function () {
$overlay.find('.app-feedback-counter').text(`${this.value.length} / ${MESSAGE_MAX_LENGTH}`);
$overlay.find('.app-feedback-send-btn').prop('disabled', sending || this.value.trim() === '');
});
const send = async () => {
const $btn = $overlay.find('.app-feedback-send-btn');
const message = String($overlay.find('.app-feedback-message').val() || '').trim();
if ( ! message || sending ) return;
sending = true;
$btn.prop('disabled', true);
$overlay.find('.app-feedback-x, .app-feedback-cancel-btn').prop('disabled', true);
$overlay.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';
$overlay.find('.app-feedback-error').text(i18n(key)).show();
return;
}
settle(true);
showPane('success');
setTimeout(close, SUCCESS_AUTOCLOSE_MS);
} catch ( e ) {
// Shown inside the form so the message survives for a retry.
console.error('app-feedback: submit failed', e);
$overlay.find('.app-feedback-error').text(i18n('app_feedback_error')).show();
} finally {
sending = false;
$overlay.find('.app-feedback-x, .app-feedback-cancel-btn').prop('disabled', false);
$overlay.find('.app-feedback-send-btn').prop('disabled',
settled || String($overlay.find('.app-feedback-message').val() || '').trim() === '');
}
};
$overlay.find('.app-feedback-send-btn').on('click', send);
// Enter alone belongs to the textarea (feedback may need paragraphs).
$overlay.find('.app-feedback-message').on('keydown', (e) => {
if ( e.key === 'Enter' && (e.metaKey || e.ctrlKey) ) {
e.preventDefault();
send();
}
});
// -- 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.
(async () => {
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 ( closed ) return;
if ( ! target.enabled ) {
showUnavailable('app_feedback_not_available');
return;
}
$overlay.find('.app-feedback-target-title').text(target.app?.title ?? '');
$overlay.find('.app-feedback-target-name').text(target.app?.name ?? '');
showPane('form');
$overlay.find('.app-feedback-message').get(0)?.focus({ preventScroll: true });
} catch ( e ) {
console.error('app-feedback: target check failed', e);
if ( ! closed ) showUnavailable('app_feedback_error');
}
})();
});
}
export default UIWindowAppFeedback;
+283
View File
@@ -1523,6 +1523,281 @@ input.myapps-group-name:focus {
cursor: default;
}
/* App feedback modal (puter.ui.showFeedbackDialog) — a from-scratch,
theme-aware overlay in the spirit of the modals above, but used from more
than the dashboard (the desktop app-IPC path and the standalone popup too).
Self-contained color tokens (light default + dark override) so it themes
correctly wherever it's mounted, independent of the dashboard variables. */
.app-feedback-overlay {
--afb-bg: #ffffff;
--afb-fg: #16202b;
--afb-muted: #64748b;
--afb-border: rgba(15, 23, 42, 0.12);
--afb-field-bg: #ffffff;
--afb-field-border: rgba(15, 23, 42, 0.18);
--afb-hover: rgba(15, 23, 42, 0.05);
--afb-scrim: rgba(0, 0, 0, 0.5);
--afb-error: #c62828;
position: fixed;
inset: 0;
/* Above app windows, the popup shell, and the drawer. */
z-index: 2147483000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
background: var(--afb-scrim);
opacity: 0;
transition: opacity 0.16s ease;
-webkit-font-smoothing: antialiased;
}
.app-feedback-overlay.app-feedback-open {
opacity: 1;
}
@media (prefers-color-scheme: dark) {
.app-feedback-overlay {
--afb-bg: #24262c;
--afb-fg: #e8ecf1;
--afb-muted: #9aa7b4;
--afb-border: rgba(255, 255, 255, 0.12);
--afb-field-bg: #1b1d22;
--afb-field-border: rgba(255, 255, 255, 0.16);
--afb-hover: rgba(255, 255, 255, 0.08);
--afb-scrim: rgba(0, 0, 0, 0.66);
--afb-error: #ff6b81;
}
}
.app-feedback-modal {
width: min(440px, 100%);
max-height: calc(100vh - 40px);
overflow-y: auto;
background: var(--afb-bg);
color: var(--afb-fg);
border: 1px solid var(--afb-border);
border-radius: 14px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
transform: translateY(8px) scale(0.98);
transition: transform 0.16s ease;
outline: none;
}
.app-feedback-open .app-feedback-modal {
transform: none;
}
.app-feedback-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 0 20px;
}
.app-feedback-title {
margin: 0;
font-size: 17px;
font-weight: 600;
}
.app-feedback-x {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
flex: none;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: var(--afb-muted);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
}
.app-feedback-x svg {
width: 16px;
height: 16px;
}
@media (hover: hover) {
.app-feedback-x:hover:not(:disabled) {
background: var(--afb-hover);
color: var(--afb-fg);
}
}
.app-feedback-body {
padding: 12px 20px 20px;
}
.app-feedback-loading {
text-align: center;
color: var(--afb-muted);
padding: 28px 10px;
font-size: 14px;
}
.app-feedback-target {
margin-bottom: 14px;
}
.app-feedback-target-title {
font-size: 15px;
font-weight: 600;
word-break: break-word;
}
.app-feedback-target-name {
font-size: 12px;
color: var(--afb-muted);
word-break: break-all;
margin-top: 2px;
}
.app-feedback-c2a {
margin: 0 0 10px;
font-size: 14px;
line-height: 1.45;
}
.app-feedback-unavailable-message {
margin: 8px 0 4px;
font-size: 14px;
line-height: 1.45;
text-align: center;
color: var(--afb-muted);
}
.app-feedback-message {
display: block;
width: 100%;
box-sizing: border-box;
min-height: 130px;
resize: vertical;
padding: 10px 12px;
font: inherit;
font-size: 14px;
line-height: 1.45;
color: var(--afb-fg);
background: var(--afb-field-bg);
border: 1px solid var(--afb-field-border);
border-radius: 8px;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.app-feedback-message:focus {
outline: none;
border-color: #088ef0;
box-shadow: 0 0 0 3px rgba(8, 142, 240, 0.18);
}
.app-feedback-message::placeholder {
color: var(--afb-muted);
}
.app-feedback-counter {
text-align: right;
font-size: 11px;
color: var(--afb-muted);
margin-top: 4px;
}
.app-feedback-note {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.4;
color: var(--afb-muted);
}
.app-feedback-error {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.4;
color: var(--afb-error);
}
.app-feedback-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
}
.app-feedback-btn {
padding: 8px 18px;
font: inherit;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
border: 1px solid var(--afb-field-border);
background: var(--afb-bg);
color: var(--afb-fg);
cursor: pointer;
transition: background-color 0.12s ease, opacity 0.12s ease, filter 0.12s ease;
}
@media (hover: hover) {
.app-feedback-btn:hover:not(:disabled) {
background: var(--afb-hover);
}
}
.app-feedback-btn-primary {
border-color: #088ef0;
background: linear-gradient(#34a5f8, #088ef0);
color: #fff;
}
@media (hover: hover) {
/* :not(:disabled) matches the base .app-feedback-btn hover rule's
specificity, so this later rule wins for primary buttons again. */
.app-feedback-btn-primary:hover:not(:disabled) {
filter: brightness(1.05);
background: linear-gradient(#34a5f8, #088ef0);
}
}
.app-feedback-btn-primary:disabled {
opacity: 0.5;
cursor: default;
filter: none;
}
/* Cancel/X are disabled while a submit is in flight. */
.app-feedback-btn:disabled,
.app-feedback-x:disabled {
opacity: 0.5;
cursor: default;
}
.app-feedback-success {
text-align: center;
padding: 18px 10px 8px;
}
.app-feedback-success-check {
width: 52px;
height: 52px;
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(34, 197, 94, 0.14);
color: #16a34a;
}
.app-feedback-success-check svg {
width: 28px;
height: 28px;
}
.app-feedback-success-text {
margin: 0;
font-size: 15px;
font-weight: 500;
}
/* Bottom-sheet on narrow screens. */
@media (max-width: 500px) {
.app-feedback-overlay {
padding: 0;
align-items: flex-end;
}
.app-feedback-modal {
width: 100%;
max-height: 92vh;
border-radius: 14px 14px 0 0;
transform: translateY(16px);
}
.app-feedback-open .app-feedback-modal {
transform: none;
}
}
/* Add-an-app chooser (see showAddAppModal): the three ways to get an app, as
rows in the same modal shell. min-width gives the two-line rows room without
overflowing a narrow phone; the shell's own max-width caps the other end. */
@@ -5390,6 +5665,13 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
opacity 0.2s ease;
}
/* An opted-in app carries a third control (feedback) before minimize; widen
the derived open surface by one button + its 2px gap so the trailing close
button isn't clipped. --open-w stays derived from the same parts. */
.dashboard-app-drawer.has-feedback {
--open-w: calc(12px + var(--icon) + var(--i2t) + var(--title-w) + var(--t2b) + var(--btn) + 2px + var(--btn) + 2px + var(--btn) + 10px);
}
.dashboard-app-drawer.collapsed {
width: var(--shut-w);
height: calc(var(--shut-h) + var(--safe-top));
@@ -5574,6 +5856,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
transition: background-color 0.12s ease;
}
.dashboard-app-drawer-feedback,
.dashboard-app-drawer-minimize {
margin-right: 2px;
}
+4
View File
@@ -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;
+10
View File
@@ -558,6 +558,13 @@ const launch_app = async (options) => {
// Add locale to URL
iframe_url.searchParams.append('puter.locale', window.locale);
// Newer IPC dialogs this GUI can answer, comma-separated. The SDK
// consults this before posting a message an older GUI has no handler
// for: such a message is never replied to, and a reply timeout can't
// stand in for the check because legitimate replies only arrive when
// the user closes the dialog.
iframe_url.searchParams.append('puter.gui_features', 'feedback-dialog');
// Add options.args to URL
iframe_url.searchParams.append('puter.args', JSON.stringify(options.args ?? {}));
@@ -678,6 +685,9 @@ const launch_app = async (options) => {
window_class: 'window-app',
update_window_url: true,
app_uuid: app_info.uuid ?? app_info.uid,
// Surfaced on the dashboard app-drawer as a "Send Feedback"
// control when the developer opted in (apps.feedbackEnabled).
feedback_enabled: app_info.feedback_enabled,
top: top,
left: left,
height: window_height,
+9
View File
@@ -42,6 +42,15 @@ 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 and email address will be shared with the developer so they can respond.',
app_feedback_privacy_note_no_email: 'Your username will be shared with the developer.',
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',
+118 -57
View File
@@ -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';
@@ -71,7 +72,7 @@ import { ThemeService } from './services/ThemeService.js';
// silently resolve to the factory — use `window.privacy_aware_path` instead.
import { privacy_aware_path as privacy_aware_path_factory } from './util/desktop.js';
import { resolveAPIOrigin } from './util/apiOrigin.js';
import { deliversTokenToOpener } from './util/popupAuth.js';
import { deliversTokenToOpener, runsUserAppTokenExchange } from './util/popupAuth.js';
import { verifyOidcPopupReturn } from './util/popupOidcReturn.js';
const postAuthActions = async (action) => {
@@ -366,7 +367,7 @@ const postAuthActions = async (action) => {
});
}
return;
} else {
} else if ( runsUserAppTokenExchange(action) ) {
const deliver_token_to_opener = deliversTokenToOpener(action);
try {
let data = await window.getUserAppToken(new URL(window.openerOrigin).origin);
@@ -735,6 +736,62 @@ 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',
});
} 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) {
@@ -2123,37 +2180,39 @@ window.initgui = async function (options) {
(async () => {
let closing = false;
try {
let msg_id =
window.url_query_params.get('msg_id');
let data = await window.getUserAppToken(
new URL(window.openerOrigin).origin,
);
// A network failure here returns null and
// an HTTP failure returns the parsed error
// body, neither of which carries a token;
// the reads below would fault or hand the
// opener an `undefined` token.
if (!data?.token) {
throw new Error(
'user-app token exchange returned no token',
);
}
// This is an implicit app and the app_uid is sent back from the server
// we cache it here so that we can use it later
window.host_app_uid = data.app_uid;
// send token to parent
if (deliversTokenToOpener(action)) {
window.opener?.postMessage(
{
msg: 'puter.token',
success: true,
msg_id: msg_id,
token: data.token,
username: window.user.username,
app_uid: data.app_uid,
},
window.openerOrigin,
if (runsUserAppTokenExchange(action)) {
let msg_id =
window.url_query_params.get('msg_id');
let data = await window.getUserAppToken(
new URL(window.openerOrigin).origin,
);
// A network failure here returns null and
// an HTTP failure returns the parsed error
// body, neither of which carries a token;
// the reads below would fault or hand the
// opener an `undefined` token.
if (!data?.token) {
throw new Error(
'user-app token exchange returned no token',
);
}
// This is an implicit app and the app_uid is sent back from the server
// we cache it here so that we can use it later
window.host_app_uid = data.app_uid;
// send token to parent
if (deliversTokenToOpener(action)) {
window.opener?.postMessage(
{
msg: 'puter.token',
success: true,
msg_id: msg_id,
token: data.token,
username: window.user.username,
app_uid: data.app_uid,
},
window.openerOrigin,
);
}
}
// close popup
if (!action || action === 'sign-in') {
@@ -2216,32 +2275,34 @@ window.initgui = async function (options) {
if (window.embedded_in_popup)
(async () => {
try {
let msg_id =
window.url_query_params.get('msg_id');
let data = await window.getUserAppToken(
new URL(window.openerOrigin).origin,
);
if (!data?.token) {
throw new Error(
'user-app token exchange returned no token',
);
}
// This is an implicit app and the app_uid is sent back from the server
// we cache it here so that we can use it later
window.host_app_uid = data.app_uid;
// send token to parent
if (deliversTokenToOpener(action)) {
window.opener?.postMessage(
{
msg: 'puter.token',
success: true,
msg_id: msg_id,
token: data.token,
username: window.user.username,
app_uid: data.app_uid,
},
window.openerOrigin,
if (runsUserAppTokenExchange(action)) {
let msg_id =
window.url_query_params.get('msg_id');
let data = await window.getUserAppToken(
new URL(window.openerOrigin).origin,
);
if (!data?.token) {
throw new Error(
'user-app token exchange returned no token',
);
}
// This is an implicit app and the app_uid is sent back from the server
// we cache it here so that we can use it later
window.host_app_uid = data.app_uid;
// send token to parent
if (deliversTokenToOpener(action)) {
window.opener?.postMessage(
{
msg: 'puter.token',
success: true,
msg_id: msg_id,
token: data.token,
username: window.user.username,
app_uid: data.app_uid,
},
window.openerOrigin,
);
}
}
} catch (err) {
console.error(
+30 -3
View File
@@ -34,14 +34,15 @@
*/
/** 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.
*
* The token exchange itself still runs for the excluded actions — it bootstraps
* For `request-permission` the token exchange itself still runs — it bootstraps
* the app row a permission grant needs and caches `host_app_uid` — only the
* hand-off to the opener is suppressed.
* hand-off to the opener is suppressed. `send-feedback` skips the exchange
* entirely; see {@link runsUserAppTokenExchange}.
*
* @param {string|null|undefined} action - The popup's `action`, as parsed from
* the URL (`/action/<name>` or `?action=<name>`); undefined for a plain
@@ -51,6 +52,32 @@ const NON_AUTH_POPUP_ACTIONS = new Set(['request-permission']);
export const deliversTokenToOpener = (action) =>
!NON_AUTH_POPUP_ACTIONS.has(action);
/**
* Popup actions that must not run the user-app token exchange at all.
*
* The exchange (`/auth/get-user-app-token`) is a write, not a read: it
* bootstraps an app row for the opener origin, grants
* `flag:app-is-authenticated` (what makes the site count as connected to the
* account), and creates the app's per-user AppData directory. Sign-in and
* file-picker popups need that, and `request-permission` needs the
* bootstrapped app row a grant is written against — but a send-feedback popup
* only ever *reads* app identity from its attested origin server-side, so
* merely opening (or cancelling) the feedback dialog must not record a
* user↔site relationship.
*/
const TOKEN_EXCHANGE_FREE_ACTIONS = new Set(['send-feedback']);
/**
* Whether a popup running `action` runs the user-app token exchange.
*
* @param {string|null|undefined} action - The popup's `action`, as parsed from
* the URL (`/action/<name>` or `?action=<name>`); undefined for a plain
* sign-in popup.
* @returns {boolean} `true` if the exchange should run for this popup.
*/
export const runsUserAppTokenExchange = (action) =>
!TOKEN_EXCHANGE_FREE_ACTIONS.has(action);
/*
* On the `opener_origin` URL parameter, which this module used to gate.
*
+32
View File
@@ -22,6 +22,7 @@ import * as popupAuth from './popupAuth.js';
import {
deliversTokenToOpener,
offersFederatedSignInInPopup,
runsUserAppTokenExchange,
} from './popupAuth.js';
describe('deliversTokenToOpener', () => {
@@ -32,6 +33,11 @@ describe('deliversTokenToOpener', () => {
expect(deliversTokenToOpener('request-permission')).toBe(false);
});
it('withholds the token from a feedback popup', () => {
// Sending feedback is not consent to sign the site in either.
expect(deliversTokenToOpener('send-feedback')).toBe(false);
});
it('delivers the token for the sign-in flows that exist to authenticate', () => {
// `undefined` is a plain sign-in popup, which carries no action.
for ( const action of [undefined, 'sign-in'] ) {
@@ -68,6 +74,32 @@ describe('offersFederatedSignInInPopup', () => {
});
});
describe('runsUserAppTokenExchange', () => {
it('skips the exchange entirely for a feedback popup', () => {
// The exchange is a write, not a read: it bootstraps an app row for
// the opener origin and records the user↔site relationship. The
// feedback flow resolves app identity read-only from the attested
// origin server-side, so opening (or cancelling) the dialog must not
// connect the site to the account.
expect(runsUserAppTokenExchange('send-feedback')).toBe(false);
});
it('runs it for sign-in, the pickers, and the permission prompt', () => {
// request-permission keeps the exchange: the app row it bootstraps is
// what a grant is written against.
for ( const action of [
undefined,
'sign-in',
'show-open-file-picker',
'show-directory-picker',
'show-save-file-picker',
'request-permission',
] ) {
expect(runsUserAppTokenExchange(action)).toBe(true);
}
});
});
describe('the retired opener_origin gate', () => {
it('is gone, because no action believes the raw parameter now', () => {
// It used to allow `opener_origin` for every action but
+166
View File
@@ -1917,6 +1917,172 @@ export class UIModule 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<boolean>} `true` when the user submitted feedback,
* `false` when the dialog was dismissed or feedback is unavailable.
* Never rejects. On a cross-origin-isolated page COOP severs the
* popup's link to the opener, so the promise resolves `false` while
* the popup stays open and the user may still submit — `false` means
* "not confirmed", not "not sent".
*/
async showFeedbackDialog () {
if ( this.env === 'app' ) {
// The host GUI advertises the IPC dialogs it can answer via
// `puter.gui_features` on the app iframe's URL (see
// launch_app.js). A GUI that predates this feature has no
// handler for the message and would never reply, hanging this
// never-rejecting promise forever — and a reply timeout can't
// stand in for the check, because a legitimate reply only
// arrives when the user closes the dialog.
const features = new URLSearchParams(globalThis.location?.search ?? '')
.get('puter.gui_features')?.split(',') ?? [];
if ( ! features.includes('feedback-dialog') ) {
return false;
}
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.
*
@@ -26,4 +26,5 @@ export const toAppObject = (raw) => ({
background: raw.background,
filetype_associations: raw.filetypeAssociations,
metadata: raw.metadata,
feedback_enabled: raw.feedbackEnabled,
});
+7
View File
@@ -32,6 +32,9 @@
* @property {boolean} [maximize_on_start] Whether the app should be maximized when it is started.
* Default is `false`.
* @property {boolean} [background] Whether the app should run in the background. Default is `false`.
* @property {boolean} [feedback_enabled] Whether users can send feedback to the app's developer through
* `puter.ui.showFeedbackDialog()`. Default is `false` for apps created through `puter.apps.create`; the
* Dev Center enables it at creation.
* @property {string[]} [filetype_associations] The file types that the app can open. Each string is in
* the format `".<extension>"` or `"mime/type"`, e.g. `[".txt", "image/png"]`. For a directory
* association, the string should be `.directory`.
@@ -85,6 +88,8 @@
* @property {boolean} [maximizeOnStart] Whether the app should be maximized when it is started.
* Defaults to `false`.
* @property {boolean} [background] Whether the app should run in the background. Defaults to `false`.
* @property {boolean} [feedbackEnabled] Whether users can send feedback to the app's developer through
* `puter.ui.showFeedbackDialog()`. Defaults to `false`.
* @property {string[]} [filetypeAssociations] The filetypes that the app can open. File extensions and
* MIME types are supported, e.g. `[".txt", ".md", "application/pdf"]`. Defaults to `[]`.
* @property {Record<string, unknown>} [metadata] Custom metadata for the app as arbitrary key-value pairs.
@@ -105,6 +110,8 @@
* @property {boolean} [maximizeOnStart] Whether the app should be maximized when it is started.
* Defaults to `false`.
* @property {boolean} [background] Whether the app should run in the background. Defaults to `false`.
* @property {boolean} [feedbackEnabled] Whether users can send feedback to the app's developer through
* `puter.ui.showFeedbackDialog()`. Omitted leaves the app's current value unchanged.
* @property {string[]} [filetypeAssociations] The filetypes that the app can open. File extensions and
* MIME types are supported, e.g. `[".txt", ".md", "application/pdf"]`. Defaults to `[]`.
* @property {Record<string, unknown>} [metadata] Custom metadata for the app as arbitrary key-value pairs.
@@ -144,6 +144,28 @@ export default suite('apps', {
t.assert.equal(updated.description, 'New description');
},
'feedbackEnabled round-trips on create and survives an unrelated update':
async (t) => {
// Mirrors Dev Center: create with feedback on, then a Save-style
// update that omits feedbackEnabled must not clear it.
const created = await t.puter.apps.create({
name: 'apps-suite-feedback',
indexURL: 'https://example.com/feedback',
feedbackEnabled: true,
});
t.assert.equal(Boolean(created.feedback_enabled), true);
const updated = await t.puter.apps.update('apps-suite-feedback', {
title: 'Feedback App',
});
t.assert.equal(Boolean(updated.feedback_enabled), true);
const disabled = await t.puter.apps.update('apps-suite-feedback', {
feedbackEnabled: false,
});
t.assert.equal(Boolean(disabled.feedback_enabled), false);
},
'get of an unknown app rejects': async (t) => {
await t.assert.rejects(
() => t.puter.apps.get('apps-suite-does-not-exist'),
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>puter-js test fixture: showFeedbackDialog</title>
<style>
body { font-family: system-ui, sans-serif; padding: 16px; }
button { padding: 8px 12px; margin-right: 8px; font-size: 14px; }
#log { margin-top: 16px; padding: 8px; background: #f5f5f5; border: 1px solid #ddd; min-height: 60px; font-family: monospace; white-space: pre-wrap; }
.ready #status { color: green; }
</style>
</head>
<body>
<h1>puter-js showFeedbackDialog fixture</h1>
<p id="status">Loading puter.js…</p>
<button id="send-feedback" disabled>Send Feedback</button>
<div id="log" data-testid="log"></div>
<script>
// Point the SDK at the local Puter instance (env=web popup flow).
window.PUTER_ORIGIN = 'http://puter.localhost:4100';
// Must be the real api subdomain: the SDK sends credentialed CORS
// requests, and only the api host answers with
// Access-Control-Allow-Credentials — on the GUI host they all fail.
window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100';
</script>
<script src="/dist/puter.dev.js"></script>
<script>
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
function log(entry) {
const line = document.createElement('div');
line.textContent = entry;
line.dataset.entry = entry;
logEl.appendChild(line);
}
function ready() {
document.body.classList.add('ready');
statusEl.textContent = `Ready (env=${puter.env})`;
document.getElementById('send-feedback').disabled = false;
}
function waitForPuter() {
if (window.puter && puter.ui) ready();
else setTimeout(waitForPuter, 50);
}
waitForPuter();
document.getElementById('send-feedback').addEventListener('click', async () => {
try {
const sent = await puter.ui.showFeedbackDialog();
log(`feedback:${sent}`);
} catch (e) {
log(`feedback:error:${e?.message ?? e}`);
}
});
</script>
</body>
</html>
@@ -0,0 +1,188 @@
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('.app-feedback-overlay');
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('.app-feedback-overlay');
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('.app-feedback-overlay');
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('dashboard app-drawer feedback control', () => {
test('opted-in app shows a feedback control in the drawer that opens the dialog', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FEEDBACK_FIXTURE_URL });
try {
await setFeedbackEnabled(page, appName, true);
// Open the app on the /app/<name> route — dashboard mode, where
// the app window wears the top-edge drawer instead of a titlebar.
await page.goto(`/app/${appName}`);
const drawer = page.locator('.window-app .dashboard-app-drawer');
await expect(drawer).toBeAttached({ timeout: 60_000 });
// The drawer opens on hover; the control lives inside it.
await drawer.hover();
const feedbackBtn = drawer.locator('.dashboard-app-drawer-feedback');
await expect(feedbackBtn).toBeVisible();
await feedbackBtn.click();
// It opens the same dialog, targeting this app.
const dialog = page.locator('.app-feedback-overlay');
await expect(dialog.locator('.app-feedback-form')).toBeVisible({ timeout: 15_000 });
await expect(dialog.locator('.app-feedback-target-name')).toHaveText(appName);
} finally {
await deleteTestApp(page, appName);
}
});
test('an app that has not opted in shows no feedback control', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FEEDBACK_FIXTURE_URL });
try {
await page.goto(`/app/${appName}`);
const drawer = page.locator('.window-app .dashboard-app-drawer');
await expect(drawer).toBeAttached({ timeout: 60_000 });
await drawer.hover();
// The control is only rendered for opted-in apps.
await expect(drawer.locator('.dashboard-app-drawer-feedback')).toHaveCount(0);
} finally {
await deleteTestApp(page, appName);
}
});
});
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('.app-feedback-overlay');
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();
});
});