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. + +![commandline and core modules](https://gitlab.com/bashrc2/epicyon/-/raw/main/architecture/epicyon_groups_Commandline-Interface_Core.png) + +The daemon runs the inbox queue in a separate thread (see *inbox.py*) and the inbox queue processes incoming ActivityPub posts one at a time in a strictly serial fashion. Doing it this way means minimum potential for any parallelism/locking issues. It also means that the inbox queue is not highly scalable, but that's ok for a system which is only intended to have a few users per instance. + +All ActivityPub posts are stored as text files, and there is no database as such other than the filesystem itself. Think of it as being like an email server. Each post is a json file stored in *accounts/nick@domain/inbox* or *accounts/nick@domain/outbox*. To avoid parsing problems slashes are replaced by hashes within the ActivityPub post filename. The filename for each post is the same as its ActivityPub id. + +![timeline and core modules](https://gitlab.com/bashrc2/epicyon/-/raw/main/architecture/epicyon_groups_Timeline_Core.png) + + +## Security + +### Themes + +It is possible to include arbitrary CSS within a custom theme. To avoid security problems the CSS is sanitized before being used. Scripts or import references to other CSS files are not permitted. + +The way that the theming system was designed is in order to avoid problems similar to Wordpress, in which an adversary will create an attactive looking theme which contains an exploit. The discovery of exploits then leads to a centralizing dynamic where there is a single "official" themes website or app store. With Epicyon, *themes should always be safe to use no matter where they were downloaded from*. There should be nothing *Turing complete* within a theme. + +### C2S + +This currently uses basic auth, which is simple to implement. Oauth2 is conventional, but seems overly complex and the user interface for it within other comparable apps is clunky. + +### Interaction with Timeline + +![timeline and security modules](https://gitlab.com/bashrc2/epicyon/-/raw/main/architecture/epicyon_groups_Timeline_Security.png) + +The *inbox* queue makes calls to check http and linked data signatures. Various modules call *auth* typically because they're implementing the basic auth of the C2S interface. + +## Accessibility + +Trying to keep up with web accessibility standards. There should be configurable keyboard shortcuts for all of the main navigation actions. High contrast themes should be available. The desktop client should support text-to-speech. There should be the ability to run in a shell browser such as Lynx, without any significant loss of functionality. + +Avoid adding any features which would be hard to make accessible. + +![web interface and accessibility modules](https://gitlab.com/bashrc2/epicyon/-/raw/main/architecture/epicyon_groups_Web-Interface_Accessibility.png) + +The *webapp_post* module generates html for each post from its ActivityPub json representation. This also calls the speaker module in order to create a text-to-speech friendly version of the post content, which can then be spoken by the desktop client. Doing this allows common acronyms and other special language to be properly pronounced. + +The *daemon* module (http server) also calls *webapp_accesskeys* to display the key shortcuts screen. + +![core and accessibility modules](https://gitlab.com/bashrc2/epicyon/-/raw/main/architecture/epicyon_groups_Core_Accessibility.png) 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-- ', '

') content = htmlReplaceEmailQuote(content) return '

' + htmlReplaceQuoteMarks(content) + '

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

- -

Copyrights

- -

Epicyon is licensed under GNU AGPL version 3 - -

Emojis designed by OpenMoji – the open-source emoji and icon project. License: CC BY-SA 4.0

- -

Blob Cat Emoji and Meowmoji were made by Nitro Blob Hub, licensed under Apache 2.0.

diff --git a/defaultcategories/en.xml b/defaultcategories/en.xml index d57452917..297b984f5 100644 --- a/defaultcategories/en.xml +++ b/defaultcategories/en.xml @@ -4,585 +4,663 @@ #categories retro - retrocomputer A500 atarist commodore teletext Retromeme matariki floppy 8bit atari atari800 trs80 floppydisk retrocomputing C64 ansi plan9 80s microcomputing omm retrogaming z80 8bitdo retro retropie amiga bbcmicro microcomputer bbsing + retrocomputer kommunalwahl 90sretro A500 CreativeCommons ecommerce atarist SistersWithTransistors vax retroarch commodore retroffiting teletext Retromeme matariki floppy recommendation 8bit cassette arcade atari communicators atari800 oldschool trs80 communication atari8bit floppydisk retrocomputing recommended C64 nostalgia bbs ansi communicationtheory plan9 80s microcomputing kommunikation vaxvms retroarcade zdfretro woocommerce cassette_tapes bonhomme omm retrogaming z80 8bitdo retro atari800xl telekommunikation VollaCommunityDays retropie commodore64 cassettetapes retrogame Trans amiga bbcmicro retrofriday microcomputer bbsing commercial - Thu, 18 Feb 2021 21:34:48 UT - - - sport - billiard darts swim motorsport snooker marathon hockey bouldering diving baseball Millwall mma sailing athletics nook dumpsterdiving skating skiing sport football combatsports - - Thu, 18 Feb 2021 21:34:48 UT - - - events - neverforget award OONIbday waybackwednesday IndigenousPeoplesDay5 notifications solo throwbackthursday valentinesday adventskalender live Day deepthoughts thingaday screenshotsaturday warmingup thursdaythoughts thursdaymorning throwback animalsweatersunday TooMuchScreenTime beethoven250thbirthday valentine humanrightsday followfriday wednesdaythought afediversechristmas whydopeopledoshitlikethis wednesdaymotivation cccamp19 PostLikeYouThinkACrabWouldSunday showerthoughts beethoven anarchymonday Verkiezingsfestival 100DaysToOffload 0day ff holiday ipv4flagday christmas livecoding weeknotes week concert festival FridayFolklore screenshottuesday VerkiezingsfestivalBIJ1 kdenlive dontstarve onthisday insideoutsockday livestream BowieDay tzag sunday weekendvibes screenshotsunday cree VerseThursday liverpool waitangiday adayinthelife day ccc InternationalCheetahDay interestingtimes sideprojectsunday birthday sixonsaturday wordoftheday christmaslights meetup - - Thu, 18 Feb 2021 21:34:48 UT - - - gafam - zuckerberg caringissharing ads apple antitrust SpringerEnteignen GoogleDown AppleSearch bankruptBezos googlesearch mycologists youtube Goggle twitterkurds chromebook headset ffs facebook interoperability amazon farcebook boycottinstagram deletewhatsappday amazonring Gafam googleplus degooglisation siri Facebook LeiharbeitAbschaffen advertising monopolies adtech fuckgoogle plottertwitter microsoft dtm twitter skype azure chrome hildebrandt corporateGiant uitkeringen youtubedl degoogled youtubers google gis walledgarden GAFCAM dt dotcoms deleteyoutube datafarms Instagram walledgardens appleii fascistbook FuckGoogle degoogle fuschia appleiie deleteinstagram ungoogled ring stopgoogle earrings affordances googledown gafam inspiring fuckoffgoogle deletefacebook fuckoffgoogleandco office365 instagram playstore bigtech whatsapp deleteamazon bluesky - - Thu, 18 Feb 2021 21:34:48 UT - - - people - Melissa harold paul Zachary markdown danielle dylan scott Barbara Kenneth theresa Denise Jesse Adam justin katherine judith Karen Patricia russell juan diane Rebecca donna olivia peter William denise Betty evelyn Christina brittany Jennifer Gregory Wayne Andrychów ethan Ralph Peter americalatina jean laura betty nathan margaret Bryan Virginia Jose Rose eric james david Joshua christine Billy CapitolRiot natalie daniel Jonathan Michael susan George johnny Lauren christina Amy kevin Natalie kenneth noahkathryn Lawrence aaron gregory Amber alexa Robert Edward Patrick Rachel bruce dennis Kayla frank Diane Donna Jack Paul Janice Brenda alexis timothy vincent Alice sarah amy Daniel jeff charlotte carolyn Emma Kyle Sean emily linda Olivia Eugene Donald janet ryan RichardDWolff bryan Hannah anna doctorow Catherine Alexander Christopher bob doris Anthony Jean diana Beverly frances Sarah margaretthatcher Jordan Anna Ethan Amanda jeremy donald mark matthew julie stephanie Jerry Diana David Linda adam richard henry Isabella elizabeth steven jessica Walter jeffrey Kevin Justin grace PeterGelderloos brandon mary jamesbaldwin sharon nicholas Benjamin amanda Emily Ruth heather albert Julie nancy stephen Cannabis James Megan Raymond michelle Nancy Frances Henry andrew Jessica julia Dorothy Jason Charles Danielle Brandon jose noamchomsky virginia beverly ronald Bob madison Helen MarkoBogoievski Jeff helen Sophia larry dorothy Dennis monbiot Nicholas Frank jack Stephen Janet Alexis Pamela Jacqueline Dylan roy brenda jesse Roger Jeffrey Brittany Shirley Nathan christopher Carol Susan jason Philip Logan sandra jacob rose isabella Cynthia Joan jackieweaver aldoushuxley Maria martha Randy carl kyle karen raymond alice jerry carol Victoria Steven Douglas Lisa Julia joshua jacqueline Ashley eugene Bruce Albert Austin thomas Evelyn Gary Scott kimberly lawrence jennifer Russell austin logan Laura Chris Teresa Aaron Keith brian marktwain maria Joseph Andrew Vincent Katherine Joyce lauren Ryan amber alan ralph megan Kathleen sophia Cheryl abigail cynthia john Alan Debra arthurgloria Christine marilyn anthony chris Elizabeth sean Louis Larry christian deborah billy Abigail joesara keith Jeremy zachary ruth Grace teresa Doris benjamin Willie george barbara Charlotte philip randy Margaret Heather Bradley Jacob shirley pamela Matthew Nicole joan judy Kelly Brian melissa Sandra joseph andrea Joe Sara robert aaronswartz Bobby emma willie william angela SachaChua samuel tyler Thomas John kroger patricia ashley bobby kelly hannah Carolyn Ann CrimsonRosella gary wayne Marilyn Deborah rms Sharon gare Mary Samuel Mark walter rebecca Madison Juan lisa cheryl janice Christian gerald Timothy roger edward bradley Gerald patrickrachel framalang Kimberly Gabriel Marie PeterHoffmann louis kathleen Arthur Gloria terry Richard jonathan Harold Roy samantha Carl Eric relationships nicole Andrea Judith Terry Stephanie Johnny Angela Noah Kathryn Ronald AskVanta Michelle Theresa gabrielmarie Samantha Judy michael charles Tyler DouglasPFry kayla catherinealexander Martha debra joyce - - Thu, 18 Feb 2021 21:34:48 UT - - - activitypub - followerpower FederatedSocialMedia Fediverse activitypub activertypub pleroma losttoot Rss2Fedi PeerTube gofed pixelfedlabs homelab fediblock fediverso lazyfedi federation instances fedilab pixiv mastotips toot fedilabfeature mastodev pixel mastodontips mastotip wallaby friendica mastodontip hiveway mastodonart mast gardenersofmastodon askthefediverse misskey collaboraoffice BlackFedi joinmastodon siskin socialhub followers fediart blocking Pixelfed contentwarnings pixelfed labournettv fediverseplaysjackbox mapeocolaborativo fedihive fedidb block FediMemories Feditip fablab Fediseminar onlyfedi socialcg monal sponsorblock tusky peertubers imagedescription joinpeertube feditips fedizens Mastodon following epicyon afediversechat peertubeadmin collaboration mastomagic YearOfTheFediverse dev fediadmin mastodonhost mond pixeldev timeline socialmedia wedistribute fosstodon instanceblock softwaredevelopment mastodonmonday isolategab greenmastodon fedireads PeertubeMastodonHost AskFediverse Bookwyrm federated socialhome greenfediverse microblocks fedivers MastodonMondays fediverse imagedescriptions Fedilab mastoadmin smithereen hackerstown blabber FediverseFutures mastodon fedi fediplay peertube adblock lab BlackMastadon mobilizon lazy gemifedi - - Thu, 18 Feb 2021 21:34:48 UT - - - programming - Easer cpp digitalpreservation programming css objects Python digitalisierung FrancisBacon2020 gitea mixers webdev gui digital release ada schutzstreifen pypi proofofwork rustlang ocaml program DigitalSouveräneSchule request_reaction penguicon2021 sqlite guile nim uptronics hypocritcal profiles typescript forums vscode publiccode computerscience hackers vieprivée early adventofcode cgit CommonJS scripting warn git ui solid trevornoah zinccoop tailwindcss guix penguicon raku fedidev c sourcecode publiekecode framaforms WendyLPatrick grep django gmic sackthelot gitportal gitlab decoder parrot relevance_P1Y kingparrot Leiharbeit programmer trunk haskell OpenSourceHardware Tarifvertrag unicode development gerrit frgmntscnr github digitalmarketsact freecodecamp openrc tuskydev threema html5 algorithms PythonJob lisp digitaldefenders forge pleaseshare HirsuteHippo resnetting fourtwenty rakudev adaptation developers libraries drivers animation freecode forgefed javascript fragment cpm code elisp commands patterns eq ECMAScriptModules html rakulang portal terminal rust request spiritbomb r dramasystem go esbuild golang clojurescript ruby contractpatch deceptionpatterns obsolescence_programmée computers developer racket forum bugreport mercurial python indiedev kabelfernsehen alternatives OpenSource Scheibenwischer - - Thu, 18 Feb 2021 21:34:48 UT - - - nature - hiking RedNeckedWallaby wat marsupial StormBella morning trees lichen badger light birds nature teamcapy frogs sunrise leopardgecko moutains coldwater inaturalist forest morningcrew australianwildlife capybara goodmorning enlightenment natur deforestation morningwalk RainforestAlliance amphibians - - Thu, 18 Feb 2021 21:34:48 UT - - - writing - blog tootfic authors poem cutupmethod pdf blogPages shortstory magazine smallstories blogging smallpoems sciencefiction writing proverbs blogs noblogo playwright hedgedoc microfiction interactivestorytelling westernjournal quote icanhazpdf WriteFreely storytelling goodread goodreads creativewriting horror limerick journals zineswap zines shortstories journalists journal poetry - - Thu, 18 Feb 2021 21:34:48 UT - - - gardening - BlagueDeCodeur sporespondence blockade inde mastogarden kinder independant communitygardening deno composting cabbage bundeswehr onions bordeaux datenschleuder florespondence garden thyme horticulture DailyFlowers acu kinderbijslag permaculture papuamerdeka lag CompanionPlanting gardens independence flowers kale gardening plants devilslettuce fahrräder golden toeslagenaffaire seeds - - Thu, 18 Feb 2021 21:34:48 UT - - - hardware - plugandplay purism opennic restauration solarpower PersonalComputer cyberdeck PineCUBE keyboards electron screenless homebrewcomputing FarmersTractorRally pinebook modem lowtech datenschutz industrial analogcomputing TrueDelta keyboard screenprinting pocketchip oshw ArmWorkstation daretocare hardwarehacking laptops electronics teamdatenschutz charm printmaker deck larp cybredeck computing laptop solarpunk recycling theatrelighting lenovo fairelectronics MacBookProService fuse ibm 3dprinting MechcanicalKeyboards hardware retrohardware pinetab openhardware raspberrypi 3dprinter barcode Quartz64 PlanetComputer jtag pinetime screens pinebookpro 3d PinebookPro 3dprint modemmanager keyboardio mechanicalkeyboard PineTalk arm ipad FireAlarms PinePower paperComputer amd openpower devopa thinkpad iot raspberrypi4 print electronic - - Thu, 18 Feb 2021 21:34:48 UT - - - internet - linkeddata decentralised immersiveweb dotcons i2p spam firefox redecentralize decentral wikipedia rtmp dataprotection decentralization inclusiónsocial decentralize w3c dotConism selfhosted piratenpartij maille meta torrent mailab k9mail data socialism sitejs anticolonial worldbusterssocialclub publicserviceinternet centralisation internetarchaeology WordPress darkages self saferinternetday contentmoderation distributed mydata decentralizedweb mailman router protection rne dataretention bigdata routeros selfhosting communityhosting brave icann selfsustaining hosting discourse weblate PeerToPeer dns openstandards nojs oauth tic hypercore CDNsAreEvil meshtastic protonmail TubEdu standards yourdataisyourdata internetfreedom gemini webui InternetCrimeFamily wlan internetBanking SmallWeb cw internetshutdown godotengine liberapay distributedcoop xmpp semanticweb socialnetwork selfie website SaferInternetDay content splinternet highavailability zim webstandards mapa domains ntp cloudfront socialnetworks metadata disconnect Meme database proton disco descentralizare icmp videocast jabber cleanuptheweb webbrowsers decentralized wiki cloudron browserextensions ssb darknet cookies Qute darkweb netcat map Reddit server browser cloudy IPFS p2p social antisocial www ilovewikipedia web WebsiteStatus netshutdowns twitch im 9front theserverroom datafree domain OpenStreetMap pirate datacracy filesharing rss openstreetmap ipns mozilla voicemail Nyxt debloattheweb mail i2pd ipfs internetradio browsers wikidata selfpub decentralizeit netscape mixcloud gmail openculture letthenetwork cyberspace messaging selfies offthegrid enxeñeríasocial cloud internet moderation decentralisation ssbroom serverMeddling missingmaps sneakernet NatureNeedsJustice internetarchive godot dweb js dark beakerbrowser openweb NetShutdown onlineharms dot Internet descentralizarea thepiratebay ftp internetshutdowns fixtheweb lazyweb socialweb mozillahubs instantmessaging interoperabilitate webmention Justice4MohamudHassan - - Thu, 18 Feb 2021 21:34:48 UT - - - science - engineering math epidemiology ethnology womeninstem archeology botany STEM biodiversity linguistic anthro supercollider nextgeneration zoology climatology dna geography physics archaeologist generalstreik geology generationidentitaire stemwijzer bioengineering botanical dawkins holo graphTheory meterology biotech psychology biology generation gene chemistry paleontology oceanography stem nextgenerationinternet biomedical anthropology - - Thu, 18 Feb 2021 21:34:48 UT - - - photos - smartphonephotography nikon 90mm photography photo photogrpahy tokyocameraclub photos photographie camera cameras myphoto naturephotography picture streetphotography photoreference - - Thu, 18 Feb 2021 21:34:48 UT - - - places - lapaz luanda asunción nouakchott conakry kyiv moscow saipan gibraltar dublin KlimaGerechtigkeit catalunya dannibleibt avarua hargeisa delhi niamey chișinău freestuff colombo brasília phnompenh mbabane danni belgrade belmopan pyongyang hannover calls ulaanbaatar oranjestad Reykjavik gaborone seattle ndjamena raw singapore kingedwardpoint abidjan nuuk asshole pretoria papeete malé zagreb gitega abudhabi flyingfishcove castries georgetown hagåtña videoFreex cassette oric borikua basseterre hamburg kinshasa suva klimaatverandering valparaíso athens roseau sheffield baku charlotteamalie antananarivo domi pristina MakoYass videocalls santiago sukhumi berlin uptronicsberlin funafuti libreville puertorico ClimateChange hanoi philipsburg tehran banjul prague rawhide andorralavella daw yerevan portauprince videoprojects dakar paramaribo tifariti capetown rigaer94 tirana klima ankara ipswich managua lisbon bishkek amsterdam climatchoas videogames klimaat portonovo santodomingo bangkok texas bucharest kathmandu aden madrid sanjuan vienna kingston kabul damascus stockholm douglas ClassOf2015 willemstad hibernoenglish thehague panamacity RassismusTötet beirut amman newdelhi tórshavn nouméa oslo alofi gustavia paris video cockburntown ottawa classical stepanakert portofspain klimakrise class fsberlin honiara berniememe asmara florida nicosia helsinki taipei tegucigalpa tokyo tashkent larochelle MadeInEU sarajevo algiers KlimaKrise nairobi muscat monaco riyadh lusaka wellington bissau juba mariehamn klimaatcrisis majuro buenosaires ngerulmud dhaka guatemalacity washington vatican kuwaitcity londonboaters SystemChangeNotClimateChange bern mexicocity bratislava myasstodontownhall bridgetown prince delhipolice crowsnestpass tunis manila stanley matautu copenhagen barcelona lomé videocall budapest ouagadougou mogadishu freetown victoria lora brazzaville portmoresby ashgabat kampala elaaiún vilnius bloemfontein sucre london passalong marseille berniesanders pagopago bradesestate oakland vaduz addis nürnberg naypyidaw CassetteNavigation khartoum baghdad bandar moroni cuirass lehavre portvila kingstown ChrisCrawford reykjavík manama accra windhoek fortworth nukualofa classic ciutatvella tbilisi canberra quito maputo cetinje adams putrajaya ramallah bogotá dodoma harare havana warsaw münster valletta localberlin ljubljana bamako kualalumpur podgorica rabat cotonou plymouth seoul Portland dushanbe bangui magnifyingglass aotearoa westisland tskhinvali palikir caracas jamestown rome munich freestuffberlin bass sãotomé jakarta daressalaam sansalvador seo apia essex yaren cairo jerusalem brussels kigali southtarawa beijing minsk montevideo vientiane maseru klimaatopwarming hamilton lorawan doha tripoli celtic portlouis lima adamstown deventer weimar abuja lilongwe nassau lobamba heathrow nyc montreal dili riga assembly lesbos monrovia nursultan Neuzulassung gab sanjosé klimaatrechtvaardigheid marigot islamabad fb malabo tallinn sahara thimphu yaoundé praia bujumbura washingtondc sofia skopje - - Thu, 18 Feb 2021 21:34:48 UT - - - music - musicprodution LaurieAnderson punk ourbeats vollgasindiekrise indieweb synthesizer indiemusic streetpunk bikepunks bandcamp musicians jamendo ipod skinheadmusic rap shoegaze mp3 steam indie steampunk Music EnvoieStopHashtagAu81212 thecure posthardcore vaporwave IndustrialMusicForIndustrialPeople Mixtip dubstep synthwave bootstrap oi rave freemusic nowplaying hiphop hardcore experimentalmusic spotify fedimusic musicbrainz eos90D soundcloud frankiegoestohollywood ccmusic typographie dj newwave dorkwave producing experimental musicproduction chiptune lastfm 1 funkwhale eos wp playlist retrosynth NowPlaying libremusicproduction MusicAdvent coinkydink pmbootstrap arianagrande synth music np techno jazz graphics darkwave mastomusic metal polychromatic funk magnatune fediversemusic cyberpunkmusic BandcampFriday - - Thu, 18 Feb 2021 21:34:48 UT - - - conferences - FOSDEM2021 debconf FOSDEM talk fossdem FreedomBoxSummit apconf2020 schmoocon summit confidenceTricks minidebconf rc3worldleaks emacsconf MCH2021 ox defcon flossevent conf rC3 rC3World FOSDEM21 conference flossconf apconf rC3one smalltalk C3 config confy - - Thu, 18 Feb 2021 21:34:48 UT - - - politics - TakeOurPowerBack redessociais trump Anarchy cia alwaysantifascist sabotage qtibpoc community wageslavery immigration dissent liberation laws fascism farmersrprotest techtuesday skyofmywindow techthursday aws freedomofspeech anarchist prochoice freeexpression RacialHealing fascisme humanrights crime leftists Socialism ukpol FreeKeithLamar Antifascisme copwatch capitalismkills petition BorisJohnson meteorología freedom techdirt ontologicalanarchy abolitionnow anarchism DefundThePolice earthship repression technews meto legal polizeigewalt smalltech police nzpolitics greenhousegas antifascists oilwars kommunismus bjp ThirdRunway testing hierarchy election republicans opinie diversity solidarity techwear sociaalDarwinisme sky_of_my_window mybodymychoice generalstrike fuckBiden neoliberal antipolitics digitalfreedom mayday hatespeech fascists LateStageOfCapitalism stopchasseacourre ciencia smashturkishfascism afropessimism burntheprisons cyberlaw peerproduction corporations iww freeassange commons choice Riot corporatewatch wageslave uspol frontex communism RemoveThePolice makecapitalismhistory Immigration biometric neoliberalism socialecology wroclawskierewolucjonistki MutualAid capitalism technology test prisons wealth conspiracytheories corporatecrime DirectAction communist KeirStarmer taoteching anarchismus retrotechnology politics inclusivity government HeroesResist brightgreen poc anarchisme feminist DominicCummings nzpol Bookchin ClemencyNow Inauguration2021 arran brexit totalitarianism privatisation TyskySour Labour death freethemall green neoliberaal BAME decolonizeyourmind alternative privilege antikapitalisme AbolishPrisonsAbolishPolice surfaceworldblows ecofascism popularitycontest SocietalChange facialrecognition cotech corruption hypocrisy anarchy esm Feminism propaganda dcc endsars celebratingfreedom decolonization digitalrights feminism HightechProblems pentesting polizei neo democracy anarchistki Govts xp powerpolitics 18Source censorshipBook radicaltech saytheirnames conspiracy anarchistbookclub redandanarchistskinheads radicaldemocracy PritiPatel latestagecapitalism racist cancelculture MexicanRevolution elections RussellMaroonShoatz LhubSocial commonspub sea white prisoners warrants policebrutality techshit borisjohnson Anarchist press Anarchism mutuality whitehouse metager freedomofexpression censorship CancelCulture decolonize HanauWarKeinEinzelfall keinmenschistillegal emmet decenterwhiteness Biden ChineseAppBan cooperative modi antifa law chip deathtoamerica manipulation ParticipatoryCultureFoundation firetotheprisons britpol Capitalism surveillancecapitalism leftist Revolution ukpolitics mdcommunity glenngreenwald JeremyCorbyn blacklivesmatter FreeAlabamaMovement Anarchismus mononeon rentstrike dsa lawandorder migration neoist mutualaid multipleexposure humanetechnology AbolishPrison anarchists fascist righttochoice socialcoop apocalypseworld DefundSurveillanceCapitalism platformcapitalism decolonizeconservation anarchistprisoners whistleblowers polizeiproblem speech uselection IDPol Antifa deathtofascism lesanarchistes Slavetrade met consumerism ourstreets extremist freespeech neorodiversiteit refugees riot BernieSanders acab ecology SurveillanceCapitalism antifascism freewestpapua sunnytech tech - - Thu, 18 Feb 2021 21:34:48 UT - - - food - vitamind cake teamviewer FoodHardship margarine zwartepiet dessert salsa caviar theexpanse BellaSpielt cookery pietons Ôtepoti panther food skillet teamgodzilla spiel liquor milk bolognese recipe foodporn yeast drinking plate waffle biscuit glaze omelette filet pastry wine Caribbeans hamburger juice Amazfish sourdough cagefree nuts gras toast broth batter foodie spiele ketchup divoc seasoning mayo soup arpanet pan voc imateapot potatoes mayonnaise vegan dish avocado spice bakery butterflies butterfly cooking teamhuman yogurt thecandycrystalrainbowcodex crumble cider caffeine butter sailfishos mastokitchen cook pottery creepypasta mastocook cobbler steak pizza soda fedikitchen aroma oil Miroil angelfish flour cream nutella pie cuisine potse freerange tartar kropotkin tea marinade mushroom entree lfi bread salad beans fresh syrup fermentation mushrooms cookie wordstoliveby curd soysauce pudding beer baking peterkropotkin fish foodwaste wheat pot TeamFerment sauerkraut stew weltspiegel chocolate paste wok recipes expanse olive burger candy spanish kitchen coffee bagel teams taste SpieleWinter2020 meat noodle raclette caramel rice eggs grill poutine demoteam lard croissant pasta vegane strawberry foods WaterDrinkers cheese oregano drink muffin foie sauce foodanddrink soy growyourfood vore pandemie cocoa sandwich mousse chili vinegar - - Thu, 18 Feb 2021 21:34:48 UT - - - farming - johndeere - - Thu, 18 Feb 2021 21:34:48 UT - - - countries - romania burma lithuania solomon chile Instanz fiji tajikistan benin paraguay eeuu icelandtrip senegal ukraine italy brunei nicaragua guyana Pflanzenbestimmung euphoria zambia iceland morocco rojava netherlands swaziland bosnian suriname winningatlife elsalvador russia samoa european czech belarus hayabusa2 kyrgyzstan uk abuse translation sanmarino catalonia panama africa japan buyused venezuela gambia freeNukem kuwait barbados papua greece switzerland uae nigeria usa angola honduras djibouti laos sierraleone nonprofit britain cambodia ych vietnam neofeud seychelles marshall kazakhstan trailrunning urk estonia tonga stlucia burundi bangladesh egypt mali congo us jordan speedrun grenada israel psychic algeria ghana bosnia translations russian eritrea bhutan ios hungary Störungsverbot saudi slovenia tig czechosvlovakia bahamas australia kiribati togo koreanorth poland Überbevölkerung malawi capeverde armenia american hautrauswasgeht bahrain mozambique beleuchtung southsudan syria micronesia maldives iran indigenous nonprofitindustrialcomplex sweden bijîberxwedanarojava ethiopia cuba liberia canada burkina somalia Chile scotland russiaToday vaticancity easttimor austria turkey yemen Bolivia denmark USBased madagascar finland philippines ivorycoast haiti ecuador Portugal azerbaijan gasuk spain albania afghanistan europe mauritania dominica ökonomisierung thailand belize westpapuauprising nerdlife macedonia montenegro running thenetherlands qatar mongolia costarica boatingeurope birdsofkenya latvia uzbekistan kabelaufklärung ireland iraq malaysia mexico mauritius dezentralisierung oman chad nz de georgia zimbabwe france serbia lesotho oddmuse tunisia argentina czechia cameroon namibia sudan indonesia colombia kryptowährung tuvalu britainology beckychambers turkmenistan tanzania germany neuhier norway comoros auteursrecht guatemala Thailand kosovo andorra wales servus pakistan belgium china antigua life koreasouth newzealand visiticeland einzelfall rwanda luxembourg libya indywales italyisntreal nauru moldova eastindiacompany palau taiwan kenya trinidad eu botswana CuriosidadesVariadas jamaica vanuatu cyprus aminus3 malta Icelandic niger s3 westpapua busse unitedstates myanmar saintvincent guinea nepal peru uganda uruguay india lebanon neurodiversity southafrica croatia europeanunion bolivia chinese dominican srilanka bulgaria slovakia speedrunning gabon psychedelicart stkitts liechtenstein neofeudalism brazil shutdowncanada - - Thu, 18 Feb 2021 21:34:48 UT - - - software - borgbackup app freedombox windows libre nginx postscript Framasoft invidious drm publicdomain ilovefreesoftware kubernetes nodrm copyleft fossmendations freedoom jami FuckOffZoom quicksy whiteboard free docker freesoftware gimp backups foss matrix dinosaur designjustice thefreethoughtproject filesystems nextcloud translate wechat opensourceseeds notmatrix HappyLight openscad uidesign TabOrder ikiwiki Linux FreeSoftware rocketchat thanksfreesw outreachy synapse API lyft photoshop nitter virtualbox discord opensource diaspora yunohost oss littlebigdetails cabal conferencing libreboot accessibility devops owncast emacs freiesoftware fluffychat writefreely videoconferencing bigbluebutton email chatapps HappyNewYear floss plugins libresoftware softwareengineering deltachat application uifail FOSS GNOMECircle bittorrent penpot vlc zoom tiling FriendofGNOME usability opendesign obnam snap pandoc cryptpad software libretranslate OwnStream upstream slack Element zrythm gnu CTZN mumble grsync freecad drmfree telegram containers tails blockchain irssi mcclim mutt design gameoftrees backup LinuxSpotted rotonde freetube GNU skydroid thunderbird sysadmin parler apps chat licensing inclusivedesign crosspost defectivebydesign screenreaders ZeroCool LINMOBapps obsproject softwareheritage profanity Tankklappe ffmpeg fossandcrafts lemmy reboot opensourcegardens OSM freesw agpl GNOME strafmaatschappij distributedledger mattermost principiadiscordia rocket ghostscript win10 Zoom elemental libreops element chatty nativeApp MatrixEffect jitsi wordpress ux rsync libreoffice dino plugin xwiki openoffice container discordia ledger sounddesign glimpse - - Thu, 18 Feb 2021 21:34:48 UT - - - cycling - bicycle cycling bike motorbike thingsonbikes Snowbike cyclist - - Thu, 18 Feb 2021 21:34:48 UT - - - phones - mobileapp fairphone3 téléphone mobian pine fdroid plasmamobile android smartphonepic ubportsqanda linuxmobile phones fennecfdroid Mobian osmf smartphone sms4you smartphones iOS14 linuxphones openmoko mobilelinux freeyourandroid QWERTYphones siskinim osmocom Smartphones lineageos molly androiddev plasma phosh BriarProject librem5 ubportsinstaller osm linuxphone pinephone mobile pinephones sms pine64 fairphone ubuntutouch linphone Android ubports osmand vodafone linuxonmobile iphones postmarketos iOS microg grapheneos phone mobileKüfA - - Thu, 18 Feb 2021 21:34:48 UT - - - security - signalboost repair encrypt letsencrypt BrowserHistory autoritäreretatismus omemo dataleak messenger password keepassxc cryptography cybersecuritynews solarwinds communityalgorithmictrust infosec gchq repairing IHaveSomethingToHide fotografie passwords IronySec cryptowars supplychainattacks UseAMaskUseTor cyberattack security tor comb e2e bruceschneier vpn itsec openssh openssl cryptoart spyware e2ee ed25519 torproject encryption informationsecurity ssh misshaialert cybersec encryptionsist FormFactors crypto giftofencryption malware corydoctorow righttorepair cryptographyisoverparty opsec keepass TastySecurity torsocks nsa protonvpn trustissues yubikey nitrokey encrypted openpgp castor9 gpgtools gpg fotopiastory equatorial cybersecurity Tor CryptoWars signal noscript trust drugs wire itsecurity cryptocurrency foto pgp cryptomator signalmessenger openvpn datasecurity autorotate regulators leak drugstore encryptiost securitynow storage tracking cloudflare - - Thu, 18 Feb 2021 21:34:48 UT - - - pandemic - covid19 corona psmeandmywholefamilycaughtcovidfromwork Coronavirus CoronaWarnApp facemasks vaccines vaccine pandemic sayhername covidville contacttracing tier4 covid coronavirus masks COVIDrelief virus Lockdown rna codid19 COVID19 YesWeWork ContactTracing COVID coronamaatregelen - - Thu, 18 Feb 2021 21:34:48 UT - - - astronomy - telescope mercury pluto galaxy venus mars amateurastronomy uranus nebula astronomy neptune space jupiter blackhole asteroid BackYardAstronomy moon observatory saturn milkyway - - Thu, 18 Feb 2021 21:34:48 UT - - - history - heirloom monarchs holocaust history makeinghistory anarchisthistory NetworkingHistory monarch computerhistory - - Thu, 18 Feb 2021 21:34:48 UT - - - news - report news flash Wikileaks newsletter newsflash rt bbc goodnews doubledownnews reuters theguardian newsboat journalism SkyNews - - Thu, 18 Feb 2021 21:34:48 UT - - - cats - Cat dailycatpic dxp DailyCatVid katze kotorico CatsOfMastodon Leopard catbellies LapCats qualitätskatzen - - Thu, 18 Feb 2021 21:34:48 UT - - - employment - InterviewQuestions jechercheunjob mywork hiring ProgrammingJob reproductivework workinprogress bullshitjobs antiwork kreaturworks worklog job DjangoJob hire hirefedi workshop carework nowhiring obs obsolescence work flossjobs jobs sexworker - - Thu, 18 Feb 2021 21:34:48 UT - - - radio - cbradio worldradioday hamr why tootlabradio pouetradio dx macintosh amateurradio radiohost radiokapital localization vantaradio ca radio healthcare listening hamradio FreeAllPoliticalPrisoners card10 fastapi radiobroadcasting radiosurvivor radioshow local radio3 noshame osh audycja hackerpublicradio audycjaradiowa california nowlistening listeningtonow radiobroadcast mastoradio spazradio anonradio kolektywneradio io api - - Thu, 18 Feb 2021 21:34:48 UT - - - indymedia - hs2 visionontv geek tredtionalmedia degeek globleIMC indymediaback pga indymedia networking stupid hs2IMC indymediaIMC network networkmonitoring roadsIMC stupidindivialisam roadstonowhere lifecult omn tv roadstonowhereIMC UKIMC fluffy 4opens openmedianetwork - - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT economics - workercoop bank bitcoin noplanetb theWorkshop feministeconomics WealthConcentration coops valuesovereignty funding platformcoop usebitcoin consommation workercoops economics value business platformcooperatives exoplanets shopping displacement shop plaintextaccounting crowdfund fairtrade RIPpla bankingCartel Bitcoin startups HenryGeorge plant economíasolidaria disablitycrowdfund crowdfunding limitstogrowth companies sharingiscaring techcoops disability micropatronage lgbtcrowdfund monetize smallbusiness pla kommerzialisierung GitPay gdp deplatforming coop smallbusinesses europeancentralbank whyBitcoin infoshop limits values banks planetary plannedObsolence worldbank + Europe workercoop InformationFriction cooperatives accounting bank bitcoin noplanetb theWorkshop feministeconomics WealthConcentration valueflows coops holochain valuesovereignty cooperativism greatplains funding platformcoop pico transcrowdfund usebitcoin shitcoin gigeconomy consommation workercoops economics cooperationjackson cooperation radical value business platformcooperatives exoplanets shopping displacement economic poplar shop companyculture plaintextaccounting MarketForLemons sovereignty crowdfund oops fairtrade RIPpla bankingCartel rope Datenbank Bitcoin startups radicalcooperation HenryGeorge scar plausible economíasolidaria disablitycrowdfund crowdfunding limitstogrowth ponzi companies theygrowupfast hermannplatz sharingiscaring techcoops plastikfrei plantprotein meetcoop disability micropatronage boarsplaining merz lgbtcrowdfund mehrplatzfürsrad monetize sineadoconnor cooperativas ua cryptocurrencies degrowth a2pico smallbusiness deliveroo intellectualproperty pla kommerzialisierung GitPay Fedigrowth gdp coopsday deplatforming timebank coop cooperativismo smallbusinesses europeancentralbank banknotes whyBitcoin cryptocurrency infoshop sine grow telecoop growth limits fuckfoodbanks btc values banks planetary plannedObsolence planet worldbank - Thu, 18 Feb 2021 21:34:48 UT - - - years - newyearsresolutions resolutions Year2020 year 1yrago newyear happynewyear 5yrsago newyearseve - - Thu, 18 Feb 2021 21:34:48 UT - - - linux - osdev opensuse linuxisnotanos elementaryos cli viernesdeescritorio shell kde Debian11 thisweekinlinux slackware bsd openwrt distros tmux nixos nix DebianBullseye ubuntubuzz shareyourdesktop wireguard linuxaudio usb nixpkgs gtk debian trisquel gnome linuxposting console showyourdesktop windowmanager desktop GuixSystem arch platform ubuntu snowfall gnulinux aur justlinuxthings xubuntu unix fedora centos openmandriva gentoo liveusb personalarchive usergroup systemd linuxgaming Debian distro destinationlinux gtk3 escritoriognulinux qubesos i3wm kubuntu epr JuiceFS reproducible haiku linuxisnotaplatform clip fall linux EMMS raspbian netbsd shellmustfall termux btrfs reproduciblebuilds artix gtk4 archlinuxarm bash archlinux platforms linuxconfau GNUlinux rhel debianinstaller debianindia linuxisajoke suse linuxconsole - - Thu, 18 Feb 2021 21:34:48 UT - - - art - arttips paperart CreativeToots Linke subresourceintegrity water urban glassart martialarts artalley artvsartist2020 circulareconomy abstract poe nomadbsd dccomics inkscape artificalintelligence circuitsculpture watercolor resources memes autisticartist barrigòtic art sona animalart krita anthroart urbanart queerart deviantart CircusInPlace pastel drawings adultcolouring source openstreeetmap collage jordanlynngribbleart linkedin thinkabout PartyPooperPost openai harmreductionart openra wallpaper political agriculture streetart coverart stickers freeculture fiberart pcb MastoArt particl ParticlV3 groenlinks creativetoots culture ganart opencl fiberarts polArt ink painting Leitartikel opencoop digitalart comic sartre artwork openbsd mandala b3d politicalcartoon glitch xkcd comics mastoart urbanterror illustration artopencall gnuimagemanipulationprogram os wireart cartoon oc AccidentalGraffiti OriginalCharacter webcomic DigitalArt partnership oilpainting irc furryart twinkle DisabledArtist pink fursona inkjet generativeart sticker enbyart particles artbreeder 17maart arttherapy fractal enby TattoosOfTheFediverse signalstickers digitalpainting artvsartist abstractart drawing sig circular sculpture artist pcbart meme cultureshipnames concretepoetry artwithopensource commissions opencallforartists commissionsopen fountainpenink peppertop visionaryart blackartist zine artists heart genart stickerei smartcard pixelart alisajart opencollective openrailwaymap JuliaHartleyBrewer digitalArt artistsOfMastodon - - Thu, 18 Feb 2021 21:34:48 UT - - - crafts - topic_imadethis hackerexchange textile upholstery hackgregator hackspacers shack dust3d hackerspaces sanding solvespace sundiy maker knitting hack hacked calligraphy biohacking wip jewelry diy upcycling woodworking 3dcad origami makers quilting hacker quilt weaving 3dmodel tinkering hacking woodwork ceramics embroidery shacks teardown - - Thu, 18 Feb 2021 21:34:48 UT - - - pets - catpics germanshepherd catofmastodon TheRabbitHole reEducationCamp mastodogs rats catbehaviour Coolcats petrats dogsofmastodon gentrification broadcats bunyPosting fostercats cats kittens pet dog acat caturday catsofmastodon meow cute catstodon dogs mastocats notpixiethecat londoninnercitykitties cat furry catcontent UserDomestication - - Thu, 18 Feb 2021 21:34:48 UT - - - war - Myanmarmilitarycoup civilwar antiwar bomber coup tank handforth landmine military autonomousweapons army Etankstelle weaponsofmathdestruction conflict navy warplane fort guns Myanmarcoup weapons siege battle WMD wmd airforce forth - - Thu, 18 Feb 2021 21:34:48 UT - - - games - minecraft tetris99 gamestop ageofempires TerraNil runequest boardgames computergames fucknintendo gameassets gamestonk FediDesign puzzle gamedesign chess nintendoswitch mud indiegame game 0ad gameart opengameart sign asset ttrpg gamedev freegames guildwars2 bideogames TetrisGore gaming gamemaker gameing nintendo roleplayinggames Gamesphere rpg gamespot tetris dosgaming DnD cyber2077 tarot cyberpunk2077 gamesforcats FreeNukum supermariomaker2 neopets minetest guildwars dnd games - - Thu, 18 Feb 2021 21:34:48 UT - - - legal - eek rma hfgkarlsruhe amro karlsruhe remotelearning SpreekJeUitBekenKleur GameSphere OnlineHarmsBill laipower gdpr intros Anticritique learning energyflow peekier mojeek digitalservicesact geekproblem dmca - - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT climate - energy renewables clouds openscience renewableenergy amp climateemergency climatechos ClimateAction climate climateracism windenergy coal weatherforecast skypack poll globalwarming climatechange weather ragingqueerenergy climatecamp windpower globally globalization climatechoas pollution global science fossilfuels Climate sky climatescience climateaction ClimateCrisis climatecrisis + YouStormOutlook heatwave energyconsumption energy energyuse SoilCarbon vampire renewables fuel clouds apollo racisme antira greenhousegas ClimateEmergency openscience renewableenergy ClimateMeme amp Nyurbinsky climateemergency climatechos gordoncampbell extremeweather ClimateAction climate climateracism renewable windenergy ClimateDenial ClimateProtection sciences ClimateStrike CycloneTauktae emissions coal climatecase climatestrike globalsouth ClimatePodcast weatherforecast kaspersky crisis foodcrisis vampiro energyvisions klimaatcrisis environment skypack climatecrises fossilfuel history_of_science earthscience tramp globalwarming mitigation limitededition weather ragingqueerenergy fossilcriminals camps climatecamp ClimateRefigees Podcast windpower sealevelrise ClimateCase globally globalization climatechoas endfossilfuels emergency CarbonOffsets heatwaves basecamp exitpoll Tyskysour pollution global parisclimateagreement science fossil energyefficiency OABarcamp21 mitmachen fossilfuels Climate sky climatescience energytransition climateaction ClimateCrisis storms warm biofuel globalviews headlamp whisky climatemitigation environmentalism Ruttecrisis climatecrisis - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - fiction - ABoringDystopia cyberpunk thehobbit fiction genrefiction + art + proudhon productivity cherrytree Fediverse oilpaint economiasolidaria arttips mastoartist paperart activism cali TraditionalArt Linke subresourceintegrity glitchart Art ocart robincolors resource urban article penandink webcomics startpage CommissionsOpen glassart afrique martialarts watercolours artsurbains artalley artvsartist2020 circulareconomy abstract artreference commission horrorart Earthquakes poe nomadbsd proxmoxve MartyMacMarty tgif coloringpage dccomics colored inkscape blink artificalintelligence draw circuitsculpture ttip watercolor proceduralart existentialcomics resources poetesss memes pinksterlanddagen ghibligif speedpaint SankeyCharts bengarrison subpoena autisticartist barrigòtic art sona animalart krita foraBolsonaroGenocida insights FreeColouringPage anthroart urbanart sigh queerart deviantart communityresources desigualdad pastel fantasyart drawings 20thcenturyillustration grafana daria artdeco adultcolouring source J19forabolsonaro collective openstreeetmap cryptoart politicalprisoners fantasy collage jordanlynngribbleart ToryParty educpop TheArtsHole linksunten risograph pro links CodeZwart thinkabout dndmemes fanfic articles protein forabolsonaro PartyPooperPost harmreductionart adhdmeme MastoArtHeader openra demoscene witch FreeArtLicense wallpaper generative political agriculture streetart coverart streetcomplete fountainpen stickers partners watercolour economy combyne freeculture fiberart PalestinianPoliticalPrisoners jet labyrinth educators mermay dpa artsale edu MastoArt particl PrisonNews FediverseApp urbansketchers ParticlV3 creativetoots culture ganart evenepoel opencl fiberarts polArt ink painting Leitartikel marten opencoop digitalart comic flyingonthewater kenmurestreet libreculture sartre artwork mandala b3d politicalcartoon blackart artsderue makingcomics glitch politicalprisoner junkart wallpapers railway linker riso xkcd supportartists proctorio drawtober startinblox comics intelligence linkinbio conceptart mastoart urbanterror illustration artopencall Hinkley gnuimagemanipulationprogram os studioghibli 2MinuteSketch wireart cartoon artistontwittter oc csa AccidentalGraffiti eink OriginalCharacter farts hattip poezio webcomic fleischproduktion DigitalArt pinkwashing partnership potentieldememe oilpainting kickstarter furryart twinkle DisabledArtist unixstickers pink fursona afriquedusud comicsans inkjet generativeart VaccineApartheid sticker enbyart originalart arts heartmind artbreeder 17maart fart TsunderdogArt videoart ivalice adultcoloring djmartinpowers arttherapy Cartudy extreemrechts fractal enby TattoosOfTheFediverse doodle artikel WorldLocalizationDay colouringpage worldwaterday NFTart netart signalstickers artschool digitalpainting intel politicaltheatre artvsartist dorktower maart abstractart drawing sig circular adhd sculpture artist pcbart meme cultureshipnames concretepoetry artwithopensource pinkwug Streeck VTMtober commissions pronouns opencallforartists DesolateEarthForThePoor VizierOpLinks commissionsopen fanon KartaView alroeart article17 fountainpenink MartinVanBeynen peppertop speedpainting animalpainting visionaryart blackartist worldpay figureart zine artists heart quickdraw error supportthearts genart urbanfantasy stickerei CurzioMalaparte tree lineart smartcard pixelart alisajart openframeworks professor networknomicon openrailwaymap politicalpolicing Earthstar JuliaHartleyBrewer fan digitalArt artistsOfMastodon glitchsoc paintings mermay2021 - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT + + + sport + billiard darts olympics2020 swim motorsport snooker locksport swimming trailrunning marathon hockey bouldering diving baseball Millwall mma mammal sailing athletics nook dumpsterdiving sportsball skating skiing sport footballers climbing football combatsports golf + + Tue, 13 Jul 2021 08:43:43 UT + + + bots + posthumanism mrrobot human dehumanification Militanzverbot nobot botanists humanity militanzverbot Sabot44 humanrobotinteraction therobots humanetechnow verbote humankind + + Tue, 13 Jul 2021 08:43:43 UT + + + events + neverforget TuesdayVibe award daffodilday carbonemissions OONIbday waybackwednesday thursdayvibes fridayfilm todayilearned ShowYourStripesDay thursdaythought sun futuroparanissan IndigenousPeoplesDay5 notifications hissyfit ddosecrets solo throwbackthursday nissan valentinesday adventskalender live dos Day deepthoughts solorpg thingaday idahobit screenshotsaturday warmingup thursdaythoughts fridays ipv hackathons thursdaymorning Gesundheitskrise throwback RomaDay assweek animalsweatersunday justwatched TooMuchScreenTime beethoven250thbirthday valentine humanrightsday time followfriday wednesdaythought afediversechristmas whydopeopledoshitlikethis birthdaypresent festivals wednesdaymotivation early MayDay2021 SwissOvershootDay IllustrationDay bigbrotherawards cccamp19 lovewhereyoulive screenshot thelibrary PostLikeYouThinkACrabWouldSunday showerthoughts BIJ1 worldpenguinday animal ScreenshotSaturday beethoven anarchymonday treibhausgasemissionen solokey tipoftheday Verkiezingsfestival primeday paperoftheday bundesnetzagentur thimblefulthursday FreeAssangeYesterday 100DaysToOffload hackathon ff kids holiday folklorethursday LURKbirthday tomorrowspaperstoday wenbiesday punday ipv4flagday ipv6 christmas livecoding verfassungsschutz weeknotes LINMOBlive week FlashFictionFriday mothersday gsd koningsdag scree concert folklore festival FridayFolklore pride poll screenshottuesday animals VerkiezingsfestivalBIJ1 towertuesday fujifilmxt2 Nakbaday kdenlive dontstarve onthisday GlobalMayDay2021 simplescreenrecorder insideoutsockday screenshots livestream blissos whiskerswednesday BowieDay morningcrew theskytoday InternationalAsexualityDay tzag TinyTuesday FridaysForFuture sunday notification Koning weekendvibes screenshotsunday worldenvironmentday2021 showerthought library koningshuis cree VerseThursday liverpool waitangiday esc2021 bigbrotheraward caturday adayinthelife goodmorning Caturday day InternationalCheetahDay flatfuckfriday songfestival ItchCreatorDay iss RabbitRoadTrip2021 interestingtimes sideprojectsunday birthday sixonsaturday supdate StPatricksDay2021 koningsdag2021 wordoftheday theweeknd christmaslights AfricaDay livefree CancelCanadaDay worldenvironmentday fridaysforfuture nationallibraryweek meetup FathersDay transpride sex kidsthesedays rechtsextreme + + Tue, 13 Jul 2021 08:43:43 UT + + + politics + hate biometrics conspiracytheory TakeOurPowerBack redessociais solidarität trump Anarchy association cia socialjustice neoliberalisme eee workerowned alwaysantifascist sabotage qtibpoc VivotecniaCrueldadAnimal solidarityeconomy pressfreedom community systemicracism wageslavery immigration antifascismo liberal telemetry dissent liberation unions endprisonslavery laws fascism farmersrprotest techtuesday warc skyofmywindow techthursday nooneisillegal capitale freedomofspeech anarchist prochoice freeexpression EthnicCleansing anticapitalist RacialHealing fascisme liberalisme humanrights Anarchisme crime leftists turkish Socialism ukpol FreeKeithLamar Antifascisme copwatch capitalismkills fireworks homeless menschenrecht left petition BorisJohnson meteorología independant antifaschismus freedom EURvalues greens techtalk bikesforrefugees housingcrisis techdirt ontologicalanarchy labourabolition techsit union tories abolitionnow anarchism wegmetdemonarchie abuse DefundThePolice nazis earthship SocialCritique repression legaltech technews pelmets Jurastil meto devimage legal meeting polizeigewalt dannenröderwald smalltech FediAntifa police nzpolitics multicast antifascists oilwars multiverse antropocene kommunismus censored postttruth technik rightorepair control nuclear bjp ThirdRunway conservatives multi seaslug UnitedInDiversity maidsafe testing nazisme hierarchy avatars chehalisrivermutualaidnetwork vat ImmigrationEnforcement election republicans opinie diversity solidarity techwear communitycontrol metantispecismo hypocrits slavery sociaalDarwinisme metoo Avanti anticiv refugeeswelcome Coronariots seashepherd ecotech mybodymychoice generalstrike fuckBiden call2power DefendDemocracy wildfire neoliberal antipolitics charity AntiLiberalisme abolition digitalfreedom transrightsarehumanrights ScottishElections2021 mayday unionyes again hatespeech fascists antropoceno policerepression LateStageOfCapitalism earth stopchasseacourre solawi ciencia smashturkishfascism afropessimism antivax fedibikes Electricians burntheprisons seamonkey qt trumpism cyberlaw bossnapping peerproduction policiaasesina atlantik dansenmetjanssen corporations iww pushbacksareillegal indianpirates DisabilityPolicy vice SomethingIsGoingWrongHere til labor intersectional commons choice depressionen feelthefreedom Riot corporatewatch postcapitalism intersectionalfeminism smalltechnology wageslave uspol frontex communism mutualaidpdx RemoveThePolice makecapitalismhistory deathvalley chipocalypse criminalization abolishpolice nationalisme oist methaan anarchisten Immigration competition biometric mh brexitreality neoliberalism NeverTrustSimone socialecology wald whistleblower wroclawskierewolucjonistki icons MutualAid MutualAidRequest capitalism technology ACAB prisons feministhackmeetings wealth supremecourt conspiracytheories corporatecrime DirectAction ChildLabour parliament communist daretocare KeirStarmer NoMoreEmptyPromises greenpeace digitalslavery bushfire censor decrecimiento helmet refugeesgr taoteching technopolice anarchismus policeviolence politiikka kapitalisme retrotechnology ZwartFront bipoc housing decriminalization decolonisation politics WarCommentary inclusivity parametric gravimetry bosch Megaprisons decreased publicknowledge antiracism government neocities greendatacenter SocialDarwinism repressions brightgreen poc privatisierung anarchisme wayfire feminist colonialism DominicCummings nzpol peoplepower homelessness Bookchin informationtechnology ClemencyNow Inauguration2021 arran Revolutionary techthoughts brexit anarchistaction antimonopoly tw privileged totalitarianism localelections raid privatisation stillwithher TyskySour Labour democraciasindical nonprofitindustrialcomplex death LabourLeaks riots freethemall bolsonarogenocida green SocialJustice neoliberaal corporateStateTotalitarianism labour BAME decolonizeyourmind alternative privilege antikapitalisme masssurveillance firejail hamas legalcounsel AbolishPrisonsAbolishPolice despotism mntreform damangecontrol earthovershootday palantir DecentraliseThePlanet anti surfaceworldblows ecofascism opentechnologyfund depression nuclearpower popularitycontest usestandardpowerjacksforfucksake pdxmutualaid PoliceTenSeven LhubTV SocietalChange facialrecognition ModiFailsIndia cotech politicaeattualità corruption florespondece hypocrisy BernieSandersMeme staterepression anarchy fire colonization Feminism propaganda dcc greenit endsars celebratingfreedom userfreedom Antillia corporateState SocialCentres decolonization pc digitalrights feminism freepress Lhub HightechProblems datacenter osstotd farm problem hochschwarzwald collaboration pentesting polizei neo democracy anarchistki Govts BelarusProtests powerpolitics bikes 18Source hungerstrike censorshipBook radicaltech 56aInfoshop saytheirnames witchesagainstwhitesupremacy gulag digitalmarketsact yes socialist conspiracy anarchistbookclub redandanarchistskinheads peace housingproject hostileenvironment technically lawyer corporate osint radicaldemocracy endmodernslaveryatsea PritiPatel nationaalparkdebiesbosch stonewallwasariot oiseau surveillance latestagecapitalism bos racist economiafeminista cancelculture postcolonial callfortesting dec AmbLastillaAlCor Selfsuffciency nonazis MexicanRevolution elections ACABPoland greatgreenwall RussellMaroonShoatz LhubSocial OctoberRevolution logitech methods Flatseal repressionen commonspub warcrimes sea policing white governance waldstattasphalt prisoners earthday2021 warrants policebrutality techshit earthday antirepression capitalismo borisjohnson wildfires ACABSpring2021 technopopulism Anarchist deepspeech notacopshop body johnson rhetoric press routerfreedom Anarchism mutuality StillTwitterpated whitehouse metropolitanpolice espresso LabourParty haltandcatchfire freedomofexpression censorship deathbycapitalism communities CancelCulture decolonize deconstruct HanauWarKeinEinzelfall musictechnology druglawreform keinmenschistillegal immigrationraids emmet racism fascisten decenterwhiteness Biden FossilFreePolitics ChineseAppBan multiplesklerose cooperative trespass modi antifa alternativen law prison chip LabourMovement deathtoamerica manipulation ParticipatoryCultureFoundation firetotheprisons consumer solidaritaet PlanetarySocial britpol financial gravimetrie BiodiversityDay Capitalism surveillancecapitalism leftist greenland general Revolution ukpolitics greenparty mdcommunity glenngreenwald support JeremyCorbyn blacklivesmatter freedomofthepress academicfreedom HeinsbergStudie apartheid FreeAlabamaMovement Anarchismus bundespolizei strike mononeon rentstrike evergreen equality otd dsa informationstechnik piracy liberty lawandorder feminismus migration power oiseaux techmess neoist edtech capitalismenumérique mutualaid capital waldspaziergang cymru multipleexposure socialsolidarityeconomy humanetechnology AbolishPrison solidaritynotcharity anarchists fascist righttochoice InformationAsymmetry socialcoop inequality vim apocalypseworld DefundSurveillanceCapitalism feministserver prisonersupport platformcapitalism decolonizeconservation anarchistprisoners whistleblowers polizeiproblem notallmen hf prisonabolition fightthepower UniversalBasicServices fuckcapitalism speech uselection IDPol Antifa deathtofascism mediafreedom lesanarchistes libertarianism Slavetrade met democracia antitrespass drugtesting populism selfcensorship consumerism greenwashing ourstreets reform MeToo extremist bright freespeech anticonsumerism kapital neorodiversiteit refugees BlackProtestLegalSupport riot BernieSanders acab ecology yesminister realcompetition antifascist SurveillanceCapitalism vimeo antifascism GlobalCapitalism Politics homeoffice bodyshaming empowerment whitepaper pdx seascape freewestpapua eris hambacherwald dui nyt justice powstaniewgetciewarszawskim sunnytech FolksWhoFailAtCapitalism expression feudalism espressif violence legalmatters academic tech capitalismodisorveglianza + + Tue, 13 Jul 2021 08:43:43 UT + + + places + communedeparis lapaz luanda asunción salisbury nouakchott conakry kyiv enviromentalism moscow winchester cardiff saipan gibraltar dublin KlimaGerechtigkeit stuff catalunya dannibleibt avarua lilo wolverhampton hargeisa delhi niamey chișinău freestuff font colombo dundee brasília phnompenh mbabane danni belgrade rotterdam stasaph belmopan pyongyang hannover strawinsky calls ulaanbaatar oranjestad kali Reykjavik Barliman gaborone seattle ndjamena lancaster chelmsford raw singapore tuberlin preston lincoln kingedwardpoint abidjan nuuk york asshole pretoria papeete DreamtimeVillage washingtonstate bradford malé rhetorical robberfly sunderland zagreb gitega abudhabi flyingfishcove castries revil georgetown suffolk wickr hagåtña podman videoFreex oric ella lichtenberg videofeedback borikua basseterre hamburg southeastasia fonts afrika kinshasa Schadensersatzforderung streetartparis suva klimaatverandering valparaíso athens roseau sheffield baku aberdeen charlotteamalie antananarivo domi pristina RadentscheidJena bordeaux diff MakoYass videocalls santiago fsb sukhumi berlin urk bristol uptronicsberlin funafuti libreville newry radentscheid puertorico ClimateChange hanoi philipsburg tehran banjul prague Stockente rawhide andorralavella daw yerevan portauprince videoprojects sensorica mewcastle dakar asu paramaribo tifariti durham CrisiClimatica capetown rigaer94 dma tirana klima ankara ipswich managua lisbon bishkek amsterdam climatchoas kent klimaat EastVirginia portonovo santodomingo wakefield bangkok texas coventry bucharest kathmandu aden madrid paris14 sanjuan vienna kingston stuttgart Utrecht inverness kabul damascus stockholm douglas ClassOf2015 willemstad brighton klimaschutz klimaatnoodtoestand hibernoenglish thehague panamacity RassismusTötet beirut belfast amman newdelhi tórshavn nottingham nouméa oslo alofi gustavia paris cockburntown berlinale manchester dominio ottawa classical buch stepanakert portofspain klimakrise class fsberlin honiara berniememe asmara florida nicosia helsinki taipei salford tegucigalpa bridge tokyo tashkent larochelle vr gabocom MadeInEU sarajevo algiers southampton KlimaKrise nairobi muscat monaco riyadh flying lusaka perth wellington wick bissau juba mariehamn majuro parisagreement mumiaabujamal norwich buenosaires douglasrushkoff ngerulmud dhaka berlinhateigenbedarf guatemalacity washington bedarf vatican kuwaitcity martlesham Erdmannhausen londonboaters SystemChangeNotClimateChange bern mexicocity amap bratislava myasstodontownhall bridgetown delhipolice stokeonTrent crowsnestpass leeds tunis manila warwickshire rigaer94verteidigen arctic stanley matautu copenhagen hereford barcelona lomé videocall budapest ouagadougou mogadishu PrawnOS freetown victoria bangor lora brazzaville portmoresby ashgabat kampala Klimaatalarm gigabitvoucher kirigami webassembly yorkshire elaaiún kalimantan vilnius ContourDrawing bloemfontein gnuassembly swansea sucre london passalong marseille berniesanders pagopago bradesestate oakland vaduz birmingham addis lisburn nürnberg naypyidaw derry CassetteNavigation khartoum baghdad bandar truro moroni cuirass rigaer lehavre klimaliste portvila kingstown armagh Klima ulm ChrisCrawford reykjavík lofi manama accra mewport windhoek fortworth nukualofa classic ciutatvella tbilisi canberra quito maputo cetinje adams putrajaya lichfield ramallah solimaske oslotown bogotá warming portsmouth dodoma berkeley harare stirling havana warsaw klimapolitik rigaer94bleibt münster valletta snes localberlin ljubljana bamako leicester kualalumpur peterborough podgorica rabat cotonou oranje plymouth seoul westminster neumünster Portland dushanbe bangui aotearoa canterbury westisland tskhinvali palikir caracas brussel jamestown rome gloucester munich cambridge ripon carlisle freestuffberlin wells chichester sãotomé jakarta daressalaam sansalvador seo apia essex klimawandel yaren cairo jerusalem brussels kigali southtarawa beijing minsk montevideo vientiane philips maseru klimaatopwarming hamilton lorawan lurk doha klimaatwake worcester tripoli celtic portlouis stalbans lima adamstown deventer weimar abuja fuckalabamapower saw lilongwe nassau lobamba heathrow nyc oxford fly montreal klimaatzaakshell rawtherapee dili feedback thesprawl riga r94 assembly lesbos monrovia nursultan Neuzulassung caste gab sanjosé klimaatrechtvaardigheid marigot islamabad fb malabo tallinn sahara thimphu klimaatzaak exeter oranjeklanten klimanotstand chester yaoundé praia bujumbura strawberries washingtondc derby sofia skopje + + Tue, 13 Jul 2021 08:43:43 UT + + + employment + justworked futureofwork InterviewQuestions jechercheunjob mywork remote employees hiring workingfromhome ProgrammingJob reproductivework frame workinprogress bullshitjobs car workplace DigitalNetwork antiwork workshops kreaturworks workers worklog sexworkers remotejob mainframe remotework remotejobs migrantworkers job culturalworkers DjangoJob teamwork framework hire KDEGear careers hirefedi career SocialNotworks workshop bedfordshire illustratorforhire tidyworkshops carework nowhiring KDE rds KDEGear21 obs workersrights obsolescence records KDEFrameworks plannedobsolescence work hertfordshire flossjobs jobs workflow precariousworkers carddav sexworker theworkshop nerdsnipe + + Tue, 13 Jul 2021 08:43:43 UT + + + gafam + zuckerberg caringissharing ads apple antitrust SpringerEnteignen peoplefarming deletewhatsapp advertisingandmarketing chromevox GoogleDown aws AppleSearch Floc bankruptBezos googlesearch googleio mycologists bringBunysBack youtube Goggle twitterkurds banadvertising chromebook fuckfacebook headset arcgis ffs AmazonMeansCops facebook wandering 100heads 20thcenturyadvertising amazon googlevoracle amazonprimeday dystopia microsoftgithub farcebook myco boycottinstagram FlocOff stopgafam genoegisgenoeg legislation amazonprime deletewhatsappday amazonring Gafam googleplus soldering GoogleForms HaringeyAnti delete FoodSharing lobbyregister degooglisation florespondance linkedin siri Facebook LeiharbeitAbschaffen advertising monopolies googleanalytics ausländerzentralregister adtech fuckgoogle storing plottertwitter failbook kadse microsoft deletechrome alanturing dtm poledance HeadscarfBan twitter skype azure chrome logistics googledoodles hildebrandt corporateGiant Tracking uitkeringen FlocOffGoogle sidewalk plot zuck nogafam youtubedl degoogled Google youtubers google stemverklaring gis walledgarden GAFCAM dt GooglevsOracle dotcoms deleteyoutube datafarms Instagram walledgardens agistri appleevent offseting Hypnagogist appleii facebookoversightboard fascistbook FuckGoogle degoogle boringdystopia fuschia ohneamazon appleiie deleteinstagram ungoogled ring stopgoogle affordances googledown decentring gafam inspiring oracle killedbygoogle fuckoffgoogle dance deletefacebook gradschool fakebook GoogleIsBad fuckoffgoogleandco office365 lordoftherings turingpi amazonas instagram TrackingFreeAds FlocBloc playstore synergistic bigtech boycottamazon whatsapp mytwitteranniversary deleteamazon bluesky Amazon + + Tue, 13 Jul 2021 08:43:43 UT + + + people + Melissa harold paul Zachary JusticiaParaVictoria danielle dylan scott Barbara Kenneth theresa Denise FrankLeech louisrossmann Jesse Adam justin JonathanCulbreath elinorostrom katherine judith Karen Patricia russell Metalang99 juan diane Rebecca donna LouisRossmann olivia peter troy William denise NathanDufour Betty evelyn Christina brittany Jennifer Gregory Wayne Andrychów ethan Ralph Peter ecc americalatina jacobites jean laura betty nathan brownmark margaret alexanderlukashenko Bryan Virginia Jose Rose eric james BomberBradbury david Joshua christine haaland Billy CapitolRiot ostrom natalie daniel Jonathan Michael susan George johnny bookmark MichaelWood Lauren christina Amy kevin Natalie kenneth noahkathryn Lawrence aaron donaldtrump gregory LindaLindas Amber alexa Robert Edward Patrick Rachel Verwaltunsgericht willemalexander bruce Forms dennis LegalCannabis Kayla frank Diane AliceHasters Donna Jack Paul Janice Brenda alexis sylvanasimons timothy vincent Alice sarah amy Daniel RobertKMerton jeff charlotte carolyn Emma Kyle Sean emily linda Olivia Eugene johnpilger Donald janet ryan Bookmarker stdavids RichardDWolff bryan DonnaStrickland Hannah anna doctorow MalcolmJohnson Catherine Alexander Christopher bob doris Anthony singlemarket Jean diana Beverly frances Sarah margaretthatcher Jordan peterrdevries JensStuhldreier Anna Ethan Amanda jeremy donald NatashaAKelly mark matthew julie ryanair BenSchrader DrJessicaHutchings stephanie Jerry SEKFrankfurt Diana David Linda adam richard henry RoyalFamily Isabella elizabeth nachrichten steven jessica Walter dry jeffrey Kevin Justin mountanMaryland grace martinluther PeterGelderloos brandon mary anwarshaikh jamesbaldwin sharon nicholas Benjamin GeorgeFloyd amanda Emily Ruth heather stephenlawrence albert julianassange Julie marktwirtschaft nancy stephen Cannabis James CarlSpender Megan bettydog Raymond eugenetica michelle frankgehtran Nancy Fedimarket Frances Henry andrew kevinRuddCoup Jessica zurich julia marketing Dorothy LoganGrendel Jason Charles JonathanMorris Danielle Brandon jose noamchomsky virginia beverly obituary ronald Bob madison alberta ceph Helen MarkoBogoievski Jeff helen Sophia larry bookmarks dorothy Dennis monbiot Nicholas Frank jack Stephen Janet ScottRosenberg Alexis Pamela Jacqueline Dylan roy brenda jackal jesse Roger Jeffrey Brittany Shirley putkevinback Nathan christopher Carol Susan jason Philip Logan sandra jacob rose isabella Cynthia Joan jackieweaver aldoushuxley Maria martha Randy SarahEverard carl kyle karen raymond alice jerry carol RussellBrown Victoria Steven Douglas Lisa JonathanZittrain Julia joshua jacqueline Ashley assange eugene Bruce Albert Austin thomas Evelyn Gary Scott kimberly lawrence virgin jennifer Russell austin erdogan betterlatethannever ShhDontTellJack logan Laura Chris walters Teresa GeorgeGalloway Aaron Keith brian marktwain maryanning LamySafari maria Joseph Andrew Vincent Katherine Joyce NathanJRobinson lauren Ryan amber davidgraeber alan ralph princephilip DennisTheMenace megan Kathleen sophia Cheryl abigail cynthia john richardstallman Alan AnnihilationOfCaste Debra GeorgeHoare arthurgloria mariadb LouisFurther Christine marilyn anthony chris Berichte Elizabeth sean Louis Larry AnnSophieBarwich christian deborah billy Abigail joesara AndreaBeste keith Jeremy CapitolRiots markkennedy zachary ruth Grace teresa Doris benjamin Willie george methane barbara scottish Charlotte philip DaveCunliffe ethanzuckerman randy Margaret Heather Bradley Jacob shirley pamela Matthew Nicole joan judy Kelly savannah Brian melissa Sandra stallman markstone joseph oberverwaltungsgericht andrea shamelessselfplug Joe Sara robert alicevision aaronswartz better Bobby emma willie william angela rich SachaChua samuel Postmarketos tyler Thomas John kroger patricia ashley bobby roses kelly fuckamerica ThomasCahill hannah Carolyn Ann CrimsonRosella Jeangu gary wayne Marilyn Deborah christenunie rms Sharon gare Mary frankfurt Samuel BreonnaTaylor Mark walter rebecca helendixon Madison Juan lisa cheryl janice jeffreyepstein Christian gerald Timothy roger edward bradley Gerald PiersMorgan patrickrachel framalang Kimberly steve Gabriel Marie EmmaFerris PeterHoffmann PaulBaran louis kathleen Arthur Gloria terry royals freejeremy bernardhickey Richard jonathan Harold shame Roy samantha DavidSeymour Carl chalice Eric AndreiKazimirov RebeccaHarvey relationships visuallyimpaired nicole Andrea Judith Terry Stephanie Johnny Angela Noah Kathryn RichardBoeth Ronald AskVanta Michelle Theresa gabrielmarie Samantha Judy michael charles GeorgeGerbner Tyler amaryllis DouglasPFry kayla catherinealexander Martha debra JohnMichaelGreer stevewozniak joyce + + Tue, 13 Jul 2021 08:43:43 UT + + + activitypub + followerpower FederatedSocialMedia mastodevs kazarma activitypub activertypub tootfic askthefedi fedivision pleroma losttoot Rss2Fedi PeerTube CreativeToots devices gofed getfedihired collaborate pixelfedlabs hometown homelab RedactionWeb fediblock fediverso lazyfedi happyfedi2u federation Invite2Fedi instances fedilab bandsofmastodon Wallabag blocks pixiv mastotips sammelabschiebung toot fedilabfeature mastodev fediversetv pixel Ktistec mastodontips catsofthefediverse mastotip pixel3a wallaby MastoDev friendica mastodontip talesfromthefediverse mastofficina fleamarket ap_c2s hiveway bands mastodonart mast Moneylab Mosstodon Adblocker fedionly DeveloperExperience askthefediverse misskey collaboraoffice activitypub_conf plsboost BlackFedi joinmastodon AskPixelfed siskin socialhub followers fediart blocking fedifreebies Metatext SocialMediaReimagined fediverse13 mondkapjesplicht Pixelfed contentwarnings pixelfed labournettv fediverseplaysjackbox mapeocolaborativo fedihive greeninstances fedidb block FediMemories mastectomy Feditip devs fablab fediverseparty collabathon Dev Fediseminar onlyfedi admin socialcg teamtoot fedbox FediMeta sponsorblock SocialNetworkingReimagined tusky retoot contentwarning peertubers imagedescription joinpeertube anastasia feditips tootcat dnsssecmastery2e fedizens Mastodon following epicyon afediversechat andstatus peertubeadmin leylableibt fediversefleamarket mastomagic YearOfTheFediverse mastodob fediadmin pleaseboost mastodonhost mond pixeldev pixelfont timeline socialmedia tips wedistribute fedivisionCollab fosstodon instanceblock softwaredevelopment freetoot mastodonmonday fedihelp fediWhen fedicat asta collaborative isolategab greenmastodon FediverseFixesThis fedireads pixeldroid networkTimeline PeertubeMastodonHost boost AskFediverse Bookwyrm federated socialhome greenfediverse WriteFreely microblocks collabora fedivers MastodonMondays fediverse imagedescriptions mastobikes gbadev lemmy Fedilab bunsenlabs mastoadmin smithereen hackerstown uadblock c2s FediverseFutures latenighttoots mastodon pcmasterrace developingcountries boostswelcome PixelfedDev fedi fediversefriday mondkapje fediplay activity widevine peertube fieldlabs mastomind lab BlackMastadon fedeproxy boosten tootorial boostwelcome lazyfediverse mastoaiuto mobilizon Fediverse13 lazy gemifedi activityPubRocks + + Tue, 13 Jul 2021 08:43:43 UT + + + internet + datasette onlinesafetybill linkeddata markdown selfsufficiency webgl LoveWattsBLM decentralised immersiveweb pep decentraliseren i2p sceptic earlyinternet Clubhouse CooperativeClouds spam firefox redecentralize NYCMesh decentral socializing Burocratic toxicmasculinity staticsitegenerator wikipedia maps rtmp PlasticFreeJuly dataprotection decentralization inclusiónsocial decentralize w3c datacollection files dotConism offlineimap DutchPolitics internetaccess agnostic gotosocial geminispace archivists gaza selfhosted piratenpartij mapuche videohosting DarkPatternsFTC metafilter maille meta wikibase CooperativeTechnology torrent mailab geocaching freenode MollyBrown mailfence bot tox k9mail nylasmail data socialism basemap webarchive sitejs meshroom anticolonial VerkehrsswendeJetzt Jabbber worldbusterssocialclub publicserviceinternet networks criticism bioinformatics online openddata centralisation internetarchaeology WordPress darkages hiddenServices chainmail datarecovery self elinks saferinternetday selfhost text SeattleHellDay contentmoderation distributed OperationPanopticon mappe mydata webhosting decentralizedweb mailman SOASJusticeforCleaners natto p2pleft socialdistancing router protection rne dataretention speedtest ublockorigin bigdata routeros internetofthings greenhosting selfhosting forkawesome communityhosting TikTok tilde CriminalJusticeBill brave panopticon aldi icann selfsustaining hosting mailart DAOs discourse digitalcolonialism weblate libera PeerToPeer wikis dns decentralizetheweb stripe service openstandards nojs ejabberd freifunk oauth Anticon tic foxes hypercore CDNsAreEvil meshtastic protonmail TubEdu standards StuffCircuit yourdataisyourdata internetfreedom mirroring onlineWhiteboard gemini antarctic zeit webui InternetCrimeFamily wlan boilemMashEmStickEmInAStew internetBanking SmallWeb fedwiki redessociales fleenode ircd coopcloud cw internetshutdown democratic criticalmass datadetox clearnet cdn cloudflared liberapay pinterest brahmaputra distributedcoop xmpp semanticweb identicurse socialnetwork Disarchive selfie anticolonialism website datasets SaferInternetDay content splinternet participation highavailability webstandards mapa groenlinks domains ntp centralized cloudfront socialnetworks metadata wikileaks disconnect Meme aioxmpp database socialanxiety proton disco web3 cloudfirewall TLSmastery descentralizare icmp organicMaps oop videocast governement jabber cleanuptheweb webbrowsers webhook communications decentralized userdata selflove wiki cloudron bsi browserextensions Fragattacks RedditDown ssb darknet cookies Qute MattHancock darkweb netcat webInstaller liberachat uberspace map Konfekoop Reddit archiv recaptcha server browser cloudy IPFS p2p social chainmaille antisocial tiddlywiki www missioncritical FreenodeTakeover corne fortinet Pluralistic databreach opendata ilovewikipedia web WebsiteStatus ownyourdata battiato netshutdowns alttext xep callforparticipation twitch im darkmode 9front bbb quadraticvoting GaiaX decentralise att jabberspam theserverroom antarctica shutdowns Watomatic datafree greenhost domain mesh selfemployed hackint OpenStreetMap gnusocial darkambient RudolfBerner slixmpp geminiprotocol statistics BurnermailIO irc eveonline pirate plaintext Graphika datacracy filesharing squatting misinformation rss openstreetmap ipns mozilla twitchbannedrevision voicemail gazaunderattack mapbox Nyxt legacyInternet yacy webrtc databases symbiotic debloattheweb crosspost jmap mail tinycircuits bureaucratic i2pd aesthetic ipfs internetradio bravenewworld routers practice browsers wikidata selfpub decentralizeit ballpointpen puredata netscape mixcloud DecolonizeTheInternet gmail openculture letthenetwork cyberspace SwitchToXmpp messaging selfies offthegrid enxeñeríasocial cloud ddg blabber snailmail cleanup selfdefense internet moderation decentralisation webinar metaverse qutebrowser _w3c_ socialcooling intox scholarsocial Seattle fox umap ssbroom pihole serverMeddling missingmaps qtox puremaps archiving bravesearch sneakernet NatureNeedsJustice Nextcloud internetarchive dataintegration mydataismydata dweb kmail js metatext adblock dark captcha socialNetworks BlackHatSEO beakerbrowser LiberaChat openweb soulseek NetShutdown enigmail libervia onlineharms webp gooddata mailinglist kernelupgrade dot Internet descentralizarea thepiratebay internetshutdowns fixtheweb mapporn contentid lazyweb atom kernel socialweb colonial AtomPub firewall shutdown ambient socialists kernenergie ebay mozillahubs instantmessaging publicservice interoperabilitate SolidProject webmention Justice4MohamudHassan cloudflare + + Tue, 13 Jul 2021 08:43:43 UT + + + linux + pubnix linuxboot compiz osdev musescore commandline opensuse share linuxisnotanos elementaryos cli buster viernesdeescritorio voidlinux shell nu cliff olinuxino deb composite beschbleibt kde FragAttacks Debian11 reprobuilds pureos nospoilers kdepim thisweekinlinux slackware search bsd tap openwrt falling runbsd distros stapler tmux nixos alpine nix DebianBullseye rm xfce ubuntubuzz gnutools vaguejoke ack shareyourdesktop shellagm personal wireguard posix lightweight whonix hardenedbsd linuxaudio mate haikuos usb nushell LinuxTablets nixpkgs wordsearch landback osi alpines computertruhe nonmateria torvalds gtk linuxmint DebianAcademy debian chroot trisquel studio gnome distrowatch linuxposting fedoraonpinephone trackers console showyourdesktop FuckDeMonarchie researchassistants anarchie windowmanager desktop GuixSystem arch personalities platform ubuntu personalwiki jodee snowfall gnulinux patriarchat aur tuxjam justlinuxthings xubuntu kdeframeworks5 stackoverflow unix fedora openbsd centos nos fittrackee tuxedocomputers tracker openmandriva backwaren gentoo buildroot aurora architecture researcher BlackLives liveusb dee SearchFu personalarchive usergroup StockOS systemd linuxgaming Debian distro 1492LandBackLane Racklet theartofcomputerprogramming icecat tape puppylinux destinationlinux LinuxSpotted lovelinux thestudio suicide show Squarch monstrosities computer gtk3 blackout deepBlah escritoriognulinux acepride qubesos i3wm clipstudiopaint dadjokes kubuntu epr JuiceFS reproducible kdecommunity haiku alpinelinux linuxisnotaplatform clip fall linux EMMS planetdebian minicomputer altap raspbian netbsd DanctNIX termux btrfs reproduciblebuilds showTheRainbow gravitationalwaves joke artix gtk4 esc linuxexpress archlinuxarm bash exposingtheinvisible archlinux hare ubuntucore linuxconfau researchers AuratAzadiMarch gnomebuilder GNUlinux rhel debianinstaller debianindia linuxisajoke tux devuan debían suse zsh linuxconsole scoobySnacks + + Tue, 13 Jul 2021 08:43:43 UT + + + programming + Easer psychotherapie DigitalInfrastructure cpp digitalpreservation programming css maui rubyonrails objects Python system digitaldivide digitalisierung FrancisBacon2020 dracut gitea orgmode mixers webdev proofing developerexperience seguridaddigital gui digital release ada schutzstreifen pypi crust codeforge workaround proofofwork zorg node websocket proofofstake ecosystem rustlang systemwandel DigitalTech python2 ocaml NapierBarracks system76 program ngiforum21 DigitalSouveräneSchule request_reaction sqlite guile capitolhillautonomouszone transcript TransZorgNu nim uptronics algorithmicharm hypocritcal profiles digitalsketch DeutschlandDigitalSicherBSI typescript forums vscode aapihm gitsyncmurder musicforhackers publiccode ocr computerscience hackers guidelines vieprivée Digitalzwangmelder laravel vala adventofcode cgit solidarność DigitalPayments beginnersguide CommonJS webdev101 scripting coding warn mauikit digitalesouveränität DevelopmentBlog anime ohShitGit digitalzwang meld git org QR_code proof sourcehut ui nocode solid nodejs systemchange trevornoah zinccoop tailwindcss terminalporn Wassersouveränität guix libertàdigitali js_of_ocaml raku fedidev c script freenode-services sourcecode publiekecode framaforms WendyLPatrick DigitalAutonomy grep django gmic zim sackthelot amada gitportal Acode gitlab crusty decoder bulldada readability parrot relevance_P1Y mnt digitalartwork Verkada react kingparrot Leiharbeit programmer trunk java haskell OpenSourceHardware CodedBias codelyoko workstation guixhome Tarifvertrag capitolhill desperatehousehackers esm penguin unicode development gittutors ursulakleguin gerrit db frgmntscnr Fagradalsfjall dev github freecodecamp openrc tuskydev threema recoverourdigitalspace html5 algorithms PythonJob lisp digitaldefenders codeberg souveränität forge ursulaleguin pleaseshare rustprogramming EspacioDigital HirsuteHippo resnetting frontenddevelopment animatedgif fourtwenty rails rakudev adaptation programme developers bug fortran libraries drivers animation printingsystems freecode forgefed javascript fragment cpm code elisp JardínOpenSource commands patterns eq ECMAScriptModules html codeofconduct vintagecomputers ConstructiveAmbiguity rakulang portal terminal c99 SemillasOpenSource rust programminghumor lowcode request AreWeTheBorg spiritbomb r FOSSlight bugbounty dramasystem go forges digitalaudioworkstation esbuild federadas commonlisp golang clojurescript vintage ruby releaseday rustc contractpatch rubylang dd deceptionpatterns mugorg debugging makejavascriptoptional nodefwd obsolescence_programmée computers developer darkpatterns racket sourceforge forum ksh digitalprivacy minimumwage bugreport mercurial aapi adafruit openappecosystem python fontforge webdeveloper indiedev ocrodjvu sh digitalGardens api assembler kabelfernsehen OpenSource Scheibenwischer + + Tue, 13 Jul 2021 08:43:43 UT + + + legal + NoALaReformaTributaria eek scanlines kurmancî rma informatik formatie2021 hfgkarlsruhe doj amro karlsruhe dmc remotelearning tamron formatie SpreekJeUitBekenKleur newnormal line disinformation kurmanji OnlineHarms GameSphere squeekboard mermaid stopline3 DNSmugOfTheWeek permagold OnlineHarmsBill laipower gdpr intros Anticritique energyflow peekier MovieGeek OnlineMeetings informationsfreiheit mojeek digitalservicesact line3 disinfo mainline darmanin airline OfflineHarms permafrost geekproblem dmca + + Tue, 13 Jul 2021 08:43:43 UT + + + nature + hiking camping RedNeckedWallaby reforestation hillwalking wat hambach nsu20 marsupial lightning StormBella zensurheberrecht insect morning lavawervelwind seashell delightful plankton trees sky_of_my_window lichen MicroOrganisms badger nsu2 ProForestation nonsupremacy light gecko birds nature embargo_watch volcano teamcapy butterflies Nature frogs rainforest snow sunrise fossils hambacherforest forestfinance lighthouse hitchhiking leopardgecko moutains coldwater rocks inaturalist clamfacts sunset naturereserve forest LandRestoration australianwildlife forests capybara rgblighting enlightened waterfall sundaymorning forestation enlightenment natur lightening finance walking watches deforestation desert lava natural WoodWideWeb birdsarentreal lichensubscribe morningwalk lighttheme nsu retraction_watch SpringRockShed insects wildlife GreatInsults afforestation northernlights RainforestAlliance ProtégeonsLaNature amphibians walk desertification otter + + Tue, 13 Jul 2021 08:43:43 UT + + + writing + blog framablog interactive amwriting authors writingprompt poem lime cutupmethod story pdf blogPages swap shortstory prompts magazine smallstories prompt blogging smallpoems sciencefiction responsetootherblogs writing proverbs quotes blogs teleprompters noblogo otf logo playwright hedgedoc interactivestorytelling westernjournal Videopoetry quote olimex QuickSummary letterwriting icanhazpdf microblog bulletjournal storytelling goodread goodreads journalist creativewriting horror wordplay writers limerick journals artjournaling zineswap zines shortstories journalists journal writingcommunity poetry 20thcenturypoetry amwritingfiction + + Tue, 13 Jul 2021 08:43:43 UT + + + music + LibreMusicChallenge musicprodution KobiRock iea travessiapelavida LaurieAnderson ics punk punkname cooperativetechnology ourbeats gas vollgasindiekrise indieweb musician cypherpunk rutasenemigas synthesizer daftpunk bootstrappable kenloach indiemusic collapseos meatpunks LibreGraphicsMeetup cipherpunk 20thcenturyjazz acousticguitar synthpop psychedelicrock steamlinux playingnow streetpunk loader hydrapaper bikepunks bandcamp mymusic pop countryrock musicians jamendo ipod skinheadmusic jam rap shoegaze mp3 steam indie steganography steampunk ldjam48 indieauthor composing folkrock perlligraphy nazipunksfuckoff Music strap EnvoieStopHashtagAu81212 anarchopunk eurovisionsongcontest biography musicmaking psychedelic thecure posthardcore vaporwave IndustrialMusicForIndustrialPeople Mixtip dubstep synthwave bootstrap princeday oi graphisme rave freemusic nowplaying hiphop hardcore frappuccino Musicsoft experimentalmusic nazi folk cp TravesíaPorLaVida spotify fedimusic ml bootstrapping webscraping elisamusicplayer funkloch musicbrainz eurovision lasvegas catsWithMusicalTalent eos90D soundcloud psicodelia frankiegoestohollywood gastropod PigTrap bassguitar collapse 20thcenturymusic powerpop vinyl rock ccmusic denachtvanjanssen typographie dj newwave dorkwave producing experimental celticmetal prince musicproduction chiptune scraping loa Schleprock thrash bluestacks lastfm uploadfilters tekno ripprince Eurvision maunaloa technocracy asus 1 funkwhale 20thcenturyrock eos wp playlist retrosynth NowPlaying contest libremusicproduction psychrock MusicAdvent poppy coinkydink appropriatetechnology toledo samensterk indiepop rockalternativo MusicTouring indierock pmbootstrap midi arianagrande indiecember synth guitar blues musiciens listeningtonow abandonedplaces music folkpunk np bass techno gmtkjam musicmonday jazz production graphics dieanstalt perl darkwave mastomusic band TheGrunge metal chipmusic graphviz tigase polychromatic funk mindjammer popos magnatune fediversemusic pegasus grunge postpunk punkrock indieauth cyberpunkmusic raveculture cleantechnologies ldjam ftp BandcampFriday elisa mixtape garagerock MusicsoftDownloader camanachd + + Tue, 13 Jul 2021 08:43:43 UT + + + gardening + seedstarting BlagueDeCodeur sporespondence blockade inde mastogarden kinder communitygardening som deno composting soil sehenswert cabbage bundeswehr opensourceseeds onions lettuce blossoms gardenersofmastodon datenschleuder florespondence garten rinder succulent mulberry cherryblossoms garden thyme flower horticulture DailyFlowers Schlachthofblockade cherryblossom acu vegetable plant bricolage financialindependence kinderbijslag permaculture awesome teracube hens papuamerdeka Auflagen lag independenceday CompanionPlanting vlag gardens independence flowers seed kale seedvault plants thegardenpath devilslettuce vegetables thegarden fahrräder gardenersworld recyclage golden beekeeping toeslagenaffaire seeds Opensourcegarden toeslagenschandaal vegetablegarden + + Tue, 13 Jul 2021 08:43:43 UT + + + countries + thai romania korean burma lithuania solomon chile europeanparliament Instanz boycottisrael fiji tajikistan benin paraguay eeuu icelandtrip senegal ukraine italy brunei nicaragua guyana Pflanzenbestimmung grönland euphoria zambia PalestineStrike iceland europeancouncil morocco netherlands swaziland EuropeanUnion bosnian suriname winningatlife elsalvador russia freeburma samoa StaatstrojanerParteiDeutschlands romanian asl european czech belarus hayabusa2 bw kyrgyzstan english uk translation sanmarino catalonia panama africa west indians unitedkingdom japan Netherlands buyused venezuela gambia freeNukem kuwait barbados papua greece switzerland brasilien uae mau england FuckIsrael nigeria usa angola honduras djibouti laos sierraleone nonprofit britain cambodia translators ych vietnam esperanto neofeud zealios seychelles marshall kazakhstan estonia investigate tonga stlucia burundi bangladesh egypt nachhaltigkeit japanese mali congo us IcelandicVerminControl jordan MusiciansForPalestine americangods digitaleurope speedrun grenada israel psychic algeria ghana bosnia translations russian eritrea bhutan armenian hama hungary Störungsverbot saudi slovenia tig czechosvlovakia bahamas libadwaita australia kiribati togo koreanorth poland Überbevölkerung ethereum malawi AlwaysBetterTogether capeverde armenia american hautrauswasgeht bahrain mozambique moa WichtigerHinweis abcbelarus japaneseglare americanpinemarten beleuchtung southsudan adminlife citylife europehoax Martesamericana syria german micronesia maldives iran indigenous sweden bijîberxwedanarojava ethiopia sid cuba liberia canada burkina indian Südwestgrönland somalia Chile whatshappeningintigray scotland Enlgand russiaToday vaticancity easttimor austria EuropeanUnionNews turkey yemen Bolivia denmark USBased domesticabuse austrianworldsummit madagascar finland Wales philippines ivorycoast haiti ecuador Portugal azerbaijan gasuk spain albania massachusetts afghanistan europe mauritania dominica ökonomisierung thailand belize westpapuauprising nerdlife macedonia montenegro ChileDesperto thenetherlands qatar mongolia costarica boatingeurope birdsofkenya boat latvia uzbekistan fatigue kabelaufklärung ireland iraq malaysia mexico investigations mauritius dezentralisierung oman chad nz de georgia zimbabwe france serbia lesotho romani halflife oddmuse tunisia argentina czechia cameroon namibia sudan indonesia lifeboat colombia worldwildlifeday kryptowährung tuvalu britainology merica beckychambers turkmenistan tanzania germany neuhier norway comoros auteursrecht guatemala Thailand kosovo eastgermany andorra wales indiastrikes vanlife Palestine servus pakistan belgium china 3615malife antigua life europeanvalues koreasouth newzealand visiticeland einzelfall rwanda luxembourg libya indywales italyisntreal nauru moldova bad spanish eastindiacompany northernireland stigmergic palau taiwan kenya trinidad eu botswana Lebensmittelzusatzstoff CuriosidadesVariadas jamaica vanuatu cyprus aminus3 israele malta Icelandic psychedelia niger s3 westpapua busse unitedstates myanmar saintvincent guinea nepal peru uganda uruguay india pacificnorthwest lebanon neurodiversity southafrica writer arte croatia europeanunion writerslife bolivia chinese dominican europeancommission srilanka bulgaria slovakia speedrunning gabon psychedelicart ether stkitts liechtenstein saveabkindonesia neofeudalism surinam brazil shutdowncanada + + Tue, 13 Jul 2021 08:43:43 UT + + + privacy + privacyplease state whatip auditableprivacy PrivacyBook SearchHistory privacyaware dataprivacyday profiling what3words surveillancestate Privacy privacypolicy WhatsApp privacyrights privacytoolsio makeprivacystick privacyweek surveillancetech onlineprivacy developertools WhatMakesMeReallyAngry privacyredirect Liberanet LiberanetChat drugpolicy privacymatters policy privacyMatters whatsappprivacypolicy dataprivacy privacywashing privacy privacyinternational whowhatwere hat NoToWhatsApp DataPrivacyDay2020 PrivacyFlaw nl WhatsappPrivacy tool + + Tue, 13 Jul 2021 08:43:43 UT + + + hardware + plugandplay bluetooth printnightmare singleboardcomputer purism dating schematics opennic zomertijd librehardware BoBurnham restauration rmw riscv solarpower carbonFootprintSham mietendeckel PersonalComputer cyberdeck PineCUBE firmware tex keyboards debuerreotype electron ChromebookDuet AbolishFrontex bond hibernation PneumaticLoudspeakers schreibmaschine imac Nottingham schwarmwissen elitesoldat handheld screenless megapixels BibliothekDerFreien KeepTheDiskSpinning homebrewcomputing FarmersTractorRally pinebook farming modem lowtech biblatex allwinner datenschutz daten home pimeroni 68 lebensmittelsicherheit industrial hambibleibt analogcomputing homer TrueDelta keyboard screenprinting robotics Pinecil mutantC raspberrypi3 pocketchip oshw misterfpga noisebridge disapora T440p ArmWorkstation datensicherheit latexrun hardwarehacking mer picodisplay laptops electronics scuttlebutt ham teamdatenschutz charm SectorDisk wolnabiblioteka permacomputing uart panasonic pcb almere armbian performance kopimi printmaker deck making hambi powerpc solar ssd acoustics ibmcompatible webcams modular larp tweedekamer cybredeck latex 3dprinted emmc ipadproart computing laptop solarpunk isa recycling modularsynth apparmor repairability theatrelighting lenovo updates fairelectronics industrialmusic librem carbonsequestration electronica sed TokyoCameraClub MacBookProService pocket box86 JingPad righttorepair mac trackball fuse date solarpunkactionweek ibm 3dprinting electro carbon MechcanicalKeyboards netbook hardware m68k pisa retrohardware pinetab sicherheit openhardware raspberrypi irobot datenautobahn webtoprint 3dprinter barcode lüneburg Quartz64 PlanetComputer jtag ebu merseyside itsicherheit CompressedAirAmplification pinetime screens pinebookpro lebensmittel 3d batteries PinebookPro 3dprint pim Handprint modemmanager securescuttlebutt keyboardio mechanicalkeyboard electronicmusic solarpunks carbondioxide robot arm lowerdecks ipad FireAlarms PinePower paperComputer amd openpower poweredSpeaker devopa a64 eeepc bahn F9600 rpi4 thinkpad RaspberryPiPico iot dat BeagleV arm64 merveilles repairable sbc circuitbending raspberrypi4 print displayport akihabara analog electronic FrameworkLaptop + + Tue, 13 Jul 2021 08:43:43 UT + + + security + zuluCrypt signalboost encrypt letsencrypt messengers BrowserHistory FlexibilizaciónResponsable autoritäreretatismus BlacksInCyber omemo autotomy saveanonymity alg onionrouting Installationsanleitung dataleak messenger foodinsecurity password keepassxc partyline cryptography party cybersecuritynews pipewire Installation cryptolalaland solarwinds bitwarden communityalgorithmictrust infosec gchq GemeinsamGegenDieTierindustrie mitm wireless castor repairing IHaveSomethingToHide fotografie passwords gif IronySec cryptowars anonym encryptioncan supplychainattacks UseAMaskUseTor anonymous cyberattack editors security tor comb e2e supplychain bruceschneier gigafactory vpn BlacksInCybersecurity ransomware toreador itsec dnssecmastery2e openssh factorio Reactorweg openssl backdoored spyware dorfleaks torx encryptionsts e2ee sequoia backdoor cryptotokens NSAmeansNationalScammingAgency stork conscientiousobjectors ed25519 torproject cryptomeanscryptography encryption 0day informationsecurity ssh misshaialert cybersec ox restore FormFactors crypto theObservatory autokorrektur giftofencryption foodsecurity kansascity auto signalapp anonymity automattic fotografía onionshare onion encryptionist kontor autofahrer infosecbikini autocrypt malware switchtosignal cloudsecurity corydoctorow RestoreOurEarth radiorepair algérie hexeditor distortions cryptographyisoverparty opsec keepass encryptionists TastySecurity cryptobros securitybyobscurity torsocks toronto nsa autorenleben schneier protonvpn trustissues InsecurityByObscurity yubikey nitrokey encrypted 1password openpgp pgpainless tatort ghibli afraleaks castor9 deletesignal prismbreak gpgtools autodidactic gpg automation fotopiastory equatorial sequoiapgp cybersecurity Tor CryptoWars signal noscript redaktor vector trust Torge Torfverbrennung sasl cryptoparty wire historia AllmendeKontor itsecurity websecurity foto pgp RobinHoodStore cryptomator signalmessenger openvpn datasecurity autorotate regulators anleitung leak drugstore encryptiost libresignal doctors securitynow storage tracking + + Tue, 13 Jul 2021 08:43:43 UT + + + science + engineering math politicalgeography epidemiology stemfie TranslateScience electrochemistry ethnology womeninstem archeology botany STEM biodiversity ocean stemgeenFVD linguistic anthro supercollider nextgeneration zoology linguistics climatology uprootthesystem oceans SolarSystems reasoning awk dna geography physics intergenerational archaeologist generalstreik geology ClinicalPsychology generationidentitaire economicanthropology Science SystemicRacism OpenScience corrosion research stemwijzer systemsmap bioengineering GotScience sistemainoperativo stemgeenPVV knowledge stemgeenVVD botanical dream dawkins ineigenersache psychogeography stemgeenVVS holo graphTheory deepdreamgenerator AnnualStatisticalReview trilateralresearch meterology botanicalart JA21 regenerative ScienceDenial biotech stemgeenJA21 psychology dreamtime pataphysics particles biology bughunting researching_research hunt pacificocean generation gene fossilhunting arthunt badscience mathematics chemistry muon processengineering paleontology oceanography stem anthropocene particlephysics nextgenerationinternet biomedical mechanicalengineering anthropology + + Tue, 13 Jul 2021 08:43:43 UT + + + photos + smartphonephotography nikon 90mm photography fujifilm rewild photogrammetry wildlifephotography wild affinityphoto photocló photo photographe photogrpahy photographer tokyocameraclub nikond90 photos macrophotography photoshop photographie photovoltaik seancephoto camera crops photomanager macropod uwphoto wildbiene macronie photographers cameras fossphotography phototherapie phonephotography myphoto rewilding naturephotography fediphoto picture wildfood macro intothewild streetphotography FujinonXF90mm wildcat photoreference crop phototherapy pictures + + Tue, 13 Jul 2021 08:43:43 UT + + + history + musichistory heirloom monarchs holocaust history arthistory makeinghistory History anarchisthistory indigenoushistorymonth gaminghistory womenshistorymonth NetworkingHistory blackhistory monarch computerhistory HistoryOfArt + + Tue, 13 Jul 2021 08:43:43 UT + + + software + beta borgbackup forms app freeUP1 freedombox windows edit nginx transclusion krebsrisiken proprietarysoftware freepalestin calibre misophonia fosshost postscript nota AAPIHeritageMonth freenet freebsd kc Framasoft tts E40 Flisol2021 invidious drm softwarelibero publicdomain ilovefreesoftware hydra readers StoryMapJS kubernetes openvms luca nodrm copyleft fossmendations happyauthor freedoom librespeed jami betatesting NottsTV libregraphics genossenschaft FuckOffZoom quicksy thunder whiteboard free docker softwarelibre opensourcehardware interoperability impression3d freesoftware gimp krebs backups foss matrix dinosaur mossad unfa weechat clapper designjustice thefreethoughtproject filesystems nextcloud translate wechat notmatrix gnupg lucaApp chats duplicati HappyLight opensourcesoftware permissionless compression openscad freeganizm uidesign softwaredeveloper neochat TabOrder searx ikiwiki prosody Linux FreeSoftware userresearch FlisolLibre2021 DisCOElements Audio rocketchat thanksfreesw libres webapps immers outreachy synapse API freelibre lyft freekirtaner nitter monitoring misogyny virtualbox ngi4eu discord reverseengineering whisperfish ee opensourcedesign vaporware opensource diaspora yunohost oss librelounge chickadee appstore dégooglisons littlebigdetails cabal conferencing cadmium libreboot musiquelibre mycroft devops kdeapplications owncast phabricator emacs freiesoftware FLOSSvol moss fluffychat dinoim impress writefreely videoconferencing bigbluebutton tile_map_editor email ngi esri chatapps HappyNewYear Eiskappe fossilfriday floss plugins softwaresuite frecklesallovertheshow graphic libresoftware softwareengineering mosstodon expandocat deltachat application uifail FOSS peatfree lucaapp GNOMECircle rockpro64 bittorrent palestinewillbefree penpot vlc zoom southasia tiling session diaspora0800 FriendofGNOME Senfstoffknappheit usability winamp opendesign obnam snap appim ProprietarySoftwareProblems pandoc Happy4thJuly freemumia write artificialintelligence blackcappedchickadee cryptpad software libretranslate OwnStream upstream maplibre slack Hummingbard hydrated emacslisp Element freeware DismantleFossilCompanies safenetwork asia jit SoftwareLibre zrythm gnu CTZN silicongraphics mumble grsync freecad drmfree telegram containers tails freeschool chatons blockchain windows11 irssi HabKeinWhatsapp information mcclim jitsimeet dedrm iso mutt librelingo freetibet WeAreAlmaLinux tilingwm sri design gameoftrees GnuLinuxAudio freegan freeriding freetool backup trueLinuxPhone ngio rotonde freetube jumpdrive GNU speechrecognition eurovison skydroid thunderbird sysadmin it sound alternativeto screenreader parler bison apps chat licensing fossasia inclusivedesign ethicalsoftware defectivebydesign metager digitalsustainability screenreaders sysadmins ZeroCool LINMOBapps freedombone uber obsproject librecast softwareheritage pittsburgh profanity delta Tankklappe doomemacs imageeditor ffmpeg fossandcrafts GNOME40 telesoftware love reboot opensourcegardens musique switchingsoftware OSM freesw agpl distribute magnifyingglass GNOME freeganizmniewybacza drive freesoftare AlmaLinux GreenandBlackCross strafmaatschappij freetillie distributedledger mattermost principiadiscordia blue LinuxPhones filesystem rocket ghostscript win10 Zoom tibet ComputerFolklore fossaudio elemental SocialCreditScores flisoltenerife libreops element platforms inclusive librelabucm engineer softwareNotAsAService ptp chatty lucafail informationwantstobefree softwareGripe nativeApp MatrixEffect culturalibre jitsi taintedlove flisol engineers dinosaurier wordpress SwitchToJami mongodb ux rsync libreoffice crossstitch Encrochat dino RainbOSM plugin xwiki tecc openoffice container discordia softwaredesign redeslibres ledger sounddesign palestine chatcontrol alternatives glimpse libregraphicsmeeting + + Tue, 13 Jul 2021 08:43:43 UT + + + conferences + FOSDEM2021 stackconf fossnorth debconf debconf21 FOSDEM talk fossdem FreedomBoxSummit apconf2020 schmoocon Aktionscamp realtalk persco penguicon2021 letstalkaboutyes summit confidenceTricks agm libreplanet SeaGL2021 confindustria confluence minidebconf edw2021 maintainerssummit rc3worldleaks rightscon StopStalkerAds SeaGL penguicon emacsconf MCH2021 flossconference LGM2021 conferences LibrePlanet defcon emfcamp flossevent askpinetalk bc conf talks defcon201 rC3 rC3World FOSDEM21 conference mozfest flossconf bootcamp apconf ccc persconferentie GeekBeaconFest rC3one smalltalk camp g7 C3 config penguicon2022 confy + + Tue, 13 Jul 2021 08:43:43 UT + + + food + vitamind cake veganism teamviewer FoodHardship vanilla pankow margarine zwartepiet panthera dessert foils salsa caviar utopie brot theexpanse BellaSpielt cookery pietons Ôtepoti panther food cakecutting skillet teamgodzilla spiel Vegan liquor SoilSovereignty milk bolognese recipe foodporn yeast drinking VendrediPeanutsNouka plate waffle pansexual biscuit glaze omelette veganismo morel filet pastry wine woke Caribbeans hamburger juice unauthorizedbread Amazfish Avocados management sourdough gedankenspiel cagefree words MauriceSchuhmann nuts gras toast broth batter foodie breadposting spiele zerowaste haggis ketchup carrots go-nuts damnfinecoffee divoc seasoning mayo nowords MastoEats soup arpanet pan voc imateapot Anglefish potatoes mayonnaise vegan dish avocado spice keto bakery butterfly cooking teamhuman SailfishOS Trypanophobia AgentProvocatuer yogurt rok thecandycrystalrainbowcodex crumble PropaneSalute cider caffeine Kinipan butter mastokitchen triceratops cook pottery kurdish creepypasta wastemanagement kitchencounter mastocook cobbler steak pizza vocaloid crystal soda fedikitchen aroma oil Miroil flour foodsovereignty cream nutella pie cut cuisine potse meatismurder freerange tartar kropotkin tea marinade cakes mushroom thekitchen govegan entree lfi dominospizza bread salad beans mush fresh syrup fermentation teamsky mushrooms cookie cookiebanners wordstoliveby curd soysauce lowcarb pudding plantbased beer organicfood peterkropotkin fish grasslands panoptykon spanisch honeypot foodnotbombs foodwaste organic wholeGrain wheat pot TeamFerment timewaster Wypierdalaj sauerkraut stew weltspiegel chocolate paste soynuevo wok rainbow recipes kitchengarden expanse olive burger mrpotatohead candy lifesnacks Steam kitchen coffee foodshortage bagel batterylife OpTinfoil teams taste SpieleWinter2020 meat johannisbeeren noodle raclette caramel rice eggs grill davewiner poutine demoteam lard croissant pasta vegane strawberry toomuchcaffeine morelmushroom foods coffeeaddict WaterDrinkers cheese oregano drink muffin bikekitchen krop LowRefresh kyotocandy foie onepiece sauce foodanddrink soy foodpics growyourfood vore mushtodon wholewheat pandemie cocoa sandwich bigoil mousse waste chili redfish + + Tue, 13 Jul 2021 08:43:43 UT + + + farming + johndeere deer + + Tue, 13 Jul 2021 08:43:43 UT + + + facts + lifehacking funfact lifehack + + Tue, 13 Jul 2021 08:43:43 UT + + + indymedia + fpga hs2 dotcons visionontv geek tredtionalmedia degeek globleIMC indymediaback openfoodnetwork pga mainstreaming indymedia networking closed stupid foo encryptionsist hs2IMC indymediaIMC network networkmonitoring Blackfoot roadsIMC stupidindivialisam roadstonowhere networkeffect lifecult closedweb avgeek monitor dotconsall omn tv roadstonowhereIMC kiss UKIMC fluffy 4opens openmedianetwork + + Tue, 13 Jul 2021 08:43:43 UT + + + cycling + bicycle bicyles cycle bic cycling bicycleday DataRecycling arabic bike motorbike bikeing cyclingtour thingsonbikes openbikesensor bikeways Snowbike cyclist + + Tue, 13 Jul 2021 08:43:43 UT gender - transparantie transistors broketrans transwomen transformativejustice transparency transcrowdfund transgender womensart female nonbinary vantascape blacktransmagic trans transpositivity transphobia women sri estradiol transgenders queer genderQuiz gender genderqueerpositivity woman transrights + black transparantie transistors transparenz broketrans internationalwomensday2021 transwomen transformativejustice womenwhocode transfobie WomenInHistory sf transmission transgender cashless RaquelvanHaver caféLatte transdayofresistance mens vieillesse womensart blacktranslivesmatter female nonbinary womensday vantascape van blacktransmagic less nb trans nonbinarycommunity transpositivity transdayofvisibility lgbtqia transphobia transmitter women lgbt bodypositive transzorg womenrock estradiol lgbtq transaid queerartist KCHomelessUnion transgenders girlboss pointlesslygendered queer transdayofvisbility genderQuiz gender genderqueerpositivity NonBinaryPositivity dagvandearbeid woman transrights transdayofrevenge - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - audio - feed audioproduction pulseaudio audi audiofeedback audio + phones + mobileapp cellular fairphone3 téléphone libre nemomobile fairtec linuxfr conocimientolibre manjaro Jingos plasmaDev TourBrandenburg21 rand 5g mobian LinuxPhoneApps lg pine Brandkopf alarmphone androidemulator fdroid plasmamobile shotonpinephone fairuse android smartphonepic nophone ubportsqanda linuxmobile sailfish phones fennecfdroid Mobian osmf AlpineConf smartphone plasma5 ios selinux mobileGNU PinePhoneOrderDay exxon sms4you mob bp microphone linuxconnexion smart smartphones iOS14 pinemarten linuxphones openmoko mobilecoin mobilelinux freeyourandroid fair QWERTYphones exxonmobil sailfishos siskinim epic monal android10 osmocom Smartphones WakeMobile lineageos molly angelfish androiddev Briar manjarolinux quasseldroid wirtschaft plasma mobilephones phosh BriarProject Fairphone librem5 ubportsinstaller osm shotonlibrem5 pinephone Teracube PinePhone pinedio mobile pinephones manjaroarm sms pine64 fairphone ubuntutouch linphone Android osmirl ubports gnomeonmobile immobilienwirtschaft Bramble osmand vodafone gnomemobile linuxonmobile iphones postmarketos iOS microg brandenburg librecellular grapheneos sail recycletechjunkuselinux phone cm mobileKüfA josm iphone linuxappsummit Xperia10mark2 newprofilepic - Thu, 18 Feb 2021 21:34:48 UT - - - media - livestreaming mainstreaming stream trad selfsustainable kawaiipunkstreams mainstream streaming weAreAllCrazy maiabeyrouti sustainability diymedia submedia theatlantic traditionalmedia videos newsmedia wikimedia mixedmedia railroads documentary streamers tootstream taina ai realmedia media independentmedia theintercept - - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT activism - protestor clearchannel grassroot FreeLibreOpen g20 rig bekannt farmersprotest protests riseup sflc DanniVive apt reuse stopspyingonus keepiton FSFE20 fsfe softwarefreedom ilovefs ann activist xr SustainableUserFreedom seachange directaction eff change openrightsgroup protest icantbreathe JeffreySDukes actiondirecte kroymann HS2 ngo MarcWittmann fsf fsfi StopHS2 grassroots HS2Rebellion antireport ClimateJustice BLM ExtinctionRebellion changeisinyourhands conservancy ngos sp JefferySaunders LiliannePloumen mongodb climatechangeadaptation Kolektiva XR freeolabini announcement isolateByoblu annieleonard + UniteInResistance rightwing rights protestor dutysolicitor roots WeDemandTransparency CallToAction annonce rojava PrisonLivesMatter clearchannel nog20 Lobauautobahn farright eni tyrannyofconvenience grassroot nonviolentcommunication FreeLibreOpen g20 JusticeForRapheal rig bekannt farmersprotest animalrights protests resistance cyborgrights riseup resistg7 DontShootTheMessenger demo PrisonSolidarity linnemann sflc DanniVive apt freeassange dangote reuse stopspyingonus keepiton Dannenroederforst FSFE20 fsfe killthebill edri softwarefreedom indigenousrights unautremondeestpossible AntiCopyright Rojava ilovefs stopnacjonalizmowi ann activist wec HeroesResist edrigram xr SustainableUserFreedom bannerlord undercurrents riseup4rojava righttoexist seachange directaction mannheim Doulingo politicalactivism diskriminierung wechange seattleprotests eff Gardening gamechanger change openrightsgroup protest icantbreathe channelname JeffreySDukes planning FSF userrights LaptevSea actiondirecte kroymann climatechange protestsupport channel climatchange HS2 ngo MarcWittmann StandWithTillie Danni FrightfulFive fsf fsfi StopHS2 grassroots HS2Rebellion protestcamp resist FreeJournalistAssange announcements antireport ClimateJustice duolingo RodrigoNunes FreedomCamping BLM ExtinctionRebellion shellmustfall namechange changeisinyourhands wlroots weareallassange conservancy ngos UserFreedom sp bin JefferySaunders freepalestine CopsOffCampus GreatGreenWall LiliannePloumen freeassangenow savetheplanet directactiongetsthegoods hauptmann activismandlaw climatechangeadaptation Kolektiva XR freeolabini tellthetruth announcement isolateByoblu annieleonard - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - questions - askmastodon askfedi question askmasto askfediverse totallyaskingforafriend ask askfosstodon + accessibility + you a11y accessibility captionyourimages hardofhearing - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - birds - RainbowBeeEater pigeon cawbird pigeonlover bird birdwatch + pandemic + covid19 coronaPolicies gevaccineerd corona getvaccinated CovidImpacts psmeandmywholefamilycaughtcovidfromwork Coronavirus CoronaWarnApp facemasks vaccines wijvaccineren culturalrevolution pandemics vaccine vaccinesupply JournalistsSpeakUpForAssange Covid vaccinated coranavirus pandemic sayhername internationalproletarianrevolution Zbalermorna covidville ZeroCovid pandemia coronapps volkstheater COVID19india contacttracing coronavaccinatie SùghAnEòrna tier4 coronapandemie covid pand volla volodine COVID19NL Moderna coronavirus masks Moderna2 COVIDrelief coronapas virus contacttracingapps moderna coronadebat vaccin COVIDー19 Lockdown rna unvaccinated codid19 CripCOVID19 LongCovid COVID19 vaccination YesWeWork ContactTracing vol CoronaCrisis COVID coronamaatregelen debat international internationalsolidarity coronabeleid - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - ethics - digitalethics ethics ethicallicense license ethical + books + readinggroup bookstore publicvoit bookbinding preview justhollythings secondhandbooks bookclub fake earthsea review ebooks docbook book notebook public amreading publishing republicday publichealth bookworm bookwyrm 5minsketch republique bookreview reading sketching theLibrary audiobooks Gempub selfpublishing sketchbook wayfarers books peerreview bookreviews failbooks sketch ebook wikibooks booktodon epub cookbook bibliothèque AnarchoBookClub - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - disability - ableism disabled + crafts + repair topic_imadethis hackerexchange exchange quilts textile upholstery hackgregator gatos gato hackspacers nrw shack 3dmodeling dust3d hackerspaces hacklab hackerexchange + + +]] tryhackme sanding solvespace theglassroom sundiy craft wirtschafthacken papercrafts maker knitting hack workspace hacked Sipcraft calligraphy biohacking wip spacecrafts hacktheplanet jewelry diy textiles projects hackerweekend handicrafts Handicraft lovecraftcountry upcycling Minecraft woodworking 3dcad glass origami hackerexchange + + +]] makers nrwe quilting crafting hacker quilt crafts rwe weaving 3dmodel handtools tinkering project hacking woodwork ceramics embroidery shacks teardown - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT + + + war + ru DonavynCoffey Myanmarmilitarycoup civilwar antiwar bomber coup tank handforth landmine tankies military autonomousweapons army Etankstelle weaponsofmathdestruction conflict navy warplane fort guns Myanmarcoup weapons siege hbomberguy battle WMD wmd airforce forth + + Tue, 13 Jul 2021 08:43:43 UT + + + techbros + bubbles bubble color redbubble securedrop einfachredeneben redditodicittadinanza coloredpencil redhat redwood hackernews weareredhat redmi red pencil reddit redon redis infrared VendrediNouka redshift optreden sec + + Tue, 13 Jul 2021 08:43:43 UT + + + astronomy + telescope immersspace mercury pluto planets galaxy spaceport venus mars bloodmoon amateurastronomy uranus spacex nebula astronomy neptune space jupiter rpc blackhole asteroid BackYardAstronomy moon thehitchhikersguidetothegalaxy observatory euspace asteroidos saturn milkyway spacelarpcafe + + Tue, 13 Jul 2021 08:43:43 UT + + + other + ageassurance bullshit klimaatbeleid justasleepypanda extinctionrebellion fail masseffect lastpass yolo nothingnew Lastpass extinction weareclosed bripe MasseyUniversity PassSanitaire solution messageToSelf TagGegenAntimuslimischenRassismus quecksilber itscomplicated Erzvorkommen test isntreal rzeźwołyńska massextinction misc manutentore frantzfanon shots assaultcube shitpost biomassacentrale mining rising devilsadvocate ACA pinside xp impfpass cda rant Terrassen righttodisassemble rassismus MassoudBarzani koerden CovPass nahrungskette SomeUsefulAndRelvantHashtag LanguageHelpForMigrants nsfw dungeonsAndDragons biomass rassismustötet oversleep ass id Chiacoin futtermittel geo oerde m assassinfly migrantstruggles sleep PointlessGriping close decluttering OCUPACAOCARLOSMARIGHELLA + + Tue, 13 Jul 2021 08:43:43 UT + + + photography + peppercarrotmini NoShothgunParsers pea landscapephotography landscapeart XSystem darktable photograph peppercarrot speakers hippeastrum landscape blackandwhite hot twinpeaks + + Tue, 13 Jul 2021 08:43:43 UT + + + month + maythe4thbewithyou yt ots april juneteenth PrideMonth2021 1may july VeganMay march pridemonth chapril marchofrobots2021 october november august june blackherstorymonth december september augustusinc may feburary jejune PrideMonth january marchofrobots blackhistorymonth march4justice month robots maythe4th blacktheirstorymonth + + Tue, 13 Jul 2021 08:43:43 UT + + + news + basicincome report news krautreporter flash basic Wikileaks newsletter aljazeera nothingnews newsflash contemporaneous_reports newsroom EUNews Worldnews rt bbc foxnews journalismisnotacrime News bbcbasic goodnews flashcrash doubledownnews bbcnews reuters newschool theguardian badReporting newsboat journalism SkyNews lobsters + + Tue, 13 Jul 2021 08:43:43 UT + + + health + merchandise FreedomIsTheOnlyTreatment gnuhealth water 4 medical CoronaApp bundesregierung runningdownthewalls autism burnout Underunderstood cannabis hand event healthinsurance medicine anxiety freshwater inflammation run eternalpuberty NHSDataGrab treatment EmotionalFirstAid safeabortion4all maryjane organisierung autistic BlockBrunsbüttel running neurodivergent health motion crunchbang actuallyautistic meds PatientSafety marijuana suicideprevention mentalhealth postmortem H5N8 healthy DarrenChandler autismmeme einzelhandel drugs atm neurodiverse asperger cigarettes hearingimpairment selfcare autismus + + Tue, 13 Jul 2021 08:43:43 UT + + + cats + Cat dailycatpic dxp DailyCatVid Cats katze kotorico kot ketikoti qualitätskatze CatsOfMastodon Catshuis Leopard SpaceCatsFightFascism CatBellies catbellies LapCats qualitätskatzen katzen + + Tue, 13 Jul 2021 08:43:43 UT + + + media + InfiniTime livestreaming ip digitalmedia mustwatch sustainable videobearbeitung transparency polarbears mediathek mianstreaming stream videoconferencias trad maistreaming ime sustainabilty mixxx shortfilm selfsustainable amstrad kawaiipunkstreams mainstream films streaming weAreAllCrazy video streamdeck puns maiabeyrouti videoconference mix mixed sustainability diymedia Fairtrade film streams massmedia stummfilm submedia theatlantic traditionalmedia videos Internetradio mediawatch mainstreamining newsmedia audiovideo videosynthesis filmnoir wikimedia mixedmedia railroads heat documentary streamers artstream vi folktraditions gstreamer tootstream taina ai mediawiki bear realmedia media independentmedia SiberianTimes theintercept + + Tue, 13 Jul 2021 08:43:43 UT podcasts - podcasting IntergalacticWasabiHour podcast tilde til postmarketOSpodcast tilderadio tildes podcasts tildeverse smallisbeautiful fertilizers tilvids + beautiful podcasting IntergalacticWasabiHour JenaFahrradies podcast rad radiopodcast postmarketOSpodcast TraditionCruelle podcasting20 tilderadio tildes podcasts tildeverse radverkehr smallisbeautiful fertilizers PineTalk radweg tilvids fahrrad tildetown qtile trillbilliespodcast - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT + + + radio + cbradio worldradioday radiokookpunt hamr freieradios varia why radioamateur shoshanazuboff winlink tootlabradio pouetradio schenklradio dx macintosh radioactive amateurradio radiohost radiokapital talkradio localization nwr vantaradio ca radio healthcare listening hamradio FreeAllPoliticalPrisoners variabroadcasts card10 fastapi webradio freeradio radiobroadcasting radiosurvivor Poecileatricapillus apis radioshow local radio3 noshame osh audycja hackerpublicradio kosher radioalhara Phosh audycjaradiowa california road nowlistening radiobroadcast radiostation mastoradio broadcasting radiodread amateurr radiolibre spazradio anonradio Capitaloceno kolektywneradio io + + Tue, 13 Jul 2021 08:43:43 UT + + + pets + buny spinning catpics shepherd leiningen uninstallman ExposureNotifications ats germanshepherd catofmastodon nin TheRabbitHole staatstrojaner deskcat verification eurocrats QuestioningTechnology toocute cataloging cathedrals petpeeve Stelleninserat acidification reEducationCamp mastodogs rats puppets catbehaviour digidog dogecoin Stallman Coolcats petrats governing dogsofmastodon gentrification evening broadcats gattini bunyPosting benjennings kitten fostercats gamification woningnet WegenErdogan jürgenconings cats uninStallman kittens Uninstallman pet dog scotties ageverification Pruning woningnood acat catontour catsofmastodon leninismo podcatcher meow cute mastocat lenin catstodon dogs reimagining catsofparkdale mastocats W3CSpecification mastodog notpixiethecat londoninnercitykitties cat blackcat furry petitie JuliaKitten dogsofmaston JurgenConings training scottie catcontent UserDomestication + + Tue, 13 Jul 2021 08:43:43 UT + + + games + appdesign gameofshrooms minecraft soloRPG nbsdgames tetris99 gamestop libregaming ageofempires mondragon BiophilicDesign videogame ksp TerraNil productdesign dungeonmaster gogodotjam AudioGame runequest miniatures dragonfall boardgames computergames creature fucknintendo fudgedice angrydesigner gameassets gamestonk videogames FediDesign gameboy puzzle indiegames gamedesign shadowrun spot godotengine adventuregames chess gamejam nintendoswitch mudrunner mud indiegame game 0ad dragon playlog gameart orca sdg lovewood designfail opengameart sign asset gilgamesh ttrpg gamedev freegames guildwars2 creaturedesign bideogames adventuregame TetrisGore gaming gamemaker gameing nintendo roleplayinggames itch unvanquished gamesdonequick Gamesphere devilutionx rpg gamespot tetris dosgaming supertuxkart freegaming DnD socialdesign cyber2077 godot gamestudies tarot cyberpunk2077 gamesforcats FreeNukum spelunkspoil boardgaming supermariomaker2 neopets minetest omake guildwars dice dnd games + + Tue, 13 Jul 2021 08:43:43 UT + + + years + newyearsresolutions resolutions Year2020 year 1yrago newyear happynewyear 5yrsago yearoftheox newyearseve + + Tue, 13 Jul 2021 08:43:43 UT + + + philosophy + postmeritocracy post minimalism maximalist Allposts maximalism digitalminimalism postprocess philosophy erp stoic spiderposting postfordismo postmodernism minimalist + + Tue, 13 Jul 2021 08:43:43 UT + + + transport + deutschland luggage Gütertransporte publictransport busses transportation train transport deutsch deutscheumwelthilfe airway journey motorway hilfe deutschebahn travel ev prorail airport rail + + Tue, 13 Jul 2021 08:43:43 UT + + + ethics + licenses digitalethics ethicaltech ethics ethicallicense ethicswashing license ethical ethicsintech + + Tue, 13 Jul 2021 08:43:43 UT + + + commons + ed mentalillness opennmsgroup OpenAccessButton niemandistvergessen distraction open linkedopenactors openaccess reopening openocd openengiadina opennms ess badges opensocial commonscloud activisim openlibrary characters opensourcing innovation openpublishing InstantMessenger LessIsMore openrefine openworlds extraction openwashing publicinterest besserorganisieren exittocommunity openinnovation opennmt openbadges act accessable ManufacturaIndependente openspades Accessibility keinvergessen GetSession openrepos2021 openftw Bessa + + Tue, 13 Jul 2021 08:43:43 UT + + + seasons + mailspring spring lupin thespinoff Dadvice autumn abolishice desummersummit licenziamenti namedropping office hooping sipping es fuckice winter EthicalLicenses ice luejenspringer hpintegrity pingpong santa summer iced LibreOffice summerschool onlyoffice pinball icedipping solstice unicef wintersolstice FederalOffice summerRolls pin mice + + Tue, 13 Jul 2021 08:43:43 UT + + + questions + checking kayaking askmastodon flockingbird biking questions king euskadi asking lockpicking factchecking askfedi basketball askafriend flask GlobalBanOnFracking TraditionalWoodworking question ska askmasto breaking scrap_booking maskengate criticalthinking askfediverse fucking totallyaskingforafriend ask daretoask askfosstodon + + Tue, 13 Jul 2021 08:43:43 UT + + + fiction + ABoringDystopia interactivefiction cyberpunk VersGNRWstoppen thehobbit fiction microfiction stopCGL nonfiction DystopianCyberpunkFuture stoptmx top flashfiction cyberpunk2020 genrefiction + + Tue, 13 Jul 2021 08:43:43 UT + + + audio + feed audiophile liveaudio audioproduction feeds pulseaudio audi webaudio feedbackd audioprogramming audioengineering audience audiogames audiofeedback audio auditoriasocial + + Tue, 13 Jul 2021 08:43:43 UT + + + garbage + Anonymous cumbria documentation no QAnonAnonymous docu cardano cum u ChanCulture + + Tue, 13 Jul 2021 08:43:43 UT + + + birds + RainbowBeeEater aves birb pigeon cawbird pigeonlover bird birdwatch birdsite birding birbposting birdwatching + + Tue, 13 Jul 2021 08:43:43 UT + + + disability + ableism disabled ableismus + + Tue, 13 Jul 2021 08:43:43 UT + + + travel + tax travellers taxi airtravel + + Tue, 13 Jul 2021 08:43:43 UT + + + religion + atheist buddhist ama neopagan pagan catholic paganism genesis jesuit SiddarthaGautama oorlogspropaganda + + Tue, 13 Jul 2021 08:43:43 UT culture etiquette - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT funding - donate disabilitycrowdfund disabledcrowdfund fundraiser BreakWalledGardens ngizero zeroknowledge edge led patreon + donate disabilitycrowdfund disabledcrowdfund erschöpfung now oled alledoerferbleiben LeylaKhaled ethicalfunding mastercard netzfundstück didyouknow fundraiser BreakWalledGardens ki membership fundamentals nzSuperFund ngizero fun oer zeroknowledge edge led zerohedge DefundLine3 vkickstarter fungiverse alledörferbleiben fungus SmallPiecesLooselyCoupled fungi EntangledLife desperate opencollective patreon FundOSS - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT identity - boomer + genx boomer genz zoomer - Thu, 18 Feb 2021 21:34:48 UT - - - political - copservation rog linguisticProgramming - - Thu, 18 Feb 2021 21:34:48 UT - - - fashion - brasil fashionistas fashionesta bras fashionista fashion punkwear socks patches feditats zebras - - Thu, 18 Feb 2021 21:34:48 UT - - - techbros - securedrop einfachredeneben coloredpencil redhat hackernews red reddit redis - - Thu, 18 Feb 2021 21:34:48 UT - - - month - april july march chapril october november august june blackherstorymonth december september may feburary jejune january blackhistorymonth month blacktheirstorymonth - - Thu, 18 Feb 2021 21:34:48 UT - - - welfare - universalcredit welfare credit - - Thu, 18 Feb 2021 21:34:48 UT - - - books - justhollythings bookclub earthsea ebooks docbook book amreading republicday failbook bookwyrm bookreview reading theLibrary wayfarers fakebook books bookreviews ebook epub cookbook AnarchoBookClub - - Thu, 18 Feb 2021 21:34:48 UT - - - comedy - laugh farce humour satire irony standup funny humor - - Thu, 18 Feb 2021 21:34:48 UT - - - health - medical burnout cannabis medicine run treatment EmotionalFirstAid safeabortion4all maryjane autistic neurodivergent health motion actuallyautistic meds marijuana mentalhealth healthy neurodiverse asperger autismus - - Thu, 18 Feb 2021 21:34:48 UT - - - facts - funfact didyouknow lifehack - - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT ai - machinelearning + macos machinelearning openai EthicsInAI - Thu, 18 Feb 2021 21:34:48 UT - - - seasons - spring autumn abolishice winter ice santa summer onlyoffice icedipping solstice wintersolstice summerRolls pin - - Thu, 18 Feb 2021 21:34:48 UT - - - commons - opennmsgroup open openocd opennms opennmt - - Thu, 18 Feb 2021 21:34:48 UT - - - philosophy - minimalism maximalist maximalism philosophy stoic postmodernism minimalist - - Thu, 18 Feb 2021 21:34:48 UT - - - introductions - newhere firsttoot recommends Introduction Introductions introduction intro introductions - - Thu, 18 Feb 2021 21:34:48 UT - - - education - SchoolForAfrica education teach tutorial - - Thu, 18 Feb 2021 21:34:48 UT - - - scifi - startrekdiscovery startrek discover SoftwareJob starwars war babylon5 - - Thu, 18 Feb 2021 21:34:48 UT - - - privacy - SearchHistory privacyaware dataprivacyday profiling surveillancestate Privacy privacypolicy makeprivacystick surveillancetech privacymatters policy whatsappprivacypolicy surveillance dataprivacy privacywashing privacy DataPrivacyDay2020 WhatsappPrivacy - - Thu, 18 Feb 2021 21:34:48 UT - - - bots - bot Militanzverbot botanists militanzverbot humanrobotinteraction - - Thu, 18 Feb 2021 21:34:48 UT - - - microcontroller - e microcontroller troll arduinoide arduino - - Thu, 18 Feb 2021 21:34:48 UT - - - religion - neopagan pagan catholic paganism genesis - - Thu, 18 Feb 2021 21:34:48 UT - - - help - mastohelp helpful help - - Thu, 18 Feb 2021 21:34:48 UT - - - obituaries - tripadvisor rip - - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT licenses - tootle copyright creative common gpl copyrightlaw creativecommons + commongoods creativecommonsrocks voice agplv3 tootle commonvoice CommunitySource place copyright commonspoly creative netcommons common gpl plugplugplug copyrightlaw commonplacebook EthicalSource questioncopyright tragedyofthecommons cc0 creativecommons commongood cc creativetoot - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - accessibility - a11y + political + copservation housekeeping gan ram progress slaughterhouse rog cops houseless brogue joerogan theteahouse bibliogram house hydrogen straming theGreenhouse teahouse progressivehouse techhouse clubhouse yayagram PDXdefendthehouseless pdxhouseless EnergyFlowDiagrams pr progress_note deephouse roguelike linguisticProgramming gancio - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - photography - landscapephotography landscapeart darktable landscape + organisations + foundation scpfoundation scp - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT + + + fashion + brasil fashionistas fashionesta bras fashionista fashion punkwear earrings socks patches feditats zebras + + Tue, 13 Jul 2021 08:43:43 UT + + + welfare + CreditReporting universalcredit welfare socialwelfare credit + + Tue, 13 Jul 2021 08:43:43 UT + + + scotland + lan atlanta northumberland glasgow highlands edinburgh loch + + Tue, 13 Jul 2021 08:43:43 UT + + + antisocial + stalking cyberstalking + + Tue, 13 Jul 2021 08:43:43 UT + + + comedy + laugh farce humour swisshumor satire irony standup funny humor punishment pun + + Tue, 13 Jul 2021 08:43:43 UT + + + obituaries + ueberwachung siberia tripadvisor rip gretathunberg JavaScriptSucks ratgeber obit ecmascript keyenberg raspberripi CyberSecurity döppersberg cybergrooming Gudensberg überblick obituaries ber civilliberties rubber + + Tue, 13 Jul 2021 08:43:43 UT + + + introductions + reintroductions newhere firsttoot recommends stt Introduction Introductions reintroduction introductons introduction intro introductions + + Tue, 13 Jul 2021 08:43:43 UT + + + geography + theCartographer + + Tue, 13 Jul 2021 08:43:43 UT + + + education + SchoolForAfrica PhDstudent mitbewohnerin techlearningcollective oh languages student teaching tutorials education academics mit academia teach Lebensmittelfarbstoff elearning learning languagelearning tutorial mitkatzundkegel ec language deeplearning collect cad mitteleuropa + + Tue, 13 Jul 2021 08:43:43 UT + + + scifi + startrekdiscovery startrek discover SoftwareJob LegDichNieMitSchwarzenKatzenAn starwars ds9 discovery SchwarzeFrauen babylon NGIForward war babylon5 + + Tue, 13 Jul 2021 08:43:43 UT + + + microcontroller + e kontrollieren microcontroller trolls Chatkontrolle troll arduinoide arduino + + Tue, 13 Jul 2021 08:43:43 UT + + + design + userfriendly friendly rf + + Tue, 13 Jul 2021 08:43:43 UT + + + help + mastohelp helpwanted lpf helpful helpMeOutHere help + + Tue, 13 Jul 2021 08:43:43 UT + + + automotive + volkswagen + + Tue, 13 Jul 2021 08:43:43 UT + + + fantasy + discworld godzilla + + Tue, 13 Jul 2021 08:43:43 UT + + + entertainment + CircusInPlace legallyblonde watching Thundercat makingof entertainment me un nowwatching mandalorian themandalorian nt + + Tue, 13 Jul 2021 08:43:43 UT election Rainbowvote voted vote - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT moderation fedblock - Thu, 18 Feb 2021 21:34:48 UT - - - entertainment - legallyblonde watching Thundercat makingof entertainment me un nowwatching themandalorian nt - - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT languages lojban gaelic - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT environment - s clim climatechaos + s crisisclimatica clim climatechaos climateadaptation - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - #music - trance + organization + conceptmap mindmapping mapping mindmap notetoself pi - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - garbage - QAnonAnonymous ChanCulture + technology + AvatarResearch tools LowtechSolutions literatools - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - other - itscomplicated misc ass OCUPACAOCARLOSMARIGHELLA + microcontrollers + esp32c3 microcontrollers esp8266 esp32 - Thu, 18 Feb 2021 21:34:48 UT - - - scotland - glasgow highlands edinburgh loch - - Thu, 18 Feb 2021 21:34:48 UT - - - transport - deutsch deutschebahn - - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT agriculture farmers - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - #software - flatpak + organisation + InstitutionalMemory - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - microcontrollers - esp8266 esp32 + skills + gardening baking - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT france Macronavirus - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT - travel - travel taxi airtravel + memes + tired - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT + + + sailing + theBoatyard + + Tue, 13 Jul 2021 08:43:43 UT + + + parenting + dadposting + + Tue, 13 Jul 2021 08:43:43 UT + + + jewelry + bracelet + + Tue, 13 Jul 2021 08:43:43 UT architecture concrete - Thu, 18 Feb 2021 21:34:48 UT + Tue, 13 Jul 2021 08:43:43 UT diff --git a/defaultthemes.txt b/defaultthemes.txt new file mode 100644 index 000000000..b082f241c --- /dev/null +++ b/defaultthemes.txt @@ -0,0 +1,16 @@ +Blue +Debian +Default +Hacker +Henge +Indymediaclassic +Indymediamodern +Lcd +Light +Night +Pixel +Purple +Rc3 +Solidaric +Starlight +Zen diff --git a/defaultwelcome/final_ar.md b/defaultwelcome/final_ar.md new file mode 100644 index 000000000..8b0ab6f2e --- /dev/null +++ b/defaultwelcome/final_ar.md @@ -0,0 +1,18 @@ +### تهانينا! +أنت الآن جاهز لبدء استخدام Epicyon. هذه مساحة اجتماعية خاضعة للإشراف ، لذا يرجى التأكد من الالتزام بـ [شروط الخدمة](/terms) الخاصة بنا ، واستمتع. + +#### تلميحات +استخدم رمز **المكبر** 🔍 للبحث عن مقابض الكون المشترك ومتابعة الأشخاص. + +يؤدي تحديد **الشعار في الجزء العلوي** من الشاشة إلى التبديل بين عرض المخطط الزمني وملف التعريف الخاص بك. + +لن يتم تحديث الشاشة تلقائيًا عند وصول المنشورات ، لذا استخدم **F5** أو زر البريد الوارد للتحديث. + +#### طقوس المرور +تدربك ثقافة الشركة على الرغبة في الحصول على أكبر عدد من المتابعين والإعجابات - للبحث عن الشهرة الشخصية والتفاعلات السطحية التي تثير الغضب لجذب الانتباه. + +لذلك إذا كنت قادمًا من تلك الثقافة ، فيرجى العلم أن هذا نوع مختلف من النظام مع مجموعة مختلفة جدًا من التوقعات. + +ليس من الضروري وجود الكثير من المتابعين ، وغالبًا ما يكون غير مرغوب فيه. قد يحظرك الناس ، ولا بأس بذلك. لا أحد لديه الحق في جمهور. إذا قام شخص ما بحظرك فأنت لا تخضع للرقابة. يمارس الناس فقط حريتهم في الارتباط بمن يرغبون فيه. + +من المتوقع أن تكون معايير السلوك الشخصي أفضل مما هي عليه في أنظمة الشركات. سلوكك له أيضًا عواقب على سمعة هذه الحالة. إذا كنت تتصرف بطريقة متهورة تتعارض مع شروط الخدمة ، فقد يتم تعليق حسابك أو إزالته. diff --git a/defaultwelcome/final_ca.md b/defaultwelcome/final_ca.md new file mode 100644 index 000000000..5731d031c --- /dev/null +++ b/defaultwelcome/final_ca.md @@ -0,0 +1,18 @@ +### Enhorabona! +Ja esteu a punt per començar a utilitzar Epicyon. Aquest és un espai social moderat, així que assegureu-vos de complir les nostres [condicions del servei](/terms) i divertir-vos. + +#### Consells +Utilitzeu la icona de **lupa** 🔍 per cercar manetes fedivers i seguir les persones. + +Si seleccioneu el **bàner a la part superior** de la pantalla es canvia entre la visualització de la línia de temps i el vostre perfil. + +La pantalla no s'actualitzarà automàticament quan arribin les publicacions, així que utilitzeu **F5** o el botó **Safata d'entrada** per actualitzar. + +#### Ritu de pas +La cultura corporativa us capacita per desitjar el màxim nombre de seguidors i gustos: per buscar fama personal i interaccions poc profundes i indignants per cridar l'atenció. + +Per tant, si proveniu d’aquesta cultura, tingueu en compte que es tracta d’un tipus de sistema diferent amb un conjunt d’expectatives molt diferents. + +No és necessari tenir molts seguidors i sovint no és desitjable. És possible que la gent us bloquegi i això està bé. Ningú no té dret a un públic. Si algú et bloqueja, no et censuraran. La gent només exerceix la seva llibertat per associar-se amb qui vulgui. + +S'espera que els estàndards de comportament personal siguin millors que en els sistemes corporatius. El vostre comportament també té conseqüències per a la reputació d'aquesta instància. Si us comporteu de manera desmesurada que va en contra de les condicions del servei, el vostre compte es pot suspendre o eliminar. diff --git a/defaultwelcome/final_cy.md b/defaultwelcome/final_cy.md new file mode 100644 index 000000000..e3b866af3 --- /dev/null +++ b/defaultwelcome/final_cy.md @@ -0,0 +1,18 @@ +### Llongyfarchiadau! +Rydych nawr yn barod i ddechrau defnyddio Epicyon. Mae hwn yn ofod cymdeithasol wedi'i gymedroli, felly gwnewch yn siŵr eich bod yn cadw at ein [telerau gwasanaeth](/terms), a chael hwyl. + +#### Awgrymiadau +Defnyddiwch yr eicon **chwyddwydr** 🔍 i chwilio am ddolenni bwydo a dilyn pobl. + +Mae dewis y faner **ar frig** y sgrin yn newid rhwng yr olygfa llinell amser a'ch proffil. + +Ni fydd y sgrin yn adnewyddu'n awtomatig pan fydd pyst yn cyrraedd, felly defnyddiwch **F5** neu'r botwm **Mewnflwch** i adnewyddu. + +#### Defod y Tocyn +Mae diwylliant corfforaethol yn eich hyfforddi i fod eisiau'r nifer uchaf o ddilynwyr a hoff bethau - i geisio enwogrwydd personol a rhyngweithio bas, sy'n achosi dicter, i fachu sylw. + +Felly os ydych chi'n dod o'r diwylliant hwnnw, byddwch yn ymwybodol bod hon yn fath wahanol o system gyda set wahanol iawn o ddisgwyliadau. + +Nid oes angen cael llawer o ddilynwyr, ac yn aml mae'n annymunol. Efallai y bydd pobl yn eich rhwystro chi, ac mae hynny'n iawn. Nid oes gan neb hawl i gynulleidfa. Os bydd rhywun yn eich blocio yna nid ydych chi'n cael eich sensro. Mae pobl yn arfer eu rhyddid i gysylltu â phwy bynnag maen nhw'n dymuno. + +Disgwylir i safonau ymddygiad personol fod yn well nag yn y systemau corfforaethol. Mae gan eich ymddygiad ganlyniadau i enw da'r achos hwn hefyd. Os ydych chi'n ymddwyn mewn modd anystyriol sy'n mynd yn groes i'r telerau gwasanaeth yna gellir atal neu ddileu eich cyfrif. diff --git a/defaultwelcome/final_en.md b/defaultwelcome/final_en.md new file mode 100644 index 000000000..c5c8a498e --- /dev/null +++ b/defaultwelcome/final_en.md @@ -0,0 +1,18 @@ +### Congratulations! +You are now ready to begin using Epicyon. This is a moderated social space, so please make sure to abide by our [terms of service](/terms), and have fun. + +#### Hints +Use the **magnifier** icon 🔍 to search for fediverse handles and follow people. + +Selecting the **banner at the top** of the screen switches between timeline view and your profile. + +The screen will not automatically refresh when posts arrive, so use **F5** or the **Inbox** button to refresh. + +#### Rite of Passage +Corporate culture trains you to want the maximum number of followers and likes - to seek personal fame and shallow, outrage-inducing interactions to grab attention. + +So if you are coming from that culture, please be aware that this is a different type of system with a very different set of expectations. + +Having a lot of followers is not necessary, and often it's undesirable. People may block you, and that's ok. Nobody has a right to an audience. If someone blocks you then you're not being censored. People are just exercising their freedom to associate with whoever they wish. + +Standards of personal behavior are expected to be better than in the corporate systems. Your behavior also has consequences for the reputation of this instance. If you behave in an inconsiderate manner which goes against the terms of service then your account may be suspended or removed. diff --git a/defaultwelcome/final_es.md b/defaultwelcome/final_es.md new file mode 100644 index 000000000..b6408449d --- /dev/null +++ b/defaultwelcome/final_es.md @@ -0,0 +1,18 @@ +### ¡Felicidades! +Ahora está listo para comenzar a usar Epicyon. Este es un espacio social moderado, así que asegúrese de cumplir con nuestros [términos de servicio](/terms) y diviértase. + +#### Sugerencias +Utilice el icono de **lupa** 🔍 para buscar identificadores de fediverse y seguir a las personas. + +Al seleccionar el **banner en la parte superior** de la pantalla, se cambia entre la vista de línea de tiempo y su perfil. + +La pantalla no se actualizará automáticamente cuando lleguen las publicaciones, así que use **F5** o el botón **Bandeja de entrada** para actualizar. + +#### Rito de paso +La cultura corporativa te entrena para querer el máximo número de seguidores y me gusta, para buscar fama personal e interacciones superficiales e indignantes para llamar la atención. + +Entonces, si viene de esa cultura, tenga en cuenta que este es un tipo diferente de sistema con un conjunto de expectativas muy diferente. + +No es necesario tener muchos seguidores y, a menudo, no es deseable. Es posible que la gente te bloquee, y eso está bien. Nadie tiene derecho a una audiencia. Si alguien te bloquea, no estás siendo censurado. La gente simplemente está ejerciendo su libertad para asociarse con quien quiera. + +Se espera que los estándares de comportamiento personal sean mejores que los de los sistemas corporativos. Su comportamiento también tiene consecuencias para la reputación de esta instancia. Si se comporta de manera desconsiderada que va en contra de los términos de servicio, su cuenta puede ser suspendida o eliminada. diff --git a/defaultwelcome/final_fr.md b/defaultwelcome/final_fr.md new file mode 100644 index 000000000..fb6c5e768 --- /dev/null +++ b/defaultwelcome/final_fr.md @@ -0,0 +1,18 @@ +### Toutes nos félicitations! +Vous êtes maintenant prêt à commencer à utiliser Epicyon. Il s'agit d'un espace social modéré, alors assurez-vous de respecter nos [conditions d'utilisation](/terms) et amusez-vous. + +#### Conseils +Utilisez l'icône **loupe** 🔍 pour rechercher des poignées fediverse et suivre les gens. + +La sélection de la **bannière en haut** de l'écran bascule entre la vue chronologique et votre profil. + +L'écran ne s'actualisera pas automatiquement à l'arrivée des messages, utilisez donc **F5** ou le bouton **Boîte de réception** pour actualiser. + +#### Rite de passage +La culture d'entreprise vous entraîne à vouloir le maximum de followers et de likes - à rechercher une renommée personnelle et des interactions superficielles et indignées pour attirer l'attention. + +Donc, si vous venez de cette culture, sachez qu'il s'agit d'un type de système différent avec un ensemble d'attentes très différent. + +Avoir beaucoup d'adeptes n'est pas nécessaire et souvent indésirable. Les gens peuvent vous bloquer, et ce n'est pas grave. Personne n'a droit à une audience. Si quelqu'un vous bloque, vous n'êtes pas censuré. Les gens exercent simplement leur liberté de s'associer avec qui ils veulent. + +On s'attend à ce que les normes de comportement personnel soient meilleures que dans les systèmes d'entreprise. Votre comportement a également des conséquences sur la réputation de cette instance. Si vous vous comportez d'une manière inconsidérée qui va à l'encontre des conditions d'utilisation, votre compte peut être suspendu ou supprimé. diff --git a/defaultwelcome/final_ga.md b/defaultwelcome/final_ga.md new file mode 100644 index 000000000..4f7995df7 --- /dev/null +++ b/defaultwelcome/final_ga.md @@ -0,0 +1,18 @@ +### Comhghairdeas! +Tá tú réidh anois chun Epicyon a úsáid. Is spás sóisialta measartha é seo, mar sin déan cinnte cloí lenár [dtéarmaí seirbhíse](/terms), agus spraoi a bheith agat. + +#### Leideanna +Úsáid an deilbhín **formhéadaitheoir** chun cuardach a dhéanamh ar láimhseálacha beathaithe agus lean daoine. + +Ag roghnú an bhratach **ag barr** na lasca scáileáin idir amharc amlíne agus do phróifíl. + +Ní dhéanfaidh an scáileán athnuachan go huathoibríoch nuair a thiocfaidh na poist, mar sin bain úsáid as **F5** nó an cnaipe **Bosca Isteach** chun athnuachan a dhéanamh. + +#### Deasghnáth an Phasáiste +Cuireann an cultúr corparáideach oiliúint ort go dteastaíonn uait an líon is mó leantóirí agus a leithéidí - clú agus cáil phearsanta agus idirghníomhaíochtaí éadomhain, spreagtha a lorg chun aird a tharraingt. + +Mar sin má tá tú ag teacht ón gcultúr sin, bí ar an eolas gur cineál difriúil córais é seo le tacar ionchais an-difriúil. + +Ní gá go leor leantóirí a bheith agat, agus go minic bíonn sé neamh-inmhianaithe. Féadfaidh daoine bac a chur ort, agus tá sé sin ceart go leor. Níl sé de cheart ag aon duine lucht féachana a fháil. Má chuireann duine bac ort níl cinsireacht á dhéanamh ort. Níl ach a saoirse á fheidhmiú ag daoine chun caidreamh a dhéanamh le cibé duine is mian leo. + +Meastar go mbeidh caighdeáin iompraíochta pearsanta níos fearr ná sna córais chorparáideacha. Tá iarmhairtí ag d’iompar freisin ar cháil an cháis seo. Má iompraíonn tú ar bhealach neamhfhreagrach a théann i gcoinne na dtéarmaí seirbhíse ansin féadfar do chuntas a chur ar fionraí nó a bhaint. diff --git a/defaultwelcome/final_hi.md b/defaultwelcome/final_hi.md new file mode 100644 index 000000000..81a156b72 --- /dev/null +++ b/defaultwelcome/final_hi.md @@ -0,0 +1,18 @@ +### बधाई हो! +अब आप एपिसकॉन का उपयोग शुरू करने के लिए तैयार हैं। यह एक मध्यम सामाजिक स्थान है, इसलिए कृपया हमारी [सेवा की शर्तों](/terms) का पालन करना सुनिश्चित करें, और मज़े करें। + +#### संकेत +फ़ेडरिवर्स हैंडल की खोज करने और लोगों का अनुसरण करने के लिए **आवर्धक आइकन** का उपयोग करें। + +समय दृश्य और आपकी प्रोफ़ाइल के बीच स्क्रीन स्विच के शीर्ष **पर स्थित** बैनर का चयन करना। + +पोस्ट आने पर स्क्रीन अपने आप रिफ्रेश नहीं होगी, इसलिए रीफ्रेश करने के लिए **F5** या **इनबॉक्स** बटन का उपयोग करें। + +#### यादगार घटना +कॉरपोरेट कल्चर आपको अधिक से अधिक संख्या में अनुयायियों और पसंदों को प्राप्त करने के लिए प्रशिक्षित करता है - ध्यान आकर्षित करने के लिए व्यक्तिगत प्रसिद्धि और उथले, नाराजगी-उत्प्रेरण बातचीत। + +इसलिए यदि आप उस संस्कृति से आ रहे हैं, तो कृपया ध्यान रखें कि यह एक अलग प्रकार की प्रणाली है जिसमें बहुत अलग अपेक्षाएं हैं। + +बहुत सारे अनुयायी होना आवश्यक नहीं है, और अक्सर यह अवांछनीय है। लोग आपको ब्लॉक कर सकते हैं, और यह ठीक है। किसी को भी एक दर्शक का अधिकार नहीं है। अगर कोई आपको ब्लॉक करता है तो आपको सेंसर नहीं किया जा रहा है। लोग बस अपनी स्वतंत्रता का प्रयोग कर रहे हैं कि वे जो चाहें करें। + +व्यक्तिगत व्यवहार के मानक कॉर्पोरेट सिस्टम की तुलना में बेहतर होने की उम्मीद है। इस उदाहरण की प्रतिष्ठा के लिए आपके व्यवहार के परिणाम भी हैं। यदि आप एक असंगत तरीके से व्यवहार करते हैं जो सेवा की शर्तों के खिलाफ जाता है तो आपका खाता निलंबित या हटाया जा सकता है। diff --git a/defaultwelcome/final_it.md b/defaultwelcome/final_it.md new file mode 100644 index 000000000..4b3eaab34 --- /dev/null +++ b/defaultwelcome/final_it.md @@ -0,0 +1,18 @@ +### Congratulazioni! +Ora sei pronto per iniziare a utilizzare Epicyon. Questo è uno spazio social moderato, quindi assicurati di rispettare i nostri [termini di servizio](/terms) e divertiti. + +#### Suggerimenti +Usa l'icona **lente d'ingrandimento** 🔍 per cercare gli handle di fediverse e seguire le persone. + +Selezionando il **banner nella parte superiore** dello schermo si passa dalla visualizzazione della sequenza temporale al tuo profilo. + +La schermata non si aggiornerà automaticamente all'arrivo dei post, quindi utilizza **F5** o il pulsante **Posta in arrivo** per aggiornare. + +#### Rito di passaggio +La cultura aziendale ti insegna a desiderare il numero massimo di follower e Mi piace, a cercare la fama personale e interazioni superficiali e indignate per attirare l'attenzione. + +Quindi, se vieni da quella cultura, tieni presente che questo è un tipo diverso di sistema con un insieme di aspettative molto diverso. + +Avere molti follower non è necessario e spesso è indesiderabile. Le persone potrebbero bloccarti, e va bene. Nessuno ha diritto a un pubblico. Se qualcuno ti blocca, non verrai censurato. Le persone stanno solo esercitando la loro libertà di associarsi con chi desiderano. + +Gli standard di comportamento personale dovrebbero essere migliori rispetto ai sistemi aziendali. Il tuo comportamento ha anche conseguenze sulla reputazione di questa istanza. Se ti comporti in modo sconsiderato che va contro i termini di servizio, il tuo account potrebbe essere sospeso o rimosso. diff --git a/defaultwelcome/final_ja.md b/defaultwelcome/final_ja.md new file mode 100644 index 000000000..8183dd287 --- /dev/null +++ b/defaultwelcome/final_ja.md @@ -0,0 +1,18 @@ +### おめでとう! +これで、Epicyonの使用を開始する準備が整いました。 適度な社交空間ですので、必ず [利用規約](/terms) を遵守して楽しんでください。 + +#### ヒント +**拡大鏡** アイコン🔍を使用して、fediverseハンドルを検索し、人々をフォローします。 + +画面の上部にある **バナー** を選択すると、タイムラインビューとプロファイルが切り替わります。 + +投稿が到着しても画面は自動的に更新されないため、 **F5** または **受信トレイ** ボタンを使用して更新してください。 + +#### 通過儀礼 +企業文化は、最大数のフォロワーや好きな人を求め、個人的な名声と浅い、怒りを誘発する相互作用を求めて注目を集めるように訓練します。 + +したがって、その文化から来ている場合、これは非常に異なる一連の期待を持つ異なるタイプのシステムであることに注意してください。 + +多くのフォロワーを持つ必要はなく、多くの場合、それは望ましくありません。 人々があなたをブロックするかもしれません、そしてそれは大丈夫です。 誰も聴衆に対する権利を持っていません。 誰かがあなたをブロックした場合、あなたは検閲されていません。 人々は、彼らが望む誰とでも交際する自由を行使しているだけです。 + +個人の行動基準は、企業システムよりも優れていると期待されています。 あなたの行動は、このインスタンスの評判にも影響を及ぼします。 利用規約に違反する軽率な行動をとった場合、アカウントが停止または削除される可能性があります。 diff --git a/defaultwelcome/final_ku.md b/defaultwelcome/final_ku.md new file mode 100644 index 000000000..5a5914dd2 --- /dev/null +++ b/defaultwelcome/final_ku.md @@ -0,0 +1,18 @@ +### Pîrozbahî! +Hûn niha amade ne ku dest bi karanîna Epicyon bikin. Ev cîhek civakî ya nermkerî ye, ji kerema xwe ji kerema xwe pabendî [mercên karûbarê me](/terms) bin, û kêf bikin. + +#### intsîretan +**îkona** ziravker 🔍 bikar bînin da ku li destanên federatê bigerin û mirovan bişopînin. + +Hilbijartina **pankarta li jor** a switches-ekranê di navbera dîtina demjimêr û profîla we de. + +Dema ku şande tên dîmender dê bixweber nûve nebe, ji ber vê yekê **F5** an bişkoja **Inbox** ji bo nûvekirinê bikar bînin. + +#### Rêûresma Derbasbûnê +Çanda pargîdaniyê we perwerde dike ku hûn jimara herî zêde şopîner û hezên xwe bixwazin - li navdariyek kesane û têkiliyên kûr, hêrs-lêgerîn digerin da ku balê bikişînin. + +Ji ber vê yekê heke hûn ji wê çandê têne, ji kerema xwe hay ji xwe hebin ku ev pergalek celebek cuda ye ku bi bendewariyek pir cûda ye. + +Hebûna gelek şagirtan ne hewce ye, û pir caran ew nexwaze. Mirov dikare we bloke bike, û ew baş e. Mafê kesî tune ku guhdar bike. Ger kesek we bloke bike wê hingê hûn nayên sansur kirin. Mirov tenê azadiya xwe ya ku bi kî bixwaze re têkildar dibe bikar tîne. + +Tê payîn ku standardên tevgera kesane ji pergalên pargîdaniyê çêtir in. Reftara we ji bo navûdengê vê nimûneyê jî encam dide. Heke hûn bi rengek bêhesib tevdigerin ku li dijî şertên karûbarê ye wê hingê dibe ku hesabê we were sekinandin an rakirin. diff --git a/defaultwelcome/final_oc.md b/defaultwelcome/final_oc.md new file mode 100644 index 000000000..49a17693c --- /dev/null +++ b/defaultwelcome/final_oc.md @@ -0,0 +1,18 @@ +# Congratulations! +You are now ready to begin using Epicyon. This is a moderated social space, so please make sure to abide by our [terms of service](/terms), and have fun. + +### Hints +Use the **magnifier** icon 🔍 to search for fediverse handles and follow people. + +Selecting the **banner at the top** of the screen switches between timeline view and your profile. + +The screen will not automatically refresh when posts arrive, so use **F5** or the **Inbox** button to refresh. + +#### Rite of Passage +Corporate culture trains you to want the maximum number of followers and likes - to seek personal fame and shallow, outrage-inducing interactions to grab attention. + +So if you are coming from that culture, please be aware that this is a different type of system with a very different set of expectations. + +Having a lot of followers is not necessary, and often it's undesirable. People may block you, and that's ok. Nobody has a right to an audience. If someone blocks you then you're not being censored. People are just exercising their freedom to associate with whoever they wish. + +Standards of personal behavior are expected to be better than in the corporate systems. Your behavior also has consequences for the reputation of this instance. If you behave in an inconsiderate manner which goes against the terms of service then your account may be suspended or removed. diff --git a/defaultwelcome/final_pt.md b/defaultwelcome/final_pt.md new file mode 100644 index 000000000..b96b0a4b3 --- /dev/null +++ b/defaultwelcome/final_pt.md @@ -0,0 +1,18 @@ +# Parabéns! +Agora você está pronto para começar a usar o Epicyon. Este é um espaço social moderado, portanto, certifique-se de seguir nossos [termos de serviço](/terms) e divirta-se. + +### Dicas +Use o ícone de **lupa** 🔍 para pesquisar as alças do fediverse e seguir pessoas. + +Selecionar o **banner na parte superior** da tela alterna entre a visualização da linha do tempo e seu perfil. + +A tela não será atualizada automaticamente quando as postagens chegarem, então use **F5** ou o botão **Caixa de entrada** para atualizar. + +#### Rito de passagem +A cultura corporativa treina você a querer o número máximo de seguidores e curtidas - a buscar fama pessoal e interações superficiais que induzem à indignação para chamar a atenção. + +Portanto, se você vem dessa cultura, saiba que esse é um tipo diferente de sistema, com um conjunto de expectativas muito diferente. + +Não é necessário ter muitos seguidores e, muitas vezes, é indesejável. As pessoas podem bloquear você, e tudo bem. Ninguém tem direito a audiência. Se alguém bloqueia você, você não está sendo censurado. As pessoas estão apenas exercendo sua liberdade de se associar com quem quiserem. + +Espera-se que os padrões de comportamento pessoal sejam melhores do que os sistemas corporativos. Seu comportamento também tem consequências para a reputação desta instância. Se você se comportar de maneira imprudente que vá contra os termos de serviço, sua conta poderá ser suspensa ou removida. diff --git a/defaultwelcome/final_ru.md b/defaultwelcome/final_ru.md new file mode 100644 index 000000000..993edc44b --- /dev/null +++ b/defaultwelcome/final_ru.md @@ -0,0 +1,18 @@ +### Поздравляю! +Теперь вы готовы начать использовать Epicyon. Это модерируемое социальное пространство, поэтому, пожалуйста, соблюдайте наши [условия обслуживания](/terms) и получайте удовольствие. + +#### Подсказки +Используйте значок **лупы** 🔍, чтобы искать нужные метки и следить за людьми. + +При выборе **баннера вверху** экрана выполняется переключение между представлением временной шкалы и вашим профилем. + +Экран не обновляется автоматически при поступлении сообщений, поэтому используйте **F5** или кнопку **Входящие** для обновления. + +#### Обряд посвящения +Корпоративная культура учит вас стремиться к максимальному количеству подписчиков и лайков - стремиться к личной славе и поверхностным, вызывающим возмущение взаимодействиям, чтобы привлечь внимание. + +Так что, если вы происходите из этой культуры, имейте в виду, что это другой тип системы с совершенно другим набором ожиданий. + +Не обязательно иметь много подписчиков, а зачастую и нежелательно. Люди могут заблокировать вас, и это нормально. Никто не имеет права на аудиенцию. Если кто-то вас блокирует, значит, вы не подвергаетесь цензуре. Люди просто пользуются своей свободой общаться с кем хотят. + +Ожидается, что стандарты личного поведения будут лучше, чем в корпоративных системах. Ваше поведение также влияет на репутацию этого экземпляра. Если вы ведете себя невнимательно, что противоречит условиям обслуживания, ваша учетная запись может быть приостановлена или удалена. diff --git a/defaultwelcome/final_sw.md b/defaultwelcome/final_sw.md new file mode 100644 index 000000000..487258418 --- /dev/null +++ b/defaultwelcome/final_sw.md @@ -0,0 +1,18 @@ +### Hongera! +Sasa uko tayari kuanza kutumia epicyon. Hii ni nafasi ya kijamii iliyopangwa, hivyo tafadhali hakikisha kuzingatia [masharti yetu ya huduma](/terms), na ufurahi. + +#### Vidokezo +Tumia **icon ya magnifier** 🔍 kutafuta vitu vinavyohusika na kufuata watu. + +Kuchagua **bendera juu** ya swichi screen kati ya mtazamo wa timeline na wasifu wako. + +Screen haitafurahisha moja kwa moja wakati machapisho yanapofika, kwa hiyo tumia **F5** au kifungo cha **Kikasha** ili upate upya. + +#### Ibada ya kifungu +Utamaduni wa utamaduni unawafundisha idadi kubwa ya wafuasi na kupenda - kutafuta umaarufu wa kibinafsi na uingiliano usiojulikana, uingizaji wa chuki ili kunyakua. + +Kwa hiyo ikiwa unakuja kutoka kwa utamaduni huo, tafadhali tahadhari kuwa hii ni aina tofauti ya mfumo na seti tofauti ya matarajio. + +Kuwa na wafuasi wengi sio lazima, na mara nyingi haifai. Watu wanaweza kukuzuia, na hiyo ni sawa. Hakuna mtu anaye haki ya watazamaji. Ikiwa mtu anakuzuia basi huna kuzingatiwa. Watu wanatumia tu uhuru wao wa kushirikiana na yeyote anayetaka. + +Viwango vya tabia ya kibinafsi vinatarajiwa kuwa bora kuliko katika mifumo ya ushirika. Tabia yako pia ina matokeo ya sifa ya mfano huu. Ikiwa unafanya kwa njia isiyo ya kawaida ambayo inapingana na Masharti ya Huduma basi akaunti yako inaweza kusimamishwa au kuondolewa. diff --git a/defaultwelcome/final_zh.md b/defaultwelcome/final_zh.md new file mode 100644 index 000000000..542ce6a67 --- /dev/null +++ b/defaultwelcome/final_zh.md @@ -0,0 +1,18 @@ +### 恭喜你! +您现在可以开始使用Epicyon。 这是一个温和的社交空间,因此请务必遵守我们的[服务条款](/terms),并从中获得乐趣。 + +####提示 +使用放大镜图标search搜索fed性的手柄并关注他人。 + +选择屏幕顶部的横幅广告可在时间轴视图和个人资料之间切换。 + +帖子到达时,屏幕不会自动刷新,因此请使用F5或“收件箱”按钮刷新。 + +#### 通行礼 +企业文化训练您想要最大数量的追随者和喜欢的人-寻求个人名望和肤浅,激怒的互动来吸引注意力。 + +因此,如果您来自这种文化,请注意,这是另一种类型的系统,具有不同的期望值。 + +拥有大量的追随者不是必需的,而且通常是不可取的。 人们可能会阻止您,没关系。 没有人有听众的权利。 如果有人阻止了您,那么您将不会受到审查。 人们只是在行使与任何希望的人交往的自由。 + +个人行为标准有望比公司系统更好。 您的行为也会对该实例的声誉产生影响。 如果您的行为举止粗鲁,违反了服务条款,那么您的帐户可能会被暂停或删除。 diff --git a/defaultwelcome/help_dm_ar.md b/defaultwelcome/help_dm_ar.md new file mode 100644 index 000000000..ab04f79d0 --- /dev/null +++ b/defaultwelcome/help_dm_ar.md @@ -0,0 +1,3 @@ +ستظهر الرسائل المباشرة هنا ، كجدول زمني زمني. + +لتجنب البريد العشوائي وتحسين الأمان ، ستتمكن افتراضيًا فقط من تلقي الرسائل المباشرة من الأشخاص الذين تتابعهم. يمكنك إيقاف تشغيل هذا ضمن إعدادات ملف التعريف الخاص بك إذا كنت بحاجة إلى ذلك ، عن طريق تحديد الشعار العلوي ثم أيقونة التحرير. diff --git a/defaultwelcome/help_dm_ca.md b/defaultwelcome/help_dm_ca.md new file mode 100644 index 000000000..2c0386a8b --- /dev/null +++ b/defaultwelcome/help_dm_ca.md @@ -0,0 +1,3 @@ +Els missatges directes apareixeran aquí com a cronologia cronològica. + +Per evitar el correu brossa i millorar la seguretat, de manera predeterminada només podreu rebre missatges directes *de persones que seguiu*. Podeu desactivar aquesta opció a la configuració del vostre perfil, si cal, seleccionant el **bàner superior** i, a continuació, la icona **edita**. diff --git a/defaultwelcome/help_dm_cy.md b/defaultwelcome/help_dm_cy.md new file mode 100644 index 000000000..ca95d8a5a --- /dev/null +++ b/defaultwelcome/help_dm_cy.md @@ -0,0 +1,3 @@ +Bydd negeseuon uniongyrchol yn ymddangos yma, fel llinell amser gronolegol. + +Er mwyn osgoi sbam a gwella diogelwch, yn ddiofyn dim ond gan bobl rydych chi'n eu dilyn y byddwch chi'n gallu derbyn negeseuon uniongyrchol. Gallwch chi ddiffodd hwn o fewn eich gosodiadau proffil os oes angen, trwy ddewis y faner **uchaf** ac yna'r eicon **golygu**. diff --git a/defaultwelcome/help_dm_de.md b/defaultwelcome/help_dm_de.md new file mode 100644 index 000000000..ea9ec03b9 --- /dev/null +++ b/defaultwelcome/help_dm_de.md @@ -0,0 +1,3 @@ +Direktnachrichten werden hier als chronologische Zeitleiste angezeigt. + +Um Spam zu vermeiden und die Sicherheit zu verbessern, können Sie standardmäßig nur Direktnachrichten *von Personen empfangen, denen Sie folgen*. Sie können dies bei Bedarf in Ihren Profileinstellungen deaktivieren, indem Sie das oberste **Banner** und dann das **Bearbeitungssymbol** auswählen. diff --git a/defaultwelcome/help_dm_en.md b/defaultwelcome/help_dm_en.md new file mode 100644 index 000000000..2f58489bf --- /dev/null +++ b/defaultwelcome/help_dm_en.md @@ -0,0 +1,3 @@ +Direct messages will appear here, as a chronological timeline. + +To avoid spam and improve security, by default you will only be able to receive direct messages *from people that you're following*. You can turn this off within your profile settings if you need to, by selecting the top **banner** and then the **edit** icon. diff --git a/defaultwelcome/help_dm_es.md b/defaultwelcome/help_dm_es.md new file mode 100644 index 000000000..03bdf082b --- /dev/null +++ b/defaultwelcome/help_dm_es.md @@ -0,0 +1,3 @@ +Los mensajes directos aparecerán aquí, como una línea de tiempo cronológica. + +Para evitar el spam y mejorar la seguridad, de forma predeterminada, solo podrá recibir mensajes directos *de las personas a las que sigue*. Puede desactivar esto dentro de la configuración de su perfil si es necesario, seleccionando el **banner** superior y luego el icono **editar**. diff --git a/defaultwelcome/help_dm_fr.md b/defaultwelcome/help_dm_fr.md new file mode 100644 index 000000000..0f2611702 --- /dev/null +++ b/defaultwelcome/help_dm_fr.md @@ -0,0 +1,3 @@ +Les messages directs apparaîtront ici, sous forme de chronologie. + +Pour éviter les spams et améliorer la sécurité, vous ne pourrez par défaut recevoir que des messages directs *des personnes que vous suivez*. Vous pouvez désactiver cette option dans les paramètres de votre profil si nécessaire, en sélectionnant la **bannière** supérieure, puis l'icône **modifier**. diff --git a/defaultwelcome/help_dm_ga.md b/defaultwelcome/help_dm_ga.md new file mode 100644 index 000000000..016d1d1fd --- /dev/null +++ b/defaultwelcome/help_dm_ga.md @@ -0,0 +1,3 @@ +Beidh teachtaireachtaí díreacha le feiceáil anseo, mar amlíne croineolaíoch. + +Chun spam a sheachaint agus slándáil a fheabhsú, de réir réamhshocraithe ní bheidh tú in ann ach teachtaireachtaí díreacha *a fháil ó dhaoine atá á leanúint agat*. Is féidir leat é seo a mhúchadh laistigh de do shocruithe próifíle más gá duit, tríd an mbratach **barr** a roghnú agus ansin an deilbhín **edit**. diff --git a/defaultwelcome/help_dm_hi.md b/defaultwelcome/help_dm_hi.md new file mode 100644 index 000000000..409e5773d --- /dev/null +++ b/defaultwelcome/help_dm_hi.md @@ -0,0 +1,3 @@ +प्रत्यक्ष संदेश यहां कालानुक्रमिक समय के रूप में दिखाई देंगे। + +स्पैम से बचने और सुरक्षा में सुधार करने के लिए, डिफ़ॉल्ट रूप से आप केवल उन लोगों से सीधे संदेश प्राप्त कर सकेंगे जो आप का अनुसरण कर रहे हैं। आप अपनी प्रोफ़ाइल सेटिंग्स के भीतर इसे बंद कर सकते हैं, अगर आपको ज़रूरत है, तो शीर्ष **बैनर** और फिर **संपादन** आइकन का चयन करके। diff --git a/defaultwelcome/help_dm_it.md b/defaultwelcome/help_dm_it.md new file mode 100644 index 000000000..03bdf082b --- /dev/null +++ b/defaultwelcome/help_dm_it.md @@ -0,0 +1,3 @@ +Los mensajes directos aparecerán aquí, como una línea de tiempo cronológica. + +Para evitar el spam y mejorar la seguridad, de forma predeterminada, solo podrá recibir mensajes directos *de las personas a las que sigue*. Puede desactivar esto dentro de la configuración de su perfil si es necesario, seleccionando el **banner** superior y luego el icono **editar**. diff --git a/defaultwelcome/help_dm_ja.md b/defaultwelcome/help_dm_ja.md new file mode 100644 index 000000000..b691e69f4 --- /dev/null +++ b/defaultwelcome/help_dm_ja.md @@ -0,0 +1,3 @@ +ダイレクトメッセージは、時系列のタイムラインとしてここに表示されます。 + +スパムを回避し、セキュリティを向上させるために、デフォルトでは、フォローしているユーザーからの直接メッセージのみを受信できます。 必要に応じて、上部のバナーを選択してから編集アイコンを選択することにより、プロファイル設定内でこれをオフにすることができます。 diff --git a/defaultwelcome/help_dm_ku.md b/defaultwelcome/help_dm_ku.md new file mode 100644 index 000000000..b42081ee6 --- /dev/null +++ b/defaultwelcome/help_dm_ku.md @@ -0,0 +1,3 @@ +Dê peyamên rasterast li vir, wekî demek kronolojîk, xuya bibin. + +Ji bo ku xwe ji spamê nehêlin û ewlehiyê baştir bikin, bi default hûn ê tenê karibin peyamên rasterast ji kesên ku hûn dişopînin bistînin. Heke hûn hewce ne, hûn dikarin vê yekê di nav vesazên profîla xwe de vemirînin, bi hilbijartina jor **banner** û dûv re îkona **edit**. diff --git a/defaultwelcome/help_dm_pt.md b/defaultwelcome/help_dm_pt.md new file mode 100644 index 000000000..e6040f89c --- /dev/null +++ b/defaultwelcome/help_dm_pt.md @@ -0,0 +1,3 @@ +As mensagens diretas aparecerão aqui, como uma linha do tempo cronológica. + +Para evitar spam e melhorar a segurança, por padrão, você só poderá receber mensagens diretas *das pessoas que está seguindo*. Você pode desativar isso nas configurações de seu perfil, se necessário, selecionando o **banner** superior e, em seguida, o ícone **editar**. diff --git a/defaultwelcome/help_dm_ru.md b/defaultwelcome/help_dm_ru.md new file mode 100644 index 000000000..c1e95cacd --- /dev/null +++ b/defaultwelcome/help_dm_ru.md @@ -0,0 +1,3 @@ +Личные сообщения будут отображаться здесь в хронологическом порядке. + +Чтобы избежать спама и повысить безопасность, по умолчанию вы сможете получать прямые сообщения только от людей, на которых вы подписаны. Вы можете отключить это в настройках своего профиля, если вам нужно, выбрав верхний **баннер**, а затем значок **изменить**. diff --git a/defaultwelcome/help_dm_sw.md b/defaultwelcome/help_dm_sw.md new file mode 100644 index 000000000..b0ed02ab5 --- /dev/null +++ b/defaultwelcome/help_dm_sw.md @@ -0,0 +1,3 @@ +Ujumbe wa moja kwa moja utaonekana hapa, kama ratiba ya muda. + +Ili kuepuka spam na kuboresha usalama, kwa default utakuwa na uwezo wa kupokea ujumbe wa moja kwa moja *kutoka kwa watu ambao unafuata*. Unaweza kuzima hii ndani ya mipangilio yako ya wasifu ikiwa unahitaji, kwa kuchagua bendera ya juu na kisha **icon ya hariri**. diff --git a/defaultwelcome/help_dm_zh.md b/defaultwelcome/help_dm_zh.md new file mode 100644 index 000000000..507aaf211 --- /dev/null +++ b/defaultwelcome/help_dm_zh.md @@ -0,0 +1,3 @@ +直接消息将按时间顺序显示在此处。 + +为了避免垃圾邮件并提高安全性,默认情况下,您只能接收来自您所关注人员的直接消息。 您可以根据需要在个人资料设置中将其关闭,方法是选择顶部横幅,然后选择编辑图标。 diff --git a/defaultwelcome/help_inbox_ar.md b/defaultwelcome/help_inbox_ar.md new file mode 100644 index 000000000..fd2cdaa1d --- /dev/null +++ b/defaultwelcome/help_inbox_ar.md @@ -0,0 +1,19 @@ +ستظهر المشاركات الواردة هنا كجدول زمني زمني. إذا قمت بإرسال أي منشورات فسوف تظهر هنا أيضًا. + +### اللافتة العلوية +في الجزء العلوي من الشاشة ، يمكنك تحديد الشعار للتبديل إلى ملف التعريف الخاص بك وتحريره أو تسجيل الخروج. + +### أزرار وأيقونات الخط الزمني +تسمح لك الأزرار الموجودة أسفل الشعار العلوي بتحديد خطوط زمنية مختلفة. توجد أيضًا رموز على اليمين للبحث أو عرض التقويم الخاص بك أو إنشاء منشورات جديدة. + +تتيح أيقونة إظهار / إخفاء عرض المزيد من أزرار المخطط الزمني ، إلى جانب عناصر تحكم الوسيط. + +### العمود الأيسر +هنا يمكنك إضافة روابط مفيدة. يظهر هذا فقط على شاشات سطح المكتب أو الأجهزة ذات الشاشات الأكبر حجمًا. إنه مشابه لقائمة المدونات. يمكنك فقط إضافة الروابط أو تعديلها إذا كان لديك دور مسؤول أو محرر. + +إذا كنت تستخدم الهاتف المحمول ، فاستخدم رمز الروابط في الأعلى لقراءة الأخبار. + +### العمود الأيمن +يمكن إضافة موجز ويب لـ RSS في العمود الأيمن ، المعروف باسم newswire. يظهر هذا فقط على شاشات سطح المكتب أو الأجهزة ذات الشاشات الأكبر حجمًا. يمكنك فقط إضافة أو تحرير الخلاصات إذا كان لديك دور مسؤول أو محرر ، ويمكن أيضًا الإشراف على عناصر الخلاصة الواردة. + +إذا كنت تستخدم الهاتف المحمول ، فاستخدم رمز الأخبار في الأعلى لقراءة الأخبار. diff --git a/defaultwelcome/help_inbox_ca.md b/defaultwelcome/help_inbox_ca.md new file mode 100644 index 000000000..cfcf96064 --- /dev/null +++ b/defaultwelcome/help_inbox_ca.md @@ -0,0 +1,19 @@ +Les publicacions entrants apareixeran aquí, com a cronologia cronològica. Si envieu missatges, també apareixeran aquí. + +### El bàner superior +A la part superior de la pantalla, podeu seleccionar el **bàner** per canviar al vostre perfil, editar-lo o tancar la sessió. + +### Botons i icones de la cronologia +Els **botons** que hi ha a sota del bàner superior us permeten seleccionar diferents terminis. També hi ha **icones** a la dreta per **cercar**, veure el vostre **calendari** o crear **noves publicacions**. + +La icona **mostra/amaga** permet mostrar més botons de cronologia, juntament amb controls de moderador. + +### Columna esquerra +Aquí podeu afegir **enllaços útils**. Això només apareix a les pantalles d'escriptori o als dispositius amb pantalles més grans. És similar a un *blogroll*. Només podeu afegir o editar enllaços si teniu un rol d’administrador **o d’editor**. + +Si esteu al mòbil, feu servir la icona d’enllaços a la part superior per llegir les notícies. + +### Columna dreta +Els canals RSS es poden afegir a la columna de la dreta, coneguda com a *newswire*. Això només apareix en pantalles d'escriptori o dispositius amb pantalles més grans. Només podeu afegir o editar feeds si teniu un rol d’administrador **o d’editor** i també es poden moderar els elements de feeds entrants. + +Si esteu al mòbil, utilitzeu la **icona de newswire** a la part superior per llegir les notícies. diff --git a/defaultwelcome/help_inbox_cy.md b/defaultwelcome/help_inbox_cy.md new file mode 100644 index 000000000..088229e99 --- /dev/null +++ b/defaultwelcome/help_inbox_cy.md @@ -0,0 +1,19 @@ +Bydd swyddi sy'n dod i mewn yn ymddangos yma, fel llinell amser gronolegol. Os anfonwch unrhyw bostiadau byddant hefyd yn ymddangos yma. + +### Y faner uchaf +Ar ben y sgrin gallwch ddewis y **faner** i'w newid i'ch proffil, a'i golygu neu allgofnodi. + +### Botymau ac eiconau llinell amser +Mae'r botymau o dan y faner uchaf yn caniatáu ichi ddewis gwahanol linellau amser. Mae yna hefyd **eiconau** ar y dde i **chwilio**, gweld eich **calendr** neu greu **postiadau newydd**. + +Mae'r eicon **dangos/cuddio** yn caniatáu dangos mwy o fotymau llinell amser, ynghyd â rheolyddion safonwr. + +### Colofn chwith +Yma gallwch ychwanegu **dolenni defnyddiol**. Dim ond ar arddangosfeydd bwrdd gwaith neu ddyfeisiau sydd â sgriniau mwy y mae hyn yn ymddangos. Mae'n debyg i * blogroll *. Dim ond os oes gennych rôl **gweinyddwr** neu **golygydd** y gallwch ychwanegu neu olygu dolenni. + +Os ydych chi ar ffôn symudol yna defnyddiwch yr eicon **cysylltiadau** ar y brig i ddarllen newyddion. + +### Colofn dde +Gellir ychwanegu porthwyr RSS yn y golofn dde, a elwir y * newswire *. Dim ond ar arddangosfeydd bwrdd gwaith neu ddyfeisiau sydd â sgriniau mwy y mae hyn yn ymddangos. Dim ond os oes gennych rôl **gweinyddwr** neu **golygydd** y gallwch ychwanegu neu olygu porthwyr, a gellir cymedroli eitemau porthiant sy'n dod i mewn hefyd. + +Os ydych chi ar ffôn symudol yna defnyddiwch yr eicon **newswire** ar y brig i ddarllen newyddion. diff --git a/defaultwelcome/help_inbox_de.md b/defaultwelcome/help_inbox_de.md new file mode 100644 index 000000000..cddaa9182 --- /dev/null +++ b/defaultwelcome/help_inbox_de.md @@ -0,0 +1,19 @@ +Eingehende Beiträge werden hier als chronologische Zeitleiste angezeigt. Wenn Sie Beiträge senden, werden diese auch hier angezeigt. + +### Das oberste Banner +Am oberen Bildschirmrand können Sie das **Banner** auswählen, um zu Ihrem Profil zu wechseln, es zu bearbeiten oder sich abzumelden. + +### Timeline-Schaltflächen und -Symbole +Mit den **Schaltflächen** unter dem oberen Banner können Sie verschiedene Zeitleisten auswählen. Es gibt auch **Symbole** auf der rechten Seite zum **Suchen**, Anzeigen Ihres **Kalenders** oder Erstellen **neuer Beiträge**. + +Mit dem Symbol **Einblenden/Ausblenden** können mehr Timeline-Schaltflächen sowie Moderatorsteuerelemente angezeigt werden. + +### Linke Spalte +Hier können Sie **nützliche Links** hinzufügen. Dies wird nur auf Desktop-Displays oder Geräten mit größeren Bildschirmen angezeigt. Es ist ähnlich wie bei einem *Blogroll*. Sie können Links nur hinzufügen oder bearbeiten, wenn Sie eine **Administrator** - oder **Editor** -Rolle haben. + +Wenn Sie mobil sind, verwenden Sie das **Links-Symbol** oben, um Nachrichten zu lesen. + +### Rechte Spalte +RSS-Feeds können in der rechten Spalte hinzugefügt werden, die als *newswire* bezeichnet wird. Dies wird nur auf Desktop-Displays oder Geräten mit größeren Bildschirmen angezeigt. Sie können Feeds nur hinzufügen oder bearbeiten, wenn Sie eine **Administrator** - oder **Editor** -Rolle haben. Eingehende Feed-Elemente können ebenfalls moderiert werden. + +Wenn Sie mobil sind, verwenden Sie das **Newswire-Symbol** oben, um Nachrichten zu lesen. diff --git a/defaultwelcome/help_inbox_en.md b/defaultwelcome/help_inbox_en.md new file mode 100644 index 000000000..82b95bb05 --- /dev/null +++ b/defaultwelcome/help_inbox_en.md @@ -0,0 +1,19 @@ +Incoming posts will appear here, as a chronological timeline. If you send any posts they will also appear here. + +### The top banner +At the top of the screen you can select the **banner** to switch to your profile, and edit it or log out. + +### Timeline buttons and icons +The **buttons** below the top banner allow you to select different timelines. There are also **icons** on the right to **search**, view your **calendar** or create **new posts**. + +The **show/hide** icon allows more timeline buttons to be shown, along with moderator controls. + +### Left column +Here you can add **useful links**. This only appears on desktop displays or devices with larger screens. It is similar to a *blogroll*. You can only add or edit links if you have an **administrator** or **editor** role. + +If you are on mobile then use the **links icon** at the top to read news. + +### Right column +RSS feeds can be added in the right column, known as the *newswire*. This only appears on desktop displays or devices with larger screens. You can only add or edit feeds if you have an **administrator** or **editor** role, and incoming feed items can also be moderated. + +If you are on mobile then use the **newswire icon** at the top to read news. diff --git a/defaultwelcome/help_inbox_es.md b/defaultwelcome/help_inbox_es.md new file mode 100644 index 000000000..d86e4a9c7 --- /dev/null +++ b/defaultwelcome/help_inbox_es.md @@ -0,0 +1,19 @@ +Las publicaciones entrantes aparecerán aquí, como una línea de tiempo cronológica. Si envía alguna publicación, también aparecerá aquí. + +### El banner superior +En la parte superior de la pantalla, puede seleccionar el **banner** para cambiar a su perfil y editarlo o cerrar la sesión. + +### Botones e íconos de la línea de tiempo +Los **botones** debajo del banner superior le permiten seleccionar diferentes líneas de tiempo. También hay **iconos** a la derecha para **buscar**, ver tu **calendario** o crear **publicaciones nuevas**. + +El icono **mostrar/ocultar** permite que se muestren más botones de la línea de tiempo, junto con los controles del moderador. + +### Columna izquierda +Aquí puede agregar **enlaces útiles**. Esto solo aparece en pantallas de escritorio o dispositivos con pantallas más grandes. Es similar a un *blogroll*. Solo puede agregar o editar enlaces si tiene una función de **administrador** o **editor**. + +Si está en un dispositivo móvil, use el **icono de enlaces** en la parte superior para leer las noticias. + +### Columna derecha +Las fuentes RSS se pueden agregar en la columna de la derecha, conocida como *newswire*. Esto solo aparece en pantallas de escritorio o dispositivos con pantallas más grandes. Solo puede agregar o editar feeds si tiene una función de **administrador** o **editor**, y los elementos entrantes del feed también se pueden moderar. + +Si está en un dispositivo móvil, use el **icono de newswire** en la parte superior para leer las noticias. diff --git a/defaultwelcome/help_inbox_fr.md b/defaultwelcome/help_inbox_fr.md new file mode 100644 index 000000000..2d9c0842c --- /dev/null +++ b/defaultwelcome/help_inbox_fr.md @@ -0,0 +1,19 @@ +Les messages entrants apparaîtront ici, sous forme de chronologie. Si vous envoyez des messages, ils apparaîtront également ici. + +### La bannière du haut +En haut de l'écran, vous pouvez sélectionner la **bannière** pour passer à votre profil, le modifier ou vous déconnecter. + +### Boutons et icônes de la chronologie +Les **boutons** sous la bannière du haut vous permettent de sélectionner différentes chronologies. Il y a aussi des **icônes** sur la droite pour **rechercher**, afficher votre **calendrier** ou créer **de nouveaux messages**. + +L'icône **afficher/masquer** permet d'afficher plus de boutons de chronologie, ainsi que les commandes du modérateur. + +### Colonne de gauche +Ici, vous pouvez ajouter des **liens utiles**. Cela n'apparaît que sur les écrans de bureau ou les appareils avec des écrans plus grands. C'est similaire à un *blogroll*. Vous ne pouvez ajouter ou modifier des liens que si vous avez un rôle **administrateur** ou **éditeur**. + +Si vous êtes sur mobile, utilisez l'icône **liens** en haut pour lire les actualités. + +### Colonne de droite +Les flux RSS peuvent être ajoutés dans la colonne de droite, connue sous le nom de *newswire*. Cela n'apparaît que sur les écrans de bureau ou les appareils avec des écrans plus grands. Vous ne pouvez ajouter ou modifier des flux que si vous avez un rôle **administrateur** ou **rédacteur**, et les éléments de flux entrants peuvent également être modérés. + +Si vous êtes sur mobile, utilisez l'icône **Newswire** en haut pour lire les actualités. diff --git a/defaultwelcome/help_inbox_ga.md b/defaultwelcome/help_inbox_ga.md new file mode 100644 index 000000000..fead80c54 --- /dev/null +++ b/defaultwelcome/help_inbox_ga.md @@ -0,0 +1,19 @@ +Beidh poist isteach le feiceáil anseo, mar amlíne croineolaíoch. Má sheolann tú aon phoist beidh siad le feiceáil anseo freisin. + +### An bhratach barr +Ag barr an scáileáin is féidir leat an **meirge** a roghnú le hathrú chuig do phróifíl, agus é a chur in eagar nó logáil amach. + +### Cnaipí agus deilbhíní amlíne +Ligeann na **cnaipí** faoin mbratach barr duit amlínte éagsúla a roghnú. Tá **deilbhíní** ar dheis chun **cuardach** a dhéanamh, féachaint ar do **fhéilire** nó **post nua** a chruthú. + +Ligeann an deilbhín **show/hide** níos mó cnaipí amlíne a thaispeáint, mar aon le rialuithe modhnóra. + +### Colún ar chlé +Anseo is féidir leat **naisc úsáideacha** a chur leis. Ní bhíonn sé seo le feiceáil ach ar thaispeántais deisce nó ar fheistí le scáileáin níos mó. Tá sé cosúil le * blogroll *. Ní féidir leat naisc a chur leis nó a chur in eagar ach má tá ról **riarthóir** nó **eagarthóir** agat. + +Má tá tú soghluaiste, úsáid an deilbhín **naisc** ag an mbarr chun nuacht a léamh. + +### Colún ar dheis +Is féidir fothaí RSS a chur leis sa cholún ar dheis, ar a dtugtar an * newswire *. Ní bhíonn sé seo le feiceáil ach ar thaispeántais deisce nó ar fheistí le scáileáin níos mó. Ní féidir leat fothaí a chur leis nó a chur in eagar ach má tá ról **riarthóir** nó **eagarthóir** agat, agus is féidir earraí beatha atá ag teacht isteach a mhodhnú freisin. + +Má tá tú soghluaiste, bain úsáid as an deilbhín **newswire** ag an mbarr chun nuacht a léamh. diff --git a/defaultwelcome/help_inbox_hi.md b/defaultwelcome/help_inbox_hi.md new file mode 100644 index 000000000..52fc3597f --- /dev/null +++ b/defaultwelcome/help_inbox_hi.md @@ -0,0 +1,19 @@ +आने वाली पोस्टें यहां कालानुक्रमिक समय के रूप में दिखाई देंगी। यदि आप कोई पोस्ट भेजते हैं तो वे भी यहाँ दिखाई देंगे। + +### शीर्ष बैनर +स्क्रीन के शीर्ष पर आप अपनी प्रोफ़ाइल पर जाने के लिए **बैनर** का चयन कर सकते हैं, और इसे संपादित या लॉग आउट कर सकते हैं। + +### समयरेखा बटन और आइकन +शीर्ष बैनर के नीचे **बटन** आपको विभिन्न समयसीमाओं का चयन करने की अनुमति देते हैं। **खोज** के दाईं ओर आइकन भी हैं, अपने **कैलेंडर** देखें या **नए पोस्ट** बनाएं। + +मॉडरेटर नियंत्रण के साथ **शो/हाइड** आइकन अधिक टाइमलाइन बटन दिखाने की अनुमति देता है। + +### बाएं स्तंभ +यहां आप **उपयोगी लिंक** जोड़ सकते हैं। यह केवल डेस्कटॉप डिस्प्ले या बड़ी स्क्रीन वाले उपकरणों पर दिखाई देता है। यह एक *ब्लॉगरोल* के समान है। यदि आपके पास **व्यवस्थापक** या **संपादक** भूमिका है, तो आप केवल लिंक जोड़ या संपादित कर सकते हैं। + +अगर आप मोबाइल पर हैं तो समाचार पढ़ने के लिए सबसे ऊपर **लिंक आइकन** का उपयोग करें। + +### दक्षिण पक्ष क़तार +RSS फ़ीड्स को सही कॉलम में जोड़ा जा सकता है, जिसे *newswire* के रूप में जाना जाता है। यह केवल डेस्कटॉप डिस्प्ले या बड़ी स्क्रीन वाले उपकरणों पर दिखाई देता है। आप केवल तभी जोड़ या संपादित कर सकते हैं जब आपके पास **व्यवस्थापक** या **संपादक** भूमिका हो, और आने वाली फ़ीड आइटम भी मॉडरेट की जा सकती हैं। + +यदि आप मोबाइल पर हैं तो समाचार पढ़ने के लिए सबसे ऊपर **newswire आइकन** का उपयोग करें। diff --git a/defaultwelcome/help_inbox_it.md b/defaultwelcome/help_inbox_it.md new file mode 100644 index 000000000..d86e4a9c7 --- /dev/null +++ b/defaultwelcome/help_inbox_it.md @@ -0,0 +1,19 @@ +Las publicaciones entrantes aparecerán aquí, como una línea de tiempo cronológica. Si envía alguna publicación, también aparecerá aquí. + +### El banner superior +En la parte superior de la pantalla, puede seleccionar el **banner** para cambiar a su perfil y editarlo o cerrar la sesión. + +### Botones e íconos de la línea de tiempo +Los **botones** debajo del banner superior le permiten seleccionar diferentes líneas de tiempo. También hay **iconos** a la derecha para **buscar**, ver tu **calendario** o crear **publicaciones nuevas**. + +El icono **mostrar/ocultar** permite que se muestren más botones de la línea de tiempo, junto con los controles del moderador. + +### Columna izquierda +Aquí puede agregar **enlaces útiles**. Esto solo aparece en pantallas de escritorio o dispositivos con pantallas más grandes. Es similar a un *blogroll*. Solo puede agregar o editar enlaces si tiene una función de **administrador** o **editor**. + +Si está en un dispositivo móvil, use el **icono de enlaces** en la parte superior para leer las noticias. + +### Columna derecha +Las fuentes RSS se pueden agregar en la columna de la derecha, conocida como *newswire*. Esto solo aparece en pantallas de escritorio o dispositivos con pantallas más grandes. Solo puede agregar o editar feeds si tiene una función de **administrador** o **editor**, y los elementos entrantes del feed también se pueden moderar. + +Si está en un dispositivo móvil, use el **icono de newswire** en la parte superior para leer las noticias. diff --git a/defaultwelcome/help_inbox_ja.md b/defaultwelcome/help_inbox_ja.md new file mode 100644 index 000000000..344320b0a --- /dev/null +++ b/defaultwelcome/help_inbox_ja.md @@ -0,0 +1,19 @@ +着信投稿は、時系列のタイムラインとしてここに表示されます。投稿を送信すると、ここにも表示されます。 + +### トップバナー +画面の上部で、**バナー**を選択してプロファイルに切り替え、編集またはログアウトできます。 + +###タイムラインのボタンとアイコン +上部のバナーの下にある**ボタン**を使用すると、さまざまなタイムラインを選択できます。 **検索**、**カレンダー**の表示、または**新しい投稿**の作成の右側には**アイコン**もあります。 + +**表示/非表示**アイコンを使用すると、モデレーターコントロールとともに、より多くのタイムラインボタンを表示できます。 + +### 左の列 +ここで**便利なリンク**を追加できます。これは、デスクトップディスプレイまたは大画面のデバイスにのみ表示されます。これは* blogroll *に似ています。リンクを追加または編集できるのは、**管理者**または**編集者**の役割がある場合のみです。 + +モバイルを使用している場合は、上部にある**リンクアイコン**を使用してニュースを読んでください。 + +### 右の列 +RSSフィードは、* newswire *と呼ばれる右側の列に追加できます。これは、デスクトップディスプレイまたは大画面のデバイスにのみ表示されます。フィードを追加または編集できるのは、**管理者**または**編集者**の役割がある場合のみです。また、受信フィードアイテムをモデレートすることもできます。 + +モバイルを使用している場合は、上部にある**ニュースワイヤーアイコン**を使用してニュースを読んでください。 diff --git a/defaultwelcome/help_inbox_ku.md b/defaultwelcome/help_inbox_ku.md new file mode 100644 index 000000000..72d534fd7 --- /dev/null +++ b/defaultwelcome/help_inbox_ku.md @@ -0,0 +1,19 @@ +Mesajên hatinê dê li vir, wekî demek kronolojîk xuya bikin. Ger hûn şandiyan bişînin ew ê jî li vir xuya bibin. + +### Pankarta jorîn +Li jor li ser ekranê hûn dikarin **pankarta** hilbijêrin da ku hûn profîla xwe veguherînin, û wê sererast bikin an jî derkevin. + +### Bişkojk û îkonên Timeline +Bişkojkên li jêr pankarta jorîn dihêlin hûn demjimêrên cihêreng hilbijêrin. Di heman demê de **îkonên** li rastê **lêgerîn**, dîtina **salnameya xwe** an afirandina **peyamên nû** hene. + +Nîşaneya **nîşan/veşêre** dihêle ku digel pêvekên moderator, bêtir bişkokên demjimêrê werin nîşandin. + +### Stûna çepê +Li vir hûn dikarin **girêdanên bikêr** zêde bikin. Ev tenê li ser dîmenderên sermaseyê an amûrên xwedan ekranên mezintir xuya dike. Ew dişibihe *blogroll*. Tenê heke we roleke **rêveber** an **edîtor** hebe hûn tenê dikarin girêdan lê zêde bikin an biguherînin. + +Heke hûn li ser mobîl in wê hingê ji bo xwendina nûçeyan **îkona girêdan** li jor bikar bînin. + +### Stûna rast +RSS-ê dikarin di stûna rastê de werin zêdekirin, ku wekî *nûçe* tê zanîn. Ev tenê li ser dîmenderên sermaseyê an amûrên xwedan ekranên mezintir xuya dike. Tenê heke we roleke **rêveber** an **edîtor** hebe hûn tenê dikarin pêvekan zêde bikin an biguhezînin, û hêmanên tewra tewra jî dikarin werin moderator kirin. + +Heke hûn li ser mobîl in wê hingê ji bo xwendina nûçeyan li jor îkona **newswire** bikar bînin. diff --git a/defaultwelcome/help_inbox_pt.md b/defaultwelcome/help_inbox_pt.md new file mode 100644 index 000000000..26288b193 --- /dev/null +++ b/defaultwelcome/help_inbox_pt.md @@ -0,0 +1,19 @@ +As postagens recebidas aparecerão aqui, como uma linha do tempo cronológica. Se você enviar alguma postagem, ela também aparecerá aqui. + +### O banner superior +Na parte superior da tela, você pode selecionar o **banner** para alternar para seu perfil e editá-lo ou fazer logout. + +### Botões e ícones da linha do tempo +Os **botões** abaixo do banner superior permitem que você selecione diferentes cronogramas. Também existem **ícones** à direita para **pesquisar**, visualizar sua **agenda** ou criar **novas postagens**. + +O ícone **mostrar / ocultar** permite que mais botões da linha do tempo sejam mostrados, junto com os controles do moderador. + +### Coluna esquerda +Aqui você pode adicionar **links úteis**. Isso só aparece em monitores de desktop ou dispositivos com telas maiores. É semelhante a um *blogroll*. Você só pode adicionar ou editar links se tiver uma função de **administrador** ou **editor**. + +Se você estiver no celular, use o **ícone de links** na parte superior para ler as notícias. + +### Coluna direita +Os feeds RSS podem ser adicionados na coluna da direita, conhecida como *newswire*. Isso só aparece em monitores de desktop ou dispositivos com telas maiores. Você só pode adicionar ou editar feeds se tiver uma função de **administrador** ou **editor**, e os itens de feed recebidos também podem ser moderados. + +Se você estiver no celular, use o **ícone de notícias** na parte superior para ler as notícias. diff --git a/defaultwelcome/help_inbox_ru.md b/defaultwelcome/help_inbox_ru.md new file mode 100644 index 000000000..dc52eeca6 --- /dev/null +++ b/defaultwelcome/help_inbox_ru.md @@ -0,0 +1,19 @@ +Входящие сообщения будут отображаться здесь в хронологическом порядке. Если вы отправите какие-либо сообщения, они также появятся здесь. + +### Верхний баннер +В верхней части экрана вы можете выбрать **баннер**, чтобы переключиться на свой профиль, отредактировать его или выйти из системы. + +### Кнопки и значки шкалы времени +**Кнопки** под верхним баннером позволяют выбирать разные временные шкалы. Также есть **значки** справа для **поиска**, просмотра **календаря** или создания **новых сообщений**. + +Значок **показать/скрыть** позволяет отображать больше кнопок временной шкалы вместе с элементами управления модератора. + +### Левый столбец +Здесь вы можете добавить **полезные ссылки**. Это появляется только на настольных дисплеях или устройствах с большими экранами. Это похоже на * блогролл *. Вы можете добавлять или редактировать ссылки только в том случае, если у вас есть роль **администратора** или **редактора**. + +Если вы используете мобильный телефон, используйте **значок ссылок** вверху, чтобы читать новости. + +### Правый столбец +RSS-каналы могут быть добавлены в правый столбец, известный как * лента новостей *. Это появляется только на настольных дисплеях или устройствах с большими экранами. Вы можете добавлять или редактировать каналы только в том случае, если у вас есть роль **администратор** или **редактор**, а входящие элементы канала также можно модерировать. + +Если вы пользуетесь мобильным телефоном, используйте **значок ленты новостей** вверху, чтобы читать новости. diff --git a/defaultwelcome/help_inbox_sw.md b/defaultwelcome/help_inbox_sw.md new file mode 100644 index 000000000..99b47f651 --- /dev/null +++ b/defaultwelcome/help_inbox_sw.md @@ -0,0 +1,19 @@ +Machapisho yanayoingia itaonekana hapa, kama ratiba ya muda. Ikiwa unatuma machapisho yoyote wataonekana pia hapa. + +### Bendera ya juu +Juu ya skrini unaweza kuchagua bendera ili kubadili wasifu wako, na uhariri au uiteke. + +### Vifungo vya Timeline na Icons +Vifungo chini ya bendera ya juu vinakuwezesha kuchagua wakati tofauti. Pia kuna **icons** kwenye haki ya **Tafuta**, angalia kalenda yako au uunda **machapisho mapya**. + +**Onyesha/Ficha** icon inaruhusu vifungo zaidi vya wakati wa wakati kuonyeshwa, pamoja na udhibiti wa moderator. + +### Safu ya kushoto +Hapa unaweza kuongeza **viungo muhimu**. Hii inaonekana tu kwenye maonyesho ya desktop au vifaa na skrini kubwa. Ni sawa na *Blogroll*. Unaweza tu kuongeza au hariri viungo ikiwa una **Msimamizi** au **Mhariri** jukumu. + +Ikiwa uko kwenye simu kisha utumie Icon ya **Links** juu ili kusoma habari. + +### Safu ya haki +RSS Feeds inaweza kuongezwa kwenye safu ya kulia, inayojulikana kama *Newswire*. Hii inaonekana tu kwenye maonyesho ya desktop au vifaa na skrini kubwa. Unaweza tu kuongeza au kuhariri feeds ikiwa una msimamizi au jukumu la mhariri, na vitu vya kulisha vinaweza pia kuzingatiwa. + +Ikiwa uko kwenye simu kisha utumie **Newswire icon** juu ili kusoma habari. diff --git a/defaultwelcome/help_inbox_zh.md b/defaultwelcome/help_inbox_zh.md new file mode 100644 index 000000000..363a450ae --- /dev/null +++ b/defaultwelcome/help_inbox_zh.md @@ -0,0 +1,19 @@ +收到的帖子将按时间顺序显示在此处。如果您发送任何帖子,它们也会显示在这里。 + +### 最高横幅 +在屏幕顶部,您可以选择横幅以切换到您的个人资料,然后对其进行编辑或注销。 + +### 时间轴按钮和图标 +顶部横幅下方的按钮使您可以选择不同的时间轴。右侧也有图标可以搜索,查看日历或创建新帖子。 + +显示/隐藏图标允许显示更多时间线按钮以及主持人控件。 + +### 左栏 +您可以在此处添加有用的链接。它仅出现在台式机显示器或具有更大屏幕的设备上。它类似于博客卷。如果您具有管理员或编辑者角色,则只能添加或编辑链接。 + +如果您在移动设备上,请使用顶部的链接图标阅读新闻。 + +### 右列 +可以在右侧栏(称为新闻专线)中添加RSS提要。它仅出现在台式机显示器或具有更大屏幕的设备上。如果您具有管理员或编辑者角色,则只能添加或编辑提要,并且传入提要项目也可以被审核。 + +如果您在移动设备上,请使用顶部的新闻专线图标阅读新闻。 diff --git a/defaultwelcome/help_outbox_ar.md b/defaultwelcome/help_outbox_ar.md new file mode 100644 index 000000000..342d8c679 --- /dev/null +++ b/defaultwelcome/help_outbox_ar.md @@ -0,0 +1 @@ +ستظهر مشاركاتك المرسلة هنا ، كجدول زمني زمني. diff --git a/defaultwelcome/help_outbox_ca.md b/defaultwelcome/help_outbox_ca.md new file mode 100644 index 000000000..589d3f385 --- /dev/null +++ b/defaultwelcome/help_outbox_ca.md @@ -0,0 +1 @@ +Les vostres publicacions enviades apareixeran aquí com a cronologia cronològica. diff --git a/defaultwelcome/help_outbox_cy.md b/defaultwelcome/help_outbox_cy.md new file mode 100644 index 000000000..439477edc --- /dev/null +++ b/defaultwelcome/help_outbox_cy.md @@ -0,0 +1 @@ +Bydd eich postiadau a anfonir yn ymddangos yma, fel llinell amser gronolegol. diff --git a/defaultwelcome/help_outbox_de.md b/defaultwelcome/help_outbox_de.md new file mode 100644 index 000000000..711f22d6f --- /dev/null +++ b/defaultwelcome/help_outbox_de.md @@ -0,0 +1 @@ +Ihre gesendeten Beiträge werden hier als chronologische Zeitleiste angezeigt. diff --git a/defaultwelcome/help_outbox_en.md b/defaultwelcome/help_outbox_en.md new file mode 100644 index 000000000..7651570d6 --- /dev/null +++ b/defaultwelcome/help_outbox_en.md @@ -0,0 +1 @@ +Your sent posts will appear here, as a cronological timeline. diff --git a/defaultwelcome/help_outbox_es.md b/defaultwelcome/help_outbox_es.md new file mode 100644 index 000000000..e3b727621 --- /dev/null +++ b/defaultwelcome/help_outbox_es.md @@ -0,0 +1 @@ +Sus publicaciones enviadas aparecerán aquí, como una línea de tiempo cronológica. diff --git a/defaultwelcome/help_outbox_fr.md b/defaultwelcome/help_outbox_fr.md new file mode 100644 index 000000000..cd68eda3a --- /dev/null +++ b/defaultwelcome/help_outbox_fr.md @@ -0,0 +1 @@ +Vos messages envoyés apparaîtront ici, sous forme de chronologie. diff --git a/defaultwelcome/help_outbox_ga.md b/defaultwelcome/help_outbox_ga.md new file mode 100644 index 000000000..1dcd60464 --- /dev/null +++ b/defaultwelcome/help_outbox_ga.md @@ -0,0 +1 @@ +Beidh do phoist seolta le feiceáil anseo, mar amlíne croineolaíoch. diff --git a/defaultwelcome/help_outbox_hi.md b/defaultwelcome/help_outbox_hi.md new file mode 100644 index 000000000..6f8fa7eb4 --- /dev/null +++ b/defaultwelcome/help_outbox_hi.md @@ -0,0 +1 @@ +आपके भेजे गए पोस्ट यहाँ दिखाई देंगे, कालानुक्रमिक समय के रूप में। diff --git a/defaultwelcome/help_outbox_it.md b/defaultwelcome/help_outbox_it.md new file mode 100644 index 000000000..e3b727621 --- /dev/null +++ b/defaultwelcome/help_outbox_it.md @@ -0,0 +1 @@ +Sus publicaciones enviadas aparecerán aquí, como una línea de tiempo cronológica. diff --git a/defaultwelcome/help_outbox_ja.md b/defaultwelcome/help_outbox_ja.md new file mode 100644 index 000000000..1d7eeb3e2 --- /dev/null +++ b/defaultwelcome/help_outbox_ja.md @@ -0,0 +1 @@ +送信した投稿は、時系列のタイムラインとしてここに表示されます。 diff --git a/defaultwelcome/help_outbox_ku.md b/defaultwelcome/help_outbox_ku.md new file mode 100644 index 000000000..2e3a644ad --- /dev/null +++ b/defaultwelcome/help_outbox_ku.md @@ -0,0 +1 @@ +Mesajên weyên şandî dê li vir, wekî demek kronolojîk, xuya bibin. diff --git a/defaultwelcome/help_outbox_pt.md b/defaultwelcome/help_outbox_pt.md new file mode 100644 index 000000000..959ebf47b --- /dev/null +++ b/defaultwelcome/help_outbox_pt.md @@ -0,0 +1 @@ +Suas postagens enviadas aparecerão aqui, como uma linha do tempo cronológica. diff --git a/defaultwelcome/help_outbox_ru.md b/defaultwelcome/help_outbox_ru.md new file mode 100644 index 000000000..e64435836 --- /dev/null +++ b/defaultwelcome/help_outbox_ru.md @@ -0,0 +1 @@ +Отправленные вами сообщения будут отображаться здесь в хронологическом порядке. diff --git a/defaultwelcome/help_outbox_sw.md b/defaultwelcome/help_outbox_sw.md new file mode 100644 index 000000000..0aa55d880 --- /dev/null +++ b/defaultwelcome/help_outbox_sw.md @@ -0,0 +1 @@ +Posts yako ya kutumwa itaonekana hapa, kama ratiba ya muda. diff --git a/defaultwelcome/help_outbox_zh.md b/defaultwelcome/help_outbox_zh.md new file mode 100644 index 000000000..2b651b931 --- /dev/null +++ b/defaultwelcome/help_outbox_zh.md @@ -0,0 +1 @@ +您发送的帖子将按时间顺序显示在此处。 diff --git a/defaultwelcome/help_tlblogs_ar.md b/defaultwelcome/help_tlblogs_ar.md new file mode 100644 index 000000000..e4a2eefcb --- /dev/null +++ b/defaultwelcome/help_tlblogs_ar.md @@ -0,0 +1,5 @@ +يحتوي هذا المخطط الزمني على أي مدونات كتبتها أنت أو أي شخص تتابعه. + +يمكنك إنشاء منشور مدونة جديد باستخدام رمز النشر أعلى العمود الأيمن. + +تختلف منشورات المدونة عن المشاركات الفدرالية العادية. يستخدمون نوع المقالة ActivityPub ، والمخصص للكتابة الطويلة. يمكن أن يكون لديهم أيضًا اقتباسات ، مختارة من العناصر الموجودة في الأخبار. diff --git a/defaultwelcome/help_tlblogs_ca.md b/defaultwelcome/help_tlblogs_ca.md new file mode 100644 index 000000000..ac51074e0 --- /dev/null +++ b/defaultwelcome/help_tlblogs_ca.md @@ -0,0 +1,5 @@ +Aquesta cronologia conté els blocs escrits per vosaltres o per qualsevol persona que seguiu. + +Podeu crear una publicació de bloc nova amb la icona de **publicar** a la part superior de la columna dreta. + +Les publicacions de bloc són diferents de les publicacions fediverses ordinàries. Utilitzen el tipus ActivityPub *Article*, destinat a escriure en format llarg. També poden tenir cites, seleccionades entre els elements del newswire. diff --git a/defaultwelcome/help_tlblogs_cy.md b/defaultwelcome/help_tlblogs_cy.md new file mode 100644 index 000000000..4df66b61c --- /dev/null +++ b/defaultwelcome/help_tlblogs_cy.md @@ -0,0 +1,5 @@ +Mae'r llinell amser hon yn cynnwys unrhyw flogiau a ysgrifennwyd gennych chi neu unrhyw un rydych chi'n eu dilyn. + +Gallwch greu post blog newydd gan ddefnyddio'r eicon **cyhoeddi** ar frig y golofn dde. + +Mae postiadau blog yn wahanol i swyddi bwydo cyffredin. Maent yn defnyddio'r math ActivityPub *Erthygl*, sydd wedi'i fwriadu ar gyfer ysgrifennu ffurf hir. Gallant hefyd gael dyfyniadau, wedi'u dewis o eitemau yn y llif newyddion. diff --git a/defaultwelcome/help_tlblogs_de.md b/defaultwelcome/help_tlblogs_de.md new file mode 100644 index 000000000..e8f66974a --- /dev/null +++ b/defaultwelcome/help_tlblogs_de.md @@ -0,0 +1,5 @@ +Diese Zeitleiste enthält alle Blogs, die von Ihnen oder anderen Personen, denen Sie folgen, geschrieben wurden. + +Sie können einen neuen Blog-Beitrag mit dem Symbol **Veröffentlichen** oben in der rechten Spalte erstellen. + +Blog-Beiträge unterscheiden sich von normalen Fediverse-Beiträgen. Sie verwenden den Typ ActivityPub *Article*, der für das Schreiben in Langform vorgesehen ist. Sie können auch Zitate haben, die aus Elementen im Newswire ausgewählt wurden. diff --git a/defaultwelcome/help_tlblogs_en.md b/defaultwelcome/help_tlblogs_en.md new file mode 100644 index 000000000..0636712b4 --- /dev/null +++ b/defaultwelcome/help_tlblogs_en.md @@ -0,0 +1,5 @@ +This timeline contains any blogs written by you or anyone that you're following. + +You can create a new blog post using the **publish** icon at the top of the right column. + +Blog posts are different from ordinary fediverse posts. They use the ActivityPub *Article* type, which is intended for long-form writing. They can also have citations, selected from items in the newswire. diff --git a/defaultwelcome/help_tlblogs_es.md b/defaultwelcome/help_tlblogs_es.md new file mode 100644 index 000000000..23b9d05f3 --- /dev/null +++ b/defaultwelcome/help_tlblogs_es.md @@ -0,0 +1,5 @@ +Esta línea de tiempo contiene cualquier blog escrito por usted o cualquier persona a la que esté siguiendo. + +Puede crear una nueva publicación de blog usando el ícono **publicar** en la parte superior de la columna de la derecha. + +Las publicaciones de blog son diferentes de las publicaciones normales de fediverse. Usan el tipo ActivityPub *Article*, que está destinado a la escritura de formato largo. También pueden tener citas, seleccionadas de elementos del newswire. diff --git a/defaultwelcome/help_tlblogs_fr.md b/defaultwelcome/help_tlblogs_fr.md new file mode 100644 index 000000000..c3e890241 --- /dev/null +++ b/defaultwelcome/help_tlblogs_fr.md @@ -0,0 +1,5 @@ +Cette chronologie contient tous les blogs écrits par vous ou par toute personne que vous suivez. + +Vous pouvez créer un nouvel article de blog à l'aide de l'icône **publier** en haut de la colonne de droite. + +Les articles de blog sont différents des articles de fediverse ordinaires. Ils utilisent le type ActivityPub *Article*, qui est destiné à la rédaction longue. Ils peuvent également avoir des citations, sélectionnées parmi les éléments du fil de presse. diff --git a/defaultwelcome/help_tlblogs_ga.md b/defaultwelcome/help_tlblogs_ga.md new file mode 100644 index 000000000..29f5fefd1 --- /dev/null +++ b/defaultwelcome/help_tlblogs_ga.md @@ -0,0 +1,5 @@ +Cuimsíonn an amlíne seo aon bhlaganna a scríobh tú féin nó aon duine atá á leanúint agat. + +Is féidir leat blagphost nua a chruthú trí úsáid a bhaint as an deilbhín **foilsigh** ag barr an cholúin ar dheis. + +Tá blagphoist difriúil ó ghnáthphoist chothaithe. Úsáideann siad an cineál ActivityPub *Article*, atá beartaithe le haghaidh scríbhneoireachta i bhfoirm fhada. Is féidir leo luanna a bheith acu freisin, a roghnófar as míreanna sa sreang nuachta. diff --git a/defaultwelcome/help_tlblogs_hi.md b/defaultwelcome/help_tlblogs_hi.md new file mode 100644 index 000000000..041abc229 --- /dev/null +++ b/defaultwelcome/help_tlblogs_hi.md @@ -0,0 +1,5 @@ +इस समयावधि में आपके या आपके द्वारा अनुसरण किए जा रहे किसी भी ब्लॉग को शामिल किया गया है। + +आप दाहिने कॉलम के शीर्ष पर **प्रकाशित** आइकन का उपयोग करके एक नया ब्लॉग पोस्ट बना सकते हैं। + +ब्लॉग पोस्ट साधारण फ़ेब्रिवर्स पोस्ट से भिन्न होते हैं। वे एक्टिविटीपब *आर्टिकल* टाइप का उपयोग करते हैं, जो लंबे फॉर्म के लेखन के लिए है। उनके पास प्रशंसा पत्र भी हो सकते हैं, जिन्हें न्यूजवायर में आइटम से चुना गया है। diff --git a/defaultwelcome/help_tlblogs_it.md b/defaultwelcome/help_tlblogs_it.md new file mode 100644 index 000000000..23b9d05f3 --- /dev/null +++ b/defaultwelcome/help_tlblogs_it.md @@ -0,0 +1,5 @@ +Esta línea de tiempo contiene cualquier blog escrito por usted o cualquier persona a la que esté siguiendo. + +Puede crear una nueva publicación de blog usando el ícono **publicar** en la parte superior de la columna de la derecha. + +Las publicaciones de blog son diferentes de las publicaciones normales de fediverse. Usan el tipo ActivityPub *Article*, que está destinado a la escritura de formato largo. También pueden tener citas, seleccionadas de elementos del newswire. diff --git a/defaultwelcome/help_tlblogs_ja.md b/defaultwelcome/help_tlblogs_ja.md new file mode 100644 index 000000000..08fe92ba2 --- /dev/null +++ b/defaultwelcome/help_tlblogs_ja.md @@ -0,0 +1,5 @@ +このタイムラインには、あなたまたはあなたがフォローしている人が書いたブログが含まれています。 + +右側の列の上部にある**公開**アイコンを使用して、新しいブログ投稿を作成できます。 + +ブログの投稿は、通常の連邦の投稿とは異なります。 長い形式の書き込みを目的としたActivityPub *Article*タイプを使用します。 ニュースワイヤーの項目から選択した引用を含めることもできます。 diff --git a/defaultwelcome/help_tlblogs_ku.md b/defaultwelcome/help_tlblogs_ku.md new file mode 100644 index 000000000..4b811d9c0 --- /dev/null +++ b/defaultwelcome/help_tlblogs_ku.md @@ -0,0 +1,5 @@ +Di vê demjimêrê de tevnvîsên ku hûn an jî kesê ku hûn dişopînin nivîsandî hene. + +Hûn dikarin li jor li ser stûna rastîn îkona **weşandin** bi posta tevnvîsek nû çêbikin. + +Mesajên blogê ji mesajên adetî yên federatê cuda ne. Ew celeb ActivityPub *Article* bikar tînin, ku ji bo nivîsandina dirêj-form tête armanc kirin. Di heman demê de ew dikarin sernavên ku ji nûçeyên tê de ji babetan hatine hilbijartin jî hebin. diff --git a/defaultwelcome/help_tlblogs_pt.md b/defaultwelcome/help_tlblogs_pt.md new file mode 100644 index 000000000..eab6e453b --- /dev/null +++ b/defaultwelcome/help_tlblogs_pt.md @@ -0,0 +1,5 @@ +Esta linha do tempo contém todos os blogs escritos por você ou alguém que você está seguindo. + +Você pode criar uma nova postagem no blog usando o ícone **publicar** na parte superior da coluna direita. + +As postagens do blog são diferentes das postagens comuns do fediverse. Eles usam o tipo ActivityPub *Artigo*, que se destina a redação longa. Eles também podem ter citações, selecionadas de itens no jornal. diff --git a/defaultwelcome/help_tlblogs_ru.md b/defaultwelcome/help_tlblogs_ru.md new file mode 100644 index 000000000..e3ec9ce1c --- /dev/null +++ b/defaultwelcome/help_tlblogs_ru.md @@ -0,0 +1,5 @@ +Эта шкала времени содержит все блоги, написанные вами или кем-либо, за кем вы следите. + +Вы можете создать новую запись в блоге, используя значок **опубликовать** в верхней части правого столбца. + +Сообщения в блогах отличаются от обычных сообщений Fediverse. Они используют тип ActivityPub *Article*, который предназначен для написания полных форм. У них также могут быть цитаты, выбранные из элементов ленты новостей. diff --git a/defaultwelcome/help_tlblogs_sw.md b/defaultwelcome/help_tlblogs_sw.md new file mode 100644 index 000000000..acb68758d --- /dev/null +++ b/defaultwelcome/help_tlblogs_sw.md @@ -0,0 +1,5 @@ +Muda huu una blogu yoyote iliyoandikwa na wewe au mtu yeyote anayefuata. + +Unaweza kuunda chapisho jipya la blogu kwa kutumia **kuchapisha** icon juu ya safu ya kulia. + +Machapisho ya blogu ni tofauti na posts ya kawaida ya fediase. Wanatumia aina ya shughuli ya shughuli, ambayo inalenga kwa kuandika kwa muda mrefu. Wanaweza pia kuwa na maandishi, kuchaguliwa kutoka kwa vitu katika Newswire. diff --git a/defaultwelcome/help_tlblogs_zh.md b/defaultwelcome/help_tlblogs_zh.md new file mode 100644 index 000000000..9acaf8bd4 --- /dev/null +++ b/defaultwelcome/help_tlblogs_zh.md @@ -0,0 +1,5 @@ +此时间轴包含您或您关注的任何人撰写的所有博客。 + +您可以使用右列顶部的发布图标来创建新的博客文章。 + +博客文章不同于普通的寓言文章。 他们使用ActivityPub Article类型,该类型旨在用于长篇文章写作。 他们还可以从新闻专栏的文章中选择引文。 diff --git a/defaultwelcome/help_tlbookmarks_ar.md b/defaultwelcome/help_tlbookmarks_ar.md new file mode 100644 index 000000000..16bf6bce7 --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_ar.md @@ -0,0 +1 @@ +تظهر أي وظيفة مرجعية هنا. diff --git a/defaultwelcome/help_tlbookmarks_ca.md b/defaultwelcome/help_tlbookmarks_ca.md new file mode 100644 index 000000000..f27cc9ff3 --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_ca.md @@ -0,0 +1 @@ +Aquí apareixen totes les publicacions marcades. diff --git a/defaultwelcome/help_tlbookmarks_cy.md b/defaultwelcome/help_tlbookmarks_cy.md new file mode 100644 index 000000000..585f15c10 --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_cy.md @@ -0,0 +1 @@ +Mae unrhyw bostiadau â nod tudalen yn ymddangos yma. diff --git a/defaultwelcome/help_tlbookmarks_de.md b/defaultwelcome/help_tlbookmarks_de.md new file mode 100644 index 000000000..e76f4176d --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_de.md @@ -0,0 +1 @@ +Alle mit Lesezeichen versehenen Beiträge werden hier angezeigt. diff --git a/defaultwelcome/help_tlbookmarks_en.md b/defaultwelcome/help_tlbookmarks_en.md new file mode 100644 index 000000000..829c860dc --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_en.md @@ -0,0 +1 @@ +Any bookmarked posts appear here. diff --git a/defaultwelcome/help_tlbookmarks_es.md b/defaultwelcome/help_tlbookmarks_es.md new file mode 100644 index 000000000..7554dd08c --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_es.md @@ -0,0 +1 @@ +Todas las publicaciones marcadas aparecen aquí. diff --git a/defaultwelcome/help_tlbookmarks_fr.md b/defaultwelcome/help_tlbookmarks_fr.md new file mode 100644 index 000000000..c5aeaf412 --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_fr.md @@ -0,0 +1 @@ +Tous les messages mis en signet apparaissent ici. diff --git a/defaultwelcome/help_tlbookmarks_ga.md b/defaultwelcome/help_tlbookmarks_ga.md new file mode 100644 index 000000000..ba50602c9 --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_ga.md @@ -0,0 +1 @@ +Tá aon phoist leabharmharcáilte le feiceáil anseo. diff --git a/defaultwelcome/help_tlbookmarks_hi.md b/defaultwelcome/help_tlbookmarks_hi.md new file mode 100644 index 000000000..966c32547 --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_hi.md @@ -0,0 +1 @@ +कोई भी बुकमार्क किए गए पोस्ट यहाँ दिखाई देते हैं। diff --git a/defaultwelcome/help_tlbookmarks_it.md b/defaultwelcome/help_tlbookmarks_it.md new file mode 100644 index 000000000..7554dd08c --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_it.md @@ -0,0 +1 @@ +Todas las publicaciones marcadas aparecen aquí. diff --git a/defaultwelcome/help_tlbookmarks_ja.md b/defaultwelcome/help_tlbookmarks_ja.md new file mode 100644 index 000000000..7eb249ef7 --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_ja.md @@ -0,0 +1 @@ +ブックマークされた投稿はここに表示されます。 diff --git a/defaultwelcome/help_tlbookmarks_ku.md b/defaultwelcome/help_tlbookmarks_ku.md new file mode 100644 index 000000000..5d2754907 --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_ku.md @@ -0,0 +1 @@ +Her postên nîşankirî li vir xuya dike. diff --git a/defaultwelcome/help_tlbookmarks_pt.md b/defaultwelcome/help_tlbookmarks_pt.md new file mode 100644 index 000000000..e0d7a750b --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_pt.md @@ -0,0 +1 @@ +Todas as postagens marcadas aparecem aqui. diff --git a/defaultwelcome/help_tlbookmarks_ru.md b/defaultwelcome/help_tlbookmarks_ru.md new file mode 100644 index 000000000..5f60be04d --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_ru.md @@ -0,0 +1 @@ +Здесь отображаются все сообщения, отмеченные закладками. diff --git a/defaultwelcome/help_tlbookmarks_sw.md b/defaultwelcome/help_tlbookmarks_sw.md new file mode 100644 index 000000000..5bf6f6584 --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_sw.md @@ -0,0 +1 @@ +Posts yoyote iliyoandikwa inaonekana hapa. diff --git a/defaultwelcome/help_tlbookmarks_zh.md b/defaultwelcome/help_tlbookmarks_zh.md new file mode 100644 index 000000000..70750e5f7 --- /dev/null +++ b/defaultwelcome/help_tlbookmarks_zh.md @@ -0,0 +1 @@ +任何带有书签的帖子都会显示在此处。 diff --git a/defaultwelcome/help_tlmedia_ar.md b/defaultwelcome/help_tlmedia_ar.md new file mode 100644 index 000000000..a5fdfa678 --- /dev/null +++ b/defaultwelcome/help_tlmedia_ar.md @@ -0,0 +1 @@ +ستظهر هنا أية منشورات واردة تحتوي على صور أو ملفات فيديو أو ملفات صوتية مع أوصافها. diff --git a/defaultwelcome/help_tlmedia_ca.md b/defaultwelcome/help_tlmedia_ca.md new file mode 100644 index 000000000..b48ee501e --- /dev/null +++ b/defaultwelcome/help_tlmedia_ca.md @@ -0,0 +1 @@ +Aquí apareixeran totes les publicacions entrants que continguin fitxers amb **imatges**, **vídeo** o **àudio**, juntament amb les seves descripcions. diff --git a/defaultwelcome/help_tlmedia_cy.md b/defaultwelcome/help_tlmedia_cy.md new file mode 100644 index 000000000..cc60414a8 --- /dev/null +++ b/defaultwelcome/help_tlmedia_cy.md @@ -0,0 +1 @@ +Bydd unrhyw bostiadau sy'n dod i mewn sy'n cynnwys ffeiliau **delweddau**, **fideo** neu **sain** yn ymddangos yma, ynghyd â'u disgrifiadau. diff --git a/defaultwelcome/help_tlmedia_de.md b/defaultwelcome/help_tlmedia_de.md new file mode 100644 index 000000000..b0da3e617 --- /dev/null +++ b/defaultwelcome/help_tlmedia_de.md @@ -0,0 +1 @@ +Alle eingehenden Beiträge, die **Bilder**, **Video** oder **Audiodateien** enthalten, werden hier zusammen mit ihren Beschreibungen angezeigt. diff --git a/defaultwelcome/help_tlmedia_en.md b/defaultwelcome/help_tlmedia_en.md new file mode 100644 index 000000000..b5daee390 --- /dev/null +++ b/defaultwelcome/help_tlmedia_en.md @@ -0,0 +1 @@ +Any incoming posts which contain **images**, **video** or **audio** files will appear here, together with their descriptions. diff --git a/defaultwelcome/help_tlmedia_es.md b/defaultwelcome/help_tlmedia_es.md new file mode 100644 index 000000000..db122c8e4 --- /dev/null +++ b/defaultwelcome/help_tlmedia_es.md @@ -0,0 +1 @@ +Todas las publicaciones entrantes que contengan **imágenes**, **videos** o **archivos de audio** aparecerán aquí, junto con sus descripciones. diff --git a/defaultwelcome/help_tlmedia_fr.md b/defaultwelcome/help_tlmedia_fr.md new file mode 100644 index 000000000..6c1a00287 --- /dev/null +++ b/defaultwelcome/help_tlmedia_fr.md @@ -0,0 +1 @@ +Tous les messages entrants contenant des fichiers **images**, **vidéo** ou **audio** apparaîtront ici, avec leurs descriptions. diff --git a/defaultwelcome/help_tlmedia_ga.md b/defaultwelcome/help_tlmedia_ga.md new file mode 100644 index 000000000..155b86285 --- /dev/null +++ b/defaultwelcome/help_tlmedia_ga.md @@ -0,0 +1 @@ +Beidh aon phoist ag teacht isteach ina bhfuil **íomhánna**, **físeán** nó **comhaid** fuaime le feiceáil anseo, mar aon lena gcur síos. diff --git a/defaultwelcome/help_tlmedia_hi.md b/defaultwelcome/help_tlmedia_hi.md new file mode 100644 index 000000000..c0c11e5fa --- /dev/null +++ b/defaultwelcome/help_tlmedia_hi.md @@ -0,0 +1 @@ +कोई भी आने वाली पोस्ट जिसमें **छवियां**, **वीडियो** या **ऑडियो** फाइलें यहां दिखाई देंगी, उनके विवरण के साथ। diff --git a/defaultwelcome/help_tlmedia_it.md b/defaultwelcome/help_tlmedia_it.md new file mode 100644 index 000000000..db122c8e4 --- /dev/null +++ b/defaultwelcome/help_tlmedia_it.md @@ -0,0 +1 @@ +Todas las publicaciones entrantes que contengan **imágenes**, **videos** o **archivos de audio** aparecerán aquí, junto con sus descripciones. diff --git a/defaultwelcome/help_tlmedia_ja.md b/defaultwelcome/help_tlmedia_ja.md new file mode 100644 index 000000000..d6f0b40fc --- /dev/null +++ b/defaultwelcome/help_tlmedia_ja.md @@ -0,0 +1 @@ +**画像**、**動画**、または**音声**ファイルを含む受信投稿は、説明とともにここに表示されます。 diff --git a/defaultwelcome/help_tlmedia_ku.md b/defaultwelcome/help_tlmedia_ku.md new file mode 100644 index 000000000..b8d2b92b8 --- /dev/null +++ b/defaultwelcome/help_tlmedia_ku.md @@ -0,0 +1 @@ +Mesajên hatinê yên ku pelên **nîgar**, **vîdeo** an **bihîstwerû** hene, digel vegotinên wan, dê li vir xuya bibin. diff --git a/defaultwelcome/help_tlmedia_pt.md b/defaultwelcome/help_tlmedia_pt.md new file mode 100644 index 000000000..117979191 --- /dev/null +++ b/defaultwelcome/help_tlmedia_pt.md @@ -0,0 +1 @@ +Todas as postagens recebidas que contenham arquivos de **imagens**, **vídeo** ou **áudio** aparecerão aqui, junto com suas descrições. diff --git a/defaultwelcome/help_tlmedia_ru.md b/defaultwelcome/help_tlmedia_ru.md new file mode 100644 index 000000000..408f1513f --- /dev/null +++ b/defaultwelcome/help_tlmedia_ru.md @@ -0,0 +1 @@ +Все входящие сообщения, содержащие **изображения**, **видео** или **аудио** файлы, будут отображаться здесь вместе с их описаниями. diff --git a/defaultwelcome/help_tlmedia_sw.md b/defaultwelcome/help_tlmedia_sw.md new file mode 100644 index 000000000..478476b1d --- /dev/null +++ b/defaultwelcome/help_tlmedia_sw.md @@ -0,0 +1 @@ +Machapisho yoyote yanayoingia ambayo yana **picha**, **Video** au **Audio** files itaonekana hapa, pamoja na maelezo yao. diff --git a/defaultwelcome/help_tlmedia_zh.md b/defaultwelcome/help_tlmedia_zh.md new file mode 100644 index 000000000..500679b32 --- /dev/null +++ b/defaultwelcome/help_tlmedia_zh.md @@ -0,0 +1 @@ +包含图像,视频或音频文件的所有传入帖子及其说明将显示在此处。 diff --git a/defaultwelcome/help_tlshares_ar.md b/defaultwelcome/help_tlshares_ar.md new file mode 100644 index 000000000..4f21b14e6 --- /dev/null +++ b/defaultwelcome/help_tlshares_ar.md @@ -0,0 +1,6 @@ +### العناصر المشتركة +هذه عادة أشياء مادية أو خدمات محلية ، يتم تبادلها أو منحها دون استخدام المال. + +على سبيل المثال ، قد ترغب في مشاركة المعدات بين أعضاء فريق رياضي في نفس الحالة ، أو مشاركة أي فائض من الملابس ، أو مشاركة الأدوات التي لم تعد تستخدمها ، أو مشاركة النباتات وأدوات البستنة بين الأشخاص الذين يستخدمون نفس مساحة النمو. + +لتجنب البريد العشوائي ، لا يتم توحيد العناصر المشتركة عبر ActivityPub وتكون محلية للأعضاء في نفس المثيل. diff --git a/defaultwelcome/help_tlshares_ca.md b/defaultwelcome/help_tlshares_ca.md new file mode 100644 index 000000000..f24b62281 --- /dev/null +++ b/defaultwelcome/help_tlshares_ca.md @@ -0,0 +1,6 @@ +### Elements compartits +Normalment es tracta d’objectes físics o serveis locals, intercanviats o cedits sense ús de diners. + +Per exemple, és possible que vulgueu compartir **equip** amb membres d’un equip esportiu en la mateixa instància, compartir qualsevol **vestimenta excedent**, compartir **gadgets** que ja no utilitzeu o compartir plantes i **eines** de jardineria entre persones que fan servir el mateix espai de cultiu. + +Per evitar el correu brossa, els elements compartits no estan federats mitjançant ActivityPub i són locals per als membres de la mateixa instància. diff --git a/defaultwelcome/help_tlshares_cy.md b/defaultwelcome/help_tlshares_cy.md new file mode 100644 index 000000000..55e16190d --- /dev/null +++ b/defaultwelcome/help_tlshares_cy.md @@ -0,0 +1,6 @@ +### Eitemau a rennir +Mae'r rhain fel rheol yn wrthrychau corfforol neu wasanaethau lleol, yn cael eu cyfnewid neu eu rhoi i ffwrdd heb ddefnyddio arian. + +Er enghraifft, efallai yr hoffech chi rannu **offer** rhwng aelodau tîm chwaraeon ar yr un achos, rhannu unrhyw ddillad dros ben, rhannu **teclynnau** nad ydych chi'n eu defnyddio mwyach, neu rannu planhigion a garddio **offer** rhwng pobl sy'n defnyddio'r un lle tyfu. + +Er mwyn osgoi sbam, nid yw eitemau a rennir yn cael eu ffedereiddio trwy ActivityPub ac maent yn lleol i aelodau ar yr un achos. diff --git a/defaultwelcome/help_tlshares_de.md b/defaultwelcome/help_tlshares_de.md new file mode 100644 index 000000000..d46bd9ae6 --- /dev/null +++ b/defaultwelcome/help_tlshares_de.md @@ -0,0 +1,6 @@ +### Freigegebene Elemente +Dies sind in der Regel physische Objekte oder lokale Dienste, die ohne Verwendung von Geld ausgetauscht oder verschenkt werden. + +Beispielsweise möchten Sie möglicherweise **Ausrüstung** zwischen Mitgliedern eines Sportteams auf derselben Instanz teilen, überschüssige **Kleidung** teilen, **Geräte** teilen, die Sie nicht mehr verwenden, oder Pflanzen und teilen Gartenarbeit **Werkzeuge** zwischen Menschen, die den gleichen Anbauraum nutzen. + +Um Spam zu vermeiden, werden freigegebene Elemente nicht über ActivityPub zusammengeschlossen und sind für Mitglieder derselben Instanz lokal. diff --git a/defaultwelcome/help_tlshares_en.md b/defaultwelcome/help_tlshares_en.md new file mode 100644 index 000000000..e984d407c --- /dev/null +++ b/defaultwelcome/help_tlshares_en.md @@ -0,0 +1,6 @@ +### Shared items +These are typically physical objects or local services, exchanged or given away without use of money. + +For example, you may want to share **equipment** between members of a sports team on the same instance, share any surplus **clothing**, share **gadgets** which you are no longer using, or share plants and gardening **tools** between people using the same growing space. + +To avoid spam, shared items are not federated via ActivityPub and are local to members on the same instance. diff --git a/defaultwelcome/help_tlshares_es.md b/defaultwelcome/help_tlshares_es.md new file mode 100644 index 000000000..494ce5fa2 --- /dev/null +++ b/defaultwelcome/help_tlshares_es.md @@ -0,0 +1,6 @@ +### Elementos compartidos +Por lo general, son objetos físicos o servicios locales, intercambiados o regalados sin usar dinero. + +Por ejemplo, es posible que desee compartir **equipo** entre miembros de un equipo deportivo en la misma instancia, compartir cualquier **ropa** sobrante, compartir **gadgets** que ya no usa o compartir plantas y **herramientas** de jardinería entre personas que utilizan el mismo espacio de cultivo. + +Para evitar el spam, los elementos compartidos no están federados a través de ActivityPub y son locales para los miembros de la misma instancia. diff --git a/defaultwelcome/help_tlshares_fr.md b/defaultwelcome/help_tlshares_fr.md new file mode 100644 index 000000000..4702cf0b8 --- /dev/null +++ b/defaultwelcome/help_tlshares_fr.md @@ -0,0 +1,6 @@ +### Éléments partagés +Ce sont généralement des objets physiques ou des services locaux, échangés ou donnés sans utilisation d'argent. + +Par exemple, vous souhaiterez peut-être partager **l'équipement** entre les membres d'une équipe sportive sur la même instance, partager les **vêtements** excédentaires, partager **gadgets** que vous n'utilisez plus, ou partager des plantes et jardinage **outils** entre personnes utilisant le même espace de culture. + +Pour éviter le spam, les éléments partagés ne sont pas fédérés via ActivityPub et sont locaux pour les membres de la même instance. diff --git a/defaultwelcome/help_tlshares_ga.md b/defaultwelcome/help_tlshares_ga.md new file mode 100644 index 000000000..f65dadc27 --- /dev/null +++ b/defaultwelcome/help_tlshares_ga.md @@ -0,0 +1,6 @@ +### Míreanna roinnte +Go hiondúil is earraí fisiciúla nó seirbhísí áitiúla iad seo, a mhalartaítear nó a thugtar ar shiúl gan airgead a úsáid. + +Mar shampla, b’fhéidir gur mhaith leat **trealamh** a roinnt idir baill d’fhoireann spóirt ar an gcaoi chéanna, aon éadaí breise a roinnt, **giuirléidí** nach bhfuil á n-úsáid agat a thuilleadh, nó plandaí a roinnt agus garraíodóireacht **uirlisí** idir daoine a úsáideann an spás fáis céanna. + +Chun turscar a sheachaint, ní dhéantar míreanna comhroinnte a chónaidhmeadh trí ActivityPub agus tá siad áitiúil do bhaill ar an gcaoi chéanna. diff --git a/defaultwelcome/help_tlshares_hi.md b/defaultwelcome/help_tlshares_hi.md new file mode 100644 index 000000000..0ab4b88d0 --- /dev/null +++ b/defaultwelcome/help_tlshares_hi.md @@ -0,0 +1,6 @@ +### साझा किए गए आइटम +ये आम तौर पर भौतिक वस्तुएं या स्थानीय सेवाएं हैं, जिनका उपयोग या धन के उपयोग के बिना दिया जाता है। + +उदाहरण के लिए, आप एक ही उदाहरण पर एक स्पोर्ट्स टीम के सदस्यों के बीच **उपकरण** साझा करना चाहते हैं, किसी भी अधिशेष **कपड़े**, शेयर **गैजेट्स** का उपयोग कर सकते हैं जो आप अब उपयोग नहीं कर रहे हैं, या पौधों और साझा करें बागवानी **उपकरण** एक ही बढ़ते स्थान का उपयोग करने वाले लोगों के बीच। + +स्पैम से बचने के लिए, साझा किए गए आइटम एक्टिविटीपब के माध्यम से फेड नहीं किए जाते हैं और एक ही उदाहरण पर सदस्यों के लिए स्थानीय होते हैं। diff --git a/defaultwelcome/help_tlshares_it.md b/defaultwelcome/help_tlshares_it.md new file mode 100644 index 000000000..494ce5fa2 --- /dev/null +++ b/defaultwelcome/help_tlshares_it.md @@ -0,0 +1,6 @@ +### Elementos compartidos +Por lo general, son objetos físicos o servicios locales, intercambiados o regalados sin usar dinero. + +Por ejemplo, es posible que desee compartir **equipo** entre miembros de un equipo deportivo en la misma instancia, compartir cualquier **ropa** sobrante, compartir **gadgets** que ya no usa o compartir plantas y **herramientas** de jardinería entre personas que utilizan el mismo espacio de cultivo. + +Para evitar el spam, los elementos compartidos no están federados a través de ActivityPub y son locales para los miembros de la misma instancia. diff --git a/defaultwelcome/help_tlshares_ja.md b/defaultwelcome/help_tlshares_ja.md new file mode 100644 index 000000000..e71b23f32 --- /dev/null +++ b/defaultwelcome/help_tlshares_ja.md @@ -0,0 +1,6 @@ +### 共有アイテム +これらは通常、物理的なオブジェクトまたはローカルサービスであり、お金を使用せずに交換または配布されます。 + +たとえば、同じインスタンスのスポーツチームのメンバー間で**機器**を共有したり、余った**衣類**を共有したり、使用しなくなった**ガジェット**を共有したり、植物を共有したりすることができます。 同じ栽培スペースを使用する人々の間のガーデニング**ツール**。 + +スパムを回避するために、共有アイテムはActivityPubを介してフェデレーションされず、同じインスタンスのメンバーに対してローカルになります。 diff --git a/defaultwelcome/help_tlshares_ku.md b/defaultwelcome/help_tlshares_ku.md new file mode 100644 index 000000000..b29a191c8 --- /dev/null +++ b/defaultwelcome/help_tlshares_ku.md @@ -0,0 +1,6 @@ +### Tiştên hevpar +Vana bi gelemperî tiştên fîzîkî an karûbarên herêmî ne, bêyî karanîna drav têne guhertin an têne dayîn. + +Mînakî, dibe ku hûn bixwazin **alavên** di navbera endamên tîmek werzîşê de li ser eynî mînakê parve bikin, cilûbergên zêde parve bikin, **alavên** ên ku hûn êdî naxwazin parve bikin, an nebat û baxçevanî **amûrên** di navbera mirovan de heman cîhê mezinbûnê bikar tînin. + +Ji bo ku spam dernekeve, tiştên parvekirî bi navgîniya ActivityPub nayên federasyon kirin û ji endamên li ser heman mîsalê re herêmî ne. diff --git a/defaultwelcome/help_tlshares_pt.md b/defaultwelcome/help_tlshares_pt.md new file mode 100644 index 000000000..e380e61d5 --- /dev/null +++ b/defaultwelcome/help_tlshares_pt.md @@ -0,0 +1,6 @@ +### Itens compartilhados +Normalmente são objetos físicos ou serviços locais, trocados ou doados sem uso de dinheiro. + +Por exemplo, você pode querer compartilhar **equipamentos** entre membros de uma equipe esportiva na mesma instância, compartilhar qualquer excesso de **roupas**, compartilhar **gadgets** que não está mais usando ou compartilhar plantas e **ferramentas** de jardinagem entre pessoas que usam o mesmo espaço de cultivo. + +Para evitar spam, os itens compartilhados não são federados via ActivityPub e são locais para membros na mesma instância. diff --git a/defaultwelcome/help_tlshares_ru.md b/defaultwelcome/help_tlshares_ru.md new file mode 100644 index 000000000..0824caeb9 --- /dev/null +++ b/defaultwelcome/help_tlshares_ru.md @@ -0,0 +1,6 @@ +### Общие элементы +Как правило, это физические объекты или местные услуги, которые можно обменять или передать без использования денег. + +Например, вы можете поделиться **оборудованием** между членами спортивной команды в одном экземпляре, поделиться любой излишней **одеждой**, поделиться **гаджетами**, которые вы больше не используете, или поделиться растениями и садовые **инструменты** между людьми, использующими одно и то же место для выращивания. + +Во избежание спама общие элементы не объединяются через ActivityPub и являются локальными для участников одного и того же экземпляра. diff --git a/defaultwelcome/help_tlshares_sw.md b/defaultwelcome/help_tlshares_sw.md new file mode 100644 index 000000000..7cfb8f344 --- /dev/null +++ b/defaultwelcome/help_tlshares_sw.md @@ -0,0 +1,6 @@ +### Vipengee vya pamoja +Hizi ni vitu vya kimwili au huduma za mitaa, kubadilishana au kutolewa bila matumizi ya pesa. + +Kwa mfano, unaweza kutaka kushiriki **vifaa** kati ya wanachama wa timu ya michezo kwa mfano huo, kushiriki yoyote ya ziada **nguo**, kushiriki **Gadgets** ambayo hutumii tena, au kushiriki mimea na Kupalilia **Vyombo** kati ya watu kutumia nafasi sawa ya kukua. + +Ili kuepuka spam, vitu vya pamoja hazifanyika kupitia shughuli za shughuli na ni za mitaa kwa wanachama sawa. diff --git a/defaultwelcome/help_tlshares_zh.md b/defaultwelcome/help_tlshares_zh.md new file mode 100644 index 000000000..96a2cfd97 --- /dev/null +++ b/defaultwelcome/help_tlshares_zh.md @@ -0,0 +1,6 @@ +### 共享的项目 +这些通常是实物或本地服务,无需花费即可交换或赠予。 + +例如,您可能想在同一实例的运动队成员之间共享设备,共享任何多余的衣服,共享不再使用的小工具或在使用相同增长空间的人们之间共享植物和园艺工具。 + +为了避免垃圾邮件,共享项不会通过ActivityPub联合,并且对于同一实例的成员而言是本地的。 diff --git a/defaultwelcome/profile_ar.md b/defaultwelcome/profile_ar.md new file mode 100644 index 000000000..0d0ed4ab9 --- /dev/null +++ b/defaultwelcome/profile_ar.md @@ -0,0 +1,2 @@ +### اعدادات الحساب +حدد صورتك الرمزية وأضف اسمك ووصفك. استخدم صورة رمزية صغيرة (على سبيل المثال ، 128 × 128 بكسل) بحيث يمكن تنزيلها بسرعة. diff --git a/defaultwelcome/profile_ca.md b/defaultwelcome/profile_ca.md new file mode 100644 index 000000000..90c5d6c85 --- /dev/null +++ b/defaultwelcome/profile_ca.md @@ -0,0 +1,2 @@ +### Configuració del compte +Seleccioneu la vostra imatge d’avatar i afegiu el vostre nom i la vostra descripció. Utilitzeu una imatge d’avatar petita (per exemple, 128x128 píxels) per baixar-la ràpidament. diff --git a/defaultwelcome/profile_cy.md b/defaultwelcome/profile_cy.md new file mode 100644 index 000000000..92ced863e --- /dev/null +++ b/defaultwelcome/profile_cy.md @@ -0,0 +1,2 @@ +### Gosod Cyfrif +Dewiswch eich delwedd avatar ac ychwanegwch eich enw a'ch disgrifiad. Defnyddiwch ddelwedd avatar fach (ee 128x128 picsel) fel ei bod yn gyflym i'w lawrlwytho. diff --git a/defaultwelcome/profile_de.md b/defaultwelcome/profile_de.md new file mode 100644 index 000000000..668da69ce --- /dev/null +++ b/defaultwelcome/profile_de.md @@ -0,0 +1,2 @@ +### Kontoeinrichtung +Wählen Sie Ihr Avatar-Bild aus und fügen Sie Ihren Namen und Ihre Beschreibung hinzu. Verwenden Sie ein kleines Avatar-Bild (z. B. 128 x 128 Pixel), damit es schnell heruntergeladen werden kann. diff --git a/defaultwelcome/profile_en.md b/defaultwelcome/profile_en.md new file mode 100644 index 000000000..2a1768332 --- /dev/null +++ b/defaultwelcome/profile_en.md @@ -0,0 +1,2 @@ +### Account 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/defaultwelcome/profile_es.md b/defaultwelcome/profile_es.md new file mode 100644 index 000000000..63bac75b9 --- /dev/null +++ b/defaultwelcome/profile_es.md @@ -0,0 +1,2 @@ +### Configuracion de cuenta +Seleccione su imagen de avatar y agregue su nombre y descripción. Utilice una imagen de avatar pequeña (por ejemplo, 128x128 píxeles) para que se descargue rápidamente. diff --git a/defaultwelcome/profile_fr.md b/defaultwelcome/profile_fr.md new file mode 100644 index 000000000..56f93d648 --- /dev/null +++ b/defaultwelcome/profile_fr.md @@ -0,0 +1,2 @@ +### Configuration du compte +Sélectionnez l'image de votre avatar et ajoutez votre nom et votre description. Utilisez une petite image d'avatar (par exemple, 128x128 pixels) pour un téléchargement rapide. diff --git a/defaultwelcome/profile_ga.md b/defaultwelcome/profile_ga.md new file mode 100644 index 000000000..b0019e644 --- /dev/null +++ b/defaultwelcome/profile_ga.md @@ -0,0 +1,2 @@ +### Socrú Cuntas +Roghnaigh d’íomhá avatar agus cuir d’ainm agus do thuairisc leis. Úsáid íomhá bheag avatar (m.sh. 128x128 picteilín) ionas go mbeidh sí gasta le híoslódáil. diff --git a/defaultwelcome/profile_hi.md b/defaultwelcome/profile_hi.md new file mode 100644 index 000000000..5190b999d --- /dev/null +++ b/defaultwelcome/profile_hi.md @@ -0,0 +1,2 @@ +### खाता स्थापित करना +अपनी अवतार छवि का चयन करें और अपना नाम और विवरण जोड़ें। एक छोटी अवतार छवि (जैसे। 128x128 पिक्सेल) का उपयोग करें ताकि यह डाउनलोड करने में तेज हो। diff --git a/defaultwelcome/profile_it.md b/defaultwelcome/profile_it.md new file mode 100644 index 000000000..97487bc62 --- /dev/null +++ b/defaultwelcome/profile_it.md @@ -0,0 +1,2 @@ +### Configurazione dell'account +Seleziona la tua immagine avatar e aggiungi il tuo nome e la descrizione. Usa una piccola immagine avatar (es. 128x128 pixel) in modo che sia veloce da scaricare. diff --git a/defaultwelcome/profile_ja.md b/defaultwelcome/profile_ja.md new file mode 100644 index 000000000..d63a80d55 --- /dev/null +++ b/defaultwelcome/profile_ja.md @@ -0,0 +1,2 @@ +### アカウントの設定 +アバター画像を選択し、名前と説明を追加します。 小さなアバター画像(128x128ピクセルなど)を使用して、すばやくダウンロードできるようにします。 diff --git a/defaultwelcome/profile_ku.md b/defaultwelcome/profile_ku.md new file mode 100644 index 000000000..00ae7ece3 --- /dev/null +++ b/defaultwelcome/profile_ku.md @@ -0,0 +1,2 @@ +### Sazkirina Hesabê +Wêneya avatar-a xwe hilbijêrin û nav û danasîna xwe lê zêde bikin. Wêneyek avatar-a piçûk bikar bînin (mînak 128x128 pixel) da ku zû were dakêşandin. diff --git a/defaultwelcome/profile_oc.md b/defaultwelcome/profile_oc.md new file mode 100644 index 000000000..10d1e09e3 --- /dev/null +++ b/defaultwelcome/profile_oc.md @@ -0,0 +1,2 @@ +## Account 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/defaultwelcome/profile_pt.md b/defaultwelcome/profile_pt.md new file mode 100644 index 000000000..9371c6ad3 --- /dev/null +++ b/defaultwelcome/profile_pt.md @@ -0,0 +1,2 @@ +## Configuração da conta +Selecione sua imagem de avatar e adicione seu nome e descrição. Use uma pequena imagem de avatar (por exemplo, 128x128 pixels) para que o download seja rápido. diff --git a/defaultwelcome/profile_ru.md b/defaultwelcome/profile_ru.md new file mode 100644 index 000000000..b0d53a568 --- /dev/null +++ b/defaultwelcome/profile_ru.md @@ -0,0 +1,2 @@ +### Настройка учетной записи +Выберите изображение своего аватара и добавьте свое имя и описание. Используйте небольшое изображение аватара (например, 128x128 пикселей), чтобы его можно было быстро загрузить. diff --git a/defaultwelcome/profile_sw.md b/defaultwelcome/profile_sw.md new file mode 100644 index 000000000..dbaa3aba3 --- /dev/null +++ b/defaultwelcome/profile_sw.md @@ -0,0 +1,2 @@ +### Kuanzisha akaunti +Chagua picha yako ya avatar na uongeze jina lako na maelezo. Tumia picha ndogo ya avatar (kwa mfano 128x128 saizi) ili iwe haraka kupakua. diff --git a/defaultwelcome/profile_zh.md b/defaultwelcome/profile_zh.md new file mode 100644 index 000000000..33a1b0a6d --- /dev/null +++ b/defaultwelcome/profile_zh.md @@ -0,0 +1,2 @@ +### 帐户设定 +选择您的头像图片并添加您的姓名和描述。 使用较小的头像图片(例如128x128像素),以便快速下载。 diff --git a/defaultwelcome/welcome_ar.md b/defaultwelcome/welcome_ar.md new file mode 100644 index 000000000..8d7170c4e --- /dev/null +++ b/defaultwelcome/welcome_ar.md @@ -0,0 +1,7 @@ +![مرحبا الصورة](/helpimages/welcome.jpg) +### مرحبًا بكم في INSTANCE +هذا خادم ActivityPub مصمم للاستضافة الذاتية السهلة لعدد قليل من الأشخاص على أنظمة منخفضة الطاقة ، مثل أجهزة الكمبيوتر ذات اللوحة الواحدة أو أجهزة الكمبيوتر المحمولة القديمة. + +قم بتشغيل وجودك على الشبكة الاجتماعية بالطريقة التي تريدها ، وداعًا لشركة Big Tech. + +الآن ، لنبدأ ... diff --git a/defaultwelcome/welcome_ca.md b/defaultwelcome/welcome_ca.md new file mode 100644 index 000000000..ada6129ed --- /dev/null +++ b/defaultwelcome/welcome_ca.md @@ -0,0 +1,7 @@ +![Imatge de benvinguda](/helpimages/welcome.jpg) +### Benvingut a INSTANCE +Es tracta d’un servidor ActivityPub dissenyat per allotjar fàcilment algunes persones en sistemes de poca potència, com ara ordinadors de placa única o portàtils antics. + +Gestioneu la vostra pròpia presència a la xarxa social com vulgueu i acomiadeu-vos de Big Tech. + +Ara, comencem ... diff --git a/defaultwelcome/welcome_cy.md b/defaultwelcome/welcome_cy.md new file mode 100644 index 000000000..cb4530faa --- /dev/null +++ b/defaultwelcome/welcome_cy.md @@ -0,0 +1,7 @@ +![Delwedd groeso](/helpimages/welcome.jpg) +### Croeso i INSTANCE +Gweinydd ActivityPub yw hwn sydd wedi'i gynllunio ar gyfer hunan-letya ychydig o bobl ar systemau pŵer isel yn hawdd, fel cyfrifiaduron bwrdd sengl neu hen gliniaduron. + +Rhedeg eich presenoldeb rhwydwaith cymdeithasol eich hun yn y ffordd rydych chi eisiau, a ffarwelio â Big Tech. + +Nawr, gadewch i ni fynd ... diff --git a/defaultwelcome/welcome_de.md b/defaultwelcome/welcome_de.md new file mode 100644 index 000000000..c6f8e1ac7 --- /dev/null +++ b/defaultwelcome/welcome_de.md @@ -0,0 +1,7 @@ +![Willkommensbild](/helpimages/welcome.jpg) +### Willkommen bei INSTANCE +Dies ist ein ActivityPub-Server, der für das einfache Selbsthosting einiger weniger Personen auf Systemen mit geringem Stromverbrauch wie Single-Board-Computern oder alten Laptops entwickelt wurde. + +Führen Sie Ihre eigene soziale Netzwerkpräsenz so, wie Sie möchten, und verabschieden Sie sich von Big Tech. + +Jetzt geht's los ... diff --git a/defaultwelcome/welcome_en.md b/defaultwelcome/welcome_en.md new file mode 100644 index 000000000..1d1e8935f --- /dev/null +++ b/defaultwelcome/welcome_en.md @@ -0,0 +1,7 @@ +![Welcome image](/helpimages/welcome.jpg) +### Welcome to INSTANCE +This is an ActivityPub server designed for easy self-hosting of a few people on low power systems, such as single board computers or old laptops. + +Run your own social network presence the way you want to, and say goodbye to Big Tech. + +Now, lets get going... diff --git a/defaultwelcome/welcome_es.md b/defaultwelcome/welcome_es.md new file mode 100644 index 000000000..0c1474dc1 --- /dev/null +++ b/defaultwelcome/welcome_es.md @@ -0,0 +1,7 @@ +![Imagen de bienvenida](/helpimages/welcome.jpg) +### Bienvenido a INSTANCE +Este es un servidor ActivityPub diseñado para el autohospedaje sencillo de algunas personas en sistemas de bajo consumo de energía, como computadoras de placa única o laptops antiguas. + +Ejecute su propia presencia en las redes sociales de la forma que desee y despídase de las grandes tecnologías. + +Ahora, vayamos ... diff --git a/defaultwelcome/welcome_fr.md b/defaultwelcome/welcome_fr.md new file mode 100644 index 000000000..7f6610630 --- /dev/null +++ b/defaultwelcome/welcome_fr.md @@ -0,0 +1,7 @@ +![Image de bienvenue](/helpimages/welcome.jpg) +### Bienvenue à INSTANCE +Il s'agit d'un serveur ActivityPub conçu pour l'auto-hébergement facile de quelques personnes sur des systèmes à faible consommation d'énergie, tels que des ordinateurs monocarte ou d'anciens ordinateurs portables. + +Gérez votre propre présence sur les réseaux sociaux comme vous le souhaitez et dites au revoir à Big Tech. + +Maintenant, allons-y ... diff --git a/defaultwelcome/welcome_ga.md b/defaultwelcome/welcome_ga.md new file mode 100644 index 000000000..91dc36d64 --- /dev/null +++ b/defaultwelcome/welcome_ga.md @@ -0,0 +1,7 @@ +![Íomhá fáilte](/helpimages/welcome.jpg) +### Fáilte go INSTANCE +Is freastalaí ActivityPub é seo atá deartha chun féin-óstáil éasca a dhéanamh ar chúpla duine ar chórais ísealchumhachta, mar ríomhairí boird aonair nó sean ríomhairí glúine. + +Rith do láithreacht líonra sóisialta féin ar an mbealach is mian leat, agus slán a fhágáil le Big Tech. + +Anois, ligeann duit dul ... diff --git a/defaultwelcome/welcome_hi.md b/defaultwelcome/welcome_hi.md new file mode 100644 index 000000000..fcffdfabd --- /dev/null +++ b/defaultwelcome/welcome_hi.md @@ -0,0 +1,7 @@ +![स्वागत है छवि](/helpimages/welcome.jpg) +### INSTANCE पर आपका स्वागत है +यह एक एक्टिविटीपब सर्वर है जो कम पावर सिस्टम पर सिंगल बोर्ड कंप्यूटर या पुराने लैपटॉप जैसे कुछ लोगों की आसान सेल्फ-होस्टिंग के लिए बनाया गया है। + +जिस तरह से आप चाहते हैं, अपने खुद के सोशल नेटवर्क उपस्थिति को चलाएं और बिग टेक को अलविदा कहें। + +अब, चल रहा है ... diff --git a/defaultwelcome/welcome_it.md b/defaultwelcome/welcome_it.md new file mode 100644 index 000000000..b74a1e053 --- /dev/null +++ b/defaultwelcome/welcome_it.md @@ -0,0 +1,7 @@ +![Immagine di benvenuto](/helpimages/welcome.jpg) +### Benvenuto in INSTANCE +Questo è un server ActivityPub progettato per un facile self-hosting di poche persone su sistemi a basso consumo, come computer a scheda singola o vecchi laptop. + +Gestisci la tua presenza sui social network come preferisci e saluta Big Tech. + +Ora andiamo ... diff --git a/defaultwelcome/welcome_ja.md b/defaultwelcome/welcome_ja.md new file mode 100644 index 000000000..dd26aa2ba --- /dev/null +++ b/defaultwelcome/welcome_ja.md @@ -0,0 +1,7 @@ +![ウェルカムイメージ](/helpimages/welcome.jpg) +### INSTANCEへようこそ +これは、シングルボードコンピューターや古いラップトップなどの低電力システムで数人を簡単にセルフホスティングするために設計されたActivityPubサーバーです。 + +独自のソーシャルネットワークプレゼンスを希望どおりに実行し、BigTechに別れを告げます。 + +さあ、始めましょう... diff --git a/defaultwelcome/welcome_ku.md b/defaultwelcome/welcome_ku.md new file mode 100644 index 000000000..406a35f4a --- /dev/null +++ b/defaultwelcome/welcome_ku.md @@ -0,0 +1,7 @@ +![Wêneyê xêrhatinê](/helpimages/welcome.jpg) +### Hûn bi xêr hatin BELAV +Ev serverek ActivityPub-ê ye ku ji bo xwe-mêvandariya hêsan a çend kesan li ser pergalên hêza kêm, wekî komputerên yekane an laptopên kevn, hatî çêkirin. + +Hebûna torgiloka xweya civakî bi awayê ku hûn dixwazin bimeşînin, û xatir ji Big Tech dixwazin. + +Naha, ka em biçin... diff --git a/defaultwelcome/welcome_oc.md b/defaultwelcome/welcome_oc.md new file mode 100644 index 000000000..520d07387 --- /dev/null +++ b/defaultwelcome/welcome_oc.md @@ -0,0 +1,7 @@ +![Welcome image](/helpimages/welcome.jpg) +### Welcome +Epicyon is an ActivityPub server designed for easy self-hosting of a few people on low power systems, such as single board computers or old laptops. + +Run your own social network presence the way you want to, and say goodbye to Big Tech. + +Now, lets get going... diff --git a/defaultwelcome/welcome_pt.md b/defaultwelcome/welcome_pt.md new file mode 100644 index 000000000..e0193f6a2 --- /dev/null +++ b/defaultwelcome/welcome_pt.md @@ -0,0 +1,7 @@ +![Imagem de boas-vindas](/helpimages/welcome.jpg) +### Bem-vindo a INSTANCE +Este é um servidor ActivityPub projetado para fácil auto-hospedagem de algumas pessoas em sistemas de baixo consumo de energia, como computadores de placa única ou laptops antigos. + +Administre sua própria presença na rede social do jeito que você quiser e diga adeus à Big Tech. + +Agora, vamos indo ... diff --git a/defaultwelcome/welcome_ru.md b/defaultwelcome/welcome_ru.md new file mode 100644 index 000000000..857581ef4 --- /dev/null +++ b/defaultwelcome/welcome_ru.md @@ -0,0 +1,7 @@ +![Приветственное изображение](/helpimages/welcome.jpg) +### Добро пожаловать в INSTANCE +Это сервер ActivityPub, предназначенный для простого самостоятельного размещения нескольких человек в системах с низким энергопотреблением, таких как одноплатные компьютеры или старые ноутбуки. + +Управляйте своим присутствием в социальных сетях так, как вы хотите, и попрощайтесь с Big Tech. + +А теперь поехали ... diff --git a/defaultwelcome/welcome_sw.md b/defaultwelcome/welcome_sw.md new file mode 100644 index 000000000..e13b0a1a3 --- /dev/null +++ b/defaultwelcome/welcome_sw.md @@ -0,0 +1,7 @@ +![Karibu picha](/helpimages/welcome.jpg) +### Karibu kwa INSTANCE +Huu ni seva ya shughuliPub iliyoundwa kwa ajili ya kujitegemea kwa watu wachache kwenye mifumo ya chini ya nguvu, kama vile kompyuta moja ya bodi au laptops ya zamani. + +Tumia uwepo wako wa kijamii uwepo kwa njia unayotaka, na sema kwaheri kwa teknolojia kubwa. + +Sasa, inakuwezesha kwenda... diff --git a/defaultwelcome/welcome_zh.md b/defaultwelcome/welcome_zh.md new file mode 100644 index 000000000..1f9a62db6 --- /dev/null +++ b/defaultwelcome/welcome_zh.md @@ -0,0 +1,7 @@ +![欢迎图片](/helpimages/welcome.jpg) +### 欢迎来到INSTANCE +这是一个ActivityPub服务器,设计用于在低功耗系统(例如单板计算机或旧笔记本电脑)上轻松实现一些人的自我托管。 + +随心所欲地经营自己的社交网络,并与Big Tech道别。 + +现在,开始吧... diff --git a/delete.py b/delete.py index 8d8d0b37a..834e8c46e 100644 --- a/delete.py +++ b/delete.py @@ -5,9 +5,11 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "ActivityPub" import os from datetime import datetime +from utils import removeDomainPort from utils import hasUsersPath from utils import getFullDomain from utils import removeIdEnding @@ -55,14 +57,14 @@ def sendDeleteViaServer(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) + print('DEBUG: delete 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: delete webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -76,11 +78,12 @@ def sendDeleteViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: delete no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: delete no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -91,10 +94,11 @@ def sendDeleteViaServer(baseDir: str, session, 'Authorization': authHeader } postResult = \ - postJson(session, newDeleteJson, [], inboxUrl, headers) + postJson(httpPrefix, fromDomainFull, + session, newDeleteJson, [], inboxUrl, headers, 3, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST delete failed for c2s to ' + inboxUrl) return 5 if debug: @@ -151,8 +155,7 @@ def outboxDelete(baseDir: str, httpPrefix: str, "wasn't created by you (nickname does not match)") return deleteDomain, deletePort = getDomainFromActor(messageId) - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domain) if deleteDomain != domain: if debug: print("DEBUG: you can't delete a post which " + diff --git a/desktop_client.py b/desktop_client.py new file mode 100644 index 000000000..0e2b0bd85 --- /dev/null +++ b/desktop_client.py @@ -0,0 +1,2411 @@ +__filename__ = "desktop_client.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@freedombone.net" +__status__ = "Production" +__module_group__ = "Client" + +import os +import html +import time +import sys +import select +import webbrowser +import urllib.parse +from pathlib import Path +from random import randint +from utils import hasObjectDict +from utils import getFullDomain +from utils import isDM +from utils import loadTranslationsFromFile +from utils import removeHtml +from utils import getNicknameFromActor +from utils import getDomainFromActor +from utils import isPGPEncrypted +from session import createSession +from speaker import speakableText +from speaker import getSpeakerPitch +from speaker import getSpeakerRate +from speaker import getSpeakerRange +from like import sendLikeViaServer +from like import sendUndoLikeViaServer +from follow import approveFollowRequestViaServer +from follow import denyFollowRequestViaServer +from follow import getFollowRequestsViaServer +from follow import getFollowingViaServer +from follow import getFollowersViaServer +from follow import sendFollowRequestViaServer +from follow import sendUnfollowRequestViaServer +from posts import sendBlockViaServer +from posts import sendUndoBlockViaServer +from posts import sendMuteViaServer +from posts import sendUndoMuteViaServer +from posts import sendPostViaServer +from posts import c2sBoxJson +from posts import downloadAnnounce +from announce import sendAnnounceViaServer +from announce import sendUndoAnnounceViaServer +from pgp import pgpLocalPublicKey +from pgp import pgpDecrypt +from pgp import hasLocalPGPkey +from pgp import pgpEncryptToActor +from pgp import pgpPublicKeyUpload +from like import noOfLikes +from bookmarks import sendBookmarkViaServer +from bookmarks import sendUndoBookmarkViaServer +from delete import sendDeleteViaServer +from person import getActorJson + + +def _desktopHelp() -> None: + """Shows help + """ + _desktopClearScreen() + indent = ' ' + print('') + print(indent + _highlightText('Help Commands:')) + print('') + print(indent + 'quit ' + + 'Exit from the desktop client') + print(indent + 'show dm|sent|inbox|replies|bookmarks ' + + 'Show a timeline') + print(indent + 'mute ' + + 'Turn off the screen reader') + print(indent + 'speak ' + + 'Turn on the screen reader') + print(indent + 'sounds on ' + + 'Turn on notification sounds') + print(indent + 'sounds off ' + + 'Turn off notification sounds') + print(indent + 'rp ' + + 'Repeat the last post') + print(indent + 'like ' + + 'Like the last post') + print(indent + 'unlike ' + + 'Unlike the last post') + print(indent + 'bookmark ' + + 'Bookmark the last post') + print(indent + 'unbookmark ' + + 'Unbookmark the last post') + print(indent + 'block [post number|handle] ' + + 'Block someone via post number or handle') + print(indent + 'unblock [handle] ' + + 'Unblock someone') + print(indent + 'mute ' + + 'Mute the last post') + print(indent + 'unmute ' + + 'Unmute the last post') + print(indent + 'reply ' + + 'Reply to the last post') + print(indent + 'post ' + + 'Create a new post') + print(indent + 'post to [handle] ' + + 'Create a new direct message') + print(indent + 'announce/boost ' + + 'Boost the last post') + print(indent + 'follow [handle] ' + + 'Make a follow request') + print(indent + 'unfollow [handle] ' + + 'Stop following the give handle') + print(indent + 'next ' + + 'Next page in the timeline') + print(indent + 'prev ' + + 'Previous page in the timeline') + print(indent + 'read [post number] ' + + 'Read a post from a timeline') + print(indent + 'open [post number] ' + + 'Open web links within a timeline post') + print(indent + 'profile [post number or handle] ' + + 'Show profile for the person who made the given post') + print(indent + 'following [page number] ' + + 'Show accounts that you are following') + print(indent + 'followers [page number] ' + + 'Show accounts that are following you') + print(indent + 'approve [handle] ' + + 'Approve a follow request') + print(indent + 'deny [handle] ' + + 'Deny a follow request') + print(indent + 'pgp ' + + 'Show your PGP public key') + print('') + + +def _createDesktopConfig(actor: str) -> None: + """Sets up directories for desktop client configuration + """ + homeDir = str(Path.home()) + if not os.path.isdir(homeDir + '/.config'): + os.mkdir(homeDir + '/.config') + if not os.path.isdir(homeDir + '/.config/epicyon'): + os.mkdir(homeDir + '/.config/epicyon') + nickname = getNicknameFromActor(actor) + domain, port = getDomainFromActor(actor) + handle = nickname + '@' + domain + if port != 443 and port != 80: + handle += '_' + str(port) + readPostsDir = homeDir + '/.config/epicyon/' + handle + if not os.path.isdir(readPostsDir): + os.mkdir(readPostsDir) + + +def _markPostAsRead(actor: str, postId: str, postCategory: str) -> None: + """Marks the given post as read by the given actor + """ + homeDir = str(Path.home()) + _createDesktopConfig(actor) + nickname = getNicknameFromActor(actor) + domain, port = getDomainFromActor(actor) + handle = nickname + '@' + domain + if port != 443 and port != 80: + handle += '_' + str(port) + readPostsDir = homeDir + '/.config/epicyon/' + handle + readPostsFilename = readPostsDir + '/' + postCategory + '.txt' + if os.path.isfile(readPostsFilename): + if postId in open(readPostsFilename).read(): + return + try: + # prepend to read posts file + postId += '\n' + with open(readPostsFilename, 'r+') as readFile: + content = readFile.read() + if postId not in content: + readFile.seek(0, 0) + readFile.write(postId + content) + except Exception as e: + print('WARN: Failed to mark post as read' + str(e)) + else: + with open(readPostsFilename, 'w+') as readFile: + readFile.write(postId + '\n') + + +def _hasReadPost(actor: str, postId: str, postCategory: str) -> bool: + """Returns true if the given post has been read by the actor + """ + homeDir = str(Path.home()) + _createDesktopConfig(actor) + nickname = getNicknameFromActor(actor) + domain, port = getDomainFromActor(actor) + handle = nickname + '@' + domain + if port != 443 and port != 80: + handle += '_' + str(port) + readPostsDir = homeDir + '/.config/epicyon/' + handle + readPostsFilename = readPostsDir + '/' + postCategory + '.txt' + if os.path.isfile(readPostsFilename): + if postId in open(readPostsFilename).read(): + return True + return False + + +def _postIsToYou(actor: str, postJsonObject: {}) -> bool: + """Returns true if the post is to the actor + """ + toYourActor = False + if postJsonObject.get('to'): + if actor in postJsonObject['to']: + toYourActor = True + if not toYourActor and postJsonObject.get('cc'): + if actor in postJsonObject['cc']: + toYourActor = True + if not toYourActor and hasObjectDict(postJsonObject): + if postJsonObject['object'].get('to'): + if actor in postJsonObject['object']['to']: + toYourActor = True + if not toYourActor and postJsonObject['object'].get('cc'): + if actor in postJsonObject['object']['cc']: + toYourActor = True + return toYourActor + + +def _newDesktopNotifications(actor: str, inboxJson: {}, + notifyJson: {}) -> None: + """Looks for changes in the inbox and adds notifications + """ + notifyJson['dmNotifyChanged'] = False + notifyJson['repliesNotifyChanged'] = False + if not inboxJson: + return + if not inboxJson.get('orderedItems'): + return + DMdone = False + replyDone = False + for postJsonObject in inboxJson['orderedItems']: + if not postJsonObject.get('id'): + continue + if not postJsonObject.get('type'): + continue + if postJsonObject['type'] == 'Announce': + continue + if not _postIsToYou(actor, postJsonObject): + continue + if isDM(postJsonObject): + if not DMdone: + if not _hasReadPost(actor, postJsonObject['id'], 'dm'): + changed = False + if not notifyJson.get('dmPostId'): + changed = True + else: + if notifyJson['dmPostId'] != postJsonObject['id']: + changed = True + if changed: + notifyJson['dmNotify'] = True + notifyJson['dmNotifyChanged'] = True + notifyJson['dmPostId'] = postJsonObject['id'] + DMdone = True + else: + if not replyDone: + if not _hasReadPost(actor, postJsonObject['id'], 'replies'): + changed = False + if not notifyJson.get('repliesPostId'): + changed = True + else: + if notifyJson['repliesPostId'] != postJsonObject['id']: + changed = True + if changed: + notifyJson['repliesNotify'] = True + notifyJson['repliesNotifyChanged'] = True + notifyJson['repliesPostId'] = postJsonObject['id'] + replyDone = True + + +def _desktopClearScreen() -> None: + """Clears the screen + """ + os.system('cls' if os.name == 'nt' else 'clear') + + +def _desktopShowBanner() -> None: + """Shows the banner at the top + """ + bannerFilename = 'banner.txt' + if not os.path.isfile(bannerFilename): + bannerTheme = 'starlight' + bannerFilename = 'theme/' + bannerTheme + '/banner.txt' + if not os.path.isfile(bannerFilename): + return + with open(bannerFilename, 'r') as bannerFile: + banner = bannerFile.read() + if banner: + print(banner + '\n') + + +def _desktopWaitForCmd(timeout: int, debug: bool) -> str: + """Waits for a command to be entered with a timeout + Returns the command, or None on timeout + """ + i, o, e = select.select([sys.stdin], [], [], timeout) + + if (i): + text = sys.stdin.readline().strip() + if debug: + print("Text entered: " + text) + return text + else: + if debug: + print("Timeout") + return None + + +def _speakerEspeak(espeak, pitch: int, rate: int, srange: int, + sayText: str) -> None: + """Speaks the given text with espeak + """ + espeak.set_parameter(espeak.Parameter.Pitch, pitch) + espeak.set_parameter(espeak.Parameter.Rate, rate) + espeak.set_parameter(espeak.Parameter.Range, srange) + espeak.synth(html.unescape(sayText)) + + +def _speakerPicospeaker(pitch: int, rate: int, systemLanguage: str, + sayText: str) -> None: + """TTS using picospeaker + """ + speakerLang = 'en-GB' + if systemLanguage: + if systemLanguage.startswith('fr'): + speakerLang = 'fr-FR' + elif systemLanguage.startswith('es'): + speakerLang = 'es-ES' + elif systemLanguage.startswith('de'): + speakerLang = 'de-DE' + elif systemLanguage.startswith('it'): + speakerLang = 'it-IT' + sayText = str(sayText).replace('"', "'") + speakerCmd = 'picospeaker ' + \ + '-l ' + speakerLang + \ + ' -r ' + str(rate) + \ + ' -p ' + str(pitch) + ' "' + \ + html.unescape(str(sayText)) + '" 2> /dev/null' + os.system(speakerCmd) + + +def _playNotificationSound(soundFilename: str, player: str = 'ffplay') -> None: + """Plays a sound + """ + if not os.path.isfile(soundFilename): + return + + if player == 'ffplay': + os.system('ffplay ' + soundFilename + + ' -autoexit -hide_banner -nodisp 2> /dev/null') + + +def _desktopNotification(notificationType: str, + title: str, message: str) -> None: + """Shows a desktop notification + """ + if not notificationType: + return + + if notificationType == 'notify-send': + # Ubuntu + os.system('notify-send "' + title + '" "' + message + '"') + elif notificationType == 'zenity': + # Zenity + os.system('zenity --notification --title "' + title + + '" --text="' + message + '"') + elif notificationType == 'osascript': + # Mac + os.system("osascript -e 'display notification \"" + + message + "\" with title \"" + title + "\"'") + elif notificationType == 'New-BurntToastNotification': + # Windows + os.system("New-BurntToastNotification -Text \"" + + title + "\", '" + message + "'") + + +def _textToSpeech(sayStr: str, screenreader: str, + pitch: int, rate: int, srange: int, + systemLanguage: str, espeak=None) -> None: + """Say something via TTS + """ + # speak the post content + if screenreader == 'espeak': + _speakerEspeak(espeak, pitch, rate, srange, sayStr) + elif screenreader == 'picospeaker': + _speakerPicospeaker(pitch, rate, + systemLanguage, sayStr) + + +def _sayCommand(content: str, sayStr: str, screenreader: str, + systemLanguage: str, + espeak=None, + speakerName: str = 'screen reader', + speakerGender: str = 'They/Them') -> None: + """Speaks a command + """ + print(content) + if not screenreader: + return + + pitch = getSpeakerPitch(speakerName, + screenreader, speakerGender) + rate = getSpeakerRate(speakerName, screenreader) + srange = getSpeakerRange(speakerName) + + _textToSpeech(sayStr, screenreader, + pitch, rate, srange, + systemLanguage, espeak) + + +def _desktopReplyToPost(session, postId: str, + baseDir: str, nickname: str, password: str, + domain: str, port: int, httpPrefix: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, subject: str, + screenreader: str, systemLanguage: str, + espeak) -> None: + """Use the desktop client to send a reply to the most recent post + """ + if '://' not in postId: + return + toNickname = getNicknameFromActor(postId) + toDomain, toPort = getDomainFromActor(postId) + sayStr = 'Replying to ' + toNickname + '@' + toDomain + _sayCommand(sayStr, sayStr, + screenreader, systemLanguage, espeak) + sayStr = 'Type your reply message, then press Enter.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + replyMessage = input() + if not replyMessage: + sayStr = 'No reply was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + replyMessage = replyMessage.strip() + if not replyMessage: + sayStr = 'No reply was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + print('') + sayStr = 'You entered this reply:' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + _sayCommand(replyMessage, replyMessage, screenreader, + systemLanguage, espeak) + sayStr = 'Send this reply, yes or no?' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + yesno = input() + if 'y' not in yesno.lower(): + sayStr = 'Abandoning reply' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + ccUrl = None + followersOnly = False + attach = None + mediaType = None + attachedImageDescription = None + isArticle = False + subject = None + commentsEnabled = True + city = 'London, England' + sayStr = 'Sending reply' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + if sendPostViaServer(__version__, + baseDir, session, nickname, password, + domain, port, + toNickname, toDomain, toPort, ccUrl, + httpPrefix, replyMessage, followersOnly, + commentsEnabled, attach, mediaType, + attachedImageDescription, city, + cachedWebfingers, personCache, isArticle, + debug, postId, postId, subject) == 0: + sayStr = 'Reply sent' + else: + sayStr = 'Reply failed' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + + +def _desktopNewPost(session, + baseDir: str, nickname: str, password: str, + domain: str, port: int, httpPrefix: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, + screenreader: str, systemLanguage: str, + espeak) -> None: + """Use the desktop client to create a new post + """ + sayStr = 'Create new post' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + sayStr = 'Type your post, then press Enter.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + newMessage = input() + if not newMessage: + sayStr = 'No post was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + newMessage = newMessage.strip() + if not newMessage: + sayStr = 'No post was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + print('') + sayStr = 'You entered this public post:' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + _sayCommand(newMessage, newMessage, screenreader, systemLanguage, espeak) + sayStr = 'Send this post, yes or no?' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + yesno = input() + if 'y' not in yesno.lower(): + sayStr = 'Abandoning new post' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + ccUrl = None + followersOnly = False + attach = None + mediaType = None + attachedImageDescription = None + city = 'London, England' + isArticle = False + subject = None + commentsEnabled = True + subject = None + sayStr = 'Sending' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + if sendPostViaServer(__version__, + baseDir, session, nickname, password, + domain, port, + None, '#Public', port, ccUrl, + httpPrefix, newMessage, followersOnly, + commentsEnabled, attach, mediaType, + attachedImageDescription, city, + cachedWebfingers, personCache, isArticle, + debug, None, None, subject) == 0: + sayStr = 'Post sent' + else: + sayStr = 'Post failed' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + + +def _safeMessage(content: str) -> str: + """Removes anything potentially unsafe from a string + """ + return content.replace('`', '').replace('$(', '$ (') + + +def _timelineIsEmpty(boxJson: {}) -> bool: + """Returns true if the given timeline is empty + """ + empty = False + if not boxJson: + empty = True + else: + if not isinstance(boxJson, dict): + empty = True + elif not boxJson.get('orderedItems'): + empty = True + return empty + + +def _getFirstItemId(boxJson: {}) -> str: + """Returns the id of the first item in the timeline + """ + if _timelineIsEmpty(boxJson): + return + if len(boxJson['orderedItems']) == 0: + return + return boxJson['orderedItems'][0]['id'] + + +def _textOnlyContent(content: str) -> str: + """Remove formatting from the given string + """ + content = urllib.parse.unquote_plus(content) + content = html.unescape(content) + return removeHtml(content) + + +def _getImageDescription(postJsonObject: {}) -> str: + """Returns a image description/s on a post + """ + imageDescription = '' + if not postJsonObject['object'].get('attachment'): + return imageDescription + + attachList = postJsonObject['object']['attachment'] + if not isinstance(attachList, list): + return imageDescription + + # for each attachment + for img in attachList: + if not isinstance(img, dict): + continue + if not img.get('name'): + continue + if not isinstance(img['name'], str): + continue + messageStr = img['name'] + if messageStr: + messageStr = messageStr.strip() + if not messageStr.endswith('.'): + imageDescription += messageStr + '. ' + else: + imageDescription += messageStr + ' ' + return imageDescription + + +def _showLikesOnPost(postJsonObject: {}, maxLikes: int) -> None: + """Shows the likes on a post + """ + if not hasObjectDict(postJsonObject): + return + if not postJsonObject['object'].get('likes'): + return + if not isinstance(postJsonObject['object']['likes'], dict): + return + if not postJsonObject['object']['likes'].get('items'): + return + if not isinstance(postJsonObject['object']['likes']['items'], list): + return + print('') + ctr = 0 + for item in postJsonObject['object']['likes']['items']: + print(' ❤ ' + str(item['actor'])) + ctr += 1 + if ctr >= maxLikes: + break + + +def _showRepliesOnPost(postJsonObject: {}, maxReplies: int) -> None: + """Shows the replies on a post + """ + if not hasObjectDict(postJsonObject): + return + if not postJsonObject['object'].get('replies'): + return + if not isinstance(postJsonObject['object']['replies'], dict): + return + if not postJsonObject['object']['replies'].get('items'): + return + if not isinstance(postJsonObject['object']['replies']['items'], list): + return + print('') + ctr = 0 + for item in postJsonObject['object']['replies']['items']: + print(' ↰ ' + str(item['url'])) + ctr += 1 + if ctr >= maxReplies: + break + + +def _readLocalBoxPost(session, nickname: str, domain: str, + httpPrefix: str, baseDir: str, boxName: str, + pageNumber: int, index: int, boxJson: {}, + systemLanguage: str, + screenreader: str, espeak, + translate: {}, yourActor: str) -> {}: + """Reads a post from the given timeline + Returns the post json + """ + if _timelineIsEmpty(boxJson): + return {} + + postJsonObject = _desktopGetBoxPostObject(boxJson, index) + if not postJsonObject: + return {} + gender = 'They/Them' + + boxNameStr = boxName + if boxName.startswith('tl'): + boxNameStr = boxName[2:] + sayStr = 'Reading ' + boxNameStr + ' post ' + str(index) + \ + ' from page ' + str(pageNumber) + '.' + sayStr2 = sayStr.replace(' dm ', ' DM ') + _sayCommand(sayStr, sayStr2, screenreader, systemLanguage, espeak) + print('') + + if postJsonObject['type'] == 'Announce': + actor = postJsonObject['actor'] + nameStr = getNicknameFromActor(actor) + recentPostsCache = {} + allowLocalNetworkAccess = False + YTReplacementDomain = None + postJsonObject2 = \ + downloadAnnounce(session, baseDir, + httpPrefix, + nickname, domain, + postJsonObject, + __version__, translate, + YTReplacementDomain, + allowLocalNetworkAccess, + recentPostsCache, False) + if postJsonObject2: + if hasObjectDict(postJsonObject2): + if postJsonObject2['object'].get('attributedTo') and \ + postJsonObject2['object'].get('content'): + attributedTo = postJsonObject2['object']['attributedTo'] + content = postJsonObject2['object']['content'] + if isinstance(attributedTo, str) and \ + isinstance(content, str): + actor = attributedTo + nameStr += ' ' + translate['announces'] + ' ' + \ + getNicknameFromActor(actor) + sayStr = nameStr + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + print('') + if screenreader: + time.sleep(2) + content = \ + _textOnlyContent(content) + content += _getImageDescription(postJsonObject2) + messageStr, detectedLinks = \ + speakableText(baseDir, content, translate) + sayStr = content + _sayCommand(sayStr, messageStr, screenreader, + systemLanguage, espeak) + return postJsonObject2 + return {} + + attributedTo = postJsonObject['object']['attributedTo'] + if not attributedTo: + return {} + content = postJsonObject['object']['content'] + if not isinstance(attributedTo, str) or \ + not isinstance(content, str): + return {} + actor = attributedTo + nameStr = getNicknameFromActor(actor) + content = _textOnlyContent(content) + content += _getImageDescription(postJsonObject) + + if isPGPEncrypted(content): + sayStr = 'Encrypted message. Please enter your passphrase.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + content = pgpDecrypt(domain, content, actor) + if isPGPEncrypted(content): + sayStr = 'Message could not be decrypted' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return {} + + content = _safeMessage(content) + messageStr, detectedLinks = speakableText(baseDir, content, translate) + + if screenreader: + time.sleep(2) + + # say the speaker's name + _sayCommand(nameStr, nameStr, screenreader, + systemLanguage, espeak, nameStr, gender) + print('') + + if postJsonObject['object'].get('inReplyTo'): + print('Replying to ' + postJsonObject['object']['inReplyTo'] + '\n') + + if screenreader: + time.sleep(2) + + # speak the post content + _sayCommand(content, messageStr, screenreader, + systemLanguage, espeak, nameStr, gender) + + _showLikesOnPost(postJsonObject, 10) + _showRepliesOnPost(postJsonObject, 10) + + # if the post is addressed to you then mark it as read + if _postIsToYou(yourActor, postJsonObject): + if isDM(postJsonObject): + _markPostAsRead(yourActor, postJsonObject['id'], 'dm') + else: + _markPostAsRead(yourActor, postJsonObject['id'], 'replies') + + return postJsonObject + + +def _desktopShowActor(baseDir: str, actorJson: {}, translate: {}, + systemLanguage: str, screenreader: str, + espeak) -> None: + """Shows information for the given actor + """ + actor = actorJson['id'] + actorNickname = getNicknameFromActor(actor) + actorDomain, actorPort = getDomainFromActor(actor) + actorDomainFull = getFullDomain(actorDomain, actorPort) + handle = '@' + actorNickname + '@' + actorDomainFull + + sayStr = 'Profile for ' + html.unescape(handle) + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + print(actor) + if actorJson.get('movedTo'): + sayStr = 'Moved to ' + html.unescape(actorJson['movedTo']) + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + if actorJson.get('alsoKnownAs'): + alsoKnownAsStr = '' + ctr = 0 + for altActor in actorJson['alsoKnownAs']: + if ctr > 0: + alsoKnownAsStr += ', ' + ctr += 1 + alsoKnownAsStr += altActor + + sayStr = 'Also known as ' + html.unescape(alsoKnownAsStr) + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + if actorJson.get('summary'): + sayStr = html.unescape(removeHtml(actorJson['summary'])) + sayStr = sayStr.replace('"', "'") + sayStr2 = speakableText(baseDir, sayStr, translate)[0] + _sayCommand(sayStr, sayStr2, screenreader, systemLanguage, espeak) + + +def _desktopShowProfile(session, nickname: str, domain: str, + httpPrefix: str, baseDir: str, boxName: str, + pageNumber: int, index: int, boxJson: {}, + systemLanguage: str, + screenreader: str, espeak, + translate: {}, yourActor: str, + postJsonObject: {}) -> {}: + """Shows the profile of the actor for the given post + Returns the actor json + """ + if _timelineIsEmpty(boxJson): + return {} + + if not postJsonObject: + postJsonObject = _desktopGetBoxPostObject(boxJson, index) + if not postJsonObject: + return {} + + actor = None + if postJsonObject['type'] == 'Announce': + nickname = getNicknameFromActor(postJsonObject['object']) + if nickname: + nickStr = '/' + nickname + '/' + if nickStr in postJsonObject['object']: + actor = \ + postJsonObject['object'].split(nickStr)[0] + \ + '/' + nickname + else: + actor = postJsonObject['object']['attributedTo'] + + if not actor: + return {} + + isHttp = False + if 'http://' in actor: + isHttp = True + actorJson, asHeader = \ + getActorJson(domain, actor, isHttp, False, False, True) + + _desktopShowActor(baseDir, actorJson, translate, + systemLanguage, screenreader, espeak) + + return actorJson + + +def _desktopShowProfileFromHandle(session, nickname: str, domain: str, + httpPrefix: str, baseDir: str, boxName: str, + handle: str, + systemLanguage: str, + screenreader: str, espeak, + translate: {}, yourActor: str, + postJsonObject: {}) -> {}: + """Shows the profile for a handle + Returns the actor json + """ + actorJson, asHeader = \ + getActorJson(domain, handle, False, False, False, True) + + _desktopShowActor(baseDir, actorJson, translate, + systemLanguage, screenreader, espeak) + + return actorJson + + +def _desktopGetBoxPostObject(boxJson: {}, index: int) -> {}: + """Gets the post with the given index from the timeline + """ + ctr = 0 + for postJsonObject in boxJson['orderedItems']: + if not postJsonObject.get('type'): + continue + if not postJsonObject.get('object'): + continue + if postJsonObject['type'] == 'Announce': + if not isinstance(postJsonObject['object'], str): + continue + ctr += 1 + if ctr == index: + return postJsonObject + continue + if not hasObjectDict(postJsonObject): + continue + if not postJsonObject['object'].get('published'): + continue + if not postJsonObject['object'].get('content'): + continue + ctr += 1 + if ctr == index: + return postJsonObject + return None + + +def _formatPublished(published: str) -> str: + """Formats the published time for display on timeline + """ + dateStr = published.split('T')[0] + monthStr = dateStr.split('-')[1] + dayStr = dateStr.split('-')[2] + timeStr = published.split('T')[1] + hourStr = timeStr.split(':')[0] + minStr = timeStr.split(':')[1] + return monthStr + '-' + dayStr + ' ' + hourStr + ':' + minStr + 'Z' + + +def _padToWidth(content: str, width: int) -> str: + """Pads the given string to the given width + """ + if len(content) > width: + content = content[:width] + else: + while len(content) < width: + content += ' ' + return content + + +def _highlightText(text: str) -> str: + """Returns a highlighted version of the given text + """ + return '\33[7m' + text + '\33[0m' + + +def _desktopShowBox(indent: str, + followRequestsJson: {}, + yourActor: str, boxName: str, boxJson: {}, + translate: {}, + screenreader: str, systemLanguage: str, espeak, + pageNumber: int = 1, + newReplies: bool = False, + newDMs: bool = False) -> bool: + """Shows online timeline + """ + numberWidth = 2 + nameWidth = 16 + contentWidth = 50 + + # title + _desktopClearScreen() + _desktopShowBanner() + + notificationIcons = '' + if boxName.startswith('tl'): + boxNameStr = boxName[2:] + else: + boxNameStr = boxName + titleStr = _highlightText(boxNameStr.upper()) + # if newDMs: + # notificationIcons += ' 📩' + # if newReplies: + # notificationIcons += ' 📨' + + if notificationIcons: + while len(titleStr) < 95 - len(notificationIcons): + titleStr += ' ' + titleStr += notificationIcons + print(indent + titleStr + '\n') + + if _timelineIsEmpty(boxJson): + boxStr = boxNameStr + if boxName == 'dm': + boxStr = 'DM' + sayStr = indent + 'You have no ' + boxStr + ' posts yet.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + print('') + return False + + ctr = 1 + for postJsonObject in boxJson['orderedItems']: + if not postJsonObject.get('type'): + continue + if postJsonObject['type'] == 'Announce': + if postJsonObject.get('actor') and \ + postJsonObject.get('object'): + if isinstance(postJsonObject['object'], str): + authorActor = postJsonObject['actor'] + name = getNicknameFromActor(authorActor) + ' ⮌' + name = _padToWidth(name, nameWidth) + ctrStr = str(ctr) + posStr = _padToWidth(ctrStr, numberWidth) + published = _formatPublished(postJsonObject['published']) + announcedNickname = \ + getNicknameFromActor(postJsonObject['object']) + announcedDomain, announcedPort = \ + getDomainFromActor(postJsonObject['object']) + announcedHandle = announcedNickname + '@' + announcedDomain + lineStr = \ + indent + str(posStr) + ' | ' + name + ' | ' + \ + published + ' | ' + \ + _padToWidth(announcedHandle, contentWidth) + print(lineStr) + ctr += 1 + continue + + if not hasObjectDict(postJsonObject): + continue + if not postJsonObject['object'].get('published'): + continue + if not postJsonObject['object'].get('content'): + continue + ctrStr = str(ctr) + posStr = _padToWidth(ctrStr, numberWidth) + + authorActor = postJsonObject['object']['attributedTo'] + contentWarning = None + if postJsonObject['object'].get('summary'): + contentWarning = '⚡' + \ + _padToWidth(postJsonObject['object']['summary'], + contentWidth) + name = getNicknameFromActor(authorActor) + + # append icons to the end of the name + spaceAdded = False + if postJsonObject['object'].get('inReplyTo'): + if not spaceAdded: + spaceAdded = True + name += ' ' + name += '↲' + if postJsonObject['object'].get('replies'): + repliesList = postJsonObject['object']['replies'] + if repliesList.get('items'): + items = repliesList['items'] + for i in range(int(items)): + name += '↰' + if i > 10: + break + likesCount = noOfLikes(postJsonObject) + if likesCount > 10: + likesCount = 10 + for like in range(likesCount): + if not spaceAdded: + spaceAdded = True + name += ' ' + name += '❤' + name = _padToWidth(name, nameWidth) + + published = _formatPublished(postJsonObject['published']) + + content = _textOnlyContent(postJsonObject['object']['content']) + if boxName != 'dm': + if isDM(postJsonObject): + content = '📧' + content + if not contentWarning: + if isPGPEncrypted(content): + content = '🔒' + content + elif '://' in content: + content = '🔗' + content + content = _padToWidth(content, contentWidth) + else: + # display content warning + if isPGPEncrypted(content): + content = '🔒' + contentWarning + else: + if '://' in content: + content = '🔗' + contentWarning + else: + content = contentWarning + if postJsonObject['object'].get('ignores'): + content = '🔇' + if postJsonObject['object'].get('bookmarks'): + content = '🔖' + content + if '\n' in content: + content = content.replace('\n', ' ') + lineStr = indent + str(posStr) + ' | ' + name + ' | ' + \ + published + ' | ' + content + if boxName == 'inbox' and \ + _postIsToYou(yourActor, postJsonObject): + if not _hasReadPost(yourActor, postJsonObject['id'], 'dm'): + if not _hasReadPost(yourActor, postJsonObject['id'], + 'replies'): + lineStr = _highlightText(lineStr) + print(lineStr) + ctr += 1 + + if followRequestsJson: + _desktopShowFollowRequests(followRequestsJson, translate) + + print('') + + # say the post number range + sayStr = indent + boxNameStr + ' page ' + str(pageNumber) + \ + ' containing ' + str(ctr - 1) + ' posts. ' + sayStr2 = sayStr.replace('\33[3m', '').replace('\33[0m', '') + sayStr2 = sayStr2.replace('show dm', 'show DM') + sayStr2 = sayStr2.replace('dm post', 'Direct message post') + _sayCommand(sayStr, sayStr2, screenreader, systemLanguage, espeak) + print('') + return True + + +def _desktopNewDM(session, toHandle: str, + baseDir: str, nickname: str, password: str, + domain: str, port: int, httpPrefix: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, + screenreader: str, systemLanguage: str, + espeak) -> None: + """Use the desktop client to create a new direct message + which can include multiple destination handles + """ + if ' ' in toHandle: + handlesList = toHandle.split(' ') + elif ',' in toHandle: + handlesList = toHandle.split(',') + elif ';' in toHandle: + handlesList = toHandle.split(';') + else: + handlesList = [toHandle] + + for handle in handlesList: + handle = handle.strip() + _desktopNewDMbase(session, handle, + baseDir, nickname, password, + domain, port, httpPrefix, + cachedWebfingers, personCache, + debug, + screenreader, systemLanguage, + espeak) + + +def _desktopNewDMbase(session, toHandle: str, + baseDir: str, nickname: str, password: str, + domain: str, port: int, httpPrefix: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, + screenreader: str, systemLanguage: str, + espeak) -> None: + """Use the desktop client to create a new direct message + """ + toPort = port + if '://' in toHandle: + toNickname = getNicknameFromActor(toHandle) + toDomain, toPort = getDomainFromActor(toHandle) + toHandle = toNickname + '@' + toDomain + else: + if toHandle.startswith('@'): + toHandle = toHandle[1:] + toNickname = toHandle.split('@')[0] + toDomain = toHandle.split('@')[1] + + sayStr = 'Create new direct message to ' + toHandle + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + sayStr = 'Type your direct message, then press Enter.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + newMessage = input() + if not newMessage: + sayStr = 'No direct message was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + newMessage = newMessage.strip() + if not newMessage: + sayStr = 'No direct message was entered.' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + sayStr = 'You entered this direct message to ' + toHandle + ':' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + _sayCommand(newMessage, newMessage, screenreader, systemLanguage, espeak) + ccUrl = None + followersOnly = False + attach = None + mediaType = None + attachedImageDescription = None + city = 'London, England' + isArticle = False + subject = None + commentsEnabled = True + subject = None + + # if there is a local PGP key then attempt to encrypt the DM + # using the PGP public key of the recipient + if hasLocalPGPkey(): + sayStr = \ + 'Local PGP key detected...' + \ + 'Fetching PGP public key for ' + toHandle + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + paddedMessage = newMessage + if len(paddedMessage) < 32: + # add some padding before and after + # This is to guard against cribs based on small messages, like "Hi" + for before in range(randint(1, 16)): + paddedMessage = ' ' + paddedMessage + for after in range(randint(1, 16)): + paddedMessage += ' ' + cipherText = \ + pgpEncryptToActor(domain, paddedMessage, toHandle) + if not cipherText: + sayStr = \ + toHandle + ' has no PGP public key. ' + \ + 'Your message will be sent in clear text' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + else: + newMessage = cipherText + sayStr = 'Message encrypted' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + + sayStr = 'Send this direct message, yes or no?' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + yesno = input() + if 'y' not in yesno.lower(): + sayStr = 'Abandoning new direct message' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + return + + sayStr = 'Sending' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + if sendPostViaServer(__version__, + baseDir, session, nickname, password, + domain, port, + toNickname, toDomain, toPort, ccUrl, + httpPrefix, newMessage, followersOnly, + commentsEnabled, attach, mediaType, + attachedImageDescription, city, + cachedWebfingers, personCache, isArticle, + debug, None, None, subject) == 0: + sayStr = 'Direct message sent' + else: + sayStr = 'Direct message failed' + _sayCommand(sayStr, sayStr, screenreader, systemLanguage, espeak) + + +def _desktopShowFollowRequests(followRequestsJson: {}, translate: {}) -> None: + """Shows any follow requests + """ + if not isinstance(followRequestsJson, dict): + return + if not followRequestsJson.get('orderedItems'): + return + if not followRequestsJson['orderedItems']: + return + indent = ' ' + print('') + print(indent + 'Follow requests:') + print('') + for item in followRequestsJson['orderedItems']: + handleNickname = getNicknameFromActor(item) + handleDomain, handlePort = getDomainFromActor(item) + handleDomainFull = \ + getFullDomain(handleDomain, handlePort) + print(indent + ' 👤 ' + + handleNickname + '@' + handleDomainFull) + + +def _desktopShowFollowing(followingJson: {}, translate: {}, + pageNumber: int, indent: str, + followType='following') -> None: + """Shows a page of accounts followed + """ + if not isinstance(followingJson, dict): + return + if not followingJson.get('orderedItems'): + return + if not followingJson['orderedItems']: + return + print('') + if followType == 'following': + print(indent + 'Following page ' + str(pageNumber)) + elif followType == 'followers': + print(indent + 'Followers page ' + str(pageNumber)) + print('') + for item in followingJson['orderedItems']: + handleNickname = getNicknameFromActor(item) + handleDomain, handlePort = getDomainFromActor(item) + handleDomainFull = \ + getFullDomain(handleDomain, handlePort) + print(indent + ' 👤 ' + + handleNickname + '@' + handleDomainFull) + + +def runDesktopClient(baseDir: str, proxyType: str, httpPrefix: str, + nickname: str, domain: str, port: int, + password: str, screenreader: str, + systemLanguage: str, + notificationSounds: bool, + notificationType: str, + noKeyPress: bool, + storeInboxPosts: bool, + showNewPosts: bool, + language: str, + debug: bool) -> None: + """Runs the desktop and screen reader client, + which announces new inbox items + """ + indent = ' ' + if showNewPosts: + indent = '' + + _desktopClearScreen() + _desktopShowBanner() + + espeak = None + if screenreader: + if screenreader == 'espeak': + print('Setting up espeak') + from espeak import espeak + elif screenreader != 'picospeaker': + print(screenreader + ' is not a supported TTS system') + return + + sayStr = indent + 'Running ' + screenreader + ' for ' + \ + nickname + '@' + domain + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + else: + print(indent + 'Running desktop notifications for ' + + nickname + '@' + domain) + if notificationSounds: + sayStr = indent + 'Notification sounds on' + else: + sayStr = indent + 'Notification sounds off' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + + currTimeline = 'inbox' + pageNumber = 1 + + postJsonObject = {} + originalScreenReader = screenreader + soundsDir = 'theme/default/sounds/' + # prevSay = '' + # prevCalendar = False + # prevFollow = False + # prevLike = '' + # prevShare = False + dmSoundFilename = soundsDir + 'dm.ogg' + replySoundFilename = soundsDir + 'reply.ogg' + # calendarSoundFilename = soundsDir + 'calendar.ogg' + # followSoundFilename = soundsDir + 'follow.ogg' + # likeSoundFilename = soundsDir + 'like.ogg' + # shareSoundFilename = soundsDir + 'share.ogg' + player = 'ffplay' + nameStr = None + gender = None + messageStr = None + content = None + cachedWebfingers = {} + personCache = {} + newRepliesExist = False + newDMsExist = False + pgpKeyUpload = False + + sayStr = indent + 'Loading translations file' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + translate, systemLanguage = \ + loadTranslationsFromFile(baseDir, language) + + sayStr = indent + 'Connecting...' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + session = createSession(proxyType) + + sayStr = indent + '/q or /quit to exit' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + + domainFull = getFullDomain(domain, port) + yourActor = httpPrefix + '://' + domainFull + '/users/' + nickname + actorJson = None + + notifyJson = { + "dmPostId": "Initial", + "dmNotify": False, + "dmNotifyChanged": False, + "repliesPostId": "Initial", + "repliesNotify": False, + "repliesNotifyChanged": False + } + prevTimelineFirstId = '' + desktopShown = False + while (1): + if not pgpKeyUpload: + if not hasLocalPGPkey(): + print('No PGP public key was found') + else: + sayStr = indent + 'Uploading PGP public key' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + pgpPublicKeyUpload(baseDir, session, + nickname, password, + domain, port, httpPrefix, + cachedWebfingers, personCache, + debug, False) + sayStr = indent + 'PGP public key uploaded' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + pgpKeyUpload = True + + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + + followRequestsJson = \ + getFollowRequestsViaServer(baseDir, session, + nickname, password, + domain, port, + httpPrefix, 1, + cachedWebfingers, personCache, + debug, __version__) + + if not (currTimeline == 'inbox' and pageNumber == 1): + # monitor the inbox to generate notifications + inboxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + 'inbox', 1, debug) + else: + inboxJson = boxJson + newDMsExist = False + newRepliesExist = False + if inboxJson: + _newDesktopNotifications(yourActor, inboxJson, notifyJson) + if notifyJson.get('dmNotify'): + newDMsExist = True + if notifyJson.get('dmNotifyChanged'): + _desktopNotification(notificationType, + "Epicyon", + "New DM " + yourActor + '/dm') + if notificationSounds: + _playNotificationSound(dmSoundFilename, player) + if notifyJson.get('repliesNotify'): + newRepliesExist = True + if notifyJson.get('repliesNotifyChanged'): + _desktopNotification(notificationType, + "Epicyon", + "New reply " + yourActor + '/replies') + if notificationSounds: + _playNotificationSound(replySoundFilename, player) + + if boxJson: + timelineFirstId = _getFirstItemId(boxJson) + if timelineFirstId != prevTimelineFirstId: + _desktopClearScreen() + _desktopShowBox(indent, followRequestsJson, + yourActor, currTimeline, boxJson, + translate, + None, systemLanguage, espeak, + pageNumber, + newRepliesExist, + newDMsExist) + desktopShown = True + prevTimelineFirstId = timelineFirstId + else: + session = createSession(proxyType) + if not desktopShown: + if not session: + print('No session\n') + + _desktopClearScreen() + _desktopShowBanner() + print('No posts\n') + if proxyType == 'tor': + print('You may need to run the desktop client ' + + 'with the --http option') + + # wait for a while, or until a key is pressed + if noKeyPress: + time.sleep(10) + else: + commandStr = _desktopWaitForCmd(30, debug) + if commandStr: + refreshTimeline = False + + if commandStr.startswith('/'): + commandStr = commandStr[1:] + if commandStr == 'q' or \ + commandStr == 'quit' or \ + commandStr == 'exit': + sayStr = 'Quit' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + if screenreader: + commandStr = _desktopWaitForCmd(2, debug) + break + elif commandStr.startswith('show dm'): + pageNumber = 1 + prevTimelineFirstId = '' + currTimeline = 'dm' + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + if boxJson: + _desktopShowBox(indent, followRequestsJson, + yourActor, currTimeline, boxJson, + translate, + screenreader, systemLanguage, espeak, + pageNumber, + newRepliesExist, newDMsExist) + newDMsExist = False + elif commandStr.startswith('show rep'): + pageNumber = 1 + prevTimelineFirstId = '' + currTimeline = 'tlreplies' + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + if boxJson: + _desktopShowBox(indent, followRequestsJson, + yourActor, currTimeline, boxJson, + translate, + screenreader, systemLanguage, espeak, + pageNumber, + newRepliesExist, newDMsExist) + # Turn off the replies indicator + newRepliesExist = False + elif commandStr.startswith('show b'): + pageNumber = 1 + prevTimelineFirstId = '' + currTimeline = 'tlbookmarks' + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + if boxJson: + _desktopShowBox(indent, followRequestsJson, + yourActor, currTimeline, boxJson, + translate, + screenreader, systemLanguage, espeak, + pageNumber, + newRepliesExist, newDMsExist) + # Turn off the replies indicator + newRepliesExist = False + elif (commandStr.startswith('show sen') or + commandStr.startswith('show out')): + pageNumber = 1 + prevTimelineFirstId = '' + currTimeline = 'outbox' + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + if boxJson: + _desktopShowBox(indent, followRequestsJson, + yourActor, currTimeline, boxJson, + translate, + screenreader, systemLanguage, espeak, + pageNumber, + newRepliesExist, newDMsExist) + elif (commandStr == 'show' or commandStr.startswith('show in') or + commandStr == 'clear'): + pageNumber = 1 + prevTimelineFirstId = '' + currTimeline = 'inbox' + refreshTimeline = True + elif commandStr.startswith('next'): + pageNumber += 1 + prevTimelineFirstId = '' + refreshTimeline = True + elif commandStr.startswith('prev'): + pageNumber -= 1 + if pageNumber < 1: + pageNumber = 1 + prevTimelineFirstId = '' + boxJson = c2sBoxJson(baseDir, session, + nickname, password, + domain, port, httpPrefix, + currTimeline, pageNumber, + debug) + if boxJson: + _desktopShowBox(indent, followRequestsJson, + yourActor, currTimeline, boxJson, + translate, + screenreader, systemLanguage, espeak, + pageNumber, + newRepliesExist, newDMsExist) + elif commandStr.startswith('read ') or commandStr == 'read': + if commandStr == 'read': + postIndexStr = '1' + else: + postIndexStr = commandStr.split('read ')[1] + if boxJson and postIndexStr.isdigit(): + _desktopClearScreen() + _desktopShowBanner() + postIndex = int(postIndexStr) + postJsonObject = \ + _readLocalBoxPost(session, nickname, domain, + httpPrefix, baseDir, currTimeline, + pageNumber, postIndex, boxJson, + systemLanguage, screenreader, + espeak, translate, yourActor) + print('') + sayStr = 'Press Enter to continue...' + sayStr2 = _highlightText(sayStr) + _sayCommand(sayStr2, sayStr, + screenreader, systemLanguage, espeak) + input() + prevTimelineFirstId = '' + refreshTimeline = True + print('') + elif commandStr.startswith('profile ') or commandStr == 'profile': + actorJson = None + if commandStr == 'profile': + if postJsonObject: + actorJson = \ + _desktopShowProfile(session, nickname, domain, + httpPrefix, baseDir, + currTimeline, + pageNumber, postIndex, + boxJson, + systemLanguage, screenreader, + espeak, translate, yourActor, + postJsonObject) + else: + postIndexStr = '1' + else: + postIndexStr = commandStr.split('profile ')[1] + + if not postIndexStr.isdigit(): + profileHandle = postIndexStr + _desktopClearScreen() + _desktopShowBanner() + _desktopShowProfileFromHandle(session, nickname, domain, + httpPrefix, baseDir, + currTimeline, profileHandle, + systemLanguage, screenreader, + espeak, translate, yourActor, + None) + sayStr = 'Press Enter to continue...' + sayStr2 = _highlightText(sayStr) + _sayCommand(sayStr2, sayStr, + screenreader, systemLanguage, espeak) + input() + prevTimelineFirstId = '' + refreshTimeline = True + elif not actorJson and boxJson: + _desktopClearScreen() + _desktopShowBanner() + postIndex = int(postIndexStr) + actorJson = \ + _desktopShowProfile(session, nickname, domain, + httpPrefix, baseDir, currTimeline, + pageNumber, postIndex, boxJson, + systemLanguage, screenreader, + espeak, translate, yourActor, + None) + sayStr = 'Press Enter to continue...' + sayStr2 = _highlightText(sayStr) + _sayCommand(sayStr2, sayStr, + screenreader, systemLanguage, espeak) + input() + prevTimelineFirstId = '' + refreshTimeline = True + print('') + elif commandStr == 'reply' or commandStr == 'r': + if postJsonObject: + if postJsonObject.get('id'): + postId = postJsonObject['id'] + subject = None + if postJsonObject['object'].get('summary'): + subject = postJsonObject['object']['summary'] + sessionReply = createSession(proxyType) + _desktopReplyToPost(sessionReply, postId, + baseDir, nickname, password, + domain, port, httpPrefix, + cachedWebfingers, personCache, + debug, subject, + screenreader, systemLanguage, + espeak) + refreshTimeline = True + print('') + elif (commandStr == 'post' or commandStr == 'p' or + commandStr == 'send' or + commandStr.startswith('dm ') or + commandStr.startswith('direct message ') or + commandStr.startswith('post ') or + commandStr.startswith('send ')): + sessionPost = createSession(proxyType) + if commandStr.startswith('dm ') or \ + commandStr.startswith('direct message ') or \ + commandStr.startswith('post ') or \ + commandStr.startswith('send '): + commandStr = commandStr.replace(' to ', ' ') + commandStr = commandStr.replace(' dm ', ' ') + commandStr = commandStr.replace(' DM ', ' ') + # direct message + toHandle = None + if commandStr.startswith('post '): + toHandle = commandStr.split('post ', 1)[1] + elif commandStr.startswith('send '): + toHandle = commandStr.split('send ', 1)[1] + elif commandStr.startswith('dm '): + toHandle = commandStr.split('dm ', 1)[1] + elif commandStr.startswith('direct message '): + toHandle = commandStr.split('direct message ', 1)[1] + if toHandle: + _desktopNewDM(sessionPost, toHandle, + baseDir, nickname, password, + domain, port, httpPrefix, + cachedWebfingers, personCache, + debug, + screenreader, systemLanguage, + espeak) + refreshTimeline = True + else: + # public post + _desktopNewPost(sessionPost, + baseDir, nickname, password, + domain, port, httpPrefix, + cachedWebfingers, personCache, + debug, + screenreader, systemLanguage, + espeak) + refreshTimeline = True + print('') + elif commandStr == 'like' or commandStr.startswith('like '): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + likeActor = postJsonObject['object']['attributedTo'] + sayStr = 'Liking post by ' + \ + getNicknameFromActor(likeActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionLike = createSession(proxyType) + sendLikeViaServer(baseDir, sessionLike, + nickname, password, + domain, port, httpPrefix, + postJsonObject['id'], + cachedWebfingers, personCache, + False, __version__) + refreshTimeline = True + print('') + elif (commandStr == 'undo mute' or + commandStr == 'undo ignore' or + commandStr == 'remove mute' or + commandStr == 'rm mute' or + commandStr == 'unmute' or + commandStr == 'unignore' or + commandStr == 'mute undo' or + commandStr.startswith('undo mute ') or + commandStr.startswith('undo ignore ') or + commandStr.startswith('remove mute ') or + commandStr.startswith('remove ignore ') or + commandStr.startswith('unignore ') or + commandStr.startswith('unmute ')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + muteActor = postJsonObject['object']['attributedTo'] + sayStr = 'Unmuting post by ' + \ + getNicknameFromActor(muteActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionMute = createSession(proxyType) + sendUndoMuteViaServer(baseDir, sessionMute, + nickname, password, + domain, port, + httpPrefix, postJsonObject['id'], + cachedWebfingers, personCache, + False, __version__) + refreshTimeline = True + print('') + elif (commandStr == 'mute' or + commandStr == 'ignore' or + commandStr.startswith('mute ') or + commandStr.startswith('ignore ')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + muteActor = postJsonObject['object']['attributedTo'] + sayStr = 'Muting post by ' + \ + getNicknameFromActor(muteActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionMute = createSession(proxyType) + sendMuteViaServer(baseDir, sessionMute, + nickname, password, + domain, port, + httpPrefix, postJsonObject['id'], + cachedWebfingers, personCache, + False, __version__) + refreshTimeline = True + print('') + elif (commandStr == 'undo bookmark' or + commandStr == 'remove bookmark' or + commandStr == 'rm bookmark' or + commandStr == 'undo bm' or + commandStr == 'rm bm' or + commandStr == 'remove bm' or + commandStr == 'unbookmark' or + commandStr == 'bookmark undo' or + commandStr == 'bm undo ' or + commandStr.startswith('undo bm ') or + commandStr.startswith('remove bm ') or + commandStr.startswith('undo bookmark ') or + commandStr.startswith('remove bookmark ') or + commandStr.startswith('unbookmark ') or + commandStr.startswith('unbm ')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + bmActor = postJsonObject['object']['attributedTo'] + sayStr = 'Unbookmarking post by ' + \ + getNicknameFromActor(bmActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionbm = createSession(proxyType) + sendUndoBookmarkViaServer(baseDir, sessionbm, + nickname, password, + domain, port, httpPrefix, + postJsonObject['id'], + cachedWebfingers, + personCache, + False, __version__) + refreshTimeline = True + print('') + elif (commandStr == 'bookmark' or + commandStr == 'bm' or + commandStr.startswith('bookmark ') or + commandStr.startswith('bm ')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + bmActor = postJsonObject['object']['attributedTo'] + sayStr = 'Bookmarking post by ' + \ + getNicknameFromActor(bmActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionbm = createSession(proxyType) + sendBookmarkViaServer(baseDir, sessionbm, + nickname, password, + domain, port, httpPrefix, + postJsonObject['id'], + cachedWebfingers, personCache, + False, __version__) + refreshTimeline = True + print('') + elif (commandStr.startswith('undo block ') or + commandStr.startswith('remove block ') or + commandStr.startswith('rm block ') or + commandStr.startswith('unblock ')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id') and \ + postJsonObject.get('object'): + if hasObjectDict(postJsonObject): + if postJsonObject['object'].get('attributedTo'): + blockActor = \ + postJsonObject['object']['attributedTo'] + sayStr = 'Unblocking ' + \ + getNicknameFromActor(blockActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionBlock = createSession(proxyType) + sendUndoBlockViaServer(baseDir, sessionBlock, + nickname, password, + domain, port, + httpPrefix, + blockActor, + cachedWebfingers, + personCache, + False, __version__) + refreshTimeline = True + print('') + elif commandStr.startswith('block '): + blockActor = None + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + else: + if '@' in postIndex: + blockHandle = postIndex + if blockHandle.startswith('@'): + blockHandle = blockHandle[1:] + if '@' in blockHandle: + blockDomain = blockHandle.split('@')[1] + blockNickname = blockHandle.split('@')[0] + blockActor = \ + httpPrefix + '://' + blockDomain + \ + '/users/' + blockNickname + if currIndex > 0 and boxJson and not blockActor: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject and not blockActor: + if postJsonObject.get('id') and \ + postJsonObject.get('object'): + if hasObjectDict(postJsonObject): + if postJsonObject['object'].get('attributedTo'): + blockActor = \ + postJsonObject['object']['attributedTo'] + if blockActor: + sayStr = 'Blocking ' + \ + getNicknameFromActor(blockActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionBlock = createSession(proxyType) + sendBlockViaServer(baseDir, sessionBlock, + nickname, password, + domain, port, + httpPrefix, + blockActor, + cachedWebfingers, + personCache, + False, __version__) + refreshTimeline = True + print('') + elif commandStr == 'unlike' or commandStr == 'undo like': + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + unlikeActor = postJsonObject['object']['attributedTo'] + sayStr = \ + 'Undoing like of post by ' + \ + getNicknameFromActor(unlikeActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionUnlike = createSession(proxyType) + sendUndoLikeViaServer(baseDir, sessionUnlike, + nickname, password, + domain, port, httpPrefix, + postJsonObject['id'], + cachedWebfingers, personCache, + False, __version__) + refreshTimeline = True + print('') + elif (commandStr.startswith('announce') or + commandStr.startswith('boost') or + commandStr.startswith('retweet')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + postId = postJsonObject['id'] + announceActor = \ + postJsonObject['object']['attributedTo'] + sayStr = 'Announcing post by ' + \ + getNicknameFromActor(announceActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionAnnounce = createSession(proxyType) + sendAnnounceViaServer(baseDir, sessionAnnounce, + nickname, password, + domain, port, + httpPrefix, postId, + cachedWebfingers, personCache, + True, __version__) + refreshTimeline = True + print('') + elif (commandStr.startswith('unannounce') or + commandStr.startswith('undo announce') or + commandStr.startswith('unboost') or + commandStr.startswith('undo boost') or + commandStr.startswith('undo retweet')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + postId = postJsonObject['id'] + announceActor = \ + postJsonObject['object']['attributedTo'] + sayStr = 'Undoing announce post by ' + \ + getNicknameFromActor(announceActor) + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionAnnounce = createSession(proxyType) + sendUndoAnnounceViaServer(baseDir, sessionAnnounce, + postJsonObject, + nickname, password, + domain, port, + httpPrefix, postId, + cachedWebfingers, + personCache, + True, __version__) + refreshTimeline = True + print('') + elif (commandStr == 'follow requests' or + commandStr.startswith('follow requests ')): + currPage = 1 + if ' ' in commandStr: + pageNum = commandStr.split(' ')[-1].strip() + if pageNum.isdigit(): + currPage = int(pageNum) + followRequestsJson = \ + getFollowRequestsViaServer(baseDir, session, + nickname, password, + domain, port, + httpPrefix, currPage, + cachedWebfingers, personCache, + debug, __version__) + if followRequestsJson: + if isinstance(followRequestsJson, dict): + _desktopShowFollowRequests(followRequestsJson, + translate) + print('') + elif (commandStr == 'following' or + commandStr.startswith('following ')): + currPage = 1 + if ' ' in commandStr: + pageNum = commandStr.split(' ')[-1].strip() + if pageNum.isdigit(): + currPage = int(pageNum) + followingJson = \ + getFollowingViaServer(baseDir, session, + nickname, password, + domain, port, + httpPrefix, currPage, + cachedWebfingers, personCache, + debug, __version__) + if followingJson: + if isinstance(followingJson, dict): + _desktopShowFollowing(followingJson, translate, + currPage, indent, + 'following') + print('') + elif (commandStr == 'followers' or + commandStr.startswith('followers ')): + currPage = 1 + if ' ' in commandStr: + pageNum = commandStr.split(' ')[-1].strip() + if pageNum.isdigit(): + currPage = int(pageNum) + followersJson = \ + getFollowersViaServer(baseDir, session, + nickname, password, + domain, port, + httpPrefix, currPage, + cachedWebfingers, personCache, + debug, __version__) + if followersJson: + if isinstance(followersJson, dict): + _desktopShowFollowing(followersJson, translate, + currPage, indent, + 'followers') + print('') + elif (commandStr == 'follow' or + commandStr.startswith('follow ')): + if commandStr == 'follow': + if actorJson: + followHandle = actorJson['id'] + else: + followHandle = '' + else: + followHandle = commandStr.replace('follow ', '').strip() + if followHandle.startswith('@'): + followHandle = followHandle[1:] + + if '@' in followHandle or '://' in followHandle: + followNickname = getNicknameFromActor(followHandle) + followDomain, followPort = \ + getDomainFromActor(followHandle) + if followNickname and followDomain: + sayStr = 'Sending follow request to ' + \ + followNickname + '@' + followDomain + _sayCommand(sayStr, sayStr, + screenreader, systemLanguage, espeak) + sessionFollow = createSession(proxyType) + sendFollowRequestViaServer(baseDir, + sessionFollow, + nickname, password, + domain, port, + followNickname, + followDomain, + followPort, + httpPrefix, + cachedWebfingers, + personCache, + debug, __version__) + else: + if followHandle: + sayStr = followHandle + ' is not valid' + else: + sayStr = 'Specify a handle to follow' + _sayCommand(sayStr, + screenreader, systemLanguage, espeak) + print('') + elif (commandStr.startswith('unfollow ') or + commandStr.startswith('stop following ')): + followHandle = commandStr.replace('unfollow ', '').strip() + followHandle = followHandle.replace('stop following ', '') + if followHandle.startswith('@'): + followHandle = followHandle[1:] + if '@' in followHandle or '://' in followHandle: + followNickname = getNicknameFromActor(followHandle) + followDomain, followPort = \ + getDomainFromActor(followHandle) + if followNickname and followDomain: + sayStr = 'Stop following ' + \ + followNickname + '@' + followDomain + _sayCommand(sayStr, sayStr, + screenreader, systemLanguage, espeak) + sessionUnfollow = createSession(proxyType) + sendUnfollowRequestViaServer(baseDir, sessionUnfollow, + nickname, password, + domain, port, + followNickname, + followDomain, + followPort, + httpPrefix, + cachedWebfingers, + personCache, + debug, __version__) + else: + sayStr = followHandle + ' is not valid' + _sayCommand(sayStr, sayStr, + screenreader, systemLanguage, espeak) + print('') + elif commandStr.startswith('approve '): + approveHandle = commandStr.replace('approve ', '').strip() + if approveHandle.startswith('@'): + approveHandle = approveHandle[1:] + + if '@' in approveHandle or '://' in approveHandle: + approveNickname = getNicknameFromActor(approveHandle) + approveDomain, approvePort = \ + getDomainFromActor(approveHandle) + if approveNickname and approveDomain: + sayStr = 'Sending approve follow request for ' + \ + approveNickname + '@' + approveDomain + _sayCommand(sayStr, sayStr, + screenreader, systemLanguage, espeak) + sessionApprove = createSession(proxyType) + approveFollowRequestViaServer(baseDir, sessionApprove, + nickname, password, + domain, port, + httpPrefix, + approveHandle, + cachedWebfingers, + personCache, + debug, + __version__) + else: + if approveHandle: + sayStr = approveHandle + ' is not valid' + else: + sayStr = 'Specify a handle to approve' + _sayCommand(sayStr, + screenreader, systemLanguage, espeak) + print('') + elif commandStr.startswith('deny '): + denyHandle = commandStr.replace('deny ', '').strip() + if denyHandle.startswith('@'): + denyHandle = denyHandle[1:] + + if '@' in denyHandle or '://' in denyHandle: + denyNickname = getNicknameFromActor(denyHandle) + denyDomain, denyPort = \ + getDomainFromActor(denyHandle) + if denyNickname and denyDomain: + sayStr = 'Sending deny follow request for ' + \ + denyNickname + '@' + denyDomain + _sayCommand(sayStr, sayStr, + screenreader, systemLanguage, espeak) + sessionDeny = createSession(proxyType) + denyFollowRequestViaServer(baseDir, sessionDeny, + nickname, password, + domain, port, + httpPrefix, + denyHandle, + cachedWebfingers, + personCache, + debug, + __version__) + else: + if denyHandle: + sayStr = denyHandle + ' is not valid' + else: + sayStr = 'Specify a handle to deny' + _sayCommand(sayStr, + screenreader, systemLanguage, espeak) + print('') + elif (commandStr == 'repeat' or commandStr == 'replay' or + commandStr == 'rp' or commandStr == 'again' or + commandStr == 'say again'): + if screenreader and nameStr and \ + gender and messageStr and content: + sayStr = 'Repeating ' + nameStr + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak, + nameStr, gender) + time.sleep(2) + _sayCommand(content, messageStr, screenreader, + systemLanguage, espeak, + nameStr, gender) + print('') + elif (commandStr == 'sounds on' or + commandStr == 'sound on' or + commandStr == 'sound'): + sayStr = 'Notification sounds on' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + notificationSounds = True + elif (commandStr == 'sounds off' or + commandStr == 'sound off' or + commandStr == 'nosound'): + sayStr = 'Notification sounds off' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + notificationSounds = False + elif (commandStr == 'speak' or + commandStr == 'screen reader on' or + commandStr == 'speaker on' or + commandStr == 'talker on' or + commandStr == 'reader on'): + if originalScreenReader: + screenreader = originalScreenReader + sayStr = 'Screen reader on' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + else: + print('No --screenreader option was specified') + elif (commandStr == 'mute' or + commandStr == 'screen reader off' or + commandStr == 'speaker off' or + commandStr == 'talker off' or + commandStr == 'reader off'): + if originalScreenReader: + screenreader = None + sayStr = 'Screen reader off' + _sayCommand(sayStr, sayStr, originalScreenReader, + systemLanguage, espeak) + else: + print('No --screenreader option was specified') + elif commandStr.startswith('open'): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject['type'] == 'Announce': + recentPostsCache = {} + allowLocalNetworkAccess = False + YTReplacementDomain = None + postJsonObject2 = \ + downloadAnnounce(session, baseDir, + httpPrefix, + nickname, domain, + postJsonObject, + __version__, translate, + YTReplacementDomain, + allowLocalNetworkAccess, + recentPostsCache, False) + if postJsonObject2: + postJsonObject = postJsonObject2 + if postJsonObject: + content = postJsonObject['object']['content'] + messageStr, detectedLinks = \ + speakableText(baseDir, content, translate) + linkOpened = False + for url in detectedLinks: + if '://' in url: + webbrowser.open(url) + linkOpened = True + if linkOpened: + sayStr = 'Opened web links' + _sayCommand(sayStr, sayStr, originalScreenReader, + systemLanguage, espeak) + else: + sayStr = 'There are no web links to open.' + _sayCommand(sayStr, sayStr, originalScreenReader, + systemLanguage, espeak) + print('') + elif commandStr.startswith('pgp') or commandStr.startswith('gpg'): + if not hasLocalPGPkey(): + print('No PGP public key was found') + else: + print(pgpLocalPublicKey()) + print('') + elif commandStr.startswith('h'): + _desktopHelp() + sayStr = 'Press Enter to continue...' + sayStr2 = _highlightText(sayStr) + _sayCommand(sayStr2, sayStr, + screenreader, systemLanguage, espeak) + input() + prevTimelineFirstId = '' + refreshTimeline = True + elif (commandStr == 'delete' or + commandStr == 'rm' or + commandStr.startswith('delete ') or + commandStr.startswith('rm ')): + currIndex = 0 + if ' ' in commandStr: + postIndex = commandStr.split(' ')[-1].strip() + if postIndex.isdigit(): + currIndex = int(postIndex) + if currIndex > 0 and boxJson: + postJsonObject = \ + _desktopGetBoxPostObject(boxJson, currIndex) + if postJsonObject: + if postJsonObject.get('id'): + rmActor = postJsonObject['object']['attributedTo'] + if rmActor != yourActor: + sayStr = 'You can only delete your own posts' + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + else: + print('') + if postJsonObject['object'].get('summary'): + print(postJsonObject['object']['summary']) + print(postJsonObject['object']['content']) + print('') + sayStr = 'Confirm delete, yes or no?' + _sayCommand(sayStr, sayStr, screenreader, + systemLanguage, espeak) + yesno = input() + if 'y' not in yesno.lower(): + sayStr = 'Deleting post' + _sayCommand(sayStr, sayStr, + screenreader, + systemLanguage, espeak) + sessionrm = createSession(proxyType) + sendDeleteViaServer(baseDir, sessionrm, + nickname, password, + domain, port, + httpPrefix, + postJsonObject['id'], + cachedWebfingers, + personCache, + False, __version__) + refreshTimeline = True + print('') + + if refreshTimeline: + if boxJson: + _desktopShowBox(indent, followRequestsJson, + yourActor, currTimeline, boxJson, + translate, + screenreader, systemLanguage, + espeak, pageNumber, + newRepliesExist, newDMsExist) diff --git a/devices.py b/devices.py index 0f0ef44a9..b2fb09707 100644 --- a/devices.py +++ b/devices.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Security" # REST API overview # @@ -32,13 +33,14 @@ __status__ = "Production" import os from utils import loadJson from utils import saveJson +from utils import acctDir def E2EEremoveDevice(baseDir: str, nickname: str, domain: str, deviceId: str) -> bool: """Unregisters a device for e2ee """ - personDir = baseDir + '/accounts/' + nickname + '@' + domain + personDir = acctDir(baseDir, nickname, domain) deviceFilename = personDir + '/devices/' + deviceId + '.json' if os.path.isfile(deviceFilename): os.remove(deviceFilename) @@ -110,7 +112,7 @@ def E2EEaddDevice(baseDir: str, nickname: str, domain: str, '?' in deviceId or '#' in deviceId or \ '.' in deviceId: return False - personDir = baseDir + '/accounts/' + nickname + '@' + domain + personDir = acctDir(baseDir, nickname, domain) if not os.path.isdir(personDir): return False if not os.path.isdir(personDir + '/devices'): @@ -137,7 +139,7 @@ def E2EEdevicesCollection(baseDir: str, nickname: str, domain: str, domainFull: str, httpPrefix: str) -> {}: """Returns a list of registered devices """ - personDir = baseDir + '/accounts/' + nickname + '@' + domain + personDir = acctDir(baseDir, nickname, domain) if not os.path.isdir(personDir): return {} personId = httpPrefix + '://' + domainFull + '/users/' + nickname diff --git a/donate.py b/donate.py index 0ccdebf99..f0942e502 100644 --- a/donate.py +++ b/donate.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Profile Metadata" def _getDonationTypes() -> str: diff --git a/emoji/002A.png b/emoji/002A.png new file mode 100644 index 000000000..9396efc78 Binary files /dev/null and b/emoji/002A.png differ diff --git a/emoji/1F394.png b/emoji/1F394.png new file mode 100644 index 000000000..8d2e2e635 Binary files /dev/null and b/emoji/1F394.png differ diff --git a/emoji/1F7D5.png b/emoji/1F7D5.png new file mode 100644 index 000000000..649ee3316 Binary files /dev/null and b/emoji/1F7D5.png differ diff --git a/emoji/20E3.png b/emoji/20E3.png new file mode 100644 index 000000000..f3415be0a Binary files /dev/null and b/emoji/20E3.png differ diff --git a/emoji/2388.png b/emoji/2388.png new file mode 100644 index 000000000..19ab44c8e Binary files /dev/null and b/emoji/2388.png differ diff --git a/emoji/2607.png b/emoji/2607.png new file mode 100644 index 000000000..5244ea431 Binary files /dev/null and b/emoji/2607.png differ diff --git a/emoji/261A.png b/emoji/261A.png new file mode 100644 index 000000000..991da46a1 Binary files /dev/null and b/emoji/261A.png differ diff --git a/emoji/2680.png b/emoji/2680.png new file mode 100644 index 000000000..7ada6e1ad Binary files /dev/null and b/emoji/2680.png differ diff --git a/emoji/26A2.png b/emoji/26A2.png new file mode 100644 index 000000000..167e10dd5 Binary files /dev/null and b/emoji/26A2.png differ diff --git a/emoji/26B3.png b/emoji/26B3.png new file mode 100644 index 000000000..28ff2d0d9 Binary files /dev/null and b/emoji/26B3.png differ diff --git a/emoji/26E2.png b/emoji/26E2.png new file mode 100644 index 000000000..ad2a7b891 Binary files /dev/null and b/emoji/26E2.png differ diff --git a/emoji/26E4.png b/emoji/26E4.png new file mode 100644 index 000000000..aab3dfe9f Binary files /dev/null and b/emoji/26E4.png differ diff --git a/emoji/26E8.png b/emoji/26E8.png new file mode 100644 index 000000000..815674746 Binary files /dev/null and b/emoji/26E8.png differ diff --git a/emoji/2700.png b/emoji/2700.png new file mode 100644 index 000000000..0012f48b7 Binary files /dev/null and b/emoji/2700.png differ diff --git a/emoji/default_emoji.json b/emoji/default_emoji.json index f241708d0..a7170135f 100644 --- a/emoji/default_emoji.json +++ b/emoji/default_emoji.json @@ -23,6 +23,8 @@ "atomsymbol": "269B", "autorickshaw": "1F6FA", "babyangel": "1F47C", + "demon": "1F47F", + "devil": "1F47F", "backhandindexpointingup": "1F446", "badminton": "1F3F8", "badger": "1F9A1", @@ -121,14 +123,12 @@ "desktopcomputer": "1F5A5", "diamond": "2666", "dieface1": "2680", - "digitzero": "0030", "dinosaur": "1F995", "dismay": "1F623", "divingmask": "1F93F", "dizzyface": "1F635", "dog": "1F415", "dogface": "1F436", - "dominotilehorizontalback": "1F030", "donut": "1F369", "doublecurlyloop": "27BF", "doubledfemalesign": "26A2", @@ -141,7 +141,6 @@ "ear": "1F442", "earwithhearingaid": "1F9BB", "eat": "1F37D", - "eight": "0038", "eightspokedasterisk": "2733", "ejectbutton": "23CF", "elephant": "1F418", @@ -163,14 +162,12 @@ "filmframes": "1F39E", "filmprojector": "1F4FD", "finish": "1F3C1", - "five": "0035", "flexedbiceps": "1F4AA", "floppydisk": "1F4BE", "football": "26BD", "football2": "1F3C9", "forkandknifewithplate": "1F37D", "fountain": "26F2", - "four": "0034", "framedpicture": "1F5BC", "frogface": "1F438", "frown": "1F626", @@ -192,6 +189,8 @@ "guidedog": "1F9AE", "halloween": "1F383", "wrench": "1F527", + "handgun": "1F52B", + "pistol": "1F52B", "spanner": "1F527", "hammer": "1F528", "hammerandpick": "2692", @@ -207,7 +206,6 @@ "heartanarchy2": "heartanarchy2", "heartexclamation": "2763", "hearttipleft": "1F394", - "heavycircle": "26E3", "hedgehog": "1F994", "helmsymbol": "2388", "hen": "1F414", @@ -232,6 +230,7 @@ "keyboard": "2328", "keyboard2": "1F3B9", "kickscooter": "1F6F4", + "diamondblue": "1F48E", "kiss": "1F48F", "kissing": "1F617", "kissingface": "1F617", @@ -247,7 +246,6 @@ "laughingcat": "1F639", "leafygreen": "1F96C", "leftarrow": "2B05", - "lefthandtelephonereceiver": "1F57B", "leftrightarrow": "2194", "leftspeechbubble": "1F5E8", "leg": "1F9B5", @@ -264,7 +262,6 @@ "loveletter": "1F48C", "loveyougesture": "1F91F", "mahjongreddragon": "1F004", - "mahjongtileeastwind": "1F000", "male": "2642", "mandancing": "1F57A", "maninsuitlevitating": "1F574", @@ -282,15 +279,10 @@ "music": "1F3B5", "nailpolish": "1F485", "network": "E249", - "neuter": "26B2", - "neutralchessking": "1FA00", "noentry": "26D4", - "none": "0039", - "numbersign": "0023", "obutton(bloodtype)": "1F17E", "octopus": "1F419", "om": "1F549", - "one": "0031", "oneo\u2019clock": "1F550", "onion": "1F9C5", "ophiuchus": "26CE", @@ -298,7 +290,6 @@ "orangecircle": "1F7E0", "orca": "1F433", "orthodoxcross": "2626", - "outlinedwhitestar": "269D", "owl": "1F989", "partalternationmark": "303D", "partyingface": "1F973", @@ -311,6 +302,7 @@ "pencil": "270F", "penguin": "1F427", "pentagram": "26E4", + "pentangle": "26E4", "peoplewrestling": "1F93C", "person": "1F9D1", "personbiking": "1F6B4", @@ -349,7 +341,6 @@ "radioactive": "2622", "rainbow": "1F308", "raisedfist": "270A", - "raisedmrsign": "1F16C", "rat": "1F400", "recycle": "267B", "recycling": "267B", @@ -359,18 +350,15 @@ "registered": "00AE", "rescueworkershelmet": "26D1", "reversebutton": "25C0", - "reversedrotatedfloralheartbullet": "2619", "ribbon": "1F380", "rightangerbubble": "1F5EF", "rightarrow": "27A1", "rightarrowcurvingleft": "21A9", "rightarrowcurvingup": "2934", - "rightspeaker": "1F568", "ringedplanet": "1FA90", "robot": "1F916", "rocket": "1F680", "rose": "1F339", - "roundedsymbolforfu": "1F260", "rugby": "1F3C8", "sad": "1F614", "sadcat": "1F63F", @@ -385,7 +373,6 @@ "scissors": "2702", "secret": "1F92B", "selfie": "1F933", - "seven": "0037", "shamrock": "2618", "shark": "1F988", "sheep": "1F411", @@ -396,7 +383,6 @@ "sick": "1F915", "signofthehorns": "1F918", "silly": "1F92A", - "six": "0036", "skate": "1F3BF", "skateboard": "1F6F9", "skier": "26F7", @@ -429,13 +415,11 @@ "stopsign": "1F6D1", "strawberry": "1F353", "studiomicrophone": "1F399", - "stupa": "1F6D3", "sun": "2600", "sunbehindsmallcloud": "1F324", "superhero": "1F9B8", "swim": "1F3CA", "tabletennis": "1F3D3", - "tagspace": "E0020", "tea": "1F372", "telephone": "260E", "tennisball": "1F3BE", @@ -454,13 +438,9 @@ "turtle": "1F422", "twitter": "E040", "birdsite": "E040", - "two": "0032", "umbrellawithraindrops": "2614", "unamusedface": "1F612", - "universalrecyclingsymbol": "2672", - "upperbladescissors": "2701", "upsidedownface": "1F643", - "variationselector16": "FE0F", "victoryhand": "270C", "videocamera": "1F4F9", "violin": "1F3BB", @@ -470,32 +450,23 @@ "waveman": "1F64B-1F3FE-200D-2642-FE0F", "wavewoman": "1F64B-1F3FE-200D-2640-FE0F", "wavydash": "3030", - "westsyriaccross": "2670", "wheelchairsymbol": "267F", "wheelchair": "1F9BD", "wheelofdharma": "2638", "whitecircle": "26AA", - "whitedraughtsman": "26C0", - "whiteflag": "2690", "whiteheart": "1F90D", - "whitelatincross": "1F546", "whitemediumsmallsquare": "25FD", "whitemediumsquare": "25FB", - "whitepennant": "1F3F1", - "whiteshogipiece": "2616", "wiltedflower": "1F940", "wine": "1F377", "winkingfacewithtongue": "1F61C", "worldmap": "1F5FA", "worried": "1F627", "worriedface": "1F61F", - "xiangqiredgeneral": "1FA60", "yawn": "1F971", "yawningface": "1F971", "yoyo": "1FA80", "zebra": "1F993", - "zero": "0030", - "zerowidthjoiner": "200D", "zippermouthface": "1F910", "ghost": "1F47B", "spaceinvader": "1F47E", @@ -758,6 +729,8 @@ "snake": "2695", "battle": "2694", "crown": "1F451", + "shirt": "1F454", + "tshirt": "1F455", "sunflower": "1F33B", "honey": "1F36F", "bee": "1F41D", @@ -765,7 +738,6 @@ "ladybug": "1F41E", "fish": "1F41F", "mobilityscooter": "1F9BC", - "sad": "sad", "globe": "1F30D", "world": "1F30E", "planet": "1F30D", @@ -790,5 +762,9 @@ "talkingemote2": "talkingEmote2", "veryhappyemote": "veryHappyEmote", "worriedemote": "worriedEmote", - "tor": "tor" + "tor": "tor", + "pine64": "pine64", + "void": "void", + "openbsd": "openbsd", + "freebsd": "freebsd" } diff --git a/emoji/freebsd.png b/emoji/freebsd.png new file mode 100644 index 000000000..fff26d416 Binary files /dev/null and b/emoji/freebsd.png differ diff --git a/emoji/openbsd.png b/emoji/openbsd.png new file mode 100644 index 000000000..001594d0f Binary files /dev/null and b/emoji/openbsd.png differ diff --git a/emoji/pine64.png b/emoji/pine64.png new file mode 100644 index 000000000..2a5cc5a00 Binary files /dev/null and b/emoji/pine64.png differ diff --git a/emoji/void.png b/emoji/void.png new file mode 100644 index 000000000..14d75399f Binary files /dev/null and b/emoji/void.png differ diff --git a/epicyon-blog.css b/epicyon-blog.css index 355ca73bd..fc790d702 100644 --- a/epicyon-blog.css +++ b/epicyon-blog.css @@ -1,6 +1,7 @@ @charset "UTF-8"; :root { + --avatar-rounding: 10%; --main-bg-color: #282c37; --link-bg-color: #282c37; --title-color: #999; @@ -795,6 +796,7 @@ div.gallery img { padding: 0px 0px; -ms-transform: translateY(-10%); transform: translateY(-10%); + border-radius: var(--avatar-rounding); } .cwButton { border-radius: var(--button-corner-radius); @@ -1150,6 +1152,7 @@ div.gallery img { padding: 0px 0px; -ms-transform: translateY(-10%); transform: translateY(-10%); + border-radius: var(--avatar-rounding); } .cwButton { border-radius: var(--button-corner-radius); diff --git a/epicyon-calendar.css b/epicyon-calendar.css index 465e6caab..a1fb0f9be 100644 --- a/epicyon-calendar.css +++ b/epicyon-calendar.css @@ -7,9 +7,11 @@ --time-color: black; --place-color: black; --event-color: #282c37; + --event-public-color: #282c37; --today-foreground: white; --today-circle: red; --event-background: orange; + --event-background-private: #ddd; --event-foreground:white; --title-text: #282c37; --title-background: #ccc; @@ -136,8 +138,24 @@ a:focus { font-size: 30px; } +.calItem { + background-color: var(--event-background-private); +} + +.calItemPublic { + background-color: var(--event-background); +} + .calendar__day__event { color: var(--event-color); + background-color: var(--event-background-private); + float: left; + font-size: 28px; + position: relative; + padding: 20px; +} +.calendar__day__event__public { + color: var(--event-public-color); float: left; font-size: 28px; position: relative; diff --git a/epicyon-links.css b/epicyon-links.css index 328f1ae89..67dcdc7ef 100644 --- a/epicyon-links.css +++ b/epicyon-links.css @@ -1,6 +1,7 @@ @charset "UTF-8"; :root { + --avatar-rounding: 10%; --main-bg-color: #282c37; --link-bg-color: #282c37; --dropdown-fg-color: #dddddd; @@ -1052,6 +1053,7 @@ aside .toggle-inside li { padding: 0px 0px; -ms-transform: translateY(-10%); transform: translateY(-10%); + border-radius: var(--avatar-rounding); } .buttonevent { border-radius: var(--button-corner-radius); @@ -1522,6 +1524,7 @@ aside .toggle-inside li { padding: 0px 0px; -ms-transform: translateY(-10%); transform: translateY(-10%); + border-radius: var(--avatar-rounding); } .buttonevent { border-radius: var(--button-corner-radius); diff --git a/epicyon-options.css b/epicyon-options.css index 49d7b8911..c520e8b9b 100644 --- a/epicyon-options.css +++ b/epicyon-options.css @@ -1,6 +1,7 @@ @charset "UTF-8"; :root { + --avatar-rounding: 10%; --options-bg-color: #282c37; --options-link-bg-color: transparent; --options-fg-color: #dddddd; @@ -144,6 +145,7 @@ a:focus { } .options img { + border-radius: var(--avatar-rounding); background-color: var(--options-bg-color); width: 15%; } @@ -239,7 +241,7 @@ a:focus { input[type=text] { width: var(--follow-text-entry-width); clear: both; - font-size: 40px; + font-size: var(--font-size); text-align: center; max-width: 50%; min-width: var(--petname-width-chars); @@ -255,7 +257,7 @@ a:focus { color: var(--button-text); text-align: center; padding: 10px; - font-size: 40px; + font-size: var(--font-size); width: 10ch; max-width: 200px; min-width: 100px; @@ -270,7 +272,7 @@ a:focus { color: var(--button-text); text-align: center; padding: 6px 80px; - font-size: 40px; + font-size: var(--font-size); max-width: 200px; min-width: 100px; cursor: pointer; @@ -283,7 +285,7 @@ a:focus { color: var(--button-small-text); text-align: center; padding: 10px; - font-size: 40px; + font-size: var(--font-size); width: 7ch; max-width: 200px; min-width: 100px; diff --git a/epicyon-profile.css b/epicyon-profile.css index 2afefdc66..e1cc91c4d 100644 --- a/epicyon-profile.css +++ b/epicyon-profile.css @@ -1,6 +1,7 @@ @charset "UTF-8"; :root { + --avatar-rounding: 10%; --timeline-icon-width: 50px; --timeline-icon-width-mobile: 100px; --header-bg-color: #282c37; @@ -87,6 +88,7 @@ --quote-right-margin: 0.1em; --quote-font-weight: normal; --quote-font-size: 120%; + --quote-font-size-mobile: 120%; --line-spacing: 180%; --line-spacing-newswire: 120%; --newswire-item-moderated-color: white; @@ -184,6 +186,30 @@ body, html { image-rendering: var(--rendering); } +video { + width: 100%; +} + +figure { + padding-left: 0; + margin-left: 0; + padding-right: 0; + margin-right: 0; + margin-top: 0; + height: auto; +} + +.accesskeys { + border: 0; + width: 100%; +} +.accesskeys-left { + width: 90%; +} +.accesskeys-right { + width: 10%; +} + .cw { font-style: var(--cw-style); font-weight: var(--cw-weight); @@ -317,7 +343,7 @@ a:focus { } .profileHeader .title { - border-radius: 50%; + border-radius: var(--avatar-rounding); position: absolute; bottom: 100%; left: 25px; @@ -426,6 +452,10 @@ a:focus { background-color: var(--timeline-posts-background-color); } +.container img.markdownImage { + width: 100%; +} + .container img.timelineicon:hover { filter: brightness(var(--icon-brightness-change)); } @@ -599,15 +629,15 @@ a:focus { vertical-align: middle; } -.darker { +.container.darker { background-color: var(--main-bg-color-reply); } -.dm { +.container.dm { background-color: var(--main-bg-color-dm); } -.report { +.container.report { border-color: #255; background-color: var(--main-bg-color-report); } @@ -1304,6 +1334,7 @@ div.container { padding: 0px 0px; -ms-transform: translateY(-10%); transform: translateY(-10%); + border-radius: var(--avatar-rounding); } .buttonevent { border-radius: var(--button-event-corner-radius); @@ -1662,6 +1693,9 @@ div.container { font-size: var(--font-size-mobile); color: var(--title-color); } + blockquote { + font-size: var(--quote-font-size-mobile); + } .accountsTable { width: 100%; border: 0; @@ -1947,6 +1981,7 @@ div.container { padding: 0px 0px; -ms-transform: translateY(-10%); transform: translateY(-10%); + border-radius: var(--avatar-rounding); } .buttonevent { border-radius: var(--button-event-corner-radius); diff --git a/epicyon-welcome.css b/epicyon-welcome.css new file mode 100644 index 000000000..89ccadb2c --- /dev/null +++ b/epicyon-welcome.css @@ -0,0 +1,243 @@ +@chaste "UTF-8"; + +:root { + --welcome-bg-color: #282c37; + --link-bg-color: #282c37; + --welcome-fg-color: #dddddd; + --main-link-color: #999; + --main-visited-color: #888; + --border-color: #505050; + --border-width: 2px; + --font-size-header: 18px; + --font-color-header: #ccc; + --welcome-font-size: 22px; + --welcome-font-size-mobile: 40px; + --text-entry-foreground: #ccc; + --text-entry-background: #111; + --time-color: #aaa; + --welcome-button-width: 12ch; + --button-text: #FFFFFF; + --button-background: #999; + --button-selected: #666; + --form-border-radius: 30px; + --focus-color: white; + --line-spacing: 130%; + --welcome-logo-width: 20%; + --welcome-avatar-width: 40%; + --main-link-color-hover: #bbb; + --rendering: normal; +} + +@font-face { + font-family: 'Bedstead'; + font-style: italic; + font-weight: normal; + font-display: block; + src: url('./fonts/bedstead.otf') format('opentype'); +} +@font-face { + font-family: 'Bedstead'; + font-style: normal; + font-weight: normal; + font-display: block; + src: url('./fonts/bedstead.otf') format('opentype'); +} + +body, html { + background-color: var(--welcome-bg-color); + color: var(--welcome-fg-color); + + background-image: url("/welcome-background.jpg"); + background-size: cover; + -webkit-background-size: cover; + -moz-background-size: cover; + background-repeat: no-repeat; + background-position: center; + height: 100%; + font-family: Arial, Helvetica, sans-serif; + max-width: 60%; + min-width: 600px; + margin: 0 auto; + font-size: var(--welcome-font-size); + line-height: var(--line-spacing); + image-rendering: var(--rendering); +} + +a, u { + color: var(--welcome-fg-color); +} + +a:visited{ + color: var(--main-visited-color); + background: var(--link-bg-color); + font-weight: normal; + text-decoration: none; +} + +a:link { + color: var(--main-link-color); + background: var(--link-bg-color); + font-weight: normal; + text-decoration: none; +} + +a:link:hover { + color: var(--main-link-color-hover); +} + +a:visited:hover { + color: var(--main-link-color-hover); +} + +a:focus { + border: 2px solid var(--focus-color); +} + +form { + border: var(--border-width) solid var(--border-color); + border-radius: var(--form-border-radius); +} + +.transparent { + color: transparent; + background: transparent; + font-size: 0px; + line-height: 0px; + height: 0px; +} + +button { + background-color: var(--button-background); + color: var(--button-text); + padding: 14px 20px; + margin: 8px 0; + border: none; + cursor: pointer; + width: var(--welcome-button-width); + font-size: var(--welcome-font-size); + font-family: Arial, Helvetica, sans-serif; +} + +.welcome-text { + font-size: var(--welcome-font-size); + font-family: Arial, Helvetica, sans-serif; +} + +button:hover { + opacity: 0.8; +} + +.imgcontainer { + text-align: center; + margin: 24px 0 12px 0; +} + +.imgcontainer img { + width: var(--welcome-logo-width); +} + +img.avatar { + width: 40%; + border-radius: 50%; +} + +.container { + padding: 16px; +} + +.container img.welcomeavatar { + width: var(--welcome-avatar-width); +} + +.container img.markdownImage { + width: 100%; +} + +.container.next { + float: right; +} + +span.psw { + float: right; + padding-top: 16px; +} + +@media screen and (min-width: 400px) { + body, html { + background-color: var(--welcome-bg-color); + color: var(--welcome-fg-color); + height: 100%; + font-family: Arial, Helvetica, sans-serif; + max-width: 60%; + min-width: 600px; + margin: 0 auto; + font-size: var(--welcome-font-size); + font-family: Arial, Helvetica, sans-serif; + position: relative; + } + .welcome-text { + font-size: var(--welcome-font-size); + font-family: Arial, Helvetica, sans-serif; + } + input[type=text], input[type=password], textarea { + width: 100%; + padding: 12px 20px; + margin: 8px 0; + display: inline-block; + border: 1px solid #ccc; + box-sizing: border-box; + font-size: var(--welcome-font-size); + font-family: Arial, Helvetica, sans-serif; + } + button { + background-color: var(--button-background); + color: var(--button-text); + padding: 14px 20px; + margin: 8px 0; + border: none; + cursor: pointer; + width: var(--welcome-button-width); + font-size: var(--welcome-font-size); + font-family: Arial, Helvetica, sans-serif; + } +} + +@media screen and (max-width: 1000px) { + body, html { + background-color: var(--welcome-bg-color); + color: var(--welcome-fg-color); + height: 100%; + font-family: Arial, Helvetica, sans-serif; + max-width: 95%; + min-width: 600px; + margin: 0 auto; + font-size: var(--welcome-font-size-mobile); + font-family: Arial, Helvetica, sans-serif; + position: relative; + } + .welcome-text { + font-size: var(--welcome-font-size-mobile); + font-family: Arial, Helvetica, sans-serif; + } + input[type=text], input[type=password], textarea { + width: 100%; + padding: 12px 20px; + margin: 8px 0; + display: inline-block; + border: 1px solid #ccc; + box-sizing: border-box; + font-size: var(--welcome-font-size-mobile); + font-family: Arial, Helvetica, sans-serif; + } + button { + background-color: var(--button-background); + color: var(--button-text); + padding: 14px 20px; + margin: 8px 0; + border: none; + cursor: pointer; + width: var(--welcome-button-width); + font-size: var(--welcome-font-size-mobile); + font-family: Arial, Helvetica, sans-serif; + } +} diff --git a/epicyon.py b/epicyon.py index cf0289c65..0fc7f86d6 100644 --- a/epicyon.py +++ b/epicyon.py @@ -5,7 +5,15 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Commandline Interface" +import os +import shutil +import sys +import time +import argparse +import getpass +from person import getActorJson from person import createPerson from person import createGroup from person import setProfileImage @@ -15,6 +23,11 @@ from person import deactivateAccount from skills import setSkillLevel from roles import setRole from webfinger import webfingerHandle +from bookmarks import sendBookmarkViaServer +from bookmarks import sendUndoBookmarkViaServer +from posts import sendMuteViaServer +from posts import sendUndoMuteViaServer +from posts import c2sBoxJson from posts import downloadFollowCollection from posts import getPublicPostDomains from posts import getPublicPostDomainsBlocked @@ -32,12 +45,11 @@ from session import getJson from newswire import getRSS from filters import addFilter from filters import removeFilter -import os -import shutil -import sys -import time from pprint import pprint from daemon import runDaemon +from follow import getFollowRequestsViaServer +from follow import getFollowingViaServer +from follow import getFollowersViaServer from follow import clearFollows from follow import followerOfPerson from follow import sendFollowRequestViaServer @@ -45,9 +57,12 @@ from follow import sendUnfollowRequestViaServer from tests import testPostMessageBetweenServers from tests import testFollowBetweenServers from tests import testClientToServer +from tests import testUpdateActor from tests import runAllTests from auth import storeBasicCredentials from auth import createPassword +from utils import removeDomainPort +from utils import getPortFromDomain from utils import hasUsersPath from utils import getFullDomain from utils import setConfigParam @@ -57,12 +72,12 @@ from utils import getNicknameFromActor from utils import followPerson from utils import validNickname from utils import getProtocolPrefixes +from utils import acctDir from media import archiveMedia from media import getAttachmentMediaType from delete import sendDeleteViaServer from like import sendLikeViaServer from like import sendUndoLikeViaServer -from roles import sendRoleViaServer from skills import sendSkillViaServer from availability import setAvailability from availability import sendAvailabilityViaServer @@ -75,7 +90,7 @@ from theme import setTheme from announce import sendAnnounceViaServer from socnet import instancesGraph from migrate import migrateAccounts -import argparse +from desktop_client import runDesktopClient def str2bool(v) -> bool: @@ -92,9 +107,15 @@ def str2bool(v) -> bool: parser = argparse.ArgumentParser(description='ActivityPub Server') +parser.add_argument('--userAgentBlocks', type=str, + default=None, + help='List of blocked user agents, separated by commas') parser.add_argument('-n', '--nickname', dest='nickname', type=str, default=None, help='Nickname of the account to use') +parser.add_argument('--screenreader', dest='screenreader', type=str, + default=None, + help='Name of the screen reader: espeak/picospeaker') parser.add_argument('--fol', '--follow', dest='follow', type=str, default=None, help='Handle of account to follow. eg. nickname@domain') @@ -105,6 +126,11 @@ parser.add_argument('--unfol', '--unfollow', dest='unfollow', type=str, parser.add_argument('-d', '--domain', dest='domain', type=str, default=None, help='Domain name of the server') +parser.add_argument('--notificationType', '--notifyType', + dest='notificationType', type=str, + default='notify-send', + help='Type of desktop notification command: ' + + 'notify-send/zenity/osascript/New-BurntToastNotification') parser.add_argument('-o', '--onion', dest='onion', type=str, default=None, help='Onion domain name of the server if ' + @@ -231,6 +257,24 @@ parser.add_argument('--rss', dest='rss', type=str, default=None, help='Show an rss feed for a given url') parser.add_argument('-f', '--federate', nargs='+', dest='federationList', help='Specify federation list separated by spaces') +parser.add_argument("--following", "--followingList", + dest='followingList', + type=str2bool, nargs='?', + const=True, default=False, + help="Get the following list. Use nickname and " + + "domain options to specify the account") +parser.add_argument("--followersList", + dest='followersList', + type=str2bool, nargs='?', + const=True, default=False, + help="Get the followers list. Use nickname and " + + "domain options to specify the account") +parser.add_argument("--followRequestsList", + dest='followRequestsList', + type=str2bool, nargs='?', + const=True, default=False, + help="Get the follow requests list. Use nickname and " + + "domain options to specify the account") parser.add_argument("--repliesEnabled", "--commentsEnabled", dest='commentsEnabled', type=str2bool, nargs='?', @@ -254,6 +298,11 @@ parser.add_argument("--iconsAsButtons", type=str2bool, nargs='?', const=True, default=False, help="Show header icons as buttons") +parser.add_argument("--logLoginFailures", + dest='logLoginFailures', + type=str2bool, nargs='?', + const=True, default=False, + help="Whether to log longin failures") parser.add_argument("--rssIconAtTop", dest='rssIconAtTop', type=str2bool, nargs='?', @@ -284,6 +333,27 @@ parser.add_argument("--brochMode", type=str2bool, nargs='?', const=True, default=False, help="Enable broch mode") +parser.add_argument("--nodeinfoaccounts", + dest='showNodeInfoAccounts', + type=str2bool, nargs='?', + const=True, default=False, + help="Show numbers of accounts within nodeinfo metadata") +parser.add_argument("--nodeinfoversion", + dest='showNodeInfoVersion', + type=str2bool, nargs='?', + const=True, default=False, + help="Show version number within nodeinfo metadata") +parser.add_argument("--noKeyPress", + dest='noKeyPress', + type=str2bool, nargs='?', + const=True, default=False, + help="Notification daemon does not wait for keypresses") +parser.add_argument("--notifyShowNewPosts", + dest='notifyShowNewPosts', + type=str2bool, nargs='?', + const=True, default=False, + help="Desktop client shows/speaks new posts " + + "as they arrive") parser.add_argument("--noapproval", type=str2bool, nargs='?', const=True, default=False, help="Allow followers without approval") @@ -306,6 +376,9 @@ parser.add_argument("--positivevoting", type=str2bool, nargs='?', parser.add_argument("--debug", type=str2bool, nargs='?', const=True, default=False, help="Show debug messages") +parser.add_argument("--notificationSounds", type=str2bool, nargs='?', + const=True, default=True, + help="Play notification sounds") parser.add_argument("--authenticatedFetch", type=str2bool, nargs='?', const=True, default=False, help="Enable authentication on GET requests" + @@ -379,16 +452,32 @@ parser.add_argument("--allowdeletion", type=str2bool, nargs='?', parser.add_argument('--repeat', '--announce', dest='announce', type=str, default=None, help='Announce/repeat a url') +parser.add_argument('--box', type=str, + default=None, + help='Returns the json for a given timeline, ' + + 'with authentication') +parser.add_argument('--page', '--pageNumber', dest='pageNumber', type=int, + default=1, + help='Page number when using the --box option') parser.add_argument('--favorite', '--like', dest='like', type=str, default=None, help='Like a url') parser.add_argument('--undolike', '--unlike', dest='undolike', type=str, default=None, help='Undo a like of a url') +parser.add_argument('--bookmark', '--bm', dest='bookmark', type=str, + default=None, + help='Bookmark the url of a post') +parser.add_argument('--unbookmark', '--unbm', dest='unbookmark', type=str, + default=None, + help='Undo a bookmark given the url of a post') parser.add_argument('--sendto', dest='sendto', type=str, default=None, help='Address to send a post to') parser.add_argument('--attach', dest='attach', type=str, default=None, help='File to attach to a post') parser.add_argument('--imagedescription', dest='imageDescription', type=str, default=None, help='Description of an attached image') +parser.add_argument('--city', dest='city', type=str, + default='London, England', + help='Spoofed city for image metadata misdirection') parser.add_argument('--warning', '--warn', '--cwsubject', '--subject', dest='subject', type=str, default=None, help='Subject of content warning') @@ -417,9 +506,6 @@ parser.add_argument('--maxEmoji', '--maxemoji', dest='maxEmoji', help='Maximum number of emoji within a post') parser.add_argument('--role', dest='role', type=str, default=None, help='Set a role for a person') -parser.add_argument('--organization', '--project', dest='project', - type=str, default=None, - help='Set a project for a person') parser.add_argument('--skill', dest='skill', type=str, default=None, help='Set a skill for a person') parser.add_argument('--level', dest='skillLevelPercent', type=int, @@ -429,15 +515,17 @@ parser.add_argument('--level', dest='skillLevelPercent', type=int, parser.add_argument('--status', '--availability', dest='availability', type=str, default=None, help='Set an availability status') +parser.add_argument('--desktop', dest='desktop', + type=str, default=None, + help='Run desktop client') parser.add_argument('--block', dest='block', type=str, default=None, help='Block a particular address') parser.add_argument('--unblock', dest='unblock', type=str, default=None, help='Remove a block on a particular address') -parser.add_argument('--delegate', dest='delegate', type=str, default=None, - help='Address of an account to delegate a role to') -parser.add_argument('--undodelegate', '--undelegate', dest='undelegate', - type=str, default=None, - help='Removes a delegated role for the given address') +parser.add_argument('--mute', dest='mute', type=str, default=None, + help='Mute a particular post URL') +parser.add_argument('--unmute', dest='unmute', type=str, default=None, + help='Unmute a particular post URL') parser.add_argument('--filter', dest='filterStr', type=str, default=None, help='Adds a word or phrase which if present will ' + 'cause a message to be ignored') @@ -474,13 +562,13 @@ parser.add_argument('--location', dest='location', type=str, default=None, parser.add_argument('--duration', dest='duration', type=str, default=None, help='Duration for which to share an item') parser.add_argument('--registration', dest='registration', type=str, - default=None, + default='open', help='Whether new registrations are open or closed') parser.add_argument("--nosharedinbox", type=str2bool, nargs='?', const=True, default=False, help='Disable shared inbox') parser.add_argument('--maxregistrations', dest='maxRegistrations', - type=int, default=None, + type=int, default=10, help='The maximum number of new registrations') parser.add_argument("--resetregistrations", type=str2bool, nargs='?', const=True, default=False, @@ -491,6 +579,9 @@ args = parser.parse_args() debug = False if args.debug: debug = True +else: + if os.path.isfile('debug'): + debug = True if args.tests: runAllTests() @@ -500,6 +591,7 @@ if args.testsnetwork: testPostMessageBetweenServers() testFollowBetweenServers() testClientToServer() + testUpdateActor() print('All tests succeeded') sys.exit() @@ -705,7 +797,7 @@ if args.json: 'Accept': 'application/ld+json; profile="' + profileStr + '"' } testJson = getJson(session, args.json, asHeader, None, - __version__, httpPrefix, None) + debug, __version__, httpPrefix, None) pprint(testJson) sys.exit() @@ -815,31 +907,16 @@ else: # if this is the initial run then allow new registrations if not getConfigParam(baseDir, 'registration'): - setConfigParam(baseDir, 'registration', 'open') - setConfigParam(baseDir, 'maxRegistrations', str(maxRegistrations)) - setConfigParam(baseDir, 'registrationsRemaining', str(maxRegistrations)) + if args.registration.lower() == 'open': + setConfigParam(baseDir, 'registration', 'open') + setConfigParam(baseDir, 'maxRegistrations', str(maxRegistrations)) + setConfigParam(baseDir, 'registrationsRemaining', + str(maxRegistrations)) if args.resetregistrations: setConfigParam(baseDir, 'registrationsRemaining', str(maxRegistrations)) print('Number of new registrations reset to ' + str(maxRegistrations)) -# whether new registrations are open or closed -if args.registration: - if args.registration.lower() == 'open': - registration = getConfigParam(baseDir, 'registration') - if not registration: - setConfigParam(baseDir, 'registrationsRemaining', - str(maxRegistrations)) - else: - if registration != 'open': - setConfigParam(baseDir, 'registrationsRemaining', - str(maxRegistrations)) - setConfigParam(baseDir, 'registration', 'open') - print('New registrations open') - else: - setConfigParam(baseDir, 'registration', 'closed') - print('New registrations closed') - # unique ID for the instance instanceId = getConfigParam(baseDir, 'instanceId') if not instanceId: @@ -964,7 +1041,7 @@ if args.followerspending: print('Specify a nickname with the --nickname option') sys.exit() - accountsDir = baseDir + '/accounts/' + args.nickname + '@' + domain + accountsDir = acctDir(baseDir, args.nickname, domain) approveFollowsFilename = accountsDir + '/followrequests.txt' approveCtr = 0 if os.path.isfile(approveFollowsFilename): @@ -983,8 +1060,11 @@ if args.message: sys.exit() if not args.password: - print('Specify a password with the --password option') - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') session = createSession(proxyType) if not args.sendto: @@ -1003,8 +1083,8 @@ if args.message: toDomain = toDomain.replace('\n', '').replace('\r', '') toPort = 443 if ':' in toDomain: - toPort = toDomain.split(':')[1] - toDomain = toDomain.split(':')[0] + toPort = getPortFromDomain(toDomain) + toDomain = removeDomainPort(toDomain) else: if args.sendto.endswith('followers'): toNickname = None @@ -1015,12 +1095,13 @@ if args.message: toDomain = 'public' toPort = port - # ccUrl=httpPrefix+'://'+domain+'/users/'+nickname+'/followers' + # ccUrl = httpPrefix + '://' + domain + '/users/' + nickname + '/followers' ccUrl = None sendMessage = args.message followersOnly = args.followersonly clientToServer = args.client attachedImageDescription = args.imageDescription + city = 'London, England' sendThreads = [] postLog = [] personCache = {} @@ -1041,7 +1122,7 @@ if args.message: toNickname, toDomain, toPort, ccUrl, httpPrefix, sendMessage, followersOnly, args.commentsEnabled, attach, mediaType, - attachedImageDescription, + attachedImageDescription, city, cachedWebfingers, personCache, isArticle, args.debug, replyTo, replyTo, subject) for i in range(10): @@ -1055,8 +1136,11 @@ if args.announce: sys.exit() if not args.password: - print('Specify a password with the --password option') - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') session = createSession(proxyType) personCache = {} @@ -1073,10 +1157,53 @@ if args.announce: time.sleep(1) sys.exit() +if args.box: + if not domain: + print('Specify a domain with the --domain option') + sys.exit() + + if not args.nickname: + print('Specify a nickname with the --nickname option') + sys.exit() + + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') + + proxyType = None + if args.tor or domain.endswith('.onion'): + proxyType = 'tor' + if domain.endswith('.onion'): + args.port = 80 + elif args.i2p or domain.endswith('.i2p'): + proxyType = 'i2p' + if domain.endswith('.i2p'): + args.port = 80 + elif args.gnunet: + proxyType = 'gnunet' + + session = createSession(proxyType) + boxJson = c2sBoxJson(baseDir, session, + args.nickname, args.password, + domain, port, httpPrefix, + args.box, args.pageNumber, + args.debug) + if boxJson: + pprint(boxJson) + else: + print('Box not found: ' + args.box) + sys.exit() + if args.itemName: if not args.password: - print('Specify a password with the --password option') - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') if not args.nickname: print('Specify a nickname with the --nickname option') @@ -1131,8 +1258,11 @@ if args.itemName: if args.undoItemName: if not args.password: - print('Specify a password with the --password option') - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') if not args.nickname: print('Specify a nickname with the --nickname option') @@ -1161,8 +1291,11 @@ if args.like: sys.exit() if not args.password: - print('Specify a password with the --password option') - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') session = createSession(proxyType) personCache = {} @@ -1186,8 +1319,11 @@ if args.undolike: sys.exit() if not args.password: - print('Specify a password with the --password option') - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') session = createSession(proxyType) personCache = {} @@ -1205,14 +1341,73 @@ if args.undolike: time.sleep(1) sys.exit() +if args.bookmark: + if not args.nickname: + print('Specify a nickname with the --nickname option') + sys.exit() + + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') + + session = createSession(proxyType) + personCache = {} + cachedWebfingers = {} + print('Sending bookmark of ' + args.bookmark) + + sendBookmarkViaServer(baseDir, session, + args.nickname, args.password, + domain, port, + httpPrefix, args.bookmark, + cachedWebfingers, personCache, + True, __version__) + for i in range(10): + # TODO detect send success/fail + time.sleep(1) + sys.exit() + +if args.unbookmark: + if not args.nickname: + print('Specify a nickname with the --nickname option') + sys.exit() + + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') + + session = createSession(proxyType) + personCache = {} + cachedWebfingers = {} + print('Sending undo bookmark of ' + args.unbookmark) + + sendUndoBookmarkViaServer(baseDir, session, + args.nickname, args.password, + domain, port, + httpPrefix, args.unbookmark, + cachedWebfingers, personCache, + True, __version__) + for i in range(10): + # TODO detect send success/fail + time.sleep(1) + sys.exit() + if args.delete: if not args.nickname: print('Specify a nickname with the --nickname option') sys.exit() if not args.password: - print('Specify a password with the --password option') - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') session = createSession(proxyType) personCache = {} @@ -1239,9 +1434,11 @@ if args.follow: print('Please specify the nickname for the account with --nickname') sys.exit() if not args.password: - print('Please specify the password for ' + args.nickname + - ' on ' + domain) - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') followNickname = getNicknameFromActor(args.follow) if not followNickname: @@ -1278,8 +1475,11 @@ if args.unfollow: print('Please specify the nickname for the account with --nickname') sys.exit() if not args.password: - print('Please specify the password for '+args.nickname+' on '+domain) - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') followNickname = getNicknameFromActor(args.unfollow) if not followNickname: @@ -1307,6 +1507,90 @@ if args.unfollow: print('Ok') sys.exit() +if args.followingList: + # following list via c2s protocol + if not args.nickname: + print('Please specify the nickname for the account with --nickname') + sys.exit() + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') + + session = createSession(proxyType) + personCache = {} + cachedWebfingers = {} + followHttpPrefix = httpPrefix + + followingJson = \ + getFollowingViaServer(baseDir, session, + args.nickname, args.password, + domain, port, + httpPrefix, args.pageNumber, + cachedWebfingers, personCache, + debug, __version__) + if followingJson: + pprint(followingJson) + sys.exit() + +if args.followersList: + # following list via c2s protocol + if not args.nickname: + print('Please specify the nickname for the account with --nickname') + sys.exit() + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') + + session = createSession(proxyType) + personCache = {} + cachedWebfingers = {} + followHttpPrefix = httpPrefix + + followersJson = \ + getFollowersViaServer(baseDir, session, + args.nickname, args.password, + domain, port, + httpPrefix, args.pageNumber, + cachedWebfingers, personCache, + debug, __version__) + if followersJson: + pprint(followersJson) + sys.exit() + +if args.followRequestsList: + # follow requests list via c2s protocol + if not args.nickname: + print('Please specify the nickname for the account with --nickname') + sys.exit() + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') + + session = createSession(proxyType) + personCache = {} + cachedWebfingers = {} + followHttpPrefix = httpPrefix + + followRequestsJson = \ + getFollowRequestsViaServer(baseDir, session, + args.nickname, args.password, + domain, port, + httpPrefix, args.pageNumber, + cachedWebfingers, personCache, + debug, __version__) + if followRequestsJson: + pprint(followRequestsJson) + sys.exit() + nickname = 'admin' if args.domain: domain = args.domain @@ -1319,9 +1603,7 @@ if args.proxyPort: setConfigParam(baseDir, 'proxyPort', proxyPort) if args.gnunet: httpPrefix = 'gnunet' -if args.dat: - httpPrefix = 'dat' -if args.hyper: +if args.dat or args.hyper: httpPrefix = 'hyper' if args.i2p: httpPrefix = 'http' @@ -1354,137 +1636,7 @@ if args.migrations: sys.exit() if args.actor: - originalActor = args.actor - if '/@' in args.actor or \ - '/users/' in args.actor or \ - args.actor.startswith('http') or \ - args.actor.startswith('dat'): - # format: https://domain/@nick - prefixes = getProtocolPrefixes() - for prefix in prefixes: - args.actor = args.actor.replace(prefix, '') - args.actor = args.actor.replace('/@', '/users/') - if not hasUsersPath(args.actor): - print('Expected actor format: ' + - 'https://domain/@nick or https://domain/users/nick') - sys.exit() - if '/users/' in args.actor: - nickname = args.actor.split('/users/')[1] - nickname = nickname.replace('\n', '').replace('\r', '') - domain = args.actor.split('/users/')[0] - elif '/profile/' in args.actor: - nickname = args.actor.split('/profile/')[1] - nickname = nickname.replace('\n', '').replace('\r', '') - domain = args.actor.split('/profile/')[0] - elif '/channel/' in args.actor: - nickname = args.actor.split('/channel/')[1] - nickname = nickname.replace('\n', '').replace('\r', '') - domain = args.actor.split('/channel/')[0] - elif '/accounts/' in args.actor: - nickname = args.actor.split('/accounts/')[1] - nickname = nickname.replace('\n', '').replace('\r', '') - domain = args.actor.split('/accounts/')[0] - elif '/u/' in args.actor: - nickname = args.actor.split('/u/')[1] - nickname = nickname.replace('\n', '').replace('\r', '') - domain = args.actor.split('/u/')[0] - else: - # format: @nick@domain - if '@' not in args.actor: - print('Syntax: --actor nickname@domain') - sys.exit() - if args.actor.startswith('@'): - args.actor = args.actor[1:] - if '@' not in args.actor: - print('Syntax: --actor nickname@domain') - sys.exit() - nickname = args.actor.split('@')[0] - domain = args.actor.split('@')[1] - domain = domain.replace('\n', '').replace('\r', '') - cachedWebfingers = {} - if args.http or domain.endswith('.onion'): - httpPrefix = 'http' - port = 80 - proxyType = 'tor' - elif domain.endswith('.i2p'): - httpPrefix = 'http' - port = 80 - proxyType = 'i2p' - elif args.gnunet: - httpPrefix = 'gnunet' - port = 80 - proxyType = 'gnunet' - else: - httpPrefix = 'https' - port = 443 - session = createSession(proxyType) - if nickname == 'inbox': - nickname = domain - - handle = nickname + '@' + domain - wfRequest = webfingerHandle(session, handle, - httpPrefix, cachedWebfingers, - None, __version__) - if not wfRequest: - print('Unable to webfinger ' + handle) - sys.exit() - if not isinstance(wfRequest, dict): - print('Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) - sys.exit() - - pprint(wfRequest) - - personUrl = None - if wfRequest.get('errors'): - print('wfRequest error: ' + str(wfRequest['errors'])) - if hasUsersPath(args.actor): - personUrl = originalActor - else: - sys.exit() - - profileStr = 'https://www.w3.org/ns/activitystreams' - asHeader = { - 'Accept': 'application/activity+json; profile="' + profileStr + '"' - } - if not personUrl: - personUrl = getUserUrl(wfRequest) - if nickname == domain: - personUrl = personUrl.replace('/users/', '/actor/') - personUrl = personUrl.replace('/accounts/', '/actor/') - personUrl = personUrl.replace('/channel/', '/actor/') - personUrl = personUrl.replace('/profile/', '/actor/') - personUrl = personUrl.replace('/u/', '/actor/') - if not personUrl: - # try single user instance - personUrl = httpPrefix + '://' + domain - profileStr = 'https://www.w3.org/ns/activitystreams' - asHeader = { - 'Accept': 'application/ld+json; profile="' + profileStr + '"' - } - if '/channel/' in personUrl or '/accounts/' in personUrl: - profileStr = 'https://www.w3.org/ns/activitystreams' - asHeader = { - 'Accept': 'application/ld+json; profile="' + profileStr + '"' - } - - personJson = \ - getJson(session, personUrl, asHeader, None, __version__, - httpPrefix, None) - if personJson: - pprint(personJson) - else: - profileStr = 'https://www.w3.org/ns/activitystreams' - asHeader = { - 'Accept': 'application/jrd+json; profile="' + profileStr + '"' - } - personJson = \ - getJson(session, personUrl, asHeader, None, - __version__, httpPrefix, None) - if personJson: - pprint(personJson) - else: - print('Failed to get ' + personUrl) + getActorJson(args.domain, args.actor, args.http, args.gnunet, debug) sys.exit() if args.followers: @@ -1492,7 +1644,7 @@ if args.followers: if '/@' in args.followers or \ '/users/' in args.followers or \ args.followers.startswith('http') or \ - args.followers.startswith('dat'): + args.followers.startswith('hyper'): # format: https://domain/@nick prefixes = getProtocolPrefixes() for prefix in prefixes: @@ -1558,7 +1710,7 @@ if args.followers: handle = nickname + '@' + domain wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - None, __version__) + None, __version__, debug) if not wfRequest: print('Unable to webfinger ' + handle) sys.exit() @@ -1617,16 +1769,28 @@ if args.addaccount: if not args.domain or not getConfigParam(baseDir, 'domain'): print('Use the --domain option to set the domain name') sys.exit() + + configuredDomain = getConfigParam(baseDir, 'domain') + if configuredDomain: + if domain != configuredDomain: + print('The account domain is expected to be ' + configuredDomain) + sys.exit() + if not validNickname(domain, nickname): print(nickname + ' is a reserved name. Use something different.') sys.exit() + if not args.password: - print('Use the --password option to set the password for ' + nickname) - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') if len(args.password.strip()) < 8: print('Password should be at least 8 characters') sys.exit() - if os.path.isdir(baseDir + '/accounts/' + nickname + '@' + domain): + accountDir = acctDir(baseDir, nickname, domain) + if os.path.isdir(accountDir): print('Account already exists') sys.exit() if os.path.isdir(baseDir + '/deactivated/' + nickname + '@' + domain): @@ -1638,7 +1802,7 @@ if args.addaccount: httpPrefix = 'http' createPerson(baseDir, nickname, domain, port, httpPrefix, True, not args.noapproval, args.password.strip()) - if os.path.isdir(baseDir + '/accounts/' + nickname + '@' + domain): + if os.path.isdir(accountDir): print('Account created for ' + nickname + '@' + domain) else: print('Account creation failed') @@ -1657,17 +1821,21 @@ if args.addgroup: print(nickname + ' is a reserved name. Use something different.') sys.exit() if not args.password: - print('Use the --password option to set the password for ' + nickname) - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') if len(args.password.strip()) < 8: print('Password should be at least 8 characters') sys.exit() - if os.path.isdir(baseDir + '/accounts/' + nickname + '@' + domain): + accountDir = acctDir(baseDir, nickname, domain) + if os.path.isdir(accountDir): print('Group already exists') sys.exit() createGroup(baseDir, nickname, domain, port, httpPrefix, True, args.password.strip()) - if os.path.isdir(baseDir + '/accounts/' + nickname + '@' + domain): + if os.path.isdir(accountDir): print('Group created for ' + nickname + '@' + domain) else: print('Group creation failed') @@ -1688,6 +1856,17 @@ if args.rmaccount: if not args.domain or not getConfigParam(baseDir, 'domain'): print('Use the --domain option to set the domain name') sys.exit() + if args.domain: + domain = args.domain + else: + domain = getConfigParam(baseDir, 'domain') + + configuredDomain = getConfigParam(baseDir, 'domain') + if configuredDomain: + if domain != configuredDomain: + print('The account domain is expected to be ' + configuredDomain) + sys.exit() + if args.deactivate: if deactivateAccount(baseDir, nickname, domain): print('Account for ' + nickname + '@' + domain + @@ -1734,7 +1913,8 @@ if args.changepassword: if len(newPassword) < 8: print('Password should be at least 8 characters') sys.exit() - if not os.path.isdir(baseDir + '/accounts/' + nickname + '@' + domain): + accountDir = acctDir(baseDir, nickname, domain) + if not os.path.isdir(accountDir): print('Account ' + nickname + '@' + domain + ' not found') sys.exit() passwordFile = baseDir + '/accounts/passwords' @@ -1772,8 +1952,9 @@ if args.avatar: if not args.nickname: print('Specify a nickname with --nickname [name]') sys.exit() + city = 'London, England' if setProfileImage(baseDir, httpPrefix, args.nickname, domain, - port, args.avatar, 'avatar', '128x128'): + port, args.avatar, 'avatar', '128x128', city): print('Avatar added for ' + args.nickname) else: print('Avatar was not added for ' + args.nickname) @@ -1786,39 +1967,26 @@ if args.backgroundImage: if not args.nickname: print('Specify a nickname with --nickname [name]') sys.exit() + city = 'London, England' if setProfileImage(baseDir, httpPrefix, args.nickname, domain, - port, args.backgroundImage, 'background', '256x256'): + port, args.backgroundImage, 'background', + '256x256', city): print('Background image added for ' + args.nickname) else: print('Background image was not added for ' + args.nickname) sys.exit() -if args.project: - if not args.delegate and not args.undelegate: - if not nickname: - print('No nickname given') - sys.exit() - - if args.role.lower() == 'none' or \ - args.role.lower() == 'remove' or \ - args.role.lower() == 'delete': - args.role = None - if args.role: - if setRole(baseDir, nickname, domain, args.project, args.role): - print('Role within ' + args.project + ' set to ' + args.role) - else: - if setRole(baseDir, nickname, domain, args.project, None): - print('Left ' + args.project) - sys.exit() - if args.skill: if not nickname: print('Specify a nickname with the --nickname option') sys.exit() if not args.password: - print('Specify a password with the --password option') - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') if not args.skillLevelPercent: print('Specify a skill level in the range 0-100') @@ -1853,8 +2021,11 @@ if args.availability: sys.exit() if not args.password: - print('Specify a password with the --password option') - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') session = createSession(proxyType) personCache = {} @@ -1873,6 +2044,55 @@ if args.availability: time.sleep(1) sys.exit() +if args.desktop: + # Announce posts as they arrive in your inbox using text-to-speech + if args.desktop.startswith('@'): + args.desktop = args.desktop[1:] + if '@' not in args.desktop: + print('Specify the handle to notify: nickname@domain') + sys.exit() + nickname = args.desktop.split('@')[0] + domain = args.desktop.split('@')[1] + + if not nickname: + print('Specify a nickname with the --nickname option') + sys.exit() + + if not args.password: + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + + args.password = args.password.replace('\n', '') + + proxyType = None + if args.tor or domain.endswith('.onion'): + proxyType = 'tor' + if domain.endswith('.onion'): + args.port = 80 + elif args.i2p or domain.endswith('.i2p'): + proxyType = 'i2p' + if domain.endswith('.i2p'): + args.port = 80 + elif args.gnunet: + proxyType = 'gnunet' + + # only store inbox posts if we are not running as a daemon + storeInboxPosts = not args.noKeyPress + + runDesktopClient(baseDir, proxyType, httpPrefix, + nickname, domain, port, args.password, + args.screenreader, args.language, + args.notificationSounds, + args.notificationType, + args.noKeyPress, + storeInboxPosts, + args.notifyShowNewPosts, + args.language, + args.debug) + sys.exit() + if federationList: print('Federating with: ' + str(federationList)) @@ -1882,8 +2102,11 @@ if args.block: sys.exit() if not args.password: - print('Specify a password with the --password option') - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') if '@' in args.block: blockedDomain = args.block.split('@')[1] @@ -1912,38 +2135,26 @@ if args.block: time.sleep(1) sys.exit() -if args.delegate: +if args.mute: if not nickname: print('Specify a nickname with the --nickname option') sys.exit() if not args.password: - print('Specify a password with the --password option') - sys.exit() - - if not args.project: - print('Specify a project with the --project option') - sys.exit() - - if not args.role: - print('Specify a role with the --role option') - sys.exit() - - if '@' in args.delegate: - delegatedNickname = args.delegate.split('@')[0] - args.delegate = blockedActor + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') session = createSession(proxyType) personCache = {} cachedWebfingers = {} - print('Sending delegation for ' + args.delegate + - ' with role ' + args.role + ' in project ' + args.project) + print('Sending mute of ' + args.mute) - sendRoleViaServer(baseDir, session, - nickname, args.password, + sendMuteViaServer(baseDir, session, nickname, args.password, domain, port, - httpPrefix, args.delegate, - args.project, args.role, + httpPrefix, args.mute, cachedWebfingers, personCache, True, __version__) for i in range(10): @@ -1951,36 +2162,28 @@ if args.delegate: time.sleep(1) sys.exit() -if args.undelegate: +if args.unmute: if not nickname: print('Specify a nickname with the --nickname option') sys.exit() if not args.password: - print('Specify a password with the --password option') - sys.exit() - - if not args.project: - print('Specify a project with the --project option') - sys.exit() - - if '@' in args.undelegate: - delegatedNickname = args.undelegate.split('@')[0] - args.undelegate = blockedActor + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') session = createSession(proxyType) personCache = {} cachedWebfingers = {} - print('Sending delegation removal for ' + args.undelegate + - ' from role ' + args.role + ' in project ' + args.project) + print('Sending undo mute of ' + args.unmute) - sendRoleViaServer(baseDir, session, - nickname, args.password, - domain, port, - httpPrefix, args.delegate, - args.project, None, - cachedWebfingers, personCache, - True, __version__) + sendUndoMuteViaServer(baseDir, session, nickname, args.password, + domain, port, + httpPrefix, args.unmute, + cachedWebfingers, personCache, + True, __version__) for i in range(10): # TODO detect send success/fail time.sleep(1) @@ -1992,8 +2195,11 @@ if args.unblock: sys.exit() if not args.password: - print('Specify a password with the --password option') - sys.exit() + args.password = getpass.getpass('Password: ') + if not args.password: + print('Specify a password with the --password option') + sys.exit() + args.password = args.password.replace('\n', '') if '@' in args.unblock: blockedDomain = args.unblock.split('@')[1] @@ -2039,6 +2245,7 @@ if args.unfilterStr: sys.exit() if args.testdata: + city = 'London, England' nickname = 'testuser567' password = 'boringpassword' print('Generating some test data for user: ' + nickname) @@ -2072,9 +2279,7 @@ if args.testdata: True, False, 'likewhateveryouwantscoob') setSkillLevel(baseDir, nickname, domain, 'testing', 60) setSkillLevel(baseDir, nickname, domain, 'typing', 50) - setRole(baseDir, nickname, domain, 'instance', 'admin') - setRole(baseDir, nickname, domain, 'epicyon', 'hacker') - setRole(baseDir, nickname, domain, 'someproject', 'assistant') + setRole(baseDir, nickname, domain, 'admin') setAvailability(baseDir, nickname, domain, 'busy') addShare(baseDir, @@ -2086,7 +2291,7 @@ if args.testdata: "mechanical", "City", "2 months", - debug) + debug, city) addShare(baseDir, httpPrefix, nickname, domain, port, "witch hat", @@ -2096,86 +2301,125 @@ if args.testdata: "clothing", "City", "3 months", - debug) + debug, city) deleteAllPosts(baseDir, nickname, domain, 'inbox') deleteAllPosts(baseDir, nickname, domain, 'outbox') testFollowersOnly = False testSaveToFile = True - testClientToServer = False + testC2S = False testCommentsEnabled = True testAttachImageFilename = None testMediaType = None testImageDescription = None + testCity = 'London, England' + testInReplyTo = None + testInReplyToAtomUri = None + testSubject = None + testSchedulePost = False + testEventDate = None + testEventTime = None + testLocation = None + testIsArticle = False createPublicPost(baseDir, nickname, domain, port, httpPrefix, "like this is totally just a #test man", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, - testMediaType, testImageDescription) + testMediaType, testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "Zoiks!!!", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, - testMediaType, testImageDescription) + testMediaType, testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "Hey scoob we need like a hundred more #milkshakes", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, - testMediaType, testImageDescription) + testMediaType, testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "Getting kinda spooky around here", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, - testMediaType, testImageDescription, - 'someone') + testMediaType, testImageDescription, testCity, + 'someone', testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "And they would have gotten away with it too" + "if it wasn't for those pesky hackers", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, 'img/logo.png', 'image/png', - 'Description of image') + 'Description of image', testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "man these centralized sites are like the worst!", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, - testMediaType, testImageDescription) + testMediaType, testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "another mystery solved #test", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, - testMediaType, testImageDescription) + testMediaType, testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) createPublicPost(baseDir, nickname, domain, port, httpPrefix, "let's go bowling", testFollowersOnly, testSaveToFile, - testClientToServer, + testC2S, testCommentsEnabled, testAttachImageFilename, - testMediaType, testImageDescription) - + testMediaType, testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) domainFull = domain + ':' + str(port) clearFollows(baseDir, nickname, domain) followPerson(baseDir, nickname, domain, 'maxboardroom', domainFull, @@ -2302,6 +2546,38 @@ brochMode = \ if brochMode is not None: args.brochMode = bool(brochMode) +logLoginFailures = \ + getConfigParam(baseDir, 'logLoginFailures') +if logLoginFailures is not None: + args.logLoginFailures = bool(logLoginFailures) + +showNodeInfoAccounts = \ + getConfigParam(baseDir, 'showNodeInfoAccounts') +if showNodeInfoAccounts is not None: + args.showNodeInfoAccounts = bool(showNodeInfoAccounts) + +showNodeInfoVersion = \ + getConfigParam(baseDir, 'showNodeInfoVersion') +if showNodeInfoVersion is not None: + args.showNodeInfoVersion = bool(showNodeInfoVersion) + +userAgentsBlocked = [] +if args.userAgentBlocks: + userAgentsBlockedStr = args.userAgentBlocks + setConfigParam(baseDir, 'userAgentsBlocked', userAgentsBlockedStr) +else: + userAgentsBlockedStr = \ + getConfigParam(baseDir, 'userAgentsBlocked') +if userAgentsBlockedStr: + agentBlocksList = userAgentsBlockedStr.split(',') + for agentBlockStr in agentBlocksList: + userAgentsBlocked.append(agentBlockStr.strip()) + +city = \ + getConfigParam(baseDir, 'city') +if city is not None: + args.city = city + YTDomain = getConfigParam(baseDir, 'youtubedomain') if YTDomain: if '://' in YTDomain: @@ -2311,11 +2587,34 @@ if YTDomain: if '.' in YTDomain: args.YTReplacementDomain = YTDomain -if setTheme(baseDir, themeName, domain, args.allowLocalNetworkAccess): +if setTheme(baseDir, themeName, domain, + args.allowLocalNetworkAccess, args.language): print('Theme set to ' + themeName) +# whether new registrations are open or closed +if args.registration: + if args.registration.lower() == 'open': + registration = getConfigParam(baseDir, 'registration') + if not registration: + setConfigParam(baseDir, 'registrationsRemaining', + str(maxRegistrations)) + else: + if registration != 'open': + setConfigParam(baseDir, 'registrationsRemaining', + str(maxRegistrations)) + setConfigParam(baseDir, 'registration', 'open') + print('New registrations open') + else: + setConfigParam(baseDir, 'registration', 'closed') + print('New registrations closed') + if __name__ == "__main__": - runDaemon(args.brochMode, + runDaemon(userAgentsBlocked, + args.logLoginFailures, + args.city, + args.showNodeInfoAccounts, + args.showNodeInfoVersion, + args.brochMode, args.verifyAllSignatures, args.sendThreadsTimeoutMins, args.dormantMonths, diff --git a/feeds.py b/feeds.py index a1671d5cd..310243981 100644 --- a/feeds.py +++ b/feeds.py @@ -5,19 +5,18 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "RSS Feeds" def rss2TagHeader(hashtag: str, httpPrefix: str, domainFull: str) -> str: - rssStr = "" - rssStr += "" - rssStr += '' - rssStr += ' #' + hashtag + '' - rssStr += ' ' + httpPrefix + '://' + domainFull + \ + return \ + "" + \ + "" + \ + '' + \ + ' #' + hashtag + '' + \ + ' ' + httpPrefix + '://' + domainFull + \ '/tags/rss2/' + hashtag + '' - return rssStr def rss2TagFooter() -> str: - rssStr = '' - rssStr += '' - return rssStr + return '' diff --git a/filters.py b/filters.py index 3b470abc9..581f75eb0 100644 --- a/filters.py +++ b/filters.py @@ -5,21 +5,21 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Moderation" import os +from utils import acctDir def addFilter(baseDir: str, nickname: str, domain: str, words: str) -> bool: """Adds a filter for particular words within the content of a incoming posts """ - filtersFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/filters.txt' + filtersFilename = acctDir(baseDir, nickname, domain) + '/filters.txt' if os.path.isfile(filtersFilename): if words in open(filtersFilename).read(): return False - filtersFile = open(filtersFilename, "a+") - filtersFile.write(words + '\n') - filtersFile.close() + with open(filtersFilename, 'a+') as filtersFile: + filtersFile.write(words + '\n') return True @@ -35,9 +35,8 @@ def addGlobalFilter(baseDir: str, words: str) -> bool: if os.path.isfile(filtersFilename): if words in open(filtersFilename).read(): return False - filtersFile = open(filtersFilename, "a+") - filtersFile.write(words + '\n') - filtersFile.close() + with open(filtersFilename, 'a+') as filtersFile: + filtersFile.write(words + '\n') return True @@ -45,19 +44,21 @@ def removeFilter(baseDir: str, nickname: str, domain: str, words: str) -> bool: """Removes a word filter """ - filtersFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/filters.txt' - if os.path.isfile(filtersFilename): - if words in open(filtersFilename).read(): - with open(filtersFilename, 'r') as fp: - with open(filtersFilename + '.new', 'w+') as fpnew: - for line in fp: - line = line.replace('\n', '') - if line != words: - fpnew.write(line + '\n') - if os.path.isfile(filtersFilename + '.new'): - os.rename(filtersFilename + '.new', filtersFilename) - return True + filtersFilename = acctDir(baseDir, nickname, domain) + '/filters.txt' + if not os.path.isfile(filtersFilename): + return False + if words not in open(filtersFilename).read(): + return False + newFiltersFilename = filtersFilename + '.new' + with open(filtersFilename, 'r') as fp: + with open(newFiltersFilename, 'w+') as fpnew: + for line in fp: + line = line.replace('\n', '') + if line != words: + fpnew.write(line + '\n') + if os.path.isfile(newFiltersFilename): + os.rename(newFiltersFilename, filtersFilename) + return True return False @@ -65,17 +66,20 @@ def removeGlobalFilter(baseDir: str, words: str) -> bool: """Removes a global word filter """ filtersFilename = baseDir + '/accounts/filters.txt' - if os.path.isfile(filtersFilename): - if words in open(filtersFilename).read(): - with open(filtersFilename, 'r') as fp: - with open(filtersFilename + '.new', 'w+') as fpnew: - for line in fp: - line = line.replace('\n', '') - if line != words: - fpnew.write(line + '\n') - if os.path.isfile(filtersFilename + '.new'): - os.rename(filtersFilename + '.new', filtersFilename) - return True + if not os.path.isfile(filtersFilename): + return False + if words not in open(filtersFilename).read(): + return False + newFiltersFilename = filtersFilename + '.new' + with open(filtersFilename, 'r') as fp: + with open(newFiltersFilename, 'w+') as fpnew: + for line in fp: + line = line.replace('\n', '') + if line != words: + fpnew.write(line + '\n') + if os.path.isfile(newFiltersFilename): + os.rename(newFiltersFilename, filtersFilename) + return True return False @@ -129,12 +133,11 @@ def isFiltered(baseDir: str, nickname: str, domain: str, content: str) -> bool: return False # optionally remove retweets - removeTwitter = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/.removeTwitter' + removeTwitter = acctDir(baseDir, nickname, domain) + '/.removeTwitter' if os.path.isfile(removeTwitter): if _isTwitterPost(content): return True - accountFiltersFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/filters.txt' + accountFiltersFilename = \ + acctDir(baseDir, nickname, domain) + '/filters.txt' return _isFilteredBase(accountFiltersFilename, content) diff --git a/follow.py b/follow.py index cce18f1b2..2654989e4 100644 --- a/follow.py +++ b/follow.py @@ -5,9 +5,12 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "ActivityPub" from pprint import pprint import os +from utils import removeDomainPort +from utils import hasObjectDict from utils import hasUsersPath from utils import getFullDomain from utils import isSystemAccount @@ -22,21 +25,25 @@ from posts import sendSignedJson from posts import getPersonBox from utils import loadJson from utils import saveJson +from utils import isAccountDir +from utils import getUserPaths +from utils import acctDir from acceptreject import createAccept from acceptreject import createReject from webfinger import webfingerHandle from auth import createBasicAuthHeader +from session import getJson from session import postJson def createInitialLastSeen(baseDir: str, httpPrefix: str) -> None: - """Creates initial lastseen files for all follows + """Creates initial lastseen files for all follows. + The lastseen files are used to generate the Zzz icons on + follows/following lists on the profile screen. """ 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) followingFilename = accountDir + '/following.txt' @@ -84,7 +91,7 @@ def _removeFromFollowBase(baseDir: str, nickname: str, domain: str, acceptOrDenyHandle: str, followFile: str, debug: bool) -> None: - """Removes a handle from follow requests or rejects file + """Removes a handle/actor from follow requests or rejects file """ handle = nickname + '@' + domain accountsDir = baseDir + '/accounts/' + handle @@ -94,14 +101,35 @@ def _removeFromFollowBase(baseDir: str, print('WARN: Approve follow requests file ' + approveFollowsFilename + ' not found') return + acceptDenyActor = None if acceptOrDenyHandle not in open(approveFollowsFilename).read(): - return - approvefilenew = open(approveFollowsFilename + '.new', 'w+') - with open(approveFollowsFilename, 'r') as approvefile: - for approveHandle in approvefile: - if not approveHandle.startswith(acceptOrDenyHandle): - approvefilenew.write(approveHandle) - approvefilenew.close() + # is this stored in the file as an actor rather than a handle? + acceptDenyNickname = acceptOrDenyHandle.split('@')[0] + acceptDenyDomain = acceptOrDenyHandle.split('@')[1] + # for each possible users path construct an actor and + # check if it exists in teh file + usersPaths = ('users', 'profile', 'channel', 'accounts', 'u') + actorFound = False + for usersName in usersPaths: + acceptDenyActor = \ + '://' + acceptDenyDomain + '/' + \ + usersName + '/' + acceptDenyNickname + if acceptDenyActor in open(approveFollowsFilename).read(): + actorFound = True + break + if not actorFound: + return + with open(approveFollowsFilename + '.new', 'w+') as approvefilenew: + with open(approveFollowsFilename, 'r') as approvefile: + if not acceptDenyActor: + for approveHandle in approvefile: + if not approveHandle.startswith(acceptOrDenyHandle): + approvefilenew.write(approveHandle) + else: + for approveHandle in approvefile: + if acceptDenyActor not in approveHandle: + approvefilenew.write(approveHandle) + os.rename(approveFollowsFilename + '.new', approveFollowsFilename) @@ -126,9 +154,9 @@ def _removeFromFollowRejects(baseDir: str, def isFollowingActor(baseDir: str, nickname: str, domain: str, actor: str) -> bool: """Is the given nickname following the given actor? + The actor can also be a handle: nickname@domain """ - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domain) handle = nickname + '@' + domain if not os.path.isdir(baseDir + '/accounts/' + handle): return False @@ -179,10 +207,8 @@ def isFollowerOfPerson(baseDir: str, nickname: str, domain: str, followerNickname: str, followerDomain: str) -> bool: """is the given nickname a follower of followerNickname? """ - if ':' in domain: - domain = domain.split(':')[0] - followersFile = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/followers.txt' + domain = removeDomainPort(domain) + followersFile = acctDir(baseDir, nickname, domain) + '/followers.txt' if not os.path.isfile(followersFile): return False handle = followerNickname + '@' + followerDomain @@ -195,30 +221,24 @@ def isFollowerOfPerson(baseDir: str, nickname: str, domain: str, if handle in followersStr: alreadyFollowing = True - elif '://' + followerDomain + \ - '/profile/' + followerNickname in followersStr: - alreadyFollowing = True - elif '://' + followerDomain + \ - '/channel/' + followerNickname in followersStr: - alreadyFollowing = True - elif '://' + followerDomain + \ - '/accounts/' + followerNickname in followersStr: - alreadyFollowing = True - elif '://' + followerDomain + \ - '/u/' + followerNickname in followersStr: - alreadyFollowing = True + else: + paths = getUserPaths() + for userPath in paths: + url = '://' + followerDomain + userPath + followerNickname + if url in followersStr: + alreadyFollowing = True + break return alreadyFollowing def unfollowAccount(baseDir: str, nickname: str, domain: str, followNickname: str, followDomain: str, - followFile='following.txt', - debug=False) -> bool: + followFile: str = 'following.txt', + debug: bool = False) -> bool: """Removes a person to the follow list """ - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domain) handle = nickname + '@' + domain handleToUnfollow = followNickname + '@' + followDomain if not os.path.isdir(baseDir + '/accounts'): @@ -237,7 +257,7 @@ def unfollowAccount(baseDir: str, nickname: str, domain: str, print('DEBUG: handle to unfollow ' + handleToUnfollow + ' is not in ' + filename) return - with open(filename, "r") as f: + with open(filename, 'r') as f: lines = f.readlines() with open(filename, 'w+') as f: for line in lines: @@ -251,10 +271,10 @@ def unfollowAccount(baseDir: str, nickname: str, domain: str, if os.path.isfile(unfollowedFilename): if handleToUnfollowLower not in \ open(unfollowedFilename).read().lower(): - with open(unfollowedFilename, "a+") as f: + with open(unfollowedFilename, 'a+') as f: f.write(handleToUnfollow + '\n') else: - with open(unfollowedFilename, "w+") as f: + with open(unfollowedFilename, 'w+') as f: f.write(handleToUnfollow + '\n') return True @@ -262,7 +282,7 @@ def unfollowAccount(baseDir: str, nickname: str, domain: str, def unfollowerOfAccount(baseDir: str, nickname: str, domain: str, followerNickname: str, followerDomain: str, - debug=False) -> bool: + debug: bool = False) -> bool: """Remove a follower of a person """ return unfollowAccount(baseDir, nickname, domain, @@ -304,7 +324,7 @@ def _getNoOfFollows(baseDir: str, nickname: str, domain: str, if not os.path.isfile(filename): return 0 ctr = 0 - with open(filename, "r") as f: + with open(filename, 'r') as f: lines = f.readlines() for line in lines: if '#' in line: @@ -314,7 +334,7 @@ def _getNoOfFollows(baseDir: str, nickname: str, domain: str, not line.startswith('http'): ctr += 1 elif ((line.startswith('http') or - line.startswith('dat')) and + line.startswith('hyper')) and hasUsersPath(line)): ctr += 1 return ctr @@ -329,14 +349,14 @@ def _getNoOfFollowers(baseDir: str, def getFollowingFeed(baseDir: str, domain: str, port: int, path: str, - httpPrefix: str, authenticated: bool, + httpPrefix: str, authorized: bool, followsPerPage=12, followFile='following') -> {}: """Returns the following and followers feeds from GET requests. This accesses the following.txt or followers.txt and builds a collection. """ - # Show a small number of follows to non-authenticated viewers - if not authenticated: + # Show a small number of follows to non-authorized viewers + if not authorized: followsPerPage = 6 if '/' + followFile not in path: @@ -346,7 +366,7 @@ def getFollowingFeed(baseDir: str, domain: str, port: int, path: str, pageNumber = None if '?page=' in path: pageNumber = path.split('?page=')[1] - if pageNumber == 'true' or not authenticated: + if pageNumber == 'true' or not authorized: pageNumber = 1 else: try: @@ -378,7 +398,7 @@ def getFollowingFeed(baseDir: str, domain: str, port: int, path: str, httpPrefix + '://' + domain + '/users/' + \ nickname + '/' + followFile totalStr = \ - _getNoOfFollows(baseDir, nickname, domain, authenticated) + _getNoOfFollows(baseDir, nickname, domain, authorized) following = { '@context': 'https://www.w3.org/ns/activitystreams', 'first': firstStr, @@ -407,8 +427,7 @@ def getFollowingFeed(baseDir: str, domain: str, port: int, path: str, } handleDomain = domain - if ':' in handleDomain: - handleDomain = domain.split(':')[0] + handleDomain = removeDomainPort(handleDomain) handle = nickname + '@' + handleDomain filename = baseDir + '/accounts/' + handle + '/' + followFile + '.txt' if not os.path.isfile(filename): @@ -416,7 +435,7 @@ def getFollowingFeed(baseDir: str, domain: str, port: int, path: str, currPage = 1 pageCtr = 0 totalCtr = 0 - with open(filename, "r") as f: + with open(filename, 'r') as f: lines = f.readlines() for line in lines: if '#' not in line: @@ -433,7 +452,7 @@ def getFollowingFeed(baseDir: str, domain: str, port: int, path: str, line2.split('@')[0] following['orderedItems'].append(url) elif ((line.startswith('http') or - line.startswith('dat')) and + line.startswith('hyper')) and hasUsersPath(line)): # https://domain/users/nickname pageCtr += 1 @@ -467,8 +486,7 @@ def _followApprovalRequired(baseDir: str, nicknameToFollow: str, return False manuallyApproveFollows = False - if ':' in domainToFollow: - domainToFollow = domainToFollow.split(':')[0] + domainToFollow = removeDomainPort(domainToFollow) actorFilename = baseDir + '/accounts/' + \ nicknameToFollow + '@' + domainToFollow + '.json' if os.path.isfile(actorFilename): @@ -498,7 +516,7 @@ def _noOfFollowRequests(baseDir: str, if not os.path.isfile(approveFollowsFilename): return 0 ctr = 0 - with open(approveFollowsFilename, "r") as f: + with open(approveFollowsFilename, 'r') as f: lines = f.readlines() if followType == "onion": for fileLine in lines: @@ -582,7 +600,7 @@ def _storeFollowRequest(baseDir: str, print('DEBUG: ' + approveHandleStored + ' is already awaiting approval') else: - with open(approveFollowsFilename, "w+") as fp: + with open(approveFollowsFilename, 'w+') as fp: fp.write(approveHandleStored + '\n') # store the follow request in its own directory @@ -740,9 +758,8 @@ def receiveFollowRequest(session, baseDir: str, httpPrefix: str, 'Failed to write entry to followers file ' + str(e)) else: - followersFile = open(followersFilename, "w+") - followersFile.write(approveHandle + '\n') - followersFile.close() + with open(followersFilename, 'w+') as followersFile: + followersFile.write(approveHandle + '\n') print('Beginning follow accept') return followedAccountAccepts(session, baseDir, httpPrefix, @@ -789,8 +806,8 @@ def followedAccountAccepts(session, baseDir: str, httpPrefix: str, if removeFollowActivity: # remove the follow request json followActivityfilename = \ - baseDir + '/accounts/' + \ - nicknameToFollow + '@' + domainToFollow + '/requests/' + \ + acctDir(baseDir, nicknameToFollow, domainToFollow) + \ + '/requests/' + \ nickname + '@' + domain + '.follow' if os.path.isfile(followActivityfilename): try: @@ -827,8 +844,7 @@ def followedAccountRejects(session, baseDir: str, httpPrefix: str, # get the json for the original follow request followActivityfilename = \ - baseDir + '/accounts/' + \ - nicknameToFollow + '@' + domainToFollow + '/requests/' + \ + acctDir(baseDir, nicknameToFollow, domainToFollow) + '/requests/' + \ nickname + '@' + domain + '.follow' followJson = loadJson(followActivityfilename) if not followJson: @@ -967,14 +983,14 @@ def sendFollowRequestViaServer(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) + print('DEBUG: follow request 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: follow request Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -988,11 +1004,12 @@ def sendFollowRequestViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: follow request no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: follow request no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -1003,14 +1020,15 @@ def sendFollowRequestViaServer(baseDir: str, session, 'Authorization': authHeader } postResult = \ - postJson(session, newFollowJson, [], inboxUrl, headers) + postJson(httpPrefix, fromDomainFull, + session, newFollowJson, [], inboxUrl, headers, 3, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST follow request failed for c2s to ' + inboxUrl) return 5 if debug: - print('DEBUG: c2s POST follow success') + print('DEBUG: c2s POST follow request success') return newFollowJson @@ -1056,14 +1074,14 @@ def sendUnfollowRequestViaServer(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) + print('DEBUG: unfollow 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: unfollow webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -1080,11 +1098,12 @@ def sendUnfollowRequestViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: unfollow no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: unfollow no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -1095,10 +1114,11 @@ def sendUnfollowRequestViaServer(baseDir: str, session, 'Authorization': authHeader } postResult = \ - postJson(session, unfollowJson, [], inboxUrl, headers) + postJson(httpPrefix, fromDomainFull, + session, unfollowJson, [], inboxUrl, headers, 3, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST unfollow failed for c2s to ' + inboxUrl) return 5 if debug: @@ -1107,6 +1127,207 @@ def sendUnfollowRequestViaServer(baseDir: str, session, return unfollowJson +def getFollowingViaServer(baseDir: str, session, + nickname: str, password: str, + domain: str, port: int, + httpPrefix: str, pageNumber: int, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> {}: + """Gets a page from the following collection as json + """ + if not session: + print('WARN: No session for getFollowingViaServer') + return 6 + + domainFull = getFullDomain(domain, port) + followActor = httpPrefix + '://' + domainFull + '/users/' + nickname + + authHeader = createBasicAuthHeader(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + + if pageNumber < 1: + pageNumber = 1 + url = followActor + '/following?page=' + str(pageNumber) + followingJson = \ + getJson(session, url, headers, {}, debug, + __version__, httpPrefix, + domain, 10, True) + if not followingJson: + if debug: + print('DEBUG: GET following list failed for c2s to ' + url) + return 5 + + if debug: + print('DEBUG: c2s GET following list request success') + + return followingJson + + +def getFollowersViaServer(baseDir: str, session, + nickname: str, password: str, + domain: str, port: int, + httpPrefix: str, pageNumber: int, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> {}: + """Gets a page from the followers collection as json + """ + if not session: + print('WARN: No session for getFollowersViaServer') + return 6 + + domainFull = getFullDomain(domain, port) + followActor = httpPrefix + '://' + domainFull + '/users/' + nickname + + authHeader = createBasicAuthHeader(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + + if pageNumber < 1: + pageNumber = 1 + url = followActor + '/followers?page=' + str(pageNumber) + followersJson = \ + getJson(session, url, headers, {}, debug, + __version__, httpPrefix, domain, 10, True) + if not followersJson: + if debug: + print('DEBUG: GET followers list failed for c2s to ' + url) + return 5 + + if debug: + print('DEBUG: c2s GET followers list request success') + + return followersJson + + +def getFollowRequestsViaServer(baseDir: str, session, + nickname: str, password: str, + domain: str, port: int, + httpPrefix: str, pageNumber: int, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> {}: + """Gets a page from the follow requests collection as json + """ + if not session: + print('WARN: No session for getFollowRequestsViaServer') + return 6 + + domainFull = getFullDomain(domain, port) + + followActor = httpPrefix + '://' + domainFull + '/users/' + nickname + authHeader = createBasicAuthHeader(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + + if pageNumber < 1: + pageNumber = 1 + url = followActor + '/followrequests?page=' + str(pageNumber) + followersJson = \ + getJson(session, url, headers, {}, debug, + __version__, httpPrefix, domain, 10, True) + if not followersJson: + if debug: + print('DEBUG: GET follow requests list failed for c2s to ' + url) + return 5 + + if debug: + print('DEBUG: c2s GET follow requests list request success') + + return followersJson + + +def approveFollowRequestViaServer(baseDir: str, session, + nickname: str, password: str, + domain: str, port: int, + httpPrefix: str, approveHandle: int, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> str: + """Approves a follow request + This is not exactly via c2s though. It simulates pressing the Approve + button on the web interface + """ + if not session: + print('WARN: No session for approveFollowRequestViaServer') + return 6 + + domainFull = getFullDomain(domain, port) + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + + authHeader = createBasicAuthHeader(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'text/html; charset=utf-8', + 'Authorization': authHeader + } + + url = actor + '/followapprove=' + approveHandle + approveHtml = \ + getJson(session, url, headers, {}, debug, + __version__, httpPrefix, domain, 10, True) + if not approveHtml: + if debug: + print('DEBUG: GET approve follow request failed for c2s to ' + url) + return 5 + + if debug: + print('DEBUG: c2s GET approve follow request request success') + + return approveHtml + + +def denyFollowRequestViaServer(baseDir: str, session, + nickname: str, password: str, + domain: str, port: int, + httpPrefix: str, denyHandle: int, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> str: + """Denies a follow request + This is not exactly via c2s though. It simulates pressing the Deny + button on the web interface + """ + if not session: + print('WARN: No session for denyFollowRequestViaServer') + return 6 + + domainFull = getFullDomain(domain, port) + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + + authHeader = createBasicAuthHeader(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'text/html; charset=utf-8', + 'Authorization': authHeader + } + + url = actor + '/followdeny=' + denyHandle + denyHtml = \ + getJson(session, url, headers, {}, debug, + __version__, httpPrefix, domain, 10, True) + if not denyHtml: + if debug: + print('DEBUG: GET deny follow request failed for c2s to ' + url) + return 5 + + if debug: + print('DEBUG: c2s GET deny follow request request success') + + return denyHtml + + def getFollowersOfActor(baseDir: str, actor: str, debug: bool) -> {}: """In a shared inbox if we receive a post we know who it's from and if it's addressed to followers then we need to get a list of those. @@ -1162,9 +1383,7 @@ def outboxUndoFollow(baseDir: str, messageJson: {}, debug: bool) -> None: return if not messageJson['type'] == 'Undo': return - if not messageJson.get('object'): - return - if not isinstance(messageJson['object'], dict): + if not hasObjectDict(messageJson): return if not messageJson['object'].get('type'): return @@ -1213,7 +1432,7 @@ def followerApprovalActive(baseDir: str, nickname: str, domain: str) -> bool: """Returns true if the given account requires follower approval """ manuallyApprovesFollowers = False - actorFilename = baseDir + '/accounts/' + nickname + '@' + domain + '.json' + actorFilename = acctDir(baseDir, nickname, domain) + '.json' if os.path.isfile(actorFilename): actorJson = loadJson(actorFilename) if actorJson: diff --git a/followingCalendar.py b/followingCalendar.py index 64e795090..9f038899e 100644 --- a/followingCalendar.py +++ b/followingCalendar.py @@ -5,10 +5,26 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Calendar" import os +def _dirAcct(baseDir: str, nickname: str, domain: str) -> str: + return baseDir + '/accounts/' + nickname + '@' + domain + +def _portDomainRemove(domain: str) -> str: + """If the domain has a port appended then remove it + eg. mydomain.com:80 becomes mydomain.com + same as removeDomainPort in utils.py + """ + if ':' in domain: + if domain.startswith('did:'): + return domain + domain = domain.split(':')[0] + return domain + + def receivingCalendarEvents(baseDir: str, nickname: str, domain: str, followingNickname: str, followingDomain: str) -> bool: @@ -18,12 +34,12 @@ def receivingCalendarEvents(baseDir: str, nickname: str, domain: str, if followingNickname == nickname and followingDomain == domain: # reminder post return True - calendarFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/followingCalendar.txt' + calendarFilename = \ + _dirAcct(baseDir, nickname, domain) + '/followingCalendar.txt' handle = followingNickname + '@' + followingDomain if not os.path.isfile(calendarFilename): - followingFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/following.txt' + followingFilename = \ + _dirAcct(baseDir, nickname, domain) + '/following.txt' if not os.path.isfile(followingFilename): return False # create a new calendar file from the following file @@ -42,10 +58,8 @@ def _receiveCalendarEvents(baseDir: str, nickname: str, domain: str, indicating whether to receive calendar events from that account """ # check that a following file exists - if ':' in domain: - domain = domain.split(':')[0] - followingFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/following.txt' + domain = _portDomainRemove(domain) + followingFilename = _dirAcct(baseDir, nickname, domain) + '/following.txt' if not os.path.isfile(followingFilename): print("WARN: following.txt doesn't exist for " + nickname + '@' + domain) @@ -57,8 +71,8 @@ def _receiveCalendarEvents(baseDir: str, nickname: str, domain: str, print('WARN: ' + handle + ' is not in ' + followingFilename) return - calendarFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/followingCalendar.txt' + calendarFilename = \ + _dirAcct(baseDir, nickname, domain) + '/followingCalendar.txt' # get the contents of the calendar file, which is # a set of handles diff --git a/fonts/LICENSES b/fonts/LICENSES index ad31c3bb6..b55aca208 100644 --- a/fonts/LICENSES +++ b/fonts/LICENSES @@ -14,6 +14,7 @@ Judges is under GPL. See https://webfonts.ffonts.net/Judges.font LinBiolinum is under GPLv2. See https://www.1001fonts.com/linux-biolinum-font.html LcdSolid is public domain. See https://www.fontspace.com/lcd-solid-font-f11346 MarginaliaRegular is public domain. See https://www.fontspace.com/marginalia-font-f32466 +Minitel is GPL. See https://www.dafont.com/minitel.font Montserrat is under OFL and came from CCC Rc3 style guide https://styleguide.rc3.world Nimbus Sans L is GPL. See https://www.fontsquirrel.com/fonts/nimbus-sans-l Octavius is created by Jack Oatley and described as "100% free to use, though credit is appreciated" https://www.dafont.com/octavius.font diff --git a/fonts/Minitel.ttf b/fonts/Minitel.ttf new file mode 100644 index 000000000..1da7576f2 Binary files /dev/null and b/fonts/Minitel.ttf differ diff --git a/gemini/EN/install.gmi b/gemini/EN/install.gmi index 5b42d03fd..23f342289 100644 --- a/gemini/EN/install.gmi +++ b/gemini/EN/install.gmi @@ -125,8 +125,6 @@ And paste the following: proxy_set_header X-Forward-For $proxy_add_x_forwarded_for; proxy_set_header X-Forward-Proto http; proxy_set_header X-Nginx-Proxy true; - expires epoch; - proxy_no_cache 1; proxy_temp_file_write_size 64k; proxy_connect_timeout 10080s; proxy_send_timeout 10080; @@ -135,28 +133,11 @@ And paste the following: proxy_buffers 16 32k; proxy_busy_buffers_size 64k; proxy_redirect off; - proxy_request_buffering on; - proxy_buffering on; - proxy_cache my_cache; - proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504; - location ~ ^/(icons|images|media|emoji)/(.*)/(.*).(png|jpg|gif|webp|mp3|ogv|ogg|mp4) { - expires 7d; - proxy_pass http://localhost:7156; - } - location ~ ^/icons/(.*)/(like|repeat|calendar)(.*).(png|jpg|gif|webp|mp3|ogv|ogg|mp4) { - expires epoch; - proxy_no_cache 1; - proxy_pass http://localhost:7156; - } - location ~ ^/icons/(like|repeat|calendar)(.*).(png|jpg|gif|webp|mp3|ogv|ogg|mp4) { - expires epoch; - proxy_no_cache 1; - proxy_pass http://localhost:7156; - } - location ~ ^/users/(.*)/(image|banner).png { - expires epoch; - proxy_no_cache 1; - proxy_pass http://localhost:7156; + 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; } diff --git a/git.py b/git.py index c7dee20df..7556d4840 100644 --- a/git.py +++ b/git.py @@ -5,9 +5,12 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Profile Metadata" import os import html +from utils import hasObjectDict +from utils import acctDir def _gitFormatContent(content: str) -> str: @@ -30,7 +33,7 @@ def _getGitProjectName(baseDir: str, nickname: str, domain: str, holder wants to receive """ gitProjectsFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + '/gitprojects.txt' + acctDir(baseDir, nickname, domain) + '/gitprojects.txt' if not os.path.isfile(gitProjectsFilename): return None subjectLineWords = subject.lower().split(' ') @@ -43,7 +46,7 @@ def _getGitProjectName(baseDir: str, nickname: str, domain: str, def isGitPatch(baseDir: str, nickname: str, domain: str, messageType: str, subject: str, content: str, - checkProjectName=True) -> bool: + checkProjectName: bool = True) -> bool: """Is the given post content a git patch? """ if messageType != 'Note' and \ @@ -112,9 +115,7 @@ def convertPostToPatch(baseDir: str, nickname: str, domain: str, """Detects whether the given post contains a patch and if so then converts it to a Patch ActivityPub type """ - 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 @@ -186,9 +187,7 @@ def receiveGitPatch(baseDir: str, nickname: str, domain: str, patchLines = patchStr.split('\n') patchFilename = None projectDir = None - patchesDir = \ - baseDir + '/accounts/' + nickname + '@' + domain + \ - '/patches' + patchesDir = acctDir(baseDir, nickname, domain) + '/patches' # get the subject line and turn it into a filename for line in patchLines: if line.startswith('Subject:'): @@ -213,8 +212,7 @@ def receiveGitPatch(baseDir: str, nickname: str, domain: str, with open(patchFilename, 'w+') as patchFile: patchFile.write(patchStr) patchNotifyFilename = \ - baseDir + '/accounts/' + \ - nickname + '@' + domain + '/.newPatchContent' + acctDir(baseDir, nickname, domain) + '/.newPatchContent' with open(patchNotifyFilename, 'w+') as patchFile: patchFile.write(patchStr) return True diff --git a/happening.py b/happening.py index 3c78521be..8583ac0b2 100644 --- a/happening.py +++ b/happening.py @@ -5,19 +5,22 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Core" import os from uuid import UUID from datetime import datetime +from datetime import timedelta +from utils import isPublicPost from utils import loadJson from utils import saveJson from utils import locatePost -from utils import daysInMonth -from utils import mergeDicts +from utils import hasObjectDict +from utils import acctDir -def _validUuid(testUuid: str, version=4): +def _validUuid(testUuid: str, version: int = 4): """Check if uuid_to_test is a valid UUID """ try: @@ -104,9 +107,8 @@ def saveEventPost(baseDir: str, handle: str, postId: str, tlEventsFilename + ' ' + str(e)) return False else: - tlEventsFile = open(tlEventsFilename, 'w+') - tlEventsFile.write(eventId + '\n') - tlEventsFile.close() + with open(tlEventsFilename, 'w+') as tlEventsFile: + tlEventsFile.write(eventId + '\n') # create a directory for the calendar year if not os.path.isdir(calendarPath + '/' + str(eventYear)): @@ -123,27 +125,18 @@ def saveEventPost(baseDir: str, handle: str, postId: str, return False # append the post Id to the file for the calendar month - calendarFile = open(calendarFilename, 'a+') - if not calendarFile: - return False - calendarFile.write(postId + '\n') - calendarFile.close() + with open(calendarFilename, 'a+') as calendarFile: + calendarFile.write(postId + '\n') # create a file which will trigger a notification that # a new event has been added calendarNotificationFilename = \ baseDir + '/accounts/' + handle + '/.newCalendar' - calendarNotificationFile = \ - open(calendarNotificationFilename, 'w+') - if not calendarNotificationFile: - return False - calendarNotificationFile.write('/calendar?year=' + - str(eventYear) + - '?month=' + - str(eventMonthNumber) + - '?day=' + - str(eventDayOfMonth)) - calendarNotificationFile.close() + with open(calendarNotificationFilename, 'w+') as calendarNotificationFile: + notifyStr = \ + '/calendar?year=' + str(eventYear) + '?month=' + \ + str(eventMonthNumber) + '?day=' + str(eventDayOfMonth) + calendarNotificationFile.write(notifyStr) return True @@ -162,9 +155,7 @@ def _isHappeningPost(postJsonObject: {}) -> bool: """ if not postJsonObject: 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('tag'): return False @@ -172,8 +163,8 @@ def _isHappeningPost(postJsonObject: {}) -> bool: def getTodaysEvents(baseDir: str, nickname: str, domain: str, - currYear=None, currMonthNumber=None, - currDayOfMonth=None) -> {}: + currYear: int = None, currMonthNumber: int = None, + currDayOfMonth: int = None) -> {}: """Retrieves calendar events for today Returns a dictionary of lists containing Event and Place activities """ @@ -192,7 +183,7 @@ def getTodaysEvents(baseDir: str, nickname: str, domain: str, dayNumber = currDayOfMonth calendarFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + \ + acctDir(baseDir, nickname, domain) + \ '/calendar/' + str(year) + '/' + str(monthNumber) + '.txt' events = {} if not os.path.isfile(calendarFilename): @@ -212,6 +203,8 @@ def getTodaysEvents(baseDir: str, nickname: str, domain: str, if not _isHappeningPost(postJsonObject): continue + publicEvent = isPublicPost(postJsonObject) + postEvent = [] dayOfMonth = None for tag in postJsonObject['object']['tag']: @@ -233,6 +226,9 @@ def getTodaysEvents(baseDir: str, nickname: str, domain: str, # link to the id so that the event can be # easily deleted tag['postId'] = postId.split('#statuses#')[1] + tag['sender'] = postId.split('#statuses#')[0] + tag['sender'] = tag['sender'].replace('#', '/') + tag['public'] = publicEvent postEvent.append(tag) else: # tag is a place @@ -245,24 +241,22 @@ def getTodaysEvents(baseDir: str, nickname: str, domain: str, # if some posts have been deleted then regenerate the calendar file if recreateEventsFile: - calendarFile = open(calendarFilename, 'w+') - for postId in calendarPostIds: - calendarFile.write(postId + '\n') - calendarFile.close() + with open(calendarFilename, 'w+') as calendarFile: + for postId in calendarPostIds: + calendarFile.write(postId + '\n') return events -def todaysEventsCheck(baseDir: str, nickname: str, domain: str) -> bool: - """Are there calendar events today? +def dayEventsCheck(baseDir: str, nickname: str, domain: str, currDate) -> bool: + """Are there calendar events for the given date? """ - now = datetime.now() - year = now.year - monthNumber = now.month - dayNumber = now.day + year = currDate.year + monthNumber = currDate.month + dayNumber = currDate.day calendarFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + \ + acctDir(baseDir, nickname, domain) + \ '/calendar/' + str(year) + '/' + str(monthNumber) + '.txt' if not os.path.isfile(calendarFilename): return False @@ -291,59 +285,14 @@ def todaysEventsCheck(baseDir: str, nickname: str, domain: str) -> bool: eventTime = \ datetime.strptime(tag['startTime'], "%Y-%m-%dT%H:%M:%S%z") - if int(eventTime.strftime("%Y")) == year and \ - int(eventTime.strftime("%m")) == monthNumber and \ - int(eventTime.strftime("%d")) == dayNumber: - eventsExist = True - break - - return eventsExist - - -def thisWeeksEventsCheck(baseDir: str, nickname: str, domain: str) -> bool: - """Are there calendar events this week? - """ - now = datetime.now() - year = now.year - monthNumber = now.month - dayNumber = now.day - - calendarFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + \ - '/calendar/' + str(year) + '/' + str(monthNumber) + '.txt' - if not os.path.isfile(calendarFilename): - return False - - eventsExist = False - with open(calendarFilename, 'r') as eventsFile: - for postId in eventsFile: - postId = postId.replace('\n', '').replace('\r', '') - postFilename = locatePost(baseDir, nickname, domain, postId) - if not postFilename: - continue - - postJsonObject = loadJson(postFilename) - if not _isHappeningPost(postJsonObject): - continue - - for tag in postJsonObject['object']['tag']: - if not _isHappeningEvent(tag): + if int(eventTime.strftime("%d")) != dayNumber: continue - # this tag is an event or a place - if tag['type'] != 'Event': + if int(eventTime.strftime("%m")) != monthNumber: continue - # tag is an event - if not tag.get('startTime'): + if int(eventTime.strftime("%Y")) != year: continue - eventTime = \ - datetime.strptime(tag['startTime'], - "%Y-%m-%dT%H:%M:%S%z") - if (int(eventTime.strftime("%Y")) == year and - int(eventTime.strftime("%m")) == monthNumber and - (int(eventTime.strftime("%d")) > dayNumber and - int(eventTime.strftime("%d")) <= dayNumber + 6)): - eventsExist = True - break + eventsExist = True + break return eventsExist @@ -355,12 +304,12 @@ def getThisWeeksEvents(baseDir: str, nickname: str, domain: str) -> {}: Note: currently not used but could be with a weekly calendar screen """ now = datetime.now() + endOfWeek = now + timedelta(7) year = now.year monthNumber = now.month - dayNumber = now.day calendarFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + \ + acctDir(baseDir, nickname, domain) + \ '/calendar/' + str(year) + '/' + str(monthNumber) + '.txt' events = {} @@ -382,7 +331,6 @@ def getThisWeeksEvents(baseDir: str, nickname: str, domain: str) -> {}: continue postEvent = [] - dayOfMonth = None weekDayIndex = None for tag in postJsonObject['object']['tag']: if not _isHappeningEvent(tag): @@ -395,45 +343,23 @@ def getThisWeeksEvents(baseDir: str, nickname: str, domain: str) -> {}: eventTime = \ datetime.strptime(tag['startTime'], "%Y-%m-%dT%H:%M:%S%z") - if (int(eventTime.strftime("%Y")) == year and - int(eventTime.strftime("%m")) == monthNumber and - (int(eventTime.strftime("%d")) >= dayNumber and - int(eventTime.strftime("%d")) <= dayNumber + 6)): - dayOfMonth = str(int(eventTime.strftime("%d"))) - weekDayIndex = dayOfMonth - dayNumber + if eventTime >= now and eventTime <= endOfWeek: + weekDayIndex = (eventTime - now).days() postEvent.append(tag) else: # tag is a place postEvent.append(tag) if postEvent and weekDayIndex: calendarPostIds.append(postId) - if not events.get(dayOfMonth): + if not events.get(weekDayIndex): events[weekDayIndex] = [] - events[dayOfMonth].append(postEvent) + events[weekDayIndex].append(postEvent) # if some posts have been deleted then regenerate the calendar file if recreateEventsFile: - calendarFile = open(calendarFilename, 'w+') - for postId in calendarPostIds: - calendarFile.write(postId + '\n') - calendarFile.close() - - lastDayOfMonth = daysInMonth(year, monthNumber) - if dayNumber+6 > lastDayOfMonth: - monthNumber += 1 - if monthNumber > 12: - monthNumber = 1 - year += 1 - for d in range(1, dayNumber + 6 - lastDayOfMonth): - dailyEvents = \ - getTodaysEvents(baseDir, nickname, domain, - year, monthNumber, d) - if dailyEvents: - if dailyEvents.get(d): - newEvents = {} - newEvents[d + (7 - (dayNumber + 6 - lastDayOfMonth))] = \ - dailyEvents[d] - events = mergeDicts(events, newEvents) + with open(calendarFilename, 'w+') as calendarFile: + for postId in calendarPostIds: + calendarFile.write(postId + '\n') return events @@ -445,7 +371,7 @@ def getCalendarEvents(baseDir: str, nickname: str, domain: str, Event and Place activities """ calendarFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + \ + acctDir(baseDir, nickname, domain) + \ '/calendar/' + str(year) + '/' + str(monthNumber) + '.txt' events = {} @@ -495,10 +421,9 @@ def getCalendarEvents(baseDir: str, nickname: str, domain: str, # if some posts have been deleted then regenerate the calendar file if recreateEventsFile: - calendarFile = open(calendarFilename, 'w+') - for postId in calendarPostIds: - calendarFile.write(postId + '\n') - calendarFile.close() + with open(calendarFilename, 'w+') as calendarFile: + for postId in calendarPostIds: + calendarFile.write(postId + '\n') return events @@ -508,7 +433,7 @@ def removeCalendarEvent(baseDir: str, nickname: str, domain: str, """Removes a calendar event """ calendarFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + \ + acctDir(baseDir, nickname, domain) + \ '/calendar/' + str(year) + '/' + str(monthNumber) + '.txt' if not os.path.isfile(calendarFilename): return @@ -517,11 +442,11 @@ def removeCalendarEvent(baseDir: str, nickname: str, domain: str, if messageId not in open(calendarFilename).read(): return lines = None - with open(calendarFilename, "r") as f: + with open(calendarFilename, 'r') as f: lines = f.readlines() if not lines: return - with open(calendarFilename, "w+") as f: + with open(calendarFilename, 'w+') as f: for line in lines: if messageId not in line: f.write(line) diff --git a/httpsig.py b/httpsig.py index 2b0c9eb1b..45372fc90 100644 --- a/httpsig.py +++ b/httpsig.py @@ -6,8 +6,12 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Security" # see https://tools.ietf.org/html/draft-cavage-http-signatures-06 +# +# This might change in future +# see https://tools.ietf.org/html/draft-ietf-httpbis-message-signatures-01 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import load_pem_private_key @@ -96,6 +100,89 @@ def signPostHeaders(dateStr: str, privateKeyPem: str, return signatureHeader +def signPostHeadersNew(dateStr: str, privateKeyPem: str, + nickname: str, + domain: str, port: int, + toDomain: str, toPort: int, + path: str, + httpPrefix: str, + messageBodyJsonStr: str, + algorithm: str) -> (str, str): + """Returns a raw signature strings that can be plugged into a header + as "Signature-Input" and "Signature" + used to verify the authenticity of an HTTP transmission. + See https://tools.ietf.org/html/draft-ietf-httpbis-message-signatures-01 + """ + domain = getFullDomain(domain, port) + + toDomain = getFullDomain(toDomain, toPort) + + timeFormat = "%a, %d %b %Y %H:%M:%S %Z" + if not dateStr: + currTime = gmtime() + dateStr = strftime(timeFormat, currTime) + else: + currTime = datetime.datetime.strptime(dateStr, timeFormat) + secondsSinceEpoch = \ + int((currTime - datetime.datetime(1970, 1, 1)).total_seconds()) + keyID = httpPrefix + '://' + domain + '/users/' + nickname + '#main-key' + if not messageBodyJsonStr: + headers = { + '*request-target': f'post {path}', + '*created': str(secondsSinceEpoch), + 'host': toDomain, + 'date': dateStr, + 'content-type': 'application/json' + } + else: + bodyDigest = messageContentDigest(messageBodyJsonStr) + contentLength = len(messageBodyJsonStr) + headers = { + '*request-target': f'post {path}', + '*created': str(secondsSinceEpoch), + 'host': toDomain, + 'date': dateStr, + 'digest': f'SHA-256={bodyDigest}', + 'content-type': 'application/activity+json', + 'content-length': str(contentLength) + } + key = load_pem_private_key(privateKeyPem.encode('utf-8'), + None, backend=default_backend()) + # build a digest for signing + signedHeaderKeys = headers.keys() + signedHeaderText = '' + for headerKey in signedHeaderKeys: + signedHeaderText += f'{headerKey}: {headers[headerKey]}\n' + signedHeaderText = signedHeaderText.strip() + headerDigest = getSHA256(signedHeaderText.encode('ascii')) + + # Sign the digest. Potentially other signing algorithms can be added here. + signature = '' + if algorithm == 'rsa-sha256': + rawSignature = key.sign(headerDigest, + padding.PKCS1v15(), + hazutils.Prehashed(hashes.SHA256())) + signature = base64.b64encode(rawSignature).decode('ascii') + + sigKey = 'sig1' + # Put it into a valid HTTP signature format + signatureInputDict = { + 'keyId': keyID, + } + signatureIndexHeader = '; '.join( + [f'{k}="{v}"' for k, v in signatureInputDict.items()]) + signatureIndexHeader += '; alg=hs2019' + signatureIndexHeader += '; created=' + str(secondsSinceEpoch) + signatureIndexHeader += \ + '; ' + sigKey + '=(' + ', '.join(signedHeaderKeys) + ')' + signatureDict = { + sigKey: signature + } + signatureHeader = '; '.join( + [f'{k}=:{v}:' for k, v in signatureDict.items()]) + return signatureIndexHeader, signatureHeader + + def createSignedHeader(privateKeyPem: str, nickname: str, domain: str, port: int, toDomain: str, toPort: int, @@ -160,7 +247,8 @@ def _verifyRecentSignature(signedDateStr: str) -> bool: def verifyPostHeaders(httpPrefix: str, publicKeyPem: str, headers: dict, path: str, GETmethod: bool, messageBodyDigest: str, - messageBodyJsonStr: str, debug: bool) -> bool: + messageBodyJsonStr: str, debug: bool, + noRecencyCheck: bool = False) -> bool: """Returns true or false depending on if the key that we plugged in here validates against the headers, method, and path. publicKeyPem - the public key from an rsa key pair @@ -177,25 +265,63 @@ def verifyPostHeaders(httpPrefix: str, publicKeyPem: str, headers: dict, if debug: print('DEBUG: verifyPostHeaders ' + method) + print('verifyPostHeaders publicKeyPem: ' + str(publicKeyPem)) + print('verifyPostHeaders headers: ' + str(headers)) + print('verifyPostHeaders messageBodyJsonStr: ' + + str(messageBodyJsonStr)) pubkey = load_pem_public_key(publicKeyPem.encode('utf-8'), backend=default_backend()) # Build a dictionary of the signature values - signatureHeader = headers['signature'] - signatureDict = { - k: v[1:-1] - for k, v in [i.split('=', 1) for i in signatureHeader.split(',')] - } + if headers.get('Signature-Input'): + signatureHeader = headers['Signature-Input'] + fieldSep2 = ',' + # split the signature input into separate fields + signatureDict = { + k.strip(): v.strip() + for k, v in [i.split('=', 1) for i in signatureHeader.split(';')] + } + requestTargetKey = None + requestTargetStr = None + for k, v in signatureDict.items(): + if v.startswith('('): + requestTargetKey = k + requestTargetStr = v[1:-1] + break + if not requestTargetKey: + return False + signatureDict[requestTargetKey] = requestTargetStr + else: + requestTargetKey = 'headers' + signatureHeader = headers['signature'] + fieldSep2 = ' ' + # split the signature input into separate fields + signatureDict = { + k: v[1:-1] + for k, v in [i.split('=', 1) for i in signatureHeader.split(',')] + } # Unpack the signed headers and set values based on current headers and # body (if a digest was included) signedHeaderList = [] - for signedHeader in signatureDict['headers'].split(' '): + for signedHeader in signatureDict[requestTargetKey].split(fieldSep2): + signedHeader = signedHeader.strip() if debug: print('DEBUG: verifyPostHeaders signedHeader=' + signedHeader) if signedHeader == '(request-target)': + # original Mastodon http signature appendStr = f'(request-target): {method.lower()} {path}' signedHeaderList.append(appendStr) + elif '*request-target' in signedHeader: + # https://tools.ietf.org/html/ + # draft-ietf-httpbis-message-signatures-01 + appendStr = f'*request-target: {method.lower()} {path}' + # remove () + # if appendStr.startswith('('): + # appendStr = appendStr.split('(')[1] + # if ')' in appendStr: + # appendStr = appendStr.split(')')[0] + signedHeaderList.append(appendStr) elif signedHeader == 'digest': if messageBodyDigest: bodyDigest = messageBodyDigest @@ -221,7 +347,7 @@ def verifyPostHeaders(httpPrefix: str, publicKeyPem: str, headers: dict, ' not found in ' + str(headers)) else: if headers.get(signedHeader): - if signedHeader == 'date': + if signedHeader == 'date' and not noRecencyCheck: if not _verifyRecentSignature(headers[signedHeader]): if debug: print('DEBUG: ' + @@ -231,7 +357,24 @@ def verifyPostHeaders(httpPrefix: str, publicKeyPem: str, headers: dict, signedHeaderList.append( f'{signedHeader}: {headers[signedHeader]}') else: - signedHeaderCap = signedHeader.capitalize() + if '-' in signedHeader: + # capitalise with dashes + # my-header becomes My-Header + headerParts = signedHeader.split('-') + signedHeaderCap = None + for part in headerParts: + if signedHeaderCap: + signedHeaderCap += '-' + part.capitalize() + else: + signedHeaderCap = part.capitalize() + else: + # header becomes Header + signedHeaderCap = signedHeader.capitalize() + + if debug: + print('signedHeaderCap: ' + signedHeaderCap) + + # if this is the date header then check it is recent if signedHeaderCap == 'Date': if not _verifyRecentSignature(headers[signedHeaderCap]): if debug: @@ -239,9 +382,17 @@ def verifyPostHeaders(httpPrefix: str, publicKeyPem: str, headers: dict, 'verifyPostHeaders date is not recent ' + headers[signedHeader]) return False + + # add the capitalised header if headers.get(signedHeaderCap): signedHeaderList.append( f'{signedHeader}: {headers[signedHeaderCap]}') + elif '-' in signedHeader: + # my-header becomes My-header + signedHeaderCap = signedHeader.capitalize() + if headers.get(signedHeaderCap): + signedHeaderList.append( + f'{signedHeader}: {headers[signedHeaderCap]}') if debug: print('DEBUG: signedHeaderList: ' + str(signedHeaderList)) @@ -250,7 +401,19 @@ def verifyPostHeaders(httpPrefix: str, publicKeyPem: str, headers: dict, headerDigest = getSHA256(signedHeaderText.encode('ascii')) # Get the signature, verify with public key, return result - signature = base64.b64decode(signatureDict['signature']) + signature = None + if headers.get('Signature-Input') and headers.get('Signature'): + # https://tools.ietf.org/html/ + # draft-ietf-httpbis-message-signatures-01 + headersSig = headers['Signature'] + # remove sig1=: + if requestTargetKey + '=:' in headersSig: + headersSig = headersSig.split(requestTargetKey + '=:')[1] + headersSig = headersSig[:len(headersSig)-1] + signature = base64.b64decode(headersSig) + else: + # Original Mastodon signature + signature = base64.b64decode(signatureDict['signature']) try: pubkey.verify( diff --git a/inbox.py b/inbox.py index 86507b87d..1a129dae3 100644 --- a/inbox.py +++ b/inbox.py @@ -5,12 +5,20 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Timeline" import json import os import datetime import time +import random from linked_data_sig import verifyJsonSignature +from utils import acctDir +from utils import removeDomainPort +from utils import getPortFromDomain +from utils import hasObjectDict +from utils import dmAllowedFromDomain +from utils import isRecentPost from utils import getConfigParam from utils import hasUsersPath from utils import validPostDate @@ -40,6 +48,7 @@ from categories import setHashtagCategory from httpsig import verifyPostHeaders from session import createSession from session import getJson +from follow import isFollowingActor from follow import receiveFollowRequest from follow import getFollowersOfActor from follow import unfollowerOfAccount @@ -56,11 +65,12 @@ from filters import isFiltered from utils import updateAnnounceCollection from utils import undoAnnounceCollectionEntry from utils import dangerousMarkup +from utils import isDM +from utils import isReply from httpsig import messageContentDigest +from posts import createDirectMessagePost from posts import validContentWarning from posts import downloadAnnounce -from posts import isDM -from posts import isReply from posts import isMuted from posts import isImageMedia from posts import sendSignedJson @@ -73,9 +83,11 @@ from git import receiveGitPatch from followingCalendar import receivingCalendarEvents from happening import saveEventPost from delete import removeOldHashtags -from follow import isFollowingActor from categories import guessHashtagCategory from context import hasValidContext +from speaker import updateSpeaker +from announce import isSelfAnnounce +from notifyOnPost import notifyWhenPersonPosts def storeHashTags(baseDir: str, nickname: str, postJsonObject: {}) -> None: @@ -84,9 +96,7 @@ def storeHashTags(baseDir: str, nickname: str, postJsonObject: {}) -> None: """ if not isPublicPost(postJsonObject): return - if not postJsonObject.get('object'): - return - if not isinstance(postJsonObject['object'], dict): + if not hasObjectDict(postJsonObject): return if not postJsonObject['object'].get('tag'): return @@ -120,10 +130,8 @@ def storeHashTags(baseDir: str, nickname: str, postJsonObject: {}) -> None: daysSinceEpoch = daysDiff.days tagline = str(daysSinceEpoch) + ' ' + nickname + ' ' + postUrl + '\n' if not os.path.isfile(tagsFilename): - tagsFile = open(tagsFilename, "w+") - if tagsFile: + with open(tagsFilename, 'w+') as tagsFile: tagsFile.write(tagline) - tagsFile.close() else: if postUrl not in open(tagsFilename).read(): try: @@ -155,13 +163,14 @@ def _inboxStorePostToHtmlCache(recentPostsCache: {}, maxRecentPosts: int, allowDeletion: bool, boxname: str, showPublishedDateOnly: bool, peertubeInstances: [], - allowLocalNetworkAccess: bool) -> None: + allowLocalNetworkAccess: bool, + themeName: str) -> None: """Converts the json post into html and stores it in a cache This enables the post to be quickly displayed later """ pageNumber = -999 avatarUrl = None - if boxname != 'tlevents' and boxname != 'outbox': + if boxname != 'outbox': boxname = 'inbox' individualPostAsHtml(True, recentPostsCache, maxRecentPosts, @@ -173,6 +182,7 @@ def _inboxStorePostToHtmlCache(recentPostsCache: {}, maxRecentPosts: int, httpPrefix, __version__, boxname, None, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, + themeName, not isDM(postJsonObject), True, True, False, True) @@ -180,9 +190,8 @@ def _inboxStorePostToHtmlCache(recentPostsCache: {}, maxRecentPosts: int, def validInbox(baseDir: str, nickname: str, domain: str) -> bool: """Checks whether files were correctly saved to the inbox """ - if ':' in domain: - domain = domain.split(':')[0] - inboxDir = baseDir+'/accounts/' + nickname + '@' + domain + '/inbox' + domain = removeDomainPort(domain) + inboxDir = acctDir(baseDir, nickname, domain) + '/inbox' if not os.path.isdir(inboxDir): return True for subdir, dirs, files in os.walk(inboxDir): @@ -203,9 +212,8 @@ def validInboxFilenames(baseDir: str, nickname: str, domain: str, """Used by unit tests to check that the port number gets appended to domain names within saved post filenames """ - if ':' in domain: - domain = domain.split(':')[0] - inboxDir = baseDir + '/accounts/' + nickname + '@' + domain + '/inbox' + domain = removeDomainPort(domain) + inboxDir = acctDir(baseDir, nickname, domain) + '/inbox' if not os.path.isdir(inboxDir): return True expectedStr = expectedDomain + ':' + str(expectedPort) @@ -248,8 +256,8 @@ def getPersonPubKey(baseDir: str, session, personUrl: str, 'Accept': 'application/activity+json; profile="' + profileStr + '"' } personJson = \ - getJson(session, personUrl, asHeader, None, projectVersion, - httpPrefix, personDomain) + getJson(session, personUrl, asHeader, None, debug, + projectVersion, httpPrefix, personDomain) if not personJson: return None pubKey = None @@ -292,7 +300,7 @@ def inboxMessageHasParams(messageJson: {}) -> bool: return False # object should be a dict or a string - if not isinstance(messageJson['object'], dict): + if not hasObjectDict(messageJson): if not isinstance(messageJson['object'], str): print('WARN: object from ' + str(messageJson['actor']) + ' should be a dict or string, but is actually: ' + @@ -324,10 +332,8 @@ def inboxPermittedMessage(domain: str, messageJson: {}, alwaysAllowedTypes = ('Follow', 'Join', 'Like', 'Delete', 'Announce') if messageJson['type'] not in alwaysAllowedTypes: - if not messageJson.get('object'): + if not hasObjectDict(messageJson): return True - if not isinstance(messageJson['object'], dict): - return False if messageJson['object'].get('inReplyTo'): inReplyTo = messageJson['object']['inReplyTo'] if not isinstance(inReplyTo, str): @@ -344,7 +350,8 @@ def savePostToInboxQueue(baseDir: str, httpPrefix: str, originalPostJsonObject: {}, messageBytes: str, httpHeaders: {}, - postPath: str, debug: bool) -> str: + postPath: str, debug: bool, + blockedCache: []) -> str: """Saves the give json to the inbox queue for the person keyId specifies the actor sending the post """ @@ -353,8 +360,7 @@ def savePostToInboxQueue(baseDir: str, httpPrefix: str, str(len(messageBytes)) + ' bytes') return None originalDomain = domain - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domain) # block at the ealiest stage possible, which means the data # isn't written to file @@ -375,42 +381,46 @@ def savePostToInboxQueue(baseDir: str, httpPrefix: str, pprint(postJsonObject) print('No post Domain in actor') return None - if isBlocked(baseDir, nickname, domain, postNickname, postDomain): + if isBlocked(baseDir, nickname, domain, + postNickname, postDomain, blockedCache): if debug: print('DEBUG: post from ' + postNickname + ' blocked') return None postDomain = getFullDomain(postDomain, postPort) - if postJsonObject.get('object'): - if isinstance(postJsonObject['object'], dict): - if postJsonObject['object'].get('inReplyTo'): - if isinstance(postJsonObject['object']['inReplyTo'], str): - inReplyTo = \ - postJsonObject['object']['inReplyTo'] - replyDomain, replyPort = \ - getDomainFromActor(inReplyTo) - if isBlockedDomain(baseDir, replyDomain): + if hasObjectDict(postJsonObject): + if postJsonObject['object'].get('inReplyTo'): + if isinstance(postJsonObject['object']['inReplyTo'], str): + inReplyTo = \ + postJsonObject['object']['inReplyTo'] + replyDomain, replyPort = \ + getDomainFromActor(inReplyTo) + if isBlockedDomain(baseDir, replyDomain, blockedCache): + if debug: print('WARN: post contains reply from ' + str(actor) + ' to a blocked domain: ' + replyDomain) - return None - else: - replyNickname = \ - getNicknameFromActor(inReplyTo) - if replyNickname and replyDomain: - if isBlocked(baseDir, nickname, domain, - replyNickname, replyDomain): + return None + else: + replyNickname = \ + getNicknameFromActor(inReplyTo) + if replyNickname and replyDomain: + if isBlocked(baseDir, nickname, domain, + replyNickname, replyDomain, + blockedCache): + if debug: print('WARN: post contains reply from ' + str(actor) + ' to a blocked account: ' + replyNickname + '@' + replyDomain) - return None - if postJsonObject['object'].get('content'): - if isinstance(postJsonObject['object']['content'], str): - if isFiltered(baseDir, nickname, domain, - postJsonObject['object']['content']): + return None + if postJsonObject['object'].get('content'): + if isinstance(postJsonObject['object']['content'], str): + if isFiltered(baseDir, nickname, domain, + postJsonObject['object']['content']): + if debug: print('WARN: post was filtered out due to content') - return None + return None originalPostId = None if postJsonObject.get('id'): if not isinstance(postJsonObject['id'], str): @@ -493,7 +503,7 @@ def _inboxPostRecipientsAdd(baseDir: str, httpPrefix: str, toList: [], if domainMatch in recipient: # get the handle for the local account nickname = recipient.split(domainMatch)[1] - handle = nickname+'@'+domain + handle = nickname + '@' + domain if os.path.isdir(baseDir + '/accounts/' + handle): recipientsDict[handle] = None else: @@ -527,8 +537,7 @@ def _inboxPostRecipients(baseDir: str, postJsonObject: {}, print('WARNING: inbox post has no actor') return recipientsDict, recipientsDictFollowers - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domain) domainBase = domain domain = getFullDomain(domain, port) domainMatch = '/' + domain + '/users/' @@ -537,51 +546,50 @@ def _inboxPostRecipients(baseDir: str, postJsonObject: {}, # first get any specific people which the post is addressed to followerRecipients = False - if postJsonObject.get('object'): - if isinstance(postJsonObject['object'], dict): - if postJsonObject['object'].get('to'): - if isinstance(postJsonObject['object']['to'], list): - recipientsList = postJsonObject['object']['to'] - else: - recipientsList = [postJsonObject['object']['to']] - if debug: - print('DEBUG: resolving "to"') - includesFollowers, recipientsDict = \ - _inboxPostRecipientsAdd(baseDir, httpPrefix, - recipientsList, - recipientsDict, - domainMatch, domainBase, - actor, debug) - if includesFollowers: - followerRecipients = True + if hasObjectDict(postJsonObject): + if postJsonObject['object'].get('to'): + if isinstance(postJsonObject['object']['to'], list): + recipientsList = postJsonObject['object']['to'] else: - if debug: - print('DEBUG: inbox post has no "to"') - - if postJsonObject['object'].get('cc'): - if isinstance(postJsonObject['object']['cc'], list): - recipientsList = postJsonObject['object']['cc'] - else: - recipientsList = [postJsonObject['object']['cc']] - includesFollowers, recipientsDict = \ - _inboxPostRecipientsAdd(baseDir, httpPrefix, - recipientsList, - recipientsDict, - domainMatch, domainBase, - actor, debug) - if includesFollowers: - followerRecipients = True - else: - if debug: - print('DEBUG: inbox post has no cc') + recipientsList = [postJsonObject['object']['to']] + if debug: + print('DEBUG: resolving "to"') + includesFollowers, recipientsDict = \ + _inboxPostRecipientsAdd(baseDir, httpPrefix, + recipientsList, + recipientsDict, + domainMatch, domainBase, + actor, debug) + if includesFollowers: + followerRecipients = True else: if debug: - if isinstance(postJsonObject['object'], str): - if '/statuses/' in postJsonObject['object']: - print('DEBUG: inbox item is a link to a post') - else: - if '/users/' in postJsonObject['object']: - print('DEBUG: inbox item is a link to an actor') + print('DEBUG: inbox post has no "to"') + + if postJsonObject['object'].get('cc'): + if isinstance(postJsonObject['object']['cc'], list): + recipientsList = postJsonObject['object']['cc'] + else: + recipientsList = [postJsonObject['object']['cc']] + includesFollowers, recipientsDict = \ + _inboxPostRecipientsAdd(baseDir, httpPrefix, + recipientsList, + recipientsDict, + domainMatch, domainBase, + actor, debug) + if includesFollowers: + followerRecipients = True + else: + if debug: + print('DEBUG: inbox post has no cc') + else: + if debug and postJsonObject.get('object'): + if isinstance(postJsonObject['object'], str): + if '/statuses/' in postJsonObject['object']: + print('DEBUG: inbox item is a link to a post') + else: + if '/users/' in postJsonObject['object']: + print('DEBUG: inbox item is a link to an actor') if postJsonObject.get('to'): if isinstance(postJsonObject['to'], list): @@ -696,14 +704,10 @@ def _receiveUndo(session, baseDir: str, httpPrefix: str, if debug: print('DEBUG: "users" or "profile" missing from actor') return False - if not messageJson.get('object'): + if not hasObjectDict(messageJson): if debug: print('DEBUG: ' + messageJson['type'] + ' has no object') return False - if not isinstance(messageJson['object'], dict): - if debug: - print('DEBUG: ' + messageJson['type'] + ' object is not a dict') - return False if not messageJson['object'].get('type'): if debug: print('DEBUG: ' + messageJson['type'] + ' has no object type') @@ -753,8 +757,9 @@ def _personReceiveUpdate(baseDir: str, debug: bool) -> bool: """Changes an actor. eg: avatar or display name change """ - print('Receiving actor update for ' + personJson['url'] + - ' ' + str(personJson)) + if debug: + print('Receiving actor update for ' + personJson['url'] + + ' ' + str(personJson)) domainFull = getFullDomain(domain, port) updateDomainFull = getFullDomain(updateDomain, updatePort) usersPaths = ('users', 'profile', 'channel', 'accounts', 'u') @@ -809,7 +814,8 @@ def _personReceiveUpdate(baseDir: str, personCache, True) # save to cache on file if saveJson(personJson, actorFilename): - print('actor updated for ' + personJson['id']) + if debug: + print('actor updated for ' + personJson['id']) # remove avatar if it exists so that it will be refreshed later # when a timeline is constructed @@ -869,14 +875,10 @@ def _receiveUpdate(recentPostsCache: {}, session, baseDir: str, if debug: print('DEBUG: ' + messageJson['type'] + ' has no actor') return False - if not messageJson.get('object'): + if not hasObjectDict(messageJson): if debug: print('DEBUG: ' + messageJson['type'] + ' has no object') return False - if not isinstance(messageJson['object'], dict): - if debug: - print('DEBUG: ' + messageJson['type'] + ' object is not a dict') - return False if not messageJson['object'].get('type'): if debug: print('DEBUG: ' + messageJson['type'] + ' object has no type') @@ -896,7 +898,9 @@ def _receiveUpdate(recentPostsCache: {}, session, baseDir: str, if messageJson['type'] == 'Person': if messageJson.get('url') and messageJson.get('id'): - print('Request to update actor unwrapped: ' + str(messageJson)) + if debug: + print('Request to update actor unwrapped: ' + + str(messageJson)) updateNickname = getNicknameFromActor(messageJson['id']) if updateNickname: updateDomain, updatePort = \ @@ -917,7 +921,8 @@ def _receiveUpdate(recentPostsCache: {}, session, baseDir: str, messageJson['object']['type'] == 'Service': if messageJson['object'].get('url') and \ messageJson['object'].get('id'): - print('Request to update actor: ' + str(messageJson)) + if debug: + print('Request to update actor: ' + str(messageJson)) updateNickname = getNicknameFromActor(messageJson['actor']) if updateNickname: updateDomain, updatePort = \ @@ -977,27 +982,27 @@ def _receiveLike(recentPostsCache: {}, # if this post in the outbox of the person? handleName = handle.split('@')[0] handleDom = handle.split('@')[1] - postFilename = locatePost(baseDir, handleName, handleDom, - messageJson['object']) + postLikedId = messageJson['object'] + postFilename = locatePost(baseDir, handleName, handleDom, postLikedId) if not postFilename: if debug: print('DEBUG: post not found in inbox or outbox') - print(messageJson['object']) + print(postLikedId) return True if debug: print('DEBUG: liked post found in inbox') handleName = handle.split('@')[0] handleDom = handle.split('@')[1] - updateLikesCollection(recentPostsCache, baseDir, postFilename, - messageJson['object'], - messageJson['actor'], domain, debug) if not _alreadyLiked(baseDir, handleName, handleDom, - messageJson['object'], + postLikedId, messageJson['actor']): _likeNotify(baseDir, domain, onionDomain, handle, - messageJson['actor'], messageJson['object']) + messageJson['actor'], postLikedId) + updateLikesCollection(recentPostsCache, baseDir, postFilename, + postLikedId, messageJson['actor'], + handleName, domain, debug) return True @@ -1013,9 +1018,7 @@ def _receiveUndoLike(recentPostsCache: {}, 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 @@ -1069,60 +1072,68 @@ def _receiveBookmark(recentPostsCache: {}, debug: bool) -> bool: """Receives a bookmark activity within the POST section of HTTPServer """ - if messageJson['type'] != 'Bookmark': + if not messageJson.get('type'): + return False + if messageJson['type'] != 'Add': return False if not messageJson.get('actor'): if debug: - print('DEBUG: ' + messageJson['type'] + ' has no actor') + print('DEBUG: no actor in inbox bookmark Add') return False - if not messageJson.get('object'): + if not hasObjectDict(messageJson): if debug: - print('DEBUG: ' + messageJson['type'] + ' has no object') + print('DEBUG: no object in inbox bookmark Add') return False - if not isinstance(messageJson['object'], str): + if not messageJson.get('target'): if debug: - print('DEBUG: ' + messageJson['type'] + ' object is not a string') + print('DEBUG: no target in inbox bookmark Add') return False - if not messageJson.get('to'): + if not messageJson['object'].get('type'): if debug: - print('DEBUG: ' + messageJson['type'] + ' has no "to" list') + print('DEBUG: no object type in inbox bookmark Add') return False - if '/users/' not in messageJson['actor']: + if not isinstance(messageJson['target'], str): if debug: - print('DEBUG: "users" missing from actor in ' + - messageJson['type']) - return False - if '/statuses/' not in messageJson['object']: - if debug: - print('DEBUG: "statuses" missing from object in ' + - messageJson['type']) - return False - if domain not in handle.split('@')[1]: - if debug: - print('DEBUG: unrecognized domain ' + handle) + print('DEBUG: inbox bookmark Add target is not string') return False domainFull = getFullDomain(domain, port) nickname = handle.split('@')[0] if not messageJson['actor'].endswith(domainFull + '/users/' + nickname): if debug: - print('DEBUG: ' + - 'bookmark actor should be the same as the handle sent to ' + - handle + ' != ' + messageJson['actor']) + print('DEBUG: inbox bookmark Add unexpected actor') return False - if not os.path.isdir(baseDir + '/accounts/' + handle): - print('DEBUG: unknown recipient of bookmark - ' + handle) - # if this post in the outbox of the person? - postFilename = locatePost(baseDir, nickname, domain, messageJson['object']) + if not messageJson['target'].endswith(messageJson['actor'] + + '/tlbookmarks'): + if debug: + print('DEBUG: inbox bookmark Add target invalid ' + + messageJson['target']) + return False + if messageJson['object']['type'] != 'Document': + if debug: + print('DEBUG: inbox bookmark Add type is not Document') + return False + if not messageJson['object'].get('url'): + if debug: + print('DEBUG: inbox bookmark Add missing url') + return False + if '/statuses/' not in messageJson['object']['url']: + if debug: + print('DEBUG: inbox bookmark Add missing statuses un url') + return False + if debug: + print('DEBUG: c2s inbox bookmark Add request arrived in outbox') + + messageUrl = removeIdEnding(messageJson['object']['url']) + domain = removeDomainPort(domain) + postFilename = locatePost(baseDir, nickname, domain, messageUrl) if not postFilename: if debug: - print('DEBUG: post not found in inbox or outbox') - print(messageJson['object']) + print('DEBUG: c2s inbox like post not found in inbox or outbox') + print(messageUrl) return True - if debug: - print('DEBUG: bookmarked post was found') updateBookmarksCollection(recentPostsCache, baseDir, postFilename, - messageJson['object'], + messageJson['object']['url'], messageJson['actor'], domain, debug) return True @@ -1135,63 +1146,69 @@ def _receiveUndoBookmark(recentPostsCache: {}, debug: bool) -> bool: """Receives an undo bookmark activity within the POST section of HTTPServer """ - if messageJson['type'] != 'Undo': + if not messageJson.get('type'): + return False + if messageJson['type'] != 'Remove': return False if not messageJson.get('actor'): + if debug: + print('DEBUG: no actor in inbox undo bookmark Remove') return False - if not messageJson.get('object'): + if not hasObjectDict(messageJson): + if debug: + print('DEBUG: no object in inbox undo bookmark Remove') return False - if not isinstance(messageJson['object'], dict): + if not messageJson.get('target'): + if debug: + print('DEBUG: no target in inbox undo bookmark Remove') return False if not messageJson['object'].get('type'): - return False - if messageJson['object']['type'] != 'Bookmark': - return False - if not messageJson['object'].get('object'): if debug: - print('DEBUG: ' + messageJson['type'] + ' like has no object') + print('DEBUG: no object type in inbox bookmark Remove') return False - if not isinstance(messageJson['object']['object'], str): + if not isinstance(messageJson['target'], str): if debug: - print('DEBUG: ' + messageJson['type'] + - ' like object is not a string') - return False - if '/users/' not in messageJson['actor']: - if debug: - print('DEBUG: "users" missing from actor in ' + - messageJson['type'] + ' like') - return False - if '/statuses/' not in messageJson['object']['object']: - if debug: - print('DEBUG: "statuses" missing from like object in ' + - messageJson['type']) + print('DEBUG: inbox Remove bookmark target is not string') return False domainFull = getFullDomain(domain, port) nickname = handle.split('@')[0] - if domain not in handle.split('@')[1]: - if debug: - print('DEBUG: unrecognized bookmark domain ' + handle) - return False if not messageJson['actor'].endswith(domainFull + '/users/' + nickname): if debug: - print('DEBUG: ' + - 'bookmark actor should be the same as the handle sent to ' + - handle + ' != ' + messageJson['actor']) + print('DEBUG: inbox undo bookmark Remove unexpected actor') return False - if not os.path.isdir(baseDir + '/accounts/' + handle): - print('DEBUG: unknown recipient of bookmark undo - ' + handle) - # if this post in the outbox of the person? - postFilename = locatePost(baseDir, nickname, domain, - messageJson['object']['object']) + if not messageJson['target'].endswith(messageJson['actor'] + + '/tlbookmarks'): + if debug: + print('DEBUG: inbox undo bookmark Remove target invalid ' + + messageJson['target']) + return False + if messageJson['object']['type'] != 'Document': + if debug: + print('DEBUG: inbox undo bookmark Remove type is not Document') + return False + if not messageJson['object'].get('url'): + if debug: + print('DEBUG: inbox undo bookmark Remove missing url') + return False + if '/statuses/' not in messageJson['object']['url']: + if debug: + print('DEBUG: inbox undo bookmark Remove missing statuses un url') + return False + if debug: + print('DEBUG: c2s inbox Remove bookmark ' + + 'request arrived in outbox') + + messageUrl = removeIdEnding(messageJson['object']['url']) + domain = removeDomainPort(domain) + postFilename = locatePost(baseDir, nickname, domain, messageUrl) if not postFilename: if debug: - print('DEBUG: unbookmarked post not found in inbox or outbox') - print(messageJson['object']['object']) + print('DEBUG: c2s inbox like post not found in inbox or outbox') + print(messageUrl) return True - if debug: - print('DEBUG: bookmarked post found. Now undoing.') + undoBookmarksCollectionEntry(recentPostsCache, baseDir, postFilename, - messageJson['object'], + messageJson['object']['url'], messageJson['actor'], domain, debug) return True @@ -1287,7 +1304,8 @@ def _receiveAnnounce(recentPostsCache: {}, personCache: {}, messageJson: {}, federationList: [], debug: bool, translate: {}, YTReplacementDomain: str, - allowLocalNetworkAccess: bool) -> bool: + allowLocalNetworkAccess: bool, + themeName: str) -> bool: """Receives an announce activity within the POST section of HTTPServer """ if messageJson['type'] != 'Announce': @@ -1320,6 +1338,10 @@ def _receiveAnnounce(recentPostsCache: {}, '"users" or "profile" missing from actor in ' + messageJson['type']) return False + if isSelfAnnounce(messageJson): + if debug: + print('DEBUG: self-boost rejected') + return False if not hasUsersPath(messageJson['object']): if debug: print('DEBUG: ' + @@ -1359,18 +1381,24 @@ def _receiveAnnounce(recentPostsCache: {}, print(messageJson['object']) return True updateAnnounceCollection(recentPostsCache, baseDir, postFilename, - messageJson['actor'], domain, debug) + messageJson['actor'], nickname, domain, debug) if debug: print('DEBUG: Downloading announce post ' + messageJson['actor'] + ' -> ' + messageJson['object']) - postJsonObject = downloadAnnounce(session, baseDir, httpPrefix, - nickname, domain, messageJson, + postJsonObject = downloadAnnounce(session, baseDir, + httpPrefix, + nickname, domain, + messageJson, __version__, translate, YTReplacementDomain, - allowLocalNetworkAccess) + allowLocalNetworkAccess, + recentPostsCache, debug) if not postJsonObject: - if domain not in messageJson['object'] and \ - onionDomain not in messageJson['object']: + notInOnion = True + if onionDomain: + if onionDomain in messageJson['object']: + notInOnion = False + if domain not in messageJson['object'] and notInOnion: if os.path.isfile(postFilename): # if the announce can't be downloaded then remove it os.remove(postFilename) @@ -1386,17 +1414,27 @@ def _receiveAnnounce(recentPostsCache: {}, if isinstance(postJsonObject['attributedTo'], str): lookupActor = postJsonObject['attributedTo'] else: - if postJsonObject.get('object'): - if isinstance(postJsonObject['object'], dict): - if postJsonObject['object'].get('attributedTo'): - attrib = postJsonObject['object']['attributedTo'] - if isinstance(attrib, str): - lookupActor = attrib + if hasObjectDict(postJsonObject): + if postJsonObject['object'].get('attributedTo'): + attrib = postJsonObject['object']['attributedTo'] + if isinstance(attrib, str): + lookupActor = attrib if lookupActor: if hasUsersPath(lookupActor): if '/statuses/' in lookupActor: lookupActor = lookupActor.split('/statuses/')[0] + if isRecentPost(postJsonObject): + if not os.path.isfile(postFilename + '.tts'): + domainFull = getFullDomain(domain, port) + updateSpeaker(baseDir, httpPrefix, + nickname, domain, domainFull, + postJsonObject, personCache, + translate, lookupActor, + themeName) + with open(postFilename + '.tts', 'w+') as ttsFile: + ttsFile.write('\n') + if debug: print('DEBUG: Obtaining actor for announce post ' + lookupActor) @@ -1407,8 +1445,9 @@ def _receiveAnnounce(recentPostsCache: {}, __version__, httpPrefix, domain, onionDomain) if pubKey: - print('DEBUG: public key obtained for announce: ' + - lookupActor) + if debug: + print('DEBUG: public key obtained for announce: ' + + lookupActor) break if debug: @@ -1432,9 +1471,7 @@ def _receiveUndoAnnounce(recentPostsCache: {}, 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('object'): return False @@ -1482,11 +1519,15 @@ def jsonPostAllowsComments(postJsonObject: {}) -> bool: """ if 'commentsEnabled' in postJsonObject: return postJsonObject['commentsEnabled'] + if 'rejectReplies' in postJsonObject: + return not postJsonObject['rejectReplies'] if postJsonObject.get('object'): - if not isinstance(postJsonObject['object'], dict): + if not hasObjectDict(postJsonObject): return False - if 'commentsEnabled' in postJsonObject['object']: + elif 'commentsEnabled' in postJsonObject['object']: return postJsonObject['object']['commentsEnabled'] + elif 'rejectReplies' in postJsonObject['object']: + return not postJsonObject['object']['rejectReplies'] return True @@ -1506,9 +1547,7 @@ def populateReplies(baseDir: str, httpPrefix: str, domain: str, """ if not messageJson.get('id'): 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('inReplyTo'): return False @@ -1535,12 +1574,14 @@ def populateReplies(baseDir: str, httpPrefix: str, domain: str, if debug: print('DEBUG: no domain found for ' + replyTo) return False + postFilename = locatePost(baseDir, replyToNickname, replyToDomain, replyTo) if not postFilename: if debug: print('DEBUG: post may have expired - ' + replyTo) return False + if not _postAllowsComments(postFilename): if debug: print('DEBUG: post does not allow comments - ' + replyTo) @@ -1553,13 +1594,11 @@ def populateReplies(baseDir: str, httpPrefix: str, domain: str, if numLines > maxReplies: return False if messageId not in open(postRepliesFilename).read(): - repliesFile = open(postRepliesFilename, 'a+') - repliesFile.write(messageId + '\n') - repliesFile.close() + with open(postRepliesFilename, 'a+') as repliesFile: + repliesFile.write(messageId + '\n') else: - repliesFile = open(postRepliesFilename, 'w+') - repliesFile.write(messageId + '\n') - repliesFile.close() + with open(postRepliesFilename, 'w+') as repliesFile: + repliesFile.write(messageId + '\n') return True @@ -1577,15 +1616,13 @@ def _estimateNumberOfEmoji(content: str) -> int: def _validPostContent(baseDir: str, nickname: str, domain: str, messageJson: {}, maxMentions: int, maxEmoji: int, - allowLocalNetworkAccess: bool) -> bool: + allowLocalNetworkAccess: bool, debug: bool) -> bool: """Is the content of a received post valid? Check for bad html Check for hellthreads Check number of tags is reasonable """ - if not messageJson.get('object'): - return True - if not isinstance(messageJson['object'], dict): + if not hasObjectDict(messageJson): return True if not messageJson['object'].get('content'): return True @@ -1596,7 +1633,7 @@ def _validPostContent(baseDir: str, nickname: str, domain: str, return False if 'Z' not in messageJson['object']['published']: return False - if not validPostDate(messageJson['object']['published']): + if not validPostDate(messageJson['object']['published'], 90, debug): return False if messageJson['object'].get('summary'): @@ -1662,7 +1699,8 @@ def _validPostContent(baseDir: str, nickname: str, domain: str, print('REJECT: reply to post which does not ' + 'allow comments: ' + originalPostId) return False - print('ACCEPT: post content is valid') + if debug: + print('ACCEPT: post content is valid') return True @@ -1672,10 +1710,7 @@ def _obtainAvatarForReplyPost(session, baseDir: str, httpPrefix: str, """Tries to obtain the actor for the person being replied to so that their avatar can later be shown """ - if not postJsonObject.get('object'): - return - - if not isinstance(postJsonObject['object'], dict): + if not hasObjectDict(postJsonObject): return if not postJsonObject['object'].get('inReplyTo'): @@ -1704,7 +1739,8 @@ def _obtainAvatarForReplyPost(session, baseDir: str, httpPrefix: str, __version__, httpPrefix, domain, onionDomain) if pubKey: - print('DEBUG: public key obtained for reply: ' + lookupActor) + if debug: + print('DEBUG: public key obtained for reply: ' + lookupActor) break if debug: @@ -1736,9 +1772,7 @@ def _alreadyLiked(baseDir: str, nickname: str, domain: str, postJsonObject = loadJson(postFilename, 1) if not postJsonObject: 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('likes'): return False @@ -1818,6 +1852,25 @@ def _likeNotify(baseDir: str, domain: str, onionDomain: str, pass +def _notifyPostArrival(baseDir: str, handle: str, url: str) -> None: + """Creates a notification that a new post has arrived. + This is for followed accounts with the notify checkbox enabled + on the person options screen + """ + accountDir = baseDir + '/accounts/' + handle + if not os.path.isdir(accountDir): + return + notifyFile = accountDir + '/.newNotifiedPost' + if os.path.isfile(notifyFile): + # check that the same notification is not repeatedly sent + with open(notifyFile, 'r') as fp: + existingNotificationMessage = fp.read() + if url in existingNotificationMessage: + return + with open(notifyFile, 'w+') as fp: + fp.write(url) + + def _replyNotify(baseDir: str, handle: str, url: str) -> None: """Creates a notification that a new reply has arrived """ @@ -1925,8 +1978,7 @@ def _sendToGroupMembers(session, baseDir: str, handle: str, port: int, # set subject if not postJsonObject['object'].get('summary'): postJsonObject['object']['summary'] = 'General Discussion' - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domain) with open(followersFile, 'r') as groupMembers: for memberHandle in groupMembers: if memberHandle != handle: @@ -1934,10 +1986,8 @@ def _sendToGroupMembers(session, baseDir: str, handle: str, port: int, memberDomain = memberHandle.split('@')[1] memberPort = port if ':' in memberDomain: - memberPortStr = memberDomain.split(':')[1] - if memberPortStr.isdigit(): - memberPort = int(memberPortStr) - memberDomain = memberDomain.split(':')[0] + memberPort = getPortFromDomain(memberDomain) + memberDomain = removeDomainPort(memberDomain) sendSignedJson(postJsonObject, session, baseDir, nickname, domain, port, memberNickname, memberDomain, memberPort, cc, @@ -1954,9 +2004,7 @@ def _inboxUpdateCalendar(baseDir: str, handle: str, """ if not postJsonObject.get('actor'): return - if not postJsonObject.get('object'): - return - if not isinstance(postJsonObject['object'], dict): + if not hasObjectDict(postJsonObject): return if not postJsonObject['object'].get('tag'): return @@ -2014,10 +2062,8 @@ def inboxUpdateIndex(boxname: str, baseDir: str, handle: str, print('WARN: Failed to write entry to index ' + str(e)) else: try: - indexFile = open(indexFilename, 'w+') - if indexFile: + with open(indexFilename, 'w+') as indexFile: indexFile.write(destinationFilename + '\n') - indexFile.close() except Exception as e: print('WARN: Failed to write initial entry to index ' + str(e)) @@ -2032,9 +2078,8 @@ def _updateLastSeen(baseDir: str, handle: str, actor: str) -> None: return nickname = handle.split('@')[0] domain = handle.split('@')[1] - if ':' in domain: - domain = domain.split(':')[0] - accountPath = baseDir + '/accounts/' + nickname + '@' + domain + domain = removeDomainPort(domain) + accountPath = acctDir(baseDir, nickname, domain) if not os.path.isdir(accountPath): return if not isFollowingActor(baseDir, nickname, domain, actor): @@ -2056,6 +2101,173 @@ def _updateLastSeen(baseDir: str, handle: str, actor: str) -> None: lastSeenFile.write(str(daysSinceEpoch)) +def _bounceDM(senderPostId: str, session, httpPrefix: str, + baseDir: str, nickname: str, domain: str, port: int, + sendingHandle: str, federationList: [], + sendThreads: [], postLog: [], + cachedWebfingers: {}, personCache: {}, + translate: {}, debug: bool, + lastBounceMessage: []) -> bool: + """Sends a bounce message back to the sending handle + if a DM has been rejected + """ + print(nickname + '@' + domain + + ' cannot receive DM from ' + sendingHandle + + ' because they do not follow them') + + # Don't send out bounce messages too frequently. + # Otherwise an adversary could try to DoS your instance + # by continuously sending DMs to you + currTime = int(time.time()) + if currTime - lastBounceMessage[0] < 60: + return False + + # record the last time that a bounce was generated + lastBounceMessage[0] = currTime + + senderNickname = sendingHandle.split('@')[0] + senderDomain = sendingHandle.split('@')[1] + senderPort = port + if ':' in senderDomain: + senderPort = getPortFromDomain(senderDomain) + senderDomain = removeDomainPort(senderDomain) + cc = [] + + # create the bounce DM + subject = None + content = translate['DM bounce'] + followersOnly = False + saveToFile = False + clientToServer = False + commentsEnabled = False + attachImageFilename = None + mediaType = None + imageDescription = '' + city = 'London, England' + inReplyTo = removeIdEnding(senderPostId) + inReplyToAtomUri = None + schedulePost = False + eventDate = None + eventTime = None + location = None + postJsonObject = \ + createDirectMessagePost(baseDir, nickname, domain, port, + httpPrefix, content, followersOnly, + saveToFile, clientToServer, + commentsEnabled, + attachImageFilename, mediaType, + imageDescription, city, + inReplyTo, inReplyToAtomUri, + subject, debug, schedulePost, + eventDate, eventTime, location) + if not postJsonObject: + print('WARN: unable to create bounce message to ' + sendingHandle) + return False + # bounce DM goes back to the sender + print('Sending bounce DM to ' + sendingHandle) + sendSignedJson(postJsonObject, session, baseDir, + nickname, domain, port, + senderNickname, senderDomain, senderPort, cc, + httpPrefix, False, False, federationList, + sendThreads, postLog, cachedWebfingers, + personCache, debug, __version__) + return True + + +def _isValidDM(baseDir: str, nickname: str, domain: str, port: int, + postJsonObject: {}, updateIndexList: [], + session, httpPrefix: str, + federationList: [], + sendThreads: [], postLog: [], + cachedWebfingers: {}, + personCache: {}, + translate: {}, debug: bool, + lastBounceMessage: [], + handle: str) -> bool: + """Is the given message a valid DM? + """ + if nickname == 'inbox': + # going to the shared inbox + return True + + # check for the flag file which indicates to + # only receive DMs from people you are following + followDMsFilename = acctDir(baseDir, nickname, domain) + '/.followDMs' + if not os.path.isfile(followDMsFilename): + # dm index will be updated + updateIndexList.append('dm') + _dmNotify(baseDir, handle, + httpPrefix + '://' + domain + '/users/' + nickname + '/dm') + return True + + # get the file containing following handles + followingFilename = acctDir(baseDir, nickname, domain) + '/following.txt' + # who is sending a DM? + if not postJsonObject.get('actor'): + return False + sendingActor = postJsonObject['actor'] + sendingActorNickname = \ + getNicknameFromActor(sendingActor) + if not sendingActorNickname: + return False + sendingActorDomain, sendingActorPort = \ + getDomainFromActor(sendingActor) + if not sendingActorDomain: + return False + # Is this DM to yourself? eg. a reminder + sendingToSelf = False + if sendingActorNickname == nickname and \ + sendingActorDomain == domain: + sendingToSelf = True + + # check that the following file exists + if not sendingToSelf: + if not os.path.isfile(followingFilename): + print('No following.txt file exists for ' + + nickname + '@' + domain + + ' so not accepting DM from ' + + sendingActorNickname + '@' + + sendingActorDomain) + return False + + # Not sending to yourself + if not sendingToSelf: + # get the handle of the DM sender + sendH = sendingActorNickname + '@' + sendingActorDomain + # check the follow + if not isFollowingActor(baseDir, nickname, domain, sendH): + # DMs may always be allowed from some domains + if not dmAllowedFromDomain(baseDir, + nickname, domain, + sendingActorDomain): + # send back a bounce DM + if postJsonObject.get('id') and \ + postJsonObject.get('object'): + # don't send bounces back to + # replies to bounce messages + obj = postJsonObject['object'] + if isinstance(obj, dict): + if not obj.get('inReplyTo'): + _bounceDM(postJsonObject['id'], + session, httpPrefix, + baseDir, + nickname, domain, + port, sendH, + federationList, + sendThreads, postLog, + cachedWebfingers, + personCache, + translate, debug, + lastBounceMessage) + return False + + # dm index will be updated + updateIndexList.append('dm') + _dmNotify(baseDir, handle, + httpPrefix + '://' + domain + '/users/' + nickname + '/dm') + return True + + def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, session, keyId: str, handle: str, messageJson: {}, baseDir: str, httpPrefix: str, sendThreads: [], @@ -2070,7 +2282,9 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, unitTest: bool, YTReplacementDomain: str, showPublishedDateOnly: bool, allowLocalNetworkAccess: bool, - peertubeInstances: []) -> bool: + peertubeInstances: [], + lastBounceMessage: [], + themeName: str) -> bool: """ Anything which needs to be done after initial checks have passed """ actor = keyId @@ -2079,6 +2293,7 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, _updateLastSeen(baseDir, handle, actor) + postIsDM = False isGroup = _groupHandle(baseDir, handle) if _receiveLike(recentPostsCache, @@ -2149,7 +2364,8 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, federationList, debug, translate, YTReplacementDomain, - allowLocalNetworkAccess): + allowLocalNetworkAccess, + themeName): if debug: print('DEBUG: Announce accepted from ' + actor) @@ -2195,9 +2411,10 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, postJsonObject = messageJson nickname = handle.split('@')[0] + jsonObj = None if _validPostContent(baseDir, nickname, domain, postJsonObject, maxMentions, maxEmoji, - allowLocalNetworkAccess): + allowLocalNetworkAccess, debug): if postJsonObject.get('object'): jsonObj = postJsonObject['object'] @@ -2262,52 +2479,21 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, # create a DM notification file if needed postIsDM = isDM(postJsonObject) if postIsDM: - if nickname != 'inbox': - followDMsFilename = \ - baseDir + '/accounts/' + \ - nickname + '@' + domain + '/.followDMs' - if os.path.isfile(followDMsFilename): - followingFilename = \ - baseDir + '/accounts/' + \ - nickname + '@' + domain + '/following.txt' - if not postJsonObject.get('actor'): - return False - sendingActor = postJsonObject['actor'] - sendingActorNickname = \ - getNicknameFromActor(sendingActor) - sendingActorDomain, sendingActorPort = \ - getDomainFromActor(sendingActor) - if sendingActorNickname and sendingActorDomain: - if not os.path.isfile(followingFilename): - print('No following.txt file exists for ' + - nickname + '@' + domain + - ' so not accepting DM from ' + - sendingActorNickname + '@' + - sendingActorDomain) - return False - sendH = \ - sendingActorNickname + '@' + sendingActorDomain - if sendH != nickname + '@' + domain: - if sendH not in \ - open(followingFilename).read(): - print(nickname + '@' + domain + - ' cannot receive DM from ' + - sendH + - ' because they do not ' + - 'follow them') - return False - else: - return False - # dm index will be updated - updateIndexList.append('dm') - _dmNotify(baseDir, handle, - httpPrefix + '://' + domain + '/users/' + - nickname + '/dm') + if not _isValidDM(baseDir, nickname, domain, port, + postJsonObject, updateIndexList, + session, httpPrefix, + federationList, + sendThreads, postLog, + cachedWebfingers, + personCache, + translate, debug, + lastBounceMessage, + handle): + return False # get the actor being replied to domainFull = getFullDomain(domain, port) - actor = httpPrefix + '://' + domainFull + \ - '/users/' + handle.split('@')[0] + actor = httpPrefix + '://' + domainFull + '/users/' + nickname # create a reply notification file if needed if not postIsDM and isReply(postJsonObject, actor): @@ -2330,15 +2516,13 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, if isImageMedia(session, baseDir, httpPrefix, nickname, domain, postJsonObject, translate, YTReplacementDomain, - allowLocalNetworkAccess): + allowLocalNetworkAccess, + recentPostsCache, debug): # media index will be updated updateIndexList.append('tlmedia') if isBlogPost(postJsonObject): # blogs index will be updated updateIndexList.append('tlblogs') - elif isEventPost(postJsonObject): - # events index will be updated - updateIndexList.append('tlevents') # get the avatar for a reply/announce _obtainAvatarForReplyPost(session, baseDir, @@ -2347,14 +2531,32 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, # save the post to file if saveJson(postJsonObject, destinationFilename): + # should we notify that a post from this person has arrived? + # This is for cases where the notify checkbox is enabled + # on the person options screen + if not postIsDM and jsonObj: + if jsonObj.get('attributedTo') and jsonObj.get('id'): + attributedTo = jsonObj['attributedTo'] + if isinstance(attributedTo, str): + fromNickname = getNicknameFromActor(attributedTo) + fromDomain, fromPort = getDomainFromActor(attributedTo) + fromDomainFull = getFullDomain(fromDomain, fromPort) + if notifyWhenPersonPosts(baseDir, nickname, domain, + fromNickname, fromDomainFull): + postId = removeIdEnding(jsonObj['id']) + postLink = \ + httpPrefix + '://' + \ + getFullDomain(domain, port) + \ + '/users/' + nickname + \ + '?notifypost=' + postId.replace('/', '-') + _notifyPostArrival(baseDir, handle, postLink) + # If this is a reply to a muted post then also mute it. # This enables you to ignore a threat that's getting boring if isReplyToMutedPost: print('MUTE REPLY: ' + destinationFilename) - muteFile = open(destinationFilename + '.muted', 'w+') - if muteFile: + with open(destinationFilename + '.muted', 'w+') as muteFile: muteFile.write('\n') - muteFile.close() # update the indexes for different timelines for boxname in updateIndexList: @@ -2362,6 +2564,13 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, destinationFilename, debug): print('ERROR: unable to update ' + boxname + ' index') else: + if boxname == 'inbox': + if isRecentPost(postJsonObject): + domainFull = getFullDomain(domain, port) + updateSpeaker(baseDir, httpPrefix, + nickname, domain, domainFull, + postJsonObject, personCache, + translate, None, themeName) if not unitTest: if debug: print('Saving inbox post as html to cache') @@ -2381,7 +2590,8 @@ def _inboxAfterInitial(recentPostsCache: {}, maxRecentPosts: int, boxname, showPublishedDateOnly, peertubeInstances, - allowLocalNetworkAccess) + allowLocalNetworkAccess, + themeName) if debug: timeDiff = \ str(int((time.time() - htmlCacheStartTime) * @@ -2427,6 +2637,7 @@ def clearQueueItems(baseDir: str, queue: []) -> None: ctr += 1 except BaseException: pass + break break if ctr > 0: print('Removed ' + str(ctr) + ' inbox queue items') @@ -2444,6 +2655,7 @@ def _restoreQueueItems(baseDir: str, queue: []) -> None: for queuesubdir, queuedirs, queuefiles in os.walk(queueDir): for qfile in queuefiles: queue.append(os.path.join(queueDir, qfile)) + break break if len(queue) > 0: print('Restored ' + str(len(queue)) + ' inbox queue items') @@ -2468,6 +2680,161 @@ def runInboxQueueWatchdog(projectVersion: str, httpd) -> None: httpd.restartInboxQueue = False +def _inboxQuotaExceeded(queue: {}, queueFilename: str, + queueJson: {}, quotasDaily: {}, quotasPerMin: {}, + domainMaxPostsPerDay: int, + accountMaxPostsPerDay: int, + debug: bool) -> bool: + """limit the number of posts which can arrive per domain per day + """ + postDomain = queueJson['postDomain'] + if not postDomain: + return False + + if domainMaxPostsPerDay > 0: + if quotasDaily['domains'].get(postDomain): + if quotasDaily['domains'][postDomain] > \ + domainMaxPostsPerDay: + print('Queue: Quota per day - Maximum posts for ' + + postDomain + ' reached (' + + str(domainMaxPostsPerDay) + ')') + if len(queue) > 0: + try: + os.remove(queueFilename) + except BaseException: + pass + queue.pop(0) + return True + quotasDaily['domains'][postDomain] += 1 + else: + quotasDaily['domains'][postDomain] = 1 + + if quotasPerMin['domains'].get(postDomain): + domainMaxPostsPerMin = \ + int(domainMaxPostsPerDay / (24 * 60)) + if domainMaxPostsPerMin < 5: + domainMaxPostsPerMin = 5 + if quotasPerMin['domains'][postDomain] > \ + domainMaxPostsPerMin: + print('Queue: Quota per min - Maximum posts for ' + + postDomain + ' reached (' + + str(domainMaxPostsPerMin) + ')') + if len(queue) > 0: + try: + os.remove(queueFilename) + except BaseException: + pass + queue.pop(0) + return True + quotasPerMin['domains'][postDomain] += 1 + else: + quotasPerMin['domains'][postDomain] = 1 + + if accountMaxPostsPerDay > 0: + postHandle = queueJson['postNickname'] + '@' + postDomain + if quotasDaily['accounts'].get(postHandle): + if quotasDaily['accounts'][postHandle] > \ + accountMaxPostsPerDay: + print('Queue: Quota account posts per day -' + + ' Maximum posts for ' + + postHandle + ' reached (' + + str(accountMaxPostsPerDay) + ')') + if len(queue) > 0: + try: + os.remove(queueFilename) + except BaseException: + pass + queue.pop(0) + return True + quotasDaily['accounts'][postHandle] += 1 + else: + quotasDaily['accounts'][postHandle] = 1 + + if quotasPerMin['accounts'].get(postHandle): + accountMaxPostsPerMin = \ + int(accountMaxPostsPerDay / (24 * 60)) + if accountMaxPostsPerMin < 5: + accountMaxPostsPerMin = 5 + if quotasPerMin['accounts'][postHandle] > \ + accountMaxPostsPerMin: + print('Queue: Quota account posts per min -' + + ' Maximum posts for ' + + postHandle + ' reached (' + + str(accountMaxPostsPerMin) + ')') + if len(queue) > 0: + try: + os.remove(queueFilename) + except BaseException: + pass + queue.pop(0) + return True + quotasPerMin['accounts'][postHandle] += 1 + else: + quotasPerMin['accounts'][postHandle] = 1 + + if debug: + if accountMaxPostsPerDay > 0 or domainMaxPostsPerDay > 0: + pprint(quotasDaily) + return False + + +def _checkJsonSignature(baseDir: str, queueJson: {}) -> (bool, bool): + """check if a json signature exists on this post + """ + hasJsonSignature = False + jwebsigType = None + originalJson = queueJson['original'] + if not originalJson.get('@context') or \ + not originalJson.get('signature'): + return hasJsonSignature, jwebsigType + if not isinstance(originalJson['signature'], dict): + return hasJsonSignature, jwebsigType + # see https://tools.ietf.org/html/rfc7515 + jwebsig = originalJson['signature'] + # signature exists and is of the expected type + if not jwebsig.get('type') or \ + not jwebsig.get('signatureValue'): + return hasJsonSignature, jwebsigType + jwebsigType = jwebsig['type'] + if jwebsigType == 'RsaSignature2017': + if hasValidContext(originalJson): + hasJsonSignature = True + else: + unknownContextsFile = \ + baseDir + '/accounts/unknownContexts.txt' + unknownContext = str(originalJson['@context']) + + print('unrecognized @context: ' + + unknownContext) + + alreadyUnknown = False + if os.path.isfile(unknownContextsFile): + if unknownContext in \ + open(unknownContextsFile).read(): + alreadyUnknown = True + + if not alreadyUnknown: + with open(unknownContextsFile, 'a+') as unknownFile: + unknownFile.write(unknownContext + '\n') + else: + print('Unrecognized jsonld signature type: ' + + jwebsigType) + + unknownSignaturesFile = \ + baseDir + '/accounts/unknownJsonSignatures.txt' + + alreadyUnknown = False + if os.path.isfile(unknownSignaturesFile): + if jwebsigType in \ + open(unknownSignaturesFile).read(): + alreadyUnknown = True + + if not alreadyUnknown: + with open(unknownSignaturesFile, 'a+') as unknownFile: + unknownFile.write(jwebsigType + '\n') + return hasJsonSignature, jwebsigType + + def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, projectVersion: str, baseDir: str, httpPrefix: str, sendThreads: [], postLog: [], @@ -2482,7 +2849,8 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, showPublishedDateOnly: bool, maxFollowers: int, allowLocalNetworkAccess: bool, peertubeInstances: [], - verifyAllSignatures: bool) -> None: + verifyAllSignatures: bool, + themeName: str) -> None: """Processes received items and moves them to the appropriate directories """ @@ -2513,14 +2881,23 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, heartBeatCtr = 0 queueRestoreCtr = 0 + # time when the last DM bounce message was sent + # This is in a list so that it can be changed by reference + # within _bounceDM + lastBounceMessage = [int(time.time())] + + # how long it takes for broch mode to lapse + brochLapseDays = random.randrange(7, 14) + while True: time.sleep(1) # heartbeat to monitor whether the inbox queue is running - heartBeatCtr += 5 + heartBeatCtr += 1 if heartBeatCtr >= 10: # turn off broch mode after it has timed out - brochModeLapses(baseDir) + if brochModeLapses(baseDir, brochLapseDays): + brochLapseDays = random.randrange(7, 14) print('>>> Heartbeat Q:' + str(len(queue)) + ' ' + '{:%F %T}'.format(datetime.datetime.now())) heartBeatCtr = 0 @@ -2553,7 +2930,8 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, queue.pop(0) continue - print('Loading queue item ' + queueFilename) + if debug: + print('Loading queue item ' + queueFilename) # Load the queue json queueJson = loadJson(queueFilename, 1) @@ -2592,95 +2970,13 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, # change the last time that this was done quotasLastUpdatePerMin = currTime - # limit the number of posts which can arrive per domain per day - postDomain = queueJson['postDomain'] - if postDomain: - if domainMaxPostsPerDay > 0: - if quotasDaily['domains'].get(postDomain): - if quotasDaily['domains'][postDomain] > \ - domainMaxPostsPerDay: - print('Queue: Quota per day - Maximum posts for ' + - postDomain + ' reached (' + - str(domainMaxPostsPerDay) + ')') - if len(queue) > 0: - try: - os.remove(queueFilename) - except BaseException: - pass - queue.pop(0) - continue - quotasDaily['domains'][postDomain] += 1 - else: - quotasDaily['domains'][postDomain] = 1 + if _inboxQuotaExceeded(queue, queueFilename, + queueJson, quotasDaily, quotasPerMin, + domainMaxPostsPerDay, + accountMaxPostsPerDay, debug): + continue - if quotasPerMin['domains'].get(postDomain): - domainMaxPostsPerMin = \ - int(domainMaxPostsPerDay / (24 * 60)) - if domainMaxPostsPerMin < 5: - domainMaxPostsPerMin = 5 - if quotasPerMin['domains'][postDomain] > \ - domainMaxPostsPerMin: - print('Queue: Quota per min - Maximum posts for ' + - postDomain + ' reached (' + - str(domainMaxPostsPerMin) + ')') - if len(queue) > 0: - try: - os.remove(queueFilename) - except BaseException: - pass - queue.pop(0) - continue - quotasPerMin['domains'][postDomain] += 1 - else: - quotasPerMin['domains'][postDomain] = 1 - - if accountMaxPostsPerDay > 0: - postHandle = queueJson['postNickname'] + '@' + postDomain - if quotasDaily['accounts'].get(postHandle): - if quotasDaily['accounts'][postHandle] > \ - accountMaxPostsPerDay: - print('Queue: Quota account posts per day -' + - ' Maximum posts for ' + - postHandle + ' reached (' + - str(accountMaxPostsPerDay) + ')') - if len(queue) > 0: - try: - os.remove(queueFilename) - except BaseException: - pass - queue.pop(0) - continue - quotasDaily['accounts'][postHandle] += 1 - else: - quotasDaily['accounts'][postHandle] = 1 - - if quotasPerMin['accounts'].get(postHandle): - accountMaxPostsPerMin = \ - int(accountMaxPostsPerDay / (24 * 60)) - if accountMaxPostsPerMin < 5: - accountMaxPostsPerMin = 5 - if quotasPerMin['accounts'][postHandle] > \ - accountMaxPostsPerMin: - print('Queue: Quota account posts per min -' + - ' Maximum posts for ' + - postHandle + ' reached (' + - str(accountMaxPostsPerMin) + ')') - if len(queue) > 0: - try: - os.remove(queueFilename) - except BaseException: - pass - queue.pop(0) - continue - quotasPerMin['accounts'][postHandle] += 1 - else: - quotasPerMin['accounts'][postHandle] = 1 - - if debug: - if accountMaxPostsPerDay > 0 or domainMaxPostsPerDay > 0: - pprint(quotasDaily) - - if queueJson.get('actor'): + if debug and queueJson.get('actor'): print('Obtaining public key for actor ' + queueJson['actor']) # Try a few times to obtain the public key @@ -2717,7 +3013,8 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, time.sleep(1) if not pubKey: - print('Queue: public key could not be obtained from ' + keyId) + if debug: + print('Queue: public key could not be obtained from ' + keyId) if os.path.isfile(queueFilename): os.remove(queueFilename) if len(queue) > 0: @@ -2739,30 +3036,13 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, debug): httpSignatureFailed = True print('Queue: Header signature check failed') - if debug: - pprint(queueJson['httpHeaders']) + pprint(queueJson['httpHeaders']) else: if debug: print('DEBUG: http header signature check success') # check if a json signature exists on this post - hasJsonSignature = False - jwebsigType = None - originalJson = queueJson['original'] - if originalJson.get('@context') and \ - originalJson.get('signature'): - if isinstance(originalJson['signature'], dict): - # see https://tools.ietf.org/html/rfc7515 - jwebsig = originalJson['signature'] - # signature exists and is of the expected type - if jwebsig.get('type') and jwebsig.get('signatureValue'): - jwebsigType = jwebsig['type'] - if jwebsigType == 'RsaSignature2017': - if hasValidContext(originalJson): - hasJsonSignature = True - else: - print('unrecognised @context: ' + - str(originalJson['@context'])) + hasJsonSignature, jwebsigType = _checkJsonSignature(baseDir, queueJson) # strict enforcement of json signatures if not hasJsonSignature: @@ -2778,6 +3058,7 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, pprint(queueJson['httpHeaders']) if verifyAllSignatures: + originalJson = queueJson['original'] print('Queue: inbox post does not have a jsonld signature ' + keyId + ' ' + str(originalJson)) @@ -2791,6 +3072,7 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, if httpSignatureFailed or verifyAllSignatures: # use the original json message received, not one which # may have been modified along the way + originalJson = queueJson['original'] if not verifyJsonSignature(originalJson, pubKey): if debug: print('WARN: jsonld inbox signature check failed ' + @@ -2892,7 +3174,8 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, federationList, queueJson['postNickname'], debug): - print('Queue: Update accepted from ' + keyId) + if debug: + print('Queue: Update accepted from ' + keyId) if os.path.isfile(queueFilename): os.remove(queueFilename) if len(queue) > 0: @@ -2905,8 +3188,9 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, httpPrefix, domain, port, debug) if len(recipientsDict.items()) == 0 and \ len(recipientsDictFollowers.items()) == 0: - print('Queue: no recipients were resolved ' + - 'for post arriving in inbox') + if debug: + print('Queue: no recipients were resolved ' + + 'for post arriving in inbox') if os.path.isfile(queueFilename): os.remove(queueFilename) if len(queue) > 0: @@ -2969,11 +3253,12 @@ def runInboxQueue(recentPostsCache: {}, maxRecentPosts: int, YTReplacementDomain, showPublishedDateOnly, allowLocalNetworkAccess, - peertubeInstances) + peertubeInstances, + lastBounceMessage, + themeName) if debug: pprint(queueJson['post']) - - print('Queue: Queue post accepted') + print('Queue: Queue post accepted') if os.path.isfile(queueFilename): os.remove(queueFilename) if len(queue) > 0: diff --git a/install-desktop-client b/install-desktop-client new file mode 100755 index 000000000..cf15c158c --- /dev/null +++ b/install-desktop-client @@ -0,0 +1,134 @@ +#!/bin/bash + +currUser="$USER" + +if [ ! -d "${HOME}/.config/systemd/user" ]; then + mkdir -p "${HOME}/.config/systemd/user" +fi + +if [ ! -f /usr/bin/zenity ]; then + if [ -f /usr/bin/apt ]; then + sudo apt -y install zenity + fi + if [ -f /usr/bin/pacman ]; then + sudo pacman -S --noconfirm zenity + fi +fi + +HANDLE=$(zenity --entry --width=400 --title "Epicyon Desktop Client" --text "Fediverse handle (name@domain): ") +if [ ! "$HANDLE" ]; then + zenity --warning --width=400 --text "No fediverse handle was given" + exit 1 +fi +if [[ "$HANDLE" != *'@'* ]]; then + zenity --warning --width=400 --text "Fediverse handle must be in the form username@domain" + exit 2 +fi +# PASSWORD=$(zenity --width=400 --password --title "Epicyon Desktop Client") +# if [ ! "$PASSWORD" ]; then +# zenity --warning --width=400 --text "No password was given" +# exit 3 +# fi + +if [ ! -f /usr/bin/git ]; then + if [ -f /usr/bin/apt ]; then + sudo apt -y install git + fi + if [ -f /usr/bin/pacman ]; then + sudo pacman -S --noconfirm git + fi +fi + +if [ ! -f /usr/bin/gpg ]; then + if [ -f /usr/bin/apt ]; then + sudo apt -y install gnupg + fi + if [ -f /usr/bin/pacman ]; then + sudo pacman -S --noconfirm gnupg + fi +fi + +if [ ! -f /usr/bin/python3 ]; then + if [ -f /usr/bin/apt ]; then + sudo apt -y install python3 + fi + if [ -f /usr/bin/pacman ]; then + sudo pacman -S --noconfirm python + fi +fi + +if [ ! -d ${HOME}/.epicyon ]; then + git clone https://gitlab.com/bashrc2/epicyon ${HOME}/.epicyon +else + cd ${HOME}/.epicyon || exit 1 + git pull +fi +if [ ! -d ${HOME}/.epicyon ]; then + zenity --warning --width=400 --text "Unable to clone Epicyon repo" + exit 4 +fi +chown -R "${currUser}":"${currUser}" ${HOME}/.epicyon + +notificationType= +if [ -f /usr/bin/notify-send ]; then + notificationType='notify-send' +else + if [ -f /usr/bin/zenity ]; then + notificationType='zenity' + fi +fi +if [[ ! "$notificationType" ]]; then + zenity --warning --width=400 --text "No desktop notification command was found." + exit 5 +fi + +if [ ! -d ${HOME}/.gnupg ]; then + echo 'Generating GPG key' + gpg --quick-generate-key "${HANDLE}" ed25519 cert +fi + +#{ echo '[Unit]'; +# echo 'Description=Epicyon Desktop Notifications'; +# echo ''; +# echo '[Service]'; +# echo "WorkingDirectory=${HOME}/.epicyon"; +# echo "ExecStart=/usr/bin/python3 epicyon.py --noKeyPress --notifyType $notificationType --notify $HANDLE --password \"$PASSWORD\""; +# echo 'Type=oneshot'; +# echo 'RemainAfterExit=yes'; +# echo ''; +# echo '[Install]'; +# echo 'WantedBy=default.target'; } > "${HOME}/.config/systemd/user/epicyon-notifications.service" +#systemctl --user daemon-reload +#systemctl --user stop epicyon-notifications.service & +#systemctl --user disable epicyon-notifications.service + +{ echo '#!/bin/bash'; + echo 'cd ~/.epicyon'; + echo "if [ \"\$1\" ]; then"; + echo " PASSWORD=\"\$1\""; + echo 'else'; + echo " PASSWORD=\$(zenity --width=400 --password --title \"Epicyon Desktop Client\")"; + echo " if [ ! \"\$PASSWORD\" ]; then"; + echo " zenity --warning --width=400 --text \"No password was given\""; + echo ' exit 3'; + echo ' fi'; + echo 'fi'; + echo "python3 epicyon.py --desktop ${HANDLE} --password \"\$PASSWORD\""; } > ~/epicyon-client +chmod +x ~/epicyon-client + +# TTS version +cp ~/epicyon-client ~/epicyon-client-tts +chmod +x ~/epicyon-client-tts +sed -i 's|epicyon.py|epicyon.py --screenreader espeak|g' ~/epicyon-client-tts + +# TTS version with picospeaker +cp ~/epicyon-client ~/epicyon-client-pico +chmod +x ~/epicyon-client-pico +sed -i 's|epicyon.py|epicyon.py --screenreader picospeaker|g' ~/epicyon-client-pico + +# TTS stream +cp ~/epicyon-client ~/epicyon-client-stream +chmod +x ~/epicyon-client-stream +sed -i 's|epicyon.py|epicyon.py --notifyShowNewPosts --screenreader espeak|g' ~/epicyon-client-stream + +zenity --info --width=400 --text "Epicyon desktop client is now installed. You can run it with ~/epicyon-client" diff --git a/jami.py b/jami.py index ee89d68c2..d0a20008b 100644 --- a/jami.py +++ b/jami.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Profile Metadata" def getJamiAddress(actorJson: {}) -> str: diff --git a/like.py b/like.py index 8378032fb..ad0bff5da 100644 --- a/like.py +++ b/like.py @@ -5,7 +5,10 @@ __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 hasUsersPath from utils import getFullDomain from utils import removeIdEnding @@ -37,9 +40,7 @@ def likedByPerson(postJsonObject: {}, nickname: str, domain: str) -> bool: def noOfLikes(postJsonObject: {}) -> int: """Returns the number of likes 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('likes'): return 0 @@ -103,7 +104,8 @@ def _like(recentPostsCache: {}, updateLikesCollection(recentPostsCache, baseDir, postFilename, objectUrl, - newLikeJson['actor'], domain, debug) + newLikeJson['actor'], + nickname, domain, debug) sendSignedJson(newLikeJson, session, baseDir, nickname, domain, port, @@ -167,14 +169,14 @@ def sendLikeViaServer(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) + print('DEBUG: like 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: like webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -189,11 +191,11 @@ def sendLikeViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: like no ' + postToBox + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: like no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -203,9 +205,12 @@ def sendLikeViaServer(baseDir: str, session, 'Content-type': 'application/json', 'Authorization': authHeader } - postResult = postJson(session, newLikeJson, [], inboxUrl, headers) + postResult = postJson(httpPrefix, fromDomainFull, + session, newLikeJson, [], inboxUrl, + headers, 3, True) if not postResult: - print('WARN: POST announce failed for c2s to ' + inboxUrl) + if debug: + print('WARN: POST like failed for c2s to ' + inboxUrl) return 5 if debug: @@ -246,14 +251,15 @@ def sendUndoLikeViaServer(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) + print('DEBUG: unlike webfinger failed for ' + handle) return 1 if not isinstance(wfRequest, dict): - print('WARN: Webfinger for ' + handle + ' did not return a dict. ' + - str(wfRequest)) + if debug: + print('WARN: unlike webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -268,11 +274,11 @@ def sendUndoLikeViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: unlike no ' + postToBox + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: unlike no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -282,13 +288,16 @@ def sendUndoLikeViaServer(baseDir: str, session, 'Content-type': 'application/json', 'Authorization': authHeader } - postResult = postJson(session, newUndoLikeJson, [], inboxUrl, headers) + postResult = postJson(httpPrefix, fromDomainFull, + session, newUndoLikeJson, [], inboxUrl, + headers, 3, True) if not postResult: - print('WARN: POST announce failed for c2s to ' + inboxUrl) + if debug: + print('WARN: POST unlike failed for c2s to ' + inboxUrl) return 5 if debug: - print('DEBUG: c2s POST undo like success') + print('DEBUG: c2s POST unlike success') return newUndoLikeJson @@ -319,8 +328,7 @@ def outboxLike(recentPostsCache: {}, print('DEBUG: c2s like request arrived in outbox') messageId = removeIdEnding(messageJson['object']) - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domain) postFilename = locatePost(baseDir, nickname, domain, messageId) if not postFilename: if debug: @@ -329,7 +337,8 @@ def outboxLike(recentPostsCache: {}, return True updateLikesCollection(recentPostsCache, baseDir, postFilename, messageId, - messageJson['actor'], domain, debug) + messageJson['actor'], + nickname, domain, debug) if debug: print('DEBUG: post liked via c2s - ' + postFilename) @@ -344,9 +353,7 @@ def outboxUndoLike(recentPostsCache: {}, return if not messageJson['type'] == 'Undo': return - if not messageJson.get('object'): - return - if not isinstance(messageJson['object'], dict): + if not hasObjectDict(messageJson): if debug: print('DEBUG: undo like object is not dict') return @@ -370,8 +377,7 @@ def outboxUndoLike(recentPostsCache: {}, print('DEBUG: c2s undo like request arrived in outbox') messageId = removeIdEnding(messageJson['object']['object']) - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domain) postFilename = locatePost(baseDir, nickname, domain, messageId) if not postFilename: if debug: diff --git a/linked_data_sig.py b/linked_data_sig.py index 5a1c5dbdd..b1acbc7da 100644 --- a/linked_data_sig.py +++ b/linked_data_sig.py @@ -7,6 +7,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Security" import base64 import hashlib diff --git a/locations.txt b/locations.txt new file mode 100644 index 000000000..acf611597 --- /dev/null +++ b/locations.txt @@ -0,0 +1,3694 @@ +AACHEN, GERMANY:50.8228:6.18722:160 +AALBORG, DENMARK:57.0928:9.84889 +AARHUS, DENMARK:56.3042:10.6194:91 +ABADAN, IRAN:30.365:48.2331 +ABAKAN, RUSSIA:53.74:91.385:112 +ABBEVILLE, FRANCE:50.1433:1.83167 +ABBOTSFORD, CANADA:49.0253:W122.363 +ABDANAN, IRAN:32.9344:47.4833 +ABECHE, CHAD:13.8469:20.8442 +ABERDEEN, UK:57.145278438534575:W2.1125505583496174:185 +ABERDEEN, USA:39.4661:W76.1694 +ABHA, SAUDI ARABIA:18.24:42.6556 +ABIDJAN, IVORY COAST:5.25972:W3.92639 +ABILENE, USA:32.4111:W99.6817 +ABQAIQ, SAUDI ARABIA:25.9114:49.5914 +ABU DHABI, UNITED ARAB EMIRATES:24.2481:54.5475 +ABUJA, NIGERIA:9.00667:7.26306 +ABUMUSA I., IRAN:25.8758:55.0328 +ABU SIMBEL, EGYPT:22.3758:31.6117 +ACAPULCO, MEXICO:16.7567:W99.7533 +ACARIGUA, VENEZUELA:9.55333:W69.2378 +ACCRA, GHANA:5.60278:W0.168056 +ADAK ISLAND, USA:51.8778:W176.646 +ADANA, TURKEY:37:35.321333:1945 +ADDIS ABABA, ETHIOPIA:8.97694:38.8:527 +ADELAIDE, AUSTRALIA:-34.9213:138.6186:500 +ADRAR, ALGERIA:27.8375:W0.186389:633 +AFYON, TURKEY:38.7261:30.6011:1025 +AGADEZ, NIGER:16.9647:7.99694:2 +AGADIR, MOROCCO:30.3811:W9.54611:51 +AGARTALA, INDIA:23.8889:91.2408:76 +AGEN, FRANCE:44.1747:0.590556:11 +AGGENEYS, SOUTH AFRICA:-29.2817:18.8136:205 +AGRA, INDIA:27.1556:77.9608:121 +AGRINION, GREECE:38.6019:21.3511:162:84 +AGUADILLA, PUERTO RICO:18.4947:W67.1294:197 +AGUASCALIENTES, MEXICO:21.7053:W102.318:385 +AHMEDABAD, INDIA:23.0747:72.6317:464 +AHWAZ, IRAN:31.3372:48.7619:185 +AIN OUSSERA, ALGERIA:35.5253:2.87861:809 +AIRE-SUR-L'ADOUR, FRANCE:43.7094:W0.245278:57 +AITUTAKI, COOK ISLANDS:-18.825:W159.774:18 +AIX-LES-MILLES, FRANCE:43.5053:5.36778 +AIZWAL, INDIA:23.7461:92.8031:457 +AJACCIO, CORSE ISL.:41.9236:8.80278:82 +AKHISAR, TURKEY:38.8086:27.8336:93 +AKITA, JAPAN:39.6156:140.219:906 +AKOLA, INDIA:20.6989:77.0564:128 +AKRON, USA:41.0375:W81.4667:161 +AKROTIRI, CYPRUS:34.5903:32.9878:254 +AKTYUBINSK, RUSSIA:50.245:57.2033:400 +AKURE, NIGERIA:7.24667:5.30083:991 +AKUREYRI, ICELAND:65.6597:W18.0725:138 +AL AIN, UNITED ARAB EMIRATES:24.2617:55.6092:5 +ALAMOGORDO, USA:32.8525:W106.106:55 +ALBACETE, SPAIN:38.9483:W1.86333:1126 +ALBANY, USA:42.7481:W73.8028:56 +ALBENGA, ITALY:44.0506:8.12722:36 +ALBERT, FRANCE:49.9714:2.69972:13 +ALBI, FRANCE:43.9136:2.11306:44 +ALBURY, AUSTRALIA:-36.0678:146.958:64 +ALDAN, RUSSIA:34.8042:W96.6711:33 +ALEPPO, SYRIA:36.1806:37.2242:190 +ALERT, CANADA:82.5178:W62.2806:142 +ALES, FRANCE:44.0694:4.14194:23 +ALESUND, NORWAY:62.5603:6.11:632 +ALEXANDER BAY, SOUTH AFRICA:-28.575:16.5333:9 +ALEXANDRA, NEW ZEALAND:-45.2117:169.373:9 +ALEXANDRIA, EGYPT:31.1839:29.9489:2679 +ALEXANDRIA, GREECE:35.50580479245094:24.024996747143533:61 +ALEXANDRIA, USA:31.3272:W92.5483:39 +ALEXANDROUPOLIS, GREECE:40.8558:25.9561:642 +ALGHERO, ITALY:40.6319:8.29056:224 +ALGIER, ALGERIA:36.6908:3.21528:1190 +AL HOCIEMA, MOROCCO:35.1769:W3.83944:10 +ALICANTE, SPAIN:38.2819:W0.558056:201 +ALICE SPRINGS, AUSTRALIA:-23.8069:133.902:148 +ALICE, USA:27.7408:W98.0269:32 +ALLAHABAD, INDIA:25.4389:81.7342:365 +ALLENDORF, GERMANY:51.0353:8.67889:41 +ALMA-ATA, KAZAKHSTAN:43.3519:77.0403:682 +ALMEIRIM, BRAZIL:-0.889722:W52.6022:222 +ALMERIA, SPAIN:36.8439:W2.37:295 +ALOFI, NEW ZEALAND:-19.08:W169.926:46 +ALOR SETAR, MALAYSIA:6.19389:100.403:666 +ALPINOPOLIS, BRAZIL:-20.7028:W46.335:458 +ALPNACH, SWITZERLAND:46.9439:8.28417:56 +ALTA, NORWAY:69.9769:23.3661:9 +ALTENBURG, GERMANY:50.9817:12.5061:45 +ALTENRHEIN, SWITZERLAND:47.485:9.56056:9 +ALTOONA, USA:40.2964:W78.32:25 +ALTUS, USA:34.6669:W99.2667:48 +AMAMI, JAPAN:28.4306:129.712:308 +AMARILLO, USA:35.2192:W101.706:269 +AMBATO, ECUADOR:-1.2125:W78.5742:46 +AMBERIEU, FRANCE:45.9872:5.32833:24 +AMBERLEY, AUSTRALIA:-10.9508:142.459:26 +AMBON, INDONESIA:-3.70889:128.09:359 +AMIENS, FRANCE:49.8714:2.38639:49 +AMMAN, JORDAN:31.7225:35.9931:1680 +AMRITSAR, INDIA:31.7075:74.7992:139 +AMSTERDAM, NETHERLANDS:52.3086:4.76389:219 +ANACO, VENEZUELA:9.42917:W64.4708:42 +ANADYR, RUSSIA:64.7347:177.741:20 +ANCENIS, FRANCE:47.4081:W1.1775:20 +ANCHORAGE, USA:61.2511:W149.806:1415 +ANCUD, CHILE:-41.9061:W73.7967:1252 +ANDAHUAYLAS, PERU:-13.7064:W73.3503:370 +ANDERSEN, USA:34.495:W82.7092:108 +ANDERSTORP, SWEDEN:57.2642:13.5992:5 +ANDOYA, NORWAY:69.2925:16.1442:489 +ANGELHOLM, SWEDEN:56.2958:12.8469:12 +ANGERS, FRANCE:47.4972:W0.5725:42 +ANGOULEME, FRANCE:45.7292:0.221389:21 +ANKARA, TURKEY:39.93:32.85:2000 +ANKLAM, GERMANY:53.8325:13.6689:56 +ANNABA, ALGERIA:36.8222:7.80917:49 +ANNECY, FRANCE:45.9292:6.09861:66 +ANNEMASSE, FRANCE:46.1919:6.26833:4 +ANNISTON, USA:33.5881:W85.8581:118 +ANTALYA, TURKEY:36.9014:30.7917:1417 +ANTANANARIVO, MADAGASCAR:-18.7967:47.4786:85 +ANTA, PERU:-9.34722:W77.5983:202 +ANTOFAGASTA, CHILE:-23.4444:W70.445:500 +ANTSIRANANA, MADAGASCAR:-12.3492:49.2917:71 +ANTSOHIHY, MADAGASCAR:-14.8986:47.9939:20 +ANTWERP, BELGIUM:51.19:4.46278:204 +ANURADHAPURA, SRI LANKA:8.30111:80.4281:36 +AOMORI, JAPAN:40.7344:140.691:824 +AQABA, JORDAN:29.6114:35.0181:375 +ARACAJU, BRAZIL:-10.9839:W37.0703:181 +ARACATUBA, BRAZIL:-21.1411:W50.4247:1167 +ARAD, ROMANIA:46.1764:21.2619:46 +ARAK, IRAN:34.1403:49.8483:50 +ARARACUARA, BRAZIL:-21.8119:W48.1328:1004 +ARAR, SAUDI ARABIA:30.9072:41.1383:50 +ARBOGA, SWEDEN:59.3864:15.9239:9 +ARCACHON, FRANCE:44.5964:W1.11083:7 +ARDABIL, IRAN:38.3261:48.4242:18 +ARDMORE, USA:34.3031:W97.0194:134 +AREQUIPA, PERU:-16.3408:W71.5831:69 +ARICA, CHILE:-18.3483:W70.3386:50 +ARMENIA, COLOMBIA:4.536307:W75.6723751:650 +ARMSTRONG, CANADA:50.2903:W88.9097:5 +ARNSBERG, GERMANY:51.4833:7.89917:193 +ARUSHA, TANZANIA:-3.36778:36.6333:50 +ARVIDSJAUR, SWEDEN:65.59:19.2817:4 +ARVIKA, SWEDEN:59.6758:12.6392:10 +ASAHIKAWA, JAPAN:43.6708:142.447:747 +ASHIYA, JAPAN:33.8831:130.653:18 +ASSIS, BRAZIL:-22.6383:W50.4558:460 +ASTRAKHAN, RUSSIA:46.2833:48.0061:208 +ASUNCION, PARAGUAY:-25.2397:W57.5189:128 +ASWAN, EGYPT:23.9642:32.8197:1000 +ASYUT, EGYPT:27.0464:31.0119:50 +ATHENS, GREECE:37.9839412:23.7283052:38 +ATLANTA, USA:33.6403:W84.4269:354 +ATLANTIC CITY, USA:39.4575:W74.5769:44 +ATSUGI, JAPAN:35.4544:139.45:93 +AUBENAS-VALS-LANAS, FRANCE:44.5442:4.37194:14 +AUCH, FRANCE:43.6878:0.601667:72 +AUCKLAND, NEW ZEALAND:-37.0081:174.792:1086 +AUGSBURG, GERMANY:48.425:10.9317:146 +AUGUSTA, USA:44.3206:W69.7972:783 +AURANGABAD, INDIA:19.8631:75.3981:139 +AURILLAC, FRANCE:44.8914:2.42194:28 +AUSTIN, USA:30.1944:W97.6697:772 +AUTUN, FRANCE:46.9664:4.26028:61 +AUXERRE, FRANCE:47.85:3.49694:49 +AVIANO, ITALY:46.0317:12.5964:113 +AVIGNON, FRANCE:43.9072:4.90167:64 +AVILES, SPAIN:43.5633:W6.03444:26 +AVORD, FRANCE:47.0533:2.6325:27 +AYACUCHO, PERU:-13.1547:W74.2042:100 +AYDIN, TURKEY:37.8158:27.8861:1582 +AYOLAS, PARAGUAY:-27.3706:W56.8539:1060 +AZENTKILYSZABADJA, HUNGARY:47.0778:17.9683:22 +BABELTHUAP, PALAU ISLAND:7.3675:134.544:370 +BACAU, ROMANIA:46.5219:26.9103:43 +BACOLOD, PHILIPPINES:10.6425:122.929:162 +BADAJOZ, SPAIN:38.8911:W6.82111:1440 +BADEN-BADEN, GERMANY:48.7911:8.18694:140 +BAFOUSSAM, CAMEROON:5.53694:10.3542:402 +BAGABAG, PHILIPPINES:16.6189:121.252:183 +BAGAN, MYANMAR:21.1819:94.9292:104 +BAGE, BRAZIL:-31.3903:W54.1122:50 +BAGHDAD, IRAQ:33.2619:44.2339:204 +BAGHDOGRA, INDIA:26.6814:88.3281:10 +BAGNOLE-DE-L'ORNE, FRANCE:48.5458:W0.387222:9 +BAGUIO, PHILIPPINES:16.375:120.619:57 +BAHAR DAR, ETHIOPIA:11.6081:37.3214:28 +BAHAWALPURE, PAKISTAN:29.3469:71.7114:246 +BAHIA BLANCA, ARGENTINA:-38.7247:W62.1692:1000 +BAHIA SOLANO, COLOMBIA:6.20278:W77.3944:1667 +BAHRAIN, BAHRAIN:25.9183:50.5906:765 +BAIA MARE, ROMANIA:47.6583:23.47:233 +BAIDOA, SOMALIA:3.09861:43.6242,1000 +BAIE COMEAU, CANADA:49.1322:W68.2072:432 +BAKER LAKE, CANADA:64.2989:W96.0778:182 +BAKERSFIELD, USA:35.4333:W119.057:391 +BAKHTARAN, IRAN:34.3464:47.1564:500 +BAKSHI KA TALAB, INDIA:26.9883:80.8931:41 +BAKU, RUSSIA:40.4675:50.0467:2140 +BALIKPAPAN, INDONESIA:-1.26806:116.894:503 +BALTIMORE, USA:39.1753:W76.6683:239 +BALURGHAT, INDIA:25.2608:88.7956:10 +BAMAKO, MALI:12.5333:W7.94972:245 +BAMBERG, GERMANY:49.9203:10.9142:54 +BAMENDA, CAMEROON:6.03917:10.1225:50 +BAM, IRAN:29.0839:58.4503:50 +BANDA ACEH, INDONESIA:5.52333:95.4203:61 +BANDAR ABBAS, IRAN:27.1581:56.1722:50 +BANDAR LENGEH, IRAN:26.5317:54.8217:2 +BANDAR MAHSHAHR, IRAN:30.5561:49.1517:20 +BANDIRMA, TURKEY:40.3178:27.9775:713 +BANDOUNDU, ZAIRE:-3.31111:17.3817:222 +BANDUNG, INDONESIA:-6.90056:107.576:167 +BANGALORE, INDIA:12.9497:77.6681:741 +BANGKOK, THAILAND:13.9125:100.607:1569 +BANGOR, USA:44.8072:W68.8281:89 +BANGUI, CENTRAL AFRICAN REP.:4.39833:18.5186:67 +BANJA LUKA, BOSNIA-HERCEGOVINA:44.9414:17.2975:1239 +BANJARMASIN, INDONESIA:-3.44194:114.761:98 +BANJUL, GAMBIA:13.3378:W16.6519:12 +BANNU, PAKISTAN:32.9719:70.5247:100 +BARACOA PLAYA, CUBA:20.3656:W74.5064:977 +BARAHONA, DOMINICAN REPUBLIC:18.2514:W71.1203:163 +BARBACENA, BRAZIL:-21.2669:W43.7608:759 +BARCELONA, SPAIN:41.40016140348051:2.159836908862287:101 +BARCELONA, VENEZUELA:10.1069:W64.6889:135 +BARDUFOSS, NORWAY:69.0556:18.5403:2 +BAREILLY, INDIA:28.4222:79.4497:106 +BARI, ITALY:41.1383:16.7606:117 +BARINAS, VENEZUELA:8.61944:W70.2206:322 +BARNAUL, RUSSIA:53.3639:83.5419:321 +BARODA, INDIA:22.3361:73.2261:220 +BARQUISIMETO, VENEZUELA:10.0425:W69.3583:353 +BARRA DEL COLORADO, COSTA RICA:10.7686:W83.5858:811 +BARRANCABERMEJA, COLOMBIA:7.02417:W73.8067:1154 +BARRANQUILLA, COLOMBIA:10.8894:W74.7806:154 +BARROW, USA:71.2853:W156.766:55 +BARTH, GERMANY:54.3381:12.71:40 +BASRAH, IRAQ:30.5486:47.6622:181 +BASSE TERRE, ST. KITTS & NEVIS:17.3111:W62.7186:6 +BASTIA, CORSE ISL.:42.5539:9.48333:19 +BATA, EQUATORIAL GUINEA:1.90528:9.80556:50 +BATAM, INDONESIA:1.12083:104.119:1595 +BATMAN, TURKEY:37.9289:41.1161:563 +BATON ROUGE, USA:30.5331:W91.1494:199 +BATSFJORD, NORWAY:70.6:29.6925:1435 +BATTAMBANG, CAMBODIA:13.0956:103.224:293 +BATTICALOA, SRI LANKA:7.70556:81.6778:50 +BAUDETTE, USA:48.7283:W94.6122:12 +BAUMHOLDER, GERMANY:49.65:7.3:69 +BAURU, BRAZIL:-22.345:W49.0536:667 +BAUTZEN, GERMANY:51.1933:14.5197:66 +BAYAMO, CUBA:20.3964:W76.6214:918 +BAYREUTH, GERMANY:49.9842:11.6383:66 +BEAUFORT, USA:32.4772:W80.7231:65 +BEAUMONT, USA:29.9508:W94.0206:220 +BEAUNE, FRANCE:47.0058:4.89333:31 +BEAUVAIS, FRANCE:49.4544:2.11278:33 +BEDFORD, USA:42.4697:W71.2889:36 +BEER-SHEBA, ISRAEL:31.2869:34.7228:117 +BEIJING, CHINA:40.08:116.584:16808:4144 +BEIRA, MOZAMBIQUE:-19.7964:34.9075:633 +BEIRUT, LEBANON:33.8133:35.4886:19 +BEJAJA, ALGERIA:36.7119:5.06972:120 +BELEM, BRAZIL:-1.37917:W48.4761:1059 +BELFAST, NORTH IRELAND:54.57925:W5.93892:115 +BELGAUM, INDIA:15.8592:74.6181:94 +BELIZE CITY, BELIZE:17.5389:W88.3081:35 +BELLEVILLE, USA:38.545:W89.835:60 +BELLINGHAM, USA:48.7925:W122.537:79 +BELO HORIZONTE, BRAZIL:-19.6336:W43.9686:330 +BENGHAZI, LIBYA:32.0967:20.2694:314 +BENGKULU, INDONESIA:-3.86389:102.341:151 +BENGUELA, ANGOLA:-12.6089:13.4036:2100 +BENIN, NIGERIA:6.31722:5.59944:1204 +BENSON, ENGLAND:51.6161:W1.09556:9 +BEOGRAD, YUGOSLAVIA:44.8183:20.3089:360 +BEQUIA, ST.VINCENT/GRENADINES:12.9883:W61.2619:367 +BERBERATI, CENTRAL AFRICAN REP.:4.22139:15.7861:67 +BERGAMO, ITALY:45.6739:9.70417:40 +BERGEN, NORWAY:60.2933:5.21806:445 +BERGERAC, FRANCE:44.8534568:0.487531:56 +BERLIN, GERMANY:52.5170365:13.3888599:891 +BERMEJO, BOLIVIA:-22.7694:W64.315:5 +BERN, SWITZERLAND:46.9139:7.49694:51 +BERTRIX, BELGIUM:49.8872:5.22861:137 +BESANCON-LA-VEZE, FRANCE:47.2083:6.08306:5 +BETHEL, USA:60.7797:W161.838:129 +BETHLEHEM, SOUTH AFRICA:-28.2483:28.3361:27 +BETTLES, USA:66.9153:W151.528:4 +BEZIERS, FRANCE:43.3239:3.35556:95 +BHAIRAWA, NEPAL:27.5056:83.4161:36 +BHAUNAGAR, INDIA:21.7519:72.185:108 +BHOPAL, INDIA:23.285:77.3372:285 +BHUBANESWAR, INDIA:20.2442:85.8178:422 +BHUJ, INDIA:23.2878:69.67:56 +BIAK, INDONESIA:-1.19:136.108:1746 +BIARRITZ-BAYONNE, FRANCE:43.4683:W1.52306:11 +BIBERACH, GERMANY:48.1108:9.76278:72 +BIDAR, INDIA:17.9078:77.4858:43 +BIKANER, INDIA:28.0725:73.2067:155 +BILASPUR, INDIA:21.9886:82.1111:205 +BILBAO, SPAIN:43.3008:W2.91056:41 +BILOXI, USA:30.4108:W88.9236:175 +BIMA, INDONESIA:-8.53944:118.687:222 +BIRATNAGAR, NEPAL:26.4833:87.2636:58 +BIRJAND, IRAN:32.8956:59.2756:50 +BIRMINGHAM, ENGLAND:52.4796992:W1.9026911:267 +BIRMINGHAM, USA:33.5628:W86.7533:383 +BISHKEK, RUSSIA:43.0617:74.4783:169 +BISHO, SOUTH AFRICA:-32.8969:27.2789:8 +BISKRA, ALGERIA:34.7931:5.73806:127 +BISSAU, GUINEA BISSAU:11.8947:W15.6536:77 +BLACKPOOL, ENGLAND:53.7717:W3.02861:34 +BLAGOVESCHENSK, RUSSIA:50.4217:127.41:321 +BLANTYRE, MALAWI:-15.6789:34.9739:240 +BLIDA, ALGERIA:36.5036:2.81417:53 +BLOEMFONTEIN, SOUTH AFRICA:-29.0328:26.1575:236 +BLYTHEVILLE, USA:35.9642:W89.9433:53 +BOA VISTA, CAPE VERDE ISLANDS:16.1367:W22.8889:631 +BOBO-DIOULASSO, BURKINA FASO:11.16:W4.33083:136 +BOCA RATON, USA:26.3783:W80.1075:81 +BODOE, NORWAY:67.2689:14.3633:1395 +BOGOTA, COLOMBIA:4.6533326:W74.083652:1775 +BOISE, USA:43.5642:W116.223:206 +BOJNORD, IRAN:37.4931:57.3006:36 +BOKARO, INDIA:23.6433:86.1486:183 +BOLOGNA, ITALY:44.5342:11.2903:140 +BOLZANO, ITALY:46.4606:11.3261:52 +BOMBAY, INDIA:19.0886:72.8678:603 +BOM JESUS DA LAPA, BRAZIL:-13.2619:W43.4081:4116 +BORA BORA, FRENCH POLYNESIA:-16.4442:W151.751:30 +BORDEAUX, FRANCE:44.841225:W0.5800364:49 +BORKUM, GERMANY:53.5953:6.70917:30 +BORLANGE, SWEDEN:60.4219:15.515:34 +BOSTON, USA:42.3642:W71.005:232 +BOTHAVILLE, SOUTH AFRICA:-27.3667:26.6292:43 +BOUAKE, IVORY COAST:7.73861:W5.07361:71 +BOUFARIK, ALGERIA:36.5458:2.87611:50 +BOURGAS, BULGARIA:42.5689:27.5139:253 +BOURGES, FRANCE:47.0581:2.37028:68 +BOURG, FRANCE:46.2008:5.29194:10 +BOURNEMOUTH, ENGLAND:50.7423141383493:W1.8999454858886722:46 +BOU SAADA, ALGERIA:35.3325:4.20639:249 +BOU SFER, ALGERIA:35.7353:W0.805278:46 +BRAGA, ACORES:41.5869:W8.445 +BRAKPAN, SOUTH AFRICA:-26.2386:28.3017:182 +BRANDON, CANADA:49.91:W99.9519:77 +BRASILIA, BRAZIL:-15.8625:W47.9125:5802 +BRASSCHAAT, BELGIUM:51.3333:4.5:38 +BRATISLAVA, SLOVAKIA:48.17:17.2125:367 +BRATSK, RUSSIA:56.3708:101.699:262 +BRAUNSCHWEIG, GERMANY:52.3192:10.5561:192 +BRAZZAVILLE, CONGO:-4.25167:15.2528:263 +BREMEN, GERMANY:53.0758196:8.8071646:326 +BREMERHAVEN, GERMANY:53.5033:8.57333:93 +BREST, FRANCE:48.4478:W4.41833:49 +BRIA, CENTRAL AFRICAN REP.:6.52806:21.9889:2 +BRIDGETOWN, BARBADOS:13.0744:W59.4922:38 +BRIENNE-LE CHATEAU, FRANCE:48.4297:4.48111:21 +BRINDISI, ITALY:40.6575:17.9469:333 +BRISBANE, AUSTRALIA:-27.467778:153.028056:1000 +BRISTOL, ENGLAND:51.4538022:W2.5972985:110 +BRIVE, FRANCE:45.1508:1.46917:48 +BRONNOYSUND, NORWAY:65.4592:12.2136:4 +BROWNSVILLE, USA:25.9067:W97.4258:376 +BROYE-LES-PESMES, FRANCE:47.335:5.51361:25 +BRUECKEBURG, GERMANY:52.2783:9.08194:68 +BRUEGGEN, GERMANY:51.1997:6.13194:61 +BRUSSELS, BELGIUM:50.9022:4.49861:32 +BRYANSK, RUSSIA:53.2142:34.1764:186 +BRYAN, USA:30.7156:W96.3311:140 +BUCARAMANGA, COLOMBIA:7.12639:W73.1847:162 +BUCHAREST, ROMANIA:44.5031:26.1019:228 +BUCKLEY, USA:39.7017:W104.751:10 +BUDAPEST, HUNGARY:47.4367:19.2556:525 +BUECHEL, GERMANY:50.1736:7.06333:12 +BUENAVENTURA, COLOMBIA:3.81944:W76.9897:50 +BUENOS AIRES, ARGENTINA:-34.5006:W58.6042:203 +BUENOS AIRES, COSTA RICA:9.16694:W83.3325:555 +BUFFALO NARROWS, CANADA:55.8419:W108.418:68 +BUFFALO, USA:42.9403:W78.7319:136 +BUJUMBURA, BURUNDI:-3.32389:29.3183:86 +BUKAVU/KAVUMU, ZAIRE:-2.30889:28.8086:45 +BUKHARA, RUSSIA:39.775:64.48:143 +BULAWAYO, ZIMBABWE:-20.0172:28.6178:1707 +BUNIA, ZAIRE:1.56556:30.2208:576 +BURBANK, USA:34.2006:W118.359:44 +BURGOS, SPAIN:42.3575:W3.62056:107 +BURLINGTON, USA:44.4717:W73.1531:40 +BURSA, TURKEY:40.2317:29.0092:1036 +BURWASH, CANADA:61.3711:W139.041:30 +BUSAN, KOREA:35.1708:129.129:770 +BUSHEHR, IRAN:28.9447:50.8344:50 +CABALLOCOCHA, PERU:-3.91667:W70.5081 +CABINDA, ANGOLA:-5.59694:12.1883 +CABO ROJO, DOMINICAN REPUBLIC:17.9289:W71.6447 +CABO VELAS, COSTA RICA:10.3556:W85.8528 +CAEN, FRANCE:49.1733:W0.45:25 +CAGLIARI, ITALY:39.2514:9.05417:85 +CAHORS, FRANCE:44.3511:1.47528 +CAIBARIEN, CUBA:22.5064:W79.4697 +CAICARA DE ORINOCO, VENEZUELA:7.62583:W66.1647 +CAIRNS, AUSTRALIA:-16.8858:145.755:1687 +CAIRO, EGYPT:30.06:31.248E:3085 +CAJAMARCA, PERU:-7.13639:W78.49 +CALABAR, NIGERIA:4.97583:8.34694 +CALABOZO, VENEZUELA:8.92444:W67.4169 +CALAMA, CHILE:-22.4986:W68.9042 +CALBAYOG, PHILIPPINES:12.0725:124.545 +CALCUTTA, INDIA:22.6547:88.4467 +CALEXICO, USA:32.6694:W115.513 +CALGARY, CANADA:51.1139:W114.02 +CALI, COLOMBIA:3.54306:W76.3814 +CALICUT, INDIA:11.1356:75.9547 +CALVI, CORSE ISL.:42.5306:8.79306 +CALVINIA, SOUTH AFRICA:-31.5003:19.7258 +CAMAGUEY, CUBA:21.4203:W77.8475 +CAMAXILO, ANGOLA:-8.37361:18.9236 +CAMBRAI, FRANCE:50.1414:3.26306:18 +CAMBRIDGE BAY, CANADA:69.1081:W105.138 +CAMBRIDGE, ENGLAND:52.205:0.175:42 +CAMDEN, AUSTRALIA:-34.0403:150.687:201 +CAMERI, ITALY:45.5294:8.66917:39 +CAMIRI, BOLIVIA:-20.0069:W63.5275 +CAMPBELL RIVER, CANADA:49.9508:W125.271 +CAMPECHE, MEXICO:19.8167:W90.5003 +CAMPINAS, BRAZIL:-23.0081:W47.1344 +CAMPO GRANDE, BRAZIL:-20.4686:W54.6725 +CAMPOS, BRAZIL:-21.6983:W41.3017 +CAMP SPRINGS, USA:38.8106:W76.8669 +CANAIMA, VENEZUELA:6.23194:W62.8542 +CANAKKALE, TURKEY:40.1375:26.4267:1016 +CANBERRA, AUSTRALIA:-35.3083:149.194:814 +CANCUN, MEXICO:21.0364:W86.8769 +CANEFIELD, DOMINICA:15.3367:W61.3919 +CANNES, FRANCE:43.5419:6.95333 +CANOUAN ISLAND, ST.VINCENT/GRENADINES:12.6989:W61.3422 +CANTON ISLAND, PHOENIX ISL.:-2.76667:W0 +CAPE DORSET, CANADA:64.23:W76.5267 +CAPE LISBURNE, USA:68.875:W166.11 +CAPE NEWENHAM, USA:58.6472:W162.061 +CAPE ROMANZOF, USA:61.7803:W166.039 +CAPE TOWN, SOUTH AFRICA:-33.9647:18.6017 +CAP HAITIEN, HAITI:19.7325:W72.1947 +CAP SKIRING, SENEGAL:12.41:W16.7461 +CARACAS, VENEZUELA:10.2861:W66.8158 +CARANSEBES, ROMANIA:45.42:22.2533 +CARAVELAS, BRAZIL:-17.6522:W39.2531 +CARCASSONNE, FRANCE:43.2158:2.30611 +CARDIFF, WALES:51.4816546:W3.1791934:140 +CAREPA, COLOMBIA:7.81194:W76.7164 +CARIBOU, USA:46.8714:W68.0178 +CARLETONVILLE, SOUTH AFRICA:-26.3694:27.35 +CARLISLE, ENGLAND:54.89144:W2.93536 +CARLSBAD, USA:32.3372:W104.263 +CARNICOBAR, INDIA:9.15306:92.8192 +CAROLINA, BRAZIL:-7.32028:W47.4586 +CARORA, VENEZUELA:10.1756:W70.065 +CARPENTRAS, FRANCE:44.0297:5.07806 +CARRIZAL, VENEZUELA:9.37194:W66.9228 +CARTAGENA, COLOMBIA:10.4422:W75.5128 +CARTAGO, COLOMBIA:4.75806:W75.9556 +CARUPANO, VENEZUELA:10.66:W63.2617 +CASABLANCA, MOROCCO:33.3678:W7.58778 +CASCAIS, ACORES:38.725:W9.355 +CASCAVEL, BRAZIL:-25.0003:W53.5006 +CASLAV, CZECH REPUBLIC:49.9394:15.3817 +CASPER, USA:42.9078:W106.464 +CASSAGNES-BEGHONES, FRANCE:44.1775:2.515 +CASTILHO, BRAZIL:-20.7769:W51.5647 +CASTLEGAR, CANADA:49.2964:W117.632 +CASTRES, FRANCE:43.5561:2.28917 +CASTRIES, ST. LUCIA ISLAND:14.02:W60.9928 +CASTRO, CHILE:-42.4908:W73.7744 +CATAMARCA, ARGENTINA:-28.5956:W65.7517 +CATANIA, ITALY:37.4667:15.0661:182 +CATARMAN, PHILIPPINES:12.5022:124.636 +CAUAYAN, PHILIPPINES:16.9297:121.753 +CAXIAS DO SUL, BRAZIL:-29.1969:W51.1875 +CAYENNE, FRENCH GUYANA:4.81972:W52.3603 +CAYMAN BARAC, CAYMAN ISLANDS:19.6867:W79.8828 +CAYO, CUBA:21.6161:W81.5456 +CAZAUX, FRANCE:44.5333:W1.125 +CAZOMBO, ANGOLA:-11.8936:22.9161 +CEBU, PHILIPPINES:7.16472:124.21 +CEDAR CITY, USA:37.7008:W113.099 +CELAYA, MEXICO:20.5458:W100.886 +CELLE, GERMANY:52.5911:10.0219 +CEPIN, CROATIA:45.5419:18.6361 +CERKLJE, SLOVENIA:45.8997:15.53 +CESKE BUDEJOVICE, CZECH REPUBLIC:48.9461:14.4272 +CHACHAPOYAS, PERU:-6.20167:W77.8558 +CHAH BAHAR, IRAN:25.4433:60.3819 +CHAITEN, CHILE:-42.9331:W72.6992 +CHALON, FRANCE:46.8258:4.8175 +CHALONS, FRANCE:48.7758:4.18444 +CHALSGROVE, ENGLAND:51.6761:W1.08083 +CHAMBERY, FRANCE:45.5608:5.97556 +CHANDIGARH, INDIA:30.6733:76.7883 +CHANDRAGARHI, NEPAL:26.5706:88.0794 +CHANGCHA, CHINA:28.1889:113.219:11819 +CHANGUINOLA, PANAMA:9.45861:W82.5167 +CHANIA, GREECE:35.5317:24.1494:12 +CHAPACURA, BOLIVIA:-16.99:W65.1414 +CHAPECO, BRAZIL:-27.1342:W52.6564 +CHAPLEAU, CANADA:47.82:W83.3464 +CHARANA, BOLIVIA:-17.5939:W69.4342 +CHARLEROI, BELGIUM:50.4592:4.45361 +CHARLESTON, USA:32.8986:W80.0403 +CHARLESTOWN, ST. KITTS & NEVIS:17.2056:W62.5897 +CHARLEVILLE, FRANCE:49.7839:4.64694:31 +CHARLIEVILLE, AUSTRALIA:-26.4133:146.262:687 +CHARLO, CANADA:47.9906:W66.3314 +CHARLOTTETOWN, CANADA:46.29:W63.1211 +CHARLOTTE, USA:35.2139:W80.9431:771 +CHATEAUDUN, FRANCE:48.0581:1.37639 +CHATEAUROUX, FRANCE:46.8619:1.73056 +CHATHAM, CANADA:47.0078:W65.4492 +CHATHAM ISLAND, NEW ZEALAND:-43.81:W176.457 +CHATTANOOGA, USA:35.0353:W85.2036 +CHEJU, KOREA:33.5111:126.493 +CHELYABINSK, RUSSIA:55.3033:61.5067:530 +CHENGDU, CHINA:30.5783:103.947:14378 +CHERBOURG, FRANCE:49.65:W1.47028:14 +CHERRY POINT, USA:34.9025:W76.8808 +CHETUMAL, MEXICO:18.5044:W88.3267 +CHEYENNE, USA:41.1556:W104.812 +CHIANG RAI, THAILAND:19.4972:100.286 +CHIAYI, TAIWAN:23.4617:120.393 +CHICAGO, USA:41.85574666473487:W87.76552685185547:589 +CHICLAYO, PERU:-6.78722:W79.8281 +CHICOPEE FALLS, USA:42.1981:W72.5342 +CHICO, USA:39.7953:W121.858 +CHIEVRES, BELGIUM:50.5758:3.83083 +CHIHUAHUA, MEXICO:28.7028:W105.964 +CHILDRESS, USA:34.4336:W100.288 +CHILE CHICO, CHILE:-46.5817:W71.6975 +CHILECITO, ARGENTINA:-29.2239:W67.4389 +CHILLAN, CHILE:-36.5828:W72.0317 +CHILLIWACK, CANADA:49.1528:W121.939 +CHILPANCINGO, MEXICO:17.5736:W99.5142 +CHIMBOTE, PERU:-9.15083:W78.5236 +CHIMKENT, RUSSIA:42.3644:69.4792:1170 +CHIMOIO, MOZAMBIQUE:-19.1511:33.4289 +CHINA, USA:35.6878:W117.691 +CHINGCHUAKANG, TAIWAN:24.2644:120.621 +CHINHAE, KOREA:35.1411:128.696 +CHINMEN, TAIWAN:24.4319:118.359 +CHIREDZI, ZIMBABWE:-21.0081:31.5783 +CHITA, RUSSIA:52.0261:113.305:534 +CHITATO, ANGOLA:-7.3575:20.8036 +CHITOSE, JAPAN:42.7944:141.666 +CHITRAL, PAKISTAN:35.8864:71.8006 +CHITTAGONG, BANGLADESH:22.2494:91.8131 +CHIVENOR, ENGLAND:51.0869:W4.15028 +CHOLET, FRANCE:47.0819:W0.876944:87 +CHONGJU, KOREA:36.7164:127.499 +CHOSMADAL, ARGENTINA:-37.4444:W70.2222 +CHOTEBOR, CZECH REPUBLIC:49.6844:15.6761 +CHRISTCHURCH, NEW ZEALAND:-43.4892:172.532 +CHUB CAY, BAHAMAS:25.4169:W77.8808 +CHUNCHON, KOREA:37.8836:127.718 +CHUNG, TAIWAN:24.1861:120.654 +CHURCHILL, CANADA:58.7392:W94.065 +CHUUK, MICRONESIA:7.46167:151.843 +CIEGO DE AVILA, CUBA:22.0269:W78.7894 +CIENFUEGOS, CUBA:22.15:W80.4142 +CILACAP, INDONESIA:-7.645:109.034 +CINCINNATI, USA:39.0461:W84.6619 +CIREBON, INDONESIA:-6.75583:108.539 +CIRO ALEGRIA, PERU:-4.6075:W77.9408 +CIUDAD ACUNA, BRAZIL:29.3319:W100.981 +CIUDAD BOLIVAR, VENEZUELA:8.12194:W63.5369 +CIUDAD DEL CARMEN, MEXICO:18.6536:W91.7989 +CIUDAD JUAREZ, MEXICO:31.6361:W106.429 +CIUDAD MANTE, MEXICO:22.7403:W99.0181 +CIUDAD OBREGON, MEXICO:27.3925:W109.833 +CIUDAD VICTORIA, MEXICO:23.7039:W98.9564 +CLARENCE BAIN, BAHAMAS:24.2875:W77.6844 +CLEAR MEWS, USA:64.3011:W149.12 +CLEMENTIA, ECUADOR:-1.70611:W79.3789 +CLERMONT FERRAND, FRANCE:45.7864:3.16917 +CLEVELAND, USA:41.4117:W81.8497:201 +CLOVIS, USA:34.3825:W103.322 +CLUJ-NAPOCA, ROMANIA:46.785:23.6861 +CLYDE RIVER, CANADA:70.4861:W68.5167 +COBAN, GUATEMALA:15.4689:W90.4067 +COBIJA, BOLIVIA:-11.0403:W68.7828 +COBURG, GERMANY:50.2625:10.9958:48 +COCA, ECUADOR:-0.462778:W76.9864 +COCHABAMBA, BOLIVIA:-17.4208:W66.1769 +COCHSTEDT, GERMANY:51.8558:11.4181:29 +COCKBURN TOWN, BAHAMAS:24.0631:W74.5239 +COCO BEACH, USA:28.2347:W80.61 +COCO ISLAND, MYANMAR:14.1414:93.3683 +COETIVY, SEYCHELLES:-7.16417:56.2639 +COFF'S HARBOUR, AUSTRALIA:-30.3206:153.116:505 +COGNAC, FRANCE:45.693165:W0.325018 +COIMBA, ACORES:40.1572:W8.47 +COIMBATORE, INDIA:11.0314:77.0439 +COLD BAY, USA:55.2056:W162.724 +COLD LAKE, CANADA:54.405:W110.279 +COLEMAN, GERMANY:49.5633:8.46333 +COLIMA, MEXICO:19.2769:W103.577 +COLLEGE STATION, USA:30.5883:W96.3636 +COLLIQUE, PERU:-11.9286:W77.0611 +COLMAR, FRANCE:47.9219:7.39944:66 +COLOGNE, GERMANY:50.8658:7.1425:405 +COLOMBIA, USA:33.9386:W81.1194 +COLOMBO, SRI LANKA:6.82194:79.8861 +COLOMBUS, USA:33.6439:W88.4436 +COLONEL HILL, BAHAMAS:22.7456:W74.1822 +COLONEL SUAREZ, ARGENTINA:-37.4461:W61.8892 +COLONIA, URUGUAY:-34.4564:W57.7706 +COLORADO SPRINGS, USA:38.8056:W104.7 +COLTISHALL, ENGLAND:52.7547:1.35722:7 +COLUMBIA, USA:38.8181:W92.2194 +COLUMBUS, USA:39.9978:W82.8917:563 +COMODORO RIVADAVIA, ARGENTINA:-45.785:W67.4656 +COMOX, CANADA:49.7108:W124.887 +CONCEICAO DO ARAGUAIA, BRAZIL:-8.34833:W49.3014 +CONCEPCION, CHILE:-36.7725:W73.0631 +CONCEPTION, BOLIVIA:-16.1433:W62.0258 +CONCEPTION, PARAGUAY:-23.4417:W57.4269 +CONCORDIA, ARGENTINA:-31.2969:W57.9964 +CONDOTO, COLOMBIA:5.07167:W76.6764 +CONGO TOWN, BAHAMAS:24.1586:W77.5897 +CONINGSBY, ENGLAND:53.0928:W0.165833:14 +CONROE, USA:30.3517:W95.4144 +CONSTANTA, ROMANIA:44.3622:28.4883 +CONSTANTINE, ALGERIA:36.2767:6.62389 +CONSTANZA, DOMINICAN REPUBLIC:18.9081:W70.72 +COOCH-BEHAR, INDIA:26.3303:89.4669 +COOLANGATTA, AUSTRALIA:-28.1644:153.505:2 +COPENHAGEN, DENMARK:55.6178:12.6558:88 +COPIAPO, CHILE:-27.2961:W70.4136 +COPPERMINE, CANADA:67.8167:W115.144 +CORAL HARBOUR, CANADA:50.3319:W115.874 +CORDOBA, ARGENTINA:-31.3236:W64.2078 +CORDOBA, SPAIN:37.8419:W4.84861 +CORDOVA, USA:60.4917:W145.477 +CORK, IRELAND:51.8428:W8.49194:187 +CORLU, TURKEY:41.1381:27.9189:899 +CORONATION, CANADA:52.075:W111.445 +CORO, VENEZUELA:11.4147:W69.6808 +COROZAL, COLOMBIA:9.3325:W75.2856 +CORPUS CHRISTI, USA:27.7703:W97.5011:416 +CORRIENTES, ARGENTINA:-27.4453:W58.7617 +CORTE, FRANCE:42.2936:9.19306 +CORUMBA, BRAZIL:-19.0117:W57.6728 +COTO 47, COSTA RICA:8.60111:W82.97 +COTONOU, BENIN:6.35722:2.38417 +COTTBUS, GERMANY:51.8894:14.5317:164 +COTULLA, USA:28.4581:W99.22 +COULOMMIERS, FRANCE:48.8375:3.01611:10 +COVENTRY, ENGLAND:52.4081812:W1.510477:81 +COVILHA, ACORES:40.2647:W7.47972 +COX'S BAZAR, BANGLADESH:21.4519:91.9642 +COYHAIQUE, CHILE:-45.5942:W72.1061 +COZUMEL, MEXICO:20.5222:W86.9256 +CRAIOVA, ROMANIA:44.3181:23.8886 +CRANBROOK, CANADA:49.6103:W115.782 +CREIL, FRANCE:49.2533:2.51889:11 +CRESTVIEW, USA:30.7786:W86.5219 +CRICIUMA, BRAZIL:-28.7256:W49.4247 +CROTONE, ITALY:38.9972:17.08:179 +CRUIZIRO DO SUL, BRAZIL:-7.6:W72.7694 +CUAMBA, MOZAMBIQUE:-14.82:36.5319 +CUBI NAS, PHILIPPINES:6.10556:125.236 +CUCUTA, COLOMBIA:7.9275:W72.5114 +CUDDAPAH, INDIA:14.5097:78.7728 +CUENCA, ECUADOR:-2.88944:W78.9842 +CUERNAVACA, MEXICO:18.835:W99.2619 +CUERS, FRANCE:43.2478:6.12667:50 +CUFAR, GUINEA BISSAU:11.2881:W15.1806 +CUIABA, BRAZIL:-15.6528:W56.1167 +CULDROSE, ENGLAND:50.0858:W5.25556 +CULIACAN, MEXICO:24.7644:W107.474 +CUMANA, VENEZUELA:10.4503:W64.1303 +CUNAGUA, CUBA:22.4611:W78.3286 +CURICO, CHILE:-34.9669:W71.2169 +CURITIBA, BRAZIL:-25.405:W49.2319 +CURUZU CUATIA, ARGENTINA:-29.7706:W57.9789 +CUTBANK, USA:48.6083:W112.376 +CUTRALCO, ARGENTINA:-38.9394:W69.2644 +CUZCO, PERU:-13.5356:W71.9386 +DAET, PHILIPPINES:14.1292:122.98 +DAGLI, NORWAY:60.4167:8.51389 +DAHRA, LIBYA:29.4697:17.9311 +DAKAR, SENEGAL:14.7394:W17.49 +DALAMAN, TURKEY:36.7131:28.7925:616 +DALBANDIN, PAKISTAN:28.8747:64.4044 +DALHART, USA:36.0225:W102.547 +DALIAN, CHINA:38.9656:121.538:13237 +DALLAS-FORT WORTH, USA:32.8964:W97.0375 +DALLAS, USA:32.8469:W96.8517:882 +DALOA, IVORY COAST:6.7925:W6.47306 +DAMAN, INDIA:20.435:72.8436 +DAMASCUS, SYRIA:33.4114:36.5156 +DAMAZIN, SUDAN:11.7858:34.3364 +DAMMAM, SAUDI ARABIA:26.4711:49.7978 +DANANG, VIET NAM:16.0439:108.199 +DARAB, IRAN:28.7214:54.4411 +DARAN, IRAN:32.9294:51.5608 +DAR ES SALAAM, TANZANIA:-6.87806:39.2025 +DASHT-E-NAZ, IRAN:36.6436:53.1883 +DAS ISLAND, UNITED ARAB EMIRATES:25.1461:52.8736 +DAUPHIN, CANADA:51.1008:W100.052 +DAVID, PANAMA:8.39083:W82.4347 +DAWEI, MYANMAR:14.0981:98.2017 +DAWSON, CANADA:64.0431:W139.128 +DAWSON CREEK, CANADA:55.7417:W120.182 +DAYTON, USA:39.8261:W84.0483 +DE AAR, SOUTH AFRICA:-30.6367:23.92 +DEASE LAKE, CANADA:58.4222:W130.032:8 +DEAUVILLE, FRANCE:49.3653:0.154167:3 +DEBRECEN, HUNGARY:47.4889:21.6153:461 +DEBRE ZEIT, ETHIOPIA:8.71556:39.0081 +DECIMOMANNU, ITALY:39.3542:8.97222:28 +DEER LAKE, CANADA:49.2108:W57.3914:73 +DEESA, INDIA:24.2678:72.2053:20 +DEHRA DUN, INDIA:30.1872:78.18:196 +DEIRE ZOR, SYRIA:35.2853:40.1758:10 +DEL BAJIO, MEXICO:20.9933:W101.481 +DELHI, INDIA:28.5664:77.1031:1484 +DEL RIO, USA:29.3594:W100.778:53 +DELTA JUNCTION, USA:63.9944:W145.721 +DENIZLI, TURKEY:37.785:29.7011:798 +DENPASAR, INDONESIA:-8.74833:115.167 +DENVER, USA:39.8583:W104.667:396 +DEPARIZO, INDIA:27.9889:94.2233:275 +DERA GHAZI KHAN, PAKISTAN:29.9611:70.4856:70 +DERA ISMAIL KHAN, PAKISTAN:31.9092:70.8964:20 +DERBY, ENGLAND:52.91652:W1.47241:64 +DERIDDER, USA:30.8317:W93.3397:24 +DES MOINES, USA:41.5339:W93.6625:234 +DESSAU, GERMANY:51.8319:12.1858:182 +DETROIT, USA:42.2122:W83.3533:370 +DEZFUL, IRAN:32.4344:48.3975:20 +DHAHRAN, SAUDI ARABIA:26.2653:50.1519:100 +DHAKA, BANGLADESH:23.7783:90.3825:306 +DHANBAD, INDIA:23.8339:86.425:275 +DIEPHOLZ, GERMANY:52.5853:8.34056:104 +DIFFA, NIGER:13.3728:12.6267:2 +DIJON, FRANCE:47.2689:5.09:40 +DILLINGHAM, USA:21.5792:W158.21:93 +DIRE DAWA, ETHIOPIA:9.625:41.8542:1213 +DIYABAKIR, TURKEY:37.8936:40.2006:500 +DJANET, ALGERIA:24.2925:9.45222:10 +DJERBA, TUNISIA:33.875:10.7753:514 +DNEPROPETROVSK, RUSSIA:48.3567:35.1017:409 +DODOMA, TANZANIA:-6.17028:35.7525:2576 +DOHA, QATAR:25.2611:51.565:132 +DOLE, FRANCE:47.0389:5.42722:38 +DOLORES, ARGENTINA:-36.3203:W57.7217:1980 +DOMINICA, DOMINICA:15.5469:W61.3:751 +DONAUESCHINGEN, GERMANY:47.9731:8.52222:104 +DONEGAL, IRELAND:55.0442:W8.34083:2 +DONETSK, RUSSIA:48.0733:37.74:358 +DONGOLA, SUDAN:19.1536:30.43:2 +DORTMUND, GERMANY:51.5142273:7.4652789:280 +DOTHAN, USA:31.3211:W85.4494:233 +DOUALA, CAMEROON:4.00583:9.71944:210 +DOUGLAS, USA:31.4689:W109.604:25 +DOVER, USA:39.13:W75.4664:62 +DRESDEN, GERMANY:51.0493286:13.7381437:328 +DRYDEN, CANADA:49.8317:W92.7442:65 +DUBAI, UNITED ARAB EMIRATES:25.2547:55.3642:4114 +DUBBO, AUSTRALIA:-32.2167:148.575:182 +DUBENDORF, SWITZERLAND:47.3986:8.64806:13 +DUBLIN, IRELAND:53.4211:W6.27:117 +DUBROVNIK, CROATIA:42.5611:18.2681:21 +DUESSELDORF, GERMANY:51.2894:6.76667:217 +DULUTH, USA:46.8419:W92.1936:207 +DUMAGUETE, PHILIPPINES:9.33417:123.302:33 +DUMAI, INDONESIA:1.60917:101.433:2039 +DUNDEE, SOUTH AFRICA:-28.1825:30.2244:48 +DUNDEE, UK:56.47786741082439:W2.9823779577636778:60 +DUNDIGUL, INDIA:17.6292:78.4033:46 +DUNEDIN, NEW ZEALAND:-45.9281:170.198:900 +DURANGO, MEXICO:24.1242:W104.528:100 +DURANGO, USA:37.1514:W107.754:44 +DURAZNO, URUGUAY:-33.3586:W56.4992:1 +DURBAN, SOUTH AFRICA:-29.7706:31.0583 +DUSHANBE, RUSSIA:38.5433:68.825:185 +DWAALBOOM, SOUTH AFRICA:-24.805:26.8317 +DZAOUDZI, MAYOTTE ISLAND:-12.8047:45.2811 +DZHEZKAZGAN, RUSSIA:47.7083:67.7333 +EAGLE PASS, USA:28.7:W100.479 +EARLTON, CANADA:47.695:W79.8489 +EASTER ISLAND, CHILE:-27.1647:W109.422 +EAST LONDON, SOUTH AFRICA:-33.0356:27.8258 +ECH-CHELIFF, ALGERIA:36.2125:1.33167 +EDINBURGH, AUSTRALIA:-34.7025:138.621:18 +EDINBURGH, UK:55.945672242712185:W3.1995328895019592:125 +EDMONTON, CANADA:53.3097:W113.58 +EDSON, CANADA:53.5789:W116.465 +EDWARDS AFB, USA:34.9053:W117.884 +EGELSBACH, GERMANY:49.9608:8.64361:14 +EGGEBECK, GERMANY:54.6247:9.34139 +EGGENFELDEN, GERMANY:48.3961:12.7236:44 +EGILSSTADIR, ICELAND:65.2833:W14.4014 +EINDHOVEN, NETHERLANDS:51.45:5.37444:88 +EISENACH, GERMANY:50.9928:10.4725:104 +EISENHUETTENSTADT, GERMANY:52.1972:14.5856:63 +EL ARISH, EGYPT:31.0733:33.8358 +ELAT, ISRAEL:29.5611:34.96 +ELAZIG, TURKEY:38.6067:39.2914:2211 +EL-BAHA, SAUDI ARABIA:20.2961:41.6342 +EL BANCO, COLOMBIA:9.04528:W73.9747 +EL BEIDA, LIBYA:32.7886:21.9642 +EL BOLSON, ARGENTINA:-41.9431:W71.5322 +EL BORMA, TUNISIA:31.7042:9.25444 +EL CARMEN, COSTA RICA:10.2019:W83.4719 +EL CENTRO, USA:32.8292:W115.672 +EL DORADO, USA:33.2208:W92.8131 +EL DORADO, VENEZUELA:6.715:W61.6389 +ELDORET, KENYA:0.404167:35.2381 +ELEFSIS, GREECE:38.0636:23.5558:18 +EL FASHER, SUDAN:13.615:25.3247 +EL GOLEA, ALGERIA:30.5711:2.85944 +EL-GORA, EGYPT:31.0689:34.1292 +ELIZABETH CITY, USA:36.2606:W76.1744 +ELKINS, USA:38.8894:W79.8569 +ELLISRAS, SOUTH AFRICA:-23.7261:27.6875 +EL MAITEN, ARGENTINA:-42.0306:W71.1697 +EL OBEID, SUDAN:13.1531:30.2325 +ELORZA, VENEZUELA:7.05972:W69.4967 +EL PALOMAR, ARGENTINA:-34.6097:W58.6125 +EL PASO, USA:31.8494:W106.38:661 +EL-TOR, EGYPT:28.2089:33.6453 +EL-YOPAL, COLOMBIA:5.31889:W72.3839 +EMAM SHAHR, IRAN:36.4236:55.1058 +EMDEN, GERMANY:53.3911:7.22722:112 +EMMABODA, SWEDEN:56.6106:15.6047 +EMMEN, SWITZERLAND:47.0922:8.305 +ENDE, INDONESIA:-8.84889:121.662 +ENID, USA:36.3397:W97.9161 +ENIWETOK ISLAND, MARSHALL ISLANDS:11.3408:162.328 +ENNISKILLEN, ENGLAND:54.3989:W7.65167 +ENONTEKIO, FINLAND:68.3625:23.4242 +ENSCHEDE, NETHERLANDS:52.27:6.87417:142 +ENSENADA, MEXICO:31.7953:W116.602 +ENTEBBE, UGANDA:0.0422222:32.4433 +ENUGU, NIGERIA:6.47417:7.56194 +EPINAL, FRANCE:48.3247:6.06972:59 +ERDING, GERMANY:48.3222:11.9486 +EREGLI, TURKEY:41.2544:31.415:976 +ERFURT, GERMANY:50.98:10.9581:269 +ERMELO, SOUTH AFRICA:-26.4953:29.98 +ER-RACHIDIA, MOROCCO:31.9489:W4.40056 +ERZINCAN, TURKEY:39.71:39.5261:89 +ERZURUM, TURKEY:39.9564:41.17 +ESFAHAN, IRAN:32.5669:51.6914 +ESKILSTUNA, SWEDEN:59.3508:16.7083 +ESKIMO POINT, CANADA:61.0942:W94.0708 +ESKISEHIR, TURKEY:39.7839:30.5819:2678 +ESPINHO, PORTUGAL:40.9742:W8.64528 +ESQUEL, ARGENTINA:-42.9078:W71.1394 +ESSADOUIRA, MOROCCO:31.4039:W9.68472 +ESSEN, GERMANY:51.4582235:7.0158171:210 +ESTEVAN, CANADA:49.2103:W102.966 +ESTIMA, MOZAMBIQUE:-15.7342:32.7567 +ETAIN, FRANCE:49.2267:5.67194:20 +EURA, FINLAND:61.1161:22.2014 +EUREKA, CANADA:79.9947:W85.8133 +EVENES, NORWAY:68.4911:16.6781 +EVERETT, USA:47.9061:W122.281 +EVORA, ACORES:38.5333:W7.88944 +EVREUX, FRANCE:49.0286:1.21972 +EXETER, ENGLAND:50.7344:W3.41389:28 +EYN-SHEMER, ISRAEL:32.4408:35.0061 +EYN-YAHAV, ISRAEL:30.6231:35.2019 +FAGERNES, NORWAY:61.0111:9.29306 +FAIRBANKS, USA:64.6656:W147.101 +FAIRFIELD, USA:38.2625:W121.927 +FAISALABAD, PAKISTAN:31.365:72.9953 +FAJARDO, PUERTO RICO:18.3089:W65.6617 +FAKARAVA, FRENCH POLYNESIA:-16.0544:W145.657 +FALEOLO, SAMOA:-13.8297:W172.008 +FALKOPING, SWEDEN:58.1697:13.5878 +FALLFORS, SWEDEN:65.1064:20.7606 +FALLON, USA:39.4164:W118.701 +FALMOUTH, USA:41.6583:W70.5214 +FANGATAU, FRENCH POLYNESIA:-15.8197:W140.887 +FARAFANGANA, MADAGASCAR:-22.8053:47.8206 +FARANAH, GUINEA:10.0356:W10.7697 +FARILA, SWEDEN:61.8981:15.7053 +FARMINGTON, USA:36.7411:W108.23 +FARNBOROUGH, ENGLAND:51.2758:W0.776111 +FARO, ACORES:37.0142:W7.96583 +FARO, CANADA:62.2075:W133.376 +FARQUHAR, SEYCHELLES:-10.1094:51.1761 +FASA, IRAN:28.8919:53.7228 +FASSBERG, GERMANY:52.9192:10.1836 +FAYA-LARGEAU, CHAD:17.9169:19.1108 +FAYETTEVILLE, USA:35.1708:W79.0144 +FENGNIN, TAIWAN:22.7567:121.093 +FERNANDO DO NORONHA, BRAZIL:-3.85472:W32.4233 +FEZ, MOROCCO:33.9272:W4.97778 +FIANARANTSOA, MADAGASCAR:-21.4414:47.1117 +FICKSBURG, SOUTH AFRICA:-28.8231:27.9089 +FIGARI, CORSE ISL.:41.5006:9.09778 +FILADELFIA, PARAGUAY:-22.3597:W60.0536 +FINCA 10, COSTA RICA:8.91611:W83.5072 +FINCA 63, COSTA RICA:8.6525:W83.0653 +FIRA, GUINEA:10.3506:W13.5692 +FIRENZE, ITALY:43.8097:11.205:102 +FLENSBURG, GERMANY:54.7717:9.37806:56 +FLIN FLON, CANADA:54.6781:W101.682 +FLORENCE, USA:34.1853:W79.7239 +FLORENCIA, COLOMBIA:1.59028:W75.5639 +FLORENNES, BELGIUM:50.2436:4.64861 +FLORES (FLORES ISL.), ACORES:39.455:W31.1311 +FLORIANOPOLIS, BRAZIL:-27.6725:W48.5478 +FLORIDABLANCA, PHILIPPINES:14.9864:120.493 +FLORIDA, CUBA:21.4997:W78.2028 +FLORO, NORWAY:61.5839:5.025 +FOGGIA, ITALY:41.4328:15.535:507 +FORDE, NORWAY:61.3925:5.76417 +FORESTVILLE, CANADA:48.7461:W69.0972 +FORLI, ITALY:44.1947:12.07:228 +FORMOSA, ARGENTINA:-26.2125:W58.2281 +FORTALEZA, BRAZIL:-3.77611:W38.5325 +FORT BENNING, USA:32.3372:W84.9911 +FORT CARSON, USA:38.6783:W104.756 +FORT CHIPEWYAN, CANADA:58.7672:W111.117 +FORT-DE-FRANCE, ANTILLES:14.5908:W61.0031 +FORT DODGE, USA:42.5514:W94.1925 +FORT DRUM, USA:44.0556:W75.7194 +FORT EUSTIS, USA:37.1325:W76.6086 +FORT HOOD, USA:31.1386:W97.7144 +FORT HUACHUCA, USA:31.5883:W110.344 +FORT IRWIN, USA:35.2803:W116.63 +FORT KNOX, USA:37.9069:W85.9719 +FORT LAUDERDALE, USA:26.0725:W80.1525 +FORT LEAVENWORTH, USA:39.3683:W94.9144 +FORT LEONARDWOOD, USA:37.7414:W92.1406 +FORT LEWIS, USA:47.0792:W122.581 +FORT MCMURRAY, CANADA:56.6533:W111.222 +FORT MCPHERSON, CANADA:67.4078:W134.86 +FORT MEADE, USA:39.0853:W76.7592 +FORT MYERS, USA:26.5361:W81.755 +FORT NELSON, CANADA:58.8364:W122.597 +FORT POLK, USA:31.0447:W93.1914 +FORT RESOLUTION, CANADA:61.1808:W113.69 +FORT RICHARDSON, USA:61.2661:W149.653 +FORT RILEY, USA:39.055:W96.7644 +FORT SAINT JOHN, CANADA:56.2381:W120.74 +FORT SILL, USA:34.6497:W98.4019 +FORT SIMPSON, CANADA:61.7603:W121.237 +FORT SMITH, CANADA:60.0222:W111.96 +FORT SMITH, USA:35.3364:W94.3672 +FORT WAINWRIGHT, USA:64.8375:W147.614 +FORT WORTH, USA:32.8197:W97.3622:880 +FORT YUKON, USA:66.5714:W145.25 +FOUMBAN, CAMEROON:5.63667:10.7506 +FOZ DO IGUACU, BRAZIL:-25.5961:W54.4869 +FRANCA, BRAZIL:-20.5919:W47.3828 +FRANCEVILLE, GABON:-1.65611:13.4378 +FRANCISCO MENDEZ, CAPE VERDE ISLANDS:14.9244:W23.4933 +FRANCISTOWN, BOTSWANA:-21.1594:27.4744 +FRANKFURT, GERMANY:50.1106444:8.6820917:248 +FREDERICTON, CANADA:45.8694:W66.5317 +FREEPORT, BAHAMAS:26.5586:W78.6953 +FREETOWN, SIERRA LEONE:8.61639:W13.1953 +FREIBURG, GERMANY:48.0203:7.83361:153 +FRESNO, USA:36.7761:W119.718 +FRIEDRICHSHAFEN, GERMANY:47.6711:9.51139:69 +FRITZLAR, GERMANY:51.1144:9.28583 +FRONTEIRA, BRAZIL:-20.2783:W49.1872 +FUERSTENFELDBRUCK, GERMANY:48.2056:11.2669 +FUERTE GRAL ROCA, ARGENTINA:-39.0006:W67.6203 +FUERTEVENTURA, CANARY ISLANDS:28.4525:W13.8636 +FUJEIRAH, UNITED ARAB EMIRATES:25.1122:56.3239 +FUKUE, JAPAN:32.6661:128.833 +FUKUI, JAPAN:36.1428:136.224 +FUKUOKA, JAPAN:33.5864:130.45 +FUNAFUTI, TUVALU ISLAND:-8.51667:179.217 +FUTEMA, JAPAN:26.2742:127.756 +FUZHOU, CHINA:25.9333:119.662:12232 +GABERONE, BOTSWANA:-24.555:25.9181 +GABES, TUNISIA:33.8767:10.1033 +GACHSARAN, IRAN:30.3375:50.8278 +GAFSA, TUNISIA:34.4219:8.8225 +GAGE, USA:36.2953:W99.7764 +GAINESVILLE, USA:29.69:W82.2717 +GALAPAGOS, GALAPAGOS I. (ECUADOR:-0.453611:W90.2658 +GALENA, USA:64.7361:W156.937 +GALLIVARE, SWEDEN:67.1322:20.8144 +GALOYA, SRI LANKA:7.3375:81.6256 +GALVESTON, USA:29.2653:W94.8603 +GALWAY, IRELAND:53.3014:W8.93917:54 +GAMBELLA, ETHIOPIA:8.12833:34.5633 +GANDER, CANADA:48.9369:W54.5681 +GAO, MALI:16.2483:W0.00527778 +GARDEN CITY, USA:37.9275:W100.724 +GARISSA, KENYA:-0.463333:39.6481 +GAROUA, CAMEROON:9.33583:13.37 +GASPE, CANADA:48.7753:W64.4786 +GASSIM, SAUDI ARABIA:26.3028:43.7744 +GATINEAU, CANADA:45.5214:W75.5642 +GAUHATI, INDIA:26.1061:91.5847 +GAVLE, SWEDEN:60.5933:16.9514 +GAYA, INDIA:24.7481:84.9425 +GAZIANTEP, TURKEY:36.9481:37.4792:6819 +GBADOLITE, ZAIRE:4.25306:20.9753 +GDANSK, POLAND:54.3775:18.4661 +GEILENKIRCHEN, GERMANY:50.9608:6.0425 +GEMENA, ZAIRE:3.23528:19.7711 +GENERAL PICO, ARGENTINA:-35.6961:W63.7581 +GENEVA, SWITZERLAND:46.2381:6.10889 +GENOA, ITALY:44.4119:8.84167:243 +GEORGE, SOUTH AFRICA:-34.0053:22.3789 +GEORGETOWN, CAYMAN ISLANDS:19.2928:W81.3575 +GERALDTON, CANADA:49.7783:W86.9394 +GERONA, SPAIN:41.9008:2.76028 +GHADAMES, LIBYA:30.1517:9.71528 +GHARDAIA, ALGERIA:32.3839:3.79389 +GHAT, LIBYA:25.1456:10.1425 +GHAZVIN, IRAN:36.2414:50.0475 +GHEDI, ITALY:45.4319:10.2675:60 +GHESHM I., IRAN:26.755:55.9019 +GHRISS, ALGERIA:35.2075:0.146944 +GIALLO, LIBYA:28.6383:21.4378 +GIBRALTAR, GIBRALTAR:36.1508:W5.34944 +GIEBELSTADT, GERMANY:49.6481:9.96639 +GIFU, JAPAN:35.3942:136.869 +GILGIT, PAKISTAN:35.9186:74.3336 +GILLAM, CANADA:56.35:W94.7 +GILZE-RIJEN, NETHERLANDS:51.5678:4.93306:65 +GIMO, SWEDEN:60.1328:18.105 +GIOIA DEL COLLE, ITALY:40.7678:16.9333:206 +GIRARDOT, COLOMBIA:4.27611:W74.7967 +GISBORNE, NEW ZEALAND:-38.6633:177.978 +GISENYI, RWANDA:-1.67694:29.2586 +GIYANI, SOUTH AFRICA:-23.2836:30.6497 +GIZAN, SAUDI ARABIA:16.9011:42.5858 +GJOA HAVEN, CANADA:68.6356:W95.8497 +GLASGOW, UK:55.8609825:W4.2488787:368 +GLENTANNER, NEW ZEALAND:-43.9067:170.128 +GOA, INDIA:15.3806:73.8331 +GOBERNADOR GORDILLO, ARGENTINA:-30.3453:W66.2936 +GODOLLO, HUNGARY:47.5708:19.3386:61 +GODTHAAB, GREENLAND:64.1908:W51.6781 +GOIANIA, BRAZIL:-16.6311:W49.2222 +GOLBANDI, IRAN:27.4839:52.6183 +GOLDSBORO, USA:35.3392:W77.9606 +GOLFITO, COSTA RICA:8.65389:W83.1819 +GOMA, ZAIRE:-1.67056:29.2383 +GOMEL, RUSSIA:52.5269:31.0167:139 +GONDAR, ETHIOPIA:12.5183:37.4322 +GOOSE BAY, CANADA:53.3192:W60.4258 +GORAKHPUR, INDIA:26.7394:83.4494 +GORE BAY, CANADA:45.8853:W82.5678 +GORGAN, IRAN:36.9092:54.4017 +GORNA ORECHOVICA, BULGARIA:43.1514:25.7128 +GOROKA, PAPUA NEW GUINEA:-6.08167:145.392 +GORONTALO, INDONESIA:0.636667:122.852 +GOTHENBORG, SWEDEN:57.6628:12.2797 +GOVERNOR'S HARBOR, BAHAMAS:25.2844:W76.3308 +GOYA, ARGENTINA:-29.1058:W59.2186 +GRACIOSA ISLAND, ACORES:39.0919:W28.0297 +GRAFENWOEHR, GERMANY:49.6994:11.9411 +GRAHAMSTOWN, SOUTH AFRICA:-33.2847:26.4981 +GRANADA, SPAIN:37.1331:W3.63556 +GRAN CANARIA, CANARY ISLANDS:27.9317:W15.3864 +GRAND-BOURG, ANTILLES:15.8686:W61.27 +GRANDE PRAIRIE, CANADA:55.1797:W118.885 +GRAND FORKS, USA:47.9492:W97.1761 +GRAND RAPIDS, USA:42.8808:W85.5228 +GRANDVIEW, USA:38.8433:W94.5606 +GRANT COUNTY AIRPORT, USA:47.2075:W119.32 +GRANTS, USA:35.1653:W107.901 +GRANVILLE, FRANCE:48.8831:W1.56417:9 +GRAZ, AUSTRIA:46.9908:15.4394 +GRAZZANISE, ITALY:41.0608:14.0819:47 +GREAT EXUMA, BAHAMAS:23.5625:W75.8778 +GREAT FALLS, USA:47.4819:W111.371 +GREEN BAY, USA:44.485:W88.1294 +GREEN ISLAND, TAIWAN:22.6744:121.458 +GREENVILE, USA:33.0678:W96.0653 +GREENWOOD, CANADA:44.9844:W64.9169 +GREENWOOD, USA:33.4942:W90.0844 +GRENCHEN, SWITZERLAND:47.1814:7.41694 +GRENOBLE, FRANCE:45.3628:5.32917 +GREYTOWN, SOUTH AFRICA:-29.1219:30.5867 +GROBNIK, CROATIA:45.3794:14.5036 +GRONINGEN, NETHERLANDS:53.1194:6.57944:83 +GROSSETO, ITALY:42.7608:11.0722:474 +GROTTAGLIE, ITALY:40.5161:17.4022:102 +GUADALAJARA, MEXICO:20.5217:W103.311 +GUAJARA-MIRIM, BRAZIL:-10.7861:W65.2847 +GUALAQUIZA, ECUADOR:-3.42333:W78.5731 +GUALEGUAYCHU, ARGENTINA:-33.0058:W58.6131 +GUANAJA, HONDURAS:16.4453:W85.9064 +GUANARE, VENEZUELA:9.02694:W69.755 +GUANGZHOU, CHINA:23.1842:113.266:7434 +GUANTANAMO, CUBA:19.9064:W75.2069 +GUAPI, COLOMBIA:2.57:W77.8983 +GUAPILES, COSTA RICA:10.2172:W83.7947 +GUARATINGUETA, BRAZIL:-22.7914:W45.2047 +GUASDUALITO, VENEZUELA:7.21083:W70.7564 +GUATEMALA CITY, GUATEMALA:14.5831:W90.5275 +GUAYANA, VENEZUELA:8.28833:W62.7603 +GUAYAQUIL, ECUADOR:-2.15778:W79.8839 +GUAYARAMERIN, BOLIVIA:-10.8217:W65.3458 +GUAYMARAL, COLOMBIA:4.81222:W74.0647 +GUAYMAS, MEXICO:27.9689:W110.925 +GUERNSEY, GUERNSEY ISLD.:49.4347:W2.60194 +GUETERSLOH, GERMANY:51.9228:8.30611 +GUIDONIA, ITALY:41.9903:12.7408:79 +GUILIN, CHINA:25.2178:110.039:27809 +GUIRIA, VENEZUELA:10.5739:W62.3125 +GUISCRIFF-SCAER, FRANCE:48.0525:W3.66444 +GUIUAN, PHILIPPINES:11.0356:125.743 +GULKANA, USA:62.1547:W145.456 +GULU, UGANDA:2.80556:32.2717 +GUNA, INDIA:24.6544:77.3472 +GUNUNG SITOLI, INDONESIA:1.16639:97.7028 +GURIAT, SAUDI ARABIA:31.4108:37.2789 +GUSAU, NIGERIA:12.1717:6.69611 +GWADAR, PAKISTAN:25.2331:62.3294 +GWALIOR, INDIA:26.2939:78.2275 +GWERT, ZIMBABWE:-19.4367:29.8617 +GWINN, USA:46.3536:W87.3958 +HA'APAI, TONGA:-19.7769:W174.341 +HACHIJOJIMA, JAPAN:33.115:139.786 +HACHINOE, JAPAN:40.5564:141.466 +HAFR AL-BATIN, SAUDI ARABIA:28.335:46.125 +HAGFORS, SWEDEN:60.02:13.5789 +HAGSHULT, SWEDEN:57.2922:14.1372 +HAGUENAU, FRANCE:48.7933:7.81611:182 +HAHN, GERMANY:49.9497:7.26389:5 +HAIFA, ISRAEL:32.8111:35.0439 +HAILAR, CHINA:49.205:119.825:1320 +HAIL, SAUDI ARABIA:27.4378:41.6861 +HAKODATE, JAPAN:41.77:140.822 +HALFPENNY GREEN, ENGLAND:52.5175:W2.25972 +HALIFAX, CANADA:44.6397:W63.4994 +HALL BEACH, CANADA:68.7761:W81.2436 +HALLE, GERMANY:51.5519:12.0525:135 +HALLI, FINLAND:61.8558:24.7864 +HALLVIKEN, SWEDEN:63.7383:15.4583 +HALMSTAD, SWEDEN:56.6911:12.82 +HAMADAN, IRAN:34.8681:48.5522 +HAMAMATSU, JAPAN:34.75:137.703 +HAMAR, NORWAY:60.8183:11.0672 +HAMBURG, GERMANY:53.550341:10.000654:755 +HAMEENKYRO, FINLAND:61.6894:23.0736 +HAMILTON, CANADA:43.1731:W79.935 +HAMILTON, NEW ZEALAND:-37.8664:175.332 +HAMPTON, USA:37.0828:W76.3603 +HANAMAKI, JAPAN:39.4308:141.136 +HANAU, GERMANY:50.1692:8.96139 +HANA, USA:20.7956:W156.014 +HANGZHOU, CHINA:30.2283:120.432:8000 +HANKO, FINLAND:59.8486:23.0833 +HANNOVER, GERMANY:52.3744779:9.7385532:204 +HANOI, VIET NAM:21.2217:105.806 +HAO ISLAND, TUAMOTU ISLANDS:-18.0747:W140.946 +HARARE, ZIMBABWE:-17.7514:30.9244 +HARBIN, CHINA:45.6233:126.25:53068 +HARGEISA, SOMALIA:9.51806:44.0886 +HARLINGEN, USA:26.2283:W97.6542 +HARMONY, SOUTH AFRICA:-28.0786:26.8611 +HARRISBURG, USA:40.1933:W76.7633 +HARRISMITH, SOUTH AFRICA:-28.235:29.1061 +HARRISON, USA:36.2614:W93.1547 +HARTFORD, USA:41.7361:W72.65 +HASSFURT, GERMANY:50.0178:10.5294:52 +HASSI-MESSAOUD, ALGERIA:31.6728:6.14028 +HASSLOSA, SWEDEN:58.4092:13.2625 +HASTINGS, NEW ZEALAND:-39.6467:176.767 +HASTINGS, SIERRA LEONE:8.39444:W13.1283 +HASVIK, NORWAY:70.4867:22.1397 +HATFIELD, ENGLAND:51.7667:0.25:9 +HAUGESUND, NORWAY:59.345:5.20833 +HAVANA, CUBA:22.9892:W82.4092 +HAVERFORDWEST, ENGLAND:51.8331:W4.96111 +HAVRE, USA:48.5428:W109.762 +HAWARDEN, ENGLAND:53.1781:W2.97778 +HAWTHORNE, USA:33.9228:W118.335 +HAY RIVER, CANADA:60.8397:W115.783 +HAZTOR, ISRAEL:31.7625:34.7272 +HEDEN, SWEDEN:65.8361:21.4714 +HEDE, SWEDEN:62.4089:13.7472 +HEFEI, CHINA:31.78:117.298:11434 +HEHO, MYANMAR:20.7431:96.7917 +HEIBRON, SOUTH AFRICA:-27.2783:27.9958 +HEIDELBERG, GERMANY:49.3933:8.65194 +HEIDELBERG, SOUTH AFRICA:-26.5061:28.3939 +HELENA, USA:46.6067:W111.983 +HELSINKI, FINLAND:60.23542123800722:24.927984421582043 +HEMAVAN, SWEDEN:65.8061:15.0828 +HENDRIK VERWOERDDAM, SOUTH AFRICA:-30.5619:25.5281 +HERAKLION, GREECE:35.3394:25.1803:245 +HERAT, AFGHANISTAN:34.2097:62.2278 +HERINGSDORF, GERMANY:53.8786:14.1522:37 +HERMOSILLO, MEXICO:29.0958:W111.048 +HEWANDORRA, ST. LUCIA ISLAND:13.7331:W60.9525 +HIBBING, USA:47.3864:W92.8389 +HICKORY, USA:35.7411:W81.3894 +HIERRO, CANARY ISLANDS:27.8147:W17.8869 +HIGH LEVEL, CANADA:58.6214:W117.165 +HIGUEROTE, VENEZUELA:10.4622:W66.0925 +HILDESHEIM, GERMANY:52.1775:9.94556:92 +HILO, USA:19.7203:W155.049 +HIRAKUD, INDIA:21.5792:84.0061 +HIROSHIMA, JAPAN:34.4353:132.922 +HISSAR, INDIA:29.1806:75.7531 +HIVA-OA, FRENCH POLYNESIA:-9.76861:W139.011 +HOBART, AUSTRALIA:-42.8361:147.51:1696 +HOBART, USA:34.9911:W99.0511 +HOBBS, USA:32.6875:W103.217 +HO CHI MINH CITY, VIET NAM:10.82:106.662 +HOEDSPRUIT, SOUTH AFRICA:-24.3686:31.0486 +HOF, GERMANY:50.2886:11.8547:58 +HOFN, ICELAND:64.2956:W15.2272 +HOFU, JAPAN:34.0344:131.549 +HOHENFELS, GERMANY:49.2181:11.8361 +HOHN, GERMANY:54.3119:9.53806 +HOKITIKA, NEW ZEALAND:-42.7136:170.985 +HOLGUIN, CUBA:20.7856:W76.315 +HOLMAN ISLAND, CANADA:70.7631:W117.806 +HOLZDORF, GERMANY:51.7678:13.1675 +HOMER, USA:59.6456:W151.476 +HOMESTEAD, USA:25.4883:W80.3836 +HONG KONG, HONG KONG:22.3089:113.914 +HON, LIBYA:29.11:15.9656 +HONOLULU, USA:21.3158:W157.927 +HOPKINSVILLE, USA:36.6683:W87.4961 +HOPSTEN, GERMANY:52.3386:7.54111 +HORTA, ACORES:38.5197:W28.7158 +HOTAN, CHINA:37.0378:79.8658:465 +HOUGHTON LAKE, USA:44.3597:W84.6711 +HOULTON, USA:46.1231:W67.7919 +HOUSTON, USA:29.6072:W95.1586:1553 +HOWARD, PANAMA:8.91444:W79.5994 +HPA-AN, MYANMAR:16.8944:97.6753 +HRADEC KRALOVE, CZECH REPUBLIC:50.2531:15.845 +HSINCHU, TAIWAN:24.8178:120.939 +HUAHINE ISLAND, FRENCH POLYNESIA:-16.6872:W151.022 +HUALIEN, TAIWAN:24.0231:121.618 +HUAMBO, ANGOLA:-12.8086:15.7603 +HUANUCO, PERU:-9.87806:W76.2042 +HUDIKSVALL, SWEDEN:61.7681:17.0806 +HUDSON BAY, CANADA:52.8167:W102.311 +HUHHOT, CHINA:40.8533:111.822:17186 +HULTSFRED, SWEDEN:57.5256:15.8239 +HUMBERSIDE, ENGLAND:53.5744:W0.350833 +HUNTER AAF, USA:32.01:W81.1456 +HURGHADA, EGYPT:27.1839:33.7983 +HURON, USA:44.385:W98.2283 +HUSAVIK, ICELAND:65.9522:W17.4258 +HWANGE NATIONAL PARK, ZIMBABWE:-18.6297:27.0208 +HYAKURI, JAPAN:36.1808:140.415 +HYDERABAD, INDIA:17.4522:78.4611 +HYDERABAD, PAKISTAN:25.3181:68.3661 +HYERES, FRANCE:43.0972:6.14583:132 +HYVINKAA, FINLAND:60.6544:24.8811 +IASI, ROMANIA:47.1789:27.62 +IAUARETE, BRAZIL:0.6075:W69.1858 +IBADAN, NIGERIA:7.36222:3.97833 +IBAGUE, COLOMBIA:4.42139:W75.1331 +IBA, PHILIPPINES:15.3256:119.969 +IBARRA, ECUADOR:0.338333:W78.1364 +IBERIA, PERU:-11.4114:W69.4886 +IBIZA, SPAIN:38.9743901:1.4197463 +IDRE, SWEDEN:61.8686:12.6906 +IEJIMA, JAPAN:26.7286:127.762 +IFRANE, MOROCCO:33.5053:W5.15278 +IGUAZU FALLS, ARGENTINA:-25.7375:W54.4731 +IKI, JAPAN:33.7489:129.785 +ILAM, IRAN:33.5856:46.4053 +ILE D'YEU, FRANCE:46.7186:W2.39111 +ILES DE LA MADELEINE, CANADA:47.4247:W61.7781 +ILHEUS, BRAZIL:-14.8158:W39.0331 +ILIAMNA, USA:59.7536:W154.911 +ILLIZI, ALGERIA:26.5733:8.48361 +ILOILO, PHILIPPINES:10.7131:122.545 +ILO, PERU:-17.695:W71.3439 +ILORIN, NIGERIA:8.44:4.49389 +IMMOLA, FINLAND:61.2492:28.9036 +IMPERATRIZ, BRAZIL:-5.53111:W47.46 +IMPERIAL, USA:32.8342:W115.579 +IMPFONDO, CONGO:1.58944:18.0469 +IMPHAL, INDIA:24.7597:93.8969 +INDIANAPOLIS, USA:39.7172:W86.2942:936 +INDIAN MOUNTAINS, USA:65.9928:W153.704 +INDIAN SPRINGS, USA:36.5869:W115.673 +INDORE, INDIA:22.7217:75.8008 +INGOLSTADT, GERMANY:48.7156:11.5339 +INHAMBANE, MOZAMBIQUE:-23.8764:35.4083 +INNSBRUCK, AUSTRIA:47.26:11.3439 +IN SALAH, ALGERIA:27.2508:2.51194 +INTERLAKEN, SWITZERLAND:46.6764:7.87917 +INTERNATIONAL FALLS, USA:48.5661:W93.4031 +INUVIK, CANADA:68.3042:W133.483 +INVERCARGILL, NEW ZEALAND:-46.4122:168.313 +INVERNESS, UK:57.4790124:W4.225739:20 +IOANNINA, GREECE:39.6964:20.8225:403 +IPATINGA, BRAZIL:-19.4706:W42.4875 +IPIALES, COLOMBIA:0.861667:W77.6717 +IPOH, MALAYSIA:4.56778:101.092 +IQALUIT, CANADA:63.7564:W68.5558 +IQUIQUE, CHILE:-20.535:W70.1811 +IQUITOS, PERU:-3.78472:W73.3086 +IRAN SHAHR, IRAN:27.2364:60.72 +IRINGA, TANZANIA:-7.66861:35.7519 +IRKUTSK, RUSSIA:52.2669:104.395:280 +IRUMA, JAPAN:35.8414:139.41 +ISAFJORDUR, ICELAND:66.0581:W23.1353 +ISHIGAKI, JAPAN:24.3444:124.187 +ISHURDI, BANGLADESH:24.1525:89.0494 +ISIOLO, KENYA:0.339167:37.5908 +ISIRO, ZAIRE:2.8275:27.5881 +ISKENDERUN, TURKEY:36.5733:36.1539:636 +ISLA DE COCHE, VENEZUELA:10.7942:W63.9814 +ISLAMABAD, PAKISTAN:33.6164:73.0992 +ISLA MUJERES, MEXICO:21.245:W86.7397 +ISLA REY JORGE, ANTARCTICA:-62.1908:W58.9867 +ISLAS DEL CISNE, HONDURAS:17.4072:W83.9325 +ISLAY, UK:55.6819:W6.25667 +ISLE OF MAN, ENGLAND:54.0833:W4.62389 +ISLIP, USA:40.795:W73.1 +ISPARTA, TURKEY:37.7853:30.5817:1049 +ISTANBUL, TURKEY:41.05497681583172:28.908688006982437:5343 +ISTRES, FRANCE:43.5225:4.92361 +ITAIPU, PARAGUAY:-25.4075:W54.6194 +ITAITUBA, BRAZIL:-3.12722:W58.4811 +ITUMBIARA, BRAZIL:-18.4444:W49.2133 +ITZEHOE, GERMANY:53.9944:9.57833 +IVALO, FINLAND:68.6072:27.4053 +IWAKUNI, JAPAN:34.1436:132.236 +IWOJIMA, JAPAN:24.7839:141.322 +IZMIR, TURKEY:38.2922:27.1569:11891 +IZTEPEC, MEXICO:16.4492:W95.0936 +IZUMO, JAPAN:35.4136:132.89 +JABALPUR, INDIA:23.1778:80.0519 +JACARE-ACANGA, BRAZIL:-6.23306:W57.7767 +JACKSON, USA:32.3111:W90.0758:287 +JACKSONVILLE, USA:30.4939:W81.6878:1935 +JACOBSBAD, PAKISTAN:28.2842:68.4494 +JAFFNA, SRI LANKA:9.79222:80.07 +JAHROM, IRAN:28.5864:53.5789 +JAIPUR, INDIA:26.8239:75.8097 +JAISALMER, INDIA:26.8892:70.8644 +JAKARTA, INDONESIA:-6.12556:106.656 +JAKOBSHAVN, GREENLAND:69.2333:W51.0667 +JALALABAD, AFGHANISTAN:34.3992:70.4994 +JALAPA, MEXICO:19.475:W96.7975 +JAMBI, INDONESIA:-1.63778:103.644 +JAMMU, INDIA:32.6897:74.8381 +JAMNAGAR, INDIA:22.4664:70.0114 +JAMSHEDPUR, INDIA:22.8119:86.1675 +JANAKPUR, NEPAL:26.7086:85.9222 +JAQUE, PANAMA:7.51722:W78.1567 +JASK, IRAN:25.6533:57.7992 +JAUJA, PERU:-11.7831:W75.4733 +JAYAPURA, INDONESIA:-2.57694:140.516 +JEBEL DHANA, UNITED ARAB EMIRATES:24.1872:52.6139 +JEDDAH, SAUDI ARABIA:21.3481:39.1728 +JENA, GERMANY:50.9172:11.7136:114 +JEREZ, SPAIN:36.7444:W6.06 +JERSEY, ENGLAND:51.7682578:W0.293425 +JERUSALEM, ISRAEL:31.8667:35.2167 +JESSORE, BANGLADESH:23.1836:89.1608 +JEVER, GERMANY:53.5333:7.88861 +JEYPORE, INDIA:18.8797:82.5519 +JHANSI, INDIA:25.4897:78.5594 +JHARSUGUDA, INDIA:21.9133:84.0503 +JHUNJU, KOREA:35.8783:127.119 +JIAMUSI, CHINA:46.8433:130.465:882 +JIJEL, ALGERIA:36.795:5.87333 +JIMMA, ETHIOPIA:7.66583:36.8164 +JIROFT, IRAN:28.7267:57.67 +JIWANI, PAKISTAN:25.0678:61.8053 +JOAO PESSOA, BRAZIL:-7.26972:W35.8961 +JODHPUR, INDIA:26.2514:73.0481 +JOENKOEPING, SWEDEN:57.7575:14.0686 +JOENSUU, FINLAND:62.6589:29.6244 +JOHANNESBURG, SOUTH AFRICA:-25.9383:27.9261 +JOHNSTON ISLAND, JOHNSTON ATOLL:16.7286:W169.534 +JOHOR BAHRU, MALAYSIA:1.64111:103.669 +JOIGNY, FRANCE:47.9922:3.39222 +JOINVILLE, BRAZIL:-26.2247:W48.7972 +JOKKMOKK, SWEDEN:66.4961:20.1469 +JONESBORO, USA:35.8317:W90.6461 +JORHAT, INDIA:26.7306:94.1756 +JOSE C. PAZ, ARGENTINA:-34.5606:W58.7894 +JOSE DE SAN MARTIN, ARGENTINA:-44.0481:W70.4592 +JOSE PANGANIBAN, PHILIPPINES:14.2928:122.646 +JOS, NIGERIA:9.63972:8.86889 +JUANJUI, PERU:-7.16944:W76.7278 +JUBAIL, SAUDI ARABIA:27.0389:49.405 +JUBA, SUDAN:4.87194:31.6011 +JUIZ DE FORA, BRAZIL:-21.7914:W43.3867 +JUJUY, ARGENTINA:-24.3928:W65.0978 +JULIACA, PERU:-15.4669:W70.1581 +JUNEAU, USA:58.3547:W134.576 +JUNIN, ARGENTINA:-34.5458:W60.9306 +JWANENG, BOTSWANA:-24.6022:24.6908 +JYVASKYLA, FINLAND:62.3994:25.6781 +KABUL, AFGHANISTAN:34.5658:69.2122 +KADENA, JAPAN:26.3556:127.767 +KADUNA, NIGERIA:10.6958:7.32 +KAEDI, MAURITANIA:16.1594:W13.5075 +KAGOSHIMA, JAPAN:31.8033:130.719 +KAHULUI, USA:20.8986:W156.431 +KAILASHAHAR, INDIA:24.3083:92.0075 +KAIMANA, INDONESIA:-3.64389:133.695 +KAITAIA, NEW ZEALAND:-35.07:173.285 +KAJAANI, FINLAND:64.2853:27.6922 +KAKAMEGA, KENYA:0.271111:34.7872 +KALABO, ZAMBIA:-14.9975:22.6475 +KALALEH, IRAN:37.3831:55.4519 +KALAMATA, GREECE:37.0683:22.0256:442 +KALAY, MYANMAR:23.1886:94.0508 +KALEMIE, ZAIRE:-5.87556:29.25 +KALGOORLIE, AUSTRALIA:-30.7894:121.462:75 +KALIBO, PHILIPPINES:11.6811:122.378 +KALININGRAD, RUSSIA:54.89:20.5925:223 +KALIXFORS, SWEDEN:67.7647:20.2572 +KALKMAR, SWEDEN:56.6853:16.2875 +KAMALPUR, INDIA:24.135:91.8106 +KAMARANG, GUYANA:5.86528:W60.6142 +KAMEMBE, RWANDA:-2.46222:28.9078 +KAMENZ, GERMANY:51.2961:14.1289:98 +KAMINA BASE, ZAIRE:-8.64194:25.2528 +KAMISHLI, SYRIA:37.0236:41.1944 +KAMLOOPS, CANADA:50.7022:W120.442 +KAMP, GERMANY:51.5303:6.53694:63 +KAMUELA, USA:20.0011:W155.668 +KANANGA, ZAIRE:-5.9:22.4692 +KANAZAWA, JAPAN:36.3936:136.408 +KANDAHAR, AFGHANISTAN:31.5058:65.8478 +KANDLA, INDIA:23.1125:70.1003 +KANEOHE BAY, USA:21.4492:W157.768 +KANGNUNG, KOREA:37.7533:128.944 +KANKAKEE, USA:41.0714:W87.8461 +KANO, NIGERIA:12.0475:8.52444 +KANOYA, JAPAN:31.3675:130.845 +KANPUR, INDIA:26.4042:80.41 +KANSAS CITY, USA:39.100105:W94.5781416:816 +KAOHSIUNG, TAIWAN:22.5753:120.351 +KAOLACK, SENEGAL:14.1467:W16.0511 +KAPOSVAR, HUNGARY:46.3892:17.7314:113 +KAPUSKASING, CANADA:49.4139:W82.4675 +KARACHI, PAKISTAN:24.8933:66.9386 +KARDLA, ESTONIA:58.9906:22.8306 +KARIBA, ZIMBABWE:-16.5197:28.8847 +KARLOVY VARY, CZECH REPUBLIC:50.2028:12.9147 +KARLSBORG, SWEDEN:58.5136:14.5069 +KARLSKOGA, SWEDEN:59.3458:14.4958 +KARLSRUHE, GERMANY:48.9822:8.33333:173 +KARONGA, MALAWI:-9.95333:33.8928 +KARRATHA, AUSTRALIA:-20.7122:116.773:10 +KARS, TURKEY:40.5622:43.115:1804 +KASANE, BOTSWANA:-17.8328:25.1622 +KASHI, CHINA:39.5433:76.0217:1057 +KASOMPE, ZAMBIA:-12.5728:27.8939 +KASSALA, SUDAN:15.3858:36.3281 +KASSEL, GERMANY:51.4083:9.3775:106 +KASTAMONU, TURKEY:41.3139:33.7958:1834 +KASTELI, GREECE:35.1869:25.3267:334 +KASTORIA, GREECE:40.4494:21.2761:57 +KASUNGU, MALAWI:-13.0144:33.4683 +KATHMANDU, NEPAL:27.6964:85.3592 +KATOWICE, POLAND:50.2383:19.035 +KAUHAJOKI, FINLAND:62.4625:22.3931 +KAUHAVA, FINLAND:63.1269:23.0514 +KAUKURA ATOLL, TUAMOTU ISLANDS:-15.6633:W146.885 +KAVALA, GREECE:40.9722:24.3417:37 +KAWTHOUNG, MYANMAR:10.0492:98.5378 +KAYES, MALI:14.4311:W11.4394 +KAYSERI, TURKEY:38.7703:35.4953 +KAZAN, RUSSIA:55.6078:49.2772:515 +KECSKEMET, HUNGARY:46.9175:19.7492:322 +KEDOUGOU, SENEGAL:12.5722:W12.2203 +KEFLAVIK, ICELAND:63.985:W22.6056 +KELOWNA, CANADA:49.9561:W119.378 +KEMI, FINLAND:65.7817:24.5989 +KEMIJARVI, FINLAND:66.7128:27.1567 +KEMOROVO, RUSSIA:55.2697:86.1069:294 +KENAI, USA:60.5731:W151.245 +KENDALL-TAMIAMI, USA:25.6478:W80.4328 +KENDARI, INDONESIA:-4.08222:122.417 +KENGTUNG, MYANMAR:21.3006:99.6367 +KENORA, CANADA:49.7883:W94.3631 +KENTIRA, MOROCCO:34.2989:W6.59583 +KERIKERI, NEW ZEALAND:-35.2628:173.912 +KERMAN, IRAN:30.2611:56.9567 +KERTEH, MALAYSIA:4.53722:103.426 +KESHOD, INDIA:21.3167:70.27 +KETAPANG, INDONESIA:-1.81639:109.963 +KETCHIKAN, USA:55.3556:W131.714 +KEY WEST, USA:24.5561:W81.7594 +KHABAROVSK, RUSSIA:48.5278:135.188:383 +KHAJURAHO, INDIA:24.8194:79.9192 +KHARAN, PAKISTAN:28.5944:65.4247 +KHARK ISLAND, IRAN:29.2592:50.3239 +KHARKOV, RUSSIA:49.3589:26.9339:350 +KHARTOUM, SUDAN:15.5894:32.5531 +KHASAB, OMAN:26.1711:56.2406 +KHORRAM ABAD, IRAN:33.4364:48.2858 +KHUZDHAR, PAKISTAN:27.7925:66.6428 +KICHINAU FIR/ACC/COM, MOLDOVA:46.9278:28.9314 +KIEL, GERMANY:54.3794:10.145:118 +KIEV, RUSSIA:50.345:30.895:839 +KIFFA, MAURITANIA:16.5897:W11.4061 +KIGALI, RWANDA:-1.96861:30.1394 +KIKALA, FINLAND:60.4625:23.6525 +KIKWIT, ZAIRE:-5.03556:18.7856 +KILIMANJARO, TANZANIA:-3.42917:37.0744 +KILLEEN, USA:31.0672:W97.8289 +KIMBERLEY, SOUTH AFRICA:-28.8028:24.765 +KIMHAE, KOREA:35.1794:128.938 +KINDERSLEY, CANADA:51.5175:W109.181 +KINDU, ZAIRE:-2.91917:25.9153 +KING KHALID MIL.CITY, SAUDI ARABIA:27.9008:45.5281 +KING SALMON, USA:58.6767:W156.649 +KINGSTON, CANADA:44.2253:W76.5969 +KINGSTON, JAMAICA:17.9356:W76.7875 +KINGSTOWN, ST.VINCENT/GRENADINES:13.1442:W61.2108 +KINGSVILLE, USA:27.5072:W97.8097 +KINSHASA, ZAIRE:-4.32472:15.3283 +KIRITIMATI, KIRIBATI:1.98611:W157.35 +KIRKENES, NORWAY:69.725:29.8875 +KIRKWALL, SCOTLAND:58.9581:W2.905 +KIRTLAND A.F.B., USA:35.04:W106.609 +KIRUNA, SWEDEN:67.8219:20.3367 +KISANGANI, ZAIRE:0.5175:25.155 +KISARAZU, JAPAN:35.3981:139.91 +KISH ISLAND, IRAN:26.5267:53.9817 +KISMAYU, SOMALIA:-0.377222:42.4592 +KISUMU, KENYA:-0.0861111:34.7289 +KITADAITO, JAPAN:25.9478:131.321 +KITAKYUSHU, JAPAN:33.8361:130.947 +KITALE, KENYA:0.971944:34.9583 +KITEE, FINLAND:62.1661:30.0736 +KITONA BASE, ZAIRE:-5.91806:12.4475 +KITTILA, FINLAND:67.7008:24.8467 +KITZINGEN, GERMANY:49.7431:10.2006 +KJELLER, NORWAY:59.9692:11.0358 +KLAGENFURT, AUSTRIA:46.6425:14.3375 +KLEIAT, LEBANON:34.5892:36.0111 +KLEINE BROGEL, BELGIUM:51.1678:5.47083 +KLEINSEE, SOUTH AFRICA:-29.6883:17.0939 +KLERKSDORP, SOUTH AFRICA:-26.8708:26.7178 +KLUANG, MALAYSIA:2.04139:103.307 +KNISLINGE, SWEDEN:56.1836:14.1322 +KNOBNOSTER, USA:38.7303:W93.5478 +KNOXVILLE, USA:35.8122:W83.9928 +KOBLENZ, GERMANY:50.3247:7.53083:105 +KOCHI, JAPAN:33.5444:133.671 +KODIAK, USA:57.75:W152.494 +KOETHEN, GERMANY:51.7211:11.9617:78 +KOHNAN, JAPAN:34.5908:133.933 +KOKSIJDE, BELGIUM:51.09:2.65278 +KOLDA, SENEGAL:12.88:W14.9553 +KOLDING, DENMARK:55.4361:9.33083 +KOLHAPUR, INDIA:16.6639:74.2881 +KOLWEZI, ZAIRE:-10.7658:25.5056 +KOMATIPOORT, SOUTH AFRICA:-25.4406:31.9297 +KOMATI POWER STATION, SOUTH AFRICA:-26.0933:29.4547 +KOMPONG CHNANG, CAMBODIA:12.255:104.564 +KONA, USA:19.7386:W156.046 +KONE, NEW CALEDONIA:-21.0533:164.838 +KONYA, TURKEY:37.9789:32.5617:38873 +KOPASKER, ICELAND:66.3136:W16.4611 +KORHOGO, IVORY COAST:9.38694:W5.55639 +KORTRIJK-VEVELGEM, BELGIUM:50.8178:3.20833 +KOSICE, SLOVAKIA:48.6631:21.2411 +KOSRAE, MICRONESIA:5.35667:162.958 +KOSTA, SWEDEN:56.8442:15.4525 +KOSZALIN, POLAND:54.0422:16.2636 +KOTA BAHRU, MALAYSIA:6.16639:102.294 +KOTA, INDIA:25.1606:75.8447 +KOTA KINABALU, MALAYSIA:5.93722:116.051 +KOTAKOLI, ZAIRE:4.1575:21.6508 +KOTZEBUE, USA:66.8844:W162.598 +KOUMAC, NEW CALEDONIA:-20.5461:164.256 +KOZANI, GREECE:40.2861:21.8408:34 +KRAKOW, POLAND:50.0775:19.7847 +KRALENDIJK, ANTILLES:12.1308:W68.2683 +KRAMFORS, SWEDEN:63.0483:17.7686 +KRASNODAR, RUSSIA:45.035:39.1717:339 +KRBI, THAILAND:8.09889:98.9861 +KRIEL, SOUTH AFRICA:-26.2517:29.1944 +KRISTIANSAND, NORWAY:58.2042:8.08528 +KRISTIANSTAD, SWEDEN:55.9217:14.0853 +KRISTIANSUND, NORWAY:63.1117:7.82444 +KROONSTAD, SOUTH AFRICA:-27.6606:27.3156 +KRUGERSDORP, SOUTH AFRICA:-26.0808:27.7256 +KRUSA-PADBORG, DENMARK:54.8703:9.27917 +KRUUNUPYY, FINLAND:63.7211:23.1431 +KUALA LUMPUR, MALAYSIA:2.74556:101.71 +KUALA TERENGGANU, MALAYSIA:5.3825:103.103 +KUANTAN, MALAYSIA:3.77528:103.209 +KUBBE, SWEDEN:63.6336:17.9397 +KUCHING, MALAYSIA:1.48472:110.346 +KUFRA, LIBYA:24.1786:23.3139 +KUITO, ANGOLA:-12.4044:16.9472 +KULU, INDIA:31.8817:77.1844 +KULUSUK, GREENLAND:65.5833:W37.15 +KUMAMOTO, JAPAN:32.8372:130.855 +KUMEJIMA, JAPAN:26.3633:126.714 +KUNDUZ, AFGHANISTAN:36.665:68.9108 +KUNMING, CHINA:24.9922:102.743:21507 +KUNOVICE, CZECH REPUBLIC:49.0294:17.4397 +KUNSAN, KOREA:35.9036:126.616 +KUNUNURRA, AUSTRALIA:-15.7781:128.707:1 +KUOPIO, FINLAND:63.0069:27.7975 +KUPANG, INDONESIA:-10.1714:123.671 +KURESSAARE, ESTONIA:58.2297:22.5094 +KURGAN, RUSSIA:55.475:65.4147:393 +KURUMAN, SOUTH AFRICA:-27.4567:23.4114 +KUSTANAY, RUSSIA:53.2064:63.5508:240 +KUTAHYA, TURKEY:39.4267:30.0164:2484 +KUUJJUARARAPIK, CANADA:55.2833:W77.7667 +KUUSAMO, FINLAND:65.9875:29.2392 +KUWAIT, KUWAIT:29.2267:47.98 +KWAJALEIN, MARSHALL ISLANDS:8.72:167.731 +KWANGJU, KOREA:35.1256:126.81 +KYAUKPYU, MYANMAR:19.4267:93.5344 +KYRITZ, GERMANY:52.9186:12.4253:156 +KYUNGJU, KOREA:35.8564:129.211 +LAAGE, GERMANY:53.9181:12.2792 +LAARBRUCH, GERMANY:51.6017:6.1425 +LA BAULE, FRANCE:47.2894:W2.34639:22 +LABE, GUINEA:11.3261:W12.2869 +LABOULAYE, ARGENTINA:-34.1353:W63.3622 +LABUAN, MALAYSIA:5.30056:115.25 +LABUHAN BAJO, INDONESIA:-8.48611:119.889 +LA CEIBA, HONDURAS:15.7422:W86.8533 +LA COLOMA, CUBA:22.3358:W83.6419 +LA CORUNA, SPAIN:43.3019:W8.37722 +LA CUMBRE, ARGENTINA:-31.0067:W64.5325 +LADAG, PHILIPPINES:8.41444:124.611 +LADYBRAND, SOUTH AFRICA:-29.1811:27.4531 +LADYSMITH, SOUTH AFRICA:-28.5817:29.7497 +LAESO, DENMARK:57.2778:11.0014 +LAFAYETTE, USA:30.2053:W91.9875 +LA FRIA, VENEZUELA:8.23917:W72.2708 +LAGHOUAT, ALGERIA:33.7639:2.92722 +LAGO AGRIO, ECUADOR:0.0922222:W76.8692 +LAGO ARGENTINO, ARGENTINA:-50.3353:W72.2483 +LAGOA SANTA, BRAZIL:-19.6614:W43.8964 +LAGOS, NIGERIA:6.57722:3.32111 +LA GRANDE RIVIERE, CANADA:53.6253:W77.7042 +LAHAD DATU, MALAYSIA:5.03222:118.324 +LAHANIA-KAPALUA, USA:20.9628:W156.674 +LAHORE, PAKISTAN:31.4947:74.3461 +LAJES, BRAZIL:-27.7819:W50.2814 +LAJES (TERCEIRA ISLAND), ACORES:38.7642:W27.0933 +LA JULIA, ECUADOR:-1.70417:W79.5522 +LAKE CHARLES, USA:30.1261:W93.2233 +LAKEHURST, USA:40.0333:W74.3533 +LAKE MANYARA, TANZANIA:-3.37611:35.8181 +LALIBELLA, ETHIOPIA:11.9747:38.9797 +LAMBARENE, GABON:-0.704167:10.2456 +LAMBASA, FIJI:-16.4667:179.34 +LAMERD, IRAN:27.3708:53.1892 +LAMEZIA, ITALY:38.9064:16.2422:162 +LA MINA, COLOMBIA:11.2325:W72.49 +LAMPANG, THAILAND:18.2719:99.5039 +LAMU, KENYA:-2.25222:40.9131 +LANAI, USA:20.7856:W156.951 +LANDIVISIAU, FRANCE:48.53:W4.15139:18 +LANDSBERG, GERMANY:48.0706:10.9058 +LANDSKRONA, SWEDEN:55.9447:12.8608 +LANGEBAANWEG, SOUTH AFRICA:-32.9689:18.1603 +LANGENLEBARN, AUSTRIA:48.3208:16.1117 +LANGGUR, INDONESIA:-5.66139:132.731 +LANSING, USA:42.7786:W84.5872 +LANYU, TAIWAN:22.0294:121.527 +LANYWA, MYANMAR:20.9403:94.8225 +LANZEROTE, CANARY ISLANDS:28.9453:W13.605 +LANZHOU, CHINA:36.5167:103.622:13087 +LA ORCHILA, VENEZUELA:11.8086:W66.1792 +LA PALMA, PANAMA:8.40667:W78.1417 +LA PAZ, BOLIVIA:-16.5131:W68.1922 +LA PAZ, MEXICO:24.0725:W110.362 +LA PLATA, ARGENTINA:-34.9722:W57.8944 +LAPPEENRANTA, FINLAND:61.0444:28.1442 +LA QUIACA, ARGENTINA:-22.1622:W65.5697 +LAREDO, USA:27.5436:W99.4614 +LA RIOJA, ARGENTINA:-29.3814:W66.7958 +LAR, IRAN:27.6736:54.3814 +LARISSA, GREECE:39.65:22.4653:122 +LARNACA, CYPRUS:34.875:33.6247 +LA ROCHELLE, FRANCE:46.17127895533832:W1.1405420877441474 +LA ROCHE-SUR-YON, FRANCE:46.7019:W1.37861:87 +LA ROMANA, DOMINICAN REPUBLIC:18.4519:W68.9117 +LA RONGE, CANADA:55.1514:W105.262 +LA SERENA, CHILE:-29.9164:W71.1911 +LAS HERAS, ARGENTINA:-46.5383:W68.9658 +LASHIO, MYANMAR:22.9775:97.7522 +LAS LOMITAS, ARGENTINA:-24.7211:W60.5486 +LAS PALMAS, PERU:-12.1606:W76.9989 +LASTOURVILLE, GABON:-0.826389:12.7467 +LAS TUNAS, CUBA:20.9878:W76.9358 +LAS VEGAS, USA:36.0803:W115.152:352 +LATACUNGA, ECUADOR:-0.906667:W78.6156 +LATAKIA, SYRIA:35.4008:35.9486 +LATINA, ITALY:41.5422:12.9089:277 +LAUNCESTON, AUSTRALIA:-41.5453:147.214:435 +LAUPHEIM, GERMANY:48.2203:9.91 +LAVAL, FRANCE:48.0311:W0.742778:34 +LAVAN ISLAND, IRAN:26.81:53.3564 +LAZARD CARDENAS, MEXICO:18.0017:W102.22 +LE CASTELLET, FRANCE:43.2525:5.785 +LECCE, ITALY:40.2386:18.1331:238 +LECHFELD, GERMANY:48.1861:10.8622 +LEEDS, ENGLAND:53.7974185:W1.5437941:551 +LEER, GERMANY:53.2719:7.44278:70 +LEEUWARDEN, NETHERLANDS:53.2286:5.76056:255 +LEGAZPI, PHILIPPINES:7.82778:123.46 +LE HAVRE, FRANCE:49.5339:0.0880556 +LEH, INDIA:34.1356:77.5456 +LEICESTER, ENGLAND:52.6361398:W1.1330789:109 +LEIPZIG, GERMANY:51.3406321:12.3747329:297 +LE LUC, FRANCE:43.3844:6.38694 +LE MANS, FRANCE:47.9486:0.201667:52 +LEMOORE, USA:36.3328:W119.952 +LEMWERDER, GERMANY:53.1431:8.62333:36 +LEON, NICARAGUA:12.4278:W86.9022 +LEON, SPAIN:42.5889:W5.65556 +LE PUY, FRANCE:45.0794:3.76472 +LES EPLATURES, SWITZERLAND:47.0836:6.79278 +LETHBRIDGE, CANADA:49.6303:W112.8 +LETHEM, GUYANA:3.3725:W59.7892 +LETICIA, COLOMBIA:-4.19306:W69.9425 +LE TOURQUET, FRANCE:50.5147:1.62722 +LEUTKIRCH, GERMANY:47.8589:10.0144:175 +LEZIGNAN-CORBIERES, FRANCE:43.1758:2.73417 +LHOK SUKON, INDONESIA:5.06944:97.2592 +LIBERIA, COSTA RICA:10.5931:W85.5442 +LIBOURNE, FRANCE:44.9822:W0.134722 +LIBREVILLE, GABON:0.458333:9.41222 +LICHINGA, MOZAMBIQUE:-13.2739:35.2661 +LICHTENBURG, SOUTH AFRICA:-26.1756:26.1844 +LIDKOPING, SWEDEN:58.4653:13.1742 +LIEGE, BELGIUM:50.6372:5.44306 +LIFOU, NEW CALEDONIA:-20.7747:167.24 +LIHUE, USA:21.9761:W159.339 +LILABARI, INDIA:27.2906:94.0967 +LILLE, FRANCE:50.5617:3.08944:34 +LIMA, PERU:-12.0217:W77.1142 +LIMOGES, FRANCE:45.8628:1.17944 +LIMON, COSTA RICA:9.95778:W83.0219 +LINCOLN, USA:40.8508:W96.7592 +LINDEN, GUYANA:5.96583:W58.2703 +LINE, CZECH REPUBLIC:49.675:13.2744 +LINGAYEN, PHILIPPINES:16.0347:120.241 +LINKOEPING, SWEDEN:58.4022:15.5256 +LINS, BRAZIL:-21.6639:W49.7303 +LINZ, AUSTRIA:48.2331:14.1875 +LIPA, PHILIPPINES:13.955:121.125 +LISALA, ZAIRE:2.17056:21.4967 +LISBON, PORTUGAL:38.7167:W9.15583 +LISTA, NORWAY:58.1003:6.625 +LITTLE ROCK, USA:34.7294:W92.2242 +LIVERPOOL, ENGLAND:53.407154:W2.991665:199 +LIVINGSTONE, ZAMBIA:-17.8217:25.8225 +LJUBLIANA, SLOVENIA:46.2236:14.4575 +LJUNGBY, SWEDEN:56.9503:13.9217 +LLANBEDR, ENGLAND:52.8117:W4.12333 +LLOYDMINSTER, CANADA:53.3092:W110.072 +LOBITO, ANGOLA:-12.3711:13.5364 +LODWAR, KENYA:3.12194:35.6086 +LODZ, POLAND:51.7219:19.3981 +LOEI, THAILAND:17.4389:101.722 +LOIKAW, MYANMAR:19.6922:97.2147 +LOKICHOGGIO, KENYA:4.20417:34.3481 +LOMA LARGA, ECUADOR:-1.50139:W79.4808 +LOME, TOGO:6.16556:1.25389 +LOMPOC, USA:34.7294:W120.577 +LONDON, CANADA:43.0356:W81.1539 +LONDONDERRY, NORTH IRELAND:54.9978678:W7.3213056 +LONDON, ENGLAND:51.5073219:W0.1276474:1738 +LONDRINA, BRAZIL:-23.3336:W51.13 +LONG BEACH, USA:33.8175:W118.151 +LONGTANG, TAIWAN:24.855:121.237 +LONGVIEW, USA:32.3847:W94.7114 +LOP BURI, THAILAND:14.8744:100.663 +LORETO, MEXICO:25.9892:W111.348 +LORIENT, FRANCE:47.7606:W3.44:17 +LOS ANGELES, CHILE:-37.4017:W72.4256 +LOS ANGELES, USA:34.0536909:W118.242766:1214 +LOS BRASILES, NICARAGUA:12.1894:W86.3539 +LOS CHILES, COSTA RICA:11.0353:W84.7061 +LOS MOCHIS, MEXICO:25.685:W109.081 +LOUDIMA, CONGO:-4.20778:12.6608 +LOUISIANA, USA:30.0375:W91.8839 +LOUIS TRICHARDT, SOUTH AFRICA:-23.1597:29.6964 +LOUISVILLE, USA:38.2278:W85.6636:842 +LOYANGALANI, KENYA:2.76306:36.7183 +LUANDA, ANGOLA:-8.85833:13.2311 +LUANG PRABANG, LAOS:19.8972:102.161 +LUBBOCK, USA:33.6636:W101.823 +LUBUMASHI, ZAIRE:-11.5911:27.5308 +LUCKNOW, INDIA:26.7606:80.8864 +LUDHIAHA, INDIA:30.8544:75.9511 +LUEBECK, GERMANY:53.8053:10.7192:214 +LUENA, ANGOLA:-11.7681:19.8975 +LUENA, ZAIRE:-9.46944:25.7589 +LUFKIN, USA:31.2339:W94.75 +LUGANO, SWITZERLAND:46.0042:8.91056 +LULEA, SWEDEN:65.5436:22.1219 +LUMBO, MOZAMBIQUE:-15.0331:40.6717 +LUNEVILLE, FRANCE:48.5972:6.54472:16 +LURE, FRANCE:47.7044:6.54583:24 +LUSAKA, ZAMBIA:-15.3306:28.4525 +LUWUK, INDONESIA:-1.03889:122.772 +LUXEMBURG, LUXEMBURG:49.6264:6.21139 +LUXEUIL, FRANCE:47.7831:6.36389:21 +LUXOR, EGYPT:25.6708:32.7064 +LVOV, RUSSIA:49.8125:23.9561:182 +LYCKSELE, SWEDEN:64.5481:18.7161 +LYDD, UK:50.9561:0.939167 +LYNEHAM, UK:51.505:W1.99333 +LYNN LAKE, CANADA:56.8639:W101.076 +LYON, FRANCE:45.7261:5.09083 +MAASTRICHT, NETHERLANDS:50.9114:5.77:60 +MACAPA, BRAZIL:0.0505556:W51.0719 +MACARA, ECUADOR:-4.38028:W79.9406 +MACAS, ECUADOR:-2.29917:W78.1206 +MACAU, MACAU:22.1494:113.591 +MACEIO, BRAZIL:-9.51028:W35.7933 +MACENTA, GUINEA:8.48111:W9.52583 +MACHALA, ECUADOR:-3.26889:W79.9617 +MACKAY, AUSTRALIA:-21.1717:149.18:208 +MACKMINNVILLE, USA:45.1944:W123.136 +MACON, FRANCE:46.295:4.79556 +MACON, USA:32.64:W83.5917 +MADANG, PAPUA NEW GUINEA:-5.20694:145.789 +MADINAH, SAUDI ARABIA:24.5533:39.705 +MADISON, USA:43.1397:W89.3375:199 +MADIUN, INDONESIA:-7.61583:111.434 +MADRAS, INDIA:12.9942:80.1803 +MADRID, SPAIN:40.44076992155027:W3.68693134643554 +MADURAI, INDIA:9.83444:78.0933 +MAFIKENG, SOUTH AFRICA:-25.7528:25.6119 +MAFRAQ, JORDAN:32.3561:36.2592 +MAGADAN, RUSSIA:59.91:150.717:295 +MAGANGUE, COLOMBIA:9.28333:W74.8394 +MAGDALENA, BOLIVIA:-13.2536:W64.0628 +MAGDEBURG, GERMANY:52.0736:11.6264:201 +MAGNETIOGORSK, RUSSIA:53.3933:58.76:392 +MAHAJANGA, MADAGASCAR:-15.6672:46.3517 +MAHE, SEYCHELLES:-4.67417:55.5217 +MAHMOOD ABAD, IRAN:34.1692:51.3175 +MAIDUGURI, NIGERIA:11.8553:13.0808 +MAIMAMA, AFGHANISTAN:35.9342:64.7592 +MAINZ, GERMANY:49.9689:8.1475:97 +MAIO, CAPE VERDE ISLANDS:15.1558:W23.2136 +MAJUBA POWER STATION, SOUTH AFRICA:-27.0792:29.7783 +MAJURO, MARSHALL ISLANDS:7.06472:171.272 +MAKABANA, CONGO:-3.48333:12.6167 +MAKALE, ETHIOPIA:13.4672:39.5333 +MAKALE, INDONESIA:-3.045:119.822 +MAKEMO, FRENCH POLYNESIA:-16.5847:W143.657 +MAKHACHKALA, RUSSIA:42.8167:47.6522:468 +MAKOKOU, GABON:0.579167:12.8908 +MAKOUA, CONGO:-0.0213889:15.5753 +MAKUNG, TAIWAN:23.5686:119.628 +MAKURDI, NIGERIA:7.70361:8.61389 +MALABO, EQUATORIAL GUINEA:3.75528:8.70861 +MALACCA, MALAYSIA:2.26333:102.251 +MALACKY, SLOVAKIA:48.4019:17.1183 +MALAGA, SPAIN:36.6736:W4.49889 +MALAKAL, SUDAN:9.55861:31.6525 +MALALANE, SOUTH AFRICA:-25.4733:31.5656 +MALANG, INDONESIA:-7.92611:112.714 +MALANJE, ANGOLA:-9.525:16.3122 +MALARGUE, ARGENTINA:-35.4839:W69.5825 +MALATYA, TURKEY:38.4358:38.0917:1582 +MALDONADO, URUGUAY:-34.855:W55.0942 +MALE, MALDIVES:4.19167:73.5289 +MALINDI, KENYA:-3.22917:40.1017 +MALMOE, SWEDEN:55.53:13.3714 +MALTA ACC, MALTA:35.9167:14.4167 +MALTA, MALTA:35.8572:14.4775 +MAMBURAO, PHILIPPINES:13.2081:120.605 +MANADO, INDONESIA:1.54917:124.926 +MANAGUA, CUBA:22.9697:W82.2747 +MANAGUA, NICARAGUA:12.1411:W86.1681 +MANAKARA, MADAGASCAR:-22.1197:48.0217 +MANANARA, MADAGASCAR:-16.1639:49.7736 +MANANJARY, MADAGASCAR:-21.2017:48.3581 +MANAPOURI, NEW ZEALAND:-45.5331:167.65 +MANAUS, BRAZIL:-3.03861:W60.0497 +MANCHESTER, ENGLAND:53.4794892:W2.2451148:1276 +MANDALAY, MYANMAR:21.7019:95.9778 +MANDERA, KENYA:3.93361:41.8442 +MANGALORE, INDIA:12.96:74.8925 +MANGLA, PAKISTAN:33.05:73.6383 +MANGOCHI, MALAWI:30.8378:W85.1817 +MANICORE, BRAZIL:-5.81139:W61.2786 +MANIHI, FRENCH POLYNESIA:-14.4367:W146.07 +MANILA, PHILIPPINES:14.5086:121.019 +MAN, IVORY COAST:7.27194:W7.58694 +MANIWAKI, CANADA:46.2744:W75.99 +MANIZALES, COLOMBIA:5.02972:W75.465 +MANNHEIM, GERMANY:49.4725:8.51361:145 +MANOKWARI, INDONESIA:-0.88:134.05 +MANSA, ZAMBIA:-11.1381:28.875 +MANSTON, ENGLAND:51.3422:1.34611 +MANTA, ECUADOR:-0.945556:W80.6781 +MANZANILLO, CUBA:20.2881:W77.0892 +MANZANILLO, MEXICO:19.1447:W104.559 +MANZINI, SWAZILAND:-26.5289:31.3075 +MAPUTO, MOZAMBIQUE:-25.9208:32.5725 +MARABA, BRAZIL:-5.36833:W49.1378 +MARACAIBO, VENEZUELA:10.1833:W67.5572 +MARACAY, VENEZUELA:10.2497:W67.6492 +MARADI, NIGER:13.5025:7.12667 +MARAGHEH, IRAN:37.3486:46.1261 +MARAGROSA, ECUADOR:-2.85083:W79.8036 +MARAMBIO BASE, ANTARCTICA:-64.2383:W56.6308 +MARATHON, GREECE:38.145:24.0142:97 +MARBLE HALL, SOUTH AFRICA:-24.9889:29.2831 +MARCOS JUAREZ, ARGENTINA:-32.6836:W62.1578 +MAR DEL PLATA, ARGENTINA:-37.9342:W57.5733 +MARE, NEW CALEDONIA:-21.4817:168.037 +MARGATE, SOUTH AFRICA:-30.8572:30.3428 +MARIBO, DENMARK:54.6992:11.44 +MARIBOR, SLOVENIA:46.4797:15.6861 +MARIEHAMN, FINLAND:60.1219:19.8981 +MARIEL, CUBA:23.0072:W82.7675 +MARIETTA, USA:33.9153:W84.5161 +MARILIA, BRAZIL:-22.1967:W49.9264 +MARINDUQUE, PHILIPPINES:13.3611:121.825 +MARINGA, BRAZIL:-23.4397:W51.9069 +MARIQUITA, COLOMBIA:5.2125:W74.8836 +MARISCAL ESTIGARRIBIA, PARAGUAY:-22.0447:W60.6217 +MARMANDE, FRANCE:44.4989:0.200278 +MAROANTSETRA, MADAGASCAR:-15.4367:49.6883 +MAROOCHYDORE, AUSTRALIA:-26.6033:153.091:55 +MAROUA, CAMEROON:10.4514:14.2572 +MARQUETTE, USA:46.5339:W87.5617 +MARRAKECH, MOROCCO:31.6067:W8.03611 +MARRUPA, MOZAMBIQUE:-13.225:37.5519 +MARSABIT, KENYA:2.345:37.9992 +MARSA BREGA, LIBYA:30.3781:19.5764 +MARSEILLE, FRANCE:43.29031232666344:5.397847473754878 +MARSH HARBOR, BAHAMAS:26.5114:W77.0833 +MARTINICA, ECUADOR:-1.73972:W79.6217 +MARUDI, MALAYSIA:4.17806:114.331 +MARY ESTHER, USA:30.4278:W86.6892 +MARYSVILLE, USA:39.1358:W121.436 +MASAMBA, INDONESIA:-2.55778:120.324 +MASBATE, PHILIPPINES:10.3075:123.979 +MASERU, LESOTHO:-29.3039:27.5033 +MASIRAH, OMAN:20.6753:58.8903 +MASJED SOLEIMAN, IRAN:32.0022:49.2706 +MASSENA, USA:44.9358:W74.8453 +MASTERTON, NEW ZEALAND:-40.9733:175.634 +MASVINGO, ZIMBABWE:-20.0553:30.8589 +MATADI, ZAIRE:-5.79944:13.4408 +MATAGAMI, CANADA:49.7617:W77.8028 +MATAIVA, FRENCH POLYNESIA:-14.8681:W148.717 +MATAMOROS, MEXICO:25.7697:W97.5253 +MATARAM, INDONESIA:-8.56056:116.094 +MATSUMOTO, JAPAN:36.1667:137.923 +MATSUSHIMA, JAPAN:38.4047:141.219 +MATSU, TAIWAN:26.2239:120.002 +MATSUYAMA, JAPAN:33.8272:132.7 +MATTHEW TOWN, BAHAMAS:20.975:W73.6667 +MATURIN, VENEZUELA:9.74917:W63.1522 +MAUBEUGE, FRANCE:50.3103:4.03306:18 +MAUMERE, INDONESIA:-8.64056:122.237 +MAUN, BOTSWANA:-19.9725:23.4308 +MAUPITI, FRENCH POLYNESIA:-16.4264:W152.244 +MAWLAMYINE, MYANMAR:16.4447:97.6606 +MAYAGUANA, BAHAMAS:22.3794:W73.0133 +MAYAGUEZ, PUERTO RICO:18.2556:W67.1483 +MAYO, CANADA:63.6167:W135.867 +MAZAR-I-SHARIF, AFGHANISTAN:36.7069:67.2092 +MAZATLAN, MEXICO:23.1611:W106.266 +MAZUFFARPUR, INDIA:26.12:85.3131 +MBANDAKA, ZAIRE:0.0225:18.2886 +M'BANZA-CONGO, ANGOLA:-6.26972:14.2469 +MBUJI-MAYI, ZAIRE:-6.12111:23.5689 +MCALESTER, USA:34.8822:W95.7833 +MCALLEN, USA:26.1758:W98.2386 +MCGRATH, USA:62.9528:W155.606 +MEADOW LAKE, CANADA:54.1253:W108.523 +MECHERIA, ALGERIA:33.5358:W0.242222 +MEDAN, INDONESIA:3.55806:98.6717 +MEDELLIN, COLOMBIA:6.22:W75.5906 +MEDICINE HAT, CANADA:50.0189:W110.721 +MEGARA, GREECE:37.9811:23.3653:330 +MEGIDO AIRSTRIP, ISRAEL:32.5986:35.2283 +MEINERZHAGEN, GERMANY:51.0994:7.60194:115 +MEIRINGEN, SWITZERLAND:46.7433:8.11 +MEKNES, MOROCCO:33.8789:W5.515 +MELBOURNE, AUSTRALIA:-37.7682:145.0622:15 +MELBOURNE, USA:28.1025:W80.645 +MELILLA, SPANISH NORTH AFRICA:35.2797:W2.95611 +MELO, URUGUAY:-32.3378:W54.2167 +MEMANBETSU, JAPAN:43.8806:144.164 +MEMMINGEN, GERMANY:47.9886:10.2394 +MEMPHIS, USA:35.1490215:W90.0516285:816 +MENDE, FRANCE:44.5019:3.53278 +MENDIG, GERMANY:50.3658:7.315 +MENDOZA, ARGENTINA:-32.8317:W68.7928 +MENGEN, GERMANY:48.0536:9.37278:49 +MENKIJARVI, FINLAND:62.9467:23.5189 +MENONGUE, ANGOLA:-14.6575:17.7197 +MENORCA, SPAIN:39.8625:4.21861 +MERAUKE, INDONESIA:-8.52028:140.418 +MERCEDES, ARGENTINA:-29.2231:W58.0881 +MERCED, USA:37.3803:W120.568 +MERIDA, MEXICO:20.9369:W89.6575 +MERIDA, VENEZUELA:8.58194:W71.1608 +MERIDIAN, USA:32.5519:W88.5556 +MERSA-MATRUH, EGYPT:31.3253:27.2217 +MERU, KENYA:0.230278:38.1703 +MERVILLE, FRANCE:50.6183:2.64222:26 +MERZIFON, TURKEY:40.8292:35.5219:972 +MESSINA, SOUTH AFRICA:-22.3558:29.9864 +METZADA, ISRAEL:31.3281:35.3883 +METZ, FRANCE:49.0717:6.13167:41 +MEXICALI, MEXICO:32.6306:W115.241 +MEXICO CITY, MEXICO:19.4361:W99.0719 +MFUWE, ZAMBIA:-13.2586:31.9364 +MIAMI, USA:25.804570343778067:W80.29181030761718 +MIANDRIVAZO, MADAGASCAR:-19.5628:45.4508 +MIANWALI, PAKISTAN:32.5631:71.5706 +MIDDELBURG, SOUTH AFRICA:-25.6847:29.44 +MIDDLESBROUGH, ENGLAND:54.5760419:W1.2344047:114 +MIDDLETON ISLAND, USA:59.4497:W146.309 +MIDLAND, USA:31.9425:W102.202 +MIDWAY, MIDWAY ISLAND:28.2014:W177.381 +MIELEC, POLAND:50.3222:21.4619 +MIHO, JAPAN:35.4922:133.236 +MIKKELI, FINLAND:61.6864:27.2017 +MILAN, ITALY:45.4453:9.27694:181 +MILANO, ITALY:45.5397:9.20222:181 +MILDENHALL, ENGLAND:52.3608:0.488333:36 +MILLAU, FRANCE:43.99:3.18306 +MILLINGTON, USA:35.3567:W89.8703 +MILLINOCKET, USA:45.6478:W68.6856 +MILLVILLE, USA:39.3678:W75.0722 +MILTON, USA:30.7242:W87.0219 +MILWAUKEE, USA:42.9472:W87.8964 +MIMIZAN, FRANCE:44.1461:W1.17444 +MINAMI DAITO, JAPAN:25.8464:131.263 +MINAMI TORI SHIMA, JAPAN:24.2894:153.979 +MINATITLAN, MEXICO:18.1033:W94.5806 +MINERALNYE VODY, RUSSIA:44.225:43.0817:51 +MINERAL WELLS, USA:32.7814:W98.06 +MINNA, NIGERIA:9.65194:6.46222 +MINNEAPOLIS, USA:44.8803:W93.2167 +MINOT, USA:48.2592:W101.28 +MINSK, RUSSIA:53.8644:27.5394:409 +MIRAMAR, USA:32.8683:W117.143 +MIRANSHAH, PAKISTAN:33.0125:70.0642 +MIRI, MALAYSIA:4.325:113.988 +MIR PUR KHAS, PAKISTAN:25.6825:69.0728 +MISAWA, JAPAN:40.7031:141.368 +MITU, COLOMBIA:1.25361:W70.2336 +MITZIC, GABON:0.775556:11.5525 +MIYAKE JIMA, JAPAN:34.0719:139.56 +MIYAKO, JAPAN:24.7828:125.295 +MIYAZAKI, JAPAN:31.8769:131.448 +MKUZE, SOUTH AFRICA:-27.6258:32.0442 +MMABATHO, SOUTH AFRICA:-25.7983:25.5478 +MNICHOVO HRADISTE, CZECH REPUBLIC:50.54:15.0064 +MOA, CUBA:20.6542:W74.9217 +MOANDA, GABON:-1.5375:13.2692 +MOBILE, USA:30.6264:W88.0678 +MOCAMEDES, ANGOLA:-15.2611:12.1467 +MOCIMBOA DA PRAIA, MOZAMBIQUE:-11.3617:40.3547 +MOCORD, BRAZIL:-5.20167:W37.3642 +MODESTO, USA:37.6256:W120.954 +MOENCHENGLADBACH, GERMANY:51.2303:6.50444:170 +MOENJODARO, PAKISTAN:27.335:68.1428 +MOGADISHU, SOMALIA:2.01361:45.3047 +MOHANBARI, INDIA:27.4833:95.0175 +MOHED, SWEDEN:64.9608:17.6964 +MOHELI, COMOROS ISLANDS:-12.2981:43.7664 +MOHOLM, SWEDEN:58.5981:14.1136 +MOKPO, KOREA:34.7589:126.38 +MOLDE, NORWAY:62.7461:7.2725 +MOLLIS, SWITZERLAND:47.0786:9.06472 +MOLOKAI, USA:21.1528:W157.096 +MOMBASA, KENYA:-4.03472:39.5942 +MOMEIK, MYANMAR:23.0925:96.645 +MONASTIR, TUNISIA:35.7581:10.7547 +MONA, UK:53.2583:W4.37333 +MONBETSU, JAPAN:44.3039:143.404 +MONCLOVA, MEXICO:26.9556:W101.47 +MONCTON, CANADA:46.1122:W64.6786 +MONG HSAT, MYANMAR:20.5167:99.2567 +MONGU, ZAMBIA:-15.2544:23.1622 +MONROE, USA:32.5108:W92.0375 +MONROVIA, LIBERIA:6.23361:W10.3622 +MONTALVO, ECUADOR:-2.06694:W76.9753 +MONTAUBAN, FRANCE:44.0256:1.37778 +MONTBELIARD, FRANCE:47.4869:6.7925:15 +MONT-DE-MARSAN, FRANCE:43.9117:W0.5075 +MONTE CASEROS, ARGENTINA:-30.2717:W57.64 +MONTEGO BAY, JAMAICA:18.5036:W77.9133 +MONTE REAL, ACORES:39.8311:W8.88722 +MONTERIA, COLOMBIA:8.82361:W75.8258 +MONTERREY, MEXICO:25.7783:W100.107 +MONTES CLAROS, BRAZIL:-16.7067:W43.8189 +MONTEVIDEO, URUGUAY:-34.7892:W56.2644 +MONTGOMERY, USA:32.3792:W86.3625:413 +MONTICHIARI, ITALY:45.4289:10.3306:81 +MONTIJO, ACORES:38.7036:W9.03583 +MONT JOLI, CANADA:48.6086:W68.2081 +MONTLUCON, FRANCE:46.3525:2.57028 +MONTLUCON-GUERET, FRANCE:46.2244:2.36306 +MONTPELIER, USA:44.2033:W72.5622 +MONTPELLIER, FRANCE:43.5761:3.96278 +MONTREAL, CANADA:45.4681:W73.7414 +MOOREA, FRENCH POLYNESIA:-17.4897:W149.762 +MOOSE JAW, CANADA:50.3303:W105.559 +MOOSONEE, CANADA:51.2911:W80.6078 +MOPTI, MALI:14.5128:W4.07944 +MOQUEGUA, PERU:-17.1789:W70.9306 +MORA, SWEDEN:60.9578:14.5111 +MORELIA, MEXICO:19.8497:W101.025 +MORLAIX, FRANCE:48.6031:W3.81556:24 +MOROMBE, MADAGASCAR:-21.7536:43.3753 +MORON, ARGENTINA:-34.6761:W58.6425 +MORONDAVA, MADAGASCAR:-20.2847:44.3175 +MORONI, COMOROS ISLANDS:-11.5336:43.2717 +MOSCOW, RUSSIA:55.7504461:37.6174943:2511 +MOSHI, TANZANIA:-3.36278:37.3233 +MOSJOEN, NORWAY:65.7839:13.2147 +MOSTAR, BOSNIA-HERCEGOVINA:43.2828:17.8458 +MOULINS, FRANCE:46.5344:3.42361 +MOUNDOU, CHAD:8.62028:16.0683 +MOUNTAIN HOME, USA:43.0433:W115.872 +MOUNTAIN VIEW, USA:37.415:W122.048 +MOUNT CLEMENS, USA:42.6128:W82.8317 +MOUNT COOK, NEW ZEALAND:-43.765:170.133 +MOUNT HAGEN, PAPUA NEW GUINEA:-5.82611:144.296 +MOUNT ISA, AUSTRALIA:-20.6639:139.489:62 +MOYALE LOWER, KENYA:3.46972:39.1014 +MOYOBAMBA, PERU:-6.01889:W76.9883 +MTWARA, TANZANIA:-10.3389:40.1817 +MUANDA, ZAIRE:-5.93083:12.3517 +MUDANJIANG, CHINA:44.5239:129.569:2495 +MUEDA, MOZAMBIQUE:-11.6728:39.5631 +MUEHLHAUSEN, GERMANY:51.3628:11.9408:130 +MUENSTER/OSNABRUECK, GERMANY:52.1344:7.68472:302 +MUFULIRA, ZAMBIA:-12.5647:28.2986 +MUIR, USA:40.4347:W76.5692 +MUKO MUKO, INDONESIA:-2.54222:101.088 +MULHOUSE, FRANCE:47.5894:7.52972:22 +MULTAN, PAKISTAN:30.2031:71.4189 +MUNICH, GERMANY:48.1371079:11.5753822:310 +MURCIA, SPAIN:37.7747:W0.812222 +MURMANSK, RUSSIA:68.7817:32.7506:154 +MURUROA, FRENCH POLYNESIA:-21.8083:W138.794 +MUSCAT, OMAN:23.5931:58.2844 +MUSKOGEE, USA:35.6575:W95.3614 +MUSKOKA, CANADA:44.9747:W79.3033 +MUSTIQUE, ST.VINCENT/GRENADINES:12.8875:W61.18 +MUS, TURKEY:38.7544:41.6611:2604 +MUTARE, ZIMBABWE:-18.9775:32.4506 +MUTOKO, ZIMBABWE:-17.4317:32.1844 +MUZAFFARABAD, PAKISTAN:34.3383:73.5083 +MWADUI, TANZANIA:-3.51417:33.6189 +MWANZA, TANZANIA:-2.44444:32.9325 +MYEIK, MYANMAR:12.4433:98.6211 +MYITKYINA, MYANMAR:25.3825:97.3528 +MYKONOS, GREECE:37.435:25.3481:85 +MYRTLE BEACH, USA:33.6797:W78.9283 +MYTILINI, GREECE:39.0567:26.5983:15 +MZUZU, MALAWI:-11.4447:34.0117 +NABIRE, INDONESIA:-3.36806:135.496 +NACALA, MOZAMBIQUE:-14.4881:40.7122 +NADOR, MOROCCO:35.1533:W2.92 +NADZAB, PAPUA NEW GUINEA:-6.56972:146.726 +NAGA, PHILIPPINES:13.5853:123.271 +NAGARJUNSAGAR, INDIA:16.5408:79.3178 +NAGASAKI, JAPAN:32.9225:129.923 +NAGOYA, JAPAN:35.255:136.924 +NAGPUR, INDIA:21.0919:79.0469 +NAHA, JAPAN:26.1956:127.646 +NAINITAL, INDIA:29.0331:79.4736 +NAIROBI, KENYA:-1.27722:36.8622 +NAIVASHA, KENYA:-0.787778:36.4333 +NAKASHIBETSU, JAPAN:43.5772:144.96 +NAKHON PATHOM, THAILAND:14.1019:99.9169 +NAKHON PHANOM, THAILAND:17.3836:104.643 +NAKHON RATCHASIMA, THAILAND:14.9342:102.079 +NAKHON SAWAN, THAILAND:15.2772:100.296 +NAKHON SI THAMMARAT, THAILAND:8.47111:99.9556 +NAKINA, CANADA:50.1828:W86.6964 +NAMEST, CZECH REPUBLIC:49.1658:16.1247 +NAMPONG, MYANMAR:25.3542:97.295 +NAMPULA, MOZAMBIQUE:-15.1056:39.2817 +NAMSANG, MYANMAR:20.89:97.7361 +NANAIMO, CANADA:49.0522:W123.87 +NANCHANG, CHINA:28.6:115.917:7194 +NANCY, FRANCE:48.6919:6.23028:15 +NANDI, FIJI:-17.7544:177.443 +NANGAPINOH, INDONESIA:-0.348611:111.748 +NANGIS, FRANCE:48.5936:3.005 +NANISIVIK, CANADA:72.9822:W84.6136 +NANJING, CHINA:31.74:118.86:6596 +NANKI-SHIRAHAMA, JAPAN:33.6622:135.364 +NANNING, CHINA:22.6081:108.172:22189 +NANTES, FRANCE:47.1531:W1.61056:65 +NANTUCKET, USA:41.2528:W70.06 +NANYUKI, KENYA:-0.0608333:37.0386 +NAPLES, ITALY:40.8858:14.2906:119 +NAPUKA ISLAND, FRENCH POLYNESIA:-14.1767:W141.267 +NARATHIWAT, THAILAND:6.51972:101.743 +NARSSARSSUAQ, GREENLAND:61.1611:W45.4275 +NASHVILLE, USA:36.1244:W86.6781:1231 +NASIK ROAD, INDIA:19.9625:73.8069 +NASSAU, BAHAMAS:25.0389:W77.4661 +NATAL, BRAZIL:-5.91111:W35.2478 +NATASHQUAN, CANADA:50.1897:W61.7892 +NATUNA, INDONESIA:3.90861:108.388 +NAUSHKI, PAKISTAN:29.5378:66.0222 +NAUSORI, FIJI:-18.0431:178.559 +NAVEGANTES, BRAZIL:-26.88:W48.6514 +NAWABSHAH, PAKISTAN:26.2192:68.39 +NAWAPARA, INDIA:20.87:82.5194 +NAZCA, PERU:-14.8539:W74.9614 +N'DELE, CENTRAL AFRICAN REP.:8.42667:20.635 +N'DJAMENA, CHAD:12.1336:15.0339 +NDOLA, ZAMBIA:-12.9981:28.6647 +NECOCHEA, ARGENTINA:-38.4894:W58.8158 +NEGAGE, ANGOLA:-7.75444:15.2875 +NEIVA, COLOMBIA:2.95:W75.2939 +NEJRAN, SAUDI ARABIA:17.6114:44.4192 +NELSON, NEW ZEALAND:-41.2983:173.221 +NELSPRUIT, SOUTH AFRICA:-25.5006:30.9133 +NEMA, MAURITANIA:16.6219:W7.31444 +NEPALGUNJ, NEPAL:28.1036:81.6669 +NERLERIT INAAT, GREENLAND:70.7394:W22.6458 +NEUBRANDENBURG, GERMANY:51.3281:12.6567:85 +NEUBURG, GERMANY:48.7108:11.2111 +NEUQUEN, ARGENTINA:-38.9489:W68.1556 +NEVATIM, ISRAEL:31.2083:35.0122 +NEVERS, FRANCE:47.0011:3.11444:17 +NEVSEHIR, TURKEY:38.7717:34.5342:5467 +NEWARK, USA:40.6922:W74.1686 +NEW BERN, USA:35.0728:W77.0428 +NEW BIGHT, BAHAMAS:24.315:W75.4539 +NEWBURGH, USA:41.5039:W74.1047 +NEWCASTLE, ENGLAND:54.9738474:W1.6131572:114 +NEWCASTLE, SOUTH AFRICA:-27.7706:29.9767 +NEW ORLEANS, USA:29.9933:W90.2578:439 +NEW PLYMOUTH, NEW ZEALAND:-39.0086:174.179 +NEWPORT NEWS, USA:37.1317:W76.4928 +NEWQUAI, ENGLAND:50.4406:W4.99528 +NEW YORK, USA:40.7127281:W74.0060152:784 +N'GAOUNDERE, CAMEROON:7.35694:13.5592 +NGERENGERE, TANZANIA:-6.71722:38.1536 +N'GIVA, ANGOLA:-17.0447:15.6869 +NHATRANG, VIET NAM:12.2181:109.2 +NIAGARA FALLS, USA:43.1072:W78.9461 +NIAMEY, NIGER:13.4814:2.18361 +NIATOUGOU, TOGO:9.76722:1.09111 +NICARO, CUBA:20.6886:W75.5314 +NICE, FRANCE:43.71335521461014:7.2626405438720765 +NIEDERSTETTEN, GERMANY:49.3917:9.95806 +NIMES, FRANCE:43.7572:4.41611:161 +NINBO, CHINA:29.8247:121.465:9816 +NIORO, MALI:15.2386:W9.57639 +NIORT, FRANCE:46.3111:W0.401389 +NIZHNEVARTOVSK, RUSSIA:60.95:76.4667:271 +NOERVENICH, GERMANY:50.8311:6.65806 +NOGALES, MEXICO:31.2258:W110.976 +NOGALES, USA:31.4175:W110.848 +NOME, USA:64.5119:W165.445 +NORDERNEY, GERMANY:53.7067:7.23:26 +NORDHOLZ, GERMANY:53.7675:8.65833 +NORFOLK, USA:36.8944:W76.2011 +NORMAN'S CAY, BAHAMAS:24.5944:W76.8319 +NORMAN WELLS, CANADA:65.2825:W126.8 +NORRKOEPING, SWEDEN:58.5861:16.2506 +NORTH BATTLEFORD, CANADA:52.7692:W108.244 +NORTH BAY, CANADA:46.3636:W79.4228 +NORTH CAICOS, TURKS & CAICOS I.:21.9172:W71.9394 +NORTH ELEUTHERA, BAHAMAS:25.4756:W76.6811 +NORTHWAY, USA:62.9611:W141.929 +NORWICH, ENGLAND:52.628606:1.29227:52 +NOSARA BEACH, COSTA RICA:9.97639:W85.6528 +NOSHAHR, IRAN:36.6633:51.4647 +NOSY-BE, MADAGASCAR:-13.3122:48.3139 +NOTODDEN, NORWAY:59.5656:9.21167 +NOTTINGHAM, ENGLAND:52.9534193:W1.1496461:176 +NOUADHIBOU, MAURITANIA:20.9283:W17.0311 +NOUAKSCHOTT, MAURITANIA:18.0978:W15.9478 +NOUMEA, NEW CALEDONIA:-22.0144:166.213 +NUERNBERG, GERMANY:49.453872:11.077298:186 +NUEVA GERONA, CUBA:21.8347:W82.7839 +NUEVO CASAS GRANDES, MEXICO:30.3972:W107.875 +NUEVO LAREDO, MEXICO:27.4439:W99.5703 +NUKU HIVA, FRENCH POLYNESIA:-8.79556:W140.229 +NUKUS, RUSSIA:42.4864:59.6225:222 +NUMMELA, FINLAND:60.3339:24.2964 +NYALA, SUDAN:12.0536:24.9553 +NYERI, KENYA:-0.368889:36.98 +NYIRREGYHAZA, HUNGARY:47.9839:21.6922:274 +NYLSTROOM, SOUTH AFRICA:-24.6858:28.4347 +NYUTABARU, JAPAN:32.0836:131.452 +N'ZEREKORE, GUINEA:7.80583:W8.70167 +OAKEY, AUSTRALIA:-27.4114:151.735:127 +OAKLAND, USA:37.7211:W122.221 +OAMARU, NEW ZEALAND:-44.97:171.082 +OAXACA, MEXICO:16.9997:W96.7264 +OBERA, ARGENTINA:-27.5181:W55.1239 +OBIDOS TIRIOS, BRAZIL:2.22333:W55.9458 +OBIHIRO, JAPAN:42.7333:143.217 +OCANA, COLOMBIA:8.31472:W73.3583 +OCANA, SPAIN:39.9375:W3.50333 +OCEANA, USA:36.8206:W76.0333 +OCHO RIOS, JAMAICA:18.4042:W76.9689 +OCSENY, HUNGARY:46.3039:18.7692:72 +ODENSE, DENMARK:55.4761:10.3292 +ODESSA, RUSSIA:46.4269:30.6781:162 +OGDENSBURG, USA:44.6819:W75.4656 +OGDEN, USA:41.1239:W111.973 +OHAKEA, NEW ZEALAND:-40.2058:175.388 +OHRID, FORMER MACEDONIA:41.1797:20.7422 +OIOIAPOQUE, BRAZIL:3.85528:W51.7967 +OITA, JAPAN:33.4794:131.737 +OKARA, PAKISTAN:30.7408:73.3575 +OKAYAMA, JAPAN:34.7569:133.856 +OKHA, RUSSIA:53.515:142.888:15254 +OKIERABU, JAPAN:27.4253:128.701 +OKI ISLAND, JAPAN:36.1811:133.325 +OKLAHOMA CITY, USA:35.3931:W97.6006:1571 +OKONDJA, GABON:-0.665278:13.6731 +OLAVARRIA, ARGENTINA:-36.8908:W60.2161 +OLBIA, ITALY:40.8986:9.5175:383 +OLD CROW, CANADA:67.5706:W139.839 +OLIKTOK POINT, USA:70.4994:W149.879 +OMAHA, USA:41.1183:W95.9125 +OMBOUE HOSPIAL, GABON:-1.57472:9.26278 +OMIDYEH, IRAN:30.835:49.5347 +OMSK, RUSSIA:54.9669:73.31:573 +ONTARIO, USA:34.0558:W117.601 +OPOLU, USA:20.265:W155.86 +OPTAND, SWEDEN:63.1286:14.8028 +ORADEA, ROMANIA:47.0253:21.9025 +ORAN, ALGERIA:35.5422:W0.532222 +ORAN, ARGENTINA:-23.1528:W64.3292 +ORANGE, FRANCE:44.1371311:4.8078783 +ORANJESTAD, ANTILLES:17.4964:W62.9792 +ORANJESTAD, ARUBA:12.5014:W70.015 +ORAPA, BOTSWANA:-21.2664:25.3203 +OREBRO, SWEDEN:59.2236:15.0378 +ORENBURG, RUSSIA:51.7956:55.4567:259 +ORIXIMINA, BRAZIL:-1.48944:W56.3967 +ORLAND, NORWAY:63.6989:9.60389 +ORLANDO, USA:28.5421109:W81.3790304:265 +ORLEANS, FRANCE:47.8969:2.16333:27 +ORMARA, PAKISTAN:25.2731:64.5883 +ORMOC, PHILIPPINES:11.0558:124.566 +ORNSKOLDSVIK, SWEDEN:63.4083:18.99 +ORSA, SWEDEN:61.19:14.7125 +ORURO, BOLIVIA:-17.9633:W67.0761 +OSAKA, JAPAN:34.5961:135.603 +OSAN, KOREA:37.0906:127.029 +OSCODA, USA:44.4514:W83.3939 +OSHIMA, JAPAN:34.7844:139.361 +OSH, RUSSIA:40.6089:72.7931:182 +OSIJEK, CROATIA:45.4625:18.8114 +OSKARSHAMN, SWEDEN:57.3503:16.4978 +OSLO, NORWAY:59.8956:10.6169 +OSORNO, CHILE:-40.6114:W73.0603 +OSTEND, BELGIUM:51.1989:2.86222 +OSTERSUND, SWEDEN:63.1939:14.5019 +OSTRAVA, CZECH REPUBLIC:49.6964:18.1111 +OTTAWA, CANADA:45.3225:W75.6692 +OTU, COLOMBIA:7.01028:W74.7153 +OUAGADOUGOU, BURKINA FASO:12.3531:W1.51222 +OUARGLA, ALGERIA:31.9172:5.41278 +OUARZAZATE, MOROCCO:30.9389:W6.90917 +OUDTSHOORN, SOUTH AFRICA:-33.6069:22.1889 +OUESSANT, FRANCE:48.4628:W5.06389 +OUESSO, CONGO:1.61583:16.0378 +OUJDA, MOROCCO:34.7869:W1.92389 +OULU, FINLAND:64.93:25.3544 +OUVEA, NEW CALEDONIA:-20.6406:166.573 +OVAR, PORTUGAL:40.9158:W8.64583 +OVDA, ISRAEL:29.94:34.9358 +OVERBERG, SOUTH AFRICA:-34.5553:20.2503 +OWANDO, CONGO:-0.531111:15.95 +OXFORD, ENGLAND:51.7520131:W1.2578499:37 +OYEM, GABON:1.54111:11.5808 +OZUKI, JAPAN:34.0453:131.052 +PACHUCA, MEXICO:20.0772:W98.7822 +PADANG, INDONESIA:-0.876111:100.352 +PADANG SIDEMPUAN, INDONESIA:1.39917:99.4319 +PADERBORN, GERMANY:51.6142:8.61611:179 +PADOVA, ITALY:45.3956:11.8478:92 +PAGO PAGO, SAMOA:-14.3308:W170.71 +PAILTON, UK:51.6681:W2.05694 +PAKSE, LAOS:15.1319:105.781 +PALA, CHAD:9.37917:14.9258 +PALACIOS, USA:28.7275:W96.2508 +PALANGKARAYA, INDONESIA:-2.225:113.943 +PALEMBANG, INDONESIA:-2.89778:104.701 +PALERMO, ITALY:38.1758:13.0908:158 +PALMA DE MALLORCA, SPAIN:39.5517:2.73861 +PALMARITO, VENEZUELA:7.57556:W70.1742 +PALMAR SUR, COSTA RICA:8.95083:W83.4683 +PALMDALE, USA:34.6292:W118.084 +PALMERSTON NORTH, NEW ZEALAND:-40.3206:175.617 +PALMER, USA:61.5947:W149.089 +PALM SPRINGS, USA:33.8294:W116.507 +PALMYRA, SYRIA:34.5572:38.3167 +PALU, INDONESIA:-0.918333:119.909 +PAMIERS, FRANCE:43.0906:1.69583 +PAMPLONA, SPAIN:42.77:W1.64611 +PANAGARH, INDIA:23.4744:87.4278 +PANAMA CITY, PANAMA:9.07111:W79.3833 +PANAMA CITY, USA:30.0697:W85.5764 +PANAMA, PANAMA:8.97333:W79.5556 +PANDORA, COSTA RICA:9.73194:W82.9831 +PANGKALAN BUN, INDONESIA:-2.705:111.673 +PANGKAL PINANG, INDONESIA:-2.16278:106.139 +PANGNIRTUNG, CANADA:66.145:W65.7136 +PANJGUR, PAKISTAN:26.9544:64.1325 +PAPA, HUNGARY:47.3639:17.5008:91 +PAPHOS, CYPRUS:34.7178:32.4856 +PARACHINAR, PAKISTAN:33.9025:70.0714 +PARAGUANA, VENEZUELA:11.7808:W70.1517 +PARAKOU, BENIN:9.35694:2.60889 +PARAMARIBO, SURINAM:5.81083:W55.1906 +PARANA, ARGENTINA:-31.7947:W60.4803 +PARAPARAUMU, NEW ZEALAND:-40.9047:174.989 +PARCHIM, GERMANY:53.4269:11.7833:124 +PARDUBICE, CZECH REPUBLIC:50.0133:15.7386 +PARIS, FRANCE:48.8566969:2.3514616:105 +PARMA, ITALY:44.8244:10.2961:260 +PARNAIBA, BRAZIL:-2.89361:W41.7319 +PARNU, ESTONIA:58.4189:24.4728 +PARO, BHUTAN:27.4031:89.4258 +PARSABAD, IRAN:39.6036:47.8811 +PARYS, SOUTH AFRICA:-26.8892:27.5033 +PASIGHAT, INDIA:28.0661:95.3356 +PASNI, PAKISTAN:25.2836:63.3328 +PASO DE LOS LIBRES, ARGENTINA:-29.6892:W57.1519 +PASSO FUNDO, BRAZIL:-28.2439:W52.3264 +PASTO, COLOMBIA:1.39639:W77.2908 +PATHANKOT, INDIA:32.2336:75.6344 +PATHEIN, MYANMAR:16.8128:94.7753 +PATIALA, INDIA:30.315:76.3633 +PATINA, INDIA:25.5906:85.0878 +PATRAS, GREECE:38.1511:21.4256:125 +PATREKSFJORDUR, ICELAND:65.5558:W23.965 +PATTANI, THAILAND:6.78528:101.153 +PATTIJOKI, FINLAND:64.6881:24.6958 +PATUXENT RIVER, USA:38.2858:W76.4117 +PAU, FRANCE:43.38:W0.418611 +PAULO ALFONSO, BRAZIL:-9.40083:W38.2506 +PAYA LEBAR, SINGAPORE:1.36028:103.909 +PAYERNE, SWITZERLAND:46.8431:6.915 +PAYSANDU, URUGUAY:-32.365:W58.0611 +PEACE RIVER, CANADA:56.2269:W117.447 +PEDERNALES, ECUADOR:0.0730556:W80.0522 +PEHUAJO, ARGENTINA:-35.8456:W61.8578 +PEKANBARU, INDONESIA:0.461111:101.444 +PELLY BAY, CANADA:68.5344:W89.8081 +PELOTAS, BRAZIL:-31.7183:W52.3275 +PEMBA, MOZAMBIQUE:-12.9867:40.5222 +PEMBA, TANZANIA:-5.25722:39.8114 +PEMBINA, USA:48.9425:W97.2408 +PENANG, MALAYSIA:5.29694:100.277 +PENDORO, INDONESIA:-3.28583:103.879 +PENSACOLA, USA:30.3525:W87.3186 +PENTICTON, CANADA:49.4628:W119.602 +PENZA, RUSSIA:28.9542:W98.5197:304 +PEREIRA, COLOMBIA:4.8125:W75.7394 +PERIGUEUX, FRANCE:45.1981:0.815556 +PERM, RUSSIA:57.9167:56.0256:803 +PERONNE, FRANCE:49.8689:3.02778 +PERPIGNAN, FRANCE:42.7403:2.87056 +PERTH, AUSTRALIA:-32.0972:115.881:1000 +PERUGIA, ITALY:43.0958:12.5131:449 +PERU, USA:40.6481:W86.1519 +PESCARA, ITALY:42.4314:14.1808:33 +PESHAWAR, PAKISTAN:33.9939:71.5144 +PETAWAWA, CANADA:45.9522:W77.3192 +PETERBOROUGH, CANADA:44.23:W78.3633 +PETERBOROUGH, ENGLAND:52.5725769:W0.2427336:44 +PETROLINA, BRAZIL:-9.36389:W40.5639 +PETROLINE 10, SAUDI ARABIA:24.1072:41.0358 +PETROLINE 3, SAUDI ARABIA:25.1744:47.4883 +PETROLINE 6, SAUDI ARABIA:24.7103:44.9644 +PETROPAVLOVSK, RUSSIA:53.1664:158.453:400 +PEVEK, RUSSIA:67.5:171:60 +PHALABORWA, SOUTH AFRICA:-23.9369:31.1553 +PHALSBOURG, FRANCE:48.7661:7.20028:13 +PHETCHABUN, THAILAND:16.8208:101.254 +PHILADELPHIA, USA:39.8719:W75.2411 +PHILIPSBURG, ANTILLES:18.0408:W63.1089 +PHITSANULOK, THAILAND:16.7828:100.279 +PHNOM-PENH, CAMBODIA:11.5464:104.844 +PHOENIX, USA:33.535:W112.383:1338 +PHONG SAVANH, LAOS:19.4547:103.218 +PHRAE, THAILAND:18.1319:100.164 +PHUKET, THAILAND:8.11306:98.3167 +PIACENZA, ITALY:44.9131:9.72333:118 +PICKLE LAKE, CANADA:51.4464:W90.2142 +PICO, ACORES:38.5544:W28.4397 +PIEDRAS NEGRAS, MEXICO:28.6272:W100.535 +PIESTANY, SLOVAKIA:48.625:17.8283 +PIETERMARITZBURG, SOUTH AFRICA:-29.6489:30.3986 +PIETERSBURG, SOUTH AFRICA:-23.8453:29.4586 +PIIKAJARVI, FINLAND:61.2456:22.1933 +PILANESBERG, SOUTH AFRICA:-25.3336:27.1733 +PILAR, PARAGUAY:-26.8814:W58.3178 +PINAR DEL RIO NORTE, CUBA:22.4211:W83.6775 +PINE BLUFF, USA:34.1747:W91.9344 +PINGTUNG, TAIWAN:22.6722:120.462 +PIRACUNUNGA, BRAZIL:-21.9853:W47.3381 +PISA, ITALY:43.6839:10.3925:185 +PISCO, PERU:-13.7447:W76.2203 +PITALITO, COLOMBIA:1.85833:W76.0858 +PITEA, SWEDEN:65.3994:21.2653 +PITT MEADOWS, CANADA:49.2161:W122.71 +PITTSBURGH (PENNSYLVA), USA:40.4914:W80.2328 +PIURA, PERU:-5.20556:W80.6164 +PLAISANCE, MAURITIUS:-20.43:57.6833 +PLATTSBURGH, USA:44.6508:W73.4681 +PLETTENBERG BAY, SOUTH AFRICA:-34.0903:23.3278 +PLOVDIV, BULGARIA:42.0678:24.8508 +PLYMOUTH, ENGLAND:50.38433871605018:W4.142479969311532:79 +PLYMOUTH, MONTSERRAT ISLAND:33.5869:W80.2086 +POCOS DE CALDAS, BRAZIL:-21.8428:W46.5678 +PODGORICA, YUGOSLAVIA:42.3592:19.2517 +POHANG, KOREA:35.9878:129.42 +POHNPEI, MICRONESIA:6.985:158.209 +POINT BARROW, USA:56.5783:W169.661 +POINT COOK, AUSTRALIA:-37.9322:144.753:9 +POINTE-A-PITRE, ANTILLES:16.2653:W61.5317 +POINTE-NOIRE, CONGO:-4.81333:11.8858 +POINT LAY, USA:69.7328:W163.005 +POINT MUGU, USA:34.1203:W119.121 +POINT SALINES, GRENADA:12.0042:W61.7861 +POITIERS, FRANCE:46.5875:0.306667 +POKHARA, NEPAL:28.2003:83.9811 +POMFRET, SOUTH AFRICA:-25.8469:23.5378 +PONCA CITY, USA:36.7306:W97.0997 +PONCE, PUERTO RICO:18.0081:W66.5628 +POND INLET, CANADA:72.6833:W77.9667 +PONGGALUKU, INDONESIA:-4.31667:122.467 +PONTA DELGADA, ACORES:37.7411:W25.6978 +PONTA GROSSA, BRAZIL:-25.1844:W50.1439 +PONTA PORA, BRAZIL:-22.5494:W55.7025 +PONTARLIER, FRANCE:46.9044:6.32694:41 +PONTIANAK, INDONESIA:-0.150556:109.404 +PONTIVY, FRANCE:48.0583:W2.92167 +POPAYAN, COLOMBIA:2.45417:W76.61 +POPRAD, SLOVAKIA:49.0733:20.2408 +POPTUN, GUATEMALA:16.3261:W89.4169 +PORBANDAR, INDIA:21.6494:69.6564 +PORI, FINLAND:61.4617:21.7997 +PORLAMAR, VENEZUELA:10.9125:W63.9664 +PORTAGE-LA-PRAIRIE, CANADA:49.9028:W98.2747 +PORT ANGELES, USA:48.1414:W123.414 +PORT ANTONIO, JAMAICA:18.1986:W76.5344 +PORT-AU-PRINCE, HAITI:18.58:W72.2925 +PORT BLAIR, INDIA:11.6456:92.7331 +PORT ELIZABETH, SOUTH AFRICA:-33.9847:25.6172 +PORT GENTIL, GABON:-0.711667:8.75417 +PORT HARDY, CANADA:50.6806:W127.367 +PORT HARTCOURT, NIGERIA:5.01528:6.94944 +PORT HURON, USA:42.9108:W82.5286 +PORTIMAO, ACORES:37.1492:W8.58389 +PORTLAND, USA:45.5886:W122.597:346 +PORT MENIER, CANADA:49.8364:W64.2886 +PORT MOLLER, USA:59.0111:W161.819 +PORT MORESBY, PAPUA NEW GUINEA:-9.44333:147.22 +PORTO, ACORES:41.2481:W8.68139 +PORTO ALEGRE, BRAZIL:-29.9458:W51.1444 +PORTO AMBOIM, ANGOLA:-10.7219:13.7653 +PORT-OF-SPAIN, TRINIDAD & TOBAGO:10.5953:W61.3372 +PORTO NACIONAL, BRAZIL:-10.7192:W48.3997 +PORTOROZ, SLOVENIA:45.4733:13.6147 +PORTO SANTO, MADEIRA:33.0733:W16.3497 +PORTO VELHO, BRAZIL:-8.70917:W63.9022 +PORTOVIEJO, ECUADOR:-1.04139:W80.4719 +PORT SAID, EGYPT:31.2794:32.24 +PORT SAINT JOHNS, SOUTH AFRICA:-31.6058:29.5197 +PORT SUDAN, SUDAN:19.5764:37.2158 +PORT-VILA, VANUATU:-17.6992:168.32 +PORVENIR, CHILE:-53.2536:W70.3192 +POSADAS, ARGENTINA:-27.3858:W55.9706 +POSO, INDONESIA:-1.41667:120.657 +POTCHEFSTROOM, SOUTH AFRICA:-26.6708:27.0817 +POTGIETERSRUS, SOUTH AFRICA:-24.2303:28.9836 +POTOSI, BOLIVIA:-19.5433:W65.7239 +POZA RICO, MEXICO:20.6025:W97.4608 +POZNAN, POLAND:52.4211:16.8264 +PRACHIN BURI, THAILAND:13.7686:102.315 +PRACHUAP KHIRI KHAN, THAILAND:12.6339:99.9508 +PRAGUE, CZECH REPUBLIC:50.1008:14.26 +PRAHA, CZECH REPUBLIC:50.1211:14.5436 +PRASLIN, SEYCHELLES:-4.31917:55.6914 +PREROV, CZECH REPUBLIC:49.4258:17.4047 +PRESCHEN, GERMANY:51.6636:14.6336 +PRESCOTT, USA:34.6544:W112.419 +PRESIDENCIA R.S.PENA, ARGENTINA:-26.7564:W60.4931 +PRESIDENT PRUDENTE, BRAZIL:-22.175:W51.4244 +PRESQUE ISLE, USA:46.6889:W68.0447 +PRESTON, ENGLAND:53.76409:W2.70456 +PRESTWICK, UK:55.5078:W4.58667:20 +PRETORIA, SOUTH AFRICA:-25.6536:28.2242 +PREVEZA, GREECE:38.9253:20.7653:381 +PRIBRAM, CZECH REPUBLIC:49.7186:14.0969 +PRINCE ALBERT, CANADA:53.2142:W105.673 +PRINCE GEORGE, CANADA:53.8894:W122.679 +PRINCE PUPERT, CANADA:54.2861:W130.445 +PRINCETON, CANADA:49.4675:W120.512 +PRINCETON, USA:45.5597:W93.6081 +PRINCIPE, SAO TOME & PRINCIPE:1.66278:7.41167 +PRISTINA, YUGOSLAVIA:42.5728:21.0358 +PROPRIANO, FRANCE:41.6631:8.89028 +PROSSERPINE, AUSTRALIA:-20.495:148.552:25 +PROVIDENCE, USA:41.7239:W71.4281 +PROVIDENCIA, COLOMBIA:13.3569:W81.3583 +PROVIDENCIALES, TURKS & CAICOS I.:21.7736:W72.2658 +PUCALLPA, PERU:-8.37778:W74.5742 +PUDASJARVI, FINLAND:65.4022:26.9469 +PUEBLA, MEXICO:19.1581:W98.3714 +PUEBLO MEMORIAL, USA:38.2889:W104.496 +PUERTO ASIS, COLOMBIA:9.4424024:W75.1379851 +PUERTO AYACUCHO, VENEZUELA:5.61972:W67.6058 +PUERTO BARRIOS, GUATEMALA:15.7308:W88.5836 +PUERTO BOLIVAR, COLOMBIA:12.2214:W71.9847 +PUERTO CABELLO, VENEZUELA:10.4803:W68.0728 +PUERTO CABEZAS, NICARAGUA:14.0469:W83.3867 +PUERTO CARRENO, COLOMBIA:6.18444:W67.4931 +PUERTO DESEADO, ARGENTINA:-47.7353:W65.9039 +PUERTO ESCONDIDO, MEXICO:15.8767:W97.0889 +PUERTO ESPERANZA, PERU:-9.76806:W70.7064 +PUERTO LEMPIRA, HONDURAS:15.2608:W83.7814 +PUERTO MADRYN, ARGENTINA:-42.7589:W65.1025 +PUERTO MALDONADO, PERU:-12.6136:W69.2289 +PUERTO MONTT, CHILE:-41.4386:W73.0939 +PUERTO PLATA, DOMINICAN REPUBLIC:19.7572:W70.5697 +PUERTO PRINCESA, PHILIPPINES:9.74194:118.759 +PUERTO SUAREZ, BOLIVIA:-18.9753:W57.8206 +PUERTO VALLARTA, MEXICO:20.68:W105.254 +PUERTO WILLIAMS, CHILE:-54.9308:W67.6261 +PUKAKI, NEW ZEALAND:-44.235:170.118 +PUKA PUKA, FRENCH POLYNESIA:-14.8094:W138.813 +PULA, CROATIA:44.8933:13.9219 +PULAU, MALAYSIA:6.32972:99.7286 +PULAU PIOMAN, MALAYSIA:2.81806:104.16 +PULLENSHOPE, SOUTH AFRICA:-25.9794:29.6186 +PUNE, INDIA:18.5819:73.9194 +PUNTA ARENAS, CHILE:-53.0028:W70.8547 +PUNTA CANA, DOMINICAN REPUBLIC:18.5672:W68.3633 +PUNTA DEL ESTE, URUGUAY:-34.9136:W54.9206 +PUNTA INDIO, ARGENTINA:-35.3478:W57.2939 +PUNTA PENASCO, MEXICO:31.3517:W113.526 +PURNEA, INDIA:25.76:87.4092 +PUTAO, MYANMAR:27.3297:97.4261 +PUTUSIBAU, INDONESIA:0.835833:112.936 +PYAY, MYANMAR:18.8244:95.2658 +PYHASALMI, FINLAND:63.7317:25.9261 +PYONGTAEK, KOREA:36.9606:127.033 +PYONGYANG, KOREA:39.0333:125.783:2000 +QASIM, PAKISTAN:33.5611:73.0319 +QINGDAO, CHINA:36.2625:120.375:1632 +QUANTICO, USA:38.5017:W77.3053 +QUEBEC, CANADA:46.7883:W71.3975 +QUEENSTOWN INTERNATIONAL, NEW ZEALAND:-45.0211:168.739 +QUEENSTOWN, SOUTH AFRICA:-31.92:26.8819 +QUELIMANE, MOZAMBIQUE:-17.8556:36.8692 +QUEPOS, COSTA RICA:9.44306:W84.1297 +QUERETARO, MEXICO:20.6239:W100.369 +QUESNEL, CANADA:53.0261:W122.51 +QUETTA, PAKISTAN:30.2511:66.9375 +QUEZALTENANGO, GUATEMALA:14.8653:W91.5019 +QUIBDO, COLOMBIA:5.69056:W76.6411 +QUIMPER, FRANCE:47.9747:W4.16778:84 +QUITO, ECUADOR:-0.141111:W78.4881 +QUUJJUAQ, CANADA:58.0961:W68.4269 +RABAT, MOROCCO:34.0514:W6.75139 +RABIGH, SAUDI ARABIA:22.7025:39.0697 +RADA, SWEDEN:58.4981:13.0531 +RAFHA, SAUDI ARABIA:29.6264:43.4906 +RAFSANJAN, IRAN:30.2978:56.0519 +RAHIM YAR KHAN, PAKISTAN:28.3853:70.2797 +RAIATEA ISLAND, FRENCH POLYNESIA:-16.7228:W151.466 +RAIBARELLI, INDIA:26.2503:81.3806 +RAIPUR, INDIA:21.1803:81.7386 +RAJAHMUNDRY, INDIA:17.1094:81.8183 +RAJKOT, INDIA:22.3092:70.7794 +RAJSHAHI, BANGLADESH:24.4369:88.6164 +RALEIGH-DURHAM, USA:35.8775:W78.7872 +RAMAT DAVID, ISRAEL:32.6603:35.1822 +RAMON, ISRAEL:30.7761:34.6667 +RAMSAR, IRAN:36.9097:50.6794 +RAMSTEIN, GERMANY:49.4375:7.60139 +RANCAGUA, CHILE:-34.1736:W70.7756 +RANCHI, INDIA:23.3147:85.3214 +RANCHO MURIETA, USA:38.4886:W121.102 +RANGIROA, FRENCH POLYNESIA:-14.9542:W147.661 +RANKIN INLET, CANADA:62.8114:W92.1158 +RANONG, THAILAND:9.7775:98.5853 +RANTASALMI, FINLAND:62.0653:28.3564 +RAPID CITY, USA:44.145:W103.103 +RARON, SWITZERLAND:46.3036:7.82333 +RAS AL KHAIMAH, UNITED ARAB EMIRATES:25.6133:55.9386 +RASH MISHAB, SAUDI ARABIA:28.0794:48.6108 +RASHT, IRAN:37.3253:49.6056 +RAS LANOUF V 40, LIBYA:30.5:18.5269 +RAS TANAJIB, SAUDI ARABIA:27.8689:48.7683 +RAS TANURA, SAUDI ARABIA:26.7231:50.0306 +RAWALA KOT, PAKISTAN:33.8492:73.7978 +RAYONG, THAILAND:12.6797:101.005 +RAYSKALA, FINLAND:60.7447:24.1078 +REAO, FRENCH POLYNESIA:-18.4658:W136.439 +RECHLIN-LAERZ, GERMANY:53.3064:12.7531:43 +RECIFE, BRAZIL:-8.12639:W34.9233 +RECONQUISTA, ARGENTINA:-29.21:W59.6908 +RED DEER INDUSTRIAL, CANADA:52.1786:W113.893 +RED RIVER, USA:47.9608:W97.4011 +REDSTONE, USA:34.6786:W86.6847 +REGGAN, ALGERIA:26.71:0.285556 +REGGIO CALABRIA, ITALY:38.0711:15.6514:239 +REGINA, CANADA:50.4319:W104.666 +REIMS, FRANCE:49.2078:4.15667:46 +RELIZANE, ALGERIA:35.7522:0.626111 +REMADA, TUNISIA:32.3061:10.3819 +RENDSBURG, GERMANY:54.2208:9.60056:23 +RENGAT, INDONESIA:-0.352778:102.335 +RENNES, FRANCE:48.0694:W1.73472:50 +RENO, USA:39.4983:W119.768 +REPULSE BAY, CANADA:66.5214:W86.2247 +RESISTENCIA, ARGENTINA:-27.4497:W59.0561 +RESOLUTE, CANADA:74.7169:W94.9694 +RETALHULEU, ARGENTINA:14.5208:W91.6972 +REUS, SPAIN:41.1472:1.16694 +REYES, BOLIVIA:-14.3061:W67.3536 +REYKJAVIK, ICELAND:64.13:W21.9406 +REYNOSA, MEXICO:26.0089:W98.2283 +RHEINE-BRENTLANGE, GERMANY:52.2911:7.38694 +RIBEIRAO PRETO, BRAZIL:-21.1342:W47.7742 +RIBERALTA, BOLIVIA:-11.0103:W66.0733 +RICHARD'S BAY, SOUTH AFRICA:-28.7408:32.0919 +RICHMOND, AUSTRALIA:-33.6006:150.781:3 +RICHMOND, USA:37.505:W77.3194 +RIESA, GERMANY:51.2944:13.3589:58 +RIJEKA, CROATIA:45.2167:14.57 +RIMINI, ITALY:44.0203:12.6119:134 +RIOBAMBA, ECUADOR:-1.65361:W78.6561 +RIO BRANCO, BRAZIL:-9.86889:W67.8936 +RIO CUARTO, ARGENTINA:-33.0856:W64.2614 +RIO DE JANEIRO, BRAZIL:-22.8089:W43.2436 +RIO GALLEGOS, ARGENTINA:-51.6086:W69.3125 +RIO GRANDE, ARGENTINA:-53.7775:W67.7492 +RIO GRANDE, BRAZIL:-32.0825:W52.1664 +RIO HACHA, COLOMBIA:11.5261:W72.9258 +RIO NEGRO, COLOMBIA:6.16444:W75.4231 +RIO TURBIO, ARGENTINA:-51.6064:W72.2167 +RISALPUR, PAKISTAN:34.0811:71.9725 +RISHIRI ISLAND, JAPAN:45.2419:141.186 +RIVERA, URUGUAY:-30.9744:W55.4761 +RIVERSIDE, USA:33.8806:W117.259 +RIVIERE DU LOUP, CANADA:47.7644:W69.5847 +RIYADH, SAUDI ARABIA:24.9575:46.6986 +ROADTOWN/BEEF ISLAND, VIRGIN ISL.:18.4447:W64.5428 +ROANNE, FRANCE:46.0583:4.00139 +ROATAN, HONDURAS:16.3167:W86.5225 +ROBERTSON, SOUTH AFRICA:-33.8119:19.9028 +ROBERVAL, CANADA:48.52:W72.2656 +ROBINSON, USA:34.85:W92.3 +ROBORE, BOLIVIA:-18.3281:W59.7661 +ROCHEFORT, FRANCE:45.8878:W0.983056 +ROCHESTER, USA:43.1186:W77.6722 +ROCKHAMPTON, AUSTRALIA:-23.3819:150.475:580 +ROCK SOUND, BAHAMAS:24.8917:W76.1775 +ROCKY MOUNTAIN HOUSE, CANADA:52.4297:W114.904 +RODEZ, FRANCE:44.4078:2.4825 +RODRIGUEZ ISLAND, MAURITIUS:-19.7578:63.3619 +ROMBLON, PHILIPPINES:6.05806:125.096 +ROME, ITALY:41.8933203:12.4829321:1285 +ROME, USA:43.2336:W75.4069 +RONCHI DE LEGIONARI, ITALY:45.8278:13.4664:17 +RONNEBY, SWEDEN:56.2667:15.265 +RONNE, DENMARK:55.0631:14.7594 +ROOSEVELT ROADS, PUERTO RICO:18.245:W65.6433 +ROROS, NORWAY:62.5783:11.3422 +ROSARIO, ARGENTINA:-32.9033:W60.7844 +ROSENTHAL, GERMANY:49.8628:11.7878:51 +ROSH PINA, ISRAEL:32.9808:35.5717 +ROSTOV, RUSSIA:47.2581:39.8178:354 +ROSWELL, USA:33.3014:W104.531 +ROTA, MARIANA ISLANDS:14.1744:145.243 +ROTA, SPAIN:36.645:W6.34944 +ROTH, GERMANY:49.2175:11.1006 +ROTORUA, NEW ZEALAND:-38.1092:176.317 +ROTTERDAM, NETHERLANDS:51.9572:4.44167:324 +ROUEN, FRANCE:49.4404591:1.0939658:21 +ROURKELA, INDIA:22.2564:84.8144 +ROUYN, CANADA:48.2061:W78.8356 +ROVANIEMI, FINLAND:66.5647:25.8303 +ROXAS, PHILIPPINES:11.5975:122.753 +ROYAN, FRANCE:45.6281:W0.9725 +RURRENABAQUE, BOLIVIA:-14.4283:W67.5014 +RURUTU, FRENCH POLYNESIA:-22.4339:W151.361 +RUSTENBURG, SOUTH AFRICA:-25.6442:27.2711 +RUTENG, INDONESIA:-8.59556:120.478 +RYGGE, NORWAY:59.3789:10.7856 +RZESZOW, POLAND:50.11:22.0189 +SAANEN, SWITZERLAND:46.4875:7.25083 +SAARBRUECKEN, GERMANY:49.2144:7.10944:167 +SABANG, INDONESIA:5.87389:95.3394 +SACE, SOUTH AFRICA:-25.9614:29.2086 +SACHON, KOREA:35.0883:128.07 +SACHS HARBOUR, CANADA:71.9939:W125.243 +SACRAMENTO, USA:38.5125:W121.493 +SAHARANPUR, INDIA:29.9942:77.4242 +SAHIWAL, PAKISTAN:31.8894:72.3092 +SAIDPUR, BANGLADESH:25.7592:88.9086 +SAIDU SHARIF, PAKISTAN:34.8131:72.3519 +SAINTE MARIE, MADAGASCAR:-17.0939:49.8158 +SAIPAN, MARIANA ISLANDS:15.1194:145.729 +SAIQ, OMAN:23.0667:57.65 +SAKON NAKHON, THAILAND:17.195:104.119 +SALALAH, OMAN:17.0386:54.0911 +SALAMANCA, SPAIN:40.9519:W5.50194 +SALDANHA, SOUTH AFRICA:-32.9639:17.9692 +SALEKHARD, RUSSIA:66.59:66.6103:84 +SALEM, INDIA:11.7819:78.0653 +SALERNO, ITALY:40.6203:14.9111:58 +SALINAS, ECUADOR:-2.20472:W80.9886 +SALISBURY, USA:38.3403:W75.5103 +SALON, FRANCE:43.6064:5.10917 +SALTA, ARGENTINA:-24.8558:W65.4861 +SALTILLO, MEXICO:25.5494:W100.929 +SALT LAKE CITY, USA:40.7883:W111.978:288 +SALTO, URUGUAY:-31.4397:W57.9906 +SALTSY, MOLDOVA:47.8378:27.7811 +SALVADOR, BRAZIL:-12.9108:W38.3308 +SALZBURG, AUSTRIA:47.7931:13.0042 +SAMANA, DOMINICAN REPUBLIC:19.1986:W69.43 +SAMARA, RUSSIA:53.5053:50.1644:541 +SAMARINDA, INDONESIA:-0.484444:117.157 +SAMARKAND, RUSSIA:39.7006:66.9847:120 +SAMBAVA, MADAGASCAR:-14.2786:50.1747 +SAMEDAN, SWITZERLAND:46.5325:9.88278 +SAMPIT, INDONESIA:-2.50111:112.977 +SAMSUN, TURKEY:41.2764:36.3036:1055 +SANANDAJ, IRAN:35.2469:47.0069 +SAN ANDRES ISLAND, COLOMBIA:12.5833:W81.7111 +SAN ANDROS, BAHAMAS:25.0536:W78.0489 +SAN ANGELO, USA:31.3575:W100.496 +SAN ANTONIO DE BANOS, CUBA:22.8714:W82.5092 +SAN ANTONIO, USA:29.5294:W98.2789:1194 +SAN ANTONIO, VENEZUELA:7.85222:W72.4347 +SAN BORJA, BOLIVIA:-14.8575:W66.7375 +SAN CARLOS DE BARILOCH, ARGENTINA:-41.1511:W71.1575 +SAN CARLOS, VENEZUELA:9.64889:W68.5753 +SAN CRISTOBAL, ECUADOR:-0.909722:W89.6158 +SAN CRISTOBAL, VENEZUELA:7.80111:W72.2028 +SANCTI SPIRITUS, CUBA:21.9706:W79.4422 +SAN DIEGO, USA:32.6992:W117.215:842 +SANDNESSJOEN, NORWAY:65.9567:12.4689 +SANDSPIT, CANADA:53.2542:W131.814 +SANDTOFT, UK:53.5597:W0.858333 +SANDUSKY, USA:41.4333:W82.6522 +SANDY POINT, BAHAMAS:26.0044:W77.3953 +SAN FELIPE, VENEZUELA:10.2786:W68.755 +SAN FERNANDO, ARGENTINA:-34.4531:W58.5894 +SAN FERNANDO DE APURE, VENEZUELA:7.8825:W67.4436 +SAN FERNANDO DEATABAPO, VENEZUELA:4.05167:W67.7008 +SAN FERNANDO, PHILIPPINES:16.5956:120.303 +SAN FILIPE, MEXICO:30.93:W114.809 +SAN FRANCISCO, USA:37.74594738515095:W122.44299445520019:121 +SANGLEY POINT, PHILIPPINES:9.75778:125.481 +SAN IGNACIO DE MOXOS, BOLIVIA:-14.9656:W65.6336 +SAN IGNACIO DE VELASCO, BOLIVIA:-16.3844:W60.9628 +SAN ISIDORO, DOMINICAN REPUBLIC:18.5036:W69.7617 +SAN JOAQUIN, BOLIVIA:-13.0528:W64.6617 +SAN JOSE, COSTA RICA:9.95722:W84.1419 +SAN JOSE DEL CABO, MEXICO:23.1517:W109.721 +SAN JOSE DEL GUAVIARE, COLOMBIA:2.57944:W72.6392 +SAN JOSE, GUATEMALA:13.9361:W90.8358 +SAN JOSE, PHILIPPINES:10.7661:121.932 +SAN JOSE, USA:37.32149403063839:W121.9286918256836 +SAN JUAN DE LOS MORROS, VENEZUELA:9.90694:W67.3794 +SAN JUAN, PERU:-15.3575:W75.1356 +SAN JUAN, PUERTO RICO:18.4392:W66.0017 +SAN JULIAN, ARGENTINA:-31.5714:W68.4181 +SAN JULIAN, CUBA:22.0953:W84.1519 +SAN JUSTO, ARGENTINA:-34.7314:W58.5994 +SANLIURFA, TURKEY:37.0919:38.8461:3668 +SAN LUIS, ARGENTINA:-33.2731:W66.3564 +SAN LUIS POTOSI, MEXICO:22.2542:W100.931 +SAN LUIS, SPAIN:39.8622:4.25833 +SAN LUIS, USA:45.7725:W122.862 +SAN MARTIN DES ANDES, ARGENTINA:-40.0753:W71.1372 +SAN MATHIAS, BOLIVIA:-16.3386:W58.4017 +SAN PEDRO, IVORY COAST:4.74667:W6.66056 +SAN PEDRO SULA, HONDURAS:15.4525:W87.9233 +SAN RAFAEL, ARGENTINA:-34.5881:W68.4025 +SAN RAMON, BOLIVIA:-13.2636:W64.6053 +SAN RAMON, PERU:-11.1286:W75.3503 +SAN SALVADOR, EL SALVADOR:13.4406:W89.0558 +SAN SEBASTIAN, SPAIN:43.3564:W1.79056 +SANTA ANA, BOLIVIA:-13.7619:W65.435 +SANTA ANA, USA:33.6756:W117.868 +SANTA BARBARA, USA:32.6083:W82.3686 +SANTA BARBARA, VENEZUELA:7.80333:W71.1656 +SANTA CLARA, COSTA RICA:10.2883:W83.7136 +SANTA CLARA, CUBA:22.4919:W79.9436 +SANTA CRUZ, ARGENTINA:-50.0164:W68.5792 +SANTA CRUZ, BOLIVIA:-17.6447:W63.1353 +SANTA CRUZ DE LA PALMA, SPAIN:28.6264:W17.7556 +SANTA FE, ARGENTINA:-31.7117:W60.8117 +SANTA FE, USA:35.6167:W106.088 +SANTA LUCIA, CUBA:21.5094:W77.0175 +SANTA MARIA (ISLAND), ACORES:36.9714:W25.1706 +SANTA MARTA, COLOMBIA:11.1194:W74.2306 +SANTANDER, SPAIN:43.4269:W3.82 +SANTAREM, BRAZIL:-29.7111:W53.6881 +SANTA ROSA, ARGENTINA:-36.5881:W64.2753 +SANTA ROSA, ECUADOR:-3.43528:W79.9778 +SANTA TERESITA, ARGENTINA:-36.5422:W56.7217 +SANTIAGO, CHILE:-33.3928:W70.7856 +SANTIAGO DE CUBA, CUBA:19.97:W75.8356 +SANTIAGO DEL ESTERO, ARGENTINA:-27.7656:W64.31 +SANTIAGO, DOMINICAN REPUBLIC:19.4092:W70.6164 +SANTIAGO, PANAMA:8.08556:W80.945 +SANTIAGO, SPAIN:42.8961:W8.415 +SANTO ANGELO, BRAZIL:-28.2817:W54.1689 +SANTO DOMINGO, CHILE:-33.6567:W71.6156 +SANTO DOMINGO, DOMINICAN REPUBLIC:18.4294:W69.6686 +SANTO DOMINGO, ECUADOR:-0.248056:W79.2144 +SANTO DOMINGO, VENEZUELA:7.565:W72.035 +SAN TOME, VENEZUELA:8.945:W64.1508 +SANTOS, BRAZIL:-23.925:W46.2875 +SANTO, VANUATU:-15.5011:167.222 +SAN VINCENTE DE CAGUAN, COLOMBIA:2.15194:W74.7661 +SAN VITO DE JABA, COSTA RICA:8.82611:W82.9589 +SAO GABRIEL, BRAZIL:-0.148333:W66.9856 +SAO JORGE ISLAND, ACORES:38.6653:W28.1756 +SAO JOSE DO RIO PRETO, BRAZIL:-20.8164:W49.4064 +SAO JOSE DOS CAMPOS, BRAZIL:-23.2292:W45.8614 +SAO LUIS, BRAZIL:-2.58861:W44.2364 +SAO NOCOLAU ISLAND, CAPE VERDE ISLANDS:16.5883:W24.2844 +SAO PAULO, BRAZIL:-23.4322:W46.4692 +SAO PEDRO DA ALDEIA, BRAZIL:-22.8128:W42.0925 +SAO TOME, SAO TOME & PRINCIPE:0.378056:6.71194 +SAO VICENTE ISLAND, CAPE VERDE ISLANDS:16.8339:W25.0567 +SAPPORO, JAPAN:42.775:141.692 +SARAJEVO, BOSNIA-HERCEGOVINA:43.8244:18.3314 +SARAKHS, IRAN:36.5011:61.0647 +SARAVENA, COLOMBIA:6.95806:W71.855 +SARGODHA, PAKISTAN:32.0486:72.665 +SARH, CHAD:9.15111:18.3794 +SARMELLEK, HUNGARY:46.6864:17.1589:35 +SARNIA, CANADA:42.9994:W82.3089 +SASKATOON, CANADA:52.1708:W106.7 +SATENAS, SWEDEN:58.4264:12.7142 +SATTNA, SWEDEN:62.4811:17.0028 +SATU MARE, ROMANIA:47.7033:22.8856 +SAULT SAINTE MARIE, CANADA:46.485:W84.5094 +SAUMUR, FRANCE:47.2567:W0.115 +SAURIMO, ANGOLA:-9.68889:20.4317 +SAVANNAH, USA:32.1275:W81.2019 +SAVANNAKHET, LAOS:16.5564:104.759 +SAVONLINNA, FINLAND:61.9431:28.945 +SCARBOROUGH, TRINIDAD & TOBAGO:11.1494:W60.8319 +SCHEFFERVILLE, CANADA:54.805:W66.8053 +SCHLESWIG, GERMANY:54.4592:9.51611 +SCHWAEBISCH HALL, GERMANY:49.1181:9.77722:104 +SEATTLE, USA:47.59840153253106:W122.31143714060059:217 +SEBHA, LIBYA:26.9869:14.4725 +SECUNDA, SOUTH AFRICA:-26.5239:29.17 +SEK KONG, HONG KONG:22.4364:114.08 +SELANPAA, FINLAND:61.0622:26.7986 +SELEBI-PHIKWE, BOTSWANA:-22.0583:27.8286 +SELIBABI, MAURITANIA:15.1794:W12.2072 +SELMA, USA:32.3439:W86.9878 +SEMARANG, INDONESIA:-6.97306:110.375 +SEMBAWANG, SINGAPORE:1.42361:103.811 +SEMNAN, IRAN:35.5908:53.495 +SENDAI, JAPAN:38.1394:140.917 +SEO DE URGEL, SPAIN:42.3386:1.40917 +SEOUL EAST, KOREA:37.4458:127.114 +SEOUL, KOREA:37.5581:126.791 +SEPAH, IRAN:32.6208:51.6967 +SEPT-ILES, CANADA:50.2233:W66.2656 +SETIF, ALGERIA:36.1781:5.32444 +SEVILLA, SPAIN:37.1747:W5.61583 +SFAX, TUNISIA:34.7178:10.6908 +SHAHR ABAD, IRAN:37.6278:56.1731 +SHANGHAI, CHINA:31.1978:121.336:6340 +SHANNON, IRELAND:52.7019:W8.92472:2 +SHANTE, MYANMAR:20.9417:95.9144 +SHANTOU, CHINA:23.4:116.683:2248 +SHAPAJA, PERU:53.7906:15.8281 +SHARJAH, UNITED ARAB EMIRATES:25.3283:55.5169 +SHARURAH, SAUDI ARABIA:17.4667:47.1211 +SHAWBURY, UK:52.7981:W2.66778 +SHEBERGHAN, AFGHANISTAN:36.7503:65.9122 +SHEMYA, USA:52.7122:174.114 +SHENZHEN, CHINA:22.6394:113.812:2050 +SHERBROOKE, CANADA:45.4381:W71.6906 +SHIJIAZHUANG, CHINA:38.2806:114.696:15849 +SHIMLA, INDIA:31.0817:77.0581 +SHIMOFUSA, JAPAN:35.7989:140.011 +SHIMOJISHIMA, JAPAN:24.8267:125.145 +SHINDAND, AFGHANISTAN:33.3911:62.2608 +SHIRAZ, IRAN:29.5392:52.5894 +SHOLAPUR, INDIA:17.6278:75.9347 +SHONAI, JAPAN:38.8117:139.787 +SHOREKOTE, PAKISTAN:30.7581:72.2825 +SHREVEPORT, USA:32.4464:W93.8256 +SIBIU, ROMANIA:45.7856:24.0911 +SIBOLGA, INDONESIA:1.55472:98.8903 +SIBU, MALAYSIA:2.26389:111.983 +SIDI BEL ABBES, ALGERIA:35.1717:W0.593056 +SIDI IFINI, MOROCCO:29.3689:W10.18 +SIEGERLAND, GERMANY:50.7075:8.08194:114 +SIEM-REAP, CAMBODIA:13.4106:103.813 +SIENA, ITALY:43.2572:11.2542:118 +SIGLUFJORDUR, ICELAND:66.1333:W18.9167 +SIGUANEA, CUBA:21.6425:W82.955 +SIIRT, TURKEY:37.9786:41.8403:284 +SILCHAR, INDIA:24.9128:92.9786 +SIMARA, NEPAL:27.1594:84.98 +SIMFEROPOL, RUSSIA:45.0386:33.9831:107 +SIMPANG, MALAYSIA:3.11222:101.703 +SINDAL, DENMARK:57.5033:10.2292 +SINGAPORE, SINGAPORE:1.35556:103.987 +SINGKEP, INDONESIA:-0.479167:104.579 +SINTANG, INDONESIA:0.0636111:111.475 +SINTRA, ACORES:38.8308:W9.33944 +SINT-TRUIDEN, BELGIUM:50.7883:5.19278 +SIOFOK, HUNGARY:46.8581:18.0956:124 +SION, SWITZERLAND:46.2194:7.32667 +SIOUX CITY, USA:42.4025:W96.3842 +SIOUX LOOKOUT, CANADA:50.1144:W91.9042 +SIRI ISLAND, IRAN:25.9094:54.5392 +SIRJAN, IRAN:29.5508:55.6725 +SISHEN, SOUTH AFRICA:-27.6483:22.9992 +SITKA, USA:57.0469:W135.361 +SITTWE, MYANMAR:20.1325:92.8725 +SIVAS, TURKEY:39.8136:36.9033:2768 +SIVRIHISAR, TURKEY:39.4514:31.3653:2987 +SKAGWAY, USA:59.46:W135.316 +SKARDU, PAKISTAN:35.3347:75.5364 +SKELLEFTEA, SWEDEN:64.6247:21.0767 +SKIEN, NORWAY:59.185:9.56694 +SKIVE, DENMARK:56.55:9.17278 +SKOPJE, FORMER MACEDONIA:41.9614:21.6214 +SKOVDE, SWEDEN:58.4564:13.9725 +SKRYDSTRUP, DENMARK:55.2253:9.26389 +SKUKUZA, SOUTH AFRICA:-24.9608:31.5886 +SLAVE LAKE, CANADA:55.2933:W114.778 +SLIAC, SLOVAKIA:48.6378:19.1339 +SLIGO, IRELAND:54.28:W8.59917:10 +SLOVENJ GRADEC, SLOVENIA:46.4719:15.1169 +SLUPSK, POLAND:54.4789:17.1075 +SMITHERS, CANADA:54.8247:W127.183 +SMITHFIELD, USA:41.9206:W71.4911 +SOBESLAV, CZECH REPUBLIC:49.2447:14.7136 +SOCHI, RUSSIA:43.4458:39.9475:176 +SODANKYLA, FINLAND:67.395:26.6189 +SOENDERBORG, DENMARK:54.9642:9.79167 +SOEST, GERMANY:51.6075:13.7378:85 +SOFIA, BULGARIA:42.695:23.4061 +SOGNDAL, NORWAY:61.1561:7.13639 +SOKCH'O, KOREA:38.1475:128.601 +SOKOTO, NIGERIA:12.9161:5.20694 +SOLENZARA, CORSE ISL.:41.9242:9.40583 +SOLO CITY, INDONESIA:-7.51583:110.757 +SON BONET, SPAIN:39.5989:2.70278 +SONDRESTROM, GREENLAND:67.0169:W50.6892 +SONGKHLA, THAILAND:6.93306:100.393 +SONGO, MOZAMBIQUE:-15.6025:32.7731 +SORKJOSEN, NORWAY:69.7869:20.9594 +SOROAKO, INDONESIA:-2.52944:121.357 +SORONG, INDONESIA:-0.925833:131.12 +SOROTI, UGANDA:1.7275:33.6228 +SOUTHAMPTON, ENGLAND:50.92571412096368:W1.4174069260253863:72 +SOUTH CAICOS, TURKS & CAICOS I.:21.5156:W71.5283 +SOUTHDOWNS, ZAMBIA:-12.9003:28.1497 +SOUTHEND, ENGLAND:51.5714:0.695556 +SOYO, ANGOLA:-6.14083:12.3717 +SPANGDAHLEM, GERMANY:49.9725:6.6925 +SPARREVOHN, USA:61.0972:W155.574 +SPARTI, GREECE:36.9733:22.5261:21 +SPENCE BAY, CANADA:69.5467:W93.5767 +SPEYER, GERMANY:49.3025:8.45111:42 +SPLIT, CROATIA:43.5389:16.2978 +SPOKANE, USA:47.615:W117.656 +SPRINGBOK, SOUTH AFRICA:-29.6892:17.9394 +SPRING POINT, BAHAMAS:22.4417:W73.9708 +SPRINGS, SOUTH AFRICA:-26.2483:28.3975 +SRINAGAR, INDIA:33.9867:74.7736 +STADTLOHN, GERMANY:51.9958:6.84028:79 +STANIEL CAY, BAHAMAS:24.1689:W76.4389 +STANLEY, FALKLAND ISLANDS:-51.6856:W57.7775 +ST. ANTHONY, CANADA:51.3917:W56.0844 +STARA ZAGORA, BULGARIA:42.3767:25.655 +ST. ATHAN, UK:51.4047:W3.43556 +STAUNING, DENMARK:55.99:8.35389 +STAVANGER, NORWAY:58.8767:5.63778 +STAVROPOL, RUSSIA:45.1092:42.1128 +ST.-BARTHELEMY, ANTILLES:17.9:W62.85 +ST. CATHERINE, EGYPT:28.6853:34.0625 +ST. CRIOX ISLAND, VIRGIN ISL.:17.7017:W64.7983 +ST.-DENIS, REUNION ISLAND:-20.8869:55.5103 +ST.-DIZIER, FRANCE:48.6358:4.89917:43 +STELLA MARIS, BAHAMAS:23.5828:W75.2686 +STENDAL, GERMANY:52.6289:11.8197:268 +STEPHEN'S ISLAND, NEW ZEALAND:-89.9997:0 +STEPHENVILLE, CANADA:48.5442:W58.55 +ST.-ETIENNE, FRANCE:45.5403:4.29639 +ST.-FLOUR, FRANCE:45.0764:2.99361 +ST.-GEORGES OYAPOCK, FRENCH GUYANA:3.8975:W51.8039 +ST.-GIRONS, FRANCE:43.0075:1.10306 +ST. JEAN, CANADA:45.2944:W73.2811 +ST. JOHN, CANADA:45.3161:W65.8903 +ST. JOHN'S, CANADA:47.6186:W52.7519 +ST. LOUIS, SENEGAL:16.0497:W16.4611 +ST. LOUIS, USA:38.7475:W90.3597 +ST. MARTIN, ANTILLES:18.0997:W63.0469 +ST.-NAZAIRE, FRANCE:47.3119:W2.14917:46 +STOCKHOLM, SWEDEN:58.7886:16.9119 +STOCKTON, USA:37.8942:W121.239 +STORD, NORWAY:59.7917:5.34083 +STORNOWAY, UK:58.2141245782211:W6.379953784594727:3 +ST. PAUL ISLAND, USA:57.1672:W170.22 +ST. PETERSBURG, RUSSIA:59.8:30.265:1439 +ST. PETERSBURG, USA:27.765:W82.6269 +ST.-PIERRE, REUNION ISLAND:-21.3208:55.4247 +STRANGNAS, SWEDEN:59.3139:17.1092 +STRASSBOURG, FRANCE:48.5381:7.62806:78 +STRATFORD, USA:41.1633:W73.1261 +STRAUBING, GERMANY:48.9008:12.5181:67 +STRAUSBERG, GERMANY:52.5797:13.9156:67 +ST.-SIMON, FRANCE:49.7583:3.21194:3 +ST. THOMAS, VIRGIN ISL.:18.3372:W64.9733 +STUNG TRENG, CAMBODIA:13.5314:106.014 +STUTTGART, GERMANY:48.7784485:9.1800132:207 +ST.-YAN, FRANCE:46.4125:4.01306 +SUAI, EAST TIMOR:-9.30306:125.287 +SUCEAVA, ROMANIA:47.6875:26.3539 +SUCRE, BOLIVIA:-19.0069:W65.2889 +SUDBURY, CANADA:46.625:W80.7989 +SUHL, GERMANY:51.3081:13.5547:141 +SUI, PAKISTAN:28.645:69.1767 +SUKHUMI, GEORGIA:42.8581:41.1281:27 +SUKKUR, PAKISTAN:27.7219:68.7917 +SULAYEL, SAUDI ARABIA:20.4647:45.6194 +SUMBAWA, INDONESIA:-8.48889:117.412 +SUMBURGH, UK:59.8789:W1.29556 +SUMMERSIDE, CANADA:46.4428:W63.8311 +SUMTER, USA:33.9728:W80.4728 +SUNDSVALL, SWEDEN:62.5281:17.4439 +SUNYANI, GHANA:7.36167:W2.32861 +SURABAYA, INDONESIA:-7.37972:112.787 +SURAT, INDIA:21.115:72.7428 +SURAT THANI, THAILAND:9.54778:100.062 +SURGUT, RUSSIA:61.25:73.5:354 +SURIN, THAILAND:14.8692:103.489 +SUWON, KOREA:37.2392:127.007 +SVALBARD, NORWAY:78.2461:15.4656 +SVARTNES, NORWAY:70.3553:31.0447 +SVEG, SWEDEN:62.0478:14.4228 +SVERDLOVSK, RUSSIA:56.7414:60.8036:468 +SWANSEA, ENGLAND:51.623192463569175:W3.9580269270752:380 +SWARTKOP, SOUTH AFRICA:-25.8094:28.1644 +SWIFT CURRENT, CANADA:50.2919:W107.691 +SYDNEY, AUSTRALIA:-33.9244:150.988:1000 +SYDNEY, CANADA:46.1614:W60.0478 +SYKTYVKAR, RUSSIA:61.6475:50.8456:152 +SYLHET OSMANI, BANGLADESH:24.9628:91.8672 +SYRACUSE, USA:43.1111:W76.1061 +SZCZECHIN, POLAND:53.3919:14.6336 +SZOLNOK, HUNGARY:47.1228:20.2353:187 +TABAS, IRAN:33.6678:56.8925 +TABATINGA, BRAZIL:-4.25556:W69.9356 +TABITEUEA NORTH, KIRIBATI:-1.22361:174.776 +TABRIZ, IRAN:38.1328:46.2347 +TABUK, SAUDI ARABIA:28.3653:36.6189 +TACHILEK, MYANMAR:20.4836:99.9353 +TACLOBAN, PHILIPPINES:11.2272:125.028 +TACNA, PERU:-18.0533:W70.2758 +TACOMA, USA:47.1375:W122.476 +TACUAREMBO, URUGUAY:-31.7489:W55.9256 +TAEGU, KOREA:35.8939:128.659 +TAHOUA, NIGER:14.8756:5.26528 +TAHUNA, INDONESIA:3.68306:125.528 +TAIF, SAUDI ARABIA:21.4833:40.5442 +TAINAN, TAIWAN:22.9503:120.206 +TAIPEI, TAIWAN:25.0694:121.552 +TAITUNG, TAIWAN:22.7931:121.182 +TAIYUAN, CHINA:37.7467:112.629:6956 +TAKAMATSU, JAPAN:34.2139:134.016 +TAKAPOTO, FRENCH POLYNESIA:-14.7125:W145.253 +TAKAROA, FRENCH POLYNESIA:-14.4556:W145.024 +TAKORADI, GHANA:4.89333:W1.775 +TAK, THAILAND:16.6997:98.545 +TALARA, PERU:-4.54972:W81.2239 +TALHAR, PAKISTAN:24.8414:68.8383 +TALKEETNA, USA:62.3203:W150.094 +TALLAHASSEE, USA:30.3964:W84.3503 +TALLINN-ULEMISTE INTERNATIONAL, ESTONIA:59.4131:24.8328 +TALUQAN, AFGHANISTAN:36.775:69.5325 +TAMALE, GHANA:9.56333:W0.863333 +TAMANRASSET, ALGERIA:22.8108:5.45083 +TAMBACOUNDA, SENEGAL:13.7367:W13.6531 +TAMBARAM, INDIA:12.9067:80.1211 +TAME, COLOMBIA:6.45083:W71.76 +TAMPA, USA:27.8492:W82.5211 +TAMPERE, FINLAND:61.4139:23.6042 +TAMPICO, MEXICO:22.2964:W97.8658 +TAMUIN, MEXICO:22.0381:W98.8064 +TAMWORTH, AUSTRALIA:-31.0839:150.847:240 +TANAGRA, GREECE:38.3397:23.5647:27 +TANANA, USA:65.1742:W152.109 +TANCOS, ACORES:39.475:W8.36444 +TANDIL, ARGENTINA:-37.2372:W59.2278 +TANEGASHIMA, JAPAN:30.5467:130.95 +TANGA, TANZANIA:-5.09222:39.0711 +TANGERANG, INDONESIA:-6.29306:106.57 +TANGER, MOROCCO:35.7267:W5.91667 +TANJORE, INDIA:10.7197:79.1036 +TANJUNG KARANG, INDONESIA:-5.24222:105.179 +TANJUNG PANDAN, INDONESIA:-2.74556:107.755 +TANJUNG PINANG, INDONESIA:0.9225:104.532 +TANJUNG REDEP, INDONESIA:2.15444:117.432 +TANJUNG SANTAN, INDONESIA:-0.0930556:117.439 +TANOUT, NIGER:14.9994:8.76694 +TAN TAN, MOROCCO:28.4481:W11.1611 +TAOYUAN, TAIWAN:25.0556:121.243 +TAPACHULA, MEXICO:14.7942:W92.37 +TARAKEN, INDONESIA:3.32639:117.566 +TARAPOA, ECUADOR:-0.122778:W76.3375 +TARAPOTO, PERU:-6.50861:W76.3731 +TARAUACA, BRAZIL:-8.155:W70.7831 +TARAWA, KIRIBATI:1.38139:173.147 +TARBES, FRANCE:43.1786:W0.00638889 +TARIJA, BOLIVIA:-21.5556:W64.7008 +TARTAGAL, ARGENTINA:-22.6197:W63.7936 +TARTU-ULENURME, ESTONIA:58.3072:26.6903 +TASHKENT, UZBEKISTAN:41.2572:69.2817:334 +TASIKMALAYA, INDONESIA:-7.34556:108.246 +TATALINA, USA:62.8942:W155.976 +TATEYAMA, JAPAN:34.9869:139.829 +TAUNGOO, MYANMAR:19.0311:96.4014 +TAUPO, NEW ZEALAND:-38.7397:176.084 +TAURA, ECUADOR:-2.26083:W79.6892 +TAURANGA, NEW ZEALAND:-37.6719:176.196 +TAWAU, MALAYSIA:4.31333:118.122 +TBILISI, GEORGIA:41.6692:44.9547:726 +TCHIBANGA, GABON:-2.88889:10.9194 +TEBESSA, ALGERIA:35.4314:8.12056 +TEFE, BRAZIL:-3.38278:W64.7239 +TEGUCIGALPA, HONDURAS:14.0608:W87.2169 +TEHERAN, IRAN:35.6447:51.3806 +TEHUACAN, MEXICO:18.4969:W97.4197 +TEISKO, FINLAND:61.7733:24.0269 +TELA, HONDURAS:15.7758:W87.4756 +TEL-AVIV FIR/CTA/UTA, ISRAEL:31.2286:35.1908 +TEL-AVIV, ISRAEL:32.0094:34.8767 +TELEMACO BORBA, BRAZIL:-24.3175:W50.6514 +TELERGMA, ALGERIA:36.1086:6.36444 +TEL-NOF, ISRAEL:31.8394:34.8217 +TEMUCO, CHILE:-38.7686:W72.6358 +TENA, ECUADOR:-0.986667:W77.8194 +TENERIFE, CANARY ISLANDS:28.0444:W16.5722 +TENGAH, SINGAPORE:1.38722:103.709 +TEODORO SAMPAIO, BRAZIL:-22.5256:W52.9719 +TEPIC, MEXICO:21.4194:W104.843 +TERBELA, PAKISTAN:33.9861:72.6114 +TERESINA, BRAZIL:-5.05972:W42.8233 +TERMEZ, RUSSIA:37.2864:67.3083:36 +TERNATE, INDONESIA:0.831944:127.381 +TERNHILL, UK:52.8711:W2.53333 +TERRACE, CANADA:54.4664:W128.577 +TERRE HAUTE, USA:39.4514:W87.3075 +TESLIN, CANADA:60.1728:W132.743 +TESSALIT, MALI:20.2461:0.980833 +TETE, MOZAMBIQUE:-16.1047:33.64 +TETERBORO, USA:40.8497:W74.0608 +TETOUAN, MOROCCO:35.5942:W5.32 +TEXARKANA, USA:33.4536:W93.9908 +THABA NCHU, BOPHUTHATSWANA:-29.3192:26.8233 +THANDWE, MYANMAR:18.4606:94.2994 +THESSALONIKI, GREECE:40.5197:22.9708:19 +THE VALLEY, ANGUILLA ISL.:18.2047:W63.055 +THISTED, DENMARK:57.0686:8.705 +THOHOYANDOU, SOUTH AFRICA:-23.0786:30.3833 +THOMPSON, CANADA:55.8011:W97.8642 +THULE, GREENLAND:76.5311:W68.7031 +THUMAMAH, SAUDI ARABIA:25.2142:46.6406 +THUMRAIT, OMAN:17.6658:54.0244 +THUNDER BAY, CANADA:48.3719:W89.3239 +TIANJIN, CHINA:39.1239:117.346:11760 +TIARET, ALGERIA:35.3411:1.46306 +TICHANG, CHINA:30.5522:111.469:21338 +TIDJIKJA, MAURITANIA:18.5703:W11.4231 +TIJUANA, MEXICO:32.5408:W116.97 +TIKEHAU, FRENCH POLYNESIA:-15.1194:W148.231 +TIKO, CAMEROON:4.08917:9.36028 +TILREMPT, ALGERIA:32.9297:3.31222 +TIMARU, NEW ZEALAND:-44.3028:171.225 +TIMIKA, INDONESIA:-4.52806:136.887 +TIMIMOUN, ALGERIA:29.2369:0.275833 +TIMISOARA, ROMANIA:45.81:21.3378 +TIMMINS, CANADA:48.5697:W81.3767 +TIN CITY, USA:65.5631:W167.922 +TINDOUF, ALGERIA:27.7003:W8.16694 +TINGO MARIA, PERU:-9.29:W76.0058 +TINOGASTA, ARGENTINA:-28.0375:W67.5803 +TIRANA, ALBANIA:41.4147:19.7206 +TIRGU MURES, ROMANIA:46.4675:24.4125 +TIRUCHCHIRAPPALLI, INDIA:10.7653:78.7089 +TIRUPETI, INDIA:13.6319:79.5431 +TIVAT, YUGOSLAVIA:42.4044:18.7231 +TLAXCALA, MEXICO:19.5364:W98.1733 +TLEMCEN, ALGERIA:35.0167:W1.45 +TOAMASINA, MADAGASCAR:-18.1094:49.3925 +TOBOLSK, RUSSIA:37.1322:W92.0839:222 +TOFINO, CANADA:49.0822:W125.772 +TOKACHI, JAPAN:42.8903:143.158 +TOKAT, TURKEY:40.305:36.3678:1923 +TOKOL, HUNGARY:47.3453:18.9808:38 +TOKUNOSHIMA, JAPAN:27.8361:128.881 +TOKUSHIMA, JAPAN:34.1328:134.606 +TOKYO, JAPAN:35.5522:139.779 +TOLAGNARO, MADAGASCAR:-25.0381:46.9561 +TOLIARA, MADAGASCAR:-23.3833:43.7283 +TOLUCA, MEXICO:19.3369:W99.5658 +TOMBOUCTOU, MALI:16.7303:W3.0075 +TOMMY'S FIELD, SOUTH AFRICA:-28.26:22.9931 +TONGATAPU, TONGA:-21.2408:W175.15 +TONOPAH, USA:37.7944:W116.779 +TOPEKA, USA:38.9508:W95.6636 +TORINO, ITALY:45.2006:7.64944:130 +TORONTO, CANADA:43.6275:W79.3961:630 +TORREON, MEXICO:25.5681:W103.411:138 +TORSBY, SWEDEN:60.1575:12.9911:5 +TORTOLI, ITALY:39.9186:9.68278:39 +TOTTORI, JAPAN:35.53:134.166:765 +TOUGGOURT, ALGERIA:33.0678:6.08861:216 +TOUHO, NEW CALEDONIA:-20.7911:165.259:283 +TOUL, FRANCE:48.78:5.97972:30 +TOULOUSE, FRANCE:43.5689:1.48083:118 +TOURS, FRANCE:47.4322:0.7275:34 +TOUSSOUS-LE-NOBLE, FRANCE:48.7517:2.10611:4 +TOWNSVILLE, AUSTRALIA:-19.2525:146.765:693 +TOYAMA, JAPAN:36.6483:137.187:1242 +TOZEUR, TUNISIA:33.9397:8.11056:5 +TRANG, THAILAND:7.50861:99.6164:14 +TRAPANI, ITALY:37.9125:12.4881:271 +TRELEW, ARGENTINA:-43.2103:W65.2703:249 +TRENCIN, SLOVAKIA:48.865:17.9922:82 +TRENTON, USA:40.2767:W74.8133:21 +TRES ARROYOS, ARGENTINA:-38.3867:W60.3294:5 +TREVISO, ITALY:45.6486:12.1953:55 +TRIER, GERMANY:49.8633:6.78889:117 +TRINCIOMALEE, SRI LANKA:8.53889:81.1814:7.5 +TRINIDAD, BOLIVIA:-14.8194:W64.9183:35 +TRINIDAD, COLOMBIA:5.43028:W71.6581:1 +TRINIDAD, CUBA:21.7883:W79.9972:10 +TRIPOLI, LIBYA:32.6633:13.1589:1507 +TRIPOLIS, GREECE:37.5308:22.405:28 +TRIVANDRUM, INDIA:8.48194:76.9181:214 +TROLLHATTAN, SWEDEN:58.3181:12.345:23 +TROMSO, NORWAY:69.6833:18.9189:21 +TRONDHEIM, NORWAY:63.4575:10.9397:321 +TROYES, FRANCE:48.3228:4.01778:13 +TRUJILLO, HONDURAS:15.9267:W85.9381:955 +TRUJILLO, PERU:-8.08472:W79.1094:111 +TRUTH OR CONSEQUENCES, USA:33.2369:W107.272:72 +TSUIKI, JAPAN:33.6853:131.041:67 +TSUSHIMA, JAPAN:34.2847:129.33:708 +TUBUAI, FRENCH POLYNESIA:-23.3653:W149.524:45 +TUCSON, USA:32.1664:W110.883:587 +TUCUMAN, ARGENTINA:-26.8408:W65.1047:91 +TUCUMCARI, USA:35.1828:W103.603:24 +TUCUPITA, VENEZUELA:9.08889:W62.0942:6 +TUCURUI, BRAZIL:-3.785:W49.7194:55 +TUGUEGARAO, PHILIPPINES:17.6381:121.731:144 +TUKTOYAKTUK, CANADA:69.4333:W133.026:13 +TULCAN, ECUADOR:0.809444:W77.7081:138 +TULCEA, ROMANIA:45.0622:28.7142:177 +TULSA, USA:36.1983:W95.8881:522 +TULUA, COLOMBIA:4.08833:W76.235:910 +TUMACO, COLOMBIA:1.81417:W78.7492:1000 +TUMBES, PERU:-3.5525:W80.3808:1800 +TUNIS, TUNISIA:36.8508:10.2269:212 +TURBO, COLOMBIA:8.07444:W76.7411:1000 +TUREIA, FRENCH POLYNESIA:-20.7833:W138.568:47 +TURIN, ITALY:45.0677551:7.6824892:130 +TURKU, FINLAND:60.5139:22.2628:243 +TURTMANN, SWITZERLAND:46.3039:7.71444:6 +TUSTIN, USA:33.7061:W117.827:28 +TUXPAN, MEXICO:19.5983:W103.372:1062 +TUXTLA GUTIERREZ, MEXICO:16.7694:W93.3414:96 +TVER, RUSSIA:56.8247:35.7575:152 +TWENTY NINE PALMS, USA:34.2961:W116.162:152 +TYLER, USA:32.3539:W95.4022:140 +TZANEEN, SOUTH AFRICA:-23.8242:30.3292:22 +UBERABA, BRAZIL:-19.765:W47.9647:1000 +UBERLANDIA, BRAZIL:-18.8828:W48.2256:1000 +UDAIPUR, INDIA:24.6175:73.9128:64 +UDBINA, CROATIA:44.5575:15.7742:683 +UDON THANI, THAILAND:17.3864:102.788:47 +UFA, RUSSIA:54.5575:55.8742:707 +UIGE, ANGOLA:-7.60306:15.0278:1188 +UJUNG PANDANG, INDONESIA:-5.06167:119.554:175 +ULAN BATOR, MONGOLIA:47.8431:106.766:1000 +ULAN-UDE, RUSSIA:51.8067:107.438:347 +ULRICHEN, SWITZERLAND:46.5011:8.29556:44 +ULSAN, KOREA:35.5933:129.352:1057 +ULUNDI, SOUTH AFRICA:-28.3206:31.4164:16 +UMEA, SWEDEN:63.7917:20.2825:33 +UMTATA, SOUTH AFRICA:-31.5478:28.6742:54 +UNALAKLEET, USA:63.8883:W160.799:12 +UNALASKA, USA:53.9:W166.543:546 +UPINGTON, SOUTH AFRICA:-28.4008:21.2606:580 +UPPSALA, SWEDEN:59.8972:17.5883:48 +URSEL, BELGIUM:51.1439:3.47417:20 +URUAPAN, MEXICO:19.3967:W102.039:954 +URUGUAIANA, BRAZIL:-29.7819:W57.0381:1000 +URUMQI, CHINA:43.9069:87.4742:14577:583 +USAK, TURKEY:38.6811:29.4714:1309 +USHUAIA, ARGENTINA:-54.8431:W68.2956:23 +USSEL, FRANCE:45.5347:2.42389:50 +VAASA, FINLAND:63.0511:21.7614:183 +VAGAR, FAROE ISL.:62.0636:W7.27694:176 +VALDEZ, USA:61.1339:W146.248:560 +VALDIVIA, CHILE:-39.6497:W73.0861:1016 +VALDOSTA, USA:30.9678:W83.1928:94 +VALENCE, FRANCE:44.9214:4.96972:36 +VALENCIA, SPAIN:39.4892:W0.481389:134 +VALENCIA, VENEZUELA:10.1581:W67.9267:309 +VALENCIENNES, FRANCE:50.3256:3.46111:13 +VALERA, VENEZUELA:9.34056:W70.5839:25 +VALKENBURG, NETHERLANDS:52.1697:4.42611:36 +VALLADOLID, SPAIN:41.7061:W4.85194:197 +VALLE DE LA PASCUA, VENEZUELA:9.22194:W65.9933:32 +VALLEDUPAR, COLOMBIA:10.435:W73.2494:1000 +VALPARAISO, USA:30.4831:W86.5253:42 +VANCOUVER, CANADA:49.195:W123.182:115 +VANDERBIJLPARK, SOUTH AFRICA:-26.6922:27.7778:177 +VANNES, FRANCE:47.7231:W2.71833:32 +VAN, TURKEY:38.4681:43.3322:1938 +VARADERO, CUBA:23.0344:W81.4353:48 +VARANASI, INDIA:25.4519:82.8589:82 +VARAZDIN, CROATIA:46.2947:16.3811:59 +VARKAUS, FINLAND:62.1711:27.8686:524 +VARNA, BULGARIA:43.2319:27.825:238 +VASTERAS, SWEDEN:59.5894:16.6336:52 +VAVA'U, TONGA:-18.5853:W173.963:138 +VAXJO, SWEDEN:56.9289:14.7278:29 +VENICE, ITALY:45.505:12.3517:414 +VERA CRUZ, MEXICO:19.1453:W96.1869:20 +VERDUN, FRANCE:49.1222:5.46889:31 +VEREENIGING, SOUTH AFRICA:-26.5661:27.9606:188 +VERMILLION, CANADA:53.3558:W110.824:13 +VERO BEACH, USA:27.6556:W80.4178:34 +VERONA, ITALY:45.4719:10.9278:1426:206 +VESOUL-FROTEY, FRANCE:47.6375:6.20389:7 +VESTHIMMERLAND, DENMARK:56.8469:9.45861 +VESTMANNAEYJAR, ICELAND:63.4242:W20.2786 +VICENZA, ITALY:45.5742:11.5306:80 +VICHY, FRANCE:46.1694:3.40361:5 +VICTORIA, CANADA:48.6469:W123.426:19 +VICTORIA FALLS, ZIMBABWE:-18.0961:25.8392:10 +VICTORVILLE, USA:34.5931:W117.379:191 +VIEDMA, ARGENTINA:-40.8692:W63.0003:28 +VIENNA, AUSTRIA:48.1103:16.5697:414 +VIENTIANE, LAOS:17.9881:102.563:1000 +VIGAN, PHILIPPINES:17.5536:120.357:28 +VIGO, SPAIN:42.2317:W8.62667:109 +VIJAYAWADA, INDIA:16.5303:80.7967:61 +VILA REAL, ACORES:41.2742:W7.72028:378 +VILEFRANCE, FRANCE:45.9164:4.64056:4 +VILHELMINA, SWEDEN:64.5789:16.8333:3 +VILHENA, BRAZIL:-12.6942:W60.0981:5 +VILLACOUBLAY, FRANCE:48.7742:2.20139:8 +VILLA DOLORES, ARGENTINA:-31.9411:W65.1422:1 +VILLA GESELL, ARGENTINA:-37.2353:W57.0292:1 +VILLAHERMOSA, MEXICO:17.9969:W92.8172:63 +VILLAVICENCIO, COLOMBIA:4.16778:W73.6136:1338 +VILLENEUVE-SUR-LOT, FRANCE:44.3969:0.758889:81 +VILSECK, GERMANY:49.6336:11.7672:64 +VILSHOFEN, GERMANY:48.6364:13.1953:86 +VIRAC, PHILIPPINES:13.5775:124.206:152 +VISBY, SWEDEN:57.6628:18.3461:12 +VISEU, ACORES:40.7253:W7.88889:10 +VITEBSK, RUSSIA:55.1667:30.1333:124 +VITERBO, ITALY:42.4303:12.0642:406 +VITORIA, BRAZIL:-20.2556:W40.2889:93 +VITORIA DA CONQUISTA, BRAZIL:-14.8625:W40.8631:10 +VITORIA, SPAIN:42.8828:W2.72444:276 +VLADIVOSTOK, RUSSIA:43.3989:132.151:331 +VODOCHODY, CZECH REPUBLIC:50.2164:14.3956:4 +VOHEMAR, MADAGASCAR:-13.3758:50.0028 +VOLGOGRAD, RUSSIA:48.7822:44.3447:859 +VORONEZH, RUSSIA:51.8142:39.2297:596 +VREDENDAL, SOUTH AFRICA:-31.6408:18.5447 +VRSAC, YUGOSLAVIA:45.1467:21.3097 +VRYBURG, SOUTH AFRICA:-26.9822:24.7286 +VRYHEID, SOUTH AFRICA:-27.7867:30.7953 +WABUSH, CANADA:52.9219:W66.8644 +WACO, USA:31.6111:W97.2303:231 +WADI-AL-DAWASIR, SAUDI ARABIA:20.5042:45.1994 +WAGGA WAGGA, AUSTRALIA:-35.1653:147.466:2 +WA, GHANA:10.0825:W2.5075 +WAIKABUBAK, INDONESIA:-9.40944:119.246 +WAINGAPU, INDONESIA:-9.67:120.304 +WAIOURU, NEW ZEALAND:-39.4464:175.658 +WAIROA, NEW ZEALAND:-39.0069:177.407 +WAJIR, KENYA:1.73306:40.0914 +WAKKANAI, JAPAN:45.4039:141.801 +WALLIS, WALLIS & FUTUNA:-13.2381:W176.199 +WALLOPS ISLAND, USA:37.94:W75.4664 +WAMENA, INDONESIA:-4.09611:138.952 +WANAKA, NEW ZEALAND:-44.7253:169.243 +WANA, PAKISTAN:32.3053:69.5694 +WANG AN, TAIWAN:23.3708:119.494 +WANGANUI, NEW ZEALAND:-39.9622:175.025 +WARANGAL, INDIA:17.9181:79.5986 +WARSAW, POLAND:52.1656:20.9669 +WARTON, UK:53.745:W2.88306 +WASHINGTON, USA:38.9444:W77.4556:217 +WATERFORD, IRELAND:52.1869:W7.08694:48 +WATERKLOOF, SOUTH AFRICA:-25.83:28.2225 +WATERLOO, CANADA:43.4589:W80.3844 +WATERTOWN, USA:43.9917:W76.0217 +WATSON LAKE, CANADA:60.1178:W128.822 +WAU, SUDAN:7.72556:27.9794 +WEELDE, BELGIUM:51.395:4.96056 +WEERT, NETHERLANDS:51.2553:5.60139:105 +WEIPA, AUSTRALIA:-12.6786:141.925:10 +WEJH, SAUDI ARABIA:26.1975:36.4761 +WELKOM, SOUTH AFRICA:-27.9978:26.6694 +WELLINGTON, NEW ZEALAND:-41.3272:174.805 +WELS, AUSTRIA:48.1831:14.0408 +WENDOVER, USA:40.7186:W114.031 +WESSELSBRUNN, SOUTH AFRICA:-27.8472:26.3497 +WEST END, BAHAMAS:26.6861:W78.9775 +WESTERLAND, GERMANY:54.9131:8.34028:10 +WEST FREUGH, UK:54.8511:W4.94778 +WEST HAMPTON BEACH, USA:40.8436:W72.6317 +WEST PALM BEACH, USA:26.5928:W80.085 +WESTPORT, NEW ZEALAND:-41.7381:171.581 +WEST TINIAN, MARIANA ISLANDS:14.9978:145.619 +WEWAK, PAPUA NEW GUINEA:-3.58361:143.669 +WEYDON, NEW ZEALAND:-77.8833:166.65 +WHAKATANE, NEW ZEALAND:-37.9206:176.914 +WHANGAREI, NEW ZEALAND:-35.7683:174.365 +WHEELER AFB., USA:21.4833:W158.039 +WHENUAPAI, NEW ZEALAND:-36.7878:174.63 +WHIDBEY ISLAND, USA:48.3517:W122.656 +WHITECOURT, CANADA:54.1439:W115.787 +WHITEHORSE, CANADA:60.7094:W135.068 +WHITE PLAINS, USA:41.0669:W73.7075 +WHITE SANDS, USA:32.3414:W106.403 +WHOK SEUMAWE, INDONESIA:5.22667:96.9503 +WIARTON, CANADA:44.7458:W81.1072 +WICHITA, USA:37.6228:W97.2672:413 +WICK, UK:58.4586:W3.09278 +WIDE AWAKE, ACORES:-7.96944:W14.3936 +WIENER NEUSTADT OST, AUSTRIA:47.8433:16.26 +WIESBADEN, GERMANY:50.0497:8.32528 +WIGRAM, NEW ZEALAND:-43.5511:172.553 +WILDWOOD, USA:39.0083:W74.9081 +WILHELMSHAVEN, GERMANY:53.5047:8.05333:106 +WILLEMSTAD, ANTILLES:12.1886:W68.9597 +WILLIAMS LAKE, CANADA:52.1831:W122.054 +WILLIAMSPORT, USA:41.2419:W76.9211 +WILLISTON, USA:48.1778:W103.642 +WILLOW GROVE, USA:40.1997:W75.1481 +WILMINGTON, USA:34.2706:W77.9025 +WINDSOR, CANADA:42.2756:W82.9556 +WINDSOR LOCKS, USA:41.9389:W72.6831 +WINK, USA:31.7794:W103.201 +WINNIPEG, CANADA:49.91:W97.2344 +WINSTON-SALEM, USA:36.1336:W80.2219 +WIRAWILA, SRI LANKA:6.25444:81.235 +WITBANK, SOUTH AFRICA:-25.8322:29.1919 +WITTERING, UK:52.6125:W0.476389:13 +WITTMUNDHAFEN, GERMANY:53.5478:7.66722 +WOENSDRECHT, NETHERLANDS:51.4489:4.34194:91 +WONJU, KOREA:37.4381:127.96 +WOODBOURNE, NEW ZEALAND:-41.5183:173.87 +WOODFORT, ENGLAND:53.3381:W2.14889 +WOODVALE, UK:53.5814:W3.05528 +WORMS, GERMANY:49.6064:8.36833:108 +WRIGHTSTOWN, USA:40.0156:W74.5936 +WRIGHT, USA:31.8889:W81.5622 +WRIGLEY, CANADA:63.2094:W123.437 +WROCLAW, POLAND:51.1025:16.8858 +WUHAN, CHINA:30.7836:114.208:8494 +WUNSTORF, GERMANY:52.4572:9.42694 +XANGONGO, ANGOLA:-16.7553:14.9653 +XIAMEN, CHINA:24.5439:118.127:1701 +XI'AN, CHINA:34.4458:108.752:10000 +XICHANG, CHINA:27.9886:102.184:2655 +YAIZU, JAPAN:34.8125:138.298 +YAKUSHIMA, JAPAN:30.3856:130.659 +YAKUTAT, USA:59.5031:W139.66 +YAKUTSK, RUSSIA:62.0931:129.771:122 +YA LA, THAILAND:6.52667:101.242 +YALINGA, CENTRAL AFRICAN REP.:45.1542:W89.1108 +YALOVA, TURKEY:40.6833:29.3786:167 +YAMAGATA, JAPAN:38.4117:140.371 +YAMAGUCHI, JAPAN:33.93:131.279 +YAMOUSSOUKRO, IVORY COAST:6.90306:W5.36583 +YANGKU, KOREA:38.0611:128.669 +YANGON, MYANMAR:16.9072:96.1331 +YANJI, CHINA:42.8817:129.448:1748 +YANTAI, CHINA:37.4017:121.372:13740 +YAOUNDE, CAMEROON:3.83528:11.5236 +YAP, MICRONESIA:9.49861:138.082 +YAZD, IRAN:31.9047:54.2764 +YECHON, KOREA:36.6317:128.355 +YELLOWKNIFE, CANADA:62.4628:W114.44 +YENBO, SAUDI ARABIA:24.1442:38.0633 +YENISEHIR, TURKEY:40.255:29.5625:786 +YEOSU, KOREA:34.8397:127.615 +YEOVILTON, UK:51.0092:W2.63861 +YEREVAN, RUSSIA:40.1483:44.3967:223 +YLIVIESKA-RAUDASKYLA, FINLAND:64.0603:24.7158 +YOGYAKARTA, INDONESIA:-7.78806:110.432 +YOKOTA, JAPAN:35.7483:139.348 +YOLA, NIGERIA:9.26028:12.4297 +YONAGUNI JIMA, JAPAN:24.4669:122.978 +YORKTON, CANADA:51.2647:W102.462 +YORON, JAPAN:27.0439:128.401 +YOUNGSTOWN, USA:41.2606:W80.6789 +YSTERPLAAT, SOUTH AFRICA:-33.9:18.4981 +YUMA, USA:32.6564:W114.606 +YURIMAGUAS, PERU:-5.89389:W76.1183 +YUZHNO-SAKHALINSK, RUSSIA:46.8886:142.717:164 +ZABOL, IRAN:31.0969:61.5439 +ZACATECAS, MEXICO:22.8969:W102.687 +ZADAR, CROATIA:44.1081:15.3467 +ZAGREB, CROATIA:45.7428:16.0686 +ZAHEDAN, IRAN:29.4761:60.9058 +ZAMA, JAPAN:35.5136:139.394 +ZAMBEZI, ZAMBIA:-13.5386:23.1097 +ZAMBOANGA, PHILIPPINES:6.92222:122.059 +ZAMORA, MEXICO:20.045:W102.276 +ZANDERY, SURINAM:5.45278:W55.1878 +ZANJAN, IRAN:36.7742:48.3597 +ZANZIBAR, TANZANIA:-6.22194:39.2247 +ZAPOPAN, MEXICO:20.7558:W103.465 +ZARAGOZA, SPAIN:41.6661:W1.04139 +ZARGHAN, IRAN:29.7542:52.6942 +ZARIA, NIGERIA:11.13:7.68556 +ZARZAITINE, ALGERIA:28.0514:9.64278 +ZEERUST, SOUTH AFRICA:-25.5989:26.0422 +ZELLA 74, LIBYA:28.59:17.2942 +ZELTWEG, AUSTRIA:47.2028:14.7442 +ZERO, INDIA:27.5881:93.8283 +ZHENGZHOU, CHINA:34.5194:113.841:7507 +ZHOB, PAKISTAN:31.3583:69.4633 +ZIELONA GORA, POLAND:52.1386:15.7986 +ZIGUINCHOR, SENEGAL:12.5556:W16.2817 +ZIHUATANEJO, MEXICO:17.6014:W101.46 +ZILINA, SLOVAKIA:49.2317:18.6136 +ZINDER, NIGER:13.7789:8.98361 +ZIRKU, UNITED ARAB EMIRATES:24.8614:53.0778 +ZISCO, ZIMBABWE:-19.0286:29.7219 +ZOERSEL, BELGIUM:51.2656:4.75472 +ZONGULDAK, TURKEY:41.5158:32.0997:633 +ZUNI PUEBLO, USA:35.0833:W108.792 +ZURICH AREA, SWITZERLAND:46.6136:7.67778 +ZURICH, SWITZERLAND:47.3833:8.56667 +ZUTENDAAL, BELGIUM:50.9483:5.59167 +ZVISHAVANE, ZIMBABWE:-20.2894:30.0883 +ZWEIBRUECKEN, GERMANY:49.2094:7.40056:70 diff --git a/locations_nogo.txt b/locations_nogo.txt new file mode 100644 index 000000000..4b2e3c591 --- /dev/null +++ b/locations_nogo.txt @@ -0,0 +1,26 @@ +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 +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 +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 +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 +BERLIN, GERMANY: 13.491E,52.469, 13.416E,52.512, 13.378E,52.517, 13.377E,52.51, 13.336E,52.508, 13.323E,52.518, 13.286E,52.518, 13.295E,52.533, 13.309E,52.522, 13.322E,52.526, 13.395E,52.524, 13.421E,52.516, 13.465E,52.499, 13.471E,52.502, 13.498E,52.487, 13.501E,52.479, 13.51E,52.46 +ISTANBUL, TURKEY: 28.758E,41.117, 28.781E,41.001, 28.871E,40.978, 28.963E,41.013, 28.923E,41.057, 28.949E,41.071, 28.952E,41.047, 28.979E,41.031, 28.998E,41.045, 29.026E,41.052, 29.05073E,41.101, 29.059E,41.127, 29.028E,41.158, 29.095E,41.214, 29.135E,41.196, 29.084E,41.145, 29.107E,41.12, 29.074E,41.098, 29.068E,41.075, 29.057E,41.046, 29.014E,41.022, 29.033E,40.987, 29.103E,40.953, 29.147E,40.907, 29.186E,40.889, 29.232E,40.877, 29.199E,40.82, 28.609E,40.932 +MADRID, SPAIN: 3.774W,40.397, 3.713W,40.415, 3.712W,40.422, 3.721W,40.432, 3.717W,40.435, 3.713W,40.463, 3.729W,40.46, 3.737W,40.475, 3.748W,40.472, 3.749W,40.477, 3.766W,40.472, 3.762W,40.458, 3.788W,40.442, 3.789W,40.427, 3.781W,40.43, 3.777W,40.426, 3.782W,40.419, 3.778W,40.407 +MADRID, SPAIN: 3.688W,40.407, 3.678W,40.408, 3.675W,40.411, 3.679W,40.422, 3.688W,40.42, 3.689W,40.416 +MADRID, SPAIN: 3.654W,40.412, 3.617W,40.405, 3.604W,40.411, 3.607W,40.419, 3.62W,40.415, 3.623W,40.419, 3.627W,40.419, 3.646W,40.427, 3.649W,40.419, 3.657W,40.422 +LOS ANGELES, USA: 118.475W,34.146, 118.499W,34.024, 118.368W,33.832, 118.254W,33.769, 118.114W,33.772, 117.891W,33.62, 117.471W,33.333, 117.954W,32.915, 119.299W,33.779 +SAN JOSE, 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 +SAN JOSE, 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 +OAKLAND, 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 +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 +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 +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 +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 +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 +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 +BRISTOL, ENGLAND: 2.59W,51.449, 2.608W,51.45, 2.62W,51.448, 2.626W,51.461, 2.614W,51.471, 2.615W,51.484, 2.635W,51.471, 2.651W,51.484, 2.653W,51.491, 2.589W,51.518, 2.688W,51.52, 2.686W,51.362, 2.632W,51.404, 2.627W,51.421, 2.61W,51.43, 2.62W,51.439, 2.614W,51.444, 2.598W,51.445 +BRISTOL, ENGLAND: 2.572W,51.479, 2.559W,51.493, 2.554W,51.496, 2.512W,51.496, 2.524W,51.486, 2.551W,51.472 +BOSTON, USA: 70.773W,42.238, 70.964W,42.22, 70.997W,42.258, 71.055W,42.303, 71.049W,42.333, 71.038W,42.344, 71.101W,42.406, 71.046W,42.389, 70.993W,42.404, 70.991W,42.418, 71.021W,42.433, 71.012W,42.444, 70.917W,42.47, 70.891W,42.494, 70.934W,42.56, 70.88W,42.549, 70.762W,42.575 +BOSTON, USA: 71.12W,42.358, 71.107W,42.354, 71.079W,42.363, 71.071W,42.372, 71.06W,42.365, 71.067W,42.365, 71.072W,42.355, 71.094W,42.348, 71.115W,42.35 +BOSTON, USA: 71.128W,42.425, 71.127W,42.459, 71.088W,42.471, 71.069W,42.44 +BOSTON, USA: 71.147W,42.364, 71.16W,42.386, 71.162W,42.405, 71.149W,42.424, 71.154W,42.432, 71.17W,42.439, 71.164W,42.447, 71.154W,42.436, 71.145W,42.446, 71.141W,42.442, 71.144W,42.437, 71.142W,42.424, 71.148W,42.406, 71.139W,42.386, 71.139W,42.374, 71.125W,42.363 +CAIRO, EGYPT: 31.148E,30.15, 31.177E,30.133, 31.209E,30.121, 31.225E,30.091, 31.22E,30.075, 31.211E,30.065, 31.218E,30.039, 31.215E,30.012, 31.212E,29.999, 31.216E,29.991, 31.214E,29.977, 31.266E,29.936, 31.275E,29.911, 31.286E,29.854, 31.286E,29.831, 31.285E,29.78, 31.294E,29.776, 31.298E,29.801, 31.296E,29.84, 31.292E,29.864, 31.288E,29.893, 31.284E,29.926, 31.273E,29.944, 31.238E,29.973, 31.228E,29.999, 31.223E,30.019, 31.228E,30.034, 31.233E,30.045, 31.227E,30.071, 31.244E,30.106, 31.236E,30.125, 31.221E,30.132, 31.183E,30.14, 31.164E,30.149, 31.143E,30.172, 31.106E,30.242 diff --git a/manualapprove.py b/manualapprove.py index 9d2944d8d..18b1b320b 100644 --- a/manualapprove.py +++ b/manualapprove.py @@ -5,12 +5,17 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "ActivityPub" import os from follow import followedAccountAccepts from follow import followedAccountRejects from follow import removeFromFollowRequests from utils import loadJson +from utils import removeDomainPort +from utils import getPortFromDomain +from utils import getUserPaths +from utils import acctDir def manualDenyFollowRequest(session, baseDir: str, @@ -24,8 +29,7 @@ def manualDenyFollowRequest(session, baseDir: str, projectVersion: str) -> None: """Manually deny a follow request """ - handle = nickname + '@' + domain - accountsDir = baseDir + '/accounts/' + handle + accountsDir = acctDir(baseDir, nickname, domain) # has this handle already been rejected? rejectedFollowsFilename = accountsDir + '/followrejects.txt' @@ -40,17 +44,16 @@ def manualDenyFollowRequest(session, baseDir: str, removeFromFollowRequests(baseDir, nickname, domain, denyHandle, debug) # Store rejected follows - rejectsFile = open(rejectedFollowsFilename, "a+") - rejectsFile.write(denyHandle + '\n') - rejectsFile.close() + with open(rejectedFollowsFilename, 'a+') as rejectsFile: + rejectsFile.write(denyHandle + '\n') denyNickname = denyHandle.split('@')[0] denyDomain = \ denyHandle.split('@')[1].replace('\n', '').replace('\r', '') denyPort = port if ':' in denyDomain: - denyPort = denyDomain.split(':')[1] - denyDomain = denyDomain.split(':')[0] + denyPort = getPortFromDomain(denyDomain) + denyDomain = removeDomainPort(denyDomain) followedAccountRejects(session, baseDir, httpPrefix, nickname, domain, port, denyNickname, denyDomain, denyPort, @@ -69,13 +72,11 @@ def _approveFollowerHandle(accountDir: str, approveHandle: str) -> None: approvedFilename = accountDir + '/approved.txt' if os.path.isfile(approvedFilename): if approveHandle not in open(approvedFilename).read(): - approvedFile = open(approvedFilename, "a+") - approvedFile.write(approveHandle + '\n') - approvedFile.close() + with open(approvedFilename, 'a+') as approvedFile: + approvedFile.write(approveHandle + '\n') else: - approvedFile = open(approvedFilename, "w+") - approvedFile.write(approveHandle + '\n') - approvedFile.close() + with open(approvedFilename, 'w+') as approvedFile: + approvedFile.write(approveHandle + '\n') def manualApproveFollowRequest(session, baseDir: str, @@ -111,18 +112,12 @@ def manualApproveFollowRequest(session, baseDir: str, reqNick = approveHandle.split('@')[0] reqDomain = approveHandle.split('@')[1].strip() reqPrefix = httpPrefix + '://' + reqDomain - if reqPrefix + '/profile/' + reqNick in approveFollowsStr: - exists = True - approveHandleFull = reqPrefix + '/profile/' + reqNick - elif reqPrefix + '/channel/' + reqNick in approveFollowsStr: - exists = True - approveHandleFull = reqPrefix + '/channel/' + reqNick - elif reqPrefix + '/accounts/' + reqNick in approveFollowsStr: - exists = True - approveHandleFull = reqPrefix + '/accounts/' + reqNick - elif reqPrefix + '/u/' + reqNick in approveFollowsStr: - exists = True - approveHandleFull = reqPrefix + '/u/' + reqNick + paths = getUserPaths() + for userPath in paths: + if reqPrefix + userPath + reqNick in approveFollowsStr: + exists = True + approveHandleFull = reqPrefix + userPath + reqNick + break if not exists: print('Manual follow accept: ' + approveHandleFull + ' not in requests file "' + @@ -130,53 +125,58 @@ def manualApproveFollowRequest(session, baseDir: str, '" ' + approveFollowsFilename) return - approvefilenew = open(approveFollowsFilename + '.new', 'w+') - updateApprovedFollowers = False - followActivityfilename = None - with open(approveFollowsFilename, 'r') as approvefile: - for handleOfFollowRequester in approvefile: - # is this the approved follow? - if handleOfFollowRequester.startswith(approveHandleFull): - handleOfFollowRequester = \ - handleOfFollowRequester.replace('\n', '').replace('\r', '') - port2 = port - if ':' in handleOfFollowRequester: - port2Str = handleOfFollowRequester.split(':')[1] - if port2Str.isdigit(): - port2 = int(port2Str) - requestsDir = accountDir + '/requests' - followActivityfilename = \ - requestsDir + '/' + handleOfFollowRequester + '.follow' - if os.path.isfile(followActivityfilename): - followJson = loadJson(followActivityfilename) - if followJson: - approveNickname = approveHandle.split('@')[0] - approveDomain = approveHandle.split('@')[1] - approveDomain = \ - approveDomain.replace('\n', '').replace('\r', '') - approvePort = port2 - if ':' in approveDomain: - approvePort = approveDomain.split(':')[1] - approveDomain = approveDomain.split(':')[0] - print('Manual follow accept: Sending Accept for ' + - handle + ' follow request from ' + - approveNickname + '@' + approveDomain) - followedAccountAccepts(session, baseDir, httpPrefix, - nickname, domain, port, - approveNickname, approveDomain, - approvePort, - followJson['actor'], - federationList, - followJson, - sendThreads, postLog, - cachedWebfingers, personCache, - debug, projectVersion, False) - updateApprovedFollowers = True - else: - # this isn't the approved follow so it will remain - # in the requests file - approvefilenew.write(handleOfFollowRequester) - approvefilenew.close() + with open(approveFollowsFilename + '.new', 'w+') as approvefilenew: + updateApprovedFollowers = False + followActivityfilename = None + with open(approveFollowsFilename, 'r') as approvefile: + for handleOfFollowRequester in approvefile: + # is this the approved follow? + if handleOfFollowRequester.startswith(approveHandleFull): + handleOfFollowRequester = \ + handleOfFollowRequester.replace('\n', '') + handleOfFollowRequester = \ + handleOfFollowRequester.replace('\r', '') + port2 = port + if ':' in handleOfFollowRequester: + port2 = getPortFromDomain(handleOfFollowRequester) + requestsDir = accountDir + '/requests' + followActivityfilename = \ + requestsDir + '/' + handleOfFollowRequester + '.follow' + if os.path.isfile(followActivityfilename): + followJson = loadJson(followActivityfilename) + if followJson: + approveNickname = approveHandle.split('@')[0] + approveDomain = approveHandle.split('@')[1] + approveDomain = \ + approveDomain.replace('\n', '') + approveDomain = \ + approveDomain.replace('\r', '') + approvePort = port2 + if ':' in approveDomain: + approvePort = getPortFromDomain(approveDomain) + approveDomain = removeDomainPort(approveDomain) + print('Manual follow accept: Sending Accept for ' + + handle + ' follow request from ' + + approveNickname + '@' + approveDomain) + followedAccountAccepts(session, baseDir, + httpPrefix, + nickname, domain, port, + approveNickname, + approveDomain, + approvePort, + followJson['actor'], + federationList, + followJson, + sendThreads, postLog, + cachedWebfingers, + personCache, + debug, + projectVersion, False) + updateApprovedFollowers = True + else: + # this isn't the approved follow so it will remain + # in the requests file + approvefilenew.write(handleOfFollowRequester) followersFilename = accountDir + '/followers.txt' if updateApprovedFollowers: @@ -200,9 +200,8 @@ def manualApproveFollowRequest(session, baseDir: str, else: print('Manual follow accept: first follower accepted for ' + handle + ' is ' + approveHandleFull) - followersFile = open(followersFilename, "w+") - followersFile.write(approveHandleFull + '\n') - followersFile.close() + with open(followersFilename, 'w+') as followersFile: + followersFile.write(approveHandleFull + '\n') # only update the follow requests file if the follow is confirmed to be # in followers.txt diff --git a/markdown.py b/markdown.py new file mode 100644 index 000000000..eb0cbf2d7 --- /dev/null +++ b/markdown.py @@ -0,0 +1,161 @@ +__filename__ = "markdown.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@freedombone.net" +__status__ = "Production" +__module_group__ = "Web Interface" + + +def _markdownEmphasisHtml(markdown: str) -> str: + """Add italics and bold html markup to the given markdown + """ + replacements = { + ' **': ' ', + '** ': ' ', + '**.': '.', + '**:': ':', + '**;': ';', + '**,': ',', + '**\n': '\n', + ' *': ' ', + '* ': ' ', + '*.': '.', + '*:': ':', + '*;': ';', + '*,': ',', + '*\n': '\n', + ' _': ' ', + '_.': '.', + '_:': ':', + '_;': ';', + '_,': ',', + '_\n': '\n' + } + for md, html in replacements.items(): + markdown = markdown.replace(md, html) + + if markdown.startswith('**'): + markdown = markdown[2:] + '' + elif markdown.startswith('*'): + markdown = markdown[1:] + '' + elif markdown.startswith('_'): + markdown = markdown[1:] + '' + return markdown + + +def _markdownReplaceQuotes(markdown: str) -> str: + """Replaces > quotes with html blockquote + """ + if '> ' not in markdown: + return markdown + lines = markdown.split('\n') + result = '' + prevQuoteLine = None + for line in lines: + if '> ' not in line: + result += line + '\n' + prevQuoteLine = None + continue + lineStr = line.strip() + if not lineStr.startswith('> '): + result += line + '\n' + prevQuoteLine = None + continue + lineStr = lineStr.replace('> ', '', 1).strip() + if prevQuoteLine: + newPrevLine = prevQuoteLine.replace('\n', '') + result = result.replace(prevQuoteLine, newPrevLine) + ' ' + lineStr += '\n' + else: + lineStr = '
' + lineStr + '
\n' + result += lineStr + prevQuoteLine = lineStr + + if '\n' in result: + result = result.replace('\n', '') + + if result.endswith('\n') and \ + not markdown.endswith('\n'): + result = result[:len(result) - 1] + return result + + +def _markdownReplaceLinks(markdown: str, images: bool = False) -> str: + """Replaces markdown links with html + Optionally replace image links + """ + replaceLinks = {} + text = markdown + startChars = '[' + if images: + startChars = '![' + while startChars in text: + if ')' not in text: + break + text = text.split(startChars, 1)[1] + markdownLink = startChars + text.split(')')[0] + ')' + if ']' not in markdownLink or \ + '(' not in markdownLink: + text = text.split(')', 1)[1] + continue + if not images: + replaceLinks[markdownLink] = \ + '' + \ + markdownLink.split(startChars)[1].split(']')[0] + \ + '' + else: + replaceLinks[markdownLink] = \ + '' + \
+                markdownLink.split(startChars)[1].split(']')[0] + \
+                '' + text = text.split(')', 1)[1] + for mdLink, htmlLink in replaceLinks.items(): + markdown = markdown.replace(mdLink, htmlLink) + return markdown + + +def markdownToHtml(markdown: str) -> str: + """Converts markdown formatted text to html + """ + markdown = _markdownReplaceQuotes(markdown) + markdown = _markdownEmphasisHtml(markdown) + markdown = _markdownReplaceLinks(markdown, True) + markdown = _markdownReplaceLinks(markdown) + + # replace headers + linesList = markdown.split('\n') + htmlStr = '' + ctr = 0 + titles = { + "h5": '#####', + "h4": '####', + "h3": '###', + "h2": '##', + "h1": '#' + } + for line in linesList: + if ctr > 0: + htmlStr += '
' + for h, hashes in titles.items(): + if line.startswith(hashes): + line = line.replace(hashes, '').strip() + line = '<' + h + '>' + line + '' + ctr = -1 + break + htmlStr += line + ctr += 1 + return htmlStr diff --git a/mastoapiv1.py b/mastoapiv1.py index 93384289c..93271f86d 100644 --- a/mastoapiv1.py +++ b/mastoapiv1.py @@ -5,12 +5,16 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "API" import os from utils import loadJson +from utils import getConfigParam +from utils import acctDir +from metadata import metaDataInstance -def getMastApiV1Id(path: str) -> int: +def _getMastApiV1Id(path: str) -> int: """Extracts the mastodon Id number from the given path """ mastoId = None @@ -46,13 +50,12 @@ def getNicknameFromMastoApiV1Id(mastoId: int) -> str: return nickname[::-1] -def getMastoApiV1Account(baseDir: str, nickname: str, domain: str) -> {}: +def _getMastoApiV1Account(baseDir: str, nickname: str, domain: str) -> {}: """See https://github.com/McKael/mastodon-documentation/ blob/master/Using-the-API/API.md#account Authorization has already been performed """ - accountFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + '.json' + accountFilename = acctDir(baseDir, nickname, domain) + '.json' if not os.path.isfile(accountFilename): return {} accountJson = loadJson(accountFilename) @@ -76,3 +79,140 @@ def getMastoApiV1Account(baseDir: str, nickname: str, domain: str) -> {}: "header_static": accountJson['image']['url'] } return mastoAccountJson + + +def mastoApiV1Response(path: str, callingDomain: str, + authorized: bool, + httpPrefix: str, + baseDir: str, nickname: str, domain: str, + domainFull: str, + onionDomain: str, i2pDomain: str, + translate: {}, + registration: bool, + systemLanguage: str, + projectVersion: str, + customEmoji: [], + showNodeInfoAccounts: bool, + brochMode: bool) -> ({}, str): + """This is a vestigil mastodon API for the purpose + of returning an empty result to sites like + https://mastopeek.app-dist.eu + """ + 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' + elif path.startswith('/api/v1/custom_emojis'): + sendJson = customEmoji + sendJsonStr = 'masto API custom emojis sent' + + adminNickname = getConfigParam(baseDir, 'admin') + if adminNickname and path == '/api/v1/instance': + instanceDescriptionShort = \ + getConfigParam(baseDir, + 'instanceDescriptionShort') + if not instanceDescriptionShort: + instanceDescriptionShort = \ + translate['Yet another Epicyon Instance'] + instanceDescription = getConfigParam(baseDir, + 'instanceDescription') + instanceTitle = getConfigParam(baseDir, 'instanceTitle') + + if callingDomain.endswith('.onion') and onionDomain: + domainFull = onionDomain + httpPrefix = 'http' + elif (callingDomain.endswith('.i2p') and i2pDomain): + domainFull = i2pDomain + httpPrefix = 'http' + + if brochMode: + showNodeInfoAccounts = False + + sendJson = \ + metaDataInstance(showNodeInfoAccounts, + instanceTitle, + instanceDescriptionShort, + instanceDescription, + httpPrefix, + baseDir, + adminNickname, + domain, + domainFull, + registration, + systemLanguage, + 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', domainFull] + sendJsonStr = 'masto API peers metadata sent' + elif path.startswith('/api/v1/instance/activity'): + sendJson = [] + sendJsonStr = 'masto API activity metadata sent' + return sendJson, sendJsonStr diff --git a/matrix.py b/matrix.py index 958b36618..0d493b0dc 100644 --- a/matrix.py +++ b/matrix.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Profile Metadata" def getMatrixAddress(actorJson: {}) -> str: diff --git a/media.py b/media.py index eb0df3d43..dc4ba5346 100644 --- a/media.py +++ b/media.py @@ -5,9 +5,12 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Timeline" import os import datetime +import subprocess +from random import randint from hashlib import sha1 from auth import createPassword from utils import getFullDomain @@ -15,9 +18,12 @@ from utils import getImageExtensions from utils import getVideoExtensions from utils import getAudioExtensions from utils import getMediaExtensions +from utils import hasObjectDict +from utils import acctDir from shutil import copyfile from shutil import rmtree from shutil import move +from city import spoofGeolocation def replaceYouTube(postJsonObject: {}, replacementDomain: str) -> None: @@ -26,7 +32,7 @@ def replaceYouTube(postJsonObject: {}, replacementDomain: str) -> None: """ if not replacementDomain: return - if not isinstance(postJsonObject['object'], dict): + if not hasObjectDict(postJsonObject): return if not postJsonObject['object'].get('content'): return @@ -37,7 +43,7 @@ def replaceYouTube(postJsonObject: {}, replacementDomain: str) -> None: replacementDomain) -def removeMetaData(imageFilename: str, outputFilename: str) -> None: +def _removeMetaData(imageFilename: str, outputFilename: str) -> None: """Attempts to do this with pure python didn't work well, so better to use a dedicated tool if one is installed """ @@ -53,7 +59,76 @@ def removeMetaData(imageFilename: str, outputFilename: str) -> None: os.system('/usr/bin/mogrify -strip ' + outputFilename) # nosec +def _spoofMetaData(baseDir: str, nickname: str, domain: str, + outputFilename: str, spoofCity: str) -> None: + """Spoof image metadata using a decoy model for a given city + """ + if not os.path.isfile(outputFilename): + print('ERROR: unable to spoof metadata within ' + outputFilename) + return + + # get the random seed used to generate a unique pattern for this account + decoySeedFilename = acctDir(baseDir, nickname, domain) + '/decoyseed' + decoySeed = 63725 + if os.path.isfile(decoySeedFilename): + with open(decoySeedFilename, 'r') as fp: + decoySeed = int(fp.read()) + else: + decoySeed = randint(10000, 10000000000000000) + try: + with open(decoySeedFilename, 'w+') as fp: + fp.write(str(decoySeed)) + except BaseException: + pass + + if os.path.isfile('/usr/bin/exiftool'): + print('Spoofing metadata in ' + outputFilename + ' using exiftool') + currTimeAdjusted = \ + datetime.datetime.utcnow() - \ + datetime.timedelta(minutes=randint(2, 120)) + published = currTimeAdjusted.strftime("%Y:%m:%d %H:%M:%S+00:00") + (latitude, longitude, latitudeRef, longitudeRef, + camMake, camModel, camSerialNumber) = \ + spoofGeolocation(baseDir, spoofCity, currTimeAdjusted, + decoySeed, None, None) + os.system('exiftool -artist="' + nickname + '" ' + + '-Make="' + camMake + '" ' + + '-Model="' + camModel + '" ' + + '-Comment="' + str(camSerialNumber) + '" ' + + '-DateTimeOriginal="' + published + '" ' + + '-FileModifyDate="' + published + '" ' + + '-CreateDate="' + published + '" ' + + '-GPSLongitudeRef=' + longitudeRef + ' ' + + '-GPSAltitude=0 ' + + '-GPSLongitude=' + str(longitude) + ' ' + + '-GPSLatitudeRef=' + latitudeRef + ' ' + + '-GPSLatitude=' + str(latitude) + ' ' + + '-Comment="" ' + + outputFilename) # nosec + else: + print('ERROR: exiftool is not installed') + return + + +def processMetaData(baseDir: str, nickname: str, domain: str, + imageFilename: str, outputFilename: str, + city: str) -> None: + """Handles image metadata. This tries to spoof the metadata + if possible, but otherwise just removes it + """ + # first remove the metadata + _removeMetaData(imageFilename, outputFilename) + + # now add some spoofed data to misdirect surveillance capitalists + _spoofMetaData(baseDir, nickname, domain, outputFilename, city) + + def _isMedia(imageFilename: str) -> bool: + """Is the given file a media file? + """ + if not os.path.isfile(imageFilename): + print('WARN: Media file does not exist ' + imageFilename) + return False permittedMedia = getMediaExtensions() for m in permittedMedia: if imageFilename.endswith('.' + m): @@ -126,9 +201,11 @@ def _updateEtag(mediaFilename: str) -> None: pass -def attachMedia(baseDir: str, httpPrefix: str, domain: str, port: int, +def attachMedia(baseDir: str, httpPrefix: str, + nickname: str, domain: str, port: int, postJson: {}, imageFilename: str, - mediaType: str, description: str) -> {}: + mediaType: str, description: str, + city: str) -> {}: """Attaches media to a json object post The description can be None """ @@ -170,11 +247,19 @@ def attachMedia(baseDir: str, httpPrefix: str, domain: str, port: int, } if mediaType.startswith('image/'): attachmentJson['focialPoint'] = [0.0, 0.0] + # find the dimensions of the image and add them as metadata + attachImageWidth, attachImageHeight = \ + getImageDimensions(imageFilename) + if attachImageWidth and attachImageHeight: + attachmentJson['width'] = attachImageWidth + attachmentJson['height'] = attachImageHeight + postJson['attachment'] = [attachmentJson] if baseDir: if mediaType.startswith('image/'): - removeMetaData(imageFilename, mediaFilename) + processMetaData(baseDir, nickname, domain, + imageFilename, mediaFilename, city) else: copyfile(imageFilename, mediaFilename) _updateEtag(mediaFilename) @@ -182,7 +267,8 @@ def attachMedia(baseDir: str, httpPrefix: str, domain: str, port: int, return postJson -def archiveMedia(baseDir: str, archiveDirectory: str, maxWeeks=4) -> None: +def archiveMedia(baseDir: str, archiveDirectory: str, + maxWeeks: int = 4) -> None: """Any media older than the given number of weeks gets archived """ if maxWeeks == 0: @@ -208,3 +294,39 @@ def archiveMedia(baseDir: str, archiveDirectory: str, maxWeeks=4) -> None: # archive to /dev/null rmtree(os.path.join(baseDir + '/media', weekDir)) break + + +def pathIsVideo(path: str) -> bool: + if path.endswith('.ogv') or \ + path.endswith('.mp4'): + return True + return False + + +def pathIsAudio(path: str) -> bool: + if path.endswith('.ogg') or \ + path.endswith('.mp3'): + return True + return False + + +def getImageDimensions(imageFilename: str) -> (int, int): + """Returns the dimensions of an image file + """ + try: + result = subprocess.run(['identify', '-format', '"%wx%h"', + imageFilename], stdout=subprocess.PIPE) + except BaseException: + return None, None + if not result: + return None, None + dimensionsStr = result.stdout.decode('utf-8').replace('"', '') + if 'x' not in dimensionsStr: + return None, None + widthStr = dimensionsStr.split('x')[0] + if not widthStr.isdigit(): + return None, None + heightStr = dimensionsStr.split('x')[1] + if not heightStr.isdigit(): + return None, None + return int(widthStr), int(heightStr) diff --git a/metadata.py b/metadata.py index 14db42f3e..4f55f1cc7 100644 --- a/metadata.py +++ b/metadata.py @@ -5,19 +5,58 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Metadata" import os +from utils import isAccountDir from utils import loadJson from utils import noOfAccounts from utils import noOfActiveAccountsMonthly -def metaDataNodeInfo(baseDir: str, registration: bool, version: str) -> {}: - """ /nodeinfo/2.0 endpoint +def _getStatusCount(baseDir: str) -> int: + """Get the total number of posts """ - activeAccounts = noOfAccounts(baseDir) - activeAccountsMonthly = noOfActiveAccountsMonthly(baseDir, 1) - activeAccountsHalfYear = noOfActiveAccountsMonthly(baseDir, 6) + statusCtr = 0 + accountsDir = baseDir + '/accounts' + for subdir, dirs, files in os.walk(accountsDir): + for acct in dirs: + if not isAccountDir(acct): + continue + acctDir = os.path.join(accountsDir, acct + '/outbox') + for subdir2, dirs2, files2 in os.walk(acctDir): + statusCtr += len(files2) + break + break + return statusCtr + + +def metaDataNodeInfo(baseDir: str, + aboutUrl: str, + termsOfServiceUrl: str, + registration: bool, version: str, + showAccounts: bool) -> {}: + """ /nodeinfo/2.0 endpoint + Also see https://socialhub.activitypub.rocks/t/ + fep-f1d5-nodeinfo-in-fediverse-software/1190/4 + + Note that there are security considerations with this. If an adversary + sees a lot of accounts and "local" posts then the instance may be + considered a higher priority target. + Also exposure of the version number and number of accounts could be + sensitive + """ + if showAccounts: + activeAccounts = noOfAccounts(baseDir) + activeAccountsMonthly = noOfActiveAccountsMonthly(baseDir, 1) + activeAccountsHalfYear = noOfActiveAccountsMonthly(baseDir, 6) + localPosts = _getStatusCount(baseDir) + else: + activeAccounts = 1 + activeAccountsMonthly = 1 + activeAccountsHalfYear = 1 + localPosts = 1 + nodeinfo = { 'openRegistrations': registration, 'protocols': ['activitypub'], @@ -25,8 +64,12 @@ def metaDataNodeInfo(baseDir: str, registration: bool, version: str) -> {}: 'name': 'epicyon', 'version': version }, + 'documents': { + 'about': aboutUrl, + 'terms': termsOfServiceUrl + }, 'usage': { - 'localPosts': 1, + 'localPosts': localPosts, 'users': { 'activeHalfyear': activeAccountsHalfYear, 'activeMonth': activeAccountsMonthly, @@ -38,7 +81,8 @@ def metaDataNodeInfo(baseDir: str, registration: bool, version: str) -> {}: return nodeinfo -def metaDataInstance(instanceTitle: str, +def metaDataInstance(showAccounts: bool, + instanceTitle: str, instanceDescriptionShort: str, instanceDescription: str, httpPrefix: str, baseDir: str, @@ -65,6 +109,13 @@ def metaDataInstance(instanceTitle: str, httpPrefix + '://' + domainFull + '/@' + \ adminActor['preferredUsername'] + if showAccounts: + activeAccounts = noOfAccounts(baseDir) + localPosts = _getStatusCount(baseDir) + else: + activeAccounts = 1 + localPosts = 1 + instance = { 'approval_required': False, 'contact_account': { @@ -72,33 +123,24 @@ def metaDataInstance(instanceTitle: str, 'avatar': adminActor['icon']['url'], 'avatar_static': adminActor['icon']['url'], 'bot': isBot, - 'created_at': '2019-07-01T10:30:00Z', 'display_name': adminActor['name'], - 'emojis': [], - 'fields': [], - 'followers_count': 1, - 'following_count': 1, 'header': adminActor['image']['url'], 'header_static': adminActor['image']['url'], - 'id': '1', - 'last_status_at': '2019-07-01T10:30:00Z', 'locked': adminActor['manuallyApprovesFollowers'], - 'note': '

Admin of '+domain+'

', - 'statuses_count': 1, + 'note': '

Admin of ' + domain + '

', 'url': url, 'username': adminActor['preferredUsername'] }, 'description': instanceDescription, - 'email': 'admin@'+domain, 'languages': [systemLanguage], 'registrations': registration, 'short_description': instanceDescriptionShort, 'stats': { - 'domain_count': 2, - 'status_count': 1, - 'user_count': noOfAccounts(baseDir) + 'domain_count': 1, + 'status_count': localPosts, + 'user_count': activeAccounts }, - 'thumbnail': httpPrefix+'://'+domainFull+'/login.png', + 'thumbnail': httpPrefix + '://' + domainFull + '/login.png', 'title': instanceTitle, 'uri': domainFull, 'urls': {}, @@ -106,3 +148,30 @@ def metaDataInstance(instanceTitle: str, } return instance + + +def metadataCustomEmoji(baseDir: str, + httpPrefix: str, domainFull: str) -> {}: + """Returns the custom emoji + Endpoint /api/v1/custom_emojis + See https://docs.joinmastodon.org/methods/instance/custom_emojis + """ + result = [] + emojisUrl = httpPrefix + '://' + domainFull + '/emoji' + for subdir, dirs, files in os.walk(baseDir + '/emoji'): + for f in files: + if len(f) < 3: + continue + if f[0].isdigit() or f[1].isdigit(): + continue + if not f.endswith('.png'): + continue + url = os.path.join(emojisUrl, f) + result.append({ + "shortcode": f.replace('.png', ''), + "url": url, + "static_url": url, + "visible_in_picker": True + }) + break + return result diff --git a/migrate.py b/migrate.py index 9d050f114..2163a1509 100644 --- a/migrate.py +++ b/migrate.py @@ -5,15 +5,18 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Core" import os +from utils import isAccountDir from utils import getNicknameFromActor from utils import getDomainFromActor +from utils import acctDir from webfinger import webfingerHandle from blocking import isBlocked -from session import getJson from posts import getUserUrl from follow import unfollowAccount +from person import getActorJson def _moveFollowingHandlesForAccount(baseDir: str, nickname: str, domain: str, @@ -23,11 +26,10 @@ def _moveFollowingHandlesForAccount(baseDir: str, nickname: str, domain: str, """Goes through all follows for an account and updates any that have moved """ ctr = 0 - followingFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + '/following.txt' + followingFilename = acctDir(baseDir, nickname, domain) + '/following.txt' if not os.path.isfile(followingFilename): return ctr - with open(followingFilename, "r") as f: + with open(followingFilename, 'r') as f: followingHandles = f.readlines() for followHandle in followingHandles: followHandle = followHandle.strip("\n").strip("\r") @@ -56,7 +58,7 @@ def _updateMovedHandle(baseDir: str, nickname: str, domain: str, handle = handle[1:] wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - None, __version__) + None, __version__, debug) if not wfRequest: print('updateMovedHandle unable to webfinger ' + handle) return ctr @@ -71,22 +73,16 @@ def _updateMovedHandle(baseDir: str, nickname: str, domain: str, print('wfRequest error: ' + str(wfRequest['errors'])) return ctr - profileStr = 'https://www.w3.org/ns/activitystreams' - asHeader = { - 'Accept': 'application/activity+json; profile="' + profileStr + '"' - } if not personUrl: - personUrl = getUserUrl(wfRequest) + personUrl = getUserUrl(wfRequest, 0, debug) if not personUrl: return ctr - profileStr = 'https://www.w3.org/ns/activitystreams' - asHeader = { - 'Accept': 'application/ld+json; profile="' + profileStr + '"' - } + gnunet = False + if httpPrefix == 'gnunet': + gnunet = True personJson = \ - getJson(session, personUrl, asHeader, None, __version__, - httpPrefix, None) + getActorJson(domain, personUrl, httpPrefix, gnunet, debug) if not personJson: return ctr if not personJson.get('movedTo'): @@ -115,18 +111,16 @@ def _updateMovedHandle(baseDir: str, nickname: str, domain: str, 'following.txt', debug) return ctr - followingFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + '/following.txt' + followingFilename = acctDir(baseDir, nickname, domain) + '/following.txt' if os.path.isfile(followingFilename): - with open(followingFilename, "r") as f: + with open(followingFilename, 'r') as f: followingHandles = f.readlines() movedToHandle = movedToNickname + '@' + movedToDomainFull handleLower = handle.lower() refollowFilename = \ - baseDir + '/accounts/' + \ - nickname + '@' + domain + '/refollow.txt' + acctDir(baseDir, nickname, domain) + '/refollow.txt' # unfollow the old handle with open(followingFilename, 'w+') as f: @@ -154,9 +148,9 @@ def _updateMovedHandle(baseDir: str, nickname: str, domain: str, f.write(movedToHandle + '\n') followersFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + '/followers.txt' + acctDir(baseDir, nickname, domain) + '/followers.txt' if os.path.isfile(followersFilename): - with open(followersFilename, "r") as f: + with open(followersFilename, 'r') as f: followerHandles = f.readlines() handleLower = handle.lower() @@ -185,11 +179,7 @@ def migrateAccounts(baseDir: str, session, ctr = 0 for subdir, dirs, files in os.walk(baseDir + '/accounts'): for handle in dirs: - if '@' not in handle: - continue - if handle.startswith('inbox@'): - continue - if handle.startswith('news@'): + if not isAccountDir(handle): continue nickname = handle.split('@')[0] domain = handle.split('@')[1] diff --git a/newsdaemon.py b/newsdaemon.py index a32628428..21b01c67a 100644 --- a/newsdaemon.py +++ b/newsdaemon.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface Columns" # Example hashtag logic: # @@ -54,19 +55,15 @@ def _updateFeedsOutboxIndex(baseDir: str, domain: str, postId: str) -> None: print('WARN: Failed to write entry to feeds posts index ' + indexFilename + ' ' + str(e)) else: - feedsFile = open(indexFilename, 'w+') - if feedsFile: + with open(indexFilename, 'w+') as feedsFile: feedsFile.write(postId + '\n') - feedsFile.close() def _saveArrivedTime(baseDir: str, postFilename: str, arrived: str) -> None: """Saves the time when an rss post arrived to a file """ - arrivedFile = open(postFilename + '.arrived', 'w+') - if arrivedFile: + with open(postFilename + '.arrived', 'w+') as arrivedFile: arrivedFile.write(arrived) - arrivedFile.close() def _removeControlCharacters(content: str) -> str: @@ -77,6 +74,120 @@ def _removeControlCharacters(content: str) -> str: return content +def _hashtagLogicalNot(tree: [], hashtags: [], moderated: bool, + content: str, url: str) -> bool: + """ NOT + """ + if len(tree) != 2: + return False + if isinstance(tree[1], str): + return tree[1] not in hashtags + elif isinstance(tree[1], list): + return not hashtagRuleResolve(tree[1], hashtags, + moderated, content, url) + return False + + +def _hashtagLogicalContains(tree: [], hashtags: [], moderated: bool, + content: str, url: str) -> bool: + """ Contains + """ + if len(tree) != 2: + return False + matchStr = None + if isinstance(tree[1], str): + matchStr = tree[1] + elif isinstance(tree[1], list): + matchStr = tree[1][0] + if matchStr: + if matchStr.startswith('"') and matchStr.endswith('"'): + matchStr = matchStr[1:] + matchStr = matchStr[:len(matchStr) - 1] + matchStrLower = matchStr.lower() + contentWithoutTags = content.replace('#' + matchStrLower, '') + return matchStrLower in contentWithoutTags + return False + + +def _hashtagLogicalFrom(tree: [], hashtags: [], moderated: bool, + content: str, url: str) -> bool: + """ FROM + """ + if len(tree) != 2: + return False + matchStr = None + if isinstance(tree[1], str): + matchStr = tree[1] + elif isinstance(tree[1], list): + matchStr = tree[1][0] + if matchStr: + if matchStr.startswith('"') and matchStr.endswith('"'): + matchStr = matchStr[1:] + matchStr = matchStr[:len(matchStr) - 1] + return matchStr.lower() in url + return False + + +def _hashtagLogicalAnd(tree: [], hashtags: [], moderated: bool, + content: str, url: str) -> bool: + """ AND + """ + if len(tree) < 3: + return False + for argIndex in range(1, len(tree)): + argValue = False + if isinstance(tree[argIndex], str): + argValue = (tree[argIndex] in hashtags) + elif isinstance(tree[argIndex], list): + argValue = hashtagRuleResolve(tree[argIndex], + hashtags, moderated, + content, url) + if not argValue: + return False + return True + + +def _hashtagLogicalOr(tree: [], hashtags: [], moderated: bool, + content: str, url: str) -> bool: + """ OR + """ + if len(tree) < 3: + return False + for argIndex in range(1, len(tree)): + argValue = False + if isinstance(tree[argIndex], str): + argValue = (tree[argIndex] in hashtags) + elif isinstance(tree[argIndex], list): + argValue = hashtagRuleResolve(tree[argIndex], + hashtags, moderated, + content, url) + if argValue: + return True + return False + + +def _hashtagLogicalXor(tree: [], hashtags: [], moderated: bool, + content: str, url: str) -> bool: + """ XOR + """ + if len(tree) < 3: + return False + trueCtr = 0 + for argIndex in range(1, len(tree)): + argValue = False + if isinstance(tree[argIndex], str): + argValue = (tree[argIndex] in hashtags) + elif isinstance(tree[argIndex], list): + argValue = hashtagRuleResolve(tree[argIndex], + hashtags, moderated, + content, url) + if argValue: + trueCtr += 1 + if trueCtr == 1: + return True + return False + + def hashtagRuleResolve(tree: [], hashtags: [], moderated: bool, content: str, url: str) -> bool: """Returns whether the tree for a hashtag rule evaluates to true or false @@ -85,79 +196,17 @@ def hashtagRuleResolve(tree: [], hashtags: [], moderated: bool, return False if tree[0] == 'not': - if len(tree) == 2: - if isinstance(tree[1], str): - return tree[1] not in hashtags - elif isinstance(tree[1], list): - return not hashtagRuleResolve(tree[1], hashtags, moderated, - content, url) + return _hashtagLogicalNot(tree, hashtags, moderated, content, url) elif tree[0] == 'contains': - if len(tree) == 2: - matchStr = None - if isinstance(tree[1], str): - matchStr = tree[1] - elif isinstance(tree[1], list): - matchStr = tree[1][0] - if matchStr: - if matchStr.startswith('"') and matchStr.endswith('"'): - matchStr = matchStr[1:] - matchStr = matchStr[:len(matchStr) - 1] - matchStrLower = matchStr.lower() - contentWithoutTags = content.replace('#' + matchStrLower, '') - return matchStrLower in contentWithoutTags + return _hashtagLogicalContains(tree, hashtags, moderated, content, url) elif tree[0] == 'from': - if len(tree) == 2: - matchStr = None - if isinstance(tree[1], str): - matchStr = tree[1] - elif isinstance(tree[1], list): - matchStr = tree[1][0] - if matchStr: - if matchStr.startswith('"') and matchStr.endswith('"'): - matchStr = matchStr[1:] - matchStr = matchStr[:len(matchStr) - 1] - return matchStr.lower() in url + return _hashtagLogicalFrom(tree, hashtags, moderated, content, url) elif tree[0] == 'and': - if len(tree) >= 3: - for argIndex in range(1, len(tree)): - argValue = False - if isinstance(tree[argIndex], str): - argValue = (tree[argIndex] in hashtags) - elif isinstance(tree[argIndex], list): - argValue = hashtagRuleResolve(tree[argIndex], - hashtags, moderated, - content, url) - if not argValue: - return False - return True + return _hashtagLogicalAnd(tree, hashtags, moderated, content, url) elif tree[0] == 'or': - if len(tree) >= 3: - for argIndex in range(1, len(tree)): - argValue = False - if isinstance(tree[argIndex], str): - argValue = (tree[argIndex] in hashtags) - elif isinstance(tree[argIndex], list): - argValue = hashtagRuleResolve(tree[argIndex], - hashtags, moderated, - content, url) - if argValue: - return True - return False + return _hashtagLogicalOr(tree, hashtags, moderated, content, url) elif tree[0] == 'xor': - if len(tree) >= 3: - trueCtr = 0 - for argIndex in range(1, len(tree)): - argValue = False - if isinstance(tree[argIndex], str): - argValue = (tree[argIndex] in hashtags) - elif isinstance(tree[argIndex], list): - argValue = hashtagRuleResolve(tree[argIndex], - hashtags, moderated, - content, url) - if argValue: - trueCtr += 1 - if trueCtr == 1: - return True + return _hashtagLogicalXor(tree, hashtags, moderated, content, url) elif tree[0].startswith('#') and len(tree) == 1: return tree[0] in hashtags elif tree[0].startswith('moderated'): @@ -228,6 +277,87 @@ def hashtagRuleTree(operators: [], return tree +def _hashtagAdd(baseDir: str, httpPrefix: str, domainFull: str, + postJsonObject: {}, + actionStr: str, hashtags: []) -> None: + """Adds a hashtag via a hashtag rule + """ + addHashtag = actionStr.split('add ', 1)[1].strip() + if not addHashtag.startswith('#'): + return + + if addHashtag not in hashtags: + hashtags.append(addHashtag) + htId = addHashtag.replace('#', '') + if not validHashTag(htId): + return + + hashtagUrl = httpPrefix + "://" + domainFull + "/tags/" + htId + newTag = { + 'href': hashtagUrl, + 'name': addHashtag, + 'type': 'Hashtag' + } + # does the tag already exist? + addTagObject = None + for t in postJsonObject['object']['tag']: + if t.get('type') and t.get('name'): + if t['type'] == 'Hashtag' and \ + t['name'] == addHashtag: + addTagObject = t + break + # append the tag if it wasn't found + if not addTagObject: + postJsonObject['object']['tag'].append(newTag) + # add corresponding html to the post content + hashtagHtml = \ + " #" + htId + "" + content = postJsonObject['object']['content'] + if hashtagHtml in content: + return + + if content.endswith('

'): + content = \ + content[:len(content) - len('

')] + \ + hashtagHtml + '

' + else: + content += hashtagHtml + postJsonObject['object']['content'] = content + storeHashTags(baseDir, 'news', postJsonObject) + + +def _hashtagRemove(httpPrefix: str, domainFull: str, postJsonObject: {}, + actionStr: str, hashtags: []) -> None: + """Removes a hashtag via a hashtag rule + """ + rmHashtag = actionStr.split('remove ', 1)[1].strip() + if not rmHashtag.startswith('#'): + return + + if rmHashtag in hashtags: + hashtags.remove(rmHashtag) + htId = rmHashtag.replace('#', '') + hashtagUrl = httpPrefix + "://" + domainFull + "/tags/" + htId + # remove tag html from the post content + hashtagHtml = \ + "#" + htId + "" + content = postJsonObject['object']['content'] + if hashtagHtml in content: + content = content.replace(hashtagHtml, '').replace(' ', ' ') + postJsonObject['object']['content'] = content + rmTagObject = None + for t in postJsonObject['object']['tag']: + if t.get('type') and t.get('name'): + if t['type'] == 'Hashtag' and \ + t['name'] == rmHashtag: + rmTagObject = t + break + if rmTagObject: + postJsonObject['object']['tag'].remove(rmTagObject) + + def _newswireHashtagProcessing(session, baseDir: str, postJsonObject: {}, hashtags: [], httpPrefix: str, domain: str, port: int, @@ -244,7 +374,7 @@ def _newswireHashtagProcessing(session, baseDir: str, postJsonObject: {}, if not os.path.isfile(rulesFilename): return True rules = [] - with open(rulesFilename, "r") as f: + with open(rulesFilename, 'r') as f: rules = f.readlines() domainFull = getFullDomain(domain, port) @@ -276,83 +406,16 @@ def _newswireHashtagProcessing(session, baseDir: str, postJsonObject: {}, # the condition matches, so do something actionStr = ruleStr.split(' then ')[1].strip() - # add a hashtag if actionStr.startswith('add '): - addHashtag = actionStr.split('add ', 1)[1].strip() - if addHashtag.startswith('#'): - if addHashtag not in hashtags: - hashtags.append(addHashtag) - htId = addHashtag.replace('#', '') - if validHashTag(htId): - hashtagUrl = \ - httpPrefix + "://" + domainFull + "/tags/" + htId - newTag = { - 'href': hashtagUrl, - 'name': addHashtag, - 'type': 'Hashtag' - } - # does the tag already exist? - addTagObject = None - for t in postJsonObject['object']['tag']: - if t.get('type') and t.get('name'): - if t['type'] == 'Hashtag' and \ - t['name'] == addHashtag: - addTagObject = t - break - # append the tag if it wasn't found - if not addTagObject: - postJsonObject['object']['tag'].append(newTag) - # add corresponding html to the post content - hashtagHtml = \ - " #" + \ - htId + "" - content = postJsonObject['object']['content'] - if hashtagHtml not in content: - if content.endswith('

'): - content = \ - content[:len(content) - len('

')] + \ - hashtagHtml + '

' - else: - content += hashtagHtml - postJsonObject['object']['content'] = content - storeHashTags(baseDir, 'news', postJsonObject) - # actionOccurred = True - - # remove a hashtag - if actionStr.startswith('remove '): - rmHashtag = actionStr.split('remove ', 1)[1].strip() - if rmHashtag.startswith('#'): - if rmHashtag in hashtags: - hashtags.remove(rmHashtag) - htId = rmHashtag.replace('#', '') - hashtagUrl = \ - httpPrefix + "://" + domainFull + "/tags/" + htId - # remove tag html from the post content - hashtagHtml = \ - "#" + \ - htId + "" - content = postJsonObject['object']['content'] - if hashtagHtml in content: - content = \ - content.replace(hashtagHtml, '').replace(' ', ' ') - postJsonObject['object']['content'] = content - rmTagObject = None - for t in postJsonObject['object']['tag']: - if t.get('type') and t.get('name'): - if t['type'] == 'Hashtag' and \ - t['name'] == rmHashtag: - rmTagObject = t - break - if rmTagObject: - postJsonObject['object']['tag'].remove(rmTagObject) - # actionOccurred = True - - # Block this item - if actionStr.startswith('block') or actionStr.startswith('drop'): + # add a hashtag + _hashtagAdd(baseDir, httpPrefix, domainFull, + postJsonObject, actionStr, hashtags) + elif actionStr.startswith('remove '): + # remove a hashtag + _hashtagRemove(httpPrefix, domainFull, postJsonObject, + actionStr, hashtags) + elif actionStr.startswith('block') or actionStr.startswith('drop'): + # Block this item return False return True @@ -408,7 +471,7 @@ def _createNewsMirror(baseDir: str, domain: str, for removePostId in removals: indexContent = \ indexContent.replace(removePostId + '\n', '') - with open(mirrorIndexFilename, "w+") as indexFile: + with open(mirrorIndexFilename, 'w+') as indexFile: indexFile.write(indexContent) mirrorArticleDir = mirrorDir + '/' + postIdNumber @@ -434,15 +497,11 @@ def _createNewsMirror(baseDir: str, domain: str, # append the post Id number to the index file if os.path.isfile(mirrorIndexFilename): - indexFile = open(mirrorIndexFilename, "a+") - if indexFile: + with open(mirrorIndexFilename, 'a+') as indexFile: indexFile.write(postIdNumber + '\n') - indexFile.close() else: - indexFile = open(mirrorIndexFilename, "w+") - if indexFile: + with open(mirrorIndexFilename, 'w+') as indexFile: indexFile.write(postIdNumber + '\n') - indexFile.close() return True @@ -468,8 +527,7 @@ def _convertRSStoActivityPub(baseDir: str, httpPrefix: str, os.mkdir(basePath) # oldest items first - newswireReverse = \ - OrderedDict(sorted(newswire.items(), reverse=False)) + newswireReverse = OrderedDict(sorted(newswire.items(), reverse=False)) for dateStr, item in newswireReverse.items(): originalDateStr = dateStr @@ -527,11 +585,17 @@ def _convertRSStoActivityPub(baseDir: str, httpPrefix: str, # NOTE: the id when the post is created will not be # consistent (it's based on the current time, not the # published time), so we change that later + saveToFile = False + attachImageFilename = None + mediaType = None + imageDescription = None + city = 'London, England' blog = createNewsPost(baseDir, domain, port, httpPrefix, rssDescription, - followersOnly, False, - None, None, None, + followersOnly, saveToFile, + attachImageFilename, mediaType, + imageDescription, city, rssTitle) if not blog: continue @@ -744,9 +808,10 @@ def runNewswireWatchdog(projectVersion: str, httpd) -> None: httpd.thrNewswireDaemon.start() while True: time.sleep(50) - if not httpd.thrNewswireDaemon.is_alive(): - httpd.thrNewswireDaemon.kill() - httpd.thrNewswireDaemon = \ - newswireOriginal.clone(runNewswireDaemon) - httpd.thrNewswireDaemon.start() - print('Restarting newswire daemon...') + if httpd.thrNewswireDaemon.is_alive(): + continue + httpd.thrNewswireDaemon.kill() + httpd.thrNewswireDaemon = \ + newswireOriginal.clone(runNewswireDaemon) + httpd.thrNewswireDaemon.start() + print('Restarting newswire daemon...') diff --git a/newswire.py b/newswire.py index 29e24e3b7..5fba2748e 100644 --- a/newswire.py +++ b/newswire.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface Columns" import os import json @@ -17,6 +18,7 @@ from datetime import timezone from collections import OrderedDict from utils import validPostDate from categories import setHashtagCategory +from utils import hasObjectDict from utils import firstParagraphFromString from utils import isPublicPost from utils import locatePost @@ -25,6 +27,8 @@ from utils import saveJson from utils import isSuspended from utils import containsInvalidChars from utils import removeHtml +from utils import isAccountDir +from utils import acctDir from blocking import isBlockedDomain from blocking import isBlockedHashtag from filters import isFiltered @@ -45,21 +49,25 @@ def rss2Header(httpPrefix: str, title: str, translate: {}) -> str: """Header for an RSS 2.0 feed """ - rssStr = "" - rssStr += "" - rssStr += '' + rssStr = \ + "" + \ + "" + \ + '' if title.startswith('News'): - rssStr += ' Newswire' - rssStr += ' ' + httpPrefix + '://' + domainFull + \ + rssStr += \ + ' Newswire' + \ + ' ' + httpPrefix + '://' + domainFull + \ '/newswire.xml' + '' elif title.startswith('Site'): - rssStr += ' ' + domainFull + '' - rssStr += ' ' + httpPrefix + '://' + domainFull + \ + rssStr += \ + ' ' + domainFull + '' + \ + ' ' + httpPrefix + '://' + domainFull + \ '/blog/rss.xml' + '' else: - rssStr += ' ' + translate[title] + '' - rssStr += ' ' + httpPrefix + '://' + domainFull + \ + rssStr += \ + ' ' + translate[title] + '' + \ + ' ' + httpPrefix + '://' + domainFull + \ '/users/' + nickname + '/rss.xml' + '' return rssStr @@ -67,8 +75,7 @@ def rss2Header(httpPrefix: str, def rss2Footer() -> str: """Footer for an RSS 2.0 feed """ - rssStr = '' - rssStr += '' + rssStr = '' return rssStr @@ -87,22 +94,43 @@ def getNewswireTags(text: str, maxTags: int) -> []: words = textSimplified.split(' ') tags = [] for wrd in words: - if wrd.startswith('#'): - if len(wrd) > 1: - if wrd not in tags: - tags.append(wrd) - if len(tags) >= maxTags: - break + if not wrd.startswith('#'): + continue + if len(wrd) <= 1: + continue + if wrd in tags: + continue + tags.append(wrd) + if len(tags) >= maxTags: + break return tags +def limitWordLengths(text: str, maxWordLength: int) -> str: + """Limits the maximum length of words so that the newswire + column cannot become too wide + """ + if ' ' not in text: + return text + words = text.split(' ') + result = '' + for wrd in words: + if len(wrd) > maxWordLength: + wrd = wrd[:maxWordLength] + if result: + result += ' ' + result += wrd + return result + + def _addNewswireDictEntry(baseDir: str, domain: str, newswire: {}, dateStr: str, title: str, link: str, votesStatus: str, postFilename: str, description: str, moderated: bool, mirrored: bool, - tags=[], maxTags=32) -> None: + tags: [] = [], + maxTags: int = 32) -> None: """Update the newswire dictionary """ # remove any markup @@ -115,6 +143,8 @@ def _addNewswireDictEntry(baseDir: str, domain: str, if isFiltered(baseDir, None, None, allText): return + title = limitWordLengths(title, 13) + if tags is None: tags = [] @@ -123,9 +153,10 @@ def _addNewswireDictEntry(baseDir: str, domain: str, # combine the tags into a single list for tag in tags: - if tag not in postTags: - if len(postTags) < maxTags: - postTags.append(tag) + if tag in postTags: + continue + if len(postTags) < maxTags: + postTags.append(tag) # check that no tags are blocked for tag in postTags: @@ -144,11 +175,11 @@ def _addNewswireDictEntry(baseDir: str, domain: str, ] -def _validFeedDate(pubDate: str) -> bool: +def _validFeedDate(pubDate: str, debug: bool = False) -> bool: # convert from YY-MM-DD HH:MM:SS+00:00 to # YY-MM-DDTHH:MM:SSZ postDate = pubDate.replace(' ', 'T').replace('+00:00', 'Z') - return validPostDate(postDate, 30) + return validPostDate(postDate, 90, debug) def parseFeedDate(pubDate: str) -> str: @@ -225,7 +256,7 @@ def loadHashtagCategories(baseDir: str, language: str) -> None: def _xml2StrToHashtagCategories(baseDir: str, xmlStr: str, maxCategoriesFeedItemSizeKb: int, - force=False) -> None: + force: bool = False) -> None: """Updates hashtag categories based upon an rss feed """ rssItems = xmlStr.split('') @@ -774,19 +805,18 @@ def getRSS(baseDir: str, domain: str, session, url: str, else: print('WARN: no result returned for feed ' + url) except requests.exceptions.RequestException as e: - print('ERROR: getRSS failed\nurl: ' + str(url) + '\n' + - 'headers: ' + str(sessionHeaders) + '\n' + - 'params: ' + str(sessionParams) + '\n') - print(e) + print('WARN: getRSS failed\nurl: ' + str(url) + ', ' + + 'headers: ' + str(sessionHeaders) + ', ' + + 'params: ' + str(sessionParams) + ', ' + str(e)) except ValueError as e: - print('ERROR: getRSS failed\nurl: ' + str(url) + '\n' + - 'headers: ' + str(sessionHeaders) + '\n' + - 'params: ' + str(sessionParams) + '\n') - print(e) + print('WARN: getRSS failed\nurl: ' + str(url) + ', ' + + 'headers: ' + str(sessionHeaders) + ', ' + + 'params: ' + str(sessionParams) + ', ' + str(e)) except SocketError as e: if e.errno == errno.ECONNRESET: - print('WARN: connection was reset during getRSS') - print(e) + print('WARN: connection was reset during getRSS ' + str(e)) + else: + print('WARN: getRSS, ' + str(e)) return None @@ -814,8 +844,9 @@ def getRSSfromDict(baseDir: str, newswire: {}, except Exception as e: print('WARN: Unable to convert date ' + published + ' ' + str(e)) continue - rssStr += '\n' - rssStr += ' ' + fields[0] + '\n' + rssStr += \ + '\n' + \ + ' ' + fields[0] + '\n' description = removeHtml(firstParagraphFromString(fields[4])) rssStr += ' ' + description + '\n' url = fields[1] @@ -825,8 +856,9 @@ def getRSSfromDict(baseDir: str, newswire: {}, rssStr += ' ' + url + '\n' rssDateStr = pubDate.strftime("%a, %d %b %Y %H:%M:%S UT") - rssStr += ' ' + rssDateStr + '\n' - rssStr += '\n' + rssStr += \ + ' ' + rssDateStr + '\n' + \ + '\n' rssStr += rss2Footer() return rssStr @@ -839,9 +871,7 @@ def _isNewswireBlogPost(postJsonObject: {}) -> bool: """ if not postJsonObject: return False - if not postJsonObject.get('object'): - return False - if not isinstance(postJsonObject['object'], dict): + if not hasObjectDict(postJsonObject): return False if postJsonObject['object'].get('summary') and \ postJsonObject['object'].get('url') and \ @@ -854,9 +884,7 @@ def _isNewswireBlogPost(postJsonObject: {}) -> bool: def _getHashtagsFromPost(postJsonObject: {}) -> []: """Returns a list of any hashtags within a post """ - if not postJsonObject.get('object'): - return [] - if not isinstance(postJsonObject['object'], dict): + if not hasObjectDict(postJsonObject): return [] if not postJsonObject['object'].get('tag'): return [] @@ -891,8 +919,7 @@ def _addAccountBlogsToNewswire(baseDir: str, nickname: str, domain: str, # local blogs can potentially be moderated moderatedFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + \ - '/.newswiremoderated' + acctDir(baseDir, nickname, domain) + '/.newswiremoderated' if os.path.isfile(moderatedFilename): moderated = True @@ -962,9 +989,7 @@ def _addBlogsToNewswire(baseDir: str, domain: str, newswire: {}, # go through each account for subdir, dirs, files in os.walk(baseDir + '/accounts'): for handle in dirs: - if '@' not in handle: - continue - if 'inbox@' in handle or 'news@' in handle: + if not isAccountDir(handle): continue nickname = handle.split('@')[0] diff --git a/notifyOnPost.py b/notifyOnPost.py new file mode 100644 index 000000000..e967e5d93 --- /dev/null +++ b/notifyOnPost.py @@ -0,0 +1,104 @@ +__filename__ = "notifyOnPost.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@freedombone.net" +__status__ = "Production" +__module_group__ = "Calendar" + +import os +from utils import removeDomainPort +from utils import acctDir + + +def _notifyOnPostArrival(baseDir: str, nickname: str, domain: str, + followingNickname: str, + followingDomain: str, + add: bool) -> None: + """Adds or removes a handle from the following.txt list into a list + indicating whether to notify when a new post arrives from that account + """ + # check that a following file exists + domain = removeDomainPort(domain) + followingFilename = acctDir(baseDir, nickname, domain) + '/following.txt' + if not os.path.isfile(followingFilename): + print("WARN: following.txt doesn't exist for " + + nickname + '@' + domain) + return + handle = followingNickname + '@' + followingDomain + + # check that you are following this handle + if handle + '\n' not in open(followingFilename).read(): + print('WARN: ' + handle + ' is not in ' + followingFilename) + return + + notifyOnPostFilename = \ + acctDir(baseDir, nickname, domain) + '/notifyOnPost.txt' + + # get the contents of the notifyOnPost file, which is + # a set of handles + followingHandles = '' + if os.path.isfile(notifyOnPostFilename): + print('notify file exists') + with open(notifyOnPostFilename, 'r') as calendarFile: + followingHandles = calendarFile.read() + else: + # create a new notifyOnPost file from the following file + print('Creating notifyOnPost file ' + notifyOnPostFilename) + followingHandles = '' + with open(followingFilename, 'r') as followingFile: + followingHandles = followingFile.read() + if add: + with open(notifyOnPostFilename, 'w+') as fp: + fp.write(followingHandles + handle + '\n') + + # already in the notifyOnPost file? + if handle + '\n' in followingHandles: + print(handle + ' exists in notifyOnPost.txt') + if add: + # already added + return + # remove from calendar file + followingHandles = followingHandles.replace(handle + '\n', '') + with open(notifyOnPostFilename, 'w+') as fp: + fp.write(followingHandles) + else: + print(handle + ' not in notifyOnPost.txt') + # not already in the notifyOnPost file + if add: + # append to the list of handles + followingHandles += handle + '\n' + with open(notifyOnPostFilename, 'w+') as fp: + fp.write(followingHandles) + + +def addNotifyOnPost(baseDir: str, nickname: str, domain: str, + followingNickname: str, + followingDomain: str) -> None: + _notifyOnPostArrival(baseDir, nickname, domain, + followingNickname, followingDomain, True) + + +def removeNotifyOnPost(baseDir: str, nickname: str, domain: str, + followingNickname: str, + followingDomain: str) -> None: + _notifyOnPostArrival(baseDir, nickname, domain, + followingNickname, followingDomain, False) + + +def notifyWhenPersonPosts(baseDir: str, nickname: str, domain: str, + followingNickname: str, + followingDomain: str) -> bool: + """Returns true if receiving notifications when the given publishes a post + """ + if followingNickname == nickname and followingDomain == domain: + return False + notifyOnPostFilename = \ + acctDir(baseDir, nickname, domain) + '/notifyOnPost.txt' + handle = followingNickname + '@' + followingDomain + if not os.path.isfile(notifyOnPostFilename): + # create a new notifyOnPost file + with open(notifyOnPostFilename, 'w+') as fp: + fp.write('') + return handle + '\n' in open(notifyOnPostFilename).read() diff --git a/orgs/instance.txt b/orgs/instance.txt deleted file mode 100644 index a797b348d..000000000 --- a/orgs/instance.txt +++ /dev/null @@ -1,21 +0,0 @@ -{ - "Administrator": { - description: "Administrator of the instance", - iconUrl: "", - members: [], - team: { - "Moderator": { - description: "Can moderate posts, suspend members and block instances", - pitch: "", - iconUrl: "", - members: [] - }, - "Delegator": { - description: "Can delegate roles", - pitch: "", - iconUrl: "", - members: [] - } - } - } -} diff --git a/orgs/protest.txt b/orgs/protest.txt deleted file mode 100644 index 19efc83e1..000000000 --- a/orgs/protest.txt +++ /dev/null @@ -1,98 +0,0 @@ -{ - "General meeting": { - "Safety": { - "Legal team": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Legal observers": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Jail support": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Medics team": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Recovery team": { - description: "", - pitch: "", - iconUrl: "", - members: [] - } - } - }, - "Organizing": { - "Logistics": { - "Police spokesperson": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Self defense team": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Spotters": { - description: "", - pitch: "", - iconUrl: "", - members: [] - } - }, - "Message": { - "Media spokesperson": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Writer": { - description: "", - pitch: "", - iconUrl: "", - members: [] - } - }, - "Mobilization": { - "Meme artist": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Banner visuals": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Social media": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Video producer": { - description: "", - pitch: "", - iconUrl: "", - members: [] - } - } - } -} diff --git a/orgs/publication.txt b/orgs/publication.txt deleted file mode 100644 index 511400222..000000000 --- a/orgs/publication.txt +++ /dev/null @@ -1,58 +0,0 @@ -{ - "Chief Editor": { - description: "", - pitch: "", - iconUrl: "", - members: [], - team: { - "Creative": { - "Photographer": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Layout editor": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Writers team": { - description: "", - pitch: "", - iconUrl: "", - members: [] - } - }, - "Copy": { - "Proof reader": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Fact checker": { - description: "", - pitch: "", - iconUrl: "", - members: [] - } - }, - "Publicity": { - "Website designer": { - description: "", - pitch: "", - iconUrl: "", - members: [] - }, - "Social media publicist": { - description: "", - pitch: "", - iconUrl: "", - members: [] - } - } - } - } -} diff --git a/outbox.py b/outbox.py index 11d595c6e..25bc2d163 100644 --- a/outbox.py +++ b/outbox.py @@ -5,31 +5,39 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Timeline" import os from shutil import copyfile from session import createSession from auth import createPassword +from posts import isImageMedia from posts import outboxMessageCreateWrap from posts import savePostToBox from posts import sendToFollowersThread from posts import sendToNamedAddresses +from utils import hasObjectDict from utils import getLocalNetworkAddresses from utils import getFullDomain from utils import removeIdEnding from utils import getDomainFromActor from utils import dangerousMarkup from utils import isFeaturedWriter +from utils import loadJson +from utils import saveJson +from utils import acctDir from blocking import isBlockedDomain from blocking import outboxBlock from blocking import outboxUndoBlock +from blocking import outboxMute +from blocking import outboxUndoMute from media import replaceYouTube from media import getMediaPath from media import createMediaDirs from inbox import inboxUpdateIndex from announce import outboxAnnounce +from announce import outboxUndoAnnounce from follow import outboxUndoFollow -from roles import outboxDelegate from skills import outboxSkills from availability import outboxAvailability from like import outboxLike @@ -41,7 +49,124 @@ from shares import outboxShareUpload from shares import outboxUndoShareUpload -def postMessageToOutbox(messageJson: {}, postToNickname: str, +def _outboxPersonReceiveUpdate(recentPostsCache: {}, + baseDir: str, httpPrefix: str, + nickname: str, domain: str, port: int, + messageJson: {}, debug: bool) -> None: + """ Receive an actor update from c2s + For example, setting the PGP key from the desktop client + """ + # these attachments are updatable via c2s + updatableAttachments = ('PGP', 'OpenPGP', 'Email') + + if not messageJson.get('type'): + return + print("messageJson['type'] " + messageJson['type']) + if messageJson['type'] != 'Update': + return + if not hasObjectDict(messageJson): + if debug: + print('DEBUG: c2s actor update object is not dict') + return + if not messageJson['object'].get('type'): + if debug: + print('DEBUG: c2s actor update - no type') + return + if messageJson['object']['type'] != 'Person': + if debug: + print('DEBUG: not a c2s actor update') + return + if not messageJson.get('to'): + if debug: + print('DEBUG: c2s actor update has no "to" field') + return + if not messageJson.get('actor'): + if debug: + print('DEBUG: c2s actor update has no actor field') + return + if not messageJson.get('id'): + if debug: + print('DEBUG: c2s actor update has no id field') + return + actor = \ + httpPrefix + '://' + getFullDomain(domain, port) + '/users/' + nickname + if len(messageJson['to']) != 1: + if debug: + print('DEBUG: c2s actor update - to does not contain one actor ' + + messageJson['to']) + return + if messageJson['to'][0] != actor: + if debug: + print('DEBUG: c2s actor update - to does not contain actor ' + + messageJson['to'] + ' ' + actor) + return + if not messageJson['id'].startswith(actor + '#updates/'): + if debug: + print('DEBUG: c2s actor update - unexpected id ' + + messageJson['id']) + return + updatedActorJson = messageJson['object'] + # load actor from file + actorFilename = acctDir(baseDir, nickname, domain) + '.json' + if not os.path.isfile(actorFilename): + print('actorFilename not found: ' + actorFilename) + return + actorJson = loadJson(actorFilename) + if not actorJson: + return + actorChanged = False + # update fields within actor + if 'attachment' in updatedActorJson: + for newPropertyValue in updatedActorJson['attachment']: + if not newPropertyValue.get('name'): + continue + if newPropertyValue['name'] not in updatableAttachments: + continue + if not newPropertyValue.get('type'): + continue + if not newPropertyValue.get('value'): + continue + if newPropertyValue['type'] != 'PropertyValue': + continue + if 'attachment' not in actorJson: + continue + found = False + for attachIdx in range(len(actorJson['attachment'])): + if actorJson['attachment'][attachIdx]['type'] != \ + 'PropertyValue': + continue + if actorJson['attachment'][attachIdx]['name'] != \ + newPropertyValue['name']: + continue + else: + if actorJson['attachment'][attachIdx]['value'] != \ + newPropertyValue['value']: + actorJson['attachment'][attachIdx]['value'] = \ + newPropertyValue['value'] + actorChanged = True + found = True + break + if not found: + actorJson['attachment'].append({ + "name": newPropertyValue['name'], + "type": "PropertyValue", + "value": newPropertyValue['value'] + }) + actorChanged = True + # save actor to file + if actorChanged: + saveJson(actorJson, actorFilename) + if debug: + print('actor saved: ' + actorFilename) + if debug: + print('New attachment: ' + str(actorJson['attachment'])) + messageJson['object'] = actorJson + if debug: + print('DEBUG: actor update via c2s - ' + nickname + '@' + domain) + + +def postMessageToOutbox(session, translate: {}, + messageJson: {}, postToNickname: str, server, baseDir: str, httpPrefix: str, domain: str, domainFull: str, onionDomain: str, i2pDomain: str, port: int, @@ -52,7 +177,8 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, proxyType: str, version: str, debug: bool, YTReplacementDomain: str, showPublishedDateOnly: bool, - allowLocalNetworkAccess: bool) -> bool: + allowLocalNetworkAccess: bool, + city: str) -> bool: """post is received by the outbox Client to server message post https://www.w3.org/TR/activitypub/#client-to-server-outbox-delivery @@ -74,14 +200,13 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, # check that the outgoing post doesn't contain any markup # which can be used to implement exploits - if messageJson.get('object'): - if isinstance(messageJson['object'], dict): - if messageJson['object'].get('content'): - if dangerousMarkup(messageJson['object']['content'], - allowLocalNetworkAccess): - print('POST to outbox contains dangerous markup: ' + - str(messageJson)) - return False + if hasObjectDict(messageJson): + if messageJson['object'].get('content'): + if dangerousMarkup(messageJson['object']['content'], + allowLocalNetworkAccess): + print('POST to outbox contains dangerous markup: ' + + str(messageJson)) + return False if messageJson['type'] == 'Create': if not (messageJson.get('id') and @@ -188,7 +313,7 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, permittedOutboxTypes = ('Create', 'Announce', 'Like', 'Follow', 'Undo', 'Update', 'Add', 'Remove', 'Block', 'Delete', - 'Delegate', 'Skill', 'Bookmark', 'Event') + 'Skill', 'Ignore') if messageJson['type'] not in permittedOutboxTypes: if debug: print('DEBUG: POST to outbox - ' + messageJson['type'] + @@ -209,13 +334,10 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, # if this is a blog post or an event then save to its own box if messageJson['type'] == 'Create': - if messageJson.get('object'): - if isinstance(messageJson['object'], dict): - if messageJson['object'].get('type'): - if messageJson['object']['type'] == 'Article': - outboxName = 'tlblogs' - elif messageJson['object']['type'] == 'Event': - outboxName = 'tlevents' + if hasObjectDict(messageJson): + if messageJson['object'].get('type'): + if messageJson['object']['type'] == 'Article': + outboxName = 'tlblogs' savedFilename = \ savePostToBox(baseDir, @@ -248,22 +370,34 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, if os.path.isfile(citationsFilename): os.remove(citationsFilename) - if messageJson['type'] == 'Create' or \ - messageJson['type'] == 'Question' or \ - messageJson['type'] == 'Note' or \ - messageJson['type'] == 'EncryptedMessage' or \ - messageJson['type'] == 'Article' or \ - messageJson['type'] == 'Event' or \ - messageJson['type'] == 'Patch' or \ - messageJson['type'] == 'Announce': + # The following activity types get added to the index files + indexedActivities = ( + 'Create', 'Question', 'Note', 'EncryptedMessage', 'Article', + 'Patch', 'Announce' + ) + if messageJson['type'] in indexedActivities: indexes = [outboxName, "inbox"] selfActor = \ httpPrefix + '://' + domainFull + '/users/' + postToNickname for boxNameIndex in indexes: if not boxNameIndex: continue + + # should this also go to the media timeline? + if boxNameIndex == 'inbox': + if isImageMedia(session, baseDir, httpPrefix, + postToNickname, domain, + messageJson, + translate, YTReplacementDomain, + allowLocalNetworkAccess, + recentPostsCache, debug): + inboxUpdateIndex('tlmedia', baseDir, + postToNickname + '@' + domain, + savedFilename, debug) + if boxNameIndex == 'inbox' and outboxName == 'tlblogs': continue + # avoid duplicates of the message if already going # back to the inbox of the same account if selfActor not in messageJson['to']: @@ -322,10 +456,6 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, print('DEBUG: handle any unfollow requests') outboxUndoFollow(baseDir, messageJson, debug) - if debug: - print('DEBUG: handle delegation requests') - outboxDelegate(baseDir, postToNickname, messageJson, debug) - if debug: print('DEBUG: handle skills changes requests') outboxSkills(baseDir, postToNickname, messageJson, debug) @@ -346,6 +476,12 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, baseDir, httpPrefix, postToNickname, domain, port, messageJson, debug) + if debug: + print('DEBUG: handle any undo announce requests') + outboxUndoAnnounce(recentPostsCache, + baseDir, httpPrefix, + postToNickname, domain, port, + messageJson, debug) if debug: print('DEBUG: handle any bookmark requests') @@ -381,11 +517,27 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, postToNickname, domain, port, messageJson, debug) + if debug: + print('DEBUG: handle mute requests') + outboxMute(baseDir, httpPrefix, + postToNickname, domain, + port, + messageJson, debug, + recentPostsCache) + + if debug: + print('DEBUG: handle undo mute requests') + outboxUndoMute(baseDir, httpPrefix, + postToNickname, domain, + port, + messageJson, debug, + recentPostsCache) + if debug: print('DEBUG: handle share uploads') outboxShareUpload(baseDir, httpPrefix, postToNickname, domain, - port, messageJson, debug) + port, messageJson, debug, city) if debug: print('DEBUG: handle undo share uploads') @@ -393,6 +545,13 @@ def postMessageToOutbox(messageJson: {}, postToNickname: str, postToNickname, domain, port, messageJson, debug) + if debug: + print('DEBUG: handle actor updates from c2s') + _outboxPersonReceiveUpdate(recentPostsCache, + baseDir, httpPrefix, + postToNickname, domain, port, + messageJson, debug) + if debug: print('DEBUG: sending c2s post to named addresses') if messageJson.get('to'): diff --git a/person.py b/person.py index b6bf9baf4..1ee5180cb 100644 --- a/person.py +++ b/person.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "ActivityPub" import time import os @@ -19,6 +20,7 @@ from cryptography.hazmat.primitives import serialization from shutil import copyfile from webfinger import createWebfingerEndpoint from webfinger import storeWebfingerEndpoint +from posts import getUserUrl from posts import createDMTimeline from posts import createRepliesTimeline from posts import createMediaTimeline @@ -26,14 +28,17 @@ from posts import createNewsTimeline from posts import createBlogsTimeline from posts import createFeaturesTimeline from posts import createBookmarksTimeline -from posts import createEventsTimeline from posts import createInbox from posts import createOutbox from posts import createModeration from auth import storeBasicCredentials from auth import removePassword from roles import setRole -from media import removeMetaData +from roles import setRolesFromList +from roles import getActorRolesList +from media import processMetaData +from utils import removeDomainPort +from utils import getStatusNumber from utils import getFullDomain from utils import validNickname from utils import loadJson @@ -41,6 +46,17 @@ from utils import saveJson from utils import setConfigParam from utils import getConfigParam from utils import refreshNewswire +from utils import getProtocolPrefixes +from utils import hasUsersPath +from utils import getImageExtensions +from utils import isImageFile +from utils import getUserPaths +from utils import acctDir +from session import createSession +from session import getJson +from webfinger import webfingerHandle +from pprint import pprint +from cache import getPersonFromCache def generateRSAKey() -> (str, str): @@ -66,24 +82,19 @@ def generateRSAKey() -> (str, str): def setProfileImage(baseDir: str, httpPrefix: str, nickname: str, domain: str, port: int, imageFilename: str, imageType: str, - resolution: str) -> bool: + resolution: str, city: str) -> bool: """Saves the given image file as an avatar or background image for the given person """ imageFilename = imageFilename.replace('\n', '').replace('\r', '') - if not (imageFilename.endswith('.png') or - imageFilename.endswith('.jpg') or - imageFilename.endswith('.jpeg') or - imageFilename.endswith('.svg') or - imageFilename.endswith('.gif')): + if not isImageFile(imageFilename): print('Profile image must be png, jpg, gif or svg format') return False if imageFilename.startswith('~/'): imageFilename = imageFilename.replace('~/', str(Path.home()) + '/') - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domain) fullDomain = getFullDomain(domain, port) handle = nickname + '@' + domain @@ -107,10 +118,16 @@ def setProfileImage(baseDir: str, httpPrefix: str, nickname: str, domain: str, imageFilename.endswith('.jpeg'): mediaType = 'image/jpeg' iconFilename = iconFilenameBase + '.jpg' - if imageFilename.endswith('.gif'): + elif imageFilename.endswith('.gif'): mediaType = 'image/gif' iconFilename = iconFilenameBase + '.gif' - if imageFilename.endswith('.svg'): + elif imageFilename.endswith('.webp'): + mediaType = 'image/webp' + iconFilename = iconFilenameBase + '.webp' + elif imageFilename.endswith('.avif'): + mediaType = 'image/avif' + iconFilename = iconFilenameBase + '.avif' + elif imageFilename.endswith('.svg'): mediaType = 'image/svg+xml' iconFilename = iconFilenameBase + '.svg' profileFilename = baseDir + '/accounts/' + handle + '/' + iconFilename @@ -120,44 +137,25 @@ def setProfileImage(baseDir: str, httpPrefix: str, nickname: str, domain: str, personJson[iconFilenameBase]['mediaType'] = mediaType personJson[iconFilenameBase]['url'] = \ httpPrefix + '://' + fullDomain + '/users/' + \ - nickname + '/'+iconFilename + nickname + '/' + iconFilename saveJson(personJson, personFilename) cmd = \ '/usr/bin/convert ' + imageFilename + ' -size ' + \ resolution + ' -quality 50 ' + profileFilename subprocess.call(cmd, shell=True) - removeMetaData(profileFilename, profileFilename) + processMetaData(baseDir, nickname, domain, + profileFilename, profileFilename, city) return True return False -def setOrganizationScheme(baseDir: str, nickname: str, domain: str, - schema: str) -> bool: - """Set the organization schema within which a person exists - This will define how roles, skills and availability are assembled - into organizations - """ - # avoid giant strings - if len(schema) > 256: - return False - actorFilename = baseDir + '/accounts/' + nickname + '@' + domain + '.json' - if not os.path.isfile(actorFilename): - return False - - actorJson = loadJson(actorFilename) - if actorJson: - actorJson['orgSchema'] = schema - saveJson(actorJson, actorFilename) - return True - - def _accountExists(baseDir: str, nickname: str, domain: str) -> bool: """Returns true if the given account exists """ - if ':' in domain: - domain = domain.split(':')[0] - return os.path.isdir(baseDir + '/accounts/' + nickname + '@' + domain) or \ + domain = removeDomainPort(domain) + accountDir = acctDir(baseDir, nickname, domain) + return os.path.isdir(accountDir) or \ os.path.isdir(baseDir + '/deactivated/' + nickname + '@' + domain) @@ -171,13 +169,17 @@ def randomizeActorImages(personJson: {}) -> None: # NOTE: these files don't need to have cryptographically # secure names randStr = str(randint(10000000000000, 99999999999999)) # nosec + baseUrl = personId.split('/users/')[0] + nickname = personJson['preferredUsername'] personJson['icon']['url'] = \ - personId + '/avatar' + randStr + '.' + existingExtension + baseUrl + '/accounts/avatars/' + nickname + \ + '/avatar' + randStr + '.' + existingExtension lastPartOfFilename = personJson['image']['url'].split('/')[-1] existingExtension = lastPartOfFilename.split('.')[1] randStr = str(randint(10000000000000, 99999999999999)) # nosec personJson['image']['url'] = \ - personId + '/image' + randStr + '.' + existingExtension + baseUrl + '/accounts/headers/' + nickname + \ + '/image' + randStr + '.' + existingExtension def getDefaultPersonContext() -> str: @@ -210,14 +212,21 @@ def getDefaultPersonContext() -> str: 'schema': 'http://schema.org#', 'suspended': 'toot:suspended', 'toot': 'http://joinmastodon.org/ns#', - 'value': 'schema:value' + 'value': 'schema:value', + 'hasOccupation': 'schema:hasOccupation', + 'Occupation': 'schema:Occupation', + 'occupationalCategory': 'schema:occupationalCategory', + 'Role': 'schema:Role', + 'WebSite': 'schema:Project', + 'CategoryCode': 'schema:CategoryCode', + 'CategoryCodeSet': 'schema:CategoryCodeSet' } def _createPersonBase(baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, saveToFile: bool, manualFollowerApproval: bool, - password=None) -> (str, str, {}, {}): + password: str = None) -> (str, str, {}, {}): """Returns the private key, public key, actor and webfinger endpoint """ privateKeyPem, publicKeyPem = generateRSAKey() @@ -265,27 +274,38 @@ def _createPersonBase(baseDir: str, nickname: str, domain: str, port: int, personId + '/avatar' + \ str(randint(10000000000000, 99999999999999)) + '.png' # nosec + statusNumber, published = getStatusNumber() newPerson = { '@context': [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1', getDefaultPersonContext() ], + 'published': published, 'alsoKnownAs': [], 'attachment': [], 'devices': personId + '/collections/devices', 'endpoints': { 'id': personId + '/endpoints', - 'sharedInbox': httpPrefix+'://' + domain + '/inbox', + 'sharedInbox': httpPrefix + '://' + domain + '/inbox', }, 'featured': personId + '/collections/featured', 'featuredTags': personId + '/collections/tags', 'followers': personId + '/followers', 'following': personId + '/following', + 'tts': personId + '/speaker', 'shares': personId + '/shares', - 'orgSchema': None, - 'skills': {}, - 'roles': {}, + 'hasOccupation': [ + { + '@type': 'Occupation', + 'name': "", + "occupationLocation": { + "@type": "City", + "name": "Fediverse" + }, + 'skills': [] + } + ], 'availability': None, 'icon': { 'mediaType': 'image/png', @@ -320,9 +340,11 @@ def _createPersonBase(baseDir: str, nickname: str, domain: str, port: int, del newPerson['outbox'] del newPerson['icon'] del newPerson['image'] - del newPerson['skills'] + if newPerson.get('skills'): + del newPerson['skills'] del newPerson['shares'] - del newPerson['roles'] + if newPerson.get('roles'): + del newPerson['roles'] del newPerson['tag'] del newPerson['availability'] del newPerson['followers'] @@ -405,7 +427,7 @@ def registerAccount(baseDir: str, httpPrefix: str, domain: str, port: int, def createGroup(baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, saveToFile: bool, - password=None) -> (str, str, {}, {}): + password: str = None) -> (str, str, {}, {}): """Returns a group """ (privateKeyPem, publicKeyPem, @@ -423,8 +445,7 @@ def savePersonQrcode(baseDir: str, """Saves a qrcode image for the handle of the person This helps to transfer onion or i2p handles to a mobile device """ - qrcodeFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/qrcode.png' + qrcodeFilename = acctDir(baseDir, nickname, domain) + '/qrcode.png' if os.path.isfile(qrcodeFilename): return handle = getFullDomain('@' + nickname + '@' + domain, port) @@ -435,7 +456,7 @@ def savePersonQrcode(baseDir: str, def createPerson(baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, saveToFile: bool, manualFollowerApproval: bool, - password=None) -> (str, str, {}, {}): + password: str = None) -> (str, str, {}, {}): """Returns the private key, public key, actor and webfinger endpoint """ if not validNickname(domain, nickname): @@ -466,26 +487,25 @@ def createPerson(baseDir: str, nickname: str, domain: str, port: int, if nickname != 'news': # print(nickname+' becomes the instance admin and a moderator') setConfigParam(baseDir, 'admin', nickname) - setRole(baseDir, nickname, domain, 'instance', 'admin') - setRole(baseDir, nickname, domain, 'instance', 'moderator') - setRole(baseDir, nickname, domain, 'instance', 'editor') - setRole(baseDir, nickname, domain, 'instance', 'delegator') + setRole(baseDir, nickname, domain, 'admin') + setRole(baseDir, nickname, domain, 'moderator') + setRole(baseDir, nickname, domain, 'editor') if not os.path.isdir(baseDir + '/accounts'): os.mkdir(baseDir + '/accounts') - if not os.path.isdir(baseDir + '/accounts/' + nickname + '@' + domain): - os.mkdir(baseDir + '/accounts/' + nickname + '@' + domain) + accountDir = acctDir(baseDir, nickname, domain) + if not os.path.isdir(accountDir): + os.mkdir(accountDir) if manualFollowerApproval: - followDMsFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/.followDMs' + followDMsFilename = acctDir(baseDir, nickname, domain) + '/.followDMs' with open(followDMsFilename, 'w+') as fFile: fFile.write('\n') # notify when posts are liked if nickname != 'news': - notifyLikesFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/.notifyLikes' + notifyLikesFilename = \ + acctDir(baseDir, nickname, domain) + '/.notifyLikes' with open(notifyLikesFilename, 'w+') as nFile: nFile.write('\n') @@ -495,15 +515,14 @@ def createPerson(baseDir: str, nickname: str, domain: str, port: int, if nickname != 'news': if os.path.isfile(baseDir + '/img/default-avatar.png'): + accountDir = acctDir(baseDir, nickname, domain) copyfile(baseDir + '/img/default-avatar.png', - baseDir + '/accounts/' + nickname + '@' + domain + - '/avatar.png') + accountDir + '/avatar.png') else: newsAvatar = baseDir + '/theme/' + theme + '/icons/avatar_news.png' if os.path.isfile(newsAvatar): - copyfile(newsAvatar, - baseDir + '/accounts/' + nickname + '@' + domain + - '/avatar.png') + accountDir = acctDir(baseDir, nickname, domain) + copyfile(newsAvatar, accountDir + '/avatar.png') defaultProfileImageFilename = baseDir + '/theme/default/image.png' if theme: @@ -511,15 +530,15 @@ def createPerson(baseDir: str, nickname: str, domain: str, port: int, defaultProfileImageFilename = \ baseDir + '/theme/' + theme + '/image.png' if os.path.isfile(defaultProfileImageFilename): - copyfile(defaultProfileImageFilename, baseDir + - '/accounts/' + nickname + '@' + domain + '/image.png') + accountDir = acctDir(baseDir, nickname, domain) + copyfile(defaultProfileImageFilename, accountDir + '/image.png') defaultBannerFilename = baseDir + '/theme/default/banner.png' if theme: if os.path.isfile(baseDir + '/theme/' + theme + '/banner.png'): defaultBannerFilename = baseDir + '/theme/' + theme + '/banner.png' if os.path.isfile(defaultBannerFilename): - copyfile(defaultBannerFilename, baseDir + '/accounts/' + - nickname + '@' + domain + '/banner.png') + accountDir = acctDir(baseDir, nickname, domain) + copyfile(defaultBannerFilename, accountDir + '/banner.png') if nickname != 'news' and remainingConfigExists: registrationsRemaining -= 1 setConfigParam(baseDir, 'registrationsRemaining', @@ -555,7 +574,107 @@ def personUpgradeActor(baseDir: str, personJson: {}, if not personJson: personJson = loadJson(filename) + # add a speaker endpoint + if not personJson.get('tts'): + personJson['tts'] = personJson['id'] + '/speaker' + updateActor = True + + if not personJson.get('published'): + statusNumber, published = getStatusNumber() + personJson['published'] = published + updateActor = True + + occupationName = '' + if personJson.get('occupationName'): + occupationName = personJson['occupationName'] + del personJson['occupationName'] + updateActor = True + if personJson.get('occupation'): + occupationName = personJson['occupation'] + del personJson['occupation'] + updateActor = True + + # if the older skills format is being used then switch + # to the new one + if not personJson.get('hasOccupation'): + personJson['hasOccupation'] = [{ + '@type': 'Occupation', + 'name': occupationName, + "occupationLocation": { + "@type": "City", + "name": "Fediverse" + }, + 'skills': [] + }] + updateActor = True + + # remove the old skills format + if personJson.get('skills'): + del personJson['skills'] + updateActor = True + + # if the older roles format is being used then switch + # to the new one + if personJson.get('affiliation'): + del personJson['affiliation'] + updateActor = True + + if not isinstance(personJson['hasOccupation'], list): + personJson['hasOccupation'] = [{ + '@type': 'Occupation', + 'name': occupationName, + 'occupationLocation': { + '@type': 'City', + 'name': 'Fediverse' + }, + 'skills': [] + }] + updateActor = True + else: + # add location if it is missing + for index in range(len(personJson['hasOccupation'])): + ocItem = personJson['hasOccupation'][index] + if ocItem.get('hasOccupation'): + ocItem = ocItem['hasOccupation'] + if ocItem.get('location'): + del ocItem['location'] + updateActor = True + if not ocItem.get('occupationLocation'): + ocItem['occupationLocation'] = { + "@type": "City", + "name": "Fediverse" + } + updateActor = True + else: + if ocItem['occupationLocation']['@type'] != 'City': + ocItem['occupationLocation'] = { + "@type": "City", + "name": "Fediverse" + } + updateActor = True + + # if no roles are defined then ensure that the admin + # roles are configured + rolesList = getActorRolesList(personJson) + if not rolesList: + adminName = getConfigParam(baseDir, 'admin') + if personJson['id'].endswith('/users/' + adminName): + rolesList = ["admin", "moderator", "editor"] + setRolesFromList(personJson, rolesList) + updateActor = True + + # remove the old roles format + if personJson.get('roles'): + del personJson['roles'] + updateActor = True + if updateActor: + personJson['@context'] = [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + getDefaultPersonContext() + ], + saveJson(personJson, filename) # also update the actor within the cache @@ -601,8 +720,7 @@ def personLookup(domain: str, path: str, baseDir: str) -> {}: return None if not isSharedInbox and not validNickname(domain, nickname): return None - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domain) handle = nickname + '@' + domain filename = baseDir + '/accounts/' + handle + '.json' if not os.path.isfile(filename): @@ -627,8 +745,7 @@ def personBoxJson(recentPostsCache: {}, boxname != 'tlblogs' and boxname != 'tlnews' and \ boxname != 'tlfeatures' and \ boxname != 'outbox' and boxname != 'moderation' and \ - boxname != 'tlbookmarks' and boxname != 'bookmarks' and \ - boxname != 'tlevents': + boxname != 'tlbookmarks' and boxname != 'bookmarks': return None if not '/' + boxname in path: @@ -677,12 +794,6 @@ def personBoxJson(recentPostsCache: {}, port, httpPrefix, noOfItems, headerOnly, pageNumber) - elif boxname == 'tlevents': - return createEventsTimeline(recentPostsCache, - session, baseDir, nickname, domain, - port, httpPrefix, - noOfItems, headerOnly, - pageNumber) elif boxname == 'tlreplies': return createRepliesTimeline(recentPostsCache, session, baseDir, nickname, domain, @@ -760,13 +871,13 @@ def reenableAccount(baseDir: str, nickname: str) -> None: """ suspendedFilename = baseDir + '/accounts/suspended.txt' if os.path.isfile(suspendedFilename): - with open(suspendedFilename, "r") as f: + lines = [] + with open(suspendedFilename, 'r') as f: lines = f.readlines() - suspendedFile = open(suspendedFilename, "w+") - for suspended in lines: - if suspended.strip('\n').strip('\r') != nickname: - suspendedFile.write(suspended) - suspendedFile.close() + with open(suspendedFilename, 'w+') as suspendedFile: + for suspended in lines: + if suspended.strip('\n').strip('\r') != nickname: + suspendedFile.write(suspended) def suspendAccount(baseDir: str, nickname: str, domain: str) -> None: @@ -782,37 +893,31 @@ def suspendAccount(baseDir: str, nickname: str, domain: str) -> None: # Don't suspend moderators moderatorsFile = baseDir + '/accounts/moderators.txt' if os.path.isfile(moderatorsFile): - with open(moderatorsFile, "r") as f: + with open(moderatorsFile, 'r') as f: lines = f.readlines() for moderator in lines: if moderator.strip('\n').strip('\r') == nickname: return - saltFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/.salt' + saltFilename = acctDir(baseDir, nickname, domain) + '/.salt' if os.path.isfile(saltFilename): os.remove(saltFilename) - tokenFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/.token' + tokenFilename = acctDir(baseDir, nickname, domain) + '/.token' if os.path.isfile(tokenFilename): os.remove(tokenFilename) 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: return - suspendedFile = open(suspendedFilename, 'a+') - if suspendedFile: + with open(suspendedFilename, 'a+') as suspendedFile: suspendedFile.write(nickname + '\n') - suspendedFile.close() else: - suspendedFile = open(suspendedFilename, 'w+') - if suspendedFile: + with open(suspendedFilename, 'w+') as suspendedFile: suspendedFile.write(nickname + '\n') - suspendedFile.close() def canRemovePost(baseDir: str, nickname: str, @@ -834,7 +939,7 @@ def canRemovePost(baseDir: str, nickname: str, # is the post by a moderator? moderatorsFile = baseDir + '/accounts/moderators.txt' if os.path.isfile(moderatorsFile): - with open(moderatorsFile, "r") as f: + with open(moderatorsFile, 'r') as f: lines = f.readlines() for moderator in lines: if domainFull + '/users/' + moderator.strip('\n') + '/' in postId: @@ -864,14 +969,13 @@ def _removeTagsForNickname(baseDir: str, nickname: str, continue if matchStr not in open(tagFilename).read(): continue - with open(tagFilename, "r") as f: + lines = [] + with open(tagFilename, 'r') as f: lines = f.readlines() - tagFile = open(tagFilename, "w+") - if tagFile: + with open(tagFilename, 'w+') as tagFile: for tagline in lines: if matchStr not in tagline: tagFile.write(tagline) - tagFile.close() def removeAccount(baseDir: str, nickname: str, @@ -888,7 +992,7 @@ def removeAccount(baseDir: str, nickname: str, # Don't remove moderators moderatorsFile = baseDir + '/accounts/moderators.txt' if os.path.isfile(moderatorsFile): - with open(moderatorsFile, "r") as f: + with open(moderatorsFile, 'r') as f: lines = f.readlines() for moderator in lines: if moderator.strip('\n') == nickname: @@ -984,8 +1088,7 @@ def isPersonSnoozed(baseDir: str, nickname: str, domain: str, snoozeActor: str) -> bool: """Returns true if the given actor is snoozed """ - snoozedFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/snoozed.txt' + snoozedFilename = acctDir(baseDir, nickname, domain) + '/snoozed.txt' if not os.path.isfile(snoozedFilename): return False if snoozeActor + ' ' not in open(snoozedFilename).read(): @@ -1013,10 +1116,8 @@ def isPersonSnoozed(baseDir: str, nickname: str, domain: str, with open(snoozedFilename, 'r') as snoozedFile: content = snoozedFile.read().replace(replaceStr, '') if content: - writeSnoozedFile = open(snoozedFilename, 'w+') - if writeSnoozedFile: + with open(snoozedFilename, 'w+') as writeSnoozedFile: writeSnoozedFile.write(content) - writeSnoozedFile.close() if snoozeActor + ' ' in open(snoozedFilename).read(): return True @@ -1027,7 +1128,7 @@ def personSnooze(baseDir: str, nickname: str, domain: str, snoozeActor: str) -> None: """Temporarily ignores the given actor """ - accountDir = baseDir + '/accounts/' + nickname + '@' + domain + accountDir = acctDir(baseDir, nickname, domain) if not os.path.isdir(accountDir): print('ERROR: unknown account ' + accountDir) return @@ -1035,18 +1136,16 @@ def personSnooze(baseDir: str, nickname: str, domain: str, if os.path.isfile(snoozedFilename): if snoozeActor + ' ' in open(snoozedFilename).read(): return - snoozedFile = open(snoozedFilename, "a+") - if snoozedFile: + with open(snoozedFilename, 'a+') as snoozedFile: snoozedFile.write(snoozeActor + ' ' + str(int(time.time())) + '\n') - snoozedFile.close() def personUnsnooze(baseDir: str, nickname: str, domain: str, snoozeActor: str) -> None: """Undoes a temporarily ignore of the given actor """ - accountDir = baseDir + '/accounts/' + nickname + '@' + domain + accountDir = acctDir(baseDir, nickname, domain) if not os.path.isdir(accountDir): print('ERROR: unknown account ' + accountDir) return @@ -1066,10 +1165,8 @@ def personUnsnooze(baseDir: str, nickname: str, domain: str, with open(snoozedFilename, 'r') as snoozedFile: content = snoozedFile.read().replace(replaceStr, '') if content: - writeSnoozedFile = open(snoozedFilename, 'w+') - if writeSnoozedFile: + with open(snoozedFilename, 'w+') as writeSnoozedFile: writeSnoozedFile.write(content) - writeSnoozedFile.close() def setPersonNotes(baseDir: str, nickname: str, domain: str, @@ -1080,11 +1177,179 @@ def setPersonNotes(baseDir: str, nickname: str, domain: str, return False if handle.startswith('@'): handle = handle[1:] - notesDir = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/notes' + notesDir = acctDir(baseDir, nickname, domain) + '/notes' if not os.path.isdir(notesDir): os.mkdir(notesDir) notesFilename = notesDir + '/' + handle + '.txt' with open(notesFilename, 'w+') as notesFile: notesFile.write(notes) return True + + +def getActorJson(hostDomain: str, handle: str, http: bool, gnunet: bool, + debug: bool, quiet: bool = False) -> ({}, {}): + """Returns the actor json + """ + if debug: + print('getActorJson for ' + handle) + originalActor = handle + if '/@' in handle or \ + '/users/' in handle or \ + handle.startswith('http') or \ + handle.startswith('hyper'): + # format: https://domain/@nick + originalHandle = handle + if not hasUsersPath(originalHandle): + if not quiet or debug: + print('getActorJson: Expected actor format: ' + + 'https://domain/@nick or https://domain/users/nick') + return None, None + prefixes = getProtocolPrefixes() + for prefix in prefixes: + handle = handle.replace(prefix, '') + handle = handle.replace('/@', '/users/') + paths = getUserPaths() + userPathFound = False + for userPath in paths: + if userPath in handle: + nickname = handle.split(userPath)[1] + nickname = nickname.replace('\n', '').replace('\r', '') + domain = handle.split(userPath)[0] + userPathFound = True + break + if not userPathFound and '://' in originalHandle: + domain = originalHandle.split('://')[1] + if '/' in domain: + domain = domain.split('/')[0] + if '://' + domain + '/' not in originalHandle: + return None, None + nickname = originalHandle.split('://' + domain + '/')[1] + if '/' in nickname or '.' in nickname: + return None, None + else: + # format: @nick@domain + if '@' not in handle: + if not quiet: + print('getActorJson Syntax: --actor nickname@domain') + return None, None + if handle.startswith('@'): + handle = handle[1:] + if '@' not in handle: + if not quiet: + print('getActorJsonSyntax: --actor nickname@domain') + return None, None + nickname = handle.split('@')[0] + domain = handle.split('@')[1] + domain = domain.replace('\n', '').replace('\r', '') + + cachedWebfingers = {} + proxyType = None + if http or domain.endswith('.onion'): + httpPrefix = 'http' + proxyType = 'tor' + elif domain.endswith('.i2p'): + httpPrefix = 'http' + proxyType = 'i2p' + elif gnunet: + httpPrefix = 'gnunet' + proxyType = 'gnunet' + else: + if '127.0.' not in domain and '192.168.' not in domain: + httpPrefix = 'https' + else: + httpPrefix = 'http' + session = createSession(proxyType) + if nickname == 'inbox': + nickname = domain + + handle = nickname + '@' + domain + wfRequest = webfingerHandle(session, handle, + httpPrefix, cachedWebfingers, + None, __version__, debug) + if not wfRequest: + if not quiet: + print('getActorJson Unable to webfinger ' + handle) + return None, None + if not isinstance(wfRequest, dict): + if not quiet: + print('getActorJson Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return None, None + + if not quiet: + pprint(wfRequest) + + personUrl = None + if wfRequest.get('errors'): + if not quiet or debug: + print('getActorJson wfRequest error: ' + str(wfRequest['errors'])) + if hasUsersPath(handle): + personUrl = originalActor + else: + if debug: + print('No users path in ' + handle) + return None, None + + profileStr = 'https://www.w3.org/ns/activitystreams' + headersList = ( + "activity+json", "ld+json", "jrd+json" + ) + if not personUrl: + personUrl = getUserUrl(wfRequest, 0, debug) + if nickname == domain: + paths = getUserPaths() + for userPath in paths: + personUrl = personUrl.replace(userPath, '/actor/') + if not personUrl: + # try single user instance + personUrl = httpPrefix + '://' + domain + '/' + nickname + headersList = ( + "ld+json", "jrd+json", "activity+json" + ) + if '/channel/' in personUrl or '/accounts/' in personUrl: + headersList = ( + "ld+json", "jrd+json", "activity+json" + ) + if debug: + print('personUrl: ' + personUrl) + for headerType in headersList: + headerMimeType = 'application/' + headerType + asHeader = { + 'Accept': headerMimeType + '; profile="' + profileStr + '"' + } + personJson = \ + getJson(session, personUrl, asHeader, None, + debug, __version__, httpPrefix, hostDomain, 20, quiet) + if personJson: + if not quiet: + pprint(personJson) + return personJson, asHeader + return None, None + + +def getPersonAvatarUrl(baseDir: str, personUrl: str, personCache: {}, + allowDownloads: bool) -> str: + """Returns the avatar url for the person + """ + personJson = \ + getPersonFromCache(baseDir, personUrl, personCache, allowDownloads) + if not personJson: + return None + + # get from locally stored image + if not personJson.get('id'): + return None + actorStr = personJson['id'].replace('/', '-') + avatarImagePath = baseDir + '/cache/avatars/' + actorStr + + imageExtension = getImageExtensions() + for ext in imageExtension: + if os.path.isfile(avatarImagePath + '.' + ext): + return '/avatars/' + actorStr + '.' + ext + elif os.path.isfile(avatarImagePath.lower() + '.' + ext): + return '/avatars/' + actorStr.lower() + '.' + ext + + if personJson.get('icon'): + if personJson['icon'].get('url'): + return personJson['icon']['url'] + return None diff --git a/petnames.py b/petnames.py index 21c46095a..6346c22f5 100644 --- a/petnames.py +++ b/petnames.py @@ -5,8 +5,10 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Core" import os +from utils import acctDir def setPetName(baseDir: str, nickname: str, domain: str, @@ -21,8 +23,7 @@ def setPetName(baseDir: str, nickname: str, domain: str, handle = handle[1:] if petname.startswith('@'): petname = petname[1:] - petnamesFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/petnames.txt' + petnamesFilename = acctDir(baseDir, nickname, domain) + '/petnames.txt' entry = petname + ' ' + handle + '\n' # does this entry already exist? @@ -62,8 +63,7 @@ def getPetName(baseDir: str, nickname: str, domain: str, return '' if handle.startswith('@'): handle = handle[1:] - petnamesFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/petnames.txt' + petnamesFilename = acctDir(baseDir, nickname, domain) + '/petnames.txt' if not os.path.isfile(petnamesFilename): return '' @@ -74,6 +74,13 @@ def getPetName(baseDir: str, nickname: str, domain: str, for pet in petnamesList: if pet.endswith(' ' + handle): return pet.replace(' ' + handle, '').strip() + elif ' ' + handle.lower() + '\n' in petnamesStr.lower(): + petnamesList = petnamesStr.split('\n') + handle = handle.lower() + for pet in petnamesList: + if pet.lower().endswith(' ' + handle): + handle2 = pet.split(' ')[-1] + return pet.replace(' ' + handle2, '').strip() return '' @@ -83,8 +90,7 @@ def _getPetNameHandle(baseDir: str, nickname: str, domain: str, """ if petname.startswith('@'): petname = petname[1:] - petnamesFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/petnames.txt' + petnamesFilename = acctDir(baseDir, nickname, domain) + '/petnames.txt' if not os.path.isfile(petnamesFilename): return '' diff --git a/pgp.py b/pgp.py index 5a332a7f3..619bad3d5 100644 --- a/pgp.py +++ b/pgp.py @@ -5,6 +5,20 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Profile Metadata" + +import os +import subprocess +from pathlib import Path +from person import getActorJson +from utils import containsPGPPublicKey +from utils import isPGPEncrypted +from utils import getFullDomain +from utils import getStatusNumber +from webfinger import webfingerHandle +from posts import getPersonBox +from auth import createBasicAuthHeader +from session import postJson def getEmailAddress(actorJson: {}) -> str: @@ -47,7 +61,7 @@ def getPGPpubKey(actorJson: {}) -> str: continue if propertyValue['type'] != 'PropertyValue': continue - if '--BEGIN PGP PUBLIC KEY' not in propertyValue['value']: + if not containsPGPPublicKey(propertyValue['value']): continue return propertyValue['value'] return '' @@ -134,7 +148,7 @@ def setPGPpubKey(actorJson: {}, PGPpubKey: str) -> None: if not PGPpubKey: removeKey = True else: - if '--BEGIN PGP PUBLIC KEY' not in PGPpubKey: + if not containsPGPPublicKey(PGPpubKey): removeKey = True if '<' in PGPpubKey: removeKey = True @@ -225,3 +239,369 @@ def setPGPfingerprint(actorJson: {}, fingerprint: str) -> None: "value": fingerprint } actorJson['attachment'].append(newPGPfingerprint) + + +def extractPGPPublicKey(content: str) -> str: + """Returns the PGP key from the given text + """ + startBlock = '--BEGIN PGP PUBLIC KEY BLOCK--' + endBlock = '--END PGP PUBLIC KEY BLOCK--' + if startBlock not in content: + return None + if endBlock not in content: + return None + if '\n' not in content: + return None + linesList = content.split('\n') + extracting = False + publicKey = '' + for line in linesList: + if not extracting: + if startBlock in line: + extracting = True + else: + if endBlock in line: + publicKey += line + break + if extracting: + publicKey += line + '\n' + return publicKey + + +def _pgpImportPubKey(recipientPubKey: str) -> str: + """ Import the given public key + """ + # do a dry run + cmdImportPubKey = \ + 'echo "' + recipientPubKey + '" | gpg --dry-run --import 2> /dev/null' + proc = subprocess.Popen([cmdImportPubKey], + stdout=subprocess.PIPE, shell=True) + (importResult, err) = proc.communicate() + if err: + return None + + # this time for real + cmdImportPubKey = \ + 'echo "' + recipientPubKey + '" | gpg --import 2> /dev/null' + proc = subprocess.Popen([cmdImportPubKey], + stdout=subprocess.PIPE, shell=True) + (importResult, err) = proc.communicate() + if err: + return None + + # get the key id + cmdImportPubKey = \ + 'echo "' + recipientPubKey + '" | gpg --show-keys' + proc = subprocess.Popen([cmdImportPubKey], + stdout=subprocess.PIPE, shell=True) + (importResult, err) = proc.communicate() + if not importResult: + return None + importResult = importResult.decode('utf-8').split('\n') + keyId = '' + for line in importResult: + if line.startswith('pub'): + continue + elif line.startswith('uid'): + continue + elif line.startswith('sub'): + continue + keyId = line.strip() + break + return keyId + + +def _pgpEncrypt(content: str, recipientPubKey: str) -> str: + """ Encrypt using your default pgp key to the given recipient + """ + keyId = _pgpImportPubKey(recipientPubKey) + if not keyId: + return None + + cmdEncrypt = \ + 'echo "' + content + '" | gpg --encrypt --armor --recipient ' + \ + keyId + ' 2> /dev/null' + proc = subprocess.Popen([cmdEncrypt], + stdout=subprocess.PIPE, shell=True) + (encryptResult, err) = proc.communicate() + if not encryptResult: + return None + encryptResult = encryptResult.decode('utf-8') + if not isPGPEncrypted(encryptResult): + return None + return encryptResult + + +def _getPGPPublicKeyFromActor(domain: str, handle: str, + actorJson: {} = None) -> str: + """Searches tags on the actor to see if there is any PGP + public key specified + """ + if not actorJson: + actorJson, asHeader = \ + getActorJson(domain, handle, False, False, False, True) + if not actorJson: + return None + if not actorJson.get('attachment'): + return None + if not isinstance(actorJson['attachment'], list): + return None + # search through the tags on the actor + for tag in actorJson['attachment']: + if not isinstance(tag, dict): + continue + if not tag.get('value'): + continue + if not isinstance(tag['value'], str): + continue + if containsPGPPublicKey(tag['value']): + return tag['value'] + return None + + +def hasLocalPGPkey() -> bool: + """Returns true if there is a local .gnupg directory + """ + homeDir = str(Path.home()) + gpgDir = homeDir + '/.gnupg' + if os.path.isdir(gpgDir): + keyId = pgpLocalPublicKey() + if keyId: + return True + return False + + +def pgpEncryptToActor(domain: str, content: str, toHandle: str) -> str: + """PGP encrypt a message to the given actor or handle + """ + # get the actor and extract the pgp public key from it + recipientPubKey = _getPGPPublicKeyFromActor(domain, toHandle) + if not recipientPubKey: + return None + # encrypt using the recipient public key + return _pgpEncrypt(content, recipientPubKey) + + +def pgpDecrypt(domain: str, content: str, fromHandle: str) -> str: + """ Encrypt using your default pgp key to the given recipient + fromHandle can be a handle or actor url + """ + if not isPGPEncrypted(content): + return content + + # if the public key is also included within the message then import it + if containsPGPPublicKey(content): + pubKey = extractPGPPublicKey(content) + else: + pubKey = _getPGPPublicKeyFromActor(domain, content, fromHandle) + if pubKey: + _pgpImportPubKey(pubKey) + + cmdDecrypt = \ + 'echo "' + content + '" | gpg --decrypt --armor 2> /dev/null' + proc = subprocess.Popen([cmdDecrypt], + stdout=subprocess.PIPE, shell=True) + (decryptResult, err) = proc.communicate() + if not decryptResult: + return content + decryptResult = decryptResult.decode('utf-8').strip() + return decryptResult + + +def _pgpLocalPublicKeyId() -> str: + """Gets the local pgp public key ID + """ + cmdStr = \ + "gpgconf --list-options gpg | " + \ + "awk -F: '$1 == \"default-key\" {print $10}'" + proc = subprocess.Popen([cmdStr], + stdout=subprocess.PIPE, shell=True) + (result, err) = proc.communicate() + if err: + return None + if not result: + return None + if len(result) < 5: + return None + return result.decode('utf-8').replace('"', '').strip() + + +def pgpLocalPublicKey() -> str: + """Gets the local pgp public key + """ + keyId = _pgpLocalPublicKeyId() + if not keyId: + keyId = '' + cmdStr = "gpg --armor --export " + keyId + proc = subprocess.Popen([cmdStr], + stdout=subprocess.PIPE, shell=True) + (result, err) = proc.communicate() + if err: + return None + if not result: + return None + return extractPGPPublicKey(result.decode('utf-8')) + + +def pgpPublicKeyUpload(baseDir: str, session, + nickname: str, password: str, + domain: str, port: int, + httpPrefix: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, test: str) -> {}: + if debug: + print('pgpPublicKeyUpload') + + if not session: + if debug: + print('WARN: No session for pgpPublicKeyUpload') + return None + + if not test: + if debug: + print('Getting PGP public key') + PGPpubKey = pgpLocalPublicKey() + if not PGPpubKey: + return None + PGPpubKeyId = _pgpLocalPublicKeyId() + else: + if debug: + print('Testing with PGP public key ' + test) + PGPpubKey = test + PGPpubKeyId = None + + domainFull = getFullDomain(domain, port) + if debug: + print('PGP test domain: ' + domainFull) + + handle = nickname + '@' + domainFull + + if debug: + print('Getting actor for ' + handle) + + actorJson, asHeader = \ + getActorJson(domain, handle, False, False, debug, True) + if not actorJson: + if debug: + print('No actor returned for ' + handle) + return None + + if debug: + print('Actor for ' + handle + ' obtained') + + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + handle = actor.replace('/users/', '/@') + + # check that this looks like the correct actor + if not actorJson.get('id'): + if debug: + print('Actor has no id') + return None + if not actorJson.get('url'): + if debug: + print('Actor has no url') + return None + if not actorJson.get('type'): + if debug: + print('Actor has no type') + return None + if actorJson['id'] != actor: + if debug: + print('Actor id is not ' + actor + + ' instead is ' + actorJson['id']) + return None + if actorJson['url'] != handle: + if debug: + print('Actor url is not ' + handle) + return None + if actorJson['type'] != 'Person': + if debug: + print('Actor type is not Person') + return None + + # set the pgp details + if PGPpubKeyId: + setPGPfingerprint(actorJson, PGPpubKeyId) + else: + if debug: + print('No PGP key Id. Continuing anyway.') + + if debug: + print('Setting PGP key within ' + actor) + setPGPpubKey(actorJson, PGPpubKey) + + # create an actor update + statusNumber, published = getStatusNumber() + actorUpdate = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': actor + '#updates/' + statusNumber, + 'type': 'Update', + 'actor': actor, + 'to': [actor], + 'cc': [], + 'object': actorJson + } + if debug: + print('actor update is ' + str(actorUpdate)) + + # lookup the inbox for the To handle + wfRequest = \ + webfingerHandle(session, handle, httpPrefix, cachedWebfingers, + domain, __version__, debug) + if not wfRequest: + if debug: + print('DEBUG: pgp actor update webfinger failed for ' + + handle) + return None + if not isinstance(wfRequest, dict): + if debug: + print('WARN: Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) + return None + + postToBox = 'outbox' + + # get the actor inbox for the To handle + (inboxUrl, pubKeyId, pubKey, + fromPersonId, sharedInbox, avatarUrl, + displayName) = getPersonBox(baseDir, session, wfRequest, personCache, + __version__, httpPrefix, nickname, + domain, postToBox, 52025) + + if not inboxUrl: + if debug: + print('DEBUG: No ' + postToBox + ' was found for ' + handle) + return None + if not fromPersonId: + if debug: + print('DEBUG: No actor was found for ' + handle) + return None + + authHeader = createBasicAuthHeader(nickname, password) + + headers = { + 'host': domain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + quiet = not debug + tries = 0 + while tries < 4: + postResult = \ + postJson(httpPrefix, domainFull, + session, actorUpdate, [], inboxUrl, + headers, 5, quiet) + if postResult: + break + tries += 1 + + if postResult is None: + if debug: + print('DEBUG: POST pgp actor update failed for c2s to ' + + inboxUrl) + return None + + if debug: + print('DEBUG: c2s POST pgp actor update success') + + return actorUpdate diff --git a/posts.py b/posts.py index ff00c98a1..757751e31 100644 --- a/posts.py +++ b/posts.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "ActivityPub" import json import html @@ -13,7 +14,6 @@ import os import shutil import sys import time -import uuid import random from socket import error as SocketError from time import gmtime, strftime @@ -31,6 +31,10 @@ from session import postImage from webfinger import webfingerHandle from httpsig import createSignedHeader from siteactive import siteIsActive +from utils import removeDomainPort +from utils import getPortFromDomain +from utils import hasObjectDict +from utils import rejectPostId from utils import removeInvalidChars from utils import fileLastModified from utils import isPublicPost @@ -39,8 +43,6 @@ from utils import validPostDate from utils import getFullDomain from utils import getFollowersList from utils import isEvil -from utils import removeIdEnding -from utils import getCachedPostFilename from utils import getStatusNumber from utils import createPersonDir from utils import urlPermitted @@ -57,8 +59,10 @@ from utils import locateNewsArrival from utils import votesOnNewswireItem from utils import removeHtml from utils import dangerousMarkup +from utils import acctDir from media import attachMedia from media import replaceYouTube +from content import limitRepeatedWords from content import tagExists from content import removeLongWords from content import addHtmlTags @@ -86,7 +90,7 @@ def isModerator(baseDir: str, nickname: str) -> bool: return True return False - with open(moderatorsFile, "r") as f: + with open(moderatorsFile, 'r') as f: lines = f.readlines() if len(lines) == 0: adminName = getConfigParam(baseDir, 'admin') @@ -110,7 +114,7 @@ def noOfFollowersOnDomain(baseDir: str, handle: str, return 0 ctr = 0 - with open(filename, "r") as followersFilename: + with open(filename, 'r') as followersFilename: for followerHandle in followersFilename: if '@' in followerHandle: followerDomain = followerHandle.split('@')[1] @@ -121,8 +125,8 @@ def noOfFollowersOnDomain(baseDir: str, handle: str, return ctr -def _getPersonKey(nickname: str, domain: str, baseDir: str, keyType='public', - debug=False): +def _getPersonKey(nickname: str, domain: str, baseDir: str, + keyType: str = 'public', debug: bool = False): """Returns the public or private key of a person """ handle = nickname + '@' + domain @@ -132,7 +136,7 @@ def _getPersonKey(nickname: str, domain: str, baseDir: str, keyType='public', print('DEBUG: private key file not found: ' + keyFilename) return '' keyPem = '' - with open(keyFilename, "r") as pemFile: + with open(keyFilename, 'r') as pemFile: keyPem = pemFile.read() if len(keyPem) < 20: if debug: @@ -147,7 +151,7 @@ def _cleanHtml(rawHtml: str) -> str: return html.unescape(text) -def getUserUrl(wfRequest: {}, sourceId=0) -> str: +def getUserUrl(wfRequest: {}, sourceId: int = 0, debug: bool = False) -> str: """Gets the actor url from a webfinger request """ if not wfRequest.get('links'): @@ -165,7 +169,7 @@ def getUserUrl(wfRequest: {}, sourceId=0) -> str: if link['type'] != 'application/activity+json': continue if '/@' not in link['href']: - if not hasUsersPath(link['href']): + if debug and not hasUsersPath(link['href']): print('getUserUrl webfinger activity+json ' + 'contains single user instance actor ' + str(sourceId) + ' ' + str(link)) @@ -182,7 +186,7 @@ def parseUserFeed(session, feedUrl: str, asHeader: {}, return None feedJson = getJson(session, feedUrl, asHeader, None, - projectVersion, httpPrefix, domain) + False, projectVersion, httpPrefix, domain) if not feedJson: return None @@ -219,6 +223,7 @@ def getPersonBox(baseDir: str, session, wfRequest: {}, nickname: str, domain: str, boxName='inbox', sourceId=0) -> (str, str, str, str, str, str, str, str): + debug = False profileStr = 'https://www.w3.org/ns/activitystreams' asHeader = { 'Accept': 'application/activity+json; profile="' + profileStr + '"' @@ -228,7 +233,7 @@ def getPersonBox(baseDir: str, session, wfRequest: {}, return None, None, None, None, None, None, None if not wfRequest.get('errors'): - personUrl = getUserUrl(wfRequest, sourceId) + personUrl = getUserUrl(wfRequest, sourceId, debug) else: if nickname == 'dev': # try single user instance @@ -249,15 +254,15 @@ def getPersonBox(baseDir: str, session, wfRequest: {}, 'Accept': 'application/ld+json; profile="' + profileStr + '"' } personJson = getJson(session, personUrl, asHeader, None, - projectVersion, httpPrefix, domain) + debug, projectVersion, httpPrefix, domain) if not personJson: asHeader = { 'Accept': 'application/ld+json; profile="' + profileStr + '"' } personJson = getJson(session, personUrl, asHeader, None, - projectVersion, httpPrefix, domain) + debug, projectVersion, httpPrefix, domain) if not personJson: - print('Unable to get actor') + print('Unable to get actor for ' + personUrl) return None, None, None, None, None, None, None boxJson = None if not personJson.get(boxName): @@ -360,11 +365,7 @@ def _getPosts(session, outboxUrl: str, maxPosts: int, if debug: print('Not Create type') continue - if not item.get('object'): - if debug: - print('No object') - continue - if not isinstance(item['object'], dict): + if not hasObjectDict(item): if debug: print('item object is not a dict') continue @@ -493,9 +494,9 @@ def _updateWordFrequency(content: str, wordFrequency: {}) -> None: that they appear """ plainText = removeHtml(content) - plainText = plainText.replace('.', ' ') - plainText = plainText.replace(';', ' ') - plainText = plainText.replace('?', ' ') + removeChars = ('.', ';', '?') + for ch in removeChars: + plainText = plainText.replace(ch, ' ') wordsList = plainText.split(' ') commonWords = ( 'that', 'some', 'about', 'then', 'they', 'were', @@ -560,9 +561,7 @@ def getPostDomains(session, outboxUrl: str, maxPosts: int, i += 1 if i > maxPosts: break - if not item.get('object'): - continue - if not isinstance(item['object'], dict): + if not hasObjectDict(item): continue if item['object'].get('content'): _updateWordFrequency(item['object']['content'], @@ -617,9 +616,7 @@ def _getPostsForBlockedDomains(baseDir: str, i += 1 if i > maxPosts: break - if not item.get('object'): - continue - if not isinstance(item['object'], dict): + if not hasObjectDict(item): continue if item['object'].get('inReplyTo'): if isinstance(item['object']['inReplyTo'], str): @@ -661,8 +658,7 @@ def deleteAllPosts(baseDir: str, """Deletes all posts for a person from inbox or outbox """ if boxname != 'inbox' and boxname != 'outbox' and \ - boxname != 'tlblogs' and boxname != 'tlnews' and \ - boxname != 'tlevents': + boxname != 'tlblogs' and boxname != 'tlnews': return boxDir = createPersonDir(nickname, domain, baseDir, boxname) for deleteFilename in os.scandir(boxDir): @@ -674,7 +670,7 @@ def deleteAllPosts(baseDir: str, elif os.path.isdir(filePath): shutil.rmtree(filePath) except Exception as e: - print(e) + print('ERROR: deleteAllPosts ' + str(e)) def savePostToBox(baseDir: str, httpPrefix: str, postId: str, @@ -685,12 +681,10 @@ def savePostToBox(baseDir: str, httpPrefix: str, postId: str, """ if boxname != 'inbox' and boxname != 'outbox' and \ boxname != 'tlblogs' and boxname != 'tlnews' and \ - boxname != 'tlevents' and \ boxname != 'scheduled': return None originalDomain = domain - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domain) if not postId: statusNumber, published = getStatusNumber() @@ -698,10 +692,9 @@ def savePostToBox(baseDir: str, httpPrefix: str, postId: str, httpPrefix + '://' + originalDomain + '/users/' + nickname + \ '/statuses/' + statusNumber postJsonObject['id'] = postId + '/activity' - if postJsonObject.get('object'): - if isinstance(postJsonObject['object'], dict): - postJsonObject['object']['id'] = postId - postJsonObject['object']['atomUri'] = postId + if hasObjectDict(postJsonObject): + postJsonObject['object']['id'] = postId + postJsonObject['object']['atomUri'] = postId boxDir = createPersonDir(nickname, domain, baseDir, boxname) filename = boxDir + '/' + postId.replace('/', '#') + '.json' @@ -727,10 +720,8 @@ def _updateHashtagsIndex(baseDir: str, tag: {}, newPostId: str) -> None: if not os.path.isfile(tagsFilename): # create a new tags index file - tagsFile = open(tagsFilename, "w+") - if tagsFile: + with open(tagsFilename, 'w+') as tagsFile: tagsFile.write(tagline) - tagsFile.close() else: # prepend to tags index file if tagline not in open(tagsFilename).read(): @@ -766,54 +757,8 @@ def _addSchedulePost(baseDir: str, nickname: str, domain: str, print('WARN: Failed to write entry to scheduled posts index ' + scheduleIndexFilename + ' ' + str(e)) else: - scheduleFile = open(scheduleIndexFilename, 'w+') - if scheduleFile: + with open(scheduleIndexFilename, 'w+') as scheduleFile: scheduleFile.write(indexStr + '\n') - scheduleFile.close() - - -def _appendEventFields(newPost: {}, - eventUUID: str, eventStatus: str, - anonymousParticipationEnabled: bool, - repliesModerationOption: str, - category: str, - joinMode: str, - eventDateStr: str, - endDateStr: str, - location: str, - maximumAttendeeCapacity: int, - ticketUrl: str, - subject: str) -> None: - """Appends Mobilizon-type event fields to a post - """ - if not eventUUID: - return - - # add attributes for Mobilizon-type events - newPost['uuid'] = eventUUID - if eventStatus: - newPost['ical:status'] = eventStatus - if anonymousParticipationEnabled: - newPost['anonymousParticipationEnabled'] = \ - anonymousParticipationEnabled - if repliesModerationOption: - newPost['repliesModerationOption'] = repliesModerationOption - if category: - newPost['category'] = category - if joinMode: - newPost['joinMode'] = joinMode - newPost['startTime'] = eventDateStr - newPost['endTime'] = endDateStr - if location: - newPost['location'] = location - if maximumAttendeeCapacity: - newPost['maximumAttendeeCapacity'] = maximumAttendeeCapacity - if ticketUrl: - newPost['ticketUrl'] = ticketUrl - if subject: - newPost['name'] = subject - newPost['summary'] = None - newPost['sensitive'] = False def validContentWarning(cw: str) -> str: @@ -831,11 +776,10 @@ def _loadAutoCW(baseDir: str, nickname: str, domain: str) -> []: """Loads automatic CWs file and returns a list containing the lines of the file """ - filename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/autocw.txt' + filename = acctDir(baseDir, nickname, domain) + '/autocw.txt' if not os.path.isfile(filename): return [] - with open(filename, "r") as f: + with open(filename, 'r') as f: return f.readlines() return [] @@ -862,23 +806,276 @@ def _addAutoCW(baseDir: str, nickname: str, domain: str, return newSubject +def _createPostCWFromReply(baseDir: str, nickname: str, domain: str, + inReplyTo: str, + sensitive: bool, summary: str) -> (bool, str): + """If this is a reply and the original post has a CW + then use the same CW + """ + if inReplyTo and not sensitive: + # locate the post which this is a reply to and check if + # it has a content warning. If it does then reproduce + # the same warning + replyPostFilename = \ + locatePost(baseDir, nickname, domain, inReplyTo) + if replyPostFilename: + replyToJson = loadJson(replyPostFilename) + if replyToJson: + if replyToJson.get('object'): + if replyToJson['object'].get('sensitive'): + if replyToJson['object']['sensitive']: + sensitive = True + if replyToJson['object'].get('summary'): + summary = replyToJson['object']['summary'] + return sensitive, summary + + +def _createPostS2S(baseDir: str, nickname: str, domain: str, port: int, + httpPrefix: str, content: str, statusNumber: str, + published: str, newPostId: str, postContext: {}, + toRecipients: [], toCC: [], inReplyTo: str, + sensitive: bool, commentsEnabled: bool, + tags: [], attachImageFilename: str, + mediaType: str, imageDescription: str, city: str, + postObjectType: str, summary: str, + inReplyToAtomUri: str) -> {}: + """Creates a new server-to-server post + """ + actorUrl = httpPrefix + '://' + domain + '/users/' + nickname + idStr = \ + httpPrefix + '://' + domain + '/users/' + nickname + \ + '/statuses/' + statusNumber + '/replies' + newPostUrl = \ + httpPrefix + '://' + domain + '/@' + nickname + '/' + statusNumber + newPostAttributedTo = \ + httpPrefix + '://' + domain + '/users/' + nickname + newPost = { + '@context': postContext, + 'id': newPostId + '/activity', + 'type': 'Create', + 'actor': actorUrl, + 'published': published, + 'to': toRecipients, + 'cc': toCC, + 'object': { + 'id': newPostId, + 'type': postObjectType, + 'summary': summary, + 'inReplyTo': inReplyTo, + 'published': published, + 'url': newPostUrl, + 'attributedTo': newPostAttributedTo, + 'to': toRecipients, + 'cc': toCC, + 'sensitive': sensitive, + 'atomUri': newPostId, + 'inReplyToAtomUri': inReplyToAtomUri, + 'commentsEnabled': commentsEnabled, + 'rejectReplies': not commentsEnabled, + 'mediaType': 'text/html', + 'content': content, + 'contentMap': { + 'en': content + }, + 'attachment': [], + 'tag': tags, + 'replies': { + 'id': idStr, + 'type': 'Collection', + 'first': { + 'type': 'CollectionPage', + 'partOf': idStr, + 'items': [] + } + } + } + } + if attachImageFilename: + newPost['object'] = \ + attachMedia(baseDir, httpPrefix, nickname, domain, port, + newPost['object'], attachImageFilename, + mediaType, imageDescription, city) + return newPost + + +def _createPostC2S(baseDir: str, nickname: str, domain: str, port: int, + httpPrefix: str, content: str, statusNumber: str, + published: str, newPostId: str, postContext: {}, + toRecipients: [], toCC: [], inReplyTo: str, + sensitive: bool, commentsEnabled: bool, + tags: [], attachImageFilename: str, + mediaType: str, imageDescription: str, city: str, + postObjectType: str, summary: str, + inReplyToAtomUri: str) -> {}: + """Creates a new client-to-server post + """ + idStr = \ + httpPrefix + '://' + domain + '/users/' + nickname + \ + '/statuses/' + statusNumber + '/replies' + newPostUrl = \ + httpPrefix + '://' + domain + '/@' + nickname + '/' + statusNumber + newPost = { + "@context": postContext, + 'id': newPostId, + 'type': postObjectType, + 'summary': summary, + 'inReplyTo': inReplyTo, + 'published': published, + 'url': newPostUrl, + 'attributedTo': httpPrefix + '://' + domain + '/users/' + nickname, + 'to': toRecipients, + 'cc': toCC, + 'sensitive': sensitive, + 'atomUri': newPostId, + 'inReplyToAtomUri': inReplyToAtomUri, + 'commentsEnabled': commentsEnabled, + 'rejectReplies': not commentsEnabled, + 'mediaType': 'text/html', + 'content': content, + 'contentMap': { + 'en': content + }, + 'attachment': [], + 'tag': tags, + 'replies': { + 'id': idStr, + 'type': 'Collection', + 'first': { + 'type': 'CollectionPage', + 'partOf': idStr, + 'items': [] + } + } + } + if attachImageFilename: + newPost = \ + attachMedia(baseDir, httpPrefix, nickname, domain, port, + newPost, attachImageFilename, + mediaType, imageDescription, city) + return newPost + + +def _createPostPlaceAndTime(eventDate: str, endDate: str, + eventTime: str, endTime: str, + summary: str, content: str, + schedulePost: bool, + eventUUID: str, + location: str, + tags: []) -> str: + """Adds a place and time to the tags on a new post + """ + endDateStr = None + if endDate: + eventName = summary + if not eventName: + eventName = content + endDateStr = endDate + if endTime: + if endTime.endswith('Z'): + endDateStr = endDate + 'T' + endTime + else: + endDateStr = endDate + 'T' + endTime + \ + ':00' + strftime("%z", gmtime()) + else: + endDateStr = endDate + 'T12:00:00Z' + + # get the starting date and time + eventDateStr = None + if eventDate: + eventName = summary + if not eventName: + eventName = content + eventDateStr = eventDate + if eventTime: + if eventTime.endswith('Z'): + eventDateStr = eventDate + 'T' + eventTime + else: + eventDateStr = eventDate + 'T' + eventTime + \ + ':00' + strftime("%z", gmtime()) + else: + eventDateStr = eventDate + 'T12:00:00Z' + if not endDateStr: + endDateStr = eventDateStr + if not schedulePost and not eventUUID: + tags.append({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Event", + "name": eventName, + "startTime": eventDateStr, + "endTime": endDateStr + }) + if location and not eventUUID: + tags.append({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Place", + "name": location + }) + return eventDateStr + + +def _createPostMentions(ccUrl: str, newPost: {}, + toRecipients: [], tags: []) -> None: + """Updates mentions for a new post + """ + if not ccUrl: + return + if len(ccUrl) == 0: + return + newPost['cc'] = [ccUrl] + if newPost.get('object'): + newPost['object']['cc'] = [ccUrl] + + # if this is a public post then include any mentions in cc + toCC = newPost['object']['cc'] + if len(toRecipients) != 1: + return + if toRecipients[0].endswith('#Public') and \ + ccUrl.endswith('/followers'): + for tag in tags: + if tag['type'] != 'Mention': + continue + if tag['href'] not in toCC: + newPost['object']['cc'].append(tag['href']) + + +def _createPostModReport(baseDir: str, + isModerationReport: bool, newPost: {}, + newPostId: str) -> None: + """ if this is a moderation report then add a status + """ + if not isModerationReport: + return + # add status + if newPost.get('object'): + newPost['object']['moderationStatus'] = 'pending' + else: + newPost['moderationStatus'] = 'pending' + # save to index file + moderationIndexFile = baseDir + '/accounts/moderation.txt' + with open(moderationIndexFile, 'a+') as modFile: + modFile.write(newPostId + '\n') + + def _createPostBase(baseDir: str, nickname: str, domain: str, port: int, toUrl: str, ccUrl: str, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, - mediaType: str, imageDescription: str, + mediaType: str, imageDescription: str, city: str, isModerationReport: bool, isArticle: bool, - inReplyTo=None, - inReplyToAtomUri=None, subject=None, schedulePost=False, - eventDate=None, eventTime=None, location=None, - eventUUID=None, category=None, joinMode=None, - endDate=None, endTime=None, - maximumAttendeeCapacity=None, - repliesModerationOption=None, - anonymousParticipationEnabled=None, - eventStatus=None, ticketUrl=None) -> {}: + inReplyTo: str, + inReplyToAtomUri: str, + subject: str, schedulePost: bool, + eventDate: str, eventTime: str, + location: str, + eventUUID: str, category: str, + joinMode: str, + endDate: str, endTime: str, + maximumAttendeeCapacity: int, + repliesModerationOption: str, + anonymousParticipationEnabled: bool, + eventStatus: str, ticketUrl: str) -> {}: """Creates a message """ content = removeInvalidChars(content) @@ -957,71 +1154,17 @@ def _createPostBase(baseDir: str, nickname: str, domain: str, port: int, tags.append(tag) if isPublic: _updateHashtagsIndex(baseDir, tag, newPostId) - print('Content tags: ' + str(tags)) + # print('Content tags: ' + str(tags)) - if inReplyTo and not sensitive: - # locate the post which this is a reply to and check if - # it has a content warning. If it does then reproduce - # the same warning - replyPostFilename = \ - locatePost(baseDir, nickname, domain, inReplyTo) - if replyPostFilename: - replyToJson = loadJson(replyPostFilename) - if replyToJson: - if replyToJson.get('object'): - if replyToJson['object'].get('sensitive'): - if replyToJson['object']['sensitive']: - sensitive = True - if replyToJson['object'].get('summary'): - summary = replyToJson['object']['summary'] + sensitive, summary = \ + _createPostCWFromReply(baseDir, nickname, domain, + inReplyTo, sensitive, summary) - # get the ending date and time - endDateStr = None - if endDate: - eventName = summary - if not eventName: - eventName = content - endDateStr = endDate - if endTime: - if endTime.endswith('Z'): - endDateStr = endDate + 'T' + endTime - else: - endDateStr = endDate + 'T' + endTime + \ - ':00' + strftime("%z", gmtime()) - else: - endDateStr = endDate + 'T12:00:00Z' - - # get the starting date and time - eventDateStr = None - if eventDate: - eventName = summary - if not eventName: - eventName = content - eventDateStr = eventDate - if eventTime: - if eventTime.endswith('Z'): - eventDateStr = eventDate + 'T' + eventTime - else: - eventDateStr = eventDate + 'T' + eventTime + \ - ':00' + strftime("%z", gmtime()) - else: - eventDateStr = eventDate + 'T12:00:00Z' - if not endDateStr: - endDateStr = eventDateStr - if not schedulePost and not eventUUID: - tags.append({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Event", - "name": eventName, - "startTime": eventDateStr, - "endTime": endDateStr - }) - if location and not eventUUID: - tags.append({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Place", - "name": location - }) + eventDateStr = \ + _createPostPlaceAndTime(eventDate, endDate, + eventTime, endTime, + summary, content, schedulePost, + eventUUID, location, tags) postContext = [ 'https://www.w3.org/ns/activitystreams', @@ -1054,140 +1197,31 @@ def _createPostBase(baseDir: str, nickname: str, domain: str, port: int, postObjectType = 'Article' if not clientToServer: - actorUrl = httpPrefix + '://' + domain + '/users/' + nickname - - idStr = \ - httpPrefix + '://' + domain + '/users/' + nickname + \ - '/statuses/' + statusNumber + '/replies' - newPost = { - '@context': postContext, - 'id': newPostId + '/activity', - 'type': 'Create', - 'actor': actorUrl, - 'published': published, - 'to': toRecipients, - 'cc': toCC, - 'object': { - 'id': newPostId, - 'type': postObjectType, - 'summary': summary, - 'inReplyTo': inReplyTo, - 'published': published, - 'url': httpPrefix+'://'+domain+'/@'+nickname+'/'+statusNumber, - 'attributedTo': httpPrefix+'://'+domain+'/users/'+nickname, - 'to': toRecipients, - 'cc': toCC, - 'sensitive': sensitive, - 'atomUri': newPostId, - 'inReplyToAtomUri': inReplyToAtomUri, - 'commentsEnabled': commentsEnabled, - 'mediaType': 'text/html', - 'content': content, - 'contentMap': { - 'en': content - }, - 'attachment': [], - 'tag': tags, - 'replies': { - 'id': idStr, - 'type': 'Collection', - 'first': { - 'type': 'CollectionPage', - 'partOf': idStr, - 'items': [] - } - } - } - } - if attachImageFilename: - newPost['object'] = \ - attachMedia(baseDir, httpPrefix, domain, port, - newPost['object'], attachImageFilename, - mediaType, imageDescription) - _appendEventFields(newPost['object'], eventUUID, eventStatus, - anonymousParticipationEnabled, - repliesModerationOption, - category, joinMode, - eventDateStr, endDateStr, - location, maximumAttendeeCapacity, - ticketUrl, subject) + newPost = \ + _createPostS2S(baseDir, nickname, domain, port, + httpPrefix, content, statusNumber, + published, newPostId, postContext, + toRecipients, toCC, inReplyTo, + sensitive, commentsEnabled, + tags, attachImageFilename, + mediaType, imageDescription, city, + postObjectType, summary, + inReplyToAtomUri) else: - idStr = \ - httpPrefix + '://' + domain + '/users/' + nickname + \ - '/statuses/' + statusNumber + '/replies' - newPost = { - "@context": postContext, - 'id': newPostId, - 'type': postObjectType, - 'summary': summary, - 'inReplyTo': inReplyTo, - 'published': published, - 'url': httpPrefix+'://'+domain+'/@'+nickname+'/'+statusNumber, - 'attributedTo': httpPrefix+'://'+domain+'/users/'+nickname, - 'to': toRecipients, - 'cc': toCC, - 'sensitive': sensitive, - 'atomUri': newPostId, - 'inReplyToAtomUri': inReplyToAtomUri, - 'commentsEnabled': commentsEnabled, - 'mediaType': 'text/html', - 'content': content, - 'contentMap': { - 'en': content - }, - 'attachment': [], - 'tag': tags, - 'replies': { - 'id': idStr, - 'type': 'Collection', - 'first': { - 'type': 'CollectionPage', - 'partOf': idStr, - 'items': [] - } - } - } - if attachImageFilename: - newPost = \ - attachMedia(baseDir, httpPrefix, domain, port, - newPost, attachImageFilename, - mediaType, imageDescription) - _appendEventFields(newPost, eventUUID, eventStatus, - anonymousParticipationEnabled, - repliesModerationOption, - category, joinMode, - eventDateStr, endDateStr, - location, maximumAttendeeCapacity, - ticketUrl, subject) - if ccUrl: - if len(ccUrl) > 0: - newPost['cc'] = [ccUrl] - if newPost.get('object'): - newPost['object']['cc'] = [ccUrl] + newPost = \ + _createPostC2S(baseDir, nickname, domain, port, + httpPrefix, content, statusNumber, + published, newPostId, postContext, + toRecipients, toCC, inReplyTo, + sensitive, commentsEnabled, + tags, attachImageFilename, + mediaType, imageDescription, city, + postObjectType, summary, + inReplyToAtomUri) - # if this is a public post then include any mentions in cc - toCC = newPost['object']['cc'] - if len(toRecipients) == 1: - if toRecipients[0].endswith('#Public') and \ - ccUrl.endswith('/followers'): - for tag in tags: - if tag['type'] == 'Mention': - if tag['href'] not in toCC: - toCC.append(tag['href']) + _createPostMentions(ccUrl, newPost, toRecipients, tags) - # if this is a moderation report then add a status - if isModerationReport: - # add status - if newPost.get('object'): - newPost['object']['moderationStatus'] = 'pending' - else: - newPost['moderationStatus'] = 'pending' - # save to index file - moderationIndexFile = baseDir + '/accounts/moderation.txt' - modFile = open(moderationIndexFile, "a+") - if modFile: - modFile.write(newPostId + '\n') - modFile.close() + _createPostModReport(baseDir, isModerationReport, newPost, newPostId) # If a patch has been posted - i.e. the output from # git format-patch - then convert the activitypub type @@ -1208,9 +1242,6 @@ def _createPostBase(baseDir: str, nickname: str, domain: str, port: int, if isArticle: savePostToBox(baseDir, httpPrefix, newPostId, nickname, domain, newPost, 'tlblogs') - elif eventUUID: - savePostToBox(baseDir, httpPrefix, newPostId, - nickname, domain, newPost, 'tlevents') else: savePostToBox(baseDir, httpPrefix, newPostId, nickname, domain, newPost, 'outbox') @@ -1266,7 +1297,7 @@ def _postIsAddressedToFollowers(baseDir: str, toList = [] ccList = [] if postJsonObject['type'] != 'Update' and \ - isinstance(postJsonObject['object'], dict): + hasObjectDict(postJsonObject): if postJsonObject['object'].get('to'): toList = postJsonObject['object']['to'] if postJsonObject['object'].get('cc'): @@ -1293,18 +1324,16 @@ def pinPost(baseDir: str, nickname: str, domain: str, pinnedContent: str) -> None: """Pins the given post Id to the profile of then given account """ - accountDir = baseDir + '/accounts/' + nickname + '@' + domain + accountDir = acctDir(baseDir, nickname, domain) pinnedFilename = accountDir + '/pinToProfile.txt' - pinFile = open(pinnedFilename, "w+") - if pinFile: + with open(pinnedFilename, 'w+') as pinFile: pinFile.write(pinnedContent) - pinFile.close() def undoPinnedPost(baseDir: str, nickname: str, domain: str) -> None: """Removes pinned content for then given account """ - accountDir = baseDir + '/accounts/' + nickname + '@' + domain + accountDir = acctDir(baseDir, nickname, domain) pinnedFilename = accountDir + '/pinToProfile.txt' if os.path.isfile(pinnedFilename): os.remove(pinnedFilename) @@ -1315,16 +1344,14 @@ def getPinnedPostAsJson(baseDir: str, httpPrefix: str, domainFull: str) -> {}: """Returns the pinned profile post as json """ - accountDir = baseDir + '/accounts/' + nickname + '@' + domain + accountDir = acctDir(baseDir, nickname, domain) pinnedFilename = accountDir + '/pinToProfile.txt' pinnedPostJson = {} actor = httpPrefix + '://' + domainFull + '/users/' + nickname if os.path.isfile(pinnedFilename): - pinFile = open(pinnedFilename, "r") pinnedContent = None - if pinFile: + with open(pinnedFilename, 'r') as pinFile: pinnedContent = pinFile.read() - pinFile.close() if pinnedContent: pinnedPostJson = { 'atomUri': actor + '/pinned', @@ -1391,11 +1418,13 @@ def createPublicPost(baseDir: str, content: str, followersOnly: bool, saveToFile: bool, clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, - imageDescription: str, - inReplyTo=None, inReplyToAtomUri=None, subject=None, - schedulePost=False, - eventDate=None, eventTime=None, location=None, - isArticle=False) -> {}: + imageDescription: str, city: str, + inReplyTo: str, + inReplyToAtomUri: str, subject: str, + schedulePost: bool, + eventDate: str, eventTime: str, + location: str, + isArticle: bool) -> {}: """Public post """ domainFull = getFullDomain(domain, port) @@ -1417,7 +1446,7 @@ def createPublicPost(baseDir: str, httpPrefix, content, followersOnly, saveToFile, clientToServer, commentsEnabled, attachImageFilename, mediaType, - imageDescription, + imageDescription, city, isModerationReport, isArticle, inReplyTo, inReplyToAtomUri, subject, schedulePost, eventDate, eventTime, location, @@ -1435,12 +1464,11 @@ def _appendCitationsToBlogPost(baseDir: str, """ # append citations tags, stored in a file citationsFilename = \ - baseDir + '/accounts/' + \ - nickname + '@' + domain + '/.citations.txt' + acctDir(baseDir, nickname, domain) + '/.citations.txt' if not os.path.isfile(citationsFilename): return citationsSeparator = '#####' - with open(citationsFilename, "r") as f: + with open(citationsFilename, 'r') as f: citations = f.readlines() for line in citations: if citationsSeparator not in line: @@ -1464,17 +1492,18 @@ def createBlogPost(baseDir: str, content: str, followersOnly: bool, saveToFile: bool, clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, - imageDescription: str, - inReplyTo=None, inReplyToAtomUri=None, subject=None, - schedulePost=False, - eventDate=None, eventTime=None, location=None) -> {}: + imageDescription: str, city: str, + inReplyTo: str, inReplyToAtomUri: str, + subject: str, schedulePost: bool, + eventDate: str, eventTime: str, + location: str) -> {}: blogJson = \ createPublicPost(baseDir, nickname, domain, port, httpPrefix, content, followersOnly, saveToFile, clientToServer, commentsEnabled, attachImageFilename, mediaType, - imageDescription, + imageDescription, city, inReplyTo, inReplyToAtomUri, subject, schedulePost, eventDate, eventTime, location, True) @@ -1489,7 +1518,7 @@ def createNewsPost(baseDir: str, domain: str, port: int, httpPrefix: str, content: str, followersOnly: bool, saveToFile: bool, attachImageFilename: str, mediaType: str, - imageDescription: str, + imageDescription: str, city: str, subject: str) -> {}: clientToServer = False inReplyTo = None @@ -1504,7 +1533,7 @@ def createNewsPost(baseDir: str, content, followersOnly, saveToFile, clientToServer, False, attachImageFilename, mediaType, - imageDescription, + imageDescription, city, inReplyTo, inReplyToAtomUri, subject, schedulePost, eventDate, eventTime, location, True) @@ -1518,7 +1547,7 @@ def createQuestionPost(baseDir: str, followersOnly: bool, saveToFile: bool, clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, - imageDescription: str, + imageDescription: str, city: str, subject: str, durationDays: int) -> {}: """Question post with multiple choice options """ @@ -1531,7 +1560,7 @@ def createQuestionPost(baseDir: str, httpPrefix, content, followersOnly, saveToFile, clientToServer, commentsEnabled, attachImageFilename, mediaType, - imageDescription, + imageDescription, city, False, False, None, None, subject, False, None, None, None, None, None, None, None, None, @@ -1562,10 +1591,11 @@ def createUnlistedPost(baseDir: str, content: str, followersOnly: bool, saveToFile: bool, clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, - imageDescription: str, - inReplyTo=None, inReplyToAtomUri=None, subject=None, - schedulePost=False, - eventDate=None, eventTime=None, location=None) -> {}: + imageDescription: str, city: str, + inReplyTo: str, inReplyToAtomUri: str, + subject: str, schedulePost: bool, + eventDate: str, eventTime: str, + location: str) -> {}: """Unlisted post. This has the #Public and followers links inverted. """ domainFull = getFullDomain(domain, port) @@ -1576,8 +1606,9 @@ def createUnlistedPost(baseDir: str, httpPrefix, content, followersOnly, saveToFile, clientToServer, commentsEnabled, attachImageFilename, mediaType, - imageDescription, - False, False, inReplyTo, inReplyToAtomUri, subject, + imageDescription, city, + False, False, + inReplyTo, inReplyToAtomUri, subject, schedulePost, eventDate, eventTime, location, None, None, None, None, None, None, None, None, None, None) @@ -1590,11 +1621,12 @@ def createFollowersOnlyPost(baseDir: str, saveToFile: bool, clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, - imageDescription: str, - inReplyTo=None, inReplyToAtomUri=None, - subject=None, schedulePost=False, - eventDate=None, eventTime=None, - location=None) -> {}: + imageDescription: str, city: str, + inReplyTo: str, + inReplyToAtomUri: str, + subject: str, schedulePost: bool, + eventDate: str, eventTime: str, + location: str) -> {}: """Followers only post """ domainFull = getFullDomain(domain, port) @@ -1605,63 +1637,14 @@ def createFollowersOnlyPost(baseDir: str, httpPrefix, content, followersOnly, saveToFile, clientToServer, commentsEnabled, attachImageFilename, mediaType, - imageDescription, - False, False, inReplyTo, inReplyToAtomUri, subject, + imageDescription, city, + False, False, + inReplyTo, inReplyToAtomUri, subject, schedulePost, eventDate, eventTime, location, None, None, None, None, None, None, None, None, None, None) -def createEventPost(baseDir: str, - nickname: str, domain: str, port: int, - httpPrefix: str, - content: str, followersOnly: bool, - saveToFile: bool, - clientToServer: bool, commentsEnabled: bool, - attachImageFilename: str, mediaType: str, - imageDescription: str, - subject=None, schedulePost=False, - eventDate=None, eventTime=None, - location=None, category=None, joinMode=None, - endDate=None, endTime=None, - maximumAttendeeCapacity=None, - repliesModerationOption=None, - anonymousParticipationEnabled=None, - eventStatus=None, ticketUrl=None) -> {}: - """Mobilizon-type Event post - """ - if not attachImageFilename: - print('Event has no attached image') - return None - if not category: - print('Event has no category') - return None - domainFull = getFullDomain(domain, port) - - # create event uuid - eventUUID = str(uuid.uuid1()) - - toStr1 = 'https://www.w3.org/ns/activitystreams#Public' - toStr2 = httpPrefix + '://' + domainFull + '/users/' + \ - nickname + '/followers', - if followersOnly: - toStr1 = toStr2 - toStr2 = None - return _createPostBase(baseDir, nickname, domain, port, - toStr1, toStr2, - httpPrefix, content, followersOnly, saveToFile, - clientToServer, commentsEnabled, - attachImageFilename, mediaType, - imageDescription, - False, False, None, None, subject, - schedulePost, eventDate, eventTime, location, - eventUUID, category, joinMode, - endDate, endTime, maximumAttendeeCapacity, - repliesModerationOption, - anonymousParticipationEnabled, - eventStatus, ticketUrl) - - def getMentionedPeople(baseDir: str, httpPrefix: str, content: str, domain: str, debug: bool) -> []: """Extracts a list of mentioned actors from the given message content @@ -1671,29 +1654,30 @@ def getMentionedPeople(baseDir: str, httpPrefix: str, mentions = [] words = content.split(' ') for wrd in words: - if wrd.startswith('@'): - handle = wrd[1:] - if debug: - print('DEBUG: mentioned handle ' + handle) - if '@' not in handle: - handle = handle + '@' + domain - if not os.path.isdir(baseDir + '/accounts/' + handle): - continue - else: - externalDomain = handle.split('@')[1] - if not ('.' in externalDomain or - externalDomain == 'localhost'): - continue - mentionedNickname = handle.split('@')[0] - mentionedDomain = handle.split('@')[1].strip('\n').strip('\r') - if ':' in mentionedDomain: - mentionedDomain = mentionedDomain.split(':')[0] - if not validNickname(mentionedDomain, mentionedNickname): + if not wrd.startswith('@'): + continue + handle = wrd[1:] + if debug: + print('DEBUG: mentioned handle ' + handle) + if '@' not in handle: + handle = handle + '@' + domain + if not os.path.isdir(baseDir + '/accounts/' + handle): continue - actor = \ - httpPrefix + '://' + handle.split('@')[1] + \ - '/users/' + mentionedNickname - mentions.append(actor) + else: + externalDomain = handle.split('@')[1] + if not ('.' in externalDomain or + externalDomain == 'localhost'): + continue + mentionedNickname = handle.split('@')[0] + mentionedDomain = handle.split('@')[1].strip('\n').strip('\r') + if ':' in mentionedDomain: + mentionedDomain = removeDomainPort(mentionedDomain) + if not validNickname(mentionedDomain, mentionedNickname): + continue + actor = \ + httpPrefix + '://' + handle.split('@')[1] + \ + '/users/' + mentionedNickname + mentions.append(actor) return mentions @@ -1704,12 +1688,13 @@ def createDirectMessagePost(baseDir: str, saveToFile: bool, clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, - imageDescription: str, - inReplyTo=None, inReplyToAtomUri=None, - subject=None, debug=False, - schedulePost=False, - eventDate=None, eventTime=None, - location=None) -> {}: + imageDescription: str, city: str, + inReplyTo: str, + inReplyToAtomUri: str, + subject: str, debug: bool, + schedulePost: bool, + eventDate: str, eventTime: str, + location: str) -> {}: """Direct Message post """ content = resolvePetnames(baseDir, nickname, domain, content) @@ -1727,8 +1712,9 @@ def createDirectMessagePost(baseDir: str, httpPrefix, content, followersOnly, saveToFile, clientToServer, commentsEnabled, attachImageFilename, mediaType, - imageDescription, - False, False, inReplyTo, inReplyToAtomUri, subject, + imageDescription, city, + False, False, + inReplyTo, inReplyToAtomUri, subject, schedulePost, eventDate, eventTime, location, None, None, None, None, None, None, None, None, None, None) @@ -1748,8 +1734,8 @@ def createReportPost(baseDir: str, content: str, followersOnly: bool, saveToFile: bool, clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, - imageDescription: str, - debug: bool, subject=None) -> {}: + imageDescription: str, city: str, + debug: bool, subject: str = None) -> {}: """Send a report to moderators """ domainFull = getFullDomain(domain, port) @@ -1766,7 +1752,7 @@ def createReportPost(baseDir: str, moderatorsList = [] moderatorsFile = baseDir + '/accounts/moderators.txt' if os.path.isfile(moderatorsFile): - with open(moderatorsFile, "r") as fileHandler: + with open(moderatorsFile, 'r') as fileHandler: for line in fileHandler: line = line.strip('\n').strip('\r') if line.startswith('#'): @@ -1781,7 +1767,7 @@ def createReportPost(baseDir: str, if moderatorActor not in moderatorsList: moderatorsList.append(moderatorActor) continue - if line.startswith('http') or line.startswith('dat'): + if line.startswith('http') or line.startswith('hyper'): # must be a local address - no remote moderators if '://' + domainFull + '/' in line: if line not in moderatorsList: @@ -1817,7 +1803,7 @@ def createReportPost(baseDir: str, httpPrefix, content, followersOnly, saveToFile, clientToServer, commentsEnabled, attachImageFilename, mediaType, - imageDescription, + imageDescription, city, True, False, None, None, subject, False, None, None, None, None, None, None, None, None, @@ -1825,17 +1811,6 @@ def createReportPost(baseDir: str, if not postJsonObject: continue - # update the inbox index with the report filename - # indexFilename = baseDir+'/accounts/'+handle+'/inbox.index' - # indexEntry = \ - # removeIdEnding(postJsonObject['id']).replace('/','#') + '.json' - # if indexEntry not in open(indexFilename).read(): - # try: - # with open(indexFilename, 'a+') as fp: - # fp.write(indexEntry) - # except: - # pass - # save a notification file so that the moderator # knows something new has appeared newReportFile = baseDir + '/accounts/' + handle + '/.newReport' @@ -1889,7 +1864,7 @@ def threadSendPost(session, postJsonStr: str, federationList: [], if debug: # save the log file postLogFilename = baseDir + '/post.log' - with open(postLogFilename, "a+") as logFile: + with open(postLogFilename, 'a+') as logFile: logFile.write(logStr + '\n') if postResult: @@ -1913,12 +1888,12 @@ def sendPost(projectVersion: str, saveToFile: bool, clientToServer: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, - imageDescription: str, + imageDescription: str, city: str, federationList: [], sendThreads: [], postLog: [], cachedWebfingers: {}, personCache: {}, isArticle: bool, - debug=False, inReplyTo=None, - inReplyToAtomUri=None, subject=None) -> int: + debug: bool = False, inReplyTo: str = None, + inReplyToAtomUri: str = None, subject: str = None) -> int: """Post to another inbox """ withDigest = True @@ -1934,7 +1909,7 @@ def sendPost(projectVersion: str, # lookup the inbox for the To handle wfRequest = webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion) + domain, projectVersion, debug) if not wfRequest: return 1 if not isinstance(wfRequest, dict): @@ -1972,7 +1947,7 @@ def sendPost(projectVersion: str, followersOnly, saveToFile, clientToServer, commentsEnabled, attachImageFilename, mediaType, - imageDescription, + imageDescription, city, False, isArticle, inReplyTo, inReplyToAtomUri, subject, False, None, None, None, None, None, @@ -2034,10 +2009,12 @@ def sendPostViaServer(projectVersion: str, httpPrefix: str, content: str, followersOnly: bool, commentsEnabled: bool, attachImageFilename: str, mediaType: str, - imageDescription: str, + imageDescription: str, city: str, cachedWebfingers: {}, personCache: {}, - isArticle: bool, debug=False, inReplyTo=None, - inReplyToAtomUri=None, subject=None) -> int: + isArticle: bool, debug: bool = False, + inReplyTo: str = None, + inReplyToAtomUri: str = None, + subject: str = None) -> int: """Send a post via a proxy (c2s) """ if not session: @@ -2051,14 +2028,14 @@ def sendPostViaServer(projectVersion: str, # 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: webfinger failed for ' + handle) + print('DEBUG: post 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: post webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -2076,11 +2053,12 @@ def sendPostViaServer(projectVersion: str, 82796) if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: post no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: post no actor was found for ' + handle) return 4 # Get the json for the c2s post, not saving anything to file @@ -2110,7 +2088,7 @@ def sendPostViaServer(projectVersion: str, followersOnly, saveToFile, clientToServer, commentsEnabled, attachImageFilename, mediaType, - imageDescription, + imageDescription, city, False, isArticle, inReplyTo, inReplyToAtomUri, subject, False, None, None, None, None, None, @@ -2129,7 +2107,7 @@ def sendPostViaServer(projectVersion: str, inboxUrl, headers) if not postResult: if debug: - print('DEBUG: Failed to upload image') + print('DEBUG: post failed to upload image') # return 9 headers = { @@ -2140,10 +2118,10 @@ def sendPostViaServer(projectVersion: str, postDumps = json.dumps(postJsonObject) postResult = \ postJsonString(session, postDumps, [], - inboxUrl, headers, debug) + inboxUrl, headers, debug, 5, True) if not postResult: if debug: - print('DEBUG: POST failed for c2s to '+inboxUrl) + print('DEBUG: POST failed for c2s to ' + inboxUrl) return 5 if debug: @@ -2159,16 +2137,17 @@ def groupFollowersByDomain(baseDir: str, nickname: str, domain: str) -> {}: if not os.path.isfile(followersFilename): return None grouped = {} - with open(followersFilename, "r") as f: + with open(followersFilename, 'r') as f: for followerHandle in f: - if '@' in followerHandle: - fHandle = \ - followerHandle.strip().replace('\n', '').replace('\r', '') - followerDomain = fHandle.split('@')[1] - if not grouped.get(followerDomain): - grouped[followerDomain] = [fHandle] - else: - grouped[followerDomain].append(fHandle) + if '@' not in followerHandle: + continue + fHandle = \ + followerHandle.strip().replace('\n', '').replace('\r', '') + followerDomain = fHandle.split('@')[1] + if not grouped.get(followerDomain): + grouped[followerDomain] = [fHandle] + else: + grouped[followerDomain].append(fHandle) return grouped @@ -2190,14 +2169,14 @@ def _addFollowersToPublicPost(postJsonObject: {}) -> None: if postJsonObject.get('cc'): return postJsonObject['cc'] = postJsonObject['actor'] + '/followers' - elif isinstance(postJsonObject['object'], dict): + elif hasObjectDict(postJsonObject): if not postJsonObject['object'].get('to'): return if len(postJsonObject['object']['to']) > 1: return - if len(postJsonObject['object']['to']) == 0: + elif len(postJsonObject['object']['to']) == 0: return - if not postJsonObject['object']['to'][0].endswith('#Public'): + elif not postJsonObject['object']['to'][0].endswith('#Public'): return if postJsonObject['object'].get('cc'): return @@ -2248,7 +2227,7 @@ def sendSignedJson(postJsonObject: {}, session, baseDir: str, # 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: webfinger for ' + handle + ' failed') @@ -2362,7 +2341,7 @@ def sendSignedJson(postJsonObject: {}, session, baseDir: str, def addToField(activityType: str, postJsonObject: {}, debug: bool) -> ({}, bool): - """The Follow activity doesn't have a 'to' field and so one + """The Follow/Add/Remove activity doesn't have a 'to' field and so one needs to be added so that activity distribution happens in a consistent way Returns true if a 'to' field exists or was added """ @@ -2381,19 +2360,34 @@ def addToField(activityType: str, postJsonObject: {}, if postJsonObject['type'] == activityType: isSameType = True if debug: - print('DEBUG: "to" field assigned to Follow') + print('DEBUG: "to" field assigned to ' + activityType) toAddress = postJsonObject['object'] if '/statuses/' in toAddress: toAddress = toAddress.split('/statuses/')[0] postJsonObject['to'] = [toAddress] toFieldAdded = True - elif isinstance(postJsonObject['object'], dict): - if postJsonObject['object'].get('type'): + elif hasObjectDict(postJsonObject): + # add a to field to bookmark add or remove + if postJsonObject.get('type') and \ + postJsonObject.get('actor') and \ + postJsonObject['object'].get('type'): + if postJsonObject['type'] == 'Add' or \ + postJsonObject['type'] == 'Remove': + if postJsonObject['object']['type'] == 'Document': + postJsonObject['to'] = \ + [postJsonObject['actor']] + postJsonObject['object']['to'] = \ + [postJsonObject['actor']] + toFieldAdded = True + + if not toFieldAdded and \ + postJsonObject['object'].get('type'): if postJsonObject['object']['type'] == activityType: isSameType = True if isinstance(postJsonObject['object']['object'], str): if debug: - print('DEBUG: "to" field assigned to Follow') + print('DEBUG: "to" field assigned to ' + + activityType) toAddress = postJsonObject['object']['object'] if '/statuses/' in toAddress: toAddress = toAddress.split('/statuses/')[0] @@ -2409,6 +2403,20 @@ def addToField(activityType: str, postJsonObject: {}, return postJsonObject, False +def _isProfileUpdate(postJsonObject: {}) -> bool: + """Is the given post a profile update? + for actor updates there is no 'to' within the object + """ + if postJsonObject['object'].get('type') and postJsonObject.get('type'): + if (postJsonObject['type'] == 'Update' and + (postJsonObject['object']['type'] == 'Person' or + postJsonObject['object']['type'] == 'Application' or + postJsonObject['object']['type'] == 'Group' or + postJsonObject['object']['type'] == 'Service')): + return True + return False + + def sendToNamedAddresses(session, baseDir: str, nickname: str, domain: str, onionDomain: str, i2pDomain: str, port: int, @@ -2424,18 +2432,12 @@ def sendToNamedAddresses(session, baseDir: str, return if not postJsonObject.get('object'): return - if isinstance(postJsonObject['object'], dict): - isProfileUpdate = False - # for actor updates there is no 'to' within the object - if postJsonObject['object'].get('type') and postJsonObject.get('type'): - if (postJsonObject['type'] == 'Update' and - (postJsonObject['object']['type'] == 'Person' or - postJsonObject['object']['type'] == 'Application' or - postJsonObject['object']['type'] == 'Group' or - postJsonObject['object']['type'] == 'Service')): - # use the original object, which has a 'to' - recipientsObject = postJsonObject - isProfileUpdate = True + isProfileUpdate = False + if hasObjectDict(postJsonObject): + if _isProfileUpdate(postJsonObject): + # use the original object, which has a 'to' + recipientsObject = postJsonObject + isProfileUpdate = True if not isProfileUpdate: if not postJsonObject['object'].get('to'): @@ -2508,6 +2510,16 @@ def sendToNamedAddresses(session, baseDir: str, toDomain, toPort = getDomainFromActor(address) if not toDomain: continue + # Don't send profile/actor updates to yourself + if isProfileUpdate: + domainFull = getFullDomain(domain, port) + toDomainFull = getFullDomain(toDomain, toPort) + if nickname == toNickname and \ + domainFull == toDomainFull: + if debug: + print('Not sending profile update to self. ' + + nickname + '@' + domainFull) + continue if debug: domainFull = getFullDomain(domain, port) toDomainFull = getFullDomain(toDomain, toPort) @@ -2537,7 +2549,8 @@ def sendToNamedAddresses(session, baseDir: str, personCache, debug, projectVersion) -def _hasSharedInbox(session, httpPrefix: str, domain: str) -> bool: +def _hasSharedInbox(session, httpPrefix: str, domain: str, + debug: bool) -> bool: """Returns true if the given domain has a shared inbox This tries the new and the old way of webfingering the shared inbox """ @@ -2546,9 +2559,8 @@ def _hasSharedInbox(session, httpPrefix: str, domain: str) -> bool: 'inbox@' + domain ] for handle in tryHandles: - wfRequest = webfingerHandle(session, handle, - httpPrefix, {}, - None, __version__) + wfRequest = webfingerHandle(session, handle, httpPrefix, {}, + None, __version__, debug) if wfRequest: if isinstance(wfRequest, dict): if not wfRequest.get('errors'): @@ -2561,9 +2573,7 @@ def _sendingProfileUpdate(postJsonObject: {}) -> bool: """ if postJsonObject['type'] != 'Update': 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 @@ -2633,7 +2643,8 @@ def sendToFollowers(session, baseDir: str, print('Sending post to followers domain is active: ' + followerDomainUrl) - withSharedInbox = _hasSharedInbox(session, httpPrefix, followerDomain) + withSharedInbox = _hasSharedInbox(session, httpPrefix, + followerDomain, debug) if debug: if withSharedInbox: print(followerDomain + ' has shared inbox') @@ -2645,8 +2656,8 @@ def sendToFollowers(session, baseDir: str, index = 0 toDomain = followerHandles[index].split('@')[1] if ':' in toDomain: - toPort = toDomain.split(':')[1] - toDomain = toDomain.split(':')[0] + toPort = getPortFromDomain(toDomain) + toDomain = removeDomainPort(toDomain) cc = '' @@ -2758,7 +2769,7 @@ def sendToFollowersThread(session, baseDir: str, def createInbox(recentPostsCache: {}, session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, headerOnly: bool, - pageNumber=None) -> {}: + pageNumber: int = None) -> {}: return _createBoxIndexed(recentPostsCache, session, baseDir, 'inbox', nickname, domain, port, httpPrefix, @@ -2768,27 +2779,17 @@ def createInbox(recentPostsCache: {}, def createBookmarksTimeline(session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, - headerOnly: bool, pageNumber=None) -> {}: + headerOnly: bool, pageNumber: int = None) -> {}: return _createBoxIndexed({}, session, baseDir, 'tlbookmarks', nickname, domain, port, httpPrefix, itemsPerPage, headerOnly, True, 0, False, 0, pageNumber) -def createEventsTimeline(recentPostsCache: {}, - session, baseDir: str, nickname: str, domain: str, - port: int, httpPrefix: str, itemsPerPage: int, - headerOnly: bool, pageNumber=None) -> {}: - return _createBoxIndexed(recentPostsCache, session, baseDir, 'tlevents', - nickname, domain, - port, httpPrefix, itemsPerPage, headerOnly, - True, 0, False, 0, pageNumber) - - def createDMTimeline(recentPostsCache: {}, session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, - headerOnly: bool, pageNumber=None) -> {}: + headerOnly: bool, pageNumber: int = None) -> {}: return _createBoxIndexed(recentPostsCache, session, baseDir, 'dm', nickname, domain, port, httpPrefix, itemsPerPage, @@ -2798,7 +2799,7 @@ def createDMTimeline(recentPostsCache: {}, def createRepliesTimeline(recentPostsCache: {}, session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, - headerOnly: bool, pageNumber=None) -> {}: + headerOnly: bool, pageNumber: int = None) -> {}: return _createBoxIndexed(recentPostsCache, session, baseDir, 'tlreplies', nickname, domain, port, httpPrefix, itemsPerPage, headerOnly, True, @@ -2807,7 +2808,7 @@ def createRepliesTimeline(recentPostsCache: {}, def createBlogsTimeline(session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, - headerOnly: bool, pageNumber=None) -> {}: + headerOnly: bool, pageNumber: int = None) -> {}: return _createBoxIndexed({}, session, baseDir, 'tlblogs', nickname, domain, port, httpPrefix, itemsPerPage, headerOnly, True, @@ -2816,7 +2817,7 @@ def createBlogsTimeline(session, baseDir: str, nickname: str, domain: str, def createFeaturesTimeline(session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, - headerOnly: bool, pageNumber=None) -> {}: + headerOnly: bool, pageNumber: int = None) -> {}: return _createBoxIndexed({}, session, baseDir, 'tlfeatures', nickname, domain, port, httpPrefix, itemsPerPage, headerOnly, True, @@ -2825,7 +2826,7 @@ def createFeaturesTimeline(session, baseDir: str, nickname: str, domain: str, def createMediaTimeline(session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, - headerOnly: bool, pageNumber=None) -> {}: + headerOnly: bool, pageNumber: int = None) -> {}: return _createBoxIndexed({}, session, baseDir, 'tlmedia', nickname, domain, port, httpPrefix, itemsPerPage, headerOnly, True, @@ -2836,7 +2837,7 @@ def createNewsTimeline(session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, headerOnly: bool, newswireVotesThreshold: int, positiveVoting: bool, votingTimeMins: int, - pageNumber=None) -> {}: + pageNumber: int = None) -> {}: return _createBoxIndexed({}, session, baseDir, 'outbox', 'news', domain, port, httpPrefix, itemsPerPage, headerOnly, True, @@ -2847,7 +2848,7 @@ def createNewsTimeline(session, baseDir: str, nickname: str, domain: str, def createOutbox(session, baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, headerOnly: bool, authorized: bool, - pageNumber=None) -> {}: + pageNumber: int = None) -> {}: return _createBoxIndexed({}, session, baseDir, 'outbox', nickname, domain, port, httpPrefix, itemsPerPage, headerOnly, authorized, @@ -2856,7 +2857,7 @@ def createOutbox(session, baseDir: str, nickname: str, domain: str, def createModeration(baseDir: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, headerOnly: bool, - pageNumber=None) -> {}: + pageNumber: int = None) -> {}: boxDir = createPersonDir(nickname, domain, baseDir, 'inbox') boxname = 'moderation' @@ -2866,18 +2867,18 @@ def createModeration(baseDir: str, nickname: str, domain: str, port: int, pageNumber = 1 pageStr = '?page=' + str(pageNumber) - boxUrl = httpPrefix+'://'+domain+'/users/'+nickname+'/'+boxname + boxUrl = httpPrefix + '://' + domain + '/users/' + nickname + '/' + boxname boxHeader = { '@context': 'https://www.w3.org/ns/activitystreams', - 'first': boxUrl+'?page=true', + 'first': boxUrl + '?page=true', 'id': boxUrl, - 'last': boxUrl+'?page=true', + 'last': boxUrl + '?page=true', 'totalItems': 0, 'type': 'OrderedCollection' } boxItems = { '@context': 'https://www.w3.org/ns/activitystreams', - 'id': boxUrl+pageStr, + 'id': boxUrl + pageStr, 'orderedItems': [ ], 'partOf': boxUrl, @@ -2887,7 +2888,7 @@ def createModeration(baseDir: str, nickname: str, domain: str, port: int, if isModerator(baseDir, nickname): moderationIndexFile = baseDir + '/accounts/moderation.txt' if os.path.isfile(moderationIndexFile): - with open(moderationIndexFile, "r") as f: + with open(moderationIndexFile, 'r') as f: lines = f.readlines() boxHeader['totalItems'] = len(lines) if headerOnly: @@ -2920,39 +2921,12 @@ def createModeration(baseDir: str, nickname: str, domain: str, port: int, return boxItems -def isDM(postJsonObject: {}) -> bool: - """Returns true if the given post is a DM - """ - if postJsonObject['type'] != 'Create': - return False - if not postJsonObject.get('object'): - return False - if not isinstance(postJsonObject['object'], dict): - 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 isImageMedia(session, baseDir: str, httpPrefix: str, nickname: str, domain: str, postJsonObject: {}, translate: {}, YTReplacementDomain: str, - allowLocalNetworkAccess: bool) -> bool: + allowLocalNetworkAccess: bool, + recentPostsCache: {}, debug: bool) -> bool: """Returns true if the given post has attached image media """ if postJsonObject['type'] == 'Announce': @@ -2961,14 +2935,13 @@ def isImageMedia(session, baseDir: str, httpPrefix: str, nickname, domain, postJsonObject, __version__, translate, YTReplacementDomain, - allowLocalNetworkAccess) + allowLocalNetworkAccess, + recentPostsCache, debug) if postJsonAnnounce: postJsonObject = postJsonAnnounce 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 postJsonObject['object'].get('moderationStatus'): return False @@ -2989,40 +2962,6 @@ def isImageMedia(session, baseDir: str, httpPrefix: str, return False -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 postJsonObject.get('object'): - return False - if not isinstance(postJsonObject['object'], dict): - 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 _addPostStringToTimeline(postStr: str, boxname: str, postsInBox: [], boxActor: str) -> bool: """ is this a valid timeline post? @@ -3052,7 +2991,10 @@ def _addPostStringToTimeline(postStr: str, boxname: str, return False elif boxname == 'tlmedia': if '"Create"' in postStr: - if 'mediaType' not in postStr or 'image/' not in postStr: + if ('mediaType' not in postStr or + ('image/' not in postStr and + 'video/' not in postStr and + 'audio/' not in postStr)): return False # add the post to the dictionary postsInBox.append(postStr) @@ -3077,12 +3019,97 @@ def _addPostToTimeline(filePath: str, boxname: str, return False +def removePostInteractions(postJsonObject: {}, force: bool) -> bool: + """ Don't show likes, replies, bookmarks, DMs or shares (announces) to + unauthorized viewers. This makes the timeline less useful to + marketers and other surveillance-oriented organizations. + Returns False if this is a private post + """ + hasObject = False + if hasObjectDict(postJsonObject): + hasObject = True + if hasObject: + postObj = postJsonObject['object'] + if not force: + # If not authorized and it's a private post + # then just don't show it within timelines + if not isPublicPost(postObj): + return False + else: + postObj = postJsonObject + + # clear the likes + if postObj.get('likes'): + postObj['likes'] = { + 'items': [] + } + # remove other collections + removeCollections = ( + 'replies', 'shares', 'bookmarks', 'ignores' + ) + for removeName in removeCollections: + if postObj.get(removeName): + postObj[removeName] = {} + return True + + +def _passedNewswireVoting(newswireVotesThreshold: int, + baseDir: str, domain: str, + postFilename: str, + positiveVoting: bool, + votingTimeMins: int) -> bool: + """Returns true if the post has passed through newswire voting + """ + # apply votes within this timeline + if newswireVotesThreshold <= 0: + return True + # note that the presence of an arrival file also indicates + # that this post is moderated + arrivalDate = \ + locateNewsArrival(baseDir, domain, postFilename) + if not arrivalDate: + return True + # how long has elapsed since this post arrived? + currDate = datetime.datetime.utcnow() + timeDiffMins = \ + int((currDate - arrivalDate).total_seconds() / 60) + # has the voting time elapsed? + if timeDiffMins < votingTimeMins: + # voting is still happening, so don't add this + # post to the timeline + return False + # if there a votes file for this post? + votesFilename = \ + locateNewsVotes(baseDir, domain, postFilename) + if not votesFilename: + return True + # load the votes file and count the votes + votesJson = loadJson(votesFilename, 0, 2) + if not votesJson: + return True + if not positiveVoting: + if votesOnNewswireItem(votesJson) >= \ + newswireVotesThreshold: + # Too many veto votes. + # Continue without incrementing + # the posts counter + return False + else: + if votesOnNewswireItem < \ + newswireVotesThreshold: + # Not enough votes. + # Continue without incrementing + # the posts counter + return False + return True + + def _createBoxIndexed(recentPostsCache: {}, session, baseDir: str, boxname: str, nickname: str, domain: str, port: int, httpPrefix: str, itemsPerPage: int, headerOnly: bool, authorized: bool, newswireVotesThreshold: int, positiveVoting: bool, - votingTimeMins: int, pageNumber=None) -> {}: + votingTimeMins: int, pageNumber: int = None) -> {}: """Constructs the box feed for a person with the given nickname """ if not authorized or not pageNumber: @@ -3093,8 +3120,7 @@ def _createBoxIndexed(recentPostsCache: {}, boxname != 'tlblogs' and boxname != 'tlnews' and \ boxname != 'tlfeatures' and \ boxname != 'outbox' and boxname != 'tlbookmarks' and \ - boxname != 'bookmarks' and \ - boxname != 'tlevents': + boxname != 'bookmarks': return None # bookmarks and events timelines are like the inbox @@ -3144,57 +3170,28 @@ def _createBoxIndexed(recentPostsCache: {}, indexFilename = \ baseDir + '/accounts/' + timelineNickname + '@' + domain + \ '/' + indexBoxName + '.index' - postsCtr = 0 + totalPostsCount = 0 + postsAddedToTimeline = 0 if os.path.isfile(indexFilename): - maxPostCtr = itemsPerPage * pageNumber with open(indexFilename, 'r') as indexFile: - while postsCtr < maxPostCtr: + postsAddedToTimeline = 0 + while postsAddedToTimeline < itemsPerPage: postFilename = indexFile.readline() if not postFilename: break - # apply votes within this timeline - if newswireVotesThreshold > 0: - # note that the presence of an arrival file also indicates - # that this post is moderated - arrivalDate = \ - locateNewsArrival(baseDir, domain, postFilename) - if arrivalDate: - # how long has elapsed since this post arrived? - currDate = datetime.datetime.utcnow() - timeDiffMins = \ - int((currDate - arrivalDate).total_seconds() / 60) - # has the voting time elapsed? - if timeDiffMins < votingTimeMins: - # voting is still happening, so don't add this - # post to the timeline - continue - # if there a votes file for this post? - votesFilename = \ - locateNewsVotes(baseDir, domain, postFilename) - if votesFilename: - # load the votes file and count the votes - votesJson = loadJson(votesFilename, 0, 2) - if votesJson: - if not positiveVoting: - if votesOnNewswireItem(votesJson) >= \ - newswireVotesThreshold: - # Too many veto votes. - # Continue without incrementing - # the posts counter - continue - else: - if votesOnNewswireItem < \ - newswireVotesThreshold: - # Not enough votes. - # Continue without incrementing - # the posts counter - continue + # Has this post passed through the newswire voting stage? + if not _passedNewswireVoting(newswireVotesThreshold, + baseDir, domain, + postFilename, + positiveVoting, + votingTimeMins): + continue # Skip through any posts previous to the current page - if postsCtr < int((pageNumber - 1) * itemsPerPage): - postsCtr += 1 + if totalPostsCount < int((pageNumber - 1) * itemsPerPage): + totalPostsCount += 1 continue # if this is a full path then remove the directories @@ -3213,19 +3210,30 @@ def _createBoxIndexed(recentPostsCache: {}, if postUrl in recentPostsCache['index']: if recentPostsCache['json'].get(postUrl): url = recentPostsCache['json'][postUrl] - _addPostStringToTimeline(url, - boxname, postsInBox, - boxActor) - postsCtr += 1 - continue + if _addPostStringToTimeline(url, + boxname, postsInBox, + boxActor): + totalPostsCount += 1 + postsAddedToTimeline += 1 + continue # read the post from file fullPostFilename = \ locatePost(baseDir, nickname, domain, postUrl, False) if fullPostFilename: - _addPostToTimeline(fullPostFilename, boxname, - postsInBox, boxActor) + # has the post been rejected? + if os.path.isfile(fullPostFilename + '.reject'): + continue + + if _addPostToTimeline(fullPostFilename, boxname, + postsInBox, boxActor): + postsAddedToTimeline += 1 + totalPostsCount += 1 + else: + print('WARN: Unable to add post ' + postUrl + + ' nickname ' + nickname + + ' timeline ' + boxname) else: if timelineNickname != nickname: # if this is the features timeline @@ -3233,8 +3241,14 @@ def _createBoxIndexed(recentPostsCache: {}, locatePost(baseDir, timelineNickname, domain, postUrl, False) if fullPostFilename: - _addPostToTimeline(fullPostFilename, boxname, - postsInBox, boxActor) + if _addPostToTimeline(fullPostFilename, boxname, + postsInBox, boxActor): + postsAddedToTimeline += 1 + totalPostsCount += 1 + else: + print('WARN: Unable to add features post ' + + postUrl + ' nickname ' + nickname + + ' timeline ' + boxname) else: print('WARN: features timeline. ' + 'Unable to locate post ' + postUrl) @@ -3242,11 +3256,13 @@ def _createBoxIndexed(recentPostsCache: {}, print('WARN: Unable to locate post ' + postUrl + ' nickname ' + nickname) - postsCtr += 1 + if totalPostsCount < 3: + print('Posts added to json timeline ' + boxname + ': ' + + str(postsAddedToTimeline)) # Generate first and last entries within header - if postsCtr > 0: - lastPage = int(postsCtr / itemsPerPage) + if totalPostsCount > 0: + lastPage = int(totalPostsCount / itemsPerPage) if lastPage < 1: lastPage = 1 boxHeader['last'] = \ @@ -3287,21 +3303,9 @@ def _createBoxIndexed(recentPostsCache: {}, # created by individualPostAsHtml p['hasReplies'] = hasReplies - # Don't show likes, replies, DMs or shares (announces) to - # unauthorized viewers if not authorized: - if p.get('object'): - if isinstance(p['object'], dict): - if not isPublicPost(p): - continue - if p['object'].get('likes'): - p['likes'] = {'items': []} - if p['object'].get('replies'): - p['replies'] = {} - if p['object'].get('shares'): - p['shares'] = {} - if p['object'].get('bookmarks'): - p['bookmarks'] = {} + if not removePostInteractions(p, False): + continue boxItems['orderedItems'].append(p) @@ -3317,7 +3321,7 @@ def expireCache(baseDir: str, personCache: {}, while True: # once per day time.sleep(60 * 60 * 24) - expirePersonCache(baseDir, personCache) + expirePersonCache(personCache) archivePosts(baseDir, httpPrefix, archiveDir, recentPostsCache, maxPostsInBox) @@ -3407,10 +3411,8 @@ def archivePostsForPerson(httpPrefix: str, nickname: str, domain: str, break # save the new index file if len(newIndex) > 0: - indexFile = open(indexFilename, 'w+') - if indexFile: + with open(indexFilename, 'w+') as indexFile: indexFile.write(newIndex) - indexFile.close() postsInBoxDict = {} postsCtr = 0 @@ -3507,7 +3509,7 @@ def getPublicPostsOfPerson(baseDir: str, nickname: str, domain: str, handle = httpPrefix + "://" + domainFull + "/@" + nickname wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion) + domain, projectVersion, debug) if not wfRequest: sys.exit() if not isinstance(wfRequest, dict): @@ -3549,7 +3551,7 @@ def getPublicPostDomains(session, baseDir: str, nickname: str, domain: str, handle = httpPrefix + "://" + domainFull + "/@" + nickname wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion) + domain, projectVersion, debug) if not wfRequest: return domainList if not isinstance(wfRequest, dict): @@ -3578,9 +3580,9 @@ def getPublicPostDomains(session, baseDir: str, nickname: str, domain: str, def downloadFollowCollection(followType: str, - session, httpPrefix, - actor: str, pageNumber=1, - noOfPages=1) -> []: + session, httpPrefix: str, + actor: str, pageNumber: int = 1, + noOfPages: int = 1, debug: bool = False) -> []: """Returns a list of following/followers for the given actor by downloading the json for their following/followers collection """ @@ -3597,8 +3599,8 @@ def downloadFollowCollection(followType: str, for pageCtr in range(noOfPages): url = actor + '/' + followType + '?page=' + str(pageNumber + pageCtr) followersJson = \ - getJson(session, url, sessionHeaders, None, __version__, - httpPrefix, None) + getJson(session, url, sessionHeaders, None, + debug, __version__, httpPrefix, None) if followersJson: if followersJson.get('orderedItems'): for followerActor in followersJson['orderedItems']: @@ -3629,7 +3631,7 @@ def getPublicPostInfo(session, baseDir: str, nickname: str, domain: str, handle = httpPrefix + "://" + domainFull + "/@" + nickname wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion) + domain, projectVersion, debug) if not wfRequest: return {} if not isinstance(wfRequest, dict): @@ -3812,10 +3814,8 @@ def populateRepliesJson(baseDir: str, nickname: str, domain: str, for boxname in repliesBoxes: messageId2 = messageId.replace('\n', '').replace('\r', '') searchFilename = \ - baseDir + \ - '/accounts/' + nickname + '@' + \ - domain+'/' + \ - boxname+'/' + \ + acctDir(baseDir, nickname, domain) + '/' + \ + boxname + '/' + \ messageId2.replace('/', '#') + '.json' if os.path.isfile(searchFilename): if authorized or \ @@ -3842,7 +3842,7 @@ def populateRepliesJson(baseDir: str, nickname: str, domain: str, searchFilename = \ baseDir + \ '/accounts/inbox@' + \ - domain+'/inbox/' + \ + domain + '/inbox/' + \ messageId2.replace('/', '#') + '.json' if os.path.isfile(searchFilename): if authorized or \ @@ -3865,32 +3865,43 @@ def populateRepliesJson(baseDir: str, nickname: str, domain: str, repliesJson['orderedItems'].append(pjo) -def _rejectAnnounce(announceFilename: str): +def _rejectAnnounce(announceFilename: str, + baseDir: str, nickname: str, domain: str, + announcePostId: str, recentPostsCache: {}): """Marks an announce as rejected """ + rejectPostId(baseDir, nickname, domain, announcePostId, recentPostsCache) + + # reject the post referenced by the announce activity object if not os.path.isfile(announceFilename + '.reject'): - rejectAnnounceFile = open(announceFilename + '.reject', "w+") - if rejectAnnounceFile: + with open(announceFilename + '.reject', 'w+') as rejectAnnounceFile: rejectAnnounceFile.write('\n') - rejectAnnounceFile.close() def downloadAnnounce(session, baseDir: str, httpPrefix: str, nickname: str, domain: str, postJsonObject: {}, projectVersion: str, translate: {}, YTReplacementDomain: str, - allowLocalNetworkAccess: bool) -> {}: + allowLocalNetworkAccess: bool, + recentPostsCache: {}, debug: bool) -> {}: """Download the post referenced by an announce """ if not postJsonObject.get('object'): return None if not isinstance(postJsonObject['object'], str): return None + # ignore self-boosts + if postJsonObject['actor'] in postJsonObject['object']: + return None # get the announced post announceCacheDir = baseDir + '/cache/announce/' + nickname if not os.path.isdir(announceCacheDir): os.mkdir(announceCacheDir) + + postId = None + if postJsonObject.get('id'): + postId = postJsonObject['id'] announceFilename = \ announceCacheDir + '/' + \ postJsonObject['object'].replace('/', '#') + '.json' @@ -3899,8 +3910,9 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, return None if os.path.isfile(announceFilename): - print('Reading cached Announce content for ' + - postJsonObject['object']) + if debug: + print('Reading cached Announce content for ' + + postJsonObject['object']) postJsonObject = loadJson(announceFilename) if postJsonObject: return postJsonObject @@ -3940,10 +3952,12 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, print('Announce download blocked object: ' + str(postJsonObject['object'])) return None - print('Downloading Announce content for ' + postJsonObject['object']) + if debug: + print('Downloading Announce content for ' + + postJsonObject['object']) announcedJson = \ getJson(session, postJsonObject['object'], asHeader, - None, projectVersion, httpPrefix, domain) + None, debug, projectVersion, httpPrefix, domain) if not announcedJson: return None @@ -3951,48 +3965,73 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, if not isinstance(announcedJson, dict): print('WARN: announce json is not a dict - ' + postJsonObject['object']) - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None if not announcedJson.get('id'): - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None if '/statuses/' not in announcedJson['id']: - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None if not hasUsersPath(announcedJson['id']): - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None if not announcedJson.get('type'): - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None if announcedJson['type'] != 'Note' and \ announcedJson['type'] != 'Article': # You can only announce Note or Article types - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None if not announcedJson.get('content'): - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None if not announcedJson.get('published'): - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None - if not validPostDate(announcedJson['published']): - _rejectAnnounce(announceFilename) + if not validPostDate(announcedJson['published'], 90, debug): + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None # Check the content of the announce contentStr = announcedJson['content'] if dangerousMarkup(contentStr, allowLocalNetworkAccess): - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None if isFiltered(baseDir, nickname, domain, contentStr): - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None # remove any long words contentStr = removeLongWords(contentStr, 40, []) + # Prevent the same word from being repeated many times + contentStr = limitRepeatedWords(contentStr, 6) + # remove text formatting, such as bold/italics contentStr = removeTextFormatting(contentStr) @@ -4006,7 +4045,9 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, announcedJson) if announcedJson['type'] != 'Create': # Create wrap failed - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None # labelAccusatoryPost(postJsonObject, translate) @@ -4022,7 +4063,9 @@ def downloadAnnounce(session, baseDir: str, httpPrefix: str, attributedDomain = getFullDomain(attributedDomain, attributedPort) if isBlocked(baseDir, nickname, domain, attributedNickname, attributedDomain): - _rejectAnnounce(announceFilename) + _rejectAnnounce(announceFilename, + baseDir, nickname, domain, postId, + recentPostsCache) return None postJsonObject = announcedJson replaceYouTube(postJsonObject, YTReplacementDomain) @@ -4042,87 +4085,6 @@ def isMuted(baseDir: str, nickname: str, domain: str, postId: str) -> bool: return False -def mutePost(baseDir: str, nickname: str, domain: str, postId: str, - recentPostsCache: {}) -> None: - """ Mutes the given post - """ - postFilename = locatePost(baseDir, nickname, domain, postId) - if not postFilename: - return - postJsonObject = loadJson(postFilename) - if not postJsonObject: - return - - # 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) - - muteFile = open(postFilename + '.muted', 'w+') - if muteFile: - muteFile.write('\n') - muteFile.close() - 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, postId: str, - recentPostsCache: {}) -> 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') - - # 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 sendBlockViaServer(baseDir: str, session, fromNickname: str, password: str, fromDomain: str, fromPort: int, @@ -4156,14 +4118,14 @@ def sendBlockViaServer(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) + print('DEBUG: block 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: block Webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -4178,11 +4140,11 @@ def sendBlockViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: block no ' + postToBox + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: block no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -4192,9 +4154,11 @@ def sendBlockViaServer(baseDir: str, session, 'Content-type': 'application/json', 'Authorization': authHeader } - postResult = postJson(session, newBlockJson, [], inboxUrl, headers) + postResult = postJson(httpPrefix, fromDomainFull, + session, newBlockJson, [], inboxUrl, + headers, 30, True) if not postResult: - print('WARN: Unable to post block') + print('WARN: block unable to post') if debug: print('DEBUG: c2s POST block success') @@ -4202,6 +4166,164 @@ def sendBlockViaServer(baseDir: str, session, return newBlockJson +def sendMuteViaServer(baseDir: str, session, + fromNickname: str, password: str, + fromDomain: str, fromPort: int, + httpPrefix: str, mutedUrl: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> {}: + """Creates a mute via c2s + """ + if not session: + print('WARN: No session for sendMuteViaServer') + return 6 + + fromDomainFull = getFullDomain(fromDomain, fromPort) + + actor = httpPrefix + '://' + fromDomainFull + '/users/' + fromNickname + handle = actor.replace('/users/', '/@') + + newMuteJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'Ignore', + 'actor': actor, + 'to': [actor], + 'object': mutedUrl + } + + # lookup the inbox for the To handle + wfRequest = webfingerHandle(session, handle, httpPrefix, + cachedWebfingers, + fromDomain, projectVersion, debug) + if not wfRequest: + if debug: + print('DEBUG: mute webfinger failed for ' + handle) + return 1 + if not isinstance(wfRequest, dict): + print('WARN: mute 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, fromNickname, + fromDomain, postToBox, 72652) + + if not inboxUrl: + if debug: + print('DEBUG: mute no ' + postToBox + ' was found for ' + handle) + return 3 + if not fromPersonId: + if debug: + print('DEBUG: mute no actor was found for ' + handle) + return 4 + + authHeader = createBasicAuthHeader(fromNickname, password) + + headers = { + 'host': fromDomain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + postResult = postJson(httpPrefix, fromDomainFull, + session, newMuteJson, [], inboxUrl, + headers, 3, True) + if postResult is None: + print('WARN: mute unable to post') + + if debug: + print('DEBUG: c2s POST mute success') + + return newMuteJson + + +def sendUndoMuteViaServer(baseDir: str, session, + fromNickname: str, password: str, + fromDomain: str, fromPort: int, + httpPrefix: str, mutedUrl: str, + cachedWebfingers: {}, personCache: {}, + debug: bool, projectVersion: str) -> {}: + """Undoes a mute via c2s + """ + if not session: + print('WARN: No session for sendUndoMuteViaServer') + return 6 + + fromDomainFull = getFullDomain(fromDomain, fromPort) + + actor = httpPrefix + '://' + fromDomainFull + '/users/' + fromNickname + handle = actor.replace('/users/', '/@') + + undoMuteJson = { + "@context": "https://www.w3.org/ns/activitystreams", + 'type': 'Undo', + 'actor': actor, + 'to': [actor], + 'object': { + 'type': 'Ignore', + 'actor': actor, + 'to': [actor], + 'object': mutedUrl + } + } + + # lookup the inbox for the To handle + wfRequest = webfingerHandle(session, handle, httpPrefix, + cachedWebfingers, + fromDomain, projectVersion, debug) + if not wfRequest: + if debug: + print('DEBUG: undo mute webfinger failed for ' + handle) + return 1 + if not isinstance(wfRequest, dict): + print('WARN: undo mute 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, fromNickname, + fromDomain, postToBox, 72652) + + if not inboxUrl: + if debug: + print('DEBUG: undo mute no ' + postToBox + + ' was found for ' + handle) + return 3 + if not fromPersonId: + if debug: + print('DEBUG: undo mute no actor was found for ' + handle) + return 4 + + authHeader = createBasicAuthHeader(fromNickname, password) + + headers = { + 'host': fromDomain, + 'Content-type': 'application/json', + 'Authorization': authHeader + } + postResult = postJson(httpPrefix, fromDomainFull, + session, undoMuteJson, [], inboxUrl, + headers, 3, True) + if postResult is None: + print('WARN: undo mute unable to post') + + if debug: + print('DEBUG: c2s POST undo mute success') + + return undoMuteJson + + def sendUndoBlockViaServer(baseDir: str, session, fromNickname: str, password: str, fromDomain: str, fromPort: int, @@ -4239,14 +4361,14 @@ def sendUndoBlockViaServer(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) + print('DEBUG: unblock 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: unblock webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -4260,11 +4382,12 @@ def sendUndoBlockViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: unblock no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: unblock no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -4274,12 +4397,14 @@ def sendUndoBlockViaServer(baseDir: str, session, 'Content-type': 'application/json', 'Authorization': authHeader } - postResult = postJson(session, newBlockJson, [], inboxUrl, headers) + postResult = postJson(httpPrefix, fromDomainFull, + session, newBlockJson, [], inboxUrl, + headers, 30, True) if not postResult: - print('WARN: Unable to post block') + print('WARN: unblock unable to post') if debug: - print('DEBUG: c2s POST block success') + print('DEBUG: c2s POST unblock success') return newBlockJson @@ -4291,7 +4416,7 @@ def postIsMuted(baseDir: str, nickname: str, domain: str, isMuted = postJsonObject.get('muted') if isMuted is True or isMuted is False: return isMuted - postDir = baseDir + '/accounts/' + nickname + '@' + domain + postDir = acctDir(baseDir, nickname, domain) muteFilename = \ postDir + '/inbox/' + messageId.replace('/', '#') + '.json.muted' if os.path.isfile(muteFilename): @@ -4306,3 +4431,39 @@ def postIsMuted(baseDir: str, nickname: str, domain: str, if os.path.isfile(muteFilename): return True return False + + +def c2sBoxJson(baseDir: str, session, + nickname: str, password: str, + domain: str, port: int, + httpPrefix: str, + boxName: str, pageNumber: int, + debug: bool) -> {}: + """C2S Authenticated GET of posts for a timeline + """ + if not session: + print('WARN: No session for c2sBoxJson') + return None + + domainFull = getFullDomain(domain, port) + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + + authHeader = createBasicAuthHeader(nickname, password) + + profileStr = 'https://www.w3.org/ns/activitystreams' + headers = { + 'host': domain, + 'Content-type': 'application/json', + 'Authorization': authHeader, + 'Accept': 'application/ld+json; profile="' + profileStr + '"' + } + + # GET json + url = actor + '/' + boxName + '?page=' + str(pageNumber) + boxJson = getJson(session, url, headers, None, + debug, __version__, httpPrefix, None) + + if boxJson is not None and debug: + print('DEBUG: GET c2sBoxJson success') + + return boxJson diff --git a/pyjsonld.py b/pyjsonld.py index 36b44d0bb..fec0a1e34 100644 --- a/pyjsonld.py +++ b/pyjsonld.py @@ -15,6 +15,7 @@ JSON-LD. __copyright__ = 'Copyright (c) 2011-2014 Digital Bazaar, Inc.' __license__ = 'New BSD license' __version__ = '0.6.8' +__module_group__ = "Security" __all__ = [ 'compact', 'expand', 'flatten', 'frame', 'link', 'from_rdf', 'to_rdf', @@ -370,7 +371,7 @@ def load_document(url): # validate URL pieces = urllib_parse.urlparse(url) if (not all([pieces.scheme, pieces.netloc]) or - pieces.scheme not in ['http', 'https', 'dat'] or + pieces.scheme not in ['http', 'https', 'hyper'] or set(pieces.netloc) > set( string.ascii_letters + string.digits + '-.:')): raise JsonLdError( @@ -2378,8 +2379,13 @@ class JsonLdProcessor(object): # hash bnode paths path_namer = UniqueNamer('_:b') path_namer.get_name(bnode) - results.append(self._hash_paths( - bnode, bnodes, namer, path_namer)) + try: + bnode_path = self._hash_paths( + bnode, bnodes, namer, path_namer) + results.append(bnode_path) + except BaseException: + print('WARN: jsonld bnode_path failed') + pass # name bnodes in hash order cmp_hashes = cmp_to_key(lambda x, y: cmp(x['hash'], y['hash'])) @@ -4292,7 +4298,8 @@ class JsonLdProcessor(object): elif v not in urls: urls[v] = False - def _retrieve_context_urls(self, input_, cycles, load_document, base=''): + def _retrieve_context_urls(self, input_, cycles, load_document, + base: str = ''): """ Retrieves external @context URLs using the given document loader. Each instance of @context in the input that refers to a URL will be @@ -4854,7 +4861,7 @@ class ActiveContextCache(object): the overhead of recomputing them. """ - def __init__(self, size=100): + def __init__(self, size: int = 100): self.order = deque() self.cache = {} self.size = size diff --git a/question.py b/question.py index 4655e9bc8..9157aeea6 100644 --- a/question.py +++ b/question.py @@ -5,11 +5,13 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "ActivityPub" import os from utils import locatePost from utils import loadJson from utils import saveJson +from utils import hasObjectDict def questionUpdateVotes(baseDir: str, nickname: str, domain: str, @@ -17,9 +19,7 @@ def questionUpdateVotes(baseDir: str, nickname: str, domain: str, """ For a given reply update the votes on a question Returns the question json object if the vote totals were changed """ - if not replyJson.get('object'): - return None - if not isinstance(replyJson['object'], dict): + if not hasObjectDict(replyJson): return None if not replyJson['object'].get('inReplyTo'): return None @@ -36,9 +36,7 @@ def questionUpdateVotes(baseDir: str, nickname: str, domain: str, questionJson = loadJson(questionPostFilename) if not questionJson: return None - if not questionJson.get('object'): - return None - if not isinstance(questionJson['object'], dict): + if not hasObjectDict(questionJson): return None if not questionJson['object'].get('type'): return None @@ -66,24 +64,20 @@ def questionUpdateVotes(baseDir: str, nickname: str, domain: str, votersFilename = questionPostFilename.replace('.json', '.voters') if not os.path.isfile(votersFilename): # create a new voters file - votersFile = open(votersFilename, 'w+') - if votersFile: + with open(votersFilename, 'w+') as votersFile: votersFile.write(replyJson['actor'] + votersFileSeparator + foundAnswer + '\n') - votersFile.close() else: if replyJson['actor'] not in open(votersFilename).read(): # append to the voters file - votersFile = open(votersFilename, "a+") - if votersFile: + with open(votersFilename, 'a+') as votersFile: votersFile.write(replyJson['actor'] + votersFileSeparator + foundAnswer + '\n') - votersFile.close() else: # change an entry in the voters file - with open(votersFilename, "r") as votersFile: + with open(votersFilename, 'r') as votersFile: lines = votersFile.readlines() newlines = [] saveVotersFile = False @@ -110,7 +104,7 @@ def questionUpdateVotes(baseDir: str, nickname: str, domain: str, if not possibleAnswer.get('name'): continue totalItems = 0 - with open(votersFilename, "r") as votersFile: + with open(votersFilename, 'r') as votersFile: lines = votersFile.readlines() for voteLine in lines: if voteLine.endswith(votersFileSeparator + @@ -132,7 +126,7 @@ def isQuestion(postObjectJson: {}) -> bool: if postObjectJson['type'] != 'Create' and \ postObjectJson['type'] != 'Update': return False - if not isinstance(postObjectJson['object'], dict): + if not hasObjectDict(postObjectJson): return False if not postObjectJson['object'].get('type'): return False diff --git a/roles.py b/roles.py index d0f05602d..1dccf2073 100644 --- a/roles.py +++ b/roles.py @@ -5,43 +5,20 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Profile Metadata" import os -from webfinger import webfingerHandle -from auth import createBasicAuthHeader -from posts import getPersonBox -from session import postJson -from utils import getFullDomain -from utils import getNicknameFromActor -from utils import getDomainFromActor from utils import loadJson from utils import saveJson +from utils import getStatusNumber +from utils import removeDomainPort +from utils import acctDir -def clearModeratorStatus(baseDir: str) -> None: - """Removes moderator status from all accounts +def _clearRoleStatus(baseDir: str, role: str) -> None: + """Removes role status from all accounts This could be slow if there are many users, but only happens - rarely when moderators are appointed or removed - """ - directory = os.fsencode(baseDir + '/accounts/') - for f in os.scandir(directory): - f = f.name - filename = os.fsdecode(f) - if filename.endswith(".json") and '@' in filename: - filename = os.path.join(baseDir + '/accounts/', filename) - if '"moderator"' in open(filename).read(): - actorJson = loadJson(filename) - if actorJson: - if actorJson['roles'].get('instance'): - if 'moderator' in actorJson['roles']['instance']: - actorJson['roles']['instance'].remove('moderator') - saveJson(actorJson, filename) - - -def clearEditorStatus(baseDir: str) -> None: - """Removes editor status from all accounts - This could be slow if there are many users, but only happens - rarely when editors are appointed or removed + rarely when roles are appointed or removed """ directory = os.fsencode(baseDir + '/accounts/') for f in os.scandir(directory): @@ -52,297 +29,249 @@ def clearEditorStatus(baseDir: str) -> None: if not filename.endswith(".json"): continue filename = os.path.join(baseDir + '/accounts/', filename) - if '"editor"' not in open(filename).read(): + if '"' + role + '"' not in open(filename).read(): continue actorJson = loadJson(filename) if not actorJson: continue - if actorJson['roles'].get('instance'): - if 'editor' in actorJson['roles']['instance']: - actorJson['roles']['instance'].remove('editor') - saveJson(actorJson, filename) + rolesList = getActorRolesList(actorJson) + if role in rolesList: + rolesList.remove(role) + setRolesFromList(actorJson, rolesList) + saveJson(actorJson, filename) -def _addModerator(baseDir: str, nickname: str, domain: str) -> None: - """Adds a moderator nickname to the file +def clearEditorStatus(baseDir: str) -> None: + """Removes editor status from all accounts + This could be slow if there are many users, but only happens + rarely when editors are appointed or removed """ - if ':' in domain: - domain = domain.split(':')[0] - moderatorsFile = baseDir + '/accounts/moderators.txt' - if os.path.isfile(moderatorsFile): + _clearRoleStatus(baseDir, 'editor') + + +def clearCounselorStatus(baseDir: str) -> None: + """Removes counselor status from all accounts + This could be slow if there are many users, but only happens + rarely when counselors are appointed or removed + """ + _clearRoleStatus(baseDir, 'editor') + + +def clearArtistStatus(baseDir: str) -> None: + """Removes artist status from all accounts + This could be slow if there are many users, but only happens + rarely when artists are appointed or removed + """ + _clearRoleStatus(baseDir, 'artist') + + +def clearModeratorStatus(baseDir: str) -> None: + """Removes moderator status from all accounts + This could be slow if there are many users, but only happens + rarely when moderators are appointed or removed + """ + _clearRoleStatus(baseDir, 'moderator') + + +def _addRole(baseDir: str, nickname: str, domain: str, + roleFilename: str) -> None: + """Adds a role nickname to the file. + This is a file containing the nicknames of accounts having this role + """ + domain = removeDomainPort(domain) + roleFile = baseDir + '/accounts/' + roleFilename + if os.path.isfile(roleFile): # is this nickname already in the file? - with open(moderatorsFile, "r") as f: + with open(roleFile, 'r') as f: lines = f.readlines() - for moderator in lines: - moderator = moderator.strip('\n').strip('\r') - if moderator == nickname: + for roleNickname in lines: + roleNickname = roleNickname.strip('\n').strip('\r') + if roleNickname == nickname: return lines.append(nickname) - with open(moderatorsFile, 'w+') as f: - for moderator in lines: - moderator = moderator.strip('\n').strip('\r') - if len(moderator) > 1: - if os.path.isdir(baseDir + '/accounts/' + - moderator + '@' + domain): - f.write(moderator + '\n') + with open(roleFile, 'w+') as f: + for roleNickname in lines: + roleNickname = roleNickname.strip('\n').strip('\r') + if len(roleNickname) < 2: + continue + if os.path.isdir(baseDir + '/accounts/' + + roleNickname + '@' + domain): + f.write(roleNickname + '\n') else: - with open(moderatorsFile, "w+") as f: - if os.path.isdir(baseDir + '/accounts/' + - nickname + '@' + domain): + with open(roleFile, 'w+') as f: + accountDir = acctDir(baseDir, nickname, domain) + if os.path.isdir(accountDir): f.write(nickname + '\n') -def _removeModerator(baseDir: str, nickname: str): - """Removes a moderator nickname from the file +def _removeRole(baseDir: str, nickname: str, roleFilename: str) -> None: + """Removes a role nickname from the file. + This is a file containing the nicknames of accounts having this role """ - moderatorsFile = baseDir + '/accounts/moderators.txt' - if not os.path.isfile(moderatorsFile): + roleFile = baseDir + '/accounts/' + roleFilename + if not os.path.isfile(roleFile): return - with open(moderatorsFile, "r") as f: + with open(roleFile, 'r') as f: lines = f.readlines() - with open(moderatorsFile, 'w+') as f: - for moderator in lines: - moderator = moderator.strip('\n').strip('\r') - if len(moderator) > 1 and moderator != nickname: - f.write(moderator + '\n') + with open(roleFile, 'w+') as f: + for roleNickname in lines: + roleNickname = roleNickname.strip('\n').strip('\r') + if len(roleNickname) > 1 and roleNickname != nickname: + f.write(roleNickname + '\n') + + +def _setActorRole(actorJson: {}, roleName: str) -> bool: + """Sets a role for an actor + """ + if not actorJson.get('hasOccupation'): + return False + if not isinstance(actorJson['hasOccupation'], list): + return False + + # occupation category from www.onetonline.org + category = None + if 'admin' in roleName: + category = '15-1299.01' + elif 'moderator' in roleName: + category = '11-9199.02' + elif 'editor' in roleName: + category = '27-3041.00' + elif 'counselor' in roleName: + category = '23-1022.00' + elif 'artist' in roleName: + category = '27-1024.00' + if not category: + 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'] != 'Role': + continue + if occupationItem['hasOccupation']['name'] == roleName: + return True + statusNumber, published = getStatusNumber() + newRole = { + "@type": "Role", + "hasOccupation": { + "@type": "Occupation", + "name": roleName, + "description": "Fediverse instance role", + "occupationLocation": { + "@type": "City", + "url": "Fediverse" + }, + "occupationalCategory": { + "@type": "CategoryCode", + "inCodeSet": { + "@type": "CategoryCodeSet", + "name": "O*Net-SOC", + "dateModified": "2019", + "url": "https://www.onetonline.org/" + }, + "codeValue": category, + "url": "https://www.onetonline.org/link/summary/" + category + } + }, + "startDate": published + } + actorJson['hasOccupation'].append(newRole) + return True + + +def setRolesFromList(actorJson: {}, rolesList: []) -> None: + """Sets roles from a list + """ + # clear Roles from the occupation list + emptyRolesList = [] + for occupationItem in actorJson['hasOccupation']: + if not isinstance(occupationItem, dict): + continue + if not occupationItem.get('@type'): + continue + if occupationItem['@type'] == 'Role': + continue + emptyRolesList.append(occupationItem) + actorJson['hasOccupation'] = emptyRolesList + + # create the new list + for roleName in rolesList: + _setActorRole(actorJson, roleName) + + +def getActorRolesList(actorJson: {}) -> []: + """Gets a list of role names from an actor + """ + if not actorJson.get('hasOccupation'): + return [] + if not isinstance(actorJson['hasOccupation'], list): + return [] + rolesList = [] + for occupationItem in actorJson['hasOccupation']: + if not isinstance(occupationItem, dict): + continue + if not occupationItem.get('@type'): + continue + if occupationItem['@type'] != 'Role': + continue + roleName = occupationItem['hasOccupation']['name'] + if roleName not in rolesList: + rolesList.append(roleName) + return rolesList def setRole(baseDir: str, nickname: str, domain: str, - project: str, role: str) -> bool: - """Set a person's role within a project + role: str) -> bool: + """Set a person's role Setting the role to an empty string or None will remove it """ # avoid giant strings - if len(role) > 128 or len(project) > 128: + if len(role) > 128: return False - actorFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '.json' + actorFilename = acctDir(baseDir, nickname, domain) + '.json' if not os.path.isfile(actorFilename): return False + roleFiles = { + "moderator": "moderators.txt", + "editor": "editors.txt", + "counselor": "counselors.txt", + "artist": "artists.txt" + } + actorJson = loadJson(actorFilename) if actorJson: + if not actorJson.get('hasOccupation'): + return False + rolesList = getActorRolesList(actorJson) + actorChanged = False if role: # add the role - if project == 'instance' and 'role' == 'moderator': - _addModerator(baseDir, nickname, domain) - if actorJson['roles'].get(project): - if role not in actorJson['roles'][project]: - actorJson['roles'][project].append(role) - else: - actorJson['roles'][project] = [role] + if roleFiles.get(role): + _addRole(baseDir, nickname, domain, roleFiles[role]) + if role not in rolesList: + rolesList.append(role) + rolesList.sort() + setRolesFromList(actorJson, rolesList) + actorChanged = True else: # remove the role - if project == 'instance': - _removeModerator(baseDir, nickname) - if actorJson['roles'].get(project): - actorJson['roles'][project].remove(role) - # if the project contains no roles then remove it - if len(actorJson['roles'][project]) == 0: - del actorJson['roles'][project] - saveJson(actorJson, actorFilename) + if roleFiles.get(role): + _removeRole(baseDir, nickname, roleFiles[role]) + if role in rolesList: + rolesList.remove(role) + setRolesFromList(actorJson, rolesList) + actorChanged = True + if actorChanged: + saveJson(actorJson, actorFilename) return True -def _getRoles(baseDir: str, nickname: str, domain: str, - project: str) -> []: - """Returns the roles for a given person on a given project +def actorHasRole(actorJson: {}, roleName: str) -> bool: + """Returns true if the given actor has the given role """ - actorFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '.json' - if not os.path.isfile(actorFilename): - return False - - actorJson = loadJson(actorFilename) - if actorJson: - if not actorJson.get('roles'): - return None - if not actorJson['roles'].get(project): - return None - return actorJson['roles'][project] - return None - - -def outboxDelegate(baseDir: str, authenticatedNickname: str, - messageJson: {}, debug: bool) -> bool: - """Handles receiving a delegation request - """ - if not messageJson.get('type'): - return False - if not messageJson['type'] == 'Delegate': - return False - if not messageJson.get('object'): - return False - if not isinstance(messageJson['object'], dict): - return False - if not messageJson['object'].get('type'): - return False - if not messageJson['object']['type'] == 'Role': - return False - if not messageJson['object'].get('object'): - return False - if not messageJson['object'].get('actor'): - return False - if not isinstance(messageJson['object']['object'], str): - return False - if ';' not in messageJson['object']['object']: - print('WARN: No ; separator between project and role') - return False - - delegatorNickname = getNicknameFromActor(messageJson['actor']) - if delegatorNickname != authenticatedNickname: - return - domain, port = getDomainFromActor(messageJson['actor']) - project = messageJson['object']['object'].split(';')[0].strip() - - # instance delegators can delagate to other projects - # than their own - canDelegate = False - delegatorRoles = _getRoles(baseDir, delegatorNickname, - domain, 'instance') - if delegatorRoles: - if 'delegator' in delegatorRoles: - canDelegate = True - - if not canDelegate: - canDelegate = True - # non-instance delegators can only delegate within their project - delegatorRoles = _getRoles(baseDir, delegatorNickname, - domain, project) - if delegatorRoles: - if 'delegator' not in delegatorRoles: - return False - else: - return False - - if not canDelegate: - return False - nickname = getNicknameFromActor(messageJson['object']['actor']) - if not nickname: - print('WARN: unable to find nickname in ' + - messageJson['object']['actor']) - return False - role = \ - messageJson['object']['object'].split(';')[1].strip().lower() - - if not role: - setRole(baseDir, nickname, domain, project, None) - return True - - # what roles is this person already assigned to? - existingRoles = _getRoles(baseDir, nickname, domain, project) - if existingRoles: - if role in existingRoles: - if debug: - print(nickname + '@' + domain + - ' is already assigned to the role ' + - role + ' within the project ' + project) - return False - setRole(baseDir, nickname, domain, project, role) - if debug: - print(nickname + '@' + domain + - ' assigned to the role ' + role + - ' within the project ' + project) - return True - - -def sendRoleViaServer(baseDir: str, session, - delegatorNickname: str, password: str, - delegatorDomain: str, delegatorPort: int, - httpPrefix: str, nickname: str, - project: str, role: str, - cachedWebfingers: {}, personCache: {}, - debug: bool, projectVersion: str) -> {}: - """A delegator creates a role for a person via c2s - Setting role to an empty string or None removes the role - """ - if not session: - print('WARN: No session for sendRoleViaServer') - return 6 - - delegatorDomainFull = getFullDomain(delegatorDomain, delegatorPort) - - toUrl = \ - httpPrefix + '://' + delegatorDomainFull + '/users/' + nickname - ccUrl = \ - httpPrefix + '://' + delegatorDomainFull + '/users/' + \ - delegatorNickname + '/followers' - - if role: - roleStr = project.lower() + ';' + role.lower() - else: - roleStr = project.lower() + ';' - actor = \ - httpPrefix + '://' + delegatorDomainFull + \ - '/users/' + delegatorNickname - delegateActor = \ - httpPrefix + '://' + delegatorDomainFull + '/users/' + nickname - newRoleJson = { - 'type': 'Delegate', - 'actor': actor, - 'object': { - 'type': 'Role', - 'actor': delegateActor, - 'object': roleStr, - 'to': [toUrl], - 'cc': [ccUrl] - }, - 'to': [toUrl], - 'cc': [ccUrl] - } - - handle = \ - httpPrefix + '://' + delegatorDomainFull + '/@' + delegatorNickname - - # lookup the inbox for the To handle - wfRequest = webfingerHandle(session, handle, httpPrefix, - cachedWebfingers, - delegatorDomain, projectVersion) - 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)) - 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, - delegatorNickname, - delegatorDomain, postToBox, - 765672) - - if not inboxUrl: - if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) - return 3 - if not fromPersonId: - if debug: - print('DEBUG: No actor was found for ' + handle) - return 4 - - authHeader = createBasicAuthHeader(delegatorNickname, password) - - headers = { - 'host': delegatorDomain, - 'Content-type': 'application/json', - 'Authorization': authHeader - } - postResult = \ - postJson(session, newRoleJson, [], inboxUrl, headers) - if not postResult: - if debug: - print('DEBUG: POST announce failed for c2s to '+inboxUrl) -# return 5 - - if debug: - print('DEBUG: c2s POST role success') - - return newRoleJson + rolesList = getActorRolesList(actorJson) + return roleName in rolesList diff --git a/schedule.py b/schedule.py index 5b4616d54..de3564d45 100644 --- a/schedule.py +++ b/schedule.py @@ -5,12 +5,16 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Calendar" import os import time import datetime +from utils import hasObjectDict from utils import getStatusNumber from utils import loadJson +from utils import isAccountDir +from utils import acctDir from outbox import postMessageToOutbox @@ -76,16 +80,17 @@ def _updatePostSchedule(baseDir: str, handle: str, httpd, statusNumber, published = getStatusNumber() if postJsonObject.get('published'): postJsonObject['published'] = published - if postJsonObject.get('object'): - if isinstance(postJsonObject['object'], dict): - if postJsonObject['object'].get('published'): - postJsonObject['published'] = published + if hasObjectDict(postJsonObject): + if postJsonObject['object'].get('published'): + postJsonObject['published'] = published print('Sending scheduled post ' + postId) if nickname: httpd.postToNickname = nickname - if not postMessageToOutbox(postJsonObject, nickname, + if not postMessageToOutbox(httpd.session, + httpd.translate, + postJsonObject, nickname, httpd, baseDir, httpd.httpPrefix, httpd.domain, @@ -106,14 +111,15 @@ def _updatePostSchedule(baseDir: str, handle: str, httpd, httpd.debug, httpd.YTReplacementDomain, httpd.showPublishedDateOnly, - httpd.allowLocalNetworkAccess): + httpd.allowLocalNetworkAccess, + httpd.city): indexLines.remove(line) os.remove(postFilename) continue # move to the outbox - outboxPostFilename = \ - postFilename.replace('/scheduled/', '/outbox/') + outboxPostFilename = postFilename.replace('/scheduled/', + '/outbox/') os.rename(postFilename, outboxPostFilename) print('Scheduled post sent ' + postId) @@ -125,11 +131,9 @@ def _updatePostSchedule(baseDir: str, handle: str, httpd, # write the new schedule index file scheduleIndexFile = \ baseDir + '/accounts/' + handle + '/schedule.index' - scheduleFile = open(scheduleIndexFile, "w+") - if scheduleFile: + with open(scheduleIndexFile, 'w+') as scheduleFile: for line in indexLines: scheduleFile.write(line) - scheduleFile.close() def runPostSchedule(baseDir: str, httpd, maxScheduledPosts: int): @@ -142,9 +146,7 @@ def runPostSchedule(baseDir: str, httpd, maxScheduledPosts: int): for account in dirs: if '@' not in account: continue - if account.startswith('inbox@'): - continue - if account.startswith('news@'): + if not isAccountDir(account): continue # scheduled posts index for this account scheduleIndexFilename = \ @@ -152,7 +154,7 @@ def runPostSchedule(baseDir: str, httpd, maxScheduledPosts: int): if not os.path.isfile(scheduleIndexFilename): continue _updatePostSchedule(baseDir, account, httpd, maxScheduledPosts) - break + break def runPostScheduleWatchdog(projectVersion: str, httpd) -> None: @@ -164,12 +166,13 @@ def runPostScheduleWatchdog(projectVersion: str, httpd) -> None: httpd.thrPostSchedule.start() while True: time.sleep(20) - if not httpd.thrPostSchedule.is_alive(): - httpd.thrPostSchedule.kill() - httpd.thrPostSchedule = \ - postScheduleOriginal.clone(runPostSchedule) - httpd.thrPostSchedule.start() - print('Restarting scheduled posts...') + if httpd.thrPostSchedule.is_alive(): + continue + httpd.thrPostSchedule.kill() + httpd.thrPostSchedule = \ + postScheduleOriginal.clone(runPostSchedule) + httpd.thrPostSchedule.start() + print('Restarting scheduled posts...') def removeScheduledPosts(baseDir: str, nickname: str, domain: str) -> None: @@ -177,12 +180,11 @@ def removeScheduledPosts(baseDir: str, nickname: str, domain: str) -> None: """ # remove the index scheduleIndexFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + '/schedule.index' + acctDir(baseDir, nickname, domain) + '/schedule.index' if os.path.isfile(scheduleIndexFilename): os.remove(scheduleIndexFilename) # remove the scheduled posts - scheduledDir = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/scheduled' + scheduledDir = acctDir(baseDir, nickname, domain) + '/scheduled' if not os.path.isdir(scheduledDir): return for scheduledPostFilename in os.listdir(scheduledDir): diff --git a/scripts/architecture b/scripts/architecture new file mode 100755 index 000000000..95b2e80dc --- /dev/null +++ b/scripts/architecture @@ -0,0 +1,12 @@ +#!/bin/bash + +if [ ! -d architecture ]; then + mkdir architecture +fi + +FILES="*.dot" +for f in $FILES +do + img_filename=$(echo ${f} | awk -F '.' '{print $1}').png + dot "$f" -Tpng -o architecture/${img_filename} +done diff --git a/scripts/blocked b/scripts/blocked new file mode 100755 index 000000000..7dffe76b0 --- /dev/null +++ b/scripts/blocked @@ -0,0 +1,7 @@ +#!/bin/bash +journalctl -u epicyon | grep 'getJson Forbidden ' > .blocked_events.txt +if [ ! -f .blocked_events.txt ]; then + echo 'No blocking events' +else + cat .blocked_events.txt +fi diff --git a/epicyon-notification b/scripts/epicyon-notification similarity index 75% rename from epicyon-notification rename to scripts/epicyon-notification index 03043f978..d2f2c6301 100755 --- a/epicyon-notification +++ b/scripts/epicyon-notification @@ -6,7 +6,7 @@ # # Something like: # -# */1 * * * * root /usr/local/bin/epicyon-notification --epicyon yes +# */1 * * * * root /usr/local/bin/epicyon-notification # # License # ======= @@ -29,6 +29,8 @@ PROJECT_NAME=epicyon epicyonInstallDir=/opt/${PROJECT_NAME} +MY_EMAIL_ADDRESS="username@domain" + local_domain=$HOSTNAME if [ -f /var/lib/tor/hidden_service_epicyon/hostname ]; then local_domain=$(cat /var/lib/tor/hidden_service_epicyon/hostname) @@ -38,18 +40,18 @@ fi function notification_translate_text { text="$1" if ! grep -q '"language":' "${epicyonInstallDir}/config.json"; then - echo "$text" - return + echo "$text" + return fi language=$(cat "${epicyonInstallDir}/config.json" | awk -F '"language":' '{print $2}' | awk -F '"' '{print $2}') translationsFilename="${epicyonInstallDir}/translations/${language}.json" if [ ! -f "$translationsFilename" ]; then - echo "$text" - return + echo "$text" + return fi if ! grep -q "\"$text\":" "$translationsFilename"; then - echo "$text" - return + echo "$text" + return fi grep "\"$text\":" "$translationsFilename" | awk -F '"' '{print $4}' } @@ -71,7 +73,7 @@ function matrix_server_message { MATRIX_DATA_DIR='/var/lib/matrix' homeserver_config="${MATRIX_DATA_DIR}/homeserver.yaml" - + # shellcheck disable=SC2002 MATRIX_DOMAIN_NAME=$(cat "$homeserver_config" | grep "server_name:" | head -n 1 | awk -F '"' '{print $2}') if [ ! "$MATRIX_DOMAIN_NAME" ]; then @@ -110,26 +112,42 @@ function sendNotification { USERNAME="$1" SUBJECT="$2" MESSAGE="$3" - + + hasSent= + if [ -d /etc/prosody ]; then if [ -f /usr/bin/sendxmpp ]; then + # generate a random password for a temporary user account notification_user_password=$(openssl rand -base64 32 | tr -dc A-Za-z0-9 | head -c 30 ; echo -n '') + # register a temporary xmpp user account to send the message if prosodyctl register "notification" "$local_domain" "$notification_user_password"; then if [[ "$SUBJECT" == *' Tor '* ]]; then MESSAGE="$SUBJECT" fi if [ -f /usr/bin/sendxmpp ]; then + # kill any existing message which hasn't sent kill_sendxmpp_process + # send the xmpp notification using the temporary account echo "${MESSAGE}" | /usr/bin/sendxmpp -u notification -p "${notification_user_password}" -j localhost -o ${local_domain} --message-type=headline -n -t -s ${PROJECT_NAME} ${USERNAME}@${local_domain} + hasSent=1 fi fi + # remove the temporary xmpp account prosodyctl deluser "notification@$local_domain" fi fi if [ -d /etc/matrix ]; then matrix_server_message "${USERNAME}" "${USERNAME}" "$MESSAGE" + hasSent=1 + fi + + if [ ! "$hasSent" ]; then + if [[ "$MY_EMAIL_ADDRESS" != "username@domain" ]]; then + # send to a fixed email address for a single user instance + echo "$MESSAGE" | /usr/bin/mail -s "$SUBJECT" "$MY_EMAIL_ADDRESS" + fi fi } @@ -140,28 +158,28 @@ function notifications { fi if [ ! -f "${epicyonInstallDir}/config.json" ]; then - return + return fi # shellcheck disable=SC2002 EPICYON_DOMAIN_NAME=$(cat "${epicyonInstallDir}/config.json" | awk -F '"domain":' '{print $2}' | awk -F '"' '{print $2}') for d in ${epicyonInstallDir}/accounts/*/ ; do - if [[ "$d" != *'@'* ]]; then - continue - fi - epicyonDir="${d::-1}" - USERNAME=$(echo "$epicyonDir" | awk -F '/' '{print $5}' | awk -F '@' '{print $1}') + if [[ "$d" != *'@'* ]]; then + continue + fi + epicyonDir="${d::-1}" + USERNAME=$(echo "$epicyonDir" | awk -F '/' '{print $5}' | awk -F '@' '{print $1}') # send notifications for calendar events to XMPP/email users epicyonCalendarfile="$epicyonDir/.newCalendar" if [ -f "$epicyonCalendarfile" ]; then if ! grep -q "##sent##" "$epicyonCalendarfile"; then - epicyonCalendarmessage=$(notification_translate_text 'New calendar event') + epicyonCalendarmessage=$(notification_translate_text 'Calendar') epicyonCalendarfileContent=$(echo "$epicyonCalendarmessage")" "$(cat "$epicyonCalendarfile") if [[ "$epicyonCalendarfileContent" == '/calendar'* ]]; then epicyonCalendarmessage="Epicyon: ${EPICYON_DOMAIN_NAME}/users/${USERNAME}${epicyonCalendarfileContent}" fi - sendNotification "$USERNAME" "Epicyon" "$epicyonCalendarmessage" + sendNotification "$USERNAME" "Epicyon" "$epicyonCalendarmessage" echo "##sent##" >> "$epicyonCalendarfile" chown ${PROJECT_NAME}:${PROJECT_NAME} "$epicyonCalendarfile" fi @@ -171,12 +189,12 @@ function notifications { epicyonDMfile="$epicyonDir/.newDM" if [ -f "$epicyonDMfile" ]; then if ! grep -q "##sent##" "$epicyonDMfile"; then - epicyonDMmessage=$(notification_translate_text 'New direct message') + epicyonDMmessage=$(notification_translate_text 'DM') epicyonDMfileContent=$(echo "$epicyonDMmessage")" "$(cat "$epicyonDMfile") if [[ "$epicyonDMfileContent" == *':'* ]]; then epicyonDMmessage="Epicyon: $epicyonDMfileContent" fi - sendNotification "$USERNAME" "Epicyon" "$epicyonDMmessage" + sendNotification "$USERNAME" "Epicyon" "$epicyonDMmessage" echo "##sent##" > "$epicyonDMfile" chown ${PROJECT_NAME}:${PROJECT_NAME} "$epicyonDMfile" fi @@ -186,14 +204,29 @@ function notifications { epicyonLikeFile="$epicyonDir/.newLike" if [ -f "$epicyonLikeFile" ]; then if ! grep -q "##sent##" "$epicyonLikeFile"; then - epicyonLikeMessage=$(notification_translate_text 'liked your post') + epicyonLikeMessage=$(notification_translate_text 'Liked by') epicyonLikeFileContent=$(cat "$epicyonLikeFile" | awk -F ' ' '{print $1}')" "$(echo "$epicyonLikeMessage")" "$(cat "$epicyonLikeFile" | awk -F ' ' '{print $2}') if [[ "$epicyonLikeFileContent" == *':'* ]]; then epicyonLikeMessage="Epicyon: $epicyonLikeFileContent" fi - "${PROJECT_NAME}-notification" -u "$USERNAME" -s "Epicyon" -m "$epicyonLikeMessage" --sensitive yes + sendNotification "$USERNAME" "Epicyon" "$epicyonLikeMessage" echo "##sent##" > "$epicyonLikeFile" - chown ${PROJECT_NAME}:${PROJECT_NAME} "$epicyonLkeFile" + chown ${PROJECT_NAME}:${PROJECT_NAME} "$epicyonLikeFile" + fi + fi + + # send notifications for posts arriving from a particular person + epicyonNotifyFile="$epicyonDir/.newNotifiedPost" + if [ -f "$epicyonNotifyFile" ]; then + if ! grep -q "##sent##" "$epicyonNotifyFile"; then + epicyonNotifyMessage=$(notification_translate_text 'New post') + epicyonNotifyFileContent=$(echo "$epicyonNotifyMessage")" "$(cat "$epicyonNotifyFile") + if [[ "$epicyonNotifyFileContent" == *':'* ]]; then + epicyonNotifyMessage="Epicyon: $epicyonNotifyFileContent" + fi + sendNotification "$USERNAME" "Epicyon" "$epicyonNotifyMessage" + echo "##sent##" > "$epicyonNotifyFile" + chown ${PROJECT_NAME}:${PROJECT_NAME} "$epicyonNotifyFile" fi fi @@ -201,12 +234,12 @@ function notifications { epicyonReplyFile="$epicyonDir/.newReply" if [ -f "$epicyonReplyFile" ]; then if ! grep -q "##sent##" "$epicyonReplyFile"; then - epicyonReplyMessage=$(notification_translate_text 'New reply') + epicyonReplyMessage=$(notification_translate_text 'Replies') epicyonReplyFileContent=$(echo "$epicyonReplyMessage")" "$(cat "$epicyonReplyFile") if [[ "$epicyonReplyFileContent" == *':'* ]]; then epicyonReplyMessage="Epicyon: $epicyonReplyFileContent" fi - sendNotification "$USERNAME" "Epicyon" "$epicyonReplyMessage" + sendNotification "$USERNAME" "Epicyon" "$epicyonReplyMessage" echo "##sent##" > "$epicyonReplyFile" chown ${PROJECT_NAME}:${PROJECT_NAME} "$epicyonReplyFile" fi @@ -216,18 +249,18 @@ function notifications { epicyonPatchFile="$epicyonDir/.newPatch" if [ -f "$epicyonPatchFile" ]; then if [ -f "${epicyonPatchFile}Content" ]; then - if ! grep -q "##sent##" "$epicyonPatchFile"; then - epicyonPatchMessage=$(cat "$epicyonPatchFile") - if [ "$epicyonPatchMessage" ]; then - # notify the member - sendNotification "$USERNAME" "Epicyon" "$epicyonPatchMessage" - echo "##sent##" > "$epicyonPatchFile" - chown ${PROJECT_NAME}:${PROJECT_NAME} "$epicyonPatchFile" - # send the patch to them by email - cat "${epicyonPatchFile}Content" | mail -s "[Epicyon] $epicyonPatchMessage" "${USERNAME}@${HOSTNAME}" - rm "${epicyonPatchFile}Content" - fi - fi + if ! grep -q "##sent##" "$epicyonPatchFile"; then + epicyonPatchMessage=$(cat "$epicyonPatchFile") + if [ "$epicyonPatchMessage" ]; then + # notify the member + sendNotification "$USERNAME" "Epicyon" "$epicyonPatchMessage" + echo "##sent##" > "$epicyonPatchFile" + chown ${PROJECT_NAME}:${PROJECT_NAME} "$epicyonPatchFile" + # send the patch to them by email + cat "${epicyonPatchFile}Content" | mail -s "[Epicyon] $epicyonPatchMessage" "${USERNAME}@${HOSTNAME}" + rm "${epicyonPatchFile}Content" + fi + fi fi fi @@ -235,12 +268,12 @@ function notifications { epicyonShareFile="$epicyonDir/.newShare" if [ -f "$epicyonShareFile" ]; then if ! grep -q "##sent##" "$epicyonShareFile"; then - epicyonShareMessage=$(notification_translate_text 'New shared item') + epicyonShareMessage=$(notification_translate_text 'Shares') epicyonShareFileContent=$(echo "$epicyonShareMessage")" "$(cat "$epicyonShareFile") if [[ "$epicyonShareFileContent" == *':'* ]]; then epicyonShareMessage="Epicyon: $epicyonShareFileContent" fi - sendNotification "$USERNAME" "Epicyon" "$epicyonShareMessage" + sendNotification "$USERNAME" "Epicyon" "$epicyonShareMessage" echo "##sent##" > "$epicyonShareFile" chown ${PROJECT_NAME}:${PROJECT_NAME} "$epicyonShareFile" fi @@ -264,13 +297,13 @@ function notifications { if [ $epicyonNotify ]; then cp "$epicyonFollowFile" "$epicyonFollowNotificationsFile" chown ${PROJECT_NAME}:${PROJECT_NAME} "$epicyonFollowNotificationsFile" - - epicyonFollowMessage=$(notification_translate_text "New follow request")" ${EPICYON_DOMAIN_NAME}/users/${USERNAME}/followers" - sendNotification "$USERNAME" "Epicyon" "$epicyonFollowMessage" + + epicyonFollowMessage=$(notification_translate_text 'Approve follower requests')" ${EPICYON_DOMAIN_NAME}/users/${USERNAME}/followers" + sendNotification "$USERNAME" "Epicyon" "$epicyonFollowMessage" fi fi fi - done + done } notifications diff --git a/scripts/unauthorized b/scripts/unauthorized new file mode 100755 index 000000000..1c6df0eb0 --- /dev/null +++ b/scripts/unauthorized @@ -0,0 +1,7 @@ +#!/bin/bash +journalctl -u epicyon | grep 'getJson Unauthorized ' > .unauthorized.txt +if [ ! -f .unauthorized.txt ]; then + echo 'No unauthorized events' +else + cat .unauthorized.txt +fi diff --git a/session.py b/session.py index d9c37907f..b1f0b8d89 100644 --- a/session.py +++ b/session.py @@ -5,13 +5,16 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Core" import os import requests from utils import urlPermitted +from utils import isImageFile import json from socket import error as SocketError import errno +from http.client import HTTPConnection baseDirectory = None @@ -21,19 +24,16 @@ def createSession(proxyType: str): try: session = requests.session() except requests.exceptions.RequestException as e: - print('WARN: requests error during createSession') - print(e) + print('WARN: requests error during createSession ' + str(e)) return None except SocketError as e: if e.errno == errno.ECONNRESET: - print('WARN: connection was reset during createSession') + print('WARN: connection was reset during createSession ' + str(e)) else: - print('WARN: socket error during createSession') - print(e) + print('WARN: socket error during createSession ' + str(e)) return None except ValueError as e: - print('WARN: error during createSession') - print(e) + print('WARN: error during createSession ' + str(e)) return None if not session: return None @@ -53,8 +53,8 @@ def createSession(proxyType: str): return session -def urlExists(session, url: str, timeoutSec=3, - httpPrefix='https', domain='testdomain') -> bool: +def urlExists(session, url: str, timeoutSec: int = 3, + httpPrefix: str = 'https', domain: str = 'testdomain') -> bool: if not isinstance(url, str): print('url: ' + str(url)) print('ERROR: urlExists failed, url should be a string') @@ -84,12 +84,14 @@ def urlExists(session, url: str, timeoutSec=3, return False -def getJson(session, url: str, headers: {}, params: {}, - version='1.2.0', httpPrefix='https', - domain='testdomain') -> {}: +def getJson(session, url: str, headers: {}, params: {}, debug: bool, + version: str = '1.2.0', httpPrefix: str = 'https', + domain: str = 'testdomain', + timeoutSec: int = 20, quiet: bool = False) -> {}: if not isinstance(url, str): - print('url: ' + str(url)) - print('ERROR: getJson failed, url should be a string') + if debug and not quiet: + print('url: ' + str(url)) + print('ERROR: getJson failed, url should be a string') return None sessionParams = {} sessionHeaders = {} @@ -102,55 +104,95 @@ def getJson(session, url: str, headers: {}, params: {}, sessionHeaders['User-Agent'] += \ '; +' + httpPrefix + '://' + domain + '/' if not session: - print('WARN: getJson failed, no session specified for getJson') + if not quiet: + print('WARN: getJson failed, no session specified for getJson') return None + + if debug: + HTTPConnection.debuglevel = 1 + try: - result = session.get(url, headers=sessionHeaders, params=sessionParams) + result = session.get(url, headers=sessionHeaders, + params=sessionParams, timeout=timeoutSec) + if result.status_code != 200: + if result.status_code == 401: + print('WARN: getJson Unauthorized url: ' + url) + elif result.status_code == 403: + print('WARN: getJson Forbidden url: ' + url) + elif result.status_code == 404: + print('WARN: getJson Not Found url: ' + url) + else: + print('WARN: getJson url: ' + url + + ' failed with error code ' + + str(result.status_code)) return result.json() except requests.exceptions.RequestException as e: - print('ERROR: getJson failed\nurl: ' + str(url) + ' ' + - 'headers: ' + str(sessionHeaders) + ' ' + - 'params: ' + str(sessionParams)) - print(e) + sessionHeaders2 = sessionHeaders.copy() + if sessionHeaders2.get('Authorization'): + sessionHeaders2['Authorization'] = 'REDACTED' + if debug and not quiet: + print('ERROR: getJson failed, url: ' + str(url) + ', ' + + 'headers: ' + str(sessionHeaders2) + ', ' + + 'params: ' + str(sessionParams) + ', ' + str(e)) except ValueError as e: - print('ERROR: getJson failed\nurl: ' + str(url) + ' ' + - 'headers: ' + str(sessionHeaders) + ' ' + - 'params: ' + str(sessionParams) + ' ') - print(e) + sessionHeaders2 = sessionHeaders.copy() + if sessionHeaders2.get('Authorization'): + sessionHeaders2['Authorization'] = 'REDACTED' + if debug and not quiet: + print('ERROR: getJson failed, url: ' + str(url) + ', ' + + 'headers: ' + str(sessionHeaders2) + ', ' + + 'params: ' + str(sessionParams) + ', ' + str(e)) except SocketError as e: - if e.errno == errno.ECONNRESET: - print('WARN: getJson failed, connection was reset during getJson') - print(e) + if not quiet: + if e.errno == errno.ECONNRESET: + print('WARN: getJson failed, ' + + 'connection was reset during getJson ' + str(e)) return None -def postJson(session, postJsonObject: {}, federationList: [], - inboxUrl: str, headers: {}) -> str: +def postJson(httpPrefix: str, domainFull: str, + session, postJsonObject: {}, federationList: [], + inboxUrl: str, headers: {}, timeoutSec: int = 60, + quiet: bool = False) -> str: """Post a json message to the inbox of another person """ # check that we are posting to a permitted domain if not urlPermitted(inboxUrl, federationList): - print('postJson: ' + inboxUrl + ' not permitted') + if not quiet: + print('postJson: ' + inboxUrl + ' not permitted') return None + sessionHeaders = headers + sessionHeaders['User-Agent'] = 'Epicyon/' + __version__ + sessionHeaders['User-Agent'] += \ + '; +' + httpPrefix + '://' + domainFull + '/' + try: postResult = \ session.post(url=inboxUrl, data=json.dumps(postJsonObject), - headers=headers) + headers=headers, timeout=timeoutSec) + except requests.Timeout as e: + if not quiet: + print('ERROR: postJson timeout ' + inboxUrl + ' ' + + json.dumps(postJsonObject) + ' ' + str(headers)) + print(e) + return '' except requests.exceptions.RequestException as e: - print('ERROR: postJson requests failed ' + inboxUrl + ' ' + - json.dumps(postJsonObject) + ' ' + str(headers)) - print(e) + if not quiet: + print('ERROR: postJson requests failed ' + inboxUrl + ' ' + + json.dumps(postJsonObject) + ' ' + str(headers) + + ' ' + str(e)) return None except SocketError as e: - if e.errno == errno.ECONNRESET: + if not quiet and e.errno == errno.ECONNRESET: print('WARN: connection was reset during postJson') return None except ValueError as e: - print('ERROR: postJson failed ' + inboxUrl + ' ' + - json.dumps(postJsonObject) + ' ' + str(headers)) - print(e) + if not quiet: + print('ERROR: postJson failed ' + inboxUrl + ' ' + + json.dumps(postJsonObject) + ' ' + str(headers) + + ' ' + str(e)) return None if postResult: return postResult.text @@ -161,7 +203,9 @@ def postJsonString(session, postJsonStr: str, federationList: [], inboxUrl: str, headers: {}, - debug: bool) -> (bool, bool): + debug: bool, + timeoutSec: int = 30, + quiet: bool = False) -> (bool, bool): """Post a json message string to the inbox of another person The second boolean returned is true if the send is unauthorized NOTE: Here we post a string rather than the original json so that @@ -170,32 +214,37 @@ def postJsonString(session, postJsonStr: str, """ try: postResult = \ - session.post(url=inboxUrl, data=postJsonStr, headers=headers) + session.post(url=inboxUrl, data=postJsonStr, + headers=headers, timeout=timeoutSec) except requests.exceptions.RequestException as e: - print('WARN: error during postJsonString requests') - print(e) + if not quiet: + print('WARN: error during postJsonString requests ' + str(e)) return None, None except SocketError as e: - if e.errno == errno.ECONNRESET: + if not quiet and e.errno == errno.ECONNRESET: print('WARN: connection was reset during postJsonString') - print('ERROR: postJsonString failed ' + inboxUrl + ' ' + - postJsonStr + ' ' + str(headers)) + if not quiet: + print('ERROR: postJsonString failed ' + inboxUrl + ' ' + + postJsonStr + ' ' + str(headers)) return None, None except ValueError as e: - print('WARN: error during postJsonString') - print(e) + if not quiet: + print('WARN: error during postJsonString ' + str(e)) return None, None if postResult.status_code < 200 or postResult.status_code > 202: if postResult.status_code >= 400 and \ postResult.status_code <= 405 and \ postResult.status_code != 404: - print('WARN: Post to ' + inboxUrl + ' is unauthorized. Code ' + - str(postResult.status_code)) + if not quiet: + print('WARN: Post to ' + inboxUrl + + ' is unauthorized. Code ' + + str(postResult.status_code)) return False, True else: - print('WARN: Failed to post to ' + inboxUrl + - ' with headers ' + str(headers)) - print('status code ' + str(postResult.status_code)) + if not quiet: + print('WARN: Failed to post to ' + inboxUrl + + ' with headers ' + str(headers)) + print('status code ' + str(postResult.status_code)) return False, False return True, False @@ -209,12 +258,8 @@ def postImage(session, attachImageFilename: str, federationList: [], print('postJson: ' + inboxUrl + ' not permitted') return None - if not (attachImageFilename.endswith('.jpg') or - attachImageFilename.endswith('.jpeg') or - attachImageFilename.endswith('.png') or - attachImageFilename.endswith('.svg') or - attachImageFilename.endswith('.gif')): - print('Image must be png, jpg, gif or svg') + if not isImageFile(attachImageFilename): + print('Image must be png, jpg, webp, avif, gif or svg') return None if not os.path.isfile(attachImageFilename): print('Image not found: ' + attachImageFilename) @@ -222,9 +267,13 @@ def postImage(session, attachImageFilename: str, federationList: [], contentType = 'image/jpeg' if attachImageFilename.endswith('.png'): contentType = 'image/png' - if attachImageFilename.endswith('.gif'): + elif attachImageFilename.endswith('.gif'): contentType = 'image/gif' - if attachImageFilename.endswith('.svg'): + elif attachImageFilename.endswith('.webp'): + contentType = 'image/webp' + elif attachImageFilename.endswith('.avif'): + contentType = 'image/avif' + elif attachImageFilename.endswith('.svg'): contentType = 'image/svg+xml' headers['Content-type'] = contentType @@ -234,19 +283,16 @@ def postImage(session, attachImageFilename: str, federationList: [], postResult = session.post(url=inboxUrl, data=mediaBinary, headers=headers) except requests.exceptions.RequestException as e: - print('WARN: error during postImage requests') - print(e) + print('WARN: error during postImage requests ' + str(e)) return None except SocketError as e: if e.errno == errno.ECONNRESET: print('WARN: connection was reset during postImage') print('ERROR: postImage failed ' + inboxUrl + ' ' + - str(headers)) - print(e) + str(headers) + ' ' + str(e)) return None except ValueError as e: - print('WARN: error during postImage') - print(e) + print('WARN: error during postImage ' + str(e)) return None if postResult: return postResult.text diff --git a/shares.py b/shares.py index 871b63ff5..20605c12a 100644 --- a/shares.py +++ b/shares.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Timeline" import os import time @@ -18,7 +19,11 @@ from utils import validNickname from utils import loadJson from utils import saveJson from utils import getImageExtensions -from media import removeMetaData +from utils import hasObjectDict +from utils import removeDomainPort +from utils import isAccountDir +from utils import acctDir +from media import processMetaData def getValidSharedItemID(displayName: str) -> str: @@ -40,8 +45,7 @@ def removeShare(baseDir: str, nickname: str, domain: str, displayName: str) -> None: """Removes a share for a person """ - sharesFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/shares.json' + sharesFilename = acctDir(baseDir, nickname, domain) + '/shares.json' if not os.path.isfile(sharesFilename): print('ERROR: missing shares.json ' + sharesFilename) return @@ -69,38 +73,42 @@ def removeShare(baseDir: str, nickname: str, domain: str, '" does not exist in ' + sharesFilename) +def _addShareDurationSec(duration: str, published: str) -> int: + """Returns the duration for the shared item in seconds + """ + if ' ' not in duration: + return 0 + durationList = duration.split(' ') + if not durationList[0].isdigit(): + return 0 + if 'hour' in durationList[1]: + return published + (int(durationList[0]) * 60 * 60) + if 'day' in durationList[1]: + return published + (int(durationList[0]) * 60 * 60 * 24) + if 'week' in durationList[1]: + return published + (int(durationList[0]) * 60 * 60 * 24 * 7) + if 'month' in durationList[1]: + return published + (int(durationList[0]) * 60 * 60 * 24 * 30) + if 'year' in durationList[1]: + return published + (int(durationList[0]) * 60 * 60 * 24 * 365) + return 0 + + def addShare(baseDir: str, httpPrefix: str, nickname: str, domain: str, port: int, displayName: str, summary: str, imageFilename: str, itemType: str, itemCategory: str, location: str, - duration: str, debug: bool) -> None: + duration: str, debug: bool, city: str) -> None: """Adds a new share """ - sharesFilename = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/shares.json' + sharesFilename = acctDir(baseDir, nickname, domain) + '/shares.json' sharesJson = {} if os.path.isfile(sharesFilename): sharesJson = loadJson(sharesFilename) duration = duration.lower() - durationSec = 0 published = int(time.time()) - if ' ' in duration: - durationList = duration.split(' ') - if durationList[0].isdigit(): - if 'hour' in durationList[1]: - durationSec = published + (int(durationList[0]) * 60 * 60) - if 'day' in durationList[1]: - durationSec = published + (int(durationList[0]) * 60 * 60 * 24) - if 'week' in durationList[1]: - durationSec = \ - published + (int(durationList[0]) * 60 * 60 * 24 * 7) - if 'month' in durationList[1]: - durationSec = \ - published + (int(durationList[0]) * 60 * 60 * 24 * 30) - if 'year' in durationList[1]: - durationSec = \ - published + (int(durationList[0]) * 60 * 60 * 24 * 365) + durationSec = _addShareDurationSec(duration, published) itemID = getValidSharedItemID(displayName) @@ -109,7 +117,7 @@ def addShare(baseDir: str, moveImage = False if not imageFilename: sharesImageFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + '/upload' + acctDir(baseDir, nickname, domain) + '/upload' formats = getImageExtensions() for ext in formats: if os.path.isfile(sharesImageFilename + '.' + ext): @@ -128,13 +136,16 @@ def addShare(baseDir: str, itemIDfile = baseDir + '/sharefiles/' + nickname + '/' + itemID formats = getImageExtensions() for ext in formats: - if imageFilename.endswith('.' + ext): - removeMetaData(imageFilename, itemIDfile + '.' + ext) - if moveImage: - os.remove(imageFilename) - imageUrl = \ - httpPrefix + '://' + domainFull + \ - '/sharefiles/' + nickname + '/' + itemID + '.' + ext + if not imageFilename.endswith('.' + ext): + continue + processMetaData(baseDir, nickname, domain, + imageFilename, itemIDfile + '.' + ext, + city) + if moveImage: + os.remove(imageFilename) + imageUrl = \ + httpPrefix + '://' + domainFull + \ + '/sharefiles/' + nickname + '/' + itemID + '.' + ext sharesJson[itemID] = { "displayName": displayName, @@ -152,7 +163,7 @@ def addShare(baseDir: str, # indicate that a new share is available for subdir, dirs, files in os.walk(baseDir + '/accounts'): for handle in dirs: - if '@' not in handle: + if not isAccountDir(handle): continue accountDir = baseDir + '/accounts/' + handle newShareFile = accountDir + '/.newShare' @@ -172,7 +183,7 @@ def expireShares(baseDir: str) -> None: """ for subdir, dirs, files in os.walk(baseDir + '/accounts'): for account in dirs: - if '@' not in account: + if not isAccountDir(account): continue nickname = account.split('@')[0] domain = account.split('@')[1] @@ -183,9 +194,7 @@ def expireShares(baseDir: str) -> None: def _expireSharesForAccount(baseDir: str, nickname: str, domain: str) -> None: """Removes expired items from shares for a particular account """ - handleDomain = domain - if ':' in handleDomain: - handleDomain = domain.split(':')[0] + handleDomain = removeDomainPort(domain) handle = nickname + '@' + handleDomain sharesFilename = baseDir + '/accounts/' + handle + '/shares.json' if os.path.isfile(sharesFilename): @@ -246,11 +255,8 @@ def getSharesFeedForPerson(baseDir: str, domain = getFullDomain(domain, port) - handleDomain = domain - if ':' in handleDomain: - handleDomain = domain.split(':')[0] - handle = nickname + '@' + handleDomain - sharesFilename = baseDir + '/accounts/' + handle + '/shares.json' + handleDomain = removeDomainPort(domain) + sharesFilename = acctDir(baseDir, nickname, handleDomain) + '/shares.json' if headerOnly: noOfShares = 0 @@ -261,8 +267,8 @@ def getSharesFeedForPerson(baseDir: str, idStr = httpPrefix + '://' + domain + '/users/' + nickname shares = { '@context': 'https://www.w3.org/ns/activitystreams', - 'first': idStr+'/shares?page=1', - 'id': idStr+'/shares', + 'first': idStr + '/shares?page=1', + 'id': idStr + '/shares', 'totalItems': str(noOfShares), 'type': 'OrderedCollection' } @@ -275,9 +281,9 @@ def getSharesFeedForPerson(baseDir: str, idStr = httpPrefix + '://' + domain + '/users/' + nickname shares = { '@context': 'https://www.w3.org/ns/activitystreams', - 'id': idStr+'/shares?page='+str(pageNumber), + 'id': idStr + '/shares?page=' + str(pageNumber), 'orderedItems': [], - 'partOf': idStr+'/shares', + 'partOf': idStr + '/shares', 'totalItems': 0, 'type': 'OrderedCollectionPage' } @@ -336,7 +342,7 @@ def sendShareViaServer(baseDir, session, "@context": "https://www.w3.org/ns/activitystreams", 'type': 'Add', 'actor': actor, - 'target': actor+'/shares', + 'target': actor + '/shares', 'object': { "type": "Offer", "displayName": displayName, @@ -358,14 +364,14 @@ def sendShareViaServer(baseDir, session, wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - fromDomain, projectVersion) + fromDomain, projectVersion, debug) if not wfRequest: if debug: - print('DEBUG: announce webfinger failed for ' + handle) + print('DEBUG: share 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: share webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -381,11 +387,12 @@ def sendShareViaServer(baseDir, session, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: share no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: share no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -406,10 +413,11 @@ def sendShareViaServer(baseDir, session, 'Authorization': authHeader } postResult = \ - postJson(session, newShareJson, [], inboxUrl, headers) + postJson(httpPrefix, fromDomainFull, + session, newShareJson, [], inboxUrl, headers, 30, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST share failed for c2s to ' + inboxUrl) # return 5 if debug: @@ -457,14 +465,14 @@ def sendUndoShareViaServer(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) + print('DEBUG: unshare 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: unshare webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -480,11 +488,12 @@ def sendUndoShareViaServer(baseDir: str, session, if not inboxUrl: if debug: - print('DEBUG: No '+postToBox+' was found for ' + handle) + print('DEBUG: unshare no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: unshare no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(fromNickname, password) @@ -495,30 +504,30 @@ def sendUndoShareViaServer(baseDir: str, session, 'Authorization': authHeader } postResult = \ - postJson(session, undoShareJson, [], inboxUrl, headers) + postJson(httpPrefix, fromDomainFull, + session, undoShareJson, [], inboxUrl, + headers, 30, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST unshare failed for c2s to ' + inboxUrl) # return 5 if debug: - print('DEBUG: c2s POST undo share success') + print('DEBUG: c2s POST unshare success') return undoShareJson def outboxShareUpload(baseDir: str, httpPrefix: str, nickname: str, domain: str, port: int, - messageJson: {}, debug: bool) -> None: + messageJson: {}, debug: bool, city: str) -> None: """ When a shared item is received by the outbox from c2s """ if not messageJson.get('type'): return if not messageJson['type'] == 'Add': return - if not messageJson.get('object'): - return - if not isinstance(messageJson['object'], dict): + if not hasObjectDict(messageJson): return if not messageJson['object'].get('type'): if debug: @@ -561,7 +570,7 @@ def outboxShareUpload(baseDir: str, httpPrefix: str, messageJson['object']['itemCategory'], messageJson['object']['location'], messageJson['object']['duration'], - debug) + debug, city) if debug: print('DEBUG: shared item received via c2s') @@ -575,9 +584,7 @@ def outboxUndoShareUpload(baseDir: str, httpPrefix: str, return if not messageJson['type'] == 'Remove': return - if not messageJson.get('object'): - return - if not isinstance(messageJson['object'], dict): + if not hasObjectDict(messageJson): return if not messageJson['object'].get('type'): if debug: diff --git a/siteactive.py b/siteactive.py index ca530bf49..3fb7173e9 100644 --- a/siteactive.py +++ b/siteactive.py @@ -6,6 +6,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Core" import http.client from urllib.parse import urlparse @@ -92,7 +93,7 @@ def _siteActiveHttpRequest(loc, timeout: int): return result -def siteIsActive(url: str, timeout=10) -> bool: +def siteIsActive(url: str, timeout: int = 10) -> bool: """Returns true if the current url is resolvable. This can be used to check that an instance is online before trying to send posts to it. diff --git a/skills.py b/skills.py index bd245de09..0c034a5a0 100644 --- a/skills.py +++ b/skills.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 @@ -15,7 +16,97 @@ from utils import getFullDomain from utils import getNicknameFromActor from utils import getDomainFromActor from utils import loadJson -from utils import saveJson +from utils import getOccupationSkills +from utils import setOccupationSkillsList +from utils import acctDir + + +def setSkillsFromDict(actorJson: {}, skillsDict: {}) -> []: + """Converts a dict containing skills to a list + Returns the string version of the dictionary + """ + skillsList = [] + for name, value in skillsDict.items(): + skillsList.append(name + ':' + str(value)) + setOccupationSkillsList(actorJson, skillsList) + return skillsList + + +def getSkillsFromList(skillsList: []) -> {}: + """Returns a dict of skills from a list + """ + if isinstance(skillsList, list): + skillsList2 = skillsList + else: + skillsList2 = skillsList.split(',') + skillsDict = {} + for skill in skillsList2: + if ':' not in skill: + continue + name = skill.split(':')[0].strip().lower() + valueStr = skill.split(':')[1] + if not valueStr.isdigit(): + continue + skillsDict[name] = int(valueStr) + return skillsDict + + +def actorSkillValue(actorJson: {}, skillName: str) -> int: + """Returns The skill level from an actor + """ + ocSkillsList = getOccupationSkills(actorJson) + skillsDict = getSkillsFromList(ocSkillsList) + if not skillsDict: + return 0 + skillName = skillName.lower() + if skillsDict.get(skillName): + return skillsDict[skillName] + return 0 + + +def noOfActorSkills(actorJson: {}) -> int: + """Returns the number of skills that an actor has + """ + if actorJson.get('hasOccupation'): + skillsList = getOccupationSkills(actorJson) + return len(skillsList) + return 0 + + +def setActorSkillLevel(actorJson: {}, + skill: str, skillLevelPercent: int) -> bool: + """Set a skill level for a person + Setting skill level to zero removes it + """ + if skillLevelPercent < 0 or skillLevelPercent > 100: + return False + + if not actorJson: + return True + if not actorJson.get('hasOccupation'): + actorJson['hasOccupation'] = [{ + '@type': 'Occupation', + 'name': '', + "occupationLocation": { + "@type": "City", + "name": "Fediverse" + }, + 'skills': [] + }] + ocSkillsList = getOccupationSkills(actorJson) + skillsDict = getSkillsFromList(ocSkillsList) + if not skillsDict.get(skill): + if len(skillsDict.items()) >= 32: + print('WARN: Maximum number of skills reached for ' + + actorJson['id']) + return False + if skillLevelPercent > 0: + skillsDict[skill] = skillLevelPercent + else: + if skillsDict.get(skill): + del skillsDict[skill] + setSkillsFromDict(actorJson, skillsDict) + return True def setSkillLevel(baseDir: str, nickname: str, domain: str, @@ -25,34 +116,28 @@ def setSkillLevel(baseDir: str, nickname: str, domain: str, """ if skillLevelPercent < 0 or skillLevelPercent > 100: 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) - if actorJson: - if not actorJson.get('skills'): - actorJson['skills'] = {} - if skillLevelPercent > 0: - actorJson['skills'][skill] = skillLevelPercent - else: - del actorJson['skills'][skill] - saveJson(actorJson, actorFilename) - return True + return setActorSkillLevel(actorJson, + skill, skillLevelPercent) def getSkills(baseDir: str, nickname: str, domain: str) -> []: """Returns the skills 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) if actorJson: - if not actorJson.get('skills'): + if not actorJson.get('hasOccupation'): return None - return actorJson['skills'] + ocSkillsList = getOccupationSkills(actorJson) + return getSkillsFromList(ocSkillsList) return None @@ -112,7 +197,7 @@ def sendSkillViaServer(baseDir: str, session, nickname: str, password: str, newSkillJson = { 'type': 'Skill', 'actor': actor, - 'object': '"'+skillStr+'"', + 'object': '"' + skillStr + '"', 'to': [toUrl], 'cc': [ccUrl] } @@ -123,14 +208,14 @@ def sendSkillViaServer(baseDir: str, session, nickname: str, password: str, 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: skill 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: skill webfinger for ' + handle + + ' did not return a dict. ' + str(wfRequest)) return 1 postToBox = 'outbox' @@ -145,11 +230,12 @@ def sendSkillViaServer(baseDir: str, session, nickname: str, password: str, if not inboxUrl: if debug: - print('DEBUG: No ' + postToBox + ' was found for ' + handle) + print('DEBUG: skill no ' + postToBox + + ' was found for ' + handle) return 3 if not fromPersonId: if debug: - print('DEBUG: No actor was found for ' + handle) + print('DEBUG: skill no actor was found for ' + handle) return 4 authHeader = createBasicAuthHeader(nickname, password) @@ -160,13 +246,25 @@ def sendSkillViaServer(baseDir: str, session, nickname: str, password: str, 'Authorization': authHeader } postResult = \ - postJson(session, newSkillJson, [], inboxUrl, headers) + postJson(httpPrefix, domainFull, + session, newSkillJson, [], inboxUrl, + headers, 30, True) if not postResult: if debug: - print('DEBUG: POST announce failed for c2s to ' + inboxUrl) + print('DEBUG: POST skill failed for c2s to ' + inboxUrl) # return 5 if debug: print('DEBUG: c2s POST skill success') return newSkillJson + + +def actorHasSkill(actorJson: {}, skillName: str) -> bool: + """Returns true if the given actor has the given skill + """ + ocSkillsList = getOccupationSkills(actorJson) + for skillStr in ocSkillsList: + if skillName + ':' in skillStr: + return True + return False diff --git a/socnet.py b/socnet.py index 42fbfd072..4ae5d3e1c 100644 --- a/socnet.py +++ b/socnet.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Moderation" from session import createSession from webfinger import webfingerHandle @@ -52,7 +53,7 @@ def instancesGraph(baseDir: str, handles: str, wfRequest = \ webfingerHandle(session, handle, httpPrefix, cachedWebfingers, - domain, projectVersion) + domain, projectVersion, debug) if not wfRequest: return dotGraphStr + '}\n' if not isinstance(wfRequest, dict): diff --git a/speaker.py b/speaker.py new file mode 100644 index 000000000..2c413abd6 --- /dev/null +++ b/speaker.py @@ -0,0 +1,549 @@ +__filename__ = "speaker.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@freedombone.net" +__status__ = "Production" +__module_group__ = "Accessibility" + +import os +import html +import random +import urllib.parse +from utils import isDM +from utils import isReply +from utils import camelCaseSplit +from utils import getDomainFromActor +from utils import getNicknameFromActor +from utils import getGenderFromBio +from utils import getDisplayName +from utils import removeHtml +from utils import loadJson +from utils import saveJson +from utils import isPGPEncrypted +from utils import hasObjectDict +from utils import acctDir +from content import htmlReplaceQuoteMarks + +speakerRemoveChars = ('.\n', '. ', ',', ';', '?', '!') + + +def getSpeakerPitch(displayName: str, screenreader: str, gender) -> int: + """Returns the speech synthesis pitch for the given name + """ + random.seed(displayName) + rangeMin = 1 + rangeMax = 100 + if 'She' in gender: + rangeMin = 50 + elif 'Him' in gender: + rangeMax = 50 + if screenreader == 'picospeaker': + rangeMin = -6 + rangeMax = 3 + if 'She' in gender: + rangeMin = -1 + elif 'Him' in gender: + rangeMax = -1 + return random.randint(rangeMin, rangeMax) + + +def getSpeakerRate(displayName: str, screenreader: str) -> int: + """Returns the speech synthesis rate for the given name + """ + random.seed(displayName) + if screenreader == 'picospeaker': + return random.randint(-40, -20) + return random.randint(50, 120) + + +def getSpeakerRange(displayName: str) -> int: + """Returns the speech synthesis range for the given name + """ + random.seed(displayName) + return random.randint(300, 800) + + +def _speakerPronounce(baseDir: str, sayText: str, translate: {}) -> str: + """Screen readers may not always pronounce correctly, so you + can have a file which specifies conversions. File should contain + line items such as: + Epicyon -> Epi-cyon + """ + pronounceFilename = baseDir + '/accounts/speaker_pronounce.txt' + convertDict = {} + if translate: + convertDict = { + "Epicyon": "Epi-cyon", + "espeak": "e-speak", + "emoji": "emowji", + "clearnet": "clear-net", + "https": "H-T-T-P-S", + "HTTPS": "H-T-T-P-S", + "XMPP": "X-M-P-P", + "xmpp": "X-M-P-P", + "sql": "S-Q-L", + ".js": " dot J-S", + "PSQL": "Postgres S-Q-L", + "SQL": "S-Q-L", + "gdpr": "G-D-P-R", + "kde": "K-D-E", + "AGPL": "Affearo G-P-L", + "agpl": "Affearo G-P-L", + "GPL": "G-P-L", + "gpl": "G-P-L", + "coop": "co-op", + "KMail": "K-Mail", + "kmail": "K-Mail", + "gmail": "G-mail", + "Gmail": "G-mail", + "OpenPGP": "Open P-G-P", + "Tor": "Toor", + "memes": "meemes", + "Memes": "Meemes", + "rofl": "roll on the floor laughing", + "ROFL": "roll on the floor laughing", + "fwiw": "for what it's worth", + "fyi": "for your information", + "irl": "in real life", + "IRL": "in real life", + "imho": "in my opinion", + "fediverse": "fediiverse", + "Fediverse": "Fediiverse", + " foss ": " free and open source software ", + " floss ": " free libre and open source software ", + " FOSS ": "free and open source software", + " FLOSS ": "free libre and open source software", + " oss ": " open source software ", + " OSS ": " open source software ", + "🤔": ". " + translate["thinking emoji"], + "RT @": "Re-Tweet ", + "#nowplaying": translate["hashtag"] + " now-playing", + "#NowPlaying": translate["hashtag"] + " now-playing", + "#": translate["hashtag"] + ' ', + ":D": '. ' + translate["laughing"], + ":-D": '. ' + translate["laughing"], + ":)": '. ' + translate["smile"], + ";)": '. ' + translate["wink"], + ":(": '. ' + translate["sad face"], + ":-)": '. ' + translate["smile"], + ":-(": '. ' + translate["sad face"], + ";-)": '. ' + translate["wink"], + ":O": '. ' + translate['shocked'], + "?": "? ", + '"': "'", + "*": "", + "(": ",", + ")": "," + } + if os.path.isfile(pronounceFilename): + with open(pronounceFilename, 'r') as fp: + pronounceList = fp.readlines() + for conversion in pronounceList: + separator = None + if '->' in conversion: + separator = '->' + elif ';' in conversion: + separator = ';' + elif ':' in conversion: + separator = ':' + elif ',' in conversion: + separator = ',' + if not separator: + continue + + text = conversion.split(separator)[0].strip() + converted = conversion.split(separator)[1].strip() + convertDict[text] = converted + for text, converted in convertDict.items(): + if text in sayText: + sayText = sayText.replace(text, converted) + return sayText + + +def speakerReplaceLinks(sayText: str, translate: {}, + detectedLinks: []) -> str: + """Replaces any links in the given text with "link to [domain]". + Instead of reading out potentially very long and meaningless links + """ + text = sayText + text = text.replace('?v=', '__v=') + for ch in speakerRemoveChars: + text = text.replace(ch, ' ') + text = text.replace('__v=', '?v=') + replacements = {} + wordsList = text.split(' ') + if translate.get('Linked'): + linkedStr = translate['Linked'] + else: + linkedStr = 'Linked' + prevWord = '' + for word in wordsList: + if word.startswith('v='): + replacements[word] = '' + if word.startswith(':'): + if word.endswith(':'): + replacements[word] = ', emowji ' + word.replace(':', '') + ',' + continue + if word.startswith('@') and not prevWord.endswith('RT'): + # replace mentions, but not re-tweets + if translate.get('mentioning'): + replacements[word] = \ + translate['mentioning'] + ' ' + word[1:] + ', ' + prevWord = word + + domain = None + domainFull = None + if 'https://' in word: + domain = word.split('https://')[1] + domainFull = 'https://' + domain + elif 'http://' in word: + domain = word.split('http://')[1] + domainFull = 'http://' + domain + if not domain: + continue + if '/' in domain: + domain = domain.split('/')[0] + if domain.startswith('www.'): + domain = domain.replace('www.', '') + replacements[domainFull] = '. ' + linkedStr + ' ' + domain + '.' + detectedLinks.append(domainFull) + for replaceStr, newStr in replacements.items(): + sayText = sayText.replace(replaceStr, newStr) + return sayText.replace('..', '.') + + +def _addSSMLemphasis(sayText: str) -> str: + """Adds emphasis to *emphasised* text + """ + if '*' not in sayText: + return sayText + text = sayText + for ch in speakerRemoveChars: + text = text.replace(ch, ' ') + wordsList = text.split(' ') + replacements = {} + for word in wordsList: + if word.startswith('*'): + if word.endswith('*'): + replacements[word] = \ + '' + \ + word.replace('*', '') + \ + '' + for replaceStr, newStr in replacements.items(): + sayText = sayText.replace(replaceStr, newStr) + return sayText + + +def _removeEmojiFromText(sayText: str) -> str: + """Removes :emoji: from the given text + """ + if ':' not in sayText: + return sayText + text = sayText + for ch in speakerRemoveChars: + text = text.replace(ch, ' ') + wordsList = text.split(' ') + replacements = {} + for word in wordsList: + if word.startswith(':'): + if word.endswith(':'): + replacements[word] = '' + for replaceStr, newStr in replacements.items(): + sayText = sayText.replace(replaceStr, newStr) + return sayText.replace(' ', ' ').strip() + + +def _speakerEndpointJson(displayName: str, summary: str, + content: str, sayContent: str, + imageDescription: str, + links: [], gender: str, postId: str, + postDM: bool, postReply: bool, + followRequestsExist: bool, + followRequestsList: [], + likedBy: str, published: str, postCal: bool, + postShare: bool, themeName: str, + isDirect: bool, replyToYou: bool) -> {}: + """Returns a json endpoint for the TTS speaker + """ + speakerJson = { + "name": displayName, + "summary": summary, + "content": content, + "say": sayContent, + "published": published, + "imageDescription": imageDescription, + "detectedLinks": links, + "id": postId, + "direct": isDirect, + "replyToYou": replyToYou, + "notify": { + "theme": themeName, + "dm": postDM, + "reply": postReply, + "followRequests": followRequestsExist, + "followRequestsList": followRequestsList, + "likedBy": likedBy, + "calendar": postCal, + "share": postShare + } + } + if gender: + speakerJson['gender'] = gender + return speakerJson + + +def _SSMLheader(systemLanguage: str, instanceTitle: str) -> str: + """Returns a header for an SSML document + """ + return '\n' + \ + '\n' + \ + ' \n' + \ + ' ' + \ + instanceTitle + ' inbox\n' + \ + ' \n' + + +def _speakerEndpointSSML(displayName: str, summary: str, + content: str, imageDescription: str, + links: [], language: str, + instanceTitle: str, + gender: str) -> str: + """Returns an SSML endpoint for the TTS speaker + https://en.wikipedia.org/wiki/Speech_Synthesis_Markup_Language + https://www.w3.org/TR/speech-synthesis/ + """ + langShort = 'en' + if language: + langShort = language[:2] + if not gender: + gender = 'neutral' + else: + if langShort == 'en': + gender = gender.lower() + if 'he/him' in gender: + gender = 'male' + elif 'she/her' in gender: + gender = 'female' + else: + gender = 'neutral' + + content = _addSSMLemphasis(content) + voiceParams = 'name="' + displayName + '" gender="' + gender + '"' + return _SSMLheader(langShort, instanceTitle) + \ + '

\n' + \ + ' \n' + \ + ' \n' + \ + ' ' + content + '\n' + \ + ' \n' + \ + ' \n' + \ + '

\n' + \ + '
\n' + + +def getSSMLbox(baseDir: str, path: str, + domain: str, + systemLanguage: str, + instanceTitle: str, + boxName: str) -> str: + """Returns SSML for the given timeline + """ + 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): + return None + speakerJson = loadJson(speakerFilename) + if not speakerJson: + return None + gender = None + if speakerJson.get('gender'): + gender = speakerJson['gender'] + return _speakerEndpointSSML(speakerJson['name'], + speakerJson['summary'], + speakerJson['say'], + speakerJson['imageDescription'], + speakerJson['detectedLinks'], + systemLanguage, + instanceTitle, gender) + + +def speakableText(baseDir: str, content: str, translate: {}) -> (str, []): + """Convert the given text to a speakable version + which includes changes for prononciation + """ + content = str(content) + if isPGPEncrypted(content): + return content, [] + + # replace some emoji before removing html + if ' <3' in content: + content = content.replace(' <3', ' ' + translate['heart']) + content = removeHtml(htmlReplaceQuoteMarks(content)) + detectedLinks = [] + content = speakerReplaceLinks(content, translate, detectedLinks) + # replace all double spaces + while ' ' in content: + content = content.replace(' ', ' ') + content = content.replace(' . ', '. ').strip() + sayContent = _speakerPronounce(baseDir, content, translate) + # replace all double spaces + while ' ' in sayContent: + sayContent = sayContent.replace(' ', ' ') + return sayContent.replace(' . ', '. ').strip(), detectedLinks + + +def _postToSpeakerJson(baseDir: str, httpPrefix: str, + nickname: str, domain: str, domainFull: str, + postJsonObject: {}, personCache: {}, + translate: {}, announcingActor: str, + themeName: str) -> {}: + """Converts an ActivityPub post into some Json containing + speech synthesis parameters. + NOTE: There currently appears to be no standardized json + format for speech synthesis + """ + if not hasObjectDict(postJsonObject): + return + if not postJsonObject['object'].get('content'): + return + if not isinstance(postJsonObject['object']['content'], str): + return + detectedLinks = [] + content = urllib.parse.unquote_plus(postJsonObject['object']['content']) + content = html.unescape(content) + content = content.replace('

', '').replace('

', ' ') + if not isPGPEncrypted(content): + # replace some emoji before removing html + if ' <3' in content: + content = content.replace(' <3', ' ' + translate['heart']) + content = removeHtml(htmlReplaceQuoteMarks(content)) + content = speakerReplaceLinks(content, translate, detectedLinks) + # replace all double spaces + while ' ' in content: + content = content.replace(' ', ' ') + content = content.replace(' . ', '. ').strip() + sayContent = content + sayContent = _speakerPronounce(baseDir, content, translate) + # replace all double spaces + while ' ' in sayContent: + sayContent = sayContent.replace(' ', ' ') + sayContent = sayContent.replace(' . ', '. ').strip() + else: + sayContent = content + + imageDescription = '' + if postJsonObject['object'].get('attachment'): + attachList = postJsonObject['object']['attachment'] + if isinstance(attachList, list): + for img in attachList: + if not isinstance(img, dict): + continue + if img.get('name'): + if isinstance(img['name'], str): + imageDescription += \ + img['name'] + '. ' + + isDirect = isDM(postJsonObject) + actor = httpPrefix + '://' + domainFull + '/users/' + nickname + replyToYou = isReply(postJsonObject, actor) + + published = '' + if postJsonObject['object'].get('published'): + published = postJsonObject['object']['published'] + + summary = '' + if postJsonObject['object'].get('summary'): + if isinstance(postJsonObject['object']['summary'], str): + summary = \ + urllib.parse.unquote_plus(postJsonObject['object']['summary']) + summary = html.unescape(summary) + + speakerName = \ + getDisplayName(baseDir, postJsonObject['actor'], personCache) + if not speakerName: + return + speakerName = _removeEmojiFromText(speakerName) + speakerName = speakerName.replace('_', ' ') + speakerName = camelCaseSplit(speakerName) + gender = getGenderFromBio(baseDir, postJsonObject['actor'], + personCache, translate) + if announcingActor: + announcedNickname = getNicknameFromActor(announcingActor) + announcedDomain, announcedport = getDomainFromActor(announcingActor) + if announcedNickname and announcedDomain: + announcedHandle = announcedNickname + '@' + announcedDomain + sayContent = \ + translate['announces'] + ' ' + \ + announcedHandle + '. ' + sayContent + content = \ + translate['announces'] + ' ' + \ + announcedHandle + '. ' + content + postId = None + if postJsonObject['object'].get('id'): + postId = postJsonObject['object']['id'] + + followRequestsExist = False + followRequestsList = [] + accountsDir = acctDir(baseDir, nickname, domainFull) + approveFollowsFilename = accountsDir + '/followrequests.txt' + if os.path.isfile(approveFollowsFilename): + with open(approveFollowsFilename, 'r') as fp: + follows = fp.readlines() + if len(follows) > 0: + followRequestsExist = True + for i in range(len(follows)): + follows[i] = follows[i].strip() + followRequestsList = follows + postDM = False + dmFilename = accountsDir + '/.newDM' + if os.path.isfile(dmFilename): + postDM = True + postReply = False + replyFilename = accountsDir + '/.newReply' + if os.path.isfile(replyFilename): + postReply = True + likedBy = '' + likeFilename = accountsDir + '/.newLike' + if os.path.isfile(likeFilename): + with open(likeFilename, 'r') as fp: + likedBy = fp.read() + calendarFilename = accountsDir + '/.newCalendar' + postCal = os.path.isfile(calendarFilename) + shareFilename = accountsDir + '/.newShare' + postShare = os.path.isfile(shareFilename) + + return _speakerEndpointJson(speakerName, summary, + content, sayContent, imageDescription, + detectedLinks, gender, postId, + postDM, postReply, + followRequestsExist, + followRequestsList, + likedBy, published, + postCal, postShare, themeName, + isDirect, replyToYou) + + +def updateSpeaker(baseDir: str, httpPrefix: str, + nickname: str, domain: str, domainFull: str, + postJsonObject: {}, personCache: {}, + translate: {}, announcingActor: str, + themeName: str) -> None: + """ Generates a json file which can be used for TTS announcement + of incoming inbox posts + """ + speakerJson = \ + _postToSpeakerJson(baseDir, httpPrefix, + nickname, domain, domainFull, + postJsonObject, personCache, + translate, announcingActor, + themeName) + speakerFilename = acctDir(baseDir, nickname, domain) + '/speaker.json' + saveJson(speakerJson, speakerFilename) diff --git a/ssb.py b/ssb.py index 0996a1438..2c2a3af7a 100644 --- a/ssb.py +++ b/ssb.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Profile Metadata" def getSSBAddress(actorJson: {}) -> str: diff --git a/static_analysis b/static_analysis index b237a5261..33e135b83 100755 --- a/static_analysis +++ b/static_analysis @@ -8,17 +8,6 @@ fi echo "Starting static analysis" -for sourceFile in epicyon/*.py -do - result=$($cmd "$sourceFile") - if [ "$result" ]; then - echo '' - echo "$result" - exit 1 - fi - echo -n '.' -done - for sourceFile in *.py do result=$($cmd "$sourceFile") diff --git a/tests.py b/tests.py index 5a4bd233e..54f16e1e7 100644 --- a/tests.py +++ b/tests.py @@ -5,14 +5,18 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Testing" import time import os import shutil import json +import datetime +from random import randint from time import gmtime, strftime from pprint import pprint from httpsig import signPostHeaders +from httpsig import signPostHeadersNew from httpsig import verifyPostHeaders from httpsig import messageContentDigest from cache import storePersonInCache @@ -20,6 +24,7 @@ from cache import getPersonFromCache from threads import threadWithTrace from daemon import runDaemon from session import createSession +from posts import removePostInteractions from posts import getMentionedPeople from posts import validContentWarning from posts import deleteAllPosts @@ -33,12 +38,14 @@ from follow import clearFollows from follow import clearFollowers from follow import sendFollowRequestViaServer from follow import sendUnfollowRequestViaServer +from siteactive import siteIsActive +from utils import userAgentDomain +from utils import camelCaseSplit from utils import decodedHost from utils import getFullDomain from utils import validNickname from utils import firstParagraphFromString from utils import removeIdEnding -from siteactive import siteIsActive from utils import updateRecentPostsCache from utils import followPerson from utils import getNicknameFromActor @@ -50,6 +57,10 @@ from utils import getStatusNumber from utils import getFollowersOfPerson from utils import removeHtml from utils import dangerousMarkup +from utils import acctDir +from pgp import extractPGPPublicKey +from pgp import pgpPublicKeyUpload +from utils import containsPGPPublicKey from follow import followerOfPerson from follow import unfollowAccount from follow import unfollowerOfAccount @@ -59,8 +70,12 @@ from person import setDisplayNickname from person import setBio # from person import generateRSAKey from skills import setSkillLevel +from skills import actorSkillValue +from skills import setSkillsFromDict +from skills import actorHasSkill +from roles import setRolesFromList from roles import setRole -from roles import outboxDelegate +from roles import actorHasRole from auth import constantTimeStringCheck from auth import createBasicAuthHeader from auth import authorizeBasic @@ -69,6 +84,10 @@ from like import likePost from like import sendLikeViaServer from announce import announcePublic from announce import sendAnnounceViaServer +from city import parseNogoString +from city import spoofGeolocation +from city import pointInNogo +from media import getImageDimensions from media import getMediaPath from media import getAttachmentMediaType from delete import sendDeleteViaServer @@ -76,6 +95,9 @@ from inbox import jsonPostAllowsComments from inbox import validInbox from inbox import validInboxFilenames from categories import guessHashtagCategory +from content import limitRepeatedWords +from content import switchWords +from content import extractTextFieldsInPOST from content import validHashTag from content import htmlReplaceEmailQuote from content import htmlReplaceQuoteMarks @@ -87,6 +109,7 @@ from content import removeLongWords from content import replaceContentDuplicates from content import removeTextFormatting from content import removeHtmlTag +from theme import updateDefaultThemesList from theme import setCSSparam from linked_data_sig import generateJsonSignature from linked_data_sig import verifyJsonSignature @@ -94,9 +117,12 @@ from newsdaemon import hashtagRuleTree from newsdaemon import hashtagRuleResolve from newswire import getNewswireTags from newswire import parseFeedDate +from newswire import limitWordLengths from mastoapiv1 import getMastoApiV1IdFromNickname from mastoapiv1 import getNicknameFromMastoApiV1Id from webapp_post import prepareHtmlPostNickname +from speaker import speakerReplaceLinks +from markdown import markdownToHtml testServerAliceRunning = False testServerBobRunning = False @@ -106,6 +132,175 @@ thrBob = None thrEve = None +def _testHttpSigNew(): + print('testHttpSigNew') + messageBodyJson = {"hello": "world"} + messageBodyJsonStr = json.dumps(messageBodyJson) + publicKeyPem = \ + '-----BEGIN RSA PUBLIC KEY-----\n' + \ + 'MIIBCgKCAQEAhAKYdtoeoy8zcAcR874L8' + \ + 'cnZxKzAGwd7v36APp7Pv6Q2jdsPBRrw\n' + \ + 'WEBnez6d0UDKDwGbc6nxfEXAy5mbhgajz' + \ + 'rw3MOEt8uA5txSKobBpKDeBLOsdJKFq\n' + \ + 'MGmXCQvEG7YemcxDTRPxAleIAgYYRjTSd' + \ + '/QBwVW9OwNFhekro3RtlinV0a75jfZg\n' + \ + 'kne/YiktSvLG34lw2zqXBDTC5NHROUqGT' + \ + 'lML4PlNZS5Ri2U4aCNx2rUPRcKIlE0P\n' + \ + 'uKxI4T+HIaFpv8+rdV6eUgOrB2xeI1dSF' + \ + 'Fn/nnv5OoZJEIB+VmuKn3DCUcCZSFlQ\n' + \ + 'PSXSfBDiUGhwOw76WuSSsf1D4b/vLoJ10wIDAQAB\n' + \ + '-----END RSA PUBLIC KEY-----\n' + + privateKeyPem = \ + '-----BEGIN RSA PRIVATE KEY-----\n' + \ + 'MIIEqAIBAAKCAQEAhAKYdtoeoy8zcAcR8' + \ + '74L8cnZxKzAGwd7v36APp7Pv6Q2jdsP\n' + \ + 'BRrwWEBnez6d0UDKDwGbc6nxfEXAy5mbh' + \ + 'gajzrw3MOEt8uA5txSKobBpKDeBLOsd\n' + \ + 'JKFqMGmXCQvEG7YemcxDTRPxAleIAgYYR' + \ + 'jTSd/QBwVW9OwNFhekro3RtlinV0a75\n' + \ + 'jfZgkne/YiktSvLG34lw2zqXBDTC5NHRO' + \ + 'UqGTlML4PlNZS5Ri2U4aCNx2rUPRcKI\n' + \ + 'lE0PuKxI4T+HIaFpv8+rdV6eUgOrB2xeI' + \ + '1dSFFn/nnv5OoZJEIB+VmuKn3DCUcCZ\n' + \ + 'SFlQPSXSfBDiUGhwOw76WuSSsf1D4b/vL' + \ + 'oJ10wIDAQABAoIBAG/JZuSWdoVHbi56\n' + \ + 'vjgCgkjg3lkO1KrO3nrdm6nrgA9P9qaPj' + \ + 'xuKoWaKO1cBQlE1pSWp/cKncYgD5WxE\n' + \ + 'CpAnRUXG2pG4zdkzCYzAh1i+c34L6oZoH' + \ + 'sirK6oNcEnHveydfzJL5934egm6p8DW\n' + \ + '+m1RQ70yUt4uRc0YSor+q1LGJvGQHReF0' + \ + 'WmJBZHrhz5e63Pq7lE0gIwuBqL8SMaA\n' + \ + 'yRXtK+JGxZpImTq+NHvEWWCu09SCq0r83' + \ + '8ceQI55SvzmTkwqtC+8AT2zFviMZkKR\n' + \ + 'Qo6SPsrqItxZWRty2izawTF0Bf5S2VAx7' + \ + 'O+6t3wBsQ1sLptoSgX3QblELY5asI0J\n' + \ + 'YFz7LJECgYkAsqeUJmqXE3LP8tYoIjMIA' + \ + 'KiTm9o6psPlc8CrLI9CH0UbuaA2JCOM\n' + \ + 'cCNq8SyYbTqgnWlB9ZfcAm/cFpA8tYci9' + \ + 'm5vYK8HNxQr+8FS3Qo8N9RJ8d0U5Csw\n' + \ + 'DzMYfRghAfUGwmlWj5hp1pQzAuhwbOXFt' + \ + 'xKHVsMPhz1IBtF9Y8jvgqgYHLbmyiu1\n' + \ + 'mwJ5AL0pYF0G7x81prlARURwHo0Yf52kE' + \ + 'w1dxpx+JXER7hQRWQki5/NsUEtv+8RT\n' + \ + 'qn2m6qte5DXLyn83b1qRscSdnCCwKtKWU' + \ + 'ug5q2ZbwVOCJCtmRwmnP131lWRYfj67\n' + \ + 'B/xJ1ZA6X3GEf4sNReNAtaucPEelgR2ns' + \ + 'N0gKQKBiGoqHWbK1qYvBxX2X3kbPDkv\n' + \ + '9C+celgZd2PW7aGYLCHq7nPbmfDV0yHcW' + \ + 'jOhXZ8jRMjmANVR/eLQ2EfsRLdW69bn\n' + \ + 'f3ZD7JS1fwGnO3exGmHO3HZG+6AvberKY' + \ + 'VYNHahNFEw5TsAcQWDLRpkGybBcxqZo\n' + \ + '81YCqlqidwfeO5YtlO7etx1xLyqa2NsCe' + \ + 'G9A86UjG+aeNnXEIDk1PDK+EuiThIUa\n' + \ + '/2IxKzJKWl1BKr2d4xAfR0ZnEYuRrbeDQ' + \ + 'YgTImOlfW6/GuYIxKYgEKCFHFqJATAG\n' + \ + 'IxHrq1PDOiSwXd2GmVVYyEmhZnbcp8Cxa' + \ + 'EMQoevxAta0ssMK3w6UsDtvUvYvF22m\n' + \ + 'qQKBiD5GwESzsFPy3Ga0MvZpn3D6EJQLg' + \ + 'snrtUPZx+z2Ep2x0xc5orneB5fGyF1P\n' + \ + 'WtP+fG5Q6Dpdz3LRfm+KwBCWFKQjg7uTx' + \ + 'cjerhBWEYPmEMKYwTJF5PBG9/ddvHLQ\n' + \ + 'EQeNC8fHGg4UXU8mhHnSBt3EA10qQJfRD' + \ + 's15M38eG2cYwB1PZpDHScDnDA0=\n' + \ + '-----END RSA PRIVATE KEY-----' + sigInput = \ + 'sig1=(date); alg=rsa-sha256; keyId="test-key-b"' + sig = \ + 'sig1=:HtXycCl97RBVkZi66ADKnC9c5eSSlb57GnQ4KFqNZplOpNfxqk62' + \ + 'JzZ484jXgLvoOTRaKfR4hwyxlcyb+BWkVasApQovBSdit9Ml/YmN2IvJDPncrlhPD' + \ + 'VDv36Z9/DiSO+RNHD7iLXugdXo1+MGRimW1RmYdenl/ITeb7rjfLZ4b9VNnLFtVWw' + \ + 'rjhAiwIqeLjodVImzVc5srrk19HMZNuUejK6I3/MyN3+3U8tIRW4LWzx6ZgGZUaEE' + \ + 'P0aBlBkt7Fj0Tt5/P5HNW/Sa/m8smxbOHnwzAJDa10PyjzdIbywlnWIIWtZKPPsoV' + \ + 'oKVopUWEU3TNhpWmaVhFrUL/O6SN3w==:' + # "hs2019", using RSASSA-PSS [RFC8017] and SHA-512 [RFC6234] + # sigInput = \ + # 'sig1=(*request-target, *created, host, date, ' + \ + # 'cache-control, x-empty-header, x-example); keyId="test-key-a"; ' + \ + # 'alg=hs2019; created=1402170695; expires=1402170995' + # sig = \ + # 'sig1=:K2qGT5srn2OGbOIDzQ6kYT+ruaycnDAAUpKv+ePFfD0RAxn/1BUe' + \ + # 'Zx/Kdrq32DrfakQ6bPsvB9aqZqognNT6be4olHROIkeV879RrsrObury8L9SCEibe' + \ + # 'oHyqU/yCjphSmEdd7WD+zrchK57quskKwRefy2iEC5S2uAH0EPyOZKWlvbKmKu5q4' + \ + # 'CaB8X/I5/+HLZLGvDiezqi6/7p2Gngf5hwZ0lSdy39vyNMaaAT0tKo6nuVw0S1MVg' + \ + # '1Q7MpWYZs0soHjttq0uLIA3DIbQfLiIvK6/l0BdWTU7+2uQj7lBkQAsFZHoA96ZZg' + \ + # 'FquQrXRlmYOh+Hx5D9fJkXcXe5tmAg==:' + nickname = 'foo' + boxpath = '/' + nickname + # headers = { + # "*request-target": "get " + boxpath, + # "*created": "1402170695", + # "host": "example.org", + # "date": "Tue, 07 Jun 2014 20:51:35 GMT", + # "cache-control": "max-age=60, must-revalidate", + # "x-emptyheader": "", + # "x-example": "Example header with some whitespace.", + # "x-dictionary": "b=2", + # "x-dictionary": "a=1", + # "x-list": "(a, b, c)", + # "Signature-Input": sigInput, + # "Signature": sig + # } + dateStr = "Tue, 07 Jun 2014 20:51:35 GMT" + secondsSinceEpoch = 1402174295 + domain = "example.com" + port = 443 + headers = { + "*created": str(secondsSinceEpoch), + "*request-target": "post /foo?param=value&pet=dog", + "host": domain, + "date": dateStr, + "content-type": "application/json", + "digest": "SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=", + "content-length": "18", + "Signature-Input": sigInput, + "Signature": sig + } + httpPrefix = 'https' + debug = False + assert verifyPostHeaders(httpPrefix, publicKeyPem, headers, + boxpath, False, None, + messageBodyJsonStr, debug, True) + # make a deliberate mistake + headers['Signature'] = headers['Signature'].replace('V', 'B') + assert not verifyPostHeaders(httpPrefix, publicKeyPem, headers, + boxpath, False, None, + messageBodyJsonStr, debug, True) + # test signing + bodyDigest = messageContentDigest(messageBodyJsonStr) + contentLength = len(messageBodyJsonStr) + headers = { + "host": domain, + "date": dateStr, + "digest": f'SHA-256={bodyDigest}', + "content-type": "application/json", + "content-length": str(contentLength) + } + signatureIndexHeader, signatureHeader = \ + signPostHeadersNew(dateStr, privateKeyPem, nickname, + domain, port, + domain, port, + boxpath, httpPrefix, messageBodyJsonStr, + 'rsa-sha256') + expectedIndexHeader = \ + 'keyId="https://example.com/users/foo#main-key"; ' + \ + 'alg=hs2019; created=' + str(secondsSinceEpoch) + '; ' + \ + 'sig1=(*request-target, *created, host, date, ' + \ + 'digest, content-type, content-length)' + if signatureIndexHeader != expectedIndexHeader: + print('Unexpected new http header: ' + signatureIndexHeader) + print('Should be: ' + expectedIndexHeader) + assert signatureIndexHeader == expectedIndexHeader + assert signatureHeader == \ + 'sig1=:euX3O1KSTYXN9/oR2qFezswWm9FbrjtRymK7xBpXNQvTs' + \ + 'XehtrNdD8nELZKzPXMvMz7PaJd6V+fjzpHoZ9upTdqqQLK2Iwml' + \ + 'p4BlHqW6Aopd7sZFCWFq7/Amm5oaizpp3e0jb5XISS5m3cRKuoi' + \ + 'LM0x+OudmAoYGi0TEEJk8bpnJAXfVCDfmOyL3XNqQeShQHeOANG' + \ + 'okiKktj8ff+KLYLaPTAJkob1k/EhoPIkbw/YzAY8IZjWQNMkf+F' + \ + 'JChApQ5HnDCQPwD5xV9eGzBpAf6D0G19xiTmQye4Hn6tAs3fy3V' + \ + '/aYa/GhW2pSrctDnAKIi4imj9joppr3CB8gqgXZOPQ==:' + + def _testHttpsigBase(withDigest): print('testHttpsig(' + str(withDigest) + ')') @@ -210,12 +405,12 @@ def _testHttpsigBase(withDigest): shutil.rmtree(path) -def testHttpsig(): +def _testHttpsig(): _testHttpsigBase(True) _testHttpsigBase(False) -def testCache(): +def _testCache(): print('testCache') personUrl = "cat@cardboard.box" personJson = { @@ -229,15 +424,15 @@ def testCache(): assert result['test'] == 'This is a test' -def testThreadsFunction(param: str): +def _testThreadsFunction(param: str): for i in range(10000): time.sleep(2) -def testThreads(): +def _testThreads(): print('testThreads') thr = \ - threadWithTrace(target=testThreadsFunction, + threadWithTrace(target=_testThreadsFunction, args=('test',), daemon=True) thr.start() @@ -271,7 +466,7 @@ def createServerAlice(path: str, domain: str, port: int, deleteAllPosts(path, nickname, domain, 'inbox') deleteAllPosts(path, nickname, domain, 'outbox') assert setSkillLevel(path, nickname, domain, 'hacking', 90) - assert setRole(path, nickname, domain, 'someproject', 'guru') + assert setRole(path, nickname, domain, 'guru') if hasFollows: followPerson(path, nickname, domain, 'bob', bobAddress, federationList, False) @@ -285,6 +480,15 @@ def createServerAlice(path: str, domain: str, port: int, testAttachImageFilename = None testMediaType = None testImageDescription = None + testCity = 'London, England' + testInReplyTo = None + testInReplyToAtomUri = None + testSubject = None + testSchedulePost = False + testEventDate = None + testEventTime = None + testLocation = None + testIsArticle = False createPublicPost(path, nickname, domain, port, httpPrefix, "No wise fish would go anywhere without a porpoise", testFollowersOnly, @@ -293,7 +497,11 @@ def createServerAlice(path: str, domain: str, port: int, testCommentsEnabled, testAttachImageFilename, testMediaType, - testImageDescription) + testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) createPublicPost(path, nickname, domain, port, httpPrefix, "Curiouser and curiouser!", testFollowersOnly, @@ -302,7 +510,11 @@ def createServerAlice(path: str, domain: str, port: int, testCommentsEnabled, testAttachImageFilename, testMediaType, - testImageDescription) + testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) createPublicPost(path, nickname, domain, port, httpPrefix, "In the gardens of memory, in the palace " + "of dreams, that is where you and I shall meet", @@ -312,7 +524,11 @@ def createServerAlice(path: str, domain: str, port: int, testCommentsEnabled, testAttachImageFilename, testMediaType, - testImageDescription) + testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) global testServerAliceRunning testServerAliceRunning = True maxMentions = 10 @@ -326,8 +542,17 @@ def createServerAlice(path: str, domain: str, port: int, maxFollowers = 10 verifyAllSignatures = True brochMode = False + showNodeInfoAccounts = True + showNodeInfoVersion = True + city = 'London, England' + logLoginFailures = False + userAgentsBlocked = [] print('Server running: Alice') - runDaemon(brochMode, + runDaemon(userAgentsBlocked, + logLoginFailures, city, + showNodeInfoAccounts, + showNodeInfoVersion, + brochMode, verifyAllSignatures, sendThreadsTimeoutMins, dormantMonths, maxNewswirePosts, @@ -368,8 +593,6 @@ def createServerBob(path: str, domain: str, port: int, False, password) deleteAllPosts(path, nickname, domain, 'inbox') deleteAllPosts(path, nickname, domain, 'outbox') - assert setRole(path, nickname, domain, 'bandname', 'bass player') - assert setRole(path, nickname, domain, 'bandname', 'publicist') if hasFollows: followPerson(path, nickname, domain, 'alice', aliceAddress, federationList, False) @@ -382,6 +605,15 @@ def createServerBob(path: str, domain: str, port: int, testAttachImageFilename = None testImageDescription = None testMediaType = None + testCity = 'London, England' + testInReplyTo = None + testInReplyToAtomUri = None + testSubject = None + testSchedulePost = False + testEventDate = None + testEventTime = None + testLocation = None + testIsArticle = False createPublicPost(path, nickname, domain, port, httpPrefix, "It's your life, live it your way.", testFollowersOnly, @@ -390,7 +622,11 @@ def createServerBob(path: str, domain: str, port: int, testCommentsEnabled, testAttachImageFilename, testMediaType, - testImageDescription) + testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) createPublicPost(path, nickname, domain, port, httpPrefix, "One of the things I've realised is that " + "I am very simple", @@ -400,7 +636,11 @@ def createServerBob(path: str, domain: str, port: int, testCommentsEnabled, testAttachImageFilename, testMediaType, - testImageDescription) + testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) createPublicPost(path, nickname, domain, port, httpPrefix, "Quantum physics is a bit of a passion of mine", testFollowersOnly, @@ -409,7 +649,11 @@ def createServerBob(path: str, domain: str, port: int, testCommentsEnabled, testAttachImageFilename, testMediaType, - testImageDescription) + testImageDescription, testCity, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) global testServerBobRunning testServerBobRunning = True maxMentions = 10 @@ -423,8 +667,17 @@ def createServerBob(path: str, domain: str, port: int, maxFollowers = 10 verifyAllSignatures = True brochMode = False + showNodeInfoAccounts = True + showNodeInfoVersion = True + city = 'London, England' + logLoginFailures = False + userAgentsBlocked = [] print('Server running: Bob') - runDaemon(brochMode, + runDaemon(userAgentsBlocked, + logLoginFailures, city, + showNodeInfoAccounts, + showNodeInfoVersion, + brochMode, verifyAllSignatures, sendThreadsTimeoutMins, dormantMonths, maxNewswirePosts, @@ -474,8 +727,17 @@ def createServerEve(path: str, domain: str, port: int, federationList: [], maxFollowers = 10 verifyAllSignatures = True brochMode = False + showNodeInfoAccounts = True + showNodeInfoVersion = True + city = 'London, England' + logLoginFailures = False + userAgentsBlocked = [] print('Server running: Eve') - runDaemon(brochMode, + runDaemon(userAgentsBlocked, + logLoginFailures, city, + showNodeInfoAccounts, + showNodeInfoVersion, + brochMode, verifyAllSignatures, sendThreadsTimeoutMins, dormantMonths, maxNewswirePosts, @@ -575,9 +837,14 @@ def testPostMessageBetweenServers(): alicePersonCache = {} aliceCachedWebfingers = {} attachedImageFilename = baseDir + '/img/logo.png' + testImageWidth, testImageHeight = \ + getImageDimensions(attachedImageFilename) + assert testImageWidth + assert testImageHeight mediaType = getAttachmentMediaType(attachedImageFilename) attachedImageDescription = 'Logo' isArticle = False + city = 'London, England' # nothing in Alice's outbox outboxPath = aliceDir + '/accounts/alice@' + aliceDomain + '/outbox' assert len([name for name in os.listdir(outboxPath) @@ -592,7 +859,7 @@ def testPostMessageBetweenServers(): followersOnly, saveToFile, clientToServer, True, attachedImageFilename, mediaType, - attachedImageDescription, federationList, + attachedImageDescription, city, federationList, aliceSendThreads, alicePostLog, aliceCachedWebfingers, alicePersonCache, isArticle, inReplyTo, inReplyToAtomUri, subject) @@ -657,6 +924,20 @@ def testPostMessageBetweenServers(): assert 'Why is a mouse when it spins?' in \ receivedJson['object']['content'] assert 'यह एक परीक्षण है' in receivedJson['object']['content'] + print('Check that message received from Alice contains an attachment') + assert receivedJson['object']['attachment'] + assert len(receivedJson['object']['attachment']) == 1 + attached = receivedJson['object']['attachment'][0] + pprint(attached) + assert attached.get('type') + assert attached.get('url') + assert attached['mediaType'] == 'image/png' + assert '/media/' in attached['url'] + assert attached['url'].endswith('.png') + assert attached.get('width') + assert attached.get('height') + assert attached['width'] > 0 + assert attached['height'] > 0 print('\n\n*******************************************************') print("Bob likes Alice's post") @@ -895,13 +1176,14 @@ def testFollowBetweenServers(): aliceCachedWebfingers = {} alicePostLog = [] isArticle = False + city = 'London, England' sendResult = \ sendPost(__version__, sessionAlice, aliceDir, 'alice', aliceDomain, alicePort, 'bob', bobDomain, bobPort, ccUrl, httpPrefix, 'Alice message', followersOnly, saveToFile, clientToServer, True, - None, None, None, federationList, + None, None, None, city, federationList, aliceSendThreads, alicePostLog, aliceCachedWebfingers, alicePersonCache, isArticle, inReplyTo, inReplyToAtomUri, subject) @@ -940,7 +1222,7 @@ def testFollowBetweenServers(): shutil.rmtree(baseDir + '/.tests') -def testFollowersOfPerson(): +def _testFollowersOfPerson(): print('testFollowersOfPerson') currDir = os.getcwd() nickname = 'mxpop' @@ -989,7 +1271,7 @@ def testFollowersOfPerson(): shutil.rmtree(baseDir) -def testNoOfFollowersOnDomain(): +def _testNoOfFollowersOnDomain(): print('testNoOfFollowersOnDomain') currDir = os.getcwd() nickname = 'mxpop' @@ -1050,7 +1332,7 @@ def testNoOfFollowersOnDomain(): shutil.rmtree(baseDir) -def testGroupFollowers(): +def _testGroupFollowers(): print('testGroupFollowers') currDir = os.getcwd() @@ -1095,7 +1377,7 @@ def testGroupFollowers(): shutil.rmtree(baseDir) -def testFollows(): +def _testFollows(): print('testFollows') currDir = os.getcwd() nickname = 'test529' @@ -1124,8 +1406,8 @@ def testFollows(): followPerson(baseDir, nickname, domain, 'giraffe', 'trees.com', federationList, False) - f = open(baseDir + '/accounts/' + nickname + '@' + domain + - '/following.txt', "r") + accountDir = acctDir(baseDir, nickname, domain) + f = open(accountDir + '/following.txt', 'r') domainFound = False for followingDomain in f: testDomain = followingDomain.split('@')[1] @@ -1159,8 +1441,8 @@ def testFollows(): followerOfPerson(baseDir, nickname, domain, 'giraffe', 'trees.com', federationList, False) - f = open(baseDir + '/accounts/' + nickname + '@' + domain + - '/followers.txt', "r") + accountDir = acctDir(baseDir, nickname, domain) + f = open(accountDir + '/followers.txt', 'r') for followerDomain in f: testDomain = followerDomain.split('@')[1] testDomain = testDomain.replace('\n', '').replace('\r', '') @@ -1172,7 +1454,7 @@ def testFollows(): shutil.rmtree(baseDir) -def testCreatePerson(): +def _testCreatePerson(): print('testCreatePerson') currDir = os.getcwd() nickname = 'test382' @@ -1197,90 +1479,34 @@ def testCreatePerson(): setBio(baseDir, nickname, domain, 'Randomly roaming in your backyard') archivePostsForPerson(nickname, domain, baseDir, 'inbox', None, {}, 4) archivePostsForPerson(nickname, domain, baseDir, 'outbox', None, {}, 4) + testInReplyTo = None + testInReplyToAtomUri = None + testSubject = None + testSchedulePost = False + testEventDate = None + testEventTime = None + testLocation = None + testIsArticle = False + content = "G'day world!" + followersOnly = False + saveToFile = True + commentsEnabled = True + attachImageFilename = None + mediaType = None createPublicPost(baseDir, nickname, domain, port, httpPrefix, - "G'day world!", False, True, clientToServer, - True, None, None, None, None, - 'Not suitable for Vogons') + content, followersOnly, saveToFile, clientToServer, + commentsEnabled, attachImageFilename, mediaType, + 'Not suitable for Vogons', 'London, England', + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) os.chdir(currDir) shutil.rmtree(baseDir) -def testDelegateRoles(): - print('testDelegateRoles') - currDir = os.getcwd() - nickname = 'test382' - nicknameDelegated = 'test383' - domain = 'badgerdomain.com' - password = 'mypass' - port = 80 - httpPrefix = 'https' - baseDir = currDir + '/.tests_delegaterole' - if os.path.isdir(baseDir): - shutil.rmtree(baseDir) - os.mkdir(baseDir) - os.chdir(baseDir) - - privateKeyPem, publicKeyPem, person, wfEndpoint = \ - createPerson(baseDir, nickname, domain, port, - httpPrefix, True, False, password) - privateKeyPem, publicKeyPem, person, wfEndpoint = \ - createPerson(baseDir, nicknameDelegated, domain, port, - httpPrefix, True, False, 'insecure') - - httpPrefix = 'http' - project = 'artechoke' - role = 'delegator' - actorDelegated = \ - httpPrefix + '://' + domain + '/users/' + nicknameDelegated - newRoleJson = { - 'type': 'Delegate', - 'actor': httpPrefix + '://' + domain + '/users/' + nickname, - 'object': { - 'type': 'Role', - 'actor': actorDelegated, - 'object': project + ';' + role, - 'to': [], - 'cc': [] - }, - 'to': [], - 'cc': [] - } - - assert outboxDelegate(baseDir, nickname, newRoleJson, False) - # second time delegation has already happened so should return false - assert outboxDelegate(baseDir, nickname, newRoleJson, False) is False - - assert '"delegator"' in open(baseDir + '/accounts/' + nickname + - '@' + domain + '.json').read() - assert '"delegator"' in open(baseDir + '/accounts/' + nicknameDelegated + - '@' + domain + '.json').read() - - newRoleJson = { - 'type': 'Delegate', - 'actor': httpPrefix + '://' + domain + '/users/' + nicknameDelegated, - 'object': { - 'type': 'Role', - 'actor': httpPrefix + '://' + domain + '/users/' + nickname, - 'object': 'otherproject;otherrole', - 'to': [], - 'cc': [] - }, - 'to': [], - 'cc': [] - } - - # non-delegators cannot assign roles - assert outboxDelegate(baseDir, nicknameDelegated, - newRoleJson, False) is False - assert '"otherrole"' not in open(baseDir + '/accounts/' + - nickname + '@' + domain + '.json').read() - - os.chdir(currDir) - shutil.rmtree(baseDir) - - -def testAuthentication(): +def _testAuthentication(): print('testAuthentication') currDir = os.getcwd() nickname = 'test8743' @@ -1400,9 +1626,10 @@ def testClientToServer(): sessionAlice = createSession(proxyType) followersOnly = False - attachedImageFilename = baseDir+'/img/logo.png' + attachedImageFilename = baseDir + '/img/logo.png' mediaType = getAttachmentMediaType(attachedImageFilename) attachedImageDescription = 'Logo' + city = 'London, England' isArticle = False cachedWebfingers = {} personCache = {} @@ -1421,7 +1648,7 @@ def testClientToServer(): httpPrefix, 'Sent from my ActivityPub client', followersOnly, True, attachedImageFilename, mediaType, - attachedImageDescription, + attachedImageDescription, city, cachedWebfingers, personCache, isArticle, True, None, None, None) print('sendResult: ' + str(sendResult)) @@ -1686,7 +1913,7 @@ def testClientToServer(): # shutil.rmtree(bobDir) -def testActorParsing(): +def _testActorParsing(): print('testActorParsing') actor = 'https://mydomain:72/users/mynick' domain, port = getDomainFromActor(actor) @@ -1733,9 +1960,25 @@ def testActorParsing(): assert nickname == 'othernick' -def testWebLinks(): +def _testWebLinks(): print('testWebLinks') + exampleText = \ + "

Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \ + " #turbot #haddock

" + resultText = removeLongWords(exampleText, 40, []) + assert resultText == "

Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \ + " #turbot " + \ + "#haddock

" + exampleText = \ '

@foo Some ' + \ @@ -1844,7 +2087,7 @@ def testWebLinks(): assert resultText == exampleText -def testAddEmoji(): +def _testAddEmoji(): print('testAddEmoji') content = "Emoji :lemon: :strawberry: :banana:" httpPrefix = 'http' @@ -1884,14 +2127,14 @@ def testAddEmoji(): tags.append(tag) content = contentModified contentModified = replaceEmojiFromTags(content, tags, 'content') - # print('contentModified: '+contentModified) + # print('contentModified: ' + contentModified) assert contentModified == '

Emoji 🍋 🍓 🍌

' os.chdir(baseDirOriginal) shutil.rmtree(baseDirOriginal + '/.tests') -def testGetStatusNumber(): +def _testGetStatusNumber(): print('testGetStatusNumber') prevStatusNumber = None for i in range(1, 20): @@ -1902,7 +2145,7 @@ def testGetStatusNumber(): prevStatusNumber = int(statusNumber) -def testJsonString() -> None: +def _testJsonString() -> None: print('testJsonString') filename = '.epicyon_tests_testJsonString.json' messageStr = "Crème brûlée यह एक परीक्षण ह" @@ -1918,7 +2161,7 @@ def testJsonString() -> None: os.remove(filename) -def testSaveLoadJson(): +def _testSaveLoadJson(): print('testSaveLoadJson') testJson = { "param1": 3, @@ -1938,7 +2181,7 @@ def testSaveLoadJson(): os.remove(testFilename) -def testTheme(): +def _testTheme(): print('testTheme') css = 'somestring --background-value: 24px; --foreground-value: 24px;' result = setCSSparam(css, 'background-value', '32px') @@ -1956,14 +2199,14 @@ def testTheme(): assert result == '--background-value: 32px; --foreground-value: 24px;' -def testRecentPostsCache(): +def _testRecentPostsCache(): print('testRecentPostsCache') recentPostsCache = {} maxRecentPosts = 3 htmlStr = '' for i in range(5): postJsonObject = { - "id": "https://somesite.whatever/users/someuser/statuses/"+str(i) + "id": "https://somesite.whatever/users/someuser/statuses/" + str(i) } updateRecentPostsCache(recentPostsCache, maxRecentPosts, postJsonObject, htmlStr) @@ -1972,7 +2215,7 @@ def testRecentPostsCache(): assert len(recentPostsCache['html'].items()) == maxRecentPosts -def testRemoveTextFormatting(): +def _testRemoveTextFormatting(): print('testRemoveTextFormatting') testStr = '

Text without formatting

' resultStr = removeTextFormatting(testStr) @@ -1982,7 +2225,7 @@ def testRemoveTextFormatting(): assert(resultStr == '

Text with formatting

') -def testJsonld(): +def _testJsonld(): print("testJsonld") jldDocument = { @@ -2071,22 +2314,31 @@ def testJsonld(): signedDocument2['signature']['signatureValue']) -def testSiteIsActive(): +def _testSiteIsActive(): print('testSiteIsActive') assert(siteIsActive('https://archive.org')) assert(siteIsActive('https://mastodon.social')) assert(not siteIsActive('https://notarealwebsite.a.b.c')) -def testRemoveHtml(): +def _testRemoveHtml(): print('testRemoveHtml') testStr = 'This string has no html.' assert(removeHtml(testStr) == testStr) testStr = 'This string has html.' assert(removeHtml(testStr) == 'This string has html.') + testStr = '' + assert(removeHtml(testStr) == 'This string has. Two labels.') + testStr = '

This string has.

Two paragraphs.

' + assert(removeHtml(testStr) == 'This string has.\n\nTwo paragraphs.') + testStr = 'This string has.
A new line.' + assert(removeHtml(testStr) == 'This string has.\nA new line.') + testStr = '

This string contains a url http://somesite.or.other

' + assert(removeHtml(testStr) == + 'This string contains a url http://somesite.or.other') -def testDangerousCSS(): +def _testDangerousCSS(): print('testDangerousCSS') baseDir = os.getcwd() for subdir, dirs, files in os.walk(baseDir): @@ -2097,7 +2349,7 @@ def testDangerousCSS(): break -def testDangerousMarkup(): +def _testDangerousMarkup(): print('testDangerousMarkup') allowLocalNetworkAccess = False content = '

This is a valid message

' @@ -2111,6 +2363,11 @@ def testDangerousMarkup(): '.innerHTML = "evil";

' assert(dangerousMarkup(content, allowLocalNetworkAccess)) + content = '

This is a valid-looking message. But wait... ' + \ + '<script>document.getElementById("concentrated")' + \ + '.innerHTML = "evil";</script>

' + assert(dangerousMarkup(content, allowLocalNetworkAccess)) + content = '

This html contains more than you expected... ' + \ '

' @@ -2154,7 +2411,7 @@ def testDangerousMarkup(): assert(not dangerousMarkup(content, allowLocalNetworkAccess)) -def runHtmlReplaceQuoteMarks(): +def _runHtmlReplaceQuoteMarks(): print('htmlReplaceQuoteMarks') testStr = 'The "cat" "sat" on the mat' result = htmlReplaceQuoteMarks(testStr) @@ -2173,7 +2430,7 @@ def runHtmlReplaceQuoteMarks(): assert result == '“hello” “test” html' -def testJsonPostAllowsComments(): +def _testJsonPostAllowsComments(): print('testJsonPostAllowsComments') postJsonObject = { "id": "123" @@ -2184,6 +2441,16 @@ def testJsonPostAllowsComments(): "commentsEnabled": False } assert not jsonPostAllowsComments(postJsonObject) + postJsonObject = { + "id": "123", + "rejectReplies": False + } + assert jsonPostAllowsComments(postJsonObject) + postJsonObject = { + "id": "123", + "rejectReplies": True + } + assert not jsonPostAllowsComments(postJsonObject) postJsonObject = { "id": "123", "commentsEnabled": True @@ -2205,7 +2472,7 @@ def testJsonPostAllowsComments(): assert not jsonPostAllowsComments(postJsonObject) -def testRemoveIdEnding(): +def _testRemoveIdEnding(): print('testRemoveIdEnding') testStr = 'https://activitypub.somedomain.net' resultStr = removeIdEnding(testStr) @@ -2231,7 +2498,7 @@ def testRemoveIdEnding(): 'https://event.somedomain.net/users/foo/statuses/34544814814' -def testValidContentWarning(): +def _testValidContentWarning(): print('testValidContentWarning') resultStr = validContentWarning('Valid content warning') assert resultStr == 'Valid content warning' @@ -2244,7 +2511,7 @@ def testValidContentWarning(): assert resultStr == 'Invalid content warning' -def testTranslations(): +def _testTranslations(): print('testTranslations') languagesStr = ('ar', 'ca', 'cy', 'de', 'es', 'fr', 'ga', 'hi', 'it', 'ja', 'oc', 'pt', 'ru', 'zh') @@ -2270,7 +2537,7 @@ def testTranslations(): assert langJson.get(englishStr) -def testConstantTimeStringCheck(): +def _testConstantTimeStringCheck(): print('testConstantTimeStringCheck') assert constantTimeStringCheck('testing', 'testing') assert not constantTimeStringCheck('testing', '1234') @@ -2308,7 +2575,7 @@ def testConstantTimeStringCheck(): assert int(timeDiffMicroseconds) < 10 -def testReplaceEmailQuote(): +def _testReplaceEmailQuote(): print('testReplaceEmailQuote') testStr = '

This content has no quote.

' assert htmlReplaceEmailQuote(testStr) == testStr @@ -2366,7 +2633,7 @@ def testReplaceEmailQuote(): assert resultStr == expectedStr -def testRemoveHtmlTag(): +def _testRemoveHtmlTag(): print('testRemoveHtmlTag') testStr = "

" @@ -2375,7 +2642,7 @@ def testRemoveHtmlTag(): "src=\"https://somesiteorother.com/image.jpg\">

" -def testHashtagRuleTree(): +def _testHashtagRuleTree(): print('testHashtagRuleTree') operators = ('not', 'and', 'or', 'xor', 'from', 'contains') @@ -2507,7 +2774,7 @@ def testHashtagRuleTree(): assert not hashtagRuleResolve(tree, hashtags, moderated, content, url) -def testGetNewswireTags(): +def _testGetNewswireTags(): print('testGetNewswireTags') rssDescription = '#ExcitingHashtagThis is a test

' + \ '

This is another paragraph

' resultStr = firstParagraphFromString(testStr) + if resultStr != 'This is a test': + print(resultStr) assert resultStr == 'This is a test' testStr = 'Testing without html' @@ -2534,7 +2803,7 @@ def testFirstParagraphFromString(): assert resultStr == testStr -def testParseFeedDate(): +def _testParseFeedDate(): print('testParseFeedDate') pubDate = "2020-12-14T00:08:06+00:00" @@ -2554,7 +2823,7 @@ def testParseFeedDate(): assert publishedDate == "2020-11-22 18:51:33+00:00" -def testValidNickname(): +def _testValidNickname(): print('testValidNickname') domain = 'somedomain.net' @@ -2571,20 +2840,20 @@ def testValidNickname(): assert not validNickname(domain, nickname) -def testGuessHashtagCategory() -> None: +def _testGuessHashtagCategory() -> None: print('testGuessHashtagCategory') hashtagCategories = { "foo": ["swan", "goose"], - "bar": ["cat", "mouse"] + "bar": ["cats", "mouse"] } guess = guessHashtagCategory("unspecifiedgoose", hashtagCategories) assert guess == "foo" - guess = guessHashtagCategory("catpic", hashtagCategories) + guess = guessHashtagCategory("mastocats", hashtagCategories) assert guess == "bar" -def testGetMentionedPeople() -> None: +def _testGetMentionedPeople() -> None: print('testGetMentionedPeople') baseDir = os.getcwd() @@ -2598,18 +2867,39 @@ def testGetMentionedPeople() -> None: assert actors[1] == "https://cave.site/users/bat" -def testReplyToPublicPost() -> None: +def _testReplyToPublicPost() -> None: baseDir = os.getcwd() nickname = 'test7492362' domain = 'other.site' port = 443 httpPrefix = 'https' postId = httpPrefix + '://rat.site/users/ninjarodent/statuses/63746173435' + content = "@ninjarodent@rat.site This is a test." + followersOnly = False + saveToFile = False + clientToServer = False + commentsEnabled = True + attachImageFilename = None + mediaType = None + imageDescription = 'Some description' + city = 'London, England' + testInReplyToAtomUri = None + testSubject = None + testSchedulePost = False + testEventDate = None + testEventTime = None + testLocation = None + testIsArticle = False reply = \ createPublicPost(baseDir, nickname, domain, port, httpPrefix, - "@ninjarodent@rat.site This is a test.", - False, False, False, True, - None, None, False, postId) + content, followersOnly, saveToFile, + clientToServer, commentsEnabled, + attachImageFilename, mediaType, + imageDescription, city, postId, + testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) # print(str(reply)) assert reply['object']['content'] == \ '

' + \ @@ -2630,7 +2920,7 @@ def testReplyToPublicPost() -> None: httpPrefix + '://rat.site/users/ninjarodent' -def getFunctionCallArgs(name: str, lines: [], startLineCtr: int) -> []: +def _getFunctionCallArgs(name: str, lines: [], startLineCtr: int) -> []: """Returns the arguments of a function call given lines of source code and a starting line number """ @@ -2668,7 +2958,7 @@ def getFunctionCalls(name: str, lines: [], startLineCtr: int, return callsFunctions -def functionArgsMatch(callArgs: [], funcArgs: []): +def _functionArgsMatch(callArgs: [], funcArgs: []): """Do the function artuments match the function call arguments """ if len(callArgs) == len(funcArgs): @@ -2692,11 +2982,98 @@ def functionArgsMatch(callArgs: [], funcArgs: []): return callArgsCtr >= funcArgsCtr -def testFunctions(): +def _moduleInGroups(modName: str, includeGroups: [], modGroups: {}) -> bool: + """Is the given module within the included groups list? + """ + for groupName in includeGroups: + if modName in modGroups[groupName]: + return True + return False + + +def _diagramGroups(includeGroups: [], + excludeExtraModules: [], + modules: {}, modGroups: {}, + maxModuleCalls: int) -> None: + """Draws a dot diagram containing only the given module groups + """ + callGraphStr = 'digraph EpicyonGroups {\n\n' + callGraphStr += ' graph [fontsize=10 fontname="Verdana" compound=true];\n' + callGraphStr += ' node [fontsize=10 fontname="Verdana"];\n\n' + excludeModulesFromDiagram = [ + 'setup', 'tests', '__init__', 'pyjsonld' + ] + excludeModulesFromDiagram += excludeExtraModules + # colors of modules nodes + for modName, modProperties in modules.items(): + if modName in excludeModulesFromDiagram: + continue + if not _moduleInGroups(modName, includeGroups, modGroups): + continue + if not modProperties.get('calls'): + callGraphStr += ' "' + modName + \ + '" [fillcolor=yellow style=filled];\n' + continue + if len(modProperties['calls']) <= int(maxModuleCalls / 8): + callGraphStr += ' "' + modName + \ + '" [fillcolor=green style=filled];\n' + elif len(modProperties['calls']) < int(maxModuleCalls / 4): + callGraphStr += ' "' + modName + \ + '" [fillcolor=orange style=filled];\n' + else: + callGraphStr += ' "' + modName + \ + '" [fillcolor=red style=filled];\n' + callGraphStr += '\n' + # connections between modules + for modName, modProperties in modules.items(): + if modName in excludeModulesFromDiagram: + continue + if not _moduleInGroups(modName, includeGroups, modGroups): + continue + if not modProperties.get('calls'): + continue + for modCall in modProperties['calls']: + if modCall in excludeModulesFromDiagram: + continue + if not _moduleInGroups(modCall, includeGroups, modGroups): + continue + callGraphStr += ' "' + modName + '" -> "' + modCall + '";\n' + # module groups/clusters + clusterCtr = 1 + for groupName, groupModules in modGroups.items(): + if groupName not in includeGroups: + continue + callGraphStr += '\n' + callGraphStr += \ + ' subgraph cluster_' + str(clusterCtr) + ' {\n' + callGraphStr += ' node [style=filled];\n' + for modName in groupModules: + if modName not in excludeModulesFromDiagram: + callGraphStr += ' ' + modName + ';\n' + callGraphStr += ' label = "' + groupName + '";\n' + callGraphStr += ' color = blue;\n' + callGraphStr += ' }\n' + clusterCtr += 1 + callGraphStr += '\n}\n' + filename = 'epicyon_groups' + for groupName in includeGroups: + filename += '_' + groupName.replace(' ', '-') + filename += '.dot' + with open(filename, 'w+') as fp: + fp.write(callGraphStr) + print('Graph saved to ' + filename) + print('Plot using: ' + + 'sfdp -x -Goverlap=false -Goverlap_scaling=2 ' + + '-Gsep=+100 -Tx11 epicyon_modules.dot') + + +def _testFunctions(): print('testFunctions') function = {} functionProperties = {} modules = {} + modGroups = {} + methodLOC = [] for subdir, dirs, files in os.walk('.'): for sourceFile in files: @@ -2707,15 +3084,50 @@ def testFunctions(): 'functions': [] } sourceStr = '' - with open(sourceFile, "r") as f: + with open(sourceFile, 'r') as f: sourceStr = f.read() modules[modName]['source'] = sourceStr - with open(sourceFile, "r") as f: + with open(sourceFile, 'r') as f: lines = f.readlines() modules[modName]['lines'] = lines + lineCount = 0 + prevLine = 'start' + methodName = '' for line in lines: + if '__module_group__' in line: + if '=' in line: + groupName = line.split('=')[1].strip() + groupName = groupName.replace('"', '') + groupName = groupName.replace("'", '') + modules[modName]['group'] = groupName + if not modGroups.get(groupName): + modGroups[groupName] = [modName] + else: + if modName not in modGroups[groupName]: + modGroups[groupName].append(modName) if not line.strip().startswith('def '): + if lineCount > 0: + lineCount += 1 + # add LOC count for this function + if len(prevLine.strip()) == 0 and \ + len(line.strip()) == 0 and \ + lineCount > 2: + lineCount -= 2 + if lineCount > 80: + locStr = str(lineCount) + ';' + methodName + if lineCount < 1000: + locStr = '0' + locStr + if lineCount < 100: + locStr = '0' + locStr + if lineCount < 10: + locStr = '0' + locStr + if locStr not in methodLOC: + methodLOC.append(locStr) + lineCount = 0 + prevLine = line continue + prevLine = line + lineCount = 1 methodName = line.split('def ', 1)[1].split('(')[0] methodArgs = \ sourceStr.split('def ' + methodName + '(')[1] @@ -2732,8 +3144,26 @@ def testFunctions(): "module": modName, "calledInModule": [] } + # LOC count for the last function + if lineCount > 2: + lineCount -= 2 + if lineCount > 80: + locStr = str(lineCount) + ';' + methodName + if lineCount < 1000: + locStr = '0' + locStr + if lineCount < 100: + locStr = '0' + locStr + if lineCount < 10: + locStr = '0' + locStr + if locStr not in methodLOC: + methodLOC.append(locStr) break + print('LOC counts:') + methodLOC.sort() + for locStr in methodLOC: + print(locStr.split(';')[0] + ' ' + locStr.split(';')[1]) + excludeFuncArgs = [ 'pyjsonld' ] @@ -2767,11 +3197,11 @@ def testFunctions(): lineCtr += 1 continue callArgs = \ - getFunctionCallArgs(name, - modules[modName]['lines'], - lineCtr) - if not functionArgsMatch(callArgs, - functionProperties[name]['args']): + _getFunctionCallArgs(name, + modules[modName]['lines'], + lineCtr) + funcArgs = functionProperties[name]['args'] + if not _functionArgsMatch(callArgs, funcArgs): print('Call to function ' + name + ' does not match its arguments') print('def args: ' + @@ -2820,13 +3250,14 @@ def testFunctions(): 'runSharesExpireWatchdog', 'getThisWeeksEvents', 'getAvailability', - 'testThreadsFunction', + '_testThreadsFunction', 'createServerAlice', 'createServerBob', 'createServerEve', 'E2EEremoveDevice', 'setOrganizationScheme', - 'fill_headers' + 'fill_headers', + '_nothing' ] excludeImports = [ 'link', @@ -2928,97 +3359,37 @@ def testFunctions(): else: modules[modName]['calls'] = [modCall] lineCtr += 1 - callGraphStr = 'digraph EpicyonModules {\n\n' - callGraphStr += ' graph [fontsize=10 fontname="Verdana" compound=true];\n' - callGraphStr += ' node [shape=record fontsize=10 fontname="Verdana"];\n\n' - # colors of modules nodes - for modName, modProperties in modules.items(): - if not modProperties.get('calls'): - callGraphStr += ' "' + modName + \ - '" [fillcolor=yellow style=filled];\n' - continue - if len(modProperties['calls']) <= int(maxModuleCalls / 8): - callGraphStr += ' "' + modName + \ - '" [fillcolor=green style=filled];\n' - elif len(modProperties['calls']) < int(maxModuleCalls / 4): - callGraphStr += ' "' + modName + \ - '" [fillcolor=orange style=filled];\n' - else: - callGraphStr += ' "' + modName + \ - '" [fillcolor=red style=filled];\n' - callGraphStr += '\n' - # connections between modules - for modName, modProperties in modules.items(): - if not modProperties.get('calls'): - continue - for modCall in modProperties['calls']: - callGraphStr += ' "' + modName + '" -> "' + modCall + '";\n' - callGraphStr += '\n}\n' - with open('epicyon_modules.dot', 'w+') as fp: - fp.write(callGraphStr) - print('Modules call graph saved to epicyon_modules.dot') - print('Plot using: ' + - 'sfdp -x -Goverlap=false -Goverlap_scaling=2 ' + - '-Gsep=+100 -Tx11 epicyon_modules.dot') - callGraphStr = 'digraph Epicyon {\n\n' - callGraphStr += ' size="8,6"; ratio=fill;\n' - callGraphStr += ' graph [fontsize=10 fontname="Verdana" compound=true];\n' - callGraphStr += ' node [shape=record fontsize=10 fontname="Verdana"];\n\n' - - for modName, modProperties in modules.items(): - callGraphStr += ' subgraph cluster_' + modName + ' {\n' - callGraphStr += ' label = "' + modName + '";\n' - callGraphStr += ' node [style=filled];\n' - moduleFunctionsStr = '' - for name in modProperties['functions']: - if name.startswith('test'): - continue - if name not in excludeFuncs: - if not functionProperties[name]['calls']: - moduleFunctionsStr += \ - ' "' + name + '" [fillcolor=yellow style=filled];\n' - continue - noOfCalls = len(functionProperties[name]['calls']) - if noOfCalls < int(maxFunctionCalls / 4): - moduleFunctionsStr += ' "' + name + \ - '" [fillcolor=orange style=filled];\n' - else: - moduleFunctionsStr += ' "' + name + \ - '" [fillcolor=red style=filled];\n' - - if moduleFunctionsStr: - callGraphStr += moduleFunctionsStr + '\n' - callGraphStr += ' color=blue;\n' - callGraphStr += ' }\n\n' - - for name, properties in functionProperties.items(): - if not properties['calls']: - continue - noOfCalls = len(properties['calls']) - if noOfCalls <= int(maxFunctionCalls / 8): - modColor = 'blue' - elif noOfCalls < int(maxFunctionCalls / 4): - modColor = 'green' - else: - modColor = 'red' - for calledFunc in properties['calls']: - if calledFunc.startswith('test'): - continue - if calledFunc not in excludeFuncs: - callGraphStr += ' "' + name + '" -> "' + calledFunc + \ - '" [color=' + modColor + '];\n' - - callGraphStr += '\n}\n' - with open('epicyon.dot', 'w+') as fp: - fp.write(callGraphStr) - print('Call graph saved to epicyon.dot') - print('Plot using: ' + - 'sfdp -x -Goverlap=prism -Goverlap_scaling=8 ' + - '-Gsep=+120 -Tx11 epicyon.dot') + _diagramGroups(['Commandline Interface', 'ActivityPub'], ['utils'], + modules, modGroups, maxModuleCalls) + _diagramGroups(['Commandline Interface', 'Core'], ['utils'], + modules, modGroups, maxModuleCalls) + _diagramGroups(['Timeline', 'Core'], ['utils'], + modules, modGroups, maxModuleCalls) + _diagramGroups(['Web Interface', 'Core'], ['utils'], + modules, modGroups, maxModuleCalls) + _diagramGroups(['Web Interface Columns', 'Core'], ['utils'], + modules, modGroups, maxModuleCalls) + _diagramGroups(['Core'], [], + modules, modGroups, maxModuleCalls) + _diagramGroups(['ActivityPub'], [], + modules, modGroups, maxModuleCalls) + _diagramGroups(['ActivityPub', 'Core'], ['utils'], + modules, modGroups, maxModuleCalls) + _diagramGroups(['ActivityPub', 'Security'], ['utils'], + modules, modGroups, maxModuleCalls) + _diagramGroups(['Core', 'Security'], ['utils'], + modules, modGroups, maxModuleCalls) + _diagramGroups(['Timeline', 'Security'], ['utils'], + modules, modGroups, maxModuleCalls) + _diagramGroups(['Web Interface', 'Accessibility'], + ['utils', 'webapp_utils'], + modules, modGroups, maxModuleCalls) + _diagramGroups(['Core', 'Accessibility'], ['utils'], + modules, modGroups, maxModuleCalls) -def testLinksWithinPost() -> None: +def _testLinksWithinPost() -> None: baseDir = os.getcwd() nickname = 'test27636' domain = 'rando.site' @@ -3026,11 +3397,34 @@ def testLinksWithinPost() -> None: httpPrefix = 'https' content = 'This is a test post with links.\n\n' + \ 'ftp://ftp.ncdc.noaa.gov/pub/data/ghcn/v4/\n\nhttps://freedombone.net' + followersOnly = False + saveToFile = False + clientToServer = False + commentsEnabled = True + attachImageFilename = None + mediaType = None + imageDescription = None + city = 'London, England' + testInReplyTo = None + testInReplyToAtomUri = None + testSubject = None + testSchedulePost = False + testEventDate = None + testEventTime = None + testLocation = None + testIsArticle = False + postJsonObject = \ createPublicPost(baseDir, nickname, domain, port, httpPrefix, - content, - False, False, False, True, - None, None, False, None) + content, followersOnly, saveToFile, + clientToServer, commentsEnabled, + attachImageFilename, mediaType, + imageDescription, city, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) + assert postJsonObject['object']['content'] == \ '

This is a test post with links.

' + \ ' None: postJsonObject = \ createPublicPost(baseDir, nickname, domain, port, httpPrefix, content, - False, False, False, True, - None, None, False, None) + False, False, + False, True, + None, None, + False, None, + testInReplyTo, testInReplyToAtomUri, + testSubject, testSchedulePost, + testEventDate, testEventTime, testLocation, + testIsArticle) assert postJsonObject['object']['content'] == content -def testMastoApi(): +def _testMastoApi(): print('testMastoApi') nickname = 'ThisIsATestNickname' mastoId = getMastoApiV1IdFromNickname(nickname) @@ -3072,7 +3472,7 @@ def testMastoApi(): assert nickname2 == nickname -def testDomainHandling(): +def _testDomainHandling(): print('testDomainHandling') testDomain = 'localhost' assert decodedHost(testDomain) == testDomain @@ -3084,7 +3484,7 @@ def testDomainHandling(): assert decodedHost(testDomain) == "españa.icom.museum" -def testPrepareHtmlPostNickname(): +def _testPrepareHtmlPostNickname(): print('testPrepareHtmlPostNickname') postHtml = ' Some quote or other' + assert markdownToHtml(markdown) == 'This is a quotation:
' + \ + '

Some quote or other
' + + markdown = 'This is a multi-line quotation:\n' + \ + '> The first line\n' + \ + '> The second line' + assert markdownToHtml(markdown) == \ + 'This is a multi-line quotation:
' + \ + '
The first line The second line
' + + markdown = 'This is **bold**' + assert markdownToHtml(markdown) == 'This is bold' + + markdown = 'This is *italic*' + assert markdownToHtml(markdown) == 'This is italic' + + markdown = 'This is _underlined_' + assert markdownToHtml(markdown) == 'This is ' + + markdown = 'This is **just** plain text' + assert markdownToHtml(markdown) == 'This is just plain text' + + markdown = '# Title1\n### Title3\n## Title2\n' + assert markdownToHtml(markdown) == \ + '

Title1

Title3

Title2

' + + markdown = \ + 'This is [a link](https://something.somewhere) to something.\n' + \ + 'And [something else](https://cat.pic).\n' + \ + 'Or ![pounce](/cat.jpg).' + assert markdownToHtml(markdown) == \ + 'This is
' + \ + 'a link to something.
' + \ + 'And ' + \ + 'something else.
' + \ + 'Or pounce.' + + +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 @@ +![Journalist image](/helpimages/journalist.jpg) +### 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 image](/helpimages/welcome.jpg) +### 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 @@ +![Journalist image](/helpimages/journalist.jpg) +### 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 image](/helpimages/welcome.jpg) +### 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 += '
' + aboutText + '
' if onionDomain: aboutForm += \ diff --git a/webapp_accesskeys.py b/webapp_accesskeys.py new file mode 100644 index 000000000..a8eba7c16 --- /dev/null +++ b/webapp_accesskeys.py @@ -0,0 +1,116 @@ +__filename__ = "webapp_accesskeys.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@freedombone.net" +__status__ = "Production" +__module_group__ = "Accessibility" + +import os +from utils import isAccountDir +from utils import loadJson +from utils import getConfigParam +from utils import acctDir +from webapp_utils import htmlHeaderWithExternalStyle +from webapp_utils import htmlFooter + + +def loadAccessKeysForAccounts(baseDir: str, keyShortcuts: {}, + accessKeysTemplate: {}) -> None: + """Loads key shortcuts for each account + """ + for subdir, dirs, files in os.walk(baseDir + '/accounts'): + for acct in dirs: + if not isAccountDir(acct): + continue + accountDir = os.path.join(baseDir + '/accounts', acct) + accessKeysFilename = accountDir + '/accessKeys.json' + if not os.path.isfile(accessKeysFilename): + continue + nickname = acct.split('@')[0] + accessKeys = loadJson(accessKeysFilename) + if accessKeys: + keyShortcuts[nickname] = accessKeysTemplate.copy() + for variableName, key in accessKeysTemplate.items(): + if accessKeys.get(variableName): + keyShortcuts[nickname][variableName] = \ + accessKeys[variableName] + break + + +def htmlAccessKeys(cssCache: {}, baseDir: str, + nickname: str, domain: str, + translate: {}, accessKeys: {}, + defaultAccessKeys: {}, + defaultTimeline: str) -> str: + """Show and edit key shortcuts + """ + accessKeysFilename = \ + acctDir(baseDir, nickname, domain) + '/accessKeys.json' + if os.path.isfile(accessKeysFilename): + accessKeysFromFile = loadJson(accessKeysFilename) + if accessKeysFromFile: + accessKeys = accessKeysFromFile + + accessKeysForm = '' + cssFilename = baseDir + '/epicyon-profile.css' + if os.path.isfile(baseDir + '/epicyon.css'): + cssFilename = baseDir + '/epicyon.css' + + instanceTitle = \ + getConfigParam(baseDir, 'instanceTitle') + accessKeysForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + accessKeysForm += '
\n' + + accessKeysForm += \ + '

' + translate['Key Shortcuts'] + '

\n' + accessKeysForm += \ + '

' + translate['These access keys may be used'] + \ + '

' + + accessKeysForm += '
\n' + + timelineKey = accessKeys['menuTimeline'] + submitKey = accessKeys['submitButton'] + accessKeysForm += \ + '
\n' + \ + ' \n' + \ + ' \n
\n' + + accessKeysForm += ' \n' + accessKeysForm += ' \n' + accessKeysForm += ' \n' + accessKeysForm += ' \n' + accessKeysForm += ' \n' + accessKeysForm += ' \n' + + for variableName, key in defaultAccessKeys.items(): + if not translate.get(variableName): + continue + keyStr = '' + keyStr += \ + '' + if accessKeys.get(variableName): + key = accessKeys[variableName] + if len(key) > 1: + key = key[0] + keyStr += \ + '\n' + accessKeysForm += keyStr + + accessKeysForm += ' \n' + accessKeysForm += '
' + keyStr += '
\n' + accessKeysForm += '
\n' + accessKeysForm += '
\n' + accessKeysForm += htmlFooter() + return accessKeysForm diff --git a/webapp_calendar.py b/webapp_calendar.py index 025614e79..10e480fc1 100644 --- a/webapp_calendar.py +++ b/webapp_calendar.py @@ -5,22 +5,26 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Calendar" import os from datetime import datetime from datetime import date from shutil import copyfile +from utils import getDisplayName from utils import getConfigParam from utils import getNicknameFromActor from utils import getDomainFromActor from utils import locatePost from utils import loadJson from utils import weekDayOfMonthStart +from utils import getAltPath +from utils import removeDomainPort +from utils import acctDir from happening import getTodaysEvents from happening import getCalendarEvents from webapp_utils import htmlHeaderWithExternalStyle from webapp_utils import htmlFooter -from webapp_utils import getAltPath from webapp_utils import htmlHideFromScreenReader from webapp_utils import htmlKeyboardNavigation @@ -94,14 +98,14 @@ def htmlCalendarDeleteConfirm(cssCache: {}, translate: {}, baseDir: str, return deletePostStr -def _htmlCalendarDay(cssCache: {}, translate: {}, +def _htmlCalendarDay(personCache: {}, cssCache: {}, translate: {}, baseDir: str, path: str, year: int, monthNumber: int, dayNumber: int, nickname: str, domain: str, dayEvents: [], monthName: str, actor: str) -> str: """Show a day within the calendar """ - accountDir = baseDir + '/accounts/' + nickname + '@' + domain + accountDir = acctDir(baseDir, nickname, domain) calendarFile = accountDir + '/.newCalendar' if os.path.isfile(calendarFile): os.remove(calendarFile) @@ -114,8 +118,7 @@ def _htmlCalendarDay(cssCache: {}, translate: {}, if '/users/' in actor: calActor = '/users/' + actor.split('/users/')[1] - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') + instanceTitle = getConfigParam(baseDir, 'instanceTitle') calendarStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) calendarStr += '
\n' calendarStr += '' + \ + '' + deleteButtonStr + '\n' elif eventTime and eventDescription and not eventPlace: calendarStr += \ - '' + \ + '' + deleteButtonStr + '\n' elif not eventTime and eventDescription and not eventPlace: calendarStr += \ - '' + \ + '' + deleteButtonStr + '\n' elif not eventTime and eventDescription and eventPlace: calendarStr += \ - '' + \ - '' + \ + '' + \ + '' + deleteButtonStr + '\n' elif eventTime and not eventDescription and eventPlace: calendarStr += \ - '' + \ + '' + \ deleteButtonStr + '\n' @@ -200,15 +242,13 @@ def _htmlCalendarDay(cssCache: {}, translate: {}, return calendarStr -def htmlCalendar(cssCache: {}, translate: {}, +def htmlCalendar(personCache: {}, cssCache: {}, translate: {}, baseDir: str, path: str, httpPrefix: str, domainFull: str, - textModeBanner: str) -> str: + textModeBanner: str, accessKeys: {}) -> str: """Show the calendar for a person """ - domain = domainFull - if ':' in domainFull: - domain = domainFull.split(':')[0] + domain = removeDomainPort(domainFull) monthNumber = 0 dayNumber = None @@ -259,7 +299,8 @@ def htmlCalendar(cssCache: {}, translate: {}, if events: if events.get(str(dayNumber)): dayEvents = events[str(dayNumber)] - return _htmlCalendarDay(cssCache, translate, baseDir, path, + return _htmlCalendarDay(personCache, cssCache, + translate, baseDir, path, year, monthNumber, dayNumber, nickname, domain, dayEvents, monthName, actor) @@ -307,17 +348,20 @@ def htmlCalendar(cssCache: {}, translate: {}, calendarStr += '\n' - calendarStr += ' \n' - calendarStr += ' \n' - calendarStr += ' \n' - calendarStr += ' \n' - calendarStr += ' \n' + days = ('Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat') + for d in days: + calendarStr += ' \n' calendarStr += '\n' calendarStr += '\n' calendarStr += '\n' @@ -415,7 +449,10 @@ def htmlCalendar(cssCache: {}, translate: {}, htmlHideFromScreenReader('←') + ' ' + translate['Previous month'] navLinks[prevMonthStr] = calActor + '/calendar?year=' + str(prevYear) + \ '?month=' + str(prevMonthNumber) + navAccessKeys = { + } screenReaderCal = \ - htmlKeyboardNavigation(textModeBanner, navLinks, monthName) + htmlKeyboardNavigation(textModeBanner, navLinks, navAccessKeys, + monthName) return headerStr + screenReaderCal + calendarStr + htmlFooter() diff --git a/webapp_column_left.py b/webapp_column_left.py index 14651564f..034153184 100644 --- a/webapp_column_left.py +++ b/webapp_column_left.py @@ -5,11 +5,13 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface Columns" import os from utils import getConfigParam from utils import getNicknameFromActor from utils import isEditor +from utils import removeDomainPort from webapp_utils import sharesTimelineJson from webapp_utils import htmlPostSeparator from webapp_utils import getLeftImageFile @@ -49,10 +51,8 @@ def _getLeftColumnShares(baseDir: str, if '<' in sharedesc or '?' in sharedesc: continue contactActor = item['actor'] - shareLink = actor + \ - '?replydm=sharedesc:' + \ - sharedesc.replace(' ', '_') + \ - '?mention=' + contactActor + shareLink = actor + '?replydm=sharedesc:' + \ + sharedesc.replace(' ', '_') + '?mention=' + contactActor linksList.append(sharedesc + ' ' + shareLink) ctr += 1 if ctr >= maxSharesInLeftColumn: @@ -68,15 +68,14 @@ def getLeftColumnContent(baseDir: str, nickname: str, domainFull: str, editor: bool, showBackButton: bool, timelinePath: str, rssIconAtTop: bool, showHeaderImage: bool, - frontPage: bool, theme: str) -> str: + frontPage: bool, theme: str, + accessKeys: {}) -> str: """Returns html content for the left column """ htmlStr = '' separatorStr = htmlPostSeparator(baseDir, 'left') - domain = domainFull - if ':' in domain: - domain = domain.split(':') + domain = removeDomainPort(domainFull) editImageClass = '' if showHeaderImage: @@ -88,16 +87,14 @@ def getLeftColumnContent(baseDir: str, nickname: str, domainFull: str, if os.path.isfile(leftColumnImageFilename): editImageClass = 'leftColEditImage' htmlStr += \ - '\n
\n' + \ - ' \n \n' + \ '
\n' if showBackButton: htmlStr += \ - '
' + \ - ' ' + \ + '
' + \ '\n' @@ -111,13 +108,11 @@ def getLeftColumnContent(baseDir: str, nickname: str, domainFull: str, if editor: # show the edit icon htmlStr += \ - ' ' + \ - '' + \
+            '      <a href=' + \ + '' + \
             translate['Edit Links'] + ' | \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 = \ - ' ' + \ - '' + rssTitle + \
-        '' + rssTitle + '\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 += \ + '

' + \ + lineStr.strip() + '

\n' + linksFileContainsEntries = True else: if lineStr.startswith('#') or lineStr.startswith('*'): lineStr = lineStr[1:].strip() @@ -220,6 +247,11 @@ def getLeftColumnContent(baseDir: str, nickname: str, domainFull: str, if firstSeparatorAdded: htmlStr += separatorStr + htmlStr += \ + '' htmlStr += \ '' @@ -240,7 +272,7 @@ def htmlLinksMobile(cssCache: {}, baseDir: str, rssIconAtTop: bool, iconsAsButtons: bool, defaultTimeline: str, - theme: str) -> str: + theme: str, accessKeys: {}) -> str: """Show the left column links within mobile view """ htmlStr = '' @@ -256,9 +288,7 @@ def htmlLinksMobile(cssCache: {}, baseDir: str, else: editor = isEditor(baseDir, nickname) - domain = domainFull - if ':' in domain: - domain = domain.split(':')[0] + domain = removeDomainPort(domainFull) instanceTitle = \ getConfigParam(baseDir, 'instanceTitle') @@ -266,7 +296,8 @@ def htmlLinksMobile(cssCache: {}, baseDir: str, bannerFile, bannerFilename = \ getBannerFile(baseDir, nickname, domain, theme) htmlStr += \ - '' + \ + '' + \ '\n' @@ -283,11 +314,10 @@ def htmlLinksMobile(cssCache: {}, baseDir: str, editor, False, timelinePath, rssIconAtTop, False, False, - theme) + theme, accessKeys) else: if editor: - htmlStr += '


\n' - htmlStr += '
\n ' + htmlStr += '


\n
\n ' htmlStr += translate['Select the edit icon to add web links'] htmlStr += '\n
\n' @@ -300,7 +330,8 @@ def htmlLinksMobile(cssCache: {}, baseDir: str, def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str, domain: str, port: int, httpPrefix: str, - defaultTimeline: str, theme: str) -> str: + defaultTimeline: str, theme: str, + accessKeys: {}) -> str: """Shows the edit links screen """ if '/users/' not in path: @@ -333,8 +364,10 @@ def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str, '
\n' + \ '\n' - editLinksForm += '\n' + editLinksForm += \ + '\n' + \ '
\n' @@ -350,7 +383,8 @@ def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str, '

' + translate['Edit Links'] + '

' editLinksForm += \ ' \n' + translate['Submit'] + '" ' + \ + 'accesskey="' + accessKeys['submitButton'] + '">\n' editLinksForm += \ '
\n' @@ -367,8 +401,8 @@ def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str, translate['One link per line. Description followed by the link.'] + \ '
' editLinksForm += \ - ' ' + ' ' editLinksForm += \ '
' @@ -376,7 +410,7 @@ def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str, adminNickname = getConfigParam(baseDir, 'admin') if adminNickname: if nickname == adminNickname: - aboutFilename = baseDir + '/accounts/about.txt' + aboutFilename = baseDir + '/accounts/about.md' aboutStr = '' if os.path.isfile(aboutFilename): with open(aboutFilename, 'r') as fp: @@ -390,11 +424,12 @@ def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str, '
' editLinksForm += \ ' ' + 'style="height:100vh" spellcheck="true" autocomplete="on">' + \ + aboutStr + '' editLinksForm += \ '' - TOSFilename = baseDir + '/accounts/tos.txt' + TOSFilename = baseDir + '/accounts/tos.md' TOSStr = '' if os.path.isfile(TOSFilename): with open(TOSFilename, 'r') as fp: @@ -408,7 +443,8 @@ def htmlEditLinks(cssCache: {}, translate: {}, baseDir: str, path: str, '
' editLinksForm += \ ' ' + 'style="height:100vh" spellcheck="true" autocomplete="on">' + \ + TOSStr + '' editLinksForm += \ '' diff --git a/webapp_column_right.py b/webapp_column_right.py index 1b4276515..ad89d56e0 100644 --- a/webapp_column_right.py +++ b/webapp_column_right.py @@ -5,10 +5,12 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface Columns" import os from datetime import datetime from content import removeLongWords +from content import limitRepeatedWords from utils import removeHtml from utils import locatePost from utils import loadJson @@ -16,6 +18,8 @@ from utils import votesOnNewswireItem from utils import getNicknameFromActor from utils import isEditor from utils import getConfigParam +from utils import removeDomainPort +from utils import acctDir from posts import isModerator from webapp_utils import getRightImageFile from webapp_utils import htmlHeaderWithExternalStyle @@ -51,14 +55,13 @@ def getRightColumnContent(baseDir: str, nickname: str, domainFull: str, authorized: bool, showHeaderImage: bool, theme: str, - defaultTimeline: str) -> str: + defaultTimeline: str, + accessKeys: {}) -> str: """Returns html content for the right column """ htmlStr = '' - domain = domainFull - if ':' in domain: - domain = domain.split(':') + domain = removeDomainPort(domainFull) if authorized: # only show the publish button if logged in, otherwise replace it with @@ -69,7 +72,8 @@ def getRightColumnContent(baseDir: str, nickname: str, domainFull: str, publishButtonStr = \ ' ' + \ + 'title="' + titleStr + '" ' + \ + 'accesskey="' + accessKeys['menuNewPost'] + '">' + \ '\n' else: @@ -124,7 +128,8 @@ def getRightColumnContent(baseDir: str, nickname: str, domainFull: str, # show the edit icon highlighted htmlStr += \ ' ' + \ + '/users/' + nickname + '/editnewswire" ' + \ + 'accesskey="' + accessKeys['menuEdit'] + '">' + \ '' + \
                 translate['Edit newswire'] + ' | ' + \ + '/users/' + nickname + '/editnewswire" ' + \ + 'accesskey="' + accessKeys['menuEdit'] + '">' + \ '' + \
                 translate['Edit newswire'] + ' | ' + \ + '/users/' + nickname + '/newblog?nodropdown" ' + \ + 'accesskey="' + accessKeys['menuNewPost'] + '">' + \ '' + \
                 titleStr + '' + 'alt="" ' + _getBrokenFavSubstitute() + '/>' moderatedItem = item[5] htmlStr += separatorStr if moderatedItem and 'vote:' + nickname in item[2]: @@ -261,12 +267,12 @@ def _htmlNewswire(baseDir: str, newswire: {}, nickname: str, moderator: bool, _votesIndicator(totalVotes, positiveVoting) title = removeLongWords(item[0], 16, []).replace('\n', '
') + title = limitRepeatedWords(title, 6) htmlStr += '

' + \ '' + \ '' + \ - faviconLink + title + \ - '' + totalVotesStr + faviconLink + title + '' + totalVotesStr if moderator: htmlStr += \ ' ' + dateShown + '' + \ '' + \ - faviconLink + title + '' + \ - totalVotesStr + faviconLink + title + '' + totalVotesStr htmlStr += ' ' htmlStr += dateShown + '

\n' @@ -332,12 +338,11 @@ def htmlCitations(baseDir: str, nickname: str, domain: str, # create a list of dates for citations # these can then be used to re-select checkboxes later citationsFilename = \ - baseDir + '/accounts/' + \ - nickname + '@' + domain + '/.citations.txt' + acctDir(baseDir, nickname, domain) + '/.citations.txt' citationsSelected = [] if os.path.isfile(citationsFilename): citationsSeparator = '#####' - with open(citationsFilename, "r") as f: + with open(citationsFilename, 'r') as f: citations = f.readlines() for line in citations: if citationsSeparator not in line: @@ -415,6 +420,7 @@ def htmlCitations(baseDir: str, nickname: str, domain: str, dateShown = publishedDate.strftime("%Y-%m-%d %H:%M") title = removeLongWords(item[0], 16, []).replace('\n', '
') + title = limitRepeatedWords(title, 6) link = item[1] citationValue = \ @@ -444,7 +450,8 @@ def htmlNewswireMobile(cssCache: {}, baseDir: str, nickname: str, rssIconAtTop: bool, iconsAsButtons: bool, defaultTimeline: str, - theme: str) -> str: + theme: str, + accessKeys: {}) -> str: """Shows the mobile version of the newswire right column """ htmlStr = '' @@ -473,7 +480,8 @@ def htmlNewswireMobile(cssCache: {}, baseDir: str, nickname: str, bannerFile, bannerFilename = \ getBannerFile(baseDir, nickname, domain, theme) htmlStr += \ - '' + \ + '' + \ '\n' @@ -493,7 +501,7 @@ def htmlNewswireMobile(cssCache: {}, baseDir: str, nickname: str, False, timelinePath, showPublishButton, showPublishAsIcon, rssIconAtTop, False, authorized, False, theme, - defaultTimeline) + defaultTimeline, accessKeys) else: if editor: htmlStr += '


\n' @@ -509,7 +517,8 @@ def htmlNewswireMobile(cssCache: {}, baseDir: str, nickname: str, def htmlEditNewswire(cssCache: {}, translate: {}, baseDir: str, path: str, domain: str, port: int, httpPrefix: str, - defaultTimeline: str, theme: str) -> str: + defaultTimeline: str, theme: str, + accessKeys: {}) -> str: """Shows the edit newswire screen """ if '/users/' not in path: @@ -542,7 +551,8 @@ def htmlEditNewswire(cssCache: {}, translate: {}, baseDir: str, path: str, '
' + \ '\n' + translate['Switch to timeline view'] + '" ' + \ + 'accesskey="' + accessKeys['menuTimeline'] + '">\n' editNewswireForm += '\n
' @@ -558,7 +568,8 @@ def htmlEditNewswire(cssCache: {}, translate: {}, baseDir: str, path: str, '
\n' editNewswireForm += \ ' \n' + translate['Submit'] + '" ' + \ + 'accesskey="' + accessKeys['submitButton'] + '">\n' editNewswireForm += \ '
\n' @@ -577,7 +588,8 @@ def htmlEditNewswire(cssCache: {}, translate: {}, baseDir: str, path: str, '
' editNewswireForm += \ ' ' + 'style="height:80vh" spellcheck="false">' + \ + newswireStr + '' filterStr = '' filterFilename = \ @@ -592,8 +604,8 @@ def htmlEditNewswire(cssCache: {}, translate: {}, baseDir: str, path: str, editNewswireForm += '
' editNewswireForm += ' \n' + 'name="filteredWordsNewswire" style="height:50vh" ' + \ + 'spellcheck="true">' + filterStr + '\n' hashtagRulesStr = '' hashtagRulesFilename = \ @@ -612,7 +624,7 @@ def htmlEditNewswire(cssCache: {}, translate: {}, baseDir: str, path: str, 'https://gitlab.com/bashrc2/epicyon/-/raw/main/hashtagrules.txt' + \ '">' + translate['See instructions'] + '\n' editNewswireForm += ' \n' editNewswireForm += \ @@ -687,7 +699,8 @@ def htmlEditNewsPost(cssCache: {}, translate: {}, baseDir: str, path: str, newsPostContent = postJsonObject['object']['content'] editNewsPostForm += \ ' ' + 'style="height:600px" spellcheck="true">' + \ + newsPostContent + '' editNewsPostForm += \ '' diff --git a/webapp_confirm.py b/webapp_confirm.py index 26b8289b7..7c820ea39 100644 --- a/webapp_confirm.py +++ b/webapp_confirm.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface" import os from shutil import copyfile @@ -14,7 +15,8 @@ from utils import getDomainFromActor from utils import locatePost from utils import loadJson from utils import getConfigParam -from webapp_utils import getAltPath +from utils import getAltPath +from utils import acctDir from webapp_utils import htmlHeaderWithExternalStyle from webapp_utils import htmlFooter from webapp_post import individualPostAsHtml @@ -31,7 +33,8 @@ def htmlConfirmDelete(cssCache: {}, YTReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], - allowLocalNetworkAccess: bool) -> str: + allowLocalNetworkAccess: bool, + themeName: str) -> str: """Shows a screen asking to confirm the deletion of a post """ if '/statuses/' not in messageId: @@ -72,6 +75,7 @@ def htmlConfirmDelete(cssCache: {}, YTReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, + themeName, False, False, False, False, False) deletePostStr += '
' deletePostStr += \ @@ -108,8 +112,7 @@ def htmlConfirmRemoveSharedItem(cssCache: {}, translate: {}, baseDir: str, nickname = getNicknameFromActor(actor) domain, port = getDomainFromActor(actor) domainFull = getFullDomain(domain, port) - sharesFile = baseDir + '/accounts/' + \ - nickname + '@' + domain + '/shares.json' + sharesFile = acctDir(baseDir, nickname, domain) + '/shares.json' if not os.path.isfile(sharesFile): print('ERROR: no shares file ' + sharesFile) return None @@ -134,8 +137,7 @@ def htmlConfirmRemoveSharedItem(cssCache: {}, translate: {}, baseDir: str, if os.path.isfile(baseDir + '/follow.css'): cssFilename = baseDir + '/follow.css' - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') + instanceTitle = getConfigParam(baseDir, 'instanceTitle') sharesStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) sharesStr += '
\n' + submitText = translate['Submit'] + if customSubmitText: + submitText = customSubmitText newPostForm += \ ' \n' + submitText + '" ' + \ + 'accesskey="' + accessKeys['submitButton'] + '">\n' - newPostForm += ' \n' - newPostForm += '
\n' @@ -134,6 +137,9 @@ def _htmlCalendarDay(cssCache: {}, translate: {}, eventDescription = None eventPlace = None postId = None + senderName = '' + senderActor = None + eventIsPublic = False # get the time place and description for ev in eventPost: if ev['type'] == 'Event': @@ -144,12 +150,38 @@ def _htmlCalendarDay(cssCache: {}, translate: {}, datetime.strptime(ev['startTime'], "%Y-%m-%dT%H:%M:%S%z") eventTime = eventDate.strftime("%H:%M").strip() + if 'public' in ev: + if ev['public'] is True: + eventIsPublic = True + if ev.get('sender'): + # get display name from sending actor + if ev.get('sender'): + senderActor = ev['sender'] + dispName = \ + getDisplayName(baseDir, senderActor, + personCache) + if dispName: + senderName = \ + '' + \ + dispName + ': ' if ev.get('name'): eventDescription = ev['name'].strip() elif ev['type'] == 'Place': if ev.get('name'): eventPlace = ev['name'] + # prepend a link to the sender of the calendar item + if senderName and eventDescription: + # if the sender is also mentioned within the event + # description then this is a reminder + senderActor2 = senderActor.replace('/users/', '/@') + if senderActor not in eventDescription and \ + senderActor2 not in eventDescription: + eventDescription = senderName + eventDescription + else: + eventDescription = \ + translate['Reminder'] + ': ' + eventDescription + deleteButtonStr = '' if postId: deleteButtonStr = \ @@ -162,33 +194,43 @@ def _htmlCalendarDay(cssCache: {}, translate: {}, translate['Delete this event'] + '" src="/' + \ 'icons/delete.png" />\n' + eventClass = 'calendar__day__event' + calItemClass = 'calItem' + if eventIsPublic: + eventClass = 'calendar__day__event__public' + calItemClass = 'calItemPublic' if eventTime and eventDescription and eventPlace: calendarStr += \ - '
' + eventTime + \ - '' + \ + '
' + eventTime + \ + '' + \ '' + \ eventPlace + '
' + eventDescription + \ '
' + eventTime + \ - '' + \ + '
' + eventTime + \ + '' + \ eventDescription + '
' + \ - '' + \ + '
' + \ + '' + \ eventDescription + '
' + \ + '
' + \ eventPlace + '
' + eventDescription + \ '
' + eventTime + \ - '' + \ + '
' + eventTime + \ + '' + \ '' + \ eventPlace + '
\n' calendarStr += \ ' ' + '?month=' + str(prevMonthNumber) + '" ' + \ + 'accesskey="' + accessKeys['Page up'] + '">' calendarStr += \ ' ' + translate['Previous month'] + \
         '\n' calendarStr += ' ' + calendarStr += translate['Switch to timeline view'] + '" ' + \ + 'accesskey="' + accessKeys['menuTimeline'] + '">' calendarStr += '

' + monthName + '

\n' calendarStr += \ ' ' + '?month=' + str(nextMonthNumber) + '" ' + \ + 'accesskey="' + accessKeys['Page down'] + '">' calendarStr += \ ' ' + translate['Next month'] + \
         '' + \ - translate['Sun'] + '\n' - calendarStr += '
' + \ - translate['Mon'] + '' + \ - translate['Tue'] + '' + \ - translate['Wed'] + '' + \ - translate['Thu'] + '' + \ - translate['Fri'] + '' + \ - translate['Sat'] + '' + \ + translate[d] + '
\n' + newPostForm += ' \n\n' newPostForm += ' \n' newPostForm += '
\n' - # newPostForm += \ - # ' \n' - newPostForm += '
\n' newPostForm += replyStr @@ -753,12 +754,20 @@ def htmlNewPost(cssCache: {}, mediaInstance: bool, translate: {}, newPostForm += \ ' \n' + str(messageBoxHeight) + 'px"' + selectedStr + \ + ' spellcheck="true" autocomplete="on">' + \ + '\n' newPostForm += extraFields + citationsStr + dateAndLocation if not mediaInstance or replyStr: newPostForm += newPostImageSection - newPostForm += ' \n' - newPostForm += '\n' + + newPostForm += \ + '
\n' + \ + ' \n' + \ + '
\n' + \ + ' \n' + \ + '\n' if not reportUrl: newPostForm = \ diff --git a/webapp_frontscreen.py b/webapp_frontscreen.py index 4e5ad14fc..a1c575029 100644 --- a/webapp_frontscreen.py +++ b/webapp_frontscreen.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Timeline" import os from utils import isSystemAccount @@ -30,7 +31,8 @@ def _htmlFrontScreenPosts(recentPostsCache: {}, maxRecentPosts: int, YTReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], - allowLocalNetworkAccess: bool) -> str: + allowLocalNetworkAccess: bool, + themeName: str) -> str: """Shows posts on the front screen of a news instance These should only be public blog posts from the features timeline which is the blog timeline of the news actor @@ -71,6 +73,7 @@ def _htmlFrontScreenPosts(recentPostsCache: {}, maxRecentPosts: int, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, + themeName, False, False, False, True, False) if postStr: profileStr += postStr + separatorStr @@ -94,8 +97,10 @@ def htmlFrontScreen(rssIconAtTop: bool, newswire: {}, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - extraJson=None, - pageNumber=None, maxItemsPerPage=None) -> str: + accessKeys: {}, + extraJson: {} = None, + pageNumber: int = None, + maxItemsPerPage: int = None) -> str: """Show the news instance front screen """ nickname = profileJson['preferredUsername'] @@ -123,22 +128,24 @@ def htmlFrontScreen(rssIconAtTop: bool, if loginButton: profileHeaderStr += '
' + loginButton + '
\n' - profileHeaderStr += '\n' - profileHeaderStr += ' \n' - profileHeaderStr += ' \n' - profileHeaderStr += ' \n' - profileHeaderStr += ' \n' - profileHeaderStr += ' \n' - profileHeaderStr += ' \n' - profileHeaderStr += ' \n' - profileHeaderStr += ' \n' + \ + ' \n' + \ + ' \n' + \ + '
\n' + profileHeaderStr += \ + '\n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' - profileHeaderStr += ' \n' + \ + ' \n' @@ -170,11 +178,12 @@ def htmlFrontScreen(rssIconAtTop: bool, False, False, newswire, False, False, None, False, False, False, True, authorized, True, theme, - defaultTimeline) - profileFooterStr += ' \n' - profileFooterStr += ' \n' - profileFooterStr += ' \n' - profileFooterStr += '
\n' profileHeaderStr += \ getLeftColumnContent(baseDir, 'news', domainFull, httpPrefix, translate, False, False, None, rssIconAtTop, True, - True, theme) - profileHeaderStr += ' \n' + True, theme, accessKeys) + profileHeaderStr += \ + ' \n' profileStr = profileHeaderStr @@ -159,7 +166,8 @@ def htmlFrontScreen(rssIconAtTop: bool, YTReplacementDomain, showPublishedDateOnly, peertubeInstances, - allowLocalNetworkAccess) + licenseStr + allowLocalNetworkAccess, + theme) + licenseStr # Footer which is only used for system accounts profileFooterStr = '
\n' + defaultTimeline, accessKeys) + profileFooterStr += \ + '
\n' instanceTitle = \ getConfigParam(baseDir, 'instanceTitle') diff --git a/webapp_hashtagswarm.py b/webapp_hashtagswarm.py index f2ffb577d..8cb784d8b 100644 --- a/webapp_hashtagswarm.py +++ b/webapp_hashtagswarm.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface" import os from shutil import copyfile @@ -20,7 +21,7 @@ from webapp_utils import htmlFooter def getHashtagCategoriesFeed(baseDir: str, - hashtagCategories=None) -> str: + hashtagCategories: {} = None) -> str: """Returns an rss feed for hashtag categories """ if not hashtagCategories: @@ -28,89 +29,38 @@ def getHashtagCategoriesFeed(baseDir: str, if not hashtagCategories: return None - rssStr = "\n" - rssStr += "\n" - rssStr += '\n' - rssStr += ' #categories\n' + rssStr = \ + "\n" + \ + "\n" + \ + '\n' + \ + ' #categories\n' rssDateStr = \ datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S UT") for categoryStr, hashtagList in hashtagCategories.items(): - rssStr += '\n' - rssStr += ' ' + categoryStr + '\n' + rssStr += \ + '\n' + \ + ' ' + categoryStr + '\n' listStr = '' for hashtag in hashtagList: + if ':' in hashtag: + continue + if '&' in hashtag: + continue listStr += hashtag + ' ' - rssStr += ' ' + listStr.strip() + '\n' - rssStr += ' \n' - rssStr += ' ' + rssDateStr + '\n' - rssStr += '\n' + rssStr += \ + ' ' + listStr.strip() + '\n' + \ + ' \n' + \ + ' ' + rssDateStr + '\n' + \ + '\n' - rssStr += '\n' - rssStr += '\n' + rssStr += \ + '\n' + \ + '\n' return rssStr -def _getHashtagDomainMax(domainHistogram: {}) -> str: - """Returns the domain with the maximum number of hashtags - """ - maxCount = 1 - maxDomain = None - for domain, count in domainHistogram.items(): - if count > maxCount: - maxDomain = domain - maxCount = count - return maxDomain - - -def _getHashtagDomainHistogram(domainHistogram: {}, translate: {}) -> str: - """Returns the html for a histogram of domains - from which hashtags are coming - """ - totalCount = 0 - for domain, count in domainHistogram.items(): - totalCount += count - if totalCount == 0: - return '' - - htmlStr = '' - histogramHeaderStr = '

\n' - histogramHeaderStr += '

' + translate['Hashtag origins'] + '

\n' - histogramHeaderStr += ' \n' - histogramHeaderStr += ' \n' - histogramHeaderStr += ' \n' - histogramHeaderStr += ' \n' - histogramHeaderStr += ' \n' - histogramHeaderStr += ' \n' - histogramHeaderStr += ' \n' - - leftColStr = '' - rightColStr = '' - - for i in range(len(domainHistogram)): - domain = _getHashtagDomainMax(domainHistogram) - if not domain: - break - percent = int(domainHistogram[domain] * 100 / totalCount) - if histogramHeaderStr: - htmlStr += histogramHeaderStr - histogramHeaderStr = None - leftColStr += str(percent) + '%
' - rightColStr += domain + '
' - del domainHistogram[domain] - - if htmlStr: - htmlStr += ' \n' - htmlStr += ' \n' - htmlStr += ' \n' - htmlStr += ' \n' - htmlStr += '
' + leftColStr + '' + rightColStr + '
\n' - htmlStr += '
\n' - - return htmlStr - - def htmlHashTagSwarm(baseDir: str, actor: str, translate: {}) -> str: """Returns a tag swarm of today's hashtags """ @@ -239,7 +189,6 @@ def htmlHashTagSwarm(baseDir: str, actor: str, translate: {}) -> str: getContentWarningButton('alltags', translate, tagSwarmStr) tagSwarmHtml = categorySwarmStr + tagSwarmStr.strip() + '\n' - # tagSwarmHtml += _getHashtagDomainHistogram(domainHistogram, translate) return tagSwarmHtml @@ -274,10 +223,11 @@ def htmlSearchHashtagCategory(cssCache: {}, translate: {}, htmlStr += '\n' - htmlStr += '' htmlStr += htmlFooter() return htmlStr diff --git a/webapp_headerbuttons.py b/webapp_headerbuttons.py index bf922d588..f81b3ba08 100644 --- a/webapp_headerbuttons.py +++ b/webapp_headerbuttons.py @@ -5,13 +5,15 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Timeline" import os import time +from utils import acctDir from datetime import datetime -from happening import todaysEventsCheck -from happening import thisWeeksEventsCheck +from datetime import timedelta +from happening import dayEventsCheck from webapp_utils import htmlHighlightLabel @@ -43,7 +45,8 @@ def headerButtonsTimeline(defaultTimeline: str, calendarPath: str, calendarImage: str, followApprovals: str, - iconsAsButtons: bool) -> str: + iconsAsButtons: bool, + accessKeys: {}) -> str: """Returns the header at the top of the timeline, containing buttons for inbox, outbox, search, calendar, etc """ @@ -52,8 +55,9 @@ def headerButtonsTimeline(defaultTimeline: str, # first button if defaultTimeline == 'tlmedia': tlStr += \ - '' elif defaultTimeline == 'tlblogs': @@ -88,8 +92,7 @@ def headerButtonsTimeline(defaultTimeline: str, '' repliesIndexFilename = \ - baseDir + '/accounts/' + \ - nickname + '@' + domain + '/tlreplies.index' + acctDir(baseDir, nickname, domain) + '/tlreplies.index' if os.path.isfile(repliesIndexFilename): tlStr += \ '' + \ @@ -101,8 +104,9 @@ def headerButtonsTimeline(defaultTimeline: str, if defaultTimeline != 'tlmedia': if not minimal and not featuresHeader: tlStr += \ - '' else: @@ -110,7 +114,7 @@ def headerButtonsTimeline(defaultTimeline: str, tlStr += \ '' if not featuresHeader: @@ -147,9 +151,10 @@ def headerButtonsTimeline(defaultTimeline: str, # show todays events buttons on the first inbox page happeningStr = '' if boxName == 'inbox' and pageNumber == 1: - if todaysEventsCheck(baseDir, nickname, domain): - now = datetime.now() - + now = datetime.now() + tomorrow = datetime.now() + timedelta(1) + twodays = datetime.now() + timedelta(2) + if dayEventsCheck(baseDir, nickname, domain, now): # happening today button if not iconsAsButtons: happeningStr += \ @@ -166,43 +171,41 @@ def headerButtonsTimeline(defaultTimeline: str, '' - # happening this week button - if thisWeeksEventsCheck(baseDir, nickname, domain): - if not iconsAsButtons: - happeningStr += \ - '' + \ - '' - else: - happeningStr += \ - '' + \ - '' - else: - # happening this week button - if thisWeeksEventsCheck(baseDir, nickname, domain): - if not iconsAsButtons: - happeningStr += \ - '' + \ - '' - else: - happeningStr += \ - '' + \ - '' + elif dayEventsCheck(baseDir, nickname, domain, tomorrow): + # happening tomorrow button + if not iconsAsButtons: + happeningStr += \ + '' + \ + '' + else: + happeningStr += \ + '' + \ + '' + elif dayEventsCheck(baseDir, nickname, domain, twodays): + if not iconsAsButtons: + happeningStr += \ + '' + \ + '' + else: + happeningStr += \ + '' + \ + '' if not featuresHeader: # button for the outbox tlStr += \ - '' # add other buttons diff --git a/webapp_login.py b/webapp_login.py index b0e893d08..45de12739 100644 --- a/webapp_login.py +++ b/webapp_login.py @@ -5,20 +5,22 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface" import os import time from shutil import copyfile from utils import getConfigParam from utils import noOfAccounts -from webapp_utils import htmlHeaderWithExternalStyle +from webapp_utils import htmlHeaderWithWebsiteMarkup from webapp_utils import htmlFooter from webapp_utils import htmlKeyboardNavigation from theme import getTextModeLogo def htmlGetLoginCredentials(loginParams: str, - lastLoginTime: int) -> (str, str, bool): + lastLoginTime: int, + domain: str) -> (str, str, bool): """Receives login credentials via HTTPServer POST """ if not loginParams.startswith('username='): @@ -34,18 +36,27 @@ def htmlGetLoginCredentials(loginParams: str, password = None register = False for arg in loginArgs: - if '=' in arg: - if arg.split('=', 1)[0] == 'username': - nickname = arg.split('=', 1)[1] - elif arg.split('=', 1)[0] == 'password': - password = arg.split('=', 1)[1] - elif arg.split('=', 1)[0] == 'register': - register = True + if '=' not in arg: + continue + if arg.split('=', 1)[0] == 'username': + nickname = arg.split('=', 1)[1] + if nickname.startswith('@'): + nickname = nickname[1:] + if '@' in nickname: + # the full nickname@domain has been entered + nickname = nickname.split('@')[0] + elif arg.split('=', 1)[0] == 'password': + password = arg.split('=', 1)[1] + elif arg.split('=', 1)[0] == 'register': + register = True return nickname, password, register def htmlLogin(cssCache: {}, translate: {}, - baseDir: str, autocomplete=True) -> str: + baseDir: str, + httpPrefix: str, domain: str, + systemLanguage: str, + autocomplete: bool = True) -> str: """Shows the login screen """ accounts = noOfAccounts(baseDir) @@ -78,7 +89,7 @@ def htmlLogin(cssCache: {}, translate: {}, copyfile(baseDir + '/img/login.png', loginImageFilename) textModeLogo = getTextModeLogo(baseDir) - textModeLogoHtml = htmlKeyboardNavigation(textModeLogo, {}) + textModeLogoHtml = htmlKeyboardNavigation(textModeLogo, {}, {}) if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'): if not os.path.isfile(baseDir + '/accounts/login-background.jpg'): @@ -93,8 +104,7 @@ def htmlLogin(cssCache: {}, translate: {}, else: loginText = \ '' - loginText += \ + translate['Please enter some credentials'] + '

' + \ '' @@ -122,8 +132,7 @@ def htmlLogin(cssCache: {}, translate: {}, TOSstr = \ '' - TOSstr += \ + translate['About this Instance'] + '

' + \ '' @@ -139,35 +148,36 @@ def htmlLogin(cssCache: {}, translate: {}, instanceTitle = \ getConfigParam(baseDir, 'instanceTitle') - loginForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - loginForm += '
\n' - loginForm += '
\n' - loginForm += '
\n' + loginForm = \ + htmlHeaderWithWebsiteMarkup(cssFilename, instanceTitle, + httpPrefix, domain, + systemLanguage) instanceTitle = getConfigParam(baseDir, 'instanceTitle') - loginForm += textModeLogoHtml + '\n' loginForm += \ + '
\n' + \ + '\n' + \ + '
\n' + \ + textModeLogoHtml + '\n' + \ ' ' + instanceTitle + '\n' - loginForm += loginText + TOSstr + '\n' - loginForm += '
\n' - loginForm += '\n' - loginForm += '
\n' - loginForm += ' \n' - loginForm += \ + '" alt="' + instanceTitle + '" class="loginimage">\n' + \ + loginText + TOSstr + '\n' + \ + '
\n' + \ + '\n' + \ + '
\n' + \ + ' \n' + \ ' \n' - loginForm += '\n' - loginForm += ' \n' - loginForm += \ + translate['Enter Nickname'] + \ + '" name="username" required autofocus>\n' + \ + '\n' + \ + ' \n' + \ ' \n' - loginForm += loginButtonStr + registerButtonStr + '\n' - loginForm += '
\n' - loginForm += '\n' - loginForm += \ + '" name="password" required>\n' + \ + loginButtonStr + registerButtonStr + '\n' + \ + '
\n' + \ + '\n' + \ '' + \ '' + \
diff --git a/webapp_media.py b/webapp_media.py
index f837e9c20..bf666063b 100644
--- a/webapp_media.py
+++ b/webapp_media.py
@@ -5,8 +5,10 @@ __version__ = None: @@ -30,7 +32,7 @@ def loadPeertubeInstances(baseDir: str, peertubeInstances: []) -> None: def _addEmbeddedVideoFromSites(translate: {}, content: str, peertubeInstances: [], - width=400, height=300) -> str: + width: int = 400, height: int = 300) -> str: """Adds embedded videos """ if '>vimeo.com/' in content: @@ -109,6 +111,8 @@ def _addEmbeddedVideoFromSites(translate: {}, content: str, if '"https://' in content: if peertubeInstances: + # only create an embedded video for a limited set of + # peertube sites. peerTubeSites = peertubeInstances else: # A default selection of the current larger peertube sites, @@ -159,19 +163,21 @@ def _addEmbeddedVideoFromSites(translate: {}, content: str, else: siteStr = 'https://' + site siteStr = '"' + siteStr - if siteStr in content: - url = content.split(siteStr)[1] - if '"' in url: - url = url.split('"')[0].replace('/watch/', '/embed/') - content = \ - content + "
\n\n
\n" - return content + if siteStr not in content: + continue + url = content.split(siteStr)[1] + if '"' not in url: + continue + url = url.split('"')[0].replace('/watch/', '/embed/') + content = \ + content + "
\n\n
\n" + return content return content @@ -204,24 +210,18 @@ def _addEmbeddedAudio(translate: {}, content: str) -> str: if not w.endswith(extension): continue - if not (w.startswith('http') or w.startswith('dat:') or - w.startswith('hyper:') or w.startswith('i2p:') or - w.startswith('gnunet:') or - '/' in w): + if not validUrlPrefix(w): continue - url = w - content += '
\n\n
\n' + '
\n\n
\n' return content -def _addEmbeddedVideo(translate: {}, content: str, - width=400, height=300) -> str: +def _addEmbeddedVideo(translate: {}, content: str) -> str: """Adds embedded video for mp4/webm/ogv """ if not ('.mp4' in content or '.webm' in content or '.ogv' in content): @@ -251,21 +251,17 @@ def _addEmbeddedVideo(translate: {}, content: str, w = w[:-1] if not w.endswith(extension): continue - if not (w.startswith('http') or w.startswith('dat:') or - w.startswith('hyper:') or w.startswith('i2p:') or - w.startswith('gnunet:') or - '/' in w): + if not validUrlPrefix(w): continue - url = w content += \ - '
\n\n
\n' + '
\n' + \ + ' \n
\n
\n' return content diff --git a/webapp_minimalbutton.py b/webapp_minimalbutton.py new file mode 100644 index 000000000..b3e3ce40b --- /dev/null +++ b/webapp_minimalbutton.py @@ -0,0 +1,40 @@ +__filename__ = "webapp_minimalbutton.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@freedombone.net" +__status__ = "Production" +__module_group__ = "Timeline" + +import os +from utils import acctDir + + +def isMinimal(baseDir: str, domain: str, nickname: str) -> bool: + """Returns true if minimal buttons should be shown + for the given account + """ + accountDir = acctDir(baseDir, nickname, domain) + if not os.path.isdir(accountDir): + return True + minimalFilename = accountDir + '/.notminimal' + if os.path.isfile(minimalFilename): + return False + return True + + +def setMinimal(baseDir: str, domain: str, nickname: str, + minimal: bool) -> None: + """Sets whether an account should display minimal buttons + """ + accountDir = acctDir(baseDir, nickname, 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') diff --git a/webapp_moderation.py b/webapp_moderation.py index 87d11da47..a58b95f3e 100644 --- a/webapp_moderation.py +++ b/webapp_moderation.py @@ -5,8 +5,10 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Moderation" import os +from utils import isAccountDir from utils import getFullDomain from utils import isEditor from utils import loadJson @@ -44,7 +46,8 @@ def htmlModeration(cssCache: {}, defaultTimeline: str, authorized: bool, moderationActionStr: str, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the moderation feed as html This is what you see when selecting the "mod" timeline """ @@ -60,7 +63,7 @@ def htmlModeration(cssCache: {}, defaultTimeline: str, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, moderationActionStr, theme, peertubeInstances, allowLocalNetworkAccess, - textModeBanner) + textModeBanner, accessKeys) def htmlAccountInfo(cssCache: {}, translate: {}, @@ -268,9 +271,7 @@ def htmlModerationInfo(cssCache: {}, translate: {}, accounts = [] 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 accounts.append(acct) break @@ -328,7 +329,7 @@ def htmlModerationInfo(cssCache: {}, translate: {}, suspendedFilename = baseDir + '/accounts/suspended.txt' if os.path.isfile(suspendedFilename): - with open(suspendedFilename, "r") as f: + with open(suspendedFilename, 'r') as f: suspendedStr = f.read() infoForm += '
\n' infoForm += '
' + \ @@ -337,14 +338,14 @@ def htmlModerationInfo(cssCache: {}, translate: {}, translate['These are currently suspended'] infoForm += \ ' \n' infoForm += '
\n' infoShown = True blockingFilename = baseDir + '/accounts/blocking.txt' if os.path.isfile(blockingFilename): - with open(blockingFilename, "r") as f: + with open(blockingFilename, 'r') as f: blockedStr = f.read() infoForm += '
\n' infoForm += \ @@ -355,14 +356,14 @@ def htmlModerationInfo(cssCache: {}, translate: {}, translate[msgStr1] infoForm += \ ' \n' infoForm += '
\n' infoShown = True filtersFilename = baseDir + '/accounts/filters.txt' if os.path.isfile(filtersFilename): - with open(filtersFilename, "r") as f: + with open(filtersFilename, 'r') as f: filteredStr = f.read() infoForm += '
\n' infoForm += \ @@ -370,7 +371,7 @@ def htmlModerationInfo(cssCache: {}, translate: {}, translate['Filtered words'] + '' infoForm += \ ' \n' infoForm += '
\n' infoShown = True diff --git a/webapp_person_options.py b/webapp_person_options.py index 75dd21045..bf967f0e2 100644 --- a/webapp_person_options.py +++ b/webapp_person_options.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface" import os from shutil import copyfile @@ -18,10 +19,12 @@ from utils import removeHtml from utils import getDomainFromActor from utils import getNicknameFromActor from utils import isFeaturedWriter +from utils import acctDir from blocking import isBlocked from follow import isFollowerOfPerson from follow import isFollowingActor from followingCalendar import receivingCalendarEvents +from notifyOnPost import notifyWhenPersonPosts from webapp_utils import htmlHeaderWithExternalStyle from webapp_utils import htmlFooter from webapp_utils import getBrokenLinkSubstitute @@ -44,6 +47,7 @@ def htmlPersonOptions(defaultTimeline: str, toxAddress: str, briarAddress: str, jamiAddress: str, + cwtchAddress: str, PGPpubKey: str, PGPfingerprint: str, emailAddress: str, @@ -53,7 +57,9 @@ def htmlPersonOptions(defaultTimeline: str, movedTo: str, alsoKnownAs: [], textModeBanner: str, - newsInstance: bool) -> str: + newsInstance: bool, + authorized: bool, + accessKeys: {}) -> str: """Show options for a person: view/follow/block/report """ optionsDomain, optionsPort = getDomainFromActor(optionsActor) @@ -118,7 +124,7 @@ def htmlPersonOptions(defaultTimeline: str, instanceTitle = \ getConfigParam(baseDir, 'instanceTitle') optionsStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - optionsStr += htmlKeyboardNavigation(textModeBanner, {}) + optionsStr += htmlKeyboardNavigation(textModeBanner, {}, {}) optionsStr += '

\n' optionsStr += '
\n' optionsStr += '
\n' @@ -211,6 +217,9 @@ def htmlPersonOptions(defaultTimeline: str, if jamiAddress: optionsStr += \ '

Jami: ' + removeHtml(jamiAddress) + '

\n' + if cwtchAddress: + optionsStr += \ + '

Cwtch: ' + removeHtml(cwtchAddress) + '

\n' if PGPfingerprint: optionsStr += '

PGP: ' + \ removeHtml(PGPfingerprint).replace('\n', '
') + '

\n' @@ -225,87 +234,108 @@ def htmlPersonOptions(defaultTimeline: str, optionsActor + '">\n' optionsStr += ' \n' - if optionsNickname: - handle = optionsNickname + '@' + optionsDomainFull - petname = getPetName(baseDir, nickname, domain, handle) - optionsStr += \ - ' ' + translate['Petname'] + ': \n' + \ - ' \n' \ - '
\n' + if authorized: + if originPathStr == '/users/' + nickname: + if optionsNickname: + # handle = optionsNickname + '@' + optionsDomainFull + petname = getPetName(baseDir, nickname, domain, handle) + optionsStr += \ + ' ' + translate['Petname'] + ': \n' + \ + ' \n' \ + '
\n' - # checkbox for receiving calendar events - if isFollowingActor(baseDir, nickname, domain, optionsActor): - checkboxStr = \ - ' ' + \ - translate['Receive calendar events from this account'] + \ - '\n
\n' - if not receivingCalendarEvents(baseDir, nickname, domain, - optionsNickname, optionsDomainFull): - checkboxStr = checkboxStr.replace(' checked>', '>') - optionsStr += checkboxStr + # Notify when a post arrives from this person + if isFollowingActor(baseDir, nickname, domain, optionsActor): + checkboxStr = \ + ' 🔔' + \ + translate['Notify me when this account posts'] + \ + '\n
\n' + if not notifyWhenPersonPosts(baseDir, nickname, domain, + optionsNickname, + optionsDomainFull): + checkboxStr = checkboxStr.replace(' checked>', '>') + optionsStr += checkboxStr - # checkbox for permission to post to newswire - newswirePostsPermitted = False - if optionsDomainFull == domainFull: - adminNickname = getConfigParam(baseDir, 'admin') - if (nickname == adminNickname or - (isModerator(baseDir, nickname) and - not isModerator(baseDir, optionsNickname))): - newswireBlockedFilename = \ - baseDir + '/accounts/' + \ - optionsNickname + '@' + optionsDomain + '/.nonewswire' - checkboxStr = \ - ' ' + \ - translate['Allow news posts'] + \ - '\n
\n' - if os.path.isfile(newswireBlockedFilename): - checkboxStr = checkboxStr.replace(' checked>', '>') - else: - newswirePostsPermitted = True - optionsStr += checkboxStr + checkboxStr = \ + ' ' + \ + translate['Receive calendar events from this account'] + \ + '\n
\n' + if not receivingCalendarEvents(baseDir, nickname, domain, + optionsNickname, + optionsDomainFull): + checkboxStr = checkboxStr.replace(' checked>', '>') + optionsStr += checkboxStr - # whether blogs created by this account are moderated on the newswire - if newswirePostsPermitted: - moderatedFilename = \ - baseDir + '/accounts/' + \ - optionsNickname + '@' + optionsDomain + '/.newswiremoderated' - checkboxStr = \ - ' ' + \ - translate['News posts are moderated'] + \ - '\n
\n' - if not os.path.isfile(moderatedFilename): - checkboxStr = checkboxStr.replace(' checked>', '>') - optionsStr += checkboxStr + # checkbox for permission to post to newswire + newswirePostsPermitted = False + if optionsDomainFull == domainFull: + adminNickname = getConfigParam(baseDir, 'admin') + if (nickname == adminNickname or + (isModerator(baseDir, nickname) and + not isModerator(baseDir, optionsNickname))): + newswireBlockedFilename = \ + baseDir + '/accounts/' + \ + optionsNickname + '@' + optionsDomain + '/.nonewswire' + checkboxStr = \ + ' ' + \ + translate['Allow news posts'] + \ + '\n
\n' + if os.path.isfile(newswireBlockedFilename): + checkboxStr = checkboxStr.replace(' checked>', '>') + else: + newswirePostsPermitted = True + optionsStr += checkboxStr - # checkbox for permission to post to featured articles - if newsInstance and optionsDomainFull == domainFull: - adminNickname = getConfigParam(baseDir, 'admin') - if (nickname == adminNickname or - (isModerator(baseDir, nickname) and - not isModerator(baseDir, optionsNickname))): - checkboxStr = \ - ' ' + \ - translate['Featured writer'] + \ - '\n
\n' - if not isFeaturedWriter(baseDir, optionsNickname, - optionsDomain): - checkboxStr = checkboxStr.replace(' checked>', '>') - optionsStr += checkboxStr + # whether blogs created by this account are moderated on + # the newswire + if newswirePostsPermitted: + moderatedFilename = \ + baseDir + '/accounts/' + \ + optionsNickname + '@' + \ + optionsDomain + '/.newswiremoderated' + checkboxStr = \ + ' ' + \ + translate['News posts are moderated'] + \ + '\n
\n' + if not os.path.isfile(moderatedFilename): + checkboxStr = checkboxStr.replace(' checked>', '>') + optionsStr += checkboxStr + + # checkbox for permission to post to featured articles + if newsInstance and optionsDomainFull == domainFull: + adminNickname = getConfigParam(baseDir, 'admin') + if (nickname == adminNickname or + (isModerator(baseDir, nickname) and + not isModerator(baseDir, optionsNickname))): + checkboxStr = \ + ' ' + \ + translate['Featured writer'] + \ + '\n
\n' + if not isFeaturedWriter(baseDir, optionsNickname, + optionsDomain): + checkboxStr = checkboxStr.replace(' checked>', '>') + optionsStr += checkboxStr optionsStr += optionsLinkStr backPath = '/' @@ -313,57 +343,80 @@ def htmlPersonOptions(defaultTimeline: str, backPath = '/users/' + nickname + '/' + defaultTimeline if 'moderation' in backToPath: backPath = '/users/' + nickname + '/moderation' - optionsStr += \ - '
\n' - optionsStr += \ - ' \n' - optionsStr += donateStr - optionsStr += \ - ' \n' - optionsStr += \ - ' \n' - optionsStr += \ - ' \n' - optionsStr += \ - ' \n' - optionsStr += \ - ' \n' - - if isModerator(baseDir, nickname): + if authorized and originPathStr == '/users/' + nickname: + optionsStr += \ + ' \n' + else: + optionsStr += \ + ' \n' + if authorized: optionsStr += \ ' \n' + 'name="submitView" accesskey="' + \ + accessKeys['viewButton'] + '">' + \ + translate['View'] + '\n' + optionsStr += donateStr + if authorized: + optionsStr += \ + ' \n' + optionsStr += \ + ' \n' + optionsStr += \ + ' \n' + optionsStr += \ + ' \n' + optionsStr += \ + ' \n' - personNotes = '' - personNotesFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + \ - '/notes/' + handle + '.txt' - if os.path.isfile(personNotesFilename): - with open(personNotesFilename, 'r') as fp: - personNotes = fp.read() + if isModerator(baseDir, nickname): + optionsStr += \ + ' \n' + + personNotes = '' + if originPathStr == '/users/' + nickname: + personNotesFilename = \ + acctDir(baseDir, nickname, domain) + \ + '/notes/' + handle + '.txt' + if os.path.isfile(personNotesFilename): + with open(personNotesFilename, 'r') as fp: + personNotes = fp.read() + + optionsStr += \ + '

' + translate['Notes'] + ': \n' + optionsStr += '
\n' + optionsStr += \ + ' \n' optionsStr += \ - '

' + translate['Notes'] + ': \n' - optionsStr += '
\n' - optionsStr += \ - ' \n' - - optionsStr += ' \n' - optionsStr += '\n' - optionsStr += '
\n' - optionsStr += '
\n' + ' \n' + \ + '\n' + \ + '\n' + \ + '\n' optionsStr += htmlFooter() return optionsStr diff --git a/webapp_post.py b/webapp_post.py index b5646c416..5eee1a3bc 100644 --- a/webapp_post.py +++ b/webapp_post.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface" import os import time @@ -19,9 +20,14 @@ from like import noOfLikes from follow import isFollowingActor from posts import postIsMuted from posts import getPersonBox -from posts import isDM from posts import downloadAnnounce from posts import populateRepliesJson +from utils import hasObjectDict +from utils import updateAnnounceCollection +from utils import isPGPEncrypted +from utils import isDM +from utils import rejectPostId +from utils import isRecentPost from utils import getConfigParam from utils import getFullDomain from utils import isEditor @@ -39,6 +45,8 @@ from utils import removeIdEnding from utils import getNicknameFromActor from utils import getDomainFromActor from utils import isEventPost +from utils import acctDir +from content import limitRepeatedWords from content import replaceEmojiFromTags from content import htmlReplaceQuoteMarks from content import htmlReplaceEmailQuote @@ -47,9 +55,9 @@ from content import removeLongWords from content import getMentionsFromHtml from content import switchWords from person import isPersonSnoozed +from person import getPersonAvatarUrl from announce import announcedByPerson from webapp_utils import getAvatarImageUrl -from webapp_utils import getPersonAvatarUrl from webapp_utils import updateAvatarImageCache from webapp_utils import loadIndividualPostAsHtmlFromCache from webapp_utils import addEmojiToDisplayName @@ -63,6 +71,7 @@ from webapp_media import addEmbeddedElements from webapp_question import insertQuestion from devices import E2EEdecryptMessageFromDevice from webfinger import webfingerHandle +from speaker import updateSpeaker def _logPostTiming(enableTimingLog: bool, postStartTime, debugId: str) -> None: @@ -155,7 +164,7 @@ def _saveIndividualPostAsHtmlToCache(baseDir: str, fp.write(postHtml) return True except Exception as e: - print('ERROR: saving post to cache ' + str(e)) + print('ERROR: saving post to cache, ' + str(e)) return False @@ -380,7 +389,10 @@ def _getEditIconHtml(baseDir: str, nickname: str, domainFull: str, return editStr -def _getAnnounceIconHtml(nickname: str, domainFull: str, +def _getAnnounceIconHtml(isAnnounced: bool, + postActor: str, + nickname: str, domainFull: str, + announceJsonObject: {}, postJsonObject: {}, isPublicRepeat: bool, isModerationPost: bool, @@ -392,35 +404,48 @@ def _getAnnounceIconHtml(nickname: str, domainFull: str, """Returns html for announce icon/button """ announceStr = '' - if not isModerationPost and showRepeatIcon: - # don't allow announce/repeat of your own posts - announceIcon = 'repeat_inactive.png' - announceLink = 'repeat' - announceEmoji = '' + + if not showRepeatIcon: + return announceStr + + if isModerationPost: + return announceStr + + # don't allow announce/repeat of your own posts + announceIcon = 'repeat_inactive.png' + announceLink = 'repeat' + announceEmoji = '' + if not isPublicRepeat: + announceLink = 'repeatprivate' + announceTitle = translate['Repeat this post'] + unannounceLinkStr = '' + + if announcedByPerson(isAnnounced, + postActor, nickname, domainFull): + announceIcon = 'repeat.png' + announceEmoji = '🔁 ' + announceLink = 'unrepeat' if not isPublicRepeat: - announceLink = 'repeatprivate' - announceTitle = translate['Repeat this post'] + announceLink = 'unrepeatprivate' + announceTitle = translate['Undo the repeat'] + if announceJsonObject: + unannounceLinkStr = '?unannounce=' + \ + removeIdEnding(announceJsonObject['id']) - if announcedByPerson(postJsonObject, nickname, domainFull): - announceIcon = 'repeat.png' - announceEmoji = '🔁 ' - if not isPublicRepeat: - announceLink = 'unrepeatprivate' - announceTitle = translate['Undo the repeat'] + announceLinkStr = '?' + \ + announceLink + '=' + postJsonObject['object']['id'] + pageNumberParam + announceStr = \ + ' \n' - announceStr = \ - ' \n' - - announceStr += \ - ' ' + \ - '' + announceEmoji + translate['Repeat this post'] + \
-            ' |\n' + announceStr += \ + ' ' + \ + '' + announceEmoji + announceTitle + \
+        ' |\n' return announceStr @@ -818,8 +843,7 @@ def _getPostTitleAnnounceHtml(baseDir: str, postJsonObject) else: titleStr += \ - _announceUnattributedHtml(translate, - postJsonObject) + _announceUnattributedHtml(translate, postJsonObject) else: titleStr += \ _announceUnattributedHtml(translate, postJsonObject) @@ -1136,11 +1160,12 @@ def individualPostAsHtml(allowDownloads: bool, showPublishedDateOnly: bool, peertubeInstances: [], allowLocalNetworkAccess: bool, - showRepeats=True, - showIcons=False, - manuallyApprovesFollowers=False, - showPublicOnly=False, - storeToCache=True) -> str: + themeName: str, + showRepeats: bool = True, + showIcons: bool = False, + manuallyApprovesFollowers: bool = False, + showPublicOnly: bool = False, + storeToCache: bool = True) -> str: """ Shows a single post as html """ if not postJsonObject: @@ -1218,7 +1243,7 @@ def individualPostAsHtml(allowDownloads: bool, postActorWf = \ webfingerHandle(session, postActorHandle, httpPrefix, cachedWebfingers, - domain, __version__) + domain, __version__, False) avatarUrl2 = None displayName = None @@ -1257,11 +1282,6 @@ def individualPostAsHtml(allowDownloads: bool, avatarImageInPost = \ '
' + avatarLink + '
\n' - # don't create new html within the bookmarks timeline - # it should already have been created for the inbox - if boxName == 'tlbookmarks' or boxName == 'bookmarks': - return '' - timelinePostBookmark = removeIdEnding(postJsonObject['id']) timelinePostBookmark = timelinePostBookmark.replace('://', '-') timelinePostBookmark = timelinePostBookmark.replace('/', '-') @@ -1269,10 +1289,9 @@ def individualPostAsHtml(allowDownloads: bool, # If this is the inbox timeline then don't show the repeat icon on any DMs showRepeatIcon = showRepeats isPublicRepeat = False - showDMicon = False + postIsDM = isDM(postJsonObject) if showRepeats: - if isDM(postJsonObject): - showDMicon = True + if postIsDM: showRepeatIcon = False else: if not isPublicPost(postJsonObject): @@ -1281,21 +1300,48 @@ def individualPostAsHtml(allowDownloads: bool, titleStr = '' galleryStr = '' isAnnounced = False + announceJsonObject = None if postJsonObject['type'] == 'Announce': + announceJsonObject = postJsonObject.copy() postJsonAnnounce = \ downloadAnnounce(session, baseDir, httpPrefix, nickname, domain, postJsonObject, projectVersion, translate, YTReplacementDomain, - allowLocalNetworkAccess) + allowLocalNetworkAccess, + recentPostsCache, False) if not postJsonAnnounce: + # if the announce could not be downloaded then mark it as rejected + rejectPostId(baseDir, nickname, domain, postJsonObject['id'], + recentPostsCache) return '' postJsonObject = postJsonAnnounce + + announceFilename = \ + locatePost(baseDir, nickname, domain, + postJsonObject['id']) + if announceFilename: + updateAnnounceCollection(recentPostsCache, + baseDir, announceFilename, + postActor, nickname, domainFull, False) + + # create a file for use by text-to-speech + if isRecentPost(postJsonObject): + if postJsonObject.get('actor'): + if not os.path.isfile(announceFilename + '.tts'): + updateSpeaker(baseDir, httpPrefix, + nickname, domain, domainFull, + postJsonObject, personCache, + translate, postJsonObject['actor'], + themeName) + with open(announceFilename + '.tts', 'w+') as ttsFile: + ttsFile.write('\n') + isAnnounced = True _logPostTiming(enableTimingLog, postStartTime, '8') - if not isinstance(postJsonObject['object'], dict): + if not hasObjectDict(postJsonObject): return '' # if this post should be public then check its recipients @@ -1347,7 +1393,7 @@ def individualPostAsHtml(allowDownloads: bool, _logPostTiming(enableTimingLog, postStartTime, '9') # Show a DM icon for DMs in the inbox timeline - if showDMicon: + if postIsDM: titleStr = \ titleStr + ' \n' @@ -1357,6 +1403,9 @@ def individualPostAsHtml(allowDownloads: bool, if 'commentsEnabled' in postJsonObject['object']: if postJsonObject['object']['commentsEnabled'] is False: commentsEnabled = False + elif 'rejectReplies' in postJsonObject['object']: + if postJsonObject['object']['rejectReplies']: + commentsEnabled = False replyStr = _getReplyIconHtml(nickname, isPublicRepeat, showIcons, commentsEnabled, @@ -1374,7 +1423,10 @@ def individualPostAsHtml(allowDownloads: bool, translate, isEvent) announceStr = \ - _getAnnounceIconHtml(nickname, domainFull, + _getAnnounceIconHtml(isAnnounced, + postActor, + nickname, domainFull, + announceJsonObject, postJsonObject, isPublicRepeat, isModerationPost, @@ -1388,7 +1440,7 @@ def individualPostAsHtml(allowDownloads: bool, # whether to show a like button hideLikeButtonFile = \ - baseDir + '/accounts/' + nickname + '@' + domain + '/.hideLikeButton' + acctDir(baseDir, nickname, domain) + '/.hideLikeButton' showLikeButton = True if os.path.isfile(hideLikeButtonFile): showLikeButton = False @@ -1501,7 +1553,7 @@ def individualPostAsHtml(allowDownloads: bool, '" class="' + timeClass + '">' + publishedStr + '\n' # change the background color for DMs in inbox timeline - if showDMicon: + if postIsDM: containerClassIcons = 'containericons dm' containerClass = 'container dm' @@ -1546,17 +1598,21 @@ def individualPostAsHtml(allowDownloads: bool, _logPostTiming(enableTimingLog, postStartTime, '16') - if not isPatch: - objectContent = \ - removeLongWords(postJsonObject['object']['content'], 40, []) - objectContent = removeTextFormatting(objectContent) - objectContent = \ - switchWords(baseDir, nickname, domain, objectContent) - objectContent = htmlReplaceEmailQuote(objectContent) - objectContent = htmlReplaceQuoteMarks(objectContent) + if not isPGPEncrypted(postJsonObject['object']['content']): + if not isPatch: + objectContent = \ + removeLongWords(postJsonObject['object']['content'], 40, []) + objectContent = removeTextFormatting(objectContent) + objectContent = limitRepeatedWords(objectContent, 6) + objectContent = \ + switchWords(baseDir, nickname, domain, objectContent) + objectContent = htmlReplaceEmailQuote(objectContent) + objectContent = htmlReplaceQuoteMarks(objectContent) + else: + objectContent = \ + postJsonObject['object']['content'] else: - objectContent = \ - postJsonObject['object']['content'] + objectContent = '🔒 ' + translate['Encrypted'] objectContent = '
' + objectContent + '
' @@ -1662,7 +1718,8 @@ def htmlIndividualPost(cssCache: {}, YTReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], - allowLocalNetworkAccess: bool) -> str: + allowLocalNetworkAccess: bool, + themeName: str) -> str: """Show an individual post as html """ postStr = '' @@ -1703,12 +1760,12 @@ def htmlIndividualPost(cssCache: {}, YTReplacementDomain, showPublishedDateOnly, peertubeInstances, - allowLocalNetworkAccess, + allowLocalNetworkAccess, themeName, False, authorized, False, False, False) messageId = removeIdEnding(postJsonObject['id']) # show the previous posts - if isinstance(postJsonObject['object'], dict): + if hasObjectDict(postJsonObject): while postJsonObject['object'].get('inReplyTo'): postFilename = \ locatePost(baseDir, nickname, domain, @@ -1731,6 +1788,7 @@ def htmlIndividualPost(cssCache: {}, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, + themeName, False, authorized, False, False, False) + postStr @@ -1761,6 +1819,7 @@ def htmlIndividualPost(cssCache: {}, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, + themeName, False, authorized, False, False, False) cssFilename = baseDir + '/epicyon-profile.css' @@ -1782,7 +1841,8 @@ def htmlPostReplies(cssCache: {}, YTReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], - allowLocalNetworkAccess: bool) -> str: + allowLocalNetworkAccess: bool, + themeName: str) -> str: """Show the replies to an individual post as html """ repliesStr = '' @@ -1801,6 +1861,7 @@ def htmlPostReplies(cssCache: {}, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, + themeName, False, False, False, False, False) cssFilename = baseDir + '/epicyon-profile.css' diff --git a/webapp_profile.py b/webapp_profile.py index dc01f0c98..b25891efb 100644 --- a/webapp_profile.py +++ b/webapp_profile.py @@ -5,12 +5,15 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface" import os from pprint import pprint +from utils import hasObjectDict +from utils import getOccupationName from utils import getLockedAccount -from utils import hasUsersPath from utils import getFullDomain +from utils import isArtist from utils import isDormant from utils import getNicknameFromActor from utils import getDomainFromActor @@ -19,13 +22,14 @@ from utils import removeHtml from utils import loadJson from utils import getConfigParam from utils import getImageFormats +from utils import acctDir from skills import getSkills from theme import getThemesList from person import personBoxJson +from person import getActorJson +from person import getPersonAvatarUrl from webfinger import webfingerHandle -from session import getJson from posts import parseUserFeed -from posts import getUserUrl from posts import getPersonBox from donate import getDonationUrl from xmpp import getXmppAddress @@ -37,19 +41,20 @@ from pgp import getPGPpubKey from tox import getToxAddress from briar import getBriarAddress from jami import getJamiAddress +from cwtch import getCwtchAddress from filters import isFiltered from follow import isFollowerOfPerson from webapp_frontscreen import htmlFrontScreen from webapp_utils import htmlKeyboardNavigation from webapp_utils import htmlHideFromScreenReader from webapp_utils import scheduledPostsExist -from webapp_utils import getPersonAvatarUrl from webapp_utils import htmlHeaderWithExternalStyle +from webapp_utils import htmlHeaderWithPersonMarkup from webapp_utils import htmlFooter from webapp_utils import addEmojiToDisplayName from webapp_utils import getBannerFile from webapp_utils import htmlPostSeparator -from webapp_utils import getBlogAddress +from blog import getBlogAddress from webapp_post import individualPostAsHtml from webapp_timeline import htmlIndividualShare @@ -66,43 +71,25 @@ def htmlProfileAfterSearch(cssCache: {}, showPublishedDateOnly: bool, defaultTimeline: str, peertubeInstances: [], - allowLocalNetworkAccess: bool) -> str: + allowLocalNetworkAccess: bool, + themeName: str, + accessKeys: {}) -> str: """Show a profile page after a search for a fediverse address """ - if hasUsersPath(profileHandle) or '/@' in profileHandle: - searchNickname = getNicknameFromActor(profileHandle) - searchDomain, searchPort = getDomainFromActor(profileHandle) - else: - if '@' not in profileHandle: - print('DEBUG: no @ in ' + profileHandle) - return None - if profileHandle.startswith('@'): - profileHandle = profileHandle[1:] - if '@' not in profileHandle: - print('DEBUG: no @ in ' + profileHandle) - return None - searchNickname = profileHandle.split('@')[0] - searchDomain = profileHandle.split('@')[1] - searchPort = None - if ':' in searchDomain: - searchPortStr = searchDomain.split(':')[1] - if searchPortStr.isdigit(): - searchPort = int(searchPortStr) - searchDomain = searchDomain.split(':')[0] - if searchPort: - print('DEBUG: Search for handle ' + - str(searchNickname) + '@' + str(searchDomain) + ':' + - str(searchPort)) - else: - print('DEBUG: Search for handle ' + - str(searchNickname) + '@' + str(searchDomain)) - if not searchNickname: - print('DEBUG: No nickname found in ' + profileHandle) - return None - if not searchDomain: - print('DEBUG: No domain found in ' + profileHandle) + http = False + gnunet = False + if httpPrefix == 'http': + http = True + elif httpPrefix == 'gnunet': + gnunet = True + profileJson, asHeader = \ + getActorJson(domain, profileHandle, http, gnunet, debug, False) + if not profileJson: return None + personUrl = profileJson['id'] + searchDomain, searchPort = getDomainFromActor(personUrl) + searchNickname = getNicknameFromActor(personUrl) searchDomainFull = getFullDomain(searchDomain, searchPort) profileStr = '' @@ -110,55 +97,6 @@ def htmlProfileAfterSearch(cssCache: {}, if os.path.isfile(baseDir + '/epicyon.css'): cssFilename = baseDir + '/epicyon.css' - wf = \ - webfingerHandle(session, - searchNickname + '@' + searchDomainFull, - httpPrefix, cachedWebfingers, - domain, projectVersion) - if not wf: - print('DEBUG: Unable to webfinger ' + - searchNickname + '@' + searchDomainFull) - print('DEBUG: cachedWebfingers ' + str(cachedWebfingers)) - print('DEBUG: httpPrefix ' + httpPrefix) - print('DEBUG: domain ' + domain) - return None - if not isinstance(wf, dict): - print('WARN: Webfinger search for ' + - searchNickname + '@' + searchDomainFull + - ' did not return a dict. ' + - str(wf)) - return None - - personUrl = None - if wf.get('errors'): - personUrl = httpPrefix + '://' + \ - searchDomainFull + '/users/' + searchNickname - - profileStr = 'https://www.w3.org/ns/activitystreams' - asHeader = { - 'Accept': 'application/activity+json; profile="' + profileStr + '"' - } - if not personUrl: - personUrl = getUserUrl(wf) - if not personUrl: - # try single user instance - asHeader = { - 'Accept': 'application/ld+json; profile="' + profileStr + '"' - } - personUrl = httpPrefix + '://' + searchDomainFull - profileJson = \ - getJson(session, personUrl, asHeader, None, - projectVersion, httpPrefix, domain) - if not profileJson: - asHeader = { - 'Accept': 'application/ld+json; profile="' + profileStr + '"' - } - profileJson = \ - getJson(session, personUrl, asHeader, None, - projectVersion, httpPrefix, domain) - if not profileJson: - print('DEBUG: No actor returned from ' + personUrl) - return None avatarUrl = '' if profileJson.get('icon'): if profileJson['icon'].get('url'): @@ -236,6 +174,11 @@ def htmlProfileAfterSearch(cssCache: {}, if profileJson.get('alsoKnownAs'): alsoKnownAs = profileJson['alsoKnownAs'] + joinedDate = None + if profileJson.get('published'): + if 'T' in profileJson['published']: + joinedDate = profileJson['published'] + profileStr = \ _getProfileHeaderAfterSearch(baseDir, nickname, defaultTimeline, @@ -246,7 +189,8 @@ def htmlProfileAfterSearch(cssCache: {}, profileDescriptionShort, avatarUrl, imageUrl, movedTo, profileJson['id'], - alsoKnownAs) + alsoKnownAs, accessKeys, + joinedDate) domainFull = getFullDomain(domain, port) @@ -267,10 +211,12 @@ def htmlProfileAfterSearch(cssCache: {}, ' \n' profileStr += \ - ' \n' profileStr += \ - ' \n' profileStr += ' \n' profileStr += ' \n' @@ -287,7 +233,7 @@ def htmlProfileAfterSearch(cssCache: {}, continue if item['type'] != 'Create': continue - if not item.get('object'): + if not hasObjectDict(item): continue profileStr += \ @@ -300,6 +246,7 @@ def htmlProfileAfterSearch(cssCache: {}, YTReplacementDomain, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, + themeName, False, False, False, False, False) i += 1 if i >= 20: @@ -321,7 +268,10 @@ def _getProfileHeader(baseDir: str, httpPrefix: str, loginButton: str, avatarUrl: str, theme: str, movedTo: str, alsoKnownAs: [], - pinnedContent) -> str: + pinnedContent: str, + accessKeys: {}, + joinedDate: str, + occupationName: str) -> str: """The header of the profile screen, containing background image and avatar """ @@ -339,9 +289,20 @@ def _getProfileHeader(baseDir: str, httpPrefix: str, translate['Switch to timeline view'] + '">\n' + \ ' \n' - htmlStr += '

' + displayName + '

\n' + + occupationStr = '' + if occupationName: + occupationStr += \ + ' ' + occupationName + '
\n' + + htmlStr += '

' + displayName + '

\n' + occupationStr + htmlStr += \ '

@' + nickname + '@' + domainFull + '
\n' + if joinedDate: + htmlStr += \ + '

' + translate['Joined'] + ' ' + \ + joinedDate.split('T')[0] + '
\n' if movedTo: newNickname = getNicknameFromActor(movedTo) newDomain, newPort = getDomainFromActor(movedTo) @@ -401,14 +362,17 @@ def _getProfileHeaderAfterSearch(baseDir: str, profileDescriptionShort: str, avatarUrl: str, imageUrl: str, movedTo: str, actor: str, - alsoKnownAs: []) -> str: + alsoKnownAs: [], + accessKeys: {}, + joinedDate: str) -> str: """The header of a searched for handle, containing background image and avatar """ htmlStr = '\n\n

\n' htmlStr += ' \n' + translate['Switch to timeline view'] + '" ' + \ + 'accesskey="' + accessKeys['menuTimeline'] + '">\n' htmlStr += ' \n' @@ -423,6 +387,9 @@ def _getProfileHeaderAfterSearch(baseDir: str, htmlStr += '

' + displayName + '

\n' htmlStr += \ '

@' + searchNickname + '@' + searchDomainFull + '
\n' + if joinedDate: + htmlStr += '

' + translate['Joined'] + ' ' + \ + joinedDate.split('T')[0] + '

\n' if followsYou: htmlStr += '

' + translate['Follows you'] + '

\n' if movedTo: @@ -479,8 +446,9 @@ def htmlProfile(rssIconAtTop: bool, peertubeInstances: [], allowLocalNetworkAccess: bool, textModeBanner: str, - extraJson=None, pageNumber=None, - maxItemsPerPage=None) -> str: + debug: bool, accessKeys: {}, city: str, + extraJson: {} = None, pageNumber: int = None, + maxItemsPerPage: int = None) -> str: """Show the profile page as html """ nickname = profileJson['preferredUsername'] @@ -498,7 +466,7 @@ def htmlProfile(rssIconAtTop: bool, YTReplacementDomain, showPublishedDateOnly, newswire, theme, extraJson, - allowLocalNetworkAccess, + allowLocalNetworkAccess, accessKeys, pageNumber, maxItemsPerPage) domain, port = getDomainFromActor(profileJson['id']) @@ -508,9 +476,7 @@ def htmlProfile(rssIconAtTop: bool, addEmojiToDisplayName(baseDir, httpPrefix, nickname, domain, profileJson['name'], True) - domainFull = domain - if port: - domainFull = domain + ':' + str(port) + domainFull = getFullDomain(domain, port) profileDescription = \ addEmojiToDisplayName(baseDir, httpPrefix, nickname, domain, @@ -553,9 +519,10 @@ def htmlProfile(rssIconAtTop: bool, toxAddress = getToxAddress(profileJson) briarAddress = getBriarAddress(profileJson) jamiAddress = getJamiAddress(profileJson) + cwtchAddress = getCwtchAddress(profileJson) if donateUrl or xmppAddress or matrixAddress or \ ssbAddress or toxAddress or briarAddress or \ - jamiAddress or PGPpubKey or \ + jamiAddress or cwtchAddress or PGPpubKey or \ PGPfingerprint or emailAddress: donateSection = '
\n' donateSection += '
\n' @@ -571,7 +538,7 @@ def htmlProfile(rssIconAtTop: bool, if xmppAddress: donateSection += \ '

' + translate['XMPP'] + ': '+xmppAddress + '

\n' + xmppAddress + '">' + xmppAddress + '

\n' if matrixAddress: donateSection += \ '

' + translate['Matrix'] + ': ' + matrixAddress + '

\n' @@ -596,6 +563,10 @@ def htmlProfile(rssIconAtTop: bool, donateSection += \ '

Jami:

\n' + if cwtchAddress: + donateSection += \ + '

Cwtch:

\n' if PGPfingerprint: donateSection += \ '

PGP: ' + \ @@ -622,8 +593,7 @@ def htmlProfile(rssIconAtTop: bool, # are there any follow requests? followRequestsFilename = \ - baseDir + '/accounts/' + \ - nickname + '@' + domain + '/followrequests.txt' + acctDir(baseDir, nickname, domain) + '/followrequests.txt' if os.path.isfile(followRequestsFilename): with open(followRequestsFilename, 'r') as f: for line in f: @@ -693,10 +663,23 @@ def htmlProfile(rssIconAtTop: bool, if profileJson.get('alsoKnownAs'): alsoKnownAs = profileJson['alsoKnownAs'] + joinedDate = None + if profileJson.get('published'): + if 'T' in profileJson['published']: + joinedDate = profileJson['published'] + occupationName = None + if profileJson.get('hasOccupation'): + occupationName = getOccupationName(profileJson) + avatarUrl = profileJson['icon']['url'] + # use alternate path for local avatars to avoid any caching issues + if '://' + domainFull + '/accounts/avatars/' in avatarUrl: + avatarUrl = \ + avatarUrl.replace('://' + domainFull + '/accounts/avatars/', + '://' + domainFull + '/users/') # get pinned post content - accountDir = baseDir + '/accounts/' + nickname + '@' + domain + accountDir = acctDir(baseDir, nickname, domain) pinnedFilename = accountDir + '/pinToProfile.txt' pinnedContent = None if os.path.isfile(pinnedFilename): @@ -712,7 +695,8 @@ def htmlProfile(rssIconAtTop: bool, profileDescriptionShort, loginButton, avatarUrl, theme, movedTo, alsoKnownAs, - pinnedContent) + pinnedContent, accessKeys, + joinedDate, occupationName) # keyboard navigation userPathStr = '/users/' + nickname @@ -744,7 +728,14 @@ def htmlProfile(rssIconAtTop: bool, menuShares: userPathStr + '/shares#timeline', menuLogout: '/logout' } - profileStr = htmlKeyboardNavigation(textModeBanner, navLinks) + navAccessKeys = {} + for variableName, key in accessKeys.items(): + if not locals().get(variableName): + continue + navAccessKeys[locals()[variableName]] = key + + profileStr = htmlKeyboardNavigation(textModeBanner, + navLinks, navAccessKeys) profileStr += profileHeaderStr + donateSection profileStr += '

\n' @@ -804,7 +795,8 @@ def htmlProfile(rssIconAtTop: bool, YTReplacementDomain, showPublishedDateOnly, peertubeInstances, - allowLocalNetworkAccess) + licenseStr + allowLocalNetworkAccess, + theme) + licenseStr elif selected == 'following': profileStr += \ _htmlProfileFollowing(translate, baseDir, httpPrefix, @@ -813,7 +805,7 @@ def htmlProfile(rssIconAtTop: bool, cachedWebfingers, personCache, extraJson, projectVersion, ["unfollow"], selected, usersPath, pageNumber, maxItemsPerPage, - dormantMonths) + dormantMonths, debug) elif selected == 'followers': profileStr += \ _htmlProfileFollowing(translate, baseDir, httpPrefix, @@ -822,7 +814,7 @@ def htmlProfile(rssIconAtTop: bool, cachedWebfingers, personCache, extraJson, projectVersion, ["block"], selected, usersPath, pageNumber, - maxItemsPerPage, dormantMonths) + maxItemsPerPage, dormantMonths, debug) elif selected == 'roles': profileStr += \ _htmlProfileRoles(translate, nickname, domainFull, @@ -841,7 +833,8 @@ def htmlProfile(rssIconAtTop: bool, instanceTitle = \ getConfigParam(baseDir, 'instanceTitle') profileStr = \ - htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + \ + htmlHeaderWithPersonMarkup(cssFilename, instanceTitle, + profileJson, city) + \ profileStr + htmlFooter() return profileStr @@ -856,7 +849,8 @@ def _htmlProfilePosts(recentPostsCache: {}, maxRecentPosts: int, YTReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], - allowLocalNetworkAccess: bool) -> str: + allowLocalNetworkAccess: bool, + themeName: str) -> str: """Shows posts on the profile screen These should only be public posts """ @@ -896,6 +890,7 @@ def _htmlProfilePosts(recentPostsCache: {}, maxRecentPosts: int, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, + themeName, False, False, False, True, False) if postStr: profileStr += postStr + separatorStr @@ -915,7 +910,7 @@ def _htmlProfileFollowing(translate: {}, baseDir: str, httpPrefix: str, feedName: str, actor: str, pageNumber: int, maxItemsPerPage: int, - dormantMonths: int) -> str: + dormantMonths: int, debug: bool) -> str: """Shows following on the profile screen """ profileStr = '' @@ -947,7 +942,7 @@ def _htmlProfileFollowing(translate: {}, baseDir: str, httpPrefix: str, domain, followingActor, authorized, nickname, httpPrefix, projectVersion, dormant, - buttons) + debug, buttons) if authorized and maxItemsPerPage and pageNumber: if len(followingJson['orderedItems']) >= maxItemsPerPage: @@ -965,20 +960,18 @@ def _htmlProfileFollowing(translate: {}, baseDir: str, httpPrefix: str, def _htmlProfileRoles(translate: {}, nickname: str, domain: str, - rolesJson: {}) -> str: + rolesList: []) -> str: """Shows roles on the profile screen """ profileStr = '' - for project, rolesList in rolesJson.items(): - profileStr += \ - '
\n

' + project + \ - '

\n
\n' - for role in rolesList: - if translate.get(role): - profileStr += '

' + translate[role] + '

\n' - else: - profileStr += '

' + role + '

\n' - profileStr += '
\n' + profileStr += \ + '
\n
\n' + for role in rolesList: + if translate.get(role): + profileStr += '

' + translate[role] + '

\n' + else: + profileStr += '

' + role + '

\n' + profileStr += '
\n' if len(profileStr) == 0: profileStr += \ '

@' + nickname + '@' + domain + ' has no roles assigned

\n' @@ -1015,177 +1008,341 @@ def _htmlProfileShares(actor: str, translate: {}, return profileStr -def htmlEditProfile(cssCache: {}, translate: {}, baseDir: str, path: str, - domain: str, port: int, httpPrefix: str, - defaultTimeline: str, theme: str, - peertubeInstances: [], - textModeBanner: str) -> str: - """Shows the edit profile screen +def _grayscaleEnabled(baseDir: str) -> bool: + """Is grayscale UI enabled? + """ + return os.path.isfile(baseDir + '/accounts/.grayscale') + + +def _htmlThemesDropdown(baseDir: str, translate: {}) -> str: + """Returns the html for theme selection dropdown + """ + # Themes section + themes = getThemesList(baseDir) + themesDropdown = '
\n' + grayscale = '' + if _grayscaleEnabled(baseDir): + grayscale = 'checked' + themesDropdown += \ + ' ' + translate['Grayscale'] + '
' + themesDropdown += '
' + if os.path.isfile(baseDir + '/fonts/custom.woff') or \ + os.path.isfile(baseDir + '/fonts/custom.woff2') or \ + os.path.isfile(baseDir + '/fonts/custom.otf') or \ + os.path.isfile(baseDir + '/fonts/custom.ttf'): + themesDropdown += \ + ' ' + \ + translate['Remove the custom font'] + '
' + themeName = getConfigParam(baseDir, 'theme') + themesDropdown = \ + themesDropdown.replace('
\n' - cssFilename = baseDir + '/epicyon-profile.css' - if os.path.isfile(baseDir + '/epicyon.css'): - cssFilename = baseDir + '/epicyon.css' - - moderatorsStr = '' - themesDropdown = '' - instanceStr = '' - editorsStr = '' - peertubeStr = '' - - adminNickname = getConfigParam(baseDir, 'admin') - if adminNickname: - if path.startswith('/users/' + adminNickname + '/'): - instanceDescription = \ - getConfigParam(baseDir, 'instanceDescription') - instanceDescriptionShort = \ - getConfigParam(baseDir, 'instanceDescriptionShort') - instanceTitle = \ - getConfigParam(baseDir, 'instanceTitle') - instanceStr += '
' - instanceStr += \ - ' ' - if instanceTitle: - instanceStr += \ - '
' - else: - instanceStr += \ - '
' - instanceStr += \ - ' ' - if instanceDescriptionShort: - instanceStr += \ - '
' - else: - instanceStr += \ - '
' - instanceStr += \ - ' ' - if instanceDescription: - instanceStr += \ - ' ' - else: - instanceStr += \ - ' ' - instanceStr += \ - ' ' - instanceStr += \ - ' ' - if getConfigParam(baseDir, "verifyAllSignatures"): - instanceStr += \ - ' ' + \ - translate['Verify all signatures'] + '
\n' - else: - instanceStr += \ - ' ' + \ - translate['Verify all signatures'] + '
\n' - if getConfigParam(baseDir, "brochMode"): - instanceStr += \ - ' ' + \ - translate['Broch mode'] + '
\n' - else: - instanceStr += \ - ' ' + \ - translate['Broch mode'] + '
\n' - instanceStr += '
' - - moderators = '' - moderatorsFile = baseDir + '/accounts/moderators.txt' - if os.path.isfile(moderatorsFile): - with open(moderatorsFile, "r") as f: - moderators = f.read() - moderatorsStr = '
' - moderatorsStr += ' ' + translate['Moderators'] + '
' - moderatorsStr += ' ' + \ - translate['A list of moderator nicknames. One per line.'] - moderatorsStr += \ - ' ' - moderatorsStr += '
' - - editors = '' - editorsFile = baseDir + '/accounts/editors.txt' - if os.path.isfile(editorsFile): - with open(editorsFile, "r") as f: - editors = f.read() - editorsStr = '
' - editorsStr += ' ' + translate['Site Editors'] + '
' - editorsStr += ' ' + \ - translate['A list of editor nicknames. One per line.'] - editorsStr += \ - ' ' - editorsStr += '
' - - themes = getThemesList(baseDir) - themesDropdown = '
' - themesDropdown += ' ' + translate['Theme'] + '
' - grayscaleFilename = \ - baseDir + '/accounts/.grayscale' - grayscale = '' - if os.path.isfile(grayscaleFilename): - grayscale = 'checked' - themesDropdown += \ - ' ' + translate['Grayscale'] + '
' - themesDropdown += '
' - if os.path.isfile(baseDir + '/fonts/custom.woff') or \ - os.path.isfile(baseDir + '/fonts/custom.woff2') or \ - os.path.isfile(baseDir + '/fonts/custom.otf') or \ - os.path.isfile(baseDir + '/fonts/custom.ttf'): - themesDropdown += \ - ' ' + \ - translate['Remove the custom font'] + '
' - themesDropdown += '
' - themeName = getConfigParam(baseDir, 'theme') - themesDropdown = \ - themesDropdown.replace('
' if len(boxFilenames) == 0: historySearchForm += \ @@ -604,6 +616,7 @@ def htmlHistorySearch(cssCache: {}, translate: {}, baseDir: str, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, + themeName, showIndividualPostIcons, showIndividualPostIcons, False, False, False) @@ -626,7 +639,8 @@ def htmlHashtagSearch(cssCache: {}, YTReplacementDomain: str, showPublishedDateOnly: bool, peertubeInstances: [], - allowLocalNetworkAccess: bool) -> str: + allowLocalNetworkAccess: bool, + themeName: str) -> str: """Show a page containing search results for a hashtag """ if hashtag.startswith('#'): @@ -645,12 +659,12 @@ def htmlHashtagSearch(cssCache: {}, # check that the directory for the nickname exists if nickname: - if not os.path.isdir(baseDir + '/accounts/' + - nickname + '@' + domain): + accountDir = acctDir(baseDir, nickname, domain) + if not os.path.isdir(accountDir): nickname = None # read the index - with open(hashtagIndexFile, "r") as f: + with open(hashtagIndexFile, 'r') as f: lines = f.readlines() # read the css @@ -776,6 +790,7 @@ def htmlHashtagSearch(cssCache: {}, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, + themeName, showRepeats, showIcons, manuallyApprovesFollowers, showPublicOnly, @@ -822,13 +837,13 @@ def rssHashtagSearch(nickname: str, domain: str, port: int, # check that the directory for the nickname exists if nickname: - if not os.path.isdir(baseDir + '/accounts/' + - nickname + '@' + domain): + accountDir = acctDir(baseDir, nickname, domain) + if not os.path.isdir(accountDir): nickname = None # read the index lines = [] - with open(hashtagIndexFile, "r") as f: + with open(hashtagIndexFile, 'r') as f: lines = f.readlines() if not lines: return None diff --git a/webapp_suspended.py b/webapp_suspended.py index 074cff7ca..40f8f4699 100644 --- a/webapp_suspended.py +++ b/webapp_suspended.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface" import os from utils import getConfigParam @@ -23,9 +24,10 @@ def htmlSuspended(cssCache: {}, baseDir: str) -> str: instanceTitle = \ getConfigParam(baseDir, 'instanceTitle') suspendedForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) - suspendedForm += '
\n' - suspendedForm += '

Account Suspended

\n' - suspendedForm += '

See Terms of Service

\n' - suspendedForm += '
\n' + suspendedForm += \ + '
\n' + \ + '

Account Suspended

\n' + \ + '

See Terms of Service

\n' + \ + '
\n' suspendedForm += htmlFooter() return suspendedForm diff --git a/webapp_timeline.py b/webapp_timeline.py index 8f2e4c399..7ade66c82 100644 --- a/webapp_timeline.py +++ b/webapp_timeline.py @@ -5,15 +5,20 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Timeline" import os import time +from shutil import copyfile +from utils import dangerousMarkup from utils import getConfigParam from utils import getFullDomain from utils import isEditor from utils import removeIdEnding +from utils import acctDir from follow import followerApprovalActive from person import isPersonSnoozed +from markdown import markdownToHtml from webapp_utils import htmlKeyboardNavigation from webapp_utils import htmlHideFromScreenReader from webapp_utils import htmlPostSeparator @@ -28,6 +33,7 @@ from webapp_column_left import getLeftColumnContent from webapp_column_right import getRightColumnContent from webapp_headerbuttons import headerButtonsTimeline from posts import isModerator +from announce import isSelfAnnounce def _logTimelineTiming(enableTimingLog: bool, timelineStartTime, @@ -42,6 +48,290 @@ def _logTimelineTiming(enableTimingLog: bool, timelineStartTime, boxName + ' ' + debugId + ' = ' + str(timeDiff)) +def _getHelpForTimeline(baseDir: str, boxName: str) -> str: + """Shows help text for the given timeline + """ + # get the filename for help for this timeline + helpFilename = baseDir + '/accounts/help_' + boxName + '.md' + if not os.path.isfile(helpFilename): + language = \ + getConfigParam(baseDir, 'language') + if not language: + language = 'en' + themeName = \ + getConfigParam(baseDir, 'theme') + defaultFilename = None + if themeName: + defaultFilename = \ + baseDir + '/theme/' + themeName + '/welcome/' + \ + 'help_' + boxName + '_' + language + '.md' + if not os.path.isfile(defaultFilename): + defaultFilename = None + if not defaultFilename: + defaultFilename = \ + baseDir + '/defaultwelcome/' + \ + 'help_' + boxName + '_' + language + '.md' + if not os.path.isfile(defaultFilename): + defaultFilename = \ + baseDir + '/defaultwelcome/help_' + boxName + '_en.md' + if os.path.isfile(defaultFilename): + copyfile(defaultFilename, helpFilename) + + # show help text + if os.path.isfile(helpFilename): + instanceTitle = \ + getConfigParam(baseDir, 'instanceTitle') + if not instanceTitle: + instanceTitle = 'Epicyon' + with open(helpFilename, 'r') as helpFile: + helpText = helpFile.read() + if dangerousMarkup(helpText, False): + return '' + helpText = helpText.replace('INSTANCE', instanceTitle) + return '
\n' + \ + markdownToHtml(helpText) + '\n' + \ + '
\n' + return '' + + +def _htmlTimelineNewPost(manuallyApproveFollowers: bool, + boxName: str, iconsAsButtons: bool, + usersPath: str, translate: {}) -> str: + """Returns html for the new post button + """ + newPostButtonStr = '' + if boxName == 'dm': + if not iconsAsButtons: + newPostButtonStr += \ + '| ' + translate['Create a new DM'] + \
+                '\n' + else: + newPostButtonStr += \ + '' + \ + '' + elif (boxName == 'tlblogs' or + boxName == 'tlnews' or + boxName == 'tlfeatures'): + if not iconsAsButtons: + newPostButtonStr += \ + '| ' + \
+                translate['Create a new post'] + \
+                '\n' + else: + newPostButtonStr += \ + '' + \ + '' + elif boxName == 'tlshares': + if not iconsAsButtons: + newPostButtonStr += \ + '| ' + \
+                translate['Create a new shared item'] + \
+                '\n' + else: + newPostButtonStr += \ + '' + \ + '' + else: + if not manuallyApproveFollowers: + if not iconsAsButtons: + newPostButtonStr += \ + '| ' + \
+                    translate['Create a new post'] + \
+                    '\n' + else: + newPostButtonStr += \ + '' + \ + '' + else: + if not iconsAsButtons: + newPostButtonStr += \ + '| ' + translate['Create a new post'] + \
+                    '\n' + else: + newPostButtonStr += \ + '' + \ + '' + return newPostButtonStr + + +def _htmlTimelineModerationButtons(moderator: bool, boxName: str, + nickname: str, moderationActionStr: str, + translate: {}) -> str: + """Returns html for the moderation screen buttons + """ + tlStr = '' + if moderator and boxName == 'moderation': + tlStr += \ + '
' + tlStr += '
\n' + idx = 'Nickname or URL. Block using *@domain or nickname@domain' + tlStr += \ + ' ' + translate[idx] + '
\n' + if moderationActionStr: + tlStr += '
\n' + else: + tlStr += '
\n' + + tlStr += \ + ' \n' + tlStr += \ + ' \n' + + tlStr += \ + ' \n' + tlStr += \ + ' \n' + + tlStr += \ + ' \n' + tlStr += \ + ' \n' + + tlStr += \ + ' \n' + tlStr += \ + ' \n' + + tlStr += '
\n
\n' + return tlStr + + +def _htmlTimelineKeyboard(moderator: bool, textModeBanner: str, usersPath: str, + nickname: str, newCalendarEvent: bool, + newDM: bool, newReply: bool, newShare: bool, + followApprovals: bool, + accessKeys: {}, translate: {}) -> str: + """Returns html for timeline keyboard navigation + """ + calendarStr = translate['Calendar'] + if newCalendarEvent: + calendarStr = '' + calendarStr + '' + dmStr = translate['DM'] + if newDM: + dmStr = '' + dmStr + '' + repliesStr = translate['Replies'] + if newReply: + repliesStr = '' + repliesStr + '' + sharesStr = translate['Shares'] + if newShare: + sharesStr = '' + sharesStr + '' + menuProfile = \ + htmlHideFromScreenReader('👤') + ' ' + \ + translate['Switch to profile view'] + menuInbox = \ + htmlHideFromScreenReader('📥') + ' ' + translate['Inbox'] + menuOutbox = \ + htmlHideFromScreenReader('📤') + ' ' + translate['Sent'] + menuSearch = \ + htmlHideFromScreenReader('🔍') + ' ' + \ + translate['Search and follow'] + menuCalendar = \ + htmlHideFromScreenReader('📅') + ' ' + calendarStr + menuDM = \ + htmlHideFromScreenReader('📩') + ' ' + dmStr + menuReplies = \ + htmlHideFromScreenReader('📨') + ' ' + repliesStr + menuBookmarks = \ + htmlHideFromScreenReader('🔖') + ' ' + translate['Bookmarks'] + menuShares = \ + htmlHideFromScreenReader('🤝') + ' ' + sharesStr + menuBlogs = \ + htmlHideFromScreenReader('📝') + ' ' + translate['Blogs'] + menuNewswire = \ + htmlHideFromScreenReader('📰') + ' ' + translate['Newswire'] + menuLinks = \ + htmlHideFromScreenReader('🔗') + ' ' + translate['Links'] + menuNewPost = \ + htmlHideFromScreenReader('➕') + ' ' + translate['Create a new post'] + menuModeration = \ + htmlHideFromScreenReader('⚡️') + ' ' + translate['Mod'] + navLinks = { + menuProfile: '/users/' + nickname, + menuInbox: usersPath + '/inbox#timelineposts', + menuSearch: usersPath + '/search', + menuNewPost: usersPath + '/newpost', + menuCalendar: usersPath + '/calendar', + menuDM: usersPath + '/dm#timelineposts', + menuReplies: usersPath + '/tlreplies#timelineposts', + menuOutbox: usersPath + '/outbox#timelineposts', + menuBookmarks: usersPath + '/tlbookmarks#timelineposts', + menuShares: usersPath + '/tlshares#timelineposts', + menuBlogs: usersPath + '/tlblogs#timelineposts', + menuNewswire: usersPath + '/newswiremobile', + menuLinks: usersPath + '/linksmobile' + } + navAccessKeys = {} + for variableName, key in accessKeys.items(): + if not locals().get(variableName): + continue + navAccessKeys[locals()[variableName]] = key + if moderator: + navLinks[menuModeration] = usersPath + '/moderation#modtimeline' + return htmlKeyboardNavigation(textModeBanner, navLinks, navAccessKeys, + None, usersPath, translate, followApprovals) + + def htmlTimeline(cssCache: {}, defaultTimeline: str, recentPostsCache: {}, maxRecentPosts: int, translate: {}, pageNumber: int, @@ -67,14 +357,15 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the timeline as html """ enableTimingLog = False timelineStartTime = time.time() - accountDir = baseDir + '/accounts/' + nickname + '@' + domain + accountDir = acctDir(baseDir, nickname, domain) # should the calendar icon be highlighted? newCalendarEvent = False @@ -196,8 +487,6 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, sharesButton = 'buttonselectedhighlighted' elif boxName == 'tlbookmarks' or boxName == 'bookmarks': bookmarksButton = 'buttonselected' -# elif boxName == 'tlevents': -# eventsButton = 'buttonselected' # get the full domain, including any port number fullDomain = getFullDomain(domain, port) @@ -210,8 +499,7 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, # show an icon for new follow approvals followApprovals = '' followRequestsFilename = \ - baseDir + '/accounts/' + \ - nickname + '@' + domain + '/followrequests.txt' + acctDir(baseDir, nickname, domain) + '/followrequests.txt' if os.path.isfile(followRequestsFilename): with open(followRequestsFilename, 'r') as f: for line in f: @@ -219,7 +507,8 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, # show follow approvals icon followApprovals = \ '' + \ + '/followers#buttonheader" ' + \ + 'accesskey="' + accessKeys['followButton'] + '">' + \ '' + \
                         translate['Approve follow requests'] + \
@@ -254,11 +543,6 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str,
             '<a href=' -# -# eventsButtonStr = \ -# '' instanceTitle = \ getConfigParam(baseDir, 'instanceTitle') @@ -278,162 +562,15 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, newPostButtonStr += '
' # what screen to go to when a new post is created - if boxName == 'dm': - if not iconsAsButtons: - newPostButtonStr += \ - '| ' + translate['Create a new DM'] + \
-                '\n' - else: - newPostButtonStr += \ - '' + \ - '' - elif (boxName == 'tlblogs' or - boxName == 'tlnews' or - boxName == 'tlfeatures'): - if not iconsAsButtons: - newPostButtonStr += \ - '| ' + \
-                translate['Create a new post'] + \
-                '\n' - else: - newPostButtonStr += \ - '' + \ - '' - elif boxName == 'tlevents': - if not iconsAsButtons: - newPostButtonStr += \ - '| ' + \
-                translate['Create a new event'] + \
-                '\n' - else: - newPostButtonStr += \ - '' + \ - '' - elif boxName == 'tlshares': - if not iconsAsButtons: - newPostButtonStr += \ - '| ' + \
-                translate['Create a new shared item'] + \
-                '\n' - else: - newPostButtonStr += \ - '' + \ - '' - else: - if not manuallyApproveFollowers: - if not iconsAsButtons: - newPostButtonStr += \ - '| ' + \
-                    translate['Create a new post'] + \
-                    '\n' - else: - newPostButtonStr += \ - '' + \ - '' - else: - if not iconsAsButtons: - newPostButtonStr += \ - '| ' + translate['Create a new post'] + \
-                    '\n' - else: - newPostButtonStr += \ - '' + \ - '' + newPostButtonStr += \ + _htmlTimelineNewPost(manuallyApproveFollowers, boxName, + iconsAsButtons, usersPath, translate) # keyboard navigation - calendarStr = translate['Calendar'] - if newCalendarEvent: - calendarStr = '' + calendarStr + '' - dmStr = translate['DM'] - if newDM: - dmStr = '' + dmStr + '' - repliesStr = translate['Replies'] - if newReply: - repliesStr = '' + repliesStr + '' - sharesStr = translate['Shares'] - if newShare: - sharesStr = '' + sharesStr + '' - menuProfile = \ - htmlHideFromScreenReader('👤') + ' ' + \ - translate['Switch to profile view'] - menuInbox = \ - htmlHideFromScreenReader('📥') + ' ' + translate['Inbox'] - menuOutbox = \ - htmlHideFromScreenReader('📤') + ' ' + translate['Outbox'] - menuSearch = \ - htmlHideFromScreenReader('🔍') + ' ' + \ - translate['Search and follow'] - menuCalendar = \ - htmlHideFromScreenReader('📅') + ' ' + calendarStr - menuDM = \ - htmlHideFromScreenReader('📩') + ' ' + dmStr - menuReplies = \ - htmlHideFromScreenReader('📨') + ' ' + repliesStr - menuBookmarks = \ - htmlHideFromScreenReader('🔖') + ' ' + \ - translate['Bookmarks'] - menuShares = \ - htmlHideFromScreenReader('🤝') + ' ' + sharesStr -# menuEvents = \ -# htmlHideFromScreenReader('🎫') + ' ' + translate['Events'] - menuBlogs = \ - htmlHideFromScreenReader('📝') + ' ' + translate['Blogs'] - menuNewswire = \ - htmlHideFromScreenReader('📰') + ' ' + translate['Newswire'] - menuLinks = \ - htmlHideFromScreenReader('🔗') + ' ' + translate['Links'] - menuNewPost = \ - htmlHideFromScreenReader('➕') + ' ' + \ - translate['Create a new post'] - menuModeration = \ - htmlHideFromScreenReader('⚡️') + ' ' + \ - translate['Mod'] - navLinks = { - menuProfile: '/users/' + nickname, - menuInbox: usersPath + '/inbox#timelineposts', - menuSearch: usersPath + '/search', - menuNewPost: usersPath + '/newpost', - menuCalendar: usersPath + '/calendar', - menuDM: usersPath + '/dm#timelineposts', - menuReplies: usersPath + '/tlreplies#timelineposts', - menuOutbox: usersPath + '/inbox#timelineposts', - menuBookmarks: usersPath + '/tlbookmarks#timelineposts', - menuShares: usersPath + '/tlshares#timelineposts', - menuBlogs: usersPath + '/tlblogs#timelineposts', - # menuEvents: usersPath + '/tlevents#timelineposts', - menuNewswire: usersPath + '/newswiremobile', - menuLinks: usersPath + '/linksmobile' - } - if moderator: - navLinks[menuModeration] = usersPath + '/moderation#modtimeline' - tlStr += htmlKeyboardNavigation(textModeBanner, navLinks, None, - usersPath, translate, followApprovals) + tlStr += \ + _htmlTimelineKeyboard(moderator, textModeBanner, usersPath, nickname, + newCalendarEvent, newDM, newReply, newShare, + followApprovals, accessKeys, translate) # banner and row of buttons tlStr += \ @@ -460,17 +597,18 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, domain, timelineStartTime, newCalendarEvent, calendarPath, calendarImage, followApprovals, - iconsAsButtons) + iconsAsButtons, accessKeys) # start the timeline - tlStr += '\n' - tlStr += ' \n' - tlStr += ' \n' - tlStr += ' \n' - tlStr += ' \n' - tlStr += ' \n' - tlStr += ' \n' - tlStr += ' \n' + tlStr += \ + '
\n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' domainFull = getFullDomain(domain, port) @@ -479,7 +617,7 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, getLeftColumnContent(baseDir, nickname, domainFull, httpPrefix, translate, editor, False, None, rssIconAtTop, - True, False, theme) + True, False, theme, accessKeys) tlStr += ' \n' @@ -500,83 +638,14 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, domain, timelineStartTime, newCalendarEvent, calendarPath, calendarImage, followApprovals, - iconsAsButtons) + iconsAsButtons, accessKeys) tlStr += '
\n' # second row of buttons for moderator actions - if moderator and boxName == 'moderation': - tlStr += \ - '
' - tlStr += '
\n' - idx = 'Nickname or URL. Block using *@domain or nickname@domain' - tlStr += \ - ' ' + translate[idx] + '
\n' - if moderationActionStr: - tlStr += '
\n' - else: - tlStr += '
\n' - - tlStr += \ - ' \n' - tlStr += \ - ' \n' - - tlStr += \ - ' \n' - tlStr += \ - ' \n' - - tlStr += \ - ' \n' - tlStr += \ - ' \n' - - tlStr += \ - ' \n' - tlStr += \ - ' \n' - - tlStr += '
\n\n' + tlStr += \ + _htmlTimelineModerationButtons(moderator, boxName, nickname, + moderationActionStr, translate) _logTimelineTiming(enableTimingLog, timelineStartTime, boxName, '6') @@ -605,7 +674,8 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, '
\n' + \ ' ' + \ + '' + \
             translate['Page up'] + '\n' + \ @@ -623,11 +693,12 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, # show each post in the timeline for item in timelineJson['orderedItems']: if item['type'] == 'Create' or \ - item['type'] == 'Announce' or \ - item['type'] == 'Update': + item['type'] == 'Announce': # is the actor who sent this post snoozed? if isPersonSnoozed(baseDir, nickname, domain, item['actor']): continue + if isSelfAnnounce(item): + continue # is the post in the memory cache of recent ones? currTlStr = None @@ -647,6 +718,8 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, _logTimelineTiming(enableTimingLog, timelineStartTime, boxName, '10') + else: + print('Muted post in timeline ' + boxName) if not currTlStr: _logTimelineTiming(enableTimingLog, @@ -670,6 +743,7 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, showPublishedDateOnly, peertubeInstances, allowLocalNetworkAccess, + theme, boxName != 'dm', showIndividualPostIcons, manuallyApproveFollowers, @@ -685,19 +759,26 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, if boxName == 'tlmedia': tlStr += '
\n' + if itemCtr < 3: + print('Items added to html timeline ' + boxName + ': ' + + str(itemCtr) + ' ' + str(timelineJson['orderedItems'])) + # page down arrow - if itemCtr > 2: + if itemCtr > 0: tlStr += textModeSeparator tlStr += \ '
\n' + \ ' ' + \ + '' + \
             translate['Page down'] + '\n' + \ '
\n' tlStr += textModeSeparator + elif itemCtr == 0: + tlStr += _getHelpForTimeline(baseDir, boxName) # end of timeline-posts tlStr += ' \n' @@ -714,7 +795,7 @@ def htmlTimeline(cssCache: {}, defaultTimeline: str, showPublishAsIcon, rssIconAtTop, publishButtonAtTop, authorized, True, theme, - defaultTimeline) + defaultTimeline, accessKeys) tlStr += ' \n' @@ -788,6 +869,7 @@ def _htmlSharesTimeline(translate: {}, pageNumber: int, itemsPerPage: int, ' \n' separatorStr = htmlPostSeparator(baseDir, None) + ctr = 0 for published, item in sharesJson.items(): showContactButton = False if item['actor'] != actor: @@ -799,6 +881,10 @@ def _htmlSharesTimeline(translate: {}, pageNumber: int, itemsPerPage: int, htmlIndividualShare(actor, item, translate, showContactButton, showRemoveButton) timelineStr += separatorStr + ctr += 1 + + if ctr == 0: + timelineStr += _getHelpForTimeline(baseDir, 'tlshares') if not lastPage: timelineStr += \ @@ -832,7 +918,8 @@ def htmlShares(cssCache: {}, defaultTimeline: str, authorized: bool, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the shares timeline as html """ manuallyApproveFollowers = \ @@ -853,7 +940,8 @@ def htmlShares(cssCache: {}, defaultTimeline: str, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, None, theme, peertubeInstances, - allowLocalNetworkAccess, textModeBanner) + allowLocalNetworkAccess, textModeBanner, + accessKeys) def htmlInbox(cssCache: {}, defaultTimeline: str, @@ -875,7 +963,8 @@ def htmlInbox(cssCache: {}, defaultTimeline: str, authorized: bool, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the inbox as html """ manuallyApproveFollowers = \ @@ -896,7 +985,8 @@ def htmlInbox(cssCache: {}, defaultTimeline: str, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, None, theme, peertubeInstances, - allowLocalNetworkAccess, textModeBanner) + allowLocalNetworkAccess, textModeBanner, + accessKeys) def htmlBookmarks(cssCache: {}, defaultTimeline: str, @@ -918,7 +1008,8 @@ def htmlBookmarks(cssCache: {}, defaultTimeline: str, authorized: bool, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the bookmarks as html """ manuallyApproveFollowers = \ @@ -939,50 +1030,8 @@ def htmlBookmarks(cssCache: {}, defaultTimeline: str, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, None, theme, peertubeInstances, - allowLocalNetworkAccess, textModeBanner) - - -def htmlEvents(cssCache: {}, defaultTimeline: str, - recentPostsCache: {}, maxRecentPosts: int, - translate: {}, pageNumber: int, itemsPerPage: int, - session, baseDir: str, - cachedWebfingers: {}, personCache: {}, - nickname: str, domain: str, port: int, bookmarksJson: {}, - allowDeletion: bool, - httpPrefix: str, projectVersion: str, - minimal: bool, YTReplacementDomain: str, - showPublishedDateOnly: bool, - newswire: {}, positiveVoting: bool, - showPublishAsIcon: bool, - fullWidthTimelineButtonHeader: bool, - iconsAsButtons: bool, - rssIconAtTop: bool, - publishButtonAtTop: bool, - authorized: bool, theme: str, - peertubeInstances: [], - allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: - """Show the events as html - """ - manuallyApproveFollowers = \ - followerApprovalActive(baseDir, nickname, domain) - - return htmlTimeline(cssCache, defaultTimeline, - recentPostsCache, maxRecentPosts, - translate, pageNumber, - itemsPerPage, session, baseDir, - cachedWebfingers, personCache, - nickname, domain, port, bookmarksJson, - 'tlevents', allowDeletion, - httpPrefix, projectVersion, manuallyApproveFollowers, - minimal, YTReplacementDomain, - showPublishedDateOnly, - newswire, False, False, - positiveVoting, showPublishAsIcon, - fullWidthTimelineButtonHeader, - iconsAsButtons, rssIconAtTop, publishButtonAtTop, - authorized, None, theme, peertubeInstances, - allowLocalNetworkAccess, textModeBanner) + allowLocalNetworkAccess, textModeBanner, + accessKeys) def htmlInboxDMs(cssCache: {}, defaultTimeline: str, @@ -1004,7 +1053,8 @@ def htmlInboxDMs(cssCache: {}, defaultTimeline: str, authorized: bool, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the DM timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1020,7 +1070,8 @@ def htmlInboxDMs(cssCache: {}, defaultTimeline: str, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, None, theme, peertubeInstances, - allowLocalNetworkAccess, textModeBanner) + allowLocalNetworkAccess, textModeBanner, + accessKeys) def htmlInboxReplies(cssCache: {}, defaultTimeline: str, @@ -1042,7 +1093,8 @@ def htmlInboxReplies(cssCache: {}, defaultTimeline: str, authorized: bool, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the replies timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1059,7 +1111,8 @@ def htmlInboxReplies(cssCache: {}, defaultTimeline: str, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, None, theme, peertubeInstances, - allowLocalNetworkAccess, textModeBanner) + allowLocalNetworkAccess, textModeBanner, + accessKeys) def htmlInboxMedia(cssCache: {}, defaultTimeline: str, @@ -1081,7 +1134,8 @@ def htmlInboxMedia(cssCache: {}, defaultTimeline: str, authorized: bool, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the media timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1098,7 +1152,8 @@ def htmlInboxMedia(cssCache: {}, defaultTimeline: str, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, None, theme, peertubeInstances, - allowLocalNetworkAccess, textModeBanner) + allowLocalNetworkAccess, textModeBanner, + accessKeys) def htmlInboxBlogs(cssCache: {}, defaultTimeline: str, @@ -1120,7 +1175,8 @@ def htmlInboxBlogs(cssCache: {}, defaultTimeline: str, authorized: bool, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the blogs timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1137,7 +1193,8 @@ def htmlInboxBlogs(cssCache: {}, defaultTimeline: str, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, None, theme, peertubeInstances, - allowLocalNetworkAccess, textModeBanner) + allowLocalNetworkAccess, textModeBanner, + accessKeys) def htmlInboxFeatures(cssCache: {}, defaultTimeline: str, @@ -1160,7 +1217,8 @@ def htmlInboxFeatures(cssCache: {}, defaultTimeline: str, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the features timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1177,7 +1235,8 @@ def htmlInboxFeatures(cssCache: {}, defaultTimeline: str, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, None, theme, peertubeInstances, - allowLocalNetworkAccess, textModeBanner) + allowLocalNetworkAccess, textModeBanner, + accessKeys) def htmlInboxNews(cssCache: {}, defaultTimeline: str, @@ -1199,7 +1258,8 @@ def htmlInboxNews(cssCache: {}, defaultTimeline: str, authorized: bool, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the news timeline as html """ return htmlTimeline(cssCache, defaultTimeline, @@ -1216,7 +1276,8 @@ def htmlInboxNews(cssCache: {}, defaultTimeline: str, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, None, theme, peertubeInstances, - allowLocalNetworkAccess, textModeBanner) + allowLocalNetworkAccess, textModeBanner, + accessKeys) def htmlOutbox(cssCache: {}, defaultTimeline: str, @@ -1238,7 +1299,8 @@ def htmlOutbox(cssCache: {}, defaultTimeline: str, authorized: bool, theme: str, peertubeInstances: [], allowLocalNetworkAccess: bool, - textModeBanner: str) -> str: + textModeBanner: str, + accessKeys: {}) -> str: """Show the Outbox as html """ manuallyApproveFollowers = \ @@ -1256,4 +1318,5 @@ def htmlOutbox(cssCache: {}, defaultTimeline: str, showPublishAsIcon, fullWidthTimelineButtonHeader, iconsAsButtons, rssIconAtTop, publishButtonAtTop, authorized, None, theme, peertubeInstances, - allowLocalNetworkAccess, textModeBanner) + allowLocalNetworkAccess, textModeBanner, + accessKeys) diff --git a/webapp_tos.py b/webapp_tos.py index da59e0632..119c853bf 100644 --- a/webapp_tos.py +++ b/webapp_tos.py @@ -5,12 +5,14 @@ __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 htmlFooter +from markdown import markdownToHtml def htmlTermsOfService(cssCache: {}, baseDir: str, @@ -18,9 +20,9 @@ def htmlTermsOfService(cssCache: {}, baseDir: str, """Show the terms of service screen """ adminNickname = getConfigParam(baseDir, 'admin') - if not os.path.isfile(baseDir + '/accounts/tos.txt'): - copyfile(baseDir + '/default_tos.txt', - baseDir + '/accounts/tos.txt') + if not os.path.isfile(baseDir + '/accounts/tos.md'): + copyfile(baseDir + '/default_tos.md', + baseDir + '/accounts/tos.md') if os.path.isfile(baseDir + '/accounts/login-background-custom.jpg'): if not os.path.isfile(baseDir + '/accounts/login-background.jpg'): @@ -28,9 +30,9 @@ def htmlTermsOfService(cssCache: {}, baseDir: str, baseDir + '/accounts/login-background.jpg') TOSText = 'Terms of Service go here.' - if os.path.isfile(baseDir + '/accounts/tos.txt'): - with open(baseDir + '/accounts/tos.txt', 'r') as file: - TOSText = file.read() + if os.path.isfile(baseDir + '/accounts/tos.md'): + with open(baseDir + '/accounts/tos.md', 'r') as file: + TOSText = markdownToHtml(file.read()) TOSForm = '' cssFilename = baseDir + '/epicyon-profile.css' diff --git a/webapp_utils.py b/webapp_utils.py index 64b1cae89..5f5938365 100644 --- a/webapp_utils.py +++ b/webapp_utils.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Web Interface" import os from collections import OrderedDict @@ -15,10 +16,11 @@ from utils import getProtocolPrefixes from utils import loadJson from utils import getCachedPostFilename from utils import getConfigParam -from cache import getPersonFromCache +from utils import acctDir from cache import storePersonInCache from content import addHtmlTags from content import replaceEmojiFromTags +from person import getPersonAvatarUrl def getBrokenLinkSubstitute() -> str: @@ -149,20 +151,6 @@ def headerButtonsFrontScreen(translate: {}, return headerStr -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 getContentWarningButton(postID: str, translate: {}, content: str) -> str: """Returns the markup for a content warning button @@ -173,48 +161,6 @@ def getContentWarningButton(postID: str, translate: {}, '\n' -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 getBlogAddress(actorJson: {}) -> str: - """Returns blog address for the given actor - """ - return _getActorPropertyUrl(actorJson, 'Blog') - - def _setActorPropertyUrl(actorJson: {}, propertyName: str, url: str) -> None: """Sets a url for the given actor property """ @@ -281,7 +227,7 @@ def setBlogAddress(actorJson: {}, blogAddress: str) -> None: def updateAvatarImageCache(session, baseDir: str, httpPrefix: str, actor: str, avatarUrl: str, personCache: {}, allowDownloads: bool, - force=False) -> str: + force: bool = False, debug: bool = False) -> str: """Updates the cached avatar for the given actor """ if not avatarUrl: @@ -313,25 +259,28 @@ def updateAvatarImageCache(session, baseDir: str, httpPrefix: str, if (not os.path.isfile(avatarImageFilename) or force) and allowDownloads: try: - print('avatar image url: ' + avatarUrl) + if debug: + print('avatar image url: ' + avatarUrl) result = session.get(avatarUrl, headers=sessionHeaders, params=None) if result.status_code < 200 or \ result.status_code > 202: - print('Avatar image download failed with status ' + - str(result.status_code)) + if debug: + print('Avatar image download failed with status ' + + str(result.status_code)) # remove partial download if os.path.isfile(avatarImageFilename): os.remove(avatarImageFilename) else: with open(avatarImageFilename, 'wb') as f: f.write(result.content) - print('avatar image downloaded for ' + actor) + if debug: + print('avatar image downloaded for ' + actor) return avatarImageFilename.replace(baseDir + '/cache', '') except Exception as e: - print('Failed to download avatar image: ' + str(avatarUrl)) - print(e) + print('WARN: Failed to download avatar image: ' + + str(avatarUrl) + ' ' + str(e)) prof = 'https://www.w3.org/ns/activitystreams' if '/channel/' not in actor or '/accounts/' not in actor: sessionHeaders = { @@ -342,8 +291,8 @@ def updateAvatarImageCache(session, baseDir: str, httpPrefix: str, 'Accept': 'application/ld+json; profile="' + prof + '"' } personJson = \ - getJson(session, actor, sessionHeaders, None, __version__, - httpPrefix, None) + getJson(session, actor, sessionHeaders, None, + debug, __version__, httpPrefix, None) if personJson: if not personJson.get('id'): return None @@ -369,37 +318,11 @@ def updateAvatarImageCache(session, baseDir: str, httpPrefix: str, return avatarImageFilename.replace(baseDir + '/cache', '') -def getPersonAvatarUrl(baseDir: str, personUrl: str, personCache: {}, - allowDownloads: bool) -> str: - """Returns the avatar url for the person - """ - personJson = \ - getPersonFromCache(baseDir, personUrl, personCache, allowDownloads) - if not personJson: - return None - - # get from locally stored image - actorStr = personJson['id'].replace('/', '-') - avatarImagePath = baseDir + '/cache/avatars/' + actorStr - - imageExtension = getImageExtensions() - for ext in imageExtension: - if os.path.isfile(avatarImagePath + '.' + ext): - return '/avatars/' + actorStr + '.' + ext - elif os.path.isfile(avatarImagePath.lower() + '.' + ext): - return '/avatars/' + actorStr.lower() + '.' + ext - - if personJson.get('icon'): - if personJson['icon'].get('url'): - return personJson['icon']['url'] - return None - - def scheduledPostsExist(baseDir: str, nickname: str, domain: str) -> bool: """Returns true if there are posts scheduled to be delivered """ scheduleIndexFilename = \ - baseDir + '/accounts/' + nickname + '@' + domain + '/schedule.index' + acctDir(baseDir, nickname, domain) + '/schedule.index' if not os.path.isfile(scheduleIndexFilename): return False if '#users#' in open(scheduleIndexFilename).read(): @@ -502,45 +425,249 @@ def _getImageFile(baseDir: str, name: str, directory: str, def getBannerFile(baseDir: str, nickname: str, domain: str, theme: str) -> (str, str): - return _getImageFile(baseDir, 'banner', - baseDir + '/accounts/' + nickname + '@' + domain, + accountDir = acctDir(baseDir, nickname, domain) + return _getImageFile(baseDir, 'banner', accountDir, nickname, domain, theme) def getSearchBannerFile(baseDir: str, nickname: str, domain: str, theme: str) -> (str, str): - return _getImageFile(baseDir, 'search_banner', - baseDir + '/accounts/' + nickname + '@' + domain, + accountDir = acctDir(baseDir, nickname, domain) + return _getImageFile(baseDir, 'search_banner', accountDir, nickname, domain, theme) def getLeftImageFile(baseDir: str, nickname: str, domain: str, theme: str) -> (str, str): - return _getImageFile(baseDir, 'left_col_image', - baseDir + '/accounts/' + nickname + '@' + domain, + accountDir = acctDir(baseDir, nickname, domain) + return _getImageFile(baseDir, 'left_col_image', accountDir, nickname, domain, theme) def getRightImageFile(baseDir: str, nickname: str, domain: str, theme: str) -> (str, str): + accountDir = acctDir(baseDir, nickname, domain) return _getImageFile(baseDir, 'right_col_image', - baseDir + '/accounts/' + nickname + '@' + domain, - nickname, domain, theme) + accountDir, nickname, domain, theme) def htmlHeaderWithExternalStyle(cssFilename: str, instanceTitle: str, lang='en') -> str: - htmlStr = '\n' - htmlStr += '\n' - htmlStr += ' \n' - htmlStr += ' \n' cssFile = '/' + cssFilename.split('/')[-1] - htmlStr += ' \n' - htmlStr += ' \n' - htmlStr += ' \n' - htmlStr += ' ' + instanceTitle + '\n' - htmlStr += ' \n' - htmlStr += ' \n' + htmlStr = \ + '\n' + \ + '\n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' \n' + \ + ' ' + instanceTitle + '\n' + \ + ' \n' + \ + ' \n' + return htmlStr + + +def htmlHeaderWithPersonMarkup(cssFilename: str, instanceTitle: str, + actorJson: {}, city: str, + lang='en') -> str: + """html header which includes person markup + https://schema.org/Person + """ + htmlStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle, lang) + if not actorJson: + return htmlStr + + cityMarkup = '' + if city: + city = city.lower().title() + addComma = '' + countryMarkup = '' + if ',' in city: + country = city.split(',', 1)[1].strip().title() + city = city.split(',', 1)[0] + countryMarkup = \ + ' "addressCountry": "' + country + '"\n' + addComma = ',' + cityMarkup = \ + ' "address": {\n' + \ + ' "@type": "PostalAddress",\n' + \ + ' "addressLocality": "' + city + '"' + addComma + '\n' + \ + countryMarkup + \ + ' },\n' + + skillsMarkup = '' + if actorJson.get('hasOccupation'): + if isinstance(actorJson['hasOccupation'], list): + skillsMarkup = ' "hasOccupation": [\n' + firstEntry = True + for skillDict in actorJson['hasOccupation']: + if skillDict['@type'] == 'Role': + if not firstEntry: + skillsMarkup += ',\n' + sk = skillDict['hasOccupation'] + roleName = sk['name'] + if not roleName: + roleName = 'member' + category = \ + sk['occupationalCategory']['codeValue'] + categoryUrl = \ + 'https://www.onetonline.org/link/summary/' + category + skillsMarkup += \ + ' {\n' + \ + ' "@type": "Role",\n' + \ + ' "hasOccupation": {\n' + \ + ' "@type": "Occupation",\n' + \ + ' "name": "' + roleName + '",\n' + \ + ' "description": ' + \ + '"Fediverse instance role",\n' + \ + ' "occupationLocation": {\n' + \ + ' "@type": "City",\n' + \ + ' "name": "' + city + '"\n' + \ + ' },\n' + \ + ' "occupationalCategory": {\n' + \ + ' "@type": "CategoryCode",\n' + \ + ' "inCodeSet": {\n' + \ + ' "@type": "CategoryCodeSet",\n' + \ + ' "name": "O*Net-SOC",\n' + \ + ' "dateModified": "2019",\n' + \ + ' ' + \ + '"url": "https://www.onetonline.org/"\n' + \ + ' },\n' + \ + ' "codeValue": "' + category + '",\n' + \ + ' "url": "' + categoryUrl + '"\n' + \ + ' }\n' + \ + ' }\n' + \ + ' }' + elif skillDict['@type'] == 'Occupation': + if not firstEntry: + skillsMarkup += ',\n' + ocName = skillDict['name'] + if not ocName: + ocName = 'member' + skillsList = skillDict['skills'] + skillsListStr = '[' + for skillStr in skillsList: + if skillsListStr != '[': + skillsListStr += ', ' + skillsListStr += '"' + skillStr + '"' + skillsListStr += ']' + skillsMarkup += \ + ' {\n' + \ + ' "@type": "Occupation",\n' + \ + ' "name": "' + ocName + '",\n' + \ + ' "description": ' + \ + '"Fediverse instance occupation",\n' + \ + ' "occupationLocation": {\n' + \ + ' "@type": "City",\n' + \ + ' "name": "' + city + '"\n' + \ + ' },\n' + \ + ' "skills": ' + skillsListStr + '\n' + \ + ' }' + firstEntry = False + skillsMarkup += '\n ],\n' + + description = removeHtml(actorJson['summary']) + nameStr = removeHtml(actorJson['name']) + personMarkup = \ + ' \n' + htmlStr = htmlStr.replace('\n', '\n' + personMarkup) + return htmlStr + + +def htmlHeaderWithWebsiteMarkup(cssFilename: str, instanceTitle: str, + httpPrefix: str, domain: str, + systemLanguage: str) -> str: + """html header which includes website markup + https://schema.org/WebSite + """ + htmlStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle, + systemLanguage) + + licenseUrl = 'https://www.gnu.org/licenses/agpl-3.0.rdf' + + # social networking category + genreUrl = 'http://vocab.getty.edu/aat/300312270' + + websiteMarkup = \ + ' \n' + htmlStr = htmlStr.replace('\n', '\n' + websiteMarkup) + return htmlStr + + +def htmlHeaderWithBlogMarkup(cssFilename: str, instanceTitle: str, + httpPrefix: str, domain: str, nickname: str, + systemLanguage: str, published: str, + title: str, snippet: str) -> str: + """html header which includes blog post markup + https://schema.org/BlogPosting + """ + htmlStr = htmlHeaderWithExternalStyle(cssFilename, instanceTitle, + systemLanguage) + + authorUrl = httpPrefix + '://' + domain + '/users/' + nickname + aboutUrl = httpPrefix + '://' + domain + '/about.html' + + # license for content on the site may be different from + # the software license + contentLicenseUrl = 'https://creativecommons.org/licenses/by/3.0' + + blogMarkup = \ + ' \n' + htmlStr = htmlStr.replace('\n', '\n' + blogMarkup) return htmlStr @@ -574,7 +701,8 @@ def loadIndividualPostAsHtmlFromCache(baseDir: str, postHtml = file.read() break except Exception as e: - print(e) + print('ERROR: loadIndividualPostAsHtmlFromCache ' + + str(tries) + ' ' + str(e)) # no sleep tries += 1 if postHtml: @@ -625,6 +753,75 @@ def addEmojiToDisplayName(baseDir: str, httpPrefix: str, return displayName +def _isImageMimeType(mimeType: str) -> bool: + """Is the given mime type an image? + """ + imageMimeTypes = ( + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/avif', + 'image/svg+xml', + 'image/gif' + ) + if mimeType in imageMimeTypes: + return True + return False + + +def _isVideoMimeType(mimeType: str) -> bool: + """Is the given mime type a video? + """ + videoMimeTypes = ( + 'video/mp4', + 'video/webm', + 'video/ogv' + ) + if mimeType in videoMimeTypes: + return True + return False + + +def _isAudioMimeType(mimeType: str) -> bool: + """Is the given mime type an audio file? + """ + audioMimeTypes = ( + 'audio/mpeg', + 'audio/ogg' + ) + if mimeType in audioMimeTypes: + return True + return False + + +def _isAttachedImage(attachmentFilename: str) -> bool: + """Is the given attachment filename an image? + """ + if '.' not in attachmentFilename: + return False + imageExt = ( + 'png', 'jpg', 'jpeg', 'webp', 'avif', 'svg', 'gif' + ) + ext = attachmentFilename.split('.')[-1] + if ext in imageExt: + return True + return False + + +def _isAttachedVideo(attachmentFilename: str) -> bool: + """Is the given attachment filename a video? + """ + if '.' not in attachmentFilename: + return False + videoExt = ( + 'mp4', 'webm', 'ogv' + ) + ext = attachmentFilename.split('.')[-1] + if ext in videoExt: + return True + return False + + def getPostAttachmentsAsHtml(postJsonObject: {}, boxName: str, translate: {}, isMuted: bool, avatarLink: str, replyStr: str, announceStr: str, likeStr: str, @@ -641,7 +838,8 @@ def getPostAttachmentsAsHtml(postJsonObject: {}, boxName: str, translate: {}, return attachmentStr, galleryStr attachmentCtr = 0 - attachmentStr += '
\n' + attachmentStr = '' + mediaStyleAdded = False for attach in postJsonObject['object']['attachment']: if not (attach.get('mediaType') and attach.get('url')): continue @@ -650,19 +848,12 @@ def getPostAttachmentsAsHtml(postJsonObject: {}, boxName: str, translate: {}, imageDescription = '' if attach.get('name'): imageDescription = attach['name'].replace('"', "'") - if mediaType == 'image/png' or \ - mediaType == 'image/jpeg' or \ - mediaType == 'image/webp' or \ - mediaType == 'image/avif' or \ - mediaType == 'image/svg+xml' or \ - mediaType == 'image/gif': - if attach['url'].endswith('.png') or \ - attach['url'].endswith('.jpg') or \ - attach['url'].endswith('.jpeg') or \ - attach['url'].endswith('.webp') or \ - attach['url'].endswith('.avif') or \ - attach['url'].endswith('.svg') or \ - attach['url'].endswith('.gif'): + if _isImageMimeType(mediaType): + if _isAttachedImage(attach['url']): + if not attachmentStr: + attachmentStr += '
\n' + mediaStyleAdded = True + if attachmentCtr > 0: attachmentStr += '
' if boxName == 'tlmedia': @@ -702,15 +893,9 @@ def getPostAttachmentsAsHtml(postJsonObject: {}, boxName: str, translate: {}, '" alt="' + imageDescription + '" title="' + \ imageDescription + '" class="attachment">\n' attachmentCtr += 1 - elif (mediaType == 'video/mp4' or - mediaType == 'video/webm' or - mediaType == 'video/ogv'): - extension = '.mp4' - if attach['url'].endswith('.webm'): - extension = '.webm' - elif attach['url'].endswith('.ogv'): - extension = '.ogv' - if attach['url'].endswith(extension): + elif _isVideoMimeType(mediaType): + if _isAttachedVideo(attach['url']): + extension = attach['url'].split('.')[-1] if attachmentCtr > 0: attachmentStr += '
' if boxName == 'tlmedia': @@ -718,16 +903,20 @@ def getPostAttachmentsAsHtml(postJsonObject: {}, boxName: str, translate: {}, if not isMuted: galleryStr += ' \n' galleryStr += \ - ' \n' if postJsonObject['object'].get('url'): videoPostUrl = postJsonObject['object']['url'] @@ -753,18 +942,20 @@ def getPostAttachmentsAsHtml(postJsonObject: {}, boxName: str, translate: {}, galleryStr += '
\n' attachmentStr += \ - '
' attachmentCtr += 1 - elif (mediaType == 'audio/mpeg' or - mediaType == 'audio/ogg'): + elif _isAudioMimeType(mediaType): extension = '.mp3' if attach['url'].endswith('.ogg'): extension = '.ogg' @@ -803,7 +994,7 @@ def getPostAttachmentsAsHtml(postJsonObject: {}, boxName: str, translate: {}, galleryStr += \ ' ' + replyStr + announceStr + \ likeStr + bookmarkStr + \ - deleteStr + muteStr+'\n' + deleteStr + muteStr + '\n' galleryStr += '
\n' galleryStr += '
\n' galleryStr += ' ' + avatarLink + '\n' @@ -820,7 +1011,8 @@ def getPostAttachmentsAsHtml(postJsonObject: {}, boxName: str, translate: {}, translate['Your browser does not support the audio tag.'] attachmentStr += '\n\n' attachmentCtr += 1 - attachmentStr += '
' + if mediaStyleAdded: + attachmentStr += '' return attachmentStr, galleryStr @@ -886,10 +1078,10 @@ def htmlHideFromScreenReader(htmlStr: str) -> str: return '' -def htmlKeyboardNavigation(banner: str, links: {}, - subHeading=None, - usersPath=None, translate=None, - followApprovals=False) -> str: +def htmlKeyboardNavigation(banner: str, links: {}, accessKeys: {}, + subHeading: str = None, + usersPath: str = None, translate: {} = None, + followApprovals: bool = False) -> str: """Given a set of links return the html for keyboard navigation """ htmlStr = '
    \n' @@ -910,8 +1102,12 @@ def htmlKeyboardNavigation(banner: str, links: {}, # show the list of links for title, url in links.items(): + accessKeyStr = '' + if accessKeys.get(title): + accessKeyStr = 'accesskey="' + accessKeys[title] + '"' + htmlStr += '
  • \n' htmlStr += '
\n' return htmlStr diff --git a/webapp_welcome.py b/webapp_welcome.py new file mode 100644 index 000000000..11bca121c --- /dev/null +++ b/webapp_welcome.py @@ -0,0 +1,103 @@ +__filename__ = "webapp_welcome.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@freedombone.net" +__status__ = "Production" +__module_group__ = "Onboarding" + +import os +from shutil import copyfile +from utils import getConfigParam +from utils import removeHtml +from utils import acctDir +from webapp_utils import htmlHeaderWithExternalStyle +from webapp_utils import htmlFooter +from markdown import markdownToHtml + + +def isWelcomeScreenComplete(baseDir: str, nickname: str, domain: str) -> bool: + """Returns true if the welcome screen is complete for the given account + """ + accountPath = acctDir(baseDir, nickname, domain) + if not os.path.isdir(accountPath): + return + completeFilename = accountPath + '/.welcome_complete' + return os.path.isfile(completeFilename) + + +def welcomeScreenIsComplete(baseDir: str, + nickname: str, domain: str) -> None: + """Indicates that the welcome screen has been shown for a given account + """ + accountPath = acctDir(baseDir, nickname, domain) + if not os.path.isdir(accountPath): + return + completeFilename = accountPath + '/.welcome_complete' + with open(completeFilename, 'w+') as completeFile: + completeFile.write('\n') + + +def htmlWelcomeScreen(baseDir: str, nickname: str, + language: str, translate: {}, + themeName: str, + currScreen='welcome') -> str: + """Returns the welcome screen + """ + # set a custom background for the welcome screen + if os.path.isfile(baseDir + '/accounts/welcome-background-custom.jpg'): + if not os.path.isfile(baseDir + '/accounts/welcome-background.jpg'): + copyfile(baseDir + '/accounts/welcome-background-custom.jpg', + baseDir + '/accounts/welcome-background.jpg') + + welcomeText = 'Welcome to Epicyon' + welcomeFilename = baseDir + '/accounts/' + currScreen + '.md' + if not os.path.isfile(welcomeFilename): + defaultFilename = None + if themeName: + defaultFilename = \ + baseDir + '/theme/' + themeName + '/welcome/' + \ + 'welcome_' + language + '.md' + if not os.path.isfile(defaultFilename): + defaultFilename = None + if not defaultFilename: + defaultFilename = \ + baseDir + '/defaultwelcome/' + \ + currScreen + '_' + language + '.md' + if not os.path.isfile(defaultFilename): + defaultFilename = \ + baseDir + '/defaultwelcome/' + currScreen + '_en.md' + copyfile(defaultFilename, welcomeFilename) + + instanceTitle = \ + getConfigParam(baseDir, 'instanceTitle') + if not instanceTitle: + instanceTitle = 'Epicyon' + + if os.path.isfile(welcomeFilename): + with open(welcomeFilename, 'r') as welcomeFile: + welcomeText = welcomeFile.read() + welcomeText = welcomeText.replace('INSTANCE', instanceTitle) + welcomeText = markdownToHtml(removeHtml(welcomeText)) + + welcomeForm = '' + cssFilename = baseDir + '/epicyon-welcome.css' + if os.path.isfile(baseDir + '/welcome.css'): + cssFilename = baseDir + '/welcome.css' + + welcomeForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + welcomeForm += \ + '\n' + welcomeForm += '
' + welcomeText + '
\n' + welcomeForm += ' \n' + welcomeForm += '\n' + welcomeForm += '\n' + welcomeForm += htmlFooter() + return welcomeForm diff --git a/webapp_welcome_final.py b/webapp_welcome_final.py new file mode 100644 index 000000000..de5940199 --- /dev/null +++ b/webapp_welcome_final.py @@ -0,0 +1,80 @@ +__filename__ = "webapp_welcome_final.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@freedombone.net" +__status__ = "Production" +__module_group__ = "Onboarding" + +import os +from shutil import copyfile +from utils import removeHtml +from utils import getConfigParam +from webapp_utils import htmlHeaderWithExternalStyle +from webapp_utils import htmlFooter +from markdown import markdownToHtml + + +def htmlWelcomeFinal(baseDir: str, nickname: str, domain: str, + httpPrefix: str, domainFull: str, + language: str, translate: {}, + themeName: str) -> str: + """Returns the final welcome screen after first login + """ + # set a custom background for the welcome screen + if os.path.isfile(baseDir + '/accounts/welcome-background-custom.jpg'): + if not os.path.isfile(baseDir + '/accounts/welcome-background.jpg'): + copyfile(baseDir + '/accounts/welcome-background-custom.jpg', + baseDir + '/accounts/welcome-background.jpg') + + finalText = 'Welcome to Epicyon' + finalFilename = baseDir + '/accounts/welcome_final.md' + if not os.path.isfile(finalFilename): + defaultFilename = None + if themeName: + defaultFilename = \ + baseDir + '/theme/' + themeName + '/welcome/' + \ + 'final_' + language + '.md' + if not os.path.isfile(defaultFilename): + defaultFilename = None + if not defaultFilename: + defaultFilename = \ + baseDir + '/defaultwelcome/final_' + language + '.md' + if not os.path.isfile(defaultFilename): + defaultFilename = baseDir + '/defaultwelcome/final_en.md' + copyfile(defaultFilename, finalFilename) + + instanceTitle = \ + getConfigParam(baseDir, 'instanceTitle') + if not instanceTitle: + instanceTitle = 'Epicyon' + + if os.path.isfile(finalFilename): + with open(finalFilename, 'r') as finalFile: + finalText = finalFile.read() + finalText = finalText.replace('INSTANCE', instanceTitle) + finalText = markdownToHtml(removeHtml(finalText)) + + finalForm = '' + cssFilename = baseDir + '/epicyon-welcome.css' + if os.path.isfile(baseDir + '/welcome.css'): + cssFilename = baseDir + '/welcome.css' + + finalForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + + finalForm += \ + '
' + finalText + '
\n' + \ + '\n' + \ + '\n' + + finalForm += '\n' + finalForm += htmlFooter() + return finalForm diff --git a/webapp_welcome_profile.py b/webapp_welcome_profile.py new file mode 100644 index 000000000..76aff001a --- /dev/null +++ b/webapp_welcome_profile.py @@ -0,0 +1,132 @@ +__filename__ = "webapp_welcome_profile.py" +__author__ = "Bob Mottram" +__license__ = "AGPL3+" +__version__ = "1.2.0" +__maintainer__ = "Bob Mottram" +__email__ = "bob@freedombone.net" +__status__ = "Production" +__module_group__ = "Onboarding" + +import os +from shutil import copyfile +from utils import removeHtml +from utils import loadJson +from utils import getConfigParam +from utils import getImageExtensions +from utils import getImageFormats +from utils import acctDir +from webapp_utils import htmlHeaderWithExternalStyle +from webapp_utils import htmlFooter +from markdown import markdownToHtml + + +def htmlWelcomeProfile(baseDir: str, nickname: str, domain: str, + httpPrefix: str, domainFull: str, + language: str, translate: {}, + themeName: str) -> str: + """Returns the welcome profile screen to set avatar and bio + """ + # set a custom background for the welcome screen + if os.path.isfile(baseDir + '/accounts/welcome-background-custom.jpg'): + if not os.path.isfile(baseDir + '/accounts/welcome-background.jpg'): + copyfile(baseDir + '/accounts/welcome-background-custom.jpg', + baseDir + '/accounts/welcome-background.jpg') + + profileText = 'Welcome to Epicyon' + profileFilename = baseDir + '/accounts/welcome_profile.md' + if not os.path.isfile(profileFilename): + defaultFilename = None + if themeName: + defaultFilename = \ + baseDir + '/theme/' + themeName + '/welcome/' + \ + 'profile_' + language + '.md' + if not os.path.isfile(defaultFilename): + defaultFilename = None + if not defaultFilename: + defaultFilename = \ + baseDir + '/defaultwelcome/profile_' + language + '.md' + if not os.path.isfile(defaultFilename): + defaultFilename = baseDir + '/defaultwelcome/profile_en.md' + copyfile(defaultFilename, profileFilename) + + instanceTitle = \ + getConfigParam(baseDir, 'instanceTitle') + if not instanceTitle: + instanceTitle = 'Epicyon' + + if os.path.isfile(profileFilename): + with open(profileFilename, 'r') as profileFile: + profileText = profileFile.read() + profileText = profileText.replace('INSTANCE', instanceTitle) + profileText = markdownToHtml(removeHtml(profileText)) + + profileForm = '' + cssFilename = baseDir + '/epicyon-welcome.css' + if os.path.isfile(baseDir + '/welcome.css'): + cssFilename = baseDir + '/welcome.css' + + profileForm = htmlHeaderWithExternalStyle(cssFilename, instanceTitle) + + # get the url of the avatar + for ext in getImageExtensions(): + avatarFilename = \ + acctDir(baseDir, nickname, domain) + '/avatar.' + ext + if os.path.isfile(avatarFilename): + break + avatarUrl = \ + httpPrefix + '://' + domainFull + \ + '/users/' + nickname + '/avatar.' + ext + + imageFormats = getImageFormats() + profileForm += '
' + profileText + '
\n' + profileForm += \ + '\n' + profileForm += '
\n' + profileForm += '
\n' + profileForm += '
\n' + profileForm += ' \n' + profileForm += '
\n' + profileForm += '
\n' + + profileForm += '
\n' + profileForm += \ + ' ' + profileForm += '
\n' + + actorFilename = acctDir(baseDir, nickname, domain) + '.json' + actorJson = loadJson(actorFilename) + displayNickname = actorJson['name'] + profileForm += '
\n' + profileForm += '
\n' + profileForm += '
\n' + + bioStr = \ + actorJson['summary'].replace('

', '').replace('

', '') + if not bioStr: + bioStr = translate['Your bio'] + profileForm += '
\n' + profileForm += ' \n' + profileForm += '
\n' + + profileForm += '\n' + + profileForm += '\n' + profileForm += htmlFooter() + return profileForm diff --git a/webfinger.py b/webfinger.py index 29c63bad7..6a31c5833 100644 --- a/webfinger.py +++ b/webfinger.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "ActivityPub" import os import urllib.parse @@ -16,6 +17,7 @@ from utils import loadJson from utils import loadJsonOnionify from utils import saveJson from utils import getProtocolPrefixes +from utils import removeDomainPort def _parseHandle(handle: str) -> (str, str): @@ -40,27 +42,25 @@ def _parseHandle(handle: str) -> (str, str): def webfingerHandle(session, handle: str, httpPrefix: str, cachedWebfingers: {}, - fromDomain: str, projectVersion: str) -> {}: + fromDomain: str, projectVersion: str, + debug: bool) -> {}: """Gets webfinger result for the given ActivityPub handle """ if not session: - print('WARN: No session specified for webfingerHandle') + if debug: + print('WARN: No session specified for webfingerHandle') return None nickname, domain = _parseHandle(handle) if not nickname: return None - wfDomain = domain - if ':' in wfDomain: - # wfPortStr=wfDomain.split(':')[1] - # if wfPortStr.isdigit(): - # wfPort=int(wfPortStr) - # if wfPort==80 or wfPort==443: - wfDomain = wfDomain.split(':')[0] + wfDomain = removeDomainPort(domain) + wf = getWebfingerFromCache(nickname + '@' + wfDomain, cachedWebfingers) if wf: - print('Webfinger from cache: ' + str(wf)) + if debug: + print('Webfinger from cache: ' + str(wf)) return wf url = '{}://{}/.well-known/webfinger'.format(httpPrefix, domain) par = { @@ -71,21 +71,23 @@ def webfingerHandle(session, handle: str, httpPrefix: str, } try: result = \ - getJson(session, url, hdr, par, projectVersion, + getJson(session, url, hdr, par, + debug, projectVersion, httpPrefix, fromDomain) except Exception as e: - print(e) + print('ERROR: webfingerHandle ' + str(e)) return None if result: storeWebfingerInCache(nickname + '@' + wfDomain, result, cachedWebfingers) else: - print("WARN: Unable to webfinger " + url + ' ' + - 'nickname: ' + str(nickname) + ' ' + - 'domain: ' + str(wfDomain) + ' ' + - 'headers: ' + str(hdr) + ' ' + - 'params: ' + str(par)) + if debug: + print("WARN: Unable to webfinger " + url + ' ' + + 'nickname: ' + str(nickname) + ' ' + + 'domain: ' + str(wfDomain) + ' ' + + 'headers: ' + str(hdr) + ' ' + + 'params: ' + str(par)) return result @@ -172,18 +174,19 @@ def webfingerNodeInfo(httpPrefix: str, domainFull: str) -> {}: def webfingerMeta(httpPrefix: str, domainFull: str) -> str: """Return /.well-known/host-meta """ - metaStr = "" - metaStr += "" - metaStr += "" - metaStr += "" - metaStr += " Resource Descriptor" - metaStr += " " - metaStr += "" + metaStr = \ + "" + \ + "" + \ + "" + \ + "" + domainFull + "" + \ + "" + \ + "" + \ + " Resource Descriptor" + \ + " " + \ + "" return metaStr @@ -262,18 +265,28 @@ def _webfingerUpdateFromProfile(wfJson: {}, actorJson: {}) -> bool: "matrix": "matrix", "email": "mailto", "ssb": "ssb", + "briar": "briar", + "cwtch": "cwtch", + "jami": "jami", "tox": "toxId" } + aliasesNotFound = [] + for name, alias in webfingerPropertyName.items(): + aliasesNotFound.append(alias) + for propertyValue in actorJson['attachment']: if not propertyValue.get('name'): continue propertyName = propertyValue['name'].lower() - if not (propertyName.startswith('ssb') or - propertyName.startswith('xmpp') or - propertyName.startswith('matrix') or - propertyName.startswith('email') or - propertyName.startswith('tox')): + found = False + for name, alias in webfingerPropertyName.items(): + if name == propertyName: + if alias in aliasesNotFound: + aliasesNotFound.remove(alias) + found = True + break + if not found: continue if not propertyValue.get('type'): continue @@ -283,6 +296,9 @@ def _webfingerUpdateFromProfile(wfJson: {}, actorJson: {}) -> bool: continue newValue = propertyValue['value'].strip() + if '://' in newValue: + newValue = newValue.split('://')[1] + aliasIndex = 0 found = False for alias in wfJson['aliases']: @@ -298,6 +314,17 @@ def _webfingerUpdateFromProfile(wfJson: {}, actorJson: {}) -> bool: else: wfJson['aliases'].append(newAlias) changed = True + + # remove any aliases which are no longer in the actor profile + removeAlias = [] + for alias in aliasesNotFound: + for fullAlias in wfJson['aliases']: + if fullAlias.startswith(alias + ':'): + removeAlias.append(fullAlias) + for fullAlias in removeAlias: + wfJson['aliases'].remove(fullAlias) + changed = True + return changed diff --git a/website/EN/index.html b/website/EN/index.html index 98e6e9b34..45f5f41e6 100644 --- a/website/EN/index.html +++ b/website/EN/index.html @@ -1,6 +1,23 @@ + @@ -1326,7 +1343,7 @@ User=epicyon
Group=epicyon
WorkingDirectory=/opt/epicyon
- ExecStart=/usr/bin/python3 /opt/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open --debug
+ ExecStart=/usr/bin/python3 /opt/epicyon/epicyon.py --port 443 --proxy 7156 --domain YOUR_DOMAIN --registration open --debug --logLoginFailures
Environment=USER=epicyon
Environment=PYTHONUNBUFFERED=true
Restart=always
@@ -1357,101 +1374,82 @@ inactive=60m use_temp_path=off; server {
- listen 80;
- listen [::]:80;
- server_name YOUR_DOMAIN;
- access_log /dev/null;
- error_log /dev/null;
- client_max_body_size 31m;
- client_body_buffer_size 128k;
-
- limit_conn conn_limit_per_ip 10;
- limit_req zone=req_limit_per_ip burst=10 nodelay;
-
- index index.html;
- rewrite ^ https://$server_name$request_uri? permanent;
+ listen 80;
+ listen [::]:80;
+ server_name YOUR_DOMAIN;
+ access_log /dev/null;
+ error_log /dev/null;
+ client_max_body_size 31m;
+ client_body_buffer_size 128k;
+
+ limit_conn conn_limit_per_ip 10;
+ limit_req zone=req_limit_per_ip burst=10 nodelay;
+
+ index index.html;
+ rewrite ^ https://$server_name$request_uri? permanent;
}

server {
- listen 443 ssl;
- server_name YOUR_DOMAIN;
-
- ssl_stapling off;
- ssl_stapling_verify off;
- ssl on;
- ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
- ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
- #ssl_dhparam /etc/ssl/certs/YOUR_DOMAIN.dhparam;
-
- ssl_session_cache builtin:1000 shared:SSL:10m;
- ssl_session_timeout 60m;
- ssl_prefer_server_ciphers on;
- ssl_protocols TLSv1.2 TLSv1.3;
- ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS';
- add_header X-Frame-Options DENY;
- add_header X-Content-Type-Options nosniff;
- add_header X-XSS-Protection "1; mode=block";
- add_header X-Download-Options noopen;
- add_header X-Permitted-Cross-Domain-Policies none;
-
- add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive";
- add_header Strict-Transport-Security max-age=15768000;
-
- access_log /dev/null;
- error_log /dev/null;
-
- index index.html;
-
- location /newsmirror {
- root /var/www/YOUR_DOMAIN;
- try_files $uri =404;
- }
-
- location / {
- proxy_http_version 1.1;
- client_max_body_size 31M;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- proxy_set_header Host $http_host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forward-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forward-Proto http;
- proxy_set_header X-Nginx-Proxy true;
- expires epoch;
- proxy_no_cache 1;
- proxy_temp_file_write_size 64k;
- proxy_connect_timeout 10080s;
- proxy_send_timeout 10080;
- proxy_read_timeout 10080;
- proxy_buffer_size 64k;
- proxy_buffers 16 32k;
- proxy_busy_buffers_size 64k;
- proxy_redirect off;
- proxy_request_buffering on;
- proxy_buffering on;
- proxy_cache my_cache;
- proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
- location ~ ^/(icons|images|media|emoji)/(.*)/(.*).(png|jpg|gif|webp|mp3|ogv|ogg|mp4) {
- expires 7d;
+ listen 443 ssl;
+ server_name YOUR_DOMAIN;
+
+ ssl_stapling off;
+ ssl_stapling_verify off;
+ ssl on;
+ ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
+ #ssl_dhparam /etc/ssl/certs/YOUR_DOMAIN.dhparam;
+
+ ssl_session_cache builtin:1000 shared:SSL:10m;
+ ssl_session_timeout 60m;
+ ssl_prefer_server_ciphers on;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS';
+ add_header X-Frame-Options DENY;
+ add_header X-Content-Type-Options nosniff;
+ add_header X-XSS-Protection "1; mode=block";
+ add_header X-Download-Options noopen;
+ add_header X-Permitted-Cross-Domain-Policies none;
+
+ add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive";
+ add_header Strict-Transport-Security max-age=15768000;
+
+ access_log /dev/null;
+ error_log /dev/null;
+
+ index index.html;
+
+ location /newsmirror {
+ root /var/www/YOUR_DOMAIN;
+ try_files $uri =404;
+ }
+
+ location / {
+ proxy_http_version 1.1;
+ client_max_body_size 31M;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forward-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forward-Proto http;
+ proxy_set_header X-Nginx-Proxy true;
+ proxy_temp_file_write_size 64k;
+ proxy_connect_timeout 10080s;
+ proxy_send_timeout 10080;
+ proxy_read_timeout 10080;
+ proxy_buffer_size 64k;
+ proxy_buffers 16 32k;
+ proxy_busy_buffers_size 64k;
+ proxy_redirect off;
+ 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;
- }
- location ~ ^/icons/(.*)/(like|repeat|calendar)(.*).(png|jpg|gif|webp|mp3|ogv|ogg|mp4) {
- expires epoch;
- proxy_no_cache 1;
- proxy_pass http://localhost:7156;
- }
- location ~ ^/icons/(like|repeat|calendar)(.*).(png|jpg|gif|webp|mp3|ogv|ogg|mp4) {
- expires epoch;
- proxy_no_cache 1;
- proxy_pass http://localhost:7156;
- }
- location ~ ^/users/(.*)/(image|banner).(png|jpg|gif|webp|mp3|ogv|ogg|mp4) {
- expires epoch;
- proxy_no_cache 1;
- proxy_pass http://localhost:7156;
- }
- proxy_pass http://localhost:7156;
- }
+ }
} @@ -1475,6 +1473,9 @@
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 then see caddy.example.conf

diff --git a/xmpp.py b/xmpp.py index 1fd0a6b22..707767a79 100644 --- a/xmpp.py +++ b/xmpp.py @@ -5,6 +5,7 @@ __version__ = "1.2.0" __maintainer__ = "Bob Mottram" __email__ = "bob@freedombone.net" __status__ = "Production" +__module_group__ = "Profile Metadata" def getXmppAddress(actorJson: {}) -> str:
' + \ leftColumnStr + ' ' + \ rightColumnStr + '