require 'English' helpers do def save_item(item, path) FileUtils.mkdir_p File.dirname(path) File.open(path, 'w+') { |f| f.puts item.to_json } end def create_activity(type, object, to) date = Time.now.utc.iso8601 rel_path = File.join(type.downcase, "#{date}.json") activity = { '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => File.join(OUTBOX[:url], rel_path), 'type' => type, 'actor' => ACTOR, 'published' => date, 'to' => to, 'object' => object } unless activity['object'].is_a? String object_rel_path = File.join('object', object['type'].downcase, "#{date}.json") object = activity['object'] object['@context'] = 'https://www.w3.org/ns/activitystreams' object['id'] = File.join(OUTBOX[:url], object_rel_path) object['published'] = date save_item activity['object'], File.join(OUTBOX[:dir], object_rel_path) if object['tag'] object['tag'].each do |tag| next unless tag['type'] == 'Hashtag' tag_path = File.join(TAGS[:dir], tag['name'].sub('#', '')) + '.json' tag_collection = if File.exist? tag_path JSON.load_file(tag_path) else { '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => tag['href'], 'type' => 'OrderedCollection', 'totalItems' => 0, 'orderedItems' => [] } end tag_collection['orderedItems'] << object['id'] tag_collection['totalItems'] = tag_collection['orderedItems'].size File.open(tag_path, 'w+') do |f| f.puts tag_collection.to_json end end end end activity_path = File.join(OUTBOX[:dir], rel_path) save_item activity, activity_path send_activity activity, activity_path end def send_activity(activity, activity_path) to = activity['to'].is_a?(String) ? [activity['to']] : activity['to'] inboxes = [] to.uniq.each do |url| next if [ACTOR, 'https://www.w3.org/ns/activitystreams#Public'].include? url if url == FOLLOWERS_URL JSON.load_file(FOLLOWERS)['orderedItems'].each do |follower| inboxes << actor_inbox(follower) end next end inboxes << actor_inbox(url) end sha256 = OpenSSL::Digest.new('SHA256') digest = "SHA-256=#{sha256.base64digest(File.read(activity_path))}" keypair = OpenSSL::PKey::RSA.new(File.read('private.pem')) inboxes.compact.uniq.each do |inbox| uri = URI(inbox) httpdate = Time.now.utc.httpdate string = "(request-target): post #{uri.request_uri}\nhost: #{uri.host}\ndate: #{httpdate}\ndigest: #{digest}\ncontent-type: #{CONTENT_TYPE}" signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), string)) signed_header = "keyId=\"#{ACTOR}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"#{signature}\"" # Net::HTTP fails with OpenSSL error curl( "-X POST -H 'Host: #{uri.host}' -H 'Date: #{httpdate}' -H 'Digest: #{digest}' -H 'Signature: #{signed_header}' --data-binary '@#{activity_path}'", inbox ) end end def actor_inbox(url) actor = fetch url return unless actor if actor['endpoints'] and actor['endpoints']['sharedInbox'] actor['endpoints']['sharedInbox'] elsif actor['inbox'] actor['inbox'] end end def update_collection(path, objects, action = 'add') objects = [objects] unless objects.is_a? Array File.open(path, 'r+') do |f| f.flock(File::LOCK_EX) json = f.read collection = JSON.parse(json) objects.each do |object| id = object['id'] || object if action == 'delete' collection['orderedItems'].delete_if { |o| o['id'] == id or o == id } elsif action == 'add' ids = collection['orderedItems'].collect { |i| i['id'] } collection['orderedItems'] << object unless ids.include?(id) or collection['orderedItems'].include?(id) end end collection['orderedItems'].uniq! collection['totalItems'] = collection['orderedItems'].size f.rewind f.puts collection.to_json f.truncate(f.pos) end end def fetch(url, accept = 'application/activity+json') begin uri = URI(url) rescue StandardError => e p url, e return nil end httpdate = Time.now.utc.httpdate keypair = OpenSSL::PKey::RSA.new(File.read('private.pem')) string = "(request-target): get #{uri.request_uri}\nhost: #{uri.host}\ndate: #{httpdate}" signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), string)) signed_header = "keyId=\"#{ACTOR}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"#{signature}\"" response = curl( "-H 'Accept: #{accept}' -H 'Host: #{uri.host}' -H 'Date: #{httpdate}' -H 'Signature: #{signed_header}' ", url ) return unless response begin JSON.parse(response) rescue StandardError => e p url, e nil end end def curl(ext, url) response = `/run/current-system/sw/bin/curl -H 'Content-Type: #{CONTENT_TYPE}' -H 'Accept: #{CONTENT_TYPE}' --fail-with-body -sSL #{ext} #{url}` if $CHILD_STATUS.success? response else p 'Curl Error:', url, response nil end end def mention(actor) person = people.select { |p| p[1] == actor } if person.empty? a = fetch(actor) return nil unless a mention = "@#{a['preferredUsername']}@#{URI(actor).host}" cache mention, actor, a mention else person[0][0] end end def actor(mention) mention = mention.chomp actors = people.select { |p| p[0] == mention } if actors.empty? server = mention.split('@').last a = fetch("https://#{server}/.well-known/webfinger?resource=acct:#{mention.sub(/^@/, '')}", 'application/jrd+json') return nil unless a actor = a['links'].select do |l| l['rel'] == 'self' end[0]['href'] cache mention, actor, a actor else actors[0][1] end end def people File.read('public/people.tsv').split("\n").collect { |l| l.chomp.split("\t") } end def cache(mention, actor, a) sharedInbox = a['endpoints']['sharedInbox'] if a['endpoints'] && a['endpoints']['sharedInbox'] File.open('public/people.tsv', 'a') { |f| f.puts "#{mention}\t#{actor}\t#{sharedInbox}" } end def media_type(url) # TODO: extend extensions extensions = { image: %w[jpeg jpg png tiff webp], audio: %w[flac wav mp3 ogg aiff], video: %w[mp4 webm] } ext = File.extname(url).sub('.', '').downcase type = extensions.find { |_k, v| v.include? ext } "#{type[0]}/#{ext}" end def find_object(id) Dir[File.join('*', '**', '*.json')].each do |file| object = JSON.load_file(file) return [file, object] if object['id'] == id end end def outbox_html(activity) html = File.read('/home/ch/src/publish/html/head.html') html += '

@pdp8@social.pdp8.info

" html += if activity == 'create' "posts | boosts" elsif activity == 'announce' "posts | boosts" end html += '

' Dir[File.join(SOCIAL_DIR, '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.collect { |a| a['object'] }.each do |object| object = fetch(object) if object.is_a? String if object mention = mention object['attributedTo'] html += "
" if activity == 'announce' html += "#{mention} " end html += "#{object['published']} #{object['content']}" if object['attachment'] object['attachment'].each do |att| w = 1024 h = 768 case att['mediaType'] when /audio/ html += "
" when /image/ if activity == 'create' w, h = `/etc/profiles/per-user/ch/bin/identify -format "%w %h" #{att['url'].sub( 'https://media.pdp8.info', '/srv/media' )}`.chomp.split(' ') end html += "
att['name']&1` end end end