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 @@
-
-
+
+
@@ -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 }}
+
+
+
+
+
+