Merge branch 'main' of ssh://code.freedombone.net:2222/bashrc/epicyon
3
Makefile
|
@ -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/*~
|
||||
|
|
18
README.md
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||

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

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

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

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

|
|
@ -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:
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
210
announce.py
|
@ -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)
|
||||
|
|
After Width: | Height: | Size: 53 KiB |
After Width: | Height: | Size: 163 KiB |
After Width: | Height: | Size: 96 KiB |
After Width: | Height: | Size: 76 KiB |
After Width: | Height: | Size: 54 KiB |
After Width: | Height: | Size: 62 KiB |
After Width: | Height: | Size: 58 KiB |
After Width: | Height: | Size: 68 KiB |
After Width: | Height: | Size: 133 KiB |
After Width: | Height: | Size: 80 KiB |
After Width: | Height: | Size: 84 KiB |
After Width: | Height: | Size: 68 KiB |
After Width: | Height: | Size: 162 KiB |
After Width: | Height: | Size: 80 KiB |
63
auth.py
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
458
blocking.py
|
@ -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
|
@ -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')
|
||||
|
|
367
bookmarks.py
|
@ -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)
|
||||
|
|
1
briar.py
|
@ -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:
|
||||
|
|
18
cache.py
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -26,6 +26,12 @@ No stalking, unwanted personal attention, or unwelcome revealing or speculating
|
|||
|
||||
In cases of sincere, good-faith curiosity about someone’s 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.
|
||||
|
|
193
content.py
|
@ -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>"' in content:
|
||||
if '"</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 '"' 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
|
||||
|
|
|
@ -5,6 +5,7 @@ __version__ = "1.2.0"
|
|||
__maintainer__ = "Bob Mottram"
|
||||
__email__ = "bob@freedombone.net"
|
||||
__status__ = "Production"
|
||||
__module_group__ = "Security"
|
||||
|
||||
|
||||
validContexts = (
|
||||
|
|
|
@ -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)
|
|
@ -0,0 +1,9 @@
|
|||
# About this Instance
|
||||
### Origin Story
|
||||
How your instance began.
|
||||
|
||||
### Lore
|
||||
Customs and rituals.
|
||||
|
||||
### Epic Tales
|
||||
Heroic deeds and dastardly foes.
|
|
@ -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>
|
|
@ -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)
|
|
@ -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>
|
|
@ -0,0 +1,16 @@
|
|||
Blue
|
||||
Debian
|
||||
Default
|
||||
Hacker
|
||||
Henge
|
||||
Indymediaclassic
|
||||
Indymediamodern
|
||||
Lcd
|
||||
Light
|
||||
Night
|
||||
Pixel
|
||||
Purple
|
||||
Rc3
|
||||
Solidaric
|
||||
Starlight
|
||||
Zen
|
|
@ -0,0 +1,18 @@
|
|||
### تهانينا!
|
||||
أنت الآن جاهز لبدء استخدام Epicyon. هذه مساحة اجتماعية خاضعة للإشراف ، لذا يرجى التأكد من الالتزام بـ [شروط الخدمة](/terms) الخاصة بنا ، واستمتع.
|
||||
|
||||
#### تلميحات
|
||||
استخدم رمز **المكبر** 🔍 للبحث عن مقابض الكون المشترك ومتابعة الأشخاص.
|
||||
|
||||
يؤدي تحديد **الشعار في الجزء العلوي** من الشاشة إلى التبديل بين عرض المخطط الزمني وملف التعريف الخاص بك.
|
||||
|
||||
لن يتم تحديث الشاشة تلقائيًا عند وصول المنشورات ، لذا استخدم **F5** أو زر البريد الوارد للتحديث.
|
||||
|
||||
#### طقوس المرور
|
||||
تدربك ثقافة الشركة على الرغبة في الحصول على أكبر عدد من المتابعين والإعجابات - للبحث عن الشهرة الشخصية والتفاعلات السطحية التي تثير الغضب لجذب الانتباه.
|
||||
|
||||
لذلك إذا كنت قادمًا من تلك الثقافة ، فيرجى العلم أن هذا نوع مختلف من النظام مع مجموعة مختلفة جدًا من التوقعات.
|
||||
|
||||
ليس من الضروري وجود الكثير من المتابعين ، وغالبًا ما يكون غير مرغوب فيه. قد يحظرك الناس ، ولا بأس بذلك. لا أحد لديه الحق في جمهور. إذا قام شخص ما بحظرك فأنت لا تخضع للرقابة. يمارس الناس فقط حريتهم في الارتباط بمن يرغبون فيه.
|
||||
|
||||
من المتوقع أن تكون معايير السلوك الشخصي أفضل مما هي عليه في أنظمة الشركات. سلوكك له أيضًا عواقب على سمعة هذه الحالة. إذا كنت تتصرف بطريقة متهورة تتعارض مع شروط الخدمة ، فقد يتم تعليق حسابك أو إزالته.
|
|
@ -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 d’aquesta cultura, tingueu en compte que es tracta d’un tipus de sistema diferent amb un conjunt d’expectatives 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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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é.
|
|
@ -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 d’iompar 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.
|
|
@ -0,0 +1,18 @@
|
|||
### बधाई हो!
|
||||
अब आप एपिसकॉन का उपयोग शुरू करने के लिए तैयार हैं। यह एक मध्यम सामाजिक स्थान है, इसलिए कृपया हमारी [सेवा की शर्तों](/terms) का पालन करना सुनिश्चित करें, और मज़े करें।
|
||||
|
||||
#### संकेत
|
||||
फ़ेडरिवर्स हैंडल की खोज करने और लोगों का अनुसरण करने के लिए **आवर्धक आइकन** का उपयोग करें।
|
||||
|
||||
समय दृश्य और आपकी प्रोफ़ाइल के बीच स्क्रीन स्विच के शीर्ष **पर स्थित** बैनर का चयन करना।
|
||||
|
||||
पोस्ट आने पर स्क्रीन अपने आप रिफ्रेश नहीं होगी, इसलिए रीफ्रेश करने के लिए **F5** या **इनबॉक्स** बटन का उपयोग करें।
|
||||
|
||||
#### यादगार घटना
|
||||
कॉरपोरेट कल्चर आपको अधिक से अधिक संख्या में अनुयायियों और पसंदों को प्राप्त करने के लिए प्रशिक्षित करता है - ध्यान आकर्षित करने के लिए व्यक्तिगत प्रसिद्धि और उथले, नाराजगी-उत्प्रेरण बातचीत।
|
||||
|
||||
इसलिए यदि आप उस संस्कृति से आ रहे हैं, तो कृपया ध्यान रखें कि यह एक अलग प्रकार की प्रणाली है जिसमें बहुत अलग अपेक्षाएं हैं।
|
||||
|
||||
बहुत सारे अनुयायी होना आवश्यक नहीं है, और अक्सर यह अवांछनीय है। लोग आपको ब्लॉक कर सकते हैं, और यह ठीक है। किसी को भी एक दर्शक का अधिकार नहीं है। अगर कोई आपको ब्लॉक करता है तो आपको सेंसर नहीं किया जा रहा है। लोग बस अपनी स्वतंत्रता का प्रयोग कर रहे हैं कि वे जो चाहें करें।
|
||||
|
||||
व्यक्तिगत व्यवहार के मानक कॉर्पोरेट सिस्टम की तुलना में बेहतर होने की उम्मीद है। इस उदाहरण की प्रतिष्ठा के लिए आपके व्यवहार के परिणाम भी हैं। यदि आप एक असंगत तरीके से व्यवहार करते हैं जो सेवा की शर्तों के खिलाफ जाता है तो आपका खाता निलंबित या हटाया जा सकता है।
|
|
@ -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.
|
|
@ -0,0 +1,18 @@
|
|||
### おめでとう!
|
||||
これで、Epicyonの使用を開始する準備が整いました。 適度な社交空間ですので、必ず [利用規約](/terms) を遵守して楽しんでください。
|
||||
|
||||
#### ヒント
|
||||
**拡大鏡** アイコン🔍を使用して、fediverseハンドルを検索し、人々をフォローします。
|
||||
|
||||
画面の上部にある **バナー** を選択すると、タイムラインビューとプロファイルが切り替わります。
|
||||
|
||||
投稿が到着しても画面は自動的に更新されないため、 **F5** または **受信トレイ** ボタンを使用して更新してください。
|
||||
|
||||
#### 通過儀礼
|
||||
企業文化は、最大数のフォロワーや好きな人を求め、個人的な名声と浅い、怒りを誘発する相互作用を求めて注目を集めるように訓練します。
|
||||
|
||||
したがって、その文化から来ている場合、これは非常に異なる一連の期待を持つ異なるタイプのシステムであることに注意してください。
|
||||
|
||||
多くのフォロワーを持つ必要はなく、多くの場合、それは望ましくありません。 人々があなたをブロックするかもしれません、そしてそれは大丈夫です。 誰も聴衆に対する権利を持っていません。 誰かがあなたをブロックした場合、あなたは検閲されていません。 人々は、彼らが望む誰とでも交際する自由を行使しているだけです。
|
||||
|
||||
個人の行動基準は、企業システムよりも優れていると期待されています。 あなたの行動は、このインスタンスの評判にも影響を及ぼします。 利用規約に違反する軽率な行動をとった場合、アカウントが停止または削除される可能性があります。
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -0,0 +1,18 @@
|
|||
### Поздравляю!
|
||||
Теперь вы готовы начать использовать Epicyon. Это модерируемое социальное пространство, поэтому, пожалуйста, соблюдайте наши [условия обслуживания](/terms) и получайте удовольствие.
|
||||
|
||||
#### Подсказки
|
||||
Используйте значок **лупы** 🔍, чтобы искать нужные метки и следить за людьми.
|
||||
|
||||
При выборе **баннера вверху** экрана выполняется переключение между представлением временной шкалы и вашим профилем.
|
||||
|
||||
Экран не обновляется автоматически при поступлении сообщений, поэтому используйте **F5** или кнопку **Входящие** для обновления.
|
||||
|
||||
#### Обряд посвящения
|
||||
Корпоративная культура учит вас стремиться к максимальному количеству подписчиков и лайков - стремиться к личной славе и поверхностным, вызывающим возмущение взаимодействиям, чтобы привлечь внимание.
|
||||
|
||||
Так что, если вы происходите из этой культуры, имейте в виду, что это другой тип системы с совершенно другим набором ожиданий.
|
||||
|
||||
Не обязательно иметь много подписчиков, а зачастую и нежелательно. Люди могут заблокировать вас, и это нормально. Никто не имеет права на аудиенцию. Если кто-то вас блокирует, значит, вы не подвергаетесь цензуре. Люди просто пользуются своей свободой общаться с кем хотят.
|
||||
|
||||
Ожидается, что стандарты личного поведения будут лучше, чем в корпоративных системах. Ваше поведение также влияет на репутацию этого экземпляра. Если вы ведете себя невнимательно, что противоречит условиям обслуживания, ваша учетная запись может быть приостановлена или удалена.
|
|
@ -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.
|
|
@ -0,0 +1,18 @@
|
|||
### 恭喜你!
|
||||
您现在可以开始使用Epicyon。 这是一个温和的社交空间,因此请务必遵守我们的[服务条款](/terms),并从中获得乐趣。
|
||||
|
||||
####提示
|
||||
使用放大镜图标search搜索fed性的手柄并关注他人。
|
||||
|
||||
选择屏幕顶部的横幅广告可在时间轴视图和个人资料之间切换。
|
||||
|
||||
帖子到达时,屏幕不会自动刷新,因此请使用F5或“收件箱”按钮刷新。
|
||||
|
||||
#### 通行礼
|
||||
企业文化训练您想要最大数量的追随者和喜欢的人-寻求个人名望和肤浅,激怒的互动来吸引注意力。
|
||||
|
||||
因此,如果您来自这种文化,请注意,这是另一种类型的系统,具有不同的期望值。
|
||||
|
||||
拥有大量的追随者不是必需的,而且通常是不可取的。 人们可能会阻止您,没关系。 没有人有听众的权利。 如果有人阻止了您,那么您将不会受到审查。 人们只是在行使与任何希望的人交往的自由。
|
||||
|
||||
个人行为标准有望比公司系统更好。 您的行为也会对该实例的声誉产生影响。 如果您的行为举止粗鲁,违反了服务条款,那么您的帐户可能会被暂停或删除。
|
|
@ -0,0 +1,3 @@
|
|||
ستظهر الرسائل المباشرة هنا ، كجدول زمني زمني.
|
||||
|
||||
لتجنب البريد العشوائي وتحسين الأمان ، ستتمكن افتراضيًا فقط من تلقي الرسائل المباشرة من الأشخاص الذين تتابعهم. يمكنك إيقاف تشغيل هذا ضمن إعدادات ملف التعريف الخاص بك إذا كنت بحاجة إلى ذلك ، عن طريق تحديد الشعار العلوي ثم أيقونة التحرير.
|
|
@ -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**.
|
|
@ -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**.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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**.
|
|
@ -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**.
|
|
@ -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**.
|
|
@ -0,0 +1,3 @@
|
|||
प्रत्यक्ष संदेश यहां कालानुक्रमिक समय के रूप में दिखाई देंगे।
|
||||
|
||||
स्पैम से बचने और सुरक्षा में सुधार करने के लिए, डिफ़ॉल्ट रूप से आप केवल उन लोगों से सीधे संदेश प्राप्त कर सकेंगे जो आप का अनुसरण कर रहे हैं। आप अपनी प्रोफ़ाइल सेटिंग्स के भीतर इसे बंद कर सकते हैं, अगर आपको ज़रूरत है, तो शीर्ष **बैनर** और फिर **संपादन** आइकन का चयन करके।
|
|
@ -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**.
|
|
@ -0,0 +1,3 @@
|
|||
ダイレクトメッセージは、時系列のタイムラインとしてここに表示されます。
|
||||
|
||||
スパムを回避し、セキュリティを向上させるために、デフォルトでは、フォローしているユーザーからの直接メッセージのみを受信できます。 必要に応じて、上部のバナーを選択してから編集アイコンを選択することにより、プロファイル設定内でこれをオフにすることができます。
|
|
@ -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**.
|
|
@ -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**.
|
|
@ -0,0 +1,3 @@
|
|||
Личные сообщения будут отображаться здесь в хронологическом порядке.
|
||||
|
||||
Чтобы избежать спама и повысить безопасность, по умолчанию вы сможете получать прямые сообщения только от людей, на которых вы подписаны. Вы можете отключить это в настройках своего профиля, если вам нужно, выбрав верхний **баннер**, а затем значок **изменить**.
|
|
@ -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**.
|
|
@ -0,0 +1,3 @@
|
|||
直接消息将按时间顺序显示在此处。
|
||||
|
||||
为了避免垃圾邮件并提高安全性,默认情况下,您只能接收来自您所关注人员的直接消息。 您可以根据需要在个人资料设置中将其关闭,方法是选择顶部横幅,然后选择编辑图标。
|
|
@ -0,0 +1,19 @@
|
|||
ستظهر المشاركات الواردة هنا كجدول زمني زمني. إذا قمت بإرسال أي منشورات فسوف تظهر هنا أيضًا.
|
||||
|
||||
### اللافتة العلوية
|
||||
في الجزء العلوي من الشاشة ، يمكنك تحديد الشعار للتبديل إلى ملف التعريف الخاص بك وتحريره أو تسجيل الخروج.
|
||||
|
||||
### أزرار وأيقونات الخط الزمني
|
||||
تسمح لك الأزرار الموجودة أسفل الشعار العلوي بتحديد خطوط زمنية مختلفة. توجد أيضًا رموز على اليمين للبحث أو عرض التقويم الخاص بك أو إنشاء منشورات جديدة.
|
||||
|
||||
تتيح أيقونة إظهار / إخفاء عرض المزيد من أزرار المخطط الزمني ، إلى جانب عناصر تحكم الوسيط.
|
||||
|
||||
### العمود الأيسر
|
||||
هنا يمكنك إضافة روابط مفيدة. يظهر هذا فقط على شاشات سطح المكتب أو الأجهزة ذات الشاشات الأكبر حجمًا. إنه مشابه لقائمة المدونات. يمكنك فقط إضافة الروابط أو تعديلها إذا كان لديك دور مسؤول أو محرر.
|
||||
|
||||
إذا كنت تستخدم الهاتف المحمول ، فاستخدم رمز الروابط في الأعلى لقراءة الأخبار.
|
||||
|
||||
### العمود الأيمن
|
||||
يمكن إضافة موجز ويب لـ RSS في العمود الأيمن ، المعروف باسم newswire. يظهر هذا فقط على شاشات سطح المكتب أو الأجهزة ذات الشاشات الأكبر حجمًا. يمكنك فقط إضافة أو تحرير الخلاصات إذا كان لديك دور مسؤول أو محرر ، ويمكن أيضًا الإشراف على عناصر الخلاصة الواردة.
|
||||
|
||||
إذا كنت تستخدم الهاتف المحمول ، فاستخدم رمز الأخبار في الأعلى لقراءة الأخبار.
|
|
@ -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 d’administrador **o d’editor**.
|
||||
|
||||
Si esteu al mòbil, feu servir la icona d’enllaç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 d’administrador **o d’editor** 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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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** nó **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** nó **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** nó **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.
|
|
@ -0,0 +1,19 @@
|
|||
आने वाली पोस्टें यहां कालानुक्रमिक समय के रूप में दिखाई देंगी। यदि आप कोई पोस्ट भेजते हैं तो वे भी यहाँ दिखाई देंगे।
|
||||
|
||||
### शीर्ष बैनर
|
||||
स्क्रीन के शीर्ष पर आप अपनी प्रोफ़ाइल पर जाने के लिए **बैनर** का चयन कर सकते हैं, और इसे संपादित या लॉग आउट कर सकते हैं।
|
||||
|
||||
### समयरेखा बटन और आइकन
|
||||
शीर्ष बैनर के नीचे **बटन** आपको विभिन्न समयसीमाओं का चयन करने की अनुमति देते हैं। **खोज** के दाईं ओर आइकन भी हैं, अपने **कैलेंडर** देखें या **नए पोस्ट** बनाएं।
|
||||
|
||||
मॉडरेटर नियंत्रण के साथ **शो/हाइड** आइकन अधिक टाइमलाइन बटन दिखाने की अनुमति देता है।
|
||||
|
||||
### बाएं स्तंभ
|
||||
यहां आप **उपयोगी लिंक** जोड़ सकते हैं। यह केवल डेस्कटॉप डिस्प्ले या बड़ी स्क्रीन वाले उपकरणों पर दिखाई देता है। यह एक *ब्लॉगरोल* के समान है। यदि आपके पास **व्यवस्थापक** या **संपादक** भूमिका है, तो आप केवल लिंक जोड़ या संपादित कर सकते हैं।
|
||||
|
||||
अगर आप मोबाइल पर हैं तो समाचार पढ़ने के लिए सबसे ऊपर **लिंक आइकन** का उपयोग करें।
|
||||
|
||||
### दक्षिण पक्ष क़तार
|
||||
RSS फ़ीड्स को सही कॉलम में जोड़ा जा सकता है, जिसे *newswire* के रूप में जाना जाता है। यह केवल डेस्कटॉप डिस्प्ले या बड़ी स्क्रीन वाले उपकरणों पर दिखाई देता है। आप केवल तभी जोड़ या संपादित कर सकते हैं जब आपके पास **व्यवस्थापक** या **संपादक** भूमिका हो, और आने वाली फ़ीड आइटम भी मॉडरेट की जा सकती हैं।
|
||||
|
||||
यदि आप मोबाइल पर हैं तो समाचार पढ़ने के लिए सबसे ऊपर **newswire आइकन** का उपयोग करें।
|
|
@ -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.
|
|
@ -0,0 +1,19 @@
|
|||
着信投稿は、時系列のタイムラインとしてここに表示されます。投稿を送信すると、ここにも表示されます。
|
||||
|
||||
### トップバナー
|
||||
画面の上部で、**バナー**を選択してプロファイルに切り替え、編集またはログアウトできます。
|
||||
|
||||
###タイムラインのボタンとアイコン
|
||||
上部のバナーの下にある**ボタン**を使用すると、さまざまなタイムラインを選択できます。 **検索**、**カレンダー**の表示、または**新しい投稿**の作成の右側には**アイコン**もあります。
|
||||
|
||||
**表示/非表示**アイコンを使用すると、モデレーターコントロールとともに、より多くのタイムラインボタンを表示できます。
|
||||
|
||||
### 左の列
|
||||
ここで**便利なリンク**を追加できます。これは、デスクトップディスプレイまたは大画面のデバイスにのみ表示されます。これは* blogroll *に似ています。リンクを追加または編集できるのは、**管理者**または**編集者**の役割がある場合のみです。
|
||||
|
||||
モバイルを使用している場合は、上部にある**リンクアイコン**を使用してニュースを読んでください。
|
||||
|
||||
### 右の列
|
||||
RSSフィードは、* newswire *と呼ばれる右側の列に追加できます。これは、デスクトップディスプレイまたは大画面のデバイスにのみ表示されます。フィードを追加または編集できるのは、**管理者**または**編集者**の役割がある場合のみです。また、受信フィードアイテムをモデレートすることもできます。
|
||||
|
||||
モバイルを使用している場合は、上部にある**ニュースワイヤーアイコン**を使用してニュースを読んでください。
|
|
@ -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.
|
|
@ -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.
|
|
@ -0,0 +1,19 @@
|
|||
Входящие сообщения будут отображаться здесь в хронологическом порядке. Если вы отправите какие-либо сообщения, они также появятся здесь.
|
||||
|
||||
### Верхний баннер
|
||||
В верхней части экрана вы можете выбрать **баннер**, чтобы переключиться на свой профиль, отредактировать его или выйти из системы.
|
||||
|
||||
### Кнопки и значки шкалы времени
|
||||
**Кнопки** под верхним баннером позволяют выбирать разные временные шкалы. Также есть **значки** справа для **поиска**, просмотра **календаря** или создания **новых сообщений**.
|
||||
|
||||
Значок **показать/скрыть** позволяет отображать больше кнопок временной шкалы вместе с элементами управления модератора.
|
||||
|
||||
### Левый столбец
|
||||
Здесь вы можете добавить **полезные ссылки**. Это появляется только на настольных дисплеях или устройствах с большими экранами. Это похоже на * блогролл *. Вы можете добавлять или редактировать ссылки только в том случае, если у вас есть роль **администратора** или **редактора**.
|
||||
|
||||
Если вы используете мобильный телефон, используйте **значок ссылок** вверху, чтобы читать новости.
|
||||
|
||||
### Правый столбец
|
||||
RSS-каналы могут быть добавлены в правый столбец, известный как * лента новостей *. Это появляется только на настольных дисплеях или устройствах с большими экранами. Вы можете добавлять или редактировать каналы только в том случае, если у вас есть роль **администратор** или **редактор**, а входящие элементы канала также можно модерировать.
|
||||
|
||||
Если вы пользуетесь мобильным телефоном, используйте **значок ленты новостей** вверху, чтобы читать новости.
|
|
@ -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.
|
|
@ -0,0 +1,19 @@
|
|||
收到的帖子将按时间顺序显示在此处。如果您发送任何帖子,它们也会显示在这里。
|
||||
|
||||
### 最高横幅
|
||||
在屏幕顶部,您可以选择横幅以切换到您的个人资料,然后对其进行编辑或注销。
|
||||
|
||||
### 时间轴按钮和图标
|
||||
顶部横幅下方的按钮使您可以选择不同的时间轴。右侧也有图标可以搜索,查看日历或创建新帖子。
|
||||
|
||||
显示/隐藏图标允许显示更多时间线按钮以及主持人控件。
|
||||
|
||||
### 左栏
|
||||
您可以在此处添加有用的链接。它仅出现在台式机显示器或具有更大屏幕的设备上。它类似于博客卷。如果您具有管理员或编辑者角色,则只能添加或编辑链接。
|
||||
|
||||
如果您在移动设备上,请使用顶部的链接图标阅读新闻。
|
||||
|
||||
### 右列
|
||||
可以在右侧栏(称为新闻专线)中添加RSS提要。它仅出现在台式机显示器或具有更大屏幕的设备上。如果您具有管理员或编辑者角色,则只能添加或编辑提要,并且传入提要项目也可以被审核。
|
||||
|
||||
如果您在移动设备上,请使用顶部的新闻专线图标阅读新闻。
|
|
@ -0,0 +1 @@
|
|||
ستظهر مشاركاتك المرسلة هنا ، كجدول زمني زمني.
|
|
@ -0,0 +1 @@
|
|||
Les vostres publicacions enviades apareixeran aquí com a cronologia cronològica.
|
|
@ -0,0 +1 @@
|
|||
Bydd eich postiadau a anfonir yn ymddangos yma, fel llinell amser gronolegol.
|
|
@ -0,0 +1 @@
|
|||
Ihre gesendeten Beiträge werden hier als chronologische Zeitleiste angezeigt.
|
|
@ -0,0 +1 @@
|
|||
Your sent posts will appear here, as a cronological timeline.
|
|
@ -0,0 +1 @@
|
|||
Sus publicaciones enviadas aparecerán aquí, como una línea de tiempo cronológica.
|
|
@ -0,0 +1 @@
|
|||
Vos messages envoyés apparaîtront ici, sous forme de chronologie.
|
|
@ -0,0 +1 @@
|
|||
Beidh do phoist seolta le feiceáil anseo, mar amlíne croineolaíoch.
|