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

main
Bob Mottram 2020-10-12 22:13:01 +01:00
commit 0f9532ed4f
107 changed files with 7648 additions and 607 deletions

View File

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

View File

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

View File

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

View File

@ -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,6 +264,8 @@ 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
if tag in content:

845
daemon.py

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
img/icons/links.png 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
img/icons/vote.png 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

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

View File

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

273
newsdaemon.py 100644
View File

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

View File

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

View File

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

View File

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

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

4909
pyjsonld.py 100644

File diff suppressed because it is too large Load Diff

View File

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

View 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

View File

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

View File

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

View File

@ -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": "محررو الموقع"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "साइट संपादकों"
}

View File

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

View File

@ -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": "サイト編集者"
}

Some files were not shown because too many files have changed in this diff Show More