diff --git a/blocking.py b/blocking.py index 836f85ca2..070b4c83e 100644 --- a/blocking.py +++ b/blocking.py @@ -118,6 +118,9 @@ def removeBlock(baseDir: str, nickname: str, domain: str, def isBlockedHashtag(baseDir: str, hashtag: str) -> bool: """Is the given hashtag blocked? """ + # avoid very long hashtags + if len(hashtag) > 32: + return True globalBlockingFilename = baseDir + '/accounts/blocking.txt' if os.path.isfile(globalBlockingFilename): hashtag = hashtag.strip('\n').strip('\r') diff --git a/blog.py b/blog.py index 2101c8e3b..e41aacdff 100644 --- a/blog.py +++ b/blog.py @@ -170,8 +170,10 @@ def htmlBlogPostContent(authorized: bool, # get the handle of the author if postJsonObject['object'].get('attributedTo'): - actor = postJsonObject['object']['attributedTo'] - authorNickname = getNicknameFromActor(actor) + authorNickname = None + if isinstance(postJsonObject['object']['attributedTo'], str): + actor = postJsonObject['object']['attributedTo'] + authorNickname = getNicknameFromActor(actor) if authorNickname: authorDomain, authorPort = getDomainFromActor(actor) if authorDomain: diff --git a/content.py b/content.py index 2d2bc7d88..488601702 100644 --- a/content.py +++ b/content.py @@ -267,6 +267,9 @@ def addWebLinks(content: str) -> str: def validHashTag(hashtag: str) -> bool: """Returns true if the give hashtag contains valid characters """ + # long hashtags are not valid + if len(hashtag) >= 32: + return False validChars = set('0123456789' + 'abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') diff --git a/daemon.py b/daemon.py index 864f69f17..05d0bf911 100644 --- a/daemon.py +++ b/daemon.py @@ -43,6 +43,8 @@ from matrix import getMatrixAddress from matrix import setMatrixAddress from donate import getDonationUrl from donate import setDonationUrl +from person import setPersonNotes +from person import getDefaultPersonContext from person import savePersonQrcode from person import randomizeActorImages from person import personUpgradeActor @@ -191,6 +193,9 @@ from bookmarks import undoBookmark from petnames import setPetName from followingCalendar import addPersonToCalendar from followingCalendar import removePersonFromCalendar +from devices import E2EEdevicesCollection +from devices import E2EEvalidDevice +from devices import E2EEaddDevice import os @@ -1047,6 +1052,8 @@ class PubServer(BaseHTTPRequestHandler): return 1 def _isAuthorized(self) -> bool: + self.authorizedNickname = None + if self.path.startswith('/icons/') or \ self.path.startswith('/avatars/') or \ self.path.startswith('/favicon.ico'): @@ -1060,6 +1067,7 @@ class PubServer(BaseHTTPRequestHandler): tokenStr = tokenStr.split(';')[0].strip() if self.server.tokensLookup.get(tokenStr): nickname = self.server.tokensLookup[tokenStr] + self.authorizedNickname = nickname # default to the inbox of the person if self.path == '/': self.path = '/users/' + nickname + '/inbox' @@ -1535,6 +1543,25 @@ class PubServer(BaseHTTPRequestHandler): self._404() 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: # show the person options screen with view/follow/block/report if '?options=' in self.path: @@ -1637,7 +1664,7 @@ class PubServer(BaseHTTPRequestHandler): # remove a shared item if htmlGET and '?rmshare=' in self.path: 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] actor = \ self.server.httpPrefix + '://' + \ @@ -3336,7 +3363,7 @@ class PubServer(BaseHTTPRequestHandler): shareDescription = \ inReplyToUrl.replace('sharedesc:', '') shareDescription = \ - urllib.parse.unquote(shareDescription.strip()) + urllib.parse.unquote_plus(shareDescription.strip()) self.path = self.path.split('?replydm=')[0]+'/newdm' if self.server.debug: print('DEBUG: replydm path ' + self.path) @@ -5754,6 +5781,159 @@ class PubServer(BaseHTTPRequestHandler): postBytes, boundary) 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): POSTstartTime = time.time() POSTtimings = [] @@ -5827,6 +6007,11 @@ class PubServer(BaseHTTPRequestHandler): print('POST Not authorized') 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 self.outboxAuthenticated = False self.postToNickname = None @@ -6616,6 +6801,12 @@ class PubServer(BaseHTTPRequestHandler): os.remove(gitProjectsFilename) # save actor json file within accounts if actorChanged: + # update the context for the actor + actorJson['@context'] = [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + getDefaultPersonContext() + ] randomizeActorImages(actorJson) saveJson(actorJson, actorFilename) webfingerUpdate(self.server.baseDir, @@ -6706,9 +6897,9 @@ class PubServer(BaseHTTPRequestHandler): if '=' in moderationStr: moderationText = \ moderationStr.split('=')[1].strip() - moderationText = moderationText.replace('+', ' ') + modText = moderationText.replace('+', ' ') moderationText = \ - urllib.parse.unquote(moderationText.strip()) + urllib.parse.unquote_plus(modText.strip()) elif moderationStr.startswith('submitInfo'): msg = htmlModerationInfo(self.server.translate, self.server.baseDir, @@ -6882,7 +7073,7 @@ class PubServer(BaseHTTPRequestHandler): questionParams = questionParams.replace('+', ' ') questionParams = questionParams.replace('%3F', '') questionParams = \ - urllib.parse.unquote(questionParams.strip()) + urllib.parse.unquote_plus(questionParams.strip()) # post being voted on messageId = None if 'messageId=' in questionParams: @@ -6964,9 +7155,8 @@ class PubServer(BaseHTTPRequestHandler): searchStr = searchParams.split('searchtext=')[1] if '&' in searchStr: searchStr = searchStr.split('&')[0] - searchStr = searchStr.replace('+', ' ') searchStr = \ - urllib.parse.unquote(searchStr.strip()) + urllib.parse.unquote_plus(searchStr.strip()) searchStr2 = searchStr.lower().strip('\n').strip('\r') print('searchStr: ' + searchStr) if searchForEmoji: @@ -7172,7 +7362,7 @@ class PubServer(BaseHTTPRequestHandler): removeShareConfirmParams = \ removeShareConfirmParams.replace('+', ' ').strip() removeShareConfirmParams = \ - urllib.parse.unquote(removeShareConfirmParams) + urllib.parse.unquote_plus(removeShareConfirmParams) shareActor = removeShareConfirmParams.split('actor=')[1] if '&' in shareActor: shareActor = shareActor.split('&')[0] @@ -7235,7 +7425,7 @@ class PubServer(BaseHTTPRequestHandler): return if '&submitYes=' in removePostConfirmParams: removePostConfirmParams = \ - urllib.parse.unquote(removePostConfirmParams) + urllib.parse.unquote_plus(removePostConfirmParams) removeMessageId = \ removePostConfirmParams.split('messageId=')[1] if '&' in removeMessageId: @@ -7327,7 +7517,7 @@ class PubServer(BaseHTTPRequestHandler): return if '&submitView=' in followConfirmParams: followingActor = \ - urllib.parse.unquote(followConfirmParams) + urllib.parse.unquote_plus(followConfirmParams) followingActor = followingActor.split('actor=')[1] if '&' in followingActor: followingActor = followingActor.split('&')[0] @@ -7336,7 +7526,7 @@ class PubServer(BaseHTTPRequestHandler): return if '&submitYes=' in followConfirmParams: followingActor = \ - urllib.parse.unquote(followConfirmParams) + urllib.parse.unquote_plus(followConfirmParams) followingActor = followingActor.split('actor=')[1] if '&' in followingActor: followingActor = followingActor.split('&')[0] @@ -7409,7 +7599,7 @@ class PubServer(BaseHTTPRequestHandler): return if '&submitYes=' in followConfirmParams: followingActor = \ - urllib.parse.unquote(followConfirmParams) + urllib.parse.unquote_plus(followConfirmParams) followingActor = followingActor.split('actor=')[1] if '&' in followingActor: followingActor = followingActor.split('&')[0] @@ -7503,7 +7693,7 @@ class PubServer(BaseHTTPRequestHandler): return if '&submitYes=' in blockConfirmParams: blockingActor = \ - urllib.parse.unquote(blockConfirmParams) + urllib.parse.unquote_plus(blockConfirmParams) blockingActor = blockingActor.split('actor=')[1] if '&' in blockingActor: blockingActor = blockingActor.split('&')[0] @@ -7600,7 +7790,7 @@ class PubServer(BaseHTTPRequestHandler): return if '&submitYes=' in blockConfirmParams: blockingActor = \ - urllib.parse.unquote(blockConfirmParams) + urllib.parse.unquote_plus(blockConfirmParams) blockingActor = blockingActor.split('actor=')[1] if '&' in blockingActor: blockingActor = blockingActor.split('&')[0] @@ -7698,7 +7888,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.POSTbusy = False return optionsConfirmParams = \ - urllib.parse.unquote(optionsConfirmParams) + urllib.parse.unquote_plus(optionsConfirmParams) # page number to return to if 'pageNumber=' in optionsConfirmParams: pageNumberStr = optionsConfirmParams.split('pageNumber=')[1] @@ -7731,6 +7921,16 @@ class PubServer(BaseHTTPRequestHandler): '?' in petname or '#' in petname: 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) if not optionsNickname: if callingDomain.endswith('.onion') and \ @@ -7773,7 +7973,23 @@ class PubServer(BaseHTTPRequestHandler): chooserNickname, self.server.domain, 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 + '?page='+str(pageNumber), cookie, callingDomain) @@ -7797,7 +8013,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.domain, optionsNickname, optionsDomainFull) - self._redirect_headers(originPathStr + '/' + + self._redirect_headers(usersPath + '/' + self.server.defaultTimeline + '?page='+str(pageNumber), cookie, callingDomain) diff --git a/devices.py b/devices.py new file mode 100644 index 000000000..69c8d2ca7 --- /dev/null +++ b/devices.py @@ -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 '' diff --git a/epicyon-options.css b/epicyon-options.css index 921fe849a..3fe023a1f 100644 --- a/epicyon-options.css +++ b/epicyon-options.css @@ -112,6 +112,12 @@ a:link { width: 15%; } +textarea { + font-size: var(--font-size4); + width: 90%; + background-color: var(--text-entry-background); +} + @media screen and (min-width: 400px) { .followText { font-size: var(--follow-text-size1); diff --git a/git.py b/git.py index ffe8e131f..68680e33e 100644 --- a/git.py +++ b/git.py @@ -126,6 +126,8 @@ def convertPostToPatch(baseDir: str, nickname: str, domain: str, return False if not postJsonObject['object'].get('attributedTo'): return False + if not isinstance(postJsonObject['object']['attributedTo'], str): + return False if not isGitPatch(baseDir, nickname, domain, postJsonObject['object']['type'], postJsonObject['object']['summary'], diff --git a/inbox.py b/inbox.py index 2a9dfae12..15bf74a25 100644 --- a/inbox.py +++ b/inbox.py @@ -1422,12 +1422,15 @@ def receiveAnnounce(recentPostsCache: {}, # so that their avatar can be shown lookupActor = None if postJsonObject.get('attributedTo'): - lookupActor = postJsonObject['attributedTo'] + if isinstance(postJsonObject['attributedTo'], str): + lookupActor = postJsonObject['attributedTo'] else: if postJsonObject.get('object'): if isinstance(postJsonObject['object'], dict): if postJsonObject['object'].get('attributedTo'): - lookupActor = postJsonObject['object']['attributedTo'] + attrib = postJsonObject['object']['attributedTo'] + if isinstance(attrib, str): + lookupActor = attrib if lookupActor: if '/users/' in lookupActor or \ '/channel/' in lookupActor or \ @@ -2190,24 +2193,25 @@ def inboxAfterCapabilities(recentPostsCache: {}, maxRecentPosts: int, postJsonObject['object'].get('summary') and \ postJsonObject['object'].get('attributedTo'): attributedTo = postJsonObject['object']['attributedTo'] - fromNickname = getNicknameFromActor(attributedTo) - fromDomain, fromPort = getDomainFromActor(attributedTo) - if fromPort: - if fromPort != 80 and fromPort != 443: - fromDomain += ':' + str(fromPort) - if receiveGitPatch(baseDir, nickname, domain, - postJsonObject['object']['type'], - postJsonObject['object']['summary'], - postJsonObject['object']['content'], - fromNickname, fromDomain): - gitPatchNotify(baseDir, handle, - postJsonObject['object']['summary'], - postJsonObject['object']['content'], - fromNickname, fromDomain) - elif '[PATCH]' in postJsonObject['object']['content']: - print('WARN: git patch not accepted - ' + - postJsonObject['object']['summary']) - return False + if isinstance(attributedTo, str): + fromNickname = getNicknameFromActor(attributedTo) + fromDomain, fromPort = getDomainFromActor(attributedTo) + if fromPort: + if fromPort != 80 and fromPort != 443: + fromDomain += ':' + str(fromPort) + if receiveGitPatch(baseDir, nickname, domain, + postJsonObject['object']['type'], + postJsonObject['object']['summary'], + postJsonObject['object']['content'], + fromNickname, fromDomain): + gitPatchNotify(baseDir, handle, + postJsonObject['object']['summary'], + postJsonObject['object']['content'], + fromNickname, fromDomain) + elif '[PATCH]' in postJsonObject['object']['content']: + print('WARN: git patch not accepted - ' + + postJsonObject['object']['summary']) + return False # replace YouTube links, so they get less tracking data replaceYouTube(postJsonObject, YTReplacementDomain) diff --git a/outbox.py b/outbox.py index c84e69dcd..bbf863f22 100644 --- a/outbox.py +++ b/outbox.py @@ -189,6 +189,7 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, if messageJson['type'] == 'Create' or \ messageJson['type'] == 'Question' or \ messageJson['type'] == 'Note' or \ + messageJson['type'] == 'EncryptedMessage' or \ messageJson['type'] == 'Article' or \ messageJson['type'] == 'Patch' or \ messageJson['type'] == 'Announce': diff --git a/person.py b/person.py index 78b20a01e..2519a426e 100644 --- a/person.py +++ b/person.py @@ -163,6 +163,38 @@ def randomizeActorImages(personJson: {}) -> None: 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, httpPrefix: str, saveToFile: bool, manualFollowerApproval: bool, @@ -212,34 +244,16 @@ def createPersonBase(baseDir: str, nickname: str, domain: str, port: int, personId + '/avatar' + \ 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 = { '@context': [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1', - contextDict + getDefaultPersonContext() ], 'attachment': [], 'alsoKnownAs': [], 'discoverable': False, + 'devices': personId + '/collections/devices', 'endpoints': { 'id': personId+'/endpoints', 'sharedInbox': httpPrefix+'://'+domain+'/inbox', @@ -1061,3 +1075,21 @@ def personUnsnooze(baseDir: str, nickname: str, domain: str, if writeSnoozedFile: writeSnoozedFile.write(content) 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 diff --git a/posts.py b/posts.py index f2aa61f43..1aa7f8b14 100644 --- a/posts.py +++ b/posts.py @@ -2400,6 +2400,7 @@ def isDM(postJsonObject: {}) -> bool: return False if postJsonObject['object']['type'] != 'Note' and \ postJsonObject['object']['type'] != 'Patch' and \ + postJsonObject['object']['type'] != 'EncryptedMessage' and \ postJsonObject['object']['type'] != 'Article': return False if postJsonObject['object'].get('moderationStatus'): @@ -2466,6 +2467,7 @@ def isReply(postJsonObject: {}, actor: str) -> bool: if postJsonObject['object'].get('moderationStatus'): return False if postJsonObject['object']['type'] != 'Note' and \ + postJsonObject['object']['type'] != 'EncryptedMessage' and \ postJsonObject['object']['type'] != 'Article': return False if postJsonObject['object'].get('inReplyTo'): @@ -2577,6 +2579,7 @@ def addPostStringToTimeline(postStr: str, boxname: str, """ # must be a recognized ActivityPub type if ('"Note"' in postStr or + '"EncryptedMessage"' in postStr or '"Article"' in postStr or '"Patch"' in postStr or '"Announce"' in postStr or diff --git a/tests.py b/tests.py index 2f85cd218..947d9d98c 100644 --- a/tests.py +++ b/tests.py @@ -1684,6 +1684,13 @@ def testWebLinks(): '

filepopout=' + \ 'TemplateAttachmentRichPopout' + exampleText = \ + '

Test1 test2 #YetAnotherExcessivelyLongwindedAndBoringHashtag

' + resultText = removeLongWords(addWebLinks(exampleText), 40, []) + assert(resultText == + '

Test1 test2 ' + '#YetAnotherExcessivelyLongwindedAndBorin\ngHashtag

') + def testAddEmoji(): print('testAddEmoji') diff --git a/translations/ar.json b/translations/ar.json index b54e8e450..b6762b3d2 100644 --- a/translations/ar.json +++ b/translations/ar.json @@ -254,5 +254,6 @@ "Grayscale": "درجات الرمادي", "Liked by": "نال إعجاب", "Solidaric": "تضامن", - "YouTube Replacement Domain": "استبدال نطاق يوتيوب" + "YouTube Replacement Domain": "استبدال نطاق يوتيوب", + "Notes": "ملاحظات" } diff --git a/translations/ca.json b/translations/ca.json index a9b786b2a..c4850e8ef 100644 --- a/translations/ca.json +++ b/translations/ca.json @@ -254,5 +254,6 @@ "Grayscale": "Escala de grisos", "Liked by": "M'agrada", "Solidaric": "Solidaritat", - "YouTube Replacement Domain": "Domini de substitució de YouTube" + "YouTube Replacement Domain": "Domini de substitució de YouTube", + "Notes": "Notes" } diff --git a/translations/cy.json b/translations/cy.json index 0edecbda1..afddb9c9f 100644 --- a/translations/cy.json +++ b/translations/cy.json @@ -254,5 +254,6 @@ "Grayscale": "Graddlwyd", "Liked by": "Hoffi", "Solidaric": "Undod", - "YouTube Replacement Domain": "Parth Amnewid YouTube" + "YouTube Replacement Domain": "Parth Amnewid YouTube", + "Notes": "Nodiadau" } diff --git a/translations/de.json b/translations/de.json index bc526301f..232e65d8b 100644 --- a/translations/de.json +++ b/translations/de.json @@ -254,5 +254,6 @@ "Grayscale": "Graustufen", "Liked by": "Gefallen von", "Solidaric": "Solidarität", - "YouTube Replacement Domain": "YouTube-Ersatzdomain" + "YouTube Replacement Domain": "YouTube-Ersatzdomain", + "Notes": "Anmerkungen" } diff --git a/translations/en.json b/translations/en.json index a472dffa7..1261381b6 100644 --- a/translations/en.json +++ b/translations/en.json @@ -254,5 +254,6 @@ "Grayscale": "Grayscale", "Liked by": "Liked by", "Solidaric": "Solidaric", - "YouTube Replacement Domain": "YouTube Replacement Domain" + "YouTube Replacement Domain": "YouTube Replacement Domain", + "Notes": "Notes" } diff --git a/translations/es.json b/translations/es.json index 04a95dfb4..333480767 100644 --- a/translations/es.json +++ b/translations/es.json @@ -254,5 +254,6 @@ "Grayscale": "Escala de grises", "Liked by": "Apreciado por", "Solidaric": "Solidaridad", - "YouTube Replacement Domain": "Dominio de reemplazo de YouTube" + "YouTube Replacement Domain": "Dominio de reemplazo de YouTube", + "Notes": "Notas" } diff --git a/translations/fr.json b/translations/fr.json index 3f9a8d363..f4b9dd0f4 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -254,5 +254,6 @@ "Grayscale": "Niveaux de gris", "Liked by": "Aimé par", "Solidaric": "Solidarité", - "YouTube Replacement Domain": "Domaine de remplacement YouTube" + "YouTube Replacement Domain": "Domaine de remplacement YouTube", + "Notes": "Remarques" } diff --git a/translations/ga.json b/translations/ga.json index cf69648d3..a812ff8e3 100644 --- a/translations/ga.json +++ b/translations/ga.json @@ -254,5 +254,6 @@ "Grayscale": "Liathscála", "Liked by": "Thaitin", "Solidaric": "Dlúthpháirtíocht", - "YouTube Replacement Domain": "Fearann Athsholáthair YouTube" + "YouTube Replacement Domain": "Fearann Athsholáthair YouTube", + "Notes": "Nótaí" } diff --git a/translations/hi.json b/translations/hi.json index b90225ecb..1b961804e 100644 --- a/translations/hi.json +++ b/translations/hi.json @@ -254,5 +254,6 @@ "Grayscale": "ग्रेस्केल", "Liked by": "द्वारा पसंद किया गया", "Solidaric": "एकजुटता", - "YouTube Replacement Domain": "YouTube रिप्लेसमेंट डोमेन" + "YouTube Replacement Domain": "YouTube रिप्लेसमेंट डोमेन", + "Notes": "टिप्पणियाँ" } diff --git a/translations/it.json b/translations/it.json index 2cecaa66d..b7c9c0507 100644 --- a/translations/it.json +++ b/translations/it.json @@ -254,5 +254,6 @@ "Grayscale": "Scala di grigi", "Liked by": "Mi è piaciuto", "Solidaric": "Solidarietà", - "YouTube Replacement Domain": "Dominio sostitutivo di YouTube" + "YouTube Replacement Domain": "Dominio sostitutivo di YouTube", + "Notes": "Appunti" } diff --git a/translations/ja.json b/translations/ja.json index 02a24543a..562118dd5 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -254,5 +254,6 @@ "Grayscale": "グレースケール", "Liked by": "好き", "Solidaric": "連帯", - "YouTube Replacement Domain": "YouTube交換ドメイン" + "YouTube Replacement Domain": "YouTube交換ドメイン", + "Notes": "ノート" } diff --git a/translations/oc.json b/translations/oc.json index 6203b272a..42ca4e070 100644 --- a/translations/oc.json +++ b/translations/oc.json @@ -250,5 +250,6 @@ "Grayscale": "Grayscale", "Liked by": "Liked by", "Solidaric": "Solidaric", - "YouTube Replacement Domain": "YouTube Replacement Domain" + "YouTube Replacement Domain": "YouTube Replacement Domain", + "Notes": "Notes" } diff --git a/translations/pt.json b/translations/pt.json index 19c0f4149..279e6c9ce 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -254,5 +254,6 @@ "Grayscale": "Escala de cinza", "Liked by": "Curtida por", "Solidaric": "Solidariedade", - "YouTube Replacement Domain": "Domínio de substituição do YouTube" + "YouTube Replacement Domain": "Domínio de substituição do YouTube", + "Notes": "Notas" } diff --git a/translations/ru.json b/translations/ru.json index bc322bf02..a5471654a 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -254,5 +254,6 @@ "Grayscale": "Оттенки серого", "Liked by": "Понравилось", "Solidaric": "солидарность", - "YouTube Replacement Domain": "Запасной домен YouTube" + "YouTube Replacement Domain": "Запасной домен YouTube", + "Notes": "Ноты" } diff --git a/translations/zh.json b/translations/zh.json index 16f29b92a..99f982152 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -253,5 +253,6 @@ "Grayscale": "灰阶", "Liked by": "喜欢的人", "Solidaric": "团结互助", - "YouTube Replacement Domain": "YouTube替换域" + "YouTube Replacement Domain": "YouTube替换域", + "Notes": "笔记" } diff --git a/webinterface.py b/webinterface.py index aade59c81..6f8030950 100644 --- a/webinterface.py +++ b/webinterface.py @@ -78,6 +78,7 @@ from git import isGitPatch from theme import getThemesList from petnames import getPetName from followingCalendar import receivingCalendarEvents +from devices import E2EEdecryptMessageFromDevice def getAltPath(actor: str, domainFull: str, callingDomain: str) -> str: @@ -3059,7 +3060,10 @@ def addEmbeddedVideoFromSites(translate: {}, content: str, return content invidiousSites = ('https://invidio.us', - 'axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4' + + 'https://invidious.snopyta.org', + 'http://c7hqkpkpemu6e7emz5b4vy' + + 'z7idjgdvgaaa3dyimmeojqbgpea3xqjoid.onion', + 'http://axqzx4s6s54s32yentfqojs3x5i7faxza6xo3ehd4' + 'bzzsg2ii4fv2iid.onion') for videoSite in invidiousSites: if '"' + videoSite in content: @@ -3812,8 +3816,9 @@ def individualPostAsHtml(recentPostsCache: {}, maxRecentPosts: int, if showIcons: replyToLink = postJsonObject['object']['id'] if postJsonObject['object'].get('attributedTo'): - replyToLink += \ - '?mention=' + postJsonObject['object']['attributedTo'] + if isinstance(postJsonObject['object']['attributedTo'], str): + replyToLink += \ + '?mention=' + postJsonObject['object']['attributedTo'] if postJsonObject['object'].get('content'): mentionedActors = \ getMentionsFromHtml(postJsonObject['object']['content']) @@ -3984,7 +3989,9 @@ def individualPostAsHtml(recentPostsCache: {}, maxRecentPosts: int, if showRepeatIcon: if isAnnounced: if postJsonObject['object'].get('attributedTo'): - attributedTo = postJsonObject['object']['attributedTo'] + attributedTo = '' + if isinstance(postJsonObject['object']['attributedTo'], str): + attributedTo = postJsonObject['object']['attributedTo'] if attributedTo.startswith(postActor): titleStr += \ ' \n' else: - announceNickname = \ - getNicknameFromActor(attributedTo) + announceNickname = None + if attributedTo: + announceNickname = getNicknameFromActor(attributedTo) if announceNickname: announceDomain, announcePort = \ getDomainFromActor(attributedTo) @@ -4248,8 +4256,13 @@ def individualPostAsHtml(recentPostsCache: {}, maxRecentPosts: int, if not postJsonObject['object'].get('summary'): postJsonObject['object']['summary'] = '' + if postJsonObject['object'].get('cipherText'): + postJsonObject['object']['content'] = \ + E2EEdecryptMessageFromDevice(postJsonObject['object']) + if not postJsonObject['object'].get('content'): return '' + isPatch = isGitPatch(baseDir, nickname, domain, postJsonObject['object']['type'], postJsonObject['object']['summary'], @@ -5560,10 +5573,10 @@ def htmlPersonOptions(translate: {}, baseDir: str, optionsStr += ' \n' optionsStr += ' \n' + handle = getNicknameFromActor(optionsActor) + '@' + optionsDomain optionsStr += \ '

' + translate['Options for'] + \ - ' @' + getNicknameFromActor(optionsActor) + '@' + \ - optionsDomain + '

\n' + ' @' + handle + '

\n' if emailAddress: optionsStr += \ '

' + translate['Email'] + \ @@ -5652,6 +5665,24 @@ def htmlPersonOptions(translate: {}, baseDir: str, ' \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 += \ + '

' + translate['Notes'] + ': \n' + optionsStr += '
\n' + optionsStr += \ + ' \n' + optionsStr += ' \n' optionsStr += '\n' optionsStr += '\n'