diff --git a/Gemfile b/Gemfile index 59eb08814..f0a77f7b6 100644 --- a/Gemfile +++ b/Gemfile @@ -38,6 +38,8 @@ group :development do gem 'web-console', '~> 2.0' gem 'spring' gem 'rubocop', require: false + gem 'better_errors' + gem 'binding_of_caller' end group :production do diff --git a/Gemfile.lock b/Gemfile.lock index ff72f4ddf..fad87d191 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -43,6 +43,10 @@ GEM descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) + better_errors (2.1.1) + coderay (>= 1.0.0) + erubis (>= 2.6.6) + rack (>= 0.9.0) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) builder (3.2.2) @@ -284,6 +288,8 @@ PLATFORMS DEPENDENCIES addressable + better_errors + binding_of_caller byebug coffee-rails (~> 4.1.0) dotenv-rails diff --git a/app/api/mastodon/entities.rb b/app/api/mastodon/entities.rb index a3f40ec48..2e56a67df 100644 --- a/app/api/mastodon/entities.rb +++ b/app/api/mastodon/entities.rb @@ -3,6 +3,8 @@ module Mastodon class Account < Grape::Entity expose :username expose :domain + expose :display_name + expose :note end class Status < Grape::Entity diff --git a/app/api/mastodon/ostatus.rb b/app/api/mastodon/ostatus.rb index fcde980f7..4676bc429 100644 --- a/app/api/mastodon/ostatus.rb +++ b/app/api/mastodon/ostatus.rb @@ -8,12 +8,10 @@ module Mastodon resource :subscriptions do helpers do - def subscription_url(account) - "https://649841dc.ngrok.io/api#{subscriptions_path(id: account.id)}" - end + include ApplicationHelper end - desc 'Receive updates from a feed' + desc 'Receive updates from an account' params do requires :id, type: String, desc: 'Account ID' @@ -23,14 +21,14 @@ module Mastodon body = request.body.read if @account.subscription(subscription_url(@account)).verify(body, env['HTTP_X_HUB_SIGNATURE']) - ProcessFeedUpdateService.new.(body, @account) + ProcessFeedService.new.(body, @account) status 201 else status 202 end end - desc 'Confirm PuSH subscription to a feed' + desc 'Confirm PuSH subscription to an account' params do requires :id, type: String, desc: 'Account ID' @@ -49,14 +47,15 @@ module Mastodon end resource :salmon do - desc 'Receive Salmon updates' + desc 'Receive Salmon updates targeted to account' params do requires :id, type: String, desc: 'Account ID' end post ':id' do - # todo + ProcessInteractionService.new.(request.body.read, @account) + status 201 end end end diff --git a/app/api/mastodon/rest.rb b/app/api/mastodon/rest.rb index e011ab34d..eaf337938 100644 --- a/app/api/mastodon/rest.rb +++ b/app/api/mastodon/rest.rb @@ -5,9 +5,34 @@ module Mastodon resource :statuses do desc 'Return a public timeline' + get :all do present Status.all, with: Mastodon::Entities::Status end + + desc 'Return the home timeline of a logged in user' + + get :home do + # todo + end + + desc 'Return the notifications timeline of a logged in user' + + get :notifications do + # todo + end + end + + resource :accounts do + desc 'Return a user profile' + + params do + requires :id, type: String, desc: 'Account ID' + end + + get ':id' do + present Account.find(params[:id]), with: Mastodon::Entities::Account + end end end end diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index e07c5a830..646c5aba4 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -12,5 +12,4 @@ // //= require jquery //= require jquery_ujs -//= require turbolinks //= require_tree . diff --git a/app/assets/javascripts/atom.coffee b/app/assets/javascripts/atom.coffee new file mode 100644 index 000000000..24f83d18b --- /dev/null +++ b/app/assets/javascripts/atom.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/javascripts/home.coffee b/app/assets/javascripts/home.coffee new file mode 100644 index 000000000..24f83d18b --- /dev/null +++ b/app/assets/javascripts/home.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/javascripts/profile.coffee b/app/assets/javascripts/profile.coffee new file mode 100644 index 000000000..24f83d18b --- /dev/null +++ b/app/assets/javascripts/profile.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/javascripts/xrd.coffee b/app/assets/javascripts/xrd.coffee new file mode 100644 index 000000000..24f83d18b --- /dev/null +++ b/app/assets/javascripts/xrd.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/stylesheets/atom.scss b/app/assets/stylesheets/atom.scss new file mode 100644 index 000000000..888698db3 --- /dev/null +++ b/app/assets/stylesheets/atom.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the Atom controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/assets/stylesheets/home.scss b/app/assets/stylesheets/home.scss new file mode 100644 index 000000000..7131aac4d --- /dev/null +++ b/app/assets/stylesheets/home.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the Home controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/assets/stylesheets/profile.scss b/app/assets/stylesheets/profile.scss new file mode 100644 index 000000000..22ee50876 --- /dev/null +++ b/app/assets/stylesheets/profile.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the Profile controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/assets/stylesheets/xrd.scss b/app/assets/stylesheets/xrd.scss new file mode 100644 index 000000000..62391c7d3 --- /dev/null +++ b/app/assets/stylesheets/xrd.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the XRD controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/controllers/atom_controller.rb b/app/controllers/atom_controller.rb new file mode 100644 index 000000000..e0b45c580 --- /dev/null +++ b/app/controllers/atom_controller.rb @@ -0,0 +1,14 @@ +class AtomController < ApplicationController + before_filter :set_format + + def user_stream + @account = Account.find_by!(id: params[:id], domain: nil) + end + + private + + def set_format + request.format = 'xml' + response.headers['Content-Type'] = 'application/atom+xml' + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 000000000..95f29929c --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,4 @@ +class HomeController < ApplicationController + def index + end +end diff --git a/app/controllers/profile_controller.rb b/app/controllers/profile_controller.rb new file mode 100644 index 000000000..2374318eb --- /dev/null +++ b/app/controllers/profile_controller.rb @@ -0,0 +1,4 @@ +class ProfileController < ApplicationController + def show + end +end diff --git a/app/controllers/xrd_controller.rb b/app/controllers/xrd_controller.rb new file mode 100644 index 000000000..4c8e958e6 --- /dev/null +++ b/app/controllers/xrd_controller.rb @@ -0,0 +1,39 @@ +class XrdController < ApplicationController + before_filter :set_format + + def host_meta + @webfinger_template = "#{webfinger_url}?resource={uri}" + end + + def webfinger + @account = Account.find_by!(username: username_from_resource, domain: nil) + @canonical_account_uri = "acct:#{@account.username}#{LOCAL_DOMAIN}" + @magic_key = pem_to_magic_key(@account.keypair.public_key) + end + + private + + def set_format + request.format = 'xml' + response.headers['Content-Type'] = 'application/xrd+xml' + end + + def username_from_resource + params[:resource].split('@').first.gsub('acct:', '') + end + + def pem_to_magic_key(public_key) + modulus, exponent = [public_key.n, public_key.e].map do |component| + result = "" + + until component == 0 do + result << [component % 256].pack('C') + component >>= 8 + end + + result.reverse! + end + + (["RSA"] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.') + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be7945..29e444a32 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,19 @@ module ApplicationHelper + include GrapeRouteHelpers::NamedRouteMatcher + + def unique_tag(date, id, type) + "tag:#{LOCAL_DOMAIN},#{date.strftime('%Y-%m-%d')}:objectId=#{id}:objectType=#{type}" + end + + def subscription_url(account) + add_base_url_prefix subscription_path(id: account.id, format: '') + end + + def salmon_url(account) + add_base_url_prefix salmon_path(id: account.id, format: '') + end + + def add_base_url_prefix(suffix) + "#{root_url}api#{suffix}" + end end diff --git a/app/helpers/atom_helper.rb b/app/helpers/atom_helper.rb new file mode 100644 index 000000000..a42a49946 --- /dev/null +++ b/app/helpers/atom_helper.rb @@ -0,0 +1,5 @@ +module AtomHelper + def stream_updated_at + @account.stream_entries.last ? @account.stream_entries.last.created_at.iso8601 : @account.updated_at.iso8601 + end +end diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb new file mode 100644 index 000000000..23de56ac6 --- /dev/null +++ b/app/helpers/home_helper.rb @@ -0,0 +1,2 @@ +module HomeHelper +end diff --git a/app/helpers/profile_helper.rb b/app/helpers/profile_helper.rb new file mode 100644 index 000000000..5a0d6b31f --- /dev/null +++ b/app/helpers/profile_helper.rb @@ -0,0 +1,2 @@ +module ProfileHelper +end diff --git a/app/helpers/xrd_helper.rb b/app/helpers/xrd_helper.rb new file mode 100644 index 000000000..6b273e122 --- /dev/null +++ b/app/helpers/xrd_helper.rb @@ -0,0 +1,2 @@ +module XrdHelper +end diff --git a/app/models/account.rb b/app/models/account.rb index c0b153794..90e8d7610 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,6 +1,38 @@ class Account < ActiveRecord::Base + # Local users + has_one :user, inverse_of: :account + + # Timelines + has_many :stream_entries, inverse_of: :account has_many :statuses, inverse_of: :account + # Follow relations + has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy + has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy + + has_many :following, through: :active_relationships, source: :target_account + has_many :followers, through: :passive_relationships, source: :account + + def follow!(other_account) + self.active_relationships.create!(target_account: other_account) + end + + def unfollow!(other_account) + self.active_relationships.find_by(target_account: other_account).destroy + end + + def following?(other_account) + following.include?(other_account) + end + + def local? + self.domain.nil? + end + + def keypair + self.private_key.nil? ? OpenSSL::PKey::RSA.new(self.public_key) : OpenSSL::PKey::RSA.new(self.private_key) + end + def subscription(webhook_url) @subscription ||= OStatus2::Subscription.new(self.remote_url, secret: self.secret, token: self.verify_token, webhook: webhook_url, hub: self.hub_url) end diff --git a/app/models/follow.rb b/app/models/follow.rb new file mode 100644 index 000000000..eec01b9ba --- /dev/null +++ b/app/models/follow.rb @@ -0,0 +1,8 @@ +class Follow < ActiveRecord::Base + belongs_to :account + belongs_to :target_account, class_name: 'Account' + + after_create do + self.account.stream_entries.create!(activity: self) + end +end diff --git a/app/models/status.rb b/app/models/status.rb index a1278ccaa..d98297643 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -1,3 +1,7 @@ class Status < ActiveRecord::Base belongs_to :account, inverse_of: :statuses + + after_create do + self.account.stream_entries.create!(activity: self) + end end diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb new file mode 100644 index 000000000..cee151a07 --- /dev/null +++ b/app/models/stream_entry.rb @@ -0,0 +1,33 @@ +class StreamEntry < ActiveRecord::Base + belongs_to :account, inverse_of: :stream_entries + belongs_to :activity, polymorphic: true + + def object_type + case self.activity_type + when 'Status' + :note + when 'Follow' + :person + end + end + + def verb + case self.activity_type + when 'Status' + :post + when 'Follow' + :follow + end + end + + def target + case self.activity_type + when 'Follow' + self.activity.target_account + end + end + + def content + self.activity.text if self.activity_type == 'Status' + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 000000000..ccfa54e4f --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,3 @@ +class User < ActiveRecord::Base + belongs_to :account, inverse_of: :user +end diff --git a/app/services/fetch_feed_service.rb b/app/services/fetch_feed_service.rb index 3b8efbe3b..059d65925 100644 --- a/app/services/fetch_feed_service.rb +++ b/app/services/fetch_feed_service.rb @@ -1,5 +1,15 @@ class FetchFeedService def call(account) - # todo + process_service.(http_client.get(account.remote_url), account) + end + + private + + def process_service + ProcessFeedService.new + end + + def http_client + HTTP end end diff --git a/app/services/follow_remote_user_service.rb b/app/services/follow_remote_account_service.rb similarity index 81% rename from app/services/follow_remote_user_service.rb rename to app/services/follow_remote_account_service.rb index f3c0e68df..41f8fa4a0 100644 --- a/app/services/follow_remote_user_service.rb +++ b/app/services/follow_remote_account_service.rb @@ -1,14 +1,14 @@ -class FollowRemoteUserService - include GrapeRouteHelpers::NamedRouteMatcher +class FollowRemoteAccountService + include ApplicationHelper - def call(user) - username, domain = user.split('@') + def call(uri) + username, domain = uri.split('@') account = Account.where(username: username, domain: domain).first return account unless account.nil? account = Account.new(username: username, domain: domain) - data = Goldfinger.finger("acct:#{user}") + data = Goldfinger.finger("acct:#{uri}") account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href account.salmon_url = data.link('salmon').href @@ -21,8 +21,9 @@ class FollowRemoteUserService feed = get_feed(account.remote_url) hubs = feed.xpath('//xmlns:link[@rel="hub"]') - return false if hubs.empty? || hubs.first.attribute('href').nil? + return false if hubs.empty? || hubs.first.attribute('href').nil? || feed.at_xpath('/xmlns:author/xmlns:uri').nil? + account.uri = feed.at_xpath('/xmlns:author/xmlns:uri').content account.hub_url = hubs.first.attribute('href').value account.save! @@ -45,7 +46,7 @@ class FollowRemoteUserService key = OpenSSL::PKey::RSA.new key.n = modulus - key.d = exponent + key.e = exponent key.to_pem end @@ -53,8 +54,4 @@ class FollowRemoteUserService def http_client HTTP end - - def subscription_url(account) - "https://649841dc.ngrok.io/api#{subscriptions_path(id: account.id)}" - end end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb new file mode 100644 index 000000000..fc606730b --- /dev/null +++ b/app/services/follow_service.rb @@ -0,0 +1,12 @@ +class FollowService + def call(source_account, uri) + target_account = follow_remote_account_service.(uri) + source_account.follow!(target_account) + end + + private + + def follow_remote_account_service + FollowRemoteAccountService.new + end +end diff --git a/app/services/process_feed_update_service.rb b/app/services/process_feed_service.rb similarity index 94% rename from app/services/process_feed_update_service.rb rename to app/services/process_feed_service.rb index 0585fad7a..f2523a313 100644 --- a/app/services/process_feed_update_service.rb +++ b/app/services/process_feed_service.rb @@ -1,4 +1,4 @@ -class ProcessFeedUpdateService +class ProcessFeedService def call(body, account) xml = Nokogiri::XML(body) diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb new file mode 100644 index 000000000..8262ead8f --- /dev/null +++ b/app/services/process_interaction_service.rb @@ -0,0 +1,38 @@ +class ProcessInteractionService + def call(envelope, target_account) + body = salmon.unpack(envelope) + xml = Nokogiri::XML(body) + + return if xml.at_xpath('//author/name').nil? || xml.at_xpath('//author/uri').nil? + + username = xml.at_xpath('//author/name').content + url = xml.at_xpath('//author/uri').content + domain = Addressable::URI.parse(url).host + account = Account.find_by(username: username, domain: domain) + + if account.nil? + account = follow_remote_account_service.("acct:#{username}@#{domain}") + end + + if salmon.verify(envelope, account.keypair) + verb = xml.at_path('//activity:verb').content + + case verb + when 'http://activitystrea.ms/schema/1.0/follow', 'follow' + account.follow!(target_account) + when 'http://activitystrea.ms/schema/1.0/unfollow', 'unfollow' + account.unfollow!(target_account) + end + end + end + + private + + def salmon + OStatus2::Salmon.new + end + + def follow_remote_account_service + FollowRemoteAccountService.new + end +end diff --git a/app/services/setup_local_account_service.rb b/app/services/setup_local_account_service.rb new file mode 100644 index 000000000..c40e51855 --- /dev/null +++ b/app/services/setup_local_account_service.rb @@ -0,0 +1,14 @@ +class SetupLocalAccountService + def call(user, username) + user.build_account + + user.account.username = username + user.account.domain = nil + + keypair = OpenSSL::PKey::RSA.new(2048) + user.account.private_key = keypair.to_pem + user.account.public_key = keypair.public_key.to_pem + + user.save! + end +end diff --git a/app/views/atom/user_stream.xml.ruby b/app/views/atom/user_stream.xml.ruby new file mode 100644 index 000000000..d418ea0ec --- /dev/null +++ b/app/views/atom/user_stream.xml.ruby @@ -0,0 +1,35 @@ +Nokogiri::XML::Builder.new do |xml| + xml.feed(xmlns: 'http://www.w3.org/2005/Atom', 'xmlns:thr': 'http://purl.org/syndication/thread/1.0', 'xmlns:activity': 'http://activitystrea.ms/spec/1.0/') do + xml.id_ atom_user_stream_url(id: @account.id) + xml.title @account.display_name + xml.subtitle @account.note + xml.updated stream_updated_at + + xml.author do + xml['activity'].send('object-type', 'http://activitystrea.ms/schema/1.0/person') + xml.uri profile_url(name: @account.username) + xml.name @account.username + xml.summary @account.note + + xml.link(rel: 'alternate', type: 'text/html', href: profile_url(name: @account.username)) + end + + xml.link(rel: 'alternate', type: 'text/html', href: profile_url(name: @account.username)) + xml.link(rel: 'hub', href: '') + xml.link(rel: 'salmon', href: salmon_url(@account)) + xml.link(rel: 'self', type: 'application/atom+xml', href: atom_user_stream_url(id: @account.id)) + + @account.stream_entries.each do |stream_entry| + xml.entry do + xml.id_ unique_tag(stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type) + xml.published stream_entry.activity.created_at.iso8601 + xml.updated stream_entry.activity.updated_at.iso8601 + xml.content({ type: 'html' }, stream_entry.content) + xml.title + + xml['activity'].send('verb', "http://activitystrea.ms/schema/1.0/#{stream_entry.verb}") + xml['activity'].send('object-type', "http://activitystrea.ms/schema/1.0/#{stream_entry.object_type}") + end + end + end +end.to_xml diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml new file mode 100644 index 000000000..862374a98 --- /dev/null +++ b/app/views/home/index.html.haml @@ -0,0 +1 @@ +Mastodon diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb deleted file mode 100644 index ff0d4c865..000000000 --- a/app/views/layouts/application.html.erb +++ /dev/null @@ -1,14 +0,0 @@ - - -
-