epicyon/person.py

1775 lines
66 KiB
Python

__filename__ = "person.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@libreserver.org"
__status__ = "Production"
__module_group__ = "ActivityPub"
import time
import os
import subprocess
import shutil
import datetime
import pyqrcode
from random import randint
from pathlib import Path
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from shutil import copyfile
from webfinger import create_webfinger_endpoint
from webfinger import store_webfinger_endpoint
from posts import get_user_url
from posts import create_dm_timeline
from posts import create_replies_timeline
from posts import create_media_timeline
from posts import create_news_timeline
from posts import create_blogs_timeline
from posts import create_features_timeline
from posts import create_bookmarks_timeline
from posts import create_inbox
from posts import create_outbox
from posts import create_moderation
from auth import store_basic_credentials
from auth import remove_password
from roles import set_role
from roles import set_rolesFromList
from roles import get_actor_roles_list
from media import process_meta_data
from utils import remove_html
from utils import contains_invalid_chars
from utils import replace_users_with_at
from utils import remove_line_endings
from utils import remove_domain_port
from utils import get_status_number
from utils import get_full_domain
from utils import valid_nickname
from utils import load_json
from utils import save_json
from utils import set_config_param
from utils import get_config_param
from utils import refresh_newswire
from utils import get_protocol_prefixes
from utils import has_users_path
from utils import get_image_extensions
from utils import is_image_file
from utils import acct_dir
from utils import get_user_paths
from utils import get_group_paths
from utils import local_actor_url
from utils import dangerous_svg
from session import create_session
from session import get_json
from webfinger import webfinger_handle
from pprint import pprint
from cache import get_person_from_cache
from cache import store_person_in_cache
from filters import is_filtered_bio
from follow import is_following_actor
def generate_rsa_key() -> (str, str):
key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
privateKeyPem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
pubkey = key.public_key()
publicKeyPem = pubkey.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
privateKeyPem = privateKeyPem.decode("utf-8")
publicKeyPem = publicKeyPem.decode("utf-8")
return privateKeyPem, publicKeyPem
def set_profile_image(base_dir: str, http_prefix: str,
nickname: str, domain: str,
port: int, image_filename: str, imageType: str,
resolution: str, city: str,
content_license_url: str) -> bool:
"""Saves the given image file as an avatar or background
image for the given person
"""
image_filename = image_filename.replace('\n', '').replace('\r', '')
if not is_image_file(image_filename):
print('Profile image must be png, jpg, gif or svg format')
return False
if image_filename.startswith('~/'):
image_filename = image_filename.replace('~/', str(Path.home()) + '/')
domain = remove_domain_port(domain)
fullDomain = get_full_domain(domain, port)
handle = nickname + '@' + domain
personFilename = base_dir + '/accounts/' + handle + '.json'
if not os.path.isfile(personFilename):
print('person definition not found: ' + personFilename)
return False
if not os.path.isdir(base_dir + '/accounts/' + handle):
print('Account not found: ' + base_dir + '/accounts/' + handle)
return False
iconFilenameBase = 'icon'
if imageType == 'avatar' or imageType == 'icon':
iconFilenameBase = 'icon'
else:
iconFilenameBase = 'image'
mediaType = 'image/png'
iconFilename = iconFilenameBase + '.png'
if image_filename.endswith('.jpg') or \
image_filename.endswith('.jpeg'):
mediaType = 'image/jpeg'
iconFilename = iconFilenameBase + '.jpg'
elif image_filename.endswith('.gif'):
mediaType = 'image/gif'
iconFilename = iconFilenameBase + '.gif'
elif image_filename.endswith('.webp'):
mediaType = 'image/webp'
iconFilename = iconFilenameBase + '.webp'
elif image_filename.endswith('.avif'):
mediaType = 'image/avif'
iconFilename = iconFilenameBase + '.avif'
elif image_filename.endswith('.svg'):
mediaType = 'image/svg+xml'
iconFilename = iconFilenameBase + '.svg'
profileFilename = base_dir + '/accounts/' + handle + '/' + iconFilename
personJson = load_json(personFilename)
if personJson:
personJson[iconFilenameBase]['mediaType'] = mediaType
personJson[iconFilenameBase]['url'] = \
local_actor_url(http_prefix, nickname, fullDomain) + \
'/' + iconFilename
save_json(personJson, personFilename)
cmd = \
'/usr/bin/convert ' + image_filename + ' -size ' + \
resolution + ' -quality 50 ' + profileFilename
subprocess.call(cmd, shell=True)
process_meta_data(base_dir, nickname, domain,
profileFilename, profileFilename, city,
content_license_url)
return True
return False
def _account_exists(base_dir: str, nickname: str, domain: str) -> bool:
"""Returns true if the given account exists
"""
domain = remove_domain_port(domain)
accountDir = acct_dir(base_dir, nickname, domain)
return os.path.isdir(accountDir) or \
os.path.isdir(base_dir + '/deactivated/' + nickname + '@' + domain)
def randomize_actor_images(personJson: {}) -> None:
"""Randomizes the filenames for avatar image and background
This causes other instances to update their cached avatar image
"""
personId = personJson['id']
lastPartOfFilename = personJson['icon']['url'].split('/')[-1]
existingExtension = lastPartOfFilename.split('.')[1]
# NOTE: these files don't need to have cryptographically
# secure names
randStr = str(randint(10000000000000, 99999999999999)) # nosec
baseUrl = personId.split('/users/')[0]
nickname = personJson['preferredUsername']
personJson['icon']['url'] = \
baseUrl + '/system/accounts/avatars/' + nickname + \
'/avatar' + randStr + '.' + existingExtension
lastPartOfFilename = personJson['image']['url'].split('/')[-1]
existingExtension = lastPartOfFilename.split('.')[1]
randStr = str(randint(10000000000000, 99999999999999)) # nosec
personJson['image']['url'] = \
baseUrl + '/system/accounts/headers/' + nickname + \
'/image' + randStr + '.' + existingExtension
def get_actor_update_json(actor_json: {}) -> {}:
"""Returns the json for an Person Update
"""
pubNumber, _ = get_status_number()
manuallyApprovesFollowers = actor_json['manuallyApprovesFollowers']
return {
'@context': [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"toot": "http://joinmastodon.org/ns#",
"featured":
{
"@id": "toot:featured",
"@type": "@id"
},
"featuredTags":
{
"@id": "toot:featuredTags",
"@type": "@id"
},
"alsoKnownAs":
{
"@id": "as:alsoKnownAs",
"@type": "@id"
},
"movedTo":
{
"@id": "as:movedTo",
"@type": "@id"
},
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
"IdentityProof": "toot:IdentityProof",
"discoverable": "toot:discoverable",
"Device": "toot:Device",
"Ed25519Signature": "toot:Ed25519Signature",
"Ed25519Key": "toot:Ed25519Key",
"Curve25519Key": "toot:Curve25519Key",
"EncryptedMessage": "toot:EncryptedMessage",
"publicKeyBase64": "toot:publicKeyBase64",
"deviceId": "toot:deviceId",
"claim":
{
"@type": "@id",
"@id": "toot:claim"
},
"fingerprintKey":
{
"@type": "@id",
"@id": "toot:fingerprintKey"
},
"identityKey":
{
"@type": "@id",
"@id": "toot:identityKey"
},
"devices":
{
"@type": "@id",
"@id": "toot:devices"
},
"messageFranking": "toot:messageFranking",
"messageType": "toot:messageType",
"cipherText": "toot:cipherText",
"suspended": "toot:suspended",
"focalPoint":
{
"@container": "@list",
"@id": "toot:focalPoint"
}
}
],
'id': actor_json['id'] + '#updates/' + pubNumber,
'type': 'Update',
'actor': actor_json['id'],
'to': ['https://www.w3.org/ns/activitystreams#Public'],
'cc': [actor_json['id'] + '/followers'],
'object': {
'id': actor_json['id'],
'type': actor_json['type'],
'icon': {
'type': 'Image',
'url': actor_json['icon']['url']
},
'image': {
'type': 'Image',
'url': actor_json['image']['url']
},
'attachment': actor_json['attachment'],
'following': actor_json['id'] + '/following',
'followers': actor_json['id'] + '/followers',
'inbox': actor_json['id'] + '/inbox',
'outbox': actor_json['id'] + '/outbox',
'featured': actor_json['id'] + '/collections/featured',
'featuredTags': actor_json['id'] + '/collections/tags',
'preferredUsername': actor_json['preferredUsername'],
'name': actor_json['name'],
'summary': actor_json['summary'],
'url': actor_json['url'],
'manuallyApprovesFollowers': manuallyApprovesFollowers,
'discoverable': actor_json['discoverable'],
'published': actor_json['published'],
'devices': actor_json['devices'],
"publicKey": actor_json['publicKey'],
}
}
def get_default_person_context() -> str:
"""Gets the default actor context
"""
return {
'Curve25519Key': 'toot:Curve25519Key',
'Device': 'toot:Device',
'Ed25519Key': 'toot:Ed25519Key',
'Ed25519Signature': 'toot:Ed25519Signature',
'EncryptedMessage': 'toot:EncryptedMessage',
'IdentityProof': 'toot:IdentityProof',
'PropertyValue': 'schema:PropertyValue',
'alsoKnownAs': {'@id': 'as:alsoKnownAs', '@type': '@id'},
'cipherText': 'toot:cipherText',
'claim': {'@id': 'toot:claim', '@type': '@id'},
'deviceId': 'toot:deviceId',
'devices': {'@id': 'toot:devices', '@type': '@id'},
'discoverable': 'toot:discoverable',
'featured': {'@id': 'toot:featured', '@type': '@id'},
'featuredTags': {'@id': 'toot:featuredTags', '@type': '@id'},
'fingerprintKey': {'@id': 'toot:fingerprintKey', '@type': '@id'},
'focalPoint': {'@container': '@list', '@id': 'toot:focalPoint'},
'identityKey': {'@id': 'toot:identityKey', '@type': '@id'},
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'messageFranking': 'toot:messageFranking',
'messageType': 'toot:messageType',
'movedTo': {'@id': 'as:movedTo', '@type': '@id'},
'publicKeyBase64': 'toot:publicKeyBase64',
'schema': 'http://schema.org#',
'suspended': 'toot:suspended',
'toot': 'http://joinmastodon.org/ns#',
'value': 'schema:value',
'hasOccupation': 'schema:hasOccupation',
'Occupation': 'schema:Occupation',
'occupationalCategory': 'schema:occupationalCategory',
'Role': 'schema:Role',
'WebSite': 'schema:Project',
'CategoryCode': 'schema:CategoryCode',
'CategoryCodeSet': 'schema:CategoryCodeSet'
}
def _create_person_base(base_dir: str, nickname: str, domain: str, port: int,
http_prefix: str, saveToFile: bool,
manual_follower_approval: bool,
group_account: bool,
password: str) -> (str, str, {}, {}):
"""Returns the private key, public key, actor and webfinger endpoint
"""
privateKeyPem, publicKeyPem = generate_rsa_key()
webfingerEndpoint = \
create_webfinger_endpoint(nickname, domain, port,
http_prefix, publicKeyPem,
group_account)
if saveToFile:
store_webfinger_endpoint(nickname, domain, port,
base_dir, webfingerEndpoint)
handle = nickname + '@' + domain
originalDomain = domain
domain = get_full_domain(domain, port)
personType = 'Person'
if group_account:
personType = 'Group'
# Enable follower approval by default
approveFollowers = manual_follower_approval
personName = nickname
personId = local_actor_url(http_prefix, nickname, domain)
inboxStr = personId + '/inbox'
personUrl = http_prefix + '://' + domain + '/@' + personName
if nickname == 'inbox':
# shared inbox
inboxStr = http_prefix + '://' + domain + '/actor/inbox'
personId = http_prefix + '://' + domain + '/actor'
personUrl = http_prefix + '://' + domain + \
'/about/more?instance_actor=true'
personName = originalDomain
approveFollowers = True
personType = 'Application'
elif nickname == 'news':
personUrl = http_prefix + '://' + domain + \
'/about/more?news_actor=true'
approveFollowers = True
personType = 'Application'
# NOTE: these image files don't need to have
# cryptographically secure names
imageUrl = \
personId + '/image' + \
str(randint(10000000000000, 99999999999999)) + '.png' # nosec
iconUrl = \
personId + '/avatar' + \
str(randint(10000000000000, 99999999999999)) + '.png' # nosec
statusNumber, published = get_status_number()
newPerson = {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
get_default_person_context()
],
'published': published,
'alsoKnownAs': [],
'attachment': [],
'devices': personId + '/collections/devices',
'endpoints': {
'id': personId + '/endpoints',
'sharedInbox': http_prefix + '://' + domain + '/inbox',
},
'featured': personId + '/collections/featured',
'featuredTags': personId + '/collections/tags',
'followers': personId + '/followers',
'following': personId + '/following',
'tts': personId + '/speaker',
'shares': personId + '/catalog',
'hasOccupation': [
{
'@type': 'Occupation',
'name': "",
"occupationLocation": {
"@type": "City",
"name": "Fediverse"
},
'skills': []
}
],
'availability': None,
'icon': {
'mediaType': 'image/png',
'type': 'Image',
'url': iconUrl
},
'id': personId,
'image': {
'mediaType': 'image/png',
'type': 'Image',
'url': imageUrl
},
'inbox': inboxStr,
'manuallyApprovesFollowers': approveFollowers,
'discoverable': True,
'name': personName,
'outbox': personId + '/outbox',
'preferredUsername': personName,
'summary': '',
'publicKey': {
'id': personId + '#main-key',
'owner': personId,
'publicKeyPem': publicKeyPem
},
'tag': [],
'type': personType,
'url': personUrl
}
if nickname == 'inbox':
# fields not needed by the shared inbox
del newPerson['outbox']
del newPerson['icon']
del newPerson['image']
if newPerson.get('skills'):
del newPerson['skills']
del newPerson['shares']
if newPerson.get('roles'):
del newPerson['roles']
del newPerson['tag']
del newPerson['availability']
del newPerson['followers']
del newPerson['following']
del newPerson['attachment']
if saveToFile:
# save person to file
peopleSubdir = '/accounts'
if not os.path.isdir(base_dir + peopleSubdir):
os.mkdir(base_dir + peopleSubdir)
if not os.path.isdir(base_dir + peopleSubdir + '/' + handle):
os.mkdir(base_dir + peopleSubdir + '/' + handle)
if not os.path.isdir(base_dir + peopleSubdir + '/' +
handle + '/inbox'):
os.mkdir(base_dir + peopleSubdir + '/' + handle + '/inbox')
if not os.path.isdir(base_dir + peopleSubdir + '/' +
handle + '/outbox'):
os.mkdir(base_dir + peopleSubdir + '/' + handle + '/outbox')
if not os.path.isdir(base_dir + peopleSubdir + '/' +
handle + '/queue'):
os.mkdir(base_dir + peopleSubdir + '/' + handle + '/queue')
filename = base_dir + peopleSubdir + '/' + handle + '.json'
save_json(newPerson, filename)
# save to cache
if not os.path.isdir(base_dir + '/cache'):
os.mkdir(base_dir + '/cache')
if not os.path.isdir(base_dir + '/cache/actors'):
os.mkdir(base_dir + '/cache/actors')
cacheFilename = base_dir + '/cache/actors/' + \
newPerson['id'].replace('/', '#') + '.json'
save_json(newPerson, cacheFilename)
# save the private key
privateKeysSubdir = '/keys/private'
if not os.path.isdir(base_dir + '/keys'):
os.mkdir(base_dir + '/keys')
if not os.path.isdir(base_dir + privateKeysSubdir):
os.mkdir(base_dir + privateKeysSubdir)
filename = base_dir + privateKeysSubdir + '/' + handle + '.key'
try:
with open(filename, 'w+') as text_file:
print(privateKeyPem, file=text_file)
except OSError:
print('EX: unable to save ' + filename)
# save the public key
publicKeysSubdir = '/keys/public'
if not os.path.isdir(base_dir + publicKeysSubdir):
os.mkdir(base_dir + publicKeysSubdir)
filename = base_dir + publicKeysSubdir + '/' + handle + '.pem'
try:
with open(filename, 'w+') as text_file:
print(publicKeyPem, file=text_file)
except OSError:
print('EX: unable to save 2 ' + filename)
if password:
password = remove_line_endings(password)
store_basic_credentials(base_dir, nickname, password)
return privateKeyPem, publicKeyPem, newPerson, webfingerEndpoint
def register_account(base_dir: str, http_prefix: str, domain: str, port: int,
nickname: str, password: str,
manual_follower_approval: bool) -> bool:
"""Registers a new account from the web interface
"""
if _account_exists(base_dir, nickname, domain):
return False
if not valid_nickname(domain, nickname):
print('REGISTER: Nickname ' + nickname + ' is invalid')
return False
if len(password) < 8:
print('REGISTER: Password should be at least 8 characters')
return False
(privateKeyPem, publicKeyPem,
newPerson, webfingerEndpoint) = create_person(base_dir, nickname,
domain, port,
http_prefix, True,
manual_follower_approval,
password)
if privateKeyPem:
return True
return False
def create_group(base_dir: str, nickname: str, domain: str, port: int,
http_prefix: str, saveToFile: bool,
password: str = None) -> (str, str, {}, {}):
"""Returns a group
"""
(privateKeyPem, publicKeyPem,
newPerson, webfingerEndpoint) = create_person(base_dir, nickname,
domain, port,
http_prefix, saveToFile,
False, password, True)
return privateKeyPem, publicKeyPem, newPerson, webfingerEndpoint
def save_person_qrcode(base_dir: str,
nickname: str, domain: str, port: int,
scale=6) -> None:
"""Saves a qrcode image for the handle of the person
This helps to transfer onion or i2p handles to a mobile device
"""
qrcodeFilename = acct_dir(base_dir, nickname, domain) + '/qrcode.png'
if os.path.isfile(qrcodeFilename):
return
handle = get_full_domain('@' + nickname + '@' + domain, port)
url = pyqrcode.create(handle)
url.png(qrcodeFilename, scale)
def create_person(base_dir: str, nickname: str, domain: str, port: int,
http_prefix: str, saveToFile: bool,
manual_follower_approval: bool,
password: str,
group_account: bool = False) -> (str, str, {}, {}):
"""Returns the private key, public key, actor and webfinger endpoint
"""
if not valid_nickname(domain, nickname):
return None, None, None, None
# If a config.json file doesn't exist then don't decrement
# remaining registrations counter
if nickname != 'news':
remainingConfigExists = \
get_config_param(base_dir, 'registrationsRemaining')
if remainingConfigExists:
registrationsRemaining = int(remainingConfigExists)
if registrationsRemaining <= 0:
return None, None, None, None
else:
if os.path.isdir(base_dir + '/accounts/news@' + domain):
# news account already exists
return None, None, None, None
manual_follower = manual_follower_approval
(privateKeyPem, publicKeyPem,
newPerson, webfingerEndpoint) = _create_person_base(base_dir, nickname,
domain, port,
http_prefix,
saveToFile,
manual_follower,
group_account,
password)
if not get_config_param(base_dir, 'admin'):
if nickname != 'news':
# print(nickname+' becomes the instance admin and a moderator')
set_config_param(base_dir, 'admin', nickname)
set_role(base_dir, nickname, domain, 'admin')
set_role(base_dir, nickname, domain, 'moderator')
set_role(base_dir, nickname, domain, 'editor')
if not os.path.isdir(base_dir + '/accounts'):
os.mkdir(base_dir + '/accounts')
accountDir = acct_dir(base_dir, nickname, domain)
if not os.path.isdir(accountDir):
os.mkdir(accountDir)
if manual_follower_approval:
followDMsFilename = \
acct_dir(base_dir, nickname, domain) + '/.followDMs'
try:
with open(followDMsFilename, 'w+') as fFile:
fFile.write('\n')
except OSError:
print('EX: unable to write ' + followDMsFilename)
# notify when posts are liked
if nickname != 'news':
notifyLikesFilename = \
acct_dir(base_dir, nickname, domain) + '/.notifyLikes'
try:
with open(notifyLikesFilename, 'w+') as nFile:
nFile.write('\n')
except OSError:
print('EX: unable to write ' + notifyLikesFilename)
# notify when posts have emoji reactions
if nickname != 'news':
notifyReactionsFilename = \
acct_dir(base_dir, nickname, domain) + '/.notifyReactions'
try:
with open(notifyReactionsFilename, 'w+') as nFile:
nFile.write('\n')
except OSError:
print('EX: unable to write ' + notifyReactionsFilename)
theme = get_config_param(base_dir, 'theme')
if not theme:
theme = 'default'
if nickname != 'news':
if os.path.isfile(base_dir + '/img/default-avatar.png'):
accountDir = acct_dir(base_dir, nickname, domain)
copyfile(base_dir + '/img/default-avatar.png',
accountDir + '/avatar.png')
else:
newsAvatar = base_dir + '/theme/' + theme + '/icons/avatar_news.png'
if os.path.isfile(newsAvatar):
accountDir = acct_dir(base_dir, nickname, domain)
copyfile(newsAvatar, accountDir + '/avatar.png')
defaultProfileImageFilename = base_dir + '/theme/default/image.png'
if theme:
if os.path.isfile(base_dir + '/theme/' + theme + '/image.png'):
defaultProfileImageFilename = \
base_dir + '/theme/' + theme + '/image.png'
if os.path.isfile(defaultProfileImageFilename):
accountDir = acct_dir(base_dir, nickname, domain)
copyfile(defaultProfileImageFilename, accountDir + '/image.png')
defaultBannerFilename = base_dir + '/theme/default/banner.png'
if theme:
if os.path.isfile(base_dir + '/theme/' + theme + '/banner.png'):
defaultBannerFilename = \
base_dir + '/theme/' + theme + '/banner.png'
if os.path.isfile(defaultBannerFilename):
accountDir = acct_dir(base_dir, nickname, domain)
copyfile(defaultBannerFilename, accountDir + '/banner.png')
if nickname != 'news' and remainingConfigExists:
registrationsRemaining -= 1
set_config_param(base_dir, 'registrationsRemaining',
str(registrationsRemaining))
save_person_qrcode(base_dir, nickname, domain, port)
return privateKeyPem, publicKeyPem, newPerson, webfingerEndpoint
def create_shared_inbox(base_dir: str, nickname: str, domain: str, port: int,
http_prefix: str) -> (str, str, {}, {}):
"""Generates the shared inbox
"""
return _create_person_base(base_dir, nickname, domain, port, http_prefix,
True, True, False, None)
def create_news_inbox(base_dir: str, domain: str, port: int,
http_prefix: str) -> (str, str, {}, {}):
"""Generates the news inbox
"""
return create_person(base_dir, 'news', domain, port,
http_prefix, True, True, None)
def person_upgrade_actor(base_dir: str, personJson: {},
handle: str, filename: str) -> None:
"""Alter the actor to add any new properties
"""
updateActor = False
if not os.path.isfile(filename):
print('WARN: actor file not found ' + filename)
return
if not personJson:
personJson = load_json(filename)
# add a speaker endpoint
if not personJson.get('tts'):
personJson['tts'] = personJson['id'] + '/speaker'
updateActor = True
if not personJson.get('published'):
statusNumber, published = get_status_number()
personJson['published'] = published
updateActor = True
if personJson.get('shares'):
if personJson['shares'].endswith('/shares'):
personJson['shares'] = personJson['id'] + '/catalog'
updateActor = True
occupationName = ''
if personJson.get('occupationName'):
occupationName = personJson['occupationName']
del personJson['occupationName']
updateActor = True
if personJson.get('occupation'):
occupationName = personJson['occupation']
del personJson['occupation']
updateActor = True
# if the older skills format is being used then switch
# to the new one
if not personJson.get('hasOccupation'):
personJson['hasOccupation'] = [{
'@type': 'Occupation',
'name': occupationName,
"occupationLocation": {
"@type": "City",
"name": "Fediverse"
},
'skills': []
}]
updateActor = True
# remove the old skills format
if personJson.get('skills'):
del personJson['skills']
updateActor = True
# if the older roles format is being used then switch
# to the new one
if personJson.get('affiliation'):
del personJson['affiliation']
updateActor = True
if not isinstance(personJson['hasOccupation'], list):
personJson['hasOccupation'] = [{
'@type': 'Occupation',
'name': occupationName,
'occupationLocation': {
'@type': 'City',
'name': 'Fediverse'
},
'skills': []
}]
updateActor = True
else:
# add location if it is missing
for index in range(len(personJson['hasOccupation'])):
ocItem = personJson['hasOccupation'][index]
if ocItem.get('hasOccupation'):
ocItem = ocItem['hasOccupation']
if ocItem.get('location'):
del ocItem['location']
updateActor = True
if not ocItem.get('occupationLocation'):
ocItem['occupationLocation'] = {
"@type": "City",
"name": "Fediverse"
}
updateActor = True
else:
if ocItem['occupationLocation']['@type'] != 'City':
ocItem['occupationLocation'] = {
"@type": "City",
"name": "Fediverse"
}
updateActor = True
# if no roles are defined then ensure that the admin
# roles are configured
rolesList = get_actor_roles_list(personJson)
if not rolesList:
admin_name = get_config_param(base_dir, 'admin')
if personJson['id'].endswith('/users/' + admin_name):
rolesList = ["admin", "moderator", "editor"]
set_rolesFromList(personJson, rolesList)
updateActor = True
# remove the old roles format
if personJson.get('roles'):
del personJson['roles']
updateActor = True
if updateActor:
personJson['@context'] = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
get_default_person_context()
],
save_json(personJson, filename)
# also update the actor within the cache
actorCacheFilename = \
base_dir + '/accounts/cache/actors/' + \
personJson['id'].replace('/', '#') + '.json'
if os.path.isfile(actorCacheFilename):
save_json(personJson, actorCacheFilename)
# update domain/@nickname in actors cache
actorCacheFilename = \
base_dir + '/accounts/cache/actors/' + \
replace_users_with_at(personJson['id']).replace('/', '#') + \
'.json'
if os.path.isfile(actorCacheFilename):
save_json(personJson, actorCacheFilename)
def person_lookup(domain: str, path: str, base_dir: str) -> {}:
"""Lookup the person for an given nickname
"""
if path.endswith('#main-key'):
path = path.replace('#main-key', '')
# is this a shared inbox lookup?
isSharedInbox = False
if path == '/inbox' or path == '/users/inbox' or path == '/sharedInbox':
# shared inbox actor on @domain@domain
path = '/users/inbox'
isSharedInbox = True
else:
notPersonLookup = ('/inbox', '/outbox', '/outboxarchive',
'/followers', '/following', '/featured',
'.png', '.jpg', '.gif', '.svg', '.mpv')
for ending in notPersonLookup:
if path.endswith(ending):
return None
nickname = None
if path.startswith('/users/'):
nickname = path.replace('/users/', '', 1)
if path.startswith('/@'):
nickname = path.replace('/@', '', 1)
if not nickname:
return None
if not isSharedInbox and not valid_nickname(domain, nickname):
return None
domain = remove_domain_port(domain)
handle = nickname + '@' + domain
filename = base_dir + '/accounts/' + handle + '.json'
if not os.path.isfile(filename):
return None
personJson = load_json(filename)
if not isSharedInbox:
person_upgrade_actor(base_dir, personJson, handle, filename)
# if not personJson:
# personJson={"user": "unknown"}
return personJson
def person_box_json(recent_posts_cache: {},
session, base_dir: str, domain: str, port: int, path: str,
http_prefix: str, noOfItems: int, boxname: str,
authorized: bool,
newswire_votes_threshold: int, positive_voting: bool,
voting_time_mins: int) -> {}:
"""Obtain the inbox/outbox/moderation feed for the given person
"""
if boxname != 'inbox' and boxname != 'dm' and \
boxname != 'tlreplies' and boxname != 'tlmedia' and \
boxname != 'tlblogs' and boxname != 'tlnews' and \
boxname != 'tlfeatures' and \
boxname != 'outbox' and boxname != 'moderation' and \
boxname != 'tlbookmarks' and boxname != 'bookmarks':
print('ERROR: person_box_json invalid box name ' + boxname)
return None
if not '/' + boxname 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 BaseException:
print('EX: person_box_json unable to convert to int ' +
str(pageNumber))
pass
path = path.split('?page=')[0]
headerOnly = False
if not path.endswith('/' + boxname):
return None
nickname = None
if path.startswith('/users/'):
nickname = path.replace('/users/', '', 1).replace('/' + boxname, '')
if path.startswith('/@'):
nickname = path.replace('/@', '', 1).replace('/' + boxname, '')
if not nickname:
return None
if not valid_nickname(domain, nickname):
return None
if boxname == 'inbox':
return create_inbox(recent_posts_cache,
session, base_dir, nickname, domain, port,
http_prefix,
noOfItems, headerOnly, pageNumber)
elif boxname == 'dm':
return create_dm_timeline(recent_posts_cache,
session, base_dir, nickname, domain, port,
http_prefix,
noOfItems, headerOnly, pageNumber)
elif boxname == 'tlbookmarks' or boxname == 'bookmarks':
return create_bookmarks_timeline(session, base_dir, nickname, domain,
port, http_prefix,
noOfItems, headerOnly,
pageNumber)
elif boxname == 'tlreplies':
return create_replies_timeline(recent_posts_cache,
session, base_dir, nickname, domain,
port, http_prefix,
noOfItems, headerOnly,
pageNumber)
elif boxname == 'tlmedia':
return create_media_timeline(session, base_dir, nickname, domain, port,
http_prefix, noOfItems, headerOnly,
pageNumber)
elif boxname == 'tlnews':
return create_news_timeline(session, base_dir, nickname, domain, port,
http_prefix, noOfItems, headerOnly,
newswire_votes_threshold, positive_voting,
voting_time_mins, pageNumber)
elif boxname == 'tlfeatures':
return create_features_timeline(session, base_dir,
nickname, domain, port,
http_prefix, noOfItems, headerOnly,
pageNumber)
elif boxname == 'tlblogs':
return create_blogs_timeline(session, base_dir, nickname, domain, port,
http_prefix, noOfItems, headerOnly,
pageNumber)
elif boxname == 'outbox':
return create_outbox(session, base_dir, nickname, domain, port,
http_prefix,
noOfItems, headerOnly, authorized,
pageNumber)
elif boxname == 'moderation':
return create_moderation(base_dir, nickname, domain, port,
http_prefix,
noOfItems, headerOnly,
pageNumber)
return None
def set_display_nickname(base_dir: str, nickname: str, domain: str,
displayName: str) -> bool:
if len(displayName) > 32:
return False
handle = nickname + '@' + domain
filename = base_dir + '/accounts/' + handle + '.json'
if not os.path.isfile(filename):
return False
personJson = load_json(filename)
if not personJson:
return False
personJson['name'] = displayName
save_json(personJson, filename)
return True
def set_bio(base_dir: str, nickname: str, domain: str, bio: str) -> bool:
"""Only used within tests
"""
if len(bio) > 32:
return False
handle = nickname + '@' + domain
filename = base_dir + '/accounts/' + handle + '.json'
if not os.path.isfile(filename):
return False
personJson = load_json(filename)
if not personJson:
return False
if not personJson.get('summary'):
return False
personJson['summary'] = bio
save_json(personJson, filename)
return True
def reenable_account(base_dir: str, nickname: str) -> None:
"""Removes an account suspention
"""
suspendedFilename = base_dir + '/accounts/suspended.txt'
if os.path.isfile(suspendedFilename):
lines = []
with open(suspendedFilename, 'r') as f:
lines = f.readlines()
try:
with open(suspendedFilename, 'w+') as suspendedFile:
for suspended in lines:
if suspended.strip('\n').strip('\r') != nickname:
suspendedFile.write(suspended)
except OSError as ex:
print('EX: unable to save ' + suspendedFilename +
' ' + str(ex))
def suspend_account(base_dir: str, nickname: str, domain: str) -> None:
"""Suspends the given account
"""
# Don't suspend the admin
adminNickname = get_config_param(base_dir, 'admin')
if not adminNickname:
return
if nickname == adminNickname:
return
# Don't suspend moderators
moderatorsFile = base_dir + '/accounts/moderators.txt'
if os.path.isfile(moderatorsFile):
with open(moderatorsFile, 'r') as f:
lines = f.readlines()
for moderator in lines:
if moderator.strip('\n').strip('\r') == nickname:
return
saltFilename = acct_dir(base_dir, nickname, domain) + '/.salt'
if os.path.isfile(saltFilename):
try:
os.remove(saltFilename)
except OSError:
print('EX: suspend_account unable to delete ' + saltFilename)
tokenFilename = acct_dir(base_dir, nickname, domain) + '/.token'
if os.path.isfile(tokenFilename):
try:
os.remove(tokenFilename)
except OSError:
print('EX: suspend_account unable to delete ' + tokenFilename)
suspendedFilename = base_dir + '/accounts/suspended.txt'
if os.path.isfile(suspendedFilename):
with open(suspendedFilename, 'r') as f:
lines = f.readlines()
for suspended in lines:
if suspended.strip('\n').strip('\r') == nickname:
return
try:
with open(suspendedFilename, 'a+') as suspendedFile:
suspendedFile.write(nickname + '\n')
except OSError:
print('EX: unable to append ' + suspendedFilename)
else:
try:
with open(suspendedFilename, 'w+') as suspendedFile:
suspendedFile.write(nickname + '\n')
except OSError:
print('EX: unable to write ' + suspendedFilename)
def can_remove_post(base_dir: str, nickname: str,
domain: str, port: int, post_id: str) -> bool:
"""Returns true if the given post can be removed
"""
if '/statuses/' not in post_id:
return False
domain_full = get_full_domain(domain, port)
# is the post by the admin?
adminNickname = get_config_param(base_dir, 'admin')
if not adminNickname:
return False
if domain_full + '/users/' + adminNickname + '/' in post_id:
return False
# is the post by a moderator?
moderatorsFile = base_dir + '/accounts/moderators.txt'
if os.path.isfile(moderatorsFile):
with open(moderatorsFile, 'r') as f:
lines = f.readlines()
for moderator in lines:
if domain_full + '/users/' + \
moderator.strip('\n') + '/' in post_id:
return False
return True
def _remove_tags_for_nickname(base_dir: str, nickname: str,
domain: str, port: int) -> None:
"""Removes tags for a nickname
"""
if not os.path.isdir(base_dir + '/tags'):
return
domain_full = get_full_domain(domain, port)
matchStr = domain_full + '/users/' + nickname + '/'
directory = os.fsencode(base_dir + '/tags/')
for f in os.scandir(directory):
f = f.name
filename = os.fsdecode(f)
if not filename.endswith(".txt"):
continue
try:
tagFilename = os.path.join(directory, filename)
except BaseException:
print('EX: _remove_tags_for_nickname unable to join ' +
str(directory) + ' ' + str(filename))
continue
if not os.path.isfile(tagFilename):
continue
if matchStr not in open(tagFilename).read():
continue
lines = []
with open(tagFilename, 'r') as f:
lines = f.readlines()
try:
with open(tagFilename, 'w+') as tagFile:
for tagline in lines:
if matchStr not in tagline:
tagFile.write(tagline)
except OSError:
print('EX: unable to write ' + tagFilename)
def remove_account(base_dir: str, nickname: str,
domain: str, port: int) -> bool:
"""Removes an account
"""
# Don't remove the admin
adminNickname = get_config_param(base_dir, 'admin')
if not adminNickname:
return False
if nickname == adminNickname:
return False
# Don't remove moderators
moderatorsFile = base_dir + '/accounts/moderators.txt'
if os.path.isfile(moderatorsFile):
with open(moderatorsFile, 'r') as f:
lines = f.readlines()
for moderator in lines:
if moderator.strip('\n') == nickname:
return False
reenable_account(base_dir, nickname)
handle = nickname + '@' + domain
remove_password(base_dir, nickname)
_remove_tags_for_nickname(base_dir, nickname, domain, port)
if os.path.isdir(base_dir + '/deactivated/' + handle):
shutil.rmtree(base_dir + '/deactivated/' + handle,
ignore_errors=False, onerror=None)
if os.path.isdir(base_dir + '/accounts/' + handle):
shutil.rmtree(base_dir + '/accounts/' + handle,
ignore_errors=False, onerror=None)
if os.path.isfile(base_dir + '/accounts/' + handle + '.json'):
try:
os.remove(base_dir + '/accounts/' + handle + '.json')
except OSError:
print('EX: remove_account unable to delete ' +
base_dir + '/accounts/' + handle + '.json')
if os.path.isfile(base_dir + '/wfendpoints/' + handle + '.json'):
try:
os.remove(base_dir + '/wfendpoints/' + handle + '.json')
except OSError:
print('EX: remove_account unable to delete ' +
base_dir + '/wfendpoints/' + handle + '.json')
if os.path.isfile(base_dir + '/keys/private/' + handle + '.key'):
try:
os.remove(base_dir + '/keys/private/' + handle + '.key')
except OSError:
print('EX: remove_account unable to delete ' +
base_dir + '/keys/private/' + handle + '.key')
if os.path.isfile(base_dir + '/keys/public/' + handle + '.pem'):
try:
os.remove(base_dir + '/keys/public/' + handle + '.pem')
except OSError:
print('EX: remove_account unable to delete ' +
base_dir + '/keys/public/' + handle + '.pem')
if os.path.isdir(base_dir + '/sharefiles/' + nickname):
shutil.rmtree(base_dir + '/sharefiles/' + nickname,
ignore_errors=False, onerror=None)
if os.path.isfile(base_dir + '/wfdeactivated/' + handle + '.json'):
try:
os.remove(base_dir + '/wfdeactivated/' + handle + '.json')
except OSError:
print('EX: remove_account unable to delete ' +
base_dir + '/wfdeactivated/' + handle + '.json')
if os.path.isdir(base_dir + '/sharefilesdeactivated/' + nickname):
shutil.rmtree(base_dir + '/sharefilesdeactivated/' + nickname,
ignore_errors=False, onerror=None)
refresh_newswire(base_dir)
return True
def deactivate_account(base_dir: str, nickname: str, domain: str) -> bool:
"""Makes an account temporarily unavailable
"""
handle = nickname + '@' + domain
accountDir = base_dir + '/accounts/' + handle
if not os.path.isdir(accountDir):
return False
deactivatedDir = base_dir + '/deactivated'
if not os.path.isdir(deactivatedDir):
os.mkdir(deactivatedDir)
shutil.move(accountDir, deactivatedDir + '/' + handle)
if os.path.isfile(base_dir + '/wfendpoints/' + handle + '.json'):
deactivatedWebfingerDir = base_dir + '/wfdeactivated'
if not os.path.isdir(deactivatedWebfingerDir):
os.mkdir(deactivatedWebfingerDir)
shutil.move(base_dir + '/wfendpoints/' + handle + '.json',
deactivatedWebfingerDir + '/' + handle + '.json')
if os.path.isdir(base_dir + '/sharefiles/' + nickname):
deactivatedSharefilesDir = base_dir + '/sharefilesdeactivated'
if not os.path.isdir(deactivatedSharefilesDir):
os.mkdir(deactivatedSharefilesDir)
shutil.move(base_dir + '/sharefiles/' + nickname,
deactivatedSharefilesDir + '/' + nickname)
refresh_newswire(base_dir)
return os.path.isdir(deactivatedDir + '/' + nickname + '@' + domain)
def activate_account(base_dir: str, nickname: str, domain: str) -> None:
"""Makes a deactivated account available
"""
handle = nickname + '@' + domain
deactivatedDir = base_dir + '/deactivated'
deactivatedAccountDir = deactivatedDir + '/' + handle
if os.path.isdir(deactivatedAccountDir):
accountDir = base_dir + '/accounts/' + handle
if not os.path.isdir(accountDir):
shutil.move(deactivatedAccountDir, accountDir)
deactivatedWebfingerDir = base_dir + '/wfdeactivated'
if os.path.isfile(deactivatedWebfingerDir + '/' + handle + '.json'):
shutil.move(deactivatedWebfingerDir + '/' + handle + '.json',
base_dir + '/wfendpoints/' + handle + '.json')
deactivatedSharefilesDir = base_dir + '/sharefilesdeactivated'
if os.path.isdir(deactivatedSharefilesDir + '/' + nickname):
if not os.path.isdir(base_dir + '/sharefiles/' + nickname):
shutil.move(deactivatedSharefilesDir + '/' + nickname,
base_dir + '/sharefiles/' + nickname)
refresh_newswire(base_dir)
def is_person_snoozed(base_dir: str, nickname: str, domain: str,
snoozeActor: str) -> bool:
"""Returns true if the given actor is snoozed
"""
snoozedFilename = acct_dir(base_dir, nickname, domain) + '/snoozed.txt'
if not os.path.isfile(snoozedFilename):
return False
if snoozeActor + ' ' not in open(snoozedFilename).read():
return False
# remove the snooze entry if it has timed out
replaceStr = None
with open(snoozedFilename, 'r') as snoozedFile:
for line in snoozedFile:
# is this the entry for the actor?
if line.startswith(snoozeActor + ' '):
snoozedTimeStr = \
line.split(' ')[1].replace('\n', '').replace('\r', '')
# is there a time appended?
if snoozedTimeStr.isdigit():
snoozedTime = int(snoozedTimeStr)
curr_time = int(time.time())
# has the snooze timed out?
if int(curr_time - snoozedTime) > 60 * 60 * 24:
replaceStr = line
else:
replaceStr = line
break
if replaceStr:
content = None
with open(snoozedFilename, 'r') as snoozedFile:
content = snoozedFile.read().replace(replaceStr, '')
if content:
try:
with open(snoozedFilename, 'w+') as writeSnoozedFile:
writeSnoozedFile.write(content)
except OSError:
print('EX: unable to write ' + snoozedFilename)
if snoozeActor + ' ' in open(snoozedFilename).read():
return True
return False
def person_snooze(base_dir: str, nickname: str, domain: str,
snoozeActor: str) -> None:
"""Temporarily ignores the given actor
"""
accountDir = acct_dir(base_dir, nickname, domain)
if not os.path.isdir(accountDir):
print('ERROR: unknown account ' + accountDir)
return
snoozedFilename = accountDir + '/snoozed.txt'
if os.path.isfile(snoozedFilename):
if snoozeActor + ' ' in open(snoozedFilename).read():
return
try:
with open(snoozedFilename, 'a+') as snoozedFile:
snoozedFile.write(snoozeActor + ' ' +
str(int(time.time())) + '\n')
except OSError:
print('EX: unable to append ' + snoozedFilename)
def person_unsnooze(base_dir: str, nickname: str, domain: str,
snoozeActor: str) -> None:
"""Undoes a temporarily ignore of the given actor
"""
accountDir = acct_dir(base_dir, nickname, domain)
if not os.path.isdir(accountDir):
print('ERROR: unknown account ' + accountDir)
return
snoozedFilename = accountDir + '/snoozed.txt'
if not os.path.isfile(snoozedFilename):
return
if snoozeActor + ' ' not in open(snoozedFilename).read():
return
replaceStr = None
with open(snoozedFilename, 'r') as snoozedFile:
for line in snoozedFile:
if line.startswith(snoozeActor + ' '):
replaceStr = line
break
if replaceStr:
content = None
with open(snoozedFilename, 'r') as snoozedFile:
content = snoozedFile.read().replace(replaceStr, '')
if content:
try:
with open(snoozedFilename, 'w+') as writeSnoozedFile:
writeSnoozedFile.write(content)
except OSError:
print('EX: unable to write ' + snoozedFilename)
def set_person_notes(base_dir: str, nickname: str, domain: str,
handle: str, notes: str) -> bool:
"""Adds notes about a person
"""
if '@' not in handle:
return False
if handle.startswith('@'):
handle = handle[1:]
notesDir = acct_dir(base_dir, nickname, domain) + '/notes'
if not os.path.isdir(notesDir):
os.mkdir(notesDir)
notesFilename = notesDir + '/' + handle + '.txt'
try:
with open(notesFilename, 'w+') as notesFile:
notesFile.write(notes)
except OSError:
print('EX: unable to write ' + notesFilename)
return False
return True
def _detect_users_path(url: str) -> str:
"""Tries to detect the /users/ path
"""
if '/' not in url:
return '/users/'
usersPaths = get_user_paths()
for possibleUsersPath in usersPaths:
if possibleUsersPath in url:
return possibleUsersPath
return '/users/'
def get_actor_json(hostDomain: str, handle: str, http: bool, gnunet: bool,
debug: bool, quiet: bool,
signing_priv_key_pem: str,
existingSession) -> ({}, {}):
"""Returns the actor json
"""
if debug:
print('get_actor_json for ' + handle)
originalActor = handle
group_account = False
# try to determine the users path
detectedUsersPath = _detect_users_path(handle)
if '/@' in handle or \
detectedUsersPath in handle or \
handle.startswith('http') or \
handle.startswith('hyper'):
groupPaths = get_group_paths()
if detectedUsersPath in groupPaths:
group_account = True
# format: https://domain/@nick
originalHandle = handle
if not has_users_path(originalHandle):
if not quiet or debug:
print('get_actor_json: Expected actor format: ' +
'https://domain/@nick or https://domain' +
detectedUsersPath + 'nick')
return None, None
prefixes = get_protocol_prefixes()
for prefix in prefixes:
handle = handle.replace(prefix, '')
handle = handle.replace('/@', detectedUsersPath)
paths = get_user_paths()
userPathFound = False
for userPath in paths:
if userPath in handle:
nickname = handle.split(userPath)[1]
nickname = nickname.replace('\n', '').replace('\r', '')
domain = handle.split(userPath)[0]
userPathFound = True
break
if not userPathFound and '://' in originalHandle:
domain = originalHandle.split('://')[1]
if '/' in domain:
domain = domain.split('/')[0]
if '://' + domain + '/' not in originalHandle:
return None, None
nickname = originalHandle.split('://' + domain + '/')[1]
if '/' in nickname or '.' in nickname:
return None, None
else:
# format: @nick@domain
if '@' not in handle:
if not quiet:
print('get_actor_json Syntax: --actor nickname@domain')
return None, None
if handle.startswith('@'):
handle = handle[1:]
elif handle.startswith('!'):
# handle for a group
handle = handle[1:]
group_account = True
if '@' not in handle:
if not quiet:
print('get_actor_jsonSyntax: --actor nickname@domain')
return None, None
nickname = handle.split('@')[0]
domain = handle.split('@')[1]
domain = domain.replace('\n', '').replace('\r', '')
cached_webfingers = {}
proxy_type = None
if http or domain.endswith('.onion'):
http_prefix = 'http'
proxy_type = 'tor'
elif domain.endswith('.i2p'):
http_prefix = 'http'
proxy_type = 'i2p'
elif gnunet:
http_prefix = 'gnunet'
proxy_type = 'gnunet'
else:
if '127.0.' not in domain and '192.168.' not in domain:
http_prefix = 'https'
else:
http_prefix = 'http'
if existingSession:
session = existingSession
else:
session = create_session(proxy_type)
if nickname == 'inbox':
nickname = domain
personUrl = None
wfRequest = None
if '://' in originalActor and \
originalActor.lower().endswith('/actor'):
if debug:
print(originalActor + ' is an instance actor')
personUrl = originalActor
elif '://' in originalActor and group_account:
if debug:
print(originalActor + ' is a group actor')
personUrl = originalActor
else:
handle = nickname + '@' + domain
wfRequest = webfinger_handle(session, handle,
http_prefix, cached_webfingers,
hostDomain, __version__, debug,
group_account, signing_priv_key_pem)
if not wfRequest:
if not quiet:
print('get_actor_json Unable to webfinger ' + handle)
return None, None
if not isinstance(wfRequest, dict):
if not quiet:
print('get_actor_json Webfinger for ' + handle +
' did not return a dict. ' + str(wfRequest))
return None, None
if not quiet:
pprint(wfRequest)
if wfRequest.get('errors'):
if not quiet or debug:
print('get_actor_json wfRequest error: ' +
str(wfRequest['errors']))
if has_users_path(handle):
personUrl = originalActor
else:
if debug:
print('No users path in ' + handle)
return None, None
profileStr = 'https://www.w3.org/ns/activitystreams'
headersList = (
"activity+json", "ld+json", "jrd+json"
)
if not personUrl and wfRequest:
personUrl = get_user_url(wfRequest, 0, debug)
if nickname == domain:
paths = get_user_paths()
for userPath in paths:
personUrl = personUrl.replace(userPath, '/actor/')
if not personUrl and group_account:
personUrl = http_prefix + '://' + domain + '/c/' + nickname
if not personUrl:
# try single user instance
personUrl = http_prefix + '://' + domain + '/' + nickname
headersList = (
"ld+json", "jrd+json", "activity+json"
)
if debug:
print('Trying single user instance ' + personUrl)
if '/channel/' in personUrl or '/accounts/' in personUrl:
headersList = (
"ld+json", "jrd+json", "activity+json"
)
if debug:
print('personUrl: ' + personUrl)
for headerType in headersList:
headerMimeType = 'application/' + headerType
asHeader = {
'Accept': headerMimeType + '; profile="' + profileStr + '"'
}
personJson = \
get_json(signing_priv_key_pem, session, personUrl, asHeader, None,
debug, __version__, http_prefix, hostDomain, 20, quiet)
if personJson:
if not quiet:
pprint(personJson)
return personJson, asHeader
return None, None
def get_person_avatar_url(base_dir: str, personUrl: str, person_cache: {},
allowDownloads: bool) -> str:
"""Returns the avatar url for the person
"""
personJson = \
get_person_from_cache(base_dir, personUrl, person_cache,
allowDownloads)
if not personJson:
return None
# get from locally stored image
if not personJson.get('id'):
return None
actorStr = personJson['id'].replace('/', '-')
avatarImagePath = base_dir + '/cache/avatars/' + actorStr
imageExtension = get_image_extensions()
for ext in imageExtension:
imFilename = avatarImagePath + '.' + ext
imPath = '/avatars/' + actorStr + '.' + ext
if not os.path.isfile(imFilename):
imFilename = avatarImagePath.lower() + '.' + ext
imPath = '/avatars/' + actorStr.lower() + '.' + ext
if not os.path.isfile(imFilename):
continue
if ext != 'svg':
return imPath
else:
content = ''
with open(imFilename, 'r') as fp:
content = fp.read()
if not dangerous_svg(content, False):
return imPath
if personJson.get('icon'):
if personJson['icon'].get('url'):
if '.svg' not in personJson['icon']['url'].lower():
return personJson['icon']['url']
return None
def add_actor_update_timestamp(actor_json: {}) -> None:
"""Adds 'updated' fields with a timestamp
"""
updatedTime = datetime.datetime.utcnow()
currDateStr = updatedTime.strftime("%Y-%m-%dT%H:%M:%SZ")
actor_json['updated'] = currDateStr
# add updated timestamp to avatar and banner
actor_json['icon']['updated'] = currDateStr
actor_json['image']['updated'] = currDateStr
def valid_sending_actor(session, base_dir: str,
nickname: str, domain: str,
person_cache: {},
post_json_object: {},
signing_priv_key_pem: str,
debug: bool, unit_test: bool) -> bool:
"""When a post arrives in the inbox this is used to check that
the sending actor is valid
"""
# who sent this post?
sendingActor = post_json_object['actor']
# If you are following them then allow their posts
if is_following_actor(base_dir, nickname, domain, sendingActor):
return True
# sending to yourself (reminder)
if sendingActor.endswith(domain + '/users/' + nickname):
return True
# get their actor
actor_json = \
get_person_from_cache(base_dir, sendingActor, person_cache, True)
downloadedActor = False
if not actor_json:
# download the actor
actor_json, _ = get_actor_json(domain, sendingActor,
True, False, debug, True,
signing_priv_key_pem, session)
if actor_json:
downloadedActor = True
if not actor_json:
# if the actor couldn't be obtained then proceed anyway
return True
if not actor_json.get('preferredUsername'):
print('REJECT: no preferredUsername within actor ' + str(actor_json))
return False
# does the actor have a bio ?
if not unit_test:
bioStr = ''
if actor_json.get('summary'):
bioStr = remove_html(actor_json['summary']).strip()
if not bioStr:
# allow no bio if it's an actor in this instance
if domain not in sendingActor:
# probably a spam actor with no bio
print('REJECT: spam actor ' + sendingActor)
return False
if len(bioStr) < 10:
print('REJECT: actor bio is not long enough ' +
sendingActor + ' ' + bioStr)
return False
bioStr += ' ' + remove_html(actor_json['preferredUsername'])
if actor_json.get('attachment'):
if isinstance(actor_json['attachment'], list):
for tag in actor_json['attachment']:
if not isinstance(tag, dict):
continue
if not tag.get('name'):
continue
if isinstance(tag['name'], str):
bioStr += ' ' + tag['name']
if tag.get('value'):
continue
if isinstance(tag['value'], str):
bioStr += ' ' + tag['value']
if actor_json.get('name'):
bioStr += ' ' + remove_html(actor_json['name'])
if contains_invalid_chars(bioStr):
print('REJECT: post actor bio contains invalid characters')
return False
if is_filtered_bio(base_dir, nickname, domain, bioStr):
print('REJECT: post actor bio contains filtered text')
return False
else:
print('Skipping check for missing bio in ' + sendingActor)
# Check any attached fields for the actor.
# Spam actors will sometimes have attached fields which are all empty
if actor_json.get('attachment'):
if isinstance(actor_json['attachment'], list):
noOfTags = 0
tagsWithoutValue = 0
for tag in actor_json['attachment']:
if not isinstance(tag, dict):
continue
if not tag.get('name'):
continue
noOfTags += 1
if not tag.get('value'):
tagsWithoutValue += 1
continue
if not isinstance(tag['value'], str):
tagsWithoutValue += 1
continue
if not tag['value'].strip():
tagsWithoutValue += 1
continue
if len(tag['value']) < 2:
tagsWithoutValue += 1
continue
if noOfTags > 0:
if int(tagsWithoutValue * 100 / noOfTags) > 50:
print('REJECT: actor has empty attachments ' +
sendingActor)
return False
if downloadedActor:
# if the actor is valid and was downloaded then
# store it in the cache, but don't write it to file
store_person_in_cache(base_dir, sendingActor, actor_json, person_cache,
False)
return True