[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);
}
});