@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,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clean image URLs: resized copies of media-library images at
|
|
3
|
+
*
|
|
4
|
+
* /media/<file id>-<width>x<height>.<format> (cropped to fill)
|
|
5
|
+
* /media/<file id>-<width>w.<format> (width only, keeps the ratio)
|
|
6
|
+
*
|
|
7
|
+
* instead of Astro's /_image?href=…&w=…&h=… endpoint. Pure helpers (no
|
|
8
|
+
* imports) so tests can load them; the middleware is in pack.ts.
|
|
9
|
+
*
|
|
10
|
+
* With a media host configured (`images: { cdn: "https://media.example.com" }`:
|
|
11
|
+
* an R2 custom domain on the media bucket, with Cloudflare Image
|
|
12
|
+
* Transformations and the zone's /s/ rewrite rules), URLs point there instead
|
|
13
|
+
* and never touch the Worker:
|
|
14
|
+
*
|
|
15
|
+
* https://media.example.com/<file> the original file
|
|
16
|
+
* https://media.example.com/s/<w>x<h>/<file> cropped to fill
|
|
17
|
+
* https://media.example.com/s/<w>/<file> width only, keeps the ratio
|
|
18
|
+
*
|
|
19
|
+
* The media host picks WebP or AVIF for browsers that accept them.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const IMAGE_PATH = "/media/";
|
|
23
|
+
/**
|
|
24
|
+
* Media library files: /_emdash/api/media/file/<key> (EmDash's media route),
|
|
25
|
+
* where the key is <stem>.<ext>. The stem is EmDash's file ULID (not the media
|
|
26
|
+
* id), and since EmDash 1.1 uploads it can hold a dot (<ulid>.<attempt ulid>),
|
|
27
|
+
* so a stem is anything up to the last dot.
|
|
28
|
+
*/
|
|
29
|
+
const MEDIA_FILE = /^(?:https?:\/\/[^/]+)?\/_emdash\/api\/media\/file\/([A-Za-z0-9_.-]+)\.([a-z0-9]{2,5})(?:[?#].*)?$/i;
|
|
30
|
+
const CLEAN = /^\/media\/([A-Za-z0-9_.-]+)-(?:(\d{1,4})x(\d{1,4})|(\d{1,4})w)\.(webp|avif|jpe?g|png)$/i;
|
|
31
|
+
export const MAX_DIMENSION = 2560;
|
|
32
|
+
/**
|
|
33
|
+
* Worker-route sizes are multiples of this many pixels, so anyone requesting
|
|
34
|
+
* /media/ URLs can't make the Worker run (and bill) a new transform for every
|
|
35
|
+
* pixel. 10 keeps the sizes themes ask for (50, 100, 400, 640, 800, 1200x630)
|
|
36
|
+
* exact; anything else rounds up.
|
|
37
|
+
*/
|
|
38
|
+
export const SIZE_STEP = 10;
|
|
39
|
+
|
|
40
|
+
/** Round a size up to the Worker route's grid (at most MAX_DIMENSION). */
|
|
41
|
+
export function snapDimension(n: number): number {
|
|
42
|
+
return Math.min(MAX_DIMENSION, Math.ceil(n / SIZE_STEP) * SIZE_STEP);
|
|
43
|
+
}
|
|
44
|
+
export const FORMATS: Record<string, string> = { webp: "image/webp", avif: "image/avif", jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png" };
|
|
45
|
+
|
|
46
|
+
/** MIME types of original files by extension (for og:image:type on media-host URLs). */
|
|
47
|
+
export const EXT_MIME: Record<string, string> = { ...FORMATS, gif: "image/gif", svg: "image/svg+xml" };
|
|
48
|
+
/** Originals the media host (Cloudflare Image Transformations) can resize. */
|
|
49
|
+
const CDN_RESIZABLE = new Set(["jpg", "jpeg", "png", "gif", "webp", "svg", "heic"]);
|
|
50
|
+
|
|
51
|
+
/** The MIME type of a file extension, or undefined when unknown. */
|
|
52
|
+
export function mimeForExt(ext: string | null | undefined): string | undefined {
|
|
53
|
+
return EXT_MIME[String(ext ?? "").toLowerCase()];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a media host to its https origin ("media.example.com",
|
|
58
|
+
* "https://media.example.com/" → "https://media.example.com"). Null for
|
|
59
|
+
* anything that isn't a plain https host name (no path, query, user or port
|
|
60
|
+
* other than the default).
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeMediaHost(input: string | null | undefined): string | null {
|
|
63
|
+
let v = String(input ?? "").trim();
|
|
64
|
+
if (!v) return null;
|
|
65
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) v = `https://${v}`;
|
|
66
|
+
v = v.replace(/\/+$/, "");
|
|
67
|
+
const m = /^https:\/\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)$/i.exec(v);
|
|
68
|
+
return m ? `https://${m[1].toLowerCase()}` : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The media host set in astro.config.mjs (`images.cdn`), and the one saved on the Clean Image URLs page (wins when set). */
|
|
72
|
+
let optionBase: string | null = null;
|
|
73
|
+
let savedBase: string | null = null;
|
|
74
|
+
|
|
75
|
+
/** Set (or clear) the media host from the plugin option. Anything but an https origin is ignored. */
|
|
76
|
+
export function setImageCdn(url: string | null | undefined): void {
|
|
77
|
+
optionBase = normalizeMediaHost(url);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Set (or clear) the media host saved in the admin. It overrides the plugin option while set. */
|
|
81
|
+
export function setSavedImageCdn(url: string | null | undefined): void {
|
|
82
|
+
savedBase = normalizeMediaHost(url);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The media host in use (https origin, no trailing slash), or null for the Worker's /media/ route. */
|
|
86
|
+
export function imageCdn(): string | null {
|
|
87
|
+
return savedBase ?? optionBase;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Add `Link: <media host>; rel=preconnect` to a page (HTML) response, so the
|
|
92
|
+
* browser opens the connection to the media host (DNS, TCP, TLS) while the
|
|
93
|
+
* page is still arriving instead of when it reaches the first image. No
|
|
94
|
+
* `crossorigin`: images are fetched without CORS, so that's the connection
|
|
95
|
+
* they use. Returns the response unchanged without a host, for anything but
|
|
96
|
+
* HTML, or when it already has a preconnect to the host.
|
|
97
|
+
*/
|
|
98
|
+
export function withMediaPreconnect(response: Response, host: string | null = imageCdn()): Response {
|
|
99
|
+
if (!host || !(response.headers.get("content-type") ?? "").startsWith("text/html")) return response;
|
|
100
|
+
const link = response.headers.get("link") ?? "";
|
|
101
|
+
if (link.includes(`<${host}>`) && /rel="?preconnect/i.test(link)) return response;
|
|
102
|
+
const value = `<${host}>; rel=preconnect`;
|
|
103
|
+
try {
|
|
104
|
+
response.headers.append("link", value);
|
|
105
|
+
return response;
|
|
106
|
+
} catch {
|
|
107
|
+
// Immutable headers (a response passed through from fetch): copy it.
|
|
108
|
+
const copy = new Response(response.body, response);
|
|
109
|
+
copy.headers.append("link", value);
|
|
110
|
+
return copy;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Where the media host comes from: the admin setting, the plugin option, or nowhere. */
|
|
115
|
+
export function imageCdnSource(): { host: string | null; option: string | null; saved: string | null } {
|
|
116
|
+
return { host: imageCdn(), option: optionBase, saved: savedBase };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ImageRequest {
|
|
120
|
+
id: string;
|
|
121
|
+
width: number;
|
|
122
|
+
height?: number;
|
|
123
|
+
format: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The file id and extension of a media-library URL, or null for anything else. */
|
|
127
|
+
export function mediaFile(src: string | null | undefined): { id: string; ext: string } | null {
|
|
128
|
+
const m = MEDIA_FILE.exec(String(src ?? "").trim());
|
|
129
|
+
return m ? { id: m[1], ext: m[2].toLowerCase() } : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The original file on the media host, or null when there's no host or `src` isn't a media-library file. */
|
|
133
|
+
export function cdnOriginalUrl(src: string | null | undefined): string | null {
|
|
134
|
+
const cdn = imageCdn();
|
|
135
|
+
if (!cdn) return null;
|
|
136
|
+
const file = mediaFile(src);
|
|
137
|
+
if (file) return `${cdn}/${file.id}.${file.ext}`;
|
|
138
|
+
// Already on the media host: its original.
|
|
139
|
+
const onHost = parseCdnUrl(String(src ?? "").trim());
|
|
140
|
+
return onHost ? `${cdn}/${onHost.id}.${onHost.ext}` : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build a clean URL; null when `src` isn't a media-library file or the size is
|
|
145
|
+
* out of range. On the media host when one is set (format chosen by the host),
|
|
146
|
+
* otherwise the Worker's /media/ route.
|
|
147
|
+
*/
|
|
148
|
+
export function cleanImagePath(src: string | null | undefined, options: { width: number; height?: number; format?: string }): string | null {
|
|
149
|
+
const file = mediaFile(src);
|
|
150
|
+
if (!file) return null;
|
|
151
|
+
const width = Math.round(options.width);
|
|
152
|
+
const height = options.height === undefined ? undefined : Math.round(options.height);
|
|
153
|
+
if (!(width >= 1 && width <= MAX_DIMENSION) || (height !== undefined && !(height >= 1 && height <= MAX_DIMENSION))) return null;
|
|
154
|
+
const cdn = imageCdn();
|
|
155
|
+
if (cdn) {
|
|
156
|
+
// Cloudflare's resizer reads JPEG, PNG, GIF, WebP, SVG and HEIC, not AVIF (it answers 415).
|
|
157
|
+
if (!CDN_RESIZABLE.has(file.ext)) return null;
|
|
158
|
+
return `${cdn}/s/${height === undefined ? width : `${width}x${height}`}/${file.id}.${file.ext}`;
|
|
159
|
+
}
|
|
160
|
+
const format = (options.format ?? "webp").toLowerCase();
|
|
161
|
+
if (!FORMATS[format]) return null;
|
|
162
|
+
return imagePath({ id: file.id, width: snapDimension(width), height: height === undefined ? undefined : snapDimension(height), format });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The Worker-route path of a request, as given (no snapping). */
|
|
166
|
+
function imagePath(request: ImageRequest): string {
|
|
167
|
+
return `${IMAGE_PATH}${request.id}-${request.height === undefined ? `${request.width}w` : `${request.width}x${request.height}`}.${request.format}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The on-grid path for a Worker-route request whose size is off the grid
|
|
172
|
+
* (see SIZE_STEP), or null when it's already on it. The middleware answers
|
|
173
|
+
* off-grid requests with a redirect there instead of resizing.
|
|
174
|
+
*/
|
|
175
|
+
export function snappedImagePath(request: ImageRequest): string | null {
|
|
176
|
+
const width = snapDimension(request.width);
|
|
177
|
+
const height = request.height === undefined ? undefined : snapDimension(request.height);
|
|
178
|
+
if (width === request.width && height === request.height) return null;
|
|
179
|
+
return imagePath({ ...request, width, height });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Parse a clean URL path; null when it isn't one (or the size is out of range). */
|
|
183
|
+
export function parseImagePath(pathname: string): ImageRequest | null {
|
|
184
|
+
const m = CLEAN.exec(pathname);
|
|
185
|
+
if (!m) return null;
|
|
186
|
+
const width = Number(m[2] ?? m[4]);
|
|
187
|
+
const height = m[3] ? Number(m[3]) : undefined;
|
|
188
|
+
if (!(width >= 1 && width <= MAX_DIMENSION) || (height !== undefined && !(height >= 1 && height <= MAX_DIMENSION))) return null;
|
|
189
|
+
return { id: m[1], width, height, format: m[5].toLowerCase() };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Parse a media-host URL (absolute, on the configured host): the file and the
|
|
194
|
+
* requested size (none for the original). Null for anything else.
|
|
195
|
+
*/
|
|
196
|
+
export function parseCdnUrl(url: string | null | undefined): { id: string; ext: string; width?: number; height?: number } | null {
|
|
197
|
+
const cdn = imageCdn();
|
|
198
|
+
const value = String(url ?? "");
|
|
199
|
+
if (!cdn || !value.startsWith(`${cdn}/`)) return null;
|
|
200
|
+
const path = value.slice(cdn.length).split(/[?#]/)[0];
|
|
201
|
+
const m = /^\/(?:s\/(\d{1,4})(?:x(\d{1,4}))?\/)?([A-Za-z0-9_.-]+)\.([a-z0-9]{2,5})$/i.exec(path);
|
|
202
|
+
if (!m) return null;
|
|
203
|
+
const width = m[1] ? Number(m[1]) : undefined;
|
|
204
|
+
const height = m[2] ? Number(m[2]) : undefined;
|
|
205
|
+
if ((width !== undefined && !(width >= 1 && width <= MAX_DIMENSION)) || (height !== undefined && !(height >= 1 && height <= MAX_DIMENSION))) return null;
|
|
206
|
+
return { id: m[3], ext: m[4].toLowerCase(), width, height };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A media-library file's bucket key as stored by EmDash (<id>.<ext>). */
|
|
210
|
+
const KEY = /^[A-Za-z0-9_.-]+\.[a-z0-9]{2,5}$/i;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Where an old Worker-route URL (/media/<id>-<w>x<h>.<format>) lives on the
|
|
214
|
+
* media host, given the file's bucket key: the same size, format chosen by
|
|
215
|
+
* the host. Null without a media host or for a malformed key.
|
|
216
|
+
*/
|
|
217
|
+
export function cdnRedirectUrl(request: Pick<ImageRequest, "width" | "height">, key: string): string | null {
|
|
218
|
+
const cdn = imageCdn();
|
|
219
|
+
if (!cdn || !KEY.test(key)) return null;
|
|
220
|
+
return `${cdn}/s/${request.height === undefined ? request.width : `${request.width}x${request.height}`}/${key}`;
|
|
221
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clean Image URLs admin routes: the media host setting, a live check of the
|
|
3
|
+
* media host, and the optional one-click Cloudflare setup (plan, then apply).
|
|
4
|
+
*
|
|
5
|
+
* The Cloudflare API token comes from the request (typed on the page, not
|
|
6
|
+
* stored unless saved), the saved secret, or a Worker secret
|
|
7
|
+
* (IMAGES_API_TOKEN, then CLOUDFLARE_API_TOKEN). It's only sent to
|
|
8
|
+
* api.cloudflare.com, never logged or returned.
|
|
9
|
+
*/
|
|
10
|
+
import type { PluginContext } from "emdash";
|
|
11
|
+
import { PluginRouteError, definePluginRoute } from "emdash";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
import { requireFeature } from "../core/features.js";
|
|
15
|
+
import { afterResponse, parseInput, secret, workerEnv } from "../shared.js";
|
|
16
|
+
import { CloudflareApiError, type CloudflareConfig, applySetup, checkMediaHost, planSetup, purgeHost } from "./cloudflare.js";
|
|
17
|
+
import { imageCdn, imageCdnSource, normalizeMediaHost } from "./lib.js";
|
|
18
|
+
import { rememberSiteSetting } from "../core/features.js";
|
|
19
|
+
import { IMAGES_SETTINGS, invalidateMediaHost, refreshMediaHost } from "./settings.js";
|
|
20
|
+
import { type BackfillDeps, type VariantsState, estimateRows, publicState, publishedCount, readVariantsState, startVariantsRun, variantCounts } from "./variants-store.js";
|
|
21
|
+
import { summarizeLibrary } from "./variants-estimate.js";
|
|
22
|
+
import { imageCrops } from "./variants.js";
|
|
23
|
+
|
|
24
|
+
const F = "images";
|
|
25
|
+
|
|
26
|
+
export const QUOTA_NOTE =
|
|
27
|
+
"Cloudflare's Free plan includes 5,000 unique image transformations a month (each new size of each image counts once a month; repeat views are served from the cache). Stored sizes are made once per image (each width and crop, as WebP and AVIF) and then served as plain files, so they don't count again. Beyond the free amount, transformations need a paid Cloudflare Images plan; without one, new sizes stop being made until the next month while existing ones keep working.";
|
|
28
|
+
|
|
29
|
+
export const VARIANTS_FALLBACK_NOTE =
|
|
30
|
+
"New uploads get their sizes right away. Images without stored sizes (existing images until you turn them on, and images over 20 MB, GIFs and SVGs) keep using the media host's /s/ resizing. Crops (thumbnails, avatars) are made the first time a page shows them; pages shown while a new upload's sizes or a crop are being made are cached for minutes instead of days, so they pick them up. Pages keep their normal caching while existing images wait for their sizes; when a run finishes, the page cache is cleared once (and warmed again, with cache warming on) so every page uses them.";
|
|
31
|
+
|
|
32
|
+
const hostInput = z
|
|
33
|
+
.string()
|
|
34
|
+
.max(253)
|
|
35
|
+
.optional()
|
|
36
|
+
.transform((v, zctx) => {
|
|
37
|
+
if (v === undefined) return undefined;
|
|
38
|
+
if (!v.trim()) return "";
|
|
39
|
+
const host = normalizeMediaHost(v);
|
|
40
|
+
if (!host) {
|
|
41
|
+
zctx.addIssue({ code: "custom", message: "The media host must be an https address like https://media.example.com (no path)." });
|
|
42
|
+
return z.NEVER;
|
|
43
|
+
}
|
|
44
|
+
return host;
|
|
45
|
+
});
|
|
46
|
+
const accountInput = z
|
|
47
|
+
.string()
|
|
48
|
+
.trim()
|
|
49
|
+
.max(64)
|
|
50
|
+
.regex(/^([0-9a-f]{32})?$/i, "The account ID is 32 letters and numbers (0-9, a-f).")
|
|
51
|
+
.optional();
|
|
52
|
+
const bucketInput = z
|
|
53
|
+
.string()
|
|
54
|
+
.trim()
|
|
55
|
+
.max(63)
|
|
56
|
+
.regex(/^([a-z0-9][a-z0-9-]{1,61}[a-z0-9])?$/, "Bucket names use lowercase letters, numbers and hyphens (3-63 characters).")
|
|
57
|
+
.optional();
|
|
58
|
+
|
|
59
|
+
const settingsInput = z.object({
|
|
60
|
+
host: hostInput,
|
|
61
|
+
accountId: accountInput,
|
|
62
|
+
bucket: bucketInput,
|
|
63
|
+
token: z.string().trim().max(400).optional(),
|
|
64
|
+
clearToken: z.boolean().optional(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const setupInput = z.object({
|
|
68
|
+
host: hostInput,
|
|
69
|
+
accountId: accountInput,
|
|
70
|
+
bucket: bucketInput,
|
|
71
|
+
token: z.string().trim().max(400).optional(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
async function env(): Promise<Record<string, unknown>> {
|
|
75
|
+
try {
|
|
76
|
+
return await workerEnv();
|
|
77
|
+
} catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function adminSettings(ctx: PluginContext) {
|
|
83
|
+
const [host, accountId, bucket, token] = await Promise.all([
|
|
84
|
+
ctx.settings.get<string>(IMAGES_SETTINGS.host),
|
|
85
|
+
ctx.settings.get<string>(IMAGES_SETTINGS.accountId),
|
|
86
|
+
ctx.settings.get<string>(IMAGES_SETTINGS.bucket),
|
|
87
|
+
ctx.settings.get<string>(IMAGES_SETTINGS.token).catch(() => null),
|
|
88
|
+
]);
|
|
89
|
+
const e = await env();
|
|
90
|
+
invalidateMediaHost(host ?? null);
|
|
91
|
+
const source = imageCdnSource();
|
|
92
|
+
return {
|
|
93
|
+
host: host ?? "",
|
|
94
|
+
optionHost: source.option ?? "",
|
|
95
|
+
activeHost: source.host ?? "",
|
|
96
|
+
accountId: accountId ?? "",
|
|
97
|
+
bucket: bucket ?? "",
|
|
98
|
+
tokenSet: Boolean(token?.trim()),
|
|
99
|
+
envToken: Boolean(secret(e, "IMAGES_API_TOKEN") || secret(e, "CLOUDFLARE_API_TOKEN")),
|
|
100
|
+
envAccountId: Boolean(secret(e, "CF_ACCOUNT_ID") || secret(e, "CLOUDFLARE_ACCOUNT_ID")),
|
|
101
|
+
siteHost: siteHostname(ctx),
|
|
102
|
+
quotaNote: QUOTA_NOTE,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function siteHostname(ctx: PluginContext): string {
|
|
107
|
+
try {
|
|
108
|
+
return ctx.site?.url ? new URL(ctx.site.url).hostname.replace(/^www\./, "") : "";
|
|
109
|
+
} catch {
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Credentials for the setup: the request's, then the saved ones, then Worker variables. */
|
|
115
|
+
async function credentials(ctx: PluginContext, input: z.infer<typeof setupInput>): Promise<CloudflareConfig & { bucket: string; host: string }> {
|
|
116
|
+
const e = await env();
|
|
117
|
+
const savedToken = await ctx.settings.get<string>(IMAGES_SETTINGS.token).catch(() => null);
|
|
118
|
+
const token = input.token || savedToken?.trim() || secret(e, "IMAGES_API_TOKEN") || secret(e, "CLOUDFLARE_API_TOKEN");
|
|
119
|
+
const accountId =
|
|
120
|
+
input.accountId || (await ctx.settings.get<string>(IMAGES_SETTINGS.accountId))?.trim() || secret(e, "CF_ACCOUNT_ID") || secret(e, "CLOUDFLARE_ACCOUNT_ID");
|
|
121
|
+
const bucket = input.bucket || (await ctx.settings.get<string>(IMAGES_SETTINGS.bucket))?.trim() || "";
|
|
122
|
+
const host = input.host || imageCdn() || "";
|
|
123
|
+
const missing = [!host && "media host", !accountId && "Cloudflare account ID", !bucket && "R2 bucket name", !token && "Cloudflare API token"].filter(Boolean);
|
|
124
|
+
if (missing.length) throw PluginRouteError.badRequest(`Enter the ${missing.join(", ")} first.`);
|
|
125
|
+
const doFetch = ctx.http ? (ctx.http.fetch.bind(ctx.http) as CloudflareConfig["fetch"]) : undefined;
|
|
126
|
+
return { token: token as string, accountId: accountId as string, bucket, host, fetch: doFetch };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The media host and the Cloudflare API access for it (the token and account
|
|
131
|
+
* saved on this page, else the IMAGES_API_TOKEN / CLOUDFLARE_API_TOKEN and
|
|
132
|
+
* CF_ACCOUNT_ID Worker secrets). `reason` says what's missing instead.
|
|
133
|
+
*/
|
|
134
|
+
export async function mediaApiAccess(
|
|
135
|
+
ctx: PluginContext,
|
|
136
|
+
): Promise<{ hostname: string; config: CloudflareConfig } | { hostname: string | null; reason: string }> {
|
|
137
|
+
await refreshMediaHost();
|
|
138
|
+
const host = imageCdn();
|
|
139
|
+
if (!host) return { hostname: null, reason: "No media host is set (Clean Image URLs)." };
|
|
140
|
+
const hostname = new URL(host).hostname;
|
|
141
|
+
const e = await env();
|
|
142
|
+
const savedToken = await ctx.settings.get<string>(IMAGES_SETTINGS.token).catch(() => null);
|
|
143
|
+
const token = savedToken?.trim() || secret(e, "IMAGES_API_TOKEN") || secret(e, "CLOUDFLARE_API_TOKEN");
|
|
144
|
+
const accountId = (await ctx.settings.get<string>(IMAGES_SETTINGS.accountId))?.trim() || secret(e, "CF_ACCOUNT_ID") || secret(e, "CLOUDFLARE_ACCOUNT_ID");
|
|
145
|
+
if (!token || !accountId) {
|
|
146
|
+
return { hostname, reason: "Add a Cloudflare API token and account ID on the Clean Image URLs page." };
|
|
147
|
+
}
|
|
148
|
+
const doFetch = ctx.http ? (ctx.http.fetch.bind(ctx.http) as CloudflareConfig["fetch"]) : undefined;
|
|
149
|
+
return { hostname, config: { token, accountId, fetch: doFetch } };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Clear the media host's images from Cloudflare's zone cache. Says why when it can't, instead of throwing. */
|
|
153
|
+
export async function purgeMediaHost(ctx: PluginContext): Promise<{ purged: boolean; host: string | null; message?: string }> {
|
|
154
|
+
const access = await mediaApiAccess(ctx);
|
|
155
|
+
if (!("config" in access)) {
|
|
156
|
+
return { purged: false, host: access.hostname, message: access.hostname ? `Images on ${access.hostname} weren't cleared. ${access.reason} The token needs Zone → Cache Purge → Purge.` : access.reason };
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
await purgeHost(access.config, access.hostname);
|
|
160
|
+
return { purged: true, host: access.hostname };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return { purged: false, host: access.hostname, message: `Images on ${access.hostname} weren't cleared. ${error instanceof Error ? error.message : String(error)}` };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function apiError(error: unknown): never {
|
|
167
|
+
if (error instanceof CloudflareApiError) throw PluginRouteError.badRequest(error.message);
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A recent image in the media library to check with (SVGs aren't resized). */
|
|
172
|
+
async function sampleFile(database: string): Promise<string | null> {
|
|
173
|
+
const e = await env();
|
|
174
|
+
const db = e[database] as D1Database | undefined;
|
|
175
|
+
if (!db) return null;
|
|
176
|
+
const row = await db
|
|
177
|
+
.prepare(
|
|
178
|
+
"SELECT storage_key FROM media WHERE mime_type LIKE 'image/%' AND mime_type NOT LIKE '%svg%' AND (status IS NULL OR status = 'ready') ORDER BY created_at DESC LIMIT 1",
|
|
179
|
+
)
|
|
180
|
+
.first<{ storage_key: string }>()
|
|
181
|
+
.catch(() => null);
|
|
182
|
+
return row?.storage_key && /^[A-Za-z0-9_.-]+\.[a-z0-9]{2,5}$/i.test(row.storage_key) ? row.storage_key : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function runCheck(host: string, database: string) {
|
|
186
|
+
const file = await sampleFile(database);
|
|
187
|
+
if (!file) throw PluginRouteError.badRequest("Upload an image to the media library first: the check fetches one from the media host.");
|
|
188
|
+
const items = await checkMediaHost(host, file);
|
|
189
|
+
return { host, file, ok: items.filter((i) => i.id !== "cached").every((i) => i.ok), items, quotaNote: QUOTA_NOTE };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface VariantsHooks {
|
|
193
|
+
/** Start the backfill's chain of steps (a new run with `force`); it works in the background. */
|
|
194
|
+
start(origin: string, force?: boolean): Promise<VariantsState | null>;
|
|
195
|
+
/** The bindings, or null when something is missing. */
|
|
196
|
+
deps(): Promise<BackfillDeps | null>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The stored-sizes status for the admin; `bulk`: sizes for existing images are on. */
|
|
200
|
+
async function variantsStatus(variants: VariantsHooks, bulk: boolean) {
|
|
201
|
+
const deps = await variants.deps();
|
|
202
|
+
if (!deps) {
|
|
203
|
+
return {
|
|
204
|
+
available: false,
|
|
205
|
+
reason: "Stored sizes need a media host, and the MEDIA bucket, IMAGES and DB bindings.",
|
|
206
|
+
bulk,
|
|
207
|
+
crops: imageCrops(),
|
|
208
|
+
fallbackNote: VARIANTS_FALLBACK_NOTE,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const [counts, { state }] = await Promise.all([variantCounts(deps.db), readVariantsState(deps.db)]);
|
|
212
|
+
return { available: true, bulk, ...counts, state: publicState(state), perHour: state ? imagesPerHour(state) : null, crops: imageCrops(), fallbackNote: VARIANTS_FALLBACK_NOTE };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Images a running backfill handles an hour, from its start (null when it's not running or just started). */
|
|
216
|
+
export function imagesPerHour(state: VariantsState, now = Date.now()): number | null {
|
|
217
|
+
if (state.phase !== "running") return null;
|
|
218
|
+
const hours = (now - Date.parse(state.startedAt)) / 3_600_000;
|
|
219
|
+
const handled = state.done + state.skipped + state.failed;
|
|
220
|
+
return hours >= 2 / 60 && handled > 0 ? Math.round(handled / hours) : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Origin of a route's request (the chain's steps are posted there). */
|
|
224
|
+
const requestOrigin = (ctx: { request: Request }) => new URL(ctx.request.url).origin;
|
|
225
|
+
|
|
226
|
+
const bulkSetting = async (ctx: PluginContext) => (await ctx.settings.get<boolean>(IMAGES_SETTINGS.bulk)) === true;
|
|
227
|
+
|
|
228
|
+
export function imagesModule(options: { database?: string; variants: VariantsHooks }) {
|
|
229
|
+
const database = options.database ?? "DB";
|
|
230
|
+
const variants = options.variants;
|
|
231
|
+
return {
|
|
232
|
+
routes: {
|
|
233
|
+
"images/settings": {
|
|
234
|
+
permission: "plugins:manage" as const,
|
|
235
|
+
handler: async (ctx: PluginContext) => {
|
|
236
|
+
await requireFeature(ctx, F);
|
|
237
|
+
return adminSettings(ctx);
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
"images/settings/save": definePluginRoute({
|
|
242
|
+
permission: "plugins:manage",
|
|
243
|
+
methods: ["POST"],
|
|
244
|
+
request: { body: "json" },
|
|
245
|
+
handler: async (ctx) => {
|
|
246
|
+
await requireFeature(ctx, F);
|
|
247
|
+
const input = parseInput(settingsInput, ctx.input);
|
|
248
|
+
if (input.host !== undefined) await ctx.settings.set(IMAGES_SETTINGS.host, input.host);
|
|
249
|
+
if (input.accountId !== undefined) await ctx.settings.set(IMAGES_SETTINGS.accountId, input.accountId);
|
|
250
|
+
if (input.bucket !== undefined) await ctx.settings.set(IMAGES_SETTINGS.bucket, input.bucket);
|
|
251
|
+
if (input.clearToken) await ctx.settings.delete(IMAGES_SETTINGS.token);
|
|
252
|
+
else if (input.token) await ctx.settings.set(IMAGES_SETTINGS.token, input.token);
|
|
253
|
+
ctx.log.info("Clean image URLs settings saved", { tokenChanged: Boolean(input.clearToken || input.token) });
|
|
254
|
+
return adminSettings(ctx);
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
|
|
258
|
+
"images/check": definePluginRoute({
|
|
259
|
+
permission: "plugins:manage",
|
|
260
|
+
methods: ["POST"],
|
|
261
|
+
request: { body: "json" },
|
|
262
|
+
handler: async (ctx) => {
|
|
263
|
+
await requireFeature(ctx, F);
|
|
264
|
+
const { host } = parseInput(z.object({ host: hostInput }), ctx.input ?? {});
|
|
265
|
+
await refreshMediaHost();
|
|
266
|
+
const target = host || imageCdn();
|
|
267
|
+
if (!target) throw PluginRouteError.badRequest("Enter a media host to check.");
|
|
268
|
+
return runCheck(target, database);
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
|
|
272
|
+
"images/variants/settings": {
|
|
273
|
+
permission: "plugins:manage" as const,
|
|
274
|
+
handler: async (ctx: PluginContext) => {
|
|
275
|
+
await requireFeature(ctx, F);
|
|
276
|
+
return variantsStatus(variants, await bulkSetting(ctx));
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
/** What stored sizes for the existing library would take: counts for the page's cost estimate (computed there). */
|
|
281
|
+
"images/variants/estimate": {
|
|
282
|
+
permission: "plugins:manage" as const,
|
|
283
|
+
handler: async (ctx: PluginContext) => {
|
|
284
|
+
await requireFeature(ctx, F);
|
|
285
|
+
const deps = await variants.deps();
|
|
286
|
+
if (!deps) throw PluginRouteError.badRequest("Stored sizes need a media host, and the MEDIA bucket, IMAGES and DB bindings.");
|
|
287
|
+
const [rows, pages] = await Promise.all([estimateRows(deps.db), publishedCount(deps.db)]);
|
|
288
|
+
return { ...summarizeLibrary(rows), pages };
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
/** Turn stored sizes for existing images on (after the page showed the estimate) or off. On starts a run. */
|
|
293
|
+
"images/variants/bulk": definePluginRoute({
|
|
294
|
+
permission: "plugins:manage",
|
|
295
|
+
methods: ["POST"],
|
|
296
|
+
request: { body: "json" },
|
|
297
|
+
handler: async (ctx) => {
|
|
298
|
+
await requireFeature(ctx, F);
|
|
299
|
+
const { on } = parseInput(z.object({ on: z.boolean() }), ctx.input ?? {});
|
|
300
|
+
const deps = await variants.deps();
|
|
301
|
+
if (on && !deps) throw PluginRouteError.badRequest("Stored sizes need a media host, and the MEDIA bucket, IMAGES and DB bindings.");
|
|
302
|
+
await ctx.settings.set(IMAGES_SETTINGS.bulk, on);
|
|
303
|
+
rememberSiteSetting(IMAGES_SETTINGS.bulk, on);
|
|
304
|
+
if (on && deps) {
|
|
305
|
+
await startVariantsRun(deps.db, { force: true });
|
|
306
|
+
// The run's steps follow in the background (a chain of requests of their own).
|
|
307
|
+
await afterResponse(variants.start(requestOrigin(ctx)).catch((error) => console.error("coywolf-pack images: couldn't start the backfill", error)));
|
|
308
|
+
}
|
|
309
|
+
ctx.log.info(on ? "Stored sizes for existing images turned on" : "Stored sizes for existing images turned off");
|
|
310
|
+
return variantsStatus(variants, on);
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* "Make missing sizes now" (only with sizes for existing images on): starts the backfill's chain
|
|
316
|
+
* (a new run with `start`), unless one is already working, and reports. The work goes on in the
|
|
317
|
+
* background; the page polls images/variants/settings for progress.
|
|
318
|
+
*/
|
|
319
|
+
"images/variants/run": definePluginRoute({
|
|
320
|
+
permission: "plugins:manage",
|
|
321
|
+
methods: ["POST"],
|
|
322
|
+
request: { body: "json" },
|
|
323
|
+
handler: async (ctx) => {
|
|
324
|
+
await requireFeature(ctx, F);
|
|
325
|
+
const deps = await variants.deps();
|
|
326
|
+
if (!deps) throw PluginRouteError.badRequest("Stored sizes need a media host, and the MEDIA bucket, IMAGES and DB bindings.");
|
|
327
|
+
if (!(await bulkSetting(ctx))) throw PluginRouteError.badRequest("Turn on stored sizes for existing images first.");
|
|
328
|
+
const { start } = parseInput(z.object({ start: z.boolean().optional() }), ctx.input ?? {});
|
|
329
|
+
if (start) await startVariantsRun(deps.db, { force: true });
|
|
330
|
+
await afterResponse(variants.start(requestOrigin(ctx)).catch((error) => console.error("coywolf-pack images: couldn't start the backfill", error)));
|
|
331
|
+
return variantsStatus(variants, true);
|
|
332
|
+
},
|
|
333
|
+
}),
|
|
334
|
+
|
|
335
|
+
"images/setup/plan": definePluginRoute({
|
|
336
|
+
permission: "plugins:manage",
|
|
337
|
+
methods: ["POST"],
|
|
338
|
+
request: { body: "json" },
|
|
339
|
+
handler: async (ctx) => {
|
|
340
|
+
await requireFeature(ctx, F);
|
|
341
|
+
const config = await credentials(ctx, parseInput(setupInput, ctx.input ?? {}));
|
|
342
|
+
try {
|
|
343
|
+
const { zone, plan } = await planSetup(config, { host: config.host, bucket: config.bucket });
|
|
344
|
+
return { zone: zone.name, plan: { host: plan.host, steps: plan.steps, done: plan.done }, quotaNote: QUOTA_NOTE };
|
|
345
|
+
} catch (error) {
|
|
346
|
+
apiError(error);
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
}),
|
|
350
|
+
|
|
351
|
+
"images/setup/apply": definePluginRoute({
|
|
352
|
+
permission: "plugins:manage",
|
|
353
|
+
methods: ["POST"],
|
|
354
|
+
request: { body: "json" },
|
|
355
|
+
handler: async (ctx) => {
|
|
356
|
+
await requireFeature(ctx, F);
|
|
357
|
+
const config = await credentials(ctx, parseInput(setupInput, ctx.input ?? {}));
|
|
358
|
+
let applied: Awaited<ReturnType<typeof applySetup>>;
|
|
359
|
+
try {
|
|
360
|
+
applied = await applySetup(config, { host: config.host, bucket: config.bucket });
|
|
361
|
+
} catch (error) {
|
|
362
|
+
apiError(error);
|
|
363
|
+
}
|
|
364
|
+
ctx.log.info("Media host setup applied", { zone: applied.zone, steps: applied.results.map((r) => `${r.id}:${r.ok ? "ok" : "failed"}`) });
|
|
365
|
+
const host = normalizeMediaHost(config.host) as string;
|
|
366
|
+
const check = applied.results.every((r) => r.ok) ? await runCheck(host, database).catch(() => null) : null;
|
|
367
|
+
return { zone: applied.zone, results: applied.results, check };
|
|
368
|
+
},
|
|
369
|
+
}),
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|