From 23367e79ea735598766ec6f507f6132e0bad3dba Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 15 Aug 2019 13:41:06 -0400 Subject: [PATCH] FEATURE: Embed topics list on remote sites via Javascript API. (#8008) This adds support for a `` tag you can embed in your site that will be rendered as a list of discourse topics. Any attributes on the tag will be passed as filters. For example: `` will filter to category 1234. To use this feature, enable the `embed topics list` site setting. Then on the site you want to embed, include the following javascript: `` Where `URL` is your discourse forum's URL. Then include the `` tag in your HTML document and it will be replaced with the list of topics. --- .../embed-application.js.no-module.es6 | 11 +++-- app/assets/stylesheets/embed.scss | 16 +++++++ app/controllers/embed_controller.rb | 27 +++++++++-- app/controllers/list_controller.rb | 24 +--------- app/views/embed/embed_topics_error.html.erb | 8 ++++ app/views/embed/topics.html.erb | 11 +++++ config/locales/server.en.yml | 2 + config/routes.rb | 1 + config/site_settings.yml | 1 + lib/topic_query_params.rb | 25 ++++++++++ public/javascripts/embed-topics.js | 47 +++++++++++++++++++ spec/requests/embed_controller_spec.rb | 27 +++++++++++ 12 files changed, 171 insertions(+), 29 deletions(-) create mode 100644 app/views/embed/embed_topics_error.html.erb create mode 100644 app/views/embed/topics.html.erb create mode 100644 lib/topic_query_params.rb create mode 100644 public/javascripts/embed-topics.js diff --git a/app/assets/javascripts/embed-application.js.no-module.es6 b/app/assets/javascripts/embed-application.js.no-module.es6 index f8d9c082276..9cdda8267e1 100644 --- a/app/assets/javascripts/embed-application.js.no-module.es6 +++ b/app/assets/javascripts/embed-application.js.no-module.es6 @@ -24,17 +24,20 @@ window.onload = function() { // get state info from data attribute - var header = document.querySelector("header"); + var embedState = document.querySelector("[data-embed-state]"); var state = "unknown"; - if (header) { - state = header.getAttribute("data-embed-state"); + var embedId = null; + if (embedState) { + state = embedState.getAttribute("data-embed-state"); + embedId = embedState.getAttribute("data-embed-id"); } // Send a post message with our loaded height and state postUp({ type: "discourse-resize", height: document["body"].offsetHeight, - state: state + state, + embedId }); var postLinks = document.querySelectorAll("a[data-link-to-post]"), diff --git a/app/assets/stylesheets/embed.scss b/app/assets/stylesheets/embed.scss index a48d0ac327e..6caaa12a9d7 100644 --- a/app/assets/stylesheets/embed.scss +++ b/app/assets/stylesheets/embed.scss @@ -175,3 +175,19 @@ aside.onebox { div.lightbox-wrapper { margin-bottom: 20px; } + +.topics-list { + width: 100%; + .topic-list-item { + td { + padding: 0.5rem; + } + + .main-link a { + color: $primary; + } + .main-link a:visited { + color: $primary-medium; + } + } +} diff --git a/app/controllers/embed_controller.rb b/app/controllers/embed_controller.rb index 759e334d4fa..93c94175521 100644 --- a/app/controllers/embed_controller.rb +++ b/app/controllers/embed_controller.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true class EmbedController < ApplicationController + include TopicQueryParams + skip_before_action :check_xhr, :preload_json, :verify_authenticity_token - before_action :ensure_embeddable, except: [ :info ] - before_action :get_embeddable_css_class, except: [ :info ] + before_action :ensure_embeddable, except: [ :info, :topics ] + before_action :get_embeddable_css_class, except: [ :info, :topics ] before_action :ensure_api_request, only: [ :info ] layout 'embed' @@ -16,7 +18,26 @@ class EmbedController < ApplicationController @show_reason = true @hosts = EmbeddableHost.all end - render 'embed_error' + render 'embed_error', status: 400 + end + + def topics + discourse_expires_in 1.minute + + response.headers['X-Frame-Options'] = "ALLOWALL" + unless SiteSetting.embed_topics_list? + render 'embed_topics_error', status: 400 + return + end + + if @embed_id = params[:discourse_embed_id] + raise Discourse::InvalidParameters.new(:embed_id) unless @embed_id =~ /^de\-[a-zA-Z0-9]+$/ + end + + list_options = build_topic_list_options + list_options[:per_page] = params[:per_page].to_i if params.has_key?(:per_page) + topic_query = TopicQuery.new(current_user, list_options) + @list = topic_query.list_latest end def comments diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index 69a6c96d6a5..6d2bace3415 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true require_dependency 'topic_list_responder' +require_dependency 'topic_query_params' class ListController < ApplicationController include TopicListResponder + include TopicQueryParams skip_before_action :check_xhr @@ -376,28 +378,6 @@ class ListController < ApplicationController end end - def build_topic_list_options - options = {} - params[:tags] = [params[:tag_id].parameterize] if params[:tag_id].present? && guardian.can_tag_pms? - - TopicQuery.public_valid_options.each do |key| - if params.key?(key) - val = options[key] = params[key] - if !TopicQuery.validate?(key, val) - raise Discourse::InvalidParameters.new key - end - end - end - - # hacky columns get special handling - options[:topic_ids] = param_to_integer_list(:topic_ids) - if options[:no_subcategories] == 'true' - options[:no_subcategories] = true - end - - options - end - def list_target_user if params[:user_id] && guardian.is_staff? User.find(params[:user_id].to_i) diff --git a/app/views/embed/embed_topics_error.html.erb b/app/views/embed/embed_topics_error.html.erb new file mode 100644 index 00000000000..392e36ee6ee --- /dev/null +++ b/app/views/embed/embed_topics_error.html.erb @@ -0,0 +1,8 @@ +
+

<%= t 'embed.error' %>

+ <%= link_to(image_tag(SiteSetting.site_logo_url, class: 'logo'), Discourse.base_url) %> +
+
+
+ <%= t('embed.error_topics') %> +
diff --git a/app/views/embed/topics.html.erb b/app/views/embed/topics.html.erb new file mode 100644 index 00000000000..6f478eeb9e7 --- /dev/null +++ b/app/views/embed/topics.html.erb @@ -0,0 +1,11 @@ +<%- if @list && @list.topics.present? %> + data-embed-id="<%= @embed_id %>"<%- end %>> + <%- @list.topics.each do |t| %> + + + + <%- end %> +
+<%- end %> diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index a3553d7a8b4..4dc3a024cc3 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -268,6 +268,7 @@ en: continue: "Continue Discussion" error: "Error Embedding" referer: "Referer:" + error_topics: "The `embed topics list` site setting was not enabled" mismatch: "The referer was either not sent, or did not match any of the following hosts:" no_hosts: "No hosts were set up for embedding." configure: "Configure Embedding" @@ -1943,6 +1944,7 @@ en: autohighlight_all_code: "Force apply code highlighting to all preformatted code blocks even when they didn't explicitly specify the language." highlighted_languages: "Included syntax highlighting rules. (Warning: including too many languages may impact performance) see: https://highlightjs.org/static/demo for a demo" + embed_topics_list: "Support HTML embedding of topics lists" embed_truncate: "Truncate the embedded posts." embed_support_markdown: "Support Markdown formatting for embedded posts." embed_whitelist_selector: "A comma separated list of CSS elements that are allowed in embeds." diff --git a/config/routes.rb b/config/routes.rb index 88db857f3a7..2ef2345bb95 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -711,6 +711,7 @@ Discourse::Application.routes.draw do end end + get 'embed/topics' => 'embed#topics' get 'embed/comments' => 'embed#comments' get 'embed/count' => 'embed#count' get 'embed/info' => 'embed#info' diff --git a/config/site_settings.yml b/config/site_settings.yml index cfaf9719484..1f1c1ee366e 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -849,6 +849,7 @@ posting: choices: - 4-spaces-indent - code-fences + embed_topics_list: false embed_truncate: default: true embed_support_markdown: diff --git a/lib/topic_query_params.rb b/lib/topic_query_params.rb new file mode 100644 index 00000000000..e1bb23cb9f7 --- /dev/null +++ b/lib/topic_query_params.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module TopicQueryParams + def build_topic_list_options + options = {} + params[:tags] = [params[:tag_id].parameterize] if params[:tag_id].present? && guardian.can_tag_pms? + + TopicQuery.public_valid_options.each do |key| + if params.key?(key) + val = options[key] = params[key] + if !TopicQuery.validate?(key, val) + raise Discourse::InvalidParameters.new key + end + end + end + + # hacky columns get special handling + options[:topic_ids] = param_to_integer_list(:topic_ids) + if options[:no_subcategories] == 'true' + options[:no_subcategories] = true + end + + options + end +end diff --git a/public/javascripts/embed-topics.js b/public/javascripts/embed-topics.js new file mode 100644 index 00000000000..3ccbf8d6ea3 --- /dev/null +++ b/public/javascripts/embed-topics.js @@ -0,0 +1,47 @@ +(function() { + function postMessageReceived(e) { + if (!e) { + return; + } + + if (e.data && e.data.type === "discourse-resize" && e.data.embedId) { + var elem = document.getElementById(e.data.embedId); + if (elem) { + elem.height = e.data.height + "px"; + } + } + } + window.addEventListener("message", postMessageReceived, false); + + document.addEventListener("DOMContentLoaded", function(event) { + var lists = document.querySelectorAll("d-topics-list"); + + for (var i = 0; i < lists.length; i++) { + var list = lists[i]; + var url = list.getAttribute("discourse-url"); + if (!url || url.length === 0) { + console.error("Error, `data-discourse-url` was not found"); + continue; + } + var frameId = + "de-" + + Math.random() + .toString(36) + .substr(2, 9); + var params = ["discourse_embed_id=" + frameId]; + list.removeAttribute("discourse-url"); + + for (var j = 0; j < list.attributes.length; j++) { + var attr = list.attributes[j]; + params.push(attr.name.replace("-", "_") + "=" + attr.value); + } + + var iframe = document.createElement("iframe"); + iframe.src = url + "/embed/topics?" + params.join("&"); + iframe.id = frameId; + iframe.frameBorder = 0; + iframe.scrolling = "no"; + list.appendChild(iframe); + } + }); +})(); diff --git a/spec/requests/embed_controller_spec.rb b/spec/requests/embed_controller_spec.rb index 1ee75c2b4e4..8d945dcb448 100644 --- a/spec/requests/embed_controller_spec.rb +++ b/spec/requests/embed_controller_spec.rb @@ -70,6 +70,33 @@ describe EmbedController do end end + context "#topics" do + it "raises an error when not enabled" do + get '/embed/topics?embed_id=de-1234' + expect(response.status).to eq(400) + end + + context "when enabled" do + before do + SiteSetting.embed_topics_list = true + end + + it "raises an error with a weird id" do + get '/embed/topics?discourse_embed_id=../asdf/-1234', headers: headers + expect(response.status).to eq(400) + end + + it "returns a list of topics" do + topic = Fabricate(:topic) + get '/embed/topics?discourse_embed_id=de-1234', headers: headers + expect(response.status).to eq(200) + expect(response.headers['X-Frame-Options']).to eq("ALLOWALL") + expect(response.body).to match("data-embed-id=\"de-1234\"") + expect(response.body).to match("data-topic-id=\"#{topic.id}\"") + end + end + end + context "with a host" do let!(:embeddable_host) { Fabricate(:embeddable_host) } let(:headers) { { 'REFERER' => embed_url } }