@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,137 @@
1
+ /**
2
+ * Site-side reads and writes for File Downloads, outside the plugin context
3
+ * (the download middleware and the Astro download card). They go straight to
4
+ * D1: plugin settings live in `options` (read with the feature switches),
5
+ * large uploads and download counts in `_plugin_storage`, media library files
6
+ * in `media`. Reads are cached per isolate; download counts are batched and written after the response.
7
+ */
8
+ import { PLUGIN_ID, invalidateFeatures, readSiteSetting, registerSiteSetting } from "../core/features.js";
9
+ import { createDownloadCounter } from "./counts.js";
10
+ import { isUploadId, normalizeBase, safeColor } from "./format.js";
11
+
12
+ export const COLLECTIONS = {
13
+ uploads: "files_uploads",
14
+ usage: "files_usage",
15
+ counts: "files_counts",
16
+ } as const;
17
+
18
+ /** Site settings the card and downloads need (never the S3 credentials). */
19
+ export interface SiteSettings {
20
+ base: string;
21
+ publicBaseUrl: string;
22
+ scheme: "auto" | "light" | "dark";
23
+ accent: string;
24
+ }
25
+
26
+ /** A downloadable file, from either store. */
27
+ export interface FileRecord {
28
+ id: string;
29
+ source: "upload" | "media";
30
+ name: string;
31
+ type: string;
32
+ size: number;
33
+ uploadedAt: string;
34
+ key: string;
35
+ }
36
+
37
+ /** Large-upload metadata in plugin storage. */
38
+ export interface UploadDoc {
39
+ id: string;
40
+ key: string;
41
+ name: string;
42
+ size: number;
43
+ type: string;
44
+ uploadedAt: string;
45
+ status: "uploading" | "ready";
46
+ uploadId?: string;
47
+ partSize?: number;
48
+ }
49
+
50
+ const SETTING_KEYS = ["filesBase", "filesPublicBaseUrl", "filesScheme", "filesAccent"] as const;
51
+ for (const key of SETTING_KEYS) registerSiteSetting(key);
52
+ const FILE_TTL = 60_000;
53
+ const FILE_CACHE_MAX = 500;
54
+
55
+ /** The last stored values and the settings made from them, so a cache hit doesn't rebuild them. */
56
+ let settingsMemo: { raw: unknown[]; value: SiteSettings } | null = null;
57
+ const fileCache = new Map<string, { value: FileRecord | null; at: number }>();
58
+
59
+ /** Forget cached settings and files (after admin changes in this isolate). */
60
+ export function invalidateSiteCache(id?: string): void {
61
+ if (id) fileCache.delete(id);
62
+ else {
63
+ invalidateFeatures();
64
+ fileCache.clear();
65
+ }
66
+ }
67
+
68
+ export function settingsFrom(raw: Record<string, unknown>): SiteSettings {
69
+ const scheme = raw.filesScheme === "light" || raw.filesScheme === "dark" ? raw.filesScheme : "auto";
70
+ const publicBase = typeof raw.filesPublicBaseUrl === "string" ? raw.filesPublicBaseUrl.trim().replace(/\/+$/, "") : "";
71
+ return {
72
+ base: normalizeBase(raw.filesBase),
73
+ publicBaseUrl: /^https:\/\/[^\s"'<>]+$/i.test(publicBase) ? publicBase : "",
74
+ scheme,
75
+ accent: safeColor(raw.filesAccent),
76
+ };
77
+ }
78
+
79
+ /** The settings from D1, in the feature switches' query and per-isolate cache (defaults if it can't be read). */
80
+ export async function readSiteSettings(db: D1Database): Promise<SiteSettings> {
81
+ const reads = await Promise.all(SETTING_KEYS.map((key) => readSiteSetting(key, db)));
82
+ if (reads.some((r) => !r)) console.error("coywolf-pack files: could not read settings");
83
+ const raw = reads.map((r) => r?.value ?? null);
84
+ const memo = settingsMemo;
85
+ if (memo && raw.every((v, i) => v === memo.raw[i])) return memo.value;
86
+ const value = settingsFrom(Object.fromEntries(SETTING_KEYS.map((key, i) => [key, raw[i] ?? undefined])));
87
+ settingsMemo = { raw, value };
88
+ return value;
89
+ }
90
+
91
+ export function uploadToRecord(doc: UploadDoc): FileRecord {
92
+ return { id: doc.id, source: "upload", name: doc.name, type: doc.type, size: doc.size, uploadedAt: doc.uploadedAt, key: doc.key };
93
+ }
94
+
95
+ /** Look up a file by id: a ready large upload, or a ready media library item. Null when missing. */
96
+ export async function resolveFile(db: D1Database, id: string): Promise<FileRecord | null> {
97
+ const hit = fileCache.get(id);
98
+ if (hit && Date.now() - hit.at < FILE_TTL) return hit.value;
99
+ let value: FileRecord | null = null;
100
+ if (isUploadId(id)) {
101
+ const row = await db
102
+ .prepare("SELECT data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?")
103
+ .bind(PLUGIN_ID, COLLECTIONS.uploads, id)
104
+ .first<{ data: string }>();
105
+ const doc = row ? (JSON.parse(row.data) as UploadDoc) : null;
106
+ value = doc && doc.status === "ready" ? uploadToRecord(doc) : null;
107
+ } else {
108
+ const row = await db
109
+ .prepare("SELECT id, filename, mime_type, size, storage_key, created_at, status FROM media WHERE id = ?")
110
+ .bind(id)
111
+ .first<{ id: string; filename: string; mime_type: string; size: number | null; storage_key: string; created_at: string; status: string | null }>();
112
+ value =
113
+ row && (row.status ?? "ready") === "ready"
114
+ ? {
115
+ id: row.id,
116
+ source: "media",
117
+ name: row.filename,
118
+ type: row.mime_type,
119
+ size: row.size ?? 0,
120
+ uploadedAt: row.created_at,
121
+ key: row.storage_key,
122
+ }
123
+ : null;
124
+ }
125
+ if (fileCache.size >= FILE_CACHE_MAX) fileCache.delete(fileCache.keys().next().value as string);
126
+ fileCache.set(id, { value, at: Date.now() });
127
+ return value;
128
+ }
129
+
130
+ // ── Download counts ──────────────────────────────────────────────
131
+
132
+ const counter = createDownloadCounter(PLUGIN_ID, COLLECTIONS.counts);
133
+
134
+ /** Count a download; counts within a second in this isolate are written together after the response. */
135
+ export function countDownload(db: D1Database, id: string, waitUntil: (p: Promise<unknown>) => void): void {
136
+ counter.count(db, id, waitUntil);
137
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Find coywolf-file blocks in saved content. Portable Text can sit in any
3
+ * field and nest (columns, sections, repeaters), so the walk is generic:
4
+ * every object with `_type: "coywolf-file"` and a string `id` counts.
5
+ * No imports, so `node --test` can load this file directly.
6
+ */
7
+
8
+ export const BLOCK_TYPE = "coywolf-file";
9
+
10
+ export interface FileBlockRef {
11
+ /** File id (large-upload id or media library id). */
12
+ id: string;
13
+ /** Top-level field the block was found in. */
14
+ field: string;
15
+ }
16
+
17
+ const MAX_DEPTH = 64;
18
+
19
+ /** Every coywolf-file block in a content record, in document order. */
20
+ export function findFileBlocks(content: unknown): FileBlockRef[] {
21
+ const out: FileBlockRef[] = [];
22
+ const seen = new WeakSet<object>();
23
+ // The field is the first key below the record (or below `data`, where EmDash keeps fields).
24
+ const visit = (value: unknown, path: string[], depth: number) => {
25
+ if (depth > MAX_DEPTH || value === null || typeof value !== "object") return;
26
+ if (seen.has(value)) return;
27
+ seen.add(value);
28
+ if (Array.isArray(value)) {
29
+ for (const item of value) visit(item, path, depth + 1);
30
+ return;
31
+ }
32
+ const record = value as Record<string, unknown>;
33
+ if (record._type === BLOCK_TYPE && typeof record.id === "string" && record.id.trim()) {
34
+ out.push({ id: record.id.trim(), field: (path[0] === "data" ? path[1] : path[0]) ?? "" });
35
+ }
36
+ for (const [key, child] of Object.entries(record)) {
37
+ if (child && typeof child === "object") visit(child, path.length < 2 ? [...path, key] : path, depth + 1);
38
+ }
39
+ };
40
+ visit(content, [], 0);
41
+ return out;
42
+ }
43
+
44
+ /** Distinct file ids referenced by a content record. */
45
+ export function fileIdsIn(content: unknown): string[] {
46
+ return [...new Set(findFileBlocks(content).map((b) => b.id))];
47
+ }
48
+
49
+ /** A readable title for a content record (EmDash puts fields under `data`). */
50
+ export function entryTitle(content: Record<string, unknown>): string {
51
+ const data = (content.data && typeof content.data === "object" ? content.data : content) as Record<string, unknown>;
52
+ for (const key of ["title", "name", "headline"]) {
53
+ const value = data[key] ?? content[key];
54
+ if (typeof value === "string" && value.trim()) return value.trim().slice(0, 200);
55
+ }
56
+ if (typeof content.slug === "string" && content.slug) return content.slug;
57
+ return String(content.id ?? "Untitled");
58
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Private form uploads: the pure parts (no EmDash or Cloudflare imports, so
3
+ * tests run them directly). See ./wrap.ts for how the Forms plugin is wrapped.
4
+ */
5
+
6
+ /** Prefix of the media id the Forms plugin stores for a file kept in the private bucket. */
7
+ export const PRIVATE_PREFIX = "coywolf-private:";
8
+ /** Forms plugin KV: one entry per private file (the index the admin page lists). */
9
+ export const ENTRY_PREFIX = "coywolf-private-upload:";
10
+ /** Forms plugin KV: which forms keep their files private, and for how long. */
11
+ export const CONFIG_KEY = "coywolf-private-uploads:config";
12
+ /** Cron task (on the Forms plugin) that tidies files whose submission is gone, and applies the retention setting. */
13
+ export const SWEEP_TASK = "coywolf-private-uploads";
14
+ /** Route names added to the Forms plugin, under /_emdash/api/plugins/emdash-forms/. */
15
+ export const ROUTE_PREFIX = "coywolf-private-uploads/";
16
+ /** The Forms plugin's own cap for one file; enforced again here in case a future version raises it. */
17
+ export const MAX_FILE_BYTES = 10 * 1024 * 1024;
18
+ /** Download chunk: plugin route responses are capped at 8 MB. */
19
+ export const CHUNK_BYTES = 4 * 1024 * 1024;
20
+ /** A file with no submission after this long belongs to a submission that failed to save. */
21
+ export const ORPHAN_AFTER_MS = 60 * 60_000;
22
+
23
+ export interface FormUploadsConfig {
24
+ /** "all": every form's files are private. "selected": only the forms in `forms` (form ids). */
25
+ scope: "all" | "selected";
26
+ forms: string[];
27
+ /** Delete private files this many days after upload (the submission stays). 0 keeps them until the submission is deleted. */
28
+ retentionDays: number;
29
+ }
30
+
31
+ export const DEFAULT_CONFIG: FormUploadsConfig = { scope: "all", forms: [], retentionDays: 0 };
32
+
33
+ export function normalizeConfig(value: unknown): FormUploadsConfig {
34
+ const v = (value && typeof value === "object" ? value : {}) as Partial<FormUploadsConfig>;
35
+ const forms = Array.isArray(v.forms) ? [...new Set(v.forms.filter((f): f is string => typeof f === "string" && f.length > 0 && f.length <= 200))].slice(0, 500) : [];
36
+ const days = Number(v.retentionDays);
37
+ return {
38
+ scope: v.scope === "selected" ? "selected" : "all",
39
+ forms,
40
+ retentionDays: Number.isInteger(days) && days > 0 ? Math.min(days, 3650) : 0,
41
+ };
42
+ }
43
+
44
+ /** Whether a form's files go to the private bucket. Matches the form's id or slug. */
45
+ export function formSelected(config: FormUploadsConfig, form: { id: string; slug?: string }): boolean {
46
+ if (config.scope === "all") return true;
47
+ return config.forms.includes(form.id) || (!!form.slug && config.forms.includes(form.slug));
48
+ }
49
+
50
+ /** One private file, as kept in the Forms plugin's KV. */
51
+ export interface UploadEntry {
52
+ id: string;
53
+ /** Object key in the private bucket. */
54
+ key: string;
55
+ formId: string;
56
+ formSlug: string;
57
+ formName: string;
58
+ fieldName: string;
59
+ fieldLabel: string;
60
+ /** Sanitized file name (what the download is saved as). */
61
+ filename: string;
62
+ /** Type the visitor's browser reported (shown to admins; never served as-is). */
63
+ contentType: string;
64
+ size: number;
65
+ uploadedAt: string;
66
+ /** The Forms submission it belongs to; null until the submission is saved. */
67
+ submissionId: string | null;
68
+ /** "media-library" when moved by the one-time migration. */
69
+ source?: "form" | "media-library";
70
+ }
71
+
72
+ export const privateMediaId = (id: string) => `${PRIVATE_PREFIX}${id}`;
73
+
74
+ /** The upload id in a private media id, or null for an ordinary media-library id. */
75
+ export function privateIdOf(mediaId: unknown): string | null {
76
+ if (typeof mediaId !== "string" || !mediaId.startsWith(PRIVATE_PREFIX)) return null;
77
+ const id = mediaId.slice(PRIVATE_PREFIX.length);
78
+ return UPLOAD_ID.test(id) ? id : null;
79
+ }
80
+
81
+ export const UPLOAD_ID = /^[a-f0-9]{32}$/;
82
+
83
+ export function newUploadId(): string {
84
+ return [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, "0")).join("");
85
+ }
86
+
87
+ export const objectKey = (id: string) => `uploads/${id}`;
88
+
89
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
90
+ const MAX_NAME = 120;
91
+
92
+ /**
93
+ * A file name that's safe to show, store and save: the last path segment,
94
+ * without control characters, characters Windows or macOS refuse
95
+ * (\ / : * ? " < > |), leading dots (hidden files) or trailing dots and
96
+ * spaces, at most 120 characters with the extension kept. Falls back to
97
+ * "upload".
98
+ */
99
+ export function sanitizeFilename(name: unknown): string {
100
+ let base = String(name ?? "")
101
+ .normalize("NFC")
102
+ .split(/[\\/]/)
103
+ .pop()!
104
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control characters is the point.
105
+ .replace(/[\u0000-\u001f\u007f-\u009f​-‏‪-‮⁦-⁩]/g, "")
106
+ .replace(/[:*?"<>|]/g, "_")
107
+ .replace(/\s+/g, " ")
108
+ .trim()
109
+ .replace(/^[.\s]+/, "")
110
+ .replace(/[.\s]+$/, "");
111
+ const dot = base.lastIndexOf(".");
112
+ let stem = dot > 0 ? base.slice(0, dot) : base;
113
+ let ext = dot > 0 ? base.slice(dot + 1) : "";
114
+ if (ext.length > 16 || !/^[\p{L}\p{N}_-]+$/u.test(ext)) {
115
+ stem = base;
116
+ ext = "";
117
+ }
118
+ if (WINDOWS_RESERVED.test(stem)) stem = `_${stem}`;
119
+ const room = MAX_NAME - (ext ? ext.length + 1 : 0);
120
+ if ([...stem].length > room) stem = [...stem].slice(0, room).join("").trimEnd();
121
+ base = ext ? `${stem}.${ext}` : stem;
122
+ return base && base !== "." ? base : "upload";
123
+ }
124
+
125
+ /**
126
+ * Types a download may be labeled with. Anything else (HTML, SVG, XML,
127
+ * JavaScript, CSS, unknown types) goes out as application/octet-stream, so
128
+ * a browser never renders an uploaded file as a page or runs it.
129
+ */
130
+ const SAFE_TYPES = new Set([
131
+ "application/pdf",
132
+ "application/zip",
133
+ "application/gzip",
134
+ "application/json",
135
+ "application/msword",
136
+ "application/rtf",
137
+ "application/vnd.ms-excel",
138
+ "application/vnd.ms-powerpoint",
139
+ "application/vnd.oasis.opendocument.text",
140
+ "application/vnd.oasis.opendocument.spreadsheet",
141
+ "application/vnd.oasis.opendocument.presentation",
142
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
143
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
144
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
145
+ "image/png",
146
+ "image/jpeg",
147
+ "image/gif",
148
+ "image/webp",
149
+ "image/avif",
150
+ "image/heic",
151
+ "image/heif",
152
+ "image/bmp",
153
+ "image/tiff",
154
+ "text/plain",
155
+ "text/csv",
156
+ "audio/mpeg",
157
+ "audio/mp4",
158
+ "audio/ogg",
159
+ "audio/wav",
160
+ "audio/webm",
161
+ "video/mp4",
162
+ "video/quicktime",
163
+ "video/webm",
164
+ "video/ogg",
165
+ ]);
166
+
167
+ export function safeContentType(type: unknown): string {
168
+ const mime = String(type ?? "")
169
+ .split(";", 1)[0]!
170
+ .trim()
171
+ .toLowerCase();
172
+ return SAFE_TYPES.has(mime) ? mime : "application/octet-stream";
173
+ }
174
+
175
+ /**
176
+ * Types a form may store: the same as EmDash's media library accepts from a
177
+ * plugin upload (images, video, audio, PDF), so keeping files private never
178
+ * lets a form take files it couldn't take before. The type is the one the
179
+ * visitor's browser reported; the field's own `accept` list is checked by
180
+ * the Forms plugin first.
181
+ */
182
+ const UPLOAD_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/jxl", "application/pdf"];
183
+ const UPLOAD_PREFIXES = ["video/", "audio/"];
184
+ const MIME = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
185
+
186
+ export function uploadTypeAllowed(type: unknown): boolean {
187
+ const mime = String(type ?? "")
188
+ .split(";", 1)[0]!
189
+ .trim()
190
+ .toLowerCase();
191
+ if (!MIME.test(mime)) return false;
192
+ return UPLOAD_TYPES.includes(mime) || UPLOAD_PREFIXES.some((p) => mime.startsWith(p));
193
+ }
194
+
195
+ /** "1.2 MB" */
196
+ export function formatBytes(bytes: number): string {
197
+ if (!Number.isFinite(bytes) || bytes < 1024) return `${Math.max(0, Math.round(bytes || 0))} B`;
198
+ const units = ["KB", "MB", "GB"];
199
+ let value = bytes / 1024;
200
+ let unit = 0;
201
+ while (value >= 1024 && unit < units.length - 1) {
202
+ value /= 1024;
203
+ unit++;
204
+ }
205
+ return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
206
+ }
207
+
208
+ /** Byte range of download chunk `part` of a file of `size` bytes, or null past the end. */
209
+ export function chunkRange(size: number, part: number): { start: number; end: number } | null {
210
+ if (!Number.isInteger(part) || part < 0) return null;
211
+ const start = part * CHUNK_BYTES;
212
+ if (start >= size && !(size === 0 && part === 0)) return null;
213
+ return { start, end: Math.min(size, start + CHUNK_BYTES) - 1 };
214
+ }
215
+
216
+ /** Total size from a "bytes 0-99/1234" Content-Range header. */
217
+ export function totalFromContentRange(header: string | null): number | null {
218
+ const match = /\/(\d+)\s*$/.exec(header ?? "");
219
+ return match ? Number(match[1]) : null;
220
+ }
221
+
222
+ /**
223
+ * Which of the submitted files a Forms upload call is for. The Forms plugin
224
+ * uploads files one by one with (filename, type, bytes) and no field name,
225
+ * so match on name and size, skipping fields already matched.
226
+ */
227
+ export function matchField(
228
+ files: Record<string, { filename: string; contentType?: string; bytes: { byteLength: number } }> | undefined,
229
+ filename: string,
230
+ size: number,
231
+ taken: Set<string>,
232
+ ): string {
233
+ for (const [field, file] of Object.entries(files ?? {})) {
234
+ if (taken.has(field)) continue;
235
+ if (file.filename === filename && file.bytes.byteLength === size) {
236
+ taken.add(field);
237
+ return field;
238
+ }
239
+ }
240
+ return "";
241
+ }
242
+
243
+ /** A short line from a submission's answers, so admins can tell submissions apart. */
244
+ export function submissionPreview(data: Record<string, unknown> | undefined, max = 140): string {
245
+ const parts: string[] = [];
246
+ for (const [key, value] of Object.entries(data ?? {})) {
247
+ if (key.startsWith("_") || key === "cf-turnstile-response") continue;
248
+ const text = Array.isArray(value) ? value.join(", ") : value === null || value === undefined ? "" : String(value);
249
+ const clean = text.replace(/\s+/g, " ").trim();
250
+ if (clean) parts.push(clean);
251
+ if (parts.join(" · ").length >= max) break;
252
+ }
253
+ const line = parts.join(" · ");
254
+ return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
255
+ }
256
+
257
+ /** Entries the daily sweep deletes: past retention, orphaned, or whose submission is gone. */
258
+ export function sweepDecision(
259
+ entry: UploadEntry,
260
+ opts: { now: number; retentionDays: number; submissionExists: boolean | null },
261
+ ): "keep" | "retention" | "orphan" | "relink" {
262
+ const age = opts.now - Date.parse(entry.uploadedAt);
263
+ if (opts.retentionDays > 0 && age > opts.retentionDays * 86_400_000) return "retention";
264
+ if (entry.submissionId) return opts.submissionExists === false ? "orphan" : "keep";
265
+ return age > ORPHAN_AFTER_MS ? "relink" : "keep";
266
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Private form uploads module: the feature switch and the Form uploads admin
3
+ * page. The work happens in the Forms plugin itself, wrapped by
4
+ * privateFormUploads() (see ./wrap.ts), so the page talks to routes on the
5
+ * Forms plugin (/_emdash/api/plugins/emdash-forms/coywolf-private-uploads/*).
6
+ */
7
+ import type { PluginDescriptor } from "emdash";
8
+
9
+ import { registerFeatures } from "../core/features.js";
10
+ import type { PackModule } from "../core/module.js";
11
+ import { FEATURE, WRAP_OPTION, type PrivateFormUploadsOptions } from "./wrap.js";
12
+
13
+ export type { PrivateFormUploadsOptions } from "./wrap.js";
14
+
15
+ export const FEATURES = [
16
+ {
17
+ id: FEATURE,
18
+ label: "Private form uploads",
19
+ description:
20
+ "Files sent through Forms plugin forms go to a private storage bucket that only admins can open, instead of the public media library.",
21
+ default: false,
22
+ },
23
+ ];
24
+ registerFeatures(FEATURES);
25
+
26
+ export function formUploadsPack(): PackModule {
27
+ return {
28
+ id: "formUploads",
29
+ label: "Private form uploads",
30
+ features: FEATURES,
31
+ adminPages: [{ path: "/form-uploads", label: "Form uploads", icon: "lock" }],
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Wrap the Forms plugin's descriptor so its file uploads can be kept private:
37
+ *
38
+ * ```js
39
+ * import { coywolfPlugin, privateFormUploads } from "@coywolf/emdash";
40
+ * import { formsPlugin } from "@emdash-cms/plugin-forms";
41
+ * plugins: [coywolfPlugin({...}), privateFormUploads(formsPlugin())]
42
+ * ```
43
+ *
44
+ * The plugin keeps its id, storage, admin pages and settings; only its code
45
+ * entry changes to "@coywolf/emdash/forms", which runs the Forms plugin with
46
+ * private uploads. Nothing changes until the feature is turned on.
47
+ */
48
+ export function privateFormUploads<T extends object>(forms: PluginDescriptor<T>, options: PrivateFormUploadsOptions = {}): PluginDescriptor<T> {
49
+ if (forms.id !== "emdash-forms") throw new Error(`privateFormUploads() wraps the EmDash Forms plugin (emdash-forms), not "${forms.id}".`);
50
+ return {
51
+ ...forms,
52
+ entrypoint: "@coywolf/emdash/forms",
53
+ options: { ...(forms.options ?? {}), [WRAP_OPTION]: options } as T,
54
+ };
55
+ }