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

merge-requests/30/head
Bob Mottram 2021-07-16 15:01:57 +01:00
commit ceb4c76228
469 changed files with 27012 additions and 8663 deletions

View File

@ -17,6 +17,9 @@ source:
clean:
rm -f *.*~ *~ *.dot
rm -f orgs/*~
rm -f defaultwelcome/*~
rm -f theme/indymediaclassic/welcome/*~
rm -f theme/indymediamodern/welcome/*~
rm -f website/EN/*~
rm -f gemini/EN/*~
rm -f scripts/*~

View File

@ -8,9 +8,9 @@ Add issues on https://gitlab.com/bashrc2/epicyon/-/issues
<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, news feed 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 suitable 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)
[Project Goals](README_goals.md) - [Commandline interface](README_commandline.md) - [Customizations](README_customizations.md) - [Software Architecture](README_architecture.md) - [Code of Conduct](code-of-conduct.md)
Matrix room: **#epicyon:matrix.freedombone.net**
@ -82,7 +82,7 @@ Type=simple
User=epicyon
Group=epicyon
WorkingDirectory=/opt/epicyon
ExecStart=/usr/bin/python3 /opt/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open
ExecStart=/usr/bin/python3 /opt/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open --logLoginFailures
Environment=USER=epicyon
Environment=PYTHONUNBUFFERED=true
Restart=always
@ -183,8 +183,12 @@ server {
proxy_buffers 16 32k;
proxy_busy_buffers_size 64k;
proxy_redirect off;
proxy_request_buffering on;
proxy_buffering on;
proxy_request_buffering off;
proxy_buffering off;
location ~ ^/accounts/(avatars|headers)/(.*).(png|jpg|gif|webp|svg) {
expires 1d;
proxy_pass http://localhost:7156;
}
proxy_pass http://localhost:7156;
}
}
@ -208,6 +212,8 @@ And restart the web server:
systemctl restart nginx
```
If you need to use **fail2ban** then failed login attempts can be found in *accounts/loginfailures.log*.
If you are using the [Caddy web server](https://caddyserver.com) then see *caddy.example.conf*
## Running Static Analysis
@ -238,7 +244,7 @@ Please be aware that such installations will not federate with ordinary fedivers
## Custom Fonts
If you want to use a particular font then copy it into the *fonts* directory, rename it as *custom.ttf/woff/woff2/otf* and then restart the epicyon daemon.
If you want to use a particular font then copy it into the *fonts* directory, rename it as *custom.ttf/woff/woff2/otf* and then restart the Epicyon daemon.
``` bash
systemctl restart epicyon

View File

@ -0,0 +1,107 @@
# Epicyon Software Architecture
## Design Constraints
### Open Standards Compliance
Follow the standards for HTML, CSS and ActivityPub. Especially with ActivityPub there is always some room for interpretation, so if in doubt about a protocol implementation detail then do whatever Mastodon does to maintain maximum compatibility.
### Multi-User
It is assumed that an instance may have multiple users, although the maximum number of users is not expected to be very high. This system is for a "family and friends" or small club type of scenario.
Although it can be single user, this is not strictly a single user system.
### Opinionated
The design of this system is opinionated, and to a large extent informed by years of past experience in the fediverse. There is no claim to neutrality of any sort. Automatic removal of hellthreads and other common griefing tactics is an example of this.
### Resisting Centralization
Centralization is characterized by the typical fixation upon "scale" within the software industry. Systems which scale, in the way which is commonly understood, mean that a few individuals can control the social lives of many, and extract value from them in often cynical and manipulative ways.
In general, methods have been preferred which do not vertically scale. This includes the decision not to use a database, and the way that the inbox is processed. Lack of scalability also simplifies the design.
Being hostile towards the common notion of scaling means that this system will be of no interest to "big tech" and can't easily be used within extractive economic models without needing a substantial rewrite. This avoids the typical cooption strategies in which large companies eventually take over what was originally software developed by grassroots activists to address real community needs.
This system should however be able to scale rhizomatically with the deployment of many small instances federated together. Instead of scaling up, scale out. In a network of many small instances nobody has overall control and corporate capture is much more unlikely. Small instances also minimize the bureaucratic requirements for governance processes, which at medium to large scale eventually becomes tyrannical.
### Roles
The roles within an instance are comparable to the crew roles onboard a ship, with the admin being its captain. Delegation is minimal, with the admin assigning roles to particular user accounts. Avoiding delegation prevents a hierarchy of roles from forming. Social organization should be as horizontal as possible. Roles could be rotated - even including that of admin - although there is no technical mechanism requiring that.
### No Javascript
This is so that the system can be accessed and used normally with javascript in the web browser turned off. If you want to have good security then this is useful, since lack of javascript greatly reduces the attack surface and constrains adversaries to a limited number of vectors.
### Block Crawlers
Ordinarily web crawlers would not be a problem, but in the context of a social network even having crawlers index public posts can create ethical dilemmas in some circumstances. News instances may allow crawlers, but other types of instances should block them.
### No Local or Federated Timelines
The local and federated timelines of other ActivityPub servers don't add much value (especially the federated one), and tend to pollute the default timeline with irrelevant posts from people that you don't follow.
Especially on a small instance with a few users, the local timeline would not be significantly useful.
### Notification handling is out of scope
There are no notifications in the conventional sense. That is, there is no streaming API or linkage to browser notifications. Instead when significant events occur these create text files which can then be detected by other systems via polling.
See *scripts/epicyon-notifications* for an example of a script which could be run in a cron job to then send notifications via XMPP or Matrix.
### Assume Network Hostility
Many of the early web systems existed in a twee world in which it was assumed that everyone is nice, but in social networks this is rarely true.
It is usually safe to assume that the federated network beyond your instance is to a lesser or greater degree hostile. So there should be effective controls for blocking adversaries or spam floods.
### Limited Linked Data Support
Where Json linked data signatures are supported there should not be arbitrary schema lookups via the web. Instead, recognized contexts should be added to *context.py*. This is in order to follow the principle of *no processing without full recognition*, in which the recognition step is not endlessly extendable by untrusted parties.
## High Level Architecture
The main modules are *epicyon.py* and *daemon.py*. *epicyon.py* is the commandline interface and *daemon.py* is the http server.
![commandline and core modules](https://gitlab.com/bashrc2/epicyon/-/raw/main/architecture/epicyon_groups_Commandline-Interface_Core.png)
The daemon runs the inbox queue in a separate thread (see *inbox.py*) and the inbox queue processes incoming ActivityPub posts one at a time in a strictly serial fashion. Doing it this way means minimum potential for any parallelism/locking issues. It also means that the inbox queue is not highly scalable, but that's ok for a system which is only intended to have a few users per instance.
All ActivityPub posts are stored as text files, and there is no database as such other than the filesystem itself. Think of it as being like an email server. Each post is a json file stored in *accounts/nick@domain/inbox* or *accounts/nick@domain/outbox*. To avoid parsing problems slashes are replaced by hashes within the ActivityPub post filename. The filename for each post is the same as its ActivityPub id.
![timeline and core modules](https://gitlab.com/bashrc2/epicyon/-/raw/main/architecture/epicyon_groups_Timeline_Core.png)
## Security
### Themes
It is possible to include arbitrary CSS within a custom theme. To avoid security problems the CSS is sanitized before being used. Scripts or import references to other CSS files are not permitted.
The way that the theming system was designed is in order to avoid problems similar to Wordpress, in which an adversary will create an attactive looking theme which contains an exploit. The discovery of exploits then leads to a centralizing dynamic where there is a single "official" themes website or app store. With Epicyon, *themes should always be safe to use no matter where they were downloaded from*. There should be nothing *Turing complete* within a theme.
### C2S
This currently uses basic auth, which is simple to implement. Oauth2 is conventional, but seems overly complex and the user interface for it within other comparable apps is clunky.
### Interaction with Timeline
![timeline and security modules](https://gitlab.com/bashrc2/epicyon/-/raw/main/architecture/epicyon_groups_Timeline_Security.png)
The *inbox* queue makes calls to check http and linked data signatures. Various modules call *auth* typically because they're implementing the basic auth of the C2S interface.
## Accessibility
Trying to keep up with web accessibility standards. There should be configurable keyboard shortcuts for all of the main navigation actions. High contrast themes should be available. The desktop client should support text-to-speech. There should be the ability to run in a shell browser such as Lynx, without any significant loss of functionality.
Avoid adding any features which would be hard to make accessible.
![web interface and accessibility modules](https://gitlab.com/bashrc2/epicyon/-/raw/main/architecture/epicyon_groups_Web-Interface_Accessibility.png)
The *webapp_post* module generates html for each post from its ActivityPub json representation. This also calls the speaker module in order to create a text-to-speech friendly version of the post content, which can then be spoken by the desktop client. Doing this allows common acronyms and other special language to be properly pronounced.
The *daemon* module (http server) also calls *webapp_accesskeys* to display the key shortcuts screen.
![core and accessibility modules](https://gitlab.com/bashrc2/epicyon/-/raw/main/architecture/epicyon_groups_Core_Accessibility.png)

View File

@ -1,6 +1,6 @@
# Commandline Admin
# Command-line Admin
This system can be administrated from the commandline.
This system can be administrated from the command-line.
## Account Management
@ -10,6 +10,8 @@ The first thing you will need to do is to create an account. You can do this wit
python3 epicyon.py --addaccount nickname@domain --password [yourpassword]
```
You can also leave out the **--password** option and then enter it manually, which has the advantage of passwords not being logged within command history.
To remove an account (be careful!):
``` bash
@ -50,7 +52,7 @@ To remove an account (be careful!):
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.
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 accounts.
## Defining a perimeter
@ -74,7 +76,7 @@ The password is for the client to obtain access to the server.
You may or may not need to use the *--port*, *--https* and *--tor* options, depending upon how your server was set up.
Unfollowing is silimar:
Unfollowing is similar:
``` bash
python3 epicyon.py --nickname [yournick] --domain [name] --unfollow othernick@domain --password [c2s password]
@ -129,12 +131,22 @@ To view the public posts for a person:
python3 epicyon.py --posts nickname@domain
```
If you want to view the raw json:
If you want to view the raw JSON:
``` bash
python3 epicyon.py --postsraw nickname@domain
```
## Getting the JSON for your timelines
The **--posts** option applies for any ActivityPub compatible fediverse account with visible public posts. You can also use an authenticated version to obtain the paginated JSON for your inbox, outbox, direct messages, etc.
``` bash
python3 epicyon.py --nickname [yournick] --domain [yourdomain] --box [inbox|outbox|dm] --page [number] --password [yourpassword]
```
You could use this to make your own c2s client, or create your own notification system.
## Listing referenced domains
To list the domains referenced in public posts:
@ -154,7 +166,7 @@ xdot socnet.dot
## Delete posts
To delete a post which you wrote you must first know its url. It is usually something like:
To delete a post which you wrote you must first know its URL. It is usually something like:
``` text
https://yourDomain/users/yourNickname/statuses/number
@ -175,7 +187,7 @@ Another complication of federated deletion is that the followers collection may
## Announcements/repeats/boosts
To announce or repeat a post you will first need to know it's url. It is usually something like:
To announce or repeat a post you will first need to know it's URL. It is usually something like:
``` text
https://domain/users/name/statuses/number
@ -190,7 +202,7 @@ python3 epicyon.py --nickname [yournick] --domain [name] \
## Like posts
To like a post you will first need to know it's url. It is usually something like:
To like a post you will first need to know it's URL. It is usually something like:
``` text
https://domain/users/name/statuses/number
@ -238,7 +250,7 @@ Whether you are using the **--federate** option to define a set of allowed insta
python3 epicyon.py --nickname yournick --domain yourdomain --block somenick@somedomain --password [c2s password]
```
This blocks at the earliest possble stage of receiving messages, such that nothing from the specified account will be written to your inbox.
This blocks at the earliest possible stage of receiving messages, such that nothing from the specified account will be written to your inbox.
Or to unblock:
@ -246,6 +258,22 @@ Or to unblock:
python3 epicyon.py --nickname yournick --domain yourdomain --unblock somenick@somedomain --password [c2s password]
```
## Bookmarking
You may want to bookmark posts for later viewing or replying. This can be done via c2s with the following:
``` bash
python3 epicyon.py --nickname yournick --domain yourdomain --bookmark [post URL] --password [c2s password]
```
Note that the URL must be that of an ActivityPub post in your timeline. Any other URL will be ignored.
And to undo the bookmark:
``` bash
python3 epicyon.py --nickname yournick --domain yourdomain --unbookmark [post URL] --password [c2s password]
```
## Filtering on words or phrases
Blocking based upon the content of a message containing certain words or phrases is relatively crude and not always effective, but can help to reduce unwanted communications.
@ -282,52 +310,6 @@ python3 epicyon.py --domainmax 1000 --accountmax 200
With these settings you're going to be receiving no more than 200 messages for any given account within a day.
## Delegated roles
Within an organization you may want to define different roles and for some projects to be delegated. By default the first account added to the system will be the admin, and be assigned *moderator* and *delegator* roles under a project called *instance*. The admin can then delegate a person to other projects with:
``` bash
python3 epicyon.py --nickname [admin nickname] --domain [mydomain] \
--delegate [person nickname] \
--project [project name] --role [title] \
--password [c2s password]
```
The other person could also be made a delegator, but they will only be able to delegate further within projects which they're assigned to. By design, this creates a restricted organizational hierarchy. For example:
``` bash
python3 epicyon.py --nickname [admin nickname] --domain [mydomain] \
--delegate [person nickname] \
--project [project name] --role delegator \
--password [c2s password]
```
A delegated role can also be removed.
``` bash
python3 epicyon.py --nickname [admin nickname] --domain [mydomain] \
--undelegate [person nickname] \
--project [project name] \
--password [c2s password]
```
This extends the ActivityPub client-to-server protocol to include activities called *Delegate* and *Role*. The json looks like:
``` json
{ 'type': 'Delegate',
'actor': https://somedomain/users/admin,
'object': {
'type': 'Role',
'actor': https://'+somedomain+'/users/'+other,
'object': 'otherproject;otherrole',
'to': [],
'cc': []
},
'to': [],
'cc': []}
```
Projects and roles are only scoped within a single instance. There presently are not enough security mechanisms to support multi-instance distributed organizations.
## Assigning skills
@ -341,7 +323,7 @@ python3 epicyon.py --nickname [nick] --domain [mydomain] \
The level value is a percentage which indicates how proficient you are with that skill.
This extends the ActivityPub client-to-server protocol to include an activity called *Skill*. The json looks like:
This extends the ActivityPub client-to-server protocol to include an activity called *Skill*. The JSON looks like:
``` json
{ 'type': 'Skill',
@ -363,7 +345,7 @@ python3 epicyon.py --nickname [nick] --domain [mydomain] \
The status value can be any string, and can become part of organization building by combining it with roles and skills.
This extends the ActivityPub client-to-server protocol to include an activity called *Availability*. "Status" was avoided because of te possibility of confusion with other things. The json looks like:
This extends the ActivityPub client-to-server protocol to include an activity called *Availability*. "Status" was avoided because of the possibility of confusion with other things. The JSON looks like:
``` json
{ 'type': 'Availability',
@ -375,7 +357,7 @@ This extends the ActivityPub client-to-server protocol to include an activity ca
## Shares
This system includes a feature for bartering or gifting (i.e. common resource pooling or exchange without money), based upon the earlier Sharings plugin made by the Las Indias group which existed within GNU Social. It's intended to operate at the municipal level, sharing physical objects with people in your local vicinity. For example, sharing gardening tools on a street or a 3D printer between makerspaces.
This system includes a feature for bartering or gifting (i.e. common resource pooling or exchange without money), based upon the earlier Sharings plugin made by the Las Indias group which existed within GNU Social. It's intended to operate at the municipal level, sharing physical objects with people in your local vicinity. For example, sharing gardening tools on a street or a 3D printer between maker-spaces.
To share an item.
@ -383,7 +365,7 @@ To share an item.
python3 epicyon.py --itemName "spanner" --nickname [yournick] --domain [yourdomain] --summary "It's a spanner" --itemType "tool" --itemCategory "mechanical" --location [yourCity] --duration "2 months" --itemImage spanner.png --password [c2s password]
```
For the duration of the share you can use hours,days,weeks,months or years.
For the duration of the share you can use hours, days, weeks, months, or years.
To remove a shared item:

View File

@ -28,4 +28,4 @@ Extra emoji can be added to the *emoji* directory and you should then update the
## Themes
If you want to create a new theme then the functions for that are within *theme.py*. These functions take the css templates and modify them. You will need to edit *themesDropdown* within *webinterface.py* and add the appropriate translations for the theme name. Themes are selectable from the profile screen of the administrator.
If you want to create a new theme then the functions for that are within *theme.py*. These functions take the CSS templates and modify them. You will need to edit *themesDropdown* within *webinterface.py* and add the appropriate translations for the theme name. Themes are selectable from the profile screen of the administrator.

View File

@ -0,0 +1,91 @@
# Desktop client
## Installing and running
You can install the desktop client with:
``` bash
./install-desktop-client
```
and run it with:
``` bash
~/epicyon-client
```
To run it with text-to-speech via espeak:
``` bash
~/epicyon-client-tts
```
Or if you have picospeaker installed:
``` bash
~/epicyon-client-pico
```
## Commands
The desktop client has a few commands, which may be more convenient than the web interface for some purposes:
``` bash
quit Exit from the desktop client
mute Turn off the screen reader
speak Turn on the screen reader
sounds on Turn on notification sounds
sounds off Turn off notification sounds
rp Repeat the last post
like Like the last post
unlike Unlike the last post
bookmark Bookmark the last post
unbookmark Unbookmark the last post
block [post number|handle] Block someone via post number or handle
unblock [handle] Unblock someone
mute Mute the last post
unmute Unmute the last post
reply Reply to the last post
post Create a new post
post to [handle] Create a new direct message
announce/boost Boost the last post
follow [handle] Make a follow request
unfollow [handle] Stop following the give handle
show dm|sent|inbox|replies|bookmarks Show a timeline
next Next page in the timeline
prev Previous page in the timeline
read [post number] Read a post from a timeline
open [post number] Open web links within a timeline post
profile [post number or handle] Show profile for the person who made the given post
following [page number] Show accounts that you are following
followers [page number] Show accounts that are following you
approve [handle] Approve a follow request
deny [handle] Deny a follow request
pgp Show your PGP public key
```
If you have a GPG key configured on your local system and are sending a direct message to someone who has a PGP key (the exported key, not just the key ID) set as a tag on their profile then it will try to encrypt the message automatically. So under some conditions end-to-end encryption is possible, such that the instance server only sees ciphertext. Conversely, for arriving direct messages if they are PGP encrypted then the desktop client will try to obtain the relevant public key and decrypt.
## Speaking your inbox
It is possible to use text-to-speech to read your inbox as posts arrive. This can be useful if you are not looking at a screen but want to stay ambiently informed of what's happening.
On Debian based systems you will need to have the **python3-espeak** package installed.
``` bash
python3 epicyon.py --notifyShowNewPosts --screenreader espeak --desktop yournickname@yourdomain
```
Or a quicker version, if you have installed the desktop client as described above.
``` bash
~/epicyon-client-stream
```
Or if you have [picospeaker](https://gitlab.com/ky1e/picospeaker) installed:
``` bash
python3 epicyon.py --notifyShowNewPosts --screenreader picospeaker --desktop yournickname@yourdomain
```
You can also use the **--password** option to provide the password. This will then stay running and incoming posts will be announced as they arrive.

View File

@ -10,22 +10,22 @@
* Attention to accessibility and should be usable in lynx with a screen reader
* Remove metadata from attached images, avatars and backgrounds
* Support for multiple themes, with ability to create custom themes
* Being able to build crowdsouced organizations with roles and skills
* Being able to build crowd-sourced organizations with roles and skills
* Sharings collection, similar to the gnusocial sharings plugin
* Quotas for received posts per day, per domain and per account
* Hellthread detection and removal
* Hell-thread detection and removal
* Instance and account level federation lists
* Support content warnings, reporting and blocking
* http signatures and basic auth
* json-LD signatures on outgoing posts, optional on incoming
* Compatible with http (onion addresses, i2p), https and hypercore
* JSON-LD signatures on outgoing posts, optional on incoming
* Compatible with HTTP (onion addresses, i2p), HTTPS and hypercore
* Minimal dependencies
* Dependencies are maintained Debian packages
* Data minimization principle. Configurable post expiry time
* Likes and repeats only visible to authorized viewers
* ReplyGuy mitigation - maxmimum replies per post or posts per day
* Reply Guy mitigation - maximum replies per post or posts per day
* Ability to delete or hide specific conversation threads
* Commandline interface
* Command-line interface
* Simple web interface
* Designed for intermittent connectivity. Assume network disruptions
* Limited visibility of follows/followers
@ -36,17 +36,17 @@
**Features which won't be implemented**
The following are considered antifeatures of other social network systems, since they encourage dysfunctional social interactions.
The following are considered anti-features of other social network systems, since they encourage dysfunctional social interactions.
* Features designed to scale to large numbers of accounts (say, more than 20 active users)
* Trending hashtags, or trending anything
* Ranking, rating or recommending mechanisms for posts or people (other than likes or repeats/boosts)
* Geolocation features
* Geo-location features
* Algorithmic timelines (i.e. non-chronological)
* Direct payment mechanisms, although integration with other services may be possible
* Any variety of blockchain
* Sponsored posts
* Enterprise features for use cases applicable only to businesses. Epicyon could be used in a small business, but it's not primarily designed for that
* Collaborative editing of posts, although you could do that outside of this system using etherpad, or similar
* Collaborative editing of posts, although you could do that outside of this system using Etherpad, or similar
* Anonymous posts from random internet users published under a single generic instance account
* Hierarchies of roles beyond ordinary moderation, such as X requires special agreement from Y before sending a post

View File

@ -1,22 +1,24 @@
# Roadman
# Roadmap
## UX
* Change animation on buttons (themeable?)
* Minimize button shows different icons or highlighting
* Layout of buttons on person options screen
## Teams
## Groups
* Test groups
* Unit test for group creation
* Groups can be defined as having particular roles/skills
* Templates for different group organizations
## Events
## Questions
* Events timeline
* Events appear on calendar
* Check compatibility with Mobilizon
* Still not implemented ideally
* Instance-only questions
* Active polls screen?
* Questions more integrated into overall organization
## Code
* Modularize daemon
* Move modules out of the daemon
* Make comment notes linking daemon functions to webinterface
* More unit test coverage
* Break up large functions into smaller ones
* Architecture diagrams
* Code documentation?

View File

@ -5,6 +5,7 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "ActivityPub"
import os
from utils import hasUsersPath
@ -14,6 +15,8 @@ from utils import getDomainFromActor
from utils import getNicknameFromActor
from utils import domainPermitted
from utils import followPerson
from utils import hasObjectDict
from utils import acctDir
def _createAcceptReject(baseDir: str, federationList: [],
@ -37,7 +40,7 @@ def _createAcceptReject(baseDir: str, federationList: [],
newAccept = {
"@context": "https://www.w3.org/ns/activitystreams",
'type': acceptType,
'actor': httpPrefix+'://' + domain + '/users/' + nickname,
'actor': httpPrefix + '://' + domain + '/users/' + nickname,
'to': [toUrl],
'cc': [],
'object': objectJson
@ -72,7 +75,7 @@ def _acceptFollow(baseDir: str, domain: str, messageJson: {},
federationList: [], debug: bool) -> None:
"""Receiving a follow Accept activity
"""
if not messageJson.get('object'):
if not hasObjectDict(messageJson):
return
if not messageJson['object'].get('type'):
return
@ -119,9 +122,9 @@ def _acceptFollow(baseDir: str, domain: str, messageJson: {},
print('DEBUG: unrecognized actor ' + thisActor)
return
else:
if not '/' + acceptedDomain+'/users/' + nickname in thisActor:
if not '/' + acceptedDomain + '/users/' + nickname in thisActor:
if debug:
print('Expected: /' + acceptedDomain+'/users/' + nickname)
print('Expected: /' + acceptedDomain + '/users/' + nickname)
print('Actual: ' + thisActor)
print('DEBUG: unrecognized actor ' + thisActor)
return
@ -133,7 +136,7 @@ def _acceptFollow(baseDir: str, domain: str, messageJson: {},
return
followedDomainFull = followedDomain
if port:
followedDomainFull = followedDomain+':' + str(port)
followedDomainFull = followedDomain + ':' + str(port)
followedNickname = getNicknameFromActor(followedActor)
if not followedNickname:
print('DEBUG: no nickname found within Follow activity object ' +
@ -145,8 +148,8 @@ def _acceptFollow(baseDir: str, domain: str, messageJson: {},
acceptedDomainFull = acceptedDomain + ':' + str(acceptedPort)
# has this person already been unfollowed?
unfollowedFilename = baseDir + '/accounts/' + \
nickname + '@' + acceptedDomainFull + '/unfollowed.txt'
unfollowedFilename = \
acctDir(baseDir, nickname, acceptedDomainFull) + '/unfollowed.txt'
if os.path.isfile(unfollowedFilename):
if followedNickname + '@' + followedDomainFull in \
open(unfollowedFilename).read():
@ -167,7 +170,7 @@ def _acceptFollow(baseDir: str, domain: str, messageJson: {},
else:
if debug:
print('DEBUG: Unable to create follow - ' +
nickname + '@' + acceptedDomain+' -> ' +
nickname + '@' + acceptedDomain + ' -> ' +
followedNickname + '@' + followedDomain)

View File

@ -5,7 +5,11 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "ActivityPub"
from utils import removeDomainPort
from utils import hasObjectDict
from utils import removeIdEnding
from utils import hasUsersPath
from utils import getFullDomain
from utils import getStatusNumber
@ -24,6 +28,24 @@ from webfinger import webfingerHandle
from auth import createBasicAuthHeader
def isSelfAnnounce(postJsonObject: {}) -> bool:
"""Is the given post a self announce?
"""
if not postJsonObject.get('actor'):
return False
if not postJsonObject.get('type'):
return False
if postJsonObject['type'] != 'Announce':
return False
if not postJsonObject.get('object'):
return False
if not isinstance(postJsonObject['actor'], str):
return False
if not isinstance(postJsonObject['object'], str):
return False
return postJsonObject['actor'] in postJsonObject['object']
def outboxAnnounce(recentPostsCache: {},
baseDir: str, messageJson: {}, debug: bool) -> bool:
""" Adds or removes announce entries from the shares collection
@ -31,6 +53,8 @@ def outboxAnnounce(recentPostsCache: {},
"""
if not messageJson.get('actor'):
return False
if not isinstance(messageJson['actor'], str):
return False
if not messageJson.get('type'):
return False
if not messageJson.get('object'):
@ -38,19 +62,22 @@ def outboxAnnounce(recentPostsCache: {},
if messageJson['type'] == 'Announce':
if not isinstance(messageJson['object'], str):
return False
if isSelfAnnounce(messageJson):
return False
nickname = getNicknameFromActor(messageJson['actor'])
if not nickname:
print('WARN: no nickname found in '+messageJson['actor'])
print('WARN: no nickname found in ' + messageJson['actor'])
return False
domain, port = getDomainFromActor(messageJson['actor'])
postFilename = locatePost(baseDir, nickname, domain,
messageJson['object'])
if postFilename:
updateAnnounceCollection(recentPostsCache, baseDir, postFilename,
messageJson['actor'], domain, debug)
messageJson['actor'],
nickname, domain, debug)
return True
if messageJson['type'] == 'Undo':
if not isinstance(messageJson['object'], dict):
elif messageJson['type'] == 'Undo':
if not hasObjectDict(messageJson):
return False
if not messageJson['object'].get('type'):
return False
@ -73,26 +100,15 @@ def outboxAnnounce(recentPostsCache: {},
return False
def announcedByPerson(postJsonObject: {}, nickname: str, domain: str) -> bool:
def announcedByPerson(isAnnounced: bool, postActor: str,
nickname: str, domainFull: str) -> bool:
"""Returns True if the given post is announced by the given person
"""
if not postJsonObject.get('object'):
if not postActor:
return False
if not isinstance(postJsonObject['object'], dict):
return False
# not to be confused with shared items
if not postJsonObject['object'].get('shares'):
return False
if not isinstance(postJsonObject['object']['shares'], dict):
return False
if not postJsonObject['object']['shares'].get('items'):
return False
if not isinstance(postJsonObject['object']['shares']['items'], list):
return False
actorMatch = domain + '/users/' + nickname
for item in postJsonObject['object']['shares']['items']:
if item['actor'].endswith(actorMatch):
return True
if isAnnounced and \
postActor.endswith(domainFull + '/users/' + nickname):
return True
return False
@ -113,8 +129,7 @@ def createAnnounce(session, baseDir: str, federationList: [],
if not urlPermitted(objectUrl, federationList):
return None
if ':' in domain:
domain = domain.split(':')[0]
domain = removeDomainPort(domain)
fullDomain = getFullDomain(domain, port)
statusNumber, published = getStatusNumber()
@ -124,7 +139,7 @@ def createAnnounce(session, baseDir: str, federationList: [],
'/statuses/' + statusNumber
newAnnounce = {
"@context": "https://www.w3.org/ns/activitystreams",
'actor': httpPrefix+'://'+fullDomain+'/users/'+nickname,
'actor': httpPrefix + '://' + fullDomain + '/users/' + nickname,
'atomUri': atomUriStr,
'cc': [],
'id': newAnnounceId + '/activity',
@ -202,9 +217,10 @@ def sendAnnounceViaServer(baseDir: str, session,
statusNumber, published = getStatusNumber()
newAnnounceId = httpPrefix + '://' + fromDomainFull + '/users/' + \
fromNickname + '/statuses/' + statusNumber
actorStr = httpPrefix + '://' + fromDomainFull + '/users/' + fromNickname
newAnnounceJson = {
"@context": "https://www.w3.org/ns/activitystreams",
'actor': httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname,
'actor': actorStr,
'atomUri': newAnnounceId,
'cc': [ccUrl],
'id': newAnnounceId + '/activity',
@ -219,14 +235,14 @@ def sendAnnounceViaServer(baseDir: str, session,
# lookup the inbox for the To handle
wfRequest = webfingerHandle(session, handle, httpPrefix,
cachedWebfingers,
fromDomain, projectVersion)
fromDomain, projectVersion, debug)
if not wfRequest:
if debug:
print('DEBUG: announce webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: Webfinger for ' + handle + ' did not return a dict. ' +
str(wfRequest))
print('WARN: announce webfinger for ' + handle +
' did not return a dict. ' + str(wfRequest))
return 1
postToBox = 'outbox'
@ -242,11 +258,12 @@ def sendAnnounceViaServer(baseDir: str, session,
if not inboxUrl:
if debug:
print('DEBUG: No ' + postToBox + ' was found for ' + handle)
print('DEBUG: announce no ' + postToBox +
' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: No actor was found for ' + handle)
print('DEBUG: announce no actor was found for ' + handle)
return 4
authHeader = createBasicAuthHeader(fromNickname, password)
@ -256,11 +273,140 @@ def sendAnnounceViaServer(baseDir: str, session,
'Content-type': 'application/json',
'Authorization': authHeader
}
postResult = postJson(session, newAnnounceJson, [], inboxUrl, headers)
postResult = postJson(httpPrefix, fromDomainFull,
session, newAnnounceJson, [], inboxUrl,
headers, 3, True)
if not postResult:
print('WARN: Announce not posted')
print('WARN: announce not posted')
if debug:
print('DEBUG: c2s POST announce success')
return newAnnounceJson
def sendUndoAnnounceViaServer(baseDir: str, session,
undoPostJsonObject: {},
nickname: str, password: str,
domain: str, port: int,
httpPrefix: str, repeatObjectUrl: str,
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str) -> {}:
"""Undo an announce message via c2s
"""
if not session:
print('WARN: No session for sendUndoAnnounceViaServer')
return 6
domainFull = getFullDomain(domain, port)
actor = httpPrefix + '://' + domainFull + '/users/' + nickname
handle = actor.replace('/users/', '/@')
statusNumber, published = getStatusNumber()
unAnnounceJson = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': actor + '/statuses/' + str(statusNumber) + '/undo',
'type': 'Undo',
'actor': actor,
'object': undoPostJsonObject['object']
}
# lookup the inbox for the To handle
wfRequest = webfingerHandle(session, handle, httpPrefix,
cachedWebfingers,
domain, projectVersion, debug)
if not wfRequest:
if debug:
print('DEBUG: undo announce webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: undo announce webfinger for ' + handle +
' did not return a dict. ' + str(wfRequest))
return 1
postToBox = 'outbox'
# get the actor inbox for the To handle
(inboxUrl, pubKeyId, pubKey, fromPersonId,
sharedInbox, avatarUrl,
displayName) = getPersonBox(baseDir, session, wfRequest,
personCache,
projectVersion, httpPrefix,
nickname, domain,
postToBox, 73528)
if not inboxUrl:
if debug:
print('DEBUG: undo announce no ' + postToBox +
' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: undo announce no actor was found for ' + handle)
return 4
authHeader = createBasicAuthHeader(nickname, password)
headers = {
'host': domain,
'Content-type': 'application/json',
'Authorization': authHeader
}
postResult = postJson(httpPrefix, domainFull,
session, unAnnounceJson, [], inboxUrl,
headers, 3, True)
if not postResult:
print('WARN: undo announce not posted')
if debug:
print('DEBUG: c2s POST undo announce success')
return unAnnounceJson
def outboxUndoAnnounce(recentPostsCache: {},
baseDir: str, httpPrefix: str,
nickname: str, domain: str, port: int,
messageJson: {}, debug: bool) -> None:
""" When an undo announce is received by the outbox from c2s
"""
if not messageJson.get('type'):
return
if not messageJson['type'] == 'Undo':
return
if not hasObjectDict(messageJson):
if debug:
print('DEBUG: undo like object is not dict')
return
if not messageJson['object'].get('type'):
if debug:
print('DEBUG: undo like - no type')
return
if not messageJson['object']['type'] == 'Announce':
if debug:
print('DEBUG: not a undo announce')
return
if not messageJson['object'].get('object'):
if debug:
print('DEBUG: no object in undo announce')
return
if not isinstance(messageJson['object']['object'], str):
if debug:
print('DEBUG: undo announce object is not string')
return
if debug:
print('DEBUG: c2s undo announce request arrived in outbox')
messageId = removeIdEnding(messageJson['object']['object'])
domain = removeDomainPort(domain)
postFilename = locatePost(baseDir, nickname, domain, messageId)
if not postFilename:
if debug:
print('DEBUG: c2s undo announce post not found in inbox or outbox')
print(messageId)
return True
undoAnnounceCollectionEntry(recentPostsCache, baseDir, postFilename,
messageJson['actor'], domain, debug)
if debug:
print('DEBUG: post undo announce via c2s - ' + postFilename)

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

63
auth.py
View File

@ -5,12 +5,14 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "Security"
import base64
import hashlib
import binascii
import os
import secrets
import datetime
from utils import isSystemAccount
from utils import hasUsersPath
@ -124,15 +126,15 @@ def authorizeBasic(baseDir: str, path: str, authHeader: str,
') does not match the one in the Authorization header (' +
nickname + ')')
return False
passwordFile = baseDir+'/accounts/passwords'
passwordFile = baseDir + '/accounts/passwords'
if not os.path.isfile(passwordFile):
if debug:
print('DEBUG: passwords file missing')
return False
providedPassword = plain.split(':')[1]
passfile = open(passwordFile, "r")
passfile = open(passwordFile, 'r')
for line in passfile:
if line.startswith(nickname+':'):
if line.startswith(nickname + ':'):
storedPassword = \
line.split(':')[1].replace('\n', '').replace('\r', '')
success = _verifyPassword(storedPassword, providedPassword)
@ -160,7 +162,7 @@ def storeBasicCredentials(baseDir: str, nickname: str, password: str) -> bool:
storeStr = nickname + ':' + _hashPassword(password)
if os.path.isfile(passwordFile):
if nickname + ':' in open(passwordFile).read():
with open(passwordFile, "r") as fin:
with open(passwordFile, 'r') as fin:
with open(passwordFile + '.new', 'w+') as fout:
for line in fin:
if not line.startswith(nickname + ':'):
@ -184,7 +186,7 @@ def removePassword(baseDir: str, nickname: str) -> None:
"""
passwordFile = baseDir + '/accounts/passwords'
if os.path.isfile(passwordFile):
with open(passwordFile, "r") as fin:
with open(passwordFile, 'r') as fin:
with open(passwordFile + '.new', 'w+') as fout:
for line in fin:
if not line.startswith(nickname + ':'):
@ -200,7 +202,56 @@ def authorize(baseDir: str, path: str, authHeader: str, debug: bool) -> bool:
return False
def createPassword(length=10):
def createPassword(length: int = 10):
validChars = 'abcdefghijklmnopqrstuvwxyz' + \
'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
return ''.join((secrets.choice(validChars) for i in range(length)))
def recordLoginFailure(baseDir: str, ipAddress: str,
countDict: {}, failTime: int,
logToFile: bool) -> None:
"""Keeps ip addresses and the number of times login failures
occured for them in a dict
"""
if not countDict.get(ipAddress):
while len(countDict.items()) > 100:
oldestTime = 0
oldestIP = None
for ipAddr, ipItem in countDict.items():
if oldestTime == 0 or ipItem['time'] < oldestTime:
oldestTime = ipItem['time']
oldestIP = ipAddr
if oldestIP:
del countDict[oldestIP]
countDict[ipAddress] = {
"count": 1,
"time": failTime
}
else:
countDict[ipAddress]['count'] += 1
countDict[ipAddress]['time'] = failTime
failCount = countDict[ipAddress]['count']
if failCount > 4:
print('WARN: ' + str(ipAddress) + ' failed to log in ' +
str(failCount) + ' times')
if not logToFile:
return
failureLog = baseDir + '/accounts/loginfailures.log'
writeType = 'a+'
if not os.path.isfile(failureLog):
writeType = 'w+'
currTime = datetime.datetime.utcnow()
try:
with open(failureLog, writeType) as fp:
# here we use a similar format to an ssh log, so that
# systems such as fail2ban can parse it
fp.write(currTime.strftime("%Y-%m-%d %H:%M:%SZ") + ' ' +
'ip-127-0-0-1 sshd[20710]: ' +
'Disconnecting invalid user epicyon ' +
ipAddress + ' port 443: ' +
'Too many authentication failures [preauth]\n')
except BaseException:
pass

View File

@ -5,6 +5,7 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "Profile Metadata"
import os
from webfinger import webfingerHandle
@ -16,6 +17,7 @@ from utils import getNicknameFromActor
from utils import getDomainFromActor
from utils import loadJson
from utils import saveJson
from utils import acctDir
def setAvailability(baseDir: str, nickname: str, domain: str,
@ -25,7 +27,7 @@ def setAvailability(baseDir: str, nickname: str, domain: str,
# avoid giant strings
if len(status) > 128:
return False
actorFilename = baseDir + '/accounts/' + nickname + '@' + domain + '.json'
actorFilename = acctDir(baseDir, nickname, domain) + '.json'
if not os.path.isfile(actorFilename):
return False
actorJson = loadJson(actorFilename)
@ -38,7 +40,7 @@ def setAvailability(baseDir: str, nickname: str, domain: str,
def getAvailability(baseDir: str, nickname: str, domain: str) -> str:
"""Returns the availability for a given person
"""
actorFilename = baseDir + '/accounts/' + nickname + '@' + domain + '.json'
actorFilename = acctDir(baseDir, nickname, domain) + '.json'
if not os.path.isfile(actorFilename):
return False
actorJson = loadJson(actorFilename)
@ -94,8 +96,8 @@ def sendAvailabilityViaServer(baseDir: str, session,
newAvailabilityJson = {
'type': 'Availability',
'actor': httpPrefix+'://'+domainFull+'/users/'+nickname,
'object': '"'+status+'"',
'actor': httpPrefix + '://' + domainFull + '/users/' + nickname,
'object': '"' + status + '"',
'to': [toUrl],
'cc': [ccUrl]
}
@ -105,14 +107,14 @@ def sendAvailabilityViaServer(baseDir: str, session,
# lookup the inbox for the To handle
wfRequest = webfingerHandle(session, handle, httpPrefix,
cachedWebfingers,
domain, projectVersion)
domain, projectVersion, debug)
if not wfRequest:
if debug:
print('DEBUG: announce webfinger failed for ' + handle)
print('DEBUG: availability webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: Webfinger for ' + handle + ' did not return a dict. ' +
str(wfRequest))
print('WARN: availability webfinger for ' + handle +
' did not return a dict. ' + str(wfRequest))
return 1
postToBox = 'outbox'
@ -127,11 +129,12 @@ def sendAvailabilityViaServer(baseDir: str, session,
if not inboxUrl:
if debug:
print('DEBUG: No ' + postToBox + ' was found for ' + handle)
print('DEBUG: availability no ' + postToBox +
' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: No actor was found for ' + handle)
print('DEBUG: availability no actor was found for ' + handle)
return 4
authHeader = createBasicAuthHeader(nickname, password)
@ -141,10 +144,11 @@ def sendAvailabilityViaServer(baseDir: str, session,
'Content-type': 'application/json',
'Authorization': authHeader
}
postResult = postJson(session, newAvailabilityJson, [],
inboxUrl, headers)
postResult = postJson(httpPrefix, domainFull,
session, newAvailabilityJson, [],
inboxUrl, headers, 30, True)
if not postResult:
print('WARN: failed to post availability')
print('WARN: availability failed to post')
if debug:
print('DEBUG: c2s POST availability success')

View File

@ -5,9 +5,18 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "Core"
import os
import json
import time
from datetime import datetime
from utils import removeDomainPort
from utils import hasObjectDict
from utils import isAccountDir
from utils import getCachedPostFilename
from utils import loadJson
from utils import saveJson
from utils import fileLastModified
from utils import setConfigParam
from utils import hasUsersPath
@ -18,6 +27,7 @@ from utils import locatePost
from utils import evilIncarnate
from utils import getDomainFromActor
from utils import getNicknameFromActor
from utils import acctDir
def addGlobalBlock(baseDir: str,
@ -32,10 +42,8 @@ def addGlobalBlock(baseDir: str,
if blockHandle in open(blockingFilename).read():
return False
# block an account handle or domain
blockFile = open(blockingFilename, "a+")
if blockFile:
with open(blockingFilename, 'a+') as blockFile:
blockFile.write(blockHandle + '\n')
blockFile.close()
else:
blockHashtag = blockNickname
# is the hashtag already blocked?
@ -43,10 +51,8 @@ def addGlobalBlock(baseDir: str,
if blockHashtag + '\n' in open(blockingFilename).read():
return False
# block a hashtag
blockFile = open(blockingFilename, "a+")
if blockFile:
with open(blockingFilename, 'a+') as blockFile:
blockFile.write(blockHashtag + '\n')
blockFile.close()
return True
@ -54,17 +60,14 @@ def addBlock(baseDir: str, nickname: str, domain: str,
blockNickname: str, blockDomain: str) -> bool:
"""Block the given account
"""
if ':' in domain:
domain = domain.split(':')[0]
blockingFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/blocking.txt'
domain = removeDomainPort(domain)
blockingFilename = acctDir(baseDir, nickname, domain) + '/blocking.txt'
blockHandle = blockNickname + '@' + blockDomain
if os.path.isfile(blockingFilename):
if blockHandle in open(blockingFilename).read():
return False
blockFile = open(blockingFilename, "a+")
blockFile.write(blockHandle + '\n')
blockFile.close()
with open(blockingFilename, 'a+') as blockFile:
blockFile.write(blockHandle + '\n')
return True
@ -108,10 +111,8 @@ def removeBlock(baseDir: str, nickname: str, domain: str,
unblockNickname: str, unblockDomain: str) -> bool:
"""Unblock the given account
"""
if ':' in domain:
domain = domain.split(':')[0]
unblockingFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/blocking.txt'
domain = removeDomainPort(domain)
unblockingFilename = acctDir(baseDir, nickname, domain) + '/blocking.txt'
unblockHandle = unblockNickname + '@' + unblockDomain
if os.path.isfile(unblockingFilename):
if unblockHandle in open(unblockingFilename).read():
@ -161,7 +162,47 @@ def getDomainBlocklist(baseDir: str) -> str:
return blockedStr
def isBlockedDomain(baseDir: str, domain: str) -> bool:
def updateBlockedCache(baseDir: str,
blockedCache: [],
blockedCacheLastUpdated: int,
blockedCacheUpdateSecs: int) -> int:
"""Updates the cache of globally blocked domains held in memory
"""
currTime = int(time.time())
if blockedCacheLastUpdated > currTime:
print('WARN: Cache updated in the future')
blockedCacheLastUpdated = 0
secondsSinceLastUpdate = currTime - blockedCacheLastUpdated
if secondsSinceLastUpdate < blockedCacheUpdateSecs:
return blockedCacheLastUpdated
globalBlockingFilename = baseDir + '/accounts/blocking.txt'
if not os.path.isfile(globalBlockingFilename):
return blockedCacheLastUpdated
with open(globalBlockingFilename, 'r') as fpBlocked:
blockedLines = fpBlocked.readlines()
# remove newlines
for index in range(len(blockedLines)):
blockedLines[index] = blockedLines[index].replace('\n', '')
# update the cache
blockedCache.clear()
blockedCache += blockedLines
return currTime
def _getShortDomain(domain: str) -> str:
""" by checking a shorter version we can thwart adversaries
who constantly change their subdomain
e.g. subdomain123.mydomain.com becomes mydomain.com
"""
sections = domain.split('.')
noOfSections = len(sections)
if noOfSections > 2:
return sections[noOfSections-2] + '.' + sections[-1]
return None
def isBlockedDomain(baseDir: str, domain: str,
blockedCache: [] = None) -> bool:
"""Is the given domain blocked?
"""
if '.' not in domain:
@ -170,27 +211,29 @@ def isBlockedDomain(baseDir: str, domain: str) -> bool:
if isEvil(domain):
return True
# by checking a shorter version we can thwart adversaries
# who constantly change their subdomain
sections = domain.split('.')
noOfSections = len(sections)
shortDomain = None
if noOfSections > 2:
shortDomain = domain[noOfSections-2] + '.' + domain[noOfSections-1]
shortDomain = _getShortDomain(domain)
allowFilename = baseDir + '/accounts/allowedinstances.txt'
if not os.path.isfile(allowFilename):
# instance block list
globalBlockingFilename = baseDir + '/accounts/blocking.txt'
if os.path.isfile(globalBlockingFilename):
with open(globalBlockingFilename, 'r') as fpBlocked:
blockedStr = fpBlocked.read()
if not brochModeIsActive(baseDir):
if blockedCache:
for blockedStr in blockedCache:
if '*@' + domain in blockedStr:
return True
if shortDomain:
if '*@' + shortDomain in blockedStr:
return True
else:
# instance block list
globalBlockingFilename = baseDir + '/accounts/blocking.txt'
if os.path.isfile(globalBlockingFilename):
with open(globalBlockingFilename, 'r') as fpBlocked:
blockedStr = fpBlocked.read()
if '*@' + domain in blockedStr:
return True
if shortDomain:
if '*@' + shortDomain in blockedStr:
return True
else:
allowFilename = baseDir + '/accounts/allowedinstances.txt'
# instance allow list
if not shortDomain:
if domain not in open(allowFilename).read():
@ -203,31 +246,58 @@ def isBlockedDomain(baseDir: str, domain: str) -> bool:
def isBlocked(baseDir: str, nickname: str, domain: str,
blockNickname: str, blockDomain: str) -> bool:
blockNickname: str, blockDomain: str,
blockedCache: [] = None) -> bool:
"""Is the given nickname blocked?
"""
if isEvil(blockDomain):
return True
globalBlockingFilename = baseDir + '/accounts/blocking.txt'
if os.path.isfile(globalBlockingFilename):
if '*@' + blockDomain in open(globalBlockingFilename).read():
return True
if blockNickname:
blockHandle = blockNickname + '@' + blockDomain
if blockHandle in open(globalBlockingFilename).read():
blockHandle = None
if blockNickname and blockDomain:
blockHandle = blockNickname + '@' + blockDomain
if not brochModeIsActive(baseDir):
# instance level block list
if blockedCache:
for blockedStr in blockedCache:
if '*@' + domain in blockedStr:
return True
if blockHandle:
if blockHandle in blockedStr:
return True
else:
globalBlockingFilename = baseDir + '/accounts/blocking.txt'
if os.path.isfile(globalBlockingFilename):
if '*@' + blockDomain in open(globalBlockingFilename).read():
return True
if blockHandle:
if blockHandle in open(globalBlockingFilename).read():
return True
else:
# instance allow list
allowFilename = baseDir + '/accounts/allowedinstances.txt'
shortDomain = _getShortDomain(blockDomain)
if not shortDomain:
if blockDomain not in open(allowFilename).read():
return True
allowFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/allowedinstances.txt'
else:
if shortDomain not in open(allowFilename).read():
return True
# account level allow list
accountDir = acctDir(baseDir, nickname, domain)
allowFilename = accountDir + '/allowedinstances.txt'
if os.path.isfile(allowFilename):
if blockDomain not in open(allowFilename).read():
return True
blockingFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/blocking.txt'
# account level block list
blockingFilename = accountDir + '/blocking.txt'
if os.path.isfile(blockingFilename):
if '*@' + blockDomain in open(blockingFilename).read():
return True
if blockNickname:
blockHandle = blockNickname + '@' + blockDomain
if blockHandle:
if blockHandle in open(blockingFilename).read():
return True
return False
@ -266,8 +336,7 @@ def outboxBlock(baseDir: str, httpPrefix: str,
if debug:
print('DEBUG: c2s block object has no nickname')
return
if ':' in domain:
domain = domain.split(':')[0]
domain = removeDomainPort(domain)
postFilename = locatePost(baseDir, nickname, domain, messageId)
if not postFilename:
if debug:
@ -301,11 +370,7 @@ def outboxUndoBlock(baseDir: str, httpPrefix: str,
if debug:
print('DEBUG: not an undo block')
return
if not messageJson.get('object'):
if debug:
print('DEBUG: no object in undo block')
return
if not isinstance(messageJson['object'], dict):
if not hasObjectDict(messageJson):
if debug:
print('DEBUG: undo block object is not string')
return
@ -338,8 +403,7 @@ def outboxUndoBlock(baseDir: str, httpPrefix: str,
if debug:
print('DEBUG: c2s undo block object has no nickname')
return
if ':' in domain:
domain = domain.split(':')[0]
domain = removeDomainPort(domain)
postFilename = locatePost(baseDir, nickname, domain, messageId)
if not postFilename:
if debug:
@ -361,6 +425,267 @@ def outboxUndoBlock(baseDir: str, httpPrefix: str,
print('DEBUG: post undo blocked via c2s - ' + postFilename)
def mutePost(baseDir: str, nickname: str, domain: str, port: int,
httpPrefix: str, postId: str, recentPostsCache: {},
debug: bool) -> None:
""" Mutes the given post
"""
postFilename = locatePost(baseDir, nickname, domain, postId)
if not postFilename:
return
postJsonObject = loadJson(postFilename)
if not postJsonObject:
return
if hasObjectDict(postJsonObject):
domainFull = getFullDomain(domain, port)
actor = httpPrefix + '://' + domainFull + '/users/' + nickname
# does this post have ignores on it from differenent actors?
if not postJsonObject['object'].get('ignores'):
if debug:
print('DEBUG: Adding initial mute to ' + postId)
ignoresJson = {
"@context": "https://www.w3.org/ns/activitystreams",
'id': postId,
'type': 'Collection',
"totalItems": 1,
'items': [{
'type': 'Ignore',
'actor': actor
}]
}
postJsonObject['object']['ignores'] = ignoresJson
else:
if not postJsonObject['object']['ignores'].get('items'):
postJsonObject['object']['ignores']['items'] = []
itemsList = postJsonObject['object']['ignores']['items']
for ignoresItem in itemsList:
if ignoresItem.get('actor'):
if ignoresItem['actor'] == actor:
return
newIgnore = {
'type': 'Ignore',
'actor': actor
}
igIt = len(itemsList)
itemsList.append(newIgnore)
postJsonObject['object']['ignores']['totalItems'] = igIt
saveJson(postJsonObject, postFilename)
# remove cached post so that the muted version gets recreated
# without its content text and/or image
cachedPostFilename = \
getCachedPostFilename(baseDir, nickname, domain, postJsonObject)
if cachedPostFilename:
if os.path.isfile(cachedPostFilename):
os.remove(cachedPostFilename)
with open(postFilename + '.muted', 'w+') as muteFile:
muteFile.write('\n')
print('MUTE: ' + postFilename + '.muted file added')
# if the post is in the recent posts cache then mark it as muted
if recentPostsCache.get('index'):
postId = \
removeIdEnding(postJsonObject['id']).replace('/', '#')
if postId in recentPostsCache['index']:
print('MUTE: ' + postId + ' is in recent posts cache')
if recentPostsCache['json'].get(postId):
postJsonObject['muted'] = True
recentPostsCache['json'][postId] = json.dumps(postJsonObject)
if recentPostsCache.get('html'):
if recentPostsCache['html'].get(postId):
del recentPostsCache['html'][postId]
print('MUTE: ' + postId +
' marked as muted in recent posts memory cache')
def unmutePost(baseDir: str, nickname: str, domain: str, port: int,
httpPrefix: str, postId: str, recentPostsCache: {},
debug: bool) -> None:
""" Unmutes the given post
"""
postFilename = locatePost(baseDir, nickname, domain, postId)
if not postFilename:
return
postJsonObject = loadJson(postFilename)
if not postJsonObject:
return
muteFilename = postFilename + '.muted'
if os.path.isfile(muteFilename):
os.remove(muteFilename)
print('UNMUTE: ' + muteFilename + ' file removed')
if hasObjectDict(postJsonObject):
if postJsonObject['object'].get('ignores'):
domainFull = getFullDomain(domain, port)
actor = httpPrefix + '://' + domainFull + '/users/' + nickname
totalItems = 0
if postJsonObject['object']['ignores'].get('totalItems'):
totalItems = \
postJsonObject['object']['ignores']['totalItems']
itemsList = postJsonObject['object']['ignores']['items']
for ignoresItem in itemsList:
if ignoresItem.get('actor'):
if ignoresItem['actor'] == actor:
if debug:
print('DEBUG: mute was removed for ' + actor)
itemsList.remove(ignoresItem)
break
if totalItems == 1:
if debug:
print('DEBUG: mute was removed from post')
del postJsonObject['object']['ignores']
else:
igItLen = len(postJsonObject['object']['ignores']['items'])
postJsonObject['object']['ignores']['totalItems'] = igItLen
saveJson(postJsonObject, postFilename)
# remove cached post so that the muted version gets recreated
# with its content text and/or image
cachedPostFilename = \
getCachedPostFilename(baseDir, nickname, domain, postJsonObject)
if cachedPostFilename:
if os.path.isfile(cachedPostFilename):
os.remove(cachedPostFilename)
# if the post is in the recent posts cache then mark it as unmuted
if recentPostsCache.get('index'):
postId = \
removeIdEnding(postJsonObject['id']).replace('/', '#')
if postId in recentPostsCache['index']:
print('UNMUTE: ' + postId + ' is in recent posts cache')
if recentPostsCache['json'].get(postId):
postJsonObject['muted'] = False
recentPostsCache['json'][postId] = json.dumps(postJsonObject)
if recentPostsCache.get('html'):
if recentPostsCache['html'].get(postId):
del recentPostsCache['html'][postId]
print('UNMUTE: ' + postId +
' marked as unmuted in recent posts cache')
def outboxMute(baseDir: str, httpPrefix: str,
nickname: str, domain: str, port: int,
messageJson: {}, debug: bool,
recentPostsCache: {}) -> None:
"""When a mute is received by the outbox from c2s
"""
if not messageJson.get('type'):
return
if not messageJson.get('actor'):
return
domainFull = getFullDomain(domain, port)
if not messageJson['actor'].endswith(domainFull + '/users/' + nickname):
return
if not messageJson['type'] == 'Ignore':
return
if not messageJson.get('object'):
if debug:
print('DEBUG: no object in mute')
return
if not isinstance(messageJson['object'], str):
if debug:
print('DEBUG: mute object is not string')
return
if debug:
print('DEBUG: c2s mute request arrived in outbox')
messageId = removeIdEnding(messageJson['object'])
if '/statuses/' not in messageId:
if debug:
print('DEBUG: c2s mute object is not a status')
return
if not hasUsersPath(messageId):
if debug:
print('DEBUG: c2s mute object has no nickname')
return
domain = removeDomainPort(domain)
postFilename = locatePost(baseDir, nickname, domain, messageId)
if not postFilename:
if debug:
print('DEBUG: c2s mute post not found in inbox or outbox')
print(messageId)
return
nicknameMuted = getNicknameFromActor(messageJson['object'])
if not nicknameMuted:
print('WARN: unable to find nickname in ' + messageJson['object'])
return
mutePost(baseDir, nickname, domain, port,
httpPrefix, messageJson['object'], recentPostsCache,
debug)
if debug:
print('DEBUG: post muted via c2s - ' + postFilename)
def outboxUndoMute(baseDir: str, httpPrefix: str,
nickname: str, domain: str, port: int,
messageJson: {}, debug: bool,
recentPostsCache: {}) -> None:
"""When an undo mute is received by the outbox from c2s
"""
if not messageJson.get('type'):
return
if not messageJson.get('actor'):
return
domainFull = getFullDomain(domain, port)
if not messageJson['actor'].endswith(domainFull + '/users/' + nickname):
return
if not messageJson['type'] == 'Undo':
return
if not hasObjectDict(messageJson):
return
if not messageJson['object'].get('type'):
return
if messageJson['object']['type'] != 'Ignore':
return
if not isinstance(messageJson['object']['object'], str):
if debug:
print('DEBUG: undo mute object is not a string')
return
if debug:
print('DEBUG: c2s undo mute request arrived in outbox')
messageId = removeIdEnding(messageJson['object']['object'])
if '/statuses/' not in messageId:
if debug:
print('DEBUG: c2s undo mute object is not a status')
return
if not hasUsersPath(messageId):
if debug:
print('DEBUG: c2s undo mute object has no nickname')
return
domain = removeDomainPort(domain)
postFilename = locatePost(baseDir, nickname, domain, messageId)
if not postFilename:
if debug:
print('DEBUG: c2s undo mute post not found in inbox or outbox')
print(messageId)
return
nicknameMuted = getNicknameFromActor(messageJson['object']['object'])
if not nicknameMuted:
print('WARN: unable to find nickname in ' +
messageJson['object']['object'])
return
unmutePost(baseDir, nickname, domain, port,
httpPrefix, messageJson['object']['object'],
recentPostsCache, debug)
if debug:
print('DEBUG: post undo mute via c2s - ' + postFilename)
def brochModeIsActive(baseDir: str) -> bool:
"""Returns true if broch mode is active
"""
allowFilename = baseDir + '/accounts/allowedinstances.txt'
return os.path.isfile(allowFilename)
def setBrochMode(baseDir: str, domainFull: str, enabled: bool) -> None:
"""Broch mode can be used to lock down the instance during
a period of time when it is temporarily under attack.
@ -387,16 +712,14 @@ def setBrochMode(baseDir: str, domainFull: str, enabled: bool) -> None:
followFiles = ('following.txt', 'followers.txt')
for subdir, dirs, files in os.walk(baseDir + '/accounts'):
for acct in dirs:
if '@' not in acct:
continue
if 'inbox@' in acct or 'news@' in acct:
if not isAccountDir(acct):
continue
accountDir = os.path.join(baseDir + '/accounts', acct)
for followFileType in followFiles:
followingFilename = accountDir + '/' + followFileType
if not os.path.isfile(followingFilename):
continue
with open(followingFilename, "r") as f:
with open(followingFilename, 'r') as f:
followList = f.readlines()
for handle in followList:
if '@' not in handle:
@ -408,18 +731,16 @@ def setBrochMode(baseDir: str, domainFull: str, enabled: bool) -> None:
break
# write the allow file
allowFile = open(allowFilename, "w+")
if allowFile:
with open(allowFilename, 'w+') as allowFile:
allowFile.write(domainFull + '\n')
for d in allowedDomains:
allowFile.write(d + '\n')
allowFile.close()
print('Broch mode enabled')
setConfigParam(baseDir, "brochMode", enabled)
def brochModeLapses(baseDir: str, lapseDays=7) -> bool:
def brochModeLapses(baseDir: str, lapseDays: int = 7) -> bool:
"""After broch mode is enabled it automatically
elapses after a period of time
"""
@ -428,22 +749,21 @@ def brochModeLapses(baseDir: str, lapseDays=7) -> bool:
return False
lastModified = fileLastModified(allowFilename)
modifiedDate = None
brochMode = True
try:
modifiedDate = \
datetime.strptime(lastModified, "%Y-%m-%dT%H:%M:%SZ")
except BaseException:
return brochMode
return False
if not modifiedDate:
return brochMode
return False
currTime = datetime.datetime.utcnow()
daysSinceBroch = (currTime - modifiedDate).days
if daysSinceBroch >= lapseDays:
try:
os.remove(allowFilename)
brochMode = False
setConfigParam(baseDir, "brochMode", brochMode)
setConfigParam(baseDir, "brochMode", False)
print('Broch mode has elapsed')
return True
except BaseException:
pass
return brochMode
return False

123
blog.py
View File

@ -5,15 +5,19 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "ActivityPub"
import os
from datetime import datetime
from content import replaceEmojiFromTags
from webapp_utils import htmlHeaderWithExternalStyle
from webapp_utils import htmlHeaderWithBlogMarkup
from webapp_utils import htmlFooter
from webapp_utils import getPostAttachmentsAsHtml
from webapp_media import addEmbeddedElements
from utils import isAccountDir
from utils import removeHtml
from utils import getConfigParam
from utils import getFullDomain
from utils import getMediaFormats
@ -22,6 +26,8 @@ from utils import getDomainFromActor
from utils import locatePost
from utils import loadJson
from utils import firstParagraphFromString
from utils import getActorPropertyUrl
from utils import acctDir
from posts import createBlogsTimeline
from newswire import rss2Header
from newswire import rss2Footer
@ -41,8 +47,8 @@ def _noOfBlogReplies(baseDir: str, httpPrefix: str, translate: {},
tryPostBox = ('tlblogs', 'inbox', 'outbox')
boxFound = False
for postBox in tryPostBox:
postFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/' + postBox + '/' + \
postFilename = \
acctDir(baseDir, nickname, domain) + '/' + postBox + '/' + \
postId.replace('/', '#') + '.replies'
if os.path.isfile(postFilename):
boxFound = True
@ -50,8 +56,8 @@ def _noOfBlogReplies(baseDir: str, httpPrefix: str, translate: {},
if not boxFound:
# post may exist but has no replies
for postBox in tryPostBox:
postFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/' + postBox + '/' + \
postFilename = \
acctDir(baseDir, nickname, domain) + '/' + postBox + '/' + \
postId.replace('/', '#')
if os.path.isfile(postFilename):
return 1
@ -60,7 +66,7 @@ def _noOfBlogReplies(baseDir: str, httpPrefix: str, translate: {},
removals = []
replies = 0
lines = []
with open(postFilename, "r") as f:
with open(postFilename, 'r') as f:
lines = f.readlines()
for replyPostId in lines:
replyPostId = replyPostId.replace('\n', '').replace('\r', '')
@ -101,8 +107,8 @@ def _getBlogReplies(baseDir: str, httpPrefix: str, translate: {},
tryPostBox = ('tlblogs', 'inbox', 'outbox')
boxFound = False
for postBox in tryPostBox:
postFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/' + postBox + '/' + \
postFilename = \
acctDir(baseDir, nickname, domain) + '/' + postBox + '/' + \
postId.replace('/', '#') + '.replies'
if os.path.isfile(postFilename):
boxFound = True
@ -110,33 +116,31 @@ def _getBlogReplies(baseDir: str, httpPrefix: str, translate: {},
if not boxFound:
# post may exist but has no replies
for postBox in tryPostBox:
postFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/' + postBox + '/' + \
postFilename = \
acctDir(baseDir, nickname, domain) + '/' + postBox + '/' + \
postId.replace('/', '#') + '.json'
if os.path.isfile(postFilename):
postFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + \
postFilename = acctDir(baseDir, nickname, domain) + \
'/postcache/' + \
postId.replace('/', '#') + '.html'
if os.path.isfile(postFilename):
with open(postFilename, "r") as postFile:
with open(postFilename, 'r') as postFile:
return postFile.read() + '\n'
return ''
with open(postFilename, "r") as f:
with open(postFilename, 'r') as f:
lines = f.readlines()
repliesStr = ''
for replyPostId in lines:
replyPostId = replyPostId.replace('\n', '').replace('\r', '')
replyPostId = replyPostId.replace('.json', '')
replyPostId = replyPostId.replace('.replies', '')
postFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + \
postFilename = acctDir(baseDir, nickname, domain) + \
'/postcache/' + \
replyPostId.replace('/', '#') + '.html'
if not os.path.isfile(postFilename):
continue
with open(postFilename, "r") as postFile:
with open(postFilename, 'r') as postFile:
repliesStr += postFile.read() + '\n'
rply = _getBlogReplies(baseDir, httpPrefix, translate,
nickname, domain, domainFull,
@ -375,11 +379,28 @@ def _htmlBlogRemoveCwButton(blogStr: str, translate: {}) -> str:
return blogStr
def _getSnippetFromBlogContent(postJsonObject: {}) -> str:
"""Returns a snippet of text from the blog post as a preview
"""
content = postJsonObject['object']['content']
if '<p>' in content:
content = content.split('<p>', 1)[1]
if '</p>' in content:
content = content.split('</p>', 1)[0]
content = removeHtml(content)
if '\n' in content:
content = content.split('\n')[0]
if len(content) >= 256:
content = content[:252] + '...'
return content
def htmlBlogPost(authorized: bool,
baseDir: str, httpPrefix: str, translate: {},
nickname: str, domain: str, domainFull: str,
postJsonObject: {},
peertubeInstances: []) -> str:
peertubeInstances: [],
systemLanguage: str) -> str:
"""Returns a html blog post
"""
blogStr = ''
@ -389,7 +410,13 @@ def htmlBlogPost(authorized: bool,
cssFilename = baseDir + '/blog.css'
instanceTitle = \
getConfigParam(baseDir, 'instanceTitle')
blogStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle)
published = postJsonObject['object']['published']
title = postJsonObject['object']['summary']
snippet = _getSnippetFromBlogContent(postJsonObject)
blogStr = htmlHeaderWithBlogMarkup(cssFilename, instanceTitle,
httpPrefix, domainFull, nickname,
systemLanguage, published,
title, snippet)
_htmlBlogRemoveCwButton(blogStr, translate)
blogStr += _htmlBlogPostContent(authorized, baseDir,
@ -441,8 +468,7 @@ def htmlBlogPage(authorized: bool, session,
blogStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle)
_htmlBlogRemoveCwButton(blogStr, translate)
blogsIndex = baseDir + '/accounts/' + \
nickname + '@' + domain + '/tlblogs.index'
blogsIndex = acctDir(baseDir, nickname, domain) + '/tlblogs.index'
if not os.path.isfile(blogsIndex):
return blogStr + htmlFooter()
@ -530,8 +556,7 @@ def htmlBlogPageRSS2(authorized: bool, session,
blogRSS2 = rss2Header(httpPrefix, nickname, domainFull,
'Blog', translate)
blogsIndex = baseDir + '/accounts/' + \
nickname + '@' + domain + '/tlblogs.index'
blogsIndex = acctDir(baseDir, nickname, domain) + '/tlblogs.index'
if not os.path.isfile(blogsIndex):
if includeHeader:
return blogRSS2 + rss2Footer()
@ -582,8 +607,7 @@ def htmlBlogPageRSS3(authorized: bool, session,
blogRSS3 = ''
blogsIndex = baseDir + '/accounts/' + \
nickname + '@' + domain + '/tlblogs.index'
blogsIndex = acctDir(baseDir, nickname, domain) + '/tlblogs.index'
if not os.path.isfile(blogsIndex):
return blogRSS3
@ -617,9 +641,7 @@ def _noOfBlogAccounts(baseDir: str) -> int:
ctr = 0
for subdir, dirs, files in os.walk(baseDir + '/accounts'):
for acct in dirs:
if '@' not in acct:
continue
if 'inbox@' in acct:
if not isAccountDir(acct):
continue
accountDir = os.path.join(baseDir + '/accounts', acct)
blogsIndex = accountDir + '/tlblogs.index'
@ -634,9 +656,7 @@ def _singleBlogAccountNickname(baseDir: str) -> str:
"""
for subdir, dirs, files in os.walk(baseDir + '/accounts'):
for acct in dirs:
if '@' not in acct:
continue
if 'inbox@' in acct:
if not isAccountDir(acct):
continue
accountDir = os.path.join(baseDir + '/accounts', acct)
blogsIndex = accountDir + '/tlblogs.index'
@ -674,9 +694,7 @@ def htmlBlogView(authorized: bool,
for subdir, dirs, files in os.walk(baseDir + '/accounts'):
for acct in dirs:
if '@' not in acct:
continue
if 'inbox@' in acct:
if not isAccountDir(acct):
continue
accountDir = os.path.join(baseDir + '/accounts', acct)
blogsIndex = accountDir + '/tlblogs.index'
@ -819,7 +837,8 @@ def htmlEditBlog(mediaInstance: bool, translate: {},
editBlogForm += \
' <textarea id="message" name="message" style="height:' + \
str(messageBoxHeight) + 'px">' + contentStr + '</textarea>'
str(messageBoxHeight) + 'px" spellcheck="true">' + \
contentStr + '</textarea>'
editBlogForm += dateAndLocation
if not mediaInstance:
editBlogForm += editBlogImageSection
@ -831,3 +850,39 @@ def htmlEditBlog(mediaInstance: bool, translate: {},
editBlogForm += htmlFooter()
return editBlogForm
def pathContainsBlogLink(baseDir: str,
httpPrefix: str, domain: str,
domainFull: str, path: str) -> (str, str):
"""If the path contains a blog entry then return its filename
"""
if '/users/' not in path:
return None, None
userEnding = path.split('/users/', 1)[1]
if '/' not in userEnding:
return None, None
userEnding2 = userEnding.split('/')
nickname = userEnding2[0]
if len(userEnding2) != 2:
return None, None
if len(userEnding2[1]) < 14:
return None, None
userEnding2[1] = userEnding2[1].strip()
if not userEnding2[1].isdigit():
return None, None
# check for blog posts
blogIndexFilename = acctDir(baseDir, nickname, domain) + '/tlblogs.index'
if not os.path.isfile(blogIndexFilename):
return None, None
if '#' + userEnding2[1] + '.' not in open(blogIndexFilename).read():
return None, None
messageId = httpPrefix + '://' + domainFull + \
'/users/' + nickname + '/statuses/' + userEnding2[1]
return locatePost(baseDir, nickname, domain, messageId), nickname
def getBlogAddress(actorJson: {}) -> str:
"""Returns blog address for the given actor
"""
return getActorPropertyUrl(actorJson, 'Blog')

View File

@ -5,9 +5,13 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "Timeline"
import os
from pprint import pprint
from webfinger import webfingerHandle
from auth import createBasicAuthHeader
from utils import removeDomainPort
from utils import hasUsersPath
from utils import getFullDomain
from utils import removeIdEnding
@ -19,6 +23,10 @@ from utils import locatePost
from utils import getCachedPostFilename
from utils import loadJson
from utils import saveJson
from utils import hasObjectDict
from utils import acctDir
from posts import getPersonBox
from session import postJson
def undoBookmarksCollectionEntry(recentPostsCache: {},
@ -42,8 +50,8 @@ def undoBookmarksCollectionEntry(recentPostsCache: {},
removePostFromCache(postJsonObject, recentPostsCache)
# remove from the index
bookmarksIndexFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/bookmarks.index'
bookmarksIndexFilename = \
acctDir(baseDir, nickname, domain) + '/bookmarks.index'
if not os.path.isfile(bookmarksIndexFilename):
return
if '/' in postFilename:
@ -56,21 +64,17 @@ def undoBookmarksCollectionEntry(recentPostsCache: {},
indexStr = ''
with open(bookmarksIndexFilename, 'r') as indexFile:
indexStr = indexFile.read().replace(bookmarkIndex + '\n', '')
bookmarksIndexFile = open(bookmarksIndexFilename, 'w+')
if bookmarksIndexFile:
with open(bookmarksIndexFilename, 'w+') as bookmarksIndexFile:
bookmarksIndexFile.write(indexStr)
bookmarksIndexFile.close()
if not postJsonObject.get('type'):
return
if postJsonObject['type'] != 'Create':
return
if not postJsonObject.get('object'):
if not hasObjectDict(postJsonObject):
if debug:
pprint(postJsonObject)
print('DEBUG: post ' + objectUrl + ' has no object')
return
if not isinstance(postJsonObject['object'], dict):
print('DEBUG: bookmarked post has no object ' +
str(postJsonObject))
return
if not postJsonObject['object'].get('bookmarks'):
return
@ -120,9 +124,7 @@ def bookmarkedByPerson(postJsonObject: {}, nickname: str, domain: str) -> bool:
def _noOfBookmarks(postJsonObject: {}) -> int:
"""Returns the number of bookmarks ona given post
"""
if not postJsonObject.get('object'):
return 0
if not isinstance(postJsonObject['object'], dict):
if not hasObjectDict(postJsonObject):
return 0
if not postJsonObject['object'].get('bookmarks'):
return 0
@ -154,11 +156,12 @@ def updateBookmarksCollection(recentPostsCache: {},
if not postJsonObject.get('object'):
if debug:
pprint(postJsonObject)
print('DEBUG: post ' + objectUrl + ' has no object')
print('DEBUG: no object in bookmarked post ' +
str(postJsonObject))
return
if not objectUrl.endswith('/bookmarks'):
objectUrl = objectUrl + '/bookmarks'
# does this post have bookmarks on it from differenent actors?
if not postJsonObject['object'].get('bookmarks'):
if debug:
print('DEBUG: Adding initial bookmarks to ' + objectUrl)
@ -180,14 +183,14 @@ def updateBookmarksCollection(recentPostsCache: {},
if bookmarkItem.get('actor'):
if bookmarkItem['actor'] == actor:
return
newBookmark = {
'type': 'Bookmark',
'actor': actor
}
nb = newBookmark
bmIt = len(postJsonObject['object']['bookmarks']['items'])
postJsonObject['object']['bookmarks']['items'].append(nb)
postJsonObject['object']['bookmarks']['totalItems'] = bmIt
newBookmark = {
'type': 'Bookmark',
'actor': actor
}
nb = newBookmark
bmIt = len(postJsonObject['object']['bookmarks']['items'])
postJsonObject['object']['bookmarks']['items'].append(nb)
postJsonObject['object']['bookmarks']['totalItems'] = bmIt
if debug:
print('DEBUG: saving post with bookmarks added')
@ -196,8 +199,8 @@ def updateBookmarksCollection(recentPostsCache: {},
saveJson(postJsonObject, postFilename)
# prepend to the index
bookmarksIndexFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/bookmarks.index'
bookmarksIndexFilename = \
acctDir(baseDir, nickname, domain) + '/bookmarks.index'
bookmarkIndex = postFilename.split('/')[-1]
if os.path.isfile(bookmarksIndexFilename):
if bookmarkIndex not in open(bookmarksIndexFilename).read():
@ -213,10 +216,8 @@ def updateBookmarksCollection(recentPostsCache: {},
print('WARN: Failed to write entry to bookmarks index ' +
bookmarksIndexFilename + ' ' + str(e))
else:
bookmarksIndexFile = open(bookmarksIndexFilename, 'w+')
if bookmarksIndexFile:
with open(bookmarksIndexFilename, 'w+') as bookmarksIndexFile:
bookmarksIndexFile.write(bookmarkIndex + '\n')
bookmarksIndexFile.close()
def bookmark(recentPostsCache: {},
@ -241,7 +242,7 @@ def bookmark(recentPostsCache: {},
newBookmarkJson = {
"@context": "https://www.w3.org/ns/activitystreams",
'type': 'Bookmark',
'actor': httpPrefix+'://'+fullDomain+'/users/'+nickname,
'actor': httpPrefix + '://' + fullDomain + '/users/' + nickname,
'object': objectUrl
}
if ccList:
@ -300,10 +301,10 @@ def undoBookmark(recentPostsCache: {},
newUndoBookmarkJson = {
"@context": "https://www.w3.org/ns/activitystreams",
'type': 'Undo',
'actor': httpPrefix+'://'+fullDomain+'/users/'+nickname,
'actor': httpPrefix + '://' + fullDomain + '/users/' + nickname,
'object': {
'type': 'Bookmark',
'actor': httpPrefix+'://'+fullDomain+'/users/'+nickname,
'actor': httpPrefix + '://' + fullDomain + '/users/' + nickname,
'object': objectUrl
}
}
@ -341,6 +342,176 @@ def undoBookmark(recentPostsCache: {},
return newUndoBookmarkJson
def sendBookmarkViaServer(baseDir: str, session,
nickname: str, password: str,
domain: str, fromPort: int,
httpPrefix: str, bookmarkUrl: str,
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str) -> {}:
"""Creates a bookmark via c2s
"""
if not session:
print('WARN: No session for sendBookmarkViaServer')
return 6
domainFull = getFullDomain(domain, fromPort)
actor = httpPrefix + '://' + domainFull + '/users/' + nickname
newBookmarkJson = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Add",
"actor": actor,
"to": [actor],
"object": {
"type": "Document",
"url": bookmarkUrl,
"to": [actor]
},
"target": actor + "/tlbookmarks"
}
handle = httpPrefix + '://' + domainFull + '/@' + nickname
# lookup the inbox for the To handle
wfRequest = webfingerHandle(session, handle, httpPrefix,
cachedWebfingers,
domain, projectVersion, debug)
if not wfRequest:
if debug:
print('DEBUG: bookmark webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: bookmark webfinger for ' + handle +
' did not return a dict. ' + str(wfRequest))
return 1
postToBox = 'outbox'
# get the actor inbox for the To handle
(inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox,
avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest,
personCache,
projectVersion, httpPrefix,
nickname, domain,
postToBox, 52594)
if not inboxUrl:
if debug:
print('DEBUG: bookmark no ' + postToBox +
' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: bookmark no actor was found for ' + handle)
return 4
authHeader = createBasicAuthHeader(nickname, password)
headers = {
'host': domain,
'Content-type': 'application/json',
'Authorization': authHeader
}
postResult = postJson(httpPrefix, domainFull,
session, newBookmarkJson, [], inboxUrl,
headers, 3, True)
if not postResult:
if debug:
print('WARN: POST bookmark failed for c2s to ' + inboxUrl)
return 5
if debug:
print('DEBUG: c2s POST bookmark success')
return newBookmarkJson
def sendUndoBookmarkViaServer(baseDir: str, session,
nickname: str, password: str,
domain: str, fromPort: int,
httpPrefix: str, bookmarkUrl: str,
cachedWebfingers: {}, personCache: {},
debug: bool, projectVersion: str) -> {}:
"""Removes a bookmark via c2s
"""
if not session:
print('WARN: No session for sendUndoBookmarkViaServer')
return 6
domainFull = getFullDomain(domain, fromPort)
actor = httpPrefix + '://' + domainFull + '/users/' + nickname
newBookmarkJson = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Remove",
"actor": actor,
"to": [actor],
"object": {
"type": "Document",
"url": bookmarkUrl,
"to": [actor]
},
"target": actor + "/tlbookmarks"
}
handle = httpPrefix + '://' + domainFull + '/@' + nickname
# lookup the inbox for the To handle
wfRequest = webfingerHandle(session, handle, httpPrefix,
cachedWebfingers,
domain, projectVersion, debug)
if not wfRequest:
if debug:
print('DEBUG: unbookmark webfinger failed for ' + handle)
return 1
if not isinstance(wfRequest, dict):
print('WARN: unbookmark webfinger for ' + handle +
' did not return a dict. ' + str(wfRequest))
return 1
postToBox = 'outbox'
# get the actor inbox for the To handle
(inboxUrl, pubKeyId, pubKey, fromPersonId, sharedInbox,
avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest,
personCache,
projectVersion, httpPrefix,
nickname, domain,
postToBox, 52594)
if not inboxUrl:
if debug:
print('DEBUG: unbookmark no ' + postToBox +
' was found for ' + handle)
return 3
if not fromPersonId:
if debug:
print('DEBUG: unbookmark no actor was found for ' + handle)
return 4
authHeader = createBasicAuthHeader(nickname, password)
headers = {
'host': domain,
'Content-type': 'application/json',
'Authorization': authHeader
}
postResult = postJson(httpPrefix, domainFull,
session, newBookmarkJson, [], inboxUrl,
headers, 3, True)
if not postResult:
if debug:
print('WARN: POST unbookmark failed for c2s to ' + inboxUrl)
return 5
if debug:
print('DEBUG: c2s POST unbookmark success')
return newBookmarkJson
def outboxBookmark(recentPostsCache: {},
baseDir: str, httpPrefix: str,
nickname: str, domain: str, port: int,
@ -348,44 +519,58 @@ def outboxBookmark(recentPostsCache: {},
""" When a bookmark request is received by the outbox from c2s
"""
if not messageJson.get('type'):
if debug:
print('DEBUG: bookmark - no type')
return
if not messageJson['type'] == 'Bookmark':
if debug:
print('DEBUG: not a bookmark')
if messageJson['type'] != 'Add':
return
if not messageJson.get('object'):
if not messageJson.get('actor'):
if debug:
print('DEBUG: no object in bookmark')
print('DEBUG: no actor in bookmark Add')
return
if not isinstance(messageJson['object'], str):
if not hasObjectDict(messageJson):
if debug:
print('DEBUG: bookmark object is not string')
print('DEBUG: no object in bookmark Add')
return
if not messageJson.get('target'):
if debug:
print('DEBUG: no target in bookmark Add')
return
if not messageJson['object'].get('type'):
if debug:
print('DEBUG: no object type in bookmark Add')
return
if not isinstance(messageJson['target'], str):
if debug:
print('DEBUG: bookmark Add target is not string')
return
domainFull = getFullDomain(domain, port)
if not messageJson['target'].endswith('://' + domainFull +
'/users/' + nickname +
'/tlbookmarks'):
if debug:
print('DEBUG: bookmark Add target invalid ' +
messageJson['target'])
return
if messageJson['object']['type'] != 'Document':
if debug:
print('DEBUG: bookmark Add type is not Document')
return
if not messageJson['object'].get('url'):
if debug:
print('DEBUG: bookmark Add missing url')
return
if messageJson.get('to'):
if not isinstance(messageJson['to'], list):
return
if len(messageJson['to']) != 1:
print('WARN: Bookmark should only be sent to one recipient')
return
if messageJson['to'][0] != messageJson['actor']:
print('WARN: Bookmark should be addressed to the same actor')
return
if debug:
print('DEBUG: c2s bookmark request arrived in outbox')
print('DEBUG: c2s bookmark Add request arrived in outbox')
messageId = removeIdEnding(messageJson['object'])
if ':' in domain:
domain = domain.split(':')[0]
postFilename = locatePost(baseDir, nickname, domain, messageId)
messageUrl = removeIdEnding(messageJson['object']['url'])
domain = removeDomainPort(domain)
postFilename = locatePost(baseDir, nickname, domain, messageUrl)
if not postFilename:
if debug:
print('DEBUG: c2s bookmark post not found in inbox or outbox')
print(messageId)
print('DEBUG: c2s like post not found in inbox or outbox')
print(messageUrl)
return True
updateBookmarksCollection(recentPostsCache,
baseDir, postFilename, messageId,
baseDir, postFilename, messageUrl,
messageJson['actor'], domain, debug)
if debug:
print('DEBUG: post bookmarked via c2s - ' + postFilename)
@ -399,53 +584,57 @@ def outboxUndoBookmark(recentPostsCache: {},
"""
if not messageJson.get('type'):
return
if not messageJson['type'] == 'Undo':
if messageJson['type'] != 'Remove':
return
if not messageJson.get('object'):
return
if not isinstance(messageJson['object'], dict):
if not messageJson.get('actor'):
if debug:
print('DEBUG: undo bookmark object is not dict')
print('DEBUG: no actor in unbookmark Remove')
return
if not hasObjectDict(messageJson):
if debug:
print('DEBUG: no object in unbookmark Remove')
return
if not messageJson.get('target'):
if debug:
print('DEBUG: no target in unbookmark Remove')
return
if not messageJson['object'].get('type'):
if debug:
print('DEBUG: undo bookmark - no type')
print('DEBUG: no object type in bookmark Remove')
return
if not messageJson['object']['type'] == 'Bookmark':
if not isinstance(messageJson['target'], str):
if debug:
print('DEBUG: not a undo bookmark')
print('DEBUG: unbookmark Remove target is not string')
return
if not messageJson['object'].get('object'):
domainFull = getFullDomain(domain, port)
if not messageJson['target'].endswith('://' + domainFull +
'/users/' + nickname +
'/tlbookmarks'):
if debug:
print('DEBUG: no object in undo bookmark')
print('DEBUG: unbookmark Remove target invalid ' +
messageJson['target'])
return
if not isinstance(messageJson['object']['object'], str):
if messageJson['object']['type'] != 'Document':
if debug:
print('DEBUG: undo bookmark object is not string')
print('DEBUG: unbookmark Remove type is not Document')
return
if not messageJson['object'].get('url'):
if debug:
print('DEBUG: unbookmark Remove missing url')
return
if messageJson.get('to'):
if not isinstance(messageJson['to'], list):
return
if len(messageJson['to']) != 1:
print('WARN: Bookmark should only be sent to one recipient')
return
if messageJson['to'][0] != messageJson['actor']:
print('WARN: Bookmark should be addressed to the same actor')
return
if debug:
print('DEBUG: c2s undo bookmark request arrived in outbox')
print('DEBUG: c2s unbookmark Remove request arrived in outbox')
messageId = removeIdEnding(messageJson['object']['object'])
if ':' in domain:
domain = domain.split(':')[0]
postFilename = locatePost(baseDir, nickname, domain, messageId)
messageUrl = removeIdEnding(messageJson['object']['url'])
domain = removeDomainPort(domain)
postFilename = locatePost(baseDir, nickname, domain, messageUrl)
if not postFilename:
if debug:
print('DEBUG: c2s undo bookmark post not found in inbox or outbox')
print(messageId)
print('DEBUG: c2s unbookmark post not found in inbox or outbox')
print(messageUrl)
return True
undoBookmarksCollectionEntry(recentPostsCache,
baseDir, postFilename, messageId,
messageJson['actor'], domain, debug)
updateBookmarksCollection(recentPostsCache,
baseDir, postFilename, messageUrl,
messageJson['actor'], domain, debug)
if debug:
print('DEBUG: post undo bookmarked via c2s - ' + postFilename)
print('DEBUG: post unbookmarked via c2s - ' + postFilename)

View File

@ -5,6 +5,7 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "Profile Metadata"
def getBriarAddress(actorJson: {}) -> str:

View File

@ -5,6 +5,7 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "Core"
import os
import datetime
@ -19,7 +20,7 @@ def _removePersonFromCache(baseDir: str, personUrl: str,
"""Removes an actor from the cache
"""
cacheFilename = baseDir + '/cache/actors/' + \
personUrl.replace('/', '#')+'.json'
personUrl.replace('/', '#') + '.json'
if os.path.isfile(cacheFilename):
try:
os.remove(cacheFilename)
@ -65,12 +66,13 @@ def storePersonInCache(baseDir: str, personUrl: str,
return
# store to file
if allowWriteToFile:
if os.path.isdir(baseDir+'/cache/actors'):
cacheFilename = baseDir + '/cache/actors/' + \
personUrl.replace('/', '#')+'.json'
if not os.path.isfile(cacheFilename):
saveJson(personJson, cacheFilename)
if not allowWriteToFile:
return
if os.path.isdir(baseDir + '/cache/actors'):
cacheFilename = baseDir + '/cache/actors/' + \
personUrl.replace('/', '#') + '.json'
if not os.path.isfile(cacheFilename):
saveJson(personJson, cacheFilename)
def getPersonFromCache(baseDir: str, personUrl: str, personCache: {},
@ -82,7 +84,7 @@ def getPersonFromCache(baseDir: str, personUrl: str, personCache: {},
if not personCache.get(personUrl):
# does the person exist as a cached file?
cacheFilename = baseDir + '/cache/actors/' + \
personUrl.replace('/', '#')+'.json'
personUrl.replace('/', '#') + '.json'
actorFilename = getFileCaseInsensitive(cacheFilename)
if actorFilename:
personJson = loadJson(actorFilename)

View File

@ -5,6 +5,7 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "RSS Feeds"
import os
import datetime
@ -29,7 +30,8 @@ def getHashtagCategory(baseDir: str, hashtag: str) -> str:
return ''
def getHashtagCategories(baseDir: str, recent=False, category=None) -> None:
def getHashtagCategories(baseDir: str,
recent: bool = False, category: str = None) -> None:
"""Returns a dictionary containing hashtag categories
"""
maxTagLength = 42
@ -127,7 +129,7 @@ def _validHashtagCategory(category: str) -> bool:
def setHashtagCategory(baseDir: str, hashtag: str, category: str,
force=False) -> bool:
force: bool = False) -> bool:
"""Sets the category for the hashtag
"""
if not _validHashtagCategory(category):
@ -163,12 +165,15 @@ def guessHashtagCategory(tagName: str, hashtagCategories: {}) -> str:
"""Tries to guess a category for the given hashtag.
This works by trying to find the longest similar hashtag
"""
if len(tagName) < 4:
return ''
categoryMatched = ''
tagMatchedLen = 0
for categoryStr, hashtagList in hashtagCategories.items():
for hashtag in hashtagList:
if len(hashtag) < 3:
if len(hashtag) < 4:
# avoid matching very small strings which often
# lead to spurious categories
continue
@ -183,5 +188,5 @@ def guessHashtagCategory(tagName: str, hashtagCategories: {}) -> str:
if len(hashtag) > tagMatchedLen:
categoryMatched = categoryStr
if not categoryMatched:
return
return ''
return categoryMatched

329
city.py 100644
View File

@ -0,0 +1,329 @@
__filename__ = "city.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "Metadata"
import os
import datetime
import random
import math
from random import randint
from utils import acctDir
# states which the simulated city dweller can be in
PERSON_SLEEP = 0
PERSON_WORK = 1
PERSON_PLAY = 2
PERSON_SHOP = 3
PERSON_EVENING = 4
PERSON_PARTY = 5
def _getDecoyCamera(decoySeed: int) -> (str, str, int):
"""Returns a decoy camera make and model which took the photo
"""
cameras = [
["Apple", "iPhone SE"],
["Apple", "iPhone XR"],
["Apple", "iPhone 6"],
["Apple", "iPhone 7"],
["Apple", "iPhone 8"],
["Apple", "iPhone 11"],
["Apple", "iPhone 11 Pro"],
["Apple", "iPhone 12"],
["Apple", "iPhone 12 Mini"],
["Apple", "iPhone 12 Pro Max"],
["Samsung", "Galaxy Note 20 Ultra"],
["Samsung", "Galaxy S20 Plus"],
["Samsung", "Galaxy S20 FE 5G"],
["Samsung", "Galaxy Z FOLD 2"],
["Samsung", "Galaxy S10 Plus"],
["Samsung", "Galaxy S10e"],
["Samsung", "Galaxy Z Flip"],
["Samsung", "Galaxy A51"],
["Samsung", "Galaxy S10"],
["Samsung", "Galaxy S10 Plus"],
["Samsung", "Galaxy S10e"],
["Samsung", "Galaxy S10 5G"],
["Samsung", "Galaxy A60"],
["Samsung", "Note 10"],
["Samsung", "Note 10 Plus"],
["Samsung", "Galaxy S21 Ultra"],
["Samsung", "Galaxy Note 20 Ultra"],
["Samsung", "Galaxy S21"],
["Samsung", "Galaxy S21 Plus"],
["Samsung", "Galaxy S20 FE"],
["Samsung", "Galaxy Z Fold 2"],
["Samsung", "Galaxy A52 5G"],
["Samsung", "Galaxy A71 5G"],
["Google", "Pixel 5"],
["Google", "Pixel 4a"],
["Google", "Pixel 4 XL"],
["Google", "Pixel 3 XL"],
["Google", "Pixel 4"],
["Google", "Pixel 4a 5G"],
["Google", "Pixel 3"],
["Google", "Pixel 3a"]
]
randgen = random.Random(decoySeed)
index = randgen.randint(0, len(cameras) - 1)
serialNumber = randgen.randint(100000000000, 999999999999999999999999)
return cameras[index][0], cameras[index][1], serialNumber
def _getCityPulse(currTimeOfDay, decoySeed: int) -> (float, float):
"""This simulates expected average patterns of movement in a city.
Jane or Joe average lives and works in the city, commuting in
and out of the central district for work. They have a unique
life pattern, which machine learning can latch onto.
This returns a polar coordinate for the simulated city dweller:
Distance from the city centre is in the range 0.0 - 1.0
Angle is in radians
"""
randgen = random.Random(decoySeed)
variance = 3
busyStates = (PERSON_WORK, PERSON_SHOP, PERSON_PLAY, PERSON_PARTY)
dataDecoyState = PERSON_SLEEP
weekday = currTimeOfDay.weekday()
minHour = 7 + randint(0, variance)
maxHour = 17 + randint(0, variance)
if currTimeOfDay.hour > minHour:
if currTimeOfDay.hour <= maxHour:
if weekday < 5:
dataDecoyState = PERSON_WORK
elif weekday == 5:
dataDecoyState = PERSON_SHOP
else:
dataDecoyState = PERSON_PLAY
else:
if weekday < 5:
dataDecoyState = PERSON_EVENING
else:
dataDecoyState = PERSON_PARTY
randgen2 = random.Random(decoySeed + dataDecoyState)
angleRadians = \
(randgen2.randint(0, 100000) / 100000) * 2 * math.pi
# some people are quite random, others have more predictable habits
decoyRandomness = randgen.randint(1, 3)
# occasionally throw in a wildcard to keep the machine learning guessing
if randint(0, 100) < decoyRandomness:
distanceFromCityCenter = (randint(0, 100000) / 100000)
angleRadians = (randint(0, 100000) / 100000) * 2 * math.pi
else:
# what consitutes the central district is fuzzy
centralDistrictFuzz = (randgen.randint(0, 100000) / 100000) * 0.1
busyRadius = 0.3 + centralDistrictFuzz
if dataDecoyState in busyStates:
# if we are busy then we're somewhere in the city center
distanceFromCityCenter = \
(randgen.randint(0, 100000) / 100000) * busyRadius
else:
# otherwise we're in the burbs
distanceFromCityCenter = busyRadius + \
((1.0 - busyRadius) * (randgen.randint(0, 100000) / 100000))
return distanceFromCityCenter, angleRadians
def parseNogoString(nogoLine: str) -> []:
"""Parses a line from locations_nogo.txt and returns the polygon
"""
nogoLine = nogoLine.replace('\n', '').replace('\r', '')
polygonStr = nogoLine.split(':', 1)[1]
if ';' in polygonStr:
pts = polygonStr.split(';')
else:
pts = polygonStr.split(',')
if len(pts) <= 4:
return []
polygon = []
for index in range(int(len(pts)/2)):
if index*2 + 1 >= len(pts):
break
longitudeStr = pts[index*2].strip()
latitudeStr = pts[index*2 + 1].strip()
if 'E' in latitudeStr or 'W' in latitudeStr:
longitudeStr = pts[index*2 + 1].strip()
latitudeStr = pts[index*2].strip()
if 'E' in longitudeStr:
longitudeStr = \
longitudeStr.replace('E', '')
longitude = float(longitudeStr)
elif 'W' in longitudeStr:
longitudeStr = \
longitudeStr.replace('W', '')
longitude = -float(longitudeStr)
else:
longitude = float(longitudeStr)
latitude = float(latitudeStr)
polygon.append([latitude, longitude])
return polygon
def spoofGeolocation(baseDir: str,
city: str, currTime, decoySeed: int,
citiesList: [],
nogoList: []) -> (float, float, str, str,
str, str, int):
"""Given a city and the current time spoofs the location
for an image
returns latitude, longitude, N/S, E/W,
camera make, camera model, camera serial number
"""
locationsFilename = baseDir + '/custom_locations.txt'
if not os.path.isfile(locationsFilename):
locationsFilename = baseDir + '/locations.txt'
nogoFilename = baseDir + '/custom_locations_nogo.txt'
if not os.path.isfile(nogoFilename):
nogoFilename = baseDir + '/locations_nogo.txt'
manCityRadius = 0.1
varianceAtLocation = 0.0004
default_latitude = 51.8744
default_longitude = 0.368333
default_latdirection = 'N'
default_longdirection = 'W'
if citiesList:
cities = citiesList
else:
if not os.path.isfile(locationsFilename):
return (default_latitude, default_longitude,
default_latdirection, default_longdirection,
"", "", 0)
cities = []
with open(locationsFilename, 'r') as f:
cities = f.readlines()
nogo = []
if nogoList:
nogo = nogoList
else:
if os.path.isfile(nogoFilename):
with open(nogoFilename, 'r') as f:
nogoList = f.readlines()
for line in nogoList:
if line.startswith(city + ':'):
polygon = parseNogoString(line)
if polygon:
nogo.append(polygon)
city = city.lower()
for cityName in cities:
if city in cityName.lower():
cityFields = cityName.split(':')
latitude = cityFields[1]
longitude = cityFields[2]
areaKm2 = 0
if len(cityFields) > 3:
areaKm2 = int(cityFields[3])
latdirection = 'N'
longdirection = 'E'
if 'S' in latitude:
latdirection = 'S'
latitude = latitude.replace('S', '')
if 'W' in longitude:
longdirection = 'W'
longitude = longitude.replace('W', '')
latitude = float(latitude)
longitude = float(longitude)
# get the time of day at the city
approxTimeZone = int(longitude / 15.0)
if longdirection == 'E':
approxTimeZone = -approxTimeZone
currTimeAdjusted = currTime - \
datetime.timedelta(hours=approxTimeZone)
camMake, camModel, camSerialNumber = \
_getDecoyCamera(decoySeed)
validCoord = False
seedOffset = 0
while not validCoord:
# patterns of activity change in the city over time
(distanceFromCityCenter, angleRadians) = \
_getCityPulse(currTimeAdjusted, decoySeed + seedOffset)
# The city radius value is in longitude and the reference
# is Manchester. Adjust for the radius of the chosen city.
if areaKm2 > 1:
manRadius = math.sqrt(1276 / math.pi)
radius = math.sqrt(areaKm2 / math.pi)
cityRadiusDeg = (radius / manRadius) * manCityRadius
else:
cityRadiusDeg = manCityRadius
# Get the position within the city, with some randomness added
latitude += \
distanceFromCityCenter * cityRadiusDeg * \
math.cos(angleRadians)
longitude += \
distanceFromCityCenter * cityRadiusDeg * \
math.sin(angleRadians)
longval = longitude
if longdirection == 'W':
longval = -longitude
validCoord = not pointInNogo(nogo, latitude, longval)
if not validCoord:
seedOffset += 1
if seedOffset > 100:
break
# add a small amount of variance around the location
fraction = randint(0, 100000) / 100000
distanceFromLocation = fraction * fraction * varianceAtLocation
fraction = randint(0, 100000) / 100000
angleFromLocation = fraction * 2 * math.pi
latitude += distanceFromLocation * math.cos(angleFromLocation)
longitude += distanceFromLocation * math.sin(angleFromLocation)
# gps locations aren't transcendental, so round to a fixed
# number of decimal places
latitude = int(latitude * 100000) / 100000.0
longitude = int(longitude * 100000) / 100000.0
return (latitude, longitude, latdirection, longdirection,
camMake, camModel, camSerialNumber)
return (default_latitude, default_longitude,
default_latdirection, default_longdirection,
"", "", 0)
def getSpoofedCity(city: str, baseDir: str, nickname: str, domain: str) -> str:
"""Returns the name of the city to use as a GPS spoofing location for
image metadata
"""
cityFilename = acctDir(baseDir, nickname, domain) + '/city.txt'
if os.path.isfile(cityFilename):
with open(cityFilename, 'r') as fp:
city = fp.read().replace('\n', '')
return city
def _pointInPolygon(poly: [], x: float, y: float) -> bool:
"""Returns true if the given point is inside the given polygon
"""
n = len(poly)
inside = False
p2x = 0.0
p2y = 0.0
xints = 0.0
p1x, p1y = poly[0]
for i in range(n + 1):
p2x, p2y = poly[i % n]
if y > min(p1y, p2y):
if y <= max(p1y, p2y):
if x <= max(p1x, p2x):
if p1y != p2y:
xints = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
if p1x == p2x or x <= xints:
inside = not inside
p1x, p1y = p2x, p2y
return inside
def pointInNogo(nogo: [], latitude: float, longitude: float) -> bool:
for polygon in nogo:
if _pointInPolygon(polygon, latitude, longitude):
return True
return False

View File

@ -26,6 +26,12 @@ No stalking, unwanted personal attention, or unwelcome revealing or speculating
In cases of sincere, good-faith curiosity about someones experience or identity, ask politely in a manner such that they will feel free to decline the request.
## No non-consenting research
People contributing to, or maintaining, this project should not be treated as research subjects in academic studies without their prior written consent. If anthropological, security, or other types of research are being conducted upon contributors then they must be made aware of this and formally agree to it taking place.
Publishing software under an AGPL license does not imply consent to become a research subject.
## No hostile communication
No insults, harassment (sexual or otherwise), condescension, ad hominem, threats, or other intimidation. Claims that such communications were intended as "ironic" or humerous will also be considered a code of conduct violation.

View File

@ -5,17 +5,22 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "Core"
import os
import email.parser
import urllib.parse
from shutil import copyfile
from utils import removeDomainPort
from utils import isValidLanguage
from utils import getImageExtensions
from utils import loadJson
from utils import fileLastModified
from utils import getLinkPrefixes
from utils import dangerousMarkup
from utils import isPGPEncrypted
from utils import containsPGPPublicKey
from utils import acctDir
from petnames import getPetName
@ -65,6 +70,8 @@ def _removeQuotesWithinQuotes(content: str) -> str:
def htmlReplaceEmailQuote(content: str) -> str:
"""Replaces an email style quote "> Some quote" with html blockquote
"""
if isPGPEncrypted(content) or containsPGPPublicKey(content):
return content
# replace quote paragraph
if '<p>&quot;' in content:
if '&quot;</p>' in content:
@ -106,6 +113,8 @@ def htmlReplaceQuoteMarks(content: str) -> str:
"""Replaces quotes with html formatting
"hello" becomes <q>hello</q>
"""
if isPGPEncrypted(content) or containsPGPPublicKey(content):
return content
if '"' not in content:
if '&quot;' not in content:
return content
@ -194,33 +203,35 @@ def dangerousCSS(filename: str, allowLocalNetworkAccess: bool) -> bool:
return False
def switchWords(baseDir: str, nickname: str, domain: str, content: str) -> str:
def switchWords(baseDir: str, nickname: str, domain: str, content: str,
rules: [] = []) -> str:
"""Performs word replacements. eg. Trump -> The Orange Menace
"""
switchWordsFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/replacewords.txt'
if not os.path.isfile(switchWordsFilename):
if isPGPEncrypted(content) or containsPGPPublicKey(content):
return content
with open(switchWordsFilename, 'r') as fp:
for line in fp:
replaceStr = line.replace('\n', '').replace('\r', '')
wordTransform = None
if '->' in replaceStr:
wordTransform = replaceStr.split('->')
elif ':' in replaceStr:
wordTransform = replaceStr.split(':')
elif ',' in replaceStr:
wordTransform = replaceStr.split(',')
elif ';' in replaceStr:
wordTransform = replaceStr.split(';')
elif '-' in replaceStr:
wordTransform = replaceStr.split('-')
if not wordTransform:
continue
if len(wordTransform) == 2:
replaceStr1 = wordTransform[0].strip().replace('"', '')
replaceStr2 = wordTransform[1].strip().replace('"', '')
content = content.replace(replaceStr1, replaceStr2)
if not rules:
switchWordsFilename = \
acctDir(baseDir, nickname, domain) + '/replacewords.txt'
if not os.path.isfile(switchWordsFilename):
return content
with open(switchWordsFilename, 'r') as fp:
rules = fp.readlines()
for line in rules:
replaceStr = line.replace('\n', '').replace('\r', '')
splitters = ('->', ':', ',', ';', '-')
wordTransform = None
for splitStr in splitters:
if splitStr in replaceStr:
wordTransform = replaceStr.split(splitStr)
break
if not wordTransform:
continue
if len(wordTransform) == 2:
replaceStr1 = wordTransform[0].strip().replace('"', '')
replaceStr2 = wordTransform[1].strip().replace('"', '')
content = content.replace(replaceStr1, replaceStr2)
return content
@ -298,7 +309,7 @@ def _addMusicTag(content: str, tag: str) -> str:
musicSites = ('soundcloud.com', 'bandcamp.com')
musicSiteFound = False
for site in musicSites:
if site+'/' in content:
if site + '/' in content:
musicSiteFound = True
break
if not musicSiteFound:
@ -450,7 +461,7 @@ def _addEmoji(baseDir: str, wordStr: str,
'type': 'Image',
'url': emojiUrl
},
'name': ':'+emoji+':',
'name': ':' + emoji + ':',
"updated": fileLastModified(emojiFilename),
"id": emojiUrl.replace('.png', ''),
'type': 'Emoji'
@ -582,6 +593,8 @@ def _addMention(wordStr: str, httpPrefix: str, following: str, petnames: str,
def replaceContentDuplicates(content: str) -> str:
"""Replaces invalid duplicates within content
"""
if isPGPEncrypted(content) or containsPGPPublicKey(content):
return content
while '<<' in content:
content = content.replace('<<', '<')
while '>>' in content:
@ -593,6 +606,8 @@ def replaceContentDuplicates(content: str) -> str:
def removeTextFormatting(content: str) -> str:
"""Removes markup for bold, italics, etc
"""
if isPGPEncrypted(content) or containsPGPPublicKey(content):
return content
if '<' not in content:
return content
removeMarkup = ('b', 'i', 'ul', 'ol', 'li', 'em', 'strong',
@ -610,6 +625,8 @@ def removeLongWords(content: str, maxWordLength: int,
"""Breaks up long words so that on mobile screens this doesn't
disrupt the layout
"""
if isPGPEncrypted(content) or containsPGPPublicKey(content):
return content
content = replaceContentDuplicates(content)
if ' ' not in content:
# handle a single very long string with no spaces
@ -629,6 +646,8 @@ def removeLongWords(content: str, maxWordLength: int,
if wordStr not in longWordsList:
longWordsList.append(wordStr)
for wordStr in longWordsList:
if wordStr.startswith('<p>'):
wordStr = wordStr.replace('<p>', '')
if wordStr.startswith('<'):
continue
if len(wordStr) == 76:
@ -664,6 +683,8 @@ def removeLongWords(content: str, maxWordLength: int,
continue
if '<' in wordStr:
replaceWord = wordStr.split('<', 1)[0]
# if len(replaceWord) > maxWordLength:
# replaceWord = replaceWord[:maxWordLength]
content = content.replace(wordStr, replaceWord)
wordStr = replaceWord
if '/' in wordStr:
@ -685,11 +706,10 @@ def _loadAutoTags(baseDir: str, nickname: str, domain: str) -> []:
"""Loads automatic tags file and returns a list containing
the lines of the file
"""
filename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/autotags.txt'
filename = acctDir(baseDir, nickname, domain) + '/autotags.txt'
if not os.path.isfile(filename):
return []
with open(filename, "r") as f:
with open(filename, 'r') as f:
return f.readlines()
return []
@ -718,7 +738,8 @@ def _autoTag(baseDir: str, nickname: str, domain: str,
def addHtmlTags(baseDir: str, httpPrefix: str,
nickname: str, domain: str, content: str,
recipients: [], hashtags: {}, isJsonContent=False) -> str:
recipients: [], hashtags: {},
isJsonContent: bool = False) -> str:
""" Replaces plaintext mentions such as @nick@domain into html
by matching against known following accounts
"""
@ -753,10 +774,8 @@ def addHtmlTags(baseDir: str, httpPrefix: str,
replaceEmoji = {}
emojiDict = {}
originalDomain = domain
if ':' in domain:
domain = domain.split(':')[0]
followingFilename = baseDir + '/accounts/' + \
nickname + '@' + domain + '/following.txt'
domain = removeDomainPort(domain)
followingFilename = acctDir(baseDir, nickname, domain) + '/following.txt'
# read the following list so that we can detect just @nick
# in addition to @nick@domain
@ -764,7 +783,7 @@ def addHtmlTags(baseDir: str, httpPrefix: str,
petnames = None
if '@' in words:
if os.path.isfile(followingFilename):
with open(followingFilename, "r") as f:
with open(followingFilename, 'r') as f:
following = f.readlines()
for handle in following:
pet = getPetName(baseDir, nickname, domain, handle)
@ -801,7 +820,7 @@ def addHtmlTags(baseDir: str, httpPrefix: str,
continue
elif ':' in wordStr:
wordStr2 = wordStr.split(':')[1]
# print('TAG: emoji located - '+wordStr)
# print('TAG: emoji located - ' + wordStr)
if not emojiDict:
# emoji.json is generated so that it can be customized and
# the changes will be retained even if default_emoji.json
@ -811,7 +830,7 @@ def addHtmlTags(baseDir: str, httpPrefix: str,
baseDir + '/emoji/emoji.json')
emojiDict = loadJson(baseDir + '/emoji/emoji.json')
# print('TAG: looking up emoji for :'+wordStr2+':')
# print('TAG: looking up emoji for :' + wordStr2 + ':')
_addEmoji(baseDir, ':' + wordStr2 + ':', httpPrefix,
originalDomain, replaceEmoji, hashtags,
emojiDict)
@ -846,6 +865,7 @@ def addHtmlTags(baseDir: str, httpPrefix: str,
content = addWebLinks(content)
if longWordsList:
content = removeLongWords(content, maxWordLength, longWordsList)
content = limitRepeatedWords(content, 6)
content = content.replace(' --linebreak-- ', '</p><p>')
content = htmlReplaceEmailQuote(content)
return '<p>' + htmlReplaceQuoteMarks(content) + '</p>'
@ -905,7 +925,7 @@ def extractMediaInFormPOST(postBytes, boundary, name: str):
def saveMediaInFormPOST(mediaBytes, debug: bool,
filenameBase=None) -> (str, str):
filenameBase: str = None) -> (str, str):
"""Saves the given media bytes extracted from http form POST
Returns the filename and attachment type
"""
@ -930,7 +950,8 @@ def saveMediaInFormPOST(mediaBytes, debug: bool,
'mp4': 'video/mp4',
'ogv': 'video/ogv',
'mp3': 'audio/mpeg',
'ogg': 'audio/ogg'
'ogg': 'audio/ogg',
'zip': 'application/zip'
}
detectedExtension = None
for extension, contentType in extensionList.items():
@ -942,7 +963,8 @@ def saveMediaInFormPOST(mediaBytes, debug: bool,
extension = 'jpg'
elif extension == 'mpeg':
extension = 'mp3'
filename = filenameBase + '.' + extension
if filenameBase:
filename = filenameBase + '.' + extension
attachmentMediaType = \
searchStr.decode().split('/')[0].replace('Content-Type: ', '')
detectedExtension = extension
@ -961,35 +983,50 @@ def saveMediaInFormPOST(mediaBytes, debug: bool,
break
# remove any existing image files with a different format
extensionTypes = getImageExtensions()
for ex in extensionTypes:
if ex == detectedExtension:
continue
possibleOtherFormat = \
filename.replace('.temp', '').replace('.' +
detectedExtension, '.' +
ex)
if os.path.isfile(possibleOtherFormat):
os.remove(possibleOtherFormat)
if detectedExtension != 'zip':
extensionTypes = getImageExtensions()
for ex in extensionTypes:
if ex == detectedExtension:
continue
possibleOtherFormat = \
filename.replace('.temp', '').replace('.' +
detectedExtension, '.' +
ex)
if os.path.isfile(possibleOtherFormat):
os.remove(possibleOtherFormat)
fd = open(filename, 'wb')
fd.write(mediaBytes[startPos:])
fd.close()
with open(filename, 'wb') as fp:
fp.write(mediaBytes[startPos:])
if not os.path.isfile(filename):
print('WARN: Media file could not be written to file: ' + filename)
return None, None
print('Uploaded media file written: ' + filename)
return filename, attachmentMediaType
def extractTextFieldsInPOST(postBytes, boundary, debug: bool) -> {}:
def extractTextFieldsInPOST(postBytes, boundary: str, debug: bool,
unitTestData: str = None) -> {}:
"""Returns a dictionary containing the text fields of a http form POST
The boundary argument comes from the http header
"""
msg = email.parser.BytesParser().parsebytes(postBytes)
if not unitTestData:
msgBytes = email.parser.BytesParser().parsebytes(postBytes)
messageFields = msgBytes.get_payload(decode=True).decode('utf-8')
else:
messageFields = unitTestData
if debug:
print('DEBUG: POST arriving ' +
msg.get_payload(decode=True).decode('utf-8'))
messageFields = msg.get_payload(decode=True)
messageFields = messageFields.decode('utf-8').split(boundary)
print('DEBUG: POST arriving ' + messageFields)
messageFields = messageFields.split(boundary)
fields = {}
fieldsWithSemicolonAllowed = (
'message', 'bio', 'autoCW', 'password', 'passwordconfirm',
'instanceDescription', 'instanceDescriptionShort',
'subject', 'location', 'imageDescription'
)
# examine each section of the POST, separated by the boundary
for f in messageFields:
if f == '--':
@ -1002,7 +1039,9 @@ def extractTextFieldsInPOST(postBytes, boundary, debug: bool) -> {}:
postKey = postStr.split('"', 1)[0]
postValueStr = postStr.split('"', 1)[1]
if ';' in postValueStr:
continue
if postKey not in fieldsWithSemicolonAllowed and \
not postKey.startswith('edited'):
continue
if '\r\n' not in postValueStr:
continue
postLines = postValueStr.split('\r\n')
@ -1012,5 +1051,37 @@ def extractTextFieldsInPOST(postBytes, boundary, debug: bool) -> {}:
if line > 2:
postValue += '\n'
postValue += postLines[line]
fields[postKey] = urllib.parse.unquote_plus(postValue)
fields[postKey] = urllib.parse.unquote(postValue)
return fields
def limitRepeatedWords(text: str, maxRepeats: int) -> str:
"""Removes words which are repeated many times
"""
words = text.replace('\n', ' ').split(' ')
repeatCtr = 0
repeatedText = ''
replacements = {}
prevWord = ''
for word in words:
if word == prevWord:
repeatCtr += 1
if repeatedText:
repeatedText += ' ' + word
else:
repeatedText = word + ' ' + word
else:
if repeatCtr > maxRepeats:
newText = ((prevWord + ' ') * maxRepeats).strip()
replacements[prevWord] = [repeatedText, newText]
repeatCtr = 0
repeatedText = ''
prevWord = word
if repeatCtr > maxRepeats:
newText = ((prevWord + ' ') * maxRepeats).strip()
replacements[prevWord] = [repeatedText, newText]
for word, item in replacements.items():
text = text.replace(item[0], item[1])
return text

View File

@ -5,6 +5,7 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "Security"
validContexts = (

92
cwtch.py 100644
View File

@ -0,0 +1,92 @@
__filename__ = "cwtch.py"
__author__ = "Bob Mottram"
__license__ = "AGPL3+"
__version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
__module_group__ = "Profile Metadata"
import re
def getCwtchAddress(actorJson: {}) -> str:
"""Returns cwtch address for the given actor
"""
if not actorJson.get('attachment'):
return ''
for propertyValue in actorJson['attachment']:
if not propertyValue.get('name'):
continue
if not propertyValue['name'].lower().startswith('cwtch'):
continue
if not propertyValue.get('type'):
continue
if not propertyValue.get('value'):
continue
if propertyValue['type'] != 'PropertyValue':
continue
propertyValue['value'] = propertyValue['value'].strip()
if len(propertyValue['value']) < 2:
continue
if '"' in propertyValue['value']:
continue
if ' ' in propertyValue['value']:
continue
if ',' in propertyValue['value']:
continue
if '.' in propertyValue['value']:
continue
return propertyValue['value']
return ''
def setCwtchAddress(actorJson: {}, cwtchAddress: str) -> None:
"""Sets an cwtch address for the given actor
"""
notCwtchAddress = False
if len(cwtchAddress) < 56:
notCwtchAddress = True
if cwtchAddress != cwtchAddress.lower():
notCwtchAddress = True
if not re.match("^[a-z0-9]*$", cwtchAddress):
notCwtchAddress = True
if not actorJson.get('attachment'):
actorJson['attachment'] = []
# remove any existing value
propertyFound = None
for propertyValue in actorJson['attachment']:
if not propertyValue.get('name'):
continue
if not propertyValue.get('type'):
continue
if not propertyValue['name'].lower().startswith('cwtch'):
continue
propertyFound = propertyValue
break
if propertyFound:
actorJson['attachment'].remove(propertyFound)
if notCwtchAddress:
return
for propertyValue in actorJson['attachment']:
if not propertyValue.get('name'):
continue
if not propertyValue.get('type'):
continue
if not propertyValue['name'].lower().startswith('cwtch'):
continue
if propertyValue['type'] != 'PropertyValue':
continue
propertyValue['value'] = cwtchAddress
return
newCwtchAddress = {
"name": "Cwtch",
"type": "PropertyValue",
"value": cwtchAddress
}
actorJson['attachment'].append(newCwtchAddress)

4199
daemon.py

File diff suppressed because it is too large Load Diff

9
default_about.md 100644
View File

@ -0,0 +1,9 @@
# About this Instance
### Origin Story
How your instance began.
### Lore
Customs and rituals.
### Epic Tales
Heroic deeds and dastardly foes.

View File

@ -1,13 +0,0 @@
<h1>About this Instance</h1>
<h3>Origin Story</h3>
<p>How your instance began.</p>
<h3>Lore</h3>
<p>Customs and rituals.</p>
<h3>Epic Tales</h3>
<p>Heroic deeds and dastardly foes.</p>

44
default_tos.md 100644
View File

@ -0,0 +1,44 @@
# Terms of Service
### Data Collected
Your username and a hash of your password, any posts you make and a list of accounts which you follow. The admin of the site does not know your password and it is not stored in plaintext anywhere.
There is a quota on the number of posts retained by this instance for each account. Older posts will be removed when the limit is reached. Anything you post here should be considered ephemeral and you should keep a separate personal copy of them if you wish to retain a permanent archive.
No IP addresses are logged.
Posts can be removed on request if there is sufficient justification, but the nature of ActivityPub means that deletion of data federated to other instances cannot be guaranteed.
### Content Policy
This instance will not host content containing sexism, racism, casteism, homophobia, transphobia, misogyny, antisemitism or other forms of bigotry or discrimination on the basis of nationality or immigration status. Claims that transgressions of this type were intended to be "ironic" will be treated as a terms of service violation.
Even if not conspicuously discriminatory, expressions of support for organizations with discrminatory agendas are not permitted on this instance. These include, but are not limited to, racial supremacist groups, the redpill/incel movement and anti-LGBT or anti-immigrant campaigns.
Depictions of injury, death or medical procedures are not permitted.
Violent or abusive content will be subject to moderation and is likely to be removed.
Content of a sexual nature may be published providing that only consenting adults (aged 18 or over) are depicted and an appropriate content warning message is added. Posting sexual content without a content warning is a terms of service violation. Sexual content is defined both as photographs of real people and also artistic or fictional depictions, edited/generated photos or narratives.
Moderators rely upon your reports. Don't assume that something of concern has already been reported. It's better for there to be duplicate reports than for something potentially damaging to go unreported.
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.
### Federation Policy
In a proactive effort to avoid the classic fate of *"embrace, extend, extinguish"* this system will block any instance launched, acquired or funded by Alphabet, Facebook, Twitter, Microsoft, Apple, Amazon, Elsevier or other monopolistic Silicon Valley companies.
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.
### Use of User Generated Content for Research
Data may not be "scraped" or otherwise obtained from this instance and used for academic research or cited within research publications without the prior written permission of the administrator. Financial remedy will be sought through the courts from any researcher publishing data obtained from this instance without consent.
### Commercial Use
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.
Commercial use includes the harvesting of data to create products which are then sold, such as statistics, business reports or machine learning models.
### Copyrights
Epicyon is licensed under [GNU AGPL version 3](https://www.gnu.org/licenses/agpl-3.0-standalone.html)
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)

View File

@ -1,45 +0,0 @@
<h1>Terms of Service</h1>
<h3>Data Collected</h3>
<p>Your username and a hash of your password, any posts you make and a list of accounts which you follow. The admin of the site does not know your password and it is not stored in plaintext anywhere.</p>
<p>There is a quota on the number of posts retained by this instance for each account. Older posts will be removed when the limit is reached. Anything you post here should be considered ephemeral and you should keep a separate personal copy of them if you wish to retain a permanent archive.</p>
<p>No IP addresses are logged.</p>
<p>Posts can be removed on request if there is sufficient justification, but the nature of ActivityPub means that deletion of data federated to other instances cannot be guaranteed.</p>
<h3>Content Policy</h3>
<p>This instance will not host content containing sexism, racism, casteism, homophobia, transphobia, misogyny, antisemitism or other forms of bigotry or discrimination on the basis of nationality or immigration status. Claims that transgressions of this type were intended to be "ironic" will be treated as a terms of service violation.</p>
<p>Violent or abusive content will be subject to moderation and is likely to be removed.</p>
<p>Content of a sexual nature may be published providing that only consenting adults (aged 18 or over) are depicted and an appropriate content warning message is added. Posting sexual content without a content warning is a terms of service violation. Sexual content is defined both as photographs of real people and also artistic or fictional depictions, edited/generated photos or narratives.</p>
<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>Use of User Generated Content for Research</h3>
<p>Data may not be "scraped" or otherwise obtained from this instance and used for academic research or cited within research publications without the prior written permission of the administrator. Financial remedy will be sought through the courts from any researcher publishing data obtained from this instance without consent.</p>
<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>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>

File diff suppressed because one or more lines are too long

16
defaultthemes.txt 100644
View File

@ -0,0 +1,16 @@
Blue
Debian
Default
Hacker
Henge
Indymediaclassic
Indymediamodern
Lcd
Light
Night
Pixel
Purple
Rc3
Solidaric
Starlight
Zen

View File

@ -0,0 +1,18 @@
### تهانينا!
أنت الآن جاهز لبدء استخدام Epicyon. هذه مساحة اجتماعية خاضعة للإشراف ، لذا يرجى التأكد من الالتزام بـ [شروط الخدمة](/terms) الخاصة بنا ، واستمتع.
#### تلميحات
استخدم رمز **المكبر** 🔍 للبحث عن مقابض الكون المشترك ومتابعة الأشخاص.
يؤدي تحديد **الشعار في الجزء العلوي** من الشاشة إلى التبديل بين عرض المخطط الزمني وملف التعريف الخاص بك.
لن يتم تحديث الشاشة تلقائيًا عند وصول المنشورات ، لذا استخدم **F5** أو زر البريد الوارد للتحديث.
#### طقوس المرور
تدربك ثقافة الشركة على الرغبة في الحصول على أكبر عدد من المتابعين والإعجابات - للبحث عن الشهرة الشخصية والتفاعلات السطحية التي تثير الغضب لجذب الانتباه.
لذلك إذا كنت قادمًا من تلك الثقافة ، فيرجى العلم أن هذا نوع مختلف من النظام مع مجموعة مختلفة جدًا من التوقعات.
ليس من الضروري وجود الكثير من المتابعين ، وغالبًا ما يكون غير مرغوب فيه. قد يحظرك الناس ، ولا بأس بذلك. لا أحد لديه الحق في جمهور. إذا قام شخص ما بحظرك فأنت لا تخضع للرقابة. يمارس الناس فقط حريتهم في الارتباط بمن يرغبون فيه.
من المتوقع أن تكون معايير السلوك الشخصي أفضل مما هي عليه في أنظمة الشركات. سلوكك له أيضًا عواقب على سمعة هذه الحالة. إذا كنت تتصرف بطريقة متهورة تتعارض مع شروط الخدمة ، فقد يتم تعليق حسابك أو إزالته.

View File

@ -0,0 +1,18 @@
### Enhorabona!
Ja esteu a punt per començar a utilitzar Epicyon. Aquest és un espai social moderat, així que assegureu-vos de complir les nostres [condicions del servei](/terms) i divertir-vos.
#### Consells
Utilitzeu la icona de **lupa** 🔍 per cercar manetes fedivers i seguir les persones.
Si seleccioneu el **bàner a la part superior** de la pantalla es canvia entre la visualització de la línia de temps i el vostre perfil.
La pantalla no s'actualitzarà automàticament quan arribin les publicacions, així que utilitzeu **F5** o el botó **Safata d'entrada** per actualitzar.
#### Ritu de pas
La cultura corporativa us capacita per desitjar el màxim nombre de seguidors i gustos: per buscar fama personal i interaccions poc profundes i indignants per cridar l'atenció.
Per tant, si proveniu daquesta cultura, tingueu en compte que es tracta dun tipus de sistema diferent amb un conjunt dexpectatives molt diferents.
No és necessari tenir molts seguidors i sovint no és desitjable. És possible que la gent us bloquegi i això està bé. Ningú no té dret a un públic. Si algú et bloqueja, no et censuraran. La gent només exerceix la seva llibertat per associar-se amb qui vulgui.
S'espera que els estàndards de comportament personal siguin millors que en els sistemes corporatius. El vostre comportament també té conseqüències per a la reputació d'aquesta instància. Si us comporteu de manera desmesurada que va en contra de les condicions del servei, el vostre compte es pot suspendre o eliminar.

View File

@ -0,0 +1,18 @@
### Llongyfarchiadau!
Rydych nawr yn barod i ddechrau defnyddio Epicyon. Mae hwn yn ofod cymdeithasol wedi'i gymedroli, felly gwnewch yn siŵr eich bod yn cadw at ein [telerau gwasanaeth](/terms), a chael hwyl.
#### Awgrymiadau
Defnyddiwch yr eicon **chwyddwydr** 🔍 i chwilio am ddolenni bwydo a dilyn pobl.
Mae dewis y faner **ar frig** y sgrin yn newid rhwng yr olygfa llinell amser a'ch proffil.
Ni fydd y sgrin yn adnewyddu'n awtomatig pan fydd pyst yn cyrraedd, felly defnyddiwch **F5** neu'r botwm **Mewnflwch** i adnewyddu.
#### Defod y Tocyn
Mae diwylliant corfforaethol yn eich hyfforddi i fod eisiau'r nifer uchaf o ddilynwyr a hoff bethau - i geisio enwogrwydd personol a rhyngweithio bas, sy'n achosi dicter, i fachu sylw.
Felly os ydych chi'n dod o'r diwylliant hwnnw, byddwch yn ymwybodol bod hon yn fath wahanol o system gyda set wahanol iawn o ddisgwyliadau.
Nid oes angen cael llawer o ddilynwyr, ac yn aml mae'n annymunol. Efallai y bydd pobl yn eich rhwystro chi, ac mae hynny'n iawn. Nid oes gan neb hawl i gynulleidfa. Os bydd rhywun yn eich blocio yna nid ydych chi'n cael eich sensro. Mae pobl yn arfer eu rhyddid i gysylltu â phwy bynnag maen nhw'n dymuno.
Disgwylir i safonau ymddygiad personol fod yn well nag yn y systemau corfforaethol. Mae gan eich ymddygiad ganlyniadau i enw da'r achos hwn hefyd. Os ydych chi'n ymddwyn mewn modd anystyriol sy'n mynd yn groes i'r telerau gwasanaeth yna gellir atal neu ddileu eich cyfrif.

View File

@ -0,0 +1,18 @@
### Congratulations!
You are now ready to begin using Epicyon. This is a moderated social space, so please make sure to abide by our [terms of service](/terms), and have fun.
#### Hints
Use the **magnifier** icon 🔍 to search for fediverse handles and follow people.
Selecting the **banner at the top** of the screen switches between timeline view and your profile.
The screen will not automatically refresh when posts arrive, so use **F5** or the **Inbox** button to refresh.
#### Rite of Passage
Corporate culture trains you to want the maximum number of followers and likes - to seek personal fame and shallow, outrage-inducing interactions to grab attention.
So if you are coming from that culture, please be aware that this is a different type of system with a very different set of expectations.
Having a lot of followers is not necessary, and often it's undesirable. People may block you, and that's ok. Nobody has a right to an audience. If someone blocks you then you're not being censored. People are just exercising their freedom to associate with whoever they wish.
Standards of personal behavior are expected to be better than in the corporate systems. Your behavior also has consequences for the reputation of this instance. If you behave in an inconsiderate manner which goes against the terms of service then your account may be suspended or removed.

View File

@ -0,0 +1,18 @@
### ¡Felicidades!
Ahora está listo para comenzar a usar Epicyon. Este es un espacio social moderado, así que asegúrese de cumplir con nuestros [términos de servicio](/terms) y diviértase.
#### Sugerencias
Utilice el icono de **lupa** 🔍 para buscar identificadores de fediverse y seguir a las personas.
Al seleccionar el **banner en la parte superior** de la pantalla, se cambia entre la vista de línea de tiempo y su perfil.
La pantalla no se actualizará automáticamente cuando lleguen las publicaciones, así que use **F5** o el botón **Bandeja de entrada** para actualizar.
#### Rito de paso
La cultura corporativa te entrena para querer el máximo número de seguidores y me gusta, para buscar fama personal e interacciones superficiales e indignantes para llamar la atención.
Entonces, si viene de esa cultura, tenga en cuenta que este es un tipo diferente de sistema con un conjunto de expectativas muy diferente.
No es necesario tener muchos seguidores y, a menudo, no es deseable. Es posible que la gente te bloquee, y eso está bien. Nadie tiene derecho a una audiencia. Si alguien te bloquea, no estás siendo censurado. La gente simplemente está ejerciendo su libertad para asociarse con quien quiera.
Se espera que los estándares de comportamiento personal sean mejores que los de los sistemas corporativos. Su comportamiento también tiene consecuencias para la reputación de esta instancia. Si se comporta de manera desconsiderada que va en contra de los términos de servicio, su cuenta puede ser suspendida o eliminada.

View File

@ -0,0 +1,18 @@
### Toutes nos félicitations!
Vous êtes maintenant prêt à commencer à utiliser Epicyon. Il s'agit d'un espace social modéré, alors assurez-vous de respecter nos [conditions d'utilisation](/terms) et amusez-vous.
#### Conseils
Utilisez l'icône **loupe** 🔍 pour rechercher des poignées fediverse et suivre les gens.
La sélection de la **bannière en haut** de l'écran bascule entre la vue chronologique et votre profil.
L'écran ne s'actualisera pas automatiquement à l'arrivée des messages, utilisez donc **F5** ou le bouton **Boîte de réception** pour actualiser.
#### Rite de passage
La culture d'entreprise vous entraîne à vouloir le maximum de followers et de likes - à rechercher une renommée personnelle et des interactions superficielles et indignées pour attirer l'attention.
Donc, si vous venez de cette culture, sachez qu'il s'agit d'un type de système différent avec un ensemble d'attentes très différent.
Avoir beaucoup d'adeptes n'est pas nécessaire et souvent indésirable. Les gens peuvent vous bloquer, et ce n'est pas grave. Personne n'a droit à une audience. Si quelqu'un vous bloque, vous n'êtes pas censuré. Les gens exercent simplement leur liberté de s'associer avec qui ils veulent.
On s'attend à ce que les normes de comportement personnel soient meilleures que dans les systèmes d'entreprise. Votre comportement a également des conséquences sur la réputation de cette instance. Si vous vous comportez d'une manière inconsidérée qui va à l'encontre des conditions d'utilisation, votre compte peut être suspendu ou supprimé.

View File

@ -0,0 +1,18 @@
### Comhghairdeas!
Tá tú réidh anois chun Epicyon a úsáid. Is spás sóisialta measartha é seo, mar sin déan cinnte cloí lenár [dtéarmaí seirbhíse](/terms), agus spraoi a bheith agat.
#### Leideanna
Úsáid an deilbhín **formhéadaitheoir** chun cuardach a dhéanamh ar láimhseálacha beathaithe agus lean daoine.
Ag roghnú an bhratach **ag barr** na lasca scáileáin idir amharc amlíne agus do phróifíl.
Ní dhéanfaidh an scáileán athnuachan go huathoibríoch nuair a thiocfaidh na poist, mar sin bain úsáid as **F5** nó an cnaipe **Bosca Isteach** chun athnuachan a dhéanamh.
#### Deasghnáth an Phasáiste
Cuireann an cultúr corparáideach oiliúint ort go dteastaíonn uait an líon is mó leantóirí agus a leithéidí - clú agus cáil phearsanta agus idirghníomhaíochtaí éadomhain, spreagtha a lorg chun aird a tharraingt.
Mar sin má tá tú ag teacht ón gcultúr sin, bí ar an eolas gur cineál difriúil córais é seo le tacar ionchais an-difriúil.
Ní gá go leor leantóirí a bheith agat, agus go minic bíonn sé neamh-inmhianaithe. Féadfaidh daoine bac a chur ort, agus tá sé sin ceart go leor. Níl sé de cheart ag aon duine lucht féachana a fháil. Má chuireann duine bac ort níl cinsireacht á dhéanamh ort. Níl ach a saoirse á fheidhmiú ag daoine chun caidreamh a dhéanamh le cibé duine is mian leo.
Meastar go mbeidh caighdeáin iompraíochta pearsanta níos fearr ná sna córais chorparáideacha. Tá iarmhairtí ag diompar freisin ar cháil an cháis seo. Má iompraíonn tú ar bhealach neamhfhreagrach a théann i gcoinne na dtéarmaí seirbhíse ansin féadfar do chuntas a chur ar fionraí nó a bhaint.

View File

@ -0,0 +1,18 @@
### बधाई हो!
अब आप एपिसकॉन का उपयोग शुरू करने के लिए तैयार हैं। यह एक मध्यम सामाजिक स्थान है, इसलिए कृपया हमारी [सेवा की शर्तों](/terms) का पालन करना सुनिश्चित करें, और मज़े करें।
#### संकेत
फ़ेडरिवर्स हैंडल की खोज करने और लोगों का अनुसरण करने के लिए **आवर्धक आइकन** का उपयोग करें।
समय दृश्य और आपकी प्रोफ़ाइल के बीच स्क्रीन स्विच के शीर्ष **पर स्थित** बैनर का चयन करना।
पोस्ट आने पर स्क्रीन अपने आप रिफ्रेश नहीं होगी, इसलिए रीफ्रेश करने के लिए **F5** या **इनबॉक्स** बटन का उपयोग करें।
#### यादगार घटना
कॉरपोरेट कल्चर आपको अधिक से अधिक संख्या में अनुयायियों और पसंदों को प्राप्त करने के लिए प्रशिक्षित करता है - ध्यान आकर्षित करने के लिए व्यक्तिगत प्रसिद्धि और उथले, नाराजगी-उत्प्रेरण बातचीत।
इसलिए यदि आप उस संस्कृति से आ रहे हैं, तो कृपया ध्यान रखें कि यह एक अलग प्रकार की प्रणाली है जिसमें बहुत अलग अपेक्षाएं हैं।
बहुत सारे अनुयायी होना आवश्यक नहीं है, और अक्सर यह अवांछनीय है। लोग आपको ब्लॉक कर सकते हैं, और यह ठीक है। किसी को भी एक दर्शक का अधिकार नहीं है। अगर कोई आपको ब्लॉक करता है तो आपको सेंसर नहीं किया जा रहा है। लोग बस अपनी स्वतंत्रता का प्रयोग कर रहे हैं कि वे जो चाहें करें।
व्यक्तिगत व्यवहार के मानक कॉर्पोरेट सिस्टम की तुलना में बेहतर होने की उम्मीद है। इस उदाहरण की प्रतिष्ठा के लिए आपके व्यवहार के परिणाम भी हैं। यदि आप एक असंगत तरीके से व्यवहार करते हैं जो सेवा की शर्तों के खिलाफ जाता है तो आपका खाता निलंबित या हटाया जा सकता है।

View File

@ -0,0 +1,18 @@
### Congratulazioni!
Ora sei pronto per iniziare a utilizzare Epicyon. Questo è uno spazio social moderato, quindi assicurati di rispettare i nostri [termini di servizio](/terms) e divertiti.
#### Suggerimenti
Usa l'icona **lente d'ingrandimento** 🔍 per cercare gli handle di fediverse e seguire le persone.
Selezionando il **banner nella parte superiore** dello schermo si passa dalla visualizzazione della sequenza temporale al tuo profilo.
La schermata non si aggiornerà automaticamente all'arrivo dei post, quindi utilizza **F5** o il pulsante **Posta in arrivo** per aggiornare.
#### Rito di passaggio
La cultura aziendale ti insegna a desiderare il numero massimo di follower e Mi piace, a cercare la fama personale e interazioni superficiali e indignate per attirare l'attenzione.
Quindi, se vieni da quella cultura, tieni presente che questo è un tipo diverso di sistema con un insieme di aspettative molto diverso.
Avere molti follower non è necessario e spesso è indesiderabile. Le persone potrebbero bloccarti, e va bene. Nessuno ha diritto a un pubblico. Se qualcuno ti blocca, non verrai censurato. Le persone stanno solo esercitando la loro libertà di associarsi con chi desiderano.
Gli standard di comportamento personale dovrebbero essere migliori rispetto ai sistemi aziendali. Il tuo comportamento ha anche conseguenze sulla reputazione di questa istanza. Se ti comporti in modo sconsiderato che va contro i termini di servizio, il tuo account potrebbe essere sospeso o rimosso.

View File

@ -0,0 +1,18 @@
### おめでとう!
これで、Epicyonの使用を開始する準備が整いました。 適度な社交空間ですので、必ず [利用規約](/terms) を遵守して楽しんでください。
#### ヒント
**拡大鏡** アイコン🔍を使用して、fediverseハンドルを検索し、人々をフォローします。
画面の上部にある **バナー** を選択すると、タイムラインビューとプロファイルが切り替わります。
投稿が到着しても画面は自動的に更新されないため、 **F5** または **受信トレイ** ボタンを使用して更新してください。
#### 通過儀礼
企業文化は、最大数のフォロワーや好きな人を求め、個人的な名声と浅い、怒りを誘発する相互作用を求めて注目を集めるように訓練します。
したがって、その文化から来ている場合、これは非常に異なる一連の期待を持つ異なるタイプのシステムであることに注意してください。
多くのフォロワーを持つ必要はなく、多くの場合、それは望ましくありません。 人々があなたをブロックするかもしれません、そしてそれは大丈夫です。 誰も聴衆に対する権利を持っていません。 誰かがあなたをブロックした場合、あなたは検閲されていません。 人々は、彼らが望む誰とでも交際する自由を行使しているだけです。
個人の行動基準は、企業システムよりも優れていると期待されています。 あなたの行動は、このインスタンスの評判にも影響を及ぼします。 利用規約に違反する軽率な行動をとった場合、アカウントが停止または削除される可能性があります。

View File

@ -0,0 +1,18 @@
### Pîrozbahî!
Hûn niha amade ne ku dest bi karanîna Epicyon bikin. Ev cîhek civakî ya nermkerî ye, ji kerema xwe ji kerema xwe pabendî [mercên karûbarê me](/terms) bin, û kêf bikin.
#### intsîretan
**îkona** ziravker 🔍 bikar bînin da ku li destanên federatê bigerin û mirovan bişopînin.
Hilbijartina **pankarta li jor** a switches-ekranê di navbera dîtina demjimêr û profîla we de.
Dema ku şande tên dîmender dê bixweber nûve nebe, ji ber vê yekê **F5** an bişkoja **Inbox** ji bo nûvekirinê bikar bînin.
#### Rêûresma Derbasbûnê
Çanda pargîdaniyê we perwerde dike ku hûn jimara herî zêde şopîner û hezên xwe bixwazin - li navdariyek kesane û têkiliyên kûr, hêrs-lêgerîn digerin da ku balê bikişînin.
Ji ber vê yekê heke hûn ji wê çandê têne, ji kerema xwe hay ji xwe hebin ku ev pergalek celebek cuda ye ku bi bendewariyek pir cûda ye.
Hebûna gelek şagirtan ne hewce ye, û pir caran ew nexwaze. Mirov dikare we bloke bike, û ew baş e. Mafê kesî tune ku guhdar bike. Ger kesek we bloke bike wê hingê hûn nayên sansur kirin. Mirov tenê azadiya xwe ya ku bi kî bixwaze re têkildar dibe bikar tîne.
Tê payîn ku standardên tevgera kesane ji pergalên pargîdaniyê çêtir in. Reftara we ji bo navûdengê vê nimûneyê jî encam dide. Heke hûn bi rengek bêhesib tevdigerin ku li dijî şertên karûbarê ye wê hingê dibe ku hesabê we were sekinandin an rakirin.

View File

@ -0,0 +1,18 @@
# Congratulations!
You are now ready to begin using Epicyon. This is a moderated social space, so please make sure to abide by our [terms of service](/terms), and have fun.
### Hints
Use the **magnifier** icon 🔍 to search for fediverse handles and follow people.
Selecting the **banner at the top** of the screen switches between timeline view and your profile.
The screen will not automatically refresh when posts arrive, so use **F5** or the **Inbox** button to refresh.
#### Rite of Passage
Corporate culture trains you to want the maximum number of followers and likes - to seek personal fame and shallow, outrage-inducing interactions to grab attention.
So if you are coming from that culture, please be aware that this is a different type of system with a very different set of expectations.
Having a lot of followers is not necessary, and often it's undesirable. People may block you, and that's ok. Nobody has a right to an audience. If someone blocks you then you're not being censored. People are just exercising their freedom to associate with whoever they wish.
Standards of personal behavior are expected to be better than in the corporate systems. Your behavior also has consequences for the reputation of this instance. If you behave in an inconsiderate manner which goes against the terms of service then your account may be suspended or removed.

View File

@ -0,0 +1,18 @@
# Parabéns!
Agora você está pronto para começar a usar o Epicyon. Este é um espaço social moderado, portanto, certifique-se de seguir nossos [termos de serviço](/terms) e divirta-se.
### Dicas
Use o ícone de **lupa** 🔍 para pesquisar as alças do fediverse e seguir pessoas.
Selecionar o **banner na parte superior** da tela alterna entre a visualização da linha do tempo e seu perfil.
A tela não será atualizada automaticamente quando as postagens chegarem, então use **F5** ou o botão **Caixa de entrada** para atualizar.
#### Rito de passagem
A cultura corporativa treina você a querer o número máximo de seguidores e curtidas - a buscar fama pessoal e interações superficiais que induzem à indignação para chamar a atenção.
Portanto, se você vem dessa cultura, saiba que esse é um tipo diferente de sistema, com um conjunto de expectativas muito diferente.
Não é necessário ter muitos seguidores e, muitas vezes, é indesejável. As pessoas podem bloquear você, e tudo bem. Ninguém tem direito a audiência. Se alguém bloqueia você, você não está sendo censurado. As pessoas estão apenas exercendo sua liberdade de se associar com quem quiserem.
Espera-se que os padrões de comportamento pessoal sejam melhores do que os sistemas corporativos. Seu comportamento também tem consequências para a reputação desta instância. Se você se comportar de maneira imprudente que vá contra os termos de serviço, sua conta poderá ser suspensa ou removida.

View File

@ -0,0 +1,18 @@
### Поздравляю!
Теперь вы готовы начать использовать Epicyon. Это модерируемое социальное пространство, поэтому, пожалуйста, соблюдайте наши [условия обслуживания](/terms) и получайте удовольствие.
#### Подсказки
Используйте значок **лупы** 🔍, чтобы искать нужные метки и следить за людьми.
При выборе **баннера вверху** экрана выполняется переключение между представлением временной шкалы и вашим профилем.
Экран не обновляется автоматически при поступлении сообщений, поэтому используйте **F5** или кнопку **Входящие** для обновления.
#### Обряд посвящения
Корпоративная культура учит вас стремиться к максимальному количеству подписчиков и лайков - стремиться к личной славе и поверхностным, вызывающим возмущение взаимодействиям, чтобы привлечь внимание.
Так что, если вы происходите из этой культуры, имейте в виду, что это другой тип системы с совершенно другим набором ожиданий.
Не обязательно иметь много подписчиков, а зачастую и нежелательно. Люди могут заблокировать вас, и это нормально. Никто не имеет права на аудиенцию. Если кто-то вас блокирует, значит, вы не подвергаетесь цензуре. Люди просто пользуются своей свободой общаться с кем хотят.
Ожидается, что стандарты личного поведения будут лучше, чем в корпоративных системах. Ваше поведение также влияет на репутацию этого экземпляра. Если вы ведете себя невнимательно, что противоречит условиям обслуживания, ваша учетная запись может быть приостановлена или удалена.

View File

@ -0,0 +1,18 @@
### Hongera!
Sasa uko tayari kuanza kutumia epicyon. Hii ni nafasi ya kijamii iliyopangwa, hivyo tafadhali hakikisha kuzingatia [masharti yetu ya huduma](/terms), na ufurahi.
#### Vidokezo
Tumia **icon ya magnifier** 🔍 kutafuta vitu vinavyohusika na kufuata watu.
Kuchagua **bendera juu** ya swichi screen kati ya mtazamo wa timeline na wasifu wako.
Screen haitafurahisha moja kwa moja wakati machapisho yanapofika, kwa hiyo tumia **F5** au kifungo cha **Kikasha** ili upate upya.
#### Ibada ya kifungu
Utamaduni wa utamaduni unawafundisha idadi kubwa ya wafuasi na kupenda - kutafuta umaarufu wa kibinafsi na uingiliano usiojulikana, uingizaji wa chuki ili kunyakua.
Kwa hiyo ikiwa unakuja kutoka kwa utamaduni huo, tafadhali tahadhari kuwa hii ni aina tofauti ya mfumo na seti tofauti ya matarajio.
Kuwa na wafuasi wengi sio lazima, na mara nyingi haifai. Watu wanaweza kukuzuia, na hiyo ni sawa. Hakuna mtu anaye haki ya watazamaji. Ikiwa mtu anakuzuia basi huna kuzingatiwa. Watu wanatumia tu uhuru wao wa kushirikiana na yeyote anayetaka.
Viwango vya tabia ya kibinafsi vinatarajiwa kuwa bora kuliko katika mifumo ya ushirika. Tabia yako pia ina matokeo ya sifa ya mfano huu. Ikiwa unafanya kwa njia isiyo ya kawaida ambayo inapingana na Masharti ya Huduma basi akaunti yako inaweza kusimamishwa au kuondolewa.

View File

@ -0,0 +1,18 @@
### 恭喜你!
您现在可以开始使用Epicyon。 这是一个温和的社交空间,因此请务必遵守我们的[服务条款](/terms),并从中获得乐趣。
####提示
使用放大镜图标search搜索fed性的手柄并关注他人。
选择屏幕顶部的横幅广告可在时间轴视图和个人资料之间切换。
帖子到达时屏幕不会自动刷新因此请使用F5或“收件箱”按钮刷新。
#### 通行礼
企业文化训练您想要最大数量的追随者和喜欢的人-寻求个人名望和肤浅,激怒的互动来吸引注意力。
因此,如果您来自这种文化,请注意,这是另一种类型的系统,具有不同的期望值。
拥有大量的追随者不是必需的,而且通常是不可取的。 人们可能会阻止您,没关系。 没有人有听众的权利。 如果有人阻止了您,那么您将不会受到审查。 人们只是在行使与任何希望的人交往的自由。
个人行为标准有望比公司系统更好。 您的行为也会对该实例的声誉产生影响。 如果您的行为举止粗鲁,违反了服务条款,那么您的帐户可能会被暂停或删除。

View File

@ -0,0 +1,3 @@
ستظهر الرسائل المباشرة هنا ، كجدول زمني زمني.
لتجنب البريد العشوائي وتحسين الأمان ، ستتمكن افتراضيًا فقط من تلقي الرسائل المباشرة من الأشخاص الذين تتابعهم. يمكنك إيقاف تشغيل هذا ضمن إعدادات ملف التعريف الخاص بك إذا كنت بحاجة إلى ذلك ، عن طريق تحديد الشعار العلوي ثم أيقونة التحرير.

View File

@ -0,0 +1,3 @@
Els missatges directes apareixeran aquí com a cronologia cronològica.
Per evitar el correu brossa i millorar la seguretat, de manera predeterminada només podreu rebre missatges directes *de persones que seguiu*. Podeu desactivar aquesta opció a la configuració del vostre perfil, si cal, seleccionant el **bàner superior** i, a continuació, la icona **edita**.

View File

@ -0,0 +1,3 @@
Bydd negeseuon uniongyrchol yn ymddangos yma, fel llinell amser gronolegol.
Er mwyn osgoi sbam a gwella diogelwch, yn ddiofyn dim ond gan bobl rydych chi'n eu dilyn y byddwch chi'n gallu derbyn negeseuon uniongyrchol. Gallwch chi ddiffodd hwn o fewn eich gosodiadau proffil os oes angen, trwy ddewis y faner **uchaf** ac yna'r eicon **golygu**.

View File

@ -0,0 +1,3 @@
Direktnachrichten werden hier als chronologische Zeitleiste angezeigt.
Um Spam zu vermeiden und die Sicherheit zu verbessern, können Sie standardmäßig nur Direktnachrichten *von Personen empfangen, denen Sie folgen*. Sie können dies bei Bedarf in Ihren Profileinstellungen deaktivieren, indem Sie das oberste **Banner** und dann das **Bearbeitungssymbol** auswählen.

View File

@ -0,0 +1,3 @@
Direct messages will appear here, as a chronological timeline.
To avoid spam and improve security, by default you will only be able to receive direct messages *from people that you're following*. You can turn this off within your profile settings if you need to, by selecting the top **banner** and then the **edit** icon.

View File

@ -0,0 +1,3 @@
Los mensajes directos aparecerán aquí, como una línea de tiempo cronológica.
Para evitar el spam y mejorar la seguridad, de forma predeterminada, solo podrá recibir mensajes directos *de las personas a las que sigue*. Puede desactivar esto dentro de la configuración de su perfil si es necesario, seleccionando el **banner** superior y luego el icono **editar**.

View File

@ -0,0 +1,3 @@
Les messages directs apparaîtront ici, sous forme de chronologie.
Pour éviter les spams et améliorer la sécurité, vous ne pourrez par défaut recevoir que des messages directs *des personnes que vous suivez*. Vous pouvez désactiver cette option dans les paramètres de votre profil si nécessaire, en sélectionnant la **bannière** supérieure, puis l'icône **modifier**.

View File

@ -0,0 +1,3 @@
Beidh teachtaireachtaí díreacha le feiceáil anseo, mar amlíne croineolaíoch.
Chun spam a sheachaint agus slándáil a fheabhsú, de réir réamhshocraithe ní bheidh tú in ann ach teachtaireachtaí díreacha *a fháil ó dhaoine atá á leanúint agat*. Is féidir leat é seo a mhúchadh laistigh de do shocruithe próifíle más gá duit, tríd an mbratach **barr** a roghnú agus ansin an deilbhín **edit**.

View File

@ -0,0 +1,3 @@
प्रत्यक्ष संदेश यहां कालानुक्रमिक समय के रूप में दिखाई देंगे।
स्पैम से बचने और सुरक्षा में सुधार करने के लिए, डिफ़ॉल्ट रूप से आप केवल उन लोगों से सीधे संदेश प्राप्त कर सकेंगे जो आप का अनुसरण कर रहे हैं। आप अपनी प्रोफ़ाइल सेटिंग्स के भीतर इसे बंद कर सकते हैं, अगर आपको ज़रूरत है, तो शीर्ष **बैनर** और फिर **संपादन** आइकन का चयन करके।

View File

@ -0,0 +1,3 @@
Los mensajes directos aparecerán aquí, como una línea de tiempo cronológica.
Para evitar el spam y mejorar la seguridad, de forma predeterminada, solo podrá recibir mensajes directos *de las personas a las que sigue*. Puede desactivar esto dentro de la configuración de su perfil si es necesario, seleccionando el **banner** superior y luego el icono **editar**.

View File

@ -0,0 +1,3 @@
ダイレクトメッセージは、時系列のタイムラインとしてここに表示されます。
スパムを回避し、セキュリティを向上させるために、デフォルトでは、フォローしているユーザーからの直接メッセージのみを受信できます。 必要に応じて、上部のバナーを選択してから編集アイコンを選択することにより、プロファイル設定内でこれをオフにすることができます。

View File

@ -0,0 +1,3 @@
Dê peyamên rasterast li vir, wekî demek kronolojîk, xuya bibin.
Ji bo ku xwe ji spamê nehêlin û ewlehiyê baştir bikin, bi default hûn ê tenê karibin peyamên rasterast ji kesên ku hûn dişopînin bistînin. Heke hûn hewce ne, hûn dikarin vê yekê di nav vesazên profîla xwe de vemirînin, bi hilbijartina jor **banner** û dûv re îkona **edit**.

View File

@ -0,0 +1,3 @@
As mensagens diretas aparecerão aqui, como uma linha do tempo cronológica.
Para evitar spam e melhorar a segurança, por padrão, você só poderá receber mensagens diretas *das pessoas que está seguindo*. Você pode desativar isso nas configurações de seu perfil, se necessário, selecionando o **banner** superior e, em seguida, o ícone **editar**.

View File

@ -0,0 +1,3 @@
Личные сообщения будут отображаться здесь в хронологическом порядке.
Чтобы избежать спама и повысить безопасность, по умолчанию вы сможете получать прямые сообщения только от людей, на которых вы подписаны. Вы можете отключить это в настройках своего профиля, если вам нужно, выбрав верхний **баннер**, а затем значок **изменить**.

View File

@ -0,0 +1,3 @@
Ujumbe wa moja kwa moja utaonekana hapa, kama ratiba ya muda.
Ili kuepuka spam na kuboresha usalama, kwa default utakuwa na uwezo wa kupokea ujumbe wa moja kwa moja *kutoka kwa watu ambao unafuata*. Unaweza kuzima hii ndani ya mipangilio yako ya wasifu ikiwa unahitaji, kwa kuchagua bendera ya juu na kisha **icon ya hariri**.

View File

@ -0,0 +1,3 @@
直接消息将按时间顺序显示在此处。
为了避免垃圾邮件并提高安全性,默认情况下,您只能接收来自您所关注人员的直接消息。 您可以根据需要在个人资料设置中将其关闭,方法是选择顶部横幅,然后选择编辑图标。

View File

@ -0,0 +1,19 @@
ستظهر المشاركات الواردة هنا كجدول زمني زمني. إذا قمت بإرسال أي منشورات فسوف تظهر هنا أيضًا.
### اللافتة العلوية
في الجزء العلوي من الشاشة ، يمكنك تحديد الشعار للتبديل إلى ملف التعريف الخاص بك وتحريره أو تسجيل الخروج.
### أزرار وأيقونات الخط الزمني
تسمح لك الأزرار الموجودة أسفل الشعار العلوي بتحديد خطوط زمنية مختلفة. توجد أيضًا رموز على اليمين للبحث أو عرض التقويم الخاص بك أو إنشاء منشورات جديدة.
تتيح أيقونة إظهار / إخفاء عرض المزيد من أزرار المخطط الزمني ، إلى جانب عناصر تحكم الوسيط.
### العمود الأيسر
هنا يمكنك إضافة روابط مفيدة. يظهر هذا فقط على شاشات سطح المكتب أو الأجهزة ذات الشاشات الأكبر حجمًا. إنه مشابه لقائمة المدونات. يمكنك فقط إضافة الروابط أو تعديلها إذا كان لديك دور مسؤول أو محرر.
إذا كنت تستخدم الهاتف المحمول ، فاستخدم رمز الروابط في الأعلى لقراءة الأخبار.
### العمود الأيمن
يمكن إضافة موجز ويب لـ RSS في العمود الأيمن ، المعروف باسم newswire. يظهر هذا فقط على شاشات سطح المكتب أو الأجهزة ذات الشاشات الأكبر حجمًا. يمكنك فقط إضافة أو تحرير الخلاصات إذا كان لديك دور مسؤول أو محرر ، ويمكن أيضًا الإشراف على عناصر الخلاصة الواردة.
إذا كنت تستخدم الهاتف المحمول ، فاستخدم رمز الأخبار في الأعلى لقراءة الأخبار.

View File

@ -0,0 +1,19 @@
Les publicacions entrants apareixeran aquí, com a cronologia cronològica. Si envieu missatges, també apareixeran aquí.
### El bàner superior
A la part superior de la pantalla, podeu seleccionar el **bàner** per canviar al vostre perfil, editar-lo o tancar la sessió.
### Botons i icones de la cronologia
Els **botons** que hi ha a sota del bàner superior us permeten seleccionar diferents terminis. També hi ha **icones** a la dreta per **cercar**, veure el vostre **calendari** o crear **noves publicacions**.
La icona **mostra/amaga** permet mostrar més botons de cronologia, juntament amb controls de moderador.
### Columna esquerra
Aquí podeu afegir **enllaços útils**. Això només apareix a les pantalles d'escriptori o als dispositius amb pantalles més grans. És similar a un *blogroll*. Només podeu afegir o editar enllaços si teniu un rol dadministrador **o deditor**.
Si esteu al mòbil, feu servir la icona denllaços a la part superior per llegir les notícies.
### Columna dreta
Els canals RSS es poden afegir a la columna de la dreta, coneguda com a *newswire*. Això només apareix en pantalles d'escriptori o dispositius amb pantalles més grans. Només podeu afegir o editar feeds si teniu un rol dadministrador **o deditor** i també es poden moderar els elements de feeds entrants.
Si esteu al mòbil, utilitzeu la **icona de newswire** a la part superior per llegir les notícies.

View File

@ -0,0 +1,19 @@
Bydd swyddi sy'n dod i mewn yn ymddangos yma, fel llinell amser gronolegol. Os anfonwch unrhyw bostiadau byddant hefyd yn ymddangos yma.
### Y faner uchaf
Ar ben y sgrin gallwch ddewis y **faner** i'w newid i'ch proffil, a'i golygu neu allgofnodi.
### Botymau ac eiconau llinell amser
Mae'r botymau o dan y faner uchaf yn caniatáu ichi ddewis gwahanol linellau amser. Mae yna hefyd **eiconau** ar y dde i **chwilio**, gweld eich **calendr** neu greu **postiadau newydd**.
Mae'r eicon **dangos/cuddio** yn caniatáu dangos mwy o fotymau llinell amser, ynghyd â rheolyddion safonwr.
### Colofn chwith
Yma gallwch ychwanegu **dolenni defnyddiol**. Dim ond ar arddangosfeydd bwrdd gwaith neu ddyfeisiau sydd â sgriniau mwy y mae hyn yn ymddangos. Mae'n debyg i * blogroll *. Dim ond os oes gennych rôl **gweinyddwr** neu **golygydd** y gallwch ychwanegu neu olygu dolenni.
Os ydych chi ar ffôn symudol yna defnyddiwch yr eicon **cysylltiadau** ar y brig i ddarllen newyddion.
### Colofn dde
Gellir ychwanegu porthwyr RSS yn y golofn dde, a elwir y * newswire *. Dim ond ar arddangosfeydd bwrdd gwaith neu ddyfeisiau sydd â sgriniau mwy y mae hyn yn ymddangos. Dim ond os oes gennych rôl **gweinyddwr** neu **golygydd** y gallwch ychwanegu neu olygu porthwyr, a gellir cymedroli eitemau porthiant sy'n dod i mewn hefyd.
Os ydych chi ar ffôn symudol yna defnyddiwch yr eicon **newswire** ar y brig i ddarllen newyddion.

View File

@ -0,0 +1,19 @@
Eingehende Beiträge werden hier als chronologische Zeitleiste angezeigt. Wenn Sie Beiträge senden, werden diese auch hier angezeigt.
### Das oberste Banner
Am oberen Bildschirmrand können Sie das **Banner** auswählen, um zu Ihrem Profil zu wechseln, es zu bearbeiten oder sich abzumelden.
### Timeline-Schaltflächen und -Symbole
Mit den **Schaltflächen** unter dem oberen Banner können Sie verschiedene Zeitleisten auswählen. Es gibt auch **Symbole** auf der rechten Seite zum **Suchen**, Anzeigen Ihres **Kalenders** oder Erstellen **neuer Beiträge**.
Mit dem Symbol **Einblenden/Ausblenden** können mehr Timeline-Schaltflächen sowie Moderatorsteuerelemente angezeigt werden.
### Linke Spalte
Hier können Sie **nützliche Links** hinzufügen. Dies wird nur auf Desktop-Displays oder Geräten mit größeren Bildschirmen angezeigt. Es ist ähnlich wie bei einem *Blogroll*. Sie können Links nur hinzufügen oder bearbeiten, wenn Sie eine **Administrator** - oder **Editor** -Rolle haben.
Wenn Sie mobil sind, verwenden Sie das **Links-Symbol** oben, um Nachrichten zu lesen.
### Rechte Spalte
RSS-Feeds können in der rechten Spalte hinzugefügt werden, die als *newswire* bezeichnet wird. Dies wird nur auf Desktop-Displays oder Geräten mit größeren Bildschirmen angezeigt. Sie können Feeds nur hinzufügen oder bearbeiten, wenn Sie eine **Administrator** - oder **Editor** -Rolle haben. Eingehende Feed-Elemente können ebenfalls moderiert werden.
Wenn Sie mobil sind, verwenden Sie das **Newswire-Symbol** oben, um Nachrichten zu lesen.

View File

@ -0,0 +1,19 @@
Incoming posts will appear here, as a chronological timeline. If you send any posts they will also appear here.
### The top banner
At the top of the screen you can select the **banner** to switch to your profile, and edit it or log out.
### Timeline buttons and icons
The **buttons** below the top banner allow you to select different timelines. There are also **icons** on the right to **search**, view your **calendar** or create **new posts**.
The **show/hide** icon allows more timeline buttons to be shown, along with moderator controls.
### Left column
Here you can add **useful links**. This only appears on desktop displays or devices with larger screens. It is similar to a *blogroll*. You can only add or edit links if you have an **administrator** or **editor** role.
If you are on mobile then use the **links icon** at the top to read news.
### Right column
RSS feeds can be added in the right column, known as the *newswire*. This only appears on desktop displays or devices with larger screens. You can only add or edit feeds if you have an **administrator** or **editor** role, and incoming feed items can also be moderated.
If you are on mobile then use the **newswire icon** at the top to read news.

View File

@ -0,0 +1,19 @@
Las publicaciones entrantes aparecerán aquí, como una línea de tiempo cronológica. Si envía alguna publicación, también aparecerá aquí.
### El banner superior
En la parte superior de la pantalla, puede seleccionar el **banner** para cambiar a su perfil y editarlo o cerrar la sesión.
### Botones e íconos de la línea de tiempo
Los **botones** debajo del banner superior le permiten seleccionar diferentes líneas de tiempo. También hay **iconos** a la derecha para **buscar**, ver tu **calendario** o crear **publicaciones nuevas**.
El icono **mostrar/ocultar** permite que se muestren más botones de la línea de tiempo, junto con los controles del moderador.
### Columna izquierda
Aquí puede agregar **enlaces útiles**. Esto solo aparece en pantallas de escritorio o dispositivos con pantallas más grandes. Es similar a un *blogroll*. Solo puede agregar o editar enlaces si tiene una función de **administrador** o **editor**.
Si está en un dispositivo móvil, use el **icono de enlaces** en la parte superior para leer las noticias.
### Columna derecha
Las fuentes RSS se pueden agregar en la columna de la derecha, conocida como *newswire*. Esto solo aparece en pantallas de escritorio o dispositivos con pantallas más grandes. Solo puede agregar o editar feeds si tiene una función de **administrador** o **editor**, y los elementos entrantes del feed también se pueden moderar.
Si está en un dispositivo móvil, use el **icono de newswire** en la parte superior para leer las noticias.

View File

@ -0,0 +1,19 @@
Les messages entrants apparaîtront ici, sous forme de chronologie. Si vous envoyez des messages, ils apparaîtront également ici.
### La bannière du haut
En haut de l'écran, vous pouvez sélectionner la **bannière** pour passer à votre profil, le modifier ou vous déconnecter.
### Boutons et icônes de la chronologie
Les **boutons** sous la bannière du haut vous permettent de sélectionner différentes chronologies. Il y a aussi des **icônes** sur la droite pour **rechercher**, afficher votre **calendrier** ou créer **de nouveaux messages**.
L'icône **afficher/masquer** permet d'afficher plus de boutons de chronologie, ainsi que les commandes du modérateur.
### Colonne de gauche
Ici, vous pouvez ajouter des **liens utiles**. Cela n'apparaît que sur les écrans de bureau ou les appareils avec des écrans plus grands. C'est similaire à un *blogroll*. Vous ne pouvez ajouter ou modifier des liens que si vous avez un rôle **administrateur** ou **éditeur**.
Si vous êtes sur mobile, utilisez l'icône **liens** en haut pour lire les actualités.
### Colonne de droite
Les flux RSS peuvent être ajoutés dans la colonne de droite, connue sous le nom de *newswire*. Cela n'apparaît que sur les écrans de bureau ou les appareils avec des écrans plus grands. Vous ne pouvez ajouter ou modifier des flux que si vous avez un rôle **administrateur** ou **rédacteur**, et les éléments de flux entrants peuvent également être modérés.
Si vous êtes sur mobile, utilisez l'icône **Newswire** en haut pour lire les actualités.

View File

@ -0,0 +1,19 @@
Beidh poist isteach le feiceáil anseo, mar amlíne croineolaíoch. Má sheolann tú aon phoist beidh siad le feiceáil anseo freisin.
### An bhratach barr
Ag barr an scáileáin is féidir leat an **meirge** a roghnú le hathrú chuig do phróifíl, agus é a chur in eagar nó logáil amach.
### Cnaipí agus deilbhíní amlíne
Ligeann na **cnaipí** faoin mbratach barr duit amlínte éagsúla a roghnú. Tá **deilbhíní** ar dheis chun **cuardach** a dhéanamh, féachaint ar do **fhéilire****post nua** a chruthú.
Ligeann an deilbhín **show/hide** níos mó cnaipí amlíne a thaispeáint, mar aon le rialuithe modhnóra.
### Colún ar chlé
Anseo is féidir leat **naisc úsáideacha** a chur leis. Ní bhíonn sé seo le feiceáil ach ar thaispeántais deisce nó ar fheistí le scáileáin níos mó. Tá sé cosúil le * blogroll *. Ní féidir leat naisc a chur leis nó a chur in eagar ach má tá ról **riarthóir****eagarthóir** agat.
Má tá tú soghluaiste, úsáid an deilbhín **naisc** ag an mbarr chun nuacht a léamh.
### Colún ar dheis
Is féidir fothaí RSS a chur leis sa cholún ar dheis, ar a dtugtar an * newswire *. Ní bhíonn sé seo le feiceáil ach ar thaispeántais deisce nó ar fheistí le scáileáin níos mó. Ní féidir leat fothaí a chur leis nó a chur in eagar ach má tá ról **riarthóir****eagarthóir** agat, agus is féidir earraí beatha atá ag teacht isteach a mhodhnú freisin.
Má tá tú soghluaiste, bain úsáid as an deilbhín **newswire** ag an mbarr chun nuacht a léamh.

View File

@ -0,0 +1,19 @@
आने वाली पोस्टें यहां कालानुक्रमिक समय के रूप में दिखाई देंगी। यदि आप कोई पोस्ट भेजते हैं तो वे भी यहाँ दिखाई देंगे।
### शीर्ष बैनर
स्क्रीन के शीर्ष पर आप अपनी प्रोफ़ाइल पर जाने के लिए **बैनर** का चयन कर सकते हैं, और इसे संपादित या लॉग आउट कर सकते हैं।
### समयरेखा बटन और आइकन
शीर्ष बैनर के नीचे **बटन** आपको विभिन्न समयसीमाओं का चयन करने की अनुमति देते हैं। **खोज** के दाईं ओर आइकन भी हैं, अपने **कैलेंडर** देखें या **नए पोस्ट** बनाएं।
मॉडरेटर नियंत्रण के साथ **शो/हाइड** आइकन अधिक टाइमलाइन बटन दिखाने की अनुमति देता है।
### बाएं स्तंभ
यहां आप **उपयोगी लिंक** जोड़ सकते हैं। यह केवल डेस्कटॉप डिस्प्ले या बड़ी स्क्रीन वाले उपकरणों पर दिखाई देता है। यह एक *ब्लॉगरोल* के समान है। यदि आपके पास **व्यवस्थापक** या **संपादक** भूमिका है, तो आप केवल लिंक जोड़ या संपादित कर सकते हैं।
अगर आप मोबाइल पर हैं तो समाचार पढ़ने के लिए सबसे ऊपर **लिंक आइकन** का उपयोग करें।
### दक्षिण पक्ष क़तार
RSS फ़ीड्स को सही कॉलम में जोड़ा जा सकता है, जिसे *newswire* के रूप में जाना जाता है। यह केवल डेस्कटॉप डिस्प्ले या बड़ी स्क्रीन वाले उपकरणों पर दिखाई देता है। आप केवल तभी जोड़ या संपादित कर सकते हैं जब आपके पास **व्यवस्थापक** या **संपादक** भूमिका हो, और आने वाली फ़ीड आइटम भी मॉडरेट की जा सकती हैं।
यदि आप मोबाइल पर हैं तो समाचार पढ़ने के लिए सबसे ऊपर **newswire आइकन** का उपयोग करें।

View File

@ -0,0 +1,19 @@
Las publicaciones entrantes aparecerán aquí, como una línea de tiempo cronológica. Si envía alguna publicación, también aparecerá aquí.
### El banner superior
En la parte superior de la pantalla, puede seleccionar el **banner** para cambiar a su perfil y editarlo o cerrar la sesión.
### Botones e íconos de la línea de tiempo
Los **botones** debajo del banner superior le permiten seleccionar diferentes líneas de tiempo. También hay **iconos** a la derecha para **buscar**, ver tu **calendario** o crear **publicaciones nuevas**.
El icono **mostrar/ocultar** permite que se muestren más botones de la línea de tiempo, junto con los controles del moderador.
### Columna izquierda
Aquí puede agregar **enlaces útiles**. Esto solo aparece en pantallas de escritorio o dispositivos con pantallas más grandes. Es similar a un *blogroll*. Solo puede agregar o editar enlaces si tiene una función de **administrador** o **editor**.
Si está en un dispositivo móvil, use el **icono de enlaces** en la parte superior para leer las noticias.
### Columna derecha
Las fuentes RSS se pueden agregar en la columna de la derecha, conocida como *newswire*. Esto solo aparece en pantallas de escritorio o dispositivos con pantallas más grandes. Solo puede agregar o editar feeds si tiene una función de **administrador** o **editor**, y los elementos entrantes del feed también se pueden moderar.
Si está en un dispositivo móvil, use el **icono de newswire** en la parte superior para leer las noticias.

View File

@ -0,0 +1,19 @@
着信投稿は、時系列のタイムラインとしてここに表示されます。投稿を送信すると、ここにも表示されます。
### トップバナー
画面の上部で、**バナー**を選択してプロファイルに切り替え、編集またはログアウトできます。
###タイムラインのボタンとアイコン
上部のバナーの下にある**ボタン**を使用すると、さまざまなタイムラインを選択できます。 **検索**、**カレンダー**の表示、または**新しい投稿**の作成の右側には**アイコン**もあります。
**表示/非表示**アイコンを使用すると、モデレーターコントロールとともに、より多くのタイムラインボタンを表示できます。
### 左の列
ここで**便利なリンク**を追加できます。これは、デスクトップディスプレイまたは大画面のデバイスにのみ表示されます。これは* blogroll *に似ています。リンクを追加または編集できるのは、**管理者**または**編集者**の役割がある場合のみです。
モバイルを使用している場合は、上部にある**リンクアイコン**を使用してニュースを読んでください。
### 右の列
RSSフィードは、* newswire *と呼ばれる右側の列に追加できます。これは、デスクトップディスプレイまたは大画面のデバイスにのみ表示されます。フィードを追加または編集できるのは、**管理者**または**編集者**の役割がある場合のみです。また、受信フィードアイテムをモデレートすることもできます。
モバイルを使用している場合は、上部にある**ニュースワイヤーアイコン**を使用してニュースを読んでください。

View File

@ -0,0 +1,19 @@
Mesajên hatinê dê li vir, wekî demek kronolojîk xuya bikin. Ger hûn şandiyan bişînin ew ê jî li vir xuya bibin.
### Pankarta jorîn
Li jor li ser ekranê hûn dikarin **pankarta** hilbijêrin da ku hûn profîla xwe veguherînin, û wê sererast bikin an jî derkevin.
### Bişkojk û îkonên Timeline
Bişkojkên li jêr pankarta jorîn dihêlin hûn demjimêrên cihêreng hilbijêrin. Di heman demê de **îkonên** li rastê **lêgerîn**, dîtina **salnameya xwe** an afirandina **peyamên nû** hene.
Nîşaneya **nîşan/veşêre** dihêle ku digel pêvekên moderator, bêtir bişkokên demjimêrê werin nîşandin.
### Stûna çepê
Li vir hûn dikarin **girêdanên bikêr** zêde bikin. Ev tenê li ser dîmenderên sermaseyê an amûrên xwedan ekranên mezintir xuya dike. Ew dişibihe *blogroll*. Tenê heke we roleke **rêveber** an **edîtor** hebe hûn tenê dikarin girêdan lê zêde bikin an biguherînin.
Heke hûn li ser mobîl in wê hingê ji bo xwendina nûçeyan **îkona girêdan** li jor bikar bînin.
### Stûna rast
RSS-ê dikarin di stûna rastê de werin zêdekirin, ku wekî *nûçe* tê zanîn. Ev tenê li ser dîmenderên sermaseyê an amûrên xwedan ekranên mezintir xuya dike. Tenê heke we roleke **rêveber** an **edîtor** hebe hûn tenê dikarin pêvekan zêde bikin an biguhezînin, û hêmanên tewra tewra jî dikarin werin moderator kirin.
Heke hûn li ser mobîl in wê hingê ji bo xwendina nûçeyan li jor îkona **newswire** bikar bînin.

View File

@ -0,0 +1,19 @@
As postagens recebidas aparecerão aqui, como uma linha do tempo cronológica. Se você enviar alguma postagem, ela também aparecerá aqui.
### O banner superior
Na parte superior da tela, você pode selecionar o **banner** para alternar para seu perfil e editá-lo ou fazer logout.
### Botões e ícones da linha do tempo
Os **botões** abaixo do banner superior permitem que você selecione diferentes cronogramas. Também existem **ícones** à direita para **pesquisar**, visualizar sua **agenda** ou criar **novas postagens**.
O ícone **mostrar / ocultar** permite que mais botões da linha do tempo sejam mostrados, junto com os controles do moderador.
### Coluna esquerda
Aqui você pode adicionar **links úteis**. Isso só aparece em monitores de desktop ou dispositivos com telas maiores. É semelhante a um *blogroll*. Você só pode adicionar ou editar links se tiver uma função de **administrador** ou **editor**.
Se você estiver no celular, use o **ícone de links** na parte superior para ler as notícias.
### Coluna direita
Os feeds RSS podem ser adicionados na coluna da direita, conhecida como *newswire*. Isso só aparece em monitores de desktop ou dispositivos com telas maiores. Você só pode adicionar ou editar feeds se tiver uma função de **administrador** ou **editor**, e os itens de feed recebidos também podem ser moderados.
Se você estiver no celular, use o **ícone de notícias** na parte superior para ler as notícias.

View File

@ -0,0 +1,19 @@
Входящие сообщения будут отображаться здесь в хронологическом порядке. Если вы отправите какие-либо сообщения, они также появятся здесь.
### Верхний баннер
В верхней части экрана вы можете выбрать **баннер**, чтобы переключиться на свой профиль, отредактировать его или выйти из системы.
### Кнопки и значки шкалы времени
**Кнопки** под верхним баннером позволяют выбирать разные временные шкалы. Также есть **значки** справа для **поиска**, просмотра **календаря** или создания **новых сообщений**.
Значок **показать/скрыть** позволяет отображать больше кнопок временной шкалы вместе с элементами управления модератора.
### Левый столбец
Здесь вы можете добавить **полезные ссылки**. Это появляется только на настольных дисплеях или устройствах с большими экранами. Это похоже на * блогролл *. Вы можете добавлять или редактировать ссылки только в том случае, если у вас есть роль **администратора** или **редактора**.
Если вы используете мобильный телефон, используйте **значок ссылок** вверху, чтобы читать новости.
### Правый столбец
RSS-каналы могут быть добавлены в правый столбец, известный как * лента новостей *. Это появляется только на настольных дисплеях или устройствах с большими экранами. Вы можете добавлять или редактировать каналы только в том случае, если у вас есть роль **администратор** или **редактор**, а входящие элементы канала также можно модерировать.
Если вы пользуетесь мобильным телефоном, используйте **значок ленты новостей** вверху, чтобы читать новости.

View File

@ -0,0 +1,19 @@
Machapisho yanayoingia itaonekana hapa, kama ratiba ya muda. Ikiwa unatuma machapisho yoyote wataonekana pia hapa.
### Bendera ya juu
Juu ya skrini unaweza kuchagua bendera ili kubadili wasifu wako, na uhariri au uiteke.
### Vifungo vya Timeline na Icons
Vifungo chini ya bendera ya juu vinakuwezesha kuchagua wakati tofauti. Pia kuna **icons** kwenye haki ya **Tafuta**, angalia kalenda yako au uunda **machapisho mapya**.
**Onyesha/Ficha** icon inaruhusu vifungo zaidi vya wakati wa wakati kuonyeshwa, pamoja na udhibiti wa moderator.
### Safu ya kushoto
Hapa unaweza kuongeza **viungo muhimu**. Hii inaonekana tu kwenye maonyesho ya desktop au vifaa na skrini kubwa. Ni sawa na *Blogroll*. Unaweza tu kuongeza au hariri viungo ikiwa una **Msimamizi** au **Mhariri** jukumu.
Ikiwa uko kwenye simu kisha utumie Icon ya **Links** juu ili kusoma habari.
### Safu ya haki
RSS Feeds inaweza kuongezwa kwenye safu ya kulia, inayojulikana kama *Newswire*. Hii inaonekana tu kwenye maonyesho ya desktop au vifaa na skrini kubwa. Unaweza tu kuongeza au kuhariri feeds ikiwa una msimamizi au jukumu la mhariri, na vitu vya kulisha vinaweza pia kuzingatiwa.
Ikiwa uko kwenye simu kisha utumie **Newswire icon** juu ili kusoma habari.

View File

@ -0,0 +1,19 @@
收到的帖子将按时间顺序显示在此处。如果您发送任何帖子,它们也会显示在这里。
### 最高横幅
在屏幕顶部,您可以选择横幅以切换到您的个人资料,然后对其进行编辑或注销。
### 时间轴按钮和图标
顶部横幅下方的按钮使您可以选择不同的时间轴。右侧也有图标可以搜索,查看日历或创建新帖子。
显示/隐藏图标允许显示更多时间线按钮以及主持人控件。
### 左栏
您可以在此处添加有用的链接。它仅出现在台式机显示器或具有更大屏幕的设备上。它类似于博客卷。如果您具有管理员或编辑者角色,则只能添加或编辑链接。
如果您在移动设备上,请使用顶部的链接图标阅读新闻。
### 右列
可以在右侧栏称为新闻专线中添加RSS提要。它仅出现在台式机显示器或具有更大屏幕的设备上。如果您具有管理员或编辑者角色则只能添加或编辑提要并且传入提要项目也可以被审核。
如果您在移动设备上,请使用顶部的新闻专线图标阅读新闻。

View File

@ -0,0 +1 @@
ستظهر مشاركاتك المرسلة هنا ، كجدول زمني زمني.

View File

@ -0,0 +1 @@
Les vostres publicacions enviades apareixeran aquí com a cronologia cronològica.

View File

@ -0,0 +1 @@
Bydd eich postiadau a anfonir yn ymddangos yma, fel llinell amser gronolegol.

View File

@ -0,0 +1 @@
Ihre gesendeten Beiträge werden hier als chronologische Zeitleiste angezeigt.

View File

@ -0,0 +1 @@
Your sent posts will appear here, as a cronological timeline.

View File

@ -0,0 +1 @@
Sus publicaciones enviadas aparecerán aquí, como una línea de tiempo cronológica.

View File

@ -0,0 +1 @@
Vos messages envoyés apparaîtront ici, sous forme de chronologie.

View File

@ -0,0 +1 @@
Beidh do phoist seolta le feiceáil anseo, mar amlíne croineolaíoch.

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