epicyon/follow.py

1590 lines
61 KiB
Python

__filename__ = "follow.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@libreserver.org"
__status__ = "Production"
__module_group__ = "ActivityPub"
from pprint import pprint
import os
from utils import hasObjectStringObject
from utils import hasObjectStringType
from utils import hasActor
from utils import removeDomainPort
from utils import hasUsersPath
from utils import getFullDomain
from utils import isSystemAccount
from utils import getFollowersList
from utils import validNickname
from utils import domainPermitted
from utils import getDomainFromActor
from utils import getNicknameFromActor
from utils import getStatusNumber
from utils import followPerson
from posts import sendSignedJson
from posts import getPersonBox
from utils import loadJson
from utils import saveJson
from utils import isAccountDir
from utils import getUserPaths
from utils import acctDir
from utils import hasGroupType
from utils import isGroupAccount
from utils import localActorUrl
from acceptreject import createAccept
from acceptreject import createReject
from webfinger import webfingerHandle
from auth import createBasicAuthHeader
from session import getJson
from session import postJson
from cache import getPersonPubKey
def createInitialLastSeen(baseDir: str, httpPrefix: str) -> None:
"""Creates initial lastseen files for all follows.
The lastseen files are used to generate the Zzz icons on
follows/following lists on the profile screen.
"""
for subdir, dirs, files in os.walk(baseDir + '/accounts'):
for acct in dirs:
if not isAccountDir(acct):
continue
accountDir = os.path.join(baseDir + '/accounts', acct)
followingFilename = accountDir + '/following.txt'
if not os.path.isfile(followingFilename):
continue
lastSeenDir = accountDir + '/lastseen'
if not os.path.isdir(lastSeenDir):
os.mkdir(lastSeenDir)
with open(followingFilename, 'r') as fp:
followingHandles = fp.readlines()
for handle in followingHandles:
if '#' in handle:
continue
if '@' not in handle:
continue
handle = handle.replace('\n', '')
nickname = handle.split('@')[0]
domain = handle.split('@')[1]
if nickname.startswith('!'):
nickname = nickname[1:]
actor = localActorUrl(httpPrefix, nickname, domain)
lastSeenFilename = \
lastSeenDir + '/' + actor.replace('/', '#') + '.txt'
if not os.path.isfile(lastSeenFilename):
with open(lastSeenFilename, 'w+') as fp:
fp.write(str(100))
break
def _preApprovedFollower(baseDir: str,
nickname: str, domain: str,
approveHandle: str) -> bool:
"""Is the given handle an already manually approved follower?
"""
handle = nickname + '@' + domain
accountDir = baseDir + '/accounts/' + handle
approvedFilename = accountDir + '/approved.txt'
if os.path.isfile(approvedFilename):
if approveHandle in open(approvedFilename).read():
return True
return False
def _removeFromFollowBase(baseDir: str,
nickname: str, domain: str,
acceptOrDenyHandle: str, followFile: str,
debug: bool) -> None:
"""Removes a handle/actor from follow requests or rejects file
"""
handle = nickname + '@' + domain
accountsDir = baseDir + '/accounts/' + handle
approveFollowsFilename = accountsDir + '/' + followFile + '.txt'
if not os.path.isfile(approveFollowsFilename):
if debug:
print('WARN: Approve follow requests file ' +
approveFollowsFilename + ' not found')
return
acceptDenyActor = None
if acceptOrDenyHandle not in open(approveFollowsFilename).read():
# is this stored in the file as an actor rather than a handle?
acceptDenyNickname = acceptOrDenyHandle.split('@')[0]
acceptDenyDomain = acceptOrDenyHandle.split('@')[1]
# for each possible users path construct an actor and
# check if it exists in teh file
usersPaths = getUserPaths()
actorFound = False
for usersName in usersPaths:
acceptDenyActor = \
'://' + acceptDenyDomain + usersName + acceptDenyNickname
if acceptDenyActor in open(approveFollowsFilename).read():
actorFound = True
break
if not actorFound:
return
with open(approveFollowsFilename + '.new', 'w+') as approvefilenew:
with open(approveFollowsFilename, 'r') as approvefile:
if not acceptDenyActor:
for approveHandle in approvefile:
if not approveHandle.startswith(acceptOrDenyHandle):
approvefilenew.write(approveHandle)
else:
for approveHandle in approvefile:
if acceptDenyActor not in approveHandle:
approvefilenew.write(approveHandle)
os.rename(approveFollowsFilename + '.new', approveFollowsFilename)
def removeFromFollowRequests(baseDir: str,
nickname: str, domain: str,
denyHandle: str, debug: bool) -> None:
"""Removes a handle from follow requests
"""
_removeFromFollowBase(baseDir, nickname, domain,
denyHandle, 'followrequests', debug)
def _removeFromFollowRejects(baseDir: str,
nickname: str, domain: str,
acceptHandle: str, debug: bool) -> None:
"""Removes a handle from follow rejects
"""
_removeFromFollowBase(baseDir, nickname, domain,
acceptHandle, 'followrejects', debug)
def isFollowingActor(baseDir: str,
nickname: str, domain: str, actor: str) -> bool:
"""Is the given nickname following the given actor?
The actor can also be a handle: nickname@domain
"""
domain = removeDomainPort(domain)
handle = nickname + '@' + domain
if not os.path.isdir(baseDir + '/accounts/' + handle):
return False
followingFile = baseDir + '/accounts/' + handle + '/following.txt'
if not os.path.isfile(followingFile):
return False
if actor.lower() in open(followingFile).read().lower():
return True
followingNickname = getNicknameFromActor(actor)
if not followingNickname:
print('WARN: unable to find nickname in ' + actor)
return False
followingDomain, followingPort = getDomainFromActor(actor)
followingHandle = \
getFullDomain(followingNickname + '@' + followingDomain, followingPort)
if followingHandle.lower() in open(followingFile).read().lower():
return True
return False
def getMutualsOfPerson(baseDir: str,
nickname: str, domain: str) -> []:
"""Returns the mutuals of a person
i.e. accounts which they follow and which also follow back
"""
followers = \
getFollowersList(baseDir, nickname, domain, 'followers.txt')
following = \
getFollowersList(baseDir, nickname, domain, 'following.txt')
mutuals = []
for handle in following:
if handle in followers:
mutuals.append(handle)
return mutuals
def followerOfPerson(baseDir: str, nickname: str, domain: str,
followerNickname: str, followerDomain: str,
federationList: [], debug: bool,
groupAccount: bool) -> bool:
"""Adds a follower of the given person
"""
return followPerson(baseDir, nickname, domain,
followerNickname, followerDomain,
federationList, debug, groupAccount, 'followers.txt')
def getFollowerDomains(baseDir: str, nickname: str, domain: str) -> []:
"""Returns a list of domains for followers
"""
domain = removeDomainPort(domain)
followersFile = acctDir(baseDir, nickname, domain) + '/followers.txt'
if not os.path.isfile(followersFile):
return []
lines = []
with open(followersFile, 'r') as fpFollowers:
lines = fpFollowers.readlines()
domainsList = []
for handle in lines:
handle = handle.replace('\n', '')
followerDomain, _ = getDomainFromActor(handle)
if not followerDomain:
continue
if followerDomain not in domainsList:
domainsList.append(followerDomain)
return domainsList
def isFollowerOfPerson(baseDir: str, nickname: str, domain: str,
followerNickname: str, followerDomain: str) -> bool:
"""is the given nickname a follower of followerNickname?
"""
if not followerDomain:
print('No followerDomain')
return False
if not followerNickname:
print('No followerNickname for ' + followerDomain)
return False
domain = removeDomainPort(domain)
followersFile = acctDir(baseDir, nickname, domain) + '/followers.txt'
if not os.path.isfile(followersFile):
return False
handle = followerNickname + '@' + followerDomain
alreadyFollowing = False
followersStr = ''
with open(followersFile, 'r') as fpFollowers:
followersStr = fpFollowers.read()
if handle in followersStr:
alreadyFollowing = True
else:
paths = getUserPaths()
for userPath in paths:
url = '://' + followerDomain + userPath + followerNickname
if url in followersStr:
alreadyFollowing = True
break
return alreadyFollowing
def unfollowAccount(baseDir: str, nickname: str, domain: str,
followNickname: str, followDomain: str,
debug: bool, groupAccount: bool,
followFile: str = 'following.txt') -> bool:
"""Removes a person to the follow list
"""
domain = removeDomainPort(domain)
handle = nickname + '@' + domain
handleToUnfollow = followNickname + '@' + followDomain
if groupAccount:
handleToUnfollow = '!' + handleToUnfollow
if not os.path.isdir(baseDir + '/accounts'):
os.mkdir(baseDir + '/accounts')
if not os.path.isdir(baseDir + '/accounts/' + handle):
os.mkdir(baseDir + '/accounts/' + handle)
filename = baseDir + '/accounts/' + handle + '/' + followFile
if not os.path.isfile(filename):
if debug:
print('DEBUG: follow file ' + filename + ' was not found')
return False
handleToUnfollowLower = handleToUnfollow.lower()
if handleToUnfollowLower not in open(filename).read().lower():
if debug:
print('DEBUG: handle to unfollow ' + handleToUnfollow +
' is not in ' + filename)
return
with open(filename, 'r') as f:
lines = f.readlines()
with open(filename, 'w+') as f:
for line in lines:
checkHandle = line.strip("\n").strip("\r").lower()
if checkHandle != handleToUnfollowLower and \
checkHandle != '!' + handleToUnfollowLower:
f.write(line)
# write to an unfollowed file so that if a follow accept
# later arrives then it can be ignored
unfollowedFilename = baseDir + '/accounts/' + handle + '/unfollowed.txt'
if os.path.isfile(unfollowedFilename):
if handleToUnfollowLower not in \
open(unfollowedFilename).read().lower():
with open(unfollowedFilename, 'a+') as f:
f.write(handleToUnfollow + '\n')
else:
with open(unfollowedFilename, 'w+') as f:
f.write(handleToUnfollow + '\n')
return True
def unfollowerOfAccount(baseDir: str, nickname: str, domain: str,
followerNickname: str, followerDomain: str,
debug: bool, groupAccount: bool) -> bool:
"""Remove a follower of a person
"""
return unfollowAccount(baseDir, nickname, domain,
followerNickname, followerDomain,
debug, groupAccount, 'followers.txt')
def clearFollows(baseDir: str, nickname: str, domain: str,
followFile: str = 'following.txt') -> None:
"""Removes all follows
"""
handle = nickname + '@' + domain
if not os.path.isdir(baseDir + '/accounts'):
os.mkdir(baseDir + '/accounts')
if not os.path.isdir(baseDir + '/accounts/' + handle):
os.mkdir(baseDir + '/accounts/' + handle)
filename = baseDir + '/accounts/' + handle + '/' + followFile
if os.path.isfile(filename):
try:
os.remove(filename)
except BaseException:
print('EX: clearFollows unable to delete ' + filename)
pass
def clearFollowers(baseDir: str, nickname: str, domain: str) -> None:
"""Removes all followers
"""
clearFollows(baseDir, nickname, domain, 'followers.txt')
def _getNoOfFollows(baseDir: str, nickname: str, domain: str,
authenticated: bool,
followFile='following.txt') -> int:
"""Returns the number of follows or followers
"""
# only show number of followers to authenticated
# account holders
# if not authenticated:
# return 9999
handle = nickname + '@' + domain
filename = baseDir + '/accounts/' + handle + '/' + followFile
if not os.path.isfile(filename):
return 0
ctr = 0
with open(filename, 'r') as f:
lines = f.readlines()
for line in lines:
if '#' in line:
continue
if '@' in line and \
'.' in line and \
not line.startswith('http'):
ctr += 1
elif ((line.startswith('http') or
line.startswith('hyper')) and
hasUsersPath(line)):
ctr += 1
return ctr
def _getNoOfFollowers(baseDir: str,
nickname: str, domain: str, authenticated: bool) -> int:
"""Returns the number of followers of the given person
"""
return _getNoOfFollows(baseDir, nickname, domain,
authenticated, 'followers.txt')
def getFollowingFeed(baseDir: str, domain: str, port: int, path: str,
httpPrefix: str, authorized: bool,
followsPerPage=12,
followFile='following') -> {}:
"""Returns the following and followers feeds from GET requests.
This accesses the following.txt or followers.txt and builds a collection.
"""
# Show a small number of follows to non-authorized viewers
if not authorized:
followsPerPage = 6
if '/' + followFile not in path:
return None
# handle page numbers
headerOnly = True
pageNumber = None
if '?page=' in path:
pageNumber = path.split('?page=')[1]
if pageNumber == 'true' or not authorized:
pageNumber = 1
else:
try:
pageNumber = int(pageNumber)
except BaseException:
print('EX: getFollowingFeed unable to convert to int ' +
str(pageNumber))
pass
path = path.split('?page=')[0]
headerOnly = False
if not path.endswith('/' + followFile):
return None
nickname = None
if path.startswith('/users/'):
nickname = path.replace('/users/', '', 1).replace('/' + followFile, '')
if path.startswith('/@'):
nickname = path.replace('/@', '', 1).replace('/' + followFile, '')
if not nickname:
return None
if not validNickname(domain, nickname):
return None
domain = getFullDomain(domain, port)
if headerOnly:
firstStr = \
localActorUrl(httpPrefix, nickname, domain) + \
'/' + followFile + '?page=1'
idStr = \
localActorUrl(httpPrefix, nickname, domain) + '/' + followFile
totalStr = \
_getNoOfFollows(baseDir, nickname, domain, authorized)
following = {
'@context': 'https://www.w3.org/ns/activitystreams',
'first': firstStr,
'id': idStr,
'totalItems': totalStr,
'type': 'OrderedCollection'
}
return following
if not pageNumber:
pageNumber = 1
nextPageNumber = int(pageNumber + 1)
idStr = \
localActorUrl(httpPrefix, nickname, domain) + \
'/' + followFile + '?page=' + str(pageNumber)
partOfStr = \
localActorUrl(httpPrefix, nickname, domain) + '/' + followFile
following = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': idStr,
'orderedItems': [],
'partOf': partOfStr,
'totalItems': 0,
'type': 'OrderedCollectionPage'
}
handleDomain = domain
handleDomain = removeDomainPort(handleDomain)
handle = nickname + '@' + handleDomain
filename = baseDir + '/accounts/' + handle + '/' + followFile + '.txt'
if not os.path.isfile(filename):
return following
currPage = 1
pageCtr = 0
totalCtr = 0
with open(filename, 'r') as f:
lines = f.readlines()
for line in lines:
if '#' not in line:
if '@' in line and not line.startswith('http'):
# nickname@domain
pageCtr += 1
totalCtr += 1
if currPage == pageNumber:
line2 = \
line.lower().replace('\n', '').replace('\r', '')
nick = line2.split('@')[0]
dom = line2.split('@')[1]
if not nick.startswith('!'):
# person actor
url = localActorUrl(httpPrefix, nick, dom)
else:
# group actor
url = httpPrefix + '://' + dom + '/c/' + nick
following['orderedItems'].append(url)
elif ((line.startswith('http') or
line.startswith('hyper')) and
hasUsersPath(line)):
# https://domain/users/nickname
pageCtr += 1
totalCtr += 1
if currPage == pageNumber:
appendStr = \
line.lower().replace('\n', '').replace('\r', '')
following['orderedItems'].append(appendStr)
if pageCtr >= followsPerPage:
pageCtr = 0
currPage += 1
following['totalItems'] = totalCtr
lastPage = int(totalCtr / followsPerPage)
if lastPage < 1:
lastPage = 1
if nextPageNumber > lastPage:
following['next'] = \
localActorUrl(httpPrefix, nickname, domain) + \
'/' + followFile + '?page=' + str(lastPage)
return following
def _followApprovalRequired(baseDir: str, nicknameToFollow: str,
domainToFollow: str, debug: bool,
followRequestHandle: str) -> bool:
""" Returns the policy for follower approvals
"""
# has this handle already been manually approved?
if _preApprovedFollower(baseDir, nicknameToFollow, domainToFollow,
followRequestHandle):
return False
manuallyApproveFollows = False
domainToFollow = removeDomainPort(domainToFollow)
actorFilename = baseDir + '/accounts/' + \
nicknameToFollow + '@' + domainToFollow + '.json'
if os.path.isfile(actorFilename):
actor = loadJson(actorFilename)
if actor:
if actor.get('manuallyApprovesFollowers'):
manuallyApproveFollows = actor['manuallyApprovesFollowers']
else:
if debug:
print(nicknameToFollow + '@' + domainToFollow +
' automatically approves followers')
else:
if debug:
print('DEBUG: Actor file not found: ' + actorFilename)
return manuallyApproveFollows
def _noOfFollowRequests(baseDir: str,
nicknameToFollow: str, domainToFollow: str,
nickname: str, domain: str, fromPort: int,
followType: str) -> int:
"""Returns the current number of follow requests
"""
accountsDir = baseDir + '/accounts/' + \
nicknameToFollow + '@' + domainToFollow
approveFollowsFilename = accountsDir + '/followrequests.txt'
if not os.path.isfile(approveFollowsFilename):
return 0
ctr = 0
with open(approveFollowsFilename, 'r') as f:
lines = f.readlines()
if followType == "onion":
for fileLine in lines:
if '.onion' in fileLine:
ctr += 1
elif followType == "i2p":
for fileLine in lines:
if '.i2p' in fileLine:
ctr += 1
else:
return len(lines)
return ctr
def _storeFollowRequest(baseDir: str,
nicknameToFollow: str, domainToFollow: str, port: int,
nickname: str, domain: str, fromPort: int,
followJson: {},
debug: bool, personUrl: str,
groupAccount: bool) -> bool:
"""Stores the follow request for later use
"""
accountsDir = baseDir + '/accounts/' + \
nicknameToFollow + '@' + domainToFollow
if not os.path.isdir(accountsDir):
return False
domainFull = getFullDomain(domain, fromPort)
approveHandle = getFullDomain(nickname + '@' + domain, fromPort)
if groupAccount:
approveHandle = '!' + approveHandle
followersFilename = accountsDir + '/followers.txt'
if os.path.isfile(followersFilename):
alreadyFollowing = False
followersStr = ''
with open(followersFilename, 'r') as fpFollowers:
followersStr = fpFollowers.read()
if approveHandle in followersStr:
alreadyFollowing = True
else:
usersPaths = getUserPaths()
for possibleUsersPath in usersPaths:
url = '://' + domainFull + possibleUsersPath + nickname
if url in followersStr:
alreadyFollowing = True
break
if alreadyFollowing:
if debug:
print('DEBUG: ' +
nicknameToFollow + '@' + domainToFollow +
' already following ' + approveHandle)
return True
# should this follow be denied?
denyFollowsFilename = accountsDir + '/followrejects.txt'
if os.path.isfile(denyFollowsFilename):
if approveHandle in open(denyFollowsFilename).read():
removeFromFollowRequests(baseDir, nicknameToFollow,
domainToFollow, approveHandle, debug)
print(approveHandle + ' was already denied as a follower of ' +
nicknameToFollow)
return True
# add to a file which contains a list of requests
approveFollowsFilename = accountsDir + '/followrequests.txt'
# store either nick@domain or the full person/actor url
approveHandleStored = approveHandle
if '/users/' not in personUrl:
approveHandleStored = personUrl
if groupAccount:
approveHandle = '!' + approveHandle
if os.path.isfile(approveFollowsFilename):
if approveHandle not in open(approveFollowsFilename).read():
with open(approveFollowsFilename, 'a+') as fp:
fp.write(approveHandleStored + '\n')
else:
if debug:
print('DEBUG: ' + approveHandleStored +
' is already awaiting approval')
else:
with open(approveFollowsFilename, 'w+') as fp:
fp.write(approveHandleStored + '\n')
# store the follow request in its own directory
# We don't rely upon the inbox because items in there could expire
requestsDir = accountsDir + '/requests'
if not os.path.isdir(requestsDir):
os.mkdir(requestsDir)
followActivityfilename = requestsDir + '/' + approveHandle + '.follow'
return saveJson(followJson, followActivityfilename)
def receiveFollowRequest(session, baseDir: str, httpPrefix: str,
port: int, sendThreads: [], postLog: [],
cachedWebfingers: {}, personCache: {},
messageJson: {}, federationList: [],
debug: bool, projectVersion: str,
maxFollowers: int, onionDomain: str,
signingPrivateKeyPem: str) -> bool:
"""Receives a follow request within the POST section of HTTPServer
"""
if not messageJson['type'].startswith('Follow'):
if not messageJson['type'].startswith('Join'):
return False
print('Receiving follow request')
if not hasActor(messageJson, debug):
return False
if not hasUsersPath(messageJson['actor']):
if debug:
print('DEBUG: users/profile/accounts/channel missing from actor')
return False
domain, tempPort = getDomainFromActor(messageJson['actor'])
fromPort = port
domainFull = getFullDomain(domain, tempPort)
if tempPort:
fromPort = tempPort
if not domainPermitted(domain, federationList):
if debug:
print('DEBUG: follower from domain not permitted - ' + domain)
return False
nickname = getNicknameFromActor(messageJson['actor'])
if not nickname:
# single user instance
nickname = 'dev'
if debug:
print('DEBUG: follow request does not contain a ' +
'nickname. Assuming single user instance.')
if not messageJson.get('to'):
messageJson['to'] = messageJson['object']
if not hasUsersPath(messageJson['object']):
if debug:
print('DEBUG: users/profile/channel/accounts ' +
'not found within object')
return False
domainToFollow, tempPort = getDomainFromActor(messageJson['object'])
if not domainPermitted(domainToFollow, federationList):
if debug:
print('DEBUG: follow domain not permitted ' + domainToFollow)
return True
domainToFollowFull = getFullDomain(domainToFollow, tempPort)
nicknameToFollow = getNicknameFromActor(messageJson['object'])
if not nicknameToFollow:
if debug:
print('DEBUG: follow request does not contain a ' +
'nickname for the account followed')
return True
if isSystemAccount(nicknameToFollow):
if debug:
print('DEBUG: Cannot follow system account - ' +
nicknameToFollow)
return True
if maxFollowers > 0:
if _getNoOfFollowers(baseDir,
nicknameToFollow, domainToFollow,
True) > maxFollowers:
print('WARN: ' + nicknameToFollow +
' has reached their maximum number of followers')
return True
handleToFollow = nicknameToFollow + '@' + domainToFollow
if domainToFollow == domain:
if not os.path.isdir(baseDir + '/accounts/' + handleToFollow):
if debug:
print('DEBUG: followed account not found - ' +
baseDir + '/accounts/' + handleToFollow)
return True
if isFollowerOfPerson(baseDir,
nicknameToFollow, domainToFollowFull,
nickname, domainFull):
if debug:
print('DEBUG: ' + nickname + '@' + domain +
' is already a follower of ' +
nicknameToFollow + '@' + domainToFollow)
return True
# what is the followers policy?
approveHandle = nickname + '@' + domainFull
if _followApprovalRequired(baseDir, nicknameToFollow,
domainToFollow, debug, approveHandle):
print('Follow approval is required')
if domain.endswith('.onion'):
if _noOfFollowRequests(baseDir,
nicknameToFollow, domainToFollow,
nickname, domain, fromPort,
'onion') > 5:
print('Too many follow requests from onion addresses')
return False
elif domain.endswith('.i2p'):
if _noOfFollowRequests(baseDir,
nicknameToFollow, domainToFollow,
nickname, domain, fromPort,
'i2p') > 5:
print('Too many follow requests from i2p addresses')
return False
else:
if _noOfFollowRequests(baseDir,
nicknameToFollow, domainToFollow,
nickname, domain, fromPort,
'') > 10:
print('Too many follow requests')
return False
# Get the actor for the follower and add it to the cache.
# Getting their public key has the same result
if debug:
print('Obtaining the following actor: ' + messageJson['actor'])
if not getPersonPubKey(baseDir, session, messageJson['actor'],
personCache, debug, projectVersion,
httpPrefix, domainToFollow, onionDomain,
signingPrivateKeyPem):
if debug:
print('Unable to obtain following actor: ' +
messageJson['actor'])
groupAccount = \
hasGroupType(baseDir, messageJson['actor'], personCache)
if groupAccount and isGroupAccount(baseDir, nickname, domain):
print('Group cannot follow a group')
return False
print('Storing follow request for approval')
return _storeFollowRequest(baseDir,
nicknameToFollow, domainToFollow, port,
nickname, domain, fromPort,
messageJson, debug, messageJson['actor'],
groupAccount)
else:
print('Follow request does not require approval ' + approveHandle)
# update the followers
accountToBeFollowed = \
acctDir(baseDir, nicknameToFollow, domainToFollow)
if os.path.isdir(accountToBeFollowed):
followersFilename = accountToBeFollowed + '/followers.txt'
# for actors which don't follow the mastodon
# /users/ path convention store the full actor
if '/users/' not in messageJson['actor']:
approveHandle = messageJson['actor']
# Get the actor for the follower and add it to the cache.
# Getting their public key has the same result
if debug:
print('Obtaining the following actor: ' + messageJson['actor'])
if not getPersonPubKey(baseDir, session, messageJson['actor'],
personCache, debug, projectVersion,
httpPrefix, domainToFollow, onionDomain,
signingPrivateKeyPem):
if debug:
print('Unable to obtain following actor: ' +
messageJson['actor'])
print('Updating followers file: ' +
followersFilename + ' adding ' + approveHandle)
if os.path.isfile(followersFilename):
if approveHandle not in open(followersFilename).read():
groupAccount = \
hasGroupType(baseDir,
messageJson['actor'], personCache)
if debug:
print(approveHandle + ' / ' + messageJson['actor'] +
' is Group: ' + str(groupAccount))
if groupAccount and \
isGroupAccount(baseDir, nickname, domain):
print('Group cannot follow a group')
return False
try:
with open(followersFilename, 'r+') as followersFile:
content = followersFile.read()
if approveHandle + '\n' not in content:
followersFile.seek(0, 0)
if not groupAccount:
followersFile.write(approveHandle +
'\n' + content)
else:
followersFile.write('!' + approveHandle +
'\n' + content)
except Exception as e:
print('WARN: ' +
'Failed to write entry to followers file ' +
str(e))
else:
with open(followersFilename, 'w+') as followersFile:
followersFile.write(approveHandle + '\n')
print('Beginning follow accept')
return followedAccountAccepts(session, baseDir, httpPrefix,
nicknameToFollow, domainToFollow, port,
nickname, domain, fromPort,
messageJson['actor'], federationList,
messageJson, sendThreads, postLog,
cachedWebfingers, personCache,
debug, projectVersion, True,
signingPrivateKeyPem)
def followedAccountAccepts(session, baseDir: str, httpPrefix: str,
nicknameToFollow: str, domainToFollow: str,
port: int,
nickname: str, domain: str, fromPort: int,
personUrl: str, federationList: [],
followJson: {}, sendThreads: [], postLog: [],
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str,
removeFollowActivity: bool,
signingPrivateKeyPem: str):
"""The person receiving a follow request accepts the new follower
and sends back an Accept activity
"""
acceptHandle = nickname + '@' + domain
# send accept back
if debug:
print('DEBUG: sending Accept activity for ' +
'follow request which arrived at ' +
nicknameToFollow + '@' + domainToFollow +
' back to ' + acceptHandle)
acceptJson = createAccept(baseDir, federationList,
nicknameToFollow, domainToFollow, port,
personUrl, '', httpPrefix,
followJson)
if debug:
pprint(acceptJson)
print('DEBUG: sending follow Accept from ' +
nicknameToFollow + '@' + domainToFollow +
' port ' + str(port) + ' to ' +
acceptHandle + ' port ' + str(fromPort))
clientToServer = False
if removeFollowActivity:
# remove the follow request json
followActivityfilename = \
acctDir(baseDir, nicknameToFollow, domainToFollow) + \
'/requests/' + \
nickname + '@' + domain + '.follow'
if os.path.isfile(followActivityfilename):
try:
os.remove(followActivityfilename)
except BaseException:
print('EX: followedAccountAccepts unable to delete ' +
followActivityfilename)
pass
groupAccount = False
if followJson:
if followJson.get('actor'):
if hasGroupType(baseDir, followJson['actor'], personCache):
groupAccount = True
return sendSignedJson(acceptJson, session, baseDir,
nicknameToFollow, domainToFollow, port,
nickname, domain, fromPort, '',
httpPrefix, True, clientToServer,
federationList,
sendThreads, postLog, cachedWebfingers,
personCache, debug, projectVersion, None,
groupAccount, signingPrivateKeyPem,
7856837)
def followedAccountRejects(session, baseDir: str, httpPrefix: str,
nicknameToFollow: str, domainToFollow: str,
port: int,
nickname: str, domain: str, fromPort: int,
federationList: [],
sendThreads: [], postLog: [],
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str,
signingPrivateKeyPem: str):
"""The person receiving a follow request rejects the new follower
and sends back a Reject activity
"""
# send reject back
if debug:
print('DEBUG: sending Reject activity for ' +
'follow request which arrived at ' +
nicknameToFollow + '@' + domainToFollow +
' back to ' + nickname + '@' + domain)
# get the json for the original follow request
followActivityfilename = \
acctDir(baseDir, nicknameToFollow, domainToFollow) + '/requests/' + \
nickname + '@' + domain + '.follow'
followJson = loadJson(followActivityfilename)
if not followJson:
print('No follow request json was found for ' +
followActivityfilename)
return None
# actor who made the follow request
personUrl = followJson['actor']
# create the reject activity
rejectJson = \
createReject(baseDir, federationList,
nicknameToFollow, domainToFollow, port,
personUrl, '', httpPrefix, followJson)
if debug:
pprint(rejectJson)
print('DEBUG: sending follow Reject from ' +
nicknameToFollow + '@' + domainToFollow +
' port ' + str(port) + ' to ' +
nickname + '@' + domain + ' port ' + str(fromPort))
clientToServer = False
denyHandle = getFullDomain(nickname + '@' + domain, fromPort)
groupAccount = False
if hasGroupType(baseDir, personUrl, personCache):
groupAccount = True
# remove from the follow requests file
removeFromFollowRequests(baseDir, nicknameToFollow, domainToFollow,
denyHandle, debug)
# remove the follow request json
try:
os.remove(followActivityfilename)
except BaseException:
print('EX: followedAccountRejects unable to delete ' +
followActivityfilename)
pass
# send the reject activity
return sendSignedJson(rejectJson, session, baseDir,
nicknameToFollow, domainToFollow, port,
nickname, domain, fromPort, '',
httpPrefix, True, clientToServer,
federationList,
sendThreads, postLog, cachedWebfingers,
personCache, debug, projectVersion, None,
groupAccount, signingPrivateKeyPem,
6393063)
def sendFollowRequest(session, baseDir: str,
nickname: str, domain: str, port: int, httpPrefix: str,
followNickname: str, followDomain: str,
followedActor: str,
followPort: int, followHttpPrefix: str,
clientToServer: bool, federationList: [],
sendThreads: [], postLog: [], cachedWebfingers: {},
personCache: {}, debug: bool,
projectVersion: str, signingPrivateKeyPem: str) -> {}:
"""Gets the json object for sending a follow request
"""
if not signingPrivateKeyPem:
print('WARN: follow request without signing key')
if not domainPermitted(followDomain, federationList):
print('You are not permitted to follow the domain ' + followDomain)
return None
fullDomain = getFullDomain(domain, port)
followActor = localActorUrl(httpPrefix, nickname, fullDomain)
requestDomain = getFullDomain(followDomain, followPort)
statusNumber, published = getStatusNumber()
groupAccount = False
if followNickname:
followedId = followedActor
followHandle = followNickname + '@' + requestDomain
groupAccount = hasGroupType(baseDir, followedActor, personCache)
if groupAccount:
followHandle = '!' + followHandle
print('Follow request being sent to group account')
else:
if debug:
print('DEBUG: sendFollowRequest - assuming single user instance')
followedId = followHttpPrefix + '://' + requestDomain
singleUserNickname = 'dev'
followHandle = singleUserNickname + '@' + requestDomain
# remove follow handle from unfollowed.txt
unfollowedFilename = acctDir(baseDir, nickname, domain) + '/unfollowed.txt'
if os.path.isfile(unfollowedFilename):
if followHandle in open(unfollowedFilename).read():
unfollowedFile = None
with open(unfollowedFilename, 'r') as fp:
unfollowedFile = fp.read()
unfollowedFile = \
unfollowedFile.replace(followHandle + '\n', '')
if unfollowedFile:
with open(unfollowedFilename, 'w+') as fp:
fp.write(unfollowedFile)
newFollowJson = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': followActor + '/statuses/' + str(statusNumber),
'type': 'Follow',
'actor': followActor,
'object': followedId
}
if groupAccount:
newFollowJson['to'] = followedId
print('Follow request: ' + str(newFollowJson))
if _followApprovalRequired(baseDir, nickname, domain, debug,
followHandle):
# Remove any follow requests rejected for the account being followed.
# It's assumed that if you are following someone then you are
# ok with them following back. If this isn't the case then a rejected
# follow request will block them again.
_removeFromFollowRejects(baseDir,
nickname, domain,
followHandle, debug)
sendSignedJson(newFollowJson, session, baseDir, nickname, domain, port,
followNickname, followDomain, followPort,
'https://www.w3.org/ns/activitystreams#Public',
httpPrefix, True, clientToServer,
federationList,
sendThreads, postLog, cachedWebfingers, personCache,
debug, projectVersion, None, groupAccount,
signingPrivateKeyPem, 8234389)
return newFollowJson
def sendFollowRequestViaServer(baseDir: str, session,
fromNickname: str, password: str,
fromDomain: str, fromPort: int,
followNickname: str, followDomain: str,
followPort: int,
httpPrefix: str,
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str,
signingPrivateKeyPem: str) -> {}:
"""Creates a follow request via c2s
"""
if not session:
print('WARN: No session for sendFollowRequestViaServer')
return 6
fromDomainFull = getFullDomain(fromDomain, fromPort)
followDomainFull = getFullDomain(followDomain, followPort)
followActor = localActorUrl(httpPrefix, fromNickname, fromDomainFull)
followedId = \
httpPrefix + '://' + followDomainFull + '/@' + followNickname
statusNumber, published = getStatusNumber()
newFollowJson = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': followActor + '/statuses/' + str(statusNumber),
'type': 'Follow',
'actor': followActor,
'object': followedId
}
handle = httpPrefix + '://' + fromDomainFull + '/@' + fromNickname
# lookup the inbox for the To handle
wfRequest = \
webfingerHandle(session, handle, httpPrefix, cachedWebfingers,
fromDomain, projectVersion, debug, False,
signingPrivateKeyPem)
if not wfRequest:
if debug:
print('DEBUG: follow request webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: follow request 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(signingPrivateKeyPem, originDomain,
baseDir, session, wfRequest, personCache,
projectVersion, httpPrefix, fromNickname,
fromDomain, postToBox, 52025)
if not inboxUrl:
if debug:
print('DEBUG: follow request no ' + postToBox +
' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: follow request no actor was found for ' + handle)
return 4
authHeader = createBasicAuthHeader(fromNickname, password)
headers = {
'host': fromDomain,
'Content-type': 'application/json',
'Authorization': authHeader
}
postResult = \
postJson(httpPrefix, fromDomainFull,
session, newFollowJson, [], inboxUrl, headers, 3, True)
if not postResult:
if debug:
print('DEBUG: POST follow request failed for c2s to ' + inboxUrl)
return 5
if debug:
print('DEBUG: c2s POST follow request success')
return newFollowJson
def sendUnfollowRequestViaServer(baseDir: str, session,
fromNickname: str, password: str,
fromDomain: str, fromPort: int,
followNickname: str, followDomain: str,
followPort: int,
httpPrefix: str,
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str,
signingPrivateKeyPem: str) -> {}:
"""Creates a unfollow request via c2s
"""
if not session:
print('WARN: No session for sendUnfollowRequestViaServer')
return 6
fromDomainFull = getFullDomain(fromDomain, fromPort)
followDomainFull = getFullDomain(followDomain, followPort)
followActor = localActorUrl(httpPrefix, fromNickname, fromDomainFull)
followedId = \
httpPrefix + '://' + followDomainFull + '/@' + followNickname
statusNumber, published = getStatusNumber()
unfollowJson = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': followActor + '/statuses/' + str(statusNumber) + '/undo',
'type': 'Undo',
'actor': followActor,
'object': {
'id': followActor + '/statuses/' + str(statusNumber),
'type': 'Follow',
'actor': followActor,
'object': followedId
}
}
handle = httpPrefix + '://' + fromDomainFull + '/@' + fromNickname
# lookup the inbox for the To handle
wfRequest = \
webfingerHandle(session, handle, httpPrefix, cachedWebfingers,
fromDomain, projectVersion, debug, False,
signingPrivateKeyPem)
if not wfRequest:
if debug:
print('DEBUG: unfollow webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: unfollow 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(signingPrivateKeyPem,
originDomain,
baseDir, session,
wfRequest, personCache,
projectVersion, httpPrefix,
fromNickname,
fromDomain, postToBox,
76536)
if not inboxUrl:
if debug:
print('DEBUG: unfollow no ' + postToBox +
' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: unfollow no actor was found for ' + handle)
return 4
authHeader = createBasicAuthHeader(fromNickname, password)
headers = {
'host': fromDomain,
'Content-type': 'application/json',
'Authorization': authHeader
}
postResult = \
postJson(httpPrefix, fromDomainFull,
session, unfollowJson, [], inboxUrl, headers, 3, True)
if not postResult:
if debug:
print('DEBUG: POST unfollow failed for c2s to ' + inboxUrl)
return 5
if debug:
print('DEBUG: c2s POST unfollow success')
return unfollowJson
def getFollowingViaServer(baseDir: str, session,
nickname: str, password: str,
domain: str, port: int,
httpPrefix: str, pageNumber: int,
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str,
signingPrivateKeyPem: str) -> {}:
"""Gets a page from the following collection as json
"""
if not session:
print('WARN: No session for getFollowingViaServer')
return 6
domainFull = getFullDomain(domain, port)
followActor = localActorUrl(httpPrefix, nickname, domainFull)
authHeader = createBasicAuthHeader(nickname, password)
headers = {
'host': domain,
'Content-type': 'application/json',
'Authorization': authHeader
}
if pageNumber < 1:
pageNumber = 1
url = followActor + '/following?page=' + str(pageNumber)
followingJson = \
getJson(signingPrivateKeyPem, session, url, headers, {}, debug,
__version__, httpPrefix, domain, 10, True)
if not followingJson:
if debug:
print('DEBUG: GET following list failed for c2s to ' + url)
return 5
if debug:
print('DEBUG: c2s GET following list request success')
return followingJson
def getFollowersViaServer(baseDir: str, session,
nickname: str, password: str,
domain: str, port: int,
httpPrefix: str, pageNumber: int,
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str,
signingPrivateKeyPem: str) -> {}:
"""Gets a page from the followers collection as json
"""
if not session:
print('WARN: No session for getFollowersViaServer')
return 6
domainFull = getFullDomain(domain, port)
followActor = localActorUrl(httpPrefix, nickname, domainFull)
authHeader = createBasicAuthHeader(nickname, password)
headers = {
'host': domain,
'Content-type': 'application/json',
'Authorization': authHeader
}
if pageNumber < 1:
pageNumber = 1
url = followActor + '/followers?page=' + str(pageNumber)
followersJson = \
getJson(signingPrivateKeyPem, session, url, headers, {}, debug,
__version__, httpPrefix, domain, 10, True)
if not followersJson:
if debug:
print('DEBUG: GET followers list failed for c2s to ' + url)
return 5
if debug:
print('DEBUG: c2s GET followers list request success')
return followersJson
def getFollowRequestsViaServer(baseDir: str, session,
nickname: str, password: str,
domain: str, port: int,
httpPrefix: str, pageNumber: int,
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str,
signingPrivateKeyPem: str) -> {}:
"""Gets a page from the follow requests collection as json
"""
if not session:
print('WARN: No session for getFollowRequestsViaServer')
return 6
domainFull = getFullDomain(domain, port)
followActor = localActorUrl(httpPrefix, nickname, domainFull)
authHeader = createBasicAuthHeader(nickname, password)
headers = {
'host': domain,
'Content-type': 'application/json',
'Authorization': authHeader
}
if pageNumber < 1:
pageNumber = 1
url = followActor + '/followrequests?page=' + str(pageNumber)
followersJson = \
getJson(signingPrivateKeyPem, session, url, headers, {}, debug,
__version__, httpPrefix, domain, 10, True)
if not followersJson:
if debug:
print('DEBUG: GET follow requests list failed for c2s to ' + url)
return 5
if debug:
print('DEBUG: c2s GET follow requests list request success')
return followersJson
def approveFollowRequestViaServer(baseDir: str, session,
nickname: str, password: str,
domain: str, port: int,
httpPrefix: str, approveHandle: int,
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str,
signingPrivateKeyPem: str) -> str:
"""Approves a follow request
This is not exactly via c2s though. It simulates pressing the Approve
button on the web interface
"""
if not session:
print('WARN: No session for approveFollowRequestViaServer')
return 6
domainFull = getFullDomain(domain, port)
actor = localActorUrl(httpPrefix, nickname, domainFull)
authHeader = createBasicAuthHeader(nickname, password)
headers = {
'host': domain,
'Content-type': 'text/html; charset=utf-8',
'Authorization': authHeader
}
url = actor + '/followapprove=' + approveHandle
approveHtml = \
getJson(signingPrivateKeyPem, session, url, headers, {}, debug,
__version__, httpPrefix, domain, 10, True)
if not approveHtml:
if debug:
print('DEBUG: GET approve follow request failed for c2s to ' + url)
return 5
if debug:
print('DEBUG: c2s GET approve follow request request success')
return approveHtml
def denyFollowRequestViaServer(baseDir: str, session,
nickname: str, password: str,
domain: str, port: int,
httpPrefix: str, denyHandle: int,
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str,
signingPrivateKeyPem: str) -> str:
"""Denies a follow request
This is not exactly via c2s though. It simulates pressing the Deny
button on the web interface
"""
if not session:
print('WARN: No session for denyFollowRequestViaServer')
return 6
domainFull = getFullDomain(domain, port)
actor = localActorUrl(httpPrefix, nickname, domainFull)
authHeader = createBasicAuthHeader(nickname, password)
headers = {
'host': domain,
'Content-type': 'text/html; charset=utf-8',
'Authorization': authHeader
}
url = actor + '/followdeny=' + denyHandle
denyHtml = \
getJson(signingPrivateKeyPem, session, url, headers, {}, debug,
__version__, httpPrefix, domain, 10, True)
if not denyHtml:
if debug:
print('DEBUG: GET deny follow request failed for c2s to ' + url)
return 5
if debug:
print('DEBUG: c2s GET deny follow request request success')
return denyHtml
def getFollowersOfActor(baseDir: str, actor: str, debug: bool) -> {}:
"""In a shared inbox if we receive a post we know who it's from
and if it's addressed to followers then we need to get a list of those.
This returns a list of account handles which follow the given actor
"""
if debug:
print('DEBUG: getting followers of ' + actor)
recipientsDict = {}
if ':' not in actor:
return recipientsDict
nickname = getNicknameFromActor(actor)
if not nickname:
if debug:
print('DEBUG: no nickname found in ' + actor)
return recipientsDict
domain, port = getDomainFromActor(actor)
if not domain:
if debug:
print('DEBUG: no domain found in ' + actor)
return recipientsDict
actorHandle = nickname + '@' + domain
if debug:
print('DEBUG: searching for handle ' + actorHandle)
# for each of the accounts
for subdir, dirs, files in os.walk(baseDir + '/accounts'):
for account in dirs:
if '@' in account and not account.startswith('inbox@'):
followingFilename = \
os.path.join(subdir, account) + '/following.txt'
if debug:
print('DEBUG: examining follows of ' + account)
print(followingFilename)
if os.path.isfile(followingFilename):
# does this account follow the given actor?
if debug:
print('DEBUG: checking if ' + actorHandle +
' in ' + followingFilename)
if actorHandle in open(followingFilename).read():
if debug:
print('DEBUG: ' + account +
' follows ' + actorHandle)
recipientsDict[account] = None
break
return recipientsDict
def outboxUndoFollow(baseDir: str, messageJson: {}, debug: bool) -> None:
"""When an unfollow request is received by the outbox from c2s
This removes the followed handle from the following.txt file
of the relevant account
"""
if not messageJson.get('type'):
return
if not messageJson['type'] == 'Undo':
return
if not hasObjectStringType(messageJson, debug):
return
if not messageJson['object']['type'] == 'Follow':
if not messageJson['object']['type'] == 'Join':
return
if not hasObjectStringObject(messageJson, debug):
return
if not messageJson['object'].get('actor'):
return
if debug:
print('DEBUG: undo follow arrived in outbox')
nicknameFollower = getNicknameFromActor(messageJson['object']['actor'])
if not nicknameFollower:
print('WARN: unable to find nickname in ' +
messageJson['object']['actor'])
return
domainFollower, portFollower = \
getDomainFromActor(messageJson['object']['actor'])
domainFollowerFull = getFullDomain(domainFollower, portFollower)
nicknameFollowing = getNicknameFromActor(messageJson['object']['object'])
if not nicknameFollowing:
print('WARN: unable to find nickname in ' +
messageJson['object']['object'])
return
domainFollowing, portFollowing = \
getDomainFromActor(messageJson['object']['object'])
domainFollowingFull = getFullDomain(domainFollowing, portFollowing)
groupAccount = hasGroupType(baseDir, messageJson['object']['object'], None)
if unfollowAccount(baseDir, nicknameFollower, domainFollowerFull,
nicknameFollowing, domainFollowingFull,
debug, groupAccount):
if debug:
print('DEBUG: ' + nicknameFollower + ' unfollowed ' +
nicknameFollowing + '@' + domainFollowingFull)
else:
if debug:
print('WARN: ' + nicknameFollower + ' could not unfollow ' +
nicknameFollowing + '@' + domainFollowingFull)
def followerApprovalActive(baseDir: str, nickname: str, domain: str) -> bool:
"""Returns true if the given account requires follower approval
"""
manuallyApprovesFollowers = False
actorFilename = acctDir(baseDir, nickname, domain) + '.json'
if os.path.isfile(actorFilename):
actorJson = loadJson(actorFilename)
if actorJson:
if actorJson.get('manuallyApprovesFollowers'):
manuallyApprovesFollowers = \
actorJson['manuallyApprovesFollowers']
return manuallyApprovesFollowers