create-feedbax 0.1.0 → 0.2.1
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/LICENSE +201 -21
- package/README.md +11 -1
- package/dist/cli.js +3283 -22
- package/dist/index.js +3394 -160
- package/package.json +40 -14
- package/template/.feedbax-template.sha256 +1 -0
- package/template/.gitignore.template +6 -0
- package/template/.npmignore.template +5 -0
- package/template/.prettierignore.template +4 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/LICENSE +201 -0
- package/template/README.md +56 -5
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +48 -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/package.json +74 -14
- package/template/public/feedbax-mark.svg +4 -0
- package/template/public/robots.txt +3 -0
- package/template/src/authorized-draft-post.test.tsx +212 -0
- package/template/src/authorized-draft-post.tsx +205 -0
- package/template/src/authorized-draft-query.ts +74 -0
- package/template/src/browser-post-state.test.ts +120 -0
- package/template/src/browser-post-state.ts +134 -0
- package/template/src/browser-vote-state.test.ts +14 -0
- package/template/src/browser-vote-state.ts +27 -0
- package/template/src/capability-authorization-error.ts +3 -0
- package/template/src/changelog-page.test.tsx +139 -0
- package/template/src/changelog-page.tsx +228 -0
- package/template/src/changelog-query.ts +13 -0
- package/template/src/changelog-runtime.ts +27 -0
- package/template/src/cloudflare-turnstile.ts +31 -0
- package/template/src/cloudflare-workers-test.ts +3 -0
- package/template/src/cloudflare-workers.d.ts +24 -0
- package/template/src/comment-creation.test.tsx +311 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +28 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +275 -0
- package/template/src/components/connected-identity-provider.tsx +108 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +324 -0
- package/template/src/components/follow-post-button.tsx +89 -0
- package/template/src/components/header.tsx +97 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +53 -0
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/profile-menu.tsx +119 -0
- package/template/src/components/ui/components/alert-dialog.tsx +185 -0
- package/template/src/components/ui/components/attachment.tsx +207 -0
- package/template/src/components/ui/components/avatar.tsx +107 -0
- package/template/src/components/ui/components/badge.tsx +52 -0
- package/template/src/components/ui/components/bubble.tsx +128 -0
- package/template/src/components/ui/components/button-group.tsx +87 -0
- package/template/src/components/ui/components/button.tsx +56 -0
- package/template/src/components/ui/components/card.tsx +103 -0
- package/template/src/components/ui/components/checkbox.tsx +26 -0
- package/template/src/components/ui/components/command.tsx +195 -0
- package/template/src/components/ui/components/dialog.tsx +154 -0
- package/template/src/components/ui/components/drawer.tsx +229 -0
- package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
- package/template/src/components/ui/components/empty.tsx +101 -0
- package/template/src/components/ui/components/field.tsx +236 -0
- package/template/src/components/ui/components/input-group.tsx +155 -0
- package/template/src/components/ui/components/input.tsx +20 -0
- package/template/src/components/ui/components/item.tsx +201 -0
- package/template/src/components/ui/components/kbd.tsx +26 -0
- package/template/src/components/ui/components/label.tsx +18 -0
- package/template/src/components/ui/components/marker.tsx +71 -0
- package/template/src/components/ui/components/message-scroller.tsx +128 -0
- package/template/src/components/ui/components/message.tsx +92 -0
- package/template/src/components/ui/components/native-select.tsx +65 -0
- package/template/src/components/ui/components/popover.tsx +88 -0
- package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
- package/template/src/components/ui/components/radio-group.tsx +36 -0
- package/template/src/components/ui/components/scroll-area.tsx +51 -0
- package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
- package/template/src/components/ui/components/select.tsx +199 -0
- package/template/src/components/ui/components/separator.tsx +23 -0
- package/template/src/components/ui/components/sheet.tsx +135 -0
- package/template/src/components/ui/components/skeleton.tsx +13 -0
- package/template/src/components/ui/components/sonner.tsx +43 -0
- package/template/src/components/ui/components/spinner.tsx +16 -0
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/components/tabs.tsx +80 -0
- package/template/src/components/ui/components/textarea.tsx +18 -0
- package/template/src/components/ui/components/tooltip.tsx +64 -0
- package/template/src/components/ui/hooks/.gitkeep +0 -0
- package/template/src/components/ui/lib/utils.ts +6 -0
- package/template/src/components/ui/styles/globals.css +151 -0
- package/template/src/connected-identity.test.ts +144 -0
- package/template/src/connected-identity.ts +122 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +241 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/create-post-overlay.tsx +82 -0
- package/template/src/draft-post-list.tsx +39 -0
- package/template/src/feedback-cache-invalidation.test.ts +185 -0
- package/template/src/feedback-cache-invalidation.ts +98 -0
- package/template/src/feedback-runtime.ts +105 -0
- package/template/src/feedbax/changelog/index.test.ts +156 -0
- package/template/src/feedbax/changelog/index.ts +206 -0
- package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
- package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
- package/template/src/feedbax/config/index.test.ts +139 -0
- package/template/src/feedbax/config/index.ts +364 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +215 -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 +43 -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 +101 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +324 -0
- package/template/src/feedbax/feedback/index.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +996 -0
- package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
- package/template/src/feedbax/feedback/notion-comments.ts +231 -0
- package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
- package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
- package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
- package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/notion-request.ts +74 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +1795 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +21 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +181 -0
- package/template/src/overlay-post-detail.tsx +43 -0
- 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.test.ts +375 -0
- package/template/src/portal-comments.ts +403 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +530 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +58 -0
- package/template/src/portal-feedback-server-function.ts +96 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +224 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +68 -0
- package/template/src/portal-voting.test.ts +106 -0
- package/template/src/portal-voting.ts +149 -0
- package/template/src/post-badges.tsx +27 -0
- package/template/src/post-creation-flow.test.tsx +230 -0
- package/template/src/post-creation-flow.tsx +88 -0
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-feed-controls.test.tsx +239 -0
- package/template/src/post-feed-controls.tsx +298 -0
- package/template/src/post-presentation.ts +37 -0
- package/template/src/post-queries.test.ts +158 -0
- package/template/src/post-queries.ts +26 -0
- package/template/src/post-results.tsx +165 -0
- package/template/src/public-cache-policy.test.ts +148 -0
- package/template/src/public-cache-policy.ts +119 -0
- package/template/src/public-cache-tags.ts +7 -0
- package/template/src/public-changelog-page.ts +15 -0
- package/template/src/public-changelog-server-function.ts +27 -0
- package/template/src/public-date.ts +11 -0
- package/template/src/public-failure-state.test.tsx +69 -0
- package/template/src/public-feedback-index.tsx +94 -0
- package/template/src/public-feedback-page.test.tsx +355 -0
- package/template/src/public-feedback-page.ts +86 -0
- package/template/src/public-feedback-server-function.ts +22 -0
- package/template/src/public-portal-error.tsx +27 -0
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +772 -0
- package/template/src/public-post-page.test.tsx +173 -0
- package/template/src/public-post-page.ts +15 -0
- package/template/src/public-post-route.test.tsx +250 -0
- package/template/src/public-post-route.tsx +158 -0
- package/template/src/public-post-server-function.ts +48 -0
- package/template/src/public-post-unavailable.tsx +10 -0
- package/template/src/public-roadmap-page.test.tsx +139 -0
- package/template/src/public-roadmap-page.ts +24 -0
- package/template/src/public-roadmap-route.test.tsx +254 -0
- package/template/src/public-roadmap-route.tsx +50 -0
- package/template/src/public-roadmap-server-function.ts +44 -0
- package/template/src/public-roadmap-view.tsx +411 -0
- package/template/src/public-shell.test.tsx +544 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +177 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +27 -0
- package/template/src/routes/__root.tsx +114 -0
- package/template/src/routes/changelog.tsx +39 -0
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/index.tsx +61 -0
- package/template/src/routes/p.$slug.tsx +16 -0
- package/template/src/routes/roadmap.tsx +11 -0
- package/template/src/routes/submit.tsx +28 -0
- package/template/src/safe-public-failure.ts +47 -0
- package/template/src/server.ts +151 -0
- package/template/src/similar-posts.tsx +45 -0
- package/template/src/submit-route.test.tsx +134 -0
- package/template/src/trusted-feedback-handler.test.ts +425 -0
- package/template/src/trusted-feedback-handler.ts +181 -0
- package/template/src/trusted-feedback-runtime.ts +17 -0
- package/template/src/turnstile-configuration.test.ts +37 -0
- package/template/src/turnstile-configuration.ts +19 -0
- package/template/src/use-media-query.ts +17 -0
- package/template/src/vote-toggle.test.tsx +171 -0
- package/template/src/vote-toggle.tsx +244 -0
- package/template/tsconfig.json +45 -0
- package/template/vite.config.ts +20 -0
- package/template/vitest.config.ts +4 -0
- package/template/vitest.setup.ts +3 -0
- package/template/wrangler.jsonc +35 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/template/.env.example +0 -4
- package/template/apps/portal/components.json +0 -20
- package/template/apps/portal/package.json +0 -31
- package/template/apps/portal/src/lib/utils.ts +0 -6
- package/template/apps/portal/src/routeTree.gen.ts +0 -59
- package/template/apps/portal/src/router.tsx +0 -10
- package/template/apps/portal/src/routes/__root.tsx +0 -31
- package/template/apps/portal/src/routes/index.tsx +0 -35
- package/template/apps/portal/src/styles.css +0 -91
- package/template/apps/portal/tsconfig.json +0 -15
- package/template/apps/portal/vite.config.ts +0 -13
- package/template/feedbax.config.ts +0 -13
- package/template/feedbax.jsonc +0 -11
- package/template/pnpm-workspace.yaml +0 -3
- package/template/turbo.json +0 -8
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
3
|
+
import { sql } from "drizzle-orm";
|
|
4
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
5
|
+
import type { BrowserCapability, FeedbackModule } from "./index";
|
|
6
|
+
import type { VerifiedParticipant } from "./connected";
|
|
7
|
+
|
|
8
|
+
export interface ImageAttachment {
|
|
9
|
+
id: string;
|
|
10
|
+
src: string;
|
|
11
|
+
contentType: string;
|
|
12
|
+
bytes: number;
|
|
13
|
+
}
|
|
14
|
+
type ImageRow = {
|
|
15
|
+
id: string;
|
|
16
|
+
subject: string;
|
|
17
|
+
object_key: string;
|
|
18
|
+
content_type: string;
|
|
19
|
+
bytes: number;
|
|
20
|
+
created_at: number;
|
|
21
|
+
deleting: number;
|
|
22
|
+
post_slug: string | null;
|
|
23
|
+
comment_id: string | null;
|
|
24
|
+
};
|
|
25
|
+
const limit = 5 * 1024 * 1024;
|
|
26
|
+
export class ImageAttachmentError extends Error {}
|
|
27
|
+
export function createImageModule(options: {
|
|
28
|
+
database: D1Database;
|
|
29
|
+
bucket: {
|
|
30
|
+
put(
|
|
31
|
+
key: string,
|
|
32
|
+
value: ArrayBuffer,
|
|
33
|
+
options: { httpMetadata: { contentType: string } },
|
|
34
|
+
): Promise<unknown>;
|
|
35
|
+
get(key: string): Promise<{ body: unknown } | null>;
|
|
36
|
+
delete(key: string): Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
feedback: FeedbackModule;
|
|
39
|
+
participant?: VerifiedParticipant;
|
|
40
|
+
processing?: "small" | "standard";
|
|
41
|
+
reviewSecret: string;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
}) {
|
|
44
|
+
const db = drizzle(options.database);
|
|
45
|
+
const now = () => options.now?.() ?? Date.now();
|
|
46
|
+
const identity = () => {
|
|
47
|
+
if (!options.participant)
|
|
48
|
+
throw new ImageAttachmentError("Sign in to add images.");
|
|
49
|
+
return options.participant;
|
|
50
|
+
};
|
|
51
|
+
const row = (id: string) =>
|
|
52
|
+
db.get<ImageRow>(sql`SELECT * FROM image_attachments WHERE id=${id}`);
|
|
53
|
+
const projection = (image: ImageRow): ImageAttachment => ({
|
|
54
|
+
id: image.id,
|
|
55
|
+
src: `/internal/images/${image.id}`,
|
|
56
|
+
contentType: image.content_type,
|
|
57
|
+
bytes: image.bytes,
|
|
58
|
+
});
|
|
59
|
+
const publicComment = async (slug: string, commentId: string) => {
|
|
60
|
+
let cursor: string | undefined;
|
|
61
|
+
do {
|
|
62
|
+
const page = await options.feedback.listCommentThreads({ slug, cursor });
|
|
63
|
+
if (
|
|
64
|
+
page.items.some((thread) =>
|
|
65
|
+
thread.comments.some((comment) => comment.id === commentId),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
return true;
|
|
69
|
+
cursor = page.nextCursor;
|
|
70
|
+
} while (cursor);
|
|
71
|
+
return false;
|
|
72
|
+
};
|
|
73
|
+
const claim = async (ids: string[], slug: string, commentId?: string) => {
|
|
74
|
+
const person = identity();
|
|
75
|
+
if (!ids.length) return;
|
|
76
|
+
if (
|
|
77
|
+
ids.length > 4 ||
|
|
78
|
+
new Set(ids).size !== ids.length ||
|
|
79
|
+
ids.some((id) => !/^[-a-f0-9]{36}$/.test(id))
|
|
80
|
+
)
|
|
81
|
+
throw new ImageAttachmentError("Choose up to four images.");
|
|
82
|
+
const list = sql.join(
|
|
83
|
+
ids.map((id) => sql`${id}`),
|
|
84
|
+
sql`, `,
|
|
85
|
+
);
|
|
86
|
+
const eligible = sql`subject=${person.subject} AND id IN (${list}) AND (post_slug IS NULL OR (post_slug=${slug} AND comment_id IS ${commentId ?? null})) AND deleting=0 AND (post_slug IS NOT NULL OR created_at > ${now() - 86400000})`;
|
|
87
|
+
const claimed = await db.all<{ id: string }>(
|
|
88
|
+
sql`UPDATE image_attachments SET post_slug=${slug},comment_id=${commentId ?? null} WHERE ${eligible} AND (SELECT COUNT(*) FROM image_attachments WHERE ${eligible})=${ids.length} AND (SELECT COUNT(*) FROM image_attachments WHERE post_slug=${slug} AND comment_id IS ${commentId ?? null} AND id NOT IN (${list}))+${ids.length} <= 4 RETURNING id`,
|
|
89
|
+
);
|
|
90
|
+
if (claimed.length !== ids.length)
|
|
91
|
+
throw new ImageAttachmentError(
|
|
92
|
+
"One or more images are unavailable. Upload them again.",
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
return {
|
|
96
|
+
async validatePending(ids: string[]) {
|
|
97
|
+
if (!ids.length) return;
|
|
98
|
+
const person = identity();
|
|
99
|
+
if (ids.length > 4 || new Set(ids).size !== ids.length)
|
|
100
|
+
throw new ImageAttachmentError("Choose up to four images.");
|
|
101
|
+
for (const id of ids) {
|
|
102
|
+
const image = await row(id);
|
|
103
|
+
if (
|
|
104
|
+
!image ||
|
|
105
|
+
image.subject !== person.subject ||
|
|
106
|
+
image.post_slug ||
|
|
107
|
+
image.deleting ||
|
|
108
|
+
image.created_at <= now() - 86400000
|
|
109
|
+
)
|
|
110
|
+
throw new ImageAttachmentError(
|
|
111
|
+
"One or more images are unavailable. Upload them again.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
async upload(
|
|
116
|
+
bytes: Uint8Array,
|
|
117
|
+
contentType: string,
|
|
118
|
+
): Promise<ImageAttachment> {
|
|
119
|
+
const person = identity();
|
|
120
|
+
if (bytes.byteLength > limit)
|
|
121
|
+
throw new ImageAttachmentError("Each image must be 5 MiB or smaller.");
|
|
122
|
+
if (!validImage(bytes, contentType))
|
|
123
|
+
throw new ImageAttachmentError(
|
|
124
|
+
"Choose a valid JPEG, PNG, or WebP image.",
|
|
125
|
+
);
|
|
126
|
+
const id = randomUUID(),
|
|
127
|
+
objectKey = `participant-images/${id}`;
|
|
128
|
+
const reserved = await db.get(
|
|
129
|
+
sql`INSERT INTO image_attachments(id,subject,object_key,content_type,bytes,created_at) SELECT ${id},${person.subject},${objectKey},${contentType},${bytes.byteLength},${now()} WHERE (SELECT COUNT(*) FROM image_attachments WHERE subject=${person.subject} AND post_slug IS NULL AND deleting=0 AND created_at > ${now() - 86400000}) < 20 RETURNING id`,
|
|
130
|
+
);
|
|
131
|
+
if (!reserved)
|
|
132
|
+
throw new ImageAttachmentError(
|
|
133
|
+
"Too many unused images. Remove one or try again later.",
|
|
134
|
+
);
|
|
135
|
+
try {
|
|
136
|
+
await options.bucket.put(objectKey, bytes.slice().buffer, {
|
|
137
|
+
httpMetadata: { contentType },
|
|
138
|
+
});
|
|
139
|
+
} catch (error) {
|
|
140
|
+
await db.run(
|
|
141
|
+
sql`UPDATE image_attachments SET deleting=1 WHERE id=${id}`,
|
|
142
|
+
);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
id,
|
|
147
|
+
src: `/internal/images/${id}`,
|
|
148
|
+
contentType,
|
|
149
|
+
bytes: bytes.byteLength,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
async attachPost(input: {
|
|
153
|
+
ids: string[];
|
|
154
|
+
id: string;
|
|
155
|
+
browserCapability: BrowserCapability;
|
|
156
|
+
}) {
|
|
157
|
+
identity();
|
|
158
|
+
const post = await options.feedback.getDraftPost({
|
|
159
|
+
id: input.id,
|
|
160
|
+
browserCapability: input.browserCapability,
|
|
161
|
+
});
|
|
162
|
+
await claim(input.ids, post.slug);
|
|
163
|
+
return this.forPost(post.slug);
|
|
164
|
+
},
|
|
165
|
+
async attachComment(input: {
|
|
166
|
+
ids: string[];
|
|
167
|
+
slug: string;
|
|
168
|
+
commentId: string;
|
|
169
|
+
}) {
|
|
170
|
+
const person = identity();
|
|
171
|
+
if (
|
|
172
|
+
!(await options.feedback.getPublicPost(input.slug)) ||
|
|
173
|
+
!(await publicComment(input.slug, input.commentId))
|
|
174
|
+
)
|
|
175
|
+
throw new ImageAttachmentError("Comment is unavailable.");
|
|
176
|
+
const author = await db.get<{ subject: string }>(
|
|
177
|
+
sql`SELECT subject FROM comment_attribution WHERE comment_id=${input.commentId}`,
|
|
178
|
+
);
|
|
179
|
+
if (author?.subject !== person.subject)
|
|
180
|
+
throw new ImageAttachmentError("Comment is unavailable.");
|
|
181
|
+
await claim(input.ids, input.slug, input.commentId);
|
|
182
|
+
return this.forComment(input.slug, input.commentId);
|
|
183
|
+
},
|
|
184
|
+
async forPost(slug: string): Promise<ImageAttachment[]> {
|
|
185
|
+
return (
|
|
186
|
+
await db.all<ImageRow>(
|
|
187
|
+
sql`SELECT * FROM image_attachments WHERE post_slug=${slug} AND comment_id IS NULL AND deleting=0 ORDER BY created_at,id`,
|
|
188
|
+
)
|
|
189
|
+
).map(projection);
|
|
190
|
+
},
|
|
191
|
+
async forComment(
|
|
192
|
+
slug: string,
|
|
193
|
+
commentId: string,
|
|
194
|
+
): Promise<ImageAttachment[]> {
|
|
195
|
+
return (
|
|
196
|
+
await db.all<ImageRow>(
|
|
197
|
+
sql`SELECT * FROM image_attachments WHERE post_slug=${slug} AND comment_id=${commentId} AND deleting=0 ORDER BY created_at,id`,
|
|
198
|
+
)
|
|
199
|
+
).map(projection);
|
|
200
|
+
},
|
|
201
|
+
async forComments(slug: string, ids: string[]) {
|
|
202
|
+
const result = new Map<string, ImageAttachment[]>();
|
|
203
|
+
if (!ids.length) return result;
|
|
204
|
+
const list = sql.join(
|
|
205
|
+
ids.map((id) => sql`${id}`),
|
|
206
|
+
sql`, `,
|
|
207
|
+
);
|
|
208
|
+
const rows = await db.all<ImageRow>(
|
|
209
|
+
sql`SELECT * FROM image_attachments WHERE post_slug=${slug} AND comment_id IN (${list}) AND deleting=0 ORDER BY created_at,id`,
|
|
210
|
+
);
|
|
211
|
+
for (const image of rows) {
|
|
212
|
+
const key = image.comment_id!;
|
|
213
|
+
result.set(key, [...(result.get(key) ?? []), projection(image)]);
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
},
|
|
217
|
+
async reviewUrl(id: string, siteUrl: string) {
|
|
218
|
+
if (options.reviewSecret.length < 32)
|
|
219
|
+
throw new Error("Image review secret is missing.");
|
|
220
|
+
if (!(await row(id)))
|
|
221
|
+
throw new ImageAttachmentError("Image is unavailable.");
|
|
222
|
+
const target = new URL(`/internal/images/${id}`, siteUrl);
|
|
223
|
+
target.searchParams.set("review", reviewMac(id, options.reviewSecret));
|
|
224
|
+
return target.toString();
|
|
225
|
+
},
|
|
226
|
+
async read(id: string, review?: string) {
|
|
227
|
+
const image = await row(id);
|
|
228
|
+
if (!image || image.deleting) return;
|
|
229
|
+
const owner = options.participant?.subject === image.subject;
|
|
230
|
+
let reviewer = false;
|
|
231
|
+
if (review && options.reviewSecret.length >= 32) {
|
|
232
|
+
const wanted = Buffer.from(reviewMac(id, options.reviewSecret)),
|
|
233
|
+
got = Buffer.from(review);
|
|
234
|
+
reviewer = wanted.length === got.length && timingSafeEqual(wanted, got);
|
|
235
|
+
}
|
|
236
|
+
let allowed: boolean;
|
|
237
|
+
if (!image.post_slug)
|
|
238
|
+
allowed = owner && image.created_at > now() - 86400000;
|
|
239
|
+
else {
|
|
240
|
+
const state = await options.feedback.getAttachmentPostState(
|
|
241
|
+
image.post_slug,
|
|
242
|
+
);
|
|
243
|
+
if (image.comment_id)
|
|
244
|
+
allowed =
|
|
245
|
+
state === "published" &&
|
|
246
|
+
(await publicComment(image.post_slug, image.comment_id));
|
|
247
|
+
else
|
|
248
|
+
allowed =
|
|
249
|
+
state === "published" ||
|
|
250
|
+
Boolean(state && reviewer) ||
|
|
251
|
+
(state === "draft" && owner);
|
|
252
|
+
}
|
|
253
|
+
if (!allowed) return;
|
|
254
|
+
const object = await options.bucket.get(image.object_key);
|
|
255
|
+
return object
|
|
256
|
+
? {
|
|
257
|
+
body: object.body,
|
|
258
|
+
contentType: image.content_type,
|
|
259
|
+
bytes: image.bytes,
|
|
260
|
+
}
|
|
261
|
+
: undefined;
|
|
262
|
+
},
|
|
263
|
+
async discardAttached(slug: string, commentId?: string) {
|
|
264
|
+
const person = identity();
|
|
265
|
+
await db.run(
|
|
266
|
+
sql`UPDATE image_attachments SET deleting=1 WHERE post_slug=${slug} AND comment_id IS ${commentId ?? null} AND subject=${person.subject}`,
|
|
267
|
+
);
|
|
268
|
+
},
|
|
269
|
+
async removePending(id: string) {
|
|
270
|
+
const person = identity();
|
|
271
|
+
const image = await db.get<ImageRow>(
|
|
272
|
+
sql`UPDATE image_attachments SET deleting=1 WHERE id=${id} AND subject=${person.subject} AND post_slug IS NULL RETURNING *`,
|
|
273
|
+
);
|
|
274
|
+
if (!image) throw new ImageAttachmentError("Image is unavailable.");
|
|
275
|
+
await options.bucket.delete(image.object_key);
|
|
276
|
+
await db.run(
|
|
277
|
+
sql`DELETE FROM image_attachments WHERE id=${id} AND deleting=1`,
|
|
278
|
+
);
|
|
279
|
+
},
|
|
280
|
+
async cleanup() {
|
|
281
|
+
const rows = await db.all<ImageRow>(
|
|
282
|
+
sql`UPDATE image_attachments SET deleting=1 WHERE id IN(SELECT id FROM image_attachments WHERE deleting=1 OR (post_slug IS NULL AND created_at <= ${now() - 86400000}) LIMIT ${options.processing === "small" ? 5 : 50}) RETURNING *`,
|
|
283
|
+
);
|
|
284
|
+
for (const image of rows) {
|
|
285
|
+
await options.bucket.delete(image.object_key);
|
|
286
|
+
await db.run(
|
|
287
|
+
sql`DELETE FROM image_attachments WHERE id=${image.id} AND deleting=1`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return rows.length;
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const reviewMac = (id: string, secret: string) =>
|
|
295
|
+
createHmac("sha256", secret)
|
|
296
|
+
.update(`feedbax-image-review-v1:${id}`)
|
|
297
|
+
.digest("base64url");
|
|
298
|
+
function validImage(b: Uint8Array, type: string) {
|
|
299
|
+
if (type === "image/png")
|
|
300
|
+
return (
|
|
301
|
+
b.length >= 33 &&
|
|
302
|
+
[137, 80, 78, 71, 13, 10, 26, 10].every((v, i) => b[i] === v) &&
|
|
303
|
+
String.fromCharCode(...b.slice(12, 16)) === "IHDR"
|
|
304
|
+
);
|
|
305
|
+
if (type === "image/jpeg")
|
|
306
|
+
return (
|
|
307
|
+
b.length >= 4 &&
|
|
308
|
+
b[0] === 255 &&
|
|
309
|
+
b[1] === 216 &&
|
|
310
|
+
b[2] === 255 &&
|
|
311
|
+
b[b.length - 2] === 255 &&
|
|
312
|
+
b[b.length - 1] === 217
|
|
313
|
+
);
|
|
314
|
+
if (type === "image/webp")
|
|
315
|
+
return (
|
|
316
|
+
b.length >= 20 &&
|
|
317
|
+
String.fromCharCode(...b.slice(0, 4)) === "RIFF" &&
|
|
318
|
+
String.fromCharCode(...b.slice(8, 12)) === "WEBP" &&
|
|
319
|
+
new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(4, true) +
|
|
320
|
+
8 ===
|
|
321
|
+
b.length
|
|
322
|
+
);
|
|
323
|
+
return false;
|
|
324
|
+
}
|