5 Commits
Author SHA1 Message Date
bashrc c6df852e34 Federation policy 2019-11-03 14:09:02 +00:00
bashrc ffc93abf3b Paragraph 2019-11-03 13:52:18 +00:00
bashrc fc2732a5ed Tidying 2019-11-03 13:43:47 +00:00
bashrc a6dc579aca Support dat 2019-11-03 12:37:47 +00:00
bashrc 8d78bb6f6b Comments 2019-11-03 12:28:52 +00:00
346 changed files with 3846 additions and 10187 deletions

No files matched your search

+1 -3
View File
@@ -1,7 +1,5 @@
all:
debug:
clean:
rm -f *.*~ *~
rm -f orgs/*~
rm -f scripts/*~
rm -f *.txt~ *.md~ *.py~ *.json~ *.css~ Makefile~
rm -rf __pycache__
+9 -8
View File
@@ -6,11 +6,9 @@ Epicyon is a modern [ActivityPub](https://www.w3.org/TR/activitypub) compliant s
[Project Goals](README_goals.md) - [Commandline interface](README_commandline.md) - [Customizations](README_customizations.md) - [Object Capabilities](ocaps.md) - [Code of Conduct](code-of-conduct.md)
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)
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://code.freedombone.net/bashrc/epicyon/raw/master/img/screenshots.jpg?raw=true" width="80%"/>
## Package Dependencies
@@ -28,7 +26,7 @@ Or on Debian:
``` bash
sudo apt-get -y install tor python3-pip python3-socks imagemagick \
python3-numpy python3-setuptools python3-crypto \
python3-dateutil python3-pil.imagetk certbot nginx
python3-dateutil python3-pil.imagetk certbot
sudo pip3 install commentjson beautifulsoup4 pycryptodome
```
@@ -36,8 +34,6 @@ sudo pip3 install commentjson beautifulsoup4 pycryptodome
In the most common case you'll be using systemd to set up a daemon to run the server.
The following instructions install Epicyon to the **/etc** directory. It's not essential that it be installed there, and it could be in any other preferred directory.
Add a dedicated user so that we don't have to run as root.
``` bash
@@ -57,7 +53,7 @@ Type=simple
User=epicyon
Group=epicyon
WorkingDirectory=/etc/epicyon
ExecStart=/usr/bin/python3 /etc/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open
ExecStart=/usr/bin/python3 /etc/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open --debug
Environment=USER=epicyon
Restart=always
StandardError=syslog
@@ -95,11 +91,15 @@ server {
listen 80;
listen [::]:80;
server_name YOUR_DOMAIN;
root /var/www/YOUR_DOMAIN/htdocs;
access_log /dev/null;
error_log /dev/null;
client_max_body_size 31m;
client_body_buffer_size 128k;
limit_conn conn_limit_per_ip 10;
limit_req zone=req_limit_per_ip burst=10 nodelay;
index index.html;
rewrite ^ https://$server_name$request_uri? permanent;
}
@@ -132,6 +132,7 @@ server {
access_log /dev/null;
error_log /dev/null;
root /var/www/YOUR_DOMAIN/htdocs;
index index.html;
location / {
-18
View File
@@ -34,24 +34,6 @@ To set the background image for an account:
python3 epicyon.py --nickname [nick] --domain [name] --background [image filename]
```
## Groups
Groups are a special type of account which relays any posts sent to it to its members (followers).
To create a group:
``` bash
python3 epicyon.py --addgroup nickname@domain --password [yourpassword]
```
To remove an account (be careful!):
``` bash
python3 epicyon.py --rmgroup nickname@domain
```
Setting avatar or changing background is the same as for any other account on the system. You can also moderate a group, applying filters, blocks or a perimeter, in the same way as for other acounts.
## Defining a perimeter
By default the server will federate with any others, but there may be cases where you want to limit this down to a defined set of servers within an organization.
-31
View File
@@ -26,34 +26,3 @@ When a moderator report is created the message at the top of the screen can be c
Extra emoji can be added to the *emoji* directory and you should then update the **emoji/emoji.json** file, which maps the name to the filename (without the .png extension).
## Style / Colors
There are a few predefined themes which you can change to. Move to the directory where Epicyon is installed, then you can use:
``` bash
theme default
theme light
theme hacker
theme purple
```
Alternatively if you want to create a much more customized theme then you can manually edit css files.
Create copies of the default files:
``` bash
cp epicyon-profile.css epicyon.css
cp epicyon-follow.css follow.css
cp epicyon-login.css login.css
cp epicyon-suspended.css suspended.css
```
You can then edit *epicyon.css*, *follow.css*, *login.css* and *follow.css* as needed and those files won't be overwritten if you upgrade.
*epicyon.css* is the main style for displaying profiles and timelines.
*follow.css* is used for displaying options when you select an avatar.
*login.css* defines the style of the login screen.
*suspended.css* is the style for the screen which shows that an account has been suspended.
+27 -20
View File
@@ -63,7 +63,8 @@ def createAccept(baseDir: str,federationList: [], \
# create capabilities accept
ocapNew= \
capabilitiesAccept(baseDir,httpPrefix, \
nickname,domain,port,toUrl,True,acceptedCaps)
nickname,domain,port, \
toUrl,True,acceptedCaps)
return createAcceptReject(baseDir,federationList, \
nickname,domain,port, \
toUrl,ccUrl,httpPrefix, \
@@ -97,6 +98,17 @@ def acceptFollow(baseDir: str,domain : str,messageJson: {}, \
if debug:
print('DEBUG: No "to" parameter in follow Accept')
return
#if len(messageJson['object']['to'])!=1:
# if debug:
# print('DEBUG: "to" does not contain a single recipient')
# print(str(messageJson['object']['to']))
# if messageJson['object'].get('object'):
# if not isinstance(messageJson['object']['object'], str):
# messageJson['object']['to']=messageJson['object']['object']
# else:
# return
# else:
# return
if debug:
print('DEBUG: follow Accept received')
thisActor=messageJson['object']['actor']
@@ -109,6 +121,10 @@ def acceptFollow(baseDir: str,domain : str,messageJson: {}, \
if debug:
print('DEBUG: domain not found in '+thisActor)
return
#if acceptedDomain != domain:
# if debug:
# print('DEBUG: domain mismatch '+acceptedDomain+' != '+domain)
# return
if not nickname:
if debug:
print('DEBUG: nickname not found in '+thisActor)
@@ -117,13 +133,12 @@ def acceptFollow(baseDir: str,domain : str,messageJson: {}, \
if '/'+acceptedDomain+':'+str(acceptedPort)+'/users/'+nickname not in thisActor:
if debug:
print('Port: '+str(acceptedPort))
print('Expected: /'+acceptedDomain+':'+str(acceptedPort)+ \
'/users/'+nickname)
print('Expected: /'+acceptedDomain+':'+str(acceptedPort)+'/users/'+nickname)
print('Actual: '+thisActor)
print('DEBUG: unrecognized actor '+thisActor)
return
else:
if not '/'+acceptedDomain+'/users/'+nickname in thisActor:
if '/'+acceptedDomain+'/users/'+nickname not in thisActor:
if debug:
print('Expected: /'+acceptedDomain+'/users/'+nickname)
print('Actual: '+thisActor)
@@ -156,13 +171,10 @@ def acceptFollow(baseDir: str,domain : str,messageJson: {}, \
followedNickname,followedDomainFull, \
federationList,debug):
if debug:
print('DEBUG: '+nickname+'@'+acceptedDomainFull+ \
' followed '+followedNickname+'@'+followedDomainFull)
print('DEBUG: '+nickname+'@'+acceptedDomainFull+' followed '+followedNickname+'@'+followedDomainFull)
else:
if debug:
print('DEBUG: Unable to create follow - '+ \
nickname+'@'+acceptedDomain+' -> '+ \
followedNickname+'@'+followedDomain)
print('DEBUG: Unable to create follow - '+nickname+'@'+acceptedDomain+' -> '+followedNickname+'@'+followedDomain)
def receiveAcceptReject(session,baseDir: str, \
httpPrefix: str,domain :str,port: int, \
@@ -177,25 +189,20 @@ def receiveAcceptReject(session,baseDir: str, \
if debug:
print('DEBUG: '+messageJson['type']+' has no actor')
return False
if '/users/' not in messageJson['actor'] and \
'/channel/' not in messageJson['actor'] and \
'/profile/' not in messageJson['actor']:
if '/users/' not in messageJson['actor'] and '/profile/' not in messageJson['actor']:
if debug:
print('DEBUG: "users" or "profile" missing from actor in '+ \
messageJson['type']+'. Assuming single user instance.')
print('DEBUG: "users" or "profile" missing from actor in '+messageJson['type'])
return False
domain,tempPort=getDomainFromActor(messageJson['actor'])
if not domainPermitted(domain,federationList):
if debug:
print('DEBUG: '+messageJson['type']+ \
' from domain not permitted - '+domain)
print('DEBUG: '+messageJson['type']+' from domain not permitted - '+domain)
return False
nickname=getNicknameFromActor(messageJson['actor'])
if not nickname:
# single user instance
nickname='dev'
if debug:
print('DEBUG: '+messageJson['type']+ \
' does not contain a nickname. Assuming single user instance.')
print('DEBUG: '+messageJson['type']+' does not contain a nickname')
return False
handle=nickname.lower()+'@'+domain.lower()
# receive follow accept
acceptFollow(baseDir,domain,messageJson,federationList,debug)
+30 -57
View File
@@ -6,8 +6,6 @@ __maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
import os
import time
import json
import commentjson
from pprint import pprint
@@ -17,9 +15,6 @@ from utils import urlPermitted
from utils import getNicknameFromActor
from utils import getDomainFromActor
from utils import locatePost
from utils import getCachedPostFilename
from utils import loadJson
from utils import saveJson
from posts import sendSignedJson
from posts import getPersonBox
from session import postJson
@@ -46,8 +41,7 @@ def outboxAnnounce(baseDir: str,messageJson: {},debug: bool) -> bool:
domain,port=getDomainFromActor(messageJson['actor'])
postFilename=locatePost(baseDir,nickname,domain,messageJson['object'])
if postFilename:
updateAnnounceCollection(baseDir,postFilename, \
messageJson['actor'],domain,debug)
updateAnnounceCollection(postFilename,messageJson['actor'],debug)
return True
if messageJson['type']=='Undo':
if not isinstance(messageJson['object'], dict):
@@ -62,32 +56,20 @@ def outboxAnnounce(baseDir: str,messageJson: {},debug: bool) -> bool:
print('WARN: no nickname found in '+messageJson['actor'])
return False
domain,port=getDomainFromActor(messageJson['actor'])
postFilename= \
locatePost(baseDir,nickname,domain, \
messageJson['object']['object'])
postFilename=locatePost(baseDir,nickname,domain,messageJson['object']['object'])
if postFilename:
undoAnnounceCollectionEntry(baseDir,postFilename, \
messageJson['actor'], \
domain,debug)
undoAnnounceCollectionEntry(postFilename,messageJson['actor'],debug)
return True
return False
def undoAnnounceCollectionEntry(baseDir: str,postFilename: str, \
actor: str,domain: str,debug: bool) -> None:
"""Undoes an announce for a particular actor by removing it from
the "shares" collection within a post. Note that the "shares"
collection has no relation to shared items in shares.py. It's
shares of posts, not shares of physical objects.
def undoAnnounceCollectionEntry(postFilename: str,actor: str, \
debug: bool) -> None:
"""Undoes an announce for a particular actor by removing it from the "shares"
collection within a post. Note that the "shares" collection has no relation
to shared items in shares.py. It's shares of posts, not shares of physical objects.
"""
postJsonObject=loadJson(postFilename)
if postJsonObject:
# remove any cached version of this announce so that the like icon is changed
nickname=getNicknameFromActor(actor)
cachedPostFilename= \
getCachedPostFilename(baseDir,nickname,domain,postJsonObject)
if os.path.isfile(cachedPostFilename):
os.remove(cachedPostFilename)
with open(postFilename, 'r') as fp:
postJsonObject=commentjson.load(fp)
if not postJsonObject.get('type'):
return
if postJsonObject['type']!='Create':
@@ -123,25 +105,16 @@ def undoAnnounceCollectionEntry(baseDir: str,postFilename: str, \
else:
postJsonObject['object']['shares']['totalItems']= \
len(postJsonObject['object']['shares']['items'])
saveJson(postJsonObject,postFilename)
with open(postFilename, 'w') as fp:
commentjson.dump(postJsonObject, fp, indent=4, sort_keys=True)
def updateAnnounceCollection(baseDir: str,postFilename: str, \
actor: str,domain: str,debug: bool) -> None:
def updateAnnounceCollection(postFilename: str,actor: str,debug: bool) -> None:
"""Updates the announcements collection within a post
Confusingly this is known as "shares", but isn't the
same as shared items within shares.py
It's shares of posts, not shares of physical objects.
Confusingly this is known as "shares", but isn't the same as shared
items within shares.py. It's shares of posts, not shares of physical objects.
"""
postJsonObject=loadJson(postFilename)
if postJsonObject:
# remove any cached version of this announce so that the like
# icon is changed
nickname=getNicknameFromActor(actor)
cachedPostFilename= \
getCachedPostFilename(baseDir,nickname,domain,postJsonObject)
if os.path.isfile(cachedPostFilename):
os.remove(cachedPostFilename)
with open(postFilename, 'r') as fp:
postJsonObject=commentjson.load(fp)
if not postJsonObject.get('object'):
if debug:
pprint(postJsonObject)
@@ -152,8 +125,7 @@ def updateAnnounceCollection(baseDir: str,postFilename: str, \
postUrl=postJsonObject['id'].replace('/activity','')+'/shares'
if not postJsonObject['object'].get('shares'):
if debug:
print('DEBUG: Adding initial shares (announcements) to '+ \
postUrl)
print('DEBUG: Adding initial shares (announcements) to '+postUrl)
announcementsJson = {
"@context": "https://www.w3.org/ns/activitystreams",
'id': postUrl,
@@ -185,7 +157,8 @@ def updateAnnounceCollection(baseDir: str,postFilename: str, \
if debug:
print('DEBUG: saving post with shares (announcements) added')
pprint(postJsonObject)
saveJson(postJsonObject,postFilename)
with open(postFilename, 'w') as fp:
commentjson.dump(postJsonObject, fp, indent=4, sort_keys=True)
def announcedByPerson(postJsonObject: {}, nickname: str,domain: str) -> bool:
"""Returns True if the given post is announced by the given person
@@ -248,14 +221,13 @@ def createAnnounce(session,baseDir: str,federationList: [], \
if saveToFile:
outboxDir = createOutboxDir(nickname,domain,baseDir)
filename=outboxDir+'/'+newAnnounceId.replace('/','#')+'.json'
saveJson(newAnnounce,filename)
with open(filename, 'w') as fp:
commentjson.dump(newAnnounce, fp, indent=4, sort_keys=False)
announceNickname=None
announceDomain=None
announcePort=None
if '/users/' in objectUrl or \
'/channel/' in objectUrl or \
'/profile/' in objectUrl:
if '/users/' in objectUrl or '/profile/' in objectUrl:
announceNickname=getNicknameFromActor(objectUrl)
announceDomain,announcePort=getDomainFromActor(objectUrl)
@@ -366,9 +338,7 @@ def undoAnnounce(session,baseDir: str,federationList: [], \
announceNickname=None
announceDomain=None
announcePort=None
if '/users/' in objectUrl or \
'/channel/' in objectUrl or \
'/profile/' in objectUrl:
if '/users/' in objectUrl or '/profile/' in objectUrl:
announceNickname=getNicknameFromActor(objectUrl)
announceDomain,announcePort=getDomainFromActor(objectUrl)
@@ -457,8 +427,7 @@ def sendAnnounceViaServer(baseDir: str,session, \
statusNumber,published = getStatusNumber()
newAnnounceId= \
httpPrefix+'://'+fromDomainFull+'/users/'+ \
fromNickname+'/statuses/'+statusNumber
httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname+'/statuses/'+statusNumber
newAnnounceJson = {
"@context": "https://www.w3.org/ns/activitystreams",
'actor': httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname,
@@ -487,7 +456,7 @@ def sendAnnounceViaServer(baseDir: str,session, \
# get the actor inbox for the To handle
inboxUrl,pubKeyId,pubKey,fromPersonId,sharedInbox,capabilityAcquisition,avatarUrl,displayName = \
getPersonBox(baseDir,session,wfRequest,personCache, \
projectVersion,httpPrefix,fromNickname,fromDomain,postToBox)
projectVersion,httpPrefix,fromDomain,postToBox)
if not inboxUrl:
if debug:
@@ -505,6 +474,10 @@ def sendAnnounceViaServer(baseDir: str,session, \
'Authorization': authHeader}
postResult = \
postJson(session,newAnnounceJson,[],inboxUrl,headers,"inbox:write")
#if not postResult:
# if debug:
# print('DEBUG: POST announce failed for c2s to '+inboxUrl)
# return 5
if debug:
print('DEBUG: c2s POST announce success')
+2 -6
View File
@@ -17,9 +17,7 @@ def hashPassword(password: str) -> str:
"""Hash a password for storing
"""
salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii')
pwdhash = hashlib.pbkdf2_hmac('sha512', \
password.encode('utf-8'), \
salt, 100000)
pwdhash = hashlib.pbkdf2_hmac('sha512', password.encode('utf-8'), salt, 100000)
pwdhash = binascii.hexlify(pwdhash)
return (salt + pwdhash).decode('ascii')
@@ -48,9 +46,7 @@ def authorizeBasic(baseDir: str,path: str,authHeader: str,debug: bool) -> bool:
if debug:
print('DEBUG: Authorixation header does not contain a space character')
return False
if '/users/' not in path and \
'/channel/' not in path and \
'/profile/' not in path:
if '/users/' not in path and '/profile/' not in path:
if debug:
print('DEBUG: Path for Authorization does not contain a user')
return False
+11 -9
View File
@@ -7,7 +7,6 @@ __email__ = "bob@freedombone.net"
__status__ = "Production"
import json
import time
import commentjson
import os
from webfinger import webfingerHandle
@@ -16,8 +15,6 @@ from posts import getPersonBox
from session import postJson
from utils import getNicknameFromActor
from utils import getDomainFromActor
from utils import loadJson
from utils import saveJson
def setAvailability(baseDir: str,nickname: str,domain: str, \
status: str) -> bool:
@@ -29,10 +26,11 @@ def setAvailability(baseDir: str,nickname: str,domain: str, \
actorFilename=baseDir+'/accounts/'+nickname+'@'+domain+'.json'
if not os.path.isfile(actorFilename):
return False
actorJson=loadJson(actorFilename)
if actorJson:
with open(actorFilename, 'r') as fp:
actorJson=commentjson.load(fp)
actorJson['availability']=status
saveJson(actorJson,actorFilename)
with open(actorFilename, 'w') as fp:
commentjson.dump(actorJson, fp, indent=4, sort_keys=False)
return True
def getAvailability(baseDir: str,nickname: str,domain: str) -> str:
@@ -41,8 +39,8 @@ def getAvailability(baseDir: str,nickname: str,domain: str) -> str:
actorFilename=baseDir+'/accounts/'+nickname+'@'+domain+'.json'
if not os.path.isfile(actorFilename):
return False
actorJson=loadJson(actorFilename)
if actorJson:
with open(actorFilename, 'r') as fp:
actorJson=commentjson.load(fp)
if not actorJson.get('availability'):
return None
return actorJson['availability']
@@ -116,7 +114,7 @@ def sendAvailabilityViaServer(baseDir: str,session, \
# get the actor inbox for the To handle
inboxUrl,pubKeyId,pubKey,fromPersonId,sharedInbox,capabilityAcquisition,avatarUrl,displayName = \
getPersonBox(baseDir,session,wfRequest,personCache, \
projectVersion,httpPrefix,nickname,domain,postToBox)
projectVersion,httpPrefix,domain,postToBox)
if not inboxUrl:
if debug:
@@ -134,6 +132,10 @@ def sendAvailabilityViaServer(baseDir: str,session, \
'Authorization': authHeader}
postResult = \
postJson(session,newAvailabilityJson,[],inboxUrl,headers,"inbox:write")
#if not postResult:
# if debug:
# print('DEBUG: POST announce failed for c2s to '+inboxUrl)
# return 5
if debug:
print('DEBUG: c2s POST availability success')
+20 -23
View File
@@ -137,13 +137,11 @@ def isBlocked(baseDir: str,nickname: str,domain: str, \
blockHandle=blockNickname+'@'+blockDomain
if blockHandle in open(globalBlockingFilename).read():
return True
allowFilename= \
baseDir+'/accounts/'+nickname+'@'+domain+'/allowedinstances.txt'
allowFilename=baseDir+'/accounts/'+nickname+'@'+domain+'/allowedinstances.txt'
if os.path.isfile(allowFilename):
if blockDomain not in open(allowFilename).read():
return True
blockingFilename= \
baseDir+'/accounts/'+nickname+'@'+domain+'/blocking.txt'
blockingFilename=baseDir+'/accounts/'+nickname+'@'+domain+'/blocking.txt'
if os.path.isfile(blockingFilename):
if '*@'+blockDomain in open(blockingFilename).read():
return True
@@ -170,9 +168,8 @@ def sendBlockViaServer(baseDir: str,session, \
if ':' not in fromDomain:
fromDomainFull=fromDomain+':'+str(fromPort)
toUrl= 'https://www.w3.org/ns/activitystreams#Public'
ccUrl= \
httpPrefix + '://'+fromDomainFull+'/users/'+fromNickname+'/followers'
toUrl = 'https://www.w3.org/ns/activitystreams#Public'
ccUrl = httpPrefix + '://'+fromDomainFull+'/users/'+fromNickname+'/followers'
blockActor=httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname
newBlockJson = {
@@ -199,8 +196,7 @@ def sendBlockViaServer(baseDir: str,session, \
# get the actor inbox for the To handle
inboxUrl,pubKeyId,pubKey,fromPersonId,sharedInbox,capabilityAcquisition,avatarUrl,displayName = \
getPersonBox(baseDir,session,wfRequest,personCache, \
projectVersion,httpPrefix,fromNickname, \
fromDomain,postToBox)
projectVersion,httpPrefix,fromDomain,postToBox)
if not inboxUrl:
if debug:
@@ -218,6 +214,10 @@ def sendBlockViaServer(baseDir: str,session, \
'Authorization': authHeader}
postResult = \
postJson(session,newBlockJson,[],inboxUrl,headers,"inbox:write")
#if not postResult:
# if debug:
# print('DEBUG: POST announce failed for c2s to '+inboxUrl)
# return 5
if debug:
print('DEBUG: c2s POST block success')
@@ -242,9 +242,8 @@ def sendUndoBlockViaServer(baseDir: str,session, \
if ':' not in fromDomain:
fromDomainFull=fromDomain+':'+str(fromPort)
toUrl= 'https://www.w3.org/ns/activitystreams#Public'
ccUrl= \
httpPrefix + '://'+fromDomainFull+'/users/'+fromNickname+'/followers'
toUrl = 'https://www.w3.org/ns/activitystreams#Public'
ccUrl = httpPrefix + '://'+fromDomainFull+'/users/'+fromNickname+'/followers'
blockActor=httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname
newBlockJson = {
@@ -263,9 +262,8 @@ def sendUndoBlockViaServer(baseDir: str,session, \
handle=httpPrefix+'://'+fromDomainFull+'/@'+fromNickname
# lookup the inbox for the To handle
wfRequest= \
webfingerHandle(session,handle,httpPrefix,cachedWebfingers, \
fromDomain,projectVersion)
wfRequest = webfingerHandle(session,handle,httpPrefix,cachedWebfingers, \
fromDomain,projectVersion)
if not wfRequest:
if debug:
print('DEBUG: announce webfinger failed for '+handle)
@@ -276,8 +274,7 @@ def sendUndoBlockViaServer(baseDir: str,session, \
# get the actor inbox for the To handle
inboxUrl,pubKeyId,pubKey,fromPersonId,sharedInbox,capabilityAcquisition,avatarUrl,displayName = \
getPersonBox(baseDir,session,wfRequest,personCache, \
projectVersion,httpPrefix,fromNickname, \
fromDomain,postToBox)
projectVersion,httpPrefix,fromDomain,postToBox)
if not inboxUrl:
if debug:
@@ -295,6 +292,10 @@ def sendUndoBlockViaServer(baseDir: str,session, \
'Authorization': authHeader}
postResult = \
postJson(session,newBlockJson,[],inboxUrl,headers,"inbox:write")
#if not postResult:
# if debug:
# print('DEBUG: POST announce failed for c2s to '+inboxUrl)
# return 5
if debug:
print('DEBUG: c2s POST block success')
@@ -330,9 +331,7 @@ def outboxBlock(baseDir: str,httpPrefix: str, \
if debug:
print('DEBUG: c2s block object is not a status')
return
if '/users/' not in messageId and \
'/channel/' not in messageId and \
'/profile/' not in messageId:
if '/users/' not in messageId and '/profile/' not in messageId:
if debug:
print('DEBUG: c2s block object has no nickname')
return
@@ -407,9 +406,7 @@ def outboxUndoBlock(baseDir: str,httpPrefix: str, \
if debug:
print('DEBUG: c2s undo block object is not a status')
return
if '/users/' not in messageId and \
'/channel/' not in messageId and \
'/profile/' not in messageId:
if '/users/' not in messageId and '/profile/' not in messageId:
if debug:
print('DEBUG: c2s undo block object has no nickname')
return
+17 -9
View File
@@ -10,10 +10,9 @@ import os
import time
import datetime
import commentjson
from utils import loadJson
from utils import saveJson
def storePersonInCache(baseDir: str,personUrl: str,personJson: {},personCache: {}) -> None:
def storePersonInCache(baseDir: str,personUrl: str, \
personJson: {},personCache: {}) -> None:
"""Store an actor in the cache
"""
currTime=datetime.datetime.utcnow()
@@ -28,7 +27,8 @@ def storePersonInCache(baseDir: str,personUrl: str,personJson: {},personCache: {
if os.path.isdir(baseDir+'/cache/actors'):
cacheFilename=baseDir+'/cache/actors/'+personUrl.replace('/','#')+'.json'
if not os.path.isfile(cacheFilename):
saveJson(personJson,cacheFilename)
with open(cacheFilename, 'w') as fp:
commentjson.dump(personJson, fp, indent=4, sort_keys=False)
def getPersonFromCache(baseDir: str,personUrl: str,personCache: {}) -> {}:
"""Get an actor from the cache
@@ -36,9 +36,16 @@ def getPersonFromCache(baseDir: str,personUrl: str,personCache: {}) -> {}:
# if the actor is not in memory then try to load it from file
loadedFromFile=False
if not personCache.get(personUrl):
cacheFilename=baseDir+'/cache/actors/'+personUrl.replace('/','#')+'.json'
cacheFilename= \
baseDir+'/cache/actors/'+personUrl.replace('/','#')+'.json'
if os.path.isfile(cacheFilename):
personJson=loadJson(cacheFilename)
personJson=None
try:
with open(cacheFilename, 'r') as fp:
personJson=commentjson.load(fp)
except Exception as e:
print('ERROR: unable to load actor from cache '+cacheFilename)
print(e)
if personJson:
storePersonInCache(baseDir,personUrl,personJson,personCache)
loadedFromFile=True
@@ -47,11 +54,12 @@ def getPersonFromCache(baseDir: str,personUrl: str,personCache: {}) -> {}:
if not loadedFromFile:
# update the timestamp for the last time the actor was retrieved
currTime=datetime.datetime.utcnow()
personCache[personUrl]['timestamp']=currTime.strftime("%Y-%m-%dT%H:%M:%SZ")
personCache[personUrl]['timestamp']= \
currTime.strftime("%Y-%m-%dT%H:%M:%SZ")
return personCache[personUrl]['actor']
return None
def expirePersonCache(personCache: {}):
def expirePersonCache(personCache: {}) -> None:
"""Expires old entries from the cache in memory
"""
currTime=datetime.datetime.utcnow()
+35 -22
View File
@@ -14,10 +14,9 @@ import commentjson
from auth import createPassword
from utils import getNicknameFromActor
from utils import getDomainFromActor
from utils import loadJson
from utils import saveJson
def getOcapFilename(baseDir :str,nickname: str,domain: str,actor :str,subdir: str) -> str:
def getOcapFilename(baseDir :str,nickname: str,domain: str, \
actor :str,subdir: str) -> str:
"""Returns the filename for a particular capability accepted or granted
Also creates directories as needed
"""
@@ -42,7 +41,8 @@ def getOcapFilename(baseDir :str,nickname: str,domain: str,actor :str,subdir: st
if not os.path.isdir(ocDir):
os.mkdir(ocDir)
return baseDir+'/accounts/'+nickname+'@'+domain+'/ocap/'+subdir+'/'+actor.replace('/','#')+'.json'
return baseDir+'/accounts/'+nickname+'@'+domain+'/ocap/'+ \
subdir+'/'+actor.replace('/','#')+'.json'
def CapablePost(postJson: {}, capabilityList: [], debug :bool) -> bool:
"""Determines whether a post arriving in the inbox
@@ -122,14 +122,16 @@ def capabilitiesAccept(baseDir: str,httpPrefix: str, \
fullDomain=domain+':'+str(port)
# make directories to store capabilities
ocapFilename=getOcapFilename(baseDir,nickname,fullDomain,acceptedActor,'accept')
ocapFilename= \
getOcapFilename(baseDir,nickname,fullDomain,acceptedActor,'accept')
if not ocapFilename:
return None
ocapAccept=None
# if the capability already exists then load it from file
if os.path.isfile(ocapFilename):
ocapAccept=loadJson(ocapFilename)
with open(ocapFilename, 'r') as fp:
ocapAccept=commentjson.load(fp)
# otherwise create a new capability
if not ocapAccept:
acceptedActorNickname=getNicknameFromActor(acceptedActor)
@@ -138,9 +140,11 @@ def capabilitiesAccept(baseDir: str,httpPrefix: str, \
return None
acceptedActorDomain,acceptedActorPort=getDomainFromActor(acceptedActor)
if acceptedActorPort:
ocapId=acceptedActorNickname+'@'+acceptedActorDomain+':'+str(acceptedActorPort)+'#'+createPassword(32)
ocapId=acceptedActorNickname+'@'+acceptedActorDomain+':'+ \
str(acceptedActorPort)+'#'+createPassword(32)
else:
ocapId=acceptedActorNickname+'@'+acceptedActorDomain+'#'+createPassword(32)
ocapId=acceptedActorNickname+'@'+acceptedActorDomain+'#'+ \
createPassword(32)
ocapAccept = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": httpPrefix+"://"+fullDomain+"/caps/"+ocapId,
@@ -153,19 +157,23 @@ def capabilitiesAccept(baseDir: str,httpPrefix: str, \
ocapAccept['actor']=httpPrefix+"://"+fullDomain+'/users/'+nickname
if saveToFile:
saveJson(ocapAccept,ocapFilename)
with open(ocapFilename, 'w') as fp:
commentjson.dump(ocapAccept, fp, indent=4, sort_keys=False)
return ocapAccept
def capabilitiesGrantedSave(baseDir :str,nickname :str,domain :str,ocap: {}) -> bool:
def capabilitiesGrantedSave(baseDir :str, \
nickname :str,domain :str,ocap: {}) -> bool:
"""A capabilities accept is received, so stor it for
reference when sending to the actor
"""
if not ocap.get('actor'):
return False
ocapFilename=getOcapFilename(baseDir,nickname,domain,ocap['actor'],'granted')
ocapFilename= \
getOcapFilename(baseDir,nickname,domain,ocap['actor'],'granted')
if not ocapFilename:
return False
saveJson(ocap,ocapFilename)
with open(ocapFilename, 'w') as fp:
commentjson.dump(ocap, fp, indent=4, sort_keys=False)
return True
def capabilitiesUpdate(baseDir: str,httpPrefix: str, \
@@ -188,7 +196,8 @@ def capabilitiesUpdate(baseDir: str,httpPrefix: str, \
fullDomain=domain+':'+str(port)
# Get the filename of the capability
ocapFilename=getOcapFilename(baseDir,nickname,fullDomain,updateActor,'accept')
ocapFilename= \
getOcapFilename(baseDir,nickname,fullDomain,updateActor,'accept')
if not ocapFilename:
return None
@@ -207,7 +216,8 @@ def capabilitiesUpdate(baseDir: str,httpPrefix: str, \
}
# read the existing capability
ocapJson=loadJson(ocapFilename)
with open(ocapFilename, 'r') as fp:
ocapJson=commentjson.load(fp)
# set the new capabilities list. eg. ["inbox:write","objects:read"]
ocapJson['capability']=updateCaps
@@ -219,15 +229,17 @@ def capabilitiesUpdate(baseDir: str,httpPrefix: str, \
return None
updateActorDomain,updateActorPort=getDomainFromActor(updateActor)
if updateActorPort:
ocapId=updateActorNickname+'@'+updateActorDomain+':'+str(updateActorPort)+'#'+createPassword(32)
ocapId=updateActorNickname+'@'+updateActorDomain+':'+ \
str(updateActorPort)+'#'+createPassword(32)
else:
ocapId=updateActorNickname+'@'+updateActorDomain+'#'+createPassword(32)
ocapJson['id']=httpPrefix+"://"+fullDomain+"/caps/"+ocapId
ocapUpdate['object']=ocapJson
# save it again
saveJson(ocapJson,ocapFilename)
with open(ocapFilename, 'w') as fp:
commentjson.dump(ocapJson, fp, indent=4, sort_keys=False)
return ocapUpdate
def capabilitiesReceiveUpdate(baseDir :str, \
@@ -248,11 +260,12 @@ def capabilitiesReceiveUpdate(baseDir :str, \
print(ocapFilename)
return False
ocapJson=loadJson(ocapFilename)
if ocapJson:
with open(ocapFilename, 'r') as fp:
ocapJson=commentjson.load(fp)
ocapJson['id']=newCapabilitiesId
ocapJson['capability']=capabilityList
return saveJson(ocapJson,ocapFilename)
with open(ocapFilename, 'w') as fp:
commentjson.dump(ocapJson, fp, indent=4, sort_keys=False)
return True
return False
+4 -2
View File
@@ -38,7 +38,7 @@ In cases where criticism of ideology or culture remains on-topic, respectfully d
## No proprietary software
This is a Free Software project and there is no intention to support or endorse any software which is proprietary, including that which may be running on client devices or corporate silo systems. For example, supporting iThings or Windows users is out of scope of the project. So too would be integration with Twitter or Google's sevices.
This is a Free Software project and there is no intention to support or endorse any software which is proprietary, including that which may be running on client devices. For example, supporting iThings or Windows users is out of scope of the project. It's possible that Epicyon may work with those systems, but if there are problems it's not within the scope of this project to resolve compatibility issues with proprietary browsers or platforms.
## Raising concerns
@@ -46,7 +46,9 @@ If you're raising concerns about something or someone, there must be demonstrabl
This is not a big project and so there is no division of labor or special enforcement committee or bureaucratic process.
Complaints should be either reported in the Matrix chat room **#epicyon:matrix.freedombone.net** or sent to bob@freedombone.net, preferably via XMPP/Conversations with OMEMO enabled but you can also use the same address for email correspondence.
If your concern is something which is suitable for public discussion then raise it in one of the chat rooms mentioned above. Be mindful to do so in a polite and considerate manner. Hostile communication towards anyone raising concerns publicly will be considered a code of conduct violation.
Private complaints should be sent to bob@freedombone.net, preferably via XMPP/Conversations with OMEMO enabled but you can also use the same address for email correspondence.
## In case of violations
+11 -13
View File
@@ -7,11 +7,8 @@ __email__ = "bob@freedombone.net"
__status__ = "Production"
import os
import time
import json
import commentjson
from utils import loadJson
from utils import saveJson
def createConfig(baseDir: str) -> None:
"""Creates a configuration file
@@ -21,26 +18,27 @@ def createConfig(baseDir: str) -> None:
return
configJson = {
}
saveJson(configJson,configFilename)
with open(configFilename, 'w') as fp:
commentjson.dump(configJson, fp, indent=4, sort_keys=True)
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)
with open(configFilename, 'r') as fp:
configJson=commentjson.load(fp)
configJson[variableName]=variableValue
saveJson(configJson,configFilename)
with open(configFilename, 'w') as fp:
commentjson.dump(configJson, fp, indent=4, sort_keys=True)
def getConfigParam(baseDir: str, variableName: str):
def getConfigParam(baseDir: str, variableName: str) -> None:
"""Gets a configuration value
"""
createConfig(baseDir)
configFilename=baseDir+'/config.json'
configJson=loadJson(configFilename)
if configJson:
if configJson.get(variableName):
return configJson[variableName]
with open(configFilename, 'r') as fp:
configJson=commentjson.load(fp)
if configJson.get(variableName):
return configJson[variableName]
return None
+96 -161
View File
@@ -7,35 +7,9 @@ __email__ = "bob@freedombone.net"
__status__ = "Production"
import os
import time
import commentjson
from shutil import copyfile
def replaceEmojiFromTags(content: str,tag: [],messageType: str) -> str:
"""Uses the tags to replace :emoji: with html image markup
"""
for tagItem in tag:
if not tagItem.get('type'):
continue
if tagItem['type']!='Emoji':
continue
if not tagItem.get('name'):
continue
if not tagItem.get('icon'):
continue
if not tagItem['icon'].get('url'):
continue
if tagItem['name'] not in content:
continue
htmlClass='emoji'
if messageType=='post header':
htmlClass='emojiheader'
if messageType=='profile':
htmlClass='emojiprofile'
emojiHtml="<img src=\""+tagItem['icon']['url']+"\" alt=\""+tagItem['name'].replace(':','')+"\" align=\"middle\" class=\""+htmlClass+"\"/>"
content=content.replace(tagItem['name'],emojiHtml)
return content
def addMusicTag(content: str,tag: str) -> str:
"""If a music link is found then ensure that the post is tagged appropriately
"""
@@ -51,16 +25,17 @@ def addMusicTag(content: str,tag: str) -> str:
break
if not musicSiteFound:
return content
return ':music: '+content+' '+tag+' '
return content+' '+tag+' '
def addWebLinks(content: str) -> str:
"""Adds markup for web links
"""
if not ('https://' in content or 'http://' in content):
if not ('https://' in content or \
'http://' in content or \
'dat://' in content):
return content
maxLinkLength=40
words=content.replace('\n',' --linebreak-- ').split(' ')
words=content.replace('\n',' --linebreak--').split(' ')
replaceDict={}
for w in words:
if w.startswith('https://') or \
@@ -75,31 +50,34 @@ def addWebLinks(content: str) -> str:
markup+='<span class="invisible">http://</span>'
elif w.startswith('dat://'):
markup+='<span class="invisible">dat://</span>'
linkText=w.replace('https://','').replace('http://','').replace('dat://','')
# prevent links from becoming too long
if len(linkText)>maxLinkLength:
markup+='<span class="ellipsis">'+linkText[:maxLinkLength]+'</span>'
markup+='<span class="invisible">'+linkText[maxLinkLength:]+'</span></a>'
else:
markup+='<span class="ellipsis">'+linkText+'</span></a>'
markup+='<span class="ellipsis">'+ \
w.replace('https://','').replace('http://','').replace('dat://','')+ \
'</span></a>'
replaceDict[w]=markup
for url,markup in replaceDict.items():
content=content.replace(url,markup)
content=content.replace(' --linebreak-- ','<br>')
content=content.replace(' --linebreak--','<br>')
return content
def validHashTag(hashtag: str) -> bool:
"""Returns true if the give hashtag contains valid characters
"""
validChars = set('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ')
validChars = \
set('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ')
if set(hashtag).issubset(validChars):
return True
return False
def addHashTags(wordStr: str,httpPrefix: str,domain: str,replaceHashTags: {},postHashtags: {}) -> bool:
def addHashTags(wordStr: str, \
httpPrefix: str,domain: str, \
replaceHashTags: {},postHashtags: {}) -> bool:
"""Detects hashtags and adds them to the replacements dict
Also updates the hashtags list to be added to the post
"""
if not wordStr.startswith('#'):
return False
if len(wordStr)<2:
return False
if replaceHashTags.get(wordStr):
return True
hashtag=wordStr[1:]
@@ -112,7 +90,9 @@ def addHashTags(wordStr: str,httpPrefix: str,domain: str,replaceHashTags: {},pos
'type': 'Hashtag'
}
replaceHashTags[wordStr]= \
"<a href=\""+hashtagUrl+"\" class=\"mention hashtag\" rel=\"tag\">#<span>"+hashtag+"</span></a>"
"<a href=\""+hashtagUrl+ \
"\" class=\"mention hashtag\" rel=\"tag\">#<span>"+ \
hashtag+"</span></a>"
return True
def loadEmojiDict(emojiDataFilename: str,emojiDict: {}) -> None:
@@ -135,12 +115,15 @@ def loadEmojiDict(emojiDataFilename: str,emojiDict: {}) -> None:
continue
if '..' in emojiUnicode:
emojiUnicode=emojiUnicode.split('..')[0]
emojiName=line.split(')',1)[1].strip().replace('\n','').replace(' ','').replace('-','')
emojiName= \
line.split(')',1)[1].strip().replace('\n','').replace(' ','').replace('-','')
if '..' in emojiName:
emojiName=emojiName.split('..')[0]
emojiDict[emojiName.lower()]=emojiUnicode
def addEmoji(baseDir: str,wordStr: str,httpPrefix: str,domain: str,replaceEmoji: {},postTags: {},emojiDict: {}) -> bool:
def addEmoji(baseDir: str,wordStr: str, \
httpPrefix: str,domain: str, \
replaceEmoji: {},postTags: {},emojiDict: {}) -> bool:
"""Detects Emoji and adds them to the replacements dict
Also updates the tags list to be added to the post
"""
@@ -152,10 +135,8 @@ def addEmoji(baseDir: str,wordStr: str,httpPrefix: str,domain: str,replaceEmoji:
return False
if replaceEmoji.get(wordStr):
return True
# remove leading and trailing : characters
emoji=wordStr[1:]
emoji=emoji[:-1]
# is the text of the emoji valid?
if not validHashTag(emoji):
return False
if not emojiDict.get(emoji):
@@ -175,9 +156,15 @@ def addEmoji(baseDir: str,wordStr: str,httpPrefix: str,domain: str,replaceEmoji:
}
return True
def addMention(wordStr: str,httpPrefix: str,following: str,replaceMentions: {},recipients: [],tags: {}) -> bool:
def addMention(wordStr: str, \
httpPrefix: str,following: str, \
replaceMentions: {},recipients: [],tags: {}) -> bool:
"""Detects mentions and adds them to the replacements dict and recipients list
"""
if not wordStr.startswith('@'):
return False
if len(wordStr)<2:
return False
possibleHandle=wordStr[1:]
# @nick
if following and '@' not in possibleHandle:
@@ -187,7 +174,8 @@ def addMention(wordStr: str,httpPrefix: str,following: str,replaceMentions: {},r
for follow in following:
if follow.startswith(possibleNickname+'@'):
replaceDomain=follow.replace('\n','').split('@')[1]
recipientActor=httpPrefix+"://"+replaceDomain+"/users/"+possibleNickname
recipientActor= \
httpPrefix+"://"+replaceDomain+"/users/"+possibleNickname
if recipientActor not in recipients:
recipients.append(recipientActor)
tags[wordStr]={
@@ -195,24 +183,21 @@ def addMention(wordStr: str,httpPrefix: str,following: str,replaceMentions: {},r
'name': wordStr,
'type': 'Mention'
}
replaceMentions[wordStr]="<span class=\"h-card\"><a href=\""+httpPrefix+"://"+replaceDomain+"/@"+possibleNickname+"\" class=\"u-url mention\">@<span>"+possibleNickname+"</span></a></span>"
replaceMentions[wordStr]= \
"<span class=\"h-card\"><a href=\""+httpPrefix+ \
"://"+replaceDomain+"/@"+possibleNickname+ \
"\" class=\"u-url mention\">@<span>"+ \
possibleNickname+"</span></a></span>"
return True
return False
possibleNickname=None
possibleDomain=None
if '@' not in possibleHandle:
return False
possibleNickname=possibleHandle.split('@')[0]
if not possibleNickname:
return False
possibleDomain=possibleHandle.split('@')[1].strip('\n')
if not possibleDomain:
return False
if following:
for follow in following:
if follow.replace('\n','')!=possibleHandle:
continue
recipientActor=httpPrefix+"://"+possibleDomain+"/users/"+possibleNickname
recipientActor= \
httpPrefix+"://"+possibleDomain+"/users/"+possibleNickname
if recipientActor not in recipients:
recipients.append(recipientActor)
tags[wordStr]={
@@ -220,70 +205,41 @@ def addMention(wordStr: str,httpPrefix: str,following: str,replaceMentions: {},r
'name': wordStr,
'type': 'Mention'
}
replaceMentions[wordStr]="<span class=\"h-card\"><a href=\""+httpPrefix+"://"+possibleDomain+"/@"+possibleNickname+"\" class=\"u-url mention\">@<span>"+possibleNickname+"</span></a></span>"
replaceMentions[wordStr]= \
"<span class=\"h-card\"><a href=\""+httpPrefix+ \
"://"+possibleDomain+"/@"+possibleNickname+ \
"\" class=\"u-url mention\">@<span>"+possibleNickname+ \
"</span></a></span>"
return True
# @nick@domain
if not (possibleDomain=='localhost' or '.' in possibleDomain):
return False
recipientActor=httpPrefix+"://"+possibleDomain+"/users/"+possibleNickname
if recipientActor not in recipients:
recipients.append(recipientActor)
tags[wordStr]={
'href': recipientActor,
'name': wordStr,
'type': 'Mention'
}
replaceMentions[wordStr]="<span class=\"h-card\"><a href=\""+httpPrefix+"://"+possibleDomain+"/@"+possibleNickname+"\" class=\"u-url mention\">@<span>"+possibleNickname+"</span></a></span>"
return True
def removeLongWords(content: str,maxWordLength: int,longWordsList: []) -> str:
"""Breaks up long words so that on mobile screens this doesn't disrupt the layout
"""
words=content.split(' ')
if not longWordsList:
longWordsList=[]
for wordStr in words:
if len(wordStr)>maxWordLength:
if wordStr not in longWordsList:
longWordsList.append(wordStr)
for wordStr in longWordsList:
if wordStr.startswith('<'):
continue
if '=\"' in wordStr:
continue
if '@' in wordStr:
if '@@' not in wordStr:
continue
if 'https:' in wordStr:
continue
elif 'http:' in wordStr:
continue
elif 'dat:' in wordStr:
continue
if '<' in wordStr:
wordStr=wordStr.split('<',1)[0]
if '/' in wordStr:
continue
if len(wordStr[maxWordLength:])<maxWordLength:
content= \
content.replace(wordStr, \
wordStr[:maxWordLength]+'\n'+ \
wordStr[maxWordLength:])
else:
content= \
content.replace(wordStr, \
wordStr[:maxWordLength])
return content
if '@' in possibleHandle:
if not (possibleDomain=='localhost' or '.' in possibleDomain):
return False
recipientActor= \
httpPrefix+"://"+possibleDomain+"/users/"+possibleNickname
if recipientActor not in recipients:
recipients.append(recipientActor)
tags[wordStr]={
'href': recipientActor,
'name': wordStr,
'type': 'Mention'
}
replaceMentions[wordStr]= \
"<span class=\"h-card\"><a href=\""+httpPrefix+ \
"://"+possibleDomain+"/@"+possibleNickname+ \
"\" class=\"u-url mention\">@<span>"+possibleNickname+ \
"</span></a></span>"
return True
return False
def addHtmlTags(baseDir: str,httpPrefix: str, \
nickname: str,domain: str,content: str, \
recipients: [],hashtags: {},isJsonContent=False) -> str:
recipients: [],hashtags: {}) -> str:
""" Replaces plaintext mentions such as @nick@domain into html
by matching against known following accounts
"""
if content.startswith('<p>'):
return content
maxWordLength=40
content=content.replace('\n',' --linebreak-- ')
content=addMusicTag(content,'nowplaying')
words=content.replace(',',' ').replace(';',' ').split(' ')
@@ -313,68 +269,48 @@ def addHtmlTags(baseDir: str,httpPrefix: str, \
# read the following list so that we can detect just @nick
# in addition to @nick@domain
following=None
if '@' in words:
if os.path.isfile(followingFilename):
with open(followingFilename, "r") as f:
following = f.readlines()
if os.path.isfile(followingFilename):
with open(followingFilename, "r") as f:
following = f.readlines()
# extract mentions and tags from words
longWordsList=[]
for wordStr in words:
wordLen=len(wordStr)
if wordLen>2:
if wordLen>maxWordLength:
longWordsList.append(wordStr)
firstChar=wordStr[0]
if firstChar=='@':
if addMention(wordStr,httpPrefix,following,replaceMentions,recipients,hashtags):
continue
elif firstChar=='#':
if addHashTags(wordStr,httpPrefix,originalDomain,replaceHashTags,hashtags):
continue
elif ':' in wordStr:
#print('TAG: emoji located - '+wordStr)
wordStr2=wordStr.split(':')[1]
if not emojiDict:
# emoji.json is generated so that it can be customized and the changes
# will be retained even if default_emoji.json is subsequently updated
if not os.path.isfile(baseDir+'/emoji/emoji.json'):
copyfile(baseDir+'/emoji/default_emoji.json',baseDir+'/emoji/emoji.json')
emojiDictCtr=0
while not emojiDict and emojiDictCtr<5:
if emojiDictCtr>0:
print('Retry emoji load '+baseDir+'/emoji/emoji.json')
try:
with open(baseDir+'/emoji/emoji.json', 'r') as fp:
emojiDict=commentjson.load(fp)
if emojiDictCtr>0:
print('emojiDict loaded on try '+str(emojiDictCtr))
break
except:
print('WARN: commentjson exception addHtmlTags')
print('Failed to load emoji (try '+str(emojiDictCtr)+'): '+baseDir+'/emoji/emoji.json')
time.sleep(1)
emojiDictCtr+=1
if addMention(wordStr,httpPrefix,following, \
replaceMentions,recipients,hashtags):
continue
if addHashTags(wordStr,httpPrefix,originalDomain, \
replaceHashTags,hashtags):
continue
if len(wordStr)>2 and wordStr.startswith(':') and \
wordStr.endswith(':') and not emojiDict:
print('Loading emoji lookup')
#print('TAG: looking up emoji for :'+wordStr2+':')
addEmoji(baseDir,':'+wordStr2+':',httpPrefix,originalDomain,replaceEmoji,hashtags,emojiDict)
# emoji.json is generated so that it can be customized and the changes
# will be retained even if default_emoji.json is subsequently updated
if not os.path.isfile(baseDir+'/emoji/emoji.json'):
copyfile(baseDir+'/emoji/default_emoji.json', \
baseDir+'/emoji/emoji.json')
with open(baseDir+'/emoji/emoji.json', 'r') as fp:
emojiDict=commentjson.load(fp)
addEmoji(baseDir,wordStr,httpPrefix,originalDomain, \
replaceEmoji,hashtags,emojiDict)
# replace words with their html versions
for wordStr,replaceStr in replaceMentions.items():
content=content.replace(wordStr,replaceStr)
for wordStr,replaceStr in replaceHashTags.items():
content=content.replace(wordStr,replaceStr)
if not isJsonContent:
for wordStr,replaceStr in replaceEmoji.items():
content=content.replace(wordStr,replaceStr)
for wordStr,replaceStr in replaceEmoji.items():
content=content.replace(wordStr,replaceStr)
content=addWebLinks(content)
if longWordsList:
content=removeLongWords(content,maxWordLength,longWordsList)
content=content.replace(' --linebreak-- ','</p><p>')
return '<p>'+content+'</p>'
def getMentionsFromHtml(htmlText: str,matchStr="<span class=\"h-card\"><a href=\"") -> []:
def getMentionsFromHtml(htmlText: str, \
matchStr="<span class=\"h-card\"><a href=\"") -> []:
"""Extracts mentioned actors from the given html content string
"""
mentions=[]
@@ -387,6 +323,5 @@ def getMentionsFromHtml(htmlText: str,matchStr="<span class=\"h-card\"><a href=\
actorStr=mentionStr.split('"')[0]
if actorStr.startswith('http') or \
actorStr.startswith('dat:'):
if actorStr not in mentions:
mentions.append(actorStr)
mentions.append(actorStr)
return mentions
+799 -1542
View File
File diff suppressed because it is too large. Load diff
+6 -8
View File
@@ -20,22 +20,20 @@
<p>Content found to be non-compliant with this policy will be removed and any accounts on this instance producing, repeating or linking to such content will be deleted typically without prior notification.</p>
<h3>Federation Policy</h3>
<p>In a proactive effort to avoid the classic fate of <i>"embrace, extend, extinguish"</i> this system will block any instance launched, acquired or funded by Alphabet, Facebook, Twitter, Microsoft, Apple, Amazon, Elsevier or other monopolistic Silicon Valley companies.</p>
<p>This system will not federate with instances whose moderation policy is incompatible with the content policy described above. If an instance lacks a moderation policy, or refuses to enforce one, it will be assumed to be incompatible.</p>
<h3>Commercial Use</h3>
<p>Commercial use of original content on this instance is strictly forbidden without the prior written permission of individual account holders. The instance administrator does not hold copyright on any original content posted by account holders. Publication or federation of content does not imply permission for commercial use.</p>
<p>Commercial use includes the harvesting of data to create products which are then sold, such as statistics, business reports or machine learning models.</p>
<h3>Federation Policy</h3>
<p>In a proactive effort to avoid the classic fate of <i>"embrace, extend, extinguish"</i> this system will block any instance launched, acquired or funded by Alphabet, Facebook, Twitter, Microsoft, Apple, Amazon, Elsevier or other monopolistic Silicon Valley companies.</p>
<p>This system will not federate with instances whose moderation policy is incompatible with the content policy described above. If an instance lacks a moderation policy, or refuses to enforce one, it will be assumed to be incompatible.</p>
<h3>Copyrights</h3>
<p>Epicyon is licensed under <a href="https://www.gnu.org/licenses/agpl-3.0-standalone.html">GNU AGPL version 3</a>
<p>Emojis designed by <a href="https://openmoji.org">OpenMoji</a> – the open-source emoji and icon project. License: <a href="https://creativecommons.org/licenses/by-sa/4.0">CC BY-SA 4.0</a></p>
<p>Blob Cat Emoji and Meowmoji were made by Nitro Blob Hub, licensed under <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache 2.0</a>.</p>
+3 -8
View File
@@ -68,9 +68,7 @@ def createDelete(session,baseDir: str,federationList: [], \
deleteNickname=None
deleteDomain=None
deletePort=None
if '/users/' in objectUrl or \
'/channel/' in objectUrl or \
'/profile/' in objectUrl:
if '/users/' in objectUrl or '/profile/' in objectUrl:
deleteNickname=getNicknameFromActor(objectUrl)
deleteDomain,deletePort=getDomainFromActor(objectUrl)
@@ -129,8 +127,7 @@ def sendDeleteViaServer(baseDir: str,session, \
# get the actor inbox for the To handle
inboxUrl,pubKeyId,pubKey,fromPersonId,sharedInbox,capabilityAcquisition,avatarUrl,displayName = \
getPersonBox(baseDir,session,wfRequest,personCache, \
projectVersion,httpPrefix,fromNickname, \
fromDomain,postToBox)
projectVersion,httpPrefix,fromDomain,postToBox)
if not inboxUrl:
if debug:
@@ -244,9 +241,7 @@ def outboxDelete(baseDir: str,httpPrefix: str, \
if debug:
print('DEBUG: c2s delete object is not a status')
return
if '/users/' not in messageId and \
'/channel/' not in messageId and \
'/profile/' not in messageId:
if '/users/' not in messageId and '/profile/' not in messageId:
if debug:
print('DEBUG: c2s delete object has no nickname')
return
-73
View File
@@ -1,73 +0,0 @@
__filename__ = "donate.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.0.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
import json
def getDonationTypes() -> str:
return ('patreon','paypal','gofundme','liberapay', \
'kickstarter','indiegogo','crowdsupply', \
'subscribestar')
def getDonationUrl(actorJson: {}) -> str:
"""Returns a link used for donations
"""
if not actorJson.get('attachment'):
return ''
donationType=getDonationTypes()
for propertyValue in actorJson['attachment']:
if not propertyValue.get('name'):
continue
if propertyValue['name'].lower() not in donationType:
continue
if not propertyValue.get('type'):
continue
if not propertyValue.get('value'):
continue
if propertyValue['type']!='PropertyValue':
continue
if '<a href="' not in propertyValue['value']:
continue
donateUrl=propertyValue['value'].split('<a href="')[1]
if '"' in donateUrl:
return donateUrl.split('"')[0]
return ''
def setDonationUrl(actorJson: {},donateUrl: str) -> None:
"""Sets a link used for donations
"""
if not actorJson.get('attachment'):
actorJson['attachment']=[]
donationType=getDonationTypes()
donateName=None
for paymentService in donationType:
if paymentService in donateUrl:
donateName=paymentService
if not donateName:
return
donateValue='<a href="'+donateUrl+'" rel="me nofollow noopener noreferrer" target="_blank">'+donateUrl+'</a>'
for propertyValue in actorJson['attachment']:
if not propertyValue.get('name'):
continue
if not propertyValue.get('type'):
continue
if propertyValue['name'].lower()!=donateName:
continue
if propertyValue['type']!='PropertyValue':
continue
propertyValue['value']=donateValue
return
newDonate={
"name": donateName,
"type": "PropertyValue",
"value": donateValue
}
actorJson['attachment'].append(newDonate)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+2 -189
View File
@@ -437,6 +437,7 @@
"tent": "26FA",
"thermometer": "1F321",
"thinking": "1F914",
"thinkingstallman": "7B2F1",
"tick": "2714",
"tiger": "1F405",
"tomato": "1F345",
@@ -529,11 +530,8 @@
"manjaro": "manjaro",
"mint": "mint",
"ubuntu": "ubuntu",
"ubuntumate": "ubuntumate",
"kubuntu": "kubuntu",
"opensuse": "opensuse",
"arch": "arch",
"archlinux": "arch",
"debian": "debian",
"zorin": "zorin",
"solus": "solus",
@@ -545,190 +543,5 @@
"intersex": "intersex",
"lesbianflag": "lesbianflag",
"transflag": "transflag",
"transfem": "transfem",
"trebuchet": "trebuchet",
"molotov": "molotov",
"walkdog": "E182",
"signpost": "E094",
"parents": "E081",
"redcross": "E090",
"firehazard": "E089",
"firstaid": "E140",
"download": "E252",
"upload": "E251",
"wifi": "E254",
"lighter": "E143",
"power": "E097",
"sleep": "1F4A4",
"goose": "1F9A2",
"shower": "1F6BF",
"shopping": "1F6D2",
"maple": "1F341",
"shamrock2": "1F340",
"shootingstar": "1F320",
"rain": "1F327",
"snow": "1F328",
"lightning2": "1F329",
"sunny": "1F324",
"free": "1F193",
"headphones": "1F3A7",
"wildcat": "wildcat",
"footprints": "1F463",
"pear": "1F350",
"snorkel": "1F93F",
"pineapple": "1F34D",
"statueliberty": "1F5FD",
"grapeheart": "grapeheart",
"firefox": "firefox",
"dove": "dove",
"unspecifiedgoose": "unspecifiedgoose",
"lesbian_heart": "lesbian_heart",
"heart_trans": "heart_trans",
"greensun": "greensun",
"calendar": "1F5D3",
"thinkpadthinking": "thinkpadthinking",
"meowflushed": "meowflushed",
"meowpeek": "meowpeek",
"meowamused": "meowamused",
"meowaww": "meowaww",
"meowkissblush": "meowkissblush",
"meowthumbsup": "meowthumbsup",
"meowthinking": "meowthinking",
"meowrollingeyes": "meowrollingeyes",
"meownervous": "meownervous",
"meowshh": "meowshh",
"meowsmilehappyeyes": "meowsmilehappyeyes",
"meowfacepalm": "meowfacepalm",
"meowmelt": "meowmelt",
"meowheart": "meowheart",
"meowsmirk": "meowsmirk",
"meowhyperthink": "meowhyperthink",
"meowuwu": "meowuwu",
"meowowo": "meowowo",
"meowthinkingcool": "meowthinkingcool",
"meowdrool": "meowdrool",
"meownauseated": "meownauseated",
"meowpopcorn": "meowpopcorn",
"meowwaitwhat": "meowwaitwhat",
"meowupset": "meowupset",
"meowtilt": "meowtilt",
"meowsleepless": "meowsleepless",
"meowtired": "meowtired",
"meowpats": "meowpats",
"meowglare": "meowglare",
"meowowoevil": "meowowoevil",
"meowpurrfecto": "meowpurrfecto",
"meowblush": "meowblush",
"meowthinkingsmirk": "meowthinkingsmirk",
"meowstop": "meowstop",
"meowsad": "meowsad",
"meowcowboy": "meowcowboy",
"meowwave": "meowwave",
"meowpolice": "meowpolice",
"meowdevil": "meowdevil",
"meowkiss": "meowkiss",
"meowpout": "meowpout",
"meowcoy": "meowcoy",
"meowcheer": "meowcheer",
"meowmeltmeltmeltmelt": "meowmeltmeltmeltmelt",
"meowsweats": "meowsweats",
"meowokhand": "meowokhand",
"meowreachrev": "meowreachrev",
"meowreach": "meowreach",
"meowopenmouth": "meowopenmouth",
"meowweary": "meowweary",
"meownomcookie": "meownomcookie",
"meowwink": "meowwink",
"meowthumbsdown": "meowthumbsdown",
"meowthinkingglare": "meowthinkingglare",
"meowscream": "meowscream",
"meowgift": "meowgift",
"meowhearteyes": "meowhearteyes",
"meowdizzy": "meowdizzy",
"meowupsidedown": "meowupsidedown",
"meownom": "meownom",
"meowneutral": "meowneutral",
"meowwaverev": "meowwaverev",
"meowsmile": "meowsmile",
"meowsleeping": "meowsleeping",
"meowhuh": "meowhuh",
"meowmelttears": "meowmelttears",
"meowsadpats": "meowsadpats",
"meowsnuggle": "meowsnuggle",
"meowcamera": "meowcamera",
"meowtongue": "meowtongue",
"meowangel": "meowangel",
"meowmeltmelt": "meowmeltmelt",
"meowmeltmeltmelt": "meowmeltmeltmelt",
"meowsob": "meowsob",
"meowdab": "meowdab",
"meowwhistle": "meowwhistle",
"meowsmileopenmouth": "meowsmileopenmouth",
"meowterrified": "meowterrified",
"meowtonguewink": "meowtonguewink",
"meowcry": "meowcry",
"meowthonkang": "meowthonkang",
"meowhearthug": "meowhearthug",
"meowcoffee": "meowcoffee",
"meowmorning": "meowmorning",
"meowevening": "meowevening",
"meowdetective": "meowdetective",
"meowxd": "meowxd",
"meowheadphones": "meowheadphones",
"meowreachsad": "meowreachsad",
"meowreachsadrev": "meowreachsadrev",
"meowthinksmart": "meowthinksmart",
"meowshrug": "meowshrug",
"meowwavepeek": "meowwavepeek",
"meowthinkingportal": "meowthinkingportal",
"meowsmilesweat": "meowsmilesweat",
"meowghostwave": "meowghostwave",
"meowmeltthumbsup": "meowmeltthumbsup",
"meowtihihi": "meowtihihi",
"meowinlove": "meowinlove",
"meowbox": "meowbox",
"meownwn": "meownwn",
"meowderpy": "meowderpy",
"blobcat": "blobcat",
"blobcathappy": "blobcathappy",
"blobcoolcat": "blobcoolcat",
"blobcatwink": "blobcatwink",
"blobcatwinktongue": "blobcatwinktongue",
"blobcatcoffee": "blobcatcoffee",
"blobcatsad": "blobcatsad",
"blobcatblep": "blobcatblep",
"blobcatevil": "blobcatevil",
"blobcataww": "blobcataww",
"blobcatpats": "blobcatpats",
"blobcatangry": "blobcatangry",
"blobcatmlem": "blobcatmlem",
"blobcatthumbsup": "blobcatthumbsup",
"blobcatthumbsdown": "blobcatthumbsdown",
"blobcatsleep": "blobcatsleep",
"blobcatblush": "blobcatblush",
"blobcatsneezing": "blobcatsneezing",
"blobcatsnuggle": "blobcatsnuggle",
"blobcathighfive": "blobcathighfive",
"blobcatheart": "blobcatheart",
"blobcatfearful": "blobcatfearful",
"blobcattilt": "blobcattilt",
"blobcatfingerguns": "blobcatfingerguns",
"blobcattea": "blobcattea",
"blobcatkissheart": "blobcatkissheart",
"blobcatshy": "blobcatshy",
"blobcathissing": "blobcathissing",
"blobcattableflip": "blobcattableflip",
"blobcathug": "blobcathug",
"blobcatlewd": "blobcatlewd",
"blobcatdab": "blobcatdab",
"blobcatcouple": "blobcatcouple",
"blobcatjava": "blobcatjava",
"blobcatunamused": "blobcatunamused",
"blobcatscared": "blobcatscared",
"blobcatmorningcoffee": "blobcatmorningcoffee",
"blobcat3c": "blobcat3c",
"blobcatbusiness": "blobcatbusiness",
"blobcatdeficit": "blobcatdeficit",
"blobcatscience": "blobcatscience",
"blobcatstop": "blobcatstop"
"transfem": "transfem"
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Loaded 100 of 346 files, more files were not shown because too many files have changed in this diff. Show more