UNPKG

@edgestore/server

Version:

Upload files with ease from React/Next.js

467 lines (462 loc) 14.7 kB
'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;