@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.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run: node --test src/files/*.test.ts
|
|
3
|
+
*/
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
|
|
7
|
+
// @ts-expect-error -- Node's type stripping needs the real .ts extension.
|
|
8
|
+
import { parseDownloadPath } from "./format.ts";
|
|
9
|
+
// @ts-expect-error -- Node's type stripping needs the real .ts extension.
|
|
10
|
+
import { MAX_THROUGH_SITE, PART_SIZE, partSizeFor } from "./r2-rules.ts";
|
|
11
|
+
|
|
12
|
+
const MiB = 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
test("parts are 6 MiB: above R2's 5 MiB minimum, under EmDash's 8 MiB route body cap", () => {
|
|
15
|
+
assert.equal(PART_SIZE, 6 * MiB);
|
|
16
|
+
assert.equal(partSizeFor(1), PART_SIZE);
|
|
17
|
+
assert.equal(partSizeFor(40 * 1024 * MiB), PART_SIZE);
|
|
18
|
+
assert.equal(MAX_THROUGH_SITE, 10_000 * PART_SIZE);
|
|
19
|
+
assert.ok(MAX_THROUGH_SITE >= 50 * 1024 ** 3, "the 50 GB setting fits");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("download paths", () => {
|
|
23
|
+
assert.deepEqual(parseDownloadPath("/download/fabcdefghijklmno/report.pdf", "download"), { id: "fabcdefghijklmno", filename: "report.pdf" });
|
|
24
|
+
assert.deepEqual(parseDownloadPath("/download/01J9ZQ3W4X5Y6Z7A8B9C0D1E2F", "download"), { id: "01J9ZQ3W4X5Y6Z7A8B9C0D1E2F", filename: null });
|
|
25
|
+
assert.equal(parseDownloadPath("/download/", "download"), null);
|
|
26
|
+
assert.equal(parseDownloadPath("/download/not-an-id/x.pdf", "download"), null);
|
|
27
|
+
assert.equal(parseDownloadPath("/download/fabcdefghijklmno/a/b", "download"), null);
|
|
28
|
+
assert.equal(parseDownloadPath("/downloads/fabcdefghijklmno/x", "download"), null);
|
|
29
|
+
assert.equal(parseDownloadPath("/download/%E0%A4%A/x", "download"), null);
|
|
30
|
+
});
|
|
@@ -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(/</g, "<")
|
|
33
|
+
.replace(/>/g, ">")
|
|
34
|
+
.replace(/"/g, '"')
|
|
35
|
+
.replace(/'/g, "'")
|
|
36
|
+
.replace(/&/g, "&");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function encodeXml(value: string): string {
|
|
40
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|
package/src/files/s3.ts
ADDED
|
@@ -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,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SigV4 against the examples AWS publishes in the S3 API reference
|
|
3
|
+
* ("Signature Calculations for the Authorization Header" and "Authenticating
|
|
4
|
+
* Requests: Using Query Parameters").
|
|
5
|
+
* Run: node --test src/files/*.test.ts
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
|
|
10
|
+
// @ts-expect-error -- Node's type stripping needs the real .ts extension.
|
|
11
|
+
import { canonicalQuery, presignUrl, sha256Hex, signRequest, uriEncode } from "./sigv4.ts";
|
|
12
|
+
|
|
13
|
+
const creds = { accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" };
|
|
14
|
+
const date = new Date("2013-05-24T00:00:00Z");
|
|
15
|
+
const host = "examplebucket.s3.amazonaws.com";
|
|
16
|
+
const base = { host, region: "us-east-1", date };
|
|
17
|
+
|
|
18
|
+
test("GET object (Range header)", async () => {
|
|
19
|
+
const { signature, headers } = await signRequest({ ...base, method: "GET", path: "/test.txt", headers: { range: "bytes=0-9" } }, creds);
|
|
20
|
+
assert.equal(signature, "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41");
|
|
21
|
+
assert.match(headers.authorization, /^AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE\/20130524\/us-east-1\/s3\/aws4_request,SignedHeaders=host;range;x-amz-content-sha256;x-amz-date,Signature=f0e8/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("PUT object (encoded key, signed body)", async () => {
|
|
25
|
+
const body = "Welcome to Amazon S3.";
|
|
26
|
+
const payload = await sha256Hex(body);
|
|
27
|
+
assert.equal(payload, "44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072");
|
|
28
|
+
const { signature } = await signRequest(
|
|
29
|
+
{
|
|
30
|
+
...base,
|
|
31
|
+
method: "PUT",
|
|
32
|
+
path: "/test$file.text",
|
|
33
|
+
headers: { date: "Fri, 24 May 2013 00:00:00 GMT", "x-amz-storage-class": "REDUCED_REDUNDANCY" },
|
|
34
|
+
},
|
|
35
|
+
creds,
|
|
36
|
+
payload,
|
|
37
|
+
);
|
|
38
|
+
assert.equal(signature, "98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("GET bucket lifecycle (valueless query parameter)", async () => {
|
|
42
|
+
const { signature } = await signRequest({ ...base, method: "GET", path: "/", query: { lifecycle: "" } }, creds);
|
|
43
|
+
assert.equal(signature, "fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("GET bucket list (sorted query)", async () => {
|
|
47
|
+
const { signature } = await signRequest({ ...base, method: "GET", path: "/", query: { prefix: "J", "max-keys": "2" } }, creds);
|
|
48
|
+
assert.equal(signature, "34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("presigned GET URL", async () => {
|
|
52
|
+
const { url, signature } = await presignUrl({ ...base, method: "GET", path: "/test.txt" }, creds, 86400);
|
|
53
|
+
assert.equal(signature, "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404");
|
|
54
|
+
assert.equal(
|
|
55
|
+
url,
|
|
56
|
+
"https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("URI encoding and query order", () => {
|
|
61
|
+
assert.equal(uriEncode("a b/ü~*", false), "a%20b/%C3%BC~%2A");
|
|
62
|
+
assert.equal(uriEncode("a/b"), "a%2Fb");
|
|
63
|
+
assert.equal(canonicalQuery({ uploadId: "x y", partNumber: "2" }), "partNumber=2&uploadId=x%20y");
|
|
64
|
+
});
|
|
@@ -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
|
+
}
|