swf_assets/show action to embed a canvas movie in a sandboxed iframe
Not using this on the item page preview yet, but we will! I like this approach over e.g. a web component specifically for the sandboxing: while I don't exactly *distrust* JS that we're loading from Neopets.com, I don't like the idea of *any* part of the site that executes arbitrary JS unsafely at runtime, even if we theoretically trust where it theoretically came from. I don't want any failure upstream to have effects on us! I copied basically all of the JS from a related project `impress-media-server` that I had spun up at one point, to investigate similar embed techniques. Easy peasy drop-in-squeezy!
This commit is contained in:
parent
5ad320fa18
commit
5b2062754d
8 changed files with 449 additions and 2 deletions
15
app/assets/javascripts/lib/easeljs.min.js
vendored
Normal file
15
app/assets/javascripts/lib/easeljs.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
app/assets/javascripts/lib/tweenjs.min.js
vendored
Normal file
12
app/assets/javascripts/lib/tweenjs.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
315
app/assets/javascripts/swf_assets/show.js
Normal file
315
app/assets/javascripts/swf_assets/show.js
Normal file
|
@ -0,0 +1,315 @@
|
|||
const canvas = document.getElementById("asset-canvas");
|
||||
const libraryScript = document.getElementById("canvas-movie-library");
|
||||
const libraryUrl = libraryScript.getAttribute("src");
|
||||
|
||||
// Read the asset ID from the URL, as an extra hint of what asset we're
|
||||
// logging for. (This is helpful when there's a lot of assets animating!)
|
||||
const assetId = document.location.pathname.split("/").at(-1);
|
||||
const logPrefix = `[${assetId}] `.padEnd(9);
|
||||
|
||||
// State for controlling the movie.
|
||||
let loadingStatus = "loading";
|
||||
let playingStatus = getInitialPlayingStatus();
|
||||
|
||||
// State for loading the movie.
|
||||
let library = null;
|
||||
let movieClip = null;
|
||||
let stage = null;
|
||||
|
||||
// State for animating the movie.
|
||||
let frameRequestId = null;
|
||||
let lastFrameTime = null;
|
||||
let lastLogTime = null;
|
||||
let numFramesSinceLastLog = 0;
|
||||
|
||||
// State for error reporting.
|
||||
let hasLoggedRenderError = false;
|
||||
|
||||
function loadImage(src) {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
image.onload = () => {
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
reject(new Error(`Failed to load image: ${JSON.stringify(src)}`));
|
||||
};
|
||||
image.src = src;
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function getLibrary() {
|
||||
if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
|
||||
throw new Error(
|
||||
`Movie library ${libraryUrl} did not add a composition to window.AdobeAn.compositions.`,
|
||||
);
|
||||
}
|
||||
const [compositionId, composition] = Object.entries(
|
||||
window.AdobeAn.compositions,
|
||||
)[0];
|
||||
if (Object.keys(window.AdobeAn.compositions).length > 1) {
|
||||
console.warn(
|
||||
`Grabbing composition ${compositionId}, but there are >1 here: `,
|
||||
Object.keys(window.AdobeAn.compositions).length,
|
||||
);
|
||||
}
|
||||
delete window.AdobeAn.compositions[compositionId];
|
||||
|
||||
const library = composition.getLibrary();
|
||||
|
||||
// One more loading step as part of loading this library is loading the
|
||||
// images it uses for sprites.
|
||||
//
|
||||
// TODO: I guess the manifest has these too, so we could put them in preload
|
||||
// meta tags to get them here faster?
|
||||
const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/");
|
||||
const manifestImages = new Map(
|
||||
library.properties.manifest.map(({ id, src }) => [
|
||||
id,
|
||||
loadImage(librarySrcDir + "/" + src),
|
||||
]),
|
||||
);
|
||||
|
||||
await Promise.all(manifestImages.values());
|
||||
|
||||
// Finally, once we have the images loaded, the library object expects us to
|
||||
// mutate it (!) to give it the actual image and sprite sheet objects from
|
||||
// the loaded images. That's how the MovieClip's internal JS objects will
|
||||
// access the loaded data!
|
||||
const images = composition.getImages();
|
||||
for (const [id, image] of manifestImages.entries()) {
|
||||
images[id] = await image;
|
||||
}
|
||||
const spriteSheets = composition.getSpriteSheet();
|
||||
for (const { name, frames } of library.ssMetadata) {
|
||||
const image = await manifestImages.get(name);
|
||||
spriteSheets[name] = new window.createjs.SpriteSheet({
|
||||
images: [image],
|
||||
frames,
|
||||
});
|
||||
}
|
||||
|
||||
return library;
|
||||
}
|
||||
|
||||
function buildMovieClip(library) {
|
||||
let constructorName;
|
||||
try {
|
||||
const fileName = decodeURI(libraryUrl).split("/").pop();
|
||||
const fileNameWithoutExtension = fileName.split(".")[0];
|
||||
constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
|
||||
if (constructorName.match(/^[0-9]/)) {
|
||||
constructorName = "_" + constructorName;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Movie libraryUrl ${JSON.stringify(libraryUrl)} did not match expected ` +
|
||||
`format: ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const LibraryMovieClipConstructor = library[constructorName];
|
||||
if (!LibraryMovieClipConstructor) {
|
||||
throw new Error(
|
||||
`Expected JS movie library ${libraryUrl} to contain a constructor ` +
|
||||
`named ${constructorName}, but it did not: ${Object.keys(library)}`,
|
||||
);
|
||||
}
|
||||
const movieClip = new LibraryMovieClipConstructor();
|
||||
|
||||
return movieClip;
|
||||
}
|
||||
|
||||
function updateStage() {
|
||||
try {
|
||||
stage.update();
|
||||
} catch (e) {
|
||||
// If rendering the frame fails, log it and proceed. If it's an
|
||||
// animation, then maybe the next frame will work? Also alert the user,
|
||||
// just as an FYI. (This is pretty uncommon, so I'm not worried about
|
||||
// being noisy!)
|
||||
if (!hasLoggedRenderError) {
|
||||
console.error(`Error rendering movie clip ${libraryUrl}`, e);
|
||||
// TODO: Inform user about the failure
|
||||
hasLoggedRenderError = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateCanvasDimensions() {
|
||||
// Set the canvas's internal dimensions to be higher, if the device has high
|
||||
// DPI. Scale the movie clip to match, too.
|
||||
const internalWidth = canvas.offsetWidth * window.devicePixelRatio;
|
||||
const internalHeight = canvas.offsetHeight * window.devicePixelRatio;
|
||||
canvas.width = internalWidth;
|
||||
canvas.height = internalHeight;
|
||||
movieClip.scaleX = internalWidth / library.properties.width;
|
||||
movieClip.scaleY = internalHeight / library.properties.height;
|
||||
}
|
||||
|
||||
async function startMovie() {
|
||||
// Load the movie's library (from the JS file already run), and use it to
|
||||
// build a movie clip.
|
||||
library = await getLibrary();
|
||||
movieClip = buildMovieClip(library);
|
||||
|
||||
updateCanvasDimensions();
|
||||
|
||||
if (canvas.getContext("2d") == null) {
|
||||
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
|
||||
// TODO: "Too many animations!"
|
||||
return;
|
||||
}
|
||||
|
||||
stage = new window.createjs.Stage(canvas);
|
||||
stage.addChild(movieClip);
|
||||
updateStage();
|
||||
|
||||
loadingStatus = "loaded";
|
||||
canvas.setAttribute("data-status", "loaded");
|
||||
|
||||
updateAnimationState();
|
||||
}
|
||||
|
||||
function updateAnimationState() {
|
||||
const shouldRunAnimations =
|
||||
loadingStatus === "loaded" && playingStatus === "playing";
|
||||
|
||||
if (shouldRunAnimations && frameRequestId == null) {
|
||||
lastFrameTime = document.timeline.currentTime;
|
||||
lastLogTime = document.timeline.currentTime;
|
||||
numFramesSinceLastLog = 0;
|
||||
documentHiddenSinceLastFrame = document.hidden;
|
||||
frameRequestId = requestAnimationFrame(onAnimationFrame);
|
||||
} else if (!shouldRunAnimations && frameRequestId != null) {
|
||||
cancelAnimationFrame(frameRequestId);
|
||||
lastFrameTime = null;
|
||||
lastLogTime = null;
|
||||
numFramesSinceLastLog = 0;
|
||||
documentHiddenSinceLastFrame = false;
|
||||
frameRequestId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onAnimationFrame() {
|
||||
const targetFps = library.properties.fps;
|
||||
const msPerFrame = 1000 / targetFps;
|
||||
const msSinceLastFrame = document.timeline.currentTime - lastFrameTime;
|
||||
const msSinceLastLog = document.timeline.currentTime - lastLogTime;
|
||||
|
||||
// If it takes too long to render a frame, cancel the movie, on the
|
||||
// assumption that we're riding the CPU too hard. (Some movies do this!)
|
||||
//
|
||||
// But note that, if the page is hidden (e.g. the window is not visible),
|
||||
// it's normal for the browser to pause animations. So, if we detected that
|
||||
// the document became hidden between this frame and the last, no
|
||||
// intervention is necesary.
|
||||
if (msSinceLastFrame >= 2000 && !documentHiddenSinceLastFrame) {
|
||||
pause();
|
||||
console.warn(`Paused movie for taking too long: ${msSinceLastFrame}ms`);
|
||||
// TODO: Display message about low FPS, and sync up to the parent.
|
||||
return;
|
||||
}
|
||||
|
||||
if (msSinceLastFrame >= msPerFrame) {
|
||||
updateStage();
|
||||
lastFrameTime = document.timeline.currentTime;
|
||||
|
||||
// If we're a little bit late to this frame, probably because the frame
|
||||
// rate isn't an even divisor of 60 FPS, backdate it to what the ideal time
|
||||
// for this frame *would* have been. (For example, without this tweak, a
|
||||
// 24 FPS animation like the Floating Negg Faerie actually runs at 20 FPS,
|
||||
// because it wants to run every 41.66ms, but a 60 FPS browser checks in
|
||||
// every 16.66ms, so the best it can do is 50ms. With this tweak, we can
|
||||
// *pretend* we ran at 41.66ms, so that the next frame timing correctly
|
||||
// takes the extra 9.33ms into account.)
|
||||
const msFrameDelay = msSinceLastFrame - msPerFrame;
|
||||
if (msFrameDelay < msPerFrame) {
|
||||
lastFrameTime -= msFrameDelay;
|
||||
}
|
||||
|
||||
numFramesSinceLastLog++;
|
||||
}
|
||||
|
||||
if (msSinceLastLog >= 5000) {
|
||||
const fps = numFramesSinceLastLog / (msSinceLastLog / 1000);
|
||||
console.debug(
|
||||
`${logPrefix} FPS: ${fps.toFixed(2)} (Target: ${targetFps})`,
|
||||
);
|
||||
lastLogTime = document.timeline.currentTime;
|
||||
numFramesSinceLastLog = 0;
|
||||
}
|
||||
|
||||
frameRequestId = requestAnimationFrame(onAnimationFrame);
|
||||
documentHiddenSinceLastFrame = document.hidden;
|
||||
}
|
||||
|
||||
// If `document.hidden` becomes true at any point, log it for the next
|
||||
// animation frame. (The next frame will reset the state, as will starting or
|
||||
// stopping the animation.)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
documentHiddenSinceLastFrame = true;
|
||||
}
|
||||
});
|
||||
|
||||
function play() {
|
||||
playingStatus = "playing";
|
||||
updateAnimationState();
|
||||
}
|
||||
|
||||
function pause() {
|
||||
playingStatus = "paused";
|
||||
updateAnimationState();
|
||||
}
|
||||
|
||||
function getInitialPlayingStatus() {
|
||||
const params = new URLSearchParams(document.location.search);
|
||||
if (params.has("playing")) {
|
||||
return "playing";
|
||||
} else {
|
||||
return "paused";
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
updateCanvasDimensions();
|
||||
|
||||
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
|
||||
// to `false`, so that we don't advance by a frame. This keeps us
|
||||
// really-paused if we're paused, and avoids skipping ahead by a frame if
|
||||
// we're playing.
|
||||
stage.tickOnUpdate = false;
|
||||
updateStage();
|
||||
stage.tickOnUpdate = true;
|
||||
});
|
||||
|
||||
window.addEventListener("message", ({ data }) => {
|
||||
// NOTE: For more sensitive messages, it's important for security to also
|
||||
// check the `origin` property of the incoming event. But in this case, I'm
|
||||
// okay with whatever site is embedding us being able to send play/pause!
|
||||
if (data.type === "play") {
|
||||
play();
|
||||
} else if (data.type === "pause") {
|
||||
pause();
|
||||
} else {
|
||||
throw new Error(`unexpected message: ${JSON.stringify(data)}`);
|
||||
}
|
||||
});
|
||||
|
||||
startMovie().catch((error) => {
|
||||
console.error(logPrefix, error);
|
||||
|
||||
loadingStatus = "error";
|
||||
canvas.setAttribute("data-status", "error");
|
||||
canvas.setAttribute("data-error-message", error.message);
|
||||
|
||||
// If loading the movie fails, show the fallback image instead, by moving
|
||||
// it out of the canvas content and into the body.
|
||||
document.body.appendChild(document.getElementById("fallback"));
|
||||
console.warn("Showing fallback image instead.");
|
||||
});
|
8
app/assets/stylesheets/swf_assets/show.css
Normal file
8
app/assets/stylesheets/swf_assets/show.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
#asset-canvas,
|
||||
#fallback {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: min(100vw, 100vh);
|
||||
height: min(100vw, 100vh);
|
||||
}
|
44
app/controllers/swf_assets_controller.rb
Normal file
44
app/controllers/swf_assets_controller.rb
Normal file
|
@ -0,0 +1,44 @@
|
|||
class SwfAssetsController < ApplicationController
|
||||
# We're very careful with what content is allowed to load. This is because
|
||||
# asset movies run arbitrary JS, and, while we generally trust content from
|
||||
# Neopets.com, let's not be *allowing* movie JS to do whatever it wants! This
|
||||
# is a good default security stance, even if we don't foresee an attack.
|
||||
content_security_policy do |policy|
|
||||
policy.sandbox "allow-scripts"
|
||||
policy.default_src "none"
|
||||
|
||||
policy.img_src -> {
|
||||
src_list(
|
||||
helpers.image_url("favicon.png"),
|
||||
@swf_asset.image_url,
|
||||
*@swf_asset.canvas_movie_sprite_urls,
|
||||
)
|
||||
}
|
||||
|
||||
policy.script_src_elem -> {
|
||||
src_list(
|
||||
helpers.javascript_url("lib/easeljs.min"),
|
||||
helpers.javascript_url("lib/tweenjs.min"),
|
||||
helpers.javascript_url("swf_assets/show"),
|
||||
@swf_asset.canvas_movie_library_url,
|
||||
)
|
||||
}
|
||||
|
||||
policy.style_src_elem -> {
|
||||
src_list(
|
||||
helpers.stylesheet_url("swf_assets/show"),
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
def show
|
||||
@swf_asset = SwfAsset.find params[:id]
|
||||
render layout: nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def src_list(*urls)
|
||||
urls.filter(&:present?).map { |url| url.sub(/\?.*\z/, "") }.join(" ")
|
||||
end
|
||||
end
|
|
@ -140,7 +140,10 @@ class SwfAsset < ApplicationRecord
|
|||
# assets in the same manifest, and earlier ones are broken and later
|
||||
# ones are fixed. I don't know the logic exactly, but that's what we've
|
||||
# seen!
|
||||
{ js: assets_by_ext[:js].last }
|
||||
{
|
||||
js: assets_by_ext[:js].last,
|
||||
sprites: assets_by_ext.fetch(:png, []),
|
||||
}
|
||||
else
|
||||
# Otherwise, return the first PNG and the first SVG. (Unlike the JS
|
||||
# case, it's important to choose the *first* PNG, because sometimes
|
||||
|
@ -185,8 +188,21 @@ class SwfAsset < ApplicationRecord
|
|||
nil
|
||||
end
|
||||
|
||||
def canvas_movie?
|
||||
canvas_movie_library_url.present?
|
||||
end
|
||||
|
||||
def canvas_movie_library_url
|
||||
manifest_asset_urls[:js]
|
||||
end
|
||||
|
||||
def canvas_movie_sprite_urls
|
||||
return [] unless canvas_movie?
|
||||
manifest_asset_urls[:sprites]
|
||||
end
|
||||
|
||||
def canvas_movie_image_url
|
||||
return nil unless manifest_asset_urls[:js]
|
||||
return nil unless canvas_movie?
|
||||
|
||||
CANVAS_MOVIE_IMAGE_URL_TEMPLATE.expand(
|
||||
libraryUrl: manifest_asset_urls[:js],
|
||||
|
|
36
app/views/swf_assets/show.html.haml
Normal file
36
app/views/swf_assets/show.html.haml
Normal file
|
@ -0,0 +1,36 @@
|
|||
!!! 5
|
||||
%html
|
||||
%head
|
||||
%meta{charset: "utf-8"}
|
||||
%meta{name: "viewport", content: "width=device-width, initial-scale=1"}
|
||||
%title
|
||||
Embed for Asset ##{@swf_asset.id} | #{t "app_name"}
|
||||
%link{href: image_path("favicon.png"), rel: "icon"}
|
||||
|
||||
-# NOTE: For all these assets, the Content-Security-Policy doesn't account
|
||||
-# for asset debug mode, so let's just opt out of it with `debug: false`!
|
||||
- if @swf_asset.canvas_movie?
|
||||
-# Load the stylesheet first, because displaying things correctly is the
|
||||
-# actual most essential thing.
|
||||
= stylesheet_link_tag "swf_assets/show", debug: false
|
||||
|
||||
-# This is optional, but preloading the sprites can help us from having
|
||||
-# to wait on all the other JS to load and set up before we start!
|
||||
- @swf_asset.canvas_movie_sprite_urls.each do |sprite_url|
|
||||
%link{rel: "preload", href: sprite_url, as: "image", crossorigin: "anonymous"}
|
||||
|
||||
-# Load the scripts: EaselJS libs first, then the asset's "library" file,
|
||||
-# then our page script that starts the movie.
|
||||
= javascript_include_tag "lib/easeljs.min", defer: true, debug: false
|
||||
= javascript_include_tag "lib/tweenjs.min", defer: true, debug: false
|
||||
= javascript_include_tag @swf_asset.canvas_movie_library_url, defer: true,
|
||||
id: "canvas-movie-library"
|
||||
= javascript_include_tag "swf_assets/show", defer: true, debug: false
|
||||
%body
|
||||
- if @swf_asset.canvas_movie?
|
||||
%canvas#asset-canvas
|
||||
-# Show a fallback image, for users with JS disabled. Lazy-load it, so
|
||||
-# the browser won't bother to load it if it's not used.
|
||||
= image_tag @swf_asset.image_url, id: "fallback", alt: "", loading: "lazy"
|
||||
- else
|
||||
= image_tag @swf_asset.image_url, alt: ""
|
|
@ -37,6 +37,7 @@ OpenneoImpressItems::Application.routes.draw do
|
|||
resources :alt_styles, path: 'alt-styles', only: [:index]
|
||||
end
|
||||
resources :alt_styles, path: 'alt-styles', only: [:index]
|
||||
resources :swf_assets, path: 'swf-assets', only: [:show]
|
||||
|
||||
# Loading and modeling pets!
|
||||
post '/pets/load' => 'pets#load', :as => :load_pet
|
||||
|
|
Loading…
Reference in a new issue