2016-05-19 22:25:08 +10:00
require 'mini_racer'
2013-02-05 14:16:51 -05:00
require 'nokogiri'
2015-01-29 22:53:48 +01:00
require_dependency 'url_helper'
2013-05-28 09:48:47 +10:00
require_dependency 'excerpt_parser'
2013-10-15 18:33:06 -04:00
require_dependency 'post'
2016-05-02 11:36:09 +10:00
require_dependency 'discourse_tagging'
2013-02-05 14:16:51 -05:00
module PrettyText
2016-05-19 22:25:08 +10:00
module Helpers
extend self
2013-07-16 17:48:48 +10:00
def t ( key , opts )
2014-10-02 18:19:01 +02:00
key = " js. " + key
unless opts
2015-04-09 17:04:14 +02:00
I18n . t ( key )
2014-10-02 18:19:01 +02:00
else
str = I18n . t ( key , Hash [ opts . entries ] . symbolize_keys ) . dup
2015-04-09 17:04:14 +02:00
opts . each { | k , v | str . gsub! ( " {{ #{ k . to_s } }} " , v . to_s ) }
str
2013-07-16 17:48:48 +10:00
end
end
2015-03-12 15:51:28 -04:00
# functions here are available to v8
2013-02-05 14:16:51 -05:00
def avatar_template ( username )
return " " unless username
2014-05-06 14:41:59 +01:00
user = User . find_by ( username_lower : username . downcase )
2015-01-29 22:53:48 +01:00
return " " unless user . present?
2015-03-12 15:51:28 -04:00
# TODO: Add support for ES6 and call `avatar-template` directly
2015-06-26 13:37:50 -04:00
if ! user . uploaded_avatar_id
avatar_template = User . default_template ( username )
2015-03-12 15:51:28 -04:00
else
avatar_template = user . avatar_template
end
2015-06-12 12:02:36 +02:00
UrlHelper . schemaless UrlHelper . absolute avatar_template
2013-02-05 14:16:51 -05:00
end
2015-11-30 17:03:47 +11:00
def mention_lookup ( username )
2013-02-05 14:16:51 -05:00
return false unless username
2015-11-30 17:03:47 +11:00
if Group . exec_sql ( 'SELECT 1 FROM groups WHERE name = ?' , username ) . values . length == 1
" group "
else
username = username . downcase
if User . exec_sql ( 'SELECT 1 FROM users WHERE username_lower = ?' , username ) . values . length == 1
" user "
else
nil
end
end
2013-02-05 14:16:51 -05:00
end
2015-09-25 13:35:14 +10:00
2015-12-28 14:28:16 +08:00
def category_hashtag_lookup ( category_slug )
2016-01-12 16:40:36 +08:00
if category = Category . query_from_hashtag_slug ( category_slug )
2016-02-11 16:04:40 +01:00
[ category . url_with_id , category_slug ]
2015-12-28 14:28:16 +08:00
else
nil
end
end
2015-09-25 13:35:14 +10:00
def get_topic_info ( topic_id )
return unless Fixnum === topic_id
# TODO this only handles public topics, secured one do not get this
topic = Topic . find_by ( id : topic_id )
if topic && Guardian . new . can_see? ( topic )
{
title : topic . title ,
href : topic . url
}
end
end
2016-04-25 15:55:15 -04:00
def category_tag_hashtag_lookup ( text )
tag_postfix = '::tag'
is_tag = text =~ / #{ tag_postfix } $ /
if ! is_tag && category = Category . query_from_hashtag_slug ( text )
[ category . url_with_id , text ]
2016-05-02 11:36:09 +10:00
elsif is_tag && tag = TopicCustomField . find_by ( name : DiscourseTagging :: TAGS_FIELD_NAME , value : text . gsub! ( " #{ tag_postfix } " , '' ) )
2016-04-25 15:55:15 -04:00
[ " #{ Discourse . base_url } /tags/ #{ tag . value } " , text ]
else
nil
end
end
2016-05-19 22:25:08 +10:00
2013-02-05 14:16:51 -05:00
end
@mutex = Mutex . new
2013-08-15 18:12:10 -04:00
@ctx_init = Mutex . new
2013-02-05 14:16:51 -05:00
def self . app_root
Rails . root
end
2013-08-15 18:12:10 -04:00
def self . create_new_context
2016-04-21 16:52:12 -07:00
# timeout any eval that takes longer than 15 seconds
2016-05-19 22:25:08 +10:00
ctx = MiniRacer :: Context . new ( timeout : 15000 )
2013-02-25 19:42:20 +03:00
2016-05-19 22:25:08 +10:00
Helpers . instance_methods . each do | method |
ctx . attach ( " helpers. #{ method } " , Helpers . method ( method ) )
end
2013-02-05 14:16:51 -05:00
2013-08-15 18:12:10 -04:00
ctx_load ( ctx ,
2016-02-05 15:27:24 +01:00
" vendor/assets/javascripts/md5.js " ,
2014-04-14 22:55:57 +02:00
" vendor/assets/javascripts/lodash.js " ,
" vendor/assets/javascripts/Markdown.Converter.js " ,
" lib/headless-ember.js " ,
" vendor/assets/javascripts/rsvp.js " ,
Rails . configuration . ember . handlebars_location
)
2013-02-05 14:16:51 -05:00
2014-01-06 16:50:04 +11:00
ctx . eval ( " var Discourse = {}; Discourse.SiteSettings = {}; " )
2013-08-15 18:12:10 -04:00
ctx . eval ( " var window = {}; window.devicePixelRatio = 2; " ) # hack to make code think stuff is retina
ctx . eval ( " var I18n = {}; I18n.t = function(a,b){ return helpers.t(a,b); } " ) ;
2013-02-05 14:16:51 -05:00
2015-03-12 15:51:28 -04:00
ctx . eval ( " var modules = {}; " )
2013-08-29 13:11:12 -04:00
decorate_context ( ctx )
2013-08-15 18:12:10 -04:00
ctx_load ( ctx ,
2014-04-14 22:55:57 +02:00
" vendor/assets/javascripts/better_markdown.js " ,
" app/assets/javascripts/defer/html-sanitizer-bundle.js " ,
2015-11-24 16:07:47 -05:00
" app/assets/javascripts/discourse/lib/utilities.js " ,
2014-04-14 22:55:57 +02:00
" app/assets/javascripts/discourse/dialects/dialect.js " ,
2015-08-25 22:25:37 +08:00
" app/assets/javascripts/discourse/lib/censored-words.js " ,
2015-03-12 15:51:28 -04:00
" app/assets/javascripts/discourse/lib/markdown.js " ,
2014-04-14 22:55:57 +02:00
)
2013-02-05 14:16:51 -05:00
2015-01-24 00:07:39 +01:00
Dir [ " #{ app_root } /app/assets/javascripts/discourse/dialects/**.js " ] . sort . each do | dialect |
2015-01-29 17:35:52 +01:00
ctx . load ( dialect ) unless dialect =~ / \/ dialect \ .js$ /
2013-08-08 18:14:12 -04:00
end
2015-01-29 17:35:52 +01:00
# emojis
2015-01-24 00:07:39 +01:00
emoji = ERB . new ( File . read ( " #{ app_root } /app/assets/javascripts/discourse/lib/emoji/emoji.js.erb " ) )
2014-12-23 11:06:55 +01:00
ctx . eval ( emoji . result )
2013-02-05 14:16:51 -05:00
# Load server side javascripts
if DiscoursePluginRegistry . server_side_javascripts . present?
DiscoursePluginRegistry . server_side_javascripts . each do | ssjs |
2013-11-20 14:38:21 +11:00
if ( ssjs =~ / \ .erb / )
erb = ERB . new ( File . read ( ssjs ) )
erb . filename = ssjs
ctx . eval ( erb . result )
else
ctx . load ( ssjs )
end
2013-02-05 14:16:51 -05:00
end
end
2013-08-15 18:12:10 -04:00
ctx
end
def self . v8
return @ctx if @ctx
# ensure we only init one of these
@ctx_init . synchronize do
return @ctx if @ctx
@ctx = create_new_context
end
2014-04-14 22:55:57 +02:00
2013-02-05 14:16:51 -05:00
@ctx
end
2014-11-14 17:51:04 +11:00
def self . reset_context
@ctx_init . synchronize do
@ctx = nil
end
end
2013-08-29 13:11:12 -04:00
def self . decorate_context ( context )
context . eval ( " Discourse.CDN = ' #{ Rails . configuration . action_controller . asset_host } '; " )
2015-07-16 00:01:00 +02:00
context . eval ( " Discourse.BaseUrl = ' #{ RailsMultisite :: ConnectionManagement . current_hostname } '.replace(/:[ \ d]*$/,''); " )
2015-09-06 20:20:59 -07:00
context . eval ( " Discourse.BaseUri = ' #{ Discourse :: base_uri } '; " )
2015-07-16 00:01:00 +02:00
context . eval ( " Discourse.SiteSettings = #{ SiteSetting . client_settings_json } ; " )
context . eval ( " Discourse.getURL = function(url) {
if ( ! url ) return url ;
if ( ! / ^ \\ / [ ^ \ \ / ] / . test ( url ) ) return url ;
var u = ( Discourse . BaseUri === undefined ? '/' : Discourse . BaseUri ) ;
if ( u [ u . length - 1 ] === '/' ) u = u . substring ( 0 , u . length - 1 ) ;
if ( url . indexOf ( u ) != = - 1 ) return url ;
if ( u . length > 0 && url [ 0 ] != = '/' ) url = '/' + url ;
return u + url ;
} ; " )
context . eval ( " Discourse.getURLWithCDN = function(url) {
url = this . getURL ( url ) ;
if ( Discourse . CDN && / ^ \\ / [ ^ \ \ / ] / . test ( url ) ) {
url = Discourse . CDN + url ;
} else if ( Discourse . S3CDN ) {
url = url . replace ( Discourse . S3BaseUrl , Discourse . S3CDN ) ;
}
return url ;
} ; " )
2013-08-29 13:11:12 -04:00
end
2013-02-05 14:16:51 -05:00
def self . markdown ( text , opts = nil )
# we use the exact same markdown converter as the client
2013-02-25 19:42:20 +03:00
# TODO: use the same extensions on both client and server (in particular the template for mentions)
2013-02-05 14:16:51 -05:00
baked = nil
2016-05-19 22:25:08 +10:00
text = text || " "
2013-02-05 14:16:51 -05:00
2014-02-04 11:12:53 +11:00
protect do
2013-08-16 13:03:47 +10:00
context = v8
2013-02-05 14:16:51 -05:00
# we need to do this to work in a multi site environment, many sites, many settings
2013-08-29 13:11:12 -04:00
decorate_context ( context )
2013-10-11 16:24:27 -04:00
context_opts = opts || { }
2015-12-10 18:14:51 -05:00
context_opts [ :sanitize ] = true unless context_opts [ :sanitize ] == false
2016-05-19 22:25:08 +10:00
context . eval ( " opts = #{ context_opts . to_json } ; " )
context . eval ( " raw = #{ text . inspect } ; " )
2013-10-11 16:24:27 -04:00
if Post . white_listed_image_classes . present?
Post . white_listed_image_classes . each do | klass |
context . eval ( " Discourse.Markdown.whiteListClass(' #{ klass } ') " )
end
end
2016-03-02 14:31:32 -05:00
if SiteSetting . enable_emoji?
context . eval ( " Discourse.Dialect.setUnicodeReplacements( #{ Emoji . unicode_replacements_json } ) " ) ;
else
context . eval ( " Discourse.Dialect.setUnicodeReplacements(null) " ) ;
end
2015-10-30 23:26:34 +01:00
# reset emojis (v8 context is shared amongst multisites)
2015-11-06 15:02:40 +01:00
context . eval ( " Discourse.Dialect.resetEmojis(); " )
2015-01-29 17:35:52 +01:00
# custom emojis
Emoji . custom . each do | emoji |
context . eval ( " Discourse.Dialect.registerEmoji(' #{ emoji . name } ', ' #{ emoji . url } '); " )
end
2015-11-06 15:02:40 +01:00
# plugin emojis
context . eval ( " Discourse.Emoji.applyCustomEmojis(); " )
2015-01-29 17:35:52 +01:00
2015-11-30 17:03:47 +11:00
context . eval ( 'opts["mentionLookup"] = function(u){return helpers.mention_lookup(u);}' )
2015-12-28 14:28:16 +08:00
context . eval ( 'opts["categoryHashtagLookup"] = function(c){return helpers.category_hashtag_lookup(c);}' )
2013-08-16 13:03:47 +10:00
context . eval ( 'opts["lookupAvatar"] = function(p){return Discourse.Utilities.avatarImg({size: "tiny", avatarTemplate: helpers.avatar_template(p)});}' )
2015-09-25 13:35:14 +10:00
context . eval ( 'opts["getTopicInfo"] = function(i){return helpers.get_topic_info(i)};' )
2016-05-02 12:01:15 +10:00
context . eval ( 'opts["categoryHashtagLookup"] = function(c){return helpers.category_tag_hashtag_lookup(c);}' )
2016-01-29 22:59:15 +08:00
DiscourseEvent . trigger ( :markdown_context , context )
2013-08-16 13:03:47 +10:00
baked = context . eval ( 'Discourse.Markdown.markdownConverter(opts).makeHtml(raw)' )
2013-02-05 14:16:51 -05:00
end
2014-11-14 17:51:04 +11:00
if baked . blank? && ! ( opts || { } ) [ :skip_blank_test ]
# we may have a js engine issue
test = markdown ( " a " , skip_blank_test : true )
if test . blank?
Rails . logger . warn ( " Markdown engine appears to have crashed, resetting context " )
reset_context
opts || = { }
opts = opts . dup
opts [ :skip_blank_test ] = true
baked = markdown ( text , opts )
end
end
2013-02-05 14:16:51 -05:00
baked
end
# leaving this here, cause it invokes v8, don't want to implement twice
2013-08-13 22:08:29 +02:00
def self . avatar_img ( avatar_template , size )
2014-02-04 11:12:53 +11:00
protect do
2016-05-19 22:25:08 +10:00
v8 . eval <<JS
avatarTemplate = #{avatar_template.inspect};
2016-05-26 07:54:55 +05:30
size = #{size.inspect};
2016-05-19 22:25:08 +10:00
JS
2013-08-29 13:11:12 -04:00
decorate_context ( v8 )
2014-02-04 11:12:53 +11:00
v8 . eval ( " Discourse.Utilities.avatarImg({ avatarTemplate: avatarTemplate, size: size }); " )
2013-02-05 14:16:51 -05:00
end
end
2015-10-15 09:59:29 +02:00
def self . unescape_emoji ( title )
protect do
decorate_context ( v8 )
2016-05-19 22:25:08 +10:00
v8 . eval ( " Discourse.Emoji.unescape( #{ title . inspect } ) " )
2015-10-15 09:59:29 +02:00
end
end
2013-02-05 14:16:51 -05:00
def self . cook ( text , opts = { } )
2015-04-23 19:33:29 +02:00
options = opts . dup
2013-02-05 14:16:51 -05:00
# we have a minor inconsistency
2015-04-23 19:33:29 +02:00
options [ :topicId ] = opts [ :topic_id ]
2015-12-30 14:35:25 -05:00
working_text = text . dup
2015-12-29 16:27:56 -05:00
sanitized = markdown ( working_text , options )
2015-05-26 11:13:12 +10:00
doc = Nokogiri :: HTML . fragment ( sanitized )
if ! options [ :omit_nofollow ] && SiteSetting . add_rel_nofollow_to_user_content
add_rel_nofollow_to_user_content ( doc )
end
if SiteSetting . s3_cdn_url . present? && SiteSetting . enable_s3_uploads
add_s3_cdn ( doc )
end
doc . to_html
end
def self . add_s3_cdn ( doc )
doc . css ( " img " ) . each do | img |
2015-06-10 18:56:44 +10:00
next unless img [ " src " ]
2015-05-26 11:13:12 +10:00
img [ " src " ] = img [ " src " ] . sub ( Discourse . store . absolute_base_url , SiteSetting . s3_cdn_url )
end
2013-02-11 11:43:07 +11:00
end
2013-02-25 19:42:20 +03:00
2015-05-26 11:13:12 +10:00
def self . add_rel_nofollow_to_user_content ( doc )
2013-02-11 18:58:19 +11:00
whitelist = [ ]
2013-02-11 19:01:33 +11:00
2013-11-20 14:38:21 +11:00
domains = SiteSetting . exclude_rel_nofollow_domains
2014-03-29 16:50:44 -07:00
whitelist = domains . split ( '|' ) if domains . present?
2013-02-11 19:01:33 +11:00
2013-02-11 11:43:07 +11:00
site_uri = nil
doc . css ( " a " ) . each do | l |
href = l [ " href " ] . to_s
2013-02-25 19:42:20 +03:00
begin
2013-02-11 11:43:07 +11:00
uri = URI ( href )
site_uri || = URI ( Discourse . base_url )
2013-02-25 19:42:20 +03:00
2013-11-05 19:04:47 +01:00
if ! uri . host . present? ||
2015-05-27 14:31:01 +10:00
uri . host == site_uri . host ||
uri . host . ends_with? ( " . " << site_uri . host ) ||
whitelist . any? { | u | uri . host == u || uri . host . ends_with? ( " . " << u ) }
2013-02-11 11:43:07 +11:00
# we are good no need for nofollow
else
l [ " rel " ] = " nofollow "
end
2014-03-07 10:44:04 +01:00
rescue URI :: InvalidURIError , URI :: InvalidComponentError
2013-02-25 19:42:20 +03:00
# add a nofollow anyway
2013-02-11 11:43:07 +11:00
l [ " rel " ] = " nofollow "
end
end
2013-02-05 14:16:51 -05:00
end
2014-07-11 14:17:01 +10:00
class DetectedLink
attr_accessor :is_quote , :url
def initialize ( url , is_quote = false )
@url = url
@is_quote = is_quote
end
end
2013-02-05 14:16:51 -05:00
def self . extract_links ( html )
links = [ ]
2013-06-05 20:53:07 +02:00
doc = Nokogiri :: HTML . fragment ( html )
2016-03-16 22:35:08 +01:00
# remove href inside quotes & elided part
doc . css ( " aside.quote a, .elided a " ) . each { | l | l [ " href " ] = " " }
2014-07-11 14:17:01 +10:00
2013-06-05 20:53:07 +02:00
# extract all links from the post
2014-07-11 14:17:01 +10:00
doc . css ( " a " ) . each { | l |
2016-04-15 20:02:18 +02:00
unless l [ " href " ] . blank? || " # " . freeze == l [ " href " ] [ 0 ]
2014-07-11 14:17:01 +10:00
links << DetectedLink . new ( l [ " href " ] )
end
}
2013-06-05 20:53:07 +02:00
# extract links to quotes
2014-05-20 17:20:52 -04:00
doc . css ( " aside.quote[data-topic] " ) . each do | a |
2013-06-05 20:53:07 +02:00
topic_id = a [ 'data-topic' ]
2013-02-25 19:42:20 +03:00
2013-02-13 15:22:04 -05:00
url = " /t/topic/ #{ topic_id } "
2013-06-05 20:53:07 +02:00
if post_number = a [ 'data-post' ]
2013-02-13 15:22:04 -05:00
url << " / #{ post_number } "
end
2014-07-11 14:17:01 +10:00
links << DetectedLink . new ( url , true )
2013-02-13 15:22:04 -05:00
end
2013-02-05 14:16:51 -05:00
links
end
2013-05-28 09:48:47 +10:00
def self . excerpt ( html , max_length , options = { } )
2014-11-05 20:37:00 +01:00
# TODO: properly fix this HACK in ExcerptParser without introducing XSS
doc = Nokogiri :: HTML . fragment ( html )
strip_image_wrapping ( doc )
html = doc . to_html
2013-05-28 09:48:47 +10:00
ExcerptParser . get_excerpt ( html , max_length , options )
end
2013-02-05 14:16:51 -05:00
2013-06-05 15:28:10 -04:00
def self . strip_links ( string )
return string if string . blank?
# If the user is not basic, strip links from their bio
fragment = Nokogiri :: HTML . fragment ( string )
2014-09-17 12:08:00 -04:00
fragment . css ( 'a' ) . each { | a | a . replace ( a . inner_html ) }
2013-06-05 15:28:10 -04:00
fragment . to_html
end
2014-04-17 12:32:51 -04:00
def self . strip_image_wrapping ( doc )
doc . css ( " .lightbox-wrapper .meta " ) . remove
end
2016-05-21 20:13:00 +02:00
def self . format_for_email ( html , post = nil , style = nil )
2016-05-21 06:17:54 -07:00
Email :: Styles . new ( html , style : style ) . tap do | doc |
2016-05-21 20:13:00 +02:00
DiscourseEvent . trigger ( :reduce_cooked , doc , post )
2016-05-21 06:17:54 -07:00
doc . make_all_links_absolute
doc . send :" format_ #{ style } " if style
end . to_html
2013-11-28 15:57:21 -05:00
end
2013-05-28 09:48:47 +10:00
protected
2013-02-05 14:16:51 -05:00
2014-02-04 11:12:53 +11:00
class JavaScriptError < StandardError
attr_accessor :message , :backtrace
def initialize ( message , backtrace )
@message = message
@backtrace = backtrace
end
end
def self . protect
rval = nil
@mutex . synchronize do
2016-05-19 22:25:08 +10:00
rval = yield
2014-02-04 11:12:53 +11:00
end
rval
end
2013-08-15 18:12:10 -04:00
def self . ctx_load ( ctx , * files )
2013-05-28 09:48:47 +10:00
files . each do | file |
2013-08-15 18:12:10 -04:00
ctx . load ( app_root + file )
2013-02-05 14:16:51 -05:00
end
end
end