@edgestore/server
Version:
Upload files with ease from React/Next.js
455 lines (451 loc) • 14.4 kB
JavaScript
import { EdgeStoreError } from '@edgestore/shared';
import { hkdf } from '@panva/hkdf';
import { serialize } from 'cookie';
import { EncryptJWT, jwtDecrypt } from 'jose';
import { v4 } from 'uuid';
const IMAGE_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'image/tiff',
'image/bmp',
'image/x-icon'
];
// TODO: change it to 1 hour when we have a way to refresh the token
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60; // 30 days
/**
* Merges the provided cookie configuration with default values
*/ function getCookieConfig(cookieConfig) {
const defaultOptions = {
path: '/',
maxAge: DEFAULT_MAX_AGE
};
// Helper function to merge options, filtering out undefined values
const mergeOptions = (configOptions)=>{
const merged = {
...defaultOptions
};
if (configOptions) {
Object.keys(configOptions).forEach((key)=>{
const value = configOptions[key];
if (value !== undefined) {
merged[key] = value;
}
});
}
return merged;
};
return {
ctx: {
name: cookieConfig?.ctx?.name ?? 'edgestore-ctx',
options: mergeOptions(cookieConfig?.ctx?.options)
},
token: {
name: cookieConfig?.token?.name ?? 'edgestore-token',
options: mergeOptions(cookieConfig?.token?.options)
}
};
}
async function init(params) {
const log = globalThis._EDGE_STORE_LOGGER;
const { ctx, provider, router, cookieConfig } = params;
log.debug('Running [init]', {
ctx
});
const resolvedCookieConfig = getCookieConfig(cookieConfig);
const ctxToken = await encryptJWT(ctx);
const { token } = await provider.init({
ctx,
router: router
});
const newCookies = [
serialize(resolvedCookieConfig.ctx.name, ctxToken, resolvedCookieConfig.ctx.options)
];
if (token) {
newCookies.push(serialize(resolvedCookieConfig.token.name, token, resolvedCookieConfig.token.options));
}
const baseUrl = await provider.getBaseUrl();
log.debug('Finished [init]', {
ctx,
newCookies,
token,
baseUrl,
providerName: provider.name
});
return {
newCookies,
token,
baseUrl,
providerName: provider.name
};
}
async function requestUpload(params) {
const { provider, router, ctxToken, body: { bucketName, input, fileInfo } } = params;
const log = globalThis._EDGE_STORE_LOGGER;
log.debug('Running [requestUpload]', {
bucketName,
input,
fileInfo
});
if (!ctxToken) {
throw new EdgeStoreError({
message: 'Missing edgestore-ctx cookie',
code: 'UNAUTHORIZED'
});
}
const ctx = await getContext(ctxToken);
log.debug('Decrypted Context', {
ctx
});
const bucket = router.buckets[bucketName];
if (!bucket) {
throw new EdgeStoreError({
message: `Bucket ${bucketName} not found`,
code: 'BAD_REQUEST'
});
}
if (bucket._def.beforeUpload) {
log.debug('Running [beforeUpload]');
const canUpload = await bucket._def.beforeUpload?.({
ctx,
input,
fileInfo: {
size: fileInfo.size,
type: fileInfo.type,
fileName: fileInfo.fileName,
extension: fileInfo.extension,
replaceTargetUrl: fileInfo.replaceTargetUrl,
temporary: fileInfo.temporary
}
});
log.debug('Finished [beforeUpload]', {
canUpload
});
if (!canUpload) {
throw new EdgeStoreError({
message: 'Upload not allowed for the current context',
code: 'UPLOAD_NOT_ALLOWED'
});
}
}
if (bucket._def.type === 'IMAGE') {
if (!IMAGE_MIME_TYPES.includes(fileInfo.type)) {
throw new EdgeStoreError({
code: 'MIME_TYPE_NOT_ALLOWED',
message: 'Only images are allowed in this bucket',
details: {
allowedMimeTypes: IMAGE_MIME_TYPES,
mimeType: fileInfo.type
}
});
}
}
if (bucket._def.bucketConfig?.maxSize) {
if (fileInfo.size > bucket._def.bucketConfig.maxSize) {
throw new EdgeStoreError({
code: 'FILE_TOO_LARGE',
message: `File size is too big. Max size is ${bucket._def.bucketConfig.maxSize}`,
details: {
maxFileSize: bucket._def.bucketConfig.maxSize,
fileSize: fileInfo.size
}
});
}
}
if (bucket._def.bucketConfig?.accept) {
const accept = bucket._def.bucketConfig.accept;
let accepted = false;
for (const acceptedMimeType of accept){
if (acceptedMimeType.endsWith('/*')) {
const mimeType = acceptedMimeType.replace('/*', '');
if (fileInfo.type.startsWith(mimeType)) {
accepted = true;
break;
}
} else if (fileInfo.type === acceptedMimeType) {
accepted = true;
break;
}
}
if (!accepted) {
throw new EdgeStoreError({
code: 'MIME_TYPE_NOT_ALLOWED',
message: `"${fileInfo.type}" is not allowed. Accepted types are ${JSON.stringify(accept)}`,
details: {
allowedMimeTypes: accept,
mimeType: fileInfo.type
}
});
}
}
const path = buildPath({
fileInfo,
bucket,
pathAttrs: {
ctx,
input
}
});
const metadata = await bucket._def.metadata?.({
ctx,
input
});
const isPublic = bucket._def.accessControl === undefined;
log.debug('upload info', {
path,
metadata,
isPublic,
bucketType: bucket._def.type
});
const requestUploadRes = await provider.requestUpload({
bucketName,
bucketType: bucket._def.type,
fileInfo: {
...fileInfo,
path,
isPublic,
metadata
}
});
const { parsedPath, pathOrder } = parsePath(path);
log.debug('Finished [requestUpload]');
return {
...requestUploadRes,
size: fileInfo.size,
uploadedAt: new Date().toISOString(),
path: parsedPath,
pathOrder,
metadata
};
}
async function requestUploadParts(params) {
const { provider, ctxToken, body: { multipart, path } } = params;
const log = globalThis._EDGE_STORE_LOGGER;
log.debug('Running [requestUploadParts]', {
multipart,
path
});
if (!ctxToken) {
throw new EdgeStoreError({
message: 'Missing edgestore-ctx cookie',
code: 'UNAUTHORIZED'
});
}
await getContext(ctxToken); // just to check if the token is valid
const res = await provider.requestUploadParts({
multipart,
path
});
log.debug('Finished [requestUploadParts]');
return res;
}
async function completeMultipartUpload(params) {
const { provider, router, ctxToken, body: { bucketName, uploadId, key, parts } } = params;
const log = globalThis._EDGE_STORE_LOGGER;
log.debug('Running [completeMultipartUpload]', {
bucketName,
uploadId,
key
});
if (!ctxToken) {
throw new EdgeStoreError({
message: 'Missing edgestore-ctx cookie',
code: 'UNAUTHORIZED'
});
}
await getContext(ctxToken); // just to check if the token is valid
const bucket = router.buckets[bucketName];
if (!bucket) {
throw new EdgeStoreError({
message: `Bucket ${bucketName} not found`,
code: 'BAD_REQUEST'
});
}
const res = await provider.completeMultipartUpload({
uploadId,
key,
parts
});
log.debug('Finished [completeMultipartUpload]');
return res;
}
async function confirmUpload(params) {
const { provider, router, ctxToken, body: { bucketName, url } } = params;
const log = globalThis._EDGE_STORE_LOGGER;
log.debug('Running [confirmUpload]', {
bucketName,
url
});
if (!ctxToken) {
throw new EdgeStoreError({
message: 'Missing edgestore-ctx cookie',
code: 'UNAUTHORIZED'
});
}
await getContext(ctxToken); // just to check if the token is valid
const bucket = router.buckets[bucketName];
if (!bucket) {
throw new EdgeStoreError({
message: `Bucket ${bucketName} not found`,
code: 'BAD_REQUEST'
});
}
const res = await provider.confirmUpload({
bucket,
url: unproxyUrl(url)
});
log.debug('Finished [confirmUpload]');
return res;
}
async function deleteFile(params) {
const { provider, router, ctxToken, body: { bucketName, url } } = params;
const log = globalThis._EDGE_STORE_LOGGER;
log.debug('Running [deleteFile]', {
bucketName,
url
});
if (!ctxToken) {
throw new EdgeStoreError({
message: 'Missing edgestore-ctx cookie',
code: 'UNAUTHORIZED'
});
}
const ctx = await getContext(ctxToken);
const bucket = router.buckets[bucketName];
if (!bucket) {
throw new EdgeStoreError({
message: `Bucket ${bucketName} not found`,
code: 'BAD_REQUEST'
});
}
if (!bucket._def.beforeDelete) {
throw new EdgeStoreError({
message: 'You need to define beforeDelete if you want to delete files directly from the frontend.',
code: 'SERVER_ERROR'
});
}
const fileInfo = await provider.getFile({
url: unproxyUrl(url)
});
const canDelete = await bucket._def.beforeDelete({
ctx,
fileInfo
});
if (!canDelete) {
throw new EdgeStoreError({
message: 'Delete not allowed for the current context',
code: 'DELETE_NOT_ALLOWED'
});
}
const res = await provider.deleteFile({
bucket,
url: unproxyUrl(url)
});
log.debug('Finished [deleteFile]');
return res;
}
async function encryptJWT(ctx) {
const secret = getEnv('EDGE_STORE_JWT_SECRET') ?? getEnv('EDGE_STORE_SECRET_KEY');
if (!secret) {
throw new EdgeStoreError({
message: 'EDGE_STORE_JWT_SECRET or EDGE_STORE_SECRET_KEY is not defined',
code: 'SERVER_ERROR'
});
}
const encryptionSecret = await getDerivedEncryptionKey(secret);
return await new EncryptJWT(ctx).setProtectedHeader({
alg: 'dir',
enc: 'A256GCM'
}).setIssuedAt().setExpirationTime(Date.now() / 1000 + DEFAULT_MAX_AGE).setJti(v4()).encrypt(encryptionSecret);
}
async function decryptJWT(token) {
const secret = getEnv('EDGE_STORE_JWT_SECRET') ?? getEnv('EDGE_STORE_SECRET_KEY');
if (!secret) {
throw new EdgeStoreError({
message: 'EDGE_STORE_JWT_SECRET or EDGE_STORE_SECRET_KEY is not defined',
code: 'SERVER_ERROR'
});
}
const encryptionSecret = await getDerivedEncryptionKey(secret);
const { payload } = await jwtDecrypt(token, encryptionSecret, {
clockTolerance: 15
});
return payload;
}
async function getDerivedEncryptionKey(secret) {
return await hkdf('sha256', secret, '', 'EdgeStore Generated Encryption Key', 32);
}
function buildPath(params) {
const { bucket } = params;
const pathParams = bucket._def.path;
const path = pathParams.map((param)=>{
const paramEntries = Object.entries(param);
if (paramEntries[0] === undefined) {
throw new EdgeStoreError({
message: `Empty path param found in: ${JSON.stringify(pathParams)}`,
code: 'SERVER_ERROR'
});
}
const [key, value] = paramEntries[0];
// this is a string like: "ctx.xxx" or "input.yyy.zzz"
const currParamVal = value().split('.').reduce((acc2, key)=>{
if (acc2[key] === undefined) {
throw new EdgeStoreError({
message: `Missing key ${key} in ${JSON.stringify(acc2)}`,
code: 'BAD_REQUEST'
});
}
return acc2[key];
}, params.pathAttrs);
return {
key,
value: currParamVal
};
});
return path;
}
function parsePath(path) {
const parsedPath = path.reduce((acc, curr)=>{
acc[curr.key] = curr.value;
return acc;
}, {});
const pathOrder = path.map((p)=>p.key);
return {
parsedPath,
pathOrder
};
}
async function getContext(token) {
return await decryptJWT(token);
}
/**
* On local development, protected files are proxied to the server,
* which changes the original URL.
*
* This function is used to get the original URL,
* so that we can delete or confirm the upload.
*/ function unproxyUrl(url) {
if (isDev() && url.startsWith('http://')) {
// get the url param from the query string
const urlParam = new URL(url).searchParams.get('url');
if (urlParam) {
return urlParam;
}
}
return url;
}
function getEnv(key) {
if (typeof process !== 'undefined' && process.env) {
// @ts-expect-error - In Vite/Astro, the env variables are available on `import.meta`.
return process.env[key] ?? import.meta.env?.[key];
}
// @ts-expect-error - In Vite/Astro, the env variables are available on `import.meta`.
return import.meta.env?.[key];
}
function isDev() {
return process?.env?.NODE_ENV === 'development' || // @ts-expect-error - In Vite/Astro, the env variables are available on `import.meta`.
import.meta.env?.DEV;
}
export { getCookieConfig as a, buildPath as b, init as c, requestUploadParts as d, completeMultipartUpload as e, confirmUpload as f, getEnv as g, deleteFile as h, isDev as i, parsePath as p, requestUpload as r };