epicyon/person.py

478 lines
19 KiB
Python
Raw Normal View History

2019-06-28 18:55:29 +00:00
__filename__ = "person.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "0.0.1"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
import json
import commentjson
import os
import fileinput
2019-07-12 14:31:56 +00:00
import subprocess
from pprint import pprint
2019-07-12 14:31:56 +00:00
from pathlib import Path
2019-06-28 18:55:29 +00:00
from Crypto.PublicKey import RSA
2019-07-12 13:51:04 +00:00
from shutil import copyfile
2019-06-28 18:55:29 +00:00
from webfinger import createWebfingerEndpoint
from webfinger import storeWebfingerEndpoint
2019-06-29 14:35:26 +00:00
from posts import createOutbox
from auth import storeBasicCredentials
2019-06-28 18:55:29 +00:00
def generateRSAKey() -> (str,str):
key = RSA.generate(2048)
privateKeyPem = key.exportKey("PEM").decode("utf-8")
publicKeyPem = key.publickey().exportKey("PEM").decode("utf-8")
return privateKeyPem,publicKeyPem
2019-07-12 13:51:04 +00:00
def setProfileImage(baseDir: str,httpPrefix :str,nickname: str,domain: str, \
port :int,imageFilename: str,imageType :str,resolution :str) -> bool:
2019-07-12 13:51:04 +00:00
"""Saves the given image file as an avatar or background
image for the given person
"""
2019-07-12 14:31:56 +00:00
imageFilename=imageFilename.replace('\n','')
2019-07-12 13:51:04 +00:00
if not (imageFilename.endswith('.png') or \
imageFilename.endswith('.jpg') or \
imageFilename.endswith('.jpeg') or \
imageFilename.endswith('.gif')):
2019-07-12 14:31:56 +00:00
print('Profile image must be png, jpg or gif format')
return False
2019-07-12 13:51:04 +00:00
2019-07-12 14:31:56 +00:00
if imageFilename.startswith('~/'):
imageFilename=imageFilename.replace('~/',str(Path.home())+'/')
if ':' in domain:
domain=domain.split(':')[0]
fullDomain=domain
2019-07-12 13:51:04 +00:00
if port!=80 and port!=443:
2019-07-12 14:31:56 +00:00
fullDomain=domain+':'+str(port)
2019-07-12 13:51:04 +00:00
handle=nickname.lower()+'@'+domain.lower()
personFilename=baseDir+'/accounts/'+handle+'.json'
if not os.path.isfile(personFilename):
2019-07-12 14:31:56 +00:00
print('person definition not found: '+personFilename)
return False
2019-07-12 13:51:04 +00:00
if not os.path.isdir(baseDir+'/accounts/'+handle):
2019-07-12 14:31:56 +00:00
print('Account not found: '+baseDir+'/accounts/'+handle)
return False
2019-07-12 13:51:04 +00:00
iconFilenameBase='icon'
if imageType=='avatar' or imageType=='icon':
iconFilenameBase='icon'
else:
iconFilenameBase='image'
mediaType='image/png'
iconFilename=iconFilenameBase+'.png'
if imageFilename.endswith('.jpg') or \
imageFilename.endswith('.jpeg'):
mediaType='image/jpeg'
iconFilename=iconFilenameBase+'.jpg'
if imageFilename.endswith('.gif'):
mediaType='image/gif'
iconFilename=iconFilenameBase+'.gif'
profileFilename=baseDir+'/accounts/'+handle+'/'+iconFilename
with open(personFilename, 'r') as fp:
personJson=commentjson.load(fp)
personJson[iconFilenameBase]['mediaType']=mediaType
2019-07-12 14:31:56 +00:00
personJson[iconFilenameBase]['url']=httpPrefix+'://'+fullDomain+'/users/'+nickname+'/'+iconFilename
2019-07-12 13:51:04 +00:00
with open(personFilename, 'w') as fp:
commentjson.dump(personJson, fp, indent=4, sort_keys=False)
2019-07-12 14:31:56 +00:00
cmd = '/usr/bin/convert '+imageFilename+' -size '+resolution+' -quality 50 '+profileFilename
2019-07-12 14:31:56 +00:00
subprocess.call(cmd, shell=True)
return True
return False
2019-07-12 13:51:04 +00:00
2019-07-14 12:08:18 +00:00
def setSkillLevel(baseDir: str,nickname: str,domain: str, \
skill: str,skillLevelPercent: int) -> bool:
"""Set a skill level for a person
2019-07-14 12:10:48 +00:00
Setting skill level to zero removes it
2019-07-14 12:08:18 +00:00
"""
if skillLevelPercent<0 or skillLevelPercent>100:
return False
actorFilename=baseDir+'/accounts/'+nickname+'@'+domain+'.json'
if not os.path.isfile(actorFilename):
return False
with open(actorFilename, 'r') as fp:
2019-07-14 12:10:48 +00:00
actorJson=commentjson.load(fp)
if not actorJson.get('skills'):
actorJson['skills']={}
2019-07-14 12:10:48 +00:00
if skillLevelPercent>0:
actorJson['skills'][skill]=skillLevelPercent
else:
del actorJson['skills'][skill]
2019-07-14 12:08:18 +00:00
with open(actorFilename, 'w') as fp:
commentjson.dump(actorJson, fp, indent=4, sort_keys=False)
return True
2019-07-14 12:10:48 +00:00
2019-07-14 12:25:16 +00:00
def setRole(baseDir: str,nickname: str,domain: str, \
project: str,role: str) -> bool:
"""Set a person's role within a project
Setting the role to an empty string or None will remove it
"""
# avoid giant strings
if len(role)>128 or len(project)>128:
return False
actorFilename=baseDir+'/accounts/'+nickname+'@'+domain+'.json'
if not os.path.isfile(actorFilename):
return False
with open(actorFilename, 'r') as fp:
actorJson=commentjson.load(fp)
if role:
if actorJson['roles'].get(project):
if role not in actorJson['roles'][project]:
actorJson['roles'][project].append(role)
else:
actorJson['roles'][project]=[role]
else:
if actorJson['roles'].get(project):
actorJson['roles'][project].remove(role)
# if the project contains no roles then remove it
if len(actorJson['roles'][project])==0:
del actorJson['roles'][project]
with open(actorFilename, 'w') as fp:
commentjson.dump(actorJson, fp, indent=4, sort_keys=False)
return True
def setOrganizationScheme(baseDir: str,nickname: str,domain: str, \
schema: str) -> bool:
"""Set the organization schema within which a person exists
This will define how roles, skills and availability are assembled
into organizations
"""
# avoid giant strings
if len(schema)>256:
return False
actorFilename=baseDir+'/accounts/'+nickname+'@'+domain+'.json'
if not os.path.isfile(actorFilename):
return False
with open(actorFilename, 'r') as fp:
actorJson=commentjson.load(fp)
actorJson['orgSchema']=schema
with open(actorFilename, 'w') as fp:
commentjson.dump(actorJson, fp, indent=4, sort_keys=False)
return True
2019-07-14 13:30:59 +00:00
def setAvailability(baseDir: str,nickname: str,domain: str, \
status: str) -> bool:
"""Set an availability status
"""
# avoid giant strings
if len(status)>128:
return False
actorFilename=baseDir+'/accounts/'+nickname+'@'+domain+'.json'
if not os.path.isfile(actorFilename):
return False
with open(actorFilename, 'r') as fp:
actorJson=commentjson.load(fp)
actorJson['availability']=status
with open(actorFilename, 'w') as fp:
commentjson.dump(actorJson, fp, indent=4, sort_keys=False)
2019-07-14 13:30:59 +00:00
return True
2019-07-05 11:27:18 +00:00
def createPersonBase(baseDir: str,nickname: str,domain: str,port: int, \
httpPrefix: str, saveToFile: bool,password=None) -> (str,str,{},{}):
2019-06-28 18:55:29 +00:00
"""Returns the private key, public key, actor and webfinger endpoint
"""
privateKeyPem,publicKeyPem=generateRSAKey()
2019-07-02 20:54:22 +00:00
webfingerEndpoint= \
2019-07-03 19:00:03 +00:00
createWebfingerEndpoint(nickname,domain,port,httpPrefix,publicKeyPem)
2019-06-28 18:55:29 +00:00
if saveToFile:
2019-07-03 09:40:27 +00:00
storeWebfingerEndpoint(nickname,domain,baseDir,webfingerEndpoint)
2019-06-30 18:23:18 +00:00
2019-07-03 09:40:27 +00:00
handle=nickname.lower()+'@'+domain.lower()
2019-06-30 18:23:18 +00:00
if port!=80 and port!=443:
domain=domain+':'+str(port)
2019-06-28 18:55:29 +00:00
newPerson = {'@context': ['https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{'Emoji': 'toot:Emoji',
'Hashtag': 'as:Hashtag',
'IdentityProof': 'toot:IdentityProof',
'PropertyValue': 'schema:PropertyValue',
'alsoKnownAs': {'@id': 'as:alsoKnownAs', '@type': '@id'},
'featured': {'@id': 'toot:featured', '@type': '@id'},
'focalPoint': {'@container': '@list', '@id': 'toot:focalPoint'},
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'movedTo': {'@id': 'as:movedTo', '@type': '@id'},
'schema': 'http://schema.org#',
'toot': 'http://joinmastodon.org/ns#',
'value': 'schema:value'}],
'attachment': [],
2019-07-03 20:52:25 +00:00
'endpoints': {
2019-07-04 08:56:15 +00:00
'id': httpPrefix+'://'+domain+'/users/'+nickname+'/endpoints',
2019-07-03 20:52:25 +00:00
'sharedInbox': httpPrefix+'://'+domain+'/inbox',
2019-07-05 22:16:19 +00:00
'uploadMedia': httpPrefix+'://'+domain+'/users/'+nickname+'/endpoints/uploadMedia'
2019-07-03 20:52:25 +00:00
},
2019-07-05 22:17:06 +00:00
'capabilityAcquisitionEndpoint': httpPrefix+'://'+domain+'/caps/new',
2019-07-03 19:00:03 +00:00
'featured': httpPrefix+'://'+domain+'/users/'+nickname+'/collections/featured',
'followers': httpPrefix+'://'+domain+'/users/'+nickname+'/followers',
'following': httpPrefix+'://'+domain+'/users/'+nickname+'/following',
'orgSchema': None,
2019-07-14 12:08:18 +00:00
'skills': {},
'roles': {},
2019-07-14 13:30:59 +00:00
'availability': None,
2019-06-28 18:55:29 +00:00
'icon': {'mediaType': 'image/png',
'type': 'Image',
2019-07-12 13:51:04 +00:00
'url': httpPrefix+'://'+domain+'/users/'+nickname+'/icon.png'},
2019-07-03 19:00:03 +00:00
'id': httpPrefix+'://'+domain+'/users/'+nickname,
2019-06-28 18:55:29 +00:00
'image': {'mediaType': 'image/png',
'type': 'Image',
2019-07-12 13:51:04 +00:00
'url': httpPrefix+'://'+domain+'/users/'+nickname+'/image.png'},
2019-07-03 19:00:03 +00:00
'inbox': httpPrefix+'://'+domain+'/users/'+nickname+'/inbox',
2019-06-28 20:02:04 +00:00
'manuallyApprovesFollowers': False,
2019-07-03 09:40:27 +00:00
'name': nickname,
2019-07-03 19:00:03 +00:00
'outbox': httpPrefix+'://'+domain+'/users/'+nickname+'/outbox',
2019-07-03 09:47:09 +00:00
'preferredUsername': ''+nickname,
2019-07-04 14:36:29 +00:00
'publicKey': {'id': httpPrefix+'://'+domain+'/users/'+nickname+'#main-key',
2019-07-03 19:00:03 +00:00
'owner': httpPrefix+'://'+domain+'/users/'+nickname,
2019-06-28 18:55:29 +00:00
'publicKeyPem': publicKeyPem,
'summary': '',
'tag': [],
'type': 'Person',
2019-07-03 19:00:03 +00:00
'url': httpPrefix+'://'+domain+'/@'+nickname}
2019-06-28 18:55:29 +00:00
}
if saveToFile:
# save person to file
peopleSubdir='/accounts'
if not os.path.isdir(baseDir+peopleSubdir):
os.mkdir(baseDir+peopleSubdir)
2019-07-05 09:20:54 +00:00
if not os.path.isdir(baseDir+peopleSubdir+'/'+handle):
os.mkdir(baseDir+peopleSubdir+'/'+handle)
2019-07-11 12:29:31 +00:00
if not os.path.isdir(baseDir+peopleSubdir+'/'+handle+'/inbox'):
os.mkdir(baseDir+peopleSubdir+'/'+handle+'/inbox')
if not os.path.isdir(baseDir+peopleSubdir+'/'+handle+'/outbox'):
os.mkdir(baseDir+peopleSubdir+'/'+handle+'/outbox')
if not os.path.isdir(baseDir+peopleSubdir+'/'+handle+'/ocap'):
os.mkdir(baseDir+peopleSubdir+'/'+handle+'/ocap')
if not os.path.isdir(baseDir+peopleSubdir+'/'+handle+'/queue'):
os.mkdir(baseDir+peopleSubdir+'/'+handle+'/queue')
2019-06-28 18:55:29 +00:00
filename=baseDir+peopleSubdir+'/'+handle+'.json'
with open(filename, 'w') as fp:
commentjson.dump(newPerson, fp, indent=4, sort_keys=False)
# save the private key
privateKeysSubdir='/keys/private'
if not os.path.isdir(baseDir+'/keys'):
os.mkdir(baseDir+'/keys')
if not os.path.isdir(baseDir+privateKeysSubdir):
os.mkdir(baseDir+privateKeysSubdir)
filename=baseDir+privateKeysSubdir+'/'+handle+'.key'
with open(filename, "w") as text_file:
print(privateKeyPem, file=text_file)
# save the public key
publicKeysSubdir='/keys/public'
if not os.path.isdir(baseDir+publicKeysSubdir):
os.mkdir(baseDir+publicKeysSubdir)
filename=baseDir+publicKeysSubdir+'/'+handle+'.pem'
with open(filename, "w") as text_file:
print(publicKeyPem, file=text_file)
if password:
storeBasicCredentials(baseDir,nickname,password)
2019-06-28 18:55:29 +00:00
return privateKeyPem,publicKeyPem,newPerson,webfingerEndpoint
2019-07-18 13:10:26 +00:00
def noOfAccounts(baseDir: str) -> bool:
"""Returns the number of accounts on the system
"""
accountCtr=0
for subdir, dirs, files in os.walk(baseDir+'/accounts'):
for account in dirs:
if '@' in account:
if not account.startswith('inbox'):
accountCtr+=1
return accountCtr
2019-07-05 11:27:18 +00:00
def createPerson(baseDir: str,nickname: str,domain: str,port: int, \
httpPrefix: str, saveToFile: bool,password=None) -> (str,str,{},{}):
"""Returns the private key, public key, actor and webfinger endpoint
"""
if not validNickname(nickname):
return None,None,None,None
2019-07-18 13:10:26 +00:00
privateKeyPem,publicKeyPem,newPerson,webfingerEndpoint = \
createPersonBase(baseDir,nickname,domain,port,httpPrefix,saveToFile,password)
if noOfAccounts(baseDir)==1:
print(nickname+' becomes the instance admin')
setRole(baseDir,nickname,domain,'instance','admin')
return privateKeyPem,publicKeyPem,newPerson,webfingerEndpoint
2019-07-05 11:27:18 +00:00
def createSharedInbox(baseDir: str,nickname: str,domain: str,port: int, \
httpPrefix: str) -> (str,str,{},{}):
"""Generates the shared inbox
"""
return createPersonBase(baseDir,nickname,domain,port,httpPrefix,True,None)
def createCapabilitiesInbox(baseDir: str,nickname: str,domain: str,port: int, \
httpPrefix: str) -> (str,str,{},{}):
"""Generates the capabilities inbox to sign requests
"""
return createPersonBase(baseDir,nickname,domain,port,httpPrefix,True,None)
2019-07-05 11:27:18 +00:00
2019-07-03 09:40:27 +00:00
def validNickname(nickname: str) -> bool:
2019-06-28 18:55:29 +00:00
forbiddenChars=['.',' ','/','?',':',';','@']
for c in forbiddenChars:
2019-07-03 09:40:27 +00:00
if c in nickname:
2019-06-28 18:55:29 +00:00
return False
2019-07-08 13:30:04 +00:00
reservedNames=['inbox','outbox','following','followers','capabilities']
2019-07-04 18:26:37 +00:00
if nickname in reservedNames:
return False
2019-06-28 18:55:29 +00:00
return True
2019-06-30 22:56:37 +00:00
def personLookup(domain: str,path: str,baseDir: str) -> {}:
2019-07-03 09:40:27 +00:00
"""Lookup the person for an given nickname
2019-06-28 18:55:29 +00:00
"""
2019-07-04 18:26:37 +00:00
if path.endswith('#main-key'):
path=path.replace('#main-key','')
2019-07-02 20:54:22 +00:00
notPersonLookup=['/inbox','/outbox','/outboxarchive', \
'/followers','/following','/featured', \
2019-07-04 14:36:29 +00:00
'.png','.jpg','.gif','.mpv']
2019-06-28 18:55:29 +00:00
for ending in notPersonLookup:
if path.endswith(ending):
return None
2019-07-03 09:40:27 +00:00
nickname=None
2019-06-28 18:55:29 +00:00
if path.startswith('/users/'):
2019-07-03 09:40:27 +00:00
nickname=path.replace('/users/','',1)
2019-06-28 18:55:29 +00:00
if path.startswith('/@'):
2019-07-03 09:40:27 +00:00
nickname=path.replace('/@','',1)
if not nickname:
2019-06-28 18:55:29 +00:00
return None
2019-07-03 09:40:27 +00:00
if not validNickname(nickname):
2019-06-28 18:55:29 +00:00
return None
2019-07-01 21:01:43 +00:00
if ':' in domain:
domain=domain.split(':')[0]
2019-07-03 09:40:27 +00:00
handle=nickname.lower()+'@'+domain.lower()
2019-06-28 18:55:29 +00:00
filename=baseDir+'/accounts/'+handle.lower()+'.json'
if not os.path.isfile(filename):
return None
personJson={"user": "unknown"}
with open(filename, 'r') as fp:
personJson=commentjson.load(fp)
return personJson
2019-06-29 14:35:26 +00:00
2019-07-04 16:24:23 +00:00
def personBoxJson(baseDir: str,domain: str,port: int,path: str, \
httpPrefix: str,noOfItems: int,boxname: str, \
authorized: bool,ocapAlways: bool) -> []:
2019-07-04 16:24:23 +00:00
"""Obtain the inbox/outbox feed for the given person
2019-06-29 14:35:26 +00:00
"""
2019-07-04 16:24:23 +00:00
if boxname!='inbox' and boxname!='outbox':
return None
if not '/'+boxname in path:
2019-06-29 16:47:37 +00:00
return None
2019-06-29 17:12:26 +00:00
# Only show the header by default
headerOnly=True
2019-06-29 16:47:37 +00:00
# handle page numbers
2019-06-29 17:12:26 +00:00
pageNumber=None
2019-06-29 16:47:37 +00:00
if '?page=' in path:
pageNumber=path.split('?page=')[1]
if pageNumber=='true':
pageNumber=1
else:
try:
pageNumber=int(pageNumber)
except:
pass
path=path.split('?page=')[0]
2019-06-29 17:12:26 +00:00
headerOnly=False
2019-06-29 16:47:37 +00:00
2019-07-04 16:24:23 +00:00
if not path.endswith('/'+boxname):
2019-06-29 15:18:35 +00:00
return None
2019-07-03 09:40:27 +00:00
nickname=None
2019-06-29 14:35:26 +00:00
if path.startswith('/users/'):
2019-07-04 16:24:23 +00:00
nickname=path.replace('/users/','',1).replace('/'+boxname,'')
2019-06-29 14:35:26 +00:00
if path.startswith('/@'):
2019-07-04 16:24:23 +00:00
nickname=path.replace('/@','',1).replace('/'+boxname,'')
2019-07-03 09:40:27 +00:00
if not nickname:
2019-06-29 15:18:35 +00:00
return None
2019-07-03 09:40:27 +00:00
if not validNickname(nickname):
2019-06-29 15:18:35 +00:00
return None
2019-07-04 16:24:23 +00:00
if boxname=='inbox':
return createInbox(baseDir,nickname,domain,port,httpPrefix, \
noOfItems,headerOnly,ocapAlways,pageNumber)
2019-07-03 19:00:03 +00:00
return createOutbox(baseDir,nickname,domain,port,httpPrefix, \
noOfItems,headerOnly,authorized,pageNumber)
2019-06-29 14:35:26 +00:00
2019-07-04 16:24:23 +00:00
def personInboxJson(baseDir: str,domain: str,port: int,path: str, \
httpPrefix: str,noOfItems: int,ocapAlways: bool) -> []:
2019-07-04 16:24:23 +00:00
"""Obtain the inbox feed for the given person
Authentication is expected to have already happened
"""
if not '/inbox' in path:
return None
# Only show the header by default
headerOnly=True
# handle page numbers
pageNumber=None
if '?page=' in path:
pageNumber=path.split('?page=')[1]
if pageNumber=='true':
pageNumber=1
else:
try:
pageNumber=int(pageNumber)
except:
pass
path=path.split('?page=')[0]
headerOnly=False
if not path.endswith('/inbox'):
return None
nickname=None
if path.startswith('/users/'):
nickname=path.replace('/users/','',1).replace('/inbox','')
if path.startswith('/@'):
nickname=path.replace('/@','',1).replace('/inbox','')
if not nickname:
return None
if not validNickname(nickname):
return None
return createInbox(baseDir,nickname,domain,port,httpPrefix, \
noOfItems,headerOnly,ocapAlways,pageNumber)
2019-07-04 16:24:23 +00:00
2019-07-03 09:40:27 +00:00
def setPreferredNickname(baseDir: str,nickname: str, domain: str, \
2019-07-02 20:54:22 +00:00
preferredName: str) -> bool:
2019-06-28 18:55:29 +00:00
if len(preferredName)>32:
return False
2019-07-03 09:40:27 +00:00
handle=nickname.lower()+'@'+domain.lower()
2019-06-28 18:55:29 +00:00
filename=baseDir+'/accounts/'+handle.lower()+'.json'
if not os.path.isfile(filename):
return False
personJson=None
with open(filename, 'r') as fp:
personJson=commentjson.load(fp)
if not personJson:
return False
2019-07-03 09:47:09 +00:00
personJson['preferredUsername']=preferredName
2019-06-28 18:55:29 +00:00
with open(filename, 'w') as fp:
commentjson.dump(personJson, fp, indent=4, sort_keys=False)
return True
2019-06-28 20:00:25 +00:00
2019-07-03 09:40:27 +00:00
def setBio(baseDir: str,nickname: str, domain: str, bio: str) -> bool:
2019-06-28 20:00:25 +00:00
if len(bio)>32:
return False
2019-07-03 09:40:27 +00:00
handle=nickname.lower()+'@'+domain.lower()
2019-06-28 20:00:25 +00:00
filename=baseDir+'/accounts/'+handle.lower()+'.json'
if not os.path.isfile(filename):
return False
personJson=None
with open(filename, 'r') as fp:
personJson=commentjson.load(fp)
if not personJson:
return False
if not personJson.get('publicKey'):
return False
personJson['publicKey']['summary']=bio
with open(filename, 'w') as fp:
commentjson.dump(personJson, fp, indent=4, sort_keys=False)
return True