From 96aeaf9b363cc00d24c27b2b9784ef95896d6cab Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Thu, 22 Oct 2026 16:03:02 +0200 Subject: [PATCH 1/8] docs: fix broken Tests badge and renamed repo URLs in README tests.yml was removed when CI was aligned with laravel-start, so the badge image 404'd. Point it at the Pest database matrix workflow, which runs on pushes to main, and use the renamed repository URL. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c9ba68d..c974a1f 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@

- Tests - E2E + Tests + E2E npm PHP 8.5 Laravel 13 @@ -173,7 +173,7 @@ No build step needed: ``` -Each [release](https://github.com/codebar-ag/feedback/releases) lists the SRI hash for `integrity="…"`. +Each [release](https://github.com/codebar-ag/feedback.codebar.ch/releases) lists the SRI hash for `integrity="…"`. ### Laravel example From d752b9cd3f58da796aaf4aef6c6a5170f984d45c Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Thu, 22 Oct 2026 16:20:19 +0200 Subject: [PATCH 2/8] docs: explain how to add multiple origins and IPs Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index c9ba68d..5361316 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,14 @@ php artisan feedback:complete {id} {id} php artisan feedback:reopen {id} ``` +`--origin`, `--add-origin`, `--remove-origin`, `--ip`, `--add-ip` and `--remove-ip` take one value each. Repeat the option for several hosts; comma-separated lists are not split: + +```bash +php artisan feedback:project:create "Name" --origin=https://app.example.com --origin=https://staging.example.com --origin=http://localhost:3000 +php artisan feedback:project:update {key} --add-origin=https://web.example.test --add-origin=https://preview.example.com +php artisan feedback:project:update {key} --remove-origin=https://staging.example.com +``` + All commands accept `--json`. Changing commands accept `--by="Name"`, which is recorded in the activity log. - **Origins**: `https://app.example.com` exact, `https://*.example.com` all subdomains, `http://localhost:*` any port, `*` any origin (needs `--allow-any-origin`). From 278f2f3d34487f432c5a01d23bc12ab01070b351 Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Thu, 22 Oct 2026 16:26:08 +0200 Subject: [PATCH 3/8] fix(widget): framework attributes no longer blank the screenshot The page clone is rendered as SVG and must be valid XML. Attributes such as Alpine's :class or x-on:click are not, and a single one made the browser render the whole capture as an empty image (gray in annotation mode, white when sent). Strip such attributes from the clone only. Co-Authored-By: Claude Opus 5.5 --- widget/src/capture/snapshot.ts | 16 ++++++++++++ widget/tests/e2e/screenshot.spec.ts | 40 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/widget/src/capture/snapshot.ts b/widget/src/capture/snapshot.ts index 83c3741..b3cd8e7 100644 --- a/widget/src/capture/snapshot.ts +++ b/widget/src/capture/snapshot.ts @@ -28,6 +28,21 @@ export class CaptureError extends Error { const MAX_BYTES = 3 * 1024 * 1024; +/** XML-safe attribute name without a namespace prefix. */ +const XML_NAME = /^[A-Za-z_][\w.-]*$/; + +/** + * The clone is rendered as SVG, so it has to be valid XML. Framework attributes such as + * Alpine's `:class`, `x-on:click` or Vue's `v-bind:href` are not, and a single one makes the + * browser render the whole page as an empty image. They carry no styling, so drop them. + */ +export function stripInvalidAttributes(node: Node): void { + if (!(node instanceof Element)) return; + for (const attr of Array.from(node.attributes)) { + if (attr.namespaceURI === null && !XML_NAME.test(attr.name)) node.removeAttribute(attr.name); + } +} + export function toJpeg(canvas: HTMLCanvasElement, quality: number): Promise { return new Promise((resolve, reject) => { canvas.toBlob((b) => (b ? resolve(b) : reject(new CaptureError('canvas_limit'))), 'image/jpeg', quality); @@ -68,6 +83,7 @@ export async function capturePage(options: CaptureOptions): Promise !(node instanceof Element && (options.exclude.includes(node) || (ignore !== '' && node.matches(ignore)))), + onCloneEachNode: stripInvalidAttributes, timeout: options.timeoutMs ?? 15_000, }), timeout, diff --git a/widget/tests/e2e/screenshot.spec.ts b/widget/tests/e2e/screenshot.spec.ts index 0fe9891..f1fa6d7 100644 --- a/widget/tests/e2e/screenshot.spec.ts +++ b/widget/tests/e2e/screenshot.spec.ts @@ -188,6 +188,46 @@ test('sends the full page with masks and burnt-in marks', async ({ page }, info) expect(annotations[0]).toEqual({ type: 'rect', x: 100, y: 650, w: 200, h: 110 }); }); +test('framework attributes like :class or x-on:click do not blank the screenshot', async ({ page }, info) => { + const id = sid(info); + const x = w(page); + await page.evaluate(() => { + const el = document.createElement('div'); + el.id = 'alpine'; + el.style.cssText = 'width:120px;height:120px;background:rgb(0,0,255)'; + for (const name of [':class', 'x-on:click', 'x-bind:title', '@click']) el.setAttribute(name, 'x'); + const tpl = document.createElement('template'); + tpl.innerHTML = '

'; + document.getElementById('intro')!.append(el, tpl); + }); + await fill(page, 'a@b.ch', 'Alpine Seite'); + await takeShot(page); + await x.done.click(); + await x.submit.click(); + await expect(x.success).toBeVisible(); + + const point = await page.evaluate(() => { + const r = document.getElementById('alpine')!.getBoundingClientRect(); + return { x: r.left + scrollX + 60, y: r.top + scrollY + 60 }; + }); + const pixel = await page.evaluate( + async ({ url, point }) => { + const img = new Image(); + img.src = url; + await img.decode(); + const c = document.createElement('canvas'); + c.width = img.naturalWidth; + c.height = img.naturalHeight; + const ctx = c.getContext('2d')!; + ctx.drawImage(img, 0, 0); + return Array.from(ctx.getImageData(point.x, point.y, 1, 1).data.slice(0, 3)); + }, + { url: `/s/${id}/__shot/0`, point }, + ); + const [r, g, b] = pixel as [number, number, number]; + expect(r < 80 && g < 80 && b > 140, `pixel ${pixel}`).toBe(true); +}); + test('a failed screenshot keeps the camera usable and sends without image', async ({ page }, info) => { const id = sid(info); const x = w(page); From b3d3f3c4e1e2da6d15fca9e59e043b08eba4701f Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Thu, 22 Oct 2026 16:42:31 +0200 Subject: [PATCH 4/8] feat: signed feedback page with screenshot download, status changes and share link - feedback:link creates a signed, expiring link to an HTML page with all form details, console/network log and the screenshot (inline + download). Team links can complete and reopen (--by is logged), --read-only omits that. - The store response returns a read-only share link (FEEDBACK_SHARE_LINK_MINUTES, 0 = off); the widget shows it on the thank you screen with a copy button. - Annotation mode gets a close button (top right, bottom right on mobile). - Only validly signed links pass BlockBrowserNavigation; invalid or expired links get the usual empty 403. Co-Authored-By: Claude Opus 5.5 --- .../Commands/Feedback/LinkFeedbackCommand.php | 68 +++++ .../Controllers/FeedbackPageController.php | 78 +++++ .../FeedbackScreenshotController.php | 34 +++ .../Controllers/FeedbackStatusController.php | 41 +++ .../Middleware/BlockBrowserNavigation.php | 7 +- .../Resources/FeedbackReceivedResource.php | 24 +- app/Support/FeedbackLink.php | 56 ++++ bootstrap/app.php | 5 +- config/feedback.php | 13 + e2e/feedback.e2e.ts | 46 +++ resources/views/feedback/show.blade.php | 178 +++++++++++ routes/api.php | 12 + tests/Feature/FeedbackLinkTest.php | 278 ++++++++++++++++++ tests/Feature/HttpTest.php | 4 +- tests/Feature/StoreFeedbackTest.php | 4 +- widget/src/api/client.ts | 8 +- widget/src/i18n/de_CH.ts | 4 + widget/src/i18n/en_CH.ts | 4 + widget/src/i18n/fr_CH.ts | 4 + widget/src/i18n/it_CH.ts | 4 + widget/src/types.ts | 2 +- widget/src/ui/status-page.ts | 60 +++- widget/src/ui/tokens.css.ts | 8 + widget/src/ui/toolbar.ts | 14 +- widget/src/widget.ts | 10 +- widget/tests/e2e/mobile.spec.ts | 18 +- widget/tests/e2e/screenshot.spec.ts | 20 ++ widget/tests/e2e/status.spec.ts | 48 ++- widget/tests/unit/client.test.ts | 12 + 29 files changed, 1041 insertions(+), 23 deletions(-) create mode 100644 app/Console/Commands/Feedback/LinkFeedbackCommand.php create mode 100644 app/Http/Controllers/FeedbackPageController.php create mode 100644 app/Http/Controllers/FeedbackScreenshotController.php create mode 100644 app/Http/Controllers/FeedbackStatusController.php create mode 100644 app/Support/FeedbackLink.php create mode 100644 resources/views/feedback/show.blade.php create mode 100644 tests/Feature/FeedbackLinkTest.php diff --git a/app/Console/Commands/Feedback/LinkFeedbackCommand.php b/app/Console/Commands/Feedback/LinkFeedbackCommand.php new file mode 100644 index 0000000..a327c5b --- /dev/null +++ b/app/Console/Commands/Feedback/LinkFeedbackCommand.php @@ -0,0 +1,68 @@ +stringOption('expires') ?? (string) FeedbackLink::DEFAULT_MINUTES; + + if (! ctype_digit($minutes) || (int) $minutes < 1 || (int) $minutes > FeedbackLink::MAX_MINUTES) { + $this->components->error('--expires must be between 1 and '.FeedbackLink::MAX_MINUTES.' minutes.'); + + return self::INVALID; + } + + $entry = $this->findEntry($this->stringArgument('id')); + + if ($entry === null) { + return self::FAILURE; + } + + $expires = now()->addMinutes((int) $minutes)->startOfSecond(); + $hasScreenshot = $entry->screenshot()->exists(); + $manage = ! $this->option('read-only'); + $url = FeedbackLink::page($entry, $expires, $manage, $this->stringOption('by')); + $screenshotUrl = $hasScreenshot ? FeedbackLink::screenshot($entry, $expires, download: true) : null; + + if ($this->option('json')) { + return $this->printJson([ + 'id' => $entry->id, + 'url' => $url, + 'screenshot_url' => $screenshotUrl, + 'manage' => $manage, + 'expires_at' => $expires->toIso8601String(), + ]); + } + + $this->components->twoColumnDetail('Expires', $expires->toDateTimeString().' UTC'); + $this->components->twoColumnDetail('Screenshot', $hasScreenshot ? 'yes' : 'none'); + $this->components->twoColumnDetail('Status changes', $manage ? 'allowed' : 'read-only'); + $this->newLine(); + $this->line($url); + $this->newLine(); + $this->components->warn($manage + ? 'Anyone with this link can see the feedback and complete or reopen it until it expires.' + : 'Anyone with this link can see the feedback until it expires.'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/FeedbackPageController.php b/app/Http/Controllers/FeedbackPageController.php new file mode 100644 index 0000000..afd3a30 --- /dev/null +++ b/app/Http/Controllers/FeedbackPageController.php @@ -0,0 +1,78 @@ +integer('expires')); + $by = $request->query('by'); + // 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); + + return response()->view('feedback.show', [ + 'entry' => $entry, + 'project' => $entry->project, + 'shot' => $shot, + 'imageUrl' => $shot === null ? null : FeedbackLink::screenshot($entry, $expires), + '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), + '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'", + 'Cache-Control' => 'private, no-store', + 'Referrer-Policy' => 'no-referrer', + 'X-Content-Type-Options' => 'nosniff', + ]); + } + + /** + * Logs come from the browser: every cell becomes a plain string. + * + * @param array|null $log + * @param list $keys + * @return list> + */ + private static function rows(?array $log, array $keys): array + { + $rows = []; + + foreach ($log ?? [] as $item) { + if (! is_array($item)) { + continue; + } + + $row = []; + + foreach ($keys as $key) { + $value = $item[$key] ?? null; + $row[$key] = is_scalar($value) ? (string) $value : ''; + } + + $rows[] = $row; + } + + return $rows; + } +} diff --git a/app/Http/Controllers/FeedbackScreenshotController.php b/app/Http/Controllers/FeedbackScreenshotController.php new file mode 100644 index 0000000..6301c03 --- /dev/null +++ b/app/Http/Controllers/FeedbackScreenshotController.php @@ -0,0 +1,34 @@ +screenshot; + + if ($shot === null || $shot->data === null) { + throw new NotFoundHttpException; + } + + $disposition = $request->boolean('download') ? 'attachment' : 'inline'; + + return new Response($shot->data, 200, [ + 'Content-Type' => $shot->mime, + 'Content-Length' => (string) strlen($shot->data), + 'Content-Disposition' => "{$disposition}; filename=\"feedback-{$entry->id}.{$shot->extension()}\"", + 'Cache-Control' => 'private, no-store', + 'X-Content-Type-Options' => 'nosniff', + 'Referrer-Policy' => 'no-referrer', + ]); + } +} diff --git a/app/Http/Controllers/FeedbackStatusController.php b/app/Http/Controllers/FeedbackStatusController.php new file mode 100644 index 0000000..478d1de --- /dev/null +++ b/app/Http/Controllers/FeedbackStatusController.php @@ -0,0 +1,41 @@ +boolean('manage')) { + return new Response('', 403); + } + + $request->validate(['status' => ['required', 'in:open,completed']]); + + $by = $request->query('by'); + Context::add('activity_by', is_string($by) && $by !== '' ? $by : 'signed link'); + + $completed = $request->input('status') === 'completed'; + + if ($completed !== ($entry->completed_at !== null)) { + $entry->completed_at = $completed ? now() : null; + $entry->save(); + } + + $expires = Carbon::createFromTimestamp($request->integer('expires')); + + return new RedirectResponse(FeedbackLink::page($entry, $expires, manage: true, by: is_string($by) ? $by : null), 303); + } +} diff --git a/app/Http/Middleware/BlockBrowserNavigation.php b/app/Http/Middleware/BlockBrowserNavigation.php index f3f1383..390df5f 100644 --- a/app/Http/Middleware/BlockBrowserNavigation.php +++ b/app/Http/Middleware/BlockBrowserNavigation.php @@ -11,14 +11,17 @@ /** * The API is only for the widget (CORS requests) and for monitoring. Typing - * a URL into a browser is a navigation and gets an empty 403. + * a URL into a browser is a navigation and gets an empty 403. The only + * exception are valid signed links from feedback:link. */ final class BlockBrowserNavigation { /** @param Closure(Request): Response $next */ public function handle(Request $request, Closure $next): Response { - if ($request->headers->get('Sec-Fetch-Mode') === 'navigate' || $request->headers->get('Sec-Fetch-Dest') === 'document') { + $navigation = $request->headers->get('Sec-Fetch-Mode') === 'navigate' || $request->headers->get('Sec-Fetch-Dest') === 'document'; + + if ($navigation && ! $request->hasValidSignature()) { return new IlluminateResponse('', 403); } diff --git a/app/Http/Resources/FeedbackReceivedResource.php b/app/Http/Resources/FeedbackReceivedResource.php index d4963ac..d5ca3a7 100644 --- a/app/Http/Resources/FeedbackReceivedResource.php +++ b/app/Http/Resources/FeedbackReceivedResource.php @@ -5,6 +5,7 @@ namespace App\Http\Resources; use App\Models\FeedbackEntry; +use App\Support\FeedbackLink; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -13,9 +14,28 @@ final class FeedbackReceivedResource extends JsonResource { public static $wrap = null; - /** @return array{id: string, status: string} */ + public function __construct(private readonly FeedbackEntry $entry) + { + parent::__construct($entry); + } + + /** @return array{id: string, status: string, url?: string, url_expires_at?: string} */ public function toArray(Request $request): array { - return ['id' => $this->id, 'status' => 'received']; + $minutes = config('feedback.share_link_minutes'); + + if (! is_int($minutes) || $minutes < 1) { + return ['id' => $this->entry->id, 'status' => 'received']; + } + + // Signed link to the sender's own entry, shown on the thank you screen. + $expires = now()->addMinutes($minutes)->startOfSecond(); + + return [ + 'id' => $this->entry->id, + 'status' => 'received', + 'url' => FeedbackLink::page($this->entry, $expires), + 'url_expires_at' => $expires->toIso8601String(), + ]; } } diff --git a/app/Support/FeedbackLink.php b/app/Support/FeedbackLink.php new file mode 100644 index 0000000..8116c43 --- /dev/null +++ b/app/Support/FeedbackLink.php @@ -0,0 +1,56 @@ + $entry->id, ...self::access($manage, $by)]); + } + + /** Form target for completing and reopening, only for team links. */ + public static function status(FeedbackEntry $entry, DateTimeInterface $expires, ?string $by = null): string + { + return URL::temporarySignedRoute('feedback.status', $expires, ['entry' => $entry->id, ...self::access(true, $by)]); + } + + public static function screenshot(FeedbackEntry $entry, DateTimeInterface $expires, bool $download = false): string + { + return URL::temporarySignedRoute('feedback.screenshot', $expires, ['entry' => $entry->id, ...($download ? ['download' => 1] : [])]); + } + + /** @return array */ + private static function access(bool $manage, ?string $by): array + { + if (! $manage) { + return []; + } + + $by = trim((string) $by); + + return ['manage' => 1, ...($by !== '' ? ['by' => $by] : [])]; + } + + /** Only http(s) URLs become clickable links, anything else stays text. */ + public static function safeHref(?string $url): ?string + { + return is_string($url) && preg_match('#^https?://#i', $url) === 1 ? $url : null; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 42d731d..9e3a00d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -21,6 +21,7 @@ use Illuminate\Http\Middleware\ValidatePostSize; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Routing\Exceptions\InvalidSignatureException; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Routing\Middleware\ThrottleRequests; use Illuminate\Validation\ValidationException; @@ -71,8 +72,8 @@ $e instanceof ValidationException => ApiError::response(ErrorCode::ValidationFailed, 422, ['fields' => $e->errors()]), $e instanceof ThrottleRequestsException => ApiError::response(ErrorCode::RateLimited, 429, [], array_map(strval(...), $e->getHeaders())), $e instanceof PostTooLargeException => ApiError::response(ErrorCode::PayloadTooLarge, 413), - // Unknown paths reveal nothing: empty 403. - $e instanceof ModelNotFoundException, $e instanceof NotFoundHttpException => new Response('', 403), + // Unknown paths and invalid or expired links reveal nothing: empty 403. + $e instanceof ModelNotFoundException, $e instanceof NotFoundHttpException, $e instanceof InvalidSignatureException => new Response('', 403), $e instanceof MethodNotAllowedHttpException => ApiError::response(ErrorCode::MethodNotAllowed, 405, [], array_map(strval(...), $e->getHeaders())), $e instanceof HttpExceptionInterface => ApiError::response(ErrorCode::ServerError, $e->getStatusCode()), default => ApiError::response(ErrorCode::ServerError, 500), diff --git a/config/feedback.php b/config/feedback.php index d54b38b..ee5b9f8 100644 --- a/config/feedback.php +++ b/config/feedback.php @@ -60,6 +60,19 @@ 'min_elapsed_ms' => $limit('FEEDBACK_MIN_ELAPSED_MS', 2000), ], + /* + |-------------------------------------------------------------------------- + | Share link after sending + |-------------------------------------------------------------------------- + | + | Minutes the signed link returned to the widget stays valid. The thank + | you screen shows it with a copy button. 0 switches it off. Links for the + | team are created with feedback:link. + | + */ + + 'share_link_minutes' => ($minutes = $limit('FEEDBACK_SHARE_LINK_MINUTES', 7 * 24 * 60)) === null ? null : min($minutes, 30 * 24 * 60), + /* |-------------------------------------------------------------------------- | Screenshot limits diff --git a/e2e/feedback.e2e.ts b/e2e/feedback.e2e.ts index 1a4afb0..20bdaee 100644 --- a/e2e/feedback.e2e.ts +++ b/e2e/feedback.e2e.ts @@ -153,3 +153,49 @@ test('a repeated request with the same Idempotency-Key creates no duplicate', as expect((await second.json()).id).toBe((await first.json()).id); expect(entries().filter((e) => e.message === message)).toHaveLength(1); }); + +test('the share link on the thank you screen and the team link open the feedback page', async ({ page, context, browserName }) => { + const message = `Link ${browserName} ${Date.now()}`; + await open(page, keys().open); + await write(page, message); + await shadow(page, '.tr button').first().click(); + await expect(shadow(page, '.tb')).toBeVisible(); + await shadow(page, '.tb [data-action=done]').click(); + await shadow(page, 'button[type=submit]').click(); + await expect(shadow(page, '.st.ok')).toBeVisible(); + + // Sender: read-only link with copy button. + const senderUrl = await shadow(page, '.st.ok .sh input').inputValue(); + expect(senderUrl).toMatch(new RegExp(`^${API}/v1/feedback/[0-9a-z]{26}\\?expires=\\d+&signature=[0-9a-f]{64}$`)); + await shadow(page, '.st.ok button.cp').click(); + await expect(shadow(page, '.st.ok button.cp')).toHaveText('Kopiert'); + + const viewer = await context.newPage(); + await viewer.goto(senderUrl); + await expect(viewer.locator('h1')).toHaveText('E2E Website'); + await expect(viewer.locator('.message')).toHaveText(message); + await expect(viewer.locator('.status')).toHaveText('open'); + await expect(viewer.locator('form.state')).toHaveCount(0); + const img = viewer.locator('.shot img'); + await expect(img).toBeVisible(); + expect(await img.evaluate((el: HTMLImageElement) => el.complete && el.naturalWidth > 500)).toBe(true); + const [download] = await Promise.all([viewer.waitForEvent('download'), viewer.getByText(/^Download/).click()]); + expect(download.suggestedFilename()).toMatch(/^feedback-[0-9a-z]{26}\.jpg$/); + + // Team: feedback:link can complete and reopen. + const id = senderUrl.split('/v1/feedback/')[1]!.split('?')[0]!; + const team = artisanJson<{ url: string; manage: boolean }>('feedback:link', id, '--by=E2E Team'); + expect(team.manage).toBe(true); + await viewer.goto(team.url); + await viewer.getByRole('button', { name: 'Mark as completed' }).click(); + await expect(viewer.locator('.status')).toContainText('completed'); + expect(artisanJson('feedback:show', id).completed_at).not.toBeNull(); + await viewer.getByRole('button', { name: 'Reopen' }).click(); + await expect(viewer.locator('.status')).toHaveText('open'); + expect(artisanJson('feedback:show', id).completed_at).toBeNull(); + + // Tampered links reveal nothing. + const forged = await viewer.goto(senderUrl.replace(/signature=./, 'signature=0')); + expect(forged!.status()).toBe(403); + expect(await forged!.text()).toBe(''); +}); diff --git a/resources/views/feedback/show.blade.php b/resources/views/feedback/show.blade.php new file mode 100644 index 0000000..a3582e9 --- /dev/null +++ b/resources/views/feedback/show.blade.php @@ -0,0 +1,178 @@ + + + + + + + + Feedback {{ $entry->id }} · {{ $project->name }} + + + +
+
+

{{ $project->name }}

+ {{ $entry->id }} + @if ($entry->completed_at === null) + open + @else + completed {{ $entry->completed_at->toDateTimeString() }} + @endif + @if ($statusUrl !== null) +
+ @if ($entry->completed_at === null) + + @else + + @endif +
+ @endif +
+ +
+

Feedback

+

{{ $entry->message }}

+
+ +
+

Details

+
+
From
+
{{ trim(($entry->name ?? '').' <'.$entry->email.'>') }}
+
Created
+
{{ $entry->created_at->toDateTimeString() }} UTC
+
Page
+
+ @if ($pageHref !== null) + {{ $entry->page_url }} + @else + {{ $entry->page_url }} + @endif +
+ @if ($entry->page_title !== null) +
Page title
+
{{ $entry->page_title }}
+ @endif + @if ($entry->submitted_from_url !== null && $entry->submitted_from_url !== $entry->page_url) +
Sent from
+
+ @if ($sentFromHref !== null) + {{ $entry->submitted_from_url }} + @else + {{ $entry->submitted_from_url }} + @endif +
+ @endif +
Locale
+
{{ $entry->locale }}
+
System
+
{{ trim(($entry->os_name ?? '?').' '.($entry->os_version ?? '')) }}
+
Browser
+
{{ trim(($entry->browser_name ?? '?').' '.($entry->browser_version ?? '')) }} ({{ $entry->version_precision }})
+ @if ($entry->device_type !== null) +
Device
+
{{ $entry->device_type }}
+ @endif +
Viewport
+
{{ $entry->viewport_w ?? '?' }} × {{ $entry->viewport_h ?? '?' }}
+
Screen
+
{{ $entry->screen_w ?? '?' }} × {{ $entry->screen_h ?? '?' }} @ {{ $entry->dpr ?? '?' }}
+ @if ($entry->timezone !== null || $entry->browser_language !== null) +
Timezone / language
+
{{ $entry->timezone ?? '?' }} / {{ $entry->browser_language ?? '?' }}
+ @endif +
IP
+
{{ $entry->ip ?? '–' }}
+
Widget
+
{{ $entry->widget_version }}
+ @if ($entry->user_agent !== null) +
User agent
+
{{ $entry->user_agent }}
+ @endif +
+
+ +
+

Screenshot

+ @if ($shot !== null) + + Screenshot of {{ $entry->page_url }} + @else +

{{ $entry->screenshot_error === null ? 'No screenshot.' : 'No screenshot ('.$entry->screenshot_error.').' }}

+ @endif +
+ + @if ($console !== []) +
+

Console ({{ count($console) }})

+ + + + @foreach ($console as $row) + + @endforeach + +
TypeMessageCount
{{ $row['type'] }}{{ $row['message'] }}{{ $row['count'] }}
+
+ @endif + + @if ($network !== []) +
+

Network ({{ count($network) }})

+ + + + @foreach ($network as $row) + + @endforeach + +
MethodURLStatusmsCount
{{ $row['method'] }}{{ $row['url'] }}{{ $row['status'] }}{{ $row['duration'] }}{{ $row['count'] }}
+
+ @endif + + @if ($customData !== null) +
+

Custom data

+
{{ $customData }}
+
+ @endif + +
This link expires {{ $expires->toDateTimeString() }} UTC.
+
+ + diff --git a/routes/api.php b/routes/api.php index 5b96231..5552642 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use App\Http\Controllers\FeedbackPageController; +use App\Http\Controllers\FeedbackScreenshotController; +use App\Http\Controllers\FeedbackStatusController; use App\Http\Controllers\HealthController; use App\Http\Controllers\ProjectConfigController; use App\Http\Controllers\StoreFeedbackController; @@ -29,3 +32,12 @@ Route::post('feedback', StoreFeedbackController::class)->middleware('throttle:feedback')->name('feedback.store'); Route::get('config', ProjectConfigController::class)->name('feedback.config'); }); + +// Signed, expiring links from feedback:link: the only pages meant for a browser. +Route::prefix('v1/feedback/{entry}') + ->middleware('signed') + ->group(function (): void { + Route::get('/', FeedbackPageController::class)->name('feedback.page'); + Route::get('screenshot', FeedbackScreenshotController::class)->name('feedback.screenshot'); + Route::post('status', FeedbackStatusController::class)->name('feedback.status'); + }); diff --git a/tests/Feature/FeedbackLinkTest.php b/tests/Feature/FeedbackLinkTest.php new file mode 100644 index 0000000..5e20154 --- /dev/null +++ b/tests/Feature/FeedbackLinkTest.php @@ -0,0 +1,278 @@ + 'navigate', 'Sec-Fetch-Dest' => 'document']; + +beforeEach(function (): void { + $this->project = FeedbackProject::factory()->create(['name' => 'Alpha', 'allowed_origins' => [TEST_ORIGIN]]); +}); + +/** @return array{id: string, url: string, screenshot_url: string|null, expires_at: string} */ +function feedbackLink(string $id, array $options = []): array +{ + Artisan::call('feedback:link', ['id' => $id, '--json' => true, ...$options]); + + return json_decode(Artisan::output(), true); +} + +function entryWithShot(FeedbackProject $project, array $attributes = []): FeedbackEntry +{ + postFeedback($project, ['screenshot' => fakeJpeg(200, 400), ...$attributes])->assertCreated(); + + return FeedbackEntry::query()->latest('id')->firstOrFail(); +} + +it('creates a signed link with the default expiry', function (): void { + $this->freezeSecond(); + $entry = entryWithShot($this->project); + + $data = feedbackLink($entry->id); + + expect($data['id'])->toBe($entry->id) + ->and($data['url'])->toStartWith(url("/v1/feedback/{$entry->id}?expires="))->toContain('&signature=') + ->and($data['screenshot_url'])->toContain("/v1/feedback/{$entry->id}/screenshot?download=1&expires=") + ->and($data['expires_at'])->toBe(now()->addMinutes(FeedbackLink::DEFAULT_MINUTES)->toIso8601String()); + + test()->artisan('feedback:link', ['id' => $entry->id, '--expires' => '60']) + ->expectsOutputToContain('/v1/feedback/'.$entry->id) + ->expectsOutputToContain(now()->addHour()->toDateTimeString()) + ->expectsOutputToContain('Anyone with this link') + ->assertExitCode(0); +}); + +it('validates the link options', function (string $expires): void { + $entry = FeedbackEntry::factory()->for($this->project, 'project')->create(); + + test()->artisan('feedback:link', ['id' => $entry->id, '--expires' => $expires]) + ->expectsOutputToContain('--expires must be between 1 and 43200') + ->assertExitCode(2); +})->with(['0', 'x', '-5', '43201', '1.5']); + +it('fails for an unknown entry and links entries without screenshot', function (): void { + test()->artisan('feedback:link', ['id' => '01JUNKNOWN'])->assertExitCode(1); + + $entry = FeedbackEntry::factory()->for($this->project, 'project')->create(); + expect(feedbackLink($entry->id)['screenshot_url'])->toBeNull(); + test()->artisan('feedback:link', ['id' => $entry->id])->expectsOutputToContain('none')->assertExitCode(0); +}); + +it('shows the feedback page in the browser with all form details', function (): void { + $entry = entryWithShot($this->project, [ + 'identity' => json_encode(['id' => 'u-1', 'name' => 'Anna Muster']), + 'submitted_from_url' => 'https://www.beispiel.ch/zwei', + 'console_log' => json_encode([['type' => 'error', 'message' => 'Boom', 'count' => 2, 'time' => 1]]), + 'network_log' => json_encode([['method' => 'POST', 'url' => 'https://api.beispiel.ch/save', 'status' => 500, 'duration' => 120, 'count' => 1, 'time' => 1]]), + 'custom_data' => json_encode(['plan' => 'pro']), + ]); + + $response = test()->get(feedbackLink($entry->id)['url'], NAVIGATE); + + $response->assertOk() + ->assertHeader('Content-Type', 'text/html; charset=utf-8') + ->assertHeader('Cache-Control', 'no-store, private') + ->assertHeader('Referrer-Policy', 'no-referrer') + ->assertHeader('X-Robots-Tag', 'noindex, nofollow') + ->assertSee('Alpha') + ->assertSee('open') + ->assertSee('Die Schaltfläche «Speichern» reagiert nicht.') + ->assertSee('Anna Muster <anna@beispiel.ch>', false) + ->assertSee('assertSee('Sent from') + ->assertSee('https://www.beispiel.ch/zwei') + ->assertSee('
Browser
', false) + ->assertSee('1541 × 947') + ->assertSee('Europe/Zurich') + ->assertSee('Boom') + ->assertSee('https://api.beispiel.ch/save') + ->assertSee('"plan": "pro"', false) + ->assertSee('200 × 400') + ->assertSee('/screenshot?expires=', false) + ->assertSee('/screenshot?download=1&expires=', false); + + $csp = (string) $response->headers->get('Content-Security-Policy'); + preg_match("/'nonce-([^']+)'/", $csp, $nonce); + expect($csp)->toContain("default-src 'none'")->toContain("img-src 'self'")->toContain("frame-ancestors 'none'") + ->and($response->getContent())->toContain('