[shaders] Add the ability to use images from other sites.

Bug: skia:11272
Change-Id: Ic80f76f8ae67c69253032c0a175ccf122377157e
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/374476
Reviewed-by: Joe Gregorio <jcgregorio@google.com>
diff --git a/shaders/modules/shadernode/index.ts b/shaders/modules/shadernode/index.ts
index b511b06..7f39dff 100644
--- a/shaders/modules/shadernode/index.ts
+++ b/shaders/modules/shadernode/index.ts
@@ -130,6 +130,9 @@
      */
     private _currentUserUniformValues: number[] = [];
 
+    /** The current image being displayed, even if a blob: url. */
+    private currentImageURL: string = '';
+
     private _numPredefinedUniformValues: number = 0;
 
     constructor(canvasKit: CanvasKit) {
@@ -161,14 +164,7 @@
       this.body = scrapBody;
       this._shaderCode = this.body.Body;
       this.currentUserUniformValues = this.body.SKSLMetaData?.Uniforms || [];
-
-      const imageURL = this.body.SKSLMetaData?.ImageURL || defaultImageURL;
-      this.promiseOnImageLoaded(imageURL).then((imageElement) => {
-        this.inputImageShaderFromCanvasImageSource(imageElement);
-        if (imageLoadedCallback) {
-          imageLoadedCallback();
-        }
-      }).catch(errorMessage);
+      this.setCurrentImageURL(this.body?.SKSLMetaData?.ImageURL || defaultImageURL, imageLoadedCallback);
       this.compile();
     }
 
@@ -182,6 +178,43 @@
     }
 
     /**
+     * Don't save or display image URLs that are blob:// or file:// urls.
+     */
+    getSafeImageURL(): string {
+      if (!this.currentImageURLIsSafe()) {
+        return this.body?.SKSLMetaData?.ImageURL || defaultImageURL;
+      }
+      return this.getCurrentImageURL();
+    }
+
+    /** The current image being used. Note that this could be a blob: URL. */
+    getCurrentImageURL(): string {
+      return this.currentImageURL;
+    }
+
+    /**
+     * Sets the current image to use. Note that if the image fails to load then
+     * the current image URL will be set to the empty string.
+     */
+    setCurrentImageURL(val: string, imageLoadedCallback: callback | null = null): void {
+      this.currentImageURL = val;
+
+      this.promiseOnImageLoaded(this.currentImageURL).then((imageElement) => {
+        this.inputImageShaderFromCanvasImageSource(imageElement);
+        if (imageLoadedCallback) {
+          imageLoadedCallback();
+        }
+      }).catch(() => {
+        errorMessage(`Failed to load image: ${this.currentImageURL}. Falling back to an empty image.`);
+        this.currentImageURL = '';
+        this.inputImageShaderFromCanvasImageSource(new Image(DEFAULT_SIZE, DEFAULT_SIZE));
+        if (imageLoadedCallback) {
+          imageLoadedCallback();
+        }
+      });
+    }
+
+    /**
      * Saves the scrap to the backend returning a Promise that resolves to the
      * scrap id that it was stored at, or reject on an error.
      */
@@ -191,10 +224,7 @@
         Type: 'sksl',
         SKSLMetaData: {
           Uniforms: this._currentUserUniformValues,
-          // TODO(jcgregorio) Remember once we start saving ImageURLs that we
-          // need to to remove ImageURLs that aren't relative or that don't
-          // start with http[s]://, e.g. don't store blob:// or file:// urls.
-          ImageURL: '',
+          ImageURL: this.getSafeImageURL(),
           Children: [],
         },
       };
@@ -290,7 +320,7 @@
 
     /** Returns true if this node or any child node needs to be saved. */
     needsSave(): boolean {
-      return (this._shaderCode !== this.body!.Body) || this.userUniformValuesHaveBeenEdited();
+      return (this._shaderCode !== this.body!.Body) || this.userUniformValuesHaveBeenEdited() || this.imageURLHasChanged();
     }
 
     /** Returns the number of uniforms in the effect. */
@@ -388,6 +418,26 @@
       return false;
     }
 
+    private currentImageURLIsSafe() {
+      const url = new URL(this.currentImageURL, window.location.toString());
+      if (url.protocol === 'https:' || url.protocol === 'http:') {
+        return true;
+      }
+      return false;
+    }
+
+    private imageURLHasChanged(): boolean {
+      if (!this.currentImageURLIsSafe()) {
+        return false;
+      }
+      const current = new URL(this.currentImageURL, window.location.toString());
+      const saved = new URL(this.body?.SKSLMetaData?.ImageURL || '', window.location.toString());
+      if (current.toString() !== saved.toString()) {
+        return true;
+      }
+      return false;
+    }
+
     private findUniform(name: string): Uniform | null {
       for (let i = 0; i < this.uniforms.length; i++) {
         if (name === this.uniforms[i].name) {
@@ -400,6 +450,7 @@
     private promiseOnImageLoaded(url: string): Promise<HTMLImageElement> {
       return new Promise<HTMLImageElement>((resolve, reject) => {
         const ele = new Image();
+        ele.crossOrigin = 'anonymous';
         ele.src = url;
         if (ele.complete) {
           resolve(ele);
diff --git a/shaders/modules/shadernode/index_test.ts b/shaders/modules/shadernode/index_test.ts
index 86d14f2..03cf702 100644
--- a/shaders/modules/shadernode/index_test.ts
+++ b/shaders/modules/shadernode/index_test.ts
@@ -104,7 +104,7 @@
       `,
       SKSLMetaData: {
         Children: [],
-        ImageURL: '',
+        ImageURL: '/dist/mandrill.png',
         Uniforms: startingUniformValues,
       },
     });
@@ -112,7 +112,7 @@
 
     // Changing the code means we need to save.
     const originalCode = node.shaderCode;
-    assert.isFalse(node.needsSave());
+    assert.isFalse(node.needsSave(), 'No need to save at the start.');
     node.shaderCode += '\n';
     assert.isTrue(node.needsSave(), 'Needs save if code changed.');
     node.shaderCode = originalCode;
@@ -161,4 +161,18 @@
     const node = await createShaderNode();
     assert.isNotNull(node.inputImageElement);
   });
+
+  it('protects against unsafe URLs', async () => {
+    const node = await createShaderNode();
+    node['currentImageURL'] = 'data:foo';
+    assert.equal(node.getCurrentImageURL(), 'data:foo');
+    assert.equal(node.getSafeImageURL(), '/dist/mandrill.png');
+  });
+
+  it('reverts to empty image URL if image fails to load.', async () => {
+    const node = await createShaderNode();
+    node.setCurrentImageURL('/dist/some-unknown-image.png', () => {
+      assert.equal(node.getCurrentImageURL(), '');
+    });
+  });
 });
diff --git a/shaders/modules/shaders-app-sk/shaders-app-sk.scss b/shaders/modules/shaders-app-sk/shaders-app-sk.scss
index 0df4dd0..067a029 100644
--- a/shaders/modules/shaders-app-sk/shaders-app-sk.scss
+++ b/shaders/modules/shaders-app-sk/shaders-app-sk.scss
@@ -132,7 +132,7 @@
 
     #imageSources {
       display: flex;
-      align-items: center;
+      align-items: flex-start;
       margin: 8px;
 
       figure {
@@ -170,6 +170,55 @@
     }
   }
 
+  details#image_edit {
+    margin: 0 8px 8px 0;
+
+    summary {
+      list-style: none;
+
+      edit-icon-sk {
+        display: inline-block;
+
+        svg {
+          width: 16px;
+          height: 16px;
+        }
+      }
+    }
+  }
+
+  details#image_edit > summary::-webkit-details-marker {
+    display: none;
+  }
+
+  details#image_edit[open] {
+    summary {
+      margin: 0 8px 8px 0;
+    }
+
+    #image_edit_dialog {
+      border: solid var(--surface-2dp) 1px;
+      padding: 8px;
+
+      > * {
+        margin: 0 8px 8px 0;
+      }
+    }
+
+    label {
+      display: block;
+      margin: 0 8px 8px 0;
+    }
+
+    button {
+      margin: 0 0 0 4px;
+    }
+
+    input[type='url'] {
+      padding: 4px;
+    }
+  }
+
   .CodeMirror {
     height: auto;
 
@@ -178,31 +227,4 @@
       font-size: 13px;
     }
   }
-
-  /* Now set the non-standard styles. Unfortunately the way webpack/cssmin is
-   configured currently it will strip the following rules from the output so we
-   turn the autoprefixer off.
-  */
-
-  /* autoprefixer: off */
-
-  #image_upload {
-    border: none;
-  }
-
-  #image_upload::-webkit-file-upload-button {
-    background-color: var(--surface-1dp);
-    color: var(--on-surface);
-    fill: var(--on-surface);
-    outline: none;
-    border: solid var(--on-surface) 1px;
-  }
-
-  #image_upload::file-selector-button {
-    background-color: var(--surface-1dp);
-    color: var(--on-surface);
-    fill: var(--on-surface);
-    outline: none;
-    border: solid var(--on-surface) 1px;
-  }
 }
diff --git a/shaders/modules/shaders-app-sk/shaders-app-sk.ts b/shaders/modules/shaders-app-sk/shaders-app-sk.ts
index da7dad9..77235f0 100644
--- a/shaders/modules/shaders-app-sk/shaders-app-sk.ts
+++ b/shaders/modules/shaders-app-sk/shaders-app-sk.ts
@@ -22,6 +22,7 @@
 import 'elements-sk/error-toast-sk';
 import 'elements-sk/styles/buttons';
 import 'elements-sk/styles/select';
+import 'elements-sk/icon/edit-icon-sk';
 import '../../../infra-sk/modules/theme-chooser-sk';
 import { SKIA_VERSION } from '../../build/version';
 import { ElementSk } from '../../../infra-sk/modules/ElementSk/ElementSk';
@@ -226,7 +227,14 @@
     </header>
     <main>
       <div>
-        <p id=examples @click=${ele.fastLoad}>Examples: <a href="/?id=@inputs">Uniforms</a> <a href="/?id=@iResolution">iResolution</a> <a href="/?id=@iTime">iTime</a> <a href="/?id=@iMouse">iMouse</a> <a href="/?id=@iImage">iImage</a></p>
+        <p id=examples @click=${ele.fastLoad}>
+        Examples:
+          <a href="/?id=@inputs">Uniforms</a>
+          <a href="/?id=@iResolution">iResolution</a>
+          <a href="/?id=@iTime">iTime</a>
+          <a href="/?id=@iMouse">iMouse</a>
+          <a href="/?id=@iImage">iImage</a>
+        </p>
         <canvas
           id="player"
           width=${ele.width}
@@ -243,9 +251,26 @@
             <figure>
               ${ele.shaderNode?.inputImageElement}
               <figcaption>iImage1</figcaption>
-              <input @change=${ele.imageUploaded} type="file" id=image_upload accept="image/*">
             </figure>
-        </div>
+            <details id=image_edit>
+              <summary><edit-icon-sk></edit-icon-sk></summary>
+              <div id=image_edit_dialog>
+                <label for=image_url>
+                  Change the URL used for the source image.
+                </label>
+                <div>
+                  <input type=url id=image_url placeholder="URL of image to use." .value="${ele.shaderNode?.getSafeImageURL() || ''}">
+                  <button @click=${ele.imageURLChanged}>Use</button>
+                </div>
+                <label for=image_upload>
+                  Or upload an image to <em>temporarily</em> try as a source for the shader. Uploaded images are not saved.
+                </label>
+                <div>
+                  <input @change=${ele.imageUploaded} type=file id=image_upload accept="image/*">
+                </div>
+              </div>
+            </details>
+          </div>
         </details>
         <div id="codeEditor"></div>
         <div ?hidden=${!ele.shaderNode?.compileErrorMessage} id="compileErrors">
@@ -500,19 +525,15 @@
       return;
     }
     const file = input.files.item(0)!;
+    this.setCurrentImageURL(URL.createObjectURL(file));
+  }
 
-    // Update the current scrap to set the ImageURL to the uploaded file.
-    const scrap = this.shaderNode!.getScrap();
-    const oldURL = scrap.SKSLMetaData?.ImageURL || '';
-
-    // Release unused memory.
-    if (oldURL.startsWith('blob:')) {
-      URL.revokeObjectURL(oldURL);
+  private imageURLChanged(): void {
+    const input = $$<HTMLInputElement>('#image_url', this)!;
+    if (!input.value) {
+      return;
     }
-
-    // Display new image.
-    scrap.SKSLMetaData!.ImageURL = URL.createObjectURL(file);
-    this.shaderNode!.setScrap(scrap, () => this._render());
+    this.setCurrentImageURL(input.value);
   }
 
   private codeChange() {
@@ -536,6 +557,17 @@
     this.stateChanged!();
     this.loadShaderIfNecessary();
   }
+
+  private setCurrentImageURL(url: string): void {
+    const oldURL = this.shaderNode!.getCurrentImageURL();
+
+    // Release unused memory.
+    if (oldURL.startsWith('blob:')) {
+      URL.revokeObjectURL(oldURL);
+    }
+
+    this.shaderNode!.setCurrentImageURL(url, () => this._render());
+  }
 }
 
 define('shaders-app-sk', ShadersAppSk);