forked from motion-canvas/motion-canvas
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(vite-plugin): fix remote sources breaking Rendering by implementi…
…ng proxy(motion-canvas#338)
- Loading branch information
1 parent
66b41e6
commit 39443c4
Showing
2 changed files
with
268 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |