Skip to content

Commit

Permalink
fix(vite-plugin): fix remote sources breaking Rendering by implementi…
Browse files Browse the repository at this point in the history
…ng proxy(motion-canvas#338)
  • Loading branch information
WaldemarLehner committed Feb 15, 2023
1 parent 66b41e6 commit 39443c4
Show file tree
Hide file tree
Showing 2 changed files with 268 additions and 0 deletions.
21 changes: 21 additions & 0 deletions packages/vite-plugin/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import fs from 'fs';
import {Readable} from 'stream';
import mime from 'mime-types';
import projectInstance from './__logs__/project-instance.md';
import {
motionCanvasCorsProxy,
MotionCanvasCorsProxyPluginOptions,
} from './proxy-middleware';

export interface MotionCanvasPluginConfig {
/**
Expand Down Expand Up @@ -67,6 +71,16 @@ export interface MotionCanvasPluginConfig {
* @default '\@motion-canvas/ui'
*/
editor?: string;
/**
* Configuration of the Proxy used for remote sources
*
* @remarks
* This passes configuration to Motion Canvas' proxy.
* Not setting this value will allow all `image/*` and `video/*` resources from any hostname.
* You can also disable the proxy entirely by passing `false`. This will however mean that you
* cannot render any animation that relies on remote sources.
*/
proxy?: false | MotionCanvasCorsProxyPluginOptions;
}

interface ProjectData {
Expand All @@ -79,6 +93,7 @@ export default ({
output = './output',
bufferedAssets = /\.(wav|ogg)$/,
editor = '@motion-canvas/ui',
proxy = undefined,
}: MotionCanvasPluginConfig = {}): Plugin => {
const editorPath = path.dirname(require.resolve(editor));
const editorFile = fs.readFileSync(path.resolve(editorPath, 'editor.html'));
Expand Down Expand Up @@ -311,6 +326,12 @@ export default ({

next();
});

// This is intentionally !== false as undefined should be allowed
if (proxy !== false) {
motionCanvasCorsProxy(server.middlewares, proxy);
}

server.ws.on('motion-canvas:meta', async ({source, data}, client) => {
timeStamps[source] = Date.now();
await fs.promises.writeFile(
Expand Down
247 changes: 247 additions & 0 deletions packages/vite-plugin/src/proxy-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* This modules provides the proxy located at
* /motion-canvas-proxy/...
*
* It is needed when acessing remote resources.
* Trying to access remote resources works while
* in preview, but will fail when you try to
* output the image (= "read" the canvas)
*
* See https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image
* for reasons
*
* Using the proxy circumvents CORS-issues because
* this way all remote resources are served from the
* same server (localhost:9000) as the main app.
*/

import {Connect} from 'vite';
import axios from 'axios';
import {ServerResponse, IncomingMessage} from 'http';

/**
* Configuration used by the Proxy plugin
*/
export interface MotionCanvasCorsProxyPluginOptions {
/**
* Set which types of resources are allowed by default.
*
* @remarks
* Catchall on the right side is supported.
* Pass an empty Array to allow all types of resources, although this is not recommended.
*
* @default ["image/*", "video/*"]
*/
allowedMimeTypes?: string[];
/**
* Set which hosts are allowed
*
*
* @remarks Note that the host if everything to the left of the first `/`, and to the right of the protocol `https://`
* Allowlist is not used by default, although you should consider setting up just the relevant hosts.
*
* Note that `api.iconify.design` is **always** enabled, as it is used by the Icon-Component.
* It gets added by the Middleware internally.
*/
allowListHosts?: string[];
}

// Add Hosts required for core functionality to work
const predefinedAllowlist = [
// Icons
'api.iconify.design',
];

export function motionCanvasCorsProxy(
middleware: Connect.Server,
config: MotionCanvasCorsProxyPluginOptions | undefined = {},
) {
// Set the default config if no config was provided
config.allowedMimeTypes ??= ['image/*', 'video/*'];
config.allowListHosts ??= [];

config.allowListHosts.push(...predefinedAllowlist);

// Check the Mime Types to have a correct structure (left/right)
// not having them in the correct format would crash the Middleware
// further down below
if ((config.allowedMimeTypes ?? []).some(e => e.split('/').length !== 2)) {
throw new Error(
"Invalid config for Proxy:\nAll Entries must have the following format:\n 'left/right' where left may be '*'",
);
}

middleware.use((req, res, next) => {
if (!req.url || !req.url.startsWith('/cors-proxy/')) {
// url does not start with /cors-proxy/, so this
// middleware does not care about it
return next();
}

// For now, only allow GET Requests
if (req.method !== 'GET') {
return writeError('Only GET Requests are allowed', res, 405);
}

const [sourceUrl, error] = extractDestination(req.url);
if (error || !sourceUrl) {
return writeError(error, res);
}

if (
!isReceivedUrlInAllowedHosts(sourceUrl.hostname, config.allowListHosts)
) {
return writeError(
`Blocked by Proxy: ${sourceUrl.hostname} is not on Hosts Allowlist`,
res,
);
}

// Get the resource, do some checks. Throws an Error
// if the checks fail. The catch then writes an Error
return tryGetResource(res, sourceUrl, config).catch(error => {
writeError(error, res);
});
});
}

/**
* Unwrap the destination from the URL.
*
* @param url - the entire URL with the `/cors-proxy/` prefix and containing the url-Encoded Path
* @returns the URL that needs to be called
*/
function extractDestination(
url: string,
): [URL, undefined] | [undefined, string] {
try {
const withoutPrefix = url.replace('/cors-proxy/', '');
const asUrl = new URL(decodeURIComponent(withoutPrefix));
if (asUrl.protocol !== 'http:' && asUrl.protocol !== 'https:') {
throw new Error('Only supported protocols are http and https');
}

return [asUrl, undefined];
} catch (err) {
return [undefined, err + ''];
}
}

/**
* A simple Error Helper that will write an Error and close
* the response
*/
function writeError(
message: string,
res: ServerResponse<IncomingMessage>,
statusCode = 400,
) {
res.writeHead(statusCode, message);
res.end();
}

/**
* Check if the Proxy is allowed to get the requested resource based on the host
*/
function isReceivedUrlInAllowedHosts(
hostname: string,
allowListHosts: string[] | undefined,
) {
if (!allowListHosts || allowListHosts.length <= predefinedAllowlist.length) {
// if the allListHosts is just the predefinedAllowlist, the user has not
// set any additional hosts. In this case, allow any hostname
return true;
}
// Check if the hostname is any of the values set in allowListHosts
return allowListHosts.some(
e => e.toLowerCase().trim() === hostname.toLowerCase().trim(),
);
}

/**
* Check if the Proxy is allowed to get the requested resource based on the MIME-Type
*
* @remarks
* Also handles catch-All Declarations like image/*
*/
function isResultOfAllowedResourceType(
foundMimeType: string,
allowedMimeTypes: string[],
) {
if (!allowedMimeTypes || allowedMimeTypes.length === 0) {
return true; // no filters set
}

if (foundMimeType.split('/').length !== 2) {
return false; // invalid mime structure
}

const [leftSegment, rightSegment] = foundMimeType
.split('/')
.map(e => e.trim().toLowerCase());

// Get all Segments where the left Part is identical between foundMimeType and allowedMimeType
const leftSegmentMatches = allowedMimeTypes.filter(
e => e.trim().toLowerCase().split('/')[0] === leftSegment,
);
if (leftSegmentMatches.length === 0) {
return false; // No matches at all, not even catchall - resource is rejected
}

// This just gets the right part of the MIME Types from the
// configured allowList, e.g. "image/png" -> png
const rightSegmentOfLeftSegmentMatches = leftSegmentMatches.map(
e => e.split('/')[1],
);

// if an exact match, or a catchall is found, the resource is allowed to be proxied.
return rightSegmentOfLeftSegmentMatches.some(
e => e === '*' || e === rightSegment,
);
}

/**
* Requests a remote resource with the help of axios
* May throw a string in case of a bad mime-type or missing headers
*/
async function tryGetResource(
res: ServerResponse<IncomingMessage>,
sourceUrl: URL,
config: MotionCanvasCorsProxyPluginOptions,
): Promise<void> {
const result = await axios.get(sourceUrl.toString(), {
responseType: 'stream',
maxRedirects: 3,
});

if (result.status >= 300) {
throw 'Unexpected Status: ' + result.status;
}

const contentType = result.headers['content-type'];
const contentLength = result.headers['content-length'];

if (!contentType) {
throw 'Proxied Response does not contain a Content Type';
}
if (!contentLength) {
throw 'Proxied Response does not contain a Content Length';
}

if (
!isResultOfAllowedResourceType(
contentType.toString(),
config.allowedMimeTypes ?? [],
)
) {
throw 'Proxied response has blocked content-type: ' + contentType;
}

// Prepare Response
delete result.headers['content-length'];
for (const key in result.headers) {
res.setHeader(key, result.headers[key]);
}
res.setHeader('x-proxy-destination', sourceUrl.toString());
result.data.pipe(res);
}

0 comments on commit 39443c4

Please sign in to comment.