merge-requests/30/head
Bob Mottram 2023-01-12 13:11:57 +00:00
commit d2cc70d58c
5 changed files with 235 additions and 129 deletions

View File

@ -674,30 +674,35 @@ class PubServer(BaseHTTPRequestHandler):
nickname, nickname,
self.server.domain_full, self.server.domain_full,
self.server.person_cache) self.server.person_cache)
reply_to_nickname = get_nickname_from_actor(in_reply_to)
reply_to_domain, reply_to_port = get_domain_from_actor(in_reply_to)
reply_to_domain_full = get_full_domain(reply_to_domain, reply_to_port)
mentions_str = '@' + reply_to_nickname + '@' + reply_to_domain_full
message_json = \ message_json = \
create_public_post(self.server.base_dir, create_direct_message_post(self.server.base_dir, nickname,
nickname,
self.server.domain, self.server.port, self.server.domain, self.server.port,
self.server.http_prefix, self.server.http_prefix,
answer, False, False, mentions_str + ' ' + answer,
False, False,
comments_enabled, comments_enabled,
attach_image_filename, media_type, attach_image_filename,
image_description, city, media_type, image_description, city,
in_reply_to, in_reply_to, in_reply_to_atom_uri,
in_reply_to_atom_uri, subject, self.server.debug,
subject,
schedule_post, schedule_post,
event_date, event_date, event_time,
event_time, event_end_time, event_end_time,
location, False, location, self.server.system_language,
self.server.system_language,
conversation_id, conversation_id,
self.server.low_bandwidth, self.server.low_bandwidth,
self.server.content_license_url, self.server.content_license_url,
languages_understood, languages_understood, False,
self.server.translate) self.server.translate)
if message_json: if message_json:
# NOTE: content and contentMap are not required, but we will keep
# them in there so that the post does not get filtered out by
# inbox processing.
# name field contains the answer # name field contains the answer
message_json['object']['name'] = answer message_json['object']['name'] = answer
if self._post_to_outbox(message_json, if self._post_to_outbox(message_json,
@ -1732,17 +1737,16 @@ class PubServer(BaseHTTPRequestHandler):
# protocol handler. See https://fedi-to.github.io/protocol-handler.html # protocol handler. See https://fedi-to.github.io/protocol-handler.html
if self.path.startswith('/.well-known/protocol-handler'): if self.path.startswith('/.well-known/protocol-handler'):
if calling_domain.endswith('.onion'): if calling_domain.endswith('.onion'):
protocol_url = \ protocol_url, _ = \
wellknown_protocol_handler(self.path, wellknown_protocol_handler(self.path, 'http',
self.server.base_dir, 'http',
self.server.onion_domain) self.server.onion_domain)
elif calling_domain.endswith('.i2p'): elif calling_domain.endswith('.i2p'):
protocol_url = \ protocol_url, _ = \
wellknown_protocol_handler(self.path, self.server.base_dir, wellknown_protocol_handler(self.path,
'http', self.server.i2p_domain) 'http', self.server.i2p_domain)
else: else:
protocol_url = \ protocol_url, _ = \
wellknown_protocol_handler(self.path, self.server.base_dir, wellknown_protocol_handler(self.path,
self.server.http_prefix, self.server.http_prefix,
self.server.domain_full) self.server.domain_full)
if protocol_url: if protocol_url:
@ -8082,6 +8086,16 @@ class PubServer(BaseHTTPRequestHandler):
"platform": "fdroid", "platform": "fdroid",
"url": app3 "url": app3
} }
],
"protocol_handlers": [
{
"protocol": "web+ap",
"url": "?target=%s"
},
{
"protocol": "web+epicyon",
"url": "?target=%s"
}
] ]
} }
msg_str = json.dumps(manifest, ensure_ascii=False) msg_str = json.dumps(manifest, ensure_ascii=False)

View File

@ -119,6 +119,7 @@ from posts import send_signed_json
from posts import send_to_followers_thread from posts import send_to_followers_thread
from webapp_post import individual_post_as_html from webapp_post import individual_post_as_html
from question import question_update_votes from question import question_update_votes
from question import is_vote
from media import replace_you_tube from media import replace_you_tube
from media import replace_twitter from media import replace_twitter
from git import is_git_patch from git import is_git_patch
@ -3820,6 +3821,20 @@ def _is_valid_dm(base_dir: str, nickname: str, domain: str, port: int,
# Not sending to yourself # Not sending to yourself
if not sending_to_self: if not sending_to_self:
# is this a vote on a question?
if is_vote(base_dir, nickname, domain,
post_json_object, debug):
# make the content the same as the vote answer
post_json_object['object']['content'] = \
post_json_object['object']['name']
# remove any other content
if post_json_object['object'].get("contentMap"):
del post_json_object['object']['contentMap']
# remove any summary / cw
post_json_object['object']['summary'] = None
if post_json_object['object'].get("summaryMap"):
del post_json_object['object']['summaryMap']
return True
# get the handle of the DM sender # get the handle of the DM sender
send_h = sending_actor_nickname + '@' + sending_actor_domain send_h = sending_actor_nickname + '@' + sending_actor_domain
# check the follow # check the follow
@ -3831,11 +3846,12 @@ def _is_valid_dm(base_dir: str, nickname: str, domain: str, port: int,
# send back a bounce DM # send back a bounce DM
if post_json_object.get('id') and \ if post_json_object.get('id') and \
post_json_object.get('object'): post_json_object.get('object'):
obj_has_dict = has_object_dict(post_json_object)
# don't send bounces back to # don't send bounces back to
# replies to bounce messages # replies to bounce messages
obj = post_json_object['object'] obj = post_json_object['object']
if isinstance(obj, dict): if obj_has_dict and \
if not obj.get('inReplyTo'): not obj.get('inReplyTo'):
bounced_id = \ bounced_id = \
remove_id_ending(post_json_object['id']) remove_id_ending(post_json_object['id'])
bounce_chat = False bounce_chat = False
@ -3892,7 +3908,8 @@ def _receive_question_vote(server, base_dir: str, nickname: str, domain: str,
""" """
# if this is a reply to a question then update the votes # if this is a reply to a question then update the votes
question_json, question_post_filename = \ question_json, question_post_filename = \
question_update_votes(base_dir, nickname, domain, post_json_object) question_update_votes(base_dir, nickname, domain,
post_json_object, debug)
if not question_json: if not question_json:
return return
if not question_post_filename: if not question_post_filename:
@ -4424,8 +4441,33 @@ def _inbox_after_initial(server, inbox_start_time,
post_json_object = message_json['post'] post_json_object = message_json['post']
else: else:
post_json_object = message_json post_json_object = message_json
nickname = handle.split('@')[0] nickname = handle.split('@')[0]
if is_vote(base_dir, nickname, domain, post_json_object, debug):
_receive_question_vote(server, base_dir, nickname, domain,
http_prefix, handle, debug,
post_json_object, recent_posts_cache,
session, session_onion, session_i2p,
onion_domain, i2p_domain, port,
federation_list, send_threads, post_log,
cached_webfingers, person_cache,
signing_priv_key_pem,
max_recent_posts, translate,
allow_deletion,
yt_replace_domain,
twitter_replacement_domain,
peertube_instances,
allow_local_network_access,
theme_name, system_language,
max_like_count,
cw_lists, lists_enabled,
bold_reading, dogwhistles,
server.min_images_for_accounts)
fitness_performance(inbox_start_time, server.fitness,
'INBOX', '_receive_question_vote',
debug)
inbox_start_time = time.time()
json_obj = None json_obj = None
domain_full = get_full_domain(domain, port) domain_full = get_full_domain(domain, port)
if _valid_post_content(base_dir, nickname, domain, if _valid_post_content(base_dir, nickname, domain,
@ -4495,30 +4537,6 @@ def _inbox_after_initial(server, inbox_start_time,
debug) debug)
inbox_start_time = time.time() inbox_start_time = time.time()
_receive_question_vote(server, base_dir, nickname, domain,
http_prefix, handle, debug,
post_json_object, recent_posts_cache,
session, session_onion, session_i2p,
onion_domain, i2p_domain, port,
federation_list, send_threads, post_log,
cached_webfingers, person_cache,
signing_priv_key_pem,
max_recent_posts, translate,
allow_deletion,
yt_replace_domain,
twitter_replacement_domain,
peertube_instances,
allow_local_network_access,
theme_name, system_language,
max_like_count,
cw_lists, lists_enabled,
bold_reading, dogwhistles,
server.min_images_for_accounts)
fitness_performance(inbox_start_time, server.fitness,
'INBOX', '_receive_question_vote',
debug)
inbox_start_time = time.time()
is_reply_to_muted_post = False is_reply_to_muted_post = False
if not is_group: if not is_group:

View File

@ -15,52 +15,105 @@ from utils import has_object_dict
from utils import text_in_file from utils import text_in_file
def question_update_votes(base_dir: str, nickname: str, domain: str, def is_vote(base_dir: str, nickname: str, domain: str,
reply_json: {}) -> ({}, str): post_json_object: {}, debug: bool) -> bool:
""" For a given reply update the votes on a question """ is the given post a vote on a Question?
Returns the question json object if the vote totals were changed
""" """
if not has_object_dict(reply_json): post_obj = post_json_object
return None, None if has_object_dict(post_json_object):
if not reply_json['object'].get('inReplyTo'): post_obj = post_json_object['object']
return None, None
if not reply_json['object']['inReplyTo']: if not post_obj.get('inReplyTo'):
return None, None return False
if not isinstance(reply_json['object']['inReplyTo'], str): if not isinstance(post_obj['inReplyTo'], str):
return None, None return False
if not reply_json['object'].get('name'): if not post_obj.get('name'):
return None, None return False
in_reply_to = reply_json['object']['inReplyTo']
if debug:
print('VOTE: ' + str(post_obj))
# is the replied to post a Question?
in_reply_to = post_obj['inReplyTo']
question_post_filename = \ question_post_filename = \
locate_post(base_dir, nickname, domain, in_reply_to) locate_post(base_dir, nickname, domain, in_reply_to)
if not question_post_filename: if not question_post_filename:
return None, None if debug:
print('VOTE REJECT: question does not exist ' + in_reply_to)
return False
question_json = load_json(question_post_filename) question_json = load_json(question_post_filename)
if not question_json: if not question_json:
return None, None if debug:
print('VOTE REJECT: invalid json ' + question_post_filename)
return False
if not has_object_dict(question_json): if not has_object_dict(question_json):
return None, None if debug:
print('VOTE REJECT: question without object ' +
question_post_filename)
return False
if not question_json['object'].get('type'): if not question_json['object'].get('type'):
return None, None if debug:
print('VOTE REJECT: question without type ' +
question_post_filename)
return False
if question_json['type'] != 'Question': if question_json['type'] != 'Question':
return None, None if debug:
print('VOTE REJECT: not a question ' +
question_post_filename)
return False
# does the question have options?
if not question_json['object'].get('oneOf'): if not question_json['object'].get('oneOf'):
return None, None if debug:
print('VOTE REJECT: question has no options ' +
question_post_filename)
return False
if not isinstance(question_json['object']['oneOf'], list): if not isinstance(question_json['object']['oneOf'], list):
return None, None if debug:
if not question_json['object'].get('content'): print('VOTE REJECT: question options is not a list ' +
return None, None question_post_filename)
reply_vote = reply_json['object']['name'] return False
# does the reply name field match any possible question option? # does the reply name field match any possible question option?
found_answer = None, None reply_vote = post_json_object['name']
found_answer_json = None
for possible_answer in question_json['object']['oneOf']: for possible_answer in question_json['object']['oneOf']:
if not possible_answer.get('name'): if not possible_answer.get('name'):
continue continue
if possible_answer['name'] == reply_vote: if possible_answer['name'] == reply_vote:
found_answer = possible_answer found_answer_json = possible_answer
break break
if not found_answer: if not found_answer_json:
if debug:
print('VOTE REJECT: question answer not found ' +
question_post_filename + ' ' + reply_vote)
return False
return True
def question_update_votes(base_dir: str, nickname: str, domain: str,
reply_json: {}, debug: bool) -> ({}, str):
""" For a given reply update the votes on a question
Returns the question json object if the vote totals were changed
"""
if not is_vote(base_dir, nickname, domain, reply_json, debug):
return None, None return None, None
post_obj = reply_json
if has_object_dict(reply_json):
post_obj = reply_json['object']
reply_vote = post_obj['name']
in_reply_to = post_obj['inReplyTo']
question_post_filename = \
locate_post(base_dir, nickname, domain, in_reply_to)
if not question_post_filename:
return None, None
question_json = load_json(question_post_filename)
if not question_json:
return None, None
# update the voters file # update the voters file
voters_file_separator = ';;;' voters_file_separator = ';;;'
voters_filename = question_post_filename.replace('.json', '.voters') voters_filename = question_post_filename.replace('.json', '.voters')
@ -71,7 +124,7 @@ def question_update_votes(base_dir: str, nickname: str, domain: str,
encoding='utf-8') as voters_file: encoding='utf-8') as voters_file:
voters_file.write(reply_json['actor'] + voters_file.write(reply_json['actor'] +
voters_file_separator + voters_file_separator +
found_answer + '\n') reply_vote + '\n')
except OSError: except OSError:
print('EX: unable to write voters file ' + voters_filename) print('EX: unable to write voters file ' + voters_filename)
else: else:
@ -82,7 +135,7 @@ def question_update_votes(base_dir: str, nickname: str, domain: str,
encoding='utf-8') as voters_file: encoding='utf-8') as voters_file:
voters_file.write(reply_json['actor'] + voters_file.write(reply_json['actor'] +
voters_file_separator + voters_file_separator +
found_answer + '\n') reply_vote + '\n')
except OSError: except OSError:
print('EX: unable to append to voters file ' + voters_filename) print('EX: unable to append to voters file ' + voters_filename)
else: else:
@ -96,7 +149,7 @@ def question_update_votes(base_dir: str, nickname: str, domain: str,
if vote_line.startswith(reply_json['actor'] + if vote_line.startswith(reply_json['actor'] +
voters_file_separator): voters_file_separator):
new_vote_line = reply_json['actor'] + \ new_vote_line = reply_json['actor'] + \
voters_file_separator + found_answer + '\n' voters_file_separator + reply_vote + '\n'
if vote_line == new_vote_line: if vote_line == new_vote_line:
break break
save_voters_file = True save_voters_file = True
@ -114,6 +167,7 @@ def question_update_votes(base_dir: str, nickname: str, domain: str,
voters_filename) voters_filename)
else: else:
return None, None return None, None
# update the vote counts # update the vote counts
question_totals_changed = False question_totals_changed = False
for possible_answer in question_json['object']['oneOf']: for possible_answer in question_json['object']['oneOf']:
@ -131,25 +185,26 @@ def question_update_votes(base_dir: str, nickname: str, domain: str,
question_totals_changed = True question_totals_changed = True
if not question_totals_changed: if not question_totals_changed:
return None, None return None, None
# save the question with altered totals # save the question with altered totals
save_json(question_json, question_post_filename) save_json(question_json, question_post_filename)
return question_json, question_post_filename return question_json, question_post_filename
def is_question(post_object_json: {}) -> bool: def is_question(post_json_object: {}) -> bool:
""" is the given post a question? """ is the given post a question?
""" """
if post_object_json['type'] != 'Create' and \ if post_json_object['type'] != 'Create' and \
post_object_json['type'] != 'Update': post_json_object['type'] != 'Update':
return False return False
if not has_object_dict(post_object_json): if not has_object_dict(post_json_object):
return False return False
if not post_object_json['object'].get('type'): if not post_json_object['object'].get('type'):
return False return False
if post_object_json['object']['type'] != 'Question': if post_json_object['object']['type'] != 'Question':
return False return False
if not post_object_json['object'].get('oneOf'): if not post_json_object['object'].get('oneOf'):
return False return False
if not isinstance(post_object_json['object']['oneOf'], list): if not isinstance(post_json_object['object']['oneOf'], list):
return False return False
return True return True

View File

@ -121,6 +121,7 @@ Let's see an example! Let's say Alyssa wants to catch up with her friend, Ben Bi
"rejectReplies": False, "rejectReplies": False,
"mediaType": "text/html", "mediaType": "text/html",
"attachment": [], "attachment": [],
"conversation": "3728447592750257207548",
"summary": "Book", "summary": "Book",
"content": "Say, did you finish reading that book I lent you?" "content": "Say, did you finish reading that book I lent you?"
} }
@ -149,6 +150,7 @@ Since this is a non-activity object, the server recognizes that this is an objec
"rejectReplies": False, "rejectReplies": False,
"mediaType": "text/html", "mediaType": "text/html",
"attachment": [], "attachment": [],
"conversation": "3728447592750257207548",
"summary": "Book", "summary": "Book",
"content": "Say, did you finish reading that book I lent you?" "content": "Say, did you finish reading that book I lent you?"
} }
@ -182,6 +184,7 @@ Cool! A while later, Alyssa checks what new messages she's gotten. Her phone pol
"published": "2039-10-15T12:45:45Z", "published": "2039-10-15T12:45:45Z",
"rejectReplies": False, "rejectReplies": False,
"mediaType": "text/html", "mediaType": "text/html",
"conversation": "3728447592750257207548",
"content": "<p>Argh, yeah, sorry, I'll get it back to you tomorrow.</p> "content": "<p>Argh, yeah, sorry, I'll get it back to you tomorrow.</p>
<p>I was reviewing the section on register machines, <p>I was reviewing the section on register machines,
since it's been a while since I wrote one.</p>" since it's been a while since I wrote one.</p>"
@ -189,6 +192,8 @@ Cool! A while later, Alyssa checks what new messages she's gotten. Her phone pol
} }
``` ```
Here the *conversation* field is any unique identifier grouping the posts within this thread together. Hence even if some posts within a chain of replies are subsequently deleted the overall thread can still be obtained.
Alyssa is relieved, and likes Ben's post: Alyssa is relieved, and likes Ben's post:
### Example 5 ### Example 5
@ -241,6 +246,7 @@ Feeling happy about things, she decides to post a public message to her follower
"https://www.w3.org/ns/activitystreams#Public"], "https://www.w3.org/ns/activitystreams#Public"],
"published": "2039-10-15T13:11:16Z", "published": "2039-10-15T13:11:16Z",
"rejectReplies": False, "rejectReplies": False,
"conversation": "57834623544792956335",
"mediaType": "text/html", "mediaType": "text/html",
"content": "Lending books to friends is nice. Getting them back is even nicer! :)" "content": "Lending books to friends is nice. Getting them back is even nicer! :)"
} }
@ -303,6 +309,7 @@ As an example, if example.com receives the activity
"attributedTo": "https://example.org/users/alice", "attributedTo": "https://example.org/users/alice",
"mediaType": "text/html", "mediaType": "text/html",
"published": "2031-03-27T14:10:25Z", "published": "2031-03-27T14:10:25Z",
"conversation": "7342325925675729",
"content": "I'm a goat" "content": "I'm a goat"
} }
} }
@ -347,6 +354,7 @@ The value of `source` is itself an object which uses its own `content` and `medi
{"@language": "en"}], {"@language": "en"}],
"type": "Note", "type": "Note",
"id": "http://postparty.example/users/username/statuses/2415", "id": "http://postparty.example/users/username/statuses/2415",
"conversation": "45327948756365",
"mediaType": "text/html" "mediaType": "text/html"
"content": "<p>I <em>really</em> like strawberries!</p>", "content": "<p>I <em>really</em> like strawberries!</p>",
"source": { "source": {
@ -386,6 +394,7 @@ In the case of attached images, the `name` field can be used to supply a descrip
"published": "2032-09-14T19:17:02Z", "published": "2032-09-14T19:17:02Z",
"summary": "", "summary": "",
"sensitive": False, "sensitive": False,
"conversation": "67243561372468724",
"mediaType": "text/html", "mediaType": "text/html",
"content": "This is a post with an attached image", "content": "This is a post with an attached image",
"attachment": [ "attachment": [
@ -422,6 +431,7 @@ When a new post is created, if it has `content` containing one or more hashtags
"to": ["https://example.net/users/fearghus/followers", "to": ["https://example.net/users/fearghus/followers",
"https://www.w3.org/ns/activitystreams#Public"], "https://www.w3.org/ns/activitystreams#Public"],
"published": "2032-05-29T15:08:47Z", "published": "2032-05-29T15:08:47Z",
"conversation": "5342890426429480",
"mediaType": "text/html", "mediaType": "text/html",
"content": "Posting with <a href=\"https://example.net/tags/ActivityPub\" class=\"mention hashtag\" rel=\"tag\">#<span>ActivityPub</span></a>", "content": "Posting with <a href=\"https://example.net/tags/ActivityPub\" class=\"mention hashtag\" rel=\"tag\">#<span>ActivityPub</span></a>",
"tag": [ "tag": [
@ -768,6 +778,7 @@ For example, when Chris likes the following article by Amy:
"type": "Article", "type": "Article",
"name": "Minimal ActivityPub update client", "name": "Minimal ActivityPub update client",
"content": "Today I finished morph, a client for posting ActivityStreams2...", "content": "Today I finished morph, a client for posting ActivityStreams2...",
"conversation": "1894367735757303",
"attributedTo": "https://rhiaro.co.uk/@amy", "attributedTo": "https://rhiaro.co.uk/@amy",
"to": ["https://rhiaro.co.uk/followers"], "to": ["https://rhiaro.co.uk/followers"],
"cc": ["https://e14n.com/@evan"] "cc": ["https://e14n.com/@evan"]
@ -837,6 +848,7 @@ The above example could be converted to this:
"id": "https://example.com/@mallory/statuses/72", "id": "https://example.com/@mallory/statuses/72",
"type": "Note", "type": "Note",
"attributedTo": "https://example.net/users/mallory", "attributedTo": "https://example.net/users/mallory",
"conversation": "784365462623755",
"content": "This is a note", "content": "This is a note",
"published": "2015-02-10T15:04:55Z", "published": "2015-02-10T15:04:55Z",
"to": ["https://example.org/@john"], "to": ["https://example.org/@john"],

View File

@ -252,12 +252,12 @@ def webfinger_meta(http_prefix: str, domain_full: str) -> str:
return meta_str return meta_str
def wellknown_protocol_handler(path: str, base_dir: str, def wellknown_protocol_handler(path: str, http_prefix: str,
http_prefix: str, domain_full: str) -> {}: domain_full: str) -> ({}, str):
"""See https://fedi-to.github.io/protocol-handler.html """See https://fedi-to.github.io/protocol-handler.html
""" """
if not path.startswith('/.well-known/protocol-handler?'): if not path.startswith('/.well-known/protocol-handler?'):
return None return None, None
if 'target=' in path: if 'target=' in path:
path = urllib.parse.unquote(path) path = urllib.parse.unquote(path)
@ -265,11 +265,11 @@ def wellknown_protocol_handler(path: str, base_dir: str,
if ';' in target: if ';' in target:
target = target.split(';')[0] target = target.split(';')[0]
if not target: if not target:
return None return None, None
if not target.startswith('web+epicyon:') and \ if not target.startswith('web+epicyon:') and \
not target.startswith('web+mastodon:') and \ not target.startswith('web+mastodon:') and \
not target.startswith('web+ap:'): not target.startswith('web+ap:'):
return None return None, None
handle = target.split(':', 1)[1].strip() handle = target.split(':', 1)[1].strip()
if handle.startswith('//'): if handle.startswith('//'):
handle = handle[2:] handle = handle[2:]
@ -277,14 +277,21 @@ def wellknown_protocol_handler(path: str, base_dir: str,
handle = handle[1:] handle = handle[1:]
if '@' in handle: if '@' in handle:
nickname = handle.split('@')[0] nickname = handle.split('@')[0]
domain = handle.split('@')[1] domain_and_path = handle.split('@')[1]
else: else:
nickname = handle nickname = handle
domain = domain_full domain_and_path = domain_full
# not an open redirect # not an open redirect
if domain == domain_full: if domain_and_path.startswith(domain_full):
return http_prefix + '://' + domain_full + '/users/' + nickname command = ''
return None if '/' in nickname:
command = nickname.split('/')[0]
nickname = nickname.split('/')[1]
domain_length = len(domain_full)
path_str = domain_and_path[domain_length:]
return http_prefix + '://' + domain_full + \
'/users/' + nickname + path_str, command
return None, None
def webfinger_lookup(path: str, base_dir: str, def webfinger_lookup(path: str, base_dir: str,