diff --git a/announce.py b/announce.py index 00804d2b5..5916f55a1 100644 --- a/announce.py +++ b/announce.py @@ -129,7 +129,7 @@ def createAnnounce(session, baseDir: str, federationList: [], 'actor': httpPrefix+'://'+fullDomain+'/users/'+nickname, 'atomUri': atomUriStr, 'cc': [], - 'id': newAnnounceId+'/activity', + 'id': newAnnounceId + '/activity', 'object': objectUrl, 'published': published, 'to': [toUrl], @@ -365,7 +365,7 @@ def sendAnnounceViaServer(baseDir: str, session, 'actor': httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname, 'atomUri': newAnnounceId, 'cc': [ccUrl], - 'id': newAnnounceId+'/activity', + 'id': newAnnounceId + '/activity', 'object': repeatObjectUrl, 'published': published, 'to': [toUrl], diff --git a/auth.py b/auth.py index d2aab5917..a9b83a938 100644 --- a/auth.py +++ b/auth.py @@ -130,7 +130,7 @@ def storeBasicCredentials(baseDir: str, nickname: str, password: str) -> bool: os.rename(passwordFile + '.new', passwordFile) else: # append to password file - with open(passwordFile, "a") as passfile: + with open(passwordFile, 'a+') as passfile: passfile.write(storeStr + '\n') else: with open(passwordFile, "w") as passfile: diff --git a/blocking.py b/blocking.py index 0c57af788..8d6436f3b 100644 --- a/blocking.py +++ b/blocking.py @@ -7,6 +7,7 @@ __email__ = "bob@freedombone.net" __status__ = "Production" import os +from utils import removeIdEnding from utils import isEvil from utils import locatePost from utils import evilIncarnate @@ -214,7 +215,7 @@ def outboxBlock(baseDir: str, httpPrefix: str, if debug: print('DEBUG: c2s block request arrived in outbox') - messageId = messageJson['object'].replace('/activity', '') + messageId = removeIdEnding(messageJson['object']) if '/statuses/' not in messageId: if debug: print('DEBUG: c2s block object is not a status') @@ -293,7 +294,7 @@ def outboxUndoBlock(baseDir: str, httpPrefix: str, if debug: print('DEBUG: c2s undo block request arrived in outbox') - messageId = messageJson['object']['object'].replace('/activity', '') + messageId = removeIdEnding(messageJson['object']['object']) if '/statuses/' not in messageId: if debug: print('DEBUG: c2s undo block object is not a status') diff --git a/bookmarks.py b/bookmarks.py index b7f1fa824..44b951971 100644 --- a/bookmarks.py +++ b/bookmarks.py @@ -8,6 +8,7 @@ __status__ = "Production" import os from pprint import pprint +from utils import removeIdEnding from utils import removePostFromCache from utils import urlPermitted from utils import getNicknameFromActor @@ -607,7 +608,7 @@ def outboxBookmark(recentPostsCache: {}, if debug: print('DEBUG: c2s bookmark request arrived in outbox') - messageId = messageJson['object'].replace('/activity', '') + messageId = removeIdEnding(messageJson['object']) if ':' in domain: domain = domain.split(':')[0] postFilename = locatePost(baseDir, nickname, domain, messageId) @@ -667,7 +668,7 @@ def outboxUndoBookmark(recentPostsCache: {}, if debug: print('DEBUG: c2s undo bookmark request arrived in outbox') - messageId = messageJson['object']['object'].replace('/activity', '') + messageId = removeIdEnding(messageJson['object']['object']) if ':' in domain: domain = domain.split(':')[0] postFilename = locatePost(baseDir, nickname, domain, messageId) diff --git a/daemon.py b/daemon.py index 072d637b0..4300d4d47 100644 --- a/daemon.py +++ b/daemon.py @@ -6,7 +6,7 @@ __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer, HTTPServer import sys import json import time @@ -69,6 +69,7 @@ from posts import createBlogPost from posts import createReportPost from posts import createUnlistedPost from posts import createFollowersOnlyPost +from posts import createEventPost from posts import createDirectMessagePost from posts import populateRepliesJson from posts import addToField @@ -126,6 +127,7 @@ from webinterface import htmlIndividualPost from webinterface import htmlProfile from webinterface import htmlInbox from webinterface import htmlBookmarks +from webinterface import htmlEvents from webinterface import htmlShares from webinterface import htmlOutbox from webinterface import htmlModeration @@ -153,6 +155,7 @@ from shares import getSharesFeedForPerson from shares import addShare from shares import removeShare from shares import expireShares +from utils import removeIdEnding from utils import updateLikesCollection from utils import undoLikesCollectionEntry from utils import deletePost @@ -315,12 +318,14 @@ class PubServer(BaseHTTPRequestHandler): print('Voting on message ' + messageId) print('Vote for: ' + answer) + commentsEnabled = True messageJson = \ createPublicPost(self.server.baseDir, nickname, self.server.domain, self.server.port, self.server.httpPrefix, answer, False, False, False, + commentsEnabled, None, None, None, True, messageId, messageId, None, False, None, None, None) @@ -2731,10 +2736,9 @@ class PubServer(BaseHTTPRequestHandler): self._benchmarkGETtimings(GETstartTime, GETtimings, 32) -# unrepeatPrivate = False if htmlGET and '?unrepeatprivate=' in self.path: self.path = self.path.replace('?unrepeatprivate=', '?unrepeat=') -# unrepeatPrivate = True + # undo an announce/repeat from the web interface if htmlGET and '?unrepeat=' in self.path: pageNumber = 1 @@ -3595,6 +3599,40 @@ class PubServer(BaseHTTPRequestHandler): self.server.GETbusy = False return + # Edit an event + if authorized and \ + '/tlevents' in self.path and \ + '?editeventpost=' in self.path and \ + '?actor=' in self.path: + messageId = self.path.split('?editeventpost=')[1] + if '?' in messageId: + messageId = messageId.split('?')[0] + actor = self.path.split('?actor=')[1] + if '?' in actor: + actor = actor.split('?')[0] + nickname = getNicknameFromActor(self.path) + if nickname == actor: + postUrl = \ + self.server.httpPrefix + '://' + \ + self.server.domainFull + '/users/' + nickname + \ + '/statuses/' + messageId + msg = None + # TODO + # htmlEditEvent(self.server.mediaInstance, + # self.server.translate, + # self.server.baseDir, + # self.server.httpPrefix, + # self.path, + # nickname, self.server.domain, + # postUrl) + if msg: + msg = msg.encode('utf-8') + self._set_headers('text/html', len(msg), + cookie, callingDomain) + self._write(msg) + self.server.GETbusy = False + return + # edit profile in web interface if '/users/' in self.path and self.path.endswith('/editprofile'): msg = htmlEditProfile(self.server.translate, @@ -3619,6 +3657,7 @@ class PubServer(BaseHTTPRequestHandler): self.path.endswith('/newfollowers') or self.path.endswith('/newdm') or self.path.endswith('/newreminder') or + self.path.endswith('/newevent') or self.path.endswith('/newreport') or self.path.endswith('/newquestion') or self.path.endswith('/newshare'))): @@ -4796,7 +4835,7 @@ class PubServer(BaseHTTPRequestHandler): else: # don't need authenticated fetch here because # there is already the authorization check - msg = json.dumps(inboxFeed, + msg = json.dumps(bookmarksFeed, ensure_ascii=False) msg = msg.encode('utf-8') self._set_headers('application/json', @@ -4819,6 +4858,103 @@ class PubServer(BaseHTTPRequestHandler): self.server.GETbusy = False return + # get the events for a given person + if self.path.endswith('/tlevents') or \ + '/tlevents?page=' in self.path or \ + self.path.endswith('/events') or \ + '/events?page=' in self.path: + if '/users/' in self.path: + if authorized: + # convert /events to /tlevents + if self.path.endswith('/events') or \ + '/events?page=' in self.path: + self.path = self.path.replace('/events', '/tlevents') + eventsFeed = \ + personBoxJson(self.server.recentPostsCache, + self.server.session, + self.server.baseDir, + self.server.domain, + self.server.port, + self.path, + self.server.httpPrefix, + maxPostsInFeed, 'tlevents', + authorized, self.server.ocapAlways) + print('eventsFeed: ' + str(eventsFeed)) + if eventsFeed: + if self._requestHTTP(): + nickname = self.path.replace('/users/', '') + nickname = nickname.replace('/tlevents', '') + pageNumber = 1 + if '?page=' in nickname: + pageNumber = nickname.split('?page=')[1] + nickname = nickname.split('?page=')[0] + if pageNumber.isdigit(): + pageNumber = int(pageNumber) + else: + pageNumber = 1 + if 'page=' not in self.path: + # if no page was specified then show the first + eventsFeed = \ + personBoxJson(self.server.recentPostsCache, + self.server.session, + self.server.baseDir, + self.server.domain, + self.server.port, + self.path + '?page=1', + self.server.httpPrefix, + maxPostsInFeed, + 'tlevents', + authorized, + self.server.ocapAlways) + msg = \ + htmlEvents(self.server.defaultTimeline, + self.server.recentPostsCache, + self.server.maxRecentPosts, + self.server.translate, + pageNumber, maxPostsInFeed, + self.server.session, + self.server.baseDir, + self.server.cachedWebfingers, + self.server.personCache, + nickname, + self.server.domain, + self.server.port, + eventsFeed, + self.server.allowDeletion, + self.server.httpPrefix, + self.server.projectVersion, + self._isMinimal(nickname), + self.server.YTReplacementDomain) + msg = msg.encode('utf-8') + self._set_headers('text/html', + len(msg), + cookie, callingDomain) + self._write(msg) + else: + # don't need authenticated fetch here because + # there is already the authorization check + msg = json.dumps(eventsFeed, + ensure_ascii=False) + msg = msg.encode('utf-8') + self._set_headers('application/json', + len(msg), + None, callingDomain) + self._write(msg) + self.server.GETbusy = False + return + else: + if self.server.debug: + nickname = self.path.replace('/users/', '') + nickname = nickname.replace('/tlevents', '') + print('DEBUG: ' + nickname + + ' was not authorized to access ' + self.path) + if self.server.debug: + print('DEBUG: GET access to events is unauthorized') + self.send_response(405) + self.end_headers() + self.server.GETbusy = False + return + self._benchmarkGETtimings(GETstartTime, GETtimings, 47) # get outbox feed for a person @@ -5486,11 +5622,13 @@ class PubServer(BaseHTTPRequestHandler): fields['subject'] = None if not fields.get('replyTo'): fields['replyTo'] = None + if not fields.get('schedulePost'): fields['schedulePost'] = False else: fields['schedulePost'] = True print('DEBUG: shedulePost ' + str(fields['schedulePost'])) + if not fields.get('eventDate'): fields['eventDate'] = None if not fields.get('eventTime'): @@ -5515,6 +5653,14 @@ class PubServer(BaseHTTPRequestHandler): mentionsStr = '' if fields.get('mentions'): mentionsStr = fields['mentions'].strip() + ' ' + if not fields.get('commentsEnabled'): + commentsEnabled = False + else: + commentsEnabled = True + if not fields.get('privateEvent'): + privateEvent = False + else: + privateEvent = True if postType == 'newpost': messageJson = \ createPublicPost(self.server.baseDir, @@ -5523,7 +5669,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.port, self.server.httpPrefix, mentionsStr + fields['message'], - False, False, False, + False, False, False, commentsEnabled, filename, attachmentMediaType, fields['imageDescription'], self.server.useBlurHash, @@ -5550,7 +5696,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.domain, self.server.port, self.server.httpPrefix, fields['message'], - False, False, False, + False, False, False, commentsEnabled, filename, attachmentMediaType, fields['imageDescription'], self.server.useBlurHash, @@ -5653,7 +5799,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.domain, self.server.port, self.server.httpPrefix, mentionsStr + fields['message'], - False, False, False, + False, False, False, commentsEnabled, filename, attachmentMediaType, fields['imageDescription'], self.server.useBlurHash, @@ -5686,6 +5832,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.httpPrefix, mentionsStr + fields['message'], True, False, False, + commentsEnabled, filename, attachmentMediaType, fields['imageDescription'], self.server.useBlurHash, @@ -5709,6 +5856,60 @@ class PubServer(BaseHTTPRequestHandler): return 1 else: return -1 + elif postType == 'newevent': + # A Mobilizon-type event is posted + + # if there is no image dscription then make it the same + # as the event title + if not fields.get('imageDescription'): + fields['imageDescription'] = fields['subject'] + # Events are public by default, with opt-in + # followers only status + if not fields.get('followersOnlyEvent'): + fields['followersOnlyEvent'] = False + + if not fields.get('anonymousParticipationEnabled'): + anonymousParticipationEnabled = False + else: + anonymousParticipationEnabled = True + maximumAttendeeCapacity = 999999 + if fields.get('maximumAttendeeCapacity'): + maximumAttendeeCapacity = \ + int(fields['maximumAttendeeCapacity']) + + messageJson = \ + createEventPost(self.server.baseDir, + nickname, + self.server.domain, + self.server.port, + self.server.httpPrefix, + mentionsStr + fields['message'], + privateEvent, + False, False, commentsEnabled, + filename, attachmentMediaType, + fields['imageDescription'], + self.server.useBlurHash, + fields['subject'], + fields['schedulePost'], + fields['eventDate'], + fields['eventTime'], + fields['location'], + fields['category'], + fields['joinMode'], + fields['endDate'], + fields['endTime'], + maximumAttendeeCapacity, + fields['repliesModerationOption'], + anonymousParticipationEnabled, + fields['eventStatus'], + fields['ticketUrl']) + if messageJson: + if fields['schedulePost']: + return 1 + if self._postToOutbox(messageJson, __version__, nickname): + return 1 + else: + return -1 elif postType == 'newdm': messageJson = None print('A DM was posted') @@ -5722,6 +5923,7 @@ class PubServer(BaseHTTPRequestHandler): mentionsStr + fields['message'], True, False, False, + commentsEnabled, filename, attachmentMediaType, fields['imageDescription'], self.server.useBlurHash, @@ -5761,7 +5963,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.port, self.server.httpPrefix, mentionsStr + fields['message'], - True, False, False, + True, False, False, False, filename, attachmentMediaType, fields['imageDescription'], self.server.useBlurHash, @@ -5794,7 +5996,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.domain, self.server.port, self.server.httpPrefix, mentionsStr + fields['message'], - True, False, False, + True, False, False, True, filename, attachmentMediaType, fields['imageDescription'], self.server.useBlurHash, @@ -5825,6 +6027,7 @@ class PubServer(BaseHTTPRequestHandler): self.server.httpPrefix, fields['message'], qOptions, False, False, False, + commentsEnabled, filename, attachmentMediaType, fields['imageDescription'], self.server.useBlurHash, @@ -6181,6 +6384,7 @@ class PubServer(BaseHTTPRequestHandler): if not self.path.endswith('confirm'): self.path = self.path.replace('/outbox/', '/outbox') self.path = self.path.replace('/tlblogs/', '/tlblogs') + self.path = self.path.replace('/tlevents/', '/tlevents') self.path = self.path.replace('/inbox/', '/inbox') self.path = self.path.replace('/shares/', '/shares') self.path = self.path.replace('/sharedInbox/', '/sharedInbox') @@ -6912,6 +7116,20 @@ class PubServer(BaseHTTPRequestHandler): if not removeTwitterActive: if os.path.isfile(removeTwitterFilename): os.remove(removeTwitterFilename) + # notify about new Likes + notifyLikesFilename = \ + self.server.baseDir + '/accounts/' + \ + nickname + '@' + self.server.domain + \ + '/.notifyLikes' + notifyLikesActive = False + if fields.get('notifyLikes'): + if fields['notifyLikes'] == 'on': + notifyLikesActive = True + with open(notifyLikesFilename, "w") as rFile: + rFile.write('\n') + if not notifyLikesActive: + if os.path.isfile(notifyLikesFilename): + os.remove(notifyLikesFilename) # this account is a bot if fields.get('isBot'): if fields['isBot'] == 'on': @@ -7822,7 +8040,7 @@ class PubServer(BaseHTTPRequestHandler): followId = followActor + '/statuses/' + str(statusNumber) unfollowJson = { '@context': 'https://www.w3.org/ns/activitystreams', - 'id': followId+'/undo', + 'id': followId + '/undo', 'type': 'Undo', 'actor': followActor, 'object': { @@ -8369,15 +8587,16 @@ class PubServer(BaseHTTPRequestHandler): # receive different types of post created by htmlNewPost postTypes = ("newpost", "newblog", "newunlisted", "newfollowers", "newdm", "newreport", "newshare", "newquestion", - "editblogpost", "newreminder") + "editblogpost", "newreminder", "newevent") for currPostType in postTypes: if not authorized: break - if currPostType != 'newshare': - postRedirect = self.server.defaultTimeline - else: + postRedirect = self.server.defaultTimeline + if currPostType == 'newshare': postRedirect = 'shares' + elif currPostType == 'newevent': + postRedirect = 'tlevents' pageNumber = self._receiveNewPost(currPostType, self.path) if pageNumber: @@ -8612,8 +8831,7 @@ class PubServer(BaseHTTPRequestHandler): if self.outboxAuthenticated: if self._postToOutbox(messageJson, __version__): if messageJson.get('id'): - locnStr = messageJson['id'].replace('/activity', '') - locnStr = locnStr.replace('/undo', '') + locnStr = removeIdEnding(messageJson['id']) self.headers['Location'] = locnStr self.send_response(201) self.end_headers() @@ -8658,16 +8876,17 @@ class PubServer(BaseHTTPRequestHandler): self._benchmarkPOSTtimings(POSTstartTime, POSTtimings, 22) - if not inboxPermittedMessage(self.server.domain, - messageJson, - self.server.federationList): - if self.server.debug: - # https://www.youtube.com/watch?v=K3PrSj9XEu4 - print('DEBUG: Ah Ah Ah') - self.send_response(403) - self.end_headers() - self.server.POSTbusy = False - return + if not self.server.unitTest: + if not inboxPermittedMessage(self.server.domain, + messageJson, + self.server.federationList): + if self.server.debug: + # https://www.youtube.com/watch?v=K3PrSj9XEu4 + print('DEBUG: Ah Ah Ah') + self.send_response(403) + self.end_headers() + self.server.POSTbusy = False + return self._benchmarkPOSTtimings(POSTstartTime, POSTtimings, 23) @@ -8711,6 +8930,17 @@ class PubServerUnitTest(PubServer): protocol_version = 'HTTP/1.0' +class EpicyonServer(ThreadingHTTPServer): + def handle_error(self, request, client_address): + # surpress connection reset errors + cls, e = sys.exc_info()[:2] + if cls is ConnectionResetError: + print('ERROR: ' + str(cls) + ", " + str(e)) + pass + else: + return HTTPServer.handle_error(self, request, client_address) + + def runPostsQueue(baseDir: str, sendThreads: [], debug: bool) -> None: """Manages the threads used to send posts """ @@ -8812,7 +9042,7 @@ def runDaemon(blogsInstance: bool, mediaInstance: bool, pubHandler = partial(PubServer) try: - httpd = ThreadingHTTPServer(serverAddress, pubHandler) + httpd = EpicyonServer(serverAddress, pubHandler) except Exception as e: if e.errno == 98: print('ERROR: HTTP server address is already in use. ' + @@ -8822,6 +9052,7 @@ def runDaemon(blogsInstance: bool, mediaInstance: bool, print('ERROR: HTTP server failed to start. ' + str(e)) return False + httpd.unitTest = unitTest httpd.YTReplacementDomain = YTReplacementDomain # This counter is used to update the list of blocked domains in memory. diff --git a/delete.py b/delete.py index f77585440..1b537d391 100644 --- a/delete.py +++ b/delete.py @@ -6,6 +6,7 @@ __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +from utils import removeIdEnding from utils import getStatusNumber from utils import urlPermitted from utils import getNicknameFromActor @@ -257,7 +258,7 @@ def outboxDelete(baseDir: str, httpPrefix: str, if debug: print('DEBUG: delete not permitted from other instances') return - messageId = messageJson['object'].replace('/activity', '') + messageId = removeIdEnding(messageJson['object']) if '/statuses/' not in messageId: if debug: print('DEBUG: c2s delete object is not a status') diff --git a/epicyon-profile.css b/epicyon-profile.css index 1c38bf5d3..62ee6412e 100644 --- a/epicyon-profile.css +++ b/epicyon-profile.css @@ -861,22 +861,22 @@ aside .toggle-inside li { @media screen and (min-width: 400px) { .container p.administeredby { - font-size: var(--font-size-header); - font-family: Arial, Helvetica, sans-serif; + font-size: var(--font-size-header); + font-family: Arial, Helvetica, sans-serif; } .toxaddr { - font-size: var(--font-size-tox); - font-family: Arial, Helvetica, sans-serif; + font-size: var(--font-size-tox); + font-family: Arial, Helvetica, sans-serif; } .ssbaddr { - font-size: var(--font-size-pgp-key); - font-family: Arial, Helvetica, sans-serif; + font-size: var(--font-size-pgp-key); + font-family: Arial, Helvetica, sans-serif; } .pgp { - font-size: var(--font-size-pgp-key); - color: var(--main-link-color); - background: var(--link-bg-color); - font-family: 'monospace'; + font-size: var(--font-size-pgp-key); + color: var(--main-link-color); + background: var(--link-bg-color); + font-family: 'monospace'; } body, html { font-size: var(--font-size4); @@ -1236,38 +1236,58 @@ aside .toggle-inside li { padding: 0px 0px; } .dropdown-menutoggle { - -webkit-margin-start: 0px; - -webkit-margin-end: 0px; - -webkit-padding-start: 40px; - border-top-left-radius: 0; - border-top-right-radius: 0; - position: absolute; - top: 100%; - left: 21px; - width: 300%; - min-width: 100%; - z-index: 1000; - display: block; - float: left; - padding: 0 17px !important; - margin: 2px 0 0 !important; - font-size: var(--font-size2); - text-align: left; - list-style: none; - color: var(--dropdown-fg-color); - background-color: var(--dropdown-bg-color); - -webkit-background-clip: padding-box; - background-clip: padding-box; + -webkit-margin-start: 0px; + -webkit-margin-end: 0px; + -webkit-padding-start: 40px; + border-top-left-radius: 0; + border-top-right-radius: 0; + position: absolute; + top: 100%; + left: 21px; + width: 300%; + min-width: 100%; + z-index: 1000; + display: block; + float: left; + padding: 0 17px !important; + margin: 2px 0 0 !important; + font-size: var(--font-size2); + text-align: left; + list-style: none; + color: var(--dropdown-fg-color); + background-color: var(--dropdown-bg-color); + -webkit-background-clip: padding-box; + background-clip: padding-box; } input[type=checkbox] { - -ms-transform: scale(2); - -moz-transform: scale(2); - -webkit-transform: scale(2); - -o-transform: scale(2); - transform: scale(2); - padding: 10px; - margin: 20px 30px; + -ms-transform: scale(2); + -moz-transform: scale(2); + -webkit-transform: scale(2); + -o-transform: scale(2); + transform: scale(2); + padding: 10px; + margin: 20px 30px; + } + input[type=radio] + { + -ms-transform: scale(2); + -moz-transform: scale(2); + -webkit-transform: scale(2); + -o-transform: scale(2); + transform: scale(2); + padding: 10px; + margin: 20px 30px; + } + input[type=number] + { + -ms-transform: scale(2); + -moz-transform: scale(2); + -webkit-transform: scale(2); + -o-transform: scale(2); + transform: scale(2); + padding: 10px; + margin: 20px 60px; } } @@ -1282,22 +1302,22 @@ aside .toggle-inside li { @media screen and (max-width: 1000px) { .container p.administeredby { - font-size: var(--font-size-tox2); - font-family: Arial, Helvetica, sans-serif; + font-size: var(--font-size-tox2); + font-family: Arial, Helvetica, sans-serif; } .toxaddr { - font-size: var(--font-size-tox2); - font-family: Arial, Helvetica, sans-serif; + font-size: var(--font-size-tox2); + font-family: Arial, Helvetica, sans-serif; } .ssbaddr { - font-size: var(--font-size-pgp-key2); - font-family: Arial, Helvetica, sans-serif; + font-size: var(--font-size-pgp-key2); + font-family: Arial, Helvetica, sans-serif; } .pgp { - font-size: var(--font-size-pgp-key2); - color: var(--main-link-color); - background: var(--link-bg-color); - font-family: 'monospace'; + font-size: var(--font-size-pgp-key2); + color: var(--main-link-color); + background: var(--link-bg-color); + font-family: 'monospace'; } body, html { font-size: var(--font-size3); @@ -1687,6 +1707,26 @@ aside .toggle-inside li { -o-transform: scale(4); transform: scale(4); padding: 20px; - margin: 30px 40px; + margin: 30px 40px; + } + input[type=radio] + { + -ms-transform: scale(2); + -moz-transform: scale(2); + -webkit-transform: scale(2); + -o-transform: scale(2); + transform: scale(2); + padding: 20px; + margin: 30px 40px; + } + input[type=number] + { + -ms-transform: scale(2); + -moz-transform: scale(2); + -webkit-transform: scale(2); + -o-transform: scale(2); + transform: scale(2); + padding: 10px; + margin: 40px 80px; } } diff --git a/epicyon.py b/epicyon.py index c7809ecbd..276dd32bf 100644 --- a/epicyon.py +++ b/epicyon.py @@ -108,7 +108,7 @@ parser.add_argument('-p', '--port', dest='port', type=int, default=None, help='Port number to run on') parser.add_argument('--postcache', dest='maxRecentPosts', type=int, - default=100, + default=512, help='The maximum number of recent posts to store in RAM') parser.add_argument('--proxy', dest='proxyPort', type=int, default=None, help='Proxy port number to run on') @@ -166,6 +166,11 @@ parser.add_argument('--json', dest='json', type=str, default=None, help='Show the json for a given activitypub url') parser.add_argument('-f', '--federate', nargs='+', dest='federationList', help='Specify federation list separated by spaces') +parser.add_argument("--repliesEnabled", "--commentsEnabled", + dest='commentsEnabled', + type=str2bool, nargs='?', + const=True, default=True, + help="Enable replies to a post") parser.add_argument("--noapproval", type=str2bool, nargs='?', const=True, default=False, help="Allow followers without approval") @@ -829,7 +834,7 @@ if args.message: domain, port, toNickname, toDomain, toPort, ccUrl, httpPrefix, sendMessage, followersOnly, - attach, mediaType, + args.commentsEnabled, attach, mediaType, attachedImageDescription, useBlurhash, cachedWebfingers, personCache, isArticle, args.debug, replyTo, replyTo, subject) @@ -1751,30 +1756,31 @@ if args.testdata: deleteAllPosts(baseDir, nickname, domain, 'outbox') createPublicPost(baseDir, nickname, domain, port, httpPrefix, "like, this is totally just a #test, man", - False, True, False, None, None, useBlurhash) + False, True, False, True, None, None, useBlurhash) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "Zoiks!!!", - False, True, False, None, None, useBlurhash) + False, True, False, True, None, None, useBlurhash) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "Hey scoob we need like a hundred more #milkshakes", - False, True, False, None, None, useBlurhash) + False, True, False, True, None, None, useBlurhash) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "Getting kinda spooky around here", - False, True, False, None, None, useBlurhash, 'someone') + False, True, False, True, None, None, + useBlurhash, 'someone') createPublicPost(baseDir, nickname, domain, port, httpPrefix, "And they would have gotten away with it too" + "if it wasn't for those pesky hackers", - False, True, False, 'img/logo.png', + False, True, False, True, 'img/logo.png', 'Description of image', useBlurhash) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "man, these centralized sites are, like, the worst!", - False, True, False, None, None, useBlurhash) + False, True, False, True, None, None, useBlurhash) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "another mystery solved #test", - False, True, False, None, None, useBlurhash) + False, True, False, True, None, None, useBlurhash) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "let's go bowling", - False, True, False, None, None, useBlurhash) + False, True, False, True, None, None, useBlurhash) domainFull = domain + ':' + str(port) clearFollows(baseDir, nickname, domain) diff --git a/follow.py b/follow.py index 3c6c6e1d0..a2da2d991 100644 --- a/follow.py +++ b/follow.py @@ -202,14 +202,14 @@ def unfollowPerson(baseDir: str, nickname: str, domain: str, if debug: print('DEBUG: follow file ' + filename + ' was not found') return False - if handleToUnfollow.lower() not in open(filename).read().lower(): + handleToUnfollowLower = handleToUnfollow.lower() + if handleToUnfollowLower not in open(filename).read().lower(): if debug: print('DEBUG: handle to unfollow ' + handleToUnfollow + ' is not in ' + filename) return with open(filename, "r") as f: lines = f.readlines() - handleToUnfollowLower = handleToUnfollow.lower() with open(filename, "w") as f: for line in lines: if line.strip("\n").strip("\r").lower() != handleToUnfollowLower: @@ -520,7 +520,7 @@ def storeFollowRequest(baseDir: str, approveFollowsFilename = accountsDir + '/followrequests.txt' if os.path.isfile(approveFollowsFilename): if approveHandle not in open(approveFollowsFilename).read(): - with open(approveFollowsFilename, "a") as fp: + with open(approveFollowsFilename, 'a+') as fp: fp.write(approveHandle + '\n') else: if debug: diff --git a/happening.py b/happening.py index 438ffa3ad..1c81e0d5c 100644 --- a/happening.py +++ b/happening.py @@ -43,12 +43,14 @@ def removeEventFromTimeline(eventId: str, tlEventsFilename: str) -> None: pass -def saveEvent(baseDir: str, handle: str, postId: str, - eventJson: {}) -> bool: +def saveEventPost(baseDir: str, handle: str, postId: str, + eventJson: {}) -> bool: """Saves an event to the calendar and/or the events timeline If an event has extra fields, as per Mobilizon, Then it is saved as a separate entity and added to the events timeline + See https://framagit.org/framasoft/mobilizon/-/blob/ + master/lib/federation/activity_stream/converter/event.ex """ calendarPath = baseDir + '/accounts/' + handle + '/calendar' if not os.path.isdir(calendarPath): @@ -71,6 +73,7 @@ def saveEvent(baseDir: str, handle: str, postId: str, eventJson.get('uuid') and eventJson.get('content'): if not validUuid(eventJson['uuid']): return False + print('Mobilizon type event') # if this is a full description of an event then save it # as a separate json file eventsPath = baseDir + '/accounts/' + handle + '/events' diff --git a/img/icons/hacker/scope_event.png b/img/icons/hacker/scope_event.png new file mode 100644 index 000000000..80ae04060 Binary files /dev/null and b/img/icons/hacker/scope_event.png differ diff --git a/img/icons/henge/scope_event.png b/img/icons/henge/scope_event.png new file mode 100644 index 000000000..eb21b795f Binary files /dev/null and b/img/icons/henge/scope_event.png differ diff --git a/img/icons/lcd/scope_event.png b/img/icons/lcd/scope_event.png new file mode 100644 index 000000000..d6d11ecf2 Binary files /dev/null and b/img/icons/lcd/scope_event.png differ diff --git a/img/icons/light/scope_event.png b/img/icons/light/scope_event.png new file mode 100644 index 000000000..6d5789c3a Binary files /dev/null and b/img/icons/light/scope_event.png differ diff --git a/img/icons/night/scope_event.png b/img/icons/night/scope_event.png new file mode 100644 index 000000000..6d5789c3a Binary files /dev/null and b/img/icons/night/scope_event.png differ diff --git a/img/icons/purple/scope_event.png b/img/icons/purple/scope_event.png new file mode 100644 index 000000000..3b3009d4f Binary files /dev/null and b/img/icons/purple/scope_event.png differ diff --git a/img/icons/scope_event.png b/img/icons/scope_event.png new file mode 100644 index 000000000..3afe1353a Binary files /dev/null and b/img/icons/scope_event.png differ diff --git a/img/icons/solidaric/scope_event.png b/img/icons/solidaric/scope_event.png new file mode 100644 index 000000000..7d951c55e Binary files /dev/null and b/img/icons/solidaric/scope_event.png differ diff --git a/img/icons/starlight/scope_event.png b/img/icons/starlight/scope_event.png new file mode 100644 index 000000000..cf9071585 Binary files /dev/null and b/img/icons/starlight/scope_event.png differ diff --git a/img/icons/zen/scope_event.png b/img/icons/zen/scope_event.png new file mode 100644 index 000000000..89d1785cb Binary files /dev/null and b/img/icons/zen/scope_event.png differ diff --git a/inbox.py b/inbox.py index 692b89c17..ced05e1f7 100644 --- a/inbox.py +++ b/inbox.py @@ -10,6 +10,8 @@ import json import os import datetime import time +from utils import isEventPost +from utils import removeIdEnding from utils import getProtocolPrefixes from utils import isBlogPost from utils import removeAvatarFromCache @@ -49,9 +51,11 @@ from filters import isFiltered from announce import updateAnnounceCollection from announce import undoAnnounceCollectionEntry from httpsig import messageContentDigest +from posts import validContentWarning from posts import downloadAnnounce from posts import isDM from posts import isReply +from posts import isMuted from posts import isImageMedia from posts import sendSignedJson from posts import sendToFollowersThread @@ -64,7 +68,7 @@ from git import isGitPatch from git import receiveGitPatch from followingCalendar import receivingCalendarEvents from content import dangerousMarkup -from happening import saveEvent +from happening import saveEventPost def storeHashTags(baseDir: str, nickname: str, postJsonObject: {}) -> None: @@ -93,7 +97,7 @@ def storeHashTags(baseDir: str, nickname: str, postJsonObject: {}) -> None: continue tagName = tag['name'].replace('#', '').strip() tagsFilename = tagsDir + '/' + tagName + '.txt' - postUrl = postJsonObject['id'].replace('/activity', '') + postUrl = removeIdEnding(postJsonObject['id']) postUrl = postUrl.replace('/', '#') daysDiff = datetime.datetime.utcnow() - datetime.datetime(1970, 1, 1) daysSinceEpoch = daysDiff.days @@ -122,13 +126,14 @@ def inboxStorePostToHtmlCache(recentPostsCache: {}, maxRecentPosts: int, session, cachedWebfingers: {}, personCache: {}, nickname: str, domain: str, port: int, postJsonObject: {}, - allowDeletion: bool) -> None: + allowDeletion: bool, boxname: str) -> None: """Converts the json post into html and stores it in a cache This enables the post to be quickly displayed later """ pageNumber = -999 avatarUrl = None - boxName = 'inbox' + if boxname != 'tlevents' and boxname != 'outbox': + boxName = 'inbox' individualPostAsHtml(recentPostsCache, maxRecentPosts, getIconsDir(baseDir), translate, pageNumber, baseDir, session, cachedWebfingers, personCache, @@ -230,9 +235,11 @@ def getPersonPubKey(baseDir: str, session, personUrl: str, def inboxMessageHasParams(messageJson: {}) -> bool: """Checks whether an incoming message contains expected parameters """ - expectedParams = ['type', 'actor', 'object'] + expectedParams = ['actor', 'type', 'object'] for param in expectedParams: if not messageJson.get(param): + # print('inboxMessageHasParams: ' + + # param + ' ' + str(messageJson)) return False if not messageJson.get('to'): allowedWithoutToParam = ['Like', 'Follow', 'Request', @@ -248,6 +255,7 @@ def inboxPermittedMessage(domain: str, messageJson: {}, """ if not messageJson.get('actor'): return False + actor = messageJson['actor'] # always allow the local domain if domain in actor: @@ -354,15 +362,13 @@ def savePostToInboxQueue(baseDir: str, httpPrefix: str, return None originalPostId = None if postJsonObject.get('id'): - originalPostId = \ - postJsonObject['id'].replace('/activity', '').replace('/undo', '') + originalPostId = removeIdEnding(postJsonObject['id']) currTime = datetime.datetime.utcnow() postId = None if postJsonObject.get('id'): - postId = postJsonObject['id'].replace('/activity', '') - postId = postId.replace('/undo', '') + postId = removeIdEnding(postJsonObject['id']) published = currTime.strftime("%Y-%m-%dT%H:%M:%SZ") if not postId: statusNumber, published = getStatusNumber() @@ -706,10 +712,9 @@ def receiveUndoFollow(session, baseDir: str, httpPrefix: str, nicknameFollowing, domainFollowingFull, nicknameFollower, domainFollowerFull, debug): - if debug: - print('DEBUG: Follower ' + - nicknameFollower + '@' + domainFollowerFull + - ' was removed') + print(nicknameFollowing + '@' + domainFollowingFull + ': ' + 'Follower ' + nicknameFollower + '@' + domainFollowerFull + + ' was removed') return True if debug: @@ -771,6 +776,28 @@ def receiveUndo(session, baseDir: str, httpPrefix: str, return False +def receiveEventPost(recentPostsCache: {}, session, baseDir: str, + httpPrefix: str, domain: str, port: int, + sendThreads: [], postLog: [], cachedWebfingers: {}, + personCache: {}, messageJson: {}, federationList: [], + nickname: str, debug: bool) -> bool: + """Receive a mobilizon-type event activity + See https://framagit.org/framasoft/mobilizon/-/blob/ + master/lib/federation/activity_stream/converter/event.ex + """ + if not isEventPost(messageJson): + return + print('Receiving event: ' + str(messageJson['object'])) + handle = nickname + '@' + domain + if port: + if port != 80 and port != 443: + handle += ':' + str(port) + + postId = removeIdEnding(messageJson['id']).replace('/', '#') + + saveEventPost(baseDir, handle, postId, messageJson['object']) + + def personReceiveUpdate(baseDir: str, domain: str, port: int, updateNickname: str, updateDomain: str, @@ -857,7 +884,7 @@ def receiveUpdateToQuestion(recentPostsCache: {}, messageJson: {}, return if not messageJson.get('actor'): return - messageId = messageJson['id'].replace('/activity', '') + messageId = removeIdEnding(messageJson['id']) if '#' in messageId: messageId = messageId.split('#', 1)[0] # find the question post @@ -1314,8 +1341,7 @@ def receiveDelete(session, handle: str, isGroup: bool, baseDir: str, if not os.path.isdir(baseDir + '/accounts/' + handle): print('DEBUG: unknown recipient of like - ' + handle) # if this post in the outbox of the person? - messageId = messageJson['object'].replace('/activity', '') - messageId = messageId.replace('/undo', '') + messageId = removeIdEnding(messageJson['object']) removeModerationPostFromIndex(baseDir, messageId, debug) postFilename = locatePost(baseDir, handle.split('@')[0], handle.split('@')[1], messageId) @@ -1532,6 +1558,28 @@ def receiveUndoAnnounce(recentPostsCache: {}, return True +def jsonPostAllowsComments(postJsonObject: {}) -> bool: + """Returns true if the given post allows comments/replies + """ + if 'commentsEnabled' in postJsonObject: + return postJsonObject['commentsEnabled'] + if postJsonObject.get('object'): + if not isinstance(postJsonObject['object'], dict): + return False + if 'commentsEnabled' in postJsonObject['object']: + return postJsonObject['object']['commentsEnabled'] + return True + + +def postAllowsComments(postFilename: str) -> bool: + """Returns true if the given post allows comments/replies + """ + postJsonObject = loadJson(postFilename) + if not postJsonObject: + return False + return jsonPostAllowsComments(postJsonObject) + + def populateReplies(baseDir: str, httpPrefix: str, domain: str, messageJson: {}, maxReplies: int, debug: bool) -> bool: """Updates the list of replies for a post on this domain if @@ -1572,16 +1620,19 @@ def populateReplies(baseDir: str, httpPrefix: str, domain: str, if debug: print('DEBUG: post may have expired - ' + replyTo) return False + if not postAllowsComments(postFilename): + if debug: + print('DEBUG: post does not allow comments - ' + replyTo) + return False # populate a text file containing the ids of replies postRepliesFilename = postFilename.replace('.json', '.replies') - messageId = messageJson['id'].replace('/activity', '') - messageId = messageId.replace('/undo', '') + messageId = removeIdEnding(messageJson['id']) if os.path.isfile(postRepliesFilename): numLines = sum(1 for line in open(postRepliesFilename)) if numLines > maxReplies: return False if messageId not in open(postRepliesFilename).read(): - repliesFile = open(postRepliesFilename, "a") + repliesFile = open(postRepliesFilename, 'a+') repliesFile.write(messageId + '\n') repliesFile.close() else: @@ -1624,6 +1675,15 @@ def validPostContent(baseDir: str, nickname: str, domain: str, if 'Z' not in messageJson['object']['published']: return False + if messageJson['object'].get('summary'): + summary = messageJson['object']['summary'] + if not isinstance(summary, str): + print('WARN: content warning is not a string') + return False + if summary != validContentWarning(summary): + print('WARN: invalid content warning ' + summary) + return False + if isGitPatch(baseDir, nickname, domain, messageJson['object']['type'], messageJson['object']['summary'], @@ -1667,6 +1727,16 @@ def validPostContent(baseDir: str, nickname: str, domain: str, messageJson['object']['content']): print('REJECT: content filtered') return False + if messageJson['object'].get('inReplyTo'): + if isinstance(messageJson['object']['inReplyTo'], str): + originalPostId = messageJson['object']['inReplyTo'] + postPostFilename = locatePost(baseDir, nickname, domain, + originalPostId) + if postPostFilename: + if not postAllowsComments(postPostFilename): + print('REJECT: reply to post which does not ' + + 'allow comments: ' + originalPostId) + return False print('ACCEPT: post content is valid') return True @@ -1778,8 +1848,12 @@ def likeNotify(baseDir: str, domain: str, onionDomain: str, return accountDir = baseDir + '/accounts/' + handle - if not os.path.isdir(accountDir): + + # are like notifications enabled? + notifyLikesEnabledFilename = accountDir + '/.notifyLikes' + if not os.path.isfile(notifyLikesEnabledFilename): return + likeFile = accountDir + '/.newLike' if os.path.isfile(likeFile): if '##sent##' not in open(likeFile).read(): @@ -1981,8 +2055,7 @@ def inboxUpdateCalendar(baseDir: str, handle: str, postJsonObject: {}) -> None: actorNickname, actorDomain): return - postId = \ - postJsonObject['id'].replace('/activity', '').replace('/', '#') + postId = removeIdEnding(postJsonObject['id']).replace('/', '#') # look for events within the tags list for tagDict in postJsonObject['object']['tag']: @@ -1992,7 +2065,7 @@ def inboxUpdateCalendar(baseDir: str, handle: str, postJsonObject: {}) -> None: continue if not tagDict.get('startTime'): continue - saveEvent(baseDir, handle, postId, tagDict) + saveEventPost(baseDir, handle, postId, tagDict) def inboxUpdateIndex(boxname: str, baseDir: str, handle: str, @@ -2171,12 +2244,18 @@ def inboxAfterCapabilities(recentPostsCache: {}, maxRecentPosts: int, if validPostContent(baseDir, nickname, domain, postJsonObject, maxMentions, maxEmoji): + if postJsonObject.get('object'): + jsonObj = postJsonObject['object'] + if not isinstance(jsonObj, dict): + jsonObj = None + else: + jsonObj = postJsonObject # check for incoming git patches - if isinstance(postJsonObject['object'], dict): - if postJsonObject['object'].get('content') and \ - postJsonObject['object'].get('summary') and \ - postJsonObject['object'].get('attributedTo'): - attributedTo = postJsonObject['object']['attributedTo'] + if jsonObj: + if jsonObj.get('content') and \ + jsonObj.get('summary') and \ + jsonObj.get('attributedTo'): + attributedTo = jsonObj['attributedTo'] if isinstance(attributedTo, str): fromNickname = getNicknameFromActor(attributedTo) fromDomain, fromPort = getDomainFromActor(attributedTo) @@ -2184,17 +2263,17 @@ def inboxAfterCapabilities(recentPostsCache: {}, maxRecentPosts: int, if fromPort != 80 and fromPort != 443: fromDomain += ':' + str(fromPort) if receiveGitPatch(baseDir, nickname, domain, - postJsonObject['object']['type'], - postJsonObject['object']['summary'], - postJsonObject['object']['content'], + jsonObj['type'], + jsonObj['summary'], + jsonObj['content'], fromNickname, fromDomain): gitPatchNotify(baseDir, handle, - postJsonObject['object']['summary'], - postJsonObject['object']['content'], + jsonObj['summary'], + jsonObj['content'], fromNickname, fromDomain) - elif '[PATCH]' in postJsonObject['object']['content']: + elif '[PATCH]' in jsonObj['content']: print('WARN: git patch not accepted - ' + - postJsonObject['object']['summary']) + jsonObj['summary']) return False # replace YouTube links, so they get less tracking data @@ -2224,6 +2303,8 @@ def inboxAfterCapabilities(recentPostsCache: {}, maxRecentPosts: int, postJsonObject, debug, __version__) + isReplyToMutedPost = False + if not isGroup: # create a DM notification file if needed postIsDM = isDM(postJsonObject) @@ -2274,9 +2355,13 @@ def inboxAfterCapabilities(recentPostsCache: {}, maxRecentPosts: int, if nickname != 'inbox': # replies index will be updated updateIndexList.append('tlreplies') - replyNotify(baseDir, handle, - httpPrefix + '://' + domain + - '/users/' + nickname + '/tlreplies') + if not isMuted(baseDir, nickname, domain, + postJsonObject['object']['inReplyTo']): + replyNotify(baseDir, handle, + httpPrefix + '://' + domain + + '/users/' + nickname + '/tlreplies') + else: + isReplyToMutedPost = True if isImageMedia(session, baseDir, httpPrefix, nickname, domain, postJsonObject, @@ -2286,6 +2371,9 @@ def inboxAfterCapabilities(recentPostsCache: {}, maxRecentPosts: int, if isBlogPost(postJsonObject): # blogs index will be updated updateIndexList.append('tlblogs') + elif isEventPost(postJsonObject): + # events index will be updated + updateIndexList.append('tlevents') # get the avatar for a reply/announce obtainAvatarForReplyPost(session, baseDir, @@ -2294,32 +2382,49 @@ def inboxAfterCapabilities(recentPostsCache: {}, maxRecentPosts: int, # save the post to file if saveJson(postJsonObject, destinationFilename): + # If this is a reply to a muted post then also mute it. + # This enables you to ignore a threat that's getting boring + if isReplyToMutedPost: + print('MUTE REPLY: ' + destinationFilename) + muteFile = open(destinationFilename + '.muted', "w") + if muteFile: + muteFile.write('\n') + muteFile.close() + # update the indexes for different timelines for boxname in updateIndexList: if not inboxUpdateIndex(boxname, baseDir, handle, destinationFilename, debug): print('ERROR: unable to update ' + boxname + ' index') + else: + if not unitTest: + if debug: + print('Saving inbox post as html to cache') + + htmlCacheStartTime = time.time() + inboxStorePostToHtmlCache(recentPostsCache, + maxRecentPosts, + translate, baseDir, + httpPrefix, + session, cachedWebfingers, + personCache, + handle.split('@')[0], + domain, port, + postJsonObject, + allowDeletion, + boxname) + if debug: + timeDiff = \ + str(int((time.time() - htmlCacheStartTime) * + 1000)) + print('Saved ' + boxname + + ' post as html to cache in ' + + timeDiff + ' mS') inboxUpdateCalendar(baseDir, handle, postJsonObject) storeHashTags(baseDir, handle.split('@')[0], postJsonObject) - if not unitTest: - if debug: - print('DEBUG: saving inbox post as html to cache') - htmlCacheStartTime = time.time() - inboxStorePostToHtmlCache(recentPostsCache, maxRecentPosts, - translate, baseDir, httpPrefix, - session, cachedWebfingers, - personCache, - handle.split('@')[0], domain, port, - postJsonObject, allowDeletion) - if debug: - timeDiff = \ - str(int((time.time() - htmlCacheStartTime) * 1000)) - print('DEBUG: saved inbox post as html to cache in ' + - timeDiff + ' mS') - # send the post out to group members if isGroup: sendToGroupMembers(session, baseDir, handle, port, @@ -2594,7 +2699,8 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, if accountMaxPostsPerDay > 0 or domainMaxPostsPerDay > 0: pprint(quotasDaily) - print('Obtaining public key for actor ' + queueJson['actor']) + if queueJson.get('actor'): + print('Obtaining public key for actor ' + queueJson['actor']) # Try a few times to obtain the public key pubKey = None @@ -2716,6 +2822,23 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, queue.pop(0) continue + if receiveEventPost(recentPostsCache, session, + baseDir, httpPrefix, + domain, port, + sendThreads, postLog, + cachedWebfingers, + personCache, + queueJson['post'], + federationList, + queueJson['postNickname'], + debug): + print('Queue: Event activity accepted from ' + keyId) + if os.path.isfile(queueFilename): + os.remove(queueFilename) + if len(queue) > 0: + queue.pop(0) + continue + if receiveUpdate(recentPostsCache, session, baseDir, httpPrefix, domain, port, diff --git a/like.py b/like.py index f3d127dcb..fb38d8100 100644 --- a/like.py +++ b/like.py @@ -6,6 +6,7 @@ __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +from utils import removeIdEnding from utils import urlPermitted from utils import getNicknameFromActor from utils import getDomainFromActor @@ -411,7 +412,7 @@ def outboxLike(recentPostsCache: {}, if debug: print('DEBUG: c2s like request arrived in outbox') - messageId = messageJson['object'].replace('/activity', '') + messageId = removeIdEnding(messageJson['object']) if ':' in domain: domain = domain.split(':')[0] postFilename = locatePost(baseDir, nickname, domain, messageId) @@ -462,7 +463,7 @@ def outboxUndoLike(recentPostsCache: {}, if debug: print('DEBUG: c2s undo like request arrived in outbox') - messageId = messageJson['object']['object'].replace('/activity', '') + messageId = removeIdEnding(messageJson['object']['object']) if ':' in domain: domain = domain.split(':')[0] postFilename = locatePost(baseDir, nickname, domain, messageId) diff --git a/outbox.py b/outbox.py index bbf863f22..e532393f7 100644 --- a/outbox.py +++ b/outbox.py @@ -13,6 +13,7 @@ from posts import outboxMessageCreateWrap from posts import savePostToBox from posts import sendToFollowersThread from posts import sendToNamedAddresses +from utils import removeIdEnding from utils import getDomainFromActor from blocking import isBlockedDomain from blocking import outboxBlock @@ -152,15 +153,14 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, permittedOutboxTypes = ('Create', 'Announce', 'Like', 'Follow', 'Undo', 'Update', 'Add', 'Remove', 'Block', 'Delete', - 'Delegate', 'Skill', 'Bookmark') + 'Delegate', 'Skill', 'Bookmark', 'Event') if messageJson['type'] not in permittedOutboxTypes: if debug: print('DEBUG: POST to outbox - ' + messageJson['type'] + ' is not a permitted activity type') return False if messageJson.get('id'): - postId = \ - messageJson['id'].replace('/activity', '').replace('/undo', '') + postId = removeIdEnding(messageJson['id']) if debug: print('DEBUG: id attribute exists within POST to outbox') else: @@ -172,13 +172,15 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, if messageJson['type'] != 'Upgrade': outboxName = 'outbox' - # if this is a blog post then save to its own box + # if this is a blog post or an event then save to its own box if messageJson['type'] == 'Create': if messageJson.get('object'): if isinstance(messageJson['object'], dict): if messageJson['object'].get('type'): if messageJson['object']['type'] == 'Article': outboxName = 'tlblogs' + elif messageJson['object']['type'] == 'Event': + outboxName = 'tlevents' savedFilename = \ savePostToBox(baseDir, @@ -186,20 +188,25 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, postId, postToNickname, domainFull, messageJson, outboxName) + if not savedFilename: + print('WARN: post not saved to outbox ' + outboxName) + return False if messageJson['type'] == 'Create' or \ messageJson['type'] == 'Question' or \ messageJson['type'] == 'Note' or \ messageJson['type'] == 'EncryptedMessage' or \ messageJson['type'] == 'Article' or \ + messageJson['type'] == 'Event' or \ messageJson['type'] == 'Patch' or \ messageJson['type'] == 'Announce': indexes = [outboxName, "inbox"] + selfActor = \ + httpPrefix + '://' + domainFull + '/users/' + postToNickname for boxNameIndex in indexes: + if not boxNameIndex: + continue if boxNameIndex == 'inbox' and outboxName == 'tlblogs': continue - selfActor = \ - httpPrefix + '://' + domainFull + \ - '/users/' + postToNickname # avoid duplicates of the message if already going # back to the inbox of the same account if selfActor not in messageJson['to']: diff --git a/person.py b/person.py index 2519a426e..0afdc7b14 100644 --- a/person.py +++ b/person.py @@ -25,6 +25,7 @@ from posts import createRepliesTimeline from posts import createMediaTimeline from posts import createBlogsTimeline from posts import createBookmarksTimeline +from posts import createEventsTimeline from posts import createInbox from posts import createOutbox from posts import createModeration @@ -459,6 +460,12 @@ def createPerson(baseDir: str, nickname: str, domain: str, port: int, with open(followDMsFilename, "w") as fFile: fFile.write('\n') + # notify when posts are liked + notifyLikesFilename = baseDir + '/accounts/' + \ + nickname + '@' + domain + '/.notifyLikes' + with open(notifyLikesFilename, "w") as fFile: + fFile.write('\n') + if not os.path.isdir(baseDir + '/accounts'): os.mkdir(baseDir + '/accounts') if not os.path.isdir(baseDir + '/accounts/' + nickname + '@' + domain): @@ -598,7 +605,8 @@ def personBoxJson(recentPostsCache: {}, boxname != 'tlreplies' and boxname != 'tlmedia' and \ boxname != 'tlblogs' and \ boxname != 'outbox' and boxname != 'moderation' and \ - boxname != 'tlbookmarks' and boxname != 'bookmarks': + boxname != 'tlbookmarks' and boxname != 'bookmarks' and \ + boxname != 'tlevents': return None if not '/' + boxname in path: @@ -638,7 +646,8 @@ def personBoxJson(recentPostsCache: {}, httpPrefix, noOfItems, headerOnly, ocapAlways, pageNumber) elif boxname == 'dm': - return createDMTimeline(session, baseDir, nickname, domain, port, + return createDMTimeline(recentPostsCache, + session, baseDir, nickname, domain, port, httpPrefix, noOfItems, headerOnly, ocapAlways, pageNumber) elif boxname == 'tlbookmarks' or boxname == 'bookmarks': @@ -646,8 +655,15 @@ def personBoxJson(recentPostsCache: {}, port, httpPrefix, noOfItems, headerOnly, ocapAlways, pageNumber) + elif boxname == 'tlevents': + return createEventsTimeline(recentPostsCache, + session, baseDir, nickname, domain, + port, httpPrefix, + noOfItems, headerOnly, ocapAlways, + pageNumber) elif boxname == 'tlreplies': - return createRepliesTimeline(session, baseDir, nickname, domain, + return createRepliesTimeline(recentPostsCache, + session, baseDir, nickname, domain, port, httpPrefix, noOfItems, headerOnly, ocapAlways, pageNumber) diff --git a/posts.py b/posts.py index 05b081e7f..1517fea4d 100644 --- a/posts.py +++ b/posts.py @@ -13,6 +13,7 @@ import os import shutil import sys import time +import uuid from socket import error as SocketError from time import gmtime, strftime from collections import OrderedDict @@ -28,6 +29,7 @@ from session import postJsonString from session import postImage from webfinger import webfingerHandle from httpsig import createSignedHeader +from utils import removeIdEnding from utils import siteIsActive from utils import removePostFromCache from utils import getCachedPostFilename @@ -45,6 +47,7 @@ from capabilities import getOcapFilename from capabilities import capabilitiesUpdate from media import attachMedia from media import replaceYouTube +from content import removeHtml from content import removeLongWords from content import addHtmlTags from content import replaceEmojiFromTags @@ -501,7 +504,8 @@ def deleteAllPosts(baseDir: str, nickname: str, domain: str, boxname: str) -> None: """Deletes all posts for a person from inbox or outbox """ - if boxname != 'inbox' and boxname != 'outbox' and boxname != 'tlblogs': + if boxname != 'inbox' and boxname != 'outbox' and \ + boxname != 'tlblogs' and boxname != 'tlevents': return boxDir = createPersonDir(nickname, domain, baseDir, boxname) for deleteFilename in os.scandir(boxDir): @@ -523,7 +527,8 @@ def savePostToBox(baseDir: str, httpPrefix: str, postId: str, Returns the filename """ if boxname != 'inbox' and boxname != 'outbox' and \ - boxname != 'tlblogs' and boxname != 'scheduled': + boxname != 'tlblogs' and boxname != 'tlevents' and \ + boxname != 'scheduled': return None originalDomain = domain if ':' in domain: @@ -606,15 +611,78 @@ def addSchedulePost(baseDir: str, nickname: str, domain: str, scheduleFile.close() +def appendEventFields(newPost: {}, + eventUUID: str, eventStatus: str, + anonymousParticipationEnabled: bool, + repliesModerationOption: str, + category: str, + joinMode: str, + eventDateStr: str, + endDateStr: str, + location: str, + maximumAttendeeCapacity: int, + ticketUrl: str, + subject: str) -> None: + """Appends Mobilizon-type event fields to a post + """ + if not eventUUID: + return + + # add attributes for Mobilizon-type events + newPost['uuid'] = eventUUID + if eventStatus: + newPost['ical:status'] = eventStatus + if anonymousParticipationEnabled: + newPost['anonymousParticipationEnabled'] = \ + anonymousParticipationEnabled + if repliesModerationOption: + newPost['repliesModerationOption'] = repliesModerationOption + if category: + newPost['category'] = category + if joinMode: + newPost['joinMode'] = joinMode + newPost['startTime'] = eventDateStr + newPost['endTime'] = endDateStr + if location: + newPost['location'] = location + if maximumAttendeeCapacity: + newPost['maximumAttendeeCapacity'] = maximumAttendeeCapacity + if ticketUrl: + newPost['ticketUrl'] = ticketUrl + if subject: + newPost['name'] = subject + newPost['summary'] = None + newPost['sensitive'] = False + + +def validContentWarning(cw: str) -> str: + """Returns a validated content warning + """ + cw = removeHtml(cw) + # hashtags within content warnings apparently cause a lot of trouble + # so remove them + if '#' in cw: + cw = cw.replace('#', '').replace(' ', ' ') + return cw + + def createPostBase(baseDir: str, nickname: str, domain: str, port: int, toUrl: str, ccUrl: str, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, clientToServer: bool, + commentsEnabled: bool, attachImageFilename: str, mediaType: str, imageDescription: str, useBlurhash: bool, isModerationReport: bool, - isArticle: bool, inReplyTo=None, + isArticle: bool, + inReplyTo=None, inReplyToAtomUri=None, subject=None, schedulePost=False, - eventDate=None, eventTime=None, location=None) -> {}: + eventDate=None, eventTime=None, location=None, + eventUUID=None, category=None, joinMode=None, + endDate=None, endTime=None, + maximumAttendeeCapacity=None, + repliesModerationOption=None, + anonymousParticipationEnabled=None, + eventStatus=None, ticketUrl=None) -> {}: """Creates a message """ mentionedRecipients = \ @@ -657,7 +725,7 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, sensitive = False summary = None if subject: - summary = subject + summary = validContentWarning(subject) sensitive = True toRecipients = [] @@ -703,6 +771,24 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, sensitive = True if replyToJson['object'].get('summary'): summary = replyToJson['object']['summary'] + + # get the ending date and time + endDateStr = None + if endDate: + eventName = summary + if not eventName: + eventName = content + endDateStr = endDate + if endTime: + if endTime.endswith('Z'): + endDateStr = endDate + 'T' + endTime + else: + endDateStr = endDate + 'T' + endTime + \ + ':00' + strftime("%z", gmtime()) + else: + endDateStr = endDate + 'T12:00:00Z' + + # get the starting date and time eventDateStr = None if eventDate: eventName = summary @@ -717,15 +803,17 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, ':00' + strftime("%z", gmtime()) else: eventDateStr = eventDate + 'T12:00:00Z' - if not schedulePost: + if not endDateStr: + endDateStr = eventDateStr + if not schedulePost and not eventUUID: tags.append({ "@context": "https://www.w3.org/ns/activitystreams", "type": "Event", "name": eventName, "startTime": eventDateStr, - "endTime": eventDateStr + "endTime": endDateStr }) - if location: + if location and not eventUUID: tags.append({ "@context": "https://www.w3.org/ns/activitystreams", "type": "Place", @@ -755,6 +843,11 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, for ccRemoval in removeFromCC: toCC.remove(ccRemoval) + # the type of post to be made + postObjectType = 'Note' + if eventUUID: + postObjectType = 'Event' + if not clientToServer: actorUrl = httpPrefix + '://' + domain + '/users/' + nickname @@ -774,7 +867,7 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, '/statuses/' + statusNumber + '/replies' newPost = { '@context': postContext, - 'id': newPostId+'/activity', + 'id': newPostId + '/activity', 'capability': capabilityIdList, 'type': 'Create', 'actor': actorUrl, @@ -783,7 +876,7 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, 'cc': toCC, 'object': { 'id': newPostId, - 'type': 'Note', + 'type': postObjectType, 'summary': summary, 'inReplyTo': inReplyTo, 'published': published, @@ -794,6 +887,7 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, 'sensitive': sensitive, 'atomUri': newPostId, 'inReplyToAtomUri': inReplyToAtomUri, + 'commentsEnabled': commentsEnabled, 'mediaType': 'text/html', 'content': content, 'contentMap': { @@ -817,6 +911,13 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, attachMedia(baseDir, httpPrefix, domain, port, newPost['object'], attachImageFilename, mediaType, imageDescription, useBlurhash) + appendEventFields(newPost['object'], eventUUID, eventStatus, + anonymousParticipationEnabled, + repliesModerationOption, + category, joinMode, + eventDateStr, endDateStr, + location, maximumAttendeeCapacity, + ticketUrl, subject) else: idStr = \ httpPrefix + '://' + domain + '/users/' + nickname + \ @@ -824,7 +925,7 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, newPost = { "@context": postContext, 'id': newPostId, - 'type': 'Note', + 'type': postObjectType, 'summary': summary, 'inReplyTo': inReplyTo, 'published': published, @@ -835,6 +936,7 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, 'sensitive': sensitive, 'atomUri': newPostId, 'inReplyToAtomUri': inReplyToAtomUri, + 'commentsEnabled': commentsEnabled, 'mediaType': 'text/html', 'content': content, 'contentMap': { @@ -857,6 +959,13 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, attachMedia(baseDir, httpPrefix, domain, port, newPost, attachImageFilename, mediaType, imageDescription, useBlurhash) + appendEventFields(newPost, eventUUID, eventStatus, + anonymousParticipationEnabled, + repliesModerationOption, + category, joinMode, + eventDateStr, endDateStr, + location, maximumAttendeeCapacity, + ticketUrl, subject) if ccUrl: if len(ccUrl) > 0: newPost['cc'] = [ccUrl] @@ -892,12 +1001,15 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int, 'date and time values') return newPost elif saveToFile: - if not isArticle: - savePostToBox(baseDir, httpPrefix, newPostId, - nickname, domain, newPost, 'outbox') - else: + if isArticle: savePostToBox(baseDir, httpPrefix, newPostId, nickname, domain, newPost, 'tlblogs') + elif eventUUID: + savePostToBox(baseDir, httpPrefix, newPostId, + nickname, domain, newPost, 'tlevents') + else: + savePostToBox(baseDir, httpPrefix, newPostId, + nickname, domain, newPost, 'outbox') return newPost @@ -924,10 +1036,10 @@ def outboxMessageCreateWrap(httpPrefix: str, capabilityUrl = [] newPost = { "@context": "https://www.w3.org/ns/activitystreams", - 'id': newPostId+'/activity', + 'id': newPostId + '/activity', 'capability': capabilityUrl, 'type': 'Create', - 'actor': httpPrefix+'://'+domain+'/users/'+nickname, + 'actor': httpPrefix + '://' + domain + '/users/' + nickname, 'published': published, 'to': messageJson['to'], 'cc': cc, @@ -1006,7 +1118,7 @@ def postIsAddressedToPublic(baseDir: str, postJsonObject: {}) -> bool: def createPublicPost(baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, - clientToServer: bool, + clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, imageDescription: str, useBlurhash: bool, inReplyTo=None, inReplyToAtomUri=None, subject=None, @@ -1024,11 +1136,13 @@ def createPublicPost(baseDir: str, httpPrefix + '://' + domainFull + '/users/' + nickname + '/followers', httpPrefix, content, followersOnly, saveToFile, - clientToServer, + clientToServer, commentsEnabled, attachImageFilename, mediaType, imageDescription, useBlurhash, False, False, inReplyTo, inReplyToAtomUri, subject, - schedulePost, eventDate, eventTime, location) + schedulePost, eventDate, eventTime, location, + None, None, None, None, None, + None, None, None, None, None) def createBlogPost(baseDir: str, @@ -1058,7 +1172,7 @@ def createQuestionPost(baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, content: str, qOptions: [], followersOnly: bool, saveToFile: bool, - clientToServer: bool, + clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, imageDescription: str, useBlurhash: bool, subject: str, durationDays: int) -> {}: @@ -1075,11 +1189,13 @@ def createQuestionPost(baseDir: str, httpPrefix + '://' + domainFull + '/users/' + nickname + '/followers', httpPrefix, content, followersOnly, saveToFile, - clientToServer, + clientToServer, commentsEnabled, attachImageFilename, mediaType, imageDescription, useBlurhash, False, False, None, None, subject, - False, None, None, None) + False, None, None, None, None, None, + None, None, None, + None, None, None, None, None) messageJson['object']['type'] = 'Question' messageJson['object']['oneOf'] = [] messageJson['object']['votersCount'] = 0 @@ -1104,7 +1220,7 @@ def createQuestionPost(baseDir: str, def createUnlistedPost(baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, - clientToServer: bool, + clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, imageDescription: str, useBlurhash: bool, inReplyTo=None, inReplyToAtomUri=None, subject=None, @@ -1122,11 +1238,13 @@ def createUnlistedPost(baseDir: str, nickname + '/followers', 'https://www.w3.org/ns/activitystreams#Public', httpPrefix, content, followersOnly, saveToFile, - clientToServer, + clientToServer, commentsEnabled, attachImageFilename, mediaType, imageDescription, useBlurhash, False, False, inReplyTo, inReplyToAtomUri, subject, - schedulePost, eventDate, eventTime, location) + schedulePost, eventDate, eventTime, location, + None, None, None, None, None, + None, None, None, None, None) def createFollowersOnlyPost(baseDir: str, @@ -1134,7 +1252,7 @@ def createFollowersOnlyPost(baseDir: str, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, - clientToServer: bool, + clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, imageDescription: str, useBlurhash: bool, inReplyTo=None, inReplyToAtomUri=None, @@ -1153,11 +1271,67 @@ def createFollowersOnlyPost(baseDir: str, nickname + '/followers', None, httpPrefix, content, followersOnly, saveToFile, - clientToServer, + clientToServer, commentsEnabled, attachImageFilename, mediaType, imageDescription, useBlurhash, False, False, inReplyTo, inReplyToAtomUri, subject, - schedulePost, eventDate, eventTime, location) + schedulePost, eventDate, eventTime, location, + None, None, None, None, None, + None, None, None, None, None) + + +def createEventPost(baseDir: str, + nickname: str, domain: str, port: int, + httpPrefix: str, + content: str, followersOnly: bool, + saveToFile: bool, + clientToServer: bool, commentsEnabled: bool, + attachImageFilename: str, mediaType: str, + imageDescription: str, useBlurhash: bool, + subject=None, schedulePost=False, + eventDate=None, eventTime=None, + location=None, category=None, joinMode=None, + endDate=None, endTime=None, + maximumAttendeeCapacity=None, + repliesModerationOption=None, + anonymousParticipationEnabled=None, + eventStatus=None, ticketUrl=None) -> {}: + """Mobilizon-type Event post + """ + if not attachImageFilename: + print('Event has no attached image') + return None + if not category: + print('Event has no category') + return None + domainFull = domain + if port: + if port != 80 and port != 443: + if ':' not in domain: + domainFull = domain + ':' + str(port) + + # create event uuid + eventUUID = str(uuid.uuid1()) + + toStr1 = 'https://www.w3.org/ns/activitystreams#Public' + toStr2 = httpPrefix + '://' + domainFull + '/users/' + \ + nickname + '/followers', + if followersOnly: + toStr1 = toStr2 + toStr2 = None + return createPostBase(baseDir, nickname, domain, port, + toStr1, toStr2, + httpPrefix, content, followersOnly, saveToFile, + clientToServer, commentsEnabled, + attachImageFilename, mediaType, + imageDescription, useBlurhash, + False, False, None, None, subject, + schedulePost, eventDate, eventTime, location, + eventUUID, category, joinMode, + endDate, endTime, maximumAttendeeCapacity, + repliesModerationOption, + anonymousParticipationEnabled, + eventStatus, ticketUrl) def getMentionedPeople(baseDir: str, httpPrefix: str, @@ -1200,6 +1374,7 @@ def createDirectMessagePost(baseDir: str, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, clientToServer: bool, + commentsEnabled: bool, attachImageFilename: str, mediaType: str, imageDescription: str, useBlurhash: bool, inReplyTo=None, inReplyToAtomUri=None, @@ -1222,11 +1397,13 @@ def createDirectMessagePost(baseDir: str, createPostBase(baseDir, nickname, domain, port, postTo, postCc, httpPrefix, content, followersOnly, saveToFile, - clientToServer, + clientToServer, commentsEnabled, attachImageFilename, mediaType, imageDescription, useBlurhash, False, False, inReplyTo, inReplyToAtomUri, subject, - schedulePost, eventDate, eventTime, location) + schedulePost, eventDate, eventTime, location, + None, None, None, None, None, + None, None, None, None, None) # mentioned recipients go into To rather than Cc messageJson['to'] = messageJson['object']['cc'] messageJson['object']['to'] = messageJson['to'] @@ -1241,7 +1418,7 @@ def createDirectMessagePost(baseDir: str, def createReportPost(baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, - clientToServer: bool, + clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, imageDescription: str, useBlurhash: bool, debug: bool, subject=None) -> {}: @@ -1314,17 +1491,20 @@ def createReportPost(baseDir: str, createPostBase(baseDir, nickname, domain, port, toUrl, postCc, httpPrefix, content, followersOnly, saveToFile, - clientToServer, + clientToServer, commentsEnabled, attachImageFilename, mediaType, imageDescription, useBlurhash, True, False, None, None, subject, - False, None, None, None) + False, None, None, None, None, None, + None, None, None, + None, None, None, None, None) if not postJsonObject: continue # update the inbox index with the report filename - # indexFilename=baseDir+'/accounts/'+handle+'/inbox.index' - # indexEntry=postJsonObject['id'].replace('/activity','').replace('/','#')+'.json' + # indexFilename = baseDir+'/accounts/'+handle+'/inbox.index' + # indexEntry = \ + # removeIdEnding(postJsonObject['id']).replace('/','#') + '.json' # if indexEntry not in open(indexFilename).read(): # try: # with open(indexFilename, 'a+') as fp: @@ -1402,6 +1582,7 @@ def sendPost(projectVersion: str, toNickname: str, toDomain: str, toPort: int, cc: str, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, clientToServer: bool, + commentsEnabled: bool, attachImageFilename: str, mediaType: str, imageDescription: str, useBlurhash: bool, federationList: [], sendThreads: [], postLog: [], @@ -1470,11 +1651,14 @@ def sendPost(projectVersion: str, createPostBase(baseDir, nickname, domain, port, toPersonId, cc, httpPrefix, content, followersOnly, saveToFile, clientToServer, + commentsEnabled, attachImageFilename, mediaType, imageDescription, useBlurhash, False, isArticle, inReplyTo, inReplyToAtomUri, subject, - False, None, None, None) + False, None, None, None, None, None, + None, None, None, + None, None, None, None, None) # get the senders private key privateKeyPem = getPersonKey(nickname, domain, baseDir, 'private') @@ -1528,6 +1712,7 @@ def sendPostViaServer(projectVersion: str, fromDomain: str, fromPort: int, toNickname: str, toDomain: str, toPort: int, cc: str, httpPrefix: str, content: str, followersOnly: bool, + commentsEnabled: bool, attachImageFilename: str, mediaType: str, imageDescription: str, useBlurhash: bool, cachedWebfingers: {}, personCache: {}, @@ -1614,11 +1799,14 @@ def sendPostViaServer(projectVersion: str, fromNickname, fromDomain, fromPort, toPersonId, cc, httpPrefix, content, followersOnly, saveToFile, clientToServer, + commentsEnabled, attachImageFilename, mediaType, imageDescription, useBlurhash, False, isArticle, inReplyTo, inReplyToAtomUri, subject, - False, None, None, None) + False, None, None, None, None, None, + None, None, None, + None, None, None, None, None) authHeader = createBasicAuthHeader(fromNickname, password) @@ -2261,20 +2449,34 @@ def createBookmarksTimeline(session, baseDir: str, nickname: str, domain: str, True, ocapAlways, pageNumber) -def createDMTimeline(session, baseDir: str, nickname: str, domain: str, +def createEventsTimeline(recentPostsCache: {}, + session, baseDir: str, nickname: str, domain: str, + port: int, httpPrefix: str, itemsPerPage: int, + headerOnly: bool, ocapAlways: bool, + pageNumber=None) -> {}: + return createBoxIndexed(recentPostsCache, session, baseDir, 'tlevents', + nickname, domain, + port, httpPrefix, itemsPerPage, headerOnly, + True, ocapAlways, pageNumber) + + +def createDMTimeline(recentPostsCache: {}, + session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, headerOnly: bool, ocapAlways: bool, pageNumber=None) -> {}: - return createBoxIndexed({}, session, baseDir, 'dm', nickname, + return createBoxIndexed(recentPostsCache, + session, baseDir, 'dm', nickname, domain, port, httpPrefix, itemsPerPage, headerOnly, True, ocapAlways, pageNumber) -def createRepliesTimeline(session, baseDir: str, nickname: str, domain: str, +def createRepliesTimeline(recentPostsCache: {}, + session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, headerOnly: bool, ocapAlways: bool, pageNumber=None) -> {}: - return createBoxIndexed({}, session, baseDir, 'tlreplies', + return createBoxIndexed(recentPostsCache, session, baseDir, 'tlreplies', nickname, domain, port, httpPrefix, itemsPerPage, headerOnly, True, ocapAlways, pageNumber) @@ -2441,6 +2643,7 @@ def isImageMedia(session, baseDir: str, httpPrefix: str, if postJsonObject['object'].get('moderationStatus'): return False if postJsonObject['object']['type'] != 'Note' and \ + postJsonObject['object']['type'] != 'Event' and \ postJsonObject['object']['type'] != 'Article': return False if not postJsonObject['object'].get('attachment'): @@ -2581,6 +2784,7 @@ def addPostStringToTimeline(postStr: str, boxname: str, # must be a recognized ActivityPub type if ('"Note"' in postStr or '"EncryptedMessage"' in postStr or + '"Event"' in postStr or '"Article"' in postStr or '"Patch"' in postStr or '"Announce"' in postStr or @@ -2632,10 +2836,12 @@ def createBoxIndexed(recentPostsCache: {}, boxname != 'tlreplies' and boxname != 'tlmedia' and \ boxname != 'tlblogs' and \ boxname != 'outbox' and boxname != 'tlbookmarks' and \ - boxname != 'bookmarks': + boxname != 'bookmarks' and \ + boxname != 'tlevents': return None - # bookmarks timeline is like the inbox but has its own separate index + # bookmarks and events timelines are like the inbox + # but have their own separate index indexBoxName = boxname if boxname == "tlbookmarks": boxname = "bookmarks" @@ -3303,6 +3509,17 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, return None +def isMuted(baseDir: str, nickname: str, domain: str, postId: str) -> bool: + """Returns true if the given post is muted + """ + postFilename = locatePost(baseDir, nickname, domain, postId) + if not postFilename: + return False + if os.path.isfile(postFilename + '.muted'): + return True + return False + + def mutePost(baseDir: str, nickname: str, domain: str, postId: str, recentPostsCache: {}) -> None: """ Mutes the given post @@ -3330,7 +3547,7 @@ def mutePost(baseDir: str, nickname: str, domain: str, postId: str, # if the post is in the recent posts cache then mark it as muted if recentPostsCache.get('index'): postId = \ - postJsonObject['id'].replace('/activity', '').replace('/', '#') + removeIdEnding(postJsonObject['id']).replace('/', '#') if postId in recentPostsCache['index']: print('MUTE: ' + postId + ' is in recent posts cache') if recentPostsCache['json'].get(postId): diff --git a/scripts/keyfailures b/scripts/keyfailures new file mode 100755 index 000000000..54eee13ee --- /dev/null +++ b/scripts/keyfailures @@ -0,0 +1,7 @@ +#!/bin/bash +journalctl -u epicyon | grep 'could not be' > .keyfailures.txt +if [ ! -f .keyfailures.txt ]; then + echo 'No key failures' +else + cat .keyfailures.txt +fi diff --git a/tests.py b/tests.py index f7a27aaa3..59b97c462 100644 --- a/tests.py +++ b/tests.py @@ -20,6 +20,7 @@ from cache import getPersonFromCache from threads import threadWithTrace from daemon import runDaemon from session import createSession +from posts import validContentWarning from posts import deleteAllPosts from posts import createPublicPost from posts import sendPost @@ -31,6 +32,7 @@ from follow import clearFollows from follow import clearFollowers from follow import sendFollowRequestViaServer from follow import sendUnfollowRequestViaServer +from utils import removeIdEnding from utils import siteIsActive from utils import updateRecentPostsCache from utils import followPerson @@ -62,6 +64,7 @@ from announce import sendAnnounceViaServer from media import getMediaPath from media import getAttachmentMediaType from delete import sendDeleteViaServer +from inbox import jsonPostAllowsComments from inbox import validInbox from inbox import validInboxFilenames from content import htmlReplaceQuoteMarks @@ -270,14 +273,16 @@ def createServerAlice(path: str, domain: str, port: int, clientToServer = False createPublicPost(path, nickname, domain, port, httpPrefix, "No wise fish would go anywhere without a porpoise", - False, True, clientToServer, None, None, useBlurhash) + False, True, clientToServer, True, + None, None, useBlurhash) createPublicPost(path, nickname, domain, port, httpPrefix, "Curiouser and curiouser!", False, True, - clientToServer, None, None, useBlurhash) + clientToServer, True, None, None, useBlurhash) createPublicPost(path, nickname, domain, port, httpPrefix, "In the gardens of memory, in the palace " + "of dreams, that is where you and I shall meet", - False, True, clientToServer, None, None, useBlurhash) + False, True, clientToServer, True, + None, None, useBlurhash) global testServerAliceRunning testServerAliceRunning = True maxMentions = 10 @@ -335,14 +340,17 @@ def createServerBob(path: str, domain: str, port: int, if hasPosts: createPublicPost(path, nickname, domain, port, httpPrefix, "It's your life, live it your way.", - False, True, clientToServer, None, None, useBlurhash) + False, True, clientToServer, True, + None, None, useBlurhash) createPublicPost(path, nickname, domain, port, httpPrefix, "One of the things I've realised is that " + "I am very simple", - False, True, clientToServer, None, None, useBlurhash) + False, True, clientToServer, True, + None, None, useBlurhash) createPublicPost(path, nickname, domain, port, httpPrefix, "Quantum physics is a bit of a passion of mine", - False, True, clientToServer, None, None, useBlurhash) + False, True, clientToServer, True, + None, None, useBlurhash) global testServerBobRunning testServerBobRunning = True maxMentions = 10 @@ -503,7 +511,8 @@ def testPostMessageBetweenServers(): 'Why is a mouse when it spins? ' + 'यह एक परीक्षण है #sillyquestion', followersOnly, - saveToFile, clientToServer, attachedImageFilename, mediaType, + saveToFile, clientToServer, True, + attachedImageFilename, mediaType, attachedImageDescription, useBlurhash, federationList, aliceSendThreads, alicePostLog, aliceCachedWebfingers, alicePersonCache, isArticle, inReplyTo, @@ -788,7 +797,8 @@ def testFollowBetweenServers(): sessionAlice, aliceDir, 'alice', aliceDomain, alicePort, 'bob', bobDomain, bobPort, ccUrl, httpPrefix, 'Alice message', followersOnly, saveToFile, - clientToServer, None, None, None, useBlurhash, federationList, + clientToServer, True, + None, None, None, useBlurhash, federationList, aliceSendThreads, alicePostLog, aliceCachedWebfingers, alicePersonCache, isArticle, inReplyTo, inReplyToAtomUri, subject) @@ -1092,7 +1102,7 @@ def testCreatePerson(): archivePostsForPerson(nickname, domain, baseDir, 'outbox', None, {}, 4) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "G'day world!", False, True, clientToServer, - None, None, useBlurhash, None, None, + True, None, None, useBlurhash, None, None, 'Not suitable for Vogons') os.chdir(currDir) @@ -1315,7 +1325,7 @@ def testClientToServer(): aliceDomain, alicePort, 'bob', bobDomain, bobPort, None, httpPrefix, 'Sent from my ActivityPub client', - followersOnly, + followersOnly, True, attachedImageFilename, mediaType, attachedImageDescription, useBlurhash, cachedWebfingers, personCache, isArticle, @@ -1356,7 +1366,7 @@ def testClientToServer(): outboxPostFilename = outboxPath + '/' + name postJsonObject = loadJson(outboxPostFilename, 0) if postJsonObject: - outboxPostId = postJsonObject['id'].replace('/activity', '') + outboxPostId = removeIdEnding(postJsonObject['id']) assert outboxPostId print('message id obtained: ' + outboxPostId) assert validInbox(bobDir, 'bob', bobDomain) @@ -1974,8 +1984,106 @@ def runHtmlReplaceQuoteMarks(): assert result == '“hello” “test” html' +def testJsonPostAllowsComments(): + print('testJsonPostAllowsComments') + postJsonObject = { + "id": "123" + } + assert jsonPostAllowsComments(postJsonObject) + postJsonObject = { + "id": "123", + "commentsEnabled": False + } + assert not jsonPostAllowsComments(postJsonObject) + postJsonObject = { + "id": "123", + "commentsEnabled": True + } + assert jsonPostAllowsComments(postJsonObject) + postJsonObject = { + "id": "123", + "object": { + "commentsEnabled": True + } + } + assert jsonPostAllowsComments(postJsonObject) + postJsonObject = { + "id": "123", + "object": { + "commentsEnabled": False + } + } + assert not jsonPostAllowsComments(postJsonObject) + + +def testRemoveIdEnding(): + print('testRemoveIdEnding') + testStr = 'https://activitypub.somedomain.net' + resultStr = removeIdEnding(testStr) + assert resultStr == 'https://activitypub.somedomain.net' + + testStr = \ + 'https://activitypub.somedomain.net/users/foo/' + \ + 'statuses/34544814814/activity' + resultStr = removeIdEnding(testStr) + assert resultStr == \ + 'https://activitypub.somedomain.net/users/foo/statuses/34544814814' + + testStr = \ + 'https://undo.somedomain.net/users/foo/statuses/34544814814/undo' + resultStr = removeIdEnding(testStr) + assert resultStr == \ + 'https://undo.somedomain.net/users/foo/statuses/34544814814' + + testStr = \ + 'https://event.somedomain.net/users/foo/statuses/34544814814/event' + resultStr = removeIdEnding(testStr) + assert resultStr == \ + 'https://event.somedomain.net/users/foo/statuses/34544814814' + + +def testValidContentWarning(): + print('testValidContentWarning') + resultStr = validContentWarning('Valid content warning') + assert resultStr == 'Valid content warning' + + resultStr = validContentWarning('Invalid #content warning') + assert resultStr == 'Invalid content warning' + + resultStr = \ + validContentWarning('Invalid content warning') + assert resultStr == 'Invalid content warning' + + +def testTranslations(): + print('testTranslations') + languagesStr = ('ar', 'ca', 'cy', 'de', 'es', 'fr', 'ga', + 'hi', 'it', 'ja', 'oc', 'pt', 'ru', 'zh') + + # load all translations into a dict + langDict = {} + for lang in languagesStr: + langJson = loadJson('translations/' + lang + '.json') + assert langJson + langDict[lang] = langJson + + # load english translations + translationsJson = loadJson('translations/en.json') + # test each english string exists in the other language files + for englishStr, translatedStr in translationsJson.items(): + for lang in languagesStr: + langJson = langDict[lang] + if not langJson.get(englishStr): + print(englishStr + ' is missing from ' + lang + '.json') + assert langJson.get(englishStr) + + def runAllTests(): print('Running tests...') + testTranslations() + testValidContentWarning() + testRemoveIdEnding() + testJsonPostAllowsComments() runHtmlReplaceQuoteMarks() testDangerousMarkup() testRemoveHtml() diff --git a/translations/ar.json b/translations/ar.json index b13483b47..b6223d1d1 100644 --- a/translations/ar.json +++ b/translations/ar.json @@ -255,5 +255,32 @@ "Liked by": "نال إعجاب", "Solidaric": "تضامن", "YouTube Replacement Domain": "استبدال نطاق يوتيوب", - "Notes": "ملاحظات" + "Notes": "ملاحظات", + "Allow replies.": "السماح بالردود.", + "Event": "حدث", + "Event name": "اسم الحدث", + "Events": "الأحداث", + "Create an event": "أنشئ حدثًا", + "Describe the event": "صف الحدث", + "Start Date": "تاريخ البدء", + "End Date": "تاريخ الانتهاء", + "Categories": "التصنيفات", + "This is a private event.": "هذا هو الحدث الخاص.", + "Allow anonymous participation.": "السماح بالمشاركة المجهولة.", + "Anyone can join": "يمكن لأي شخص الانضمام", + "Apply to join": "تقديم طلب للانضمام", + "Invitation only": "المدعوون فقط", + "Joining": "انضمام", + "Status of the event": "حالة الحدث", + "Tentative": "مؤقت", + "Confirmed": "تم تأكيد", + "Cancelled": "ألغيت", + "Event banner image description": "وصف صورة شعار الحدث", + "Banner image": "صورة بانر", + "Maximum attendees": "الحد الأقصى للحضور", + "Ticket URL": "عنوان URL للتذكرة", + "Create a new event": "أنشئ حدثًا جديدًا", + "Moderation policy or code of conduct": "سياسة الوسطية أو قواعد السلوك", + "Edit event": "تحرير الحدث", + "Notify when posts are liked": "يخطر عندما يتم اعجاب المشاركات" } diff --git a/translations/ca.json b/translations/ca.json index 774fd067a..343576942 100644 --- a/translations/ca.json +++ b/translations/ca.json @@ -255,5 +255,32 @@ "Liked by": "M'agrada", "Solidaric": "Solidaritat", "YouTube Replacement Domain": "Domini de substitució de YouTube", - "Notes": "Notes" + "Notes": "Notes", + "Allow replies.": "Permetre respostes.", + "Event": "Esdeveniment", + "Event name": "Nom de l’esdeveniment", + "Events": "Esdeveniments", + "Create an event": "Crea un esdeveniment", + "Describe the event": "Descriviu l’esdeveniment", + "Start Date": "Data d'inici", + "End Date": "Data de finalització", + "Categories": "Categories", + "This is a private event.": "Aquest és un esdeveniment privat.", + "Allow anonymous participation.": "Permet una participació anònima.", + "Anyone can join": "Qualsevol persona s’hi pot apuntar", + "Apply to join": "Sol·liciteu participar", + "Invitation only": "Només invitació", + "Joining": "Unir-se", + "Status of the event": "Estat de l’esdeveniment", + "Tentative": "Temptatiu", + "Confirmed": "Confirmat", + "Cancelled": "Cancel·lat", + "Event banner image description": "Descripció de la imatge del banner de l’esdeveniment", + "Banner image": "Imatge de pancarta", + "Maximum attendees": "Màxim d’assistents", + "Ticket URL": "URL de l'entrada", + "Create a new event": "Creeu un esdeveniment nou", + "Moderation policy or code of conduct": "Política de moderació o codi de conducta", + "Edit event": "Edita l’esdeveniment", + "Notify when posts are liked": "Notifiqueu-ho quan us agradin les publicacions" } diff --git a/translations/cy.json b/translations/cy.json index 28f8cdc38..631892be2 100644 --- a/translations/cy.json +++ b/translations/cy.json @@ -255,5 +255,32 @@ "Liked by": "Hoffi", "Solidaric": "Undod", "YouTube Replacement Domain": "Parth Amnewid YouTube", - "Notes": "Nodiadau" + "Notes": "Nodiadau", + "Allow replies.": "Caniatáu atebion.", + "Event": "Digwyddiad", + "Event name": "Enw'r digwyddiad", + "Events": "Digwyddiadau", + "Create an event": "Creu digwyddiad", + "Describe the event": "Disgrifiwch y digwyddiad", + "Start Date": "Dyddiad cychwyn", + "End Date": "Dyddiad Gorffen", + "Categories": "Categorïau", + "This is a private event.": "Digwyddiad preifat yw hwn.", + "Allow anonymous participation.": "Caniatáu cyfranogiad dienw.", + "Anyone can join": "Gall unrhyw un ymuno", + "Apply to join": "Gwnewch gais i ymuno", + "Invitation only": "Gwahoddiad yn unig", + "Joining": "Yn ymuno", + "Status of the event": "Statws y digwyddiad", + "Tentative": "Cynhyrfus", + "Confirmed": "Cadarnhawyd", + "Cancelled": "Wedi'i ganslo", + "Event banner image description": "Disgrifiad delwedd baner y digwyddiad", + "Banner image": "Delwedd baner", + "Maximum attendees": "Uchafswm mynychwyr", + "Ticket URL": "URL y tocyn", + "Create a new event": "Creu digwyddiad newydd", + "Moderation policy or code of conduct": "Polisi cymedroli neu god ymddygiad", + "Edit event": "Golygu digwyddiad", + "Notify when posts are liked": "Hysbysu pryd mae swyddi'n cael eu hoffi" } diff --git a/translations/de.json b/translations/de.json index 86f695959..99794eb22 100644 --- a/translations/de.json +++ b/translations/de.json @@ -255,5 +255,32 @@ "Liked by": "Gefallen von", "Solidaric": "Solidarität", "YouTube Replacement Domain": "YouTube-Ersatzdomain", - "Notes": "Anmerkungen" + "Notes": "Anmerkungen", + "Allow replies.": "Antworten zulassen.", + "Event": "Veranstaltung", + "Event name": "Veranstaltungsname", + "Events": "Veranstaltungen", + "Create an event": "Erstellen Sie ein Ereignis", + "Describe the event": "Beschreiben Sie das Ereignis", + "Start Date": "Anfangsdatum", + "End Date": "Endtermin", + "Categories": "Kategorien", + "This is a private event.": "Dies ist eine private Veranstaltung.", + "Allow anonymous participation.": "Anonyme Teilnahme zulassen.", + "Anyone can join": "Jeder kann mitmachen", + "Apply to join": "Sich anmelden um teilzunehmen", + "Invitation only": "Nur Einladungen", + "Joining": "Beitritt", + "Status of the event": "Status des Ereignisses", + "Tentative": "Vorsichtig", + "Confirmed": "Bestätigt", + "Cancelled": "Abgesagt", + "Event banner image description": "Beschreibung des Ereignisbannerbildes", + "Banner image": "Bannerbild", + "Maximum attendees": "Maximale Teilnehmerzahl", + "Ticket URL": "Ticket URL", + "Create a new event": "Erstellen Sie ein neues Ereignis", + "Moderation policy or code of conduct": "Moderationsrichtlinie oder Verhaltenskodex", + "Edit event": "Ereignis bearbeiten", + "Notify when posts are liked": "Benachrichtigen, wenn Beiträge gefallen" } diff --git a/translations/en.json b/translations/en.json index 4a559bb42..2476be31e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -255,5 +255,32 @@ "Liked by": "Liked by", "Solidaric": "Solidaric", "YouTube Replacement Domain": "YouTube Replacement Domain", - "Notes": "Notes" + "Notes": "Notes", + "Allow replies.": "Allow replies.", + "Event": "Event", + "Event name": "Event name", + "Events": "Events", + "Create an event": "Create an event", + "Describe the event": "Describe the event", + "Start Date": "Start Date", + "End Date": "End Date", + "Categories": "Categories", + "This is a private event.": "This is a private event.", + "Allow anonymous participation.": "Allow anonymous participation.", + "Anyone can join": "Anyone can join", + "Apply to join": "Apply to join", + "Invitation only": "Invitation only", + "Joining": "Joining", + "Status of the event": "Status of the event", + "Tentative": "Tentative", + "Confirmed": "Confirmed", + "Cancelled": "Cancelled", + "Event banner image description": "Event banner image description", + "Banner image": "Banner image", + "Maximum attendees": "Maximum attendees", + "Ticket URL": "Ticket URL", + "Create a new event": "Create a new event", + "Moderation policy or code of conduct": "Moderation policy or code of conduct", + "Edit event": "Edit event", + "Notify when posts are liked": "Notify when posts are liked" } diff --git a/translations/es.json b/translations/es.json index e19e01a6b..a6969342a 100644 --- a/translations/es.json +++ b/translations/es.json @@ -255,5 +255,32 @@ "Liked by": "Apreciado por", "Solidaric": "Solidaridad", "YouTube Replacement Domain": "Dominio de reemplazo de YouTube", - "Notes": "Notas" + "Notes": "Notas", + "Allow replies.": "Permitir respuestas.", + "Event": "Evento", + "Event name": "Nombre del evento", + "Events": "Eventos", + "Create an event": "Crea un evento", + "Describe the event": "Describe el evento", + "Start Date": "Fecha de inicio", + "End Date": "Fecha final", + "Categories": "Categorías", + "This is a private event.": "Este es un evento privado.", + "Allow anonymous participation.": "Permitir la participación anónima.", + "Anyone can join": "Cualquiera puede unirse", + "Apply to join": "Aplica para unirte", + "Invitation only": "Sólo con Invitación", + "Joining": "Unión", + "Status of the event": "Estado del evento", + "Tentative": "Tentativa", + "Confirmed": "Confirmada", + "Cancelled": "Cancelada", + "Event banner image description": "Descripción de la imagen del banner del evento", + "Banner image": "Imagen de banner", + "Maximum attendees": "Asistentes máximos", + "Ticket URL": "URL del ticket", + "Create a new event": "Crea un nuevo evento", + "Moderation policy or code of conduct": "Política de moderación o código de conducta", + "Edit event": "Editar evento", + "Notify when posts are liked": "Notificar cuando les gusten las publicaciones" } diff --git a/translations/fr.json b/translations/fr.json index 17dafb27d..b113e888b 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -255,5 +255,32 @@ "Liked by": "Aimé par", "Solidaric": "Solidarité", "YouTube Replacement Domain": "Domaine de remplacement YouTube", - "Notes": "Remarques" + "Notes": "Remarques", + "Allow replies.": "Autoriser les réponses.", + "Event": "un événement", + "Event name": "Nom de l'événement", + "Events": "Événements", + "Create an event": "Créer un événement", + "Describe the event": "Décrivez l'événement", + "Start Date": "Date de début", + "End Date": "Date de fin", + "Categories": "Catégories", + "This is a private event.": "Ceci est un événement privé.", + "Allow anonymous participation.": "Autorisez la participation anonyme.", + "Anyone can join": "Tout le monde peut joindre", + "Apply to join": "Postuler pour rejoindre", + "Invitation only": "Invitation uniquement", + "Joining": "Joindre", + "Status of the event": "Statut de l'événement", + "Tentative": "Provisoire", + "Confirmed": "Confirmé", + "Cancelled": "Annulé", + "Event banner image description": "Description de l'image de la bannière de l'événement", + "Banner image": "Image de bannière", + "Maximum attendees": "Nombre maximum de participants", + "Ticket URL": "URL du ticket", + "Create a new event": "Créer un nouvel événement", + "Moderation policy or code of conduct": "Politique de modération ou code de conduite", + "Edit event": "Modifier l'événement", + "Notify when posts are liked": "Notifier lorsque les messages sont aimés" } diff --git a/translations/ga.json b/translations/ga.json index b012a8098..04ad6f2f3 100644 --- a/translations/ga.json +++ b/translations/ga.json @@ -255,5 +255,32 @@ "Liked by": "Thaitin", "Solidaric": "Dlúthpháirtíocht", "YouTube Replacement Domain": "Fearann Athsholáthair YouTube", - "Notes": "Nótaí" + "Notes": "Nótaí", + "Allow replies.": "Ceadaigh freagraí.", + "Event": "Imeacht", + "Event name": "Ainm na hócáide", + "Events": "Imeachtaí", + "Create an event": "Cruthaigh imeacht", + "Describe the event": "Déan cur síos ar an ócáid", + "Start Date": "Dáta tosaigh", + "End Date": "Dáta deiridh", + "Categories": "Catagóirí", + "This is a private event.": "Is ócáid phríobháideach é seo.", + "Allow anonymous participation.": "Lig rannpháirtíocht gan ainm.", + "Anyone can join": "Is féidir le duine ar bith a bheith páirteach", + "Apply to join": "Déan iarratas ar bhallraíocht", + "Invitation only": "Cuireadh amháin", + "Joining": "Ag teacht le chéile", + "Status of the event": "Stádas na hócáide", + "Tentative": "Sealadach", + "Confirmed": "Deimhnithe", + "Cancelled": "Cealaithe", + "Event banner image description": "Tuairisc íomhá meirge na hócáide", + "Banner image": "Íomhá meirge", + "Maximum attendees": "Uasmhéid freastail", + "Ticket URL": "URL na dticéad", + "Create a new event": "Cruthaigh imeacht nua", + "Moderation policy or code of conduct": "Beartas modhnóireachta nó cód iompair", + "Edit event": "Cuir imeacht in eagar", + "Notify when posts are liked": "Cuir in iúl cathain is maith poist" } diff --git a/translations/hi.json b/translations/hi.json index a05b9e387..a8f984adb 100644 --- a/translations/hi.json +++ b/translations/hi.json @@ -255,5 +255,32 @@ "Liked by": "द्वारा पसंद किया गया", "Solidaric": "एकजुटता", "YouTube Replacement Domain": "YouTube रिप्लेसमेंट डोमेन", - "Notes": "टिप्पणियाँ" + "Notes": "टिप्पणियाँ", + "Allow replies.": "जवाब दें।", + "Event": "प्रतिस्पर्धा", + "Event name": "कार्यक्रम नाम", + "Events": "आयोजन", + "Create an event": "एक घटना बनाएँ", + "Describe the event": "घटना का वर्णन करें", + "Start Date": "आरंभ करने की तिथि", + "End Date": "अंतिम तिथि", + "Categories": "श्रेणियाँ", + "This is a private event.": "यह एक निजी कार्यक्रम है।", + "Allow anonymous participation.": "अनाम भागीदारी की अनुमति दें।", + "Anyone can join": "कोई भी शामिल हो सकता है", + "Apply to join": "जुड़ने के लिए आवेदन करें", + "Invitation only": "केवल आमंत्रण", + "Joining": "में शामिल होने से", + "Status of the event": "घटना की स्थिति", + "Tentative": "जांच का", + "Confirmed": "की पुष्टि", + "Cancelled": "रद्द", + "Event banner image description": "घटना बैनर छवि विवरण", + "Banner image": "बैनर की छवि", + "Maximum attendees": "अधिकतम उपस्थित", + "Ticket URL": "टिकट URL", + "Create a new event": "एक नई घटना बनाएँ", + "Moderation policy or code of conduct": "मॉडरेशन पॉलिसी या आचार संहिता", + "Edit event": "घटना संपादित करें", + "Notify when posts are liked": "पोस्ट पसंद आने पर सूचित करें" } diff --git a/translations/it.json b/translations/it.json index 356112f64..35443ffa4 100644 --- a/translations/it.json +++ b/translations/it.json @@ -255,5 +255,32 @@ "Liked by": "Mi è piaciuto", "Solidaric": "Solidarietà", "YouTube Replacement Domain": "Dominio sostitutivo di YouTube", - "Notes": "Appunti" + "Notes": "Appunti", + "Allow replies.": "Consenti risposte.", + "Event": "Evento", + "Event name": "Nome dell'evento", + "Events": "Eventi", + "Create an event": "Crea un evento", + "Describe the event": "Descrivi l'evento", + "Start Date": "Data d'inizio", + "End Date": "Data di fine", + "Categories": "Categorie", + "This is a private event.": "Questo è un evento privato.", + "Allow anonymous participation.": "Consenti la partecipazione anonima.", + "Anyone can join": "Chiunque può partecipare", + "Apply to join": "Richiedi di partecipare", + "Invitation only": "Solo su invito", + "Joining": "Partecipare", + "Status of the event": "Stato dell'evento", + "Tentative": "Tentativa", + "Confirmed": "Confermata", + "Cancelled": "Annullata", + "Event banner image description": "Descrizione dell'immagine del banner dell'evento", + "Banner image": "Immagine banner", + "Maximum attendees": "Numero massimo di partecipanti", + "Ticket URL": "URL del biglietto", + "Create a new event": "Crea un nuovo evento", + "Moderation policy or code of conduct": "Politica di moderazione o codice di condotta", + "Edit event": "Modifica evento", + "Notify when posts are liked": "Avvisa quando i post sono piaciuti" } diff --git a/translations/ja.json b/translations/ja.json index 89351b413..a11bf5ea1 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -255,5 +255,32 @@ "Liked by": "好き", "Solidaric": "連帯", "YouTube Replacement Domain": "YouTube交換ドメイン", - "Notes": "ノート" + "Notes": "ノート", + "Allow replies.": "返信を許可します。", + "Event": "イベント", + "Event name": "イベント名", + "Events": "イベント", + "Create an event": "イベントを作成する", + "Describe the event": "イベントについて説明する", + "Start Date": "開始日", + "End Date": "終了日", + "Categories": "カテゴリー", + "This is a private event.": "これはプライベートイベントです。", + "Allow anonymous participation.": "匿名参加を許可します。", + "Anyone can join": "誰でも参加できます", + "Apply to join": "参加を申し込む", + "Invitation only": "招待のみ", + "Joining": "接合", + "Status of the event": "イベントのステータス", + "Tentative": "暫定の", + "Confirmed": "確認済み", + "Cancelled": "キャンセル", + "Event banner image description": "イベントバナー画像の説明", + "Banner image": "バナー画像", + "Maximum attendees": "最大参加者", + "Ticket URL": "チケットURL", + "Create a new event": "新しいイベントを作成する", + "Moderation policy or code of conduct": "モデレートポリシーまたは行動規範", + "Edit event": "イベントを編集", + "Notify when posts are liked": "投稿が高く評価されたときに通知する" } diff --git a/translations/oc.json b/translations/oc.json index 71eddf628..2a020527c 100644 --- a/translations/oc.json +++ b/translations/oc.json @@ -251,5 +251,32 @@ "Liked by": "Liked by", "Solidaric": "Solidaric", "YouTube Replacement Domain": "YouTube Replacement Domain", - "Notes": "Notes" + "Notes": "Notes", + "Allow replies.": "Allow replies.", + "Event": "Event", + "Event name": "Event name", + "Events": "Events", + "Create an event": "Create an event", + "Describe the event": "Describe the event", + "Start Date": "Start Date", + "End Date": "End Date", + "Categories": "Categories", + "This is a private event.": "This is a private event.", + "Allow anonymous participation.": "Allow anonymous participation.", + "Anyone can join": "Anyone can join", + "Apply to join": "Apply to join", + "Invitation only": "Invitation only", + "Joining": "Joining", + "Status of the event": "Status of the event", + "Tentative": "Tentative", + "Confirmed": "Confirmed", + "Cancelled": "Cancelled", + "Event banner image description": "Event banner image description", + "Banner image": "Banner image", + "Maximum attendees": "Maximum attendees", + "Ticket URL": "Ticket URL", + "Create a new event": "Create a new event", + "Moderation policy or code of conduct": "Moderation policy or code of conduct", + "Edit event": "Edit event", + "Notify when posts are liked": "Notify when posts are liked" } diff --git a/translations/pt.json b/translations/pt.json index 79c2a7cee..27a0466bb 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -255,5 +255,32 @@ "Liked by": "Curtida por", "Solidaric": "Solidariedade", "YouTube Replacement Domain": "Domínio de substituição do YouTube", - "Notes": "Notas" + "Notes": "Notas", + "Allow replies.": "Permitir respostas.", + "Event": "Evento", + "Event name": "Nome do evento", + "Events": "Eventos", + "Create an event": "Crie um evento", + "Describe the event": "Descreva o evento", + "Start Date": "Data de início", + "End Date": "Data final", + "Categories": "Categorias", + "This is a private event.": "Este é um evento privado.", + "Allow anonymous participation.": "Permita a participação anônima.", + "Anyone can join": "Qualquer um pode participar", + "Apply to join": "Aplicar para participar", + "Invitation only": "Somente para convidados", + "Joining": "Juntando", + "Status of the event": "Status do evento", + "Tentative": "Provisório", + "Confirmed": "Confirmada", + "Cancelled": "Cancelada", + "Event banner image description": "Descrição da imagem do banner do evento", + "Banner image": "Imagem de banner", + "Maximum attendees": "Máximo de participantes", + "Ticket URL": "URL do bilhete", + "Create a new event": "Crie um novo evento", + "Moderation policy or code of conduct": "Política de moderação ou código de conduta", + "Edit event": "Editar evento", + "Notify when posts are liked": "Notificar quando as postagens forem curtidas" } diff --git a/translations/ru.json b/translations/ru.json index e9befe81c..2b5d61d44 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -255,5 +255,32 @@ "Liked by": "Понравилось", "Solidaric": "солидарность", "YouTube Replacement Domain": "Запасной домен YouTube", - "Notes": "Ноты" + "Notes": "Ноты", + "Allow replies.": "Разрешить ответы.", + "Event": "Мероприятие", + "Event name": "Название события", + "Events": "События", + "Create an event": "Создать мероприятие", + "Describe the event": "Опишите событие", + "Start Date": "Дата начала", + "End Date": "Дата окончания", + "Categories": "Категории", + "This is a private event.": "Это частное мероприятие.", + "Allow anonymous participation.": "Разрешить анонимное участие.", + "Anyone can join": "Каждый может присоединиться", + "Apply to join": "Подать заявку на присоединение", + "Invitation only": "Только приглашение", + "Joining": "Присоединение", + "Status of the event": "Статус мероприятия", + "Tentative": "Предварительно", + "Confirmed": "Подтверждено", + "Cancelled": "Отменено", + "Event banner image description": "Описание изображения баннера мероприятия", + "Banner image": "Изображение баннера", + "Maximum attendees": "Максимальное количество участников", + "Ticket URL": "URL билета", + "Create a new event": "Создать новое мероприятие", + "Moderation policy or code of conduct": "Политика модерации или кодекс поведения", + "Edit event": "Изменить мероприятие", + "Notify when posts are liked": "Уведомлять, когда публикации нравятся" } diff --git a/translations/zh.json b/translations/zh.json index 2afa7f0c0..3ce3f2d7d 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -255,5 +255,32 @@ "Liked by": "喜欢的人", "Solidaric": "团结互助", "YouTube Replacement Domain": "YouTube替换域", - "Notes": "笔记" + "Notes": "笔记", + "Allow replies.": "允许回复。", + "Event": "事件", + "Event name": "活动名称", + "Events": "大事记", + "Create an event": "建立活动", + "Describe the event": "描述事件", + "Start Date": "开始日期", + "End Date": "结束日期", + "Categories": "分类目录", + "This is a private event.": "这是私人活动。", + "Allow anonymous participation.": "允许匿名参与。", + "Anyone can join": "任何人都可以加入", + "Apply to join": "申请加入", + "Invitation only": "仅邀请", + "Joining": "加盟", + "Status of the event": "活动状态", + "Tentative": "暂定", + "Confirmed": "已确认", + "Cancelled": "取消", + "Event banner image description": "活动横幅图片说明", + "Banner image": "横幅图片", + "Maximum attendees": "参加人数上限", + "Ticket URL": "工单URL", + "Create a new event": "建立新活动", + "Moderation policy or code of conduct": "审核政策或行为准则", + "Edit event": "编辑活动", + "Notify when posts are liked": "通知喜欢的帖子" } diff --git a/utils.py b/utils.py index d5601f651..073e820a8 100644 --- a/utils.py +++ b/utils.py @@ -19,6 +19,20 @@ from calendar import monthrange from followingCalendar import addPersonToCalendar +def removeIdEnding(idStr: str) -> str: + """Removes endings such as /activity and /undo + """ + if idStr.endswith('/activity'): + idStr = idStr[:-len('/activity')] + elif idStr.endswith('/undo'): + idStr = idStr[:-len('/undo')] + elif idStr.endswith('/event'): + idStr = idStr[:-len('/event')] + elif idStr.endswith('/replies'): + idStr = idStr[:-len('/replies')] + return idStr + + def getProtocolPrefixes() -> []: """Returns a list of valid prefixes """ @@ -384,13 +398,13 @@ def locatePost(baseDir: str, nickname: str, domain: str, extension = 'replies' # if this post in the shared inbox? - postUrl = postUrl.replace('/', '#').replace('/activity', '').strip() + postUrl = removeIdEnding(postUrl.strip()).replace('/', '#') # add the extension postUrl = postUrl + '.' + extension # search boxes - boxes = ('inbox', 'outbox', 'tlblogs') + boxes = ('inbox', 'outbox', 'tlblogs', 'tlevents') accountDir = baseDir + '/accounts/' + nickname + '@' + domain + '/' for boxName in boxes: postFilename = accountDir + boxName + '/' + postUrl @@ -402,7 +416,7 @@ def locatePost(baseDir: str, nickname: str, domain: str, if os.path.isfile(postFilename): return postFilename - print('WARN: unable to locate ' + nickname + ' ' + postUrl) + # print('WARN: unable to locate ' + nickname + ' ' + postUrl) return None @@ -435,7 +449,7 @@ def removeModerationPostFromIndex(baseDir: str, postUrl: str, moderationIndexFile = baseDir + '/accounts/moderation.txt' if not os.path.isfile(moderationIndexFile): return - postId = postUrl.replace('/activity', '') + postId = removeIdEnding(postUrl) if postId in open(moderationIndexFile).read(): with open(moderationIndexFile, "r") as f: lines = f.readlines() @@ -463,7 +477,7 @@ def isReplyToBlogPost(baseDir: str, nickname: str, domain: str, nickname + '@' + domain + '/tlblogs.index' if not os.path.isfile(blogsIndexFilename): return False - postId = postJsonObject['object']['inReplyTo'].replace('/activity', '') + postId = removeIdEnding(postJsonObject['object']['inReplyTo']) postId = postId.replace('/', '#') if postId in open(blogsIndexFilename).read(): return True @@ -494,7 +508,7 @@ def deletePost(baseDir: str, httpPrefix: str, # remove from recent posts cache in memory if recentPostsCache: postId = \ - postJsonObject['id'].replace('/activity', '').replace('/', '#') + removeIdEnding(postJsonObject['id']).replace('/', '#') if recentPostsCache.get('index'): if postId in recentPostsCache['index']: recentPostsCache['index'].remove(postId) @@ -526,7 +540,7 @@ def deletePost(baseDir: str, httpPrefix: str, if isinstance(postJsonObject['object'], dict): if postJsonObject['object'].get('moderationStatus'): if postJsonObject.get('id'): - postId = postJsonObject['id'].replace('/activity', '') + postId = removeIdEnding(postJsonObject['id']) removeModerationPostFromIndex(baseDir, postId, debug) # remove any hashtags index entries @@ -540,8 +554,7 @@ def deletePost(baseDir: str, httpPrefix: str, if postJsonObject['object'].get('id') and \ postJsonObject['object'].get('tag'): # get the id of the post - postId = \ - postJsonObject['object']['id'].replace('/activity', '') + postId = removeIdEnding(postJsonObject['object']['id']) for tag in postJsonObject['object']['tag']: if tag['type'] != 'Hashtag': continue @@ -600,6 +613,7 @@ def validNickname(domain: str, nickname: str) -> bool: 'public', 'followers', 'channel', 'capabilities', 'calendar', 'tlreplies', 'tlmedia', 'tlblogs', + 'tlevents', 'moderation', 'activity', 'undo', 'reply', 'replies', 'question', 'like', 'likes', 'users', 'statuses', @@ -710,7 +724,7 @@ def getCachedPostFilename(baseDir: str, nickname: str, domain: str, return None cachedPostFilename = \ cachedPostDir + \ - '/' + postJsonObject['id'].replace('/activity', '').replace('/', '#') + '/' + removeIdEnding(postJsonObject['id']).replace('/', '#') cachedPostFilename = cachedPostFilename + '.html' return cachedPostFilename @@ -727,7 +741,7 @@ def removePostFromCache(postJsonObject: {}, recentPostsCache: {}): postId = postJsonObject['id'] if '#' in postId: postId = postId.split('#', 1)[0] - postId = postId.replace('/activity', '').replace('/', '#') + postId = removeIdEnding(postId).replace('/', '#') if postId not in recentPostsCache['index']: return @@ -747,7 +761,7 @@ def updateRecentPostsCache(recentPostsCache: {}, maxRecentPosts: int, postId = postJsonObject['id'] if '#' in postId: postId = postId.split('#', 1)[0] - postId = postId.replace('/activity', '').replace('/', '#') + postId = removeIdEnding(postId).replace('/', '#') if recentPostsCache.get('index'): if postId in recentPostsCache['index']: return @@ -757,6 +771,7 @@ def updateRecentPostsCache(recentPostsCache: {}, maxRecentPosts: int, recentPostsCache['html'][postId] = htmlStr while len(recentPostsCache['html'].items()) > maxRecentPosts: + postId = recentPostsCache['index'][0] recentPostsCache['index'].pop(0) del recentPostsCache['json'][postId] del recentPostsCache['html'][postId] @@ -792,6 +807,43 @@ def mergeDicts(dict1: {}, dict2: {}) -> {}: return res +def isEventPost(messageJson: {}) -> bool: + """Is the given post a mobilizon-type event activity? + See https://framagit.org/framasoft/mobilizon/-/blob/ + master/lib/federation/activity_stream/converter/event.ex + """ + if not messageJson.get('id'): + return False + if not messageJson.get('actor'): + return False + if not messageJson.get('object'): + return False + if not isinstance(messageJson['object'], dict): + return False + if not messageJson['object'].get('type'): + return False + if messageJson['object']['type'] != 'Event': + return False + print('Event arriving') + if not messageJson['object'].get('startTime'): + print('No event start time') + return False + if not messageJson['object'].get('actor'): + print('No event actor') + return False + if not messageJson['object'].get('content'): + print('No event content') + return False + if not messageJson['object'].get('name'): + print('No event name') + return False + if not messageJson['object'].get('uuid'): + print('No event UUID') + return False + print('Event detected') + return True + + def isBlogPost(postJsonObject: {}) -> bool: """Is the given post a blog post? """ @@ -1071,7 +1123,7 @@ def updateAnnounceCollection(recentPostsCache: {}, return if not isinstance(postJsonObject['object'], dict): return - postUrl = postJsonObject['id'].replace('/activity', '') + '/shares' + postUrl = removeIdEnding(postJsonObject['id']) + '/shares' if not postJsonObject['object'].get('shares'): if debug: print('DEBUG: Adding initial shares (announcements) to ' + diff --git a/webinterface.py b/webinterface.py index 79205e8c6..a2e451b4d 100644 --- a/webinterface.py +++ b/webinterface.py @@ -25,9 +25,11 @@ from ssb import getSSBAddress from tox import getToxAddress from matrix import getMatrixAddress from donate import getDonationUrl +from utils import removeIdEnding from utils import getProtocolPrefixes from utils import getFileCaseInsensitive from utils import searchBoxPosts +from utils import isEventPost from utils import isBlogPost from utils import updateRecentPostsCache from utils import getNicknameFromActor @@ -1081,6 +1083,7 @@ def htmlEditProfile(translate: {}, baseDir: str, path: str, isGroup = '' followDMs = '' removeTwitter = '' + notifyLikes = '' mediaInstanceStr = '' displayNickname = nickname bioStr = '' @@ -1128,6 +1131,9 @@ def htmlEditProfile(translate: {}, baseDir: str, path: str, if os.path.isfile(baseDir + '/accounts/' + nickname + '@' + domain + '/.removeTwitter'): removeTwitter = 'checked' + if os.path.isfile(baseDir + '/accounts/' + + nickname + '@' + domain + '/.notifyLikes'): + notifyLikes = 'checked' mediaInstance = getConfigParam(baseDir, "mediaInstance") if mediaInstance: @@ -1463,6 +1469,10 @@ def htmlEditProfile(translate: {}, baseDir: str, path: str, ' ' + \ translate['This is a media instance'] + '
\n' + editProfileForm += \ + ' ' + \ + translate['Notify when posts are liked'] + '
\n' editProfileForm += \ '