diff --git a/Makefile b/Makefile
index 5420916ad..034be8abb 100644
--- a/Makefile
+++ b/Makefile
@@ -23,4 +23,4 @@ clean:
rm -f deploy/*~
rm -f translations/*~
rm -rf __pycache__
- rm calendar.css blog.css epicyon.css follow.css login.css options.css search.css suspended.css
+ rm -f calendar.css blog.css epicyon.css follow.css login.css options.css search.css suspended.css
diff --git a/blocking.py b/blocking.py
index f0759f963..29aa2a45b 100644
--- a/blocking.py
+++ b/blocking.py
@@ -7,6 +7,9 @@ __email__ = "bob@freedombone.net"
__status__ = "Production"
import os
+from datetime import datetime
+from utils import fileLastModified
+from utils import setConfigParam
from utils import hasUsersPath
from utils import getFullDomain
from utils import removeIdEnding
@@ -175,15 +178,27 @@ def isBlockedDomain(baseDir: str, domain: str) -> bool:
if noOfSections > 2:
shortDomain = domain[noOfSections-2] + '.' + domain[noOfSections-1]
- globalBlockingFilename = baseDir + '/accounts/blocking.txt'
- if os.path.isfile(globalBlockingFilename):
- with open(globalBlockingFilename, 'r') as fpBlocked:
- blockedStr = fpBlocked.read()
- if '*@' + domain in blockedStr:
- return True
- if shortDomain:
- if '*@' + shortDomain in blockedStr:
+ allowFilename = baseDir + '/accounts/allowedinstances.txt'
+ if not os.path.isfile(allowFilename):
+ # instance block list
+ globalBlockingFilename = baseDir + '/accounts/blocking.txt'
+ if os.path.isfile(globalBlockingFilename):
+ with open(globalBlockingFilename, 'r') as fpBlocked:
+ blockedStr = fpBlocked.read()
+ if '*@' + domain in blockedStr:
return True
+ if shortDomain:
+ if '*@' + shortDomain in blockedStr:
+ return True
+ else:
+ # instance allow list
+ if not shortDomain:
+ if domain not in open(allowFilename).read():
+ return True
+ else:
+ if shortDomain not in open(allowFilename).read():
+ return True
+
return False
@@ -344,3 +359,91 @@ def outboxUndoBlock(baseDir: str, httpPrefix: str,
nicknameBlocked, domainBlockedFull)
if debug:
print('DEBUG: post undo blocked via c2s - ' + postFilename)
+
+
+def setBrochMode(baseDir: str, domainFull: str, enabled: bool) -> None:
+ """Broch mode can be used to lock down the instance during
+ a period of time when it is temporarily under attack.
+ For example, where an adversary is constantly spinning up new
+ instances.
+ It surveys the following lists of all accounts and uses that
+ to construct an instance level allow list. Anything arriving
+ which is then not from one of the allowed domains will be dropped
+ """
+ allowFilename = baseDir + '/accounts/allowedinstances.txt'
+
+ if not enabled:
+ # remove instance allow list
+ if os.path.isfile(allowFilename):
+ os.remove(allowFilename)
+ print('Broch mode turned off')
+ else:
+ if os.path.isfile(allowFilename):
+ lastModified = fileLastModified(allowFilename)
+ print('Broch mode already activated ' + lastModified)
+ return
+ # generate instance allow list
+ allowedDomains = [domainFull]
+ followFiles = ('following.txt', 'followers.txt')
+ for subdir, dirs, files in os.walk(baseDir + '/accounts'):
+ for acct in dirs:
+ if '@' not in acct:
+ continue
+ if 'inbox@' in acct or 'news@' in acct:
+ continue
+ accountDir = os.path.join(baseDir + '/accounts', acct)
+ for followFileType in followFiles:
+ followingFilename = accountDir + '/' + followFileType
+ if not os.path.isfile(followingFilename):
+ continue
+ with open(followingFilename, "r") as f:
+ followList = f.readlines()
+ for handle in followList:
+ if '@' not in handle:
+ continue
+ handle = handle.replace('\n', '')
+ handleDomain = handle.split('@')[1]
+ if handleDomain not in allowedDomains:
+ allowedDomains.append(handleDomain)
+ break
+
+ # write the allow file
+ allowFile = open(allowFilename, "w+")
+ if allowFile:
+ allowFile.write(domainFull + '\n')
+ for d in allowedDomains:
+ allowFile.write(d + '\n')
+ allowFile.close()
+ print('Broch mode enabled')
+
+ setConfigParam(baseDir, "brochMode", enabled)
+
+
+def brochModeLapses(baseDir: str, lapseDays=7) -> bool:
+ """After broch mode is enabled it automatically
+ elapses after a period of time
+ """
+ allowFilename = baseDir + '/accounts/allowedinstances.txt'
+ if not os.path.isfile(allowFilename):
+ return False
+ lastModified = fileLastModified(allowFilename)
+ modifiedDate = None
+ brochMode = True
+ try:
+ modifiedDate = \
+ datetime.strptime(lastModified, "%Y-%m-%dT%H:%M:%SZ")
+ except BaseException:
+ return brochMode
+ if not modifiedDate:
+ return brochMode
+ currTime = datetime.datetime.utcnow()
+ daysSinceBroch = (currTime - modifiedDate).days
+ if daysSinceBroch >= lapseDays:
+ try:
+ os.remove(allowFilename)
+ brochMode = False
+ setConfigParam(baseDir, "brochMode", brochMode)
+ print('Broch mode has elapsed')
+ except BaseException:
+ pass
+ return brochMode
diff --git a/cache.py b/cache.py
index 0505ef543..19cda4084 100644
--- a/cache.py
+++ b/cache.py
@@ -8,11 +8,45 @@ __status__ = "Production"
import os
import datetime
+from session import urlExists
from utils import loadJson
from utils import saveJson
from utils import getFileCaseInsensitive
+def _removePersonFromCache(baseDir: str, personUrl: str,
+ personCache: {}) -> bool:
+ """Removes an actor from the cache
+ """
+ cacheFilename = baseDir + '/cache/actors/' + \
+ personUrl.replace('/', '#')+'.json'
+ if os.path.isfile(cacheFilename):
+ try:
+ os.remove(cacheFilename)
+ except BaseException:
+ pass
+ if personCache.get(personUrl):
+ del personCache[personUrl]
+
+
+def checkForChangedActor(session, baseDir: str,
+ httpPrefix: str, domainFull: str,
+ personUrl: str, avatarUrl: str, personCache: {},
+ timeoutSec: int):
+ """Checks if the avatar url exists and if not then
+ the actor has probably changed without receiving an actor/Person Update.
+ So clear the actor from the cache and it will be refreshed when the next
+ post from them is sent
+ """
+ if not session or not avatarUrl:
+ return
+ if domainFull in avatarUrl:
+ return
+ if urlExists(session, avatarUrl, timeoutSec, httpPrefix, domainFull):
+ return
+ _removePersonFromCache(baseDir, personUrl, personCache)
+
+
def storePersonInCache(baseDir: str, personUrl: str,
personJson: {}, personCache: {},
allowWriteToFile: bool) -> None:
diff --git a/content.py b/content.py
index 1d8334412..bc3db29e6 100644
--- a/content.py
+++ b/content.py
@@ -788,6 +788,13 @@ def addHtmlTags(baseDir: str, httpPrefix: str,
prevWordStr = ''
continue
elif firstChar == '#':
+ # remove any endings from the hashtag
+ hashTagEndings = ('.', ':', ';', '-', '\n')
+ for ending in hashTagEndings:
+ if wordStr.endswith(ending):
+ wordStr = wordStr[:len(wordStr) - 1]
+ break
+
if _addHashTags(wordStr, httpPrefix, originalDomain,
replaceHashTags, hashtags):
prevWordStr = ''
diff --git a/daemon.py b/daemon.py
index 7afb725b9..f2515ad8e 100644
--- a/daemon.py
+++ b/daemon.py
@@ -109,6 +109,7 @@ from threads import threadWithTrace
from threads import removeDormantThreads
from media import replaceYouTube
from media import attachMedia
+from blocking import setBrochMode
from blocking import addBlock
from blocking import removeBlock
from blocking import addGlobalBlock
@@ -185,6 +186,7 @@ from shares import addShare
from shares import removeShare
from shares import expireShares
from categories import setHashtagCategory
+from utils import getLocalNetworkAddresses
from utils import decodedHost
from utils import isPublicPost
from utils import getLockedAccount
@@ -218,6 +220,7 @@ from utils import loadJson
from utils import saveJson
from utils import isSuspended
from utils import dangerousMarkup
+from utils import refreshNewswire
from manualapprove import manualDenyFollowRequest
from manualapprove import manualApproveFollowRequest
from announce import createAnnounce
@@ -227,6 +230,7 @@ from content import extractMediaInFormPOST
from content import saveMediaInFormPOST
from content import extractTextFieldsInPOST
from media import removeMetaData
+from cache import checkForChangedActor
from cache import storePersonInCache
from cache import getPersonFromCache
from httpsig import verifyPostHeaders
@@ -392,7 +396,7 @@ class PubServer(BaseHTTPRequestHandler):
schedulePost,
eventDate,
eventTime,
- location)
+ location, False)
if messageJson:
# name field contains the answer
messageJson['object']['name'] = answer
@@ -476,6 +480,10 @@ class PubServer(BaseHTTPRequestHandler):
if 'text/html' not in self.headers['Accept']:
return False
if self.headers['Accept'].startswith('*'):
+ if self.headers.get('User-Agent'):
+ if 'ELinks' in self.headers['User-Agent'] or \
+ 'Lynx' in self.headers['User-Agent']:
+ return True
return False
if 'json' in self.headers['Accept']:
return False
@@ -1151,20 +1159,46 @@ class PubServer(BaseHTTPRequestHandler):
# check for blocked domains so that they can be rejected early
messageDomain = None
- if messageJson.get('actor'):
- messageDomain, messagePort = \
- getDomainFromActor(messageJson['actor'])
- if isBlockedDomain(self.server.baseDir, messageDomain):
- print('POST from blocked domain ' + messageDomain)
- self._400()
- self.server.POSTbusy = False
- return 3
- else:
+ if not messageJson.get('actor'):
print('Message arriving at inbox queue has no actor')
self._400()
self.server.POSTbusy = False
return 3
+ # actor should be a string
+ if not isinstance(messageJson['actor'], str):
+ self._400()
+ self.server.POSTbusy = False
+ return 3
+
+ # actor should look like a url
+ if '://' not in messageJson['actor'] or \
+ '.' not in messageJson['actor']:
+ print('POST actor does not look like a url ' +
+ messageJson['actor'])
+ self._400()
+ self.server.POSTbusy = False
+ return 3
+
+ # sent by an actor on a local network address?
+ if not self.server.allowLocalNetworkAccess:
+ localNetworkPatternList = getLocalNetworkAddresses()
+ for localNetworkPattern in localNetworkPatternList:
+ if localNetworkPattern in messageJson['actor']:
+ print('POST actor contains local network address ' +
+ messageJson['actor'])
+ self._400()
+ self.server.POSTbusy = False
+ return 3
+
+ messageDomain, messagePort = \
+ getDomainFromActor(messageJson['actor'])
+ if isBlockedDomain(self.server.baseDir, messageDomain):
+ print('POST from blocked domain ' + messageDomain)
+ self._400()
+ self.server.POSTbusy = False
+ return 3
+
# if the inbox queue is full then return a busy code
if len(self.server.inboxQueue) >= self.server.maxQueueLength:
if messageDomain:
@@ -1947,12 +1981,50 @@ class PubServer(BaseHTTPRequestHandler):
if postsToNews == 'on':
if os.path.isfile(newswireBlockedFilename):
os.remove(newswireBlockedFilename)
+ refreshNewswire(self.server.baseDir)
else:
if os.path.isdir(accountDir):
noNewswireFile = open(newswireBlockedFilename, "w+")
if noNewswireFile:
noNewswireFile.write('\n')
noNewswireFile.close()
+ refreshNewswire(self.server.baseDir)
+ usersPathStr = \
+ usersPath + '/' + self.server.defaultTimeline + \
+ '?page=' + str(pageNumber)
+ self._redirect_headers(usersPathStr, cookie,
+ callingDomain)
+ self.server.POSTbusy = False
+ return
+
+ # person options screen, permission to post to featured articles
+ # See htmlPersonOptions
+ if '&submitPostToFeatures=' in optionsConfirmParams:
+ adminNickname = getConfigParam(self.server.baseDir, 'admin')
+ if (chooserNickname != optionsNickname and
+ (chooserNickname == adminNickname or
+ (isModerator(self.server.baseDir, chooserNickname) and
+ not isModerator(self.server.baseDir, optionsNickname)))):
+ postsToFeatures = None
+ if 'postsToFeatures=' in optionsConfirmParams:
+ postsToFeatures = \
+ optionsConfirmParams.split('postsToFeatures=')[1]
+ if '&' in postsToFeatures:
+ postsToFeatures = postsToFeatures.split('&')[0]
+ accountDir = self.server.baseDir + '/accounts/' + \
+ optionsNickname + '@' + optionsDomain
+ featuresBlockedFilename = accountDir + '/.nofeatures'
+ if postsToFeatures == 'on':
+ if os.path.isfile(featuresBlockedFilename):
+ os.remove(featuresBlockedFilename)
+ refreshNewswire(self.server.baseDir)
+ else:
+ if os.path.isdir(accountDir):
+ noFeaturesFile = open(featuresBlockedFilename, "w+")
+ if noFeaturesFile:
+ noFeaturesFile.write('\n')
+ noFeaturesFile.close()
+ refreshNewswire(self.server.baseDir)
usersPathStr = \
usersPath + '/' + self.server.defaultTimeline + \
'?page=' + str(pageNumber)
@@ -4472,6 +4544,18 @@ class PubServer(BaseHTTPRequestHandler):
setConfigParam(baseDir, "verifyAllSignatures",
verifyAllSignatures)
+ brochMode = False
+ if fields.get('brochMode'):
+ if fields['brochMode'] == 'on':
+ brochMode = True
+ currBrochMode = \
+ getConfigParam(baseDir, "brochMode")
+ if brochMode != currBrochMode:
+ setBrochMode(self.server.baseDir,
+ self.server.domainFull,
+ brochMode)
+ setConfigParam(baseDir, "brochMode", brochMode)
+
# change moderators list
if fields.get('moderators'):
if path.startswith('/users/' +
@@ -5449,6 +5533,15 @@ class PubServer(BaseHTTPRequestHandler):
PGPfingerprint = getPGPfingerprint(actorJson)
if actorJson.get('alsoKnownAs'):
alsoKnownAs = actorJson['alsoKnownAs']
+
+ if self.server.session:
+ checkForChangedActor(self.server.session,
+ self.server.baseDir,
+ self.server.httpPrefix,
+ self.server.domainFull,
+ optionsActor, optionsProfileUrl,
+ self.server.personCache, 5)
+
msg = htmlPersonOptions(self.server.defaultTimeline,
self.server.cssCache,
self.server.translate,
@@ -5468,7 +5561,9 @@ class PubServer(BaseHTTPRequestHandler):
self.server.dormantMonths,
backToPath,
lockedAccount,
- movedTo, alsoKnownAs).encode('utf-8')
+ movedTo, alsoKnownAs,
+ self.server.textModeBanner,
+ self.server.newsInstance).encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
cookie, callingDomain)
@@ -10010,6 +10105,16 @@ class PubServer(BaseHTTPRequestHandler):
# replace https://domain/@nick with https://domain/users/nick
if self.path.startswith('/@'):
self.path = self.path.replace('/@', '/users/')
+ # replace https://domain/@nick/statusnumber
+ # with https://domain/users/nick/statuses/statusnumber
+ nickname = self.path.split('/users/')[1]
+ if '/' in nickname:
+ statusNumberStr = nickname.split('/')[1]
+ if statusNumberStr.isdigit():
+ nickname = nickname.split('/')[0]
+ self.path = \
+ self.path.replace('/users/' + nickname + '/',
+ '/users/' + nickname + '/statuses/')
# turn off dropdowns on new post screen
noDropDown = False
@@ -10038,9 +10143,13 @@ class PubServer(BaseHTTPRequestHandler):
# manifest for progressive web apps
if '/manifest.json' in self.path:
- self._progressiveWebAppManifest(callingDomain,
- GETstartTime, GETtimings)
- return
+ if self._hasAccept(callingDomain):
+ if not self._requestHTTP():
+ self._progressiveWebAppManifest(callingDomain,
+ GETstartTime, GETtimings)
+ return
+ else:
+ self.path = '/'
# default newswire favicon, for links to sites which
# have no favicon
@@ -11084,7 +11193,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.translate,
self.server.baseDir, self.path,
self.server.httpPrefix,
- self.server.domainFull).encode('utf-8')
+ self.server.domainFull,
+ self.server.textModeBanner).encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen, cookie, callingDomain)
self._write(msg)
@@ -12373,7 +12483,7 @@ class PubServer(BaseHTTPRequestHandler):
fields['replyTo'], fields['replyTo'],
fields['subject'], fields['schedulePost'],
fields['eventDate'], fields['eventTime'],
- fields['location'])
+ fields['location'], False)
if messageJson:
if fields['schedulePost']:
return 1
@@ -12419,6 +12529,12 @@ class PubServer(BaseHTTPRequestHandler):
return 1
else:
return -1
+ if not fields['subject']:
+ print('WARN: blog posts must have a title')
+ return -1
+ if not fields['message']:
+ print('WARN: blog posts must have content')
+ return -1
# submit button on newblog screen
messageJson = \
createBlogPost(self.server.baseDir, nickname,
@@ -12438,6 +12554,7 @@ class PubServer(BaseHTTPRequestHandler):
if fields['schedulePost']:
return 1
if self._postToOutbox(messageJson, __version__, nickname):
+ refreshNewswire(self.server.baseDir)
populateReplies(self.server.baseDir,
self.server.httpPrefix,
self.server.domainFull,
@@ -13820,7 +13937,8 @@ def loadTokens(baseDir: str, tokensDict: {}, tokensLookup: {}) -> None:
break
-def runDaemon(verifyAllSignatures: bool,
+def runDaemon(brochMode: bool,
+ verifyAllSignatures: bool,
sendThreadsTimeoutMins: int,
dormantMonths: int,
maxNewswirePosts: int,
@@ -14073,6 +14191,9 @@ def runDaemon(verifyAllSignatures: bool,
# cache to store css files
httpd.cssCache = {}
+ # whether to enable broch mode, which locks down the instance
+ setBrochMode(baseDir, httpd.domainFull, brochMode)
+
if not os.path.isdir(baseDir + '/accounts/inbox@' + domain):
print('Creating shared inbox: inbox@' + domain)
createSharedInbox(baseDir, 'inbox', domain, port, httpPrefix)
diff --git a/emoji/copyleft.png b/emoji/1F12F.png
similarity index 100%
rename from emoji/copyleft.png
rename to emoji/1F12F.png
diff --git a/emoji/android.png b/emoji/android.png
new file mode 100644
index 000000000..2fb586588
Binary files /dev/null and b/emoji/android.png differ
diff --git a/emoji/default_emoji.json b/emoji/default_emoji.json
index 9f86c0384..f241708d0 100644
--- a/emoji/default_emoji.json
+++ b/emoji/default_emoji.json
@@ -1,4 +1,5 @@
{
+ "android": "android",
"popcorn": "1F37F",
"1stplacemedal": "1F947",
"abbutton": "1F18E",
diff --git a/epicyon-calendar.css b/epicyon-calendar.css
index 223f3836a..f9d48b826 100644
--- a/epicyon-calendar.css
+++ b/epicyon-calendar.css
@@ -84,6 +84,14 @@ a:focus {
border: 2px solid var(--focus-color);
}
+.transparent {
+ color: transparent;
+ background: transparent;
+ font-size: 0px;
+ line-height: 0px;
+ height: 0px;
+}
+
.calendar__day__header,
.calendar__day__cell {
border: 2px solid var(--lines-color);
diff --git a/epicyon-options.css b/epicyon-options.css
index aaff9989b..f7093c1a7 100644
--- a/epicyon-options.css
+++ b/epicyon-options.css
@@ -98,6 +98,14 @@ a:focus {
border: 2px solid var(--focus-color);
}
+.transparent {
+ color: transparent;
+ background: transparent;
+ font-size: 0px;
+ line-height: 0px;
+ height: 0px;
+}
+
.follow {
height: 100%;
position: relative;
diff --git a/epicyon-profile.css b/epicyon-profile.css
index 282aed28f..f5af28671 100644
--- a/epicyon-profile.css
+++ b/epicyon-profile.css
@@ -87,7 +87,7 @@
--quote-right-margin: 0.1em;
--quote-font-weight: normal;
--quote-font-size: 120%;
- --line-spacing: 130%;
+ --line-spacing: 180%;
--line-spacing-newswire: 120%;
--newswire-item-moderated-color: white;
--newswire-date-moderated-color: white;
@@ -106,11 +106,11 @@
--column-left-icons-margin: 0;
--column-right-border-width: 0;
--column-left-border-color: black;
- --column-left-icon-size: 20%;
+ --column-left-icon-size: 2.1vw;
--column-left-icon-size-mobile: 10%;
--column-left-image-width-mobile: 40vw;
--column-right-image-width-mobile: 100vw;
- --column-right-icon-size: 20%;
+ --column-right-icon-size: 2.1vw;
--column-right-icon-size-mobile: 10%;
--newswire-date-color: white;
--newswire-voted-background-color: black;
diff --git a/epicyon.py b/epicyon.py
index 1a741302c..cf0289c65 100644
--- a/epicyon.py
+++ b/epicyon.py
@@ -279,6 +279,11 @@ parser.add_argument("--verifyAllSignatures",
const=True, default=False,
help="Whether to require that all incoming " +
"posts have valid jsonld signatures")
+parser.add_argument("--brochMode",
+ dest='brochMode',
+ type=str2bool, nargs='?',
+ const=True, default=False,
+ help="Enable broch mode")
parser.add_argument("--noapproval", type=str2bool, nargs='?',
const=True, default=False,
help="Allow followers without approval")
@@ -2292,6 +2297,11 @@ verifyAllSignatures = \
if verifyAllSignatures is not None:
args.verifyAllSignatures = bool(verifyAllSignatures)
+brochMode = \
+ getConfigParam(baseDir, 'brochMode')
+if brochMode is not None:
+ args.brochMode = bool(brochMode)
+
YTDomain = getConfigParam(baseDir, 'youtubedomain')
if YTDomain:
if '://' in YTDomain:
@@ -2305,7 +2315,8 @@ if setTheme(baseDir, themeName, domain, args.allowLocalNetworkAccess):
print('Theme set to ' + themeName)
if __name__ == "__main__":
- runDaemon(args.verifyAllSignatures,
+ runDaemon(args.brochMode,
+ args.verifyAllSignatures,
args.sendThreadsTimeoutMins,
args.dormantMonths,
args.maxNewswirePosts,
diff --git a/inbox.py b/inbox.py
index 26d9e5268..86507b87d 100644
--- a/inbox.py
+++ b/inbox.py
@@ -51,6 +51,7 @@ from bookmarks import updateBookmarksCollection
from bookmarks import undoBookmarksCollectionEntry
from blocking import isBlocked
from blocking import isBlockedDomain
+from blocking import brochModeLapses
from filters import isFiltered
from utils import updateAnnounceCollection
from utils import undoAnnounceCollectionEntry
@@ -2518,6 +2519,8 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int,
# heartbeat to monitor whether the inbox queue is running
heartBeatCtr += 5
if heartBeatCtr >= 10:
+ # turn off broch mode after it has timed out
+ brochModeLapses(baseDir)
print('>>> Heartbeat Q:' + str(len(queue)) + ' ' +
'{:%F %T}'.format(datetime.datetime.now()))
heartBeatCtr = 0
@@ -2726,6 +2729,7 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int,
print('DEBUG: checking http header signature')
pprint(queueJson['httpHeaders'])
postStr = json.dumps(queueJson['post'])
+ httpSignatureFailed = False
if not verifyPostHeaders(httpPrefix,
pubKey,
queueJson['httpHeaders'],
@@ -2733,19 +2737,17 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int,
queueJson['digest'],
postStr,
debug):
+ httpSignatureFailed = True
print('Queue: Header signature check failed')
- pprint(queueJson['httpHeaders'])
- if os.path.isfile(queueFilename):
- os.remove(queueFilename)
- if len(queue) > 0:
- queue.pop(0)
- continue
-
- if debug:
- print('DEBUG: http header signature check success')
+ if debug:
+ pprint(queueJson['httpHeaders'])
+ else:
+ if debug:
+ print('DEBUG: http header signature check success')
# check if a json signature exists on this post
- checkJsonSignature = False
+ hasJsonSignature = False
+ jwebsigType = None
originalJson = queueJson['original']
if originalJson.get('@context') and \
originalJson.get('signature'):
@@ -2754,41 +2756,59 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int,
jwebsig = originalJson['signature']
# signature exists and is of the expected type
if jwebsig.get('type') and jwebsig.get('signatureValue'):
- if jwebsig['type'] == 'RsaSignature2017':
+ jwebsigType = jwebsig['type']
+ if jwebsigType == 'RsaSignature2017':
if hasValidContext(originalJson):
- checkJsonSignature = True
+ hasJsonSignature = True
else:
print('unrecognised @context: ' +
str(originalJson['@context']))
# strict enforcement of json signatures
- if verifyAllSignatures and \
- not checkJsonSignature:
- print('inbox post does not have a jsonld signature ' +
- keyId + ' ' + str(originalJson))
- if os.path.isfile(queueFilename):
- os.remove(queueFilename)
- if len(queue) > 0:
- queue.pop(0)
- continue
-
- if checkJsonSignature and verifyAllSignatures:
- # use the original json message received, not one which may have
- # been modified along the way
- if not verifyJsonSignature(originalJson, pubKey):
- if debug:
- print('WARN: jsonld inbox signature check failed ' +
- keyId + ' ' + pubKey + ' ' + str(originalJson))
+ if not hasJsonSignature:
+ if httpSignatureFailed:
+ if jwebsigType:
+ print('Queue: Header signature check failed and does ' +
+ 'not have a recognised jsonld signature type ' +
+ jwebsigType)
else:
- print('WARN: jsonld inbox signature check failed ' +
- keyId)
+ print('Queue: Header signature check failed and ' +
+ 'does not have jsonld signature')
+ if debug:
+ pprint(queueJson['httpHeaders'])
+
+ if verifyAllSignatures:
+ print('Queue: inbox post does not have a jsonld signature ' +
+ keyId + ' ' + str(originalJson))
+
+ if httpSignatureFailed or verifyAllSignatures:
if os.path.isfile(queueFilename):
os.remove(queueFilename)
if len(queue) > 0:
queue.pop(0)
continue
- else:
- print('jsonld inbox signature check success ' + keyId)
+ else:
+ if httpSignatureFailed or verifyAllSignatures:
+ # use the original json message received, not one which
+ # may have been modified along the way
+ if not verifyJsonSignature(originalJson, pubKey):
+ if debug:
+ print('WARN: jsonld inbox signature check failed ' +
+ keyId + ' ' + pubKey + ' ' + str(originalJson))
+ else:
+ print('WARN: jsonld inbox signature check failed ' +
+ keyId)
+ if os.path.isfile(queueFilename):
+ os.remove(queueFilename)
+ if len(queue) > 0:
+ queue.pop(0)
+ continue
+ else:
+ if httpSignatureFailed:
+ print('jsonld inbox signature check success ' +
+ 'via relay ' + keyId)
+ else:
+ print('jsonld inbox signature check success ' + keyId)
# set the id to the same as the post filename
# This makes the filename and the id consistent
diff --git a/newsdaemon.py b/newsdaemon.py
index 84f2f4bc4..a32628428 100644
--- a/newsdaemon.py
+++ b/newsdaemon.py
@@ -660,6 +660,7 @@ def runNewswireDaemon(baseDir: str, httpd,
"""Periodically updates RSS feeds
"""
newswireStateFilename = baseDir + '/accounts/.newswirestate.json'
+ refreshFilename = baseDir + '/accounts/.refresh_newswire'
# initial sleep to allow the system to start up
time.sleep(50)
@@ -722,7 +723,16 @@ def runNewswireDaemon(baseDir: str, httpd,
httpd.maxNewsPosts)
# wait a while before the next feeds update
- time.sleep(1200)
+ for tick in range(120):
+ time.sleep(10)
+ # if a new blog post has been created then stop
+ # waiting and recalculate the newswire
+ if os.path.isfile(refreshFilename):
+ try:
+ os.remove(refreshFilename)
+ except BaseException:
+ pass
+ break
def runNewswireWatchdog(projectVersion: str, httpd) -> None:
diff --git a/newswire.py b/newswire.py
index ccaf983bf..29e24e3b7 100644
--- a/newswire.py
+++ b/newswire.py
@@ -7,6 +7,7 @@ __email__ = "bob@freedombone.net"
__status__ = "Production"
import os
+import json
import requests
from socket import error as SocketError
import errno
@@ -301,6 +302,7 @@ def _xml2StrToDict(baseDir: str, domain: str, xmlStr: str,
continue
title = rssItem.split('
')[1]
title = _removeCDATA(title.split(' ')[0])
+ title = removeHtml(title)
description = ''
if '' in rssItem and ' ' in rssItem:
description = rssItem.split('')[1]
@@ -332,12 +334,14 @@ def _xml2StrToDict(baseDir: str, domain: str, xmlStr: str,
result, pubDateStr,
title, link,
votesStatus, postFilename,
- description, moderated, mirrored)
+ description, moderated,
+ mirrored)
postCtr += 1
if postCtr >= maxPostsPerSource:
break
if postCtr > 0:
- print('Added ' + str(postCtr) + ' rss 2.0 feed items to newswire')
+ print('Added ' + str(postCtr) +
+ ' rss 2.0 feed items to newswire')
return result
@@ -385,6 +389,7 @@ def _xml1StrToDict(baseDir: str, domain: str, xmlStr: str,
continue
title = rssItem.split('')[1]
title = _removeCDATA(title.split(' ')[0])
+ title = removeHtml(title)
description = ''
if '' in rssItem and ' ' in rssItem:
description = rssItem.split('')[1]
@@ -416,12 +421,14 @@ def _xml1StrToDict(baseDir: str, domain: str, xmlStr: str,
result, pubDateStr,
title, link,
votesStatus, postFilename,
- description, moderated, mirrored)
+ description, moderated,
+ mirrored)
postCtr += 1
if postCtr >= maxPostsPerSource:
break
if postCtr > 0:
- print('Added ' + str(postCtr) + ' rss 1.0 feed items to newswire')
+ print('Added ' + str(postCtr) +
+ ' rss 1.0 feed items to newswire')
return result
@@ -457,6 +464,7 @@ def _atomFeedToDict(baseDir: str, domain: str, xmlStr: str,
continue
title = atomItem.split('')[1]
title = _removeCDATA(title.split(' ')[0])
+ title = removeHtml(title)
description = ''
if '' in atomItem and ' ' in atomItem:
description = atomItem.split('')[1]
@@ -488,12 +496,124 @@ def _atomFeedToDict(baseDir: str, domain: str, xmlStr: str,
result, pubDateStr,
title, link,
votesStatus, postFilename,
- description, moderated, mirrored)
+ description, moderated,
+ mirrored)
postCtr += 1
if postCtr >= maxPostsPerSource:
break
if postCtr > 0:
- print('Added ' + str(postCtr) + ' atom feed items to newswire')
+ print('Added ' + str(postCtr) +
+ ' atom feed items to newswire')
+ return result
+
+
+def _jsonFeedV1ToDict(baseDir: str, domain: str, xmlStr: str,
+ moderated: bool, mirrored: bool,
+ maxPostsPerSource: int,
+ maxFeedItemSizeKb: int) -> {}:
+ """Converts a json feed string to a dictionary
+ See https://jsonfeed.org/version/1.1
+ """
+ if '"items"' not in xmlStr:
+ return {}
+ try:
+ feedJson = json.loads(xmlStr)
+ except BaseException:
+ return {}
+ maxBytes = maxFeedItemSizeKb * 1024
+ if not feedJson.get('version'):
+ return {}
+ if not feedJson['version'].startswith('https://jsonfeed.org/version/1'):
+ return {}
+ if not feedJson.get('items'):
+ return {}
+ if not isinstance(feedJson['items'], list):
+ return {}
+ postCtr = 0
+ result = {}
+ for jsonFeedItem in feedJson['items']:
+ if not jsonFeedItem:
+ continue
+ if not isinstance(jsonFeedItem, dict):
+ continue
+ if not jsonFeedItem.get('url'):
+ continue
+ if not isinstance(jsonFeedItem['url'], str):
+ continue
+ if not jsonFeedItem.get('date_published'):
+ if not jsonFeedItem.get('date_modified'):
+ continue
+ if not jsonFeedItem.get('content_text'):
+ if not jsonFeedItem.get('content_html'):
+ continue
+ if jsonFeedItem.get('content_html'):
+ if not isinstance(jsonFeedItem['content_html'], str):
+ continue
+ title = removeHtml(jsonFeedItem['content_html'])
+ else:
+ if not isinstance(jsonFeedItem['content_text'], str):
+ continue
+ title = removeHtml(jsonFeedItem['content_text'])
+ if len(title) > maxBytes:
+ print('WARN: json feed title is too long')
+ continue
+ description = ''
+ if jsonFeedItem.get('description'):
+ if not isinstance(jsonFeedItem['description'], str):
+ continue
+ description = removeHtml(jsonFeedItem['description'])
+ if len(description) > maxBytes:
+ print('WARN: json feed description is too long')
+ continue
+ if jsonFeedItem.get('tags'):
+ if isinstance(jsonFeedItem['tags'], list):
+ for tagName in jsonFeedItem['tags']:
+ if not isinstance(tagName, str):
+ continue
+ if ' ' in tagName:
+ continue
+ if not tagName.startswith('#'):
+ tagName = '#' + tagName
+ if tagName not in description:
+ description += ' ' + tagName
+
+ link = jsonFeedItem['url']
+ if '://' not in link:
+ continue
+ if len(link) > maxBytes:
+ print('WARN: json feed link is too long')
+ continue
+ itemDomain = link.split('://')[1]
+ if '/' in itemDomain:
+ itemDomain = itemDomain.split('/')[0]
+ if isBlockedDomain(baseDir, itemDomain):
+ continue
+ if jsonFeedItem.get('date_published'):
+ if not isinstance(jsonFeedItem['date_published'], str):
+ continue
+ pubDate = jsonFeedItem['date_published']
+ else:
+ if not isinstance(jsonFeedItem['date_modified'], str):
+ continue
+ pubDate = jsonFeedItem['date_modified']
+
+ pubDateStr = parseFeedDate(pubDate)
+ if pubDateStr:
+ if _validFeedDate(pubDateStr):
+ postFilename = ''
+ votesStatus = []
+ _addNewswireDictEntry(baseDir, domain,
+ result, pubDateStr,
+ title, link,
+ votesStatus, postFilename,
+ description, moderated,
+ mirrored)
+ postCtr += 1
+ if postCtr >= maxPostsPerSource:
+ break
+ if postCtr > 0:
+ print('Added ' + str(postCtr) +
+ ' json feed items to newswire')
return result
@@ -593,6 +713,10 @@ def _xmlStrToDict(baseDir: str, domain: str, xmlStr: str,
return _atomFeedToDict(baseDir, domain,
xmlStr, moderated, mirrored,
maxPostsPerSource, maxFeedItemSizeKb)
+ elif 'https://jsonfeed.org/version/1' in xmlStr:
+ return _jsonFeedV1ToDict(baseDir, domain,
+ xmlStr, moderated, mirrored,
+ maxPostsPerSource, maxFeedItemSizeKb)
return {}
@@ -794,7 +918,7 @@ def _addAccountBlogsToNewswire(baseDir: str, nickname: str, domain: str,
locatePost(baseDir, nickname,
domain, postUrl, False)
if not fullPostFilename:
- print('Unable to locate post ' + postUrl)
+ print('Unable to locate post for newswire ' + postUrl)
ctr += 1
if ctr >= maxBlogsPerAccount:
break
@@ -840,7 +964,7 @@ def _addBlogsToNewswire(baseDir: str, domain: str, newswire: {},
for handle in dirs:
if '@' not in handle:
continue
- if 'inbox@' in handle:
+ if 'inbox@' in handle or 'news@' in handle:
continue
nickname = handle.split('@')[0]
diff --git a/outbox.py b/outbox.py
index e50000386..11d595c6e 100644
--- a/outbox.py
+++ b/outbox.py
@@ -14,10 +14,12 @@ from posts import outboxMessageCreateWrap
from posts import savePostToBox
from posts import sendToFollowersThread
from posts import sendToNamedAddresses
+from utils import getLocalNetworkAddresses
from utils import getFullDomain
from utils import removeIdEnding
from utils import getDomainFromActor
from utils import dangerousMarkup
+from utils import isFeaturedWriter
from blocking import isBlockedDomain
from blocking import outboxBlock
from blocking import outboxUndoBlock
@@ -113,6 +115,23 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str,
'Create does not have the "to" parameter ' +
str(messageJson))
return False
+
+ # actor should be a string
+ if not isinstance(messageJson['actor'], str):
+ return False
+
+ # actor should look like a url
+ if '://' not in messageJson['actor'] or \
+ '.' not in messageJson['actor']:
+ return False
+
+ # sent by an actor on a local network address?
+ if not allowLocalNetworkAccess:
+ localNetworkPatternList = getLocalNetworkAddresses()
+ for localNetworkPattern in localNetworkPatternList:
+ if localNetworkPattern in messageJson['actor']:
+ return False
+
testDomain, testPort = getDomainFromActor(messageJson['actor'])
testDomain = getFullDomain(testDomain, testPort)
if isBlockedDomain(baseDir, testDomain):
@@ -211,14 +230,16 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str,
# save all instance blogs to the news actor
if postToNickname != 'news' and outboxName == 'tlblogs':
if '/' in savedFilename:
- savedPostId = savedFilename.split('/')[-1]
- blogsDir = baseDir + '/accounts/news@' + domain + '/tlblogs'
- if not os.path.isdir(blogsDir):
- os.mkdir(blogsDir)
- copyfile(savedFilename, blogsDir + '/' + savedPostId)
- inboxUpdateIndex('tlblogs', baseDir,
- 'news@' + domain,
- savedFilename, debug)
+ if isFeaturedWriter(baseDir, postToNickname, domain):
+ savedPostId = savedFilename.split('/')[-1]
+ blogsDir = \
+ baseDir + '/accounts/news@' + domain + '/tlblogs'
+ if not os.path.isdir(blogsDir):
+ os.mkdir(blogsDir)
+ copyfile(savedFilename, blogsDir + '/' + savedPostId)
+ inboxUpdateIndex('tlblogs', baseDir,
+ 'news@' + domain,
+ savedFilename, debug)
# clear the citations file if it exists
citationsFilename = \
diff --git a/person.py b/person.py
index 71422ac42..b6bf9baf4 100644
--- a/person.py
+++ b/person.py
@@ -40,6 +40,7 @@ from utils import loadJson
from utils import saveJson
from utils import setConfigParam
from utils import getConfigParam
+from utils import refreshNewswire
def generateRSAKey() -> (str, str):
@@ -915,6 +916,9 @@ def removeAccount(baseDir: str, nickname: str,
os.remove(baseDir + '/wfdeactivated/' + handle + '.json')
if os.path.isdir(baseDir + '/sharefilesdeactivated/' + nickname):
shutil.rmtree(baseDir + '/sharefilesdeactivated/' + nickname)
+
+ refreshNewswire(baseDir)
+
return True
@@ -944,6 +948,9 @@ def deactivateAccount(baseDir: str, nickname: str, domain: str) -> bool:
os.mkdir(deactivatedSharefilesDir)
shutil.move(baseDir + '/sharefiles/' + nickname,
deactivatedSharefilesDir + '/' + nickname)
+
+ refreshNewswire(baseDir)
+
return os.path.isdir(deactivatedDir + '/' + nickname + '@' + domain)
@@ -970,6 +977,8 @@ def activateAccount(baseDir: str, nickname: str, domain: str) -> None:
shutil.move(deactivatedSharefilesDir + '/' + nickname,
baseDir + '/sharefiles/' + nickname)
+ refreshNewswire(baseDir)
+
def isPersonSnoozed(baseDir: str, nickname: str, domain: str,
snoozeActor: str) -> bool:
diff --git a/posts.py b/posts.py
index d44257bd2..fa0d72452 100644
--- a/posts.py
+++ b/posts.py
@@ -30,6 +30,8 @@ from session import postJsonString
from session import postImage
from webfinger import webfingerHandle
from httpsig import createSignedHeader
+from siteactive import siteIsActive
+from utils import removeInvalidChars
from utils import fileLastModified
from utils import isPublicPost
from utils import hasUsersPath
@@ -38,7 +40,6 @@ from utils import getFullDomain
from utils import getFollowersList
from utils import isEvil
from utils import removeIdEnding
-from utils import siteIsActive
from utils import getCachedPostFilename
from utils import getStatusNumber
from utils import createPersonDir
@@ -823,7 +824,7 @@ def validContentWarning(cw: str) -> str:
# so remove them
if '#' in cw:
cw = cw.replace('#', '').replace(' ', ' ')
- return cw
+ return removeInvalidChars(cw)
def _loadAutoCW(baseDir: str, nickname: str, domain: str) -> []:
@@ -880,6 +881,8 @@ def _createPostBase(baseDir: str, nickname: str, domain: str, port: int,
eventStatus=None, ticketUrl=None) -> {}:
"""Creates a message
"""
+ content = removeInvalidChars(content)
+
subject = _addAutoCW(baseDir, nickname, domain, subject, content)
if nickname != 'news':
@@ -924,7 +927,7 @@ def _createPostBase(baseDir: str, nickname: str, domain: str, port: int,
sensitive = False
summary = None
if subject:
- summary = validContentWarning(subject)
+ summary = removeInvalidChars(validContentWarning(subject))
sensitive = True
toRecipients = []
@@ -1047,6 +1050,8 @@ def _createPostBase(baseDir: str, nickname: str, domain: str, port: int,
postObjectType = 'Note'
if eventUUID:
postObjectType = 'Event'
+ if isArticle:
+ postObjectType = 'Article'
if not clientToServer:
actorUrl = httpPrefix + '://' + domain + '/users/' + nickname
@@ -1389,10 +1394,22 @@ def createPublicPost(baseDir: str,
imageDescription: str,
inReplyTo=None, inReplyToAtomUri=None, subject=None,
schedulePost=False,
- eventDate=None, eventTime=None, location=None) -> {}:
+ eventDate=None, eventTime=None, location=None,
+ isArticle=False) -> {}:
"""Public post
"""
domainFull = getFullDomain(domain, port)
+ isModerationReport = False
+ eventUUID = None
+ category = None
+ joinMode = None
+ endDate = None
+ endTime = None
+ maximumAttendeeCapacity = None
+ repliesModerationOption = None
+ anonymousParticipationEnabled = None
+ eventStatus = None
+ ticketUrl = None
return _createPostBase(baseDir, nickname, domain, port,
'https://www.w3.org/ns/activitystreams#Public',
httpPrefix + '://' + domainFull + '/users/' +
@@ -1401,10 +1418,45 @@ def createPublicPost(baseDir: str,
clientToServer, commentsEnabled,
attachImageFilename, mediaType,
imageDescription,
- False, False, inReplyTo, inReplyToAtomUri, subject,
+ isModerationReport, isArticle,
+ inReplyTo, inReplyToAtomUri, subject,
schedulePost, eventDate, eventTime, location,
- None, None, None, None, None,
- None, None, None, None, None)
+ eventUUID, category, joinMode, endDate, endTime,
+ maximumAttendeeCapacity,
+ repliesModerationOption,
+ anonymousParticipationEnabled,
+ eventStatus, ticketUrl)
+
+
+def _appendCitationsToBlogPost(baseDir: str,
+ nickname: str, domain: str,
+ blogJson: {}) -> None:
+ """Appends any citations to a new blog post
+ """
+ # append citations tags, stored in a file
+ citationsFilename = \
+ baseDir + '/accounts/' + \
+ nickname + '@' + domain + '/.citations.txt'
+ if not os.path.isfile(citationsFilename):
+ return
+ citationsSeparator = '#####'
+ with open(citationsFilename, "r") as f:
+ citations = f.readlines()
+ for line in citations:
+ if citationsSeparator not in line:
+ continue
+ sections = line.strip().split(citationsSeparator)
+ if len(sections) != 3:
+ continue
+ # dateStr = sections[0]
+ title = sections[1]
+ link = sections[2]
+ tagJson = {
+ "type": "Article",
+ "name": title,
+ "url": link
+ }
+ blogJson['object']['tag'].append(tagJson)
def createBlogPost(baseDir: str,
@@ -1416,7 +1468,7 @@ def createBlogPost(baseDir: str,
inReplyTo=None, inReplyToAtomUri=None, subject=None,
schedulePost=False,
eventDate=None, eventTime=None, location=None) -> {}:
- blog = \
+ blogJson = \
createPublicPost(baseDir,
nickname, domain, port, httpPrefix,
content, followersOnly, saveToFile,
@@ -1425,34 +1477,11 @@ def createBlogPost(baseDir: str,
imageDescription,
inReplyTo, inReplyToAtomUri, subject,
schedulePost,
- eventDate, eventTime, location)
- blog['object']['type'] = 'Article'
+ eventDate, eventTime, location, True)
- # append citations tags, stored in a file
- citationsFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + '/.citations.txt'
- if os.path.isfile(citationsFilename):
- citationsSeparator = '#####'
- with open(citationsFilename, "r") as f:
- citations = f.readlines()
- for line in citations:
- if citationsSeparator not in line:
- continue
- sections = line.strip().split(citationsSeparator)
- if len(sections) != 3:
- continue
- # dateStr = sections[0]
- title = sections[1]
- link = sections[2]
- tagJson = {
- "type": "Article",
- "name": title,
- "url": link
- }
- blog['object']['tag'].append(tagJson)
+ _appendCitationsToBlogPost(baseDir, nickname, domain, blogJson)
- return blog
+ return blogJson
def createNewsPost(baseDir: str,
@@ -1477,7 +1506,7 @@ def createNewsPost(baseDir: str,
imageDescription,
inReplyTo, inReplyToAtomUri, subject,
schedulePost,
- eventDate, eventTime, location)
+ eventDate, eventTime, location, True)
blog['object']['type'] = 'Article'
return blog
diff --git a/session.py b/session.py
index cf235fd76..d9c37907f 100644
--- a/session.py
+++ b/session.py
@@ -53,6 +53,37 @@ def createSession(proxyType: str):
return session
+def urlExists(session, url: str, timeoutSec=3,
+ httpPrefix='https', domain='testdomain') -> bool:
+ if not isinstance(url, str):
+ print('url: ' + str(url))
+ print('ERROR: urlExists failed, url should be a string')
+ return False
+ sessionParams = {}
+ sessionHeaders = {}
+ sessionHeaders['User-Agent'] = 'Epicyon/' + __version__
+ if domain:
+ sessionHeaders['User-Agent'] += \
+ '; +' + httpPrefix + '://' + domain + '/'
+ if not session:
+ print('WARN: urlExists failed, no session specified')
+ return True
+ try:
+ result = session.get(url, headers=sessionHeaders,
+ params=sessionParams,
+ timeout=timeoutSec)
+ if result:
+ if result.status_code == 200 or \
+ result.status_code == 304:
+ return True
+ else:
+ print('urlExists for ' + url + ' returned ' +
+ str(result.status_code))
+ except BaseException:
+ pass
+ return False
+
+
def getJson(session, url: str, headers: {}, params: {},
version='1.2.0', httpPrefix='https',
domain='testdomain') -> {}:
@@ -72,6 +103,7 @@ def getJson(session, url: str, headers: {}, params: {},
'; +' + httpPrefix + '://' + domain + '/'
if not session:
print('WARN: getJson failed, no session specified for getJson')
+ return None
try:
result = session.get(url, headers=sessionHeaders, params=sessionParams)
return result.json()
diff --git a/siteactive.py b/siteactive.py
new file mode 100644
index 000000000..ca530bf49
--- /dev/null
+++ b/siteactive.py
@@ -0,0 +1,121 @@
+__filename__ = "siteactive.py"
+__author__ = "Bob Mottram"
+__credits__ = ["webchk"]
+__license__ = "AGPL3+"
+__version__ = "1.2.0"
+__maintainer__ = "Bob Mottram"
+__email__ = "bob@freedombone.net"
+__status__ = "Production"
+
+import http.client
+from urllib.parse import urlparse
+import ssl
+
+
+class Result:
+ """Holds result of an URL check.
+
+ The redirect attribute is a Result object that the URL was redirected to.
+
+ The sitemap_urls attribute will contain a list of Result object if url
+ is a sitemap file and http_response() was run with parse set to True.
+ """
+ def __init__(self, url):
+ self.url = url
+ self.status = 0
+ self.desc = ''
+ self.headers = None
+ self.latency = 0
+ self.content = ''
+ self.redirect = None
+ self.sitemap_urls = None
+
+ def __repr__(self):
+ if self.status == 0:
+ return '{} ... {}'.format(self.url, self.desc)
+ return '{} ... {} {} ({})'.format(
+ self.url, self.status, self.desc, self.latency
+ )
+
+ def fill_headers(self, headers):
+ """Takes a list of tuples and converts it a dictionary."""
+ self.headers = {h[0]: h[1] for h in headers}
+
+
+def _siteActiveParseUrl(url):
+ """Returns an object with properties representing
+
+ scheme: URL scheme specifier
+ netloc: Network location part
+ path: Hierarchical path
+ params: Parameters for last path element
+ query: Query component
+ fragment: Fragment identifier
+ username: User name
+ password: Password
+ hostname: Host name (lower case)
+ port: Port number as integer, if present
+ """
+ loc = urlparse(url)
+
+ # if the scheme (http, https ...) is not available urlparse wont work
+ if loc.scheme == "":
+ url = "http://" + url
+ loc = urlparse(url)
+ return loc
+
+
+def _siteACtiveHttpConnect(loc, timeout: int):
+ """Connects to the host and returns an HTTP or HTTPS connections."""
+ if loc.scheme == "https":
+ ssl_context = ssl.SSLContext()
+ return http.client.HTTPSConnection(
+ loc.netloc, context=ssl_context, timeout=timeout)
+ return http.client.HTTPConnection(loc.netloc, timeout=timeout)
+
+
+def _siteActiveHttpRequest(loc, timeout: int):
+ """Performs a HTTP request and return response in a Result object.
+ """
+ conn = _siteACtiveHttpConnect(loc, timeout)
+ method = 'HEAD'
+
+ conn.request(method, loc.path)
+ resp = conn.getresponse()
+
+ result = Result(loc.geturl())
+ result.status = resp.status
+ result.desc = resp.reason
+ result.fill_headers(resp.getheaders())
+
+ conn.close()
+ return result
+
+
+def siteIsActive(url: str, timeout=10) -> bool:
+ """Returns true if the current url is resolvable.
+ This can be used to check that an instance is online before
+ trying to send posts to it.
+ """
+ if not url.startswith('http'):
+ return False
+ if '.onion/' in url or '.i2p/' in url or \
+ url.endswith('.onion') or \
+ url.endswith('.i2p'):
+ # skip this check for onion and i2p
+ return True
+
+ loc = _siteActiveParseUrl(url)
+ result = Result(url=url)
+
+ try:
+ result = _siteActiveHttpRequest(loc, timeout)
+
+ if 400 <= result.status < 500:
+ return result
+
+ return True
+
+ except BaseException:
+ pass
+ return False
diff --git a/tests.py b/tests.py
index 308e1fb0c..5a4bd233e 100644
--- a/tests.py
+++ b/tests.py
@@ -38,7 +38,7 @@ from utils import getFullDomain
from utils import validNickname
from utils import firstParagraphFromString
from utils import removeIdEnding
-from utils import siteIsActive
+from siteactive import siteIsActive
from utils import updateRecentPostsCache
from utils import followPerson
from utils import getNicknameFromActor
@@ -325,8 +325,10 @@ def createServerAlice(path: str, domain: str, port: int,
sendThreadsTimeoutMins = 30
maxFollowers = 10
verifyAllSignatures = True
+ brochMode = False
print('Server running: Alice')
- runDaemon(verifyAllSignatures,
+ runDaemon(brochMode,
+ verifyAllSignatures,
sendThreadsTimeoutMins,
dormantMonths, maxNewswirePosts,
allowLocalNetworkAccess,
@@ -420,8 +422,10 @@ def createServerBob(path: str, domain: str, port: int,
sendThreadsTimeoutMins = 30
maxFollowers = 10
verifyAllSignatures = True
+ brochMode = False
print('Server running: Bob')
- runDaemon(verifyAllSignatures,
+ runDaemon(brochMode,
+ verifyAllSignatures,
sendThreadsTimeoutMins,
dormantMonths, maxNewswirePosts,
allowLocalNetworkAccess,
@@ -469,8 +473,10 @@ def createServerEve(path: str, domain: str, port: int, federationList: [],
sendThreadsTimeoutMins = 30
maxFollowers = 10
verifyAllSignatures = True
+ brochMode = False
print('Server running: Eve')
- runDaemon(verifyAllSignatures,
+ runDaemon(brochMode,
+ verifyAllSignatures,
sendThreadsTimeoutMins,
dormantMonths, maxNewswirePosts,
allowLocalNetworkAccess,
@@ -2067,6 +2073,7 @@ def testJsonld():
def testSiteIsActive():
print('testSiteIsActive')
+ assert(siteIsActive('https://archive.org'))
assert(siteIsActive('https://mastodon.social'))
assert(not siteIsActive('https://notarealwebsite.a.b.c'))
@@ -2818,7 +2825,8 @@ def testFunctions():
'createServerBob',
'createServerEve',
'E2EEremoveDevice',
- 'setOrganizationScheme'
+ 'setOrganizationScheme',
+ 'fill_headers'
]
excludeImports = [
'link',
diff --git a/theme/default/banner.png b/theme/default/banner.png
index 46eb37e91..5860c6b9c 100644
Binary files a/theme/default/banner.png and b/theme/default/banner.png differ
diff --git a/theme/default/icons/separator_right.png b/theme/default/icons/separator_right.png
new file mode 100644
index 000000000..3f31f0fce
Binary files /dev/null and b/theme/default/icons/separator_right.png differ
diff --git a/theme/default/left_col_image.png b/theme/default/left_col_image.png
new file mode 100644
index 000000000..268bbd86f
Binary files /dev/null and b/theme/default/left_col_image.png differ
diff --git a/theme/default/right_col_image.png b/theme/default/right_col_image.png
new file mode 100644
index 000000000..268bbd86f
Binary files /dev/null and b/theme/default/right_col_image.png differ
diff --git a/theme/default/search_banner.png b/theme/default/search_banner.png
index 4f2811684..5860c6b9c 100644
Binary files a/theme/default/search_banner.png and b/theme/default/search_banner.png differ
diff --git a/theme/default/theme.json b/theme/default/theme.json
index 4d3a1cc47..62c1e5817 100644
--- a/theme/default/theme.json
+++ b/theme/default/theme.json
@@ -1,4 +1,6 @@
{
+ "post-separator-margin-top": "10px",
+ "post-separator-margin-bottom": "10px",
"newswire-publish-icon": "True",
"full-width-timeline-buttons": "False",
"icons-as-buttons": "False",
diff --git a/theme/indymediamodern/theme.json b/theme/indymediamodern/theme.json
index 91859ddaa..8f9d41143 100644
--- a/theme/indymediamodern/theme.json
+++ b/theme/indymediamodern/theme.json
@@ -6,7 +6,7 @@
"header-bg-color": "#efefef",
"verticals-width": "10px",
"newswire-publish-icon": "False",
- "line-spacing-newswire": "150%",
+ "line-spacing-newswire": "180%",
"full-width-timeline-buttons": "False",
"icons-as-buttons": "True",
"rss-icon-at-top": "False",
diff --git a/theme/night/theme.json b/theme/night/theme.json
index 1762cfd2a..f29c4cd12 100644
--- a/theme/night/theme.json
+++ b/theme/night/theme.json
@@ -60,7 +60,7 @@
"event-color": "#0481f5",
"event-background": "#00014a",
"quote-right-margin": "0",
- "line-spacing": "150%",
+ "line-spacing": "180%",
"header-font": "'solidaric'",
"*font-family": "'solidaric'",
"*src": "url('./fonts/solidaric.woff2') format('woff2')",
diff --git a/theme/rc3/theme.json b/theme/rc3/theme.json
index 4eb466d6f..95b54d546 100644
--- a/theme/rc3/theme.json
+++ b/theme/rc3/theme.json
@@ -84,7 +84,7 @@
"event-color": "#0481f5",
"event-background": "#00014a",
"quote-right-margin": "0",
- "line-spacing": "150%",
+ "line-spacing": "180%",
"*font-family": "'Montserrat-Regular'",
"*src": "url('./fonts/Montserrat-Regular.ttf') format('truetype')",
"**font-family": "'Orbitron'",
diff --git a/theme/solidaric/theme.json b/theme/solidaric/theme.json
index 69cc6e057..e3e46b6ff 100644
--- a/theme/solidaric/theme.json
+++ b/theme/solidaric/theme.json
@@ -80,7 +80,7 @@
"title-background": "#ccc",
"gallery-text-color": "#2d2c37",
"quote-right-margin": "0",
- "line-spacing": "150%",
+ "line-spacing": "180%",
"header-font": "'solidaric'",
"*font-family": "'solidaric'",
"*src": "url('./fonts/solidaric.woff2') format('woff2')",
diff --git a/translations/ar.json b/translations/ar.json
index b3a408074..fd4f8fbbe 100644
--- a/translations/ar.json
+++ b/translations/ar.json
@@ -367,5 +367,7 @@
"Skip to timeline": "تخطي إلى الجدول الزمني",
"Skip to Newswire": "انتقل إلى Newswire",
"Skip to Links": "تخطي إلى روابط الويب",
- "Publish a blog article": "نشر مقال بلوق"
+ "Publish a blog article": "نشر مقال بلوق",
+ "Featured writer": "كاتب متميز",
+ "Broch mode": "وضع الكتيب"
}
diff --git a/translations/ca.json b/translations/ca.json
index 232ee4300..2965f88d8 100644
--- a/translations/ca.json
+++ b/translations/ca.json
@@ -367,5 +367,7 @@
"Skip to timeline": "Ves a la cronologia",
"Skip to Newswire": "Vés a Newswire",
"Skip to Links": "Vés als enllaços web",
- "Publish a blog article": "Publicar un article del bloc"
+ "Publish a blog article": "Publicar un article del bloc",
+ "Featured writer": "Escriptor destacat",
+ "Broch mode": "Mode Broch"
}
diff --git a/translations/cy.json b/translations/cy.json
index 55b4b95e1..f1e31b718 100644
--- a/translations/cy.json
+++ b/translations/cy.json
@@ -367,5 +367,7 @@
"Skip to timeline": "Neidio i'r llinell amser",
"Skip to Newswire": "Neidio i Newswire",
"Skip to Links": "Neidio i Dolenni Gwe",
- "Publish a blog article": "Cyhoeddi erthygl blog"
+ "Publish a blog article": "Cyhoeddi erthygl blog",
+ "Featured writer": "Awdur dan sylw",
+ "Broch mode": "Modd Broch"
}
diff --git a/translations/de.json b/translations/de.json
index 140712ebd..4053a453c 100644
--- a/translations/de.json
+++ b/translations/de.json
@@ -367,5 +367,7 @@
"Skip to timeline": "Zur Zeitleiste springen",
"Skip to Newswire": "Springe zu Newswire",
"Skip to Links": "Springe zu Weblinks",
- "Publish a blog article": "Veröffentlichen Sie einen Blog-Artikel"
+ "Publish a blog article": "Veröffentlichen Sie einen Blog-Artikel",
+ "Featured writer": "Ausgewählter Schriftsteller",
+ "Broch mode": "Broch-Modus"
}
diff --git a/translations/en.json b/translations/en.json
index 77da14052..d376154ee 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -367,5 +367,7 @@
"Skip to timeline": "Skip to timeline",
"Skip to Newswire": "Skip to Newswire",
"Skip to Links": "Skip to Links",
- "Publish a blog article": "Publish a blog article"
+ "Publish a blog article": "Publish a blog article",
+ "Featured writer": "Featured writer",
+ "Broch mode": "Broch mode"
}
diff --git a/translations/es.json b/translations/es.json
index cf09d1446..135ff9c13 100644
--- a/translations/es.json
+++ b/translations/es.json
@@ -367,5 +367,7 @@
"Skip to timeline": "Saltar a la línea de tiempo",
"Skip to Newswire": "Saltar a Newswire",
"Skip to Links": "Saltar a enlaces web",
- "Publish a blog article": "Publica un artículo de blog"
+ "Publish a blog article": "Publica un artículo de blog",
+ "Featured writer": "Escritora destacada",
+ "Broch mode": "Modo broche"
}
diff --git a/translations/fr.json b/translations/fr.json
index ac51b24fa..308ba5e9b 100644
--- a/translations/fr.json
+++ b/translations/fr.json
@@ -367,5 +367,7 @@
"Skip to timeline": "Passer à la chronologie",
"Skip to Newswire": "Passer à Newswire",
"Skip to Links": "Passer aux liens Web",
- "Publish a blog article": "Publier un article de blog"
+ "Publish a blog article": "Publier un article de blog",
+ "Featured writer": "Écrivain en vedette",
+ "Broch mode": "Mode Broch"
}
diff --git a/translations/ga.json b/translations/ga.json
index 5cff4c525..92f67c50f 100644
--- a/translations/ga.json
+++ b/translations/ga.json
@@ -367,5 +367,7 @@
"Skip to timeline": "Scipeáil chuig an amlíne",
"Skip to Newswire": "Scipeáil chuig Newswire",
"Skip to Links": "Scipeáil chuig Naisc Ghréasáin",
- "Publish a blog article": "Foilsigh alt blagála"
+ "Publish a blog article": "Foilsigh alt blagála",
+ "Featured writer": "Scríbhneoir mór le rá",
+ "Broch mode": "Modh broch"
}
diff --git a/translations/hi.json b/translations/hi.json
index a83d8d5a9..82c401422 100644
--- a/translations/hi.json
+++ b/translations/hi.json
@@ -367,5 +367,7 @@
"Skip to timeline": "टाइमलाइन पर जाएं",
"Skip to Newswire": "Newswire पर जाएं",
"Skip to Links": "वेब लिंक पर जाएं",
- "Publish a blog article": "एक ब्लॉग लेख प्रकाशित करें"
+ "Publish a blog article": "एक ब्लॉग लेख प्रकाशित करें",
+ "Featured writer": "फीचर्ड लेखक",
+ "Broch mode": "ब्रोच मोड"
}
diff --git a/translations/it.json b/translations/it.json
index e7082ff98..c189c2335 100644
--- a/translations/it.json
+++ b/translations/it.json
@@ -367,5 +367,7 @@
"Skip to timeline": "Passa alla sequenza temporale",
"Skip to Newswire": "Passa a Newswire",
"Skip to Links": "Passa a collegamenti Web",
- "Publish a blog article": "Pubblica un articolo sul blog"
+ "Publish a blog article": "Pubblica un articolo sul blog",
+ "Featured writer": "Scrittore in primo piano",
+ "Broch mode": "Modalità Broch"
}
diff --git a/translations/ja.json b/translations/ja.json
index 38d6685a4..49bfffbef 100644
--- a/translations/ja.json
+++ b/translations/ja.json
@@ -367,5 +367,7 @@
"Skip to timeline": "タイムラインにスキップ",
"Skip to Newswire": "Newswireにスキップ",
"Skip to Links": "Webリンクにスキップ",
- "Publish a blog article": "ブログ記事を公開する"
+ "Publish a blog article": "ブログ記事を公開する",
+ "Featured writer": "注目の作家",
+ "Broch mode": "ブロッホモード"
}
diff --git a/translations/oc.json b/translations/oc.json
index 7cd70a858..fa4620d9a 100644
--- a/translations/oc.json
+++ b/translations/oc.json
@@ -363,5 +363,7 @@
"Skip to timeline": "Skip to timeline",
"Skip to Newswire": "Skip to Newswire",
"Skip to Links": "Skip to Links",
- "Publish a blog article": "Publish a blog article"
+ "Publish a blog article": "Publish a blog article",
+ "Featured writer": "Featured writer",
+ "Broch mode": "Broch mode"
}
diff --git a/translations/pt.json b/translations/pt.json
index 2abe336bf..52694082b 100644
--- a/translations/pt.json
+++ b/translations/pt.json
@@ -367,5 +367,7 @@
"Skip to timeline": "Pular para a linha do tempo",
"Skip to Newswire": "Pular para Newswire",
"Skip to Links": "Pular para links da web",
- "Publish a blog article": "Publique um artigo de blog"
+ "Publish a blog article": "Publique um artigo de blog",
+ "Featured writer": "Escritor em destaque",
+ "Broch mode": "Modo broch"
}
diff --git a/translations/ru.json b/translations/ru.json
index b75628208..41c2d5a91 100644
--- a/translations/ru.json
+++ b/translations/ru.json
@@ -367,5 +367,7 @@
"Skip to timeline": "Перейти к временной шкале",
"Skip to Newswire": "Перейти к ленте новостей",
"Skip to Links": "Перейти к веб-ссылкам",
- "Publish a blog article": "Опубликовать статью в блоге"
+ "Publish a blog article": "Опубликовать статью в блоге",
+ "Featured writer": "Избранный писатель",
+ "Broch mode": "Брош режим"
}
diff --git a/translations/zh.json b/translations/zh.json
index ea0dcce39..f63deb616 100644
--- a/translations/zh.json
+++ b/translations/zh.json
@@ -367,5 +367,7 @@
"Skip to timeline": "跳到时间线",
"Skip to Newswire": "跳到新闻专线",
"Skip to Links": "跳到网页链接",
- "Publish a blog article": "发布博客文章"
+ "Publish a blog article": "发布博客文章",
+ "Featured writer": "特色作家",
+ "Broch mode": "断点模式"
}
diff --git a/utils.py b/utils.py
index 0f3d811cf..8f2348062 100644
--- a/utils.py
+++ b/utils.py
@@ -11,9 +11,6 @@ import time
import shutil
import datetime
import json
-from socket import error as SocketError
-import errno
-import urllib.request
import idna
from pprint import pprint
from calendar import monthrange
@@ -21,6 +18,34 @@ from followingCalendar import addPersonToCalendar
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
+# posts containing these strings will always get screened out,
+# both incoming and outgoing.
+# Could include dubious clacks or admin dogwhistles
+invalidCharacters = (
+ '卐', '卍', '࿕', '࿖', '࿗', '࿘'
+)
+
+
+def isFeaturedWriter(baseDir: str, nickname: str, domain: str) -> bool:
+ """Is the given account a featured writer, appearing in the features
+ timeline on news instances?
+ """
+ featuresBlockedFilename = \
+ baseDir + '/accounts/' + \
+ nickname + '@' + domain + '/.nofeatures'
+ return not os.path.isfile(featuresBlockedFilename)
+
+
+def refreshNewswire(baseDir: str):
+ """Causes the newswire to be updates after a change to user accounts
+ """
+ refreshNewswireFilename = baseDir + '/accounts/.refresh_newswire'
+ if os.path.isfile(refreshNewswireFilename):
+ return
+ refreshFile = open(refreshNewswireFilename, 'w+')
+ refreshFile.write('\n')
+ refreshFile.close()
+
def getSHA256(msg: str):
"""Returns a SHA256 hash of the given string
@@ -517,17 +542,23 @@ def isEvil(domain: str) -> bool:
def containsInvalidChars(jsonStr: str) -> bool:
"""Does the given json string contain invalid characters?
- e.g. dubious clacks/admin dogwhistles
"""
- invalidStrings = {
- '卐', '卍', '࿕', '࿖', '࿗', '࿘'
- }
- for isInvalid in invalidStrings:
+ for isInvalid in invalidCharacters:
if isInvalid in jsonStr:
return True
return False
+def removeInvalidChars(text: str) -> str:
+ """Removes any invalid characters from a string
+ """
+ for isInvalid in invalidCharacters:
+ if isInvalid not in text:
+ continue
+ text = text.replace(isInvalid, '')
+ return text
+
+
def createPersonDir(nickname: str, domain: str, baseDir: str,
dirname: str) -> str:
"""Create a directory for a person
@@ -574,6 +605,12 @@ def urlPermitted(url: str, federationList: []):
return False
+def getLocalNetworkAddresses() -> []:
+ """Returns patterns for local network address detection
+ """
+ return ('localhost', '127.0.', '192.168', '10.0.')
+
+
def dangerousMarkup(content: str, allowLocalNetworkAccess: bool) -> bool:
"""Returns true if the given content contains dangerous html markup
"""
@@ -584,7 +621,7 @@ def dangerousMarkup(content: str, allowLocalNetworkAccess: bool) -> bool:
contentSections = content.split('<')
invalidPartials = ()
if not allowLocalNetworkAccess:
- invalidPartials = ('localhost', '127.0.', '192.168', '10.0.')
+ invalidPartials = getLocalNetworkAddresses()
invalidStrings = ('script', 'canvas', 'style', 'abbr',
'frame', 'iframe', 'html', 'body',
'hr', 'allow-popups', 'allow-scripts')
@@ -1841,28 +1878,6 @@ def updateAnnounceCollection(recentPostsCache: {},
saveJson(postJsonObject, postFilename)
-def siteIsActive(url: str) -> bool:
- """Returns true if the current url is resolvable.
- This can be used to check that an instance is online before
- trying to send posts to it.
- """
- if not url.startswith('http'):
- return False
- if '.onion/' in url or '.i2p/' in url or \
- url.endswith('.onion') or \
- url.endswith('.i2p'):
- # skip this check for onion and i2p
- return True
- try:
- req = urllib.request.Request(url)
- urllib.request.urlopen(req, timeout=10) # nosec
- return True
- except SocketError as e:
- if e.errno == errno.ECONNRESET:
- print('WARN: connection was reset during siteIsActive')
- return False
-
-
def weekDayOfMonthStart(monthNumber: int, year: int) -> int:
"""Gets the day number of the first day of the month
1=sun, 7=sat
diff --git a/webapp_calendar.py b/webapp_calendar.py
index 0135cc08e..025614e79 100644
--- a/webapp_calendar.py
+++ b/webapp_calendar.py
@@ -21,6 +21,8 @@ from happening import getCalendarEvents
from webapp_utils import htmlHeaderWithExternalStyle
from webapp_utils import htmlFooter
from webapp_utils import getAltPath
+from webapp_utils import htmlHideFromScreenReader
+from webapp_utils import htmlKeyboardNavigation
def htmlCalendarDeleteConfirm(cssCache: {}, translate: {}, baseDir: str,
@@ -200,7 +202,8 @@ def _htmlCalendarDay(cssCache: {}, translate: {},
def htmlCalendar(cssCache: {}, translate: {},
baseDir: str, path: str,
- httpPrefix: str, domainFull: str) -> str:
+ httpPrefix: str, domainFull: str,
+ textModeBanner: str) -> str:
"""Show the calendar for a person
"""
domain = domainFull
@@ -297,8 +300,10 @@ def htmlCalendar(cssCache: {}, translate: {},
instanceTitle = \
getConfigParam(baseDir, 'instanceTitle')
- calendarStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle)
- calendarStr += '\n'
+ headerStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle)
+
+ # the main graphical calendar as a table
+ calendarStr = '\n'
calendarStr += '\n'
calendarStr += \
' ' + \
+ calendarStr += ' \n'
- calendarStr += ' \n'
- calendarStr += ' \n'
- calendarStr += ' \n'
- calendarStr += ' \n'
- calendarStr += ' \n'
- calendarStr += ' \n'
calendarStr += '\n'
calendarStr += '\n'
calendarStr += ' \n'
+ # beginning of the links used for accessibility
+ navLinks = {}
+ timelineLinkStr = htmlHideFromScreenReader('🏠') + ' ' + \
+ translate['Switch to timeline view']
+ navLinks[timelineLinkStr] = calActor + '/inbox'
+
dayOfMonth = 0
dow = weekDayOfMonthStart(monthNumber, year)
for weekOfMonth in range(1, 7):
@@ -358,8 +369,15 @@ def htmlCalendar(cssCache: {}, translate: {},
url = calActor + '/calendar?year=' + \
str(year) + '?month=' + \
str(monthNumber) + '?day=' + str(dayOfMonth)
- dayLink = '' + \
+ dayDescription = monthName + ' ' + str(dayOfMonth)
+ dayLink = ' ' + \
str(dayOfMonth) + ' '
+ # accessibility menu links
+ menuOptionStr = \
+ htmlHideFromScreenReader('📅') + ' ' + \
+ dayDescription
+ navLinks[menuOptionStr] = url
# there are events for this day
if not isToday:
calendarStr += \
@@ -387,5 +405,17 @@ def htmlCalendar(cssCache: {}, translate: {},
calendarStr += ' \n'
calendarStr += '
\n'
- calendarStr += htmlFooter()
- return calendarStr
+
+ # end of the links used for accessibility
+ nextMonthStr = \
+ htmlHideFromScreenReader('→') + ' ' + translate['Next month']
+ navLinks[nextMonthStr] = calActor + '/calendar?year=' + str(nextYear) + \
+ '?month=' + str(nextMonthNumber)
+ prevMonthStr = \
+ htmlHideFromScreenReader('←') + ' ' + translate['Previous month']
+ navLinks[prevMonthStr] = calActor + '/calendar?year=' + str(prevYear) + \
+ '?month=' + str(prevMonthNumber)
+ screenReaderCal = \
+ htmlKeyboardNavigation(textModeBanner, navLinks, monthName)
+
+ return headerStr + screenReaderCal + calendarStr + htmlFooter()
diff --git a/webapp_hashtagswarm.py b/webapp_hashtagswarm.py
index 19f08661f..f2ffb577d 100644
--- a/webapp_hashtagswarm.py
+++ b/webapp_hashtagswarm.py
@@ -203,8 +203,12 @@ def htmlHashTagSwarm(baseDir: str, actor: str, translate: {}) -> str:
categoryStr = \
getHashtagCategory(baseDir, hashTagName)
if len(categoryStr) < maxTagLength:
- if categoryStr not in categorySwarm:
- categorySwarm.append(categoryStr)
+ if '#' not in categoryStr and \
+ '&' not in categoryStr and \
+ '"' not in categoryStr and \
+ "'" not in categoryStr:
+ if categoryStr not in categorySwarm:
+ categorySwarm.append(categoryStr)
break
break
diff --git a/webapp_person_options.py b/webapp_person_options.py
index 3dc819f7b..75dd21045 100644
--- a/webapp_person_options.py
+++ b/webapp_person_options.py
@@ -17,6 +17,7 @@ from utils import isDormant
from utils import removeHtml
from utils import getDomainFromActor
from utils import getNicknameFromActor
+from utils import isFeaturedWriter
from blocking import isBlocked
from follow import isFollowerOfPerson
from follow import isFollowingActor
@@ -24,6 +25,7 @@ from followingCalendar import receivingCalendarEvents
from webapp_utils import htmlHeaderWithExternalStyle
from webapp_utils import htmlFooter
from webapp_utils import getBrokenLinkSubstitute
+from webapp_utils import htmlKeyboardNavigation
def htmlPersonOptions(defaultTimeline: str,
@@ -49,7 +51,9 @@ def htmlPersonOptions(defaultTimeline: str,
backToPath: str,
lockedAccount: bool,
movedTo: str,
- alsoKnownAs: []) -> str:
+ alsoKnownAs: [],
+ textModeBanner: str,
+ newsInstance: bool) -> str:
"""Show options for a person: view/follow/block/report
"""
optionsDomain, optionsPort = getDomainFromActor(optionsActor)
@@ -108,12 +112,13 @@ def htmlPersonOptions(defaultTimeline: str,
if donateUrl:
donateStr = \
' ' + \
+ ' tabindex="-1"">' + \
translate['Donate'] + ' \n'
instanceTitle = \
getConfigParam(baseDir, 'instanceTitle')
optionsStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle)
+ optionsStr += htmlKeyboardNavigation(textModeBanner, {})
optionsStr += ' \n'
optionsStr += '\n'
optionsStr += '
\n'
@@ -284,6 +289,24 @@ def htmlPersonOptions(defaultTimeline: str,
checkboxStr = checkboxStr.replace(' checked>', '>')
optionsStr += checkboxStr
+ # checkbox for permission to post to featured articles
+ if newsInstance and optionsDomainFull == domainFull:
+ adminNickname = getConfigParam(baseDir, 'admin')
+ if (nickname == adminNickname or
+ (isModerator(baseDir, nickname) and
+ not isModerator(baseDir, optionsNickname))):
+ checkboxStr = \
+ ' ' + \
+ translate['Featured writer'] + \
+ '\n ' + \
+ translate['Submit'] + ' \n'
+ if not isFeaturedWriter(baseDir, optionsNickname,
+ optionsDomain):
+ checkboxStr = checkboxStr.replace(' checked>', '>')
+ optionsStr += checkboxStr
+
optionsStr += optionsLinkStr
backPath = '/'
if nickname:
diff --git a/webapp_post.py b/webapp_post.py
index dd4c9a24b..b5646c416 100644
--- a/webapp_post.py
+++ b/webapp_post.py
@@ -327,9 +327,13 @@ def _getEditIconHtml(baseDir: str, nickname: str, domainFull: str,
"""
editStr = ''
actor = postJsonObject['actor']
- if (actor.endswith(domainFull + '/users/' + nickname) or
+ # This should either be a post which you created,
+ # or it could be generated from the newswire (see
+ # _addBlogsToNewswire) in which case anyone with
+ # editor status should be able to alter it
+ if (actor.endswith('/' + domainFull + '/users/' + nickname) or
(isEditor(baseDir, nickname) and
- actor.endswith(domainFull + '/users/news'))):
+ actor.endswith('/' + domainFull + '/users/news'))):
postId = postJsonObject['object']['id']
diff --git a/webapp_profile.py b/webapp_profile.py
index b4859a6d9..0d2fe362d 100644
--- a/webapp_profile.py
+++ b/webapp_profile.py
@@ -1278,6 +1278,16 @@ def htmlEditProfile(cssCache: {}, translate: {}, baseDir: str, path: str,
' ' + \
translate['Verify all signatures'] + ' \n'
+ if getConfigParam(baseDir, "brochMode"):
+ instanceStr += \
+ ' ' + \
+ translate['Broch mode'] + ' \n'
+ else:
+ instanceStr += \
+ ' ' + \
+ translate['Broch mode'] + ' \n'
instanceStr += '
'
moderators = ''
@@ -1745,6 +1755,10 @@ def _individualFollowAsHtml(translate: {},
if avatarUrl2:
avatarUrl = avatarUrl2
if displayName:
+ displayName = \
+ addEmojiToDisplayName(baseDir, httpPrefix,
+ actorNickname, domain,
+ displayName, False)
titleStr = displayName
if dormant:
diff --git a/webapp_timeline.py b/webapp_timeline.py
index f3707b826..b5a9a41b0 100644
--- a/webapp_timeline.py
+++ b/webapp_timeline.py
@@ -416,23 +416,23 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str,
translate['Mod']
navLinks = {
menuProfile: '/users/' + nickname,
- menuInbox: usersPath + '/inbox#timeline',
+ menuInbox: usersPath + '/inbox#timelineposts',
menuSearch: usersPath + '/search',
menuNewPost: usersPath + '/newpost',
menuCalendar: usersPath + '/calendar',
- menuDM: usersPath + '/dm#timeline',
- menuReplies: usersPath + '/tlreplies#timeline',
- menuOutbox: usersPath + '/inbox#timeline',
- menuBookmarks: usersPath + '/tlbookmarks#timeline',
- menuShares: usersPath + '/tlshares#timeline',
- menuBlogs: usersPath + '/tlblogs#timeline',
- # menuEvents: usersPath + '/tlevents#timeline',
- menuNewswire: '#newswire',
- menuLinks: '#links'
+ menuDM: usersPath + '/dm#timelineposts',
+ menuReplies: usersPath + '/tlreplies#timelineposts',
+ menuOutbox: usersPath + '/inbox#timelineposts',
+ menuBookmarks: usersPath + '/tlbookmarks#timelineposts',
+ menuShares: usersPath + '/tlshares#timelineposts',
+ menuBlogs: usersPath + '/tlblogs#timelineposts',
+ # menuEvents: usersPath + '/tlevents#timelineposts',
+ menuNewswire: usersPath + '/newswiremobile',
+ menuLinks: usersPath + '/linksmobile'
}
if moderator:
navLinks[menuModeration] = usersPath + '/moderation#modtimeline'
- tlStr += htmlKeyboardNavigation(textModeBanner, navLinks,
+ tlStr += htmlKeyboardNavigation(textModeBanner, navLinks, None,
usersPath, translate, followApprovals)
# banner and row of buttons
@@ -502,7 +502,7 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str,
calendarImage, followApprovals,
iconsAsButtons)
- tlStr += '
\n'
+ tlStr += '
\n'
# second row of buttons for moderator actions
if moderator and boxName == 'moderation':
diff --git a/webapp_utils.py b/webapp_utils.py
index 1511b1408..64b1cae89 100644
--- a/webapp_utils.py
+++ b/webapp_utils.py
@@ -887,26 +887,31 @@ def htmlHideFromScreenReader(htmlStr: str) -> str:
def htmlKeyboardNavigation(banner: str, links: {},
+ subHeading=None,
usersPath=None, translate=None,
followApprovals=False) -> str:
"""Given a set of links return the html for keyboard navigation
"""
- htmlStr = '
'
+ htmlStr = '\n'
if banner:
- htmlStr += ' ' + banner + ' '
+ htmlStr += '
\n' + banner + '\n \n'
+
+ if subHeading:
+ htmlStr += '
' + \
+ subHeading + ' \n'
# show new follower approvals
if usersPath and translate and followApprovals:
htmlStr += '
' + \
'' + \
translate['Approve follow requests'] + ' ' + \
- ' '
+ '
\n'
# show the list of links
for title, url in links.items():
htmlStr += '
' + \
'' + \
- str(title) + ' '
- htmlStr += '
'
+ str(title) + '\n'
+ htmlStr += ' \n'
return htmlStr
diff --git a/website/EN/index.html b/website/EN/index.html
index 6ec59cc09..98e6e9b34 100644
--- a/website/EN/index.html
+++ b/website/EN/index.html
@@ -1,6 +1,9 @@
+
+
+
+
+
Epicyon ActivityPub server
+