diff --git a/COPYRIGHT.md b/COPYRIGHT.md new file mode 100644 index 00000000000..80c989e96b5 --- /dev/null +++ b/COPYRIGHT.md @@ -0,0 +1,59 @@ +# Legal notice + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or (at +your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with this program as the file LICENSE.txt; if not, please see +http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + +## Trademark + +Discourse is a registered trademark of Civilized Discourse Construction Kit. + +## Other copyright notices + +Discourse includes works under other copyright notices and distributed +according to the terms of the GNU General Public License or a compatible +license (where indicated), including: + +- Ember.js - Copyright (c) 2020 Yehuda Katz, Tom Dale and Ember.js contributors + MIT License + +- jQuery - Copyright OpenJS Foundation and other contributors, https://openjsf.org/ + MIT License + +- Rails - Copyright (c) 2005-2021 David Heinemeier Hansson + MIT License + +- Onebox - Copyright (c) 2013 jzeta + MIT License + +MIT License: + +``` +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/COPYRIGHT.txt b/COPYRIGHT.txt deleted file mode 100644 index cb867e5604b..00000000000 --- a/COPYRIGHT.txt +++ /dev/null @@ -1,31 +0,0 @@ -All Discourse code is Copyright 2013 by Civilized Discourse Construction Kit, Inc. - -This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or (at -your option) any later version. - -This program is distributed in the hope that it will be useful, but -WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY -or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -for more details. - -You should have received a copy of the GNU General Public License -along with this program as the file LICENSE.txt; if not, please see -http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. - -Discourse is a registered trademark of Civilized Discourse Construction Kit. - -Discourse includes works under other copyright notices and distributed -according to the terms of the GNU General Public License or a compatible -license (where indicated), including: - -Javascript - - Ember.js - Copyright (c) 2012-2013 Yehuda Katz, Tom Dale, Charles Jolley and Ember.js contributors - - jQuery - Copyright (c) 2010-2013 John Resig - -Ruby - - Rails - Copyright (c) 2005-2013 David Heinemeier Hansson, Rails Core Team contributors (MIT) diff --git a/Gemfile b/Gemfile index e54a9dc8819..703c5da332a 100644 --- a/Gemfile +++ b/Gemfile @@ -60,8 +60,6 @@ gem 'redis-namespace' # better maintained living fork gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox' - gem 'http_accept_language', require: false # Ember related gems need to be pinned cause they control client side @@ -229,6 +227,8 @@ gem 'sshkey', require: false gem 'rchardet', require: false gem 'lz4-ruby', require: false, platform: :ruby +gem 'sanitize' + if ENV["IMPORT"] == "1" gem 'mysql2' gem 'redcarpet' diff --git a/Gemfile.lock b/Gemfile.lock index cba9b033d36..8935c621c8d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -277,13 +277,6 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - onebox (2.2.15) - addressable (~> 2.7.0) - htmlentities (~> 4.3) - multi_json (~> 1.11) - mustache - nokogiri (~> 1.7) - sanitize openssl (2.2.0) openssl-signature_algorithm (1.1.1) openssl (~> 2.0) @@ -558,7 +551,6 @@ DEPENDENCIES omniauth-google-oauth2 omniauth-oauth2 omniauth-twitter - onebox parallel_tests pg pry-byebug @@ -589,6 +581,7 @@ DEPENDENCIES ruby-prof ruby-readability rubyzip + sanitize sassc (= 2.0.1) sassc-rails seed-fu @@ -610,4 +603,4 @@ DEPENDENCIES yaml-lint BUNDLED WITH - 2.2.16 + 2.2.17 diff --git a/LICENSE.txt b/LICENSE.txt index 94fb84639c4..d159169d105 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,12 +1,12 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - Preamble + Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public @@ -56,7 +56,7 @@ patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. - GNU GENERAL PUBLIC LICENSE + GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains @@ -255,7 +255,7 @@ make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - NO WARRANTY + NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN @@ -277,9 +277,9 @@ YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - END OF TERMS AND CONDITIONS + END OF TERMS AND CONDITIONS - How to Apply These Terms to Your New Programs + How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it diff --git a/lib/onebox.rb b/lib/onebox.rb new file mode 100644 index 00000000000..12244ad75ec --- /dev/null +++ b/lib/onebox.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "openssl" +require "open-uri" +require "multi_json" +require "nokogiri" +require "mustache" +require "ostruct" +require "cgi" +require "net/http" +require "digest" +require "sanitize" +require_relative "onebox/sanitize_config" + +module Onebox + DEFAULTS = { + connect_timeout: 5, + timeout: 10, + max_download_kb: (10 * 1024), # 10MB + load_paths: [File.join(Rails.root, "lib/onebox/templates")], + allowed_ports: [80, 443], + allowed_schemes: ["http", "https"], + sanitize_config: Sanitize::Config::ONEBOX, + redirect_limit: 5 + } + + @@options = DEFAULTS + + def self.preview(url, options = Onebox.options) + Preview.new(url, options) + end + + def self.check(url, options = Onebox.options) + StatusCheck.new(url, options) + end + + def self.options + OpenStruct.new(@@options) + end + + def self.has_matcher?(url) + !!Matcher.new(url).oneboxed + end + + def self.options=(options) + @@options = DEFAULTS.merge(options) + end +end + +require_relative "onebox/preview" +require_relative "onebox/status_check" +require_relative "onebox/matcher" +require_relative "onebox/engine" +require_relative "onebox/layout" +require_relative "onebox/view" diff --git a/lib/onebox/engine.rb b/lib/onebox/engine.rb new file mode 100644 index 00000000000..1b2448dc37d --- /dev/null +++ b/lib/onebox/engine.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +module Onebox + module Engine + def self.included(object) + object.extend(ClassMethods) + end + + def self.engines + constants.select do |constant| + constant.to_s =~ /Onebox$/ + end.map(&method(:const_get)) + end + + def self.all_iframe_origins + engines.flat_map { |e| e.iframe_origins }.uniq.compact + end + + def self.origins_to_regexes(origins) + return /.*/ if origins.include?("*") + origins.map do |origin| + escaped_origin = Regexp.escape(origin) + if origin.start_with?("*.", "https://*.", "http://*.") + escaped_origin = escaped_origin.sub("\\*", '\S*') + end + + Regexp.new("\\A#{escaped_origin}", 'i') + end + end + + attr_reader :url, :uri, :options, :timeout + attr :errors + + DEFAULT = {} + + def options=(opt) + return @options if opt.nil? # make sure options provided + opt = opt.to_h if opt.instance_of?(OpenStruct) + @options.merge!(opt) + @options + end + + def initialize(url, timeout = nil) + @errors = {} + @options = DEFAULT + class_name = self.class.name.split("::").last.to_s + + # Set the engine options extracted from global options. + self.options = Onebox.options[class_name] || {} + + @url = url + @uri = URI(url) + if always_https? + @uri.scheme = 'https' + @url = @uri.to_s + end + @timeout = timeout || Onebox.options.timeout + end + + # raises error if not defined in onebox engine. + # This is the output method for an engine. + def to_html + fail NoMethodError, "Engines need to implement this method" + end + + # Some oneboxes create iframes or other complicated controls. If you're using + # a live editor with HTML preview, rendering those complicated controls can + # be slow or cause flickering. + # + # This method allows engines to produce a placeholder such as static image + # frame of a video. + # + # By default it just calls `to_html` unless implemented. + def placeholder_html + to_html + end + + private + + # raises error if not defined in onebox engine + # in each onebox, uses either Nokogiri or StandardEmbed to get raw HTML from url + def raw + fail NoMethodError, "Engines need to implement this method" + end + + # raises error if not defined in onebox engine + # in each onebox, returns hash of desired onebox content + def data + fail NoMethodError, "Engines need this method defined" + end + + def link + ::Onebox::Helpers.uri_encode(@url) + end + + def always_https? + self.class.always_https? + end + + module ClassMethods + def ===(other) + if other.kind_of?(URI) + !!(other.to_s =~ class_variable_get(:@@matcher)) + else + super + end + end + + def priority + 100 + end + + def matches_regexp(r) + class_variable_set :@@matcher, r + end + + def requires_iframe_origins(*origins) + class_variable_set :@@iframe_origins, origins + end + + def iframe_origins + class_variable_defined?(:@@iframe_origins) ? class_variable_get(:@@iframe_origins) : [] + end + + # calculates a name for onebox using the class name of engine + def onebox_name + name.split("::").last.downcase.gsub(/onebox/, "") + end + + def always_https + @https = true + end + + def always_https? + defined?(@https) ? @https : false + end + end + end +end + +require_relative "helpers" +require_relative "layout_support" +require_relative "file_type_finder" +require_relative "engine/standard_embed" +require_relative "engine/html" +require_relative "engine/json" +require_relative "engine/amazon_onebox" +require_relative "engine/github_issue_onebox" +require_relative "engine/github_blob_onebox" +require_relative "engine/github_commit_onebox" +require_relative "engine/github_folder_onebox" +require_relative "engine/github_gist_onebox" +require_relative "engine/github_pull_request_onebox" +require_relative "engine/google_calendar_onebox" +require_relative "engine/google_docs_onebox" +require_relative "engine/google_maps_onebox" +require_relative "engine/google_play_app_onebox" +require_relative "engine/image_onebox" +require_relative "engine/video_onebox" +require_relative "engine/audio_onebox" +require_relative "engine/stack_exchange_onebox" +require_relative "engine/twitter_status_onebox" +require_relative "engine/wikimedia_onebox" +require_relative "engine/wikipedia_onebox" +require_relative "engine/youtube_onebox" +require_relative "engine/youku_onebox" +require_relative "engine/allowlisted_generic_onebox" +require_relative "engine/pubmed_onebox" +require_relative "engine/sound_cloud_onebox" +require_relative "engine/imgur_onebox" +require_relative "engine/pastebin_onebox" +require_relative "engine/slides_onebox" +require_relative "engine/xkcd_onebox" +require_relative "engine/giphy_onebox" +require_relative "engine/gfycat_onebox" +require_relative "engine/typeform_onebox" +require_relative "engine/vimeo_onebox" +require_relative "engine/steam_store_onebox" +require_relative "engine/sketch_fab_onebox" +require_relative "engine/audioboom_onebox" +require_relative "engine/replit_onebox" +require_relative "engine/asciinema_onebox" +require_relative "engine/mixcloud_onebox" +require_relative "engine/band_camp_onebox" +require_relative "engine/coub_onebox" +require_relative "engine/flickr_onebox" +require_relative "engine/flickr_shortened_onebox" +require_relative "engine/five_hundred_px_onebox" +require_relative "engine/pdf_onebox" +require_relative "engine/twitch_clips_onebox" +require_relative "engine/twitch_stream_onebox" +require_relative "engine/twitch_video_onebox" +require_relative "engine/trello_onebox" +require_relative "engine/cloud_app_onebox" +require_relative "engine/wistia_onebox" +require_relative "engine/simplecast_onebox" +require_relative "engine/instagram_onebox" +require_relative "engine/gitlab_blob_onebox" +require_relative "engine/google_photos_onebox" +require_relative "engine/kaltura_onebox" +require_relative "engine/reddit_media_onebox" +require_relative "engine/google_drive_onebox" +require_relative "engine/facebook_media_onebox" diff --git a/lib/onebox/engine/allowlisted_generic_onebox.rb b/lib/onebox/engine/allowlisted_generic_onebox.rb index d13d91748a9..f4d5d3b7d15 100644 --- a/lib/onebox/engine/allowlisted_generic_onebox.rb +++ b/lib/onebox/engine/allowlisted_generic_onebox.rb @@ -1,21 +1,267 @@ # frozen_string_literal: true +require 'htmlentities' require "ipaddr" module Onebox module Engine class AllowlistedGenericOnebox + include Engine + include StandardEmbed + include LayoutSupport + + def self.priority + 200 + end + + # Often using the `html` attribute is not what we want, like for some blogs that + # include the entire page HTML. However for some providers like Flickr it allows us + # to return gifv and galleries. + def self.default_html_providers + ['Flickr', 'Meetup'] + end + + def self.html_providers + @html_providers ||= default_html_providers.dup + end + + def self.html_providers=(new_provs) + @html_providers = new_provs + end + + # A re-written URL converts http:// -> https:// + def self.rewrites + @rewrites ||= https_hosts.dup + end + + def self.rewrites=(new_list) + @rewrites = new_list + end + + def self.https_hosts + %w(slideshare.net dailymotion.com livestream.com imgur.com flickr.com) + end + + def self.host_matches(uri, list) + !!list.find { |h| %r((^|\.)#{Regexp.escape(h)}$).match(uri.host) } + end + + def self.allowed_twitter_labels + ['brand', 'price', 'usd', 'cad', 'reading time', 'likes'] + end - # overwrite the allowlist def self.===(other) other.is_a?(URI) ? (IPAddr.new(other.hostname) rescue nil).nil? : true end - # ensure we're the last engine to be used - def self.priority - Float::INFINITY + def to_html + rewrite_https(generic_html) end + def placeholder_html + return article_html if is_article? + return image_html if is_image? + return Onebox::Helpers.video_placeholder_html if is_video? || is_card? + return Onebox::Helpers.generic_placeholder_html if is_embedded? + to_html + end + + def data + @data ||= begin + html_entities = HTMLEntities.new + d = { link: link }.merge(raw) + + if !Onebox::Helpers.blank?(d[:title]) + d[:title] = html_entities.decode(Onebox::Helpers.truncate(d[:title], 80)) + end + + d[:description] ||= d[:summary] + if !Onebox::Helpers.blank?(d[:description]) + d[:description] = html_entities.decode(Onebox::Helpers.truncate(d[:description], 250)) + end + + if !Onebox::Helpers.blank?(d[:site_name]) + d[:domain] = html_entities.decode(Onebox::Helpers.truncate(d[:site_name], 80)) + elsif !Onebox::Helpers.blank?(d[:domain]) + d[:domain] = "http://#{d[:domain]}" unless d[:domain] =~ /^https?:\/\// + d[:domain] = URI(d[:domain]).host.to_s.sub(/^www\./, '') rescue nil + end + + # prefer secure URLs + d[:image] = d[:image_secure_url] || d[:image_url] || d[:thumbnail_url] || d[:image] + d[:image] = Onebox::Helpers::get_absolute_image_url(d[:image], @url) + d[:image] = Onebox::Helpers::normalize_url_for_output(html_entities.decode(d[:image])) + d[:image] = nil if Onebox::Helpers.blank?(d[:image]) + + d[:video] = d[:video_secure_url] || d[:video_url] || d[:video] + d[:video] = nil if Onebox::Helpers.blank?(d[:video]) + + d[:published_time] = d[:article_published_time] unless Onebox::Helpers.blank?(d[:article_published_time]) + if !Onebox::Helpers.blank?(d[:published_time]) + d[:article_published_time] = Time.parse(d[:published_time]).strftime("%-d %b %y") + d[:article_published_time_title] = Time.parse(d[:published_time]).strftime("%I:%M%p - %d %B %Y") + end + + # Twitter labels + if !Onebox::Helpers.blank?(d[:label1]) && !Onebox::Helpers.blank?(d[:data1]) && !!AllowlistedGenericOnebox.allowed_twitter_labels.find { |l| d[:label1] =~ /#{l}/i } + d[:label_1] = Onebox::Helpers.truncate(d[:label1]) + d[:data_1] = Onebox::Helpers.truncate(d[:data1]) + end + if !Onebox::Helpers.blank?(d[:label2]) && !Onebox::Helpers.blank?(d[:data2]) && !!AllowlistedGenericOnebox.allowed_twitter_labels.find { |l| d[:label2] =~ /#{l}/i } + unless Onebox::Helpers.blank?(d[:label_1]) + d[:label_2] = Onebox::Helpers.truncate(d[:label2]) + d[:data_2] = Onebox::Helpers.truncate(d[:data2]) + else + d[:label_1] = Onebox::Helpers.truncate(d[:label2]) + d[:data_1] = Onebox::Helpers.truncate(d[:data2]) + end + end + + if Onebox::Helpers.blank?(d[:label_1]) && !Onebox::Helpers.blank?(d[:price_amount]) && !Onebox::Helpers.blank?(d[:price_currency]) + d[:label_1] = "Price" + d[:data_1] = Onebox::Helpers.truncate("#{d[:price_currency].strip} #{d[:price_amount].strip}") + end + + skip_missing_tags = [:video] + d.each do |k, v| + next if skip_missing_tags.include?(k) + if v == nil || v == '' + errors[k] ||= [] + errors[k] << 'is blank' + end + end + + d + end + end + + private + + def rewrite_https(html) + return unless html + if AllowlistedGenericOnebox.host_matches(uri, AllowlistedGenericOnebox.rewrites) + html = html.gsub("http://", "https://") + end + html + end + + def generic_html + return article_html if is_article? + return video_html if is_video? + return image_html if is_image? + return embedded_html if is_embedded? + return card_html if is_card? + return article_html if (has_text? || is_image_article?) + end + + def is_card? + data[:card] == 'player' && + data[:player] =~ URI::regexp && + options[:allowed_iframe_regexes]&.any? { |r| data[:player] =~ r } + end + + def is_article? + (data[:type] =~ /article/ || data[:asset_type] =~ /article/) && + has_text? + end + + def has_text? + has_title? && !Onebox::Helpers.blank?(data[:description]) + end + + def has_title? + !Onebox::Helpers.blank?(data[:title]) + end + + def is_image_article? + has_title? && has_image? + end + + def is_image? + data[:type] =~ /photo|image/ && + data[:type] !~ /photostream/ && + has_image? + end + + def has_image? + !Onebox::Helpers.blank?(data[:image]) + end + + def is_video? + data[:type] =~ /^video[\/\.]/ && + data[:video_type] == "video/mp4" && # Many sites include 'videos' with text/html types (i.e. iframes) + !Onebox::Helpers.blank?(data[:video]) + end + + def is_embedded? + return false unless data[:html] && data[:height] + return true if AllowlistedGenericOnebox.html_providers.include?(data[:provider_name]) + return false unless data[:html]["iframe"] + + fragment = Nokogiri::HTML5::fragment(data[:html]) + src = fragment.at_css('iframe')&.[]("src") + options[:allowed_iframe_regexes]&.any? { |r| src =~ r } + end + + def card_html + escaped_url = ::Onebox::Helpers.normalize_url_for_output(data[:player]) + + <<~RAW + + RAW + end + + def article_html + layout.to_html + end + + def image_html + return if Onebox::Helpers.blank?(data[:image]) + + escaped_src = ::Onebox::Helpers.normalize_url_for_output(data[:image]) + + alt = data[:description] || data[:title] + width = data[:image_width] || data[:thumbnail_width] || data[:width] + height = data[:image_height] || data[:thumbnail_height] || data[:height] + + "#{alt}" + end + + def video_html + escaped_video_src = ::Onebox::Helpers.normalize_url_for_output(data[:video]) + escaped_image_src = ::Onebox::Helpers.normalize_url_for_output(data[:image]) + + <<-HTML + + HTML + end + + def embedded_html + fragment = Nokogiri::HTML5::fragment(data[:html]) + fragment.css("img").each { |img| img["class"] = "thumbnail" } + if iframe = fragment.at_css("iframe") + iframe.remove_attribute("style") + iframe["width"] = data[:width] || "100%" + iframe["height"] = data[:height] + iframe["scrolling"] = "no" + iframe["frameborder"] = "0" + end + fragment.to_html + end end end end diff --git a/lib/onebox/engine/amazon_onebox.rb b/lib/onebox/engine/amazon_onebox.rb new file mode 100644 index 00000000000..4ea26aa9189 --- /dev/null +++ b/lib/onebox/engine/amazon_onebox.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require 'json' +require "onebox/open_graph" + +module Onebox + module Engine + class AmazonOnebox + include Engine + include LayoutSupport + include HTML + + always_https + matches_regexp(/^https?:\/\/(?:www\.)?(?:smile\.)?(amazon|amzn)\.(?com|ca|de|it|es|fr|co\.jp|co\.uk|cn|in|com\.br|com\.mx|nl|pl|sa|sg|se|com\.tr|ae)\//) + + def url + @raw ||= nil + + # If possible, fetch the cached HTML body immediately so we can + # try to grab the canonical URL from that document, + # rather than guess at the best URL structure to use + if !@raw && has_cached_body + @raw = Onebox::Helpers.fetch_html_doc(@url, http_params, body_cacher) + end + + if @raw + canonical_link = @raw.at('//link[@rel="canonical"]/@href') + return canonical_link.to_s if canonical_link + end + + if match && match[:id] + id = Addressable::URI.encode_component(match[:id], Addressable::URI::CharacterClasses::PATH) + return "https://www.amazon.#{tld}/dp/#{id}" + end + + @url + end + + def tld + @tld ||= @@matcher.match(@url)["tld"] + end + + def http_params + if @options && @options[:user_agent] + { 'User-Agent' => @options[:user_agent] } + end + end + + private + + def has_cached_body + body_cacher&.respond_to?('cache_response_body?') && + body_cacher.cache_response_body?(uri.to_s) && + body_cacher.cached_response_body_exists?(uri.to_s) + end + + def match + @match ||= @url.match(/(?:d|g)p\/(?:product\/|video\/detail\/)?(?[A-Z0-9]+)(?:\/|\?|$)/mi) + end + + def image + if (main_image = raw.css("#main-image")) && main_image.any? + attributes = main_image.first.attributes + + if attributes["data-a-hires"] + return attributes["data-a-hires"].to_s + elsif attributes["data-a-dynamic-image"] + return ::JSON.parse(attributes["data-a-dynamic-image"].value).keys.first + end + end + + if (landing_image = raw.css("#landingImage")) && landing_image.any? + attributes = landing_image.first.attributes + + if attributes["data-old-hires"] + return attributes["data-old-hires"].to_s + else + return landing_image.first["src"].to_s + end + end + + if (ebook_image = raw.css("#ebooksImgBlkFront")) && ebook_image.any? + ::JSON.parse(ebook_image.first.attributes["data-a-dynamic-image"].value).keys.first + end + end + + def price + # get item price (Amazon markup is inconsistent, deal with it) + if raw.css("#priceblock_ourprice .restOfPrice")[0] && raw.css("#priceblock_ourprice .restOfPrice")[0].inner_text + "#{raw.css("#priceblock_ourprice .restOfPrice")[0].inner_text}#{raw.css("#priceblock_ourprice .buyingPrice")[0].inner_text}.#{raw.css("#priceblock_ourprice .restOfPrice")[1].inner_text}" + elsif raw.css("#priceblock_dealprice") && (dealprice = raw.css("#priceblock_dealprice span")[0]) + dealprice.inner_text + elsif !raw.css("#priceblock_ourprice").inner_text.empty? + raw.css("#priceblock_ourprice").inner_text + else + raw.css(".mediaMatrixListItem.a-active .a-color-price").inner_text + end + end + + def multiple_authors(authors_xpath) + raw + .xpath(authors_xpath) + .map { |a| a.inner_text.strip } + .join(", ") + end + + def data + og = ::Onebox::OpenGraph.new(raw) + + if raw.at_css('#dp.book_mobile') # printed books + title = raw.at("h1#title")&.inner_text + authors = raw.at_css('#byline_secondary_view_div') ? multiple_authors("//div[@id='byline_secondary_view_div']//span[@class='a-text-bold']") : raw.at("#byline")&.inner_text + rating = raw.at("#averageCustomerReviews_feature_div .a-icon")&.inner_text || raw.at("#cmrsArcLink .a-icon")&.inner_text + + table_xpath = "//div[@id='productDetails_secondary_view_div']//table[@id='productDetails_techSpec_section_1']" + isbn = raw.xpath("#{table_xpath}//tr[8]//td").inner_text.strip + + # if ISBN is misplaced or absent it's hard to find out which data is + # available and where to find it so just set it all to nil + if /^\d(\-?\d){12}$/.match(isbn) + publisher = raw.xpath("#{table_xpath}//tr[1]//td").inner_text.strip + published = raw.xpath("#{table_xpath}//tr[2]//td").inner_text.strip + book_length = raw.xpath("#{table_xpath}//tr[6]//td").inner_text.strip + else + isbn = publisher = published = book_length = nil + end + + result = { + link: url, + title: title, + by_info: authors, + image: og.image || image, + description: raw.at("#productDescription")&.inner_text, + rating: "#{rating}#{', ' if rating && (!isbn&.empty? || !price&.empty?)}", + price: price, + isbn_asin_text: "ISBN", + isbn_asin: isbn, + publisher: publisher, + published: "#{published}#{', ' if published && !price&.empty?}" + } + + elsif raw.at_css('#dp.ebooks_mobile') # ebooks + title = raw.at("#ebooksTitle")&.inner_text + authors = raw.at_css('#a-popover-mobile-udp-contributor-popover-id') ? multiple_authors("//div[@id='a-popover-mobile-udp-contributor-popover-id']//span[contains(@class,'a-text-bold')]") : (raw.at("#byline")&.inner_text&.strip || raw.at("#bylineInfo")&.inner_text&.strip) + rating = raw.at("#averageCustomerReviews_feature_div .a-icon")&.inner_text || raw.at("#cmrsArcLink .a-icon")&.inner_text || raw.at("#acrCustomerReviewLink .a-icon")&.inner_text + + table_xpath = "//div[@id='detailBullets_secondary_view_div']//ul" + asin = raw.xpath("#{table_xpath}//li[4]/span/span[2]").inner_text + + # if ASIN is misplaced or absent it's hard to find out which data is + # available and where to find it so just set it all to nil + if /^[0-9A-Z]{10}$/.match(asin) + publisher = raw.xpath("#{table_xpath}//li[2]/span/span[2]").inner_text + published = raw.xpath("#{table_xpath}//li[1]/span/span[2]").inner_text + else + asin = publisher = published = nil + end + + result = { + link: url, + title: title, + by_info: authors, + image: og.image || image, + description: raw.at("#productDescription")&.inner_text, + rating: "#{rating}#{', ' if rating && (!asin&.empty? || !price&.empty?)}", + price: price, + isbn_asin_text: "ASIN", + isbn_asin: asin, + publisher: publisher, + published: "#{published}#{', ' if published && !price&.empty?}" + } + + else + title = og.title || CGI.unescapeHTML(raw.css("title").inner_text) + result = { + link: url, + title: title, + image: og.image || image, + price: price + } + + result[:by_info] = raw.at("#by-line") + result[:by_info] = Onebox::Helpers.clean(result[:by_info].inner_html) if result[:by_info] + + summary = raw.at("#productDescription") + + description = og.description || summary&.inner_text + description ||= raw.css("meta[name=description]").first&.[]("content") + result[:description] = CGI.unescapeHTML(Onebox::Helpers.truncate(description, 250)) if description + end + + result[:price] = nil if result[:price].start_with?("$0") || result[:price] == 0 + + result + end + end + end +end diff --git a/lib/onebox/engine/asciinema_onebox.rb b/lib/onebox/engine/asciinema_onebox.rb new file mode 100644 index 00000000000..f6c27108f19 --- /dev/null +++ b/lib/onebox/engine/asciinema_onebox.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class AsciinemaOnebox + include Engine + include StandardEmbed + + always_https + matches_regexp(/^https?:\/\/asciinema\.org\/a\/[\p{Alnum}_\-]+$/) + + def to_html + "" + end + + def placeholder_html + "" + end + + private + + def match + @match ||= @url.match(/asciinema\.org\/a\/(?[\p{Alnum}_\-]+)$/) + end + end + end +end diff --git a/lib/onebox/engine/audio_onebox.rb b/lib/onebox/engine/audio_onebox.rb new file mode 100644 index 00000000000..1ac09ce11d0 --- /dev/null +++ b/lib/onebox/engine/audio_onebox.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class AudioOnebox + include Engine + + matches_regexp(/^(https?:)?\/\/.*\.(mp3|ogg|opus|wav|m4a)(\?.*)?$/i) + + def always_https? + AllowlistedGenericOnebox.host_matches(uri, AllowlistedGenericOnebox.https_hosts) + end + + def to_html + escaped_url = ::Onebox::Helpers.normalize_url_for_output(@url) + + <<-HTML + + HTML + end + + def placeholder_html + ::Onebox::Helpers.audio_placeholder_html + end + end + end +end diff --git a/lib/onebox/engine/audioboom_onebox.rb b/lib/onebox/engine/audioboom_onebox.rb new file mode 100644 index 00000000000..89986f46857 --- /dev/null +++ b/lib/onebox/engine/audioboom_onebox.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class AudioboomOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/audioboom\.com\/posts\/\d+/) + always_https + + def placeholder_html + oembed = get_oembed + + <<-HTML + + HTML + end + + def to_html + get_oembed.html + end + end + end +end diff --git a/lib/onebox/engine/band_camp_onebox.rb b/lib/onebox/engine/band_camp_onebox.rb new file mode 100644 index 00000000000..a31e5890322 --- /dev/null +++ b/lib/onebox/engine/band_camp_onebox.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class BandCampOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/.*\.bandcamp\.com\/(album|track)\//) + always_https + requires_iframe_origins "https://bandcamp.com" + + def placeholder_html + og = get_opengraph + "" + end + + def to_html + og = get_opengraph + escaped_src = og.video_secure_url || og.video + + <<-HTML + + HTML + end + end + end +end diff --git a/lib/onebox/engine/cloud_app_onebox.rb b/lib/onebox/engine/cloud_app_onebox.rb new file mode 100644 index 00000000000..f1b985edf7c --- /dev/null +++ b/lib/onebox/engine/cloud_app_onebox.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class CloudAppOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/cl\.ly/) + always_https + + def to_html + og = get_opengraph + + if !og.image.nil? + image_html(og) + elsif og.title.to_s[/\.(mp4|ogv|webm)$/] + video_html(og) + else + link_html(og) + end + end + + private + + def link_html(og) + <<-HTML + + #{og.title} + + HTML + end + + def video_html(og) + direct_src = ::Onebox::Helpers.normalize_url_for_output("#{og.get(:url)}/#{og.title}") + + <<-HTML + + HTML + end + + def image_html(og) + <<-HTML + + CloudApp + + HTML + end + end + end +end diff --git a/lib/onebox/engine/coub_onebox.rb b/lib/onebox/engine/coub_onebox.rb new file mode 100644 index 00000000000..7e57e454296 --- /dev/null +++ b/lib/onebox/engine/coub_onebox.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class CoubOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/coub\.com\/view\//) + always_https + + def placeholder_html + oembed = get_oembed + "" + end + + def to_html + get_oembed.html + end + end + end +end diff --git a/lib/onebox/engine/facebook_media_onebox.rb b/lib/onebox/engine/facebook_media_onebox.rb new file mode 100644 index 00000000000..903eccb131a --- /dev/null +++ b/lib/onebox/engine/facebook_media_onebox.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class FacebookMediaOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/.*\.facebook\.com\/(\w+)\/(videos|\?).*/) + always_https + requires_iframe_origins "https://www.facebook.com" + + def to_html + metadata = get_twitter + if metadata.present? && metadata[:card] == "player" && metadata[:player].present? + <<-HTML + + HTML + else + html = Onebox::Engine::AllowlistedGenericOnebox.new(@url, @timeout).to_html + return if Onebox::Helpers.blank?(html) + html + end + end + end + end +end diff --git a/lib/onebox/engine/five_hundred_px_onebox.rb b/lib/onebox/engine/five_hundred_px_onebox.rb new file mode 100644 index 00000000000..806b5f9e6af --- /dev/null +++ b/lib/onebox/engine/five_hundred_px_onebox.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class FiveHundredPxOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/500px\.com\/photo\/\d+\//) + always_https + + def to_html + og = get_opengraph + "" + end + end + end +end diff --git a/lib/onebox/engine/flickr_onebox.rb b/lib/onebox/engine/flickr_onebox.rb new file mode 100644 index 00000000000..3ed26684a76 --- /dev/null +++ b/lib/onebox/engine/flickr_onebox.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative './opengraph_image' + +module Onebox + module Engine + class FlickrOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/www\.flickr\.com\/photos\//) + always_https + + def to_html + og = get_opengraph + return album_html(og) if og.url =~ /\/sets\// + return image_html(og) if !og.image.nil? + nil + end + + private + + def album_html(og) + escaped_url = ::Onebox::Helpers.normalize_url_for_output(url) + album_title = "[Album] #{og.title}" + + <<-HTML + + HTML + end + + def image_html(og) + escaped_url = ::Onebox::Helpers.normalize_url_for_output(url) + + <<-HTML + + Imgur + + HTML + end + end + end +end diff --git a/lib/onebox/engine/flickr_shortened_onebox.rb b/lib/onebox/engine/flickr_shortened_onebox.rb new file mode 100644 index 00000000000..1c1243050bc --- /dev/null +++ b/lib/onebox/engine/flickr_shortened_onebox.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative './opengraph_image' + +module Onebox + module Engine + class FlickrShortenedOnebox + include Engine + include StandardEmbed + include OpengraphImage + + matches_regexp(/^https?:\/\/flic\.kr\/p\//) + always_https + end + end +end diff --git a/lib/onebox/engine/gfycat_onebox.rb b/lib/onebox/engine/gfycat_onebox.rb new file mode 100644 index 00000000000..27fd4bf79e0 --- /dev/null +++ b/lib/onebox/engine/gfycat_onebox.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class GfycatOnebox + include Engine + include JSON + + matches_regexp(/^https?:\/\/gfycat\.com\//) + always_https + + # This engine should have priority over AllowlistedGenericOnebox. + def self.priority + 1 + end + + def to_html + <<-HTML + + HTML + end + + def placeholder_html + <<-HTML + +
+ #{data[:name]} +
+ HTML + end + + private + + def match + @match ||= @url.match(/^https?:\/\/gfycat\.com\/(gifs\/detail\/)?(?.+)/) + end + + def og_data + return @og_data if defined?(@og_data) + + response = Onebox::Helpers.fetch_response(url, redirect_limit: 10) rescue nil + page = Nokogiri::HTML(response) + script = page.at_css('script[type="application/ld+json"]') + + if json_string = script&.text + @og_data = Onebox::Helpers.symbolize_keys(::MultiJson.load(json_string)) + else + @og_data = {} + end + end + + def data + return @data if defined?(@data) + + @data = { + name: match[:name], + title: og_data[:headline] || 'No Title', + author: og_data[:author], + url: @url, + } + + if keywords = og_data[:keywords]&.split(',') + @data[:keywords] = keywords + .map { |keyword| "##{keyword}" } + .join(' ') + end + + if og_data[:video] + content_url = ::Onebox::Helpers.normalize_url_for_output(og_data[:video][:contentUrl]) + video_url = Pathname.new(content_url) + @data[:webmUrl] = video_url.sub_ext(".webm").to_s + @data[:mp4Url] = video_url.sub_ext(".mp4").to_s + + thumbnail_url = ::Onebox::Helpers.normalize_url_for_output(og_data[:video][:thumbnailUrl]) + @data[:posterUrl] = thumbnail_url + + @data[:width] = og_data[:video][:width] + @data[:height] = og_data[:video][:height] + end + + @data + end + end + end +end diff --git a/lib/onebox/engine/giphy_onebox.rb b/lib/onebox/engine/giphy_onebox.rb new file mode 100644 index 00000000000..569f423451d --- /dev/null +++ b/lib/onebox/engine/giphy_onebox.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class GiphyOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/(giphy\.com\/gifs|gph\.is)\//) + always_https + + def to_html + oembed = get_oembed + + <<-HTML + + + + HTML + end + end + end +end diff --git a/lib/onebox/engine/github_blob_onebox.rb b/lib/onebox/engine/github_blob_onebox.rb new file mode 100644 index 00000000000..fd70a4b2af1 --- /dev/null +++ b/lib/onebox/engine/github_blob_onebox.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative '../mixins/git_blob_onebox' + +module Onebox + module Engine + class GithubBlobOnebox + def self.git_regexp + /^https?:\/\/(www\.)?github\.com.*\/blob\// + end + + def self.onebox_name + "githubblob" + end + + include Onebox::Mixins::GitBlobOnebox + + def raw_regexp + /github\.com\/(?[^\/]+)\/(?[^\/]+)\/blob\/(?[^\/]+)\/(?[^#]+)(#(L(?[^-]*)(-L(?.*))?))?/mi + end + + def raw_template(m) + "https://raw.githubusercontent.com/#{m[:user]}/#{m[:repo]}/#{m[:sha1]}/#{m[:file]}" + end + + def title + Sanitize.fragment(Onebox::Helpers.uri_unencode(link).sub(/^https?\:\/\/github\.com\//, '')) + end + end + end +end diff --git a/lib/onebox/engine/github_commit_onebox.rb b/lib/onebox/engine/github_commit_onebox.rb new file mode 100644 index 00000000000..d584eb165ec --- /dev/null +++ b/lib/onebox/engine/github_commit_onebox.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative '../mixins/github_body' + +module Onebox + module Engine + class GithubCommitOnebox + include Engine + include LayoutSupport + include JSON + include Onebox::Mixins::GithubBody + + matches_regexp(/^https?:\/\/(?:www\.)?(?:(?:\w)+\.)?(github)\.com(?:\/)?(?:.)*\/commit\//) + always_https + + def url + "https://api.github.com/repos/#{match[:owner]}/#{match[:repository]}/commits/#{match[:sha]}" + end + + private + + def match + return @match if defined?(@match) + + @match = @url.match(%{github\.com/(?[^/]+)/(?[^/]+)/commit/(?[^/]+)}) + @match ||= @url.match(%{github\.com/(?[^/]+)/(?[^/]+)/pull/(?[^/]+)/commit/(?[^/]+)}) + + @match + end + + def data + result = raw.clone + + lines = result['commit']['message'].split("\n") + result['title'] = lines.first + result['body'], result['excerpt'] = compute_body(lines[1..lines.length].join("\n")) + + committed_at = Time.parse(result['commit']['author']['date']) + result['committed_at'] = committed_at.strftime("%I:%M%p - %d %b %y %Z") + result['committed_at_date'] = committed_at.strftime("%F") + result['committed_at_time'] = committed_at.strftime("%T") + + result['link'] = link + ulink = URI(link) + result['domain'] = "#{ulink.host}/#{ulink.path.split('/')[1]}/#{ulink.path.split('/')[2]}" + + result + end + end + end +end diff --git a/lib/onebox/engine/github_folder_onebox.rb b/lib/onebox/engine/github_folder_onebox.rb new file mode 100644 index 00000000000..a0c565f81b6 --- /dev/null +++ b/lib/onebox/engine/github_folder_onebox.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class GithubFolderOnebox + include Engine + include StandardEmbed + include LayoutSupport + + matches_regexp(/^https?:\/\/(?:www\.)?(?:(?:\w)+\.)?(github)\.com[\:\d]*(\/[^\/]+){2}/) + always_https + + def self.priority + # This engine should have lower priority than the other Github engines + 150 + end + + private + + def data + og = get_opengraph + + max_length = 250 + + display_path = extract_path(og.url, max_length) + display_description = clean_description(og.description, og.title, max_length) + + title = og.title + + fragment = Addressable::URI.parse(url).fragment + if fragment + fragment = Addressable::URI.unencode(fragment) + + if html_doc.css('.Box.md') + # For links to markdown docs + node = html_doc.css('a.anchor').find { |n| n['href'] == "##{fragment}" } + subtitle = node&.parent&.text + elsif html_doc.css('.Box.rdoc') + # For links to rdoc docs + node = html_doc.css('h3').find { |n| n['id'] == "user-content-#{fragment.downcase}" } + subtitle = node&.css('text()')&.first&.text + end + + title = "#{title} - #{subtitle}" if subtitle + end + + { + link: url, + image: og.image, + title: Onebox::Helpers.truncate(title, 250), + path: display_path, + description: display_description, + favicon: get_favicon + } + end + + def extract_path(root, max_length) + path = url.split('#')[0].split('?')[0] + path = path["#{root}/tree/".length..-1] + + return unless path + + path.length > max_length ? path[-max_length..-1] : path + end + + def clean_description(description, title, max_length) + return unless description + + desc_end = " - #{title}" + if description[-desc_end.length..-1] == desc_end + description = description[0...-desc_end.length] + end + + Onebox::Helpers.truncate(description, max_length) + end + end + end +end diff --git a/lib/onebox/engine/github_gist_onebox.rb b/lib/onebox/engine/github_gist_onebox.rb new file mode 100644 index 00000000000..21561d85ffb --- /dev/null +++ b/lib/onebox/engine/github_gist_onebox.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class GithubGistOnebox + include Engine + include LayoutSupport + include JSON + + MAX_FILES = 3 + + matches_regexp(/^http(?:s)?:\/\/gist\.(?:(?:\w)+\.)?(github)\.com(?:\/)?/) + always_https + + def url + "https://api.github.com/gists/#{match[:sha]}" + end + + private + + def data + @data ||= { + title: 'gist.github.com', + link: link, + gist_files: gist_files.take(MAX_FILES), + truncated_files?: truncated_files? + } + end + + def truncated_files? + gist_files.size > MAX_FILES + end + + def gist_files + return [] unless gist_api + + @gist_files ||= gist_api["files"].values.map do |file_json| + GistFile.new(file_json) + end + end + + def gist_api + @raw ||= raw.clone + rescue OpenURI::HTTPError + # The Gist API rate limit of 60 requests per hour was reached. + nil + end + + def match + @match ||= @url.match(%r{gist\.github\.com/([^/]+/)?(?[0-9a-f]+)}) + end + + class GistFile + attr_reader :filename + attr_reader :language + + MAX_LINES = 10 + + def initialize(json) + @json = json + @filename = @json["filename"] + @language = @json["language"] + end + + def content + lines.take(MAX_LINES).join("\n") + end + + def truncated? + lines.size > MAX_LINES + end + + private + + def lines + @lines ||= @json["content"].split("\n") + end + end + end + end +end diff --git a/lib/onebox/engine/github_issue_onebox.rb b/lib/onebox/engine/github_issue_onebox.rb new file mode 100644 index 00000000000..0bb26b70ac2 --- /dev/null +++ b/lib/onebox/engine/github_issue_onebox.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative '../mixins/github_body' + +module Onebox + module Engine + class GithubIssueOnebox + #Author Lidlanca 2014 + include Engine + include LayoutSupport + include JSON + include Onebox::Mixins::GithubBody + + matches_regexp(/^https?:\/\/(?:www\.)?(?:(?:\w)+\.)?github\.com\/(?.+)\/(?.+)\/issues\/([[:digit:]]+)/) + always_https + + def url + m = match + "https://api.github.com/repos/#{m["org"]}/#{m["repo"]}/issues/#{m["item_id"]}" + end + + private + + def match + @match ||= @url.match(/^http(?:s)?:\/\/(?:www\.)?(?:(?:\w)+\.)?github\.com\/(?.+)\/(?.+)\/(?issues)\/(?[\d]+)/) + end + + def data + created_at = Time.parse(raw['created_at']) + closed_at = Time.parse(raw['closed_at']) if raw['closed_at'] + body, excerpt = compute_body(raw['body']) + ulink = URI(link) + + { + link: @url, + title: raw["title"], + body: body, + excerpt: excerpt, + labels: raw["labels"], + user: raw['user'], + created_at: created_at.strftime("%I:%M%p - %d %b %y %Z"), + created_at_date: created_at.strftime("%F"), + created_at_time: created_at.strftime("%T"), + closed_at: closed_at&.strftime("%I:%M%p - %d %b %y %Z"), + closed_at_date: closed_at&.strftime("%F"), + closed_at_time: closed_at&.strftime("%T"), + closed_by: raw['closed_by'], + avatar: "https://avatars1.githubusercontent.com/u/#{raw['user']['id']}?v=2&s=96", + domain: "#{ulink.host}/#{ulink.path.split('/')[1]}/#{ulink.path.split('/')[2]}", + } + end + end + end +end diff --git a/lib/onebox/engine/github_pull_request_onebox.rb b/lib/onebox/engine/github_pull_request_onebox.rb new file mode 100644 index 00000000000..090fddea66a --- /dev/null +++ b/lib/onebox/engine/github_pull_request_onebox.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative '../mixins/github_body' + +module Onebox + module Engine + class GithubPullRequestOnebox + include Engine + include LayoutSupport + include JSON + include Onebox::Mixins::GithubBody + + GITHUB_COMMENT_REGEX = /(\r\n)/ + + matches_regexp(/^https?:\/\/(?:www\.)?(?:(?:\w)+\.)?(github)\.com(?:\/)?(?:.)*\/pull/) + always_https + + def url + "https://api.github.com/repos/#{match[:owner]}/#{match[:repository]}/pulls/#{match[:number]}" + end + + private + + def match + @match ||= @url.match(%r{github\.com/(?[^/]+)/(?[^/]+)/pull/(?[^/]+)}) + end + + def data + result = raw.clone + result['link'] = link + + created_at = Time.parse(result['created_at']) + result['created_at'] = created_at.strftime("%I:%M%p - %d %b %y %Z") + result['created_at_date'] = created_at.strftime("%F") + result['created_at_time'] = created_at.strftime("%T") + + ulink = URI(link) + result['domain'] = "#{ulink.host}/#{ulink.path.split('/')[1]}/#{ulink.path.split('/')[2]}" + + result['body'], result['excerpt'] = compute_body(result['body']) + + result + end + end + end +end diff --git a/lib/onebox/engine/gitlab_blob_onebox.rb b/lib/onebox/engine/gitlab_blob_onebox.rb new file mode 100644 index 00000000000..d8ba1973381 --- /dev/null +++ b/lib/onebox/engine/gitlab_blob_onebox.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative '../mixins/git_blob_onebox' + +module Onebox + module Engine + class GitlabBlobOnebox + def self.git_regexp + /^https?:\/\/(www\.)?gitlab\.com.*\/blob\// + end + + def self.onebox_name + "gitlabblob" + end + + include Onebox::Mixins::GitBlobOnebox + + def raw_regexp + /gitlab\.com\/(?[^\/]+)\/(?[^\/]+)\/blob\/(?[^\/]+)\/(?[^#]+)(#(L(?[^-]*)(-L(?.*))?))?/mi + end + + def raw_template(m) + "https://gitlab.com/#{m[:user]}/#{m[:repo]}/raw/#{m[:sha1]}/#{m[:file]}" + end + + def title + Sanitize.fragment(Onebox::Helpers.uri_unencode(link).sub(/^https?\:\/\/gitlab\.com\//, '')) + end + end + end +end diff --git a/lib/onebox/engine/google_calendar_onebox.rb b/lib/onebox/engine/google_calendar_onebox.rb new file mode 100644 index 00000000000..b666df4d080 --- /dev/null +++ b/lib/onebox/engine/google_calendar_onebox.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class GoogleCalendarOnebox + include Engine + + matches_regexp(/^(https?:)?\/\/((www|calendar)\.google\.[\w.]{2,}|goo\.gl)\/calendar\/.+$/) + always_https + requires_iframe_origins "https://calendar.google.com" + + def to_html + url = @url.split('&').first + src = ::Onebox::Helpers.normalize_url_for_output(url) + "" + end + + def placeholder_html + <<-HTML +
+
+
+ +

Google Calendar

+
+
+
+ HTML + end + end + end +end diff --git a/lib/onebox/engine/google_docs_onebox.rb b/lib/onebox/engine/google_docs_onebox.rb new file mode 100644 index 00000000000..cc7872aebe4 --- /dev/null +++ b/lib/onebox/engine/google_docs_onebox.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class GoogleDocsOnebox + include Engine + include StandardEmbed + include LayoutSupport + + SUPPORTED_ENDPOINTS = %w(spreadsheets document forms presentation) + SHORT_TYPES = { + spreadsheets: :sheets, + document: :docs, + presentation: :slides, + forms: :forms, + } + + matches_regexp(/^(https?:)?\/\/(docs\.google\.com)\/(?(#{SUPPORTED_ENDPOINTS.join('|')}))\/d\/((?[\w-]*)).+$/) + always_https + + private + + def data + og_data = get_opengraph + short_type = SHORT_TYPES[match[:endpoint].to_sym] + + description = if Onebox::Helpers.blank?(og_data.description) + "This #{short_type.to_s.chop.capitalize} is private" + else + Onebox::Helpers.truncate(og_data.description, 250) + end + + { + link: link, + title: og_data.title || "Google #{short_type.to_s.capitalize}", + description: description, + type: short_type + } + end + + def match + @match ||= @url.match(@@matcher) + end + end + end +end diff --git a/lib/onebox/engine/google_drive_onebox.rb b/lib/onebox/engine/google_drive_onebox.rb new file mode 100644 index 00000000000..82628228ea9 --- /dev/null +++ b/lib/onebox/engine/google_drive_onebox.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class GoogleDriveOnebox + include Engine + include StandardEmbed + include LayoutSupport + + matches_regexp(/^(https?:)?\/\/(drive\.google\.com)\/file\/d\/(?[\w-]*)\/.+$/) + always_https + + protected + + def data + og_data = get_opengraph + title = og_data.title || "Google Drive" + title = "#{og_data.title} (video)" if og_data.type =~ /^video[\/\.]/ + description = og_data.description || "Google Drive file." + + { + link: link, + title: title, + description: Onebox::Helpers.truncate(description, 250), + image: og_data.image + } + end + end + end +end diff --git a/lib/onebox/engine/google_maps_onebox.rb b/lib/onebox/engine/google_maps_onebox.rb new file mode 100644 index 00000000000..bbf8e21c1be --- /dev/null +++ b/lib/onebox/engine/google_maps_onebox.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class GoogleMapsOnebox + include Engine + + class << self + def ===(other) + if other.kind_of? URI + @@matchers && @@matchers.any? { |m| other.to_s =~ m[:regexp] } + else + super + end + end + + private + + def matches_regexp(key, regexp) + (@@matchers ||= []) << { key: key, regexp: regexp } + end + end + + always_https + requires_iframe_origins("https://maps.google.com", "https://google.com") + + # Matches shortened Google Maps URLs + matches_regexp :short, %r"^(https?:)?//goo\.gl/maps/" + + # Matches URLs for custom-created maps + matches_regexp :custom, %r"^(?:https?:)?//www\.google(?:\.(?:\w{2,}))+/maps/d/(?:edit|viewer|embed)\?mid=.+$" + + # Matches URLs with streetview data + matches_regexp :streetview, %r"^(?:https?:)?//www\.google(?:\.(?:\w{2,}))+/maps[^@]+@(?-?[\d.]+),(?-?[\d.]+),(?:\d+)a,(?[\d.]+)y,(?[\d.]+)h,(?[\d.]+)t.+?data=.*?!1s(?[^!]{22})" + + # Matches "normal" Google Maps URLs with arbitrary data + matches_regexp :standard, %r"^(?:https?:)?//www\.google(?:\.(?:\w{2,}))+/maps" + + # Matches URLs for the old Google Maps domain which we occasionally get redirected to + matches_regexp :canonical, %r"^(?:https?:)?//maps\.google(?:\.(?:\w{2,}))+/maps\?" + + def initialize(url, timeout = nil) + super + resolve_url! + rescue Net::HTTPServerException, Timeout::Error, Net::HTTPError, Errno::ECONNREFUSED, RuntimeError => err + raise ArgumentError, "malformed url or unresolveable: #{err.message}" + end + + def streetview? + !!@streetview + end + + def to_html + "
" + end + + def placeholder_html + ::Onebox::Helpers.map_placeholder_html + end + + private + + def data + { link: url, title: url } + end + + def resolve_url! + @streetview = false + type, match = match_url + + # Resolve shortened URL, if necessary + if type == :short + follow_redirect! + type, match = match_url + end + + # Try to get the old-maps URI, it is far easier to embed. + if type == :standard + retry_count = 10 + while (retry_count -= 1) > 0 + follow_redirect! + type, match = match_url + break if type != :standard + sleep 0.1 + end + end + + case type + when :standard + # Fallback for map URLs that don't resolve into an easily embeddable old-style URI + # Roadmaps use a "z" zoomlevel, satellite maps use "m" the horizontal width in meters + # TODO: tilted satellite maps using "a,y,t" + match = @url.match(/@(?[\d.-]+),(?[\d.-]+),(?\d+)(?[mz])/) + raise "unexpected standard url #{@url}" unless match + zoom = match[:mz] == "z" ? match[:zoom] : Math.log2(57280048.0 / match[:zoom].to_f).round + location = "#{match[:lon]},#{match[:lat]}" + url = "https://maps.google.com/maps?ll=#{location}&z=#{zoom}&output=embed&dg=ntvb" + url += "&q=#{$1}" if match = @url.match(/\/place\/([^\/\?]+)/) + url += "&cid=#{($1 + $2).to_i(16)}" if @url.match(/!3m1!1s0x(\h{16}):0x(\h{16})/) + @url = url + @placeholder = "https://maps.googleapis.com/maps/api/staticmap?maptype=roadmap¢er=#{location}&zoom=#{zoom}&size=690x400&sensor=false" + + when :custom + url = @url.dup + @url = rewrite_custom_url(url, "embed") + @placeholder = rewrite_custom_url(url, "thumbnail") + @placeholder_height = @placeholder_width = 120 + + when :streetview + @streetview = true + panoid = match[:pano] + lon = match[:lon].to_f.to_s + lat = match[:lat].to_f.to_s + heading = match[:heading].to_f.round(4).to_s + pitch = (match[:pitch].to_f / 10.0).round(4).to_s + fov = (match[:zoom].to_f / 100.0).round(4).to_s + zoom = match[:zoom].to_f.round + @url = "https://www.google.com/maps/embed?pb=!3m2!2sen!4v0!6m8!1m7!1s#{panoid}!2m2!1d#{lon}!2d#{lat}!3f#{heading}!4f#{pitch}!5f#{fov}" + @placeholder = "https://maps.googleapis.com/maps/api/streetview?size=690x400&location=#{lon},#{lat}&pano=#{panoid}&fov=#{zoom}&heading=#{heading}&pitch=#{pitch}&sensor=false" + + when :canonical + query = URI::decode_www_form(uri.query).to_h + if !query.has_key?("ll") + raise ArgumentError, "canonical url lacks location argument" unless query.has_key?("sll") + query["ll"] = query["sll"] + @url += "&ll=#{query["sll"]}" + end + location = query["ll"] + if !query.has_key?("z") + raise ArgumentError, "canonical url has incomplete query arguments" unless query.has_key?("spn") || query.has_key?("sspn") + if !query.has_key?("spn") + query["spn"] = query["sspn"] + @url += "&spn=#{query["sspn"]}" + end + angle = query["spn"].split(",").first.to_f + zoom = (Math.log(690.0 * 360.0 / angle / 256.0) / Math.log(2)).round + else + zoom = query["z"] + end + @url = @url.sub('output=classic', 'output=embed') + @placeholder = "https://maps.googleapis.com/maps/api/staticmap?maptype=roadmap&size=690x400&sensor=false¢er=#{location}&zoom=#{zoom}" + + else + raise "unexpected url type #{type.inspect}" + end + end + + def match_url + @@matchers.each do |matcher| + if m = matcher[:regexp].match(@url) + return matcher[:key], m + end + end + raise ArgumentError, "\"#{@url}\" does not match any known pattern" + end + + def rewrite_custom_url(url, target) + uri = URI(url) + uri.path = uri.path.sub(/(?<=^\/maps\/d\/)\w+$/, target) + uri.to_s + end + + def follow_redirect! + begin + http = Net::HTTP.start( + uri.host, + uri.port, + use_ssl: uri.scheme == 'https', + open_timeout: timeout, + read_timeout: timeout + ) + + response = http.head(uri.path) + raise "unexpected response code #{response.code}" unless %w(200 301 302).include?(response.code) + + @url = response.code == "200" ? uri.to_s : response["Location"] + @uri = URI(@url) + ensure + http.finish rescue nil + end + end + end + end +end diff --git a/lib/onebox/engine/google_photos_onebox.rb b/lib/onebox/engine/google_photos_onebox.rb new file mode 100644 index 00000000000..3a07b7df699 --- /dev/null +++ b/lib/onebox/engine/google_photos_onebox.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class GooglePhotosOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/(photos)\.(app\.goo\.gl|google\.com)/) + always_https + + def to_html + og = get_opengraph + return video_html(og) if og.video_secure_url + return album_html(og) if og.type == "google_photos:photo_album" + return image_html(og) if og.image + nil + end + + private + + def video_html(og) + escaped_url = ::Onebox::Helpers.normalize_url_for_output(url) + + <<-HTML + + HTML + end + + def album_html(og) + escaped_url = ::Onebox::Helpers.normalize_url_for_output(url) + album_title = og.description.nil? ? og.title : "[#{og.description}] #{og.title}" + + <<-HTML + + HTML + end + + def image_html(og) + escaped_url = ::Onebox::Helpers.normalize_url_for_output(url) + + <<-HTML + + Google Photos + + HTML + end + end + end +end diff --git a/lib/onebox/engine/google_play_app_onebox.rb b/lib/onebox/engine/google_play_app_onebox.rb new file mode 100644 index 00000000000..1e2557d4422 --- /dev/null +++ b/lib/onebox/engine/google_play_app_onebox.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class GooglePlayAppOnebox + include Engine + include LayoutSupport + include HTML + + DEFAULTS = { + MAX_DESCRIPTION_CHARS: 500 + } + + matches_regexp(/^https?:\/\/play\.(?:(?:\w)+\.)?(google)\.com(?:\/)?\/store\/apps\//) + always_https + + private + + def data + price = raw.css("meta[itemprop=price]").first["content"] rescue "Free" + { + link: link, + title: raw.css("meta[property='og:title']").first["content"].gsub(" - Apps on Google Play", ""), + image: ::Onebox::Helpers.normalize_url_for_output(raw.css("meta[property='og:image']").first["content"]), + description: raw.css("meta[name=description]").first["content"][0..DEFAULTS[:MAX_DESCRIPTION_CHARS]].chop + "...", + price: price == "0" ? "Free" : price + } + end + end + end +end diff --git a/lib/onebox/engine/html.rb b/lib/onebox/engine/html.rb new file mode 100644 index 00000000000..b0dfba21f17 --- /dev/null +++ b/lib/onebox/engine/html.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Onebox + module Engine + module HTML + private + + # Overwrite for any custom headers + def http_params + {} + end + + def raw + @raw ||= Onebox::Helpers.fetch_html_doc(url, http_params, body_cacher) + end + + def body_cacher + self.options&.[](:body_cacher) + end + + def html? + raw.respond_to(:css) + end + end + end +end diff --git a/lib/onebox/engine/image_onebox.rb b/lib/onebox/engine/image_onebox.rb new file mode 100644 index 00000000000..91d64f69da3 --- /dev/null +++ b/lib/onebox/engine/image_onebox.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class ImageOnebox + include Engine + + matches_regexp(/^(https?:)?\/\/.+\.(png|jpg|jpeg|gif|bmp|tif|tiff)(\?.*)?$/i) + + def always_https? + AllowlistedGenericOnebox.host_matches(uri, AllowlistedGenericOnebox.https_hosts) + end + + def to_html + # Fix Dropbox image links + if @url[/^https:\/\/www.dropbox.com\/s\//] + @url.sub!("https://www.dropbox.com", "https://dl.dropboxusercontent.com") + end + + escaped_url = ::Onebox::Helpers.normalize_url_for_output(@url) + <<-HTML + + + + HTML + end + end + end +end diff --git a/lib/onebox/engine/imgur_onebox.rb b/lib/onebox/engine/imgur_onebox.rb new file mode 100644 index 00000000000..26a90379dd1 --- /dev/null +++ b/lib/onebox/engine/imgur_onebox.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class ImgurOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/(www\.)?imgur\.com/) + always_https + + def to_html + og = get_opengraph + return video_html(og) if !og.video_secure_url.nil? + return album_html(og) if is_album? + return image_html(og) if !og.image.nil? + nil + end + + private + + def video_html(og) + <<-HTML + + HTML + end + + def album_html(og) + escaped_url = ::Onebox::Helpers.normalize_url_for_output(url) + album_title = "[Album] #{og.title}" + + <<-HTML + + HTML + end + + def is_album? + response = Onebox::Helpers.fetch_response("https://api.imgur.com/oembed.json?url=#{url}") rescue "{}" + oembed_data = Onebox::Helpers.symbolize_keys(::MultiJson.load(response)) + imgur_data_id = Nokogiri::HTML(oembed_data[:html]).xpath("//blockquote").attr("data-id") + imgur_data_id.to_s[/a\//] + end + + def image_html(og) + escaped_url = ::Onebox::Helpers.normalize_url_for_output(url) + + <<-HTML + + Imgur + + HTML + end + end + end +end diff --git a/lib/onebox/engine/instagram_onebox.rb b/lib/onebox/engine/instagram_onebox.rb new file mode 100644 index 00000000000..21a8ae6c6ff --- /dev/null +++ b/lib/onebox/engine/instagram_onebox.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class InstagramOnebox + include Engine + include StandardEmbed + include LayoutSupport + + matches_regexp(/^https?:\/\/(?:www\.)?(?:instagram\.com|instagr\.am)\/?(?:.*)\/(?:p|tv)\/[a-zA-Z\d_-]+/) + always_https + + def clean_url + url.scan(/^https?:\/\/(?:www\.)?(?:instagram\.com|instagr\.am)\/?(?:.*)\/(?:p|tv)\/[a-zA-Z\d_-]+/).flatten.first + end + + def data + oembed = get_oembed + raise "No oEmbed data found. Ensure 'facebook_app_access_token' is valid" if oembed.data.empty? + + { + link: clean_url.gsub("/#{oembed.author_name}/", "/"), + title: "@#{oembed.author_name}", + image: oembed.thumbnail_url, + description: Onebox::Helpers.truncate(oembed.title, 250), + } + + end + + protected + + def access_token + (options[:facebook_app_access_token] || Onebox.options.facebook_app_access_token).to_s + end + + def get_oembed_url + if access_token != '' + "https://graph.facebook.com/v9.0/instagram_oembed?url=#{clean_url}&access_token=#{access_token}" + else + # The following is officially deprecated by Instagram, but works in some limited circumstances. + "https://api.instagram.com/oembed/?url=#{clean_url}" + end + end + end + end +end diff --git a/lib/onebox/engine/json.rb b/lib/onebox/engine/json.rb new file mode 100644 index 00000000000..261dc0309cf --- /dev/null +++ b/lib/onebox/engine/json.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Onebox + module Engine + module JSON + private + + def raw + @raw ||= ::MultiJson.load(URI.open(url, read_timeout: timeout)) + end + end + end +end diff --git a/lib/onebox/engine/kaltura_onebox.rb b/lib/onebox/engine/kaltura_onebox.rb new file mode 100644 index 00000000000..d94091a4f0a --- /dev/null +++ b/lib/onebox/engine/kaltura_onebox.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class KalturaOnebox + include Engine + include StandardEmbed + + always_https + matches_regexp(/^https?:\/\/[a-z0-9]+\.kaltura\.com\/id\/[a-zA-Z0-9]+/) + requires_iframe_origins "https://*.kaltura.com" + + def preview_html + og = get_opengraph + + <<~HTML + + HTML + end + + def to_html + og = get_opengraph + + <<~HTML + + HTML + end + end + end +end diff --git a/lib/onebox/engine/mixcloud_onebox.rb b/lib/onebox/engine/mixcloud_onebox.rb new file mode 100644 index 00000000000..a225ccf4bb7 --- /dev/null +++ b/lib/onebox/engine/mixcloud_onebox.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class MixcloudOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/www\.mixcloud\.com\//) + always_https + + def placeholder_html + oembed = get_oembed + "" + end + + def to_html + get_oembed.html + end + end + end +end diff --git a/lib/onebox/engine/opengraph_image.rb b/lib/onebox/engine/opengraph_image.rb new file mode 100644 index 00000000000..a104db36ffb --- /dev/null +++ b/lib/onebox/engine/opengraph_image.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Onebox + module Engine + module OpengraphImage + + def to_html + og = get_opengraph + "" + end + end + end +end diff --git a/lib/onebox/engine/pastebin_onebox.rb b/lib/onebox/engine/pastebin_onebox.rb new file mode 100644 index 00000000000..d9b26467f3c --- /dev/null +++ b/lib/onebox/engine/pastebin_onebox.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class PastebinOnebox + include Engine + include LayoutSupport + + MAX_LINES = 10 + + matches_regexp(/^http?:\/\/pastebin\.com/) + + private + + def data + @data ||= { + title: 'pastebin.com', + link: link, + content: content, + truncated?: truncated? + } + end + + def content + lines.take(MAX_LINES).join("\n") + end + + def truncated? + lines.size > MAX_LINES + end + + def lines + return @lines if defined?(@lines) + response = Onebox::Helpers.fetch_response("http://pastebin.com/raw/#{paste_key}", redirect_limit: 1) rescue "" + @lines = response.split("\n") + end + + def paste_key + regex = case uri + when /\/raw\// + /\/raw\/([^\/]+)/ + when /\/download\// + /\/download\/([^\/]+)/ + when /\/embed\// + /\/embed\/([^\/]+)/ + else + /\/([^\/]+)/ + end + + match = uri.path.match(regex) + match[1] if match && match[1] + end + end + end +end diff --git a/lib/onebox/engine/pdf_onebox.rb b/lib/onebox/engine/pdf_onebox.rb new file mode 100644 index 00000000000..2a8d46f0d4a --- /dev/null +++ b/lib/onebox/engine/pdf_onebox.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class PdfOnebox + include Engine + include LayoutSupport + + matches_regexp(/^(https?:)?\/\/.*\.pdf(\?.*)?$/i) + always_https + + private + + def data + begin + size = Onebox::Helpers.fetch_content_length(@url) + rescue + raise "Unable to read pdf file: #{@url}" + end + + { + link: link, + title: File.basename(uri.path), + filesize: size ? Onebox::Helpers.pretty_filesize(size.to_i) : nil, + } + end + end + end +end diff --git a/lib/onebox/engine/pubmed_onebox.rb b/lib/onebox/engine/pubmed_onebox.rb new file mode 100644 index 00000000000..1cf8a0ac9bb --- /dev/null +++ b/lib/onebox/engine/pubmed_onebox.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class PubmedOnebox + include Engine + include LayoutSupport + + matches_regexp(/^https?:\/\/(?:(?:\w)+\.)?(www.ncbi.nlm.nih)\.gov(?:\/)?\/pubmed\/\d+/) + + private + + def xml + return @xml if defined?(@xml) + doc = Nokogiri::XML(URI.open(URI.join(@url, "?report=xml&format=text"))) + pre = doc.xpath("//pre") + @xml = Nokogiri::XML("" + pre.text + "") + end + + def authors + initials = xml.css("Initials").map { |x| x.content } + last_names = xml.css("LastName").map { |x| x.content } + author_list = (initials.zip(last_names)).map { |i, l| i + " " + l } + if author_list.length > 1 then + author_list[-2] = author_list[-2] + " and " + author_list[-1] + author_list.pop + end + author_list.join(", ") + end + + def date + xml.css("PubDate") + .children + .map { |x| x.content } + .select { |s| !s.match(/^\s+$/) } + .map { |s| s.split } + .flatten + .sort + .reverse + .join(" ") # Reverse sort so month before year. + end + + def data + { + title: xml.css("ArticleTitle").text, + authors: authors, + journal: xml.css("Title").text, + abstract: xml.css("AbstractText").text, + date: date, + link: @url, + pmid: match[:pmid] + } + end + + def match + @match ||= @url.match(%r{www\.ncbi\.nlm\.nih\.gov/pubmed/(?[0-9]+)}) + end + end + end +end diff --git a/lib/onebox/engine/reddit_media_onebox.rb b/lib/onebox/engine/reddit_media_onebox.rb new file mode 100644 index 00000000000..c9d6cdcbff4 --- /dev/null +++ b/lib/onebox/engine/reddit_media_onebox.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class RedditMediaOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/(www\.)?reddit\.com/) + + def to_html + if raw[:type] == "image" + <<-HTML + + HTML + elsif raw[:type] =~ /^video[\/\.]/ + <<-HTML + + HTML + else + html = Onebox::Engine::AllowlistedGenericOnebox.new(@url, @timeout).to_html + return if Onebox::Helpers.blank?(html) + html + end + end + end + end +end diff --git a/lib/onebox/engine/replit_onebox.rb b/lib/onebox/engine/replit_onebox.rb new file mode 100644 index 00000000000..5a4fcc01524 --- /dev/null +++ b/lib/onebox/engine/replit_onebox.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class ReplitOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/repl\.it\/.+/) + always_https + + def placeholder_html + oembed = get_oembed + + <<-HTML + + HTML + end + + def to_html + get_oembed.html + end + end + end +end diff --git a/lib/onebox/engine/simplecast_onebox.rb b/lib/onebox/engine/simplecast_onebox.rb new file mode 100644 index 00000000000..068c5301c8d --- /dev/null +++ b/lib/onebox/engine/simplecast_onebox.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class SimplecastOnebox + include Engine + include StandardEmbed + + matches_regexp(/https?:\/\/(.+)?simplecast.com\/(episodes|s)\/.*/) + always_https + requires_iframe_origins("https://embed.simplecast.com") + + def to_html + get_oembed.html + end + + def placeholder_html + oembed = get_oembed + return if Onebox::Helpers.blank?(oembed.thumbnail_url) + "" + end + + private + + def get_oembed_url + if id = url.scan(/([a-zA-Z0-9]*)\Z/).flatten.first + oembed_url = "https://simplecast.com/s/#{id}" + else + oembed_url = url + end + + "https://simplecast.com/oembed?url=#{oembed_url}" + end + end + end +end diff --git a/lib/onebox/engine/sketch_fab_onebox.rb b/lib/onebox/engine/sketch_fab_onebox.rb new file mode 100644 index 00000000000..e45f6f0cf34 --- /dev/null +++ b/lib/onebox/engine/sketch_fab_onebox.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class SketchFabOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/sketchfab\.com\/(?:models\/|3d-models\/(?:[^\/\s]+-)?)([a-z0-9]{32})/) + always_https + requires_iframe_origins("https://sketchfab.com") + + def to_html + og = get_opengraph + src = og.video_url.gsub("autostart=1", "") + + <<-HTML + + HTML + end + + def placeholder_html + "" + end + end + end +end diff --git a/lib/onebox/engine/slides_onebox.rb b/lib/onebox/engine/slides_onebox.rb new file mode 100644 index 00000000000..3681b2faf90 --- /dev/null +++ b/lib/onebox/engine/slides_onebox.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class SlidesOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/slides\.com\/[\p{Alnum}_\-]+\/[\p{Alnum}_\-]+$/) + requires_iframe_origins "https://slides.com" + + def to_html + <<-HTML + + HTML + end + + def placeholder_html + escaped_src = ::Onebox::Helpers.normalize_url_for_output(raw[:image]) + "" + end + end + end +end diff --git a/lib/onebox/engine/sound_cloud_onebox.rb b/lib/onebox/engine/sound_cloud_onebox.rb new file mode 100644 index 00000000000..c257932c2cd --- /dev/null +++ b/lib/onebox/engine/sound_cloud_onebox.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class SoundCloudOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/soundcloud\.com/) + requires_iframe_origins "https://w.soundcloud.com" + always_https + + def to_html + oembed = get_oembed + oembed.html.gsub('visual=true', 'visual=false') + end + + def placeholder_html + oembed = get_oembed + return if Onebox::Helpers.blank?(oembed.thumbnail_url) + "" + end + + protected + + def get_oembed_url + oembed_url = "https://soundcloud.com/oembed.json?url=#{url}" + oembed_url += "&maxheight=166" unless url["/sets/"] + oembed_url + end + end + end +end diff --git a/lib/onebox/engine/stack_exchange_onebox.rb b/lib/onebox/engine/stack_exchange_onebox.rb new file mode 100644 index 00000000000..c918ed93aac --- /dev/null +++ b/lib/onebox/engine/stack_exchange_onebox.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class StackExchangeOnebox + include Engine + include LayoutSupport + include JSON + + def self.domains + %w(stackexchange.com stackoverflow.com superuser.com serverfault.com askubuntu.com stackapps.com mathoverflow.net) + .map { |domain| Regexp.escape(domain) } + end + + matches_regexp(/^https?:\/\/(?:(?:(?\w*)\.)?(?\w*)\.)?(?#{domains.join('|')})\/((?:questions|q)\/(?\d*)(\/.*\/(?\d*))?|(a\/(?\d*)))/) + + def always_https? + uri.host.split('.').length <= 3 + end + + private + + def match + @match ||= @url.match(@@matcher) + end + + def url + domain = uri.host + question_id = match[:question_id] + answer_id = match[:answer_id2] || match[:answer_id1] + + if answer_id + "https://api.stackexchange.com/2.2/answers/#{answer_id}?site=#{domain}&filter=!.FjueITQdx6-Rq3Ue9PWG.QZ2WNdW" + else + "https://api.stackexchange.com/2.2/questions/#{question_id}?site=#{domain}&filter=!5-duuxrJa-iw9oVvOA(JNimB5VIisYwZgwcfNI" + end + end + + def data + return @data if defined?(@data) + + result = raw['items'][0] + if result + result['creation_date'] = + Time.at(result['creation_date'].to_i).strftime("%I:%M%p - %d %b %y %Z") + + result['tags'] = result['tags'].take(4).join(', ') + result['is_answer'] = result.key?('answer_id') + result['is_question'] = result.key?('question_id') + end + + @data = result + end + end + end +end diff --git a/lib/onebox/engine/standard_embed.rb b/lib/onebox/engine/standard_embed.rb new file mode 100644 index 00000000000..f19f060cb2d --- /dev/null +++ b/lib/onebox/engine/standard_embed.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "cgi" +require "onebox/open_graph" +require 'onebox/oembed' + +module Onebox + module Engine + module StandardEmbed + def self.oembed_providers + @@oembed_providers ||= {} + end + + def self.add_oembed_provider(regexp, endpoint) + oembed_providers[regexp] = endpoint + end + + def self.opengraph_providers + @@opengraph_providers ||= [] + end + + def self.add_opengraph_provider(regexp) + opengraph_providers << regexp + end + + # Some oembed providers (like meetup.com) don't provide links to themselves + add_oembed_provider(/www\.meetup\.com\//, 'http://api.meetup.com/oembed') + add_oembed_provider(/www\.mixcloud\.com\//, 'https://www.mixcloud.com/oembed/') + # In order to support Private Videos + add_oembed_provider(/vimeo\.com\//, 'https://vimeo.com/api/oembed.json') + # NYT requires login so use oembed only + add_oembed_provider(/nytimes\.com\//, 'https://www.nytimes.com/svc/oembed/json/') + + def always_https? + AllowlistedGenericOnebox.host_matches(uri, AllowlistedGenericOnebox.https_hosts) || super + end + + def raw + return @raw if defined?(@raw) + + og = get_opengraph + twitter = get_twitter + oembed = get_oembed + + @raw = {} + + og.data.each do |k, v| + next if k == "title_attr" + v = og.send(k) + @raw[k] ||= v unless v.nil? + end + + twitter.each { |k, v| @raw[k] ||= v unless Onebox::Helpers::blank?(v) } + + oembed.data.each do |k, v| + v = oembed.send(k) + @raw[k] ||= v unless v.nil? + end + + favicon = get_favicon + @raw["favicon".to_sym] = favicon unless Onebox::Helpers::blank?(favicon) + + @raw + end + + protected + + def html_doc + return @html_doc if defined?(@html_doc) + + headers = nil + headers = { 'Cookie' => options[:cookie] } if options[:cookie] + + @html_doc = Onebox::Helpers.fetch_html_doc(url, headers) + end + + def get_oembed + @oembed ||= Onebox::Oembed.new(get_json_response) + end + + def get_opengraph + @opengraph ||= ::Onebox::OpenGraph.new(html_doc) + end + + def get_twitter + return {} unless html_doc + + twitter = {} + + html_doc.css('meta').each do |m| + if (m["property"] && m["property"][/^twitter:(.+)$/i]) || (m["name"] && m["name"][/^twitter:(.+)$/i]) + value = (m["content"] || m["value"]).to_s + twitter[$1.tr('-:' , '_').to_sym] ||= value unless (Onebox::Helpers::blank?(value) || value == "0 minutes") + end + end + + twitter + end + + def get_favicon + return nil unless html_doc + + favicon = html_doc.css('link[rel="shortcut icon"], link[rel="icon shortcut"], link[rel="shortcut"], link[rel="icon"]').first + favicon = favicon.nil? ? nil : (favicon['href'].nil? ? nil : favicon['href'].strip) + + Onebox::Helpers::get_absolute_image_url(favicon, url) + end + + def get_json_response + oembed_url = get_oembed_url + + return "{}" if Onebox::Helpers.blank?(oembed_url) + + Onebox::Helpers.fetch_response(oembed_url) rescue "{}" + rescue Errno::ECONNREFUSED, Net::HTTPError, Net::HTTPFatalError, MultiJson::LoadError + "{}" + end + + def get_oembed_url + oembed_url = nil + + StandardEmbed.oembed_providers.each do |regexp, endpoint| + if url =~ regexp + oembed_url = "#{endpoint}?url=#{url}" + break + end + end + + if html_doc + if Onebox::Helpers.blank?(oembed_url) + application_json = html_doc.at("//link[@type='application/json+oembed']/@href") + oembed_url = application_json.value if application_json + end + + if Onebox::Helpers.blank?(oembed_url) + text_json = html_doc.at("//link[@type='text/json+oembed']/@href") + oembed_url ||= text_json.value if text_json + end + end + + oembed_url + end + end + end +end diff --git a/lib/onebox/engine/steam_store_onebox.rb b/lib/onebox/engine/steam_store_onebox.rb new file mode 100644 index 00000000000..e39ab843424 --- /dev/null +++ b/lib/onebox/engine/steam_store_onebox.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class SteamStoreOnebox + include Engine + include StandardEmbed + + always_https + matches_regexp(/^https?:\/\/store\.steampowered\.com\/app\/\d+/) + requires_iframe_origins "https://store.steampowered.com" + + def placeholder_html + og = get_opengraph + <<-HTML +
+
+

#{og.title}

+ +

#{og.description}

+
+
+ HTML + end + + def to_html + iframe_url = @url[/https?:\/\/store\.steampowered\.com\/app\/\d+/].gsub("/app/", "/widget/") + escaped_src = ::Onebox::Helpers.normalize_url_for_output(iframe_url) + + <<-HTML + + HTML + end + end + end +end diff --git a/lib/onebox/engine/trello_onebox.rb b/lib/onebox/engine/trello_onebox.rb new file mode 100644 index 00000000000..c74b39f01ac --- /dev/null +++ b/lib/onebox/engine/trello_onebox.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class TrelloOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https:\/\/trello\.com\/[bc]\/\W*/) + requires_iframe_origins "https://trello.com" + always_https + + def to_html + src = "https://trello.com/#{match[:type]}/#{match[:key]}.html" + height = match[:type] == 'b' ? 400 : 200 + + <<-HTML + + HTML + end + + def placeholder_html + ::Onebox::Helpers.generic_placeholder_html + end + + private + + def match + return @match if defined?(@match) + @match = @url.match(%{trello\.com/(?[^/]+)/(?[^/]+)/?\W*}) + end + end + end +end diff --git a/lib/onebox/engine/twitch_clips_onebox.rb b/lib/onebox/engine/twitch_clips_onebox.rb new file mode 100644 index 00000000000..8c4fb525b63 --- /dev/null +++ b/lib/onebox/engine/twitch_clips_onebox.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative '../mixins/twitch_onebox' + +class Onebox::Engine::TwitchClipsOnebox + def self.twitch_regexp + /^https?:\/\/clips\.twitch\.tv\/([a-zA-Z0-9_]+\/?[^#\?\/]+)/ + end + + include Onebox::Mixins::TwitchOnebox + requires_iframe_origins "https://clips.twitch.tv" + + def query_params + "clip=#{twitch_id}" + end + + def base_url + "clips.twitch.tv/embed?" + end +end diff --git a/lib/onebox/engine/twitch_stream_onebox.rb b/lib/onebox/engine/twitch_stream_onebox.rb new file mode 100644 index 00000000000..f0e1d5d346c --- /dev/null +++ b/lib/onebox/engine/twitch_stream_onebox.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative '../mixins/twitch_onebox' + +class Onebox::Engine::TwitchStreamOnebox + def self.twitch_regexp + /^https?:\/\/(?:www\.|go\.)?twitch\.tv\/(?!directory)([a-zA-Z0-9_]{4,25})$/ + end + + include Onebox::Mixins::TwitchOnebox + + def query_params + "channel=#{twitch_id}" + end +end diff --git a/lib/onebox/engine/twitch_video_onebox.rb b/lib/onebox/engine/twitch_video_onebox.rb new file mode 100644 index 00000000000..47fd2eefeb4 --- /dev/null +++ b/lib/onebox/engine/twitch_video_onebox.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative '../mixins/twitch_onebox' + +class Onebox::Engine::TwitchVideoOnebox + def self.twitch_regexp + /^https?:\/\/(?:www\.)?twitch\.tv\/videos\/([0-9]+)/ + end + + include Onebox::Mixins::TwitchOnebox + + def query_params + "video=v#{twitch_id}" + end +end diff --git a/lib/onebox/engine/twitter_status_onebox.rb b/lib/onebox/engine/twitter_status_onebox.rb new file mode 100644 index 00000000000..31fffadeb95 --- /dev/null +++ b/lib/onebox/engine/twitter_status_onebox.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class TwitterStatusOnebox + include Engine + include LayoutSupport + include HTML + + matches_regexp(/^https?:\/\/(mobile\.|www\.)?twitter\.com\/.+?\/status(es)?\/\d+(\/(video|photo)\/\d?+)?+(\/?\?.*)?\/?$/) + always_https + + def http_params + { 'User-Agent' => 'DiscourseBot/1.0' } + end + + private + + def get_twitter_data + response = Onebox::Helpers.fetch_response(url, headers: http_params) rescue nil + html = Nokogiri::HTML(response) + twitter_data = {} + html.css('meta').each do |m| + if m.attribute('property') && m.attribute('property').to_s.match(/^og:/i) + m_content = m.attribute('content').to_s.strip + m_property = m.attribute('property').to_s.gsub('og:', '') + twitter_data[m_property.to_sym] = m_content + end + end + twitter_data + end + + def match + @match ||= @url.match(%r{twitter\.com/.+?/status(es)?/(?\d+)}) + end + + def twitter_data + @twitter_data ||= get_twitter_data + end + + def client + Onebox.options.twitter_client + end + + def twitter_api_credentials_present? + client && !client.twitter_credentials_missing? + end + + def raw + if twitter_api_credentials_present? + @raw ||= OpenStruct.new(client.status(match[:id]).to_hash) + else + super + end + end + + def access(*keys) + keys.reduce(raw) do |memo, key| + next unless memo + memo[key] || memo[key.to_s] + end + end + + def tweet + if twitter_api_credentials_present? + client.prettify_tweet(raw)&.strip + else + twitter_data[:description].gsub(/“(.+?)”/im) { $1 } if twitter_data[:description] + end + end + + def timestamp + if twitter_api_credentials_present? + date = DateTime.strptime(access(:created_at), "%a %b %d %H:%M:%S %z %Y") + user_offset = access(:user, :utc_offset).to_i + offset = (user_offset >= 0 ? "+" : "-") + Time.at(user_offset.abs).gmtime.strftime("%H%M") + date.new_offset(offset).strftime("%-l:%M %p - %-d %b %Y") + else + attr_at_css(".tweet-timestamp", 'title') + end + end + + def title + if twitter_api_credentials_present? + "#{access(:user, :name)} (#{access(:user, :screen_name)})" + else + "#{attr_at_css('.tweet.permalink-tweet', 'data-name')} (#{attr_at_css('.tweet.permalink-tweet', 'data-screen-name')})" + end + end + + def avatar + if twitter_api_credentials_present? + access(:user, :profile_image_url_https).sub('normal', '400x400') + elsif twitter_data[:image] + twitter_data[:image] + end + end + + def likes + if twitter_api_credentials_present? + prettify_number(access(:favorite_count).to_i) + else + attr_at_css(".request-favorited-popup", 'data-compact-localized-count') + end + end + + def retweets + if twitter_api_credentials_present? + prettify_number(access(:retweet_count).to_i) + else + attr_at_css(".request-retweeted-popup", 'data-compact-localized-count') + end + end + + def quoted_full_name + if twitter_api_credentials_present? + access(:quoted_status, :user, :name) + else + raw.css('.QuoteTweet-fullname')[0]&.text + end + end + + def quoted_screen_name + if twitter_api_credentials_present? + access(:quoted_status, :user, :screen_name) + else + attr_at_css(".QuoteTweet-innerContainer", "data-screen-name") + end + end + + def quoted_tweet + if twitter_api_credentials_present? + access(:quoted_status, :full_text) + else + raw.css('.QuoteTweet-text')[0]&.text + end + end + + def quoted_link + if twitter_api_credentials_present? + "https://twitter.com/#{quoted_screen_name}/status/#{access(:quoted_status, :id)}" + else + "https://twitter.com#{attr_at_css(".QuoteTweet-innerContainer", "href")}" + end + end + + def prettify_number(count) + count > 0 ? client.prettify_number(count) : nil + end + + def attr_at_css(css_property, attribute_name) + raw.at_css(css_property)&.attr(attribute_name) + end + + def data + @data ||= { + link: link, + tweet: tweet, + timestamp: timestamp, + title: title, + avatar: avatar, + likes: likes, + retweets: retweets, + quoted_tweet: quoted_tweet, + quoted_full_name: quoted_full_name, + quoted_screen_name: quoted_screen_name, + quoted_link: quoted_link + } + end + end + end +end diff --git a/lib/onebox/engine/typeform_onebox.rb b/lib/onebox/engine/typeform_onebox.rb new file mode 100644 index 00000000000..184aff5e02e --- /dev/null +++ b/lib/onebox/engine/typeform_onebox.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class TypeformOnebox + include Engine + + matches_regexp(/^https?:\/\/[a-z0-9\-_]+\.typeform\.com\/to\/[a-zA-Z0-9]+/) + requires_iframe_origins "https://*.typeform.com" + always_https + + def to_html + typeform_src = build_typeform_src + + <<~HTML + + HTML + end + + def placeholder_html + ::Onebox::Helpers.generic_placeholder_html + end + + private + + def build_typeform_src + escaped_src = ::Onebox::Helpers.normalize_url_for_output(@url) + query_params = CGI::parse(URI::parse(escaped_src).query || '') + + return escaped_src if query_params.has_key?('typeform-embed') + + if query_params.empty? + escaped_src += '?' unless escaped_src.end_with?('?') + else + escaped_src += '&' + end + + escaped_src += 'typeform-embed=embed-widget' + end + end + end +end diff --git a/lib/onebox/engine/video_onebox.rb b/lib/onebox/engine/video_onebox.rb new file mode 100644 index 00000000000..1fd60543ff9 --- /dev/null +++ b/lib/onebox/engine/video_onebox.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class VideoOnebox + include Engine + + matches_regexp(/^(https?:)?\/\/.*\.(mov|mp4|webm|ogv)(\?.*)?$/i) + + def always_https? + AllowlistedGenericOnebox.host_matches(uri, AllowlistedGenericOnebox.https_hosts) + end + + def to_html + # Fix Dropbox image links + if @url[/^https:\/\/www.dropbox.com\/s\//] + @url.sub!("https://www.dropbox.com", "https://dl.dropboxusercontent.com") + end + + escaped_url = ::Onebox::Helpers.normalize_url_for_output(@url) + <<-HTML +
+ +
+ HTML + end + + def placeholder_html + ::Onebox::Helpers.video_placeholder_html + end + end + end +end diff --git a/lib/onebox/engine/vimeo_onebox.rb b/lib/onebox/engine/vimeo_onebox.rb new file mode 100644 index 00000000000..d35dc54b964 --- /dev/null +++ b/lib/onebox/engine/vimeo_onebox.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class VimeoOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/(www\.)?vimeo\.com\/\d+/) + requires_iframe_origins "https://player.vimeo.com" + always_https + + WIDTH ||= 640 + HEIGHT ||= 360 + + def placeholder_html + ::Onebox::Helpers.video_placeholder_html + end + + def to_html + video_id = oembed_data[:video_id] + if video_id.nil? + # for private videos + video_id = uri.path[/\/(\d+)/, 1] + end + video_src = "https://player.vimeo.com/video/#{video_id}" + video_src = video_src.gsub('autoplay=1', '').chomp("?") + + <<-HTML + + HTML + end + + private + + def oembed_data + response = Onebox::Helpers.fetch_response("https://vimeo.com/api/oembed.json?url=#{url}") + @oembed_data = Onebox::Helpers.symbolize_keys(::MultiJson.load(response)) + rescue + "{}" + end + + def og_data + @og_data = get_opengraph + end + end + end +end diff --git a/lib/onebox/engine/wikimedia_onebox.rb b/lib/onebox/engine/wikimedia_onebox.rb new file mode 100644 index 00000000000..c222f58bf91 --- /dev/null +++ b/lib/onebox/engine/wikimedia_onebox.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class WikimediaOnebox + include Engine + include LayoutSupport + include JSON + + matches_regexp(/^https?:\/\/commons\.wikimedia\.org\/wiki\/(File:.+)/) + always_https + + def self.priority + # Wikimedia links end in an image extension. + # E.g. https://commons.wikimedia.org/wiki/File:Stones_members_montage2.jpg + # This engine should have priority over the generic ImageOnebox. + + 1 + end + + def url + "https://en.wikipedia.org/w/api.php?action=query&titles=#{match[:name]}&prop=imageinfo&iilimit=50&iiprop=timestamp|user|url&iiurlwidth=500&format=json" + end + + private + + def match + @match ||= @url.match(/^https?:\/\/commons\.wikimedia\.org\/wiki\/(?File:.+)/) + end + + def data + first_page = raw['query']['pages'].first[1] + + { + link: first_page['imageinfo'].first['descriptionurl'], + title: first_page['title'], + image: first_page['imageinfo'].first['url'], + thumbnail: first_page['imageinfo'].first['thumburl'] + } + end + end + end +end diff --git a/lib/onebox/engine/wikipedia_onebox.rb b/lib/onebox/engine/wikipedia_onebox.rb new file mode 100644 index 00000000000..e86a9014334 --- /dev/null +++ b/lib/onebox/engine/wikipedia_onebox.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class WikipediaOnebox + include Engine + include LayoutSupport + include HTML + + matches_regexp(/^https?:\/\/.*\.wikipedia\.(com|org)/) + always_https + + private + + def data + paras = [] + text = "" + + # Detect section Hash in the url and retrive the related paragraphs. if no hash provided the first few paragraphs will be used + # Author Lidlanca + # Date 9/8/2014 + if (m_url_hash = @url.match(/#([^\/?]+)/)) # extract url hash + m_url_hash_name = m_url_hash[1] + end + + unless m_url_hash.nil? + section_header_title = raw.xpath("//span[@id='#{m_url_hash_name}']") + + if section_header_title.empty? + paras = raw.search("p") # default get all the paras + else + section_title_text = section_header_title.inner_text + section_header = section_header_title[0].parent # parent element of the section span element should be an

node + cur_element = section_header + + # p|text|div covers the general case. We assume presence of at least 1 P node. if section has no P node we may end up with a P node from the next section. + # div tag is commonly used as an assets wraper in an article section. often as the first element holding an image. + # ul support will imporve the output generated for a section with a list as the main content (for example: an Author Bibliography, A musician Discography, etc) + first_p_found = nil + while (((next_sibling = cur_element.next_sibling).name =~ /p|text|div|ul/) || first_p_found.nil?) do # from section header get the next sibling until it is a breaker tag + cur_element = next_sibling + if (cur_element.name == "p" || cur_element.name == "ul") #we treat a list as we detect a p to avoid showing + first_p_found = true + paras.push(cur_element) + end + end + end + else # no hash found in url + paras = raw.search("p") # default get all the paras + end + + unless paras.empty? + cnt = 0 + while text.length < Onebox::LayoutSupport.max_text && cnt <= 3 + break if cnt >= paras.size + text += " " unless cnt == 0 + + if paras[cnt].name == "ul" # Handle UL tag. Generate a textual ordered list (1.item | 2.item | 3.item). Unfortunately no newline allowed in output + li_index = 1 + list_items = [] + paras[cnt].children.css("li").each { |li| list_items.push "#{li_index}." + li.inner_text ; li_index += 1 } + paragraph = (list_items.join " |\n ")[0..Onebox::LayoutSupport.max_text] + else + paragraph = paras[cnt].inner_text[0..Onebox::LayoutSupport.max_text] + end + + paragraph.gsub!(/\[\d+\]/mi, "") + text += paragraph + cnt += 1 + end + end + + text = "#{text[0..Onebox::LayoutSupport.max_text]}..." if text.length > Onebox::LayoutSupport.max_text + + result = { + link: link, + title: raw.css("html body h1").inner_text + (section_title_text ? " | " + section_title_text : ""), #if a section sub title exists add it to the main article title + description: text + } + + img = raw.css(".image img") + + if img && img.size > 0 + img.each do |i| + src = i["src"] + if src !~ /Question_book/ + result[:image] = src + break + end + end + end + + result + end + end + end +end diff --git a/lib/onebox/engine/wistia_onebox.rb b/lib/onebox/engine/wistia_onebox.rb new file mode 100644 index 00000000000..4abb2ff7fc4 --- /dev/null +++ b/lib/onebox/engine/wistia_onebox.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class WistiaOnebox + include Engine + include StandardEmbed + + matches_regexp(/https?:\/\/(.+)?(wistia.com|wi.st)\/(medias|embed)\/.*/) + requires_iframe_origins("https://fast.wistia.com", "https://fast.wistia.net") + always_https + + def to_html + get_oembed.html + end + + def placeholder_html + oembed = get_oembed + return if Onebox::Helpers.blank?(oembed.thumbnail_url) + "" + end + + private + + def get_oembed_url + "https://fast.wistia.com/oembed?embedType=iframe&url=#{url}" + end + end + end +end diff --git a/lib/onebox/engine/xkcd_onebox.rb b/lib/onebox/engine/xkcd_onebox.rb new file mode 100644 index 00000000000..b16c25b098a --- /dev/null +++ b/lib/onebox/engine/xkcd_onebox.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class XkcdOnebox + include Engine + include LayoutSupport + include JSON + + matches_regexp(/^https?:\/\/(www\.)?(m\.)?xkcd\.com\/\d+/) + + def url + "https://xkcd.com/#{match[:comic_id]}/info.0.json" + end + + private + + def match + @match ||= @url.match(%{xkcd\.com/(?\\d+)}) + end + + def data + { + link: @url, + title: raw['safe_title'], + image: raw['img'], + description: raw['alt'] + } + end + end + end +end diff --git a/lib/onebox/engine/youku_onebox.rb b/lib/onebox/engine/youku_onebox.rb new file mode 100644 index 00000000000..2b9234fd931 --- /dev/null +++ b/lib/onebox/engine/youku_onebox.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class YoukuOnebox + include Engine + include HTML + + matches_regexp(/^(https?:\/\/)?([\da-z\.-]+)(youku.com\/)(.)+\/?$/) + requires_iframe_origins "https://player.youku.com" + + # Try to get the video ID. Works for URLs of the form: + # * http://v.youku.com/v_show/id_XNjM3MzAxNzc2.html + # * http://v.youku.com/v_show/id_XMTQ5MjgyMjMyOA==.html?from=y1.3-tech-index3-232-10183.89969-89963.3-1 + def video_id + match = uri.path.match(/\/v_show\/id_([a-zA-Z0-9_=\-]+)(\.html)?.*/) + match && match[1] + rescue + nil + end + + def to_html + <<~HTML + + HTML + end + end + end +end diff --git a/lib/onebox/engine/youtube_onebox.rb b/lib/onebox/engine/youtube_onebox.rb new file mode 100644 index 00000000000..81f822ce0bd --- /dev/null +++ b/lib/onebox/engine/youtube_onebox.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class YoutubeOnebox + include Engine + include StandardEmbed + + matches_regexp(/^https?:\/\/(?:www\.)?(?:m\.)?(?:youtube\.com|youtu\.be)\/.+$/) + requires_iframe_origins "https://www.youtube.com" + always_https + + WIDTH ||= 480 + HEIGHT ||= 360 + + def parse_embed_response + return unless video_id + return @parse_embed_response if defined?(@parse_embed_response) + + embed_url = "https://www.youtube.com/embed/#{video_id}" + @embed_doc ||= Onebox::Helpers.fetch_html_doc(embed_url) + + begin + script_tag = @embed_doc.xpath('//script').find { |tag| tag.to_s.include?('ytcfg.set') }.to_s + match = script_tag.to_s.match(/ytcfg\.set\((?.*)\)/) + + yt_json = ::JSON.parse(match[:json]) + renderer = ::JSON.parse(yt_json['PLAYER_VARS']['embedded_player_response'])['embedPreview']['thumbnailPreviewRenderer'] + + title = renderer['title']['runs'].first['text'] + + image = "https://img.youtube.com/vi/#{video_id}/hqdefault.jpg" + rescue + return + end + + @parse_embed_response = { image: image, title: title } + end + + def placeholder_html + if video_id || list_id + result = parse_embed_response + result ||= get_opengraph.data + + "" + else + to_html + end + end + + def to_html + if video_id + <<-HTML + + HTML + elsif list_id + <<-HTML + + HTML + else + # for channel pages + html = Onebox::Engine::AllowlistedGenericOnebox.new(@url, @timeout).to_html + return if Onebox::Helpers.blank?(html) + html.gsub!(/['"]\/\//, "https://") + html + end + end + + def video_title + @video_title ||= begin + result = parse_embed_response || get_opengraph.data + result[:title] + end + end + + private + + def video_id + @video_id ||= begin + # http://youtu.be/afyK1HSFfgw + if uri.host["youtu.be"] + id = uri.path[/\/([\w\-]+)/, 1] + return id if id + end + + # https://www.youtube.com/embed/vsF0K3Ou1v0 + if uri.path["/embed/"] + id = uri.path[/\/embed\/([\w\-]+)/, 1] + return id if id + end + + # https://www.youtube.com/watch?v=Z0UISCEe52Y + params['v'] + end + end + + def list_id + @list_id ||= params['list'] + end + + def embed_params + p = { 'feature' => 'oembed', 'wmode' => 'opaque' } + + p['list'] = list_id if list_id + + # Parse timestrings, and assign the result as a start= parameter + start = if params['start'] + params['start'] + elsif params['t'] + params['t'] + elsif uri.fragment && uri.fragment.start_with?('t=') + # referencing uri is safe here because any throws were already caught by video_id returning nil + # remove the t= from the start + uri.fragment[2..-1] + end + + p['start'] = parse_timestring(start) if start + p['end'] = parse_timestring params['end'] if params['end'] + + # Official workaround for looping videos + # https://developers.google.com/youtube/player_parameters#loop + # use params.include? so that you can just add "&loop" + if params.include?('loop') + p['loop'] = 1 + p['playlist'] = video_id + end + + # https://developers.google.com/youtube/player_parameters#rel + p['rel'] = 0 if params.include?('rel') + + # https://developers.google.com/youtube/player_parameters#enablejsapi + p['enablejsapi'] = params['enablejsapi'] if params.include?('enablejsapi') + + URI.encode_www_form(p) + end + + def parse_timestring(string) + if string =~ /(\d+h)?(\d+m)?(\d+s?)?/ + ($1.to_i * 3600) + ($2.to_i * 60) + $3.to_i + end + end + + def params + return {} unless uri.query + # This mapping is necessary because CGI.parse returns a hash of keys to arrays. + # And *that* is necessary because querystrings support arrays, so they + # force you to deal with it to avoid security issues that would pop up + # if one day it suddenly gave you an array. + # + # However, we aren't interested. Just take the first one. + @params ||= begin + p = {} + CGI.parse(uri.query).each { |k, v| p[k] = v.first } + p + end + rescue + {} + end + end + end +end diff --git a/lib/onebox/file_type_finder.rb b/lib/onebox/file_type_finder.rb new file mode 100644 index 00000000000..8ba4dc8c3f4 --- /dev/null +++ b/lib/onebox/file_type_finder.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Onebox + module FileTypeFinder + # In general, most of file extension names would be recognized + # by Highlights.js. However, some need to be checked in other + # ways, either because they just aren't included, because they + # are extensionless, or because they contain dots (they are + # multi-part). + # IMPORTANT: to prevent false positive matching, start all + # entries on this list with a "." + # + # For easy reference, keep these sorted in alphabetical order. + @long_file_types = { + ".bib" => "tex", + ".html.hbs" => "handlebars", + ".html.handlebars" => "handlebars", + ".latex" => "tex", + ".ru" => "rb", + ".simplecov" => "rb", # Not official, but seems commonly found + ".sty" => "tex" + } + + # Some extensionless files for which we know the type + # These should all be stored LOWERCASE, just for consistency. + # The ones that I know of also include the ".lock" fake extension. + # + # For easy reference, keep these sorted in alphabetical order, + # FIRST by their types and THEN by their names. + @extensionless_files = { + "cmake.in" => "cmake", + + "gruntfile" => "js", + "gulpfile" => "js", + + "artisan" => "php", + + "berksfile" => "rb", + "capfile" => "rb", + "cheffile" => "rb", + "cheffile.lock" => "rb", + "gemfile" => "rb", + "guardfile" => "rb", + "rakefile" => "rb", + "thorfile" => "rb", + "vagrantfile" => "rb", + + "boxfile" => "yaml" # Not currently (2014-11) in Highlight.js + } + + def self.from_file_name(file_name) + lower_name = file_name.downcase + # First check against the known lists of "special" files and extensions. + return @extensionless_files[lower_name] if @extensionless_files.has_key?(lower_name) + + @long_file_types.each { |extension, type| + return type if lower_name.end_with?(extension) + } + + # Otherwise, just split on the last ".", + # but add one so we don't return the "." itself. + dot_spot = lower_name.rindex(".") + return lower_name[(dot_spot + 1)..-1] if dot_spot + + # If we couldn't figure it out from the name, + # let the highlighter figure it out from the content. + "" + end + end +end diff --git a/lib/onebox/helpers.rb b/lib/onebox/helpers.rb new file mode 100644 index 00000000000..a3df3f2a042 --- /dev/null +++ b/lib/onebox/helpers.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require "addressable" + +module Onebox + module Helpers + + class DownloadTooLarge < StandardError; end + + IGNORE_CANONICAL_DOMAINS ||= ['www.instagram.com', 'youtube.com'] + + def self.symbolize_keys(hash) + return {} if hash.nil? + + hash.inject({}) do |result, (key, value)| + new_key = key.is_a?(String) ? key.to_sym : key + new_value = value.is_a?(Hash) ? symbolize_keys(value) : value + result[new_key] = new_value + result + end + end + + def self.clean(html) + html.gsub(/<[^>]+>/, ' ').gsub(/\n/, '') + end + + def self.fetch_html_doc(url, headers = nil, body_cacher = nil) + response = (fetch_response(url, headers: headers, body_cacher: body_cacher) rescue nil) + doc = Nokogiri::HTML(response) + uri = Addressable::URI.parse(url) + + ignore_canonical_tag = doc.at('meta[property="og:ignore_canonical"]') + should_ignore_canonical = IGNORE_CANONICAL_DOMAINS.map { |hostname| uri.hostname.match?(hostname) }.any? + + unless (ignore_canonical_tag && ignore_canonical_tag['content'].to_s == 'true') || should_ignore_canonical + # prefer canonical link + canonical_link = doc.at('//link[@rel="canonical"]/@href') + canonical_uri = Addressable::URI.parse(canonical_link) + if canonical_link && "#{canonical_uri.host}#{canonical_uri.path}" != "#{uri.host}#{uri.path}" + response = (fetch_response(canonical_uri.to_s, headers: headers, body_cacher: body_cacher) rescue nil) + doc = Nokogiri::HTML(response) if response + end + end + + doc + end + + def self.fetch_response(location, redirect_limit: 5, domain: nil, headers: nil, body_cacher: nil) + redirect_limit = Onebox.options.redirect_limit if redirect_limit > Onebox.options.redirect_limit + + raise Net::HTTPError.new('HTTP redirect too deep', location) if redirect_limit == 0 + + uri = Addressable::URI.parse(location) + uri = Addressable::URI.join(domain, uri) if !uri.host + + use_body_cacher = body_cacher && body_cacher.respond_to?('fetch_cached_response_body') + if use_body_cacher + response_body = body_cacher.fetch_cached_response_body(uri.to_s) + + if response_body.present? + return response_body + end + end + + result = StringIO.new + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.normalized_scheme == 'https') do |http| + http.open_timeout = Onebox.options.connect_timeout + http.read_timeout = Onebox.options.timeout + http.verify_mode = OpenSSL::SSL::VERIFY_NONE # Work around path building bugs + + headers ||= {} + + if Onebox.options.user_agent && !headers['User-Agent'] + headers['User-Agent'] = Onebox.options.user_agent + end + + request = Net::HTTP::Get.new(uri.request_uri, headers) + start_time = Time.now + + size_bytes = Onebox.options.max_download_kb * 1024 + http.request(request) do |response| + + if cookie = response.get_fields('set-cookie') + # HACK: If this breaks again in the future, use HTTP::CookieJar from gem 'http-cookie' + # See test: it "does not send cookies to the wrong domain" + redir_header = { 'Cookie' => cookie.join('; ') } + end + + redir_header = nil unless redir_header.is_a? Hash + + code = response.code.to_i + unless code === 200 + response.error! unless [301, 302, 303, 307, 308].include?(code) + + return fetch_response( + response['location'], + redirect_limit: redirect_limit - 1, + domain: "#{uri.scheme}://#{uri.host}", + headers: redir_header + ) + end + + response.read_body do |chunk| + result.write(chunk) + raise DownloadTooLarge.new if result.size > size_bytes + raise Timeout::Error.new if (Time.now - start_time) > Onebox.options.timeout + end + + if use_body_cacher && body_cacher.cache_response_body?(uri) + body_cacher.cache_response_body(uri.to_s, result.string) + end + + return result.string + end + end + end + + def self.fetch_content_length(location) + uri = URI(location) + + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.is_a?(URI::HTTPS)) do |http| + http.open_timeout = Onebox.options.connect_timeout + http.read_timeout = Onebox.options.timeout + if uri.is_a?(URI::HTTPS) + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + + http.request_head([uri.path, uri.query].join("?")) do |response| + code = response.code.to_i + unless code === 200 || Onebox::Helpers.blank?(response.content_length) + return nil + end + return response.content_length + end + end + end + + def self.pretty_filesize(size) + conv = [ 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB' ] + scale = 1024 + + ndx = 1 + if (size < 2 * (scale**ndx)) then + return "#{(size)} #{conv[ndx - 1]}" + end + size = size.to_f + [2, 3, 4, 5, 6, 7].each do |i| + if (size < 2 * (scale**i)) then + return "#{'%.2f' % (size / (scale**(i - 1)))} #{conv[i - 1]}" + end + end + ndx = 7 + "#{'%.2f' % (size / (scale**(ndx - 1)))} #{conv[ndx - 1]}" + end + + def self.click_to_scroll_div(width = 690, height = 400) + "
" + end + + def self.blank?(value) + if value.nil? + true + elsif String === value + value.empty? || !(/[[:^space:]]/ === value) + else + value.respond_to?(:empty?) ? !!value.empty? : !value + end + end + + def self.truncate(string, length = 50) + return string if string.nil? + string.size > length ? string[0...(string.rindex(" ", length) || length)] + "..." : string + end + + def self.get(meta, attr) + (meta && !blank?(meta[attr])) ? sanitize(meta[attr]) : nil + end + + def self.sanitize(value, length = 50) + return nil if blank?(value) + Sanitize.fragment(value).strip + end + + def self.normalize_url_for_output(url) + return "" unless url + url = url.dup + # expect properly encoded url, remove any unsafe chars + url.gsub!(' ', '%20') + url.gsub!("'", "'") + url.gsub!('"', """) + url.gsub!(/[^\w\-`.~:\/?#\[\]@!$&'\(\)*+,;=%\p{M}’]/, "") + + parsed = Addressable::URI.parse(url) + return "" unless parsed.host + + url + end + + def self.get_absolute_image_url(src, url) + if src && !!(src =~ /^\/\//) + uri = URI(url) + src = "#{uri.scheme}:#{src}" + elsif src && src.match(/^https?:\/\//i).nil? + uri = URI(url) + src = if !src.start_with?("/") && uri.path.present? + "#{uri.scheme}://#{uri.host.sub(/\/$/, '')}#{uri.path.sub(/\/$/, '')}/#{src.sub(/^\//, '')}" + else + "#{uri.scheme}://#{uri.host.sub(/\/$/, '')}/#{src.sub(/^\//, '')}" + end + end + src + end + + # Percent-encodes a URI string per RFC3986 - https://tools.ietf.org/html/rfc3986 + def self.uri_encode(url) + return "" unless url + + uri = Addressable::URI.parse(url) + + encoded_uri = Addressable::URI.new( + scheme: Addressable::URI.encode_component(uri.scheme, Addressable::URI::CharacterClasses::SCHEME), + authority: Addressable::URI.encode_component(uri.authority, Addressable::URI::CharacterClasses::AUTHORITY), + path: Addressable::URI.encode_component(uri.path, Addressable::URI::CharacterClasses::PATH + "\\%"), + query: Addressable::URI.encode_component(uri.query, "a-zA-Z0-9\\-\\.\\_\\~\\$\\&\\*\\,\\=\\:\\@\\?\\%"), + fragment: Addressable::URI.encode_component(uri.fragment, "a-zA-Z0-9\\-\\.\\_\\~\\!\\$\\&\\'\\(\\)\\*\\+\\,\\;\\=\\:\\/\\?\\%") + ) + + encoded_uri.to_s + end + + def self.uri_unencode(url) + Addressable::URI.unencode(url) + end + + def self.video_placeholder_html + "
" + end + + def self.audio_placeholder_html + "
" + end + + def self.map_placeholder_html + "
" + end + + def self.generic_placeholder_html + "
" + end + end +end diff --git a/lib/onebox/layout.rb b/lib/onebox/layout.rb new file mode 100644 index 00000000000..a697ba24f07 --- /dev/null +++ b/lib/onebox/layout.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative "template_support" + +module Onebox + class Layout < Mustache + include TemplateSupport + + VERSION = "1.0.0" + + attr_reader :record + attr_reader :view + + def initialize(name, record) + @record = Onebox::Helpers.symbolize_keys(record) + + # Fix any relative paths + if @record[:image] && @record[:image] =~ /^\/[^\/]/ + @record[:image] = "#{uri.scheme}://#{uri.host}/#{@record[:image]}" + end + + @md5 = Digest::MD5.new + @view = View.new(name, @record) + @template_name = "_layout" + @template_path = load_paths.last + end + + def to_html + render(details) + end + + private + + def uri + @uri ||= URI(::Onebox::Helpers.normalize_url_for_output(record[:link])) + end + + def details + { + link: record[:link], + title: record[:title], + favicon: record[:favicon], + domain: record[:domain] || uri.host.to_s.sub(/^www\./, ''), + article_published_time: record[:article_published_time], + article_published_time_title: record[:article_published_time_title], + metadata_1_label: record[:metadata_1_label], + metadata_1_value: record[:metadata_1_value], + metadata_2_label: record[:metadata_2_label], + metadata_2_value: record[:metadata_2_value], + subname: view.template_name, + view: view.to_html + } + end + end +end diff --git a/lib/onebox/layout_support.rb b/lib/onebox/layout_support.rb new file mode 100644 index 00000000000..dcec7cf287c --- /dev/null +++ b/lib/onebox/layout_support.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Onebox + module LayoutSupport + + def self.max_text + 500 + end + + def layout + @layout ||= Layout.new(self.class.onebox_name, data) + end + + def to_html + layout.to_html + end + end +end diff --git a/lib/onebox/matcher.rb b/lib/onebox/matcher.rb new file mode 100644 index 00000000000..2e337904e7e --- /dev/null +++ b/lib/onebox/matcher.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Onebox + class Matcher + def initialize(url, options = {}) + begin + @uri = URI(url) + rescue URI::InvalidURIError + end + + @options = options + end + + def ordered_engines + @ordered_engines ||= Engine.engines.sort_by do |e| + e.respond_to?(:priority) ? e.priority : 100 + end + end + + def oneboxed + return if @uri.nil? + return if @uri.port && !Onebox.options.allowed_ports.include?(@uri.port) + return if @uri.scheme && !Onebox.options.allowed_schemes.include?(@uri.scheme) + ordered_engines.find { |engine| engine === @uri && has_allowed_iframe_origins?(engine) } + end + + def has_allowed_iframe_origins?(engine) + allowed_regexes = @options[:allowed_iframe_regexes] || [] + engine.iframe_origins.all? { |o| allowed_regexes.any? { |r| o =~ r } } + end + end +end diff --git a/lib/onebox/mixins/git_blob_onebox.rb b/lib/onebox/mixins/git_blob_onebox.rb new file mode 100644 index 00000000000..cac7b2a7644 --- /dev/null +++ b/lib/onebox/mixins/git_blob_onebox.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +module Onebox + module Mixins + module GitBlobOnebox + def self.included(klass) + klass.include(Onebox::Engine) + klass.include(Onebox::LayoutSupport) + klass.matches_regexp(klass.git_regexp) + klass.always_https + klass.include(InstanceMethods) + end + + EXPAND_AFTER = 0b001 + EXPAND_BEFORE = 0b010 + EXPAND_NONE = 0b0 + + DEFAULTS = { + EXPAND_ONE_LINER: EXPAND_AFTER | EXPAND_BEFORE, #set how to expand a one liner. user EXPAND_NONE to disable expand + LINES_BEFORE: 10, + LINES_AFTER: 10, + SHOW_LINE_NUMBER: true, + MAX_LINES: 20, + MAX_CHARS: 5000 + } + + module InstanceMethods + def initialize(url, timeout = nil) + super url, timeout + # merge engine options from global Onebox.options interface + # self.options = Onebox.options["GithubBlobOnebox"] # self.class.name.split("::").last.to_s + # self.options = Onebox.options[self.class.name.split("::").last.to_s] #We can use this a more generic approach. extract the engine class name automatically + + self.options = DEFAULTS + + @selected_lines_array = nil + @selected_one_liner = 0 + @model_file = nil + + # Define constant after merging options set in Onebox.options + # We can define constant automatically. + options.each_pair do |constant_name, value| + constant_name_u = constant_name.to_s.upcase + if constant_name_u == constant_name.to_s + #define a constant if not already defined + unless self.class.const_defined? constant_name_u.to_sym + Onebox::Mixins::GitBlobOnebox.const_set constant_name_u.to_sym , options[constant_name_u.to_sym] + end + end + end + end + + private + + def calc_range(m, contents_lines_size) + truncated = false + from = /\d+/.match(m[:from]) #get numeric should only match a positive interger + to = /\d+/.match(m[:to]) #get numeric should only match a positive interger + range_provided = !(from.nil? && to.nil?) #true if "from" or "to" provided in URL + from = from.nil? ? 1 : from[0].to_i #if from not provided default to 1st line + to = to.nil? ? -1 : to[0].to_i #if to not provided default to undefiend to be handled later in the logic + + if to === -1 && range_provided #case "from" exists but no valid "to". aka ONE_LINER + one_liner = true + to = from + else + one_liner = false + end + + unless range_provided #case no range provided default to 1..MAX_LINES + from = 1 + to = MAX_LINES + truncated = true if contents_lines_size > MAX_LINES + #we can technically return here + end + + from, to = [from, to].sort #enforce valid range. [from < to] + from = 1 if from > contents_lines_size #if "from" out of TOP bound set to 1st line + to = contents_lines_size if to > contents_lines_size #if "to" is out of TOP bound set to last line. + + if one_liner + @selected_one_liner = from + if EXPAND_ONE_LINER != EXPAND_NONE + if (EXPAND_ONE_LINER & EXPAND_BEFORE != 0) # check if EXPAND_BEFORE flag is on + from = [1, from - LINES_BEFORE].max # make sure expand before does not go out of bound + end + + if (EXPAND_ONE_LINER & EXPAND_AFTER != 0) # check if EXPAND_FLAG flag is on + to = [to + LINES_AFTER, contents_lines_size].min # make sure expand after does not go out of bound + end + + from = contents_lines_size if from > contents_lines_size #if "from" is out of the content top bound + # to = contents_lines_size if to > contents_lines_size #if "to" is out of the content top bound + else + #no expand show the one liner solely + end + end + + if to - from > MAX_LINES && !one_liner #if exceed the MAX_LINES limit correct unless range was produced by one_liner which it expand setting will allow exceeding the line limit + truncated = true + to = from + MAX_LINES - 1 + end + + { + from: from, #calculated from + from_minus_one: from - 1, #used for getting currect ol>li numbering with css used in template + to: to, #calculated to + one_liner: one_liner, #boolean if a one-liner + selected_one_liner: @selected_one_liner, #if a one liner is provided we create a reference for it. + range_provided: range_provided, #boolean if range provided + truncated: truncated + } + end + + #minimize/compact leading indentation while preserving overall indentation + def removeLeadingIndentation(str) + min_space = 100 + a_lines = str.lines + a_lines.each do |l| + l = l.chomp("\n") # remove new line + m = l.match(/^[ ]*/) # find leading spaces 0 or more + unless m.nil? || l.size == m[0].size || l.size == 0 # no match | only spaces in line | empty line + m_str_length = m[0].size + if m_str_length <= 1 # minimum space is 1 or nothing we can break we found our minimum + min_space = m_str_length + break #stop iteration + end + if m_str_length < min_space + min_space = m_str_length + end + else + next # SKIP no match or line is only spaces + end + end + a_lines.each do |l| + re = Regexp.new "^[ ]{#{min_space}}" #match the minimum spaces of the line + l.gsub!(re, "") + end + a_lines.join + end + + def line_number_helper(lines, start, selected) + lines = removeLeadingIndentation(lines.join).lines # A little ineffeicent we could modify removeLeadingIndentation to accept array and return array, but for now it is only working with a string + hash_builder = [] + output_builder = [] + lines.map.with_index { |line, i| + lnum = (i.to_i + start) + hash_builder.push(line_number: lnum, data: line.gsub("\n", ""), selected: (selected == lnum) ? true : false) + output_builder.push "#{lnum}: #{line}" + } + { output: output_builder.join(), array: hash_builder } + end + + def raw + return @raw if defined?(@raw) + + m = @url.match(self.raw_regexp) + + if m + from = /\d+/.match(m[:from]) #get numeric should only match a positive interger + to = /\d+/.match(m[:to]) #get numeric should only match a positive interger + + @file = m[:file] + @lang = Onebox::FileTypeFinder.from_file_name(m[:file]) + + if @lang == "stl" && link.match?(/^https?:\/\/(www\.)?github\.com.*\/blob\//) + @model_file = @lang.dup + @raw = "https://render.githubusercontent.com/view/solid?url=" + self.raw_template(m) + else + contents = URI.open(self.raw_template(m), read_timeout: timeout).read + + contents_lines = contents.lines #get contents lines + contents_lines_size = contents_lines.size #get number of lines + + cr = calc_range(m, contents_lines_size) #calculate the range of lines for output + selected_one_liner = cr[:selected_one_liner] #if url is a one-liner calc_range will return it + from = cr[:from] + to = cr[:to] + @truncated = cr[:truncated] + range_provided = cr[:range_provided] + @cr_results = cr + + if range_provided #if a range provided (single line or more) + if SHOW_LINE_NUMBER + lines_result = line_number_helper(contents_lines[(from - 1)..(to - 1)], from, selected_one_liner) #print code with prefix line numbers in case range provided + contents = lines_result[:output] + @selected_lines_array = lines_result[:array] + else + contents = contents_lines[(from - 1)..(to - 1)].join() + end + + else + contents = contents_lines[(from - 1)..(to - 1)].join() + end + + if contents.length > MAX_CHARS #truncate content chars to limits + contents = contents[0..MAX_CHARS] + @truncated = true + end + + @raw = contents + end + end + end + + def data + @data ||= { + title: title, + link: link, + # IMPORTANT NOTE: All of the other class variables are populated + # as *side effects* of the `raw` method! They must all appear + # AFTER the call to `raw`! Don't get bitten by this like I did! + content: raw, + lang: "lang-#{@lang}", + lines: @selected_lines_array , + has_lines: !@selected_lines_array.nil?, + selected_one_liner: @selected_one_liner, + cr_results: @cr_results, + truncated: @truncated, + model_file: @model_file, + width: 480, + height: 360 + } + end + end + end + end +end diff --git a/lib/onebox/mixins/github_body.rb b/lib/onebox/mixins/github_body.rb new file mode 100644 index 00000000000..22ee13ab3f5 --- /dev/null +++ b/lib/onebox/mixins/github_body.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Onebox + module Mixins + module GithubBody + def self.included(klass) + klass.include(Onebox::Engine) + klass.include(InstanceMethods) + end + + module InstanceMethods + GITHUB_COMMENT_REGEX = /(\r\n)/ + MAX_BODY_LENGTH = 80 + def compute_body(body) + body = body.dup + excerpt = nil + + body = (body || '').gsub(GITHUB_COMMENT_REGEX, '') + body = body.length > 0 ? body : nil + if body && body.length > MAX_BODY_LENGTH + excerpt = body[MAX_BODY_LENGTH..body.length].rstrip + body = body[0..MAX_BODY_LENGTH - 1] + end + + [body, excerpt] + end + end + end + end +end diff --git a/lib/onebox/mixins/twitch_onebox.rb b/lib/onebox/mixins/twitch_onebox.rb new file mode 100644 index 00000000000..ec3bc4a6742 --- /dev/null +++ b/lib/onebox/mixins/twitch_onebox.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Onebox + module Mixins + module TwitchOnebox + def self.included(klass) + klass.include(Onebox::Engine) + klass.matches_regexp(klass.twitch_regexp) + klass.requires_iframe_origins "https://player.twitch.tv" + klass.include(InstanceMethods) + end + + module InstanceMethods + def twitch_id + @url.match(self.class.twitch_regexp)[1] + end + + def base_url + "player.twitch.tv/?" + end + + def placeholder_html + ::Onebox::Helpers.video_placeholder_html + end + + def to_html + <<~HTML + + HTML + end + end + end + end +end diff --git a/lib/onebox/oembed.rb b/lib/onebox/oembed.rb new file mode 100644 index 00000000000..3068a161e23 --- /dev/null +++ b/lib/onebox/oembed.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Onebox + class Oembed < OpenGraph + + def initialize(response) + @data = Onebox::Helpers.symbolize_keys(::MultiJson.load(response)) + + # never use oembed from WordPress 4.4 (it's broken) + data.delete(:html) if data[:html] && data[:html]["wp-embedded-content"] + end + + def html + get(:html, nil, false) + end + end +end diff --git a/lib/onebox/open_graph.rb b/lib/onebox/open_graph.rb new file mode 100644 index 00000000000..f78e3d088da --- /dev/null +++ b/lib/onebox/open_graph.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Onebox + class OpenGraph + + attr_reader :data + + def initialize(doc) + @data = extract(doc) + end + + def title + get(:title, 80) + end + + def title_attr + !title.nil? ? "title='#{title}'" : "" + end + + def secure_image_url + secure_url = URI(get(:image)) + secure_url.scheme = 'https' + secure_url.to_s + end + + def method_missing(attr, *args, &block) + value = get(attr, *args) + + return nil if Onebox::Helpers::blank?(value) + + method_name = attr.to_s + if method_name.end_with?(*integer_suffixes) + value.to_i + elsif method_name.end_with?(*url_suffixes) + result = Onebox::Helpers.normalize_url_for_output(value) + result unless Onebox::Helpers::blank?(result) + else + value + end + end + + def get(attr, length = nil, sanitize = true) + return nil if Onebox::Helpers::blank?(data) + + value = data[attr] + + return nil if Onebox::Helpers::blank?(value) + + value = html_entities.decode(value) + value = Sanitize.fragment(value) if sanitize + value.strip! + value = Onebox::Helpers.truncate(value, length) unless length.nil? + + value + end + + private + + def integer_suffixes + ['width', 'height'] + end + + def url_suffixes + ['url', 'image', 'video'] + end + + def html_entities + @html_entities ||= HTMLEntities.new + end + + def extract(doc) + return {} if Onebox::Helpers::blank?(doc) + + data = {} + + doc.css('meta').each do |m| + if (m["property"] && m["property"][/^(?:og|article|product):(.+)$/i]) || (m["name"] && m["name"][/^(?:og|article|product):(.+)$/i]) + value = (m["content"] || m["value"]).to_s + data[$1.tr('-:', '_').to_sym] ||= value unless Onebox::Helpers::blank?(value) + end + end + + # Attempt to retrieve the title from the meta tag + title_element = doc.at_css('title') + if title_element && title_element.text + data[:title] ||= title_element.text unless Onebox::Helpers.blank?(title_element.text) + end + + data + end + + end +end diff --git a/lib/onebox/preview.rb b/lib/onebox/preview.rb new file mode 100644 index 00000000000..ada48e1b749 --- /dev/null +++ b/lib/onebox/preview.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Onebox + class Preview + # see https://bugs.ruby-lang.org/issues/14688 + client_exception = defined?(Net::HTTPClientException) ? Net::HTTPClientException : Net::HTTPServerException + WEB_EXCEPTIONS ||= [client_exception, OpenURI::HTTPError, Timeout::Error, Net::HTTPError, Errno::ECONNREFUSED] + + def initialize(url, options = Onebox.options) + @url = url + @options = options.dup + + allowed_origins = @options[:allowed_iframe_origins] || Onebox::Engine.all_iframe_origins + @options[:allowed_iframe_regexes] = Engine.origins_to_regexes(allowed_origins) + + @engine_class = Matcher.new(@url, @options).oneboxed + end + + def to_s + return "" unless engine + sanitize process_html engine_html + rescue *WEB_EXCEPTIONS + "" + end + + def placeholder_html + return "" unless engine + sanitize process_html engine.placeholder_html + rescue *WEB_EXCEPTIONS + "" + end + + def errors + return {} unless engine + engine.errors + end + + def data + return {} unless engine + engine.data + end + + def options + OpenStruct.new(@options) + end + + private + + def engine_html + engine.to_html + end + + def process_html(html) + return "" unless html + + if @options[:max_width] + doc = Nokogiri::HTML5::fragment(html) + if doc + doc.css('[width]').each do |e| + width = e['width'].to_i + + if width > @options[:max_width] + height = e['height'].to_i + if (height > 0) + ratio = (height.to_f / width.to_f) + e['height'] = (@options[:max_width] * ratio).floor + end + e['width'] = @options[:max_width] + end + end + return doc.to_html + end + end + + html + end + + def sanitize(html) + config = @options[:sanitize_config] || Sanitize::Config::ONEBOX + config = config.merge(allowed_iframe_regexes: @options[:allowed_iframe_regexes]) + + Sanitize.fragment(html, config) + end + + def engine + return nil unless @engine_class + return @engine if defined?(@engine) + + @engine = @engine_class.new(@url) + @engine.options = @options + @engine + end + + class InvalidURI < StandardError; end + end +end diff --git a/lib/onebox/sanitize_config.rb b/lib/onebox/sanitize_config.rb new file mode 100644 index 00000000000..59cb48e0408 --- /dev/null +++ b/lib/onebox/sanitize_config.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class Sanitize + module Config + + HTTP_PROTOCOLS ||= ['http', 'https', :relative].freeze + + ONEBOX ||= freeze_config merge(RELAXED, + elements: RELAXED[:elements] + %w[audio details embed iframe source video svg path], + + attributes: { + 'a' => RELAXED[:attributes]['a'] + %w(target), + 'audio' => %w[controls controlslist], + 'embed' => %w[height src type width], + 'iframe' => %w[allowfullscreen frameborder height scrolling src width data-original-href data-unsanitized-src], + 'source' => %w[src type], + 'video' => %w[controls height loop width autoplay muted poster controlslist playsinline], + 'path' => %w[d], + 'svg' => ['aria-hidden', 'width', 'height', 'viewbox'], + 'div' => [:data], # any data-* attributes, + 'span' => [:data], # any data-* attributes + }, + + add_attributes: { + 'iframe' => { + 'seamless' => 'seamless', + 'sandbox' => 'allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox' \ + ' allow-presentation', + } + }, + + transformers: (RELAXED[:transformers] || []) + [ + lambda do |env| + next unless env[:node_name] == 'a' + a_tag = env[:node] + a_tag['href'] ||= '#' + if a_tag['href'] =~ %r{^(?:[a-z]+:)?//} + a_tag['rel'] = 'nofollow ugc noopener' + else + a_tag.remove_attribute('target') + end + end, + + lambda do |env| + next unless env[:node_name] == 'iframe' + + iframe = env[:node] + allowed_regexes = env[:config][:allowed_iframe_regexes] || [/.*/] + + allowed = allowed_regexes.any? { |r| iframe["src"] =~ r } + + if !allowed + # add a data attribute with the blocked src. This is not required + # but makes it much easier to troubleshoot onebox issues + iframe["data-unsanitized-src"] = iframe["src"] + iframe.remove_attribute("src") + end + end + ], + + protocols: { + 'embed' => { 'src' => HTTP_PROTOCOLS }, + 'iframe' => { 'src' => HTTP_PROTOCOLS }, + 'source' => { 'src' => HTTP_PROTOCOLS }, + }, + + css: { + properties: RELAXED[:css][:properties] + %w[--aspect-ratio] + } + ) + end +end diff --git a/lib/onebox/status_check.rb b/lib/onebox/status_check.rb new file mode 100644 index 00000000000..73b3bca24e6 --- /dev/null +++ b/lib/onebox/status_check.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Onebox + class StatusCheck + def initialize(url, options = Onebox.options) + @url = url + @options = options + @status = -1 + end + + def ok? + status > 199 && status < 300 + end + + def status + check if @status == -1 + @status + end + + def human_status + case status + when 0 + :connection_error + when 200..299 + :success + when 400..499 + :client_error + when 500..599 + :server_error + else + :unknown_error + end + end + + private + + def check + res = URI.open(@url, read_timeout: (@options.timeout || Onebox.options.timeout)) + @status = res.status.first.to_i + rescue OpenURI::HTTPError => e + @status = e.io.status.first.to_i + rescue Timeout::Error, Errno::ECONNREFUSED, Net::HTTPError + @status = 0 + end + end +end diff --git a/lib/onebox/template_support.rb b/lib/onebox/template_support.rb new file mode 100644 index 00000000000..4e09d41103e --- /dev/null +++ b/lib/onebox/template_support.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Onebox + module TemplateSupport + def load_paths + Onebox.options.load_paths.select(&method(:template?)) + end + + def template?(path) + File.exist?(File.join(path, "#{template_name}.#{template_extension}")) + end + end +end diff --git a/lib/onebox/templates/_layout.mustache b/lib/onebox/templates/_layout.mustache new file mode 100644 index 00000000000..9075319f891 --- /dev/null +++ b/lib/onebox/templates/_layout.mustache @@ -0,0 +1,25 @@ + diff --git a/lib/onebox/templates/allowlistedgeneric.mustache b/lib/onebox/templates/allowlistedgeneric.mustache new file mode 100644 index 00000000000..abc700be933 --- /dev/null +++ b/lib/onebox/templates/allowlistedgeneric.mustache @@ -0,0 +1,16 @@ +{{#image}}{{/image}} + +

{{title}}

+ +{{#description}} +

{{description}}

+{{/description}} + +{{#data_1}} +

+ {{label_1}}: {{data_1}} + {{#data_2}} + {{label_2}}: {{data_2}} + {{/data_2}} +

+{{/data_1}} diff --git a/lib/onebox/templates/amazon.mustache b/lib/onebox/templates/amazon.mustache new file mode 100644 index 00000000000..e5a3c61703d --- /dev/null +++ b/lib/onebox/templates/amazon.mustache @@ -0,0 +1,15 @@ +{{#image}}{{/image}} + +

{{title}}

+ +{{#by_info}}{{by_info}}{{/by_info}} + +

{{description}}

+ +

+ {{#rating}}{{rating}}{{/rating}} + {{#isbn_asin}}{{isbn_asin_text}}: {{isbn_asin}}, {{/isbn_asin}} + {{#publisher}}{{publisher}}, {{/publisher}} + {{#published}}{{published}}{{/published}} + {{#price}}{{price}}{{/price}} +

diff --git a/lib/onebox/templates/github/github_body.mustache b/lib/onebox/templates/github/github_body.mustache new file mode 100644 index 00000000000..dae9ba142e0 --- /dev/null +++ b/lib/onebox/templates/github/github_body.mustache @@ -0,0 +1,5 @@ +{{#body}} +
+

{{body}}{{#excerpt}}{{/excerpt}}{{#excerpt}}{{/excerpt}}

+
+{{/body}} diff --git a/lib/onebox/templates/githubblob.mustache b/lib/onebox/templates/githubblob.mustache new file mode 100644 index 00000000000..810c9ce6622 --- /dev/null +++ b/lib/onebox/templates/githubblob.mustache @@ -0,0 +1,54 @@ +

{{title}}

+ +{{^has_lines}} + {{#model_file}} + + {{/model_file}} + + {{^model_file}} +
{{content}}
+ {{/model_file}} +{{/has_lines}} + +{{#has_lines}} + {{! This is a template comment | Sample rules for this box + + }} + +
+    
+      
    + {{#lines}} + {{data}} + {{/lines}} +
+
+
+{{/has_lines}} + +{{#truncated}} + This file has been truncated. show original +{{/truncated}} diff --git a/lib/onebox/templates/githubcommit.mustache b/lib/onebox/templates/githubcommit.mustache new file mode 100644 index 00000000000..b9d15cb76f0 --- /dev/null +++ b/lib/onebox/templates/githubcommit.mustache @@ -0,0 +1,33 @@ +
+
+ +
+ +
+

+ {{title}} +

+ +
+
+ committed {{committed_at}} +
+ + + + +
+
+
+ +{{> github/github_body}} diff --git a/lib/onebox/templates/githubfolder.mustache b/lib/onebox/templates/githubfolder.mustache new file mode 100644 index 00000000000..cac5d382ca0 --- /dev/null +++ b/lib/onebox/templates/githubfolder.mustache @@ -0,0 +1,11 @@ +{{#image}}{{/image}} + +

{{title}}

+ +{{#path}} +

{{path}}

+{{/path}} + +{{#description}} +

{{description}}

+{{/description}} diff --git a/lib/onebox/templates/githubgist.mustache b/lib/onebox/templates/githubgist.mustache new file mode 100644 index 00000000000..eaad9530081 --- /dev/null +++ b/lib/onebox/templates/githubgist.mustache @@ -0,0 +1,15 @@ +

{{link}}

+ +{{#gist_files}} +
{{filename}}
+
{{content}}
+ {{#truncated?}} + This file has been truncated. show original + {{/truncated?}} +{{/gist_files}} + +

+ {{#truncated_files?}} + There are more than three files. show original + {{/truncated_files?}} +

diff --git a/lib/onebox/templates/githubissue.mustache b/lib/onebox/templates/githubissue.mustache new file mode 100644 index 00000000000..f8766736b34 --- /dev/null +++ b/lib/onebox/templates/githubissue.mustache @@ -0,0 +1,38 @@ +
+
+ +
+ +
+

+ {{title}} +

+ +
+
+ opened {{created_at}} +
+ + {{#closed_at}} +
+ closed {{closed_at}} +
+ {{/closed_at}} + + +
+ +
+ {{#labels}} + {{name}} + {{/labels}} +
+
+
+ +{{> github/github_body}} diff --git a/lib/onebox/templates/githubpullrequest.mustache b/lib/onebox/templates/githubpullrequest.mustache new file mode 100644 index 00000000000..81eabb41afe --- /dev/null +++ b/lib/onebox/templates/githubpullrequest.mustache @@ -0,0 +1,37 @@ +
+
+ +
+ +
+

+ {{title}} +

+ +
+ {{base.label}}{{head.label}} +
+ +
+
+ opened {{created_at}} +
+ + + + +
+
+
+ +{{> github/github_body}} diff --git a/lib/onebox/templates/gitlabblob.mustache b/lib/onebox/templates/gitlabblob.mustache new file mode 100644 index 00000000000..a24ffdfb4c6 --- /dev/null +++ b/lib/onebox/templates/gitlabblob.mustache @@ -0,0 +1,21 @@ +

{{title}}

+ +{{^has_lines}} +
{{content}}
+{{/has_lines}} + +{{#has_lines}} +
+    
+      
    + {{#lines}} + {{data}} + {{/lines}} +
+
+
+{{/has_lines}} + +{{#truncated}} + This file has been truncated. show original +{{/truncated}} diff --git a/lib/onebox/templates/googledocs.mustache b/lib/onebox/templates/googledocs.mustache new file mode 100644 index 00000000000..bc0f598a57a --- /dev/null +++ b/lib/onebox/templates/googledocs.mustache @@ -0,0 +1,5 @@ + + +

{{title}}

+ +

{{description}}

diff --git a/lib/onebox/templates/googledrive.mustache b/lib/onebox/templates/googledrive.mustache new file mode 100644 index 00000000000..09159143b74 --- /dev/null +++ b/lib/onebox/templates/googledrive.mustache @@ -0,0 +1,9 @@ +{{^image}} + +{{/image}} + +{{#image}}{{/image}} + +

{{title}}

+ +

{{description}}

diff --git a/lib/onebox/templates/googleplayapp.mustache b/lib/onebox/templates/googleplayapp.mustache new file mode 100644 index 00000000000..5958430bc25 --- /dev/null +++ b/lib/onebox/templates/googleplayapp.mustache @@ -0,0 +1,5 @@ +

{{title}}

+ + +

{{description}}

+{{price}} diff --git a/lib/onebox/templates/instagram.mustache b/lib/onebox/templates/instagram.mustache new file mode 100644 index 00000000000..3f933b7cbfa --- /dev/null +++ b/lib/onebox/templates/instagram.mustache @@ -0,0 +1,13 @@ +

{{title}}

+ +{{#image}} +
+ + + +
+{{/image}} + +{{#description}} +
{{description}}
+{{/description}} diff --git a/lib/onebox/templates/pastebin.mustache b/lib/onebox/templates/pastebin.mustache new file mode 100644 index 00000000000..236b8d7b0a3 --- /dev/null +++ b/lib/onebox/templates/pastebin.mustache @@ -0,0 +1,7 @@ +

{{link}}

+ +
{{content}}
+ +{{#truncated?}} + This paste has been truncated. show original +{{/truncated?}} diff --git a/lib/onebox/templates/pdf.mustache b/lib/onebox/templates/pdf.mustache new file mode 100644 index 00000000000..cc0b9490720 --- /dev/null +++ b/lib/onebox/templates/pdf.mustache @@ -0,0 +1,7 @@ + + +

{{title}}

+ +{{#filesize}} +

{{filesize}}

+{{/filesize}} diff --git a/lib/onebox/templates/pubmed.mustache b/lib/onebox/templates/pubmed.mustache new file mode 100644 index 00000000000..72a593e3b49 --- /dev/null +++ b/lib/onebox/templates/pubmed.mustache @@ -0,0 +1,12 @@ +

+ {{title}} +

+ +
+ {{authors}}, + {{journal}}, {{date}} +
+ +

+ {{abstract}} +

diff --git a/lib/onebox/templates/stackexchange.mustache b/lib/onebox/templates/stackexchange.mustache new file mode 100644 index 00000000000..59bf8812449 --- /dev/null +++ b/lib/onebox/templates/stackexchange.mustache @@ -0,0 +1,22 @@ +{{#owner.profile_image}} + + {{owner.display_name}} + +{{/owner.profile_image}} + +

+ {{{title}}} +

+ +
+ {{tags}} +
+ +
+ {{#is_question}}asked by{{/is_question}} + {{#is_answer}}answered by{{/is_answer}} + + {{owner.display_name}} + + on {{creation_date}} +
diff --git a/lib/onebox/templates/twitterstatus.mustache b/lib/onebox/templates/twitterstatus.mustache new file mode 100644 index 00000000000..67f1b60c4ea --- /dev/null +++ b/lib/onebox/templates/twitterstatus.mustache @@ -0,0 +1,38 @@ +{{#avatar}}{{/avatar}} + +

{{title}}

+ +
+ {{{tweet}}} + {{#quoted_tweet}} + + {{/quoted_tweet}} +
+ +
+ {{timestamp}} + + {{#likes}} + + {{/likes}} + + {{#retweets}} + + + {{retweets}} + + {{/retweets}} +
diff --git a/lib/onebox/templates/wikimedia.mustache b/lib/onebox/templates/wikimedia.mustache new file mode 100644 index 00000000000..4b1ab487c0e --- /dev/null +++ b/lib/onebox/templates/wikimedia.mustache @@ -0,0 +1,3 @@ +{{#image}}{{/image}} + +

{{title}}

diff --git a/lib/onebox/templates/wikipedia.mustache b/lib/onebox/templates/wikipedia.mustache new file mode 100644 index 00000000000..8e8905fd732 --- /dev/null +++ b/lib/onebox/templates/wikipedia.mustache @@ -0,0 +1,5 @@ +{{#image}}{{/image}} + +

{{title}}

+ +

{{description}}

diff --git a/lib/onebox/templates/xkcd.mustache b/lib/onebox/templates/xkcd.mustache new file mode 100644 index 00000000000..1649b60976a --- /dev/null +++ b/lib/onebox/templates/xkcd.mustache @@ -0,0 +1,7 @@ +

{{title}}

+ +{{#image}} +
+{{/image}} + +

{{description}}

diff --git a/lib/onebox/view.rb b/lib/onebox/view.rb new file mode 100644 index 00000000000..e50e7c17f12 --- /dev/null +++ b/lib/onebox/view.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "template_support" + +module Onebox + class View < Mustache + include TemplateSupport + + attr_reader :record + + def initialize(name, record) + @record = record + self.template_name = name + self.template_path = load_paths.last + end + + def to_html + render(record) + end + end +end diff --git a/plugins/lazy-yt/plugin.rb b/plugins/lazy-yt/plugin.rb index c2756080b29..04d14d413e7 100644 --- a/plugins/lazy-yt/plugin.rb +++ b/plugins/lazy-yt/plugin.rb @@ -8,6 +8,8 @@ hide_plugin if self.respond_to?(:hide_plugin) +require "onebox" + # javascript register_asset "javascripts/lazyYT.js" diff --git a/spec/components/onebox/engine/allowlisted_generic_onebox_spec.rb b/spec/components/onebox/engine/allowlisted_generic_onebox_spec.rb deleted file mode 100644 index 1e9ac4c2b8b..00000000000 --- a/spec/components/onebox/engine/allowlisted_generic_onebox_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' -require 'oneboxer' - -describe Onebox::Engine::AllowlistedGenericOnebox do - - describe ".===" do - - it "matches any domain" do - expect(described_class === URI('http://foo.bar/resource')).to be(true) - end - - it "doesn't match an IP address" do - expect(described_class === URI('http://1.2.3.4/resource')).to be(false) - expect(described_class === URI('http://1.2.3.4:1234/resource')).to be(false) - end - - end - -end diff --git a/spec/fixtures/onebox/amazon-ebook.response b/spec/fixtures/onebox/amazon-ebook.response new file mode 100644 index 00000000000..68e327960e7 --- /dev/null +++ b/spec/fixtures/onebox/amazon-ebook.response @@ -0,0 +1,6170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers) 1, Bruce Tate, eBook - Amazon.com + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers) by [Tate, Bruce] +
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+ + +
+
+
+1st Edition +
+ + +
+ +
+
+
+
+
+ISBN-13: + 978-1934356593 +
+
+
+
+
+
+
+
+
+
+ +Print List Price: + +
+
+ +$34.95 + +
+
+
+
+ +Kindle Price: + +
+
+ +$25.00 + +
+
+
+
+ +You Save: + +
+
+ +$9.95 (28%) + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ + + + +
+
+

+Select Format +

+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ +Kindle price includes VAT + +
+ + + + + + + +
+
+
+ +
+ +
+
+
+

Deliver to your Kindle or other device

+
+ + +
+ +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+
+ +
+ +
+ +
+ + +
+ + +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ + + + + +
+ +
+ +
+
+Share this product with friends +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+

+Kindle Feature Spotlight +

+
+ + + + + +
+
+
+
+Page Flip + +
+
+ +
+ + +
+
+
+
+
+
+
+Notes & Highlights + +
+
+ +
+ + +
+
+
+
+
+
+
+Adjustable Type + +
+
+ +
+ + +
+
+
+
+
+
+
+Sharing + +
+
+ +
+ + +
+
+
+
+
+
+
+X-Ray for Textbooks + +
+
+ +
+ + +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+

About the Kindle Edition

+
+ +
+
+

Length: 330 pages

+Based on the print edition (ISBN 9781934356593). +
+
+
+

Screen Reader: Supported

+ +The text of this eBook can be read by many popular screen readers: VoiceView on Fire Tablets and Kindle E-readers, VoiceOver on iOS, TalkBack on Android, and NVDA on Windows. Descriptive text for images (known as “ALT text”) can be read using the Kindle for PC app if the publisher has included it. If this eBook contains other types of non-text content (for example, some charts and math equations), that content will not currently be read by screen readers. See the Kindle Accessibility page to learn more + +
+
+
+

Enhanced Typesetting: Enabled

+Enhanced typesetting improvements offer faster reading with less eye strain and beautiful page layouts, even at larger font sizes. +
+
+
+

Page Flip: Enabled

+ +Page Flip is a new way to explore your books without losing your place. + +
+
+ +
+
+

Text to Speech: Enabled

+Text-to-Speech is available for Kindle Touch, Kindle Keyboard, Kindle (2nd generation), and Kindle DX. +
+
+
+
+
+
+
+

About this item +

+
+
+
+

From the manufacturer

+
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+Seven Languages in Seven Weeks + +Seven More Languages in Seven Weeks + +Seven Databases in Seven Weeks + +Seven Web Frameworks in Seven Weeks + +Seven Concurrency Models in Seven Weeks + +Seven Mobile Apps in Seven Weeks +
+Seven Languages in Seven Weeks + +Seven More Languages in Seven Weeks + +Seven Databases in Seven Weeks + +Seven Web Frameworks in Seven Weeks + +Seven Concurrency Models in Seven Weeks + +Seven Mobile Apps in Seven Weeks +
+ +Subtitle + + + +A Pragmatic Guide to Learning Programming Languages + + + +Languages That Are Shaping the Future + + + +A Guide to Modern Databases and the NoSQL Movement + + + +Adventures in Better Web Apps + + + +When Threads Unravel + + + +Native Apps, Multiple Platforms + +
+ +Content Coverage + + + +Clojure, Haskell, Io, Prolog, Scala, Erlang, and Ruby + + + +Lua, Factor, Elixir, Elm, Julia, MiniKanren, and Idris + + + +Redis, Neo4J, CouchDB, MongoDB, HBase, Riak and Postgres + + + +Sinatra, CanJS, AngularJS, Ring, Webmachine, Yesod, and Immutant + + + +Threads & locks, functional programming, separating identity & state, actors, sequential processes, data parallelism, and the lambda architecture + + + +iOS, Android, Windows, RubyMotion, React Native, and Xamarin + +
+ +Pages + + + +328 pages + + + +320 pages + + + +354 pages + + + +304 pages + + + +300 pages + + + +360 pages + +
+
+
+
+
+
+
+
+
+
+
+ +
+

+Description +

+
+

+You should learn a programming language every year, as recommended by The Pragmatic Programmer. But if one per year is good, how about Seven Languages in Seven Weeks? In this book you'll get a hands-o ... +

+
+
+
+ +
+

+Description +

+
+

Product Description

+
+

You should learn a programming language every year, as recommended by The Pragmatic Programmer. But if one per year is good, how about Seven Languages in Seven Weeks? In this book you'll get a hands-on tour of Clojure, Haskell, Io, Prolog, Scala, Erlang, and Ruby. Whether or not your favorite language is on that list, you'll broaden your perspective of programming by examining these languages side-by-side. You'll learn something new from each, and best of all, you'll learn how to learn a language quickly.

Ruby, Io, Prolog, Scala, Erlang, Clojure, Haskell. With Seven Languages in Seven Weeks, by Bruce A. Tate, you'll go beyond the syntax-and beyond the 20-minute tutorial you'll find someplace online. This book has an audacious goal: to present a meaningful exploration of seven languages within a single book. Rather than serve as a complete reference or installation guide, Seven Languages hits what's essential and unique about each language. Moreover, this approach will help teach you how to grok new languages.

For each language, you'll solve a nontrivial problem, using techniques that show off the language's most important features. As the book proceeds, you'll discover the strengths and weaknesses of the languages, while dissecting the process of learning languages quickly--for example, finding the typing and programming models, decision structures, and how you interact with them.

Among this group of seven, you'll explore the most critical programming models of our time. Learn the dynamic typing that makes Ruby, Python, and Perl so flexible and compelling. Understand the underlying prototype system that's at the heart of JavaScript. See how pattern matching in Prolog shaped the development of Scala and Erlang. Discover how pure functional programming in Haskell is different from the Lisp family of languages, including Clojure.

Explore the concurrency techniques that are quickly becoming the backbone of a new generation of Internet applications. Find out how to use Erlang's let-it-crash philosophy for building fault-tolerant systems. Understand the actor model that drives concurrency design in Io and Scala. Learn how Clojure uses versioning to solve some of the most difficult concurrency problems.

It's all here, all in one place. Use the concepts from one language to find creative solutions in another-or discover a language that may become one of your favorites.

+
+

Review

+
+

""I have been programming for 25 years in a variety of hardware and software languages. After reading Seven Languages in Seven Weeks, I am starting to understand how to evaluate languages for their objective strengths and weaknesses. More importantly, I feel as if I could pick one of them to actually get some work done.""--Chris Kappler, Senior scientist Raytheon, BBN Technologies

""I spent most of my time as a computer sciences student saying I didn't want to be a software developer and then became one anyway. Seven Languages in Seven Weeks expanded my way of thinking about problems and reminded me what I love about programming.""--Travis Kaspar, Software engineer, Northrop Grumman

""Do you want seven kick starts into learning your "language of the year"? Do you want your thinking challenged about programming in general? Look no further than this book. I personally was taken back in time to my undergraduate computer science days, coasting through my programming languages survey course. The difference is that Bruce won't let you coast through this course! This isn't a leisurely read--you'll have to work this book. I believe you'll find it both mindblowing and intensely practical at the same time.""--Matt Stine Group leader, Research Application Development, St. Jude Children's Research Hospital

+
+

About the Author

+
+

Bruce Tate runs RapidRed, an Austin, TX-based practice that consults on lightweight development in Ruby. Previously he worked at IBM in roles ranging from a database systems programmer to Java consultant. He left IBM to work for several startups in roles ranging from Client Solutions Director to CTO. He speaks internationally and is the author of more than ten books, including From Java to Ruby, Deploying Rails Applications, the best-selling Bitter series, Beyond Java, and the Jolt-winning Better, Faster, Lighter Java.

+
+
+
+
+
+
+
+
+
+ +
+

+Features & details +

+
+ +
+
    +
  • + +Publication date: + +November 10, 2010 +
  • +
  • + +Publisher: + +Pragmatic Bookshelf +
  • +
  • + +Language: + +English +
  • +
+
+
+
+
+ +
+

+About this item +

+
+

+Product Details +

+ +
+
    +
  • + +Publication date: + +November 10, 2010 +
  • +
  • + +Publisher: + +Pragmatic Bookshelf +
  • +
  • + +Language: + +English +
  • +
  • + +ASIN: + +B00AYQNR46 +
  • +
  • + +Amazon.com Sales Rank: + +375493 +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + +

Customer Reviews

58 customer reviews
4.2 out of 5 stars4.2 out of 5 stars
+
+
+
+
+ +Rated by customers interested in + +
+ +
+
+ + +
+ +
+
+ +

Top reviews

+
See all 58 reviews
Write a review
+
+
+
+
+
+
+
+
+ +
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
\ No newline at end of file diff --git a/spec/fixtures/onebox/amazon-og.response b/spec/fixtures/onebox/amazon-og.response new file mode 100644 index 00000000000..0b16d9c6a18 --- /dev/null +++ b/spec/fixtures/onebox/amazon-og.response @@ -0,0 +1,3675 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Amazon.com: Christine: Rebecca Hall, Michael C. Hall, Antonio Campos, Craig Shilowich: Amazon Digital Services LLC + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Amazon Vehicles + + + + + +Beauty + + + + + +Best Books of the Month + + + + + +STEM + + + + + +nav_sap_plcc_ascpsc + + + + + + + + + + + +Electronics Dads and Grads Gift Guide + + + + + +Starting at $39.99 + + + + + +Wickedly Prime + + + + + +Handmade Wedding Shop + + + + + +Home Gift Guide +Father's Day Gifts +Home Gift Guide + + + + + +Shop Popular Services + + + + + +ALongStrangeTrip +ALongStrangeTrip +ALongStrangeTrip + + + + + + Introducing Echo Show + + + + + +All-New Fire 7, starting at $49.99 + + + + + +Kindle Oasis + + + + + +AutoRip in CDs & Vinyl + + + + + +Shop Now + + + + + +toystl17_gno + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + +

+ Christine + 2017 + +

+ + + + + + + + + + + + + + + R + CC + + +
+ + + + + + +
+ + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + 3.5 out of 5 stars + + + + (84) + + + + + + + + + + + + + + + + + + + + + IMDb + 7/10 + +
+
+ + + + + + +
+
+
+ +
+ +
+ + + +
+
+
+ + +
+
+ + + + + + +
+ + +

When renting, you have 30 days to start watching this video, and 48 hours to finish once started.

+ + +
+ + + +
Rent Movie HD $4.99
+
+
+ + + + +
+ + + + +
+ + + +
Buy Movie HD $12.99
+
+
+ + + + +
+ +
+ + + + + + + + + + + + +
+ +
+ +
+

+ Rent +

+ + +

When renting, you have 30 days to start watching this video, and 48 hours to finish once started.

+ + +
+ + + +
Rent Movie HD $4.99
+
+
+ + + + +
+ + + + +
+ + + +
Rent Movie SD $3.99
+
+
+ + + + +
+ +
+ + +
+

+ Buy +

+ + + + +
+ + + +
Buy Movie HD $12.99
+
+
+ + + + +
+ + + + +
+ + + +
Buy Movie SD $9.99
+
+
+ + + + +
+ +
+ + + + + + + + + +
+ +
+ + + + + More Purchase Options + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ +
+
+ + + + + + + + + + + + + + + +
+ +
+ +
+

CHRISTINE is the story of an aspiring newswoman caught in the midst of a personal and professional life crisis. Between unrequited love, frustration at work, a tumultuous home, and self-doubt; she begins to spiral down a dark path.

+
+
+
Starring:
+
Rebecca Hall, Michael C. Hall
+
Runtime:
+
1 hour, 59 minutes
+
+

Available to watch on supported devices.

+
+ + +
+ + + +
+ + + +
+
+
+ +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + +
+
+ By placing your order or playing a video, you agree to our Terms of Use. Sold by Amazon Digital Services LLC. Additional taxes may apply. +
+
+ + + + + +
+
+
+ +
+ + + + +
+ +
+
+
+ +
+ + + + + + + + + +
+
+ +

+ Product details +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Genres + + Drama + +
+ Director + + Antonio Campos + +
+ Starring + + Rebecca Hall, Michael C. Hall + +
+ Studio + + The Orchard + +
+ MPAA rating + + R (Restricted) + +
+ Captions and subtitles + + English + + + + Details + + + + +
+ Purchase rights + + Stream instantly + + + + Details + + + + +
+ Format + + Amazon Video (streaming online video) + +
+ +
+
+ + + + + + + + + + + + + + + + + +
+
+ +

+ Other formats +

+ + +
+
+ + + + + + + + + + + +
+ +
+
+
+
+ + +

Customer Reviews

Top Customer Reviews

on March 2, 2017
Format: Amazon Video|Verified Purchase
33 comments| + 13 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
on January 26, 2017
Format: Amazon Video|Verified Purchase
0Comment| + 13 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
on May 12, 2017
Format: Amazon Video|Verified Purchase
0Comment| + One person found this helpful. + + Was this review helpful to you?YesNoReport abuse
on January 26, 2017
Format: Amazon Video|Verified Purchase
0Comment| + 2 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
on February 26, 2017
Format: Amazon Video|Verified Purchase
0Comment| + 3 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
on March 6, 2017
Format: Amazon Video|Verified Purchase
0Comment| + 2 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
on February 25, 2017
Format: Amazon Video|Verified Purchase
0Comment| + 2 people found this helpful. + + Was this review helpful to you?YesNoReport abuse
on February 23, 2017
Format: Amazon Video|Verified Purchase
0Comment| + One person found this helpful. + + Was this review helpful to you?YesNoReport abuse

Most Recent Customer Reviews

+
+
+ +
+
+
+ + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/spec/fixtures/onebox/amazon.response b/spec/fixtures/onebox/amazon.response new file mode 100644 index 00000000000..42d25aece7b --- /dev/null +++ b/spec/fixtures/onebox/amazon.response @@ -0,0 +1,3773 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers): Bruce Tate: 8601234653110: Amazon.com: Books + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+

Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers) + +

+
+
+
+
+
+1st Edition +
+
+
+ISBN-13: + 978-1934356593, +ISBN-10: + 193435659X +
+
+ +
+ +
+
+
+
+ + + +
+
+
+
+ +
    +
  • +
  • +
+
+
+ +
+ + +
Double-tap to zoom
+ +
+
+
+ +
+
+ + + + +
+
+

+Select Format +

+ +
+ +
+
+
+
+ +$ + + +21 + + +11 + +
+
+
+ + + + + + +
+
+ +Save $13.84 (40%) + +
+
+
+
+
+ + +
+
+ + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Save an extra $1.29 at checkout. +
+
+
+
+
+
+
+
+
+
+
+
+ +In Stock. + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +This item ships to Germany. Want it Monday, Feb. 19? Order within 4 hrs 42 mins and choose AmazonGlobal Priority Shipping at checkout. + + +
+ + + +
+
+
+
+Ships from and sold by Amazon.com. +Gift-wrap available. +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+FREE Shipping on orders over $25 +
+
+
+ + +
+
+
+ +Sold by Mayon Collectibles and fulfilled by Amazon. + +
+
+ +Access codes and supplements are not guaranteed with used items. + +
+
+ +
+
+ + + +Ship to: + + +Germany + + +
+
+
+ +To see addresses, please + +
+
+ +
+
or
+
+ +Use this location: + +
+
+
+
+
+ +
+
+
+ +Please enter a valid US zip code. + +
+ + +
+
or
+
+ +
+
+
+
+
+ +
+ + +
+
+
+
+ + + + + + +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + +Ship to: + + +Germany + + +
+
+ + +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+ + +
+ + +
+
+
+
+ +
+
+
+ +
+ + + + + + + + + + + +
+ +
+
+Share this product with friends +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+
+
+
+

+Frequently bought together +

+
+
+
Choose items to buy together.
+
    +
  • Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers)
  • +
  • +Seven More Languages in Seven Weeks: Languages That Are Shaping the Future +
  • +
  • +Seven Databases in Seven Weeks: A Guide to Modern Databases and the NoSQL Movement +
  • +
+
+
+ +
+ +
+
+

Frequently bought together

+
+
+
+
Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers)
+
+ +
+
+
+$21.11 +
Paperback
+
FREE Shipping on orders over $25. Details
+
In Stock.
+
Ships from and sold by Amazon.com.
+
+
+
+
Seven More Languages in Seven Weeks: Languages That Are Shaping the Future
+
+ +
+
+
+$28.54 +
Paperback
+
FREE Shipping. Details
+
Only 16 left in stock (more on the way).
+
Ships from and sold by Amazon.com.
+
+
+
+
Seven Databases in Seven Weeks: A Guide to Modern Databases and the NoSQL Movement
+
+ +
+
+
+$26.28 +
Paperback
+
FREE Shipping. Details
+
Only 12 left in stock (more on the way).
+
Ships from and sold by Amazon.com.
+
+
+
+
+
+
+
+
+ +
+
+
+

About this item +

+
+
+
+

From the manufacturer

+
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+Seven Languages in Seven Weeks + +Seven More Languages in Seven Weeks + +Seven Databases in Seven Weeks + +Seven Web Frameworks in Seven Weeks + +Seven Concurrency Models in Seven Weeks + +Seven Mobile Apps in Seven Weeks +
+Seven Languages in Seven Weeks + +Seven More Languages in Seven Weeks + +Seven Databases in Seven Weeks + +Seven Web Frameworks in Seven Weeks + +Seven Concurrency Models in Seven Weeks + +Seven Mobile Apps in Seven Weeks +
+ +Subtitle + + + +A Pragmatic Guide to Learning Programming Languages + + + +Languages That Are Shaping the Future + + + +A Guide to Modern Databases and the NoSQL Movement + + + +Adventures in Better Web Apps + + + +When Threads Unravel + + + +Native Apps, Multiple Platforms + +
+ +Content Coverage + + + +Clojure, Haskell, Io, Prolog, Scala, Erlang, and Ruby + + + +Lua, Factor, Elixir, Elm, Julia, MiniKanren, and Idris + + + +Redis, Neo4J, CouchDB, MongoDB, HBase, Riak and Postgres + + + +Sinatra, CanJS, AngularJS, Ring, Webmachine, Yesod, and Immutant + + + +Threads & locks, functional programming, separating identity & state, actors, sequential processes, data parallelism, and the lambda architecture + + + +iOS, Android, Windows, RubyMotion, React Native, and Xamarin + +
+ +Pages + + + +328 pages + + + +320 pages + + + +354 pages + + + +304 pages + + + +300 pages + + + +360 pages + +
+
+
+
+
+
+
+
+
+
+
+ +
+

+Description +

+
+

+You should learn a programming language every year, as recommended by The Pragmatic Programmer. But if one per year is good, how about Seven Languages in Seven Weeks? In this book you'll get a hands-o ... +

+
+
+
+ +
+

+Description +

+
+

Product description

+
+

+

You should learn a programming language every year, as recommended by The Pragmatic Programmer. But if one per year is good, how about Seven Languages in Seven Weeks? In this book you'll get a hands-on tour of Clojure, Haskell, Io, Prolog, Scala, Erlang, and Ruby. Whether or not your favorite language is on that list, you'll broaden your perspective of programming by examining these languages side-by-side. You'll learn something new from each, and best of all, you'll learn how to learn a language quickly.

Ruby, Io, Prolog, Scala, Erlang, Clojure, Haskell. With Seven Languages in Seven Weeks, by Bruce A. Tate, you'll go beyond the syntax-and beyond the 20-minute tutorial you'll find someplace online. This book has an audacious goal: to present a meaningful exploration of seven languages within a single book. Rather than serve as a complete reference or installation guide, Seven Languages hits what's essential and unique about each language. Moreover, this approach will help teach you how to grok new languages.

For each language, you'll solve a nontrivial problem, using techniques that show off the language's most important features. As the book proceeds, you'll discover the strengths and weaknesses of the languages, while dissecting the process of learning languages quickly--for example, finding the typing and programming models, decision structures, and how you interact with them.

Among this group of seven, you'll explore the most critical programming models of our time. Learn the dynamic typing that makes Ruby, Python, and Perl so flexible and compelling. Understand the underlying prototype system that's at the heart of JavaScript. See how pattern matching in Prolog shaped the development of Scala and Erlang. Discover how pure functional programming in Haskell is different from the Lisp family of languages, including Clojure.

Explore the concurrency techniques that are quickly becoming the backbone of a new generation of Internet applications. Find out how to use Erlang's let-it-crash philosophy for building fault-tolerant systems. Understand the actor model that drives concurrency design in Io and Scala. Learn how Clojure uses versioning to solve some of the most difficult concurrency problems.

It's all here, all in one place. Use the concepts from one language to find creative solutions in another-or discover a language that may become one of your favorites.

+

+
+

Review

+
+

+

""I have been programming for 25 years in a variety of hardware and software languages. After reading Seven Languages in Seven Weeks, I am starting to understand how to evaluate languages for their objective strengths and weaknesses. More importantly, I feel as if I could pick one of them to actually get some work done.""--Chris Kappler, Senior scientist Raytheon, BBN Technologies

+

""I spent most of my time as a computer sciences student saying I didn't want to be a software developer and then became one anyway. Seven Languages in Seven Weeks expanded my way of thinking about problems and reminded me what I love about programming.""--Travis Kaspar, Software engineer, Northrop Grumman

+

""Do you want seven kick starts into learning your "language of the year"? Do you want your thinking challenged about programming in general? Look no further than this book. I personally was taken back in time to my undergraduate computer science days, coasting through my programming languages survey course. The difference is that Bruce won't let you coast through this course! This isn't a leisurely read--you'll have to work this book. I believe you'll find it both mindblowing and intensely practical at the same time.""--Matt Stine Group leader, Research Application Development, St. Jude Children's Research Hospital

+

+
+

About the Author

+
+

+

+

Bruce Tate runs RapidRed, an Austin, TX-based practice that consults on lightweight development in Ruby. Previously he worked at IBM in roles ranging from a database systems programmer to Java consultant. He left IBM to work for several startups in roles ranging from Client Solutions Director to CTO. He speaks internationally and is the author of more than ten books, including From Java to Ruby, Deploying Rails Applications, the best-selling Bitter series, Beyond Java, and the Jolt-winning Better, Faster, Lighter Java.

+
+

+
+
+
+
+
+
+
+
+
+ +
+

+Features & details +

+
+

+Product information + + +

+
+

+ + + + + + + + + + + + + + +
+
+
+
+ +
+

+About this item +

+
+

+Product information + + +

+
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+
+
+ +Pricing: + + +Savings are based on the strikethrough price. This is either the previous Amazon price or the + +List Price. + + +
+
+
+
+
+
+
+
+ + +

Customer Reviews

58 customer reviews
4.2 out of 5 stars4.2 out of 5 stars
+
+
+
+
+ +Rated by customers interested in + +
+ +
+
+ + +
+ +
+
+ +

Top reviews

+
See all 58 reviews
Write a review
+
+
+
+
+
+
+ +
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
\ No newline at end of file diff --git a/spec/fixtures/onebox/cloudapp-gif.response b/spec/fixtures/onebox/cloudapp-gif.response new file mode 100644 index 00000000000..a24ad59906d --- /dev/null +++ b/spec/fixtures/onebox/cloudapp-gif.response @@ -0,0 +1,66 @@ + + + + + giphy.gif + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/spec/fixtures/onebox/cloudapp-jpg.response b/spec/fixtures/onebox/cloudapp-jpg.response new file mode 100644 index 00000000000..3718a31abb0 --- /dev/null +++ b/spec/fixtures/onebox/cloudapp-jpg.response @@ -0,0 +1,66 @@ + + + + + Image 2016-11-27 at 10.47.21 PM.jpg + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/spec/fixtures/onebox/cloudapp-mp4.response b/spec/fixtures/onebox/cloudapp-mp4.response new file mode 100644 index 00000000000..2a4d889691c --- /dev/null +++ b/spec/fixtures/onebox/cloudapp-mp4.response @@ -0,0 +1,65 @@ + + + + + click-link.mp4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/spec/fixtures/onebox/cloudapp-others.response b/spec/fixtures/onebox/cloudapp-others.response new file mode 100644 index 00000000000..f9f39145471 --- /dev/null +++ b/spec/fixtures/onebox/cloudapp-others.response @@ -0,0 +1,66 @@ + + + + + + sample.pdf + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/spec/fixtures/onebox/cnn.response b/spec/fixtures/onebox/cnn.response new file mode 100644 index 00000000000..3c0e90f64f5 --- /dev/null +++ b/spec/fixtures/onebox/cnn.response @@ -0,0 +1,4 @@ +People are fostering and adopting pets during the pandemic + + + diff --git a/spec/fixtures/onebox/dailymail.response b/spec/fixtures/onebox/dailymail.response new file mode 100644 index 00000000000..e5aa0c8f3e1 --- /dev/null +++ b/spec/fixtures/onebox/dailymail.response @@ -0,0 +1,5914 @@ + + + + + Brutality or justice? The truth behind the tarred and feathered drug dealer | Mail Online + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ + + + + + +
+
 
+ + + + + + + +
+
+
+ +
+ +
+
+
+
+
    + + +
  • + +
  • +
  • + + + +
  • +
  • + +
  • +
+
+ +
+
+ + + + +
+
+
+
+ +

Brutality or justice? The truth behind the tarred and feathered drug dealer

By ANDREW MALONE

Last updated at 00:47 01 September 2007


It was the most chilling image of the week ... a drug-dealer tarred and feathered in a medieval act of retribution. Sheer savagery? Or the desperate response of a community that decided to fight back?

The bar fell silent. Some drinkers put down their pints and walked out. Others suddenly became engrossed in newspapers, turning their stools around so that their faces couldn't be seen by anyone approaching through the only door.

"We don't know anything. Nothing at all," said one man in his 40s, looking up sharply from behind the pages of the Belfast Telegraph. "It's not a good idea to ask too many questions around here, ye know what I'm saying, my friend?"

Strangers are not welcome in the Taughmonagh Social Club, a working man's bar less than 100 yards from where a mess of tar and feathers still litter the pavement, following one of the most shocking acts of violence and public humiliation since the Troubles ended.

Scroll down for more ...

+ tarred feathered +

+

But an older man sitting alone had been studying me. He got up from his seat and called two of the other silent drinkers into a far corner of the bar. They stood in a huddle; nobody could hear what was being said. I waited.

After a short discussion between the three, I was ordered into an unlit backroom of the club, which is just two miles from the centre of Belfast now booming with upmarket restaurants, five-star hotels and non-stop construction as a result of the peace dividend after decades of civil war.

In the private room, under black and white photographs of famous moments in the history of Glasgow Rangers Football Club - which until recently was a Protestant-only team - the truth about the chilling events of last week was revealed for the first time.

The older man - measured, polite - had decided it was time the world was told why a man should be abducted, tied to a lamppost and have boiling tar poured over his head and body before being 'decorated' with feathers.

This show of "community justice" may have happened in Northern Ireland, but the professed reasons behind it may strike a chord with millions of law-abiding people in communities across the UK - where the police and courts are each day failing countless victims of violent crime.

Indeed, the man in the unlit backroom, who is happy to be called "William", but refuses to give his real name, insists this is simply the story of ordinary people driven to take the law into their own hands.

And whether you agree, or regard his words as a shameless attempt to defend the indefensible, his account gives a brutal insight into the grim reality of life in the harsher parts of "peaceful" Ulster.

"This man had been warned," he says. "This man was known to have been dealing drugs in our community. If you have kids rolling through the doors with their eyes all over their heads, you know that something is not right.

"It doesn't take Sherlock Holmes to work it out. Selling drugs to children is not on. The community wants drug dealers off the street, but they have no confidence in the police. If police catch these people dealing, they don't do anything.

"He was making money out of this. He was starting people off with drugs. What follows is that you have people breaking into houses, stealing cars, that sort of thing - just to pay for their drug habit.

"Then they start mugging people - old ladies and such like. The community goes to the dogs. We can't be standing for that. It's just not on."

And so it was that local man Jock Nelson was subjected to this most brutal form of public punishment.

Nelson had been living in the area for years. Indeed, until recently he could be found propping up the bar alongside "William" and his staunch Loyalist friends in the club.

But he had problems. He had recently lost his job as a doorman at Lavery's, a popular bar and nightclub near the centre of Belfast. Locals say he was sacked because of using and selling drugs - a charge denied by Lavery's.

Nelson also had marital problems with Julie, his wife and mother of their four children. He had moved out of the family home in the area, although his parents and sister had remained in Taughmonagh. But Nelson started coming back to the streets around the social club, dealing drugs, locals say, to teenage children.

He was repeatedly warned to keep away - but chose to ignore those words of wisdom. It was to prove a grave mistake.

Scroll down for more ...

+ +

+

+ +

+

Late last Saturday night, word swept the social club that Nelson was back - dealing drugs to children in a nearby park. Over drinks, a plan was hatched to put him out of business for good.

William says: "We are not stupid - we know that kids will smoke a bit of weed here and there. We don't want them to, but they do and it's probably not going to kill them in the long run. But this man [he refuses to use Nelson's name throughout the conversation] was selling hard drugs. We'd had enough."

The following night, Nelson was again spotted in the area. Children questioned by their parents had admitted he had been selling them drugs - not just "weed", but also crack cocaine and heroin.

Men with "woolly faces" - the local codeword for balaclavas - gathered nearby. After living through decades of violence between Catholic and Loyalist paramilitaries, the men of Taughmonagh questioned Nelson the only way they knew how: with extreme prejudice.

After savagely beating him and searching his pockets, William says they found five or six bags of crack cocaine. They dragged Nelson through the streets as women and children looked on.

The guilty man did not take his punishment well. Screaming for mercy, he was tied to a lamppost outside the local shops, opposite the park where he had been selling the drugs.

As locals watched in silence, another man in a balaclava appeared from near the social club. He was carrying a bucket of boiling tar and pillows. Nelson's shirt was pulled down over his shoulders, to ensure the tar burned his flesh.

Realising what was about to happen, Nelson "lost control of his bowels", through sheer terror, according to William. "But please don't write that. People might feel sorry for him."

The tar was poured over the offending drug dealer. Then the pillows were torn up and the feathers tipped over him - a punishment designed to ensure that he carried the "mark of justice" by the mob around with him for days.

A piece of cardboard with the words: "I'm a drug dealing scumbag" was strung round his neck. Then photographs were taken to serve as a warning to others that drug dealing will not be tolerated in Taughmonagh.

The pictures were sent to local newspapers - and subsequently beamed around the world. Belfast's politicians were horrified, saying it was a "barbaric act" that had "no place in a civilised society". Police appealed for witnesses; by last night, none had come forward.

Nothing, surely, can excuse such horrific savagery on our streets - and such casual contempt for the basic principles of justice. Yet, many people in areas across Britain will recognise the sense of impotence felt by the people of Taughmonagh, a rugged, working-class estate with the Union Jack hanging from virtually every house. There is a real sense of community in the area.

"Everybody here has grown up together," says Moira, a married woman with two children who works in a shop nearby. "We know everyone - the mums, the kids, the aunties, the dads. Here, we know everybody else's business. We look after each other."

The tarring and feathering certainly seems to have had the effect the community wanted: Jock Nelson fled the city soon after the attack.

"He's gone away to Scotland," Jean Nelson, the man's mother, said. "He's not here. He just wants to get away from everything for a while."

Asked what she felt about her son's involvement in drugs, she was furious. "That's slander. How dare you say that. Who told you where I live? Who told you my name? How would you like it if this was happening to you? We still have to live here. Get away! Just get away!"

A close friend of Jock Nelson's said he had gone away for a "few days" with his estranged wife and children until things calmed down. "There is no chance of him talking about this. It's too dangerous."

In many respects, Nelson was lucky: drug dealing and other anti-social behaviour often proves deadly in Belfast. Fedup with the lack of police action against criminals operating in their locality, there is a long tradition of summary justice being meted out on the streets.

First practised on informers and enemies from rival paramilitary groups, the technique of "knee-capping" - where the victim is shot in both legs, permanently disabling them - became synonymous with daily life during the Troubles.

But after the 1994 ceasefire between the warring Protestant and Catholic factions, many of the weapons were either decommissioned or hidden, forcing the paramilitaries to come up with fresh methods, or resurrect old ones, to deal with local troublemakers.

Once used against Catholic women caught having relationships with British soldiers during the Troubles, the first recorded incident of tarring and feathering came in 1191, when Richard I of England ordered soldiers to punish thieves in the Holy Land during the Crusades.

In America, this technique was used in the 18th century, when the criminal was covered in tar and feathers before being paraded through the town on the back of a horse-drawn cart. According to records from the time, the "aim was to hurt and humiliate a person enough to leave town and cause no more mischief".

The punishment rarely causes serious injury although it does cause minor burns. Tar boils at 60C rather than 100c for water and the tar has frequently cooled by the time it is poured over the victim. Jock Nelson was not taken to hospital after last week's incident.

While pictures of last week's tarring and feathering made international headlines, there is a relentless unreported wave of violence by vigilantes against known criminals in both north and south of the border each month.

With police either lacking the evidence to act, or too scared to enter streets which for decades were no-go areas, car thieves, paedophiles and drug dealers are regularly dealt with by the "men with woolly faces".

In one case, James O'Donoghue, a convicted rapist, was attacked by four men in balaclavas and stabbed repeatedly before being locked in the back of a van with four vicious pitbull terriers.

"I was kept there for an hour with those dogs," he said. "All I did was kept swinging and kicking, trying to defend myself. The men then dumped me at the side of the road. I got 216 stitches and went into cardiac arrest in hospital."

The son of Jonny "Mad Dog" Adair, the psychotic former leader of the Ulster Freedom Fighters' notorious "C" Company, was shot in both legs in 2002 after being named as a drug dealer, leaving him maimed for life.

Few in Belfast had any sympathy last week for criminals beaten or covered in tar and feathers as punishment. "They should just get shot," said Kevin Nolan, an office worker. "I'm not being nasty but they have made their choice and know the consequences."

Yet some experts suspect that these acts are not simply designed to prevent crime spiralling out of control. For decades, Loyalist gangs have had links to the criminal underworld, prompting speculation that they are simply trying to "take out" rivals in Northern Ireland's lucrative drugs trade.

Back at the Taughmonagh Social Club, William dismisses this notion. "This was nothing to do with paramilitaries," he says. "This was to do with a law-abiding community deciding to take action against a man who has been poisoning our children with drugs.

"This is a strong community. There's very little housebreaking or any other crime here. If we see anyone's children behaving badly, we let their parents know and they deal with it. That's how it works - we're all in this together."

Yet, on the streets of Belfast, which is successfully rebranding itself as a tourist destination, with guided tours up the Republican Falls and Loyalist Shankill Roads, there was little doubt about what would happen if anybody started dealing drugs on the streets.

A group of three drug users I met had no fear of the police, saying that they might know they were using drugs, but they would never get enough evidence because the drugs would just be thrown away before they were arrested.

"But there's no danger of us dealing drugs on the streets," said one man in his 20s, as a group of tourists walked past the Europa Hotel in the centre, marvelling at the fact it has been blown up more than any other hotel in the world.

Slurping furtively from a can of lager - drinking on the street is banned in many areas - the young man added: "The paramilitaries would come after us. Some people say it's because they want to deal all the drugs.

"I don't think it's because of that. I think it's just because they like violence - and I mean really like it. We wouldn't stand a chance if we sold drugs. We'd be dead within a week."

Certainly, the streets of Belfast are remarkably safe places to walk, with little petty crime, drug-dealing or gangs of drunken youths roaming the streets.

As a result of this curious by-product of the Northern Ireland peace process, it is no longer the law-abiding majority who are scared to go out after dark. These days, it seems, Belfast's criminals are the ones who live in mortal fear of being caught doing anything wrong.

The question is, at what price? Vigilante justice betrays all the values that were supposedly being defended in the long fight against terrorism. We tolerate it at our peril.

+ + + + + +
+
+ + + +
+
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +

No comments have so far been submitted. Why not be the first to send us your thoughts, + or debate this issue live on our message boards. +

+ +
+ + + + + + + +

We are no longer accepting comments on this article.

+ + + +
+ + Who is this week's top commenter? + Find out now +
+ + +
+
+ + +
+ + + +
+ +
+
+ +
+
+
+ Bing +
+ + + + + + + + +
+ +
+ + + + +
+
+ +
+ +
+
+ +
+
+ + + +
+
+ +   +   +

DON'T MISS

+ + +
+ +
+ + + + + + + +
+
+ +
+
+ +
+
+ +   +   +

MORE DON'T MISS

+ + +
+ +
+ + +
+
+ +
+ +
+
+ +
+ + + + + +
+
+ +
+
+ +
+ + + + + +
+ +
+
+ + MailOnline iPad app + +
+
+ +
+ + + + +
+ +
+ + + + +
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + +
+
+
+
+

Next story

+

+ + 'America is coming to help': Obama to bomb Iraq to save thousands of non-Muslims trapped on mountains and forced to choose between starving to death and slaughter by ISIS fanatics + ISIS take hundreds of Yazidi women hostage in bid to call Obama's bluff as America begins bombing Iraq after Islamist fanatics reach the gates of former Kurdish safe haven where thousands have fled + +

+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+
+ + +
 
+ + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/discourse_topic.response b/spec/fixtures/onebox/discourse_topic.response new file mode 100644 index 00000000000..00a0e24fe99 --- /dev/null +++ b/spec/fixtures/onebox/discourse_topic.response @@ -0,0 +1,373 @@ + + + + + + Congratulations, most stars in 2013 GitHub Octoverse! - praise - Discourse Meta + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/discourse_topic_reply.response b/spec/fixtures/onebox/discourse_topic_reply.response new file mode 100644 index 00000000000..d204320e713 --- /dev/null +++ b/spec/fixtures/onebox/discourse_topic_reply.response @@ -0,0 +1,369 @@ + + + + + + Congratulations, most stars in 2013 GitHub Octoverse! - praise - Discourse Meta + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/etsy.response b/spec/fixtures/onebox/etsy.response new file mode 100644 index 00000000000..663dd727a90 --- /dev/null +++ b/spec/fixtures/onebox/etsy.response @@ -0,0 +1,5389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Personalized Word Pillow Case Letter Symbol Text Cushion | Etsy + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+ + + + +
+ +
+ + + + + + + + + + + + + +
+ + +
+
+
+ + +
+
+
+
+ + + + + + + + + + + +
+ + + +
+ + + + + + + +
+ + + + + + + +
+
+ + +

Personalized Word Pillow Case | Letter, Symbol, Text Cushion Cover | 18x18 inch / 45x45 cm Decorative Pillow | Modern Home Decor

+
+ + + + + + + +
+
+
+
+ + +
+

+ Personalized Word Pillow Case | Letter, Symbol, Text Cushion Cover | 18x18 inch / 45x45 cm Decorative Pillow | Modern Home Decor +

+
+ + + + + +
+ +

+ + US$ 36.97+ + + + US$ 41.08+ + +

+

+ You save US$ 4.11 (10%) +

+ +
+ Local taxes included (where applicable) +
+
+ + +
+
+ +
+ +
+
Please select an option
+
+ +
+ +
+
Please select a colour
+
+ +

+ Last step: Enter the Fabric you want from options of the chart. Thank you!
- - -
Dernière étape: Entrez le Tissu désiré parmi les choix de la charte. Merci! +

+ +
+ 256 +
+
This item requires personalisation
+
You’ve reached the limit! Use 256 characters or less.
+
+ +
+ +
+
Please select a quantity
+
+
+ + + +
+ + +
+
+ + + + + + + + + +
+
+
+ + + +
+
+
+
+ +
+
+ Don't miss out. There's only 4 available and 3 other people have this in their basket right now. + +
+
+
+
+ + + + +
+ +
+
+

Item details

+ + +
+ +

Handmade

+
+ +
+
+
Materials
+ +

+ Natural fiber fabrics, Vinyl, Threads, YKK invisible zipper +

+ +
+
+
Dimensions
+ +

Length: 18 Inches; Width: 18 Inches

+
+
+
+ +
+
+ +
+
+ + - 15% off when you buy 2 items / 20% off when you buy 3 items -

Allow your personality to shine through your decor; this contemporary and modern accent will help you do just that. Personalize this 18" x 18" (45 x 45 cm) pillow cover with your favorite letter, symbol or word in the fabric and vinyl color of your choice, have fun!

>>> Here is how to create your own pillow cover (It's easy!): You just have to select the appliqué plus the primary color (vinyl for the appliqué) and write the fabric you want in the personalization box before adding the item to your cart.
▲*This item is made to order just for you in 2 to 4 business days!*

SPECIFICATIONS:
▲ Designed and handmade by us!
▲ 18" x 18" (45 x 45 cm) pillow cover {for same size pillow form or one size up}.
▲ 100% cotton, 50% cotton / 50% linen or 55% ramie / 45% cotton canvases {Pre-shrunk fabrics: the cover will keep its original shape after the first wash (No shrinkage!)}.
▲ Durable vinyl leather like sewn all around.
▲ Invisible zipper at the bottom {for an easy removal and clean finish}.
▲ Serged interior seams {makes it resistant to wash}.
▲ Pillow form is not included.

CARE:
Wash upside down and closed in cold water at gentle cycle or by hand with a gentle detergent. Do not use bleach. Dry flat or hang to dry. Iron *upside down* at medium high (cotton) temperature with medium steam.

▲ 12" x 18" (30 x 45 cm) lumbar size: http://www.etsy.com/listing/69325334
▲ 16" x 16" (40 x 40 cm) size: http://www.etsy.com/listing/103762611
▲ 20" x 20" (50 x 50 cm) size: http://www.etsy.com/listing/99807109

Contact us for any questions ;-) Thanks for visiting!

More from us, here on Etsy (!):
Digital art prints: http://www.etsy.com/shop/RocailArt
Vintage finds: http://www.etsy.com/shop/rocailoldandloved

All Designs & images © 2010-2020 ROCAIL / ROCAIL Studio. All rights reserved. +
+ +
+
+ +
+
+
+
+
+
+
+
+
+

Delivery & returns

+ +
+
+ + +
+ +
+ Ready to dispatch in 1–3 business days +
+
+ +
+ From Canada +
+
+
+ +
+
+
+ + + +
+
+
+
+ Sorry, this item doesn’t deliver to India. Contact the shop to find out about available delivery options. +
+ +
+
+ + +
+
+
+
+ +
No returns or exchanges
+
+ But please contact me if you have any problems with your order. +
+
+
+ +
+ +
+ +
+
+
+

+ Meet RocailStudio +

+ +
+
+ Melanie and Valerie +
+
+

Melanie and Valerie

+

+ Montreal, Canada +

+
+
+ +
This seller usually responds within 24 hours.
+
+ +
+ + +
+
+
+ +
+
+
+
+
+ +
+

Reviews

+ + + + + 5 out of 5 stars + + + + + + + (1,238) + + + +
+
+
+ + + + + + + + + + + +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ + + +
+ +
+ + +
+
Montreal, Canada
+
6,634 Sales
+
On Etsy since 2010
+
+
+
+ + +
+ +
+
+ +
+ +
+ + + +
+
+
+ + + +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
+ + + + + + + + +
+
+ +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/etsy_mobile.response b/spec/fixtures/onebox/etsy_mobile.response new file mode 100644 index 00000000000..0b7953dae90 --- /dev/null +++ b/spec/fixtures/onebox/etsy_mobile.response @@ -0,0 +1,5389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Personalized Word Pillow Case Letter Symbol Text Cushion | Etsy + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+ + + + +
+ +
+ + + + + + + + + + + + + +
+ + +
+
+
+ + +
+
+
+
+ + + + + + + + + + + +
+ + + +
+ + + + + + + +
+ + + + + + + +
+
+ + +

Personalized Word Pillow Case | Letter, Symbol, Text Cushion Cover | 18x18 inch / 45x45 cm Decorative Pillow | Modern Home Decor

+
+ + + + + + + +
+
+
+
+ + +
+

+ Personalized Word Pillow Case | Letter, Symbol, Text Cushion Cover | 18x18 inch / 45x45 cm Decorative Pillow | Modern Home Decor +

+
+ + + + + +
+ +

+ + US$ 36.97+ + + + US$ 41.08+ + +

+

+ You save US$ 4.11 (10%) +

+ +
+ Local taxes included (where applicable) +
+
+ + +
+
+ +
+ +
+
Please select an option
+
+ +
+ +
+
Please select a colour
+
+ +

+ Last step: Enter the Fabric you want from options of the chart. Thank you!
- - -
Dernière étape: Entrez le Tissu désiré parmi les choix de la charte. Merci! +

+ +
+ 256 +
+
This item requires personalisation
+
You’ve reached the limit! Use 256 characters or less.
+
+ +
+ +
+
Please select a quantity
+
+
+ + + +
+ + +
+
+ + + + + + + + + +
+
+
+ + + +
+
+
+
+ +
+
+ Don't miss out. There's only 4 available and 3 other people have this in their basket right now. + +
+
+
+
+ + + + +
+ +
+
+

Item details

+ + +
+ +

Handmade

+
+ +
+
+
Materials
+ +

+ Natural fiber fabrics, Vinyl, Threads, YKK invisible zipper +

+ +
+
+
Dimensions
+ +

Length: 18 Inches; Width: 18 Inches

+
+
+
+ +
+
+ +
+
+ + - 15% off when you buy 2 items / 20% off when you buy 3 items -

Allow your personality to shine through your decor; this contemporary and modern accent will help you do just that. Personalize this 18" x 18" (45 x 45 cm) pillow cover with your favorite letter, symbol or word in the fabric and vinyl color of your choice, have fun!

>>> Here is how to create your own pillow cover (It's easy!): You just have to select the appliqué plus the primary color (vinyl for the appliqué) and write the fabric you want in the personalization box before adding the item to your cart.
▲*This item is made to order just for you in 2 to 4 business days!*

SPECIFICATIONS:
▲ Designed and handmade by us!
▲ 18" x 18" (45 x 45 cm) pillow cover {for same size pillow form or one size up}.
▲ 100% cotton, 50% cotton / 50% linen or 55% ramie / 45% cotton canvases {Pre-shrunk fabrics: the cover will keep its original shape after the first wash (No shrinkage!)}.
▲ Durable vinyl leather like sewn all around.
▲ Invisible zipper at the bottom {for an easy removal and clean finish}.
▲ Serged interior seams {makes it resistant to wash}.
▲ Pillow form is not included.

CARE:
Wash upside down and closed in cold water at gentle cycle or by hand with a gentle detergent. Do not use bleach. Dry flat or hang to dry. Iron *upside down* at medium high (cotton) temperature with medium steam.

▲ 12" x 18" (30 x 45 cm) lumbar size: http://www.etsy.com/listing/69325334
▲ 16" x 16" (40 x 40 cm) size: http://www.etsy.com/listing/103762611
▲ 20" x 20" (50 x 50 cm) size: http://www.etsy.com/listing/99807109

Contact us for any questions ;-) Thanks for visiting!

More from us, here on Etsy (!):
Digital art prints: http://www.etsy.com/shop/RocailArt
Vintage finds: http://www.etsy.com/shop/rocailoldandloved

All Designs & images © 2010-2020 ROCAIL / ROCAIL Studio. All rights reserved. +
+ +
+
+ +
+
+
+
+
+
+
+
+
+

Delivery & returns

+ +
+
+ + +
+ +
+ Ready to dispatch in 1–3 business days +
+
+ +
+ From Canada +
+
+
+ +
+
+
+ + + +
+
+
+
+ Sorry, this item doesn’t deliver to India. Contact the shop to find out about available delivery options. +
+ +
+
+ + +
+
+
+
+ +
No returns or exchanges
+
+ But please contact me if you have any problems with your order. +
+
+
+ +
+ +
+ +
+
+
+

+ Meet RocailStudio +

+ +
+
+ Melanie and Valerie +
+
+

Melanie and Valerie

+

+ Montreal, Canada +

+
+
+ +
This seller usually responds within 24 hours.
+
+ +
+ + +
+
+
+ +
+
+
+
+
+ +
+

Reviews

+ + + + + 5 out of 5 stars + + + + + + + (1,238) + + + +
+
+
+ + + + + + + + + + + +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ + + +
+ +
+ + +
+
Montreal, Canada
+
6,634 Sales
+
On Etsy since 2010
+
+
+
+ + +
+ +
+
+ +
+ +
+ + + +
+
+
+ + + +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
+ + + + + + + + +
+
+ +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/gfycat.response b/spec/fixtures/onebox/gfycat.response new file mode 100644 index 00000000000..6e34867afbb --- /dev/null +++ b/spec/fixtures/onebox/gfycat.response @@ -0,0 +1,40 @@ + +Goal 11: Kerbal GIF by Gif Your Game (@gifyourgame) | Find, Make & Share Gfycat GIFs
diff --git a/spec/fixtures/onebox/githubblob.response b/spec/fixtures/onebox/githubblob.response new file mode 100644 index 00000000000..9cf89bef5aa --- /dev/null +++ b/spec/fixtures/onebox/githubblob.response @@ -0,0 +1,46 @@ +require_dependency 'oneboxer/handlebars_onebox' + +module Oneboxer + class GithubBlobOnebox < HandlebarsOnebox + + matcher /^https?:\/\/(?:www\.)?github\.com\/[^\/]+\/[^\/]+\/blob\/.*/ + favicon 'github.png' + + def translate_url + m = @url.match(/github\.com\/(?[^\/]+)\/(?[^\/]+)\/blob\/(?[^\/]+)\/(?[^#]+)(#(L(?[^-]*)(-L(?.*))?))?/mi) + if m + @from = (m[:from] || -1).to_i + @to = (m[:to] || -1).to_i + @file = m[:file] + return "https://raw.github.com/#{m[:user]}/#{m[:repo]}/#{m[:sha1]}/#{m[:file]}" + end + nil + end + + def parse(data) + + if @from > 0 + if @to < 0 + @from = @from - 10 + @to = @from + 20 + end + if @to > @from + data = data.split("\n")[@from..@to].join("\n") + end + end + + extension = @file.split(".")[-1] + @lang = extension + + truncated = false + if data.length > SiteSetting.onebox_max_chars + data = data[0..SiteSetting.onebox_max_chars-1] + truncated = true + end + + {content: data, truncated: truncated} + end + + end +end + diff --git a/spec/fixtures/onebox/githubcommit.response b/spec/fixtures/onebox/githubcommit.response new file mode 100644 index 00000000000..46944600abc --- /dev/null +++ b/spec/fixtures/onebox/githubcommit.response @@ -0,0 +1,87 @@ +{ + "sha": "803d023e2307309f8b776ab3b8b7e38ba91c0919", + "commit": { + "author": { + "name": "Sam", + "email": "sam.saffron@gmail.com", + "date": "2013-08-02T02:03:53Z" + }, + "committer": { + "name": "Sam", + "email": "sam.saffron@gmail.com", + "date": "2013-08-02T02:16:44Z" + }, + "message": "Fixed GitHub auth, GitHub can provide us with a valid email - so automatically log in for those cases", + "tree": { + "sha": "8e0f3e17bb5ee3edc5701229dc1ad82dc5a41de6", + "url": "https://api.github.com/repos/discourse/discourse/git/trees/8e0f3e17bb5ee3edc5701229dc1ad82dc5a41de6" + }, + "url": "https://api.github.com/repos/discourse/discourse/git/commits/803d023e2307309f8b776ab3b8b7e38ba91c0919", + "comment_count": 0 + }, + "url": "https://api.github.com/repos/discourse/discourse/commits/803d023e2307309f8b776ab3b8b7e38ba91c0919", + "html_url": "https://github.com/discourse/discourse/commit/803d023e2307309f8b776ab3b8b7e38ba91c0919", + "comments_url": "https://api.github.com/repos/discourse/discourse/commits/803d023e2307309f8b776ab3b8b7e38ba91c0919/comments", + "author": { + "login": "SamSaffron", + "id": 5213, + "avatar_url": "https://2.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce?d=https%3A%2F%2Fidenticons.github.com%2F7d3010c11d08cf990b7614d2c2ca9098.png", + "gravatar_id": "3dcae8378d46c244172a115c28ca49ce", + "url": "https://api.github.com/users/SamSaffron", + "html_url": "https://github.com/SamSaffron", + "followers_url": "https://api.github.com/users/SamSaffron/followers", + "following_url": "https://api.github.com/users/SamSaffron/following{/other_user}", + "gists_url": "https://api.github.com/users/SamSaffron/gists{/gist_id}", + "starred_url": "https://api.github.com/users/SamSaffron/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/SamSaffron/subscriptions", + "organizations_url": "https://api.github.com/users/SamSaffron/orgs", + "repos_url": "https://api.github.com/users/SamSaffron/repos", + "events_url": "https://api.github.com/users/SamSaffron/events{/privacy}", + "received_events_url": "https://api.github.com/users/SamSaffron/received_events", + "type": "User" + }, + "committer": { + "login": "SamSaffron", + "id": 5213, + "avatar_url": "https://2.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce?d=https%3A%2F%2Fidenticons.github.com%2F7d3010c11d08cf990b7614d2c2ca9098.png", + "gravatar_id": "3dcae8378d46c244172a115c28ca49ce", + "url": "https://api.github.com/users/SamSaffron", + "html_url": "https://github.com/SamSaffron", + "followers_url": "https://api.github.com/users/SamSaffron/followers", + "following_url": "https://api.github.com/users/SamSaffron/following{/other_user}", + "gists_url": "https://api.github.com/users/SamSaffron/gists{/gist_id}", + "starred_url": "https://api.github.com/users/SamSaffron/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/SamSaffron/subscriptions", + "organizations_url": "https://api.github.com/users/SamSaffron/orgs", + "repos_url": "https://api.github.com/users/SamSaffron/repos", + "events_url": "https://api.github.com/users/SamSaffron/events{/privacy}", + "received_events_url": "https://api.github.com/users/SamSaffron/received_events", + "type": "User" + }, + "parents": [ + { + "sha": "cf333268d5b48946a659f173716aecc1096d7e66", + "url": "https://api.github.com/repos/discourse/discourse/commits/cf333268d5b48946a659f173716aecc1096d7e66", + "html_url": "https://github.com/discourse/discourse/commit/cf333268d5b48946a659f173716aecc1096d7e66" + } + ], + "stats": { + "total": 20, + "additions": 18, + "deletions": 2 + }, + "files": [ + { + "sha": "0edc93bbf3d28a5020ee8b2d44ed68d4e3706a1f", + "filename": "app/controllers/users/omniauth_callbacks_controller.rb", + "status": "modified", + "additions": 18, + "deletions": 2, + "changes": 20, + "blob_url": "https://github.com/discourse/discourse/blob/803d023e2307309f8b776ab3b8b7e38ba91c0919/app/controllers/users/omniauth_callbacks_controller.rb", + "raw_url": "https://github.com/discourse/discourse/raw/803d023e2307309f8b776ab3b8b7e38ba91c0919/app/controllers/users/omniauth_callbacks_controller.rb", + "contents_url": "https://api.github.com/repos/discourse/discourse/contents/app/controllers/users/omniauth_callbacks_controller.rb?ref=803d023e2307309f8b776ab3b8b7e38ba91c0919", + "patch": "@@ -210,6 +210,8 @@ def create_or_sign_on_user_using_openid(auth_token)\n \n if user_open_id.blank? && user = User.find_by_email(email)\n # we trust so do an email lookup\n+ # TODO some openid providers may not be trust worthy, allow for that\n+ # for now we are good (google, yahoo are trust worthy)\n user_open_id = UserOpenId.create(url: identity_url , user_id: user.id, email: email, active: true)\n end\n \n@@ -250,18 +252,32 @@ def create_or_sign_on_user_using_github(auth_token)\n \n data = auth_token[:info]\n screen_name = data[\"nickname\"]\n+ email = data[\"email\"]\n github_user_id = auth_token[\"uid\"]\n \n session[:authentication] = {\n github_user_id: github_user_id,\n- github_screen_name: screen_name\n+ github_screen_name: screen_name,\n+ email: email,\n+ email_valid: true\n }\n \n user_info = GithubUserInfo.where(github_user_id: github_user_id).first\n \n+ if !user_info && user = User.find_by_email(email)\n+ # we trust so do an email lookup\n+ user_info = GithubUserInfo.create(\n+ user_id: user.id,\n+ screen_name: screen_name,\n+ github_user_id: github_user_id\n+ )\n+ end\n+\n @data = {\n username: screen_name,\n- auth_provider: \"Github\"\n+ auth_provider: \"Github\",\n+ email: email,\n+ email_valid: true\n }\n \n process_user_info(user_info, screen_name)" + } + ] +} diff --git a/spec/fixtures/onebox/githubfolder-discourse-root.response b/spec/fixtures/onebox/githubfolder-discourse-root.response new file mode 100644 index 00000000000..23075c565b4 --- /dev/null +++ b/spec/fixtures/onebox/githubfolder-discourse-root.response @@ -0,0 +1,2358 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GitHub - discourse/discourse: A platform for community discussion. Free, open, simple. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Skip to content + + + + + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + +
+ +
+ +
+

+ + + / + + discourse + + +

+ + +
+ + + +
+
+

+ A platform for community discussion. Free, open, simple. +

+
+ + + www.discourse.org + +
+ + +
+ + +
+
+ + + +
+ + +
+
+ + + + +
+ +
+ + + + + +
+ +
+ + + + + + +
+ +
+
+ + + master + + + + +
+ + + +
+
+
+ +
+ + + + +
+ + + Go to file + + + + + + + +
+ + + Code + +
+ +
+
+
+ + + +
+
+ + + + +
+
+

Latest commit

+
+ +
+
 
+
+

Git stats

+ +
+
+
+

Files

+ + + + + Permalink + +
+ + + Failed to load latest commit information. + +
+
+
+
Type
+
Name
+
Latest commit message
+
Commit time
+
+ +
+
+ + +
+ +
+ .github +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ app +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ bin +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ config +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ db +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ docs +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ images +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ lib +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ log +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ plugins +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ public +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ script +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ spec +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ test +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ vendor +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ .eslintrc +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ .rspec +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ Brewfile +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ Gemfile +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ README.md +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ Rakefile +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ adminjs +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ config.ru +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ d +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ jsapp +
+ +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ + + +
+
 
+
+ +
+
 
+
+ +
+
+
+ + +
+ +
+ yarn.lock +
+ +
+
 
+
+ +
+
 
+
+ +
+
+ +
+ +
+ + +
+ +
+
+

+ README.md +

+
+ + +
+

+

Discourse is the 100% open source discussion platform built for the next decade of the Internet. Use it as a:

+
    +
  • mailing list
  • +
  • discussion forum
  • +
  • long-form chat room
  • +
+

To learn more about the philosophy and goals of the project, visit discourse.org.

+

Screenshots

+

Boing Boing + + +

+

Mobile

+

Browse lots more notable Discourse instances.

+

Development

+

To get your environment setup, follow the community setup guide for your operating system.

+
    +
  1. If you're on macOS, try the macOS development guide.
  2. +
  3. If you're on Ubuntu, try the Ubuntu development guide.
  4. +
  5. If you're on Windows, try the Windows 10 development guide.
  6. +
+

If you're familiar with how Rails works and are comfortable setting up your own environment, you can also try out the Discourse Advanced Developer Guide, which is aimed primarily at Ubuntu and macOS environments.

+

Before you get started, ensure you have the following minimum versions: Ruby 2.6+, PostgreSQL 10+, Redis 4.0+. If you're having trouble, please see our TROUBLESHOOTING GUIDE first!

+

Setting up Discourse

+

If you want to set up a Discourse forum for production use, see our Discourse Install Guide.

+

If you're looking for business class hosting, see discourse.org/buy.

+

Requirements

+

Discourse is built for the next 10 years of the Internet, so our requirements are high.

+

Discourse supports the latest, stable releases of all major browsers and platforms:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BrowsersTabletsPhones
Apple SafariiPadOSiOS
Google ChromeAndroidAndroid
Microsoft Edge
Mozilla Firefox
+

Built With

+
    +
  • Ruby on Rails — Our back end API is a Rails app. It responds to requests RESTfully in JSON.
  • +
  • Ember.js — Our front end is an Ember.js app that communicates with the Rails API.
  • +
  • PostgreSQL — Our main data store is in Postgres.
  • +
  • Redis — We use Redis as a cache and for transient data.
  • +
  • BrowserStack — We use BrowserStack to test on real devices and browsers.
  • +
+

Plus lots of Ruby Gems, a complete list of which is at /master/Gemfile.

+

Contributing

+

Build Status

+

Discourse is 100% free and open source. We encourage and support an active, healthy community that +accepts contributions from the public – including you!

+

Before contributing to Discourse:

+
    +
  1. Please read the complete mission statements on discourse.org. Yes we actually believe this stuff; you should too.
  2. +
  3. Read and sign the Electronic Discourse Forums Contribution License Agreement.
  4. +
  5. Dig into CONTRIBUTING.MD, which covers submitting bugs, requesting new features, preparing your code for a pull request, etc.
  6. +
  7. Always strive to collaborate with mutual respect.
  8. +
  9. Not sure what to work on? We've got some ideas.
  10. +
+

We look forward to seeing your pull requests!

+

Security

+

We take security very seriously at Discourse; all our code is 100% open source and peer reviewed. Please read our security guide for an overview of security measures in Discourse, or if you wish to report a security issue.

+

The Discourse Team

+

The original Discourse code contributors can be found in AUTHORS.MD. For a complete list of the many individuals that contributed to the design and implementation of Discourse, please refer to the official Discourse blog and GitHub's list of contributors.

+

Copyright / License

+

Copyright 2014 - 2020 Civilized Discourse Construction Kit, Inc.

+

Licensed under the GNU General Public License Version 2.0 (or later); +you may not use this work except in compliance with the License. +You may obtain a copy of the License in the LICENSE file, or at:

+

https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt

+

Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.

+

Discourse logo and “Discourse Forum” ®, Civilized Discourse Construction Kit, Inc.

+

Dedication

+

Discourse is built with love, Internet style.

+
+
+
+ + +
+
+ + +
+
+
+

About

+ +

+ A platform for community discussion. Free, open, simple. +

+
+ + + www.discourse.org + +
+ +

Topics

+ + +

Resources

+ + +

License

+ + +
+
+ +
+
+

+ + Contributors 794 +

+ + + +
    +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
  • +
    +
  • +
+
+ + +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ + + + + + +
+ + + You can’t perform that action at this time. +
+ + + + + + + + + + + + diff --git a/spec/fixtures/onebox/githubfolder.response b/spec/fixtures/onebox/githubfolder.response new file mode 100644 index 00000000000..6e2ae422213 --- /dev/null +++ b/spec/fixtures/onebox/githubfolder.response @@ -0,0 +1,1549 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + discourse/spec/fixtures at master · discourse/discourse · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Skip to content + + + + + + + + +
+ +
+ + + + + +
+ + + +
+ + + + + + + + + +
+
+
+ + + + + + + + + + + + + + +
+ +
+ +
+

+ + + / + + discourse + + +

+ + +
+ + + +
+ + +
+ + +
+
+ + + +
+ +
+
+ + + master + + + + +
+ + + +
+
+
+ +
+ + +
+
+ discourse/spec/fixtures/ +
+
+ + +
+ +
discourse/spec/fixtures/
+ + + +
+
+

Latest commit

+
+ +
+ +
+
+ + @jbrw +
+
+ +
+ +
+ + +
#11253)
+
+* FEATURE: display error if Oneboxing fails due to HTTP error
+
+- display warning if onebox URL is unresolvable
+- display warning if attributes are missing
+
+* FEATURE: Use new Instagram oEmbed endpoint if access token is configured
+
+Instagram requires an Access Token to access their oEmbed endpoint. The requirements (from https://developers.facebook.com/docs/instagram/oembed/) are as follows:
+
+- a Facebook Developer account, which you can create at developers.facebook.com
+- a registered Facebook app
+- the oEmbed Product added to the app
+- an Access Token
+- The Facebook app must be in Live Mode
+
+The generated Access Token, once added to SiteSetting.facebook_app_access_token, will be passed to onebox. Onebox can then use this token to access the oEmbed endpoint to generate a onebox for Instagram.
+
+* DEV: update user agent string
+
+* DEV: don’t do HEAD requests against news.yahoo.com
+
+* DEV: Bump onebox version from 2.1.5 to 2.1.6
+
+* DEV: Avoid re-reading templates
+
+* DEV: Tweaks to onebox mustache templates
+
+* DEV: simplified error message for missing onebox data
+
+* Apply suggestions from code review
+Co-authored-by: Gerhard Schlager <mail@gerhard-schlager.at>
+
+ 331236d +
+
+
+

Git stats

+ +
+
+
+

Files

+ + + + Permalink + +
+ + + Failed to load latest commit information. + +
+
+
+
Type
+
Name
+
Latest commit message
+
Commit time
+
+
+ +
+
+
+ +
+
+ + +
+ +
+ backups +
+ + + +
+ Aug 21, 2020 +
+ +
+
+
+ + +
+ +
+ csv +
+ + + +
+ Jul 29, 2020 +
+ +
+
+
+ + +
+ +
+ db +
+ + + +
+ Jun 16, 2020 +
+ +
+
+
+ + +
+ +
+ emails +
+ + + +
+ Jul 27, 2020 +
+ +
+
+
+ + +
+ +
+ encodings +
+ + + +
+ Aug 1, 2018 +
+ +
+
+
+ + +
+ +
+ feed +
+ + + +
+ Aug 1, 2018 +
+ +
+
+
+ + +
+ +
+ i18n +
+ + + +
+ Jun 5, 2019 +
+ +
+
+
+ + +
+ +
+ images +
+ + + +
+ Oct 26, 2020 +
+ +
+
+
+ + +
+ +
+ json +
+ + + +
+ Aug 24, 2020 +
+ +
+
+
+ + +
+ +
+ md +
+ + + +
+ Oct 8, 2019 +
+ +
+
+
+ + +
+ +
+ media +
+ + + +
+ Jun 17, 2020 +
+ +
+
+
+ + +
+ +
+ mmdb +
+ + + +
+ Oct 25, 2018 +
+ +
+
+
+ + +
+ +
+ multisite +
+ + + +
+ Aug 8, 2017 +
+ +
+
+
+ + +
+ +
+ onebox +
+ + + +
+ Nov 18, 2020 +
+ +
+
+
+ + +
+ +
+ pdf +
+ + + +
+ Jul 25, 2019 +
+ +
+
+
+ + +
+ +
+ plugins +
+ + + +
+ Nov 11, 2020 +
+ +
+
+
+ + +
+ +
+ scss +
+ + + +
+ Sep 21, 2018 +
+ +
+
+
+ + +
+ + + + + +
+ Aug 15, 2017 +
+ +
+
+
+ + +
+ + + + + +
+ Apr 15, 2020 +
+ +
+
+
+ + +
+ +
+ themes +
+ + + +
+ Oct 14, 2019 +
+ +
+
+
+ + +
+ +
+ woff2 +
+ + + +
+ May 10, 2017 +
+ +
+
+
+ + + + +
+ + + + + +
+
+ +
+
+ +
+ + + + + + +
+ + + You can’t perform that action at this time. +
+ + + + + + + + + + + + diff --git a/spec/fixtures/onebox/githubgist.response b/spec/fixtures/onebox/githubgist.response new file mode 100644 index 00000000000..5975d952560 --- /dev/null +++ b/spec/fixtures/onebox/githubgist.response @@ -0,0 +1,310 @@ +{ + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b", + "forks_url": "https://api.github.com/gists/208fdd59fc4b4c39283b/forks", + "commits_url": "https://api.github.com/gists/208fdd59fc4b4c39283b/commits", + "id": "208fdd59fc4b4c39283b", + "git_pull_url": "https://gist.github.com/208fdd59fc4b4c39283b.git", + "git_push_url": "https://gist.github.com/208fdd59fc4b4c39283b.git", + "html_url": "https://gist.github.com/208fdd59fc4b4c39283b", + "files": { + "0.rb": { + "filename": "0.rb", + "type": "application/x-ruby", + "language": "Ruby", + "raw_url": "https://gist.githubusercontent.com/karreiro/208fdd59fc4b4c39283b/raw/42864e791652564ec50f773589df168998fbfdf7/0.rb", + "size": 384, + "truncated": false, + "content": "3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n\n3.times { puts \"Gist API test.\" }\n" + }, + "1.js": { + "filename": "1.js", + "type": "application/javascript", + "language": "JavaScript", + "raw_url": "https://gist.githubusercontent.com/karreiro/208fdd59fc4b4c39283b/raw/767c3a1cec198cc2a9e6bf8a8043977cfcf3a469/1.js", + "size": 22, + "truncated": false, + "content": "console.log(\"Hey! ;)\")" + }, + "2.md": { + "filename": "2.md", + "type": "text/plain", + "language": "Markdown", + "raw_url": "https://gist.githubusercontent.com/karreiro/208fdd59fc4b4c39283b/raw/5da5715735f9d4d908003b4656426d53bfd69a96/2.md", + "size": 25, + "truncated": false, + "content": "#### Hey, this is a test!" + }, + "3.java": { + "filename": "3.java", + "type": "text/plain", + "language": "Java", + "raw_url": "https://gist.githubusercontent.com/karreiro/208fdd59fc4b4c39283b/raw/1a5f6d12fc557951f87b52f91c9cb8d6bdb2562d/3.java", + "size": 43, + "truncated": false, + "content": "System.out.println(\"Wow! This is a test!\");" + } + }, + "public": true, + "created_at": "2014-11-23T20:34:53Z", + "updated_at": "2014-11-26T01:06:05Z", + "description": "", + "comments": 0, + "user": null, + "comments_url": "https://api.github.com/gists/208fdd59fc4b4c39283b/comments", + "owner": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "forks": [ + + ], + "history": [ + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "e272e4f835e80f53fb61df2dca190fdc84b9077d", + "committed_at": "2014-11-26T01:06:05Z", + "change_status": { + "total": 4, + "additions": 2, + "deletions": 2 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/e272e4f835e80f53fb61df2dca190fdc84b9077d" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "3e26db8aae98340fce9d0eed3e0105c78dc440e9", + "committed_at": "2014-11-26T01:05:48Z", + "change_status": { + "total": 48, + "additions": 24, + "deletions": 24 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/3e26db8aae98340fce9d0eed3e0105c78dc440e9" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "4ad435c22f01aca33b6b9425505b257b8e79fe51", + "committed_at": "2014-11-26T01:05:16Z", + "change_status": { + "total": 22, + "additions": 21, + "deletions": 1 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/4ad435c22f01aca33b6b9425505b257b8e79fe51" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "766d913a9bbe70181944a7a74818db5a1531d7e2", + "committed_at": "2014-11-26T00:56:12Z", + "change_status": { + "total": 2, + "additions": 2, + "deletions": 0 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/766d913a9bbe70181944a7a74818db5a1531d7e2" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "0b66a247bcbcdaeaee33b43a3b8accd499f82c8d", + "committed_at": "2014-11-24T23:48:58Z", + "change_status": { + "total": 1, + "additions": 1, + "deletions": 0 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/0b66a247bcbcdaeaee33b43a3b8accd499f82c8d" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "310b5888a5ee830a38972f0fbace28055ab05759", + "committed_at": "2014-11-24T23:47:40Z", + "change_status": { + "total": 99, + "additions": 0, + "deletions": 99 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/310b5888a5ee830a38972f0fbace28055ab05759" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "dc19c6c9c36079f56363062eea81e448fe1f996e", + "committed_at": "2014-11-24T01:06:00Z", + "change_status": { + "total": 99, + "additions": 99, + "deletions": 0 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/dc19c6c9c36079f56363062eea81e448fe1f996e" + }, + { + "user": { + "login": "karreiro", + "id": 1079279, + "avatar_url": "https://avatars.githubusercontent.com/u/1079279?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/karreiro", + "html_url": "https://github.com/karreiro", + "followers_url": "https://api.github.com/users/karreiro/followers", + "following_url": "https://api.github.com/users/karreiro/following{/other_user}", + "gists_url": "https://api.github.com/users/karreiro/gists{/gist_id}", + "starred_url": "https://api.github.com/users/karreiro/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/karreiro/subscriptions", + "organizations_url": "https://api.github.com/users/karreiro/orgs", + "repos_url": "https://api.github.com/users/karreiro/repos", + "events_url": "https://api.github.com/users/karreiro/events{/privacy}", + "received_events_url": "https://api.github.com/users/karreiro/received_events", + "type": "User", + "site_admin": false + }, + "version": "9ec949557a17391117a30aebcd907a14d61eae88", + "committed_at": "2014-11-23T20:34:53Z", + "change_status": { + "total": 1, + "additions": 1, + "deletions": 0 + }, + "url": "https://api.github.com/gists/208fdd59fc4b4c39283b/9ec949557a17391117a30aebcd907a14d61eae88" + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/onebox/githubpullrequest.response b/spec/fixtures/onebox/githubpullrequest.response new file mode 100644 index 00000000000..01ad3e48bdd --- /dev/null +++ b/spec/fixtures/onebox/githubpullrequest.response @@ -0,0 +1,216 @@ +{ + "url": "https://api.github.com/repos/discourse/discourse/pulls/1253", + "id": 7186071, + "html_url": "https://github.com/discourse/discourse/pull/1253", + "diff_url": "https://github.com/discourse/discourse/pull/1253.diff", + "patch_url": "https://github.com/discourse/discourse/pull/1253.patch", + "issue_url": "https://github.com/discourse/discourse/pull/1253", + "number": 1253, + "state": "closed", + "title": "Add audio onebox", + "user": { + "login": "jamesaanderson", + "id": 2722987, + "avatar_url": "https://0.gravatar.com/avatar/b3e9977094ce189bbb493cf7f9adea21?d=https%3A%2F%2Fidenticons.github.com%2Fb4a68f5d10a482ee680e30f88540942a.png", + "gravatar_id": "b3e9977094ce189bbb493cf7f9adea21", + "url": "https://api.github.com/users/jamesaanderson", + "html_url": "https://github.com/jamesaanderson", + "followers_url": "https://api.github.com/users/jamesaanderson/followers", + "following_url": "https://api.github.com/users/jamesaanderson/following{/other_user}", + "gists_url": "https://api.github.com/users/jamesaanderson/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jamesaanderson/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jamesaanderson/subscriptions", + "organizations_url": "https://api.github.com/users/jamesaanderson/orgs", + "repos_url": "https://api.github.com/users/jamesaanderson/repos", + "events_url": "https://api.github.com/users/jamesaanderson/events{/privacy}", + "received_events_url": "https://api.github.com/users/jamesaanderson/received_events", + "type": "User" + }, + "body": "http://meta.discourse.org/t/audio-html5-tag/8168", + "created_at": "2013-07-26T02:05:53Z", + "updated_at": "2013-07-26T15:31:57Z", + "closed_at": "2013-07-26T15:30:57Z", + "merged_at": "2013-07-26T15:30:57Z", + "merge_commit_sha": null, + "assignee": null, + "milestone": null, + "commits_url": "https://github.com/discourse/discourse/pull/1253/commits", + "review_comments_url": "https://github.com/discourse/discourse/pull/1253/comments", + "review_comment_url": "/repos/discourse/discourse/pulls/comments/{number}", + "comments_url": "https://api.github.com/repos/discourse/discourse/issues/1253/comments", + "head": { + "label": "jamesaanderson:add-audio-onebox", + "ref": "add-audio-onebox", + "sha": "d7d3be1130c665cc7fab9f05dbf32335229137a6", + "user": { + "login": "jamesaanderson", + "id": 2722987, + "avatar_url": "https://0.gravatar.com/avatar/b3e9977094ce189bbb493cf7f9adea21?d=https%3A%2F%2Fidenticons.github.com%2Fb4a68f5d10a482ee680e30f88540942a.png", + "gravatar_id": "b3e9977094ce189bbb493cf7f9adea21", + "url": "https://api.github.com/users/jamesaanderson", + "html_url": "https://github.com/jamesaanderson", + "followers_url": "https://api.github.com/users/jamesaanderson/followers", + "following_url": "https://api.github.com/users/jamesaanderson/following{/other_user}", + "gists_url": "https://api.github.com/users/jamesaanderson/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jamesaanderson/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jamesaanderson/subscriptions", + "organizations_url": "https://api.github.com/users/jamesaanderson/orgs", + "repos_url": "https://api.github.com/users/jamesaanderson/repos", + "events_url": "https://api.github.com/users/jamesaanderson/events{/privacy}", + "received_events_url": "https://api.github.com/users/jamesaanderson/received_events", + "type": "User" + }, + "repo": null + }, + "base": { + "label": "discourse:master", + "ref": "master", + "sha": "cc79d22f82ede170dd86a05274eb3c2c5eb02912", + "user": { + "login": "discourse", + "id": 3220138, + "avatar_url": "https://0.gravatar.com/avatar/b30fff48d257cdd17c4437afac19fd30?d=https%3A%2F%2Fidenticons.github.com%2Fa42d8d01d12f7137e49e7c1ee1b2b3f0.png", + "gravatar_id": "b30fff48d257cdd17c4437afac19fd30", + "url": "https://api.github.com/users/discourse", + "html_url": "https://github.com/discourse", + "followers_url": "https://api.github.com/users/discourse/followers", + "following_url": "https://api.github.com/users/discourse/following{/other_user}", + "gists_url": "https://api.github.com/users/discourse/gists{/gist_id}", + "starred_url": "https://api.github.com/users/discourse/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/discourse/subscriptions", + "organizations_url": "https://api.github.com/users/discourse/orgs", + "repos_url": "https://api.github.com/users/discourse/repos", + "events_url": "https://api.github.com/users/discourse/events{/privacy}", + "received_events_url": "https://api.github.com/users/discourse/received_events", + "type": "Organization" + }, + "repo": { + "id": 7569578, + "name": "discourse", + "full_name": "discourse/discourse", + "owner": { + "login": "discourse", + "id": 3220138, + "avatar_url": "https://0.gravatar.com/avatar/b30fff48d257cdd17c4437afac19fd30?d=https%3A%2F%2Fidenticons.github.com%2Fa42d8d01d12f7137e49e7c1ee1b2b3f0.png", + "gravatar_id": "b30fff48d257cdd17c4437afac19fd30", + "url": "https://api.github.com/users/discourse", + "html_url": "https://github.com/discourse", + "followers_url": "https://api.github.com/users/discourse/followers", + "following_url": "https://api.github.com/users/discourse/following{/other_user}", + "gists_url": "https://api.github.com/users/discourse/gists{/gist_id}", + "starred_url": "https://api.github.com/users/discourse/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/discourse/subscriptions", + "organizations_url": "https://api.github.com/users/discourse/orgs", + "repos_url": "https://api.github.com/users/discourse/repos", + "events_url": "https://api.github.com/users/discourse/events{/privacy}", + "received_events_url": "https://api.github.com/users/discourse/received_events", + "type": "Organization" + }, + "private": false, + "html_url": "https://github.com/discourse/discourse", + "description": "A platform for community discussion. Free, open, simple.", + "fork": false, + "url": "https://api.github.com/repos/discourse/discourse", + "forks_url": "https://api.github.com/repos/discourse/discourse/forks", + "keys_url": "https://api.github.com/repos/discourse/discourse/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/discourse/discourse/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/discourse/discourse/teams", + "hooks_url": "https://api.github.com/repos/discourse/discourse/hooks", + "issue_events_url": "https://api.github.com/repos/discourse/discourse/issues/events{/number}", + "events_url": "https://api.github.com/repos/discourse/discourse/events", + "assignees_url": "https://api.github.com/repos/discourse/discourse/assignees{/user}", + "branches_url": "https://api.github.com/repos/discourse/discourse/branches{/branch}", + "tags_url": "https://api.github.com/repos/discourse/discourse/tags", + "blobs_url": "https://api.github.com/repos/discourse/discourse/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/discourse/discourse/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/discourse/discourse/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/discourse/discourse/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/discourse/discourse/statuses/{sha}", + "languages_url": "https://api.github.com/repos/discourse/discourse/languages", + "stargazers_url": "https://api.github.com/repos/discourse/discourse/stargazers", + "contributors_url": "https://api.github.com/repos/discourse/discourse/contributors", + "subscribers_url": "https://api.github.com/repos/discourse/discourse/subscribers", + "subscription_url": "https://api.github.com/repos/discourse/discourse/subscription", + "commits_url": "https://api.github.com/repos/discourse/discourse/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/discourse/discourse/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/discourse/discourse/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/discourse/discourse/issues/comments/{number}", + "contents_url": "https://api.github.com/repos/discourse/discourse/contents/{+path}", + "compare_url": "https://api.github.com/repos/discourse/discourse/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/discourse/discourse/merges", + "archive_url": "https://api.github.com/repos/discourse/discourse/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/discourse/discourse/downloads", + "issues_url": "https://api.github.com/repos/discourse/discourse/issues{/number}", + "pulls_url": "https://api.github.com/repos/discourse/discourse/pulls{/number}", + "milestones_url": "https://api.github.com/repos/discourse/discourse/milestones{/number}", + "notifications_url": "https://api.github.com/repos/discourse/discourse/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/discourse/discourse/labels{/name}", + "created_at": "2013-01-12T00:25:55Z", + "updated_at": "2013-09-28T16:44:54Z", + "pushed_at": "2013-09-27T19:08:59Z", + "git_url": "git://github.com/discourse/discourse.git", + "ssh_url": "git@github.com:discourse/discourse.git", + "clone_url": "https://github.com/discourse/discourse.git", + "svn_url": "https://github.com/discourse/discourse", + "homepage": "http://www.discourse.org", + "size": 48020, + "watchers_count": 7857, + "language": "JavaScript", + "has_issues": true, + "has_downloads": true, + "has_wiki": true, + "forks_count": 1876, + "mirror_url": null, + "open_issues_count": 38, + "forks": 1876, + "open_issues": 38, + "watchers": 7857, + "master_branch": "master", + "default_branch": "master" + } + }, + "_links": { + "self": { + "href": "https://api.github.com/repos/discourse/discourse/pulls/1253" + }, + "html": { + "href": "https://github.com/discourse/discourse/pull/1253" + }, + "issue": { + "href": "https://api.github.com/repos/discourse/discourse/issues/1253" + }, + "comments": { + "href": "https://api.github.com/repos/discourse/discourse/issues/1253/comments" + }, + "review_comments": { + "href": "https://api.github.com/repos/discourse/discourse/pulls/1253/comments" + } + }, + "merged": true, + "mergeable": null, + "mergeable_state": "unknown", + "merged_by": { + "login": "eviltrout", + "id": 17538, + "avatar_url": "https://0.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9?d=https%3A%2F%2Fidenticons.github.com%2Fba01baa4856d494a66a0d5eca39f5418.png", + "gravatar_id": "c6e17f2ae2a215e87ff9e878a4e63cd9", + "url": "https://api.github.com/users/eviltrout", + "html_url": "https://github.com/eviltrout", + "followers_url": "https://api.github.com/users/eviltrout/followers", + "following_url": "https://api.github.com/users/eviltrout/following{/other_user}", + "gists_url": "https://api.github.com/users/eviltrout/gists{/gist_id}", + "starred_url": "https://api.github.com/users/eviltrout/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/eviltrout/subscriptions", + "organizations_url": "https://api.github.com/users/eviltrout/orgs", + "repos_url": "https://api.github.com/users/eviltrout/repos", + "events_url": "https://api.github.com/users/eviltrout/events{/privacy}", + "received_events_url": "https://api.github.com/users/eviltrout/received_events", + "type": "User" + }, + "comments": 2, + "review_comments": 0, + "commits": 1, + "additions": 19, + "deletions": 1, + "changed_files": 4 +} diff --git a/spec/fixtures/onebox/gitlabblob.response b/spec/fixtures/onebox/gitlabblob.response new file mode 100644 index 00000000000..9249619b61e --- /dev/null +++ b/spec/fixtures/onebox/gitlabblob.response @@ -0,0 +1,21 @@ +require_relative '../mixins/git_blob_onebox' + +module Onebox + module Engine + class GitlabBlobOnebox + def self.git_regexp + /^https?:\/\/(www\.)?gitlab\.com.*\/blob\// + end + include Onebox::Mixins::GitBlobOnebox + def raw_regexp + /gitlab\.com\/(?[^\/]+)\/(?[^\/]+)\/blob\/(?[^\/]+)\/(?[^#]+)(#(L(?[^-]*)(-L(?.*))?))?/mi + end + def raw_template(m) + "https://gitlab.com/#{m[:user]}/#{m[:repo]}/raw/#{m[:sha1]}/#{m[:file]}" + end + def title + Sanitize.fragment(URI.unescape(link).sub(/^https?\:\/\/gitlab\.com\//, '')) + end + end + end +end diff --git a/spec/fixtures/onebox/googledocs.response b/spec/fixtures/onebox/googledocs.response new file mode 100644 index 00000000000..be56d2cf1cd --- /dev/null +++ b/spec/fixtures/onebox/googledocs.response @@ -0,0 +1,182 @@ +Lorem Ipsum! - Dokumenty Google
Lorem Ipsum
 Udostępnij
Używana przez Ciebie wersja przeglądarki nie jest już obsługiwana. Uaktualnij przeglądarkę do obsługiwanej wersji.Zamknij

diff --git a/spec/fixtures/onebox/googledrive.response b/spec/fixtures/onebox/googledrive.response new file mode 100644 index 00000000000..caaff11360c --- /dev/null +++ b/spec/fixtures/onebox/googledrive.response @@ -0,0 +1,304 @@ +test.txt - Google Drive + +
Google Account
John Doe
xyz@gmail.com
Main menu
diff --git a/spec/fixtures/onebox/googlephotos.response b/spec/fixtures/onebox/googlephotos.response new file mode 100644 index 00000000000..dd4898a5219 --- /dev/null +++ b/spec/fixtures/onebox/googlephotos.response @@ -0,0 +1,800 @@ + +Mesmerizing Singapore - Google Photos

Press question mark to see available shortcut keys

Mesmerizing Singapore
Sep 23–29
 · 
Shared
Arpit Jalan (Owner)
Add photos
Automatically add photos of people & pets
Select photos
Tip: Drag photos & videos anywhere to upload
Google apps
Main menu
diff --git a/spec/fixtures/onebox/googleplayapp.response b/spec/fixtures/onebox/googleplayapp.response new file mode 100644 index 00000000000..8c21dbacc2c --- /dev/null +++ b/spec/fixtures/onebox/googleplayapp.response @@ -0,0 +1,2269 @@ +Hulu: Stream TV, Movies & more - Apps on Google Play

Hulu: Stream TV, Movies & more

Contains Ads

Enjoy all your TV in one place with a new Hulu experience – more personalized and intuitive than ever before.

The choice is yours - select a plan featuring Hulu’s entire streaming library or one that includes the entire library plus 50+ top Live and On Demand channels.

Access Hulu’s huge streaming library featuring current and past seasons from many popular shows exclusively streaming on Hulu including Seinfeld, Fargo, South Park and Fear the Walking Dead; bold Hulu Originals you can’t stream anywhere else including The Handmaid’s Tale, Harlots, The Mindy Project, and Casual; along with current shows, hit movies, kid’s series and more from many top channels including FOX, NBC, Disney Channel, ABC, Cartoon Network, FX and A&E. Limited and commercial-free options are available for Hulu plans without Live TV.

And now choose from an option to stream Hulu’s entire library, plus over 50 top Live and On Demand channels, including FOX, ABC, NBC, CBS, ESPN, FX, NBCSN, FS1, History Channel and TNT. Watch live sports from top pro and college leagues plus regional sports networks available in many areas. Plus, enjoy national news with local feeds available in select cities, popular kids shows and can’t-miss events.

Features

With any subscription, you’ll enjoy the following features that enhance how you watch TV:
• The more you watch, the better it gets. Enjoy a reimagined TV experience that adjusts to your tastes every time you use Hulu.
• Create up to 6 personalized profiles for the whole household. Enjoy your own collection of shows, movies, networks, and more.
• Track your favorites with My Stuff. Add shows, networks, and movies for quick access across your devices.
• Browse while you watch with Fliptray for recommendations of what to watch next.

Hulu with Live TV (Beta) provides access to additional features including:
• Record Live TV with your Cloud DVR to watch your favorites anytime.
• Watch concurrent streams on multiple devices.
• Track and record games from your favorite teams with My Teams.

Download the Hulu app now, and choose the Hulu with Live TV (Beta) plan which includes the entire Hulu streaming library plus over 50 Live and On Demand channels. Limited and No Commercials plans featuring Hulu’s streaming library without Live TV are also available – the choice is yours.

If you’re new to Hulu, your base Hulu subscription fee will be $7.99/month for the Limited Commercials plan or $11.99/month for the No Commercials plan, or starting at $39.99/month for a Hulu with Live TV (Beta) plan as a recurring transaction starting the end of your free trial (unless you cancel during the free trial). Payment will automatically renew unless you cancel your account at least 24 hours before the end of the current subscription month. You can manage your subscription, cancel anytime, or turn off auto-renewal by accessing your Hulu account via Settings. Hulu is available to US customers only.

Terms of Use: http://www.hulu.com/terms

Privacy Policy: http://www.hulu.com/privacy

This app features third party software, enabling third parties to calculate measurement statistics (e.g., Nielsen’s TV Ratings).

We may work with mobile advertising companies to help deliver online and in-app advertisements tailored to your interests based on your activities on our website and apps and on other, unaffiliated website and apps. To learn more, visit www.aboutads.info. To opt-out of online interest-based advertising, visit www.aboutads.info/choices. To opt-out of cross-app advertising, download the App Choices app at www.aboutads.info/appchoices. Hulu is committed to complying with the DAA’s Self-Regulatory Principles for Online Behavioral Advertising and the DAA’s Application of Self-Regulatory Principles for the Mobile Environment.

Hulu, LLC

Web Site: https://www.hulu.com/

Support: https://help.hulu.com/
Read more
4.0
325,156 total
5
4
3
2
1
Loading...

What's New

Various performance improvements and fixes
Read more

Additional Information

Updated
October 1, 2018
Size
Varies with device
Installs
10,000,000+
Current Version
Varies with device
Requires Android
5.0 and up
Content Rating
Rated for 12+
Parental Guidance Recommended
Interactive Elements
Shares Info
Permissions
Offered By
Hulu
©2018 GoogleSite Terms of ServicePrivacyDevelopersArtistsAbout Google|Location: IndiaLanguage: EnglishAll prices include GST.
By purchasing this item, you are transacting with Google Payments and agreeing to the Google Payments Terms of Service and Privacy Notice.
diff --git a/spec/fixtures/onebox/image.response b/spec/fixtures/onebox/image.response new file mode 100644 index 00000000000..03fdde670c3 Binary files /dev/null and b/spec/fixtures/onebox/image.response differ diff --git a/spec/fixtures/onebox/imgur.response b/spec/fixtures/onebox/imgur.response new file mode 100644 index 00000000000..58cc2ec9a9e --- /dev/null +++ b/spec/fixtures/onebox/imgur.response @@ -0,0 +1,836 @@ + + + + + + + + + + Did you <b>miss me</b>? - Album on Imgur + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + New post + + + +
+ +
+
+
+
+ + + + + + + + + + + +
+
+ + + + +
+
+ +
+ +
+
+
+ +

Did you <b>miss me</b>?

+ +
+ +
+ + + + by + + + + 16h + + + + +
+
+ +
+ + + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ + + + + + +
+ + + + + + +
+ +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
TAKE ME UP
+
+
+
+
+
+
+ +
+ + +
+ + + +
+ +
+ +
+
+
+
+

Embed Code

+ +
+ +
+
+
+
+ +
+
+
+

Use old embed code

+
+
+
+ Copy and paste the HTML below into your website: +
+ +
+
+
+
+ +
+ +
+
+ + +

Preview

+
+
    +
  • + # +
  • +
  • + # +
  • +
  • + # +
  • +
  • + +
  • +
+
+

Hide old embed code

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/instagram.response b/spec/fixtures/onebox/instagram.response new file mode 100644 index 00000000000..f16dc1b5a73 --- /dev/null +++ b/spec/fixtures/onebox/instagram.response @@ -0,0 +1,12 @@ +{ + "version":"1.0", + "author_name":"natgeo", + "provider_name":"Instagram", + "provider_url":"https:\/\/www.instagram.com\/", + "type":"rich", + "width":658, + "html":"\u003Cblockquote class=\"instagram-media\" data-instgrm-captioned data-instgrm-permalink=\"https:\/\/www.instagram.com\/p\/CARbvuYDm3Q\/?utm_source=ig_embed&utm_campaign=loading\" data-instgrm-version=\"13\" style=\" background:#FFF; border:0; border-radius:3px; box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15); margin: 1px; max-width:658px; min-width:326px; padding:0; width:99.375\u0025; width:-webkit-calc(100\u0025 - 2px); width:calc(100\u0025 - 2px);\">\u003Cdiv style=\"padding:16px;\"> \u003Ca href=\"https:\/\/www.instagram.com\/p\/CARbvuYDm3Q\/?utm_source=ig_embed&utm_campaign=loading\" style=\" background:#FFFFFF; line-height:0; padding:0 0; text-align:center; text-decoration:none; width:100\u0025;\" target=\"_blank\"> \u003Cdiv style=\" display: flex; flex-direction: row; align-items: center;\"> \u003Cdiv style=\"background-color: #F4F4F4; border-radius: 50\u0025; flex-grow: 0; height: 40px; margin-right: 14px; width: 40px;\">\u003C\/div> \u003Cdiv style=\"display: flex; flex-direction: column; flex-grow: 1; justify-content: center;\"> \u003Cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 100px;\">\u003C\/div> \u003Cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 60px;\">\u003C\/div>\u003C\/div>\u003C\/div>\u003Cdiv style=\"padding: 19\u0025 0;\">\u003C\/div> \u003Cdiv style=\"display:block; height:50px; margin:0 auto 12px; width:50px;\">\u003Csvg width=\"50px\" height=\"50px\" viewBox=\"0 0 60 60\" version=\"1.1\" xmlns=\"https:\/\/www.w3.org\/2000\/svg\" xmlns:xlink=\"https:\/\/www.w3.org\/1999\/xlink\">\u003Cg stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\">\u003Cg transform=\"translate(-511.000000, -20.000000)\" fill=\"#000000\">\u003Cg>\u003Cpath d=\"M556.869,30.41 C554.814,30.41 553.148,32.076 553.148,34.131 C553.148,36.186 554.814,37.852 556.869,37.852 C558.924,37.852 560.59,36.186 560.59,34.131 C560.59,32.076 558.924,30.41 556.869,30.41 M541,60.657 C535.114,60.657 530.342,55.887 530.342,50 C530.342,44.114 535.114,39.342 541,39.342 C546.887,39.342 551.658,44.114 551.658,50 C551.658,55.887 546.887,60.657 541,60.657 M541,33.886 C532.1,33.886 524.886,41.1 524.886,50 C524.886,58.899 532.1,66.113 541,66.113 C549.9,66.113 557.115,58.899 557.115,50 C557.115,41.1 549.9,33.886 541,33.886 M565.378,62.101 C565.244,65.022 564.756,66.606 564.346,67.663 C563.803,69.06 563.154,70.057 562.106,71.106 C561.058,72.155 560.06,72.803 558.662,73.347 C557.607,73.757 556.021,74.244 553.102,74.378 C549.944,74.521 548.997,74.552 541,74.552 C533.003,74.552 532.056,74.521 528.898,74.378 C525.979,74.244 524.393,73.757 523.338,73.347 C521.94,72.803 520.942,72.155 519.894,71.106 C518.846,70.057 518.197,69.06 517.654,67.663 C517.244,66.606 516.755,65.022 516.623,62.101 C516.479,58.943 516.448,57.996 516.448,50 C516.448,42.003 516.479,41.056 516.623,37.899 C516.755,34.978 517.244,33.391 517.654,32.338 C518.197,30.938 518.846,29.942 519.894,28.894 C520.942,27.846 521.94,27.196 523.338,26.654 C524.393,26.244 525.979,25.756 528.898,25.623 C532.057,25.479 533.004,25.448 541,25.448 C548.997,25.448 549.943,25.479 553.102,25.623 C556.021,25.756 557.607,26.244 558.662,26.654 C560.06,27.196 561.058,27.846 562.106,28.894 C563.154,29.942 563.803,30.938 564.346,32.338 C564.756,33.391 565.244,34.978 565.378,37.899 C565.522,41.056 565.552,42.003 565.552,50 C565.552,57.996 565.522,58.943 565.378,62.101 M570.82,37.631 C570.674,34.438 570.167,32.258 569.425,30.349 C568.659,28.377 567.633,26.702 565.965,25.035 C564.297,23.368 562.623,22.342 560.652,21.575 C558.743,20.834 556.562,20.326 553.369,20.18 C550.169,20.033 549.148,20 541,20 C532.853,20 531.831,20.033 528.631,20.18 C525.438,20.326 523.257,20.834 521.349,21.575 C519.376,22.342 517.703,23.368 516.035,25.035 C514.368,26.702 513.342,28.377 512.574,30.349 C511.834,32.258 511.326,34.438 511.181,37.631 C511.035,40.831 511,41.851 511,50 C511,58.147 511.035,59.17 511.181,62.369 C511.326,65.562 511.834,67.743 512.574,69.651 C513.342,71.625 514.368,73.296 516.035,74.965 C517.703,76.634 519.376,77.658 521.349,78.425 C523.257,79.167 525.438,79.673 528.631,79.82 C531.831,79.965 532.853,80.001 541,80.001 C549.148,80.001 550.169,79.965 553.369,79.82 C556.562,79.673 558.743,79.167 560.652,78.425 C562.623,77.658 564.297,76.634 565.965,74.965 C567.633,73.296 568.659,71.625 569.425,69.651 C570.167,67.743 570.674,65.562 570.82,62.369 C570.966,59.17 571,58.147 571,50 C571,41.851 570.966,40.831 570.82,37.631\">\u003C\/path>\u003C\/g>\u003C\/g>\u003C\/g>\u003C\/svg>\u003C\/div>\u003Cdiv style=\"padding-top: 8px;\"> \u003Cdiv style=\" color:#3897f0; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:550; line-height:18px;\"> View this post on Instagram\u003C\/div>\u003C\/div>\u003Cdiv style=\"padding: 12.5\u0025 0;\">\u003C\/div> \u003Cdiv style=\"display: flex; flex-direction: row; margin-bottom: 14px; align-items: center;\">\u003Cdiv> \u003Cdiv style=\"background-color: #F4F4F4; border-radius: 50\u0025; height: 12.5px; width: 12.5px; transform: translateX(0px) translateY(7px);\">\u003C\/div> \u003Cdiv style=\"background-color: #F4F4F4; height: 12.5px; transform: rotate(-45deg) translateX(3px) translateY(1px); width: 12.5px; flex-grow: 0; margin-right: 14px; margin-left: 2px;\">\u003C\/div> \u003Cdiv style=\"background-color: #F4F4F4; border-radius: 50\u0025; height: 12.5px; width: 12.5px; transform: translateX(9px) translateY(-18px);\">\u003C\/div>\u003C\/div>\u003Cdiv style=\"margin-left: 8px;\"> \u003Cdiv style=\" background-color: #F4F4F4; border-radius: 50\u0025; flex-grow: 0; height: 20px; width: 20px;\">\u003C\/div> \u003Cdiv style=\" width: 0; height: 0; border-top: 2px solid transparent; border-left: 6px solid #f4f4f4; border-bottom: 2px solid transparent; transform: translateX(16px) translateY(-4px) rotate(30deg)\">\u003C\/div>\u003C\/div>\u003Cdiv style=\"margin-left: auto;\"> \u003Cdiv style=\" width: 0px; border-top: 8px solid #F4F4F4; border-right: 8px solid transparent; transform: translateY(16px);\">\u003C\/div> \u003Cdiv style=\" background-color: #F4F4F4; flex-grow: 0; height: 12px; width: 16px; transform: translateY(-4px);\">\u003C\/div> \u003Cdiv style=\" width: 0; height: 0; border-top: 8px solid #F4F4F4; border-left: 8px solid transparent; transform: translateY(-4px) translateX(8px);\">\u003C\/div>\u003C\/div>\u003C\/div> \u003Cdiv style=\"display: flex; flex-direction: column; flex-grow: 1; justify-content: center; margin-bottom: 24px;\"> \u003Cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 224px;\">\u003C\/div> \u003Cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 144px;\">\u003C\/div>\u003C\/div>\u003C\/a>\u003Cp style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; line-height:17px; margin-bottom:0; margin-top:8px; overflow:hidden; padding:8px 0 7px; text-align:center; text-overflow:ellipsis; white-space:nowrap;\">\u003Ca href=\"https:\/\/www.instagram.com\/p\/CARbvuYDm3Q\/?utm_source=ig_embed&utm_campaign=loading\" style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:normal; line-height:17px; text-decoration:none;\" target=\"_blank\">A post shared by National Geographic (\u0040natgeo)\u003C\/a>\u003C\/p>\u003C\/div>\u003C\/blockquote>\n\u003Cscript async src=\"\/\/platform.instagram.com\/en_US\/embeds.js\">\u003C\/script>", + "thumbnail_url":"https:\/\/scontent.cdninstagram.com\/v\/t51.2885-15\/sh0.08\/e35\/s640x640\/97565241_163250548553285_9172168193050746487_n.jpg?_nc_ht=scontent.cdninstagram.com&_nc_cat=105&_nc_ohc=dnXCQ6urT_gAX9KlZ1l&_nc_tp=24&oh=b5fd90cdc61c5a8bba19b41e2f72040c&oe=5FDD8836", + "thumbnail_width":640, + "thumbnail_height":427 +} diff --git a/spec/fixtures/onebox/instagram_old_onebox.response b/spec/fixtures/onebox/instagram_old_onebox.response new file mode 100644 index 00000000000..b0c0a9a5c4e --- /dev/null +++ b/spec/fixtures/onebox/instagram_old_onebox.response @@ -0,0 +1,17 @@ + +{ +"version": "1.0", +"title": "Photo by Pete McBride @pedromcbride | For the first time in three decades, inhabitants of northern India are able to see the Himalaya\u2014thanks to reduced air pollution over the last few weeks. Considering that India experiences some of the worst pollution in the world, this is a literal breath of fresh air. When I was there, the air was so thick you could taste the smoke and fumes.\n\nThe coronavirus pandemic that has led to India's temporary reduction in pollutants has also put the country on the world's largest lockdown, and it's too soon to tell what impact that has had on curbing the disease\u2014as well as what the long-term effects will be on attitudes toward fresh air once the population returns to business as usual. For more on India and the environment, follow @pedromcbride. #india #himalaya #covid19 #pollution", +"author_name": "natgeo", +"author_url": "https://www.instagram.com/natgeo", +"author_id": 787132, "media_id": "2310750110684704208_787132", +"provider_name": "Instagram", +"provider_url": "https://www.instagram.com", +"type": "rich", +"width": 658, +"height": null, +"html": "\u003cblockquote class=\"instagram-media\" data-instgrm-captioned data-instgrm-permalink=\"https://www.instagram.com/p/CARbvuYDm3Q/?utm_source=ig_embed\u0026amp;utm_campaign=loading\" data-instgrm-version=\"13\" style=\" background:#FFF; border:0; border-radius:3px; box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15); margin: 1px; max-width:658px; min-width:326px; padding:0; width:99.375%; width:-webkit-calc(100% - 2px); width:calc(100% - 2px);\"\u003e\u003cdiv style=\"padding:16px;\"\u003e \u003ca href=\"https://www.instagram.com/p/CARbvuYDm3Q/?utm_source=ig_embed\u0026amp;utm_campaign=loading\" style=\" background:#FFFFFF; line-height:0; padding:0 0; text-align:center; text-decoration:none; width:100%;\" target=\"_blank\"\u003e \u003cdiv style=\" display: flex; flex-direction: row; align-items: center;\"\u003e \u003cdiv style=\"background-color: #F4F4F4; border-radius: 50%; flex-grow: 0; height: 40px; margin-right: 14px; width: 40px;\"\u003e\u003c/div\u003e \u003cdiv style=\"display: flex; flex-direction: column; flex-grow: 1; justify-content: center;\"\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 100px;\"\u003e\u003c/div\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 60px;\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"padding: 19% 0;\"\u003e\u003c/div\u003e \u003cdiv style=\"display:block; height:50px; margin:0 auto 12px; width:50px;\"\u003e\u003csvg width=\"50px\" height=\"50px\" viewBox=\"0 0 60 60\" version=\"1.1\" xmlns=\"https://www.w3.org/2000/svg\" xmlns:xlink=\"https://www.w3.org/1999/xlink\"\u003e\u003cg stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\"\u003e\u003cg transform=\"translate(-511.000000, -20.000000)\" fill=\"#000000\"\u003e\u003cg\u003e\u003cpath d=\"M556.869,30.41 C554.814,30.41 553.148,32.076 553.148,34.131 C553.148,36.186 554.814,37.852 556.869,37.852 C558.924,37.852 560.59,36.186 560.59,34.131 C560.59,32.076 558.924,30.41 556.869,30.41 M541,60.657 C535.114,60.657 530.342,55.887 530.342,50 C530.342,44.114 535.114,39.342 541,39.342 C546.887,39.342 551.658,44.114 551.658,50 C551.658,55.887 546.887,60.657 541,60.657 M541,33.886 C532.1,33.886 524.886,41.1 524.886,50 C524.886,58.899 532.1,66.113 541,66.113 C549.9,66.113 557.115,58.899 557.115,50 C557.115,41.1 549.9,33.886 541,33.886 M565.378,62.101 C565.244,65.022 564.756,66.606 564.346,67.663 C563.803,69.06 563.154,70.057 562.106,71.106 C561.058,72.155 560.06,72.803 558.662,73.347 C557.607,73.757 556.021,74.244 553.102,74.378 C549.944,74.521 548.997,74.552 541,74.552 C533.003,74.552 532.056,74.521 528.898,74.378 C525.979,74.244 524.393,73.757 523.338,73.347 C521.94,72.803 520.942,72.155 519.894,71.106 C518.846,70.057 518.197,69.06 517.654,67.663 C517.244,66.606 516.755,65.022 516.623,62.101 C516.479,58.943 516.448,57.996 516.448,50 C516.448,42.003 516.479,41.056 516.623,37.899 C516.755,34.978 517.244,33.391 517.654,32.338 C518.197,30.938 518.846,29.942 519.894,28.894 C520.942,27.846 521.94,27.196 523.338,26.654 C524.393,26.244 525.979,25.756 528.898,25.623 C532.057,25.479 533.004,25.448 541,25.448 C548.997,25.448 549.943,25.479 553.102,25.623 C556.021,25.756 557.607,26.244 558.662,26.654 C560.06,27.196 561.058,27.846 562.106,28.894 C563.154,29.942 563.803,30.938 564.346,32.338 C564.756,33.391 565.244,34.978 565.378,37.899 C565.522,41.056 565.552,42.003 565.552,50 C565.552,57.996 565.522,58.943 565.378,62.101 M570.82,37.631 C570.674,34.438 570.167,32.258 569.425,30.349 C568.659,28.377 567.633,26.702 565.965,25.035 C564.297,23.368 562.623,22.342 560.652,21.575 C558.743,20.834 556.562,20.326 553.369,20.18 C550.169,20.033 549.148,20 541,20 C532.853,20 531.831,20.033 528.631,20.18 C525.438,20.326 523.257,20.834 521.349,21.575 C519.376,22.342 517.703,23.368 516.035,25.035 C514.368,26.702 513.342,28.377 512.574,30.349 C511.834,32.258 511.326,34.438 511.181,37.631 C511.035,40.831 511,41.851 511,50 C511,58.147 511.035,59.17 511.181,62.369 C511.326,65.562 511.834,67.743 512.574,69.651 C513.342,71.625 514.368,73.296 516.035,74.965 C517.703,76.634 519.376,77.658 521.349,78.425 C523.257,79.167 525.438,79.673 528.631,79.82 C531.831,79.965 532.853,80.001 541,80.001 C549.148,80.001 550.169,79.965 553.369,79.82 C556.562,79.673 558.743,79.167 560.652,78.425 C562.623,77.658 564.297,76.634 565.965,74.965 C567.633,73.296 568.659,71.625 569.425,69.651 C570.167,67.743 570.674,65.562 570.82,62.369 C570.966,59.17 571,58.147 571,50 C571,41.851 570.966,40.831 570.82,37.631\"\u003e\u003c/path\u003e\u003c/g\u003e\u003c/g\u003e\u003c/g\u003e\u003c/svg\u003e\u003c/div\u003e\u003cdiv style=\"padding-top: 8px;\"\u003e \u003cdiv style=\" color:#3897f0; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:550; line-height:18px;\"\u003e View this post on Instagram\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"padding: 12.5% 0;\"\u003e\u003c/div\u003e \u003cdiv style=\"display: flex; flex-direction: row; margin-bottom: 14px; align-items: center;\"\u003e\u003cdiv\u003e \u003cdiv style=\"background-color: #F4F4F4; border-radius: 50%; height: 12.5px; width: 12.5px; transform: translateX(0px) translateY(7px);\"\u003e\u003c/div\u003e \u003cdiv style=\"background-color: #F4F4F4; height: 12.5px; transform: rotate(-45deg) translateX(3px) translateY(1px); width: 12.5px; flex-grow: 0; margin-right: 14px; margin-left: 2px;\"\u003e\u003c/div\u003e \u003cdiv style=\"background-color: #F4F4F4; border-radius: 50%; height: 12.5px; width: 12.5px; transform: translateX(9px) translateY(-18px);\"\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"margin-left: 8px;\"\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 50%; flex-grow: 0; height: 20px; width: 20px;\"\u003e\u003c/div\u003e \u003cdiv style=\" width: 0; height: 0; border-top: 2px solid transparent; border-left: 6px solid #f4f4f4; border-bottom: 2px solid transparent; transform: translateX(16px) translateY(-4px) rotate(30deg)\"\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"margin-left: auto;\"\u003e \u003cdiv style=\" width: 0px; border-top: 8px solid #F4F4F4; border-right: 8px solid transparent; transform: translateY(16px);\"\u003e\u003c/div\u003e \u003cdiv style=\" background-color: #F4F4F4; flex-grow: 0; height: 12px; width: 16px; transform: translateY(-4px);\"\u003e\u003c/div\u003e \u003cdiv style=\" width: 0; height: 0; border-top: 8px solid #F4F4F4; border-left: 8px solid transparent; transform: translateY(-4px) translateX(8px);\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e \u003cdiv style=\"display: flex; flex-direction: column; flex-grow: 1; justify-content: center; margin-bottom: 24px;\"\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 224px;\"\u003e\u003c/div\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 144px;\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/a\u003e\u003cp style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; line-height:17px; margin-bottom:0; margin-top:8px; overflow:hidden; padding:8px 0 7px; text-align:center; text-overflow:ellipsis; white-space:nowrap;\"\u003e\u003ca href=\"https://www.instagram.com/p/CARbvuYDm3Q/?utm_source=ig_embed\u0026amp;utm_campaign=loading\" style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:normal; line-height:17px; text-decoration:none;\" target=\"_blank\"\u003eA post shared by National Geographic (@natgeo)\u003c/a\u003e\u003c/p\u003e\u003c/div\u003e\u003c/blockquote\u003e\n\u003cscript async src=\"//www.instagram.com/embed.js\"\u003e\u003c/script\u003e", +"thumbnail_url": "https://scontent-yyz1-1.cdninstagram.com/v/t51.2885-15/sh0.08/e35/s640x640/97565241_163250548553285_9172168193050746487_n.jpg?_nc_ht=scontent-yyz1-1.cdninstagram.com\u0026_nc_cat=105\u0026_nc_ohc=dnXCQ6urT_gAX99AO01\u0026_nc_tp=24\u0026oh=32b676a618164ab0248e2726767dae14\u0026oe=5FDD8836", +"thumbnail_width": 640, +"thumbnail_height": 427 +} diff --git a/spec/fixtures/onebox/kaltura.response b/spec/fixtures/onebox/kaltura.response new file mode 100644 index 00000000000..9195ad4cb21 --- /dev/null +++ b/spec/fixtures/onebox/kaltura.response @@ -0,0 +1,781 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Kaltura Overview - Kaltura Videos + + + + + + + + +
+
+
+
+
+
+
+ + + + + + +
+ + + + + +
+
+ +
+ + + + +
+ + + + +
+ + + + +
+
+
+
+
+
+

+ Kaltura Overview

+

+ + From Alon Finkelstein A year ago   + +

+
+
+ + + + + + likes + + + + + + + + views + + + + + + + comments + + + + + + + + + +
+
+
+
+ + +
+
+
+ +
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+ + +
+ +
+
+ + + + + +
+
+
+ + + + +
+
+ +
+
+
+ + + + + + +
+ + \ No newline at end of file diff --git a/spec/fixtures/onebox/meetup.response.html b/spec/fixtures/onebox/meetup.response.html new file mode 100644 index 00000000000..4e7099aed89 --- /dev/null +++ b/spec/fixtures/onebox/meetup.response.html @@ -0,0 +1,4419 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +February EmberTO Meet-up - +Toronto Ember.js Meetup (Toronto, ON) + + +| Meetup + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
addressalign-toparrow-leftarrow-rightbackbellblockcalendarcameraccwcheckchevron-downchevron-leftchevron-rightchevron-small-downchevron-small-leftchevron-small-rightchevron-small-upchevron-upcircle-with-checkcircle-with-crosscircle-with-pluscrossdots-three-verticaleditemptyheartexporteye-with-lineeyefacebookfolderfullheartglobegmailgooglegroupsimageimagesinstagramlinklocation-pinm-swarmSearchmailmessagesminusmoremuplabelShape 3 + Rectangle 1outlookpersonJoin Group on CardStartprice-ribbonImported LayersImported LayersImported Layersshieldstartickettrashtriangle-downtriangle-uptwitteruseryahoo
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + + + +
+ + +
+ + +
+ + + +
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + + + +
+

+February EmberTO Meet-up +

+
+ + +
+ +
    +
  • + + + +Feb 5, 2015 · 6:30 PM + +
  • + +
  • +
    + + +

    This location is shown only to members

    + +
    +
  • + +
+ + +
+ +

Hey Folks, 

+

We're trying new (read: bigger) venues on for size in 2015, starting with the wonderful new ExChange space in the BrightLane building for our February meet-up. 

+

This month we have our own Jorge Villalobos waxing on building faux-dynamic, SEO-friendly sites with Ember + Middleman, Precision Nutrition's Justin Giancola will be giving a lightning talk on using redis as a proxy when developing ember applications, and Taras Mankovski will be sharing some tips and tricks regarding Ember Table. 

+

Also, special thanks to Brightlane (an awesome new co-working facility) for donating their gorgeous new space to us for the evening as well! Check them out at http://brightlane.ca  

+

See you soon! And as another aside, sign up for the mailing list at http://torontoemberjs.com/to start getting the 411 of our meet-ups directly, including more in-depth insights into our speaker content and more info. on Toronto Ember happenings in 2015. 

+ +
+ +
+
+
+ + +
+

+Join or login to comment. +

+
+ + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
  • Peter C.

    Sorry, can't make it to this meetup. I'll see you all at the next one!

    February 2, 2015

  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • Gianni C.

    hello i used ember once and i plan to do it again in the future

    8 · January 21, 2015

  • + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + + + +
+
+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 55 went + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + + + + + + + + + + + + + + + +
    + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Meghann O. + +
    + + + + + + + + + +
    Event Host
    + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Eric B. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Jesse B. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Aidan N. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Phil S. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Mike + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Andy T. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Tasveer S. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Misha P. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Emerson L. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Zeus G. + + +1 + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Mina S. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Kerry + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Andydrew + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Brian G. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + K M Rakibul I. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Carsten N. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Richard C. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Taras M. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Robin W. + +
    + + + + +
    + Co-Organizer +
    + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Natalie P. + + +1 + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Brennan M. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Justin G. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Tessa T. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Dan O. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Ian I. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Jorge V. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Mattia G. + +
    + + + + +
    + Organizer +
    + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Christophe­r M. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Joshua G. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Nate S. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Brenna O. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Gianni C. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Jaron A. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Joshua K. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + alen + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Adib S. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Irene + + +1 + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + + + + + + + + + + +
    +
    + +
    +
    + + + + +
    + Kenneth B. + +
    + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
  • + + + + +
  • + + +
    +
    + +
    +
    + A former member + +
    +
    + + + + + + +
  • + + + + +
  • + + +
    +
    + +
    +
    + A former member + +
    +
    + + + + + + +
  • + + + + +
  • + + +
    +
    + +
    +
    + A former member + +
    +
    + + + + + + +
  • + + + + +
  • + + +
    +
    + +
    +
    + A former member + +
    +
    + + + + + + +
  • + + + + +
  • + + +
    +
    + +
    +
    + A former member + +
    +
    + + + + + + +
  • + + + + +
  • + + +
    +
    + +
    +
    + A former member + +
    +
    + + + + + + +
  • + + + + +
  • + + +
    +
    + +
    +
    + A former member + +
    +
    + + + + + + +
  • + + + + +
  • + + +
    +
    + +
    +
    + A former member + +
    +
    + + + + + + +
  • + + + + +
  • + + +
    +
    + +
    +
    + A former member + +
    +
    + + + + + + +
  • + + + + +
  • + + +
    +
    + +
    +
    + A former member + +
    +
    + + + + + + +
  • + + + + +
  • + + +
    +
    + +
    +
    + A former member + +
    + +2 + + + + guests + + +
    + +
    +
    + + + + + + +
  • + +
+ + + +
+ +
+ +
+
+
+ + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +

+ +Toronto, + +ON + + +

+
+Founded +Feb 12, 2014
+
+ + +
+ +
+ + + +
+ +
+ + + + +

+ + + +Organizers: + + + +

+
+
+ + + +
+ + +Mattia Gheda, +Robin Ward and 2 more… + + +
+ + + + Contact + +
+
+
+
We're about:
+ +
+ + + + + + + + + + + + + + + +
+ + + + Open Source + · + + + + JavaScript + · + + + + Web Development + · + + + + JavaScript Libraries + · + + + + JavaScript Frameworks + · + + + + Front-end Development + · + + + + nodeJS + · + + + + Backbone.js + · + + + + Ember JS + · + + + + AngularJS + · + + + + JavaScript Applications + · + + + + Ember Data + + +
+ + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +

People in this
Meetup are also in:

+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + +
+ +

Sign up

+ + + +

Meetup members, Log in

+ +
+ +
+ +
+ + +
+ +
+
+or +
+
+ +
+ + + + + + + + + + + +
+ +
+ +
+
+ +
+

+By clicking "Sign up" or "Sign up using Facebook", you confirm that you accept our Terms of Service & Privacy Policy +

+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/meetup_oembed.response b/spec/fixtures/onebox/meetup_oembed.response new file mode 100644 index 00000000000..567890fea0f --- /dev/null +++ b/spec/fixtures/onebox/meetup_oembed.response @@ -0,0 +1 @@ +{"title":"February EmberTO Meet-up","height":397,"width":308,"html":" + + + + + + + + + + + + + + +
+ + + +
+ + + + + +
+ +
+
+
+
+
+
+

ECMAScript 2015 : Deep Dive

+
+
+
+
+ +
+
+
+
+ +
+
+
+

+ David Leonard +

+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+

+ What I do: +

+
+
+
+
+

 

+
    +
  • +

    + + Grad Student at CCNY + +

    +
  • +
  • +

    + + Game Developer + +

    +
  • +
  • +

    + + Yahoo! Developer Network + +

    +
  • +
+
+
+
+
+ +
+
+
+

Why ES6?

+
+
+
+
+ +
+
+
+
+
+

+ Deep Dive +

+
+
+
+
+ +
+
+
+
+
    +
  • +

    Tooling 

    +
  • +
  • +

    Variables and Scoping

    +
  • +
  • +

    Strings

    +
  • +
  • +

    Destructuring

    +
  • +
  • +

    Parameter Handling

    +
  • +
  • +

    Arrow Functions

    +
  • +
  • +

    Classes

    +
  • +
  • +

    Modules

    +
  • +
  • +

    Generators

    +
  • +
  • +

    Promises

    +
  • +
+
+
+
+
+ +
+
+
+

Running ES6

+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+

Variables and Scoping

+
+
+

+ var vs. let / const +

+
+
+
+
+ +
+
+
+
+
var snack = 'Meow Mix';
+
+function getFood(food) {
+    if (food) {
+        var snack = 'Friskies';
+        return snack;
+    }
+    return snack;
+}
+
+getFood(false);
+
+
+ +
+
+ +
+
+
+
+
+
// undefined
+
+
+
+
Credit: https://github.com/venegu
+
+
+

+ var vs. let / const +

+
+
+
+
+ +
+
+
+
+
var snack = 'Meow Mix';
+
+function getFood(food) {
+    var snack;
+    
+    if (food) {
+        snack = 'Friskies';
+        return snack;
+    }
+    return snack;
+}
+
+getFood(false); 
+
+
+ + + +
+
+ +
+
+
+
+
+
+
// undefined
+
+
+

+ var vs. let / const +

+
+
+
+
+ +
+
+
+
+
let snack = 'Meow Mix';
+
+function getFood(food) {
+
+    if (food) {
+        let snack = 'Friskies';
+        return snack;
+    }
+    return snack;
+}
+
+getFood(false); 
+
+
+ +
+
+ +
+
+
+
+
// A
+
+
+
+
// B
+
+
+
+
+
// 'Meow Mix'
+
+
+
+
Credit: https://github.com/venegu
+
+
+

+ var vs. let / const +

+
+
+
+
+ +
+
+
+
+
let snack = 'Meow Mix';
+
+function getFood(food) {
+
+    if (food) {
+        let snack = 'Friskies';
+        return snack;
+    }
+    return snack;
+}
+
+getFood(false); 
+
+
+ +
+
+ +
+
+
+
+
// A
+
+
+
+
// B
+
+
+
+
+
// 'Meow Mix'
+
+
+
+
Credit: https://github.com/venegu
+
+
+

IIFE   > Blocks

+
+
+
+
+ +
+
+
+
+
(function () {  
+    var food = 'Meow Mix';
+}());  
+console.log(food);
+
+
+ +
+
+
+ +
+
+
+
+
// Reference Error
+
+
+

IIFE  > Blocks

+
+
+
+
+ +
+
+
+
+
{  
+    let food = 'Meow Mix';
+} 
+console.log(food); 
+
+
+ +
+
+
+ +
+
+
+
+
// Reference Error
+
+
+

Scoping

+
+
+
+
+ +
+
+ + + + + + +
+
function Person(name) {
+    this.name = name;
+}
+
+Person.prototype.prefixName = function (arr) {
+    return arr.map(function (character) {
+        return this.name + character;
+    });
+};
+
+
+
+ +
+
+
+
+
+
// Cannot read property 'name' of undefined
+
+
+
+
// A
+
+
+
+
// B
+
+
+
+ +
+
+ + + + + + +
+
function Person(name) {
+    this.name = name;
+}
+
+Person.prototype.prefixName = function (arr) {
+    var that = this;
+    return arr.map(function (character) {
+        return that.name + character;
+    });
+};
+
+
+
+ +
+
+
+
+
// Store this
+
+
+
+
+

Scoping

+
+
+
+
+ +
+
+ + + + + + +
+
function Person(name) {
+    this.name = name;
+}
+
+Person.prototype.prefixName = function (arr) {
+    return arr.map(function (character) {
+        return this.name + character;
+    }, this);
+}
+
+
+
+ +
+
+
+
+
+

Scoping

+
+
+
+
+ +
+
+ + + + + + +
function Person(name) {
+    this.name = name;
+}
+
+Person.prototype.prefixName = function (arr) {
+    return arr.map(function (character) {
+        return this.name + character;
+    }.bind(this));
+}
+
+
+ +
+
+
+
+
+

Scoping

+
+
+
+

Arrow Functions

+
+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
function Person(name) {
+    this.name = name;
+}
+
+Person.prototype.prefixName = function (arr) {
+    return arr.map((character) => this.name + character );
+}
+
+
+
+

Arrow Functions

+
+
+
+
+ +
+
+ + + + + + +
+
const arr = [1, 2, 3, 4, 5];
+const squares = arr.map(x => x * x);
+
+
+
const squares = arr.map(function (x) { return x * x });
+
+
+
+ +
+
+
+
+
// Function Expression
+
+
+
+
// Terse 
+
+
+

Strings

+
+
+
+

String.prototype.includes

+
+
+
+
+ +
+
+
+
+
var string = 'food';
+var substring = 'foo';
+console.log(string.indexOf(substring) > -1);
+
+
+ +
+
+
const string = 'food';
+const substring = 'foo';
+console.log(string.includes(substring)); 
+
+
+ + +
+
+ +
+
+
+
+
+
// true
+
+
+
// true
+
+
+

String.prototype.repeat

+
+
+
+
+ +
+
+
+
+
function repeat(string, count) {
+    var strings = [];
+    while(strings.length < count) {
+        strings.push(string);
+    }
+    return strings.join('');
+}
+
+
+
+
+
'meow'.repeat(3); 
+
+
+ +
+
+ +
+
+
+
+
// meowmeowmow
+
+
+

Template Literals: Escaping Characters

+
+
+
+
+ +
+
+
+
+
var text = "This string contains \"double quotes\" which are escaped."
+
+
+
+
+
 
+ +
let text = `This string contains "double quotes" which are escaped.`
+
+ +

 

+
+
+
+
+ +
+
+
+

Template Literals: Interpolation

+
+
+
+
+ +
+
+
+
+
const name = 'Tiger';
+const age = 13;
+console.log(`My cat is named ${name} and is ${age} years old.`);
+
+
+
+
var name = 'Tiger';
+var age = 13;
+console.log('My cat is named ' + name + ' and is ' + age + ' years old.');
+
+ + +
+
+ +
+
+
+
+
+
+
Credit: https://github.com/venegu
+
+
+

Template Literals: Multi-line Strings

+
+
+
+
+ +
+
+
+
var text = (
+  'cat\n' +
+  'dog\n' +
+  'nickelodeon'
+)
+
+
+
var text = [
+  'cat',
+  'dog',
+  'nickelodeon'
+].join('\n')
+
+
+
var text = (
+  `cat
+  dog
+  nickelodeon`
+)
+
+ + + +
+
+ +
+
+
+
+
+

Template Literals: Expressions

+
+
+
+
+ +
+
+
+
+
let today = new Date()
+let text = `The time and date is ${today.toLocaleString()}`
+
+
+
+
+ +
+
+ +
+
+
+
+

Template Literals: Multi-line Strings

+
+
+
+
+ +
+
+ + + + + + +
+
let book = {
+  title: 'Harry Potter and The Sorcercers Stone',
+  summary: 'Much magic. Such depth.',
+  author: 'J.K. Rowling'
+}
+
+let html = `<header>
+  <h1>${book.title}</h1>
+</header>
+<section>
+  <div>${book.summary}</div>
+  <div>${book.author}</div>
+</section>`
+
+
+
+ +
+
+
+
+
+
+

Destructuring

+
+
+
+

Destructuring

+
+
+
+
+ +
+
+ + + + + + +
+

+
var luke = { occupation: 'jedi', father: 'anakin' }
+var {occupation, father} = luke;
+console.log(occupation); // 'jedi'
+console.log(father); // 'anakin'
+
+
+
var [a, b] = [10, 20]
+console.log(a); // 10
+
+ +
console.log(b); // 20
+
+
+
+ +
+
+
+
+
+
+

Destructuring

+
+
+
+
+ +
+
+ + + + + + +
+
function getCoords () {
+  return {
+    x: 10,
+    y: 22
+  }
+}
+
+var {x, y} = getCoords()
+console.log(x); // 10
+console.log(y); // 22
+
+
+
+ +
+
+
+
+

Modules

+
+
+
+

Credit: https://www.flickr.com/photos/lucaohman/3473867313

+
+
+

Exporting in CommonJS

+
+
+
+
+ +
+
+ + + + + + +
+
module.exports = 1
+module.exports = { foo: 'bar' }
+module.exports = ['foo', 'bar']
+module.exports = function bar () {}
+
+
+
export default 1
+export default { foo: 'bar' }
+export default ['foo', 'bar']
+export default function bar () {}
+
+
+
+ +
+
+
+

Named Exports

+
+
+
+
+ +
+
+ + + + + + +
+
module.exports.name = 'David';
+module.exports.age = 25;
+
+
+
export var name = 'David';
+export var age  = 25;​​
+
+
+
+ +
+
+
+

Exporting in ES6

+
+
+
+
+ +
+
+ + + + + + +
+
// math/addition.js
+function sumTwo(a, b) {
+    return a + b;
+}
+
+function sumThree(a, b) {
+    return a + b + c;
+}
+
+
+
+
+
+
+ +
+
+
+
export { sumTwo, sumThree };
+
+
+

Exporting in ES6

+
+
+
+
+ +
+
+ + + + + + +
+
export function sumTwo(a, b) {
+    return a + b;
+}
+
+export function sumThree(a, b) {
+    return a + b + c;
+}
+
+
+
+ +
+
+
+
+
+

Exporting default bindings

+
+
+
+
+ +
+
+ + + + + + +
+
function sumTwo(a, b) {
+    return a + b;
+}
+
+function sumThree(a, b) {
+    return a + b + c;
+}
+
+
+
+
+
+ +
+
+
+
var api = {
+    sumTwo  : sumTwo,
+    sumThree: sumThree
+}
+
+
+
export default api
+
+
+

Importing Modules

+
+
+
+
+ +
+
+ + + + + + +
+
var _ = require('underscore');​
+
+
+
import _ from 'underscore';
+
+
+
import { sumTwo, sumThree } from 'math/addition'
+
+
+
import { 
+  sumTwo as addTwoNumbers, 
+  sumThree as sumThreeNumbers} from
+} from 'math/addition'
+
+
+
import * as util from 'math/addition'
+
+
+
+ +
+
+
+
+
+
+

Parameters

+
+
+
+

Default Parameters

+
+
+
+
+ +
+
+ + + + + + +
+
function addTwoNumbers(x, y) {
+    x = x || 0;
+    y = y || 0;
+    return x + y;
+}
+
+
+
function addTwoNumbers(x=0, y=0) {
+    return x + y;
+}
+
+
+
+
+ +
+
+
+
+
+
+
addTwoNumbers(2, 4); // 6
+addTwoNumbers(2); // 2
+addTwoNumbers(); // 0
+
+
+

Rest Parameters

+
+
+
+
+ +
+
+ + + + + + +
+
function logArguments() {
+    for (var i=0; i < arguments.length; i++) {
+        console.log(arguments[i]);
+    }
+}
+
+
+
function logArguments(...args) {
+    for (let arg of args) {
+        console.log(arg);
+    }
+}
+
+
+
+ +
+
+
+
+
+

Named Parameters

+
+
+
+
+ +
+
+ + + + + + +
+
function initializeCanvas(options) {
+    var height = options.height || 600;
+    var width  = options.width  || 400;
+    var lineStroke = options.lineStroke || 'black';
+}
+
+
+
function initializeCanvas(
+    { height=600, width=400, lineStroke='black'}) {
+        ...
+    }
+
+
+
+ +
+
+
+
+
+
+
function initializeCanvas(
+    { height=600, width=400, lineStroke='black'} = {}) {
+        ...
+    }
+
+
+
+

Spread Operator

+
+
+
+
+ +
+
+ + + + + + +
+
Math.max(...[-1, 100, 9001, -32]) // 9001
+
+ +
+
var arr = [1, ...[2,3], 4];
+console.log(arr); // [1, 2, 3, 4]
+
+
+
+ +
+
+
+
+
var arr1 = [0, 1, 2];
+var arr2 = [3, 4, 5];
+arr1.push(...arr2);
+
+
+

Classes

+
+
+
+

Base Classes

+
+
+
+
+ +
+
+ + + + + + + + +
+
function Person(name, age, gender) {
+    this.name   = name;
+    this.age    = age;
+    this.gender = gender;
+}
+
+Person.prototype.incrementAge = function () {
+    return this.age += 1;
+};
+
+
+
+ +
+
+
+
+
+

Extended Classes

+
+
+
+
+ +
+
+ + + + + + + + +
+
function Personal(name, age, gender, occupation, hobby) {
+    Person.call(this, name, age, gender);
+    this.occupation = occupation;
+    this.hobby = hobby;
+}
+
+Personal.prototype = Object.create(Person.prototype);
+Personal.prototype.constructor = Personal;
+Personal.prototype.incrementAge = function () {
+    return Person.prototype.incrementAge.call(this) += 1;
+}
+
+
+
+ +
+
+
+
+
+
+
+
+
+

Base Classes in ES6

+
+
+
+
+ +
+
+ + + + + + + + +
+
class Person {
+    constructor(name, age, gender) {
+        this.name   = name;
+        this.age    = age;
+        this.gender = gender;
+    }
+    
+    incrementAge() {
+      this.age += 1;
+    }
+}
+
+
+
+ +
+
+
+
+
+

Extended Classes in ES6

+
+
+
+
+ +
+
+ + + + + + + + +
+
class Personal extends Person {
+    constructor(name, age, gender, occupation, hobby) {
+      super(name, age, gender);
+      this.occupation = occupation;
+      this.hobby = hobby;
+    }
+    
+    incrementAge() {
+      super.incrementAge();
+      this.age += 20;
+      console.log(this.age);
+    }
+}
+
+
+
+ +
+
+
+
+
+
+
+
// Calls parent incrementAge()
+
+
+

Symbols

+
+
+
+

Unique Property Keys

+
+
+
+
+ +
+
+ + + + + + + + +
+
const key = Symbol();
+const keyTwo = Symbol();
+const object = {};
+
+
+
+
+ +
+
+
+
>> key === keyTwo 
+>> false
+
+
+
object.key = 'Such magic.';
+object.keyTwo = 'Much Uniqueness'
+
+
+

Symbols as Concepts

+
+
+
+
+ +
+
+ + + + + + + + +
+
const anakin = 'jedi';
+const yoda   = 'jedi master';
+const luke   = 'jedi';
+
+
+
+
+ +
+
+
+
const anakin = Symbol();
+const yoda   = Symbol();
+const luke   = Symbol();
+
+
+

Maps

+
+
+
+

(Hash) Maps in ES5

+
+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
var map = new Object();
+map[key1] = 'value1';
+map[key2] = 'value2';
+
+

Seems functional, right...?

+
+
+

Get Own Properties

+
+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
function getOwnProperty(object, propertyKey) {
+    return (object.hasOwnProperty(propertyKey) ? object[propertyKey]: undefined);
+}
+
+
+
+

We should be safe...right?

+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
> getOwnProperty({ hasOwnProperty: 'Hah, overwritten'}, 'Pwned');
+> TypeError: Propery 'hasOwnProperty' is not a function
+
+
+
+
+
+

Credit: http://memesvault.com/nooo-meme-darth-vader/

+
+

Second time is the charm.

+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
function getOwnProperty(object, propertyKey) {
+    return (Object.prototype.hasOwnProperty(object, propertyKey) ? object[propertyKey]: undefined);
+}
+
+
+
+

credit: http://deloiz.blogspot.com/2014/01/Pusheen.html

+
+
+

Maps in ES6

+
+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
let map = new Map();
+> map.set('name', 'david');
+> map.get('name'); // david
+> map.has('name'); // true
+
+
+
+
+
+
+
+
// key
+
+
+
+
// value
+
+
+
+

Keys can be more than strings!

+
+
+

Arbitrary values as keys

+
+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
let map = new Map([
+    ['name', 'david'],
+    [true, 'false'],
+    [1, 'one'],
+    [{}, 'object'],
+    [function () {}, 'function']
+]);
+
+
+
+
+
+
+
+
+
for (let key of map.keys()) {
+    console.log(typeof key);
+    // > string, boolean, number, object, function
+};
+
+
+

.entries( )

+
+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
for (let entry of map.entries()) {
+  console.log(entry[0], entry[1]);
+}
+
+
+
for (let [key, value] of map.entries()) {
+  console.log(key, value);
+}
+
+
+
+
+

WeakMaps

+
+
+
+

Classes 101

+
+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
class Person {
+    constructor(age) {
+        this.age = age;
+    }
+    
+    incrementAge() {
+      this.age += 1;
+    }
+}
+
+

Private data?

+
+
+

Naming Conventions

+
+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
class Person {
+    constructor(age) {
+        this._age = age;
+    }
+    
+    _incrementAge() {
+      this._age += 1;
+    }
+}
+
+
+
+
+

WeakMaps to the rescue!

+
+
+

(Maybe they're not so weak)

+
+
+

WeakMaps for Privacy

+
+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
let _age = new WeakMap();
+class Person { 
+  constructor(age) {
+    _age.set(this, age);
+  }
+
+  incrementAge() {
+    let age = _age.get(this);
+      if(age > 90) {
+        console.log('Midlife crisis');
+      }
+  }
+}
+
+
+
> const person = new Person(90);
+> person.incrementAge(); // 'Midlife crisis'
+> Reflect.ownKeys(person); // []
+
+ +

 

+
+
+
+
+
+
+
credit: http://wildermuth.com/images/pinky-promise_2.jpg
+
+
+

Promises

+
+
+

Callback Hell

+
+
+
+
+ +
+
+ + + + + + + + +
+
func1(function (value1) {
+  func2(value1, function(value2) {
+    func3(value2, function(value3) {
+      func4(value3, function(value4) {
+        func5(value4, function(value5) {
+          // Do something with value 5
+        });
+      });
+    });
+  });
+});
+
+
+
+ +
+
+
+
+
+
+

D

+ +

O

+ +

O

+ +

M

+
+
+

Promises

+
+
+
+
+ +
+
+ + + + + + + + +
+
func1(value1)
+  .then(func2(value1) { })
+  .then(func3(value2) { })
+  .then(func4(value3) { })
+  .then(func5(value4) { 
+    // Do something with value 5 
+  });
+
+
+
+ +
+
+
+
+

Promises

+
+
+
+
+ +
+
+ + + + + + + + +
+
+
+
+ +
+
+
+

Promises

+
+
+
+
+ +
+
+ + + + + + + + +
+
new Promise(resolve => resolve(data))
+    .then(result => console.log(data));
+
+
+
+
+new Promise((resolve, reject) => 
+    reject(new Error('Failed to fufill Promise')))
+    .catch(reason => console.log(reason));
+
+
+
+ +
+
+
+
+
+
+
+

Promises

+
+
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+
+

Promises

+
+
+
+
+ +
+
+ + + + + + + + +
+
var fetchJSON = function(url) {  
+  return new Promise((resolve, reject) => {
+    $.getJSON(url)
+      .done((json) => resolve(json))
+      .fail((xhr, status, err) => reject(status + err.message));
+  });
+}
+
+
+
+ +
+
+
+
+
+
+
+
+
+

Parallelizing using Promises

+
+
+
+
+ +
+
+ + + + + + + + +
+
var urls = [ 
+  'http://www.api.com/items/1234',
+  'http://www.api.com/items/4567'
+];
+
+var urlPromises = urls.map(fetchJSON);
+
+Promise.all(urlPromises)  
+  .then(function(results) {
+     results.forEach(function(data) {
+     });
+  })
+  .catch(function(err) {
+    console.log("Failed: ", err);
+  });
+
+
+
+
+
+
+

Generators

+
+
+

Syntax

+
+
+
+
+ +
+
+ + + + + + + + +
+
function* sillyGenerator() {
+    yield 1;
+    yield 2;
+    yield 3;
+    yield 4;
+}
+
+
+
var generator = sillyGenerator();
+var value = generator.next();
+> console.log(value); // { value: 1, done: false }
+> console.log(value); // { value: 2, done: false }
+> console.log(value); // { value: 3, done: false }
+> console.log(value); // { value: 4, done: false }
+
+
+
+
+
+

What about using return?

+
+
+

Return in a Generator

+
+
+
+
+ +
+
+ + + + + + + + +
+
function* sillyGenerator() {
+    yield 1;
+    yield 2;
+    yield 3;
+    yield 4;
+    return 5;
+}
+
+for(let val of sillyGenerator()) {
+    console.log(val); // 1, 2, 3, 4
+} 
+
+
+
+
+

Real Generator Function

+
+
+
+
+ +
+
+ + + + + + + + +
+
function* factorial(){
+  let [current, total] = [0, 1];
+
+  while (true){
+    yield total;
+    current++;
+    total = total * current;
+  }
+}
+
+for (let n of factorial()) {
+  console.log(n); 
+  if(n >= 100000) {
+    break;
+  }
+}
+
+
+
+
+
+

Writing Sync-Async 

+
+
+
+
+ +
+
+ + + + + + + + +
+
function request(url) {
+    getJSON(url, function(response) {
+        generator.next(response);
+    });
+}
+
+function* getData() {
+    var entry1 = yield request('http://some_api/item1');
+    var data1  = JSON.parse(entry1);
+    var entry2 = yield request('http://some_api/item2');
+    var data2  = JSON.parse(entry2);
+}
+
+
+
+
+
+

Not without problems though...

+
+
+

 

+ +
    +
  • +

    How do we handle errors?

    +
  • +
  • +

    getJSON not in control

    +
  • +
  • +

    Parallelize?

    +
  • +
+
+
+
+
+ +
+
+
+

Generators & Promises

+
+
+
+
+ +
+
+ + + + + + + + +
+
function request(url) {
+    return new Promise((resolve, reject) => {
+        getJSON(url, resolve);
+    });
+}
+
+
+
+

Generators & Promises

+
+
+
+
+ +
+
+ + + + + + + + +
+
function iterateGenerator(gen) {
+    var generator = gen();
+    var ret;
+    (function iterate(val) {
+        ret = generator.next();
+        if(!ret.done) {
+            ret.value.then(iterate);
+        } else {
+            setTimeout(function() {
+                iterate(ret.value);
+            });
+        }
+    })(); 
+}
+
+
+
+
+
+
+

Generators & Promises

+
+
+
+
+ +
+
+ + + + + + + + +
+
iterateGenerator(function* getData() {
+  var entry1 = yield request('http://some_api/item1');
+  var data1  = JSON.parse(entry1);
+  var entry2 = yield request('http://some_api/item2');
+  var data2  = JSON.parse(entry2);
+});
+
+

Alternate Solution?

+
+
+

Beyond ES6

+
+
+

Async / Await (ES7)

+
+
+
+
+ +
+
+ + + + + + + + +
+
var request = require('request');
+ 
+function getJSON(url) {
+
+  request(url, function(error, response, body) {
+    return body;
+  });
+}
+ 
+function main() {
+  var data = getJSON('http://some_api/item1');
+  console.log(data); // Undefined
+}
+ 
+main();
+
+
+
+
+
+
+
+

Async / Await (ES7)

+
+
+
+
+ +
+
+ + + + + + + + +
+
var request = require('request');
+ 
+function getJSON(url) {
+  return new Promise(function(resolve, reject) {
+    request(url, function(error, response, body) {
+      resolve(body);
+    });
+  });
+}
+ 
+async function main() {
+  var data = await getJSON();
+  console.log(data); // NOT undefined!
+}
+ 
+main();
+console.log('The data is: ');
+
+
+
+
+
+
+

Thank you everyone!

+
+ + + + +
+
+
+
+
+ +
+ +
+ +
+
+
+

ECMAScript 2015

+

By David Leonard

+
+
+ +
+
+
+

+

+
+
+ +
+ +
+
+ + + + +
+ + + +
+ +
+
+

ECMAScript 2015

+

An overview of ES6 features.

+
    +
  • + + +
  • +
  • + + +
  • +
  • + + 3,451 +
  • +
+
+ + + +
+
+
+ +
+
Loading comments...
+
+ +
+

More from David Leonard

+ +
+ +
+ +
+ + +
+ + + + + + + + + + diff --git a/spec/fixtures/onebox/stackexchange-answer.response b/spec/fixtures/onebox/stackexchange-answer.response new file mode 100644 index 00000000000..b3426fd2deb --- /dev/null +++ b/spec/fixtures/onebox/stackexchange-answer.response @@ -0,0 +1 @@ +{"items":[{"tags":["c","deobfuscation"],"owner":{"profile_image":"https://www.gravatar.com/avatar/4af3541c00d591e9a518b9c0b3b1190a?s=128&d=identicon&r=PG","display_name":"dasblinkenlight","link":"http://stackoverflow.com/users/335858/dasblinkenlight"},"last_activity_date":1461433376,"creation_date":1375356813,"answer_id":17992906,"link":"http://stackoverflow.com/questions/17992553/concept-behind-these-four-lines-of-tricky-c-code/17992906#17992906","title":"Concept behind these four lines of tricky C code"}],"has_more":false,"quota_max":300,"quota_remaining":291} \ No newline at end of file diff --git a/spec/fixtures/onebox/stackexchange-question.response b/spec/fixtures/onebox/stackexchange-question.response new file mode 100644 index 00000000000..52bcff8b246 --- /dev/null +++ b/spec/fixtures/onebox/stackexchange-question.response @@ -0,0 +1 @@ +{"items":[{"tags":["c","deobfuscation"],"owner":{"profile_image":"https://www.gravatar.com/avatar/a19d396231d67d604c92866b90fe723d?s=128&d=identicon&r=PG","display_name":"codeslayer1","link":"http://stackoverflow.com/users/2547190/codeslayer1"},"last_activity_date":1461433376,"creation_date":1375355768,"question_id":17992553,"link":"http://stackoverflow.com/questions/17992553/concept-behind-these-four-lines-of-tricky-c-code","title":"Concept behind these four lines of tricky C code"}],"has_more":false,"quota_max":300,"quota_remaining":292} \ No newline at end of file diff --git a/spec/fixtures/onebox/twitterstatus.response b/spec/fixtures/onebox/twitterstatus.response new file mode 100644 index 00000000000..8bb442d5d8e --- /dev/null +++ b/spec/fixtures/onebox/twitterstatus.response @@ -0,0 +1,2814 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Vyki Englert on Twitter: "I'm a sucker for pledges. @Peers Pledge #sharingeconomy http://t.co/T4Sc47KAzh" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ +
    +
  • +

    Add a location to your Tweets

    +

    + When you tweet with a location, Twitter stores that location. + You can switch location on/off before each Tweet and always have the option to delete your location history. + Learn more +

    +
    + + +
    +
  • +
+
+ +
+ +
+
+ +
+
+ + +
+
+
    +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + diff --git a/spec/fixtures/onebox/twitterstatus_quoted.response b/spec/fixtures/onebox/twitterstatus_quoted.response new file mode 100644 index 00000000000..e1c12efe70f --- /dev/null +++ b/spec/fixtures/onebox/twitterstatus_quoted.response @@ -0,0 +1,7199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Metallica en Twitter: "Thank you to everyone who came out for #MetInParis last night for helping us support @EMMAUSolidarite & @PompiersParis. #AWMH #MetalicaGivesBack… https://t.co/00ZbffUluP" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Saltar al contenido + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + +
    +
    + +
      +
    • +

      Twittear con la ubicación

      +

      + Puedes agregar la información de ubicación a tus Tweets, como tu ciudad o tu ubicación exacta, desde la web y a través de aplicaciones de terceros. Siempre tendrás la opción de eliminar el historial de ubicaciones de tus Tweets. + Más información +

      +
      + + +
      +
    • +
    +
    + +
    + +
    +
    + +
    +
    + + +
    +
    +
      +
      +
      + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/onebox/video.response b/spec/fixtures/onebox/video.response new file mode 100644 index 00000000000..abe11ec5c4e Binary files /dev/null and b/spec/fixtures/onebox/video.response differ diff --git a/spec/fixtures/onebox/wikimedia.response b/spec/fixtures/onebox/wikimedia.response new file mode 100644 index 00000000000..3481ce93a45 --- /dev/null +++ b/spec/fixtures/onebox/wikimedia.response @@ -0,0 +1 @@ +{"batchcomplete":"","query":{"normalized":[{"from":"File:Stones_members_montage2.jpg","to":"File:Stones members montage2.jpg"}],"pages":{"-1":{"ns":6,"title":"File:Stones members montage2.jpg","missing":"","known":"","imagerepository":"shared","imageinfo":[{"timestamp":"2010-12-07T23:13:30Z","user":"84user","thumburl":"https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Stones_members_montage2.jpg/500px-Stones_members_montage2.jpg","thumbwidth":500,"thumbheight":459,"url":"https://upload.wikimedia.org/wikipedia/commons/a/af/Stones_members_montage2.jpg","descriptionurl":"https://commons.wikimedia.org/wiki/File:Stones_members_montage2.jpg","descriptionshorturl":"https://commons.wikimedia.org/w/index.php?curid=12245228"}]}}}} \ No newline at end of file diff --git a/spec/fixtures/onebox/wikipedia.response b/spec/fixtures/onebox/wikipedia.response new file mode 100644 index 00000000000..3eb69cadd58 --- /dev/null +++ b/spec/fixtures/onebox/wikipedia.response @@ -0,0 +1,566 @@ + + + + +Billy Jack - Wikipedia, the free encyclopedia + + + + + + + + + + + + + + + + + + + + + + +
      +
      +
      + + +
      +

      Billy Jack

      +
      +
      From Wikipedia, the free encyclopedia
      +
      +
      + Jump to: navigation, search +
      +
      +
      This article is about the 1971 film. For the wrestler of a similar name, see Billy Jack Haynes.
      +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      Billy Jack
      Billy Jack poster.jpg
      +
      Theatrical release poster.
      +
      Directed byTom Laughlin
      +as T.C. Frank
      Produced byTom Laughlin
      +as Mary Rose Solti
      Written byTom Laughlin
      +(as Frank Christina)
      +Delores Taylor
      +(as Theresa Christina)
      StarringTom Laughlin
      +Delores Taylor
      Music byMundell Lowe, Dennis Lambert, Brian Potter
      CinematographyFred Koenekamp
      +John M. Stephens
      Editing byLarry Heath
      +Marion Rothman
      StudioNational Student Film Corporation
      Distributed byWarner Bros.
      Release datesMay 1, 1971
      Running time114 min.
      CountryUnited States
      LanguageEnglish
      Budget$800,000
      Box office$32,500,000[1]
      +

      Billy Jack is a 1971 action/drama independent film; the second of four films centering on a character of the same name which began with the movie The Born Losers (1967), played by Tom Laughlin, who directed and co-wrote the script. Filming began in Prescott, Arizona, in the fall of 1969, but the movie was not completed until 1971. American International Pictures pulled out, halting filming. 20th Century-Fox came forward and filming eventually resumed but when that studio refused to distribute the film, Warner Bros. stepped forward.

      +

      Still, the film lacked distribution, so Laughlin booked it in to theaters himself in 1971.[1] The film died at the box office in its initial run, but eventually took in more than $40 million in its 1973 re-release, with distribution supervised by Laughlin.

      +

      + +

      +

      Plot[edit]

      +

      Billy Jack is a "half-breed" American Navajo Indian[citation needed], a Green Beret Vietnam War veteran, and a hapkido master. The character made his début in The Born Losers (1967), a "biker film" about a motorcycle gang terrorizing a California town. Billy Jack rises to the occasion to defeat the gang when defending a college student with evidence against them for gang rape.

      +

      In the second film, Billy Jack, the hero defends the hippie-themed Freedom School and students from townspeople who do not understand or like the counterculture students. The school is organized by Jean Roberts (Delores Taylor).

      +

      In one scene, a group of Indian children from the school go to town for ice cream and are refused service and then abused and humiliated by Bernard Posner and his gang. This prompts a violent outburst by Billy. Later, Billy's girlfriend Jean is raped and an Indian student is murdered by Bernard (David Roya), the son of the county's corrupt political boss (Bert Freed). Billy confronts Bernard and sustains a gunshot wound before killing him with a hand strike to the throat, after Bernard was having sex with a 13-year-old girl. After a climactic shootout with the police, and pleading from Jean, Billy Jack surrenders to the authorities and is arrested. As he is driven away, a large crowd of supporters raise their fists as a show of defiance and support. The plot continues in the sequel, The Trial of Billy Jack.

      +

      Box-office and critical reception[edit]

      +

      The film was re-released in 1973 and earned an estimated $8,275,000 in North American rentals.[2]

      +

      Billy Jack holds a "Fresh" rating of 62% at Rotten Tomatoes.[3] As of February 2014 it has a score of 6.1 on IMDB.

      +

      In his Movie and Video Guide, film critic Leonard Maltin writes: "Seen today, its politics are highly questionable, and its 'message' of peace looks ridiculous, considering the amount of violence in the film."

      +

      Roger Ebert also saw the message of the film as self-contradictory, writing: "I'm also somewhat disturbed by the central theme of the movie. 'Billy Jack' seems to be saying the same thing as 'Born Losers,' that a gun is better than a constitution in the enforcement of justice."[4]

      +

      Delores Taylor received a Golden Globe nomination as Most Promising Newcoming Actress. Tom Laughlin won the grand prize for the film at the 1971 Taormina International Film Festival in Italy.

      +

      Soundtrack[edit]

      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      Billy Jack
      Soundtrack album by Mundell Lowe
      Released1972
      Recorded1971
      GenreFilm score
      LabelWarner Bros.
      +WS 1926
      ProducerMundell Lowe
      Mundell Lowe chronology
      + + + + + + +
      Satan in High Heels
      +(1961)
      Billy Jack
      +(1971)
      California Guitar
      +(1974)
      +
      +

      The film score was composed, arranged and conducted by Mundell Lowe and the soundtrack album was originally released on the Warner Bros. label.[5]

      +

      Reception[edit]

      +

      The Allmusic review states "a strange and striking combination of styles that somehow is effective... a listenable disc whose flaws only add to the warmth".[6] The film's theme song, "One Tin Soldier (The Legend of Billy Jack)" by the band Coven, became a Top 40 hit in 1971, and featured the chorus:

      +
      +

      Go ahead and hate your neighbor; go ahead and cheat a friend.
      +Do it in the name of heaven; you can justify it in the end.
      +There won't be any trumpets blowin' come the judgment day
      +On the bloody morning after, one tin soldier rides away

      +
      + + + + + + + + + + + + + + + +
      Professional ratings
      Review scores
      SourceRating
      Allmusic3.5/5 stars[6]
      +

      Track listing[edit]

      +

      All compositions by Mundell Lowe, except as indicated.

      +
        +
      1. "One Tin Soldier" (Dennis Lambert, Brian Potter) – 3:18
      2. +
      3. "Hello Billy Jack" – 0:45
      4. +
      5. "Old and the New" – 1:00
      6. +
      7. "Johnnie" (Teresa Kelly) – 2:35
      8. +
      9. "Look, Look to the Mountain" (Kelly) – 1:40
      10. +
      11. "When Will Billy Love Me" (Lynn Baker) – 3:24
      12. +
      13. "Freedom Over Me" (Gwen Smith) – 0:35
      14. +
      15. "All Forked Tongue Talk Alike" – 2:54
      16. +
      17. "Challenge" – 2:20
      18. +
      19. "Rainbow Made of Children" (Baker) – 3:50
      20. +
      21. "Most Beautiful Day" – 0:30
      22. +
      23. "An Indian Dance" – 1:15
      24. +
      25. "Ceremonial Dance" – 1:59
      26. +
      27. "Flick of the Wrist" – 2:15
      28. +
      29. "It's All She Left Me" – 1:56
      30. +
      31. "You Shouldn't Do That" – 3:21
      32. +
      33. "Ring Song" (Katy Moffatt) – 4:25
      34. +
      35. "Thy Loving Hand" – 1:35
      36. +
      37. "Say Goodbye 'Cause You're Leavin'" – 2:36
      38. +
      39. "The Theme from Billy Jack" – 2:21
      40. +
      41. "One Tin Soldier (End Title)" (Lambert, Potter) – 1:06
      42. +
      +

      Personnel[edit]

      +
        +
      • Mundell Lowe: arranger, conductor
      • +
      • Coven featuring Jinx Dawson (tracks 1 & 21), Teresa Kelly (tracks 4 & 5), Lynn Baker (tracks 6 & 10), Gwen Smith (track 7), Katy Moffatt (track 17): vocals
      • +
      • Other unidentified musicians
      • +
      +

      Influence[edit]

      +

      Marketed as an action film, the story focuses on the plight of Native Americans during the civil rights movement. It attained a cult following among younger audiences due to its youth-oriented, anti-authority message and the then-novel martial arts fight scenes which predate the Bruce Lee/kung fu movie trend that followed.[7] The centerpiece of the film features Billy Jack, enraged over the mistreatment of his Indian friends, fighting racist thugs using hapkido techniques.

      +

      Billy Jack's wardrobe (black T-shirt, blue denim jacket, blue jeans, and a black hat with a beadwork band) would become nearly as iconic as the character.

      +

      The second major movie to make use of the word "fuck" (MASH being the first). A black student says the words "fucked up" during the scene where the Freedom school students are talking about the "Second Coming".

      +

      Billy Jack in popular culture[edit]

      +
        +
      • In 1975 (release date 12/30/1974), Firesign Theater, an American comedy group, made reference to Billy Jack on their album, "In The Next World, You're on Your Own," in the form of "Billy Jack Dog Food", and "I'm not Billy Jacking you," among other thematic references.
      • +
      • In 1975, musician Curtis Mayfield recorded and released a song titled, "Billy Jack" on his album There's No Place Like America Today.
      • +
      • In 1976 musician Paul Simon played "Billy Paul" (a parody of Billy Jack, unrelated[8] to musician Billy Paul) in a sketch on the second season of the NBC comedy show Saturday Night Live, after the film Billy Jack aired earlier that evening on NBC.
      • +
      • In 1982, a professional wrestler, Billy Jack Haynes, debuted as "Billy Jack" wearing a hat like Billy Jack. He changed his wrestling name from "Billy Jack" to "Billy Jack Haynes" after Tom Laughlin threatened to sue.
      • +
      • In the series Mystery Science Theater 3000, at least two episodes reference Billy Jack: on the episode Werewolf, after a fight breaks out between a racist dig supervisor and his Indian help, Tom Servo says, "This is where Billy Jack should come riding up."; on the episode Track of the Moon Beast, after the Native American professor finishes telling a story, Crow says, "Uh huh...do you know Billy Jack?"
      • +
      • In an episode of The Simpsons ("Bart of War"), Bart joins a Boy Scouts of America-like group called the "Pre-Teen Braves", and they engage in a rivalry with "the Cavalry Kids". A montage of the two groups fighting each other is set to Coven's version of One Tin Soldier.
      • +
      • The song "Kooler than Jesus" by My Life with the Thrill Kill Kult features samples from the film.
      • +
      • Billy Jack is referenced in an episode of Gilmore Girls ("Red Light on the Wedding Night") while Lorelai and Rory are watching the movie in their living room. At the line "Billy Jack, I'm gonna kill you if it's the last thing I do!", Lorelai responds, "Ugh, he so jinxed himself with that one." Rory replies, "Yeah, he should've said 'Billy Jack, I'm gonna kill you or buy myself a lovely chenille sweater.'"
      • +
      • Upon meeting serial killer Cary Stayner—then considered a possible material witness to a 1999 murder in Yosemite National Park—FBI Agent Jeff Rinek asked if Stayner had ever seen the movie Billy Jack, noting Stayner's resemblance to the film's hero. Initially, Stayner denied seeing the movie.[9] However, 90 minutes later, after building rapport during the drive to the FBI headquarters in Sacramento from the nudist resort where he was picked up, Stayner surprised Rinek by reciting several of Billy Jack's lines.[10]
      • +
      • In the motion picture Major Payne, Damon Wayans as the title character references the iconic fight scene quote "Now, what I'm goin' do is take this right foot and I'm 'a put it 'cross the left side your face."
      • +
      • In season three of the television series Sabrina The Teenage Witch, principal Mr. Kraft reveals that Billy Jack is his favorite film.
      • +
      • Billy Jack was referenced by Jim Carrey in Yes Man.
      • +
      • Metal band Goblin Cock have a song entitled "Ode to Billy Jack" on their 2009 album Come With Me if You Want to Live, which is a tribute to him.
      • +
      • In the movie Drillbit Taylor, actor Owen Wilson references Billy Jack by saying to a cast mate "I am gonna Billy Jack your ass."
      • +
      • In the episode of the animated show Pinky and the Brain, titled "Brainy Jack," Brain assumes the role of the titular Brainy Jack to trick a commune of hippies into helping him take over the world. Brain's wardrobe is a direct reference to Billy Jack, especially the hat with a beaded hat-band. Likewise, the song Pinky sings in the episode is a parody of "One Tin Soldier."
      • +
      • British electro band Relaxed Muscle (fronted by Jarvis Cocker, from Pulp) released a song called "Billy Jack" on their only album A Heavy Nite With... in 2003. It was released as a single with a music video that featured Cocker (as alter ego, Darren Spooner) in Western garb reminiscent of Billy Jack's trademark outfit.
      • +
      • In the Warehouse 13 Season 2 episode "13.1", the brain damaged Hugo Miller shouts "Billy Jack!" excitedly after Myka Bering kicks a gas station attendant who had pulled a gun.
      • +
      • In the book "The Berlin Blues", a play by Drew Hayden Taylor, the character named Trailer references Billy Jack when he says on page 92, "No Cirque du Billy Jack?" when the plan for Ojibway World which was supposed to be opening on the reserve falls through.
      • +
      • Bill Maher referenced Billy Jack in a July 2012 blog post about fundamentalist Mormons.
      • +
      +

      References[edit]

      +
      +
        +
      1. ^ a b Waxman, Sharon (June 20, 2005). "Billy Jack Is Ready to Fight the Good Fight Again". The New York Times. Retrieved 2011-01-02. 
      2. +
      3. ^ "Big Rental Films of 1973", Variety, 9 January 1974 p 19
      4. +
      5. ^ Billy Jack - Rotten Tomatoes
      6. +
      7. ^ Billy Jack - Roger Ebert
      8. +
      9. ^ Mundell Lowe discography accessed August 23, 2012
      10. +
      11. ^ a b Viglione, J. Allmusic Review accessed August 23, 2012
      12. +
      13. ^ Stewart, Jocelyn Y. (January 14, 2007). "Bong Soo Han, 73; grand master of hapkido won film fans for martial arts". Los Angeles Times. Retrieved 2010-11-25. 
      14. +
      15. ^ "The Story of Billy Paul". November 20, 1976. Retrieved 2012-07-15. 
      16. +
      17. ^ Finz, Stacy (December 15, 2002). "The Case of a Lifetime, Part One (2002, December 15)". SFGate.com. Retrieved 2008-12-10. 
      18. +
      19. ^ "The Case of a Lifetime, Part Two (2002, December 15)". SFGate.com. December 14, 2002. Retrieved 2008-12-10. 
      20. +
      +
      +

      External links[edit]

      + + + + + + + + + + + +
      +
      +
      +
      +
      +

      Navigation menu

      +
      + +
      + + +
      +
      + + + +
      +
      + +
      + + + + + + + diff --git a/spec/fixtures/onebox/wikipediaredirected.response b/spec/fixtures/onebox/wikipediaredirected.response new file mode 100644 index 00000000000..3c2f11c7c6e --- /dev/null +++ b/spec/fixtures/onebox/wikipediaredirected.response @@ -0,0 +1,899 @@ + + + +Ruby - Wikipedia, the free encyclopedia + + + + + + + + + + + + + + + + + + +
      +
      +
      + + +
      +

      Ruby

      +
      +
      From Wikipedia, the free encyclopedia
      +
      +
      + Jump to: navigation, search +
      +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      Ruby
      Ruby - Winza, Tanzania.jpg
      +
      Natural ruby crystals from Winza, Tanzania
      +
      General
      CategoryMineral variety
      Formula
      +(repeating unit)
      aluminium oxide with chromium, Al2O3:Cr
      Identification
      ColorRed, may be brownish, purplish, or pinkish
      Crystal habitVaries with locality. Terminated tabular hexagonal prisms.
      Crystal systemTrigonal (Hexagonal Scalenohedral), symbol (−3 2/m), space group R3c
      CleavageNo true cleavage
      FractureUneven or conchoidal
      Mohs scale hardness9.0
      LusterVitreous
      Streakwhite
      Diaphaneitytransparent
      Specific gravity4.0
      Refractive indexnω=1.768–1.772
      +nε=1.760–1.763
      Birefringence0.008
      PleochroismOrangey red, purplish red
      Ultraviolet fluorescencered under longwave
      Melting point2044 °C
      Solubilitynone
      Major varieties
      SapphireAny color except shades of red
      Corundumvarious colors
      EmeryGranular
      +

      A ruby is a pink to blood-red colored gemstone, a variety of the mineral corundum (aluminium oxide). The red color is caused mainly by the presence of the element chromium. Its name comes from ruber, Latin for red. Other varieties of gem-quality corundum are called sapphires. The ruby is considered one of the four precious stones, together with the sapphire, the emerald, and the diamond.[1]

      +

      Prices of rubies are primarily determined by color. The brightest and most valuable "red" called pigeon blood-red, commands a large premium over other rubies of similar quality. After color follows clarity: similar to diamonds, a clear stone will command a premium, but a ruby without any needle-like rutile inclusions may indicate that the stone has been treated. Cut and carat (weight) are also an important factor in determining the price.

      + +

      Physical properties[edit]

      +
      +
      +
      +
      +Crystal structure of ruby
      +
      +
      +

      Rubies have a hardness of 9.0 on the Mohs scale of mineral hardness. Among the natural gems only moissanite and diamond are harder, with diamond having a Mohs hardness of 10.0 and moissonite falling somewhere in between corundum (ruby) and diamond in hardness. Ruby is α-alumina (the most stable form of Al2O3) in which a small fraction of the aluminium3+ ions are replaced by chromium3+ ions. Each Cr3+ is surrounded octahedrally by six O2- ions. This crystallographic arrangement strongly affects each Cr3+, resulting in light absorption in the yellow-green region of the spectrum and thus in the red color of the gem. When yellow-green light is absorbed by Cr3+, it is re-emitted as red luminescence.[2] This red emission adds to the red color perceived by the subtraction of green and violet light from white light, and adds luster to the gem's appearance. When the optical arrangement is such that the emission is stimulated by 694-nanometer photons reflecting back and forth between two mirrors, the emission grows strongly in intensity. This effect was used by Theodore Maiman in 1960 to make the first successful laser, based on ruby.

      +

      All natural rubies have imperfections in them, including color impurities and inclusions of rutile needles known as "silk". Gemologists use these needle inclusions found in natural rubies to distinguish them from synthetics, simulants, or substitutes. Usually the rough stone is heated before cutting. Almost all rubies today are treated in some form, with heat treatment being the most common practice. However, rubies that are completely untreated but still of excellent quality command a large premium.

      +

      Some rubies show a three-point or six-point asterism or "star". These rubies are cut into cabochons to display the effect properly. Asterisms are best visible with a single-light source, and move across the stone as the light moves or the stone is rotated. Such effects occur when light is reflected off the "silk" (the structurally oriented rutile needle inclusions) in a certain way. This is one example where inclusions increase the value of a gemstone. Furthermore, rubies can show color changes—though this occurs very rarely—as well as chatoyancy or the "cat's eye" effect.

      +

      Color[edit]

      +

      Generally, gemstone-quality corundum in all shades of red, including pink, are called rubies.[3][4] However, in the United States, a minimum color saturation must be met to be called a ruby, otherwise the stone will be called a pink sapphire.[3] This distinction between rubies and pink sapphires is relatively new, having arisen sometime in the 20th century. If a distinction is made, the line separating a ruby from a pink sapphire is not clear and highly debated.[5] As a result of the difficulty and subjectiveness of such distinctions, trade organizations such as the International Colored Gemstone Association (ICA) have adopted the broader definition for ruby which encompasses its lighter shades, including pink.[6][7]

      +

      Natural occurrence[edit]

      +

      The Mogok Valley in Upper Myanmar (Burma) was for centuries the world's main source for rubies. That region has produced some of the finest rubies ever mined, but in recent years very few good rubies have been found there. The very best color in Myanmar rubies is sometimes described as "pigeon's blood." In central Myanmar, the area of Mong Hsu began producing rubies during the 1990s and rapidly became the world's main ruby mining area. The most recently found ruby deposit in Myanmar is in Namya (Namyazeik) located in the northern state of Kachin.

      +

      Rubies have historically been mined in Thailand, the Pailin and Samlout District of Cambodia, Burma, India, Afghanistan and in Pakistan. In Sri Lanka, lighter shades of rubies (often "pink sapphires") are more commonly found. After the Second World War ruby deposits were found in Tanzania, Madagascar, Vietnam, Nepal, Tajikistan, and Pakistan.

      +

      A few rubies have been found in the U.S. states of Montana, North Carolina, South Carolina and Wyoming. While searching for aluminous schists in Wyoming, geologist Dan Hausel noted an association of vermiculite with ruby and sapphire and located six previously undocumented deposits.[8]

      +

      More recently, large ruby deposits have been found under the receding ice shelf of Greenland.

      +

      Republic of Macedonia is the only country in mainland Europe to have naturally occurring rubies. They can mainly be found around the city of Prilep. Macedonian ruby has a unique raspberry color.

      +

      In 2002 rubies were found in the Waseges River area of Kenya. There are reports of a large deposit of rubies found in 2009 in Mozambique, in Nanhumbir in the Cabo Delgado district of Montepuez.[9]

      +

      Spinel, another red gemstone, is sometimes found along with rubies in the same gem gravel or marble. Red spinel may be mistaken for ruby by those lacking experience with gems. However, the finest red spinels can have a value approaching that of the average ruby.[10] The color of rubies varies from vermilion to red. The most desired color is "pigeon's blood", which is pure red with a hint of blue. If the color is too pink, the stone is a pink sapphire. The same is true if it is too violet – it is a violet sapphire. The best rubies and star rubies are bright red. Most rubies come from Burma and Thailand.

      +

      Factors affecting value[edit]

      +

      Diamonds are graded using criteria that have become known as the four Cs, namely color, cut, clarity and carat weight. Similarly natural rubies can be evaluated using the four Cs together with their size and geographic origin.

      +

      Color: In the evaluation of colored gemstones, color is the single most important factor. Color divides into three components; hue, saturation and tone. Hue refers to "color" as we normally use the term. Transparent gemstones occur in the following primary hues: red, orange, yellow, green, blue, violet. These are known as pure spectral hues.[11] In nature there are rarely pure hues so when speaking of the hue of a gemstone we speak of primary and secondary and sometimes tertiary hues. In ruby the primary hue must be red. All other hues of the gem species corundum are called sapphire. Ruby may exhibit a range of secondary hues. Orange, purple, violet and pink are possible.

      + +

      The finest ruby is best described as being a vivid medium-dark toned red. Secondary hues add an additional complication. Pink, orange, and purple are the normal secondary hues in ruby. Of the three, purple is preferred because, firstly, the purple reinforces the red making it appear richer.[11] Secondly, purple occupies a position on the color wheel halfway between red and blue. In Burma where the term pigeon blood originated, rubies are set in pure gold. Pure gold is itself a highly saturated yellow. Set a purplish-red ruby in yellow and the yellow neutralizes its complement blue leaving the stone appearing to be pure red in the setting.[citation needed]

      +

      Treatments and enhancements[edit]

      +

      Improving the quality of gemstones by treating them is common practice. Some treatments are used in almost all cases and are therefore considered acceptable. During the late 1990s, a large supply of low-cost materials caused a sudden surge in supply of heat-treated rubies, leading to a downward pressure on ruby prices.

      +

      Improvements used include color alteration, improving transparency by dissolving rutile inclusions, healing of fractures (cracks) or even completely filling them.

      +

      The most common treatment is the application of heat. Most, if not all, rubies at the lower end of the market are heat treated on the rough stones to improve color, remove purple tinge, blue patches and silk. These heat treatments typically occur around temperatures of 1800 °C (3300 °F).[12] Some rubies undergo a process of low tube heat, when the stone is heated over charcoal of a temperature of about 1300 °C (2400 °F) for 20 to 30 minutes. The silk is only partially broken as the color is improved.

      +

      Another treatment, which has become more frequent in recent years, is lead glass filling. Filling the fractures inside the ruby with lead glass (or a similar material) dramatically improves the transparency of the stone, making previously unsuitable rubies fit for applications in jewelry.[13] The process is done in four steps:

      +
        +
      1. The rough stones are pre-polished to eradicate all surface impurities that may affect the process
      2. +
      3. The rough is cleaned with hydrogen fluoride
      4. +
      5. The first heating process during which no fillers are added. The heating process eradicates impurities inside the fractures. Although this can be done at temperatures up to 1400 °C (2500 °F) it most likely occurs at a temperature of around 900 °C (1600 °F) since the rutile silk is still intact.
      6. +
      7. The second heating process in an electrical oven with different chemical additives. Different solutions and mixes have shown to be successful, however mostly lead-containing glass-powder is used at present. The ruby is dipped into oils, then covered with powder, embedded on a tile and placed in the oven where it is heated at around 900 °C (1600 °F) for one hour in an oxidizing atmosphere. The orange colored powder transforms upon heating into a transparent to yellow-colored paste, which fills all fractures. After cooling the color of the paste is fully transparent and dramatically improves the overall transparency of the ruby.[14]
      8. +
      +

      If a color needs to be added, the glass powder can be "enhanced" with copper or other metal oxides as well as elements such as sodium, calcium, potassium etc.

      +

      The second heating process can be repeated three to four times, even applying different mixtures.[15] When jewelry containing rubies is heated (for repairs) it should not be coated with boracic acid or any other substance, as this can etch the surface; it does not have to be "protected" like a diamond.

      +

      The treatment can easily be determined using a 10x loupe and determination focuses on finding bubbles either in the cavities or in the fractures that were filled with glass.[16]

      +

      Synthetic and imitation rubies[edit]

      +
      +
      +
      +
      +
      +
      +
      +
      Artificial ruby under a normal light (top) and under a green laser light (bottom). Red light is emitted
      +
      +
      +
      +

      In 1837 Gaudin made the first synthetic rubies by fusing potash alum at a high temperature with a little chromium as a pigment. In 1847 Ebelmen made white sapphire by fusing alumina in boric acid. In 1877 Frenic and Freil made crystal corundum from which small stones could be cut. Frimy and Auguste Verneuil manufactured artificial ruby by fusing BaF2 and Al2O3 with a little chromium at red heat. In 1903 Verneuil announced he could produce synthetic rubies on a commercial scale using this flame fusion process.[17] By 1910, Verneuil's laboratory had expanded into a 30 furnace production facility, with annual gemstone production having reached 1,000 kilograms (2,000 lb) in 1907.

      +

      Other processes in which synthetic rubies can be produced are through Czochralski's pulling process, flux process, and the hydrothermal process. Most synthetic rubies originate from flame fusion, due to the low costs involved. Synthetic rubies may have no imperfections visible to the naked eye but magnification may reveal curves, striae and gas bubbles. The fewer the number and the less obvious the imperfections, the more valuable the ruby is; unless there are no imperfections (i.e., a "perfect" ruby), in which case it will be suspected of being artificial. Dopants are added to some manufactured rubies so they can be identified as synthetic, but most need gemological testing to determine their origin.

      +

      Synthetic rubies have technological uses as well as gemological ones. Rods of synthetic ruby are used to make ruby lasers and masers. The first working laser was made by Theodore H. Maiman in 1960[18] at Hughes Research Laboratories in Malibu, California, beating several research teams including those of Charles H. Townes at Columbia University, Arthur Schawlow at Bell Labs,[19] and Gould at a company called TRG (Technical Research Group). Maiman used a solid-state light-pumped synthetic ruby to produce red laser light at a wavelength of 694 nanometers (nm). Ruby lasers are still in use. Rubies are also used in applications where high hardness is required such as at wear exposed locations in modern mechanical clockworks, or as scanning probe tips in a coordinate measuring machine.

      +

      Imitation rubies are also marketed. Red spinels, red garnets, and colored glass have been falsely claimed to be rubies. Imitations go back to Roman times and already in the 17th century techniques were developed to color foil red—by burning scarlet wool in the bottom part of the furnace—which was then placed under the imitation stone.[20] Trade terms such as balas ruby for red spinel and rubellite for red tourmaline can mislead unsuspecting buyers. Such terms are therefore discouraged from use by many gemological associations such as the Laboratory Manual Harmonisation Committee (LMHC).

      +

      Records and famous rubies[edit]

      + +
        +
      • The Smithsonian's National Museum of Natural History in Washington, D.C. has received one of the world's largest and finest ruby gemstones. The 23.1 carats (4.6 g) Burmese ruby, set in a platinum ring with diamonds, was donated by businessman and philanthropist Peter Buck in memory of his late wife Carmen Lúcia. This gemstone displays a richly saturated red color combined with an exceptional transparency. The finely proportioned cut provides vivid red reflections. The stone was mined from the Mogok region of Burma (now Myanmar) in the 1930s.[21]
      • +
      • In 2007 the London jeweler Garrard & Co featured on their website a heart-shaped 40.63-carat ruby.[22]
      • +
      • On December 13/14, 2011 Elizabeth Taylor's complete jewellery collection was auctioned by Christie's. Several ruby-set pieces were included in the sale, notably a ring set with an 8.24 ct gem that broke the 'price-per-carat' record for rubies ($512,925 per carat, i.e. over $4.2 million in total),[23] and a necklace[24] that sold for over $3.7 million.
      • +
      • The Liberty Bell Ruby is the largest mined ruby in the world. It was stolen in a heist in 2011.[25]
      • +
      +

      Historical and cultural references[edit]

      +
        +
      • An early recorded transport and trading of rubies arises in the literature on the North Silk Road of China, wherein about 200 BC rubies were carried along this ancient trackway moving westward from China.[26]
      • +
      • Rubies have always been held in high esteem in Asian countries. They were used to ornament armor, scabbards, and harnesses of noblemen in India and China. Rubies were laid beneath the foundation of buildings to secure good fortune to the structure.[27]
      • +
      +

      See also[edit]

      + +

      References[edit]

      +
      +
        +
      1. ^ Precious Stones, Max Bauer, p. 2
      2. +
      3. ^ "Ruby: causes of color". Retrieved 15 may 2009. 
      4. +
      5. ^ a b Matlins, Antoinette Leonard (2010). Colored Gemstones. Gemstone Press. p. 203. ISBN 0-943763-72-X. 
      6. +
      7. ^ Reed, Peter (1991). Gemmology. Butterworth-Heinemann. p. 337. ISBN 0-7506-6449-5. 
      8. +
      9. ^ Wise, Richard G. "Gemstone Connoisseurship; The Finer Points, Part II". 
      10. +
      11. ^ Hughes, Richard W. "Walking the line in ruby & sapphire". ruby-sapphire.com. 
      12. +
      13. ^ Federman, David. "Pink Sapphire". Modern Jeweler. 
      14. +
      15. ^ Hausel, W. Dan (2009). Gems, Minerals and Rocks of Wyoming. Book Surge. p. 176. ISBN 1-4392-1856-0. 
      16. +
      17. ^ Mozambique: Police Seize Boat With 96 Illegal Immigrants. AllAfrica. 4 November 2010
      18. +
      19. ^ Wenk, Hans-Rudolf; Bulakh, A. G. (2004). Minerals: their constitution and origin. Cambridge, U.K.: Cambridge University Press. pp. 539–541. ISBN 0-521-52958-1. 
      20. +
      21. ^ a b Wise, Richard W. (2006). Secrets Of The Gem Trade, The Connoisseur's Guide To Precious Gemstones. Brunswick House Press. pp. 18–22. ISBN 0-9728223-8-0. 
      22. +
      23. ^ The Heat Treatment of Ruby and Sapphire. Bangkok, Thailand: Gemlab Inc. 1992. ISBN 0940965100. 
      24. +
      25. ^ Vincent Pardieu Lead Glass Filled/Repaired Rubies. Asian Institute of Gemological Sciences Gem Testing Laboratory. February 2005
      26. +
      27. ^ Richard W. Hughes (1997), Ruby & Sapphire, Boulder, CO, RWH Publishing, ISBN 978-0-9645097-6-4
      28. +
      29. ^ Milisenda, C C (2005). "Rubine mit bleihaltigen Glasern gefullt". Zeitschrift der Deutschen Gemmologischen Gesellschaft (in German) (Deutschen Gemmologischen Gesellschaft) 54 (1): 35–41. 
      30. +
      31. ^ "Lead Glass-Filled Rubies". GIA Global Dispatch (Gemological Institute of America). 2012. 
      32. +
      33. ^ "Bahadur: a Handbook of Precious Stones". 1943. Retrieved 2007-08-19. 
      34. +
      35. ^ Maiman, T.H. (1960). "Stimulated optical radiation in ruby". Nature 187 (4736): 493–494. Bibcode:1960Natur.187..493M. doi:10.1038/187493a0. 
      36. +
      37. ^ Hecht, Jeff (2005). Beam: The Race to Make the Laser. Oxford University Press. ISBN 0-19-514210-1. 
      38. +
      39. ^ "Thomas Nicols: A Lapidary or History of Gemstones". 1652. Retrieved 2007-08-19. 
      40. +
      41. ^ "The Carmen Lúcia Ruby". Exhibitions. Retrieved 2008-02-28. 
      42. +
      43. ^ "Garrards – Treasures (large and important jewelry pieces)". Retrieved 2010-11-08. 
      44. +
      45. ^ The Legendary Jewels, Evening Sale & Jewelry (Sessions II and III) | Press Release | Christie's. Christies.com (2011-12-14). Retrieved on 2012-07-11.
      46. +
      47. ^ Elizabeth Taylor's ruby and diamond necklace. News.yahoo.com (2011-09-07). Retrieved on 2012-07-11.
      48. +
      49. ^ http://philadelphia.cbslocal.com/2012/01/09/irreplaceable-2-million-ruby-stolen-in-wilmington-jewelry-heist/
      50. +
      51. ^ C. Michael Hogan, Silk Road, North China, The Megalithic Portal. 19 November 2007
      52. +
      53. ^ Smith, Henry G. (1896). "Chapter 2, Sapphires, Rubies". Gems and Precious Stones. Charles Potter Government Printer, Australia. 
      54. +
      +
      +

      External links[edit]

      + + + + + + + +


      + + + + + + + + + + +
      +
      +
      +
      +
      +

      Navigation menu

      +
      + +
      + + +
      +
      + + + +
      +
      +
      + + + + + + +
      +
      + + + + + + + diff --git a/spec/fixtures/onebox/xkcd.response b/spec/fixtures/onebox/xkcd.response new file mode 100644 index 00000000000..00c7bd6df5d --- /dev/null +++ b/spec/fixtures/onebox/xkcd.response @@ -0,0 +1 @@ +{"month": "10", "num": 327, "link": "", "year": "2007", "news": "", "safe_title": "Exploits of a Mom", "transcript": "[[A woman is talking on the phone, holding a cup]]\nPhone: Hi, this is your son's school. We're having some computer trouble.\nMom: Oh dear\u00c3\u00a2\u00c2\u0080\u00c2\u0094did he break something?\nPhone: In a way\u00c3\u00a2\u00c2\u0080\u00c2\u0094\nPhone: Did you really name your son \"Robert'); DROP TABLE Students;--\" ?\nMom: Oh, yes. Little Bobby Tables, we call him.\nPhone: Well, we've lost this year's student records. I hope you're happy.\nMom: And I hope you've learned to sanitize your database inputs.\n{{title-text: Her daughter is named Help I'm trapped in a driver's license factory.}}", "alt": "Her daughter is named Help I'm trapped in a driver's license factory.", "img": "http:\/\/imgs.xkcd.com\/comics\/exploits_of_a_mom.png", "title": "Exploits of a Mom", "day": "10"} \ No newline at end of file diff --git a/spec/fixtures/onebox/youku-meta.response b/spec/fixtures/onebox/youku-meta.response new file mode 100644 index 00000000000..4948d1ab44a --- /dev/null +++ b/spec/fixtures/onebox/youku-meta.response @@ -0,0 +1 @@ +{"data":[{"ct":"f","cs":"2139","logo":"http:\/\/g2.ykimg.com\/1100641F46528C827D38F313B54FF057305EFD-1D91-46C5-821D-DC010415AC44","seed":6981,"tags":["\u674e\u5b97\u76db","\u5317\u4eac\u6f14\u5531\u4f1a","\u9ad8\u6e05"],"categories":"95","videoid":"159325444","vidEncoded":"XNjM3MzAxNzc2","username":"\u7f2a\u65af\u5de6\u53f3\u624b","userid":"330649584","title":"20131116\u674e\u5b97\u76db\u65e2\u7136\u9752\u6625\u7559\u4e0d\u4f4f\u5317\u4eac\u6f14\u5531\u4f1a_\u5c71\u4e18\u9ad8\u6e05","up":0,"down":0,"ts":"eCPhhDOr8KU0kcSeAbfYFkk","tsup":"eCPlljCr8KU0kcSeAqXcFkk","preview":{"thumbs":["05210001528C82AA6A074A508F0ED369"],"sectiontime":"6000","host":"http:\/\/g2.ykimg.com\/"},"key1":"b344b3f4","key2":"f502ea489c71b119","tt":"0","videoSource":"1","seconds":"426.46","streamfileids":{"flv":"38*15*38*38*38*40*38*40*38*38*53*40*30*57*7*0*2*43*53*0*25*57*43*15*48*53*2*63*63*38*38*43*38*40*25*57*32*49*60*32*32*2*58*60*30*48*58*32*60*63*63*0*58*60*38*32*15*63*40*58*63*57*43*2*40*57*","mp4":"38*15*38*38*38*30*38*40*38*38*53*40*30*57*7*25*48*43*53*0*25*57*43*15*48*53*2*63*63*38*38*43*38*40*25*57*32*49*60*32*32*2*58*60*30*48*58*32*60*63*63*0*58*60*38*32*15*63*40*58*63*57*43*2*40*57*","hd2":"38*15*38*38*38*43*38*15*38*38*53*40*30*57*30*40*7*49*53*0*25*57*43*15*48*53*2*63*63*38*38*43*38*40*25*57*32*49*60*32*32*2*58*60*30*48*58*32*60*63*63*0*58*60*38*32*15*63*40*58*63*57*43*2*40*57*"},"segs":{"flv":[{"no":"0","size":"6184613","seconds":205,"k":"37c1eef9d45943ef261e1651","k2":"143edcb51afd4d171"},{"no":"1","size":"9216438","seconds":222,"k":"220a2502b066ad2d24120d63","k2":"1139d718a87e8cc40"}],"mp4":[{"no":"0","size":"13698266","seconds":228,"k":"76da9c943b762c3724120d63","k2":"16661d1c25fdaee0c"},{"no":"1","size":"15953117","seconds":199,"k":"28efdc07f014982624120d63","k2":"13b9062ccfa57562d"}],"hd2":[{"no":"0","size":"27687337","seconds":205,"k":"29f6914d58f7f3a7282a1f3e","k2":"1bd4a6f687452e7fe"},{"no":"1","size":"16198622","seconds":119,"k":"524dd8fbfb7de533261e1651","k2":"163153a4deeaa0408"},{"no":"2","size":"21240220","seconds":102,"k":"e3cafd8cbe886d8e261e1651","k2":"1c4344748c6bdac6c"}]},"streamsizes":{"flv":"15401051","mp4":"29651383","hd2":"65126179"},"stream_ids":{"flv":"34575279","mp4":"34575265","hd2":"34575346"},"streamlogos":{"flv":1,"mp4":1,"hd2":1},"streamtypes":["flv","mp4","hd2"],"streamtypes_o":["hd2","flvhd","mp4"]}],"user":{"id":0},"controller":{"search_count":true,"mp4_restrict":1,"stream_mode":1,"video_capture":true,"hd3_enabled":false,"area_code":617,"dma_code":506,"continuous":0,"playmode":"normal","circle":false,"tsflag":false,"other_disable":false,"xplayer_disable":false,"app_disable":false,"share_disabled":false,"download_disabled":false,"pc_disabled":false,"pad_disabled":false,"mobile_disabled":false,"tv_disabled":false,"comment_disabled":false}} \ No newline at end of file diff --git a/spec/fixtures/onebox/youku.response b/spec/fixtures/onebox/youku.response new file mode 100644 index 00000000000..e669e3fd9cb --- /dev/null +++ b/spec/fixtures/onebox/youku.response @@ -0,0 +1,1443 @@ + + + + +20131116李宗盛既然青春留不住北京演唱会_山丘高清—在线播放—优酷网,视频高清在线观看 + + + + + + + + + + + + + + + + + + + + + + + +
      +
      +
      +
      + + + +
      +
      +
      + +
      +
      + +
      +
      +
      +
      + + +
      +
      +
      + APP下载 +
      +
      +
      +
      + 会员 +
      +
      + +
      +
      + +
      +
      + +
      +
      + + + + +
      + +
      +
      +
      +
      +
      + 音乐频道 + > + 音乐列表 + > + + 演唱会 +
      +
      +

      视频: 20131116李宗盛既然青春留不住北京演唱会_山丘高清

      +
      + +
      +
      + + + +
      +
      +
      +
      +
      +
      + +
      +
      +
      +
      + +
      +
      +
      +
      +
      + +
      + +
      +
      +
      + +
      +
      +
      +
      + +
      +
      +
      +
      +
      + 00:00/00:00 +
      +
      +
      标屏
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      + +
      +
      +
      + +
      +
      +
      + +
      + +
      +
      + +
      + +
      +
      +
      +
      +
      +
      +
      +
      + Galaxy A 年轻正发声 +
      01:00
      +
      +
      + +
      + +
      5,105,100
      +
      +
      +
      +
      + 群星演绎为爱热牛奶 +
      04:55
      +
      +
      + +
      + +
      8,862,094
      +
      +
      + +
      +
      + +
      + +
      + +
      +
      +
      + + +
      +
      +
      +
      + +
      +
      +
      +
      +
      + +
      +
      + + +
      +
      + + +
      + +
      + +
      + + + +
      + +
      + +
      +
      +
      +
        +
      • +
      • +
      • +
      • +
      + 分享给好友 +
      +
      +
      +
      + + +
      + +
      + + + + + + +
      +
      + +
      +
      +
      +
      + +
      + +
      +
      订  阅
      +
      +
      +
      +
      +
      既然青春留不住,“爱的代价”“哭”“领悟”; +越过“山丘”“笑红尘”,“希望”“你走你的路”。 + +李宗盛“既然青春留不住” 北京演唱会(歌单) +part1人生的梦醒时分 +1.《爱情有什么道理》(张艾嘉) +2.《忙与盲》(张艾嘉) +3.《阿宗的三件事》(李宗盛) +4.《生命中的精灵》(李宗盛) +5....详情
      + +
      1年前 上传
      + +
      +
      + + +
      + +
      +
      + +
      + +
      +
      + +
      +
      + + + + +
      + +
      0/300
      +
      +
      +
      +
      +
      +
      + +
      +
      +
      + + 稀饭 + + + 无语 + 难过 + + 搞笑 + + '+ +
      +
      +
      +
      +
      +
      + +
      +
      +
      +
      +
      发表评论
      + +
      +
      + + + +
      +
      +
      +
      +
      +
      + + + +
      +
      + 发表评论 +
      +
      + +
      +
      + + +
      +
      + +
      + +
      +
      + +
      +
      + + +
      +
      +
      +
      + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spec/fixtures/onebox/youtube-channel.response b/spec/fixtures/onebox/youtube-channel.response new file mode 100644 index 00000000000..3db11c8b6ec --- /dev/null +++ b/spec/fixtures/onebox/youtube-channel.response @@ -0,0 +1,5446 @@ + + + + + + + + + + + + + + Google Chrome + - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + + + + + + + + + + + + + + + + + + + + + + +
      + + + + + + + + + + +
      +
      +
      +
      + +
      + +
      +
      +
      +
      Upload
      +
      + + Google Chrome + +
      + +
      + +
      +
      +
      + +
      + + +
      + +
      + + +
      + +
      + + +
      + +
      + + + +
      + +
      + + +
      + +
      + + +
      + +
      + + +
      + +
      + + +
      + +
      + + +
      + +
      + + +
      + +
      + + +
      + +
      + + +
      +
      +
      + +
      +
      + +
      +
      + +
      + +
      + +
      +
      + +
      + +
      + +
      + + + + + +
      + +
      +
      +
      + + + + +
      +
      +
      +
      + + +
      +
      +
      +
      + + + + Google Chrome + + +
      + +
      +
      +
      + 846,509 + +
      + +
      +
      + +
      +
      +
      +

      + Subscription preferences + + +

      +
      +
      +
      +
      Loading...
      +
      + +
      +
      +
      +
      +

      + Loading icon + + +Loading... + +

      + +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      Working...
      +
      +
      + +
      +
      +
      +
      +
      + + +
      + +
      + +
      +

      + Google Chrome +

      +
      + +
      + +
      + + +
      + + + +
      + +
      +
      +
      +
      +
      + + + + + + + + + +
      +
      + +
      + + + + +
      + +
      +
      +
      + + + + + +
      +
      +
      +
      +
      + + +
      +
      + +
      +
      +
      +
      +
      Loading...
      +
      + +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      Working...
      +
      +
      + +
      +
      +
      +
      +
      + + +
      + to add this to Watch Later + +
      +
      +
      +

      +Add to +

      +
      +
      +
      +
      +
      + + + + + + + + diff --git a/spec/fixtures/onebox/youtube-embed.response b/spec/fixtures/onebox/youtube-embed.response new file mode 100644 index 00000000000..7395472b47e --- /dev/null +++ b/spec/fixtures/onebox/youtube-embed.response @@ -0,0 +1,7 @@ +YouTube
      \ No newline at end of file diff --git a/spec/fixtures/onebox/youtube-playlist.response b/spec/fixtures/onebox/youtube-playlist.response new file mode 100644 index 00000000000..1b67e5f95ed --- /dev/null +++ b/spec/fixtures/onebox/youtube-playlist.response @@ -0,0 +1,221 @@ + + + + + + + + The web is what you make of it - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spec/fixtures/onebox/youtube.response b/spec/fixtures/onebox/youtube.response new file mode 100644 index 00000000000..4915ea2ca0d --- /dev/null +++ b/spec/fixtures/onebox/youtube.response @@ -0,0 +1,1625 @@ + + + + + + + + +96neko - orange - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      +
      IN +
      Upload
      + +
      + + + + + + +
      + +
      + +
      + +
      +
      + +
      + +
      + +
      +

      + This video is unavailable. +

      +
      +
      +
      + + +
      + +
      + + + + + + + + + +
      + +
      + +
      +
      +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      +
      +
      +
      +
      +

      + + + + + + + + 96neko - orange + + +

      +
      + + +
      +
      +
      +

      Sign in to YouTube

      +
      + Sign in with your Google Account (YouTube, Google+, Gmail, Orkut, Picasa, or Chrome) to like korotto5810's video. + +
      +
        +
      • +
        +
      • +
      • +
        +
      • +
      • +
        +
      • +
      • +
        +
      • +
      • +
        +
      • +
      + +
      +
      +

      Sign in to YouTube

      +
      + Sign in with your Google Account (YouTube, Google+, Gmail, Orkut, Picasa, or Chrome) to dislike korotto5810's video. + +
      +
        +
      • +
        +
      • +
      • +
        +
      • +
      • +
        +
      • +
      • +
        +
      • +
      • +
        +
      • +
      + +
      +
      +
      +
      + + + + + + + + + + +
      +

      Sign in to YouTube

      +
      + Sign in with your Google Account (YouTube, Google+, Gmail, Orkut, Picasa, or Chrome) to add korotto5810's video to your playlist. + +
      +
        +
      • +
        +
      • +
      • +
        +
      • +
      • +
        +
      • +
      • +
        +
      • +
      • +
        +
      • +
      + +
      + +
      + + + + + + + + + + +
      +
      + +
      +
      +
      +
      +
      +

      + Uploaded on Apr 29, 2011 + +

      + +
      +

      From NicoNicoDouga
      http://nine.nicovideo.jp/watch/sm1423...

      MP3 download link
      http://nicosound.anyap.info/sound/sm1...
      「MP3 を 抽出」←click

      eng & romaji
      http://www.animelyrics.com/doujin/voc...

      +
      +
      + +
        +
      • +

        +Category +

        +
        +

        Music

        + +
        +
      • + + +
      • +

        License

        +
        +

        +Standard YouTube License +

        + +
        +
      • +
      +
      + +
      +
        + +
      +
      + +
      +
      + +
      +
      + +
      +
      +
      +
      + +
      +
      +
      +

      + Loading icon + + +Loading... + +

      + +
      +
      +
      + +
      + +
      +
      +

      + Loading icon + + +Loading... + +

      + +
      +
      + +
      +
      +

      + Loading icon + + +Loading... + +

      + +
      +
      + +
      +
      +

      + Loading icon + + +Loading... + +

      + +
      +
      + +
      + +
      + +
      +
      + Ratings have been disabled for this video. +
      + +
      + +
      +
      + Rating is available when the video has been rented. +
      + +
      + +
      +
      + This feature is not available right now. Please try again later. +
      +
      + + +
      + +
      + + + +
      + +
      +
      +
      +

      + Loading icon + + +Loading... + +

      + +
      +
      + + +
      + + +
      +
      +
      + +
      + + + +
      + +
      + +
      +
      + +
      +
      +
      + +
      +
      + +
      + +
      + + +
      +
      + +
      +
      +
      +
      +
      Loading...
      +
      + +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      +
      Working...
      +
      +
      + +
      +
      +
      +
      +
      + + + +
      + to add this to Watch Later + +
      + + + + + + + +
      + + diff --git a/spec/lib/onebox/engine/allowlisted_generic_onebox_spec.rb b/spec/lib/onebox/engine/allowlisted_generic_onebox_spec.rb new file mode 100644 index 00000000000..b66c91c14fc --- /dev/null +++ b/spec/lib/onebox/engine/allowlisted_generic_onebox_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Onebox::Engine::AllowlistedGenericOnebox do + describe ".===" do + it "matches any domain" do + expect(described_class === URI('http://foo.bar/resource')).to be(true) + end + + it "doesn't match an IP address" do + expect(described_class === URI('http://1.2.3.4/resource')).to be(false) + expect(described_class === URI('http://1.2.3.4:1234/resource')).to be(false) + end + end + + describe 'html_providers' do + class HTMLOnebox < Onebox::Engine::AllowlistedGenericOnebox + def data + { + html: 'cool html', + height: 123, + provider_name: 'CoolSite', + } + end + end + + it "doesn't return the HTML when not in the `html_providers`" do + Onebox::Engine::AllowlistedGenericOnebox.html_providers = [] + expect(HTMLOnebox.new("http://coolsite.com").to_html).to be_nil + end + + it "returns the HMTL when in the `html_providers`" do + Onebox::Engine::AllowlistedGenericOnebox.html_providers = ['CoolSite'] + expect(HTMLOnebox.new("http://coolsite.com").to_html).to eq "cool html" + end + end + + describe 'rewrites' do + class DummyOnebox < Onebox::Engine::AllowlistedGenericOnebox + def generic_html + "" + end + end + + it "doesn't rewrite URLs that arent in the list" do + Onebox::Engine::AllowlistedGenericOnebox.rewrites = [] + expect(DummyOnebox.new("http://youtube.com").to_html).to eq "" + end + + it "rewrites URLs when allowlisted" do + Onebox::Engine::AllowlistedGenericOnebox.rewrites = %w(youtube.com) + expect(DummyOnebox.new("http://youtube.com").to_html).to eq "" + end + end + + describe 'oembed_providers' do + let(:url) { "http://www.meetup.com/Toronto-Ember-JS-Meetup/events/219939537" } + + before do + stub_request(:get, url).to_return(status: 200, body: onebox_response('meetup')) + stub_request(:get, "http://api.meetup.com/oembed?url=#{url}").to_return(status: 200, body: onebox_response('meetup_oembed')) + end + + it 'uses the endpoint for the url' do + onebox = described_class.new("http://www.meetup.com/Toronto-Ember-JS-Meetup/events/219939537") + expect(onebox.raw).not_to be_nil + expect(onebox.raw[:title]).to eq "February EmberTO Meet-up" + end + end + + describe "cookie support" do + let(:url) { "http://www.dailymail.co.uk/news/article-479146/Brutality-justice-The-truth-tarred-feathered-drug-dealer.html" } + + it "sends the cookie with the request" do + stub_request(:get, url) + .with(headers: { cookie: 'evil=trout' }) + .to_return(status: 200, body: onebox_response('dailymail')) + + onebox = described_class.new(url) + onebox.options = { cookie: "evil=trout" } + + expect(onebox.to_html).not_to be_empty + end + + it "fetches site_name and article_published_time tags" do + stub_request(:get, url).to_return(status: 200, body: onebox_response('dailymail')) + onebox = described_class.new(url) + + expect(onebox.to_html).to include("Mail Online – 8 Aug 14") + end + end + + describe 'canonical link' do + context 'uses canonical link if available' do + let(:mobile_url) { "https://m.etsy.com/in-en/listing/87673424/personalized-word-pillow-case-letter" } + let(:canonical_url) { "https://www.etsy.com/in-en/listing/87673424/personalized-word-pillow-case-letter" } + before do + stub_request(:get, mobile_url).to_return(status: 200, body: onebox_response('etsy_mobile')) + stub_request(:get, canonical_url).to_return(status: 200, body: onebox_response('etsy')) + end + + it 'fetches opengraph data and price from canonical link' do + onebox = described_class.new(mobile_url) + expect(onebox.to_html).not_to be_nil + expect(onebox.to_html).to include("images/favicon.ico") + expect(onebox.to_html).to include("Etsy") + expect(onebox.to_html).to include("Personalized Word Pillow Case") + expect(onebox.to_html).to include("Allow your personality to shine through your decor; this contemporary and modern accent will help you do just that.") + expect(onebox.to_html).to include("https://i.etsystatic.com/6088772/r/il/719b4b/1631899982/il_570xN.1631899982_2iay.jpg") + expect(onebox.to_html).to include("CAD 52.00") + end + end + + context 'does not use canonical link for Discourse topics' do + let(:discourse_topic_url) { "https://meta.discourse.org/t/congratulations-most-stars-in-2013-github-octoverse/12483" } + let(:discourse_topic_reply_url) { "https://meta.discourse.org/t/congratulations-most-stars-in-2013-github-octoverse/12483/2" } + before do + stub_request(:get, discourse_topic_url).to_return(status: 200, body: onebox_response('discourse_topic')) + stub_request(:get, discourse_topic_reply_url).to_return(status: 200, body: onebox_response('discourse_topic_reply')) + end + + it 'fetches opengraph data from original link' do + onebox = described_class.new(discourse_topic_reply_url) + expect(onebox.to_html).not_to be_nil + expect(onebox.to_html).to include("Congratulations, most stars in 2013 GitHub Octoverse!") + expect(onebox.to_html).to include("Thanks for that link and thank you – and everyone else who is contributing to the project!") + expect(onebox.to_html).to include("https://d11a6trkgmumsb.cloudfront.net/optimized/2X/d/d063b3b0807377d98695ee08042a9ba0a8c593bd_2_690x362.png") + end + end + end + + describe 'to_html' do + let(:original_link) { "http://www.dailymail.co.uk/pages/live/articles/news/news.html?in_article_id=479146&in_page_id=1770" } + let(:redirect_link) { 'http://www.dailymail.co.uk/news/article-479146/Brutality-justice-The-truth-tarred-feathered-drug-dealer.html' } + + before do + stub_request(:get, original_link).to_return( + status: 301, + headers: { + location: redirect_link, + } + ) + stub_request(:get, redirect_link).to_return(status: 200, body: onebox_response('dailymail')) + end + + around do |example| + previous_options = Onebox.options.to_h + example.run + Onebox.options = previous_options + end + + it "follows redirects and includes the summary" do + Onebox.options = { redirect_limit: 2 } + onebox = described_class.new(original_link) + expect(onebox.to_html).to include("It was the most chilling image of the week") + end + + it "recives an error with too many redirects" do + Onebox.options = { redirect_limit: 1 } + onebox = described_class.new(original_link) + expect(onebox.to_html).to be_nil + end + end + + describe 'missing description' do + context 'works without description if image is present' do + before do + stub_request(:get, "https://edition.cnn.com/2020/05/15/health/gallery/coronavirus-people-adopting-pets-photos/index.html") + .to_return(status: 200, body: onebox_response('cnn')) + + stub_request(:get, "https://www.cnn.com/2020/05/15/health/gallery/coronavirus-people-adopting-pets-photos/index.html") + .to_return(status: 200, body: onebox_response('cnn')) + end + + it 'shows basic onebox' do + onebox = described_class.new("https://edition.cnn.com/2020/05/15/health/gallery/coronavirus-people-adopting-pets-photos/index.html") + expect(onebox.to_html).not_to be_nil + expect(onebox.to_html).to include("https://edition.cnn.com/2020/05/15/health/gallery/coronavirus-people-adopting-pets-photos/index.html") + expect(onebox.to_html).to include("https://cdn.cnn.com/cnnnext/dam/assets/200427093451-10-coronavirus-people-adopting-pets-super-tease.jpg") + expect(onebox.to_html).to include("People are fostering and adopting pets during the pandemic") + end + end + end +end diff --git a/spec/lib/onebox/engine/amazon_onebox_spec.rb b/spec/lib/onebox/engine/amazon_onebox_spec.rb new file mode 100644 index 00000000000..44479d18920 --- /dev/null +++ b/spec/lib/onebox/engine/amazon_onebox_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Onebox::Engine::AmazonOnebox do + context "regular amazon page" do + before do + @link = "https://www.amazon.com/Knit-Noro-Accessories-Colorful-Little/dp/193609620X" + @uri = "https://www.amazon.com/dp/193609620X" + + stub_request(:get, "https://www.amazon.com/Seven-Languages-Weeks-Programming-Programmers/dp/193435659X") + .to_return(status: 200, body: onebox_response("amazon")) + end + + include_context "engines" + it_behaves_like "an engine" + + describe "works with international domains" do + def check_link(tdl, link) + onebox_cls = Onebox::Matcher.new(link).oneboxed + expect(onebox_cls).to_not be(nil) + expect(onebox_cls.new(link).url).to include("https://www.amazon.#{tdl}") + end + + it "matches canadian domains" do + check_link("ca", "https://www.amazon.ca/Too-Much-Happiness-Alice-Munro-ebook/dp/B0031TZ98K/") + end + + it "matches german domains" do + check_link("de", "https://www.amazon.de/Buddenbrooks-Verfall-einer-Familie-Roman/dp/3596294312/") + end + + it "matches uk domains" do + check_link("co.uk", "https://www.amazon.co.uk/Pygmalion-George-Bernard-Shaw/dp/1420925237/") + end + + it "matches japanese domains" do + check_link("co.jp", "https://www.amazon.co.jp/%E9%9B%AA%E5%9B%BD-%E6%96%B0%E6%BD%AE%E6%96%87%E5%BA%AB-%E3%81%8B-1-1-%E5%B7%9D%E7%AB%AF-%E5%BA%B7%E6%88%90/dp/4101001014/") + end + + it "matches chinese domains" do + check_link("cn", "https://www.amazon.cn/%E5%AD%99%E5%AD%90%E5%85%B5%E6%B3%95-%E5%AD%99%E8%86%91%E5%85%B5%E6%B3%95-%E5%AD%99%E6%AD%A6/dp/B0011C40FC/") + end + + it "matches french domains" do + check_link("fr", "https://www.amazon.fr/Les-Mots-autres-%C3%A9crits-autobiographiques/dp/2070114147/") + end + + it "matches italian domains" do + check_link("it", "https://www.amazon.it/Tutte-poesie-Salvatore-Quasimodo/dp/8804520477/") + end + + it "matches spanish domains" do + check_link("es", "https://www.amazon.es/familia-Pascual-Duarte-Camilo-Jos%C3%A9-ebook/dp/B00EJRTKTW/") + end + + it "matches brasilian domains" do + check_link("com.br", "https://www.amazon.com.br/A-p%C3%A1tria-chuteiras-Nelson-Rodrigues-ebook/dp/B00J2B414Y/") + end + + it "matches indian domains" do + check_link("in", "https://www.amazon.in/Fireflies-Rabindranath-Tagore/dp/9381523169/") + end + + it "matches mexican domains" do + check_link("com.mx", "https://www.amazon.com.mx/Legend-Zelda-Links-Awakening-Nintendo/dp/B07SG15148/") + end + end + + describe "#url" do + let(:long_url) { "https://www.amazon.ca/gp/product/B087Z3N428?pf_rd_r=SXABADD0ZZ3NF9Q5F8TW&pf_rd_p=05378fd5-c43e-4948-99b1-a65b129fdd73&pd_rd_r=0237fb28-7f47-49f4-986a-be0c78e52863&pd_rd_w=FfIoI&pd_rd_wg=Hw4qq&ref_=pd_gw_unk" } + + it "maintains the same http/https scheme as the requested URL" do + expect(described_class.new("https://www.amazon.fr/gp/product/B01BYD0TZM").url) + .to eq("https://www.amazon.fr/dp/B01BYD0TZM") + + expect(described_class.new("http://www.amazon.fr/gp/product/B01BYD0TZM").url) + .to eq("https://www.amazon.fr/dp/B01BYD0TZM") + end + + it "removes parameters from the URL" do + expect(described_class.new(long_url).url) + .not_to include("?pf_rd_r") + end + end + + describe "#to_html" do + it "includes image" do + expect(html).to include("https://images-na.ssl-images-amazon.com/images/I/51opYcR6kVL._SY400_.jpg") + end + + it "includes description" do + expect(html).to include("You should learn a programming language every year, as recommended by The Pragmatic Programmer.") + end + + it "includes price" do + expect(html).to include("$21.11") + end + + it "includes title" do + expect(html).to include("Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers)") + end + end + end + + context "amazon with opengraph" do + let(:link) { "https://www.amazon.com/dp/B01MFXN4Y2" } + let(:html) { described_class.new(link).to_html } + + before do + stub_request(:get, "https://www.amazon.com/dp/B01MFXN4Y2") + .to_return(status: 200, body: onebox_response("amazon-og")) + + stub_request(:get, "https://www.amazon.com/Christine-Rebecca-Hall/dp/B01MFXN4Y2") + .to_return(status: 200, body: onebox_response("amazon-og")) + end + + describe "#to_html" do + it "includes image" do + expect(html).to include("https://images-na.ssl-images-amazon.com/images/I/51nOF2iBa6L._SX940_.jpg") + end + + it "includes description" do + expect(html).to include("CHRISTINE is the story of an aspiring newswoman caught in the midst of a personal and professional life crisis. Between unrequited love, frustration at work, a tumultuous home, and self-doubt; she begins to spiral down a dark path.") + end + + it "includes title" do + expect(html).to include("Watch Christine online - Amazon Video") + end + end + end + + context "amazon book page" do + let(:link) { "https://www.amazon.com/dp/B00AYQNR46" } + let(:html) { described_class.new(link).to_html } + + before do + stub_request(:get, "https://www.amazon.com/dp/B00AYQNR46") + .to_return(status: 200, body: onebox_response("amazon")) + + stub_request(:get, "https://www.amazon.com/Seven-Languages-Weeks-Programming-Programmers/dp/193435659X") + .to_return(status: 200, body: onebox_response("amazon")) + end + + describe "#to_html" do + it "includes title and author" do + expect(html).to include("Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers)") + expect(html).to include("Bruce Tate") + end + + it "includes ISBN" do + expect(html).to include("978-1934356593") + end + + it "includes publisher" do + expect(html).to include("Pragmatic Bookshelf") + end + end + end + + context "amazon ebook page" do + let(:link) { "https://www.amazon.com/dp/193435659X" } + let(:html) { described_class.new(link).to_html } + + before do + stub_request(:get, "https://www.amazon.com/dp/193435659X") + .to_return(status: 200, body: onebox_response("amazon-ebook")) + + stub_request(:get, "https://www.amazon.com/Seven-Languages-Weeks-Programming-Programmers-ebook/dp/B00AYQNR46") + .to_return(status: 200, body: onebox_response("amazon-ebook")) + end + + describe "#to_html" do + it "includes title and author" do + expect(html).to include("Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages (Pragmatic Programmers)") + expect(html).to include("Bruce Tate") + end + + it "includes image" do + expect(html).to include("https://images-na.ssl-images-amazon.com/images/I/51LZT%2BtSrTL._SX133_.jpg") + end + + it "includes ASIN" do + expect(html).to include("B00AYQNR46") + end + + it "includes rating" do + expect(html).to include("4.2 out of 5 stars") + end + + it "includes publisher" do + expect(html).to include("Pragmatic Bookshelf") + end + end + end +end diff --git a/spec/lib/onebox/engine/audio_onebox_spec.rb b/spec/lib/onebox/engine/audio_onebox_spec.rb new file mode 100644 index 00000000000..dbb12ad6de5 --- /dev/null +++ b/spec/lib/onebox/engine/audio_onebox_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Onebox::Engine::AudioOnebox do + it "supports ogg" do + expect(Onebox.preview('http://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg').to_s).to match(/