@lucidcms/plugin-s3
Version:
The official S3 plugin for Lucid CMS
663 lines (662 loc) • 20.7 kB
JavaScript
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