# server-server post '/inbox' do request.body.rewind # in case someone already read it @body = request.body.read begin @activity = JSON.parse @body rescue StandardError => e p e, @body halt 400 end verify! handle_activity 200 end # public get '/' do redirect 'https://social.pdp8.info/outbox' end get '/outbox/:activity', provides: 'html' do # outbox_html params['activity'] redirect "https://pdp8.info/social/#{params['activity']}.html" end get '/outbox', provides: 'html' do redirect 'https://pdp8.info/social/create.html' end get '/outbox' do ids = public_outbox.collect { |a| a['id'] } { '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => 'https://social.pdp8.info/outbox', 'type' => 'OrderedCollection', 'totalItems' => ids.size, 'orderedItems' => ids }.to_json end get '/pdp8', provides: 'html' do redirect 'https://pdp8.info/social/create.html' end get '/pdp8' do send_file(File.join(PUBLIC_DIR, 'pdp8.json'), type: CONTENT_TYPE) end get '/.well-known/webfinger' do halt 404 unless request.params['resource'] == "acct:#{MENTION}" send_file(WEBFINGER, type: 'application/jrd+json') end ['/following', '/followers'].each do |path| get path do send_file(File.join(PUBLIC_DIR, path) + '.json', type: CONTENT_TYPE) end end get '/tags/:tag' do |tag| send_file(File.join(PUBLIC_DIR, 'tags', tag) + '.json', type: CONTENT_TYPE) end helpers do def create @count ||= 0 @object ||= @activity['object'] @object = @object['object'] if @object['type'] and ACTIVITIES.include? @object['type'].downcase.to_sym # lemmy return unless @object and @object['type'] != 'Person' @object = fetch(@object) if @object.is_a? String and @object.match(/^http/) return unless @object if @object['id'] and File.readlines(VISITED, chomp: true).include? @object['id'] && !(@activity['type'] = 'Update') return end save_inbox @object File.open(VISITED, 'a+') { |f| f.puts @object['id'] } return unless @object and @object['inReplyTo'] and @count < 5 return if File.readlines(VISITED, chomp: true).include?(@object['inReplyTo']) # recursive thread download @object = @object['inReplyTo'] @count += 1 create end def announce @object ||= @activity['object'] @object = fetch(@object) if @object.is_a? String and @object.match(/^http/) @object['announce'] = @activity['actor'] if @object create end def like @object ||= @activity['object'] @object = fetch(@object) if @object.is_a? String and @object.match(/^http/) @object['like'] = @activity['actor'] if @object create end def follow # save_item @activity, File.join(INBOX[:dir], @activity['type'].downcase, activity_name) update_collection FOLLOWERS, @activity['actor'] create_activity 'Accept', @activity, [@activity['actor']] end def accept # save_item @activity, File.join(INBOX[:dir], @activity['type'].downcase, activity_name) if @activity['object']['type'] == 'Follow' update_collection FOLLOWING, @activity['object']['object'] else p "Error: Cannot accept @activity['object']['type']" jj @activity halt 501 end end def undo case @activity['object']['type'] when 'Follow' update_collection FOLLOWERS, @activity['object']['actor'], 'delete' when 'Create', 'Announce' file = File.join(INBOX_DIR, @activity['object']['id'].sub('https://', '')) # file, object = find_object @activity['object']['object'] FileUtils.rm(file) if file and File.exist? file # and @activity['actor'] == object['attributedTo'] else p "Error: Cannot undo @activity['object']['type']" jj @activity halt 501 end end def update delete create end def delete file = File.join(INBOX_DIR, @activity['object']['id'].sub('https://', '')) FileUtils.rm_f(file) if file end def move create_activity 'Follow', @activity['target'], [@activity['target']] if @activity['actor'] == @activity['object'] end def handle_activity type = @activity['type'].downcase.to_sym # save_item @activity, File.join(INBOX[:dir], @activity['type'].downcase, activity_name) if ACTIVITIES.include? type send(type) else unless %w[Add Remove].include? @activity['type'] p "Error: Unknown activity #{type}:" jj @activity end end end def activity_name @activity['published'] ? "#{@activity['published']}_#{mention(@activity['actor'])}.json" : "#{Time.now.utc.iso8601}_#{mention(@activity['actor'])}.json" end def outbox(activity) Dir[File.join('outbox', activity, '*.json')].collect do |f| JSON.load_file(f) end.select { |a| a['to'].include?('https://www.w3.org/ns/activitystreams#Public') }.sort_by { |a| a['published'] }.reverse end def public_outbox outbox('create') + outbox('announce') end # https://github.com/mastodon/mastodon/blob/main/app/controllers/concerns/signature_verification.rb def verify! # deleted actors return 403 => verification error halt 200 if @activity['type'] == 'Delete' and @activity['actor'] == @activity['object'] # digest sha256 = OpenSSL::Digest.new('SHA256') digest = "SHA-256=#{sha256.base64digest(@body)}" unless digest == request.env['HTTP_DIGEST'] p 'Error: Invalid digest' p @body halt 403 end # signature signature_params = {} request.env['HTTP_SIGNATURE'].split(',').each do |pair| k, v = pair.split('=') signature_params[k] = v.gsub('"', '') end key_id = signature_params['keyId'] headers = signature_params['headers'] signature = Base64.decode64(signature_params['signature']) actor = fetch key_id unless actor p 'Error: No actor' jj @activity halt 403 end key = OpenSSL::PKey::RSA.new(actor['publicKey']['publicKeyPem']) comparison = headers.split(' ').map do |signed_params_name| if signed_params_name == '(request-target)' '(request-target): post /inbox' elsif signed_params_name == 'content-type' "#{signed_params_name}: #{request.env['CONTENT_TYPE']}" elsif signed_params_name == 'content-length' "#{signed_params_name}: #{request.env['CONTENT_LENGTH']}" else "#{signed_params_name}: #{request.env["HTTP_#{signed_params_name.upcase.gsub('-', '_')}"]}" end end.join("\n") return if key.verify(OpenSSL::Digest.new('SHA256'), signature, comparison) p 'Error: Verification failed' jj signature_params jj request.env.select { |k, _v| k.start_with? 'HTTP_' }.to_h puts comparison halt 403 end end