@edgestore/server
Version:
Upload files with ease from React/Next.js
467 lines (462 loc) • 14.7 kB
JavaScript
'use strict';
var shared = require('@edgestore/shared');
var hkdf = require('@panva/hkdf');
var cookie = require('cookie');
var jose = require('jose');
var uuid = require('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 = [
cookie.serialize(resolvedCookieConfig.ctx.name, ctxToken, resolvedCookieConfig.ctx.options)
];
if (token) {
newCookies.push(cookie.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 shared.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 shared.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 shared.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 shared.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 shared.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 shared.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 shared.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 shared.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 shared.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 shared.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 shared.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 shared.EdgeStoreError({
message: 'Missing edgestore-ctx cookie',
code: 'UNAUTHORIZED'
});
}
const ctx = await getContext(ctxToken);
const bucket = router.buckets[bucketName];
if (!bucket) {
throw new shared.EdgeStoreError({
message: `Bucket ${bucketName} not found`,
code: 'BAD_REQUEST'
});
}
if (!bucket._def.beforeDelete) {
throw new shared.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 shared.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 shared.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 jose.EncryptJWT(ctx).setProtectedHeader({
alg: 'dir',
enc: 'A256GCM'
}).setIssuedAt().setExpirationTime(Date.now() / 1000 + DEFAULT_MAX_AGE).setJti(uuid.v4()).encrypt(encryptionSecret);
}
async function decryptJWT(token) {
const secret = getEnv('EDGE_STORE_JWT_SECRET') ?? getEnv('EDGE_STORE_SECRET_KEY');
if (!secret) {
throw new shared.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 jose.jwtDecrypt(token, encryptionSecret, {
clockTolerance: 15
});
return payload;
}
async function getDerivedEncryptionKey(secret) {
return await hkdf.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 shared.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 shared.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] ?? undefined?.[key];
}
// @ts-expect-error - In Vite/Astro, the env variables are available on `import.meta`.
return undefined?.[key];
}
function isDev() {
return process?.env?.NODE_ENV === 'development' || // @ts-expect-error - In Vite/Astro, the env variables are available on `import.meta`.
undefined?.DEV;
}
exports.buildPath = buildPath;
exports.completeMultipartUpload = completeMultipartUpload;
exports.confirmUpload = confirmUpload;
exports.deleteFile = deleteFile;
exports.getCookieConfig = getCookieConfig;
exports.getEnv = getEnv;
exports.init = init;
exports.isDev = isDev;
exports.parsePath = parsePath;
exports.requestUpload = requestUpload;
exports.requestUploadParts = requestUploadParts;