summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--activitypub.rb19
-rw-r--r--client.rb105
-rw-r--r--helpers.rb89
-rw-r--r--public/pdp84
-rw-r--r--server.rb55
-rw-r--r--views/collection.erb9
-rw-r--r--views/object.erb35
7 files changed, 160 insertions, 156 deletions
diff --git a/activitypub.rb b/activitypub.rb
index eefae9b..93bb45a 100644
--- a/activitypub.rb
+++ b/activitypub.rb
@@ -5,25 +5,26 @@ require 'base64'
require 'digest/sha2'
require 'sinatra'
-USER = 'pdp8'
-WWW_DOMAIN = 'pdp8.info'
-WWW_URL = "https://#{WWW_DOMAIN}"
-SOCIAL_DOMAIN = "social.#{WWW_DOMAIN}"
SOCIAL_DIR = '/srv/social/'
-INBOX = File.join(SOCIAL_DIR, 'inbox')
+INBOX_DIR = File.join(SOCIAL_DIR, 'inbox')
+# ARCHIVE_DIR = File.join(SOCIAL_DIR, 'archive')
PUBLIC_DIR = File.join(SOCIAL_DIR, 'public')
-OUTBOX = File.join(PUBLIC_DIR, 'outbox')
+OUTBOX_DIR = File.join(PUBLIC_DIR, 'outbox')
FOLLOWERS = File.join(PUBLIC_DIR, 'followers')
-FOLLOWING = File.join(PUBLIC_DIR, 'following')
+FOLLOWING_DIR = File.join(PUBLIC_DIR, 'following')
TAGS = File.join(PUBLIC_DIR, 'tags')
-ACCOUNT = "#{USER}@#{SOCIAL_DOMAIN}"
+USER = 'pdp8'
+SOCIAL_DOMAIN = 'social.pdp8.info'
+MENTION = "#{USER}@#{SOCIAL_DOMAIN}"
+
SOCIAL_URL = "https://#{SOCIAL_DOMAIN}"
ACTOR = File.join(SOCIAL_URL, USER)
+OUTBOX_URL = File.join(SOCIAL_URL, 'outbox')
+FOLLOWING_URL = File.join(SOCIAL_URL, 'following')
enable :sessions
set :session_secret, File.read('.secret').chomp
-# set :default_content_type, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
set :default_content_type, 'application/activity+json'
set :port, 9292
diff --git a/client.rb b/client.rb
index 3016d02..8f3557a 100644
--- a/client.rb
+++ b/client.rb
@@ -3,14 +3,10 @@
# client-server
post '/' do
protected!
- date = Time.now.strftime('%Y-%m-%dT%H:%M:%S')
- outbox_path = File.join('public/outbox', "#{date}.json")
- notes_path = File.join('public/notes', "#{date}.json")
+ date = Time.now.strftime('%Y-%m-%dT%H:%M:%S.%N')
- recipients = ['https://www.w3.org/ns/activitystreams#Public', params[:to]]
- recipients += Dir[File.join('public/followers', '*.json')].collect { |f| JSON.parse(File.read(f))['actor'] }
- recipients.delete ACTOR
- recipients.uniq!
+ recipients = public
+ recipients << params[:to]
content = []
attachment = []
@@ -45,99 +41,68 @@ post '/' do
end
end
- create = {
- '@context' => 'https://www.w3.org/ns/activitystreams',
- 'id' => File.join(SOCIAL_URL, outbox_path),
- 'type' => 'Create',
- 'actor' => ACTOR,
- 'object' => {
- 'id' => File.join(SOCIAL_URL, notes_path),
- 'type' => 'Note',
- 'attributedTo' => ACTOR,
- 'inReplyTo' => params[:inReplyTo],
- 'published' => date,
- 'content' => "<p>\n#{content.join("\n<br>")}\n</p>",
- 'attachment' => attachment,
- 'tag' => tag,
- 'to' => recipients
- },
- 'published' => date,
+ object = {
+ 'type' => 'Note',
+ 'attributedTo' => ACTOR,
+ 'inReplyTo' => params[:inReplyTo],
+ 'content' => "<p>\n#{content.join("\n<br>")}\n</p>",
+ 'attachment' => attachment,
+ 'tag' => tag,
'to' => recipients
}
- File.open(outbox_path, 'w+') { |f| f.puts create.to_json }
- File.open(notes_path, 'w+') { |f| f.puts create['object'].to_json }
- tag.each do |t|
- dir = File.join('public', 'tags', t['name'].sub('#', ''))
- FileUtils.mkdir_p dir
- FileUtils.ln_s File.join('/srv/social/', notes_path), dir
- end
-
- # recipients.delete "https://www.w3.org/ns/activitystreams#Public"
- # recipients.each { |r| send_signed create, r }
- # send_signed create # , r }
- outbox create
- redirect params['redirect']
+ outbox 'Create', object, recipients, true
+ redirect(params['anchor'] || '/inbox')
end
-post '/archive' do
+post '/share' do
protected!
- FileUtils.mv params['file'], 'archive/'
- redirect to(params['redirect'])
+ selection(params).each { |f| FileUtils.mv f, f.sub(%r{/inbox/}, '/shared/') }
+ outbox 'Announce', params['id'], public
+ redirect(params['anchor'] || '/inbox')
end
-post '/delete' do # delete not supported by html forms
+post '/delete' do
protected!
- FileUtils.rm_f(params['file'] || Dir['inbox/*.json'])
- redirect(params['redirect'] || '/')
+ selection(params).each { |f| FileUtils.rm f }
+ redirect(params['anchor'] || '/inbox')
end
post '/follow' do
protected!
actor, mention = parse_follow params['follow']
- follow = { '@context' => 'https://www.w3.org/ns/activitystreams',
- 'id' => File.join(SOCIAL_URL, 'following', "#{mention}.json"),
- 'type' => 'Follow',
- 'actor' => ACTOR,
- 'object' => actor,
- 'to' => [actor] }
- send_signed follow # , actor
- redirect '/'
+ outbox 'Follow', actor, [actor]
+ redirect(params['anchor'] || '/inbox')
end
post '/unfollow' do
protected!
actor, mention = parse_follow params['follow']
- following_path = File.join('public', 'following', "#{mention}.json")
+ following_path = File.join(FOLLOWING_DIR, "#{mention}.json")
if File.exist?(following_path)
- undo = { '@context' => 'https://www.w3.org/ns/activitystreams',
- 'id' => File.join("#{SOCIAL_URL}#undo", SecureRandom.uuid),
- 'type' => 'Undo',
- 'actor' => ACTOR,
- 'object' => JSON.parse(File.read(following_path)),
- 'to' => [actor] }
- send_signed undo # , actor
- FileUtils.rm following_path
- redirect '/'
+ outbox 'Undo', JSON.parse(File.read(following_path)), [actor]
+ FileUtils.rm_f following_path
+ redirect(params['anchor'] || '/inbox')
end
end
post '/login' do
session['client'] = (OpenSSL::Digest::SHA256.base64digest(params['secret']) == File.read('.digest').chomp)
- redirect '/'
+ redirect '/inbox'
end
get '/' do
+ protected!
redirect '/inbox'
end
-['/inbox', '/archive', '/outbox'].each do |path|
+['/inbox', '/shared', '/outbox'].each do |path|
get path, provides: 'html' do
protected!
@dir = path.sub('/', '')
collection = ordered_collection(File.join(SOCIAL_DIR, path, 'note'))['orderedItems']
@threads = []
- collection.each do |object|
+ collection.each_with_index do |object, _idx|
object['indent'] = 0
object['replies'] = []
if object['inReplyTo'].nil? || collection.select { |o| o['id'] == object['inReplyTo'] }.empty?
@@ -158,6 +123,18 @@ helpers do
halt 403 unless session['client']
end
+ def selection(params)
+ selection = Dir[File.join(SOCIAL_DIR, params['dir'], '*', '*.json')]
+ params['id'] ? selection.select { |f| JSON.parse(File.read(f))['id'] == params['id'] } : selection
+ end
+
+ def public
+ recipients = ['https://www.w3.org/ns/activitystreams#Public']
+ recipients += Dir[File.join(FOLLOWERS, '*.json')].collect { |f| JSON.parse(File.read(f))['actor'] }
+ recipients.delete ACTOR
+ recipients.uniq
+ end
+
def parse_follow(follow)
case follow
when /^#/
diff --git a/helpers.rb b/helpers.rb
index fdbd894..556f187 100644
--- a/helpers.rb
+++ b/helpers.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'English'
+
helpers do
def curl(ext, url)
p url
@@ -8,7 +9,6 @@ helpers do
$CHILD_STATUS.success? ? response : nil
end
- # def fetch(url, accept = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
def fetch(url, accept = 'application/activity+json')
response = curl("-H 'Accept: #{accept}'", url)
response ? JSON.parse(response) : nil
@@ -16,36 +16,60 @@ helpers do
# https://github.com/mastodon/mastodon/blob/main/app/lib/request.rb
# , url
- def send_signed(object)
+ def outbox(type, object, recipients, add_recipients = false)
+ activity = {
+ '@context' => 'https://www.w3.org/ns/activitystreams',
+ 'type' => type,
+ 'actor' => ACTOR,
+ 'object' => object
+ }
+
+ now = Time.now
+ date = now.strftime('%Y-%m-%dT%H:%M:%S.%N')
+ httpdate = now.utc.httpdate
+ basename = "#{date}.json"
+ activity['id'] = File.join(OUTBOX_URL, basename)
+ activity['published'] = httpdate
+ if activity['object'] and activity['object']['type'] and !activity['object']['id']
+ rel_path = File.join activity['object']['type'].downcase, basename
+ activity['object']['published'] = httpdate
+ activity['object']['id'] = File.join(OUTBOX_URL, rel_path)
+ File.open(File.join(OUTBOX_DIR, rel_path), 'w+') { |f| f.puts activity.to_json }
+ end
+ File.open(File.join(OUTBOX_DIR, basename), 'w+') { |f| f.puts activity.to_json }
+
keypair = OpenSSL::PKey::RSA.new(File.read('private.pem'))
- date = Time.now.utc.httpdate
- body = object.to_json
+ body = activity.to_json
sha256 = OpenSSL::Digest.new('SHA256')
digest = "SHA-256=#{sha256.base64digest(body)}"
- recipients = [object['to'], object['cc'], object['bto'], object['bcc'], object['audience']].flatten.compact.uniq
- recipients.each do |url|
- next if url == 'https://www.w3.org/ns/activitystreams#Public'
-
- host = URI.parse(url).host
- inbox = fetch(url)['inbox']
- if inbox
- request_uri = URI(inbox).request_uri
-
- signed_string = "(request-target): post #{request_uri}\nhost: #{host}\ndate: #{date}\ndigest: #{digest}\ncontent-type: application/activity+json"
- signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
- signed_header = "keyId=\"#{ACTOR}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"#{signature}\""
-
- curl(
- "-X POST -H 'Content-Type: application/activity+json' -H 'Host: #{host}' -H 'Date: #{date}' -H 'Digest: #{digest}' -H 'Signature: #{signed_header}' -d '#{body}'", inbox
- )
- else
- p "No inbox for #{url}"
- end
+
+ # assumes that recipient collections have been expanded by sender
+ # put all recipients into 'to', avoid 'cc' 'bto' 'bcc' 'audience' !!
+ activity['to'] = recipients if add_recipients
+ inboxes = if recipients.include? 'https://www.w3.org/ns/activitystreams#Public'
+ people.collect { |p| p[2] }.uniq # cached sharedInboxes
+ else
+ []
+ end
+ recipients.uniq.each do |url|
+ next if [ACTOR, 'https://www.w3.org/ns/activitystreams#Public'].include? url
+
+ actor = fetch url
+ next unless actor and actor['inbox']
+
+ inbox = actor['endpoints']['sharedInbox']
+ inboxes << (inbox || actor['inbox'])
end
- end
+ inboxes.compact.uniq.each do |inbox|
+ uri = URI(inbox)
+ string = "(request-target): post #{uri.request_uri}\nhost: #{uri.host}\ndate: #{httpdate}\ndigest: #{digest}\ncontent-type: application/activity+json"
+ 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}\""
- def people
- File.read('cache/people.tsv').split("\n").collect { |l| l.chomp.split("\t") }
+ curl(
+ "-X POST -H 'Content-Type: application/activity+json' -H 'Host: #{uri.host}' -H 'Date: #{httpdate}' -H 'Digest: #{digest}' -H 'Signature: #{signed_header}' -d '#{body}'", inbox
+ )
+ end
end
def mention(actor)
@@ -55,7 +79,7 @@ helpers do
return nil unless a
mention = "#{a['preferredUsername']}@#{URI(actor).host}"
- File.open('cache/people.tsv', 'a') { |f| f.puts "#{mention}\t#{actor}" }
+ cache mention, actor, a
mention
else
person[0][0]
@@ -73,10 +97,19 @@ helpers do
actor = a['links'].select do |l|
l['rel'] == 'self'
end[0]['href']
- File.open('cache/people.tsv', 'a') { |f| f.puts "#{mention}\t#{actor}" }
+ cache mention, actor, a
actor
else
actors[0][1]
end
end
+
+ def people
+ File.read('cache/people.tsv').split("\n").collect { |l| l.chomp.split("\t") }
+ end
+
+ def cache(mention, actor, a)
+ sharedInbox = a['endpoints']['sharedInbox'] if a['endpoints'] and a['endpoints']['sharedInbox']
+ File.open('cache/people.tsv', 'a') { |f| f.puts "#{mention}\t#{actor}\t#{sharedInbox}" }
+ end
end
diff --git a/public/pdp8 b/public/pdp8
index fafc4e0..36c4ac9 100644
--- a/public/pdp8
+++ b/public/pdp8
@@ -10,6 +10,7 @@
"outbox": "https://social.pdp8.info/outbox",
"following": "https://social.pdp8.info/following",
"followers": "https://social.pdp8.info/followers",
+ "manuallyApprovesFollowers": false,
"icon": {
"type": "Image",
"url": "https://pdp8.info/pdp8.png"
@@ -31,6 +32,9 @@
"value": "<a rel=\"me\" href=\"https://matrix.to/#/@pdp8:matrix.pdp8.info\">@pdp8:matrix.pdp8.info</a>"
}
],
+ "endpoints": {
+ "sharedInbox": "https://social.pdp8.info/inbox"
+ }
"publicKey": {
"id": "https://social.pdp8.info/pdp8#main-key",
"owner": "https://social.pdp8.info/pdp8",
diff --git a/server.rb b/server.rb
index 386b519..11b6411 100644
--- a/server.rb
+++ b/server.rb
@@ -4,21 +4,26 @@
post '/inbox' do
request.body.rewind # in case someone already read it
@body = request.body.read
- unless @body.empty?
+ halt 400 if @body.empty?
+ begin
@activity = JSON.parse @body
- @object = @activity['object']
- @object = fetch(@object) if @object.is_a?(String) && @object.match(/^http/)
- halt 400 unless @object
+ rescue StandardError
+ p @body
+ halt 400
end
- verify!
type = @activity['type'].downcase.to_sym
p type
- respond_to?(type) ? send(type) : p("Unknown activity: #{type}")
+ halt 501 unless respond_to?(type)
+ @object = @activity['object']
+ @object = fetch(@object) if @object.is_a?(String) && @object.match(/^http/)
+ halt 400 unless @object
+ verify!
+ send(type)
end
# public
get '/.well-known/webfinger' do
- if request['resource'] == "acct:#{ACCOUNT}"
+ if request['resource'] == "acct:#{MENTION}"
send_file('./public/webfinger',
type: 'application/jrd+json')
else
@@ -27,14 +32,9 @@ get '/.well-known/webfinger' do
end
get '/outbox' do
- ordered_collection(OUTBOX).to_json
+ ordered_collection(OUTBOX_DIR).to_json
end
-# get '/inbox' do
-# protected!
-# ordered_collection(File.join(INBOX, 'note')).to_json
-# end
-
['/following', '/followers'].each do |path|
get path do
ordered_collection(File.join(PUBLIC_DIR, path)).to_json
@@ -93,36 +93,19 @@ helpers do
create if @object
end
- def delete
- Dir['inbox/*/*.json'].each do |file|
- FileUtils.rm file if JSON.parse(File.read(file))['id'] == @object['id']
- end
- end
-
- def update
- delete
- create
- end
-
def announce
create
end
def follow
File.open(File.join(FOLLOWERS, "#{mention(@activity['actor'])}.json"), 'w+') { |f| f.puts @body }
- accept = { '@context' => 'https://www.w3.org/ns/activitystreams',
- 'id' => File.join("#{SOCIAL_URL}#accepts", SecureRandom.uuid),
- 'type' => 'Accept',
- 'actor' => ACTOR,
- 'object' => @activity,
- 'to' => [@activity['actor']] }
- send_signed accept
+ outbox 'Accept', @activity, [@activity['actor']]
end
def accept
return unless @object['type'] == 'Follow'
- File.open(File.join(FOLLOWING, "#{mention(@object['object'])}.json"), 'w+') { |f| f.puts @object.to_json }
+ File.open(File.join(FOLLOWING_DIR, "#{mention(@object['object'])}.json"), 'w+') { |f| f.puts @object.to_json }
end
def undo
@@ -133,14 +116,8 @@ helpers do
end
end
- # when "Like"
- # when "Move"
- # when "Add"
- # when "Remove"
- # when "Block"
-
def inbox
- Dir[File.join(INBOX, '*', '*.json')].collect do |file|
+ Dir[File.join(INBOX_DIR, 'note', '*.json')].collect do |file|
JSON.parse(File.read(file))
end.sort_by { |o| o['published'] }
end
diff --git a/views/collection.erb b/views/collection.erb
index 8dd3878..4cc449f 100644
--- a/views/collection.erb
+++ b/views/collection.erb
@@ -5,7 +5,7 @@
</head>
<body>
<h1><%= @dir %>
- <% dirs = ['inbox','outbox','archive']
+ <% dirs = ['inbox','outbox','shared']
dirs.delete(@dir)
dirs.each do |d| %>
<form action='/<%= d %>' method='get'>
@@ -13,13 +13,18 @@
</form>
<% end %>
</h1>
- <% @threads.each do |object|
+ <% @idx = 0
+ @threads.each do |object|
@object = object %>
<%= erb :object %>
<% end %>
+ <% unless @dir == 'shared' %>
<form action='/delete' method='post'>
+ <input type='hidden' name='dir' value='/<%= @dir %>' />
+ <input type='hidden' name='anchor' value='/<%= @dir %>' />
<button>Delete all</button>
</form>
+ <% end %>
</body>
<script>
const reply_buttons = document.querySelectorAll(".reply");
diff --git a/views/object.erb b/views/object.erb
index 744a518..3e6e617 100644
--- a/views/object.erb
+++ b/views/object.erb
@@ -1,26 +1,31 @@
-
-<% mention = mention @object['attributedTo']
- following_path = File.join(FOLLOWING, "#{mention}.json")
- follow = File.exist?(following_path) ? 'unfollow' : 'follow'
+<% @idx +=1
+ mention = mention @object['attributedTo']
+ follow = File.exist?(File.join(FOLLOWING_DIR, "#{mention}.json")) ? 'unfollow' : 'follow'
%>
-<div style='margin-left:<%= @object['indent']%>em' id='<%= @object['id'] %>'>
+<div style='margin-left:<%= @object['indent']%>em' id='<%= @idx %>'>
<b><a href='<%= @object['attributedTo'] %>', target='_blank'><%= mention %></a></b>&nbsp;
<form action='/<%= follow %>' method='post'>
<input type='hidden' name='follow' value='<%= @object['attributedTo'] %>' />
- <input type='hidden' name='redirect' value='/#<%= @object['id'] %>' />
+ <input type='hidden' name='anchor' value='/<%= @dir %>#<%= @idx %>' />
<button><%= follow.capitalize %></button>
</form>
&nbsp;
+ <em><%= @object['published'] %></em>
+ &nbsp;
<form action='/delete' method='post'>
<input type='hidden' name='id' value='<%= @object['id'] %>' />
- <input type='hidden' name='redirect' value='/#<%= @object['id'] %>' />
+ <input type='hidden' name='dir' value='<%= @dir %>' />
+ <input type='hidden' name='anchor' value='/<%= @dir %>#<%= @idx %>' />
<button>Delete</button>
</form>
&nbsp;
- <form action='/like' method='post'>
+ <% unless @dir == 'shared' %>
+ <form action='/share' method='post'>
<input type='hidden' name='id' value='<%= @object['id'] %>' />
- <input type='hidden' name='redirect' value='/#<%= @object['id'] %>' />
- <button>Like</button>
+ <input type='hidden' name='dir' value='<%= @dir %>' />
+ <input type='hidden' name='anchor' value='/<%= @dir %>#<%= @idx %>' />
+ <button>Share</button>
+ <% end %>
</form>
<%= @object['content'] %>
<% if @object['attachment']
@@ -39,16 +44,18 @@
<% end %>
<% end %>
<p>
- <button class='reply' data-index='<%= @object['id'] %>'>Reply</button>
- <form action='/' method='post' id='form<%= @object['id'] %>' style='display:none;' >
+ <% unless @dir == 'shared' %>
+ <button class='reply' data-index='<%= @idx %>'>Reply</button>
+ <form action='/' method='post' id='form<%= @idx %>' style='display:none;' >
<input type='hidden' name='to' value='<%= @object['attributedTo'] %>' />
<input type='hidden' name='inReplyTo' value='<%= @object['id'] %>' />
- <input type='hidden' name='redirect' value='/#<%= @object['id'] %>' />
+ <input type='hidden' name='anchor' value='/<%= @dir %>#<%= @idx %>' />
<textarea name='content'></textarea>
<br>
- <button class='cancel' data-index='<%= @object['id'] %>'>Cancel</button>
+ <button class='cancel' data-index='<%= @idx %>'>Cancel</button>
<input type='submit' value='Send'>
</form>
+ <% end %>
</div>
<% @object['replies'].each do |reply|
@object = reply %>