Capability on post is a list

master
Bob Mottram 2019-07-08 14:30:04 +01:00
parent 8a1fd82584
commit db68b34cc5
7 changed files with 143 additions and 63 deletions

View File

@ -102,7 +102,7 @@ Follow Accept from **Bob** to **Alice** with attached capabilities.
'type': 'Accept'} 'type': 'Accept'}
``` ```
When posts are subsequently sent from the following instance (server-to-server) they should have the corresponding capability id string attached within the Create wrapper. In the above example that would be **http://bobdomain.net/caps/rOYtHApyr4ZWDUgEE1KqjhTe0kI3T2wJ**. It should contain a random string which is hard to guess by brute force methods. When posts are subsequently sent from the following instance (server-to-server) they should have the corresponding capability id string attached within the Create wrapper. To handle the *shared inbox* scenario this should be a list rather than a single string. In the above example that would be *['http://bobdomain.net/caps/rOYtHApyr4ZWDUgEE1KqjhTe0kI3T2wJ']*. It should contain a random string which is hard to guess by brute force methods.
``` text ``` text
Alice Alice
@ -141,6 +141,12 @@ Subsequently **Bob** could change the stored capabilities for **Alice** in their
Object capabilities can be strictly enforced by adding the **--ocap** option when running the server. The only activities which it is not enforced upon are **Follow** and **Accept**. Anyone can create a follow request or accept updated capabilities. Object capabilities can be strictly enforced by adding the **--ocap** option when running the server. The only activities which it is not enforced upon are **Follow** and **Accept**. Anyone can create a follow request or accept updated capabilities.
## Object capabilities in the shared inbox scenario
Shared inboxes are obviously essential for any kind of scalability, otherwise there would be vast amounts of duplicated messages being dumped onto the intertubes like a big truck.
With the shared inbox instead of sending from Alice to 500 of her fans on a different instance - repeatedly sending the same message to individual inboxes - a single message is sent to its shared inbox (which has its own special account called 'inbox') and it then decides how to distribute that. If a list of capability ids is attached to the message which gets sent to the shared inbox then the receiving server can use that.
## Some capabilities ## Some capabilities
*inbox:write* - follower can post anything to your inbox *inbox:write* - follower can post anything to your inbox

View File

@ -491,7 +491,7 @@ class PubServer(BaseHTTPRequestHandler):
else: else:
if self.path == '/sharedInbox' or self.path == '/inbox': if self.path == '/sharedInbox' or self.path == '/inbox':
print('DEBUG: POST to shared inbox') print('DEBUG: POST to shared inbox')
if self._updateInboxQueue('sharedinbox',messageJson): if self._updateInboxQueue('inbox',messageJson):
return return
self.send_response(200) self.send_response(200)
self.end_headers() self.end_headers()

View File

@ -332,9 +332,9 @@ if not os.path.isdir(baseDir+'/accounts/'+nickname+'@'+domain):
setConfigParam(baseDir,'adminPassword',adminPassword) setConfigParam(baseDir,'adminPassword',adminPassword)
createPerson(baseDir,nickname,domain,port,httpPrefix,True,adminPassword) createPerson(baseDir,nickname,domain,port,httpPrefix,True,adminPassword)
if not os.path.isdir(baseDir+'/accounts/sharedinbox@'+domain): if not os.path.isdir(baseDir+'/accounts/inbox@'+domain):
print('Creating shared inbox') print('Creating shared inbox: inbox@'+domain)
createSharedInbox(baseDir,'sharedinbox',domain,port,httpPrefix) createSharedInbox(baseDir,'inbox',domain,port,httpPrefix)
if not os.path.isdir(baseDir+'/accounts/capabilities@'+domain): if not os.path.isdir(baseDir+'/accounts/capabilities@'+domain):
print('Creating capabilities account which can sign requests') print('Creating capabilities account which can sign requests')

View File

@ -33,7 +33,7 @@ def getFollowersOfPerson(baseDir: str, \
for subdir, dirs, files in os.walk(baseDir+'/accounts'): for subdir, dirs, files in os.walk(baseDir+'/accounts'):
for account in dirs: for account in dirs:
filename = os.path.join(subdir, account)+'/'+followFile filename = os.path.join(subdir, account)+'/'+followFile
if account == handle or account.startswith('sharedinbox@'): if account == handle or account.startswith('inbox@'):
continue continue
if not os.path.isfile(filename): if not os.path.isfile(filename):
continue continue

102
inbox.py
View File

@ -124,7 +124,7 @@ def savePostToInboxQueue(baseDir: str,httpPrefix: str,nickname: str, domain: str
filename=inboxQueueDir+'/'+postId.replace('/','#')+'.json' filename=inboxQueueDir+'/'+postId.replace('/','#')+'.json'
sharedInboxItem=False sharedInboxItem=False
if nickname=='sharedinbox': if nickname=='inbox':
sharedInboxItem=True sharedInboxItem=True
newQueueItem = { newQueueItem = {
@ -179,56 +179,70 @@ def runInboxQueue(baseDir: str,httpPrefix: str,sendThreads: [],postLog: [],cache
with open(queueFilename, 'r') as fp: with open(queueFilename, 'r') as fp:
queueJson=commentjson.load(fp) queueJson=commentjson.load(fp)
# check that capabilities are accepted sentToSharedInbox=False
capabilitiesPassed=False if queueJson['post'].get('actor'):
if queueJson['post'].get('capability'): if queueJson['post']['actor'].endswith('/inbox'):
if isinstance(queueJson['post']['capability'], dict): sentToSharedInbox=True
if debug:
print('DEBUG: capability is a dictionary when it should be a string') if sentToSharedInbox:
os.remove(queueFilename) # if this is arriving at the shared inbox then
queue.pop(0) # don't do the capabilities checks
continue capabilitiesPassed=True
ocapFilename= \ # TODO how to handle capabilities in the shared inbox scenario?
getOcapFilename(baseDir, \ # should 'capability' be a list instead of a single value?
queueJson['nickname'],queueJson['domain'], \ else:
queueJson['post']['actor'],'accept') # check that capabilities are accepted
if not os.path.isfile(ocapFilename): capabilitiesPassed=False
if debug: if queueJson['post'].get('capability'):
print('DEBUG: capabilities for '+ \ if not isinstance(queueJson['post']['capability'], list):
queueJson['post']['actor']+' do not exist')
os.remove(queueFilename)
queue.pop(0)
continue
with open(ocapFilename, 'r') as fp:
oc=commentjson.load(fp)
if not oc.get('id'):
if debug: if debug:
print('DEBUG: capabilities for '+queueJson['post']['actor']+' do not contain an id') print('DEBUG: capability on post should be a list')
os.remove(queueFilename) os.remove(queueFilename)
queue.pop(0) queue.pop(0)
continue continue
if oc['id']!=queueJson['post']['capability']: capabilityIdList=queueJson['post']['capability']
ocapFilename= \
getOcapFilename(baseDir, \
queueJson['nickname'],queueJson['domain'], \
queueJson['post']['actor'],'accept')
if not os.path.isfile(ocapFilename):
if debug: if debug:
print('DEBUG: capability id mismatch') print('DEBUG: capabilities for '+ \
queueJson['post']['actor']+' do not exist')
os.remove(queueFilename) os.remove(queueFilename)
queue.pop(0) queue.pop(0)
continue continue
if not oc.get('capability'): with open(ocapFilename, 'r') as fp:
oc=commentjson.load(fp)
if not oc.get('id'):
if debug:
print('DEBUG: capabilities for '+queueJson['post']['actor']+' do not contain an id')
os.remove(queueFilename)
queue.pop(0)
continue
if oc['id']!=capabilityIdList[0]:
if debug:
print('DEBUG: capability id mismatch')
os.remove(queueFilename)
queue.pop(0)
continue
if not oc.get('capability'):
if debug:
print('DEBUG: missing capability list')
os.remove(queueFilename)
queue.pop(0)
continue
if not CapablePost(queueJson['post'],oc['capability'],debug):
if debug:
print('DEBUG: insufficient capabilities to write to inbox from '+ \
queueJson['post']['actor'])
os.remove(queueFilename)
queue.pop(0)
continue
if debug: if debug:
print('DEBUG: missing capability list') print('DEBUG: object capabilities check success')
os.remove(queueFilename) capabilitiesPassed=True
queue.pop(0)
continue
if not CapablePost(queueJson['post'],oc['capability'],debug):
if debug:
print('DEBUG: insufficient capabilities to write to inbox from '+ \
queueJson['post']['actor'])
os.remove(queueFilename)
queue.pop(0)
continue
if debug:
print('DEBUG: object capabilities check success')
capabilitiesPassed=True
if ocapAlways and not capabilitiesPassed: if ocapAlways and not capabilitiesPassed:
# Allow follow types through # Allow follow types through
@ -347,7 +361,7 @@ def runInboxQueue(baseDir: str,httpPrefix: str,sendThreads: [],postLog: [],cache
# post already exists in this person's inbox # post already exists in this person's inbox
continue continue
# We could do this in a more storage space efficient way # We could do this in a more storage space efficient way
# by linking to the inbox of sharedinbox@domain # by linking to the inbox of inbox@domain
# However, this allows for easy deletion by individuals # However, this allows for easy deletion by individuals
# without affecting any other people # without affecting any other people
copyfile(queueFilename, destination) copyfile(queueFilename, destination)

View File

@ -140,7 +140,7 @@ def validNickname(nickname: str) -> bool:
for c in forbiddenChars: for c in forbiddenChars:
if c in nickname: if c in nickname:
return False return False
reservedNames=['inbox','outbox','following','followers','sharedinbox','capabilities'] reservedNames=['inbox','outbox','following','followers','capabilities']
if nickname in reservedNames: if nickname in reservedNames:
return False return False
return True return True

View File

@ -351,19 +351,17 @@ def createPostBase(baseDir: str,nickname: str, domain: str, port: int, \
# if capabilities have been granted for this actor # if capabilities have been granted for this actor
# then get the corresponding id # then get the corresponding id
capabilityId=None capabilityId=None
ocapFilename= getOcapFilename(baseDir,nickname,domain,toUrl,'granted') capabilityIdList=[]
#print('ocapFilename: '+ocapFilename) ocapFilename=getOcapFilename(baseDir,nickname,domain,toUrl,'granted')
if os.path.isfile(ocapFilename): if os.path.isfile(ocapFilename):
with open(ocapFilename, 'r') as fp: with open(ocapFilename, 'r') as fp:
oc=commentjson.load(fp) oc=commentjson.load(fp)
if oc.get('id'): if oc.get('id'):
capabilityId=oc['id'] capabilityIdList=[oc['id']]
#else:
# print('ocapFilename: '+ocapFilename+' not found')
newPost = { newPost = {
'id': newPostId+'/activity', 'id': newPostId+'/activity',
'capability': capabilityId, 'capability': capabilityIdList,
'type': 'Create', 'type': 'Create',
'actor': actorUrl, 'actor': actorUrl,
'published': published, 'published': published,
@ -464,6 +462,32 @@ def outboxMessageCreateWrap(httpPrefix: str,nickname: str,domain: str, \
httpPrefix+'://'+domain+'/users/'+nickname+'/statuses/'+statusNumber httpPrefix+'://'+domain+'/users/'+nickname+'/statuses/'+statusNumber
return newPost return newPost
def postIsAddressedToFollowers(baseDir: str,
nickname: str, domain: str, port: int,httpPrefix: str,
postJson: {}) -> bool:
"""Returns true if the given post is addressed to followers of the nickname
"""
if port!=80 and port!=443:
domain=domain+':'+str(port)
if not postJson.get('object'):
return False
if not postJson['object'].get('to'):
return False
followersUrl=httpPrefix+'://'+domain+'/users/'+nickname+'/followers'
# does the followers url exist in 'to' or 'cc' lists?
addressedToFollowers=False
if followersUrl in postJson['object']['to']:
addressedToFollowers=True
if not addressedToFollowers:
if not postJson['object'].get('cc'):
return False
if followersUrl in postJson['object']['cc']:
addressedToFollowers=True
return addressedToFollowers
def createPublicPost(baseDir: str, def createPublicPost(baseDir: str,
nickname: str, domain: str, port: int,httpPrefix: str, \ nickname: str, domain: str, port: int,httpPrefix: str, \
content: str, followersOnly: bool, saveToFile: bool, content: str, followersOnly: bool, saveToFile: bool,
@ -626,6 +650,10 @@ def sendSignedJson(postJsonObject: {},session,baseDir: str, \
""" """
withDigest=True withDigest=True
sharedInbox=False
if toNickname=='inbox':
sharedInbox=True
if toPort!=80 and toPort!=443: if toPort!=80 and toPort!=443:
if ':' not in toDomain: if ':' not in toDomain:
toDomain=toDomain+':'+str(toPort) toDomain=toDomain+':'+str(toPort)
@ -633,7 +661,7 @@ def sendSignedJson(postJsonObject: {},session,baseDir: str, \
handle=httpPrefix+'://'+toDomain+'/@'+toNickname handle=httpPrefix+'://'+toDomain+'/@'+toNickname
# lookup the inbox for the To handle # lookup the inbox for the To handle
wfRequest = webfingerHandle(session,handle,httpPrefix,cachedWebfingers) wfRequest=webfingerHandle(session,handle,httpPrefix,cachedWebfingers)
if not wfRequest: if not wfRequest:
if debug: if debug:
print('DEBUG: webfinger for '+handle+' failed') print('DEBUG: webfinger for '+handle+' failed')
@ -645,18 +673,16 @@ def sendSignedJson(postJsonObject: {},session,baseDir: str, \
postToBox='outbox' postToBox='outbox'
# get the actor inbox/outbox/capabilities for the To handle # get the actor inbox/outbox/capabilities for the To handle
inboxUrl,pubKeyId,pubKey,toPersonId,sharedInbox,capabilityAcquisition = \ inboxUrl,pubKeyId,pubKey,toPersonId,sharedInboxUrl,capabilityAcquisition = \
getPersonBox(session,wfRequest,personCache,postToBox) getPersonBox(session,wfRequest,personCache,postToBox)
# If there are more than one followers on the target domain
# then send to teh shared inbox indead of the individual inbox
if nickname=='capabilities': if nickname=='capabilities':
inboxUrl=capabilityAcquisition inboxUrl=capabilityAcquisition
if not capabilityAcquisition: if not capabilityAcquisition:
return 2 return 2
else: else:
if noOfFollowersOnDomain(baseDir,handle,toDomain)>1 and sharedInbox: if sharedInbox and sharedInboxUrl:
inboxUrl=sharedInbox inboxUrl=sharedInboxUrl
if debug: if debug:
print('DEBUG: Sending to endpoint '+inboxUrl) print('DEBUG: Sending to endpoint '+inboxUrl)
@ -702,6 +728,40 @@ def sendSignedJson(postJsonObject: {},session,baseDir: str, \
thr.start() thr.start()
return 0 return 0
def sendToFollowers(session,baseDir: str,
nickname: str, domain: str, port: int,httpPrefix: str,
postJsonObject: {}):
"""sends a post to the followers of the given nickname
"""
if not postIsAddressedToFollowers(baseDir,nickname,domain, \
port,httpPrefix,postJsonObject):
return
grouped=groupFollowersByDomain(baseDir,nickname,domain)
if not grouped:
return
# for each instance
for followerDomain,followerHandles in grouped.items():
toPort=port
index=0
toDomain=followerHandles[index].split('@')[1]
if ':' in toDomain:
toPort=toDomain.split(':')[1]
toDomain=toDomain.split(':')[0]
toNickname=followerHandles[index].split('@')[0]
cc=''
if len(followerHandles)>1:
nickname='inbox'
toNickname='inbox'
sendSignedJson(postJsonObject,session,baseDir, \
nickname,domain,port, \
toNickname,toDomain,toPort, \
cc,httpPrefix,True,clientToServer, \
federationList,ocapGranted, \
sendThreads,postLog,cachedWebfingers, \
personCache,debug)
def createInbox(baseDir: str,nickname: str,domain: str,port: int,httpPrefix: str, \ def createInbox(baseDir: str,nickname: str,domain: str,port: int,httpPrefix: str, \
itemsPerPage: int,headerOnly: bool,pageNumber=None) -> {}: itemsPerPage: int,headerOnly: bool,pageNumber=None) -> {}:
return createBoxBase(baseDir,'inbox',nickname,domain,port,httpPrefix, \ return createBoxBase(baseDir,'inbox',nickname,domain,port,httpPrefix, \