Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to the API and the widget are documented here. The format fo

### Added

- Feedback page redesign: screenshot first with the sender's message as a sticky, hideable card; technical details on a second tab with copy buttons for every value and log entry and "Copy report" as plain text; console errors highlighted and counted on the tab.
- Feedback pages behind signed, expiring links with all details, console and network log and the screenshot (inline and download).
- `feedback:link {id}` creates team links that can complete and reopen the entry (`--by` is logged, `--read-only`, `--expires`).
- The API returns a read-only share link after sending (`FEEDBACK_SHARE_LINK_MINUTES`, `0` turns it off); the widget shows it on the thank you screen with a copy button, `after_send` receives it as `url`.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ Changing commands accept `--by="Name"`, which is recorded in the activity log.

## Feedback pages and share links

Every entry has a page with the message, sender, page URL, browser, system, viewport, console and network log, custom data and the screenshot (inline, full size and as download). The page is reached through a **signed, expiring link**: no account, no admin panel, nothing to install for whoever receives it.
Every entry has a page built for fixing it. The **Feedback** tab shows the screenshot first, with the sender's message floating over it: name, email, comment, page and time, sticky while you scroll and hideable with one click. The **Technical** tab lists the environment (browser, system, viewport, screen, language, IP, user agent …), the console with errors and warnings highlighted and stack traces, failed requests and custom data. Every value and log entry has a copy button, **Copy report** puts everything into the clipboard as plain text for a ticket. Download and full size are one click away, team links add **Mark as completed** / **Reopen**. The page is reached through a **signed, expiring link**: no account, no admin panel, nothing to install for whoever receives it.

**Team links** come from the CLI and can complete and reopen the entry:

Expand Down Expand Up @@ -357,7 +357,7 @@ Event::listen(function (FeedbackReceived $event): void {
});
```

**Security.** Links are signed with `APP_KEY`; changing any part (ID, expiry, `manage`, `by`) invalidates them. Invalid and expired links get the same empty `403` as every other unknown URL. Anyone holding a link can use it until it expires, so pick the expiry to match where you share it. Rotating `APP_KEY` revokes all links at once. Pages are `noindex`, `no-store`, `no-referrer`, run no JavaScript and load nothing from other hosts. The link host comes from `APP_URL` (CLI) or the request (widget), so set `APP_URL` to the public URL of the API.
**Security.** Links are signed with `APP_KEY`; changing any part (ID, expiry, `manage`, `by`) invalidates them. Invalid and expired links get the same empty `403` as every other unknown URL. Anyone holding a link can use it until it expires, so pick the expiry to match where you share it. Rotating `APP_KEY` revokes all links at once. Pages are `noindex`, `no-store`, `no-referrer`, run only one inline script for tabs and copy buttons (allowed by a per-request nonce) and load nothing from other hosts. The link host comes from `APP_URL` (CLI) or the request (widget), so set `APP_URL` to the public URL of the API.

## Configuration

Expand Down
111 changes: 105 additions & 6 deletions app/Http/Controllers/FeedbackPageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
use Illuminate\Support\Str;

/**
* Read-only page of one feedback entry behind a signed link (feedback:link).
* The only HTML the API serves: no scripts, no external resources.
* Page of one feedback entry behind a signed link (feedback:link, share link):
* screenshot with the sender's message, technical details in a second tab.
* The only HTML the API serves: one nonce'd inline script (tabs, copy
* buttons), nothing loaded from other hosts.
*/
final class FeedbackPageController
{
Expand All @@ -24,6 +26,10 @@ public function __invoke(Request $request, FeedbackEntry $entry): Response
// Metadata only, the BLOB is served by the screenshot link.
$shot = $entry->screenshot()->select(['entry_id', 'mime', 'width', 'height', 'bytes'])->first();
$nonce = Str::random(32);
$environment = self::environment($entry);
$console = self::rows($entry->console_log, ['type', 'message', 'count', 'source', 'line', 'column', 'stack']);
$network = self::rows($entry->network_log, ['method', 'url', 'status', 'duration', 'count']);
$customData = $entry->custom_data === null || $entry->custom_data === [] ? null : (string) json_encode($entry->custom_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

return response()->view('feedback.show', [
'entry' => $entry,
Expand All @@ -33,20 +39,113 @@ public function __invoke(Request $request, FeedbackEntry $entry): Response
'downloadUrl' => $shot === null ? null : FeedbackLink::screenshot($entry, $expires, download: true),
'pageHref' => FeedbackLink::safeHref($entry->page_url),
'sentFromHref' => FeedbackLink::safeHref($entry->submitted_from_url),
'console' => self::rows($entry->console_log, ['type', 'message', 'count']),
'network' => self::rows($entry->network_log, ['method', 'url', 'status', 'duration', 'count']),
'customData' => $entry->custom_data === null || $entry->custom_data === [] ? null : (string) json_encode($entry->custom_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'environment' => $environment,
'console' => $console,
'consoleErrors' => count(array_filter($console, fn (array $row): bool => $row['type'] === 'error')),
'network' => $network,
'customData' => $customData,
'report' => self::report($entry, $environment, $console, $network, $customData),
'statusUrl' => $request->boolean('manage') ? FeedbackLink::status($entry, $expires, is_string($by) ? $by : null) : null,
'expires' => $expires,
'nonce' => $nonce,
])->withHeaders([
'Content-Security-Policy' => "default-src 'none'; img-src 'self'; style-src 'nonce-{$nonce}'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
'Content-Security-Policy' => "default-src 'none'; img-src 'self'; style-src 'nonce-{$nonce}'; script-src 'nonce-{$nonce}'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
'Cache-Control' => 'private, no-store',
'Referrer-Policy' => 'no-referrer',
'X-Content-Type-Options' => 'nosniff',
]);
}

/** @return array<string, string> label => value, only what is known */
private static function environment(FeedbackEntry $entry): array
{
$size = fn (?int $w, ?int $h): ?string => $w === null || $h === null ? null : "{$w} × {$h}";
$screen = $size($entry->screen_w, $entry->screen_h);

return array_filter([
'Browser' => trim(($entry->browser_name ?? '').' '.($entry->browser_version ?? '')) ?: null,
'Operating system' => trim(($entry->os_name ?? '').' '.($entry->os_version ?? '')) ?: null,
'Device' => $entry->device_type,
'Viewport' => $size($entry->viewport_w, $entry->viewport_h),
'Screen' => $screen === null ? null : $screen.($entry->dpr !== null ? " @ {$entry->dpr}x" : ''),
'Page size' => $size($entry->page_w, $entry->page_h),
'Scroll position' => $size($entry->scroll_x, $entry->scroll_y),
'Color depth' => $entry->color_depth === null ? null : "{$entry->color_depth} bit",
'Timezone' => $entry->timezone,
'Browser language' => $entry->browser_language,
'Widget language' => $entry->locale,
'Page' => $entry->page_url,
'Page title' => $entry->page_title,
'Sent from' => $entry->submitted_from_url !== $entry->page_url ? $entry->submitted_from_url : null,
'Referrer' => $entry->referrer,
'Origin' => $entry->origin,
'IP address' => $entry->ip,
'User ID' => $entry->identity_user_id,
'Time to send' => $entry->elapsed_ms === null ? null : number_format($entry->elapsed_ms / 1000, 1).' s',
'Widget version' => $entry->widget_version,
'User agent' => $entry->user_agent,
], fn (?string $value): bool => $value !== null && $value !== '');
}

/**
* Plain text of the technical tab, for "Copy report" (tickets, chats).
*
* @param array<string, string> $environment
* @param list<array<string, string>> $console
* @param list<array<string, string>> $network
*/
private static function report(FeedbackEntry $entry, array $environment, array $console, array $network, ?string $customData): string
{
$lines = ["Feedback {$entry->id} ({$entry->project->name}), {$entry->created_at->toDateTimeString()} UTC", '', $entry->message, ''];

foreach ($environment as $label => $value) {
$lines[] = "{$label}: {$value}";
}

if ($console !== []) {
$lines[] = '';
$lines[] = 'Console:';

foreach ($console as $row) {
$lines[] = self::consoleLine($row);
}
}

if ($network !== []) {
$lines[] = '';
$lines[] = 'Network:';

foreach ($network as $row) {
$lines[] = self::networkLine($row);
}
}

if ($customData !== null) {
$lines[] = '';
$lines[] = 'Custom data:';
$lines[] = $customData;
}

return implode("\n", $lines);
}

/** @param array<string, string> $row */
public static function consoleLine(array $row): string
{
$where = $row['source'] === '' ? '' : ' ('.$row['source'].($row['line'] === '' ? '' : ':'.$row['line']).($row['column'] === '' ? '' : ':'.$row['column']).')';
$times = (int) $row['count'] > 1 ? " ×{$row['count']}" : '';

return "[{$row['type']}] {$row['message']}{$where}{$times}".($row['stack'] === '' ? '' : "\n{$row['stack']}");
}

/** @param array<string, string> $row */
public static function networkLine(array $row): string
{
$times = (int) $row['count'] > 1 ? " ×{$row['count']}" : '';

return trim("{$row['method']} {$row['url']} → {$row['status']}".($row['duration'] === '' ? '' : " ({$row['duration']} ms)").$times);
}

/**
* Logs come from the browser: every cell becomes a plain string.
*
Expand Down
29 changes: 29 additions & 0 deletions e2e/feedback.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,35 @@
const img = viewer.locator('.shot img');
await expect(img).toBeVisible();
expect(await img.evaluate((el: HTMLImageElement) => el.complete && el.naturalWidth > 500)).toBe(true);
// Feedback first: the sender's message floats over the screenshot and can be hidden.
await expect(viewer.locator('#card')).toContainText('e2e@beispiel.ch');
await viewer.getByRole('button', { name: 'Hide message' }).click();
await expect(viewer.locator('.message')).toBeHidden();
await viewer.getByRole('button', { name: 'Show message' }).click();
await expect(viewer.locator('.message')).toBeVisible();
// Technical details on the second tab, with copy buttons.
await expect(viewer.locator('#panel-technical')).toBeHidden();
await viewer.getByRole('tab', { name: /Technical/ }).click();
await expect(viewer.locator('#panel-technical')).toBeVisible();
await expect(viewer.locator('#panel-feedback')).toBeHidden();
expect(new URL(viewer.url()).hash).toBe('#technical');
await expect(viewer.locator('#h-console')).toContainText('Console');
await expect(viewer.locator('.kv')).toContainText('Viewport');
const copyReport = viewer.getByRole('button', { name: 'Copy report' });
if (browserName === 'chromium') {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await copyReport.click();
const report = await viewer.evaluate(() => navigator.clipboard.readText());
expect(report).toContain(message);
expect(report).toContain('Console:');
} else {
await copyReport.click();
}
await expect(copyReport).toHaveClass(/done/);
await viewer.reload();
await expect(viewer.locator('#panel-technical')).toBeVisible();
await viewer.getByRole('tab', { name: 'Feedback' }).click();

const [download] = await Promise.all([viewer.waitForEvent('download'), viewer.getByText(/^Download/).click()]);
expect(download.suggestedFilename()).toMatch(/^feedback-[0-9a-z]{26}\.jpg$/);

Expand All @@ -214,7 +243,7 @@
const forged = await viewer.request.get(senderUrl.replace(/signature=./, 'signature=0'), {
headers: { 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document' },
});
expect(forged.status()).toBe(403);

Check failure on line 246 in e2e/feedback.e2e.ts

View workflow job for this annotation

GitHub Actions / Playwright (Chromium, Firefox, WebKit, Mobile) + full stack

[webkit] › ../e2e/feedback.e2e.ts:173:5 › the share link on the thank you screen and the team link open the feedback page

2) [webkit] › ../e2e/feedback.e2e.ts:173:5 › the share link on the thank you screen and the team link open the feedback page Error: expect(received).toBe(expected) // Object.is equality Expected: 403 Received: 200 244 | headers: { 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document' }, 245 | }); > 246 | expect(forged.status()).toBe(403); | ^ 247 | expect(await forged.text()).toBe(''); 248 | }); 249 | at /home/runner/work/feedback.codebar.ch/feedback.codebar.ch/e2e/feedback.e2e.ts:246:27

Check failure on line 246 in e2e/feedback.e2e.ts

View workflow job for this annotation

GitHub Actions / Playwright (Chromium, Firefox, WebKit, Mobile) + full stack

[firefox] › ../e2e/feedback.e2e.ts:173:5 › the share link on the thank you screen and the team link open the feedback page

1) [firefox] › ../e2e/feedback.e2e.ts:173:5 › the share link on the thank you screen and the team link open the feedback page Error: expect(received).toBe(expected) // Object.is equality Expected: 403 Received: 200 244 | headers: { 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document' }, 245 | }); > 246 | expect(forged.status()).toBe(403); | ^ 247 | expect(await forged.text()).toBe(''); 248 | }); 249 | at /home/runner/work/feedback.codebar.ch/feedback.codebar.ch/e2e/feedback.e2e.ts:246:27
expect(await forged.text()).toBe('');
});

Expand Down
Loading
Loading