Skip to content
Merged

m/p #36

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,28 @@ 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.

### Changed

- The widget only appears on pages the API accepts: it checks the project config first and stays hidden on a `403` (origin or IP not allowed). Refused `GET` requests now carry `Access-Control-Allow-Origin` so the widget can read the `403`; the preflight for sending stays refused.
- Dev dependencies: vite 8.3.5, typescript-eslint 8.72.0.

### 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.

- 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
178 changes: 158 additions & 20 deletions README.md

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions app/Console/Commands/Feedback/LinkFeedbackCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands\Feedback;

use App\Console\Commands\Feedback\Concerns\InteractsWithFeedback;
use App\Support\FeedbackLink;
use Illuminate\Console\Command;

final class LinkFeedbackCommand extends Command
{
use InteractsWithFeedback;

protected $signature = 'feedback:link
{id : Feedback ID}
{--expires='.FeedbackLink::DEFAULT_MINUTES.' : Minutes until the link expires (max '.FeedbackLink::MAX_MINUTES.' = 30 days)}
{--read-only : Without the buttons to complete and reopen}
{--by= : Name recorded in the activity log for status changes made with this link}
{--json : Output as JSON}';

protected $description = 'Create a signed, expiring link to the feedback page (details, screenshot, complete/reopen)';

public function handle(): int
{
$minutes = $this->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;
}
}
78 changes: 78 additions & 0 deletions app/Http/Controllers/FeedbackPageController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\FeedbackEntry;
use App\Support\FeedbackLink;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;

/**
* Read-only page of one feedback entry behind a signed link (feedback:link).
* The only HTML the API serves: no scripts, no external resources.
*/
final class FeedbackPageController
{
public function __invoke(Request $request, FeedbackEntry $entry): Response
{
$expires = Carbon::createFromTimestamp($request->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<mixed>|null $log
* @param list<string> $keys
* @return list<array<string, string>>
*/
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;
}
}
34 changes: 34 additions & 0 deletions app/Http/Controllers/FeedbackScreenshotController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\FeedbackEntry;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/** Screenshot behind a signed link, inline or as download (?download=1). */
final class FeedbackScreenshotController
{
public function __invoke(Request $request, FeedbackEntry $entry): Response
{
$shot = $entry->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',
]);
}
}
41 changes: 41 additions & 0 deletions app/Http/Controllers/FeedbackStatusController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\FeedbackEntry;
use App\Support\FeedbackLink;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Context;

/** Completes or reopens an entry from a team link (feedback:link), then shows the page again. */
final class FeedbackStatusController
{
public function __invoke(Request $request, FeedbackEntry $entry): RedirectResponse|Response
{
// Links shown to the sender after sending are read-only.
if (! $request->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);
}
}
7 changes: 6 additions & 1 deletion app/Http/Middleware/ApplyProjectCors.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
/**
* Dynamic CORS per project. Answers the preflight and sets
* Access-Control-Allow-Origin to exactly the requesting origin, but only if
* the project allows it. Never "*", never with credentials.
* the project allows it. Never "*", never with credentials. Refused
* GET requests only get Access-Control-Allow-Origin, so the widget can read the 403.
*/
final class ApplyProjectCors
{
Expand All @@ -40,6 +41,10 @@ public function handle(Request $request, Closure $next): Response
$response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Idempotency-Key');
$response->headers->set('Access-Control-Expose-Headers', 'Retry-After');
$response->headers->set('Access-Control-Max-Age', '600');
} elseif ($origin !== null && $request->isMethod('GET')) {
// Lets the widget read the 403 and stay hidden instead of mistaking it for a network error.
// The preflight stays refused, so a foreign page still cannot send.
$response->headers->set('Access-Control-Allow-Origin', $origin);
}

return $response;
Expand Down
7 changes: 5 additions & 2 deletions app/Http/Middleware/BlockBrowserNavigation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
24 changes: 22 additions & 2 deletions app/Http/Resources/FeedbackReceivedResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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(),
];
}
}
56 changes: 56 additions & 0 deletions app/Support/FeedbackLink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace App\Support;

use App\Models\FeedbackEntry;
use DateTimeInterface;
use Illuminate\Support\Facades\URL;

/**
* Signed, expiring links to the feedback page and its screenshot. Team links
* (manage) can also complete and reopen the entry, the optional name is
* recorded in the activity log. Anyone with the link can use it until it
* expires; only rotating APP_KEY revokes links early.
*/
final readonly class FeedbackLink
{
public const int DEFAULT_MINUTES = 7 * 24 * 60;

public const int MAX_MINUTES = 30 * 24 * 60;

public static function page(FeedbackEntry $entry, DateTimeInterface $expires, bool $manage = false, ?string $by = null): string
{
return URL::temporarySignedRoute('feedback.page', $expires, ['entry' => $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<string, string|int> */
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;
}
}
Loading
Loading