[geary/wip/728002-webkit2] Reenable basic deceptive link highlighting.



commit 4a6c524c78caaf8ef2ade7dfedc7359e6c053afb
Author: Michael James Gratton <mike vee net>
Date:   Tue Jan 24 00:05:44 2017 +1100

    Reenable basic deceptive link highlighting.
    
    * bindings/vapi/javascriptcore-4.0.vapi (Object::get_property): Fix
      return type.
    
    * src/client/conversation-viewer/conversation-message.vala (GtkTemplate):
      Hook up to new deceptive_link_clicked signal, remove old DOM-based
      implementation.
    
    * src/client/conversation-viewer/conversation-web-view.vala
      (ConversationWebView): Add new deceptive_link_clicked signal and
      DeceptiveText enum, listen for deceptiveLinkClicked JS message and fire
      signal when received.
    
    * src/client/util/util-webkit.vala (WebKitUtil): Add to_object util function.
    
    * src/engine/util/util-js.vala (Geary.JS): Add to_object and get_property
      util functions.
    
    * ui/conversation-web-view.js (ConversationPageState) Listen for link
      clicks, check for deceptive text and send message if found. Add unit
      tests for deceptive text check.
    
    * test/js/composer-page-state-test.vala: Move ::run_javascript to parent
      class so new ConversationPageStateTest class can use it, adapt call
      sites to different parent signature.

 bindings/vapi/javascriptcore-4.0.vapi              |    6 +-
 .../conversation-viewer/conversation-message.vala  |  144 +++++---------------
 .../conversation-viewer/conversation-web-view.vala |   68 +++++++++-
 src/client/util/util-webkit.vala                   |   12 ++
 src/engine/util/util-js.vala                       |   41 ++++++
 test/CMakeLists.txt                                |    1 +
 .../components/client-web-view-test-case.vala      |    9 ++
 test/js/composer-page-state-test.vala              |   48 +++----
 test/js/conversation-page-state-test.vala          |   93 +++++++++++++
 test/main.vala                                     |    1 +
 ui/conversation-web-view.js                        |   92 +++++++++++++
 11 files changed, 373 insertions(+), 142 deletions(-)
---
diff --git a/bindings/vapi/javascriptcore-4.0.vapi b/bindings/vapi/javascriptcore-4.0.vapi
index f31478e..d152ce2 100644
--- a/bindings/vapi/javascriptcore-4.0.vapi
+++ b/bindings/vapi/javascriptcore-4.0.vapi
@@ -89,9 +89,9 @@ namespace JS {
         public bool has_property(Context ctx, String property_name);
 
         [CCode (cname = "JSObjectGetProperty", instance_pos = 1.1)]
-        public String get_property(Context ctx,
-                                   String property_name,
-                                   out Value? exception);
+        public Value get_property(Context ctx,
+                                  String property_name,
+                                  out Value? exception);
 
        }
 
diff --git a/src/client/conversation-viewer/conversation-message.vala 
b/src/client/conversation-viewer/conversation-message.vala
index f432a60..1209161 100644
--- a/src/client/conversation-viewer/conversation-message.vala
+++ b/src/client/conversation-viewer/conversation-message.vala
@@ -210,10 +210,10 @@ public class ConversationMessage : Gtk.Grid {
 
     [GtkChild]
     private Gtk.Popover link_popover;
-    //[GtkChild]
-    //private Gtk.Label good_link_label;
-    //[GtkChild]
-    //private Gtk.Label bad_link_label;
+    [GtkChild]
+    private Gtk.Label good_link_label;
+    [GtkChild]
+    private Gtk.Label bad_link_label;
 
     [GtkChild]
     private Gtk.InfoBar remote_images_infobar;
@@ -382,6 +382,7 @@ public class ConversationMessage : Gtk.Grid {
             this.web_view.allow_remote_image_loading();
         }
         this.web_view.context_menu.connect(on_context_menu);
+        this.web_view.deceptive_link_clicked.connect(on_deceptive_link_clicked);
         this.web_view.link_activated.connect((link) => {
                 link_activated(link);
             });
@@ -725,75 +726,6 @@ public class ConversationMessage : Gtk.Grid {
         }
     }
 
-    /*
-     * Test whether text looks like a URI that leads somewhere other than href.  The text
-     * will have a scheme prepended if it doesn't already have one, and the short versions
-     * have the scheme skipped and long paths truncated.
-     */
-    // private bool deceptive_text(string href, ref string text, out string href_short,
-    //     out string text_short) {
-    //     href_short = "";
-    //     text_short = "";
-    //     // mailto URLs have a different form, and the worst they can do is pop up a composer,
-    //     // so we don't trigger on them.
-    //     if (href.has_prefix("mailto:";))
-    //         return false;
-        
-    //     // First, does text look like a URI?  Right now, just test whether it has
-    //     // <string>.<string> in it.  More sophisticated tests are possible.
-    //     GLib.MatchInfo text_match, href_match;
-    //     try {
-    //         GLib.Regex domain = new GLib.Regex(
-    //             "([a-z]*://)?"                  // Optional scheme
-    //             + "([^\\s:/]+\\.[^\\s:/\\.]+)"  // Domain
-    //             + "(/[^\\s]*)?"                 // Optional path
-    //             );
-    //         if (!domain.match(text, 0, out text_match))
-    //             return false;
-    //         if (!domain.match(href, 0, out href_match)) {
-    //             // If href doesn't look like a URL, something is fishy, so warn the user
-    //             href_short = href + _(" (Invalid?)");
-    //             text_short = text;
-    //             return true;
-    //         }
-    //     } catch (Error error) {
-    //         warning("Error in Regex text for deceptive urls: %s", error.message);
-    //         return false;
-    //     }
-        
-    //     // Second, do the top levels of the two domains match?  We compare the top n levels,
-    //     // where n is the minimum of the number of levels of the two domains.
-    //     string[] href_parts = href_match.fetch_all();
-    //     string[] text_parts = text_match.fetch_all();
-    //     string[] text_domain = text_parts[2].down().reverse().split(".");
-    //     string[] href_domain = href_parts[2].down().reverse().split(".");
-    //     for (int i = 0; i < text_domain.length && i < href_domain.length; i++) {
-    //         if (text_domain[i] != href_domain[i]) {
-    //             if (href_parts[1] == "")
-    //                 href_parts[1] = "http://";;
-    //             if (text_parts[1] == "")
-    //                 text_parts[1] = href_parts[1];
-    //             string temp;
-    //             assemble_uris(href_parts, out temp, out href_short);
-    //             assemble_uris(text_parts, out text, out text_short);
-    //             return true;
-    //         }
-    //     }
-    //     return false;
-    // }
-
-    // private void assemble_uris(string[] parts, out string full, out string short_) {
-    //     full = parts[1] + parts[2];
-    //     short_ = parts[2];
-    //     if (parts.length == 4 && parts[3] != "/") {
-    //         full += parts[3];
-    //         if (parts[3].length > 20)
-    //             short_ += parts[3].substring(0, 20) + "…";
-    //         else
-    //             short_ += parts[3];
-    //     }
-    // }
-
     private inline void set_revealer(Gtk.Revealer revealer,
                                      bool expand,
                                      bool use_transition) {
@@ -936,42 +868,36 @@ public class ConversationMessage : Gtk.Grid {
         this.body_container.trigger_tooltip_query();
     }
 
-    // // Check for possible phishing links, displays a popover if found.
-    // // If not, lets it go through to the default handler.
-    // private bool on_link_clicked() {
-    //     string? href = element.get_attribute("href");
-    //     if (Geary.String.is_empty(href))
-    //         return false;
-    //     string text = ((WebKit.DOM.HTMLElement) element).get_inner_text();
-    //     string href_short, text_short;
-    //     if (!deceptive_text(href, ref text, out href_short, out text_short))
-    //         return false;
-
-    //     Escape text and especially URLs since we got them from the
-    //     HREF, and Gtk.Label.set_markup is a strict parser.
-    //     good_link_label.set_markup(
-    //         Markup.printf_escaped("<a href=\"%s\">%s</a>", text, text_short)
-    //     );
-    //     bad_link_label.set_markup(
-    //         Markup.printf_escaped("<a href=\"%s\">%s</a>", href, href_short)
-    //     );
-
-    //     Work out the link's position, update the popover.
-    //     Gdk.Rectangle link_rect = Gdk.Rectangle();
-    //     web_view.get_allocation(out link_rect);
-    //     WebKit.DOM.Element? offset_parent = element;
-    //     while (offset_parent != null) {
-    //         link_rect.x += (int) offset_parent.offset_left;
-    //         link_rect.y += (int) offset_parent.offset_top;
-    //         offset_parent = offset_parent.offset_parent;
-    //     }
-    //     link_rect.width = (int) element.offset_width;
-    //     link_rect.height = (int) element.offset_height;
-    //     link_popover.set_pointing_to(link_rect);
-
-    //     link_popover.show();
-    //     return true;
-    // }
+    // Check for possible phishing links, displays a popover if found.
+    // If not, lets it go through to the default handler.
+    private void on_deceptive_link_clicked(ConversationWebView.DeceptiveText reason,
+                                           string text,
+                                           string href,
+                                           Gdk.Rectangle location) {
+        string text_href = text;
+        if (Uri.parse_scheme(text_href) == null) {
+            text_href = "http://"; + text_href;
+        }
+        string text_label = Soup.URI.decode(text_href);
+
+        string anchor_href = href;
+        if (Uri.parse_scheme(anchor_href) == null) {
+            anchor_href = "http://"; + anchor_href;
+        }
+        string anchor_label = Soup.URI.decode(anchor_href);
+
+        // Escape text and especially URLs since we got them from the
+        // HREF, and Gtk.Label.set_markup is a strict parser.
+        good_link_label.set_markup(
+            Markup.printf_escaped("<a href=\"%s\">%s</a>", text_href, text_label)
+        );
+        bad_link_label.set_markup(
+            Markup.printf_escaped("<a href=\"%s\">%s</a>", anchor_href, anchor_label)
+        );
+        link_popover.set_relative_to(this.web_view);
+        link_popover.set_pointing_to(location);
+        link_popover.show();
+    }
 
     [GtkCallback]
     private bool on_link_popover_activated() {
diff --git a/src/client/conversation-viewer/conversation-web-view.vala 
b/src/client/conversation-viewer/conversation-web-view.vala
index 03e7f46..78889cd 100644
--- a/src/client/conversation-viewer/conversation-web-view.vala
+++ b/src/client/conversation-viewer/conversation-web-view.vala
@@ -1,6 +1,6 @@
-/* 
+/*
  * Copyright 2016 Software Freedom Conservancy Inc.
- * Copyright 2016 Michael Gratton <mike vee net>
+ * Copyright 2017 Michael Gratton <mike vee net>
  *
  * This software is licensed under the GNU Lesser General Public License
  * (version 2.1 or later).  See the COPYING file in this distribution.
@@ -10,6 +10,19 @@ public class ConversationWebView : ClientWebView {
 
     private const string USER_CSS = "user-message.css";
 
+    private const string DECEPTIVE_LINK_CLICKED = "deceptiveLinkClicked";
+
+    /** Specifies the type of deceptive link text when clicked. */
+    public enum DeceptiveText {
+        // Keep this in sync with JS ConversationPageState
+        /** No deceptive text found. */
+        NOT_DECEPTIVE = 0,
+        /** The link had an invalid HREF value. */
+        DECEPTIVE_HREF = 1,
+        /** The domain of the link's text did not match the HREF. */
+        DECEPTIVE_DOMAIN = 2;
+    }
+
     private static WebKit.UserStyleSheet? user_stylesheet = null;
     private static WebKit.UserStyleSheet? app_stylesheet = null;
     private static WebKit.UserScript? app_script = null;
@@ -28,6 +41,12 @@ public class ConversationWebView : ClientWebView {
     }
 
 
+    /** Emitted when the user clicks on a link with deceptive text. */
+    public signal void deceptive_link_clicked(
+        DeceptiveText reason, string text, string href, Gdk.Rectangle location
+    );
+
+
     public ConversationWebView(Configuration config) {
         base(config);
         this.user_content_manager.add_script(ConversationWebView.app_script);
@@ -35,6 +54,10 @@ public class ConversationWebView : ClientWebView {
         if (ConversationWebView.user_stylesheet != null) {
             this.user_content_manager.add_style_sheet(ConversationWebView.user_stylesheet);
         }
+
+        register_message_handler(
+            DECEPTIVE_LINK_CLICKED, on_deceptive_link_clicked
+        );
     }
 
     /**
@@ -57,4 +80,45 @@ public class ConversationWebView : ClientWebView {
         return WebKitUtil.to_string(result);
     }
 
+    private void on_deceptive_link_clicked(WebKit.JavascriptResult result) {
+        try {
+            JS.GlobalContext context = result.get_global_context();
+            JS.Object details = WebKitUtil.to_object(result);
+
+            uint reason = (uint) Geary.JS.to_number(
+                context,
+                Geary.JS.get_property(context, details, "reason"));
+
+            string href = Geary.JS.to_string(
+                context,
+                Geary.JS.get_property(context, details, "href"));
+
+            string text = Geary.JS.to_string(
+                context,
+                Geary.JS.get_property(context, details, "text"));
+
+            JS.Object js_location = Geary.JS.to_object(
+                context,
+                Geary.JS.get_property(context, details, "location"));
+
+            Gdk.Rectangle location = new Gdk.Rectangle();
+            location.x = (int) Geary.JS.to_number(
+                context,
+                Geary.JS.get_property(context, js_location, "x"));
+            location.y = (int) Geary.JS.to_number(
+                context,
+                Geary.JS.get_property(context, js_location, "y"));
+            location.width = (int) Geary.JS.to_number(
+                context,
+                Geary.JS.get_property(context, js_location, "width"));
+            location.height = (int) Geary.JS.to_number(
+                context,
+                Geary.JS.get_property(context, js_location, "height"));
+
+            deceptive_link_clicked((DeceptiveText) reason, text, href, location);
+        } catch (Geary.JS.Error err) {
+            debug("Could not get deceptive link param: %s", err.message);
+        }
+    }
+
 }
diff --git a/src/client/util/util-webkit.vala b/src/client/util/util-webkit.vala
index 79bdd3b..375c47b 100644
--- a/src/client/util/util-webkit.vala
+++ b/src/client/util/util-webkit.vala
@@ -67,4 +67,16 @@ namespace WebKitUtil {
         return Geary.JS.to_string_released(js_str);
     }
 
+    /**
+     * Returns a WebKit {@link WebKit.JavascriptResult} as an Object.
+     *
+     * This will raise a {@link Geary.JS.Error.TYPE} error if the
+     * result is not a JavaScript `Object`.
+     */
+    public JS.Object to_object(WebKit.JavascriptResult result)
+        throws Geary.JS.Error {
+        return Geary.JS.to_object(result.get_global_context(),
+                                  result.get_value());
+    }
+
 }
diff --git a/src/engine/util/util-js.vala b/src/engine/util/util-js.vala
index a25e330..af2fb26 100644
--- a/src/engine/util/util-js.vala
+++ b/src/engine/util/util-js.vala
@@ -76,6 +76,26 @@ namespace Geary.JS {
     }
 
     /**
+     * Returns a JSC Value as an object.
+     *
+     * This will raise a {@link Geary.JS.Error.TYPE} error if the
+     * value is not a JavaScript `Object`.
+     */
+    public global::JS.Object to_object(global::JS.Context context,
+                                       global::JS.Value value)
+        throws Geary.JS.Error {
+        if (!value.is_object(context)) {
+            throw new Geary.JS.Error.TYPE("Value is not a JS Object");
+        }
+
+        global::JS.Value? err = null;
+        global::JS.Object js_obj = value.to_object(context, out err);
+        Geary.JS.check_exception(context, err);
+
+        return js_obj;
+    }
+
+    /**
      * Returns a JSC {@link JS.String} as a Vala {@link string}.
      */
     public inline string to_string_released(global::JS.String js) {
@@ -87,6 +107,27 @@ namespace Geary.JS {
     }
 
     /**
+     * Returns the value of an object's property.
+     *
+     * This will raise a {@link Geary.JS.Error.TYPE} error if the
+     * object does not contain the named property.
+     */
+    public inline global::JS.Value get_property(global::JS.Context context,
+                                                global::JS.Object object,
+                                                string name)
+        throws Geary.JS.Error {
+        global::JS.String js_name = new global::JS.String.create_with_utf8_cstring(name);
+        global::JS.Value? err = null;
+        global::JS.Value prop = object.get_property(context, js_name, out err);
+        try {
+            Geary.JS.check_exception(context, err);
+        } finally {
+            js_name.release();
+        }
+        return prop;
+    }
+
+    /**
      * Checks an JS exception returned from a JSC call.
      *
      * This method will raise a {@link Geary.JS.Error} if the given
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 363fa52..c050287 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -19,6 +19,7 @@ set(TEST_SRC
   client/composer/composer-web-view-test.vala
 
   js/composer-page-state-test.vala
+  js/conversation-page-state-test.vala
 )
 
 # Vala
diff --git a/test/client/components/client-web-view-test-case.vala 
b/test/client/components/client-web-view-test-case.vala
index ddbe9a8..9854a6a 100644
--- a/test/client/components/client-web-view-test-case.vala
+++ b/test/client/components/client-web-view-test-case.vala
@@ -42,4 +42,13 @@ public abstract class ClientWebViewTestCase<V> : Gee.TestCase {
         }
     }
 
+    protected WebKit.JavascriptResult run_javascript(string command) throws Error {
+        ClientWebView view = (ClientWebView) this.test_view;
+        view.run_javascript.begin(
+            command, null, (obj, res) => { async_complete(res); }
+        );
+
+        return view.run_javascript.end(async_result());
+    }
+
 }
diff --git a/test/js/composer-page-state-test.vala b/test/js/composer-page-state-test.vala
index 68b7279..42439c3 100644
--- a/test/js/composer-page-state-test.vala
+++ b/test/js/composer-page-state-test.vala
@@ -25,7 +25,7 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
         load_body_fixture(html);
 
         try {
-            assert(run_javascript(@"new EditContext(document.getElementById('test')).encode()")
+            assert(WebKitUtil.to_string(run_javascript(@"new 
EditContext(document.getElementById('test')).encode()"))
                    .has_prefix("1,url,"));
         } catch (Geary.JS.Error err) {
             print("Geary.JS.Error: %s\n", err.message);
@@ -41,8 +41,8 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
         load_body_fixture(html);
 
         try {
-            assert(run_javascript(@"new EditContext(document.getElementById('test')).encode()")
-                   == ("0,,Comic Sans,144"));
+            assert(WebKitUtil.to_string(run_javascript(@"new 
EditContext(document.getElementById('test')).encode()")) ==
+                   "0,,Comic Sans,144");
         } catch (Geary.JS.Error err) {
             print("Geary.JS.Error: %s\n", err.message);
             assert_not_reached();
@@ -56,7 +56,8 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
         string html = "<p>para</p>";
         load_body_fixture(html);
         try {
-            assert(run_javascript(@"window.geary.getHtml();") == html + "<br><br>");
+            assert(WebKitUtil.to_string(run_javascript(@"window.geary.getHtml();")) ==
+                   html + "<br><br>");
         } catch (Geary.JS.Error err) {
             print("Geary.JS.Error: %s\n", err.message);
             assert_not_reached();
@@ -69,7 +70,8 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
     public void get_text() {
         load_body_fixture("<p>para</p>");
         try {
-            assert(run_javascript(@"window.geary.getText();") == "para\n\n\n\n");
+            assert(WebKitUtil.to_string(run_javascript(@"window.geary.getText();")) ==
+                   "para\n\n\n\n");
         } catch (Geary.JS.Error err) {
             print("Geary.JS.Error: %s\n", err.message);
             assert_not_reached();
@@ -83,7 +85,7 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
         unichar q_marker = Geary.RFC822.Utils.QUOTE_MARKER;
         load_body_fixture("<p>pre</p> <blockquote><p>quote</p></blockquote> <p>post</p>");
         try {
-            assert(run_javascript(@"window.geary.getText();") ==
+            assert(WebKitUtil.to_string(run_javascript(@"window.geary.getText();")) ==
                    @"pre\n\n$(q_marker)quote\n$(q_marker)\npost\n\n\n\n");
         } catch (Geary.JS.Error err) {
             print("Geary.JS.Error: %s", err.message);
@@ -98,7 +100,7 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
         unichar q_marker = Geary.RFC822.Utils.QUOTE_MARKER;
         load_body_fixture("<p>pre</p> <blockquote><p>quote1</p> 
<blockquote><p>quote2</p></blockquote></blockquote> <p>post</p>");
         try {
-            assert(run_javascript(@"window.geary.getText();") ==
+            assert(WebKitUtil.to_string(run_javascript(@"window.geary.getText();")) ==
                    
@"pre\n\n$(q_marker)quote1\n$(q_marker)\n$(q_marker)$(q_marker)quote2\n$(q_marker)$(q_marker)\npost\n\n\n\n");
         } catch (Geary.JS.Error err) {
             print("Geary.JS.Error: %s\n", err.message);
@@ -122,17 +124,17 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
         string js_cosy_quote2 = @"foo$(q_start)0$(q_end)$(q_start)1$(q_end)bar";
         string js_values = "['quote1','quote2']";
         try {
-            assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_no_quote)', $(js_values));") ==
+            assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_no_quote)', 
$(js_values));")) ==
                    @"foo");
-            assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_spaced_quote)', $(js_values));") 
==
+            
assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_spaced_quote)', 
$(js_values));")) ==
                    @"foo \n$(q_marker)quote1\n bar");
-            assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_leading_quote)', $(js_values));") 
==
+            
assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_leading_quote)', 
$(js_values));")) ==
                    @"$(q_marker)quote1\n bar");
-            assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_hanging_quote)', $(js_values));") 
==
+            
assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_hanging_quote)', 
$(js_values));")) ==
                    @"foo \n$(q_marker)quote1");
-            assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_cosy_quote1)', $(js_values));") ==
+            
assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_cosy_quote1)', 
$(js_values));")) ==
                    @"foo\n$(q_marker)quote1\nbar");
-            assert(run_javascript(@"ComposerPageState.resolveNesting('$(js_cosy_quote2)', $(js_values));") ==
+            
assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.resolveNesting('$(js_cosy_quote2)', 
$(js_values));")) ==
                    @"foo\n$(q_marker)quote1\n$(q_marker)quote2\nbar");
         } catch (Geary.JS.Error err) {
             print("Geary.JS.Error: %s\n", err.message);
@@ -147,11 +149,11 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
         load_body_fixture();
         unichar q_marker = Geary.RFC822.Utils.QUOTE_MARKER;
         try {
-            assert(run_javascript("ComposerPageState.quoteLines('');") ==
+            assert(WebKitUtil.to_string(run_javascript("ComposerPageState.quoteLines('');")) ==
                    @"$(q_marker)");
-            assert(run_javascript("ComposerPageState.quoteLines('line1');") ==
+            assert(WebKitUtil.to_string(run_javascript("ComposerPageState.quoteLines('line1');")) ==
                    @"$(q_marker)line1");
-            assert(run_javascript("ComposerPageState.quoteLines('line1\\nline2');") ==
+            assert(WebKitUtil.to_string(run_javascript("ComposerPageState.quoteLines('line1\\nline2');")) ==
                    @"$(q_marker)line1\n$(q_marker)line2");
         } catch (Geary.JS.Error err) {
             print("Geary.JS.Error: %s\n", err.message);
@@ -167,9 +169,9 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
         string single_nbsp = "a b";
         string multiple_nbsp = "a b c";
         try {
-            assert(run_javascript(@"ComposerPageState.replaceNonBreakingSpace('$(single_nbsp)');") ==
+            
assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.replaceNonBreakingSpace('$(single_nbsp)');")) 
==
                    "a b");
-            assert(run_javascript(@"ComposerPageState.replaceNonBreakingSpace('$(multiple_nbsp)');") ==
+            
assert(WebKitUtil.to_string(run_javascript(@"ComposerPageState.replaceNonBreakingSpace('$(multiple_nbsp)');"))
 ==
                    "a b c");
         } catch (Geary.JS.Error err) {
             print("Geary.JS.Error: %s\n", err.message);
@@ -196,14 +198,4 @@ class ComposerPageStateTest : ClientWebViewTestCase<ComposerWebView> {
         }
     }
 
-    protected string run_javascript(string command) throws Error {
-        this.test_view.run_javascript.begin(
-            command, null, (obj, res) => { async_complete(res); }
-        );
-
-        WebKit.JavascriptResult result =
-           this.test_view.run_javascript.end(async_result());
-        return WebKitUtil.to_string(result);
-    }
-
 }
diff --git a/test/js/conversation-page-state-test.vala b/test/js/conversation-page-state-test.vala
new file mode 100644
index 0000000..48a1016
--- /dev/null
+++ b/test/js/conversation-page-state-test.vala
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2017 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+class ConversationPageStateTest : ClientWebViewTestCase<ConversationWebView> {
+
+    public ConversationPageStateTest() {
+        base("ConversationPageStateTest");
+        add_test("is_deceptive_text_not_url", is_deceptive_text_not_url);
+        add_test("is_deceptive_text_identical_text", is_deceptive_text_identical_text);
+        add_test("is_deceptive_text_matching_url", is_deceptive_text_matching_url);
+        add_test("is_deceptive_text_common_href_subdomain", is_deceptive_text_common_href_subdomain);
+        add_test("is_deceptive_text_common_text_subdomain", is_deceptive_text_common_text_subdomain);
+        add_test("is_deceptive_text_deceptive_href", is_deceptive_text_deceptive_href);
+        add_test("is_deceptive_text_non_matching_subdomain", is_deceptive_text_non_matching_subdomain);
+        add_test("is_deceptive_text_different_domain", is_deceptive_text_different_domain);
+    }
+
+    public void is_deceptive_text_not_url() {
+        load_body_fixture("<p>my hovercraft is full of eels</p>");
+        assert(exec_is_deceptive_text("ohhai!", "http://example.com";) ==
+               ConversationWebView.DeceptiveText.NOT_DECEPTIVE);
+    }
+
+    public void is_deceptive_text_identical_text() {
+        load_body_fixture("<p>my hovercraft is full of eels</p>");
+        assert(exec_is_deceptive_text("http://example.com";, "http://example.com";) ==
+               ConversationWebView.DeceptiveText.NOT_DECEPTIVE);
+    }
+
+    public void is_deceptive_text_matching_url() {
+        load_body_fixture("<p>my hovercraft is full of eels</p>");
+        assert(exec_is_deceptive_text("example.com", "http://example.com";) ==
+               ConversationWebView.DeceptiveText.NOT_DECEPTIVE);
+    }
+
+    public void is_deceptive_text_common_href_subdomain() {
+        load_body_fixture("<p>my hovercraft is full of eels</p>");
+        assert(exec_is_deceptive_text("example.com", "http://foo.example.com";) ==
+               ConversationWebView.DeceptiveText.NOT_DECEPTIVE);
+    }
+
+    public void is_deceptive_text_common_text_subdomain() {
+        load_body_fixture("<p>my hovercraft is full of eels</p>");
+        assert(exec_is_deceptive_text("www.example.com", "http://example.com";) ==
+               ConversationWebView.DeceptiveText.NOT_DECEPTIVE);
+    }
+
+    public void is_deceptive_text_deceptive_href() {
+        load_body_fixture("<p>my hovercraft is full of eels</p>");
+        assert(exec_is_deceptive_text("www.example.com", "ohhai!") ==
+               ConversationWebView.DeceptiveText.DECEPTIVE_HREF);
+    }
+
+    public void is_deceptive_text_non_matching_subdomain() {
+        load_body_fixture("<p>my hovercraft is full of eels</p>");
+        assert(exec_is_deceptive_text("www.example.com", "phishing.com") ==
+               ConversationWebView.DeceptiveText.DECEPTIVE_DOMAIN);
+    }
+
+    public void is_deceptive_text_different_domain() {
+        load_body_fixture("<p>my hovercraft is full of eels</p>");
+        assert(exec_is_deceptive_text("www.example.com", "phishing.net") ==
+               ConversationWebView.DeceptiveText.DECEPTIVE_DOMAIN);
+    }
+
+    protected override ConversationWebView set_up_test_view() {
+        try {
+            ConversationWebView.load_resources(File.new_for_path(""));
+        } catch (Error err) {
+            assert_not_reached();
+        }
+        return new ConversationWebView(this.config);
+    }
+
+    private uint exec_is_deceptive_text(string text, string href) {
+        try {
+            return (uint) WebKitUtil.to_number(
+                run_javascript(@"ConversationPageState.isDeceptiveText(\"$text\", \"$href\")")
+            );
+        } catch (Geary.JS.Error err) {
+            print("Geary.JS.Error: %s\n", err.message);
+            assert_not_reached();
+        } catch (Error err) {
+            print("WKError: %s\n", err.message);
+            assert_not_reached();
+        }
+    }
+
+}
diff --git a/test/main.vala b/test/main.vala
index fe7f6e5..d141516 100644
--- a/test/main.vala
+++ b/test/main.vala
@@ -53,6 +53,7 @@ int main(string[] args) {
     TestSuite js = new TestSuite("js");
 
     js.add_suite(new ComposerPageStateTest().get_suite());
+    js.add_suite(new ConversationPageStateTest().get_suite());
 
     /*
      * Run the tests
diff --git a/ui/conversation-web-view.js b/ui/conversation-web-view.js
index cf1dcb7..3fd6fc7 100644
--- a/ui/conversation-web-view.js
+++ b/ui/conversation-web-view.js
@@ -16,10 +16,24 @@ let ConversationPageState = function() {
 ConversationPageState.QUOTE_CONTAINER_CLASS = "geary-quote-container";
 ConversationPageState.QUOTE_HIDE_CLASS = "geary-hide";
 
+// Keep these in sync with ConversationWebView
+ConversationPageState.NOT_DECEPTIVE = 0;
+ConversationPageState.DECEPTIVE_HREF = 1;
+ConversationPageState.DECEPTIVE_DOMAIN = 2;
+
 ConversationPageState.prototype = {
     __proto__: PageState.prototype,
     init: function() {
         PageState.prototype.init.apply(this, []);
+
+        let state = this;
+        document.addEventListener("click", function(e) {
+            if (e.target.tagName == "A" &&
+                state.linkClicked(e.target)) {
+                e.preventDefault();
+            }
+        }, true);
+
     },
     loaded: function() {
         this.updateDirection();
@@ -209,9 +223,87 @@ ConversationPageState.prototype = {
             }
         }
         return value;
+    },
+    linkClicked: function(link) {
+        let cancelClick = false;
+        let href = link.href;
+        if (!href.startsWith("mailto:";)) {
+            let text = link.innerText;
+            let reason = ConversationPageState.isDeceptiveText(text, href);
+            if (reason != ConversationPageState.NOT_DECEPTIVE) {
+                cancelClick = true;
+                window.webkit.messageHandlers.deceptiveLinkClicked.postMessage({
+                    reason: reason,
+                    text: text,
+                    href: href,
+                    location: ConversationPageState.getNodeBounds(link)
+                });
+            }
+        }
+
+        return cancelClick;
     }
 };
 
+/**
+ * Returns an [x, y, width, height] array of a node's bounds.
+ */
+ConversationPageState.getNodeBounds = function(node) {
+    let x = 0;
+    let y = 0;
+    let parent = node;
+    while (parent != null) {
+        x += parent.offsetLeft;
+        y += parent.offsetTop;
+        parent = parent.offsetParent;
+    }
+    return {
+        x: x,
+        y: y,
+        width: node.offsetWidth,
+        height: node.offsetHeight
+    };
+};
+
+/**
+ * Test for URL-like `text` that leads somewhere other than `href`.
+ */
+ConversationPageState.isDeceptiveText = function(text, href) {
+    // First, does text look like a URI?  Right now, just test whether
+    // it has <string>.<string> in it.  More sophisticated tests are
+    // possible.
+    let domain = new RegExp("([a-z]*://)?"               // Optional scheme
+                          + "([^\\s:/]+\\.[^\\s:/\\.]+)" // Domain
+                          + "(/[^\\s]*)?");              // Optional path
+    let textParts = text.match(domain);
+    if (textParts == null) {
+        return ConversationPageState.NOT_DECEPTIVE;
+    }
+    let hrefParts = href.match(domain);
+    if (hrefParts == null) {
+        // If href doesn't look like a URL, something is fishy, so
+        // warn the user
+        return ConversationPageState.DECEPTIVE_HREF;
+    }
+
+    // Second, do the top levels of the two domains match?  We
+    // compare the top n levels, where n is the minimum of the
+    // number of levels of the two domains.
+    let textDomain = textParts[2].toLowerCase().split(".").reverse();
+    let hrefDomain = hrefParts[2].toLowerCase().split(".").reverse();
+    let segmentCount = Math.min(textDomain.length, hrefDomain.length);
+    if (segmentCount == 0) {
+        return ConversationPageState.DECEPTIVE_DOMAIN;
+    }
+    for (let i = 0; i < segmentCount; i++) {
+        if (textDomain[i] != hrefDomain[i]) {
+            return ConversationPageState.DECEPTIVE_DOMAIN;
+        }
+    }
+
+    return ConversationPageState.NOT_DECEPTIVE;
+};
+
 ConversationPageState.isDescendantOf = function(node, ancestorTag) {
     let ancestor = node.parentNode;
     while (ancestor != null) {


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]