__filename__ = "follow.py" __author__ = "Bob Mottram" __license__ = "AGPL3+" __version__ = "1.1.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" from pprint import pprint import os 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 acceptreject import createAccept from acceptreject import createReject from webfinger import webfingerHandle from auth import createBasicAuthHeader from session import postJson def preApprovedFollower(baseDir: str, nickname: str, domain: str, approveHandle: str, allowNewsFollowers: bool) -> bool: """Is the given handle an already manually approved follower? """ # optionally allow the news account to be followed if nickname == 'news' and allowNewsFollowers: return True 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 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 if acceptOrDenyHandle not in open(approveFollowsFilename).read(): return approvefilenew = open(approveFollowsFilename + '.new', 'w+') with open(approveFollowsFilename, 'r') as approvefile: for approveHandle in approvefile: if not approveHandle.startswith(acceptOrDenyHandle): approvefilenew.write(approveHandle) approvefilenew.close() 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? """ if ':' in domain: domain = domain.split(':')[0] 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 = followingNickname + '@' + followingDomain if followingPort: if followingPort != 80 and followingPort != 443: if ':' not in followingHandle: followingHandle += ':' + str(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) -> bool: """Adds a follower of the given person """ return followPerson(baseDir, nickname, domain, followerNickname, followerDomain, federationList, debug, 'followers.txt') def isFollowerOfPerson(baseDir: str, nickname: str, domain: str, followerNickname: str, followerDomain: str) -> bool: """is the given nickname a follower of followerNickname? """ if ':' in domain: domain = domain.split(':')[0] followersFile = baseDir + '/accounts/' + \ 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 elif '://' + followerDomain + \ '/profile/' + followerNickname in followersStr: alreadyFollowing = True elif '://' + followerDomain + \ '/channel/' + followerNickname in followersStr: alreadyFollowing = True elif '://' + followerDomain + \ '/accounts/' + followerNickname in followersStr: alreadyFollowing = True return alreadyFollowing def unfollowPerson(baseDir: str, nickname: str, domain: str, followNickname: str, followDomain: str, followFile='following.txt', debug=False) -> bool: """Removes a person to the follow list """ if ':' in domain: domain = domain.split(':')[0] handle = nickname + '@' + domain handleToUnfollow = followNickname + '@' + followDomain 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: if line.strip("\n").strip("\r").lower() != \ 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 unfollowerOfPerson(baseDir: str, nickname: str, domain: str, followerNickname: str, followerDomain: str, debug=False) -> bool: """Remove a follower of a person """ return unfollowPerson(baseDir, nickname, domain, followerNickname, followerDomain, 'followers.txt', debug) def clearFollows(baseDir: str, nickname: str, domain: str, followFile='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): os.remove(filename) 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('dat')) and ('/users/' in line or '/profile/' in line or '/accounts/' in line or '/channel/' in 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, authenticated: 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-authenticated viewers if not authenticated: 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 authenticated: pageNumber = 1 else: try: pageNumber = int(pageNumber) except BaseException: 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 if port: if port != 80 and port != 443: if ':' not in domain: domain = domain + ':' + str(port) if headerOnly: firstStr = \ httpPrefix + '://' + domain + '/users/' + \ nickname + '/' + followFile + '?page=1' idStr = \ httpPrefix + '://' + domain + '/users/' + \ nickname + '/' + followFile totalStr = \ getNoOfFollows(baseDir, nickname, domain, authenticated) 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 = \ httpPrefix + '://' + domain + '/users/' + \ nickname + '/' + followFile + '?page=' + str(pageNumber) partOfStr = \ httpPrefix + '://' + domain + '/users/' + nickname + '/' + followFile following = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': idStr, 'orderedItems': [], 'partOf': partOfStr, 'totalItems': 0, 'type': 'OrderedCollectionPage' } handleDomain = domain if ':' in handleDomain: handleDomain = domain.split(':')[0] 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', '') url = httpPrefix + '://' + \ line2.split('@')[1] + \ '/users/' + \ line2.split('@')[0] following['orderedItems'].append(url) elif ((line.startswith('http') or line.startswith('dat')) and ('/users/' in line or '/profile/' in line or '/accounts/' in line or '/channel/' in 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'] = \ httpPrefix + '://' + domain + '/users/' + \ nickname + '/' + followFile + '?page=' + str(lastPage) return following def followApprovalRequired(baseDir: str, nicknameToFollow: str, domainToFollow: str, debug: bool, followRequestHandle: str, allowNewsFollowers: bool) -> bool: """ Returns the policy for follower approvals """ # has this handle already been manually approved? if preApprovedFollower(baseDir, nicknameToFollow, domainToFollow, followRequestHandle, allowNewsFollowers): return False manuallyApproveFollows = False if ':' in domainToFollow: domainToFollow = domainToFollow.split(':')[0] 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) -> bool: """Stores the follow request for later use """ accountsDir = baseDir + '/accounts/' + \ nicknameToFollow + '@' + domainToFollow if not os.path.isdir(accountsDir): return False approveHandle = nickname + '@' + domain domainFull = domain if fromPort: if fromPort != 80 and fromPort != 443: if ':' not in domain: approveHandle = nickname + '@' + domain + ':' + str(fromPort) domainFull = domain + ':' + str(fromPort) 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 elif '://' + domainFull + '/profile/' + nickname in followersStr: alreadyFollowing = True elif '://' + domainFull + '/channel/' + nickname in followersStr: alreadyFollowing = True elif '://' + domainFull + '/accounts/' + nickname in followersStr: alreadyFollowing = True 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 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, allowNewsFollowers: bool, maxFollowers: int) -> bool: """Receives a follow request within the POST section of HTTPServer """ if not messageJson['type'].startswith('Follow'): return False print('Receiving follow request') if not messageJson.get('actor'): if debug: print('DEBUG: follow request has no actor') return False if '/users/' not in messageJson['actor'] and \ '/accounts/' not in messageJson['actor'] and \ '/channel/' not in messageJson['actor'] and \ '/profile/' not in messageJson['actor']: if debug: print('DEBUG: users/profile/accounts/channel missing from actor') return False domain, tempPort = getDomainFromActor(messageJson['actor']) fromPort = port domainFull = domain if tempPort: fromPort = tempPort if tempPort != 80 and tempPort != 443: if ':' not in domain: domainFull = domain + ':' + str(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 '/users/' not in messageJson['object'] and \ '/accounts/' not in messageJson['object'] and \ '/channel/' not in messageJson['object'] and \ '/profile/' not in 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 = domainToFollow if tempPort: if tempPort != 80 and tempPort != 443: if ':' not in domainToFollow: domainToFollowFull = domainToFollow + ':' + str(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 not (nicknameToFollow == 'news' and allowNewsFollowers): 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, allowNewsFollowers): 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 print('Storing follow request for approval') return storeFollowRequest(baseDir, nicknameToFollow, domainToFollow, port, nickname, domain, fromPort, messageJson, debug, messageJson['actor']) else: print('Follow request does not require approval') # update the followers if os.path.isdir(baseDir + '/accounts/' + nicknameToFollow + '@' + domainToFollow): followersFilename = \ baseDir + '/accounts/' + \ nicknameToFollow + '@' + domainToFollow + '/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'] print('Updating followers file: ' + followersFilename + ' adding ' + approveHandle) if os.path.isfile(followersFilename): if approveHandle not in open(followersFilename).read(): try: with open(followersFilename, 'r+') as followersFile: content = followersFile.read() followersFile.seek(0, 0) followersFile.write(approveHandle + '\n' + content) except Exception as e: print('WARN: ' + 'Failed to write entry to followers file ' + str(e)) else: followersFile = open(followersFilename, "w+") followersFile.write(approveHandle + '\n') followersFile.close() 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) 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): """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 = \ baseDir + '/accounts/' + \ nicknameToFollow + '@' + domainToFollow + '/requests/' + \ nickname + '@' + domain + '.follow' if os.path.isfile(followActivityfilename): try: os.remove(followActivityfilename) except BaseException: pass return sendSignedJson(acceptJson, session, baseDir, nicknameToFollow, domainToFollow, port, nickname, domain, fromPort, '', httpPrefix, True, clientToServer, federationList, sendThreads, postLog, cachedWebfingers, personCache, debug, projectVersion) 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): """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 = \ baseDir + '/accounts/' + \ 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 = nickname + '@' + domain if fromPort: if fromPort != 80 and fromPort != 443: denyHandle = denyHandle + ':' + str(fromPort) # remove from the follow requests file removeFromFollowRequests(baseDir, nicknameToFollow, domainToFollow, denyHandle, debug) # remove the follow request json try: os.remove(followActivityfilename) except BaseException: 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) def sendFollowRequest(session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, followNickname: str, followDomain: str, followPort: int, followHttpPrefix: str, clientToServer: bool, federationList: [], sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, debug: bool, projectVersion: str, allowNewsFollowers: bool) -> {}: """Gets the json object for sending a follow request """ if not domainPermitted(followDomain, federationList): return None fullDomain = domain followActor = httpPrefix + '://' + domain + '/users/' + nickname if port: if port != 80 and port != 443: if ':' not in domain: fullDomain = domain + ':' + str(port) followActor = httpPrefix + '://' + \ fullDomain + '/users/' + nickname requestDomain = followDomain if followPort: if followPort != 80 and followPort != 443: if ':' not in followDomain: requestDomain = followDomain + ':' + str(followPort) statusNumber, published = getStatusNumber() if followNickname: followedId = followHttpPrefix + '://' + \ requestDomain + '/users/' + followNickname followHandle = followNickname + '@' + requestDomain else: if debug: print('DEBUG: sendFollowRequest - assuming single user instance') followedId = followHttpPrefix + '://' + requestDomain singleUserNickname = 'dev' followHandle = singleUserNickname + '@' + requestDomain newFollowJson = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': followActor + '/statuses/' + str(statusNumber), 'type': 'Follow', 'actor': followActor, 'object': followedId } if followApprovalRequired(baseDir, nickname, domain, debug, followHandle, allowNewsFollowers): # 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) 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) -> {}: """Creates a follow request via c2s """ if not session: print('WARN: No session for sendFollowRequestViaServer') return 6 fromDomainFull = fromDomain if fromPort: if fromPort != 80 and fromPort != 443: if ':' not in fromDomain: fromDomainFull = fromDomain + ':' + str(fromPort) followDomainFull = followDomain if followPort: if followPort != 80 and followPort != 443: if ':' not in followDomain: followDomainFull = followDomain + ':' + str(followPort) followActor = httpPrefix + '://' + \ fromDomainFull + '/users/' + fromNickname followedId = httpPrefix + '://' + \ followDomainFull + '/users/' + 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) if not wfRequest: if debug: print('DEBUG: announce webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' # get the actor inbox for the To handle (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, personCache, projectVersion, httpPrefix, fromNickname, fromDomain, postToBox) if not inboxUrl: if debug: print('DEBUG: No ' + postToBox + ' was found for ' + handle) return 3 if not fromPersonId: if debug: print('DEBUG: No actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) headers = { 'host': fromDomain, 'Content-type': 'application/json', 'Authorization': authHeader } postResult = \ postJson(session, newFollowJson, [], inboxUrl, headers) if not postResult: if debug: print('DEBUG: POST announce failed for c2s to ' + inboxUrl) return 5 if debug: print('DEBUG: c2s POST follow 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) -> {}: """Creates a unfollow request via c2s """ if not session: print('WARN: No session for sendUnfollowRequestViaServer') return 6 fromDomainFull = fromDomain if fromPort: if fromPort != 80 and fromPort != 443: if ':' not in fromDomain: fromDomainFull = fromDomain + ':' + str(fromPort) followDomainFull = followDomain if followPort: if followPort != 80 and followPort != 443: if ':' not in followDomain: followDomainFull = followDomain + ':' + str(followPort) followActor = httpPrefix + '://' + \ fromDomainFull + '/users/' + fromNickname followedId = httpPrefix + '://' + \ followDomainFull + '/users/' + 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) if not wfRequest: if debug: print('DEBUG: announce webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' # get the actor inbox for the To handle (inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox, avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest, personCache, projectVersion, httpPrefix, fromNickname, fromDomain, postToBox) if not inboxUrl: if debug: print('DEBUG: No ' + postToBox + ' was found for ' + handle) return 3 if not fromPersonId: if debug: print('DEBUG: No actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) headers = { 'host': fromDomain, 'Content-type': 'application/json', 'Authorization': authHeader } postResult = \ postJson(session, unfollowJson, [], inboxUrl, headers) if not postResult: if debug: print('DEBUG: POST announce failed for c2s to ' + inboxUrl) return 5 if debug: print('DEBUG: c2s POST unfollow success') return unfollowJson 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 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 messageJson.get('object'): return if not isinstance(messageJson['object'], dict): return if not messageJson['object'].get('type'): return if not messageJson['object']['type'] == 'Follow': return if not messageJson['object'].get('object'): return if not messageJson['object'].get('actor'): return if not isinstance(messageJson['object']['object'], str): 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 = domainFollower if portFollower: if portFollower != 80 and portFollower != 443: if ':' not in domainFollower: domainFollowerFull = domainFollower + ':' + str(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 = domainFollowing if portFollowing: if portFollowing != 80 and portFollowing != 443: if ':' not in domainFollowing: domainFollowingFull = \ domainFollowing + ':' + str(portFollowing) if unfollowPerson(baseDir, nicknameFollower, domainFollowerFull, nicknameFollowing, domainFollowingFull): 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 = baseDir + '/accounts/' + nickname + '@' + domain + '.json' if os.path.isfile(actorFilename): actorJson = loadJson(actorFilename) if actorJson: if actorJson.get('manuallyApprovesFollowers'): manuallyApprovesFollowers = \ actorJson['manuallyApprovesFollowers'] return manuallyApprovesFollowers