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

merge-requests/30/head
Bob Mottram 2020-11-10 22:29:25 +00:00
commit b98155a6d6
18 changed files with 1239 additions and 1069 deletions

View File

@ -4,7 +4,7 @@ Add issues on https://gitlab.com/bashrc2/epicyon/-/issues
<blockquote><b>Epicyon</b>, meaning <i>"more than a dog"</i>. Largest of the <i>Borophaginae</i> which lived in North America 20-5 million years ago.</blockquote>
<img src="https://epicyon.net/img/screenshot_indymedia.jpg" width="80%"/>
<img src="https://epicyon.net/img/screenshot_starlight.jpg" width="80%"/>
<img src="https://epicyon.net/img/mobile.jpg" width="30%"/>

View File

@ -117,9 +117,9 @@ from webapp_utils import setBlogAddress
from webapp_utils import getBlogAddress
from webapp_calendar import htmlCalendarDeleteConfirm
from webapp_calendar import htmlCalendar
from webapp_about import htmlAbout
from webapp import htmlFollowingList
from webapp import htmlDeletePost
from webapp import htmlAbout
from webapp import htmlRemoveSharedItem
from webapp import htmlUnblockConfirm
from webapp_person_options import htmlPersonOptions
@ -133,16 +133,15 @@ from webapp_timeline import htmlInboxMedia
from webapp_timeline import htmlInboxBlogs
from webapp_timeline import htmlInboxNews
from webapp_timeline import htmlOutbox
from webapp_timeline import htmlModeration
from webapp_moderation import htmlModeration
from webapp_moderation import htmlModerationInfo
from webapp_create_post import htmlNewPost
from webapp import htmlLogin
from webapp import htmlSuspended
from webapp import htmlGetLoginCredentials
from webapp_login import htmlLogin
from webapp_login import htmlGetLoginCredentials
from webapp_suspended import htmlSuspended
from webapp_tos import htmlTermsOfService
from webapp import htmlFollowConfirm
from webapp import htmlUnfollowConfirm
from webapp import htmlEditNewsPost
from webapp import htmlTermsOfService
from webapp import htmlModerationInfo
from webapp import htmlHashtagBlocked
from webapp_post import htmlPostReplies
from webapp_post import htmlIndividualPost
@ -154,6 +153,7 @@ from webapp_column_left import htmlEditLinks
from webapp_column_right import htmlNewswireMobile
from webapp_column_right import htmlEditNewswire
from webapp_column_right import htmlCitations
from webapp_column_right import htmlEditNewsPost
from webapp_search import htmlSkillsSearch
from webapp_search import htmlHistorySearch
from webapp_search import htmlHashtagSearch
@ -166,6 +166,7 @@ from shares import getSharesFeedForPerson
from shares import addShare
from shares import removeShare
from shares import expireShares
from utils import getCSS
from utils import firstParagraphFromString
from utils import clearFromPostCaches
from utils import containsInvalidChars
@ -191,6 +192,7 @@ from utils import isSuspended
from manualapprove import manualDenyFollowRequest
from manualapprove import manualApproveFollowRequest
from announce import createAnnounce
from content import dangerousMarkup
from content import replaceEmojiFromTags
from content import addHtmlTags
from content import extractMediaInFormPOST
@ -2891,10 +2893,13 @@ class PubServer(BaseHTTPRequestHandler):
return
linksFilename = baseDir + '/accounts/links.txt'
aboutFilename = baseDir + '/accounts/about.txt'
TOSFilename = baseDir + '/accounts/tos.txt'
# extract all of the text fields into a dict
fields = \
extractTextFieldsInPOST(postBytes, boundary, debug)
if fields.get('editedLinks'):
linksStr = fields['editedLinks']
linksFile = open(linksFilename, "w+")
@ -2905,6 +2910,31 @@ class PubServer(BaseHTTPRequestHandler):
if os.path.isfile(linksFilename):
os.remove(linksFilename)
adminNickname = \
getConfigParam(baseDir, 'admin')
if nickname == adminNickname:
if fields.get('editedAbout'):
aboutStr = fields['editedAbout']
if not dangerousMarkup(aboutStr):
aboutFile = open(aboutFilename, "w+")
if aboutFile:
aboutFile.write(aboutStr)
aboutFile.close()
else:
if os.path.isfile(aboutFilename):
os.remove(aboutFilename)
if fields.get('editedTOS'):
TOSStr = fields['editedTOS']
if not dangerousMarkup(TOSStr):
TOSFile = open(TOSFilename, "w+")
if TOSFile:
TOSFile.write(TOSStr)
TOSFile.close()
else:
if os.path.isfile(TOSFilename):
os.remove(TOSFilename)
# redirect back to the default timeline
if callingDomain.endswith('.onion') and \
onionDomain:
@ -4678,7 +4708,8 @@ class PubServer(BaseHTTPRequestHandler):
emailAddress = getEmailAddress(actorJson)
PGPpubKey = getPGPpubKey(actorJson)
PGPfingerprint = getPGPfingerprint(actorJson)
msg = htmlPersonOptions(self.server.cssCache,
msg = htmlPersonOptions(self.server.defaultTimeline,
self.server.cssCache,
self.server.translate,
baseDir, domain,
domainFull,
@ -8250,8 +8281,9 @@ class PubServer(BaseHTTPRequestHandler):
tries = 0
while tries < 5:
try:
with open(path, 'r') as cssfile:
css = cssfile.read()
css = getCSS(self.server.baseDir, path,
self.server.cssCache)
if css:
break
except Exception as e:
print(e)

View File

@ -26,7 +26,7 @@
<p>This system will not federate with instances whose moderation policy is incompatible with the content policy described above. If an instance lacks a moderation policy, or refuses to enforce one, it will be assumed to be incompatible.</p>
<h3>Use of Data for Research Purposes</h3>
<h3>Use of User Generated Content for Research</h3>
<p>Data may not be "scraped" or otherwise obtained from this instance and used for academic research or cited within research publications without the prior written permission of the administrator. Financial remedy will be sought through the courts from any researcher publishing data obtained from this instance without consent.</p>

View File

@ -33,6 +33,7 @@
--follow-text-size2: 40px;
--follow-text-entry-width: 90%;
--focus-color: white;
--petname-width-chars: 16ch;
}
@font-face {
@ -139,6 +140,7 @@ a:focus {
input[type=text] {
width: var(--follow-text-entry-width);
clear: both;
min-width: var(--petname-width-chars);
font-size: 24px;
text-align: center;
color: var(--text-entry-foreground);
@ -216,6 +218,7 @@ a:focus {
clear: both;
font-size: 40px;
text-align: center;
min-width: var(--petname-width-chars);
color: var(--text-entry-foreground);
background-color: var(--text-entry-background);
font-family: Arial, Helvetica, sans-serif;

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

412
webapp.py
View File

@ -6,17 +6,13 @@ __maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
import time
import os
from shutil import copyfile
from utils import getCSS
from utils import getNicknameFromActor
from utils import getDomainFromActor
from utils import locatePost
from utils import noOfAccounts
from utils import loadJson
from utils import getConfigParam
from posts import isEditor
from shares import getValidSharedItemID
from webapp_utils import getAltPath
from webapp_utils import getIconsDir
@ -51,395 +47,6 @@ def htmlFollowingList(cssCache: {}, baseDir: str,
return ''
def htmlModerationInfo(cssCache: {}, translate: {},
baseDir: str, httpPrefix: str) -> str:
msgStr1 = \
'These are globally blocked for all accounts on this instance'
msgStr2 = \
'Any blocks or suspensions made by moderators will be shown here.'
infoForm = ''
cssFilename = baseDir + '/epicyon-profile.css'
if os.path.isfile(baseDir + '/epicyon.css'):
cssFilename = baseDir + '/epicyon.css'
infoCSS = getCSS(baseDir, cssFilename, cssCache)
if infoCSS:
if httpPrefix != 'https':
infoCSS = infoCSS.replace('https://',
httpPrefix + '://')
infoForm = htmlHeader(cssFilename, infoCSS)
infoForm += \
'<center><h1>' + \
translate['Moderation Information'] + \
'</h1></center>'
infoShown = False
suspendedFilename = baseDir + '/accounts/suspended.txt'
if os.path.isfile(suspendedFilename):
with open(suspendedFilename, "r") as f:
suspendedStr = f.read()
infoForm += '<div class="container">'
infoForm += ' <br><b>' + \
translate['Suspended accounts'] + '</b>'
infoForm += ' <br>' + \
translate['These are currently suspended']
infoForm += \
' <textarea id="message" ' + \
'name="suspended" style="height:200px">' + \
suspendedStr + '</textarea>'
infoForm += '</div>'
infoShown = True
blockingFilename = baseDir + '/accounts/blocking.txt'
if os.path.isfile(blockingFilename):
with open(blockingFilename, "r") as f:
blockedStr = f.read()
infoForm += '<div class="container">'
infoForm += \
' <br><b>' + \
translate['Blocked accounts and hashtags'] + '</b>'
infoForm += \
' <br>' + \
translate[msgStr1]
infoForm += \
' <textarea id="message" ' + \
'name="blocked" style="height:700px">' + \
blockedStr + '</textarea>'
infoForm += '</div>'
infoShown = True
if not infoShown:
infoForm += \
'<center><p>' + \
translate[msgStr2] + \
'</p></center>'
infoForm += htmlFooter()
return infoForm
def htmlEditNewsPost(cssCache: {}, translate: {}, baseDir: str, path: str,
domain: str, port: int,
httpPrefix: str, postUrl: str) -> str:
"""Edits a news post
"""
if '/users/' not in path:
return ''
pathOriginal = path
nickname = getNicknameFromActor(path)
if not nickname:
return ''
# is the user an editor?
if not isEditor(baseDir, nickname):
return ''
postUrl = postUrl.replace('/', '#')
postFilename = locatePost(baseDir, nickname, domain, postUrl)
if not postFilename:
return ''
postJsonObject = loadJson(postFilename)
if not postJsonObject:
return ''
cssFilename = baseDir + '/epicyon-links.css'
if os.path.isfile(baseDir + '/links.css'):
cssFilename = baseDir + '/links.css'
editCSS = getCSS(baseDir, cssFilename, cssCache)
if editCSS:
if httpPrefix != 'https':
editCSS = \
editCSS.replace('https://', httpPrefix + '://')
editNewsPostForm = htmlHeader(cssFilename, editCSS)
editNewsPostForm += \
'<form enctype="multipart/form-data" method="POST" ' + \
'accept-charset="UTF-8" action="' + path + '/newseditdata">\n'
editNewsPostForm += \
' <div class="vertical-center">\n'
editNewsPostForm += \
' <p class="new-post-text">' + translate['Edit News Post'] + '</p>'
editNewsPostForm += \
' <div class="container">\n'
editNewsPostForm += \
' <a href="' + pathOriginal + '/tlnews">' + \
'<button class="cancelbtn">' + translate['Go Back'] + '</button></a>\n'
editNewsPostForm += \
' <input type="submit" name="submitEditedNewsPost" value="' + \
translate['Submit'] + '">\n'
editNewsPostForm += \
' </div>\n'
editNewsPostForm += \
'<div class="container">'
editNewsPostForm += \
' <input type="hidden" name="newsPostUrl" value="' + \
postUrl + '">\n'
newsPostTitle = postJsonObject['object']['summary']
editNewsPostForm += \
' <input type="text" name="newsPostTitle" value="' + \
newsPostTitle + '"><br>\n'
newsPostContent = postJsonObject['object']['content']
editNewsPostForm += \
' <textarea id="message" name="editedNewsPost" ' + \
'style="height:600px">' + newsPostContent + '</textarea>'
editNewsPostForm += \
'</div>'
editNewsPostForm += htmlFooter()
return editNewsPostForm
def htmlGetLoginCredentials(loginParams: str,
lastLoginTime: int) -> (str, str, bool):
"""Receives login credentials via HTTPServer POST
"""
if not loginParams.startswith('username='):
return None, None, None
# minimum time between login attempts
currTime = int(time.time())
if currTime < lastLoginTime+10:
return None, None, None
if '&' not in loginParams:
return None, None, None
loginArgs = loginParams.split('&')
nickname = None
password = None
register = False
for arg in loginArgs:
if '=' in arg:
if arg.split('=', 1)[0] == 'username':
nickname = arg.split('=', 1)[1]
elif arg.split('=', 1)[0] == 'password':
password = arg.split('=', 1)[1]
elif arg.split('=', 1)[0] == 'register':
register = True
return nickname, password, register
def htmlLogin(cssCache: {}, translate: {},
baseDir: str, autocomplete=True) -> str:
"""Shows the login screen
"""
accounts = noOfAccounts(baseDir)
loginImage = 'login.png'
loginImageFilename = None
if os.path.isfile(baseDir + '/accounts/' + loginImage):
loginImageFilename = baseDir + '/accounts/' + loginImage
elif os.path.isfile(baseDir + '/accounts/login.jpg'):
loginImage = 'login.jpg'
loginImageFilename = baseDir + '/accounts/' + loginImage
elif os.path.isfile(baseDir + '/accounts/login.jpeg'):
loginImage = 'login.jpeg'
loginImageFilename = baseDir + '/accounts/' + loginImage
elif os.path.isfile(baseDir + '/accounts/login.gif'):
loginImage = 'login.gif'
loginImageFilename = baseDir + '/accounts/' + loginImage
elif os.path.isfile(baseDir + '/accounts/login.webp'):
loginImage = 'login.webp'
loginImageFilename = baseDir + '/accounts/' + loginImage
elif os.path.isfile(baseDir + '/accounts/login.avif'):
loginImage = 'login.avif'
loginImageFilename = baseDir + '/accounts/' + loginImage
if not loginImageFilename:
loginImageFilename = baseDir + '/accounts/' + loginImage
copyfile(baseDir + '/img/login.png', loginImageFilename)
if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'):
if not os.path.isfile(baseDir + '/accounts/login-background.jpg'):
copyfile(baseDir + '/accounts/login-background-custom.jpg',
baseDir + '/accounts/login-background.jpg')
if accounts > 0:
loginText = \
'<p class="login-text">' + \
translate['Welcome. Please enter your login details below.'] + \
'</p>'
else:
loginText = \
'<p class="login-text">' + \
translate['Please enter some credentials'] + '</p>'
loginText += \
'<p class="login-text">' + \
translate['You will become the admin of this site.'] + \
'</p>'
if os.path.isfile(baseDir + '/accounts/login.txt'):
# custom login message
with open(baseDir + '/accounts/login.txt', 'r') as file:
loginText = '<p class="login-text">' + file.read() + '</p>'
cssFilename = baseDir + '/epicyon-login.css'
if os.path.isfile(baseDir + '/login.css'):
cssFilename = baseDir + '/login.css'
loginCSS = getCSS(baseDir, cssFilename, cssCache)
if not loginCSS:
print('ERROR: login css file missing ' + cssFilename)
return None
# show the register button
registerButtonStr = ''
if getConfigParam(baseDir, 'registration') == 'open':
if int(getConfigParam(baseDir, 'registrationsRemaining')) > 0:
if accounts > 0:
idx = 'Welcome. Please login or register a new account.'
loginText = \
'<p class="login-text">' + \
translate[idx] + \
'</p>'
registerButtonStr = \
'<button type="submit" name="register">Register</button>'
TOSstr = \
'<p class="login-text"><a href="/terms">' + \
translate['Terms of Service'] + '</a></p>'
TOSstr += \
'<p class="login-text"><a href="/about">' + \
translate['About this Instance'] + '</a></p>'
loginButtonStr = ''
if accounts > 0:
loginButtonStr = \
'<button type="submit" name="submit">' + \
translate['Login'] + '</button>'
autocompleteStr = ''
if not autocomplete:
autocompleteStr = 'autocomplete="off" value=""'
loginForm = htmlHeader(cssFilename, loginCSS)
loginForm += '<br>\n'
loginForm += '<form method="POST" action="/login">\n'
loginForm += ' <div class="imgcontainer">\n'
loginForm += \
' <img loading="lazy" src="' + loginImage + \
'" alt="login image" class="loginimage">\n'
loginForm += loginText + TOSstr + '\n'
loginForm += ' </div>\n'
loginForm += '\n'
loginForm += ' <div class="container">\n'
loginForm += ' <label for="nickname"><b>' + \
translate['Nickname'] + '</b></label>\n'
loginForm += \
' <input type="text" ' + autocompleteStr + ' placeholder="' + \
translate['Enter Nickname'] + '" name="username" required autofocus>\n'
loginForm += '\n'
loginForm += ' <label for="password"><b>' + \
translate['Password'] + '</b></label>\n'
loginForm += \
' <input type="password" ' + autocompleteStr + \
' placeholder="' + translate['Enter Password'] + \
'" name="password" required>\n'
loginForm += loginButtonStr + registerButtonStr + '\n'
loginForm += ' </div>\n'
loginForm += '</form>\n'
loginForm += \
'<a href="https://gitlab.com/bashrc2/epicyon">' + \
'<img loading="lazy" class="license" title="' + \
translate['Get the source code'] + '" alt="' + \
translate['Get the source code'] + '" src="/icons/agpl.png" /></a>\n'
loginForm += htmlFooter()
return loginForm
def htmlTermsOfService(cssCache: {}, baseDir: str,
httpPrefix: str, domainFull: str) -> str:
"""Show the terms of service screen
"""
adminNickname = getConfigParam(baseDir, 'admin')
if not os.path.isfile(baseDir + '/accounts/tos.txt'):
copyfile(baseDir + '/default_tos.txt',
baseDir + '/accounts/tos.txt')
if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'):
if not os.path.isfile(baseDir + '/accounts/login-background.jpg'):
copyfile(baseDir + '/accounts/login-background-custom.jpg',
baseDir + '/accounts/login-background.jpg')
TOSText = 'Terms of Service go here.'
if os.path.isfile(baseDir + '/accounts/tos.txt'):
with open(baseDir + '/accounts/tos.txt', 'r') as file:
TOSText = file.read()
TOSForm = ''
cssFilename = baseDir + '/epicyon-profile.css'
if os.path.isfile(baseDir + '/epicyon.css'):
cssFilename = baseDir + '/epicyon.css'
termsCSS = getCSS(baseDir, cssFilename, cssCache)
if termsCSS:
if httpPrefix != 'https':
termsCSS = termsCSS.replace('https://', httpPrefix+'://')
TOSForm = htmlHeader(cssFilename, termsCSS)
TOSForm += '<div class="container">' + TOSText + '</div>\n'
if adminNickname:
adminActor = httpPrefix + '://' + domainFull + \
'/users/' + adminNickname
TOSForm += \
'<div class="container"><center>\n' + \
'<p class="administeredby">Administered by <a href="' + \
adminActor + '">' + adminNickname + '</a></p>\n' + \
'</center></div>\n'
TOSForm += htmlFooter()
return TOSForm
def htmlAbout(cssCache: {}, baseDir: str, httpPrefix: str,
domainFull: str, onionDomain: str) -> str:
"""Show the about screen
"""
adminNickname = getConfigParam(baseDir, 'admin')
if not os.path.isfile(baseDir + '/accounts/about.txt'):
copyfile(baseDir + '/default_about.txt',
baseDir + '/accounts/about.txt')
if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'):
if not os.path.isfile(baseDir + '/accounts/login-background.jpg'):
copyfile(baseDir + '/accounts/login-background-custom.jpg',
baseDir + '/accounts/login-background.jpg')
aboutText = 'Information about this instance goes here.'
if os.path.isfile(baseDir + '/accounts/about.txt'):
with open(baseDir + '/accounts/about.txt', 'r') as aboutFile:
aboutText = aboutFile.read()
aboutForm = ''
cssFilename = baseDir + '/epicyon-profile.css'
if os.path.isfile(baseDir + '/epicyon.css'):
cssFilename = baseDir + '/epicyon.css'
aboutCSS = getCSS(baseDir, cssFilename, cssCache)
if aboutCSS:
if httpPrefix != 'http':
aboutCSS = aboutCSS.replace('https://',
httpPrefix + '://')
aboutForm = htmlHeader(cssFilename, aboutCSS)
aboutForm += '<div class="container">' + aboutText + '</div>'
if onionDomain:
aboutForm += \
'<div class="container"><center>\n' + \
'<p class="administeredby">' + \
'http://' + onionDomain + '</p>\n</center></div>\n'
if adminNickname:
adminActor = '/users/' + adminNickname
aboutForm += \
'<div class="container"><center>\n' + \
'<p class="administeredby">Administered by <a href="' + \
adminActor + '">' + adminNickname + '</a></p>\n' + \
'</center></div>\n'
aboutForm += htmlFooter()
return aboutForm
def htmlHashtagBlocked(cssCache: {}, baseDir: str, translate: {}) -> str:
"""Show the screen for a blocked hashtag
"""
@ -463,25 +70,6 @@ def htmlHashtagBlocked(cssCache: {}, baseDir: str, translate: {}) -> str:
return blockedHashtagForm
def htmlSuspended(cssCache: {}, baseDir: str) -> str:
"""Show the screen for suspended accounts
"""
suspendedForm = ''
cssFilename = baseDir + '/epicyon-suspended.css'
if os.path.isfile(baseDir + '/suspended.css'):
cssFilename = baseDir + '/suspended.css'
suspendedCSS = getCSS(baseDir, cssFilename, cssCache)
if suspendedCSS:
suspendedForm = htmlHeader(cssFilename, suspendedCSS)
suspendedForm += '<div><center>\n'
suspendedForm += ' <p class="screentitle">Account Suspended</p>\n'
suspendedForm += ' <p>See <a href="/terms">Terms of Service</a></p>\n'
suspendedForm += '</center></div>\n'
suspendedForm += htmlFooter()
return suspendedForm
def htmlRemoveSharedItem(cssCache: {}, translate: {}, baseDir: str,
actor: str, shareName: str,
callingDomain: str) -> str:

62
webapp_about.py 100644
View File

@ -0,0 +1,62 @@
__filename__ = "webapp_about.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.1.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
import os
from shutil import copyfile
from utils import getCSS
from utils import getConfigParam
from webapp_utils import htmlHeader
from webapp_utils import htmlFooter
def htmlAbout(cssCache: {}, baseDir: str, httpPrefix: str,
domainFull: str, onionDomain: str) -> str:
"""Show the about screen
"""
adminNickname = getConfigParam(baseDir, 'admin')
if not os.path.isfile(baseDir + '/accounts/about.txt'):
copyfile(baseDir + '/default_about.txt',
baseDir + '/accounts/about.txt')
if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'):
if not os.path.isfile(baseDir + '/accounts/login-background.jpg'):
copyfile(baseDir + '/accounts/login-background-custom.jpg',
baseDir + '/accounts/login-background.jpg')
aboutText = 'Information about this instance goes here.'
if os.path.isfile(baseDir + '/accounts/about.txt'):
with open(baseDir + '/accounts/about.txt', 'r') as aboutFile:
aboutText = aboutFile.read()
aboutForm = ''
cssFilename = baseDir + '/epicyon-profile.css'
if os.path.isfile(baseDir + '/epicyon.css'):
cssFilename = baseDir + '/epicyon.css'
aboutCSS = getCSS(baseDir, cssFilename, cssCache)
if aboutCSS:
if httpPrefix != 'http':
aboutCSS = aboutCSS.replace('https://',
httpPrefix + '://')
aboutForm = htmlHeader(cssFilename, aboutCSS)
aboutForm += '<div class="container">' + aboutText + '</div>'
if onionDomain:
aboutForm += \
'<div class="container"><center>\n' + \
'<p class="administeredby">' + \
'http://' + onionDomain + '</p>\n</center></div>\n'
if adminNickname:
adminActor = '/users/' + adminNickname
aboutForm += \
'<div class="container"><center>\n' + \
'<p class="administeredby">Administered by <a href="' + \
adminActor + '">' + adminNickname + '</a></p>\n' + \
'</center></div>\n'
aboutForm += htmlFooter()
return aboutForm

View File

@ -17,7 +17,7 @@ from webapp_utils import getLeftImageFile
from webapp_utils import getImageFile
from webapp_utils import headerButtonsFrontScreen
from webapp_utils import getIconsDir
from webapp_utils import htmlHeader
from webapp_utils import htmlHeaderWithExternalStyle
from webapp_utils import htmlFooter
from webapp_utils import getBannerFile
@ -125,6 +125,13 @@ def getLeftColumnContent(baseDir: str, nickname: str, domainFull: str,
# if showHeaderImage:
# htmlStr += '<br>'
htmlStr += \
'<p class="login-text"><a href="/about">' + \
translate['About this Instance'] + '</a></p>'
htmlStr += \
'<p class="login-text"><a href="/terms">' + \
translate['Terms of Service'] + '</a></p>'
linksFilename = baseDir + '/accounts/links.txt'
linksFileContainsEntries = False
if os.path.isfile(linksFilename):
@ -213,7 +220,7 @@ def htmlLinksMobile(cssCache: {}, baseDir: str,
if ':' in domain:
domain = domain.split(':')[0]
htmlStr = htmlHeader(cssFilename, profileStyle)
htmlStr = htmlHeaderWithExternalStyle(cssFilename, profileStyle)
bannerFile, bannerFilename = getBannerFile(baseDir, nickname, domain)
htmlStr += \
'<a href="/users/' + nickname + '/' + defaultTimeline + '">' + \
@ -265,7 +272,7 @@ def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str,
# filename of the banner shown at the top
bannerFile, bannerFilename = getBannerFile(baseDir, nickname, domain)
editLinksForm = htmlHeader(cssFilename, editCSS)
editLinksForm = htmlHeaderWithExternalStyle(cssFilename, editCSS)
# top banner
editLinksForm += \
@ -284,9 +291,6 @@ def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str,
' <p class="new-post-text">' + translate['Edit Links'] + '</p>'
editLinksForm += \
' <div class="container">\n'
# editLinksForm += \
# ' <a href="' + pathOriginal + '"><button class="cancelbtn">' + \
# translate['Go Back'] + '</button></a>\n'
editLinksForm += \
' <center>\n' + \
' <input type="submit" name="submitLinks" value="' + \
@ -313,5 +317,45 @@ def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str,
editLinksForm += \
'</div>'
# the admin can edit terms of service and about text
adminNickname = getConfigParam(baseDir, 'admin')
if adminNickname:
if nickname == adminNickname:
aboutFilename = baseDir + '/accounts/about.txt'
aboutStr = ''
if os.path.isfile(aboutFilename):
with open(aboutFilename, 'r') as fp:
aboutStr = fp.read()
editLinksForm += \
'<div class="container">'
editLinksForm += \
' ' + \
translate['About this Instance'] + \
'<br>'
editLinksForm += \
' <textarea id="message" name="editedAbout" ' + \
'style="height:100vh">' + aboutStr + '</textarea>'
editLinksForm += \
'</div>'
TOSFilename = baseDir + '/accounts/tos.txt'
TOSStr = ''
if os.path.isfile(TOSFilename):
with open(TOSFilename, 'r') as fp:
TOSStr = fp.read()
editLinksForm += \
'<div class="container">'
editLinksForm += \
' ' + \
translate['Terms of Service'] + \
'<br>'
editLinksForm += \
' <textarea id="message" name="editedTOS" ' + \
'style="height:100vh">' + TOSStr + '</textarea>'
editLinksForm += \
'</div>'
editLinksForm += htmlFooter()
return editLinksForm

View File

@ -10,6 +10,8 @@ import os
from datetime import datetime
from shutil import copyfile
from content import removeLongWords
from utils import locatePost
from utils import loadJson
from utils import getCSS
from utils import getConfigParam
from utils import votesOnNewswireItem
@ -18,7 +20,7 @@ from posts import isEditor
from posts import isModerator
from webapp_utils import getRightImageFile
from webapp_utils import getImageFile
from webapp_utils import htmlHeader
from webapp_utils import htmlHeaderWithExternalStyle
from webapp_utils import htmlFooter
from webapp_utils import getBannerFile
from webapp_utils import htmlPostSeparator
@ -312,7 +314,7 @@ def htmlCitations(baseDir: str, nickname: str, domain: str,
# iconsDir = getIconsDir(baseDir)
htmlStr = htmlHeader(cssFilename, profileStyle)
htmlStr = htmlHeaderWithExternalStyle(cssFilename, profileStyle)
# top banner
bannerFile, bannerFilename = getBannerFile(baseDir, nickname, domain)
@ -422,7 +424,7 @@ def htmlNewswireMobile(cssCache: {}, baseDir: str, nickname: str,
showPublishButton = editor
htmlStr = htmlHeader(cssFilename, profileStyle)
htmlStr = htmlHeaderWithExternalStyle(cssFilename, profileStyle)
bannerFile, bannerFilename = getBannerFile(baseDir, nickname, domain)
htmlStr += \
@ -477,7 +479,7 @@ def htmlEditNewswire(cssCache: {}, translate: {}, baseDir: str, path: str,
# filename of the banner shown at the top
bannerFile, bannerFilename = getBannerFile(baseDir, nickname, domain)
editNewswireForm = htmlHeader(cssFilename, editCSS)
editNewswireForm = htmlHeaderWithExternalStyle(cssFilename, editCSS)
# top banner
editNewswireForm += \
@ -565,3 +567,81 @@ def htmlEditNewswire(cssCache: {}, translate: {}, baseDir: str, path: str,
editNewswireForm += htmlFooter()
return editNewswireForm
def htmlEditNewsPost(cssCache: {}, translate: {}, baseDir: str, path: str,
domain: str, port: int,
httpPrefix: str, postUrl: str) -> str:
"""Edits a news post on the news/features timeline
"""
if '/users/' not in path:
return ''
pathOriginal = path
nickname = getNicknameFromActor(path)
if not nickname:
return ''
# is the user an editor?
if not isEditor(baseDir, nickname):
return ''
postUrl = postUrl.replace('/', '#')
postFilename = locatePost(baseDir, nickname, domain, postUrl)
if not postFilename:
return ''
postJsonObject = loadJson(postFilename)
if not postJsonObject:
return ''
cssFilename = baseDir + '/epicyon-links.css'
if os.path.isfile(baseDir + '/links.css'):
cssFilename = baseDir + '/links.css'
editCSS = getCSS(baseDir, cssFilename, cssCache)
if editCSS:
if httpPrefix != 'https':
editCSS = \
editCSS.replace('https://', httpPrefix + '://')
editNewsPostForm = htmlHeaderWithExternalStyle(cssFilename, editCSS)
editNewsPostForm += \
'<form enctype="multipart/form-data" method="POST" ' + \
'accept-charset="UTF-8" action="' + path + '/newseditdata">\n'
editNewsPostForm += \
' <div class="vertical-center">\n'
editNewsPostForm += \
' <p class="new-post-text">' + translate['Edit News Post'] + '</p>'
editNewsPostForm += \
' <div class="container">\n'
editNewsPostForm += \
' <a href="' + pathOriginal + '/tlnews">' + \
'<button class="cancelbtn">' + translate['Go Back'] + '</button></a>\n'
editNewsPostForm += \
' <input type="submit" name="submitEditedNewsPost" value="' + \
translate['Submit'] + '">\n'
editNewsPostForm += \
' </div>\n'
editNewsPostForm += \
'<div class="container">'
editNewsPostForm += \
' <input type="hidden" name="newsPostUrl" value="' + \
postUrl + '">\n'
newsPostTitle = postJsonObject['object']['summary']
editNewsPostForm += \
' <input type="text" name="newsPostTitle" value="' + \
newsPostTitle + '"><br>\n'
newsPostContent = postJsonObject['object']['content']
editNewsPostForm += \
' <textarea id="message" name="editedNewsPost" ' + \
'style="height:600px">' + newsPostContent + '</textarea>'
editNewsPostForm += \
'</div>'
editNewsPostForm += htmlFooter()
return editNewsPostForm

View File

@ -13,7 +13,7 @@ from utils import getNicknameFromActor
from utils import getDomainFromActor
from webapp_utils import getIconsDir
from webapp_utils import getBannerFile
from webapp_utils import htmlHeader
from webapp_utils import htmlHeaderWithExternalStyle
from webapp_utils import htmlFooter
@ -558,7 +558,7 @@ def htmlNewPost(cssCache: {}, mediaInstance: bool, translate: {},
dateAndLocation += '<input type="text" name="category">\n'
dateAndLocation += '</div>\n'
newPostForm = htmlHeader(cssFilename, newPostCSS)
newPostForm = htmlHeaderWithExternalStyle(cssFilename, newPostCSS)
newPostForm += \
'<a href="/users/' + nickname + '/' + defaultTimeline + '" title="' + \

170
webapp_login.py 100644
View File

@ -0,0 +1,170 @@
__filename__ = "webapp_login.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.1.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
import os
import time
from shutil import copyfile
from utils import getConfigParam
from utils import noOfAccounts
from utils import getCSS
from webapp_utils import htmlHeader
from webapp_utils import htmlFooter
def htmlGetLoginCredentials(loginParams: str,
lastLoginTime: int) -> (str, str, bool):
"""Receives login credentials via HTTPServer POST
"""
if not loginParams.startswith('username='):
return None, None, None
# minimum time between login attempts
currTime = int(time.time())
if currTime < lastLoginTime+10:
return None, None, None
if '&' not in loginParams:
return None, None, None
loginArgs = loginParams.split('&')
nickname = None
password = None
register = False
for arg in loginArgs:
if '=' in arg:
if arg.split('=', 1)[0] == 'username':
nickname = arg.split('=', 1)[1]
elif arg.split('=', 1)[0] == 'password':
password = arg.split('=', 1)[1]
elif arg.split('=', 1)[0] == 'register':
register = True
return nickname, password, register
def htmlLogin(cssCache: {}, translate: {},
baseDir: str, autocomplete=True) -> str:
"""Shows the login screen
"""
accounts = noOfAccounts(baseDir)
loginImage = 'login.png'
loginImageFilename = None
if os.path.isfile(baseDir + '/accounts/' + loginImage):
loginImageFilename = baseDir + '/accounts/' + loginImage
elif os.path.isfile(baseDir + '/accounts/login.jpg'):
loginImage = 'login.jpg'
loginImageFilename = baseDir + '/accounts/' + loginImage
elif os.path.isfile(baseDir + '/accounts/login.jpeg'):
loginImage = 'login.jpeg'
loginImageFilename = baseDir + '/accounts/' + loginImage
elif os.path.isfile(baseDir + '/accounts/login.gif'):
loginImage = 'login.gif'
loginImageFilename = baseDir + '/accounts/' + loginImage
elif os.path.isfile(baseDir + '/accounts/login.webp'):
loginImage = 'login.webp'
loginImageFilename = baseDir + '/accounts/' + loginImage
elif os.path.isfile(baseDir + '/accounts/login.avif'):
loginImage = 'login.avif'
loginImageFilename = baseDir + '/accounts/' + loginImage
if not loginImageFilename:
loginImageFilename = baseDir + '/accounts/' + loginImage
copyfile(baseDir + '/img/login.png', loginImageFilename)
if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'):
if not os.path.isfile(baseDir + '/accounts/login-background.jpg'):
copyfile(baseDir + '/accounts/login-background-custom.jpg',
baseDir + '/accounts/login-background.jpg')
if accounts > 0:
loginText = \
'<p class="login-text">' + \
translate['Welcome. Please enter your login details below.'] + \
'</p>'
else:
loginText = \
'<p class="login-text">' + \
translate['Please enter some credentials'] + '</p>'
loginText += \
'<p class="login-text">' + \
translate['You will become the admin of this site.'] + \
'</p>'
if os.path.isfile(baseDir + '/accounts/login.txt'):
# custom login message
with open(baseDir + '/accounts/login.txt', 'r') as file:
loginText = '<p class="login-text">' + file.read() + '</p>'
cssFilename = baseDir + '/epicyon-login.css'
if os.path.isfile(baseDir + '/login.css'):
cssFilename = baseDir + '/login.css'
loginCSS = getCSS(baseDir, cssFilename, cssCache)
if not loginCSS:
print('ERROR: login css file missing ' + cssFilename)
return None
# show the register button
registerButtonStr = ''
if getConfigParam(baseDir, 'registration') == 'open':
if int(getConfigParam(baseDir, 'registrationsRemaining')) > 0:
if accounts > 0:
idx = 'Welcome. Please login or register a new account.'
loginText = \
'<p class="login-text">' + \
translate[idx] + \
'</p>'
registerButtonStr = \
'<button type="submit" name="register">Register</button>'
TOSstr = \
'<p class="login-text"><a href="/about">' + \
translate['About this Instance'] + '</a></p>'
TOSstr += \
'<p class="login-text"><a href="/terms">' + \
translate['Terms of Service'] + '</a></p>'
loginButtonStr = ''
if accounts > 0:
loginButtonStr = \
'<button type="submit" name="submit">' + \
translate['Login'] + '</button>'
autocompleteStr = ''
if not autocomplete:
autocompleteStr = 'autocomplete="off" value=""'
loginForm = htmlHeader(cssFilename, loginCSS)
loginForm += '<br>\n'
loginForm += '<form method="POST" action="/login">\n'
loginForm += ' <div class="imgcontainer">\n'
loginForm += \
' <img loading="lazy" src="' + loginImage + \
'" alt="login image" class="loginimage">\n'
loginForm += loginText + TOSstr + '\n'
loginForm += ' </div>\n'
loginForm += '\n'
loginForm += ' <div class="container">\n'
loginForm += ' <label for="nickname"><b>' + \
translate['Nickname'] + '</b></label>\n'
loginForm += \
' <input type="text" ' + autocompleteStr + ' placeholder="' + \
translate['Enter Nickname'] + '" name="username" required autofocus>\n'
loginForm += '\n'
loginForm += ' <label for="password"><b>' + \
translate['Password'] + '</b></label>\n'
loginForm += \
' <input type="password" ' + autocompleteStr + \
' placeholder="' + translate['Enter Password'] + \
'" name="password" required>\n'
loginForm += loginButtonStr + registerButtonStr + '\n'
loginForm += ' </div>\n'
loginForm += '</form>\n'
loginForm += \
'<a href="https://gitlab.com/bashrc2/epicyon">' + \
'<img loading="lazy" class="license" title="' + \
translate['Get the source code'] + '" alt="' + \
translate['Get the source code'] + '" src="/icons/agpl.png" /></a>\n'
loginForm += htmlFooter()
return loginForm

View File

@ -0,0 +1,111 @@
__filename__ = "webapp_moderation.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.1.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
import os
from utils import getCSS
from webapp_timeline import htmlTimeline
from webapp_utils import htmlHeader
from webapp_utils import htmlFooter
def htmlModeration(cssCache: {}, defaultTimeline: str,
recentPostsCache: {}, maxRecentPosts: int,
translate: {}, pageNumber: int, itemsPerPage: int,
session, baseDir: str, wfRequest: {}, personCache: {},
nickname: str, domain: str, port: int, inboxJson: {},
allowDeletion: bool,
httpPrefix: str, projectVersion: str,
YTReplacementDomain: str,
showPublishedDateOnly: bool,
newswire: {}, positiveVoting: bool,
showPublishAsIcon: bool,
fullWidthTimelineButtonHeader: bool,
iconsAsButtons: bool,
rssIconAtTop: bool,
publishButtonAtTop: bool,
authorized: bool) -> str:
"""Show the moderation feed as html
This is what you see when selecting the "mod" timeline
"""
return htmlTimeline(cssCache, defaultTimeline,
recentPostsCache, maxRecentPosts,
translate, pageNumber,
itemsPerPage, session, baseDir, wfRequest, personCache,
nickname, domain, port, inboxJson, 'moderation',
allowDeletion, httpPrefix, projectVersion, True, False,
YTReplacementDomain, showPublishedDateOnly,
newswire, False, False, positiveVoting,
showPublishAsIcon, fullWidthTimelineButtonHeader,
iconsAsButtons, rssIconAtTop, publishButtonAtTop,
authorized)
def htmlModerationInfo(cssCache: {}, translate: {},
baseDir: str, httpPrefix: str) -> str:
msgStr1 = \
'These are globally blocked for all accounts on this instance'
msgStr2 = \
'Any blocks or suspensions made by moderators will be shown here.'
infoForm = ''
cssFilename = baseDir + '/epicyon-profile.css'
if os.path.isfile(baseDir + '/epicyon.css'):
cssFilename = baseDir + '/epicyon.css'
infoCSS = getCSS(baseDir, cssFilename, cssCache)
if infoCSS:
if httpPrefix != 'https':
infoCSS = infoCSS.replace('https://',
httpPrefix + '://')
infoForm = htmlHeader(cssFilename, infoCSS)
infoForm += \
'<center><h1>' + \
translate['Moderation Information'] + \
'</h1></center>'
infoShown = False
suspendedFilename = baseDir + '/accounts/suspended.txt'
if os.path.isfile(suspendedFilename):
with open(suspendedFilename, "r") as f:
suspendedStr = f.read()
infoForm += '<div class="container">'
infoForm += ' <br><b>' + \
translate['Suspended accounts'] + '</b>'
infoForm += ' <br>' + \
translate['These are currently suspended']
infoForm += \
' <textarea id="message" ' + \
'name="suspended" style="height:200px">' + \
suspendedStr + '</textarea>'
infoForm += '</div>'
infoShown = True
blockingFilename = baseDir + '/accounts/blocking.txt'
if os.path.isfile(blockingFilename):
with open(blockingFilename, "r") as f:
blockedStr = f.read()
infoForm += '<div class="container">'
infoForm += \
' <br><b>' + \
translate['Blocked accounts and hashtags'] + '</b>'
infoForm += \
' <br>' + \
translate[msgStr1]
infoForm += \
' <textarea id="message" ' + \
'name="blocked" style="height:700px">' + \
blockedStr + '</textarea>'
infoForm += '</div>'
infoShown = True
if not infoShown:
infoForm += \
'<center><p>' + \
translate[msgStr2] + \
'</p></center>'
infoForm += htmlFooter()
return infoForm

View File

@ -21,7 +21,8 @@ from webapp_utils import htmlHeader
from webapp_utils import htmlFooter
def htmlPersonOptions(cssCache: {}, translate: {}, baseDir: str,
def htmlPersonOptions(defaultTimeline: str,
cssCache: {}, translate: {}, baseDir: str,
domain: str, domainFull: str,
originPathStr: str,
optionsActor: str,
@ -202,9 +203,13 @@ def htmlPersonOptions(cssCache: {}, translate: {}, baseDir: str,
optionsStr += checkboxStr
optionsStr += optionsLinkStr
backPath = '/'
if nickname:
backPath = '/users/' + nickname + '/' + defaultTimeline
optionsStr += \
' <a href="/"><button type="button" class="buttonIcon" ' + \
'name="submitBack">' + translate['Go Back'] + '</button></a>'
' <a href="' + backPath + '"><button type="button" ' + \
'class="buttonIcon" name="submitBack">' + translate['Go Back'] + \
'</button></a>'
optionsStr += \
' <button type="submit" class="button" name="submitView">' + \
translate['View'] + '</button>'

View File

@ -0,0 +1,31 @@
__filename__ = "webapp_suspended.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.1.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
import os
from utils import getCSS
from webapp_utils import htmlHeader
from webapp_utils import htmlFooter
def htmlSuspended(cssCache: {}, baseDir: str) -> str:
"""Show the screen for suspended accounts
"""
suspendedForm = ''
cssFilename = baseDir + '/epicyon-suspended.css'
if os.path.isfile(baseDir + '/suspended.css'):
cssFilename = baseDir + '/suspended.css'
suspendedCSS = getCSS(baseDir, cssFilename, cssCache)
if suspendedCSS:
suspendedForm = htmlHeader(cssFilename, suspendedCSS)
suspendedForm += '<div><center>\n'
suspendedForm += ' <p class="screentitle">Account Suspended</p>\n'
suspendedForm += ' <p>See <a href="/terms">Terms of Service</a></p>\n'
suspendedForm += '</center></div>\n'
suspendedForm += htmlFooter()
return suspendedForm

File diff suppressed because it is too large Load Diff

57
webapp_tos.py 100644
View File

@ -0,0 +1,57 @@
__filename__ = "webapp_tos.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.1.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
import os
from shutil import copyfile
from utils import getCSS
from utils import getConfigParam
from webapp_utils import htmlHeader
from webapp_utils import htmlFooter
def htmlTermsOfService(cssCache: {}, baseDir: str,
httpPrefix: str, domainFull: str) -> str:
"""Show the terms of service screen
"""
adminNickname = getConfigParam(baseDir, 'admin')
if not os.path.isfile(baseDir + '/accounts/tos.txt'):
copyfile(baseDir + '/default_tos.txt',
baseDir + '/accounts/tos.txt')
if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'):
if not os.path.isfile(baseDir + '/accounts/login-background.jpg'):
copyfile(baseDir + '/accounts/login-background-custom.jpg',
baseDir + '/accounts/login-background.jpg')
TOSText = 'Terms of Service go here.'
if os.path.isfile(baseDir + '/accounts/tos.txt'):
with open(baseDir + '/accounts/tos.txt', 'r') as file:
TOSText = file.read()
TOSForm = ''
cssFilename = baseDir + '/epicyon-profile.css'
if os.path.isfile(baseDir + '/epicyon.css'):
cssFilename = baseDir + '/epicyon.css'
termsCSS = getCSS(baseDir, cssFilename, cssCache)
if termsCSS:
if httpPrefix != 'https':
termsCSS = termsCSS.replace('https://', httpPrefix+'://')
TOSForm = htmlHeader(cssFilename, termsCSS)
TOSForm += '<div class="container">' + TOSText + '</div>\n'
if adminNickname:
adminActor = httpPrefix + '://' + domainFull + \
'/users/' + adminNickname
TOSForm += \
'<div class="container"><center>\n' + \
'<p class="administeredby">Administered by <a href="' + \
adminActor + '">' + adminNickname + '</a></p>\n' + \
'</center></div>\n'
TOSForm += htmlFooter()
return TOSForm

View File

@ -437,6 +437,25 @@ def htmlHeader(cssFilename: str, css: str, lang='en') -> str:
return htmlStr
def htmlHeaderWithExternalStyle(cssFilename: str, css: str, lang='en') -> str:
htmlStr = '<!DOCTYPE html>\n'
htmlStr += '<html lang="' + lang + '">\n'
htmlStr += ' <head>\n'
htmlStr += ' <meta charset="utf-8">\n'
fontName, fontFormat = getFontFromCss(css)
if fontName:
htmlStr += ' <link rel="preload" as="font" type="' + \
fontFormat + '" href="' + fontName + '" crossorigin>\n'
cssFile = cssFilename.split('/')[-1]
htmlStr += ' <link rel="stylesheet" href="' + cssFile + '">\n'
htmlStr += ' <link rel="manifest" href="/manifest.json">\n'
htmlStr += ' <meta name="theme-color" content="grey">\n'
htmlStr += ' <title>Epicyon</title>\n'
htmlStr += ' </head>\n'
htmlStr += ' <body>\n'
return htmlStr
def htmlFooter() -> str:
htmlStr = ' </body>\n'
htmlStr += '</html>\n'

View File

@ -1168,7 +1168,7 @@
<th><a href="img/screenshot_hacker.jpg"><img width="90%" src="img/screenshot_hacker.jpg" alt="hacker theme profile page" /></a></th>
</tr>
<tr>
<th><a href="img/screenshot_indymedia.jpg"><img width="120%" src="img/screenshots.jpg" alt="various screenshots" /></a></th>
<th><a href="img/screenshot_starlight.jpg"><img width="120%" src="img/screenshot_starlight.jpg" alt="various screenshots" /></a></th>
<th><img width="50%" src="img/mobile.jpg" alt="mobile screenshot" /></th>
</tr>
<tr>
@ -1199,9 +1199,8 @@
<p class="siteheader">Federated Blogging</p>
<img width="10%" src="img/icons/rss.png" alt="RSS 2.0"/>
<img width="10%" src="img/icons/rss3.png" alt="RSS 3.0"/>
<p class="intro">
You don't need a separate blog system. Blog posts can be added and edited, and are federated as ActivityPub articles. They also have RSS version 2.0 and <a href="http://r3r.sourceforge.net/rss3-spec.xhtml">3.0</a> feeds. People can comment on blog posts, but unlike other systems the moderation settings apply just the same as they do for any other fediverse post arriving at your server. This makes blog spam much easier to keep control over.
You don't need a separate blog system. Blog posts can be added and edited, and are federated as ActivityPub articles. They also have RSS version 2.0 feeds. People can comment on blog posts, but unlike other systems the moderation settings apply just the same as they do for any other fediverse post arriving at your server. This makes blog spam much easier to keep control over. You can subscribe to other people's blogs and they will appear in the right hand newswire column.
</p>
<p class="siteheader">International and Customizable</p>