diff --git a/Makefile b/Makefile
index 034be8abb..7a54fd9db 100644
--- a/Makefile
+++ b/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/*~
diff --git a/README.md b/README.md
index 60743e433..27d01c317 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,9 @@ Add issues on https://gitlab.com/bashrc2/epicyon/-/issues
-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
diff --git a/README_architecture.md b/README_architecture.md
new file mode 100644
index 000000000..a7dff92aa
--- /dev/null
+++ b/README_architecture.md
@@ -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.
+
+
diff --git a/README_commandline.md b/README_commandline.md
index a8bfc9c92..9529671b9 100644
--- a/README_commandline.md
+++ b/README_commandline.md
@@ -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:
diff --git a/README_customizations.md b/README_customizations.md
index 841596df8..a6f86ad7d 100644
--- a/README_customizations.md
+++ b/README_customizations.md
@@ -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.
diff --git a/README_desktop_client.md b/README_desktop_client.md
new file mode 100644
index 000000000..a1ac25fe6
--- /dev/null
+++ b/README_desktop_client.md
@@ -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.
diff --git a/README_goals.md b/README_goals.md
index 1fd443003..8403bba5c 100644
--- a/README_goals.md
+++ b/README_goals.md
@@ -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
diff --git a/README_roadmap.md b/README_roadmap.md
index 62b2ba3ba..bfc0ddc4f 100644
--- a/README_roadmap.md
+++ b/README_roadmap.md
@@ -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
\ No newline at end of file
+ * More unit test coverage
+ * Break up large functions into smaller ones
+ * Architecture diagrams
+ * Code documentation?
diff --git a/acceptreject.py b/acceptreject.py
index d3f4c50cd..e61b6d558 100644
--- a/acceptreject.py
+++ b/acceptreject.py
@@ -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)
diff --git a/announce.py b/announce.py
index c8c54c61a..92c412c5c 100644
--- a/announce.py
+++ b/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)
diff --git a/architecture/epicyon_groups_ActivityPub.png b/architecture/epicyon_groups_ActivityPub.png
new file mode 100644
index 000000000..d8928af1c
Binary files /dev/null and b/architecture/epicyon_groups_ActivityPub.png differ
diff --git a/architecture/epicyon_groups_ActivityPub_Core.png b/architecture/epicyon_groups_ActivityPub_Core.png
new file mode 100644
index 000000000..4dfb426e5
Binary files /dev/null and b/architecture/epicyon_groups_ActivityPub_Core.png differ
diff --git a/architecture/epicyon_groups_ActivityPub_Security.png b/architecture/epicyon_groups_ActivityPub_Security.png
new file mode 100644
index 000000000..c68653ec0
Binary files /dev/null and b/architecture/epicyon_groups_ActivityPub_Security.png differ
diff --git a/architecture/epicyon_groups_Commandline-Interface_ActivityPub.png b/architecture/epicyon_groups_Commandline-Interface_ActivityPub.png
new file mode 100644
index 000000000..5aabb8f9d
Binary files /dev/null and b/architecture/epicyon_groups_Commandline-Interface_ActivityPub.png differ
diff --git a/architecture/epicyon_groups_Commandline-Interface_Core.png b/architecture/epicyon_groups_Commandline-Interface_Core.png
new file mode 100644
index 000000000..5fdd5bd0e
Binary files /dev/null and b/architecture/epicyon_groups_Commandline-Interface_Core.png differ
diff --git a/architecture/epicyon_groups_Core.png b/architecture/epicyon_groups_Core.png
new file mode 100644
index 000000000..5c4885c9f
Binary files /dev/null and b/architecture/epicyon_groups_Core.png differ
diff --git a/architecture/epicyon_groups_Core_Accessibility.png b/architecture/epicyon_groups_Core_Accessibility.png
new file mode 100644
index 000000000..8a860e0f4
Binary files /dev/null and b/architecture/epicyon_groups_Core_Accessibility.png differ
diff --git a/architecture/epicyon_groups_Core_Security.png b/architecture/epicyon_groups_Core_Security.png
new file mode 100644
index 000000000..5cdf37b91
Binary files /dev/null and b/architecture/epicyon_groups_Core_Security.png differ
diff --git a/architecture/epicyon_groups_Timeline_Core.png b/architecture/epicyon_groups_Timeline_Core.png
new file mode 100644
index 000000000..50f67dd04
Binary files /dev/null and b/architecture/epicyon_groups_Timeline_Core.png differ
diff --git a/architecture/epicyon_groups_Timeline_Security.png b/architecture/epicyon_groups_Timeline_Security.png
new file mode 100644
index 000000000..4a27e079b
Binary files /dev/null and b/architecture/epicyon_groups_Timeline_Security.png differ
diff --git a/architecture/epicyon_groups_Web-Interface-Columns_Core.png b/architecture/epicyon_groups_Web-Interface-Columns_Core.png
new file mode 100644
index 000000000..4fc87f114
Binary files /dev/null and b/architecture/epicyon_groups_Web-Interface-Columns_Core.png differ
diff --git a/architecture/epicyon_groups_Web-Interface_Accessibility.png b/architecture/epicyon_groups_Web-Interface_Accessibility.png
new file mode 100644
index 000000000..738fd1f73
Binary files /dev/null and b/architecture/epicyon_groups_Web-Interface_Accessibility.png differ
diff --git a/architecture/epicyon_groups_Web-Interface_Core.png b/architecture/epicyon_groups_Web-Interface_Core.png
new file mode 100644
index 000000000..49e98b3a2
Binary files /dev/null and b/architecture/epicyon_groups_Web-Interface_Core.png differ
diff --git a/architecture/groups_Timeline_Security.png b/architecture/groups_Timeline_Security.png
new file mode 100644
index 000000000..4a27e079b
Binary files /dev/null and b/architecture/groups_Timeline_Security.png differ
diff --git a/auth.py b/auth.py
index 5d3dbdf8e..5103365f3 100644
--- a/auth.py
+++ b/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
diff --git a/availability.py b/availability.py
index d0c116c41..ce7f01d11 100644
--- a/availability.py
+++ b/availability.py
@@ -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')
diff --git a/blocking.py b/blocking.py
index 29aa2a45b..6988bc310 100644
--- a/blocking.py
+++ b/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
diff --git a/blog.py b/blog.py
index a8dfcbaa8..7823ae3b0 100644
--- a/blog.py
+++ b/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 '
' in content:
+ content = content.split('
', 1)[1]
+ if '
' in content:
+ content = content.split('', 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 += \
' '
+ str(messageBoxHeight) + 'px" spellcheck="true">' + \
+ contentStr + ''
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')
diff --git a/bookmarks.py b/bookmarks.py
index 4749ba505..cc27e1aba 100644
--- a/bookmarks.py
+++ b/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)
diff --git a/briar.py b/briar.py
index 63a3123b1..6e3f1e1d0 100644
--- a/briar.py
+++ b/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:
diff --git a/cache.py b/cache.py
index 19cda4084..8d6291316 100644
--- a/cache.py
+++ b/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)
diff --git a/categories.py b/categories.py
index a99241b73..06f0d4056 100644
--- a/categories.py
+++ b/categories.py
@@ -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
diff --git a/city.py b/city.py
new file mode 100644
index 000000000..161ef9b5a
--- /dev/null
+++ b/city.py
@@ -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
diff --git a/code-of-conduct.md b/code-of-conduct.md
index 01c77166c..73e4b2a1e 100644
--- a/code-of-conduct.md
+++ b/code-of-conduct.md
@@ -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.
diff --git a/content.py b/content.py
index bc3db29e6..3f3b1d8ec 100644
--- a/content.py
+++ b/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 '
"' in content:
if '"
' in content:
@@ -106,6 +113,8 @@ def htmlReplaceQuoteMarks(content: str) -> str:
"""Replaces quotes with html formatting
"hello" becomes hello
"""
+ 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('
'):
+ wordStr = wordStr.replace('
', '')
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-- ', '
'
@@ -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
diff --git a/context.py b/context.py
index 11d9b727d..9afa78d79 100644
--- a/context.py
+++ b/context.py
@@ -5,6 +5,7 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
+__module_group__ = "Security"
validContexts = (
diff --git a/cwtch.py b/cwtch.py
new file mode 100644
index 000000000..9619067f1
--- /dev/null
+++ b/cwtch.py
@@ -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)
diff --git a/daemon.py b/daemon.py
index 8f1dfd3aa..4f247e267 100644
--- a/daemon.py
+++ b/daemon.py
@@ -5,12 +5,12 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
+__module_group__ = "Core"
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer, HTTPServer
import sys
import json
import time
-import locale
import urllib.parse
import datetime
from socket import error as SocketError
@@ -25,11 +25,9 @@ from webfinger import webfingerMeta
from webfinger import webfingerNodeInfo
from webfinger import webfingerLookup
from webfinger import webfingerUpdate
-from mastoapiv1 import getMastoApiV1Account
-from mastoapiv1 import getMastApiV1Id
-from mastoapiv1 import getNicknameFromMastoApiV1Id
-from metadata import metaDataInstance
+from mastoapiv1 import mastoApiV1Response
from metadata import metaDataNodeInfo
+from metadata import metadataCustomEmoji
from pgp import getEmailAddress
from pgp import setEmailAddress
from pgp import getPGPpubKey
@@ -46,6 +44,8 @@ from briar import getBriarAddress
from briar import setBriarAddress
from jami import getJamiAddress
from jami import setJamiAddress
+from cwtch import getCwtchAddress
+from cwtch import setCwtchAddress
from matrix import getMatrixAddress
from matrix import setMatrixAddress
from donate import getDonationUrl
@@ -68,21 +68,19 @@ from person import removeAccount
from person import canRemovePost
from person import personSnooze
from person import personUnsnooze
+from posts import removePostInteractions
from posts import outboxMessageCreateWrap
from posts import getPinnedPostAsJson
from posts import pinPost
from posts import jsonPinPost
from posts import undoPinnedPost
from posts import isModerator
-from posts import mutePost
-from posts import unmutePost
from posts import createQuestionPost
from posts import createPublicPost
from posts import createBlogPost
from posts import createReportPost
from posts import createUnlistedPost
from posts import createFollowersOnlyPost
-from posts import createEventPost
from posts import createDirectMessagePost
from posts import populateRepliesJson
from posts import addToField
@@ -100,6 +98,12 @@ from follow import getFollowingFeed
from follow import sendFollowRequest
from follow import unfollowAccount
from follow import createInitialLastSeen
+from skills import getSkillsFromList
+from skills import noOfActorSkills
+from skills import actorHasSkill
+from skills import actorSkillValue
+from skills import setActorSkillLevel
+from auth import recordLoginFailure
from auth import authorize
from auth import createPassword
from auth import createBasicAuthHeader
@@ -109,7 +113,13 @@ from threads import threadWithTrace
from threads import removeDormantThreads
from media import replaceYouTube
from media import attachMedia
+from media import pathIsVideo
+from media import pathIsAudio
+from blocking import updateBlockedCache
+from blocking import mutePost
+from blocking import unmutePost
from blocking import setBrochMode
+from blocking import brochModeIsActive
from blocking import addBlock
from blocking import removeBlock
from blocking import addGlobalBlock
@@ -117,23 +127,31 @@ from blocking import removeGlobalBlock
from blocking import isBlockedHashtag
from blocking import isBlockedDomain
from blocking import getDomainBlocklist
+from roles import getActorRolesList
from roles import setRole
from roles import clearModeratorStatus
from roles import clearEditorStatus
+from roles import clearCounselorStatus
+from roles import clearArtistStatus
+from blog import pathContainsBlogLink
from blog import htmlBlogPageRSS2
from blog import htmlBlogPageRSS3
from blog import htmlBlogView
from blog import htmlBlogPage
from blog import htmlBlogPost
from blog import htmlEditBlog
+from blog import getBlogAddress
+from webapp_minimalbutton import setMinimal
+from webapp_minimalbutton import isMinimal
from webapp_utils import getAvatarImageUrl
from webapp_utils import htmlHashtagBlocked
from webapp_utils import htmlFollowingList
from webapp_utils import setBlogAddress
-from webapp_utils import getBlogAddress
from webapp_calendar import htmlCalendarDeleteConfirm
from webapp_calendar import htmlCalendar
from webapp_about import htmlAbout
+from webapp_accesskeys import htmlAccessKeys
+from webapp_accesskeys import loadAccessKeysForAccounts
from webapp_confirm import htmlConfirmDelete
from webapp_confirm import htmlConfirmRemoveSharedItem
from webapp_confirm import htmlConfirmUnblock
@@ -141,7 +159,6 @@ from webapp_person_options import htmlPersonOptions
from webapp_timeline import htmlShares
from webapp_timeline import htmlInbox
from webapp_timeline import htmlBookmarks
-from webapp_timeline import htmlEvents
from webapp_timeline import htmlInboxDMs
from webapp_timeline import htmlInboxReplies
from webapp_timeline import htmlInboxMedia
@@ -181,11 +198,28 @@ from webapp_search import htmlSearchEmojiTextEntry
from webapp_search import htmlSearch
from webapp_hashtagswarm import getHashtagCategoriesFeed
from webapp_hashtagswarm import htmlSearchHashtagCategory
+from webapp_welcome import welcomeScreenIsComplete
+from webapp_welcome import htmlWelcomeScreen
+from webapp_welcome import isWelcomeScreenComplete
+from webapp_welcome_profile import htmlWelcomeProfile
+from webapp_welcome_final import htmlWelcomeFinal
from shares import getSharesFeedForPerson
from shares import addShare
from shares import removeShare
from shares import expireShares
from categories import setHashtagCategory
+from utils import acctDir
+from utils import getImageExtensionFromMimeType
+from utils import getImageMimeType
+from utils import hasObjectDict
+from utils import userAgentDomain
+from utils import isLocalNetworkAddress
+from utils import permittedDir
+from utils import isAccountDir
+from utils import getOccupationSkills
+from utils import getOccupationName
+from utils import setOccupationName
+from utils import loadTranslationsFromFile
from utils import getLocalNetworkAddresses
from utils import decodedHost
from utils import isPublicPost
@@ -194,6 +228,7 @@ from utils import hasUsersPath
from utils import getFullDomain
from utils import removeHtml
from utils import isEditor
+from utils import isArtist
from utils import getImageExtensions
from utils import mediaFileMimeType
from utils import getCSS
@@ -221,6 +256,7 @@ from utils import saveJson
from utils import isSuspended
from utils import dangerousMarkup
from utils import refreshNewswire
+from utils import isImageFile
from manualapprove import manualDenyFollowRequest
from manualapprove import manualApproveFollowRequest
from announce import createAnnounce
@@ -229,11 +265,14 @@ from content import addHtmlTags
from content import extractMediaInFormPOST
from content import saveMediaInFormPOST
from content import extractTextFieldsInPOST
-from media import removeMetaData
+from media import processMetaData
from cache import checkForChangedActor
from cache import storePersonInCache
from cache import getPersonFromCache
from httpsig import verifyPostHeaders
+from theme import importTheme
+from theme import exportTheme
+from theme import isNewsThemeName
from theme import getTextModeBanner
from theme import setNewsAvatar
from theme import setTheme
@@ -250,6 +289,8 @@ from bookmarks import undoBookmark
from petnames import setPetName
from followingCalendar import addPersonToCalendar
from followingCalendar import removePersonFromCalendar
+from notifyOnPost import addNotifyOnPost
+from notifyOnPost import removeNotifyOnPost
from devices import E2EEdevicesCollection
from devices import E2EEvalidDevice
from devices import E2EEaddDevice
@@ -263,6 +304,8 @@ from filters import isFiltered
from filters import addGlobalFilter
from filters import removeGlobalFilter
from context import hasValidContext
+from speaker import getSSMLbox
+from city import getSpoofedCity
import os
@@ -300,67 +343,45 @@ def saveDomainQrcode(baseDir: str, httpPrefix: str,
class PubServer(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
- def _pathIsImage(self, path: str) -> bool:
- if path.endswith('.png') or \
- path.endswith('.jpg') or \
- path.endswith('.gif') or \
- path.endswith('.svg') or \
- path.endswith('.avif') or \
- path.endswith('.webp'):
- return True
- return False
+ def _getInstalceUrl(self, callingDomain: str) -> str:
+ """Returns the URL for this instance
+ """
+ if callingDomain.endswith('.onion') and \
+ self.server.onionDomain:
+ instanceUrl = 'http://' + self.server.onionDomain
+ elif (callingDomain.endswith('.i2p') and
+ self.server.i2pDomain):
+ instanceUrl = 'http://' + self.server.i2pDomain
+ else:
+ instanceUrl = \
+ self.server.httpPrefix + '://' + self.server.domainFull
+ return instanceUrl
- def _pathIsVideo(self, path: str) -> bool:
- if path.endswith('.ogv') or \
- path.endswith('.mp4'):
- return True
- return False
-
- def _pathIsAudio(self, path: str) -> bool:
- if path.endswith('.ogg') or \
- path.endswith('.mp3'):
- return True
- return False
+ def _getheaderSignatureInput(self):
+ """There are different versions of http signatures with
+ different header styles
+ """
+ if self.headers.get('Signature-Input'):
+ # https://tools.ietf.org/html/
+ # draft-ietf-httpbis-message-signatures-01
+ return self.headers['Signature-Input']
+ elif self.headers.get('signature'):
+ # Ye olde Masto http sig
+ return self.headers['signature']
+ return None
def handle_error(self, request, client_address):
print('ERROR: http server error: ' + str(request) + ', ' +
str(client_address))
pass
- def _isMinimal(self, nickname: str) -> bool:
- """Returns true if minimal buttons should be shown
- for the given account
- """
- accountDir = self.server.baseDir + '/accounts/' + \
- nickname + '@' + self.server.domain
- if not os.path.isdir(accountDir):
- return True
- minimalFilename = accountDir + '/.notminimal'
- if os.path.isfile(minimalFilename):
- return False
- return True
-
- def _setMinimal(self, nickname: str, minimal: bool) -> None:
- """Sets whether an account should display minimal buttons
- """
- accountDir = self.server.baseDir + '/accounts/' + \
- nickname + '@' + self.server.domain
- if not os.path.isdir(accountDir):
- return
- minimalFilename = accountDir + '/.notminimal'
- minimalFileExists = os.path.isfile(minimalFilename)
- if minimal and minimalFileExists:
- os.remove(minimalFilename)
- elif not minimal and not minimalFileExists:
- with open(minimalFilename, 'w+') as fp:
- fp.write('\n')
-
def _sendReplyToQuestion(self, nickname: str, messageId: str,
answer: str) -> None:
"""Sends a reply to a question
"""
- votesFilename = self.server.baseDir + '/accounts/' + \
- nickname + '@' + self.server.domain + '/questions.txt'
+ votesFilename = \
+ acctDir(self.server.baseDir, nickname, self.server.domain) + \
+ '/questions.txt'
if os.path.isfile(votesFilename):
# have we already voted on this?
@@ -381,6 +402,10 @@ class PubServer(BaseHTTPRequestHandler):
eventDate = None
eventTime = None
location = None
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ nickname, self.server.domain)
+
messageJson = \
createPublicPost(self.server.baseDir,
nickname,
@@ -389,7 +414,7 @@ class PubServer(BaseHTTPRequestHandler):
answer, False, False, False,
commentsEnabled,
attachImageFilename, mediaType,
- imageDescription,
+ imageDescription, city,
inReplyTo,
inReplyToAtomUri,
subject,
@@ -414,10 +439,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.maxReplies,
self.server.debug)
# record the vote
- votesFile = open(votesFilename, 'a+')
- if votesFile:
+ with open(votesFilename, 'a+') as votesFile:
votesFile.write(messageId + '\n')
- votesFile.close()
# ensure that the cached post is removed if it exists,
# so that it then will be recreated
@@ -437,55 +460,82 @@ class PubServer(BaseHTTPRequestHandler):
else:
print('ERROR: unable to create vote')
- def _removePostInteractions(self, postJsonObject: {}) -> None:
- """Removes potentially sensitive interactions from a post
- This is the type of thing which would be of interest to marketers
- or of saleable value to them. eg. Knowing who likes who or what.
+ def _blockedUserAgent(self, callingDomain: str) -> bool:
+ """Should a GET or POST be blocked based upon its user agent?
"""
- if postJsonObject.get('likes'):
- postJsonObject['likes'] = {'items': []}
- if postJsonObject.get('shares'):
- postJsonObject['shares'] = {}
- if postJsonObject.get('replies'):
- postJsonObject['replies'] = {}
- if postJsonObject.get('bookmarks'):
- postJsonObject['bookmarks'] = {}
- if not postJsonObject.get('object'):
- return
- if not isinstance(postJsonObject['object'], dict):
- return
- if postJsonObject['object'].get('likes'):
- postJsonObject['object']['likes'] = {'items': []}
- if postJsonObject['object'].get('shares'):
- postJsonObject['object']['shares'] = {}
- if postJsonObject['object'].get('replies'):
- postJsonObject['object']['replies'] = {}
- if postJsonObject['object'].get('bookmarks'):
- postJsonObject['object']['bookmarks'] = {}
+ agentDomain = None
+ agentStr = None
+ if self.headers.get('User-Agent'):
+ agentStr = self.headers['User-Agent']
+ # is this a web crawler? If so the block it
+ agentStrLower = agentStr.lower()
+ if 'bot/' in agentStrLower or 'bot-' in agentStrLower:
+ if self.server.newsInstance:
+ return False
+ print('Blocked Crawler: ' + agentStr)
+ return True
+ # get domain name from User-Agent
+ agentDomain = userAgentDomain(agentStr, self.server.debug)
+ else:
+ # no User-Agent header is present
+ return True
+
+ # is the User-Agent type blocked? eg. "Mastodon"
+ if self.server.userAgentsBlocked:
+ blockedUA = False
+ for agentName in self.server.userAgentsBlocked:
+ if agentName in agentStr:
+ blockedUA = True
+ break
+ if blockedUA:
+ return True
+
+ if not agentDomain:
+ return False
+
+ # is the User-Agent domain blocked
+ blockedUA = False
+ if not agentDomain.startswith(callingDomain):
+ self.server.blockedCacheLastUpdated = \
+ updateBlockedCache(self.server.baseDir,
+ self.server.blockedCache,
+ self.server.blockedCacheLastUpdated,
+ self.server.blockedCacheUpdateSecs)
+
+ blockedUA = isBlockedDomain(self.server.baseDir, agentDomain,
+ self.server.blockedCache)
+ # if self.server.debug:
+ if blockedUA:
+ print('Blocked User agent: ' + agentDomain)
+ return blockedUA
def _requestHTTP(self) -> bool:
"""Should a http response be given?
"""
if not self.headers.get('Accept'):
return False
+ acceptStr = self.headers['Accept']
if self.server.debug:
- print('ACCEPT: ' + self.headers['Accept'])
- if 'image/' in self.headers['Accept']:
- if 'text/html' not in self.headers['Accept']:
+ print('ACCEPT: ' + acceptStr)
+ if 'application/ssml' in acceptStr:
+ if 'text/html' not in acceptStr:
return False
- if 'video/' in self.headers['Accept']:
- if 'text/html' not in self.headers['Accept']:
+ if 'image/' in acceptStr:
+ if 'text/html' not in acceptStr:
return False
- if 'audio/' in self.headers['Accept']:
- if 'text/html' not in self.headers['Accept']:
+ if 'video/' in acceptStr:
+ if 'text/html' not in acceptStr:
return False
- if self.headers['Accept'].startswith('*'):
+ if 'audio/' in acceptStr:
+ if 'text/html' not in acceptStr:
+ return False
+ if acceptStr.startswith('*'):
if self.headers.get('User-Agent'):
if 'ELinks' in self.headers['User-Agent'] or \
'Lynx' in self.headers['User-Agent']:
return True
return False
- if 'json' in self.headers['Accept']:
+ if 'json' in acceptStr:
return False
return True
@@ -560,9 +610,6 @@ class PubServer(BaseHTTPRequestHandler):
self.send_header('Host', callingDomain)
self.send_header('WWW-Authenticate',
'title="Login to Epicyon", Basic realm="epicyon"')
- self.send_header('X-Robots-Tag',
- 'noindex, nofollow, noarchive, nosnippet')
- self.send_header('Referrer-Policy', 'origin')
self.end_headers()
def _logout_headers(self, fileFormat: str, length: int,
@@ -574,11 +621,18 @@ class PubServer(BaseHTTPRequestHandler):
self.send_header('Host', callingDomain)
self.send_header('WWW-Authenticate',
'title="Login to Epicyon", Basic realm="epicyon"')
- self.send_header('X-Robots-Tag',
- 'noindex, nofollow, noarchive, nosnippet')
- self.send_header('Referrer-Policy', 'origin')
self.end_headers()
+ def _quoted_redirect(self, redirect: str) -> str:
+ """hashtag screen urls sometimes contain non-ascii characters which
+ need to be url encoded
+ """
+ if '/tags/' not in redirect:
+ return redirect
+ lastStr = redirect.split('/')[-1]
+ return redirect.replace('/' + lastStr, '/' +
+ urllib.parse.quote_plus(lastStr))
+
def _logout_redirect(self, redirect: str, cookie: str,
callingDomain: str) -> None:
if '://' not in redirect:
@@ -587,13 +641,10 @@ class PubServer(BaseHTTPRequestHandler):
self.send_response(303)
self.send_header('Set-Cookie', 'epicyon=; SameSite=Strict')
- self.send_header('Location', redirect)
+ self.send_header('Location', self._quoted_redirect(redirect))
self.send_header('Host', callingDomain)
self.send_header('InstanceID', self.server.instanceId)
self.send_header('Content-Length', '0')
- self.send_header('X-Robots-Tag',
- 'noindex, nofollow, noarchive, nosnippet')
- self.send_header('Referrer-Policy', 'origin')
self.end_headers()
def _set_headers_base(self, fileFormat: str, length: int, cookie: str,
@@ -611,16 +662,13 @@ class PubServer(BaseHTTPRequestHandler):
self.send_header('Cookie', cookieStr)
self.send_header('Host', callingDomain)
self.send_header('InstanceID', self.server.instanceId)
- self.send_header('X-Robots-Tag',
- 'noindex, nofollow, noarchive, nosnippet')
self.send_header('X-Clacks-Overhead', 'GNU Natalie Nguyen')
- self.send_header('Referrer-Policy', 'origin')
- self.send_header('Accept-Ranges', 'none')
+ self.send_header('Cache-Control', 'max-age=0')
+ self.send_header('Cache-Control', 'public')
def _set_headers(self, fileFormat: str, length: int, cookie: str,
callingDomain: str) -> None:
self._set_headers_base(fileFormat, length, cookie, callingDomain)
- self.send_header('Cache-Control', 'public, max-age=0')
self.end_headers()
def _set_headers_head(self, fileFormat: str, length: int, etag: str,
@@ -634,7 +682,7 @@ class PubServer(BaseHTTPRequestHandler):
data, cookie: str, callingDomain: str) -> None:
datalen = len(data)
self._set_headers_base(fileFormat, datalen, cookie, callingDomain)
- self.send_header('Cache-Control', 'public, max-age=86400')
+ # self.send_header('Cache-Control', 'public, max-age=86400')
etag = None
if os.path.isfile(mediaFilename + '.etag'):
try:
@@ -695,13 +743,10 @@ class PubServer(BaseHTTPRequestHandler):
self.send_header('Cookie', cookieStr)
else:
self.send_header('Set-Cookie', cookieStr)
- self.send_header('Location', redirect)
+ self.send_header('Location', self._quoted_redirect(redirect))
self.send_header('Host', callingDomain)
self.send_header('InstanceID', self.server.instanceId)
self.send_header('Content-Length', '0')
- self.send_header('X-Robots-Tag',
- 'noindex, nofollow, noarchive, nosnippet')
- self.send_header('Referrer-Policy', 'origin')
self.end_headers()
def _httpReturnCode(self, httpCode: int, httpDescription: str,
@@ -721,9 +766,6 @@ class PubServer(BaseHTTPRequestHandler):
self.send_header('Content-Type', 'text/html; charset=utf-8')
msgLenStr = str(len(msg))
self.send_header('Content-Length', msgLenStr)
- self.send_header('X-Robots-Tag',
- 'noindex, nofollow, noarchive, nosnippet')
- self.send_header('Referrer-Policy', 'origin')
self.end_headers()
if not self._write(msg):
print('Error when showing ' + str(httpCode))
@@ -789,8 +831,12 @@ class PubServer(BaseHTTPRequestHandler):
try:
self.wfile.write(msg)
return True
+ except BrokenPipeError as e:
+ if self.server.debug:
+ print('ERROR: _write error ' + str(tries) + ' ' + str(e))
+ break
except Exception as e:
- print(e)
+ print('ERROR: _write error ' + str(tries) + ' ' + str(e))
time.sleep(0.5)
tries += 1
return False
@@ -807,6 +853,8 @@ class PubServer(BaseHTTPRequestHandler):
return True
def _hasAccept(self, callingDomain: str) -> bool:
+ """Do the http headers have an Accept field?
+ """
if self.headers.get('Accept') or callingDomain.endswith('.b32.i2p'):
if not self.headers.get('Accept'):
self.headers['Accept'] = \
@@ -819,7 +867,14 @@ class PubServer(BaseHTTPRequestHandler):
authorized: bool,
httpPrefix: str,
baseDir: str, nickname: str, domain: str,
- domainFull: str) -> bool:
+ domainFull: str,
+ onionDomain: str, i2pDomain: str,
+ translate: {},
+ registration: bool,
+ systemLanguage: str,
+ projectVersion: str,
+ customEmoji: [],
+ showNodeInfoAccounts: bool) -> bool:
"""This is a vestigil mastodon API for the purpose
of returning an empty result to sites like
https://mastopeek.app-dist.eu
@@ -830,109 +885,23 @@ class PubServer(BaseHTTPRequestHandler):
print('mastodon api v1: authorized ' + str(authorized))
print('mastodon api v1: nickname ' + str(nickname))
- sendJson = None
- sendJsonStr = ''
-
- # parts of the api needing authorization
- if authorized and nickname:
- if path == '/api/v1/accounts/verify_credentials':
- sendJson = getMastoApiV1Account(baseDir, nickname, domain)
- sendJsonStr = 'masto API account sent for ' + nickname
-
- # Parts of the api which don't need authorization
- mastoId = getMastApiV1Id(path)
- if mastoId is not None:
- pathNickname = getNicknameFromMastoApiV1Id(mastoId)
- if pathNickname:
- originalPath = path
- if '/followers?' in path or \
- '/following?' in path or \
- '/search?' in path or \
- '/relationships?' in path or \
- '/statuses?' in path:
- path = path.split('?')[0]
- if path.endswith('/followers'):
- sendJson = []
- sendJsonStr = 'masto API followers sent for ' + nickname
- elif path.endswith('/following'):
- sendJson = []
- sendJsonStr = 'masto API following sent for ' + nickname
- elif path.endswith('/statuses'):
- sendJson = []
- sendJsonStr = 'masto API statuses sent for ' + nickname
- elif path.endswith('/search'):
- sendJson = []
- sendJsonStr = 'masto API search sent ' + originalPath
- elif path.endswith('/relationships'):
- sendJson = []
- sendJsonStr = \
- 'masto API relationships sent ' + originalPath
- else:
- sendJson = \
- getMastoApiV1Account(baseDir, pathNickname, domain)
- sendJsonStr = 'masto API account sent for ' + nickname
-
- if path.startswith('/api/v1/blocks'):
- sendJson = []
- sendJsonStr = 'masto API instance blocks sent'
- elif path.startswith('/api/v1/favorites'):
- sendJson = []
- sendJsonStr = 'masto API favorites sent'
- elif path.startswith('/api/v1/follow_requests'):
- sendJson = []
- sendJsonStr = 'masto API follow requests sent'
- elif path.startswith('/api/v1/mutes'):
- sendJson = []
- sendJsonStr = 'masto API mutes sent'
- elif path.startswith('/api/v1/notifications'):
- sendJson = []
- sendJsonStr = 'masto API notifications sent'
- elif path.startswith('/api/v1/reports'):
- sendJson = []
- sendJsonStr = 'masto API reports sent'
- elif path.startswith('/api/v1/statuses'):
- sendJson = []
- sendJsonStr = 'masto API statuses sent'
- elif path.startswith('/api/v1/timelines'):
- sendJson = []
- sendJsonStr = 'masto API timelines sent'
-
- adminNickname = getConfigParam(self.server.baseDir, 'admin')
- if adminNickname and path == '/api/v1/instance':
- instanceDescriptionShort = \
- getConfigParam(self.server.baseDir,
- 'instanceDescriptionShort')
- if not instanceDescriptionShort:
- instanceDescriptionShort = \
- self.server.translate['Yet another Epicyon Instance']
- instanceDescription = getConfigParam(self.server.baseDir,
- 'instanceDescription')
- instanceTitle = getConfigParam(self.server.baseDir,
- 'instanceTitle')
- sendJson = \
- metaDataInstance(instanceTitle,
- instanceDescriptionShort,
- instanceDescription,
- self.server.httpPrefix,
- self.server.baseDir,
- adminNickname,
- self.server.domain,
- self.server.domainFull,
- self.server.registration,
- self.server.systemLanguage,
- self.server.projectVersion)
- sendJsonStr = 'masto API instance metadata sent'
- elif path.startswith('/api/v1/instance/peers'):
- # This is just a dummy result.
- # Showing the full list of peers would have privacy implications.
- # On a large instance you are somewhat lost in the crowd, but on
- # small instances a full list of peers would convey a lot of
- # information about the interests of a small number of accounts
- sendJson = ['mastodon.social', self.server.domainFull]
- sendJsonStr = 'masto API peers metadata sent'
- elif path.startswith('/api/v1/instance/activity'):
- sendJson = []
- sendJsonStr = 'masto API activity metadata sent'
+ brochMode = brochModeIsActive(baseDir)
+ sendJson, sendJsonStr = mastoApiV1Response(path,
+ callingDomain,
+ authorized,
+ httpPrefix,
+ baseDir,
+ nickname, domain,
+ domainFull,
+ onionDomain,
+ i2pDomain,
+ translate,
+ registration,
+ systemLanguage,
+ projectVersion,
+ customEmoji,
+ showNodeInfoAccounts,
+ brochMode)
if sendJson is not None:
msg = json.dumps(sendJson).encode('utf-8')
@@ -959,19 +928,50 @@ class PubServer(BaseHTTPRequestHandler):
def _mastoApi(self, path: str, callingDomain: str,
authorized: bool, httpPrefix: str,
baseDir: str, nickname: str, domain: str,
- domainFull: str) -> bool:
+ domainFull: str,
+ onionDomain: str, i2pDomain: str,
+ translate: {},
+ registration: bool,
+ systemLanguage: str,
+ projectVersion: str,
+ customEmoji: [],
+ showNodeInfoAccounts: bool) -> bool:
return self._mastoApiV1(path, callingDomain, authorized,
httpPrefix, baseDir, nickname, domain,
- domainFull)
+ domainFull, onionDomain, i2pDomain,
+ translate, registration, systemLanguage,
+ projectVersion, customEmoji,
+ showNodeInfoAccounts)
def _nodeinfo(self, callingDomain: str) -> bool:
if not self.path.startswith('/nodeinfo/2.0'):
return False
if self.server.debug:
print('DEBUG: nodeinfo ' + self.path)
+
+ # If we are in broch mode then don't show potentially
+ # sensitive metadata.
+ # For example, if this or allied instances are being attacked
+ # then numbers of accounts may be changing as people
+ # migrate, and that information may be useful to an adversary
+ brochMode = brochModeIsActive(self.server.baseDir)
+
+ nodeInfoVersion = self.server.projectVersion
+ if not self.server.showNodeInfoVersion or brochMode:
+ nodeInfoVersion = '0.0.0'
+
+ showNodeInfoAccounts = self.server.showNodeInfoAccounts
+ if brochMode:
+ showNodeInfoAccounts = False
+
+ instanceUrl = self._getInstalceUrl(callingDomain)
+ aboutUrl = instanceUrl + '/about'
+ termsOfServiceUrl = instanceUrl + '/terms'
info = metaDataNodeInfo(self.server.baseDir,
+ aboutUrl, termsOfServiceUrl,
self.server.registration,
- self.server.projectVersion)
+ nodeInfoVersion,
+ showNodeInfoAccounts)
if info:
msg = json.dumps(info).encode('utf-8')
msglen = len(msg)
@@ -1071,27 +1071,24 @@ class PubServer(BaseHTTPRequestHandler):
self._404()
return True
- def _permittedDir(self, path: str) -> bool:
- """These are special paths which should not be accessible
- directly via GET or POST
- """
- if path.startswith('/wfendpoints') or \
- path.startswith('/keys') or \
- path.startswith('/accounts'):
- return False
- return True
-
def _postToOutbox(self, messageJson: {}, version: str,
- postToNickname=None) -> bool:
+ postToNickname: str = None) -> bool:
"""post is received by the outbox
Client to server message post
https://www.w3.org/TR/activitypub/#client-to-server-outbox-delivery
"""
+ city = self.server.city
+
if postToNickname:
print('Posting to nickname ' + postToNickname)
self.postToNickname = postToNickname
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ postToNickname, self.server.domain)
- return postMessageToOutbox(messageJson, self.postToNickname,
+ return postMessageToOutbox(self.server.session,
+ self.server.translate,
+ messageJson, self.postToNickname,
self.server, self.server.baseDir,
self.server.httpPrefix,
self.server.domain,
@@ -1111,7 +1108,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.debug,
self.server.YTReplacementDomain,
self.server.showPublishedDateOnly,
- self.server.allowLocalNetworkAccess)
+ self.server.allowLocalNetworkAccess,
+ city)
def _postToOutboxThread(self, messageJson: {}) -> bool:
"""Creates a thread to send a post
@@ -1171,6 +1169,48 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return 3
+ # check that some additional fields are strings
+ stringFields = ('id', 'type', 'published')
+ for checkField in stringFields:
+ if not messageJson.get(checkField):
+ continue
+ if not isinstance(messageJson[checkField], str):
+ self._400()
+ self.server.POSTbusy = False
+ return 3
+
+ # check that to/cc fields are lists
+ listFields = ('to', 'cc')
+ for checkField in listFields:
+ if not messageJson.get(checkField):
+ continue
+ if not isinstance(messageJson[checkField], list):
+ self._400()
+ self.server.POSTbusy = False
+ return 3
+
+ if hasObjectDict(messageJson):
+ stringFields = (
+ 'id', 'actor', 'type', 'content', 'published',
+ 'summary', 'url', 'attributedTo'
+ )
+ for checkField in stringFields:
+ if not messageJson['object'].get(checkField):
+ continue
+ if not isinstance(messageJson['object'][checkField], str):
+ self._400()
+ self.server.POSTbusy = False
+ return 3
+ # check that some fields are lists
+ listFields = ('to', 'cc', 'attachment')
+ for checkField in listFields:
+ if not messageJson['object'].get(checkField):
+ continue
+ if not isinstance(messageJson['object'][checkField], list):
+ self._400()
+ self.server.POSTbusy = False
+ return 3
+
# actor should look like a url
if '://' not in messageJson['actor'] or \
'.' not in messageJson['actor']:
@@ -1193,7 +1233,15 @@ class PubServer(BaseHTTPRequestHandler):
messageDomain, messagePort = \
getDomainFromActor(messageJson['actor'])
- if isBlockedDomain(self.server.baseDir, messageDomain):
+
+ self.server.blockedCacheLastUpdated = \
+ updateBlockedCache(self.server.baseDir,
+ self.server.blockedCache,
+ self.server.blockedCacheLastUpdated,
+ self.server.blockedCacheUpdateSecs)
+
+ if isBlockedDomain(self.server.baseDir, messageDomain,
+ self.server.blockedCache):
print('POST from blocked domain ' + messageDomain)
self._400()
self.server.POSTbusy = False
@@ -1221,6 +1269,9 @@ class PubServer(BaseHTTPRequestHandler):
headersDict['Date'] = self.headers['Date']
if self.headers.get('digest'):
headersDict['digest'] = self.headers['digest']
+ if self.headers.get('Collection-Synchronization'):
+ headersDict['Collection-Synchronization'] = \
+ self.headers['Collection-Synchronization']
if self.headers.get('Content-type'):
headersDict['Content-type'] = self.headers['Content-type']
if self.headers.get('Content-Length'):
@@ -1230,19 +1281,22 @@ class PubServer(BaseHTTPRequestHandler):
originalMessageJson = messageJson.copy()
- # For follow activities add a 'to' field, which is a copy
- # of the object field
- messageJson, toFieldExists = \
- addToField('Follow', messageJson, self.server.debug)
-
- # For like activities add a 'to' field, which is a copy of
- # the actor within the object field
- messageJson, toFieldExists = \
- addToField('Like', messageJson, self.server.debug)
+ # whether to add a 'to' field to the message
+ addToFieldTypes = ('Follow', 'Like', 'Add', 'Remove', 'Ignore')
+ for addToType in addToFieldTypes:
+ messageJson, toFieldExists = \
+ addToField(addToType, messageJson, self.server.debug)
beginSaveTime = time.time()
# save the json for later queue processing
messageBytesDecoded = messageBytes.decode('utf-8')
+
+ self.server.blockedCacheLastUpdated = \
+ updateBlockedCache(self.server.baseDir,
+ self.server.blockedCache,
+ self.server.blockedCacheLastUpdated,
+ self.server.blockedCacheUpdateSecs)
+
queueFilename = \
savePostToInboxQueue(self.server.baseDir,
self.server.httpPrefix,
@@ -1252,7 +1306,8 @@ class PubServer(BaseHTTPRequestHandler):
messageBytesDecoded,
headersDict,
self.path,
- self.server.debug)
+ self.server.debug,
+ self.server.blockedCache)
if queueFilename:
# add json to the queue
if queueFilename not in self.server.inboxQueue:
@@ -1273,13 +1328,15 @@ class PubServer(BaseHTTPRequestHandler):
def _isAuthorized(self) -> bool:
self.authorizedNickname = None
- if self.path.startswith('/icons/') or \
- self.path.startswith('/avatars/') or \
- self.path.startswith('/favicon.ico') or \
- self.path.startswith('/newswire_favicon.ico') or \
- self.path.startswith('/categories.xml') or \
- self.path.startswith('/newswire.xml'):
- return False
+ notAuthPaths = (
+ '/icons/', '/avatars/',
+ '/accounts/avatars/', '/accounts/headers/',
+ '/favicon.ico', '/newswire.xml',
+ '/newswire_favicon.ico', '/categories.xml'
+ )
+ for notAuthStr in notAuthPaths:
+ if self.path.startswith(notAuthStr):
+ return False
# token based authenticated used by the web interface
if self.headers.get('Cookie'):
@@ -1303,8 +1360,9 @@ class PubServer(BaseHTTPRequestHandler):
return True
elif self.path.endswith('/' + nickname):
return True
- print('AUTH: nickname ' + nickname +
- ' was not found in path ' + self.path)
+ if self.server.debug:
+ print('AUTH: nickname ' + nickname +
+ ' was not found in path ' + self.path)
return False
print('AUTH: epicyon cookie ' +
'authorization failed, header=' +
@@ -1349,7 +1407,7 @@ class PubServer(BaseHTTPRequestHandler):
if GETtimings.get(prevGetId):
timeDiff = int(timeDiff - int(GETtimings[prevGetId]))
GETtimings[currGetId] = str(timeDiff)
- if logEvent:
+ if logEvent and self.server.debug:
print('GET TIMING ' + currGetId + ' = ' + str(timeDiff))
def _benchmarkPOSTtimings(self, POSTstartTime, POSTtimings: [],
@@ -1367,39 +1425,10 @@ class PubServer(BaseHTTPRequestHandler):
if logEvent:
ctr = 1
for timeDiff in POSTtimings:
- print('POST TIMING|' + str(ctr) + '|' + timeDiff)
+ if self.server.debug:
+ print('POST TIMING|' + str(ctr) + '|' + timeDiff)
ctr += 1
- def _pathContainsBlogLink(self, 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 = baseDir + '/accounts/' + \
- 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 _loginScreen(self, path: str, callingDomain: str, cookie: str,
baseDir: str, httpPrefix: str,
domain: str, domainFull: str, port: int,
@@ -1407,6 +1436,13 @@ class PubServer(BaseHTTPRequestHandler):
debug: bool) -> None:
"""Shows the login screen
"""
+ # ensure that there is a minimum delay between failed login
+ # attempts, to mitigate brute force
+ if int(time.time()) - self.server.lastLoginFailure < 5:
+ self._503()
+ self.server.POSTbusy = False
+ return
+
# get the contents of POST containing login credentials
length = int(self.headers['Content-length'])
if length > 512:
@@ -1429,15 +1465,16 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST login read failed')
- print(e)
+ print('ERROR: POST login read failed, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
return
loginNickname, loginPassword, register = \
- htmlGetLoginCredentials(loginParams, self.server.lastLoginTime)
+ htmlGetLoginCredentials(loginParams,
+ self.server.lastLoginTime,
+ self.server.domain)
if loginNickname:
if isSystemAccount(loginNickname):
print('Invalid username login: ' + loginNickname +
@@ -1466,14 +1503,33 @@ class PubServer(BaseHTTPRequestHandler):
return
authHeader = \
createBasicAuthHeader(loginNickname, loginPassword)
+ if self.headers.get('X-Forward-For'):
+ ipAddress = self.headers['X-Forward-For']
+ elif self.headers.get('X-Forwarded-For'):
+ ipAddress = self.headers['X-Forwarded-For']
+ else:
+ ipAddress = self.client_address[0]
+ if not domain.endswith('.onion'):
+ if not isLocalNetworkAddress(ipAddress):
+ print('Login attempt from IP: ' + str(ipAddress))
if not authorizeBasic(baseDir, '/users/' +
loginNickname + '/outbox',
authHeader, False):
print('Login failed: ' + loginNickname)
self._clearLoginDetails(loginNickname, callingDomain)
+ failTime = int(time.time())
+ self.server.lastLoginFailure = failTime
+ if not domain.endswith('.onion'):
+ if not isLocalNetworkAddress(ipAddress):
+ recordLoginFailure(baseDir, ipAddress,
+ self.server.loginFailureCount,
+ failTime,
+ self.server.logLoginFailures)
self.server.POSTbusy = False
return
else:
+ if self.server.loginFailureCount.get(ipAddress):
+ del self.server.loginFailureCount[ipAddress]
if isSuspended(baseDir, loginNickname):
msg = \
htmlSuspended(self.server.cssCache,
@@ -1491,8 +1547,7 @@ class PubServer(BaseHTTPRequestHandler):
# This produces a deterministic token based
# on nick+password+salt
saltFilename = \
- baseDir+'/accounts/' + \
- loginNickname + '@' + domain + '/.salt'
+ acctDir(baseDir, loginNickname, domain) + '/.salt'
salt = createPassword(32)
if os.path.isfile(saltFilename):
try:
@@ -1514,7 +1569,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.tokens[loginNickname] = token
loginHandle = loginNickname + '@' + domain
tokenFilename = \
- baseDir+'/accounts/' + \
+ baseDir + '/accounts/' + \
loginHandle + '/.token'
try:
with open(tokenFilename, 'w+') as fp:
@@ -1565,17 +1620,13 @@ class PubServer(BaseHTTPRequestHandler):
"""
usersPath = path.replace('/moderationaction', '')
nickname = usersPath.replace('/users/', '')
+ actorStr = self._getInstalceUrl(callingDomain) + usersPath
if not isModerator(self.server.baseDir, nickname):
- if callingDomain.endswith('.onion') and onionDomain:
- actorStr = 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and i2pDomain):
- actorStr = 'http://' + i2pDomain + usersPath
- self._redirect_headers(actorStr + '/moderation',
- cookie, callingDomain)
+ self._redirect_headers(actorStr + '/moderation',
+ cookie, callingDomain)
self.server.POSTbusy = False
return
- actorStr = httpPrefix + '://' + domainFull + usersPath
length = int(self.headers['Content-length'])
try:
@@ -1591,8 +1642,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST moderationParams rfile.read failed')
- print(e)
+ print('ERROR: POST moderationParams rfile.read failed, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -1679,7 +1729,7 @@ class PubServer(BaseHTTPRequestHandler):
print('moderationText: ' + moderationText)
nickname = moderationText
if nickname.startswith('http') or \
- nickname.startswith('dat'):
+ nickname.startswith('hyper'):
nickname = getNicknameFromActor(nickname)
if '@' in nickname:
nickname = nickname.split('@')[0]
@@ -1694,7 +1744,7 @@ class PubServer(BaseHTTPRequestHandler):
if moderationButton == 'block':
fullBlockDomain = None
if moderationText.startswith('http') or \
- moderationText.startswith('dat'):
+ moderationText.startswith('hyper'):
# https://domain
blockDomain, blockPort = \
getDomainFromActor(moderationText)
@@ -1712,7 +1762,7 @@ class PubServer(BaseHTTPRequestHandler):
if moderationButton == 'unblock':
fullBlockDomain = None
if moderationText.startswith('http') or \
- moderationText.startswith('dat'):
+ moderationText.startswith('hyper'):
# https://domain
blockDomain, blockPort = \
getDomainFromActor(moderationText)
@@ -1762,15 +1812,96 @@ class PubServer(BaseHTTPRequestHandler):
debug,
self.server.recentPostsCache)
- if callingDomain.endswith('.onion') and onionDomain:
- actorStr = 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and i2pDomain):
- actorStr = 'http://' + i2pDomain + usersPath
self._redirect_headers(actorStr + '/moderation',
cookie, callingDomain)
self.server.POSTbusy = False
return
+ def _keyShortcuts(self, path: str,
+ callingDomain: str, cookie: str,
+ baseDir: str, httpPrefix: str, nickname: str,
+ domain: str, domainFull: str, port: int,
+ onionDomain: str, i2pDomain: str,
+ debug: bool, accessKeys: {},
+ defaultTimeline: str) -> None:
+ """Receive POST from webapp_accesskeys
+ """
+ usersPath = '/users/' + nickname
+ originPathStr = \
+ httpPrefix + '://' + domainFull + usersPath + '/' + defaultTimeline
+ length = int(self.headers['Content-length'])
+
+ try:
+ accessKeysParams = self.rfile.read(length).decode('utf-8')
+ except SocketError as e:
+ if e.errno == errno.ECONNRESET:
+ print('WARN: POST accessKeysParams ' +
+ 'connection reset by peer')
+ else:
+ print('WARN: POST accessKeysParams socket error')
+ self.send_response(400)
+ self.end_headers()
+ self.server.POSTbusy = False
+ return
+ except ValueError as e:
+ print('ERROR: POST accessKeysParams rfile.read failed, ' + str(e))
+ self.send_response(400)
+ self.end_headers()
+ self.server.POSTbusy = False
+ return
+ accessKeysParams = \
+ urllib.parse.unquote_plus(accessKeysParams)
+
+ # key shortcuts screen, back button
+ # See htmlAccessKeys
+ if 'submitAccessKeysCancel=' in accessKeysParams or \
+ 'submitAccessKeys=' not in accessKeysParams:
+ if callingDomain.endswith('.onion') and onionDomain:
+ originPathStr = \
+ 'http://' + onionDomain + usersPath + '/' + defaultTimeline
+ elif callingDomain.endswith('.i2p') and i2pDomain:
+ originPathStr = \
+ 'http://' + i2pDomain + usersPath + '/' + defaultTimeline
+ self._redirect_headers(originPathStr, cookie, callingDomain)
+ self.server.POSTbusy = False
+ return
+
+ saveKeys = False
+ accessKeysTemplate = self.server.accessKeys
+ for variableName, key in accessKeysTemplate.items():
+ if not accessKeys.get(variableName):
+ accessKeys[variableName] = accessKeysTemplate[variableName]
+
+ variableName2 = variableName.replace(' ', '_')
+ if variableName2 + '=' in accessKeysParams:
+ newKey = accessKeysParams.split(variableName2 + '=')[1]
+ if '&' in newKey:
+ newKey = newKey.split('&')[0]
+ if newKey:
+ if len(newKey) > 1:
+ newKey = newKey[0]
+ if newKey != accessKeys[variableName]:
+ accessKeys[variableName] = newKey
+ saveKeys = True
+
+ if saveKeys:
+ accessKeysFilename = \
+ acctDir(baseDir, nickname, domain) + '/accessKeys.json'
+ saveJson(accessKeys, accessKeysFilename)
+ if not self.server.keyShortcuts.get(nickname):
+ self.server.keyShortcuts[nickname] = accessKeys.copy()
+
+ # redirect back from key shortcuts screen
+ if callingDomain.endswith('.onion') and onionDomain:
+ originPathStr = \
+ 'http://' + onionDomain + usersPath + '/' + defaultTimeline
+ elif callingDomain.endswith('.i2p') and i2pDomain:
+ originPathStr = \
+ 'http://' + i2pDomain + usersPath + '/' + defaultTimeline
+ self._redirect_headers(originPathStr, cookie, callingDomain)
+ self.server.POSTbusy = False
+ return
+
def _personOptions(self, path: str,
callingDomain: str, cookie: str,
baseDir: str, httpPrefix: str,
@@ -1809,8 +1940,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST optionsConfirmParams rfile.read failed')
- print(e)
+ print('ERROR: ' +
+ 'POST optionsConfirmParams rfile.read failed, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -1962,6 +2093,34 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
+ # person options screen, on notify checkbox
+ # See htmlPersonOptions
+ if '&submitNotifyOnPost=' in optionsConfirmParams:
+ notify = None
+ if 'notifyOnPost=' in optionsConfirmParams:
+ notify = optionsConfirmParams.split('notifyOnPost=')[1]
+ if '&' in notify:
+ notify = notify.split('&')[0]
+ if notify == 'on':
+ addNotifyOnPost(baseDir,
+ chooserNickname,
+ domain,
+ optionsNickname,
+ optionsDomainFull)
+ else:
+ removeNotifyOnPost(baseDir,
+ chooserNickname,
+ domain,
+ optionsNickname,
+ optionsDomainFull)
+ usersPathStr = \
+ usersPath + '/' + self.server.defaultTimeline + \
+ '?page=' + str(pageNumber)
+ self._redirect_headers(usersPathStr, cookie,
+ callingDomain)
+ self.server.POSTbusy = False
+ return
+
# person options screen, permission to post to newswire
# See htmlPersonOptions
if '&submitPostToNews=' in optionsConfirmParams:
@@ -1975,8 +2134,8 @@ class PubServer(BaseHTTPRequestHandler):
postsToNews = optionsConfirmParams.split('postsToNews=')[1]
if '&' in postsToNews:
postsToNews = postsToNews.split('&')[0]
- accountDir = self.server.baseDir + '/accounts/' + \
- optionsNickname + '@' + optionsDomain
+ accountDir = acctDir(self.server.baseDir,
+ optionsNickname, optionsDomain)
newswireBlockedFilename = accountDir + '/.nonewswire'
if postsToNews == 'on':
if os.path.isfile(newswireBlockedFilename):
@@ -1984,10 +2143,9 @@ class PubServer(BaseHTTPRequestHandler):
refreshNewswire(self.server.baseDir)
else:
if os.path.isdir(accountDir):
- noNewswireFile = open(newswireBlockedFilename, "w+")
- if noNewswireFile:
+ nwFilename = newswireBlockedFilename
+ with open(nwFilename, 'w+') as noNewswireFile:
noNewswireFile.write('\n')
- noNewswireFile.close()
refreshNewswire(self.server.baseDir)
usersPathStr = \
usersPath + '/' + self.server.defaultTimeline + \
@@ -2011,8 +2169,8 @@ class PubServer(BaseHTTPRequestHandler):
optionsConfirmParams.split('postsToFeatures=')[1]
if '&' in postsToFeatures:
postsToFeatures = postsToFeatures.split('&')[0]
- accountDir = self.server.baseDir + '/accounts/' + \
- optionsNickname + '@' + optionsDomain
+ accountDir = acctDir(self.server.baseDir,
+ optionsNickname, optionsDomain)
featuresBlockedFilename = accountDir + '/.nofeatures'
if postsToFeatures == 'on':
if os.path.isfile(featuresBlockedFilename):
@@ -2020,10 +2178,9 @@ class PubServer(BaseHTTPRequestHandler):
refreshNewswire(self.server.baseDir)
else:
if os.path.isdir(accountDir):
- noFeaturesFile = open(featuresBlockedFilename, "w+")
- if noFeaturesFile:
+ featFilename = featuresBlockedFilename
+ with open(featFilename, 'w+') as noFeaturesFile:
noFeaturesFile.write('\n')
- noFeaturesFile.close()
refreshNewswire(self.server.baseDir)
usersPathStr = \
usersPath + '/' + self.server.defaultTimeline + \
@@ -2047,18 +2204,17 @@ class PubServer(BaseHTTPRequestHandler):
optionsConfirmParams.split('modNewsPosts=')[1]
if '&' in modPostsToNews:
modPostsToNews = modPostsToNews.split('&')[0]
- accountDir = self.server.baseDir + '/accounts/' + \
- optionsNickname + '@' + optionsDomain
+ accountDir = acctDir(self.server.baseDir,
+ optionsNickname, optionsDomain)
newswireModFilename = accountDir + '/.newswiremoderated'
if modPostsToNews != 'on':
if os.path.isfile(newswireModFilename):
os.remove(newswireModFilename)
else:
if os.path.isdir(accountDir):
- modNewswireFile = open(newswireModFilename, "w+")
- if modNewswireFile:
+ nwFilename = newswireModFilename
+ with open(nwFilename, 'w+') as modNewswireFile:
modNewswireFile.write('\n')
- modNewswireFile.close()
usersPathStr = \
usersPath + '/' + self.server.defaultTimeline + \
'?page=' + str(pageNumber)
@@ -2139,6 +2295,17 @@ class PubServer(BaseHTTPRequestHandler):
if debug:
print('Sending DM to ' + optionsActor)
reportPath = path.replace('/personoptions', '') + '/newdm'
+
+ accessKeys = self.server.accessKeys
+ if '/users/' in path:
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
+
+ customSubmitText = getConfigParam(baseDir, 'customSubmitText')
+
msg = htmlNewPost(self.server.cssCache,
False, self.server.translate,
baseDir,
@@ -2152,7 +2319,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.defaultTimeline,
self.server.newswire,
self.server.themeName,
- True).encode('utf-8')
+ True, accessKeys,
+ customSubmitText).encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
cookie, callingDomain)
@@ -2239,6 +2407,17 @@ class PubServer(BaseHTTPRequestHandler):
print('Reporting ' + optionsActor)
reportPath = \
path.replace('/personoptions', '') + '/newreport'
+
+ accessKeys = self.server.accessKeys
+ if '/users/' in path:
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
+
+ customSubmitText = getConfigParam(baseDir, 'customSubmitText')
+
msg = htmlNewPost(self.server.cssCache,
False, self.server.translate,
baseDir,
@@ -2251,7 +2430,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.defaultTimeline,
self.server.newswire,
self.server.themeName,
- True).encode('utf-8')
+ True, accessKeys,
+ customSubmitText).encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
cookie, callingDomain)
@@ -2295,8 +2475,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST followConfirmParams rfile.read failed')
- print(e)
+ print('ERROR: POST followConfirmParams rfile.read failed, ' +
+ str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -2379,8 +2559,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST followConfirmParams rfile.read failed')
- print(e)
+ print('ERROR: POST followConfirmParams rfile.read failed, ' +
+ str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -2477,8 +2657,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST blockConfirmParams rfile.read failed')
- print(e)
+ print('ERROR: POST blockConfirmParams rfile.read failed, ' +
+ str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -2561,8 +2741,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST blockConfirmParams rfile.read failed')
- print(e)
+ print('ERROR: POST blockConfirmParams rfile.read failed, ' +
+ str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -2629,7 +2809,7 @@ class PubServer(BaseHTTPRequestHandler):
path = path.split('?page=')[0]
usersPath = path.replace('/searchhandle', '')
- actorStr = httpPrefix + '://' + domainFull + usersPath
+ actorStr = self._getInstalceUrl(callingDomain) + usersPath
length = int(self.headers['Content-length'])
try:
searchParams = self.rfile.read(length).decode('utf-8')
@@ -2643,18 +2823,13 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST searchParams rfile.read failed')
- print(e)
+ print('ERROR: POST searchParams rfile.read failed, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
return
if 'submitBack=' in searchParams:
# go back on search screen
- if callingDomain.endswith('.onion') and onionDomain:
- actorStr = 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and i2pDomain):
- actorStr = 'http://' + i2pDomain + usersPath
self._redirect_headers(actorStr + '/' +
self.server.defaultTimeline,
cookie, callingDomain)
@@ -2690,7 +2865,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.YTReplacementDomain,
self.server.showPublishedDateOnly,
self.server.peertubeInstances,
- self.server.allowLocalNetworkAccess)
+ self.server.allowLocalNetworkAccess,
+ self.server.themeName)
if hashtagStr:
msg = hashtagStr.encode('utf-8')
msglen = len(msg)
@@ -2722,7 +2898,7 @@ class PubServer(BaseHTTPRequestHandler):
elif searchStr.startswith('!'):
# your post history search
nickname = getNicknameFromActor(actorStr)
- searchStr = searchStr.replace('!', '').strip()
+ searchStr = searchStr.replace('!', '', 1).strip()
historyStr = \
htmlHistorySearch(self.server.cssCache,
self.server.translate,
@@ -2743,7 +2919,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.YTReplacementDomain,
self.server.showPublishedDateOnly,
self.server.peertubeInstances,
- self.server.allowLocalNetworkAccess)
+ self.server.allowLocalNetworkAccess,
+ self.server.themeName, 'outbox')
if historyStr:
msg = historyStr.encode('utf-8')
msglen = len(msg)
@@ -2752,16 +2929,47 @@ class PubServer(BaseHTTPRequestHandler):
self._write(msg)
self.server.POSTbusy = False
return
+ elif searchStr.startswith('-'):
+ # bookmark search
+ nickname = getNicknameFromActor(actorStr)
+ searchStr = searchStr.replace('-', '', 1).strip()
+ bookmarksStr = \
+ htmlHistorySearch(self.server.cssCache,
+ self.server.translate,
+ baseDir,
+ httpPrefix,
+ nickname,
+ domain,
+ searchStr,
+ maxPostsInFeed,
+ pageNumber,
+ self.server.projectVersion,
+ self.server.recentPostsCache,
+ self.server.maxRecentPosts,
+ self.server.session,
+ self.server.cachedWebfingers,
+ self.server.personCache,
+ port,
+ self.server.YTReplacementDomain,
+ self.server.showPublishedDateOnly,
+ self.server.peertubeInstances,
+ self.server.allowLocalNetworkAccess,
+ self.server.themeName, 'bookmarks')
+ if bookmarksStr:
+ msg = bookmarksStr.encode('utf-8')
+ msglen = len(msg)
+ self._login_headers('text/html',
+ msglen, callingDomain)
+ self._write(msg)
+ self.server.POSTbusy = False
+ return
elif ('@' in searchStr or
('://' in searchStr and
hasUsersPath(searchStr))):
if searchStr.endswith(':') or \
searchStr.endswith(';') or \
searchStr.endswith('.'):
- if callingDomain.endswith('.onion') and onionDomain:
- actorStr = 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and i2pDomain):
- actorStr = 'http://' + i2pDomain + usersPath
+ actorStr = self._getInstalceUrl(callingDomain) + usersPath
self._redirect_headers(actorStr + '/search',
cookie, callingDomain)
self.server.POSTbusy = False
@@ -2806,12 +3014,17 @@ class PubServer(BaseHTTPRequestHandler):
domain, domainFull,
GETstartTime, GETtimings,
onionDomain, i2pDomain,
- cookie, debug)
+ cookie, debug, authorized)
return
else:
showPublishedDateOnly = self.server.showPublishedDateOnly
allowLocalNetworkAccess = \
self.server.allowLocalNetworkAccess
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
+
profileStr = \
htmlProfileAfterSearch(self.server.cssCache,
self.server.recentPostsCache,
@@ -2833,7 +3046,9 @@ class PubServer(BaseHTTPRequestHandler):
showPublishedDateOnly,
self.server.defaultTimeline,
self.server.peertubeInstances,
- allowLocalNetworkAccess)
+ allowLocalNetworkAccess,
+ self.server.themeName,
+ accessKeys)
if profileStr:
msg = profileStr.encode('utf-8')
msglen = len(msg)
@@ -2843,10 +3058,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
else:
- if callingDomain.endswith('.onion') and onionDomain:
- actorStr = 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and i2pDomain):
- actorStr = 'http://' + i2pDomain + usersPath
+ actorStr = self._getInstalceUrl(callingDomain) + usersPath
self._redirect_headers(actorStr + '/search',
cookie, callingDomain)
self.server.POSTbusy = False
@@ -2891,10 +3103,7 @@ class PubServer(BaseHTTPRequestHandler):
self._write(msg)
self.server.POSTbusy = False
return
- if callingDomain.endswith('.onion') and onionDomain:
- actorStr = 'http://' + onionDomain + usersPath
- elif callingDomain.endswith('.i2p') and i2pDomain:
- actorStr = 'http://' + i2pDomain + usersPath
+ actorStr = self._getInstalceUrl(callingDomain) + usersPath
self._redirect_headers(actorStr + '/' +
self.server.defaultTimeline,
cookie, callingDomain)
@@ -2949,8 +3158,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST questionParams rfile.read failed')
- print(e)
+ print('ERROR: POST questionParams rfile.read failed, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -3010,9 +3218,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
self.postFromNickname = pathUsersSection.split('/')[0]
- accountsDir = \
- baseDir + '/accounts/' + \
- self.postFromNickname + '@' + domain
+ accountsDir = acctDir(baseDir, self.postFromNickname, domain)
if not os.path.isdir(accountsDir):
self._404()
self.server.POSTbusy = False
@@ -3031,25 +3237,16 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST mediaBytes rfile.read failed')
- print(e)
+ print('ERROR: POST mediaBytes rfile.read failed, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
return
mediaFilenameBase = accountsDir + '/upload'
- mediaFilename = mediaFilenameBase + '.png'
- if self.headers['Content-type'].endswith('jpeg'):
- mediaFilename = mediaFilenameBase + '.jpg'
- if self.headers['Content-type'].endswith('gif'):
- mediaFilename = mediaFilenameBase + '.gif'
- if self.headers['Content-type'].endswith('svg+xml'):
- mediaFilename = mediaFilenameBase + '.svg'
- if self.headers['Content-type'].endswith('webp'):
- mediaFilename = mediaFilenameBase + '.webp'
- if self.headers['Content-type'].endswith('avif'):
- mediaFilename = mediaFilenameBase + '.avif'
+ mediaFilename = \
+ mediaFilenameBase + '.' + \
+ getImageExtensionFromMimeType(self.headers['Content-type'])
with open(mediaFilename, 'wb') as avFile:
avFile.write(mediaBytes)
if debug:
@@ -3085,8 +3282,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST removeShareConfirmParams rfile.read failed')
- print(e)
+ print('ERROR: POST removeShareConfirmParams rfile.read failed, ' +
+ str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -3147,8 +3344,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST removePostConfirmParams rfile.read failed')
- print(e)
+ print('ERROR: POST removePostConfirmParams rfile.read failed, ' +
+ str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -3228,7 +3425,7 @@ class PubServer(BaseHTTPRequestHandler):
"""
usersPath = path.replace('/linksdata', '')
usersPath = usersPath.replace('/editlinks', '')
- actorStr = httpPrefix + '://' + domainFull + usersPath
+ actorStr = self._getInstalceUrl(callingDomain) + usersPath
if ' boundary=' in self.headers['Content-type']:
boundary = self.headers['Content-type'].split('boundary=')[1]
if ';' in boundary:
@@ -3240,14 +3437,6 @@ class PubServer(BaseHTTPRequestHandler):
if nickname:
editor = isEditor(baseDir, nickname)
if not nickname or not editor:
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
if not nickname:
print('WARN: nickname not found in ' + actorStr)
else:
@@ -3260,14 +3449,6 @@ class PubServer(BaseHTTPRequestHandler):
# check that the POST isn't too large
if length > self.server.maxPostLength:
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
print('Maximum links data length exceeded ' + str(length))
self._redirect_headers(actorStr, cookie, callingDomain)
self.server.POSTbusy = False
@@ -3288,16 +3469,15 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: failed to read bytes for POST')
- print(e)
+ print('ERROR: failed to read bytes for POST, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
return
linksFilename = baseDir + '/accounts/links.txt'
- aboutFilename = baseDir + '/accounts/about.txt'
- TOSFilename = baseDir + '/accounts/tos.txt'
+ aboutFilename = baseDir + '/accounts/about.md'
+ TOSFilename = baseDir + '/accounts/tos.md'
# extract all of the text fields into a dict
fields = \
@@ -3305,10 +3485,8 @@ class PubServer(BaseHTTPRequestHandler):
if fields.get('editedLinks'):
linksStr = fields['editedLinks']
- linksFile = open(linksFilename, "w+")
- if linksFile:
+ with open(linksFilename, 'w+') as linksFile:
linksFile.write(linksStr)
- linksFile.close()
else:
if os.path.isfile(linksFilename):
os.remove(linksFilename)
@@ -3320,10 +3498,8 @@ class PubServer(BaseHTTPRequestHandler):
aboutStr = fields['editedAbout']
if not dangerousMarkup(aboutStr,
allowLocalNetworkAccess):
- aboutFile = open(aboutFilename, "w+")
- if aboutFile:
+ with open(aboutFilename, 'w+') as aboutFile:
aboutFile.write(aboutStr)
- aboutFile.close()
else:
if os.path.isfile(aboutFilename):
os.remove(aboutFilename)
@@ -3332,23 +3508,13 @@ class PubServer(BaseHTTPRequestHandler):
TOSStr = fields['editedTOS']
if not dangerousMarkup(TOSStr,
allowLocalNetworkAccess):
- TOSFile = open(TOSFilename, "w+")
- if TOSFile:
+ with open(TOSFilename, 'w+') as TOSFile:
TOSFile.write(TOSStr)
- TOSFile.close()
else:
if os.path.isfile(TOSFilename):
os.remove(TOSFilename)
# redirect back to the default timeline
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
self._redirect_headers(actorStr + '/' + defaultTimeline,
cookie, callingDomain)
self.server.POSTbusy = False
@@ -3381,7 +3547,7 @@ class PubServer(BaseHTTPRequestHandler):
self._404()
return
usersPath = usersPath.split('/tags/')[0]
- actorStr = httpPrefix + '://' + domainFull + usersPath
+ actorStr = self._getInstalceUrl(callingDomain) + usersPath
tagScreenStr = actorStr + '/tags/' + hashtag
if ' boundary=' in self.headers['Content-type']:
boundary = self.headers['Content-type'].split('boundary=')[1]
@@ -3394,14 +3560,6 @@ class PubServer(BaseHTTPRequestHandler):
if nickname:
editor = isEditor(baseDir, nickname)
if not hashtag or not editor:
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
if not nickname:
print('WARN: nickname not found in ' + actorStr)
else:
@@ -3414,14 +3572,6 @@ class PubServer(BaseHTTPRequestHandler):
# check that the POST isn't too large
if length > self.server.maxPostLength:
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
print('Maximum links data length exceeded ' + str(length))
self._redirect_headers(tagScreenStr, cookie, callingDomain)
self.server.POSTbusy = False
@@ -3442,8 +3592,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: failed to read bytes for POST')
- print(e)
+ print('ERROR: failed to read bytes for POST, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -3464,14 +3613,6 @@ class PubServer(BaseHTTPRequestHandler):
os.remove(categoryFilename)
# redirect back to the default timeline
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
self._redirect_headers(tagScreenStr,
cookie, callingDomain)
self.server.POSTbusy = False
@@ -3486,7 +3627,7 @@ class PubServer(BaseHTTPRequestHandler):
"""
usersPath = path.replace('/newswiredata', '')
usersPath = usersPath.replace('/editnewswire', '')
- actorStr = httpPrefix + '://' + domainFull + usersPath
+ actorStr = self._getInstalceUrl(callingDomain) + usersPath
if ' boundary=' in self.headers['Content-type']:
boundary = self.headers['Content-type'].split('boundary=')[1]
if ';' in boundary:
@@ -3498,14 +3639,6 @@ class PubServer(BaseHTTPRequestHandler):
if nickname:
moderator = isModerator(baseDir, nickname)
if not nickname or not moderator:
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
if not nickname:
print('WARN: nickname not found in ' + actorStr)
else:
@@ -3518,14 +3651,6 @@ class PubServer(BaseHTTPRequestHandler):
# check that the POST isn't too large
if length > self.server.maxPostLength:
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
print('Maximum newswire data length exceeded ' + str(length))
self._redirect_headers(actorStr, cookie, callingDomain)
self.server.POSTbusy = False
@@ -3546,8 +3671,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: failed to read bytes for POST')
- print(e)
+ print('ERROR: failed to read bytes for POST, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -3560,10 +3684,8 @@ class PubServer(BaseHTTPRequestHandler):
extractTextFieldsInPOST(postBytes, boundary, debug)
if fields.get('editedNewswire'):
newswireStr = fields['editedNewswire']
- newswireFile = open(newswireFilename, "w+")
- if newswireFile:
+ with open(newswireFilename, 'w+') as newswireFile:
newswireFile.write(newswireStr)
- newswireFile.close()
else:
if os.path.isfile(newswireFilename):
os.remove(newswireFilename)
@@ -3594,23 +3716,13 @@ class PubServer(BaseHTTPRequestHandler):
newswireTrusted = fields['trustedNewswire']
if not newswireTrusted.endswith('\n'):
newswireTrusted += '\n'
- trustFile = open(newswireTrustedFilename, "w+")
- if trustFile:
+ with open(newswireTrustedFilename, 'w+') as trustFile:
trustFile.write(newswireTrusted)
- trustFile.close()
else:
if os.path.isfile(newswireTrustedFilename):
os.remove(newswireTrustedFilename)
# redirect back to the default timeline
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
self._redirect_headers(actorStr + '/' + defaultTimeline,
cookie, callingDomain)
self.server.POSTbusy = False
@@ -3626,12 +3738,11 @@ class PubServer(BaseHTTPRequestHandler):
update button on the citations screen
"""
usersPath = path.replace('/citationsdata', '')
- actorStr = httpPrefix + '://' + domainFull + usersPath
+ actorStr = self._getInstalceUrl(callingDomain) + usersPath
nickname = getNicknameFromActor(actorStr)
citationsFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + '/.citations.txt'
+ acctDir(baseDir, nickname, domain) + '/.citations.txt'
# remove any existing citations file
if os.path.isfile(citationsFilename):
os.remove(citationsFilename)
@@ -3646,14 +3757,6 @@ class PubServer(BaseHTTPRequestHandler):
# check that the POST isn't too large
if length > self.server.maxPostLength:
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
print('Maximum citations data length exceeded ' + str(length))
self._redirect_headers(actorStr, cookie, callingDomain)
self.server.POSTbusy = False
@@ -3676,8 +3779,7 @@ class PubServer(BaseHTTPRequestHandler):
return
except ValueError as e:
print('ERROR: failed to read bytes for ' +
- 'citations screen POST')
- print(e)
+ 'citations screen POST, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -3700,20 +3802,10 @@ class PubServer(BaseHTTPRequestHandler):
citationsStr += citationDate + '\n'
# save citations dates, so that they can be added when
# reloading the newblog screen
- citationsFile = open(citationsFilename, "w+")
- if citationsFile:
+ with open(citationsFilename, 'w+') as citationsFile:
citationsFile.write(citationsStr)
- citationsFile.close()
# redirect back to the default timeline
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
self._redirect_headers(actorStr + '/newblog',
cookie, callingDomain)
self.server.POSTbusy = False
@@ -3728,7 +3820,7 @@ class PubServer(BaseHTTPRequestHandler):
"""
usersPath = path.replace('/newseditdata', '')
usersPath = usersPath.replace('/editnewspost', '')
- actorStr = httpPrefix + '://' + domainFull + usersPath
+ actorStr = self._getInstalceUrl(callingDomain) + usersPath
if ' boundary=' in self.headers['Content-type']:
boundary = self.headers['Content-type'].split('boundary=')[1]
if ';' in boundary:
@@ -3740,14 +3832,6 @@ class PubServer(BaseHTTPRequestHandler):
if nickname:
editorRole = isEditor(baseDir, nickname)
if not nickname or not editorRole:
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
if not nickname:
print('WARN: nickname not found in ' + actorStr)
else:
@@ -3765,14 +3849,6 @@ class PubServer(BaseHTTPRequestHandler):
# check that the POST isn't too large
if length > self.server.maxPostLength:
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
print('Maximum news data length exceeded ' + str(length))
if self.server.newsInstance:
self._redirect_headers(actorStr + '/tlfeatures',
@@ -3798,8 +3874,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: failed to read bytes for POST')
- print(e)
+ print('ERROR: failed to read bytes for POST, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -3847,7 +3922,7 @@ class PubServer(BaseHTTPRequestHandler):
saveJson(self.server.newswire,
newswireStateFilename)
except Exception as e:
- print('ERROR saving newswire state, ' + str(e))
+ print('ERROR: saving newswire state, ' + str(e))
# remove any previous cached news posts
newsId = \
@@ -3859,14 +3934,6 @@ class PubServer(BaseHTTPRequestHandler):
saveJson(postJsonObject, postFilename)
# redirect back to the default timeline
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
if self.server.newsInstance:
self._redirect_headers(actorStr + '/tlfeatures',
cookie, callingDomain)
@@ -3880,13 +3947,14 @@ class PubServer(BaseHTTPRequestHandler):
baseDir: str, httpPrefix: str,
domain: str, domainFull: str,
onionDomain: str, i2pDomain: str,
- debug: bool, allowLocalNetworkAccess: bool) -> None:
+ debug: bool, allowLocalNetworkAccess: bool,
+ systemLanguage: str) -> None:
"""Updates your user profile after editing via the Edit button
on the profile screen
"""
usersPath = path.replace('/profiledata', '')
usersPath = usersPath.replace('/editprofile', '')
- actorStr = httpPrefix + '://' + domainFull + usersPath
+ actorStr = self._getInstalceUrl(callingDomain) + usersPath
if ' boundary=' in self.headers['Content-type']:
boundary = self.headers['Content-type'].split('boundary=')[1]
if ';' in boundary:
@@ -3895,14 +3963,6 @@ class PubServer(BaseHTTPRequestHandler):
# get the nickname
nickname = getNicknameFromActor(actorStr)
if not nickname:
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
print('WARN: nickname not found in ' + actorStr)
self._redirect_headers(actorStr, cookie, callingDomain)
self.server.POSTbusy = False
@@ -3912,14 +3972,6 @@ class PubServer(BaseHTTPRequestHandler):
# check that the POST isn't too large
if length > self.server.maxPostLength:
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
print('Maximum profile data length exceeded ' +
str(length))
self._redirect_headers(actorStr, cookie, callingDomain)
@@ -3941,8 +3993,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: failed to read bytes for POST')
- print(e)
+ print('ERROR: failed to read bytes for POST, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -3955,7 +4006,8 @@ class PubServer(BaseHTTPRequestHandler):
profileMediaTypes = ('avatar', 'image',
'banner', 'search_banner',
'instanceLogo',
- 'left_col_image', 'right_col_image')
+ 'left_col_image', 'right_col_image',
+ 'submitImportTheme')
profileMediaTypesUploaded = {}
for mType in profileMediaTypes:
# some images can only be changed by the admin
@@ -3967,18 +4019,18 @@ class PubServer(BaseHTTPRequestHandler):
if debug:
print('DEBUG: profile update extracting ' + mType +
- ' image or font from POST')
+ ' image, zip or font from POST')
mediaBytes, postBytes = \
extractMediaInFormPOST(postBytes, boundary, mType)
if mediaBytes:
if debug:
print('DEBUG: profile update ' + mType +
- ' image or font was found. ' +
+ ' image, zip or font was found. ' +
str(len(mediaBytes)) + ' bytes')
else:
if debug:
print('DEBUG: profile update, no ' + mType +
- ' image or font was found in POST')
+ ' image, zip or font was found in POST')
continue
# Note: a .temp extension is used here so that at no
@@ -3987,10 +4039,16 @@ class PubServer(BaseHTTPRequestHandler):
if mType == 'instanceLogo':
filenameBase = \
baseDir + '/accounts/login.temp'
+ elif mType == 'submitImportTheme':
+ if not os.path.isdir(baseDir + '/imports'):
+ os.mkdir(baseDir + '/imports')
+ filenameBase = \
+ baseDir + '/imports/newtheme.zip'
+ if os.path.isfile(filenameBase):
+ os.remove(filenameBase)
else:
filenameBase = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
+ acctDir(baseDir, nickname, domain) + \
'/' + mType + '.temp'
filename, attachmentMediaType = \
@@ -3998,10 +4056,19 @@ class PubServer(BaseHTTPRequestHandler):
filenameBase)
if filename:
print('Profile update POST ' + mType +
- ' media or font filename is ' + filename)
+ ' media, zip or font filename is ' + filename)
else:
print('Profile update, no ' + mType +
- ' media or font filename in POST')
+ ' media, zip or font filename in POST')
+ continue
+
+ if mType == 'submitImportTheme':
+ if nickname == adminNickname or \
+ isArtist(baseDir, nickname):
+ if importTheme(baseDir, filename):
+ print(nickname + ' uploaded a theme')
+ else:
+ print('Only admin or artist can import a theme')
continue
postImageFilename = filename.replace('.temp', '')
@@ -4014,10 +4081,16 @@ class PubServer(BaseHTTPRequestHandler):
os.remove(postImageFilename + '.etag')
except BaseException:
pass
- removeMetaData(filename, postImageFilename)
+
+ city = getSpoofedCity(self.server.city,
+ baseDir, nickname, domain)
+
+ processMetaData(baseDir, nickname, domain,
+ filename, postImageFilename, city)
if os.path.isfile(postImageFilename):
print('profile update POST ' + mType +
- ' image or font saved to ' + postImageFilename)
+ ' image, zip or font saved to ' +
+ postImageFilename)
if mType != 'instanceLogo':
lastPartOfImageFilename = \
postImageFilename.split('/')[-1]
@@ -4029,6 +4102,35 @@ class PubServer(BaseHTTPRequestHandler):
' image or font could not be saved to ' +
postImageFilename)
+ postBytesStr = postBytes.decode('utf-8')
+ redirectPath = ''
+ checkNameAndBio = False
+ onFinalWelcomeScreen = False
+ if 'name="previewAvatar"' in postBytesStr:
+ redirectPath = '/welcome_profile'
+ elif 'name="initialWelcomeScreen"' in postBytesStr:
+ redirectPath = '/welcome'
+ elif 'name="finalWelcomeScreen"' in postBytesStr:
+ checkNameAndBio = True
+ redirectPath = '/welcome_final'
+ elif 'name="welcomeCompleteButton"' in postBytesStr:
+ redirectPath = '/' + self.server.defaultTimeline
+ welcomeScreenIsComplete(self.server.baseDir, nickname,
+ self.server.domain)
+ onFinalWelcomeScreen = True
+ elif 'name="submitExportTheme"' in postBytesStr:
+ print('submitExportTheme')
+ themeDownloadPath = actorStr
+ if exportTheme(self.server.baseDir,
+ self.server.themeName):
+ themeDownloadPath += \
+ '/exports/' + self.server.themeName + '.zip'
+ print('submitExportTheme path=' + themeDownloadPath)
+ self._redirect_headers(themeDownloadPath,
+ cookie, callingDomain)
+ self.server.POSTbusy = False
+ return
+
# extract all of the text fields into a dict
fields = \
extractTextFieldsInPOST(postBytes, boundary, debug)
@@ -4042,8 +4144,7 @@ class PubServer(BaseHTTPRequestHandler):
# load the json for the actor for this user
actorFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + '.json'
+ acctDir(baseDir, nickname, domain) + '.json'
if os.path.isfile(actorFilename):
actorJson = loadJson(actorFilename)
if actorJson:
@@ -4052,22 +4153,6 @@ class PubServer(BaseHTTPRequestHandler):
# which isn't implemented in Epicyon
actorJson['discoverable'] = True
actorChanged = True
- if not actorJson['@context'][2].get('orgSchema'):
- actorJson['@context'][2]['orgSchema'] = \
- 'toot:orgSchema'
- actorChanged = True
- if not actorJson['@context'][2].get('skills'):
- actorJson['@context'][2]['skills'] = 'toot:skills'
- actorChanged = True
- if not actorJson['@context'][2].get('shares'):
- actorJson['@context'][2]['shares'] = 'toot:shares'
- actorChanged = True
- if not actorJson['@context'][2].get('roles'):
- actorJson['@context'][2]['roles'] = 'toot:roles'
- actorChanged = True
- if not actorJson['@context'][2].get('availability'):
- actorJson['@context'][2]['availaibility'] = \
- 'toot:availability'
if actorJson.get('capabilityAcquisitionEndpoint'):
del actorJson['capabilityAcquisitionEndpoint']
actorChanged = True
@@ -4105,7 +4190,7 @@ class PubServer(BaseHTTPRequestHandler):
# set skill levels
skillCtr = 1
- newSkills = {}
+ actorSkillsCtr = noOfActorSkills(actorJson)
while skillCtr < 10:
skillName = \
fields.get('skillName' + str(skillCtr))
@@ -4120,21 +4205,21 @@ class PubServer(BaseHTTPRequestHandler):
if not skillValue:
skillCtr += 1
continue
- if not actorJson['skills'].get(skillName):
+ if not actorHasSkill(actorJson, skillName):
actorChanged = True
else:
- if actorJson['skills'][skillName] != \
+ if actorSkillValue(actorJson, skillName) != \
int(skillValue):
actorChanged = True
- newSkills[skillName] = int(skillValue)
+ setActorSkillLevel(actorJson,
+ skillName, int(skillValue))
skillsStr = self.server.translate['Skills']
setHashtagCategory(baseDir, skillName,
skillsStr.lower())
skillCtr += 1
- if len(actorJson['skills'].items()) != \
- len(newSkills.items()):
+ if noOfActorSkills(actorJson) != \
+ actorSkillsCtr:
actorChanged = True
- actorJson['skills'] = newSkills
# change password
if fields.get('password'):
@@ -4148,6 +4233,13 @@ class PubServer(BaseHTTPRequestHandler):
nickname,
pwd)
+ # change city
+ if fields.get('cityDropdown'):
+ cityFilename = \
+ acctDir(baseDir, nickname, domain) + '/city.txt'
+ with open(cityFilename, 'w+') as fp:
+ fp.write(fields['cityDropdown'])
+
# change displayed name
if fields.get('displayNickname'):
if fields['displayNickname'] != actorJson['name']:
@@ -4159,115 +4251,199 @@ class PubServer(BaseHTTPRequestHandler):
actorJson['name'] = displayName
else:
actorJson['name'] = nickname
+ if checkNameAndBio:
+ redirectPath = 'previewAvatar'
actorChanged = True
-
- # change media instance status
- if fields.get('mediaInstance'):
- self.server.mediaInstance = False
- self.server.defaultTimeline = 'inbox'
- if fields['mediaInstance'] == 'on':
- self.server.mediaInstance = True
- self.server.blogsInstance = False
- self.server.newsInstance = False
- self.server.defaultTimeline = 'tlmedia'
- setConfigParam(baseDir,
- "mediaInstance",
- self.server.mediaInstance)
- setConfigParam(baseDir,
- "blogsInstance",
- self.server.blogsInstance)
- setConfigParam(baseDir,
- "newsInstance",
- self.server.newsInstance)
else:
- if self.server.mediaInstance:
+ if checkNameAndBio:
+ redirectPath = 'previewAvatar'
+
+ if nickname == adminNickname or \
+ isArtist(baseDir, nickname):
+ # change theme
+ if fields.get('themeDropdown'):
+ self.server.themeName = fields['themeDropdown']
+ setTheme(baseDir, self.server.themeName, domain,
+ allowLocalNetworkAccess, systemLanguage)
+ self.server.textModeBanner = \
+ getTextModeBanner(self.server.baseDir)
+ self.server.iconsCache = {}
+ self.server.fontsCache = {}
+ self.server.showPublishAsIcon = \
+ getConfigParam(self.server.baseDir,
+ 'showPublishAsIcon')
+ self.server.fullWidthTimelineButtonHeader = \
+ getConfigParam(self.server.baseDir,
+ 'fullWidthTimelineButtonHeader')
+ self.server.iconsAsButtons = \
+ getConfigParam(self.server.baseDir,
+ 'iconsAsButtons')
+ self.server.rssIconAtTop = \
+ getConfigParam(self.server.baseDir,
+ 'rssIconAtTop')
+ self.server.publishButtonAtTop = \
+ getConfigParam(self.server.baseDir,
+ 'publishButtonAtTop')
+ setNewsAvatar(baseDir,
+ fields['themeDropdown'],
+ httpPrefix,
+ domain,
+ domainFull)
+
+ if nickname == adminNickname:
+ # change media instance status
+ if fields.get('mediaInstance'):
self.server.mediaInstance = False
self.server.defaultTimeline = 'inbox'
+ if fields['mediaInstance'] == 'on':
+ self.server.mediaInstance = True
+ self.server.blogsInstance = False
+ self.server.newsInstance = False
+ self.server.defaultTimeline = 'tlmedia'
setConfigParam(baseDir,
"mediaInstance",
self.server.mediaInstance)
-
- # change news instance status
- if fields.get('newsInstance'):
- self.server.newsInstance = False
- self.server.defaultTimeline = 'inbox'
- if fields['newsInstance'] == 'on':
- self.server.newsInstance = True
- self.server.blogsInstance = False
- self.server.mediaInstance = False
- self.server.defaultTimeline = 'tlfeatures'
- setConfigParam(baseDir,
- "mediaInstance",
- self.server.mediaInstance)
- setConfigParam(baseDir,
- "blogsInstance",
- self.server.blogsInstance)
- setConfigParam(baseDir,
- "newsInstance",
- self.server.newsInstance)
- else:
- if self.server.newsInstance:
- self.server.newsInstance = False
- self.server.defaultTimeline = 'inbox'
- setConfigParam(baseDir,
- "newsInstance",
- self.server.mediaInstance)
-
- # change blog instance status
- if fields.get('blogsInstance'):
- self.server.blogsInstance = False
- self.server.defaultTimeline = 'inbox'
- if fields['blogsInstance'] == 'on':
- self.server.blogsInstance = True
- self.server.mediaInstance = False
- self.server.newsInstance = False
- self.server.defaultTimeline = 'tlblogs'
- setConfigParam(baseDir,
- "blogsInstance",
- self.server.blogsInstance)
- setConfigParam(baseDir,
- "mediaInstance",
- self.server.mediaInstance)
- setConfigParam(baseDir,
- "newsInstance",
- self.server.newsInstance)
- else:
- if self.server.blogsInstance:
- self.server.blogsInstance = False
- self.server.defaultTimeline = 'inbox'
setConfigParam(baseDir,
"blogsInstance",
self.server.blogsInstance)
+ setConfigParam(baseDir,
+ "newsInstance",
+ self.server.newsInstance)
+ else:
+ if self.server.mediaInstance:
+ self.server.mediaInstance = False
+ self.server.defaultTimeline = 'inbox'
+ setConfigParam(baseDir,
+ "mediaInstance",
+ self.server.mediaInstance)
- # change theme
- if fields.get('themeDropdown'):
- self.server.themeName = fields['themeDropdown']
- setTheme(baseDir, self.server.themeName, domain,
- allowLocalNetworkAccess)
- self.server.textModeBanner = \
- getTextModeBanner(self.server.baseDir)
- self.server.iconsCache = {}
- self.server.fontsCache = {}
- self.server.showPublishAsIcon = \
- getConfigParam(self.server.baseDir,
- 'showPublishAsIcon')
- self.server.fullWidthTimelineButtonHeader = \
- getConfigParam(self.server.baseDir,
- 'fullWidthTimelineButtonHeader')
- self.server.iconsAsButtons = \
- getConfigParam(self.server.baseDir,
- 'iconsAsButtons')
- self.server.rssIconAtTop = \
- getConfigParam(self.server.baseDir,
- 'rssIconAtTop')
- self.server.publishButtonAtTop = \
- getConfigParam(self.server.baseDir,
- 'publishButtonAtTop')
- setNewsAvatar(baseDir,
- fields['themeDropdown'],
- httpPrefix,
- domain,
- domainFull)
+ # is this a news theme?
+ if isNewsThemeName(self.server.baseDir,
+ self.server.themeName):
+ fields['newsInstance'] = 'on'
+
+ # change news instance status
+ if fields.get('newsInstance'):
+ self.server.newsInstance = False
+ self.server.defaultTimeline = 'inbox'
+ if fields['newsInstance'] == 'on':
+ self.server.newsInstance = True
+ self.server.blogsInstance = False
+ self.server.mediaInstance = False
+ self.server.defaultTimeline = 'tlfeatures'
+ setConfigParam(baseDir,
+ "mediaInstance",
+ self.server.mediaInstance)
+ setConfigParam(baseDir,
+ "blogsInstance",
+ self.server.blogsInstance)
+ setConfigParam(baseDir,
+ "newsInstance",
+ self.server.newsInstance)
+ else:
+ if self.server.newsInstance:
+ self.server.newsInstance = False
+ self.server.defaultTimeline = 'inbox'
+ setConfigParam(baseDir,
+ "newsInstance",
+ self.server.mediaInstance)
+
+ # change blog instance status
+ if fields.get('blogsInstance'):
+ self.server.blogsInstance = False
+ self.server.defaultTimeline = 'inbox'
+ if fields['blogsInstance'] == 'on':
+ self.server.blogsInstance = True
+ self.server.mediaInstance = False
+ self.server.newsInstance = False
+ self.server.defaultTimeline = 'tlblogs'
+ setConfigParam(baseDir,
+ "blogsInstance",
+ self.server.blogsInstance)
+ setConfigParam(baseDir,
+ "mediaInstance",
+ self.server.mediaInstance)
+ setConfigParam(baseDir,
+ "newsInstance",
+ self.server.newsInstance)
+ else:
+ if self.server.blogsInstance:
+ self.server.blogsInstance = False
+ self.server.defaultTimeline = 'inbox'
+ setConfigParam(baseDir,
+ "blogsInstance",
+ self.server.blogsInstance)
+
+ # change instance title
+ if fields.get('instanceTitle'):
+ currInstanceTitle = \
+ getConfigParam(baseDir, 'instanceTitle')
+ if fields['instanceTitle'] != currInstanceTitle:
+ setConfigParam(baseDir, 'instanceTitle',
+ fields['instanceTitle'])
+
+ # change YouTube alternate domain
+ if fields.get('ytdomain'):
+ currYTDomain = self.server.YTReplacementDomain
+ if fields['ytdomain'] != currYTDomain:
+ newYTDomain = fields['ytdomain']
+ if '://' in newYTDomain:
+ newYTDomain = newYTDomain.split('://')[1]
+ if '/' in newYTDomain:
+ newYTDomain = newYTDomain.split('/')[0]
+ if '.' in newYTDomain:
+ setConfigParam(baseDir,
+ 'youtubedomain',
+ newYTDomain)
+ self.server.YTReplacementDomain = \
+ newYTDomain
+ else:
+ setConfigParam(baseDir,
+ 'youtubedomain', '')
+ self.server.YTReplacementDomain = None
+
+ # change custom post submit button text
+ currCustomSubmitText = \
+ getConfigParam(baseDir, 'customSubmitText')
+ if fields.get('customSubmitText'):
+ if fields['customSubmitText'] != \
+ currCustomSubmitText:
+ customText = fields['customSubmitText']
+ setConfigParam(baseDir,
+ 'customSubmitText',
+ customText)
+ else:
+ if currCustomSubmitText:
+ setConfigParam(baseDir,
+ 'customSubmitText', '')
+
+ # change instance description
+ currInstanceDescriptionShort = \
+ getConfigParam(baseDir,
+ 'instanceDescriptionShort')
+ if fields.get('instanceDescriptionShort'):
+ if fields['instanceDescriptionShort'] != \
+ currInstanceDescriptionShort:
+ iDesc = fields['instanceDescriptionShort']
+ setConfigParam(baseDir,
+ 'instanceDescriptionShort',
+ iDesc)
+ else:
+ if currInstanceDescriptionShort:
+ setConfigParam(baseDir,
+ 'instanceDescriptionShort', '')
+ currInstanceDescription = \
+ getConfigParam(baseDir, 'instanceDescription')
+ if fields.get('instanceDescription'):
+ if fields['instanceDescription'] != \
+ currInstanceDescription:
+ setConfigParam(baseDir,
+ 'instanceDescription',
+ fields['instanceDescription'])
+ else:
+ if currInstanceDescription:
+ setConfigParam(baseDir,
+ 'instanceDescription', '')
# change email address
currentEmailAddress = getEmailAddress(actorJson)
@@ -4364,6 +4540,18 @@ class PubServer(BaseHTTPRequestHandler):
setJamiAddress(actorJson, '')
actorChanged = True
+ # change cwtch address
+ currentCwtchAddress = getCwtchAddress(actorJson)
+ if fields.get('cwtchAddress'):
+ if fields['cwtchAddress'] != currentCwtchAddress:
+ setCwtchAddress(actorJson,
+ fields['cwtchAddress'])
+ actorChanged = True
+ else:
+ if currentCwtchAddress:
+ setCwtchAddress(actorJson, '')
+ actorChanged = True
+
# change PGP public key
currentPGPpubKey = getPGPpubKey(actorJson)
if fields.get('pgp'):
@@ -4415,6 +4603,21 @@ class PubServer(BaseHTTPRequestHandler):
del actorJson['movedTo']
actorChanged = True
+ # Other accounts (alsoKnownAs)
+ occupationName = getOccupationName(actorJson)
+ if fields.get('occupationName'):
+ fields['occupationName'] = \
+ removeHtml(fields['occupationName'])
+ if occupationName != \
+ fields['occupationName']:
+ setOccupationName(actorJson,
+ fields['occupationName'])
+ actorChanged = True
+ else:
+ if occupationName:
+ setOccupationName(actorJson, '')
+ actorChanged = True
+
# Other accounts (alsoKnownAs)
alsoKnownAs = []
if actorJson.get('alsoKnownAs'):
@@ -4448,62 +4651,6 @@ class PubServer(BaseHTTPRequestHandler):
del actorJson['alsoKnownAs']
actorChanged = True
- # change instance title
- if fields.get('instanceTitle'):
- currInstanceTitle = \
- getConfigParam(baseDir, 'instanceTitle')
- if fields['instanceTitle'] != currInstanceTitle:
- setConfigParam(baseDir, 'instanceTitle',
- fields['instanceTitle'])
-
- # change YouTube alternate domain
- if fields.get('ytdomain'):
- currYTDomain = self.server.YTReplacementDomain
- if fields['ytdomain'] != currYTDomain:
- newYTDomain = fields['ytdomain']
- if '://' in newYTDomain:
- newYTDomain = newYTDomain.split('://')[1]
- if '/' in newYTDomain:
- newYTDomain = newYTDomain.split('/')[0]
- if '.' in newYTDomain:
- setConfigParam(baseDir,
- 'youtubedomain',
- newYTDomain)
- self.server.YTReplacementDomain = \
- newYTDomain
- else:
- setConfigParam(baseDir,
- 'youtubedomain', '')
- self.server.YTReplacementDomain = None
-
- # change instance description
- currInstanceDescriptionShort = \
- getConfigParam(baseDir,
- 'instanceDescriptionShort')
- if fields.get('instanceDescriptionShort'):
- if fields['instanceDescriptionShort'] != \
- currInstanceDescriptionShort:
- iDesc = fields['instanceDescriptionShort']
- setConfigParam(baseDir,
- 'instanceDescriptionShort',
- iDesc)
- else:
- if currInstanceDescriptionShort:
- setConfigParam(baseDir,
- 'instanceDescriptionShort', '')
- currInstanceDescription = \
- getConfigParam(baseDir, 'instanceDescription')
- if fields.get('instanceDescription'):
- if fields['instanceDescription'] != \
- currInstanceDescription:
- setConfigParam(baseDir,
- 'instanceDescription',
- fields['instanceDescription'])
- else:
- if currInstanceDescription:
- setConfigParam(baseDir,
- 'instanceDescription', '')
-
# change user bio
if fields.get('bio'):
if fields['bio'] != actorJson['summary']:
@@ -4522,10 +4669,12 @@ class PubServer(BaseHTTPRequestHandler):
for tagName, tag in actorTags.items():
actorJson['tag'].append(tag)
actorChanged = True
+ else:
+ if checkNameAndBio:
+ redirectPath = 'previewAvatar'
else:
- if actorJson['summary']:
- actorJson['summary'] = ''
- actorChanged = True
+ if checkNameAndBio:
+ redirectPath = 'previewAvatar'
adminNickname = \
getConfigParam(baseDir, 'admin')
@@ -4535,6 +4684,24 @@ class PubServer(BaseHTTPRequestHandler):
# on all incoming posts
if path.startswith('/users/' +
adminNickname + '/'):
+ showNodeInfoAccounts = False
+ if fields.get('showNodeInfoAccounts'):
+ if fields['showNodeInfoAccounts'] == 'on':
+ showNodeInfoAccounts = True
+ self.server.showNodeInfoAccounts = \
+ showNodeInfoAccounts
+ setConfigParam(baseDir, "showNodeInfoAccounts",
+ showNodeInfoAccounts)
+
+ showNodeInfoVersion = False
+ if fields.get('showNodeInfoVersion'):
+ if fields['showNodeInfoVersion'] == 'on':
+ showNodeInfoVersion = True
+ self.server.showNodeInfoVersion = \
+ showNodeInfoVersion
+ setConfigParam(baseDir, "showNodeInfoVersion",
+ showNodeInfoVersion)
+
verifyAllSignatures = False
if fields.get('verifyallsignatures'):
if fields['verifyallsignatures'] == 'on':
@@ -4566,17 +4733,16 @@ class PubServer(BaseHTTPRequestHandler):
clearModeratorStatus(baseDir)
if ',' in fields['moderators']:
# if the list was given as comma separated
- modFile = open(moderatorsFile, "w+")
- mods = fields['moderators'].split(',')
- for modNick in mods:
- modNick = modNick.strip()
- modDir = baseDir + \
- '/accounts/' + modNick + \
- '@' + domain
- if os.path.isdir(modDir):
- modFile.write(modNick + '\n')
- modFile.close()
mods = fields['moderators'].split(',')
+ with open(moderatorsFile, 'w+') as modFile:
+ for modNick in mods:
+ modNick = modNick.strip()
+ modDir = baseDir + \
+ '/accounts/' + modNick + \
+ '@' + domain
+ if os.path.isdir(modDir):
+ modFile.write(modNick + '\n')
+
for modNick in mods:
modNick = modNick.strip()
modDir = baseDir + \
@@ -4585,21 +4751,20 @@ class PubServer(BaseHTTPRequestHandler):
if os.path.isdir(modDir):
setRole(baseDir,
modNick, domain,
- 'instance', 'moderator')
+ 'moderator')
else:
# nicknames on separate lines
- modFile = open(moderatorsFile, "w+")
- mods = fields['moderators'].split('\n')
- for modNick in mods:
- modNick = modNick.strip()
- modDir = \
- baseDir + \
- '/accounts/' + modNick + \
- '@' + domain
- if os.path.isdir(modDir):
- modFile.write(modNick + '\n')
- modFile.close()
mods = fields['moderators'].split('\n')
+ with open(moderatorsFile, 'w+') as modFile:
+ for modNick in mods:
+ modNick = modNick.strip()
+ modDir = \
+ baseDir + \
+ '/accounts/' + modNick + \
+ '@' + domain
+ if os.path.isdir(modDir):
+ modFile.write(modNick + '\n')
+
for modNick in mods:
modNick = modNick.strip()
modDir = \
@@ -4610,7 +4775,6 @@ class PubServer(BaseHTTPRequestHandler):
if os.path.isdir(modDir):
setRole(baseDir,
modNick, domain,
- 'instance',
'moderator')
# change site editors list
@@ -4623,17 +4787,16 @@ class PubServer(BaseHTTPRequestHandler):
clearEditorStatus(baseDir)
if ',' in fields['editors']:
# if the list was given as comma separated
- edFile = open(editorsFile, "w+")
- eds = fields['editors'].split(',')
- for edNick in eds:
- edNick = edNick.strip()
- edDir = baseDir + \
- '/accounts/' + edNick + \
- '@' + domain
- if os.path.isdir(edDir):
- edFile.write(edNick + '\n')
- edFile.close()
eds = fields['editors'].split(',')
+ with open(editorsFile, 'w+') as edFile:
+ for edNick in eds:
+ edNick = edNick.strip()
+ edDir = baseDir + \
+ '/accounts/' + edNick + \
+ '@' + domain
+ if os.path.isdir(edDir):
+ edFile.write(edNick + '\n')
+
for edNick in eds:
edNick = edNick.strip()
edDir = baseDir + \
@@ -4642,21 +4805,20 @@ class PubServer(BaseHTTPRequestHandler):
if os.path.isdir(edDir):
setRole(baseDir,
edNick, domain,
- 'instance', 'editor')
+ 'editor')
else:
# nicknames on separate lines
- edFile = open(editorsFile, "w+")
- eds = fields['editors'].split('\n')
- for edNick in eds:
- edNick = edNick.strip()
- edDir = \
- baseDir + \
- '/accounts/' + edNick + \
- '@' + domain
- if os.path.isdir(edDir):
- edFile.write(edNick + '\n')
- edFile.close()
eds = fields['editors'].split('\n')
+ with open(editorsFile, 'w+') as edFile:
+ for edNick in eds:
+ edNick = edNick.strip()
+ edDir = \
+ baseDir + \
+ '/accounts/' + edNick + \
+ '@' + domain
+ if os.path.isdir(edDir):
+ edFile.write(edNick + '\n')
+
for edNick in eds:
edNick = edNick.strip()
edDir = \
@@ -4667,9 +4829,116 @@ class PubServer(BaseHTTPRequestHandler):
if os.path.isdir(edDir):
setRole(baseDir,
edNick, domain,
- 'instance',
'editor')
+ # change site counselors list
+ if fields.get('counselors'):
+ if path.startswith('/users/' +
+ adminNickname + '/'):
+ counselorsFile = \
+ baseDir + \
+ '/accounts/counselors.txt'
+ clearCounselorStatus(baseDir)
+ if ',' in fields['counselors']:
+ # if the list was given as comma separated
+ eds = fields['counselors'].split(',')
+ with open(counselorsFile, 'w+') as edFile:
+ for edNick in eds:
+ edNick = edNick.strip()
+ edDir = baseDir + \
+ '/accounts/' + edNick + \
+ '@' + domain
+ if os.path.isdir(edDir):
+ edFile.write(edNick + '\n')
+
+ for edNick in eds:
+ edNick = edNick.strip()
+ edDir = baseDir + \
+ '/accounts/' + edNick + \
+ '@' + domain
+ if os.path.isdir(edDir):
+ setRole(baseDir,
+ edNick, domain,
+ 'counselor')
+ else:
+ # nicknames on separate lines
+ eds = fields['counselors'].split('\n')
+ with open(counselorsFile, 'w+') as edFile:
+ for edNick in eds:
+ edNick = edNick.strip()
+ edDir = \
+ baseDir + \
+ '/accounts/' + edNick + \
+ '@' + domain
+ if os.path.isdir(edDir):
+ edFile.write(edNick + '\n')
+
+ for edNick in eds:
+ edNick = edNick.strip()
+ edDir = \
+ baseDir + \
+ '/accounts/' + \
+ edNick + '@' + \
+ domain
+ if os.path.isdir(edDir):
+ setRole(baseDir,
+ edNick, domain,
+ 'counselor')
+
+ # change site artists list
+ if fields.get('artists'):
+ if path.startswith('/users/' +
+ adminNickname + '/'):
+ artistsFile = \
+ baseDir + \
+ '/accounts/artists.txt'
+ clearArtistStatus(baseDir)
+ if ',' in fields['artists']:
+ # if the list was given as comma separated
+ eds = fields['artists'].split(',')
+ with open(artistsFile, 'w+') as edFile:
+ for edNick in eds:
+ edNick = edNick.strip()
+ edDir = baseDir + \
+ '/accounts/' + edNick + \
+ '@' + domain
+ if os.path.isdir(edDir):
+ edFile.write(edNick + '\n')
+
+ for edNick in eds:
+ edNick = edNick.strip()
+ edDir = baseDir + \
+ '/accounts/' + edNick + \
+ '@' + domain
+ if os.path.isdir(edDir):
+ setRole(baseDir,
+ edNick, domain,
+ 'artist')
+ else:
+ # nicknames on separate lines
+ eds = fields['artists'].split('\n')
+ with open(artistsFile, 'w+') as edFile:
+ for edNick in eds:
+ edNick = edNick.strip()
+ edDir = \
+ baseDir + \
+ '/accounts/' + edNick + \
+ '@' + domain
+ if os.path.isdir(edDir):
+ edFile.write(edNick + '\n')
+
+ for edNick in eds:
+ edNick = edNick.strip()
+ edDir = \
+ baseDir + \
+ '/accounts/' + \
+ edNick + '@' + \
+ domain
+ if os.path.isdir(edDir):
+ setRole(baseDir,
+ edNick, domain,
+ 'artist')
+
# remove scheduled posts
if fields.get('removeScheduledPosts'):
if fields['removeScheduledPosts'] == 'on':
@@ -4677,19 +4946,27 @@ class PubServer(BaseHTTPRequestHandler):
nickname, domain)
# approve followers
- approveFollowers = False
- if fields.get('approveFollowers'):
- if fields['approveFollowers'] == 'on':
- approveFollowers = True
- if approveFollowers != \
- actorJson['manuallyApprovesFollowers']:
- actorJson['manuallyApprovesFollowers'] = \
- approveFollowers
+ if onFinalWelcomeScreen:
+ # Default setting created via the welcome screen
+ actorJson['manuallyApprovesFollowers'] = True
actorChanged = True
+ else:
+ approveFollowers = False
+ if fields.get('approveFollowers'):
+ if fields['approveFollowers'] == 'on':
+ approveFollowers = True
+ if approveFollowers != \
+ actorJson['manuallyApprovesFollowers']:
+ actorJson['manuallyApprovesFollowers'] = \
+ approveFollowers
+ actorChanged = True
# remove a custom font
if fields.get('removeCustomFont'):
- if fields['removeCustomFont'] == 'on':
+ if (fields['removeCustomFont'] == 'on' and
+ (isArtist(baseDir, nickname) or
+ path.startswith('/users/' +
+ adminNickname + '/'))):
fontExt = ('woff', 'woff2', 'otf', 'ttf')
for ext in fontExt:
if os.path.isfile(baseDir +
@@ -4705,48 +4982,55 @@ class PubServer(BaseHTTPRequestHandler):
currTheme = getTheme(baseDir)
if currTheme:
self.server.themeName = currTheme
+ allowLocalNetworkAccess = \
+ self.server.allowLocalNetworkAccess
setTheme(baseDir, currTheme, domain,
- self.server.allowLocalNetworkAccess)
+ allowLocalNetworkAccess,
+ systemLanguage)
self.server.textModeBanner = \
- getTextModeBanner(self.server.baseDir)
+ getTextModeBanner(baseDir)
self.server.iconsCache = {}
self.server.fontsCache = {}
self.server.showPublishAsIcon = \
- getConfigParam(self.server.baseDir,
+ getConfigParam(baseDir,
'showPublishAsIcon')
self.server.fullWidthTimelineButtonHeader = \
- getConfigParam(self.server.baseDir,
+ getConfigParam(baseDir,
'fullWidthTimeline' +
'ButtonHeader')
self.server.iconsAsButtons = \
- getConfigParam(self.server.baseDir,
+ getConfigParam(baseDir,
'iconsAsButtons')
self.server.rssIconAtTop = \
- getConfigParam(self.server.baseDir,
+ getConfigParam(baseDir,
'rssIconAtTop')
self.server.publishButtonAtTop = \
- getConfigParam(self.server.baseDir,
+ getConfigParam(baseDir,
'publishButtonAtTop')
# only receive DMs from accounts you follow
followDMsFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
- '/.followDMs'
- followDMsActive = False
- if fields.get('followDMs'):
- if fields['followDMs'] == 'on':
- followDMsActive = True
- with open(followDMsFilename, 'w+') as fFile:
- fFile.write('\n')
- if not followDMsActive:
- if os.path.isfile(followDMsFilename):
- os.remove(followDMsFilename)
+ acctDir(baseDir, nickname, domain) + '/.followDMs'
+ if onFinalWelcomeScreen:
+ # initial default setting created via
+ # the welcome screen
+ with open(followDMsFilename, 'w+') as fFile:
+ fFile.write('\n')
+ actorChanged = True
+ else:
+ followDMsActive = False
+ if fields.get('followDMs'):
+ if fields['followDMs'] == 'on':
+ followDMsActive = True
+ with open(followDMsFilename, 'w+') as fFile:
+ fFile.write('\n')
+ if not followDMsActive:
+ if os.path.isfile(followDMsFilename):
+ os.remove(followDMsFilename)
# remove Twitter retweets
removeTwitterFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
+ acctDir(baseDir, nickname, domain) + \
'/.removeTwitter'
removeTwitterActive = False
if fields.get('removeTwitter'):
@@ -4761,12 +5045,10 @@ class PubServer(BaseHTTPRequestHandler):
# hide Like button
hideLikeButtonFile = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
+ acctDir(baseDir, nickname, domain) + \
'/.hideLikeButton'
notifyLikesFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
+ acctDir(baseDir, nickname, domain) + \
'/.notifyLikes'
hideLikeButtonActive = False
if fields.get('hideLikeButton'):
@@ -4782,16 +5064,22 @@ class PubServer(BaseHTTPRequestHandler):
os.remove(hideLikeButtonFile)
# notify about new Likes
- notifyLikesActive = False
- if fields.get('notifyLikes'):
- if fields['notifyLikes'] == 'on' and \
- not hideLikeButtonActive:
- notifyLikesActive = True
- with open(notifyLikesFilename, 'w+') as rFile:
- rFile.write('\n')
- if not notifyLikesActive:
- if os.path.isfile(notifyLikesFilename):
- os.remove(notifyLikesFilename)
+ if onFinalWelcomeScreen:
+ # default setting from welcome screen
+ with open(notifyLikesFilename, 'w+') as rFile:
+ rFile.write('\n')
+ actorChanged = True
+ else:
+ notifyLikesActive = False
+ if fields.get('notifyLikes'):
+ if fields['notifyLikes'] == 'on' and \
+ not hideLikeButtonActive:
+ notifyLikesActive = True
+ with open(notifyLikesFilename, 'w+') as rFile:
+ rFile.write('\n')
+ if not notifyLikesActive:
+ if os.path.isfile(notifyLikesFilename):
+ os.remove(notifyLikesFilename)
# this account is a bot
if fields.get('isBot'):
@@ -4813,19 +5101,20 @@ class PubServer(BaseHTTPRequestHandler):
actorChanged = True
# grayscale theme
- grayscale = False
- if fields.get('grayscale'):
- if fields['grayscale'] == 'on':
- grayscale = True
- if grayscale:
- enableGrayscale(baseDir)
- else:
- disableGrayscale(baseDir)
+ if path.startswith('/users/' + adminNickname + '/') or \
+ isArtist(baseDir, nickname):
+ grayscale = False
+ if fields.get('grayscale'):
+ if fields['grayscale'] == 'on':
+ grayscale = True
+ if grayscale:
+ enableGrayscale(baseDir)
+ else:
+ disableGrayscale(baseDir)
# save filtered words list
filterFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
+ acctDir(baseDir, nickname, domain) + \
'/filters.txt'
if fields.get('filteredWords'):
with open(filterFilename, 'w+') as filterfile:
@@ -4836,8 +5125,7 @@ class PubServer(BaseHTTPRequestHandler):
# word replacements
switchFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
+ acctDir(baseDir, nickname, domain) + \
'/replacewords.txt'
if fields.get('switchWords'):
with open(switchFilename, 'w+') as switchfile:
@@ -4848,8 +5136,7 @@ class PubServer(BaseHTTPRequestHandler):
# autogenerated tags
autoTagsFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
+ acctDir(baseDir, nickname, domain) + \
'/autotags.txt'
if fields.get('autoTags'):
with open(autoTagsFilename, 'w+') as autoTagsFile:
@@ -4860,8 +5147,7 @@ class PubServer(BaseHTTPRequestHandler):
# autogenerated content warnings
autoCWFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
+ acctDir(baseDir, nickname, domain) + \
'/autocw.txt'
if fields.get('autoCW'):
with open(autoCWFilename, 'w+') as autoCWFile:
@@ -4872,8 +5158,7 @@ class PubServer(BaseHTTPRequestHandler):
# save blocked accounts list
blockedFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
+ acctDir(baseDir, nickname, domain) + \
'/blocking.txt'
if fields.get('blocked'):
with open(blockedFilename, 'w+') as blockedfile:
@@ -4882,10 +5167,23 @@ class PubServer(BaseHTTPRequestHandler):
if os.path.isfile(blockedFilename):
os.remove(blockedFilename)
+ # Save DM allowed instances list.
+ # The allow list for incoming DMs,
+ # if the .followDMs flag file exists
+ dmAllowedInstancesFilename = \
+ acctDir(baseDir, nickname, domain) + \
+ '/dmAllowedinstances.txt'
+ if fields.get('dmAllowedInstances'):
+ with open(dmAllowedInstancesFilename, 'w+') as aFile:
+ aFile.write(fields['dmAllowedInstances'])
+ else:
+ if os.path.isfile(dmAllowedInstancesFilename):
+ os.remove(dmAllowedInstancesFilename)
+
# save allowed instances list
+ # This is the account level allow list
allowedInstancesFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
+ acctDir(baseDir, nickname, domain) + \
'/allowedinstances.txt'
if fields.get('allowedInstances'):
with open(allowedInstancesFilename, 'w+') as aFile:
@@ -4894,15 +5192,34 @@ class PubServer(BaseHTTPRequestHandler):
if os.path.isfile(allowedInstancesFilename):
os.remove(allowedInstancesFilename)
- # save peertube instances list
- peertubeInstancesFile = \
- baseDir + '/accounts/peertube.txt'
- if fields.get('ptInstances'):
- adminNickname = \
- getConfigParam(baseDir, 'admin')
- if adminNickname and \
- path.startswith('/users/' +
- adminNickname + '/'):
+ # save blocked user agents
+ # This is admin lebel and global to the instance
+ if path.startswith('/users/' + adminNickname + '/'):
+ userAgentsBlocked = []
+ if fields.get('userAgentsBlockedStr'):
+ userAgentsBlockedStr = \
+ fields['userAgentsBlockedStr']
+ userAgentsBlockedList = \
+ userAgentsBlockedStr.split('\n')
+ for ua in userAgentsBlockedList:
+ if ua in userAgentsBlocked:
+ continue
+ userAgentsBlocked.append(ua.strip())
+ if str(self.server.userAgentsBlocked) != \
+ str(userAgentsBlocked):
+ self.server.userAgentsBlocked = userAgentsBlocked
+ userAgentsBlockedStr = ''
+ for ua in userAgentsBlocked:
+ if userAgentsBlockedStr:
+ userAgentsBlockedStr += ','
+ userAgentsBlockedStr += ua
+ setConfigParam(baseDir, 'userAgentsBlocked',
+ userAgentsBlockedStr)
+
+ # save peertube instances list
+ peertubeInstancesFile = \
+ baseDir + '/accounts/peertube.txt'
+ if fields.get('ptInstances'):
self.server.peertubeInstances.clear()
with open(peertubeInstancesFile, 'w+') as aFile:
aFile.write(fields['ptInstances'])
@@ -4916,15 +5233,14 @@ class PubServer(BaseHTTPRequestHandler):
if url in self.server.peertubeInstances:
continue
self.server.peertubeInstances.append(url)
- else:
- if os.path.isfile(peertubeInstancesFile):
- os.remove(peertubeInstancesFile)
- self.server.peertubeInstances.clear()
+ else:
+ if os.path.isfile(peertubeInstancesFile):
+ os.remove(peertubeInstancesFile)
+ self.server.peertubeInstances.clear()
# save git project names list
gitProjectsFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + \
+ acctDir(baseDir, nickname, domain) + \
'/gitprojects.txt'
if fields.get('gitProjects'):
with open(gitProjectsFilename, 'w+') as aFile:
@@ -5001,15 +5317,8 @@ class PubServer(BaseHTTPRequestHandler):
return
# redirect back to the profile screen
- if callingDomain.endswith('.onion') and \
- onionDomain:
- actorStr = \
- 'http://' + onionDomain + usersPath
- elif (callingDomain.endswith('.i2p') and
- i2pDomain):
- actorStr = \
- 'http://' + i2pDomain + usersPath
- self._redirect_headers(actorStr, cookie, callingDomain)
+ self._redirect_headers(actorStr + redirectPath,
+ cookie, callingDomain)
self.server.POSTbusy = False
def _progressiveWebAppManifest(self, callingDomain: str,
@@ -5173,6 +5482,45 @@ class PubServer(BaseHTTPRequestHandler):
print('favicon not sent: ' + callingDomain)
self._404()
+ def _getSpeaker(self, callingDomain: str, path: str,
+ baseDir: str, domain: str, debug: bool) -> None:
+ """Returns the speaker file used for TTS and
+ accessed via c2s
+ """
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ speakerFilename = \
+ acctDir(baseDir, nickname, domain) + '/speaker.json'
+ if not os.path.isfile(speakerFilename):
+ self._404()
+ return
+
+ speakerJson = loadJson(speakerFilename)
+ msg = json.dumps(speakerJson,
+ ensure_ascii=False).encode('utf-8')
+ msglen = len(msg)
+ self._set_headers('application/json', msglen,
+ None, callingDomain)
+ self._write(msg)
+
+ def _getExportedTheme(self, callingDomain: str, path: str,
+ baseDir: str, domainFull: str,
+ debug: bool) -> None:
+ """Returns an exported theme zip file
+ """
+ filename = path.split('/exports/', 1)[1]
+ filename = baseDir + '/exports/' + filename
+ if os.path.isfile(filename):
+ with open(filename, 'rb') as fp:
+ exportBinary = fp.read()
+ exportType = 'application/zip'
+ self._set_headers_etag(filename, exportType,
+ exportBinary, None,
+ domainFull)
+ self._write(exportBinary)
+ self._404()
+
def _getFonts(self, callingDomain: str, path: str,
baseDir: str, debug: bool,
GETstartTime, GETtimings: {}) -> None:
@@ -5244,8 +5592,8 @@ class PubServer(BaseHTTPRequestHandler):
if '/' in nickname:
nickname = nickname.split('/')[0]
if not nickname.startswith('rss.'):
- if os.path.isdir(self.server.baseDir +
- '/accounts/' + nickname + '@' + domain):
+ accountDir = acctDir(self.server.baseDir, nickname, domain)
+ if os.path.isdir(accountDir):
if not self.server.session:
print('Starting new session during RSS request')
self.server.session = \
@@ -5307,9 +5655,7 @@ class PubServer(BaseHTTPRequestHandler):
msg = ''
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
nickname = acct.split('@')[0]
domain = acct.split('@')[1]
@@ -5432,8 +5778,8 @@ class PubServer(BaseHTTPRequestHandler):
if '/' in nickname:
nickname = nickname.split('/')[0]
if not nickname.startswith('rss.'):
- if os.path.isdir(baseDir +
- '/accounts/' + nickname + '@' + domain):
+ accountDir = acctDir(baseDir, nickname, domain)
+ if os.path.isdir(accountDir):
if not self.server.session:
print('Starting new session during RSS3 request')
self.server.session = \
@@ -5473,7 +5819,8 @@ class PubServer(BaseHTTPRequestHandler):
domain: str, domainFull: str,
GETstartTime, GETtimings: {},
onionDomain: str, i2pDomain: str,
- cookie: str, debug: bool) -> None:
+ cookie: str, debug: bool,
+ authorized: bool) -> None:
"""Show person options screen
"""
backToPath = ''
@@ -5507,6 +5854,7 @@ class PubServer(BaseHTTPRequestHandler):
toxAddress = None
briarAddress = None
jamiAddress = None
+ cwtchAddress = None
ssbAddress = None
emailAddress = None
lockedAccount = False
@@ -5528,6 +5876,7 @@ class PubServer(BaseHTTPRequestHandler):
toxAddress = getToxAddress(actorJson)
briarAddress = getBriarAddress(actorJson)
jamiAddress = getJamiAddress(actorJson)
+ cwtchAddress = getCwtchAddress(actorJson)
emailAddress = getEmailAddress(actorJson)
PGPpubKey = getPGPpubKey(actorJson)
PGPfingerprint = getPGPfingerprint(actorJson)
@@ -5542,6 +5891,13 @@ class PubServer(BaseHTTPRequestHandler):
optionsActor, optionsProfileUrl,
self.server.personCache, 5)
+ accessKeys = self.server.accessKeys
+ if '/users/' in path:
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
msg = htmlPersonOptions(self.server.defaultTimeline,
self.server.cssCache,
self.server.translate,
@@ -5555,7 +5911,7 @@ class PubServer(BaseHTTPRequestHandler):
xmppAddress, matrixAddress,
ssbAddress, blogAddress,
toxAddress, briarAddress,
- jamiAddress,
+ jamiAddress, cwtchAddress,
PGPpubKey, PGPfingerprint,
emailAddress,
self.server.dormantMonths,
@@ -5563,7 +5919,9 @@ class PubServer(BaseHTTPRequestHandler):
lockedAccount,
movedTo, alsoKnownAs,
self.server.textModeBanner,
- self.server.newsInstance).encode('utf-8')
+ self.server.newsInstance,
+ authorized,
+ accessKeys).encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
cookie, callingDomain)
@@ -5595,9 +5953,9 @@ class PubServer(BaseHTTPRequestHandler):
GETstartTime, GETtimings: {}) -> None:
"""Returns a media file
"""
- if self._pathIsImage(path) or \
- self._pathIsVideo(path) or \
- self._pathIsAudio(path):
+ if isImageFile(path) or \
+ pathIsVideo(path) or \
+ pathIsAudio(path):
mediaStr = path.split('/media/')[1]
mediaFilename = baseDir + '/media/' + mediaStr
if os.path.isfile(mediaFilename):
@@ -5625,7 +5983,7 @@ class PubServer(BaseHTTPRequestHandler):
GETstartTime, GETtimings: {}) -> None:
"""Returns an emoji image
"""
- if self._pathIsImage(path):
+ if isImageFile(path):
emojiStr = path.split('/emoji/')[1]
emojiFilename = baseDir + '/emoji/' + emojiStr
if os.path.isfile(emojiFilename):
@@ -5634,23 +5992,11 @@ class PubServer(BaseHTTPRequestHandler):
self._304()
return
- mediaImageType = 'png'
- if emojiFilename.endswith('.png'):
- mediaImageType = 'png'
- elif emojiFilename.endswith('.jpg'):
- mediaImageType = 'jpeg'
- elif emojiFilename.endswith('.webp'):
- mediaImageType = 'webp'
- elif emojiFilename.endswith('.avif'):
- mediaImageType = 'avif'
- elif emojiFilename.endswith('.svg'):
- mediaImageType = 'svg+xml'
- else:
- mediaImageType = 'gif'
+ mediaImageType = getImageMimeType(emojiFilename)
with open(emojiFilename, 'rb') as avFile:
mediaBinary = avFile.read()
self._set_headers_etag(emojiFilename,
- 'image/' + mediaImageType,
+ mediaImageType,
mediaBinary, None,
self.server.domainFull)
self._write(mediaBinary)
@@ -5708,6 +6054,48 @@ class PubServer(BaseHTTPRequestHandler):
return
self._404()
+ def _showHelpScreenImage(self, callingDomain: str, path: str,
+ baseDir: str,
+ GETstartTime, GETtimings: {}) -> None:
+ """Shows a help screen image
+ """
+ if not isImageFile(path):
+ return
+ mediaStr = path.split('/helpimages/')[1]
+ if '/' not in mediaStr:
+ if not self.server.themeName:
+ theme = 'default'
+ else:
+ theme = self.server.themeName
+ iconFilename = mediaStr
+ else:
+ theme = mediaStr.split('/')[0]
+ iconFilename = mediaStr.split('/')[1]
+ mediaFilename = \
+ baseDir + '/theme/' + theme + '/helpimages/' + iconFilename
+ # if there is no theme-specific help image then use the default one
+ if not os.path.isfile(mediaFilename):
+ mediaFilename = \
+ baseDir + '/theme/default/helpimages/' + iconFilename
+ if self._etag_exists(mediaFilename):
+ # The file has not changed
+ self._304()
+ return
+ if os.path.isfile(mediaFilename):
+ with open(mediaFilename, 'rb') as avFile:
+ mediaBinary = avFile.read()
+ mimeType = mediaFileMimeType(mediaFilename)
+ self._set_headers_etag(mediaFilename,
+ mimeType,
+ mediaBinary, None,
+ self.server.domainFull)
+ self._write(mediaBinary)
+ self._benchmarkGETtimings(GETstartTime, GETtimings,
+ 'show files done',
+ 'help image shown')
+ return
+ self._404()
+
def _showCachedAvatar(self, callingDomain: str, path: str,
baseDir: str,
GETstartTime, GETtimings: {}) -> None:
@@ -5763,10 +6151,11 @@ class PubServer(BaseHTTPRequestHandler):
return
nickname = None
if '/users/' in path:
- actor = \
- httpPrefix + '://' + domainFull + path
- nickname = \
- getNicknameFromActor(actor)
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if '?' in nickname:
+ nickname = nickname.split('?')[0]
hashtagStr = \
htmlHashtagSearch(self.server.cssCache,
nickname, domain, port,
@@ -5782,7 +6171,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.YTReplacementDomain,
self.server.showPublishedDateOnly,
self.server.peertubeInstances,
- self.server.allowLocalNetworkAccess)
+ self.server.allowLocalNetworkAccess,
+ self.server.themeName)
if hashtagStr:
msg = hashtagStr.encode('utf-8')
msglen = len(msg)
@@ -5900,12 +6290,7 @@ class PubServer(BaseHTTPRequestHandler):
if not self.postToNickname:
print('WARN: unable to find nickname in ' + actor)
self.server.GETbusy = False
- actorAbsolute = \
- httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif (callingDomain.endswith('.i2p') and i2pDomain):
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + \
'?page=' + str(pageNumber)
@@ -5948,11 +6333,7 @@ class PubServer(BaseHTTPRequestHandler):
del self.server.iconsCache['repeat.png']
self._postToOutboxThread(announceJson)
self.server.GETbusy = False
- actorAbsolute = httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif callingDomain.endswith('.i2p') and i2pDomain:
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + '?page=' + \
str(pageNumber) + timelineBookmark
@@ -5968,13 +6349,17 @@ class PubServer(BaseHTTPRequestHandler):
domain: str, domainFull: str, port: int,
onionDomain: str, i2pDomain: str,
GETstartTime, GETtimings: {},
- repeatPrivate: bool, debug: bool):
+ repeatPrivate: bool, debug: bool,
+ recentPostsCache: {}):
"""Undo announce/repeat button was pressed
"""
pageNumber = 1
+
+ # the post which was referenced by the announce post
repeatUrl = path.split('?unrepeat=')[1]
if '?' in repeatUrl:
repeatUrl = repeatUrl.split('?')[0]
+
timelineBookmark = ''
if '?bm=' in path:
timelineBookmark = path.split('?bm=')[1]
@@ -5999,11 +6384,7 @@ class PubServer(BaseHTTPRequestHandler):
if not self.postToNickname:
print('WARN: unable to find nickname in ' + actor)
self.server.GETbusy = False
- actorAbsolute = httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif (callingDomain.endswith('.i2p') and i2pDomain):
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + '?page=' + \
str(pageNumber)
@@ -6027,11 +6408,11 @@ class PubServer(BaseHTTPRequestHandler):
"@context": "https://www.w3.org/ns/activitystreams",
'actor': undoAnnounceActor,
'type': 'Undo',
- 'cc': [undoAnnounceActor+'/followers'],
+ 'cc': [undoAnnounceActor + '/followers'],
'to': [unRepeatToStr],
'object': {
'actor': undoAnnounceActor,
- 'cc': [undoAnnounceActor+'/followers'],
+ 'cc': [undoAnnounceActor + '/followers'],
'object': repeatUrl,
'to': [unRepeatToStr],
'type': 'Announce'
@@ -6040,13 +6421,26 @@ class PubServer(BaseHTTPRequestHandler):
# clear the icon from the cache so that it gets updated
if self.server.iconsCache.get('repeat_inactive.png'):
del self.server.iconsCache['repeat_inactive.png']
+
+ # delete the announce post
+ if '?unannounce=' in path:
+ announceUrl = path.split('?unannounce=')[1]
+ if '?' in announceUrl:
+ announceUrl = announceUrl.split('?')[0]
+ postFilename = None
+ nickname = getNicknameFromActor(announceUrl)
+ if nickname:
+ if domainFull + '/users/' + nickname + '/' in announceUrl:
+ postFilename = \
+ locatePost(baseDir, nickname, domain, announceUrl)
+ if postFilename:
+ deletePost(baseDir, httpPrefix,
+ nickname, domain, postFilename,
+ debug, recentPostsCache)
+
self._postToOutboxThread(newUndoAnnounce)
self.server.GETbusy = False
- actorAbsolute = httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif (callingDomain.endswith('.i2p') and i2pDomain):
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + '?page=' + \
str(pageNumber) + timelineBookmark
@@ -6067,6 +6461,11 @@ class PubServer(BaseHTTPRequestHandler):
originPathStr = path.split('/followapprove=')[0]
followerNickname = originPathStr.replace('/users/', '')
followingHandle = path.split('/followapprove=')[1]
+ if '://' in followingHandle:
+ handleNickname = getNicknameFromActor(followingHandle)
+ handleDomain, handlePort = getDomainFromActor(followingHandle)
+ followingHandle = \
+ handleNickname + '@' + getFullDomain(handleDomain, handlePort)
if '@' in followingHandle:
if not self.server.session:
print('Starting new session during follow approval')
@@ -6137,7 +6536,7 @@ class PubServer(BaseHTTPRequestHandler):
try:
saveJson(newswire, newswireStateFilename)
except Exception as e:
- print('ERROR saving newswire state, ' + str(e))
+ print('ERROR: saving newswire state, ' + str(e))
if filename:
saveJson(newswireItem[votesIndex],
filename + '.votes')
@@ -6192,7 +6591,7 @@ class PubServer(BaseHTTPRequestHandler):
try:
saveJson(newswire, newswireStateFilename)
except Exception as e:
- print('ERROR saving newswire state, ' + str(e))
+ print('ERROR: saving newswire state, ' + str(e))
if filename:
saveJson(newswireItem[votesIndex],
filename + '.votes')
@@ -6228,6 +6627,11 @@ class PubServer(BaseHTTPRequestHandler):
originPathStr = path.split('/followdeny=')[0]
followerNickname = originPathStr.replace('/users/', '')
followingHandle = path.split('/followdeny=')[1]
+ if '://' in followingHandle:
+ handleNickname = getNicknameFromActor(followingHandle)
+ handleDomain, handlePort = getDomainFromActor(followingHandle)
+ followingHandle = \
+ handleNickname + '@' + getFullDomain(handleDomain, handlePort)
if '@' in followingHandle:
manualDenyFollowRequest(self.server.session,
baseDir, httpPrefix,
@@ -6294,12 +6698,7 @@ class PubServer(BaseHTTPRequestHandler):
if not self.postToNickname:
print('WARN: unable to find nickname in ' + actor)
self.server.GETbusy = False
- actorAbsolute = \
- httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif (callingDomain.endswith('.i2p') and i2pDomain):
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + \
'?page=' + str(pageNumber) + timelineBookmark
@@ -6338,7 +6737,8 @@ class PubServer(BaseHTTPRequestHandler):
updateLikesCollection(self.server.recentPostsCache,
baseDir,
likedPostFilename, likeUrl,
- likeActor, domain,
+ likeActor,
+ self.postToNickname, domain,
debug)
# clear the icon from the cache so that it gets updated
if self.server.iconsCache.get('like.png'):
@@ -6349,12 +6749,7 @@ class PubServer(BaseHTTPRequestHandler):
# send out the like to followers
self._postToOutbox(likeJson, self.server.projectVersion)
self.server.GETbusy = False
- actorAbsolute = \
- httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif (callingDomain.endswith('.i2p') and i2pDomain):
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + \
'?page=' + str(pageNumber) + timelineBookmark
@@ -6401,12 +6796,7 @@ class PubServer(BaseHTTPRequestHandler):
if not self.postToNickname:
print('WARN: unable to find nickname in ' + actor)
self.server.GETbusy = False
- actorAbsolute = \
- httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif (callingDomain.endswith('.i2p') and onionDomain):
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + \
'?page=' + str(pageNumber)
@@ -6456,11 +6846,7 @@ class PubServer(BaseHTTPRequestHandler):
# send out the undo like to followers
self._postToOutbox(undoLikeJson, self.server.projectVersion)
self.server.GETbusy = False
- actorAbsolute = httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif callingDomain.endswith('.i2p') and i2pDomain:
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + \
'?page=' + str(pageNumber) + timelineBookmark
@@ -6508,12 +6894,7 @@ class PubServer(BaseHTTPRequestHandler):
if not self.postToNickname:
print('WARN: unable to find nickname in ' + actor)
self.server.GETbusy = False
- actorAbsolute = \
- httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif callingDomain.endswith('.i2p') and i2pDomain:
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + \
'?page=' + str(pageNumber)
@@ -6552,12 +6933,7 @@ class PubServer(BaseHTTPRequestHandler):
del self.server.iconsCache['bookmark.png']
# self._postToOutbox(bookmarkJson, self.server.projectVersion)
self.server.GETbusy = False
- actorAbsolute = \
- httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif callingDomain.endswith('.i2p') and i2pDomain:
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + \
'?page=' + str(pageNumber) + timelineBookmark
@@ -6604,12 +6980,7 @@ class PubServer(BaseHTTPRequestHandler):
if not self.postToNickname:
print('WARN: unable to find nickname in ' + actor)
self.server.GETbusy = False
- actorAbsolute = \
- httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif callingDomain.endswith('.i2p') and i2pDomain:
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + \
'?page=' + str(pageNumber)
@@ -6648,12 +7019,7 @@ class PubServer(BaseHTTPRequestHandler):
del self.server.iconsCache['bookmark_inactive.png']
# self._postToOutbox(undoBookmarkJson, self.server.projectVersion)
self.server.GETbusy = False
- actorAbsolute = \
- httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif callingDomain.endswith('.i2p') and i2pDomain:
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
actorPathStr = \
actorAbsolute + '/' + timelineStr + \
'?page=' + str(pageNumber) + timelineBookmark
@@ -6673,7 +7039,7 @@ class PubServer(BaseHTTPRequestHandler):
"""Delete button is pressed on a post
"""
if not cookie:
- print('ERROR: no cookie given when deleting')
+ print('ERROR: no cookie given when deleting ' + path)
self._400()
self.server.GETbusy = False
return
@@ -6745,7 +7111,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.YTReplacementDomain,
self.server.showPublishedDateOnly,
self.server.peertubeInstances,
- self.server.allowLocalNetworkAccess)
+ self.server.allowLocalNetworkAccess,
+ self.server.themeName)
if deleteStr:
deleteStrLen = len(deleteStr)
self._set_headers('text/html', deleteStrLen,
@@ -6790,8 +7157,9 @@ class PubServer(BaseHTTPRequestHandler):
actor = \
httpPrefix + '://' + domainFull + path.split('?mute=')[0]
nickname = getNicknameFromActor(actor)
- mutePost(baseDir, nickname, domain,
- muteUrl, self.server.recentPostsCache)
+ mutePost(baseDir, nickname, domain, port,
+ httpPrefix, muteUrl,
+ self.server.recentPostsCache, debug)
self.server.GETbusy = False
if callingDomain.endswith('.onion') and onionDomain:
actor = \
@@ -6834,8 +7202,9 @@ class PubServer(BaseHTTPRequestHandler):
actor = \
httpPrefix + '://' + domainFull + path.split('?unmute=')[0]
nickname = getNicknameFromActor(actor)
- unmutePost(baseDir, nickname, domain,
- muteUrl, self.server.recentPostsCache)
+ unmutePost(baseDir, nickname, domain, port,
+ httpPrefix, muteUrl,
+ self.server.recentPostsCache, debug)
self.server.GETbusy = False
if callingDomain.endswith('.onion') and onionDomain:
actor = \
@@ -6881,7 +7250,7 @@ class PubServer(BaseHTTPRequestHandler):
boxname = 'outbox'
# get the replies file
postDir = \
- baseDir + '/accounts/' + nickname + '@' + domain + '/' + boxname
+ acctDir(baseDir, nickname, domain) + '/' + boxname
postRepliesFilename = \
postDir + '/' + \
httpPrefix + ':##' + domainFull + '#users#' + \
@@ -6950,7 +7319,8 @@ class PubServer(BaseHTTPRequestHandler):
ytDomain,
self.server.showPublishedDateOnly,
peertubeInstances,
- self.server.allowLocalNetworkAccess)
+ self.server.allowLocalNetworkAccess,
+ self.server.themeName)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -7037,7 +7407,8 @@ class PubServer(BaseHTTPRequestHandler):
ytDomain,
self.server.showPublishedDateOnly,
peertubeInstances,
- self.server.allowLocalNetworkAccess)
+ self.server.allowLocalNetworkAccess,
+ self.server.themeName)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -7079,8 +7450,7 @@ class PubServer(BaseHTTPRequestHandler):
postSections = namedStatus.split('/')
nickname = postSections[0]
- actorFilename = \
- baseDir + '/accounts/' + nickname + '@' + domain + '.json'
+ actorFilename = acctDir(baseDir, nickname, domain) + '.json'
if not os.path.isfile(actorFilename):
return False
@@ -7088,7 +7458,7 @@ class PubServer(BaseHTTPRequestHandler):
if not actorJson:
return False
- if actorJson.get('roles'):
+ if actorJson.get('hasOccupation'):
if self._requestHTTP():
getPerson = \
personLookup(domain, path.replace('/roles', ''),
@@ -7104,6 +7474,15 @@ class PubServer(BaseHTTPRequestHandler):
self.server.YTReplacementDomain
iconsAsButtons = \
self.server.iconsAsButtons
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
+
+ rolesList = getActorRolesList(actorJson)
+ city = \
+ getSpoofedCity(self.server.city,
+ baseDir, nickname, domain)
msg = \
htmlProfile(self.server.rssIconAtTop,
self.server.cssCache,
@@ -7126,7 +7505,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
self.server.textModeBanner,
- actorJson['roles'],
+ self.server.debug,
+ accessKeys, city, rolesList,
None, None)
msg = msg.encode('utf-8')
msglen = len(msg)
@@ -7138,7 +7518,8 @@ class PubServer(BaseHTTPRequestHandler):
'show roles')
else:
if self._fetchAuthenticated():
- msg = json.dumps(actorJson['roles'],
+ rolesList = getActorRolesList(actorJson)
+ msg = json.dumps(rolesList,
ensure_ascii=False)
msg = msg.encode('utf-8')
msglen = len(msg)
@@ -7165,13 +7546,11 @@ class PubServer(BaseHTTPRequestHandler):
if '/' in namedStatus:
postSections = namedStatus.split('/')
nickname = postSections[0]
- actorFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + '.json'
+ actorFilename = acctDir(baseDir, nickname, domain) + '.json'
if os.path.isfile(actorFilename):
actorJson = loadJson(actorFilename)
if actorJson:
- if actorJson.get('skills'):
+ if noOfActorSkills(actorJson) > 0:
if self._requestHTTP():
getPerson = \
personLookup(domain,
@@ -7192,6 +7571,16 @@ class PubServer(BaseHTTPRequestHandler):
self.server.iconsAsButtons
allowLocalNetworkAccess = \
self.server.allowLocalNetworkAccess
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+ actorSkillsList = \
+ getOccupationSkills(actorJson)
+ skills = getSkillsFromList(actorSkillsList)
+ city = getSpoofedCity(self.server.city,
+ baseDir,
+ nickname, domain)
msg = \
htmlProfile(self.server.rssIconAtTop,
self.server.cssCache,
@@ -7214,7 +7603,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.peertubeInstances,
allowLocalNetworkAccess,
self.server.textModeBanner,
- actorJson['skills'],
+ self.server.debug,
+ accessKeys, city, skills,
None, None)
msg = msg.encode('utf-8')
msglen = len(msg)
@@ -7227,7 +7617,10 @@ class PubServer(BaseHTTPRequestHandler):
'show skills')
else:
if self._fetchAuthenticated():
- msg = json.dumps(actorJson['skills'],
+ actorSkillsList = \
+ getOccupationSkills(actorJson)
+ skills = getSkillsFromList(actorSkillsList)
+ msg = json.dumps(skills,
ensure_ascii=False)
msg = msg.encode('utf-8')
msglen = len(msg)
@@ -7240,11 +7633,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.GETbusy = False
return True
actor = path.replace('/skills', '')
- actorAbsolute = httpPrefix + '://' + domainFull + actor
- if callingDomain.endswith('.onion') and onionDomain:
- actorAbsolute = 'http://' + onionDomain + actor
- elif callingDomain.endswith('.i2p') and i2pDomain:
- actorAbsolute = 'http://' + i2pDomain + actor
+ actorAbsolute = self._getInstalceUrl(callingDomain) + actor
self._redirect_headers(actorAbsolute, cookie, callingDomain)
self.server.GETbusy = False
return True
@@ -7273,108 +7662,106 @@ class PubServer(BaseHTTPRequestHandler):
if '/' not in namedStatus:
# show actor
nickname = namedStatus
+ return False
+
+ postSections = namedStatus.split('/')
+ if len(postSections) != 2:
+ return False
+ nickname = postSections[0]
+ statusNumber = postSections[1]
+ if len(statusNumber) <= 10 or not statusNumber.isdigit():
+ return False
+
+ postFilename = \
+ acctDir(baseDir, nickname, domain) + '/outbox/' + \
+ httpPrefix + ':##' + domainFull + '#users#' + nickname + \
+ '#statuses#' + statusNumber + '.json'
+
+ return self._showPostFromFile(postFilename, likedBy,
+ authorized, callingDomain, path,
+ baseDir, httpPrefix, nickname,
+ domain, domainFull, port,
+ onionDomain, i2pDomain,
+ GETstartTime, GETtimings,
+ proxyType, cookie, debug)
+
+ def _showPostFromFile(self, postFilename: str, likedBy: str,
+ authorized: bool,
+ callingDomain: str, path: str,
+ baseDir: str, httpPrefix: str, nickname: str,
+ domain: str, domainFull: str, port: int,
+ onionDomain: str, i2pDomain: str,
+ GETstartTime, GETtimings: {},
+ proxyType: str, cookie: str,
+ debug: str) -> bool:
+ """Shows an individual post from its filename
+ """
+ if not os.path.isfile(postFilename):
+ self._404()
+ self.server.GETbusy = False
+ return True
+
+ postJsonObject = loadJson(postFilename)
+ if not postJsonObject:
+ self.send_response(429)
+ self.end_headers()
+ self.server.GETbusy = False
+ return True
+
+ # Only authorized viewers get to see likes on posts
+ # Otherwize marketers could gain more social graph info
+ if not authorized:
+ pjo = postJsonObject
+ if not isPublicPost(pjo):
+ self._404()
+ self.server.GETbusy = False
+ return True
+ removePostInteractions(pjo, True)
+ if self._requestHTTP():
+ msg = \
+ htmlIndividualPost(self.server.cssCache,
+ self.server.recentPostsCache,
+ self.server.maxRecentPosts,
+ self.server.translate,
+ baseDir,
+ self.server.session,
+ self.server.cachedWebfingers,
+ self.server.personCache,
+ nickname, domain, port,
+ authorized,
+ postJsonObject,
+ httpPrefix,
+ self.server.projectVersion,
+ likedBy,
+ self.server.YTReplacementDomain,
+ self.server.showPublishedDateOnly,
+ self.server.peertubeInstances,
+ self.server.allowLocalNetworkAccess,
+ self.server.themeName)
+ msg = msg.encode('utf-8')
+ msglen = len(msg)
+ self._set_headers('text/html', msglen,
+ cookie, callingDomain)
+ self._write(msg)
+ self._benchmarkGETtimings(GETstartTime,
+ GETtimings,
+ 'show skills ' +
+ 'done',
+ 'show status')
else:
- postSections = namedStatus.split('/')
- if len(postSections) == 2:
- nickname = postSections[0]
- statusNumber = postSections[1]
- if len(statusNumber) > 10 and statusNumber.isdigit():
- postFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + \
- domain + '/outbox/' + \
- httpPrefix + ':##' + \
- domainFull + '#users#' + \
- nickname + '#statuses#' + \
- statusNumber + '.json'
- if os.path.isfile(postFilename):
- postJsonObject = loadJson(postFilename)
- loadedPost = False
- if postJsonObject:
- loadedPost = True
- else:
- postJsonObject = {}
- if loadedPost:
- # Only authorized viewers get to see likes
- # on posts. Otherwize marketers could gain
- # more social graph info
- if not authorized:
- pjo = postJsonObject
- if not isPublicPost(pjo):
- self._404()
- self.server.GETbusy = False
- return True
- self._removePostInteractions(pjo)
- if self._requestHTTP():
- recentPostsCache = \
- self.server.recentPostsCache
- maxRecentPosts = \
- self.server.maxRecentPosts
- translate = \
- self.server.translate
- cachedWebfingers = \
- self.server.cachedWebfingers
- personCache = \
- self.server.personCache
- projectVersion = \
- self.server.projectVersion
- ytDomain = \
- self.server.YTReplacementDomain
- showPublishedDateOnly = \
- self.server.showPublishedDateOnly
- peertubeInstances = \
- self.server.peertubeInstances
- cssCache = self.server.cssCache
- allowLocalNetworkAccess = \
- self.server.allowLocalNetworkAccess
- msg = \
- htmlIndividualPost(cssCache,
- recentPostsCache,
- maxRecentPosts,
- translate,
- self.server.baseDir,
- self.server.session,
- cachedWebfingers,
- personCache,
- nickname,
- domain,
- port,
- authorized,
- postJsonObject,
- httpPrefix,
- projectVersion,
- likedBy,
- ytDomain,
- showPublishedDateOnly,
- peertubeInstances,
- allowLocalNetworkAccess)
- msg = msg.encode('utf-8')
- msglen = len(msg)
- self._set_headers('text/html', msglen,
- cookie, callingDomain)
- self._write(msg)
- else:
- if self._fetchAuthenticated():
- msg = json.dumps(postJsonObject,
- ensure_ascii=False)
- msg = msg.encode('utf-8')
- msglen = len(msg)
- self._set_headers('application/json',
- msglen,
- None, callingDomain)
- self._write(msg)
- else:
- self._404()
- self.server.GETbusy = False
- self._benchmarkGETtimings(GETstartTime, GETtimings,
- 'new post done',
- 'individual post shown')
- return True
- else:
- self._404()
- self.server.GETbusy = False
- return True
- return False
+ if self._fetchAuthenticated():
+ msg = json.dumps(postJsonObject,
+ ensure_ascii=False)
+ msg = msg.encode('utf-8')
+ msglen = len(msg)
+ self._set_headers('application/json',
+ msglen,
+ None, callingDomain)
+ self._write(msg)
+ else:
+ self._404()
+ self.server.GETbusy = False
+ return True
def _showIndividualPost(self, authorized: bool,
callingDomain: str, path: str,
@@ -7402,105 +7789,51 @@ class PubServer(BaseHTTPRequestHandler):
statusNumber = postSections[2]
if len(statusNumber) <= 10 or (not statusNumber.isdigit()):
return False
- postFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + \
- domain + '/outbox/' + \
- httpPrefix + ':##' + \
- domainFull + '#users#' + \
- nickname + '#statuses#' + \
- statusNumber + '.json'
- if os.path.isfile(postFilename):
- postJsonObject = loadJson(postFilename)
- if not postJsonObject:
- self.send_response(429)
- self.end_headers()
- self.server.GETbusy = False
- return True
- else:
- # Only authorized viewers get to see likes
- # on posts
- # Otherwize marketers could gain more social
- # graph info
- if not authorized:
- pjo = postJsonObject
- if not isPublicPost(pjo):
- self._404()
- self.server.GETbusy = False
- return True
- self._removePostInteractions(pjo)
- if self._requestHTTP():
- recentPostsCache = \
- self.server.recentPostsCache
- maxRecentPosts = \
- self.server.maxRecentPosts
- translate = \
- self.server.translate
- cachedWebfingers = \
- self.server.cachedWebfingers
- personCache = \
- self.server.personCache
- projectVersion = \
- self.server.projectVersion
- ytDomain = \
- self.server.YTReplacementDomain
- showPublishedDateOnly = \
- self.server.showPublishedDateOnly
- peertubeInstances = \
- self.server.peertubeInstances
- allowLocalNetworkAccess = \
- self.server.allowLocalNetworkAccess
- msg = \
- htmlIndividualPost(self.server.cssCache,
- recentPostsCache,
- maxRecentPosts,
- translate,
- baseDir,
- self.server.session,
- cachedWebfingers,
- personCache,
- nickname,
- domain,
- port,
- authorized,
- postJsonObject,
- httpPrefix,
- projectVersion,
- likedBy,
- ytDomain,
- showPublishedDateOnly,
- peertubeInstances,
- allowLocalNetworkAccess)
- msg = msg.encode('utf-8')
- msglen = len(msg)
- self._set_headers('text/html', msglen,
- cookie, callingDomain)
- self._write(msg)
- self._benchmarkGETtimings(GETstartTime,
- GETtimings,
- 'show skills ' +
- 'done',
- 'show status')
- else:
- if self._fetchAuthenticated():
- msg = json.dumps(postJsonObject,
- ensure_ascii=False)
- msg = msg.encode('utf-8')
- msglen = len(msg)
- self._set_headers('application/json',
- msglen,
- None, callingDomain)
- self._write(msg)
- else:
- self._404()
- self.server.GETbusy = False
- return True
- else:
- self._404()
- self.server.GETbusy = False
- return True
- return False
+ postFilename = \
+ acctDir(baseDir, nickname, domain) + '/outbox/' + \
+ httpPrefix + ':##' + domainFull + '#users#' + nickname + \
+ '#statuses#' + statusNumber + '.json'
+
+ return self._showPostFromFile(postFilename, likedBy,
+ authorized, callingDomain, path,
+ baseDir, httpPrefix, nickname,
+ domain, domainFull, port,
+ onionDomain, i2pDomain,
+ GETstartTime, GETtimings,
+ proxyType, cookie, debug)
+
+ def _showNotifyPost(self, authorized: bool,
+ callingDomain: str, path: str,
+ baseDir: str, httpPrefix: str,
+ domain: str, domainFull: str, port: int,
+ onionDomain: str, i2pDomain: str,
+ GETstartTime, GETtimings: {},
+ proxyType: str, cookie: str,
+ debug: str) -> bool:
+ """Shows an individual post from an account which you are following
+ and where you have the notify checkbox set on person options
+ """
+ likedBy = None
+ postId = path.split('?notifypost=')[1].strip()
+ postId = postId.replace('-', '/')
+ path = path.split('?notifypost=')[0]
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ return False
+ replies = False
+
+ postFilename = locatePost(baseDir, nickname, domain, postId, replies)
+ if not postFilename:
+ return False
+
+ return self._showPostFromFile(postFilename, likedBy,
+ authorized, callingDomain, path,
+ baseDir, httpPrefix, nickname,
+ domain, domainFull, port,
+ onionDomain, i2pDomain,
+ GETstartTime, GETtimings,
+ proxyType, cookie, debug)
def _showInbox(self, authorized: bool,
callingDomain: str, path: str,
@@ -7574,7 +7907,13 @@ class PubServer(BaseHTTPRequestHandler):
'show inbox page')
fullWidthTimelineButtonHeader = \
self.server.fullWidthTimelineButtonHeader
- minimalNick = self._isMinimal(nickname)
+ minimalNick = isMinimal(baseDir, domain, nickname)
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
msg = htmlInbox(self.server.cssCache,
defaultTimeline,
recentPostsCache,
@@ -7606,7 +7945,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.themeName,
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
+ self.server.textModeBanner,
+ accessKeys)
if GETstartTime:
self._benchmarkGETtimings(GETstartTime, GETtimings,
'show status done',
@@ -7703,7 +8043,13 @@ class PubServer(BaseHTTPRequestHandler):
self.server.votingTimeMins)
fullWidthTimelineButtonHeader = \
self.server.fullWidthTimelineButtonHeader
- minimalNick = self._isMinimal(nickname)
+ minimalNick = isMinimal(baseDir, domain, nickname)
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
msg = \
htmlInboxDMs(self.server.cssCache,
self.server.defaultTimeline,
@@ -7735,7 +8081,8 @@ class PubServer(BaseHTTPRequestHandler):
authorized, self.server.themeName,
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
+ self.server.textModeBanner,
+ accessKeys)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -7825,7 +8172,13 @@ class PubServer(BaseHTTPRequestHandler):
self.server.votingTimeMins)
fullWidthTimelineButtonHeader = \
self.server.fullWidthTimelineButtonHeader
- minimalNick = self._isMinimal(nickname)
+ minimalNick = isMinimal(baseDir, domain, nickname)
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
msg = \
htmlInboxReplies(self.server.cssCache,
self.server.defaultTimeline,
@@ -7857,7 +8210,8 @@ class PubServer(BaseHTTPRequestHandler):
authorized, self.server.themeName,
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
+ self.server.textModeBanner,
+ accessKeys)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -7947,7 +8301,13 @@ class PubServer(BaseHTTPRequestHandler):
self.server.votingTimeMins)
fullWidthTimelineButtonHeader = \
self.server.fullWidthTimelineButtonHeader
- minimalNick = self._isMinimal(nickname)
+ minimalNick = isMinimal(baseDir, domain, nickname)
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
msg = \
htmlInboxMedia(self.server.cssCache,
self.server.defaultTimeline,
@@ -7980,7 +8340,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.themeName,
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
+ self.server.textModeBanner,
+ accessKeys)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -8070,7 +8431,13 @@ class PubServer(BaseHTTPRequestHandler):
self.server.votingTimeMins)
fullWidthTimelineButtonHeader = \
self.server.fullWidthTimelineButtonHeader
- minimalNick = self._isMinimal(nickname)
+ minimalNick = isMinimal(baseDir, domain, nickname)
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
msg = \
htmlInboxBlogs(self.server.cssCache,
self.server.defaultTimeline,
@@ -8103,7 +8470,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.themeName,
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
+ self.server.textModeBanner,
+ accessKeys)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -8201,7 +8569,13 @@ class PubServer(BaseHTTPRequestHandler):
editor = isEditor(baseDir, currNickname)
fullWidthTimelineButtonHeader = \
self.server.fullWidthTimelineButtonHeader
- minimalNick = self._isMinimal(nickname)
+ minimalNick = isMinimal(baseDir, domain, nickname)
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
msg = \
htmlInboxNews(self.server.cssCache,
self.server.defaultTimeline,
@@ -8235,7 +8609,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.themeName,
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
+ self.server.textModeBanner,
+ accessKeys)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -8330,7 +8705,13 @@ class PubServer(BaseHTTPRequestHandler):
currNickname = currNickname.split('/')[0]
fullWidthTimelineButtonHeader = \
self.server.fullWidthTimelineButtonHeader
- minimalNick = self._isMinimal(nickname)
+ minimalNick = isMinimal(baseDir, domain, nickname)
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
msg = \
htmlInboxFeatures(self.server.cssCache,
self.server.defaultTimeline,
@@ -8363,7 +8744,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.themeName,
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
+ self.server.textModeBanner,
+ accessKeys)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -8423,6 +8805,12 @@ class PubServer(BaseHTTPRequestHandler):
pageNumber = int(pageNumber)
else:
pageNumber = 1
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
msg = \
htmlShares(self.server.cssCache,
self.server.defaultTimeline,
@@ -8452,7 +8840,8 @@ class PubServer(BaseHTTPRequestHandler):
authorized, self.server.themeName,
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
+ self.server.textModeBanner,
+ accessKeys)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -8525,7 +8914,13 @@ class PubServer(BaseHTTPRequestHandler):
self.server.votingTimeMins)
fullWidthTimelineButtonHeader = \
self.server.fullWidthTimelineButtonHeader
- minimalNick = self._isMinimal(nickname)
+ minimalNick = isMinimal(baseDir, domain, nickname)
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
msg = \
htmlBookmarks(self.server.cssCache,
self.server.defaultTimeline,
@@ -8558,7 +8953,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.themeName,
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
+ self.server.textModeBanner,
+ accessKeys)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -8593,131 +8989,6 @@ class PubServer(BaseHTTPRequestHandler):
self.server.GETbusy = False
return True
- def _showEventsTimeline(self, authorized: bool,
- callingDomain: str, path: str,
- baseDir: str, httpPrefix: str,
- domain: str, domainFull: str, port: int,
- onionDomain: str, i2pDomain: str,
- GETstartTime, GETtimings: {},
- proxyType: str, cookie: str,
- debug: str) -> bool:
- """Shows the events timeline
- """
- if '/users/' in path:
- if authorized:
- # convert /events to /tlevents
- if path.endswith('/events') or \
- '/events?page=' in path:
- path = path.replace('/events', '/tlevents')
- eventsFeed = \
- personBoxJson(self.server.recentPostsCache,
- self.server.session,
- baseDir,
- domain,
- port,
- path,
- httpPrefix,
- maxPostsInFeed, 'tlevents',
- authorized,
- 0, self.server.positiveVoting,
- self.server.votingTimeMins)
- print('eventsFeed: ' + str(eventsFeed))
- if eventsFeed:
- if self._requestHTTP():
- nickname = path.replace('/users/', '')
- nickname = nickname.replace('/tlevents', '')
- pageNumber = 1
- if '?page=' in nickname:
- pageNumber = nickname.split('?page=')[1]
- nickname = nickname.split('?page=')[0]
- if pageNumber.isdigit():
- pageNumber = int(pageNumber)
- else:
- pageNumber = 1
- if 'page=' not in path:
- # if no page was specified then show the first
- eventsFeed = \
- personBoxJson(self.server.recentPostsCache,
- self.server.session,
- baseDir,
- domain,
- port,
- path + '?page=1',
- httpPrefix,
- maxPostsInFeed,
- 'tlevents',
- authorized,
- 0, self.server.positiveVoting,
- self.server.votingTimeMins)
- fullWidthTimelineButtonHeader = \
- self.server.fullWidthTimelineButtonHeader
- minimalNick = self._isMinimal(nickname)
- msg = \
- htmlEvents(self.server.cssCache,
- self.server.defaultTimeline,
- self.server.recentPostsCache,
- self.server.maxRecentPosts,
- self.server.translate,
- pageNumber, maxPostsInFeed,
- self.server.session,
- baseDir,
- self.server.cachedWebfingers,
- self.server.personCache,
- nickname,
- domain,
- port,
- eventsFeed,
- self.server.allowDeletion,
- httpPrefix,
- self.server.projectVersion,
- minimalNick,
- self.server.YTReplacementDomain,
- self.server.showPublishedDateOnly,
- self.server.newswire,
- self.server.positiveVoting,
- self.server.showPublishAsIcon,
- fullWidthTimelineButtonHeader,
- self.server.iconsAsButtons,
- self.server.rssIconAtTop,
- self.server.publishButtonAtTop,
- authorized,
- self.server.themeName,
- self.server.peertubeInstances,
- self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
- msg = msg.encode('utf-8')
- msglen = len(msg)
- self._set_headers('text/html', msglen,
- cookie, callingDomain)
- self._write(msg)
- self._benchmarkGETtimings(GETstartTime, GETtimings,
- 'show bookmarks 2 done',
- 'show events')
- else:
- # don't need authenticated fetch here because
- # there is already the authorization check
- msg = json.dumps(eventsFeed,
- ensure_ascii=False)
- msg = msg.encode('utf-8')
- msglen = len(msg)
- self._set_headers('application/json', msglen,
- None, callingDomain)
- self._write(msg)
- self.server.GETbusy = False
- return True
- else:
- if debug:
- nickname = path.replace('/users/', '')
- nickname = nickname.replace('/tlevents', '')
- print('DEBUG: ' + nickname +
- ' was not authorized to access ' + path)
- if debug:
- print('DEBUG: GET access to events is unauthorized')
- self.send_response(405)
- self.end_headers()
- self.server.GETbusy = False
- return True
-
def _showOutboxTimeline(self, authorized: bool,
callingDomain: str, path: str,
baseDir: str, httpPrefix: str,
@@ -8769,7 +9040,13 @@ class PubServer(BaseHTTPRequestHandler):
self.server.votingTimeMins)
fullWidthTimelineButtonHeader = \
self.server.fullWidthTimelineButtonHeader
- minimalNick = self._isMinimal(nickname)
+ minimalNick = isMinimal(baseDir, domain, nickname)
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
msg = \
htmlOutbox(self.server.cssCache,
self.server.defaultTimeline,
@@ -8802,7 +9079,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.themeName,
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
+ self.server.textModeBanner,
+ accessKeys)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -8879,6 +9157,12 @@ class PubServer(BaseHTTPRequestHandler):
fullWidthTimelineButtonHeader = \
self.server.fullWidthTimelineButtonHeader
moderationActionStr = ''
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
msg = \
htmlModeration(self.server.cssCache,
self.server.defaultTimeline,
@@ -8910,7 +9194,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.themeName,
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
- self.server.textModeBanner)
+ self.server.textModeBanner,
+ accessKeys)
msg = msg.encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -8989,6 +9274,18 @@ class PubServer(BaseHTTPRequestHandler):
self._404()
self.server.GETbusy = False
return True
+
+ accessKeys = self.server.accessKeys
+ if '/users/' in path:
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
+ city = getSpoofedCity(self.server.city,
+ baseDir, nickname, domain)
msg = \
htmlProfile(self.server.rssIconAtTop,
self.server.cssCache,
@@ -9012,6 +9309,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
self.server.textModeBanner,
+ self.server.debug,
+ accessKeys, city,
shares,
pageNumber, sharesPerPage)
msg = msg.encode('utf-8')
@@ -9051,7 +9350,8 @@ class PubServer(BaseHTTPRequestHandler):
"""
following = \
getFollowingFeed(baseDir, domain, port, path,
- httpPrefix, authorized, followsPerPage)
+ httpPrefix, authorized, followsPerPage,
+ 'following')
if following:
if self._requestHTTP():
pageNumber = 1
@@ -9087,6 +9387,18 @@ class PubServer(BaseHTTPRequestHandler):
self.server.GETbusy = False
return True
+ accessKeys = self.server.accessKeys
+ city = None
+ if '/users/' in path:
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
+ city = getSpoofedCity(self.server.city,
+ baseDir, nickname, domain)
msg = \
htmlProfile(self.server.rssIconAtTop,
self.server.cssCache,
@@ -9110,6 +9422,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
self.server.textModeBanner,
+ self.server.debug,
+ accessKeys, city,
following,
pageNumber,
followsPerPage).encode('utf-8')
@@ -9184,6 +9498,19 @@ class PubServer(BaseHTTPRequestHandler):
self._404()
self.server.GETbusy = False
return True
+
+ accessKeys = self.server.accessKeys
+ city = None
+ if '/users/' in path:
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
+ city = getSpoofedCity(self.server.city,
+ baseDir, nickname, domain)
msg = \
htmlProfile(self.server.rssIconAtTop,
self.server.cssCache,
@@ -9208,6 +9535,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
self.server.textModeBanner,
+ self.server.debug,
+ accessKeys, city,
followers,
pageNumber,
followsPerPage).encode('utf-8')
@@ -9305,6 +9634,19 @@ class PubServer(BaseHTTPRequestHandler):
self._404()
self.server.GETbusy = False
return True
+
+ accessKeys = self.server.accessKeys
+ city = None
+ if '/users/' in path:
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
+ city = getSpoofedCity(self.server.city,
+ baseDir, nickname, domain)
msg = \
htmlProfile(self.server.rssIconAtTop,
self.server.cssCache,
@@ -9329,6 +9671,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.peertubeInstances,
self.server.allowLocalNetworkAccess,
self.server.textModeBanner,
+ self.server.debug,
+ accessKeys, city,
None, None).encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -9421,6 +9765,7 @@ class PubServer(BaseHTTPRequestHandler):
'/emoji/' not in path and \
'/tags/' not in path and \
'/avatars/' not in path and \
+ '/headers/' not in path and \
'/fonts/' not in path and \
'/icons/' not in path:
divertToLoginScreen = True
@@ -9430,9 +9775,7 @@ class PubServer(BaseHTTPRequestHandler):
divertToLoginScreen = False
else:
if path.endswith('/following') or \
- '/following?page=' in path or \
path.endswith('/followers') or \
- '/followers?page=' in path or \
path.endswith('/skills') or \
path.endswith('/roles') or \
path.endswith('/shares'):
@@ -9484,7 +9827,7 @@ class PubServer(BaseHTTPRequestHandler):
if css:
break
except Exception as e:
- print(e)
+ print('ERROR: _getStyleSheet ' + str(tries) + ' ' + str(e))
time.sleep(1)
tries += 1
msg = css.encode('utf-8')
@@ -9507,7 +9850,7 @@ class PubServer(BaseHTTPRequestHandler):
nickname = getNicknameFromActor(path)
savePersonQrcode(baseDir, nickname, domain, port)
qrFilename = \
- baseDir + '/accounts/' + nickname + '@' + domain + '/qrcode.png'
+ acctDir(baseDir, nickname, domain) + '/qrcode.png'
if os.path.isfile(qrFilename):
if self._etag_exists(qrFilename):
# The file has not changed
@@ -9522,7 +9865,7 @@ class PubServer(BaseHTTPRequestHandler):
mediaBinary = avFile.read()
break
except Exception as e:
- print(e)
+ print('ERROR: _showQRcode ' + str(tries) + ' ' + str(e))
time.sleep(1)
tries += 1
if mediaBinary:
@@ -9545,8 +9888,7 @@ class PubServer(BaseHTTPRequestHandler):
"""
nickname = getNicknameFromActor(path)
bannerFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + '/search_banner.png'
+ acctDir(baseDir, nickname, domain) + '/search_banner.png'
if os.path.isfile(bannerFilename):
if self._etag_exists(bannerFilename):
# The file has not changed
@@ -9561,7 +9903,8 @@ class PubServer(BaseHTTPRequestHandler):
mediaBinary = avFile.read()
break
except Exception as e:
- print(e)
+ print('ERROR: _searchScreenBanner ' +
+ str(tries) + ' ' + str(e))
time.sleep(1)
tries += 1
if mediaBinary:
@@ -9587,8 +9930,7 @@ class PubServer(BaseHTTPRequestHandler):
self._404()
return True
bannerFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + '/' + side + '_col_image.png'
+ acctDir(baseDir, nickname, domain) + '/' + side + '_col_image.png'
if os.path.isfile(bannerFilename):
if self._etag_exists(bannerFilename):
# The file has not changed
@@ -9603,7 +9945,7 @@ class PubServer(BaseHTTPRequestHandler):
mediaBinary = avFile.read()
break
except Exception as e:
- print(e)
+ print('ERROR: _columnImage ' + str(tries) + ' ' + str(e))
time.sleep(1)
tries += 1
if mediaBinary:
@@ -9626,7 +9968,7 @@ class PubServer(BaseHTTPRequestHandler):
"""
imageExtensions = getImageExtensions()
for ext in imageExtensions:
- for bg in ('follow', 'options', 'login'):
+ for bg in ('follow', 'options', 'login', 'welcome'):
# follow screen background image
if path.endswith('/' + bg + '-background.' + ext):
bgFilename = \
@@ -9646,7 +9988,8 @@ class PubServer(BaseHTTPRequestHandler):
bgBinary = avFile.read()
break
except Exception as e:
- print(e)
+ print('ERROR: _showBackgroundImage ' +
+ str(tries) + ' ' + str(e))
time.sleep(1)
tries += 1
if bgBinary:
@@ -9671,41 +10014,33 @@ class PubServer(BaseHTTPRequestHandler):
GETstartTime, GETtimings: {}) -> bool:
"""Show a shared item image
"""
- if self._pathIsImage(path):
- mediaStr = path.split('/sharefiles/')[1]
- mediaFilename = \
- baseDir + '/sharefiles/' + mediaStr
- if os.path.isfile(mediaFilename):
- if self._etag_exists(mediaFilename):
- # The file has not changed
- self._304()
- return True
+ if not isImageFile(path):
+ self._404()
+ return True
- mediaFileType = 'png'
- if mediaFilename.endswith('.png'):
- mediaFileType = 'png'
- elif mediaFilename.endswith('.jpg'):
- mediaFileType = 'jpeg'
- elif mediaFilename.endswith('.webp'):
- mediaFileType = 'webp'
- elif mediaFilename.endswith('.avif'):
- mediaFileType = 'avif'
- elif mediaFilename.endswith('.svg'):
- mediaFileType = 'svg+xml'
- else:
- mediaFileType = 'gif'
- with open(mediaFilename, 'rb') as avFile:
- mediaBinary = avFile.read()
- self._set_headers_etag(mediaFilename,
- 'image/' + mediaFileType,
- mediaBinary, None,
- self.server.domainFull)
- self._write(mediaBinary)
- self._benchmarkGETtimings(GETstartTime, GETtimings,
- 'show media done',
- 'share files shown')
- return True
- self._404()
+ mediaStr = path.split('/sharefiles/')[1]
+ mediaFilename = \
+ baseDir + '/sharefiles/' + mediaStr
+ if not os.path.isfile(mediaFilename):
+ self._404()
+ return True
+
+ if self._etag_exists(mediaFilename):
+ # The file has not changed
+ self._304()
+ return True
+
+ mediaFileType = getImageMimeType(mediaFilename)
+ with open(mediaFilename, 'rb') as avFile:
+ mediaBinary = avFile.read()
+ self._set_headers_etag(mediaFilename,
+ mediaFileType,
+ mediaBinary, None,
+ self.server.domainFull)
+ self._write(mediaBinary)
+ self._benchmarkGETtimings(GETstartTime, GETtimings,
+ 'show media done',
+ 'share files shown')
return True
def _showAvatarOrBanner(self, callingDomain: str, path: str,
@@ -9713,59 +10048,56 @@ class PubServer(BaseHTTPRequestHandler):
GETstartTime, GETtimings: {}) -> bool:
"""Shows an avatar or banner or profile background image
"""
- if '/users/' in path:
- if self._pathIsImage(path):
- avatarStr = path.split('/users/')[1]
- if '/' in avatarStr and '.temp.' not in path:
- avatarNickname = avatarStr.split('/')[0]
- avatarFile = avatarStr.split('/')[1]
- avatarFileExt = avatarFile.split('.')[-1]
- # remove any numbers, eg. avatar123.png becomes avatar.png
- if avatarFile.startswith('avatar'):
- avatarFile = 'avatar.' + avatarFileExt
- elif avatarFile.startswith('banner'):
- avatarFile = 'banner.' + avatarFileExt
- elif avatarFile.startswith('search_banner'):
- avatarFile = 'search_banner.' + avatarFileExt
- elif avatarFile.startswith('image'):
- avatarFile = 'image.' + avatarFileExt
- elif avatarFile.startswith('left_col_image'):
- avatarFile = 'left_col_image.' + avatarFileExt
- elif avatarFile.startswith('right_col_image'):
- avatarFile = 'right_col_image.' + avatarFileExt
- avatarFilename = \
- baseDir + '/accounts/' + \
- avatarNickname + '@' + domain + '/' + avatarFile
- if os.path.isfile(avatarFilename):
- if self._etag_exists(avatarFilename):
- # The file has not changed
- self._304()
- return True
- mediaImageType = 'png'
- if avatarFile.endswith('.png'):
- mediaImageType = 'png'
- elif avatarFile.endswith('.jpg'):
- mediaImageType = 'jpeg'
- elif avatarFile.endswith('.gif'):
- mediaImageType = 'gif'
- elif avatarFile.endswith('.avif'):
- mediaImageType = 'avif'
- elif avatarFile.endswith('.svg'):
- mediaImageType = 'svg+xml'
- else:
- mediaImageType = 'webp'
- with open(avatarFilename, 'rb') as avFile:
- mediaBinary = avFile.read()
- self._set_headers_etag(avatarFilename,
- 'image/' + mediaImageType,
- mediaBinary, None,
- self.server.domainFull)
- self._write(mediaBinary)
- self._benchmarkGETtimings(GETstartTime, GETtimings,
- 'icon shown done',
- 'avatar background shown')
- return True
- return False
+ if '/users/' not in path:
+ if '/accounts/avatars/' not in path:
+ if '/accounts/headers/' not in path:
+ return False
+ if not isImageFile(path):
+ return False
+ if '/accounts/avatars/' in path:
+ avatarStr = path.split('/accounts/avatars/')[1]
+ elif '/accounts/headers/' in path:
+ avatarStr = path.split('/accounts/headers/')[1]
+ else:
+ avatarStr = path.split('/users/')[1]
+ if not ('/' in avatarStr and '.temp.' not in path):
+ return False
+ avatarNickname = avatarStr.split('/')[0]
+ avatarFile = avatarStr.split('/')[1]
+ avatarFileExt = avatarFile.split('.')[-1]
+ # remove any numbers, eg. avatar123.png becomes avatar.png
+ if avatarFile.startswith('avatar'):
+ avatarFile = 'avatar.' + avatarFileExt
+ elif avatarFile.startswith('banner'):
+ avatarFile = 'banner.' + avatarFileExt
+ elif avatarFile.startswith('search_banner'):
+ avatarFile = 'search_banner.' + avatarFileExt
+ elif avatarFile.startswith('image'):
+ avatarFile = 'image.' + avatarFileExt
+ elif avatarFile.startswith('left_col_image'):
+ avatarFile = 'left_col_image.' + avatarFileExt
+ elif avatarFile.startswith('right_col_image'):
+ avatarFile = 'right_col_image.' + avatarFileExt
+ avatarFilename = \
+ acctDir(baseDir, avatarNickname, domain) + '/' + avatarFile
+ if not os.path.isfile(avatarFilename):
+ return False
+ if self._etag_exists(avatarFilename):
+ # The file has not changed
+ self._304()
+ return True
+ mediaImageType = getImageMimeType(avatarFile)
+ with open(avatarFilename, 'rb') as avFile:
+ mediaBinary = avFile.read()
+ self._set_headers_etag(avatarFilename,
+ mediaImageType,
+ mediaBinary, None,
+ self.server.domainFull)
+ self._write(mediaBinary)
+ self._benchmarkGETtimings(GETstartTime, GETtimings,
+ 'icon shown done',
+ 'avatar background shown')
+ return True
def _confirmDeleteEvent(self, callingDomain: str, path: str,
baseDir: str, httpPrefix: str, cookie: str,
@@ -9840,7 +10172,7 @@ class PubServer(BaseHTTPRequestHandler):
# Various types of new post in the web interface
newPostEnd = ('newpost', 'newblog', 'newunlisted',
'newfollowers', 'newdm', 'newreminder',
- 'newevent', 'newreport', 'newquestion',
+ 'newreport', 'newquestion',
'newshare')
for postType in newPostEnd:
if path.endswith('/' + postType):
@@ -9848,6 +10180,13 @@ class PubServer(BaseHTTPRequestHandler):
break
if isNewPostEndpoint:
nickname = getNicknameFromActor(path)
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
+
+ customSubmitText = getConfigParam(baseDir, 'customSubmitText')
+
msg = htmlNewPost(self.server.cssCache,
mediaInstance,
translate,
@@ -9862,7 +10201,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.defaultTimeline,
self.server.newswire,
self.server.themeName,
- noDropDown).encode('utf-8')
+ noDropDown, accessKeys,
+ customSubmitText).encode('utf-8')
if not msg:
print('Error replying to ' + inReplyToUrl)
self._404()
@@ -9887,6 +10227,18 @@ class PubServer(BaseHTTPRequestHandler):
"""
if '/users/' in path and path.endswith('/editprofile'):
peertubeInstances = self.server.peertubeInstances
+ nickname = getNicknameFromActor(path)
+ if nickname:
+ city = getSpoofedCity(self.server.city,
+ baseDir, nickname, domain)
+ else:
+ city = self.server.city
+
+ accessKeys = self.server.accessKeys
+ if '/users/' in path:
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
+
msg = htmlEditProfile(self.server.cssCache,
translate,
baseDir,
@@ -9896,7 +10248,10 @@ class PubServer(BaseHTTPRequestHandler):
self.server.defaultTimeline,
self.server.themeName,
peertubeInstances,
- self.server.textModeBanner).encode('utf-8')
+ self.server.textModeBanner,
+ city,
+ self.server.userAgentsBlocked,
+ accessKeys).encode('utf-8')
if msg:
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -9915,6 +10270,14 @@ class PubServer(BaseHTTPRequestHandler):
"""Show the links from the left column
"""
if '/users/' in path and path.endswith('/editlinks'):
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
+
msg = htmlEditLinks(self.server.cssCache,
translate,
baseDir,
@@ -9922,7 +10285,7 @@ class PubServer(BaseHTTPRequestHandler):
port,
httpPrefix,
self.server.defaultTimeline,
- theme).encode('utf-8')
+ theme, accessKeys).encode('utf-8')
if msg:
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -9941,6 +10304,14 @@ class PubServer(BaseHTTPRequestHandler):
"""Show the newswire from the right column
"""
if '/users/' in path and path.endswith('/editnewswire'):
+ nickname = path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
+
msg = htmlEditNewswire(self.server.cssCache,
translate,
baseDir,
@@ -9948,7 +10319,8 @@ class PubServer(BaseHTTPRequestHandler):
port,
httpPrefix,
self.server.defaultTimeline,
- self.server.themeName).encode('utf-8')
+ self.server.themeName,
+ accessKeys).encode('utf-8')
if msg:
msglen = len(msg)
self._set_headers('text/html', msglen,
@@ -9995,43 +10367,28 @@ class PubServer(BaseHTTPRequestHandler):
return True
return False
- def _editEvent(self, callingDomain: str, path: str,
- httpPrefix: str, domain: str, domainFull: str,
- baseDir: str, translate: {},
- mediaInstance: bool,
- cookie: str) -> bool:
- """Show edit event screen
+ def _getFollowingJson(self, baseDir: str, path: str,
+ callingDomain: str,
+ httpPrefix: str,
+ domain: str, port: int,
+ followingItemsPerPage: int,
+ debug: bool, listName='following') -> None:
+ """Returns json collection for following.txt
"""
- messageId = path.split('?editeventpost=')[1]
- if '?' in messageId:
- messageId = messageId.split('?')[0]
- actor = path.split('?actor=')[1]
- if '?' in actor:
- actor = actor.split('?')[0]
- nickname = getNicknameFromActor(path)
- if nickname == actor:
- # postUrl = \
- # httpPrefix + '://' + \
- # domainFull + '/users/' + nickname + \
- # '/statuses/' + messageId
- msg = None
- # TODO
- # htmlEditEvent(mediaInstance,
- # translate,
- # baseDir,
- # httpPrefix,
- # path,
- # nickname, domain,
- # postUrl)
- if msg:
- msg = msg.encode('utf-8')
- msglen = len(msg)
- self._set_headers('text/html', msglen,
- cookie, callingDomain)
- self._write(msg)
- self.server.GETbusy = False
- return True
- return False
+ followingJson = \
+ getFollowingFeed(baseDir, domain, port, path, httpPrefix,
+ True, followingItemsPerPage, listName)
+ if not followingJson:
+ if debug:
+ print(listName + ' json feed not found for ' + path)
+ self._404()
+ return
+ msg = json.dumps(followingJson,
+ ensure_ascii=False).encode('utf-8')
+ msglen = len(msg)
+ self._set_headers('application/json',
+ msglen, None, callingDomain)
+ self._write(msg)
def do_GET(self):
callingDomain = self.server.domainFull
@@ -10051,6 +10408,10 @@ class PubServer(BaseHTTPRequestHandler):
self._400()
return
+ if self._blockedUserAgent(callingDomain):
+ self._400()
+ return
+
GETstartTime = time.time()
GETtimings = {}
@@ -10070,7 +10431,11 @@ class PubServer(BaseHTTPRequestHandler):
msg = \
htmlLogin(self.server.cssCache,
self.server.translate,
- self.server.baseDir, False).encode('utf-8')
+ self.server.baseDir,
+ self.server.httpPrefix,
+ self.server.domainFull,
+ self.server.systemLanguage,
+ False).encode('utf-8')
msglen = len(msg)
self._logout_headers('text/html', msglen, callingDomain)
self._write(msg)
@@ -10183,7 +10548,15 @@ class PubServer(BaseHTTPRequestHandler):
self.server.baseDir,
self.authorizedNickname,
self.server.domain,
- self.server.domainFull):
+ self.server.domainFull,
+ self.server.onionDomain,
+ self.server.i2pDomain,
+ self.server.translate,
+ self.server.registration,
+ self.server.systemLanguage,
+ self.server.projectVersion,
+ self.server.customEmoji,
+ self.server.showNodeInfoAccounts):
return
self._benchmarkGETtimings(GETstartTime, GETtimings,
@@ -10234,6 +10607,13 @@ class PubServer(BaseHTTPRequestHandler):
GETstartTime, GETtimings):
return
+ if authorized and '/exports/' in self.path:
+ self._getExportedTheme(callingDomain, self.path,
+ self.server.baseDir,
+ self.server.domainFull,
+ self.server.debug)
+ return
+
# get fonts
if '/fonts/' in self.path:
self._getFonts(callingDomain, self.path,
@@ -10330,6 +10710,81 @@ class PubServer(BaseHTTPRequestHandler):
if '/users/' in self.path:
usersInPath = True
+ if authorized and not htmlGET and usersInPath:
+ if '/following?page=' in self.path:
+ self._getFollowingJson(self.server.baseDir,
+ self.path,
+ callingDomain,
+ self.server.httpPrefix,
+ self.server.domain,
+ self.server.port,
+ self.server.followingItemsPerPage,
+ self.server.debug, 'following')
+ return
+ elif '/followers?page=' in self.path:
+ self._getFollowingJson(self.server.baseDir,
+ self.path,
+ callingDomain,
+ self.server.httpPrefix,
+ self.server.domain,
+ self.server.port,
+ self.server.followingItemsPerPage,
+ self.server.debug, 'followers')
+ return
+ elif '/followrequests?page=' in self.path:
+ self._getFollowingJson(self.server.baseDir,
+ self.path,
+ callingDomain,
+ self.server.httpPrefix,
+ self.server.domain,
+ self.server.port,
+ self.server.followingItemsPerPage,
+ self.server.debug,
+ 'followrequests')
+ return
+
+ # authorized endpoint used for TTS of posts
+ # arriving in your inbox
+ if authorized and usersInPath and \
+ self.path.endswith('/speaker'):
+ if 'application/ssml' not in self.headers['Accept']:
+ # json endpoint
+ self._getSpeaker(callingDomain, self.path,
+ self.server.baseDir,
+ self.server.domain,
+ self.server.debug)
+ else:
+ xmlStr = \
+ getSSMLbox(self.server.baseDir,
+ self.path, self.server.domain,
+ self.server.systemLanguage,
+ self.server.instanceTitle,
+ 'inbox')
+ if xmlStr:
+ msg = xmlStr.encode('utf-8')
+ msglen = len(msg)
+ self._set_headers('application/xrd+xml', msglen,
+ None, callingDomain)
+ self._write(msg)
+ return
+
+ # redirect to the welcome screen
+ if htmlGET and authorized and usersInPath and \
+ '/welcome' not in self.path:
+ nickname = self.path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if '?' in nickname:
+ nickname = nickname.split('?')[0]
+ if nickname == self.authorizedNickname and \
+ self.path != '/users/' + nickname:
+ if not isWelcomeScreenComplete(self.server.baseDir,
+ nickname,
+ self.server.domain):
+ self._redirect_headers('/users/' + nickname + '/welcome',
+ cookie, callingDomain)
+ return
+
if not htmlGET and \
usersInPath and self.path.endswith('/pinned'):
nickname = self.path.split('/users/')[1]
@@ -10482,7 +10937,8 @@ class PubServer(BaseHTTPRequestHandler):
GETstartTime, GETtimings,
self.server.onionDomain,
self.server.i2pDomain,
- cookie, self.server.debug)
+ cookie, self.server.debug,
+ authorized)
return
self._benchmarkGETtimings(GETstartTime, GETtimings,
@@ -10490,11 +10946,11 @@ class PubServer(BaseHTTPRequestHandler):
'person options done')
# show blog post
blogFilename, nickname = \
- self._pathContainsBlogLink(self.server.baseDir,
- self.server.httpPrefix,
- self.server.domain,
- self.server.domainFull,
- self.path)
+ pathContainsBlogLink(self.server.baseDir,
+ self.server.httpPrefix,
+ self.server.domain,
+ self.server.domainFull,
+ self.path)
if blogFilename and nickname:
postJsonObject = loadJson(blogFilename)
if isBlogPost(postJsonObject):
@@ -10505,7 +10961,8 @@ class PubServer(BaseHTTPRequestHandler):
nickname, self.server.domain,
self.server.domainFull,
postJsonObject,
- self.server.peertubeInstances)
+ self.server.peertubeInstances,
+ self.server.systemLanguage)
if msg is not None:
msg = msg.encode('utf-8')
msglen = len(msg)
@@ -10593,8 +11050,8 @@ class PubServer(BaseHTTPRequestHandler):
self.path.endswith('/followingaccounts'):
nickname = getNicknameFromActor(self.path)
followingFilename = \
- self.server.baseDir + '/accounts/' + \
- nickname + '@' + self.server.domain + '/following.txt'
+ acctDir(self.server.baseDir,
+ nickname, self.server.domain) + '/following.txt'
if not os.path.isfile(followingFilename):
self._404()
return
@@ -10618,13 +11075,15 @@ class PubServer(BaseHTTPRequestHandler):
htmlAbout(self.server.cssCache,
self.server.baseDir, 'http',
self.server.onionDomain,
- None, self.server.translate)
+ None, self.server.translate,
+ self.server.systemLanguage)
elif callingDomain.endswith('.i2p'):
msg = \
htmlAbout(self.server.cssCache,
self.server.baseDir, 'http',
self.server.i2pDomain,
- None, self.server.translate)
+ None, self.server.translate,
+ self.server.systemLanguage)
else:
msg = \
htmlAbout(self.server.cssCache,
@@ -10632,7 +11091,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.httpPrefix,
self.server.domainFull,
self.server.onionDomain,
- self.server.translate)
+ self.server.translate,
+ self.server.systemLanguage)
msg = msg.encode('utf-8')
msglen = len(msg)
self._login_headers('text/html', msglen, callingDomain)
@@ -10642,6 +11102,34 @@ class PubServer(BaseHTTPRequestHandler):
'show about screen')
return
+ if htmlGET and usersInPath and authorized and \
+ self.path.endswith('/accesskeys'):
+ nickname = self.path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = \
+ self.server.keyShortcuts[nickname]
+
+ msg = \
+ htmlAccessKeys(self.server.cssCache,
+ self.server.baseDir,
+ nickname, self.server.domain,
+ self.server.translate,
+ accessKeys,
+ self.server.accessKeys,
+ self.server.defaultTimeline)
+ msg = msg.encode('utf-8')
+ msglen = len(msg)
+ self._login_headers('text/html', msglen, callingDomain)
+ self._write(msg)
+ self._benchmarkGETtimings(GETstartTime, GETtimings,
+ 'following accounts done',
+ 'show accesskeys screen')
+ return
+
self._benchmarkGETtimings(GETstartTime, GETtimings,
'following accounts done',
'show about screen done')
@@ -10654,9 +11142,90 @@ class PubServer(BaseHTTPRequestHandler):
'show about screen done',
'robots txt')
+ # the initial welcome screen after first logging in
+ if htmlGET and authorized and \
+ '/users/' in self.path and self.path.endswith('/welcome'):
+ nickname = self.path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if not isWelcomeScreenComplete(self.server.baseDir,
+ nickname,
+ self.server.domain):
+ msg = \
+ htmlWelcomeScreen(self.server.baseDir, nickname,
+ self.server.systemLanguage,
+ self.server.translate,
+ self.server.themeName)
+ msg = msg.encode('utf-8')
+ msglen = len(msg)
+ self._login_headers('text/html', msglen, callingDomain)
+ self._write(msg)
+ self._benchmarkGETtimings(GETstartTime, GETtimings,
+ 'following accounts done',
+ 'show welcome screen')
+ return
+ else:
+ self.path = self.path.replace('/welcome', '')
+
+ # the welcome screen which allows you to set an avatar image
+ if htmlGET and authorized and \
+ '/users/' in self.path and self.path.endswith('/welcome_profile'):
+ nickname = self.path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if not isWelcomeScreenComplete(self.server.baseDir,
+ nickname,
+ self.server.domain):
+ msg = \
+ htmlWelcomeProfile(self.server.baseDir, nickname,
+ self.server.domain,
+ self.server.httpPrefix,
+ self.server.domainFull,
+ self.server.systemLanguage,
+ self.server.translate,
+ self.server.themeName)
+ msg = msg.encode('utf-8')
+ msglen = len(msg)
+ self._login_headers('text/html', msglen, callingDomain)
+ self._write(msg)
+ self._benchmarkGETtimings(GETstartTime, GETtimings,
+ 'show welcome screen',
+ 'show welcome profile screen')
+ return
+ else:
+ self.path = self.path.replace('/welcome_profile', '')
+
+ # the final welcome screen
+ if htmlGET and authorized and \
+ '/users/' in self.path and self.path.endswith('/welcome_final'):
+ nickname = self.path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+ if not isWelcomeScreenComplete(self.server.baseDir,
+ nickname,
+ self.server.domain):
+ msg = \
+ htmlWelcomeFinal(self.server.baseDir, nickname,
+ self.server.domain,
+ self.server.httpPrefix,
+ self.server.domainFull,
+ self.server.systemLanguage,
+ self.server.translate,
+ self.server.themeName)
+ msg = msg.encode('utf-8')
+ msglen = len(msg)
+ self._login_headers('text/html', msglen, callingDomain)
+ self._write(msg)
+ self._benchmarkGETtimings(GETstartTime, GETtimings,
+ 'show welcome profile screen',
+ 'show welcome final screen')
+ return
+ else:
+ self.path = self.path.replace('/welcome_final', '')
+
# if not authorized then show the login screen
if htmlGET and self.path != '/login' and \
- not self._pathIsImage(self.path) and \
+ not isImageFile(self.path) and \
self.path != '/' and \
self.path != '/users/news/linksmobile' and \
self.path != '/users/news/newswiremobile':
@@ -10700,7 +11269,8 @@ class PubServer(BaseHTTPRequestHandler):
mediaBinary = avFile.read()
break
except Exception as e:
- print(e)
+ print('ERROR: manifest logo ' +
+ str(tries) + ' ' + str(e))
time.sleep(1)
tries += 1
if mediaBinary:
@@ -10740,7 +11310,8 @@ class PubServer(BaseHTTPRequestHandler):
mediaBinary = avFile.read()
break
except Exception as e:
- print(e)
+ print('ERROR: manifest screenshot ' +
+ str(tries) + ' ' + str(e))
time.sleep(1)
tries += 1
if mediaBinary:
@@ -10761,14 +11332,9 @@ class PubServer(BaseHTTPRequestHandler):
'show screenshot done')
# image on login screen or qrcode
- if self.path == '/login.png' or \
- self.path == '/login.gif' or \
- self.path == '/login.svg' or \
- self.path == '/login.webp' or \
- self.path == '/login.avif' or \
- self.path == '/login.jpeg' or \
- self.path == '/login.jpg' or \
- self.path == '/qrcode.png':
+ if (isImageFile(self.path) and
+ (self.path.startswith('/login.') or
+ self.path.startswith('/qrcode.png'))):
iconFilename = \
self.server.baseDir + '/accounts' + self.path
if os.path.isfile(iconFilename):
@@ -10785,7 +11351,8 @@ class PubServer(BaseHTTPRequestHandler):
mediaBinary = avFile.read()
break
except Exception as e:
- print(e)
+ print('ERROR: login screen image ' +
+ str(tries) + ' ' + str(e))
time.sleep(1)
tries += 1
if mediaBinary:
@@ -10903,6 +11470,14 @@ class PubServer(BaseHTTPRequestHandler):
GETstartTime, GETtimings)
return
+ # help screen images
+ # Note that this comes before the busy flag to avoid conflicts
+ if self.path.startswith('/helpimages/'):
+ self._showHelpScreenImage(callingDomain, self.path,
+ self.server.baseDir,
+ GETstartTime, GETtimings)
+ return
+
self._benchmarkGETtimings(GETstartTime, GETtimings,
'show files done',
'icon shown done')
@@ -10949,7 +11524,7 @@ class PubServer(BaseHTTPRequestHandler):
'avatar background shown done',
'GET busy time')
- if not self._permittedDir(self.path):
+ if not permittedDir(self.path):
if self.server.debug:
print('DEBUG: GET Not permitted')
self._404()
@@ -10976,7 +11551,10 @@ class PubServer(BaseHTTPRequestHandler):
# request basic auth
msg = htmlLogin(self.server.cssCache,
self.server.translate,
- self.server.baseDir).encode('utf-8')
+ self.server.baseDir,
+ self.server.httpPrefix,
+ self.server.domainFull,
+ self.server.systemLanguage).encode('utf-8')
msglen = len(msg)
self._login_headers('text/html', msglen, callingDomain)
self._write(msg)
@@ -11034,6 +11612,9 @@ class PubServer(BaseHTTPRequestHandler):
rssIconAtTop = self.server.rssIconAtTop
iconsAsButtons = self.server.iconsAsButtons
defaultTimeline = self.server.defaultTimeline
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
msg = htmlNewswireMobile(self.server.cssCache,
self.server.baseDir,
nickname,
@@ -11049,7 +11630,8 @@ class PubServer(BaseHTTPRequestHandler):
rssIconAtTop,
iconsAsButtons,
defaultTimeline,
- self.server.themeName).encode('utf-8')
+ self.server.themeName,
+ accessKeys).encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen,
cookie, callingDomain)
@@ -11068,6 +11650,9 @@ class PubServer(BaseHTTPRequestHandler):
self._404()
self.server.GETbusy = False
return
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
timelinePath = \
'/users/' + nickname + '/' + self.server.defaultTimeline
iconsAsButtons = self.server.iconsAsButtons
@@ -11082,7 +11667,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.rssIconAtTop,
iconsAsButtons,
defaultTimeline,
- self.server.themeName).encode('utf-8')
+ self.server.themeName,
+ accessKeys).encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen, cookie, callingDomain)
self._write(msg)
@@ -11127,7 +11713,10 @@ class PubServer(BaseHTTPRequestHandler):
nickname = self.path.split('/users/')[1]
if '/' in nickname:
nickname = nickname.split('/')[0]
- self._setMinimal(nickname, not self._isMinimal(nickname))
+ notMin = not isMinimal(self.server.baseDir,
+ self.server.domain, nickname)
+ setMinimal(self.server.baseDir,
+ self.server.domain, nickname, notMin)
if not (self.server.mediaInstance or
self.server.blogsInstance):
self.path = '/users/' + nickname + '/inbox'
@@ -11146,6 +11735,15 @@ class PubServer(BaseHTTPRequestHandler):
'/search?' in self.path:
if '?' in self.path:
self.path = self.path.split('?')[0]
+
+ nickname = self.path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
+
# show the search screen
msg = htmlSearch(self.server.cssCache,
self.server.translate,
@@ -11153,7 +11751,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.domain,
self.server.defaultTimeline,
self.server.themeName,
- self.server.textModeBanner).encode('utf-8')
+ self.server.textModeBanner,
+ accessKeys).encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen, cookie, callingDomain)
self._write(msg)
@@ -11188,13 +11787,23 @@ class PubServer(BaseHTTPRequestHandler):
# Show the calendar for a user
if htmlGET and usersInPath:
if '/calendar' in self.path:
+ nickname = self.path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+
+ accessKeys = self.server.accessKeys
+ if self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.keyShortcuts[nickname]
+
# show the calendar screen
- msg = htmlCalendar(self.server.cssCache,
+ msg = htmlCalendar(self.server.personCache,
+ self.server.cssCache,
self.server.translate,
self.server.baseDir, self.path,
self.server.httpPrefix,
self.server.domainFull,
- self.server.textModeBanner).encode('utf-8')
+ self.server.textModeBanner,
+ accessKeys).encode('utf-8')
msglen = len(msg)
self._set_headers('text/html', msglen, cookie, callingDomain)
self._write(msg)
@@ -11255,7 +11864,7 @@ class PubServer(BaseHTTPRequestHandler):
repeatPrivate = True
self.path = self.path.replace('?repeatprivate=', '?repeat=')
# announce/repeat button was pressed
- if htmlGET and '?repeat=' in self.path:
+ if authorized and htmlGET and '?repeat=' in self.path:
self._announceButton(callingDomain, self.path,
self.server.baseDir,
cookie, self.server.proxyType,
@@ -11274,11 +11883,11 @@ class PubServer(BaseHTTPRequestHandler):
'emoji search shown done',
'show announce done')
- if htmlGET and '?unrepeatprivate=' in self.path:
+ if authorized and htmlGET and '?unrepeatprivate=' in self.path:
self.path = self.path.replace('?unrepeatprivate=', '?unrepeat=')
# undo an announce/repeat from the web interface
- if htmlGET and '?unrepeat=' in self.path:
+ if authorized and htmlGET and '?unrepeat=' in self.path:
self._undoAnnounceButton(callingDomain, self.path,
self.server.baseDir,
cookie, self.server.proxyType,
@@ -11290,7 +11899,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.i2pDomain,
GETstartTime, GETtimings,
repeatPrivate,
- self.server.debug)
+ self.server.debug,
+ self.server.recentPostsCache)
return
self._benchmarkGETtimings(GETstartTime, GETtimings,
@@ -11376,7 +11986,7 @@ class PubServer(BaseHTTPRequestHandler):
'follow deny done')
# like from the web interface icon
- if htmlGET and '?like=' in self.path:
+ if authorized and htmlGET and '?like=' in self.path:
self._likeButton(callingDomain, self.path,
self.server.baseDir,
self.server.httpPrefix,
@@ -11395,7 +12005,7 @@ class PubServer(BaseHTTPRequestHandler):
'like shown done')
# undo a like from the web interface icon
- if htmlGET and '?unlike=' in self.path:
+ if authorized and htmlGET and '?unlike=' in self.path:
self._undoLikeButton(callingDomain, self.path,
self.server.baseDir,
self.server.httpPrefix,
@@ -11413,7 +12023,7 @@ class PubServer(BaseHTTPRequestHandler):
'unlike shown done')
# bookmark from the web interface icon
- if htmlGET and '?bookmark=' in self.path:
+ if authorized and htmlGET and '?bookmark=' in self.path:
self._bookmarkButton(callingDomain, self.path,
self.server.baseDir,
self.server.httpPrefix,
@@ -11432,7 +12042,7 @@ class PubServer(BaseHTTPRequestHandler):
'bookmark shown done')
# undo a bookmark from the web interface icon
- if htmlGET and '?unbookmark=' in self.path:
+ if authorized and htmlGET and '?unbookmark=' in self.path:
self._undoBookmarkButton(callingDomain, self.path,
self.server.baseDir,
self.server.httpPrefix,
@@ -11451,7 +12061,7 @@ class PubServer(BaseHTTPRequestHandler):
'unbookmark shown done')
# delete button is pressed on a post
- if htmlGET and '?delete=' in self.path:
+ if authorized and htmlGET and '?delete=' in self.path:
self._deleteButton(callingDomain, self.path,
self.server.baseDir,
self.server.httpPrefix,
@@ -11470,7 +12080,7 @@ class PubServer(BaseHTTPRequestHandler):
'delete shown done')
# The mute button is pressed
- if htmlGET and '?mute=' in self.path:
+ if authorized and htmlGET and '?mute=' in self.path:
self._muteButton(callingDomain, self.path,
self.server.baseDir,
self.server.httpPrefix,
@@ -11489,7 +12099,7 @@ class PubServer(BaseHTTPRequestHandler):
'post muted done')
# unmute a post from the web interface icon
- if htmlGET and '?unmute=' in self.path:
+ if authorized and htmlGET and '?unmute=' in self.path:
self._undoMuteButton(callingDomain, self.path,
self.server.baseDir,
self.server.httpPrefix,
@@ -11634,21 +12244,6 @@ class PubServer(BaseHTTPRequestHandler):
self.server.GETbusy = False
return
- # Edit an event
- if authorized and \
- '/tlevents' in self.path and \
- '?editeventpost=' in self.path and \
- '?actor=' in self.path:
- if self._editEvent(callingDomain, self.path,
- self.server.httpPrefix,
- self.server.domain,
- self.server.domainFull,
- self.server.baseDir,
- self.server.translate,
- self.server.mediaInstance,
- cookie):
- return
-
# edit profile in web interface
if self._editProfile(callingDomain, self.path,
self.server.translate,
@@ -11786,6 +12381,21 @@ class PubServer(BaseHTTPRequestHandler):
'post roles done',
'show skills done')
+ if '?notifypost=' in self.path and usersInPath and authorized:
+ if self._showNotifyPost(authorized,
+ callingDomain, self.path,
+ self.server.baseDir,
+ self.server.httpPrefix,
+ self.server.domain,
+ self.server.domainFull,
+ self.server.port,
+ self.server.onionDomain,
+ self.server.i2pDomain,
+ GETstartTime, GETtimings,
+ self.server.proxyType,
+ cookie, self.server.debug):
+ return
+
# get an individual post from the path
# /users/nickname/statuses/number
if '/statuses/' in self.path and usersInPath:
@@ -12067,29 +12677,6 @@ class PubServer(BaseHTTPRequestHandler):
'show shares 2 done',
'show bookmarks 2 done')
- # get the events for a given person
- if self.path.endswith('/tlevents') or \
- '/tlevents?page=' in self.path or \
- self.path.endswith('/events') or \
- '/events?page=' in self.path:
- if self._showEventsTimeline(authorized,
- callingDomain, self.path,
- self.server.baseDir,
- self.server.httpPrefix,
- self.server.domain,
- self.server.domainFull,
- self.server.port,
- self.server.onionDomain,
- self.server.i2pDomain,
- GETstartTime, GETtimings,
- self.server.proxyType,
- cookie, self.server.debug):
- return
-
- self._benchmarkGETtimings(GETstartTime, GETtimings,
- 'show bookmarks 2 done',
- 'show events done')
-
# outbox timeline
if self._showOutboxTimeline(authorized,
callingDomain, self.path,
@@ -12271,9 +12858,9 @@ class PubServer(BaseHTTPRequestHandler):
fileLength = -1
if '/media/' in self.path:
- if self._pathIsImage(self.path) or \
- self._pathIsVideo(self.path) or \
- self._pathIsAudio(self.path):
+ if isImageFile(self.path) or \
+ pathIsVideo(self.path) or \
+ pathIsAudio(self.path):
mediaStr = self.path.split('/media/')[1]
mediaFilename = \
self.server.baseDir + '/media/' + mediaStr
@@ -12358,8 +12945,8 @@ class PubServer(BaseHTTPRequestHandler):
# Note: a .temp extension is used here so that at no time is
# an image with metadata publicly exposed, even for a few mS
filenameBase = \
- self.server.baseDir + '/accounts/' + \
- nickname + '@' + self.server.domain + '/upload.temp'
+ acctDir(self.server.baseDir,
+ nickname, self.server.domain) + '/upload.temp'
filename, attachmentMediaType = \
saveMediaInFormPOST(mediaBytes, self.server.debug,
@@ -12371,15 +12958,15 @@ class PubServer(BaseHTTPRequestHandler):
print('DEBUG: no media filename in POST')
if filename:
- if filename.endswith('.png') or \
- filename.endswith('.jpg') or \
- filename.endswith('.webp') or \
- filename.endswith('.avif') or \
- filename.endswith('.svg') or \
- filename.endswith('.gif'):
+ if isImageFile(filename):
postImageFilename = filename.replace('.temp', '')
print('Removing metadata from ' + postImageFilename)
- removeMetaData(filename, postImageFilename)
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ nickname, self.server.domain)
+ processMetaData(self.server.baseDir,
+ nickname, self.server.domain,
+ filename, postImageFilename, city)
if os.path.isfile(postImageFilename):
print('POST media saved to ' + postImageFilename)
else:
@@ -12387,7 +12974,9 @@ class PubServer(BaseHTTPRequestHandler):
postImageFilename)
else:
if os.path.isfile(filename):
- os.rename(filename, filename.replace('.temp', ''))
+ newFilename = filename.replace('.temp', '')
+ os.rename(filename, newFilename)
+ filename = newFilename
fields = \
extractTextFieldsInPOST(postBytes, boundary,
@@ -12413,9 +13002,13 @@ class PubServer(BaseHTTPRequestHandler):
not fields.get('pinToProfile'):
print('WARN: no message, image description or pin')
return -1
+ submitText = self.server.translate['Submit']
+ customSubmitText = \
+ getConfigParam(self.server.baseDir, 'customSubmitText')
+ if customSubmitText:
+ submitText = customSubmitText
if fields.get('submitPost'):
- if fields['submitPost'] != \
- self.server.translate['Submit']:
+ if fields['submitPost'] != submitText:
print('WARN: no submit field ' + fields['submitPost'])
return -1
else:
@@ -12447,13 +13040,11 @@ class PubServer(BaseHTTPRequestHandler):
# since epoch when an attempt to post something was made.
# This is then used for active monthly users counts
lastUsedFilename = \
- self.server.baseDir + '/accounts/' + \
- nickname + '@' + self.server.domain + '/.lastUsed'
+ acctDir(self.server.baseDir,
+ nickname, self.server.domain) + '/.lastUsed'
try:
- lastUsedFile = open(lastUsedFilename, 'w+')
- if lastUsedFile:
+ with open(lastUsedFilename, 'w+') as lastUsedFile:
lastUsedFile.write(str(int(time.time())))
- lastUsedFile.close()
except BaseException:
pass
@@ -12465,11 +13056,6 @@ class PubServer(BaseHTTPRequestHandler):
else:
commentsEnabled = True
- if not fields.get('privateEvent'):
- privateEvent = False
- else:
- privateEvent = True
-
if postType == 'newpost':
if not fields.get('pinToProfile'):
pinToProfile = False
@@ -12482,6 +13068,9 @@ class PubServer(BaseHTTPRequestHandler):
nickname, self.server.domain)
return 1
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ nickname, self.server.domain)
messageJson = \
createPublicPost(self.server.baseDir,
nickname,
@@ -12492,6 +13081,7 @@ class PubServer(BaseHTTPRequestHandler):
False, False, False, commentsEnabled,
filename, attachmentMediaType,
fields['imageDescription'],
+ city,
fields['replyTo'], fields['replyTo'],
fields['subject'], fields['schedulePost'],
fields['eventDate'], fields['eventTime'],
@@ -12548,14 +13138,20 @@ class PubServer(BaseHTTPRequestHandler):
print('WARN: blog posts must have content')
return -1
# submit button on newblog screen
+ followersOnly = False
+ saveToFile = False
+ clientToServer = False
+ city = None
messageJson = \
createBlogPost(self.server.baseDir, nickname,
self.server.domain, self.server.port,
self.server.httpPrefix,
fields['message'],
- False, False, False, commentsEnabled,
+ followersOnly, saveToFile,
+ clientToServer, commentsEnabled,
filename, attachmentMediaType,
fields['imageDescription'],
+ city,
fields['replyTo'], fields['replyTo'],
fields['subject'],
fields['schedulePost'],
@@ -12586,8 +13182,8 @@ class PubServer(BaseHTTPRequestHandler):
postJsonObject = loadJson(postFilename)
if postJsonObject:
cachedFilename = \
- self.server.baseDir + '/accounts/' + \
- nickname + '@' + self.server.domain + \
+ acctDir(self.server.baseDir,
+ nickname, self.server.domain) + \
'/postcache/' + \
fields['postUrl'].replace('/', '#') + '.html'
if os.path.isfile(cachedFilename):
@@ -12628,15 +13224,21 @@ class PubServer(BaseHTTPRequestHandler):
imgDescription = fields['imageDescription']
if filename:
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ nickname,
+ self.server.domain)
postJsonObject['object'] = \
attachMedia(self.server.baseDir,
self.server.httpPrefix,
+ nickname,
self.server.domain,
self.server.port,
postJsonObject['object'],
filename,
attachmentMediaType,
- imgDescription)
+ imgDescription,
+ city)
replaceYouTube(postJsonObject,
self.server.YTReplacementDomain)
@@ -12658,15 +13260,24 @@ class PubServer(BaseHTTPRequestHandler):
str(fields['postUrl']))
return -1
elif postType == 'newunlisted':
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ nickname,
+ self.server.domain)
+ followersOnly = False
+ saveToFile = False
+ clientToServer = False
messageJson = \
createUnlistedPost(self.server.baseDir,
nickname,
self.server.domain, self.server.port,
self.server.httpPrefix,
mentionsStr + fields['message'],
- False, False, False, commentsEnabled,
+ followersOnly, saveToFile,
+ clientToServer, commentsEnabled,
filename, attachmentMediaType,
fields['imageDescription'],
+ city,
fields['replyTo'],
fields['replyTo'],
fields['subject'],
@@ -12688,6 +13299,13 @@ class PubServer(BaseHTTPRequestHandler):
else:
return -1
elif postType == 'newfollowers':
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ nickname,
+ self.server.domain)
+ followersOnly = True
+ saveToFile = False
+ clientToServer = False
messageJson = \
createFollowersOnlyPost(self.server.baseDir,
nickname,
@@ -12695,10 +13313,12 @@ class PubServer(BaseHTTPRequestHandler):
self.server.port,
self.server.httpPrefix,
mentionsStr + fields['message'],
- True, False, False,
+ followersOnly, saveToFile,
+ clientToServer,
commentsEnabled,
filename, attachmentMediaType,
fields['imageDescription'],
+ city,
fields['replyTo'],
fields['replyTo'],
fields['subject'],
@@ -12719,63 +13339,17 @@ class PubServer(BaseHTTPRequestHandler):
return 1
else:
return -1
- elif postType == 'newevent':
- # A Mobilizon-type event is posted
-
- # if there is no image dscription then make it the same
- # as the event title
- if not fields.get('imageDescription'):
- fields['imageDescription'] = fields['subject']
- # Events are public by default, with opt-in
- # followers only status
- if not fields.get('followersOnlyEvent'):
- fields['followersOnlyEvent'] = False
-
- if not fields.get('anonymousParticipationEnabled'):
- anonymousParticipationEnabled = False
- else:
- anonymousParticipationEnabled = True
- maximumAttendeeCapacity = 999999
- if fields.get('maximumAttendeeCapacity'):
- maximumAttendeeCapacity = \
- int(fields['maximumAttendeeCapacity'])
-
- messageJson = \
- createEventPost(self.server.baseDir,
- nickname,
- self.server.domain,
- self.server.port,
- self.server.httpPrefix,
- mentionsStr + fields['message'],
- privateEvent,
- False, False, commentsEnabled,
- filename, attachmentMediaType,
- fields['imageDescription'],
- fields['subject'],
- fields['schedulePost'],
- fields['eventDate'],
- fields['eventTime'],
- fields['location'],
- fields['category'],
- fields['joinMode'],
- fields['endDate'],
- fields['endTime'],
- maximumAttendeeCapacity,
- fields['repliesModerationOption'],
- anonymousParticipationEnabled,
- fields['eventStatus'],
- fields['ticketUrl'])
- if messageJson:
- if fields['schedulePost']:
- return 1
- if self._postToOutbox(messageJson, __version__, nickname):
- return 1
- else:
- return -1
elif postType == 'newdm':
messageJson = None
print('A DM was posted')
if '@' in mentionsStr:
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ nickname,
+ self.server.domain)
+ followersOnly = True
+ saveToFile = False
+ clientToServer = False
messageJson = \
createDirectMessagePost(self.server.baseDir,
nickname,
@@ -12784,10 +13358,12 @@ class PubServer(BaseHTTPRequestHandler):
self.server.httpPrefix,
mentionsStr +
fields['message'],
- True, False, False,
+ followersOnly, saveToFile,
+ clientToServer,
commentsEnabled,
filename, attachmentMediaType,
fields['imageDescription'],
+ city,
fields['replyTo'],
fields['replyTo'],
fields['subject'],
@@ -12816,6 +13392,14 @@ class PubServer(BaseHTTPRequestHandler):
print('A reminder was posted for ' + handle)
if '@' + handle not in mentionsStr:
mentionsStr = '@' + handle + ' ' + mentionsStr
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ nickname,
+ self.server.domain)
+ followersOnly = True
+ saveToFile = False
+ clientToServer = False
+ commentsEnabled = False
messageJson = \
createDirectMessagePost(self.server.baseDir,
nickname,
@@ -12823,9 +13407,11 @@ class PubServer(BaseHTTPRequestHandler):
self.server.port,
self.server.httpPrefix,
mentionsStr + fields['message'],
- True, False, False, False,
+ followersOnly, saveToFile,
+ clientToServer, commentsEnabled,
filename, attachmentMediaType,
fields['imageDescription'],
+ city,
None, None,
fields['subject'],
True, fields['schedulePost'],
@@ -12849,6 +13435,10 @@ class PubServer(BaseHTTPRequestHandler):
# and not accounts being reported we disable any
# included fediverse addresses by replacing '@' with '-at-'
fields['message'] = fields['message'].replace('@', '-at-')
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ nickname,
+ self.server.domain)
messageJson = \
createReportPost(self.server.baseDir,
nickname,
@@ -12858,6 +13448,7 @@ class PubServer(BaseHTTPRequestHandler):
True, False, False, True,
filename, attachmentMediaType,
fields['imageDescription'],
+ city,
self.server.debug, fields['subject'])
if messageJson:
if self._postToOutbox(messageJson, __version__, nickname):
@@ -12877,6 +13468,10 @@ class PubServer(BaseHTTPRequestHandler):
str(questionCtr)])
if not qOptions:
return -1
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ nickname,
+ self.server.domain)
messageJson = \
createQuestionPost(self.server.baseDir,
nickname,
@@ -12888,6 +13483,7 @@ class PubServer(BaseHTTPRequestHandler):
commentsEnabled,
filename, attachmentMediaType,
fields['imageDescription'],
+ city,
fields['subject'],
int(fields['duration']))
if messageJson:
@@ -12912,6 +13508,10 @@ class PubServer(BaseHTTPRequestHandler):
if durationStr:
if ' ' not in durationStr:
durationStr = durationStr + ' days'
+ city = getSpoofedCity(self.server.city,
+ self.server.baseDir,
+ nickname,
+ self.server.domain)
addShare(self.server.baseDir,
self.server.httpPrefix,
nickname,
@@ -12923,7 +13523,8 @@ class PubServer(BaseHTTPRequestHandler):
fields['category'],
fields['location'],
durationStr,
- self.server.debug)
+ self.server.debug,
+ city)
if filename:
if os.path.isfile(filename):
os.remove(filename)
@@ -13017,8 +13618,7 @@ class PubServer(BaseHTTPRequestHandler):
print('WARN: POST postBytes socket error')
return None
except ValueError as e:
- print('ERROR: POST postBytes rfile.read failed')
- print(e)
+ print('ERROR: POST postBytes rfile.read failed, ' + str(e))
return None
# second length check from the bytes received
@@ -13059,8 +13659,8 @@ class PubServer(BaseHTTPRequestHandler):
print('WARN: handle POST messageBytes socket error')
return {}
except ValueError as e:
- print('ERROR: handle POST messageBytes rfile.read failed')
- print(e)
+ print('ERROR: handle POST messageBytes rfile.read failed ' +
+ str(e))
return {}
lenMessage = len(messageBytes)
@@ -13102,8 +13702,7 @@ class PubServer(BaseHTTPRequestHandler):
print('WARN: POST messageBytes socket error')
return {}
except ValueError as e:
- print('ERROR: POST messageBytes rfile.read failed')
- print(e)
+ print('ERROR: POST messageBytes rfile.read failed, ' + str(e))
return {}
lenMessage = len(messageBytes)
@@ -13236,6 +13835,10 @@ class PubServer(BaseHTTPRequestHandler):
self._400()
return
+ if self._blockedUserAgent(callingDomain):
+ self._400()
+ return
+
self.server.POSTbusy = True
if not self.headers.get('Content-type'):
print('Content-type header missing')
@@ -13247,7 +13850,6 @@ class PubServer(BaseHTTPRequestHandler):
if not self.path.endswith('confirm'):
self.path = self.path.replace('/outbox/', '/outbox')
self.path = self.path.replace('/tlblogs/', '/tlblogs')
- self.path = self.path.replace('/tlevents/', '/tlevents')
self.path = self.path.replace('/inbox/', '/inbox')
self.path = self.path.replace('/shares/', '/shares')
self.path = self.path.replace('/sharedInbox/', '/sharedInbox')
@@ -13264,7 +13866,7 @@ class PubServer(BaseHTTPRequestHandler):
# check authorization
authorized = self._isAuthorized()
- if not authorized:
+ if not authorized and self.server.debug:
print('POST Not authorized')
print(str(self.headers))
@@ -13314,7 +13916,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.domainFull,
self.server.onionDomain,
self.server.i2pDomain, self.server.debug,
- self.server.allowLocalNetworkAccess)
+ self.server.allowLocalNetworkAccess,
+ self.server.systemLanguage)
return
if authorized and self.path.endswith('/linksdata'):
@@ -13552,27 +14155,55 @@ class PubServer(BaseHTTPRequestHandler):
self.server.debug)
return
+ # Change the key shortcuts
+ if usersInPath and \
+ self.path.endswith('/changeAccessKeys'):
+ nickname = self.path.split('/users/')[1]
+ if '/' in nickname:
+ nickname = nickname.split('/')[0]
+
+ if not self.server.keyShortcuts.get(nickname):
+ accessKeys = self.server.accessKeys
+ self.server.keyShortcuts[nickname] = accessKeys.copy()
+ accessKeys = self.server.keyShortcuts[nickname]
+
+ self._keyShortcuts(self.path,
+ callingDomain, cookie,
+ self.server.baseDir,
+ self.server.httpPrefix,
+ nickname,
+ self.server.domain,
+ self.server.domainFull,
+ self.server.port,
+ self.server.onionDomain,
+ self.server.i2pDomain,
+ self.server.debug,
+ accessKeys,
+ self.server.defaultTimeline)
+ return
+
self._benchmarkPOSTtimings(POSTstartTime, POSTtimings, 14)
# receive different types of post created by htmlNewPost
postTypes = ("newpost", "newblog", "newunlisted", "newfollowers",
"newdm", "newreport", "newshare", "newquestion",
- "editblogpost", "newreminder", "newevent")
+ "editblogpost", "newreminder")
for currPostType in postTypes:
if not authorized:
+ if self.server.debug:
+ print('POST was not authorized')
break
postRedirect = self.server.defaultTimeline
if currPostType == 'newshare':
postRedirect = 'shares'
- elif currPostType == 'newevent':
- postRedirect = 'tlevents'
pageNumber = \
self._receiveNewPost(currPostType, self.path,
callingDomain, cookie,
authorized)
if pageNumber:
+ print(currPostType + ' post received')
nickname = self.path.split('/users/')[1]
if '?' in nickname:
nickname = nickname.split('?')[0]
@@ -13690,8 +14321,8 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST unknownPost rfile.read failed')
- print(e)
+ print('ERROR: POST unknownPost rfile.read failed, ' +
+ str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -13735,8 +14366,7 @@ class PubServer(BaseHTTPRequestHandler):
self.server.POSTbusy = False
return
except ValueError as e:
- print('ERROR: POST messageBytes rfile.read failed')
- print(e)
+ print('ERROR: POST messageBytes rfile.read failed, ' + str(e))
self.send_response(400)
self.end_headers()
self.server.POSTbusy = False
@@ -13805,8 +14435,10 @@ class PubServer(BaseHTTPRequestHandler):
self._benchmarkPOSTtimings(POSTstartTime, POSTtimings, 21)
- if not self.headers.get('signature'):
- if 'keyId=' not in self.headers['signature']:
+ headerSignature = self._getheaderSignatureInput()
+
+ if headerSignature:
+ if 'keyId=' not in headerSignature:
if self.server.debug:
print('DEBUG: POST to inbox has no keyId in ' +
'header signature parameter')
@@ -13858,7 +14490,8 @@ class PubServer(BaseHTTPRequestHandler):
return
else:
if self.path == '/sharedInbox' or self.path == '/inbox':
- print('DEBUG: POST to shared inbox')
+ if self.server.debug:
+ print('DEBUG: POST to shared inbox')
queueStatus = \
self._updateInboxQueue('inbox', messageJson, messageBytes)
if queueStatus >= 0 and queueStatus <= 3:
@@ -13876,9 +14509,13 @@ class EpicyonServer(ThreadingHTTPServer):
# surpress connection reset errors
cls, e = sys.exc_info()[:2]
if cls is ConnectionResetError:
- print('ERROR: ' + str(cls) + ", " + str(e))
+ if e.errno != errno.ECONNRESET:
+ print('ERROR: (EpicyonServer) ' + str(cls) + ", " + str(e))
+ pass
+ elif cls is BrokenPipeError:
pass
else:
+ print('ERROR: (EpicyonServer) ' + str(cls) + ", " + str(e))
return HTTPServer.handle_error(self, request, client_address)
@@ -13907,11 +14544,12 @@ def runPostsWatchdog(projectVersion: str, httpd) -> None:
httpd.thrPostsQueue.start()
while True:
time.sleep(20)
- if not httpd.thrPostsQueue.is_alive():
- httpd.thrPostsQueue.kill()
- httpd.thrPostsQueue = postsQueueOriginal.clone(runPostsQueue)
- httpd.thrPostsQueue.start()
- print('Restarting posts queue...')
+ if httpd.thrPostsQueue.is_alive():
+ continue
+ httpd.thrPostsQueue.kill()
+ httpd.thrPostsQueue = postsQueueOriginal.clone(runPostsQueue)
+ httpd.thrPostsQueue.start()
+ print('Restarting posts queue...')
def runSharesExpireWatchdog(projectVersion: str, httpd) -> None:
@@ -13922,11 +14560,12 @@ def runSharesExpireWatchdog(projectVersion: str, httpd) -> None:
httpd.thrSharesExpire.start()
while True:
time.sleep(20)
- if not httpd.thrSharesExpire.is_alive():
- httpd.thrSharesExpire.kill()
- httpd.thrSharesExpire = sharesExpireOriginal.clone(runSharesExpire)
- httpd.thrSharesExpire.start()
- print('Restarting shares expiry...')
+ if httpd.thrSharesExpire.is_alive():
+ continue
+ httpd.thrSharesExpire.kill()
+ httpd.thrSharesExpire = sharesExpireOriginal.clone(runSharesExpire)
+ httpd.thrSharesExpire.start()
+ print('Restarting shares expiry...')
def loadTokens(baseDir: str, tokensDict: {}, tokensLookup: {}) -> None:
@@ -13951,7 +14590,12 @@ def loadTokens(baseDir: str, tokensDict: {}, tokensLookup: {}) -> None:
break
-def runDaemon(brochMode: bool,
+def runDaemon(userAgentsBlocked: [],
+ logLoginFailures: bool,
+ city: str,
+ showNodeInfoAccounts: bool,
+ showNodeInfoVersion: bool,
+ brochMode: bool,
verifyAllSignatures: bool,
sendThreadsTimeoutMins: int,
dormantMonths: int,
@@ -13982,14 +14626,19 @@ def runDaemon(brochMode: bool,
baseDir: str, domain: str,
onionDomain: str, i2pDomain: str,
YTReplacementDomain: str,
- port=80, proxyPort=80, httpPrefix='https',
- fedList=[], maxMentions=10, maxEmoji=10,
- authenticatedFetch=False,
- proxyType=None, maxReplies=64,
- domainMaxPostsPerDay=8640, accountMaxPostsPerDay=864,
- allowDeletion=False, debug=False, unitTest=False,
- instanceOnlySkillsSearch=False, sendThreads=[],
- manualFollowerApproval=True) -> None:
+ port: int = 80, proxyPort: int = 80,
+ httpPrefix: str = 'https',
+ fedList: [] = [],
+ maxMentions: int = 10, maxEmoji: int = 10,
+ authenticatedFetch: bool = False,
+ proxyType: str = None, maxReplies: int = 64,
+ domainMaxPostsPerDay: int = 8640,
+ accountMaxPostsPerDay: int = 864,
+ allowDeletion: bool = False,
+ debug: bool = False, unitTest: bool = False,
+ instanceOnlySkillsSearch: bool = False,
+ sendThreads: [] = [],
+ manualFollowerApproval: bool = True) -> None:
if len(domain) == 0:
domain = 'localhost'
if '.' not in domain:
@@ -14017,11 +14666,60 @@ def runDaemon(brochMode: bool,
return False
print('ERROR: HTTP server failed to start. ' + str(e))
+ print('serverAddress: ' + str(serverAddress))
return False
+ httpd.showNodeInfoAccounts = showNodeInfoAccounts
+ httpd.showNodeInfoVersion = showNodeInfoVersion
+
# ASCII/ANSI text banner used in shell browsers, such as Lynx
httpd.textModeBanner = getTextModeBanner(baseDir)
+ # key shortcuts SHIFT + ALT + [key]
+ httpd.accessKeys = {
+ 'Page up': ',',
+ 'Page down': '.',
+ 'submitButton': 'y',
+ 'followButton': 'f',
+ 'blockButton': 'b',
+ 'infoButton': 'i',
+ 'snoozeButton': 's',
+ 'reportButton': '[',
+ 'viewButton': 'v',
+ 'enterPetname': 'p',
+ 'enterNotes': 'n',
+ 'menuTimeline': 't',
+ 'menuEdit': 'e',
+ 'menuProfile': 'p',
+ 'menuInbox': 'i',
+ 'menuSearch': '/',
+ 'menuNewPost': 'n',
+ 'menuCalendar': 'c',
+ 'menuDM': 'd',
+ 'menuReplies': 'r',
+ 'menuOutbox': 's',
+ 'menuBookmarks': 'q',
+ 'menuShares': 'h',
+ 'menuBlogs': 'b',
+ 'menuNewswire': 'w',
+ 'menuLinks': 'l',
+ 'menuMedia': 'm',
+ 'menuModeration': 'o',
+ 'menuFollowing': 'f',
+ 'menuFollowers': 'g',
+ 'menuRoles': 'o',
+ 'menuSkills': 'a',
+ 'menuLogout': 'x',
+ 'menuKeys': 'k',
+ 'Public': 'p',
+ 'Reminder': 'r'
+ }
+ httpd.keyShortcuts = {}
+ loadAccessKeysForAccounts(baseDir, httpd.keyShortcuts, httpd.accessKeys)
+
+ # list of blocked user agent types within the User-Agent header
+ httpd.userAgentsBlocked = userAgentsBlocked
+
httpd.unitTest = unitTest
httpd.allowLocalNetworkAccess = allowLocalNetworkAccess
if unitTest:
@@ -14049,47 +14747,21 @@ def runDaemon(brochMode: bool,
httpd.i2pDomain = i2pDomain
httpd.mediaInstance = mediaInstance
httpd.blogsInstance = blogsInstance
- httpd.newsInstance = newsInstance
- httpd.defaultTimeline = 'inbox'
- if mediaInstance:
- httpd.defaultTimeline = 'tlmedia'
- if blogsInstance:
- httpd.defaultTimeline = 'tlblogs'
- if newsInstance:
- httpd.defaultTimeline = 'tlfeatures'
# load translations dictionary
httpd.translate = {}
httpd.systemLanguage = 'en'
if not unitTest:
- if not os.path.isdir(baseDir + '/translations'):
- print('ERROR: translations directory not found')
- return
- if not language:
- systemLanguage = locale.getdefaultlocale()[0]
- else:
- systemLanguage = language
- if not systemLanguage:
- systemLanguage = 'en'
- if '_' in systemLanguage:
- systemLanguage = systemLanguage.split('_')[0]
- while '/' in systemLanguage:
- systemLanguage = systemLanguage.split('/')[1]
- if '.' in systemLanguage:
- systemLanguage = systemLanguage.split('.')[0]
- translationsFile = baseDir + '/translations/' + \
- systemLanguage + '.json'
- if not os.path.isfile(translationsFile):
- systemLanguage = 'en'
- translationsFile = baseDir + '/translations/' + \
- systemLanguage + '.json'
- print('System language: ' + systemLanguage)
- httpd.systemLanguage = systemLanguage
- httpd.translate = loadJson(translationsFile)
+ httpd.translate, httpd.systemLanguage = \
+ loadTranslationsFromFile(baseDir, language)
+ print('System language: ' + httpd.systemLanguage)
if not httpd.translate:
- print('ERROR: no translations loaded from ' + translationsFile)
+ print('ERROR: no translations were loaded')
sys.exit()
+ # spoofed city for gps location misdirection
+ httpd.city = city
+
# For moderated newswire feeds this is the amount of time allowed
# for voting after the post arrives
httpd.votingTimeMins = votingTimeMins
@@ -14153,6 +14825,7 @@ def runDaemon(brochMode: bool,
# for it to be considered dormant?
httpd.dormantMonths = dormantMonths
+ httpd.followingItemsPerPage = 12
if registration == 'open':
httpd.registration = True
else:
@@ -14165,8 +14838,8 @@ def runDaemon(brochMode: bool,
# max POST size of 30M
httpd.maxPostLength = 1024 * 1024 * 30
httpd.maxMediaSize = httpd.maxPostLength
- # Maximum text length is 32K - enough for a blog post
- httpd.maxMessageLength = 32000
+ # Maximum text length is 64K - enough for a blog post
+ httpd.maxMessageLength = 64000
# Maximum overall number of posts per box
httpd.maxPostsInBox = 32000
httpd.domain = domain
@@ -14194,6 +14867,9 @@ def runDaemon(brochMode: bool,
httpd.maxQueueLength = 64
httpd.allowDeletion = allowDeletion
httpd.lastLoginTime = 0
+ httpd.lastLoginFailure = 0
+ httpd.loginFailureCount = {}
+ httpd.logLoginFailures = logLoginFailures
httpd.maxReplies = maxReplies
httpd.tokens = {}
httpd.tokensLookup = {}
@@ -14202,9 +14878,23 @@ def runDaemon(brochMode: bool,
# contains threads used to send posts to followers
httpd.followersThreads = []
+ # create a cache of blocked domains in memory.
+ # This limits the amount of slow disk reads which need to be done
+ httpd.blockedCache = []
+ httpd.blockedCacheLastUpdated = 0
+ httpd.blockedCacheUpdateSecs = 120
+ httpd.blockedCacheLastUpdated = \
+ updateBlockedCache(baseDir, httpd.blockedCache,
+ httpd.blockedCacheLastUpdated,
+ httpd.blockedCacheUpdateSecs)
+
# cache to store css files
httpd.cssCache = {}
+ # get the list of custom emoji, for use by the mastodon api
+ httpd.customEmoji = \
+ metadataCustomEmoji(baseDir, httpPrefix, httpd.domainFull)
+
# whether to enable broch mode, which locks down the instance
setBrochMode(baseDir, httpd.domainFull, brochMode)
@@ -14220,6 +14910,18 @@ def runDaemon(brochMode: bool,
httpd.themeName = getConfigParam(baseDir, 'theme')
if not httpd.themeName:
httpd.themeName = 'default'
+ if isNewsThemeName(baseDir, httpd.themeName):
+ newsInstance = True
+
+ httpd.newsInstance = newsInstance
+ httpd.defaultTimeline = 'inbox'
+ if mediaInstance:
+ httpd.defaultTimeline = 'tlmedia'
+ if blogsInstance:
+ httpd.defaultTimeline = 'tlblogs'
+ if newsInstance:
+ httpd.defaultTimeline = 'tlfeatures'
+
setNewsAvatar(baseDir,
httpd.themeName,
httpPrefix,
@@ -14310,7 +15012,8 @@ def runDaemon(brochMode: bool,
httpd.maxFollowers,
httpd.allowLocalNetworkAccess,
httpd.peertubeInstances,
- verifyAllSignatures), daemon=True)
+ verifyAllSignatures,
+ httpd.themeName), daemon=True)
print('Creating scheduled post thread')
httpd.thrPostSchedule = \
diff --git a/default_about.md b/default_about.md
new file mode 100644
index 000000000..4d03b1056
--- /dev/null
+++ b/default_about.md
@@ -0,0 +1,9 @@
+# About this Instance
+### Origin Story
+How your instance began.
+
+### Lore
+Customs and rituals.
+
+### Epic Tales
+Heroic deeds and dastardly foes.
diff --git a/default_about.txt b/default_about.txt
deleted file mode 100644
index a1e535820..000000000
--- a/default_about.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-
About this Instance
-
-
Origin Story
-
-
How your instance began.
-
-
Lore
-
-
Customs and rituals.
-
-
Epic Tales
-
-
Heroic deeds and dastardly foes.
diff --git a/default_tos.md b/default_tos.md
new file mode 100644
index 000000000..eb28005c6
--- /dev/null
+++ b/default_tos.md
@@ -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)
diff --git a/default_tos.txt b/default_tos.txt
deleted file mode 100644
index 176240893..000000000
--- a/default_tos.txt
+++ /dev/null
@@ -1,45 +0,0 @@
-
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.
-
-
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.
-
-
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.
'
+
+ markdown = \
+ 'This is [a link](https://something.somewhere) to something.\n' + \
+ 'And [something else](https://cat.pic).\n' + \
+ 'Or .'
+ assert markdownToHtml(markdown) == \
+ 'This is ' + \
+ 'a link to something. ' + \
+ 'And ' + \
+ 'something else. ' + \
+ 'Or .'
+
+
+def _testExtractTextFieldsInPOST():
+ print('testExtractTextFieldsInPOST')
+ boundary = '-----------------------------116202748023898664511855843036'
+ formData = '-----------------------------116202748023898664511855' + \
+ '843036\r\nContent-Disposition: form-data; name="submitPost"' + \
+ '\r\n\r\nSubmit\r\n-----------------------------116202748023' + \
+ '898664511855843036\r\nContent-Disposition: form-data; name=' + \
+ '"subject"\r\n\r\n\r\n-----------------------------116202748' + \
+ '023898664511855843036\r\nContent-Disposition: form-data; na' + \
+ 'me="message"\r\n\r\nThis is a ; test\r\n-------------------' + \
+ '----------116202748023898664511855843036\r\nContent-Disposi' + \
+ 'tion: form-data; name="commentsEnabled"\r\n\r\non\r\n------' + \
+ '-----------------------116202748023898664511855843036\r\nCo' + \
+ 'ntent-Disposition: form-data; name="eventDate"\r\n\r\n\r\n' + \
+ '-----------------------------116202748023898664511855843036' + \
+ '\r\nContent-Disposition: form-data; name="eventTime"\r\n\r' + \
+ '\n\r\n-----------------------------116202748023898664511855' + \
+ '843036\r\nContent-Disposition: form-data; name="location"' + \
+ '\r\n\r\n\r\n-----------------------------116202748023898664' + \
+ '511855843036\r\nContent-Disposition: form-data; name=' + \
+ '"imageDescription"\r\n\r\n\r\n-----------------------------' + \
+ '116202748023898664511855843036\r\nContent-Disposition: ' + \
+ 'form-data; name="attachpic"; filename=""\r\nContent-Type: ' + \
+ 'application/octet-stream\r\n\r\n\r\n----------------------' + \
+ '-------116202748023898664511855843036--\r\n'
+ debug = False
+ fields = extractTextFieldsInPOST(None, boundary, debug, formData)
+ assert fields['submitPost'] == 'Submit'
+ assert fields['subject'] == ''
+ assert fields['commentsEnabled'] == 'on'
+ assert fields['eventDate'] == ''
+ assert fields['eventTime'] == ''
+ assert fields['location'] == ''
+ assert fields['imageDescription'] == ''
+ assert fields['message'] == 'This is a ; test'
+
+
+def _testSpeakerReplaceLinks():
+ print('testSpeakerReplaceLinks')
+ text = 'The Tor Project: For Snowflake volunteers: If you use ' + \
+ 'Firefox, Brave, or Chrome, our Snowflake extension turns ' + \
+ 'your browser into a proxy that connects Tor users in ' + \
+ 'censored regions to the Tor network. Note: you should ' + \
+ 'not run more than one snowflake in the same ' + \
+ 'network.https://support.torproject.org/censorship/' + \
+ 'how-to-help-running-snowflake/'
+ detectedLinks = []
+ result = speakerReplaceLinks(text, {'Linked': 'Web link'}, detectedLinks)
+ assert len(detectedLinks) == 1
+ assert detectedLinks[0] == \
+ 'https://support.torproject.org/censorship/' + \
+ 'how-to-help-running-snowflake/'
+ assert 'Web link support.torproject.org' in result
+
+
+def _testCamelCaseSplit():
+ print('testCamelCaseSplit')
+ testStr = 'ThisIsCamelCase'
+ assert camelCaseSplit(testStr) == 'This Is Camel Case'
+
+ testStr = 'Notcamelcase test'
+ assert camelCaseSplit(testStr) == 'Notcamelcase test'
+
+
+def _testEmojiImages():
+ print('testEmojiImages')
+ emojiFilename = 'emoji/default_emoji.json'
+ assert os.path.isfile(emojiFilename)
+ emojiJson = loadJson(emojiFilename)
+ assert emojiJson
+ for emojiName, emojiImage in emojiJson.items():
+ emojiImageFilename = 'emoji/' + emojiImage + '.png'
+ if not os.path.isfile(emojiImageFilename):
+ print('Missing emoji image ' + emojiName + ' ' +
+ emojiImage + '.png')
+ assert os.path.isfile(emojiImageFilename)
+
+
+def _testExtractPGPPublicKey():
+ print('testExtractPGPPublicKey')
+ pubKey = \
+ '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n' + \
+ 'mDMEWZBueBYJKwYBBAHaRw8BAQdAKx1t6wL0RTuU6/' + \
+ 'IBjngMbVJJ3Wg/3UW73/PV\n' + \
+ 'I47xKTS0IUJvYiBNb3R0cmFtIDxib2JAZnJlZWRvb' + \
+ 'WJvbmUubmV0PoiQBBMWCAA4\n' + \
+ 'FiEEmruCwAq/OfgmgEh9zCU2GR+nwz8FAlmQbngCG' + \
+ 'wMFCwkIBwMFFQoJCAsFFgID\n' + \
+ 'AQACHgECF4AACgkQzCU2GR+nwz/9sAD/YgsHnVszH' + \
+ 'Nz1zlVc5EgY1ByDupiJpHj0\n' + \
+ 'XsLYk3AbNRgBALn45RqgD4eWHpmOriH09H5Rc5V9i' + \
+ 'N4+OiGUn2AzJ6oHuDgEWZBu\n' + \
+ 'eBIKKwYBBAGXVQEFAQEHQPRBG2ZQJce475S3e0Dxe' + \
+ 'b0Fz5WdEu2q3GYLo4QG+4Ry\n' + \
+ 'AwEIB4h4BBgWCAAgFiEEmruCwAq/OfgmgEh9zCU2G' + \
+ 'R+nwz8FAlmQbngCGwwACgkQ\n' + \
+ 'zCU2GR+nwz+OswD+JOoyBku9FzuWoVoOevU2HH+bP' + \
+ 'OMDgY2OLnST9ZSyHkMBAMcK\n' + \
+ 'fnaZ2Wi050483Sj2RmQRpb99Dod7rVZTDtCqXk0J\n' + \
+ '=gv5G\n' + \
+ '-----END PGP PUBLIC KEY BLOCK-----'
+ testStr = "Some introduction\n\n" + pubKey + "\n\nSome message."
+ assert containsPGPPublicKey(testStr)
+ assert not containsPGPPublicKey('String without a pgp key')
+ result = extractPGPPublicKey(testStr)
+ assert result
+ assert result == pubKey
+
+
+def testUpdateActor():
+ print('Testing update of actor properties')
+
+ global testServerAliceRunning
+ testServerAliceRunning = False
+
+ httpPrefix = 'http'
+ proxyType = None
+ federationList = []
+
+ baseDir = os.getcwd()
+ if os.path.isdir(baseDir + '/.tests'):
+ shutil.rmtree(baseDir + '/.tests')
+ os.mkdir(baseDir + '/.tests')
+
+ # create the server
+ aliceDir = baseDir + '/.tests/alice'
+ aliceDomain = '127.0.0.11'
+ alicePort = 61792
+ aliceSendThreads = []
+ bobAddress = '127.0.0.84:6384'
+
+ global thrAlice
+ if thrAlice:
+ while thrAlice.is_alive():
+ thrAlice.stop()
+ time.sleep(1)
+ thrAlice.kill()
+
+ thrAlice = \
+ threadWithTrace(target=createServerAlice,
+ args=(aliceDir, aliceDomain, alicePort, bobAddress,
+ federationList, False, False,
+ aliceSendThreads),
+ daemon=True)
+
+ thrAlice.start()
+ assert thrAlice.is_alive() is True
+
+ # wait for server to be running
+ ctr = 0
+ while not testServerAliceRunning:
+ time.sleep(1)
+ ctr += 1
+ if ctr > 60:
+ break
+ print('Alice online: ' + str(testServerAliceRunning))
+
+ print('\n\n*******************************************************')
+ print('Alice updates her PGP key')
+
+ sessionAlice = createSession(proxyType)
+ cachedWebfingers = {}
+ personCache = {}
+ password = 'alicepass'
+ outboxPath = aliceDir + '/accounts/alice@' + aliceDomain + '/outbox'
+ actorFilename = aliceDir + '/accounts/' + 'alice@' + aliceDomain + '.json'
+ assert os.path.isfile(actorFilename)
+ assert len([name for name in os.listdir(outboxPath)
+ if os.path.isfile(os.path.join(outboxPath, name))]) == 0
+ pubKey = \
+ '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n' + \
+ 'mDMEWZBueBYJKwYBBAHaRw8BAQdAKx1t6wL0RTuU6/' + \
+ 'IBjngMbVJJ3Wg/3UW73/PV\n' + \
+ 'I47xKTS0IUJvYiBNb3R0cmFtIDxib2JAZnJlZWRvb' + \
+ 'WJvbmUubmV0PoiQBBMWCAA4\n' + \
+ 'FiEEmruCwAq/OfgmgEh9zCU2GR+nwz8FAlmQbngCG' + \
+ 'wMFCwkIBwMFFQoJCAsFFgID\n' + \
+ 'AQACHgECF4AACgkQzCU2GR+nwz/9sAD/YgsHnVszH' + \
+ 'Nz1zlVc5EgY1ByDupiJpHj0\n' + \
+ 'XsLYk3AbNRgBALn45RqgD4eWHpmOriH09H5Rc5V9i' + \
+ 'N4+OiGUn2AzJ6oHuDgEWZBu\n' + \
+ 'eBIKKwYBBAGXVQEFAQEHQPRBG2ZQJce475S3e0Dxe' + \
+ 'b0Fz5WdEu2q3GYLo4QG+4Ry\n' + \
+ 'AwEIB4h4BBgWCAAgFiEEmruCwAq/OfgmgEh9zCU2G' + \
+ 'R+nwz8FAlmQbngCGwwACgkQ\n' + \
+ 'zCU2GR+nwz+OswD+JOoyBku9FzuWoVoOevU2HH+bP' + \
+ 'OMDgY2OLnST9ZSyHkMBAMcK\n' + \
+ 'fnaZ2Wi050483Sj2RmQRpb99Dod7rVZTDtCqXk0J\n' + \
+ '=gv5G\n' + \
+ '-----END PGP PUBLIC KEY BLOCK-----'
+ actorUpdate = \
+ pgpPublicKeyUpload(aliceDir, sessionAlice,
+ 'alice', password,
+ aliceDomain, alicePort,
+ httpPrefix,
+ cachedWebfingers, personCache,
+ True, pubKey)
+ print('actor update result: ' + str(actorUpdate))
+ assert actorUpdate
+
+ # load alice actor
+ print('Loading actor: ' + actorFilename)
+ actorJson = loadJson(actorFilename)
+ assert actorJson
+ if len(actorJson['attachment']) == 0:
+ print("actorJson['attachment'] has no contents")
+ assert len(actorJson['attachment']) > 0
+ propertyFound = False
+ for propertyValue in actorJson['attachment']:
+ if propertyValue['name'] == 'PGP':
+ print('PGP property set within attachment')
+ assert pubKey in propertyValue['value']
+ propertyFound = True
+ assert propertyFound
+
+ # stop the server
+ thrAlice.kill()
+ thrAlice.join()
+ assert thrAlice.is_alive() is False
+
+ os.chdir(baseDir)
+ if os.path.isdir(baseDir + '/.tests'):
+ shutil.rmtree(baseDir + '/.tests')
+
+
+def _testRemovePostInteractions() -> None:
+ print('testRemovePostInteractions')
+ postJsonObject = {
+ "type": "Create",
+ "object": {
+ "to": ["#Public"],
+ "likes": {
+ "items": ["a", "b", "c"]
+ },
+ "replies": {
+ "replyStuff": ["a", "b", "c"]
+ },
+ "shares": {
+ "sharesStuff": ["a", "b", "c"]
+ },
+ "bookmarks": {
+ "bookmarksStuff": ["a", "b", "c"]
+ },
+ "ignores": {
+ "ignoresStuff": ["a", "b", "c"]
+ }
+ }
+ }
+ removePostInteractions(postJsonObject, True)
+ assert postJsonObject['object']['likes']['items'] == []
+ assert postJsonObject['object']['replies'] == {}
+ assert postJsonObject['object']['shares'] == {}
+ assert postJsonObject['object']['bookmarks'] == {}
+ assert postJsonObject['object']['ignores'] == {}
+ assert not removePostInteractions(postJsonObject, False)
+
+
+def _testSpoofGeolocation() -> None:
+ print('testSpoofGeolocation')
+ nogoLine = \
+ 'NEW YORK, USA: 73.951W,40.879, 73.974W,40.83, ' + \
+ '74.029W,40.756, 74.038W,40.713, 74.056W,40.713, ' + \
+ '74.127W,40.647, 74.038W,40.629, 73.995W,40.667, ' + \
+ '74.014W,40.676, 73.994W,40.702, 73.967W,40.699, ' + \
+ '73.958W,40.729, 73.956W,40.745, 73.918W,40.781, ' + \
+ '73.937W,40.793, 73.946W,40.782, 73.977W,40.738, ' + \
+ '73.98W,40.713, 74.012W,40.705, 74.006W,40.752, ' + \
+ '73.955W,40.824'
+ polygon = parseNogoString(nogoLine)
+ assert len(polygon) > 0
+ assert polygon[0][1] == -73.951
+ assert polygon[0][0] == 40.879
+ citiesList = [
+ 'NEW YORK, USA:40.7127281:W74.0060152:784',
+ 'LOS ANGELES, USA:34.0536909:W118.242766:1214',
+ 'SAN FRANCISCO, USA:37.74594738515095:W122.44299445520019:121',
+ 'HOUSTON, USA:29.6072:W95.1586:1553',
+ 'MANCHESTER, ENGLAND:53.4794892:W2.2451148:1276',
+ 'BERLIN, GERMANY:52.5170365:13.3888599:891',
+ 'ANKARA, TURKEY:39.93:32.85:24521',
+ 'LONDON, ENGLAND:51.5073219:W0.1276474:1738',
+ 'SEATTLE, USA:47.59840153253106:W122.31143714060059:217'
+ ]
+ testSquare = [
+ [[0.03, 0.01], [0.02, 10], [10.01, 10.02], [10.03, 0.02]]
+ ]
+ assert pointInNogo(testSquare, 5, 5)
+ assert pointInNogo(testSquare, 2, 3)
+ assert not pointInNogo(testSquare, 20, 5)
+ assert not pointInNogo(testSquare, 11, 6)
+ assert not pointInNogo(testSquare, 5, -5)
+ assert not pointInNogo(testSquare, 5, 11)
+ assert not pointInNogo(testSquare, -5, -5)
+ assert not pointInNogo(testSquare, -5, 5)
+ nogoList = []
+ currTime = datetime.datetime.utcnow()
+ decoySeed = 7634681
+ cityRadius = 0.1
+ coords = spoofGeolocation('', 'los angeles', currTime,
+ decoySeed, citiesList, nogoList)
+ assert coords[0] >= 34.0536909 - cityRadius
+ assert coords[0] <= 34.0536909 + cityRadius
+ assert coords[1] >= 118.242766 - cityRadius
+ assert coords[1] <= 118.242766 + cityRadius
+ assert coords[2] == 'N'
+ assert coords[3] == 'W'
+ assert len(coords[4]) > 4
+ assert len(coords[5]) > 4
+ assert coords[6] > 0
+ nogoList = []
+ coords = spoofGeolocation('', 'unknown', currTime,
+ decoySeed, citiesList, nogoList)
+ assert coords[0] >= 51.8744 - cityRadius
+ assert coords[0] <= 51.8744 + cityRadius
+ assert coords[1] >= 0.368333 - cityRadius
+ assert coords[1] <= 0.368333 + cityRadius
+ assert coords[2] == 'N'
+ assert coords[3] == 'W'
+ assert len(coords[4]) == 0
+ assert len(coords[5]) == 0
+ assert coords[6] == 0
+ kmlStr = '\n'
+ kmlStr += '\n'
+ kmlStr += '\n'
+ nogoLine2 = \
+ 'NEW YORK, USA: 74.115W,40.663, 74.065W,40.602, ' + \
+ '74.118W,40.555, 74.047W,40.516, 73.882W,40.547, ' + \
+ '73.909W,40.618, 73.978W,40.579, 74.009W,40.602, ' + \
+ '74.033W,40.61, 74.039W,40.623, 74.032W,40.641, ' + \
+ '73.996W,40.665'
+ polygon2 = parseNogoString(nogoLine2)
+ nogoList = [polygon, polygon2]
+ for i in range(1000):
+ dayNumber = randint(10, 30)
+ hour = randint(1, 23)
+ hourStr = str(hour)
+ if hour < 10:
+ hourStr = '0' + hourStr
+ dateTimeStr = "2021-05-" + str(dayNumber) + " " + hourStr + ":14"
+ currTime = datetime.datetime.strptime(dateTimeStr, "%Y-%m-%d %H:%M")
+ coords = spoofGeolocation('', 'new york, usa', currTime,
+ decoySeed, citiesList, nogoList)
+ longitude = coords[1]
+ if coords[3] == 'W':
+ longitude = -coords[1]
+ kmlStr += '\n'
+ kmlStr += ' ' + str(i) + '\n'
+ kmlStr += ' \n'
+ kmlStr += ' ' + str(longitude) + ',' + \
+ str(coords[0]) + ',0\n'
+ kmlStr += ' \n'
+ kmlStr += '\n'
+
+ nogoLine = \
+ 'LONDON, ENGLAND: 0.23888E,51.459, 0.1216E,51.5, ' + \
+ '0.016E,51.479, 0.097W,51.502, 0.126W,51.482, ' + \
+ '0.196W,51.457, 0.292W,51.465, 0.309W,51.49, ' + \
+ '0.226W,51.495, 0.198W,51.47, 0.174W,51.488, ' + \
+ '0.136W,51.489, 0.1189W,51.515, 0.038E,51.513, ' + \
+ '0.0692E,51.51, 0.12833E,51.526, 0.3289E,51.475'
+ polygon = parseNogoString(nogoLine)
+ nogoLine2 = \
+ 'LONDON, ENGLAND: 0.054W,51.535, 0.044W,51.53, ' + \
+ '0.008W,51.55, 0.0429W,51.57, 0.038W,51.6, ' + \
+ '0.0209W,51.603, 0.032W,51.613, 0.00191E,51.66, ' + \
+ '0.024W,51.666, 0.0313W,51.659, 0.0639W,51.579, ' + \
+ '0.059W,51.568, 0.0329W,51.552'
+ polygon2 = parseNogoString(nogoLine2)
+ nogoList = [polygon, polygon2]
+ for i in range(1000):
+ dayNumber = randint(10, 30)
+ hour = randint(1, 23)
+ hourStr = str(hour)
+ if hour < 10:
+ hourStr = '0' + hourStr
+ dateTimeStr = "2021-05-" + str(dayNumber) + " " + hourStr + ":14"
+ currTime = datetime.datetime.strptime(dateTimeStr, "%Y-%m-%d %H:%M")
+ coords = spoofGeolocation('', 'london, england', currTime,
+ decoySeed, citiesList, nogoList)
+ longitude = coords[1]
+ if coords[3] == 'W':
+ longitude = -coords[1]
+ kmlStr += '\n'
+ kmlStr += ' ' + str(i) + '\n'
+ kmlStr += ' \n'
+ kmlStr += ' ' + str(longitude) + ',' + \
+ str(coords[0]) + ',0\n'
+ kmlStr += ' \n'
+ kmlStr += '\n'
+
+ nogoLine = \
+ 'SAN FRANCISCO, USA: 121.988W,37.408, 121.924W,37.452, ' + \
+ '121.951W,37.498, 121.992W,37.505, 122.056W,37.54, ' + \
+ '122.077W,37.578, 122.098W,37.618, 122.131W,37.637, ' + \
+ '122.189W,37.706, 122.227W,37.775, 122.279W,37.798, ' + \
+ '122.315W,37.802, 122.291W,37.832, 122.309W,37.902, ' + \
+ '122.382W,37.915, 122.368W,37.927, 122.514W,37.882, ' + \
+ '122.473W,37.83, 122.481W,37.788, 122.394W,37.796, ' + \
+ '122.384W,37.729, 122.4W,37.688, 122.382W,37.654, ' + \
+ '122.406W,37.637, 122.392W,37.612, 122.356W,37.586, ' + \
+ '122.332W,37.586, 122.275W,37.529, 122.228W,37.488, ' + \
+ '122.181W,37.482, 122.134W,37.48, 122.128W,37.471, ' + \
+ '122.122W,37.448, 122.095W,37.428, 122.07W,37.413, ' + \
+ '122.036W,37.402, 122.035W,37.421'
+ polygon = parseNogoString(nogoLine)
+ nogoLine2 = \
+ 'SAN FRANCISCO, USA: 122.446W,37.794, 122.511W,37.778, ' + \
+ '122.51W,37.771, 122.454W,37.775, 122.452W,37.766, ' + \
+ '122.510W,37.763, 122.506W,37.735, 122.498W,37.733, ' + \
+ '122.496W,37.729, 122.491W,37.729, 122.475W,37.73, ' + \
+ '122.474W,37.72, 122.484W,37.72, 122.485W,37.703, ' + \
+ '122.495W,37.702, 122.493W,37.679, 122.486W,37.667, ' + \
+ '122.492W,37.664, 122.493W,37.629, 122.456W,37.625, ' + \
+ '122.450W,37.617, 122.455W,37.621, 122.41W,37.586, ' + \
+ '122.383W,37.561, 122.335W,37.509, 122.655W,37.48, ' + \
+ '122.67W,37.9, 122.272W,37.93, 122.294W,37.801, ' + \
+ '122.448W,37.804'
+ polygon2 = parseNogoString(nogoLine2)
+ nogoList = [polygon, polygon2]
+ for i in range(1000):
+ dayNumber = randint(10, 30)
+ hour = randint(1, 23)
+ hourStr = str(hour)
+ if hour < 10:
+ hourStr = '0' + hourStr
+ dateTimeStr = "2021-05-" + str(dayNumber) + " " + hourStr + ":14"
+ currTime = datetime.datetime.strptime(dateTimeStr, "%Y-%m-%d %H:%M")
+ coords = spoofGeolocation('', 'SAN FRANCISCO, USA', currTime,
+ decoySeed, citiesList, nogoList)
+ longitude = coords[1]
+ if coords[3] == 'W':
+ longitude = -coords[1]
+ kmlStr += '\n'
+ kmlStr += ' ' + str(i) + '\n'
+ kmlStr += ' \n'
+ kmlStr += ' ' + str(longitude) + ',' + \
+ str(coords[0]) + ',0\n'
+ kmlStr += ' \n'
+ kmlStr += '\n'
+
+ nogoLine = \
+ 'SEATTLE, USA: 122.247W,47.918, 122.39W,47.802, ' + \
+ '122.389W,47.769, 122.377W,47.758, 122.371W,47.726, ' + \
+ '122.379W,47.706, 122.4W,47.696, 122.405W,47.673, ' + \
+ '122.416W,47.65, 122.414W,47.642, 122.391W,47.632, ' + \
+ '122.373W,47.633, 122.336W,47.602, 122.288W,47.501, ' + \
+ '122.299W,47.503, 122.386W,47.592, 122.412W,47.574, ' + \
+ '122.394W,47.549, 122.388W,47.507, 122.35W,47.481, ' + \
+ '122.365W,47.459, 122.33W,47.406, 122.323W,47.392, ' + \
+ '122.321W,47.346, 122.441W,47.302, 122.696W,47.085, ' + \
+ '122.926W,47.066, 122.929W,48.383'
+ polygon = parseNogoString(nogoLine)
+ nogoLine2 = \
+ 'SEATTLE, USA: 122.267W,47.758, 122.29W,47.471, ' + \
+ '122.272W,47.693, 122.256W,47.672, 122.278W,47.652, ' + \
+ '122.29W,47.583, 122.262W,47.548, 122.265W,47.52, ' + \
+ '122.218W,47.498, 122.194W,47.501, 122.193W,47.55, ' + \
+ '122.173W,47.58, 122.22W,47.617, 122.238W,47.617, ' + \
+ '122.239W,47.637, 122.2W,47.644, 122.207W,47.703, ' + \
+ '122.22W,47.705, 122.231W,47.699, 122.255W,47.751'
+ polygon2 = parseNogoString(nogoLine2)
+ nogoLine3 = \
+ 'SEATTLE, USA: 122.347W,47.675, 122.344W,47.681, ' + \
+ '122.337W,47.685, 122.324W,47.679, 122.331W,47.677, ' + \
+ '122.34W,47.669, 122.34W,47.664, 122.348W,47.665'
+ polygon3 = parseNogoString(nogoLine3)
+ nogoLine4 = \
+ 'SEATTLE, USA: 122.423W,47.669, 122.345W,47.641, ' + \
+ '122.34W,47.625, 122.327W,47.626, 122.274W,47.64, ' + \
+ '122.268W,47.654, 122.327W,47.654, 122.336W,47.647, ' + \
+ '122.429W,47.684'
+ polygon4 = parseNogoString(nogoLine4)
+ nogoList = [polygon, polygon2, polygon3, polygon4]
+ for i in range(1000):
+ dayNumber = randint(10, 30)
+ hour = randint(1, 23)
+ hourStr = str(hour)
+ if hour < 10:
+ hourStr = '0' + hourStr
+ dateTimeStr = "2021-05-" + str(dayNumber) + " " + hourStr + ":14"
+ currTime = datetime.datetime.strptime(dateTimeStr, "%Y-%m-%d %H:%M")
+ coords = spoofGeolocation('', 'SEATTLE, USA', currTime,
+ decoySeed, citiesList, nogoList)
+ longitude = coords[1]
+ if coords[3] == 'W':
+ longitude = -coords[1]
+ kmlStr += '\n'
+ kmlStr += ' ' + str(i) + '\n'
+ kmlStr += ' \n'
+ kmlStr += ' ' + str(longitude) + ',' + \
+ str(coords[0]) + ',0\n'
+ kmlStr += ' \n'
+ kmlStr += '\n'
+
+ kmlStr += '\n'
+ kmlStr += ''
+ with open('unittest_decoy.kml', 'w+') as kmlFile:
+ kmlFile.write(kmlStr)
+
+
+def _testSkills() -> None:
+ print('testSkills')
+ actorJson = {
+ 'hasOccupation': [
+ {
+ '@type': 'Occupation',
+ 'name': "Sysop",
+ "occupationLocation": {
+ "@type": "City",
+ "name": "Fediverse"
+ },
+ 'skills': []
+ }
+ ]
+ }
+ skillsDict = {
+ 'bakery': 40,
+ 'gardening': 70
+ }
+ setSkillsFromDict(actorJson, skillsDict)
+ assert actorHasSkill(actorJson, 'bakery')
+ assert actorHasSkill(actorJson, 'gardening')
+ assert actorSkillValue(actorJson, 'bakery') == 40
+ assert actorSkillValue(actorJson, 'gardening') == 70
+
+
+def _testRoles() -> None:
+ print('testRoles')
+ actorJson = {
+ 'hasOccupation': [
+ {
+ '@type': 'Occupation',
+ 'name': "Sysop",
+ 'occupationLocation': {
+ '@type': 'City',
+ 'name': 'Fediverse'
+ },
+ 'skills': []
+ }
+ ]
+ }
+ testRolesList = ["admin", "moderator"]
+ setRolesFromList(actorJson, testRolesList)
+ assert actorHasRole(actorJson, "admin")
+ assert actorHasRole(actorJson, "moderator")
+ assert not actorHasRole(actorJson, "editor")
+ assert not actorHasRole(actorJson, "counselor")
+ assert not actorHasRole(actorJson, "artist")
+
+
+def _testUserAgentDomain() -> None:
+ print('testUserAgentDomain')
+ userAgent = \
+ 'http.rb/4.4.1 (Mastodon/9.10.11; +https://mastodon.something/)'
+ assert userAgentDomain(userAgent, False) == 'mastodon.something'
+ userAgent = \
+ 'Mozilla/70.0 (X11; Linux x86_64; rv:1.0) Gecko/20450101 Firefox/1.0'
+ assert userAgentDomain(userAgent, False) is None
+
+
+def _testSwitchWords() -> None:
+ print('testSwitchWords')
+ rules = [
+ "rock -> hamster",
+ "orange -> lemon"
+ ]
+ baseDir = os.getcwd()
+ nickname = 'testuser'
+ domain = 'testdomain.com'
+
+ content = 'This is a test'
+ result = switchWords(baseDir, nickname, domain, content, rules)
+ assert result == content
+
+ content = 'This is orange test'
+ result = switchWords(baseDir, nickname, domain, content, rules)
+ assert result == 'This is lemon test'
+
+ content = 'This is a test rock'
+ result = switchWords(baseDir, nickname, domain, content, rules)
+ assert result == 'This is a test hamster'
+
+
+def _testLimitWordLengths() -> None:
+ print('testLimitWordLengths')
+ maxWordLength = 13
+ text = "This is a test"
+ result = limitWordLengths(text, maxWordLength)
+ assert result == text
+
+ text = "This is an exceptionallylongword test"
+ result = limitWordLengths(text, maxWordLength)
+ assert result == "This is an exceptionally test"
+
+
+def _testLimitRepetedWords() -> None:
+ print('limitRepeatedWords')
+ text = \
+ "This is a preamble.\n\n" + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same\n\n" + \
+ "Some other text."
+ expected = \
+ "This is a preamble.\n\n" + \
+ "Same Same Same Same Same Same\n\n" + \
+ "Some other text."
+ result = limitRepeatedWords(text, 6)
+ assert result == expected
+
+ text = \
+ "This is other preamble.\n\n" + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same " + \
+ "Same Same Same Same Same Same Same Same Same Same"
+ expected = \
+ "This is other preamble.\n\n" + \
+ "Same Same Same Same Same Same"
+ result = limitRepeatedWords(text, 6)
+ assert result == expected
+
+
def runAllTests():
print('Running tests...')
- testFunctions()
- testValidHashTag()
- testPrepareHtmlPostNickname()
- testDomainHandling()
- testMastoApi()
- testLinksWithinPost()
- testReplyToPublicPost()
- testGetMentionedPeople()
- testGuessHashtagCategory()
- testValidNickname()
- testParseFeedDate()
- testFirstParagraphFromString()
- testGetNewswireTags()
- testHashtagRuleTree()
- testRemoveHtmlTag()
- testReplaceEmailQuote()
- testConstantTimeStringCheck()
- testTranslations()
- testValidContentWarning()
- testRemoveIdEnding()
- testJsonPostAllowsComments()
- runHtmlReplaceQuoteMarks()
- testDangerousCSS()
- testDangerousMarkup()
- testRemoveHtml()
- testSiteIsActive()
- testJsonld()
- testRemoveTextFormatting()
- testWebLinks()
- testRecentPostsCache()
- testTheme()
- testSaveLoadJson()
- testJsonString()
- testGetStatusNumber()
- testAddEmoji()
- testActorParsing()
- testHttpsig()
- testCache()
- testThreads()
- testCreatePerson()
- testAuthentication()
- testFollowersOfPerson()
- testNoOfFollowersOnDomain()
- testFollows()
- testGroupFollowers()
- testDelegateRoles()
+ updateDefaultThemesList(os.getcwd())
+ _testLimitRepetedWords()
+ _testLimitWordLengths()
+ _testSwitchWords()
+ _testFunctions()
+ _testUserAgentDomain()
+ _testRoles()
+ _testSkills()
+ _testSpoofGeolocation()
+ _testRemovePostInteractions()
+ _testExtractPGPPublicKey()
+ _testEmojiImages()
+ _testCamelCaseSplit()
+ _testSpeakerReplaceLinks()
+ _testExtractTextFieldsInPOST()
+ _testMarkdownToHtml()
+ _testValidHashTag()
+ _testPrepareHtmlPostNickname()
+ _testDomainHandling()
+ _testMastoApi()
+ _testLinksWithinPost()
+ _testReplyToPublicPost()
+ _testGetMentionedPeople()
+ _testGuessHashtagCategory()
+ _testValidNickname()
+ _testParseFeedDate()
+ _testFirstParagraphFromString()
+ _testGetNewswireTags()
+ _testHashtagRuleTree()
+ _testRemoveHtmlTag()
+ _testReplaceEmailQuote()
+ _testConstantTimeStringCheck()
+ _testTranslations()
+ _testValidContentWarning()
+ _testRemoveIdEnding()
+ _testJsonPostAllowsComments()
+ _runHtmlReplaceQuoteMarks()
+ _testDangerousCSS()
+ _testDangerousMarkup()
+ _testRemoveHtml()
+ _testSiteIsActive()
+ _testJsonld()
+ _testRemoveTextFormatting()
+ _testWebLinks()
+ _testRecentPostsCache()
+ _testTheme()
+ _testSaveLoadJson()
+ _testJsonString()
+ _testGetStatusNumber()
+ _testAddEmoji()
+ _testActorParsing()
+ _testHttpsig()
+ _testHttpSigNew()
+ _testCache()
+ _testThreads()
+ _testCreatePerson()
+ _testAuthentication()
+ _testFollowersOfPerson()
+ _testNoOfFollowersOnDomain()
+ _testFollows()
+ _testGroupFollowers()
print('Tests succeeded\n')
diff --git a/theme.py b/theme.py
index 9a27ec6e6..7f8d96086 100644
--- a/theme.py
+++ b/theme.py
@@ -5,21 +5,108 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
+__module_group__ = "Web Interface"
import os
+from utils import isAccountDir
from utils import loadJson
from utils import saveJson
from utils import getImageExtensions
+from utils import copytree
+from utils import acctDir
from shutil import copyfile
+from shutil import make_archive
+from shutil import unpack_archive
+from shutil import rmtree
from content import dangerousCSS
+def importTheme(baseDir: str, filename: str) -> bool:
+ """Imports a theme
+ """
+ if not os.path.isfile(filename):
+ return False
+ tempThemeDir = baseDir + '/imports/files'
+ if os.path.isdir(tempThemeDir):
+ rmtree(tempThemeDir)
+ os.mkdir(tempThemeDir)
+ unpack_archive(filename, tempThemeDir, 'zip')
+ essentialThemeFiles = ('name.txt', 'theme.json')
+ for themeFile in essentialThemeFiles:
+ if not os.path.isfile(tempThemeDir + '/' + themeFile):
+ print('WARN: ' + themeFile +
+ ' missing from imported theme')
+ return False
+ newThemeName = None
+ with open(tempThemeDir + '/name.txt', 'r') as fp:
+ newThemeName = fp.read().replace('\n', '').replace('\r', '')
+ if len(newThemeName) > 20:
+ print('WARN: Imported theme name is too long')
+ return False
+ if len(newThemeName) < 2:
+ print('WARN: Imported theme name is too short')
+ return False
+ newThemeName = newThemeName.lower()
+ forbiddenChars = (
+ ' ', ';', '/', '\\', '?', '!', '#', '@',
+ ':', '%', '&', '"', '+', '<', '>', '$'
+ )
+ for ch in forbiddenChars:
+ if ch in newThemeName:
+ print('WARN: theme name contains forbidden character')
+ return False
+ if not newThemeName:
+ return False
+
+ # if the theme name in the default themes list?
+ defaultThemesFilename = baseDir + '/defaultthemes.txt'
+ if os.path.isfile(defaultThemesFilename):
+ if newThemeName.title() + '\n' in open(defaultThemesFilename).read():
+ newThemeName = newThemeName + '2'
+
+ themeDir = baseDir + '/theme/' + newThemeName
+ if not os.path.isdir(themeDir):
+ os.mkdir(themeDir)
+ copytree(tempThemeDir, themeDir)
+ if os.path.isdir(tempThemeDir):
+ rmtree(tempThemeDir)
+ return os.path.isfile(themeDir + '/theme.json')
+
+
+def exportTheme(baseDir: str, theme: str) -> bool:
+ """Exports a theme as a zip file
+ """
+ themeDir = baseDir + '/theme/' + theme
+ if not os.path.isfile(themeDir + '/theme.json'):
+ return False
+ if not os.path.isdir(baseDir + '/exports'):
+ os.mkdir(baseDir + '/exports')
+ exportFilename = baseDir + '/exports/' + theme + '.zip'
+ if os.path.isfile(exportFilename):
+ os.remove(exportFilename)
+ try:
+ make_archive(baseDir + '/exports/' + theme, 'zip', themeDir)
+ except BaseException:
+ pass
+ return os.path.isfile(exportFilename)
+
+
def _getThemeFiles() -> []:
"""Gets the list of theme style sheets
"""
return ('epicyon.css', 'login.css', 'follow.css',
'suspended.css', 'calendar.css', 'blog.css',
- 'options.css', 'search.css', 'links.css')
+ 'options.css', 'search.css', 'links.css',
+ 'welcome.css')
+
+
+def isNewsThemeName(baseDir: str, themeName: str) -> bool:
+ """Returns true if the given theme is a news instance
+ """
+ themeDir = baseDir + '/theme/' + themeName
+ if os.path.isfile(themeDir + '/is_news_instance'):
+ return True
+ return False
def getThemesList(baseDir: str) -> []:
@@ -40,6 +127,30 @@ def getThemesList(baseDir: str) -> []:
return themes
+def _copyThemeHelpFiles(baseDir: str, themeName: str,
+ systemLanguage: str) -> None:
+ """Copies any theme specific help files from the welcome subdirectory
+ """
+ if not systemLanguage:
+ systemLanguage = 'en'
+ themeDir = baseDir + '/theme/' + themeName + '/welcome'
+ if not os.path.isdir(themeDir):
+ themeDir = baseDir + '/defaultwelcome'
+ for subdir, dirs, files in os.walk(themeDir):
+ for helpMarkdownFile in files:
+ if not helpMarkdownFile.endswith('_' + systemLanguage + '.md'):
+ continue
+ destHelpMarkdownFile = \
+ helpMarkdownFile.replace('_' + systemLanguage + '.md', '.md')
+ if destHelpMarkdownFile == 'profile.md' or \
+ destHelpMarkdownFile == 'final.md':
+ destHelpMarkdownFile = 'welcome_' + destHelpMarkdownFile
+ if os.path.isdir(baseDir + '/accounts'):
+ copyfile(themeDir + '/' + helpMarkdownFile,
+ baseDir + '/accounts/' + destHelpMarkdownFile)
+ break
+
+
def _setThemeInConfig(baseDir: str, name: str) -> bool:
"""Sets the theme with the given name within config.json
"""
@@ -256,14 +367,12 @@ def _setThemeFromDict(baseDir: str, name: str,
with open(filename, 'w+') as cssfile:
cssfile.write(css)
- if bgParams.get('login'):
- _setBackgroundFormat(baseDir, name, 'login', bgParams['login'])
- if bgParams.get('follow'):
- _setBackgroundFormat(baseDir, name, 'follow', bgParams['follow'])
- if bgParams.get('options'):
- _setBackgroundFormat(baseDir, name, 'options', bgParams['options'])
- if bgParams.get('search'):
- _setBackgroundFormat(baseDir, name, 'search', bgParams['search'])
+ screenName = (
+ 'login', 'follow', 'options', 'search', 'welcome'
+ )
+ for s in screenName:
+ if bgParams.get(s):
+ _setBackgroundFormat(baseDir, name, s, bgParams[s])
def _setBackgroundFormat(baseDir: str, name: str,
@@ -507,14 +616,13 @@ def _setThemeImages(baseDir: str, name: str) -> None:
_setTextModeTheme(baseDir, themeNameLower)
backgroundNames = ('login', 'shares', 'delete', 'follow',
- 'options', 'block', 'search', 'calendar')
+ 'options', 'block', 'search', 'calendar',
+ 'welcome')
extensions = getImageExtensions()
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)
@@ -614,13 +722,23 @@ def setNewsAvatar(baseDir: str, name: str,
os.remove(filename)
if os.path.isdir(baseDir + '/cache/avatars'):
copyfile(newFilename, filename)
- copyfile(newFilename,
- baseDir + '/accounts/' +
- nickname + '@' + domain + '/avatar.png')
+ accountDir = acctDir(baseDir, nickname, domain)
+ copyfile(newFilename, accountDir + '/avatar.png')
+
+
+def _setClearCacheFlag(baseDir: str) -> None:
+ """Sets a flag which can be used by an external system
+ (eg. a script in a cron job) to clear the browser cache
+ """
+ if not os.path.isdir(baseDir + '/accounts'):
+ return
+ flagFilename = baseDir + '/accounts/.clear_cache'
+ with open(flagFilename, 'w+') as flagFile:
+ flagFile.write('\n')
def setTheme(baseDir: str, name: str, domain: str,
- allowLocalNetworkAccess: bool) -> bool:
+ allowLocalNetworkAccess: bool, systemLanguage: str) -> bool:
"""Sets the theme with the given name as the current theme
"""
result = False
@@ -673,5 +791,17 @@ def setTheme(baseDir: str, name: str, domain: str,
else:
disableGrayscale(baseDir)
+ _copyThemeHelpFiles(baseDir, name, systemLanguage)
_setThemeInConfig(baseDir, name)
+ _setClearCacheFlag(baseDir)
return result
+
+
+def updateDefaultThemesList(baseDir: str) -> None:
+ """Recreates the list of default themes
+ """
+ themeNames = getThemesList(baseDir)
+ defaultThemesFilename = baseDir + '/defaultthemes.txt'
+ with open(defaultThemesFilename, 'w+') as defaultThemesFile:
+ for name in themeNames:
+ defaultThemesFile.write(name + '\n')
diff --git a/theme/blue/name.txt b/theme/blue/name.txt
new file mode 100644
index 000000000..24560d9b8
--- /dev/null
+++ b/theme/blue/name.txt
@@ -0,0 +1 @@
+blue
diff --git a/theme/blue/theme.json b/theme/blue/theme.json
index 2abaf6666..39bb7effd 100644
--- a/theme/blue/theme.json
+++ b/theme/blue/theme.json
@@ -18,6 +18,7 @@
"gallery-font-size-mobile": "55px",
"main-bg-color": "#002365",
"login-bg-color": "#002365",
+ "welcome-bg-color": "#002365",
"options-bg-color": "#002365",
"post-bg-color": "#002365",
"timeline-posts-background-color": "#002365",
diff --git a/theme/debian/helpimages/welcome.jpg b/theme/debian/helpimages/welcome.jpg
new file mode 100644
index 000000000..2dca20d97
Binary files /dev/null and b/theme/debian/helpimages/welcome.jpg differ
diff --git a/theme/debian/icons/calendar.png b/theme/debian/icons/calendar.png
index 6d5789c3a..609425aa8 100644
Binary files a/theme/debian/icons/calendar.png and b/theme/debian/icons/calendar.png differ
diff --git a/theme/debian/icons/calendar_notify.png b/theme/debian/icons/calendar_notify.png
index 635f715b0..477fd0d2b 100644
Binary files a/theme/debian/icons/calendar_notify.png and b/theme/debian/icons/calendar_notify.png differ
diff --git a/theme/debian/icons/newswire.png b/theme/debian/icons/newswire.png
index f3521130d..8837c95ac 100644
Binary files a/theme/debian/icons/newswire.png and b/theme/debian/icons/newswire.png differ
diff --git a/theme/debian/icons/scope_event.png b/theme/debian/icons/scope_event.png
index 6d5789c3a..2759541d5 100644
Binary files a/theme/debian/icons/scope_event.png and b/theme/debian/icons/scope_event.png differ
diff --git a/theme/debian/name.txt b/theme/debian/name.txt
new file mode 100644
index 000000000..2dee1753e
--- /dev/null
+++ b/theme/debian/name.txt
@@ -0,0 +1 @@
+debian
diff --git a/theme/debian/theme.json b/theme/debian/theme.json
index 225bf948c..50bfcd2ce 100644
--- a/theme/debian/theme.json
+++ b/theme/debian/theme.json
@@ -9,6 +9,7 @@
"button-selected-highlighted": "#2b5c6d",
"button-approve": "#2b5c6d",
"login-button-color": "#2b5c6d",
+ "welcome-button-color": "#2b5c6d",
"button-event-background-color": "#2b5c6d",
"post-separator-margin-top": "10px",
"post-separator-margin-bottom": "10px",
@@ -42,6 +43,7 @@
"column-left-color": "#e6ebf0",
"main-bg-color": "#e6ebf0",
"login-bg-color": "#010026",
+ "welcome-bg-color": "#010026",
"options-bg-color": "#010026",
"post-bg-color": "#e6ebf0",
"timeline-posts-background-color": "#e6ebf0",
@@ -54,6 +56,7 @@
"cw-color": "#2d2c37",
"main-fg-color": "#2d2c37",
"login-fg-color": "white",
+ "welcome-fg-color": "white",
"options-fg-color": "lightgrey",
"column-left-fg-color": "#2d2c37",
"border-color": "#c0cdd9",
@@ -79,6 +82,7 @@
"place-color": "black",
"event-color": "#282c37",
"today-foreground": "white",
+ "event-background-private": "grey",
"event-background": "lightgrey",
"event-foreground": "white",
"title-text": "white",
diff --git a/theme/default/helpimages/welcome.jpg b/theme/default/helpimages/welcome.jpg
new file mode 100644
index 000000000..77e97a3a6
Binary files /dev/null and b/theme/default/helpimages/welcome.jpg differ
diff --git a/theme/default/icons/calendar.png b/theme/default/icons/calendar.png
index 3afe1353a..0562fbbf0 100644
Binary files a/theme/default/icons/calendar.png and b/theme/default/icons/calendar.png differ
diff --git a/theme/default/icons/calendar_notify.png b/theme/default/icons/calendar_notify.png
index f118a83d9..24f1ccc73 100644
Binary files a/theme/default/icons/calendar_notify.png and b/theme/default/icons/calendar_notify.png differ
diff --git a/theme/default/icons/newswire.png b/theme/default/icons/newswire.png
index 61b0570eb..8837c95ac 100644
Binary files a/theme/default/icons/newswire.png and b/theme/default/icons/newswire.png differ
diff --git a/theme/default/icons/scope_event.png b/theme/default/icons/scope_event.png
index 3afe1353a..2759541d5 100644
Binary files a/theme/default/icons/scope_event.png and b/theme/default/icons/scope_event.png differ
diff --git a/theme/default/name.txt b/theme/default/name.txt
new file mode 100644
index 000000000..4ad96d515
--- /dev/null
+++ b/theme/default/name.txt
@@ -0,0 +1 @@
+default
diff --git a/theme/default/sounds/calendar.ogg b/theme/default/sounds/calendar.ogg
new file mode 100644
index 000000000..7822f9a1f
Binary files /dev/null and b/theme/default/sounds/calendar.ogg differ
diff --git a/theme/default/sounds/dm.ogg b/theme/default/sounds/dm.ogg
new file mode 100644
index 000000000..05ba472e2
Binary files /dev/null and b/theme/default/sounds/dm.ogg differ
diff --git a/theme/default/sounds/follow.ogg b/theme/default/sounds/follow.ogg
new file mode 100644
index 000000000..dd7bea6e1
Binary files /dev/null and b/theme/default/sounds/follow.ogg differ
diff --git a/theme/default/sounds/like.ogg b/theme/default/sounds/like.ogg
new file mode 100644
index 000000000..de41cf5e7
Binary files /dev/null and b/theme/default/sounds/like.ogg differ
diff --git a/theme/default/sounds/reply.ogg b/theme/default/sounds/reply.ogg
new file mode 100644
index 000000000..05ba472e2
Binary files /dev/null and b/theme/default/sounds/reply.ogg differ
diff --git a/theme/default/sounds/share.ogg b/theme/default/sounds/share.ogg
new file mode 100644
index 000000000..7822f9a1f
Binary files /dev/null and b/theme/default/sounds/share.ogg differ
diff --git a/theme/hacker/banner.png b/theme/hacker/banner.png
index 5d181aa52..7d29a1303 100644
Binary files a/theme/hacker/banner.png and b/theme/hacker/banner.png differ
diff --git a/theme/hacker/banner.txt b/theme/hacker/banner.txt
index db5cf9014..e26d52719 100644
--- a/theme/hacker/banner.txt
+++ b/theme/hacker/banner.txt
@@ -1,10 +1,6 @@
- 88888888888 88
- 88 ""
- 88
- 88aaaaa 8b,dPPYba, 88 ,adPPYba, 8b d8 ,adPPYba, 8b,dPPYba,
- 88""""" 88P' "8a 88 a8" "" `8b d8' a8" "8a 88P' `"8a
- 88 88 d8 88 8b `8b d8' 8b d8 88 88
- 88 88b, ,a8" 88 "8a, ,aa `8b,d8' "8a, ,a8" 88 88
- 88888888888 88`YbbdP"' 88 `"Ybbd8"' Y88' `"YbbdP"' 88 88
- 88 d8'
- 88 d8'
+ _____ __ _ __ _ _ E P I C Y O N
+ |_ _| /_/ | | /_/ _ __ ___ __ _ | |_ (_) __ _ _ _ ___
+ | | / _ \ | | / _ \ | '_ ` _ \ / _` | | __| | | / _` | | | | | / _ \
+ | | | __/ | | | __/ | | | | | | | (_| | | |_ | | | (_| | | |_| | | __/
+ |_| \___| |_| \___| |_| |_| |_| \__,_| \__| |_| \__, | \__,_| \___|
+ |_|
diff --git a/theme/hacker/helpimages/welcome.jpg b/theme/hacker/helpimages/welcome.jpg
new file mode 100644
index 000000000..939c7b591
Binary files /dev/null and b/theme/hacker/helpimages/welcome.jpg differ
diff --git a/theme/hacker/icons/add.png b/theme/hacker/icons/add.png
index c5b008fa1..3b1e726c0 100644
Binary files a/theme/hacker/icons/add.png and b/theme/hacker/icons/add.png differ
diff --git a/theme/hacker/icons/avatar_default.png b/theme/hacker/icons/avatar_default.png
index 66c11c47b..9c50078f5 100644
Binary files a/theme/hacker/icons/avatar_default.png and b/theme/hacker/icons/avatar_default.png differ
diff --git a/theme/hacker/icons/avatar_news.png b/theme/hacker/icons/avatar_news.png
index cc9b1a71c..0b5b4dd49 100644
Binary files a/theme/hacker/icons/avatar_news.png and b/theme/hacker/icons/avatar_news.png differ
diff --git a/theme/hacker/icons/bookmark_inactive.png b/theme/hacker/icons/bookmark_inactive.png
index f031d94d6..c96d9ca0f 100644
Binary files a/theme/hacker/icons/bookmark_inactive.png and b/theme/hacker/icons/bookmark_inactive.png differ
diff --git a/theme/hacker/icons/calendar.png b/theme/hacker/icons/calendar.png
index c70130d9f..c7c0bdd15 100644
Binary files a/theme/hacker/icons/calendar.png and b/theme/hacker/icons/calendar.png differ
diff --git a/theme/hacker/icons/calendar_notify.png b/theme/hacker/icons/calendar_notify.png
index b29692291..8b94b0e08 100644
Binary files a/theme/hacker/icons/calendar_notify.png and b/theme/hacker/icons/calendar_notify.png differ
diff --git a/theme/hacker/icons/categoriesrss.png b/theme/hacker/icons/categoriesrss.png
index a746c2337..8085b431d 100644
Binary files a/theme/hacker/icons/categoriesrss.png and b/theme/hacker/icons/categoriesrss.png differ
diff --git a/theme/hacker/icons/delete.png b/theme/hacker/icons/delete.png
index 11977e1be..5ac4a3fb7 100644
Binary files a/theme/hacker/icons/delete.png and b/theme/hacker/icons/delete.png differ
diff --git a/theme/hacker/icons/dm.png b/theme/hacker/icons/dm.png
index 646c65a23..5e1331675 100644
Binary files a/theme/hacker/icons/dm.png and b/theme/hacker/icons/dm.png differ
diff --git a/theme/hacker/icons/download.png b/theme/hacker/icons/download.png
index c31d75b29..aa9067110 100644
Binary files a/theme/hacker/icons/download.png and b/theme/hacker/icons/download.png differ
diff --git a/theme/hacker/icons/edit.png b/theme/hacker/icons/edit.png
index 02ee8c7b3..ff6ade968 100644
Binary files a/theme/hacker/icons/edit.png and b/theme/hacker/icons/edit.png differ
diff --git a/theme/hacker/icons/like_inactive.png b/theme/hacker/icons/like_inactive.png
index adc5b1855..6dcf4ee89 100644
Binary files a/theme/hacker/icons/like_inactive.png and b/theme/hacker/icons/like_inactive.png differ
diff --git a/theme/hacker/icons/links.png b/theme/hacker/icons/links.png
index 965033d5c..dc23017ba 100644
Binary files a/theme/hacker/icons/links.png and b/theme/hacker/icons/links.png differ
diff --git a/theme/hacker/icons/logorss.png b/theme/hacker/icons/logorss.png
index 4d9db882c..a8eff40dd 100644
Binary files a/theme/hacker/icons/logorss.png and b/theme/hacker/icons/logorss.png differ
diff --git a/theme/hacker/icons/logout.png b/theme/hacker/icons/logout.png
index 8ae5bee69..901334b00 100644
Binary files a/theme/hacker/icons/logout.png and b/theme/hacker/icons/logout.png differ
diff --git a/theme/hacker/icons/mute.png b/theme/hacker/icons/mute.png
index 6969e74d3..cce158ecf 100644
Binary files a/theme/hacker/icons/mute.png and b/theme/hacker/icons/mute.png differ
diff --git a/theme/hacker/icons/newpost.png b/theme/hacker/icons/newpost.png
index d5017855b..e69bbb9f0 100644
Binary files a/theme/hacker/icons/newpost.png and b/theme/hacker/icons/newpost.png differ
diff --git a/theme/hacker/icons/newswire.png b/theme/hacker/icons/newswire.png
index 1ca69d635..957b49f1b 100644
Binary files a/theme/hacker/icons/newswire.png and b/theme/hacker/icons/newswire.png differ
diff --git a/theme/hacker/icons/pagedown.png b/theme/hacker/icons/pagedown.png
index e1027f9a8..4eb89a48f 100644
Binary files a/theme/hacker/icons/pagedown.png and b/theme/hacker/icons/pagedown.png differ
diff --git a/theme/hacker/icons/pageup.png b/theme/hacker/icons/pageup.png
index 1b4ce3216..06140dfdc 100644
Binary files a/theme/hacker/icons/pageup.png and b/theme/hacker/icons/pageup.png differ
diff --git a/theme/hacker/icons/person.png b/theme/hacker/icons/person.png
index 47a0c1d45..09048353d 100644
Binary files a/theme/hacker/icons/person.png and b/theme/hacker/icons/person.png differ
diff --git a/theme/hacker/icons/prev.png b/theme/hacker/icons/prev.png
index 0c7a3ecbf..f9c50964c 100644
Binary files a/theme/hacker/icons/prev.png and b/theme/hacker/icons/prev.png differ
diff --git a/theme/hacker/icons/publish.png b/theme/hacker/icons/publish.png
index 6cd724fd8..194180c27 100644
Binary files a/theme/hacker/icons/publish.png and b/theme/hacker/icons/publish.png differ
diff --git a/theme/hacker/icons/repeat_inactive.png b/theme/hacker/icons/repeat_inactive.png
index a08227403..e9da71aa3 100644
Binary files a/theme/hacker/icons/repeat_inactive.png and b/theme/hacker/icons/repeat_inactive.png differ
diff --git a/theme/hacker/icons/reply.png b/theme/hacker/icons/reply.png
index c0b3fb8f2..1cc0ac457 100644
Binary files a/theme/hacker/icons/reply.png and b/theme/hacker/icons/reply.png differ
diff --git a/theme/hacker/icons/scope_blog.png b/theme/hacker/icons/scope_blog.png
index 6cd724fd8..cbeec5c3c 100644
Binary files a/theme/hacker/icons/scope_blog.png and b/theme/hacker/icons/scope_blog.png differ
diff --git a/theme/hacker/icons/scope_dm.png b/theme/hacker/icons/scope_dm.png
index ca11f79a9..160f7c2c8 100644
Binary files a/theme/hacker/icons/scope_dm.png and b/theme/hacker/icons/scope_dm.png differ
diff --git a/theme/hacker/icons/scope_event.png b/theme/hacker/icons/scope_event.png
index c70130d9f..c7c0bdd15 100644
Binary files a/theme/hacker/icons/scope_event.png and b/theme/hacker/icons/scope_event.png differ
diff --git a/theme/hacker/icons/scope_followers.png b/theme/hacker/icons/scope_followers.png
index dd19c92f5..4f0854aa1 100644
Binary files a/theme/hacker/icons/scope_followers.png and b/theme/hacker/icons/scope_followers.png differ
diff --git a/theme/hacker/icons/scope_public.png b/theme/hacker/icons/scope_public.png
index dc8d371b0..80f7f4af2 100644
Binary files a/theme/hacker/icons/scope_public.png and b/theme/hacker/icons/scope_public.png differ
diff --git a/theme/hacker/icons/scope_question.png b/theme/hacker/icons/scope_question.png
index 846d11e21..9010c548c 100644
Binary files a/theme/hacker/icons/scope_question.png and b/theme/hacker/icons/scope_question.png differ
diff --git a/theme/hacker/icons/scope_reminder.png b/theme/hacker/icons/scope_reminder.png
index ac56a4c4f..644071bd0 100644
Binary files a/theme/hacker/icons/scope_reminder.png and b/theme/hacker/icons/scope_reminder.png differ
diff --git a/theme/hacker/icons/scope_report.png b/theme/hacker/icons/scope_report.png
index a3e0952a5..4fd8bb059 100644
Binary files a/theme/hacker/icons/scope_report.png and b/theme/hacker/icons/scope_report.png differ
diff --git a/theme/hacker/icons/scope_share.png b/theme/hacker/icons/scope_share.png
index 250b2bc39..d44b708e4 100644
Binary files a/theme/hacker/icons/scope_share.png and b/theme/hacker/icons/scope_share.png differ
diff --git a/theme/hacker/icons/scope_unlisted.png b/theme/hacker/icons/scope_unlisted.png
index 31fd5402b..34d5407ed 100644
Binary files a/theme/hacker/icons/scope_unlisted.png and b/theme/hacker/icons/scope_unlisted.png differ
diff --git a/theme/hacker/icons/search.png b/theme/hacker/icons/search.png
index 7317eeb13..a9a7552f5 100644
Binary files a/theme/hacker/icons/search.png and b/theme/hacker/icons/search.png differ
diff --git a/theme/hacker/icons/showhide.png b/theme/hacker/icons/showhide.png
index 0c7a3ecbf..082225dae 100644
Binary files a/theme/hacker/icons/showhide.png and b/theme/hacker/icons/showhide.png differ
diff --git a/theme/hacker/image.png b/theme/hacker/image.png
index 2976fa338..469c13d19 100644
Binary files a/theme/hacker/image.png and b/theme/hacker/image.png differ
diff --git a/theme/hacker/name.txt b/theme/hacker/name.txt
new file mode 100644
index 000000000..52b4d7301
--- /dev/null
+++ b/theme/hacker/name.txt
@@ -0,0 +1 @@
+hacker
diff --git a/theme/hacker/search_banner.png b/theme/hacker/search_banner.png
index 5d181aa52..23bd0dce2 100644
Binary files a/theme/hacker/search_banner.png and b/theme/hacker/search_banner.png differ
diff --git a/theme/hacker/theme.json b/theme/hacker/theme.json
index a33b48b7b..14f6161c0 100644
--- a/theme/hacker/theme.json
+++ b/theme/hacker/theme.json
@@ -1,4 +1,32 @@
{
+ "font-size-header": "12px",
+ "font-size-header-mobile": "20px",
+ "font-size-button-mobile": "20px",
+ "font-size-links": "16px",
+ "font-size-publish-button": "12px",
+ "font-size-newswire": "16px",
+ "font-size-newswire-mobile": "36px",
+ "font-size-dropdown-header": "26px",
+ "font-size-mobile": "20px",
+ "font-size": "26px",
+ "font-size2": "16px",
+ "font-size3": "36px",
+ "font-size4": "16px",
+ "font-size5": "16px",
+ "font-size-likes": "12px",
+ "font-size-likes-mobile": "26px",
+ "font-size-pgp-key": "10px",
+ "font-size-pgp-key2": "10px",
+ "font-size-tox": "10px",
+ "font-size-tox2": "10px",
+ "gallery-font-size": "12px",
+ "gallery-font-size-mobile": "36px",
+ "quote-font-size": "26px",
+ "quote-font-size-mobile": "36px",
+ "dropdown-fg-color": "#dddddd",
+ "dropdown-bg-color": "#111",
+ "dropdown-bg-color-hover": "#035103",
+ "dropdown-fg-color-hover": "#dddddd",
"newswire-publish-icon": "True",
"full-width-timeline-buttons": "False",
"icons-as-buttons": "False",
@@ -7,6 +35,7 @@
"focus-color": "green",
"main-bg-color": "black",
"login-bg-color": "black",
+ "welcome-bg-color": "black",
"options-bg-color": "black",
"post-bg-color": "black",
"timeline-posts-background-color": "black",
@@ -17,13 +46,14 @@
"main-bg-color-reply": "#030202",
"main-bg-color-report": "#050202",
"main-header-color-roles": "#1f192d",
- "cw-color": "#00ff00",
- "main-fg-color": "#00ff00",
- "login-fg-color": "#00ff00",
- "options-fg-color": "#00ff00",
- "column-left-fg-color": "#00ff00",
+ "cw-color": "#9ad791",
+ "main-fg-color": "#9ad791",
+ "login-fg-color": "#9ad791",
+ "welcome-fg-color": "#9ad791",
+ "options-fg-color": "#9ad791",
+ "column-left-fg-color": "#9ad791",
"border-color": "#035103",
- "main-link-color": "#2fff2f",
+ "main-link-color": "#9ad791",
"main-link-color-hover": "#afff2f",
"options-main-link-color": "#2fff2f",
"options-main-link-color-hover": "#afff2f",
@@ -32,19 +62,19 @@
"options-main-visited-color": "#3c8234",
"button-selected": "#063200",
"button-background-hover": "#a62200",
- "button-text-hover": "#00ff00",
+ "button-text-hover": "#9ad791",
"publish-button-background": "#062200",
"button-background": "#062200",
"button-small-background": "#062200",
- "button-text": "#00ff00",
- "button-selected-text": "#00ff00",
- "publish-button-text": "#00ff00",
- "button-small-text": "#00ff00",
+ "button-text": "#9ad791",
+ "button-selected-text": "#9ad791",
+ "publish-button-text": "#9ad791",
+ "button-small-text": "#9ad791",
"button-corner-radius": "4px",
"timeline-border-radius": "4px",
"header-font": "'Bedstead'",
"*font-family": "'Bedstead'",
- "*src": "url('./fonts/bedstead.otf') format('opentype')",
+ "*src": "url('./fonts/MarginaliaRegular.woff2') format('woff2')",
"color: #FFFFFE;": "color: green;",
"calendar-bg-color": "black",
"lines-color": "green",
@@ -53,12 +83,13 @@
"today-foreground": "white",
"today-circle": "red",
"event-background": "lightgreen",
+ "event-background-private": "darkgreen",
"event-foreground": "black",
"title-text": "black",
"title-background": "darkgreen",
"gallery-text-color": "green",
- "time-color": "#00ff00",
- "place-color": "#00ff00",
- "event-color": "#00ff00",
+ "time-color": "#9ad791",
+ "place-color": "#9ad791",
+ "event-color": "#9ad791",
"image-corners": "0%"
}
diff --git a/theme/henge/helpimages/welcome.jpg b/theme/henge/helpimages/welcome.jpg
new file mode 100644
index 000000000..6a417ba6b
Binary files /dev/null and b/theme/henge/helpimages/welcome.jpg differ
diff --git a/theme/henge/icons/calendar.png b/theme/henge/icons/calendar.png
index eb21b795f..653cf84f4 100644
Binary files a/theme/henge/icons/calendar.png and b/theme/henge/icons/calendar.png differ
diff --git a/theme/henge/icons/calendar_notify.png b/theme/henge/icons/calendar_notify.png
index a8bb393b4..b056a8a85 100644
Binary files a/theme/henge/icons/calendar_notify.png and b/theme/henge/icons/calendar_notify.png differ
diff --git a/theme/henge/icons/scope_event.png b/theme/henge/icons/scope_event.png
index eb21b795f..d1b40beba 100644
Binary files a/theme/henge/icons/scope_event.png and b/theme/henge/icons/scope_event.png differ
diff --git a/theme/henge/name.txt b/theme/henge/name.txt
new file mode 100644
index 000000000..ae57110cf
--- /dev/null
+++ b/theme/henge/name.txt
@@ -0,0 +1 @@
+henge
diff --git a/theme/henge/theme.json b/theme/henge/theme.json
index 0048d7b0d..ae2617326 100644
--- a/theme/henge/theme.json
+++ b/theme/henge/theme.json
@@ -4,7 +4,9 @@
"time-color": "grey",
"event-color": "white",
"login-bg-color": "#567726",
+ "welcome-bg-color": "#ccc",
"login-fg-color": "black",
+ "welcome-fg-color": "black",
"options-bg-color": "black",
"newswire-publish-icon": "True",
"full-width-timeline-buttons": "False",
@@ -62,6 +64,7 @@
"day-number": "#c5d2b9",
"day-number2": "#ccc",
"event-background": "#555",
+ "event-background-private": "#999",
"timeline-border-radius": "20px",
"image-corners": "8%",
"quote-right-margin": "0.1em",
diff --git a/theme/indymediaclassic/helpimages/journalist.jpg b/theme/indymediaclassic/helpimages/journalist.jpg
new file mode 100644
index 000000000..a9bdd19b8
Binary files /dev/null and b/theme/indymediaclassic/helpimages/journalist.jpg differ
diff --git a/theme/indymediaclassic/helpimages/welcome.jpg b/theme/indymediaclassic/helpimages/welcome.jpg
new file mode 100644
index 000000000..3d70dc731
Binary files /dev/null and b/theme/indymediaclassic/helpimages/welcome.jpg differ
diff --git a/theme/indymediaclassic/is_news_instance b/theme/indymediaclassic/is_news_instance
new file mode 100644
index 000000000..0519ecba6
--- /dev/null
+++ b/theme/indymediaclassic/is_news_instance
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/theme/indymediaclassic/name.txt b/theme/indymediaclassic/name.txt
new file mode 100644
index 000000000..eacded40b
--- /dev/null
+++ b/theme/indymediaclassic/name.txt
@@ -0,0 +1 @@
+indymediaclassic
diff --git a/theme/indymediaclassic/theme.json b/theme/indymediaclassic/theme.json
index 80d9c6eff..60b20a44c 100644
--- a/theme/indymediaclassic/theme.json
+++ b/theme/indymediaclassic/theme.json
@@ -28,6 +28,7 @@
"font-size5": "22px",
"main-bg-color": "black",
"login-bg-color": "black",
+ "welcome-bg-color": "black",
"options-bg-color": "black",
"post-bg-color": "black",
"timeline-posts-background-color": "black",
@@ -47,6 +48,7 @@
"cw-color": "white",
"main-fg-color": "white",
"login-fg-color": "white",
+ "welcome-fg-color": "white",
"options-fg-color": "white",
"column-left-fg-color": "white",
"main-bg-color-dm": "#0b0a0a",
@@ -65,6 +67,7 @@
"button-selected": "blue",
"calendar-bg-color": "#0f0d10",
"event-background": "#555",
+ "event-background-private": "#999",
"border-color": "#003366",
"lines-color": "#ff9900",
"day-number": "lightblue",
@@ -80,5 +83,7 @@
"column-right-width": "20vw",
"column-right-icon-size": "11%",
"login-button-color": "red",
- "login-button-fg-color": "white"
+ "welcome-button-color": "red",
+ "login-button-fg-color": "white",
+ "welcome-button-fg-color": "white"
}
diff --git a/theme/indymediaclassic/welcome/final_en.md b/theme/indymediaclassic/welcome/final_en.md
new file mode 100644
index 000000000..2fd063cff
--- /dev/null
+++ b/theme/indymediaclassic/welcome/final_en.md
@@ -0,0 +1,10 @@
+
+### You are now a journalist!
+Welcome onboard the team. This is a moderated news instance, so please ensure that anything you write is in accordance with our [editorial policy](/terms).
+
+#### 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 articles arrive, so use **F5** or the **Features** button to refresh.
diff --git a/theme/indymediaclassic/welcome/help_tlblogs_en.md b/theme/indymediaclassic/welcome/help_tlblogs_en.md
new file mode 100644
index 000000000..b5752f41d
--- /dev/null
+++ b/theme/indymediaclassic/welcome/help_tlblogs_en.md
@@ -0,0 +1,3 @@
+This timeline contains any articles published by you or anyone that you're following.
+
+You can create a new article using the **publish** icon at the top of the newswire column, or on mobile via the newswire icon.
diff --git a/theme/indymediaclassic/welcome/profile_en.md b/theme/indymediaclassic/welcome/profile_en.md
new file mode 100644
index 000000000..21cf17613
--- /dev/null
+++ b/theme/indymediaclassic/welcome/profile_en.md
@@ -0,0 +1,2 @@
+### Journalist Setup
+Select your avatar image and add your name and description. Use a small avatar image (eg. 128x128 pixels) so that it's quick to download.
diff --git a/theme/indymediaclassic/welcome/welcome_en.md b/theme/indymediaclassic/welcome/welcome_en.md
new file mode 100644
index 000000000..cc812899e
--- /dev/null
+++ b/theme/indymediaclassic/welcome/welcome_en.md
@@ -0,0 +1,7 @@
+
+### Welcome to INSTANCE
+This is an ActivityPub server designed for publishing in the Indymedia network. It can run on low power single board computers or old laptops.
+
+Don't complain about the media. *Be the media*.
+
+Now, lets get going...
diff --git a/theme/indymediamodern/helpimages/journalist.jpg b/theme/indymediamodern/helpimages/journalist.jpg
new file mode 100644
index 000000000..a9bdd19b8
Binary files /dev/null and b/theme/indymediamodern/helpimages/journalist.jpg differ
diff --git a/theme/indymediamodern/helpimages/welcome.jpg b/theme/indymediamodern/helpimages/welcome.jpg
new file mode 100644
index 000000000..145d424a5
Binary files /dev/null and b/theme/indymediamodern/helpimages/welcome.jpg differ
diff --git a/theme/indymediamodern/is_news_instance b/theme/indymediamodern/is_news_instance
new file mode 100644
index 000000000..0519ecba6
--- /dev/null
+++ b/theme/indymediamodern/is_news_instance
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/theme/indymediamodern/name.txt b/theme/indymediamodern/name.txt
new file mode 100644
index 000000000..aaa9e5577
--- /dev/null
+++ b/theme/indymediamodern/name.txt
@@ -0,0 +1 @@
+indymediamodern
diff --git a/theme/indymediamodern/theme.json b/theme/indymediamodern/theme.json
index 8f9d41143..d35527ca7 100644
--- a/theme/indymediamodern/theme.json
+++ b/theme/indymediamodern/theme.json
@@ -64,7 +64,9 @@
"tab-border-color": "transparent",
"button-corner-radius": "0px",
"login-button-color": "#25408f",
+ "welcome-button-color": "#25408f",
"login-button-fg-color": "white",
+ "welcome-button-fg-color": "white",
"column-left-width": "10vw",
"column-center-width": "75vw",
"column-right-width": "15vw",
@@ -91,6 +93,7 @@
"column-left-color": "#efefef",
"main-bg-color": "#efefef",
"login-bg-color": "#efefef",
+ "welcome-bg-color": "#efefef",
"options-bg-color": "#efefef",
"post-bg-color": "white",
"timeline-posts-background-color": "white",
@@ -102,6 +105,7 @@
"cw-color": "black",
"main-fg-color": "black",
"login-fg-color": "black",
+ "welcome-fg-color": "black",
"options-fg-color": "black",
"column-left-fg-color": "#25408f",
"border-color": "#c0cdd9",
@@ -129,6 +133,7 @@
"today-foreground": "white",
"today-circle": "red",
"event-background": "lightblue",
+ "event-background-private": "#ccc",
"event-foreground": "white",
"title-text": "#282c37",
"title-background": "#ccc",
diff --git a/theme/indymediamodern/welcome/final_en.md b/theme/indymediamodern/welcome/final_en.md
new file mode 100644
index 000000000..2fd063cff
--- /dev/null
+++ b/theme/indymediamodern/welcome/final_en.md
@@ -0,0 +1,10 @@
+
+### You are now a journalist!
+Welcome onboard the team. This is a moderated news instance, so please ensure that anything you write is in accordance with our [editorial policy](/terms).
+
+#### 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 articles arrive, so use **F5** or the **Features** button to refresh.
diff --git a/theme/indymediamodern/welcome/help_tlblogs_en.md b/theme/indymediamodern/welcome/help_tlblogs_en.md
new file mode 100644
index 000000000..b5752f41d
--- /dev/null
+++ b/theme/indymediamodern/welcome/help_tlblogs_en.md
@@ -0,0 +1,3 @@
+This timeline contains any articles published by you or anyone that you're following.
+
+You can create a new article using the **publish** icon at the top of the newswire column, or on mobile via the newswire icon.
diff --git a/theme/indymediamodern/welcome/profile_en.md b/theme/indymediamodern/welcome/profile_en.md
new file mode 100644
index 000000000..21cf17613
--- /dev/null
+++ b/theme/indymediamodern/welcome/profile_en.md
@@ -0,0 +1,2 @@
+### Journalist Setup
+Select your avatar image and add your name and description. Use a small avatar image (eg. 128x128 pixels) so that it's quick to download.
diff --git a/theme/indymediamodern/welcome/welcome_en.md b/theme/indymediamodern/welcome/welcome_en.md
new file mode 100644
index 000000000..cc812899e
--- /dev/null
+++ b/theme/indymediamodern/welcome/welcome_en.md
@@ -0,0 +1,7 @@
+
+### Welcome to INSTANCE
+This is an ActivityPub server designed for publishing in the Indymedia network. It can run on low power single board computers or old laptops.
+
+Don't complain about the media. *Be the media*.
+
+Now, lets get going...
diff --git a/theme/lcd/name.txt b/theme/lcd/name.txt
new file mode 100644
index 000000000..b57d509d9
--- /dev/null
+++ b/theme/lcd/name.txt
@@ -0,0 +1 @@
+lcd
diff --git a/theme/lcd/theme.json b/theme/lcd/theme.json
index 8f80f567e..f65ecba80 100644
--- a/theme/lcd/theme.json
+++ b/theme/lcd/theme.json
@@ -9,6 +9,7 @@
"column-left-header-color": "#33390d",
"main-bg-color": "#9fb42b",
"login-bg-color": "#9fb42b",
+ "welcome-bg-color": "#9fb42b",
"options-bg-color": "#9fb42b",
"post-bg-color": "#9fb42b",
"timeline-posts-background-color": "#9fb42b",
@@ -25,6 +26,7 @@
"cw-color": "#33390d",
"main-fg-color": "#33390d",
"login-fg-color": "#33390d",
+ "welcome-fg-color": "#33390d",
"options-fg-color": "#33390d",
"border-color": "#33390d",
"border-width": "5px",
@@ -54,6 +56,7 @@
"today-foreground": "white",
"today-circle": "red",
"event-background": "yellow",
+ "event-background-private": "#ccc",
"event-foreground": "white",
"title-text": "white",
"gallery-text-color": "#33390d",
diff --git a/theme/light/helpimages/welcome.jpg b/theme/light/helpimages/welcome.jpg
new file mode 100644
index 000000000..327cf7adf
Binary files /dev/null and b/theme/light/helpimages/welcome.jpg differ
diff --git a/theme/light/icons/calendar.png b/theme/light/icons/calendar.png
index 3d4eadcc2..8dfb24295 100644
Binary files a/theme/light/icons/calendar.png and b/theme/light/icons/calendar.png differ
diff --git a/theme/light/icons/calendar_notify.png b/theme/light/icons/calendar_notify.png
index 8887b3caa..16bf5e79c 100644
Binary files a/theme/light/icons/calendar_notify.png and b/theme/light/icons/calendar_notify.png differ
diff --git a/theme/light/icons/newswire.png b/theme/light/icons/newswire.png
index 99a3ad1a3..07210f589 100644
Binary files a/theme/light/icons/newswire.png and b/theme/light/icons/newswire.png differ
diff --git a/theme/light/name.txt b/theme/light/name.txt
new file mode 100644
index 000000000..162faa69f
--- /dev/null
+++ b/theme/light/name.txt
@@ -0,0 +1 @@
+light
diff --git a/theme/light/theme.json b/theme/light/theme.json
index 586a04bcd..3e4e10c1e 100644
--- a/theme/light/theme.json
+++ b/theme/light/theme.json
@@ -1,4 +1,6 @@
{
+ "avatar-rounding": "50%",
+ "icon-brightness-change": "80%",
"button-selected": "#999",
"button-background": "#bbbbbb",
"button-background-hover": "#999",
@@ -27,23 +29,25 @@
"column-left-color": "#e6ebf0",
"main-bg-color": "#e6ebf0",
"login-bg-color": "#e6ebf0",
+ "welcome-bg-color": "#e6ebf0",
"options-bg-color": "#e6ebf0",
"post-bg-color": "#e6ebf0",
"timeline-posts-background-color": "#e6ebf0",
"header-bg-color": "#e6ebf0",
- "main-bg-color-dm": "#e3dbf0",
- "link-bg-color": "#e6ebf0",
- "main-bg-color-reply": "white",
- "main-bg-color-report": "#e3dbf0",
+ "main-bg-color-dm": "#dbe2ea",
+ "link-bg-color": "#eef4fa",
+ "main-bg-color-reply": "#eaeced",
+ "main-bg-color-report": "#dbe2ea",
"main-header-color-roles": "#ebebf0",
"cw-color": "#777",
"main-fg-color": "#2d2c37",
"login-fg-color": "#2d2c37",
+ "welcome-fg-color": "#2d2c37",
"options-fg-color": "#2d2c37",
"column-left-fg-color": "#2d2c37",
"border-color": "#c0cdd9",
"main-link-color": "#2a2c37",
- "main-link-color-hover": "#aa2c37",
+ "main-link-color-hover": "#777",
"options-main-link-color": "#2a2c37",
"options-main-link-color-hover": "#aa2c37",
"title-color": "#2a2c37",
@@ -66,6 +70,7 @@
"today-foreground": "white",
"today-circle": "red",
"event-background": "lightblue",
+ "event-background-private": "#ccc",
"event-foreground": "white",
"title-text": "#282c37",
"title-background": "#ccc",
diff --git a/theme/night/helpimages/welcome.jpg b/theme/night/helpimages/welcome.jpg
new file mode 100644
index 000000000..e11b8089c
Binary files /dev/null and b/theme/night/helpimages/welcome.jpg differ
diff --git a/theme/night/name.txt b/theme/night/name.txt
new file mode 100644
index 000000000..41feb5b6d
--- /dev/null
+++ b/theme/night/name.txt
@@ -0,0 +1 @@
+night
diff --git a/theme/night/theme.json b/theme/night/theme.json
index f29c4cd12..4335d43ba 100644
--- a/theme/night/theme.json
+++ b/theme/night/theme.json
@@ -1,4 +1,5 @@
{
+ "avatar-rounding": "50%",
"newswire-publish-icon": "True",
"full-width-timeline-buttons": "False",
"icons-as-buttons": "False",
@@ -22,6 +23,7 @@
"font-size5": "22px",
"main-bg-color": "#0f0d10",
"login-bg-color": "#0f0d10",
+ "welcome-bg-color": "#0f0d10",
"options-bg-color": "#0f0d10",
"post-bg-color": "#0f0d10",
"timeline-posts-background-color": "#0f0d10",
@@ -36,6 +38,7 @@
"cw-color": "#0481f5",
"main-fg-color": "#0481f5",
"login-fg-color": "#0481f5",
+ "welcome-fg-color": "#0481f5",
"options-fg-color": "#0481f5",
"column-left-fg-color": "#0481f5",
"main-bg-color-dm": "#0b0a0a",
@@ -59,6 +62,7 @@
"place-color": "#0481f5",
"event-color": "#0481f5",
"event-background": "#00014a",
+ "event-background-private": "darkpurple",
"quote-right-margin": "0",
"line-spacing": "180%",
"header-font": "'solidaric'",
diff --git a/theme/pixel/helpimages/welcome.jpg b/theme/pixel/helpimages/welcome.jpg
new file mode 100644
index 000000000..0a5c37cfc
Binary files /dev/null and b/theme/pixel/helpimages/welcome.jpg differ
diff --git a/theme/pixel/name.txt b/theme/pixel/name.txt
new file mode 100644
index 000000000..ae0edb173
--- /dev/null
+++ b/theme/pixel/name.txt
@@ -0,0 +1 @@
+pixel
diff --git a/theme/pixel/theme.json b/theme/pixel/theme.json
index d51e87316..0aec8c372 100644
--- a/theme/pixel/theme.json
+++ b/theme/pixel/theme.json
@@ -34,6 +34,7 @@
"button-approve": "#12435f",
"border-color": "#7152a3",
"login-fg-color": "black",
+ "welcome-fg-color": "black",
"cw-color": "black",
"main-link-color": "#333",
"options-main-link-color": "#333",
@@ -44,6 +45,7 @@
"dropdown-bg-color": "#8ba0d4",
"dropdown-bg-color-hover": "#7ba0d4",
"login-bg-color": "#9ba0d4",
+ "welcome-bg-color": "#9ba0d4",
"text-entry-background": "#8ba0d4",
"timeline-posts-background-color": "#9ba0d4",
"header-bg-color": "#9ba0d4",
diff --git a/theme/purple/helpimages/welcome.jpg b/theme/purple/helpimages/welcome.jpg
new file mode 100644
index 000000000..328cfa37e
Binary files /dev/null and b/theme/purple/helpimages/welcome.jpg differ
diff --git a/theme/purple/icons/calendar.png b/theme/purple/icons/calendar.png
index 3b3009d4f..5e73cb386 100644
Binary files a/theme/purple/icons/calendar.png and b/theme/purple/icons/calendar.png differ
diff --git a/theme/purple/icons/calendar_notify.png b/theme/purple/icons/calendar_notify.png
index cae61fe67..60d2f30ab 100644
Binary files a/theme/purple/icons/calendar_notify.png and b/theme/purple/icons/calendar_notify.png differ
diff --git a/theme/purple/icons/newswire.png b/theme/purple/icons/newswire.png
index 542a4f4c8..b71236642 100644
Binary files a/theme/purple/icons/newswire.png and b/theme/purple/icons/newswire.png differ
diff --git a/theme/purple/icons/scope_event.png b/theme/purple/icons/scope_event.png
index 3b3009d4f..77903ff15 100644
Binary files a/theme/purple/icons/scope_event.png and b/theme/purple/icons/scope_event.png differ
diff --git a/theme/purple/name.txt b/theme/purple/name.txt
new file mode 100644
index 000000000..08ec89e7f
--- /dev/null
+++ b/theme/purple/name.txt
@@ -0,0 +1 @@
+purple
diff --git a/theme/purple/theme.json b/theme/purple/theme.json
index 3a1e8582d..e1f14ff94 100644
--- a/theme/purple/theme.json
+++ b/theme/purple/theme.json
@@ -21,6 +21,7 @@
"font-size5": "22px",
"main-bg-color": "#1f152d",
"login-bg-color": "#1f152d",
+ "welcome-bg-color": "#1f152d",
"options-bg-color": "#1f152d",
"post-bg-color": "#1f152d",
"timeline-posts-background-color": "#1f152d",
@@ -33,6 +34,7 @@
"cw-color": "#f98bb0",
"main-fg-color": "#f98bb0",
"login-fg-color": "#f98bb0",
+ "welcome-fg-color": "#f98bb0",
"options-fg-color": "#f98bb0",
"column-left-fg-color": "#f98bb0",
"border-color": "#3f2145",
@@ -61,6 +63,7 @@
"today-foreground": "white",
"today-circle": "red",
"event-background": "#444",
+ "event-background-private": "#888",
"event-foreground": "white",
"title-text": "white",
"title-background": "#ff42a0",
diff --git a/theme/rc3/icons/calendar.png b/theme/rc3/icons/calendar.png
index 9513e2aaa..d39f7c09e 100644
Binary files a/theme/rc3/icons/calendar.png and b/theme/rc3/icons/calendar.png differ
diff --git a/theme/rc3/icons/calendar_notify.png b/theme/rc3/icons/calendar_notify.png
index 0bfafad34..01a74849d 100644
Binary files a/theme/rc3/icons/calendar_notify.png and b/theme/rc3/icons/calendar_notify.png differ
diff --git a/theme/rc3/icons/scope_event.png b/theme/rc3/icons/scope_event.png
index 0b22d9560..c3b5f7e03 100644
Binary files a/theme/rc3/icons/scope_event.png and b/theme/rc3/icons/scope_event.png differ
diff --git a/theme/rc3/name.txt b/theme/rc3/name.txt
new file mode 100644
index 000000000..c215c765c
--- /dev/null
+++ b/theme/rc3/name.txt
@@ -0,0 +1 @@
+rc3
diff --git a/theme/rc3/theme.json b/theme/rc3/theme.json
index 95b54d546..14083636b 100644
--- a/theme/rc3/theme.json
+++ b/theme/rc3/theme.json
@@ -12,7 +12,9 @@
"button-selected-highlighted": "#0481f5",
"button-fg-highlighted": "white",
"login-button-color": "#6800e7",
+ "welcome-button-color": "#6800e7",
"login-button-fg-color": "white",
+ "welcome-button-fg-color": "white",
"verticals-width": "16px",
"tab-border-color": "#6800e7",
"timeline-border-radius": "0",
@@ -43,6 +45,7 @@
"font-size-likes": "10px",
"main-bg-color": "#100e23",
"login-bg-color": "#100e23",
+ "welcome-bg-color": "#100e23",
"options-bg-color": "#100e23",
"post-bg-color": "#100e23",
"timeline-posts-background-color": "#100e23",
@@ -57,6 +60,7 @@
"cw-color": "white",
"main-fg-color": "white",
"login-fg-color": "white",
+ "welcome-fg-color": "white",
"options-fg-color": "white",
"title-color": "white",
"column-left-fg-color": "#05b9ec",
@@ -83,6 +87,7 @@
"place-color": "#0481f5",
"event-color": "#0481f5",
"event-background": "#00014a",
+ "event-background-private": "darkpurple",
"quote-right-margin": "0",
"line-spacing": "180%",
"*font-family": "'Montserrat-Regular'",
diff --git a/theme/solidaric/helpimages/welcome.jpg b/theme/solidaric/helpimages/welcome.jpg
new file mode 100644
index 000000000..02e4eb006
Binary files /dev/null and b/theme/solidaric/helpimages/welcome.jpg differ
diff --git a/theme/solidaric/name.txt b/theme/solidaric/name.txt
new file mode 100644
index 000000000..5ff20cbee
--- /dev/null
+++ b/theme/solidaric/name.txt
@@ -0,0 +1 @@
+solidaric
diff --git a/theme/solidaric/theme.json b/theme/solidaric/theme.json
index e3e46b6ff..6c28047de 100644
--- a/theme/solidaric/theme.json
+++ b/theme/solidaric/theme.json
@@ -35,6 +35,7 @@
"rgba(0, 0, 0, 0.5)": "rgba(0, 0, 0, 0.0)",
"main-bg-color": "#eeeeee",
"login-bg-color": "#eeeeee",
+ "welcome-bg-color": "#eeeeee",
"options-bg-color": "#eeeeee",
"post-bg-color": "#eeeeee",
"timeline-posts-background-color": "#eeeeee",
@@ -48,6 +49,7 @@
"cw-color": "#2d2c37",
"main-fg-color": "#2d2c37",
"login-fg-color": "#2d2c37",
+ "welcome-fg-color": "#2d2c37",
"options-fg-color": "#2d2c37",
"column-left-fg-color": "#2d2c37",
"border-color": "#c0cdd9",
@@ -75,6 +77,7 @@
"today-foreground": "#eeeeee",
"today-circle": "red",
"event-background": "lightblue",
+ "event-background-private": "lightgrey",
"event-foreground": "#eeeeee",
"title-text": "#282c37",
"title-background": "#ccc",
diff --git a/theme/starlight/helpimages/welcome.jpg b/theme/starlight/helpimages/welcome.jpg
new file mode 100644
index 000000000..970e080d3
Binary files /dev/null and b/theme/starlight/helpimages/welcome.jpg differ
diff --git a/theme/starlight/icons/calendar.png b/theme/starlight/icons/calendar.png
index cf9071585..ef0d413b2 100644
Binary files a/theme/starlight/icons/calendar.png and b/theme/starlight/icons/calendar.png differ
diff --git a/theme/starlight/icons/calendar_notify.png b/theme/starlight/icons/calendar_notify.png
index c704054d0..66191625e 100644
Binary files a/theme/starlight/icons/calendar_notify.png and b/theme/starlight/icons/calendar_notify.png differ
diff --git a/theme/starlight/icons/scope_event.png b/theme/starlight/icons/scope_event.png
index cf9071585..6860f310f 100644
Binary files a/theme/starlight/icons/scope_event.png and b/theme/starlight/icons/scope_event.png differ
diff --git a/theme/starlight/name.txt b/theme/starlight/name.txt
new file mode 100644
index 000000000..8fb456919
--- /dev/null
+++ b/theme/starlight/name.txt
@@ -0,0 +1 @@
+starlight
diff --git a/theme/starlight/theme.json b/theme/starlight/theme.json
index 1be4c4002..2d8734b38 100644
--- a/theme/starlight/theme.json
+++ b/theme/starlight/theme.json
@@ -19,6 +19,7 @@
"font-size5": "22px",
"main-bg-color": "#0f0d10",
"login-bg-color": "#0f0d10",
+ "welcome-bg-color": "#0f0d10",
"options-bg-color": "#0f0d10",
"post-bg-color": "#0f0d10",
"timeline-posts-background-color": "#0f0d10",
@@ -36,6 +37,7 @@
"cw-color": "#ffc4bc",
"main-fg-color": "#ffc4bc",
"login-fg-color": "#ffc4bc",
+ "welcome-fg-color": "#ffc4bc",
"options-fg-color": "#ffc4bc",
"column-left-fg-color": "#ffc4bc",
"main-bg-color-dm": "#0b0a0a",
@@ -63,6 +65,7 @@
"day-number": "#ffc4bc",
"day-number2": "#aaa",
"event-background": "#12435f",
+ "event-background-private": "darkblue",
"timeline-border-radius": "20px",
"time-color": "#ffc4bc",
"place-color": "#ffc4bc",
diff --git a/theme/starlight/welcome_background.jpg b/theme/starlight/welcome_background.jpg
new file mode 100644
index 000000000..f7a3f5dd0
Binary files /dev/null and b/theme/starlight/welcome_background.jpg differ
diff --git a/theme/zen/helpimages/welcome.jpg b/theme/zen/helpimages/welcome.jpg
new file mode 100644
index 000000000..b12f111c4
Binary files /dev/null and b/theme/zen/helpimages/welcome.jpg differ
diff --git a/theme/zen/icons/calendar.png b/theme/zen/icons/calendar.png
index 5a5e687d9..b9020884c 100644
Binary files a/theme/zen/icons/calendar.png and b/theme/zen/icons/calendar.png differ
diff --git a/theme/zen/icons/calendar_notify.png b/theme/zen/icons/calendar_notify.png
index 8fe306838..edf76fe06 100644
Binary files a/theme/zen/icons/calendar_notify.png and b/theme/zen/icons/calendar_notify.png differ
diff --git a/theme/zen/icons/scope_event.png b/theme/zen/icons/scope_event.png
index 7c597d200..6aa5323b4 100644
Binary files a/theme/zen/icons/scope_event.png and b/theme/zen/icons/scope_event.png differ
diff --git a/theme/zen/name.txt b/theme/zen/name.txt
new file mode 100644
index 000000000..d88970a7c
--- /dev/null
+++ b/theme/zen/name.txt
@@ -0,0 +1 @@
+zen
diff --git a/theme/zen/theme.json b/theme/zen/theme.json
index 03b743247..24892fcb7 100644
--- a/theme/zen/theme.json
+++ b/theme/zen/theme.json
@@ -1,4 +1,5 @@
{
+ "avatar-rounding": "50%",
"dropdown-bg-color-hover": "#463b35",
"cw-color": "#d5c7b7",
"main-fg-color": "#d5c7b7",
@@ -6,6 +7,7 @@
"button-text": "#d5c7b7",
"button-selected-text": "#d5c7b7",
"login-bg-color": "#212e3f",
+ "welcome-bg-color": "#212e3f",
"lines-color": "#b6a188",
"day-number": "black",
"day-number2": "#bbb",
diff --git a/threads.py b/threads.py
index c07de0821..d49981f90 100644
--- a/threads.py
+++ b/threads.py
@@ -5,6 +5,7 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
+__module_group__ = "Core"
import threading
import sys
@@ -70,7 +71,7 @@ class threadWithTrace(threading.Thread):
def removeDormantThreads(baseDir: str, threadsList: [], debug: bool,
- timeoutMins=30) -> None:
+ timeoutMins: int = 30) -> None:
"""Removes threads whose execution has completed
"""
if len(threadsList) == 0:
@@ -140,7 +141,7 @@ def removeDormantThreads(baseDir: str, threadsList: [], debug: bool,
if debug:
sendLogFilename = baseDir + '/send.csv'
try:
- with open(sendLogFilename, "a+") as logFile:
+ with open(sendLogFilename, 'a+') as logFile:
logFile.write(currTime.strftime("%Y-%m-%dT%H:%M:%SZ") +
',' + str(noOfActiveThreads) +
',' + str(len(threadsList)) + '\n')
diff --git a/tox.py b/tox.py
index 502e64ec2..ecb058673 100644
--- a/tox.py
+++ b/tox.py
@@ -5,6 +5,7 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
+__module_group__ = "Profile Metadata"
def getToxAddress(actorJson: {}) -> str:
diff --git a/translations/ar.json b/translations/ar.json
index ecd8cced3..50cca5a13 100644
--- a/translations/ar.json
+++ b/translations/ar.json
@@ -65,7 +65,7 @@
"Create a new DM": "إنشاء DM جديد",
"Switch to profile view": "التبديل إلى عرض الملف الشخصي",
"Inbox": "صندوق الوارد",
- "Outbox": "صندوق الحفظ",
+ "Sent": "أرسلت",
"Search and follow": "بحث ومتابعة",
"Refresh": "تحديث",
"Nickname or URL. Block using *@domain or nickname@domain": "الاسم المستعار أو عنوان URL. حظر استخدام *@domain أو اسم النطاق@domain",
@@ -213,6 +213,7 @@
"Sensitive": "حساس",
"Word Replacements": "استبدال الكلمات",
"Happening Today": "اليوم",
+ "Happening Tomorrow": "غدا",
"Happening This Week": "هكذا",
"Blog": "مدونة",
"Blogs": "المدونات",
@@ -370,5 +371,84 @@
"Publish a blog article": "نشر مقال بلوق",
"Featured writer": "كاتب متميز",
"Broch mode": "وضع الكتيب",
- "Pixel": "بكسل"
+ "Pixel": "بكسل",
+ "DM bounce": "يتم قبول الرسائل فقط من الحسابات المتبعة",
+ "Next": "التالي",
+ "Preview": "معاينة",
+ "Linked": "رابط موقع",
+ "hashtag": "رابطة هاشتاق",
+ "smile": "ابتسامة",
+ "wink": "غمزة",
+ "mentioning": "ذكر",
+ "sad face": "وجه حزين",
+ "thinking emoji": "التفكير الرموز التعبيرية",
+ "laughing": "يضحك",
+ "gender": "جنس تذكير أو تأنيث",
+ "He/Him": "هو",
+ "She/Her": "هي",
+ "girl": "فتاة",
+ "boy": "صبي",
+ "pronoun": "ضمير",
+ "Type of instance": "نوع المثيل",
+ "Security": "حماية",
+ "Enabling broch mode": "يوفر تمكين وضع الكتيب تحصينًا مؤقتًا ضد الهجوم. لن يتم قبول سوى المشاركات من خلال المثيلات المعروفة بالفعل. تنقضي بعد أسبوع.",
+ "Instance Settings": "إعدادات المثيل",
+ "Video Settings": "اعدادات الفيديو",
+ "Filtering and Blocking": "التصفية والحظر",
+ "Role Assignment": "تعيين الدور",
+ "Contact Details": "بيانات المتصل",
+ "Background Images": "صور الخلفية",
+ "heart": "قلب",
+ "counselor": "مستشار",
+ "Counselors": "المستشارين",
+ "shocked": "صدمت",
+ "Encrypted": "مشفر",
+ "Direct Message permitted instances": "الرسالة المباشرة المسموح بها",
+ "Direct messages are always allowed from these instances.": "الرسائل المباشرة مسموح بها دائما من هذه المثيلات.",
+ "Key Shortcuts": "الاختصارات الرئيسية",
+ "menuTimeline": "عرض الجدول الزمني",
+ "menuEdit": "يحرر",
+ "menuProfile": "عرض الملف الشخصي",
+ "menuInbox": "صندوق الوارد",
+ "menuSearch": "البحث / المتتالية",
+ "menuNewPost": "منشور جديد",
+ "menuCalendar": "تقويم",
+ "menuDM": "رسالة مباشرة",
+ "menuReplies": "الردود",
+ "menuOutbox": "مرسل",
+ "menuBookmarks": "إشارات مرجعية",
+ "menuShares": "البنود المشتركة",
+ "menuBlogs": "المدونات",
+ "menuNewswire": "Newswire",
+ "menuLinks": "روابط انترنت",
+ "menuModeration": "الاعتدال",
+ "menuFollowing": "التالية",
+ "menuFollowers": "متابعون",
+ "menuRoles": "أدوار",
+ "menuSkills": "مهارات",
+ "menuLogout": "تسجيل خروج",
+ "menuKeys": "الاختصارات الرئيسية",
+ "submitButton": "زر الإرسال",
+ "menuMedia": "وسائط",
+ "followButton": "زر متابعة / متابعة",
+ "blockButton": "زر كتلة",
+ "infoButton": "زر المعلومات",
+ "snoozeButton": "زر الغفوة",
+ "reportButton": "زر تقرير",
+ "viewButton": "عرض زر",
+ "enterPetname": "أدخل PETNAME",
+ "enterNotes": "أدخل الملاحظات",
+ "These access keys may be used": "قد يتم استخدام مفاتيح الوصول هذه، عادة مع مفتاح ALT + SHIFT + مفتاح ALT +",
+ "Show numbers of accounts within instance metadata": "إظهار عدد الحسابات داخل البيانات الوصفية للمثيلات",
+ "Show version number within instance metadata": "إظهار رقم الإصدار داخل البيانات الوصفية للمثيل",
+ "Joined": "تاريخ الانضمام",
+ "City for spoofed GPS image metadata": "مدينة للبيانات الوصفية لصور GPS المخادعة",
+ "Occupation": "الاحتلال",
+ "Artists": "الفنانين",
+ "Graphic Design": "التصميم الجرافيكي",
+ "Import Theme": "استيراد الموضوع",
+ "Export Theme": "موضوع التصدير",
+ "Custom post submit button text": "عرف نشر إرسال نص زر",
+ "Blocked User Agents": "عوامل المستخدم المحظورة",
+ "Notify me when this account posts": "أعلمني عندما ينشر الحساب هذا"
}
diff --git a/translations/ca.json b/translations/ca.json
index fd87456c5..c9a15be88 100644
--- a/translations/ca.json
+++ b/translations/ca.json
@@ -65,7 +65,7 @@
"Create a new DM": "Crea un nou missatge directe",
"Switch to profile view": "Canvia a la vista del perfil",
"Inbox": "entrada",
- "Outbox": "sortida",
+ "Sent": "Enviat",
"Search and follow": "Cerca i segueix",
"Refresh": "Actualització",
"Nickname or URL. Block using *@domain or nickname@domain": "Nickname o URL. Bloquegeu amb el domini *@ o el sobrenom@",
@@ -213,6 +213,7 @@
"Sensitive": "Sensible",
"Word Replacements": "Substitucions de paraula",
"Happening Today": "Avui",
+ "Happening Tomorrow": "Demà",
"Happening This Week": "Aviat",
"Blog": "Bloc",
"Blogs": "Blocs",
@@ -370,5 +371,84 @@
"Publish a blog article": "Publicar un article del bloc",
"Featured writer": "Escriptor destacat",
"Broch mode": "Mode Broch",
- "Pixel": "Pixel"
+ "Pixel": "Pixel",
+ "DM bounce": "Els missatges només s’accepten des dels comptes seguits",
+ "Next": "Pròxim",
+ "Preview": "Vista prèvia",
+ "Linked": "enllaç web",
+ "hashtag": "etiqueta",
+ "smile": "somriure",
+ "wink": "fer l'ullet",
+ "mentioning": "esmentant",
+ "sad face": "cara trista",
+ "thinking emoji": "emoji pensant",
+ "laughing": "rient",
+ "gender": "gènere",
+ "He/Him": "Ell",
+ "She/Her": "Ella",
+ "girl": "noia",
+ "boy": "noi",
+ "pronoun": "pronom",
+ "Type of instance": "Tipus d’instància",
+ "Security": "Seguretat",
+ "Enabling broch mode": "L'activació del mode de fulletó proporciona una fortificació temporal contra l'atac. Només s’acceptaran publicacions d’instàncies ja conegudes. Transcorre al cap d’una setmana.",
+ "Instance Settings": "Configuració de la instància",
+ "Video Settings": "Configuració del vídeo",
+ "Filtering and Blocking": "Filtratge i bloqueig",
+ "Role Assignment": "Assignació de funcions",
+ "Contact Details": "Detalls de contacte",
+ "Background Images": "Imatges de fons",
+ "heart": "cor",
+ "counselor": "conseller",
+ "Counselors": "Consellers",
+ "shocked": "sorprès",
+ "Encrypted": "Xifrat",
+ "Direct Message permitted instances": "Instàncies permeses del missatge directe",
+ "Direct messages are always allowed from these instances.": "Els missatges directes sempre estan permesos d'aquests casos.",
+ "Key Shortcuts": "Dreceres clau",
+ "menuTimeline": "Vista de la línia de temps",
+ "menuEdit": "Preprarar una edició",
+ "menuProfile": "Vista de perfil",
+ "menuInbox": "Capa inferior",
+ "menuSearch": "Cerca / Segueix",
+ "menuNewPost": "Nou missatge",
+ "menuCalendar": "Calendari",
+ "menuDM": "Missatges directes",
+ "menuReplies": "Resum",
+ "menuOutbox": "Present",
+ "menuBookmarks": "Adreces d'interès",
+ "menuShares": "Articles compartits",
+ "menuBlogs": "Blocs",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Enllaços web",
+ "menuModeration": "Moderació",
+ "menuFollowing": "Proper",
+ "menuFollowers": "Seguidors",
+ "menuRoles": "Rols",
+ "menuSkills": "Habilitats",
+ "menuLogout": "Tancar sessió",
+ "menuKeys": "Dreceres clau",
+ "submitButton": "Envia el botó",
+ "menuMedia": "Medis de comunicació",
+ "followButton": "Seguiu / no seguit",
+ "blockButton": "Botó de bloc",
+ "infoButton": "Botó d'informació",
+ "snoozeButton": "Botó de snooze",
+ "reportButton": "Botó d'informe",
+ "viewButton": "Botó Veure",
+ "enterPetname": "Introduïu PETNAME",
+ "enterNotes": "Introduïu notes",
+ "These access keys may be used": "Es poden utilitzar aquestes tecles d'accés, típicament amb Alt + Maj + tecla o Alt + clau",
+ "Show numbers of accounts within instance metadata": "Mostra el nombre de comptes a les metadades de la instància",
+ "Show version number within instance metadata": "Mostra el número de versió a les metadades de la instància",
+ "Joined": "Data d'unió",
+ "City for spoofed GPS image metadata": "Ciutat per a metadades d'imatges GPS falsificades",
+ "Occupation": "Ocupació",
+ "Artists": "Artistes",
+ "Graphic Design": "Disseny gràfic",
+ "Import Theme": "Importació temàtica",
+ "Export Theme": "Tema d'exportació",
+ "Custom post submit button text": "Text de botó d'enviament de publicacions personalitzades",
+ "Blocked User Agents": "Agents d'usuari bloquejats",
+ "Notify me when this account posts": "Aviseu-me quan publiqui aquest compte"
}
diff --git a/translations/cy.json b/translations/cy.json
index c14bab40a..0cf0e9980 100644
--- a/translations/cy.json
+++ b/translations/cy.json
@@ -65,7 +65,7 @@
"Create a new DM": "Creu Neges Uniongyrchol newydd",
"Switch to profile view": "Newid i olwg proffil",
"Inbox": "Mewnflwch",
- "Outbox": "Allan",
+ "Sent": "Anfonwyd",
"Search and follow": "Chwilio a dilyn",
"Refresh": "Adnewyddu",
"Nickname or URL. Block using *@domain or nickname@domain": "Llysenw neu URL. Blociwch gan ddefnyddio *@domain neu lysenw@domain",
@@ -213,6 +213,7 @@
"Sensitive": "Sensitif",
"Word Replacements": "Amnewidiadau Geiriau",
"Happening Today": "Heddiw",
+ "Happening Tomorrow": "Yfory",
"Happening This Week": "Yn fuan",
"Blog": "Blog",
"Blogs": "Blogs",
@@ -370,5 +371,84 @@
"Publish a blog article": "Cyhoeddi erthygl blog",
"Featured writer": "Awdur dan sylw",
"Broch mode": "Modd Broch",
- "Pixel": "Pixel"
+ "Pixel": "Pixel",
+ "DM bounce": "Dim ond o gyfrifon a ddilynir y derbynnir negeseuon",
+ "Next": "Nesaf",
+ "Preview": "Rhagolwg",
+ "Linked": "Dolen we",
+ "hashtag": "hash-nod",
+ "smile": "gwenu",
+ "wink": "winc",
+ "mentioning": "sôn",
+ "sad face": "wyneb trist",
+ "thinking emoji": "meddwl emoji",
+ "laughing": "chwerthin",
+ "gender": "rhyw",
+ "He/Him": "Ef",
+ "She/Her": "Hi/Ei",
+ "girl": "merch",
+ "boy": "bachgen",
+ "pronoun": "rhagenw",
+ "Type of instance": "Math o enghraifft",
+ "Security": "Diogelwch",
+ "Enabling broch mode": "Mae modd galluogi broch yn darparu amddiffynfa dros dro yn erbyn ymosodiad. Dim ond swyddi mewn achosion y gwyddys amdanynt eisoes a dderbynnir. Mae'n mynd heibio ar ôl wythnos.",
+ "Instance Settings": "Gosodiadau Instance",
+ "Video Settings": "Gosodiadau Fideo",
+ "Filtering and Blocking": "Hidlo a Blocio",
+ "Role Assignment": "Aseiniad Rôl",
+ "Background Images": "Delweddau Cefndir",
+ "Contact Details": "Manylion cyswllt",
+ "heart": "galon",
+ "counselor": "cynghorydd",
+ "Counselors": "Cynghorwyr",
+ "shocked": "sioc",
+ "Encrypted": "Amgryptio",
+ "Direct Message permitted instances": "Achosion a ganiateir negeseuon uniongyrchol",
+ "Direct messages are always allowed from these instances.": "Caniateir negeseuon uniongyrchol bob amser o'r achosion hyn.",
+ "Key Shortcuts": "Llwybrau byr allweddol",
+ "menuTimeline": "View View",
+ "menuEdit": "Golygaf",
+ "menuProfile": "Gweld Proffil",
+ "menuInbox": "Mewnflwch",
+ "menuSearch": "Chwilio / Dilyn",
+ "menuNewPost": "Swydd newydd",
+ "menuCalendar": "Galendr",
+ "menuDM": "Negeseuon Uniongyrchol",
+ "menuReplies": "Atebion",
+ "menuOutbox": "Hanfon",
+ "menuBookmarks": "Nodau tudalen",
+ "menuShares": "Eitemau a Rennir",
+ "menuBlogs": "Blogiau",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Cysylltiadau",
+ "menuModeration": "Safoniad",
+ "menuFollowing": "Ddilynol",
+ "menuFollowers": "Ddilynwyr",
+ "menuRoles": "Rolau",
+ "menuSkills": "Medrau",
+ "menuLogout": "Allgofnodi",
+ "menuKeys": "Llwybrau byr allweddol",
+ "submitButton": "Cyflwyno botwm",
+ "menuMedia": "Chyfryngau",
+ "followButton": "Dilynwch / Peidiwch â Dilynwch y botwm",
+ "blockButton": "Botwm bloc",
+ "infoButton": "Botwm info",
+ "snoozeButton": "Botwm Snooze",
+ "reportButton": "Botwm adroddiadau",
+ "viewButton": "Gweld y botwm",
+ "enterPetname": "Rhowch enw PETName",
+ "enterNotes": "Rhowch nodiadau",
+ "These access keys may be used": "Gellir defnyddio'r allweddi mynediad hyn, fel arfer gyda ALT + Shift + Allwedd Allwedd neu ALT +",
+ "Show numbers of accounts within instance metadata": "Dangos nifer y cyfrifon o fewn metadata",
+ "Show version number within instance metadata": "Dangos rhif y fersiwn o fewn metadata",
+ "Joined": "Dyddiad ymuno",
+ "City for spoofed GPS image metadata": "Dinas ar gyfer metadata delwedd GPS spoofed",
+ "Occupation": "Ngalwedigaeth",
+ "Artists": "Artistiaid",
+ "Graphic Design": "Dylunio Graffig",
+ "Import Theme": "Thema Mewnforio",
+ "Export Theme": "Thema Allforio",
+ "Custom post submit button text": "Testun Post Post Post",
+ "Blocked User Agents": "Asiantau defnyddwyr wedi'u blocio",
+ "Notify me when this account posts": "Rhoi gwybod i mi pan fydd y cyfrifon cyfrif hwn"
}
diff --git a/translations/de.json b/translations/de.json
index b1336dc61..df10a2e6e 100644
--- a/translations/de.json
+++ b/translations/de.json
@@ -65,7 +65,7 @@
"Create a new DM": "Neue Direktnachricht",
"Switch to profile view": "Zur Profilansicht wechseln",
"Inbox": "Eingang",
- "Outbox": "Ausgang",
+ "Sent": "Geschickt",
"Search and follow": "Suchen und folgen",
"Refresh": "Aktualisieren",
"Nickname or URL. Block using *@domain or nickname@domain": "Benutzername oder URL. *@Domäne oder Benutzer@Domäne sperren",
@@ -213,6 +213,7 @@
"Sensitive": "Empfindlich",
"Word Replacements": "Wortersetzungen",
"Happening Today": "Heute",
+ "Happening Tomorrow": "Morgen",
"Happening This Week": "Demnächst",
"Blog": "Blog",
"Blogs": "Blogs",
@@ -370,5 +371,84 @@
"Publish a blog article": "Veröffentlichen Sie einen Blog-Artikel",
"Featured writer": "Ausgewählter Schriftsteller",
"Broch mode": "Broch-Modus",
- "Pixel": "Pixel"
+ "Pixel": "Pixel",
+ "DM bounce": "Nachrichten werden nur von folgenden Konten akzeptiert",
+ "Next": "Nächster",
+ "Preview": "Vorschau",
+ "Linked": "Weblink",
+ "hashtag": "hash-tag",
+ "smile": "Lächeln",
+ "wink": "zwinkern",
+ "mentioning": "Erwähnen",
+ "sad face": "trauriges Gesicht",
+ "thinking emoji": "Emowji denken",
+ "laughing": "Lachen",
+ "gender": "geschlecht",
+ "He/Him": "Er/ihm",
+ "She/Her": "Sie",
+ "girl": "mädchen",
+ "boy": "junge",
+ "pronoun": "pronomen",
+ "Type of instance": "Art der Instanz",
+ "Security": "Sicherheit",
+ "Enabling broch mode": "Das Aktivieren des Broch-Modus bietet eine vorübergehende Verstärkung gegen Angriffe. Es werden nur Beiträge von bereits bekannten Instanzen akzeptiert. Es vergeht nach einer Woche.",
+ "Instance Settings": "Instanzeinstellungen",
+ "Video Settings": "Video-Einstellungen",
+ "Filtering and Blocking": "Filtern und Blockieren",
+ "Role Assignment": "Rollenzuweisung",
+ "Background Images": "Hintergrundbilder",
+ "Contact Details": "Kontaktdetails",
+ "heart": "herz",
+ "counselor": "Beraterin",
+ "Counselors": "Berater",
+ "shocked": "schockiert",
+ "Encrypted": "Verschlüsselt",
+ "Direct Message permitted instances": "Direktnachricht erlaubte Instanzen",
+ "Direct messages are always allowed from these instances.": "Direkte Nachrichten sind in diesen Fällen immer zulässig.",
+ "Key Shortcuts": "Schlüsselverknüpfungen",
+ "menuTimeline": "Timeline-Ansicht",
+ "menuEdit": "Bearbeiten",
+ "menuProfile": "Profilansicht",
+ "menuInbox": "Inbox",
+ "menuSearch": "Suche / Folgen",
+ "menuNewPost": "Neuer Beitrag",
+ "menuCalendar": "Kalender",
+ "menuDM": "Direkte Nachrichten",
+ "menuReplies": "Antworten",
+ "menuOutbox": "Geschickt",
+ "menuBookmarks": "Lesezeichen",
+ "menuShares": "Gemeinsame Artikel",
+ "menuBlogs": "Blogs",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Web-Links",
+ "menuModeration": "Mäßigung",
+ "menuFollowing": "Folgen",
+ "menuFollowers": "Anhänger",
+ "menuRoles": "Rollen",
+ "menuSkills": "Kompetenzen",
+ "menuLogout": "Ausloggen",
+ "menuKeys": "Schlüsselverknüpfungen",
+ "submitButton": "Button einreichen",
+ "menuMedia": "Medien",
+ "followButton": "Folgen / folgen Sie nicht der Taste",
+ "blockButton": "Blockknopf",
+ "infoButton": "Info-Taste",
+ "snoozeButton": "Schlummertaste",
+ "reportButton": "Berichtsknopf",
+ "viewButton": "Schaltfläche anzeigen",
+ "enterPetname": "Petname eingeben",
+ "enterNotes": "Notizen eingeben",
+ "These access keys may be used": "Diese Zugriffstasten können verwendet werden, typischerweise mit ALT + SHIFT + -Taste oder ALT + -Taste",
+ "Show numbers of accounts within instance metadata": "Anzahl der Konten in Instanzmetadaten anzeigen",
+ "Show version number within instance metadata": "Versionsnummer in Instanzmetadaten anzeigen",
+ "Joined": "Verbundenes Datum",
+ "City for spoofed GPS image metadata": "Stadt für gefälschte GPS-Bildmetadaten",
+ "Occupation": "Besetzung",
+ "Artists": "Künstler",
+ "Graphic Design": "Grafikdesign",
+ "Import Theme": "Theme importieren",
+ "Export Theme": "Theme exportieren",
+ "Custom post submit button text": "Benutzerdefinierte Post-Senden Schaltfläche Text",
+ "Blocked User Agents": "Blockierte Benutzeragenten",
+ "Notify me when this account posts": "Benachrichtigen Sie mich, wenn dieses Konto postet"
}
diff --git a/translations/en.json b/translations/en.json
index f98ac234d..31d1bbf40 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -65,7 +65,7 @@
"Create a new DM": "Create a new DM",
"Switch to profile view": "Profile view",
"Inbox": "Inbox",
- "Outbox": "Outbox",
+ "Sent": "Sent",
"Search and follow": "Search/follow",
"Refresh": "Refresh",
"Nickname or URL. Block using *@domain or nickname@domain": "Nickname or URL. Block using *@domain or nickname@domain",
@@ -90,7 +90,7 @@
"View": "View",
"Stop blocking": "Stop blocking",
"Enter an emoji name to search for": "Enter an emoji name to search for",
- "Enter an address, shared item, !history, #hashtag, *skill or :emoji: to search for": "Enter an address, shared item, !history, #hashtag, *skill or :emoji: to search for",
+ "Enter an address, shared item, !history, #hashtag, *skill or :emoji: to search for": "Enter an address, shared item, -save, !history, #hashtag, *skill or :emoji: to search for",
"Go Back": "◀",
"Moderation Information": "Moderation Information",
"Suspended accounts": "Suspended accounts",
@@ -111,7 +111,7 @@
"Profile for": "Profile for",
"The files attached below should be no larger than 10MB in total uploaded at once.": "The files attached below should be no larger than 10MB in total uploaded at once.",
"Avatar image": "Avatar image",
- "Background image": "Background image",
+ "Background image": "Background image, which appears behind your avatar",
"Timeline banner image": "Timeline banner image",
"Approve follower requests": "Approve follower requests",
"This is a bot account": "This is a bot account",
@@ -213,6 +213,7 @@
"Sensitive": "Sensitive",
"Word Replacements": "Word Replacements",
"Happening Today": "Today",
+ "Happening Tomorrow": "Tomorrow",
"Happening This Week": "Soon",
"Blog": "Blog",
"Blogs": "Blogs",
@@ -360,7 +361,7 @@
"New account": "New account",
"Moved to new account address": "Moved to new account address",
"Yet another Epicyon Instance": "Yet another Epicyon Instance",
- "Other accounts": "Other accounts",
+ "Other accounts": "Other fediverse accounts",
"Pin this post to your profile.": "Pin this post to your profile.",
"Administered by": "Administered by",
"Version": "Version",
@@ -370,5 +371,84 @@
"Publish a blog article": "Publish a blog article",
"Featured writer": "Featured writer",
"Broch mode": "Broch mode",
- "Pixel": "Pixel"
+ "Pixel": "Pixel",
+ "DM bounce": "Messages are only accepted from followed accounts",
+ "Next": "Next",
+ "Preview": "Preview",
+ "Linked": "Web linked",
+ "hashtag": "hash-tag",
+ "smile": "smile",
+ "wink": "wink",
+ "mentioning": "mentioning",
+ "sad face": "sad face",
+ "thinking emoji": "thinking emowji",
+ "laughing": "laughing",
+ "gender": "gender",
+ "He/Him": "He/Him",
+ "She/Her": "She/Her",
+ "girl": "girl",
+ "boy": "boy",
+ "pronoun": "pronoun",
+ "Type of instance": "Type of instance",
+ "Security": "Security",
+ "Enabling broch mode": "Enabling broch mode provides a temporary fortification against attack. Only posts by already known instances will be accepted. If not turned off, it elapses after a week.",
+ "Instance Settings": "Instance Settings",
+ "Video Settings": "Video Settings",
+ "Filtering and Blocking": "Filtering and Blocking",
+ "Role Assignment": "Role Assignment",
+ "Contact Details": "Contact Details",
+ "Background Images": "Background Images",
+ "heart": "heart",
+ "counselor": "counselor",
+ "Counselors": "Counselors",
+ "shocked": "shocked",
+ "Encrypted": "Encrypted",
+ "Direct Message permitted instances": "Direct Message permitted instances",
+ "Direct messages are always allowed from these instances.": "Direct messages are always allowed from these instances.",
+ "Key Shortcuts": "Key Shortcuts",
+ "menuTimeline": "Timeline view",
+ "menuEdit": "Edit",
+ "menuProfile": "Profile view",
+ "menuInbox": "Inbox",
+ "menuSearch": "Search/follow",
+ "menuNewPost": "New post",
+ "menuCalendar": "Calendar",
+ "menuDM": "Direct Messages",
+ "menuReplies": "Replies",
+ "menuOutbox": "Sent",
+ "menuBookmarks": "Bookmarks",
+ "menuShares": "Shared items",
+ "menuBlogs": "Blogs",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Links",
+ "menuModeration": "Moderation",
+ "menuFollowing": "Following",
+ "menuFollowers": "Followers",
+ "menuRoles": "Roles",
+ "menuSkills": "Skills",
+ "menuLogout": "Logout",
+ "menuKeys": "Key Shortcuts",
+ "submitButton": "Submit button",
+ "menuMedia": "Media",
+ "followButton": "Follow/unfollow button",
+ "blockButton": "Block button",
+ "infoButton": "Info button",
+ "snoozeButton": "Snooze button",
+ "reportButton": "Report button",
+ "viewButton": "View button",
+ "enterPetname": "Enter petname",
+ "enterNotes": "Enter notes",
+ "These access keys may be used": "These access keys may be used, typically with ALT + SHIFT + key or ALT + key",
+ "Show numbers of accounts within instance metadata": "Show numbers of accounts within instance metadata",
+ "Show version number within instance metadata": "Show version number within instance metadata",
+ "Joined": "Joined",
+ "City for spoofed GPS image metadata": "City for spoofed GPS image metadata",
+ "Occupation": "Occupation",
+ "Artists": "Artists",
+ "Graphic Design": "Graphic Design",
+ "Import Theme": "Import Theme",
+ "Export Theme": "Export Theme",
+ "Custom post submit button text": "Custom post submit button text",
+ "Blocked User Agents": "Blocked User Agents",
+ "Notify me when this account posts": "Notify me when this account posts"
}
diff --git a/translations/es.json b/translations/es.json
index 83869d7f2..ac096a01d 100644
--- a/translations/es.json
+++ b/translations/es.json
@@ -65,7 +65,7 @@
"Create a new DM": "Crear un nuevo mensaje directo",
"Switch to profile view": "Cambiar a la vista de perfil",
"Inbox": "Entrada",
- "Outbox": "Salida",
+ "Sent": "Enviada",
"Search and follow": "Busca y sigue",
"Refresh": "Refrescar",
"Nickname or URL. Block using *@domain or nickname@domain": "Apodo o URL. Bloquear usando *@dominio o apodo@dominio",
@@ -213,6 +213,7 @@
"Sensitive": "Sensible",
"Word Replacements": "Reemplazos de palabras",
"Happening Today": "Hoy",
+ "Happening Tomorrow": "Mañana",
"Happening This Week": "Pronto",
"Blog": "Blog",
"Blogs": "Blogs",
@@ -370,5 +371,84 @@
"Publish a blog article": "Publica un artículo de blog",
"Featured writer": "Escritora destacada",
"Broch mode": "Modo broche",
- "Pixel": "Pixel"
+ "Pixel": "Pixel",
+ "DM bounce": "Solo se aceptan mensajes de cuentas seguidas",
+ "Next": "Próxima",
+ "Preview": "Avance",
+ "Linked": "enlace web",
+ "hashtag": "hash-tag",
+ "smile": "sonreír",
+ "wink": "guiño",
+ "mentioning": "mencionar",
+ "sad face": "cara triste",
+ "thinking emoji": "pensando emowji",
+ "laughing": "risa",
+ "gender": "género",
+ "He/Him": "El",
+ "She/Her": "Ella",
+ "girl": "muchacha",
+ "boy": "niño",
+ "pronoun": "pronombre",
+ "Type of instance": "Tipo de instancia",
+ "Security": "Seguridad",
+ "Enabling broch mode": "Habilitar el modo broche proporciona una fortificación temporal contra el ataque. Solo se aceptarán publicaciones de instancias ya conocidas. Transcurre después de una semana.",
+ "Instance Settings": "Configuración de instancia",
+ "Video Settings": "Ajustes de video",
+ "Filtering and Blocking": "Filtrado y bloqueo",
+ "Role Assignment": "Asignación de roles",
+ "Background Images": "Imágenes de fondo",
+ "Contact Details": "Detalles de contacto",
+ "heart": "corazón",
+ "counselor": "Consejera",
+ "Counselors": "Consejeras",
+ "shocked": "conmocionada",
+ "Encrypted": "Cifrada",
+ "Direct Message permitted instances": "Mensaje directo permitido instancias",
+ "Direct messages are always allowed from these instances.": "Los mensajes directos siempre están permitidos de estas instancias.",
+ "Key Shortcuts": "Atajos clave",
+ "menuTimeline": "Vista de la línea de tiempo",
+ "menuEdit": "Editar",
+ "menuProfile": "Vista del perfil",
+ "menuInbox": "Bandeja de entrada",
+ "menuSearch": "Búsqueda / Seguir",
+ "menuNewPost": "Nueva publicación",
+ "menuCalendar": "Calendario",
+ "menuDM": "Mensajes directos",
+ "menuReplies": "Respuestas",
+ "menuOutbox": "Enviada",
+ "menuBookmarks": "Marcadores",
+ "menuShares": "Artículos compartidos",
+ "menuBlogs": "Blogs",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Enlaces web",
+ "menuModeration": "Moderación",
+ "menuFollowing": "Siguiente",
+ "menuFollowers": "De seguidores",
+ "menuRoles": "Roles",
+ "menuSkills": "Habilidades",
+ "menuLogout": "Cerrar sesión",
+ "menuKeys": "Atajos clave",
+ "submitButton": "Botón de enviar",
+ "menuMedia": "Medios de comunicación",
+ "followButton": "Botón de seguimiento / dejo",
+ "blockButton": "Botón de bloqueo",
+ "infoButton": "Botón de información",
+ "snoozeButton": "El botón de dormitar",
+ "reportButton": "Botón de informe",
+ "viewButton": "Botón de vista",
+ "enterPetname": "Entrar en nombre de pettname",
+ "enterNotes": "Ingresar notas",
+ "These access keys may be used": "Se pueden usar estas teclas de acceso, típicamente con teclas ALT + MAYÚS + teclas o ALT +",
+ "Show numbers of accounts within instance metadata": "Muestra el número de cuentas dentro de los metadatos de la instancia.",
+ "Show version number within instance metadata": "Mostrar el número de versión dentro de los metadatos de la instancia",
+ "Joined": "Fecha unida",
+ "City for spoofed GPS image metadata": "Ciudad para metadatos de imagen GPS falsificados",
+ "Occupation": "Ocupación",
+ "Artists": "Artistas",
+ "Graphic Design": "Diseño gráfico",
+ "Import Theme": "Tema de importación",
+ "Export Theme": "Tema de exportación",
+ "Custom post submit button text": "POST POST PERSONALIZADO Botón Texto",
+ "Blocked User Agents": "Agentes de usuario bloqueados",
+ "Notify me when this account posts": "Notifíqueme cuando se publique esta cuenta"
}
diff --git a/translations/fr.json b/translations/fr.json
index c579130b3..6defd9956 100644
--- a/translations/fr.json
+++ b/translations/fr.json
@@ -65,7 +65,7 @@
"Create a new DM": "Créer un nouveau message direct",
"Switch to profile view": "Passer en vue de profil",
"Inbox": "Réception",
- "Outbox": "Envoi",
+ "Sent": "Expédié",
"Search and follow": "Rechercher et suivre",
"Refresh": "Rafraîchir",
"Nickname or URL. Block using *@domain or nickname@domain": "Surnom ou URL. Bloquer en utilisant *@domain ou pseudo@domain",
@@ -213,6 +213,7 @@
"Sensitive": "Sensible",
"Word Replacements": "Remplacements de mots",
"Happening Today": "Aujourd'hui",
+ "Happening Tomorrow": "Demain",
"Happening This Week": "Bientôt",
"Blog": "Blog",
"Blogs": "Blogs",
@@ -370,5 +371,84 @@
"Publish a blog article": "Publier un article de blog",
"Featured writer": "Écrivain en vedette",
"Broch mode": "Mode Broch",
- "Pixel": "Pixel"
+ "Pixel": "Pixel",
+ "DM bounce": "Les messages ne sont acceptés que des comptes suivis",
+ "Next": "Suivante",
+ "Preview": "Aperçu",
+ "Linked": "lien Web",
+ "hashtag": "hash-tag",
+ "smile": "le sourire",
+ "wink": "clin d'œil",
+ "mentioning": "mentionnant",
+ "sad face": "visage triste",
+ "thinking emoji": "penser emowji",
+ "laughing": "en riant",
+ "gender": "le genre",
+ "He/Him": "Il/Lui",
+ "She/Her": "Elle",
+ "girl": "fille",
+ "boy": "garçon",
+ "pronoun": "pronom",
+ "Type of instance": "Type d'instance",
+ "Security": "Sécurité",
+ "Enabling broch mode": "L'activation du mode broch fournit une fortification temporaire contre les attaques. Seuls les messages par des instances déjà connues seront acceptés. Il s'écoule après une semaine.",
+ "Instance Settings": "Paramètres d'instance",
+ "Video Settings": "Paramètres vidéo",
+ "Filtering and Blocking": "Filtrage et blocage",
+ "Role Assignment": "Attribution de rôle",
+ "Background Images": "Images d'arrière-plan",
+ "Contact Details": "Détails du contact",
+ "heart": "cœur",
+ "counselor": "Conseillère",
+ "Counselors": "Conseillères",
+ "shocked": "sous le choc",
+ "Encrypted": "Crypté",
+ "Direct Message permitted instances": "Message direct des instances autorisées",
+ "Direct messages are always allowed from these instances.": "Les messages directs sont toujours autorisés dans ces instances.",
+ "Key Shortcuts": "Raccourcis clés",
+ "menuTimeline": "Vue de la chronologie",
+ "menuEdit": "Éditer",
+ "menuProfile": "Voir",
+ "menuInbox": "Boîte de réception",
+ "menuSearch": "Rechercher / suivre",
+ "menuNewPost": "Nouveau poste",
+ "menuCalendar": "Calendrier",
+ "menuDM": "Messages directs",
+ "menuReplies": "réponses",
+ "menuOutbox": "Envoyée",
+ "menuBookmarks": "Favoris",
+ "menuShares": "Articles partagés",
+ "menuBlogs": "Blogs",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Liens web",
+ "menuModeration": "Modération",
+ "menuFollowing": "Suivante",
+ "menuFollowers": "Suiveuses",
+ "menuRoles": "Les rôles",
+ "menuSkills": "Compétences",
+ "menuLogout": "Se déconnecter",
+ "menuKeys": "Raccourcis clés",
+ "submitButton": "Bouton de soumission",
+ "menuMedia": "Médias",
+ "followButton": "Suivez / Bouton Suivi",
+ "blockButton": "Bouton de bloc",
+ "infoButton": "Bouton info",
+ "snoozeButton": "Le bouton de la sieste",
+ "reportButton": "Bouton de rapport",
+ "viewButton": "Bouton d'affichage",
+ "enterPetname": "Entrez PETNAME",
+ "enterNotes": "Faire entrer des notes",
+ "These access keys may be used": "Ces touches d'accès peuvent être utilisées typiquement avec une touche Alt + Maj + ou Alt +",
+ "Show numbers of accounts within instance metadata": "Afficher le nombre de comptes dans les métadonnées de l'instance",
+ "Show version number within instance metadata": "Afficher le numéro de version dans les métadonnées de l'instance",
+ "Joined": "Joint",
+ "City for spoofed GPS image metadata": "Ville pour les métadonnées d'image GPS falsifiées",
+ "Occupation": "Occupation",
+ "Artists": "Artistes",
+ "Graphic Design": "Conception graphique",
+ "Import Theme": "Import thème",
+ "Export Theme": "Thème d'exportation",
+ "Custom post submit button text": "Texte de bouton d'envoi postal personnalisé",
+ "Blocked User Agents": "Agents d'utilisateur bloqués",
+ "Notify me when this account posts": "Avertissez-moi quand ce compte publie"
}
diff --git a/translations/ga.json b/translations/ga.json
index a225f7303..632b57ab0 100644
--- a/translations/ga.json
+++ b/translations/ga.json
@@ -65,7 +65,7 @@
"Create a new DM": "Cruthaigh Teachtaireacht Dhíreach nua",
"Switch to profile view": "Athraigh an amharcphróifíl",
"Inbox": "Isteach",
- "Outbox": "Outbox",
+ "Sent": "Seolta",
"Search and follow": "Cuardaigh agus leanúint",
"Refresh": "Athnuachan",
"Nickname or URL. Block using *@domain or nickname@domain": "Leasainm nó URL. Bloc ag baint úsáide as *@fearainn nó leasainm@fearainn",
@@ -213,6 +213,7 @@
"Sensitive": "Íogair",
"Word Replacements": "Athchur Focal",
"Happening Today": "Inniu",
+ "Happening Tomorrow": "Amárach",
"Happening This Week": "Go gairid",
"Blog": "Blag",
"Blogs": "Blaganna",
@@ -370,5 +371,84 @@
"Publish a blog article": "Foilsigh alt blagála",
"Featured writer": "Scríbhneoir mór le rá",
"Broch mode": "Modh broch",
- "Pixel": "Pixel"
+ "Pixel": "Pixel",
+ "DM bounce": "Ní ghlactar le teachtaireachtaí ach ó chuntais a leanann",
+ "Next": "Ar Aghaidh",
+ "Preview": "Réamhamharc",
+ "Linked": "Nasc gréasáin",
+ "hashtag": "hash-tag",
+ "smile": "aoibh gháire",
+ "wink": "wink",
+ "mentioning": "ag lua",
+ "sad face": "aghaidh brónach",
+ "thinking emoji": "ag smaoineamh emowji",
+ "laughing": "ag gáire",
+ "gender": "inscne",
+ "He/Him": "Sé/Eisean",
+ "She/Her": "Sí",
+ "girl": "cailín",
+ "boy": "buachaill",
+ "pronoun": "forainm",
+ "Type of instance": "Cineál mar shampla",
+ "Security": "Slándáil",
+ "Enabling broch mode": "Soláthraíonn modh bróiste cumasaithe daingniú sealadach ar ionsaí. Ní ghlacfar ach le poist de réir cásanna a bhfuil eolas orthu cheana. Maireann sé tar éis seachtaine.",
+ "Instance Settings": "Socruithe Institiúide",
+ "Video Settings": "Socruithe Físe",
+ "Filtering and Blocking": "Scagadh agus Blocáil",
+ "Role Assignment": "Sannadh Róil",
+ "Background Images": "Íomhánna Cúlra",
+ "Contact Details": "Sonraí Teagmhála",
+ "heart": "chroí",
+ "counselor": "Comhairleoir",
+ "Counselors": "Comhairleoirí",
+ "shocked": "ionadh",
+ "Encrypted": "Criptithe",
+ "Direct Message permitted instances": "Ceadaíonn teachtaireacht dhíreach cásanna",
+ "Direct messages are always allowed from these instances.": "Ceadaítear teachtaireachtaí díreacha i gcónaí ó na cásanna seo.",
+ "Key Shortcuts": "Príomh-aicearraí",
+ "menuTimeline": "Amlíne View",
+ "menuEdit": "Eagarthóireacht a dhéanamh ar",
+ "menuProfile": "Dearcadh próifíle",
+ "menuInbox": "Bosca isteach",
+ "menuSearch": "Cuardaigh / Lean",
+ "menuNewPost": "Post nua",
+ "menuCalendar": "Caileandar",
+ "menuDM": "Teachtaireachtaí díreacha",
+ "menuReplies": "Freagraí",
+ "menuOutbox": "Seoltar",
+ "menuBookmarks": "Leabharmharcanna",
+ "menuShares": "Míreanna Comhroinnte",
+ "menuBlogs": "Blaganna",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Naisc Ghréasáin",
+ "menuModeration": "Modhnóireacht a dhéanamh ar",
+ "menuFollowing": "Lucht tacaíochta",
+ "menuFollowers": "Leanúna",
+ "menuRoles": "Róil",
+ "menuSkills": "Scileanna",
+ "menuLogout": "Logáil Amach",
+ "menuKeys": "Príomh-aicearraí",
+ "submitButton": "Cuir an cnaipe isteach",
+ "menuMedia": "Na meáin",
+ "followButton": "Lean / Cnaipe Unurollow",
+ "blockButton": "Cnaipe bloc",
+ "infoButton": "Cnaipe Info",
+ "snoozeButton": "Cnaipe snooze",
+ "reportButton": "Cnaipe Tuairisce",
+ "viewButton": "Féach an cnaipe",
+ "enterPetname": "Cuir isteach PetName",
+ "enterNotes": "Cuir nótaí isteach",
+ "These access keys may be used": "Is féidir na heochracha rochtana seo a úsáid, de ghnáth le Alt + Shift + Eochair nó Alt + Eochair",
+ "Show numbers of accounts within instance metadata": "Taispeáin líon na gcuntas laistigh de mheiteashonraí",
+ "Show version number within instance metadata": "Taispeáin uimhir an leagain laistigh de mheiteashonraí",
+ "Joined": "Dáta comhcheangailte",
+ "City for spoofed GPS image metadata": "Cathair le haghaidh meiteashonraí íomhá GPS spoofed",
+ "Occupation": "Slí bheatha",
+ "Artists": "Ealaíontóirí",
+ "Graphic Design": "Dearadh grafach",
+ "Import Theme": "Téama Iompórtáil",
+ "Export Theme": "Téama Easpórtála",
+ "Custom post submit button text": "Post saincheaptha Cuir isteach an cnaipe Téacs",
+ "Blocked User Agents": "Gníomhairí úsáideora blocáilte",
+ "Notify me when this account posts": "Cuir in iúl dom nuair a phostófar an cuntas seo"
}
diff --git a/translations/hi.json b/translations/hi.json
index e500e6f66..2f3e13f0e 100644
--- a/translations/hi.json
+++ b/translations/hi.json
@@ -65,7 +65,7 @@
"Create a new DM": "नया डीएम बनाएं",
"Switch to profile view": "प्रोफ़ाइल दृश्य पर स्विच करें",
"Inbox": "इनबॉक्स",
- "Outbox": "आउटबॉक्स",
+ "Sent": "भेज दिया",
"Search and follow": "खोज और अनुसरण करें",
"Refresh": "ताज़ा करना",
"Nickname or URL. Block using *@domain or nickname@domain": "उपनाम या URL। *@डोमेन या उपनाम@डोमेन का उपयोग करके ब्लॉक करें",
@@ -213,6 +213,7 @@
"Sensitive": "संवेदनशील",
"Word Replacements": "शब्द प्रतिस्थापन",
"Happening Today": "आज",
+ "Happening Tomorrow": "आने वाला कल",
"Happening This Week": "जल्द ही",
"Blog": "ब्लॉग",
"Blogs": "ब्लॉग",
@@ -370,5 +371,84 @@
"Publish a blog article": "एक ब्लॉग लेख प्रकाशित करें",
"Featured writer": "फीचर्ड लेखक",
"Broch mode": "ब्रोच मोड",
- "Pixel": "पिक्सेल"
+ "Pixel": "पिक्सेल",
+ "DM bounce": "संदेश केवल अनुसरण किए गए खातों से स्वीकार किए जाते हैं",
+ "Next": "अगला",
+ "Preview": "पूर्वावलोकन",
+ "Linked": "वेब लिंक",
+ "hashtag": "हैशटैग",
+ "smile": "मुस्कुराओ",
+ "wink": "आँख मारना",
+ "mentioning": "उल्लेख",
+ "sad face": "उदास चेहरा",
+ "thinking emoji": "सोच रहे हैं इमोजी",
+ "laughing": "हस रहा",
+ "gender": "लिंग",
+ "He/Him": "वह/उसे",
+ "She/Her": "वह/उसकी",
+ "girl": "लड़की",
+ "boy": "लड़का",
+ "pronoun": "सवर्नाम",
+ "Type of instance": "उदाहरण के प्रकार",
+ "Security": "सुरक्षा",
+ "Enabling broch mode": "ब्रोश मोड को सक्षम करना हमले के खिलाफ एक अस्थायी किलेबंदी प्रदान करता है। केवल पहले से ज्ञात उदाहरणों द्वारा पोस्ट स्वीकार किए जाएंगे। यह एक सप्ताह के बाद समाप्त हो जाता है।",
+ "Instance Settings": "उदाहरण सेटिंग्स",
+ "Video Settings": "वीडियो सेटिंग्स",
+ "Filtering and Blocking": "छानना और अवरुद्ध करना",
+ "Role Assignment": "भूमिका असाइनमेंट",
+ "Background Images": "पृष्ठभूमि छवियों",
+ "Contact Details": "सम्पर्क करने का विवरण",
+ "heart": "दिल",
+ "counselor": "काउंसलर",
+ "Counselors": "सलाहकार",
+ "shocked": "हैरान",
+ "Encrypted": "को गोपित",
+ "Direct Message permitted instances": "प्रत्यक्ष संदेश अनुमत उदाहरण",
+ "Direct messages are always allowed from these instances.": "इन उदाहरणों से प्रत्यक्ष संदेश हमेशा अनुमति देते हैं।",
+ "Key Shortcuts": "कुंजी शॉर्टकट",
+ "menuTimeline": "समयरेखा दृश्य",
+ "menuEdit": "संपादित करें",
+ "menuProfile": "प्रोफ़ाइल दृश्य",
+ "menuInbox": "इनबॉक्स",
+ "menuSearch": "खोज / अनुसरण करें",
+ "menuNewPost": "नई पोस्ट",
+ "menuCalendar": "पंचांग",
+ "menuDM": "सीधे संदेश",
+ "menuReplies": "जवाब",
+ "menuOutbox": "भेज दिया",
+ "menuBookmarks": "बुकमार्क",
+ "menuShares": "साझा आइटम",
+ "menuBlogs": "ब्लॉग",
+ "menuNewswire": "न्यूज़वायर",
+ "menuLinks": "वेब लिंक",
+ "menuModeration": "संयम",
+ "menuFollowing": "निम्नलिखित",
+ "menuFollowers": "समर्थक",
+ "menuRoles": "भूमिकाएँ",
+ "menuSkills": "कौशल",
+ "menuLogout": "लॉग आउट",
+ "menuKeys": "कुंजी शॉर्टकट",
+ "submitButton": "जमा करने वाला बटन",
+ "menuMedia": "मीडिया",
+ "followButton": "फॉलो / अनफ़ॉलो बटन",
+ "blockButton": "ब्लॉक बटन",
+ "infoButton": "जानकारी बटन",
+ "snoozeButton": "अलार्म को थोड़ी देर बंद करने वाला बटन",
+ "reportButton": "रिपोर्ट बटन",
+ "viewButton": "देखें बटन",
+ "enterPetname": "PETNAME दर्ज करें",
+ "enterNotes": "नोट्स दर्ज करें",
+ "These access keys may be used": "इन एक्सेस कुंजियों का उपयोग किया जा सकता है, आमतौर पर Alt + Shift + कुंजी या Alt + कुंजी के साथ",
+ "Show numbers of accounts within instance metadata": "उदाहरण मेटाडेटा के भीतर खातों की संख्या दिखाएं",
+ "Show version number within instance metadata": "उदाहरण मेटाडेटा के भीतर संस्करण संख्या दिखाएं",
+ "Joined": "दिनांक",
+ "City for spoofed GPS image metadata": "स्पूफ जीपीएस जीपीएस मेटाडेटा के लिए शहर",
+ "Occupation": "व्यवसाय",
+ "Artists": "कलाकार की",
+ "Graphic Design": "ग्राफ़िक डिज़ाइन",
+ "Import Theme": "आयात विषय",
+ "Export Theme": "निर्यात विषय",
+ "Custom post submit button text": "कस्टम पोस्ट सबमिट बटन टेक्स्ट",
+ "Blocked User Agents": "अवरुद्ध उपयोगकर्ता एजेंट",
+ "Notify me when this account posts": "यह खाता पोस्ट होने पर मुझे सूचित करें"
}
diff --git a/translations/it.json b/translations/it.json
index b626f6d8a..8406ccbda 100644
--- a/translations/it.json
+++ b/translations/it.json
@@ -65,7 +65,7 @@
"Create a new DM": "Crea un nuovo messaggio diretto",
"Switch to profile view": "Passa alla vista profilo",
"Inbox": "Arrivo",
- "Outbox": "In uscita",
+ "Sent": "Inviata",
"Search and follow": "Cerca e segui",
"Refresh": "Ricaricare",
"Nickname or URL. Block using *@domain or nickname@domain": "Soprannome o URL. Blocca usando *@domain o nickname@domain",
@@ -213,6 +213,7 @@
"Sensitive": "Sensibile",
"Word Replacements": "Sostituzioni di parole",
"Happening Today": "Oggi",
+ "Happening Tomorrow": "Domani",
"Happening This Week": "Presto",
"Blog": "Blog",
"Blogs": "Blog",
@@ -370,5 +371,84 @@
"Publish a blog article": "Pubblica un articolo sul blog",
"Featured writer": "Scrittore in primo piano",
"Broch mode": "Modalità Broch",
- "Pixel": "Pixel"
+ "Pixel": "Pixel",
+ "DM bounce": "I messaggi sono accettati solo dagli account seguiti",
+ "Next": "Il prossimo",
+ "Preview": "Anteprima",
+ "Linked": "collegamento web",
+ "hashtag": "hash-tag",
+ "smile": "Sorridi",
+ "wink": "occhiolino",
+ "mentioning": "menzionando",
+ "sad face": "faccia triste",
+ "thinking emoji": "pensiero emoji",
+ "laughing": "ridendo",
+ "gender": "genere",
+ "He/Him": "Lui",
+ "She/Her": "Lei",
+ "girl": "ragazza",
+ "boy": "ragazzo",
+ "pronoun": "pronome",
+ "Type of instance": "Tipo di istanza",
+ "Security": "Sicurezza",
+ "Enabling broch mode": "L'attivazione della modalità Broch fornisce una fortificazione temporanea contro gli attacchi. Verranno accettati solo i post di istanze già note. Scade dopo una settimana.",
+ "Instance Settings": "Impostazioni istanza",
+ "Video Settings": "Impostazioni video",
+ "Filtering and Blocking": "Filtraggio e blocco",
+ "Role Assignment": "Assegnazione del ruolo",
+ "Background Images": "Immagini di sfondo",
+ "Contact Details": "Dettagli del contatto",
+ "heart": "cuore",
+ "counselor": "Consulente",
+ "Counselors": "Consiglieri",
+ "shocked": "scioccata",
+ "Encrypted": "Crittografato",
+ "Direct Message permitted instances": "Messaggio diretto istanze consentite",
+ "Direct messages are always allowed from these instances.": "I messaggi diretti sono sempre ammessi da questi casi.",
+ "Key Shortcuts": "Scorciatoie chiave",
+ "menuTimeline": "Vista della cronologia",
+ "menuEdit": "Modificare",
+ "menuProfile": "Visualizzazione del profilo",
+ "menuInbox": "Posta in arrivo",
+ "menuSearch": "Cerca / Segui",
+ "menuNewPost": "Nuovo post.",
+ "menuCalendar": "Calendario",
+ "menuDM": "Messaggi diretti",
+ "menuReplies": "Risposte",
+ "menuOutbox": "Inviata",
+ "menuBookmarks": "Segnalibri",
+ "menuShares": "Articoli condivisi",
+ "menuBlogs": "Blog",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Link internet",
+ "menuModeration": "Moderazione",
+ "menuFollowing": "A seguire",
+ "menuFollowers": "Seguaci",
+ "menuRoles": "Ruoli",
+ "menuSkills": "Competenze",
+ "menuLogout": "Disconnettersi",
+ "menuKeys": "Scorciatoie chiave",
+ "submitButton": "Invia il pulsante",
+ "menuMedia": "Media",
+ "followButton": "Segui il pulsante Segui / Unfollow",
+ "blockButton": "Blocco pulsante",
+ "infoButton": "Pulsante info",
+ "snoozeButton": "Pulsante snooze.",
+ "reportButton": "Pulsante report.",
+ "viewButton": "Visualizza il pulsante",
+ "enterPetname": "Inserisci PetName",
+ "enterNotes": "Inserisci le note",
+ "These access keys may be used": "Questi tasti di accesso possono essere utilizzati, in genere con tasto ALT + MAIUSC + o ALT + Key",
+ "Show numbers of accounts within instance metadata": "Mostra il numero di account all'interno dei metadati dell'istanza",
+ "Show version number within instance metadata": "Mostra il numero di versione nei metadati dell'istanza",
+ "Joined": "Unito",
+ "City for spoofed GPS image metadata": "Città per metadati di immagini GPS falsificate",
+ "Occupation": "Occupazione",
+ "Artists": "Artiste",
+ "Graphic Design": "Graphic design",
+ "Import Theme": "Tema dell'importazione",
+ "Export Theme": "Esportare tema",
+ "Custom post submit button text": "Pulsante di invio del post personalizzato",
+ "Blocked User Agents": "Agenti utente bloccati",
+ "Notify me when this account posts": "Avvisami quando questo account messaggi"
}
diff --git a/translations/ja.json b/translations/ja.json
index 03a83f83b..494661a86 100644
--- a/translations/ja.json
+++ b/translations/ja.json
@@ -65,7 +65,7 @@
"Create a new DM": "新しいDMを作成する",
"Switch to profile view": "縦断ビューに切り替え",
"Inbox": "受信トレイ",
- "Outbox": "送信トレイ",
+ "Sent": "送信済み",
"Search and follow": "検索してフォローする",
"Refresh": "リフレッシュ",
"Nickname or URL. Block using *@domain or nickname@domain": "ニックネームまたはURL。 * @ domainまたはnickname @ domainを使用してブロックする",
@@ -213,6 +213,7 @@
"Sensitive": "敏感",
"Word Replacements": "単語の置換",
"Happening Today": "今日",
+ "Happening Tomorrow": "明日",
"Happening This Week": "すぐに",
"Blog": "ブログ",
"Blogs": "ブログ",
@@ -370,5 +371,84 @@
"Publish a blog article": "ブログ記事を公開する",
"Featured writer": "注目の作家",
"Broch mode": "ブロッホモード",
- "Pixel": "ピクセル"
+ "Pixel": "ピクセル",
+ "DM bounce": "メッセージはフォローされているアカウントからのみ受け付けられます",
+ "Next": "次",
+ "Preview": "プレビュー",
+ "Linked": "ウェブリンク",
+ "hashtag": "ハッシュタグ",
+ "smile": "スマイル",
+ "wink": "ウィンク",
+ "mentioning": "言及する",
+ "sad face": "悲しい顔",
+ "thinking emoji": "絵文字を考える",
+ "laughing": "笑い",
+ "gender": "性別",
+ "He/Him": "彼",
+ "She/Her": "彼女",
+ "girl": "女の子",
+ "boy": "男の子",
+ "pronoun": "代名詞",
+ "Type of instance": "インスタンスのタイプ",
+ "Security": "セキュリティ",
+ "Enabling broch mode": "ブロッホモードを有効にすると、攻撃に対する一時的な要塞が提供されます。 既知のインスタンスによる投稿のみが受け入れられます。 一週間後に経過します。",
+ "Instance Settings": "インスタンス設定",
+ "Video Settings": "ビデオ設定",
+ "Filtering and Blocking": "フィルタリングとブロッキング",
+ "Role Assignment": "役割の割り当て",
+ "Background Images": "背景画像",
+ "Contact Details": "連絡先の詳細",
+ "heart": "ハート",
+ "counselor": "カウンセラー",
+ "Counselors": "カウンセラー",
+ "shocked": "ショックを受けた",
+ "Encrypted": "暗号化",
+ "Direct Message permitted instances": "直接メッセージ許可インスタンス",
+ "Direct messages are always allowed from these instances.": "直接メッセージは常にこれらのインスタンスから許可されています。",
+ "Key Shortcuts": "キーショートカット",
+ "menuTimeline": "タイムラインビュー",
+ "menuEdit": "編集する",
+ "menuProfile": "プロファイルビュー",
+ "menuInbox": "受信箱",
+ "menuSearch": "検索/フォロー",
+ "menuNewPost": "新しい投稿",
+ "menuCalendar": "カレンダー",
+ "menuDM": "ダイレクトメッセージ",
+ "menuReplies": "返信",
+ "menuOutbox": "送り返した",
+ "menuBookmarks": "ブックマーク",
+ "menuShares": "共有アイテム",
+ "menuBlogs": "ブログ",
+ "menuNewswire": "ニューワイヤー",
+ "menuLinks": "Webリンク",
+ "menuModeration": "節度",
+ "menuFollowing": "以下",
+ "menuFollowers": "フォロワー",
+ "menuRoles": "役割",
+ "menuSkills": "スキル",
+ "menuLogout": "ログアウト",
+ "menuKeys": "キーショートカット",
+ "submitButton": "送信ボタン",
+ "menuMedia": "メディア",
+ "followButton": "フォロー/フォローダウンボタン",
+ "blockButton": "ブロックボタン",
+ "infoButton": "情報ボタン",
+ "snoozeButton": "スヌーズボタン",
+ "reportButton": "レポートボタン",
+ "viewButton": "ボタンを見る",
+ "enterPetname": "PetNameを入力してください",
+ "enterNotes": "ノートを入力してください",
+ "These access keys may be used": "これらのアクセスキーは、通常はAlt + Shift +キーまたはAlt +キーを使用して使用できます。",
+ "Show numbers of accounts within instance metadata": "インスタンスメタデータ内のアカウント数を表示する",
+ "Show version number within instance metadata": "インスタンスメタデータ内にバージョン番号を表示する",
+ "Joined": "参加日",
+ "City for spoofed GPS image metadata": "なりすましGPS画像メタデータの都市",
+ "Occupation": "職業",
+ "Artists": "アーティスト",
+ "Graphic Design": "グラフィックデザイン",
+ "Import Theme": "輸入テーマ",
+ "Export Theme": "テーマをエクスポートします",
+ "Custom post submit button text": "カスタムポスト送信ボタンテキスト",
+ "Blocked User Agents": "ブロックされたユーザーエージェント",
+ "Notify me when this account posts": "この口座投稿を通知する"
}
diff --git a/translations/ku.json b/translations/ku.json
new file mode 100644
index 000000000..8d99ddd72
--- /dev/null
+++ b/translations/ku.json
@@ -0,0 +1,454 @@
+{
+ "SHOW MORE": "ZOREDETIR N SHOWAN DE",
+ "Your browser does not support the video tag.": "Geroka we nîşana vîdyoyê piştgirî nake.",
+ "Your browser does not support the audio tag.": "Geroka we nîşana deng piştgirî nake.",
+ "Show profile": "Profîl nîşan bide",
+ "Show options for this person": "Ji bo vî kesê vebijarkan nîşan bidin",
+ "Repeat this post": "Dûbare",
+ "Undo the repeat": "Dubarekirinê betal bikin",
+ "Like this post": "Çawa",
+ "Undo the like": "Berevajî",
+ "Delete this post": "Jêbirin",
+ "Delete this event": "Jêbirin",
+ "Reply to this post": "Bersiv",
+ "Write your post text below.": "Nûçe nû",
+ "Write your reply to": "Bersiva xwe binivîsin",
+ "this post": "ev post",
+ "Write your report below.": "Rapora xwe li jêr binivîse.",
+ "This message only goes to moderators, even if it mentions other fediverse addresses.": "Ev peyam tenê ji moderator re diçe, heke ew navnîşanên din ên federatê jî behs bike.",
+ "Also see": "Her weha bibînin",
+ "Terms of Service": "Mercên Xizmetê",
+ "Enter the details for your shared item below.": "Agahdariyên tiştê parvekirî yê xwe li jêr binivîse.",
+ "Subject or Content Warning (optional)": "Hişyariya Mijar an Naverok (vebijarkî)",
+ "Write something": "Tiştek binivîse",
+ "Name of the shared item": "Navê tiştê parvekirî",
+ "Description of the item being shared": "Danasîna tiştê hatî parve kirin",
+ "Type of shared item. eg. hat": "Cûreyek tiştê parvekirî. mînak. kûm",
+ "Category of shared item. eg. clothing": "Kategoriya tiştê parvekirî. mînak. lebas",
+ "Duration of listing in days": "Demjimara navnîşkirinê bi rojan",
+ "City or location of the shared item": "Bajar an cîhê tiştê parvekirî",
+ "Describe a shared item": "Tiştek parvekirî vebêjin",
+ "Public": "Alenî",
+ "Visible to anyone": "Ji her kesê re xuya ye",
+ "Unlisted": "Negirtî",
+ "Not on public timeline": "Ne li ser dema giştî ye",
+ "Followers": "Followers",
+ "Only to followers": "Tenê ji şagirtan re",
+ "DM": "DM",
+ "Only to mentioned people": "Tenê ji mirovên navborî re",
+ "Report": "Nûçe",
+ "Send to moderators": "Ji moderator re bişînin",
+ "Search for emoji": "Emoji bigerin",
+ "Cancel": "✘",
+ "Submit": "Nermijîn",
+ "Image description": "Danasîna wêneyê",
+ "Item image": "Wêneyê hêmanê",
+ "Type": "Awa",
+ "Category": "Liq",
+ "Location": "Cîh",
+ "Login": "Têkevin",
+ "Edit": "Weşandin",
+ "Switch to timeline view": "Dîtina demjimêrê",
+ "Approve": "Destûrdan",
+ "Deny": "Înkarkirin",
+ "Posts": "Sandin",
+ "Following": "Pêketînî",
+ "Followers": "Followers",
+ "Roles": "Rol",
+ "Skills": "Illsarezayî",
+ "Shares": "Parve dike",
+ "Block": "Deste",
+ "Unfollow": "Unfollow",
+ "Your browser does not support the audio element.": "Geroka we hêmana deng piştgirî nake.",
+ "Your browser does not support the video element.": "Geroka we hêmana vîdyoyê piştgirî nake.",
+ "Create a new post": "Nûçe nû",
+ "Create a new DM": "DM-ya nû çêbikin",
+ "Switch to profile view": "Dîtina profîlê",
+ "Inbox": "Inbox",
+ "Sent": "Andin",
+ "Search and follow": "Lêgerîn / şopandin",
+ "Refresh": "Hênikkirin",
+ "Nickname or URL. Block using *@domain or nickname@domain": "Nasnav an URL. Bikaranîna *@domain an navnîşa@domain asteng bikin",
+ "Remove the above item": "Tişta jorîn rakin",
+ "Remove": "Dûrxistin",
+ "Suspend the above account nickname": "Nasnavê hesabê jorîn bidin sekinandin",
+ "Suspend": "Dardekirin",
+ "Remove a suspension for an account nickname": "Ji bo navnîşek navnîşek hesabek rawestanê hilînin",
+ "Unsuspend": "Bêserûber kirin",
+ "Block an account on another instance": "Hesabek li mînakek din asteng bikin",
+ "Unblock": "Asteng bikin",
+ "Unblock an account on another instance": "Hesabek li mînakek din vekin",
+ "Information about current blocks/suspensions": "Agahdarî li ser blokan / rawestandinên heyî",
+ "Info": "Agahdarî",
+ "Remove": "Dûrxistin",
+ "Yes": "Erê",
+ "No": "Na",
+ "Delete this post?": "Vê posteyê jê bibe?",
+ "Follow": "Pêketin",
+ "Stop following": "Followingopandinê rawestînin",
+ "Options for": "Vebijarkên ji bo",
+ "View": "Dîtinî",
+ "Stop blocking": "Asteng bikin",
+ "Enter an emoji name to search for": "Ji bo lêgerînê navek emoji binivîse",
+ "Enter an address, shared item, !history, #hashtag, *skill or :emoji: to search for": "Navnîşanek, hêmanek parvekirî,! Dîrok, #hashtag, * jêhatî an: emoji: lêgerîn",
+ "Go Back": "◀",
+ "Moderation Information": "Agahdariya Moderatoriyê",
+ "Suspended accounts": "Hesabên rawestandî",
+ "These are currently suspended": "Vana niha têne rawestandin",
+ "Blocked accounts and hashtags": "Hesab û hashtagên astengkirî",
+ "These are globally blocked for all accounts on this instance": "Vana li seranserê cîhanê ji bo hemî hesabên li ser vê mînakê têne asteng kirin",
+ "Any blocks or suspensions made by moderators will be shown here.": "Astengkirin an rawestandinên ku ji hêla moderator ve hatine çêkirin dê li vir werin xuyang kirin.",
+ "Welcome. Please enter your login details below.": "Bi xêr hatî. Ji kerema xwe hûrguliyên têketina xwe li jêr binivîsin.",
+ "Welcome. Please login or register a new account.": "Bi xêr hatî. Ji kerema xwe têkevin an hesabek nû tomar bikin.",
+ "Please enter some credentials": "Ji kerema xwe çend pêbaweriyan binivîsin",
+ "You will become the admin of this site.": "Hûn ê bibin rêveberê vê malperê.",
+ "Terms of Service": "Mercên Xizmetê",
+ "About this Instance": "Li ser vê Mînakê",
+ "Nickname": "Nasnav",
+ "Enter Nickname": "Navnîşan bikin",
+ "Password": "Şîfre",
+ "Enter Password": "Şifreyê têke",
+ "Profile for": "Profîl ji bo",
+ "The files attached below should be no larger than 10MB in total uploaded at once.": "Pelên ku li jêr hatine vegirtin divê bi tevahî di yek carekî de barkirî ji 10 MB mezintir nebin.",
+ "Avatar image": "Wêneyê Avatar",
+ "Background image": "Wêneyê paşnavê, ku li pişt avatar te xuya dike",
+ "Timeline banner image": "Wêneya banner a timeline",
+ "Approve follower requests": "Daxwazên şopînerê dipejirînin",
+ "This is a bot account": "Ev hesabek bot e",
+ "Filtered words": "Gotinên parzûnkirî",
+ "One per line": "Her rêzek yek",
+ "Blocked accounts": "Hesabên astengkirî",
+ "Blocked accounts, one per line, in the form nickname@domain or *@blockeddomain": "Hesabên blokkirî, yek her rêzik, di forma nickname@domain an *@Blockdomain",
+ "Federation list": "Lîsteya federasyonê",
+ "Federate only with a defined set of instances. One domain name per line.": "Federasyon tenê bi diyardeyek diyarkirî ya mînakan. Navê navnîşê yek rêzê.",
+ "If you want to participate within organizations then you can indicate some skills that you have and approximate proficiency levels. This helps organizers to construct teams with an appropriate combination of skills.": "Heke hûn dixwazin di nav rêxistinan de beşdar bibin wê hingê hûn dikarin hin behreyên ku we hene nîşan bikin û astên jêhatîbûnê yên nêzikî jî nîşan bikin. Ev ji organîzatoran re dibe alîkar ku tîmên bi têkeliyek guncan a behreyan çêbikin.",
+ "A list of moderator nicknames. One per line.": "Navnîşek navên navên moderator. Her rêzek yek.",
+ "Moderators": "Moderator",
+ "List of moderator nicknames": "Navnîşê navên navdêr",
+ "Your bio": "Bîyo te",
+ "Skill": "Jîrî",
+ "Copy the text then paste it into your post": "Nivîsarê kopî bikin û paşê li navnîşa xwe bişînin",
+ "Emoji Search": "Emoji Search",
+ "No results": "Encam tune",
+ "Skills search": "Lêgerîna şiyanan",
+ "Shared Items Search": "Lêgerîna Tiştikên Hevpar",
+ "Contact": "Têkelî",
+ "Shared Item": "Tişta Parvekirî",
+ "Mod": "Navînî",
+ "Approve follow requests": "Daxwazên şopandinê dipejirînin",
+ "Page down": "Rûpel daket",
+ "Page up": "Rûpel",
+ "Vote": "Deng",
+ "Replies": "Bersiv dide",
+ "Media": "Medya",
+ "This is a group account": "Ev hesabek komê ye",
+ "Date": "Rojek",
+ "Time": "Dem",
+ "Location": "Cîh",
+ "Calendar": "Salname",
+ "Sun": "Tav",
+ "Mon": "Duş",
+ "Tue": "Sêş",
+ "Wed": "Çar",
+ "Thu": "Pên",
+ "Fri": "În",
+ "Sat": "Şem",
+ "January": "Rêbendan",
+ "February": "Reşemî",
+ "March": "Adar",
+ "April": "Avrêl",
+ "May": "Gulan",
+ "June": "Pûşper",
+ "July": "Tîrmeh",
+ "August": "Tebax",
+ "September": "Îlon",
+ "October": "Cotmeh",
+ "November": "Mijdar",
+ "December": "Berfanbar",
+ "Only people I follow can send me DMs": "Tenê kesên ku ez dişopînim dikarin DM ji min re bişînin",
+ "Logout": "Derkeve",
+ "Danger Zone": "Qada Xetereyê",
+ "Deactivate this account": "Vê hesabê deaktîv bikin",
+ "Snooze": "Snooze",
+ "Unsnooze": "Betalkirin",
+ "Donations link": "Zencîreya bexşan",
+ "Donate": "Bêşdan",
+ "Change Password": "Îfreyê biguherînin",
+ "Confirm Password": "di pêşîyê da em sipas dikin",
+ "Instance Title": "Sernavê Instance",
+ "Instance Short Description": "Danasîna Kurte ya Nimûne",
+ "Instance Description": "Danasîna Bûyerê",
+ "Instance Logo": "Logo-ya Nimûne",
+ "Bookmark this post": "Viya ji bo dîtina paşê hilînin",
+ "Undo the bookmark": "Nîşankirin",
+ "Bookmarks": "nîşankirin",
+ "Theme": "Mijad",
+ "Default": "Destçûnî",
+ "Light": "Sivik",
+ "Purple": "Mor",
+ "Hacker": "Hacker",
+ "HighVis": "SilavVis",
+ "Question": "Pirs",
+ "Enter your question": "Pirsa xwe têkevinê",
+ "Enter the choices for your question below.": "Hilbijarkên ji bo pirsa xwe li jêr binivîse.",
+ "Ask a question": "Pirsek bipirsin",
+ "Possible answers": "Bersivên gengaz",
+ "replying to": "bersivandin",
+ "replying to themselves": "bersiva xwe didin",
+ "announces": "îlan dike",
+ "Previous month": "Meha berê",
+ "Next month": "Meha bê",
+ "Get the source code": "Koda çavkaniyê bistînin",
+ "This is a media instance": "Ev mînakek medyayê ye",
+ "Mute this post": "Bêdeng",
+ "Undo mute": "Bêdengiyê betal bike",
+ "XMPP": "XMPP",
+ "Matrix": "Matrix",
+ "Email": "E-nameyê bişînin",
+ "PGP": "PGP Key",
+ "PGP Fingerprint": "PGP Şopa tilî",
+ "This is a scheduled post.": "Ev peyamek plansazkirî ye.",
+ "Remove scheduled posts": "Mesajên plansazkirî rakin",
+ "Remove Twitter posts": "Mesajên Twitter-ê hilweşînin",
+ "Sensitive": "Pêketî",
+ "Word Replacements": "Veguheztinên Peyvan",
+ "Happening Today": "Îro",
+ "Happening Tomorrow": "Sibê",
+ "Happening This Week": "Nêzda",
+ "Blog": "Blog",
+ "Blogs": "Blogs",
+ "Title": "Nav",
+ "About the author": "Der barê nivîskar de",
+ "Edit blog post": "Posta tevnvîsê biguherînin",
+ "Publicly visible post": "Postê bi gelemperî xuya dike",
+ "Your Posts": "Mesajên We",
+ "Git Projects": "Projeyên Git",
+ "List of project names that you wish to receive git patches for": "Navnîşa navên projeyê ku hûn dixwazin ji bo wan pîneyên git bistînin",
+ "Show/Hide Buttons": "Nîşan/Veşêrin",
+ "Custom Font": "Custom Font",
+ "Remove the custom font": "Ponta xwerû hilînin",
+ "Lcd": "LCD",
+ "Blue": "Şîn",
+ "Zen": "Zen",
+ "Night": "Şev",
+ "Starlight": "Ronahiya stêrkan",
+ "Search banner image": "Wêneyê pankarta lêgerînê",
+ "Henge": "Henge",
+ "QR Code": "QR Code",
+ "Reminder": "Bîranîn",
+ "Scheduled note to yourself": "Ji xwe re nota plansazkirî",
+ "Replying to": "Bersiv didin",
+ "Send to": "Send to",
+ "Show a list of addresses to send to": "Navnîşek navnîşan nîşan bikin ku bişînin",
+ "Petname": "Navê pet",
+ "Ok": "Ok",
+ "This is nothing less than an utter triumph": "Ev ji serfiraziyek bêkêmasî ne tiştek e",
+ "Not Found": "Peyda nebû",
+ "These are not the droids you are looking for": "Ev droîdên ku hûn lê digerin ne",
+ "Not changed": "Neguherî",
+ "The contents of your local cache are up to date": "Naveroka cacheya weya herêmî rojane ne",
+ "Bad Request": "Daxwaza Xerab",
+ "Better luck next time": "Carek din bextê çêtir",
+ "Unavailable": "Nediyar",
+ "The server is busy. Please try again later": "Pêşkêşker mijûl e. Ji kerema xwe paşê dîsa biceribînin",
+ "Receive calendar events from this account": "Bûyerên salnameyê ji vê hesabê bistînin",
+ "Grayscale": "Grayscale",
+ "Liked by": "Ji te hez kirin",
+ "Solidaric": "Hevgirtin",
+ "YouTube Replacement Domain": "Domain Replacement YouTube",
+ "Notes": "Nîşe",
+ "Allow replies.": "Destûrê bide bersivan.",
+ "Event": "Bûyer",
+ "Event name": "Navê bûyerê",
+ "Events": "Bûyerên",
+ "Create an event": "Bûyerek çêbikin",
+ "Describe the event": "Bûyerê vebêjin",
+ "Start Date": "Dîroka Destpêkê",
+ "End Date": "Dîroka Dawiyê",
+ "Categories": "Kategorî",
+ "This is a private event.": "Ev bûyerek taybet e.",
+ "Allow anonymous participation.": "Beşdariya bênav destûr bidin.",
+ "Anyone can join": "Her kes dikare tevlî bibe",
+ "Apply to join": "Serlêdana tevlîbûnê bikin",
+ "Invitation only": "Tenê vexwendin",
+ "Joining": "Tevlêbûn",
+ "Status of the event": "Rewşa bûyerê",
+ "Tentative": "Demok",
+ "Confirmed": "Piştrast kirin",
+ "Cancelled": "Hat betalkirin",
+ "Event banner image description": "Danasîna wêneya pankarta bûyerê",
+ "Banner image": "Wêneyê banner",
+ "Maximum attendees": "Beşdarên herî zêde",
+ "Ticket URL": "URL-ya bilêtê",
+ "Create a new event": "Bûyerek nû çêbikin",
+ "Moderation policy or code of conduct": "Polîtîkaya moderatoriyê an koda tevgerê",
+ "Edit event": "Bûyerê biguherînin",
+ "Notify when posts are liked": "Dema ku şandin têne ecibandin agahdar bikin",
+ "Don't show the Like button": "Bişkoja Like nîşan nedin",
+ "Autogenerated Hashtags": "Autogenerated Hashtags",
+ "Autogenerated Content Warnings": "Hişyariyên Naverokê yên otogenerakirî",
+ "Indymedia": "Indymedia",
+ "Indymediaclassic": "Indymedia Klasîk",
+ "Indymediamodern": "Indymedia Rojane",
+ "Hashtag Blocked": "Hashtag Blocked",
+ "This is a blogging instance": "Ev mînakek tevnvîsînê ye",
+ "Edit Links": "Zencîreyan Biguherîne",
+ "One link per line. Description followed by the link.": "Her rêzek zencîrek. Danasîn bi lînkê hatî şandin. Divê sernav bi # dest pê bikin",
+ "Left column image": "Wêneyê stûna çepê",
+ "Right column image": "Wêneya stûna rast",
+ "RSS feed for this site": "RSS feed ji bo vê malperê",
+ "Edit newswire": "Newswire biguherîne",
+ "Add RSS feed links below.": "Zencîreyên RSS-yên jêrîn. Di destpêk an daviyê de * lê zêde bikin da ku diyar bikin ku pêdivî ye ku xwarinek were moder kirin. Add a! di destpêkê de an diqedîne da ku diyar bike ku divê naveroka xwarinê were neynik kirin.",
+ "Newswire RSS Feed": "Newswire RSS Feed",
+ "Nicknames whose blog entries appear on the newswire.": "Navên ku navnîşên wan ên tevnvîsê li ser nûçegihanê têne xuyang kirin.",
+ "Posts to be approved": "Postên bêne pejirandin",
+ "Discuss": "Hevaxaftin",
+ "Moderator Discussion": "Gotûbêja Moderator",
+ "Vote": "Deng",
+ "Remove Vote": "Deng rakin",
+ "This is a news instance": "Ev mînakek nûçeyê ye",
+ "News": "Nûçe",
+ "Read more...": "Bêtir bixwînin...",
+ "Edit News Post": "Nûçeya Nûçeyê Biguherîne",
+ "A list of editor nicknames. One per line.": "Navnîşek navên navnîşên edîtor. Her rêzek yek.",
+ "Site Editors": "Edîtorên Malperê",
+ "Allow news posts": "Destûrê bide nûçegihanan",
+ "Publish": "Weşandin",
+ "Publish a news article": "Gotarek nûçe weşandin",
+ "News tagging rules": "Qanûnên nîşankirina nûçeyan",
+ "See instructions": "Rêwerzan bibînin",
+ "Search": "Gerr",
+ "Newswire": "Newswire",
+ "Links": "Zencîre",
+ "Post": "Koz",
+ "User": "Bikaranîvan",
+ "Features" : "Taybetmendî",
+ "Article": "Tişt",
+ "Create an article": "Gotarek çêbikin",
+ "Settings": "Mîhengên",
+ "Citations": "Citations",
+ "Choose newswire items referenced in your article": "Tiştên newswire yên ku di gotara we de hatine referansandin hilbijêrin",
+ "RSS feed for your blog": "RSS-ê ji bo tevnvîsa we",
+ "Create a new shared item": "Tiştek nû ya hevbeş çêbikin",
+ "Rc3": "Rc3",
+ "Hashtag origins": "Kokên Hashtag",
+ "admin": "admin",
+ "moderator": "moderator",
+ "editor": "weşanvan",
+ "delegator": "delege",
+ "Debian": "Debian",
+ "Select the edit icon to add RSS feeds": "Îkona guherandinê hilbijêrin da ku RSS-ê zêde bikin",
+ "Select the edit icon to add web links": "Ji bo zêdekirina girêdanên tevnê îkona guherandinê hilbijêrin",
+ "Hashtag Categories RSS Feed": "Kategoriyên Hashtag RSS Feed",
+ "Ask about a shared item.": "Li ser tiştek parvekirî bipirsin.",
+ "Account Information": "Agahdariya Hesabê",
+ "This account interacts with the following instances": "Ev hesab bi nimûneyên jêrîn re têkildar dibe",
+ "News posts are moderated": "Mesajên nûçeyan têne moderator kirin",
+ "Filter": "Parzûn",
+ "Filter out words": "Gotinan parzûn bikin",
+ "Unfilter": "Fîlterkirin",
+ "Unfilter words": "Gotinên bêfîlter",
+ "Show Accounts": "Hesaban nîşan bide",
+ "Peertube Instances": "Mînakên Peertube",
+ "Show video previews for the following Peertube sites.": "Pêşniyarên vîdyoyê ji bo malperên Peertube yên jêrîn nîşan bidin.",
+ "Follows you": "Li dû we tê",
+ "Verify all signatures": "Hemî îmzeyan rast bikin",
+ "Blocked followers": "Şopînerên bloke kirin",
+ "Blocked following": "Li pey asteng kirin",
+ "Receives posts from the following accounts": "Ji hesabên jêrîn şandiyan distîne",
+ "Sends out posts to the following accounts": "Ji bo hesabên jêrîn şandiyan dişîne",
+ "Word frequencies": "Frekansên peyvan",
+ "New account": "Hesabê nû",
+ "Moved to new account address": "Veguhestin navnîşana hesabê nû",
+ "Yet another Epicyon Instance": "Dîsa Dîsa Epîyonek Din",
+ "Other accounts": "Hesabên din ên federasyonê",
+ "Pin this post to your profile.": "Vê postê bi profîla xwe ve pin bikin.",
+ "Administered by": "Bi rêve kirin",
+ "Version": "Awa",
+ "Skip to timeline": "Derbasî demjimêrê bibin",
+ "Skip to Newswire": "Skip to Newswire",
+ "Skip to Links": "Derbarê Zencîreyên Tevne",
+ "Publish a blog article": "Gotarek blogê belav bikin",
+ "Featured writer": "Nivîskarê bijare",
+ "Broch mode": "Moda broşeyê",
+ "Pixel": "Pixel",
+ "DM bounce": "Peyam tenê ji hesabên şopandî têne qebûl kirin",
+ "Next": "Piştî",
+ "Preview": "Pêşnerîn",
+ "Linked": "Tevne girêdan",
+ "hashtag": "hash-tag",
+ "smile": "kenn",
+ "wink": "çavqûrçî",
+ "mentioning": "behs kirin",
+ "sad face": "rûyê xemgîn",
+ "thinking emoji": "emojî difikirin",
+ "laughing": "dikenin",
+ "gender": "zayendî",
+ "He/Him": "Ew/Wî",
+ "She/Her": "Ew/Wê",
+ "girl": "keç",
+ "boy": "xort",
+ "pronoun": "pronav",
+ "Type of instance": "Cûreyek nimûne",
+ "Security": "Ewlekarî",
+ "Enabling broch mode": "Modela broşeyê çalakkirin li dijî êrîşê kelekbûnek demkî peyda dike. Tenê mesajên ji hêla nimûneyên berê ve têne zanîn dê bêne qebûl kirin. Heke neyê vemirandin, ew piştî hefteyek derbas dibe.",
+ "Instance Settings": "Mîhengên Instance",
+ "Video Settings": "Vebijarkên Vîdyoyê",
+ "Filtering and Blocking": "Fîlterkirin û Astengkirin",
+ "Role Assignment": "Erk Rol",
+ "Contact Details": "Agahdariyên Têkiliyê",
+ "Background Images": "Wêneyên Paşê",
+ "heart": "dil",
+ "counselor": "Pêşnîyarvan",
+ "Counselors": "Selêwirmendan",
+ "shocked": "şok kirin",
+ "Encrypted": "Encîfre kirin",
+ "Direct Message permitted instances": "Peyama rasterast destûrê",
+ "Direct messages are always allowed from these instances.": "Peyamên rasterast her gav ji van deman têne destûr kirin.",
+ "Key Shortcuts": "Kurteyên Key",
+ "menuTimeline": "Dîtina Timeline",
+ "menuEdit": "Weşandin",
+ "menuProfile": "View Profîl",
+ "menuInbox": "Inbott",
+ "menuSearch": "Lêgerîn / bişopîne",
+ "menuNewPost": "Peyama nû",
+ "menuCalendar": "Salname",
+ "menuDM": "Peyamên rasterast",
+ "menuReplies": "Bersiv",
+ "menuOutbox": "Şandin",
+ "menuBookmarks": "Emîrê",
+ "menuShares": "Tiştên parvekirî",
+ "menuBlogs": "Blogs",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Girêdanên malperê",
+ "menuModeration": "Nermbûn",
+ "menuFollowing": "Pêketînî",
+ "menuFollowers": "Followers",
+ "menuRoles": "Roles",
+ "menuSkills": "Şarezayên",
+ "menuLogout": "Derkeve",
+ "menuKeys": "Kurteyên Key",
+ "submitButton": "Bişkojka bişînin",
+ "menuMedia": "Medya",
+ "followButton": "Bişkojka bişopînin / Nexşe",
+ "blockButton": "Bişkojka Block",
+ "infoButton": "Bişkoja INFO",
+ "snoozeButton": "Bişkojka Snooze",
+ "reportButton": "Bişkoja Report",
+ "viewButton": "Bişkoja View",
+ "enterPetname": "Porê binivîse",
+ "enterNotes": "Nîşan binivîse",
+ "These access keys may be used": "Dibe ku ev keysên gihîştinê bikar bînin, bi gelemperî bi alt + shift + key an alt + key",
+ "Show numbers of accounts within instance metadata": "Di nav metadata mînakê de hejmarên hesaban nîşan bidin",
+ "Show version number within instance metadata": "Di nav metadata mînakê de nimreya guhertoyê nîşan bide",
+ "Joined": "Beşdarbûna Dîrokê",
+ "City for spoofed GPS image metadata": "Bajar ji bo metadata wêneya GPS ya xapînok",
+ "Occupation": "Sinet",
+ "Artists": "Hunermend",
+ "Graphic Design": "Sêwirana grafîkî",
+ "Import Theme": "Mijara Import",
+ "Export Theme": "Mijara Export",
+ "Custom post submit button text": "Nivîsa bişkojka paşîn a paşîn",
+ "Blocked User Agents": "Karmendên bikarhêner asteng kirin",
+ "Notify me when this account posts": "Dema ku ev postên hesabê min agahdar bikin"
+}
diff --git a/translations/oc.json b/translations/oc.json
index 1221d8e4d..411216f8a 100644
--- a/translations/oc.json
+++ b/translations/oc.json
@@ -116,7 +116,7 @@
"Remove": "Suprimir",
"Refresh": "Actualizar",
"Search and follow": "Cercar e seguir",
- "Outbox": "Enviats",
+ "Sent": "Enviats",
"Inbox": "Recepcion",
"Switch to profile view": "Passar a la vista perfil",
"Create a new DM": "Crear un messatge dirèct nòu",
@@ -208,7 +208,8 @@
"Remove Twitter posts": "Remove Twitter posts",
"Sensitive": "Sensitive",
"Word Replacements": "Word Replacements",
- "Happening Today": "Happening Today",
+ "Happening Today": "Today",
+ "Happening Tomorrow": "Tomorrow",
"Happening This Week": "Soon",
"Blog": "Blog",
"Blogs": "Blogs",
@@ -356,7 +357,7 @@
"New account": "New account",
"Moved to new account address": "Moved to new account address",
"Yet another Epicyon Instance": "Yet another Epicyon Instance",
- "Other accounts": "Other accounts",
+ "Other accounts": "Other fediverse accounts",
"Pin this post to your profile.": "Pin this post to your profile.",
"Administered by": "Administered by",
"Version": "Version",
@@ -366,5 +367,84 @@
"Publish a blog article": "Publish a blog article",
"Featured writer": "Featured writer",
"Broch mode": "Broch mode",
- "Pixel": "Pixel"
+ "Pixel": "Pixel",
+ "DM bounce": "Messages are only accepted from followed accounts",
+ "Next": "Next",
+ "Preview": "Preview",
+ "Linked": "Web link",
+ "hashtag": "hash-tag",
+ "smile": "smile",
+ "wink": "wink",
+ "mentioning": "mentioning",
+ "sad face": "sad face",
+ "thinking emoji": "thinking emowji",
+ "laughing": "laughing",
+ "gender": "gender",
+ "He/Him": "He/Him",
+ "She/Her": "She/Her",
+ "girl": "girl",
+ "boy": "boy",
+ "pronoun": "pronoun",
+ "Type of instance": "Type of instance",
+ "Security": "Security",
+ "Enabling broch mode": "Enabling broch mode provides a temporary fortification against attack. Only posts by already known instances will be accepted. If not turned off, it elapses after a week.",
+ "Instance Settings": "Instance Settings",
+ "Video Settings": "Video Settings",
+ "Filtering and Blocking": "Filtering and Blocking",
+ "Role Assignment": "Role Assignment",
+ "Background Images": "Background Images",
+ "Contact Details": "Contact Details",
+ "heart": "heart",
+ "counselor": "Counselors",
+ "Counselors": "Counselors",
+ "shocked": "shocked",
+ "Encrypted": "Encrypted",
+ "Direct Message permitted instances": "Direct Message permitted instances",
+ "Direct messages are always allowed from these instances.": "Direct messages are always allowed from these instances.",
+ "Key Shortcuts": "Key Shortcuts",
+ "menuTimeline": "Timeline view",
+ "menuEdit": "Edit",
+ "menuProfile": "Profile view",
+ "menuInbox": "Inbox",
+ "menuSearch": "Search/follow",
+ "menuNewPost": "New post",
+ "menuCalendar": "Calendar",
+ "menuDM": "Direct Messages",
+ "menuReplies": "Replies",
+ "menuOutbox": "Sent",
+ "menuBookmarks": "Bookmarks",
+ "menuShares": "Shared items",
+ "menuBlogs": "Blogs",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Links",
+ "menuModeration": "Moderation",
+ "menuFollowing": "Following",
+ "menuFollowers": "Followers",
+ "menuRoles": "Roles",
+ "menuSkills": "Skills",
+ "menuLogout": "Logout",
+ "menuKeys": "Key Shortcuts",
+ "submitButton": "Submit button",
+ "menuMedia": "Media",
+ "followButton": "Follow/unfollow button",
+ "blockButton": "Block button",
+ "infoButton": "Info button",
+ "snoozeButton": "Snooze button",
+ "reportButton": "Report button",
+ "viewButton": "View button",
+ "enterPetname": "Enter petname",
+ "enterNotes": "Enter notes",
+ "These access keys may be used": "These access keys may be used, typically with ALT + SHIFT + key or ALT + key",
+ "Show numbers of accounts within instance metadata": "Show numbers of accounts within instance metadata",
+ "Show version number within instance metadata": "Show version number within instance metadata",
+ "Joined": "Joined",
+ "City for spoofed GPS image metadata": "City for spoofed GPS image metadata",
+ "Occupation": "Occupation",
+ "Artists": "Artists",
+ "Graphic Design": "Graphic Design",
+ "Import Theme": "Import Theme",
+ "Export Theme": "Export Theme",
+ "Custom post submit button text": "Custom post submit button text",
+ "Blocked User Agents": "Blocked User Agents",
+ "Notify me when this account posts": "Notify me when this account posts"
}
diff --git a/translations/pt.json b/translations/pt.json
index 83d726b1b..8d05812de 100644
--- a/translations/pt.json
+++ b/translations/pt.json
@@ -64,8 +64,8 @@
"Create a new post": "Crie uma nova postagem",
"Create a new DM": "Crie uma nova mensagem direta",
"Switch to profile view": "Mudar para a vista de perfil",
- "Inbox": "Caixa de entrada",
- "Outbox": "Caixa de fora",
+ "Inbox": "Entrada",
+ "Sent": "Enviado",
"Search and follow": "Pesquise e siga",
"Refresh": "Atualizar",
"Nickname or URL. Block using *@domain or nickname@domain": "Apelido ou URL. Bloquear usando *@domain ou apelido@domain",
@@ -213,6 +213,7 @@
"Sensitive": "Sensível",
"Word Replacements": "Substituições do Word",
"Happening Today": "Hoje",
+ "Happening Tomorrow": "Amanhã",
"Happening This Week": "Em breve",
"Blog": "Blog",
"Blogs": "Blogs",
@@ -370,5 +371,84 @@
"Publish a blog article": "Publique um artigo de blog",
"Featured writer": "Escritor em destaque",
"Broch mode": "Modo broch",
- "Pixel": "Pixel"
+ "Pixel": "Pixel",
+ "DM bounce": "Mensagens são aceitas apenas de contas seguidas",
+ "Next": "Próxima",
+ "Preview": "Antevisão",
+ "Linked": "link da web",
+ "hashtag": "hash-tag",
+ "smile": "sorrir",
+ "wink": "piscar",
+ "mentioning": "mencionando",
+ "sad face": "rosto triste",
+ "thinking emoji": "pensando emowji",
+ "laughing": "rindo",
+ "gender": "gênero",
+ "He/Him": "Ele",
+ "She/Her": "Ela",
+ "girl": "garota",
+ "boy": "garoto",
+ "pronoun": "pronome",
+ "Type of instance": "Tipo de instância",
+ "Security": "Segurança",
+ "Enabling broch mode": "Habilitar o modo broch fornece uma fortificação temporária contra ataques. Somente postagens de instâncias já conhecidas serão aceitas. Decorre depois de uma semana.",
+ "Instance Settings": "Configurações de instância",
+ "Video Settings": "Configurações de vídeo",
+ "Filtering and Blocking": "Filtragem e Bloqueio",
+ "Role Assignment": "Atribuição de Função",
+ "Background Images": "Imagens de fundo",
+ "Contact Details": "Detalhes do contato",
+ "heart": "coração",
+ "counselor": "Conselheira",
+ "Counselors": "Conselheiras",
+ "shocked": "chocada",
+ "Encrypted": "Criptografada",
+ "Direct Message permitted instances": "Mensagens diretas permitidas instâncias",
+ "Direct messages are always allowed from these instances.": "Mensagens diretas são sempre permitidas a partir dessas instâncias.",
+ "Key Shortcuts": "Atalhos-chave",
+ "menuTimeline": "Vista da linha do tempo",
+ "menuEdit": "Editar",
+ "menuProfile": "Vista de perfil",
+ "menuInbox": "Caixa de entrada",
+ "menuSearch": "Pesquisa / Siga",
+ "menuNewPost": "Nova postagem",
+ "menuCalendar": "Calendário",
+ "menuDM": "Mensagens diretas",
+ "menuReplies": "Respostas",
+ "menuOutbox": "Enviei",
+ "menuBookmarks": "Favoritas",
+ "menuShares": "Itens compartilhados",
+ "menuBlogs": "Blogs",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Links da Web",
+ "menuModeration": "Moderação",
+ "menuFollowing": "Seguindo",
+ "menuFollowers": "Seguidoras",
+ "menuRoles": "Papéis",
+ "menuSkills": "Habilidades",
+ "menuLogout": "Sair",
+ "menuKeys": "Atalhos-chave",
+ "submitButton": "Botão de envio",
+ "menuMedia": "meios de comunicação",
+ "followButton": "Siga / Deixar botão",
+ "blockButton": "Botão de bloco",
+ "infoButton": "Botão de informação",
+ "snoozeButton": "Botão Snooze",
+ "reportButton": "Botão de relatório",
+ "viewButton": "Botão de visualização",
+ "enterPetname": "Digite Petname",
+ "enterNotes": "Digite notas",
+ "These access keys may be used": "Essas teclas de acesso podem ser usadas, normalmente com tecla Alt + Shift + Key ou Alt +",
+ "Show numbers of accounts within instance metadata": "Mostra o número de contas nos metadados da instância",
+ "Show version number within instance metadata": "Mostrar o número da versão nos metadados da instância",
+ "Joined": "Data juntada",
+ "City for spoofed GPS image metadata": "Cidade para metadados de imagem GPS falsificados",
+ "Occupation": "Ocupação",
+ "Artists": "Artistas",
+ "Graphic Design": "Design gráfico",
+ "Import Theme": "Importar tema",
+ "Export Theme": "Exportar tema",
+ "Custom post submit button text": "Texto de botão de envio de post personalizado",
+ "Blocked User Agents": "Agentes de usuário bloqueados",
+ "Notify me when this account posts": "Notifique-me quando esta conta posts"
}
diff --git a/translations/ru.json b/translations/ru.json
index 93e5e2d6b..4663cef78 100644
--- a/translations/ru.json
+++ b/translations/ru.json
@@ -65,7 +65,7 @@
"Create a new DM": "Создать новое прямое сообщение",
"Switch to profile view": "Переключиться на вид профиля",
"Inbox": "входящие",
- "Outbox": "Исходящие",
+ "Sent": "Отправлено",
"Search and follow": "Искать и следовать",
"Refresh": "обновление",
"Nickname or URL. Block using *@domain or nickname@domain": "Псевдоним или URL. Блокировка с использованием *@domain или псевдоним@domain",
@@ -213,6 +213,7 @@
"Sensitive": "чувствительный",
"Word Replacements": "Замены слов",
"Happening Today": "Cегодня",
+ "Happening Tomorrow": "Завтра",
"Happening This Week": "Скоро",
"Blog": "Блог",
"Blogs": "Блоги",
@@ -370,5 +371,84 @@
"Publish a blog article": "Опубликовать статью в блоге",
"Featured writer": "Избранный писатель",
"Broch mode": "Брош режим",
- "Pixel": "Пиксель"
+ "Pixel": "Пиксель",
+ "DM bounce": "Сообщения принимаются только от следующих аккаунтов",
+ "Next": "Следующий",
+ "Preview": "Предварительный просмотр",
+ "Linked": "интернет-ссылка",
+ "hashtag": "хэштег",
+ "smile": "улыбка",
+ "wink": "подмигивание",
+ "mentioning": "упоминание",
+ "sad face": "грустное лицо",
+ "thinking emoji": "думающий смайлик",
+ "laughing": "смеющийся",
+ "gender": "Пол",
+ "He/Him": "Он/Его",
+ "She/Her": "Она/Ее",
+ "girl": "девочка",
+ "boy": "мальчик",
+ "pronoun": "местоимение",
+ "Type of instance": "Тип экземпляра",
+ "Security": "Безопасность",
+ "Enabling broch mode": "Включение режима брошюры обеспечивает временную защиту от атак. Будут приниматься только сообщения от уже известных экземпляров. Проходит через неделю.",
+ "Instance Settings": "Настройки экземпляра",
+ "Video Settings": "Настройки видео",
+ "Filtering and Blocking": "Фильтрация и блокировка",
+ "Role Assignment": "Назначение ролей",
+ "Background Images": "Фоновые изображения",
+ "Contact Details": "Контактная информация",
+ "heart": "сердце",
+ "counselor": "Советник",
+ "Counselors": "Советники",
+ "shocked": "потрясенный",
+ "Encrypted": "Зашифрованный",
+ "Direct Message permitted instances": "Прямое сообщение разрешено экземпляры",
+ "Direct messages are always allowed from these instances.": "Прямые сообщения всегда допускаются из этих экземпляров.",
+ "Key Shortcuts": "Клавичные ярлыки",
+ "menuTimeline": "Сроки зрения",
+ "menuEdit": "Редактировать",
+ "menuProfile": "вид профиля",
+ "menuInbox": "Входящие",
+ "menuSearch": "Поиск / следующее",
+ "menuNewPost": "Новый пост",
+ "menuCalendar": "Календарь",
+ "menuDM": "Прямые сообщения",
+ "menuReplies": "Отвечает",
+ "menuOutbox": "Отправил",
+ "menuBookmarks": "Закладки",
+ "menuShares": "Общие предметы",
+ "menuBlogs": "Блоги",
+ "menuNewswire": "Новобранец",
+ "menuLinks": "веб ссылки",
+ "menuModeration": "На модерации",
+ "menuFollowing": "Следующий",
+ "menuFollowers": "Подписчики",
+ "menuRoles": "Роли",
+ "menuSkills": "Навыки и умения",
+ "menuLogout": "Выйти",
+ "menuKeys": "Клавичные ярлыки",
+ "submitButton": "Отправить кнопку",
+ "menuMedia": "СМИ",
+ "followButton": "Следуйте / отписаться кнопка",
+ "blockButton": "Кнопка блокировки",
+ "infoButton": "Информация Кнопка",
+ "snoozeButton": "Кнопка сножения",
+ "reportButton": "Кнопка отчета",
+ "viewButton": "Кнопка просмотра",
+ "enterPetname": "Введите petname",
+ "enterNotes": "Введите ноты",
+ "These access keys may be used": "Эти ключевые ключи доступа могут быть использованы, обычно с ALT + Shift + Key или Alt + Key",
+ "Show numbers of accounts within instance metadata": "Показать количество учетных записей в метаданных экземпляра",
+ "Show version number within instance metadata": "Показать номер версии в метаданных экземпляра",
+ "Joined": "Присоединенная дата",
+ "City for spoofed GPS image metadata": "Город для поддельных метаданных изображения GPS",
+ "Occupation": "Занятие",
+ "Artists": "Художники",
+ "Graphic Design": "Графический дизайн",
+ "Import Theme": "Импортировать тему",
+ "Export Theme": "Экспортная тема",
+ "Custom post submit button text": "Пользовательский пост Отправить кнопку текста",
+ "Blocked User Agents": "Заблокированные пользовательские агенты",
+ "Notify me when this account posts": "Сообщите мне, когда эта учетная запись"
}
diff --git a/translations/sw.json b/translations/sw.json
new file mode 100644
index 000000000..4f2fa1bb7
--- /dev/null
+++ b/translations/sw.json
@@ -0,0 +1,454 @@
+{
+ "SHOW MORE": "Onyesha Zaidi",
+ "Your browser does not support the video tag.": "Kivinjari chako hachiunga mkono lebo ya video.",
+ "Your browser does not support the audio tag.": "Kivinjari chako hachiunga mkono lebo ya sauti.",
+ "Show profile": "Onyesha Profile.",
+ "Show options for this person": "Onyesha chaguo kwa mtu huyu.",
+ "Repeat this post": "Rudia",
+ "Undo the repeat": "Tengeneza kurudia",
+ "Like this post": "Kama",
+ "Undo the like": "Tofauti na",
+ "Delete this post": "Futa ujumbe huu",
+ "Delete this event": "Futa tukio hili",
+ "Reply to this post": "Jibu ujumbe huu",
+ "Write your post text below.": "Ujumbe mpya",
+ "Write your reply to": "Andika jibu lako",
+ "this post": "Ujumbe huu",
+ "Write your report below.": "Andika ripoti yako hapa chini.",
+ "This message only goes to moderators, even if it mentions other fediverse addresses.": "Ujumbe huu unaenda tu kwa wasimamizi, hata kama inazungumzia anwani nyingine za fediase.",
+ "Also see": "Pia angalia",
+ "Terms of Service": "Masharti ya Huduma",
+ "Enter the details for your shared item below.": "Ingiza maelezo ya bidhaa yako iliyoshirikiwa hapa chini.",
+ "Subject or Content Warning (optional)": "Somo au onyo la maudhui (hiari)",
+ "Write something": "Andika kitu",
+ "Name of the shared item": "Jina la bidhaa iliyoshirikiwa",
+ "Description of the item being shared": "Maelezo ya kipengee kilichoshirikiwa.",
+ "Type of shared item. eg. hat": "Aina ya bidhaa iliyoshirikiwa. mfano. Hat",
+ "Category of shared item. eg. clothing": "Jamii ya bidhaa iliyoshirikiwa. mfano. Mavazi",
+ "Duration of listing in days": "Muda wa orodha katika siku",
+ "City or location of the shared item": "Jiji au eneo la bidhaa iliyoshirikiwa",
+ "Describe a shared item": "Eleza kipengee kilichoshirikiwa",
+ "Public": "Watu wa umma",
+ "Visible to anyone": "Inaonekana kwa mtu yeyote",
+ "Unlisted": "Haijulikani",
+ "Not on public timeline": "Si kwa wakati wa umma",
+ "Followers": "Wafuasi",
+ "Only to followers": "Tu kwa wafuasi",
+ "DM": "DM",
+ "Only to mentioned people": "Tu kwa watu waliotajwa",
+ "Report": "Ripoti",
+ "Send to moderators": "Tuma kwa wasimamizi",
+ "Search for emoji": "Tafuta emoji",
+ "Cancel": "✘",
+ "Submit": "Tuma",
+ "Image description": "Maelezo ya picha",
+ "Item image": "Picha ya picha",
+ "Type": "Andika",
+ "Category": "Jamii",
+ "Location": "Mahali",
+ "Login": "Ingia",
+ "Edit": "Hariri",
+ "Switch to timeline view": "Mtazamo wa Timeline",
+ "Approve": "Thibitisha",
+ "Deny": "Kukataa",
+ "Posts": "Posts",
+ "Following": "Kufuata",
+ "Followers": "Wafuasi",
+ "Roles": "Wajibu",
+ "Skills": "Ujuzi",
+ "Shares": "Hisa",
+ "Block": "Block",
+ "Unfollow": "Unfollow",
+ "Your browser does not support the audio element.": "Kivinjari chako hachiunga mkono kipengele cha sauti.",
+ "Your browser does not support the video element.": "Kivinjari chako hachiunga mkono kipengele cha video.",
+ "Create a new post": "Ujumbe mpya",
+ "Create a new DM": "Unda ujumbe mpya wa moja kwa moja",
+ "Switch to profile view": "Mtazamo wa wasifu",
+ "Inbox": "Kikasha",
+ "Sent": "Imetumwa",
+ "Search and follow": "Tafuta/Kufuata",
+ "Refresh": "Furahisha",
+ "Nickname or URL. Block using *@domain or nickname@domain": "Jina la utani au URL. Kuzuia kutumia *@domain au Jina la jina@domain.",
+ "Remove the above item": "Ondoa kipengee hapo juu",
+ "Remove": "Ondoa",
+ "Suspend the above account nickname": "Kusimamisha jina la utani la juu",
+ "Suspend": "Kusimamishwa",
+ "Remove a suspension for an account nickname": "Ondoa kusimamishwa kwa jina la jina la akaunti",
+ "Unsuspend": "Haijulikani",
+ "Block an account on another instance": "Zima akaunti kwenye mfano mwingine",
+ "Unblock": "Fungua",
+ "Unblock an account on another instance": "Fungua akaunti kwenye mfano mwingine",
+ "Information about current blocks/suspensions": "Maelezo kuhusu vitalu vya sasa / kusimamishwa.",
+ "Info": "Taarifa",
+ "Remove": "Ondoa",
+ "Yes": "Ndiyo",
+ "No": "Hapana",
+ "Delete this post?": "Futa ujumbe huu?",
+ "Follow": "Fuata",
+ "Stop following": "Acha kufuata",
+ "Options for": "Chaguzi kwa",
+ "View": "Tazama",
+ "Stop blocking": "Acha kuzuia",
+ "Enter an emoji name to search for": "Ingiza jina la emoji kutafuta",
+ "Enter an address, shared item, !history, #hashtag, *skill or :emoji: to search for": "Ingiza anwani, bidhaa iliyoshirikiwa, -Save, Historia, #Hashtag, * Ujuzi au: Emoji: Ili kutafuta",
+ "Go Back": "◀",
+ "Moderation Information": "Maelezo ya kiasi",
+ "Suspended accounts": "Akaunti ya kusimamishwa.",
+ "These are currently suspended": "Hizi sasa zimesimamishwa",
+ "Blocked accounts and hashtags": "Akaunti zilizozuiwa na hashtags.",
+ "These are globally blocked for all accounts on this instance": "Hizi zimezuiwa kimataifa kwa akaunti zote juu ya mfano huu",
+ "Any blocks or suspensions made by moderators will be shown here.": "Vitalu vyovyote au kusimamishwa vilivyotengenezwa na wasimamizi vitaonyeshwa hapa.",
+ "Welcome. Please enter your login details below.": "Karibu. Tafadhali ingiza maelezo yako ya kuingia hapa chini.",
+ "Welcome. Please login or register a new account.": "Karibu. Tafadhali ingia au usajili akaunti mpya.",
+ "Please enter some credentials": "Tafadhali ingiza sifa fulani",
+ "You will become the admin of this site.": "Utakuwa admin ya tovuti hii.",
+ "Terms of Service": "Masharti ya Huduma",
+ "About this Instance": "Kuhusu mfano huu",
+ "Nickname": "Jina la utani",
+ "Enter Nickname": "Ingiza jina la utani",
+ "Password": "Nenosiri",
+ "Enter Password": "Ingiza nenosiri",
+ "Profile for": "Profaili kwa",
+ "The files attached below should be no larger than 10MB in total uploaded at once.": "Faili zilizounganishwa hapa chini haipaswi kuwa kubwa kuliko 10MB kwa jumla iliyopakiwa mara moja.",
+ "Avatar image": "Avatar picha",
+ "Background image": "Picha ya asili, ambayo inaonekana nyuma ya avatar yako",
+ "Timeline banner image": "Picha ya Banner ya Timeline",
+ "Approve follower requests": "Thibitisha Maombi ya Follower",
+ "This is a bot account": "Hii ni akaunti ya bot",
+ "Filtered words": "Maneno yaliyochujwa",
+ "One per line": "Moja kwa kila mstari.",
+ "Blocked accounts": "Akaunti zilizozuiwa",
+ "Blocked accounts, one per line, in the form nickname@domain or *@blockeddomain": "Akaunti zilizozuiwa, moja kwa kila mstari, katika jina la utani@kikoa au *@blockeddomain",
+ "Federation list": "Orodha ya Shirikisho",
+ "Federate only with a defined set of instances. One domain name per line.": "Shirikisho tu na seti iliyoelezwa ya matukio. Jina moja la kikoa kwa kila mstari.",
+ "If you want to participate within organizations then you can indicate some skills that you have and approximate proficiency levels. This helps organizers to construct teams with an appropriate combination of skills.": "Ikiwa unataka kushiriki katika mashirika basi unaweza kuonyesha ujuzi fulani unao na viwango vya ustadi wa karibu. Hii husaidia waandaaji kujenga timu na mchanganyiko sahihi wa ujuzi.",
+ "A list of moderator nicknames. One per line.": "Orodha ya majina ya moderator. Moja kwa kila mstari.",
+ "Moderators": "Wasimamizi",
+ "List of moderator nicknames": "Orodha ya Majina ya Moderator",
+ "Your bio": "Wasifu wako",
+ "Skill": "Ujuzi",
+ "Copy the text then paste it into your post": "Nakala maandishi kisha uifanye kwenye ujumbe wako",
+ "Emoji Search": "Utafutaji wa Emoji",
+ "No results": "Hakuna matokeo",
+ "Skills search": "Utafutaji wa ujuzi",
+ "Shared Items Search": "Vitu vilivyoshirikishwa",
+ "Contact": "Mawasiliano",
+ "Shared Item": "Bidhaa iliyoshirikishwa",
+ "Mod": "Wastani",
+ "Approve follow requests": "Thibitisha maombi ya kufuata",
+ "Page down": "Ukurasa wa chini",
+ "Page up": "Ukurasa up",
+ "Vote": "Kura",
+ "Replies": "Jibu",
+ "Media": "Vyombo",
+ "This is a group account": "Hii ni akaunti ya kikundi",
+ "Date": "Tarehe",
+ "Time": "Wakati",
+ "Location": "Mahali",
+ "Calendar": "Kalenda",
+ "Sun": "Sun",
+ "Mon": "Mon",
+ "Tue": "Tue",
+ "Wed": "Wed",
+ "Thu": "Thu",
+ "Fri": "Fri",
+ "Sat": "Sat",
+ "January": "Januari",
+ "February": "Februari",
+ "March": "Machi",
+ "April": "Aprili",
+ "May": "Mei",
+ "June": "Juni",
+ "July": "Julai",
+ "August": "Agosti",
+ "September": "Septemba",
+ "October": "Oktoba",
+ "November": "Novemba",
+ "December": "Desemba",
+ "Only people I follow can send me DMs": "Watu tu ninaoofuata wanaweza kunitumia ujumbe wa moja kwa moja",
+ "Logout": "Ingia",
+ "Danger Zone": "Eneo la hatari",
+ "Deactivate this account": "Ondoa akaunti hii",
+ "Snooze": "Snooze",
+ "Unsnooze": "Unsnooze",
+ "Donations link": "Mchango huunganisha",
+ "Donate": "Msaada",
+ "Change Password": "Badilisha neno la siri",
+ "Confirm Password": "Thibitisha nenosiri",
+ "Instance Title": "Kichwa cha mfano",
+ "Instance Short Description": "Mfano mfupi maelezo",
+ "Instance Description": "Maelezo ya mfano",
+ "Instance Logo": "Alama ya alama",
+ "Bookmark this post": "Hifadhi hii kwa kutazama baadaye",
+ "Undo the bookmark": "Uhusiano wa Kitabu",
+ "Bookmarks": "Inaokoa",
+ "Theme": "Mandhari",
+ "Default": "Kupuuza",
+ "Light": "Mwanga",
+ "Purple": "Zambarau",
+ "Hacker": "Hacker",
+ "HighVis": "Kuonekana kwa juu",
+ "Question": "Swali",
+ "Enter your question": "Ingiza swali lako",
+ "Enter the choices for your question below.": "Ingiza uchaguzi kwa swali lako hapa chini.",
+ "Ask a question": "Uliza Swali",
+ "Possible answers": "Majibu yawezekana",
+ "replying to": "kujibu kwa",
+ "replying to themselves": "kujibu wenyewe",
+ "announces": "inatangaza",
+ "Previous month": "Mwezi uliopita",
+ "Next month": "Mwezi ujao",
+ "Get the source code": "Pata msimbo wa chanzo",
+ "This is a media instance": "Hii ni mfano wa vyombo vya habari",
+ "Mute this post": "Mute",
+ "Undo mute": "Tengeneza Mute",
+ "XMPP": "XMPP",
+ "Matrix": "Matrix",
+ "Email": "Barua pepe",
+ "PGP": "PGP Key",
+ "PGP Fingerprint": "PGP Fingerprint",
+ "This is a scheduled post.": "Hii ni ujumbe uliopangwa kufanyika",
+ "Remove scheduled posts": "Ondoa ujumbe uliopangwa",
+ "Remove Twitter posts": "Kuondoa ujumbe wa Twitter",
+ "Sensitive": "Nyepesi",
+ "Word Replacements": "Mabadiliko ya neno",
+ "Happening Today": "Leo",
+ "Happening Tomorrow": "Kesho",
+ "Happening This Week": "Hivi",
+ "Blog": "Blog",
+ "Blogs": "Blogs",
+ "Title": "Kichwa",
+ "About the author": "Kuhusu mwandishi",
+ "Edit blog post": "Badilisha chapisho cha blogu",
+ "Publicly visible post": "Ujumbe unaoonekana kwa umma",
+ "Your Posts": "Ujumbe wako",
+ "Git Projects": "Miradi ya Git",
+ "List of project names that you wish to receive git patches for": "Orodha ya majina ya mradi unayotaka kupokea patches za git",
+ "Show/Hide Buttons": "Onyesha/kujificha",
+ "Custom Font": "Font Desturi",
+ "Remove the custom font": "Ondoa font ya desturi",
+ "Lcd": "LCD",
+ "Blue": "Bluu",
+ "Zen": "Zen",
+ "Night": "Usiku",
+ "Starlight": "Starlight",
+ "Search banner image": "Tafuta picha ya bendera",
+ "Henge": "Henge",
+ "QR Code": "Kanuni ya QR",
+ "Reminder": "Kumbukumbu",
+ "Scheduled note to yourself": "Kumbuka iliyopangwa mwenyewe",
+ "Replying to": "Kujibu kwa",
+ "Send to": "Tuma kwa",
+ "Show a list of addresses to send to": "Onyesha orodha ya anwani kutuma kwa",
+ "Petname": "Petname",
+ "Ok": "Sawa",
+ "This is nothing less than an utter triumph": "Hii sio chini ya ushindi mkubwa",
+ "Not Found": "Not Found",
+ "These are not the droids you are looking for": "Hizi sio droids unayotafuta",
+ "Not changed": "Haibadilishwa",
+ "The contents of your local cache are up to date": "Yaliyomo ya cache yako ya ndani ni hadi sasa",
+ "Bad Request": "Ombi mbaya",
+ "Better luck next time": "Bahati bora wakati ujao",
+ "Unavailable": "Haipatikani",
+ "The server is busy. Please try again later": "Seva ni busy. Tafadhali jaribu tena baadae",
+ "Receive calendar events from this account": "Pata matukio ya kalenda kutoka kwa akaunti hii",
+ "Grayscale": "Grayscale",
+ "Liked by": "Walipenda na",
+ "Solidaric": "Mshikamano",
+ "YouTube Replacement Domain": "Eneo la Uingizaji wa YouTube",
+ "Notes": "Vidokezo",
+ "Allow replies.": "Ruhusu majibu.",
+ "Event": "Tukio",
+ "Event name": "Jina la Tukio",
+ "Events": "Matukio",
+ "Create an event": "Unda tukio",
+ "Describe the event": "Eleza tukio hilo",
+ "Start Date": "Tarehe ya kuanza",
+ "End Date": "Tarehe ya mwisho",
+ "Categories": "Jamii",
+ "This is a private event.": "Hii ni tukio la kibinafsi",
+ "Allow anonymous participation.": "Ruhusu ushiriki usiojulikana",
+ "Anyone can join": "Mtu yeyote anaweza kujiunga",
+ "Apply to join": "Omba kujiunga",
+ "Invitation only": "Mwaliko tu",
+ "Joining": "Kujiunga",
+ "Status of the event": "Hali ya tukio hilo",
+ "Tentative": "Tamaa",
+ "Confirmed": "Imethibitishwa",
+ "Cancelled": "Imefutwa",
+ "Event banner image description": "Tukio la Banner Image Maelezo.",
+ "Banner image": "Banner Image",
+ "Maximum attendees": "Washiriki wa juu",
+ "Ticket URL": "URL ya tiketi",
+ "Create a new event": "Unda tukio jipya",
+ "Moderation policy or code of conduct": "Sera ya Upimaji au Kanuni ya Maadili",
+ "Edit event": "Hariri tukio",
+ "Notify when posts are liked": "Arifa wakati machapisho yanapendezwa",
+ "Don't show the Like button": "Usionyeshe kifungo kama hicho",
+ "Autogenerated Hashtags": "Hashtags ya Autogenerated",
+ "Autogenerated Content Warnings": "Maonyo ya Maudhui ya Autogenerated",
+ "Indymedia": "Indymedia",
+ "Indymediaclassic": "Indymedia Classic",
+ "Indymediamodern": "Indymedia Modern",
+ "Hashtag Blocked": "Hashtag imefungwa",
+ "This is a blogging instance": "Hii ni mfano wa blogu",
+ "Edit Links": "Hariri Links",
+ "One link per line. Description followed by the link.": "Kiungo kimoja kwa mstari. Maelezo ikifuatiwa na kiungo. Majina yanapaswa kuanza na #",
+ "Left column image": "Safu ya kushoto ya picha",
+ "Right column image": "Sura ya safu ya haki",
+ "RSS feed for this site": "RSS kulisha kwa tovuti hii",
+ "Edit newswire": "Hariri Newswire",
+ "Add RSS feed links below.": "RSS kulisha viungo chini. Ongeza * mwanzoni au mwisho ili kuonyesha kwamba chakula kinapaswa kuhesabiwa. Ongeza! Mwanzoni au mwisho ili kuonyesha kwamba maudhui ya malisho yanapaswa kuonyeshwa.",
+ "Newswire RSS Feed": "Newswire RSS Feed",
+ "Nicknames whose blog entries appear on the newswire.": "Majina ya majina ambayo maingilio ya blogu yanaonekana kwenye Newswire.",
+ "Posts to be approved": "Ujumbe wa kupitishwa",
+ "Discuss": "Jadili",
+ "Moderator Discussion": "Mjadala wa Moderator",
+ "Vote": "Kura",
+ "Remove Vote": "Ondoa Vote",
+ "This is a news instance": "Hii ni mfano wa habari",
+ "News": "Habari",
+ "Read more...": "Soma zaidi...",
+ "Edit News Post": "Hariri Habari Post",
+ "A list of editor nicknames. One per line.": "Orodha ya majina ya jina la mhariri. Moja kwa kila mstari.",
+ "Site Editors": "Wahariri wa tovuti",
+ "Allow news posts": "Ruhusu posts ya habari",
+ "Publish": "Kuchapisha",
+ "Publish a news article": "Chapisha habari ya habari",
+ "News tagging rules": "Kanuni za kuchapishwa habari",
+ "See instructions": "Angalia maelekezo",
+ "Search": "Utafutaji",
+ "Newswire": "Newswire",
+ "Links": "Viungo",
+ "Post": "Ujumbe",
+ "User": "Mtumiaji",
+ "Features" : "Vipengele",
+ "Article": "Kifungu",
+ "Create an article": "Unda makala",
+ "Settings": "Mipangilio",
+ "Citations": "Makala",
+ "Choose newswire items referenced in your article": "Chagua vitu vya Newswire vinavyotajwa katika makala yako",
+ "RSS feed for your blog": "RSS kulisha kwa blogu yako",
+ "Create a new shared item": "Unda kipengee kipya cha pamoja",
+ "Rc3": "Rc3",
+ "Hashtag origins": "Mwanzo wa Hashtag",
+ "admin": "admin",
+ "moderator": "moderator",
+ "editor": "mhariri",
+ "delegator": "desegator",
+ "Debian": "Debian",
+ "Select the edit icon to add RSS feeds": "Chagua icon ya hariri ili kuongeza feeds RSS",
+ "Select the edit icon to add web links": "Chagua icon ya hariri ili kuongeza viungo vya wavuti",
+ "Hashtag Categories RSS Feed": "Makundi ya Hashtag RSS Feed",
+ "Ask about a shared item.": "Uliza kuhusu kipengee kilichoshirikiwa",
+ "Account Information": "Maelezo ya Akaunti",
+ "This account interacts with the following instances": "Akaunti hii inaingiliana na matukio yafuatayo",
+ "News posts are moderated": "Machapisho ya habari yanapangwa",
+ "Filter": "Futa",
+ "Filter out words": "Futa maneno",
+ "Unfilter": "Ondoa chujio",
+ "Unfilter words": "Ondoa chujio kwa maneno",
+ "Show Accounts": "Onyesha akaunti",
+ "Peertube Instances": "Matukio ya PeurTube",
+ "Show video previews for the following Peertube sites.": "Onyesha hakikisho za video kwa maeneo yafuatayo ya PeurTube.",
+ "Follows you": "Inakufuata",
+ "Verify all signatures": "Thibitisha saini zote",
+ "Blocked followers": "Wafuasi waliozuiwa",
+ "Blocked following": "Imefungwa kufuatia",
+ "Receives posts from the following accounts": "Inapokea machapisho kutoka kwa akaunti zifuatazo",
+ "Sends out posts to the following accounts": "Inatuma machapisho kwenye akaunti zifuatazo",
+ "Word frequencies": "Frequency neno",
+ "New account": "Akaunti mpya",
+ "Moved to new account address": "Ilihamishwa kwenye anwani ya akaunti mpya",
+ "Yet another Epicyon Instance": "Hata hivyo mfano mwingine wa epicyon",
+ "Other accounts": "Akaunti nyingine za fediverse",
+ "Pin this post to your profile.": "Piga chapisho hili kwa wasifu wako.",
+ "Administered by": "Inasimamiwa na",
+ "Version": "Toleo",
+ "Skip to timeline": "Ruka kwa Timeline",
+ "Skip to Newswire": "Ruka kwa Newswire",
+ "Skip to Links": "Ruka kwa viungo",
+ "Publish a blog article": "Chapisha makala ya blogu",
+ "Featured writer": "Mwandishi wa Matukio",
+ "Broch mode": "Mode ya broch",
+ "Pixel": "Pixel",
+ "DM bounce": "Ujumbe unakubaliwa tu kutoka kwa akaunti zilizofuatiwa",
+ "Next": "Ijayo",
+ "Preview": "Hakikisho",
+ "Linked": "Mtandao unaohusishwa",
+ "hashtag": "alama ya reli",
+ "smile": "smile",
+ "wink": "wink",
+ "mentioning": "kutaja",
+ "sad face": "uso wa kusikitisha.",
+ "thinking emoji": "kufikiri emoji",
+ "laughing": "kucheka",
+ "gender": "jinsia",
+ "He/Him": "Yeye",
+ "She/Her": "Yeye/wake",
+ "girl": "msichana",
+ "boy": "mvulana",
+ "pronoun": "mtangazaji",
+ "Type of instance": "Aina ya mfano",
+ "Security": "Usalama",
+ "Enabling broch mode": "Kuwezesha Mode ya Broch hutoa kizuizi cha muda dhidi ya mashambulizi. Machapisho tu na matukio yaliyojulikana tayari yatakubaliwa. Ikiwa haijazimwa, inapita baada ya wiki.",
+ "Instance Settings": "Mipangilio ya mfano",
+ "Video Settings": "Mipangilio ya Video",
+ "Filtering and Blocking": "Kuchuja na kuzuia.",
+ "Role Assignment": "Kazi ya jukumu",
+ "Contact Details": "Maelezo ya Mawasiliano",
+ "Background Images": "Picha za asili",
+ "heart": "moyo",
+ "counselor": "mshauri",
+ "Counselors": "washauri",
+ "shocked": "alishtuka",
+ "Encrypted": "Encrypted",
+ "Direct Message permitted instances": "Ujumbe wa moja kwa moja unaruhusiwa",
+ "Direct messages are always allowed from these instances.": "Ujumbe wa moja kwa moja daima unaruhusiwa kutoka kwa matukio haya.",
+ "Key Shortcuts": "Njia za mkato muhimu",
+ "menuTimeline": "Mtazamo wa Timeline",
+ "menuEdit": "Hariri",
+ "menuProfile": "Mtazamo wa wasifu",
+ "menuInbox": "Kikasha",
+ "menuSearch": "Tafuta/Kufuata",
+ "menuNewPost": "Ujumbe mpya",
+ "menuCalendar": "Kalenda",
+ "menuDM": "Ujumbe wa moja kwa moja",
+ "menuReplies": "Jibu",
+ "menuOutbox": "Imetumwa",
+ "menuBookmarks": "Vitambulisho",
+ "menuShares": "Vipengee vya pamoja",
+ "menuBlogs": "Blogu",
+ "menuNewswire": "Newswire",
+ "menuLinks": "Viungo",
+ "menuModeration": "Kiasi",
+ "menuFollowing": "Kufuata",
+ "menuFollowers": "Wafuasi",
+ "menuRoles": "Wajibu",
+ "menuSkills": "Ujuzi",
+ "menuLogout": "Ingia",
+ "menuKeys": "Njia za mkato muhimu",
+ "submitButton": "Tuma kifungo",
+ "menuMedia": "Vyombo vya habari",
+ "followButton": "Fuata/kufuta kifungo",
+ "blockButton": "Block button",
+ "infoButton": "Kitufe cha habari",
+ "snoozeButton": "Kulala kifungo",
+ "reportButton": "Ripoti kifungo",
+ "viewButton": "Angalia kifungo",
+ "enterPetname": "Ingiza Petname",
+ "enterNotes": "Ingiza maelezo",
+ "These access keys may be used": "Funguo hizi za kufikia zinaweza kutumika, kwa kawaida na kitufe cha Alt + Shift + au ALT +",
+ "Show numbers of accounts within instance metadata": "Onyesha idadi ya akaunti ndani ya metadata ya mfano",
+ "Show version number within instance metadata": "Onyesha namba ya toleo ndani ya metadata ya mfano",
+ "Joined": "Alijiunga",
+ "City for spoofed GPS image metadata": "Jiji la metadata ya picha ya GPS iliyopigwa",
+ "Occupation": "Kazi",
+ "Artists": "Wasanii",
+ "Graphic Design": "Graphic design",
+ "Import Theme": "Ingiza mandhari",
+ "Export Theme": "Tuma mandhari",
+ "Custom post submit button text": "Ujumbe wa Desturi Wasilisha Nakala ya kifungo",
+ "Blocked User Agents": "Wakala wa watumiaji waliozuiwa",
+ "Notify me when this account posts": "Nijulishe wakati akaunti hii ya akaunti."
+}
diff --git a/translations/zh.json b/translations/zh.json
index eb981683b..eb143dc04 100644
--- a/translations/zh.json
+++ b/translations/zh.json
@@ -65,7 +65,7 @@
"Create a new DM": "建立新的直接讯息",
"Switch to profile view": "切换到个人资料视图",
"Inbox": "收件箱",
- "Outbox": "发件箱",
+ "Sent": "发送",
"Search and follow": "搜索并关注",
"Refresh": "刷新",
"Nickname or URL. Block using *@domain or nickname@domain": "昵称或网址。 使用*@domain或昵称@domain阻止",
@@ -213,6 +213,7 @@
"Sensitive": "敏感",
"Word Replacements": "单词替换",
"Happening Today": "今天",
+ "Happening Tomorrow": "明天",
"Happening This Week": "不久",
"Blog": "博客",
"Blogs": "网志",
@@ -370,5 +371,84 @@
"Publish a blog article": "发布博客文章",
"Featured writer": "特色作家",
"Broch mode": "断点模式",
- "Pixel": "像素点"
+ "Pixel": "像素点",
+ "DM bounce": "仅接受来自后续帐户的邮件",
+ "Next": "下一个",
+ "Preview": "预览",
+ "Linked": "网页链接",
+ "hashtag": "井号",
+ "smile": "微笑",
+ "wink": "眨眼",
+ "mentioning": "提及",
+ "sad face": "悲伤的脸",
+ "thinking emoji": "思维表情符号",
+ "laughing": "笑",
+ "gender": "性别",
+ "He/Him": "他",
+ "She/Her": "她",
+ "girl": "女孩",
+ "boy": "男生",
+ "pronoun": "代词",
+ "Type of instance": "实例类型",
+ "Security": "安全",
+ "Enabling broch mode": "启用broch模式可提供针对攻击的临时防御。 仅接受已知实例的帖子。 一个星期后就过去了。",
+ "Instance Settings": "实例设定",
+ "Video Settings": "影片设定",
+ "Filtering and Blocking": "过滤和阻止",
+ "Role Assignment": "角色分配",
+ "Background Images": "背景图片",
+ "Contact Details": "联系方式",
+ "heart": "心",
+ "counselor": "顾问",
+ "Counselors": "辅导员",
+ "shocked": "震惊的",
+ "Encrypted": "加密的",
+ "Direct Message permitted instances": "直接留言允许实例",
+ "Direct messages are always allowed from these instances.": "这些实例始终允许直接消息。",
+ "Key Shortcuts": "关键捷径",
+ "menuTimeline": "时间表视图",
+ "menuEdit": "编辑",
+ "menuProfile": "个人资料视图",
+ "menuInbox": "收件箱",
+ "menuSearch": "搜索/关注",
+ "menuNewPost": "最新帖子",
+ "menuCalendar": "日历",
+ "menuDM": "直接留言",
+ "menuReplies": "答案",
+ "menuOutbox": "发送",
+ "menuBookmarks": "书签",
+ "menuShares": "共享项目",
+ "menuBlogs": "博客",
+ "menuNewswire": "新闻界.",
+ "menuLinks": "网页链接",
+ "menuModeration": "适度",
+ "menuFollowing": "下列的",
+ "menuFollowers": "追随者",
+ "menuRoles": "角色",
+ "menuSkills": "技能",
+ "menuLogout": "登出",
+ "menuKeys": "关键捷径",
+ "submitButton": "提交按钮",
+ "menuMedia": "媒体",
+ "followButton": "关注/取消关注按钮",
+ "blockButton": "块按钮",
+ "infoButton": "信息按钮",
+ "snoozeButton": "贪睡按钮",
+ "reportButton": "报告按钮",
+ "viewButton": "查看按钮",
+ "enterPetname": "进入宠物名",
+ "enterNotes": "输入笔记",
+ "These access keys may be used": "可以使用这些访问密钥,通常使用Alt + Shift +键或ALT +键",
+ "Show numbers of accounts within instance metadata": "显示实例元数据中的帐户数",
+ "Show version number within instance metadata": "在实例元数据中显示版本号",
+ "Joined": "加入日期",
+ "City for spoofed GPS image metadata": "欺骗性GPS影像元数据的城市",
+ "Occupation": "职业",
+ "Artists": "艺人",
+ "Graphic Design": "平面设计",
+ "Import Theme": "进口主题",
+ "Export Theme": "出口主题",
+ "Custom post submit button text": "自定义发布提交按钮文本",
+ "Blocked User Agents": "阻止用户代理商",
+ "Notify me when this account posts": "此帐户帖子时通知我"
}
diff --git a/utils.py b/utils.py
index 8f2348062..997cf68ce 100644
--- a/utils.py
+++ b/utils.py
@@ -5,15 +5,17 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
+__module_group__ = "Core"
import os
+import re
import time
import shutil
import datetime
import json
import idna
+import locale
from pprint import pprint
-from calendar import monthrange
from followingCalendar import addPersonToCalendar
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
@@ -26,13 +28,16 @@ invalidCharacters = (
)
+def acctDir(baseDir: str, nickname: str, domain: str) -> str:
+ return baseDir + '/accounts/' + nickname + '@' + domain
+
+
def isFeaturedWriter(baseDir: str, nickname: str, domain: str) -> bool:
"""Is the given account a featured writer, appearing in the features
timeline on news instances?
"""
featuresBlockedFilename = \
- baseDir + '/accounts/' + \
- nickname + '@' + domain + '/.nofeatures'
+ acctDir(baseDir, nickname, domain) + '/.nofeatures'
return not os.path.isfile(featuresBlockedFilename)
@@ -42,9 +47,8 @@ def refreshNewswire(baseDir: str):
refreshNewswireFilename = baseDir + '/accounts/.refresh_newswire'
if os.path.isfile(refreshNewswireFilename):
return
- refreshFile = open(refreshNewswireFilename, 'w+')
- refreshFile.write('\n')
- refreshFile.close()
+ with open(refreshNewswireFilename, 'w+') as refreshFile:
+ refreshFile.write('\n')
def getSHA256(msg: str):
@@ -96,10 +100,21 @@ def hasUsersPath(pathStr: str) -> bool:
for usersStr in usersList:
if '/' + usersStr + '/' in pathStr:
return True
+ if '://' in pathStr:
+ domain = pathStr.split('://')[1]
+ if '/' in domain:
+ domain = domain.split('/')[0]
+ if '://' + domain + '/' not in pathStr:
+ return False
+ nickname = pathStr.split('://' + domain + '/')[1]
+ if '/' in nickname or '.' in nickname:
+ return False
+ return True
return False
-def validPostDate(published: str, maxAgeDays=7) -> bool:
+def validPostDate(published: str, maxAgeDays: int = 90,
+ debug: bool = False) -> bool:
"""Returns true if the published date is recent and is not in the future
"""
baselineTime = datetime.datetime(1970, 1, 1)
@@ -117,11 +132,13 @@ def validPostDate(published: str, maxAgeDays=7) -> bool:
postDaysSinceEpoch = daysDiff.days
if postDaysSinceEpoch > nowDaysSinceEpoch:
- print("Inbox post has a published date in the future!")
+ if debug:
+ print("Inbox post has a published date in the future!")
return False
if nowDaysSinceEpoch - postDaysSinceEpoch >= maxAgeDays:
- print("Inbox post is not recent enough")
+ if debug:
+ print("Inbox post is not recent enough")
return False
return True
@@ -139,12 +156,11 @@ def getFullDomain(domain: str, port: int) -> str:
def isDormant(baseDir: str, nickname: str, domain: str, actor: str,
- dormantMonths=3) -> bool:
+ dormantMonths: int = 3) -> bool:
"""Is the given followed actor dormant, from the standpoint
of the given account
"""
- lastSeenFilename = \
- baseDir + '/accounts/' + nickname + '@' + domain + \
+ lastSeenFilename = acctDir(baseDir, nickname, domain) + \
'/lastseen/' + actor.replace('/', '#') + '.txt'
if not os.path.isfile(lastSeenFilename):
@@ -175,7 +191,7 @@ def isEditor(baseDir: str, nickname: str) -> bool:
return True
return False
- with open(editorsFile, "r") as f:
+ with open(editorsFile, 'r') as f:
lines = f.readlines()
if len(lines) == 0:
adminName = getConfigParam(baseDir, 'admin')
@@ -190,12 +206,74 @@ def isEditor(baseDir: str, nickname: str) -> bool:
return False
+def isArtist(baseDir: str, nickname: str) -> bool:
+ """Returns true if the given nickname is an artist
+ """
+ artistsFile = baseDir + '/accounts/artists.txt'
+
+ if not os.path.isfile(artistsFile):
+ adminName = getConfigParam(baseDir, 'admin')
+ if not adminName:
+ return False
+ if adminName == nickname:
+ return True
+ return False
+
+ with open(artistsFile, 'r') as f:
+ lines = f.readlines()
+ if len(lines) == 0:
+ adminName = getConfigParam(baseDir, 'admin')
+ if not adminName:
+ return False
+ if adminName == nickname:
+ return True
+ for artist in lines:
+ artist = artist.strip('\n').strip('\r')
+ if artist == nickname:
+ return True
+ return False
+
+
def getImageExtensions() -> []:
"""Returns a list of the possible image file extensions
"""
return ('png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg')
+def getImageMimeType(imageFilename: str) -> str:
+ """Returns the mime type for the given image
+ """
+ extensionsToMime = {
+ 'png': 'png',
+ 'jpg': 'jpeg',
+ 'gif': 'gif',
+ 'avif': 'avif',
+ 'svg': 'svg+xml',
+ 'webp': 'webp'
+ }
+ for ext, mimeExt in extensionsToMime.items():
+ if imageFilename.endswith('.' + ext):
+ return 'image/' + mimeExt
+ return 'image/png'
+
+
+def getImageExtensionFromMimeType(contentType: str) -> str:
+ """Returns the image extension from a mime type, such as image/jpeg
+ """
+ imageMedia = {
+ 'png': 'png',
+ 'jpeg': 'jpg',
+ 'gif': 'gif',
+ 'svg+xml': 'svg',
+ 'webp': 'webp',
+ 'avif': 'avif'
+ }
+ for mimeExt, ext in imageMedia.items():
+ if contentType.endswith(mimeExt):
+ return ext
+ return 'png'
+
+
def getVideoExtensions() -> []:
"""Returns a list of the possible video file extensions
"""
@@ -228,6 +306,15 @@ def getImageFormats() -> str:
return imageFormats
+def isImageFile(filename: str) -> bool:
+ """Is the given filename an image?
+ """
+ for ext in getImageExtensions():
+ if filename.endswith('.' + ext):
+ return True
+ return False
+
+
def getMediaFormats() -> str:
"""Returns a string of permissable media formats
used when selecting an attachment for a new post
@@ -249,7 +336,9 @@ def removeHtml(content: str) -> str:
if '<' not in content:
return content
removing = False
+ content = content.replace('', '"').replace('', '"')
+ content = content.replace('
', '\n\n').replace(' ', '\n')
result = ''
for ch in content:
if ch == '<':
@@ -258,6 +347,19 @@ def removeHtml(content: str) -> str:
removing = False
elif not removing:
result += ch
+
+ plainText = result.replace(' ', ' ')
+
+ # insert spaces after full stops
+ strLen = len(plainText)
+ result = ''
+ for i in range(strLen):
+ result += plainText[i]
+ if plainText[i] == '.' and i < strLen - 1:
+ if plainText[i + 1] >= 'A' and plainText[i + 1] <= 'Z':
+ result += ' '
+
+ result = result.replace(' ', ' ').strip()
return result
@@ -327,7 +429,7 @@ def isSuspended(baseDir: str, nickname: str) -> bool:
suspendedFilename = baseDir + '/accounts/suspended.txt'
if os.path.isfile(suspendedFilename):
- with open(suspendedFilename, "r") as f:
+ with open(suspendedFilename, 'r') as f:
lines = f.readlines()
for suspended in lines:
if suspended.strip('\n').strip('\r') == nickname:
@@ -340,13 +442,12 @@ def getFollowersList(baseDir: str,
followFile='following.txt') -> []:
"""Returns a list of followers for the given account
"""
- filename = \
- baseDir + '/accounts/' + nickname + '@' + domain + '/' + followFile
+ filename = acctDir(baseDir, nickname, domain) + '/' + followFile
if not os.path.isfile(filename):
return []
- with open(filename, "r") as f:
+ with open(filename, 'r') as f:
lines = f.readlines()
for i in range(len(lines)):
lines[i] = lines[i].strip()
@@ -361,15 +462,16 @@ def getFollowersOfPerson(baseDir: str,
Used by the shared inbox to know who to send incoming mail to
"""
followers = []
- if ':' in domain:
- domain = domain.split(':')[0]
+ domain = removeDomainPort(domain)
handle = nickname + '@' + domain
if not os.path.isdir(baseDir + '/accounts/' + handle):
return followers
for subdir, dirs, files in os.walk(baseDir + '/accounts'):
for account in dirs:
filename = os.path.join(subdir, account) + '/' + followFile
- if account == handle or account.startswith('inbox@'):
+ if account == handle or \
+ account.startswith('inbox@') or \
+ account.startswith('news@'):
continue
if not os.path.isfile(filename):
continue
@@ -443,7 +545,7 @@ def saveJson(jsonObject: {}, filename: str) -> bool:
return False
-def loadJson(filename: str, delaySec=2, maxTries=5) -> {}:
+def loadJson(filename: str, delaySec: int = 2, maxTries: int = 5) -> {}:
"""Makes a few attempts to load a json formatted file
"""
jsonObject = None
@@ -463,7 +565,7 @@ def loadJson(filename: str, delaySec=2, maxTries=5) -> {}:
def loadJsonOnionify(filename: str, domain: str, onionDomain: str,
- delaySec=2) -> {}:
+ delaySec: int = 2) -> {}:
"""Makes a few attempts to load a json formatted file
This also converts the domain name to the onion domain
"""
@@ -487,7 +589,7 @@ def loadJsonOnionify(filename: str, domain: str, onionDomain: str,
return jsonObject
-def getStatusNumber(publishedStr=None) -> (str, str):
+def getStatusNumber(publishedStr: str = None) -> (str, str):
"""Returns the status number and published date
"""
if not publishedStr:
@@ -587,8 +689,7 @@ def createInboxQueueDir(nickname: str, domain: str, baseDir: str) -> str:
def domainPermitted(domain: str, federationList: []):
if len(federationList) == 0:
return True
- if ':' in domain:
- domain = domain.split(':')[0]
+ domain = removeDomainPort(domain)
if domain in federationList:
return True
return False
@@ -611,35 +712,50 @@ def getLocalNetworkAddresses() -> []:
return ('localhost', '127.0.', '192.168', '10.0.')
+def isLocalNetworkAddress(ipAddress: str) -> bool:
+ """
+ """
+ localIPs = getLocalNetworkAddresses()
+ for ipAddr in localIPs:
+ if ipAddress.startswith(ipAddr):
+ return True
+ return False
+
+
def dangerousMarkup(content: str, allowLocalNetworkAccess: bool) -> bool:
"""Returns true if the given content contains dangerous html markup
"""
- if '<' not in content:
- return False
- if '>' not in content:
- return False
- contentSections = content.split('<')
- invalidPartials = ()
- if not allowLocalNetworkAccess:
- invalidPartials = getLocalNetworkAddresses()
- invalidStrings = ('script', 'canvas', 'style', 'abbr',
- 'frame', 'iframe', 'html', 'body',
- 'hr', 'allow-popups', 'allow-scripts')
- for markup in contentSections:
- if '>' not in markup:
+ separators = (['<', '>'], ['<', '>'])
+ for separatorStyle in separators:
+ startChar = separatorStyle[0]
+ endChar = separatorStyle[1]
+ if startChar not in content:
continue
- markup = markup.split('>')[0].strip()
- for partialMatch in invalidPartials:
- if partialMatch in markup:
- return True
- if ' ' not in markup:
- for badStr in invalidStrings:
- if badStr in markup:
- return True
- else:
- for badStr in invalidStrings:
- if badStr + ' ' in markup:
+ if endChar not in content:
+ continue
+ contentSections = content.split(startChar)
+ invalidPartials = ()
+ if not allowLocalNetworkAccess:
+ invalidPartials = getLocalNetworkAddresses()
+ invalidStrings = ('script', 'noscript',
+ 'canvas', 'style', 'abbr',
+ 'frame', 'iframe', 'html', 'body',
+ 'hr', 'allow-popups', 'allow-scripts')
+ for markup in contentSections:
+ if endChar not in markup:
+ continue
+ markup = markup.split(endChar)[0].strip()
+ for partialMatch in invalidPartials:
+ if partialMatch in markup:
return True
+ if ' ' not in markup:
+ for badStr in invalidStrings:
+ if badStr in markup:
+ return True
+ else:
+ for badStr in invalidStrings:
+ if badStr + ' ' in markup:
+ return True
return False
@@ -669,51 +785,132 @@ def getDisplayName(baseDir: str, actor: str, personCache: {}) -> str:
return nameFound
+def _genderFromString(translate: {}, text: str) -> str:
+ """Given some text, does it contain a gender description?
+ """
+ gender = None
+ textOrig = text
+ text = text.lower()
+ if translate['He/Him'].lower() in text or \
+ translate['boy'].lower() in text:
+ gender = 'He/Him'
+ elif (translate['She/Her'].lower() in text or
+ translate['girl'].lower() in text):
+ gender = 'She/Her'
+ elif 'him' in text or 'male' in text:
+ gender = 'He/Him'
+ elif 'her' in text or 'she' in text or \
+ 'fem' in text or 'woman' in text:
+ gender = 'She/Her'
+ elif 'man' in text or 'He' in textOrig:
+ gender = 'He/Him'
+ return gender
+
+
+def getGenderFromBio(baseDir: str, actor: str, personCache: {},
+ translate: {}) -> str:
+ """Tries to ascertain gender from bio description
+ This is for use by text-to-speech for pitch setting
+ """
+ defaultGender = 'They/Them'
+ if '/statuses/' in actor:
+ actor = actor.split('/statuses/')[0]
+ if not personCache.get(actor):
+ return defaultGender
+ bioFound = None
+ if translate:
+ pronounStr = translate['pronoun'].lower()
+ else:
+ pronounStr = 'pronoun'
+ actorJson = None
+ if personCache[actor].get('actor'):
+ actorJson = personCache[actor]['actor']
+ else:
+ # Try to obtain from the cached actors
+ cachedActorFilename = \
+ baseDir + '/cache/actors/' + (actor.replace('/', '#')) + '.json'
+ if os.path.isfile(cachedActorFilename):
+ actorJson = loadJson(cachedActorFilename, 1)
+ if not actorJson:
+ return defaultGender
+ # is gender defined as a profile tag?
+ if actorJson.get('attachment'):
+ tagsList = actorJson['attachment']
+ if isinstance(tagsList, list):
+ # look for a gender field name
+ for tag in tagsList:
+ if not isinstance(tag, dict):
+ continue
+ if not tag.get('name') or not tag.get('value'):
+ continue
+ if tag['name'].lower() == \
+ translate['gender'].lower():
+ bioFound = tag['value']
+ break
+ elif tag['name'].lower().startswith(pronounStr):
+ bioFound = tag['value']
+ break
+ # the field name could be anything,
+ # just look at the value
+ if not bioFound:
+ for tag in tagsList:
+ if not isinstance(tag, dict):
+ continue
+ if not tag.get('name') or not tag.get('value'):
+ continue
+ gender = _genderFromString(translate, tag['value'])
+ if gender:
+ return gender
+ # if not then use the bio
+ if not bioFound and actorJson.get('summary'):
+ bioFound = actorJson['summary']
+ if not bioFound:
+ return defaultGender
+ gender = _genderFromString(translate, bioFound)
+ if not gender:
+ gender = defaultGender
+ return gender
+
+
def getNicknameFromActor(actor: str) -> str:
"""Returns the nickname from an actor url
"""
if actor.startswith('@'):
actor = actor[1:]
- if '/users/' not in actor:
- if '/profile/' in actor:
- nickStr = actor.split('/profile/')[1].replace('@', '')
+ usersPaths = getUserPaths()
+ for possiblePath in usersPaths:
+ if possiblePath in actor:
+ nickStr = actor.split(possiblePath)[1].replace('@', '')
if '/' not in nickStr:
return nickStr
else:
return nickStr.split('/')[0]
- elif '/channel/' in actor:
- nickStr = actor.split('/channel/')[1].replace('@', '')
- if '/' not in nickStr:
- return nickStr
- else:
- return nickStr.split('/')[0]
- elif '/accounts/' in actor:
- nickStr = actor.split('/accounts/')[1].replace('@', '')
- if '/' not in nickStr:
- return nickStr
- else:
- return nickStr.split('/')[0]
- elif '/u/' in actor:
- nickStr = actor.split('/u/')[1].replace('@', '')
- if '/' not in nickStr:
- return nickStr
- else:
- return nickStr.split('/')[0]
- elif '/@' in actor:
- # https://domain/@nick
- nickStr = actor.split('/@')[1]
- if '/' in nickStr:
- nickStr = nickStr.split('/')[0]
- return nickStr
- elif '@' in actor:
- nickStr = actor.split('@')[0]
- return nickStr
- return None
- nickStr = actor.split('/users/')[1].replace('@', '')
- if '/' not in nickStr:
+ if '/@' in actor:
+ # https://domain/@nick
+ nickStr = actor.split('/@')[1]
+ if '/' in nickStr:
+ nickStr = nickStr.split('/')[0]
return nickStr
- else:
- return nickStr.split('/')[0]
+ elif '@' in actor:
+ nickStr = actor.split('@')[0]
+ return nickStr
+ elif '://' in actor:
+ domain = actor.split('://')[1]
+ if '/' in domain:
+ domain = domain.split('/')[0]
+ if '://' + domain + '/' not in actor:
+ return None
+ nickStr = actor.split('://' + domain + '/')[1]
+ if '/' in nickStr or '.' in nickStr:
+ return None
+ return nickStr
+ return None
+
+
+def getUserPaths() -> []:
+ """Returns possible user paths
+ """
+ return ('/users/', '/profile/', '/accounts/', '/channel/', '/u/')
def getDomainFromActor(actor: str) -> (str, int):
@@ -723,27 +920,14 @@ def getDomainFromActor(actor: str) -> (str, int):
actor = actor[1:]
port = None
prefixes = getProtocolPrefixes()
- if '/profile/' in actor:
- domain = actor.split('/profile/')[0]
- for prefix in prefixes:
- domain = domain.replace(prefix, '')
- elif '/accounts/' in actor:
- domain = actor.split('/accounts/')[0]
- for prefix in prefixes:
- domain = domain.replace(prefix, '')
- elif '/channel/' in actor:
- domain = actor.split('/channel/')[0]
- for prefix in prefixes:
- domain = domain.replace(prefix, '')
- elif '/users/' in actor:
- domain = actor.split('/users/')[0]
- for prefix in prefixes:
- domain = domain.replace(prefix, '')
- elif '/u/' in actor:
- domain = actor.split('/u/')[0]
- for prefix in prefixes:
- domain = domain.replace(prefix, '')
- elif '/@' in actor:
+ usersPaths = getUserPaths()
+ for possiblePath in usersPaths:
+ if possiblePath in actor:
+ domain = actor.split(possiblePath)[0]
+ for prefix in prefixes:
+ domain = domain.replace(prefix, '')
+ break
+ if '/@' in actor:
domain = actor.split('/@')[0]
for prefix in prefixes:
domain = domain.replace(prefix, '')
@@ -756,11 +940,8 @@ def getDomainFromActor(actor: str) -> (str, int):
if '/' in actor:
domain = domain.split('/')[0]
if ':' in domain:
- portStr = domain.split(':')[1]
- if not portStr.isdigit():
- return None, None
- port = int(portStr)
- domain = domain.split(':')[0]
+ port = getPortFromDomain(domain)
+ domain = removeDomainPort(domain)
return domain, port
@@ -769,9 +950,8 @@ def _setDefaultPetName(baseDir: str, nickname: str, domain: str,
"""Sets a default petname
This helps especially when using onion or i2p address
"""
- if ':' in domain:
- domain = domain.split(':')[0]
- userPath = baseDir + '/accounts/' + nickname + '@' + domain
+ domain = removeDomainPort(domain)
+ userPath = acctDir(baseDir, nickname, domain)
petnamesFilename = userPath + '/petnames.txt'
petnameLookupEntry = followNickname + ' ' + \
@@ -812,7 +992,8 @@ def followPerson(baseDir: str, nickname: str, domain: str,
print('DEBUG: follow of domain ' + followDomain)
if ':' in domain:
- handle = nickname + '@' + domain.split(':')[0]
+ domainOnly = removeDomainPort(domain)
+ handle = nickname + '@' + domainOnly
else:
handle = nickname + '@' + domain
@@ -821,7 +1002,8 @@ def followPerson(baseDir: str, nickname: str, domain: str,
return False
if ':' in followDomain:
- handleToFollow = followNickname + '@' + followDomain.split(':')[0]
+ followDomainOnly = removeDomainPort(followDomain)
+ handleToFollow = followNickname + '@' + followDomainOnly
else:
handleToFollow = followNickname + '@' + followDomain
@@ -831,7 +1013,7 @@ def followPerson(baseDir: str, nickname: str, domain: str,
if handleToFollow in open(unfollowedFilename).read():
# remove them from the unfollowed file
newLines = ''
- with open(unfollowedFilename, "r") as f:
+ with open(unfollowedFilename, 'r') as f:
lines = f.readlines()
for line in lines:
if handleToFollow not in line:
@@ -958,7 +1140,7 @@ def clearFromPostCaches(baseDir: str, recentPostsCache: {},
for acct in dirs:
if '@' not in acct:
continue
- if 'inbox@' in acct:
+ if acct.startswith('inbox@'):
continue
cacheDir = os.path.join(baseDir + '/accounts', acct)
postFilename = cacheDir + filename
@@ -983,7 +1165,7 @@ def clearFromPostCaches(baseDir: str, recentPostsCache: {},
def locatePost(baseDir: str, nickname: str, domain: str,
- postUrl: str, replies=False) -> str:
+ postUrl: str, replies: bool = False) -> str:
"""Returns the filename for the given status post url
"""
if not replies:
@@ -998,8 +1180,8 @@ def locatePost(baseDir: str, nickname: str, domain: str,
postUrl = postUrl + '.' + extension
# search boxes
- boxes = ('inbox', 'outbox', 'tlblogs', 'tlevents')
- accountDir = baseDir + '/accounts/' + nickname + '@' + domain + '/'
+ boxes = ('inbox', 'outbox', 'tlblogs')
+ accountDir = acctDir(baseDir, nickname, domain) + '/'
for boxName in boxes:
postFilename = accountDir + boxName + '/' + postUrl
if os.path.isfile(postFilename):
@@ -1026,10 +1208,6 @@ def _removeAttachment(baseDir: str, httpPrefix: str, domain: str,
return
if not postJson['attachment'][0].get('url'):
return
-# if port:
-# if port != 80 and port != 443:
-# if ':' not in domain:
-# domain = domain + ':' + str(port)
attachmentUrl = postJson['attachment'][0]['url']
if not attachmentUrl:
return
@@ -1052,9 +1230,9 @@ def removeModerationPostFromIndex(baseDir: str, postUrl: str,
return
postId = removeIdEnding(postUrl)
if postId in open(moderationIndexFile).read():
- with open(moderationIndexFile, "r") as f:
+ with open(moderationIndexFile, 'r') as f:
lines = f.readlines()
- with open(moderationIndexFile, "w+") as f:
+ with open(moderationIndexFile, 'w+') as f:
for line in lines:
if line.strip("\n").strip("\r") != postId:
f.write(line)
@@ -1068,16 +1246,13 @@ def _isReplyToBlogPost(baseDir: str, nickname: str, domain: str,
postJsonObject: str):
"""Is the given post a reply to a blog post?
"""
- if not postJsonObject.get('object'):
- return False
- if not isinstance(postJsonObject['object'], dict):
+ if not hasObjectDict(postJsonObject):
return False
if not postJsonObject['object'].get('inReplyTo'):
return False
if not isinstance(postJsonObject['object']['inReplyTo'], str):
return False
- blogsIndexFilename = baseDir + '/accounts/' + \
- nickname + '@' + domain + '/tlblogs.index'
+ blogsIndexFilename = acctDir(baseDir, nickname, domain) + '/tlblogs.index'
if not os.path.isfile(blogsIndexFilename):
return False
postId = removeIdEnding(postJsonObject['object']['inReplyTo'])
@@ -1087,125 +1262,192 @@ def _isReplyToBlogPost(baseDir: str, nickname: str, domain: str,
return False
+def _deletePostRemoveReplies(baseDir: str, nickname: str, domain: str,
+ httpPrefix: str, postFilename: str,
+ recentPostsCache: {}, debug: bool) -> None:
+ """Removes replies when deleting a post
+ """
+ repliesFilename = postFilename.replace('.json', '.replies')
+ if not os.path.isfile(repliesFilename):
+ return
+ if debug:
+ print('DEBUG: removing replies to ' + postFilename)
+ with open(repliesFilename, 'r') as f:
+ for replyId in f:
+ replyFile = locatePost(baseDir, nickname, domain, replyId)
+ if not replyFile:
+ continue
+ if os.path.isfile(replyFile):
+ deletePost(baseDir, httpPrefix,
+ nickname, domain, replyFile, debug,
+ recentPostsCache)
+ # remove the replies file
+ os.remove(repliesFilename)
+
+
+def _isBookmarked(baseDir: str, nickname: str, domain: str,
+ postFilename: str) -> bool:
+ """Returns True if the given post is bookmarked
+ """
+ bookmarksIndexFilename = \
+ acctDir(baseDir, nickname, domain) + '/bookmarks.index'
+ if os.path.isfile(bookmarksIndexFilename):
+ bookmarkIndex = postFilename.split('/')[-1] + '\n'
+ if bookmarkIndex in open(bookmarksIndexFilename).read():
+ return True
+ return False
+
+
+def removePostFromCache(postJsonObject: {}, recentPostsCache: {}) -> None:
+ """ if the post exists in the recent posts cache then remove it
+ """
+ if not recentPostsCache:
+ return
+
+ if not postJsonObject.get('id'):
+ return
+
+ if not recentPostsCache.get('index'):
+ return
+
+ postId = postJsonObject['id']
+ if '#' in postId:
+ postId = postId.split('#', 1)[0]
+ postId = removeIdEnding(postId).replace('/', '#')
+ if postId not in recentPostsCache['index']:
+ return
+
+ if recentPostsCache.get('index'):
+ if postId in recentPostsCache['index']:
+ recentPostsCache['index'].remove(postId)
+
+ if recentPostsCache.get('json'):
+ if recentPostsCache['json'].get(postId):
+ del recentPostsCache['json'][postId]
+
+ if recentPostsCache.get('html'):
+ if recentPostsCache['html'].get(postId):
+ del recentPostsCache['html'][postId]
+
+
+def _deleteCachedHtml(baseDir: str, nickname: str, domain: str,
+ postJsonObject: {}):
+ """Removes cached html file for the given post
+ """
+ cachedPostFilename = \
+ getCachedPostFilename(baseDir, nickname, domain, postJsonObject)
+ if cachedPostFilename:
+ if os.path.isfile(cachedPostFilename):
+ os.remove(cachedPostFilename)
+
+
+def _deleteHashtagsOnPost(baseDir: str, postJsonObject: {}) -> None:
+ """Removes hashtags when a post is deleted
+ """
+ removeHashtagIndex = False
+ if hasObjectDict(postJsonObject):
+ if postJsonObject['object'].get('content'):
+ if '#' in postJsonObject['object']['content']:
+ removeHashtagIndex = True
+
+ if not removeHashtagIndex:
+ return
+
+ if not postJsonObject['object'].get('id') or \
+ not postJsonObject['object'].get('tag'):
+ return
+
+ # get the id of the post
+ postId = removeIdEnding(postJsonObject['object']['id'])
+ for tag in postJsonObject['object']['tag']:
+ if tag['type'] != 'Hashtag':
+ continue
+ if not tag.get('name'):
+ continue
+ # find the index file for this tag
+ tagIndexFilename = baseDir + '/tags/' + tag['name'][1:] + '.txt'
+ if not os.path.isfile(tagIndexFilename):
+ continue
+ # remove postId from the tag index file
+ lines = None
+ with open(tagIndexFilename, 'r') as f:
+ lines = f.readlines()
+ if not lines:
+ continue
+ newlines = ''
+ for fileLine in lines:
+ if postId in fileLine:
+ # skip over the deleted post
+ continue
+ newlines += fileLine
+ if not newlines.strip():
+ # if there are no lines then remove the hashtag file
+ os.remove(tagIndexFilename)
+ else:
+ # write the new hashtag index without the given post in it
+ with open(tagIndexFilename, 'w+') as f:
+ f.write(newlines)
+
+
def deletePost(baseDir: str, httpPrefix: str,
nickname: str, domain: str, postFilename: str,
debug: bool, recentPostsCache: {}) -> None:
"""Recursively deletes a post and its replies and attachments
"""
postJsonObject = loadJson(postFilename, 1)
- if postJsonObject:
- # don't allow deletion of bookmarked posts
- bookmarksIndexFilename = \
- baseDir + '/accounts/' + nickname + '@' + domain + \
- '/bookmarks.index'
- if os.path.isfile(bookmarksIndexFilename):
- bookmarkIndex = postFilename.split('/')[-1] + '\n'
- if bookmarkIndex in open(bookmarksIndexFilename).read():
- return
+ if not postJsonObject:
+ # remove any replies
+ _deletePostRemoveReplies(baseDir, nickname, domain,
+ httpPrefix, postFilename,
+ recentPostsCache, debug)
+ # finally, remove the post itself
+ os.remove(postFilename)
+ return
- # don't remove replies to blog posts
- if _isReplyToBlogPost(baseDir, nickname, domain,
- postJsonObject):
- return
+ # don't allow deletion of bookmarked posts
+ if _isBookmarked(baseDir, nickname, domain, postFilename):
+ return
- # remove from recent posts cache in memory
- if recentPostsCache:
- postId = \
- removeIdEnding(postJsonObject['id']).replace('/', '#')
- if recentPostsCache.get('index'):
- if postId in recentPostsCache['index']:
- recentPostsCache['index'].remove(postId)
- if recentPostsCache.get('json'):
- if recentPostsCache['json'].get(postId):
- del recentPostsCache['json'][postId]
- if recentPostsCache.get('html'):
- if recentPostsCache['html'].get(postId):
- del recentPostsCache['html'][postId]
+ # don't remove replies to blog posts
+ if _isReplyToBlogPost(baseDir, nickname, domain,
+ postJsonObject):
+ return
- # remove any attachment
- _removeAttachment(baseDir, httpPrefix, domain, postJsonObject)
+ # remove from recent posts cache in memory
+ removePostFromCache(postJsonObject, recentPostsCache)
- extensions = ('votes', 'arrived', 'muted')
- for ext in extensions:
- extFilename = postFilename + '.' + ext
- if os.path.isfile(extFilename):
- os.remove(extFilename)
+ # remove any attachment
+ _removeAttachment(baseDir, httpPrefix, domain, postJsonObject)
- # remove cached html version of the post
- cachedPostFilename = \
- getCachedPostFilename(baseDir, nickname, domain, postJsonObject)
- if cachedPostFilename:
- if os.path.isfile(cachedPostFilename):
- os.remove(cachedPostFilename)
- # removePostFromCache(postJsonObject,recentPostsCache)
+ extensions = ('votes', 'arrived', 'muted', 'tts', 'reject')
+ for ext in extensions:
+ extFilename = postFilename + '.' + ext
+ if os.path.isfile(extFilename):
+ os.remove(extFilename)
- hasObject = False
- if postJsonObject.get('object'):
- hasObject = True
+ # remove cached html version of the post
+ _deleteCachedHtml(baseDir, nickname, domain, postJsonObject)
- # remove from moderation index file
- if hasObject:
- if isinstance(postJsonObject['object'], dict):
- if postJsonObject['object'].get('moderationStatus'):
- if postJsonObject.get('id'):
- postId = removeIdEnding(postJsonObject['id'])
- removeModerationPostFromIndex(baseDir, postId, debug)
+ hasObject = False
+ if postJsonObject.get('object'):
+ hasObject = True
- # remove any hashtags index entries
- removeHashtagIndex = False
- if hasObject:
- if hasObject and isinstance(postJsonObject['object'], dict):
- if postJsonObject['object'].get('content'):
- if '#' in postJsonObject['object']['content']:
- removeHashtagIndex = True
- if removeHashtagIndex:
- if postJsonObject['object'].get('id') and \
- postJsonObject['object'].get('tag'):
- # get the id of the post
- postId = removeIdEnding(postJsonObject['object']['id'])
- for tag in postJsonObject['object']['tag']:
- if tag['type'] != 'Hashtag':
- continue
- if not tag.get('name'):
- continue
- # find the index file for this tag
- tagIndexFilename = \
- baseDir + '/tags/' + tag['name'][1:] + '.txt'
- if not os.path.isfile(tagIndexFilename):
- continue
- # remove postId from the tag index file
- lines = None
- with open(tagIndexFilename, "r") as f:
- lines = f.readlines()
- if lines:
- newlines = ''
- for fileLine in lines:
- if postId in fileLine:
- continue
- newlines += fileLine
- if not newlines.strip():
- # if there are no lines then remove the
- # hashtag file
- os.remove(tagIndexFilename)
- else:
- with open(tagIndexFilename, "w+") as f:
- f.write(newlines)
+ # remove from moderation index file
+ if hasObject:
+ if hasObjectDict(postJsonObject):
+ if postJsonObject['object'].get('moderationStatus'):
+ if postJsonObject.get('id'):
+ postId = removeIdEnding(postJsonObject['id'])
+ removeModerationPostFromIndex(baseDir, postId, debug)
+
+ # remove any hashtags index entries
+ if hasObject:
+ _deleteHashtagsOnPost(baseDir, postJsonObject)
# remove any replies
- repliesFilename = postFilename.replace('.json', '.replies')
- if os.path.isfile(repliesFilename):
- if debug:
- print('DEBUG: removing replies to ' + postFilename)
- with open(repliesFilename, 'r') as f:
- for replyId in f:
- replyFile = locatePost(baseDir, nickname, domain, replyId)
- if replyFile:
- if os.path.isfile(replyFile):
- deletePost(baseDir, httpPrefix,
- nickname, domain, replyFile, debug,
- recentPostsCache)
- # remove the replies file
- os.remove(repliesFilename)
+ _deletePostRemoveReplies(baseDir, nickname, domain,
+ httpPrefix, postFilename,
+ recentPostsCache, debug)
# finally, remove the post itself
os.remove(postFilename)
@@ -1266,14 +1508,20 @@ def _isReservedName(nickname: str) -> bool:
'public', 'followers', 'category',
'channel', 'calendar',
'tlreplies', 'tlmedia', 'tlblogs',
- 'tlevents', 'tlblogs', 'tlfeatures',
+ 'tlblogs', 'tlfeatures',
'moderation', 'moderationaction',
'activity', 'undo', 'pinned',
'reply', 'replies', 'question', 'like',
'likes', 'users', 'statuses', 'tags',
- 'accounts', 'channels', 'profile', 'u',
+ 'accounts', 'headers',
+ 'channels', 'profile', 'u',
'updates', 'repeat', 'announce',
- 'shares', 'fonts', 'icons', 'avatars')
+ 'shares', 'fonts', 'icons', 'avatars',
+ 'welcome', 'helpimages',
+ 'bookmark', 'bookmarks', 'tlbookmarks',
+ 'ignores', 'linksmobile', 'newswiremobile',
+ 'minimal', 'search', 'eventdelete',
+ 'searchemoji')
if nickname in reservedNames:
return True
return False
@@ -1302,9 +1550,8 @@ def noOfAccounts(baseDir: str) -> bool:
accountCtr = 0
for subdir, dirs, files in os.walk(baseDir + '/accounts'):
for account in dirs:
- if '@' in account:
- if not account.startswith('inbox@'):
- accountCtr += 1
+ if isAccountDir(account):
+ accountCtr += 1
break
return accountCtr
@@ -1317,17 +1564,18 @@ def noOfActiveAccountsMonthly(baseDir: str, months: int) -> bool:
monthSeconds = int(60*60*24*30*months)
for subdir, dirs, files in os.walk(baseDir + '/accounts'):
for account in dirs:
- if '@' in account:
- if not account.startswith('inbox@'):
- lastUsedFilename = \
- baseDir + '/accounts/' + account + '/.lastUsed'
- if os.path.isfile(lastUsedFilename):
- with open(lastUsedFilename, 'r') as lastUsedFile:
- lastUsed = lastUsedFile.read()
- if lastUsed.isdigit():
- timeDiff = (currTime - int(lastUsed))
- if timeDiff < monthSeconds:
- accountCtr += 1
+ if not isAccountDir(account):
+ continue
+ lastUsedFilename = \
+ baseDir + '/accounts/' + account + '/.lastUsed'
+ if not os.path.isfile(lastUsedFilename):
+ continue
+ with open(lastUsedFilename, 'r') as lastUsedFile:
+ lastUsed = lastUsedFile.read()
+ if lastUsed.isdigit():
+ timeDiff = (currTime - int(lastUsed))
+ if timeDiff < monthSeconds:
+ accountCtr += 1
break
return accountCtr
@@ -1352,9 +1600,7 @@ def isPublicPost(postJsonObject: {}) -> bool:
return False
if postJsonObject['type'] != 'Create':
return False
- if not postJsonObject.get('object'):
- return False
- if not isinstance(postJsonObject['object'], dict):
+ if not hasObjectDict(postJsonObject):
return False
if not postJsonObject['object'].get('to'):
return False
@@ -1364,7 +1610,7 @@ def isPublicPost(postJsonObject: {}) -> bool:
return False
-def copytree(src: str, dst: str, symlinks=False, ignore=None):
+def copytree(src: str, dst: str, symlinks: str = False, ignore: bool = None):
"""Copy a directory
"""
for item in os.listdir(src):
@@ -1379,8 +1625,7 @@ def copytree(src: str, dst: str, symlinks=False, ignore=None):
def getCachedPostDirectory(baseDir: str, nickname: str, domain: str) -> str:
"""Returns the directory where the html post cache exists
"""
- htmlPostCacheDir = baseDir + '/accounts/' + \
- nickname + '@' + domain + '/postcache'
+ htmlPostCacheDir = acctDir(baseDir, nickname, domain) + '/postcache'
return htmlPostCacheDir
@@ -1390,39 +1635,16 @@ def getCachedPostFilename(baseDir: str, nickname: str, domain: str,
"""
cachedPostDir = getCachedPostDirectory(baseDir, nickname, domain)
if not os.path.isdir(cachedPostDir):
- # print('ERROR: invalid html cache directory '+cachedPostDir)
+ # print('ERROR: invalid html cache directory ' + cachedPostDir)
return None
if '@' not in cachedPostDir:
- # print('ERROR: invalid html cache directory '+cachedPostDir)
+ # print('ERROR: invalid html cache directory ' + cachedPostDir)
return None
cachedPostId = removeIdEnding(postJsonObject['id'])
cachedPostFilename = cachedPostDir + '/' + cachedPostId.replace('/', '#')
return cachedPostFilename + '.html'
-def removePostFromCache(postJsonObject: {}, recentPostsCache: {}):
- """ if the post exists in the recent posts cache then remove it
- """
- if not postJsonObject.get('id'):
- return
-
- if not recentPostsCache.get('index'):
- return
-
- postId = postJsonObject['id']
- if '#' in postId:
- postId = postId.split('#', 1)[0]
- postId = removeIdEnding(postId).replace('/', '#')
- if postId not in recentPostsCache['index']:
- return
-
- if recentPostsCache['json'].get(postId):
- del recentPostsCache['json'][postId]
- if recentPostsCache['html'].get(postId):
- del recentPostsCache['html'][postId]
- recentPostsCache['index'].remove(postId)
-
-
def updateRecentPostsCache(recentPostsCache: {}, maxRecentPosts: int,
postJsonObject: {}, htmlStr: str) -> None:
"""Store recent posts in memory so that they can be quickly recalled
@@ -1444,8 +1666,10 @@ def updateRecentPostsCache(recentPostsCache: {}, maxRecentPosts: int,
while len(recentPostsCache['html'].items()) > maxRecentPosts:
postId = recentPostsCache['index'][0]
recentPostsCache['index'].pop(0)
- del recentPostsCache['json'][postId]
- del recentPostsCache['html'][postId]
+ if recentPostsCache['json'].get(postId):
+ del recentPostsCache['json'][postId]
+ if recentPostsCache['html'].get(postId):
+ del recentPostsCache['html'][postId]
else:
recentPostsCache['index'] = [postId]
recentPostsCache['json'] = {}
@@ -1491,22 +1715,6 @@ def getCSS(baseDir: str, cssFilename: str, cssCache: {}) -> str:
return None
-def daysInMonth(year: int, monthNumber: int) -> int:
- """Returns the number of days in the month
- """
- if monthNumber < 1 or monthNumber > 12:
- return None
- daysRange = monthrange(year, monthNumber)
- return daysRange[1]
-
-
-def mergeDicts(dict1: {}, dict2: {}) -> {}:
- """Merges two dictionaries
- """
- res = {**dict1, **dict2}
- return res
-
-
def isEventPost(messageJson: {}) -> bool:
"""Is the given post a mobilizon-type event activity?
See https://framagit.org/framasoft/mobilizon/-/blob/
@@ -1516,9 +1724,7 @@ def isEventPost(messageJson: {}) -> bool:
return False
if not messageJson.get('actor'):
return False
- if not messageJson.get('object'):
- return False
- if not isinstance(messageJson['object'], dict):
+ if not hasObjectDict(messageJson):
return False
if not messageJson['object'].get('type'):
return False
@@ -1549,9 +1755,7 @@ def isBlogPost(postJsonObject: {}) -> bool:
"""
if postJsonObject['type'] != 'Create':
return False
- if not postJsonObject.get('object'):
- return False
- if not isinstance(postJsonObject['object'], dict):
+ if not hasObjectDict(postJsonObject):
return False
if not postJsonObject['object'].get('type'):
return False
@@ -1568,14 +1772,70 @@ def isNewsPost(postJsonObject: {}) -> bool:
return postJsonObject.get('news')
+def _searchVirtualBoxPosts(baseDir: str, nickname: str, domain: str,
+ searchStr: str, maxResults: int,
+ boxName: str) -> []:
+ """Searches through a virtual box, which is typically an index on the inbox
+ """
+ indexFilename = \
+ acctDir(baseDir, nickname, domain) + '/' + boxName + '.index'
+ if boxName == 'bookmarks':
+ boxName = 'inbox'
+ path = acctDir(baseDir, nickname, domain) + '/' + boxName
+ if not os.path.isdir(path):
+ return []
+
+ searchStr = searchStr.lower().strip()
+
+ if '+' in searchStr:
+ searchWords = searchStr.split('+')
+ for index in range(len(searchWords)):
+ searchWords[index] = searchWords[index].strip()
+ print('SEARCH: ' + str(searchWords))
+ else:
+ searchWords = [searchStr]
+
+ res = []
+ with open(indexFilename, 'r') as indexFile:
+ postFilename = 'start'
+ while postFilename:
+ postFilename = indexFile.readline()
+ if not postFilename:
+ break
+ if '.json' not in postFilename:
+ break
+ postFilename = path + '/' + postFilename.strip()
+ if not os.path.isfile(postFilename):
+ continue
+ with open(postFilename, 'r') as postFile:
+ data = postFile.read().lower()
+
+ notFound = False
+ for keyword in searchWords:
+ if keyword not in data:
+ notFound = True
+ break
+ if notFound:
+ continue
+
+ res.append(postFilename)
+ if len(res) >= maxResults:
+ return res
+ return res
+
+
def searchBoxPosts(baseDir: str, nickname: str, domain: str,
searchStr: str, maxResults: int,
boxName='outbox') -> []:
"""Search your posts and return a list of the filenames
containing matching strings
"""
- path = baseDir + '/accounts/' + nickname + '@' + domain + '/' + boxName
+ path = acctDir(baseDir, nickname, domain) + '/' + boxName
+ # is this a virtual box, such as direct messages?
if not os.path.isdir(path):
+ if os.path.isfile(path + '.index'):
+ return _searchVirtualBoxPosts(baseDir, nickname, domain,
+ searchStr, maxResults, boxName)
return []
searchStr = searchStr.lower().strip()
@@ -1617,13 +1877,6 @@ def getFileCaseInsensitive(path: str) -> str:
if path != path.lower():
if os.path.isfile(path.lower()):
return path.lower()
- # directory, filename = os.path.split(path)
- # directory, filename = (directory or '.'), filename.lower()
- # for f in os.listdir(directory):
- # if f.lower() == filename:
- # newpath = os.path.join(directory, f)
- # if os.path.isfile(newpath):
- # return newpath
return None
@@ -1633,65 +1886,6 @@ def undoLikesCollectionEntry(recentPostsCache: {},
"""Undoes a like for a particular actor
"""
postJsonObject = loadJson(postFilename)
- if postJsonObject:
- # remove any cached version of this post so that the
- # like icon is changed
- nickname = getNicknameFromActor(actor)
- cachedPostFilename = getCachedPostFilename(baseDir, nickname,
- domain, postJsonObject)
- if cachedPostFilename:
- if os.path.isfile(cachedPostFilename):
- os.remove(cachedPostFilename)
- removePostFromCache(postJsonObject, recentPostsCache)
-
- if not postJsonObject.get('type'):
- return
- if postJsonObject['type'] != 'Create':
- return
- if not postJsonObject.get('object'):
- if debug:
- pprint(postJsonObject)
- print('DEBUG: post '+objectUrl+' has no object')
- return
- if not isinstance(postJsonObject['object'], dict):
- return
- if not postJsonObject['object'].get('likes'):
- return
- if not isinstance(postJsonObject['object']['likes'], dict):
- return
- if not postJsonObject['object']['likes'].get('items'):
- return
- totalItems = 0
- if postJsonObject['object']['likes'].get('totalItems'):
- totalItems = postJsonObject['object']['likes']['totalItems']
- itemFound = False
- for likeItem in postJsonObject['object']['likes']['items']:
- if likeItem.get('actor'):
- if likeItem['actor'] == actor:
- if debug:
- print('DEBUG: like was removed for ' + actor)
- postJsonObject['object']['likes']['items'].remove(likeItem)
- itemFound = True
- break
- if itemFound:
- if totalItems == 1:
- if debug:
- print('DEBUG: likes was removed from post')
- del postJsonObject['object']['likes']
- else:
- itlen = len(postJsonObject['object']['likes']['items'])
- postJsonObject['object']['likes']['totalItems'] = itlen
-
- saveJson(postJsonObject, postFilename)
-
-
-def updateLikesCollection(recentPostsCache: {},
- baseDir: str, postFilename: str,
- objectUrl: str,
- actor: str, domain: str, debug: bool) -> None:
- """Updates the likes collection within a post
- """
- postJsonObject = loadJson(postFilename)
if not postJsonObject:
return
# remove any cached version of this post so that the
@@ -1704,12 +1898,68 @@ def updateLikesCollection(recentPostsCache: {},
os.remove(cachedPostFilename)
removePostFromCache(postJsonObject, recentPostsCache)
- if not postJsonObject.get('object'):
+ if not postJsonObject.get('type'):
+ return
+ if postJsonObject['type'] != 'Create':
+ return
+ if not hasObjectDict(postJsonObject):
if debug:
pprint(postJsonObject)
print('DEBUG: post ' + objectUrl + ' has no object')
return
- if not isinstance(postJsonObject['object'], dict):
+ if not postJsonObject['object'].get('likes'):
+ return
+ if not isinstance(postJsonObject['object']['likes'], dict):
+ return
+ if not postJsonObject['object']['likes'].get('items'):
+ return
+ totalItems = 0
+ if postJsonObject['object']['likes'].get('totalItems'):
+ totalItems = postJsonObject['object']['likes']['totalItems']
+ itemFound = False
+ for likeItem in postJsonObject['object']['likes']['items']:
+ if likeItem.get('actor'):
+ if likeItem['actor'] == actor:
+ if debug:
+ print('DEBUG: like was removed for ' + actor)
+ postJsonObject['object']['likes']['items'].remove(likeItem)
+ itemFound = True
+ break
+ if not itemFound:
+ return
+ if totalItems == 1:
+ if debug:
+ print('DEBUG: likes was removed from post')
+ del postJsonObject['object']['likes']
+ else:
+ itlen = len(postJsonObject['object']['likes']['items'])
+ postJsonObject['object']['likes']['totalItems'] = itlen
+
+ saveJson(postJsonObject, postFilename)
+
+
+def updateLikesCollection(recentPostsCache: {},
+ baseDir: str, postFilename: str,
+ objectUrl: str, actor: str,
+ nickname: str, domain: str, debug: bool) -> None:
+ """Updates the likes collection within a post
+ """
+ postJsonObject = loadJson(postFilename)
+ if not postJsonObject:
+ return
+ # remove any cached version of this post so that the
+ # like icon is changed
+ removePostFromCache(postJsonObject, recentPostsCache)
+ cachedPostFilename = getCachedPostFilename(baseDir, nickname,
+ domain, postJsonObject)
+ if cachedPostFilename:
+ if os.path.isfile(cachedPostFilename):
+ os.remove(cachedPostFilename)
+
+ if not hasObjectDict(postJsonObject):
+ if debug:
+ pprint(postJsonObject)
+ print('DEBUG: post ' + objectUrl + ' has no object')
return
if not objectUrl.endswith('/likes'):
objectUrl = objectUrl + '/likes'
@@ -1758,124 +2008,123 @@ def undoAnnounceCollectionEntry(recentPostsCache: {},
shares of posts, not shares of physical objects.
"""
postJsonObject = loadJson(postFilename)
- if postJsonObject:
- # remove any cached version of this announce so that the announce
- # icon is changed
- nickname = getNicknameFromActor(actor)
- cachedPostFilename = getCachedPostFilename(baseDir, nickname, domain,
- postJsonObject)
- if cachedPostFilename:
- if os.path.isfile(cachedPostFilename):
- os.remove(cachedPostFilename)
- removePostFromCache(postJsonObject, recentPostsCache)
+ if not postJsonObject:
+ return
+ # remove any cached version of this announce so that the announce
+ # icon is changed
+ nickname = getNicknameFromActor(actor)
+ cachedPostFilename = getCachedPostFilename(baseDir, nickname, domain,
+ postJsonObject)
+ if cachedPostFilename:
+ if os.path.isfile(cachedPostFilename):
+ os.remove(cachedPostFilename)
+ removePostFromCache(postJsonObject, recentPostsCache)
- if not postJsonObject.get('type'):
- return
- if postJsonObject['type'] != 'Create':
- return
- if not postJsonObject.get('object'):
- if debug:
- pprint(postJsonObject)
- print('DEBUG: post has no object')
- return
- if not isinstance(postJsonObject['object'], dict):
- return
- if not postJsonObject['object'].get('shares'):
- return
- if not postJsonObject['object']['shares'].get('items'):
- return
- totalItems = 0
- if postJsonObject['object']['shares'].get('totalItems'):
- totalItems = postJsonObject['object']['shares']['totalItems']
- itemFound = False
- for announceItem in postJsonObject['object']['shares']['items']:
- if announceItem.get('actor'):
- if announceItem['actor'] == actor:
- if debug:
- print('DEBUG: Announce was removed for ' + actor)
- anIt = announceItem
- postJsonObject['object']['shares']['items'].remove(anIt)
- itemFound = True
- break
- if itemFound:
- if totalItems == 1:
+ if not postJsonObject.get('type'):
+ return
+ if postJsonObject['type'] != 'Create':
+ return
+ if not hasObjectDict(postJsonObject):
+ if debug:
+ pprint(postJsonObject)
+ print('DEBUG: post has no object')
+ return
+ if not postJsonObject['object'].get('shares'):
+ return
+ if not postJsonObject['object']['shares'].get('items'):
+ return
+ totalItems = 0
+ if postJsonObject['object']['shares'].get('totalItems'):
+ totalItems = postJsonObject['object']['shares']['totalItems']
+ itemFound = False
+ for announceItem in postJsonObject['object']['shares']['items']:
+ if announceItem.get('actor'):
+ if announceItem['actor'] == actor:
if debug:
- print('DEBUG: shares (announcements) ' +
- 'was removed from post')
- del postJsonObject['object']['shares']
- else:
- itlen = len(postJsonObject['object']['shares']['items'])
- postJsonObject['object']['shares']['totalItems'] = itlen
+ print('DEBUG: Announce was removed for ' + actor)
+ anIt = announceItem
+ postJsonObject['object']['shares']['items'].remove(anIt)
+ itemFound = True
+ break
+ if not itemFound:
+ return
+ if totalItems == 1:
+ if debug:
+ print('DEBUG: shares (announcements) ' +
+ 'was removed from post')
+ del postJsonObject['object']['shares']
+ else:
+ itlen = len(postJsonObject['object']['shares']['items'])
+ postJsonObject['object']['shares']['totalItems'] = itlen
- saveJson(postJsonObject, postFilename)
+ saveJson(postJsonObject, postFilename)
def updateAnnounceCollection(recentPostsCache: {},
baseDir: str, postFilename: str,
- actor: str, domain: str, debug: bool) -> None:
+ actor: str,
+ nickname: str, domain: str, debug: bool) -> None:
"""Updates the announcements collection within a post
Confusingly this is known as "shares", but isn't the
same as shared items within shares.py
It's shares of posts, not shares of physical objects.
"""
postJsonObject = loadJson(postFilename)
- if postJsonObject:
- # remove any cached version of this announce so that the announce
- # icon is changed
- nickname = getNicknameFromActor(actor)
- cachedPostFilename = getCachedPostFilename(baseDir, nickname, domain,
- postJsonObject)
- if cachedPostFilename:
- if os.path.isfile(cachedPostFilename):
- os.remove(cachedPostFilename)
- removePostFromCache(postJsonObject, recentPostsCache)
-
- if not postJsonObject.get('object'):
- if debug:
- pprint(postJsonObject)
- print('DEBUG: post ' + postFilename + ' has no object')
- return
- if not isinstance(postJsonObject['object'], dict):
- return
- postUrl = removeIdEnding(postJsonObject['id']) + '/shares'
- if not postJsonObject['object'].get('shares'):
- if debug:
- print('DEBUG: Adding initial shares (announcements) to ' +
- postUrl)
- announcementsJson = {
- "@context": "https://www.w3.org/ns/activitystreams",
- 'id': postUrl,
- 'type': 'Collection',
- "totalItems": 1,
- 'items': [{
- 'type': 'Announce',
- 'actor': actor
- }]
- }
- postJsonObject['object']['shares'] = announcementsJson
- else:
- if postJsonObject['object']['shares'].get('items'):
- sharesItems = postJsonObject['object']['shares']['items']
- for announceItem in sharesItems:
- if announceItem.get('actor'):
- if announceItem['actor'] == actor:
- return
- newAnnounce = {
- 'type': 'Announce',
- 'actor': actor
- }
- postJsonObject['object']['shares']['items'].append(newAnnounce)
- itlen = len(postJsonObject['object']['shares']['items'])
- postJsonObject['object']['shares']['totalItems'] = itlen
- else:
- if debug:
- print('DEBUG: shares (announcements) section of post ' +
- 'has no items list')
+ if not postJsonObject:
+ return
+ # remove any cached version of this announce so that the announce
+ # icon is changed
+ cachedPostFilename = getCachedPostFilename(baseDir, nickname, domain,
+ postJsonObject)
+ if cachedPostFilename:
+ if os.path.isfile(cachedPostFilename):
+ os.remove(cachedPostFilename)
+ removePostFromCache(postJsonObject, recentPostsCache)
+ if not hasObjectDict(postJsonObject):
if debug:
- print('DEBUG: saving post with shares (announcements) added')
pprint(postJsonObject)
- saveJson(postJsonObject, postFilename)
+ print('DEBUG: post ' + postFilename + ' has no object')
+ return
+ postUrl = removeIdEnding(postJsonObject['id']) + '/shares'
+ if not postJsonObject['object'].get('shares'):
+ if debug:
+ print('DEBUG: Adding initial shares (announcements) to ' +
+ postUrl)
+ announcementsJson = {
+ "@context": "https://www.w3.org/ns/activitystreams",
+ 'id': postUrl,
+ 'type': 'Collection',
+ "totalItems": 1,
+ 'items': [{
+ 'type': 'Announce',
+ 'actor': actor
+ }]
+ }
+ postJsonObject['object']['shares'] = announcementsJson
+ else:
+ if postJsonObject['object']['shares'].get('items'):
+ sharesItems = postJsonObject['object']['shares']['items']
+ for announceItem in sharesItems:
+ if announceItem.get('actor'):
+ if announceItem['actor'] == actor:
+ return
+ newAnnounce = {
+ 'type': 'Announce',
+ 'actor': actor
+ }
+ postJsonObject['object']['shares']['items'].append(newAnnounce)
+ itlen = len(postJsonObject['object']['shares']['items'])
+ postJsonObject['object']['shares']['totalItems'] = itlen
+ else:
+ if debug:
+ print('DEBUG: shares (announcements) section of post ' +
+ 'has no items list')
+
+ if debug:
+ print('DEBUG: saving post with shares (announcements) added')
+ pprint(postJsonObject)
+ saveJson(postJsonObject, postFilename)
def weekDayOfMonthStart(monthNumber: int, year: int) -> int:
@@ -1909,3 +2158,423 @@ def mediaFileMimeType(filename: str) -> str:
if not extensions.get(fileExt):
return 'image/png'
return extensions[fileExt]
+
+
+def isRecentPost(postJsonObject: {}, maxDays: int = 3) -> bool:
+ """ Is the given post recent?
+ """
+ if not hasObjectDict(postJsonObject):
+ return False
+ if not postJsonObject['object'].get('published'):
+ return False
+ if not isinstance(postJsonObject['object']['published'], str):
+ return False
+ currTime = datetime.datetime.utcnow()
+ daysSinceEpoch = (currTime - datetime.datetime(1970, 1, 1)).days
+ recently = daysSinceEpoch - maxDays
+
+ publishedDateStr = postJsonObject['object']['published']
+ try:
+ publishedDate = \
+ datetime.datetime.strptime(publishedDateStr,
+ "%Y-%m-%dT%H:%M:%SZ")
+ except BaseException:
+ return False
+
+ publishedDaysSinceEpoch = \
+ (publishedDate - datetime.datetime(1970, 1, 1)).days
+ if publishedDaysSinceEpoch < recently:
+ return False
+ return True
+
+
+def camelCaseSplit(text: str) -> str:
+ """ Splits CamelCase into "Camel Case"
+ """
+ matches = re.finditer('.+?(?:(?<=[a-z])(?=[A-Z])|' +
+ '(?<=[A-Z])(?=[A-Z][a-z])|$)', text)
+ if not matches:
+ return text
+ resultStr = ''
+ for word in matches:
+ resultStr += word.group(0) + ' '
+ return resultStr.strip()
+
+
+def rejectPostId(baseDir: str, nickname: str, domain: str,
+ postId: str, recentPostsCache: {}) -> None:
+ """ Marks the given post as rejected,
+ for example an announce which is too old
+ """
+ postFilename = locatePost(baseDir, nickname, domain, postId)
+ if not postFilename:
+ return
+
+ if recentPostsCache.get('index'):
+ # if this is a full path then remove the directories
+ indexFilename = postFilename
+ if '/' in postFilename:
+ indexFilename = postFilename.split('/')[-1]
+
+ # filename of the post without any extension or path
+ # This should also correspond to any index entry in
+ # the posts cache
+ postUrl = \
+ indexFilename.replace('\n', '').replace('\r', '')
+ postUrl = postUrl.replace('.json', '').strip()
+
+ if postUrl in recentPostsCache['index']:
+ if recentPostsCache['json'].get(postUrl):
+ del recentPostsCache['json'][postUrl]
+ if recentPostsCache['html'].get(postUrl):
+ del recentPostsCache['html'][postUrl]
+
+ with open(postFilename + '.reject', 'w+') as rejectFile:
+ rejectFile.write('\n')
+
+
+def isDM(postJsonObject: {}) -> bool:
+ """Returns true if the given post is a DM
+ """
+ if postJsonObject['type'] != 'Create':
+ return False
+ if not hasObjectDict(postJsonObject):
+ return False
+ if postJsonObject['object']['type'] != 'Note' and \
+ postJsonObject['object']['type'] != 'Patch' and \
+ postJsonObject['object']['type'] != 'EncryptedMessage' and \
+ postJsonObject['object']['type'] != 'Article':
+ return False
+ if postJsonObject['object'].get('moderationStatus'):
+ return False
+ fields = ('to', 'cc')
+ for f in fields:
+ if not postJsonObject['object'].get(f):
+ continue
+ for toAddress in postJsonObject['object'][f]:
+ if toAddress.endswith('#Public'):
+ return False
+ if toAddress.endswith('followers'):
+ return False
+ return True
+
+
+def isReply(postJsonObject: {}, actor: str) -> bool:
+ """Returns true if the given post is a reply to the given actor
+ """
+ if postJsonObject['type'] != 'Create':
+ return False
+ if not hasObjectDict(postJsonObject):
+ return False
+ if postJsonObject['object'].get('moderationStatus'):
+ return False
+ if postJsonObject['object']['type'] != 'Note' and \
+ postJsonObject['object']['type'] != 'EncryptedMessage' and \
+ postJsonObject['object']['type'] != 'Article':
+ return False
+ if postJsonObject['object'].get('inReplyTo'):
+ if isinstance(postJsonObject['object']['inReplyTo'], str):
+ if postJsonObject['object']['inReplyTo'].startswith(actor):
+ return True
+ if not postJsonObject['object'].get('tag'):
+ return False
+ if not isinstance(postJsonObject['object']['tag'], list):
+ return False
+ for tag in postJsonObject['object']['tag']:
+ if not tag.get('type'):
+ continue
+ if tag['type'] == 'Mention':
+ if not tag.get('href'):
+ continue
+ if actor in tag['href']:
+ return True
+ return False
+
+
+def containsPGPPublicKey(content: str) -> bool:
+ """Returns true if the given content contains a PGP public key
+ """
+ if '--BEGIN PGP PUBLIC KEY BLOCK--' in content:
+ if '--END PGP PUBLIC KEY BLOCK--' in content:
+ return True
+ return False
+
+
+def isPGPEncrypted(content: str) -> bool:
+ """Returns true if the given content is PGP encrypted
+ """
+ if '--BEGIN PGP MESSAGE--' in content:
+ if '--END PGP MESSAGE--' in content:
+ return True
+ return False
+
+
+def loadTranslationsFromFile(baseDir: str, language: str) -> ({}, str):
+ """Returns the translations dictionary
+ """
+ if not os.path.isdir(baseDir + '/translations'):
+ print('ERROR: translations directory not found')
+ return
+ if not language:
+ systemLanguage = locale.getdefaultlocale()[0]
+ else:
+ systemLanguage = language
+ if not systemLanguage:
+ systemLanguage = 'en'
+ if '_' in systemLanguage:
+ systemLanguage = systemLanguage.split('_')[0]
+ while '/' in systemLanguage:
+ systemLanguage = systemLanguage.split('/')[1]
+ if '.' in systemLanguage:
+ systemLanguage = systemLanguage.split('.')[0]
+ translationsFile = baseDir + '/translations/' + \
+ systemLanguage + '.json'
+ if not os.path.isfile(translationsFile):
+ systemLanguage = 'en'
+ translationsFile = baseDir + '/translations/' + \
+ systemLanguage + '.json'
+ return loadJson(translationsFile), systemLanguage
+
+
+def dmAllowedFromDomain(baseDir: str,
+ nickname: str, domain: str,
+ sendingActorDomain: str) -> bool:
+ """When a DM is received and the .followDMs flag file exists
+ Then optionally some domains can be specified as allowed,
+ regardless of individual follows.
+ i.e. Mostly you only want DMs from followers, but there are
+ a few particular instances that you trust
+ """
+ dmAllowedInstancesFilename = \
+ acctDir(baseDir, nickname, domain) + '/dmAllowedInstances.txt'
+ if not os.path.isfile(dmAllowedInstancesFilename):
+ return False
+ if sendingActorDomain + '\n' in open(dmAllowedInstancesFilename).read():
+ return True
+ return False
+
+
+def getOccupationSkills(actorJson: {}) -> []:
+ """Returns the list of skills for an actor
+ """
+ if 'hasOccupation' not in actorJson:
+ return []
+ if not isinstance(actorJson['hasOccupation'], list):
+ return []
+ for occupationItem in actorJson['hasOccupation']:
+ if not isinstance(occupationItem, dict):
+ continue
+ if not occupationItem.get('@type'):
+ continue
+ if not occupationItem['@type'] == 'Occupation':
+ continue
+ if not occupationItem.get('skills'):
+ continue
+ if isinstance(occupationItem['skills'], list):
+ return occupationItem['skills']
+ elif isinstance(occupationItem['skills'], str):
+ return [occupationItem['skills']]
+ break
+ return []
+
+
+def getOccupationName(actorJson: {}) -> str:
+ """Returns the occupation name an actor
+ """
+ if not actorJson.get('hasOccupation'):
+ return ""
+ if not isinstance(actorJson['hasOccupation'], list):
+ return ""
+ for occupationItem in actorJson['hasOccupation']:
+ if not isinstance(occupationItem, dict):
+ continue
+ if not occupationItem.get('@type'):
+ continue
+ if occupationItem['@type'] != 'Occupation':
+ continue
+ if not occupationItem.get('name'):
+ continue
+ if isinstance(occupationItem['name'], str):
+ return occupationItem['name']
+ break
+ return ""
+
+
+def setOccupationName(actorJson: {}, name: str) -> bool:
+ """Sets the occupation name of an actor
+ """
+ if not actorJson.get('hasOccupation'):
+ return False
+ if not isinstance(actorJson['hasOccupation'], list):
+ return False
+ for index in range(len(actorJson['hasOccupation'])):
+ occupationItem = actorJson['hasOccupation'][index]
+ if not isinstance(occupationItem, dict):
+ continue
+ if not occupationItem.get('@type'):
+ continue
+ if occupationItem['@type'] != 'Occupation':
+ continue
+ occupationItem['name'] = name
+ return True
+ return False
+
+
+def setOccupationSkillsList(actorJson: {}, skillsList: []) -> bool:
+ """Sets the occupation skills for an actor
+ """
+ if 'hasOccupation' not in actorJson:
+ return False
+ if not isinstance(actorJson['hasOccupation'], list):
+ return False
+ for index in range(len(actorJson['hasOccupation'])):
+ occupationItem = actorJson['hasOccupation'][index]
+ if not isinstance(occupationItem, dict):
+ continue
+ if not occupationItem.get('@type'):
+ continue
+ if occupationItem['@type'] != 'Occupation':
+ continue
+ occupationItem['skills'] = skillsList
+ return True
+ return False
+
+
+def isAccountDir(dirName: str) -> bool:
+ """Is the given directory an account within /accounts ?
+ """
+ if '@' not in dirName:
+ return False
+ if 'inbox@' in dirName or 'news@' in dirName:
+ return False
+ return True
+
+
+def permittedDir(path: str) -> bool:
+ """These are special paths which should not be accessible
+ directly via GET or POST
+ """
+ if path.startswith('/wfendpoints') or \
+ path.startswith('/keys') or \
+ path.startswith('/accounts'):
+ return False
+ return True
+
+
+def userAgentDomain(userAgent: str, debug: bool) -> str:
+ """If the User-Agent string contains a domain
+ then return it
+ """
+ if '+http' not in userAgent:
+ return None
+ agentDomain = userAgent.split('+http')[1].strip()
+ if '://' in agentDomain:
+ agentDomain = agentDomain.split('://')[1]
+ if '/' in agentDomain:
+ agentDomain = agentDomain.split('/')[0]
+ if ')' in agentDomain:
+ agentDomain = agentDomain.split(')')[0].strip()
+ if ' ' in agentDomain:
+ agentDomain = agentDomain.replace(' ', '')
+ if ';' in agentDomain:
+ agentDomain = agentDomain.replace(';', '')
+ if '.' not in agentDomain:
+ return None
+ if debug:
+ print('User-Agent Domain: ' + agentDomain)
+ return agentDomain
+
+
+def hasObjectDict(postJsonObject: {}) -> bool:
+ """Returns true if the given post has an object dict
+ """
+ if postJsonObject.get('object'):
+ if isinstance(postJsonObject['object'], dict):
+ return True
+ return False
+
+
+def getAltPath(actor: str, domainFull: str, callingDomain: str) -> str:
+ """Returns alternate path from the actor
+ eg. https://clearnetdomain/path becomes http://oniondomain/path
+ """
+ postActor = actor
+ if callingDomain not in actor and domainFull in actor:
+ if callingDomain.endswith('.onion') or \
+ callingDomain.endswith('.i2p'):
+ postActor = \
+ 'http://' + callingDomain + actor.split(domainFull)[1]
+ print('Changed POST domain from ' + actor + ' to ' + postActor)
+ return postActor
+
+
+def getActorPropertyUrl(actorJson: {}, propertyName: str) -> str:
+ """Returns a url property from an actor
+ """
+ if not actorJson.get('attachment'):
+ return ''
+ propertyName = propertyName.lower()
+ for propertyValue in actorJson['attachment']:
+ if not propertyValue.get('name'):
+ continue
+ if not propertyValue['name'].lower().startswith(propertyName):
+ continue
+ if not propertyValue.get('type'):
+ continue
+ if not propertyValue.get('value'):
+ continue
+ if propertyValue['type'] != 'PropertyValue':
+ continue
+ propertyValue['value'] = propertyValue['value'].strip()
+ prefixes = getProtocolPrefixes()
+ prefixFound = False
+ for prefix in prefixes:
+ if propertyValue['value'].startswith(prefix):
+ prefixFound = True
+ break
+ if not prefixFound:
+ continue
+ if '.' not in propertyValue['value']:
+ continue
+ if ' ' in propertyValue['value']:
+ continue
+ if ',' in propertyValue['value']:
+ continue
+ return propertyValue['value']
+ return ''
+
+
+def removeDomainPort(domain: str) -> str:
+ """If the domain has a port appended then remove it
+ eg. mydomain.com:80 becomes mydomain.com
+ """
+ if ':' in domain:
+ if domain.startswith('did:'):
+ return domain
+ domain = domain.split(':')[0]
+ return domain
+
+
+def getPortFromDomain(domain: str) -> int:
+ """If the domain has a port number appended then return it
+ eg. mydomain.com:80 returns 80
+ """
+ if ':' in domain:
+ if domain.startswith('did:'):
+ return None
+ portStr = domain.split(':')[1]
+ if portStr.isdigit():
+ return int(portStr)
+ return None
+
+
+def validUrlPrefix(url: str) -> bool:
+ """Does the given url have a valid prefix?
+ """
+ if '/' not in url:
+ return False
+ prefixes = ('https:', 'http:', 'hyper:', 'i2p:', 'gnunet:')
+ for pre in prefixes:
+ if url.startswith(pre):
+ return True
+ return False
diff --git a/webapp_about.py b/webapp_about.py
index 9493c1b59..607e08a6d 100644
--- a/webapp_about.py
+++ b/webapp_about.py
@@ -5,22 +5,25 @@ __version__ = "1.2.0"
__maintainer__ = "Bob Mottram"
__email__ = "bob@freedombone.net"
__status__ = "Production"
+__module_group__ = "Web Interface"
import os
from shutil import copyfile
from utils import getConfigParam
-from webapp_utils import htmlHeaderWithExternalStyle
+from webapp_utils import htmlHeaderWithWebsiteMarkup
from webapp_utils import htmlFooter
+from markdown import markdownToHtml
def htmlAbout(cssCache: {}, baseDir: str, httpPrefix: str,
- domainFull: str, onionDomain: str, translate: {}) -> str:
+ domainFull: str, onionDomain: str, translate: {},
+ systemLanguage: str) -> str:
"""Show the about screen
"""
adminNickname = getConfigParam(baseDir, 'admin')
- if not os.path.isfile(baseDir + '/accounts/about.txt'):
- copyfile(baseDir + '/default_about.txt',
- baseDir + '/accounts/about.txt')
+ if not os.path.isfile(baseDir + '/accounts/about.md'):
+ copyfile(baseDir + '/default_about.md',
+ baseDir + '/accounts/about.md')
if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'):
if not os.path.isfile(baseDir + '/accounts/login-background.jpg'):
@@ -28,9 +31,9 @@ def htmlAbout(cssCache: {}, baseDir: str, httpPrefix: str,
baseDir + '/accounts/login-background.jpg')
aboutText = 'Information about this instance goes here.'
- if os.path.isfile(baseDir + '/accounts/about.txt'):
- with open(baseDir + '/accounts/about.txt', 'r') as aboutFile:
- aboutText = aboutFile.read()
+ if os.path.isfile(baseDir + '/accounts/about.md'):
+ with open(baseDir + '/accounts/about.md', 'r') as aboutFile:
+ aboutText = markdownToHtml(aboutFile.read())
aboutForm = ''
cssFilename = baseDir + '/epicyon-profile.css'
@@ -39,7 +42,10 @@ def htmlAbout(cssCache: {}, baseDir: str, httpPrefix: str,
instanceTitle = \
getConfigParam(baseDir, 'instanceTitle')
- aboutForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle)
+ aboutForm = \
+ htmlHeaderWithWebsiteMarkup(cssFilename, instanceTitle,
+ httpPrefix, domainFull,
+ systemLanguage)
aboutForm += '
' + \
'\n'
@@ -111,13 +108,11 @@ def getLeftColumnContent(baseDir: str, nickname: str, domainFull: str,
if editor:
# show the edit icon
htmlStr += \
- ' ' + \
- '' + \
+ '\n'
+ translate['Edit Links'] + '" src="/icons/edit.png" />\n'
# RSS icon
if nickname != 'news':
@@ -132,10 +127,8 @@ def getLeftColumnContent(baseDir: str, nickname: str, domainFull: str,
else:
rssTitle = translate['RSS feed for this site']
rssIconStr = \
- ' ' + \
- '\n'
if rssIconAtTop:
htmlStr += rssIconStr
@@ -157,7 +150,7 @@ def getLeftColumnContent(baseDir: str, nickname: str, domainFull: str,
linksFileContainsEntries = False
linksList = None
if os.path.isfile(linksFilename):
- with open(linksFilename, "r") as f:
+ with open(linksFilename, 'r') as f:
linksList = f.readlines()
if not frontPage:
@@ -176,19 +169,42 @@ def getLeftColumnContent(baseDir: str, nickname: str, domainFull: str,
if ' ' not in lineStr:
if '#' not in lineStr:
if '*' not in lineStr:
- continue
+ if not lineStr.startswith('['):
+ if not lineStr.startswith('=> '):
+ continue
lineStr = lineStr.strip()
- words = lineStr.split(' ')
- # get the link
linkStr = None
- for word in words:
- if word == '#':
+ if not lineStr.startswith('['):
+ words = lineStr.split(' ')
+ # get the link
+ for word in words:
+ if word == '#':
+ continue
+ if word == '*':
+ continue
+ if word == '=>':
+ continue
+ if '://' in word:
+ linkStr = word
+ break
+ else:
+ # markdown link
+ if ']' not in lineStr:
continue
- if word == '*':
+ if '(' not in lineStr:
continue
- if '://' in word:
- linkStr = word
- break
+ if ')' not in lineStr:
+ continue
+ linkStr = lineStr.split('(')[1]
+ if ')' not in linkStr:
+ continue
+ linkStr = linkStr.split(')')[0]
+ if '://' not in linkStr:
+ continue
+ lineStr = lineStr.split('[')[1]
+ if ']' not in lineStr:
+ continue
+ lineStr = lineStr.split(']')[0]
if linkStr:
lineStr = lineStr.replace(linkStr, '').strip()
# avoid any dubious scripts being added
@@ -203,6 +219,17 @@ def getLeftColumnContent(baseDir: str, nickname: str, domainFull: str,
'rel="nofollow noopener noreferrer">' + \
lineStr + '\n'
linksFileContainsEntries = True
+ elif lineStr.startswith('=> '):
+ # gemini style link
+ lineStr = lineStr.replace('=> ', '')
+ lineStr = lineStr.replace(linkStr, '')
+ # add link to the returned html
+ htmlStr += \
+ '