visual-feedback 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -7
- package/CHANGELOG.md +110 -0
- package/README.md +5 -3
- package/dist/dialog-W6m1knfu.mjs +1382 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +1 -1
- package/dist/keep-BG5WxZPU.mjs +74 -0
- package/dist/pdf-Gj-ZO9W5.mjs +85 -0
- package/dist/react.d.mts +1 -1
- package/dist/react.mjs +1 -1
- package/dist/{report-BxonlIdW.mjs → report-BwLr1sYt.mjs} +45 -21
- package/dist/{report-CIgy7uzP.d.mts → report-CaJegYyg.d.mts} +30 -16
- package/dist/{report-BRZX6sWL.mjs → report-CnspEsHf.mjs} +27 -15
- package/dist/server.d.mts +2 -3
- package/dist/server.mjs +22 -13
- package/dist/widget-DvGTo0B4.mjs +565 -0
- package/dist/{widget-Ca1heF8n.d.mts → widget-Z1KOUKjx.d.mts} +14 -4
- package/docs/API.md +59 -31
- package/docs/SPEC.md +122 -30
- package/docs/THEMING.md +50 -19
- package/docs/recipes/github-issues.md +44 -20
- package/docs/recipes/local.md +17 -13
- package/docs/recipes/postgres.md +42 -20
- package/docs/recipes/s3.md +15 -10
- package/docs/recipes/supabase.md +18 -18
- package/docs/recipes/vercel-blob.md +18 -12
- package/package.json +6 -3
- package/dist/dialog-BTY6FKLo.mjs +0 -485
- package/dist/widget-CVnusNlY.mjs +0 -278
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# Recipe: GitHub Issues (private repo only)
|
|
2
2
|
|
|
3
|
-
Each report becomes an issue: the note, the page URL, the browser data and the
|
|
4
|
-
category as a label.
|
|
5
|
-
`feedback/<id>/
|
|
6
|
-
it
|
|
7
|
-
|
|
3
|
+
Each report becomes an issue: the note, the page URL, the browser data and the screenshots, with
|
|
4
|
+
the category as a label. Each screenshot's image is committed to the repo with GitHub's contents
|
|
5
|
+
API, at `feedback/<id>/screenshot-<n>.png`, numbered from 0, and shown inside the issue, with its
|
|
6
|
+
own page URL under it when it was taken on another page than the report's. Then the
|
|
7
|
+
whole report is committed beside them as `feedback/<id>/report.json`, with each screenshot's `image`
|
|
8
|
+
set to its GitHub link and `issue` to the issue number: a resend that finds it opens no second
|
|
9
|
+
issue. The route checks on every send that the repo
|
|
8
10
|
is private, and refuses otherwise. It uses `fetch` and GitHub's REST API, so it needs no package.
|
|
9
11
|
|
|
10
12
|
`report.context` comes from the browser (docs/API.md): the issue shows it as data, never as proof
|
|
@@ -50,7 +52,7 @@ Create a fine-grained personal access token for that one repo, with **Issues: re
|
|
|
50
52
|
|
|
51
53
|
Send PNG from the widget, the image type this recipe was checked with inside an issue:
|
|
52
54
|
`createFeedback({ endpoint: "/api/feedback", capture: { type: "image/png" } })`. Without it the
|
|
53
|
-
widget sends WebP (docs/API.md): the route commits and links `
|
|
55
|
+
widget sends WebP (docs/API.md): the route commits and links `screenshot-<n>.webp` the same way, but whether
|
|
54
56
|
the issue shows a WebP image inline is not checked.
|
|
55
57
|
|
|
56
58
|
## 3. The route
|
|
@@ -101,24 +103,46 @@ function fenced(text: string, max: number): string {
|
|
|
101
103
|
return `${fence}text\n${shown}\n${fence}`;
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
|
|
106
|
+
// A screenshot of another page than the report's names its own, as a draft can span routes.
|
|
107
|
+
function shots(report: FeedbackReport, imageUrls: string[], share: number): string {
|
|
108
|
+
return imageUrls
|
|
109
|
+
.map((url, i) => {
|
|
110
|
+
const page = report.screenshots[i]?.page.url ?? report.page.url;
|
|
111
|
+
const where = page === report.page.url ? "" : `\n${fenced(page, 2_000 * share)}`;
|
|
112
|
+
return `${where}`;
|
|
113
|
+
})
|
|
114
|
+
.join("\n\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function issueBody(report: FeedbackReport, imageUrls: string[]): string {
|
|
118
|
+
// A fence can be as long as its text, so a report of backticks can pass GitHub's limit at the
|
|
119
|
+
// usual cuts: they shrink until it fits. At a tenth, the longest report is far under it.
|
|
120
|
+
let body = "";
|
|
121
|
+
for (const share of [1, 0.5, 0.25, 0.1]) {
|
|
122
|
+
body = bodyAt(report, imageUrls, share);
|
|
123
|
+
if (body.length <= 65_536) break;
|
|
124
|
+
}
|
|
125
|
+
return body;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function bodyAt(report: FeedbackReport, imageUrls: string[], share: number): string {
|
|
105
129
|
const { page, viewport, userAgent, language, timeZone, createdAt, id } = report;
|
|
106
130
|
const screen = `${viewport.width}x${viewport.height} @${viewport.devicePixelRatio}x`;
|
|
107
131
|
// Each fence starts its own line: after other text on that line it is no block.
|
|
108
132
|
return [
|
|
109
|
-
fenced(report.note || "(no note)", 10_000),
|
|
133
|
+
fenced(report.note || "(no note)", 10_000 * share),
|
|
110
134
|
// The angle brackets keep a ( or ) in a branch name from ending the link.
|
|
111
|
-
|
|
112
|
-
`**Page:**\n${fenced(page.url, 2_000)}`,
|
|
113
|
-
`**Browser:**\n${fenced(`${userAgent}\n${screen}, ${language}, ${timeZone}`, 1_000)}`,
|
|
135
|
+
shots(report, imageUrls, share) || "No screenshot.",
|
|
136
|
+
`**Page:**\n${fenced(page.url, 2_000 * share)}`,
|
|
137
|
+
`**Browser:**\n${fenced(`${userAgent}\n${screen}, ${language}, ${timeZone}`, 1_000 * share)}`,
|
|
114
138
|
`**Sent:** ${createdAt}, report \`${id}\``,
|
|
115
|
-
`**Context, from the browser:**\n${fenced(JSON.stringify(report.context, null, 2), 6_000)}`,
|
|
139
|
+
`**Context, from the browser:**\n${fenced(JSON.stringify(report.context, null, 2), 6_000 * share)}`,
|
|
116
140
|
].join("\n\n");
|
|
117
141
|
}
|
|
118
142
|
|
|
119
143
|
export async function POST(request: Request): Promise<Response> {
|
|
120
144
|
try {
|
|
121
|
-
const { report
|
|
145
|
+
const { report } = await parseFeedbackRequest(request);
|
|
122
146
|
const api = github();
|
|
123
147
|
const repo = await api("GET", "");
|
|
124
148
|
// Checked on every send: a repo made public later must not receive reports.
|
|
@@ -151,24 +175,24 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
151
175
|
throw new Error(`GitHub answered ${commit.status} for ${name}.`);
|
|
152
176
|
}
|
|
153
177
|
};
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const name = `
|
|
178
|
+
const imageUrls: string[] = [];
|
|
179
|
+
for (const [i, { image }] of report.screenshots.entries()) {
|
|
180
|
+
const name = `screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
|
|
157
181
|
await put(name, new Uint8Array(await image.arrayBuffer()));
|
|
158
182
|
const path = `${branch.split("/").map(encodeURIComponent).join("/")}/${folder}/${name}`;
|
|
159
|
-
|
|
183
|
+
imageUrls.push(`https://github.com/${repo.json.full_name}/blob/${path}?raw=true`);
|
|
160
184
|
}
|
|
161
185
|
// GitHub refuses a label name over 50 characters, and one that is only space.
|
|
162
186
|
const category = cut(report.category?.replace(/\s+/g, " ").trim() ?? "", 49);
|
|
163
187
|
const title = cut(report.note.split("\n")[0]?.trim() || "Feedback", 80);
|
|
164
188
|
const issue = await api("POST", "/issues", {
|
|
165
189
|
title,
|
|
166
|
-
body: issueBody(report,
|
|
190
|
+
body: issueBody(report, imageUrls),
|
|
167
191
|
labels: ["feedback", ...(category ? [category] : [])],
|
|
168
192
|
});
|
|
169
193
|
if (issue.status !== 201) throw new Error(`GitHub answered ${issue.status} for the issue.`);
|
|
170
|
-
const
|
|
171
|
-
const json = JSON.stringify({ ...
|
|
194
|
+
const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: imageUrls[i] }));
|
|
195
|
+
const json = JSON.stringify({ ...report, screenshots, issue: issue.json.number }, null, 2);
|
|
172
196
|
await put("report.json", new TextEncoder().encode(json));
|
|
173
197
|
return new Response(null, { status: 204 });
|
|
174
198
|
} catch (error) {
|
package/docs/recipes/local.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
For local development only: it writes only when `NODE_ENV` is `development`. `next dev` sets it;
|
|
4
4
|
start any other server with `NODE_ENV=development`.
|
|
5
5
|
|
|
6
|
-
Each report becomes one folder, `.visual-feedback/<id>/`, holding `
|
|
7
|
-
`
|
|
8
|
-
|
|
6
|
+
Each report becomes one folder, `.visual-feedback/<id>/`, holding `report.json` and one image per
|
|
7
|
+
screenshot: `screenshot-0.png` or `screenshot-0.webp`, then `screenshot-1`, and so on. That file is
|
|
8
|
+
the `FeedbackReport` (docs/API.md), with each screenshot's `image` set to its file name.
|
|
9
9
|
|
|
10
10
|
It does not work on hosts whose disk is read-only or wiped on each deploy or restart, such as
|
|
11
11
|
Vercel, Netlify, Cloudflare and most containers. Use another recipe there.
|
|
@@ -31,7 +31,7 @@ interface unless it is started as `next dev -H 127.0.0.1`.
|
|
|
31
31
|
|
|
32
32
|
```ts
|
|
33
33
|
// app/api/feedback/route.ts
|
|
34
|
-
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
34
|
+
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
35
35
|
import { join } from "node:path";
|
|
36
36
|
import { FeedbackRequestError, parseFeedbackRequest } from "visual-feedback/server";
|
|
37
37
|
|
|
@@ -41,21 +41,25 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
41
41
|
return new Response("Local feedback storage works only in development.", { status: 503 });
|
|
42
42
|
}
|
|
43
43
|
try {
|
|
44
|
-
const { report
|
|
44
|
+
const { report } = await parseFeedbackRequest(request);
|
|
45
45
|
// report.id is a checked UUID, so it is safe as a folder name.
|
|
46
46
|
const folder = join(process.cwd(), ".visual-feedback", report.id);
|
|
47
|
-
const
|
|
47
|
+
const name = (image: Blob, i: number) =>
|
|
48
|
+
`screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
|
|
48
49
|
await mkdir(folder, { recursive: true });
|
|
49
|
-
// The
|
|
50
|
-
|
|
51
|
-
await writeFile(join(folder,
|
|
50
|
+
// The images first: a report.json on disk then always has its images beside it.
|
|
51
|
+
for (const [i, { image }] of report.screenshots.entries()) {
|
|
52
|
+
await writeFile(join(folder, name(image, i)), new Uint8Array(await image.arrayBuffer()));
|
|
53
|
+
}
|
|
54
|
+
const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
|
|
52
55
|
await writeFile(
|
|
53
56
|
join(folder, "report.json"),
|
|
54
|
-
JSON.stringify({ ...report,
|
|
57
|
+
JSON.stringify({ ...report, screenshots }, null, 2),
|
|
55
58
|
);
|
|
56
|
-
// After report.json, so a resend with
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
// After report.json, so a resend with other images, or fewer, leaves no stray image.
|
|
60
|
+
const kept = new Set(["report.json", ...screenshots.map((shot) => shot.image)]);
|
|
61
|
+
for (const old of await readdir(folder)) {
|
|
62
|
+
if (!kept.has(old)) await rm(join(folder, old), { force: true });
|
|
59
63
|
}
|
|
60
64
|
return new Response(null, { status: 204 });
|
|
61
65
|
} catch (error) {
|
package/docs/recipes/postgres.md
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
# Recipe: Postgres
|
|
2
2
|
|
|
3
|
-
Each report becomes one row
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Each report becomes one row in `feedback_reports`: the `FeedbackReport` (docs/API.md), its
|
|
4
|
+
screenshots without their images, as `jsonb`, and the user who sent it, taken from your own session.
|
|
5
|
+
Each screenshot's image becomes one row in `feedback_screenshots`, by its `position` in
|
|
6
|
+
`report.screenshots`: the image in `image` (`bytea`) and its type in `image_type`. `user_id` is the
|
|
7
|
+
proof of who sent a report; `report.context` is not (docs/API.md).
|
|
6
8
|
|
|
7
9
|
To keep images in a bucket instead (docs/recipes/s3.md):
|
|
8
10
|
|
|
9
11
|
1. Install `aws4fetch` and set the `S3_*` variables as that recipe's step 1 says.
|
|
10
12
|
2. Copy its `bucket()` function into this route.
|
|
11
|
-
3. Before the insert, PUT
|
|
12
|
-
recipe's `POST` does:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
3. Before the insert, PUT each image to `url(report.id, "screenshot-<position>.png")` or `.webp`,
|
|
14
|
+
as that recipe's `POST` does:
|
|
15
|
+
with `"if-none-match": "*"`, so a reused id never replaces a stored image, and throw on any
|
|
16
|
+
refusal but 412, so a row never lacks its image.
|
|
17
|
+
4. Insert `null` for `image` and keep `image_type`: `image/png` is the file
|
|
18
|
+
`screenshot-<position>.png`, and `image/webp` is `screenshot-<position>.webp`.
|
|
16
19
|
5. Read images back with that recipe's `GET`, its `canRead` replaced by the same admin check your
|
|
17
20
|
screens use.
|
|
18
21
|
|
|
@@ -30,9 +33,15 @@ create table feedback_reports (
|
|
|
30
33
|
id uuid primary key,
|
|
31
34
|
user_id text not null,
|
|
32
35
|
created_at timestamptz not null,
|
|
33
|
-
report jsonb not null
|
|
36
|
+
report jsonb not null
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
create table feedback_screenshots (
|
|
40
|
+
report_id uuid not null references feedback_reports (id) on delete cascade,
|
|
41
|
+
position integer not null,
|
|
34
42
|
image bytea,
|
|
35
|
-
image_type text
|
|
43
|
+
image_type text not null,
|
|
44
|
+
primary key (report_id, position)
|
|
36
45
|
);
|
|
37
46
|
```
|
|
38
47
|
|
|
@@ -70,17 +79,30 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
70
79
|
const user = await currentUser(request);
|
|
71
80
|
if (!user) return new Response(null, { status: 401 });
|
|
72
81
|
try {
|
|
73
|
-
const { report
|
|
74
|
-
const { image: _, ...
|
|
75
|
-
const bytes = image ? new Uint8Array(await image.arrayBuffer()) : null;
|
|
82
|
+
const { report } = await parseFeedbackRequest(request);
|
|
83
|
+
const screenshots = report.screenshots.map(({ image: _, ...shot }) => shot);
|
|
76
84
|
// The report was parsed from JSON, so it is JSON; context is only typed as unknown values.
|
|
77
|
-
const json = db().json(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
const json = db().json({ ...report, screenshots } as postgres.JSONValue);
|
|
86
|
+
const images = await Promise.all(
|
|
87
|
+
report.screenshots.map(async ({ image }, position) => ({
|
|
88
|
+
report_id: report.id,
|
|
89
|
+
position,
|
|
90
|
+
image: new Uint8Array(await image.arrayBuffer()),
|
|
91
|
+
image_type: image.type,
|
|
92
|
+
})),
|
|
93
|
+
);
|
|
94
|
+
// One transaction, so a report row never lacks its screenshots.
|
|
95
|
+
await db().begin(async (sql) => {
|
|
96
|
+
// A "Try again" sends the same id, so the rows it already wrote stay as they are.
|
|
97
|
+
const inserted = await sql`
|
|
98
|
+
insert into feedback_reports (id, user_id, created_at, report)
|
|
99
|
+
values (${report.id}, ${user}, ${report.createdAt}, ${json})
|
|
100
|
+
on conflict (id) do nothing
|
|
101
|
+
returning id`;
|
|
102
|
+
if (inserted.length && images.length) {
|
|
103
|
+
await sql`insert into feedback_screenshots ${sql(images)}`;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
84
106
|
return new Response(null, { status: 204 });
|
|
85
107
|
} catch (error) {
|
|
86
108
|
if (error instanceof FeedbackRequestError) {
|
package/docs/recipes/s3.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Recipe: an S3-compatible bucket (S3, R2, Railway Buckets)
|
|
2
2
|
|
|
3
|
-
Each report becomes
|
|
4
|
-
(docs/recipes/local.md): `feedback/<id>/report.json`,
|
|
5
|
-
`feedback/<id>/
|
|
3
|
+
Each report becomes objects in a private bucket, its report and one per screenshot, named and shaped as in the local recipe
|
|
4
|
+
(docs/recipes/local.md): `feedback/<id>/report.json`, where each screenshot's `image` is its file
|
|
5
|
+
name, and `feedback/<id>/screenshot-<n>.png` or `.webp` for each screenshot, numbered from 0.
|
|
6
6
|
|
|
7
7
|
The bucket stays private: nothing is public, and people read reports only through your own route,
|
|
8
8
|
behind your own admin check. `report.context` comes from the browser (docs/API.md), so an admin view
|
|
@@ -64,7 +64,7 @@ function bucket() {
|
|
|
64
64
|
|
|
65
65
|
export async function POST(request: Request): Promise<Response> {
|
|
66
66
|
try {
|
|
67
|
-
const { report
|
|
67
|
+
const { report } = await parseFeedbackRequest(request);
|
|
68
68
|
const { client, url } = bucket();
|
|
69
69
|
// A report already stored under this id stays: a retry, or a reused id, changes nothing.
|
|
70
70
|
const stored = await client.fetch(url(report.id, "report.json"), { method: "HEAD" });
|
|
@@ -73,11 +73,15 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
73
73
|
if (stored.status !== 403 && stored.status !== 404) {
|
|
74
74
|
throw new Error(`The bucket answered ${stored.status} for report.json.`);
|
|
75
75
|
}
|
|
76
|
-
const name = image
|
|
77
|
-
|
|
76
|
+
const name = (image: Blob, i: number) =>
|
|
77
|
+
`screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
|
|
78
|
+
const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
|
|
79
|
+
// The images first: a report.json in the bucket then always has its images beside it.
|
|
78
80
|
const puts: [string, BodyInit, string][] = [
|
|
79
|
-
...(
|
|
80
|
-
|
|
81
|
+
...report.screenshots.map(
|
|
82
|
+
({ image }, i): [string, BodyInit, string] => [name(image, i), image, image.type],
|
|
83
|
+
),
|
|
84
|
+
["report.json", JSON.stringify({ ...report, screenshots }), "application/json"],
|
|
81
85
|
];
|
|
82
86
|
for (const [file, body, type] of puts) {
|
|
83
87
|
const put = await client.fetch(url(report.id, file), {
|
|
@@ -99,14 +103,15 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
|
|
102
|
-
// GET /api/feedback?id=<report id>&file=report.json |
|
|
106
|
+
// GET /api/feedback?id=<report id>&file=report.json | screenshot-<n>.png | screenshot-<n>.webp
|
|
103
107
|
export async function GET(request: Request): Promise<Response> {
|
|
104
108
|
if (!(await canRead(request))) return new Response(null, { status: 403 });
|
|
105
109
|
const query = new URL(request.url).searchParams;
|
|
106
110
|
const id = query.get("id") ?? "";
|
|
107
111
|
const file = query.get("file") ?? "";
|
|
108
112
|
// Only a report id and a known file name, so the key cannot leave feedback/<id>/.
|
|
109
|
-
|
|
113
|
+
const known = file === "report.json" || /^screenshot-\d\.(png|webp)$/.test(file);
|
|
114
|
+
if (!/^[0-9a-f-]{36}$/.test(id) || !known) {
|
|
110
115
|
return new Response(null, { status: 400 });
|
|
111
116
|
}
|
|
112
117
|
const { client, url } = bucket();
|
package/docs/recipes/supabase.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# Recipe: Supabase
|
|
2
2
|
|
|
3
|
-
Each report becomes one row in a table and
|
|
4
|
-
|
|
5
|
-
(docs/recipes/postgres.md), except that `user_id` is the
|
|
6
|
-
Storage instead of
|
|
7
|
-
only your app's admins read either,
|
|
3
|
+
Each report becomes one row in a table and one image per screenshot in the private Storage bucket
|
|
4
|
+
`feedback`, named `<id>/screenshot-<n>.png` or `.webp`, numbered from 0. The row has the columns of
|
|
5
|
+
the Postgres recipe's `feedback_reports` (docs/recipes/postgres.md), except that `user_id` is the
|
|
6
|
+
Supabase user's uuid. The images are in Storage instead of a second table, and each screenshot's
|
|
7
|
+
`image` in `report` is its file name. Row-level security lets only your app's admins read either,
|
|
8
|
+
and nobody but your server can write.
|
|
8
9
|
|
|
9
10
|
`user_id` is the proof of who sent a report; `report.context` is not (docs/API.md). The images show
|
|
10
11
|
the screen as the user saw it, with nothing masked (SPEC §7).
|
|
@@ -26,8 +27,7 @@ create table feedback_reports (
|
|
|
26
27
|
id uuid primary key,
|
|
27
28
|
user_id uuid not null references auth.users (id) on delete cascade,
|
|
28
29
|
created_at timestamptz not null,
|
|
29
|
-
report jsonb not null
|
|
30
|
-
image_type text
|
|
30
|
+
report jsonb not null
|
|
31
31
|
);
|
|
32
32
|
alter table feedback_reports enable row level security;
|
|
33
33
|
grant select on feedback_reports to authenticated;
|
|
@@ -49,9 +49,9 @@ error, never a row, and a download from `feedback` is refused.
|
|
|
49
49
|
|
|
50
50
|
An admin screen reads with the admin's own session, never the service role key:
|
|
51
51
|
`from("feedback_reports").select()` for the rows, filtered and paged as the screen needs, and
|
|
52
|
-
`storage.from("feedback").createSignedUrl("<id
|
|
53
|
-
`
|
|
54
|
-
it, never share it.
|
|
52
|
+
`storage.from("feedback").createSignedUrl("<id>/<image>", 60)` for a screenshot, where `<image>` is
|
|
53
|
+
its `image` in `report`, such as `screenshot-0.png`. A signed URL works for anyone who holds it for
|
|
54
|
+
those 60 seconds: show it, never share it.
|
|
55
55
|
|
|
56
56
|
Set `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` on the server. The service role key bypasses
|
|
57
57
|
row-level security: never send it to the browser.
|
|
@@ -87,28 +87,28 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
87
87
|
const user = await currentUser(request);
|
|
88
88
|
if (!user) return new Response(null, { status: 401 });
|
|
89
89
|
try {
|
|
90
|
-
const { report
|
|
90
|
+
const { report } = await parseFeedbackRequest(request);
|
|
91
91
|
const db = supabase();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const name = (image: Blob, i: number) =>
|
|
93
|
+
`screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
|
|
94
|
+
const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
|
|
95
|
+
// The images first, so a row never lacks its images.
|
|
96
|
+
for (const [i, { image }] of report.screenshots.entries()) {
|
|
95
97
|
const upload = await db.storage
|
|
96
98
|
.from("feedback")
|
|
97
|
-
.upload(name, image, { contentType: image.type, upsert: false });
|
|
99
|
+
.upload(`${report.id}/${name(image, i)}`, image, { contentType: image.type, upsert: false });
|
|
98
100
|
// A resend finds its image there (409); the id comes from the browser, so never replace it.
|
|
99
101
|
const error = upload.error;
|
|
100
102
|
const stored = error && "statusCode" in error && error.statusCode === "409";
|
|
101
103
|
if (error && !stored) throw error;
|
|
102
104
|
}
|
|
103
|
-
const { image: _, ...stored } = report;
|
|
104
105
|
// A "Try again" sends the same id, so the row it already wrote stays as it is.
|
|
105
106
|
const insert = await db.from("feedback_reports").upsert(
|
|
106
107
|
{
|
|
107
108
|
id: report.id,
|
|
108
109
|
user_id: user,
|
|
109
110
|
created_at: report.createdAt,
|
|
110
|
-
report:
|
|
111
|
-
image_type: image?.type ?? null,
|
|
111
|
+
report: { ...report, screenshots },
|
|
112
112
|
},
|
|
113
113
|
{ onConflict: "id", ignoreDuplicates: true },
|
|
114
114
|
);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Recipe: Vercel Blob
|
|
2
2
|
|
|
3
3
|
Each report becomes private blobs, named and shaped as in the S3 recipe (docs/recipes/s3.md):
|
|
4
|
-
`feedback/<id>/report.json` and `feedback/<id>/
|
|
4
|
+
`feedback/<id>/report.json` and `feedback/<id>/screenshot-<n>.png` or `.webp` for each screenshot.
|
|
5
|
+
People read them only
|
|
5
6
|
through your own route, behind your own admin check. `report.context` comes from the browser
|
|
6
7
|
(docs/API.md), so an admin view must not show it as proof of who sent a report. The images show the
|
|
7
8
|
screen as the user saw it, with nothing masked (SPEC §7).
|
|
@@ -16,9 +17,9 @@ on the server. Never send it to the browser. Check it: open
|
|
|
16
17
|
`https://<store id>.private.blob.vercel-storage.com/feedback/<id>/report.json` without the token,
|
|
17
18
|
where the store id is the part after `vercel_blob_rw_` in the token, and Vercel refuses it.
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
(docs/API.md) keeps screenshots under
|
|
20
|
+
Vercel refuses a request body over 4.5 MB, all screenshots of a report together, with 413, and the
|
|
21
|
+
widget then shows its send error. The route takes images up to 3 MB each; a smaller
|
|
22
|
+
`capture.maxWidth` (docs/API.md) keeps several screenshots under the limit.
|
|
22
23
|
|
|
23
24
|
## 2. The route
|
|
24
25
|
|
|
@@ -72,8 +73,8 @@ function blob() {
|
|
|
72
73
|
|
|
73
74
|
export async function POST(request: Request): Promise<Response> {
|
|
74
75
|
try {
|
|
75
|
-
// Vercel refuses a
|
|
76
|
-
const { report
|
|
76
|
+
// Vercel refuses a request body over 4.5 MB, all screenshots together; this caps each image.
|
|
77
|
+
const { report } = await parseFeedbackRequest(request, { maxImageBytes: 3_000_000 });
|
|
77
78
|
const store = blob();
|
|
78
79
|
// The id comes from the browser: once a report is stored, a later request does not replace it.
|
|
79
80
|
// Two requests with one id at the same moment still can; the check and the write are separate.
|
|
@@ -84,10 +85,14 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
84
85
|
if (stored.status !== 404) {
|
|
85
86
|
throw new Error(`Vercel Blob answered ${stored.status} for report.json.`);
|
|
86
87
|
}
|
|
87
|
-
const name = image
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
const name = (image: Blob, i: number) =>
|
|
89
|
+
`screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
|
|
90
|
+
const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
|
|
91
|
+
// The images first: a report.json then always has its images beside it.
|
|
92
|
+
for (const [i, { image }] of report.screenshots.entries()) {
|
|
93
|
+
await store.put(report.id, name(image, i), image, image.type);
|
|
94
|
+
}
|
|
95
|
+
const json = JSON.stringify({ ...report, screenshots });
|
|
91
96
|
await store.put(report.id, "report.json", json, "application/json");
|
|
92
97
|
return new Response(null, { status: 204 });
|
|
93
98
|
} catch (error) {
|
|
@@ -98,14 +103,15 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
98
103
|
}
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
// GET /api/feedback?id=<report id>&file=report.json |
|
|
106
|
+
// GET /api/feedback?id=<report id>&file=report.json | screenshot-<n>.png | screenshot-<n>.webp
|
|
102
107
|
export async function GET(request: Request): Promise<Response> {
|
|
103
108
|
if (!(await canRead(request))) return new Response(null, { status: 403 });
|
|
104
109
|
const query = new URL(request.url).searchParams;
|
|
105
110
|
const id = query.get("id") ?? "";
|
|
106
111
|
const file = query.get("file") ?? "";
|
|
107
112
|
// Only a report id and a known file name, so the read cannot leave feedback/<id>/.
|
|
108
|
-
|
|
113
|
+
const known = file === "report.json" || /^screenshot-\d\.(png|webp)$/.test(file);
|
|
114
|
+
if (!/^[0-9a-f-]{36}$/.test(id) || !known) {
|
|
109
115
|
return new Response(null, { status: 400 });
|
|
110
116
|
}
|
|
111
117
|
const object = await blob().read(id, file);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "visual-feedback",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A small button lets your users screenshot the page, mark it up and write a note. You receive one report and store it where you want.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"feedback",
|
|
@@ -60,10 +60,13 @@
|
|
|
60
60
|
"test:pack": "playwright test -c tests/pack-smoke/playwright.config.ts",
|
|
61
61
|
"size": "size-limit",
|
|
62
62
|
"publint": "publint --strict",
|
|
63
|
-
"check": "
|
|
63
|
+
"check": "node tests/check.mjs",
|
|
64
|
+
"check:static": "npm run lint && npm run typecheck && npm run build && npm run test && npm run test:edge && npm run size && npm run publint",
|
|
65
|
+
"check:browser": "npm run test:e2e && npm run test:react && npm run test:pack",
|
|
64
66
|
"prepack": "npm run --silent build -- --logLevel warn",
|
|
65
67
|
"dev": "node tests/dev.mjs",
|
|
66
|
-
"demo": "node tests/demo.mjs"
|
|
68
|
+
"demo": "node tests/demo.mjs",
|
|
69
|
+
"release": "node tests/release.mjs"
|
|
67
70
|
},
|
|
68
71
|
"devDependencies": {
|
|
69
72
|
"@axe-core/playwright": "^4.13.0",
|