Merge branch 'main' of ssh://code.freedombone.net:2222/bashrc/epicyon into main
|
|
@ -16,7 +16,6 @@ RUN apt-get update && \
|
|||
python3-idna \
|
||||
libimage-exiftool-perl \
|
||||
python3-flake8 \
|
||||
python3-pyld \
|
||||
python3-django-timezone-field \
|
||||
tor
|
||||
RUN adduser --system --home=/opt/epicyon --group epicyon
|
||||
|
|
|
|||
14
README.md
|
|
@ -4,11 +4,11 @@ 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://code.freedombone.net/bashrc/epicyon/raw/master/img/screenshot_indymedia.jpg?raw=true" width="80%"/>
|
||||
<img src="https://epicyon.net/img/screenshot_indymedia.jpg" width="80%"/>
|
||||
|
||||
<img src="https://code.freedombone.net/bashrc/epicyon/raw/master/img/mobile.jpg?raw=true" width="30%"/>
|
||||
<img src="https://epicyon.net/img/mobile.jpg" width="30%"/>
|
||||
|
||||
Epicyon is a modern [ActivityPub](https://www.w3.org/TR/activitypub) compliant server implementing both S2S and C2S protocols and sutable for installation on single board computers. It includes features such as moderation tools, post expiry, content warnings, image descriptions and perimeter defense against adversaries. It contains *no javascript* and uses HTML+CSS with a Python backend.
|
||||
Epicyon is a modern [ActivityPub](https://www.w3.org/TR/activitypub) compliant server implementing both S2S and C2S protocols and sutable for installation on single board computers. It includes features such as moderation tools, post expiry, content warnings, image descriptions, news feed and perimeter defense against adversaries. It contains *no javascript* and uses HTML+CSS with a Python backend.
|
||||
|
||||
[Project Goals](README_goals.md) - [Commandline interface](README_commandline.md) - [Customizations](README_customizations.md) - [Code of Conduct](code-of-conduct.md)
|
||||
|
||||
|
|
@ -16,7 +16,9 @@ Matrix room: **#epicyon:matrix.freedombone.net**
|
|||
|
||||
Includes emojis designed by [OpenMoji](https://openmoji.org) – the open-source emoji and icon project. License: [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0). Blob Cat Emoji and Meowmoji were made by Nitro Blob Hub, licensed under [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0).
|
||||
|
||||
<img src="https://code.freedombone.net/bashrc/epicyon/raw/main/img/screenshot_light.jpg" width="80%"/>
|
||||
<img src="https://epicyon.net/img/screenshot_light.jpg" width="80%"/>
|
||||
|
||||
<img src="https://epicyon.net/img/screenshot_login.jpg" width="80%"/>
|
||||
|
||||
## Package Dependencies
|
||||
|
||||
|
|
@ -29,7 +31,7 @@ sudo pacman -S tor python-pip python-pysocks python-pycryptodome \
|
|||
imagemagick python-pillow python-requests \
|
||||
perl-image-exiftool python-numpy python-dateutil \
|
||||
certbot flake8 bandit
|
||||
sudo pip3 install pyLD pyqrcode pypng
|
||||
sudo pip3 install pyqrcode pypng
|
||||
```
|
||||
|
||||
Or on Debian:
|
||||
|
|
@ -41,7 +43,7 @@ sudo apt install -y \
|
|||
python3-crypto python3-pycryptodome \
|
||||
python3-dateutil python3-pil.imagetk
|
||||
python3-idna python3-requests \
|
||||
python3-pyld python3-django-timezone-field \
|
||||
python3-django-timezone-field \
|
||||
libimage-exiftool-perl python3-flake8 \
|
||||
python3-pyqrcode python3-png python3-bandit \
|
||||
certbot nginx
|
||||
|
|
|
|||
46
config.py
|
|
@ -1,46 +0,0 @@
|
|||
__filename__ = "config.py"
|
||||
__author__ = "Bob Mottram"
|
||||
__license__ = "AGPL3+"
|
||||
__version__ = "1.1.0"
|
||||
__maintainer__ = "Bob Mottram"
|
||||
__email__ = "bob@freedombone.net"
|
||||
__status__ = "Production"
|
||||
|
||||
import os
|
||||
from utils import loadJson
|
||||
from utils import saveJson
|
||||
|
||||
|
||||
def createConfig(baseDir: str) -> None:
|
||||
"""Creates a configuration file
|
||||
"""
|
||||
configFilename = baseDir + '/config.json'
|
||||
if os.path.isfile(configFilename):
|
||||
return
|
||||
configJson = {
|
||||
}
|
||||
saveJson(configJson, configFilename)
|
||||
|
||||
|
||||
def setConfigParam(baseDir: str, variableName: str, variableValue) -> None:
|
||||
"""Sets a configuration value
|
||||
"""
|
||||
createConfig(baseDir)
|
||||
configFilename = baseDir + '/config.json'
|
||||
configJson = {}
|
||||
if os.path.isfile(configFilename):
|
||||
configJson = loadJson(configFilename)
|
||||
configJson[variableName] = variableValue
|
||||
saveJson(configJson, configFilename)
|
||||
|
||||
|
||||
def getConfigParam(baseDir: str, variableName: str):
|
||||
"""Gets a configuration value
|
||||
"""
|
||||
createConfig(baseDir)
|
||||
configFilename = baseDir + '/config.json'
|
||||
configJson = loadJson(configFilename)
|
||||
if configJson:
|
||||
if configJson.get(variableName):
|
||||
return configJson[variableName]
|
||||
return None
|
||||
21
content.py
|
|
@ -14,6 +14,23 @@ from utils import fileLastModified
|
|||
from utils import getLinkPrefixes
|
||||
|
||||
|
||||
def removeHtmlTag(htmlStr: str, tag: str) -> str:
|
||||
"""Removes a given tag from a html string
|
||||
"""
|
||||
tagFound = True
|
||||
while tagFound:
|
||||
matchStr = ' ' + tag + '="'
|
||||
if matchStr not in htmlStr:
|
||||
tagFound = False
|
||||
break
|
||||
sections = htmlStr.split(matchStr, 1)
|
||||
if '"' not in sections[1]:
|
||||
tagFound = False
|
||||
break
|
||||
htmlStr = sections[0] + sections[1].split('"', 1)[1]
|
||||
return htmlStr
|
||||
|
||||
|
||||
def removeQuotesWithinQuotes(content: str) -> str:
|
||||
"""Removes any blockquote inside blockquote
|
||||
"""
|
||||
|
|
@ -247,8 +264,10 @@ def addMusicTag(content: str, tag: str) -> str:
|
|||
"""If a music link is found then ensure that the post is
|
||||
tagged appropriately
|
||||
"""
|
||||
if '#podcast' in content or '#documentary' in content:
|
||||
return content
|
||||
if '#' not in tag:
|
||||
tag = '#'+tag
|
||||
tag = '#' + tag
|
||||
if tag in content:
|
||||
return content
|
||||
musicSites = ('soundcloud.com', 'bandcamp.com')
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
--main-header-color-roles: #282237;
|
||||
--main-fg-color: #dddddd;
|
||||
--column-left-fg-color: #dddddd;
|
||||
--column-right-fg-color: #dddddd;
|
||||
--column-right-fg-color: yellow;
|
||||
--column-right-fg-color-voted-on: red;
|
||||
--main-link-color: #999;
|
||||
--main-link-color-hover: #bbb;
|
||||
--main-visited-color: #888;
|
||||
|
|
@ -26,6 +27,7 @@
|
|||
--font-size-button-mobile: 34px;
|
||||
--font-size-links: 18px;
|
||||
--font-size-newswire: 18px;
|
||||
--font-size-newswire-mobile: 48px;
|
||||
--font-size: 30px;
|
||||
--font-size2: 24px;
|
||||
--font-size3: 38px;
|
||||
|
|
@ -67,16 +69,21 @@
|
|||
--quote-font-size: 120%;
|
||||
--line-spacing: 130%;
|
||||
--line-spacing-newswire: 100%;
|
||||
--newswire-moderate-color: yellow;
|
||||
--newswire-item-moderated-color: white;
|
||||
--newswire-date-moderated-color: white;
|
||||
--column-left-width: 10vw;
|
||||
--column-center-width: 80vw;
|
||||
--column-right-width: 10vw;
|
||||
--column-left-header-background: #555;
|
||||
--column-left-header-color: #fff;
|
||||
--column-left-header-size: 20px;
|
||||
--column-left-header-size-mobile: 50px;
|
||||
--column-left-icon-size: 20%;
|
||||
--column-left-icon-size-mobile: 10%;
|
||||
--column-left-image-width-mobile: 40vw;
|
||||
--column-right-icon-size: 20%;
|
||||
--newswire-date-color: white;
|
||||
--newswire-voted-background-color: black;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
|
|
@ -132,6 +139,11 @@ blockquote p {
|
|||
display: inline;
|
||||
}
|
||||
|
||||
.voteicon {
|
||||
width: 1.1vw;
|
||||
margin: -4px 5px;
|
||||
}
|
||||
|
||||
.imageAnchor:focus img{
|
||||
border: 2px solid var(--focus-color);
|
||||
}
|
||||
|
|
@ -140,15 +152,6 @@ h1 {
|
|||
color: var(--title-color);
|
||||
}
|
||||
|
||||
h3.linksHeader {
|
||||
background-color: var(--column-left-header-background);
|
||||
color: var(--column-left-header-color);
|
||||
font-size: var(--column-left-header-size);
|
||||
text-transform: uppercase;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
a, u {
|
||||
color: var(--main-fg-color);
|
||||
}
|
||||
|
|
@ -226,25 +229,6 @@ a:focus {
|
|||
width: 50%;
|
||||
}
|
||||
|
||||
.newswireItem {
|
||||
font-size: var(--font-size-newswire);
|
||||
color: var(--column-right-fg-color);
|
||||
line-height: var(--line-spacing-newswire);
|
||||
}
|
||||
|
||||
.newswireItemModerate {
|
||||
font-size: var(--font-size-newswire);
|
||||
color: var(--newswire-moderate-color);
|
||||
font-weight: bold;
|
||||
line-height: var(--line-spacing-newswire);
|
||||
}
|
||||
|
||||
.newswireDate {
|
||||
font-size: var(--font-size-newswire);
|
||||
color: var(--newswire-date-color);
|
||||
float: right;
|
||||
}
|
||||
|
||||
.new-post-text {
|
||||
font-size: var(--font-size2);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
|
|
@ -966,6 +950,53 @@ aside .toggle-inside li {
|
|||
font-size: var(--font-size);
|
||||
line-height: var(--line-spacing);
|
||||
}
|
||||
h3.linksHeader {
|
||||
background-color: var(--column-left-header-background);
|
||||
color: var(--column-left-header-color);
|
||||
font-size: var(--column-left-header-size);
|
||||
text-transform: uppercase;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
}
|
||||
.newswireItem {
|
||||
font-size: var(--font-size-newswire);
|
||||
color: var(--column-right-fg-color);
|
||||
line-height: var(--line-spacing-newswire);
|
||||
}
|
||||
.newswireItemModerated {
|
||||
font-size: var(--font-size-newswire);
|
||||
color: var(--newswire-item-moderated-color);
|
||||
line-height: var(--line-spacing-newswire);
|
||||
}
|
||||
.newswireDateModerated {
|
||||
font-size: var(--font-size-newswire);
|
||||
font-weight: bold;
|
||||
color: var(--newswire-date-moderated-color);
|
||||
float: right;
|
||||
}
|
||||
.newswireItemVotedOn a:link {
|
||||
background: var(--newswire-voted-background-color);
|
||||
}
|
||||
.newswireItemVotedOn {
|
||||
font-size: var(--font-size-newswire);
|
||||
font-weight: bold;
|
||||
color: var(--column-right-fg-color-voted-on);
|
||||
line-height: var(--line-spacing-newswire);
|
||||
}
|
||||
.newswireDate {
|
||||
font-size: var(--font-size-newswire);
|
||||
color: var(--newswire-date-color);
|
||||
float: right;
|
||||
}
|
||||
.newswireDateVotedOn {
|
||||
font-size: var(--font-size-newswire);
|
||||
font-weight: bold;
|
||||
color: var(--column-right-fg-color-voted-on);
|
||||
float: right;
|
||||
}
|
||||
.imageAnchorMobile img{
|
||||
display: none;
|
||||
}
|
||||
.timeline-banner {
|
||||
background-image: linear-gradient(rgba(0, 0, 0, 0.0), rgba(0, 0, 0, 0.5)), url("banner.png");
|
||||
height: 15%;
|
||||
|
|
@ -1512,6 +1543,78 @@ aside .toggle-inside li {
|
|||
font-size: var(--font-size);
|
||||
line-height: var(--line-spacing);
|
||||
}
|
||||
h3.linksHeader {
|
||||
background-color: var(--column-left-header-background);
|
||||
color: var(--column-left-header-color);
|
||||
font-size: var(--column-left-header-size-mobile);
|
||||
text-transform: uppercase;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
}
|
||||
.leftColEditImage {
|
||||
background: var(--main-bg-color);
|
||||
width: var(--column-left-icon-size-mobile);
|
||||
float: right;
|
||||
margin: 20px 0px;
|
||||
}
|
||||
.leftColImg {
|
||||
background: var(--main-bg-color);
|
||||
width: var(--column-left-image-width-mobile);
|
||||
float: right;
|
||||
margin: 0 0;
|
||||
padding: 0 0;
|
||||
}
|
||||
.rightColEditImage {
|
||||
background: var(--main-bg-color);
|
||||
width: var(--column-right-icon-size);
|
||||
float: right;
|
||||
margin: 20px 0px;
|
||||
}
|
||||
.rightColImg {
|
||||
background: var(--main-bg-color);
|
||||
width: 100vw;
|
||||
margin: 0 0;
|
||||
padding: 0 0;
|
||||
}
|
||||
.newswireItem {
|
||||
font-size: var(--font-size-newswire-mobile);
|
||||
color: var(--column-right-fg-color);
|
||||
line-height: var(--line-spacing-newswire);
|
||||
}
|
||||
.newswireItemModerated {
|
||||
font-size: var(--font-size-newswire-mobile);
|
||||
color: var(--newswire-item-moderated-color);
|
||||
line-height: var(--line-spacing-newswire);
|
||||
}
|
||||
.newswireDateModerated {
|
||||
font-size: var(--font-size-newswire-mobile);
|
||||
font-weight: bold;
|
||||
color: var(--newswire-date-moderated-color);
|
||||
float: right;
|
||||
}
|
||||
.newswireItemVotedOn a:link {
|
||||
background: var(--newswire-voted-background-color);
|
||||
}
|
||||
.newswireItemVotedOn {
|
||||
font-size: var(--font-size-newswire-mobile);
|
||||
font-weight: bold;
|
||||
color: var(--column-right-fg-color-voted-on);
|
||||
line-height: var(--line-spacing-newswire);
|
||||
}
|
||||
.newswireDate {
|
||||
font-size: var(--font-size-newswire-mobile);
|
||||
color: var(--newswire-date-color);
|
||||
float: right;
|
||||
}
|
||||
.newswireDateVotedOn {
|
||||
font-size: var(--font-size-newswire-mobile);
|
||||
font-weight: bold;
|
||||
color: var(--column-right-fg-color-voted-on);
|
||||
float: right;
|
||||
}
|
||||
.imageAnchorMobile img{
|
||||
display: inline;
|
||||
}
|
||||
.timeline-banner {
|
||||
background-image: linear-gradient(rgba(0, 0, 0, 0.0), rgba(0, 0, 0, 0.5)), url("banner.png");
|
||||
height: 6%;
|
||||
|
|
|
|||
53
epicyon.py
|
|
@ -45,10 +45,10 @@ from tests import testPostMessageBetweenServers
|
|||
from tests import testFollowBetweenServers
|
||||
from tests import testClientToServer
|
||||
from tests import runAllTests
|
||||
from config import setConfigParam
|
||||
from config import getConfigParam
|
||||
from auth import storeBasicCredentials
|
||||
from auth import createPassword
|
||||
from utils import setConfigParam
|
||||
from utils import getConfigParam
|
||||
from utils import getDomainFromActor
|
||||
from utils import getNicknameFromActor
|
||||
from utils import followPerson
|
||||
|
|
@ -192,9 +192,19 @@ parser.add_argument("--noapproval", type=str2bool, nargs='?',
|
|||
parser.add_argument("--mediainstance", type=str2bool, nargs='?',
|
||||
const=True, default=False,
|
||||
help="Media Instance - favor media over text")
|
||||
parser.add_argument("--dateonly", type=str2bool, nargs='?',
|
||||
const=True, default=False,
|
||||
help="Only show the date at the bottom of posts")
|
||||
parser.add_argument("--blogsinstance", type=str2bool, nargs='?',
|
||||
const=True, default=False,
|
||||
help="Blogs Instance - favor blogs over microblogging")
|
||||
parser.add_argument("--newsinstance", type=str2bool, nargs='?',
|
||||
const=True, default=False,
|
||||
help="News Instance - favor news over microblogging")
|
||||
parser.add_argument("--positivevoting", type=str2bool, nargs='?',
|
||||
const=True, default=False,
|
||||
help="On newswire, whether moderators vote " +
|
||||
"positively for or veto against items")
|
||||
parser.add_argument("--debug", type=str2bool, nargs='?',
|
||||
const=True, default=False,
|
||||
help="Show debug messages")
|
||||
|
|
@ -249,6 +259,13 @@ parser.add_argument('--archiveweeks', dest='archiveWeeks', type=str,
|
|||
parser.add_argument('--maxposts', dest='archiveMaxPosts', type=str,
|
||||
default=None,
|
||||
help='Maximum number of posts in in/outbox')
|
||||
parser.add_argument('--minimumvotes', dest='minimumvotes', type=int,
|
||||
default=1,
|
||||
help='Minimum number of votes to remove or add' +
|
||||
' a newswire item')
|
||||
parser.add_argument('--votingtime', dest='votingtime', type=int,
|
||||
default=1440,
|
||||
help='Time to vote on newswire items in minutes')
|
||||
parser.add_argument('--message', dest='message', type=str,
|
||||
default=None,
|
||||
help='Message content')
|
||||
|
|
@ -626,6 +643,15 @@ if not args.mediainstance:
|
|||
args.mediainstance = mediaInstance
|
||||
if args.mediainstance:
|
||||
args.blogsinstance = False
|
||||
args.newsinstance = False
|
||||
|
||||
if not args.newsinstance:
|
||||
newsInstance = getConfigParam(baseDir, 'newsInstance')
|
||||
if newsInstance is not None:
|
||||
args.newsinstance = newsInstance
|
||||
if args.newsinstance:
|
||||
args.blogsinstance = False
|
||||
args.mediainstance = False
|
||||
|
||||
if not args.blogsinstance:
|
||||
blogsInstance = getConfigParam(baseDir, 'blogsInstance')
|
||||
|
|
@ -633,6 +659,7 @@ if not args.blogsinstance:
|
|||
args.blogsinstance = blogsInstance
|
||||
if args.blogsinstance:
|
||||
args.mediainstance = False
|
||||
args.newsinstance = False
|
||||
|
||||
# set the instance title in config.json
|
||||
title = getConfigParam(baseDir, 'instanceTitle')
|
||||
|
|
@ -1885,6 +1912,19 @@ registration = getConfigParam(baseDir, 'registration')
|
|||
if not registration:
|
||||
registration = False
|
||||
|
||||
minimumvotes = getConfigParam(baseDir, 'minvotes')
|
||||
if minimumvotes:
|
||||
args.minimumvotes = int(minimumvotes)
|
||||
|
||||
votingtime = getConfigParam(baseDir, 'votingtime')
|
||||
if votingtime:
|
||||
args.votingtime = votingtime
|
||||
|
||||
# only show the date at the bottom of posts
|
||||
dateonly = getConfigParam(baseDir, 'dateonly')
|
||||
if dateonly:
|
||||
args.dateonly = dateonly
|
||||
|
||||
YTDomain = getConfigParam(baseDir, 'youtubedomain')
|
||||
if YTDomain:
|
||||
if '://' in YTDomain:
|
||||
|
|
@ -1894,11 +1934,16 @@ if YTDomain:
|
|||
if '.' in YTDomain:
|
||||
args.YTReplacementDomain = YTDomain
|
||||
|
||||
if setTheme(baseDir, themeName):
|
||||
if setTheme(baseDir, themeName, domain):
|
||||
print('Theme set to ' + themeName)
|
||||
|
||||
if __name__ == "__main__":
|
||||
runDaemon(args.blogsinstance, args.mediainstance,
|
||||
runDaemon(args.dateonly,
|
||||
args.votingtime,
|
||||
args.positivevoting,
|
||||
args.minimumvotes,
|
||||
args.newsinstance,
|
||||
args.blogsinstance, args.mediainstance,
|
||||
args.maxRecentPosts,
|
||||
not args.nosharedinbox,
|
||||
registration, args.language, __version__,
|
||||
|
|
|
|||
|
|
@ -574,6 +574,10 @@ def receiveFollowRequest(session, baseDir: str, httpPrefix: str,
|
|||
print('DEBUG: follow request does not contain a ' +
|
||||
'nickname for the account followed')
|
||||
return True
|
||||
if nicknameToFollow == 'news' or nicknameToFollow == 'inbox':
|
||||
if debug:
|
||||
print('DEBUG: Cannot follow the news or inbox accounts')
|
||||
return True
|
||||
handleToFollow = nicknameToFollow + '@' + domainToFollow
|
||||
if domainToFollow == domain:
|
||||
if not os.path.isdir(baseDir + '/accounts/' + handleToFollow):
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ You will need python version 3.7 or later.
|
|||
|
||||
On a Debian based system:
|
||||
|
||||
sudo apt install -y tor python3-socks imagemagick python3-numpy python3-setuptools python3-crypto python3-pycryptodome python3-dateutil python3-pil.imagetk python3-idna python3-requests python3-flake8 python3-pyld python3-django-timezone-field python3-pyqrcode python3-png python3-bandit libimage-exiftool-perl certbot nginx
|
||||
sudo apt install -y tor python3-socks imagemagick python3-numpy python3-setuptools python3-crypto python3-pycryptodome python3-dateutil python3-pil.imagetk python3-idna python3-requests python3-flake8 python3-django-timezone-field python3-pyqrcode python3-png python3-bandit libimage-exiftool-perl certbot nginx
|
||||
|
||||
The following instructions install Epicyon to the /opt directory. It's not essential that it be installed there, and it could be in any other preferred directory.
|
||||
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 89 KiB |
36
inbox.py
|
|
@ -129,20 +129,22 @@ def inboxStorePostToHtmlCache(recentPostsCache: {}, maxRecentPosts: int,
|
|||
session, cachedWebfingers: {}, personCache: {},
|
||||
nickname: str, domain: str, port: int,
|
||||
postJsonObject: {},
|
||||
allowDeletion: bool, boxname: str) -> None:
|
||||
allowDeletion: bool, boxname: str,
|
||||
showPublishedDateOnly: bool) -> None:
|
||||
"""Converts the json post into html and stores it in a cache
|
||||
This enables the post to be quickly displayed later
|
||||
"""
|
||||
pageNumber = -999
|
||||
avatarUrl = None
|
||||
if boxname != 'tlevents' and boxname != 'outbox':
|
||||
boxName = 'inbox'
|
||||
boxname = 'inbox'
|
||||
individualPostAsHtml(True, recentPostsCache, maxRecentPosts,
|
||||
getIconsDir(baseDir), translate, pageNumber,
|
||||
baseDir, session, cachedWebfingers, personCache,
|
||||
nickname, domain, port, postJsonObject,
|
||||
avatarUrl, True, allowDeletion,
|
||||
httpPrefix, __version__, boxName,
|
||||
httpPrefix, __version__, boxname, None,
|
||||
showPublishedDateOnly,
|
||||
not isDM(postJsonObject),
|
||||
True, True, False, True)
|
||||
|
||||
|
|
@ -2027,7 +2029,8 @@ def inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int,
|
|||
queueFilename: str, destinationFilename: str,
|
||||
maxReplies: int, allowDeletion: bool,
|
||||
maxMentions: int, maxEmoji: int, translate: {},
|
||||
unitTest: bool, YTReplacementDomain: str) -> bool:
|
||||
unitTest: bool, YTReplacementDomain: str,
|
||||
showPublishedDateOnly: bool) -> bool:
|
||||
""" Anything which needs to be done after initial checks have passed
|
||||
"""
|
||||
actor = keyId
|
||||
|
|
@ -2235,13 +2238,23 @@ def inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int,
|
|||
sendingActorDomain, sendingActorPort = \
|
||||
getDomainFromActor(sendingActor)
|
||||
if sendingActorNickname and sendingActorDomain:
|
||||
if not os.path.isfile(followingFilename):
|
||||
print('No following.txt file exists for ' +
|
||||
nickname + '@' + domain +
|
||||
' so not accepting DM from ' +
|
||||
sendingActorNickname + '@' +
|
||||
sendingActorDomain)
|
||||
return False
|
||||
sendH = \
|
||||
sendingActorNickname + '@' + sendingActorDomain
|
||||
if sendH != nickname + '@' + domain:
|
||||
if sendH not in open(followingFilename).read():
|
||||
if sendH not in \
|
||||
open(followingFilename).read():
|
||||
print(nickname + '@' + domain +
|
||||
' cannot receive DM from ' + sendH +
|
||||
' because they do not follow them')
|
||||
' cannot receive DM from ' +
|
||||
sendH +
|
||||
' because they do not ' +
|
||||
'follow them')
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
|
@ -2327,7 +2340,8 @@ def inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int,
|
|||
domain, port,
|
||||
postJsonObject,
|
||||
allowDeletion,
|
||||
boxname)
|
||||
boxname,
|
||||
showPublishedDateOnly)
|
||||
if debug:
|
||||
timeDiff = \
|
||||
str(int((time.time() - htmlCacheStartTime) *
|
||||
|
|
@ -2421,7 +2435,8 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int,
|
|||
domainMaxPostsPerDay: int, accountMaxPostsPerDay: int,
|
||||
allowDeletion: bool, debug: bool, maxMentions: int,
|
||||
maxEmoji: int, translate: {}, unitTest: bool,
|
||||
YTReplacementDomain: str) -> None:
|
||||
YTReplacementDomain: str,
|
||||
showPublishedDateOnly: bool) -> None:
|
||||
"""Processes received items and moves them to the appropriate
|
||||
directories
|
||||
"""
|
||||
|
|
@ -2833,7 +2848,8 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int,
|
|||
maxReplies, allowDeletion,
|
||||
maxMentions, maxEmoji,
|
||||
translate, unitTest,
|
||||
YTReplacementDomain)
|
||||
YTReplacementDomain,
|
||||
showPublishedDateOnly)
|
||||
if debug:
|
||||
pprint(queueJson['post'])
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ except ImportError:
|
|||
from Crypto.Hash import SHA256
|
||||
from Crypto.Signature import PKCS1_v1_5
|
||||
|
||||
from pyld import jsonld
|
||||
from pyjsonld import normalize
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
|
@ -107,7 +107,7 @@ def jsonldNormalize(jldDocument: str):
|
|||
'algorithm': 'URDNA2015',
|
||||
'format': 'application/nquads'
|
||||
}
|
||||
normalized = jsonld.normalize(jldDocument, options=options)
|
||||
normalized = normalize(jldDocument, options=options)
|
||||
normalizedHash = SHA256.new(data=normalized.encode('utf-8')).digest()
|
||||
return normalizedHash
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
__filename__ = "newsdaemon.py"
|
||||
__author__ = "Bob Mottram"
|
||||
__license__ = "AGPL3+"
|
||||
__version__ = "1.1.0"
|
||||
__maintainer__ = "Bob Mottram"
|
||||
__email__ = "bob@freedombone.net"
|
||||
__status__ = "Production"
|
||||
|
||||
import os
|
||||
import time
|
||||
import datetime
|
||||
from collections import OrderedDict
|
||||
from newswire import getDictFromNewswire
|
||||
from posts import createNewsPost
|
||||
from content import removeHtmlTag
|
||||
from content import dangerousMarkup
|
||||
from utils import loadJson
|
||||
from utils import saveJson
|
||||
from utils import getStatusNumber
|
||||
|
||||
|
||||
def updateFeedsOutboxIndex(baseDir: str, domain: str, postId: str) -> None:
|
||||
"""Updates the index used for imported RSS feeds
|
||||
"""
|
||||
basePath = baseDir + '/accounts/news@' + domain
|
||||
indexFilename = basePath + '/outbox.index'
|
||||
|
||||
if os.path.isfile(indexFilename):
|
||||
if postId not in open(indexFilename).read():
|
||||
try:
|
||||
with open(indexFilename, 'r+') as feedsFile:
|
||||
content = feedsFile.read()
|
||||
feedsFile.seek(0, 0)
|
||||
feedsFile.write(postId + '\n' + content)
|
||||
print('DEBUG: feeds post added to index')
|
||||
except Exception as e:
|
||||
print('WARN: Failed to write entry to feeds posts index ' +
|
||||
indexFilename + ' ' + str(e))
|
||||
else:
|
||||
feedsFile = open(indexFilename, 'w+')
|
||||
if feedsFile:
|
||||
feedsFile.write(postId + '\n')
|
||||
feedsFile.close()
|
||||
|
||||
|
||||
def saveArrivedTime(baseDir: str, postFilename: str, arrived: str) -> None:
|
||||
"""Saves the time when an rss post arrived to a file
|
||||
"""
|
||||
arrivedFile = open(postFilename + '.arrived', 'w+')
|
||||
if arrivedFile:
|
||||
arrivedFile.write(arrived)
|
||||
arrivedFile.close()
|
||||
|
||||
|
||||
def removeControlCharacters(content: str) -> str:
|
||||
"""TODO this is hacky and a better solution is needed
|
||||
the unicode is messing up somehow
|
||||
"""
|
||||
lookups = {
|
||||
"8211": "-",
|
||||
"8230": "...",
|
||||
"8216": "'",
|
||||
"8217": "'",
|
||||
"8220": '"',
|
||||
"8221": '"'
|
||||
}
|
||||
for code, ch in lookups.items():
|
||||
content = content.replace('&' + code + ';', ch)
|
||||
content = content.replace('&#' + code + ';', ch)
|
||||
return content
|
||||
|
||||
|
||||
def convertRSStoActivityPub(baseDir: str, httpPrefix: str,
|
||||
domain: str, port: int,
|
||||
newswire: {},
|
||||
translate: {},
|
||||
recentPostsCache: {}, maxRecentPosts: int,
|
||||
session, cachedWebfingers: {},
|
||||
personCache: {}) -> None:
|
||||
"""Converts rss items in a newswire into posts
|
||||
"""
|
||||
basePath = baseDir + '/accounts/news@' + domain + '/outbox'
|
||||
if not os.path.isdir(basePath):
|
||||
os.mkdir(basePath)
|
||||
|
||||
# oldest items first
|
||||
newswireReverse = \
|
||||
OrderedDict(sorted(newswire.items(), reverse=False))
|
||||
|
||||
for dateStr, item in newswireReverse.items():
|
||||
originalDateStr = dateStr
|
||||
# convert the date to the format used by ActivityPub
|
||||
dateStr = dateStr.replace(' ', 'T')
|
||||
dateStr = dateStr.replace('+00:00', 'Z')
|
||||
|
||||
statusNumber, published = getStatusNumber(dateStr)
|
||||
newPostId = \
|
||||
httpPrefix + '://' + domain + \
|
||||
'/users/news/statuses/' + statusNumber
|
||||
|
||||
# file where the post is stored
|
||||
filename = basePath + '/' + newPostId.replace('/', '#') + '.json'
|
||||
if os.path.isfile(filename):
|
||||
# don't create the post if it already exists
|
||||
# set the url
|
||||
newswire[originalDateStr][1] = \
|
||||
'/users/news/statuses/' + statusNumber
|
||||
# set the filename
|
||||
newswire[originalDateStr][3] = filename
|
||||
continue
|
||||
|
||||
rssTitle = removeControlCharacters(item[0])
|
||||
url = item[1]
|
||||
if dangerousMarkup(url) or dangerousMarkup(rssTitle):
|
||||
continue
|
||||
rssDescription = ''
|
||||
|
||||
# get the rss description if it exists
|
||||
rssDescription = removeControlCharacters(item[4])
|
||||
if rssDescription.startswith('<![CDATA['):
|
||||
rssDescription = rssDescription.replace('<![CDATA[', '')
|
||||
rssDescription = rssDescription.replace(']]>', '')
|
||||
rssDescription = '<p>' + rssDescription + '<p>'
|
||||
|
||||
# add the off-site link to the description
|
||||
if rssDescription and not dangerousMarkup(rssDescription):
|
||||
rssDescription += \
|
||||
'<br><a href="' + url + '">' + \
|
||||
translate['Read more...'] + '</a>'
|
||||
else:
|
||||
rssDescription = \
|
||||
'<a href="' + url + '">' + \
|
||||
translate['Read more...'] + '</a>'
|
||||
|
||||
# remove image dimensions
|
||||
if '<img' in rssDescription:
|
||||
rssDescription = removeHtmlTag(rssDescription, 'width')
|
||||
rssDescription = removeHtmlTag(rssDescription, 'height')
|
||||
|
||||
followersOnly = False
|
||||
useBlurhash = False
|
||||
# NOTE: the id when the post is created will not be
|
||||
# consistent (it's based on the current time, not the
|
||||
# published time), so we change that later
|
||||
blog = createNewsPost(baseDir,
|
||||
domain, port, httpPrefix,
|
||||
rssDescription,
|
||||
followersOnly, False,
|
||||
None, None, None, useBlurhash,
|
||||
rssTitle)
|
||||
if not blog:
|
||||
continue
|
||||
|
||||
idStr = \
|
||||
httpPrefix + '://' + domain + '/users/news' + \
|
||||
'/statuses/' + statusNumber + '/replies'
|
||||
blog['news'] = True
|
||||
|
||||
# note the time of arrival
|
||||
currTime = datetime.datetime.utcnow()
|
||||
blog['object']['arrived'] = currTime.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# change the id, based upon the published time
|
||||
blog['object']['replies']['id'] = idStr
|
||||
blog['object']['replies']['first']['partOf'] = idStr
|
||||
|
||||
blog['id'] = newPostId + '/activity'
|
||||
blog['object']['id'] = newPostId
|
||||
blog['object']['atomUri'] = newPostId
|
||||
blog['object']['url'] = \
|
||||
httpPrefix + '://' + domain + '/@news/' + statusNumber
|
||||
blog['object']['published'] = dateStr
|
||||
|
||||
postId = newPostId.replace('/', '#')
|
||||
|
||||
moderated = item[5]
|
||||
|
||||
# save the post and update the index
|
||||
if saveJson(blog, filename):
|
||||
updateFeedsOutboxIndex(baseDir, domain, postId + '.json')
|
||||
|
||||
# Save a file containing the time when the post arrived
|
||||
# this can then later be used to construct the news timeline
|
||||
# excluding items during the voting period
|
||||
if moderated:
|
||||
saveArrivedTime(baseDir, filename, blog['object']['arrived'])
|
||||
else:
|
||||
if os.path.isfile(filename + '.arrived'):
|
||||
os.remove(filename + '.arrived')
|
||||
|
||||
# set the url
|
||||
newswire[originalDateStr][1] = \
|
||||
'/users/news/statuses/' + statusNumber
|
||||
# set the filename
|
||||
newswire[originalDateStr][3] = filename
|
||||
|
||||
|
||||
def mergeWithPreviousNewswire(oldNewswire: {}, newNewswire: {}) -> None:
|
||||
"""Preserve any votes or generated activitypub post filename
|
||||
as rss feeds are updated
|
||||
"""
|
||||
for published, fields in oldNewswire.items():
|
||||
if not newNewswire.get(published):
|
||||
continue
|
||||
newNewswire[published][1] = fields[1]
|
||||
newNewswire[published][2] = fields[2]
|
||||
newNewswire[published][3] = fields[3]
|
||||
|
||||
|
||||
def runNewswireDaemon(baseDir: str, httpd,
|
||||
httpPrefix: str, domain: str, port: int,
|
||||
translate: {}) -> None:
|
||||
"""Periodically updates RSS feeds
|
||||
"""
|
||||
newswireStateFilename = baseDir + '/accounts/.newswirestate.json'
|
||||
|
||||
# initial sleep to allow the system to start up
|
||||
time.sleep(50)
|
||||
while True:
|
||||
# has the session been created yet?
|
||||
if not httpd.session:
|
||||
print('Newswire daemon waiting for session')
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
# try to update the feeds
|
||||
newNewswire = None
|
||||
try:
|
||||
newNewswire = getDictFromNewswire(httpd.session, baseDir)
|
||||
except Exception as e:
|
||||
print('WARN: unable to update newswire ' + str(e))
|
||||
time.sleep(120)
|
||||
continue
|
||||
|
||||
if not httpd.newswire:
|
||||
if os.path.isfile(newswireStateFilename):
|
||||
httpd.newswire = loadJson(newswireStateFilename)
|
||||
|
||||
mergeWithPreviousNewswire(httpd.newswire, newNewswire)
|
||||
|
||||
httpd.newswire = newNewswire
|
||||
saveJson(httpd.newswire, newswireStateFilename)
|
||||
print('Newswire updated')
|
||||
|
||||
convertRSStoActivityPub(baseDir,
|
||||
httpPrefix, domain, port,
|
||||
newNewswire, translate,
|
||||
httpd.recentPostsCache,
|
||||
httpd.maxRecentPosts,
|
||||
httpd.session,
|
||||
httpd.cachedWebfingers,
|
||||
httpd.personCache)
|
||||
print('Newswire feed converted to ActivityPub')
|
||||
|
||||
# wait a while before the next feeds update
|
||||
time.sleep(1200)
|
||||
|
||||
|
||||
def runNewswireWatchdog(projectVersion: str, httpd) -> None:
|
||||
"""This tries to keep the newswire update thread running even if it dies
|
||||
"""
|
||||
print('Starting newswire watchdog')
|
||||
newswireOriginal = \
|
||||
httpd.thrPostSchedule.clone(runNewswireDaemon)
|
||||
httpd.thrNewswireDaemon.start()
|
||||
while True:
|
||||
time.sleep(50)
|
||||
if not httpd.thrNewswireDaemon.isAlive():
|
||||
httpd.thrNewswireDaemon.kill()
|
||||
httpd.thrNewswireDaemon = \
|
||||
newswireOriginal.clone(runNewswireDaemon)
|
||||
httpd.thrNewswireDaemon.start()
|
||||
print('Restarting newswire daemon...')
|
||||
247
newswire.py
|
|
@ -7,7 +7,6 @@ __email__ = "bob@freedombone.net"
|
|||
__status__ = "Production"
|
||||
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from socket import error as SocketError
|
||||
import errno
|
||||
|
|
@ -15,11 +14,15 @@ from datetime import datetime
|
|||
from collections import OrderedDict
|
||||
from utils import locatePost
|
||||
from utils import loadJson
|
||||
from utils import saveJson
|
||||
from utils import isSuspended
|
||||
|
||||
|
||||
def rss2Header(httpPrefix: str,
|
||||
nickname: str, domainFull: str,
|
||||
title: str, translate: {}) -> str:
|
||||
"""Header for an RSS 2.0 feed
|
||||
"""
|
||||
rssStr = "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"
|
||||
rssStr += "<rss version=\"2.0\">"
|
||||
rssStr += '<channel>'
|
||||
|
|
@ -37,12 +40,14 @@ def rss2Header(httpPrefix: str,
|
|||
|
||||
|
||||
def rss2Footer() -> str:
|
||||
"""Footer for an RSS 2.0 feed
|
||||
"""
|
||||
rssStr = '</channel>'
|
||||
rssStr += '</rss>'
|
||||
return rssStr
|
||||
|
||||
|
||||
def xml2StrToDict(xmlStr: str) -> {}:
|
||||
def xml2StrToDict(xmlStr: str, moderated: bool) -> {}:
|
||||
"""Converts an xml 2.0 string to a dictionary
|
||||
"""
|
||||
if '<item>' not in xmlStr:
|
||||
|
|
@ -64,6 +69,10 @@ def xml2StrToDict(xmlStr: str) -> {}:
|
|||
continue
|
||||
title = rssItem.split('<title>')[1]
|
||||
title = title.split('</title>')[0]
|
||||
description = ''
|
||||
if '<description>' in rssItem and '</description>' in rssItem:
|
||||
description = rssItem.split('<description>')[1]
|
||||
description = description.split('</description>')[0]
|
||||
link = rssItem.split('<link>')[1]
|
||||
link = link.split('</link>')[0]
|
||||
pubDate = rssItem.split('<pubDate>')[1]
|
||||
|
|
@ -72,7 +81,11 @@ def xml2StrToDict(xmlStr: str) -> {}:
|
|||
try:
|
||||
publishedDate = \
|
||||
datetime.strptime(pubDate, "%a, %d %b %Y %H:%M:%S %z")
|
||||
result[str(publishedDate)] = [title, link]
|
||||
postFilename = ''
|
||||
votesStatus = []
|
||||
result[str(publishedDate)] = [title, link,
|
||||
votesStatus, postFilename,
|
||||
description, moderated]
|
||||
parsed = True
|
||||
except BaseException:
|
||||
pass
|
||||
|
|
@ -88,15 +101,71 @@ def xml2StrToDict(xmlStr: str) -> {}:
|
|||
return result
|
||||
|
||||
|
||||
def xmlStrToDict(xmlStr: str) -> {}:
|
||||
def atomFeedToDict(xmlStr: str, moderated: bool) -> {}:
|
||||
"""Converts an atom feed string to a dictionary
|
||||
"""
|
||||
if '<entry>' not in xmlStr:
|
||||
return {}
|
||||
result = {}
|
||||
rssItems = xmlStr.split('<entry>')
|
||||
for rssItem in rssItems:
|
||||
if '<title>' not in rssItem:
|
||||
continue
|
||||
if '</title>' not in rssItem:
|
||||
continue
|
||||
if '<link>' not in rssItem:
|
||||
continue
|
||||
if '</link>' not in rssItem:
|
||||
continue
|
||||
if '<updated>' not in rssItem:
|
||||
continue
|
||||
if '</updated>' not in rssItem:
|
||||
continue
|
||||
title = rssItem.split('<title>')[1]
|
||||
title = title.split('</title>')[0]
|
||||
description = ''
|
||||
if '<summary>' in rssItem and '</summary>' in rssItem:
|
||||
description = rssItem.split('<summary>')[1]
|
||||
description = description.split('</summary>')[0]
|
||||
link = rssItem.split('<link>')[1]
|
||||
link = link.split('</link>')[0]
|
||||
pubDate = rssItem.split('<updated>')[1]
|
||||
pubDate = pubDate.split('</updated>')[0]
|
||||
parsed = False
|
||||
try:
|
||||
publishedDate = \
|
||||
datetime.strptime(pubDate, "%Y-%m-%dT%H:%M:%SZ")
|
||||
postFilename = ''
|
||||
votesStatus = []
|
||||
result[str(publishedDate)] = [title, link,
|
||||
votesStatus, postFilename,
|
||||
description, moderated]
|
||||
parsed = True
|
||||
except BaseException:
|
||||
pass
|
||||
if not parsed:
|
||||
try:
|
||||
publishedDate = \
|
||||
datetime.strptime(pubDate, "%a, %d %b %Y %H:%M:%S UT")
|
||||
result[str(publishedDate) + '+00:00'] = [title, link]
|
||||
parsed = True
|
||||
except BaseException:
|
||||
print('WARN: unrecognized atom feed date format: ' + pubDate)
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def xmlStrToDict(xmlStr: str, moderated: bool) -> {}:
|
||||
"""Converts an xml string to a dictionary
|
||||
"""
|
||||
if 'rss version="2.0"' in xmlStr:
|
||||
return xml2StrToDict(xmlStr)
|
||||
return xml2StrToDict(xmlStr, moderated)
|
||||
elif 'xmlns="http://www.w3.org/2005/Atom"' in xmlStr:
|
||||
return atomFeedToDict(xmlStr, moderated)
|
||||
return {}
|
||||
|
||||
|
||||
def getRSS(session, url: str) -> {}:
|
||||
def getRSS(session, url: str, moderated: bool) -> {}:
|
||||
"""Returns an RSS url as a dict
|
||||
"""
|
||||
if not isinstance(url, str):
|
||||
|
|
@ -119,7 +188,7 @@ def getRSS(session, url: str) -> {}:
|
|||
print('WARN: no session specified for getRSS')
|
||||
try:
|
||||
result = session.get(url, headers=sessionHeaders, params=sessionParams)
|
||||
return xmlStrToDict(result.text)
|
||||
return xmlStrToDict(result.text, moderated)
|
||||
except requests.exceptions.RequestException as e:
|
||||
print('ERROR: getRSS failed\nurl: ' + str(url) + '\n' +
|
||||
'headers: ' + str(sessionHeaders) + '\n' +
|
||||
|
|
@ -155,7 +224,10 @@ def getRSSfromDict(baseDir: str, newswire: {},
|
|||
continue
|
||||
rssStr += '<item>\n'
|
||||
rssStr += ' <title>' + fields[0] + '</title>\n'
|
||||
rssStr += ' <link>' + fields[1] + '</link>\n'
|
||||
url = fields[1]
|
||||
if domainFull not in url:
|
||||
url = httpPrefix + '://' + domainFull + url
|
||||
rssStr += ' <link>' + url + '</link>\n'
|
||||
|
||||
rssDateStr = pubDate.strftime("%a, %d %b %Y %H:%M:%S UT")
|
||||
rssStr += ' <pubDate>' + rssDateStr + '</pubDate>\n'
|
||||
|
|
@ -164,6 +236,22 @@ def getRSSfromDict(baseDir: str, newswire: {},
|
|||
return rssStr
|
||||
|
||||
|
||||
def isaBlogPost(postJsonObject: {}) -> bool:
|
||||
"""Is the given object a blog post?
|
||||
"""
|
||||
if not postJsonObject:
|
||||
return False
|
||||
if not postJsonObject.get('object'):
|
||||
return False
|
||||
if not isinstance(postJsonObject['object'], dict):
|
||||
return False
|
||||
if postJsonObject['object'].get('summary') and \
|
||||
postJsonObject['object'].get('url') and \
|
||||
postJsonObject['object'].get('published'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def addAccountBlogsToNewswire(baseDir: str, nickname: str, domain: str,
|
||||
newswire: {},
|
||||
maxBlogsPerAccount: int,
|
||||
|
|
@ -172,6 +260,16 @@ def addAccountBlogsToNewswire(baseDir: str, nickname: str, domain: str,
|
|||
"""
|
||||
if not os.path.isfile(indexFilename):
|
||||
return
|
||||
# local blog entries are unmoderated by default
|
||||
moderated = False
|
||||
|
||||
# local blogs can potentially be moderated
|
||||
moderatedFilename = \
|
||||
baseDir + '/accounts/' + nickname + '@' + domain + \
|
||||
'/.newswiremoderated'
|
||||
if os.path.isfile(moderatedFilename):
|
||||
moderated = True
|
||||
|
||||
with open(indexFilename, 'r') as indexFile:
|
||||
postFilename = 'start'
|
||||
ctr = 0
|
||||
|
|
@ -193,43 +291,39 @@ def addAccountBlogsToNewswire(baseDir: str, nickname: str, domain: str,
|
|||
fullPostFilename = \
|
||||
locatePost(baseDir, nickname,
|
||||
domain, postUrl, False)
|
||||
isAPost = False
|
||||
if not fullPostFilename:
|
||||
print('Unable to locate post ' + postUrl)
|
||||
ctr += 1
|
||||
if ctr >= maxBlogsPerAccount:
|
||||
break
|
||||
continue
|
||||
|
||||
postJsonObject = None
|
||||
if fullPostFilename:
|
||||
postJsonObject = loadJson(fullPostFilename)
|
||||
if postJsonObject:
|
||||
if postJsonObject.get('object'):
|
||||
if isinstance(postJsonObject['object'], dict):
|
||||
isAPost = True
|
||||
if isAPost:
|
||||
if postJsonObject['object'].get('summary') and \
|
||||
postJsonObject['object'].get('url') and \
|
||||
postJsonObject['object'].get('published'):
|
||||
if isaBlogPost(postJsonObject):
|
||||
published = postJsonObject['object']['published']
|
||||
published = published.replace('T', ' ')
|
||||
published = published.replace('Z', '+00:00')
|
||||
votes = []
|
||||
if os.path.isfile(fullPostFilename + '.votes'):
|
||||
votes = loadJson(fullPostFilename + '.votes')
|
||||
description = ''
|
||||
newswire[published] = \
|
||||
[postJsonObject['object']['summary'],
|
||||
postJsonObject['object']['url']]
|
||||
postJsonObject['object']['url'], votes,
|
||||
fullPostFilename, description, moderated]
|
||||
|
||||
ctr += 1
|
||||
if ctr >= maxBlogsPerAccount:
|
||||
break
|
||||
|
||||
|
||||
def addLocalBlogsToNewswire(baseDir: str, newswire: {},
|
||||
def addBlogsToNewswire(baseDir: str, newswire: {},
|
||||
maxBlogsPerAccount: int) -> None:
|
||||
"""Adds blogs from this instance into the newswire
|
||||
"""Adds blogs from each user account into the newswire
|
||||
"""
|
||||
# get the list of handles who are trusted to post to the newswire
|
||||
newswireTrusted = ''
|
||||
newswireTrustedFilename = baseDir + '/accounts/newswiretrusted.txt'
|
||||
if os.path.isfile(newswireTrustedFilename):
|
||||
with open(newswireTrustedFilename, "r") as trustFile:
|
||||
newswireTrusted = trustFile.read()
|
||||
|
||||
# file containing suspended account nicknames
|
||||
suspendedFilename = baseDir + '/accounts/suspended.txt'
|
||||
moderationDict = {}
|
||||
|
||||
# go through each account
|
||||
for subdir, dirs, files in os.walk(baseDir + '/accounts'):
|
||||
|
|
@ -238,25 +332,19 @@ def addLocalBlogsToNewswire(baseDir: str, newswire: {},
|
|||
continue
|
||||
if 'inbox@' in handle:
|
||||
continue
|
||||
if handle not in newswireTrusted:
|
||||
if handle.split('@')[0] + '\n' not in newswireTrusted:
|
||||
continue
|
||||
accountDir = os.path.join(baseDir + '/accounts', handle)
|
||||
|
||||
nickname = handle.split('@')[0]
|
||||
|
||||
# has this account been suspended?
|
||||
nickname = handle.split('@')[0]
|
||||
if os.path.isfile(suspendedFilename):
|
||||
with open(suspendedFilename, "r") as f:
|
||||
lines = f.readlines()
|
||||
foundSuspended = False
|
||||
for nick in lines:
|
||||
if nick == nickname + '\n':
|
||||
foundSuspended = True
|
||||
break
|
||||
if foundSuspended:
|
||||
if isSuspended(baseDir, nickname):
|
||||
continue
|
||||
|
||||
if os.path.isfile(baseDir + '/accounts/' + handle +
|
||||
'/.nonewswire'):
|
||||
continue
|
||||
|
||||
# is there a blogs timeline for this account?
|
||||
accountDir = os.path.join(baseDir + '/accounts', handle)
|
||||
blogsIndex = accountDir + '/tlblogs.index'
|
||||
if os.path.isfile(blogsIndex):
|
||||
domain = handle.split('@')[1]
|
||||
|
|
@ -264,6 +352,18 @@ def addLocalBlogsToNewswire(baseDir: str, newswire: {},
|
|||
newswire, maxBlogsPerAccount,
|
||||
blogsIndex)
|
||||
|
||||
# sort the moderation dict into chronological order, latest first
|
||||
sortedModerationDict = \
|
||||
OrderedDict(sorted(moderationDict.items(), reverse=True))
|
||||
# save the moderation queue details for later display
|
||||
newswireModerationFilename = baseDir + '/accounts/newswiremoderation.txt'
|
||||
if sortedModerationDict:
|
||||
saveJson(sortedModerationDict, newswireModerationFilename)
|
||||
else:
|
||||
# remove the file if there is nothing to moderate
|
||||
if os.path.isfile(newswireModerationFilename):
|
||||
os.remove(newswireModerationFilename)
|
||||
|
||||
|
||||
def getDictFromNewswire(session, baseDir: str) -> {}:
|
||||
"""Gets rss feeds as a dictionary from newswire file
|
||||
|
|
@ -279,61 +379,28 @@ def getDictFromNewswire(session, baseDir: str) -> {}:
|
|||
result = {}
|
||||
for url in rssFeed:
|
||||
url = url.strip()
|
||||
|
||||
# Does this contain a url?
|
||||
if '://' not in url:
|
||||
continue
|
||||
|
||||
# is this a comment?
|
||||
if url.startswith('#'):
|
||||
continue
|
||||
itemsList = getRSS(session, url)
|
||||
|
||||
# should this feed be moderated?
|
||||
moderated = False
|
||||
if '*' in url:
|
||||
moderated = True
|
||||
url = url.replace('*', '').strip()
|
||||
|
||||
itemsList = getRSS(session, url, moderated)
|
||||
for dateStr, item in itemsList.items():
|
||||
result[dateStr] = item
|
||||
|
||||
# add local content
|
||||
addLocalBlogsToNewswire(baseDir, result, 5)
|
||||
# add blogs from each user account
|
||||
addBlogsToNewswire(baseDir, result, 5)
|
||||
|
||||
# sort into chronological order, latest first
|
||||
sortedResult = OrderedDict(sorted(result.items(), reverse=True))
|
||||
return sortedResult
|
||||
|
||||
|
||||
def runNewswireDaemon(baseDir: str, httpd):
|
||||
"""Periodically updates RSS feeds
|
||||
"""
|
||||
# initial sleep to allow the system to start up
|
||||
time.sleep(70)
|
||||
while True:
|
||||
# has the session been created yet?
|
||||
if not httpd.session:
|
||||
print('Newswire daemon waiting for session')
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
# try to update the feeds
|
||||
newNewswire = None
|
||||
try:
|
||||
newNewswire = getDictFromNewswire(httpd.session, baseDir)
|
||||
except BaseException:
|
||||
print('WARN: unable to update newswire')
|
||||
time.sleep(120)
|
||||
continue
|
||||
|
||||
httpd.newswire = newNewswire
|
||||
print('Newswire updated')
|
||||
# wait a while before the next feeds update
|
||||
time.sleep(1200)
|
||||
|
||||
|
||||
def runNewswireWatchdog(projectVersion: str, httpd) -> None:
|
||||
"""This tries to keep the newswire update thread running even if it dies
|
||||
"""
|
||||
print('Starting newswire watchdog')
|
||||
newswireOriginal = \
|
||||
httpd.thrPostSchedule.clone(runNewswireDaemon)
|
||||
httpd.thrNewswireDaemon.start()
|
||||
while True:
|
||||
time.sleep(50)
|
||||
if not httpd.thrNewswireDaemon.isAlive():
|
||||
httpd.thrNewswireDaemon.kill()
|
||||
httpd.thrNewswireDaemon = \
|
||||
newswireOriginal.clone(runNewswireDaemon)
|
||||
httpd.thrNewswireDaemon.start()
|
||||
print('Restarting newswire daemon...')
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str,
|
|||
postLog: [], cachedWebfingers: {},
|
||||
personCache: {}, allowDeletion: bool,
|
||||
proxyType: str, version: str, debug: bool,
|
||||
YTReplacementDomain: str) -> bool:
|
||||
YTReplacementDomain: str,
|
||||
showPublishedDateOnly: bool) -> bool:
|
||||
"""post is received by the outbox
|
||||
Client to server message post
|
||||
https://www.w3.org/TR/activitypub/#client-to-server-outbox-delivery
|
||||
|
|
|
|||
53
person.py
|
|
@ -23,6 +23,7 @@ from webfinger import storeWebfingerEndpoint
|
|||
from posts import createDMTimeline
|
||||
from posts import createRepliesTimeline
|
||||
from posts import createMediaTimeline
|
||||
from posts import createNewsTimeline
|
||||
from posts import createBlogsTimeline
|
||||
from posts import createBookmarksTimeline
|
||||
from posts import createEventsTimeline
|
||||
|
|
@ -34,11 +35,10 @@ from auth import removePassword
|
|||
from roles import setRole
|
||||
from media import removeMetaData
|
||||
from utils import validNickname
|
||||
from utils import noOfAccounts
|
||||
from utils import loadJson
|
||||
from utils import saveJson
|
||||
from config import setConfigParam
|
||||
from config import getConfigParam
|
||||
from utils import setConfigParam
|
||||
from utils import getConfigParam
|
||||
|
||||
|
||||
def generateRSAKey() -> (str, str):
|
||||
|
|
@ -444,12 +444,13 @@ def createPerson(baseDir: str, nickname: str, domain: str, port: int,
|
|||
saveToFile,
|
||||
manualFollowerApproval,
|
||||
password)
|
||||
if noOfAccounts(baseDir) == 1:
|
||||
if not getConfigParam(baseDir, 'admin'):
|
||||
# print(nickname+' becomes the instance admin and a moderator')
|
||||
setConfigParam(baseDir, 'admin', nickname)
|
||||
setRole(baseDir, nickname, domain, 'instance', 'admin')
|
||||
setRole(baseDir, nickname, domain, 'instance', 'moderator')
|
||||
setRole(baseDir, nickname, domain, 'instance', 'editor')
|
||||
setRole(baseDir, nickname, domain, 'instance', 'delegator')
|
||||
setConfigParam(baseDir, 'admin', nickname)
|
||||
|
||||
if not os.path.isdir(baseDir + '/accounts'):
|
||||
os.mkdir(baseDir + '/accounts')
|
||||
|
|
@ -503,6 +504,14 @@ def createSharedInbox(baseDir: str, nickname: str, domain: str, port: int,
|
|||
True, True, None)
|
||||
|
||||
|
||||
def createNewsInbox(baseDir: str, domain: str, port: int,
|
||||
httpPrefix: str) -> (str, str, {}, {}):
|
||||
"""Generates the news inbox
|
||||
"""
|
||||
return createPersonBase(baseDir, 'news', domain, port, httpPrefix,
|
||||
True, True, None)
|
||||
|
||||
|
||||
def personUpgradeActor(baseDir: str, personJson: {},
|
||||
handle: str, filename: str) -> None:
|
||||
"""Alter the actor to add any new properties
|
||||
|
|
@ -586,12 +595,14 @@ def personLookup(domain: str, path: str, baseDir: str) -> {}:
|
|||
def personBoxJson(recentPostsCache: {},
|
||||
session, baseDir: str, domain: str, port: int, path: str,
|
||||
httpPrefix: str, noOfItems: int, boxname: str,
|
||||
authorized: bool) -> {}:
|
||||
authorized: bool,
|
||||
newswireVotesThreshold: int, positiveVoting: bool,
|
||||
votingTimeMins: int) -> {}:
|
||||
"""Obtain the inbox/outbox/moderation feed for the given person
|
||||
"""
|
||||
if boxname != 'inbox' and boxname != 'dm' and \
|
||||
boxname != 'tlreplies' and boxname != 'tlmedia' and \
|
||||
boxname != 'tlblogs' and \
|
||||
boxname != 'tlblogs' and boxname != 'tlnews' and \
|
||||
boxname != 'outbox' and boxname != 'moderation' and \
|
||||
boxname != 'tlbookmarks' and boxname != 'bookmarks' and \
|
||||
boxname != 'tlevents':
|
||||
|
|
@ -659,6 +670,11 @@ def personBoxJson(recentPostsCache: {},
|
|||
return createMediaTimeline(session, baseDir, nickname, domain, port,
|
||||
httpPrefix, noOfItems, headerOnly,
|
||||
pageNumber)
|
||||
elif boxname == 'tlnews':
|
||||
return createNewsTimeline(session, baseDir, nickname, domain, port,
|
||||
httpPrefix, noOfItems, headerOnly,
|
||||
newswireVotesThreshold, positiveVoting,
|
||||
votingTimeMins, pageNumber)
|
||||
elif boxname == 'tlblogs':
|
||||
return createBlogsTimeline(session, baseDir, nickname, domain, port,
|
||||
httpPrefix, noOfItems, headerOnly,
|
||||
|
|
@ -754,23 +770,6 @@ def setBio(baseDir: str, nickname: str, domain: str, bio: str) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def isSuspended(baseDir: str, nickname: str) -> bool:
|
||||
"""Returns true if the given nickname is suspended
|
||||
"""
|
||||
adminNickname = getConfigParam(baseDir, 'admin')
|
||||
if nickname == adminNickname:
|
||||
return False
|
||||
|
||||
suspendedFilename = baseDir + '/accounts/suspended.txt'
|
||||
if os.path.isfile(suspendedFilename):
|
||||
with open(suspendedFilename, "r") as f:
|
||||
lines = f.readlines()
|
||||
for suspended in lines:
|
||||
if suspended.strip('\n').strip('\r') == nickname:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def unsuspendAccount(baseDir: str, nickname: str) -> None:
|
||||
"""Removes an account suspention
|
||||
"""
|
||||
|
|
@ -790,6 +789,8 @@ def suspendAccount(baseDir: str, nickname: str, domain: str) -> None:
|
|||
"""
|
||||
# Don't suspend the admin
|
||||
adminNickname = getConfigParam(baseDir, 'admin')
|
||||
if not adminNickname:
|
||||
return
|
||||
if nickname == adminNickname:
|
||||
return
|
||||
|
||||
|
|
@ -844,6 +845,8 @@ def canRemovePost(baseDir: str, nickname: str,
|
|||
|
||||
# is the post by the admin?
|
||||
adminNickname = getConfigParam(baseDir, 'admin')
|
||||
if not adminNickname:
|
||||
return False
|
||||
if domainFull + '/users/' + adminNickname + '/' in postId:
|
||||
return False
|
||||
|
||||
|
|
@ -900,6 +903,8 @@ def removeAccount(baseDir: str, nickname: str,
|
|||
"""
|
||||
# Don't remove the admin
|
||||
adminNickname = getConfigParam(baseDir, 'admin')
|
||||
if not adminNickname:
|
||||
return False
|
||||
if nickname == adminNickname:
|
||||
return False
|
||||
|
||||
|
|
|
|||
169
posts.py
|
|
@ -45,6 +45,10 @@ from utils import validNickname
|
|||
from utils import locatePost
|
||||
from utils import loadJson
|
||||
from utils import saveJson
|
||||
from utils import getConfigParam
|
||||
from utils import locateNewsVotes
|
||||
from utils import locateNewsArrival
|
||||
from utils import votesOnNewswireItem
|
||||
from media import attachMedia
|
||||
from media import replaceYouTube
|
||||
from content import removeHtml
|
||||
|
|
@ -53,16 +57,11 @@ from content import addHtmlTags
|
|||
from content import replaceEmojiFromTags
|
||||
from content import removeTextFormatting
|
||||
from auth import createBasicAuthHeader
|
||||
from config import getConfigParam
|
||||
from blocking import isBlocked
|
||||
from filters import isFiltered
|
||||
from git import convertPostToPatch
|
||||
from jsonldsig import jsonldSign
|
||||
from petnames import resolvePetnames
|
||||
# try:
|
||||
# from BeautifulSoup import BeautifulSoup
|
||||
# except ImportError:
|
||||
# from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
def isModerator(baseDir: str, nickname: str) -> bool:
|
||||
|
|
@ -71,14 +70,20 @@ def isModerator(baseDir: str, nickname: str) -> bool:
|
|||
moderatorsFile = baseDir + '/accounts/moderators.txt'
|
||||
|
||||
if not os.path.isfile(moderatorsFile):
|
||||
if getConfigParam(baseDir, 'admin') == nickname:
|
||||
adminName = getConfigParam(baseDir, 'admin')
|
||||
if not adminName:
|
||||
return False
|
||||
if adminName == nickname:
|
||||
return True
|
||||
return False
|
||||
|
||||
with open(moderatorsFile, "r") as f:
|
||||
lines = f.readlines()
|
||||
if len(lines) == 0:
|
||||
if getConfigParam(baseDir, 'admin') == nickname:
|
||||
adminName = getConfigParam(baseDir, 'admin')
|
||||
if not adminName:
|
||||
return False
|
||||
if adminName == nickname:
|
||||
return True
|
||||
for moderator in lines:
|
||||
moderator = moderator.strip('\n').strip('\r')
|
||||
|
|
@ -87,6 +92,34 @@ def isModerator(baseDir: str, nickname: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def isEditor(baseDir: str, nickname: str) -> bool:
|
||||
"""Returns true if the given nickname is an editor
|
||||
"""
|
||||
editorsFile = baseDir + '/accounts/editors.txt'
|
||||
|
||||
if not os.path.isfile(editorsFile):
|
||||
adminName = getConfigParam(baseDir, 'admin')
|
||||
if not adminName:
|
||||
return False
|
||||
if adminName == nickname:
|
||||
return True
|
||||
return False
|
||||
|
||||
with open(editorsFile, "r") as f:
|
||||
lines = f.readlines()
|
||||
if len(lines) == 0:
|
||||
adminName = getConfigParam(baseDir, 'admin')
|
||||
if not adminName:
|
||||
return False
|
||||
if adminName == nickname:
|
||||
return True
|
||||
for editor in lines:
|
||||
editor = editor.strip('\n').strip('\r')
|
||||
if editor == nickname:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def noOfFollowersOnDomain(baseDir: str, handle: str,
|
||||
domain: str, followFile='followers.txt') -> int:
|
||||
"""Returns the number of followers of the given handle from the given domain
|
||||
|
|
@ -505,7 +538,8 @@ def deleteAllPosts(baseDir: str,
|
|||
"""Deletes all posts for a person from inbox or outbox
|
||||
"""
|
||||
if boxname != 'inbox' and boxname != 'outbox' and \
|
||||
boxname != 'tlblogs' and boxname != 'tlevents':
|
||||
boxname != 'tlblogs' and boxname != 'tlnews' and \
|
||||
boxname != 'tlevents':
|
||||
return
|
||||
boxDir = createPersonDir(nickname, domain, baseDir, boxname)
|
||||
for deleteFilename in os.scandir(boxDir):
|
||||
|
|
@ -527,7 +561,8 @@ def savePostToBox(baseDir: str, httpPrefix: str, postId: str,
|
|||
Returns the filename
|
||||
"""
|
||||
if boxname != 'inbox' and boxname != 'outbox' and \
|
||||
boxname != 'tlblogs' and boxname != 'tlevents' and \
|
||||
boxname != 'tlblogs' and boxname != 'tlnews' and \
|
||||
boxname != 'tlevents' and \
|
||||
boxname != 'scheduled':
|
||||
return None
|
||||
originalDomain = domain
|
||||
|
|
@ -722,8 +757,11 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int,
|
|||
"""
|
||||
subject = addAutoCW(baseDir, nickname, domain, subject, content)
|
||||
|
||||
if nickname != 'news':
|
||||
mentionedRecipients = \
|
||||
getMentionedPeople(baseDir, httpPrefix, content, domain, False)
|
||||
else:
|
||||
mentionedRecipients = ''
|
||||
|
||||
tags = []
|
||||
hashtagsDict = {}
|
||||
|
|
@ -734,6 +772,7 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int,
|
|||
domain = domain + ':' + str(port)
|
||||
|
||||
# add tags
|
||||
if nickname != 'news':
|
||||
content = \
|
||||
addHtmlTags(baseDir, httpPrefix,
|
||||
nickname, domain, content,
|
||||
|
|
@ -745,6 +784,7 @@ def createPostBase(baseDir: str, nickname: str, domain: str, port: int,
|
|||
for tagName, tag in hashtagsDict.items():
|
||||
tags.append(tag)
|
||||
# get list of tags
|
||||
if nickname != 'news':
|
||||
content = replaceEmojiFromTags(content, tags, 'content')
|
||||
# remove replaced emoji
|
||||
hashtagsDictCopy = hashtagsDict.copy()
|
||||
|
|
@ -1177,11 +1217,39 @@ def createBlogPost(baseDir: str,
|
|||
inReplyTo=None, inReplyToAtomUri=None, subject=None,
|
||||
schedulePost=False,
|
||||
eventDate=None, eventTime=None, location=None) -> {}:
|
||||
commentsEnabled = True
|
||||
blog = \
|
||||
createPublicPost(baseDir,
|
||||
nickname, domain, port, httpPrefix,
|
||||
content, followersOnly, saveToFile,
|
||||
clientToServer,
|
||||
clientToServer, commentsEnabled,
|
||||
attachImageFilename, mediaType,
|
||||
imageDescription, useBlurhash,
|
||||
inReplyTo, inReplyToAtomUri, subject,
|
||||
schedulePost,
|
||||
eventDate, eventTime, location)
|
||||
blog['object']['type'] = 'Article'
|
||||
return blog
|
||||
|
||||
|
||||
def createNewsPost(baseDir: str,
|
||||
domain: str, port: int, httpPrefix: str,
|
||||
content: str, followersOnly: bool, saveToFile: bool,
|
||||
attachImageFilename: str, mediaType: str,
|
||||
imageDescription: str, useBlurhash: bool,
|
||||
subject: str) -> {}:
|
||||
clientToServer = False
|
||||
inReplyTo = None
|
||||
inReplyToAtomUri = None
|
||||
schedulePost = False
|
||||
eventDate = None
|
||||
eventTime = None
|
||||
location = None
|
||||
blog = \
|
||||
createPublicPost(baseDir,
|
||||
'news', domain, port, httpPrefix,
|
||||
content, followersOnly, saveToFile,
|
||||
clientToServer, False,
|
||||
attachImageFilename, mediaType,
|
||||
imageDescription, useBlurhash,
|
||||
inReplyTo, inReplyToAtomUri, subject,
|
||||
|
|
@ -1688,8 +1756,8 @@ def sendPost(projectVersion: str,
|
|||
try:
|
||||
signedPostJsonObject = jsonldSign(postJsonObject, privateKeyPem)
|
||||
postJsonObject = signedPostJsonObject
|
||||
except BaseException:
|
||||
print('WARN: failed to JSON-LD sign post')
|
||||
except Exception as e:
|
||||
print('WARN: failed to JSON-LD sign post, ' + str(e))
|
||||
pass
|
||||
|
||||
# convert json to string so that there are no
|
||||
|
|
@ -2027,8 +2095,8 @@ def sendSignedJson(postJsonObject: {}, session, baseDir: str,
|
|||
try:
|
||||
signedPostJsonObject = jsonldSign(postJsonObject, privateKeyPem)
|
||||
postJsonObject = signedPostJsonObject
|
||||
except BaseException:
|
||||
print('WARN: failed to JSON-LD sign post')
|
||||
except Exception as e:
|
||||
print('WARN: failed to JSON-LD sign post, ' + str(e))
|
||||
pass
|
||||
|
||||
# convert json to string so that there are no
|
||||
|
|
@ -2444,7 +2512,7 @@ def createInbox(recentPostsCache: {},
|
|||
session, baseDir, 'inbox',
|
||||
nickname, domain, port, httpPrefix,
|
||||
itemsPerPage, headerOnly, True,
|
||||
pageNumber)
|
||||
0, False, 0, pageNumber)
|
||||
|
||||
|
||||
def createBookmarksTimeline(session, baseDir: str, nickname: str, domain: str,
|
||||
|
|
@ -2453,7 +2521,7 @@ def createBookmarksTimeline(session, baseDir: str, nickname: str, domain: str,
|
|||
return createBoxIndexed({}, session, baseDir, 'tlbookmarks',
|
||||
nickname, domain,
|
||||
port, httpPrefix, itemsPerPage, headerOnly,
|
||||
True, pageNumber)
|
||||
True, 0, False, 0, pageNumber)
|
||||
|
||||
|
||||
def createEventsTimeline(recentPostsCache: {},
|
||||
|
|
@ -2463,7 +2531,7 @@ def createEventsTimeline(recentPostsCache: {},
|
|||
return createBoxIndexed(recentPostsCache, session, baseDir, 'tlevents',
|
||||
nickname, domain,
|
||||
port, httpPrefix, itemsPerPage, headerOnly,
|
||||
True, pageNumber)
|
||||
True, 0, False, 0, pageNumber)
|
||||
|
||||
|
||||
def createDMTimeline(recentPostsCache: {},
|
||||
|
|
@ -2473,7 +2541,7 @@ def createDMTimeline(recentPostsCache: {},
|
|||
return createBoxIndexed(recentPostsCache,
|
||||
session, baseDir, 'dm', nickname,
|
||||
domain, port, httpPrefix, itemsPerPage,
|
||||
headerOnly, True, pageNumber)
|
||||
headerOnly, True, 0, False, 0, pageNumber)
|
||||
|
||||
|
||||
def createRepliesTimeline(recentPostsCache: {},
|
||||
|
|
@ -2483,7 +2551,7 @@ def createRepliesTimeline(recentPostsCache: {},
|
|||
return createBoxIndexed(recentPostsCache, session, baseDir, 'tlreplies',
|
||||
nickname, domain, port, httpPrefix,
|
||||
itemsPerPage, headerOnly, True,
|
||||
pageNumber)
|
||||
0, False, 0, pageNumber)
|
||||
|
||||
|
||||
def createBlogsTimeline(session, baseDir: str, nickname: str, domain: str,
|
||||
|
|
@ -2492,7 +2560,7 @@ def createBlogsTimeline(session, baseDir: str, nickname: str, domain: str,
|
|||
return createBoxIndexed({}, session, baseDir, 'tlblogs', nickname,
|
||||
domain, port, httpPrefix,
|
||||
itemsPerPage, headerOnly, True,
|
||||
pageNumber)
|
||||
0, False, 0, pageNumber)
|
||||
|
||||
|
||||
def createMediaTimeline(session, baseDir: str, nickname: str, domain: str,
|
||||
|
|
@ -2501,7 +2569,19 @@ def createMediaTimeline(session, baseDir: str, nickname: str, domain: str,
|
|||
return createBoxIndexed({}, session, baseDir, 'tlmedia', nickname,
|
||||
domain, port, httpPrefix,
|
||||
itemsPerPage, headerOnly, True,
|
||||
pageNumber)
|
||||
0, False, 0, pageNumber)
|
||||
|
||||
|
||||
def createNewsTimeline(session, baseDir: str, nickname: str, domain: str,
|
||||
port: int, httpPrefix: str, itemsPerPage: int,
|
||||
headerOnly: bool, newswireVotesThreshold: int,
|
||||
positiveVoting: bool, votingTimeMins: int,
|
||||
pageNumber=None) -> {}:
|
||||
return createBoxIndexed({}, session, baseDir, 'outbox', 'news',
|
||||
domain, port, httpPrefix,
|
||||
itemsPerPage, headerOnly, True,
|
||||
newswireVotesThreshold, positiveVoting,
|
||||
votingTimeMins, pageNumber)
|
||||
|
||||
|
||||
def createOutbox(session, baseDir: str, nickname: str, domain: str,
|
||||
|
|
@ -2511,7 +2591,7 @@ def createOutbox(session, baseDir: str, nickname: str, domain: str,
|
|||
return createBoxIndexed({}, session, baseDir, 'outbox',
|
||||
nickname, domain, port, httpPrefix,
|
||||
itemsPerPage, headerOnly, authorized,
|
||||
pageNumber)
|
||||
0, False, 0, pageNumber)
|
||||
|
||||
|
||||
def createModeration(baseDir: str, nickname: str, domain: str, port: int,
|
||||
|
|
@ -2775,7 +2855,7 @@ def addPostStringToTimeline(postStr: str, boxname: str,
|
|||
elif boxname == 'tlreplies':
|
||||
if boxActor not in postStr:
|
||||
return False
|
||||
elif boxname == 'tlblogs':
|
||||
elif boxname == 'tlblogs' or boxname == 'tlnews':
|
||||
if '"Create"' not in postStr:
|
||||
return False
|
||||
if '"Article"' not in postStr:
|
||||
|
|
@ -2804,7 +2884,8 @@ def createBoxIndexed(recentPostsCache: {},
|
|||
session, baseDir: str, boxname: str,
|
||||
nickname: str, domain: str, port: int, httpPrefix: str,
|
||||
itemsPerPage: int, headerOnly: bool, authorized: bool,
|
||||
pageNumber=None) -> {}:
|
||||
newswireVotesThreshold: int, positiveVoting: bool,
|
||||
votingTimeMins: int, pageNumber=None) -> {}:
|
||||
"""Constructs the box feed for a person with the given nickname
|
||||
"""
|
||||
if not authorized or not pageNumber:
|
||||
|
|
@ -2812,7 +2893,7 @@ def createBoxIndexed(recentPostsCache: {},
|
|||
|
||||
if boxname != 'inbox' and boxname != 'dm' and \
|
||||
boxname != 'tlreplies' and boxname != 'tlmedia' and \
|
||||
boxname != 'tlblogs' and \
|
||||
boxname != 'tlblogs' and boxname != 'tlnews' and \
|
||||
boxname != 'outbox' and boxname != 'tlbookmarks' and \
|
||||
boxname != 'bookmarks' and \
|
||||
boxname != 'tlevents':
|
||||
|
|
@ -2873,6 +2954,44 @@ def createBoxIndexed(recentPostsCache: {},
|
|||
if not postFilename:
|
||||
break
|
||||
|
||||
# apply votes within this timeline
|
||||
if newswireVotesThreshold > 0:
|
||||
# note that the presence of an arrival file also indicates
|
||||
# that this post is moderated
|
||||
arrivalDate = \
|
||||
locateNewsArrival(baseDir, domain, postFilename)
|
||||
if arrivalDate:
|
||||
# how long has elapsed since this post arrived?
|
||||
currDate = datetime.datetime.utcnow()
|
||||
timeDiffMins = \
|
||||
int((currDate - arrivalDate).total_seconds() / 60)
|
||||
# has the voting time elapsed?
|
||||
if timeDiffMins < votingTimeMins:
|
||||
# voting is still happening, so don't add this
|
||||
# post to the timeline
|
||||
continue
|
||||
# if there a votes file for this post?
|
||||
votesFilename = \
|
||||
locateNewsVotes(baseDir, domain, postFilename)
|
||||
if votesFilename:
|
||||
# load the votes file and count the votes
|
||||
votesJson = loadJson(votesFilename, 0, 2)
|
||||
if votesJson:
|
||||
if not positiveVoting:
|
||||
if votesOnNewswireItem(votesJson) >= \
|
||||
newswireVotesThreshold:
|
||||
# Too many veto votes.
|
||||
# Continue without incrementing
|
||||
# the posts counter
|
||||
continue
|
||||
else:
|
||||
if votesOnNewswireItem < \
|
||||
newswireVotesThreshold:
|
||||
# Not enough votes.
|
||||
# Continue without incrementing
|
||||
# the posts counter
|
||||
continue
|
||||
|
||||
# Skip through any posts previous to the current page
|
||||
if postsCtr < int((pageNumber - 1) * itemsPerPage):
|
||||
postsCtr += 1
|
||||
|
|
|
|||
20
roles.py
|
|
@ -37,6 +37,26 @@ def clearModeratorStatus(baseDir: str) -> None:
|
|||
saveJson(actorJson, filename)
|
||||
|
||||
|
||||
def clearEditorStatus(baseDir: str) -> None:
|
||||
"""Removes editor status from all accounts
|
||||
This could be slow if there are many users, but only happens
|
||||
rarely when editors are appointed or removed
|
||||
"""
|
||||
directory = os.fsencode(baseDir + '/accounts/')
|
||||
for f in os.scandir(directory):
|
||||
f = f.name
|
||||
filename = os.fsdecode(f)
|
||||
if filename.endswith(".json") and '@' in filename:
|
||||
filename = os.path.join(baseDir + '/accounts/', filename)
|
||||
if '"editor"' in open(filename).read():
|
||||
actorJson = loadJson(filename)
|
||||
if actorJson:
|
||||
if actorJson['roles'].get('instance'):
|
||||
if 'editor' in actorJson['roles']['instance']:
|
||||
actorJson['roles']['instance'].remove('editor')
|
||||
saveJson(actorJson, filename)
|
||||
|
||||
|
||||
def addModerator(baseDir: str, nickname: str, domain: str) -> None:
|
||||
"""Adds a moderator nickname to the file
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
#!/bin/bash
|
||||
rm accounts/news@*/outbox/*
|
||||
rm accounts/news@*/postcache/*
|
||||
rm accounts/news@*/outbox.index
|
||||
if [ -f accounts/.newswirestate.json ]; then
|
||||
rm accounts/.newswirestate.json
|
||||
fi
|
||||
if [ -f accounts/.currentnewswire.json ]; then
|
||||
rm accounts/.currentnewswire.json
|
||||
fi
|
||||
31
tests.py
|
|
@ -78,6 +78,7 @@ from content import addHtmlTags
|
|||
from content import removeLongWords
|
||||
from content import replaceContentDuplicates
|
||||
from content import removeTextFormatting
|
||||
from content import removeHtmlTag
|
||||
from theme import setCSSparam
|
||||
from jsonldsig import testSignJsonld
|
||||
from jsonldsig import jsonldVerify
|
||||
|
|
@ -287,7 +288,8 @@ def createServerAlice(path: str, domain: str, port: int,
|
|||
onionDomain = None
|
||||
i2pDomain = None
|
||||
print('Server running: Alice')
|
||||
runDaemon(False, False, 5, True, True, 'en', __version__,
|
||||
runDaemon(False, 0, False, 1, False, False, False,
|
||||
5, True, True, 'en', __version__,
|
||||
"instanceId", False, path, domain,
|
||||
onionDomain, i2pDomain, None, port, port,
|
||||
httpPrefix, federationList, maxMentions, maxEmoji, False,
|
||||
|
|
@ -349,7 +351,8 @@ def createServerBob(path: str, domain: str, port: int,
|
|||
onionDomain = None
|
||||
i2pDomain = None
|
||||
print('Server running: Bob')
|
||||
runDaemon(False, False, 5, True, True, 'en', __version__,
|
||||
runDaemon(False, 0, False, 1, False, False, False,
|
||||
5, True, True, 'en', __version__,
|
||||
"instanceId", False, path, domain,
|
||||
onionDomain, i2pDomain, None, port, port,
|
||||
httpPrefix, federationList, maxMentions, maxEmoji, False,
|
||||
|
|
@ -385,7 +388,8 @@ def createServerEve(path: str, domain: str, port: int, federationList: [],
|
|||
onionDomain = None
|
||||
i2pDomain = None
|
||||
print('Server running: Eve')
|
||||
runDaemon(False, False, 5, True, True, 'en', __version__,
|
||||
runDaemon(False, 0, False, 1, False, False, False,
|
||||
5, True, True, 'en', __version__,
|
||||
"instanceId", False, path, domain,
|
||||
onionDomain, i2pDomain, None, port, port,
|
||||
httpPrefix, federationList, maxMentions, maxEmoji, False,
|
||||
|
|
@ -1766,9 +1770,9 @@ def testGetStatusNumber():
|
|||
prevStatusNumber = int(statusNumber)
|
||||
|
||||
|
||||
def testCommentJson() -> None:
|
||||
print('testCommentJson')
|
||||
filename = '/tmp/test.json'
|
||||
def testJsonString() -> None:
|
||||
print('testJsonString')
|
||||
filename = '.epicyon_tests_testJsonString.json'
|
||||
messageStr = "Crème brûlée यह एक परीक्षण ह"
|
||||
testJson = {
|
||||
"content": messageStr
|
||||
|
|
@ -1779,6 +1783,7 @@ def testCommentJson() -> None:
|
|||
assert receivedJson['content'] == messageStr
|
||||
encodedStr = json.dumps(testJson, ensure_ascii=False)
|
||||
assert messageStr in encodedStr
|
||||
os.remove(filename)
|
||||
|
||||
|
||||
def testSaveLoadJson():
|
||||
|
|
@ -1787,7 +1792,7 @@ def testSaveLoadJson():
|
|||
"param1": 3,
|
||||
"param2": '"Crème brûlée यह एक परीक्षण ह"'
|
||||
}
|
||||
testFilename = '/tmp/.epicyonTestSaveLoadJson.json'
|
||||
testFilename = '.epicyon_tests_testSaveLoadJson.json'
|
||||
if os.path.isfile(testFilename):
|
||||
os.remove(testFilename)
|
||||
assert saveJson(testJson, testFilename)
|
||||
|
|
@ -2159,8 +2164,18 @@ def testReplaceEmailQuote():
|
|||
assert resultStr == expectedStr
|
||||
|
||||
|
||||
def testRemoveHtmlTag():
|
||||
print('testRemoveHtmlTag')
|
||||
testStr = "<p><img width=\"864\" height=\"486\" " + \
|
||||
"src=\"https://somesiteorother.com/image.jpg\"></p>"
|
||||
resultStr = removeHtmlTag(testStr, 'width')
|
||||
assert resultStr == "<p><img height=\"486\" " + \
|
||||
"src=\"https://somesiteorother.com/image.jpg\"></p>"
|
||||
|
||||
|
||||
def runAllTests():
|
||||
print('Running tests...')
|
||||
testRemoveHtmlTag()
|
||||
testReplaceEmailQuote()
|
||||
testConstantTimeStringCheck()
|
||||
testTranslations()
|
||||
|
|
@ -2177,7 +2192,7 @@ def runAllTests():
|
|||
testRecentPostsCache()
|
||||
testTheme()
|
||||
testSaveLoadJson()
|
||||
testCommentJson()
|
||||
testJsonString()
|
||||
testGetStatusNumber()
|
||||
testAddEmoji()
|
||||
testActorParsing()
|
||||
|
|
|
|||
21
theme.py
|
|
@ -254,6 +254,16 @@ def setThemeIndymedia(baseDir: str):
|
|||
"search": "jpg"
|
||||
}
|
||||
themeParams = {
|
||||
"font-size-newswire": "18px",
|
||||
"font-size-newswire-mobile": "48px",
|
||||
"line-spacing-newswire": "100%",
|
||||
"newswire-item-moderated-color": "white",
|
||||
"newswire-date-moderated-color": "white",
|
||||
"newswire-date-color": "white",
|
||||
"newswire-voted-background-color": "black",
|
||||
"column-left-image-width-mobile": "40vw",
|
||||
"column-right-fg-color": "#ff9900",
|
||||
"column-right-fg-color-voted-on": "red",
|
||||
"button-corner-radius": "5px",
|
||||
"timeline-border-radius": "5px",
|
||||
"focus-color": "blue",
|
||||
|
|
@ -996,7 +1006,7 @@ def setThemeImages(baseDir: str, name: str) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def setTheme(baseDir: str, name: str) -> bool:
|
||||
def setTheme(baseDir: str, name: str, domain: str) -> bool:
|
||||
result = False
|
||||
|
||||
prevThemeName = getTheme(baseDir)
|
||||
|
|
@ -1019,6 +1029,15 @@ def setTheme(baseDir: str, name: str) -> bool:
|
|||
result = True
|
||||
|
||||
setCustomFont(baseDir)
|
||||
|
||||
# set the news avatar
|
||||
newsAvatarThemeFilename = \
|
||||
baseDir + '/img/icons/' + name + '/avatar_news.png'
|
||||
if os.path.isfile(newsAvatarThemeFilename):
|
||||
newsAvatarFilename = \
|
||||
baseDir + '/accounts/news@' + domain + '/avatar.png'
|
||||
copyfile(newsAvatarThemeFilename, newsAvatarFilename)
|
||||
|
||||
grayscaleFilename = baseDir + '/accounts/.grayscale'
|
||||
if os.path.isfile(grayscaleFilename):
|
||||
enableGrayscale(baseDir)
|
||||
|
|
|
|||
|
|
@ -297,5 +297,16 @@
|
|||
"Edit newswire": "تحرير الأخبار",
|
||||
"Add RSS feed links below.": "إضافة روابط تغذية RSS أدناه.",
|
||||
"Newswire RSS Feed": "Newswire موجز RSS",
|
||||
"Nicknames whose blog entries appear on the newswire.": "الألقاب التي تظهر إدخالات المدونة الخاصة بها على موقع الأخبار."
|
||||
"Nicknames whose blog entries appear on the newswire.": "الألقاب التي تظهر إدخالات المدونة الخاصة بها على موقع الأخبار.",
|
||||
"Posts to be approved": "الوظائف المطلوب الموافقة عليها",
|
||||
"Discuss": "مناقشة",
|
||||
"Moderator Discussion": "مناقشة المنسق",
|
||||
"Vote": "تصويت",
|
||||
"Remove Vote": "إزالة التصويت",
|
||||
"This is a news instance": "هذا مثال أخبار",
|
||||
"News": "أخبار",
|
||||
"Read more...": "اقرأ أكثر...",
|
||||
"Edit News Post": "تحرير منشور الأخبار",
|
||||
"A list of editor nicknames. One per line.": "قائمة بأسماء المحرر. واحد في كل سطر.",
|
||||
"Site Editors": "محررو الموقع"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,5 +297,16 @@
|
|||
"Edit newswire": "Editeu newswire",
|
||||
"Add RSS feed links below.": "Afegiu enllaços de canals RSS a continuació.",
|
||||
"Newswire RSS Feed": "Feed RSS de Newswire",
|
||||
"Nicknames whose blog entries appear on the newswire.": "Sobrenoms les entrades del bloc apareixen a newswire."
|
||||
"Nicknames whose blog entries appear on the newswire.": "Sobrenoms les entrades del bloc apareixen a newswire.",
|
||||
"Posts to be approved": "Missatges per aprovar",
|
||||
"Discuss": "Discuteix",
|
||||
"Moderator Discussion": "Discussió sobre moderadors",
|
||||
"Vote": "Notícies",
|
||||
"Remove Vote": "Elimina el vot",
|
||||
"This is a news instance": "Aquesta és una instància de notícies",
|
||||
"News": "Notícies",
|
||||
"Read more...": "Llegeix més...",
|
||||
"Edit News Post": "Edita la publicació de notícies",
|
||||
"A list of editor nicknames. One per line.": "Una llista de sobrenoms de l'editor. Un per línia.",
|
||||
"Site Editors": "Editors de llocs"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,5 +297,16 @@
|
|||
"Edit newswire": "Golygu newyddion",
|
||||
"Add RSS feed links below.": "Ychwanegwch ddolenni porthiant RSS isod.",
|
||||
"Newswire RSS Feed": "Newswire RSS Feed",
|
||||
"Nicknames whose blog entries appear on the newswire.": "Llysenwau y mae eu cofnodion blog yn ymddangos ar y we newyddion."
|
||||
"Nicknames whose blog entries appear on the newswire.": "Llysenwau y mae eu cofnodion blog yn ymddangos ar y we newyddion.",
|
||||
"Posts to be approved": "Swyddi i'w cymeradwyo",
|
||||
"Discuss": "Trafodwch",
|
||||
"Moderator Discussion": "Trafodaeth Cymedrolwr",
|
||||
"Vote": "Newyddion",
|
||||
"Remove Vote": "Tynnwch y Bleidlais",
|
||||
"This is a news instance": "Dyma enghraifft newyddion",
|
||||
"News": "Newyddion",
|
||||
"Read more...": "Darllen mwy...",
|
||||
"Edit News Post": "Golygu News News",
|
||||
"A list of editor nicknames. One per line.": "Rhestr o lysenwau golygydd. Un i bob llinell.",
|
||||
"Site Editors": "Golygyddion Safle"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,5 +297,16 @@
|
|||
"Edit newswire": "Newswire bearbeiten",
|
||||
"Add RSS feed links below.": "Fügen Sie unten RSS-Feed-Links hinzu.",
|
||||
"Newswire RSS Feed": "Newswire RSS Feed",
|
||||
"Nicknames whose blog entries appear on the newswire.": "Spitznamen, deren Blogeinträge im Newswire erscheinen."
|
||||
"Nicknames whose blog entries appear on the newswire.": "Spitznamen, deren Blogeinträge im Newswire erscheinen.",
|
||||
"Posts to be approved": "Zu genehmigende Beiträge",
|
||||
"Discuss": "Diskutieren",
|
||||
"Moderator Discussion": "Moderatorendiskussion",
|
||||
"Vote": "Abstimmung",
|
||||
"Remove Vote": "Abstimmung entfernen",
|
||||
"This is a news instance": "Dies ist eine Nachrichteninstanz",
|
||||
"News": "Nachrichten",
|
||||
"Read more...": "Weiterlesen...",
|
||||
"Edit News Post": "Nachrichtenbeitrag bearbeiten",
|
||||
"A list of editor nicknames. One per line.": "Eine Liste der Editor-Spitznamen. Eine pro Zeile.",
|
||||
"Site Editors": "Site-Editoren"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,8 +212,8 @@
|
|||
"Remove Twitter posts": "Remove Twitter posts",
|
||||
"Sensitive": "Sensitive",
|
||||
"Word Replacements": "Word Replacements",
|
||||
"Happening Today": "Happening Today",
|
||||
"Happening This Week": "Happening This Week",
|
||||
"Happening Today": "Today",
|
||||
"Happening This Week": "This Week",
|
||||
"Blog": "Blog",
|
||||
"Blogs": "Blogs",
|
||||
"Title": "Title",
|
||||
|
|
@ -295,7 +295,18 @@
|
|||
"Right column image": "Right column image",
|
||||
"RSS feed for this site": "RSS feed for this site",
|
||||
"Edit newswire": "Edit newswire",
|
||||
"Add RSS feed links below.": "Add RSS feed links below.",
|
||||
"Add RSS feed links below.": "RSS feed links below. Add a * at the beginning or end to indicate that a feed should be moderated.",
|
||||
"Newswire RSS Feed": "Newswire RSS Feed",
|
||||
"Nicknames whose blog entries appear on the newswire.": "Nicknames whose blog entries appear on the newswire."
|
||||
"Nicknames whose blog entries appear on the newswire.": "Nicknames whose blog entries appear on the newswire.",
|
||||
"Posts to be approved": "Posts to be approved",
|
||||
"Discuss": "Discuss",
|
||||
"Moderator Discussion": "Moderator Discussion",
|
||||
"Vote": "Vote",
|
||||
"Remove Vote": "Remove Vote",
|
||||
"This is a news instance": "This is a news instance",
|
||||
"News": "News",
|
||||
"Read more...": "Read more...",
|
||||
"Edit News Post": "Edit News Post",
|
||||
"A list of editor nicknames. One per line.": "A list of editor nicknames. One per line.",
|
||||
"Site Editors": "Site Editors"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,5 +297,16 @@
|
|||
"Edit newswire": "Editar newswire",
|
||||
"Add RSS feed links below.": "Agregue los enlaces de fuentes RSS a continuación.",
|
||||
"Newswire RSS Feed": "Canal RSS de Newswire",
|
||||
"Nicknames whose blog entries appear on the newswire.": "Apodos cuyas entradas de blog aparecen en el newswire."
|
||||
"Nicknames whose blog entries appear on the newswire.": "Apodos cuyas entradas de blog aparecen en el newswire.",
|
||||
"Posts to be approved": "Publicaciones a aprobar",
|
||||
"Discuss": "Discutir",
|
||||
"Moderator Discussion": "Discusión del moderador",
|
||||
"Vote": "Votar",
|
||||
"Remove Vote": "Eliminar voto",
|
||||
"This is a news instance": "Esta es una instancia de noticias",
|
||||
"News": "Noticias",
|
||||
"Read more...": "Lee mas...",
|
||||
"Edit News Post": "Editar publicación de noticias",
|
||||
"A list of editor nicknames. One per line.": "Una lista de apodos de los editores. Uno por línea.",
|
||||
"Site Editors": "Editores del sitio"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,5 +297,16 @@
|
|||
"Edit newswire": "Modifier le fil d'actualité",
|
||||
"Add RSS feed links below.": "Ajoutez des liens de flux RSS ci-dessous.",
|
||||
"Newswire RSS Feed": "Flux RSS de Newswire",
|
||||
"Nicknames whose blog entries appear on the newswire.": "Surnoms dont les entrées de blog apparaissent sur le fil de presse."
|
||||
"Nicknames whose blog entries appear on the newswire.": "Surnoms dont les entrées de blog apparaissent sur le fil de presse.",
|
||||
"Posts to be approved": "Postes à approuver",
|
||||
"Discuss": "Discuter",
|
||||
"Moderator Discussion": "Discussion du modérateur",
|
||||
"Vote": "Voter",
|
||||
"Remove Vote": "Supprimer le vote",
|
||||
"This is a news instance": "Ceci est une instance d'actualité",
|
||||
"News": "Nouvelles",
|
||||
"Read more...": "Lire la suite...",
|
||||
"Edit News Post": "Modifier l'article d'actualité",
|
||||
"A list of editor nicknames. One per line.": "Une liste de surnoms d'éditeur. Un par ligne.",
|
||||
"Site Editors": "Éditeurs du site"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,5 +297,16 @@
|
|||
"Edit newswire": "Cuir sreang nuachta in eagar",
|
||||
"Add RSS feed links below.": "Cuir naisc beatha RSS thíos.",
|
||||
"Newswire RSS Feed": "Newswire RSS Feed",
|
||||
"Nicknames whose blog entries appear on the newswire.": "Leasainmneacha a bhfuil a n-iontrálacha blag le feiceáil ar an sreang nuachta."
|
||||
"Nicknames whose blog entries appear on the newswire.": "Leasainmneacha a bhfuil a n-iontrálacha blag le feiceáil ar an sreang nuachta.",
|
||||
"Posts to be approved": "Poist le ceadú",
|
||||
"Discuss": "Pléigh",
|
||||
"Moderator Discussion": "Plé Modhnóir",
|
||||
"Vote": "Vóta",
|
||||
"Remove Vote": "Bain Vóta",
|
||||
"This is a news instance": "Is sampla nuachta é seo",
|
||||
"News": "Nuacht",
|
||||
"Read more...": "Leigh Nios mo...",
|
||||
"Edit News Post": "Cuir News Post in eagar",
|
||||
"A list of editor nicknames. One per line.": "Liosta leasainmneacha eagarthóra. Ceann in aghaidh na líne.",
|
||||
"Site Editors": "Eagarthóirí Suímh"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,5 +297,16 @@
|
|||
"Edit newswire": "नवांश संपादित करें",
|
||||
"Add RSS feed links below.": "नीचे आरएसएस फ़ीड लिंक जोड़ें।",
|
||||
"Newswire RSS Feed": "Newswire RSS फ़ीड",
|
||||
"Nicknames whose blog entries appear on the newswire.": "उपनाम जिनकी ब्लॉग प्रविष्टियाँ न्यूज़वायर पर दिखाई देती हैं।"
|
||||
"Nicknames whose blog entries appear on the newswire.": "उपनाम जिनकी ब्लॉग प्रविष्टियाँ न्यूज़वायर पर दिखाई देती हैं।",
|
||||
"Posts to be approved": "स्वीकृत किए जाने वाले पद",
|
||||
"Discuss": "चर्चा करें",
|
||||
"Moderator Discussion": "मॉडरेटर चर्चा",
|
||||
"Vote": "वोट",
|
||||
"Remove Vote": "वोट हटा दें",
|
||||
"This is a news instance": "यह एक समाचार का उदाहरण है",
|
||||
"News": "समाचार",
|
||||
"Read more...": "अधिक पढ़ें...",
|
||||
"Edit News Post": "समाचार पोस्ट संपादित करें",
|
||||
"A list of editor nicknames. One per line.": "संपादक उपनामों की एक सूची। प्रति पंक्ति एक।",
|
||||
"Site Editors": "साइट संपादकों"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,5 +297,16 @@
|
|||
"Edit newswire": "Modifica newswire",
|
||||
"Add RSS feed links below.": "Aggiungi i link ai feed RSS di seguito.",
|
||||
"Newswire RSS Feed": "Feed RSS di Newswire",
|
||||
"Nicknames whose blog entries appear on the newswire.": "Soprannomi le cui voci di blog compaiono nel newswire."
|
||||
"Nicknames whose blog entries appear on the newswire.": "Soprannomi le cui voci di blog compaiono nel newswire.",
|
||||
"Posts to be approved": "Post da approvare",
|
||||
"Discuss": "Discutere",
|
||||
"Moderator Discussion": "Discussione del moderatore",
|
||||
"Vote": "Votazione",
|
||||
"Remove Vote": "Rimuovi voto",
|
||||
"This is a news instance": "Questa è un'istanza di notizie",
|
||||
"News": "Notizia",
|
||||
"Read more...": "Leggi di più...",
|
||||
"Edit News Post": "Modifica post di notizie",
|
||||
"A list of editor nicknames. One per line.": "Un elenco di soprannomi dell'editor. Uno per riga.",
|
||||
"Site Editors": "Editori del sito"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,5 +297,16 @@
|
|||
"Edit newswire": "ニュースワイヤーを編集",
|
||||
"Add RSS feed links below.": "以下にRSSフィードリンクを追加します。",
|
||||
"Newswire RSS Feed": "NewswireRSSフィード",
|
||||
"Nicknames whose blog entries appear on the newswire.": "ブログエントリがニュースワイヤーに表示されるニックネーム。"
|
||||
"Nicknames whose blog entries appear on the newswire.": "ブログエントリがニュースワイヤーに表示されるニックネーム。",
|
||||
"Posts to be approved": "承認される投稿",
|
||||
"Discuss": "議論する",
|
||||
"Moderator Discussion": "モデレーターディスカッション",
|
||||
"Vote": "投票",
|
||||
"Remove Vote": "投票を削除",
|
||||
"This is a news instance": "これはニュースインスタンスです",
|
||||
"News": "ニュース",
|
||||
"Read more...": "続きを読む...",
|
||||
"Edit News Post": "ニュース投稿を編集する",
|
||||
"A list of editor nicknames. One per line.": "編集者のニックネームのリスト。 1行に1つ。",
|
||||
"Site Editors": "サイト編集者"
|
||||
}
|
||||
|
|
|
|||