@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,448 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Coywolf Video: a Cloudflare Stream player in a <figure>, sized by the
|
|
4
|
+
* video's aspect ratio (no layout shift), with an optional caption and
|
|
5
|
+
* plays/likes/date row. Schema comes from the page:metadata hook, not from here.
|
|
6
|
+
*
|
|
7
|
+
* Light embed (the default; Videos → Player): the page shows only the poster,
|
|
8
|
+
* sized for the screen, and loads Stream's player (~350 KB of JavaScript) when
|
|
9
|
+
* someone presses play, or for autoplaying videos on the visitor's first
|
|
10
|
+
* interaction once the video is on screen. Without it a video near the top of a page loaded
|
|
11
|
+
* with the page and, on a slow phone connection, became the page's largest
|
|
12
|
+
* paint several seconds late. Without JavaScript the player loads as before.
|
|
13
|
+
*
|
|
14
|
+
* Used for `coywolf-video` Portable Text blocks, and usable directly from a
|
|
15
|
+
* theme: <CoywolfVideo node={{ uid, preset: "gif" }} />. Also accepts the
|
|
16
|
+
* WordPress cloudflare-stream marker attributes (id, host, aspect, name,
|
|
17
|
+
* description, seconds), which default to the GIF preset.
|
|
18
|
+
*
|
|
19
|
+
* Moving pictures can always be stopped (WCAG 2.2.2): autoplaying videos
|
|
20
|
+
* without controls (the GIF preset) get a pause/play button, and visitors who
|
|
21
|
+
* ask for reduced motion get the poster and a play button instead of autoplay.
|
|
22
|
+
*
|
|
23
|
+
* What shows under the video (name, description, views, likes, date) follows
|
|
24
|
+
* the site defaults on the Videos settings tab unless the block says Show or
|
|
25
|
+
* Hide; the look comes from the same tab as custom properties (render.ts).
|
|
26
|
+
* The CSS is printed once per page.
|
|
27
|
+
*/
|
|
28
|
+
import { getPublicPluginApiRouteHandler } from "emdash/plugin-utils";
|
|
29
|
+
|
|
30
|
+
import { PLUGIN_ID, siteFeatureOn } from "../../core/features.js";
|
|
31
|
+
import { noteReadFailure, routeFailed } from "../../core/read-failures.js";
|
|
32
|
+
import {
|
|
33
|
+
type VideoBlock,
|
|
34
|
+
aspectRatio,
|
|
35
|
+
isUid,
|
|
36
|
+
normalizeCustomerHost,
|
|
37
|
+
playerConfig,
|
|
38
|
+
playerSrc,
|
|
39
|
+
} from "../../videos/lib.js";
|
|
40
|
+
import { hostedPosterImage } from "../../videos/poster.js";
|
|
41
|
+
import { DISPLAY_DEFAULTS, VIDEO_CSS, blockDisplay, type VideoDisplay, captionHtml, displayVars, metaHtml, schemeClass } from "../../videos/render.js";
|
|
42
|
+
|
|
43
|
+
interface Props {
|
|
44
|
+
node: VideoBlock;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface EmbedData {
|
|
48
|
+
host: string | null;
|
|
49
|
+
accent: string | null;
|
|
50
|
+
background: string | null;
|
|
51
|
+
lightEmbed?: boolean;
|
|
52
|
+
display?: VideoDisplay;
|
|
53
|
+
engagement: boolean;
|
|
54
|
+
video: {
|
|
55
|
+
name: string | null;
|
|
56
|
+
description: string | null;
|
|
57
|
+
duration: number;
|
|
58
|
+
created: string | null;
|
|
59
|
+
width: number;
|
|
60
|
+
height: number;
|
|
61
|
+
posterTime: number | null;
|
|
62
|
+
posterImage: string | null;
|
|
63
|
+
} | null;
|
|
64
|
+
counts: { plays: number; likes: number } | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { node } = Astro.props;
|
|
68
|
+
const uid = isUid(node?.uid) ? node.uid : isUid(node?.id) ? node.id : null;
|
|
69
|
+
const on = await siteFeatureOn("videos");
|
|
70
|
+
|
|
71
|
+
let data: EmbedData | null = null;
|
|
72
|
+
if (on && uid) {
|
|
73
|
+
const handler = getPublicPluginApiRouteHandler(Astro.locals as Parameters<typeof getPublicPluginApiRouteHandler>[0]);
|
|
74
|
+
if (handler) {
|
|
75
|
+
try {
|
|
76
|
+
const res = (await handler(
|
|
77
|
+
PLUGIN_ID,
|
|
78
|
+
"POST",
|
|
79
|
+
"/videos/embed",
|
|
80
|
+
new Request(new URL(`/_emdash/api/plugins/${PLUGIN_ID}/videos/embed`, Astro.url), {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ uid }),
|
|
84
|
+
}),
|
|
85
|
+
)) as { success?: boolean; data?: EmbedData; status?: number; error?: { code?: string } };
|
|
86
|
+
data = res?.success && res.data ? res.data : null;
|
|
87
|
+
// A server error (a read that failed): the player renders with defaults, so the page isn't edge-cached.
|
|
88
|
+
if (routeFailed(res)) noteReadFailure("video embed data");
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.warn("coywolf-pack: video embed data failed", error);
|
|
91
|
+
noteReadFailure("video embed data");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const host = normalizeCustomerHost(node?.host) ?? data?.host ?? null;
|
|
97
|
+
const video = data?.video ?? null;
|
|
98
|
+
const display = data?.display ?? DISPLAY_DEFAULTS;
|
|
99
|
+
const cfg = playerConfig(node ?? {}, display);
|
|
100
|
+
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
101
|
+
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined);
|
|
102
|
+
const name = str(node?.title) ?? str(node?.name) ?? video?.name ?? "Video";
|
|
103
|
+
const description = str(node?.caption) ?? str(node?.description) ?? video?.description ?? "";
|
|
104
|
+
const block = { posterTime: num(node?.posterTime), posterImage: str(node?.posterImage) };
|
|
105
|
+
const posterFallback = { posterTime: video?.posterTime ?? (node?.host ? 1 : undefined), posterImage: video?.posterImage ?? undefined };
|
|
106
|
+
// From the media host when there is one (copied from Stream once), else Stream's thumbnails.
|
|
107
|
+
const hostedPoster = uid ? await hostedPosterImage(host, uid, block, posterFallback, Astro.url.origin, Astro.locals as Record<string, unknown>) : null;
|
|
108
|
+
const poster = hostedPoster?.full;
|
|
109
|
+
const playerExtra = { startTime: num(node?.startTime), poster, accent: data?.accent ?? undefined, background: data?.background ?? undefined };
|
|
110
|
+
const src = uid ? playerSrc(host, uid, cfg, playerExtra) : "";
|
|
111
|
+
// Light embed: the poster now, the player later. Pressing play starts it at once.
|
|
112
|
+
const light = Boolean(uid) && data?.lightEmbed !== false;
|
|
113
|
+
const playSrc = uid && light && !cfg.autoplay ? playerSrc(host, uid, { ...cfg, autoplay: true }, playerExtra) : src;
|
|
114
|
+
const posterImg = light ? hostedPoster : null;
|
|
115
|
+
// The theme names the block that's the page's largest early paint (Astro.locals.coywolfPriorityKey):
|
|
116
|
+
// its poster loads at once and first, not lazily.
|
|
117
|
+
const priority = Boolean(node?._key) && (Astro.locals as { coywolfPriorityKey?: string }).coywolfPriorityKey === node?._key;
|
|
118
|
+
const ratio = aspectRatio(video?.width, video?.height, num(node?.aspect));
|
|
119
|
+
const maxWidth = node?.sizeMode === "maxwidth" && num(node?.maxWidth) ? Math.max(100, Math.round(node.maxWidth as number)) : null;
|
|
120
|
+
const counts = data?.engagement ? data.counts : null;
|
|
121
|
+
const captionMarkup = captionHtml(name, description, { name: cfg.showName, description: cfg.showDescription });
|
|
122
|
+
const metaMarkup = uid
|
|
123
|
+
? metaHtml({
|
|
124
|
+
uid,
|
|
125
|
+
likes: cfg.showLikes && counts !== null ? counts.likes : null,
|
|
126
|
+
showLikeCount: cfg.showLikeCount,
|
|
127
|
+
likeIcon: display.likeIcon,
|
|
128
|
+
plays: cfg.showPlays && counts !== null && !cfg.autoplay ? counts.plays : null,
|
|
129
|
+
date: cfg.showDate ? (video?.created ?? null) : null,
|
|
130
|
+
dateStyle: display.dateStyle,
|
|
131
|
+
})
|
|
132
|
+
: "";
|
|
133
|
+
const track = data?.engagement && !cfg.autoplay;
|
|
134
|
+
// Autoplaying without controls: our own pause/play button is the only way to stop it.
|
|
135
|
+
const pausable = cfg.autoplay && !cfg.controls;
|
|
136
|
+
const fallbackHref = /^https?:\/\//i.test(str(node?.url) ?? "") ? str(node?.url) : undefined;
|
|
137
|
+
const style = [`--cw-video-ratio:${ratio}`, maxWidth ? `max-width:${maxWidth}px` : "", ...displayVars(blockDisplay(display, node as Record<string, unknown>), data?.background)].filter(Boolean).join(";");
|
|
138
|
+
// The figure's CSS, once per page.
|
|
139
|
+
const locals = (Astro.locals ?? {}) as Record<string, unknown>;
|
|
140
|
+
const css = on && uid && !locals.__cwVideoCss ? VIDEO_CSS : "";
|
|
141
|
+
if (css) locals.__cwVideoCss = true;
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
{css && <style is:inline set:html={css} />}
|
|
145
|
+
{on && uid ? (
|
|
146
|
+
<figure class:list={["cw-video", cfg.gif && "cw-video--gif", schemeClass(display.scheme)]} style={style} data-cw-video={uid} data-cw-track={track ? "" : undefined} data-cw-autoplay={cfg.autoplay ? "" : undefined}>
|
|
147
|
+
{light && posterImg ? (
|
|
148
|
+
<div class="cw-video__frame" data-cw-player={playSrc} data-cw-title={name} data-cw-auto={cfg.autoplay ? "" : undefined}>
|
|
149
|
+
{posterImg.avif ? (
|
|
150
|
+
<picture>
|
|
151
|
+
<source type="image/avif" srcset={posterImg.avif} sizes="(min-width: 50rem) 768px, 100vw" />
|
|
152
|
+
<img class="cw-video__poster" src={posterImg.src} srcset={posterImg.srcset} sizes="(min-width: 50rem) 768px, 100vw" alt="" loading={priority ? "eager" : "lazy"} fetchpriority={priority ? "high" : undefined} decoding="async" />
|
|
153
|
+
</picture>
|
|
154
|
+
) : (
|
|
155
|
+
<img class="cw-video__poster" src={posterImg.src} srcset={posterImg.srcset} sizes="(min-width: 50rem) 768px, 100vw" alt="" loading={priority ? "eager" : "lazy"} fetchpriority={priority ? "high" : undefined} decoding="async" />
|
|
156
|
+
)}
|
|
157
|
+
{/* Autoplaying videos with controls keep this hidden unless the visitor prefers reduced motion. */}
|
|
158
|
+
{!pausable && (
|
|
159
|
+
<button type="button" class="cw-video__play" data-cw-play aria-label={`Play video: ${name}`} hidden={cfg.autoplay}>
|
|
160
|
+
<svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true" focusable="false"><path d="M8 5v14l11-7z" fill="currentColor" /></svg>
|
|
161
|
+
</button>
|
|
162
|
+
)}
|
|
163
|
+
{pausable && (
|
|
164
|
+
<button type="button" class="cw-video__toggle" data-cw-toggle data-cw-name={name} aria-label={`Pause video: ${name}`}>
|
|
165
|
+
<svg class="cw-video__pause" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M6 5h4v14H6zm8 0h4v14h-4z" fill="currentColor" /></svg>
|
|
166
|
+
<svg class="cw-video__resume" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M8 5v14l11-7z" fill="currentColor" /></svg>
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
<noscript>
|
|
170
|
+
<iframe src={src} title={name} loading="lazy" allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;" allowfullscreen></iframe>
|
|
171
|
+
</noscript>
|
|
172
|
+
</div>
|
|
173
|
+
) : (
|
|
174
|
+
<div class="cw-video__frame">
|
|
175
|
+
<iframe
|
|
176
|
+
src={src}
|
|
177
|
+
title={name}
|
|
178
|
+
loading="lazy"
|
|
179
|
+
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
|
|
180
|
+
allowfullscreen
|
|
181
|
+
></iframe>
|
|
182
|
+
{pausable && (
|
|
183
|
+
<button type="button" class="cw-video__toggle" data-cw-toggle data-cw-name={name} aria-label={`Pause video: ${name}`}>
|
|
184
|
+
<svg class="cw-video__pause" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M6 5h4v14H6zm8 0h4v14h-4z" fill="currentColor" /></svg>
|
|
185
|
+
<svg class="cw-video__resume" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M8 5v14l11-7z" fill="currentColor" /></svg>
|
|
186
|
+
</button>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
{captionMarkup && <Fragment set:html={captionMarkup} />}
|
|
191
|
+
{metaMarkup && <Fragment set:html={metaMarkup} />}
|
|
192
|
+
</figure>
|
|
193
|
+
) : on && fallbackHref ? (
|
|
194
|
+
<p class="cw-video-link"><a href={fallbackHref}>{str(node?.title) ?? "Watch the video"}</a></p>
|
|
195
|
+
) : null}
|
|
196
|
+
|
|
197
|
+
<script>
|
|
198
|
+
// Light embed (poster first, player on demand), pause/play for autoplaying
|
|
199
|
+
// videos without controls, plays (once per session, after 2 seconds of
|
|
200
|
+
// playback) and likes. Stream's player SDK is loaded only to count plays or
|
|
201
|
+
// to pause and play, and only once a player exists.
|
|
202
|
+
import { relativeDate } from "../../videos/date.js";
|
|
203
|
+
import { playerControl } from "../../videos/pause.js";
|
|
204
|
+
|
|
205
|
+
const API = "/_emdash/api/plugins/coywolf-pack/videos/";
|
|
206
|
+
// X-Coywolf-Video: likes are refused without it, so other sites can't send them.
|
|
207
|
+
const post = (path: string, body: { uid: string; liked?: boolean }) =>
|
|
208
|
+
fetch(API + path, { method: "POST", headers: { "Content-Type": "application/json", "X-Coywolf-Video": "1" }, body: JSON.stringify(body) })
|
|
209
|
+
.then((r) => (r.ok ? (r.json() as Promise<{ data?: { plays?: number; likes?: number; liked?: boolean } }>) : null))
|
|
210
|
+
.then((r) => r?.data ?? null)
|
|
211
|
+
.catch(() => null);
|
|
212
|
+
const store = {
|
|
213
|
+
get: (k: string) => { try { return sessionStorage.getItem(k) ?? localStorage.getItem(k); } catch { return null; } },
|
|
214
|
+
session: (k: string) => { try { sessionStorage.setItem(k, "1"); } catch { /* private mode */ } },
|
|
215
|
+
like: (k: string, on: boolean) => { try { on ? localStorage.setItem(k, "1") : localStorage.removeItem(k); } catch { /* private mode */ } },
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
type StreamPlayer = { addEventListener(e: string, f: () => void): void; currentTime: number; play(): unknown; pause(): unknown };
|
|
219
|
+
let sdk: Promise<((el: HTMLIFrameElement) => StreamPlayer) | null> | null = null;
|
|
220
|
+
const loadSdk = () =>
|
|
221
|
+
(sdk ??= new Promise((resolve) => {
|
|
222
|
+
const s = document.createElement("script");
|
|
223
|
+
s.src = "https://embed.cloudflarestream.com/embed/sdk.latest.js";
|
|
224
|
+
s.async = true;
|
|
225
|
+
s.onload = () => resolve((window as unknown as { Stream?: (el: HTMLIFrameElement) => StreamPlayer }).Stream ?? null);
|
|
226
|
+
s.onerror = () => resolve(null);
|
|
227
|
+
document.head.appendChild(s);
|
|
228
|
+
}));
|
|
229
|
+
const players = new WeakMap<HTMLIFrameElement, Promise<StreamPlayer | null>>();
|
|
230
|
+
const playerFor = (frame: HTMLIFrameElement) => {
|
|
231
|
+
let player = players.get(frame);
|
|
232
|
+
if (!player) {
|
|
233
|
+
player = loadSdk().then((Stream) => (Stream ? Stream(frame) : null));
|
|
234
|
+
players.set(frame, player);
|
|
235
|
+
}
|
|
236
|
+
return player;
|
|
237
|
+
};
|
|
238
|
+
/** Pause/play that holds to the visitor's choice until the player is ready (see videos/pause.ts). */
|
|
239
|
+
const controls = new WeakMap<HTMLIFrameElement, (playing: boolean) => void>();
|
|
240
|
+
const controlFor = (frame: HTMLIFrameElement) => {
|
|
241
|
+
let control = controls.get(frame);
|
|
242
|
+
if (!control) {
|
|
243
|
+
control = playerControl(playerFor(frame));
|
|
244
|
+
controls.set(frame, control);
|
|
245
|
+
}
|
|
246
|
+
return control;
|
|
247
|
+
};
|
|
248
|
+
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
249
|
+
|
|
250
|
+
/** Count a play (once per session) after 2 seconds of playback in this figure's player. */
|
|
251
|
+
const track = (fig: HTMLElement, frame: HTMLIFrameElement) => {
|
|
252
|
+
const uid = fig.dataset.cwVideo as string;
|
|
253
|
+
if (!fig.hasAttribute("data-cw-track") || store.get(`cw-video-play-${uid}`)) return;
|
|
254
|
+
void playerFor(frame).then((player) => {
|
|
255
|
+
if (!player) return;
|
|
256
|
+
let done = false;
|
|
257
|
+
player.addEventListener("timeupdate", () => {
|
|
258
|
+
if (done || player.currentTime < 2) return;
|
|
259
|
+
done = true;
|
|
260
|
+
store.session(`cw-video-play-${uid}`);
|
|
261
|
+
void post("play", { uid }).then((r) => {
|
|
262
|
+
const el = fig.querySelector<HTMLElement>("[data-cw-plays]");
|
|
263
|
+
if (el && r && typeof r.plays === "number" && r.plays > 0) el.textContent = `${r.plays.toLocaleString("en-US")} ${r.plays === 1 ? "view" : "views"}`;
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** Light embed: replace the poster with Stream's player. */
|
|
270
|
+
const load = (frameEl: HTMLElement, focus: boolean) => {
|
|
271
|
+
if (frameEl.querySelector("iframe")) return;
|
|
272
|
+
const frame = document.createElement("iframe");
|
|
273
|
+
frame.src = frameEl.dataset.cwPlayer as string;
|
|
274
|
+
frame.title = frameEl.dataset.cwTitle ?? "Video";
|
|
275
|
+
frame.allow = "accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;";
|
|
276
|
+
frame.allowFullscreen = true;
|
|
277
|
+
frameEl.querySelector("[data-cw-play]")?.remove();
|
|
278
|
+
frameEl.appendChild(frame);
|
|
279
|
+
// The poster stays underneath until the player has painted, so nothing flashes.
|
|
280
|
+
frame.addEventListener("load", () => {
|
|
281
|
+
const poster = frameEl.querySelector(".cw-video__poster");
|
|
282
|
+
(poster?.parentElement?.tagName === "PICTURE" ? poster.parentElement : poster)?.remove();
|
|
283
|
+
}, { once: true });
|
|
284
|
+
if (focus) frame.focus();
|
|
285
|
+
const fig = frameEl.closest<HTMLElement>("[data-cw-video]");
|
|
286
|
+
if (fig) track(fig, frame);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
document.querySelectorAll<HTMLElement>("[data-cw-player]").forEach((frameEl) => {
|
|
290
|
+
frameEl.querySelector<HTMLButtonElement>("[data-cw-play]")?.addEventListener("click", () => load(frameEl, true));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Reduced motion: no autoplay. Light embeds keep their poster (data-cw-held) with a play
|
|
294
|
+
// button; players rendered with the page lose their autoplay parameter.
|
|
295
|
+
if (reduceMotion) {
|
|
296
|
+
document.querySelectorAll<HTMLElement>("[data-cw-player][data-cw-auto]").forEach((frameEl) => {
|
|
297
|
+
frameEl.setAttribute("data-cw-held", "");
|
|
298
|
+
const play = frameEl.querySelector<HTMLButtonElement>("[data-cw-play]");
|
|
299
|
+
if (play) play.hidden = false;
|
|
300
|
+
});
|
|
301
|
+
document.querySelectorAll<HTMLIFrameElement>("[data-cw-autoplay] .cw-video__frame:not([data-cw-player]) iframe").forEach((frame) => {
|
|
302
|
+
try {
|
|
303
|
+
const url = new URL(frame.src);
|
|
304
|
+
if (!url.searchParams.has("autoplay")) return;
|
|
305
|
+
url.searchParams.delete("autoplay");
|
|
306
|
+
frame.src = url.href;
|
|
307
|
+
} catch {
|
|
308
|
+
// Leave it as it is.
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Pause/play for autoplaying videos without controls. Before a light embed's player has
|
|
314
|
+
// loaded, pausing keeps the poster (the video doesn't autoplay) and playing loads it.
|
|
315
|
+
document.querySelectorAll<HTMLButtonElement>("[data-cw-toggle]").forEach((btn) => {
|
|
316
|
+
const frameEl = btn.closest<HTMLElement>(".cw-video__frame");
|
|
317
|
+
if (!frameEl) return;
|
|
318
|
+
const name = btn.dataset.cwName ?? "";
|
|
319
|
+
let playing = !reduceMotion;
|
|
320
|
+
const show = () => {
|
|
321
|
+
btn.setAttribute("aria-label", `${playing ? "Pause" : "Play"} video: ${name}`);
|
|
322
|
+
btn.toggleAttribute("data-paused", !playing);
|
|
323
|
+
};
|
|
324
|
+
if (!playing) frameEl.setAttribute("data-cw-held", "");
|
|
325
|
+
show();
|
|
326
|
+
btn.addEventListener("click", () => {
|
|
327
|
+
playing = !playing;
|
|
328
|
+
show();
|
|
329
|
+
const want = playing;
|
|
330
|
+
const frame = frameEl.querySelector<HTMLIFrameElement>("iframe");
|
|
331
|
+
if (frame) {
|
|
332
|
+
// Applied once the SDK and player are ready, and again if autoplay starts after a pause.
|
|
333
|
+
controlFor(frame)(want);
|
|
334
|
+
} else if (want) {
|
|
335
|
+
frameEl.removeAttribute("data-cw-held");
|
|
336
|
+
load(frameEl, false);
|
|
337
|
+
} else {
|
|
338
|
+
frameEl.setAttribute("data-cw-held", "");
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Autoplaying videos: on the visitor's first interaction (scroll, touch, pointer or
|
|
344
|
+
// key), once the video is (nearly) on screen. Loading on idle instead still ran
|
|
345
|
+
// Stream's player (about 1.5 s of script on a mid-range phone) inside the page's
|
|
346
|
+
// load window, which PageSpeed counts as blocking time.
|
|
347
|
+
const autos = document.querySelectorAll<HTMLElement>("[data-cw-player][data-cw-auto]");
|
|
348
|
+
if (autos.length && !reduceMotion) {
|
|
349
|
+
let interacted = false;
|
|
350
|
+
// A first interaction on a video's pause/play button: that video waits for the button's
|
|
351
|
+
// click (which, from the start, pauses it and keeps the poster) instead of autoplaying.
|
|
352
|
+
let pressing: HTMLElement | null = null;
|
|
353
|
+
const visible = new Set<HTMLElement>();
|
|
354
|
+
const flush = () => {
|
|
355
|
+
if (!interacted) return;
|
|
356
|
+
visible.forEach((el) => {
|
|
357
|
+
if (el === pressing && !el.hasAttribute("data-cw-held")) return;
|
|
358
|
+
visible.delete(el);
|
|
359
|
+
// Paused before its player loaded (data-cw-held): keep the poster.
|
|
360
|
+
if (!el.hasAttribute("data-cw-held")) load(el, false);
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
const events = ["scroll", "pointermove", "pointerdown", "touchstart", "keydown", "wheel"] as const;
|
|
364
|
+
const onFirst = (event: Event) => {
|
|
365
|
+
interacted = true;
|
|
366
|
+
const toggle = event.target instanceof Element ? event.target.closest("[data-cw-toggle]") : null;
|
|
367
|
+
pressing = toggle?.closest<HTMLElement>("[data-cw-player][data-cw-auto]") ?? null;
|
|
368
|
+
if (pressing) {
|
|
369
|
+
// The click decides; if it never comes (pointer dragged away), load it as usual.
|
|
370
|
+
const settle = () => {
|
|
371
|
+
pressing = null;
|
|
372
|
+
flush();
|
|
373
|
+
};
|
|
374
|
+
addEventListener("click", () => setTimeout(settle), { once: true, capture: true });
|
|
375
|
+
setTimeout(settle, 1000);
|
|
376
|
+
}
|
|
377
|
+
events.forEach((e) => removeEventListener(e, onFirst));
|
|
378
|
+
flush();
|
|
379
|
+
};
|
|
380
|
+
events.forEach((e) => addEventListener(e, onFirst, { passive: true, once: true }));
|
|
381
|
+
if ("IntersectionObserver" in window) {
|
|
382
|
+
const seen = new IntersectionObserver((entries) => {
|
|
383
|
+
for (const entry of entries) {
|
|
384
|
+
if (!entry.isIntersecting) continue;
|
|
385
|
+
seen.unobserve(entry.target);
|
|
386
|
+
visible.add(entry.target as HTMLElement);
|
|
387
|
+
}
|
|
388
|
+
flush();
|
|
389
|
+
}, { rootMargin: "200px" });
|
|
390
|
+
autos.forEach((el) => seen.observe(el));
|
|
391
|
+
} else {
|
|
392
|
+
autos.forEach((el) => visible.add(el));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Players rendered with the page (light embed off): count plays once they're near the screen.
|
|
397
|
+
const tracked = document.querySelectorAll<HTMLElement>("[data-cw-video][data-cw-track]");
|
|
398
|
+
const io = "IntersectionObserver" in window
|
|
399
|
+
? new IntersectionObserver((entries) => {
|
|
400
|
+
for (const entry of entries) {
|
|
401
|
+
if (!entry.isIntersecting) continue;
|
|
402
|
+
io?.unobserve(entry.target);
|
|
403
|
+
const fig = entry.target as HTMLElement;
|
|
404
|
+
const frame = fig.querySelector<HTMLIFrameElement>(".cw-video__frame:not([data-cw-player]) iframe");
|
|
405
|
+
if (frame) track(fig, frame);
|
|
406
|
+
}
|
|
407
|
+
}, { rootMargin: "200px" })
|
|
408
|
+
: null;
|
|
409
|
+
tracked.forEach((fig) => io?.observe(fig));
|
|
410
|
+
|
|
411
|
+
// Relative upload dates ("7 months ago"): pages can be served from a cache for days.
|
|
412
|
+
document.querySelectorAll<HTMLTimeElement>("time[data-cw-relative]").forEach((el) => {
|
|
413
|
+
const text = relativeDate(el.dateTime);
|
|
414
|
+
if (text) el.textContent = text;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
document.querySelectorAll<HTMLButtonElement>("[data-cw-like]").forEach((btn) => {
|
|
418
|
+
const uid = btn.dataset.cwLike as string;
|
|
419
|
+
const key = `cw-video-liked-${uid}`;
|
|
420
|
+
if (store.get(key)) btn.setAttribute("aria-pressed", "true");
|
|
421
|
+
let busy = false;
|
|
422
|
+
// After a click the new state holds (no hover preview of the other state) until the pointer or focus leaves.
|
|
423
|
+
const release = () => btn.removeAttribute("data-held");
|
|
424
|
+
btn.addEventListener("mouseleave", release);
|
|
425
|
+
btn.addEventListener("blur", release);
|
|
426
|
+
btn.addEventListener("animationend", () => btn.removeAttribute("data-pop"));
|
|
427
|
+
btn.addEventListener("click", () => {
|
|
428
|
+
if (busy) return;
|
|
429
|
+
busy = true;
|
|
430
|
+
const next = btn.getAttribute("aria-pressed") !== "true";
|
|
431
|
+
btn.setAttribute("aria-pressed", String(next));
|
|
432
|
+
btn.setAttribute("data-held", "");
|
|
433
|
+
// Restart the pop (CSS skips it for reduced motion).
|
|
434
|
+
btn.removeAttribute("data-pop");
|
|
435
|
+
void btn.offsetWidth;
|
|
436
|
+
btn.setAttribute("data-pop", "");
|
|
437
|
+
// The wanted state, not a toggle: visitors sharing an address share a like.
|
|
438
|
+
void post("like", { uid, liked: next }).then((r) => {
|
|
439
|
+
busy = false;
|
|
440
|
+
if (!r) return;
|
|
441
|
+
btn.setAttribute("aria-pressed", String(Boolean(r.liked)));
|
|
442
|
+
store.like(key, Boolean(r.liked));
|
|
443
|
+
const count = btn.querySelector("[data-cw-likes]");
|
|
444
|
+
if (count && typeof r.likes === "number") count.textContent = r.likes > 0 ? r.likes.toLocaleString("en-US") : "";
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
</script>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup downloads. Plugin routes can only answer with a body held in memory,
|
|
3
|
+
* and a database dump can be larger than a Worker should buffer, so the
|
|
4
|
+
* Backups page asks the backups/download-link route (admins only) for a
|
|
5
|
+
* short-lived link, and the pack middleware streams the file from R2 at that
|
|
6
|
+
* link:
|
|
7
|
+
*
|
|
8
|
+
* /_coywolf-pack/backup/<token>/<file name>
|
|
9
|
+
*
|
|
10
|
+
* The token is random (192 bits) and names a ticket in the backup bucket,
|
|
11
|
+
* downloads/<token>.json, that says which file it opens and until when. A
|
|
12
|
+
* ticket works for ten minutes (so a browser can retry or resume), and old
|
|
13
|
+
* tickets are deleted whenever a new one is made.
|
|
14
|
+
*/
|
|
15
|
+
import { contentDisposition } from "../files/format.js";
|
|
16
|
+
import { FILE, STAMP } from "./store.js";
|
|
17
|
+
|
|
18
|
+
export const DOWNLOAD_PATH = "/_coywolf-pack/backup/";
|
|
19
|
+
const TICKET_PREFIX = "downloads/";
|
|
20
|
+
const TICKET_TTL_MS = 10 * 60_000;
|
|
21
|
+
const TOKEN = /^[a-f0-9]{48}$/;
|
|
22
|
+
|
|
23
|
+
interface Ticket {
|
|
24
|
+
key: string;
|
|
25
|
+
filename: string;
|
|
26
|
+
expires: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Set by backupsPack() when the module is configured; the middleware does nothing without it. */
|
|
30
|
+
let config: { backups: string } | null = null;
|
|
31
|
+
|
|
32
|
+
export function configureBackupDownloads(options: { backups?: string }) {
|
|
33
|
+
config = { backups: options.backups ?? "BACKUPS" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function newToken(): string {
|
|
37
|
+
return [...crypto.getRandomValues(new Uint8Array(24))].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The file name a download is saved as: mysite-2026-10-03T1802Z.sql.gz. */
|
|
41
|
+
export function downloadFilename(file: string, stamp: string): string {
|
|
42
|
+
const match = /^(.*?)(\.sql\.gz|\.gz|\.sql)?$/i.exec(file);
|
|
43
|
+
return `${match?.[1] || "database"}-${stamp}${match?.[2] ?? ""}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Delete tickets that have expired (by upload time, so no ticket has to be read). */
|
|
47
|
+
async function pruneTickets(bucket: R2Bucket) {
|
|
48
|
+
const cutoff = Date.now() - TICKET_TTL_MS;
|
|
49
|
+
let cursor: string | undefined;
|
|
50
|
+
do {
|
|
51
|
+
const page = await bucket.list({ prefix: TICKET_PREFIX, cursor });
|
|
52
|
+
const old = page.objects.filter((o) => o.uploaded.getTime() < cutoff).map((o) => o.key);
|
|
53
|
+
if (old.length) await bucket.delete(old);
|
|
54
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
55
|
+
} while (cursor);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Make a download link for one backup file, or null when it doesn't exist. */
|
|
59
|
+
export async function createDownloadLink(bucket: R2Bucket, stamp: string, file: string): Promise<{ url: string; filename: string; bytes: number; expires: string } | null> {
|
|
60
|
+
if (!STAMP.test(stamp) || !FILE.test(file)) return null;
|
|
61
|
+
const key = `d1/${stamp}/${file}`;
|
|
62
|
+
const head = await bucket.head(key);
|
|
63
|
+
if (!head) return null;
|
|
64
|
+
await pruneTickets(bucket).catch(() => undefined);
|
|
65
|
+
const token = newToken();
|
|
66
|
+
const filename = downloadFilename(file, stamp);
|
|
67
|
+
const ticket: Ticket = { key, filename, expires: new Date(Date.now() + TICKET_TTL_MS).toISOString() };
|
|
68
|
+
await bucket.put(`${TICKET_PREFIX}${token}.json`, JSON.stringify(ticket), { httpMetadata: { contentType: "application/json" } });
|
|
69
|
+
return { url: `${DOWNLOAD_PATH}${token}/${encodeURIComponent(filename)}`, filename, bytes: head.size, expires: ticket.expires };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const NOT_FOUND = () =>
|
|
73
|
+
new Response("This download link has expired. Download the backup again from the Backups page.", {
|
|
74
|
+
status: 404,
|
|
75
|
+
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Robots-Tag": "noindex" },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Middleware: stream a backup file for a valid ticket. Returns undefined for any other URL. */
|
|
79
|
+
export async function serveBackupDownload(request: Request, url: URL, env: Record<string, unknown>): Promise<Response | undefined> {
|
|
80
|
+
if (!config || !url.pathname.startsWith(DOWNLOAD_PATH)) return undefined;
|
|
81
|
+
if (request.method !== "GET" && request.method !== "HEAD") return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
|
|
82
|
+
const token = url.pathname.slice(DOWNLOAD_PATH.length).split("/")[0];
|
|
83
|
+
if (!TOKEN.test(token)) return NOT_FOUND();
|
|
84
|
+
const bucket = env[config.backups] as R2Bucket | undefined;
|
|
85
|
+
if (!bucket) return NOT_FOUND();
|
|
86
|
+
|
|
87
|
+
const ticketObject = await bucket.get(`${TICKET_PREFIX}${token}.json`);
|
|
88
|
+
if (!ticketObject) return NOT_FOUND();
|
|
89
|
+
const ticket = (await ticketObject.json()) as Ticket;
|
|
90
|
+
if (!(Date.parse(ticket.expires) > Date.now()) || !ticket.key.startsWith("d1/")) return NOT_FOUND();
|
|
91
|
+
|
|
92
|
+
const headers = new Headers({
|
|
93
|
+
"Content-Type": "application/gzip",
|
|
94
|
+
"Content-Disposition": contentDisposition(ticket.filename),
|
|
95
|
+
"Cache-Control": "private, no-store",
|
|
96
|
+
"Referrer-Policy": "no-referrer",
|
|
97
|
+
"X-Content-Type-Options": "nosniff",
|
|
98
|
+
"X-Robots-Tag": "noindex",
|
|
99
|
+
});
|
|
100
|
+
if (request.method === "HEAD") {
|
|
101
|
+
const head = await bucket.head(ticket.key);
|
|
102
|
+
if (!head) return NOT_FOUND();
|
|
103
|
+
headers.set("Content-Length", String(head.size));
|
|
104
|
+
return new Response(null, { headers });
|
|
105
|
+
}
|
|
106
|
+
const object = await bucket.get(ticket.key);
|
|
107
|
+
if (!object) return NOT_FOUND();
|
|
108
|
+
headers.set("Content-Length", String(object.size));
|
|
109
|
+
headers.set("ETag", object.httpEtag);
|
|
110
|
+
// Streamed straight from R2: the Worker never holds the whole file.
|
|
111
|
+
return new Response(object.body, { headers });
|
|
112
|
+
}
|