require 'sinatra' require 'xmlrpc/marshal' require 'xmlrpc/create' require 'discourse_api' require 'json' require 'pp' require 'chronic' require 'base64' require 'net/https' # TODO: monkey-patch XMLRPC requests to support tapatalk's invalid bools. # The hack below works, ish. # We need to monkey-patch our XMLRPC responses to have invalid(!) responses for # dateTime.iso8601 objects module Tapatalk class Time < ::Time end end module XMLRPC class Create private alias_method :real_conv2value, :conv2value def conv2value( param ) if param.is_a?(::Tapatalk::Time ) value = @writer.tag( "dateTime.iso8601", param.iso8601 ) # includes the timezone @writer.ele("value", value) else real_conv2value( param ) end end end end PUSH_TYPES = "ann,conv,pm,sub,like,thank,quote,newtopic,tag" CONFIG = YAML.load( File.read( File.join( File.expand_path( File.join( File.dirname( __FILE__ ), ".." ) ), "config.yml" ) ) ) #use Rack::Session::Cookie, :key => "rack.session", # :domain => DOMAIN, # :path => "/", # :expire_after => 86400 * 7, # login every 7 days... # :secret => "change_me", # change these # :old_secret => "change_me" set :discourse_api, URI::parse( CONFIG.fetch( 'discourse_url' ) ) set :discourse_api_key, CONFIG.fetch( 'discourse_api_key' ) # For push notifications. TODO. set :tapatalk_api, CONFIG.fetch( 'tapatalk_url', nil ) set :tapatalk_api_key, CONFIG.fetch( 'tapatalk_api_key', nil ) before do # Pass through all cookies to the discourse instance discourse( request.cookies ) # TODO: Override on auth failures, cookie timeouts, etc. to set this to false mobiquo_login_header!( request.cookies.has_key?("_t") ) end helpers do def mobiquo_login_header!( val ) headers "Mobiquo_is_login" => val.to_s end # Use this as our entry point def dispatch xml = fixup_tapatalk_xml( @request.body.read ) fixup_tapatalk_xml( xml ) halt 400, "Empty XMLRPC request" if xml.empty? # Parse xml method, arguments = XMLRPC::Marshal.load_call(xml) method = "xmlrpc_#{method.gsub(/([A-Z])/, '_ ').downcase}" # FIXME: this is pretty evil filtered_args = if method == "xmlrpc_login" tmp = arguments.dup tmp[1] = "[FILTERED]" tmp elsif method == "xmlrpc_update_push_status" tmp = arguments.dup tmp[2] = "[FILTERED]" tmp else arguments end puts "method = #{method}, args = #{filtered_args.inspect}" # Check if method exists if(respond_to?(method)) content_type("text/xml", :charset => "utf-8") send(method, *arguments) else puts "UNKNOWN METHOD: #{method}" respond_xmlrpc( 'result' => false, 'result_text' => binary_xmlrpc( "Unknown method: #{method}" ) ) end end # Apparently, tapatalk formats booleans contrary to the XMLRPC standard. # https://github.com/svdgraaf/django-tapatalk/blob/master/tapatalk/dispatcher.py def fixup_tapatalk_xml( text ) text.gsub( 'true', '1' ). gsub( 'false', '0' ) end def make_xmlrpc_datetime( string ) t = Chronic.parse( string ) ::Tapatalk::Time.new( t.year, t.month, t.day, t.hour, t.min, t.sec, t.utc_offset ) end def binary_xmlrpc( string ) # assume it's utf-8 for now, wrap in base64 encoding XMLRPC::Base64.new( string ) end def make_avatar_url( template_txt, size = 120 ) template_txt.to_s. gsub(/\A\/\//, "http://"). # srsly wat, discourse? Well, it's probably for selection between HTTP or HTTPS more easily. But still. gsub( "{size}", size.to_s ) # url parameter end # Clean up the text, converting it to BBCode (TODO) unless use_html def format_post_content_for_xmlrpc( html_text, use_html ) raise NotImplementedError.new( "Can't convert to BBCode yet" ) unless use_html html_text end def build_tapatalk_topic( discourse_topic, extra = {} ) result = { 'forum_id' => discourse_topic["category_id"].to_s, 'forum_name' => binary_xmlrpc( "" ), # TODO 'topic_id' => discourse_topic["id"].to_s, 'topic_title' => binary_xmlrpc( discourse_topic["title"].to_s ), # 'prefix' => binary_xmlrpc( "" ), # optional 'post_author_name' => binary_xmlrpc( "" ), 'topic_author_name' => binary_xmlrpc( "" ), # override later 'topic_author_display_name' => binary_xmlrpc( "" ), # also # 'post_author_id' => 0, # required, Int (apparently), level 4 'is_subscribed' => false, # TODO 'can_subscribe' => false, # TODO 'is_closed' => false, # TODO 'icon_url' => "", # optional, overriden later # participated_uids # optional, level 4 'post_time' => make_xmlrpc_datetime( discourse_topic['last_posted_at'] ), # one of these is not like the other 'last_reply_time' => make_xmlrpc_datetime( discourse_topic['last_posted_at'] ), 'reply_number' => discourse_topic['posts_count'].to_i, # reply_count ? 'new_post' => !!discourse_topic['unseen'], 'view_number' => discourse_topic['views'].to_i, 'new_post' => false, # TODO # 'short_content' => binary_xmlrpc( "" ) # api level 4, not provided by discourse in some calls, TODO } # Fill in some association-related data if extra[:categories] category = extra[:categories].find {|hsh| hsh["id"] == discourse_topic["category_id"] } || {} result["forum_name"] = binary_xmlrpc( category.fetch( "name", "Uncategorised" ) ) end if discourse_topic["posters"] orig_poster = discourse_topic["posters"].find {|hsh| hsh['description'].to_s =~ /Original Poster/i } result["post_author_id"] = orig_poster["user_id"] if orig_poster if extra[:users] && result["post_author_id"] user = extra[:users].find {|hsh| hsh["id"] == result["post_author_id"] } if user result["post_author_name"] = binary_xmlrpc( user["username"].to_s ) result["topic_author_name"] = binary_xmlrpc( user["username"].to_s ) result["topic_author_display_name"] = binary_xmlrpc( user["username"].to_s ) # TODO result["icon_url"] = make_avatar_url( user["avatar_template"] ) end end end # FIXME: how do these differ from post_author_name/id ? most recent / original? result['topic_author_name'] = result['post_author_name'] result['topic_author_id'] = result['post_author_id'].to_s result end # Try to keep this instance around for as long as possible... # TODO: To keep it around, push into Rack::Session, or cookies? Some sort of # long-lived store. Can go away with process restarts, of course. def discourse( cookies = {} ) @discourse ||= begin uri = settings.discourse_api uri = URI.parse(uri) unless uri.is_a?( URI::Generic ) client = DiscourseApi::Client.new( uri.host, uri.port, uri.scheme ) client.api_key = settings.discourse_api_key client.cookies = cookies client end end def respond_xmlrpc( rsp ) data = XMLRPC::Marshal.dump_response( rsp ) data.gsub!( '', '' ) data.tr!("\n", "") data end ## http://tapatalk.com/api/api_section.php ## # First function called by tapatalk, apparently we should always return the # whole hash. def xmlrpc_get_config( name = nil ) mobiquo_login_header!( false ) result = { # Uncontroversial "sys_version" => "0.1", # For now "version" => "dev", # For now "api_level" => "3", # Maybe? "is_open" => true, # Set to false to stop working "guest_okay" => true, # These are optional, and assume 1 if missing "mark_read" => "0", # TODO "subscribe_forum" => "0", # TODO "announcement" => "0", # Probably? # These are optional, and assume 0 if missing # "delete_reason" => "0", # TODO # "m_approve" => "0", # TODO # "m_delete" => "0", # TODO # "m_report" => "0", # TODO # "guest_search" => "0", # TODO # "guest_whosonline" => "0", # TODO # "emoji" => "0", # Probably? # "support_md5" => "0", # TODO # "support_sha1" => "0", # TODO # "conversation" => "0", # TODO # "advanced_delete" => "0", # TODO # "get_activity" => "0", # TODO # "prefix_edit" => "0", # TODO # "anonymous_login" => "0", # TODO # "search_user" => "0", # TODO # "user_recommend" => "0", # TODO # Other optional params "min_search_length" => 3, # Boring default "alert" => "0", # TODO "direct_unsubscribe" => "0", # TODO "push_type" => PUSH_TYPES, # "ban_delete_type" => "none", # TODO "inappreg" => "0", # TODO "inappsignin" => "1", "ignore_user" => "0", # TODO # These are API level 4 items. Ignore for now. # "goto_post" => false, # TODO # "goto_unread" => false, # TODO # "mark_forum" => "0", # TODO # "no_refresh_on_post" => "0", # TODO # "get_latest_topic" => "0", # TODO # "get_id_by_url" => "0", # TODO # "anonymous" => "0", # TODO # "searchid" => "0", # TODO # "avatar" => "0", # TODO # "pm_load" => "0", # TODO # "subscribe_load" => "0", # TODO # "inbox_stat" => "0", # TODO # "multi_quote" => "0", # TODO # "default_smilies" => "1", # Probably? # "can_unread" => "0", # Probably # "get_forum" => "1", # No sub-forums in discourse but it needs this anyway # "get_topic_status" => "0", # TODO # "get_participated_forum" => "0", # TODO # "get_forum_status" => "1", # TODO # "get_smilies" => "0", # TODO # "advanced_online_users" => "0", # TODO # "mark_pm_unread" => "0", # Probably? # "advanced_search" => "0", # TODO # "mass_subscribe" => "0", # TODO # "user_id" => "0", # TODO # "mark_topic_read" => "0", # TODO # "first_unread" => "0", # TODO # These are returned by some other forums but aren't documented "key" => settings.tapatalk_api_key, # tapatalk API key, at a guess? "push" => "1", # "allow_moderate", # "charset" => "ISO-8859-1", "disable_bbcode" => "0", # "disable_subscribe_forum" => "0", # "forum_signature" => 1, # "get_online_users" => "1", # No idea what this does # "hide_forum_id" => "", # "reg_url" => "register.php", "sign_in" => "0", # what does this do? # "stats" => {"user"=>320812, "topic"=>233040, "post"=>2389071} } respond_xmlrpc( result ) end def xmlrpc_login( user = nil, pass = nil, anonymous = false, push = '1' ) mobiquo_login_header!( false ) return respond_xmlrpc( 'result' => false, 'result_text' => binary_xmlrpc( 'Anonymous login not supported' ) ) if anonymous # We need to send on the cookies we received from discourse, and ensure they're # passed in for future API requests # we need one of these to play with the session API if discourse.csrf_token.nil? discourse.csrf_token = discourse.session_csrf({}).fetch("csrf") end # We raise if session creation fails user_info = begin rsp = discourse.session_create( :login => user, :password => pass ) return respond_xmlrpc( 'result' => false, 'result_text' => binary_xmlrpc( rsp['error'] ) ) if rsp['error'] rsp rescue => err puts err.inspect puts err.backtrace.join("\n") return respond_xmlrpc( 'result' => false ) end set_cookies = discourse.cookies.each.collect {|c| c.set_cookie_value } headers( "Set-Cookie" => set_cookies ) if set_cookies.size > 0 user = user_info.fetch("user") respond_xmlrpc( 'result' => true, 'user_id' => user["id"], # api level 4 'username' => binary_xmlrpc( user["username"] ), # api level 4 # 'usergroup_id' => [], # api level 4 # 'email' => binary_xmlrpc( "" ) # api level 4 # not sent by discourse # 'icon_url' => make_avatar_url( user["avatar_template"] ), # api level 4 # 'post_count' => , # api level 4 # discourse gives back stats with action_type and count members 'can_pm' => false, # TODO! 'can_send_pm' => false, # TODO! # can_send_private_message_to_user is always false...? 'can_moderate' => false, # TODO! user["moderator"] # can_search => true, # api level 4 # can_whosonline => true, # api level 4 # can_profile => true, # api level 4 'can_upload_avatar' => false, # TODO # phpBB3 returns the values as booleans 'push_type' => PUSH_TYPES.split(",").collect {|type| { 'name' => type, 'value' => ( push == "1" ) } } ) end # Unsure what this does just yet, trying to get push notifications working. # Pokes directory.tapatalk.com, as does update_push_status... glue? def xmlrpc_sign_in( token, code, unknown, username, password ) pp [ "xmlrpc_sign_in args:", args ] pp [ "rack environment:", request.env ] respond_xmlrpc( 'result' => false ) end def xmlrpc_logout_user discourse.session_destroy({}) respond_xmlrpc( 'result' => true ) end def xmlrpc_get_inbox_stat # TODO: get real numbers instead... respond_xmlrpc( 'inbox_read_count' => 0 ) end # Ignore forum_id for now. Naughty naughty. def xmlrpc_get_forum( return_description = nil, forum_id = nil) forum_info = discourse.categories({}) category_list = forum_info.fetch( "category_list" ) categories = category_list.fetch( "categories" ) result = categories.collect {|hsh| tmp = { 'forum_id' => hsh['id'].to_s, 'forum_name' => binary_xmlrpc( hsh['name'].to_s ), # FIXME: python encodes this to UTF8. Can't be nil 'parent_id' => "-1", # no such thing as sub-categories in Discourse 'sub_only' => false, # 'child' => [], # Don't return this if we have no children # 'is_protected' => false, # ? 'is_subscribed' => true, # TODO - we pretend all forums are subscribed for now 'can_subscribe' => false, # TODO 'can_post' => !!category_list["can_create_topic"], # FIXME: this may not be correct! 'new_post' => true, # TODO "is_protected" => false, # Not required, apparently 'description' => binary_xmlrpc( hsh['description'].to_s ), "child" => [], # These are returned by some others 'new_post' => false, 'url' => '', 'logo_url' => '', 'unread_count' => 0, # TODO } # FIXME: may not be right? # if return_description # # end tmp } respond_xmlrpc( result ) end def xmlrpc_get_subscribed_forum xmlrpc_get_forum end def xmlrpc_get_subscribed_topic xmlrpc_get_latest_topic end def xmlrpc_get_new_topic xmlrpc_get_latest_topic end # In API level 4, we can say this doesn't exist and force tapatalk to use get_topic def xmlrpc_get_unread_topic( start_num = 0, last_num = 0, search_id = nil, filters = nil ) result = { 'result' => true } unread_topics = discourse.topics_unread({}) topic_list = unread_topics.fetch( "topic_list" ) topics = topic_list.fetch( "topics" ) # No idea what discourse actually returns, other than 0 result['topics'] = topics.select {|hsh| hsh["unread"] != 0 }.collect {|hsh| build_tapatalk_topic( hsh, :users => unread_topics["users"], :categories => unread_topics["categories"] ) } result['total_topic_num'] = result['topics'].count # api level 4 respond_xmlrpc( result ) end def xmlrpc_get_latest_topic( start_num = 0, last_num = 0, search_id = nil, filters = nil ) result = { 'result' => true } latest_topics = discourse.topics_latest({}) topic_list = latest_topics.fetch( "topic_list" ) topics = topic_list.fetch( "topics" ) result['topics'] = topics.collect {|hsh| build_tapatalk_topic( hsh, :users => latest_topics["users"], :categories => latest_topics["categories"] ) } result['total_topic_num'] = result['topics'].count # api level 4 respond_xmlrpc( result ) end def xmlrpc_get_topic( forum_id, start_num = 0, last_num = 0, mode = "DATE" ) if forum_id == "0" # && %w|ANN TOP|.include?(mode) # In these cases, we're not interested in a particular forum # TODO! This is for announcements (mode = "ANN")! FIXME! # TODO: also for latest, like xmlrpc_get_latest_topic! FIXME! return respond_xmlrpc( 'result' => false ) end # TODO: Need to respect pagination, restrict to just one category forum_info = discourse.category(:category_id => forum_id) categories = forum_info.fetch( "categories" ) users = forum_info["users"] categories.each {|hsh| hsh["can_create_topic"] = forum_info["topic_list"]["can_create_topic"] } category = categories.find {|hsh| hsh["id"].to_s == forum_id } # Null category to handle failure-to-find cases above category ||= { 'topic_count' => 0, 'id' => forum_id.to_i, "name" => "", 'can_create_topic' => false, 'topics' => [] } result = { 'forum_id' => category['id'].to_s, 'forum_name' => binary_xmlrpc( category['name'].to_s ), # 'unread_sticky_count' => 0, # TODO - api level 4 # 'unread_announce_count' => 0, # 'can_subscribe' => false, # TODO - api level 4 # 'is_subscribed' => false, # TODO - api level 4 # 'require_prefix' => false, # TODO - api level 4 'prefixes' => [], 'prefix_id' => "", 'prefix_display_name' => binary_xmlrpc( "" ), 'can_post' => !!category['can_create_topic'], # FIXME: may not be correct! 'can_upload' => false, 'require_prefix' => false, # api level 4 } topic_list = forum_info.fetch( "topic_list" ) result['topics'] = topic_list.fetch( "topics" ).collect {|hsh| build_tapatalk_topic( hsh, :users => users, :categories => categories ) } result['total_topic_num'] = result['topics'].count respond_xmlrpc( result ) end # def xmlrpc_get_id_by_url( url ) # TODO # end def xmlrpc_get_thread( topic_id, start_num = 0, end_num = 0, return_html = false ) topic_info = discourse.topic(:topic_id => topic_id.to_i) post_stream = topic_info.fetch( "post_stream" ) posts = post_stream.fetch( "posts" ) result = { "forum_id" => topic_info["category_id"].to_s, "forum_name" => binary_xmlrpc( "Unknown" ), # FIXME: not returned in topic info, need to pull from cache with forum_id "forum_title" => binary_xmlrpc( "Unknown" ), # django insists it's called this "topic_id" => topic_info["id"].to_s, "topic_title" => binary_xmlrpc( topic_info["title"].to_s ), "prefix" => "", "is_subscribed" => false, "can_subscribe" => false, "can_reply" => true, # FIXME: ensure this is accurate "is_approved" => true, # FIXME: wat is das? "can_upload" => false, "is_poll" => false, "is_closed" => !!topic_info["closed"], "can_report" => false, # TODO "breadcrumb" => [], # TODO ? } result["posts"] = posts.collect {|hsh| { "post_id" => hsh["id"].to_s, "post_title" => binary_xmlrpc( hsh["user_title"].to_s ), "post_content" => binary_xmlrpc( format_post_content_for_xmlrpc( hsh["cooked"], return_html ) ), "forum_name" => result["forum_name"], # django says we need this "forum_id" => result["forum_id"], "post_author_id" => hsh["user_id"].to_s, "post_author_name" => binary_xmlrpc( hsh["display_username"].to_s ), # FIXME: display username or real username? "is_online" => !!hsh["yours"], # TODO: not included in discourse output? "can_edit" => !!hsh["can_edit"], # TODO "is_approved" => true, # django again "icon_url" => make_avatar_url( hsh["avatar_template"] ), # TODO: this is a template, may need formatting "post_time" => make_xmlrpc_datetime( hsh["updated_at"] ), "allow_smilies" => true, "reply_number" => hsh["post_number"].to_i, "view_count" => hsh["reads"].to_i, # "attachments" => [], # TODO! # "thanks_info" => [], # TODO # Probably not needed anyway # "likes_info" => [], # TODO! } } result["total_post_num"] = result["posts"].count.to_i respond_xmlrpc( result ) end def csrf_token! # we need one of these to play with the session API, do POSTs, etc. if discourse.csrf_token.nil? discourse.csrf_token = discourse.session_csrf({}).fetch("csrf") end end # attachments and group id are TODO with uploads in general def xmlrpc_new_topic( forum_id, subject, text_body, prefix_id = nil, attachment_id_array = nil, group_id = nil ) csrf_token! # To make it a topic instead of a post, specify a category instead of a topic_id rsp = discourse.post_create( :category => forum_id, :title => subject, :raw => text_body ) respond_xmlrpc( 'result' => true, 'topic_id' => rsp["id"].to_s ) end # attachments, return_html are api level 4 def xmlrpc_reply_post( forum_id, topic_id, subject, text_body, attachment_id_array = nil, return_html = false ) csrf_token! rsp = discourse.post_create( :topic_id => topic_id, :title => subject, :raw => text_body ) result = { 'result' => true, 'post_id' => rsp["id"].to_s, # 'state' => "1", # if post requires moderation # post_content # api level 4 # 'can_edit' => !!rsp['can_edit'] # api level 4 # 'can_delete' => !!rsp['can_delete'] # api level 4 # 'post_time' => make_xmlrpc_datetime( rsp['updated_at'] ) # api level 4 # attachments # api level 4 } respond_xmlrpc( result ) end # def get_smilies # TODO - not a priority # end # Grr. Bad tapatalk, bad. def php_serialize( hash ) # PHP's array serialize format is like this: # a::{s::"...";i:;...;} raise ArgumentError.new( "Not a hash" ) unless hash.is_a?(Hash ) elems = hash.collect {|k, v| raise ArgumentError.new( "key #{k} not a string") unless k.is_a?( String ) raise ArgumentError.new( "value #{v} not Fixnum") unless v.is_a?( Fixnum ) "s:#{k.size}:\"#{k}\";i:#{v}" }.join(";") "a:#{hash.size}:{#{elems};}" end def xmlrpc_update_push_status( push_status, username, password ) user = discourse.user( :username => username ).fetch( "user" ) # FIXME: TODO: CHECK THE PASSWORD IS VALID! if !username || !password || password == "" || username == "" || !user.has_key?("id") || !user.has_key?("username") puts "Returning false - problems validating user" pp user return respond_xmlrpc( 'result' => false ) end # push_status is a hash: {"pm"=>1, "conversation"=>1, "newtopic"=>1, "sub"=>1, "like"=>1, "tag"=>1, "quote"=>1} # We have to do a php-style serialize() on it, base64-encode it and pass it # on to tapatalk's push API. This is going to get nasty. uri = URI.parse( settings.tapatalk_api ) uri.path = "/au_update_push_setting.php" req_data = { 'url' => settings.discourse_api.to_s, 'key' => settings.tapatalk_api_key, 'uid' => user["id"], # FIXME: should we use id instead? 'data' => Base64::encode64( php_serialize( push_status ) ).tr!("\n", "") } # We have this strange call to a strange path - that seems to fail anyway - # to do tapatalk = Net::HTTP.new( "directory.tapatalk.com", 443 ) tapatalk.use_ssl = true tapatalk.verify_mode = OpenSSL::SSL::VERIFY_NONE # FIXME rsp = tapatalk.start {|remote| req = Net::HTTP::Post.new( uri.path ) req.form_data = req_data remote.request( req ) } raise "Unknown response: #{rsp.inspect} #{rsp.body}" unless rsp.is_a?( Net::HTTPOK ) rsp_data = JSON.parse( rsp.body, :create_extensions => false ) unless rsp_data['result'] puts "Warning: POSTing to directory failed: #{rsp_data.inspect}. Upstream ignores said failures, so we will too." end respond_xmlrpc( 'result' => true ) end end post "/" do dispatch end post "/mobiquo/mobiquo.php" do dispatch end