@coywolf/emdash 0.0.0-stage → 0.35.14

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 (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pure R2 helpers: multipart part sizing and minimal XML reading. No imports, so
3
+ * `node --test` can load this file.
4
+ */
5
+
6
+ /**
7
+ * Every part goes through the site (files/upload-part; EmDash's admin CSP blocks the
8
+ * browser from reaching R2), so parts must fit a plugin route body (8 MiB). 6 MiB is
9
+ * above R2's 5 MiB minimum and leaves headroom.
10
+ */
11
+ export const PART_SIZE = 6 * 1024 * 1024;
12
+ const MAX_PARTS = 10_000;
13
+ /** The largest file that fits in 10,000 parts of PART_SIZE (just under 60 GB). */
14
+ export const MAX_THROUGH_SITE = PART_SIZE * MAX_PARTS;
15
+
16
+ /** Equal-sized parts (R2 requires every part but the last to be the same size). */
17
+ export function partSizeFor(_size: number): number {
18
+ return PART_SIZE;
19
+ }
20
+
21
+ export function xmlValue(xml: string, tag: string): string | undefined {
22
+ const match = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml);
23
+ return match ? decodeXml(match[1]) : undefined;
24
+ }
25
+
26
+ export function xmlValues(xml: string, tag: string): string[] {
27
+ return [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))].map((m) => decodeXml(m[1]));
28
+ }
29
+
30
+ function decodeXml(value: string): string {
31
+ return value
32
+ .replace(/&lt;/g, "<")
33
+ .replace(/&gt;/g, ">")
34
+ .replace(/&quot;/g, '"')
35
+ .replace(/&apos;/g, "'")
36
+ .replace(/&amp;/g, "&");
37
+ }
38
+
39
+ export function encodeXml(value: string): string {
40
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
41
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * HTTP byte ranges for downloads (RFC 9110 §14). Only single ranges are
3
+ * served; multiple ranges, malformed headers and a mismatched If-Range fall
4
+ * back to the full file (200). No imports, so `node --test` can load this file.
5
+ */
6
+
7
+ export type ByteRange = { offset: number; end?: number } | { suffix: number };
8
+
9
+ /** Parse a Range header. Null means "ignore it and send the whole file". */
10
+ export function parseRange(header: string | null | undefined): ByteRange | null {
11
+ if (!header) return null;
12
+ const match = /^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$/i.exec(header);
13
+ if (!match) return null; // Malformed, another unit, or several ranges.
14
+ const [, first, last] = match;
15
+ if (first === "" && last === "") return null;
16
+ if (first === "") {
17
+ const suffix = Number(last);
18
+ return suffix > 0 ? { suffix } : null;
19
+ }
20
+ const offset = Number(first);
21
+ if (last === "") return { offset };
22
+ const end = Number(last);
23
+ return end >= offset ? { offset, end } : null;
24
+ }
25
+
26
+ /** The bytes a range covers in a file of `size`, clamped; null when unsatisfiable. */
27
+ export function resolveRange(range: ByteRange, size: number): { offset: number; length: number } | null {
28
+ if ("suffix" in range) {
29
+ if (size === 0) return null;
30
+ const length = Math.min(range.suffix, size);
31
+ return { offset: size - length, length };
32
+ }
33
+ if (range.offset >= size) return null;
34
+ const end = Math.min(range.end ?? size - 1, size - 1);
35
+ return { offset: range.offset, length: end - range.offset + 1 };
36
+ }
37
+
38
+ /**
39
+ * Whether an If-Range validator still matches the file. A strong ETag must
40
+ * match exactly; a date must equal Last-Modified (to the second). Weak ETags
41
+ * never match.
42
+ */
43
+ export function ifRangeMatches(ifRange: string | null | undefined, etag: string, lastModified: Date): boolean {
44
+ if (!ifRange) return true;
45
+ const value = ifRange.trim();
46
+ if (value.startsWith("W/")) return false;
47
+ if (value.startsWith('"')) return value === etag;
48
+ const date = Date.parse(value);
49
+ return !Number.isNaN(date) && Math.floor(date / 1000) === Math.floor(lastModified.getTime() / 1000);
50
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The few R2 S3-API calls large uploads need: start, upload a part of, complete and
3
+ * abort a multipart upload, and delete an object. Requests are signed with ./sigv4.ts.
4
+ */
5
+ import { encodeXml, xmlValue } from "./r2-rules.js";
6
+ import { EMPTY_SHA256, type SigV4Credentials, UNSIGNED_PAYLOAD, sha256Hex, signRequest } from "./sigv4.js";
7
+
8
+ export { MAX_THROUGH_SITE, PART_SIZE, partSizeFor } from "./r2-rules.js";
9
+
10
+ export interface R2Config extends SigV4Credentials {
11
+ accountId: string;
12
+ bucket: string;
13
+ }
14
+
15
+ type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
16
+
17
+ export function r2Host(accountId: string): string {
18
+ return `${accountId}.r2.cloudflarestorage.com`;
19
+ }
20
+
21
+ export class S3Error extends Error {
22
+ readonly status: number;
23
+ readonly code?: string;
24
+ constructor(message: string, status: number, code?: string) {
25
+ super(message);
26
+ this.status = status;
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ export function r2Client(config: R2Config, fetchImpl: Fetch) {
32
+ const host = r2Host(config.accountId);
33
+ const objectPath = (key: string) => `/${config.bucket}/${key}`;
34
+
35
+ async function call(
36
+ method: string,
37
+ path: string,
38
+ options: { query?: Record<string, string>; headers?: Record<string, string>; body?: string | Uint8Array } = {},
39
+ ): Promise<{ status: number; text: string; headers: Headers }> {
40
+ // A part's bytes go unsigned (TLS protects them; hashing megabytes per request costs CPU).
41
+ const payloadHash = options.body instanceof Uint8Array ? UNSIGNED_PAYLOAD : options.body ? await sha256Hex(options.body) : EMPTY_SHA256;
42
+ const signed = await signRequest(
43
+ { method, host, path, query: options.query, headers: options.headers, region: "auto" },
44
+ config,
45
+ payloadHash,
46
+ );
47
+ const response = await fetchImpl(signed.url, { method, headers: signed.headers, body: options.body instanceof Uint8Array ? new Uint8Array(options.body) : options.body });
48
+ const text = await response.text();
49
+ if (!response.ok) {
50
+ const code = xmlValue(text, "Code");
51
+ const message = xmlValue(text, "Message") ?? `R2 answered ${response.status}`;
52
+ throw new S3Error(code ? `${code}: ${message}` : message, response.status, code);
53
+ }
54
+ return { status: response.status, text, headers: response.headers };
55
+ }
56
+
57
+ return {
58
+ async createMultipart(key: string, contentType: string): Promise<string> {
59
+ const { text } = await call("POST", objectPath(key), {
60
+ query: { uploads: "" },
61
+ headers: { "content-type": contentType || "application/octet-stream" },
62
+ });
63
+ const uploadId = xmlValue(text, "UploadId");
64
+ if (!uploadId) throw new S3Error("R2 didn't return an upload ID.", 502);
65
+ return uploadId;
66
+ },
67
+
68
+ /** Uploads one part; returns its ETag (for completeMultipart). */
69
+ async uploadPart(key: string, uploadId: string, partNumber: number, bytes: Uint8Array): Promise<string> {
70
+ const { headers } = await call("PUT", objectPath(key), { query: { partNumber: String(partNumber), uploadId }, body: bytes });
71
+ const etag = headers.get("etag");
72
+ if (!etag) throw new S3Error("R2 didn't return an ETag for the part.", 502);
73
+ return etag;
74
+ },
75
+
76
+ async completeMultipart(key: string, uploadId: string, parts: Array<{ partNumber: number; etag: string }>): Promise<string | undefined> {
77
+ const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber);
78
+ const body = `<CompleteMultipartUpload>${sorted
79
+ .map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${encodeXml(p.etag)}</ETag></Part>`)
80
+ .join("")}</CompleteMultipartUpload>`;
81
+ const { text } = await call("POST", objectPath(key), {
82
+ query: { uploadId },
83
+ headers: { "content-type": "application/xml" },
84
+ body,
85
+ });
86
+ // S3 can answer 200 with an <Error> body.
87
+ if (text.includes("<Error>")) throw new S3Error(xmlValue(text, "Message") ?? "Completing the upload failed.", 500, xmlValue(text, "Code"));
88
+ return xmlValue(text, "ETag");
89
+ },
90
+
91
+ async abortMultipart(key: string, uploadId: string): Promise<void> {
92
+ try {
93
+ await call("DELETE", objectPath(key), { query: { uploadId } });
94
+ } catch (error) {
95
+ if (!(error instanceof S3Error && error.status === 404)) throw error;
96
+ }
97
+ },
98
+
99
+ async headObject(key: string): Promise<{ size: number; etag: string | null } | null> {
100
+ try {
101
+ const { headers } = await call("HEAD", objectPath(key));
102
+ return { size: Number(headers.get("content-length") ?? 0), etag: headers.get("etag") };
103
+ } catch (error) {
104
+ if (error instanceof S3Error && error.status === 404) return null;
105
+ throw error;
106
+ }
107
+ },
108
+
109
+ async deleteObject(key: string): Promise<void> {
110
+ await call("DELETE", objectPath(key));
111
+ },
112
+ };
113
+ }
114
+
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Serves stable download URLs, /<base>/<id>/<filename>, through the pack
3
+ * middleware. The file streams from R2 with Content-Disposition: attachment
4
+ * (or redirects to a public bucket / CDN URL), with ETag, conditional
5
+ * requests and byte ranges. Downloads are counted after the response.
6
+ */
7
+ import { contentDisposition, downloadPath, parseDownloadPath } from "./format.js";
8
+ import { type ByteRange, ifRangeMatches, parseRange, resolveRange } from "./range.js";
9
+ import { type FileRecord, countDownload, readSiteSettings, resolveFile } from "./site.js";
10
+
11
+ export interface FilesSiteOptions {
12
+ /** D1 binding of the site database. Default "DB". */
13
+ database?: string;
14
+ /** R2 binding of the media bucket. Default "MEDIA". */
15
+ media?: string;
16
+ /** R2 binding of the bucket large uploads go to. Default: FILES when bound, else the media bucket. */
17
+ uploads?: string;
18
+ /** Count downloads (the files.counts feature). */
19
+ count?: boolean;
20
+ }
21
+
22
+ /** The R2 binding large uploads live in. */
23
+ export function uploadsBindingName(env: Record<string, unknown>, options: { media?: string; uploads?: string }): string {
24
+ return options.uploads ?? (env.FILES ? "FILES" : (options.media ?? "MEDIA"));
25
+ }
26
+
27
+ const NOT_FOUND = () =>
28
+ new Response("File not found", { status: 404, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" } });
29
+
30
+ /** Whether a request should count as a download (not HEAD, not a later range chunk, not a 304). */
31
+ function countsAsDownload(request: Request, range: ByteRange | null): boolean {
32
+ return request.method === "GET" && (!range || ("offset" in range && range.offset === 0));
33
+ }
34
+
35
+ export async function serveDownload(
36
+ request: Request,
37
+ url: URL,
38
+ env: Record<string, unknown>,
39
+ waitUntil: (p: Promise<unknown>) => void,
40
+ options: FilesSiteOptions = {},
41
+ ): Promise<Response | undefined> {
42
+ if (request.method !== "GET" && request.method !== "HEAD") return undefined;
43
+ if (url.pathname.startsWith("/_")) return undefined;
44
+ const db = env[options.database ?? "DB"] as D1Database | undefined;
45
+ if (!db) return undefined;
46
+
47
+ const settings = await readSiteSettings(db);
48
+ const parsed = parseDownloadPath(url.pathname, settings.base);
49
+ if (!parsed) return undefined;
50
+
51
+ const file = await resolveFile(db, parsed.id);
52
+ if (!file) return NOT_FOUND();
53
+
54
+ const canonical = downloadPath(settings.base, file.id, file.name);
55
+ if (!parsed.filename) return Response.redirect(new URL(canonical, url).href, 301);
56
+
57
+ if (settings.publicBaseUrl) {
58
+ if (options.count && countsAsDownload(request, parseRange(request.headers.get("Range")))) countDownload(db, file.id, waitUntil);
59
+ const target = `${settings.publicBaseUrl}/${file.key.split("/").map(encodeURIComponent).join("/")}`;
60
+ return new Response(null, { status: 302, headers: { Location: target, "Cache-Control": "no-store" } });
61
+ }
62
+
63
+ const bucketName = file.source === "upload" ? uploadsBindingName(env, options) : (options.media ?? "MEDIA");
64
+ const bucket = env[bucketName] as R2Bucket | undefined;
65
+ if (!bucket) {
66
+ console.error(`coywolf-pack files: R2 binding ${bucketName} is missing`);
67
+ return NOT_FOUND();
68
+ }
69
+ const count = options.count ? () => countDownload(db, file.id, waitUntil) : () => undefined;
70
+ return streamObject(request, bucket, file, count);
71
+ }
72
+
73
+ async function streamObject(request: Request, bucket: R2Bucket, file: FileRecord, count: () => void) {
74
+ const headers = new Headers({
75
+ "Content-Type": file.type || "application/octet-stream",
76
+ "Content-Disposition": contentDisposition(file.name),
77
+ "Accept-Ranges": "bytes",
78
+ "Cache-Control": "public, max-age=0, must-revalidate",
79
+ "X-Content-Type-Options": "nosniff",
80
+ });
81
+
82
+ if (request.method === "HEAD") {
83
+ const head = await bucket.head(file.key);
84
+ if (!head) return NOT_FOUND();
85
+ headers.set("ETag", head.httpEtag);
86
+ headers.set("Content-Length", String(head.size));
87
+ headers.set("Last-Modified", head.uploaded.toUTCString());
88
+ return new Response(null, { status: 200, headers });
89
+ }
90
+
91
+ // One range at most; several ranges or a malformed header get the whole file.
92
+ let range = parseRange(request.headers.get("Range"));
93
+ if (range && request.headers.has("If-Range")) {
94
+ const head = await bucket.head(file.key);
95
+ if (!head) return NOT_FOUND();
96
+ if (!ifRangeMatches(request.headers.get("If-Range"), head.httpEtag, head.uploaded)) range = null;
97
+ }
98
+
99
+ let object: R2Object | R2ObjectBody | null;
100
+ try {
101
+ object = await bucket.get(file.key, {
102
+ onlyIf: request.headers,
103
+ ...(range ? { range: "suffix" in range ? { suffix: range.suffix } : { offset: range.offset, ...(range.end !== undefined ? { length: range.end - range.offset + 1 } : {}) } } : {}),
104
+ });
105
+ } catch (error) {
106
+ if (!range) throw error;
107
+ if ("suffix" in range) {
108
+ // A suffix longer than the file means the whole file (sent as a 206 of every byte).
109
+ object = await bucket.get(file.key, { onlyIf: request.headers });
110
+ } else {
111
+ // R2 rejects a range that starts past the end.
112
+ const head = await bucket.head(file.key);
113
+ return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${head?.size ?? 0}` } });
114
+ }
115
+ }
116
+ if (!object) return NOT_FOUND();
117
+ headers.set("ETag", object.httpEtag);
118
+ headers.set("Last-Modified", object.uploaded.toUTCString());
119
+
120
+ // A precondition failed (If-None-Match matched, or If-Match didn't): R2 returns the object without a body.
121
+ if (!("body" in object) || !object.body) {
122
+ const matched = request.headers.has("If-None-Match") || request.headers.has("If-Modified-Since");
123
+ return new Response(null, { status: matched ? 304 : 412, headers });
124
+ }
125
+
126
+ if (countsAsDownload(request, range)) count();
127
+
128
+ const body = object as R2ObjectBody;
129
+ if (range) {
130
+ const bytes = resolveRange(range, body.size);
131
+ if (!bytes) {
132
+ await body.body.cancel();
133
+ return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${body.size}` } });
134
+ }
135
+ headers.set("Content-Range", `bytes ${bytes.offset}-${bytes.offset + bytes.length - 1}/${body.size}`);
136
+ headers.set("Content-Length", String(bytes.length));
137
+ return new Response(body.body, { status: 206, headers });
138
+ }
139
+ headers.set("Content-Length", String(body.size));
140
+ return new Response(body.body, { status: 200, headers });
141
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * AWS Signature Version 4 for the S3 API (Cloudflare R2), with Web Crypto
3
+ * only, so the Worker bundle doesn't need the AWS SDK. Two forms:
4
+ * header-signed requests (the Worker calling R2) and presigned URLs (the
5
+ * browser uploading parts straight to R2). No imports, so `node --test` can
6
+ * load this file directly.
7
+ */
8
+
9
+ export interface SigV4Credentials {
10
+ accessKeyId: string;
11
+ secretAccessKey: string;
12
+ }
13
+
14
+ export interface SigV4Request {
15
+ method: string;
16
+ /** Host, e.g. "<account>.r2.cloudflarestorage.com". */
17
+ host: string;
18
+ /** Unencoded path, e.g. "/bucket/files/abc/report.pdf". Each segment is encoded here. */
19
+ path: string;
20
+ /** Query parameters (unencoded). Use "" for valueless ones such as `uploads` or `cors`. */
21
+ query?: Record<string, string>;
22
+ /** Extra headers to sign (host is added). */
23
+ headers?: Record<string, string>;
24
+ region: string;
25
+ service?: string;
26
+ /** Defaults to now. */
27
+ date?: Date;
28
+ }
29
+
30
+ const encoder = new TextEncoder();
31
+ export const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
32
+ export const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
33
+
34
+ function hex(buffer: ArrayBuffer): string {
35
+ return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, "0")).join("");
36
+ }
37
+
38
+ export async function sha256Hex(data: string | Uint8Array): Promise<string> {
39
+ const bytes = typeof data === "string" ? encoder.encode(data) : data;
40
+ return hex(await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>));
41
+ }
42
+
43
+ async function hmac(key: ArrayBuffer | Uint8Array, data: string): Promise<ArrayBuffer> {
44
+ const cryptoKey = await crypto.subtle.importKey("raw", key as Uint8Array<ArrayBuffer>, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
45
+ return crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(data));
46
+ }
47
+
48
+ /** S3's URI encoding: everything but A-Z a-z 0-9 - . _ ~ (and "/" when encoding a path). */
49
+ export function uriEncode(value: string, encodeSlash = true): string {
50
+ let out = "";
51
+ for (const byte of encoder.encode(value)) {
52
+ const c = String.fromCharCode(byte);
53
+ if (/[A-Za-z0-9\-._~]/.test(c) || (c === "/" && !encodeSlash)) out += c;
54
+ else out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ export function canonicalQuery(query: Record<string, string> = {}): string {
60
+ return Object.entries(query)
61
+ .map(([k, v]) => [uriEncode(k), uriEncode(v)] as const)
62
+ .sort(([a, av], [b, bv]) => (a < b ? -1 : a > b ? 1 : av < bv ? -1 : av > bv ? 1 : 0))
63
+ .map(([k, v]) => `${k}=${v}`)
64
+ .join("&");
65
+ }
66
+
67
+ /** "20130524T000000Z" */
68
+ export function amzDate(date: Date): string {
69
+ return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
70
+ }
71
+
72
+ async function signingKey(secret: string, day: string, region: string, service: string): Promise<ArrayBuffer> {
73
+ const kDate = await hmac(encoder.encode(`AWS4${secret}`), day);
74
+ const kRegion = await hmac(kDate, region);
75
+ const kService = await hmac(kRegion, service);
76
+ return hmac(kService, "aws4_request");
77
+ }
78
+
79
+ interface Prepared {
80
+ canonicalRequest: string;
81
+ stringToSign: string;
82
+ signature: string;
83
+ signedHeaders: string;
84
+ scope: string;
85
+ datetime: string;
86
+ }
87
+
88
+ async function prepare(
89
+ req: SigV4Request,
90
+ creds: SigV4Credentials,
91
+ headers: Record<string, string>,
92
+ query: Record<string, string>,
93
+ payloadHash: string,
94
+ datetime: string,
95
+ ): Promise<Prepared> {
96
+ const service = req.service ?? "s3";
97
+ const day = datetime.slice(0, 8);
98
+ const scope = `${day}/${req.region}/${service}/aws4_request`;
99
+ const lower = Object.entries(headers).map(([k, v]) => [k.toLowerCase(), String(v).trim().replace(/\s+/g, " ")] as const);
100
+ lower.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
101
+ const signedHeaders = lower.map(([k]) => k).join(";");
102
+ const canonicalRequest = [
103
+ req.method.toUpperCase(),
104
+ uriEncode(req.path, false),
105
+ canonicalQuery(query),
106
+ lower.map(([k, v]) => `${k}:${v}\n`).join(""),
107
+ signedHeaders,
108
+ payloadHash,
109
+ ].join("\n");
110
+ const stringToSign = ["AWS4-HMAC-SHA256", datetime, scope, await sha256Hex(canonicalRequest)].join("\n");
111
+ const signature = hex(await hmac(await signingKey(creds.secretAccessKey, day, req.region, service), stringToSign));
112
+ return { canonicalRequest, stringToSign, signature, signedHeaders, scope, datetime };
113
+ }
114
+
115
+ /**
116
+ * Sign a request with an Authorization header. Returns the headers to send
117
+ * (including host, x-amz-date and x-amz-content-sha256) and the full URL.
118
+ */
119
+ export async function signRequest(
120
+ req: SigV4Request,
121
+ creds: SigV4Credentials,
122
+ payloadHash: string = EMPTY_SHA256,
123
+ ): Promise<{ url: string; headers: Record<string, string>; signature: string }> {
124
+ const datetime = amzDate(req.date ?? new Date());
125
+ const headers: Record<string, string> = {
126
+ ...req.headers,
127
+ host: req.host,
128
+ "x-amz-date": datetime,
129
+ "x-amz-content-sha256": payloadHash,
130
+ };
131
+ const query = req.query ?? {};
132
+ const p = await prepare(req, creds, headers, query, payloadHash, datetime);
133
+ const out = { ...headers };
134
+ delete out.host; // fetch sets Host itself.
135
+ out.authorization = `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${p.scope},SignedHeaders=${p.signedHeaders},Signature=${p.signature}`;
136
+ const qs = canonicalQuery(query);
137
+ return { url: `https://${req.host}${uriEncode(req.path, false)}${qs ? `?${qs}` : ""}`, headers: out, signature: p.signature };
138
+ }
139
+
140
+ /** A presigned URL (query-string auth, unsigned payload). Only `host` is signed unless `headers` adds more. */
141
+ export async function presignUrl(req: SigV4Request, creds: SigV4Credentials, expiresSeconds: number): Promise<{ url: string; signature: string }> {
142
+ const datetime = amzDate(req.date ?? new Date());
143
+ const service = req.service ?? "s3";
144
+ const headers: Record<string, string> = { ...req.headers, host: req.host };
145
+ const signedHeaders = Object.keys(headers)
146
+ .map((k) => k.toLowerCase())
147
+ .sort()
148
+ .join(";");
149
+ const query: Record<string, string> = {
150
+ ...req.query,
151
+ "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
152
+ "X-Amz-Credential": `${creds.accessKeyId}/${datetime.slice(0, 8)}/${req.region}/${service}/aws4_request`,
153
+ "X-Amz-Date": datetime,
154
+ "X-Amz-Expires": String(Math.max(1, Math.min(604800, Math.floor(expiresSeconds)))),
155
+ "X-Amz-SignedHeaders": signedHeaders,
156
+ };
157
+ const p = await prepare(req, creds, headers, query, UNSIGNED_PAYLOAD, datetime);
158
+ const qs = `${canonicalQuery(query)}&X-Amz-Signature=${p.signature}`;
159
+ return { url: `https://${req.host}${uriEncode(req.path, false)}?${qs}`, signature: p.signature };
160
+ }
@@ -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
+ }