Merge pull request 'Small improvements to the Gentoo installation isntructions' ...
authorfloatingghost <hannah@coffee-and-dreams.uk>
Wed, 7 Dec 2022 11:07:06 +0000 (11:07 +0000)
committerfloatingghost <hannah@coffee-and-dreams.uk>
Wed, 7 Dec 2022 11:07:06 +0000 (11:07 +0000)
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/335

34 files changed:
CHANGELOG.md
docs/Pipfile.lock
docs/docs/administration/backup.md
docs/docs/configuration/integrations/howto_ejabberd.md [moved from docs/docs/configuration/howto_ejabberd.md with 100% similarity]
docs/docs/configuration/integrations/howto_mongooseim.md [moved from docs/docs/configuration/howto_mongooseim.md with 100% similarity]
docs/docs/configuration/optimisation/optimizing_beam.md [moved from docs/docs/configuration/optimizing_beam.md with 100% similarity]
docs/docs/configuration/optimisation/varnish_cache.md [new file with mode: 0644]
docs/docs/images/akko_badday.png [deleted file]
docs/docs/images/favicon.ico [new file with mode: 0644]
docs/docs/images/favicon.png [new file with mode: 0755]
docs/docs/images/logo.png [new file with mode: 0755]
docs/mkdocs.yml
installation/akkoma.vcl
lib/mix/tasks/pleroma/user.ex
lib/pleroma/hashtag.ex
lib/pleroma/user.ex
lib/pleroma/user/hashtag_follow.ex [new file with mode: 0644]
lib/pleroma/user/search.ex
lib/pleroma/web/activity_pub/activity_pub.ex
lib/pleroma/web/api_spec/operations/tag_operation.ex [new file with mode: 0644]
lib/pleroma/web/api_spec/schemas/tag.ex
lib/pleroma/web/mastodon_api/controllers/tag_controller.ex [new file with mode: 0644]
lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex
lib/pleroma/web/mastodon_api/views/tag_view.ex [new file with mode: 0644]
lib/pleroma/web/router.ex
lib/pleroma/web/streamer.ex
priv/repo/migrations/20221203232118_add_user_follows_hashtag.exs [new file with mode: 0644]
priv/static/favicon.png
test/pleroma/user/user_search_test.exs [new file with mode: 0644]
test/pleroma/user_test.exs
test/pleroma/web/activity_pub/activity_pub_test.exs
test/pleroma/web/mastodon_api/controllers/tag_controller_test.exs [new file with mode: 0644]
test/pleroma/web/streamer_test.exs
test/support/factory.ex

index 136c7e65fc3366c1d97f53e8dedfb5c9da2cdc1f..e0946b76dd6988d6871806b40fb82de30b31cc20 100644 (file)
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Ability to set a default post expiry time, after which the post will be deleted. If used in concert with ActivityExpiration MRF, the expiry which comes _sooner_ will be applied.
 - Regular task to prune local transient activities
 - Task to manually run the transient prune job (pleroma.database prune\_task)
+- Ability to follow hashtags
 
 ## Changed
 - MastoAPI: Accept BooleanLike input on `/api/v1/accounts/:id/follow` (fixes follows with mastodon.py)
index c7b8f50dbb183168260e1035b2a823e9e7f7e9ea..4cd8c59b98c894106965c1e692357def8043c6f6 100644 (file)
@@ -19,7 +19,7 @@
                 "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14",
                 "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"
             ],
-            "markers": "python_version >= '3.6'",
+            "markers": "python_full_version >= '3.6.0'",
             "version": "==2022.9.24"
         },
         "charset-normalizer": {
@@ -27,7 +27,7 @@
                 "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845",
                 "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"
             ],
-            "markers": "python_version >= '3.6'",
+            "markers": "python_full_version >= '3.6.0'",
             "version": "==2.1.1"
         },
         "click": {
                 "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874",
                 "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"
             ],
-            "markers": "python_version >= '3.6'",
+            "markers": "python_full_version >= '3.6.0'",
             "version": "==3.3.7"
         },
         "markdown-include": {
             "hashes": [
-                "sha256:a06183b7c7225e73112737acdc6fe0ac0686c39457234eeb5ede23881fed001d"
+                "sha256:b8f6b6f4e8b506cbe773d7e26c74a97d1354c35f3a3452d3449140a8f578d665",
+                "sha256:d12fb51500c46334a53608635035c78b7d8ad7f772566f70b8a6a9b2ef2ddbf5"
             ],
             "index": "pypi",
-            "version": "==0.7.0"
+            "version": "==0.8.0"
         },
         "markupsafe": {
             "hashes": [
                 "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8",
                 "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"
             ],
-            "markers": "python_version >= '3.6'",
+            "markers": "python_full_version >= '3.6.0'",
             "version": "==1.3.4"
         },
         "mkdocs": {
         },
         "mkdocs-material": {
             "hashes": [
-                "sha256:143ea55843b3747b640e1110824d91e8a4c670352380e166e64959f9abe98862",
-                "sha256:45eeabb23d2caba8fa3b85c91d9ec8e8b22add716e9bba8faf16d56af8aa5622"
+                "sha256:b0ea0513fd8cab323e8a825d6692ea07fa83e917bb5db042e523afecc7064ab7",
+                "sha256:c907b4b052240a5778074a30a78f31a1f8ff82d7012356dc26898b97559f082e"
             ],
             "index": "pypi",
-            "version": "==8.5.9"
+            "version": "==8.5.11"
         },
         "mkdocs-material-extensions": {
             "hashes": [
-                "sha256:96ca979dae66d65c2099eefe189b49d5ac62f76afb59c38e069ffc7cf3c131ec",
-                "sha256:bcc2e5fc70c0ec50e59703ee6e639d87c7e664c0c441c014ea84461a90f1e902"
+                "sha256:9c003da71e2cc2493d910237448c672e00cefc800d3d6ae93d2fc69979e3bd93",
+                "sha256:e41d9f38e4798b6617ad98ca8f7f1157b1e4385ac1459ca1e4ea219b556df945"
             ],
             "markers": "python_version >= '3.7'",
-            "version": "==1.1"
+            "version": "==1.1.1"
         },
         "packaging": {
             "hashes": [
                 "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb",
                 "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"
             ],
-            "markers": "python_version >= '3.6'",
+            "markers": "python_full_version >= '3.6.0'",
             "version": "==21.3"
         },
         "pygments": {
                 "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1",
                 "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"
             ],
-            "markers": "python_version >= '3.6'",
+            "markers": "python_full_version >= '3.6.0'",
             "version": "==2.13.0"
         },
         "pymdown-extensions": {
             "hashes": [
-                "sha256:1bd4a173095ef8c433b831af1f3cb13c10883be0c100ae613560668e594651f7",
-                "sha256:8e62688a8b1128acd42fa823f3d429d22f4284b5e6dd4d3cd56721559a5a211b"
+                "sha256:0f8fb7b74a37a61cc34e90b2c91865458b713ec774894ffad64353a5fce85cfc",
+                "sha256:ac698c15265680db5eb13cd4342abfcde2079ac01e5486028f47a1b41547b859"
             ],
             "markers": "python_version >= '3.7'",
-            "version": "==9.8"
+            "version": "==9.9"
         },
         "pyparsing": {
             "hashes": [
                 "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
                 "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
             ],
-            "markers": "python_version >= '3.6'",
+            "markers": "python_full_version >= '3.6.0'",
             "version": "==6.0"
         },
         "pyyaml-env-tag": {
                 "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb",
                 "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"
             ],
-            "markers": "python_version >= '3.6'",
+            "markers": "python_full_version >= '3.6.0'",
             "version": "==0.1"
         },
         "requests": {
         },
         "urllib3": {
             "hashes": [
-                "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e",
-                "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"
+                "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc",
+                "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"
             ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4'",
-            "version": "==1.26.12"
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
+            "version": "==1.26.13"
         },
         "watchdog": {
             "hashes": [
-                "sha256:083171652584e1b8829581f965b9b7723ca5f9a2cd7e20271edf264cfd7c1412",
-                "sha256:117ffc6ec261639a0209a3252546b12800670d4bf5f84fbd355957a0595fe654",
-                "sha256:186f6c55abc5e03872ae14c2f294a153ec7292f807af99f57611acc8caa75306",
-                "sha256:195fc70c6e41237362ba720e9aaf394f8178bfc7fa68207f112d108edef1af33",
-                "sha256:226b3c6c468ce72051a4c15a4cc2ef317c32590d82ba0b330403cafd98a62cfd",
-                "sha256:247dcf1df956daa24828bfea5a138d0e7a7c98b1a47cf1fa5b0c3c16241fcbb7",
-                "sha256:255bb5758f7e89b1a13c05a5bceccec2219f8995a3a4c4d6968fe1de6a3b2892",
-                "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609",
-                "sha256:4f4e1c4aa54fb86316a62a87b3378c025e228178d55481d30d857c6c438897d6",
-                "sha256:5952135968519e2447a01875a6f5fc8c03190b24d14ee52b0f4b1682259520b1",
-                "sha256:64a27aed691408a6abd83394b38503e8176f69031ca25d64131d8d640a307591",
-                "sha256:6b17d302850c8d412784d9246cfe8d7e3af6bcd45f958abb2d08a6f8bedf695d",
-                "sha256:70af927aa1613ded6a68089a9262a009fbdf819f46d09c1a908d4b36e1ba2b2d",
-                "sha256:7a833211f49143c3d336729b0020ffd1274078e94b0ae42e22f596999f50279c",
-                "sha256:8250546a98388cbc00c3ee3cc5cf96799b5a595270dfcfa855491a64b86ef8c3",
-                "sha256:97f9752208f5154e9e7b76acc8c4f5a58801b338de2af14e7e181ee3b28a5d39",
-                "sha256:9f05a5f7c12452f6a27203f76779ae3f46fa30f1dd833037ea8cbc2887c60213",
-                "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330",
-                "sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428",
-                "sha256:b530ae007a5f5d50b7fbba96634c7ee21abec70dc3e7f0233339c81943848dc1",
-                "sha256:bfc4d351e6348d6ec51df007432e6fe80adb53fd41183716017026af03427846",
-                "sha256:d3dda00aca282b26194bdd0adec21e4c21e916956d972369359ba63ade616153",
-                "sha256:d9820fe47c20c13e3c9dd544d3706a2a26c02b2b43c993b62fcd8011bcc0adb3",
-                "sha256:ed80a1628cee19f5cfc6bb74e173f1b4189eb532e705e2a13e3250312a62e0c9",
-                "sha256:ee3e38a6cc050a8830089f79cbec8a3878ec2fe5160cdb2dc8ccb6def8552658"
+                "sha256:1893d425ef4fb4f129ee8ef72226836619c2950dd0559bba022b0818c63a7b60",
+                "sha256:1a410dd4d0adcc86b4c71d1317ba2ea2c92babaf5b83321e4bde2514525544d5",
+                "sha256:1f2b0665c57358ce9786f06f5475bc083fea9d81ecc0efa4733fd0c320940a37",
+                "sha256:1f8eca9d294a4f194ce9df0d97d19b5598f310950d3ac3dd6e8d25ae456d4c8a",
+                "sha256:27e49268735b3c27310883012ab3bd86ea0a96dcab90fe3feb682472e30c90f3",
+                "sha256:28704c71afdb79c3f215c90231e41c52b056ea880b6be6cee035c6149d658ed1",
+                "sha256:2ac0bd7c206bb6df78ef9e8ad27cc1346f2b41b1fef610395607319cdab89bc1",
+                "sha256:2af1a29fd14fc0a87fb6ed762d3e1ae5694dcde22372eebba50e9e5be47af03c",
+                "sha256:3a048865c828389cb06c0bebf8a883cec3ae58ad3e366bcc38c61d8455a3138f",
+                "sha256:441024df19253bb108d3a8a5de7a186003d68564084576fecf7333a441271ef7",
+                "sha256:56fb3f40fc3deecf6e518303c7533f5e2a722e377b12507f6de891583f1b48aa",
+                "sha256:619d63fa5be69f89ff3a93e165e602c08ed8da402ca42b99cd59a8ec115673e1",
+                "sha256:74535e955359d79d126885e642d3683616e6d9ab3aae0e7dcccd043bd5a3ff4f",
+                "sha256:76a2743402b794629a955d96ea2e240bd0e903aa26e02e93cd2d57b33900962b",
+                "sha256:83cf8bc60d9c613b66a4c018051873d6273d9e45d040eed06d6a96241bd8ec01",
+                "sha256:920a4bda7daa47545c3201a3292e99300ba81ca26b7569575bd086c865889090",
+                "sha256:9e99c1713e4436d2563f5828c8910e5ff25abd6ce999e75f15c15d81d41980b6",
+                "sha256:a5bd9e8656d07cae89ac464ee4bcb6f1b9cecbedc3bf1334683bed3d5afd39ba",
+                "sha256:ad0150536469fa4b693531e497ffe220d5b6cd76ad2eda474a5e641ee204bbb6",
+                "sha256:af4b5c7ba60206759a1d99811b5938ca666ea9562a1052b410637bb96ff97512",
+                "sha256:c7bd98813d34bfa9b464cf8122e7d4bec0a5a427399094d2c17dd5f70d59bc61",
+                "sha256:ceaa9268d81205876bedb1069f9feab3eccddd4b90d9a45d06a0df592a04cae9",
+                "sha256:cf05e6ff677b9655c6e9511d02e9cc55e730c4e430b7a54af9c28912294605a4",
+                "sha256:d0fb5f2b513556c2abb578c1066f5f467d729f2eb689bc2db0739daf81c6bb7e",
+                "sha256:d6ae890798a3560688b441ef086bb66e87af6b400a92749a18b856a134fc0318",
+                "sha256:e5aed2a700a18c194c39c266900d41f3db0c1ebe6b8a0834b9995c835d2ca66e",
+                "sha256:e722755d995035dd32177a9c633d158f2ec604f2a358b545bba5bed53ab25bca",
+                "sha256:ed91c3ccfc23398e7aa9715abf679d5c163394b8cad994f34f156d57a7c163dc"
             ],
-            "markers": "python_version >= '3.6'",
-            "version": "==2.1.9"
+            "markers": "python_full_version >= '3.6.0'",
+            "version": "==2.2.0"
         }
     },
     "develop": {}
index ffc74e1ff8ec50d70b50bf1e912894b260146a16..cf2f7d1b08de781d4f95b2b5110b2ef4a7b750f9 100644 (file)
@@ -4,38 +4,62 @@
 
 1. Stop the Akkoma service.
 2. Go to the working directory of Akkoma (default is `/opt/akkoma`)
-3. Run `sudo -Hu postgres pg_dump -d <akkoma_db> --format=custom -f </path/to/backup_location/akkoma.pgdump>` (make sure the postgres user has write access to the destination file)
-4. Copy `akkoma.pgdump`, `config/prod.secret.exs`, `config/setup_db.psql` (if still available) and the `uploads` folder to your backup destination. If you have other modifications, copy those changes too.
+3. Run[¹] `sudo -Hu postgres pg_dump -d akkoma --format=custom -f </path/to/backup_location/akkoma.pgdump>` (make sure the postgres user has write access to the destination file)
+4. Copy `akkoma.pgdump`, `config/prod.secret.exs`[²], `config/setup_db.psql` (if still available) and the `uploads` folder to your backup destination. If you have other modifications, copy those changes too.
 5. Restart the Akkoma service.
 
+[¹]: We assume the database name is "akkoma". If not, you can find the correct name in your config files.  
+[²]: If you've installed using OTP, you need `config/config.exs` instead of `config/prod.secret.exs`.  
+
 ## Restore/Move
 
 1. Optionally reinstall Akkoma (either on the same server or on another server if you want to move servers).
 2. Stop the Akkoma service.
 3. Go to the working directory of Akkoma (default is `/opt/akkoma`)
 4. Copy the above mentioned files back to their original position.
-5. Drop the existing database and user if restoring in-place. `sudo -Hu postgres psql -c 'DROP DATABASE <akkoma_db>;';` `sudo -Hu postgres psql -c 'DROP USER <akkoma_db>;'`
-6. Restore the database schema and akkoma postgres role the with the original `setup_db.psql` if you have it: `sudo -Hu postgres psql -f config/setup_db.psql`.
-
-  Alternatively, run the `mix pleroma.instance gen` task again. You can ignore most of the questions, but make the database user, name, and password the same as found in your backup of `config/prod.secret.exs`. Then run the restoration of the akkoma role and schema with of the generated `config/setup_db.psql` as instructed above. You may delete the `config/generated_config.exs` file as it is not needed.
-
-7. Now restore the Akkoma instance's data into the empty database schema: `sudo -Hu postgres pg_restore -d <akkoma_db> -v -1 </path/to/backup_location/akkoma.pgdump>`
-8. If you installed a newer Akkoma version, you should run `mix ecto.migrate`[^1]. This task performs database migrations, if there were any.
+5. Drop the existing database and user if restoring in-place[¹]. `sudo -Hu postgres psql -c 'DROP DATABASE akkoma;';` `sudo -Hu postgres psql -c 'DROP USER akkoma;'`
+6. Restore the database schema and akkoma role using either of the following options
+    * You can use the original `setup_db.psql` if you have it[²]: `sudo -Hu postgres psql -f config/setup_db.psql`.
+    * Or recreate the database and user yourself (replace the password with the one you find in the config file) `sudo -Hu postgres psql -c "CREATE USER akkoma WITH ENCRYPTED PASSWORD '<database-password-wich-you-can-find-in-your-config-file>'; CREATE DATABASE akkoma OWNER akkoma;"`.
+7. Now restore the Akkoma instance's data into the empty database schema[¹][³]: `sudo -Hu postgres pg_restore -d akkoma -v -1 </path/to/backup_location/akkoma.pgdump>`
+8. If you installed a newer Akkoma version, you should run `MIX_ENV=prod mix ecto.migrate`[⁴]. This task performs database migrations, if there were any.
 9. Restart the Akkoma service.
 10. Run `sudo -Hu postgres vacuumdb --all --analyze-in-stages`. This will quickly generate the statistics so that postgres can properly plan queries.
 11. If setting up on a new server configure Nginx by using the `installation/akkoma.nginx` config sample or reference the Akkoma installation guide for your OS which contains the Nginx configuration instructions.
 
-[^1]: Prefix with `MIX_ENV=prod` to run it using the production config file.
+[¹]: We assume the database name and user are both "akkoma". If not, you can find the correct name in your config files.  
+[²]: You can recreate the `config/setup_db.psql` by running the `mix pleroma.instance gen` task again. You can ignore most of the questions, but make the database user, name, and password the same as found in your backed up config file. This will also create a new `config/generated_config.exs` file which you may delete as it is not needed.  
+[³]: `pg_restore` will add data before adding indexes. The indexes are added in alphabetical order. There's one index, `activities_visibility_index` which may take a long time because it can't make use of an index that's only added later. You can significantly speed up restoration by skipping this index and add it afterwards. For that, you can do the following (we assume the akkoma.pgdump is in the directory you're running the commands):  
+
+```sh
+pg_restore -l akkoma.pgdump > db.list
+
+# Comment out the step for creating activities_visibility_index by adding a semi colon at the start of the line
+sed -i -E 's/(.*activities_visibility_index.*)/;\1/' db.list
+
+# We restore the database using the db.list list-file
+sudo -Hu postgres pg_restore -L db.list -d akkoma -v -1 akkoma.pgdump
+
+# You can see the sql statement with which to create the index using
+grep -Eao 'CREATE INDEX activities_visibility_index.*' akkoma.pgdump
+
+# Then create the index manually
+# Make sure that the command to create is correct! You never know it has changed since writing this guide
+sudo -Hu postgres psql -d pleroma_ynh -c "CREATE INDEX activities_visibility_index ON public.activities USING btree (public.activity_visibility(actor, recipients, data), id DESC NULLS LAST) WHERE ((data ->> 'type'::text) = 'Create'::text);"
+```
+[⁴]: Prefix with `MIX_ENV=prod` to run it using the production config file.  
 
 ## Remove
 
 1. Optionally you can remove the users of your instance. This will trigger delete requests for their accounts and posts. Note that this is 'best effort' and doesn't mean that all traces of your instance will be gone from the fediverse.
     * You can do this from the admin-FE where you can select all local users and delete the accounts using the *Moderate multiple users* dropdown.
-    * You can also list local users and delete them individualy using the CLI tasks for [Managing users](./CLI_tasks/user.md).
+    * You can also list local users and delete them individually using the CLI tasks for [Managing users](./CLI_tasks/user.md).
 2. Stop the Akkoma service `systemctl stop akkoma`
-3. Disable akkoma from systemd `systemctl disable akkoma`
+3. Disable Akkoma from systemd `systemctl disable akkoma`
 4. Remove the files and folders you created during installation (see installation guide). This includes the akkoma, nginx and systemd files and folders.
 5. Reload nginx now that the configuration is removed `systemctl reload nginx`
-6. Remove the database and database user `sudo -Hu postgres psql -c 'DROP DATABASE <akkoma_db>;';` `sudo -Hu postgres psql -c 'DROP USER <akkoma_db>;'`
+6. Remove the database and database user[¹] `sudo -Hu postgres psql -c 'DROP DATABASE akkoma;';` `sudo -Hu postgres psql -c 'DROP USER akkoma;'`
 7. Remove the system user `userdel akkoma`
 8. Remove the dependencies that you don't need anymore (see installation guide). Make sure you don't remove packages that are still needed for other software that you have running!
+
+[¹]: We assume the database name and user are both "akkoma". If not, you can find the correct name in your config files.  
diff --git a/docs/docs/configuration/optimisation/varnish_cache.md b/docs/docs/configuration/optimisation/varnish_cache.md
new file mode 100644 (file)
index 0000000..1598354
--- /dev/null
@@ -0,0 +1,54 @@
+# Using a Varnish Cache
+
+Varnish is a layer that sits between your web server and your backend application -
+it does something similar to nginx caching, but tends to be optimised for speed over
+all else.
+
+To set up a varnish cache, first you'll need to install varnish. 
+
+This will vary by distribution, and since this is a rather advanced guide,
+no copy-paste instructions are provided. It's probably in your distribution's
+package manager, though. `apt-get install varnish` and so on.
+
+Once you have varnish installed, you'll need to configure it to work with akkoma.
+
+Copy the configuration file to the varnish configuration directory:
+
+    cp installation/akkoma.vcl /etc/varnish/akkoma.vcl
+
+You may want to check if varnish added a `default.vcl` file to the same directory,
+if so you can just remove it without issue.
+
+Then boot up varnish, probably `systemctl start varnish` or `service varnish start`.
+
+Now you should be able to `curl -D- localhost:6081` and see a bunch of
+akkoma javascript.
+
+Once that's out of the way, we can point our webserver at varnish. This
+
+=== "Nginx"
+
+    upstream phoenix {
+        server 127.0.0.1:6081 max_fails=5 fail_timeout=60s;
+    }
+
+
+=== "Caddy"
+
+    reverse_proxy 127.0.0.1:6081
+
+Now hopefully it all works
+
+If you get a HTTPS redirect loop, you may need to remove this part of the VCL
+
+```vcl
+if (std.port(server.ip) != 443) {
+      set req.http.X-Forwarded-Proto = "http";
+      set req.http.x-redir = "https://" + req.http.host + req.url;
+      return (synth(750, ""));
+} else {
+  set req.http.X-Forwarded-Proto = "https";
+}
+```
+
+This will allow your webserver alone to handle redirects.
\ No newline at end of file
diff --git a/docs/docs/images/akko_badday.png b/docs/docs/images/akko_badday.png
deleted file mode 100644 (file)
index b79c67d..0000000
Binary files a/docs/docs/images/akko_badday.png and /dev/null differ
diff --git a/docs/docs/images/favicon.ico b/docs/docs/images/favicon.ico
new file mode 100644 (file)
index 0000000..a0dd726
Binary files /dev/null and b/docs/docs/images/favicon.ico differ
diff --git a/docs/docs/images/favicon.png b/docs/docs/images/favicon.png
new file mode 100755 (executable)
index 0000000..1a12b4a
Binary files /dev/null and b/docs/docs/images/favicon.png differ
diff --git a/docs/docs/images/logo.png b/docs/docs/images/logo.png
new file mode 100755 (executable)
index 0000000..d82112c
Binary files /dev/null and b/docs/docs/images/logo.png differ
index c194399420a5edae1ec349482b0de264e5f16eea..b554d3df3aacdf6b34cdf16863b4a7d125840eed 100644 (file)
@@ -1,13 +1,16 @@
 site_name: Akkoma Documentation
 theme:
-  favicon: 'images/akko_badday.png'
+  favicon: 'images/favicon.ico'
   name: 'material'
   custom_dir: 'theme'
   # Disable google fonts
   font: false
-  logo: 'images/akko_badday.png'
+  logo: 'images/logo.png'
   features:
-    - tabs
+    - navigation.tabs
+    - toc.follow
+    - navigation.instant
+    - navigation.sections
   palette:
     primary: 'deep purple'
     accent: 'blue grey'
@@ -31,7 +34,8 @@ markdown_extensions:
   - pymdownx.tasklist:
       custom_checkbox: true
   - pymdownx.superfences
-  - pymdownx.tabbed
+  - pymdownx.tabbed:
+      alternate_style: true
   - pymdownx.details
   - markdown_include.include:
       base_path: docs
index 4752510ea010be1c91abb14bbf947d433d046131..4eb2f3cfae8d3e996347ffff43091022bceff67f 100644 (file)
@@ -1,4 +1,5 @@
 # Recommended varnishncsa logging format: '%h %l %u %t "%m %{X-Forwarded-Proto}i://%{Host}i%U%q %H" %s %b "%{Referer}i" "%{User-agent}i"'
+# Please use Varnish 7.0+ for proper Range Requests / Chunked encoding support
 vcl 4.1;
 import std;
 
@@ -22,11 +23,6 @@ sub vcl_recv {
       set req.http.X-Forwarded-Proto = "https";
     }
 
-    # CHUNKED SUPPORT
-    if (req.http.Range ~ "bytes=") {
-      set req.http.x-range = req.http.Range;
-    }
-
     # Pipe if WebSockets request is coming through
     if (req.http.upgrade ~ "(?i)websocket") {
       return (pipe);
@@ -35,9 +31,9 @@ sub vcl_recv {
     # Allow purging of the cache
     if (req.method == "PURGE") {
       if (!client.ip ~ purge) {
-        return(synth(405,"Not allowed."));
+        return (synth(405,"Not allowed."));
       }
-      return(purge);
+      return (purge);
     }
 }
 
@@ -53,17 +49,11 @@ sub vcl_backend_response {
       return (retry);
     }
 
-    # CHUNKED SUPPORT
-    if (bereq.http.x-range ~ "bytes=" && beresp.status == 206) {
-      set beresp.ttl = 10m;
-      set beresp.http.CR = beresp.http.content-range;
-    }
-
     # Bypass cache for large files
     # 50000000 ~ 50MB
     if (std.integer(beresp.http.content-length, 0) > 50000000) {
        set beresp.uncacheable = true;
-       return(deliver);
+       return (deliver);
     }
 
     # Don't cache objects that require authentication
@@ -94,7 +84,7 @@ sub vcl_synth {
     if (resp.status == 750) {
       set resp.status = 301;
       set resp.http.Location = req.http.x-redir;
-      return(deliver);
+      return (deliver);
     }
 }
 
@@ -106,25 +96,12 @@ sub vcl_pipe {
     }
 }
 
-sub vcl_hash {
-    # CHUNKED SUPPORT
-    if (req.http.x-range ~ "bytes=") {
-      hash_data(req.http.x-range);
-      unset req.http.Range;
-    }
-}
-
 sub vcl_backend_fetch {
     # Be more lenient for slow servers on the fediverse
     if (bereq.url ~ "^/proxy/") {
       set bereq.first_byte_timeout = 300s;
     }
 
-    # CHUNKED SUPPORT
-    if (bereq.http.x-range) {
-      set bereq.http.Range = bereq.http.x-range;
-    }
-
     if (bereq.retries == 0) {
         # Clean up the X-Varnish-Backend-503 flag that is used internally
         # to mark broken backend responses that should be retried.
@@ -143,14 +120,6 @@ sub vcl_backend_fetch {
     }
 }
 
-sub vcl_deliver {
-    # CHUNKED SUPPORT
-    if (resp.http.CR) {
-      set resp.http.Content-Range = resp.http.CR;
-      unset resp.http.CR;
-    }
-}
-
 sub vcl_backend_error {
     # Retry broken backend responses.
     set bereq.http.X-Varnish-Backend-503 = "1";
index 278a01acc27cec721476894c6cce4e4e7423c577..dd1cdca5b72d6804604cab3919f1fcd027bf1a3d 100644 (file)
@@ -471,9 +471,15 @@ defmodule Mix.Tasks.Pleroma.User do
 
   def run(["timeline_query", nickname]) do
     start_pleroma()
+
     params = %{local: true}
 
     with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do
+      followed_hashtags =
+        user
+        |> User.followed_hashtags()
+        |> Enum.map(& &1.id)
+
       params =
         params
         |> Map.put(:type, ["Create", "Announce"])
@@ -484,6 +490,7 @@ defmodule Mix.Tasks.Pleroma.User do
         |> Map.put(:announce_filtering_user, user)
         |> Map.put(:user, user)
         |> Map.put(:local_only, params[:local])
+        |> Map.put(:hashtags, followed_hashtags)
         |> Map.delete(:local)
 
       _activities =
index 53e2e9c897d564dd788306a72fa640ff75cbdce8..9030ee4e92c0f1537476459f0eda21eb2b49c756 100644 (file)
@@ -10,6 +10,7 @@ defmodule Pleroma.Hashtag do
 
   alias Ecto.Multi
   alias Pleroma.Hashtag
+  alias Pleroma.User.HashtagFollow
   alias Pleroma.Object
   alias Pleroma.Repo
 
@@ -27,6 +28,14 @@ defmodule Pleroma.Hashtag do
     |> String.trim()
   end
 
+  def get_by_id(id) do
+    Repo.get(Hashtag, id)
+  end
+
+  def get_by_name(name) do
+    Repo.get_by(Hashtag, name: normalize_name(name))
+  end
+
   def get_or_create_by_name(name) do
     changeset = changeset(%Hashtag{}, %{name: name})
 
@@ -103,4 +112,22 @@ defmodule Pleroma.Hashtag do
       {:ok, deleted_count}
     end
   end
+
+  def get_followers(%Hashtag{id: hashtag_id}) do
+    from(hf in HashtagFollow)
+    |> where([hf], hf.hashtag_id == ^hashtag_id)
+    |> join(:inner, [hf], u in assoc(hf, :user))
+    |> select([hf, u], u.id)
+    |> Repo.all()
+  end
+
+  def get_recipients_for_activity(%Pleroma.Activity{object: %{hashtags: tags}})
+      when is_list(tags) do
+    tags
+    |> Enum.map(&get_followers/1)
+    |> List.flatten()
+    |> Enum.uniq()
+  end
+
+  def get_recipients_for_activity(_activity), do: []
 end
index b0ab9d0cd6ada61835b7bad09019524b9edee2ef..c8262b37b6a146942bfa0f7e3f34257fce42c02e 100644 (file)
@@ -18,6 +18,8 @@ defmodule Pleroma.User do
   alias Pleroma.Emoji
   alias Pleroma.FollowingRelationship
   alias Pleroma.Formatter
+  alias Pleroma.Hashtag
+  alias Pleroma.User.HashtagFollow
   alias Pleroma.HTML
   alias Pleroma.Keys
   alias Pleroma.MFA
@@ -168,6 +170,12 @@ defmodule Pleroma.User do
 
     has_many(:frontend_profiles, Pleroma.Akkoma.FrontendSettingsProfile)
 
+    many_to_many(:followed_hashtags, Hashtag,
+      on_replace: :delete,
+      on_delete: :delete_all,
+      join_through: HashtagFollow
+    )
+
     for {relationship_type,
          [
            {outgoing_relation, outgoing_relation_target},
@@ -2550,4 +2558,54 @@ defmodule Pleroma.User do
       _ -> {:error, user}
     end
   end
+
+  defp maybe_load_followed_hashtags(%User{followed_hashtags: follows} = user)
+       when is_list(follows),
+       do: user
+
+  defp maybe_load_followed_hashtags(%User{} = user) do
+    followed_hashtags = HashtagFollow.get_by_user(user)
+    %{user | followed_hashtags: followed_hashtags}
+  end
+
+  def followed_hashtags(%User{followed_hashtags: follows})
+      when is_list(follows),
+      do: follows
+
+  def followed_hashtags(%User{} = user) do
+    {:ok, user} =
+      user
+      |> maybe_load_followed_hashtags()
+      |> set_cache()
+
+    user.followed_hashtags
+  end
+
+  def follow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
+    Logger.debug("Follow hashtag #{hashtag.name} for user #{user.nickname}")
+    user = maybe_load_followed_hashtags(user)
+
+    with {:ok, _} <- HashtagFollow.new(user, hashtag),
+         follows <- HashtagFollow.get_by_user(user),
+         %User{} = user <- user |> Map.put(:followed_hashtags, follows) do
+      user
+      |> set_cache()
+    end
+  end
+
+  def unfollow_hashtag(%User{} = user, %Hashtag{} = hashtag) do
+    Logger.debug("Unfollow hashtag #{hashtag.name} for user #{user.nickname}")
+    user = maybe_load_followed_hashtags(user)
+
+    with {:ok, _} <- HashtagFollow.delete(user, hashtag),
+         follows <- HashtagFollow.get_by_user(user),
+         %User{} = user <- user |> Map.put(:followed_hashtags, follows) do
+      user
+      |> set_cache()
+    end
+  end
+
+  def following_hashtag?(%User{} = user, %Hashtag{} = hashtag) do
+    not is_nil(HashtagFollow.get(user, hashtag))
+  end
 end
diff --git a/lib/pleroma/user/hashtag_follow.ex b/lib/pleroma/user/hashtag_follow.ex
new file mode 100644 (file)
index 0000000..43ed93f
--- /dev/null
@@ -0,0 +1,49 @@
+defmodule Pleroma.User.HashtagFollow do
+  use Ecto.Schema
+  import Ecto.Query
+  import Ecto.Changeset
+
+  alias Pleroma.User
+  alias Pleroma.Hashtag
+  alias Pleroma.Repo
+
+  schema "user_follows_hashtag" do
+    belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
+    belongs_to(:hashtag, Hashtag)
+  end
+
+  def changeset(%__MODULE__{} = user_hashtag_follow, attrs) do
+    user_hashtag_follow
+    |> cast(attrs, [:user_id, :hashtag_id])
+    |> unique_constraint(:hashtag_id,
+      name: :user_hashtag_follows_user_id_hashtag_id_index,
+      message: "already following"
+    )
+    |> validate_required([:user_id, :hashtag_id])
+  end
+
+  def new(%User{} = user, %Hashtag{} = hashtag) do
+    %__MODULE__{}
+    |> changeset(%{user_id: user.id, hashtag_id: hashtag.id})
+    |> Repo.insert(on_conflict: :nothing)
+  end
+
+  def delete(%User{} = user, %Hashtag{} = hashtag) do
+    with %__MODULE__{} = user_hashtag_follow <- get(user, hashtag) do
+      Repo.delete(user_hashtag_follow)
+    else
+      _ -> {:ok, nil}
+    end
+  end
+
+  def get(%User{} = user, %Hashtag{} = hashtag) do
+    from(hf in __MODULE__)
+    |> where([hf], hf.user_id == ^user.id and hf.hashtag_id == ^hashtag.id)
+    |> Repo.one()
+  end
+
+  def get_by_user(%User{} = user) do
+    Ecto.assoc(user, :followed_hashtags)
+    |> Repo.all()
+  end
+end
index 6b3f589993afee4b97d8d45ecdb730af4cafc7ce..ddce51775bd2dd74cb454f49ddbea6eab8f14111 100644 (file)
@@ -62,6 +62,11 @@ defmodule Pleroma.User.Search do
     end
   end
 
+  def sanitise_domain(domain) do
+    domain
+    |> String.replace(~r/[!-\,|@|?|<|>|[-`|{-~|\/|:|\s]+/, "")
+  end
+
   defp format_query(query_string) do
     # Strip the beginning @ off if there is a query
     query_string = String.trim_leading(query_string, "@")
@@ -69,7 +74,7 @@ defmodule Pleroma.User.Search do
     with [name, domain] <- String.split(query_string, "@") do
       encoded_domain =
         domain
-        |> String.replace(~r/[!-\-|@|[-`|{-~|\/|:|\s]+/, "")
+        |> sanitise_domain()
         |> String.to_charlist()
         |> :idna.encode()
         |> to_string()
index a4f1c7041969510c30a2a2cecefb9ac6f98132ff..3f46a8ecb18d6f35c2785f268311b69dff153457 100644 (file)
@@ -933,6 +933,31 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
     )
   end
 
+  # Essentially, either look for activities addressed to `recipients`, _OR_ ones
+  # that reference a hashtag that the user follows
+  # Firstly, two fallbacks in case there's no hashtag constraint, or the user doesn't
+  # follow any
+  defp restrict_recipients_or_hashtags(query, recipients, user, nil) do
+    restrict_recipients(query, recipients, user)
+  end
+
+  defp restrict_recipients_or_hashtags(query, recipients, user, []) do
+    restrict_recipients(query, recipients, user)
+  end
+
+  defp restrict_recipients_or_hashtags(query, recipients, _user, hashtag_ids) do
+    from([activity, object] in query)
+    |> join(:left, [activity, object], hto in "hashtags_objects",
+      on: hto.object_id == object.id,
+      as: :hto
+    )
+    |> where(
+      [activity, object, hto: hto],
+      (hto.hashtag_id in ^hashtag_ids and ^Constants.as_public() in activity.recipients) or
+        fragment("? && ?", ^recipients, activity.recipients)
+    )
+  end
+
   defp restrict_local(query, %{local_only: true}) do
     from(activity in query, where: activity.local == true)
   end
@@ -1380,7 +1405,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
       |> maybe_preload_report_notes(opts)
       |> maybe_set_thread_muted_field(opts)
       |> maybe_order(opts)
-      |> restrict_recipients(recipients, opts[:user])
+      |> restrict_recipients_or_hashtags(recipients, opts[:user], opts[:followed_hashtags])
       |> restrict_replies(opts)
       |> restrict_since(opts)
       |> restrict_local(opts)
diff --git a/lib/pleroma/web/api_spec/operations/tag_operation.ex b/lib/pleroma/web/api_spec/operations/tag_operation.ex
new file mode 100644 (file)
index 0000000..e224571
--- /dev/null
@@ -0,0 +1,65 @@
+defmodule Pleroma.Web.ApiSpec.TagOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.Tag
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def show_operation do
+    %Operation{
+      tags: ["Tags"],
+      summary: "Hashtag",
+      description: "View a hashtag",
+      security: [%{"oAuth" => ["read"]}],
+      parameters: [id_param()],
+      operationId: "TagController.show",
+      responses: %{
+        200 => Operation.response("Hashtag", "application/json", Tag),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  def follow_operation do
+    %Operation{
+      tags: ["Tags"],
+      summary: "Follow a hashtag",
+      description: "Follow a hashtag",
+      security: [%{"oAuth" => ["write:follows"]}],
+      parameters: [id_param()],
+      operationId: "TagController.follow",
+      responses: %{
+        200 => Operation.response("Hashtag", "application/json", Tag),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  def unfollow_operation do
+    %Operation{
+      tags: ["Tags"],
+      summary: "Unfollow a hashtag",
+      description: "Unfollow a hashtag",
+      security: [%{"oAuth" => ["write:follow"]}],
+      parameters: [id_param()],
+      operationId: "TagController.unfollow",
+      responses: %{
+        200 => Operation.response("Hashtag", "application/json", Tag),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp id_param do
+    Operation.parameter(
+      :id,
+      :path,
+      %Schema{type: :string},
+      "Name of the hashtag"
+    )
+  end
+end
index 657b675e5ad40b773a9536652d45e5039aeae20a..41b5e5c785277cdfb971f796037f4a608874c0e9 100644 (file)
@@ -17,11 +17,16 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Tag do
         type: :string,
         format: :uri,
         description: "A link to the hashtag on the instance"
+      },
+      following: %Schema{
+        type: :boolean,
+        description: "Whether the authenticated user is following the hashtag"
       }
     },
     example: %{
       name: "cofe",
-      url: "https://lain.com/tag/cofe"
+      url: "https://lain.com/tag/cofe",
+      following: false
     }
   })
 end
diff --git a/lib/pleroma/web/mastodon_api/controllers/tag_controller.ex b/lib/pleroma/web/mastodon_api/controllers/tag_controller.ex
new file mode 100644 (file)
index 0000000..b8995eb
--- /dev/null
@@ -0,0 +1,47 @@
+defmodule Pleroma.Web.MastodonAPI.TagController do
+  @moduledoc "Hashtag routes for mastodon API"
+  use Pleroma.Web, :controller
+
+  alias Pleroma.User
+  alias Pleroma.Hashtag
+
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+  plug(Pleroma.Web.Plugs.OAuthScopesPlug, %{scopes: ["read"]} when action in [:show])
+
+  plug(
+    Pleroma.Web.Plugs.OAuthScopesPlug,
+    %{scopes: ["write:follows"]} when action in [:follow, :unfollow]
+  )
+
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TagOperation
+
+  def show(conn, %{id: id}) do
+    with %Hashtag{} = hashtag <- Hashtag.get_by_name(id) do
+      render(conn, "show.json", tag: hashtag, for_user: conn.assigns.user)
+    else
+      _ -> conn |> render_error(:not_found, "Hashtag not found")
+    end
+  end
+
+  def follow(conn, %{id: id}) do
+    with %Hashtag{} = hashtag <- Hashtag.get_by_name(id),
+         %User{} = user <- conn.assigns.user,
+         {:ok, _} <-
+           User.follow_hashtag(user, hashtag) do
+      render(conn, "show.json", tag: hashtag, for_user: user)
+    else
+      _ -> render_error(conn, :not_found, "Hashtag not found")
+    end
+  end
+
+  def unfollow(conn, %{id: id}) do
+    with %Hashtag{} = hashtag <- Hashtag.get_by_name(id),
+         %User{} = user <- conn.assigns.user,
+         {:ok, _} <-
+           User.unfollow_hashtag(user, hashtag) do
+      render(conn, "show.json", tag: hashtag, for_user: user)
+    else
+      _ -> render_error(conn, :not_found, "Hashtag not found")
+    end
+  end
+end
index 5f8acb2df3cd7e4b06b4401f6d46354b7211240c..2d0e36420f7a90c6007acd255e73fb467ddedda8 100644 (file)
@@ -41,6 +41,11 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
 
   # GET /api/v1/timelines/home
   def home(%{assigns: %{user: user}} = conn, params) do
+    followed_hashtags =
+      user
+      |> User.followed_hashtags()
+      |> Enum.map(& &1.id)
+
     params =
       params
       |> Map.put(:type, ["Create", "Announce"])
@@ -50,6 +55,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
       |> Map.put(:announce_filtering_user, user)
       |> Map.put(:user, user)
       |> Map.put(:local_only, params[:local])
+      |> Map.put(:followed_hashtags, followed_hashtags)
       |> Map.delete(:local)
 
     activities =
diff --git a/lib/pleroma/web/mastodon_api/views/tag_view.ex b/lib/pleroma/web/mastodon_api/views/tag_view.ex
new file mode 100644 (file)
index 0000000..6e491c2
--- /dev/null
@@ -0,0 +1,21 @@
+defmodule Pleroma.Web.MastodonAPI.TagView do
+  use Pleroma.Web, :view
+  alias Pleroma.User
+  alias Pleroma.Web.Router.Helpers
+
+  def render("show.json", %{tag: tag, for_user: user}) do
+    following =
+      with %User{} <- user do
+        User.following_hashtag?(user, tag)
+      else
+        _ -> false
+      end
+
+    %{
+      name: tag.name,
+      url: Helpers.tag_feed_url(Pleroma.Web.Endpoint, :feed, tag.name),
+      history: [],
+      following: following
+    }
+  end
+end
index 71a9e4d29febdcac4223d043bd874eaa1c6e6066..a34dd26ceb94d9f1a83023977491f29ec1d55c7f 100644 (file)
@@ -598,6 +598,10 @@ defmodule Pleroma.Web.Router do
 
     get("/announcements", AnnouncementController, :index)
     post("/announcements/:id/dismiss", AnnouncementController, :mark_read)
+
+    get("/tags/:id", TagController, :show)
+    post("/tags/:id/follow", TagController, :follow)
+    post("/tags/:id/unfollow", TagController, :unfollow)
   end
 
   scope "/api/web", Pleroma.Web do
index c03e7fc30a569844cf7032ce4a93e0e8bc7a4903..f009fbd9e1853e01001209207e6cd83270ca1a89 100644 (file)
@@ -17,6 +17,7 @@ defmodule Pleroma.Web.Streamer do
   alias Pleroma.Web.OAuth.Token
   alias Pleroma.Web.Plugs.OAuthScopesPlug
   alias Pleroma.Web.StreamerView
+  require Pleroma.Constants
 
   @mix_env Mix.env()
   @registry Pleroma.Web.StreamerRegistry
@@ -252,7 +253,17 @@ defmodule Pleroma.Web.Streamer do
       User.get_recipients_from_activity(item)
       |> Enum.map(fn %{id: id} -> "user:#{id}" end)
 
-    Enum.each(recipient_topics, fn topic ->
+    hashtag_recipients =
+      if Pleroma.Constants.as_public() in item.recipients do
+        Pleroma.Hashtag.get_recipients_for_activity(item)
+        |> Enum.map(fn id -> "user:#{id}" end)
+      else
+        []
+      end
+
+    all_recipients = Enum.uniq(recipient_topics ++ hashtag_recipients)
+
+    Enum.each(all_recipients, fn topic ->
       push_to_socket(topic, item)
     end)
   end
diff --git a/priv/repo/migrations/20221203232118_add_user_follows_hashtag.exs b/priv/repo/migrations/20221203232118_add_user_follows_hashtag.exs
new file mode 100644 (file)
index 0000000..27fff25
--- /dev/null
@@ -0,0 +1,12 @@
+defmodule Pleroma.Repo.Migrations.AddUserFollowsHashtag do
+  use Ecto.Migration
+
+  def change do
+    create table(:user_follows_hashtag) do
+      add(:hashtag_id, references(:hashtags))
+      add(:user_id, references(:users, type: :uuid, on_delete: :delete_all))
+    end
+
+    create(unique_index(:user_follows_hashtag, [:user_id, :hashtag_id]))
+  end
+end
index 287c75bfafa195f66b769066d3b13a9dba88e40e..1a12b4ad8acebcd289c638c37e532c6e84584382 100644 (file)
Binary files a/priv/static/favicon.png and b/priv/static/favicon.png differ
diff --git a/test/pleroma/user/user_search_test.exs b/test/pleroma/user/user_search_test.exs
new file mode 100644 (file)
index 0000000..5ea5bf7
--- /dev/null
@@ -0,0 +1,22 @@
+defmodule Pleroma.User.SearchTest do
+  use Pleroma.DataCase
+
+  describe "sanitise_domain/1" do
+    test "should remove url-reserved characters" do
+      examples = [
+        ["example.com", "example.com"],
+        ["no spaces", "nospaces"],
+        ["no@at", "noat"],
+        ["dash-is-ok", "dash-is-ok"],
+        ["underscore_not_so_much", "underscorenotsomuch"],
+        ["no!", "no"],
+        ["no?", "no"],
+        ["a$b%s^o*l(u)t'e#l<y n>o/t", "absolutelynot"]
+      ]
+
+      for [input, expected] <- examples do
+        assert Pleroma.User.Search.sanitise_domain(input) == expected
+      end
+    end
+  end
+end
index 44763daf70bcf544c273dea4866693b3444b6495..cc6634aba37197b8979a7a1ca7618ece7e13cd6b 100644 (file)
@@ -2679,4 +2679,74 @@ defmodule Pleroma.UserTest do
       assert user.ap_id in user3_updated.also_known_as
     end
   end
+
+  describe "follow_hashtag/2" do
+    test "should follow a hashtag" do
+      user = insert(:user)
+      hashtag = insert(:hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+
+      user = User.get_cached_by_ap_id(user.ap_id)
+
+      assert user.followed_hashtags |> Enum.count() == 1
+      assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+    end
+
+    test "should not follow a hashtag twice" do
+      user = insert(:user)
+      hashtag = insert(:hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+
+      user = User.get_cached_by_ap_id(user.ap_id)
+
+      assert user.followed_hashtags |> Enum.count() == 1
+      assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+    end
+
+    test "can follow multiple hashtags" do
+      user = insert(:user)
+      hashtag = insert(:hashtag)
+      other_hashtag = insert(:hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+      assert {:ok, _} = user |> User.follow_hashtag(other_hashtag)
+
+      user = User.get_cached_by_ap_id(user.ap_id)
+
+      assert user.followed_hashtags |> Enum.count() == 2
+      assert hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+      assert other_hashtag.name in Enum.map(user.followed_hashtags, fn %{name: name} -> name end)
+    end
+  end
+
+  describe "unfollow_hashtag/2" do
+    test "should unfollow a hashtag" do
+      user = insert(:user)
+      hashtag = insert(:hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+      assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
+
+      user = User.get_cached_by_ap_id(user.ap_id)
+
+      assert user.followed_hashtags |> Enum.count() == 0
+    end
+
+    test "should not error when trying to unfollow a hashtag twice" do
+      user = insert(:user)
+      hashtag = insert(:hashtag)
+
+      assert {:ok, _} = user |> User.follow_hashtag(hashtag)
+      assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
+      assert {:ok, _} = user |> User.unfollow_hashtag(hashtag)
+
+      user = User.get_cached_by_ap_id(user.ap_id)
+
+      assert user.followed_hashtags |> Enum.count() == 0
+    end
+  end
 end
index 8d39b1076918fb6830b44684c29609d86af1eb65..17c52fc912354a460665445a8860533c05cf8acc 100644 (file)
@@ -719,6 +719,33 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do
     end
   end
 
+  describe "fetch activities for followed hashtags" do
+    test "it should return public activities that reference a given hashtag" do
+      hashtag = insert(:hashtag, name: "tenshi")
+      user = insert(:user)
+      other_user = insert(:user)
+
+      {:ok, normally_visible} =
+        CommonAPI.post(other_user, %{status: "hello :)", visibility: "public"})
+
+      {:ok, public} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "public"})
+      {:ok, _unrelated} = CommonAPI.post(user, %{status: "dai #tensh", visibility: "public"})
+      {:ok, unlisted} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "unlisted"})
+      {:ok, _private} = CommonAPI.post(user, %{status: "maji #tenshi", visibility: "private"})
+
+      activities =
+        ActivityPub.fetch_activities([other_user.follower_address], %{
+          followed_hashtags: [hashtag.id]
+        })
+
+      assert length(activities) == 3
+      normal_id = normally_visible.id
+      public_id = public.id
+      unlisted_id = unlisted.id
+      assert [%{id: ^normal_id}, %{id: ^public_id}, %{id: ^unlisted_id}] = activities
+    end
+  end
+
   describe "fetch activities in context" do
     test "retrieves activities that have a given context" do
       {:ok, activity} = ActivityBuilder.insert(%{"type" => "Create", "context" => "2hu"})
diff --git a/test/pleroma/web/mastodon_api/controllers/tag_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/tag_controller_test.exs
new file mode 100644 (file)
index 0000000..a1b73ad
--- /dev/null
@@ -0,0 +1,97 @@
+defmodule Pleroma.Web.MastodonAPI.TagControllerTest do
+  use Pleroma.Web.ConnCase
+
+  import Pleroma.Factory
+  import Tesla.Mock
+
+  alias Pleroma.User
+
+  setup do
+    mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
+    :ok
+  end
+
+  describe "GET /api/v1/tags/:id" do
+    test "returns 200 with tag" do
+      %{user: user, conn: conn} = oauth_access(["read"])
+
+      tag = insert(:hashtag, name: "jubjub")
+      {:ok, _user} = User.follow_hashtag(user, tag)
+
+      response =
+        conn
+        |> get("/api/v1/tags/jubjub")
+        |> json_response_and_validate_schema(200)
+
+      assert %{
+               "name" => "jubjub",
+               "url" => "http://localhost:4001/tags/jubjub",
+               "history" => [],
+               "following" => true
+             } = response
+    end
+
+    test "returns 404 with unknown tag" do
+      %{conn: conn} = oauth_access(["read"])
+
+      conn
+      |> get("/api/v1/tags/jubjub")
+      |> json_response_and_validate_schema(404)
+    end
+  end
+
+  describe "POST /api/v1/tags/:id/follow" do
+    test "should follow a hashtag" do
+      %{user: user, conn: conn} = oauth_access(["write:follows"])
+      hashtag = insert(:hashtag, name: "jubjub")
+
+      response =
+        conn
+        |> post("/api/v1/tags/jubjub/follow")
+        |> json_response_and_validate_schema(200)
+
+      assert response["following"] == true
+      user = User.get_cached_by_ap_id(user.ap_id)
+      assert User.following_hashtag?(user, hashtag)
+    end
+
+    test "should 404 if hashtag doesn't exist" do
+      %{conn: conn} = oauth_access(["write:follows"])
+
+      response =
+        conn
+        |> post("/api/v1/tags/rubrub/follow")
+        |> json_response_and_validate_schema(404)
+
+      assert response["error"] == "Hashtag not found"
+    end
+  end
+
+  describe "POST /api/v1/tags/:id/unfollow" do
+    test "should unfollow a hashtag" do
+      %{user: user, conn: conn} = oauth_access(["write:follows"])
+      hashtag = insert(:hashtag, name: "jubjub")
+      {:ok, user} = User.follow_hashtag(user, hashtag)
+
+      response =
+        conn
+        |> post("/api/v1/tags/jubjub/unfollow")
+        |> json_response_and_validate_schema(200)
+
+      assert response["following"] == false
+      user = User.get_cached_by_ap_id(user.ap_id)
+      refute User.following_hashtag?(user, hashtag)
+    end
+
+    test "should 404 if hashtag doesn't exist" do
+      %{conn: conn} = oauth_access(["write:follows"])
+
+      response =
+        conn
+        |> post("/api/v1/tags/rubrub/unfollow")
+        |> json_response_and_validate_schema(404)
+
+      assert response["error"] == "Hashtag not found"
+    end
+  end
+end
index a9db5a0158162e6f196480147dfc2c9dc55c69de..b07c16faa1aba84525e6d1daf690e77ca7cbde2f 100644 (file)
@@ -410,6 +410,36 @@ defmodule Pleroma.Web.StreamerTest do
       assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream}
       refute Streamer.filtered_by_user?(user, edited)
     end
+
+    test "it streams posts containing followed hashtags on the 'user' stream", %{
+      user: user,
+      token: oauth_token
+    } do
+      hashtag = insert(:hashtag, %{name: "tenshi"})
+      other_user = insert(:user)
+      {:ok, user} = User.follow_hashtag(user, hashtag)
+
+      Streamer.get_topic_and_add_socket("user", user, oauth_token)
+      {:ok, activity} = CommonAPI.post(other_user, %{status: "hey #tenshi"})
+
+      assert_receive {:render_with_user, _, "update.json", ^activity, _}
+    end
+
+    test "should not stream private posts containing followed hashtags on the 'user' stream", %{
+      user: user,
+      token: oauth_token
+    } do
+      hashtag = insert(:hashtag, %{name: "tenshi"})
+      other_user = insert(:user)
+      {:ok, user} = User.follow_hashtag(user, hashtag)
+
+      Streamer.get_topic_and_add_socket("user", user, oauth_token)
+
+      {:ok, activity} =
+        CommonAPI.post(other_user, %{status: "hey #tenshi", visibility: "private"})
+
+      refute_receive {:render_with_user, _, "update.json", ^activity, _}
+    end
   end
 
   describe "public streams" do
index 6ce4decbcc144212797e8569c90b2b0c152f67d7..808f8f8879e9148bca2dd0e63e6dd5f0c8fed596 100644 (file)
@@ -716,4 +716,11 @@ defmodule Pleroma.Factory do
       user: user
     }
   end
+
+  def hashtag_factory(params \\ %{}) do
+    %Pleroma.Hashtag{
+      name: "test #{sequence(:hashtag_name, & &1)}"
+    }
+    |> Map.merge(params)
+  end
 end