[jsfiddle] Add storage and retrieval to GCS

Bug: skia:
Change-Id: I824c60021308b175e37c68a1ba5fb0e7d149bdb5
Reviewed-on: https://skia-review.googlesource.com/155660
Reviewed-by: Joe Gregorio <jcgregorio@google.com>
Commit-Queue: Kevin Lubick <kjlubick@google.com>
diff --git a/jsfiddle/go/jsfiddle_server/main.go b/jsfiddle/go/jsfiddle_server/main.go
index 86e8030..f045a06 100644
--- a/jsfiddle/go/jsfiddle_server/main.go
+++ b/jsfiddle/go/jsfiddle_server/main.go
@@ -1,8 +1,11 @@
 package main
 
+// The webserver for jsfiddle.skia.org. It serves up the web page
+
 import (
 	"encoding/json"
 	"flag"
+	"fmt"
 	"io/ioutil"
 	"net/http"
 	"path/filepath"
@@ -13,6 +16,7 @@
 	"go.skia.org/infra/go/httputils"
 	"go.skia.org/infra/go/sklog"
 	"go.skia.org/infra/go/util"
+	"go.skia.org/infra/jsfiddle/go/store"
 )
 
 var (
@@ -22,8 +26,14 @@
 	resourcesDir = flag.String("resources_dir", "./dist", "The directory to find templates, JS, and CSS files. If blank the current directory will be used.")
 )
 
+const MAX_FIDDLE_SIZE = 10 * 1024 * 1024 // 10KB ought to be enough for anyone.
+
 var pathkitPage []byte
 
+var knownTypes = []string{"pathkit", "canvaskit"}
+
+var fiddleStore *store.Store
+
 func healthzHandler(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
 }
@@ -34,30 +44,55 @@
 		loadPages()
 	}
 	w.Header().Set("Content-Type", "text/html")
+	// This page should not be iframed. Maybe one day, something will be iframed,
+	// but likely not this page.
+	w.Header().Add("X-Frame-Options", "deny")
 	w.WriteHeader(http.StatusOK)
 	if _, err := w.Write(pathkitPage); err != nil {
 		httputils.ReportError(w, r, err, "Server could not load page")
 	}
 }
 
-type codeResponse struct {
+type fiddleContext struct {
 	Code string `json:"code"`
+	Type string `json:"type,omitempty"`
+}
+
+type saveResponse struct {
+	NewURL string `json:"new_url"`
 }
 
 func codeHandler(w http.ResponseWriter, r *http.Request) {
 	qp := r.URL.Query()
-	if util.In("pathkit", qp["type"]) {
-		if util.In("demo", qp["hash"]) {
-			cr := codeResponse{Code: pathkitDemoCode}
-			w.Header().Set("Content-Type", "application/json")
-			if err := json.NewEncoder(w).Encode(cr); err != nil {
-				httputils.ReportError(w, r, err, "Failed to JSON Encode response.")
-			}
-			return
-		}
-		// TODO(kjlubick): actually look up the code from GCS
+	fiddleType := ""
+	if xt, ok := qp["type"]; ok {
+		fiddleType = xt[0]
 	}
-	http.Error(w, "Not found", http.StatusBadRequest)
+	if !util.In(fiddleType, knownTypes) {
+		sklog.Warningf("Unknown type requested %s", qp["type"])
+		http.Error(w, "Invalid Type", http.StatusBadRequest)
+		return
+	}
+
+	hash := ""
+	if xh, ok := qp["hash"]; ok {
+		hash = xh[0]
+	}
+	if hash == "" {
+		// use demo code
+		hash = "d962f6408d45d22c5e0dfe0a0b5cf2bad9dfaa49c4abc0e2b1dfb30726ab838d"
+	}
+
+	code, err := fiddleStore.GetCode(hash, fiddleType)
+	if err != nil {
+		http.Error(w, "Not found", http.StatusBadRequest)
+		return
+	}
+	cr := fiddleContext{Code: code}
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(cr); err != nil {
+		httputils.ReportError(w, r, err, "Failed to JSON Encode response.")
+	}
 }
 
 func makeResourceHandler() func(http.ResponseWriter, *http.Request) {
@@ -84,8 +119,31 @@
 }
 
 func saveHandler(w http.ResponseWriter, r *http.Request) {
-	// TODO(kjlubick)
-	http.Error(w, "Not implemented", 500)
+	req := fiddleContext{}
+	dec := json.NewDecoder(r.Body)
+	defer util.Close(r.Body)
+	if err := dec.Decode(&req); err != nil {
+		httputils.ReportError(w, r, err, "Failed to decode request.")
+		return
+	}
+	if !util.In(req.Type, knownTypes) {
+		http.Error(w, "Invalid type", http.StatusBadRequest)
+		return
+	}
+	if len(req.Code) > MAX_FIDDLE_SIZE {
+		http.Error(w, fmt.Sprintf("Fiddle Too Big, max size is %d bytes", MAX_FIDDLE_SIZE), http.StatusBadRequest)
+		return
+	}
+
+	hash, err := fiddleStore.PutCode(req.Code, req.Type)
+	if err != nil {
+		httputils.ReportError(w, r, err, "Failed to save fiddle.")
+	}
+	sr := saveResponse{NewURL: fmt.Sprintf("/%s/%s", req.Type, hash)}
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(sr); err != nil {
+		httputils.ReportError(w, r, err, "Failed to JSON Encode response.")
+	}
 }
 
 func mainHandler(w http.ResponseWriter, r *http.Request) {
@@ -93,6 +151,19 @@
 	http.Redirect(w, r, "/pathkit", http.StatusFound)
 }
 
+// cspHandler is an HTTP handler function which adds CSP (Content-Security-Policy)
+// headers to this request
+func cspHandler(h func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		// recommended by https://content-security-policy.com/
+		// "This policy allows images, scripts, AJAX, and CSS from the same origin, and does
+		// not allow any other resources to load (eg object, frame, media, etc).
+		// It is a good starting point for many sites."
+		w.Header().Add("Access-Control-Allow-Origin", "default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self';")
+		h(w, r)
+	}
+}
+
 func main() {
 	common.InitWithMust(
 		"jsfiddle",
@@ -100,13 +171,19 @@
 		common.MetricsLoggingOpt(),
 	)
 	loadPages()
+	var err error
+	fiddleStore, err = store.New(*local)
+	if err != nil {
+		sklog.Fatalf("Failed to connect to store: %s", err)
+	}
 
 	r := mux.NewRouter()
-	r.PathPrefix("/res/").HandlerFunc(makeResourceHandler())
-	r.HandleFunc("/pathkit", pathkitHandler)
-	r.HandleFunc("/", mainHandler)
-	r.HandleFunc("/_/save", saveHandler)
-	r.HandleFunc("/_/code", codeHandler)
+	r.PathPrefix("/res/").HandlerFunc(makeResourceHandler()).Methods("GET")
+	r.HandleFunc("/pathkit", cspHandler(pathkitHandler)).Methods("GET")
+	r.HandleFunc("/pathkit/{id:[@0-9a-zA-Z_]+}", cspHandler(pathkitHandler)).Methods("GET")
+	r.HandleFunc("/", mainHandler).Methods("GET")
+	r.HandleFunc("/_/save", saveHandler).Methods("PUT")
+	r.HandleFunc("/_/code", codeHandler).Methods("GET")
 
 	h := httputils.LoggingGzipRequestResponse(r)
 	h = httputils.HealthzAndHTTPS(h)
@@ -114,32 +191,3 @@
 	sklog.Infoln("Ready to serve.")
 	sklog.Fatal(http.ListenAndServe(*port, nil))
 }
-
-const pathkitDemoCode = `// canvas and PathKit are globally available
-let firstPath = PathKit.FromSVGString('M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z');
-
-let secondPath = PathKit.NewPath();
-// Acts somewhat like the Canvas API, except can be chained
-secondPath.moveTo(1, 1)
-          .lineTo(20, 1)
-          .lineTo(10, 30)
-          .closePath();
-
-// Join the two paths together (mutating firstPath in the process)
-firstPath.op(secondPath, PathKit.PathOp.INTERSECT);
-
-// Draw directly to Canvas
-let ctx = canvas.getContext('2d');
-ctx.strokeStyle = '#CC0000';
-ctx.fillStyle = '#000000';
-ctx.scale(20, 20);
-ctx.beginPath();
-firstPath.toCanvas(ctx);
-ctx.fill();
-ctx.stroke();
-
-
-// clean up WASM memory
-// See http://kripken.github.io/emscripten-site/docs/porting/connecting_cpp_and_javascript/embind.html?highlight=memory#memory-management
-firstPath.delete();
-secondPath.delete();`
diff --git a/jsfiddle/go/store/store.go b/jsfiddle/go/store/store.go
new file mode 100644
index 0000000..fafc61d
--- /dev/null
+++ b/jsfiddle/go/store/store.go
@@ -0,0 +1,80 @@
+// Stores and retrieves jsfiddles in Google Storage.
+package store
+
+import (
+	"context"
+	"crypto/sha256"
+	"fmt"
+	"io/ioutil"
+	"strings"
+
+	"cloud.google.com/go/storage"
+	"go.skia.org/infra/go/auth"
+	"go.skia.org/infra/go/util"
+	"google.golang.org/api/option"
+)
+
+const (
+	JSFIDDLE_STORAGE_BUCKET = "skia-jsfiddle"
+)
+
+// Store is used to read and write user code and media to and from Google
+// Storage.
+type Store struct {
+	bucket *storage.BucketHandle
+}
+
+// New creates a new Store.
+//
+// local - True if running locally.
+func New(local bool) (*Store, error) {
+	ts, err := auth.NewDefaultTokenSource(local, auth.SCOPE_READ_WRITE)
+	if err != nil {
+		return nil, fmt.Errorf("Problem setting up client OAuth: %s", err)
+	}
+	client := auth.ClientFromTokenSource(ts)
+	storageClient, err := storage.NewClient(context.Background(), option.WithHTTPClient(client))
+	if err != nil {
+		return nil, fmt.Errorf("Problem creating storage client: %s", err)
+	}
+	return &Store{
+		bucket: storageClient.Bucket(JSFIDDLE_STORAGE_BUCKET),
+	}, nil
+}
+
+// PutCode writes the code to Google Storage.
+// Returns the fiddleHash.
+func (s *Store) PutCode(code, fiddleType string) (string, error) {
+	hash := computeHash(code)
+
+	path := strings.Join([]string{fiddleType, hash, "draw.js"}, "/")
+	w := s.bucket.Object(path).NewWriter(context.Background())
+	defer util.Close(w)
+	w.ObjectAttrs.ContentEncoding = "text/plain"
+
+	if n, err := w.Write([]byte(code)); err != nil {
+		return "", fmt.Errorf("There was a problem storing the code. Uploaded %d bytes: %s", n, err)
+	}
+	return hash, nil
+}
+
+// PutCode writes the code to Google Storage.
+// Returns the fiddleHash.
+func (s *Store) GetCode(hash, fiddleType string) (string, error) {
+	path := strings.Join([]string{fiddleType, hash, "draw.js"}, "/")
+	o := s.bucket.Object(path)
+	r, err := o.NewReader(context.Background())
+	if err != nil {
+		return "", fmt.Errorf("Failed to open source file for %s: %s", hash, err)
+	}
+	b, err := ioutil.ReadAll(r)
+	if err != nil {
+		return "", fmt.Errorf("Failed to read source file for %s: %s", hash, err)
+	}
+	return string(b), nil
+}
+
+func computeHash(code string) string {
+	sum := sha256.Sum256([]byte(code))
+	return fmt.Sprintf("%x", sum)
+}
diff --git a/jsfiddle/modules/pathkit-fiddle/pathkit-fiddle.js b/jsfiddle/modules/pathkit-fiddle/pathkit-fiddle.js
index 9264a1a..3e7aff1 100644
--- a/jsfiddle/modules/pathkit-fiddle/pathkit-fiddle.js
+++ b/jsfiddle/modules/pathkit-fiddle/pathkit-fiddle.js
@@ -38,7 +38,7 @@
 <div id=editor>
   <textarea class=code spellcheck=false rows=${lines(ele.content)} cols=80
         @paste=${() => ele._changed()} @input=${() => ele._changed()}
-  >${ele.content}</textarea>
+  ></textarea>
   <div class=numbers>
     ${repeat(lines(ele.content)).map((_, n) => _lineNumber(n+1))}
   </div>
@@ -98,6 +98,7 @@
   set content(c) {
     this._content = c;
     this._render();
+    this._editor.value = c;
   }
 
   connectedCallback() {
@@ -112,22 +113,46 @@
     });
 
     if (!this.content) {
-      fetch("/_/code?type=pathkit&hash=demo")
-        .then(jsonOrThrow)
-        .then((json) => {
-          this.content = json.code;
-          if (this.PathKit) {
-            this._run(); // auto-run the code if PathKit is loaded.
-          }
-        }
-      );
+      this._loadCode();
     }
+    // Listen for the forward and back buttons and re-load the code
+    // on any changes. Without this, the url changes, but nothing
+    // happens in the DOM.
+    window.addEventListener('popstate', this._loadCode.bind(this));
+  }
+
+  disconnectedCallback() {
+    window.removeEventListener('popstate', this._loadCode.bind(this));
   }
 
   _changed() {
     this.content = this._editor.value;
   }
 
+  _loadCode() {
+    // The location should be either /pathkit or /pathkit/<fiddlehash>
+    let path = window.location.pathname;
+    let hash = '';
+    if (path.length > 9) { // 9 characters in '/pathkit/'
+      hash = path.slice(9);
+    }
+
+    fetch(`/_/code?type=pathkit&hash=${hash}`)
+      .then(jsonOrThrow)
+      .then((json) => {
+        this.content = json.code;
+        if (this.PathKit) {
+          this._run(); // auto-run the code if PathKit is loaded.
+        }
+      }
+    ).catch((e) => {
+      errorMessage('Fiddle not Found', 10000);
+      this.content = '';
+      const canvas = $$('#canvas', this);
+      resetCanvas(canvas);
+    });
+  }
+
   _render() {
     render(template(this), this);
     this._editor = $$('#editor textarea', this);
@@ -142,7 +167,16 @@
     const canvas = $$('#canvas', this);
     resetCanvas(canvas);
     try {
-      const f = new Function('PathKit', 'canvas', this.content);
+      let f = new Function('PathKit', 'canvas', // actual params
+          // shadow these globals to at least make exploitation harder. CSP
+          // is our first line of defense, this adds another layer.
+          'window', 'document', 'open', 'event', 'Function', 'eval', 'frames',
+          'frameElement', 'localStorage', 'history', 'messageManager', 'name',
+          'opener', 'pkcs11', 'self', 'status', 'top', 'visualViewport',
+          'caches', 'origin', 'indexedDB', 'Worker', 'openDialog', 'alert',
+          'prompt', 'parent',
+           this.content); // user given code
+      f = f.bind({}); // By default, f is bound to Window.  Re bind it to remove that access.
       f(this.PathKit, canvas);
     } catch(e) {
       errorMessage(e);
@@ -150,18 +184,19 @@
   }
 
   _save() {
-    // TODO(kjlubick):
-    // make a POST request to /_/save with form
-    // {
-    //   "code": ...,
-    //   "type": "pathkit",
-    // }
-    // which will return JSON of the form
-    // {
-    //   "new_url": "/pathkit/123adfs45asdf59923123"
-    // }
-    // where new_url is the hash (probably sha256) of the content
-    // and this will re-direct the browser to that new url.
+    fetch('/_/save', {
+      method: 'PUT',
+      headers: new Headers({
+        'content-type': 'application/json',
+      }),
+      body: JSON.stringify({
+        code: this.content,
+        type: 'pathkit',
+      })
+    }).then(jsonOrThrow).then((json) => {
+        history.pushState(null, '', json.new_url);
+      }
+    ).catch(errorMessage);
   }
 
 });