main
Bob Mottram 2021-02-16 11:47:48 +00:00
commit 98300fb5f9
29 changed files with 297 additions and 61 deletions

View File

@ -3,6 +3,6 @@ image: debian:testing
test:
script:
- apt-get update
- apt-get install -y python3-crypto python3-dateutil python3-idna python3-numpy python3-pil.imagetk python3-pycryptodome python3-requests python3-socks python3-setuptools python3-pyqrcode
- apt-get install -y python3-cryptography python3-dateutil python3-idna python3-numpy python3-pil.imagetk python3-requests python3-socks python3-setuptools python3-pyqrcode
- python3 epicyon.py --tests
- python3 epicyon.py --testsnetwork

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

@ -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
@ -478,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
@ -1153,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:
@ -4512,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/' +
@ -10099,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
@ -13889,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,
@ -14142,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

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

View File

@ -14,6 +14,7 @@ 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
@ -114,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):

6
setup.py 100644
View File

@ -0,0 +1,6 @@
#!/usr/bin/python3
import setuptools
if __name__ == "__main__":
setuptools.setup()

View File

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

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "انتقل إلى Newswire",
"Skip to Links": "تخطي إلى روابط الويب",
"Publish a blog article": "نشر مقال بلوق",
"Featured writer": "كاتب متميز"
"Featured writer": "كاتب متميز",
"Broch mode": "وضع الكتيب"
}

View File

@ -368,5 +368,6 @@
"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",
"Featured writer": "Escriptor destacat"
"Featured writer": "Escriptor destacat",
"Broch mode": "Mode Broch"
}

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "Neidio i Newswire",
"Skip to Links": "Neidio i Dolenni Gwe",
"Publish a blog article": "Cyhoeddi erthygl blog",
"Featured writer": "Awdur dan sylw"
"Featured writer": "Awdur dan sylw",
"Broch mode": "Modd Broch"
}

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "Springe zu Newswire",
"Skip to Links": "Springe zu Weblinks",
"Publish a blog article": "Veröffentlichen Sie einen Blog-Artikel",
"Featured writer": "Ausgewählter Schriftsteller"
"Featured writer": "Ausgewählter Schriftsteller",
"Broch mode": "Broch-Modus"
}

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "Skip to Newswire",
"Skip to Links": "Skip to Links",
"Publish a blog article": "Publish a blog article",
"Featured writer": "Featured writer"
"Featured writer": "Featured writer",
"Broch mode": "Broch mode"
}

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "Saltar a Newswire",
"Skip to Links": "Saltar a enlaces web",
"Publish a blog article": "Publica un artículo de blog",
"Featured writer": "Escritora destacada"
"Featured writer": "Escritora destacada",
"Broch mode": "Modo broche"
}

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "Passer à Newswire",
"Skip to Links": "Passer aux liens Web",
"Publish a blog article": "Publier un article de blog",
"Featured writer": "Écrivain en vedette"
"Featured writer": "Écrivain en vedette",
"Broch mode": "Mode Broch"
}

View File

@ -368,5 +368,6 @@
"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",
"Featured writer": "Scríbhneoir mór le rá"
"Featured writer": "Scríbhneoir mór le rá",
"Broch mode": "Modh broch"
}

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "Newswire पर जाएं",
"Skip to Links": "वेब लिंक पर जाएं",
"Publish a blog article": "एक ब्लॉग लेख प्रकाशित करें",
"Featured writer": "फीचर्ड लेखक"
"Featured writer": "फीचर्ड लेखक",
"Broch mode": "ब्रोच मोड"
}

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "Passa a Newswire",
"Skip to Links": "Passa a collegamenti Web",
"Publish a blog article": "Pubblica un articolo sul blog",
"Featured writer": "Scrittore in primo piano"
"Featured writer": "Scrittore in primo piano",
"Broch mode": "Modalità Broch"
}

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "Newswireにスキップ",
"Skip to Links": "Webリンクにスキップ",
"Publish a blog article": "ブログ記事を公開する",
"Featured writer": "注目の作家"
"Featured writer": "注目の作家",
"Broch mode": "ブロッホモード"
}

View File

@ -364,5 +364,6 @@
"Skip to Newswire": "Skip to Newswire",
"Skip to Links": "Skip to Links",
"Publish a blog article": "Publish a blog article",
"Featured writer": "Featured writer"
"Featured writer": "Featured writer",
"Broch mode": "Broch mode"
}

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "Pular para Newswire",
"Skip to Links": "Pular para links da web",
"Publish a blog article": "Publique um artigo de blog",
"Featured writer": "Escritor em destaque"
"Featured writer": "Escritor em destaque",
"Broch mode": "Modo broch"
}

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "Перейти к ленте новостей",
"Skip to Links": "Перейти к веб-ссылкам",
"Publish a blog article": "Опубликовать статью в блоге",
"Featured writer": "Избранный писатель"
"Featured writer": "Избранный писатель",
"Broch mode": "Брош режим"
}

View File

@ -368,5 +368,6 @@
"Skip to Newswire": "跳到新闻专线",
"Skip to Links": "跳到网页链接",
"Publish a blog article": "发布博客文章",
"Featured writer": "特色作家"
"Featured writer": "特色作家",
"Broch mode": "断点模式"
}

View File

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

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

View File

@ -416,19 +416,19 @@ 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'
@ -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

@ -892,26 +892,26 @@ def htmlKeyboardNavigation(banner: str, links: {},
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>'
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>