@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,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Downloads: the admin side. Routes for the block's file picker, the
|
|
3
|
+
* Files page (list, delete, rebuild the usage index), and direct-to-R2 large
|
|
4
|
+
* uploads (start, upload a part through the site, complete, abort). Hooks keep an
|
|
5
|
+
* index of which entries use which files.
|
|
6
|
+
*/
|
|
7
|
+
import type { PluginContext, StorageCollection } from "emdash";
|
|
8
|
+
import { PluginRouteError, definePluginRoute } from "emdash";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
import { ctxFeatures, requireFeature } from "../core/features.js";
|
|
12
|
+
import { parseInput, workerEnv } from "../shared.js";
|
|
13
|
+
import { downloadPath, extensionOf, formatSize, isUploadId, newUploadId, safeFilename } from "./format.js";
|
|
14
|
+
import { MAX_THROUGH_SITE, type R2Config, S3Error, partSizeFor, r2Client } from "./s3.js";
|
|
15
|
+
import { uploadsBindingName } from "./serve.js";
|
|
16
|
+
import { COLLECTIONS, type UploadDoc, invalidateSiteCache, settingsFrom } from "./site.js";
|
|
17
|
+
import { entryTitle, fileIdsIn } from "./walker.js";
|
|
18
|
+
|
|
19
|
+
export interface FilesOptions {
|
|
20
|
+
/** R2 binding of the media bucket. Default "MEDIA". */
|
|
21
|
+
media?: string;
|
|
22
|
+
/** R2 binding of the bucket large uploads go to (the bucket named in settings). Default: FILES when bound, else the media bucket. */
|
|
23
|
+
uploads?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const FILES_FEATURE = "files";
|
|
27
|
+
export const LARGE_UPLOADS_FEATURE = "files.largeUploads";
|
|
28
|
+
export const COUNTS_FEATURE = "files.counts";
|
|
29
|
+
|
|
30
|
+
// Parts go through the site (6 MiB each, 10,000 at most): just under 60 GB.
|
|
31
|
+
const MAX_UPLOAD = MAX_THROUGH_SITE;
|
|
32
|
+
/** The Files → Settings ceiling for the largest upload, in GB (fits MAX_UPLOAD). */
|
|
33
|
+
const MAX_UPLOAD_GB_SETTING = 50;
|
|
34
|
+
const DEFAULT_MAX_UPLOAD_GB = 5;
|
|
35
|
+
/** A part's body: PART_SIZE (6 MiB) plus slack, under EmDash's 8 MiB plugin route cap. */
|
|
36
|
+
const PART_BODY_MAX = 7 * 1024 * 1024;
|
|
37
|
+
export const UPLOAD_ID_HEADER = "x-cw-upload-id";
|
|
38
|
+
export const PART_NUMBER_HEADER = "x-cw-part-number";
|
|
39
|
+
const DEFAULT_BASE = "download";
|
|
40
|
+
const STALE_UPLOAD_MS = 24 * 60 * 60 * 1000;
|
|
41
|
+
export const CLEANUP_TASK = "files-abort-stale-uploads";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* File Downloads settings, edited on the Files page → Settings. Only the R2
|
|
45
|
+
* secret access key is in the plugin's settingsSchema (secrets only), so every
|
|
46
|
+
* other read supplies its default here or in settingsFrom().
|
|
47
|
+
*/
|
|
48
|
+
const R2_KEYS = ["filesR2AccountId", "filesR2AccessKeyId", "filesR2SecretAccessKey", "filesR2Bucket"] as const;
|
|
49
|
+
|
|
50
|
+
const hexColor = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
51
|
+
const filesSettingsInput = z.object({
|
|
52
|
+
filesBase: z
|
|
53
|
+
.string()
|
|
54
|
+
.trim()
|
|
55
|
+
.transform((v) => v.replace(/^\/+|\/+$/g, "").toLowerCase())
|
|
56
|
+
.refine((v) => v === "" || (/^[a-z0-9][a-z0-9_-]{0,63}$/.test(v) && !v.startsWith("_")), "The download URL base must be one path segment: letters, digits, - or _."),
|
|
57
|
+
filesPublicBaseUrl: z
|
|
58
|
+
.string()
|
|
59
|
+
.trim()
|
|
60
|
+
.max(500)
|
|
61
|
+
.refine((v) => v === "" || /^https:\/\/[^\s"'<>]+$/i.test(v), "The public bucket or CDN URL must start with https://."),
|
|
62
|
+
filesScheme: z.enum(["auto", "light", "dark"]),
|
|
63
|
+
filesAccent: z
|
|
64
|
+
.string()
|
|
65
|
+
.trim()
|
|
66
|
+
.refine((v) => v === "" || hexColor.test(v), "The accent color must be a hex color such as #007392."),
|
|
67
|
+
filesMaxUploadGb: z.number().int().min(1, "The largest upload must be at least 1 GB.").max(MAX_UPLOAD_GB_SETTING, `The largest upload can be at most ${MAX_UPLOAD_GB_SETTING} GB.`),
|
|
68
|
+
filesR2AccountId: z
|
|
69
|
+
.string()
|
|
70
|
+
.trim()
|
|
71
|
+
.refine((v) => v === "" || /^[a-f0-9]{32}$/i.test(v), "The R2 account ID should be 32 hexadecimal characters."),
|
|
72
|
+
filesR2AccessKeyId: z.string().trim().max(200),
|
|
73
|
+
filesR2Bucket: z
|
|
74
|
+
.string()
|
|
75
|
+
.trim()
|
|
76
|
+
.refine((v) => v === "" || /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(v), "The R2 bucket name isn't valid."),
|
|
77
|
+
/** Write-only: a new secret replaces the saved one; empty keeps it. */
|
|
78
|
+
filesR2SecretAccessKey: z.string().trim().max(500).optional(),
|
|
79
|
+
clearR2Secret: z.boolean().optional(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Whether all four R2 credentials are saved (not whether they work). */
|
|
83
|
+
async function r2Configured(ctx: PluginContext): Promise<boolean> {
|
|
84
|
+
const values = await Promise.all(R2_KEYS.map((key) => ctx.settings.get<string>(key).catch(() => null)));
|
|
85
|
+
return values.every((v) => typeof v === "string" && v.trim() !== "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface UsageDoc {
|
|
89
|
+
fileId: string;
|
|
90
|
+
collection: string;
|
|
91
|
+
entryId: string;
|
|
92
|
+
entryKey: string;
|
|
93
|
+
title: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface CountDoc {
|
|
97
|
+
downloads: number;
|
|
98
|
+
lastDownload?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const filesStorage = {
|
|
102
|
+
[COLLECTIONS.uploads]: { indexes: ["status", "uploadedAt"] },
|
|
103
|
+
[COLLECTIONS.usage]: { indexes: ["fileId", "entryKey"] },
|
|
104
|
+
[COLLECTIONS.counts]: { indexes: [] },
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function col<T>(ctx: PluginContext, name: string): StorageCollection<T> {
|
|
108
|
+
return (ctx.storage as Record<string, StorageCollection<T>>)[name];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Every document in a collection matching `where` (paged; capped). */
|
|
112
|
+
async function queryAll<T>(c: StorageCollection<T>, where?: Record<string, string>, maxPages = 50): Promise<Array<{ id: string; data: T }>> {
|
|
113
|
+
const out: Array<{ id: string; data: T }> = [];
|
|
114
|
+
let cursor: string | undefined;
|
|
115
|
+
for (let page = 0; page < maxPages; page++) {
|
|
116
|
+
const result = await c.query({ where, limit: 100, cursor });
|
|
117
|
+
out.push(...result.items);
|
|
118
|
+
if (!result.hasMore || !result.cursor) break;
|
|
119
|
+
cursor = result.cursor;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function r2Config(ctx: PluginContext): Promise<R2Config> {
|
|
125
|
+
const [accountId, accessKeyId, secretAccessKey, bucket] = await Promise.all([
|
|
126
|
+
ctx.settings.get<string>("filesR2AccountId"),
|
|
127
|
+
ctx.settings.get<string>("filesR2AccessKeyId"),
|
|
128
|
+
ctx.settings.get<string>("filesR2SecretAccessKey"),
|
|
129
|
+
ctx.settings.get<string>("filesR2Bucket"),
|
|
130
|
+
]);
|
|
131
|
+
if (!accountId || !accessKeyId || !secretAccessKey || !bucket)
|
|
132
|
+
throw PluginRouteError.badRequest(
|
|
133
|
+
"Large uploads aren't set up: add the R2 account ID, access key ID, secret access key and bucket name on the Files page → Settings.",
|
|
134
|
+
);
|
|
135
|
+
if (!/^[a-f0-9]{32}$/i.test(accountId.trim())) throw PluginRouteError.badRequest("The R2 account ID should be 32 hexadecimal characters.");
|
|
136
|
+
if (!/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(bucket.trim())) throw PluginRouteError.badRequest("The R2 bucket name isn't valid.");
|
|
137
|
+
return { accountId: accountId.trim(), accessKeyId: accessKeyId.trim(), secretAccessKey: secretAccessKey.trim(), bucket: bucket.trim() };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function client(ctx: PluginContext) {
|
|
141
|
+
const config = await r2Config(ctx);
|
|
142
|
+
if (!ctx.http) throw PluginRouteError.internal("File Downloads needs the network:request capability.");
|
|
143
|
+
const http = ctx.http;
|
|
144
|
+
return r2Client(config, (url, init) => http.fetch(url, init));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** S3 errors become readable 400s; credentials never appear in messages. */
|
|
148
|
+
async function s3<T>(fn: () => Promise<T>): Promise<T> {
|
|
149
|
+
try {
|
|
150
|
+
return await fn();
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof S3Error) {
|
|
153
|
+
const hint =
|
|
154
|
+
error.status === 403
|
|
155
|
+
? " Check the access key, secret and bucket permissions."
|
|
156
|
+
: error.code === "NoSuchUpload"
|
|
157
|
+
? " The upload expired or was already finished."
|
|
158
|
+
: "";
|
|
159
|
+
throw PluginRouteError.badRequest(`R2: ${error.message}.${hint}`);
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Usage index ──────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
const usageId = (fileId: string, collection: string, entryId: string) => `${fileId}|${collection}|${entryId}`;
|
|
168
|
+
|
|
169
|
+
/** Bring the usage rows for one entry in line with its content. */
|
|
170
|
+
export async function indexEntry(ctx: PluginContext, collection: string, content: Record<string, unknown>): Promise<void> {
|
|
171
|
+
const entryId = String(content.id ?? "");
|
|
172
|
+
if (!entryId) return;
|
|
173
|
+
const usage = col<UsageDoc>(ctx, COLLECTIONS.usage);
|
|
174
|
+
const entryKey = `${collection}:${entryId}`;
|
|
175
|
+
const wanted = fileIdsIn(content);
|
|
176
|
+
const existing = await queryAll(usage, { entryKey }, 5);
|
|
177
|
+
const title = entryTitle(content);
|
|
178
|
+
const keep = new Set(wanted.map((id) => usageId(id, collection, entryId)));
|
|
179
|
+
const stale = existing.filter((row) => !keep.has(row.id)).map((row) => row.id);
|
|
180
|
+
const current = new Map(existing.map((row) => [row.id, row.data]));
|
|
181
|
+
const puts = wanted
|
|
182
|
+
.map((fileId) => ({ id: usageId(fileId, collection, entryId), data: { fileId, collection, entryId, entryKey, title } }))
|
|
183
|
+
.filter((row) => current.get(row.id)?.title !== title);
|
|
184
|
+
if (stale.length) await usage.deleteMany(stale);
|
|
185
|
+
if (puts.length) await usage.putMany(puts);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function unindexEntry(ctx: PluginContext, collection: string, entryId: string): Promise<void> {
|
|
189
|
+
const usage = col<UsageDoc>(ctx, COLLECTIONS.usage);
|
|
190
|
+
const rows = await queryAll(usage, { entryKey: `${collection}:${entryId}` }, 5);
|
|
191
|
+
if (rows.length) await usage.deleteMany(rows.map((r) => r.id));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Cleanup ──────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/** Abort multipart uploads that started more than a day ago and never finished, and drop their records. */
|
|
197
|
+
export async function abortStaleUploads(ctx: PluginContext): Promise<number> {
|
|
198
|
+
const uploads = col<UploadDoc>(ctx, COLLECTIONS.uploads);
|
|
199
|
+
const cutoff = Date.now() - STALE_UPLOAD_MS;
|
|
200
|
+
const stale = (await queryAll(uploads, { status: "uploading" }, 10)).filter((row) => Date.parse(row.data.uploadedAt) < cutoff);
|
|
201
|
+
if (!stale.length) return 0;
|
|
202
|
+
let r2: Awaited<ReturnType<typeof client>> | null = null;
|
|
203
|
+
try {
|
|
204
|
+
r2 = await client(ctx);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
ctx.log.warn("Stale uploads: R2 credentials missing; dropping records only (an R2 lifecycle rule cleans up the parts)", {
|
|
207
|
+
error: String(error instanceof Error ? error.message : error),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
let removed = 0;
|
|
211
|
+
for (const { id, data } of stale) {
|
|
212
|
+
try {
|
|
213
|
+
if (r2 && data.uploadId) await r2.abortMultipart(data.key, data.uploadId);
|
|
214
|
+
await uploads.delete(id);
|
|
215
|
+
removed++;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
ctx.log.warn("Could not abort a stale upload", { id, error: String(error instanceof Error ? error.message : error) });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
ctx.log.info("Stale uploads aborted", { removed });
|
|
221
|
+
return removed;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Routes ───────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
const idInput = z.object({ id: z.string().min(1).max(64) });
|
|
227
|
+
|
|
228
|
+
export function filesModule(options: FilesOptions) {
|
|
229
|
+
async function uploadsBucket(): Promise<R2Bucket | undefined> {
|
|
230
|
+
const env = await workerEnv();
|
|
231
|
+
return env[uploadsBindingName(env, options)] as R2Bucket | undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function siteSettings(ctx: PluginContext) {
|
|
235
|
+
const raw: Record<string, unknown> = {};
|
|
236
|
+
for (const key of ["filesBase", "filesPublicBaseUrl", "filesScheme", "filesAccent"]) raw[key] = await ctx.settings.get(key);
|
|
237
|
+
return settingsFrom(raw);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function readFilesSettings(ctx: PluginContext) {
|
|
241
|
+
const site = await siteSettings(ctx);
|
|
242
|
+
const [accountId, accessKeyId, secretKey, bucket, maxGb] = await Promise.all([
|
|
243
|
+
ctx.settings.get<string>("filesR2AccountId"),
|
|
244
|
+
ctx.settings.get<string>("filesR2AccessKeyId"),
|
|
245
|
+
ctx.settings.get<string>("filesR2SecretAccessKey").catch(() => null),
|
|
246
|
+
ctx.settings.get<string>("filesR2Bucket"),
|
|
247
|
+
ctx.settings.get<number>("filesMaxUploadGb"),
|
|
248
|
+
]);
|
|
249
|
+
return {
|
|
250
|
+
filesBase: site.base,
|
|
251
|
+
filesPublicBaseUrl: site.publicBaseUrl,
|
|
252
|
+
filesScheme: site.scheme,
|
|
253
|
+
filesAccent: site.accent,
|
|
254
|
+
filesMaxUploadGb: typeof maxGb === "number" && Number.isFinite(maxGb) ? maxGb : DEFAULT_MAX_UPLOAD_GB,
|
|
255
|
+
filesR2AccountId: accountId ?? "",
|
|
256
|
+
filesR2AccessKeyId: accessKeyId ?? "",
|
|
257
|
+
filesR2Bucket: bucket ?? "",
|
|
258
|
+
r2SecretSet: typeof secretKey === "string" && secretKey.trim() !== "",
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function getUpload(ctx: PluginContext, id: string): Promise<UploadDoc> {
|
|
263
|
+
const doc = isUploadId(id) ? await col<UploadDoc>(ctx, COLLECTIONS.uploads).get(id) : null;
|
|
264
|
+
if (!doc) throw PluginRouteError.notFound("That upload doesn't exist.");
|
|
265
|
+
return doc;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const routes = {
|
|
269
|
+
/** Options for the block's file picker (Block Kit select with optionsRoute). */
|
|
270
|
+
"files/options": definePluginRoute({
|
|
271
|
+
permission: "content:create",
|
|
272
|
+
methods: ["POST"],
|
|
273
|
+
request: { body: "json" },
|
|
274
|
+
handler: async (ctx) => {
|
|
275
|
+
await requireFeature(ctx, FILES_FEATURE);
|
|
276
|
+
const items: Array<{ id: string; name: string }> = [];
|
|
277
|
+
const uploads = await queryAll(col<UploadDoc>(ctx, COLLECTIONS.uploads), { status: "ready" }, 10);
|
|
278
|
+
uploads.sort((a, b) => b.data.uploadedAt.localeCompare(a.data.uploadedAt));
|
|
279
|
+
for (const { data } of uploads) items.push({ id: data.id, name: `${data.name} (${formatSize(data.size)}, large upload)` });
|
|
280
|
+
if (ctx.media) {
|
|
281
|
+
let cursor: string | undefined;
|
|
282
|
+
for (let page = 0; page < 5; page++) {
|
|
283
|
+
const result = await ctx.media.list({ limit: 100, cursor });
|
|
284
|
+
for (const m of result.items) items.push({ id: m.id, name: `${m.filename}${m.size ? ` (${formatSize(m.size)})` : ""}` });
|
|
285
|
+
if (!result.hasMore || !result.cursor) break;
|
|
286
|
+
cursor = result.cursor;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { items };
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
292
|
+
|
|
293
|
+
/** Everything the Files page shows. */
|
|
294
|
+
"files/list": {
|
|
295
|
+
permission: "plugins:manage" as const,
|
|
296
|
+
handler: async (ctx: PluginContext) => {
|
|
297
|
+
await requireFeature(ctx, FILES_FEATURE);
|
|
298
|
+
const settings = await siteSettings(ctx);
|
|
299
|
+
const uploads = await queryAll(col<UploadDoc>(ctx, COLLECTIONS.uploads));
|
|
300
|
+
const usage = await queryAll(col<UsageDoc>(ctx, COLLECTIONS.usage));
|
|
301
|
+
|
|
302
|
+
const usedIn = new Map<string, Array<{ collection: string; id: string; title: string }>>();
|
|
303
|
+
for (const { data } of usage) {
|
|
304
|
+
const list = usedIn.get(data.fileId) ?? [];
|
|
305
|
+
list.push({ collection: data.collection, id: data.entryId, title: data.title });
|
|
306
|
+
usedIn.set(data.fileId, list);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
type Item = {
|
|
310
|
+
id: string;
|
|
311
|
+
source: "upload" | "media" | "missing";
|
|
312
|
+
status: "ready" | "uploading";
|
|
313
|
+
name: string;
|
|
314
|
+
type: string;
|
|
315
|
+
ext: string;
|
|
316
|
+
size: number;
|
|
317
|
+
uploadedAt: string | null;
|
|
318
|
+
url: string | null;
|
|
319
|
+
};
|
|
320
|
+
const items: Item[] = [];
|
|
321
|
+
const seen = new Set<string>();
|
|
322
|
+
for (const { data } of uploads) {
|
|
323
|
+
seen.add(data.id);
|
|
324
|
+
items.push({
|
|
325
|
+
id: data.id,
|
|
326
|
+
source: "upload",
|
|
327
|
+
status: data.status,
|
|
328
|
+
name: data.name,
|
|
329
|
+
type: data.type,
|
|
330
|
+
ext: extensionOf(data.name),
|
|
331
|
+
size: data.size,
|
|
332
|
+
uploadedAt: data.uploadedAt,
|
|
333
|
+
url: data.status === "ready" ? downloadPath(settings.base, data.id, data.name) : null,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const mediaIds = [...usedIn.keys()].filter((id) => !seen.has(id)).slice(0, 300);
|
|
337
|
+
for (const id of mediaIds) {
|
|
338
|
+
const m = ctx.media && !isUploadId(id) ? await ctx.media.get(id) : null;
|
|
339
|
+
items.push(
|
|
340
|
+
m
|
|
341
|
+
? {
|
|
342
|
+
id,
|
|
343
|
+
source: "media",
|
|
344
|
+
status: "ready",
|
|
345
|
+
name: m.filename,
|
|
346
|
+
type: m.mimeType,
|
|
347
|
+
ext: extensionOf(m.filename),
|
|
348
|
+
size: m.size ?? 0,
|
|
349
|
+
uploadedAt: m.createdAt,
|
|
350
|
+
url: downloadPath(settings.base, id, m.filename),
|
|
351
|
+
}
|
|
352
|
+
: { id, source: "missing", status: "ready", name: "Missing file", type: "", ext: "", size: 0, uploadedAt: null, url: null },
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const counts = await col<CountDoc>(ctx, COLLECTIONS.counts).getMany(items.map((i) => i.id));
|
|
357
|
+
const features = await ctxFeatures(ctx);
|
|
358
|
+
return {
|
|
359
|
+
items: items.map((item) => ({
|
|
360
|
+
...item,
|
|
361
|
+
downloads: counts.get(item.id)?.downloads ?? 0,
|
|
362
|
+
lastDownload: counts.get(item.id)?.lastDownload ?? null,
|
|
363
|
+
usedIn: usedIn.get(item.id) ?? [],
|
|
364
|
+
})),
|
|
365
|
+
base: settings.base,
|
|
366
|
+
countsEnabled: features[COUNTS_FEATURE] ?? false,
|
|
367
|
+
largeUploadsEnabled: features[LARGE_UPLOADS_FEATURE] ?? false,
|
|
368
|
+
r2Configured: await r2Configured(ctx),
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
/** The Files page's Settings tab (the R2 secret is reported as set or not, never returned). */
|
|
374
|
+
"files/settings": {
|
|
375
|
+
permission: "plugins:manage" as const,
|
|
376
|
+
handler: async (ctx: PluginContext) => {
|
|
377
|
+
await requireFeature(ctx, FILES_FEATURE);
|
|
378
|
+
return readFilesSettings(ctx);
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
"files/settings/save": definePluginRoute({
|
|
383
|
+
permission: "plugins:manage",
|
|
384
|
+
methods: ["POST"],
|
|
385
|
+
request: { body: "json" },
|
|
386
|
+
handler: async (ctx) => {
|
|
387
|
+
await requireFeature(ctx, FILES_FEATURE);
|
|
388
|
+
const { filesR2SecretAccessKey, clearR2Secret, ...plain } = parseInput(filesSettingsInput, ctx.input);
|
|
389
|
+
for (const [key, value] of Object.entries(plain)) await ctx.settings.set(key, key === "filesBase" && value === "" ? DEFAULT_BASE : value);
|
|
390
|
+
if (clearR2Secret) await ctx.settings.delete("filesR2SecretAccessKey");
|
|
391
|
+
else if (filesR2SecretAccessKey) await ctx.settings.set("filesR2SecretAccessKey", filesR2SecretAccessKey);
|
|
392
|
+
invalidateSiteCache();
|
|
393
|
+
ctx.log.info("File Downloads settings saved", { r2SecretChanged: Boolean(clearR2Secret || filesR2SecretAccessKey) });
|
|
394
|
+
return readFilesSettings(ctx);
|
|
395
|
+
},
|
|
396
|
+
}),
|
|
397
|
+
|
|
398
|
+
/** Delete a large upload (object and record). Media library files are deleted in the Media Library. */
|
|
399
|
+
"files/delete": definePluginRoute({
|
|
400
|
+
permission: "plugins:manage",
|
|
401
|
+
methods: ["POST"],
|
|
402
|
+
request: { body: "json" },
|
|
403
|
+
handler: async (ctx) => {
|
|
404
|
+
await requireFeature(ctx, FILES_FEATURE);
|
|
405
|
+
const { id } = parseInput(idInput, ctx.input);
|
|
406
|
+
const doc = await getUpload(ctx, id);
|
|
407
|
+
if (doc.status === "uploading" && doc.uploadId) {
|
|
408
|
+
const r2 = await client(ctx);
|
|
409
|
+
await s3(() => r2.abortMultipart(doc.key, doc.uploadId!));
|
|
410
|
+
} else {
|
|
411
|
+
const bucket = await uploadsBucket();
|
|
412
|
+
if (bucket) await bucket.delete(doc.key);
|
|
413
|
+
else {
|
|
414
|
+
const r2 = await client(ctx);
|
|
415
|
+
await s3(() => r2.deleteObject(doc.key));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const usage = col<UsageDoc>(ctx, COLLECTIONS.usage);
|
|
419
|
+
const rows = await queryAll(usage, { fileId: id }, 10);
|
|
420
|
+
await col<UploadDoc>(ctx, COLLECTIONS.uploads).delete(id);
|
|
421
|
+
await col<CountDoc>(ctx, COLLECTIONS.counts).delete(id);
|
|
422
|
+
if (rows.length) await usage.deleteMany(rows.map((r) => r.id));
|
|
423
|
+
invalidateSiteCache(id);
|
|
424
|
+
ctx.log.info("File deleted", { id, key: doc.key });
|
|
425
|
+
return { deleted: true, usedIn: rows.map((r) => ({ collection: r.data.collection, id: r.data.entryId, title: r.data.title })) };
|
|
426
|
+
},
|
|
427
|
+
}),
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Rebuild the usage index from all content, a few pages per call. Start
|
|
431
|
+
* with {} and send back `next` until `done`.
|
|
432
|
+
*/
|
|
433
|
+
"files/reindex": definePluginRoute({
|
|
434
|
+
permission: "plugins:manage",
|
|
435
|
+
methods: ["POST"],
|
|
436
|
+
request: { body: "json" },
|
|
437
|
+
handler: async (ctx) => {
|
|
438
|
+
await requireFeature(ctx, FILES_FEATURE);
|
|
439
|
+
const input = parseInput(
|
|
440
|
+
z.object({
|
|
441
|
+
next: z.object({ collections: z.array(z.string().max(100)).max(200), index: z.number().int().min(0), cursor: z.string().max(2000).optional() }).optional(),
|
|
442
|
+
}),
|
|
443
|
+
ctx.input,
|
|
444
|
+
);
|
|
445
|
+
if (!ctx.content || !ctx.schema) throw PluginRouteError.internal("File Downloads needs the content:read and schema:read capabilities.");
|
|
446
|
+
const usage = col<UsageDoc>(ctx, COLLECTIONS.usage);
|
|
447
|
+
let step = input.next;
|
|
448
|
+
if (!step) {
|
|
449
|
+
for (let i = 0; i < 20; i++) {
|
|
450
|
+
const page = await usage.query({ limit: 100 });
|
|
451
|
+
if (!page.items.length) break;
|
|
452
|
+
await usage.deleteMany(page.items.map((r) => r.id));
|
|
453
|
+
}
|
|
454
|
+
const collections = (await ctx.schema.listCollections()).map((c) => c.slug);
|
|
455
|
+
step = { collections, index: 0 };
|
|
456
|
+
}
|
|
457
|
+
let scanned = 0;
|
|
458
|
+
for (let pages = 0; pages < 4 && step.index < step.collections.length; pages++) {
|
|
459
|
+
const collection = step.collections[step.index];
|
|
460
|
+
const result = await ctx.content.list(collection, { limit: 50, cursor: step.cursor });
|
|
461
|
+
const rows: Array<{ id: string; data: UsageDoc }> = [];
|
|
462
|
+
for (const item of result.items) {
|
|
463
|
+
scanned++;
|
|
464
|
+
const record = { ...item } as unknown as Record<string, unknown>;
|
|
465
|
+
const title = entryTitle(record);
|
|
466
|
+
for (const fileId of fileIdsIn(item.data))
|
|
467
|
+
rows.push({ id: usageId(fileId, collection, item.id), data: { fileId, collection, entryId: item.id, entryKey: `${collection}:${item.id}`, title } });
|
|
468
|
+
}
|
|
469
|
+
if (rows.length) await usage.putMany(rows);
|
|
470
|
+
if (result.hasMore && result.cursor) step = { ...step, cursor: result.cursor };
|
|
471
|
+
else step = { collections: step.collections, index: step.index + 1 };
|
|
472
|
+
}
|
|
473
|
+
const done = step.index >= step.collections.length;
|
|
474
|
+
return { done, scanned, next: done ? undefined : step };
|
|
475
|
+
},
|
|
476
|
+
}),
|
|
477
|
+
|
|
478
|
+
// ── Large uploads ──
|
|
479
|
+
|
|
480
|
+
"files/upload-start": definePluginRoute({
|
|
481
|
+
permission: "plugins:manage",
|
|
482
|
+
methods: ["POST"],
|
|
483
|
+
request: { body: "json" },
|
|
484
|
+
handler: async (ctx) => {
|
|
485
|
+
await requireFeature(ctx, LARGE_UPLOADS_FEATURE);
|
|
486
|
+
const input = parseInput(
|
|
487
|
+
z.object({
|
|
488
|
+
name: z.string().trim().min(1).max(255),
|
|
489
|
+
size: z.number().int().positive().max(MAX_UPLOAD),
|
|
490
|
+
type: z.string().max(255).optional(),
|
|
491
|
+
}),
|
|
492
|
+
ctx.input,
|
|
493
|
+
);
|
|
494
|
+
const maxGb = (await ctx.settings.get<number>("filesMaxUploadGb")) ?? DEFAULT_MAX_UPLOAD_GB;
|
|
495
|
+
if (input.size > Math.min(maxGb, MAX_UPLOAD_GB_SETTING) * 1024 ** 3)
|
|
496
|
+
throw PluginRouteError.badRequest(`That file is ${formatSize(input.size)}; the limit is ${maxGb} GB (Files → Settings).`);
|
|
497
|
+
const r2 = await client(ctx);
|
|
498
|
+
const id = newUploadId();
|
|
499
|
+
const key = `files/${id}/${safeFilename(input.name)}`;
|
|
500
|
+
const type = /^[\w.+-]+\/[\w.+-]+$/.test(input.type ?? "") ? input.type! : "application/octet-stream";
|
|
501
|
+
const uploadId = await s3(() => r2.createMultipart(key, type));
|
|
502
|
+
const partSize = partSizeFor(input.size);
|
|
503
|
+
const doc: UploadDoc = {
|
|
504
|
+
id,
|
|
505
|
+
key,
|
|
506
|
+
name: input.name.replace(/[\r\n\t\0]/g, " "),
|
|
507
|
+
size: input.size,
|
|
508
|
+
type,
|
|
509
|
+
uploadedAt: new Date().toISOString(),
|
|
510
|
+
status: "uploading",
|
|
511
|
+
uploadId,
|
|
512
|
+
partSize,
|
|
513
|
+
};
|
|
514
|
+
await col<UploadDoc>(ctx, COLLECTIONS.uploads).put(id, doc);
|
|
515
|
+
return { id, partSize, partCount: Math.max(1, Math.ceil(input.size / partSize)) };
|
|
516
|
+
},
|
|
517
|
+
}),
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* One part of a large upload, uploaded to R2 by the Worker. EmDash's admin
|
|
521
|
+
* Content-Security-Policy (connect-src 'self', since EmDash 1.3.1) blocks the browser
|
|
522
|
+
* from sending parts to R2 itself. The part goes to the multipart upload recorded by
|
|
523
|
+
* files/upload-start, and must be exactly the size that part should be.
|
|
524
|
+
*/
|
|
525
|
+
"files/upload-part": definePluginRoute({
|
|
526
|
+
permission: "plugins:manage",
|
|
527
|
+
methods: ["POST"],
|
|
528
|
+
request: { body: "bytes", maxBytes: PART_BODY_MAX, headers: [UPLOAD_ID_HEADER, PART_NUMBER_HEADER] },
|
|
529
|
+
handler: async (ctx) => {
|
|
530
|
+
await requireFeature(ctx, LARGE_UPLOADS_FEATURE);
|
|
531
|
+
const id = ctx.request.headers.get(UPLOAD_ID_HEADER) ?? "";
|
|
532
|
+
const partNumber = Number(ctx.request.headers.get(PART_NUMBER_HEADER));
|
|
533
|
+
const bytes = ctx.input as Uint8Array;
|
|
534
|
+
if (!id || id.length > 64) throw PluginRouteError.badRequest("Unknown upload.");
|
|
535
|
+
const doc = await getUpload(ctx, id);
|
|
536
|
+
if (doc.status !== "uploading" || !doc.uploadId || !doc.partSize) throw PluginRouteError.conflict("That upload is already finished.");
|
|
537
|
+
const partCount = Math.max(1, Math.ceil(doc.size / doc.partSize));
|
|
538
|
+
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > partCount) throw PluginRouteError.badRequest("Part number out of range.");
|
|
539
|
+
const expected = partNumber < partCount ? doc.partSize : doc.size - (partCount - 1) * doc.partSize;
|
|
540
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== expected)
|
|
541
|
+
throw PluginRouteError.badRequest(`Part ${partNumber} should be ${expected} bytes, not ${bytes instanceof Uint8Array ? bytes.byteLength : 0}.`);
|
|
542
|
+
const r2 = await client(ctx);
|
|
543
|
+
const etag = await s3(() => r2.uploadPart(doc.key, doc.uploadId!, partNumber, bytes));
|
|
544
|
+
return { partNumber, etag };
|
|
545
|
+
},
|
|
546
|
+
}),
|
|
547
|
+
|
|
548
|
+
"files/upload-complete": definePluginRoute({
|
|
549
|
+
permission: "plugins:manage",
|
|
550
|
+
methods: ["POST"],
|
|
551
|
+
request: { body: "json", maxBytes: 1024 * 1024 },
|
|
552
|
+
handler: async (ctx) => {
|
|
553
|
+
await requireFeature(ctx, LARGE_UPLOADS_FEATURE);
|
|
554
|
+
const input = parseInput(
|
|
555
|
+
z.object({
|
|
556
|
+
id: z.string().max(64),
|
|
557
|
+
parts: z.array(z.object({ partNumber: z.number().int().min(1).max(10_000), etag: z.string().min(1).max(200) })).min(1).max(10_000),
|
|
558
|
+
}),
|
|
559
|
+
ctx.input,
|
|
560
|
+
);
|
|
561
|
+
const doc = await getUpload(ctx, input.id);
|
|
562
|
+
if (doc.status !== "uploading" || !doc.uploadId) throw PluginRouteError.conflict("That upload is already finished.");
|
|
563
|
+
const r2 = await client(ctx);
|
|
564
|
+
await s3(() => r2.completeMultipart(doc.key, doc.uploadId!, input.parts));
|
|
565
|
+
const head = await s3(() => r2.headObject(doc.key));
|
|
566
|
+
if (!head) throw PluginRouteError.badRequest("R2 didn't keep the uploaded file. Try again.");
|
|
567
|
+
const ready: UploadDoc = { id: doc.id, key: doc.key, name: doc.name, size: head.size || doc.size, type: doc.type, uploadedAt: new Date().toISOString(), status: "ready" };
|
|
568
|
+
await col<UploadDoc>(ctx, COLLECTIONS.uploads).put(doc.id, ready);
|
|
569
|
+
invalidateSiteCache(doc.id);
|
|
570
|
+
ctx.log.info("Large upload finished", { id: doc.id, size: ready.size });
|
|
571
|
+
const settings = await siteSettings(ctx);
|
|
572
|
+
return { ...ready, url: downloadPath(settings.base, ready.id, ready.name) };
|
|
573
|
+
},
|
|
574
|
+
}),
|
|
575
|
+
|
|
576
|
+
"files/upload-abort": definePluginRoute({
|
|
577
|
+
permission: "plugins:manage",
|
|
578
|
+
methods: ["POST"],
|
|
579
|
+
request: { body: "json" },
|
|
580
|
+
handler: async (ctx) => {
|
|
581
|
+
await requireFeature(ctx, LARGE_UPLOADS_FEATURE);
|
|
582
|
+
const { id } = parseInput(idInput, ctx.input);
|
|
583
|
+
const doc = await getUpload(ctx, id);
|
|
584
|
+
if (doc.status !== "uploading") throw PluginRouteError.conflict("That upload is already finished; delete it instead.");
|
|
585
|
+
if (doc.uploadId) {
|
|
586
|
+
const r2 = await client(ctx);
|
|
587
|
+
await s3(() => r2.abortMultipart(doc.key, doc.uploadId!));
|
|
588
|
+
}
|
|
589
|
+
await col<UploadDoc>(ctx, COLLECTIONS.uploads).delete(id);
|
|
590
|
+
return { aborted: true };
|
|
591
|
+
},
|
|
592
|
+
}),
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
return { routes };
|
|
596
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { PluginContext } from "emdash";
|
|
2
|
+
|
|
3
|
+
import { isOn, registerFeatures, siteFeatures } from "../core/features.js";
|
|
4
|
+
import type { PackMiddleware, PackModule } from "../core/module.js";
|
|
5
|
+
import { CLEANUP_TASK, COUNTS_FEATURE, FILES_FEATURE, LARGE_UPLOADS_FEATURE, type FilesOptions, filesModule, filesStorage, abortStaleUploads, indexEntry, unindexEntry } from "./module.js";
|
|
6
|
+
import { serveDownload } from "./serve.js";
|
|
7
|
+
import { BLOCK_TYPE } from "./walker.js";
|
|
8
|
+
|
|
9
|
+
const FEATURES = [
|
|
10
|
+
{
|
|
11
|
+
id: FILES_FEATURE,
|
|
12
|
+
label: "File Downloads",
|
|
13
|
+
description: "A download card block for any file, stable download URLs (/download/<id>/<name>), and a Files page.",
|
|
14
|
+
default: false,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: COUNTS_FEATURE,
|
|
18
|
+
label: "Download counts",
|
|
19
|
+
description: "Count downloads per file (written after the response, batched).",
|
|
20
|
+
default: false,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: LARGE_UPLOADS_FEATURE,
|
|
24
|
+
label: "Large uploads",
|
|
25
|
+
description: "Upload files up to 50 GB of any type to R2 through the site (needs R2 API credentials).",
|
|
26
|
+
default: false,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
registerFeatures(FEATURES);
|
|
30
|
+
|
|
31
|
+
/** Toggles default to on; a block can hide each part of its card. */
|
|
32
|
+
const toggle = (action_id: string, label: string) => ({ type: "toggle" as const, action_id, label, initial_value: true });
|
|
33
|
+
|
|
34
|
+
export function filesPack(options: FilesOptions): PackModule {
|
|
35
|
+
return {
|
|
36
|
+
id: "files",
|
|
37
|
+
label: "File Downloads",
|
|
38
|
+
features: FEATURES,
|
|
39
|
+
routes: filesModule(options).routes,
|
|
40
|
+
hooks: {
|
|
41
|
+
"content:afterSave": async (event: { content: Record<string, unknown>; collection: string }, ctx: PluginContext) => {
|
|
42
|
+
await indexEntry(ctx, event.collection, event.content);
|
|
43
|
+
},
|
|
44
|
+
"content:afterDelete": async (event: { id: string; collection: string }, ctx: PluginContext) => {
|
|
45
|
+
await unindexEntry(ctx, event.collection, event.id);
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
tasks: [
|
|
49
|
+
{
|
|
50
|
+
name: CLEANUP_TASK,
|
|
51
|
+
schedule: "@daily",
|
|
52
|
+
feature: LARGE_UPLOADS_FEATURE,
|
|
53
|
+
handler: async (ctx: PluginContext) => {
|
|
54
|
+
await abortStaleUploads(ctx);
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
adminPages: [{ path: "/files", label: "Files", icon: "file-arrow-down" }],
|
|
59
|
+
storage: filesStorage,
|
|
60
|
+
capabilities: ["content:read", "schema:read", "media:read", "network:request"],
|
|
61
|
+
allowedHosts: ["*.r2.cloudflarestorage.com"],
|
|
62
|
+
portableTextBlocks: [
|
|
63
|
+
{
|
|
64
|
+
type: BLOCK_TYPE,
|
|
65
|
+
label: "File download",
|
|
66
|
+
icon: "file",
|
|
67
|
+
category: "Media",
|
|
68
|
+
description: "A download card for a file from the Media Library or a large upload",
|
|
69
|
+
fields: [
|
|
70
|
+
{ type: "select", action_id: "id", label: "File", options: [], optionsRoute: "files/options" },
|
|
71
|
+
{ type: "text_input", action_id: "title", label: "Title (optional; defaults to the file name)" },
|
|
72
|
+
{ type: "text_input", action_id: "description", label: "Description (optional)", multiline: true },
|
|
73
|
+
toggle("showIcon", "Show the file-type icon"),
|
|
74
|
+
toggle("showDescription", "Show the description"),
|
|
75
|
+
toggle("showMeta", "Show type, size and upload date"),
|
|
76
|
+
toggle("showDownload", "Show the Download button"),
|
|
77
|
+
toggle("showCopyLink", "Show the Copy link button"),
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Serves /<base>/<id>/<name> downloads. Bindings: DB, MEDIA, and FILES when large uploads use their own bucket. */
|
|
85
|
+
export const filesMiddleware: PackMiddleware = {
|
|
86
|
+
module: "files",
|
|
87
|
+
feature: FILES_FEATURE,
|
|
88
|
+
handle: async (context, env, waitUntil) => {
|
|
89
|
+
const count = isOn(await siteFeatures(), COUNTS_FEATURE);
|
|
90
|
+
return serveDownload(context.request, context.url, env, waitUntil, { count });
|
|
91
|
+
},
|
|
92
|
+
};
|