FEATURE: Client-sided search in encrypted topics

FEATURE: Client-sided search in encrypted topics

The client maintains a cache of encrypted posts and topics in session storage, uses it to search in encrypted topic titles and adds to the search result set.

diff --git a/app/controllers/encrypt_controller.rb b/app/controllers/encrypt_controller.rb
index 1e11e4d..6b2eac0 100644
--- a/app/controllers/encrypt_controller.rb
+++ b/app/controllers/encrypt_controller.rb
@@ -127,6 +127,28 @@ class DiscourseEncrypt::EncryptController < ApplicationController
     render json: success_json
   end
 
+  # Lists encrypted topics and posts of a user.
+  #
+  # Returns status code 200, topics and posts.
+  def posts
+    posts = Post
+      .joins(:topic, topic: :encrypted_topics_users)
+      .where(encrypted_topics_users: { user_id: current_user.id })
+      .order(created_at: :desc)
+      .limit(1000)
+
+    topics = posts.map(&:topic)
+
+    render json: success_json.merge(
+      ActiveModel::ArraySerializer.new(
+        posts,
+        scope: current_user.guardian,
+        each_serializer: SearchPostSerializer,
+        root: :posts
+      ).as_json
+    )
+  end
+
   # Updates an encrypted post, used immediately after creating one to
   # update signature.
   #
diff --git a/assets/javascripts/discourse/initializers/add-search-results.js b/assets/javascripts/discourse/initializers/add-search-results.js
new file mode 100644
index 0000000..56f22ee
--- /dev/null
+++ b/assets/javascripts/discourse/initializers/add-search-results.js
@@ -0,0 +1,130 @@
+import { Promise } from "rsvp";
+import { ajax } from "discourse/lib/ajax";
+import { withPluginApi } from "discourse/lib/plugin-api";
+import Post from "discourse/models/post";
+import Topic from "discourse/models/topic";
+import {
+  ENCRYPT_ACTIVE,
+  getEncryptionStatus,
+  getIdentity,
+} from "discourse/plugins/discourse-encrypt/lib/discourse";
+import {
+  decrypt,
+  importKey,
+} from "discourse/plugins/discourse-encrypt/lib/protocol";
+import I18n from "I18n";
+
+const CACHE_KEY = "discourse-encrypt-cache";
+
+function getCache() {
+  const cache = window.sessionStorage.getItem(CACHE_KEY);
+  return cache ? JSON.parse(cache) : {};
+}
+
+function addCacheItem(type, item) {
+  const cache = getCache();
+  if (!cache[type]) {
+    cache[type] = [];
+  } else if (item.id) {
+    cache[type] = cache[type].filter((i) => i.id !== item.id);
+  }
+  cache[type].push(item);
+  window.sessionStorage.setItem(CACHE_KEY, JSON.stringify(cache));
+}
+
+function getOrFetchCache() {
+  let promise = Promise.resolve();
+  if (!window.sessionStorage.getItem(CACHE_KEY)) {
+    promise = ajax("/encrypt/posts").then((result) => {
+      const promises = [];
+
+      result.posts.forEach((post) => {
+        addCacheItem("posts", post);
+      });
+
+      result.topics.forEach((topic) => {
+        promises.push(
+          getIdentity()
+            .then((id) => importKey(topic.topic_key, id.encryptPrivate))
+            .then((key) => decrypt(key, topic.encrypted_title))
+            .then((decrypted) => {
+              topic.title = topic.fancy_title = decrypted.raw;
+              addCacheItem("topics", topic);
+            })
+        );
+      });
+
+      return Promise.all(promises);
+    });
+  }
+  return promise.then(() => getCache());
+}
+
+export default {
+  name: "add-search-results",
+
+  initialize(container) {
+    const currentUser = container.lookup("current-user:main");
+    const siteSettings = container.lookup("site-settings:main");
+    if (getEncryptionStatus(currentUser, siteSettings) !== ENCRYPT_ACTIVE) {
+      return;
+    }
+
+    withPluginApi("0.11.3", (api) => {
+      api.addSearchResultsCallback((results) => {
+        const term = results.grouped_search_result.term;
+        const words = term.split(/\s+/);
+
+        if (!words.some((w) => w === "in:personal")) {
+          return Promise.resolve(results);
+        }
+
+        return getOrFetchCache().then((cache) => {
+          const topics = {};
+          if (cache.topics) {
+            cache.topics.forEach((topic) => {
+              if (!words.some((word) => topic.title.indexOf(word) !== -1)) {
+                return;
+              }
+
+              const topicObj = results.topics.find((t) => topic.id === t.id);
+              if (topicObj) {
+                topicObj.setProperties(topic);
+              } else {
+                topic = Topic.create(topic);
+                topics[topic.id] = topic;
+              }
+            });
+          }
+
+          const posts = {};
+          if (cache.posts) {
+            cache.posts.forEach((post) => {
+              if (!topics[post.topic_id]) {
+                return;
+              }
+
+              post = Post.create(post);
+              post.setProperties({
+                topic: topics[post.topic_id],
+                blurb: I18n.t("encrypt.encrypted_post"),
+              });
+              posts[post.topic_id] = post;
+            });
+          }
+
+          Object.values(topics).forEach((topic) => {
+            results.topics.unshift(topic);
+          });
+
+          Object.values(posts).forEach((p) => {
+            results.posts.unshift(p);
+            results.grouped_search_result.post_ids.unshift(p.id);
+          });
+
+          return results;
+        });
+      });
+    });
+  },
+};
diff --git a/assets/javascripts/discourse/initializers/hook-decrypt-topic.js b/assets/javascripts/discourse/initializers/hook-decrypt-topic.js
index ce5d49a..46f5ec3 100644
--- a/assets/javascripts/discourse/initializers/hook-decrypt-topic.js
+++ b/assets/javascripts/discourse/initializers/hook-decrypt-topic.js
@@ -58,9 +58,6 @@ function decryptElements(containerSelector, elementSelector, opts) {
 
     // TODO: Hide quick-edit button for the time being.
     $(this).find(".edit-topic").hide();
-
-    // Hide excerpt in search
-    $(this).parents(".search-link").find(".blurb").hide();
   });
 }
 
@@ -160,7 +157,6 @@ export default {
     decryptElements("a.topic-link[data-topic-id]", "span");
     decryptElements("a.topic-link[data-topic-id]", { addIcon: true });
     decryptElements("a.raw-topic-link[data-topic-id]", { addIcon: true });
-    decryptElements(".search-result-topic span[data-topic-id]");
   },
 
   decryptDocTitle(data) {
diff --git a/plugin.rb b/plugin.rb
index 65108cf..3b4ea17 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -5,6 +5,7 @@
 # version: 1.0
 # authors: Dan Ungureanu
 # url: https://github.com/udan11/discourse-encrypt.git
+# transpile_js: true
 
 enabled_site_setting :encrypt_enabled
 
@@ -52,6 +53,7 @@ after_initialize do
     delete '/encrypt/keys'           => 'encrypt#delete_key'
     get    '/encrypt/user'           => 'encrypt#show_user'
     post   '/encrypt/reset'          => 'encrypt#reset_user'
+    get    '/encrypt/posts'          => 'encrypt#posts'
     put    '/encrypt/post'           => 'encrypt#update_post'
     get    '/encrypt/stats'          => 'encrypt#stats'
     get    '/encrypt/rotate'         => 'encrypt#show_all_keys'
diff --git a/test/acceptance/encrypt-test.js b/test/acceptance/encrypt-test.js
index 40e29d7..35db187 100644
--- a/test/acceptance/encrypt-test.js
+++ b/test/acceptance/encrypt-test.js
@@ -180,6 +180,10 @@ acceptance("Encrypt", function (needs) {
       return helper.response(response);
     });
 
+    server.get("/encrypt/posts", () => {
+      return helper.response({ posts: [], topics: [] });
+    });
+
     server.put("/encrypt/post", () => {
       return helper.response({});
     });

GitHub sha: f86e3113127fe865253deaefa6682d159c391766

This commit appears in #97 which was approved by eviltrout. It was merged by udan11.