From 8ea4174b9a7c552ed6c52b3e686f8fba0f2df372 Mon Sep 17 00:00:00 2001 From: neonwatty Date: Mon, 11 May 2026 09:26:40 -0700 Subject: [PATCH 1/3] feat: add BugDrop Sandbox --- .github/workflows/ci.yml | 8 + .github/workflows/deploy.yml | 8 + README.md | 3 + docs/website/ci-testing.mdx | 14 +- docs/website/configuration.mdx | 37 ++- docs/website/getting-started.mdx | 1 + docs/website/installation.mdx | 9 +- e2e/sandbox.spec.ts | 163 +++++++++++ public/sandbox/index.html | 176 ++++++++++++ public/sandbox/preview.html | 477 +++++++++++++++++++++++++++++++ public/sandbox/sandbox.css | 311 ++++++++++++++++++++ public/sandbox/sandbox.js | 305 ++++++++++++++++++++ src/index.ts | 30 ++ 13 files changed, 1526 insertions(+), 16 deletions(-) create mode 100644 e2e/sandbox.spec.ts create mode 100644 public/sandbox/index.html create mode 100644 public/sandbox/preview.html create mode 100644 public/sandbox/sandbox.css create mode 100644 public/sandbox/sandbox.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c539699..4225f79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,14 @@ jobs: echo "Preview worker not ready after 300s" exit 1 + - name: Verify preview sandbox assets + run: | + WORKER_ORIGIN="https://bugdrop-preview.neonwatty.workers.dev" + for path in /sandbox/ /sandbox/preview /sandbox/sandbox.css /sandbox/sandbox.js /widget.js; do + echo "Checking ${WORKER_ORIGIN}${path}..." + curl -sfo /dev/null "${WORKER_ORIGIN}${path}" || (echo "Missing preview asset: $path" && exit 1) + done + - name: Verify test venue is reachable run: | VENUE_URL="${PLAYWRIGHT_BASE_URL}" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index efa1fe0..bdcc297 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -64,3 +64,11 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Verify production sandbox assets + run: | + WORKER_ORIGIN="https://bugdrop.neonwatty.workers.dev" + for path in /sandbox/ /sandbox/preview /sandbox/sandbox.css /sandbox/sandbox.js /widget.js; do + echo "Checking ${WORKER_ORIGIN}${path}..." + curl -sfo /dev/null "${WORKER_ORIGIN}${path}" || (echo "Missing production asset: $path" && exit 1) + done diff --git a/README.md b/README.md index d7775d4..d517474 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Version](https://img.shields.io/badge/version-1.11.0-14b8a6)](./CHANGELOG.md) [![Security Policy](https://img.shields.io/badge/Security-Policy-blue)](./SECURITY.md) [![Live Demo](https://img.shields.io/badge/Demo-Try_It_Live-ff9e64)](https://bugdrop-widget-test.vercel.app) +[![Sandbox](https://img.shields.io/badge/Sandbox-Configure_Widget-7c3aed)](https://bugdrop.neonwatty.workers.dev/sandbox/) [![GitHub Marketplace](https://img.shields.io/badge/GitHub%20Marketplace-Install-2ea44f?logo=github)](https://github.com/marketplace/bugdrop-in-app-feedback-to-github-issues) [![Product Hunt](https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1141615&theme=light&t=1778415221018)](https://www.producthunt.com/products/bugdrop-2?utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-bugdrop-2) @@ -55,6 +56,8 @@ That's it! Users can now click the bug button to submit feedback as GitHub Issue See [full documentation](https://bugdrop.dev/docs/configuration) for all options including styling, submitter info, and dismissible button. +Use the [BugDrop Sandbox](https://bugdrop.neonwatty.workers.dev/sandbox/) to preview settings, check GitHub App installation, test screenshot masking, and generate a script tag. + ## Documentation - [Full Documentation](https://bugdrop.dev/docs) diff --git a/docs/website/ci-testing.mdx b/docs/website/ci-testing.mdx index b1c8370..5f2a440 100644 --- a/docs/website/ci-testing.mdx +++ b/docs/website/ci-testing.mdx @@ -45,7 +45,7 @@ const EXPECTED = { requireEmail: false, buttonDismissible: false, showRestore: true, - welcomeMessage: "Report a bug or request a feature", + welcome: "once", }; const URL = "https://your-site.com"; // Replace with your site URL @@ -114,17 +114,17 @@ test.describe("BugDrop Widget", () => { expect(bgColor).toBeTruthy(); }); - test("displays correct welcome message", async ({ page }) => { + test("uses expected welcome behavior", async ({ page }) => { const btn = await shadowEl(page, "bug-drop-widget", ".floating-btn"); await (btn as any).click(); - const welcomeText = await page.evaluate(() => { + const welcomeVisible = await page.evaluate(() => { const host = document.querySelector("bug-drop-widget"); const welcome = host?.shadowRoot?.querySelector(".welcome-message, h2"); - return welcome?.textContent?.trim(); + return Boolean(welcome); }); - expect(welcomeText).toContain(EXPECTED.welcomeMessage); + expect(welcomeVisible).toBe(EXPECTED.welcome !== "never"); }); test("shows/hides name field based on config", async ({ page }) => { @@ -249,7 +249,7 @@ The test suite covers three areas: - **Opens and closes the form** -- Clicks the button, verifies the form appears, then closes it - **Correct position** -- Validates the button is in the expected corner (bottom-right or bottom-left) - **Correct accent color** -- Checks the button's background color -- **Welcome message** -- Opens the form and verifies the correct welcome text is displayed +- **Welcome screen** -- Opens the form and verifies the expected welcome behavior - **Name/email field visibility** -- Confirms fields show or hide based on your configuration ### Accessibility Tests (WCAG AA) @@ -272,7 +272,7 @@ const EXPECTED = { requireEmail: false, // data-require-email buttonDismissible: false, // data-button-dismissible showRestore: true, // data-show-restore - welcomeMessage: "Report a bug or request a feature", // data-welcome + welcome: "once", // data-welcome }; ``` diff --git a/docs/website/configuration.mdx b/docs/website/configuration.mdx index 7c8244b..a6673ca 100644 --- a/docs/website/configuration.mdx +++ b/docs/website/configuration.mdx @@ -9,14 +9,14 @@ These attributes control the fundamental behavior of the widget. | Attribute | Default | Description | |-----------|---------|-------------| | `data-repo` | **(required)** | GitHub repository in `owner/repo` format | -| `data-theme` | `"light"` | Widget theme: `"light"` or `"dark"` | +| `data-theme` | `"auto"` | Widget theme: `"auto"`, `"light"`, or `"dark"` | | `data-position` | `"bottom-right"` | Button position: `"bottom-right"` or `"bottom-left"` | | `data-color` | `"#7c3aed"` | Primary accent color (any CSS color value) | | `data-icon` | *(default bug icon)* | Custom icon: URL to an image, `"none"` to hide, or omit for default | | `data-label` | `""` | Text label displayed next to the button icon | | `data-category-labels` | built-in labels | Self-hosted JSON mapping from built-in categories to GitHub labels | | `data-button` | `"true"` | Show the floating button: `"true"` or `"false"` | -| `data-welcome` | `"Report a bug or request a feature"` | Welcome message displayed at the top of the form | +| `data-welcome` | `"once"` | Welcome screen behavior: `"once"`, `"always"`, `"never"`, or `"false"` | ### Basic Example @@ -27,7 +27,7 @@ These attributes control the fundamental behavior of the widget. data-theme="dark" data-position="bottom-left" data-color="#ef4444" - data-welcome="Found a bug? Let us know!" + data-welcome="always" > ``` @@ -35,11 +35,16 @@ These attributes control the fundamental behavior of the widget. The `data-theme` attribute controls the overall color scheme of the widget: -- **`light`** (default) -- White background with dark text. Suitable for most sites. +- **`auto`** (default) -- Follows the user's system color scheme. +- **`light`** -- White background with dark text. Suitable for most sites. - **`dark`** -- Dark background with light text. Ideal for sites with dark color schemes. ```html - + + + + @@ -55,6 +60,24 @@ The `data-position` attribute controls where the floating button appears: - **`bottom-right`** (default) -- Fixed to the bottom-right corner - **`bottom-left`** -- Fixed to the bottom-left corner +### Welcome Screen Options + +The `data-welcome` attribute controls when the built-in welcome screen appears: + +- **`once`** (default) -- Show the welcome screen once per repository, then skip it on future opens in the same browser. +- **`always`** -- Show the welcome screen every time the user opens the widget. +- **`never`** or **`false`** -- Skip the welcome screen and open the feedback form directly. + +```html + + + + + +``` + ## Submitter Information Control whether the feedback form collects the submitter's name and email address. @@ -307,7 +330,7 @@ Here is a script tag using many configuration options together: data-theme="dark" data-position="bottom-left" data-color="#6366f1" - data-welcome="We'd love to hear from you!" + data-welcome="always" data-show-name="true" data-show-email="true" data-require-email="true" @@ -318,7 +341,7 @@ Here is a script tag using many configuration options together: > ``` -This configuration creates a dark-themed widget in the bottom-left corner with an indigo accent color, collects the submitter's name and email (email required), and shows a "Feedback" label on the button. The button is dismissible for 2 hours with a restore link. +This configuration creates a dark-themed widget in the bottom-left corner with an indigo accent color, always shows the built-in welcome screen, collects the submitter's name and email (email required), and shows a "Feedback" label on the button. The button is dismissible for 2 hours with a restore link. ## Next Steps diff --git a/docs/website/getting-started.mdx b/docs/website/getting-started.mdx index 0dd330b..bd2a065 100644 --- a/docs/website/getting-started.mdx +++ b/docs/website/getting-started.mdx @@ -50,6 +50,7 @@ That is it. No servers to run, no databases to manage, no user accounts required Ready to get started? Here is where to go next: - **[Installation](/docs/installation)** -- Step-by-step setup guide to add BugDrop to your site +- **[Sandbox](https://bugdrop.neonwatty.workers.dev/sandbox/)** -- Preview settings, check installation, and generate your script tag - **[Configuration](/docs/configuration)** -- All widget attributes for customizing behavior - **[Styling](/docs/styling)** -- Theme your widget with colors, fonts, borders, and shadows - **[JavaScript API](/docs/javascript-api)** -- Programmatic control with `window.BugDrop` diff --git a/docs/website/installation.mdx b/docs/website/installation.mdx index 3cefc41..4c863d7 100644 --- a/docs/website/installation.mdx +++ b/docs/website/installation.mdx @@ -65,12 +65,16 @@ You can add data attributes to customize the widget's appearance and behavior: data-theme="dark" data-position="bottom-left" data-color="#6366f1" - data-welcome="We'd love your feedback!" + data-welcome="always" > ``` See the [Configuration](/docs/configuration) and [Styling](/docs/styling) docs for all available attributes. +### Try it in the Sandbox + +Use the [BugDrop Sandbox](https://bugdrop.neonwatty.workers.dev/sandbox/) to preview widget behavior, check GitHub App installation, test screenshot masking, and generate a script tag before adding BugDrop to your app. + ## Protecting sensitive data If your page renders customer data, billing details, or any other content you do not @@ -84,7 +88,7 @@ want to appear in submitted screenshots, mark those elements with `data-bugdrop- BugDrop covers each marked element with an opaque rectangle on the captured screenshot. Password inputs and credit-card autocomplete fields are masked automatically. See -[Screenshot masking](/security#screenshot-masking) on the Security page for details. +[Screenshot masking](/docs/security#screenshot-masking) on the Security page for details. ## Step 3: You Are Done @@ -174,6 +178,7 @@ You can also test by clicking the bug button and submitting a test report. Check ## Next Steps +- [Try the Sandbox](https://bugdrop.neonwatty.workers.dev/sandbox/) to generate and test your script tag - [Configure the widget](/docs/configuration) with custom attributes - [Style the widget](/docs/styling) to match your site's design - [Use the JavaScript API](/docs/javascript-api) for advanced integrations diff --git a/e2e/sandbox.spec.ts b/e2e/sandbox.spec.ts new file mode 100644 index 0000000..1e5509b --- /dev/null +++ b/e2e/sandbox.spec.ts @@ -0,0 +1,163 @@ +import { test, expect } from '@playwright/test'; + +test.describe('BugDrop Sandbox', () => { + test('serves sandbox route assets from the Worker', async ({ request }) => { + for (const path of [ + '/sandbox/', + '/sandbox/preview', + '/sandbox/sandbox.css', + '/sandbox/sandbox.js', + '/widget.js', + ]) { + const response = await request.get(path); + expect(response.ok(), `${path} should resolve`).toBe(true); + } + }); + + test('generates a script tag and loads the configured preview widget', async ({ page }) => { + const widgetRequests: string[] = []; + page.on('request', request => { + if (request.url().endsWith('/widget.js')) widgetRequests.push(request.url()); + }); + + await page.route('**/api/check/**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true, repo: 'mean-weasel/bugdrop' }), + }); + }); + + await page.goto('/sandbox/'); + + await expect(page.getByRole('heading', { name: 'BugDrop Sandbox' })).toBeVisible(); + await expect(page.locator('#sandbox-preview')).toHaveAttribute('sandbox', /allow-scripts/); + await expect(page.locator('#script-code')).toContainText('data-repo="mean-weasel/bugdrop"'); + + await page.locator('#repo').fill('acme/app'); + await page.locator('#theme').selectOption('dark'); + await page.locator('#position').selectOption('bottom-left'); + await page.locator('#screenshot').selectOption('required'); + await page.locator('#label').fill('Send Feedback'); + await page.locator('#icon').fill('none'); + await page.locator('#screenshotScale').fill('3'); + await page.locator('#radius').fill('10px'); + await page.locator('#categoryLabels').fill('{"bug":["defect"],"feature":"idea"}'); + + const scriptCode = page.locator('#script-code'); + await expect(scriptCode).toContainText('data-repo="acme/app"'); + await expect(scriptCode).toContainText('data-theme="dark"'); + await expect(scriptCode).toContainText('data-position="bottom-left"'); + await expect(scriptCode).toContainText('data-screenshot="required"'); + await expect(scriptCode).toContainText('data-label="Send Feedback"'); + await expect(scriptCode).toContainText('data-icon="none"'); + await expect(scriptCode).toContainText('data-screenshot-scale="3"'); + await expect(scriptCode).toContainText('data-radius="10px"'); + await expect(scriptCode).toContainText( + 'data-category-labels="{"bug":["defect"],"feature":"idea"}"' + ); + await expect(scriptCode).not.toContainText('async'); + await expect(scriptCode).not.toContainText('defer'); + + const frame = page.frameLocator('#sandbox-preview'); + await expect(frame.locator('#bugdrop-host').locator('css=.bd-trigger')).toBeVisible({ + timeout: 10_000, + }); + await expect(frame.locator('[data-bugdrop-mask]')).toHaveCount(3); + expect(widgetRequests.some(url => url === `${new URL(page.url()).origin}/widget.js`)).toBe( + true + ); + }); + + test('validates and encodes repo paths before checking installation', async ({ page }) => { + const checkRequests: string[] = []; + await page.route('**/api/check/**', async route => { + checkRequests.push(route.request().url()); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: false, repo: 'acme/app' }), + }); + }); + + await page.goto('/sandbox/'); + + await page.locator('#repo').fill('not-a-repo'); + await expect(page.locator('#repo-feedback')).toHaveText( + 'Repository must use GitHub owner/repo format with letters, numbers, dots, underscores, or hyphens.' + ); + + await page.locator('#repo').fill('acme/app?x=1'); + await expect(page.locator('#repo-feedback')).toHaveText( + 'Repository must use GitHub owner/repo format with letters, numbers, dots, underscores, or hyphens.' + ); + + await page.locator('#repo').fill('acme/app'); + await expect(page.locator('#repo-feedback')).toHaveText('Ready to check installation.'); + await page.locator('#check-installation').click(); + await expect(page.locator('#repo-feedback')).toHaveText( + 'BugDrop is not installed on acme/app.' + ); + expect(checkRequests).toContain(`${new URL(page.url()).origin}/api/check/acme/app`); + }); + + test('ignores stale installation checks when repo changes', async ({ page }) => { + await page.route('**/api/check/slow/repo', async route => { + await new Promise(resolve => setTimeout(resolve, 250)); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true, repo: 'slow/repo' }), + }); + }); + await page.route('**/api/check/fast/repo', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: false, repo: 'fast/repo' }), + }); + }); + + await page.goto('/sandbox/'); + await page.locator('#repo').fill('slow/repo'); + await page.locator('#check-installation').click(); + await page.locator('#repo').fill('fast/repo'); + await page.locator('#check-installation').click(); + + await expect(page.locator('#repo-feedback')).toHaveText( + 'BugDrop is not installed on fast/repo.' + ); + await page.waitForTimeout(300); + await expect(page.locator('#repo-feedback')).toHaveText( + 'BugDrop is not installed on fast/repo.' + ); + }); + + test('required contact fields imply visible contact fields in generated script', async ({ + page, + }) => { + await page.goto('/sandbox/'); + + await page.locator('#requireEmail').check(); + await page.locator('#requireName').check(); + + const scriptCode = page.locator('#script-code'); + await expect(scriptCode).toContainText('data-show-email="true"'); + await expect(scriptCode).toContainText('data-require-email="true"'); + await expect(scriptCode).toContainText('data-show-name="true"'); + await expect(scriptCode).toContainText('data-require-name="true"'); + }); + + test('reports clipboard failures without throwing', async ({ page }) => { + await page.goto('/sandbox/'); + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + }); + + await page.locator('#copy-script').click(); + await expect(page.locator('#copy-script')).toHaveText('Copy failed'); + }); +}); diff --git a/public/sandbox/index.html b/public/sandbox/index.html new file mode 100644 index 0000000..143da33 --- /dev/null +++ b/public/sandbox/index.html @@ -0,0 +1,176 @@ + + + + + + BugDrop Sandbox + + + +
+
+
+
+

BugDrop Sandbox

+

Configure the widget, preview it on a realistic page, and generate the script tag.

+
+ Installation docs +
+ +
+ + +
+
+
+

Preview

+

Reloads with the selected script attributes.

+
+ +
+ + +
+
+
+

Script Tag

+

Paste this before the closing body tag in your app.

+
+ +
+
+
+ +
+

Redaction Check

+

The preview includes fields marked with data-bugdrop-mask. Screenshot masking is best-effort visual masking for elements developers mark; it is not automatic secret detection.

+
+
+
+
+
+ + + diff --git a/public/sandbox/preview.html b/public/sandbox/preview.html new file mode 100644 index 0000000..da03c9c --- /dev/null +++ b/public/sandbox/preview.html @@ -0,0 +1,477 @@ + + + + + + BugDrop Sandbox Preview + + + +
+
+
Acme Console
+ +
+ +
+
+

Ship feedback from the page where users notice it.

+

This preview page gives the widget real layout, forms, private fields, scroll depth, and UI density for setup testing.

+
+ + +
+
+ +
+ +
+
+
+

Recent Feedback

+ Live queue +
+
+
+
+
Checkout total changes after coupon edit
+
Reported from /billing/checkout
+
+ Bug + High +
+
+
+
Add export option for filtered events
+
Reported from /events
+
+ Feature + Medium +
+
+
+
Clarify which workspace owns audit logs
+
Reported from /settings/security
+
+ Question + Low +
+
+
+
Keyboard focus skips the invite role selector
+
Reported from /team/invites
+
+ Bug + High +
+
+
+ + +
+
+ + + + diff --git a/public/sandbox/sandbox.css b/public/sandbox/sandbox.css new file mode 100644 index 0000000..5cdd5a9 --- /dev/null +++ b/public/sandbox/sandbox.css @@ -0,0 +1,311 @@ +:root { + color-scheme: light; + --bg: #f7f8fb; + --panel: #ffffff; + --panel-subtle: #f1f4f8; + --text: #172033; + --muted: #617089; + --border: #d9e0ea; + --strong-border: #b7c2d1; + --accent: #7c3aed; + --accent-strong: #5b21b6; + --success: #0f766e; + --warning: #9a3412; + --danger: #b91c1c; + --shadow: 0 18px 44px rgba(23, 32, 51, 0.11); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--text); + background: + linear-gradient(180deg, rgba(124, 58, 237, 0.08), transparent 360px), + var(--bg); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button, +.docs-link { + min-height: 40px; + border: 1px solid transparent; + border-radius: 8px; + padding: 0 14px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 650; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +button { + color: #fff; + background: var(--accent); +} + +button:hover { + background: var(--accent-strong); +} + +.secondary-button, +.docs-link { + color: var(--text); + background: var(--panel); + border-color: var(--border); +} + +.secondary-button:hover, +.docs-link:hover { + background: var(--panel-subtle); +} + +.sandbox-shell { + min-height: 100vh; + padding: 28px; +} + +.workspace { + width: min(1480px, 100%); + margin: 0 auto; +} + +.workspace-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 22px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: clamp(2rem, 4vw, 3.75rem); + line-height: 1; + font-weight: 760; +} + +.workspace-header p, +.preview-toolbar p, +.script-header p, +.safety-note p { + color: var(--muted); + margin-top: 8px; + line-height: 1.5; +} + +.layout-grid { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +.controls-panel, +.script-output, +.safety-note { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.controls-panel { + position: sticky; + top: 20px; + max-height: calc(100vh - 40px); + overflow: auto; +} + +.control-section { + padding: 18px; + border-bottom: 1px solid var(--border); +} + +.control-section:last-child { + border-bottom: 0; +} + +h2 { + color: var(--text); + font-size: 0.78rem; + font-weight: 780; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.field { + display: grid; + gap: 7px; + margin-top: 14px; +} + +.field span { + color: var(--muted); + font-size: 0.84rem; + font-weight: 650; +} + +input, +textarea, +select { + width: 100%; + color: var(--text); + background: #fff; + border: 1px solid var(--border); + border-radius: 8px; + padding: 0 11px; + outline: none; +} + +input, +select { + min-height: 40px; +} + +textarea { + min-height: 88px; + padding-top: 10px; + resize: vertical; +} + +input:focus, +textarea:focus, +select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.14); +} + +.repo-feedback { + margin: 10px 0 12px; + min-height: 20px; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.4; +} + +.repo-feedback.ok { + color: var(--success); +} + +.repo-feedback.warn { + color: var(--warning); +} + +.repo-feedback.error { + color: var(--danger); +} + +.checkbox-grid { + display: grid; + gap: 10px; + margin-top: 16px; +} + +.checkbox-grid label { + display: flex; + align-items: center; + gap: 9px; + color: var(--text); + font-size: 0.9rem; + font-weight: 560; +} + +.checkbox-grid input { + width: 16px; + min-height: 16px; + accent-color: var(--accent); +} + +.preview-column { + display: grid; + gap: 18px; +} + +.preview-toolbar, +.script-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +#sandbox-preview { + width: 100%; + min-height: 680px; + border: 1px solid var(--strong-border); + border-radius: 8px; + background: #fff; + box-shadow: var(--shadow); +} + +.script-output, +.safety-note { + padding: 18px; +} + +pre { + margin: 16px 0 0; + padding: 16px; + overflow: auto; + color: #e5e7eb; + background: #111827; + border-radius: 8px; + font-size: 0.84rem; + line-height: 1.55; +} + +code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.safety-note code { + color: var(--accent-strong); + background: rgba(124, 58, 237, 0.08); + border-radius: 4px; + padding: 2px 4px; +} + +@media (max-width: 980px) { + .sandbox-shell { + padding: 18px; + } + + .workspace-header, + .preview-toolbar, + .script-header { + align-items: flex-start; + flex-direction: column; + } + + .layout-grid { + grid-template-columns: 1fr; + } + + .controls-panel { + position: static; + max-height: none; + } + + #sandbox-preview { + min-height: 620px; + } +} diff --git a/public/sandbox/sandbox.js b/public/sandbox/sandbox.js new file mode 100644 index 0000000..e80b6d9 --- /dev/null +++ b/public/sandbox/sandbox.js @@ -0,0 +1,305 @@ +const form = document.querySelector('#sandbox-form'); +const preview = document.querySelector('#sandbox-preview'); +const scriptCode = document.querySelector('#script-code'); +const repoFeedback = document.querySelector('#repo-feedback'); +const copyButton = document.querySelector('#copy-script'); +const checkButton = document.querySelector('#check-installation'); +const refreshButton = document.querySelector('#refresh-preview'); +const GITHUB_REPO_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9._-]{1,100}$/; +const SAFE_TEXT_PATTERN = /^[\w .,:;!?@#%&()[\]/'"_+=-]{0,120}$/; +let installationCheckId = 0; + +const SCRIPT_ATTRIBUTE_MAP = { + repo: 'data-repo', + theme: 'data-theme', + position: 'data-position', + color: 'data-color', + label: 'data-label', + icon: 'data-icon', + screenshot: 'data-screenshot', + welcome: 'data-welcome', + showName: 'data-show-name', + requireName: 'data-require-name', + showEmail: 'data-show-email', + requireEmail: 'data-require-email', + buttonDismissible: 'data-button-dismissible', + dismissDuration: 'data-dismiss-duration', + showRestore: 'data-show-restore', + showButton: 'data-button', + screenshotScale: 'data-screenshot-scale', + font: 'data-font', + radius: 'data-radius', + bg: 'data-bg', + text: 'data-text', + borderWidth: 'data-border-width', + borderColor: 'data-border-color', + shadow: 'data-shadow', + categoryLabels: 'data-category-labels', +}; + +function readConfig() { + return { + repo: form.repo.value.trim(), + theme: form.theme.value, + position: form.position.value, + color: form.color.value.trim(), + label: form.label.value.trim(), + icon: form.icon.value, + screenshot: form.screenshot.value, + welcome: form.welcome.value, + showName: form.showName.checked, + requireName: form.requireName.checked, + showEmail: form.showEmail.checked, + requireEmail: form.requireEmail.checked, + buttonDismissible: form.buttonDismissible.checked, + dismissDuration: form.dismissDuration.value.trim(), + showRestore: form.showRestore.checked, + showButton: form.showButton.checked, + screenshotScale: form.screenshotScale.value.trim(), + font: form.font.value.trim(), + radius: form.radius.value.trim(), + bg: form.bg.value.trim(), + text: form.text.value.trim(), + borderWidth: form.borderWidth.value.trim(), + borderColor: form.borderColor.value.trim(), + shadow: form.shadow.value.trim(), + categoryLabels: form.categoryLabels.value.trim(), + }; +} + +function normalizeConfig(config) { + return { + ...config, + showName: config.requireName || config.showName, + showEmail: config.requireEmail || config.showEmail, + }; +} + +function sanitizeConfig(config) { + const normalized = normalizeConfig(config); + const validRepo = isValidRepo(normalized.repo) ? normalized.repo : ''; + + return { + ...normalized, + repo: validRepo, + theme: ['auto', 'light', 'dark'].includes(normalized.theme) ? normalized.theme : 'auto', + position: ['bottom-right', 'bottom-left'].includes(normalized.position) + ? normalized.position + : 'bottom-right', + screenshot: ['optional', 'required', 'auto'].includes(normalized.screenshot) + ? normalized.screenshot + : 'optional', + welcome: ['once', 'always', 'never'].includes(normalized.welcome) + ? normalized.welcome + : 'once', + color: sanitizeCssToken(normalized.color, 80), + label: sanitizePlainText(normalized.label, 80), + icon: sanitizeIcon(normalized.icon), + dismissDuration: sanitizeDismissDuration(normalized.dismissDuration), + screenshotScale: sanitizeScreenshotScale(normalized.screenshotScale), + font: sanitizeCssToken(normalized.font, 120), + radius: sanitizeCssToken(normalized.radius, 40), + bg: sanitizeCssToken(normalized.bg, 80), + text: sanitizeCssToken(normalized.text, 80), + borderWidth: sanitizeCssToken(normalized.borderWidth, 40), + borderColor: sanitizeCssToken(normalized.borderColor, 80), + shadow: sanitizeCssToken(normalized.shadow, 120), + categoryLabels: sanitizeCategoryLabels(normalized.categoryLabels), + }; +} + +function getWidgetSrc() { + return `${window.location.origin}/widget.js`; +} + +function getScriptAttributes(config) { + const attrs = { + repo: config.repo, + theme: config.theme, + position: config.position, + color: config.color, + label: config.label, + icon: config.icon, + screenshot: config.screenshot, + welcome: config.welcome === 'once' ? '' : config.welcome, + showName: config.showName ? 'true' : '', + requireName: config.requireName ? 'true' : '', + showEmail: config.showEmail ? 'true' : '', + requireEmail: config.requireEmail ? 'true' : '', + buttonDismissible: config.buttonDismissible ? 'true' : '', + dismissDuration: config.dismissDuration, + showRestore: config.showRestore ? '' : 'false', + showButton: config.showButton ? '' : 'false', + screenshotScale: config.screenshotScale === '2' ? '' : config.screenshotScale, + font: config.font, + radius: config.radius, + bg: config.bg, + text: config.text, + borderWidth: config.borderWidth, + borderColor: config.borderColor, + shadow: config.shadow, + categoryLabels: config.categoryLabels, + }; + + return Object.entries(attrs).filter(([, value]) => value !== ''); +} + +function generateScriptTag(config) { + const lines = [``; + return lines.join('\n'); +} + +function escapeAttribute(value) { + return String(value).replaceAll('&', '&').replaceAll('"', '"'); +} + +function isValidRepo(repo) { + return GITHUB_REPO_PATTERN.test(repo) && !repo.endsWith('.git'); +} + +function getRepoPath(repo) { + const [owner, name] = repo.split('/'); + return `${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; +} + +function sanitizePlainText(value, maxLength) { + const trimmed = value.trim().slice(0, maxLength); + return SAFE_TEXT_PATTERN.test(trimmed) ? trimmed : ''; +} + +function sanitizeCssToken(value, maxLength) { + const trimmed = value.trim().slice(0, maxLength); + return /^[^<>"`{}]*$/.test(trimmed) ? trimmed : ''; +} + +function sanitizeIcon(value) { + const trimmed = value.trim(); + if (!trimmed || trimmed === 'none') return trimmed; + + try { + const url = new URL(trimmed); + return ['https:', 'http:'].includes(url.protocol) ? url.toString() : ''; + } catch { + return ''; + } +} + +function sanitizeDismissDuration(value) { + if (!value || value === 'session') return value; + return /^\d{1,5}$/.test(value) ? value : ''; +} + +function sanitizeScreenshotScale(value) { + if (!value) return ''; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 1 && parsed <= 4 ? String(parsed) : ''; +} + +function sanitizeCategoryLabels(value) { + if (!value) return ''; + + try { + JSON.parse(value); + return value.length <= 1000 ? value : ''; + } catch { + return ''; + } +} + +function updateRequiredImplications() { + if (form.requireName.checked) form.showName.checked = true; + if (form.requireEmail.checked) form.showEmail.checked = true; +} + +function updatePreview() { + updateRequiredImplications(); + const rawConfig = normalizeConfig(readConfig()); + const config = sanitizeConfig(rawConfig); + const params = new URLSearchParams(); + + for (const [key, value] of getScriptAttributes(config)) { + params.set(key, value); + } + + scriptCode.textContent = generateScriptTag(config); + preview.src = `./preview?${params.toString()}&v=${Date.now()}`; + validateRepo(rawConfig.repo, false); +} + +function validateRepo(repo, announceSuccess) { + repoFeedback.className = 'repo-feedback'; + + if (!repo) { + repoFeedback.classList.add('error'); + repoFeedback.textContent = 'Enter a repository in owner/repo format.'; + return false; + } + + if (!isValidRepo(repo)) { + repoFeedback.classList.add('error'); + repoFeedback.textContent = + 'Repository must use GitHub owner/repo format with letters, numbers, dots, underscores, or hyphens.'; + return false; + } + + repoFeedback.classList.add('ok'); + repoFeedback.textContent = announceSuccess + ? 'Repository format looks valid.' + : 'Ready to check installation.'; + + return true; +} + +async function checkInstallation() { + const { repo } = readConfig(); + if (!validateRepo(repo, false)) return; + + const requestId = ++installationCheckId; + repoFeedback.className = 'repo-feedback'; + repoFeedback.textContent = 'Checking GitHub App installation...'; + + try { + const response = await fetch(`/api/check/${getRepoPath(repo)}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + if (requestId !== installationCheckId || readConfig().repo !== repo) return; + + repoFeedback.className = `repo-feedback ${result.installed ? 'ok' : 'warn'}`; + repoFeedback.textContent = result.installed + ? `BugDrop is installed on ${result.repo}.` + : `BugDrop is not installed on ${result.repo}.`; + } catch { + if (requestId !== installationCheckId || readConfig().repo !== repo) return; + repoFeedback.className = 'repo-feedback error'; + repoFeedback.textContent = 'Unable to reach the BugDrop API from this page.'; + } +} + +async function copyScript() { + try { + if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable'); + await navigator.clipboard.writeText(scriptCode.textContent); + copyButton.textContent = 'Copied'; + } catch { + copyButton.textContent = 'Copy failed'; + } + + window.setTimeout(() => { + copyButton.textContent = 'Copy'; + }, 1400); +} + +form.addEventListener('input', updatePreview); +form.addEventListener('change', updatePreview); +checkButton.addEventListener('click', checkInstallation); +refreshButton.addEventListener('click', updatePreview); +copyButton.addEventListener('click', copyScript); + +updatePreview(); diff --git a/src/index.ts b/src/index.ts index d229ec4..2a06f75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,36 @@ app.use('*', logger()); // Mount API routes app.route('/api', api); +function fetchAsset(c: { req: { url: string; raw: Request }; env: Env }, pathname: string) { + const url = new URL(c.req.url); + url.pathname = pathname; + return c.env.ASSETS.fetch(new Request(url, c.req.raw)); +} + +app.get('/sandbox', c => { + return c.redirect('/sandbox/', 301); +}); + +app.get('/sandbox/', c => { + return fetchAsset(c, '/sandbox/index.html'); +}); + +app.get('/sandbox/preview', c => { + return fetchAsset(c, '/sandbox/preview.html'); +}); + +app.get('/sandbox/preview.html', c => { + return fetchAsset(c, '/sandbox/preview.html'); +}); + +app.get('/sandbox/sandbox.css', c => { + return fetchAsset(c, '/sandbox/sandbox.css'); +}); + +app.get('/sandbox/sandbox.js', c => { + return fetchAsset(c, '/sandbox/sandbox.js'); +}); + // Redirect to landing page on Vercel app.get('/', c => { return c.redirect('https://bugdrop.dev', 301); From 8b96a3019764284fdd9d571731b5d25f4424b086 Mon Sep 17 00:00:00 2001 From: neonwatty Date: Mon, 11 May 2026 15:40:06 -0700 Subject: [PATCH 2/3] fix: address sandbox PR review feedback Apply review findings on PR #154: - ci-testing.mdx welcome-screen test: tag welcome modal with `bd-welcome` class so the published example can distinguish it from the feedback form - CI/deploy smoke checks: replace `curl -sfo /dev/null` (passes on any 200) with content-grep assertions; move worker origin to `env:` block - checkInstallation: bind and console.warn the error, distinguish HTTP errors from network errors instead of showing a generic "unable to reach" - Sanitizer feedback: aggregate rejected fields in updatePreview and surface "Ignored invalid values for: ..." inline below the preview - Redaction copy: mention auto-masked password/cc-* defaults - Extract SCRIPT_ATTRIBUTE_MAP to public/sandbox/attribute-map.js as ESM module imported by sandbox.js; preview.html keeps an inlined copy because the iframe sandbox="allow-scripts" (no allow-same-origin) blocks ES module imports via CORS - src/index.ts: delete 5 redundant `app.get('/sandbox/...')` handlers and `fetchAsset` helper; Wrangler Assets serves them directly so the worker is no longer invoked (and logger no longer runs) for every static asset hit. Keep only the /sandbox -> /sandbox/ 301 redirect - preview.html: add script.onerror banner when /widget.js fails to load - Extract sanitizers to public/sandbox/sanitizers.js (45-test vitest suite covers escapeAttribute ordering, sanitizeIcon javascript:/data: rejection, sanitizeCategoryLabels JSON shape/size, sanitizeCssToken XSS chars + embedded whitespace, isValidRepo length boundaries, etc.) - Tighten sanitizeCategoryLabels to require non-null non-array object - Tighten sanitizeCssToken to reject embedded \r\n\t - copyScript: bind and console.warn the clipboard error - sandbox.css: drop color-scheme: light -> light dark - E2E: replace waitForTimeout(300) with deterministic releaseSlow + expect.poll; add stale-check-on-input-change test (covers the readConfig().repo !== repo guard at the second site); add sanitize-feedback visibility test; assert content-type on each served asset (catches 200-with-wrong-content) --- .github/workflows/ci.yml | 19 +- .github/workflows/deploy.yml | 19 +- docs/website/ci-testing.mdx | 6 +- e2e/sandbox.spec.ts | 88 +++++++-- public/sandbox/attribute-map.js | 30 +++ public/sandbox/index.html | 8 +- public/sandbox/preview.html | 23 ++- public/sandbox/sandbox.css | 13 +- public/sandbox/sandbox.js | 240 +++++++++--------------- public/sandbox/sanitizers.js | 107 +++++++++++ src/index.ts | 32 +--- src/widget/index.ts | 3 +- test/sandboxSanitizers.test.ts | 312 ++++++++++++++++++++++++++++++++ 13 files changed, 682 insertions(+), 218 deletions(-) create mode 100644 public/sandbox/attribute-map.js create mode 100644 public/sandbox/sanitizers.js create mode 100644 test/sandboxSanitizers.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4225f79..752817c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,12 +185,21 @@ jobs: exit 1 - name: Verify preview sandbox assets + env: + WORKER_ORIGIN: https://bugdrop-preview.neonwatty.workers.dev run: | - WORKER_ORIGIN="https://bugdrop-preview.neonwatty.workers.dev" - for path in /sandbox/ /sandbox/preview /sandbox/sandbox.css /sandbox/sandbox.js /widget.js; do - echo "Checking ${WORKER_ORIGIN}${path}..." - curl -sfo /dev/null "${WORKER_ORIGIN}${path}" || (echo "Missing preview asset: $path" && exit 1) - done + check() { + local path="$1" expected="$2" + echo "Checking ${WORKER_ORIGIN}${path} for '${expected}'..." + body=$(curl -sSf "${WORKER_ORIGIN}${path}") || { echo "Fetch failed: $path"; exit 1; } + grep -q -- "$expected" <<<"$body" || { echo "Wrong content for $path (expected: $expected)"; exit 1; } + } + check /sandbox/ 'BugDrop Sandbox' + check /sandbox/preview 'BugDrop Sandbox Preview' + check /sandbox/sandbox.css 'sandbox-shell' + check /sandbox/sandbox.js 'generateScriptTag' + check /sandbox/attribute-map.js 'data-repo' + check /widget.js 'bd-trigger' - name: Verify test venue is reachable run: | diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bdcc297..146e34d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -66,9 +66,18 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - name: Verify production sandbox assets + env: + WORKER_ORIGIN: https://bugdrop.neonwatty.workers.dev run: | - WORKER_ORIGIN="https://bugdrop.neonwatty.workers.dev" - for path in /sandbox/ /sandbox/preview /sandbox/sandbox.css /sandbox/sandbox.js /widget.js; do - echo "Checking ${WORKER_ORIGIN}${path}..." - curl -sfo /dev/null "${WORKER_ORIGIN}${path}" || (echo "Missing production asset: $path" && exit 1) - done + check() { + local path="$1" expected="$2" + echo "Checking ${WORKER_ORIGIN}${path} for '${expected}'..." + body=$(curl -sSf "${WORKER_ORIGIN}${path}") || { echo "Fetch failed: $path"; exit 1; } + grep -q -- "$expected" <<<"$body" || { echo "Wrong content for $path (expected: $expected)"; exit 1; } + } + check /sandbox/ 'BugDrop Sandbox' + check /sandbox/preview 'BugDrop Sandbox Preview' + check /sandbox/sandbox.css 'sandbox-shell' + check /sandbox/sandbox.js 'generateScriptTag' + check /sandbox/attribute-map.js 'data-repo' + check /widget.js 'bd-trigger' diff --git a/docs/website/ci-testing.mdx b/docs/website/ci-testing.mdx index 5f2a440..ed9eaef 100644 --- a/docs/website/ci-testing.mdx +++ b/docs/website/ci-testing.mdx @@ -118,10 +118,12 @@ test.describe("BugDrop Widget", () => { const btn = await shadowEl(page, "bug-drop-widget", ".floating-btn"); await (btn as any).click(); + // The welcome modal carries the `.bd-welcome` class; the feedback form does not. + // For `welcome: "once"`, the welcome modal only appears on the first open per repo; + // clear localStorage in `beforeEach` if you want this test to assert it deterministically. const welcomeVisible = await page.evaluate(() => { const host = document.querySelector("bug-drop-widget"); - const welcome = host?.shadowRoot?.querySelector(".welcome-message, h2"); - return Boolean(welcome); + return Boolean(host?.shadowRoot?.querySelector(".bd-welcome")); }); expect(welcomeVisible).toBe(EXPECTED.welcome !== "never"); diff --git a/e2e/sandbox.spec.ts b/e2e/sandbox.spec.ts index 1e5509b..51309d9 100644 --- a/e2e/sandbox.spec.ts +++ b/e2e/sandbox.spec.ts @@ -2,15 +2,19 @@ import { test, expect } from '@playwright/test'; test.describe('BugDrop Sandbox', () => { test('serves sandbox route assets from the Worker', async ({ request }) => { - for (const path of [ - '/sandbox/', - '/sandbox/preview', - '/sandbox/sandbox.css', - '/sandbox/sandbox.js', - '/widget.js', - ]) { + const cases: Array<{ path: string; contentType: RegExp }> = [ + { path: '/sandbox/', contentType: /html/ }, + { path: '/sandbox/preview', contentType: /html/ }, + { path: '/sandbox/attribute-map.js', contentType: /javascript/ }, + { path: '/sandbox/sanitizers.js', contentType: /javascript/ }, + { path: '/sandbox/sandbox.css', contentType: /css/ }, + { path: '/sandbox/sandbox.js', contentType: /javascript/ }, + { path: '/widget.js', contentType: /javascript/ }, + ]; + for (const { path, contentType } of cases) { const response = await request.get(path); expect(response.ok(), `${path} should resolve`).toBe(true); + expect(response.headers()['content-type'], `${path} content-type`).toMatch(contentType); } }); @@ -102,8 +106,12 @@ test.describe('BugDrop Sandbox', () => { }); test('ignores stale installation checks when repo changes', async ({ page }) => { + let releaseSlow: (() => void) | undefined; + const slowReleased = new Promise(resolve => { + releaseSlow = resolve; + }); await page.route('**/api/check/slow/repo', async route => { - await new Promise(resolve => setTimeout(resolve, 250)); + await slowReleased; await route.fulfill({ status: 200, contentType: 'application/json', @@ -121,16 +129,51 @@ test.describe('BugDrop Sandbox', () => { await page.goto('/sandbox/'); await page.locator('#repo').fill('slow/repo'); await page.locator('#check-installation').click(); + await expect(page.locator('#repo-feedback')).toHaveText('Checking GitHub App installation...'); await page.locator('#repo').fill('fast/repo'); await page.locator('#check-installation').click(); - - await expect(page.locator('#repo-feedback')).toHaveText( - 'BugDrop is not installed on fast/repo.' - ); - await page.waitForTimeout(300); await expect(page.locator('#repo-feedback')).toHaveText( 'BugDrop is not installed on fast/repo.' ); + + // Now release the slow request; its callback must not clobber the fast result. + releaseSlow?.(); + // Drive the deterministic guarantee: poll for stability rather than wall-clock wait. + await expect + .poll(() => page.locator('#repo-feedback').textContent(), { timeout: 2000 }) + .toBe('BugDrop is not installed on fast/repo.'); + }); + + test('discards stale check when repo input changes mid-flight (no second click)', async ({ + page, + }) => { + let releaseSlow: (() => void) | undefined; + const slowReleased = new Promise(resolve => { + releaseSlow = resolve; + }); + await page.route('**/api/check/slow/repo', async route => { + await slowReleased; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true, repo: 'slow/repo' }), + }); + }); + + await page.goto('/sandbox/'); + await page.locator('#repo').fill('slow/repo'); + await page.locator('#check-installation').click(); + await expect(page.locator('#repo-feedback')).toHaveText('Checking GitHub App installation...'); + + // User edits the repo without clicking check again, then the slow response returns. + await page.locator('#repo').fill('other/repo'); + releaseSlow?.(); + + // Validation feedback (from input handler) is fine; what must NOT happen is the + // stale "installed on slow/repo" text overwriting it. + await expect(page.locator('#repo-feedback')).not.toHaveText(/installed on slow\/repo/, { + timeout: 2000, + }); }); test('required contact fields imply visible contact fields in generated script', async ({ @@ -148,6 +191,25 @@ test.describe('BugDrop Sandbox', () => { await expect(scriptCode).toContainText('data-require-name="true"'); }); + test('surfaces an "ignored invalid values" notice when sanitizers reject input', async ({ + page, + }) => { + await page.goto('/sandbox/'); + const notice = page.locator('#sanitize-feedback'); + await expect(notice).toBeHidden(); + + // Inject a value the CSS-token sanitizer rejects. + await page.locator('#color').fill('red"; onerror="alert(1)'); + + await expect(notice).toBeVisible(); + await expect(notice).toContainText(/Ignored invalid values for: .*Accent color/); + await expect(page.locator('#script-code')).not.toContainText('data-color'); + + // Restoring a valid value clears the notice. + await page.locator('#color').fill('#7c3aed'); + await expect(notice).toBeHidden(); + }); + test('reports clipboard failures without throwing', async ({ page }) => { await page.goto('/sandbox/'); await page.evaluate(() => { diff --git a/public/sandbox/attribute-map.js b/public/sandbox/attribute-map.js new file mode 100644 index 0000000..ca4b6b7 --- /dev/null +++ b/public/sandbox/attribute-map.js @@ -0,0 +1,30 @@ +// Single source of truth for the widget's camelCase → data-* attribute mapping. +// Imported by sandbox.js (config UI) and preview.html (iframe). Keep in sync +// with the consumers in src/widget/index.ts (script.dataset.* reads). +export const ATTRIBUTE_MAP = Object.freeze({ + repo: 'data-repo', + theme: 'data-theme', + position: 'data-position', + color: 'data-color', + label: 'data-label', + icon: 'data-icon', + screenshot: 'data-screenshot', + welcome: 'data-welcome', + showName: 'data-show-name', + requireName: 'data-require-name', + showEmail: 'data-show-email', + requireEmail: 'data-require-email', + buttonDismissible: 'data-button-dismissible', + dismissDuration: 'data-dismiss-duration', + showRestore: 'data-show-restore', + showButton: 'data-button', + screenshotScale: 'data-screenshot-scale', + font: 'data-font', + radius: 'data-radius', + bg: 'data-bg', + text: 'data-text', + borderWidth: 'data-border-width', + borderColor: 'data-border-color', + shadow: 'data-shadow', + categoryLabels: 'data-category-labels', +}); diff --git a/public/sandbox/index.html b/public/sandbox/index.html index 143da33..0f7f6bb 100644 --- a/public/sandbox/index.html +++ b/public/sandbox/index.html @@ -152,6 +152,8 @@

Preview

+ +
@@ -164,13 +166,13 @@

Script Tag

-

Redaction Check

-

The preview includes fields marked with data-bugdrop-mask. Screenshot masking is best-effort visual masking for elements developers mark; it is not automatic secret detection.

+

Screenshot Masking

+

The preview includes fields marked with data-bugdrop-mask. Masking also covers a small set of built-in defaults (input[type="password"] and credit-card autocomplete fields). It is best-effort visual masking, not automatic secret detection.

- + diff --git a/public/sandbox/preview.html b/public/sandbox/preview.html index da03c9c..9f962b3 100644 --- a/public/sandbox/preview.html +++ b/public/sandbox/preview.html @@ -432,7 +432,11 @@

Private Notes

`; return lines.join('\n'); } -function escapeAttribute(value) { - return String(value).replaceAll('&', '&').replaceAll('"', '"'); -} - -function isValidRepo(repo) { - return GITHUB_REPO_PATTERN.test(repo) && !repo.endsWith('.git'); -} - -function getRepoPath(repo) { - const [owner, name] = repo.split('/'); - return `${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; -} - -function sanitizePlainText(value, maxLength) { - const trimmed = value.trim().slice(0, maxLength); - return SAFE_TEXT_PATTERN.test(trimmed) ? trimmed : ''; -} - -function sanitizeCssToken(value, maxLength) { - const trimmed = value.trim().slice(0, maxLength); - return /^[^<>"`{}]*$/.test(trimmed) ? trimmed : ''; +function updateRequiredImplications() { + if (form.requireName.checked) form.showName.checked = true; + if (form.requireEmail.checked) form.showEmail.checked = true; } -function sanitizeIcon(value) { - const trimmed = value.trim(); - if (!trimmed || trimmed === 'none') return trimmed; - - try { - const url = new URL(trimmed); - return ['https:', 'http:'].includes(url.protocol) ? url.toString() : ''; - } catch { - return ''; +function describeRejectedFields(raw, sanitized) { + const rejected = []; + for (const [key, label] of Object.entries(FIELD_LABELS)) { + const before = typeof raw[key] === 'string' ? raw[key] : ''; + if (before && !sanitized[key]) rejected.push(label); } + return rejected; } -function sanitizeDismissDuration(value) { - if (!value || value === 'session') return value; - return /^\d{1,5}$/.test(value) ? value : ''; -} - -function sanitizeScreenshotScale(value) { - if (!value) return ''; - const parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 1 && parsed <= 4 ? String(parsed) : ''; -} - -function sanitizeCategoryLabels(value) { - if (!value) return ''; - - try { - JSON.parse(value); - return value.length <= 1000 ? value : ''; - } catch { - return ''; +function renderSanitizeFeedback(rejected) { + if (!sanitizeFeedback) return; + if (rejected.length === 0) { + sanitizeFeedback.textContent = ''; + sanitizeFeedback.hidden = true; + return; } -} - -function updateRequiredImplications() { - if (form.requireName.checked) form.showName.checked = true; - if (form.requireEmail.checked) form.showEmail.checked = true; + sanitizeFeedback.textContent = `Ignored invalid values for: ${rejected.join(', ')}.`; + sanitizeFeedback.hidden = false; } function updatePreview() { @@ -230,6 +149,7 @@ function updatePreview() { scriptCode.textContent = generateScriptTag(config); preview.src = `./preview?${params.toString()}&v=${Date.now()}`; + renderSanitizeFeedback(describeRejectedFields(rawConfig, config)); validateRepo(rawConfig.repo, false); } @@ -267,7 +187,10 @@ async function checkInstallation() { try { const response = await fetch(`/api/check/${getRepoPath(repo)}`); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}${detail ? `: ${detail.slice(0, 80)}` : ''}`); + } const result = await response.json(); if (requestId !== installationCheckId || readConfig().repo !== repo) return; @@ -275,10 +198,14 @@ async function checkInstallation() { repoFeedback.textContent = result.installed ? `BugDrop is installed on ${result.repo}.` : `BugDrop is not installed on ${result.repo}.`; - } catch { + } catch (err) { if (requestId !== installationCheckId || readConfig().repo !== repo) return; + console.warn('[BugDrop sandbox] installation check failed:', err); + const message = err instanceof Error ? err.message : ''; repoFeedback.className = 'repo-feedback error'; - repoFeedback.textContent = 'Unable to reach the BugDrop API from this page.'; + repoFeedback.textContent = message.startsWith('HTTP') + ? `Installation check failed (${message}). Try again or open an issue.` + : 'Unable to reach the BugDrop API from this page.'; } } @@ -287,7 +214,8 @@ async function copyScript() { if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable'); await navigator.clipboard.writeText(scriptCode.textContent); copyButton.textContent = 'Copied'; - } catch { + } catch (err) { + console.warn('[BugDrop sandbox] clipboard write failed:', err); copyButton.textContent = 'Copy failed'; } diff --git a/public/sandbox/sanitizers.js b/public/sandbox/sanitizers.js new file mode 100644 index 0000000..17f9819 --- /dev/null +++ b/public/sandbox/sanitizers.js @@ -0,0 +1,107 @@ +// Pure sanitization/validation helpers for the sandbox config UI. +// All inputs are user-typed; outputs are safe to embed in script tag attributes +// and URL parameters. Invalid inputs coerce to '' (the generator omits them). + +const GITHUB_REPO_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9._-]{1,100}$/; +const SAFE_TEXT_PATTERN = /^[\w .,:;!?@#%&()[\]/'"_+=-]{0,120}$/; +const CSS_TOKEN_REJECT = /[<>"`{}\r\n\t]/; + +export function escapeAttribute(value) { + return String(value).replaceAll('&', '&').replaceAll('"', '"'); +} + +export function isValidRepo(repo) { + return GITHUB_REPO_PATTERN.test(repo) && !repo.endsWith('.git'); +} + +export function getRepoPath(repo) { + const [owner, name] = repo.split('/'); + return `${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; +} + +export function sanitizePlainText(value, maxLength) { + const trimmed = value.trim().slice(0, maxLength); + return SAFE_TEXT_PATTERN.test(trimmed) ? trimmed : ''; +} + +export function sanitizeCssToken(value, maxLength) { + const trimmed = value.trim().slice(0, maxLength); + return !CSS_TOKEN_REJECT.test(trimmed) ? trimmed : ''; +} + +export function sanitizeIcon(value) { + const trimmed = value.trim(); + if (!trimmed || trimmed === 'none') return trimmed; + try { + const url = new URL(trimmed); + return ['https:', 'http:'].includes(url.protocol) ? url.toString() : ''; + } catch { + return ''; + } +} + +export function sanitizeDismissDuration(value) { + if (!value || value === 'session') return value; + return /^\d{1,5}$/.test(value) ? value : ''; +} + +export function sanitizeScreenshotScale(value) { + if (!value) return ''; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 1 && parsed <= 4 ? String(parsed) : ''; +} + +export function sanitizeCategoryLabels(value) { + if (!value) return ''; + if (value.length > 1000) return ''; + let parsed; + try { + parsed = JSON.parse(value); + } catch { + return ''; + } + // Widget expects a non-null, non-array object. Other JSON shapes are rejected. + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return ''; + } + return value; +} + +export function normalizeConfig(config) { + return { + ...config, + showName: config.requireName || config.showName, + showEmail: config.requireEmail || config.showEmail, + }; +} + +const THEMES = ['auto', 'light', 'dark']; +const POSITIONS = ['bottom-right', 'bottom-left']; +const SCREENSHOTS = ['optional', 'required', 'auto']; +const WELCOMES = ['once', 'always', 'never']; + +export function sanitizeConfig(config) { + const normalized = normalizeConfig(config); + return { + ...normalized, + repo: isValidRepo(normalized.repo) ? normalized.repo : '', + theme: THEMES.includes(normalized.theme) ? normalized.theme : 'auto', + position: POSITIONS.includes(normalized.position) ? normalized.position : 'bottom-right', + screenshot: SCREENSHOTS.includes(normalized.screenshot) ? normalized.screenshot : 'optional', + welcome: WELCOMES.includes(normalized.welcome) ? normalized.welcome : 'once', + color: sanitizeCssToken(normalized.color, 80), + label: sanitizePlainText(normalized.label, 80), + icon: sanitizeIcon(normalized.icon), + dismissDuration: sanitizeDismissDuration(normalized.dismissDuration), + screenshotScale: sanitizeScreenshotScale(normalized.screenshotScale), + font: sanitizeCssToken(normalized.font, 120), + radius: sanitizeCssToken(normalized.radius, 40), + bg: sanitizeCssToken(normalized.bg, 80), + text: sanitizeCssToken(normalized.text, 80), + borderWidth: sanitizeCssToken(normalized.borderWidth, 40), + borderColor: sanitizeCssToken(normalized.borderColor, 80), + shadow: sanitizeCssToken(normalized.shadow, 120), + categoryLabels: sanitizeCategoryLabels(normalized.categoryLabels), + }; +} diff --git a/src/index.ts b/src/index.ts index 2a06f75..f8fc5aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,35 +35,9 @@ app.use('*', logger()); // Mount API routes app.route('/api', api); -function fetchAsset(c: { req: { url: string; raw: Request }; env: Env }, pathname: string) { - const url = new URL(c.req.url); - url.pathname = pathname; - return c.env.ASSETS.fetch(new Request(url, c.req.raw)); -} - -app.get('/sandbox', c => { - return c.redirect('/sandbox/', 301); -}); - -app.get('/sandbox/', c => { - return fetchAsset(c, '/sandbox/index.html'); -}); - -app.get('/sandbox/preview', c => { - return fetchAsset(c, '/sandbox/preview.html'); -}); - -app.get('/sandbox/preview.html', c => { - return fetchAsset(c, '/sandbox/preview.html'); -}); - -app.get('/sandbox/sandbox.css', c => { - return fetchAsset(c, '/sandbox/sandbox.css'); -}); - -app.get('/sandbox/sandbox.js', c => { - return fetchAsset(c, '/sandbox/sandbox.js'); -}); +// /sandbox needs a trailing-slash redirect; everything under /sandbox/* is served +// directly by Wrangler Assets (see [assets] in wrangler.toml) without invoking the Worker. +app.get('/sandbox', c => c.redirect('/sandbox/', 301)); // Redirect to landing page on Vercel app.get('/', c => { diff --git a/src/widget/index.ts b/src/widget/index.ts index 9fb4f64..7242360 100644 --- a/src/widget/index.ts +++ b/src/widget/index.ts @@ -1057,7 +1057,8 @@ function showWelcomeScreen(root: HTMLElement): Promise { `, - true + true, + 'bd-welcome' ); const closeBtn = modal.querySelector('.bd-close') as HTMLElement; diff --git a/test/sandboxSanitizers.test.ts b/test/sandboxSanitizers.test.ts new file mode 100644 index 0000000..9e58872 --- /dev/null +++ b/test/sandboxSanitizers.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect } from 'vitest'; +import { + escapeAttribute, + isValidRepo, + getRepoPath, + sanitizePlainText, + sanitizeCssToken, + sanitizeIcon, + sanitizeDismissDuration, + sanitizeScreenshotScale, + sanitizeCategoryLabels, + normalizeConfig, + sanitizeConfig, + // @ts-expect-error — plain JS module, no type declarations +} from '../public/sandbox/sanitizers.js'; + +describe('escapeAttribute', () => { + it('escapes & before " so order is correct', () => { + expect(escapeAttribute('&"')).toBe('&"'); + }); + + it('replaces all occurrences, not just the first', () => { + expect(escapeAttribute('a&b&c"d"e')).toBe('a&b&c"d"e'); + }); + + it('coerces non-strings via String()', () => { + expect(escapeAttribute(42)).toBe('42'); + expect(escapeAttribute(true)).toBe('true'); + }); +}); + +describe('isValidRepo', () => { + it('accepts valid owner/repo names', () => { + expect(isValidRepo('mean-weasel/bugdrop')).toBe(true); + expect(isValidRepo('acme/app.io')).toBe(true); + expect(isValidRepo('Acme/my_repo')).toBe(true); + expect(isValidRepo('a/b')).toBe(true); + }); + + it('rejects names ending with .git', () => { + expect(isValidRepo('acme/app.git')).toBe(false); + }); + + it('rejects formats without exactly one slash', () => { + expect(isValidRepo('justone')).toBe(false); + expect(isValidRepo('one/two/three')).toBe(false); + expect(isValidRepo('')).toBe(false); + }); + + it('rejects unsafe characters', () => { + expect(isValidRepo('acme/app?x=1')).toBe(false); + expect(isValidRepo('acme/app#frag')).toBe(false); + expect(isValidRepo('acme/