epicyon/posts.py

5311 lines
202 KiB
Python

__filename__ = "posts.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@libreserver.org"
__status__ = "Production"
__module_group__ = "ActivityPub"
import json
import html
import datetime
import os
import shutil
import sys
import time
import random
from socket import error as SocketError
from time import gmtime, strftime
from collections import OrderedDict
from threads import threadWithTrace
from cache import storePersonInCache
from cache import getPersonFromCache
from cache import expirePersonCache
from pprint import pprint
from session import createSession
from session import getJson
from session import postJson
from session import postJsonString
from session import postImage
from webfinger import webfingerHandle
from httpsig import createSignedHeader
from siteactive import siteIsActive
from languages import understoodPostLanguage
from utils import getUserPaths
from utils import invalidCiphertext
from utils import hasObjectStringType
from utils import removeIdEnding
from utils import replaceUsersWithAt
from utils import hasGroupType
from utils import getBaseContentFromPost
from utils import removeDomainPort
from utils import getPortFromDomain
from utils import hasObjectDict
from utils import rejectPostId
from utils import removeInvalidChars
from utils import fileLastModified
from utils import isPublicPost
from utils import hasUsersPath
from utils import validPostDate
from utils import getFullDomain
from utils import getFollowersList
from utils import isEvil
from utils import getStatusNumber
from utils import createPersonDir
from utils import urlPermitted
from utils import getNicknameFromActor
from utils import getDomainFromActor
from utils import deletePost
from utils import validNickname
from utils import locatePost
from utils import loadJson
from utils import saveJson
from utils import getConfigParam
from utils import locateNewsVotes
from utils import locateNewsArrival
from utils import votesOnNewswireItem
from utils import removeHtml
from utils import dangerousMarkup
from utils import acctDir
from utils import localActorUrl
from media import attachMedia
from media import replaceYouTube
from media import replaceTwitter
from content import wordsSimilarity
from content import limitRepeatedWords
from content import tagExists
from content import removeLongWords
from content import addHtmlTags
from content import replaceEmojiFromTags
from content import removeTextFormatting
from auth import createBasicAuthHeader
from blocking import isBlocked
from blocking import isBlockedDomain
from filters import isFiltered
from git import convertPostToPatch
from linked_data_sig import generateJsonSignature
from petnames import resolvePetnames
from video import convertVideoToNote
from context import getIndividualPostContext
def isModerator(base_dir: str, nickname: str) -> bool:
"""Returns true if the given nickname is a moderator
"""
moderatorsFile = base_dir + '/accounts/moderators.txt'
if not os.path.isfile(moderatorsFile):
adminName = getConfigParam(base_dir, 'admin')
if not adminName:
return False
if adminName == nickname:
return True
return False
with open(moderatorsFile, 'r') as f:
lines = f.readlines()
if len(lines) == 0:
adminName = getConfigParam(base_dir, 'admin')
if not adminName:
return False
if adminName == nickname:
return True
for moderator in lines:
moderator = moderator.strip('\n').strip('\r')
if moderator == nickname:
return True
return False
def noOfFollowersOnDomain(base_dir: str, handle: str,
domain: str, followFile='followers.txt') -> int:
"""Returns the number of followers of the given handle from the given domain
"""
filename = base_dir + '/accounts/' + handle + '/' + followFile
if not os.path.isfile(filename):
return 0
ctr = 0
with open(filename, 'r') as followersFilename:
for followerHandle in followersFilename:
if '@' in followerHandle:
followerDomain = followerHandle.split('@')[1]
followerDomain = followerDomain.replace('\n', '')
followerDomain = followerDomain.replace('\r', '')
if domain == followerDomain:
ctr += 1
return ctr
def _getLocalPrivateKey(base_dir: str, nickname: str, domain: str) -> str:
"""Returns the private key for a local account
"""
if not domain or not nickname:
return None
handle = nickname + '@' + domain
keyFilename = base_dir + '/keys/private/' + handle.lower() + '.key'
if not os.path.isfile(keyFilename):
return None
with open(keyFilename, 'r') as pemFile:
return pemFile.read()
return None
def getInstanceActorKey(base_dir: str, domain: str) -> str:
"""Returns the private key for the instance actor used for
signing GET posts
"""
return _getLocalPrivateKey(base_dir, 'inbox', domain)
def _getLocalPublicKey(base_dir: str, nickname: str, domain: str) -> str:
"""Returns the public key for a local account
"""
if not domain or not nickname:
return None
handle = nickname + '@' + domain
keyFilename = base_dir + '/keys/public/' + handle.lower() + '.key'
if not os.path.isfile(keyFilename):
return None
with open(keyFilename, 'r') as pemFile:
return pemFile.read()
return None
def _getPersonKey(nickname: str, domain: str, base_dir: str,
keyType: str = 'public', debug: bool = False):
"""Returns the public or private key of a person
"""
if keyType == 'private':
keyPem = _getLocalPrivateKey(base_dir, nickname, domain)
else:
keyPem = _getLocalPublicKey(base_dir, nickname, domain)
if not keyPem:
if debug:
print('DEBUG: ' + keyType + ' key file not found')
return ''
if len(keyPem) < 20:
if debug:
print('DEBUG: private key was too short: ' + keyPem)
return ''
return keyPem
def _cleanHtml(rawHtml: str) -> str:
# text=BeautifulSoup(rawHtml, 'html.parser').get_text()
text = rawHtml
return html.unescape(text)
def getUserUrl(wfRequest: {}, sourceId: int, debug: bool) -> str:
"""Gets the actor url from a webfinger request
"""
if not wfRequest.get('links'):
if sourceId == 72367:
print('getUserUrl ' + str(sourceId) +
' failed to get display name for webfinger ' +
str(wfRequest))
else:
print('getUserUrl webfinger activity+json contains no links ' +
str(sourceId) + ' ' + str(wfRequest))
return None
for link in wfRequest['links']:
if not (link.get('type') and link.get('href')):
continue
if link['type'] != 'application/activity+json':
continue
if '/@' not in link['href']:
if debug and not hasUsersPath(link['href']):
print('getUserUrl webfinger activity+json ' +
'contains single user instance actor ' +
str(sourceId) + ' ' + str(link))
else:
return link['href'].replace('/@', '/users/')
return link['href']
return None
def parseUserFeed(signing_priv_key_pem: str,
session, feedUrl: str, asHeader: {},
project_version: str, http_prefix: str,
originDomain: str, debug: bool, depth: int = 0) -> []:
if depth > 10:
if debug:
print('Maximum search depth reached')
return None
if debug:
print('Getting user feed for ' + feedUrl)
print('User feed header ' + str(asHeader))
print('http_prefix ' + str(http_prefix))
print('originDomain ' + str(originDomain))
feedJson = getJson(signing_priv_key_pem, session, feedUrl, asHeader, None,
debug, project_version, http_prefix, originDomain)
if not feedJson:
profileStr = 'https://www.w3.org/ns/activitystreams'
acceptStr = 'application/ld+json; profile="' + profileStr + '"'
if asHeader['Accept'] != acceptStr:
asHeader = {
'Accept': acceptStr
}
feedJson = getJson(signing_priv_key_pem, session, feedUrl,
asHeader, None, debug, project_version,
http_prefix, originDomain)
if not feedJson:
if debug:
print('No user feed was returned')
return None
if debug:
print('User feed:')
pprint(feedJson)
if 'orderedItems' in feedJson:
return feedJson['orderedItems']
elif 'items' in feedJson:
return feedJson['items']
nextUrl = None
if 'first' in feedJson:
nextUrl = feedJson['first']
elif 'next' in feedJson:
nextUrl = feedJson['next']
if debug:
print('User feed next url: ' + str(nextUrl))
if nextUrl:
if isinstance(nextUrl, str):
if '?max_id=0' not in nextUrl:
userFeed = \
parseUserFeed(signing_priv_key_pem,
session, nextUrl, asHeader,
project_version, http_prefix,
originDomain, debug, depth + 1)
if userFeed:
return userFeed
elif isinstance(nextUrl, dict):
userFeed = nextUrl
if userFeed.get('orderedItems'):
return userFeed['orderedItems']
elif userFeed.get('items'):
return userFeed['items']
return None
def _getPersonBoxActor(session, base_dir: str, actor: str,
profileStr: str, asHeader: {},
debug: bool, project_version: str,
http_prefix: str, originDomain: str,
person_cache: {},
signing_priv_key_pem: str,
sourceId: int) -> {}:
"""Returns the actor json for the given actor url
"""
personJson = \
getPersonFromCache(base_dir, actor, person_cache, True)
if personJson:
return personJson
if '/channel/' in actor or '/accounts/' in actor:
asHeader = {
'Accept': 'application/ld+json; profile="' + profileStr + '"'
}
personJson = getJson(signing_priv_key_pem, session, actor, asHeader, None,
debug, project_version, http_prefix, originDomain)
if personJson:
return personJson
asHeader = {
'Accept': 'application/ld+json; profile="' + profileStr + '"'
}
personJson = getJson(signing_priv_key_pem, session, actor, asHeader, None,
debug, project_version, http_prefix, originDomain)
if personJson:
return personJson
print('Unable to get actor for ' + actor + ' ' + str(sourceId))
if not signing_priv_key_pem:
print('No signing key provided when getting actor')
return None
def getPersonBox(signing_priv_key_pem: str, originDomain: str,
base_dir: str, session, wfRequest: {}, person_cache: {},
project_version: str, http_prefix: str,
nickname: str, domain: str,
boxName: str = 'inbox',
sourceId=0) -> (str, str, str, str, str, str, str, bool):
debug = False
profileStr = 'https://www.w3.org/ns/activitystreams'
asHeader = {
'Accept': 'application/activity+json; profile="' + profileStr + '"'
}
if not wfRequest:
print('No webfinger given')
return None, None, None, None, None, None, None, None
# get the actor / personUrl
if not wfRequest.get('errors'):
# get the actor url from webfinger links
personUrl = getUserUrl(wfRequest, sourceId, debug)
else:
if nickname == 'dev':
# try single user instance
print('getPersonBox: Trying single user instance with ld+json')
personUrl = http_prefix + '://' + domain
asHeader = {
'Accept': 'application/ld+json; profile="' + profileStr + '"'
}
else:
# the final fallback is a mastodon style url
personUrl = localActorUrl(http_prefix, nickname, domain)
if not personUrl:
return None, None, None, None, None, None, None, None
# get the actor json from the url
personJson = \
_getPersonBoxActor(session, base_dir, personUrl,
profileStr, asHeader,
debug, project_version,
http_prefix, originDomain,
person_cache, signing_priv_key_pem,
sourceId)
if not personJson:
return None, None, None, None, None, None, None, None
isGroup = False
if personJson.get('type'):
if personJson['type'] == 'Group':
isGroup = True
# get the url for the box/collection
boxJson = None
if not personJson.get(boxName):
if personJson.get('endpoints'):
if personJson['endpoints'].get(boxName):
boxJson = personJson['endpoints'][boxName]
else:
boxJson = personJson[boxName]
if not boxJson:
return None, None, None, None, None, None, None, None
personId = None
if personJson.get('id'):
personId = personJson['id']
pubKeyId = None
pubKey = None
if personJson.get('publicKey'):
if personJson['publicKey'].get('id'):
pubKeyId = personJson['publicKey']['id']
if personJson['publicKey'].get('publicKeyPem'):
pubKey = personJson['publicKey']['publicKeyPem']
sharedInbox = None
if personJson.get('sharedInbox'):
sharedInbox = personJson['sharedInbox']
else:
if personJson.get('endpoints'):
if personJson['endpoints'].get('sharedInbox'):
sharedInbox = personJson['endpoints']['sharedInbox']
avatarUrl = None
if personJson.get('icon'):
if personJson['icon'].get('url'):
avatarUrl = personJson['icon']['url']
displayName = None
if personJson.get('name'):
displayName = personJson['name']
if dangerousMarkup(personJson['name'], False):
displayName = '*ADVERSARY*'
elif isFiltered(base_dir,
nickname, domain,
displayName):
displayName = '*FILTERED*'
# have they moved?
if personJson.get('movedTo'):
displayName += ''
storePersonInCache(base_dir, personUrl, personJson, person_cache, True)
return boxJson, pubKeyId, pubKey, personId, sharedInbox, \
avatarUrl, displayName, isGroup
def _isPublicFeedPost(item: {}, personPosts: {}, debug: bool) -> bool:
"""Is the given post a public feed post?
"""
if not isinstance(item, dict):
if debug:
print('item object is not a dict')
pprint(item)
return False
if not item.get('id'):
if debug:
print('No id')
return False
if not item.get('type'):
if debug:
print('No type')
return False
if item['type'] != 'Create' and \
item['type'] != 'Announce' and \
item['type'] != 'Page' and \
item['type'] != 'Note':
if debug:
print('Not a Create/Note/Announce type')
return False
if item.get('object'):
if isinstance(item['object'], dict):
if not item['object'].get('published'):
if debug:
print('No published attribute')
return False
elif isinstance(item['object'], str):
if not item.get('published'):
if debug:
print('No published attribute')
return False
else:
if debug:
print('object is not a dict or string')
return False
elif item['type'] == 'Note' or item['type'] == 'Page':
if not item.get('published'):
if debug:
print('No published attribute')
return False
if not personPosts.get(item['id']):
thisItem = item
if item.get('object'):
thisItem = item['object']
# check that this is a public post
# #Public should appear in the "to" list
itemIsNote = False
if item['type'] == 'Note' or item['type'] == 'Page':
itemIsNote = True
if isinstance(thisItem, dict):
if thisItem.get('to'):
isPublic = False
for recipient in thisItem['to']:
if recipient.endswith('#Public'):
isPublic = True
break
if not isPublic:
return False
elif isinstance(thisItem, str) or itemIsNote:
if item.get('to'):
isPublic = False
for recipient in item['to']:
if recipient.endswith('#Public'):
isPublic = True
break
if not isPublic:
return False
return True
def isCreateInsideAnnounce(item: {}) -> bool:
""" is this a Create inside of an Announce?
eg. lemmy feed item
"""
if not isinstance(item, dict):
return False
if item['type'] != 'Announce':
return False
if not item.get('object'):
return False
if not isinstance(item['object'], dict):
return False
if not item['object'].get('type'):
return False
if item['object']['type'] != 'Create':
return False
return True
def _getPosts(session, outboxUrl: str, maxPosts: int,
max_mentions: int,
max_emoji: int, maxAttachments: int,
federationList: [],
person_cache: {}, raw: bool,
simple: bool, debug: bool,
project_version: str, http_prefix: str,
originDomain: str, system_language: str,
signing_priv_key_pem: str) -> {}:
"""Gets public posts from an outbox
"""
if debug:
print('Getting outbox posts for ' + outboxUrl)
personPosts = {}
if not outboxUrl:
return personPosts
profileStr = 'https://www.w3.org/ns/activitystreams'
acceptStr = \
'application/activity+json; ' + \
'profile="' + profileStr + '"'
asHeader = {
'Accept': acceptStr
}
if '/outbox/' in outboxUrl:
acceptStr = \
'application/ld+json; ' + \
'profile="' + profileStr + '"'
asHeader = {
'Accept': acceptStr
}
if raw:
if debug:
print('Returning the raw feed')
result = []
i = 0
userFeed = parseUserFeed(signing_priv_key_pem,
session, outboxUrl, asHeader,
project_version, http_prefix,
originDomain, debug)
for item in userFeed:
result.append(item)
i += 1
if i == maxPosts:
break
pprint(result)
return None
if debug:
print('Returning a human readable version of the feed')
userFeed = parseUserFeed(signing_priv_key_pem,
session, outboxUrl, asHeader,
project_version, http_prefix,
originDomain, debug)
if not userFeed:
return personPosts
i = 0
for item in userFeed:
if isCreateInsideAnnounce(item):
item = item['object']
if not _isPublicFeedPost(item, personPosts, debug):
continue
thisItem = item
if item['type'] != 'Note' and item['type'] != 'Page':
thisItem = item['object']
content = getBaseContentFromPost(item, system_language)
content = content.replace('&apos;', "'")
mentions = []
emoji = {}
summary = ''
inReplyTo = ''
attachment = []
sensitive = False
if isinstance(thisItem, dict):
if thisItem.get('tag'):
for tagItem in thisItem['tag']:
if not tagItem.get('type'):
continue
tagType = tagItem['type'].lower()
if tagType == 'emoji':
if tagItem.get('name') and tagItem.get('icon'):
if tagItem['icon'].get('url'):
# No emoji from non-permitted domains
if urlPermitted(tagItem['icon']['url'],
federationList):
emojiName = tagItem['name']
emojiIcon = tagItem['icon']['url']
emoji[emojiName] = emojiIcon
else:
if debug:
print('url not permitted ' +
tagItem['icon']['url'])
if tagType == 'mention':
if tagItem.get('name'):
if tagItem['name'] not in mentions:
mentions.append(tagItem['name'])
if len(mentions) > max_mentions:
if debug:
print('max mentions reached')
continue
if len(emoji) > max_emoji:
if debug:
print('max emojis reached')
continue
if thisItem.get('summary'):
if thisItem['summary']:
summary = thisItem['summary']
if thisItem.get('inReplyTo'):
if thisItem['inReplyTo']:
if isinstance(thisItem['inReplyTo'], str):
# No replies to non-permitted domains
if not urlPermitted(thisItem['inReplyTo'],
federationList):
if debug:
print('url not permitted ' +
thisItem['inReplyTo'])
continue
inReplyTo = thisItem['inReplyTo']
if thisItem.get('attachment'):
if thisItem['attachment']:
for attach in thisItem['attachment']:
if attach.get('name') and attach.get('url'):
# no attachments from non-permitted domains
if urlPermitted(attach['url'],
federationList):
attachment.append([attach['name'],
attach['url']])
else:
if debug:
print('url not permitted ' +
attach['url'])
sensitive = False
if thisItem.get('sensitive'):
sensitive = thisItem['sensitive']
if content:
if simple:
print(_cleanHtml(content) + '\n')
else:
pprint(item)
personPosts[item['id']] = {
"sensitive": sensitive,
"inreplyto": inReplyTo,
"summary": summary,
"html": content,
"plaintext": _cleanHtml(content),
"attachment": attachment,
"mentions": mentions,
"emoji": emoji
}
i += 1
if i == maxPosts:
break
return personPosts
def _getCommonWords() -> str:
"""Returns a list of common words
"""
return (
'that', 'some', 'about', 'then', 'they', 'were',
'also', 'from', 'with', 'this', 'have', 'more',
'need', 'here', 'would', 'these', 'into', 'very',
'well', 'when', 'what', 'your', 'there', 'which',
'even', 'there', 'such', 'just', 'those', 'only',
'will', 'much', 'than', 'them', 'each', 'goes',
'been', 'over', 'their', 'where', 'could', 'though',
'like', 'think', 'same', 'maybe', 'really', 'thing',
'something', 'possible', 'actual', 'actually',
'because', 'around', 'having', 'especially', 'other',
'making', 'made', 'make', 'makes', 'including',
'includes', 'know', 'knowing', 'knows', 'things',
'say', 'says', 'saying', 'many', 'somewhat',
'problem', 'problems', 'idea', 'ideas',
'using', 'uses', 'https', 'still', 'want', 'wants'
)
def _updateWordFrequency(content: str, wordFrequency: {}) -> None:
"""Creates a dictionary containing words and the number of times
that they appear
"""
plainText = removeHtml(content)
removeChars = ('.', ';', '?', '\n', ':')
for ch in removeChars:
plainText = plainText.replace(ch, ' ')
wordsList = plainText.split(' ')
commonWords = _getCommonWords()
for word in wordsList:
wordLen = len(word)
if wordLen < 3:
continue
if wordLen < 4:
if word.upper() != word:
continue
if '&' in word or \
'"' in word or \
'@' in word or \
"'" in word or \
"--" in word or \
'//' in word:
continue
if word.lower() in commonWords:
continue
if wordFrequency.get(word):
wordFrequency[word] += 1
else:
wordFrequency[word] = 1
def getPostDomains(session, outboxUrl: str, maxPosts: int,
max_mentions: int,
max_emoji: int, maxAttachments: int,
federationList: [],
person_cache: {},
debug: bool,
project_version: str, http_prefix: str,
domain: str,
wordFrequency: {},
domainList: [], system_language: str,
signing_priv_key_pem: str) -> []:
"""Returns a list of domains referenced within public posts
"""
if not outboxUrl:
return []
profileStr = 'https://www.w3.org/ns/activitystreams'
acceptStr = \
'application/activity+json; ' + \
'profile="' + profileStr + '"'
asHeader = {
'Accept': acceptStr
}
if '/outbox/' in outboxUrl:
acceptStr = \
'application/ld+json; ' + \
'profile="' + profileStr + '"'
asHeader = {
'Accept': acceptStr
}
postDomains = domainList
i = 0
userFeed = parseUserFeed(signing_priv_key_pem,
session, outboxUrl, asHeader,
project_version, http_prefix, domain, debug)
for item in userFeed:
i += 1
if i > maxPosts:
break
if not hasObjectDict(item):
continue
contentStr = getBaseContentFromPost(item, system_language)
if contentStr:
_updateWordFrequency(contentStr, wordFrequency)
if item['object'].get('inReplyTo'):
if isinstance(item['object']['inReplyTo'], str):
postDomain, postPort = \
getDomainFromActor(item['object']['inReplyTo'])
if postDomain not in postDomains:
postDomains.append(postDomain)
if item['object'].get('tag'):
for tagItem in item['object']['tag']:
if not tagItem.get('type'):
continue
tagType = tagItem['type'].lower()
if tagType == 'mention':
if tagItem.get('href'):
postDomain, postPort = \
getDomainFromActor(tagItem['href'])
if postDomain not in postDomains:
postDomains.append(postDomain)
return postDomains
def _getPostsForBlockedDomains(base_dir: str,
session, outboxUrl: str, maxPosts: int,
max_mentions: int,
max_emoji: int, maxAttachments: int,
federationList: [],
person_cache: {},
debug: bool,
project_version: str, http_prefix: str,
domain: str,
signing_priv_key_pem: str) -> {}:
"""Returns a dictionary of posts for blocked domains
"""
if not outboxUrl:
return {}
profileStr = 'https://www.w3.org/ns/activitystreams'
acceptStr = \
'application/activity+json; ' + \
'profile="' + profileStr + '"'
asHeader = {
'Accept': acceptStr
}
if '/outbox/' in outboxUrl:
acceptStr = \
'application/ld+json; ' + \
'profile="' + profileStr + '"'
asHeader = {
'Accept': acceptStr
}
blockedPosts = {}
i = 0
userFeed = parseUserFeed(signing_priv_key_pem,
session, outboxUrl, asHeader,
project_version, http_prefix, domain, debug)
for item in userFeed:
i += 1
if i > maxPosts:
break
if not hasObjectDict(item):
continue
if item['object'].get('inReplyTo'):
if isinstance(item['object']['inReplyTo'], str):
postDomain, postPort = \
getDomainFromActor(item['object']['inReplyTo'])
if isBlockedDomain(base_dir, postDomain):
if item['object'].get('url'):
url = item['object']['url']
else:
url = item['object']['id']
if not blockedPosts.get(postDomain):
blockedPosts[postDomain] = [url]
else:
if url not in blockedPosts[postDomain]:
blockedPosts[postDomain].append(url)
if item['object'].get('tag'):
for tagItem in item['object']['tag']:
if not tagItem.get('type'):
continue
tagType = tagItem['type'].lower()
if tagType == 'mention' and tagItem.get('href'):
postDomain, postPort = \
getDomainFromActor(tagItem['href'])
if isBlockedDomain(base_dir, postDomain):
if item['object'].get('url'):
url = item['object']['url']
else:
url = item['object']['id']
if not blockedPosts.get(postDomain):
blockedPosts[postDomain] = [url]
else:
if url not in blockedPosts[postDomain]:
blockedPosts[postDomain].append(url)
return blockedPosts
def deleteAllPosts(base_dir: str,
nickname: str, domain: str, boxname: str) -> None:
"""Deletes all posts for a person from inbox or outbox
"""
if boxname != 'inbox' and boxname != 'outbox' and \
boxname != 'tlblogs' and boxname != 'tlnews':
return
boxDir = createPersonDir(nickname, domain, base_dir, boxname)
for deleteFilename in os.scandir(boxDir):
deleteFilename = deleteFilename.name
filePath = os.path.join(boxDir, deleteFilename)
try:
if os.path.isfile(filePath):
os.unlink(filePath)
elif os.path.isdir(filePath):
shutil.rmtree(filePath, ignore_errors=False, onerror=None)
except Exception as ex:
print('ERROR: deleteAllPosts ' + str(ex))
def savePostToBox(base_dir: str, http_prefix: str, postId: str,
nickname: str, domain: str, post_json_object: {},
boxname: str) -> str:
"""Saves the give json to the give box
Returns the filename
"""
if boxname != 'inbox' and boxname != 'outbox' and \
boxname != 'tlblogs' and boxname != 'tlnews' and \
boxname != 'scheduled':
return None
originalDomain = domain
domain = removeDomainPort(domain)
if not postId:
statusNumber, published = getStatusNumber()
postId = \
localActorUrl(http_prefix, nickname, originalDomain) + \
'/statuses/' + statusNumber
post_json_object['id'] = postId + '/activity'
if hasObjectDict(post_json_object):
post_json_object['object']['id'] = postId
post_json_object['object']['atomUri'] = postId
boxDir = createPersonDir(nickname, domain, base_dir, boxname)
filename = boxDir + '/' + postId.replace('/', '#') + '.json'
saveJson(post_json_object, filename)
return filename
def _updateHashtagsIndex(base_dir: str, tag: {}, newPostId: str) -> None:
"""Writes the post url for hashtags to a file
This allows posts for a hashtag to be quickly looked up
"""
if tag['type'] != 'Hashtag':
return
# create hashtags directory
tagsDir = base_dir + '/tags'
if not os.path.isdir(tagsDir):
os.mkdir(tagsDir)
tagName = tag['name']
tagsFilename = tagsDir + '/' + tagName[1:] + '.txt'
tagline = newPostId + '\n'
if not os.path.isfile(tagsFilename):
# create a new tags index file
with open(tagsFilename, 'w+') as tagsFile:
tagsFile.write(tagline)
else:
# prepend to tags index file
if tagline not in open(tagsFilename).read():
try:
with open(tagsFilename, 'r+') as tagsFile:
content = tagsFile.read()
if tagline not in content:
tagsFile.seek(0, 0)
tagsFile.write(tagline + content)
except Exception as ex:
print('WARN: Failed to write entry to tags file ' +
tagsFilename + ' ' + str(ex))
def _addSchedulePost(base_dir: str, nickname: str, domain: str,
eventDateStr: str, postId: str) -> None:
"""Adds a scheduled post to the index
"""
handle = nickname + '@' + domain
scheduleIndexFilename = \
base_dir + '/accounts/' + handle + '/schedule.index'
indexStr = eventDateStr + ' ' + postId.replace('/', '#')
if os.path.isfile(scheduleIndexFilename):
if indexStr not in open(scheduleIndexFilename).read():
try:
with open(scheduleIndexFilename, 'r+') as scheduleFile:
content = scheduleFile.read()
if indexStr + '\n' not in content:
scheduleFile.seek(0, 0)
scheduleFile.write(indexStr + '\n' + content)
print('DEBUG: scheduled post added to index')
except Exception as ex:
print('WARN: Failed to write entry to scheduled posts index ' +
scheduleIndexFilename + ' ' + str(ex))
else:
with open(scheduleIndexFilename, 'w+') as scheduleFile:
scheduleFile.write(indexStr + '\n')
def validContentWarning(cw: str) -> str:
"""Returns a validated content warning
"""
cw = removeHtml(cw)
# hashtags within content warnings apparently cause a lot of trouble
# so remove them
if '#' in cw:
cw = cw.replace('#', '').replace(' ', ' ')
return removeInvalidChars(cw)
def _loadAutoCW(base_dir: str, nickname: str, domain: str) -> []:
"""Loads automatic CWs file and returns a list containing
the lines of the file
"""
filename = acctDir(base_dir, nickname, domain) + '/autocw.txt'
if not os.path.isfile(filename):
return []
with open(filename, 'r') as f:
return f.readlines()
return []
def _addAutoCW(base_dir: str, nickname: str, domain: str,
subject: str, content: str) -> str:
"""Appends any automatic CW to the subject line
and returns the new subject line
"""
newSubject = subject
autoCWList = _loadAutoCW(base_dir, nickname, domain)
for cwRule in autoCWList:
if '->' not in cwRule:
continue
rulematch = cwRule.split('->')[0].strip()
if rulematch not in content:
continue
cwStr = cwRule.split('->')[1].strip()
if newSubject:
if cwStr not in newSubject:
newSubject += ', ' + cwStr
else:
newSubject = cwStr
return newSubject
def _createPostCWFromReply(base_dir: str, nickname: str, domain: str,
inReplyTo: str,
sensitive: bool, summary: str) -> (bool, str):
"""If this is a reply and the original post has a CW
then use the same CW
"""
if inReplyTo and not sensitive:
# locate the post which this is a reply to and check if
# it has a content warning. If it does then reproduce
# the same warning
replyPostFilename = \
locatePost(base_dir, nickname, domain, inReplyTo)
if replyPostFilename:
replyToJson = loadJson(replyPostFilename)
if replyToJson:
if replyToJson.get('object'):
if replyToJson['object'].get('sensitive'):
if replyToJson['object']['sensitive']:
sensitive = True
if replyToJson['object'].get('summary'):
summary = replyToJson['object']['summary']
return sensitive, summary
def _createPostS2S(base_dir: str, nickname: str, domain: str, port: int,
http_prefix: str, content: str, statusNumber: str,
published: str, newPostId: str, postContext: {},
toRecipients: [], toCC: [], inReplyTo: str,
sensitive: bool, commentsEnabled: bool,
tags: [], attachImageFilename: str,
mediaType: str, imageDescription: str, city: str,
postObjectType: str, summary: str,
inReplyToAtomUri: str, system_language: str,
conversationId: str, low_bandwidth: bool,
content_license_url: str) -> {}:
"""Creates a new server-to-server post
"""
actorUrl = localActorUrl(http_prefix, nickname, domain)
idStr = \
localActorUrl(http_prefix, nickname, domain) + \
'/statuses/' + statusNumber + '/replies'
newPostUrl = \
http_prefix + '://' + domain + '/@' + nickname + '/' + statusNumber
newPostAttributedTo = \
localActorUrl(http_prefix, nickname, domain)
if not conversationId:
conversationId = newPostId
newPost = {
'@context': postContext,
'id': newPostId + '/activity',
'type': 'Create',
'actor': actorUrl,
'published': published,
'to': toRecipients,
'cc': toCC,
'object': {
'id': newPostId,
'conversation': conversationId,
'type': postObjectType,
'summary': summary,
'inReplyTo': inReplyTo,
'published': published,
'url': newPostUrl,
'attributedTo': newPostAttributedTo,
'to': toRecipients,
'cc': toCC,
'sensitive': sensitive,
'atomUri': newPostId,
'inReplyToAtomUri': inReplyToAtomUri,
'commentsEnabled': commentsEnabled,
'rejectReplies': not commentsEnabled,
'mediaType': 'text/html',
'content': content,
'contentMap': {
system_language: content
},
'attachment': [],
'tag': tags,
'replies': {
'id': idStr,
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': idStr + '?only_other_accounts=true&page=true',
'partOf': idStr,
'items': []
}
}
}
}
if attachImageFilename:
newPost['object'] = \
attachMedia(base_dir, http_prefix, nickname, domain, port,
newPost['object'], attachImageFilename,
mediaType, imageDescription, city, low_bandwidth,
content_license_url)
return newPost
def _createPostC2S(base_dir: str, nickname: str, domain: str, port: int,
http_prefix: str, content: str, statusNumber: str,
published: str, newPostId: str, postContext: {},
toRecipients: [], toCC: [], inReplyTo: str,
sensitive: bool, commentsEnabled: bool,
tags: [], attachImageFilename: str,
mediaType: str, imageDescription: str, city: str,
postObjectType: str, summary: str,
inReplyToAtomUri: str, system_language: str,
conversationId: str, low_bandwidth: str,
content_license_url: str) -> {}:
"""Creates a new client-to-server post
"""
domainFull = getFullDomain(domain, port)
idStr = \
localActorUrl(http_prefix, nickname, domainFull) + \
'/statuses/' + statusNumber + '/replies'
newPostUrl = \
http_prefix + '://' + domain + '/@' + nickname + '/' + statusNumber
if not conversationId:
conversationId = newPostId
newPost = {
"@context": postContext,
'id': newPostId,
'conversation': conversationId,
'type': postObjectType,
'summary': summary,
'inReplyTo': inReplyTo,
'published': published,
'url': newPostUrl,
'attributedTo': localActorUrl(http_prefix, nickname, domainFull),
'to': toRecipients,
'cc': toCC,
'sensitive': sensitive,
'atomUri': newPostId,
'inReplyToAtomUri': inReplyToAtomUri,
'commentsEnabled': commentsEnabled,
'rejectReplies': not commentsEnabled,
'mediaType': 'text/html',
'content': content,
'contentMap': {
system_language: content
},
'attachment': [],
'tag': tags,
'replies': {
'id': idStr,
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': idStr + '?only_other_accounts=true&page=true',
'partOf': idStr,
'items': []
}
}
}
if attachImageFilename:
newPost = \
attachMedia(base_dir, http_prefix, nickname, domain, port,
newPost, attachImageFilename,
mediaType, imageDescription, city, low_bandwidth,
content_license_url)
return newPost
def _createPostPlaceAndTime(eventDate: str, endDate: str,
eventTime: str, endTime: str,
summary: str, content: str,
schedulePost: bool,
eventUUID: str,
location: str,
tags: []) -> str:
"""Adds a place and time to the tags on a new post
"""
endDateStr = None
if endDate:
eventName = summary
if not eventName:
eventName = content
endDateStr = endDate
if endTime:
if endTime.endswith('Z'):
endDateStr = endDate + 'T' + endTime
else:
endDateStr = endDate + 'T' + endTime + \
':00' + strftime("%z", gmtime())
else:
endDateStr = endDate + 'T12:00:00Z'
# get the starting date and time
eventDateStr = None
if eventDate:
eventName = summary
if not eventName:
eventName = content
eventDateStr = eventDate
if eventTime:
if eventTime.endswith('Z'):
eventDateStr = eventDate + 'T' + eventTime
else:
eventDateStr = eventDate + 'T' + eventTime + \
':00' + strftime("%z", gmtime())
else:
eventDateStr = eventDate + 'T12:00:00Z'
if not endDateStr:
endDateStr = eventDateStr
if not schedulePost and not eventUUID:
tags.append({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Event",
"name": eventName,
"startTime": eventDateStr,
"endTime": endDateStr
})
if location and not eventUUID:
tags.append({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Place",
"name": location
})
return eventDateStr
def _consolidateActorsList(actorsList: []) -> None:
""" consolidate duplicated actors
https://domain/@nick gets merged with https://domain/users/nick
"""
possibleDuplicateActors = []
for ccActor in actorsList:
if '/@' in ccActor:
if ccActor not in possibleDuplicateActors:
possibleDuplicateActors.append(ccActor)
if possibleDuplicateActors:
uPaths = getUserPaths()
removeActors = []
for ccActor in possibleDuplicateActors:
for usrPath in uPaths:
ccActorFull = ccActor.replace('/@', usrPath)
if ccActorFull in actorsList:
if ccActor not in removeActors:
removeActors.append(ccActor)
break
for ccActor in removeActors:
actorsList.remove(ccActor)
def _createPostMentions(ccUrl: str, newPost: {},
toRecipients: [], tags: []) -> None:
"""Updates mentions for a new post
"""
if not ccUrl:
return
if len(ccUrl) == 0:
return
if newPost.get('object'):
if ccUrl not in newPost['object']['cc']:
newPost['object']['cc'] = [ccUrl] + newPost['object']['cc']
# if this is a public post then include any mentions in cc
toCC = newPost['object']['cc']
if len(toRecipients) != 1:
return
if toRecipients[0].endswith('#Public') and \
ccUrl.endswith('/followers'):
for tag in tags:
if tag['type'] != 'Mention':
continue
if tag['href'] not in toCC:
newPost['object']['cc'].append(tag['href'])
_consolidateActorsList(newPost['object']['cc'])
newPost['cc'] = newPost['object']['cc']
else:
if ccUrl not in newPost['cc']:
newPost['cc'] = [ccUrl] + newPost['cc']
_consolidateActorsList(['cc'])
def _createPostModReport(base_dir: str,
isModerationReport: bool, newPost: {},
newPostId: str) -> None:
""" if this is a moderation report then add a status
"""
if not isModerationReport:
return
# add status
if newPost.get('object'):
newPost['object']['moderationStatus'] = 'pending'
else:
newPost['moderationStatus'] = 'pending'
# save to index file
moderationIndexFile = base_dir + '/accounts/moderation.txt'
with open(moderationIndexFile, 'a+') as modFile:
modFile.write(newPostId + '\n')
def getActorFromInReplyTo(inReplyTo: str) -> str:
"""Tries to get the replied to actor from the inReplyTo post id
Note: this will not always be successful for some instance types
"""
replyNickname = getNicknameFromActor(inReplyTo)
if not replyNickname:
return None
replyActor = None
if '/' + replyNickname + '/' in inReplyTo:
replyActor = \
inReplyTo.split('/' + replyNickname + '/')[0] + \
'/' + replyNickname
elif '#' + replyNickname + '#' in inReplyTo:
replyActor = \
inReplyTo.split('#' + replyNickname + '#')[0] + \
'#' + replyNickname
replyActor = replyActor.replace('#', '/')
if not replyActor:
return None
if '://' not in replyActor:
return None
return replyActor
def _createPostBase(base_dir: str,
nickname: str, domain: str, port: int,
toUrl: str, ccUrl: str, http_prefix: str, content: str,
followersOnly: bool, saveToFile: bool,
client_to_server: bool, commentsEnabled: bool,
attachImageFilename: str,
mediaType: str, imageDescription: str, city: str,
isModerationReport: bool,
isArticle: bool,
inReplyTo: str,
inReplyToAtomUri: str,
subject: str, schedulePost: bool,
eventDate: str, eventTime: str,
location: str,
eventUUID: str, category: str,
joinMode: str,
endDate: str, endTime: str,
maximumAttendeeCapacity: int,
repliesModerationOption: str,
anonymousParticipationEnabled: bool,
eventStatus: str, ticketUrl: str,
system_language: str,
conversationId: str, low_bandwidth: bool,
content_license_url: str) -> {}:
"""Creates a message
"""
content = removeInvalidChars(content)
subject = _addAutoCW(base_dir, nickname, domain, subject, content)
if nickname != 'news':
mentionedRecipients = \
getMentionedPeople(base_dir, http_prefix, content, domain, False)
else:
mentionedRecipients = ''
tags = []
hashtagsDict = {}
domain = getFullDomain(domain, port)
# add tags
if nickname != 'news':
content = \
addHtmlTags(base_dir, http_prefix,
nickname, domain, content,
mentionedRecipients,
hashtagsDict, True)
# replace emoji with unicode
tags = []
for tagName, tag in hashtagsDict.items():
tags.append(tag)
# get list of tags
if nickname != 'news':
content = \
replaceEmojiFromTags(None, base_dir, content, tags, 'content',
False)
# remove replaced emoji
hashtagsDictCopy = hashtagsDict.copy()
for tagName, tag in hashtagsDictCopy.items():
if tag.get('name'):
if tag['name'].startswith(':'):
if tag['name'] not in content:
del hashtagsDict[tagName]
statusNumber, published = getStatusNumber()
newPostId = \
localActorUrl(http_prefix, nickname, domain) + \
'/statuses/' + statusNumber
sensitive = False
summary = None
if subject:
summary = removeInvalidChars(validContentWarning(subject))
sensitive = True
toRecipients = []
toCC = []
if toUrl:
if not isinstance(toUrl, str):
print('ERROR: toUrl is not a string')
return None
toRecipients = [toUrl]
# who to send to
if mentionedRecipients:
for mention in mentionedRecipients:
if mention not in toCC:
toCC.append(mention)
isPublic = False
for recipient in toRecipients:
if recipient.endswith('#Public'):
isPublic = True
break
# create a list of hashtags
# Only posts which are #Public are searchable by hashtag
if hashtagsDict:
for tagName, tag in hashtagsDict.items():
if not tagExists(tag['type'], tag['name'], tags):
tags.append(tag)
if isPublic:
_updateHashtagsIndex(base_dir, tag, newPostId)
# print('Content tags: ' + str(tags))
sensitive, summary = \
_createPostCWFromReply(base_dir, nickname, domain,
inReplyTo, sensitive, summary)
eventDateStr = \
_createPostPlaceAndTime(eventDate, endDate,
eventTime, endTime,
summary, content, schedulePost,
eventUUID, location, tags)
postContext = getIndividualPostContext()
if not isPublic:
# make sure that CC doesn't also contain a To address
# eg. To: [ "https://mydomain/users/foo/followers" ]
# CC: [ "X", "Y", "https://mydomain/users/foo", "Z" ]
removeFromCC = []
for ccRecipient in toCC:
for sendToActor in toRecipients:
if ccRecipient in sendToActor and \
ccRecipient not in removeFromCC:
removeFromCC.append(ccRecipient)
break
for ccRemoval in removeFromCC:
toCC.remove(ccRemoval)
else:
if inReplyTo:
# If this is a public post then get the actor being
# replied to end ensure that it is within the CC list
replyActor = getActorFromInReplyTo(inReplyTo)
if replyActor:
if replyActor not in toCC:
toCC.append(replyActor)
# the type of post to be made
postObjectType = 'Note'
if isArticle:
postObjectType = 'Article'
if not client_to_server:
newPost = \
_createPostS2S(base_dir, nickname, domain, port,
http_prefix, content, statusNumber,
published, newPostId, postContext,
toRecipients, toCC, inReplyTo,
sensitive, commentsEnabled,
tags, attachImageFilename,
mediaType, imageDescription, city,
postObjectType, summary,
inReplyToAtomUri, system_language,
conversationId, low_bandwidth,
content_license_url)
else:
newPost = \
_createPostC2S(base_dir, nickname, domain, port,
http_prefix, content, statusNumber,
published, newPostId, postContext,
toRecipients, toCC, inReplyTo,
sensitive, commentsEnabled,
tags, attachImageFilename,
mediaType, imageDescription, city,
postObjectType, summary,
inReplyToAtomUri, system_language,
conversationId, low_bandwidth,
content_license_url)
_createPostMentions(ccUrl, newPost, toRecipients, tags)
_createPostModReport(base_dir, isModerationReport, newPost, newPostId)
# If a patch has been posted - i.e. the output from
# git format-patch - then convert the activitypub type
convertPostToPatch(base_dir, nickname, domain, newPost)
if schedulePost:
if eventDate and eventTime:
# add an item to the scheduled post index file
_addSchedulePost(base_dir, nickname, domain,
eventDateStr, newPostId)
savePostToBox(base_dir, http_prefix, newPostId,
nickname, domain, newPost, 'scheduled')
else:
print('Unable to create scheduled post without ' +
'date and time values')
return newPost
elif saveToFile:
if isArticle:
savePostToBox(base_dir, http_prefix, newPostId,
nickname, domain, newPost, 'tlblogs')
else:
savePostToBox(base_dir, http_prefix, newPostId,
nickname, domain, newPost, 'outbox')
return newPost
def outboxMessageCreateWrap(http_prefix: str,
nickname: str, domain: str, port: int,
messageJson: {}) -> {}:
"""Wraps a received message in a Create
https://www.w3.org/TR/activitypub/#object-without-create
"""
domain = getFullDomain(domain, port)
statusNumber, published = getStatusNumber()
if messageJson.get('published'):
published = messageJson['published']
newPostId = \
localActorUrl(http_prefix, nickname, domain) + \
'/statuses/' + statusNumber
cc = []
if messageJson.get('cc'):
cc = messageJson['cc']
newPost = {
"@context": "https://www.w3.org/ns/activitystreams",
'id': newPostId + '/activity',
'type': 'Create',
'actor': localActorUrl(http_prefix, nickname, domain),
'published': published,
'to': messageJson['to'],
'cc': cc,
'object': messageJson
}
newPost['object']['id'] = newPost['id']
newPost['object']['url'] = \
http_prefix + '://' + domain + '/@' + nickname + '/' + statusNumber
newPost['object']['atomUri'] = \
localActorUrl(http_prefix, nickname, domain) + \
'/statuses/' + statusNumber
return newPost
def _postIsAddressedToFollowers(base_dir: str,
nickname: str, domain: str, port: int,
http_prefix: str,
post_json_object: {}) -> bool:
"""Returns true if the given post is addressed to followers of the nickname
"""
domainFull = getFullDomain(domain, port)
if not post_json_object.get('object'):
return False
toList = []
ccList = []
if post_json_object['type'] != 'Update' and \
hasObjectDict(post_json_object):
if post_json_object['object'].get('to'):
toList = post_json_object['object']['to']
if post_json_object['object'].get('cc'):
ccList = post_json_object['object']['cc']
else:
if post_json_object.get('to'):
toList = post_json_object['to']
if post_json_object.get('cc'):
ccList = post_json_object['cc']
followersUrl = \
localActorUrl(http_prefix, nickname, domainFull) + '/followers'
# does the followers url exist in 'to' or 'cc' lists?
addressedToFollowers = False
if followersUrl in toList:
addressedToFollowers = True
elif followersUrl in ccList:
addressedToFollowers = True
return addressedToFollowers
def pinPost(base_dir: str, nickname: str, domain: str,
pinnedContent: str, followersOnly: bool) -> None:
"""Pins the given post Id to the profile of then given account
"""
accountDir = acctDir(base_dir, nickname, domain)
pinnedFilename = accountDir + '/pinToProfile.txt'
try:
with open(pinnedFilename, 'w+') as pinFile:
pinFile.write(pinnedContent)
except OSError:
print('EX: unable to write ' + pinnedFilename)
def undoPinnedPost(base_dir: str, nickname: str, domain: str) -> None:
"""Removes pinned content for then given account
"""
accountDir = acctDir(base_dir, nickname, domain)
pinnedFilename = accountDir + '/pinToProfile.txt'
if os.path.isfile(pinnedFilename):
try:
os.remove(pinnedFilename)
except OSError:
print('EX: undoPinnedPost unable to delete ' + pinnedFilename)
def getPinnedPostAsJson(base_dir: str, http_prefix: str,
nickname: str, domain: str,
domainFull: str, system_language: str) -> {}:
"""Returns the pinned profile post as json
"""
accountDir = acctDir(base_dir, nickname, domain)
pinnedFilename = accountDir + '/pinToProfile.txt'
pinnedPostJson = {}
actor = localActorUrl(http_prefix, nickname, domainFull)
if os.path.isfile(pinnedFilename):
pinnedContent = None
with open(pinnedFilename, 'r') as pinFile:
pinnedContent = pinFile.read()
if pinnedContent:
pinnedPostJson = {
'atomUri': actor + '/pinned',
'attachment': [],
'attributedTo': actor,
'cc': [
actor + '/followers'
],
'content': pinnedContent,
'contentMap': {
system_language: pinnedContent
},
'id': actor + '/pinned',
'inReplyTo': None,
'inReplyToAtomUri': None,
'published': fileLastModified(pinnedFilename),
'replies': {},
'sensitive': False,
'summary': None,
'tag': [],
'to': ['https://www.w3.org/ns/activitystreams#Public'],
'type': 'Note',
'url': replaceUsersWithAt(actor) + '/pinned'
}
return pinnedPostJson
def jsonPinPost(base_dir: str, http_prefix: str,
nickname: str, domain: str,
domainFull: str, system_language: str) -> {}:
"""Returns a pinned post as json
"""
pinnedPostJson = \
getPinnedPostAsJson(base_dir, http_prefix,
nickname, domain,
domainFull, system_language)
itemsList = []
if pinnedPostJson:
itemsList = [pinnedPostJson]
actor = localActorUrl(http_prefix, nickname, domainFull)
postContext = getIndividualPostContext()
return {
'@context': postContext,
'id': actor + '/collections/featured',
'orderedItems': itemsList,
'totalItems': len(itemsList),
'type': 'OrderedCollection'
}
def regenerateIndexForBox(base_dir: str,
nickname: str, domain: str, boxName: str) -> None:
"""Generates an index for the given box if it doesn't exist
Used by unit tests to artificially create an index
"""
boxDir = acctDir(base_dir, nickname, domain) + '/' + boxName
boxIndexFilename = boxDir + '.index'
if not os.path.isdir(boxDir):
return
if os.path.isfile(boxIndexFilename):
return
indexLines = []
for subdir, dirs, files in os.walk(boxDir):
for f in files:
if ':##' not in f:
continue
indexLines.append(f)
break
indexLines.sort(reverse=True)
result = ''
with open(boxIndexFilename, 'w+') as fp:
for line in indexLines:
result += line + '\n'
fp.write(line + '\n')
print('Index generated for ' + boxName + '\n' + result)
def createPublicPost(base_dir: str,
nickname: str, domain: str, port: int, http_prefix: str,
content: str, followersOnly: bool, saveToFile: bool,
client_to_server: bool, commentsEnabled: bool,
attachImageFilename: str, mediaType: str,
imageDescription: str, city: str,
inReplyTo: str,
inReplyToAtomUri: str, subject: str,
schedulePost: bool,
eventDate: str, eventTime: str,
location: str,
isArticle: bool,
system_language: str,
conversationId: str, low_bandwidth: bool,
content_license_url: str) -> {}:
"""Public post
"""
domainFull = getFullDomain(domain, port)
isModerationReport = False
eventUUID = None
category = None
joinMode = None
endDate = None
endTime = None
maximumAttendeeCapacity = None
repliesModerationOption = None
anonymousParticipationEnabled = None
eventStatus = None
ticketUrl = None
localActor = localActorUrl(http_prefix, nickname, domainFull)
return _createPostBase(base_dir, nickname, domain, port,
'https://www.w3.org/ns/activitystreams#Public',
localActor + '/followers',
http_prefix, content, followersOnly, saveToFile,
client_to_server, commentsEnabled,
attachImageFilename, mediaType,
imageDescription, city,
isModerationReport, isArticle,
inReplyTo, inReplyToAtomUri, subject,
schedulePost, eventDate, eventTime, location,
eventUUID, category, joinMode, endDate, endTime,
maximumAttendeeCapacity,
repliesModerationOption,
anonymousParticipationEnabled,
eventStatus, ticketUrl, system_language,
conversationId, low_bandwidth,
content_license_url)
def _appendCitationsToBlogPost(base_dir: str,
nickname: str, domain: str,
blogJson: {}) -> None:
"""Appends any citations to a new blog post
"""
# append citations tags, stored in a file
citationsFilename = \
acctDir(base_dir, nickname, domain) + '/.citations.txt'
if not os.path.isfile(citationsFilename):
return
citationsSeparator = '#####'
with open(citationsFilename, 'r') as f:
citations = f.readlines()
for line in citations:
if citationsSeparator not in line:
continue
sections = line.strip().split(citationsSeparator)
if len(sections) != 3:
continue
# dateStr = sections[0]
title = sections[1]
link = sections[2]
tagJson = {
"type": "Article",
"name": title,
"url": link
}
blogJson['object']['tag'].append(tagJson)
def createBlogPost(base_dir: str,
nickname: str, domain: str, port: int, http_prefix: str,
content: str, followersOnly: bool, saveToFile: bool,
client_to_server: bool, commentsEnabled: bool,
attachImageFilename: str, mediaType: str,
imageDescription: str, city: str,
inReplyTo: str, inReplyToAtomUri: str,
subject: str, schedulePost: bool,
eventDate: str, eventTime: str,
location: str, system_language: str,
conversationId: str, low_bandwidth: bool,
content_license_url: str) -> {}:
blogJson = \
createPublicPost(base_dir,
nickname, domain, port, http_prefix,
content, followersOnly, saveToFile,
client_to_server, commentsEnabled,
attachImageFilename, mediaType,
imageDescription, city,
inReplyTo, inReplyToAtomUri, subject,
schedulePost,
eventDate, eventTime, location,
True, system_language, conversationId,
low_bandwidth, content_license_url)
blogJson['object']['url'] = \
blogJson['object']['url'].replace('/@', '/users/')
_appendCitationsToBlogPost(base_dir, nickname, domain, blogJson)
return blogJson
def createNewsPost(base_dir: str,
domain: str, port: int, http_prefix: str,
content: str, followersOnly: bool, saveToFile: bool,
attachImageFilename: str, mediaType: str,
imageDescription: str, city: str,
subject: str, system_language: str,
conversationId: str, low_bandwidth: bool,
content_license_url: str) -> {}:
client_to_server = False
inReplyTo = None
inReplyToAtomUri = None
schedulePost = False
eventDate = None
eventTime = None
location = None
blog = \
createPublicPost(base_dir,
'news', domain, port, http_prefix,
content, followersOnly, saveToFile,
client_to_server, False,
attachImageFilename, mediaType,
imageDescription, city,
inReplyTo, inReplyToAtomUri, subject,
schedulePost,
eventDate, eventTime, location,
True, system_language, conversationId,
low_bandwidth, content_license_url)
blog['object']['type'] = 'Article'
return blog
def createQuestionPost(base_dir: str,
nickname: str, domain: str, port: int, http_prefix: str,
content: str, qOptions: [],
followersOnly: bool, saveToFile: bool,
client_to_server: bool, commentsEnabled: bool,
attachImageFilename: str, mediaType: str,
imageDescription: str, city: str,
subject: str, durationDays: int,
system_language: str, low_bandwidth: bool,
content_license_url: str) -> {}:
"""Question post with multiple choice options
"""
domainFull = getFullDomain(domain, port)
localActor = localActorUrl(http_prefix, nickname, domainFull)
messageJson = \
_createPostBase(base_dir, nickname, domain, port,
'https://www.w3.org/ns/activitystreams#Public',
localActor + '/followers',
http_prefix, content, followersOnly, saveToFile,
client_to_server, commentsEnabled,
attachImageFilename, mediaType,
imageDescription, city,
False, False, None, None, subject,
False, None, None, None, None, None,
None, None, None,
None, None, None, None, None, system_language,
None, low_bandwidth, content_license_url)
messageJson['object']['type'] = 'Question'
messageJson['object']['oneOf'] = []
messageJson['object']['votersCount'] = 0
currTime = datetime.datetime.utcnow()
daysSinceEpoch = \
int((currTime - datetime.datetime(1970, 1, 1)).days + durationDays)
endTime = datetime.datetime(1970, 1, 1) + \
datetime.timedelta(daysSinceEpoch)
messageJson['object']['endTime'] = endTime.strftime("%Y-%m-%dT%H:%M:%SZ")
for questionOption in qOptions:
messageJson['object']['oneOf'].append({
"type": "Note",
"name": questionOption,
"replies": {
"type": "Collection",
"totalItems": 0
}
})
return messageJson
def createUnlistedPost(base_dir: str,
nickname: str, domain: str, port: int, http_prefix: str,
content: str, followersOnly: bool, saveToFile: bool,
client_to_server: bool, commentsEnabled: bool,
attachImageFilename: str, mediaType: str,
imageDescription: str, city: str,
inReplyTo: str, inReplyToAtomUri: str,
subject: str, schedulePost: bool,
eventDate: str, eventTime: str,
location: str, system_language: str,
conversationId: str, low_bandwidth: bool,
content_license_url: str) -> {}:
"""Unlisted post. This has the #Public and followers links inverted.
"""
domainFull = getFullDomain(domain, port)
localActor = localActorUrl(http_prefix, nickname, domainFull)
return _createPostBase(base_dir, nickname, domain, port,
localActor + '/followers',
'https://www.w3.org/ns/activitystreams#Public',
http_prefix, content, followersOnly, saveToFile,
client_to_server, commentsEnabled,
attachImageFilename, mediaType,
imageDescription, city,
False, False,
inReplyTo, inReplyToAtomUri, subject,
schedulePost, eventDate, eventTime, location,
None, None, None, None, None,
None, None, None, None, None, system_language,
conversationId, low_bandwidth,
content_license_url)
def createFollowersOnlyPost(base_dir: str,
nickname: str, domain: str, port: int,
http_prefix: str,
content: str, followersOnly: bool,
saveToFile: bool,
client_to_server: bool, commentsEnabled: bool,
attachImageFilename: str, mediaType: str,
imageDescription: str, city: str,
inReplyTo: str,
inReplyToAtomUri: str,
subject: str, schedulePost: bool,
eventDate: str, eventTime: str,
location: str, system_language: str,
conversationId: str, low_bandwidth: bool,
content_license_url: str) -> {}:
"""Followers only post
"""
domainFull = getFullDomain(domain, port)
localActor = localActorUrl(http_prefix, nickname, domainFull)
return _createPostBase(base_dir, nickname, domain, port,
localActor + '/followers',
None,
http_prefix, content, followersOnly, saveToFile,
client_to_server, commentsEnabled,
attachImageFilename, mediaType,
imageDescription, city,
False, False,
inReplyTo, inReplyToAtomUri, subject,
schedulePost, eventDate, eventTime, location,
None, None, None, None, None,
None, None, None, None, None, system_language,
conversationId, low_bandwidth,
content_license_url)
def getMentionedPeople(base_dir: str, http_prefix: str,
content: str, domain: str, debug: bool) -> []:
"""Extracts a list of mentioned actors from the given message content
"""
if '@' not in content:
return None
mentions = []
words = content.split(' ')
for wrd in words:
if not wrd.startswith('@'):
continue
handle = wrd[1:]
if debug:
print('DEBUG: mentioned handle ' + handle)
if '@' not in handle:
handle = handle + '@' + domain
if not os.path.isdir(base_dir + '/accounts/' + handle):
continue
else:
externalDomain = handle.split('@')[1]
if not ('.' in externalDomain or
externalDomain == 'localhost'):
continue
mentionedNickname = handle.split('@')[0]
mentionedDomain = handle.split('@')[1].strip('\n').strip('\r')
if ':' in mentionedDomain:
mentionedDomain = removeDomainPort(mentionedDomain)
if not validNickname(mentionedDomain, mentionedNickname):
continue
actor = \
localActorUrl(http_prefix, mentionedNickname, handle.split('@')[1])
mentions.append(actor)
return mentions
def createDirectMessagePost(base_dir: str,
nickname: str, domain: str, port: int,
http_prefix: str,
content: str, followersOnly: bool,
saveToFile: bool, client_to_server: bool,
commentsEnabled: bool,
attachImageFilename: str, mediaType: str,
imageDescription: str, city: str,
inReplyTo: str,
inReplyToAtomUri: str,
subject: str, debug: bool,
schedulePost: bool,
eventDate: str, eventTime: str,
location: str, system_language: str,
conversationId: str, low_bandwidth: bool,
content_license_url: str) -> {}:
"""Direct Message post
"""
content = resolvePetnames(base_dir, nickname, domain, content)
mentionedPeople = \
getMentionedPeople(base_dir, http_prefix, content, domain, debug)
if debug:
print('mentionedPeople: ' + str(mentionedPeople))
if not mentionedPeople:
return None
postTo = None
postCc = None
messageJson = \
_createPostBase(base_dir, nickname, domain, port,
postTo, postCc,
http_prefix, content, followersOnly, saveToFile,
client_to_server, commentsEnabled,
attachImageFilename, mediaType,
imageDescription, city,
False, False,
inReplyTo, inReplyToAtomUri, subject,
schedulePost, eventDate, eventTime, location,
None, None, None, None, None,
None, None, None, None, None, system_language,
conversationId, low_bandwidth,
content_license_url)
# mentioned recipients go into To rather than Cc
messageJson['to'] = messageJson['object']['cc']
messageJson['object']['to'] = messageJson['to']
messageJson['cc'] = []
messageJson['object']['cc'] = []
if schedulePost:
postId = removeIdEnding(messageJson['object']['id'])
savePostToBox(base_dir, http_prefix, postId,
nickname, domain, messageJson, 'scheduled')
return messageJson
def createReportPost(base_dir: str,
nickname: str, domain: str, port: int, http_prefix: str,
content: str, followersOnly: bool, saveToFile: bool,
client_to_server: bool, commentsEnabled: bool,
attachImageFilename: str, mediaType: str,
imageDescription: str, city: str,
debug: bool, subject: str, system_language: str,
low_bandwidth: bool,
content_license_url: str) -> {}:
"""Send a report to moderators
"""
domainFull = getFullDomain(domain, port)
# add a title to distinguish moderation reports from other posts
reportTitle = 'Moderation Report'
if not subject:
subject = reportTitle
else:
if not subject.startswith(reportTitle):
subject = reportTitle + ': ' + subject
# create the list of moderators from the moderators file
moderatorsList = []
moderatorsFile = base_dir + '/accounts/moderators.txt'
if os.path.isfile(moderatorsFile):
with open(moderatorsFile, 'r') as fileHandler:
for line in fileHandler:
line = line.strip('\n').strip('\r')
if line.startswith('#'):
continue
if line.startswith('/users/'):
line = line.replace('users', '')
if line.startswith('@'):
line = line[1:]
if '@' in line:
nick = line.split('@')[0]
moderatorActor = \
localActorUrl(http_prefix, nick, domainFull)
if moderatorActor not in moderatorsList:
moderatorsList.append(moderatorActor)
continue
if line.startswith('http') or line.startswith('hyper'):
# must be a local address - no remote moderators
if '://' + domainFull + '/' in line:
if line not in moderatorsList:
moderatorsList.append(line)
else:
if '/' not in line:
moderatorActor = \
localActorUrl(http_prefix, line, domainFull)
if moderatorActor not in moderatorsList:
moderatorsList.append(moderatorActor)
if len(moderatorsList) == 0:
# if there are no moderators then the admin becomes the moderator
adminNickname = getConfigParam(base_dir, 'admin')
if adminNickname:
localActor = localActorUrl(http_prefix, adminNickname, domainFull)
moderatorsList.append(localActor)
if not moderatorsList:
return None
if debug:
print('DEBUG: Sending report to moderators')
print(str(moderatorsList))
postTo = moderatorsList
postCc = None
post_json_object = None
for toUrl in postTo:
# who is this report going to?
toNickname = toUrl.split('/users/')[1]
handle = toNickname + '@' + domain
post_json_object = \
_createPostBase(base_dir, nickname, domain, port,
toUrl, postCc,
http_prefix, content, followersOnly, saveToFile,
client_to_server, commentsEnabled,
attachImageFilename, mediaType,
imageDescription, city,
True, False, None, None, subject,
False, None, None, None, None, None,
None, None, None,
None, None, None, None, None, system_language,
None, low_bandwidth, content_license_url)
if not post_json_object:
continue
# save a notification file so that the moderator
# knows something new has appeared
newReportFile = base_dir + '/accounts/' + handle + '/.newReport'
if os.path.isfile(newReportFile):
continue
try:
with open(newReportFile, 'w+') as fp:
fp.write(toUrl + '/moderation')
except OSError:
print('EX: createReportPost unable to write ' + newReportFile)
return post_json_object
def threadSendPost(session, postJsonStr: str, federationList: [],
inboxUrl: str, base_dir: str,
signatureHeaderJson: {}, postLog: [],
debug: bool) -> None:
"""Sends a with retries
"""
tries = 0
sendIntervalSec = 30
for attempt in range(20):
postResult = None
unauthorized = False
if debug:
print('Getting postJsonString for ' + inboxUrl)
try:
postResult, unauthorized, returnCode = \
postJsonString(session, postJsonStr, federationList,
inboxUrl, signatureHeaderJson,
debug)
if returnCode >= 500 and returnCode < 600:
# if an instance is returning a code which indicates that
# it might have a runtime error, like 503, then don't
# continue to post to it
break
if debug:
print('Obtained postJsonString for ' + inboxUrl +
' unauthorized: ' + str(unauthorized))
except Exception as ex:
print('ERROR: postJsonString failed ' + str(ex))
if unauthorized:
print('WARN: threadSendPost: Post is unauthorized ' +
inboxUrl + ' ' + postJsonStr)
break
if postResult:
logStr = 'Success on try ' + str(tries) + ': ' + postJsonStr
else:
logStr = 'Retry ' + str(tries) + ': ' + postJsonStr
postLog.append(logStr)
# keep the length of the log finite
# Don't accumulate massive files on systems with limited resources
while len(postLog) > 16:
postLog.pop(0)
if debug:
# save the log file
postLogFilename = base_dir + '/post.log'
if os.path.isfile(postLogFilename):
with open(postLogFilename, 'a+') as logFile:
logFile.write(logStr + '\n')
else:
with open(postLogFilename, 'w+') as logFile:
logFile.write(logStr + '\n')
if postResult:
if debug:
print('DEBUG: successful json post to ' + inboxUrl)
# our work here is done
break
if debug:
print(postJsonStr)
print('DEBUG: json post to ' + inboxUrl +
' failed. Waiting for ' +
str(sendIntervalSec) + ' seconds.')
time.sleep(sendIntervalSec)
tries += 1
def sendPost(signing_priv_key_pem: str, project_version: str,
session, base_dir: str, nickname: str, domain: str, port: int,
toNickname: str, toDomain: str, toPort: int, cc: str,
http_prefix: str, content: str, followersOnly: bool,
saveToFile: bool, client_to_server: bool,
commentsEnabled: bool,
attachImageFilename: str, mediaType: str,
imageDescription: str, city: str,
federationList: [], send_threads: [], postLog: [],
cached_webfingers: {}, person_cache: {},
isArticle: bool, system_language: str,
shared_items_federated_domains: [],
sharedItemFederationTokens: {},
low_bandwidth: bool, content_license_url: str,
debug: bool = False, inReplyTo: str = None,
inReplyToAtomUri: str = None, subject: str = None) -> int:
"""Post to another inbox. Used by unit tests.
"""
withDigest = True
conversationId = None
if toNickname == 'inbox':
# shared inbox actor on @domain@domain
toNickname = toDomain
toDomain = getFullDomain(toDomain, toPort)
handle = http_prefix + '://' + toDomain + '/@' + toNickname
# lookup the inbox for the To handle
wfRequest = webfingerHandle(session, handle, http_prefix,
cached_webfingers,
domain, project_version, debug, False,
signing_priv_key_pem)
if not wfRequest:
return 1
if not isinstance(wfRequest, dict):
print('WARN: Webfinger for ' + handle + ' did not return a dict. ' +
str(wfRequest))
return 1
if not client_to_server:
postToBox = 'inbox'
else:
postToBox = 'outbox'
if isArticle:
postToBox = 'tlblogs'
# get the actor inbox for the To handle
originDomain = domain
(inboxUrl, pubKeyId, pubKey, toPersonId, sharedInbox, avatarUrl,
displayName, _) = getPersonBox(signing_priv_key_pem,
originDomain,
base_dir, session, wfRequest,
person_cache,
project_version, http_prefix,
nickname, domain, postToBox,
72533)
if not inboxUrl:
return 3
if not pubKey:
return 4
if not toPersonId:
return 5
# sharedInbox is optional
post_json_object = \
_createPostBase(base_dir, nickname, domain, port,
toPersonId, cc, http_prefix, content,
followersOnly, saveToFile, client_to_server,
commentsEnabled,
attachImageFilename, mediaType,
imageDescription, city,
False, isArticle, inReplyTo,
inReplyToAtomUri, subject,
False, None, None, None, None, None,
None, None, None,
None, None, None, None, None, system_language,
conversationId, low_bandwidth,
content_license_url)
# get the senders private key
privateKeyPem = _getPersonKey(nickname, domain, base_dir, 'private')
if len(privateKeyPem) == 0:
return 6
if toDomain not in inboxUrl:
return 7
postPath = inboxUrl.split(toDomain, 1)[1]
if not post_json_object.get('signature'):
try:
signedPostJsonObject = post_json_object.copy()
generateJsonSignature(signedPostJsonObject, privateKeyPem)
post_json_object = signedPostJsonObject
except Exception as ex:
print('WARN: failed to JSON-LD sign post, ' + str(ex))
pass
# convert json to string so that there are no
# subsequent conversions after creating message body digest
postJsonStr = json.dumps(post_json_object)
# construct the http header, including the message body digest
signatureHeaderJson = \
createSignedHeader(None, privateKeyPem, nickname, domain, port,
toDomain, toPort,
postPath, http_prefix, withDigest, postJsonStr,
None)
# if the "to" domain is within the shared items
# federation list then send the token for this domain
# so that it can request a catalog
if toDomain in shared_items_federated_domains:
domainFull = getFullDomain(domain, port)
if sharedItemFederationTokens.get(domainFull):
signatureHeaderJson['Origin'] = domainFull
signatureHeaderJson['SharesCatalog'] = \
sharedItemFederationTokens[domainFull]
if debug:
print('SharesCatalog added to header')
elif debug:
print(domainFull + ' not in sharedItemFederationTokens')
elif debug:
print(toDomain + ' not in shared_items_federated_domains ' +
str(shared_items_federated_domains))
if debug:
print('signatureHeaderJson: ' + str(signatureHeaderJson))
# Keep the number of threads being used small
while len(send_threads) > 1000:
print('WARN: Maximum threads reached - killing send thread')
send_threads[0].kill()
send_threads.pop(0)
print('WARN: thread killed')
thr = \
threadWithTrace(target=threadSendPost,
args=(session,
postJsonStr,
federationList,
inboxUrl, base_dir,
signatureHeaderJson.copy(),
postLog,
debug), daemon=True)
send_threads.append(thr)
thr.start()
return 0
def sendPostViaServer(signing_priv_key_pem: str, project_version: str,
base_dir: str, session, fromNickname: str, password: str,
fromDomain: str, fromPort: int,
toNickname: str, toDomain: str, toPort: int, cc: str,
http_prefix: str, content: str, followersOnly: bool,
commentsEnabled: bool,
attachImageFilename: str, mediaType: str,
imageDescription: str, city: str,
cached_webfingers: {}, person_cache: {},
isArticle: bool, system_language: str,
low_bandwidth: bool,
content_license_url: str,
debug: bool = False,
inReplyTo: str = None,
inReplyToAtomUri: str = None,
conversationId: str = None,
subject: str = None) -> int:
"""Send a post via a proxy (c2s)
"""
if not session:
print('WARN: No session for sendPostViaServer')
return 6
fromDomainFull = getFullDomain(fromDomain, fromPort)
handle = http_prefix + '://' + fromDomainFull + '/@' + fromNickname
# lookup the inbox for the To handle
wfRequest = \
webfingerHandle(session, handle, http_prefix, cached_webfingers,
fromDomainFull, project_version, debug, False,
signing_priv_key_pem)
if not wfRequest:
if debug:
print('DEBUG: post webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: post webfinger for ' + handle +
' did not return a dict. ' + str(wfRequest))
return 1
postToBox = 'outbox'
if isArticle:
postToBox = 'tlblogs'
# get the actor inbox for the To handle
originDomain = fromDomain
(inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl,
displayName, _) = getPersonBox(signing_priv_key_pem,
originDomain,
base_dir, session, wfRequest,
person_cache,
project_version, http_prefix,
fromNickname,
fromDomainFull, postToBox,
82796)
if not inboxUrl:
if debug:
print('DEBUG: post no ' + postToBox +
' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: post no actor was found for ' + handle)
return 4
# Get the json for the c2s post, not saving anything to file
# Note that base_dir is set to None
saveToFile = False
client_to_server = True
if toDomain.lower().endswith('public'):
toPersonId = 'https://www.w3.org/ns/activitystreams#Public'
cc = localActorUrl(http_prefix, fromNickname, fromDomainFull) + \
'/followers'
else:
if toDomain.lower().endswith('followers') or \
toDomain.lower().endswith('followersonly'):
toPersonId = \
localActorUrl(http_prefix, fromNickname, fromDomainFull) + \
'/followers'
else:
toDomainFull = getFullDomain(toDomain, toPort)
toPersonId = localActorUrl(http_prefix, toNickname, toDomainFull)
post_json_object = \
_createPostBase(base_dir,
fromNickname, fromDomain, fromPort,
toPersonId, cc, http_prefix, content,
followersOnly, saveToFile, client_to_server,
commentsEnabled,
attachImageFilename, mediaType,
imageDescription, city,
False, isArticle, inReplyTo,
inReplyToAtomUri, subject,
False, None, None, None, None, None,
None, None, None,
None, None, None, None, None, system_language,
conversationId, low_bandwidth,
content_license_url)
authHeader = createBasicAuthHeader(fromNickname, password)
if attachImageFilename:
headers = {
'host': fromDomainFull,
'Authorization': authHeader
}
postResult = \
postImage(session, attachImageFilename, [],
inboxUrl, headers)
if not postResult:
if debug:
print('DEBUG: post failed to upload image')
# return 9
headers = {
'host': fromDomainFull,
'Content-type': 'application/json',
'Authorization': authHeader
}
postDumps = json.dumps(post_json_object)
postResult, unauthorized, returnCode = \
postJsonString(session, postDumps, [],
inboxUrl, headers, debug, 5, True)
if not postResult:
if debug:
if unauthorized:
print('DEBUG: POST failed for c2s to ' +
inboxUrl + ' unathorized')
else:
print('DEBUG: POST failed for c2s to '
+ inboxUrl + ' return code ' + str(returnCode))
return 5
if debug:
print('DEBUG: c2s POST success')
return 0
def groupFollowersByDomain(base_dir: str, nickname: str, domain: str) -> {}:
"""Returns a dictionary with followers grouped by domain
"""
handle = nickname + '@' + domain
followersFilename = base_dir + '/accounts/' + handle + '/followers.txt'
if not os.path.isfile(followersFilename):
return None
grouped = {}
with open(followersFilename, 'r') as f:
for followerHandle in f:
if '@' not in followerHandle:
continue
fHandle = \
followerHandle.strip().replace('\n', '').replace('\r', '')
followerDomain = fHandle.split('@')[1]
if not grouped.get(followerDomain):
grouped[followerDomain] = [fHandle]
else:
grouped[followerDomain].append(fHandle)
return grouped
def _addFollowersToPublicPost(post_json_object: {}) -> None:
"""Adds followers entry to cc if it doesn't exist
"""
if not post_json_object.get('actor'):
return
if isinstance(post_json_object['object'], str):
if not post_json_object.get('to'):
return
if len(post_json_object['to']) > 1:
return
if len(post_json_object['to']) == 0:
return
if not post_json_object['to'][0].endswith('#Public'):
return
if post_json_object.get('cc'):
return
post_json_object['cc'] = post_json_object['actor'] + '/followers'
elif hasObjectDict(post_json_object):
if not post_json_object['object'].get('to'):
return
if len(post_json_object['object']['to']) > 1:
return
elif len(post_json_object['object']['to']) == 0:
return
elif not post_json_object['object']['to'][0].endswith('#Public'):
return
if post_json_object['object'].get('cc'):
return
post_json_object['object']['cc'] = \
post_json_object['actor'] + '/followers'
def sendSignedJson(post_json_object: {}, session, base_dir: str,
nickname: str, domain: str, port: int,
toNickname: str, toDomain: str, toPort: int, cc: str,
http_prefix: str, saveToFile: bool, client_to_server: bool,
federationList: [],
send_threads: [], postLog: [], cached_webfingers: {},
person_cache: {}, debug: bool, project_version: str,
sharedItemsToken: str, groupAccount: bool,
signing_priv_key_pem: str,
sourceId: int) -> int:
"""Sends a signed json object to an inbox/outbox
"""
if debug:
print('DEBUG: sendSignedJson start')
if not session:
print('WARN: No session specified for sendSignedJson')
return 8
withDigest = True
if toDomain.endswith('.onion') or toDomain.endswith('.i2p'):
http_prefix = 'http'
if toNickname == 'inbox':
# shared inbox actor on @domain@domain
toNickname = toDomain
toDomain = getFullDomain(toDomain, toPort)
toDomainUrl = http_prefix + '://' + toDomain
if not siteIsActive(toDomainUrl, 10):
print('Domain is inactive: ' + toDomainUrl)
return 9
print('Domain is active: ' + toDomainUrl)
handleBase = toDomainUrl + '/@'
if toNickname:
handle = handleBase + toNickname
else:
singleUserInstanceNickname = 'dev'
handle = handleBase + singleUserInstanceNickname
if debug:
print('DEBUG: handle - ' + handle + ' toPort ' + str(toPort))
# lookup the inbox for the To handle
wfRequest = webfingerHandle(session, handle, http_prefix,
cached_webfingers,
domain, project_version, debug, groupAccount,
signing_priv_key_pem)
if not wfRequest:
if debug:
print('DEBUG: webfinger for ' + handle + ' failed')
return 1
if not isinstance(wfRequest, dict):
print('WARN: Webfinger for ' + handle + ' did not return a dict. ' +
str(wfRequest))
return 1
if wfRequest.get('errors'):
if debug:
print('DEBUG: webfinger for ' + handle +
' failed with errors ' + str(wfRequest['errors']))
if not client_to_server:
postToBox = 'inbox'
else:
postToBox = 'outbox'
# get the actor inbox/outbox for the To handle
originDomain = domain
(inboxUrl, pubKeyId, pubKey, toPersonId, sharedInboxUrl, avatarUrl,
displayName, _) = getPersonBox(signing_priv_key_pem,
originDomain,
base_dir, session, wfRequest,
person_cache,
project_version, http_prefix,
nickname, domain, postToBox,
sourceId)
print("inboxUrl: " + str(inboxUrl))
print("toPersonId: " + str(toPersonId))
print("sharedInboxUrl: " + str(sharedInboxUrl))
if inboxUrl:
if inboxUrl.endswith('/actor/inbox'):
inboxUrl = sharedInboxUrl
if not inboxUrl:
if debug:
print('DEBUG: missing inboxUrl')
return 3
if debug:
print('DEBUG: Sending to endpoint ' + inboxUrl)
if not pubKey:
if debug:
print('DEBUG: missing pubkey')
return 4
if not toPersonId:
if debug:
print('DEBUG: missing personId')
return 5
# sharedInbox is optional
# get the senders private key
privateKeyPem = _getPersonKey(nickname, domain, base_dir, 'private', debug)
if len(privateKeyPem) == 0:
if debug:
print('DEBUG: Private key not found for ' +
nickname + '@' + domain +
' in ' + base_dir + '/keys/private')
return 6
if toDomain not in inboxUrl:
if debug:
print('DEBUG: ' + toDomain + ' is not in ' + inboxUrl)
return 7
postPath = inboxUrl.split(toDomain, 1)[1]
_addFollowersToPublicPost(post_json_object)
if not post_json_object.get('signature'):
try:
signedPostJsonObject = post_json_object.copy()
generateJsonSignature(signedPostJsonObject, privateKeyPem)
post_json_object = signedPostJsonObject
except Exception as ex:
print('WARN: failed to JSON-LD sign post, ' + str(ex))
pass
# convert json to string so that there are no
# subsequent conversions after creating message body digest
postJsonStr = json.dumps(post_json_object)
# construct the http header, including the message body digest
signatureHeaderJson = \
createSignedHeader(None, privateKeyPem, nickname, domain, port,
toDomain, toPort,
postPath, http_prefix, withDigest, postJsonStr,
None)
# optionally add a token so that the receiving instance may access
# your shared items catalog
if sharedItemsToken:
signatureHeaderJson['Origin'] = getFullDomain(domain, port)
signatureHeaderJson['SharesCatalog'] = sharedItemsToken
elif debug:
print('Not sending shared items federation token')
# Keep the number of threads being used small
while len(send_threads) > 1000:
print('WARN: Maximum threads reached - killing send thread')
send_threads[0].kill()
send_threads.pop(0)
print('WARN: thread killed')
if debug:
print('DEBUG: starting thread to send post')
pprint(post_json_object)
thr = \
threadWithTrace(target=threadSendPost,
args=(session,
postJsonStr,
federationList,
inboxUrl, base_dir,
signatureHeaderJson.copy(),
postLog,
debug), daemon=True)
send_threads.append(thr)
# thr.start()
return 0
def addToField(activityType: str, post_json_object: {},
debug: bool) -> ({}, bool):
"""The Follow/Add/Remove activity doesn't have a 'to' field and so one
needs to be added so that activity distribution happens in a consistent way
Returns true if a 'to' field exists or was added
"""
if post_json_object.get('to'):
return post_json_object, True
if debug:
pprint(post_json_object)
print('DEBUG: no "to" field when sending to named addresses 2')
isSameType = False
toFieldAdded = False
if post_json_object.get('object'):
if isinstance(post_json_object['object'], str):
if post_json_object.get('type'):
if post_json_object['type'] == activityType:
isSameType = True
if debug:
print('DEBUG: "to" field assigned to ' + activityType)
toAddress = post_json_object['object']
if '/statuses/' in toAddress:
toAddress = toAddress.split('/statuses/')[0]
post_json_object['to'] = [toAddress]
toFieldAdded = True
elif hasObjectDict(post_json_object):
# add a to field to bookmark add or remove
if post_json_object.get('type') and \
post_json_object.get('actor') and \
post_json_object['object'].get('type'):
if post_json_object['type'] == 'Add' or \
post_json_object['type'] == 'Remove':
if post_json_object['object']['type'] == 'Document':
post_json_object['to'] = \
[post_json_object['actor']]
post_json_object['object']['to'] = \
[post_json_object['actor']]
toFieldAdded = True
if not toFieldAdded and \
post_json_object['object'].get('type'):
if post_json_object['object']['type'] == activityType:
isSameType = True
if isinstance(post_json_object['object']['object'], str):
if debug:
print('DEBUG: "to" field assigned to ' +
activityType)
toAddress = post_json_object['object']['object']
if '/statuses/' in toAddress:
toAddress = toAddress.split('/statuses/')[0]
post_json_object['object']['to'] = [toAddress]
post_json_object['to'] = \
[post_json_object['object']['object']]
toFieldAdded = True
if not isSameType:
return post_json_object, True
if toFieldAdded:
return post_json_object, True
return post_json_object, False
def _isProfileUpdate(post_json_object: {}) -> bool:
"""Is the given post a profile update?
for actor updates there is no 'to' within the object
"""
if post_json_object.get('type'):
if hasObjectStringType(post_json_object, False):
if (post_json_object['type'] == 'Update' and
(post_json_object['object']['type'] == 'Person' or
post_json_object['object']['type'] == 'Application' or
post_json_object['object']['type'] == 'Group' or
post_json_object['object']['type'] == 'Service')):
return True
return False
def _sendToNamedAddresses(session, base_dir: str,
nickname: str, domain: str,
onion_domain: str, i2p_domain: str, port: int,
http_prefix: str, federationList: [],
send_threads: [], postLog: [],
cached_webfingers: {}, person_cache: {},
post_json_object: {}, debug: bool,
project_version: str,
shared_items_federated_domains: [],
sharedItemFederationTokens: {},
signing_priv_key_pem: str) -> None:
"""sends a post to the specific named addresses in to/cc
"""
if not session:
print('WARN: No session for sendToNamedAddresses')
return
if not post_json_object.get('object'):
return
isProfileUpdate = False
if hasObjectDict(post_json_object):
if _isProfileUpdate(post_json_object):
# use the original object, which has a 'to'
recipientsObject = post_json_object
isProfileUpdate = True
if not isProfileUpdate:
if not post_json_object['object'].get('to'):
if debug:
pprint(post_json_object)
print('DEBUG: ' +
'no "to" field when sending to named addresses')
if hasObjectStringType(post_json_object, debug):
if post_json_object['object']['type'] == 'Follow' or \
post_json_object['object']['type'] == 'Join':
post_json_obj2 = post_json_object['object']['object']
if isinstance(post_json_obj2, str):
if debug:
print('DEBUG: "to" field assigned to Follow')
post_json_object['object']['to'] = \
[post_json_object['object']['object']]
if not post_json_object['object'].get('to'):
return
recipientsObject = post_json_object['object']
else:
post_json_object, fieldAdded = \
addToField('Follow', post_json_object, debug)
if not fieldAdded:
return
post_json_object, fieldAdded = \
addToField('Like', post_json_object, debug)
if not fieldAdded:
return
recipientsObject = post_json_object
recipients = []
recipientType = ('to', 'cc')
for rType in recipientType:
if not recipientsObject.get(rType):
continue
if isinstance(recipientsObject[rType], list):
if debug:
pprint(recipientsObject)
print('recipientsObject: ' + str(recipientsObject[rType]))
for address in recipientsObject[rType]:
if not address:
continue
if '/' not in address:
continue
if address.endswith('#Public'):
continue
if address.endswith('/followers'):
continue
recipients.append(address)
elif isinstance(recipientsObject[rType], str):
address = recipientsObject[rType]
if address:
if '/' in address:
if address.endswith('#Public'):
continue
if address.endswith('/followers'):
continue
recipients.append(address)
if not recipients:
if debug:
print('DEBUG: no individual recipients')
return
if debug:
print('DEBUG: Sending individually addressed posts: ' +
str(recipients))
# this is after the message has arrived at the server
client_to_server = False
for address in recipients:
toNickname = getNicknameFromActor(address)
if not toNickname:
continue
toDomain, toPort = getDomainFromActor(address)
if not toDomain:
continue
# Don't send profile/actor updates to yourself
if isProfileUpdate:
domainFull = getFullDomain(domain, port)
toDomainFull = getFullDomain(toDomain, toPort)
if nickname == toNickname and \
domainFull == toDomainFull:
if debug:
print('Not sending profile update to self. ' +
nickname + '@' + domainFull)
continue
if debug:
domainFull = getFullDomain(domain, port)
toDomainFull = getFullDomain(toDomain, toPort)
print('DEBUG: Post sending s2s: ' + nickname + '@' + domainFull +
' to ' + toNickname + '@' + toDomainFull)
# if we have an alt onion domain and we are sending to
# another onion domain then switch the clearnet
# domain for the onion one
fromDomain = domain
fromDomainFull = getFullDomain(domain, port)
fromHttpPrefix = http_prefix
if onion_domain:
if toDomain.endswith('.onion'):
fromDomain = onion_domain
fromDomainFull = onion_domain
fromHttpPrefix = 'http'
elif i2p_domain:
if toDomain.endswith('.i2p'):
fromDomain = i2p_domain
fromDomainFull = i2p_domain
fromHttpPrefix = 'http'
cc = []
# if the "to" domain is within the shared items
# federation list then send the token for this domain
# so that it can request a catalog
sharedItemsToken = None
if toDomain in shared_items_federated_domains:
if sharedItemFederationTokens.get(fromDomainFull):
sharedItemsToken = sharedItemFederationTokens[fromDomainFull]
groupAccount = hasGroupType(base_dir, address, person_cache)
sendSignedJson(post_json_object, session, base_dir,
nickname, fromDomain, port,
toNickname, toDomain, toPort,
cc, fromHttpPrefix, True, client_to_server,
federationList,
send_threads, postLog, cached_webfingers,
person_cache, debug, project_version,
sharedItemsToken, groupAccount,
signing_priv_key_pem, 34436782)
def sendToNamedAddressesThread(session, base_dir: str,
nickname: str, domain: str,
onion_domain: str, i2p_domain: str, port: int,
http_prefix: str, federationList: [],
send_threads: [], postLog: [],
cached_webfingers: {}, person_cache: {},
post_json_object: {}, debug: bool,
project_version: str,
shared_items_federated_domains: [],
sharedItemFederationTokens: {},
signing_priv_key_pem: str):
"""Returns a thread used to send a post to named addresses
"""
sendThread = \
threadWithTrace(target=_sendToNamedAddresses,
args=(session, base_dir,
nickname, domain,
onion_domain, i2p_domain, port,
http_prefix, federationList,
send_threads, postLog,
cached_webfingers, person_cache,
post_json_object, debug,
project_version,
shared_items_federated_domains,
sharedItemFederationTokens,
signing_priv_key_pem), daemon=True)
try:
sendThread.start()
except SocketError as ex:
print('WARN: socket error while starting ' +
'thread to send to named addresses. ' + str(ex))
return None
except ValueError as ex:
print('WARN: error while starting ' +
'thread to send to named addresses. ' + str(ex))
return None
return sendThread
def _hasSharedInbox(session, http_prefix: str, domain: str,
debug: bool, signing_priv_key_pem: str) -> bool:
"""Returns true if the given domain has a shared inbox
This tries the new and the old way of webfingering the shared inbox
"""
tryHandles = []
if ':' not in domain:
tryHandles.append(domain + '@' + domain)
tryHandles.append('inbox@' + domain)
for handle in tryHandles:
wfRequest = webfingerHandle(session, handle, http_prefix, {},
domain, __version__, debug, False,
signing_priv_key_pem)
if wfRequest:
if isinstance(wfRequest, dict):
if not wfRequest.get('errors'):
return True
return False
def _sendingProfileUpdate(post_json_object: {}) -> bool:
"""Returns true if the given json is a profile update
"""
if post_json_object['type'] != 'Update':
return False
if not hasObjectStringType(post_json_object, False):
return False
activityType = post_json_object['object']['type']
if activityType == 'Person' or \
activityType == 'Application' or \
activityType == 'Group' or \
activityType == 'Service':
return True
return False
def sendToFollowers(session, base_dir: str,
nickname: str,
domain: str,
onion_domain: str, i2p_domain: str, port: int,
http_prefix: str, federationList: [],
send_threads: [], postLog: [],
cached_webfingers: {}, person_cache: {},
post_json_object: {}, debug: bool,
project_version: str,
shared_items_federated_domains: [],
sharedItemFederationTokens: {},
signing_priv_key_pem: str) -> None:
"""sends a post to the followers of the given nickname
"""
print('sendToFollowers')
if not session:
print('WARN: No session for sendToFollowers')
return
if not _postIsAddressedToFollowers(base_dir, nickname, domain,
port, http_prefix,
post_json_object):
if debug:
print('Post is not addressed to followers')
return
print('Post is addressed to followers')
grouped = groupFollowersByDomain(base_dir, nickname, domain)
if not grouped:
if debug:
print('Post to followers did not resolve any domains')
return
print('Post to followers resolved domains')
# print(str(grouped))
# this is after the message has arrived at the server
client_to_server = False
# for each instance
sendingStartTime = datetime.datetime.utcnow()
print('Sending post to followers begins ' +
sendingStartTime.strftime("%Y-%m-%dT%H:%M:%SZ"))
sendingCtr = 0
for followerDomain, followerHandles in grouped.items():
print('Sending post to followers progress ' +
str(int(sendingCtr * 100 / len(grouped.items()))) + '% ' +
followerDomain)
sendingCtr += 1
if debug:
pprint(followerHandles)
# if the followers domain is within the shared items
# federation list then send the token for this domain
# so that it can request a catalog
sharedItemsToken = None
if followerDomain in shared_items_federated_domains:
domainFull = getFullDomain(domain, port)
if sharedItemFederationTokens.get(domainFull):
sharedItemsToken = sharedItemFederationTokens[domainFull]
# check that the follower's domain is active
followerDomainUrl = http_prefix + '://' + followerDomain
if not siteIsActive(followerDomainUrl, 10):
print('Sending post to followers domain is inactive: ' +
followerDomainUrl)
continue
print('Sending post to followers domain is active: ' +
followerDomainUrl)
withSharedInbox = \
_hasSharedInbox(session, http_prefix, followerDomain, debug,
signing_priv_key_pem)
if debug:
if withSharedInbox:
print(followerDomain + ' has shared inbox')
if not withSharedInbox:
print('Sending post to followers, ' + followerDomain +
' does not have a shared inbox')
toPort = port
index = 0
toDomain = followerHandles[index].split('@')[1]
if ':' in toDomain:
toPort = getPortFromDomain(toDomain)
toDomain = removeDomainPort(toDomain)
cc = ''
# if we are sending to an onion domain and we
# have an alt onion domain then use the alt
fromDomain = domain
fromHttpPrefix = http_prefix
if onion_domain:
if toDomain.endswith('.onion'):
fromDomain = onion_domain
fromHttpPrefix = 'http'
elif i2p_domain:
if toDomain.endswith('.i2p'):
fromDomain = i2p_domain
fromHttpPrefix = 'http'
if withSharedInbox:
toNickname = followerHandles[index].split('@')[0]
groupAccount = False
if toNickname.startswith('!'):
groupAccount = True
toNickname = toNickname[1:]
# if there are more than one followers on the domain
# then send the post to the shared inbox
if len(followerHandles) > 1:
toNickname = 'inbox'
if toNickname != 'inbox' and post_json_object.get('type'):
if _sendingProfileUpdate(post_json_object):
print('Sending post to followers ' +
'shared inbox of ' + toDomain)
toNickname = 'inbox'
print('Sending post to followers from ' +
nickname + '@' + domain +
' to ' + toNickname + '@' + toDomain)
sendSignedJson(post_json_object, session, base_dir,
nickname, fromDomain, port,
toNickname, toDomain, toPort,
cc, fromHttpPrefix, True, client_to_server,
federationList,
send_threads, postLog, cached_webfingers,
person_cache, debug, project_version,
sharedItemsToken, groupAccount,
signing_priv_key_pem, 639342)
else:
# send to individual followers without using a shared inbox
for handle in followerHandles:
print('Sending post to followers ' + handle)
toNickname = handle.split('@')[0]
groupAccount = False
if toNickname.startswith('!'):
groupAccount = True
toNickname = toNickname[1:]
if post_json_object['type'] != 'Update':
print('Sending post to followers from ' +
nickname + '@' + domain + ' to ' +
toNickname + '@' + toDomain)
else:
print('Sending post to followers profile update from ' +
nickname + '@' + domain + ' to ' +
toNickname + '@' + toDomain)
sendSignedJson(post_json_object, session, base_dir,
nickname, fromDomain, port,
toNickname, toDomain, toPort,
cc, fromHttpPrefix, True, client_to_server,
federationList,
send_threads, postLog, cached_webfingers,
person_cache, debug, project_version,
sharedItemsToken, groupAccount,
signing_priv_key_pem, 634219)
time.sleep(4)
if debug:
print('DEBUG: End of sendToFollowers')
sendingEndTime = datetime.datetime.utcnow()
sendingMins = int((sendingEndTime - sendingStartTime).total_seconds() / 60)
print('Sending post to followers ends ' + str(sendingMins) + ' mins')
def sendToFollowersThread(session, base_dir: str,
nickname: str,
domain: str,
onion_domain: str, i2p_domain: str, port: int,
http_prefix: str, federationList: [],
send_threads: [], postLog: [],
cached_webfingers: {}, person_cache: {},
post_json_object: {}, debug: bool,
project_version: str,
shared_items_federated_domains: [],
sharedItemFederationTokens: {},
signing_priv_key_pem: str):
"""Returns a thread used to send a post to followers
"""
sendThread = \
threadWithTrace(target=sendToFollowers,
args=(session, base_dir,
nickname, domain,
onion_domain, i2p_domain, port,
http_prefix, federationList,
send_threads, postLog,
cached_webfingers, person_cache,
post_json_object.copy(), debug,
project_version,
shared_items_federated_domains,
sharedItemFederationTokens,
signing_priv_key_pem), daemon=True)
try:
sendThread.start()
except SocketError as ex:
print('WARN: socket error while starting ' +
'thread to send to followers. ' + str(ex))
return None
except ValueError as ex:
print('WARN: error while starting ' +
'thread to send to followers. ' + str(ex))
return None
return sendThread
def createInbox(recentPostsCache: {},
session, base_dir: str, nickname: str, domain: str, port: int,
http_prefix: str, itemsPerPage: int, headerOnly: bool,
pageNumber: int) -> {}:
return _createBoxIndexed(recentPostsCache,
session, base_dir, 'inbox',
nickname, domain, port, http_prefix,
itemsPerPage, headerOnly, True,
0, False, 0, pageNumber)
def createBookmarksTimeline(session, base_dir: str, nickname: str, domain: str,
port: int, http_prefix: str, itemsPerPage: int,
headerOnly: bool, pageNumber: int) -> {}:
return _createBoxIndexed({}, session, base_dir, 'tlbookmarks',
nickname, domain,
port, http_prefix, itemsPerPage, headerOnly,
True, 0, False, 0, pageNumber)
def createDMTimeline(recentPostsCache: {},
session, base_dir: str, nickname: str, domain: str,
port: int, http_prefix: str, itemsPerPage: int,
headerOnly: bool, pageNumber: int) -> {}:
return _createBoxIndexed(recentPostsCache,
session, base_dir, 'dm', nickname,
domain, port, http_prefix, itemsPerPage,
headerOnly, True, 0, False, 0, pageNumber)
def createRepliesTimeline(recentPostsCache: {},
session, base_dir: str, nickname: str, domain: str,
port: int, http_prefix: str, itemsPerPage: int,
headerOnly: bool, pageNumber: int) -> {}:
return _createBoxIndexed(recentPostsCache, session, base_dir, 'tlreplies',
nickname, domain, port, http_prefix,
itemsPerPage, headerOnly, True,
0, False, 0, pageNumber)
def createBlogsTimeline(session, base_dir: str, nickname: str, domain: str,
port: int, http_prefix: str, itemsPerPage: int,
headerOnly: bool, pageNumber: int) -> {}:
return _createBoxIndexed({}, session, base_dir, 'tlblogs', nickname,
domain, port, http_prefix,
itemsPerPage, headerOnly, True,
0, False, 0, pageNumber)
def createFeaturesTimeline(session, base_dir: str, nickname: str, domain: str,
port: int, http_prefix: str, itemsPerPage: int,
headerOnly: bool, pageNumber: int) -> {}:
return _createBoxIndexed({}, session, base_dir, 'tlfeatures', nickname,
domain, port, http_prefix,
itemsPerPage, headerOnly, True,
0, False, 0, pageNumber)
def createMediaTimeline(session, base_dir: str, nickname: str, domain: str,
port: int, http_prefix: str, itemsPerPage: int,
headerOnly: bool, pageNumber: int) -> {}:
return _createBoxIndexed({}, session, base_dir, 'tlmedia', nickname,
domain, port, http_prefix,
itemsPerPage, headerOnly, True,
0, False, 0, pageNumber)
def createNewsTimeline(session, base_dir: str, nickname: str, domain: str,
port: int, http_prefix: str, itemsPerPage: int,
headerOnly: bool, newswire_votes_threshold: int,
positive_voting: bool, voting_time_mins: int,
pageNumber: int) -> {}:
return _createBoxIndexed({}, session, base_dir, 'outbox', 'news',
domain, port, http_prefix,
itemsPerPage, headerOnly, True,
newswire_votes_threshold, positive_voting,
voting_time_mins, pageNumber)
def createOutbox(session, base_dir: str, nickname: str, domain: str,
port: int, http_prefix: str,
itemsPerPage: int, headerOnly: bool, authorized: bool,
pageNumber: int) -> {}:
return _createBoxIndexed({}, session, base_dir, 'outbox',
nickname, domain, port, http_prefix,
itemsPerPage, headerOnly, authorized,
0, False, 0, pageNumber)
def createModeration(base_dir: str, nickname: str, domain: str, port: int,
http_prefix: str, itemsPerPage: int, headerOnly: bool,
pageNumber: int) -> {}:
boxDir = createPersonDir(nickname, domain, base_dir, 'inbox')
boxname = 'moderation'
domain = getFullDomain(domain, port)
if not pageNumber:
pageNumber = 1
pageStr = '?page=' + str(pageNumber)
boxUrl = localActorUrl(http_prefix, nickname, domain) + '/' + boxname
boxHeader = {
'@context': 'https://www.w3.org/ns/activitystreams',
'first': boxUrl + '?page=true',
'id': boxUrl,
'last': boxUrl + '?page=true',
'totalItems': 0,
'type': 'OrderedCollection'
}
boxItems = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': boxUrl + pageStr,
'orderedItems': [
],
'partOf': boxUrl,
'type': 'OrderedCollectionPage'
}
if isModerator(base_dir, nickname):
moderationIndexFile = base_dir + '/accounts/moderation.txt'
if os.path.isfile(moderationIndexFile):
with open(moderationIndexFile, 'r') as f:
lines = f.readlines()
boxHeader['totalItems'] = len(lines)
if headerOnly:
return boxHeader
pageLines = []
if len(lines) > 0:
endLineNumber = len(lines) - 1 - int(itemsPerPage * pageNumber)
if endLineNumber < 0:
endLineNumber = 0
startLineNumber = \
len(lines) - 1 - int(itemsPerPage * (pageNumber - 1))
if startLineNumber < 0:
startLineNumber = 0
lineNumber = startLineNumber
while lineNumber >= endLineNumber:
pageLines.append(lines[lineNumber].strip('\n').strip('\r'))
lineNumber -= 1
for postUrl in pageLines:
postFilename = \
boxDir + '/' + postUrl.replace('/', '#') + '.json'
if os.path.isfile(postFilename):
post_json_object = loadJson(postFilename)
if post_json_object:
boxItems['orderedItems'].append(post_json_object)
if headerOnly:
return boxHeader
return boxItems
def isImageMedia(session, base_dir: str, http_prefix: str,
nickname: str, domain: str,
post_json_object: {}, translate: {},
yt_replace_domain: str,
twitter_replacement_domain: str,
allow_local_network_access: bool,
recentPostsCache: {}, debug: bool,
system_language: str,
domainFull: str, person_cache: {},
signing_priv_key_pem: str) -> bool:
"""Returns true if the given post has attached image media
"""
if post_json_object['type'] == 'Announce':
blockedCache = {}
postJsonAnnounce = \
downloadAnnounce(session, base_dir, http_prefix,
nickname, domain, post_json_object,
__version__, translate,
yt_replace_domain,
twitter_replacement_domain,
allow_local_network_access,
recentPostsCache, debug,
system_language,
domainFull, person_cache,
signing_priv_key_pem,
blockedCache)
if postJsonAnnounce:
post_json_object = postJsonAnnounce
if post_json_object['type'] != 'Create':
return False
if not hasObjectDict(post_json_object):
return False
if post_json_object['object'].get('moderationStatus'):
return False
if post_json_object['object']['type'] != 'Note' and \
post_json_object['object']['type'] != 'Page' and \
post_json_object['object']['type'] != 'Event' and \
post_json_object['object']['type'] != 'Article':
return False
if not post_json_object['object'].get('attachment'):
return False
if not isinstance(post_json_object['object']['attachment'], list):
return False
for attach in post_json_object['object']['attachment']:
if attach.get('mediaType') and attach.get('url'):
if attach['mediaType'].startswith('image/') or \
attach['mediaType'].startswith('audio/') or \
attach['mediaType'].startswith('video/'):
return True
return False
def _addPostStringToTimeline(postStr: str, boxname: str,
postsInBox: [], boxActor: str) -> bool:
""" is this a valid timeline post?
"""
# must be a recognized ActivityPub type
if ('"Note"' in postStr or
'"EncryptedMessage"' in postStr or
'"Event"' in postStr or
'"Article"' in postStr or
'"Patch"' in postStr or
'"Announce"' in postStr or
('"Question"' in postStr and
('"Create"' in postStr or '"Update"' in postStr))):
if boxname == 'dm':
if '#Public' in postStr or '/followers' in postStr:
return False
elif boxname == 'tlreplies':
if boxActor not in postStr:
return False
elif (boxname == 'tlblogs' or
boxname == 'tlnews' or
boxname == 'tlfeatures'):
if '"Create"' not in postStr:
return False
if '"Article"' not in postStr:
return False
elif boxname == 'tlmedia':
if '"Create"' in postStr:
if ('mediaType' not in postStr or
('image/' not in postStr and
'video/' not in postStr and
'audio/' not in postStr)):
return False
# add the post to the dictionary
postsInBox.append(postStr)
return True
return False
def _addPostToTimeline(filePath: str, boxname: str,
postsInBox: [], boxActor: str) -> bool:
""" Reads a post from file and decides whether it is valid
"""
with open(filePath, 'r') as postFile:
postStr = postFile.read()
if filePath.endswith('.json'):
repliesFilename = filePath.replace('.json', '.replies')
if os.path.isfile(repliesFilename):
# append a replies identifier, which will later be removed
postStr += '<hasReplies>'
return _addPostStringToTimeline(postStr, boxname, postsInBox, boxActor)
return False
def removePostInteractions(post_json_object: {}, force: bool) -> bool:
""" Don't show likes, replies, bookmarks, DMs or shares (announces) to
unauthorized viewers. This makes the timeline less useful to
marketers and other surveillance-oriented organizations.
Returns False if this is a private post
"""
hasObject = False
if hasObjectDict(post_json_object):
hasObject = True
if hasObject:
postObj = post_json_object['object']
if not force:
# If not authorized and it's a private post
# then just don't show it within timelines
if not isPublicPost(post_json_object):
return False
else:
postObj = post_json_object
# clear the likes
if postObj.get('likes'):
postObj['likes'] = {
'items': []
}
# clear the reactions
if postObj.get('reactions'):
postObj['reactions'] = {
'items': []
}
# remove other collections
removeCollections = (
'replies', 'shares', 'bookmarks', 'ignores'
)
for removeName in removeCollections:
if postObj.get(removeName):
postObj[removeName] = {}
return True
def _passedNewswireVoting(newswire_votes_threshold: int,
base_dir: str, domain: str,
postFilename: str,
positive_voting: bool,
voting_time_mins: int) -> bool:
"""Returns true if the post has passed through newswire voting
"""
# apply votes within this timeline
if newswire_votes_threshold <= 0:
return True
# note that the presence of an arrival file also indicates
# that this post is moderated
arrivalDate = \
locateNewsArrival(base_dir, domain, postFilename)
if not arrivalDate:
return True
# how long has elapsed since this post arrived?
currDate = datetime.datetime.utcnow()
timeDiffMins = \
int((currDate - arrivalDate).total_seconds() / 60)
# has the voting time elapsed?
if timeDiffMins < voting_time_mins:
# voting is still happening, so don't add this
# post to the timeline
return False
# if there a votes file for this post?
votesFilename = \
locateNewsVotes(base_dir, domain, postFilename)
if not votesFilename:
return True
# load the votes file and count the votes
votesJson = loadJson(votesFilename, 0, 2)
if not votesJson:
return True
if not positive_voting:
if votesOnNewswireItem(votesJson) >= \
newswire_votes_threshold:
# Too many veto votes.
# Continue without incrementing
# the posts counter
return False
else:
if votesOnNewswireItem < \
newswire_votes_threshold:
# Not enough votes.
# Continue without incrementing
# the posts counter
return False
return True
def _createBoxIndexed(recentPostsCache: {},
session, base_dir: str, boxname: str,
nickname: str, domain: str, port: int, http_prefix: str,
itemsPerPage: int, headerOnly: bool, authorized: bool,
newswire_votes_threshold: int, positive_voting: bool,
voting_time_mins: int, pageNumber: int) -> {}:
"""Constructs the box feed for a person with the given nickname
"""
if not authorized or not pageNumber:
pageNumber = 1
if boxname != 'inbox' and boxname != 'dm' and \
boxname != 'tlreplies' and boxname != 'tlmedia' and \
boxname != 'tlblogs' and boxname != 'tlnews' and \
boxname != 'tlfeatures' and \
boxname != 'outbox' and boxname != 'tlbookmarks' and \
boxname != 'bookmarks':
print('ERROR: invalid boxname ' + boxname)
return None
# bookmarks and events timelines are like the inbox
# but have their own separate index
indexBoxName = boxname
timelineNickname = nickname
if boxname == "tlbookmarks":
boxname = "bookmarks"
indexBoxName = boxname
elif boxname == "tlfeatures":
boxname = "tlblogs"
indexBoxName = boxname
timelineNickname = 'news'
originalDomain = domain
domain = getFullDomain(domain, port)
boxActor = localActorUrl(http_prefix, nickname, domain)
pageStr = '?page=true'
if pageNumber:
if pageNumber < 1:
pageNumber = 1
try:
pageStr = '?page=' + str(pageNumber)
except BaseException:
print('EX: _createBoxIndexed ' +
'unable to convert page number to string')
pass
boxUrl = localActorUrl(http_prefix, nickname, domain) + '/' + boxname
boxHeader = {
'@context': 'https://www.w3.org/ns/activitystreams',
'first': boxUrl + '?page=true',
'id': boxUrl,
'last': boxUrl + '?page=true',
'totalItems': 0,
'type': 'OrderedCollection'
}
boxItems = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': boxUrl + pageStr,
'orderedItems': [
],
'partOf': boxUrl,
'type': 'OrderedCollectionPage'
}
postsInBox = []
postUrlsInBox = []
indexFilename = \
acctDir(base_dir, timelineNickname, originalDomain) + \
'/' + indexBoxName + '.index'
totalPostsCount = 0
postsAddedToTimeline = 0
if os.path.isfile(indexFilename):
with open(indexFilename, 'r') as indexFile:
postsAddedToTimeline = 0
while postsAddedToTimeline < itemsPerPage:
postFilename = indexFile.readline()
if not postFilename:
break
# Has this post passed through the newswire voting stage?
if not _passedNewswireVoting(newswire_votes_threshold,
base_dir, domain,
postFilename,
positive_voting,
voting_time_mins):
continue
# Skip through any posts previous to the current page
if totalPostsCount < int((pageNumber - 1) * itemsPerPage):
totalPostsCount += 1
continue
# if this is a full path then remove the directories
if '/' in postFilename:
postFilename = postFilename.split('/')[-1]
# filename of the post without any extension or path
# This should also correspond to any index entry in
# the posts cache
postUrl = \
postFilename.replace('\n', '').replace('\r', '')
postUrl = postUrl.replace('.json', '').strip()
if postUrl in postUrlsInBox:
continue
# is the post cached in memory?
if recentPostsCache.get('index'):
if postUrl in recentPostsCache['index']:
if recentPostsCache['json'].get(postUrl):
url = recentPostsCache['json'][postUrl]
if _addPostStringToTimeline(url,
boxname, postsInBox,
boxActor):
totalPostsCount += 1
postsAddedToTimeline += 1
postUrlsInBox.append(postUrl)
continue
else:
print('Post not added to timeline')
# read the post from file
fullPostFilename = \
locatePost(base_dir, nickname,
originalDomain, postUrl, False)
if fullPostFilename:
# has the post been rejected?
if os.path.isfile(fullPostFilename + '.reject'):
continue
if _addPostToTimeline(fullPostFilename, boxname,
postsInBox, boxActor):
postsAddedToTimeline += 1
totalPostsCount += 1
postUrlsInBox.append(postUrl)
else:
print('WARN: Unable to add post ' + postUrl +
' nickname ' + nickname +
' timeline ' + boxname)
else:
if timelineNickname != nickname:
# if this is the features timeline
fullPostFilename = \
locatePost(base_dir, timelineNickname,
originalDomain, postUrl, False)
if fullPostFilename:
if _addPostToTimeline(fullPostFilename, boxname,
postsInBox, boxActor):
postsAddedToTimeline += 1
totalPostsCount += 1
postUrlsInBox.append(postUrl)
else:
print('WARN: Unable to add features post ' +
postUrl + ' nickname ' + nickname +
' timeline ' + boxname)
else:
print('WARN: features timeline. ' +
'Unable to locate post ' + postUrl)
else:
print('WARN: Unable to locate post ' + postUrl +
' nickname ' + nickname)
if totalPostsCount < 3:
print('Posts added to json timeline ' + boxname + ': ' +
str(postsAddedToTimeline))
# Generate first and last entries within header
if totalPostsCount > 0:
lastPage = int(totalPostsCount / itemsPerPage)
if lastPage < 1:
lastPage = 1
boxHeader['last'] = \
localActorUrl(http_prefix, nickname, domain) + \
'/' + boxname + '?page=' + str(lastPage)
if headerOnly:
boxHeader['totalItems'] = len(postsInBox)
prevPageStr = 'true'
if pageNumber > 1:
prevPageStr = str(pageNumber - 1)
boxHeader['prev'] = \
localActorUrl(http_prefix, nickname, domain) + \
'/' + boxname + '?page=' + prevPageStr
nextPageStr = str(pageNumber + 1)
boxHeader['next'] = \
localActorUrl(http_prefix, nickname, domain) + \
'/' + boxname + '?page=' + nextPageStr
return boxHeader
for postStr in postsInBox:
# Check if the post has replies
hasReplies = False
if postStr.endswith('<hasReplies>'):
hasReplies = True
# remove the replies identifier
postStr = postStr.replace('<hasReplies>', '')
p = None
try:
p = json.loads(postStr)
except BaseException:
print('EX: _createBoxIndexed unable to load json ' + postStr)
continue
# Does this post have replies?
# This will be used to indicate that replies exist within the html
# created by individualPostAsHtml
p['hasReplies'] = hasReplies
if not authorized:
if not removePostInteractions(p, False):
continue
boxItems['orderedItems'].append(p)
return boxItems
def expireCache(base_dir: str, person_cache: {},
http_prefix: str, archiveDir: str,
recentPostsCache: {},
maxPostsInBox=32000):
"""Thread used to expire actors from the cache and archive old posts
"""
while True:
# once per day
time.sleep(60 * 60 * 24)
expirePersonCache(person_cache)
archivePosts(base_dir, http_prefix, archiveDir, recentPostsCache,
maxPostsInBox)
def archivePosts(base_dir: str, http_prefix: str, archiveDir: str,
recentPostsCache: {},
maxPostsInBox=32000) -> None:
"""Archives posts for all accounts
"""
if maxPostsInBox == 0:
return
if archiveDir:
if not os.path.isdir(archiveDir):
os.mkdir(archiveDir)
if archiveDir:
if not os.path.isdir(archiveDir + '/accounts'):
os.mkdir(archiveDir + '/accounts')
for subdir, dirs, files in os.walk(base_dir + '/accounts'):
for handle in dirs:
if '@' in handle:
nickname = handle.split('@')[0]
domain = handle.split('@')[1]
archiveSubdir = None
if archiveDir:
if not os.path.isdir(archiveDir + '/accounts/' + handle):
os.mkdir(archiveDir + '/accounts/' + handle)
if not os.path.isdir(archiveDir + '/accounts/' +
handle + '/inbox'):
os.mkdir(archiveDir + '/accounts/' +
handle + '/inbox')
if not os.path.isdir(archiveDir + '/accounts/' +
handle + '/outbox'):
os.mkdir(archiveDir + '/accounts/' +
handle + '/outbox')
archiveSubdir = archiveDir + '/accounts/' + \
handle + '/inbox'
archivePostsForPerson(http_prefix, nickname, domain, base_dir,
'inbox', archiveSubdir,
recentPostsCache, maxPostsInBox)
if archiveDir:
archiveSubdir = archiveDir + '/accounts/' + \
handle + '/outbox'
archivePostsForPerson(http_prefix, nickname, domain, base_dir,
'outbox', archiveSubdir,
recentPostsCache, maxPostsInBox)
break
def archivePostsForPerson(http_prefix: str, nickname: str, domain: str,
base_dir: str,
boxname: str, archiveDir: str,
recentPostsCache: {},
maxPostsInBox=32000) -> None:
"""Retain a maximum number of posts within the given box
Move any others to an archive directory
"""
if boxname != 'inbox' and boxname != 'outbox':
return
if archiveDir:
if not os.path.isdir(archiveDir):
os.mkdir(archiveDir)
boxDir = createPersonDir(nickname, domain, base_dir, boxname)
postsInBox = os.scandir(boxDir)
noOfPosts = 0
for f in postsInBox:
noOfPosts += 1
if noOfPosts <= maxPostsInBox:
print('Checked ' + str(noOfPosts) + ' ' + boxname +
' posts for ' + nickname + '@' + domain)
return
# remove entries from the index
handle = nickname + '@' + domain
indexFilename = base_dir + '/accounts/' + handle + '/' + boxname + '.index'
if os.path.isfile(indexFilename):
indexCtr = 0
# get the existing index entries as a string
newIndex = ''
with open(indexFilename, 'r') as indexFile:
for postId in indexFile:
newIndex += postId
indexCtr += 1
if indexCtr >= maxPostsInBox:
break
# save the new index file
if len(newIndex) > 0:
with open(indexFilename, 'w+') as indexFile:
indexFile.write(newIndex)
postsInBoxDict = {}
postsCtr = 0
postsInBox = os.scandir(boxDir)
for postFilename in postsInBox:
postFilename = postFilename.name
if not postFilename.endswith('.json'):
continue
# Time of file creation
fullFilename = os.path.join(boxDir, postFilename)
if os.path.isfile(fullFilename):
content = open(fullFilename).read()
if '"published":' in content:
publishedStr = content.split('"published":')[1]
if '"' in publishedStr:
publishedStr = publishedStr.split('"')[1]
if publishedStr.endswith('Z'):
postsInBoxDict[publishedStr] = postFilename
postsCtr += 1
noOfPosts = postsCtr
if noOfPosts <= maxPostsInBox:
print('Checked ' + str(noOfPosts) + ' ' + boxname +
' posts for ' + nickname + '@' + domain)
return
# sort the list in ascending order of date
postsInBoxSorted = \
OrderedDict(sorted(postsInBoxDict.items(), reverse=False))
# directory containing cached html posts
postCacheDir = boxDir.replace('/' + boxname, '/postcache')
removeCtr = 0
for publishedStr, postFilename in postsInBoxSorted.items():
filePath = os.path.join(boxDir, postFilename)
if not os.path.isfile(filePath):
continue
if archiveDir:
archivePath = os.path.join(archiveDir, postFilename)
os.rename(filePath, archivePath)
extensions = ('replies', 'votes', 'arrived', 'muted')
for ext in extensions:
extPath = filePath.replace('.json', '.' + ext)
if os.path.isfile(extPath):
os.rename(extPath,
archivePath.replace('.json', '.' + ext))
else:
extPath = filePath.replace('.json',
'.json.' + ext)
if os.path.isfile(extPath):
os.rename(extPath,
archivePath.replace('.json', '.json.' + ext))
else:
deletePost(base_dir, http_prefix, nickname, domain,
filePath, False, recentPostsCache)
# remove cached html posts
postCacheFilename = \
os.path.join(postCacheDir, postFilename).replace('.json', '.html')
if os.path.isfile(postCacheFilename):
try:
os.remove(postCacheFilename)
except OSError:
print('EX: archivePostsForPerson unable to delete ' +
postCacheFilename)
noOfPosts -= 1
removeCtr += 1
if noOfPosts <= maxPostsInBox:
break
if archiveDir:
print('Archived ' + str(removeCtr) + ' ' + boxname +
' posts for ' + nickname + '@' + domain)
else:
print('Removed ' + str(removeCtr) + ' ' + boxname +
' posts for ' + nickname + '@' + domain)
print(nickname + '@' + domain + ' has ' + str(noOfPosts) +
' in ' + boxname)
def getPublicPostsOfPerson(base_dir: str, nickname: str, domain: str,
raw: bool, simple: bool, proxy_type: str,
port: int, http_prefix: str,
debug: bool, project_version: str,
system_language: str,
signing_priv_key_pem: str,
originDomain: str) -> None:
""" This is really just for test purposes
"""
if debug:
if signing_priv_key_pem:
print('Signing key available')
else:
print('Signing key missing')
print('Starting new session for getting public posts')
session = createSession(proxy_type)
if not session:
if debug:
print('Session was not created')
return
person_cache = {}
cached_webfingers = {}
federationList = []
groupAccount = False
if nickname.startswith('!'):
nickname = nickname[1:]
groupAccount = True
domainFull = getFullDomain(domain, port)
handle = http_prefix + "://" + domainFull + "/@" + nickname
wfRequest = \
webfingerHandle(session, handle, http_prefix, cached_webfingers,
originDomain, project_version, debug, groupAccount,
signing_priv_key_pem)
if not wfRequest:
if debug:
print('No webfinger result was returned for ' + handle)
sys.exit()
if not isinstance(wfRequest, dict):
print('Webfinger for ' + handle + ' did not return a dict. ' +
str(wfRequest))
sys.exit()
if debug:
print('Getting the outbox for ' + handle)
(personUrl, pubKeyId, pubKey, personId, shaedInbox, avatarUrl,
displayName, _) = getPersonBox(signing_priv_key_pem,
originDomain,
base_dir, session, wfRequest,
person_cache,
project_version, http_prefix,
nickname, domain, 'outbox',
62524)
if debug:
print('Actor url: ' + str(personId))
if not personId:
return
max_mentions = 10
max_emoji = 10
maxAttachments = 5
_getPosts(session, personUrl, 30, max_mentions, max_emoji,
maxAttachments, federationList,
person_cache, raw, simple, debug,
project_version, http_prefix, originDomain, system_language,
signing_priv_key_pem)
def getPublicPostDomains(session, base_dir: str, nickname: str, domain: str,
originDomain: str,
proxy_type: str, port: int, http_prefix: str,
debug: bool, project_version: str,
wordFrequency: {}, domainList: [],
system_language: str,
signing_priv_key_pem: str) -> []:
""" Returns a list of domains referenced within public posts
"""
if not session:
session = createSession(proxy_type)
if not session:
return domainList
person_cache = {}
cached_webfingers = {}
federationList = []
domainFull = getFullDomain(domain, port)
handle = http_prefix + "://" + domainFull + "/@" + nickname
wfRequest = \
webfingerHandle(session, handle, http_prefix, cached_webfingers,
domain, project_version, debug, False,
signing_priv_key_pem)
if not wfRequest:
return domainList
if not isinstance(wfRequest, dict):
print('Webfinger for ' + handle + ' did not return a dict. ' +
str(wfRequest))
return domainList
(personUrl, pubKeyId, pubKey, personId, sharedInbox, avatarUrl,
displayName, _) = getPersonBox(signing_priv_key_pem,
originDomain,
base_dir, session, wfRequest,
person_cache,
project_version, http_prefix,
nickname, domain, 'outbox',
92522)
max_mentions = 99
max_emoji = 99
maxAttachments = 5
postDomains = \
getPostDomains(session, personUrl, 64, max_mentions, max_emoji,
maxAttachments, federationList,
person_cache, debug,
project_version, http_prefix, domain,
wordFrequency, domainList, system_language,
signing_priv_key_pem)
postDomains.sort()
return postDomains
def downloadFollowCollection(signing_priv_key_pem: str,
followType: str,
session, http_prefix: str,
actor: str, pageNumber: int,
noOfPages: int, debug: bool) -> []:
"""Returns a list of following/followers for the given actor
by downloading the json for their following/followers collection
"""
prof = 'https://www.w3.org/ns/activitystreams'
if '/channel/' not in actor or '/accounts/' not in actor:
acceptStr = \
'application/activity+json; ' + \
'profile="' + prof + '"'
sessionHeaders = {
'Accept': acceptStr
}
else:
acceptStr = \
'application/ld+json; ' + \
'profile="' + prof + '"'
sessionHeaders = {
'Accept': acceptStr
}
result = []
for pageCtr in range(noOfPages):
url = actor + '/' + followType + '?page=' + str(pageNumber + pageCtr)
followersJson = \
getJson(signing_priv_key_pem, session, url, sessionHeaders, None,
debug, __version__, http_prefix, None)
if followersJson:
if followersJson.get('orderedItems'):
for followerActor in followersJson['orderedItems']:
if followerActor not in result:
result.append(followerActor)
elif followersJson.get('items'):
for followerActor in followersJson['items']:
if followerActor not in result:
result.append(followerActor)
else:
break
else:
break
return result
def getPublicPostInfo(session, base_dir: str, nickname: str, domain: str,
originDomain: str,
proxy_type: str, port: int, http_prefix: str,
debug: bool, project_version: str,
wordFrequency: {}, system_language: str,
signing_priv_key_pem: str) -> []:
""" Returns a dict of domains referenced within public posts
"""
if not session:
session = createSession(proxy_type)
if not session:
return {}
person_cache = {}
cached_webfingers = {}
federationList = []
domainFull = getFullDomain(domain, port)
handle = http_prefix + "://" + domainFull + "/@" + nickname
wfRequest = \
webfingerHandle(session, handle, http_prefix, cached_webfingers,
domain, project_version, debug, False,
signing_priv_key_pem)
if not wfRequest:
return {}
if not isinstance(wfRequest, dict):
print('Webfinger for ' + handle + ' did not return a dict. ' +
str(wfRequest))
return {}
(personUrl, pubKeyId, pubKey, personId, sharedInbox, avatarUrl,
displayName, _) = getPersonBox(signing_priv_key_pem,
originDomain,
base_dir, session, wfRequest,
person_cache,
project_version, http_prefix,
nickname, domain, 'outbox',
13863)
max_mentions = 99
max_emoji = 99
maxAttachments = 5
maxPosts = 64
postDomains = \
getPostDomains(session, personUrl, maxPosts,
max_mentions, max_emoji,
maxAttachments, federationList,
person_cache, debug,
project_version, http_prefix, domain,
wordFrequency, [], system_language,
signing_priv_key_pem)
postDomains.sort()
domainsInfo = {}
for d in postDomains:
if not domainsInfo.get(d):
domainsInfo[d] = []
blockedPosts = \
_getPostsForBlockedDomains(base_dir, session, personUrl, maxPosts,
max_mentions,
max_emoji, maxAttachments,
federationList,
person_cache,
debug,
project_version, http_prefix,
domain, signing_priv_key_pem)
for blockedDomain, postUrlList in blockedPosts.items():
domainsInfo[blockedDomain] += postUrlList
return domainsInfo
def getPublicPostDomainsBlocked(session, base_dir: str,
nickname: str, domain: str,
proxy_type: str, port: int, http_prefix: str,
debug: bool, project_version: str,
wordFrequency: {}, domainList: [],
system_language: str,
signing_priv_key_pem: str) -> []:
""" Returns a list of domains referenced within public posts which
are globally blocked on this instance
"""
originDomain = domain
postDomains = \
getPublicPostDomains(session, base_dir, nickname, domain,
originDomain,
proxy_type, port, http_prefix,
debug, project_version,
wordFrequency, domainList, system_language,
signing_priv_key_pem)
if not postDomains:
return []
blockingFilename = base_dir + '/accounts/blocking.txt'
if not os.path.isfile(blockingFilename):
return []
# read the blocked domains as a single string
blockedStr = ''
with open(blockingFilename, 'r') as fp:
blockedStr = fp.read()
blockedDomains = []
for domainName in postDomains:
if '@' not in domainName:
continue
# get the domain after the @
domainName = domainName.split('@')[1].strip()
if isEvil(domainName):
blockedDomains.append(domainName)
continue
if domainName in blockedStr:
blockedDomains.append(domainName)
return blockedDomains
def _getNonMutualsOfPerson(base_dir: str,
nickname: str, domain: str) -> []:
"""Returns the followers who are not mutuals of a person
i.e. accounts which follow you but you don't follow them
"""
followers = \
getFollowersList(base_dir, nickname, domain, 'followers.txt')
following = \
getFollowersList(base_dir, nickname, domain, 'following.txt')
nonMutuals = []
for handle in followers:
if handle not in following:
nonMutuals.append(handle)
return nonMutuals
def checkDomains(session, base_dir: str,
nickname: str, domain: str,
proxy_type: str, port: int, http_prefix: str,
debug: bool, project_version: str,
maxBlockedDomains: int, singleCheck: bool,
system_language: str,
signing_priv_key_pem: str) -> None:
"""Checks follower accounts for references to globally blocked domains
"""
wordFrequency = {}
nonMutuals = _getNonMutualsOfPerson(base_dir, nickname, domain)
if not nonMutuals:
print('No non-mutual followers were found')
return
followerWarningFilename = base_dir + '/accounts/followerWarnings.txt'
updateFollowerWarnings = False
followerWarningStr = ''
if os.path.isfile(followerWarningFilename):
with open(followerWarningFilename, 'r') as fp:
followerWarningStr = fp.read()
if singleCheck:
# checks a single random non-mutual
index = random.randrange(0, len(nonMutuals))
handle = nonMutuals[index]
if '@' in handle:
nonMutualNickname = handle.split('@')[0]
nonMutualDomain = handle.split('@')[1].strip()
blockedDomains = \
getPublicPostDomainsBlocked(session, base_dir,
nonMutualNickname,
nonMutualDomain,
proxy_type, port, http_prefix,
debug, project_version,
wordFrequency, [],
system_language,
signing_priv_key_pem)
if blockedDomains:
if len(blockedDomains) > maxBlockedDomains:
followerWarningStr += handle + '\n'
updateFollowerWarnings = True
else:
# checks all non-mutuals
for handle in nonMutuals:
if '@' not in handle:
continue
if handle in followerWarningStr:
continue
nonMutualNickname = handle.split('@')[0]
nonMutualDomain = handle.split('@')[1].strip()
blockedDomains = \
getPublicPostDomainsBlocked(session, base_dir,
nonMutualNickname,
nonMutualDomain,
proxy_type, port, http_prefix,
debug, project_version,
wordFrequency, [],
system_language,
signing_priv_key_pem)
if blockedDomains:
print(handle)
for d in blockedDomains:
print(' ' + d)
if len(blockedDomains) > maxBlockedDomains:
followerWarningStr += handle + '\n'
updateFollowerWarnings = True
if updateFollowerWarnings and followerWarningStr:
with open(followerWarningFilename, 'w+') as fp:
fp.write(followerWarningStr)
if not singleCheck:
print(followerWarningStr)
def populateRepliesJson(base_dir: str, nickname: str, domain: str,
postRepliesFilename: str, authorized: bool,
repliesJson: {}) -> None:
pubStr = 'https://www.w3.org/ns/activitystreams#Public'
# populate the items list with replies
repliesBoxes = ('outbox', 'inbox')
with open(postRepliesFilename, 'r') as repliesFile:
for messageId in repliesFile:
replyFound = False
# examine inbox and outbox
for boxname in repliesBoxes:
messageId2 = messageId.replace('\n', '').replace('\r', '')
searchFilename = \
acctDir(base_dir, nickname, domain) + '/' + \
boxname + '/' + \
messageId2.replace('/', '#') + '.json'
if os.path.isfile(searchFilename):
if authorized or \
pubStr in open(searchFilename).read():
post_json_object = loadJson(searchFilename)
if post_json_object:
if post_json_object['object'].get('cc'):
pjo = post_json_object
if (authorized or
(pubStr in pjo['object']['to'] or
pubStr in pjo['object']['cc'])):
repliesJson['orderedItems'].append(pjo)
replyFound = True
else:
if authorized or \
pubStr in post_json_object['object']['to']:
pjo = post_json_object
repliesJson['orderedItems'].append(pjo)
replyFound = True
break
# if not in either inbox or outbox then examine the shared inbox
if not replyFound:
messageId2 = messageId.replace('\n', '').replace('\r', '')
searchFilename = \
base_dir + \
'/accounts/inbox@' + \
domain + '/inbox/' + \
messageId2.replace('/', '#') + '.json'
if os.path.isfile(searchFilename):
if authorized or \
pubStr in open(searchFilename).read():
# get the json of the reply and append it to
# the collection
post_json_object = loadJson(searchFilename)
if post_json_object:
if post_json_object['object'].get('cc'):
pjo = post_json_object
if (authorized or
(pubStr in pjo['object']['to'] or
pubStr in pjo['object']['cc'])):
pjo = post_json_object
repliesJson['orderedItems'].append(pjo)
else:
if authorized or \
pubStr in post_json_object['object']['to']:
pjo = post_json_object
repliesJson['orderedItems'].append(pjo)
def _rejectAnnounce(announceFilename: str,
base_dir: str, nickname: str, domain: str,
announcePostId: str, recentPostsCache: {}):
"""Marks an announce as rejected
"""
rejectPostId(base_dir, nickname, domain, announcePostId, recentPostsCache)
# reject the post referenced by the announce activity object
if not os.path.isfile(announceFilename + '.reject'):
with open(announceFilename + '.reject', 'w+') as rejectAnnounceFile:
rejectAnnounceFile.write('\n')
def downloadAnnounce(session, base_dir: str, http_prefix: str,
nickname: str, domain: str,
post_json_object: {}, project_version: str,
translate: {},
yt_replace_domain: str,
twitter_replacement_domain: str,
allow_local_network_access: bool,
recentPostsCache: {}, debug: bool,
system_language: str,
domainFull: str, person_cache: {},
signing_priv_key_pem: str,
blockedCache: {}) -> {}:
"""Download the post referenced by an announce
"""
if not post_json_object.get('object'):
return None
if not isinstance(post_json_object['object'], str):
return None
# ignore self-boosts
if post_json_object['actor'] in post_json_object['object']:
return None
# get the announced post
announceCacheDir = base_dir + '/cache/announce/' + nickname
if not os.path.isdir(announceCacheDir):
os.mkdir(announceCacheDir)
postId = None
if post_json_object.get('id'):
postId = removeIdEnding(post_json_object['id'])
announceFilename = \
announceCacheDir + '/' + \
post_json_object['object'].replace('/', '#') + '.json'
if os.path.isfile(announceFilename + '.reject'):
return None
if os.path.isfile(announceFilename):
if debug:
print('Reading cached Announce content for ' +
post_json_object['object'])
post_json_object = loadJson(announceFilename)
if post_json_object:
return post_json_object
else:
profileStr = 'https://www.w3.org/ns/activitystreams'
acceptStr = \
'application/activity+json; ' + \
'profile="' + profileStr + '"'
asHeader = {
'Accept': acceptStr
}
if '/channel/' in post_json_object['actor'] or \
'/accounts/' in post_json_object['actor']:
acceptStr = \
'application/ld+json; ' + \
'profile="' + profileStr + '"'
asHeader = {
'Accept': acceptStr
}
actorNickname = getNicknameFromActor(post_json_object['actor'])
actorDomain, actorPort = getDomainFromActor(post_json_object['actor'])
if not actorDomain:
print('Announce actor does not contain a ' +
'valid domain or port number: ' +
str(post_json_object['actor']))
return None
if isBlocked(base_dir, nickname, domain, actorNickname, actorDomain):
print('Announce download blocked actor: ' +
actorNickname + '@' + actorDomain)
return None
objectNickname = getNicknameFromActor(post_json_object['object'])
objectDomain, objectPort = \
getDomainFromActor(post_json_object['object'])
if not objectDomain:
print('Announce object does not contain a ' +
'valid domain or port number: ' +
str(post_json_object['object']))
return None
if isBlocked(base_dir, nickname, domain, objectNickname, objectDomain):
if objectNickname and objectDomain:
print('Announce download blocked object: ' +
objectNickname + '@' + objectDomain)
else:
print('Announce download blocked object: ' +
str(post_json_object['object']))
return None
if debug:
print('Downloading Announce content for ' +
post_json_object['object'])
announcedJson = \
getJson(signing_priv_key_pem, session,
post_json_object['object'],
asHeader, None, debug, project_version,
http_prefix, domain)
if not announcedJson:
return None
if not isinstance(announcedJson, dict):
print('WARN: announce json is not a dict - ' +
post_json_object['object'])
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
if not announcedJson.get('id'):
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
if not announcedJson.get('type'):
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
if announcedJson['type'] == 'Video':
convertedJson = \
convertVideoToNote(base_dir, nickname, domain,
system_language,
announcedJson, blockedCache)
if convertedJson:
announcedJson = convertedJson
if '/statuses/' not in announcedJson['id']:
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
if not hasUsersPath(announcedJson['id']):
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
if announcedJson['type'] != 'Note' and \
announcedJson['type'] != 'Page' and \
announcedJson['type'] != 'Article':
# You can only announce Note or Article types
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
if not announcedJson.get('content'):
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
if not announcedJson.get('published'):
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
if not validPostDate(announcedJson['published'], 90, debug):
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
if not understoodPostLanguage(base_dir, nickname, domain,
announcedJson, system_language,
http_prefix, domainFull,
person_cache):
return None
# Check the content of the announce
contentStr = announcedJson['content']
if dangerousMarkup(contentStr, allow_local_network_access):
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
if isFiltered(base_dir, nickname, domain, contentStr):
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
if invalidCiphertext(contentStr):
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
print('WARN: Invalid ciphertext within announce ' +
str(announcedJson))
return None
# remove any long words
contentStr = removeLongWords(contentStr, 40, [])
# Prevent the same word from being repeated many times
contentStr = limitRepeatedWords(contentStr, 6)
# remove text formatting, such as bold/italics
contentStr = removeTextFormatting(contentStr)
# set the content after santitization
announcedJson['content'] = contentStr
# wrap in create to be consistent with other posts
announcedJson = \
outboxMessageCreateWrap(http_prefix,
actorNickname, actorDomain, actorPort,
announcedJson)
if announcedJson['type'] != 'Create':
# Create wrap failed
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
# labelAccusatoryPost(post_json_object, translate)
# set the id to the original status
announcedJson['id'] = post_json_object['object']
announcedJson['object']['id'] = post_json_object['object']
# check that the repeat isn't for a blocked account
attributedNickname = \
getNicknameFromActor(announcedJson['object']['id'])
attributedDomain, attributedPort = \
getDomainFromActor(announcedJson['object']['id'])
if attributedNickname and attributedDomain:
attributedDomain = getFullDomain(attributedDomain, attributedPort)
if isBlocked(base_dir, nickname, domain,
attributedNickname, attributedDomain):
_rejectAnnounce(announceFilename,
base_dir, nickname, domain, postId,
recentPostsCache)
return None
post_json_object = announcedJson
replaceYouTube(post_json_object, yt_replace_domain, system_language)
replaceTwitter(post_json_object, twitter_replacement_domain,
system_language)
if saveJson(post_json_object, announceFilename):
return post_json_object
return None
def isMuted(base_dir: str, nickname: str, domain: str, postId: str,
conversationId: str) -> bool:
"""Returns true if the given post is muted
"""
if conversationId:
convMutedFilename = \
acctDir(base_dir, nickname, domain) + '/conversation/' + \
conversationId.replace('/', '#') + '.muted'
if os.path.isfile(convMutedFilename):
return True
postFilename = locatePost(base_dir, nickname, domain, postId)
if not postFilename:
return False
if os.path.isfile(postFilename + '.muted'):
return True
return False
def sendBlockViaServer(base_dir: str, session,
fromNickname: str, password: str,
fromDomain: str, fromPort: int,
http_prefix: str, blockedUrl: str,
cached_webfingers: {}, person_cache: {},
debug: bool, project_version: str,
signing_priv_key_pem: str) -> {}:
"""Creates a block via c2s
"""
if not session:
print('WARN: No session for sendBlockViaServer')
return 6
fromDomainFull = getFullDomain(fromDomain, fromPort)
blockActor = localActorUrl(http_prefix, fromNickname, fromDomainFull)
toUrl = 'https://www.w3.org/ns/activitystreams#Public'
ccUrl = blockActor + '/followers'
newBlockJson = {
"@context": "https://www.w3.org/ns/activitystreams",
'type': 'Block',
'actor': blockActor,
'object': blockedUrl,
'to': [toUrl],
'cc': [ccUrl]
}
handle = http_prefix + '://' + fromDomainFull + '/@' + fromNickname
# lookup the inbox for the To handle
wfRequest = webfingerHandle(session, handle, http_prefix,
cached_webfingers,
fromDomain, project_version, debug, False,
signing_priv_key_pem)
if not wfRequest:
if debug:
print('DEBUG: block webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: block Webfinger for ' + handle +
' did not return a dict. ' + str(wfRequest))
return 1
postToBox = 'outbox'
# get the actor inbox for the To handle
originDomain = fromDomain
(inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl,
displayName, _) = getPersonBox(signing_priv_key_pem,
originDomain,
base_dir, session, wfRequest,
person_cache,
project_version, http_prefix, fromNickname,
fromDomain, postToBox, 72652)
if not inboxUrl:
if debug:
print('DEBUG: block no ' + postToBox + ' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: block no actor was found for ' + handle)
return 4
authHeader = createBasicAuthHeader(fromNickname, password)
headers = {
'host': fromDomain,
'Content-type': 'application/json',
'Authorization': authHeader
}
postResult = postJson(http_prefix, fromDomainFull,
session, newBlockJson, [], inboxUrl,
headers, 30, True)
if not postResult:
print('WARN: block unable to post')
if debug:
print('DEBUG: c2s POST block success')
return newBlockJson
def sendMuteViaServer(base_dir: str, session,
fromNickname: str, password: str,
fromDomain: str, fromPort: int,
http_prefix: str, mutedUrl: str,
cached_webfingers: {}, person_cache: {},
debug: bool, project_version: str,
signing_priv_key_pem: str) -> {}:
"""Creates a mute via c2s
"""
if not session:
print('WARN: No session for sendMuteViaServer')
return 6
fromDomainFull = getFullDomain(fromDomain, fromPort)
actor = localActorUrl(http_prefix, fromNickname, fromDomainFull)
handle = replaceUsersWithAt(actor)
newMuteJson = {
"@context": "https://www.w3.org/ns/activitystreams",
'type': 'Ignore',
'actor': actor,
'to': [actor],
'object': mutedUrl
}
# lookup the inbox for the To handle
wfRequest = webfingerHandle(session, handle, http_prefix,
cached_webfingers,
fromDomain, project_version, debug, False,
signing_priv_key_pem)
if not wfRequest:
if debug:
print('DEBUG: mute webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: mute Webfinger for ' + handle +
' did not return a dict. ' + str(wfRequest))
return 1
postToBox = 'outbox'
# get the actor inbox for the To handle
originDomain = fromDomain
(inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl,
displayName, _) = getPersonBox(signing_priv_key_pem,
originDomain,
base_dir, session, wfRequest,
person_cache,
project_version, http_prefix, fromNickname,
fromDomain, postToBox, 72652)
if not inboxUrl:
if debug:
print('DEBUG: mute no ' + postToBox + ' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: mute no actor was found for ' + handle)
return 4
authHeader = createBasicAuthHeader(fromNickname, password)
headers = {
'host': fromDomain,
'Content-type': 'application/json',
'Authorization': authHeader
}
postResult = postJson(http_prefix, fromDomainFull,
session, newMuteJson, [], inboxUrl,
headers, 3, True)
if postResult is None:
print('WARN: mute unable to post')
if debug:
print('DEBUG: c2s POST mute success')
return newMuteJson
def sendUndoMuteViaServer(base_dir: str, session,
fromNickname: str, password: str,
fromDomain: str, fromPort: int,
http_prefix: str, mutedUrl: str,
cached_webfingers: {}, person_cache: {},
debug: bool, project_version: str,
signing_priv_key_pem: str) -> {}:
"""Undoes a mute via c2s
"""
if not session:
print('WARN: No session for sendUndoMuteViaServer')
return 6
fromDomainFull = getFullDomain(fromDomain, fromPort)
actor = localActorUrl(http_prefix, fromNickname, fromDomainFull)
handle = replaceUsersWithAt(actor)
undoMuteJson = {
"@context": "https://www.w3.org/ns/activitystreams",
'type': 'Undo',
'actor': actor,
'to': [actor],
'object': {
'type': 'Ignore',
'actor': actor,
'to': [actor],
'object': mutedUrl
}
}
# lookup the inbox for the To handle
wfRequest = webfingerHandle(session, handle, http_prefix,
cached_webfingers,
fromDomain, project_version, debug, False,
signing_priv_key_pem)
if not wfRequest:
if debug:
print('DEBUG: undo mute webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: undo mute Webfinger for ' + handle +
' did not return a dict. ' + str(wfRequest))
return 1
postToBox = 'outbox'
# get the actor inbox for the To handle
originDomain = fromDomain
(inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl,
displayName, _) = getPersonBox(signing_priv_key_pem,
originDomain,
base_dir, session, wfRequest,
person_cache,
project_version, http_prefix, fromNickname,
fromDomain, postToBox, 72652)
if not inboxUrl:
if debug:
print('DEBUG: undo mute no ' + postToBox +
' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: undo mute no actor was found for ' + handle)
return 4
authHeader = createBasicAuthHeader(fromNickname, password)
headers = {
'host': fromDomain,
'Content-type': 'application/json',
'Authorization': authHeader
}
postResult = postJson(http_prefix, fromDomainFull,
session, undoMuteJson, [], inboxUrl,
headers, 3, True)
if postResult is None:
print('WARN: undo mute unable to post')
if debug:
print('DEBUG: c2s POST undo mute success')
return undoMuteJson
def sendUndoBlockViaServer(base_dir: str, session,
fromNickname: str, password: str,
fromDomain: str, fromPort: int,
http_prefix: str, blockedUrl: str,
cached_webfingers: {}, person_cache: {},
debug: bool, project_version: str,
signing_priv_key_pem: str) -> {}:
"""Creates a block via c2s
"""
if not session:
print('WARN: No session for sendBlockViaServer')
return 6
fromDomainFull = getFullDomain(fromDomain, fromPort)
blockActor = localActorUrl(http_prefix, fromNickname, fromDomainFull)
toUrl = 'https://www.w3.org/ns/activitystreams#Public'
ccUrl = blockActor + '/followers'
newBlockJson = {
"@context": "https://www.w3.org/ns/activitystreams",
'type': 'Undo',
'actor': blockActor,
'object': {
'type': 'Block',
'actor': blockActor,
'object': blockedUrl,
'to': [toUrl],
'cc': [ccUrl]
}
}
handle = http_prefix + '://' + fromDomainFull + '/@' + fromNickname
# lookup the inbox for the To handle
wfRequest = webfingerHandle(session, handle, http_prefix,
cached_webfingers,
fromDomain, project_version, debug, False,
signing_priv_key_pem)
if not wfRequest:
if debug:
print('DEBUG: unblock webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: unblock webfinger for ' + handle +
' did not return a dict. ' + str(wfRequest))
return 1
postToBox = 'outbox'
# get the actor inbox for the To handle
originDomain = fromDomain
(inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl,
displayName, _) = getPersonBox(signing_priv_key_pem,
originDomain,
base_dir, session, wfRequest, person_cache,
project_version, http_prefix, fromNickname,
fromDomain, postToBox, 53892)
if not inboxUrl:
if debug:
print('DEBUG: unblock no ' + postToBox +
' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: unblock no actor was found for ' + handle)
return 4
authHeader = createBasicAuthHeader(fromNickname, password)
headers = {
'host': fromDomain,
'Content-type': 'application/json',
'Authorization': authHeader
}
postResult = postJson(http_prefix, fromDomainFull,
session, newBlockJson, [], inboxUrl,
headers, 30, True)
if not postResult:
print('WARN: unblock unable to post')
if debug:
print('DEBUG: c2s POST unblock success')
return newBlockJson
def postIsMuted(base_dir: str, nickname: str, domain: str,
post_json_object: {}, messageId: str) -> bool:
""" Returns true if the given post is muted
"""
isMuted = None
if 'muted' in post_json_object:
isMuted = post_json_object['muted']
if isMuted is True or isMuted is False:
return isMuted
isMuted = False
postDir = acctDir(base_dir, nickname, domain)
muteFilename = \
postDir + '/inbox/' + messageId.replace('/', '#') + '.json.muted'
if os.path.isfile(muteFilename):
isMuted = True
else:
muteFilename = \
postDir + '/outbox/' + messageId.replace('/', '#') + '.json.muted'
if os.path.isfile(muteFilename):
isMuted = True
else:
muteFilename = \
base_dir + '/accounts/cache/announce/' + nickname + \
'/' + messageId.replace('/', '#') + '.json.muted'
if os.path.isfile(muteFilename):
isMuted = True
return isMuted
def c2sBoxJson(base_dir: str, session,
nickname: str, password: str,
domain: str, port: int,
http_prefix: str,
boxName: str, pageNumber: int,
debug: bool, signing_priv_key_pem: str) -> {}:
"""C2S Authenticated GET of posts for a timeline
"""
if not session:
print('WARN: No session for c2sBoxJson')
return None
domainFull = getFullDomain(domain, port)
actor = localActorUrl(http_prefix, nickname, domainFull)
authHeader = createBasicAuthHeader(nickname, password)
profileStr = 'https://www.w3.org/ns/activitystreams'
headers = {
'host': domain,
'Content-type': 'application/json',
'Authorization': authHeader,
'Accept': 'application/ld+json; profile="' + profileStr + '"'
}
# GET json
url = actor + '/' + boxName + '?page=' + str(pageNumber)
boxJson = getJson(signing_priv_key_pem, session, url, headers, None,
debug, __version__, http_prefix, None)
if boxJson is not None and debug:
print('DEBUG: GET c2sBoxJson success')
return boxJson
def secondsBetweenPublished(published1: str, published2: str) -> int:
"""Returns the number of seconds between two published dates
"""
try:
published1Time = \
datetime.datetime.strptime(published1, '%Y-%m-%dT%H:%M:%SZ')
except BaseException:
print('EX: secondsBetweenPublished unable to parse date 1 ' +
str(published1))
return -1
try:
published2Time = \
datetime.datetime.strptime(published2, '%Y-%m-%dT%H:%M:%SZ')
except BaseException:
print('EX: secondsBetweenPublished unable to parse date 2 ' +
str(published2))
return -1
return (published2Time - published1Time).seconds
def editedPostFilename(base_dir: str, nickname: str, domain: str,
post_json_object: {}, debug: bool,
maxTimeDiffSeconds: int) -> str:
"""Returns the filename of the edited post
"""
if not hasObjectDict(post_json_object):
return ''
if not post_json_object.get('type'):
return ''
if not post_json_object['object'].get('type'):
return ''
if not post_json_object['object'].get('published'):
return ''
if not post_json_object['object'].get('id'):
return ''
if not post_json_object['object'].get('content'):
return ''
if not post_json_object['object'].get('attributedTo'):
return ''
if not isinstance(post_json_object['object']['attributedTo'], str):
return ''
actor = post_json_object['object']['attributedTo']
actorFilename = \
acctDir(base_dir, nickname, domain) + '/lastpost/' + \
actor.replace('/', '#')
if not os.path.isfile(actorFilename):
return ''
postId = removeIdEnding(post_json_object['object']['id'])
lastpostId = None
try:
with open(actorFilename, 'r') as fp:
lastpostId = fp.read()
except OSError:
print('EX: editedPostFilename unable to read ' + actorFilename)
return ''
if not lastpostId:
return ''
if lastpostId == postId:
return ''
lastpostFilename = \
locatePost(base_dir, nickname, domain, lastpostId, False)
if not lastpostFilename:
return ''
lastpostJson = loadJson(lastpostFilename, 0)
if not lastpostJson:
return ''
if not lastpostJson.get('type'):
return ''
if lastpostJson['type'] != post_json_object['type']:
return ''
if not lastpostJson['object'].get('type'):
return ''
if lastpostJson['object']['type'] != post_json_object['object']['type']:
return
if not lastpostJson['object'].get('published'):
return ''
if not lastpostJson['object'].get('id'):
return ''
if not lastpostJson['object'].get('content'):
return ''
if not lastpostJson['object'].get('attributedTo'):
return ''
if not isinstance(lastpostJson['object']['attributedTo'], str):
return ''
timeDiffSeconds = \
secondsBetweenPublished(lastpostJson['object']['published'],
post_json_object['object']['published'])
if timeDiffSeconds > maxTimeDiffSeconds:
return ''
if debug:
print(postId + ' might be an edit of ' + lastpostId)
if wordsSimilarity(lastpostJson['object']['content'],
post_json_object['object']['content'], 10) < 70:
return ''
print(postId + ' is an edit of ' + lastpostId)
return lastpostFilename
def getOriginalPostFromAnnounceUrl(announceUrl: str, base_dir: str,
nickname: str,
domain: str) -> (str, str, str):
"""From the url of an announce this returns the actor, url and
filename (if available) of the original post being announced
"""
postFilename = locatePost(base_dir, nickname, domain, announceUrl)
if not postFilename:
return None, None, None
announcePostJson = loadJson(postFilename, 0, 1)
if not announcePostJson:
return None, None, postFilename
if not announcePostJson.get('type'):
return None, None, postFilename
if announcePostJson['type'] != 'Announce':
return None, None, postFilename
if not announcePostJson.get('object'):
return None, None, postFilename
if not isinstance(announcePostJson['object'], str):
return None, None, postFilename
actor = url = None
# do we have the original post?
origPostId = announcePostJson['object']
origFilename = locatePost(base_dir, nickname, domain, origPostId)
if origFilename:
# we have the original post
origPostJson = loadJson(origFilename, 0, 1)
if origPostJson:
if hasObjectDict(origPostJson):
if origPostJson['object'].get('attributedTo'):
if isinstance(origPostJson['object']['attributedTo'], str):
actor = origPostJson['object']['attributedTo']
url = origPostId
elif origPostJson['object'].get('actor'):
actor = origPostJson['actor']
url = origPostId
else:
# we don't have the original post
if hasUsersPath(origPostId):
# get the actor from the original post url
origNick = getNicknameFromActor(origPostId)
origDomain, origPort = getDomainFromActor(origPostId)
if origNick and origDomain:
actor = \
origPostId.split('/' + origNick + '/')[0] + \
'/' + origNick
url = origPostId
return actor, url, origFilename