create-feedbax 0.2.1 → 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 +502 -209
- package/dist/index.js +251 -68
- 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 +1 -1
- package/template/CONNECTED.md +16 -2
- package/template/README.md +52 -13
- package/template/feedbax-vite.ts +79 -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 +34 -33
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +3 -0
- package/template/src/comment-creation.test.tsx +53 -6
- package/template/src/components/connected-identity-provider.tsx +44 -17
- package/template/src/components/follow-post-button.tsx +2 -13
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +7 -0
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +7 -3
- package/template/src/connected-identity.ts +6 -1
- package/template/src/connected-runtime.test.ts +5 -2
- package/template/src/feedback-cache-invalidation.ts +2 -0
- package/template/src/feedback-runtime.ts +61 -18
- 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 +45 -1
- package/template/src/feedbax/config/index.ts +112 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +41 -12
- package/template/src/feedbax/feedback/connected.ts +15 -5
- 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/image-feedback.ts +23 -10
- package/template/src/feedbax/feedback/images.ts +26 -0
- package/template/src/feedbax/feedback/index.ts +172 -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/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/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/index.css +6 -0
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- 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/participation-runtime.ts +3 -3
- package/template/src/portal-comments.ts +11 -0
- package/template/src/portal-feedback-form.tsx +11 -3
- package/template/src/portal-feedback-submission.ts +9 -0
- package/template/src/portal-server-entry.ts +9 -0
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail.tsx +332 -256
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/routeTree.gen.ts +21 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/server.ts +46 -1
- package/template/src/submission-intent.ts +26 -0
- package/template/vite.config.ts +2 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { createFeedbackModule } from "@feedbax/feedback";
|
|
3
|
+
import {
|
|
4
|
+
createChangelogModule,
|
|
5
|
+
createInMemoryChangelogStorage,
|
|
6
|
+
} from "@feedbax/changelog";
|
|
7
|
+
import { createPublicReleaseFeedbackModule } from "./public-release-links";
|
|
8
|
+
|
|
9
|
+
it("links only published local Changelog Entries or safe explicit external release notes", async () => {
|
|
10
|
+
const entry = {
|
|
11
|
+
id: "entry",
|
|
12
|
+
slug: "released-export",
|
|
13
|
+
title: "Export",
|
|
14
|
+
summary: "Public",
|
|
15
|
+
body: "Public",
|
|
16
|
+
date: "2026-10-15" as const,
|
|
17
|
+
labels: [],
|
|
18
|
+
images: [],
|
|
19
|
+
published: true,
|
|
20
|
+
createdAt: new Date(1000),
|
|
21
|
+
updatedAt: new Date(1000),
|
|
22
|
+
};
|
|
23
|
+
const changelog = createChangelogModule({
|
|
24
|
+
storage: createInMemoryChangelogStorage([
|
|
25
|
+
entry,
|
|
26
|
+
{ ...entry, id: "draft", slug: "private-release", published: false },
|
|
27
|
+
]),
|
|
28
|
+
});
|
|
29
|
+
const post = {
|
|
30
|
+
id: "p",
|
|
31
|
+
slug: "export",
|
|
32
|
+
title: "Export",
|
|
33
|
+
description: "Public",
|
|
34
|
+
type: "Feature Request" as const,
|
|
35
|
+
status: "Shipped" as const,
|
|
36
|
+
published: true,
|
|
37
|
+
createdAt: new Date(1000),
|
|
38
|
+
updatedAt: new Date(1000),
|
|
39
|
+
};
|
|
40
|
+
const module = (url: string) =>
|
|
41
|
+
createPublicReleaseFeedbackModule({
|
|
42
|
+
feedback: createFeedbackModule({
|
|
43
|
+
initialItems: [{ ...post, releaseNotesUrl: url }],
|
|
44
|
+
}),
|
|
45
|
+
siteUrl: "https://portal.example",
|
|
46
|
+
changelog,
|
|
47
|
+
});
|
|
48
|
+
expect(
|
|
49
|
+
(await module("/changelog#released-export").getPublicPost(post.slug))
|
|
50
|
+
?.releaseNotesUrl,
|
|
51
|
+
).toBe("/changelog#released-export");
|
|
52
|
+
for (const url of [
|
|
53
|
+
"/changelog#private-release",
|
|
54
|
+
"/internal/private",
|
|
55
|
+
"//evil.example",
|
|
56
|
+
"https://user:password@release.example",
|
|
57
|
+
"javascript:alert(1)",
|
|
58
|
+
"https://portal.example/changelog#private-release",
|
|
59
|
+
"https://github.com/private-team/private-repo/issues/1",
|
|
60
|
+
"https://github.com/private-team/private-repo/pull/2",
|
|
61
|
+
"https://github.com/private-team/private-repo/%69ssues/1",
|
|
62
|
+
"https://github.com./private-team/private-repo/issues/1",
|
|
63
|
+
])
|
|
64
|
+
expect(
|
|
65
|
+
(await module(url).getPublicPost(post.slug))?.releaseNotesUrl,
|
|
66
|
+
).toBeUndefined();
|
|
67
|
+
expect(
|
|
68
|
+
(
|
|
69
|
+
await module("https://example.com/releases/export").getPublicPost(
|
|
70
|
+
post.slug,
|
|
71
|
+
)
|
|
72
|
+
)?.releaseNotesUrl,
|
|
73
|
+
).toBe("https://example.com/releases/export");
|
|
74
|
+
expect(
|
|
75
|
+
(await module("/changelog#private-release").listPublicPosts()).items[0]
|
|
76
|
+
?.releaseNotesUrl,
|
|
77
|
+
).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("checks current public visibility before exposing a GitHub release URL", async () => {
|
|
81
|
+
const post = {
|
|
82
|
+
id: "p",
|
|
83
|
+
slug: "export",
|
|
84
|
+
title: "Export",
|
|
85
|
+
description: "Public",
|
|
86
|
+
type: "Feature Request" as const,
|
|
87
|
+
status: "Shipped" as const,
|
|
88
|
+
published: true,
|
|
89
|
+
createdAt: new Date(1000),
|
|
90
|
+
updatedAt: new Date(1000),
|
|
91
|
+
};
|
|
92
|
+
const module = (owner: string) =>
|
|
93
|
+
createPublicReleaseFeedbackModule({
|
|
94
|
+
feedback: createFeedbackModule({
|
|
95
|
+
initialItems: [
|
|
96
|
+
{
|
|
97
|
+
...post,
|
|
98
|
+
releaseNotesUrl: `https://github.com/${owner}/feedback/releases/tag/v1`,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
}),
|
|
102
|
+
siteUrl: "https://portal.example",
|
|
103
|
+
request: async (input, init) => {
|
|
104
|
+
expect(String(input)).toBe(
|
|
105
|
+
`https://api.github.com/repos/${owner}/feedback`,
|
|
106
|
+
);
|
|
107
|
+
expect(init?.redirect).toBe("manual");
|
|
108
|
+
expect(new Headers(init?.headers).has("Authorization")).toBe(false);
|
|
109
|
+
return owner === "public-team"
|
|
110
|
+
? Response.json({ id: 123, private: false })
|
|
111
|
+
: new Response(null, { status: 404 });
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
expect(
|
|
115
|
+
(await module("public-team").getPublicPost(post.slug))?.releaseNotesUrl,
|
|
116
|
+
).toBe("https://github.com/public-team/feedback/releases/tag/v1");
|
|
117
|
+
expect(
|
|
118
|
+
(await module("private-team").getPublicPost(post.slug))?.releaseNotesUrl,
|
|
119
|
+
).toBeUndefined();
|
|
120
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { FeedbackModule, PublicPost } from "@feedbax/feedback";
|
|
2
|
+
import type { ChangelogModule } from "@feedbax/changelog";
|
|
3
|
+
|
|
4
|
+
export function createPublicReleaseFeedbackModule(options: {
|
|
5
|
+
feedback: FeedbackModule;
|
|
6
|
+
siteUrl?: string;
|
|
7
|
+
changelog?: ChangelogModule;
|
|
8
|
+
request?: typeof fetch;
|
|
9
|
+
}): FeedbackModule {
|
|
10
|
+
const { feedback } = options;
|
|
11
|
+
let published: Promise<Set<string>> | undefined;
|
|
12
|
+
const publishedSlugs = () =>
|
|
13
|
+
(published ??= (async () => {
|
|
14
|
+
const slugs = new Set<string>();
|
|
15
|
+
if (!options.changelog) return slugs;
|
|
16
|
+
let cursor: string | undefined;
|
|
17
|
+
for (let page = 0; page < 10; page++) {
|
|
18
|
+
const result = await options.changelog.listPublishedEntries({ cursor });
|
|
19
|
+
for (const entry of result.items) slugs.add(entry.slug);
|
|
20
|
+
cursor = result.nextCursor;
|
|
21
|
+
if (!cursor) break;
|
|
22
|
+
}
|
|
23
|
+
return slugs;
|
|
24
|
+
})());
|
|
25
|
+
const resolve = async (value: string) => {
|
|
26
|
+
if (
|
|
27
|
+
/[\\\s]/u.test(value) ||
|
|
28
|
+
Array.from(value).some(
|
|
29
|
+
(character) =>
|
|
30
|
+
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
const url = new URL(value, options.siteUrl ?? "https://feedbax.invalid");
|
|
36
|
+
if (
|
|
37
|
+
url.protocol !== "https:" ||
|
|
38
|
+
url.username ||
|
|
39
|
+
url.password ||
|
|
40
|
+
value.startsWith("//")
|
|
41
|
+
)
|
|
42
|
+
return;
|
|
43
|
+
const local =
|
|
44
|
+
value.startsWith("/") ||
|
|
45
|
+
(options.siteUrl && url.origin === new URL(options.siteUrl).origin);
|
|
46
|
+
if (!local) {
|
|
47
|
+
const host = url.hostname.toLowerCase().replace(/\.+$/, "");
|
|
48
|
+
if (
|
|
49
|
+
[
|
|
50
|
+
"api.github.com",
|
|
51
|
+
"gist.github.com",
|
|
52
|
+
"raw.githubusercontent.com",
|
|
53
|
+
].includes(host)
|
|
54
|
+
)
|
|
55
|
+
return;
|
|
56
|
+
if (host === "github.com" || host === "www.github.com") {
|
|
57
|
+
const path = decodeURIComponent(url.pathname);
|
|
58
|
+
const release = path.match(
|
|
59
|
+
/^\/([A-Za-z0-9-]+)\/([A-Za-z0-9_.-]+)\/releases(?:\/|$)/,
|
|
60
|
+
);
|
|
61
|
+
if (!release || path.includes("%")) return;
|
|
62
|
+
const response = await (options.request ?? fetch)(
|
|
63
|
+
`https://api.github.com/repos/${release[1]}/${release[2]}`,
|
|
64
|
+
{
|
|
65
|
+
redirect: "manual",
|
|
66
|
+
signal: AbortSignal.timeout(5000),
|
|
67
|
+
headers: {
|
|
68
|
+
Accept: "application/vnd.github+json",
|
|
69
|
+
"User-Agent": "Feedbax",
|
|
70
|
+
"X-GitHub-Api-Version": "2026-03-10",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
if (!response.ok) return;
|
|
75
|
+
const repository: unknown = await response.json();
|
|
76
|
+
if (
|
|
77
|
+
!repository ||
|
|
78
|
+
typeof repository !== "object" ||
|
|
79
|
+
Reflect.get(repository, "private") !== false
|
|
80
|
+
)
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
if (
|
|
86
|
+
url.pathname !== "/changelog" ||
|
|
87
|
+
url.search ||
|
|
88
|
+
!/^#[a-z0-9]+(?:-[a-z0-9]+)*$/.test(url.hash)
|
|
89
|
+
)
|
|
90
|
+
return;
|
|
91
|
+
return (await publishedSlugs()).has(url.hash.slice(1))
|
|
92
|
+
? value
|
|
93
|
+
: undefined;
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const project = async <T extends PublicPost>(post: T): Promise<T> => {
|
|
99
|
+
if (!post.releaseNotesUrl) return post;
|
|
100
|
+
const url = await resolve(post.releaseNotesUrl);
|
|
101
|
+
const result = { ...post };
|
|
102
|
+
if (url) result.releaseNotesUrl = url;
|
|
103
|
+
else delete result.releaseNotesUrl;
|
|
104
|
+
return result;
|
|
105
|
+
};
|
|
106
|
+
const roadmap: FeedbackModule["listPublicRoadmapPosts"] = async (query) => {
|
|
107
|
+
const page = await feedback.listPublicRoadmapPosts(query);
|
|
108
|
+
return { ...page, items: await Promise.all(page.items.map(project)) };
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
...feedback,
|
|
112
|
+
async getPublicPost(slug) {
|
|
113
|
+
const post = await feedback.getPublicPost(slug);
|
|
114
|
+
return post ? project(post) : undefined;
|
|
115
|
+
},
|
|
116
|
+
async listPublicPosts(query) {
|
|
117
|
+
const page = await feedback.listPublicPosts(query);
|
|
118
|
+
return { ...page, items: await Promise.all(page.items.map(project)) };
|
|
119
|
+
},
|
|
120
|
+
listPublicRoadmapPosts: roadmap,
|
|
121
|
+
async getPublicPostRoadmap() {
|
|
122
|
+
const [planned, progress, shipped] = await Promise.all([
|
|
123
|
+
roadmap({ status: "Planned" }),
|
|
124
|
+
roadmap({ status: "In Progress" }),
|
|
125
|
+
roadmap({ status: "Shipped" }),
|
|
126
|
+
]);
|
|
127
|
+
return { Planned: planned, "In Progress": progress, Shipped: shipped };
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|
|
12
12
|
import { Route as IndexRouteImport } from './routes/index'
|
|
13
13
|
import { Route as ChangelogRouteImport } from './routes/changelog'
|
|
14
14
|
import { Route as EmailPreferencesRouteImport } from './routes/email-preferences'
|
|
15
|
+
import { Route as MyFeedbackRouteImport } from './routes/my-feedback'
|
|
15
16
|
import { Route as RoadmapRouteImport } from './routes/roadmap'
|
|
16
17
|
import { Route as SubmitRouteImport } from './routes/submit'
|
|
17
18
|
import { Route as PSlugRouteImport } from './routes/p.$slug'
|
|
@@ -31,6 +32,11 @@ const EmailPreferencesRoute = EmailPreferencesRouteImport.update({
|
|
|
31
32
|
path: '/email-preferences',
|
|
32
33
|
getParentRoute: () => rootRouteImport,
|
|
33
34
|
} as any)
|
|
35
|
+
const MyFeedbackRoute = MyFeedbackRouteImport.update({
|
|
36
|
+
id: '/my-feedback',
|
|
37
|
+
path: '/my-feedback',
|
|
38
|
+
getParentRoute: () => rootRouteImport,
|
|
39
|
+
} as any)
|
|
34
40
|
const RoadmapRoute = RoadmapRouteImport.update({
|
|
35
41
|
id: '/roadmap',
|
|
36
42
|
path: '/roadmap',
|
|
@@ -51,6 +57,7 @@ export interface FileRoutesByFullPath {
|
|
|
51
57
|
'/': typeof IndexRoute
|
|
52
58
|
'/changelog': typeof ChangelogRoute
|
|
53
59
|
'/email-preferences': typeof EmailPreferencesRoute
|
|
60
|
+
'/my-feedback': typeof MyFeedbackRoute
|
|
54
61
|
'/roadmap': typeof RoadmapRoute
|
|
55
62
|
'/submit': typeof SubmitRoute
|
|
56
63
|
'/p/$slug': typeof PSlugRoute
|
|
@@ -59,6 +66,7 @@ export interface FileRoutesByTo {
|
|
|
59
66
|
'/': typeof IndexRoute
|
|
60
67
|
'/changelog': typeof ChangelogRoute
|
|
61
68
|
'/email-preferences': typeof EmailPreferencesRoute
|
|
69
|
+
'/my-feedback': typeof MyFeedbackRoute
|
|
62
70
|
'/roadmap': typeof RoadmapRoute
|
|
63
71
|
'/submit': typeof SubmitRoute
|
|
64
72
|
'/p/$slug': typeof PSlugRoute
|
|
@@ -68,6 +76,7 @@ export interface FileRoutesById {
|
|
|
68
76
|
'/': typeof IndexRoute
|
|
69
77
|
'/changelog': typeof ChangelogRoute
|
|
70
78
|
'/email-preferences': typeof EmailPreferencesRoute
|
|
79
|
+
'/my-feedback': typeof MyFeedbackRoute
|
|
71
80
|
'/roadmap': typeof RoadmapRoute
|
|
72
81
|
'/submit': typeof SubmitRoute
|
|
73
82
|
'/p/$slug': typeof PSlugRoute
|
|
@@ -78,6 +87,7 @@ export interface FileRouteTypes {
|
|
|
78
87
|
| '/'
|
|
79
88
|
| '/changelog'
|
|
80
89
|
| '/email-preferences'
|
|
90
|
+
| '/my-feedback'
|
|
81
91
|
| '/roadmap'
|
|
82
92
|
| '/submit'
|
|
83
93
|
| '/p/$slug'
|
|
@@ -86,6 +96,7 @@ export interface FileRouteTypes {
|
|
|
86
96
|
| '/'
|
|
87
97
|
| '/changelog'
|
|
88
98
|
| '/email-preferences'
|
|
99
|
+
| '/my-feedback'
|
|
89
100
|
| '/roadmap'
|
|
90
101
|
| '/submit'
|
|
91
102
|
| '/p/$slug'
|
|
@@ -94,6 +105,7 @@ export interface FileRouteTypes {
|
|
|
94
105
|
| '/'
|
|
95
106
|
| '/changelog'
|
|
96
107
|
| '/email-preferences'
|
|
108
|
+
| '/my-feedback'
|
|
97
109
|
| '/roadmap'
|
|
98
110
|
| '/submit'
|
|
99
111
|
| '/p/$slug'
|
|
@@ -103,6 +115,7 @@ export interface RootRouteChildren {
|
|
|
103
115
|
IndexRoute: typeof IndexRoute
|
|
104
116
|
ChangelogRoute: typeof ChangelogRoute
|
|
105
117
|
EmailPreferencesRoute: typeof EmailPreferencesRoute
|
|
118
|
+
MyFeedbackRoute: typeof MyFeedbackRoute
|
|
106
119
|
RoadmapRoute: typeof RoadmapRoute
|
|
107
120
|
SubmitRoute: typeof SubmitRoute
|
|
108
121
|
PSlugRoute: typeof PSlugRoute
|
|
@@ -131,6 +144,13 @@ declare module '@tanstack/react-router' {
|
|
|
131
144
|
preLoaderRoute: typeof EmailPreferencesRouteImport
|
|
132
145
|
parentRoute: typeof rootRouteImport
|
|
133
146
|
}
|
|
147
|
+
'/my-feedback': {
|
|
148
|
+
id: '/my-feedback'
|
|
149
|
+
path: '/my-feedback'
|
|
150
|
+
fullPath: '/my-feedback'
|
|
151
|
+
preLoaderRoute: typeof MyFeedbackRouteImport
|
|
152
|
+
parentRoute: typeof rootRouteImport
|
|
153
|
+
}
|
|
134
154
|
'/roadmap': {
|
|
135
155
|
id: '/roadmap'
|
|
136
156
|
path: '/roadmap'
|
|
@@ -159,6 +179,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|
|
159
179
|
IndexRoute: IndexRoute,
|
|
160
180
|
ChangelogRoute: ChangelogRoute,
|
|
161
181
|
EmailPreferencesRoute: EmailPreferencesRoute,
|
|
182
|
+
MyFeedbackRoute: MyFeedbackRoute,
|
|
162
183
|
RoadmapRoute: RoadmapRoute,
|
|
163
184
|
SubmitRoute: SubmitRoute,
|
|
164
185
|
PSlugRoute: PSlugRoute,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createFileRoute, notFound } from "@tanstack/react-router";
|
|
2
|
+
import feedbax from "../feedbax";
|
|
3
|
+
import { MyFeedbackPage } from "../my-feedback-page";
|
|
4
|
+
export const Route = createFileRoute("/my-feedback")({
|
|
5
|
+
beforeLoad: () => {
|
|
6
|
+
if (!feedbax.connected) throw notFound();
|
|
7
|
+
},
|
|
8
|
+
head: () => ({
|
|
9
|
+
meta: [
|
|
10
|
+
{ title: "My feedback" },
|
|
11
|
+
{ name: "robots", content: "noindex,nofollow" },
|
|
12
|
+
{ name: "referrer", content: "no-referrer" },
|
|
13
|
+
],
|
|
14
|
+
}),
|
|
15
|
+
component: MyFeedbackPage,
|
|
16
|
+
});
|
package/template/src/server.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createMyFeedbackRequestHandler } from "./my-feedback-runtime";
|
|
2
|
+
import { createPublicHistoryRequestHandler } from "./history-runtime";
|
|
1
3
|
import { handleImageRequest } from "./images-runtime";
|
|
2
4
|
import {
|
|
3
5
|
handleParticipationRequest,
|
|
@@ -8,7 +10,13 @@ import { configuredTurnstileSecret } from "./turnstile-configuration";
|
|
|
8
10
|
import { createPortalServerEntry } from "./portal-server-entry";
|
|
9
11
|
import { createConfiguredTrustedFeedbackHandler } from "./trusted-feedback-runtime";
|
|
10
12
|
import { env } from "cloudflare:workers";
|
|
11
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
configuredIntegration,
|
|
15
|
+
configuredHistory,
|
|
16
|
+
createConfiguredContentFeedbackModule,
|
|
17
|
+
createConfiguredFeedbackModule,
|
|
18
|
+
requestParticipant,
|
|
19
|
+
} from "./feedback-runtime";
|
|
12
20
|
import { createPortalVoteHandler } from "./portal-voting";
|
|
13
21
|
import { createCloudflareTurnstileVerifier } from "./cloudflare-turnstile";
|
|
14
22
|
import feedbax from "./feedbax";
|
|
@@ -20,7 +28,39 @@ import {
|
|
|
20
28
|
} from "./portal-comments";
|
|
21
29
|
|
|
22
30
|
const application = createPortalServerEntry({
|
|
31
|
+
cachePublicReads: feedbax.provider !== "github" && !feedbax.history,
|
|
23
32
|
connectedHandler: async (request) =>
|
|
33
|
+
(await createPublicHistoryRequestHandler({
|
|
34
|
+
enabled: Boolean(feedbax.history),
|
|
35
|
+
history: feedbax.history ? configuredHistory() : undefined,
|
|
36
|
+
rateLimit: async (input) =>
|
|
37
|
+
(
|
|
38
|
+
await env.TRUSTED_SUBMISSIONS_RATE_LIMITER.limit({
|
|
39
|
+
key: `history:${input.headers.get("CF-Connecting-IP") ?? "unknown"}`,
|
|
40
|
+
})
|
|
41
|
+
).success,
|
|
42
|
+
})(request)) ??
|
|
43
|
+
(feedbax.connected &&
|
|
44
|
+
env.FEEDBAX_DB &&
|
|
45
|
+
request.url.includes("/internal/my-feedback")
|
|
46
|
+
? await createMyFeedbackRequestHandler({
|
|
47
|
+
enabled: true,
|
|
48
|
+
siteUrl: feedbax.product.siteUrl!,
|
|
49
|
+
database: env.FEEDBAX_DB,
|
|
50
|
+
getParticipant: requestParticipant,
|
|
51
|
+
getFeedback: createConfiguredFeedbackModule,
|
|
52
|
+
rateLimit: async (input) =>
|
|
53
|
+
(
|
|
54
|
+
await env.TRUSTED_SUBMISSIONS_RATE_LIMITER.limit({
|
|
55
|
+
key: `my-feedback:${input.headers.get("CF-Connecting-IP") ?? "unknown"}`,
|
|
56
|
+
})
|
|
57
|
+
).success,
|
|
58
|
+
limits: feedbax.submissions,
|
|
59
|
+
})(request)
|
|
60
|
+
: undefined) ??
|
|
61
|
+
(feedbax.provider === "github"
|
|
62
|
+
? await configuredIntegration().handle(request)
|
|
63
|
+
: undefined) ??
|
|
24
64
|
(await handleImageRequest(request)) ??
|
|
25
65
|
(await handleParticipationRequest(request)) ??
|
|
26
66
|
handleConnectedRequest(request),
|
|
@@ -123,6 +163,11 @@ const application = createPortalServerEntry({
|
|
|
123
163
|
export default {
|
|
124
164
|
...application,
|
|
125
165
|
async scheduled() {
|
|
166
|
+
if (feedbax.history) await configuredHistory().synchronize();
|
|
167
|
+
if (feedbax.provider === "github")
|
|
168
|
+
await configuredIntegration().reconcile(
|
|
169
|
+
createConfiguredContentFeedbackModule(),
|
|
170
|
+
);
|
|
126
171
|
if (!feedbax.connected) return;
|
|
127
172
|
if (feedbax.connected.processing === "standard") {
|
|
128
173
|
await synchronizeConnectedUpdates();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Session storage is tab-scoped. A failed request reuses the same secret intent
|
|
2
|
+
// across a retry/reload; successful completion makes a later submission distinct.
|
|
3
|
+
export async function submissionIntent(
|
|
4
|
+
storage: Pick<Storage, "getItem" | "setItem" | "removeItem">,
|
|
5
|
+
input: unknown,
|
|
6
|
+
) {
|
|
7
|
+
const bytes = await crypto.subtle.digest(
|
|
8
|
+
"SHA-256",
|
|
9
|
+
new TextEncoder().encode(JSON.stringify(input)),
|
|
10
|
+
);
|
|
11
|
+
const digest = Array.from(new Uint8Array(bytes), (value) =>
|
|
12
|
+
value.toString(16).padStart(2, "0"),
|
|
13
|
+
).join("");
|
|
14
|
+
const slot = `feedbax:pending-submission:${digest}`;
|
|
15
|
+
let key = storage.getItem(slot);
|
|
16
|
+
if (!key || !/^[A-Za-z0-9_-]{43}$/.test(key)) {
|
|
17
|
+
key = btoa(
|
|
18
|
+
String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))),
|
|
19
|
+
)
|
|
20
|
+
.replaceAll("+", "-")
|
|
21
|
+
.replaceAll("/", "_")
|
|
22
|
+
.replace(/=+$/, "");
|
|
23
|
+
storage.setItem(slot, key);
|
|
24
|
+
}
|
|
25
|
+
return { key, complete: () => storage.removeItem(slot) };
|
|
26
|
+
}
|
package/template/vite.config.ts
CHANGED
|
@@ -3,6 +3,7 @@ import tailwindcss from "@tailwindcss/vite";
|
|
|
3
3
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
|
4
4
|
import viteReact from "@vitejs/plugin-react";
|
|
5
5
|
import { defineConfig } from "vite";
|
|
6
|
+
import { publicFeedbaxConfiguration } from "./feedbax-vite";
|
|
6
7
|
|
|
7
8
|
export default defineConfig({
|
|
8
9
|
server: {
|
|
@@ -12,6 +13,7 @@ export default defineConfig({
|
|
|
12
13
|
tsconfigPaths: true,
|
|
13
14
|
},
|
|
14
15
|
plugins: [
|
|
16
|
+
publicFeedbaxConfiguration(),
|
|
15
17
|
cloudflare({ viteEnvironment: { name: "ssr" } }),
|
|
16
18
|
tailwindcss(),
|
|
17
19
|
tanstackStart({ server: { entry: "./server.ts" } }),
|