create-feedbax 0.2.0 → 0.2.2
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/README.md +17 -5
- package/dist/cli.js +2610 -291
- package/dist/index.js +2375 -200
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +418 -0
- package/template/README.md +97 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/feedbax-vite.ts +79 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +39 -34
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +9 -0
- package/template/src/comment-creation.test.tsx +95 -7
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +135 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +78 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +37 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +148 -0
- package/template/src/connected-identity.ts +127 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +244 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +3 -0
- package/template/src/feedback-runtime.ts +123 -13
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +73 -1
- package/template/src/feedbax/config/index.ts +205 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +225 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +114 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +350 -0
- package/template/src/feedbax/feedback/index.ts +226 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +103 -440
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.ts +35 -5
- package/template/src/portal-feedback-form.tsx +29 -6
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +19 -0
- package/template/src/portal-server-entry.ts +13 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +437 -271
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +70 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +87 -2
- package/template/src/submission-intent.ts +26 -0
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
- package/template/vite.config.ts +2 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEmailSender,
|
|
3
|
+
createParticipationModule,
|
|
4
|
+
type VerifiedParticipant,
|
|
5
|
+
} from "@feedbax/feedback";
|
|
6
|
+
import { env } from "cloudflare:workers";
|
|
7
|
+
import feedbax from "./feedbax";
|
|
8
|
+
import {
|
|
9
|
+
configuredImages,
|
|
10
|
+
createConfiguredContentFeedbackModule,
|
|
11
|
+
getConnectedStore,
|
|
12
|
+
} from "./feedback-runtime";
|
|
13
|
+
import {
|
|
14
|
+
readSessionToken,
|
|
15
|
+
sameOrigin,
|
|
16
|
+
sessionHash,
|
|
17
|
+
} from "./connected-identity";
|
|
18
|
+
|
|
19
|
+
type ParticipationModule = ReturnType<typeof createParticipationModule>;
|
|
20
|
+
const paths = new Set([
|
|
21
|
+
"follow",
|
|
22
|
+
"notifications",
|
|
23
|
+
"preferences",
|
|
24
|
+
"unsubscribe",
|
|
25
|
+
]);
|
|
26
|
+
export function createParticipationRequestHandler(options: {
|
|
27
|
+
siteUrl: string;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
getParticipant(request: Request): Promise<VerifiedParticipant | undefined>;
|
|
30
|
+
getModule(participant?: VerifiedParticipant): ParticipationModule;
|
|
31
|
+
rateLimit(request: Request): Promise<boolean>;
|
|
32
|
+
}) {
|
|
33
|
+
return async (request: Request): Promise<Response | undefined> => {
|
|
34
|
+
const url = new URL(request.url);
|
|
35
|
+
const route = url.pathname.replace("/internal/connected/", "");
|
|
36
|
+
if (!url.pathname.startsWith("/internal/connected/") || !paths.has(route))
|
|
37
|
+
return;
|
|
38
|
+
const json = (body: unknown, status = 200) =>
|
|
39
|
+
Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
|
|
40
|
+
if (!options.enabled) return json({ error: "Not found." }, 404);
|
|
41
|
+
if (!["GET", "POST"].includes(request.method))
|
|
42
|
+
return json({ error: "Method not allowed." }, 405);
|
|
43
|
+
if (
|
|
44
|
+
route !== "unsubscribe" &&
|
|
45
|
+
request.method !== "GET" &&
|
|
46
|
+
!sameOrigin(request, options.siteUrl)
|
|
47
|
+
)
|
|
48
|
+
return json({ error: "Request origin is invalid." }, 403);
|
|
49
|
+
try {
|
|
50
|
+
if (route === "unsubscribe") {
|
|
51
|
+
const token = url.searchParams.get("token") ?? "";
|
|
52
|
+
if (!/^[a-f0-9]{32}\.[A-Za-z0-9_-]{43}$/.test(token))
|
|
53
|
+
return json({ error: "Invalid unsubscribe link." }, 400);
|
|
54
|
+
if (request.method === "GET")
|
|
55
|
+
return new Response(null, {
|
|
56
|
+
status: 303,
|
|
57
|
+
headers: {
|
|
58
|
+
Location: `/email-preferences?token=${encodeURIComponent(token)}`,
|
|
59
|
+
"Cache-Control": "no-store",
|
|
60
|
+
"Referrer-Policy": "no-referrer",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
if (!(await options.rateLimit(request)))
|
|
64
|
+
return json({ error: "Try again shortly." }, 429);
|
|
65
|
+
await options.getModule().unsubscribe(token);
|
|
66
|
+
return new Response(
|
|
67
|
+
"Email notifications are turned off. You can turn them on again in Feedbax.",
|
|
68
|
+
{
|
|
69
|
+
headers: {
|
|
70
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
71
|
+
"Cache-Control": "no-store",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const participant = await options.getParticipant(request);
|
|
77
|
+
if (!participant)
|
|
78
|
+
return json(
|
|
79
|
+
{
|
|
80
|
+
error: "Sign in through the product to continue.",
|
|
81
|
+
code: "sign_in_required",
|
|
82
|
+
},
|
|
83
|
+
401,
|
|
84
|
+
);
|
|
85
|
+
const module = options.getModule(participant);
|
|
86
|
+
if (request.method === "GET") {
|
|
87
|
+
if (route === "follow")
|
|
88
|
+
return json(
|
|
89
|
+
await module.followState(url.searchParams.get("slug") ?? ""),
|
|
90
|
+
);
|
|
91
|
+
if (route === "notifications")
|
|
92
|
+
return json(
|
|
93
|
+
await module.inbox(url.searchParams.get("cursor") ?? undefined),
|
|
94
|
+
);
|
|
95
|
+
if (route === "preferences") return json(await module.preferences());
|
|
96
|
+
}
|
|
97
|
+
if (!(await options.rateLimit(request)))
|
|
98
|
+
return json({ error: "Too many changes. Try again shortly." }, 429);
|
|
99
|
+
if (Number(request.headers.get("Content-Length")) > 4096)
|
|
100
|
+
return json({ error: "Request is too large." }, 413);
|
|
101
|
+
const reader = request.body?.getReader();
|
|
102
|
+
const chunks: Uint8Array[] = [];
|
|
103
|
+
let length = 0;
|
|
104
|
+
if (reader)
|
|
105
|
+
while (true) {
|
|
106
|
+
const { done, value } = await reader.read();
|
|
107
|
+
if (done) break;
|
|
108
|
+
length += value.byteLength;
|
|
109
|
+
if (length > 4096) {
|
|
110
|
+
await reader.cancel();
|
|
111
|
+
return json({ error: "Request is too large." }, 413);
|
|
112
|
+
}
|
|
113
|
+
chunks.push(value);
|
|
114
|
+
}
|
|
115
|
+
const bytes = new Uint8Array(length);
|
|
116
|
+
let offset = 0;
|
|
117
|
+
for (const chunk of chunks) {
|
|
118
|
+
bytes.set(chunk, offset);
|
|
119
|
+
offset += chunk.byteLength;
|
|
120
|
+
}
|
|
121
|
+
const text = new TextDecoder().decode(bytes);
|
|
122
|
+
if (text.length > 4096)
|
|
123
|
+
return json({ error: "Request is too large." }, 413);
|
|
124
|
+
let body: Record<string, unknown>;
|
|
125
|
+
try {
|
|
126
|
+
body = JSON.parse(text);
|
|
127
|
+
} catch {
|
|
128
|
+
return json({ error: "Invalid request." }, 400);
|
|
129
|
+
}
|
|
130
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
131
|
+
return json({ error: "Invalid request." }, 400);
|
|
132
|
+
if (
|
|
133
|
+
route === "follow" &&
|
|
134
|
+
typeof body.slug === "string" &&
|
|
135
|
+
body.slug.length <= 512 &&
|
|
136
|
+
typeof body.following === "boolean"
|
|
137
|
+
)
|
|
138
|
+
return json(await module.follow(body.slug, body.following));
|
|
139
|
+
if (
|
|
140
|
+
route === "notifications" &&
|
|
141
|
+
(body.id === undefined ||
|
|
142
|
+
(typeof body.id === "string" && /^[a-f0-9]{32}$/.test(body.id)))
|
|
143
|
+
) {
|
|
144
|
+
await module.markRead(body.id as string | undefined);
|
|
145
|
+
return json({ updated: true });
|
|
146
|
+
}
|
|
147
|
+
if (route === "preferences" && typeof body.emailEnabled === "boolean") {
|
|
148
|
+
await module.setEmailPreference(body.emailEnabled);
|
|
149
|
+
return json(await module.preferences());
|
|
150
|
+
}
|
|
151
|
+
return json({ error: "Invalid request." }, 400);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const message = error instanceof Error ? error.message : "";
|
|
154
|
+
const known = [
|
|
155
|
+
"Post is unavailable.",
|
|
156
|
+
"Invalid notification cursor.",
|
|
157
|
+
"Invalid unsubscribe link.",
|
|
158
|
+
"Email notifications require a verified application email and a configured sender.",
|
|
159
|
+
];
|
|
160
|
+
return json(
|
|
161
|
+
{
|
|
162
|
+
error: known.includes(message)
|
|
163
|
+
? message
|
|
164
|
+
: "Updates are temporarily unavailable. Try again.",
|
|
165
|
+
},
|
|
166
|
+
message === "Post is unavailable."
|
|
167
|
+
? 404
|
|
168
|
+
: known.includes(message)
|
|
169
|
+
? 400
|
|
170
|
+
: 503,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function configuredParticipation(participant?: VerifiedParticipant) {
|
|
177
|
+
return createConfiguredParticipation(
|
|
178
|
+
createConfiguredContentFeedbackModule(),
|
|
179
|
+
participant,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function createConfiguredParticipation(
|
|
184
|
+
feedback: import("@feedbax/feedback").FeedbackModule,
|
|
185
|
+
participant?: VerifiedParticipant,
|
|
186
|
+
) {
|
|
187
|
+
if (!feedbax.connected || !env.FEEDBAX_DB)
|
|
188
|
+
throw new Error("Connected database is missing.");
|
|
189
|
+
const config = feedbax.connected.email;
|
|
190
|
+
let email: Parameters<typeof createParticipationModule>[0]["email"];
|
|
191
|
+
if (config) {
|
|
192
|
+
const sender =
|
|
193
|
+
config.provider === "cloudflare"
|
|
194
|
+
? createEmailSender({
|
|
195
|
+
provider: "cloudflare",
|
|
196
|
+
binding: requireBinding(env.FEEDBAX_EMAIL),
|
|
197
|
+
})
|
|
198
|
+
: config.provider === "resend"
|
|
199
|
+
? createEmailSender({
|
|
200
|
+
provider: "resend",
|
|
201
|
+
token: env.EMAIL_PROVIDER_SECRET ?? "",
|
|
202
|
+
})
|
|
203
|
+
: createEmailSender({
|
|
204
|
+
provider: "custom",
|
|
205
|
+
url: config.endpoint!,
|
|
206
|
+
token: env.EMAIL_PROVIDER_SECRET ?? "",
|
|
207
|
+
});
|
|
208
|
+
email = {
|
|
209
|
+
sender,
|
|
210
|
+
from: config.from,
|
|
211
|
+
siteUrl: feedbax.product.siteUrl!,
|
|
212
|
+
unsubscribeSecret: env.PARTICIPATION_SIGNING_SECRET ?? "",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return createParticipationModule({
|
|
216
|
+
database: env.FEEDBAX_DB,
|
|
217
|
+
feedback,
|
|
218
|
+
participant,
|
|
219
|
+
processing: feedbax.connected.processing ?? "small",
|
|
220
|
+
email,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const requireBinding = <T>(value: T | undefined): T => {
|
|
224
|
+
if (!value) throw new Error("Email binding is missing.");
|
|
225
|
+
return value;
|
|
226
|
+
};
|
|
227
|
+
export const handleParticipationRequest = createParticipationRequestHandler({
|
|
228
|
+
enabled: Boolean(feedbax.connected),
|
|
229
|
+
siteUrl: feedbax.product.siteUrl ?? "https://not-configured.invalid",
|
|
230
|
+
getParticipant: async (request) => {
|
|
231
|
+
const token = readSessionToken(request);
|
|
232
|
+
return token ? getConnectedStore().session(sessionHash(token)) : undefined;
|
|
233
|
+
},
|
|
234
|
+
getModule: configuredParticipation,
|
|
235
|
+
rateLimit: async (request) =>
|
|
236
|
+
(
|
|
237
|
+
await env.PORTAL_SUBMISSIONS_RATE_LIMITER.limit({
|
|
238
|
+
key: `updates:${request.headers.get("CF-Connecting-IP") ?? "unknown"}`,
|
|
239
|
+
})
|
|
240
|
+
).success,
|
|
241
|
+
});
|
|
242
|
+
export async function synchronizeConnectedUpdates() {
|
|
243
|
+
if (!feedbax.connected) return;
|
|
244
|
+
const content = createConfiguredContentFeedbackModule();
|
|
245
|
+
const module = createConfiguredParticipation(content);
|
|
246
|
+
const observation = await module.synchronize();
|
|
247
|
+
const email = await module.deliverEmails();
|
|
248
|
+
if (feedbax.connected.images) await configuredImages(content).cleanup();
|
|
249
|
+
// Counts only: never log identities, addresses, provider responses, or message bodies.
|
|
250
|
+
if (observation.failed || email.failed)
|
|
251
|
+
console.warn("Connected update processing will retry", {
|
|
252
|
+
observations: observation.failed,
|
|
253
|
+
deliveries: email.failed,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
@@ -26,6 +26,8 @@ export interface PortalCommentResult {
|
|
|
26
26
|
const commentCapabilityLifetime = 15 * 60 * 1_000;
|
|
27
27
|
|
|
28
28
|
export interface PortalCommentRequest {
|
|
29
|
+
submissionKey?: string;
|
|
30
|
+
imageIds?: string[];
|
|
29
31
|
slug: string;
|
|
30
32
|
body: string;
|
|
31
33
|
displayName: string;
|
|
@@ -36,6 +38,7 @@ export interface PortalCommentRequest {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export function createPortalCommentHandler(options: {
|
|
41
|
+
verifiedParticipant?: { displayName: string };
|
|
39
42
|
feedback: Pick<FeedbackModule, "createComment" | "replyToCommentThread">;
|
|
40
43
|
commentsEnabled: boolean;
|
|
41
44
|
rateLimiter: PortalSubmissionRateLimiter;
|
|
@@ -50,7 +53,7 @@ export function createPortalCommentHandler(options: {
|
|
|
50
53
|
"Comments are disabled for this Installation.",
|
|
51
54
|
"comments_disabled",
|
|
52
55
|
);
|
|
53
|
-
const value = validateCommentInput(input);
|
|
56
|
+
const value = validateCommentInput(input, options.verifiedParticipant);
|
|
54
57
|
const { success } = await options.rateLimiter.limit({
|
|
55
58
|
key: `comment:${options.rateLimitKey}`,
|
|
56
59
|
});
|
|
@@ -89,6 +92,8 @@ export function createPortalCommentHandler(options: {
|
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
const command = {
|
|
95
|
+
...(value.imageIds ? { imageIds: value.imageIds } : {}),
|
|
96
|
+
...(value.submissionKey ? { submissionKey: value.submissionKey } : {}),
|
|
92
97
|
slug: value.slug,
|
|
93
98
|
body: value.body,
|
|
94
99
|
displayName: value.displayName,
|
|
@@ -338,10 +343,16 @@ export function createPortalCommentMutationRequestHandler(
|
|
|
338
343
|
};
|
|
339
344
|
}
|
|
340
345
|
|
|
341
|
-
function validateCommentInput(
|
|
346
|
+
function validateCommentInput(
|
|
347
|
+
input: unknown,
|
|
348
|
+
participant?: { displayName: string },
|
|
349
|
+
): PortalCommentRequest {
|
|
342
350
|
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
343
351
|
throw invalidCommentRequest();
|
|
344
|
-
const value
|
|
352
|
+
const value: Record<string, unknown> = {
|
|
353
|
+
...(input as Record<string, unknown>),
|
|
354
|
+
...(participant ? { displayName: participant.displayName } : {}),
|
|
355
|
+
};
|
|
345
356
|
if (typeof value.slug !== "string" || !value.slug.trim())
|
|
346
357
|
throw invalidCommentRequest();
|
|
347
358
|
if (typeof value.body !== "string" || !value.body.trim())
|
|
@@ -354,16 +365,35 @@ function validateCommentInput(input: unknown): PortalCommentRequest {
|
|
|
354
365
|
"Device Profile display name is required before commenting.",
|
|
355
366
|
"profile_required",
|
|
356
367
|
);
|
|
357
|
-
if (!isValidCommentEmail(value.email))
|
|
368
|
+
if (!participant && !isValidCommentEmail(value.email))
|
|
358
369
|
throw new ActionablePortalFailure(
|
|
359
370
|
"A Device Profile with a valid email is required before commenting.",
|
|
360
371
|
"profile_required",
|
|
361
372
|
);
|
|
373
|
+
if (
|
|
374
|
+
value.imageIds !== undefined &&
|
|
375
|
+
(!Array.isArray(value.imageIds) ||
|
|
376
|
+
value.imageIds.length > 4 ||
|
|
377
|
+
value.imageIds.some(
|
|
378
|
+
(id) => typeof id !== "string" || !/^[-a-f0-9]{36}$/.test(id),
|
|
379
|
+
))
|
|
380
|
+
)
|
|
381
|
+
throw invalidCommentRequest();
|
|
382
|
+
if (
|
|
383
|
+
value.submissionKey !== undefined &&
|
|
384
|
+
(typeof value.submissionKey !== "string" ||
|
|
385
|
+
!/^[A-Za-z0-9_-]{43}$/.test(value.submissionKey))
|
|
386
|
+
)
|
|
387
|
+
throw invalidCommentRequest();
|
|
362
388
|
return {
|
|
389
|
+
...(typeof value.submissionKey === "string"
|
|
390
|
+
? { submissionKey: value.submissionKey }
|
|
391
|
+
: {}),
|
|
392
|
+
...(value.imageIds ? { imageIds: value.imageIds as string[] } : {}),
|
|
363
393
|
slug: value.slug.trim(),
|
|
364
394
|
body: value.body.trim(),
|
|
365
395
|
displayName: value.displayName.trim(),
|
|
366
|
-
email: value.email.trim(),
|
|
396
|
+
email: typeof value.email === "string" ? value.email.trim() : "",
|
|
367
397
|
...(typeof value.discussionId === "string" && value.discussionId
|
|
368
398
|
? { discussionId: value.discussionId }
|
|
369
399
|
: {}),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { submissionIntent } from "./submission-intent";
|
|
2
|
+
import { ImageAttachments } from "./components/image-attachments";
|
|
1
3
|
import { configuredTurnstileSiteKey } from "./turnstile-configuration";
|
|
2
4
|
import type { Post, PostType, SubmittedPost } from "@feedbax/feedback";
|
|
3
5
|
import { useForm } from "@tanstack/react-form";
|
|
@@ -120,6 +122,10 @@ export function PortalFeedbackForm({
|
|
|
120
122
|
}) {
|
|
121
123
|
const [draft, setDraft] = useState<StoredDraft | undefined>(initialDraft);
|
|
122
124
|
const [pending, setPending] = useState(false);
|
|
125
|
+
const [images, setImages] = useState<
|
|
126
|
+
import("@feedbax/feedback").ImageAttachment[]
|
|
127
|
+
>([]);
|
|
128
|
+
const [uploading, setUploading] = useState(false);
|
|
123
129
|
const [message, setMessage] = useState<string>();
|
|
124
130
|
const [confirmingWithdrawal, setConfirmingWithdrawal] = useState(false);
|
|
125
131
|
const turnstileToken = useRef<string | undefined>(undefined);
|
|
@@ -158,9 +164,18 @@ export function PortalFeedbackForm({
|
|
|
158
164
|
setPending(true);
|
|
159
165
|
setMessage(undefined);
|
|
160
166
|
try {
|
|
167
|
+
const intent = await submissionIntent(window.sessionStorage, {
|
|
168
|
+
...values,
|
|
169
|
+
imageIds: images.map((image) => image.id),
|
|
170
|
+
submitter: readDeviceProfile(window.localStorage),
|
|
171
|
+
});
|
|
161
172
|
const result = await mutations.submitPost({
|
|
162
173
|
data: {
|
|
163
174
|
...values,
|
|
175
|
+
submissionKey: intent.key,
|
|
176
|
+
...(images.length
|
|
177
|
+
? { imageIds: images.map((image) => image.id) }
|
|
178
|
+
: {}),
|
|
164
179
|
...(turnstileToken.current
|
|
165
180
|
? { turnstileToken: turnstileToken.current }
|
|
166
181
|
: {}),
|
|
@@ -170,9 +185,9 @@ export function PortalFeedbackForm({
|
|
|
170
185
|
const storedDraft: StoredDraft = {
|
|
171
186
|
id: result.id,
|
|
172
187
|
browserCapability: result.browserCapability,
|
|
173
|
-
title:
|
|
174
|
-
description:
|
|
175
|
-
type:
|
|
188
|
+
title: result.title,
|
|
189
|
+
description: result.description,
|
|
190
|
+
type: result.type,
|
|
176
191
|
};
|
|
177
192
|
setDraft(storedDraft);
|
|
178
193
|
try {
|
|
@@ -185,6 +200,7 @@ export function PortalFeedbackForm({
|
|
|
185
200
|
draftStorageKey,
|
|
186
201
|
JSON.stringify(storedDraft),
|
|
187
202
|
);
|
|
203
|
+
intent.complete();
|
|
188
204
|
} catch {
|
|
189
205
|
setMessage(
|
|
190
206
|
"Draft submitted, but this browser could not save access. Keep this page open to manage it.",
|
|
@@ -408,9 +424,16 @@ export function PortalFeedbackForm({
|
|
|
408
424
|
);
|
|
409
425
|
}}
|
|
410
426
|
/>
|
|
427
|
+
{!draft ? (
|
|
428
|
+
<ImageAttachments
|
|
429
|
+
value={images}
|
|
430
|
+
onChange={setImages}
|
|
431
|
+
onUploadingChange={setUploading}
|
|
432
|
+
/>
|
|
433
|
+
) : null}
|
|
411
434
|
</FieldGroup>
|
|
412
435
|
<div className="feedback-submit-actions">
|
|
413
|
-
<Button type="submit" disabled={pending}>
|
|
436
|
+
<Button type="submit" disabled={pending || uploading}>
|
|
414
437
|
{pending ? "Working…" : draft ? "Save changes" : "Create Post"}
|
|
415
438
|
</Button>
|
|
416
439
|
{display === "overlay" ? (
|
|
@@ -419,7 +442,7 @@ export function PortalFeedbackForm({
|
|
|
419
442
|
variant="ghost"
|
|
420
443
|
className="feedback-cancel"
|
|
421
444
|
onClick={onCancel}
|
|
422
|
-
disabled={pending}
|
|
445
|
+
disabled={pending || uploading}
|
|
423
446
|
>
|
|
424
447
|
Cancel
|
|
425
448
|
</Button>
|
|
@@ -430,7 +453,7 @@ export function PortalFeedbackForm({
|
|
|
430
453
|
variant="destructive"
|
|
431
454
|
className="feedback-withdraw"
|
|
432
455
|
onClick={() => setConfirmingWithdrawal(true)}
|
|
433
|
-
disabled={pending}
|
|
456
|
+
disabled={pending || uploading}
|
|
434
457
|
>
|
|
435
458
|
Withdraw draft
|
|
436
459
|
</Button>
|
|
@@ -10,6 +10,7 @@ interface PortalSubmissionEnvironment {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
interface PortalSubmissionSecurityOptions {
|
|
13
|
+
turnstile?: FeedbaxConfig["turnstile"];
|
|
13
14
|
environment: PortalSubmissionEnvironment;
|
|
14
15
|
rateLimitKey: string;
|
|
15
16
|
}
|
|
@@ -22,11 +23,13 @@ interface TurnstileWarningOptions {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function createPortalSubmissionSecurity({
|
|
26
|
+
turnstile = feedbax.turnstile,
|
|
25
27
|
environment,
|
|
26
28
|
rateLimitKey,
|
|
27
29
|
}: PortalSubmissionSecurityOptions) {
|
|
28
30
|
const turnstileSecretKey = configuredTurnstileSecret(
|
|
29
31
|
environment.TURNSTILE_SECRET_KEY,
|
|
32
|
+
turnstile,
|
|
30
33
|
);
|
|
31
34
|
|
|
32
35
|
return {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
-
import { getRequestIP } from "@tanstack/react-start/server";
|
|
2
|
+
import { getRequest, getRequestIP } from "@tanstack/react-start/server";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
requestParticipant,
|
|
5
6
|
createConfiguredFeedbackModule,
|
|
6
7
|
feedbackSubmissionLimits,
|
|
7
8
|
} from "./feedback-runtime";
|
|
@@ -24,9 +25,10 @@ export const submitPortalFeedback = createServerFn({ method: "POST" })
|
|
|
24
25
|
environment: env,
|
|
25
26
|
nodeEnv: import.meta.env.PROD ? "production" : "development",
|
|
26
27
|
});
|
|
28
|
+
const participant = await requestParticipant(getRequest());
|
|
27
29
|
return runSafePortalMutation(() =>
|
|
28
30
|
createPortalFeedbackSubmission({
|
|
29
|
-
feedback: createConfiguredFeedbackModule(),
|
|
31
|
+
feedback: createConfiguredFeedbackModule(participant),
|
|
30
32
|
limits: feedbackSubmissionLimits,
|
|
31
33
|
...createPortalSubmissionSecurity({
|
|
32
34
|
environment: env,
|
|
@@ -40,9 +42,10 @@ export const submitPortalPost = createServerFn({ method: "POST" })
|
|
|
40
42
|
.validator((input: unknown) => input)
|
|
41
43
|
.handler(async ({ data }) => {
|
|
42
44
|
const { env } = await import("cloudflare:workers");
|
|
45
|
+
const participant = await requestParticipant(getRequest());
|
|
43
46
|
return runSafePortalMutation(() =>
|
|
44
47
|
createPortalPostSubmission({
|
|
45
|
-
feedback: createConfiguredFeedbackModule(),
|
|
48
|
+
feedback: createConfiguredFeedbackModule(participant),
|
|
46
49
|
limits: feedbackSubmissionLimits,
|
|
47
50
|
...createPortalSubmissionSecurity({
|
|
48
51
|
environment: env,
|
|
@@ -147,8 +147,27 @@ function validateInput(
|
|
|
147
147
|
throw invalidSubmission();
|
|
148
148
|
}
|
|
149
149
|
if (!isValidSubmitter(value.submitter)) throw invalidSubmission();
|
|
150
|
+
if (
|
|
151
|
+
value.imageIds !== undefined &&
|
|
152
|
+
(!Array.isArray(value.imageIds) ||
|
|
153
|
+
value.imageIds.length > 4 ||
|
|
154
|
+
value.imageIds.some(
|
|
155
|
+
(id) => typeof id !== "string" || !/^[-a-f0-9]{36}$/.test(id),
|
|
156
|
+
))
|
|
157
|
+
)
|
|
158
|
+
throw invalidSubmission();
|
|
150
159
|
|
|
160
|
+
if (
|
|
161
|
+
value.submissionKey !== undefined &&
|
|
162
|
+
(typeof value.submissionKey !== "string" ||
|
|
163
|
+
!/^[A-Za-z0-9_-]{43}$/.test(value.submissionKey))
|
|
164
|
+
)
|
|
165
|
+
throw invalidSubmission();
|
|
151
166
|
return {
|
|
167
|
+
...(value.submissionKey
|
|
168
|
+
? { submissionKey: value.submissionKey as string }
|
|
169
|
+
: {}),
|
|
170
|
+
...(value.imageIds ? { imageIds: value.imageIds as string[] } : {}),
|
|
152
171
|
title: value.title,
|
|
153
172
|
description: value.description,
|
|
154
173
|
type: value.type,
|
|
@@ -8,21 +8,27 @@ import {
|
|
|
8
8
|
} from "./public-cache-policy";
|
|
9
9
|
|
|
10
10
|
export function createPortalServerEntry({
|
|
11
|
+
connectedHandler,
|
|
11
12
|
trustedFeedbackHandler,
|
|
12
13
|
voteHandler,
|
|
13
14
|
commentHandler,
|
|
14
15
|
commentMutationHandler,
|
|
15
16
|
applicationHandler = startHandler.fetch,
|
|
17
|
+
cachePublicReads = true,
|
|
16
18
|
}: {
|
|
19
|
+
connectedHandler?: (request: Request) => Promise<Response | undefined>;
|
|
17
20
|
trustedFeedbackHandler: (request: Request) => Promise<Response>;
|
|
18
21
|
voteHandler?: (request: Request) => Promise<Response>;
|
|
19
22
|
commentHandler?: (request: Request) => Promise<Response>;
|
|
20
23
|
commentMutationHandler?: (request: Request) => Promise<Response>;
|
|
21
24
|
applicationHandler?: typeof startHandler.fetch;
|
|
25
|
+
cachePublicReads?: boolean;
|
|
22
26
|
}) {
|
|
23
27
|
return createServerEntry({
|
|
24
28
|
async fetch(request, options) {
|
|
25
29
|
const url = new URL(request.url);
|
|
30
|
+
const connectedResponse = await connectedHandler?.(request);
|
|
31
|
+
if (connectedResponse) return connectedResponse;
|
|
26
32
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
27
33
|
return Response.json(
|
|
28
34
|
{ status: "ok" },
|
|
@@ -58,6 +64,13 @@ export function createPortalServerEntry({
|
|
|
58
64
|
return Response.redirect(canonicalRequest.url, 308);
|
|
59
65
|
}
|
|
60
66
|
const response = await applicationHandler(request, options);
|
|
67
|
+
if (!cachePublicReads) {
|
|
68
|
+
const privateResponse = new Response(response.body, response);
|
|
69
|
+
privateResponse.headers.set("Cache-Control", "no-store");
|
|
70
|
+
privateResponse.headers.delete("Cloudflare-CDN-Cache-Control");
|
|
71
|
+
privateResponse.headers.delete("Cache-Tag");
|
|
72
|
+
return privateResponse;
|
|
73
|
+
}
|
|
61
74
|
return applyPublicCachePolicy(request, response);
|
|
62
75
|
},
|
|
63
76
|
});
|
|
@@ -65,11 +65,11 @@ export function PostCreationFlow({
|
|
|
65
65
|
export function PostCreationSkeleton() {
|
|
66
66
|
return (
|
|
67
67
|
<main
|
|
68
|
-
className="feedback-index
|
|
68
|
+
className="feedback-index"
|
|
69
69
|
aria-label="Loading Post creation"
|
|
70
70
|
aria-busy="true"
|
|
71
71
|
>
|
|
72
|
-
<section className="feedback-submit
|
|
72
|
+
<section className="feedback-submit" aria-hidden="true">
|
|
73
73
|
<div className="feedback-submit-copy">
|
|
74
74
|
<Skeleton className="creation-skeleton-eyebrow" />
|
|
75
75
|
<Skeleton className="creation-skeleton-heading" />
|