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

main
Bob Mottram 2020-08-27 21:27:12 +01:00
commit afecdb80d8
44 changed files with 1769 additions and 310 deletions

View File

@ -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],

View File

@ -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:

View File

@ -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')

View File

@ -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)

263
daemon.py
View File

@ -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:
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,6 +8876,7 @@ class PubServer(BaseHTTPRequestHandler):
self._benchmarkPOSTtimings(POSTstartTime, POSTtimings, 22)
if not self.server.unitTest:
if not inboxPermittedMessage(self.server.domain,
messageJson,
self.server.federationList):
@ -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.

View File

@ -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')

View File

@ -1269,6 +1269,26 @@ aside .toggle-inside li {
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;
}
}
@media screen and (min-width: 2200px) {
@ -1689,4 +1709,24 @@ aside .toggle-inside li {
padding: 20px;
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;
}
}

View File

@ -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)

View File

@ -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:

View File

@ -43,12 +43,14 @@ def removeEventFromTimeline(eventId: str, tlEventsFilename: str) -> None:
pass
def saveEvent(baseDir: str, handle: str, postId: str,
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'

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

221
inbox.py
View File

@ -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,12 +126,13 @@ 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
if boxname != 'tlevents' and boxname != 'outbox':
boxName = 'inbox'
individualPostAsHtml(recentPostsCache, maxRecentPosts,
getIconsDir(baseDir), translate, pageNumber,
@ -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,9 +712,8 @@ def receiveUndoFollow(session, baseDir: str, httpPrefix: str,
nicknameFollowing, domainFollowingFull,
nicknameFollower, domainFollowerFull,
debug):
if debug:
print('DEBUG: Follower ' +
nicknameFollower + '@' + domainFollowerFull +
print(nicknameFollowing + '@' + domainFollowingFull + ': '
'Follower ' + nicknameFollower + '@' + domainFollowerFull +
' was removed')
return True
@ -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')
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,6 +2699,7 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int,
if accountMaxPostsPerDay > 0 or domainMaxPostsPerDay > 0:
pprint(quotasDaily)
if queueJson.get('actor'):
print('Obtaining public key for actor ' + queueJson['actor'])
# Try a few times to obtain the public key
@ -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,

View File

@ -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)

View File

@ -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']:

View File

@ -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)

307
posts.py
View File

@ -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({}, session, baseDir, 'dm', nickname,
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(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):

View File

@ -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

130
tests.py
View File

@ -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” <a href="somesite.html">“test” html</a>'
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 <a href="somesite">content warning</a>')
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()

View File

@ -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": "يخطر عندما يتم اعجاب المشاركات"
}

View File

@ -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 lesdeveniment",
"Events": "Esdeveniments",
"Create an event": "Crea un esdeveniment",
"Describe the event": "Descriviu lesdeveniment",
"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 shi pot apuntar",
"Apply to join": "Sol·liciteu participar",
"Invitation only": "Només invitació",
"Joining": "Unir-se",
"Status of the event": "Estat de lesdeveniment",
"Tentative": "Temptatiu",
"Confirmed": "Confirmat",
"Cancelled": "Cancel·lat",
"Event banner image description": "Descripció de la imatge del banner de lesdeveniment",
"Banner image": "Imatge de pancarta",
"Maximum attendees": "Màxim dassistents",
"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 lesdeveniment",
"Notify when posts are liked": "Notifiqueu-ho quan us agradin les publicacions"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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": "पोस्ट पसंद आने पर सूचित करें"
}

View File

@ -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"
}

View File

@ -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": "投稿が高く評価されたときに通知する"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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": "Уведомлять, когда публикации нравятся"
}

View File

@ -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": "通知喜欢的帖子"
}

View File

@ -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 ' +

View File

@ -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,
' <input type="checkbox" class="profilecheckbox" ' + \
'name="mediaInstance" ' + mediaInstanceStr + '> ' + \
translate['This is a media instance'] + '<br>\n'
editProfileForm += \
' <input type="checkbox" class="profilecheckbox" ' + \
'name="notifyLikes" ' + notifyLikes + '> ' + \
translate['Notify when posts are liked'] + '<br>\n'
editProfileForm += \
' <br><b><label class="labels">' + \
@ -1837,6 +1847,7 @@ def htmlNewPost(mediaInstance: bool, translate: {},
replyStr = ''
showPublicOnDropdown = True
messageBoxHeight = 400
if not path.endswith('/newshare'):
if not path.endswith('/newreport'):
@ -1920,15 +1931,31 @@ def htmlNewPost(mediaInstance: bool, translate: {},
pathBase = path.replace('/newreport', '').replace('/newpost', '')
pathBase = pathBase.replace('/newblog', '').replace('/newshare', '')
pathBase = pathBase.replace('/newunlisted', '')
pathBase = pathBase.replace('/newevent', '')
pathBase = pathBase.replace('/newreminder', '')
pathBase = pathBase.replace('/newfollowers', '').replace('/newdm', '')
newPostImageSection = ' <div class="container">'
if not path.endswith('/newevent'):
newPostImageSection += \
' <label class="labels">' + translate['Image description'] + \
'</label>\n'
' <label class="labels">' + \
translate['Image description'] + '</label>\n'
else:
newPostImageSection += \
' <label class="labels">' + \
translate['Event banner image description'] + '</label>\n'
newPostImageSection += \
' <input type="text" name="imageDescription">\n'
if path.endswith('/newevent'):
newPostImageSection += \
' <label class="labels">' + \
translate['Banner image'] + '</label>\n'
newPostImageSection += \
' <input type="file" id="attachpic" name="attachpic"'
newPostImageSection += \
' accept=".png, .jpg, .jpeg, .gif, .webp">\n'
else:
newPostImageSection += \
' <input type="file" id="attachpic" name="attachpic"'
newPostImageSection += \
@ -1969,6 +1996,12 @@ def htmlNewPost(mediaInstance: bool, translate: {},
scopeIcon = 'scope_reminder.png'
scopeDescription = translate['Reminder']
endpoint = 'newreminder'
elif path.endswith('/newevent'):
scopeIcon = 'scope_event.png'
scopeDescription = translate['Event']
endpoint = 'newevent'
placeholderSubject = translate['Event name']
placeholderMessage = translate['Describe the event'] + '...'
elif path.endswith('/newreport'):
scopeIcon = 'scope_report.png'
scopeDescription = translate['Report']
@ -2027,29 +2060,139 @@ def htmlNewPost(mediaInstance: bool, translate: {},
if endpoint != 'newshare' and \
endpoint != 'newreport' and \
endpoint != 'newquestion':
dateAndLocation = '<div class="container">'
dateAndLocation = '<div class="container">\n'
if not inReplyTo:
if endpoint == 'newevent':
# event status
dateAndLocation += '<label class="labels">' + \
translate['Status of the event'] + ':</label><br>\n'
dateAndLocation += '<input type="radio" id="tentative" ' + \
'name="eventStatus" value="tentative">\n'
dateAndLocation += '<label class="labels" for="tentative">' + \
translate['Tentative'] + '</label><br>\n'
dateAndLocation += '<input type="radio" id="confirmed" ' + \
'name="eventStatus" value="confirmed" checked>\n'
dateAndLocation += '<label class="labels" for="confirmed">' + \
translate['Confirmed'] + '</label><br>\n'
dateAndLocation += '<input type="radio" id="cancelled" ' + \
'name="eventStatus" value="cancelled">\n'
dateAndLocation += '<label class="labels" for="cancelled">' + \
translate['Cancelled'] + '</label><br>\n'
dateAndLocation += '</div>\n'
dateAndLocation += '<div class="container">\n'
# maximum attendees
dateAndLocation += '<label class="labels" ' + \
'for="maximumAttendeeCapacity">' + \
translate['Maximum attendees'] + ':</label>\n'
dateAndLocation += '<input type="number" ' + \
'id="maximumAttendeeCapacity" ' + \
'name="maximumAttendeeCapacity" min="1" max="999999" ' + \
'value="100">\n'
dateAndLocation += '</div>\n'
dateAndLocation += '<div class="container">\n'
# event joining options
dateAndLocation += '<label class="labels">' + \
translate['Joining'] + ':</label><br>\n'
dateAndLocation += '<input type="radio" id="free" ' + \
'name="joinMode" value="free" checked>\n'
dateAndLocation += '<label class="labels" for="free">' + \
translate['Anyone can join'] + '</label><br>\n'
dateAndLocation += '<input type="radio" id="restricted" ' + \
'name="joinMode" value="restricted">\n'
dateAndLocation += '<label class="labels" for="female">' + \
translate['Apply to join'] + '</label><br>\n'
dateAndLocation += '<input type="radio" id="invite" ' + \
'name="joinMode" value="invite">\n'
dateAndLocation += '<label class="labels" for="other">' + \
translate['Invitation only'] + '</label>\n'
dateAndLocation += '</div>\n'
dateAndLocation += '<div class="container">\n'
# Event posts don't allow replies - they're just an announcement.
# They also have a few more checkboxes
dateAndLocation += \
'<p><input type="checkbox" class="profilecheckbox" ' + \
'name="privateEvent"><label class="labels"> ' + \
translate['This is a private event.'] + '</label></p>\n'
dateAndLocation += \
'<p><input type="checkbox" class="profilecheckbox" ' + \
'name="anonymousParticipationEnabled">' + \
'<label class="labels"> ' + \
translate['Allow anonymous participation.'] + '</label></p>\n'
else:
dateAndLocation += \
'<p><input type="checkbox" class="profilecheckbox" ' + \
'name="commentsEnabled" checked><label class="labels"> ' + \
translate['Allow replies.'] + '</label></p>\n'
if not inReplyTo and endpoint != 'newevent':
dateAndLocation += \
'<p><input type="checkbox" class="profilecheckbox" ' + \
'name="schedulePost"><label class="labels"> ' + \
translate['This is a scheduled post.'] + '</label></p>\n'
if endpoint != 'newevent':
dateAndLocation += \
'<p><img loading="lazy" alt="" title="" ' + \
'class="emojicalendar" src="/' + \
iconsDir + '/calendar.png"/>\n'
# select a date and time for this post
dateAndLocation += '<label class="labels">' + \
translate['Date'] + ': </label>\n'
dateAndLocation += '<input type="date" name="eventDate">\n'
dateAndLocation += '<label class="labelsright">' + \
translate['Time'] + ':'
dateAndLocation += '<input type="time" name="eventTime"></label></p>\n'
dateAndLocation += \
'<input type="time" name="eventTime"></label></p>\n'
else:
dateAndLocation += '</div>\n'
dateAndLocation += '<div class="container">\n'
dateAndLocation += \
'<p><img loading="lazy" alt="" title="" ' + \
'class="emojicalendar" src="/' + \
iconsDir + '/calendar.png"/>\n'
# select start time for the event
dateAndLocation += '<label class="labels">' + \
translate['Start Date'] + ': </label>\n'
dateAndLocation += '<input type="date" name="eventDate">\n'
dateAndLocation += '<label class="labelsright">' + \
translate['Time'] + ':'
dateAndLocation += \
'<input type="time" name="eventTime"></label></p>\n'
# select end time for the event
dateAndLocation += \
'<br><img loading="lazy" alt="" title="" ' + \
'class="emojicalendar" src="/' + \
iconsDir + '/calendar.png"/>\n'
dateAndLocation += '<label class="labels">' + \
translate['End Date'] + ': </label>\n'
dateAndLocation += '<input type="date" name="endDate">\n'
dateAndLocation += '<label class="labelsright">' + \
translate['Time'] + ':'
dateAndLocation += \
'<input type="time" name="endTime"></label>\n'
if endpoint == 'newevent':
dateAndLocation += '</div>\n'
dateAndLocation += '<div class="container">\n'
dateAndLocation += '<br><label class="labels">' + \
translate['Moderation policy or code of conduct'] + \
': </label>\n'
dateAndLocation += \
' <textarea id="message" ' + \
'name="repliesModerationOption" style="height:' + \
str(messageBoxHeight) + 'px"></textarea>\n'
dateAndLocation += '</div>\n'
dateAndLocation += '<div class="container">\n'
dateAndLocation += '<br><label class="labels">' + \
translate['Location'] + ': </label>\n'
dateAndLocation += '<input type="text" name="location">\n'
if endpoint == 'newevent':
dateAndLocation += '<br><label class="labels">' + \
translate['Ticket URL'] + ': </label>\n'
dateAndLocation += '<input type="text" name="ticketUrl">\n'
dateAndLocation += '<br><label class="labels">' + \
translate['Categories'] + ': </label>\n'
dateAndLocation += '<input type="text" name="category">\n'
dateAndLocation += '</div>\n'
newPostForm = htmlHeader(cssFilename, newPostCSS)
@ -2093,6 +2236,7 @@ def htmlNewPost(mediaInstance: bool, translate: {},
dropdownUnlistedSuffix = '/newunlisted'
dropdownFollowersSuffix = '/newfollowers'
dropdownDMSuffix = '/newdm'
dropdownEventSuffix = '/newevent'
dropdownReminderSuffix = '/newreminder'
dropdownReportSuffix = '/newreport'
if inReplyTo or mentions:
@ -2101,6 +2245,7 @@ def htmlNewPost(mediaInstance: bool, translate: {},
dropdownUnlistedSuffix = ''
dropdownFollowersSuffix = ''
dropdownDMSuffix = ''
dropdownEventSuffix = ''
dropdownReminderSuffix = ''
dropdownReportSuffix = ''
if inReplyTo:
@ -2176,6 +2321,12 @@ def htmlNewPost(mediaInstance: bool, translate: {},
iconsDir + '/scope_reminder.png"/><b>' + translate['Reminder'] + \
'</b><br>' + translate['Scheduled note to yourself'] + \
'</li></a>\n'
dropDownContent += " " \
'<a href="' + pathBase + dropdownEventSuffix + \
'"><li><img loading="lazy" alt="" title="" src="/' + \
iconsDir + '/scope_event.png"/><b>' + translate['Event'] + \
'</b><br>' + translate['Create an event'] + \
'</li></a>\n'
dropDownContent += " " \
'<a href="' + pathBase + dropdownReportSuffix + \
'"><li><img loading="lazy" alt="" title="" src="/' + iconsDir + \
@ -2251,7 +2402,6 @@ def htmlNewPost(mediaInstance: bool, translate: {},
newPostForm += \
' <br><label class="labels">' + placeholderMessage + '</label>'
messageBoxHeight = 400
if mediaInstance:
messageBoxHeight = 200
@ -3186,7 +3336,7 @@ def insertQuestion(baseDir: str, translate: {},
return content
if len(postJsonObject['object']['oneOf']) == 0:
return content
messageId = postJsonObject['id'].replace('/activity', '')
messageId = removeIdEnding(postJsonObject['id'])
if '#' in messageId:
messageId = messageId.split('#', 1)[0]
pageNumberStr = ''
@ -3654,7 +3804,7 @@ def individualPostAsHtml(recentPostsCache: {}, maxRecentPosts: int,
avatarPosition = ''
messageId = ''
if postJsonObject.get('id'):
messageId = postJsonObject['id'].replace('/activity', '')
messageId = removeIdEnding(postJsonObject['id'])
messageIdStr = ''
if messageId:
@ -3743,7 +3893,7 @@ def individualPostAsHtml(recentPostsCache: {}, maxRecentPosts: int,
if boxName == 'tlbookmarks' or boxName == 'bookmarks':
return ''
timelinePostBookmark = postJsonObject['id'].replace('/activity', '')
timelinePostBookmark = removeIdEnding(postJsonObject['id'])
timelinePostBookmark = timelinePostBookmark.replace('://', '-')
timelinePostBookmark = timelinePostBookmark.replace('/', '-')
@ -3828,7 +3978,13 @@ def individualPostAsHtml(recentPostsCache: {}, maxRecentPosts: int,
iconsDir + '/dm.png" class="DMicon"/>\n'
replyStr = ''
if showIcons:
# check if replying is permitted
commentsEnabled = True
if 'commentsEnabled' in postJsonObject['object']:
if postJsonObject['object']['commentsEnabled'] is False:
commentsEnabled = False
if showIcons and commentsEnabled:
# reply is permitted - create reply icon
replyToLink = postJsonObject['object']['id']
if postJsonObject['object'].get('attributedTo'):
if isinstance(postJsonObject['object']['attributedTo'], str):
@ -3872,20 +4028,35 @@ def individualPostAsHtml(recentPostsCache: {}, maxRecentPosts: int,
translate['Reply to this post'] + \
' |" src="/' + iconsDir + '/reply.png"/></a>\n'
isEvent = isEventPost(postJsonObject)
editStr = ''
if fullDomain + '/users/' + nickname in postJsonObject['actor']:
if isBlogPost(postJsonObject):
if '/statuses/' in postJsonObject['object']['id']:
if isBlogPost(postJsonObject):
blogPostId = postJsonObject['object']['id']
editStr += \
'<a class="imageAnchor" href="/users/' + nickname + \
'/tlblogs?editblogpost=' + \
postJsonObject['object']['id'].split('/statuses/')[1] + \
blogPostId.split('/statuses/')[1] + \
'?actor=' + actorNickname + \
'" title="' + translate['Edit blog post'] + '">' + \
'<img loading="lazy" title="' + \
translate['Edit blog post'] + '" alt="' + \
translate['Edit blog post'] + \
' |" src="/' + iconsDir + '/edit.png"/></a>\n'
elif isEvent:
eventPostId = postJsonObject['object']['id']
editStr += \
'<a class="imageAnchor" href="/users/' + nickname + \
'/tlblogs?editeventpost=' + \
eventPostId.split('/statuses/')[1] + \
'?actor=' + actorNickname + \
'" title="' + translate['Edit event'] + '">' + \
'<img loading="lazy" title="' + \
translate['Edit event'] + '" alt="' + \
translate['Edit event'] + \
' |" src="/' + iconsDir + '/edit.png"/></a>\n'
announceStr = ''
if not isModerationPost and showRepeatIcon:
@ -4448,7 +4619,7 @@ def htmlTimeline(defaultTimeline: str,
if boxName == 'tlshares':
os.remove(newShareFile)
# should the Moderation button be highlighted?
# should the Moderation/reports button be highlighted?
newReport = False
newReportFile = accountDir + '/.newReport'
if os.path.isfile(newReportFile):
@ -4456,11 +4627,16 @@ def htmlTimeline(defaultTimeline: str,
if boxName == 'moderation':
os.remove(newReportFile)
# directory where icons are found
# This changes depending upon theme
iconsDir = getIconsDir(baseDir)
# the css filename
cssFilename = baseDir + '/epicyon-profile.css'
if os.path.isfile(baseDir + '/epicyon.css'):
cssFilename = baseDir + '/epicyon.css'
# filename of the banner shown at the top
bannerFile = 'banner.png'
bannerFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/' + bannerFile
@ -4476,16 +4652,20 @@ def htmlTimeline(defaultTimeline: str,
bannerFile = 'banner.webp'
with open(cssFilename, 'r') as cssFile:
# load css
profileStyle = \
cssFile.read().replace('banner.png',
'/users/' + nickname + '/' + bannerFile)
# replace any https within the css with whatever prefix is needed
if httpPrefix != 'https':
profileStyle = \
profileStyle.replace('https://',
httpPrefix + '://')
# is the user a moderator?
moderator = isModerator(baseDir, nickname)
# the appearance of buttons - highlighted or not
inboxButton = 'button'
blogsButton = 'button'
dmButton = 'button'
@ -4496,6 +4676,7 @@ def htmlTimeline(defaultTimeline: str,
repliesButton = 'buttonhighlighted'
mediaButton = 'button'
bookmarksButton = 'button'
eventsButton = 'button'
sentButton = 'button'
sharesButton = 'button'
if newShare:
@ -4529,16 +4710,21 @@ def htmlTimeline(defaultTimeline: str,
sharesButton = 'buttonselectedhighlighted'
elif boxName == 'tlbookmarks' or boxName == 'bookmarks':
bookmarksButton = 'buttonselected'
elif boxName == 'tlevents':
eventsButton = 'buttonselected'
# get the full domain, including any port number
fullDomain = domain
if port != 80 and port != 443:
if ':' not in domain:
fullDomain = domain + ':' + str(port)
usersPath = '/users/' + nickname
actor = httpPrefix + '://' + fullDomain + usersPath
showIndividualPostIcons = True
# show an icon for new follow approvals
followApprovals = ''
followRequestsFilename = \
baseDir + '/accounts/' + \
@ -4558,6 +4744,7 @@ def htmlTimeline(defaultTimeline: str,
'" src="/' + iconsDir + '/person.png"/></a>\n'
break
# moderation / reports button
moderationButtonStr = ''
if moderator and not minimal:
moderationButtonStr = \
@ -4567,8 +4754,10 @@ def htmlTimeline(defaultTimeline: str,
htmlHighlightLabel(translate['Mod'], newReport) + \
' </span></button></a>\n'
# shares, bookmarks and events buttons
sharesButtonStr = ''
bookmarksButtonStr = ''
eventsButtonStr = ''
if not minimal:
sharesButtonStr = \
'<a href="' + usersPath + '/tlshares"><button class="' + \
@ -4581,10 +4770,39 @@ def htmlTimeline(defaultTimeline: str,
bookmarksButton + '"><span>' + translate['Bookmarks'] + \
' </span></button></a>\n'
eventsButtonStr = \
'<a href="' + usersPath + '/tlevents"><button class="' + \
eventsButton + '"><span>' + translate['Events'] + \
' </span></button></a>\n'
tlStr = htmlHeader(cssFilename, profileStyle)
if boxName != 'dm':
if boxName != 'tlblogs':
# what screen to go to when a new post is created
if boxName == 'dm':
newPostButtonStr = \
'<a class="imageAnchor" href="' + usersPath + \
'/newdm"><img loading="lazy" src="/' + \
iconsDir + '/newpost.png" title="' + \
translate['Create a new DM'] + \
'" alt="| ' + translate['Create a new DM'] + \
'" class="timelineicon"/></a>\n'
elif boxName == 'tlblogs':
newPostButtonStr = \
'<a class="imageAnchor" href="' + usersPath + \
'/newblog"><img loading="lazy" src="/' + \
iconsDir + '/newpost.png" title="' + \
translate['Create a new post'] + '" alt="| ' + \
translate['Create a new post'] + \
'" class="timelineicon"/></a>\n'
elif boxName == 'tlevents':
newPostButtonStr = \
'<a class="imageAnchor" href="' + usersPath + \
'/newevent"><img loading="lazy" src="/' + \
iconsDir + '/newpost.png" title="' + \
translate['Create a new event'] + '" alt="| ' + \
translate['Create a new event'] + \
'" class="timelineicon"/></a>\n'
else:
if not manuallyApproveFollowers:
newPostButtonStr = \
'<a class="imageAnchor" href="' + usersPath + \
@ -4601,22 +4819,6 @@ def htmlTimeline(defaultTimeline: str,
translate['Create a new post'] + \
'" alt="| ' + translate['Create a new post'] + \
'" class="timelineicon"/></a>\n'
else:
newPostButtonStr = \
'<a class="imageAnchor" href="' + usersPath + \
'/newblog"><img loading="lazy" src="/' + \
iconsDir + '/newpost.png" title="' + \
translate['Create a new post'] + '" alt="| ' + \
translate['Create a new post'] + \
'" class="timelineicon"/></a>\n'
else:
newPostButtonStr = \
'<a class="imageAnchor" href="' + usersPath + \
'/newdm"><img loading="lazy" src="/' + \
iconsDir + '/newpost.png" title="' + \
translate['Create a new DM'] + \
'" alt="| ' + translate['Create a new DM'] + \
'" class="timelineicon"/></a>\n'
# This creates a link to the profile page when viewed
# in lynx, but should be invisible in a graphical web browser
@ -4681,6 +4883,7 @@ def htmlTimeline(defaultTimeline: str,
'</span></button></a>\n'
# typically the blogs button
# but may change if this is a blogging oriented instance
if defaultTimeline != 'tlblogs':
if not minimal:
tlStr += \
@ -4696,14 +4899,19 @@ def htmlTimeline(defaultTimeline: str,
inboxButton + '"><span>' + translate['Inbox'] + \
'</span></button></a>\n'
# button for the outbox
tlStr += \
' <a href="' + usersPath + \
'/outbox"><button class="' + \
sentButton+'"><span>' + translate['Outbox'] + \
'</span></button></a>\n'
# add other buttons
tlStr += \
sharesButtonStr + bookmarksButtonStr + \
sharesButtonStr + bookmarksButtonStr + eventsButtonStr + \
moderationButtonStr + newPostButtonStr
# the search button
tlStr += \
' <a class="imageAnchor" href="' + usersPath + \
'/search"><img loading="lazy" src="/' + \
@ -4711,6 +4919,7 @@ def htmlTimeline(defaultTimeline: str,
translate['Search and follow'] + '" alt="| ' + \
translate['Search and follow'] + '" class="timelineicon"/></a>\n'
# the calendar button
calendarAltText = translate['Calendar']
if newCalendarEvent:
# indicate that the calendar icon is highlighted
@ -4721,6 +4930,7 @@ def htmlTimeline(defaultTimeline: str,
calendarImage + '" title="' + translate['Calendar'] + \
'" alt="| ' + calendarAltText + '" class="timelineicon"/></a>\n'
# the show/hide button, for a simpler header appearance
tlStr += \
' <a class="imageAnchor" href="' + usersPath + '/minimal' + \
'"><img loading="lazy" src="/' + iconsDir + \
@ -4781,11 +4991,15 @@ def htmlTimeline(defaultTimeline: str,
if boxName == 'inbox' and pageNumber == 1:
if todaysEventsCheck(baseDir, nickname, domain):
now = datetime.now()
# happening today button
tlStr += \
'<center>\n<a href="' + usersPath + '/calendar?year=' + \
str(now.year) + '?month=' + str(now.month) + \
'?day=' + str(now.day) + '"><button class="buttonevent">' + \
translate['Happening Today'] + '</button></a>\n'
# happening this week button
if thisWeeksEventsCheck(baseDir, nickname, domain):
tlStr += \
'<a href="' + usersPath + \
@ -4793,6 +5007,7 @@ def htmlTimeline(defaultTimeline: str,
translate['Happening This Week'] + '</button></a>\n'
tlStr += '</center>\n'
else:
# happening this week button
if thisWeeksEventsCheck(baseDir, nickname, domain):
tlStr += \
'<center>\n<a href="' + usersPath + \
@ -4813,10 +5028,13 @@ def htmlTimeline(defaultTimeline: str,
# show the posts
itemCtr = 0
if timelineJson:
# if this is the media timeline then add an extra gallery container
if boxName == 'tlmedia':
if pageNumber > 1:
tlStr += '<br>'
tlStr += '<div class="galleryContainer">\n'
# show each post in the timeline
for item in timelineJson['orderedItems']:
if item['type'] == 'Create' or \
item['type'] == 'Announce' or \
@ -4830,7 +5048,7 @@ def htmlTimeline(defaultTimeline: str,
if boxName != 'tlmedia' and \
recentPostsCache.get('index'):
postId = \
item['id'].replace('/activity', '').replace('/', '#')
removeIdEnding(item['id']).replace('/', '#')
if postId in recentPostsCache['index']:
if not item.get('muted'):
if recentPostsCache['html'].get(postId):
@ -4942,6 +5160,28 @@ def htmlBookmarks(defaultTimeline: str,
minimal, YTReplacementDomain)
def htmlEvents(defaultTimeline: str,
recentPostsCache: {}, maxRecentPosts: int,
translate: {}, pageNumber: int, itemsPerPage: int,
session, baseDir: str, wfRequest: {}, personCache: {},
nickname: str, domain: str, port: int, bookmarksJson: {},
allowDeletion: bool,
httpPrefix: str, projectVersion: str,
minimal: bool, YTReplacementDomain: str) -> str:
"""Show the events as html
"""
manuallyApproveFollowers = \
followerApprovalActive(baseDir, nickname, domain)
return htmlTimeline(defaultTimeline, recentPostsCache, maxRecentPosts,
translate, pageNumber,
itemsPerPage, session, baseDir, wfRequest, personCache,
nickname, domain, port, bookmarksJson,
'tlevents', allowDeletion,
httpPrefix, projectVersion, manuallyApproveFollowers,
minimal, YTReplacementDomain)
def htmlInboxDMs(defaultTimeline: str,
recentPostsCache: {}, maxRecentPosts: int,
translate: {}, pageNumber: int, itemsPerPage: int,
@ -5105,7 +5345,7 @@ def htmlIndividualPost(recentPostsCache: {}, maxRecentPosts: int,
httpPrefix, projectVersion, 'inbox',
YTReplacementDomain,
False, authorized, False, False, False)
messageId = postJsonObject['id'].replace('/activity', '')
messageId = removeIdEnding(postJsonObject['id'])
# show the previous posts
if isinstance(postJsonObject['object'], dict):