diff --git a/CHANGELOG.md b/CHANGELOG.md index d93b434..b8466b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to the API and the widget are documented here. The format fo ## [Unreleased] +### Added + +- 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`. +- Close button in marking mode (top right, bottom right on phones), same as Esc. + +### Fixed + +- Screenshots of pages using Alpine, Vue or other framework attributes (`:class`, `x-on:click`) were blank. + ## [1.0.0] - 2026-10-16 Initial release. @@ -11,5 +22,5 @@ Initial release. - Laravel 13 API: projects with origin and IP allowlists, rate limits, honeypot, idempotency, screenshots stored in the database, Artisan commands, Oh Dear health checks. - Widget `@codebar-ag/feedback-widget`: full-page screenshots with rectangle and highlighter, persistent drafts, diagnostics with redaction, identity, four languages, project colors, WCAG 2.2 AA. -[Unreleased]: https://github.com/codebar-ag/feedback/compare/v1.0.0...HEAD -[1.0.0]: https://github.com/codebar-ag/feedback/releases/tag/v1.0.0 +[Unreleased]: https://github.com/codebar-ag/feedback.codebar.ch/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/codebar-ag/feedback.codebar.ch/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 4ba7343..9a07c31 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,28 @@ ## Features -- **Full-page screenshots** on demand, with a rectangle and a highlighter to mark the problem -- **Privacy first**: mask sensitive elements with `data-feedback-mask`, password fields are always hidden, console logs are redacted in the browser +**For visitors** + +- **Full-page screenshots** on demand, with a rectangle and a highlighter to mark the problem. Close with ✕ or Esc +- **Share link after sending**: the thank you screen shows a link to the submitted feedback with a copy button - **Drafts survive** closing the panel, navigating and reloading the page -- **Diagnostics**: console errors, failed requests, browser, OS, viewport and page size -- **Identity**: pass the signed-in user and the email field becomes "Sending as" -- **Four languages** (`de_CH`, `fr_CH`, `en_CH`, `it_CH`) and a project color with automatic contrast -- **Accessible** (WCAG 2.2 AA), isolated in a Shadow DOM, 18 KB gzip, no runtime dependencies -- **Your data, your server**: a small Laravel API with origin and IP allowlists, rate limits and a honeypot. Screenshots are stored in your database +- **Four languages** (`de_CH`, `fr_CH`, `en_CH`, `it_CH`), every text can be overridden +- **Accessible** (WCAG 2.2 AA), keyboard and touch friendly, bottom sheet on phones + +**For your team** + +- **Diagnostics**: console errors, failed requests, browser, OS, viewport, page size and your own `customData` +- **Feedback page per entry**: a signed, expiring link shows every detail and the screenshot, with download and buttons to complete or reopen. No login, no admin panel to run +- **Artisan workflow**: list, filter by project, show, export, link, complete and reopen, all with `--json` for scripts +- **Hooks**: widget events in the browser, a Laravel event on the server for Slack, mail or your ticket system +- **Activity log**: who completed or reopened what, from the CLI or from a link + +**For your stack** + +- **Any website or app**: npm package or a single script tag, React, Vue, Alpine, Livewire, plain HTML. Isolated in a Shadow DOM, 18 KB gzip, no runtime dependencies +- **Many projects, many environments**: one API serves any number of projects, each with several origins (wildcards and `localhost:*` included), optional IP ranges, its own color and daily limit +- **Privacy first**: mask elements with `data-feedback-mask`, password fields are always hidden, console logs are redacted in the browser +- **Your data, your server**: a small Laravel API with origin and IP allowlists, rate limits and a honeypot. Screenshots are stored in your database (SQLite, MySQL or PostgreSQL) ## How it works @@ -43,7 +57,8 @@

3. The widget sends message, screenshot and diagnostics to your API: `POST /v1/projects/{key}/feedback`. -4. The API checks origin, IP and rate limits and stores everything in your database. You work through the feedback with Artisan commands. +4. The API checks origin, IP and rate limits, stores everything in your database and answers with a signed link to the feedback, which the thank you screen offers to copy. +5. Your team works through the feedback with Artisan commands or shares a [feedback page link](#feedback-pages-and-share-links) with whoever fixes it, who can mark it as completed right there. The repository contains both parts: the **API**, a Laravel 13 app in the root folder, and the **widget** in [`widget/`](widget), published as [`@codebar-ag/feedback-widget`](https://www.npmjs.com/package/@codebar-ag/feedback-widget). @@ -52,7 +67,7 @@ The repository contains both parts: the **API**, a Laravel 13 app in the root fo **1. Install the API** ```bash -git clone https://github.com/codebar-ag/feedback.git && cd feedback +git clone https://github.com/codebar-ag/feedback.codebar.ch.git feedback && cd feedback composer install cp .env.example .env && php artisan key:generate touch database/database.sqlite && php artisan migrate @@ -147,7 +162,7 @@ feedback.on('before_send', (payload) => { payload.custom_data = { ...payload.custom_data, route: location.pathname }; // return false to cancel }); -feedback.on('after_send', ({ id }) => console.log('Feedback stored', id)); +feedback.on('after_send', ({ id, url }) => console.log('Feedback stored', id, url)); // url: share link, if enabled feedback.on('error', ({ code, status }) => console.warn(code, status)); ``` @@ -196,6 +211,49 @@ In single-page apps call `feedback.identify(user)` after sign-in. Allow the API in `connect-src` and `blob: data:` in `img-src`. For the script tag also allow its host in `script-src`. Styles need nothing, the widget uses `adoptedStyleSheets`. +### Recipes + +**Several environments, one project.** Production, staging, preview deployments and local development can share a project: + +```bash +php artisan feedback:project:create "Shop" \ + --origin=https://shop.example.com \ + --origin=https://*.preview.example.com \ + --origin=http://localhost:* +``` + +**Internal tool.** Combine origins with the office network and VPN, both must match: + +```bash +php artisan feedback:project:create "Intranet" --origin=https://intranet.example.com --ip=10.0.0.0/8 --ip=2001:db8::/32 +``` + +**Acceptance testing.** Make the screenshot mandatory and tag every report with the build under test: + +```ts +initFeedback({ projectKey, endpoint, screenshot: { mode: 'required' }, customData: { build: import.meta.env.VITE_BUILD } }); +``` + +**Multi-tenant SaaS.** Send the tenant and plan along, and the signed-in user instead of an email field: + +```ts +feedback.identify({ id: user.id, name: user.name, email: user.email }); +feedback.setData({ tenant: tenant.slug, plan: tenant.plan }); +``` + +**Your own button.** Hide the launcher and open the panel from your help menu: + +```ts +feedback.hideLauncher(); +document.querySelector('#report-problem')!.addEventListener('click', () => feedback.open()); +``` + +**Analytics or support chat.** React to sent feedback in the page: + +```ts +feedback.on('after_send', ({ id }) => analytics.track('feedback_sent', { id })); +``` + ## Managing projects and feedback ```bash @@ -205,13 +263,18 @@ php artisan feedback:project:show {key} php artisan feedback:project:update {key} --add-origin=https://staging.example.com --color=white --deactivate php artisan feedback:project:rotate-key {key} -php artisan feedback:list --open +php artisan feedback:list --open --project={key} php artisan feedback:show {id} +php artisan feedback:link {id} [--expires=10080] [--by="Name"] [--read-only] php artisan feedback:export {id} --path=./export -php artisan feedback:complete {id} {id} -php artisan feedback:reopen {id} +php artisan feedback:complete {id} {id} [--by="Name"] +php artisan feedback:reopen {id} [--by="Name"] ``` +`feedback:list` shows the newest 20 entries: filter with `--project` (public key or ID), `--open` or `--completed`, and change the number with `--limit`. + +### Multiple origins and IPs + `--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 @@ -220,11 +283,66 @@ php artisan feedback:project:update {key} --add-origin=https://web.example.test 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. +All commands accept `--json`, handy for scripts and CI: + +```bash +php artisan feedback:list --open --project=fbk_live_92T7QQB6fj6Zb37jBGzrDI --json | jq -r '.[].id' +``` + +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`). - **IPs**: single addresses or CIDR ranges, IPv4 and IPv6. No IPs means no restriction. A request must match the origins **and** the IPs. - **Color**: `black`, `white` or a hex value. Text becomes white or dark blue, whichever has more contrast, and button tones are adjusted until they reach 4.5:1. +- **Daily limit**: `--limit=500` caps the entries per project and day, `--deactivate` / `--activate` switch a project off and on without deleting anything, `rotate-key` replaces a leaked key. + +## 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. + +**Team links** come from the CLI and can complete and reopen the entry: + +```bash +php artisan feedback:link 01JD8Z4W8K5Q3F7N2M6R9T1V0X --by="Anna Muster" +``` + +``` + Expires ............................................ 2026-10-29 14:30:52 UTC + Screenshot ................................................................ yes + Status changes ........................................................ allowed + +https://feedback.example.com/v1/feedback/01JD8Z4W8K5Q3F7N2M6R9T1V0X?by=Anna%20Muster&expires=…&manage=1&signature=… +``` + +| Option | | +|---|---| +| `--expires=` | Minutes the link is valid, default 10080 (7 days), at most 43200 (30 days) | +| `--by=` | Name written to the activity log when the status is changed through this link | +| `--read-only` | Page and screenshot only, without the buttons | +| `--json` | `url`, `screenshot_url` (direct download), `manage`, `expires_at` | + +Paste the link into a ticket, a chat or an email. The developer opens it, downloads the screenshot and clicks **Mark as completed** when the fix is live. + +**Share links for the sender** are returned by the API after sending and shown on the thank you screen with a copy button, so the visitor can attach the report to an email or a support ticket. They are always read-only and valid for `FEEDBACK_SHARE_LINK_MINUTES` (default 7 days, `0` turns them off). + +**Notifications.** Every stored feedback dispatches `App\Events\FeedbackReceived`. A listener can post a team link to Slack, Teams or your ticket system: + +```php +use App\Events\FeedbackReceived; +use App\Support\FeedbackLink; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Http; + +Event::listen(function (FeedbackReceived $event): void { + $link = FeedbackLink::page($event->entry, now()->addDays(14), manage: true, by: 'Slack'); + + Http::post(config('services.slack.feedback_webhook'), [ + 'text' => "New feedback for {$event->entry->project->name}: {$event->entry->message}\n{$link}", + ]); +}); +``` + +**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. ## Configuration @@ -237,6 +355,8 @@ Set in `.env`, see [`config/feedback.php`](config/feedback.php): | `FEEDBACK_LIMIT_PER_IP_PER_MINUTE` | `10` | `0` disables the limit | | `FEEDBACK_LIMIT_PER_PROJECT_PER_MINUTE` | `60` | `0` disables the limit | | `FEEDBACK_MIN_ELAPSED_MS` | `2000` | Faster submissions are dropped silently | +| `FEEDBACK_SHARE_LINK_MINUTES` | `10080` | Validity of the share link shown after sending, max. 43200, `0` turns it off | +| `APP_URL` | | Public URL of the API, used for links created with `feedback:link` | | `TRUSTED_PROXIES` | | Load balancer or CDN addresses | | `CACHE_STORE` | `file` | Use `database` or `redis` with several instances | | `OH_DEAR_HEALTH_CHECK_*` | on, needs the secret | [Oh Dear](https://ohdear.app) application health | @@ -250,7 +370,7 @@ Set in `.env`, see [`config/feedback.php`](config/feedback.php): - Screenshots are up to 6 MB: set `post_max_size` and `upload_max_filesize` to at least 8 MB. On MySQL raise `max_allowed_packet` to 16 MB. - Behind a load balancer set `TRUSTED_PROXIES`, otherwise IP allowlists and rate limits see the proxy. - Run `php artisan schedule:run` every minute for the health checks. -- `GET /up` is the health endpoint. Every other path, including `/`, returns 404 and is excluded from search engines. +- `GET /up` is the health endpoint. Every other path, including `/`, returns an empty `403` and is excluded from search engines. Browsers can only open validly signed feedback pages. ## HTTP API @@ -258,13 +378,22 @@ Set in `.env`, see [`config/feedback.php`](config/feedback.php): |---|---|---| | `POST` | `/v1/projects/{key}/feedback` | Submit feedback (`multipart/form-data`, `Idempotency-Key` header) | | `GET` | `/v1/projects/{key}/config` | Project color for the widget | +| `GET` | `/v1/feedback/{id}?expires=…&signature=…` | Feedback page (signed link) | +| `GET` | `/v1/feedback/{id}/screenshot?…` | Screenshot, `download=1` as attachment (signed link) | +| `POST` | `/v1/feedback/{id}/status?manage=1&…` | `status=completed` or `status=open` (signed team link) | | `GET` | `/up` | Health check | -Responses: `201` created, `200` duplicate `Idempotency-Key`, `403` origin or IP not allowed, `404` unknown project, `413` too large, `422` validation failed, `429` rate limited. Every stored feedback dispatches `App\Events\FeedbackReceived`, the place to hook in notifications. +Responses: `201` created, `200` duplicate `Idempotency-Key`, `403` origin or IP not allowed, `404` unknown project, `413` too large, `422` validation failed, `429` rate limited. A successful submission returns: + +```json +{ "id": "01JD8Z4W8K5Q3F7N2M6R9T1V0X", "status": "received", "url": "https://feedback.example.com/v1/feedback/01JD8Z…?expires=…&signature=…", "url_expires_at": "2026-10-29T14:30:52+00:00" } +``` + +`url` and `url_expires_at` are missing when share links are turned off. Every stored feedback dispatches `App\Events\FeedbackReceived`, see [Notifications](#feedback-pages-and-share-links). ## Security and privacy -The project key is public by design. The origin check is enforced by browsers, so use IP allowlists for internal apps and keep rate limits on for public sites. The API stores email, message, screenshot, browser data and the full IP address: mention the widget in your privacy policy. Please report vulnerabilities as described in [SECURITY.md](SECURITY.md). +The project key is public by design. Feedback pages are only reachable through signed, expiring links, see [Security](#feedback-pages-and-share-links). The origin check is enforced by browsers, so use IP allowlists for internal apps and keep rate limits on for public sites. The API stores email, message, screenshot, browser data and the full IP address: mention the widget in your privacy policy. Please report vulnerabilities as described in [SECURITY.md](SECURITY.md). ## Development 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..e5462dc 100644 --- a/e2e/feedback.e2e.ts +++ b/e2e/feedback.e2e.ts @@ -153,3 +153,51 @@ 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. A request, not a navigation: Firefox never fires "load" for an empty 403. + 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); + 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('