Skip to content

Commit 15c60ca

Browse files
committed
feat: add user screenshot redaction
1 parent 9001d9f commit 15c60ca

7 files changed

Lines changed: 389 additions & 16 deletions

File tree

docs/website/configuration.mdx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,11 @@ Control how screenshots are collected during the feedback flow.
147147

148148
### Screenshot Modes
149149

150-
- **`optional`** -- Shows the screenshot checkbox checked by default. Users can choose full page, element, area, annotate, or skip.
151-
- **`auto`** -- Automatically captures a full-page screenshot after the form is submitted, without showing the manual screenshot picker.
152-
- **`required`** -- Requires a screenshot before submission. Users can choose full page, element, or area, but cannot skip the screenshot step.
150+
- **`optional`** -- Shows the screenshot checkbox checked by default. Users can choose full page, element, area, annotate, redact, or skip.
151+
- **`auto`** -- Automatically captures a full-page screenshot after the form is submitted, without showing the manual screenshot picker or redaction step.
152+
- **`required`** -- Requires a screenshot before submission. Users can choose full page, element, or area, then annotate or redact before submitting.
153+
154+
Manual redaction is controlled by the person submitting feedback. Use developer-configured masking for fields that should never appear in screenshots, especially with automatic screenshots.
153155

154156
```html
155157
<!-- Automatically attach a full-page screenshot -->

docs/website/faq.mdx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,13 @@ Since BugDrop uses Shadow DOM for isolation, it does not conflict with any frame
4545
BugDrop uses [html2canvas](https://html2canvas.hertzen.com/) to capture screenshots entirely on the client side. When a user clicks the screenshot button:
4646

4747
1. html2canvas renders the current page to an HTML Canvas element in the user's browser
48-
2. The canvas is converted to a PNG image
49-
3. The image is sent to the BugDrop API along with the form submission
50-
4. The API commits the image to the `bugdrop-screenshots` branch in your GitHub repository
51-
5. A link to the screenshot is included in the GitHub Issue
48+
2. In optional and required manual screenshot flows, the user can annotate the screenshot and cover sensitive regions with opaque blocks
49+
3. The canvas is converted to a PNG image
50+
4. The image is sent to the BugDrop API along with the form submission
51+
5. The API commits the image to the `bugdrop-screenshots` branch in your GitHub repository
52+
6. A link to the screenshot is included in the GitHub Issue
5253

53-
No server-side rendering or page access is involved. The screenshot captures exactly what the user sees in their browser at the time of submission.
54+
No server-side rendering or page access is involved. The initial capture is rendered from the current page in the user's browser. In manual flows, the submitted PNG may include user annotations or opaque redaction blocks. Auto screenshots upload without a user redaction step.
5455

5556
### Are screenshots stored securely?
5657

docs/website/security.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,12 @@ Treat screenshots as unauthenticated user-generated content. The hosted service
5555

5656
Screenshots are captured client-side using [html2canvas](https://html2canvas.hertzen.com/), which renders the current page to a canvas element in the user's browser. The canvas is then converted to a PNG image and uploaded. This means:
5757

58-
- The screenshot captures what the user actually sees
58+
- The initial screenshot capture is rendered from what the user actually sees
5959
- No server-side rendering or page access is required
6060
- The screenshot is generated entirely in the user's browser before being sent to the API
61+
- Users can redact additional screenshot regions before submitting when using the manual screenshot flow
62+
63+
Manual redaction is user-driven and does not automatically detect sensitive content. It complements, but does not replace, developer-configured masking for fields that should never appear in screenshots, especially when using automatic screenshots.
6164

6265
Because clients are untrusted, the API validates screenshot uploads server-side before storing them. BugDrop currently accepts PNG data URLs only and rejects SVG, malformed base64, oversized payloads, and data that does not have a PNG file signature.
6366

e2e/widget.live.spec.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,19 @@ async function openScreenshotOptions(page: Page, title: string) {
8282
return host;
8383
}
8484

85+
async function trackLiveFeedbackPayloads(page: Page) {
86+
const payloads: Array<Record<string, unknown>> = [];
87+
await page.route('**/feedback', async route => {
88+
payloads.push(route.request().postDataJSON());
89+
await route.fulfill({
90+
status: 200,
91+
contentType: 'application/json',
92+
body: JSON.stringify({ success: true, issueNumber: 1, issueUrl: '#', isPublic: false }),
93+
});
94+
});
95+
return payloads;
96+
}
97+
8598
async function countRedPixelsInRegion(
8699
canvas: Locator,
87100
region: { left: number; top: number; right: number; bottom: number }
@@ -118,6 +131,93 @@ async function countRedPixelsInRegion(
118131
}, region);
119132
}
120133

134+
async function countBlackPixelsInRegion(
135+
canvas: Locator,
136+
region: { left: number; top: number; right: number; bottom: number }
137+
) {
138+
return canvas.evaluate((el, targetRegion) => {
139+
const source = el as HTMLCanvasElement;
140+
const ctx = source.getContext('2d');
141+
if (!ctx) {
142+
throw new Error('Missing canvas context');
143+
}
144+
145+
const xStart = Math.floor(source.width * targetRegion.left);
146+
const xEnd = Math.ceil(source.width * targetRegion.right);
147+
const yStart = Math.floor(source.height * targetRegion.top);
148+
const yEnd = Math.ceil(source.height * targetRegion.bottom);
149+
const { data, width } = ctx.getImageData(xStart, yStart, xEnd - xStart, yEnd - yStart);
150+
let black = 0;
151+
152+
for (let y = 0; y < yEnd - yStart; y++) {
153+
for (let x = 0; x < width; x++) {
154+
const i = (y * width + x) * 4;
155+
const r = data[i];
156+
const g = data[i + 1];
157+
const b = data[i + 2];
158+
const a = data[i + 3];
159+
160+
if (a > 200 && r < 20 && g < 20 && b < 20) {
161+
black++;
162+
}
163+
}
164+
}
165+
166+
return black;
167+
}, region);
168+
}
169+
170+
async function countBlackPixelsInDataUrl(
171+
page: Page,
172+
dataUrl: string,
173+
region: { left: number; top: number; right: number; bottom: number }
174+
) {
175+
return page.evaluate(
176+
async target => {
177+
const img = new Image();
178+
await new Promise<void>((resolve, reject) => {
179+
img.onload = () => resolve();
180+
img.onerror = () => reject(new Error('Failed to load screenshot payload'));
181+
img.src = target.dataUrl;
182+
});
183+
184+
const source = document.createElement('canvas');
185+
source.width = img.width;
186+
source.height = img.height;
187+
const ctx = source.getContext('2d');
188+
if (!ctx) {
189+
throw new Error('Missing canvas context');
190+
}
191+
192+
ctx.drawImage(img, 0, 0);
193+
194+
const xStart = Math.floor(source.width * target.region.left);
195+
const xEnd = Math.ceil(source.width * target.region.right);
196+
const yStart = Math.floor(source.height * target.region.top);
197+
const yEnd = Math.ceil(source.height * target.region.bottom);
198+
const { data, width } = ctx.getImageData(xStart, yStart, xEnd - xStart, yEnd - yStart);
199+
let black = 0;
200+
201+
for (let y = 0; y < yEnd - yStart; y++) {
202+
for (let x = 0; x < width; x++) {
203+
const i = (y * width + x) * 4;
204+
const r = data[i];
205+
const g = data[i + 1];
206+
const b = data[i + 2];
207+
const a = data[i + 3];
208+
209+
if (a > 200 && r < 20 && g < 20 && b < 20) {
210+
black++;
211+
}
212+
}
213+
}
214+
215+
return black;
216+
},
217+
{ dataUrl, region }
218+
);
219+
}
220+
121221
async function dragOnCanvas(
122222
page: Page,
123223
canvas: Locator,
@@ -422,6 +522,49 @@ test.describe('Screenshot Capture (Live)', () => {
422522
expect(await countRedPixelsInRegion(canvas, firstRegion)).toBeGreaterThan(20);
423523
expect(await countRedPixelsInRegion(canvas, latestRegion)).toBeLessThan(5);
424524
});
525+
526+
test('redaction works on the deployed preview widget', async ({ page }) => {
527+
const payloads = await trackLiveFeedbackPayloads(page);
528+
await mockLiveScreenshotCapture(page);
529+
const host = await openScreenshotOptions(page, 'Live preview redaction');
530+
531+
const captureBtn = host.locator('css=[data-action="capture"]');
532+
await expect(captureBtn).toBeVisible({ timeout: 5_000 });
533+
await captureBtn.click();
534+
535+
const canvas = host.locator('css=#annotation-canvas canvas');
536+
await expect(host.locator('css=.bd-modal--annotator')).toBeVisible({ timeout: 10_000 });
537+
await expect(canvas).toBeVisible({ timeout: 10_000 });
538+
await expect.poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).width)).toBe(600);
539+
540+
await host.locator('css=[data-tool="redact"]').click();
541+
await dragOnCanvas(page, canvas, { x: 0.18, y: 0.28 }, { x: 0.42, y: 0.68 });
542+
await dragOnCanvas(page, canvas, { x: 0.58, y: 0.28 }, { x: 0.82, y: 0.68 });
543+
544+
const firstRegion = { left: 0.1, top: 0.18, right: 0.48, bottom: 0.78 };
545+
const latestRegion = { left: 0.52, top: 0.18, right: 0.9, bottom: 0.78 };
546+
547+
expect(await countBlackPixelsInRegion(canvas, firstRegion)).toBeGreaterThan(1000);
548+
expect(await countBlackPixelsInRegion(canvas, latestRegion)).toBeGreaterThan(1000);
549+
550+
await host.locator('css=[data-action="undo"]').click();
551+
552+
expect(await countBlackPixelsInRegion(canvas, firstRegion)).toBeGreaterThan(1000);
553+
expect(await countBlackPixelsInRegion(canvas, latestRegion)).toBeLessThan(20);
554+
555+
await host.locator('css=[data-action="done"]').click();
556+
await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 10_000 });
557+
558+
expect(payloads).toHaveLength(1);
559+
const submittedScreenshot = payloads[0].screenshot;
560+
expect(typeof submittedScreenshot).toBe('string');
561+
expect(
562+
await countBlackPixelsInDataUrl(page, submittedScreenshot as string, firstRegion)
563+
).toBeGreaterThan(1000);
564+
expect(
565+
await countBlackPixelsInDataUrl(page, submittedScreenshot as string, latestRegion)
566+
).toBeLessThan(20);
567+
});
425568
});
426569

427570
test.describe('Feedback Submission (Live)', () => {

0 commit comments

Comments
 (0)