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

merge-requests/30/head
Bob Mottram 2021-02-16 11:03:05 +00:00
commit 524af7ef22
55 changed files with 1006 additions and 206 deletions

View File

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

View File

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

View File

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

View File

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

155
daemon.py
View File

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

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
emoji/android.png 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -1,4 +1,5 @@
{
"android": "android",
"popcorn": "1F37F",
"1stplacemedal": "1F947",
"abbutton": "1F18E",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('<title>')[1]
title = _removeCDATA(title.split('</title>')[0])
title = removeHtml(title)
description = ''
if '<description>' in rssItem and '</description>' in rssItem:
description = rssItem.split('<description>')[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('<title>')[1]
title = _removeCDATA(title.split('</title>')[0])
title = removeHtml(title)
description = ''
if '<description>' in rssItem and '</description>' in rssItem:
description = rssItem.split('<description>')[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('<title>')[1]
title = _removeCDATA(title.split('</title>')[0])
title = removeHtml(title)
description = ''
if '<summary>' in atomItem and '</summary>' in atomItem:
description = atomItem.split('<summary>')[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]

View File

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

View File

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

View File

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

View File

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

121
siteactive.py 100644
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 131 KiB

View File

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

View File

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

View File

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

View File

@ -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'",

View File

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

View File

@ -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": "وضع الكتيب"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "ब्रोच मोड"
}

View File

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

View File

@ -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": "ブロッホモード"
}

View File

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

View File

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

View File

@ -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": "Брош режим"
}

View File

@ -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": "断点模式"
}

View File

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

View File

@ -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 += '<main><table class="calendar">\n'
headerStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle)
# the main graphical calendar as a table
calendarStr = '<main><table class="calendar">\n'
calendarStr += '<caption class="calendar__banner--month">\n'
calendarStr += \
' <a href="' + calActor + '/calendar?year=' + str(prevYear) + \
@ -320,24 +325,30 @@ def htmlCalendar(cssCache: {}, translate: {},
calendarStr += '</caption>\n'
calendarStr += '<thead>\n'
calendarStr += '<tr>\n'
calendarStr += ' <th class="calendar__day__header">' + \
calendarStr += ' <th scope="col" class="calendar__day__header">' + \
translate['Sun'] + '</th>\n'
calendarStr += ' <th class="calendar__day__header">' + \
calendarStr += ' <th scope="col" class="calendar__day__header">' + \
translate['Mon'] + '</th>\n'
calendarStr += ' <th class="calendar__day__header">' + \
calendarStr += ' <th scope="col" class="calendar__day__header">' + \
translate['Tue'] + '</th>\n'
calendarStr += ' <th class="calendar__day__header">' + \
calendarStr += ' <th scope="col" class="calendar__day__header">' + \
translate['Wed'] + '</th>\n'
calendarStr += ' <th class="calendar__day__header">' + \
calendarStr += ' <th scope="col" class="calendar__day__header">' + \
translate['Thu'] + '</th>\n'
calendarStr += ' <th class="calendar__day__header">' + \
calendarStr += ' <th scope="col" class="calendar__day__header">' + \
translate['Fri'] + '</th>\n'
calendarStr += ' <th class="calendar__day__header">' + \
calendarStr += ' <th scope="col" class="calendar__day__header">' + \
translate['Sat'] + '</th>\n'
calendarStr += '</tr>\n'
calendarStr += '</thead>\n'
calendarStr += '<tbody>\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 = '<a href="' + url + '">' + \
dayDescription = monthName + ' ' + str(dayOfMonth)
dayLink = '<a href="' + url + '" ' + \
'title="' + dayDescription + '">' + \
str(dayOfMonth) + '</a>'
# 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 += '</tbody>\n'
calendarStr += '</table></main>\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()

View File

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

View File

@ -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 = \
' <a href="' + donateUrl + \
'"><button class="button" name="submitDonate">' + \
' tabindex="-1""><button class="button" name="submitDonate">' + \
translate['Donate'] + '</button></a>\n'
instanceTitle = \
getConfigParam(baseDir, 'instanceTitle')
optionsStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle)
optionsStr += htmlKeyboardNavigation(textModeBanner, {})
optionsStr += '<br><br>\n'
optionsStr += '<div class="options">\n'
optionsStr += ' <div class="optionsAvatar">\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 = \
' <input type="checkbox" ' + \
'class="profilecheckbox" name="postsToFeatures" checked> ' + \
translate['Featured writer'] + \
'\n <button type="submit" class="buttonsmall" ' + \
'name="submitPostToFeatures">' + \
translate['Submit'] + '</button><br>\n'
if not isFeaturedWriter(baseDir, optionsNickname,
optionsDomain):
checkboxStr = checkboxStr.replace(' checked>', '>')
optionsStr += checkboxStr
optionsStr += optionsLinkStr
backPath = '/'
if nickname:

View File

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

View File

@ -1278,6 +1278,16 @@ def htmlEditProfile(cssCache: {}, translate: {}, baseDir: str, path: str,
' <input type="checkbox" class="profilecheckbox" ' + \
'name="verifyallsignatures"> ' + \
translate['Verify all signatures'] + '<br>\n'
if getConfigParam(baseDir, "brochMode"):
instanceStr += \
' <input type="checkbox" class="profilecheckbox" ' + \
'name="brochMode" checked> ' + \
translate['Broch mode'] + '<br>\n'
else:
instanceStr += \
' <input type="checkbox" class="profilecheckbox" ' + \
'name="brochMode"> ' + \
translate['Broch mode'] + '<br>\n'
instanceStr += '</div>'
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:

View File

@ -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 += ' <div id="timeline" class="timeline-posts">\n'
tlStr += ' <div id="timelineposts" class="timeline-posts">\n'
# second row of buttons for moderator actions
if moderator and boxName == 'moderation':

View File

@ -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 = '<div class="transparent"><ul>'
htmlStr = '<div class="transparent"><ul>\n'
if banner:
htmlStr += '<pre aria-label="">' + banner + '<br><br></pre>'
htmlStr += '<pre aria-label="">\n' + banner + '\n<br><br></pre>\n'
if subHeading:
htmlStr += '<strong><label class="transparent">' + \
subHeading + '</label></strong><br>\n'
# show new follower approvals
if usersPath and translate and followApprovals:
htmlStr += '<strong><label class="transparent">' + \
'<a href="' + usersPath + '/followers#timeline">' + \
translate['Approve follow requests'] + '</a>' + \
'</label></strong><br><br>'
'</label></strong><br><br>\n'
# show the list of links
for title, url in links.items():
htmlStr += '<li><label class="transparent">' + \
'<a href="' + str(url) + '">' + \
str(title) + '</a></label></li>'
htmlStr += '</ul></div>'
str(title) + '</a></label></li>\n'
htmlStr += '</ul></div>\n'
return htmlStr

View File

@ -1,6 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8">
<meta name="description" content="ActivityPub server written in Python, HTML and CSS, and suitable for self-hosting on single board computers">
<meta name="keywords" content="ActivityPub, Fediverse, Python, HTML, CSS">
<meta name="author" content="Bob Mottram">
<style>
@charset "UTF-8";
@ -1141,6 +1144,9 @@
}
</style>
<head>
<title>Epicyon ActivityPub server</title>
</head>
<body>
<div class="timeline-banner"></div>
<center>