[particles] Add server upload/download

This is mostly cribbed from Skottie.

Bug: skia:
Change-Id: I6c6d0e8ef1bf02a998b8827c49c83fa0f70c9b45
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/199249
Commit-Queue: Kevin Lubick <kjlubick@google.com>
Reviewed-by: Joe Gregorio <jcgregorio@google.com>
diff --git a/particles/Makefile b/particles/Makefile
index 6664e77..5913b41 100644
--- a/particles/Makefile
+++ b/particles/Makefile
@@ -2,6 +2,7 @@
 	npx webpack --mode=development
 
 release: wasm_libs_tot
+	CGO_ENABLED=0 GOOS=linux go install -a ./go/particles
 	npx webpack --mode=production
 	./build_release
 
@@ -15,6 +16,9 @@
 	npm install
 	touch package-lock.json
 
+app: package-lock.json
+	go install ./go/particles
+
 get_latest_skia:
 	docker pull gcr.io/skia-public/skia-wasm-release:prod
 
diff --git a/particles/go/particles/main.go b/particles/go/particles/main.go
new file mode 100644
index 0000000..c3455b8
--- /dev/null
+++ b/particles/go/particles/main.go
@@ -0,0 +1,258 @@
+package main
+
+import (
+	"context"
+	"crypto/md5"
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"html/template"
+	"io"
+	"mime"
+	"net/http"
+	"path/filepath"
+	"runtime"
+	"strings"
+
+	"cloud.google.com/go/storage"
+	"github.com/gorilla/mux"
+	"go.skia.org/infra/go/allowed"
+	"go.skia.org/infra/go/auth"
+	"go.skia.org/infra/go/common"
+	"go.skia.org/infra/go/httputils"
+	"go.skia.org/infra/go/login"
+	"go.skia.org/infra/go/skerr"
+	"go.skia.org/infra/go/sklog"
+	"go.skia.org/infra/go/util"
+	"google.golang.org/api/option"
+)
+
+const (
+	// BUCKET is the Cloud Storage bucket we store files in.
+	BUCKET          = "skparticles-renderer"
+	BUCKET_INTERNAL = "skparticles-renderer-internal"
+
+	MAX_FILENAME_SIZE = 5 * 1024
+	MAX_JSON_SIZE     = 10 * 1024 * 1024
+)
+
+// flags
+var (
+	local        = flag.Bool("local", false, "Running locally if true. As opposed to in production.")
+	lockedDown   = flag.Bool("locked_down", false, "Restricted to only @google.com accounts.")
+	port         = flag.String("port", ":8000", "HTTP service address (e.g., ':8000')")
+	promPort     = flag.String("prom_port", ":20000", "Metrics service address (e.g., ':10110')")
+	resourcesDir = flag.String("resources_dir", "", "The directory to find templates, JS, and CSS files. If blank the current directory will be used.")
+)
+
+var (
+	invalidRequestErr = errors.New("")
+)
+
+// Server is the state of the server.
+type Server struct {
+	bucket    *storage.BucketHandle
+	templates *template.Template
+}
+
+func New() (*Server, error) {
+	if *resourcesDir == "" {
+		_, filename, _, _ := runtime.Caller(0)
+		*resourcesDir = filepath.Join(filepath.Dir(filename), "../../dist")
+	}
+
+	// Need to set the mime-type for wasm files so streaming compile works.
+	if err := mime.AddExtensionType(".wasm", "application/wasm"); err != nil {
+		sklog.Fatal(err)
+	}
+
+	ts, err := auth.NewDefaultTokenSource(*local, storage.ScopeFullControl)
+	if err != nil {
+		return nil, fmt.Errorf("Failed to get token source: %s", err)
+	}
+	client := httputils.DefaultClientConfig().WithTokenSource(ts).With2xxOnly().Client()
+	storageClient, err := storage.NewClient(context.Background(), option.WithHTTPClient(client))
+	if err != nil {
+		return nil, fmt.Errorf("Problem creating storage client: %s", err)
+	}
+
+	if *lockedDown {
+		allow := allowed.NewAllowedFromList([]string{"google.com"})
+		login.InitWithAllow(*port, *local, nil, nil, allow)
+	}
+
+	bucket := BUCKET
+	if *lockedDown {
+		bucket = BUCKET_INTERNAL
+	}
+
+	srv := &Server{
+		bucket: storageClient.Bucket(bucket),
+	}
+	srv.loadTemplates()
+	return srv, nil
+}
+
+func (srv *Server) loadTemplates() {
+	srv.templates = template.Must(template.New("").Delims("{%", "%}").ParseFiles(
+		filepath.Join(*resourcesDir, "index.html"),
+	))
+}
+func (srv *Server) templateHandler(filename string) func(http.ResponseWriter, *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/html")
+		if *local {
+			srv.loadTemplates()
+		}
+		if err := srv.templates.ExecuteTemplate(w, filename, nil); err != nil {
+			sklog.Errorf("Failed to expand template %s: %s", filename, err)
+		}
+	}
+}
+func (srv *Server) jsonHandler(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+	hash := mux.Vars(r)["hash"]
+	path := strings.Join([]string{hash, "input.json"}, "/")
+	reader, err := srv.bucket.Object(path).NewReader(r.Context())
+	if err != nil {
+		sklog.Warningf("Can't load JSON file %s from GCS: %s", path, err)
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+	if _, err = io.Copy(w, reader); err != nil {
+		httputils.ReportError(w, r, err, "Failed to write JSON file.")
+		return
+	}
+}
+
+type UploadRequest struct {
+	ParticlesJSON interface{} `json:"json"` // the parsed JSON
+	Filename      string      `json:"filename"`
+}
+
+type UploadResponse struct {
+	Hash string `json:"hash"`
+}
+
+func (srv *Server) uploadHandler(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	// Extract json file.
+	defer util.Close(r.Body)
+	var req UploadRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		httputils.ReportError(w, r, err, "Error decoding JSON.")
+		return
+	}
+	// Check for maliciously sized input on any field we upload to GCS
+	if len(req.Filename) > MAX_FILENAME_SIZE {
+		httputils.ReportError(w, r, nil, "Input file(s) too big")
+		return
+	}
+
+	// Calculate md5 of UploadRequest (json contents and file name)
+	h := md5.New()
+	b, err := json.Marshal(req)
+	if err != nil {
+		httputils.ReportError(w, r, err, "Can't re-encode request.")
+		return
+	}
+	if _, err = h.Write(b); err != nil {
+		httputils.ReportError(w, r, err, "Failed calculating hash.")
+		return
+	}
+	hash := fmt.Sprintf("%x", h.Sum(nil))
+
+	if strings.HasSuffix(req.Filename, ".json") {
+		if err := srv.createFromJSON(&req, hash, ctx); err != nil {
+			httputils.ReportError(w, r, err, "Failed handing input of JSON.")
+			return
+		}
+	} else {
+		w.WriteHeader(http.StatusBadRequest)
+		msg := "Only .json files allowed"
+		if _, err := w.Write([]byte(msg)); err != nil {
+			sklog.Errorf("Failed to write error response: %s", err)
+		}
+		return
+	}
+
+	resp := UploadResponse{
+		Hash: hash,
+	}
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
+		sklog.Errorf("Failed to write response: %s", err)
+	}
+}
+
+func (srv *Server) createFromJSON(req *UploadRequest, hash string, ctx context.Context) error {
+	b, err := json.Marshal(req.ParticlesJSON)
+	if err != nil {
+		return skerr.Fmt("Can't re-encode json file: %s", err)
+	}
+	if len(b) > MAX_JSON_SIZE {
+		return skerr.Fmt("Particles JSON is too big (%d bytes): %s", len(b), err)
+	}
+
+	return srv.uploadState(req, hash, ctx)
+}
+
+func (srv *Server) uploadState(req *UploadRequest, hash string, ctx context.Context) error {
+	// Write JSON file, containing the state (filename, json, etc)
+	bytesToUpload, err := json.Marshal(req)
+	if err != nil {
+		return skerr.Fmt("Can't re-encode request: %s", err)
+	}
+
+	path := strings.Join([]string{hash, "input.json"}, "/")
+	obj := srv.bucket.Object(path)
+	wr := obj.NewWriter(ctx)
+	wr.ObjectAttrs.ContentEncoding = "application/json"
+	if _, err := wr.Write(bytesToUpload); err != nil {
+		return skerr.Fmt("Failed writing JSON to GCS: %s", err)
+	}
+	if err := wr.Close(); err != nil {
+		return skerr.Fmt("Failed writing JSON to GCS on close: %s", err)
+	}
+	return nil
+}
+
+func main() {
+	common.InitWithMust(
+		"particles",
+		common.PrometheusOpt(promPort),
+		common.MetricsLoggingOpt(),
+	)
+
+	if *lockedDown && *local {
+		sklog.Fatalf("Can't be run as both --locked_down and --local.")
+	}
+
+	srv, err := New()
+	if err != nil {
+		sklog.Fatalf("Failed to start: %s", err)
+	}
+
+	r := mux.NewRouter()
+	r.HandleFunc("/{hash:[0-9A-Za-z]*}", srv.templateHandler("index.html")).Methods("GET")
+	r.HandleFunc("/_/j/{hash:[0-9A-Za-z]+}", srv.jsonHandler).Methods("GET")
+	r.HandleFunc("/_/upload", srv.uploadHandler).Methods("POST")
+
+	r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.HandlerFunc(httputils.CorsHandler(httputils.MakeResourceHandler(*resourcesDir))))).Methods("GET")
+
+	// TODO(jcgregorio) Implement CSRF.
+	h := httputils.LoggingGzipRequestResponse(r)
+	if !*local {
+		if *lockedDown {
+			h = login.RestrictViewer(h)
+			h = login.ForceAuth(h, login.DEFAULT_REDIRECT_URL)
+		}
+		h = httputils.HealthzAndHTTPS(h)
+	}
+
+	http.Handle("/", h)
+	sklog.Infoln("Ready to serve.")
+	sklog.Fatal(http.ListenAndServe(*port, nil))
+}
diff --git a/particles/modules/particles-config-sk/index.js b/particles/modules/particles-config-sk/index.js
new file mode 100644
index 0000000..5f0035f
--- /dev/null
+++ b/particles/modules/particles-config-sk/index.js
@@ -0,0 +1,2 @@
+import './particles-config-sk.js'
+import './particles-config-sk.scss'
diff --git a/particles/modules/particles-config-sk/particles-config-sk.js b/particles/modules/particles-config-sk/particles-config-sk.js
new file mode 100644
index 0000000..c41e10d
--- /dev/null
+++ b/particles/modules/particles-config-sk/particles-config-sk.js
@@ -0,0 +1,159 @@
+/**
+ * @module particles-config-sk
+ * @description <h2><code>particles-config-sk</code></h2>
+ *
+ * <p>
+ *   A dialog for configuring how to render a Particles JSON file.
+ * </p>
+ *
+ * <p>
+ *   The form of the 'state' property looks like a serialized UploadRequest:
+ * </p>
+ * <pre>
+ *   {
+ *     filename: 'foo.json',
+ *     json: {},
+ *   }
+ * <pre>
+ *
+ * @evt particles-json-selected - This event is generated when the user presses Go.
+ *         The updated state, width, and height is available in the event detail.
+ *         There is also an indication if the particles file was changed.
+ *
+ * @evt cancelled - This event is generated when the user presses Cancel.
+ *
+ */
+import 'elements-sk/styles/buttons'
+import { errorMessage } from 'elements-sk/errorMessage'
+import { html, render } from 'lit-html'
+import { $$ } from 'common-sk/modules/dom'
+
+const DEFAULT_SIZE = 600;
+
+const cancelButton = (ele) => ele._hasCancel() ? html`<button id=cancel @click=${ele._cancel}>Cancel</button>` : '';
+
+const template = (ele) => html`
+  <label class=file>Particles file to upload
+    <input type=file name=file id=file @change=${ele._onFileChange}/>
+  </label>
+  <div class="filename ${ele._state.filename ? '' : 'empty'}">
+    ${ele._state.filename ? ele._state.filename : 'No file selected.'}
+  </div>
+  <label class=number>
+    <input type=number id=width .value=${ele._width} required /> Width (px)
+  </label>
+  <label class=number>
+    <input type=number id=height .value=${ele._height} required /> Height (px)
+  </label>
+  <div id=dialog-buttons>
+    ${cancelButton(ele)}
+    <button class=action ?disabled=${ele._readyToGo()} @click=${ele._go}>Go</button>
+  </div>
+`;
+
+class ParticlesConfigSk extends HTMLElement {
+  constructor() {
+    super();
+    this._state = {
+      filename: '',
+      json: null
+    };
+    this._width = DEFAULT_SIZE;
+    this._height = DEFAULT_SIZE;
+    this._fileChanged = false;
+    this._starting_state = Object.assign({}, this._state);
+  }
+
+  connectedCallback() {
+    this._render();
+    this.addEventListener('input', this._inputEvent);
+  }
+
+  disconnectedCallback() {
+    this.removeEventListener('input', this._inputEvent);
+  }
+
+  /** @prop height {Number} Selected height for animation. */
+  get height() { return this._height; }
+  set height(val) {
+    this._height= +val;
+    this._render();
+  }
+
+  /** @prop state {string} Object that describes the state of the config dialog. */
+  get state() { return this._state; }
+  set state(val) {
+    this._state = Object.assign({}, val);
+    this._starting_state = Object.assign({}, this._state);
+    this._render();
+  }
+
+  /** @prop width {Number} Selected width for animation. */
+  get width() { return this._width; }
+  set width(val) {
+    this._width = +val;
+    this._render();
+  }
+
+  _hasCancel() {
+     return !!this._starting_state.json;
+  }
+
+  _readyToGo() {
+    return !this._state.json;
+  }
+
+  _onFileChange(e) {
+    this._fileChanged = true;
+    let reader = new FileReader();
+    reader.addEventListener('load', () => {
+      let parsed = {};
+      try {
+        parsed = JSON.parse(reader.result);
+      }
+      catch(error) {
+        errorMessage(`Not a valid JSON file: ${error}`);
+        return;
+      }
+      this._state.json = parsed;
+      this._state.filename = e.target.files[0].name;
+      this._width = parsed.w || DEFAULT_SIZE;
+      this._height = parsed.h || DEFAULT_SIZE;
+      this._render();
+    });
+    reader.addEventListener('error', () => {
+      errorMessage('Failed to load.');
+    });
+    reader.readAsText(e.target.files[0]);
+  }
+
+  _updateState() {
+    this._width = +$$('#width', this).value;
+    this._height = +$$('#height', this).value;
+  }
+
+  _go() {
+    this._updateState();
+    this.dispatchEvent(new CustomEvent('particles-json-selected', { detail: {
+      'state' : this._state,
+      'fileChanged': this._fileChanged,
+      'width' : this._width,
+      'height': this._height,
+    }, bubbles: true }));
+  }
+
+  _cancel() {
+    this.dispatchEvent(new CustomEvent('cancelled', { bubbles: true }));
+  }
+
+  _inputEvent() {
+    this._updateState();
+    this._render();
+  }
+
+  _render() {
+    render(template(this), this, {eventContext: this});
+  }
+};
+
+window.customElements.define('particles-config-sk', ParticlesConfigSk);
\ No newline at end of file
diff --git a/particles/modules/particles-config-sk/particles-config-sk.scss b/particles/modules/particles-config-sk/particles-config-sk.scss
new file mode 100644
index 0000000..ff38791
--- /dev/null
+++ b/particles/modules/particles-config-sk/particles-config-sk.scss
@@ -0,0 +1,58 @@
+@import url(~elements-sk/colors.css);
+
+particles-config-sk {
+  display: block;
+
+  // TODO(jcgregorio) Add a .button-like class to elements-sk to avoid the copy-paste.
+  label.file {
+    min-width: 5.14em;
+    background-color: var(--white);
+    color: var(--blue);
+    fill: var(--blue);
+    text-align: center;
+    outline: none;
+    border-radius: 4px;
+    padding: 0.6em 1.2em;
+    border: solid var(--light-gray) 1px;
+    margin: 0.6em;
+    height: 3em;
+    transition: box-shadow 0.1s cubic-bezier(0.4, 0, 0.2, 1);
+    box-shadow: 2px 2px 8px 0 rgba(0, 0, 0, 0.6);
+  }
+
+  input[type=file] {
+    visibility: hidden;
+    width: 0;
+  }
+
+  .filename {
+    border: solid var(--light-gray) 1px;
+    padding: 0.4em;
+    margin: 0.6em;
+    display: inline-block;
+    font-style: inherit;
+    font-weight: bold;
+  }
+
+  .empty {
+    font-style: italic;
+    font-weight: inherit;
+  }
+
+  input[type=number] {
+    margin-right: 0.6em;
+    padding: 0.3em;
+    border: solid var(--light-gray) 1px;
+  }
+
+  label.number {
+    display: block;
+    margin: 0.6em;
+  }
+
+  #dialog-buttons {
+    width: 21em;
+    margin-top: 1em;
+    text-align: right;
+  }
+}
diff --git a/particles/modules/particles-sk/particles-sk-demo.js b/particles/modules/particles-sk/particles-sk-demo.js
index e9919ee..2449e2e 100644
--- a/particles/modules/particles-sk/particles-sk-demo.js
+++ b/particles/modules/particles-sk/particles-sk-demo.js
@@ -1,6 +1,27 @@
 import './index.js'
 import './particles-sk-demo.css'
 
+import { spiral } from './test_data.js'
+
 const fetchMock = require('fetch-mock');
 
-// TODO
\ No newline at end of file
+let state = {
+  filename: 'spiral.json',
+  json: spiral,
+}
+fetchMock.get('glob:/_/j/*', {
+  status: 200,
+  body: JSON.stringify(state),
+  headers: {'Content-Type':'application/json'},
+});
+
+fetchMock.post('glob:/_/upload', {
+  status: 200,
+  body: JSON.stringify({
+    hash: 'MOCK_UPLOADED',
+  }),
+  headers: {'Content-Type':'application/json'},
+});
+
+// Pass-through CanvasKit.
+fetchMock.get('glob:*.wasm', fetchMock.realFetch.bind(window));
\ No newline at end of file
diff --git a/particles/modules/particles-sk/particles-sk.js b/particles/modules/particles-sk/particles-sk.js
index 662ee41..eb2dcae 100644
--- a/particles/modules/particles-sk/particles-sk.js
+++ b/particles/modules/particles-sk/particles-sk.js
@@ -8,6 +8,7 @@
  *
  */
 import '../particles-player-sk'
+import '../particles-config-sk'
 import 'elements-sk/checkbox-sk'
 import 'elements-sk/error-toast-sk'
 import 'elements-sk/styles/buttons'
@@ -18,9 +19,6 @@
 import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow'
 import { stateReflector } from 'common-sk/modules/stateReflector'
 
-// FIXME(kjlubick): remove this placeholder
-import { spiral } from './test_data.js'
-
 const JSONEditor = require('jsoneditor/dist/jsoneditor-minimalist.js');
 
 const DIALOG_MODE = 1;
@@ -32,7 +30,7 @@
 const SCRUBBER_RANGE = 1000;
 
 const displayDialog = (ele) => html`
-<div> TODO config / dialog </div>
+<particles-config-sk .state=${ele._state} .width=${ele._width} .height=${ele._height}></particles-config-sk>
 `;
 
 const particlesPlayer = (ele) => html`
@@ -53,7 +51,15 @@
 </section>`;
 }
 
+const gallery = (ele) => html`
+Check out these examples ==>
+<a href="/a879da270cf25c70600810cb42ed78ff">spiral</a>
+<a href="/1afc7fa7bc923aad06f538982ddf5ba8">swirl</a>
+<a href="/7c132e60cc25fd6893998bd797eafb65">text</a>
+`;
+
 const displayLoaded = (ele) => html`
+${gallery(ele)}
 <button class=edit-config @click=${ ele._startEdit}>
   ${ele._state.filename} ${ele._width}x${ele._height} ...
 </button>
@@ -237,26 +243,39 @@
     this._playing = !this._playing;
   }
 
+  _recoverFromError(msg) {
+      errorMessage(msg);
+      console.error(msg);
+      window.history.pushState(null, '', '/');
+      this._ui = DIALOG_MODE;
+      this.render();
+  }
+
   _reflectFromURL() {
     // Check URL.
     let match = window.location.pathname.match(/\/([a-zA-Z0-9]+)/);
     if (!match) {
       // Make this the hash of the particles file you want to play on startup.
-      this._hash = '1112d01d28a776d777cebcd0632da15b'; // spiral.json
+      this._hash = 'a879da270cf25c70600810cb42ed78ff'; // spiral.json
     } else {
       this._hash = match[1];
     }
     this._ui = LOADING_MODE;
     this.render();
-    // TODO(kjlubick) Actually make a fetch request
-    this._state.json = spiral;
-    this._state.filename = 'spiral.json';
-    this._ui = LOADED_MODE;
-    this.render();
-    this._initializePlayer();
-    // Force start playing
-    this._playing = false;
-    this._playpause();
+    // Run this on the next micro-task to allow mocks to be set up if needed.
+    setTimeout(() => {
+      fetch(`/_/j/${this._hash}`, {
+        credentials: 'include',
+      }).then(jsonOrThrow).then(json => {
+        this._state = json;
+        this._ui = LOADED_MODE;
+        this.render();
+        this._initializePlayer();
+        // Force start playing
+        this._playing = false;
+        this._playpause();
+      }).catch((msg) => this._recoverFromError(msg));
+    });
   }
 
   render() {
@@ -344,7 +363,21 @@
     this._editor = null;
     // Clean up the old animation and other wasm objects
     this.render();
-    console.log('should upload JSON')
+     fetch('/_/upload', {
+      credentials: 'include',
+      body: JSON.stringify(this._state),
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      method: 'POST',
+    }).then(jsonOrThrow).then((json) => {
+      // Should return with the hash
+      this._ui = LOADED_MODE;
+      this._hash = json.hash;
+      window.history.pushState(null, '', '/' + this._hash);
+      this._stateChanged();
+      this.render();
+    }).catch((msg) => this._recoverFromError(msg));
 
     this._ui = LOADED_MODE;
     // Start drawing right away, no need to wait for
diff --git a/particles/pages/index.html b/particles/pages/index.html
new file mode 100644
index 0000000..35d40a4
--- /dev/null
+++ b/particles/pages/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>Particles Playground for Skia</title>
+  <meta charset="utf-8" />
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <link rel="stylesheet" href="/static/jsoneditor.css">
+</head>
+<body>
+  <particles-sk></particles-sk>
+</body>
+</html>
diff --git a/particles/pages/index.js b/particles/pages/index.js
new file mode 100644
index 0000000..0af5fbc
--- /dev/null
+++ b/particles/pages/index.js
@@ -0,0 +1,3 @@
+import '../node_modules/@webcomponents/custom-elements/custom-elements.min.js'
+import './index.scss'
+import '../modules/particles-sk'
diff --git a/particles/pages/index.scss b/particles/pages/index.scss
new file mode 100644
index 0000000..2b84b26
--- /dev/null
+++ b/particles/pages/index.scss
@@ -0,0 +1,6 @@
+body {
+  margin: 0;
+  padding: 0;
+  transition: none;
+  font-family: sans-serif;
+}
diff --git a/skottie/go/skottie/main.go b/skottie/go/skottie/main.go
index c369766..cef5b0b 100644
--- a/skottie/go/skottie/main.go
+++ b/skottie/go/skottie/main.go
@@ -480,8 +480,8 @@
 	r := mux.NewRouter()
 	r.HandleFunc("/drive", srv.templateHandler("drive.html"))
 	r.HandleFunc("/google99d1f93c6755806b.html", srv.verificationHandler)
-	r.HandleFunc("/{hash:[0-9A-Za-z]*}", srv.templateHandler("index.html"))
-	r.HandleFunc("/e/{hash:[0-9A-Za-z]*}", srv.templateHandler("embed.html"))
+	r.HandleFunc("/{hash:[0-9A-Za-z]*}", srv.templateHandler("index.html")).Methods("GET")
+	r.HandleFunc("/e/{hash:[0-9A-Za-z]*}", srv.templateHandler("embed.html")).Methods("GET")
 
 	r.HandleFunc("/_/j/{hash:[0-9A-Za-z]+}", srv.jsonHandler).Methods("GET")
 	r.HandleFunc(`/_/a/{hash:[0-9A-Za-z]+}/{name:[A-Za-z0-9\._\-]+}`, srv.assetsHandler).Methods("GET")
diff --git a/skottie/modules/skottie-config-sk/skottie-config-sk.js b/skottie/modules/skottie-config-sk/skottie-config-sk.js
index ce143a6..b2c61bf 100644
--- a/skottie/modules/skottie-config-sk/skottie-config-sk.js
+++ b/skottie/modules/skottie-config-sk/skottie-config-sk.js
@@ -1,5 +1,5 @@
 /**
- * @module /skottie-config-sk
+ * @module skottie-config-sk
  * @description <h2><code>skottie-config-sk</code></h2>
  *
  * <p>
@@ -13,9 +13,8 @@
  *   {
  *     filename: 'foo.json',
  *     lottie: {},
- *     width: 256,
- *     height: 256,
- *     fps: 30,
+ *     assetsZip: 'data:application/zip;base64,...'
+ *     assetsFileName: 'assets.zip'
  *   }
  * <pre>
  *
diff --git a/skottie/modules/skottie-sk/skottie-sk.js b/skottie/modules/skottie-sk/skottie-sk.js
index 8ca675c..02a6111 100644
--- a/skottie/modules/skottie-sk/skottie-sk.js
+++ b/skottie/modules/skottie-sk/skottie-sk.js
@@ -449,6 +449,14 @@
     this._playing = !this._playing;
   }
 
+  _recoverFromError(msg) {
+      errorMessage(msg);
+      console.error(msg);
+      window.history.pushState(null, '', '/');
+      this._ui = DIALOG_MODE;
+      this.render();
+  }
+
   _reflectFromURL() {
     // Check URL.
     let match = window.location.pathname.match(/\/([a-zA-Z0-9]+)/);
@@ -477,13 +485,7 @@
         this._ui = LOADED_MODE;
         this._loadAssetsAndRender();
         this._rewind();
-      }).catch((msg) => {
-        console.error(msg);
-        errorMessage(msg);
-        window.history.pushState(null, '', '/');
-        this._ui = DIALOG_MODE;
-        this.render();
-      });
+      }).catch((msg) => this._recoverFromError(msg));
     });
   }
 
@@ -684,12 +686,7 @@
         this._rewind();
       }
       this.render();
-    }).catch(msg => {
-      errorMessage(msg);
-      window.history.pushState(null, '', '/');
-      this._ui = DIALOG_MODE;
-      this.render();
-    });
+    }).catch((msg) => this._recoverFromError(msg));
 
     if (!this._state.assetsZip) {
       this._ui = LOADED_MODE;