- 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)
"sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14",
"sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"
],
- "markers": "python_version >= '3.6'",
+ "markers": "python_full_version >= '3.6.0'",
"version": "==2022.9.24"
},
"charset-normalizer": {
"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": {}
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.
--- /dev/null
+# 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
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'
- pymdownx.tasklist:
custom_checkbox: true
- pymdownx.superfences
- - pymdownx.tabbed
+ - pymdownx.tabbed:
+ alternate_style: true
- pymdownx.details
- markdown_include.include:
base_path: docs
# 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;
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);
# 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);
}
}
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
if (resp.status == 750) {
set resp.status = 301;
set resp.http.Location = req.http.x-redir;
- return(deliver);
+ return (deliver);
}
}
}
}
-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.
}
}
-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";
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"])
|> 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 =
alias Ecto.Multi
alias Pleroma.Hashtag
+ alias Pleroma.User.HashtagFollow
alias Pleroma.Object
alias Pleroma.Repo
|> 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})
{: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
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
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},
_ -> {: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
--- /dev/null
+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
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, "@")
with [name, domain] <- String.split(query_string, "@") do
encoded_domain =
domain
- |> String.replace(~r/[!-\-|@|[-`|{-~|\/|:|\s]+/, "")
+ |> sanitise_domain()
|> String.to_charlist()
|> :idna.encode()
|> to_string()
)
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
|> 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)
--- /dev/null
+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
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
--- /dev/null
+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
# 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"])
|> 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 =
--- /dev/null
+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
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
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
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
--- /dev/null
+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
--- /dev/null
+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
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
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"})
--- /dev/null
+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
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
user: user
}
end
+
+ def hashtag_factory(params \\ %{}) do
+ %Pleroma.Hashtag{
+ name: "test #{sequence(:hashtag_name, & &1)}"
+ }
+ |> Map.merge(params)
+ end
end