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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 0 additions & 72 deletions .github/workflows/release.yml

This file was deleted.

2 changes: 0 additions & 2 deletions .github/workflows/widget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,3 @@ jobs:
- run: npm run test:coverage
- run: npm run build
- run: npm run size
- run: npm run publint
- run: npm run attw
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ Thumbs.db
/e2e/test-results
/e2e/playwright-report
/public/widget
/resources/widget
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ All notable changes to the API and the widget are documented here. The format fo

### Changed

- The widget is no longer published to npm: the API serves it at `/widget/v1/feedback.js` (one script tag, no npm, no CDN, updated with every deploy). `npm run build` compiles it into `resources/widget/v1`. The npm package `@codebar-ag/feedback-widget` 1.0.0 stays available but gets no updates. Removed: release workflow, type definitions, SRI file, publint and attw.
- 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

- `<noscript>` content (e.g. of an Alpine toast region) showed up as literal markup in screenshots; `<noscript>` and `<template>` are now left out.
- Screenshots with the script tag from the API: the lazy screenshot chunk was served without `Access-Control-Allow-Origin`, so other websites could not load it.
- Screenshots of pages using Alpine, Vue or other framework attributes (`:class`, `x-on:click`) were blank.

## [1.0.0] - 2026-10-16
Expand Down
73 changes: 45 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
<p align="center">
<a href="https://github.com/codebar-ag/feedback.codebar.ch/actions/workflows/pest_database_matrix_pull_request.yml"><img src="https://github.com/codebar-ag/feedback.codebar.ch/actions/workflows/pest_database_matrix_pull_request.yml/badge.svg?branch=main" alt="Tests"></a>
<a href="https://github.com/codebar-ag/feedback.codebar.ch/actions/workflows/e2e.yml"><img src="https://github.com/codebar-ag/feedback.codebar.ch/actions/workflows/e2e.yml/badge.svg" alt="E2E"></a>
<a href="https://www.npmjs.com/package/@codebar-ag/feedback-widget"><img src="https://img.shields.io/npm/v/@codebar-ag/feedback-widget?label=npm" alt="npm"></a>
<img src="https://img.shields.io/badge/PHP-8.5-777BB4?logo=php&logoColor=white" alt="PHP 8.5">
<img src="https://img.shields.io/badge/Laravel-13-FF2D20?logo=laravel&logoColor=white" alt="Laravel 13">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License"></a>
Expand Down Expand Up @@ -42,14 +41,14 @@

**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
- **Any website or app**: one script tag, served by your own API. React, Vue, Alpine, Livewire, plain HTML, no npm and no build step on the website. Isolated in a Shadow DOM, 18 KB gzip, no runtime dependencies. Updates arrive with every API deploy
- **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

1. The widget runs on your website, in a Shadow DOM that leaves your styles untouched.
1. Your website loads the widget from your API with one script tag. It runs in a Shadow DOM that leaves your styles untouched.
2. A visitor writes feedback and can attach a screenshot of the whole page. In marking mode they draw rectangles or highlight text:

<p align="center">
Expand All @@ -60,7 +59,7 @@
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).
The repository contains both parts: the **API**, a Laravel 13 app in the root folder, and the **widget** in [`widget/`](widget), which the deploy build compiles and the API serves at `/widget/v1/feedback.js`.

## Quick start

Expand Down Expand Up @@ -92,29 +91,31 @@ php artisan feedback:project:create "Example Website" --origin=https://www.examp
Daily limit ........................................................... (no limit)
```

**3. Add the widget to your website**
**3. Build the widget**

```bash
npm install @codebar-ag/feedback-widget
npm ci && npm run build # Node 26, compiles the widget into resources/widget/v1
```

```ts
import { initFeedback } from '@codebar-ag/feedback-widget';
**4. Add it to your website**

await initFeedback({
projectKey: 'fbk_live_92T7QQB6fj6Zb37jBGzrDI',
endpoint: 'https://feedback.example.com',
});
```html
<script src="https://feedback.example.com/widget/v1/feedback.js"></script>
<script>
CodebarFeedback.init({ projectKey: 'fbk_live_92T7QQB6fj6Zb37jBGzrDI', endpoint: 'https://feedback.example.com' });
</script>
```

That is all: no npm package, no bundler, no third-party CDN. The script always matches your API version.

## Widget

### Options

Only `projectKey` and `endpoint` are required.

```ts
const feedback = await initFeedback({
const feedback = await CodebarFeedback.init({
projectKey: 'fbk_live_92T7QQB6fj6Zb37jBGzrDI',
endpoint: 'https://feedback.example.com',

Expand Down Expand Up @@ -144,7 +145,7 @@ const feedback = await initFeedback({
});
```

Call `initFeedback()` as early as possible: console errors are captured from that moment on. Calling it twice returns the same instance.
Call `CodebarFeedback.init()` as early as possible: console errors are captured from that moment on. Calling it twice returns the same instance.

### Methods and events

Expand Down Expand Up @@ -177,26 +178,39 @@ Events: `ready`, `open`, `close`, `before_send`, `after_send`, `error`.

Password fields and `autocomplete="cc-*"` fields are always masked. Images from other hosts without CORS headers and cross-origin iframes render empty. If rendering fails, the feedback is sent without a screenshot.

### Script tag
### Loading the script

The API serves the widget at `/widget/v1/feedback.js` (cached 5 minutes) and loads the screenshot code lazily from `/widget/v1/chunks/…` (cached for a year, the file name changes with every build). Both are sent with `Access-Control-Allow-Origin: *`, so they work on any website. A new widget version reaches every website with the next API deploy.

No build step needed:
**Without blocking the page**, start it when the script has loaded:

```html
<script src="https://unpkg.com/@codebar-ag/feedback-widget@1/dist/feedback.iife.js" crossorigin="anonymous"></script>
<script>
CodebarFeedback.init({ projectKey: 'fbk_live_92T7QQB6fj6Zb37jBGzrDI', endpoint: 'https://feedback.example.com' });
</script>
<script async src="https://feedback.example.com/widget/v1/feedback.js" onload="CodebarFeedback.init({ projectKey: 'fbk_live_…', endpoint: 'https://feedback.example.com' })"></script>
```

Each [release](https://github.com/codebar-ag/feedback.codebar.ch/releases) lists the SRI hash for `integrity="…"`.
**From your own JavaScript** (Vite, webpack, a strict CSP without inline handlers), load it on demand, for example after the first interaction:

```js
function loadFeedback(endpoint) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `${endpoint}/widget/v1/feedback.js`;
script.onload = () => resolve(window.CodebarFeedback);
script.onerror = reject;
document.head.append(script);
});
}

const CodebarFeedback = await loadFeedback('https://feedback.example.com');
const feedback = await CodebarFeedback.init({ projectKey: 'fbk_live_…', endpoint: 'https://feedback.example.com' });
```

### Laravel example

```blade
<script type="module">
import { initFeedback } from '@codebar-ag/feedback-widget';

initFeedback({
<script src="{{ config('services.feedback.url') }}/widget/v1/feedback.js"></script>
<script>
CodebarFeedback.init({
projectKey: @js(config('services.feedback.key')),
endpoint: @js(config('services.feedback.url')),
locale: @js(str(app()->getLocale())->replace('-', '_')),
Expand All @@ -209,7 +223,7 @@ In single-page apps call `feedback.identify(user)` after sign-in.

### Content Security Policy

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`.
Allow the API in `script-src` and `connect-src`, and `blob: data:` in `img-src`. Styles need nothing, the widget uses `adoptedStyleSheets`.

### Recipes

Expand All @@ -231,7 +245,7 @@ php artisan feedback:project:create "Intranet" --origin=https://intranet.example
**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 } });
CodebarFeedback.init({ projectKey, endpoint, screenshot: { mode: 'required' }, customData: { build: '2026.10.3' } });
```

**Multi-tenant SaaS.** Send the tenant and plan along, and the signed-in user instead of an email field:
Expand Down Expand Up @@ -368,6 +382,7 @@ Set in `.env`, see [`config/feedback.php`](config/feedback.php):
## Deployment

- PHP 8.5 with `pdo_pgsql`, `pdo_mysql` or `pdo_sqlite`, `fileinfo` and `intl`. No queue worker needed.
- Node 26 at build time: `npm ci && npm run build` compiles the widget into `resources/widget/v1` (Laravel Cloud runs this as its build command). Without it `/widget/v1/feedback.js` answers `403`.
- 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.
Expand All @@ -382,6 +397,7 @@ Set in `.env`, see [`config/feedback.php`](config/feedback.php):
| `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` | `/widget/v1/feedback.js` | The widget for the script tag, plus its lazy `chunks/` |
| `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. A successful submission returns:
Expand All @@ -403,13 +419,14 @@ Requires PHP 8.5, Composer and Node 26.
```bash
composer install && npm --prefix widget ci
composer check # Pint, PHPStan, Pest
npm run build # builds the widget and copies it to resources/widget/v1
npm --prefix widget run check # lint, types, Vitest, build, size budget
npm --prefix widget run e2e # Playwright: Chromium, Firefox, WebKit, mobile
npm --prefix widget run e2e:full # API and widget together
npm --prefix widget run dev # playground with a mock API on http://localhost:4173/fixtures/index.html
```

Releases are published from a `v*` tag through GitHub Actions with npm trusted publishing (`release.yml`). The very first publish uses an `NPM_TOKEN` repository secret, since a trusted publisher can only be linked to an existing package. See the [changelog](CHANGELOG.md) for changes.
There is nothing to publish: the widget ships with every API deploy. See the [changelog](CHANGELOG.md) for changes.

## License

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Supported versions

Security fixes are released for the latest minor version of the API and of `@codebar-ag/feedback-widget`.
Security fixes are released for the latest version of the API and the widget it serves.

## Reporting a vulnerability

Expand Down
42 changes: 42 additions & 0 deletions app/Http/Controllers/WidgetAssetController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

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

/**
* Serves the widget (copied by the deploy build, see widget/scripts/build.mjs)
* to any website: CORS for the lazy screenshot chunk, a short cache for
* feedback.js so deploys roll out, a long cache for the hashed chunks.
*/
final class WidgetAssetController
{
public function __invoke(Request $request, string $path): Response
{
$dir = config('feedback.widget_path');
$file = is_string($dir) ? rtrim($dir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$path : '';

if (! is_file($file)) {
throw new NotFoundHttpException;
}

$contents = (string) file_get_contents($file);
$hashed = str_starts_with($path, 'chunks/');

$response = new Response($contents, 200, [
'Content-Type' => 'text/javascript; charset=utf-8',
'Cache-Control' => $hashed ? 'public, max-age=31536000, immutable' : 'public, max-age=300',
'Access-Control-Allow-Origin' => '*',
'Cross-Origin-Resource-Policy' => 'cross-origin',
'X-Content-Type-Options' => 'nosniff',
]);
$response->setEtag(hash('xxh128', $contents));
$response->isNotModified($request);

return $response;
}
}
12 changes: 12 additions & 0 deletions config/feedback.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@
'min_elapsed_ms' => $limit('FEEDBACK_MIN_ELAPSED_MS', 2000),
],

/*
|--------------------------------------------------------------------------
| Widget for script tags
|--------------------------------------------------------------------------
|
| The deploy build (npm run build in the root) copies the script tag build
| here; the API serves it at /widget/v1/feedback.js.
|
*/

'widget_path' => resource_path('widget/v1'),

/*
|--------------------------------------------------------------------------
| Share link after sending
Expand Down
26 changes: 26 additions & 0 deletions e2e/feedback.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,29 @@ test('the share link on the thank you screen and the team link open the feedback
expect(forged.status()).toBe(403);
expect(await forged.text()).toBe('');
});

test('a website loads the widget with a script tag from the API, screenshots included', async ({ page, request, browserName }) => {
const scriptRes = await request.get(`${API}/widget/v1/feedback.js`, { headers: { Origin: WEB } });
expect(scriptRes.status()).toBe(200);
expect(scriptRes.headers()['access-control-allow-origin']).toBe('*');
expect(scriptRes.headers()['content-type']).toBe('text/javascript; charset=utf-8');

const message = `Script-Tag ${browserName} ${Date.now()}`;
await page.goto(`${WEB}/script.html?key=${keys().open}&api=${encodeURIComponent(API)}`);
await expect(page.locator('html')).toHaveAttribute('data-feedback-ready', '1');
await shadow(page, 'button.l').click();
await write(page, message);
// The screenshot chunk is loaded with import() from the API origin: a CORS request.
await shadow(page, '.tr button').first().click();
await expect(shadow(page, '.tb')).toBeVisible();
await shadow(page, '.tb [data-action=done]').click();
await expect(shadow(page, 'img.th')).toBeVisible();
await shadow(page, 'button[type=submit]').click();
await expect(shadow(page, '.st.ok')).toBeVisible();

const item = entries().find((e) => e.message === message);
const entry = artisanJson<Entry>('feedback:show', item!.id);
expect(entry.origin).toBe(WEB);
expect(entry.screenshot).toMatchObject({ mime: 'image/jpeg' });
expect(entry.screenshot!.bytes).toBeGreaterThan(1000);
});
Loading
Loading