Merge branch 'main' of ssh://code.freedombone.net:2222/bashrc/epicyon into main

main
Bob Mottram 2020-08-11 21:28:06 +01:00
commit 5d87949c6c
28 changed files with 598 additions and 82 deletions

View File

@ -118,6 +118,9 @@ def removeBlock(baseDir: str, nickname: str, domain: str,
def isBlockedHashtag(baseDir: str, hashtag: str) -> bool: def isBlockedHashtag(baseDir: str, hashtag: str) -> bool:
"""Is the given hashtag blocked? """Is the given hashtag blocked?
""" """
# avoid very long hashtags
if len(hashtag) > 32:
return True
globalBlockingFilename = baseDir + '/accounts/blocking.txt' globalBlockingFilename = baseDir + '/accounts/blocking.txt'
if os.path.isfile(globalBlockingFilename): if os.path.isfile(globalBlockingFilename):
hashtag = hashtag.strip('\n').strip('\r') hashtag = hashtag.strip('\n').strip('\r')

View File

@ -170,8 +170,10 @@ def htmlBlogPostContent(authorized: bool,
# get the handle of the author # get the handle of the author
if postJsonObject['object'].get('attributedTo'): if postJsonObject['object'].get('attributedTo'):
actor = postJsonObject['object']['attributedTo'] authorNickname = None
authorNickname = getNicknameFromActor(actor) if isinstance(postJsonObject['object']['attributedTo'], str):
actor = postJsonObject['object']['attributedTo']
authorNickname = getNicknameFromActor(actor)
if authorNickname: if authorNickname:
authorDomain, authorPort = getDomainFromActor(actor) authorDomain, authorPort = getDomainFromActor(actor)
if authorDomain: if authorDomain:

View File

@ -267,6 +267,9 @@ def addWebLinks(content: str) -> str:
def validHashTag(hashtag: str) -> bool: def validHashTag(hashtag: str) -> bool:
"""Returns true if the give hashtag contains valid characters """Returns true if the give hashtag contains valid characters
""" """
# long hashtags are not valid
if len(hashtag) >= 32:
return False
validChars = set('0123456789' + validChars = set('0123456789' +
'abcdefghijklmnopqrstuvwxyz' + 'abcdefghijklmnopqrstuvwxyz' +
'ABCDEFGHIJKLMNOPQRSTUVWXYZ') 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')

250
daemon.py
View File

@ -43,6 +43,8 @@ from matrix import getMatrixAddress
from matrix import setMatrixAddress from matrix import setMatrixAddress
from donate import getDonationUrl from donate import getDonationUrl
from donate import setDonationUrl from donate import setDonationUrl
from person import setPersonNotes
from person import getDefaultPersonContext
from person import savePersonQrcode from person import savePersonQrcode
from person import randomizeActorImages from person import randomizeActorImages
from person import personUpgradeActor from person import personUpgradeActor
@ -191,6 +193,9 @@ from bookmarks import undoBookmark
from petnames import setPetName from petnames import setPetName
from followingCalendar import addPersonToCalendar from followingCalendar import addPersonToCalendar
from followingCalendar import removePersonFromCalendar from followingCalendar import removePersonFromCalendar
from devices import E2EEdevicesCollection
from devices import E2EEvalidDevice
from devices import E2EEaddDevice
import os import os
@ -1047,6 +1052,8 @@ class PubServer(BaseHTTPRequestHandler):
return 1 return 1
def _isAuthorized(self) -> bool: def _isAuthorized(self) -> bool:
self.authorizedNickname = None
if self.path.startswith('/icons/') or \ if self.path.startswith('/icons/') or \
self.path.startswith('/avatars/') or \ self.path.startswith('/avatars/') or \
self.path.startswith('/favicon.ico'): self.path.startswith('/favicon.ico'):
@ -1060,6 +1067,7 @@ class PubServer(BaseHTTPRequestHandler):
tokenStr = tokenStr.split(';')[0].strip() tokenStr = tokenStr.split(';')[0].strip()
if self.server.tokensLookup.get(tokenStr): if self.server.tokensLookup.get(tokenStr):
nickname = self.server.tokensLookup[tokenStr] nickname = self.server.tokensLookup[tokenStr]
self.authorizedNickname = nickname
# default to the inbox of the person # default to the inbox of the person
if self.path == '/': if self.path == '/':
self.path = '/users/' + nickname + '/inbox' self.path = '/users/' + nickname + '/inbox'
@ -1535,6 +1543,25 @@ class PubServer(BaseHTTPRequestHandler):
self._404() self._404()
return return
# list of registered devices for e2ee
# see https://github.com/tootsuite/mastodon/pull/13820
if authorized and '/users/' in self.path:
if self.path.endswith('/collections/devices'):
nickname = self.path.split('/users/')
if '/' in nickname:
nickname = nickname.split('/')[0]
devJson = E2EEdevicesCollection(self.server.baseDir,
nickname,
self.server.domain,
self.server.domainFull,
self.server.httpPrefix)
msg = json.dumps(devJson,
ensure_ascii=False).encode('utf-8')
self._set_headers('application/json',
len(msg),
None, callingDomain)
self._write(msg)
if htmlGET and '/users/' in self.path: if htmlGET and '/users/' in self.path:
# show the person options screen with view/follow/block/report # show the person options screen with view/follow/block/report
if '?options=' in self.path: if '?options=' in self.path:
@ -1637,7 +1664,7 @@ class PubServer(BaseHTTPRequestHandler):
# remove a shared item # remove a shared item
if htmlGET and '?rmshare=' in self.path: if htmlGET and '?rmshare=' in self.path:
shareName = self.path.split('?rmshare=')[1] shareName = self.path.split('?rmshare=')[1]
shareName = urllib.parse.unquote(shareName.strip()) shareName = urllib.parse.unquote_plus(shareName.strip())
usersPath = self.path.split('?rmshare=')[0] usersPath = self.path.split('?rmshare=')[0]
actor = \ actor = \
self.server.httpPrefix + '://' + \ self.server.httpPrefix + '://' + \
@ -3336,7 +3363,7 @@ class PubServer(BaseHTTPRequestHandler):
shareDescription = \ shareDescription = \
inReplyToUrl.replace('sharedesc:', '') inReplyToUrl.replace('sharedesc:', '')
shareDescription = \ shareDescription = \
urllib.parse.unquote(shareDescription.strip()) urllib.parse.unquote_plus(shareDescription.strip())
self.path = self.path.split('?replydm=')[0]+'/newdm' self.path = self.path.split('?replydm=')[0]+'/newdm'
if self.server.debug: if self.server.debug:
print('DEBUG: replydm path ' + self.path) print('DEBUG: replydm path ' + self.path)
@ -5754,6 +5781,159 @@ class PubServer(BaseHTTPRequestHandler):
postBytes, boundary) postBytes, boundary)
return pageNumber return pageNumber
def _cryptoAPIreadHandle(self):
"""Reads handle
"""
messageBytes = None
maxDeviceIdLength = 2048
length = int(self.headers['Content-length'])
if length >= maxDeviceIdLength:
print('WARN: handle post to crypto API is too long ' +
str(length) + ' bytes')
return {}
try:
messageBytes = self.rfile.read(length)
except SocketError as e:
if e.errno == errno.ECONNRESET:
print('WARN: handle POST messageBytes ' +
'connection reset by peer')
else:
print('WARN: handle POST messageBytes socket error')
return {}
except ValueError as e:
print('ERROR: handle POST messageBytes rfile.read failed')
print(e)
return {}
lenMessage = len(messageBytes)
if lenMessage > 2048:
print('WARN: handle post to crypto API is too long ' +
str(lenMessage) + ' bytes')
return {}
handle = messageBytes.decode("utf-8")
if not handle:
return None
if '@' not in handle:
return None
if '[' in handle:
return json.loads(messageBytes)
if handle.startswith('@'):
handle = handle[1:]
if '@' not in handle:
return None
return handle.strip()
def _cryptoAPIreadJson(self) -> {}:
"""Obtains json from POST to the crypto API
"""
messageBytes = None
maxCryptoMessageLength = 10240
length = int(self.headers['Content-length'])
if length >= maxCryptoMessageLength:
print('WARN: post to crypto API is too long ' +
str(length) + ' bytes')
return {}
try:
messageBytes = self.rfile.read(length)
except SocketError as e:
if e.errno == errno.ECONNRESET:
print('WARN: POST messageBytes ' +
'connection reset by peer')
else:
print('WARN: POST messageBytes socket error')
return {}
except ValueError as e:
print('ERROR: POST messageBytes rfile.read failed')
print(e)
return {}
lenMessage = len(messageBytes)
if lenMessage > 10240:
print('WARN: post to crypto API is too long ' +
str(lenMessage) + ' bytes')
return {}
return json.loads(messageBytes)
def _cryptoAPIQuery(self, callingDomain: str) -> bool:
handle = self._cryptoAPIreadHandle()
if not handle:
return False
if isinstance(handle, str):
personDir = self.server.baseDir + '/accounts/' + handle
if not os.path.isdir(personDir + '/devices'):
return False
devicesList = []
for subdir, dirs, files in os.walk(personDir + '/devices'):
for f in files:
deviceFilename = os.path.join(personDir + '/devices', f)
if not os.path.isfile(deviceFilename):
continue
contentJson = loadJson(deviceFilename)
if contentJson:
devicesList.append(contentJson)
# return the list of devices for this handle
msg = \
json.dumps(devicesList,
ensure_ascii=False).encode('utf-8')
self._set_headers('application/json',
len(msg),
None, callingDomain)
self._write(msg)
return True
return False
def _cryptoAPI(self, path: str, authorized: bool) -> None:
"""POST or GET with the crypto API
"""
if authorized and path.startswith('/api/v1/crypto/keys/upload'):
# register a device to an authorized account
if not self.authorizedNickname:
self._400()
return
deviceKeys = self._cryptoAPIreadJson()
if not deviceKeys:
self._400()
return
if isinstance(deviceKeys, dict):
if not E2EEvalidDevice(deviceKeys):
self._400()
return
E2EEaddDevice(self.server.baseDir,
self.authorizedNickname,
self.server.domain,
deviceKeys['deviceId'],
deviceKeys['name'],
deviceKeys['claim'],
deviceKeys['fingerprintKey']['publicKeyBase64'],
deviceKeys['identityKey']['publicKeyBase64'],
deviceKeys['fingerprintKey']['type'],
deviceKeys['identityKey']['type'])
self._200()
return
self._400()
elif path.startswith('/api/v1/crypto/keys/query'):
# given a handle (nickname@domain) return the devices
# registered to that handle
if not self._cryptoAPIQuery():
self._400()
elif path.startswith('/api/v1/crypto/keys/claim'):
# TODO
self._200()
elif authorized and path.startswith('/api/v1/crypto/delivery'):
# TODO
self._200()
elif (authorized and
path.startswith('/api/v1/crypto/encrypted_messages/clear')):
# TODO
self._200()
elif path.startswith('/api/v1/crypto/encrypted_messages'):
# TODO
self._200()
else:
self._400()
def do_POST(self): def do_POST(self):
POSTstartTime = time.time() POSTstartTime = time.time()
POSTtimings = [] POSTtimings = []
@ -5827,6 +6007,11 @@ class PubServer(BaseHTTPRequestHandler):
print('POST Not authorized') print('POST Not authorized')
print(str(self.headers)) print(str(self.headers))
if self.path.startswith('/api/v1/crypto/'):
self._cryptoAPI(self.path, authorized)
self.server.POSTbusy = False
return
# if this is a POST to the outbox then check authentication # if this is a POST to the outbox then check authentication
self.outboxAuthenticated = False self.outboxAuthenticated = False
self.postToNickname = None self.postToNickname = None
@ -6616,6 +6801,12 @@ class PubServer(BaseHTTPRequestHandler):
os.remove(gitProjectsFilename) os.remove(gitProjectsFilename)
# save actor json file within accounts # save actor json file within accounts
if actorChanged: if actorChanged:
# update the context for the actor
actorJson['@context'] = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
getDefaultPersonContext()
]
randomizeActorImages(actorJson) randomizeActorImages(actorJson)
saveJson(actorJson, actorFilename) saveJson(actorJson, actorFilename)
webfingerUpdate(self.server.baseDir, webfingerUpdate(self.server.baseDir,
@ -6706,9 +6897,9 @@ class PubServer(BaseHTTPRequestHandler):
if '=' in moderationStr: if '=' in moderationStr:
moderationText = \ moderationText = \
moderationStr.split('=')[1].strip() moderationStr.split('=')[1].strip()
moderationText = moderationText.replace('+', ' ') modText = moderationText.replace('+', ' ')
moderationText = \ moderationText = \
urllib.parse.unquote(moderationText.strip()) urllib.parse.unquote_plus(modText.strip())
elif moderationStr.startswith('submitInfo'): elif moderationStr.startswith('submitInfo'):
msg = htmlModerationInfo(self.server.translate, msg = htmlModerationInfo(self.server.translate,
self.server.baseDir, self.server.baseDir,
@ -6882,7 +7073,7 @@ class PubServer(BaseHTTPRequestHandler):
questionParams = questionParams.replace('+', ' ') questionParams = questionParams.replace('+', ' ')
questionParams = questionParams.replace('%3F', '') questionParams = questionParams.replace('%3F', '')
questionParams = \ questionParams = \
urllib.parse.unquote(questionParams.strip()) urllib.parse.unquote_plus(questionParams.strip())
# post being voted on # post being voted on
messageId = None messageId = None
if 'messageId=' in questionParams: if 'messageId=' in questionParams:
@ -6964,9 +7155,8 @@ class PubServer(BaseHTTPRequestHandler):
searchStr = searchParams.split('searchtext=')[1] searchStr = searchParams.split('searchtext=')[1]
if '&' in searchStr: if '&' in searchStr:
searchStr = searchStr.split('&')[0] searchStr = searchStr.split('&')[0]
searchStr = searchStr.replace('+', ' ')
searchStr = \ searchStr = \
urllib.parse.unquote(searchStr.strip()) urllib.parse.unquote_plus(searchStr.strip())
searchStr2 = searchStr.lower().strip('\n').strip('\r') searchStr2 = searchStr.lower().strip('\n').strip('\r')
print('searchStr: ' + searchStr) print('searchStr: ' + searchStr)
if searchForEmoji: if searchForEmoji:
@ -7172,7 +7362,7 @@ class PubServer(BaseHTTPRequestHandler):
removeShareConfirmParams = \ removeShareConfirmParams = \
removeShareConfirmParams.replace('+', ' ').strip() removeShareConfirmParams.replace('+', ' ').strip()
removeShareConfirmParams = \ removeShareConfirmParams = \
urllib.parse.unquote(removeShareConfirmParams) urllib.parse.unquote_plus(removeShareConfirmParams)
shareActor = removeShareConfirmParams.split('actor=')[1] shareActor = removeShareConfirmParams.split('actor=')[1]
if '&' in shareActor: if '&' in shareActor:
shareActor = shareActor.split('&')[0] shareActor = shareActor.split('&')[0]
@ -7235,7 +7425,7 @@ class PubServer(BaseHTTPRequestHandler):
return return
if '&submitYes=' in removePostConfirmParams: if '&submitYes=' in removePostConfirmParams:
removePostConfirmParams = \ removePostConfirmParams = \
urllib.parse.unquote(removePostConfirmParams) urllib.parse.unquote_plus(removePostConfirmParams)
removeMessageId = \ removeMessageId = \
removePostConfirmParams.split('messageId=')[1] removePostConfirmParams.split('messageId=')[1]
if '&' in removeMessageId: if '&' in removeMessageId:
@ -7327,7 +7517,7 @@ class PubServer(BaseHTTPRequestHandler):
return return
if '&submitView=' in followConfirmParams: if '&submitView=' in followConfirmParams:
followingActor = \ followingActor = \
urllib.parse.unquote(followConfirmParams) urllib.parse.unquote_plus(followConfirmParams)
followingActor = followingActor.split('actor=')[1] followingActor = followingActor.split('actor=')[1]
if '&' in followingActor: if '&' in followingActor:
followingActor = followingActor.split('&')[0] followingActor = followingActor.split('&')[0]
@ -7336,7 +7526,7 @@ class PubServer(BaseHTTPRequestHandler):
return return
if '&submitYes=' in followConfirmParams: if '&submitYes=' in followConfirmParams:
followingActor = \ followingActor = \
urllib.parse.unquote(followConfirmParams) urllib.parse.unquote_plus(followConfirmParams)
followingActor = followingActor.split('actor=')[1] followingActor = followingActor.split('actor=')[1]
if '&' in followingActor: if '&' in followingActor:
followingActor = followingActor.split('&')[0] followingActor = followingActor.split('&')[0]
@ -7409,7 +7599,7 @@ class PubServer(BaseHTTPRequestHandler):
return return
if '&submitYes=' in followConfirmParams: if '&submitYes=' in followConfirmParams:
followingActor = \ followingActor = \
urllib.parse.unquote(followConfirmParams) urllib.parse.unquote_plus(followConfirmParams)
followingActor = followingActor.split('actor=')[1] followingActor = followingActor.split('actor=')[1]
if '&' in followingActor: if '&' in followingActor:
followingActor = followingActor.split('&')[0] followingActor = followingActor.split('&')[0]
@ -7503,7 +7693,7 @@ class PubServer(BaseHTTPRequestHandler):
return return
if '&submitYes=' in blockConfirmParams: if '&submitYes=' in blockConfirmParams:
blockingActor = \ blockingActor = \
urllib.parse.unquote(blockConfirmParams) urllib.parse.unquote_plus(blockConfirmParams)
blockingActor = blockingActor.split('actor=')[1] blockingActor = blockingActor.split('actor=')[1]
if '&' in blockingActor: if '&' in blockingActor:
blockingActor = blockingActor.split('&')[0] blockingActor = blockingActor.split('&')[0]
@ -7600,7 +7790,7 @@ class PubServer(BaseHTTPRequestHandler):
return return
if '&submitYes=' in blockConfirmParams: if '&submitYes=' in blockConfirmParams:
blockingActor = \ blockingActor = \
urllib.parse.unquote(blockConfirmParams) urllib.parse.unquote_plus(blockConfirmParams)
blockingActor = blockingActor.split('actor=')[1] blockingActor = blockingActor.split('actor=')[1]
if '&' in blockingActor: if '&' in blockingActor:
blockingActor = blockingActor.split('&')[0] blockingActor = blockingActor.split('&')[0]
@ -7698,7 +7888,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False self.server.POSTbusy = False
return return
optionsConfirmParams = \ optionsConfirmParams = \
urllib.parse.unquote(optionsConfirmParams) urllib.parse.unquote_plus(optionsConfirmParams)
# page number to return to # page number to return to
if 'pageNumber=' in optionsConfirmParams: if 'pageNumber=' in optionsConfirmParams:
pageNumberStr = optionsConfirmParams.split('pageNumber=')[1] pageNumberStr = optionsConfirmParams.split('pageNumber=')[1]
@ -7731,6 +7921,16 @@ class PubServer(BaseHTTPRequestHandler):
'?' in petname or '#' in petname: '?' in petname or '#' in petname:
petname = None petname = None
personNotes = None
if 'optionnotes' in optionsConfirmParams:
personNotes = optionsConfirmParams.split('optionnotes=')[1]
if '&' in personNotes:
personNotes = personNotes.split('&')[0]
personNotes = urllib.parse.unquote_plus(personNotes.strip())
# Limit the length of the notes
if len(personNotes) > 64000:
personNotes = None
optionsNickname = getNicknameFromActor(optionsActor) optionsNickname = getNicknameFromActor(optionsActor)
if not optionsNickname: if not optionsNickname:
if callingDomain.endswith('.onion') and \ if callingDomain.endswith('.onion') and \
@ -7773,7 +7973,23 @@ class PubServer(BaseHTTPRequestHandler):
chooserNickname, chooserNickname,
self.server.domain, self.server.domain,
handle, petname) handle, petname)
self._redirect_headers(originPathStr + '/' + self._redirect_headers(usersPath + '/' +
self.server.defaultTimeline +
'?page='+str(pageNumber), cookie,
callingDomain)
self.server.POSTbusy = False
return
if '&submitPersonNotes=' in optionsConfirmParams:
if self.server.debug:
print('Change person notes')
handle = optionsNickname + '@' + optionsDomainFull
if not personNotes:
personNotes = ''
setPersonNotes(self.server.baseDir,
chooserNickname,
self.server.domain,
handle, personNotes)
self._redirect_headers(usersPath + '/' +
self.server.defaultTimeline + self.server.defaultTimeline +
'?page='+str(pageNumber), cookie, '?page='+str(pageNumber), cookie,
callingDomain) callingDomain)
@ -7797,7 +8013,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.domain, self.server.domain,
optionsNickname, optionsNickname,
optionsDomainFull) optionsDomainFull)
self._redirect_headers(originPathStr + '/' + self._redirect_headers(usersPath + '/' +
self.server.defaultTimeline + self.server.defaultTimeline +
'?page='+str(pageNumber), cookie, '?page='+str(pageNumber), cookie,
callingDomain) callingDomain)

191
devices.py 100644
View File

@ -0,0 +1,191 @@
__filename__ = "devices.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.1.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
# REST API overview
#
# To support Olm, the following APIs are required:
#
# * Uploading keys for a device (current app)
# POST /api/v1/crypto/keys/upload
#
# * Querying available devices of people you want to establish a session with
# POST /api/v1/crypto/keys/query
#
# * Claiming a pre-key (one-time-key) for each device you want to establish
# a session with
# POST /api/v1/crypto/keys/claim
#
# * Sending encrypted messages directly to specific devices of other people
# POST /api/v1/crypto/delivery
#
# * Collect encrypted messages addressed to the current device
# GET /api/v1/crypto/encrypted_messages
#
# * Clear all encrypted messages addressed to the current device
# POST /api/v1/crypto/encrypted_messages/clear
import os
from utils import loadJson
from utils import saveJson
def E2EEremoveDevice(baseDir: str, nickname: str, domain: str,
deviceId: str) -> bool:
"""Unregisters a device for e2ee
"""
personDir = baseDir + '/accounts/' + nickname + '@' + domain
deviceFilename = personDir + '/devices/' + deviceId + '.json'
if os.path.isfile(deviceFilename):
os.remove(deviceFilename)
return True
return False
def E2EEvalidDevice(deviceJson: {}) -> bool:
"""Returns true if the given json contains valid device keys
"""
if not isinstance(deviceJson, dict):
return False
if not deviceJson.get('deviceId'):
return False
if not isinstance(deviceJson['deviceId'], str):
return False
if not deviceJson.get('type'):
return False
if not isinstance(deviceJson['type'], str):
return False
if not deviceJson.get('name'):
return False
if not isinstance(deviceJson['name'], str):
return False
if deviceJson['type'] != 'Device':
return False
if not deviceJson.get('claim'):
return False
if not isinstance(deviceJson['claim'], str):
return False
if not deviceJson.get('fingerprintKey'):
return False
if not isinstance(deviceJson['fingerprintKey'], dict):
return False
if not deviceJson['fingerprintKey'].get('type'):
return False
if not isinstance(deviceJson['fingerprintKey']['type'], str):
return False
if not deviceJson['fingerprintKey'].get('publicKeyBase64'):
return False
if not isinstance(deviceJson['fingerprintKey']['publicKeyBase64'], str):
return False
if not deviceJson.get('identityKey'):
return False
if not isinstance(deviceJson['identityKey'], dict):
return False
if not deviceJson['identityKey'].get('type'):
return False
if not isinstance(deviceJson['identityKey']['type'], str):
return False
if not deviceJson['identityKey'].get('publicKeyBase64'):
return False
if not isinstance(deviceJson['identityKey']['publicKeyBase64'], str):
return False
return True
def E2EEaddDevice(baseDir: str, nickname: str, domain: str,
deviceId: str, name: str, claimUrl: str,
fingerprintPublicKey: str,
identityPublicKey: str,
fingerprintKeyType="Ed25519Key",
identityKeyType="Curve25519Key") -> bool:
"""Registers a device for e2ee
claimUrl could be something like:
http://localhost:3000/users/admin/claim?id=11119
"""
if ' ' in deviceId or '/' in deviceId or \
'?' in deviceId or '#' in deviceId or \
'.' in deviceId:
return False
personDir = baseDir + '/accounts/' + nickname + '@' + domain
if not os.path.isdir(personDir):
return False
if not os.path.isdir(personDir + '/devices'):
os.mkdir(personDir + '/devices')
deviceDict = {
"deviceId": deviceId,
"type": "Device",
"name": name,
"claim": claimUrl,
"fingerprintKey": {
"type": fingerprintKeyType,
"publicKeyBase64": fingerprintPublicKey
},
"identityKey": {
"type": identityKeyType,
"publicKeyBase64": identityPublicKey
}
}
deviceFilename = personDir + '/devices/' + deviceId + '.json'
return saveJson(deviceDict, deviceFilename)
def E2EEdevicesCollection(baseDir: str, nickname: str, domain: str,
domainFull: str, httpPrefix: str) -> {}:
"""Returns a list of registered devices
"""
personDir = baseDir + '/accounts/' + nickname + '@' + domain
if not os.path.isdir(personDir):
return {}
personId = httpPrefix + '://' + domainFull + '/users/' + nickname
if not os.path.isdir(personDir + '/devices'):
os.mkdir(personDir + '/devices')
deviceList = []
for subdir, dirs, files in os.walk(personDir + '/devices/'):
for dev in files:
if not dev.endswith('.json'):
continue
deviceFilename = os.path.join(personDir + '/devices', dev)
devJson = loadJson(deviceFilename)
if devJson:
deviceList.append(devJson)
devicesDict = {
'id': personId + '/collections/devices',
'type': 'Collection',
'totalItems': len(deviceList),
'items': deviceList
}
return devicesDict
def E2EEdecryptMessageFromDevice(messageJson: {}) -> str:
"""Locally decrypts a message on the device.
This should probably be a link to a local script
or native app, such that what the user sees isn't
something which the server could get access to.
"""
# TODO
# {
# "type": "EncryptedMessage",
# "messageType": 0,
# "cipherText": "...",
# "digest": {
# "type": "Digest",
# "digestAlgorithm": "http://www.w3.org/2000/09/xmldsig#hmac-sha256",
# "digestValue": "5f6ad31acd64995483d75c7..."
# },
# "messageFranking": "...",
# "attributedTo": {
# "type": "Device",
# "deviceId": "11119"
# },
# "to": {
# "type": "Device",
# "deviceId": "11876"
# }
# }
return ''

View File

@ -112,6 +112,12 @@ a:link {
width: 15%; width: 15%;
} }
textarea {
font-size: var(--font-size4);
width: 90%;
background-color: var(--text-entry-background);
}
@media screen and (min-width: 400px) { @media screen and (min-width: 400px) {
.followText { .followText {
font-size: var(--follow-text-size1); font-size: var(--follow-text-size1);

2
git.py
View File

@ -126,6 +126,8 @@ def convertPostToPatch(baseDir: str, nickname: str, domain: str,
return False return False
if not postJsonObject['object'].get('attributedTo'): if not postJsonObject['object'].get('attributedTo'):
return False return False
if not isinstance(postJsonObject['object']['attributedTo'], str):
return False
if not isGitPatch(baseDir, nickname, domain, if not isGitPatch(baseDir, nickname, domain,
postJsonObject['object']['type'], postJsonObject['object']['type'],
postJsonObject['object']['summary'], postJsonObject['object']['summary'],

View File

@ -1422,12 +1422,15 @@ def receiveAnnounce(recentPostsCache: {},
# so that their avatar can be shown # so that their avatar can be shown
lookupActor = None lookupActor = None
if postJsonObject.get('attributedTo'): if postJsonObject.get('attributedTo'):
lookupActor = postJsonObject['attributedTo'] if isinstance(postJsonObject['attributedTo'], str):
lookupActor = postJsonObject['attributedTo']
else: else:
if postJsonObject.get('object'): if postJsonObject.get('object'):
if isinstance(postJsonObject['object'], dict): if isinstance(postJsonObject['object'], dict):
if postJsonObject['object'].get('attributedTo'): if postJsonObject['object'].get('attributedTo'):
lookupActor = postJsonObject['object']['attributedTo'] attrib = postJsonObject['object']['attributedTo']
if isinstance(attrib, str):
lookupActor = attrib
if lookupActor: if lookupActor:
if '/users/' in lookupActor or \ if '/users/' in lookupActor or \
'/channel/' in lookupActor or \ '/channel/' in lookupActor or \
@ -2190,24 +2193,25 @@ def inboxAfterCapabilities(recentPostsCache: {}, maxRecentPosts: int,
postJsonObject['object'].get('summary') and \ postJsonObject['object'].get('summary') and \
postJsonObject['object'].get('attributedTo'): postJsonObject['object'].get('attributedTo'):
attributedTo = postJsonObject['object']['attributedTo'] attributedTo = postJsonObject['object']['attributedTo']
fromNickname = getNicknameFromActor(attributedTo) if isinstance(attributedTo, str):
fromDomain, fromPort = getDomainFromActor(attributedTo) fromNickname = getNicknameFromActor(attributedTo)
if fromPort: fromDomain, fromPort = getDomainFromActor(attributedTo)
if fromPort != 80 and fromPort != 443: if fromPort:
fromDomain += ':' + str(fromPort) if fromPort != 80 and fromPort != 443:
if receiveGitPatch(baseDir, nickname, domain, fromDomain += ':' + str(fromPort)
postJsonObject['object']['type'], if receiveGitPatch(baseDir, nickname, domain,
postJsonObject['object']['summary'], postJsonObject['object']['type'],
postJsonObject['object']['content'], postJsonObject['object']['summary'],
fromNickname, fromDomain): postJsonObject['object']['content'],
gitPatchNotify(baseDir, handle, fromNickname, fromDomain):
postJsonObject['object']['summary'], gitPatchNotify(baseDir, handle,
postJsonObject['object']['content'], postJsonObject['object']['summary'],
fromNickname, fromDomain) postJsonObject['object']['content'],
elif '[PATCH]' in postJsonObject['object']['content']: fromNickname, fromDomain)
print('WARN: git patch not accepted - ' + elif '[PATCH]' in postJsonObject['object']['content']:
postJsonObject['object']['summary']) print('WARN: git patch not accepted - ' +
return False postJsonObject['object']['summary'])
return False
# replace YouTube links, so they get less tracking data # replace YouTube links, so they get less tracking data
replaceYouTube(postJsonObject, YTReplacementDomain) replaceYouTube(postJsonObject, YTReplacementDomain)

View File

@ -189,6 +189,7 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str,
if messageJson['type'] == 'Create' or \ if messageJson['type'] == 'Create' or \
messageJson['type'] == 'Question' or \ messageJson['type'] == 'Question' or \
messageJson['type'] == 'Note' or \ messageJson['type'] == 'Note' or \
messageJson['type'] == 'EncryptedMessage' or \
messageJson['type'] == 'Article' or \ messageJson['type'] == 'Article' or \
messageJson['type'] == 'Patch' or \ messageJson['type'] == 'Patch' or \
messageJson['type'] == 'Announce': messageJson['type'] == 'Announce':

View File

@ -163,6 +163,38 @@ def randomizeActorImages(personJson: {}) -> None:
personId + '/image' + randStr + '.' + existingExtension personId + '/image' + randStr + '.' + existingExtension
def getDefaultPersonContext() -> str:
"""Gets the default actor context
"""
return {
'Emoji': 'toot:Emoji',
'Hashtag': 'as:Hashtag',
'IdentityProof': 'toot:IdentityProof',
'PropertyValue': 'schema:PropertyValue',
'alsoKnownAs': {
'@id': 'as:alsoKnownAs', '@type': '@id'
},
'focalPoint': {
'@container': '@list', '@id': 'toot:focalPoint'
},
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'movedTo': {
'@id': 'as:movedTo', '@type': '@id'
},
'schema': 'http://schema.org#',
'value': 'schema:value',
'Curve25519Key': 'toot:Curve25519Key',
'Device': 'toot:Device',
'Ed25519Key': 'toot:Ed25519Key',
'Ed25519Signature': 'toot:Ed25519Signature',
'EncryptedMessage': 'toot:EncryptedMessage',
'identityKey': {'@id': 'toot:identityKey', '@type': '@id'},
'fingerprintKey': {'@id': 'toot:fingerprintKey', '@type': '@id'},
'messageFranking': 'toot:messageFranking',
'publicKeyBase64': 'toot:publicKeyBase64'
}
def createPersonBase(baseDir: str, nickname: str, domain: str, port: int, def createPersonBase(baseDir: str, nickname: str, domain: str, port: int,
httpPrefix: str, saveToFile: bool, httpPrefix: str, saveToFile: bool,
manualFollowerApproval: bool, manualFollowerApproval: bool,
@ -212,34 +244,16 @@ def createPersonBase(baseDir: str, nickname: str, domain: str, port: int,
personId + '/avatar' + \ personId + '/avatar' + \
str(randint(10000000000000, 99999999999999)) + '.png' # nosec str(randint(10000000000000, 99999999999999)) + '.png' # nosec
contextDict = {
'Emoji': 'toot:Emoji',
'Hashtag': 'as:Hashtag',
'IdentityProof': 'toot:IdentityProof',
'PropertyValue': 'schema:PropertyValue',
'alsoKnownAs': {
'@id': 'as:alsoKnownAs', '@type': '@id'
},
'focalPoint': {
'@container': '@list', '@id': 'toot:focalPoint'
},
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'movedTo': {
'@id': 'as:movedTo', '@type': '@id'
},
'schema': 'http://schema.org#',
'value': 'schema:value'
}
newPerson = { newPerson = {
'@context': [ '@context': [
'https://www.w3.org/ns/activitystreams', 'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1', 'https://w3id.org/security/v1',
contextDict getDefaultPersonContext()
], ],
'attachment': [], 'attachment': [],
'alsoKnownAs': [], 'alsoKnownAs': [],
'discoverable': False, 'discoverable': False,
'devices': personId + '/collections/devices',
'endpoints': { 'endpoints': {
'id': personId+'/endpoints', 'id': personId+'/endpoints',
'sharedInbox': httpPrefix+'://'+domain+'/inbox', 'sharedInbox': httpPrefix+'://'+domain+'/inbox',
@ -1061,3 +1075,21 @@ def personUnsnooze(baseDir: str, nickname: str, domain: str,
if writeSnoozedFile: if writeSnoozedFile:
writeSnoozedFile.write(content) writeSnoozedFile.write(content)
writeSnoozedFile.close() writeSnoozedFile.close()
def setPersonNotes(baseDir: 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 = baseDir + '/accounts/' + \
nickname + '@' + domain + '/notes'
if not os.path.isdir(notesDir):
os.mkdir(notesDir)
notesFilename = notesDir + '/' + handle + '.txt'
with open(notesFilename, 'w+') as notesFile:
notesFile.write(notes)
return True

View File

@ -2400,6 +2400,7 @@ def isDM(postJsonObject: {}) -> bool:
return False return False
if postJsonObject['object']['type'] != 'Note' and \ if postJsonObject['object']['type'] != 'Note' and \
postJsonObject['object']['type'] != 'Patch' and \ postJsonObject['object']['type'] != 'Patch' and \
postJsonObject['object']['type'] != 'EncryptedMessage' and \
postJsonObject['object']['type'] != 'Article': postJsonObject['object']['type'] != 'Article':
return False return False
if postJsonObject['object'].get('moderationStatus'): if postJsonObject['object'].get('moderationStatus'):
@ -2466,6 +2467,7 @@ def isReply(postJsonObject: {}, actor: str) -> bool:
if postJsonObject['object'].get('moderationStatus'): if postJsonObject['object'].get('moderationStatus'):
return False return False
if postJsonObject['object']['type'] != 'Note' and \ if postJsonObject['object']['type'] != 'Note' and \
postJsonObject['object']['type'] != 'EncryptedMessage' and \
postJsonObject['object']['type'] != 'Article': postJsonObject['object']['type'] != 'Article':
return False return False
if postJsonObject['object'].get('inReplyTo'): if postJsonObject['object'].get('inReplyTo'):
@ -2577,6 +2579,7 @@ def addPostStringToTimeline(postStr: str, boxname: str,
""" """
# must be a recognized ActivityPub type # must be a recognized ActivityPub type
if ('"Note"' in postStr or if ('"Note"' in postStr or
'"EncryptedMessage"' in postStr or
'"Article"' in postStr or '"Article"' in postStr or
'"Patch"' in postStr or '"Patch"' in postStr or
'"Announce"' in postStr or '"Announce"' in postStr or

View File

@ -1684,6 +1684,13 @@ def testWebLinks():
'<p>' + \ '<p>' + \
'' ''
exampleText = \
'<p>Test1 test2 #YetAnotherExcessivelyLongwindedAndBoringHashtag</p>'
resultText = removeLongWords(addWebLinks(exampleText), 40, [])
assert(resultText ==
'<p>Test1 test2 '
'#YetAnotherExcessivelyLongwindedAndBorin\ngHashtag</p>')
def testAddEmoji(): def testAddEmoji():
print('testAddEmoji') print('testAddEmoji')

View File

@ -254,5 +254,6 @@
"Grayscale": "درجات الرمادي", "Grayscale": "درجات الرمادي",
"Liked by": "نال إعجاب", "Liked by": "نال إعجاب",
"Solidaric": "تضامن", "Solidaric": "تضامن",
"YouTube Replacement Domain": "استبدال نطاق يوتيوب" "YouTube Replacement Domain": "استبدال نطاق يوتيوب",
"Notes": "ملاحظات"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "Escala de grisos", "Grayscale": "Escala de grisos",
"Liked by": "M'agrada", "Liked by": "M'agrada",
"Solidaric": "Solidaritat", "Solidaric": "Solidaritat",
"YouTube Replacement Domain": "Domini de substitució de YouTube" "YouTube Replacement Domain": "Domini de substitució de YouTube",
"Notes": "Notes"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "Graddlwyd", "Grayscale": "Graddlwyd",
"Liked by": "Hoffi", "Liked by": "Hoffi",
"Solidaric": "Undod", "Solidaric": "Undod",
"YouTube Replacement Domain": "Parth Amnewid YouTube" "YouTube Replacement Domain": "Parth Amnewid YouTube",
"Notes": "Nodiadau"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "Graustufen", "Grayscale": "Graustufen",
"Liked by": "Gefallen von", "Liked by": "Gefallen von",
"Solidaric": "Solidarität", "Solidaric": "Solidarität",
"YouTube Replacement Domain": "YouTube-Ersatzdomain" "YouTube Replacement Domain": "YouTube-Ersatzdomain",
"Notes": "Anmerkungen"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "Grayscale", "Grayscale": "Grayscale",
"Liked by": "Liked by", "Liked by": "Liked by",
"Solidaric": "Solidaric", "Solidaric": "Solidaric",
"YouTube Replacement Domain": "YouTube Replacement Domain" "YouTube Replacement Domain": "YouTube Replacement Domain",
"Notes": "Notes"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "Escala de grises", "Grayscale": "Escala de grises",
"Liked by": "Apreciado por", "Liked by": "Apreciado por",
"Solidaric": "Solidaridad", "Solidaric": "Solidaridad",
"YouTube Replacement Domain": "Dominio de reemplazo de YouTube" "YouTube Replacement Domain": "Dominio de reemplazo de YouTube",
"Notes": "Notas"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "Niveaux de gris", "Grayscale": "Niveaux de gris",
"Liked by": "Aimé par", "Liked by": "Aimé par",
"Solidaric": "Solidarité", "Solidaric": "Solidarité",
"YouTube Replacement Domain": "Domaine de remplacement YouTube" "YouTube Replacement Domain": "Domaine de remplacement YouTube",
"Notes": "Remarques"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "Liathscála", "Grayscale": "Liathscála",
"Liked by": "Thaitin", "Liked by": "Thaitin",
"Solidaric": "Dlúthpháirtíocht", "Solidaric": "Dlúthpháirtíocht",
"YouTube Replacement Domain": "Fearann Athsholáthair YouTube" "YouTube Replacement Domain": "Fearann Athsholáthair YouTube",
"Notes": "Nótaí"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "ग्रेस्केल", "Grayscale": "ग्रेस्केल",
"Liked by": "द्वारा पसंद किया गया", "Liked by": "द्वारा पसंद किया गया",
"Solidaric": "एकजुटता", "Solidaric": "एकजुटता",
"YouTube Replacement Domain": "YouTube रिप्लेसमेंट डोमेन" "YouTube Replacement Domain": "YouTube रिप्लेसमेंट डोमेन",
"Notes": "टिप्पणियाँ"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "Scala di grigi", "Grayscale": "Scala di grigi",
"Liked by": "Mi è piaciuto", "Liked by": "Mi è piaciuto",
"Solidaric": "Solidarietà", "Solidaric": "Solidarietà",
"YouTube Replacement Domain": "Dominio sostitutivo di YouTube" "YouTube Replacement Domain": "Dominio sostitutivo di YouTube",
"Notes": "Appunti"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "グレースケール", "Grayscale": "グレースケール",
"Liked by": "好き", "Liked by": "好き",
"Solidaric": "連帯", "Solidaric": "連帯",
"YouTube Replacement Domain": "YouTube交換ドメイン" "YouTube Replacement Domain": "YouTube交換ドメイン",
"Notes": "ノート"
} }

View File

@ -250,5 +250,6 @@
"Grayscale": "Grayscale", "Grayscale": "Grayscale",
"Liked by": "Liked by", "Liked by": "Liked by",
"Solidaric": "Solidaric", "Solidaric": "Solidaric",
"YouTube Replacement Domain": "YouTube Replacement Domain" "YouTube Replacement Domain": "YouTube Replacement Domain",
"Notes": "Notes"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "Escala de cinza", "Grayscale": "Escala de cinza",
"Liked by": "Curtida por", "Liked by": "Curtida por",
"Solidaric": "Solidariedade", "Solidaric": "Solidariedade",
"YouTube Replacement Domain": "Domínio de substituição do YouTube" "YouTube Replacement Domain": "Domínio de substituição do YouTube",
"Notes": "Notas"
} }

View File

@ -254,5 +254,6 @@
"Grayscale": "Оттенки серого", "Grayscale": "Оттенки серого",
"Liked by": "Понравилось", "Liked by": "Понравилось",
"Solidaric": "солидарность", "Solidaric": "солидарность",
"YouTube Replacement Domain": "Запасной домен YouTube" "YouTube Replacement Domain": "Запасной домен YouTube",
"Notes": "Ноты"
} }

View File

@ -253,5 +253,6 @@
"Grayscale": "灰阶", "Grayscale": "灰阶",
"Liked by": "喜欢的人", "Liked by": "喜欢的人",
"Solidaric": "团结互助", "Solidaric": "团结互助",
"YouTube Replacement Domain": "YouTube替换域" "YouTube Replacement Domain": "YouTube替换域",
"Notes": "笔记"
} }

View File

@ -78,6 +78,7 @@ from git import isGitPatch
from theme import getThemesList from theme import getThemesList
from petnames import getPetName from petnames import getPetName
from followingCalendar import receivingCalendarEvents from followingCalendar import receivingCalendarEvents
from devices import E2EEdecryptMessageFromDevice
def getAltPath(actor: str, domainFull: str, callingDomain: str) -> str: def getAltPath(actor: str, domainFull: str, callingDomain: str) -> str:
@ -3059,7 +3060,10 @@ def addEmbeddedVideoFromSites(translate: {}, content: str,
return content return content
invidiousSites = ('https://invidio.us', invidiousSites = ('https://invidio.us',
'axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4' + 'https://invidious.snopyta.org',
'http://c7hqkpkpemu6e7emz5b4vy' +
'z7idjgdvgaaa3dyimmeojqbgpea3xqjoid.onion',
'http://axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4' +
'bzzsg2ii4fv2iid.onion') 'bzzsg2ii4fv2iid.onion')
for videoSite in invidiousSites: for videoSite in invidiousSites:
if '"' + videoSite in content: if '"' + videoSite in content:
@ -3812,8 +3816,9 @@ def individualPostAsHtml(recentPostsCache: {}, maxRecentPosts: int,
if showIcons: if showIcons:
replyToLink = postJsonObject['object']['id'] replyToLink = postJsonObject['object']['id']
if postJsonObject['object'].get('attributedTo'): if postJsonObject['object'].get('attributedTo'):
replyToLink += \ if isinstance(postJsonObject['object']['attributedTo'], str):
'?mention=' + postJsonObject['object']['attributedTo'] replyToLink += \
'?mention=' + postJsonObject['object']['attributedTo']
if postJsonObject['object'].get('content'): if postJsonObject['object'].get('content'):
mentionedActors = \ mentionedActors = \
getMentionsFromHtml(postJsonObject['object']['content']) getMentionsFromHtml(postJsonObject['object']['content'])
@ -3984,7 +3989,9 @@ def individualPostAsHtml(recentPostsCache: {}, maxRecentPosts: int,
if showRepeatIcon: if showRepeatIcon:
if isAnnounced: if isAnnounced:
if postJsonObject['object'].get('attributedTo'): if postJsonObject['object'].get('attributedTo'):
attributedTo = postJsonObject['object']['attributedTo'] attributedTo = ''
if isinstance(postJsonObject['object']['attributedTo'], str):
attributedTo = postJsonObject['object']['attributedTo']
if attributedTo.startswith(postActor): if attributedTo.startswith(postActor):
titleStr += \ titleStr += \
' <img loading="lazy" title="' + \ ' <img loading="lazy" title="' + \
@ -3993,8 +4000,9 @@ def individualPostAsHtml(recentPostsCache: {}, maxRecentPosts: int,
'" src="/' + iconsDir + \ '" src="/' + iconsDir + \
'/repeat_inactive.png" class="announceOrReply"/>\n' '/repeat_inactive.png" class="announceOrReply"/>\n'
else: else:
announceNickname = \ announceNickname = None
getNicknameFromActor(attributedTo) if attributedTo:
announceNickname = getNicknameFromActor(attributedTo)
if announceNickname: if announceNickname:
announceDomain, announcePort = \ announceDomain, announcePort = \
getDomainFromActor(attributedTo) getDomainFromActor(attributedTo)
@ -4248,8 +4256,13 @@ def individualPostAsHtml(recentPostsCache: {}, maxRecentPosts: int,
if not postJsonObject['object'].get('summary'): if not postJsonObject['object'].get('summary'):
postJsonObject['object']['summary'] = '' postJsonObject['object']['summary'] = ''
if postJsonObject['object'].get('cipherText'):
postJsonObject['object']['content'] = \
E2EEdecryptMessageFromDevice(postJsonObject['object'])
if not postJsonObject['object'].get('content'): if not postJsonObject['object'].get('content'):
return '' return ''
isPatch = isGitPatch(baseDir, nickname, domain, isPatch = isGitPatch(baseDir, nickname, domain,
postJsonObject['object']['type'], postJsonObject['object']['type'],
postJsonObject['object']['summary'], postJsonObject['object']['summary'],
@ -5560,10 +5573,10 @@ def htmlPersonOptions(translate: {}, baseDir: str,
optionsStr += ' <a href="' + optionsActor + '">\n' optionsStr += ' <a href="' + optionsActor + '">\n'
optionsStr += ' <img loading="lazy" src="' + optionsProfileUrl + \ optionsStr += ' <img loading="lazy" src="' + optionsProfileUrl + \
'"/></a>\n' '"/></a>\n'
handle = getNicknameFromActor(optionsActor) + '@' + optionsDomain
optionsStr += \ optionsStr += \
' <p class="optionsText">' + translate['Options for'] + \ ' <p class="optionsText">' + translate['Options for'] + \
' @' + getNicknameFromActor(optionsActor) + '@' + \ ' @' + handle + '</p>\n'
optionsDomain + '</p>\n'
if emailAddress: if emailAddress:
optionsStr += \ optionsStr += \
'<p class="imText">' + translate['Email'] + \ '<p class="imText">' + translate['Email'] + \
@ -5652,6 +5665,24 @@ def htmlPersonOptions(translate: {}, baseDir: str,
' <button type="submit" class="button" name="submitReport">' + \ ' <button type="submit" class="button" name="submitReport">' + \
translate['Report'] + '</button>\n' translate['Report'] + '</button>\n'
personNotes = ''
personNotesFilename = \
baseDir + '/accounts/' + nickname + '@' + domain + \
'/notes/' + handle + '.txt'
if os.path.isfile(personNotesFilename):
with open(personNotesFilename, 'r') as fp:
personNotes = fp.read()
optionsStr += \
' <br><br>' + translate['Notes'] + ': \n'
optionsStr += '<button type="submit" class="button" ' + \
'name="submitPersonNotes">' + \
translate['Submit'] + '</button><br>\n'
optionsStr += \
' <textarea id="message" ' + \
'name="optionnotes" style="height:400px">' + \
personNotes + '</textarea>\n'
optionsStr += ' </form>\n' optionsStr += ' </form>\n'
optionsStr += '</center>\n' optionsStr += '</center>\n'
optionsStr += '</div>\n' optionsStr += '</div>\n'