@coywolf/emdash 0.0.0-stage → 0.35.13

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.
Files changed (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Private form uploads: wraps the EmDash Forms plugin so files sent through
3
+ * its forms go to a private R2 bucket instead of the public media library.
4
+ *
5
+ * The Forms plugin (@emdash-cms/plugin-forms) stores a submitted file with
6
+ * `ctx.media.upload()` (src/handlers/submit.ts) and deletes it with
7
+ * `ctx.media.delete()` when a submission, a form's submissions or expired
8
+ * submissions are deleted. Its handlers are otherwise untouched: every route
9
+ * and hook runs with a `ctx.media` whose upload() writes to the private
10
+ * bucket (when the feature is on and the form is selected) and whose delete()
11
+ * removes private files. Spam protection, validation, file type and size
12
+ * checks, notifications, webhooks and the confirmation message are all still
13
+ * the Forms plugin's own.
14
+ *
15
+ * The submission records the file as media id "coywolf-private:<id>". The
16
+ * file's details live in the Forms plugin's KV (coywolf-private-upload:<id>),
17
+ * which the Form uploads admin page lists. Downloads go through a
18
+ * permission-checked plugin route that only answers admins, always as an
19
+ * attachment with a type that can't render as a page.
20
+ */
21
+ import { PluginRouteError, pluginResponse } from "emdash";
22
+ import { z } from "zod";
23
+
24
+ import { getManyBatched } from "../core/storage.js";
25
+ import { contentDisposition } from "../files/format.js";
26
+ import { parseInput, workerEnv } from "../shared.js";
27
+ import {
28
+ CONFIG_KEY,
29
+ ENTRY_PREFIX,
30
+ MAX_FILE_BYTES,
31
+ ROUTE_PREFIX,
32
+ SWEEP_TASK,
33
+ UPLOAD_ID,
34
+ chunkRange,
35
+ formSelected,
36
+ matchField,
37
+ newUploadId,
38
+ normalizeConfig,
39
+ objectKey,
40
+ privateIdOf,
41
+ privateMediaId,
42
+ safeContentType,
43
+ sanitizeFilename,
44
+ submissionPreview,
45
+ sweepDecision,
46
+ uploadTypeAllowed,
47
+ type FormUploadsConfig,
48
+ type UploadEntry,
49
+ } from "./lib.js";
50
+
51
+ export const FEATURE = "formUploads";
52
+ const PACK_FEATURES_OPTION = "plugin:coywolf-pack:settings:features";
53
+ const SCHEDULED_KEY = "coywolf-private-uploads:scheduled";
54
+
55
+ /** Options passed through the Forms plugin descriptor by privateFormUploads(). */
56
+ export interface PrivateFormUploadsOptions {
57
+ /** R2 binding of the private bucket. Default "FORM_UPLOADS". */
58
+ bucket?: string;
59
+ /** R2 binding of EmDash's media bucket, for moving earlier uploads. Default "MEDIA". */
60
+ mediaBucket?: string;
61
+ /** D1 binding of the site database. Default "DB". */
62
+ database?: string;
63
+ }
64
+
65
+ export const WRAP_OPTION = "coywolfPrivateUploads";
66
+
67
+ // ── Loose shapes of what the Forms plugin hands us ───────────────
68
+
69
+ interface SubmissionFile {
70
+ fieldName: string;
71
+ filename: string;
72
+ contentType: string;
73
+ size: number;
74
+ mediaId: string;
75
+ }
76
+ interface Submission {
77
+ formId: string;
78
+ data: Record<string, unknown>;
79
+ files?: SubmissionFile[];
80
+ status?: string;
81
+ createdAt: string;
82
+ [key: string]: unknown;
83
+ }
84
+ interface FormField {
85
+ name: string;
86
+ label?: string;
87
+ type: string;
88
+ }
89
+ interface FormDefinition {
90
+ name: string;
91
+ slug: string;
92
+ pages?: Array<{ fields?: FormField[] }>;
93
+ [key: string]: unknown;
94
+ }
95
+ interface Collection<T> {
96
+ get(id: string): Promise<T | null>;
97
+ put(id: string, data: T): Promise<void>;
98
+ getMany(ids: string[]): Promise<Map<string, T>>;
99
+ deleteMany(ids: string[]): Promise<number>;
100
+ query(options?: Record<string, unknown>): Promise<{ items: Array<{ id: string; data: T }>; cursor?: string; hasMore?: boolean }>;
101
+ }
102
+ interface Media {
103
+ upload?(filename: string, contentType: string, bytes: ArrayBuffer): Promise<{ mediaId: string; storageKey: string; url: string }>;
104
+ delete?(id: string): Promise<boolean>;
105
+ get?(id: string): Promise<{ id: string; filename: string; mimeType: string; size: number | null } | null>;
106
+ [key: string]: unknown;
107
+ }
108
+ export interface FormsCtx {
109
+ input?: unknown;
110
+ media?: Media;
111
+ kv: {
112
+ get<T>(key: string): Promise<T | null>;
113
+ set(key: string, value: unknown): Promise<void>;
114
+ delete(key: string): Promise<boolean>;
115
+ list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
116
+ };
117
+ storage: Record<string, unknown>;
118
+ cron?: { schedule(name: string, opts: { schedule: string }): Promise<void> };
119
+ log?: { info(msg: string, data?: unknown): void; warn(msg: string, data?: unknown): void; error(msg: string, data?: unknown): void };
120
+ [key: string]: unknown;
121
+ }
122
+
123
+ const formsOf = (ctx: FormsCtx) => ctx.storage.forms as Collection<FormDefinition>;
124
+ const submissionsOf = (ctx: FormsCtx) => ctx.storage.submissions as Collection<Submission>;
125
+ const fieldsOf = (form: FormDefinition) => (form.pages ?? []).flatMap((p) => p.fields ?? []);
126
+
127
+ /** Bindings and the feature switch, injectable for tests. */
128
+ export interface Deps {
129
+ bucket(): Promise<R2Bucket | undefined>;
130
+ mediaBucket(): Promise<R2Bucket | undefined>;
131
+ database(): Promise<D1Database | undefined>;
132
+ /** The "Private form uploads" switch on the Coywolf Pack page. */
133
+ enabled(): Promise<boolean>;
134
+ }
135
+
136
+ export function defaultDeps(options: PrivateFormUploadsOptions = {}): Deps {
137
+ const env = async () => {
138
+ try {
139
+ return await workerEnv();
140
+ } catch {
141
+ return {} as Record<string, unknown>;
142
+ }
143
+ };
144
+ const database = async () => (await env())[options.database ?? "DB"] as D1Database | undefined;
145
+ return {
146
+ bucket: async () => (await env())[options.bucket ?? "FORM_UPLOADS"] as R2Bucket | undefined,
147
+ mediaBucket: async () => (await env())[options.mediaBucket ?? "MEDIA"] as R2Bucket | undefined,
148
+ database,
149
+ enabled: async () => {
150
+ try {
151
+ const db = await database();
152
+ if (!db) return false;
153
+ const row = await db.prepare("SELECT value FROM options WHERE name = ?").bind(PACK_FEATURES_OPTION).first<{ value: string }>();
154
+ const stored = row?.value ? (JSON.parse(row.value) as Record<string, unknown>) : null;
155
+ return stored?.[FEATURE] === true;
156
+ } catch (error) {
157
+ // Can't tell whether the switch is on: keep the file private rather than publish it.
158
+ console.error("coywolf-pack: could not read the Private form uploads switch; keeping files private", error);
159
+ return true;
160
+ }
161
+ },
162
+ };
163
+ }
164
+
165
+ // ── The private media sink ───────────────────────────────────────
166
+
167
+ interface Decision {
168
+ private: boolean;
169
+ form?: { id: string; def: FormDefinition };
170
+ }
171
+
172
+ /** Per-request state: whether this request's uploads are private, and which ones were made. */
173
+ interface RequestState {
174
+ decision?: Promise<Decision>;
175
+ taken: Set<string>;
176
+ uploaded: string[];
177
+ }
178
+
179
+ async function findForm(ctx: FormsCtx, idOrSlug: string): Promise<{ id: string; def: FormDefinition } | null> {
180
+ const byId = await formsOf(ctx).get(idOrSlug);
181
+ if (byId) return { id: idOrSlug, def: byId };
182
+ const bySlug = await formsOf(ctx).query({ where: { slug: idOrSlug }, limit: 1 });
183
+ const first = bySlug.items[0];
184
+ return first ? { id: first.id, def: first.data } : null;
185
+ }
186
+
187
+ export async function readConfig(ctx: FormsCtx): Promise<FormUploadsConfig> {
188
+ return normalizeConfig(await ctx.kv.get(CONFIG_KEY));
189
+ }
190
+
191
+ async function decide(ctx: FormsCtx, deps: Deps): Promise<Decision> {
192
+ if (!(await deps.enabled())) return { private: false };
193
+ const formId = (ctx.input as { formId?: unknown } | undefined)?.formId;
194
+ // An upload outside a form submission (a future Forms feature): keep it private.
195
+ if (typeof formId !== "string" || !formId) return { private: true };
196
+ const form = await findForm(ctx, formId);
197
+ if (!form) return { private: true };
198
+ return { private: formSelected(await readConfig(ctx), { id: form.id, slug: form.def.slug }), form };
199
+ }
200
+
201
+ export async function deletePrivate(ctx: FormsCtx, deps: Deps, id: string): Promise<boolean> {
202
+ const entry = await ctx.kv.get<UploadEntry>(`${ENTRY_PREFIX}${id}`);
203
+ const bucket = await deps.bucket();
204
+ if (bucket) await bucket.delete(entry?.key ?? objectKey(id));
205
+ else if (entry) throw new Error("The private uploads bucket isn't bound, so the file can't be deleted.");
206
+ await ctx.kv.delete(`${ENTRY_PREFIX}${id}`);
207
+ return !!entry;
208
+ }
209
+
210
+ /** Store one file in the private bucket and index it. */
211
+ export async function storePrivate(
212
+ ctx: FormsCtx,
213
+ bucket: R2Bucket,
214
+ file: { filename: string; contentType: string; bytes: Uint8Array },
215
+ meta: { form?: { id: string; def: FormDefinition }; fieldName: string; submissionId?: string | null; source?: UploadEntry["source"] },
216
+ ): Promise<UploadEntry> {
217
+ if (file.bytes.byteLength > MAX_FILE_BYTES) throw PluginRouteError.badRequest(`File is too large. Maximum: ${MAX_FILE_BYTES / 1024 / 1024} MB`);
218
+ const id = newUploadId();
219
+ const key = objectKey(id);
220
+ const filename = sanitizeFilename(file.filename);
221
+ const field = meta.form ? fieldsOf(meta.form.def).find((f) => f.name === meta.fieldName) : undefined;
222
+ const entry: UploadEntry = {
223
+ id,
224
+ key,
225
+ formId: meta.form?.id ?? "",
226
+ formSlug: meta.form?.def.slug ?? "",
227
+ formName: meta.form?.def.name ?? "",
228
+ fieldName: meta.fieldName,
229
+ fieldLabel: field?.label ?? meta.fieldName,
230
+ filename,
231
+ contentType: String(file.contentType || "application/octet-stream").slice(0, 120),
232
+ size: file.bytes.byteLength,
233
+ uploadedAt: new Date().toISOString(),
234
+ submissionId: meta.submissionId ?? null,
235
+ ...(meta.source ? { source: meta.source } : {}),
236
+ };
237
+ await bucket.put(key, file.bytes, {
238
+ httpMetadata: { contentType: safeContentType(file.contentType), contentDisposition: contentDisposition(filename) },
239
+ customMetadata: { formId: entry.formId, field: entry.fieldName, filename },
240
+ });
241
+ try {
242
+ await ctx.kv.set(`${ENTRY_PREFIX}${id}`, entry);
243
+ } catch (error) {
244
+ await bucket.delete(key).catch(() => undefined);
245
+ throw error;
246
+ }
247
+ return entry;
248
+ }
249
+
250
+ /** `ctx.media` for the Forms plugin's handlers: private upload and delete, everything else unchanged. */
251
+ export function privateMedia(ctx: FormsCtx, deps: Deps, state: RequestState): Media {
252
+ const original = ctx.media;
253
+ const upload = async (filename: string, contentType: string, bytes: ArrayBuffer) => {
254
+ state.decision ??= decide(ctx, deps);
255
+ const decision = await state.decision;
256
+ if (!decision.private) {
257
+ if (!original?.upload) throw PluginRouteError.internal("File uploads are not configured");
258
+ return original.upload(filename, contentType, bytes);
259
+ }
260
+ const bucket = await deps.bucket();
261
+ // Fail closed: with the feature on, a missing bucket never sends files to the public library.
262
+ if (!bucket) throw PluginRouteError.internal("File uploads are not configured");
263
+ if (!uploadTypeAllowed(contentType)) throw new PluginRouteError("UNSUPPORTED_MEDIA_TYPE", "File type not allowed", 415);
264
+ const data = new Uint8Array(bytes);
265
+ const files = (ctx.input as { files?: Record<string, { filename: string; bytes: { byteLength: number } }> } | undefined)?.files;
266
+ const fieldName = matchField(files, filename, data.byteLength, state.taken);
267
+ const entry = await storePrivate(ctx, bucket, { filename, contentType, bytes: data }, { form: decision.form, fieldName });
268
+ state.uploaded.push(entry.id);
269
+ return { mediaId: privateMediaId(entry.id), storageKey: entry.key, url: "" };
270
+ };
271
+ const del = async (mediaId: string) => {
272
+ const id = privateIdOf(mediaId);
273
+ if (id) return deletePrivate(ctx, deps, id);
274
+ if (!original?.delete) return false;
275
+ return original.delete(mediaId);
276
+ };
277
+ if (!original) return { upload, delete: del };
278
+ return new Proxy(original, {
279
+ get: (target, prop, receiver) => (prop === "upload" ? upload : prop === "delete" ? del : Reflect.get(target, prop, receiver)),
280
+ has: (target, prop) => prop === "upload" || prop === "delete" || Reflect.has(target, prop),
281
+ });
282
+ }
283
+
284
+ /** Record which submission the files of this request belong to. */
285
+ export async function linkUploads(ctx: FormsCtx, ids: string[]): Promise<number> {
286
+ if (!ids.length) return 0;
287
+ const entries = (await Promise.all(ids.map((id) => ctx.kv.get<UploadEntry>(`${ENTRY_PREFIX}${id}`)))).filter((e): e is UploadEntry => !!e);
288
+ const wanted = new Set(ids.map(privateMediaId));
289
+ const formIds = [...new Set(entries.map((e) => e.formId).filter(Boolean))];
290
+ let linked = 0;
291
+ for (const formId of formIds) {
292
+ const recent = await submissionsOf(ctx).query({ where: { formId }, orderBy: { createdAt: "desc" }, limit: 25 });
293
+ for (const item of recent.items) {
294
+ for (const file of item.data.files ?? []) {
295
+ if (!wanted.has(file.mediaId)) continue;
296
+ const id = privateIdOf(file.mediaId)!;
297
+ const entry = entries.find((e) => e.id === id);
298
+ if (!entry || entry.submissionId === item.id) continue;
299
+ entry.submissionId = item.id;
300
+ await ctx.kv.set(`${ENTRY_PREFIX}${id}`, entry);
301
+ linked++;
302
+ }
303
+ }
304
+ }
305
+ return linked;
306
+ }
307
+
308
+ // ── Admin routes (added to the Forms plugin) ─────────────────────
309
+
310
+ export async function listEntries(ctx: FormsCtx): Promise<UploadEntry[]> {
311
+ const rows = await ctx.kv.list(ENTRY_PREFIX);
312
+ return rows
313
+ .map((row) => row.value as UploadEntry)
314
+ .filter((e) => e && typeof e.id === "string" && UPLOAD_ID.test(e.id))
315
+ .sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt));
316
+ }
317
+
318
+ async function ensureSweepScheduled(ctx: FormsCtx, force = false) {
319
+ if (!ctx.cron) return;
320
+ if (!force && (await ctx.kv.get(SCHEDULED_KEY))) return;
321
+ await ctx.cron.schedule(SWEEP_TASK, { schedule: "@daily" });
322
+ await ctx.kv.set(SCHEDULED_KEY, new Date().toISOString());
323
+ }
324
+
325
+ /** Files of submissions still in the media library (from before the feature was on). */
326
+ async function* mediaLibraryFiles(ctx: FormsCtx, config: FormUploadsConfig) {
327
+ const forms = new Map<string, FormDefinition>();
328
+ let cursor: string | undefined;
329
+ do {
330
+ const page = await submissionsOf(ctx).query({ limit: 100, cursor });
331
+ for (const item of page.items) {
332
+ const files = (item.data.files ?? []).filter((f) => f.mediaId && !privateIdOf(f.mediaId));
333
+ if (!files.length) continue;
334
+ if (!forms.has(item.data.formId)) {
335
+ const def = await formsOf(ctx).get(item.data.formId);
336
+ if (def) forms.set(item.data.formId, def);
337
+ }
338
+ const def = forms.get(item.data.formId);
339
+ if (def && !formSelected(config, { id: item.data.formId, slug: def.slug })) continue;
340
+ yield { submissionId: item.id, submission: item.data, files, form: def ? { id: item.data.formId, def } : undefined };
341
+ }
342
+ cursor = page.hasMore === false ? undefined : page.cursor;
343
+ } while (cursor);
344
+ }
345
+
346
+ async function countMediaLibraryFiles(ctx: FormsCtx, config: FormUploadsConfig): Promise<number> {
347
+ let n = 0;
348
+ for await (const item of mediaLibraryFiles(ctx, config)) n += item.files.length;
349
+ return n;
350
+ }
351
+
352
+ /** Move up to `limit` earlier uploads from the media library into the private bucket. */
353
+ export async function migrateBatch(ctx: FormsCtx, deps: Deps, limit: number) {
354
+ const bucket = await deps.bucket();
355
+ const media = await deps.mediaBucket();
356
+ const db = await deps.database();
357
+ if (!bucket) throw PluginRouteError.badRequest("Add the FORM_UPLOADS R2 binding first.");
358
+ if (!media || !db) throw PluginRouteError.badRequest("The media bucket (MEDIA) or database (DB) binding isn't available.");
359
+ const original = ctx.media;
360
+ const config = await readConfig(ctx);
361
+ let moved = 0;
362
+ const failed: Array<{ submissionId: string; filename: string; reason: string }> = [];
363
+ for await (const item of mediaLibraryFiles(ctx, config)) {
364
+ if (moved + failed.length >= limit) break;
365
+ const files = [...(item.submission.files ?? [])];
366
+ let changed = false;
367
+ for (const [index, file] of files.entries()) {
368
+ if (privateIdOf(file.mediaId) || moved + failed.length >= limit) continue;
369
+ try {
370
+ const row = await db.prepare("SELECT storage_key FROM media WHERE id = ?").bind(file.mediaId).first<{ storage_key: string }>();
371
+ const object = row?.storage_key ? await media.get(row.storage_key) : null;
372
+ if (!object) {
373
+ failed.push({ submissionId: item.submissionId, filename: file.filename, reason: "Not in the media library any more" });
374
+ continue;
375
+ }
376
+ const bytes = new Uint8Array(await object.arrayBuffer());
377
+ const entry = await storePrivate(
378
+ ctx,
379
+ bucket,
380
+ { filename: file.filename, contentType: file.contentType, bytes },
381
+ { form: item.form, fieldName: file.fieldName, submissionId: item.submissionId, source: "media-library" },
382
+ );
383
+ files[index] = { ...file, mediaId: privateMediaId(entry.id) };
384
+ changed = true;
385
+ // Point the submission at the private copy before the public one goes.
386
+ await submissionsOf(ctx).put(item.submissionId, { ...item.submission, files });
387
+ if (original?.delete) await original.delete(file.mediaId);
388
+ moved++;
389
+ } catch (error) {
390
+ failed.push({ submissionId: item.submissionId, filename: file.filename, reason: error instanceof Error ? error.message : String(error) });
391
+ }
392
+ }
393
+ if (changed) item.submission.files = files;
394
+ }
395
+ const remaining = await countMediaLibraryFiles(ctx, config);
396
+ ctx.log?.info("Form uploads moved to the private bucket", { moved, failed: failed.length, remaining });
397
+ return { moved, failed, remaining: Math.max(0, remaining - failed.length) };
398
+ }
399
+
400
+ /** Daily: apply the retention setting, and remove files whose submission is gone or never saved. */
401
+ export async function sweep(ctx: FormsCtx, deps: Deps, now = Date.now()) {
402
+ const config = await readConfig(ctx);
403
+ const entries = await listEntries(ctx);
404
+ const linked = entries.filter((e) => e.submissionId).map((e) => e.submissionId!) as string[];
405
+ const existing = await getManyBatched(submissionsOf(ctx) as never, linked);
406
+ const counts = { retention: 0, orphan: 0, relinked: 0 };
407
+ for (const entry of entries) {
408
+ const decision = sweepDecision(entry, {
409
+ now,
410
+ retentionDays: config.retentionDays,
411
+ submissionExists: entry.submissionId ? existing.has(entry.submissionId) : null,
412
+ });
413
+ if (decision === "keep") continue;
414
+ if (decision === "relink" && (await linkUploads(ctx, [entry.id]))) {
415
+ counts.relinked++;
416
+ continue;
417
+ }
418
+ await deletePrivate(ctx, deps, entry.id);
419
+ counts[decision === "retention" ? "retention" : "orphan"]++;
420
+ }
421
+ if (counts.retention || counts.orphan || counts.relinked) ctx.log?.info("Private form uploads tidied", counts);
422
+ return counts;
423
+ }
424
+
425
+ function adminRoutes(deps: Deps) {
426
+ const route = (def: Record<string, unknown>) => ({ permission: "plugins:manage", ...def });
427
+ return {
428
+ [`${ROUTE_PREFIX}status`]: route({
429
+ methods: ["GET"],
430
+ request: { body: "none" },
431
+ handler: async (ctx: FormsCtx) => {
432
+ const [bucket, mediaBucket, enabled, config] = await Promise.all([deps.bucket(), deps.mediaBucket(), deps.enabled(), readConfig(ctx)]);
433
+ await ensureSweepScheduled(ctx).catch(() => undefined);
434
+ const forms: Array<{ id: string; slug: string; name: string; fileFields: number }> = [];
435
+ let cursor: string | undefined;
436
+ do {
437
+ const page = await formsOf(ctx).query({ limit: 100, cursor });
438
+ for (const item of page.items) {
439
+ forms.push({ id: item.id, slug: item.data.slug, name: item.data.name, fileFields: fieldsOf(item.data).filter((f) => f.type === "file").length });
440
+ }
441
+ cursor = page.hasMore === false ? undefined : page.cursor;
442
+ } while (cursor);
443
+ forms.sort((a, b) => a.name.localeCompare(b.name));
444
+ return {
445
+ wrapped: true,
446
+ enabled,
447
+ bucketBound: !!bucket,
448
+ mediaBucketBound: !!mediaBucket,
449
+ config,
450
+ forms,
451
+ mediaLibraryFiles: await countMediaLibraryFiles(ctx, config),
452
+ };
453
+ },
454
+ }),
455
+
456
+ [`${ROUTE_PREFIX}list`]: route({
457
+ methods: ["GET"],
458
+ request: { body: "none" },
459
+ handler: async (ctx: FormsCtx) => {
460
+ const entries = await listEntries(ctx);
461
+ const submissions = await getManyBatched(submissionsOf(ctx) as never, entries.map((e) => e.submissionId).filter((id): id is string => !!id));
462
+ return {
463
+ items: entries.map((entry) => {
464
+ const sub = entry.submissionId ? (submissions.get(entry.submissionId) as Submission | undefined) : undefined;
465
+ return {
466
+ ...entry,
467
+ submission: sub ? { id: entry.submissionId, createdAt: sub.createdAt, status: sub.status ?? "new", preview: submissionPreview(sub.data) } : null,
468
+ };
469
+ }),
470
+ };
471
+ },
472
+ }),
473
+
474
+ [`${ROUTE_PREFIX}settings`]: route({
475
+ methods: ["POST"],
476
+ request: { body: "json" },
477
+ handler: async (ctx: FormsCtx) => {
478
+ const input = parseInput(
479
+ z.object({
480
+ scope: z.enum(["all", "selected"]),
481
+ forms: z.array(z.string().min(1).max(200)).max(500),
482
+ retentionDays: z.number().int().min(0).max(3650),
483
+ }),
484
+ ctx.input,
485
+ );
486
+ const config = normalizeConfig(input);
487
+ await ctx.kv.set(CONFIG_KEY, config);
488
+ await ensureSweepScheduled(ctx, true).catch((error) => ctx.log?.warn("Couldn't schedule the private uploads cleanup", { error: String(error) }));
489
+ return config;
490
+ },
491
+ }),
492
+
493
+ /** One chunk (4 MB) of a file; the admin page joins the chunks. Raw bytes, always an attachment. */
494
+ [`${ROUTE_PREFIX}download`]: route({
495
+ methods: ["GET"],
496
+ request: { body: "none" },
497
+ response: "raw",
498
+ handler: async (ctx: FormsCtx) => {
499
+ const input = parseInput(z.object({ id: z.string().regex(UPLOAD_ID), part: z.coerce.number().int().min(0).max(100).default(0) }), ctx.input);
500
+ const entry = await ctx.kv.get<UploadEntry>(`${ENTRY_PREFIX}${input.id}`);
501
+ const bucket = await deps.bucket();
502
+ if (!entry || !bucket) throw PluginRouteError.notFound("File not found");
503
+ const head = await bucket.head(entry.key);
504
+ if (!head) throw PluginRouteError.notFound("File not found");
505
+ const range = chunkRange(head.size, input.part);
506
+ if (!range) throw PluginRouteError.badRequest("No such part");
507
+ const object = head.size === 0 ? null : await bucket.get(entry.key, { range: { offset: range.start, length: range.end - range.start + 1 } });
508
+ const bytes = object ? new Uint8Array(await object.arrayBuffer()) : new Uint8Array();
509
+ ctx.log?.info("Private form upload downloaded", { id: entry.id, part: input.part });
510
+ return pluginResponse({
511
+ status: 200,
512
+ headers: {
513
+ "Content-Type": safeContentType(entry.contentType),
514
+ "Content-Disposition": contentDisposition(entry.filename),
515
+ "Content-Range": `bytes ${range.start}-${Math.max(range.start, range.end)}/${head.size}`,
516
+ },
517
+ body: { kind: "bytes", value: bytes },
518
+ });
519
+ },
520
+ }),
521
+
522
+ [`${ROUTE_PREFIX}delete`]: route({
523
+ methods: ["POST"],
524
+ request: { body: "json" },
525
+ handler: async (ctx: FormsCtx) => {
526
+ const input = parseInput(z.object({ id: z.string().regex(UPLOAD_ID) }), ctx.input);
527
+ const deleted = await deletePrivate(ctx, deps, input.id);
528
+ if (!deleted) throw PluginRouteError.notFound("File not found");
529
+ ctx.log?.info("Private form upload deleted", { id: input.id });
530
+ return { deleted: true };
531
+ },
532
+ }),
533
+
534
+ [`${ROUTE_PREFIX}migrate`]: route({
535
+ methods: ["POST"],
536
+ request: { body: "json" },
537
+ handler: async (ctx: FormsCtx) => {
538
+ const input = parseInput(z.object({ limit: z.number().int().min(1).max(50).default(20) }), ctx.input ?? {});
539
+ if (!(await deps.enabled())) throw PluginRouteError.badRequest("Turn on Private form uploads on the Coywolf Pack page first.");
540
+ return migrateBatch(ctx, deps, input.limit);
541
+ },
542
+ }),
543
+ };
544
+ }
545
+
546
+ // ── The wrapper ──────────────────────────────────────────────────
547
+
548
+ // biome-ignore lint/suspicious/noExplicitAny: EmDash's resolved plugin shape.
549
+ type Resolved = { routes?: Record<string, any>; hooks?: Record<string, any>; [key: string]: unknown };
550
+
551
+ function withMedia(ctx: FormsCtx, deps: Deps, state: RequestState): FormsCtx {
552
+ return { ...ctx, media: privateMedia(ctx, deps, state) };
553
+ }
554
+
555
+ /**
556
+ * The Forms plugin with private uploads: every route and hook gets the
557
+ * private `ctx.media`, the daily tidy runs on its cron hook, and the admin
558
+ * routes are added. Its id, storage, admin pages and capabilities are kept.
559
+ */
560
+ export function wrapFormsPlugin<T extends Resolved>(plugin: T, deps: Deps): T {
561
+ const routes: Record<string, unknown> = {};
562
+ for (const [name, route] of Object.entries(plugin.routes ?? {})) {
563
+ routes[name] = {
564
+ ...route,
565
+ handler: async (ctx: FormsCtx) => {
566
+ const state: RequestState = { taken: new Set(), uploaded: [] };
567
+ const result = await route.handler(withMedia(ctx, deps, state));
568
+ if (state.uploaded.length) {
569
+ await linkUploads(ctx, state.uploaded).catch((error) => ctx.log?.warn("Couldn't link private uploads to their submission yet", { error: String(error) }));
570
+ }
571
+ return result;
572
+ },
573
+ };
574
+ }
575
+ Object.assign(routes, adminRoutes(deps));
576
+
577
+ const hooks: Record<string, unknown> = {};
578
+ for (const [name, hook] of Object.entries(plugin.hooks ?? {})) {
579
+ hooks[name] = { ...hook, handler: (event: unknown, ctx: FormsCtx) => hook.handler(event, withMedia(ctx, deps, { taken: new Set(), uploaded: [] })) };
580
+ }
581
+ const cron = hooks.cron as { handler: (event: { name?: string }, ctx: FormsCtx) => Promise<unknown> } | undefined;
582
+ const sweepOr = (next?: (event: { name?: string }, ctx: FormsCtx) => Promise<unknown>) => async (event: { name?: string }, ctx: FormsCtx) => {
583
+ if (event?.name === SWEEP_TASK) return sweep(ctx, deps);
584
+ return next?.(event, ctx);
585
+ };
586
+ hooks.cron = cron
587
+ ? // The tidy can take longer than a hook's default 5 seconds on a big backlog.
588
+ { ...cron, timeout: Math.max(Number((cron as { timeout?: number }).timeout) || 0, 30_000), handler: sweepOr(cron.handler) }
589
+ : { handler: sweepOr(), priority: 100, timeout: 30_000, dependencies: [], errorPolicy: "continue", exclusive: false, pluginId: (plugin as { id?: string }).id };
590
+
591
+ return { ...plugin, routes, hooks } as T;
592
+ }
package/src/forms.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The EmDash Forms plugin with Coywolf Pack's private form uploads. Not
3
+ * imported directly: privateFormUploads() (from "@coywolf/emdash") points the
4
+ * Forms plugin's descriptor here, and EmDash calls createPlugin() with the
5
+ * Forms options plus the pack's. Needs @emdash-cms/plugin-forms installed.
6
+ */
7
+ import { createPlugin as createFormsPlugin } from "@emdash-cms/plugin-forms";
8
+
9
+ import { WRAP_OPTION, defaultDeps, wrapFormsPlugin, type PrivateFormUploadsOptions } from "./formUploads/wrap.js";
10
+
11
+ export function createPlugin(options: Record<string, unknown> = {}) {
12
+ const { [WRAP_OPTION]: ours, ...formsOptions } = options;
13
+ return wrapFormsPlugin(createFormsPlugin(formsOptions), defaultDeps((ours ?? {}) as PrivateFormUploadsOptions));
14
+ }
15
+
16
+ export default createPlugin;