UNPKG

@lucidcms/plugin-s3

Version:

The official S3 plugin for Lucid CMS

663 lines (662 loc) 20.7 kB
import { AwsClient } from "aws4fetch"; import { copy } from "@lucidcms/core/plugin"; //#region src/clients/aws-client.ts let awsClient = null; const getS3Client = (pluginOptions) => { if (!awsClient) awsClient = new AwsClient(pluginOptions.clientOptions); return awsClient; }; //#endregion //#region src/services/delete-multiple.ts var delete_multiple_default = (client, pluginOptions) => { const deleteMultiple = async (_context, { keys }) => { try { const deleteXml = `<?xml version="1.0" encoding="UTF-8"?> <Delete> ${keys.map((key) => `<Object><Key>${key}</Key></Object>`).join("")} </Delete>`; const response = await client.sign(new Request(`${pluginOptions.endpoint}/${pluginOptions.bucket}?delete`, { method: "POST", body: deleteXml, headers: { "Content-Type": "application/xml" } })); const result = await fetch(response); if (!result.ok) return { error: { type: "plugin", message: copy("server:plugin.s3.objects.delete.multiple.failed", { data: { status: result.status, statusText: result.statusText } }) }, data: void 0 }; return { error: void 0, data: void 0 }; } catch (e) { return { error: { type: "plugin", message: e instanceof Error ? copy.literal(e.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; return deleteMultiple; }; //#endregion //#region src/services/delete-single.ts var delete_single_default = (client, pluginOptions) => { const deleteSingle = async (_context, { key }) => { try { const response = await client.sign(new Request(`${pluginOptions.endpoint}/${pluginOptions.bucket}/${key}`, { method: "DELETE" })); const result = await fetch(response); if (!result.ok) return { error: { type: "plugin", message: copy("server:plugin.s3.objects.delete.single.failed", { data: { status: result.status, statusText: result.statusText } }) }, data: void 0 }; return { error: void 0, data: void 0 }; } catch (e) { return { error: { type: "plugin", message: e instanceof Error ? copy.literal(e.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; return deleteSingle; }; //#endregion //#region src/constants.ts const PLUGIN_KEY = "plugin-s3"; const LUCID_VERSION = "0.x.x"; const PRESIGNED_URL_EXPIRY = 3600; const DEFAULT_PART_SIZE = 8 * 1024 * 1024; //#endregion //#region src/utils/build-download-content-disposition.ts const replaceControlCharacters = (value) => Array.from(value, (char) => { const code = char.charCodeAt(0); return code <= 31 || code === 127 ? " " : char; }).join(""); const sanitizeDownloadFileName = (value) => { const basename = value.replace(/\\/g, "/").split("/").filter(Boolean).at(-1) ?? value; return replaceControlCharacters(basename).replace(/"/g, "").replace(/\s+/g, " ").trim() || "download"; }; const buildDownloadContentDisposition = (props) => { const persistedFileName = props.fileName?.trim(); const normalizedKey = props.key.split("/").filter(Boolean).at(-1) ?? props.key; return `attachment; filename="${persistedFileName ? sanitizeDownloadFileName(persistedFileName) : sanitizeDownloadFileName(props.extension && !normalizedKey.endsWith(`.${props.extension}`) ? `${normalizedKey}.${props.extension}` : normalizedKey)}"`; }; //#endregion //#region src/services/get-download-url.ts var get_download_url_default = (client, pluginOptions) => { const getDownloadUrl = async (_context, props) => { try { const objectUrl = new URL(`${pluginOptions.endpoint}/${pluginOptions.bucket}/${props.key}`); objectUrl.searchParams.set("X-Amz-Expires", String(PRESIGNED_URL_EXPIRY)); objectUrl.searchParams.set("response-content-disposition", buildDownloadContentDisposition({ key: props.key, fileName: props.fileName, extension: props.extension })); return { error: void 0, data: { url: (await client.sign(new Request(objectUrl.toString(), { method: "GET" }), { aws: { signQuery: true } })).url.toString() } }; } catch (e) { return { error: { type: "plugin", message: e instanceof Error ? copy.literal(e.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; return getDownloadUrl; }; //#endregion //#region src/utils/metadata-headers.ts const METADATA_EXTENSION_HEADER = "x-amz-meta-extension"; const METADATA_SIZE_HEADER = "x-amz-meta-size"; const applyMetadataHeaders = (headers, meta) => { if (meta.mimeType) headers.set("Content-Type", meta.mimeType); if (meta.extension) headers.set(METADATA_EXTENSION_HEADER, meta.extension); if (meta.size !== void 0) headers.set(METADATA_SIZE_HEADER, `${meta.size}`); }; const parseStoredSize = (value) => { if (!value) return null; const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; //#endregion //#region src/services/get-metadata.ts var get_metadata_default = (client, pluginOptions) => { const getMetadata = async (_context, { key }) => { try { const response = await client.sign(new Request(`${pluginOptions.endpoint}/${pluginOptions.bucket}/${key}`, { method: "HEAD" })); const result = await fetch(response); if (!result.ok) return { error: { type: "plugin", message: copy("server:plugin.s3.objects.metadata.fetch.failed", { data: { status: result.status, statusText: result.statusText } }) }, data: void 0 }; const contentLength = parseStoredSize(result.headers.get("content-length")) ?? parseStoredSize(result.headers.get("x-amz-meta-size")); if (contentLength === null) return { error: { message: copy("server:plugin.s3.objects.metadata.missing") }, data: void 0 }; const contentType = result.headers.get("content-type"); const etag = result.headers.get("etag"); return { error: void 0, data: { size: contentLength, mimeType: contentType || null, etag: etag || null } }; } catch (e) { return { error: { type: "plugin", message: e instanceof Error ? copy.literal(e.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; return getMetadata; }; //#endregion //#region src/services/rename.ts var rename_default = (client, pluginOptions) => { const rename = async (_context, props) => { try { const signObjectRequest = async (key, method, headers) => client.sign(new Request(`${pluginOptions.endpoint}/${pluginOptions.bucket}/${key}`, { method, headers })); const copyReq = await signObjectRequest(props.to, "PUT", { "x-amz-copy-source": `/${pluginOptions.bucket}/${props.from}` }); const copyRes = await fetch(copyReq); if (!copyRes.ok) return { error: { type: "plugin", message: copy("server:plugin.s3.objects.copy.failed", { data: { status: copyRes.status, statusText: copyRes.statusText } }) }, data: void 0 }; const headReq = await signObjectRequest(props.to, "HEAD"); const headRes = await fetch(headReq); if (!headRes.ok) { try { const cleanupReq = await signObjectRequest(props.to, "DELETE"); await fetch(cleanupReq); } catch {} return { error: { type: "plugin", message: copy("server:plugin.s3.objects.copy.failed", { data: { status: headRes.status, statusText: headRes.statusText } }) }, data: void 0 }; } const deleteReq = await signObjectRequest(props.from, "DELETE"); const deleteRes = await fetch(deleteReq); if (!deleteRes.ok) { try { const cleanupReq = await signObjectRequest(props.to, "DELETE"); await fetch(cleanupReq); } catch {} return { error: { type: "plugin", message: copy("server:plugin.s3.objects.delete.failed", { data: { status: deleteRes.status, statusText: deleteRes.statusText } }) }, data: void 0 }; } return { error: void 0, data: void 0 }; } catch (e) { return { error: { type: "plugin", message: e instanceof Error ? copy.literal(e.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; return rename; }; //#endregion //#region src/services/stream.ts var stream_default = (client, pluginOptions) => { const stream = async (_context, { key, range, ifNoneMatch }) => { try { const headers = {}; if (range) { const start = range.start; const end = range.end; headers.Range = end !== void 0 ? `bytes=${start}-${end}` : `bytes=${start}-`; } if (ifNoneMatch) headers["If-None-Match"] = ifNoneMatch; const response = await client.sign(new Request(`${pluginOptions.endpoint}/${pluginOptions.bucket}/${key}`, { method: "GET", headers })); const result = await fetch(response); if (result.status === 304) return { error: void 0, data: { contentLength: void 0, contentType: void 0, body: /* @__PURE__ */ new Uint8Array(), etag: result.headers.get("etag"), notModified: true } }; if (!result.ok) return { error: { message: copy("server:plugin.s3.objects.stream.failed", { data: { status: result.status, statusText: result.statusText } }) }, data: void 0 }; if (!result.body) return { error: { message: copy("server:plugin.s3.objects.body.missing") }, data: void 0 }; let isPartialContent = false; let totalSize; let rangeRes; const contentRange = result.headers.get("content-range"); if (contentRange) { isPartialContent = true; const match = contentRange.match(/bytes (\d+)-(\d+)\/(\d+)/); if (match?.[1] && match[2] && match[3]) { rangeRes = { start: Number.parseInt(match[1], 10), end: Number.parseInt(match[2], 10) }; totalSize = Number.parseInt(match[3], 10); } } const contentLength = result.headers.get("content-length"); const contentType = result.headers.get("content-type"); const etag = result.headers.get("etag"); return { error: void 0, data: { contentLength: contentLength ? Number.parseInt(contentLength, 10) : void 0, contentType: contentType || void 0, body: result.body, etag: etag || null, isPartialContent, totalSize: totalSize || (contentLength ? Number.parseInt(contentLength, 10) : void 0), range: rangeRes } }; } catch (e) { return { error: { type: "plugin", message: e instanceof Error ? copy.literal(e.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; return stream; }; //#endregion //#region src/services/upload-session/helpers.ts /** Builds the S3 object URL that aws4fetch signs for multipart operations. */ const objectUrl = (pluginOptions, key, query = "") => `${pluginOptions.endpoint}/${pluginOptions.bucket}/${key}${query}`; /** Extracts simple S3 XML response values without adding a runtime XML parser. */ const extractXmlValue = (xml, tag) => { return xml.match(new RegExp(`<${tag}>(.*?)</${tag}>`))?.[1]; }; /** Converts S3 list-parts XML into Lucid's adapter part shape for resume checks. */ const parseParts = (xml) => { return Array.from(xml.matchAll(/<Part>([\s\S]*?)<\/Part>/g)).map((match) => { const partXml = match[1] ?? ""; return { partNumber: Number(extractXmlValue(partXml, "PartNumber") ?? 0), etag: (extractXmlValue(partXml, "ETag") ?? "").replace(/"/g, ""), size: Number(extractXmlValue(partXml, "Size") ?? 0) }; }); }; /** * Falls back to Lucid's single-upload contract for zero-byte files, where an S3 * multipart session cannot produce meaningful parts. */ const createSingleUploadSession = async (client, pluginOptions, key, meta) => { const headers = new Headers(); applyMetadataHeaders(headers, meta); const response = await client.sign(new Request(objectUrl(pluginOptions, key, `?X-Amz-Expires=${PRESIGNED_URL_EXPIRY}`), { method: "PUT" }), { headers, aws: { signQuery: true } }); return { mode: "single", key, url: response.url.toString(), headers: Object.fromEntries(response.headers.entries()) }; }; //#endregion //#region src/services/upload-session/abort-upload-session.ts /** * Cancels an unfinished multipart upload so S3 can discard uploaded parts and * stop charging for incomplete session storage. */ const abortUploadSession = (client, pluginOptions) => { return async (_context, props) => { try { const signed = await client.sign(new Request(objectUrl(pluginOptions, props.key, `?uploadId=${encodeURIComponent(props.uploadId)}`), { method: "DELETE" })); const response = await fetch(signed); if (!response.ok && response.status !== 404) return { error: { type: "plugin", message: copy("server:plugin.s3.objects.delete.failed", { data: { status: response.status, statusText: response.statusText } }) }, data: void 0 }; return { error: void 0, data: void 0 }; } catch (error) { return { error: { type: "plugin", message: error instanceof Error ? copy.literal(error.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; }; //#endregion //#region src/services/upload-session/complete-upload-session.ts /** * Finalizes the multipart upload with S3 after Lucid has verified every part is * present, making the object available as one media file. */ const completeUploadSession = (client, pluginOptions) => { return async (_context, props) => { try { const body = `<CompleteMultipartUpload>${props.parts.sort((a, b) => a.partNumber - b.partNumber).map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>"${part.etag.replace(/"/g, "")}"</ETag></Part>`).join("")}</CompleteMultipartUpload>`; const signed = await client.sign(new Request(objectUrl(pluginOptions, props.key, `?uploadId=${encodeURIComponent(props.uploadId)}`), { method: "POST", body, headers: { "Content-Type": "application/xml" } })); const response = await fetch(signed); if (!response.ok) return { error: { type: "plugin", message: copy("server:plugin.s3.objects.upload.failed", { data: { status: response.status, statusText: response.statusText } }) }, data: void 0 }; return { error: void 0, data: { etag: extractXmlValue(await response.text(), "ETag")?.replace(/"/g, "") } }; } catch (error) { return { error: { type: "plugin", message: error instanceof Error ? copy.literal(error.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; }; //#endregion //#region src/services/upload-session/create-upload-session.ts /** * Starts an S3 multipart upload and returns Lucid's resumable session metadata. * Zero-byte files stay on a single signed PUT because multipart has no parts. */ const createUploadSession = (client, pluginOptions) => { return async (_context, props) => { try { if (props.size === 0) return { error: void 0, data: await createSingleUploadSession(client, pluginOptions, props.key, props) }; const headers = new Headers(); applyMetadataHeaders(headers, props); const signed = await client.sign(new Request(objectUrl(pluginOptions, props.key, "?uploads"), { method: "POST", headers })); const response = await fetch(signed); if (!response.ok) return { error: { type: "plugin", message: copy("server:plugin.s3.objects.upload.failed", { data: { status: response.status, statusText: response.statusText } }) }, data: void 0 }; const uploadId = extractXmlValue(await response.text(), "UploadId"); if (!uploadId) return { error: { type: "plugin", message: copy("server:plugin.s3.upload.sessions.upload.id.missing") }, data: void 0 }; return { error: void 0, data: { mode: "resumable", key: props.key, uploadId, partSize: DEFAULT_PART_SIZE, expiresAt: new Date(Date.now() + PRESIGNED_URL_EXPIRY * 1e3).toISOString(), uploadedParts: [] } }; } catch (error) { return { error: { type: "plugin", message: error instanceof Error ? copy.literal(error.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; }; //#endregion //#region src/services/upload-session/get-upload-part-urls.ts /** * Signs browser-writable URLs for the missing multipart chunks requested by * Lucid, keeping AWS credentials on the server. */ const getUploadPartUrls = (client, pluginOptions) => { return async (_context, props) => { try { return { error: void 0, data: { parts: await Promise.all(props.partNumbers.map(async (partNumber) => { const signed = await client.sign(new Request(objectUrl(pluginOptions, props.key, `?partNumber=${partNumber}&uploadId=${encodeURIComponent(props.uploadId)}&X-Amz-Expires=${PRESIGNED_URL_EXPIRY}`), { method: "PUT" }), { aws: { signQuery: true } }); return { partNumber, url: signed.url.toString(), headers: Object.fromEntries(signed.headers.entries()) }; })) } }; } catch (error) { return { error: { type: "plugin", message: error instanceof Error ? copy.literal(error.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; }; //#endregion //#region src/services/upload-session/list-upload-parts.ts /** * Reads S3's multipart state so the admin can resume from the parts the storage * provider has already accepted. */ const listUploadParts = (client, pluginOptions) => { return async (_context, props) => { try { const signed = await client.sign(new Request(objectUrl(pluginOptions, props.key, `?uploadId=${encodeURIComponent(props.uploadId)}`), { method: "GET" })); const response = await fetch(signed); if (!response.ok) return { error: { type: "plugin", message: copy("server:plugin.s3.objects.metadata.fetch.failed", { data: { status: response.status, statusText: response.statusText } }) }, data: void 0 }; return { error: void 0, data: { uploadedParts: parseParts(await response.text()) } }; } catch (error) { return { error: { type: "plugin", message: error instanceof Error ? copy.literal(error.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; }; //#endregion //#region src/services/upload-single.ts var upload_single_default = (client, pluginOptions) => { const uploadSingle = async (_context, props) => { try { const headers = new Headers(); applyMetadataHeaders(headers, props); const response = await client.sign(new Request(`${pluginOptions.endpoint}/${pluginOptions.bucket}/${props.key}`, { method: "PUT", body: props.body, headers })); const result = await fetch(response); if (!result.ok) return { error: { type: "plugin", message: copy("server:plugin.s3.objects.upload.failed", { data: { status: result.status, statusText: result.statusText } }) }, data: void 0 }; return { error: void 0, data: { etag: result.headers.get("etag")?.replace(/"/g, "") } }; } catch (e) { return { error: { type: "plugin", message: e instanceof Error ? copy.literal(e.message) : copy("server:plugin.s3.errors.unknown") }, data: void 0 }; } }; return uploadSingle; }; //#endregion //#region src/adapter.ts const s3MediaAdapter = (options) => { const client = getS3Client(options); return { type: "media-adapter", key: "s3", createUploadSession: createUploadSession(client, options), getUploadPartUrls: getUploadPartUrls(client, options), listUploadParts: listUploadParts(client, options), completeUploadSession: completeUploadSession(client, options), abortUploadSession: abortUploadSession(client, options), getDownloadUrl: get_download_url_default(client, options), getMeta: get_metadata_default(client, options), stream: stream_default(client, options), upload: upload_single_default(client, options), delete: delete_single_default(client, options), deleteMultiple: delete_multiple_default(client, options), rename: rename_default(client, options), getOptions: () => options }; }; //#endregion //#region src/plugin.ts const plugin = (pluginOptions) => { return { key: PLUGIN_KEY, lucid: LUCID_VERSION, recipe: (draft) => { draft.i18n.sources.push("@lucidcms/plugin-s3/translations"); draft.media.adapter = s3MediaAdapter(pluginOptions); } }; }; //#endregion //#region src/index.ts var src_default = plugin; //#endregion export { src_default as default, plugin as s3Plugin }; //# sourceMappingURL=index.mjs.map